diff --git a/go/cmd/orion/main.go b/go/cmd/orion/main.go
index 24a1e05..7b867f3 100644
--- a/go/cmd/orion/main.go
+++ b/go/cmd/orion/main.go
@@ -106,37 +106,14 @@ func main() {
mux.Handle("POST /auth/sign-out", withAuth(h.SignOut))
mux.Handle("GET /auth/me", withAuth(h.GetCurrentHuman))
- // Bootstrap startup data
- mux.Handle("GET /startup", withAuth(h.StartupData))
-
// Networks
mux.Handle("POST /networks", withAuth(h.CreateNetwork))
mux.Handle("GET /networks", withAuth(h.ListNetworks))
mux.Handle("GET /networks/{id}", withAuth(h.GetNetwork))
mux.Handle("POST /networks/{id}/members", withAuth(h.AddMembersToNetwork))
- // TODO: what about members who are part of streams visibility within this network?
- mux.Handle("DELETE /networks/{id}/members/{email}", withAuth(h.RemoveMemberFromNetwork))
- mux.Handle("PUT /networks/{id}/capacity", withAuth(h.SetOpenStreamCapacity))
-
- // Streams
- mux.Handle("POST /networks/{network_id}/streams", withAuth(h.CreateStream))
- mux.Handle("GET /streams/{id}", withAuth(h.GetStream))
- mux.Handle("PATCH /streams/{id}", withAuth(h.UpdateStream))
- mux.Handle("POST /streams/{id}/particles", withAuth(h.CreateStreamParticle))
- mux.Handle("POST /streams/{id}/open", withAuth(h.OpenStream))
- mux.Handle("POST /streams/{id}/close", withAuth(h.CloseStream))
- mux.Handle("POST /streams/{id}/members", withAuth(h.AddMembers))
- mux.Handle("DELETE /streams/{id}/members", withAuth(h.RemoveMembers))
// Particles
- mux.Handle("GET /networks/{network_id}/particles", withAuth(h.ListParticles))
- mux.Handle("GET /particles/{id}", withAuth(h.GetParticle))
- mux.Handle("PATCH /particles/{id}", withAuth(h.UpdateParticle))
- mux.Handle("DELETE /particles/{id}", withAuth(h.DeleteParticle))
- mux.Handle("POST /particles/{id}/seen", withAuth(h.MarkSeen))
- mux.Handle("POST /particles/{id}/ack", withAuth(h.AckParticle))
- mux.Handle("GET /particles/{id}/download", withAuth(h.DownloadParticle))
- mux.Handle("POST /particles/seen", withAuth(h.MarkSeenBatch))
+ mux.Handle("GET /particles/{id}/download", withAuth(h.DownloadParticleMedia))
// Depot
mux.Handle("POST /depot/upload", withAuth(h.PrepareUpload))
diff --git a/go/docs/api.md b/go/docs/api.md
index c44ac1c..2cb43a2 100644
--- a/go/docs/api.md
+++ b/go/docs/api.md
@@ -51,54 +51,6 @@ Returns the authenticated user.
---
-## Startup
-
-### Get Startup Data
-`GET /startup` (Protected)
-
-Bootstrap endpoint for initial app load. Returns all networks the user belongs to, with all streams and their particles fully enriched.
-
-**Response:**
-```json
-{
- "networks": [
- {
- "id": "net-456",
- "name": "My Team",
- "admin_human": { "id": "...", "email": "...", "email_prefix": "...", "created_at": "..." },
- "humans": [{ "id": "...", "email": "...", "email_prefix": "...", "created_at": "..." }],
- "open_stream_count": 2,
- "open_stream_capacity": 5,
- "created_at": "2025-01-15T10:30:00Z",
- "streams": [
- {
- "id": "p-001",
- "name": "Sprint Planning",
- "description": "Weekly sync",
- "status": "open",
- "members": ["alice@example.com"],
- "particles": [
- {
- "id": "p-002",
- "type": "text",
- "data": { "content": "Hello" },
- "created_by_email": "alice@example.com",
- "seen": true,
- "acks": [],
- "updated_at": "...",
- "created_at": "..."
- }
- ],
- "unseen_count": 0
- }
- ]
- }
- ]
-}
-```
-
----
-
## Networks
### Create Network
@@ -136,183 +88,15 @@ Returns a specific network by ID.
Removes a member by email from the network.
-### Set Open Stream Capacity
-`PUT /networks/{id}/capacity` (Protected, Admin only)
-
-**Request Body:**
-```json
-{
- "capacity": 10
-}
-```
-
---
-## Streams
-
-Streams are top-level particles of type `stream`. They have dedicated endpoints for creation and management, and contain child particles.
-
-### Create Stream
-`POST /networks/{network_id}/streams` (Protected)
-
-**Request Body:**
-```json
-{
- "name": "Sprint Planning",
- "description": "Weekly sync",
- "visibility": "custom",
- "members": ["user@example.com"]
-}
-```
-
-- `visibility`: `network_all` (default) or `custom`
-- `members` is required when visibility is `custom`
-
-**Response:** `201 Created` — returns a [Stream](#stream-1) object.
-
-### Get Stream
-`GET /streams/{id}` (Protected)
-
-Returns a stream with all its child particles, enriched with seen/ack state.
-
-**Response:** returns a [Stream](#stream-1) object.
-
-### Update Stream
-`PATCH /streams/{id}` (Protected)
-
-Updates a stream's name and/or description. Status is not affected (use the open/close endpoints instead). Only provided fields are updated.
-
-**Request Body:**
-```json
-{
- "name": "New Name",
- "description": "New description"
-}
-```
-
-- Both fields are optional — omit a field to leave it unchanged
-- `name` cannot be empty if provided
-
-**Response:** returns the updated [Stream](#stream-1) object.
-
-### Create Stream Particle
-`POST /streams/{id}/particles` (Protected)
-
-Creates a child particle inside a stream. Child particles inherit visibility from the stream.
-
-**Request Body:**
-```json
-{
- "type": "text|media|file|quest|paper",
- "data": {}
-}
-```
-
-- Cannot create `stream` or `folder` types as children
-- For `media` and `file` types, `data` must include a valid `object_id` from depot
-
-**Response:** `201 Created` — returns a [StreamParticle](#streamparticle) object.
-
-### Open Stream
-`POST /streams/{id}/open` (Protected)
-
-Opens a closed stream. Fails with `409` if capacity would be exceeded.
-
-### Close Stream
-`POST /streams/{id}/close` (Protected)
-
-Closes an open stream.
-
-### Add Members to Stream
-`POST /streams/{id}/members` (Protected)
-
-**Request Body:**
-```json
-{
- "emails": ["user@example.com"]
-}
-```
-
-### Remove Members from Stream
-`DELETE /streams/{id}/members` (Protected)
-
-**Request Body:**
-```json
-{
- "emails": ["user@example.com"]
-}
-```
-
----
-
-## Particles
-
-### List Particles
-`GET /networks/{network_id}/particles` (Protected)
-
-**Query Parameters:**
-- `parent_id` (optional): Filter by parent particle
-- `cursor` (optional): Pagination cursor
-- `direction` (optional): `after` or `before` (default: `after`)
-- `type` (optional, repeatable): Filter by particle type
-
-**Response enrichment:**
-Each particle in the response includes:
-- `seen` (boolean): Whether the requester has marked this particle as seen
-- `acks` (array): List of acknowledgments `[{email, acked_at}]`
-- `unseen_count` (integer, streams only): Count of unseen child particles
-
-### Get Particle
-`GET /particles/{id}` (Protected)
-
-### Update Particle
-`PATCH /particles/{id}` (Protected)
-
-**Request Body:**
-```json
-{
- "data": {}
-}
-```
-
-### Delete Particle
-`DELETE /particles/{id}` (Protected)
-
-Deletes the particle and all children. If it references a depot object, that is also deleted.
-
-### Download Particle
+### Download Particle Object
`GET /particles/{id}/download` (Protected)
+For now, the `{id}` should be an object id. Not the particle id.
+
Returns a `302` redirect to a signed download URL. Only works for `media` and `file` particles.
-### Mark Seen
-`POST /particles/{id}/seen` (Protected)
-
-Marks a particle as seen by the requester. This is private state, only visible to the requester.
-
-Returns `204 No Content` on success.
-
-### Mark Seen (Batch)
-`POST /particles/seen` (Protected)
-
-Marks multiple particles as seen by the requester.
-
-**Request Body:**
-```json
-{
- "particle_ids": ["particle_uuid1", "particle_uuid2"]
-}
-```
-
-Returns `204 No Content` on success.
-
-### Acknowledge Particle
-`POST /particles/{id}/ack` (Protected)
-
-Acknowledges a particle. Acknowledgments are public and permanent, visible to all users with access. Also marks the particle as seen.
-
-Returns `204 No Content` on success.
-
---
## Depot (File Storage)
@@ -395,8 +179,6 @@ Confirms that an upload has been completed.
| `name` | `string` | Display name of the network. |
| `admin_human` | `Human` | The network administrator. |
| `humans` | `Human[]` | All members of the network (including admin). |
-| `open_stream_count` | `integer` | Number of currently open streams. |
-| `open_stream_capacity` | `integer` | Maximum number of concurrent open streams (default: 5). |
| `created_at` | `string` | ISO 8601 timestamp. |
```json
@@ -407,86 +189,10 @@ Confirms that an upload has been completed.
"humans": [
{ "id": "abc-123", "email": "alice@example.com", "email_prefix": "alice", "created_at": "..." }
],
- "open_stream_count": 2,
- "open_stream_capacity": 5,
"created_at": "2025-01-15T10:30:00Z"
}
```
-### Stream
-
-| Field | Type | Description |
-|-------|------|-------------|
-| `id` | `string` | Unique identifier (this is a particle ID). |
-| `name` | `string` | Stream name. |
-| `description` | `string` | Stream description. |
-| `status` | `string` | `"open"`, `"closed"`, or `"unspecified"`. |
-| `members` | `string[]` | Emails of stream members. Omitted for `network_all` visibility. |
-| `particles` | `StreamParticle[]` | Child particles in the stream. |
-| `unseen_count` | `integer` | Number of unseen child particles for the requester. |
-
-```json
-{
- "id": "p-001",
- "name": "Sprint Planning",
- "description": "Weekly sync",
- "status": "open",
- "members": ["alice@example.com"],
- "particles": [],
- "unseen_count": 0
-}
-```
-
-### StreamParticle
-
-| Field | Type | Description |
-|-------|------|-------------|
-| `id` | `string` | Unique identifier. |
-| `type` | `string` | One of: `media`, `file`, `text`, `quest`, `paper`. |
-| `data` | `object` | Type-specific payload (see [Particle Data by Type](#particle-data-by-type)). |
-| `created_by_email` | `string` | Email of the creator. |
-| `seen` | `boolean` | Whether the requester has seen this particle. |
-| `acks` | `AckInfo[]` | Acknowledgments from users. |
-| `updated_at` | `string` | ISO 8601 timestamp. |
-| `created_at` | `string` | ISO 8601 timestamp. |
-
-### Particle
-
-| Field | Type | Description |
-|-------|------|-------------|
-| `id` | `string` | Unique identifier. |
-| `type` | `string` | One of: `stream`, `folder`, `media`, `file`, `text`, `quest`, `paper`. |
-| `network_id` | `string` | The network this particle belongs to. |
-| `parent_id` | `string \| null` | Parent particle ID, if nested. |
-| `created_by_email` | `string` | Email of the creator. |
-| `visibility` | `string` | `"network_all"`, `"custom"`, or `"inherited"`. |
-| `stream_status` | `string \| null` | Only on `stream` type: `"open"` or `"closed"`. |
-| `data` | `object` | Type-specific payload (see [Particle Data by Type](#particle-data-by-type)). |
-| `download_url` | `string \| null` | Signed download URL. Only on `media`/`file` particles. |
-| `seen` | `boolean \| null` | Whether the requester has seen this particle. Only in list responses. |
-| `acks` | `AckInfo[]` | Acknowledgments. Only in list responses. |
-| `unseen_count` | `integer \| null` | Unseen child count. Only on `stream` particles in list responses. |
-| `updated_at` | `string` | ISO 8601 timestamp. |
-| `created_at` | `string` | ISO 8601 timestamp. |
-
-### AckInfo
-
-| Field | Type | Description |
-|-------|------|-------------|
-| `email` | `string` | Email of the user who acknowledged. |
-| `acked_at` | `string` | ISO 8601 timestamp of the acknowledgment. |
-
-### ParticleList
-
-Returned by `GET /networks/{network_id}/particles`.
-
-| Field | Type | Description |
-|-------|------|-------------|
-| `particles` | `Particle[]` | Array of enriched particle objects. |
-| `has_more` | `boolean` | Whether more results exist beyond this page. |
-| `next_cursor` | `string \| null` | Cursor to fetch the next page. |
-| `prev_cursor` | `string \| null` | Cursor to fetch the previous page. |
-
### DepotObject
Returned by `POST /depot/objects/{id}/confirm`.
@@ -591,20 +297,6 @@ The `data` field on a Particle is a JSON object whose schema depends on the part
---
-## Visibility
-
-Particles support three visibility modes:
-
-| Mode | Description |
-|------|-------------|
-| `network_all` | Visible to all network members. |
-| `custom` | Visible only to specified members (requires `members` list). |
-| `inherited` | Inherits visibility from parent particle. Used for child particles in streams. |
-
-Root-level particles (streams, folders) use `network_all` or `custom`. Child particles created via `POST /streams/{id}/particles` automatically use `inherited`.
-
----
-
## Error Responses
All endpoints return standard HTTP status codes with plain text error bodies:
diff --git a/go/internal/handler/handler.go b/go/internal/handler/handler.go
index 727d552..cb261a1 100644
--- a/go/internal/handler/handler.go
+++ b/go/internal/handler/handler.go
@@ -43,85 +43,16 @@ type Human struct {
Id *string `json:"id"`
Email string `json:"email"`
EmailPrefix string `json:"email_prefix"`
+ // CreatedAt will be nil if this human is not registered
CreatedAt *time.Time `json:"created_at"`
}
type Network struct {
- Id string `json:"id"`
- Name string `json:"name"`
- AdminHuman Human `json:"admin_human"`
- Humans []Human `json:"humans"`
- OpenStreamCount int `json:"open_stream_count"`
- OpenStreamCapacity int `json:"open_stream_capacity"`
- CreatedAt time.Time `json:"created_at"`
-}
-
-type StreamParticle struct {
- Id string `json:"id"`
- Type string `json:"type"`
- Data json.RawMessage `json:"data"`
- CreatedByEmail string `json:"created_by_email"`
- Seen bool `json:"seen"`
- Acks []*AckInfo `json:"acks"`
- UpdatedAt time.Time `json:"updated_at"`
- CreatedAt time.Time `json:"created_at"`
-}
-
-type Particle struct {
- Id string `json:"id"`
- Type string `json:"type"`
- Data json.RawMessage `json:"data"`
- CreatedByEmail string `json:"created_by_email"`
- Visibility string `json:"visibility"`
- Members []string `json:"members,omitempty"`
- StreamStatus *string `json:"stream_status,omitempty"`
- Seen bool `json:"seen,omitempty"`
- Acks []*AckInfo `json:"acks,omitempty"`
- UnseenCount *int `json:"unseen_count,omitempty"`
- UpdatedAt time.Time `json:"updated_at"`
- CreatedAt time.Time `json:"created_at"`
-}
-
-type Stream struct {
- Id string `json:"id"`
- Name string `json:"name"`
- Description string `json:"description"`
- Status StreamStatus `json:"status"`
- // The emails of the members in this stream
- Members []string `json:"members,omitempty"`
- Particles []*StreamParticle `json:"particles"`
- UnseenCount int `json:"unseen_count"`
- UpdatedAt time.Time `json:"updated_at"`
- CreatedAt time.Time `json:"created_at"`
-}
-
-type StreamStatus string
-
-const (
- STREAM_STATUS_OPEN StreamStatus = "open"
- STREAM_STATUS_CLOSED StreamStatus = "closed"
- STREAM_STATUS_UNSPECIFIED StreamStatus = "unspecified"
-)
-
-type NetworkWithStreams struct {
- Network
- Streams []*Stream `json:"streams"`
-}
-
-type StartData struct {
- Networks []*NetworkWithStreams `json:"networks"`
-}
-
-type AckInfo struct {
- Email string `json:"email"`
- AckedAt time.Time `json:"acked_at"`
-}
-
-type ParticleList struct {
- Particles []Particle `json:"particles"`
- HasMore bool `json:"has_more"`
- NextCursor *string `json:"next_cursor,omitempty"`
- PrevCursor *string `json:"prev_cursor,omitempty"`
+ Id string `json:"id"`
+ Name string `json:"name"`
+ AdminHuman Human `json:"admin_human"`
+ Humans []Human `json:"humans"`
+ CreatedAt time.Time `json:"created_at"`
}
// Auth Request/Response DTOs
@@ -154,37 +85,10 @@ type SetOpenStreamCapacityRequest struct {
Capacity int `json:"capacity"`
}
-// Particle Request DTOs
-
-type CreateStreamParticleRequest struct {
- Type string `json:"type"`
- Data json.RawMessage `json:"data"`
-}
-
-type CreateStreamRequest struct {
- Name string `json:"name"`
- Description string `json:"description"`
- Visibility string `json:"visibility"`
- Members []string `json:"members"`
-}
-
-type UpdateStreamRequest struct {
- Name *string `json:"name"`
- Description *string `json:"description"`
-}
-
-type UpdateParticleRequest struct {
- Data json.RawMessage `json:"data"`
-}
-
type MembersRequest struct {
Emails []string `json:"emails"`
}
-type MarkSeenBatchRequest struct {
- ParticleIDs []string `json:"particle_ids"`
-}
-
// Depot DTOs
type PrepareUploadRequest struct {
@@ -303,583 +207,6 @@ func (h *Handler) SignOut(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
-func (h *Handler) StartupData(w http.ResponseWriter, r *http.Request) {
- email, ok := middleware.EmailFromContext(r.Context())
- if !ok {
- http.Error(w, "unauthorized", http.StatusUnauthorized)
- return
- }
-
- networks, err := h.networkSvc.ListForEmail(r.Context(), email)
- if err != nil {
- http.Error(w, "unable to fetch networks", http.StatusInternalServerError)
- return
- }
-
- data := StartData{
- Networks: make([]*NetworkWithStreams, 0, len(networks)),
- }
-
- streamFilter := particle.ListFilter{
- Types: []particle.ParticleType{particle.TypeStream},
- }
-
- for _, net := range networks {
- networkDTO, err := h.networkToDTO(r.Context(), net)
- if err != nil {
- slog.Error("unable to convert db network to dto", "error", err, "network_id", net.ID)
- continue
- }
-
- nws := &NetworkWithStreams{
- Network: networkDTO,
- }
-
- // Fetch all top-level streams for this network
- streams, err := h.listAllParticles(r.Context(), net.ID, nil, email, streamFilter)
- if err != nil {
- slog.Error("unable to list streams for network", "error", err, "network_id", net.ID)
- data.Networks = append(data.Networks, nws)
- continue
- }
-
- // Collect stream IDs for unseen counts
- streamIDs := make([]string, len(streams))
- for i, s := range streams {
- streamIDs[i] = s.ID
- }
-
- // Get unseen counts and members for all streams in this network
- var unseenCounts map[string]int
- var streamMembersMap map[string][]string
- if len(streamIDs) > 0 {
- unseenCounts, err = h.particleSvc.GetUnseenCounts(r.Context(), net.ID, streamIDs, email)
- if err != nil {
- slog.Warn("failed to get unseen counts for streams", "error", err, "network_id", net.ID)
- unseenCounts = make(map[string]int)
- }
-
- streamMembersMap, err = h.particleSvc.GetMembersMap(r.Context(), streamIDs)
- if err != nil {
- slog.Warn("failed to get members map for streams", "error", err, "network_id", net.ID)
- streamMembersMap = make(map[string][]string)
- }
- }
-
- nws.Streams = make([]*Stream, 0, len(streams))
- for _, sp := range streams {
- // Parse stream metadata from particle data
- var streamData particle.StreamData
- if err := json.Unmarshal(sp.Data, &streamData); err != nil {
- slog.Warn("failed to parse stream data", "error", err, "particle_id", sp.ID)
- }
-
- status := parseStreamStatus(streamData.Status)
-
- stream := &Stream{
- Id: sp.ID,
- Name: streamData.Name,
- Description: utils.OptionalString(streamData.Description),
- Status: status,
- Members: h.getStreamMembers(r.Context(), sp, streamMembersMap),
- UnseenCount: unseenCounts[sp.ID],
- UpdatedAt: sp.UpdatedAt,
- CreatedAt: sp.CreatedAt,
- }
-
- // Fetch child particles for this stream
- children, err := h.listAllParticles(r.Context(), net.ID, &sp.ID, email, particle.ListFilter{})
- if err != nil {
- slog.Error("unable to list particles for stream", "error", err, "stream_id", sp.ID)
- nws.Streams = append(nws.Streams, stream)
- continue
- }
-
- // Collect child particle IDs for bulk enrichment
- childIDs := make([]string, len(children))
- for i, p := range children {
- childIDs[i] = p.ID
- }
-
- seenMap, err := h.particleSvc.GetSeenMap(r.Context(), childIDs, email)
- if err != nil {
- slog.Warn("failed to get seen map", "error", err)
- seenMap = make(map[string]bool)
- }
-
- acksMap, err := h.particleSvc.GetAcksMap(r.Context(), childIDs)
- if err != nil {
- slog.Warn("failed to get acks map", "error", err)
- acksMap = make(map[string][]particle.AckInfo)
- }
-
- // Build enriched particle DTOs
- stream.Particles = make([]*StreamParticle, 0, len(children))
- for _, p := range children {
- dto := h.streamParticleToDTO(r.Context(), p)
-
- if seen, ok := seenMap[p.ID]; ok {
- dto.Seen = seen
- }
-
- if acks, ok := acksMap[p.ID]; ok && len(acks) > 0 {
- dto.Acks = make([]*AckInfo, len(acks))
- for i, a := range acks {
- dto.Acks[i] = &AckInfo{Email: a.Email, AckedAt: a.AckedAt}
- }
- } else {
- dto.Acks = []*AckInfo{}
- }
-
- stream.Particles = append(stream.Particles, &dto)
- }
-
- nws.Streams = append(nws.Streams, stream)
- }
-
- data.Networks = append(data.Networks, nws)
- }
-
- w.Header().Set("Content-Type", "application/json")
- json.NewEncoder(w).Encode(data)
-}
-
-func (h *Handler) CreateStream(w http.ResponseWriter, r *http.Request) {
- email, ok := middleware.EmailFromContext(r.Context())
- if !ok {
- http.Error(w, "unauthorized", http.StatusUnauthorized)
- return
- }
-
- networkID := r.PathValue("network_id")
- if networkID == "" {
- http.Error(w, "network_id is required", http.StatusBadRequest)
- return
- }
-
- // Check network membership
- isMember, err := h.networkSvc.IsMember(r.Context(), networkID, email)
- if err != nil {
- slog.Error("failed to check network membership", "error", err, "network_id", networkID, "email", email)
- http.Error(w, "internal server error", http.StatusInternalServerError)
- return
- }
- if !isMember {
- http.Error(w, "access denied", http.StatusForbidden)
- return
- }
-
- var req CreateStreamRequest
- if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
- http.Error(w, "invalid request body", http.StatusBadRequest)
- return
- }
-
- if req.Name == "" {
- http.Error(w, "name is required", http.StatusBadRequest)
- return
- }
-
- streamData := &particle.StreamData{
- Name: req.Name,
- Description: utils.CreateOptionalString(req.Description),
- Status: string(particle.StreamStatusOpen),
- }
- data, err := json.Marshal(streamData)
- if err != nil {
- slog.Error("failed to marshal stream data", "error", err, "stream_name", req.Name)
- http.Error(w, "internal server error", http.StatusInternalServerError)
- return
- }
- visibilityMode, err := particle.ParseVisibilityMode(req.Visibility)
- if err != nil {
- slog.Error("invalid visibility mode", "error", err, "visibility", req.Visibility)
- http.Error(w, "invalid visibility mode", http.StatusBadRequest)
- return
- }
- params := particle.CreateInput{
- Type: particle.TypeStream,
- NetworkID: networkID,
- ParentID: nil,
- Data: data,
- Members: req.Members,
- Visibility: visibilityMode,
- }
- created, err := h.particleSvc.Create(r.Context(), params, email)
- if err != nil {
- if errors.Is(err, particle.ErrMembersRequired) {
- http.Error(w, err.Error(), http.StatusBadRequest)
- return
- }
- if errors.Is(err, particle.ErrCapacityExceeded) {
- http.Error(w, "stream capacity exceeded", http.StatusConflict)
- return
- }
- if errors.Is(err, particle.ErrInvalidData) {
- http.Error(w, err.Error(), http.StatusBadRequest)
- return
- }
- slog.Error("failed to create stream particle", "error", err, "stream_name", req.Name, "network_id", networkID)
- http.Error(w, "internal server error", http.StatusInternalServerError)
- return
- }
-
- // Get members for the created stream
- membersMap, _ := h.particleSvc.GetMembersMap(r.Context(), []string{created.ID})
-
- var parsedData particle.StreamData
- _ = json.Unmarshal(created.Data, &parsedData)
-
- resp := Stream{
- Id: created.ID,
- Name: parsedData.Name,
- Description: utils.OptionalString(parsedData.Description),
- Status: parseStreamStatus(parsedData.Status),
- Members: h.getStreamMembers(r.Context(), created, membersMap),
- Particles: []*StreamParticle{},
- UnseenCount: 0,
- UpdatedAt: created.UpdatedAt,
- CreatedAt: created.CreatedAt,
- }
-
- w.Header().Set("Content-Type", "application/json")
- w.WriteHeader(http.StatusCreated)
- json.NewEncoder(w).Encode(resp)
-}
-
-// GetStream returns a stream with its child particles, members, and unseen count
-func (h *Handler) GetStream(w http.ResponseWriter, r *http.Request) {
- email, ok := middleware.EmailFromContext(r.Context())
- if !ok {
- http.Error(w, "unauthorized", http.StatusUnauthorized)
- return
- }
-
- streamID := r.PathValue("id")
- if streamID == "" {
- http.Error(w, "stream id is required", http.StatusBadRequest)
- return
- }
-
- // Get the stream particle and verify access
- sp, err := h.particleSvc.GetByID(r.Context(), streamID, email)
- if err != nil {
- if errors.Is(err, particle.ErrNotFound) {
- http.Error(w, "stream not found", http.StatusNotFound)
- return
- }
- if errors.Is(err, particle.ErrAccessDenied) {
- http.Error(w, "access denied", http.StatusForbidden)
- return
- }
- slog.Error("failed to get stream", "error", err, "stream_id", streamID, "email", email)
- http.Error(w, "internal server error", http.StatusInternalServerError)
- return
- }
-
- if sp.Type != particle.TypeStream {
- http.Error(w, "particle is not a stream", http.StatusBadRequest)
- return
- }
-
- // Parse stream data
- var streamData particle.StreamData
- if err := json.Unmarshal(sp.Data, &streamData); err != nil {
- slog.Warn("failed to parse stream data", "error", err, "stream_id", streamID)
- }
-
- // Get members
- membersMap, err := h.particleSvc.GetMembersMap(r.Context(), []string{streamID})
- if err != nil {
- slog.Warn("failed to get members for stream", "error", err, "stream_id", streamID)
- membersMap = make(map[string][]string)
- }
-
- // Get unseen count
- unseenCounts, err := h.particleSvc.GetUnseenCounts(r.Context(), sp.NetworkID, []string{streamID}, email)
- if err != nil {
- slog.Warn("failed to get unseen counts", "error", err, "stream_id", streamID)
- unseenCounts = make(map[string]int)
- }
-
- stream := &Stream{
- Id: sp.ID,
- Name: streamData.Name,
- Description: utils.OptionalString(streamData.Description),
- Status: parseStreamStatus(streamData.Status),
- Members: h.getStreamMembers(r.Context(), sp, membersMap),
- UnseenCount: unseenCounts[sp.ID],
- UpdatedAt: sp.UpdatedAt,
- CreatedAt: sp.CreatedAt,
- }
-
- // Fetch child particles
- children, err := h.listAllParticles(r.Context(), sp.NetworkID, &sp.ID, email, particle.ListFilter{})
- if err != nil {
- slog.Error("unable to list particles for stream", "error", err, "stream_id", sp.ID)
- stream.Particles = []*StreamParticle{}
- w.Header().Set("Content-Type", "application/json")
- json.NewEncoder(w).Encode(stream)
- return
- }
-
- // Collect child IDs for bulk enrichment
- childIDs := make([]string, len(children))
- for i, p := range children {
- childIDs[i] = p.ID
- }
-
- seenMap, err := h.particleSvc.GetSeenMap(r.Context(), childIDs, email)
- if err != nil {
- slog.Warn("failed to get seen map", "error", err)
- seenMap = make(map[string]bool)
- }
-
- acksMap, err := h.particleSvc.GetAcksMap(r.Context(), childIDs)
- if err != nil {
- slog.Warn("failed to get acks map", "error", err)
- acksMap = make(map[string][]particle.AckInfo)
- }
-
- stream.Particles = make([]*StreamParticle, 0, len(children))
- for _, p := range children {
- dto := h.streamParticleToDTO(r.Context(), p)
-
- if seen, ok := seenMap[p.ID]; ok {
- dto.Seen = seen
- }
-
- if acks, ok := acksMap[p.ID]; ok && len(acks) > 0 {
- dto.Acks = make([]*AckInfo, len(acks))
- for i, a := range acks {
- dto.Acks[i] = &AckInfo{Email: a.Email, AckedAt: a.AckedAt}
- }
- } else {
- dto.Acks = []*AckInfo{}
- }
-
- stream.Particles = append(stream.Particles, &dto)
- }
-
- w.Header().Set("Content-Type", "application/json")
- json.NewEncoder(w).Encode(stream)
-}
-
-// UpdateStream updates a stream's name and/or description (not status)
-func (h *Handler) UpdateStream(w http.ResponseWriter, r *http.Request) {
- email, ok := middleware.EmailFromContext(r.Context())
- if !ok {
- http.Error(w, "unauthorized", http.StatusUnauthorized)
- return
- }
-
- streamID := r.PathValue("id")
- if streamID == "" {
- http.Error(w, "stream id is required", http.StatusBadRequest)
- return
- }
-
- var req UpdateStreamRequest
- if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
- http.Error(w, "invalid request body", http.StatusBadRequest)
- return
- }
-
- // Get the existing stream to preserve status and merge fields
- sp, err := h.particleSvc.GetByID(r.Context(), streamID, email)
- if err != nil {
- if errors.Is(err, particle.ErrNotFound) {
- http.Error(w, "stream not found", http.StatusNotFound)
- return
- }
- if errors.Is(err, particle.ErrAccessDenied) {
- http.Error(w, "access denied", http.StatusForbidden)
- return
- }
- slog.Error("failed to get stream for update", "error", err, "stream_id", streamID, "email", email)
- http.Error(w, "internal server error", http.StatusInternalServerError)
- return
- }
-
- if sp.Type != particle.TypeStream {
- http.Error(w, "particle is not a stream", http.StatusBadRequest)
- return
- }
-
- // Parse existing data to preserve status
- var existing particle.StreamData
- if err := json.Unmarshal(sp.Data, &existing); err != nil {
- slog.Error("failed to parse existing stream data", "error", err, "stream_id", streamID)
- http.Error(w, "internal server error", http.StatusInternalServerError)
- return
- }
-
- // Merge: only update fields that were provided
- if req.Name != nil {
- if *req.Name == "" {
- http.Error(w, "name cannot be empty", http.StatusBadRequest)
- return
- }
- existing.Name = *req.Name
- }
- if req.Description != nil {
- existing.Description = utils.CreateOptionalString(*req.Description)
- }
-
- newData, err := json.Marshal(existing)
- if err != nil {
- slog.Error("failed to marshal updated stream data", "error", err, "stream_id", streamID)
- http.Error(w, "internal server error", http.StatusInternalServerError)
- return
- }
-
- updated, err := h.particleSvc.Update(r.Context(), streamID, newData, email)
- if err != nil {
- if errors.Is(err, particle.ErrNotFound) {
- http.Error(w, "stream not found", http.StatusNotFound)
- return
- }
- if errors.Is(err, particle.ErrAccessDenied) {
- http.Error(w, "access denied", http.StatusForbidden)
- return
- }
- if errors.Is(err, particle.ErrInvalidData) {
- http.Error(w, err.Error(), http.StatusBadRequest)
- return
- }
- slog.Error("failed to update stream", "error", err, "stream_id", streamID, "email", email)
- http.Error(w, "internal server error", http.StatusInternalServerError)
- return
- }
-
- // Build response
- var parsedData particle.StreamData
- _ = json.Unmarshal(updated.Data, &parsedData)
-
- membersMap, _ := h.particleSvc.GetMembersMap(r.Context(), []string{updated.ID})
-
- unseenCounts, _ := h.particleSvc.GetUnseenCounts(r.Context(), updated.NetworkID, []string{updated.ID}, email)
-
- resp := Stream{
- Id: updated.ID,
- Name: parsedData.Name,
- Description: utils.OptionalString(parsedData.Description),
- Status: parseStreamStatus(parsedData.Status),
- Members: h.getStreamMembers(r.Context(), updated, membersMap),
- Particles: []*StreamParticle{},
- UnseenCount: unseenCounts[updated.ID],
- UpdatedAt: updated.UpdatedAt,
- CreatedAt: updated.CreatedAt,
- }
-
- w.Header().Set("Content-Type", "application/json")
- json.NewEncoder(w).Encode(resp)
-}
-
-// CreateStreamParticle creates a particle inside a stream
-func (h *Handler) CreateStreamParticle(w http.ResponseWriter, r *http.Request) {
- email, ok := middleware.EmailFromContext(r.Context())
- if !ok {
- http.Error(w, "unauthorized", http.StatusUnauthorized)
- return
- }
-
- streamID := r.PathValue("id")
- if streamID == "" {
- http.Error(w, "stream id is required", http.StatusBadRequest)
- return
- }
-
- var req CreateStreamParticleRequest
- if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
- http.Error(w, "invalid request body", http.StatusBadRequest)
- return
- }
-
- // Get the stream to find NetworkID and verify it's a stream
- stream, err := h.particleSvc.GetByID(r.Context(), streamID, email)
- if err != nil {
- if errors.Is(err, particle.ErrNotFound) {
- http.Error(w, "stream not found", http.StatusNotFound)
- return
- }
- if errors.Is(err, particle.ErrAccessDenied) {
- http.Error(w, "access denied", http.StatusForbidden)
- return
- }
- slog.Error("failed to get stream for particle creation", "error", err, "stream_id", streamID, "email", email)
- http.Error(w, "internal server error", http.StatusInternalServerError)
- return
- }
-
- if stream.Type != particle.TypeStream {
- http.Error(w, "particle is not a stream", http.StatusBadRequest)
- return
- }
-
- // Parse and validate particle type
- particleType, err := particle.ParseParticleType(req.Type)
- if err != nil {
- http.Error(w, "invalid particle type", http.StatusBadRequest)
- return
- }
-
- // Reject streams and folders as children
- if particleType == particle.TypeStream || particleType == particle.TypeFolder {
- http.Error(w, "cannot create streams or folders inside a stream", http.StatusBadRequest)
- return
- }
-
- // For media/file types, validate object_id exists in depot
- if req.Type == "media" || req.Type == "file" {
- var data struct {
- ObjectID string `json:"object_id"`
- }
- if err := json.Unmarshal(req.Data, &data); err == nil && data.ObjectID != "" {
- exists, err := h.depotSvc.Exists(r.Context(), data.ObjectID)
- if err != nil {
- slog.Error("failed to check depot object existence", "error", err, "object_id", data.ObjectID)
- http.Error(w, "internal server error", http.StatusInternalServerError)
- return
- }
- if !exists {
- http.Error(w, "object_id does not exist in depot", http.StatusBadRequest)
- return
- }
- }
- }
-
- // Service will force inherited visibility and nil members
- input := particle.CreateInput{
- Type: particleType,
- NetworkID: stream.NetworkID,
- ParentID: &streamID,
- Data: req.Data,
- }
-
- created, err := h.particleSvc.Create(r.Context(), input, email)
- if err != nil {
- if errors.Is(err, particle.ErrInvalidType) || errors.Is(err, particle.ErrInvalidData) || errors.Is(err, particle.ErrInvalidParent) {
- http.Error(w, err.Error(), http.StatusBadRequest)
- return
- }
- if errors.Is(err, particle.ErrAccessDenied) {
- http.Error(w, "access denied", http.StatusForbidden)
- return
- }
- slog.Error("failed to create stream particle", "error", err, "stream_id", streamID, "type", req.Type, "email", email)
- http.Error(w, "internal server error", http.StatusInternalServerError)
- return
- }
-
- resp := h.streamParticleToDTO(r.Context(), created)
- resp.Acks = []*AckInfo{}
-
- w.Header().Set("Content-Type", "application/json")
- w.WriteHeader(http.StatusCreated)
- json.NewEncoder(w).Encode(resp)
-}
-
// GetCurrentHuman returns the authenticated human
func (h *Handler) GetCurrentHuman(w http.ResponseWriter, r *http.Request) {
email, ok := middleware.EmailFromContext(r.Context())
@@ -1120,677 +447,48 @@ func (h *Handler) RemoveMemberFromNetwork(w http.ResponseWriter, r *http.Request
w.WriteHeader(http.StatusNoContent)
}
-// SetOpenStreamCapacity sets the open stream capacity for a network
-func (h *Handler) SetOpenStreamCapacity(w http.ResponseWriter, r *http.Request) {
- email, ok := middleware.EmailFromContext(r.Context())
+// DownloadParticleMedia redirects to a fresh signed download URL for media/file particles
+func (h *Handler) DownloadParticleMedia(w http.ResponseWriter, r *http.Request) {
+ _, ok := middleware.EmailFromContext(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
- networkID := r.PathValue("id")
- if networkID == "" {
- http.Error(w, "network id is required", http.StatusBadRequest)
- return
- }
-
- // Get network to check admin
- net, err := h.networkSvc.GetByID(r.Context(), networkID)
- if err != nil {
- if errors.Is(err, network.ErrNotFound) {
- http.Error(w, "network not found", http.StatusNotFound)
- return
- }
- slog.Error("failed to get network for capacity update", "error", err, "network_id", networkID)
- http.Error(w, "internal server error", http.StatusInternalServerError)
- return
- }
-
- // Only admin can set capacity
- if net.AdminEmail != email {
- http.Error(w, "only admin can set capacity", http.StatusForbidden)
- return
- }
-
- var req SetOpenStreamCapacityRequest
- if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
- http.Error(w, "invalid request body", http.StatusBadRequest)
- return
- }
-
- if err := h.networkSvc.SetOpenStreamCapacity(r.Context(), networkID, req.Capacity); err != nil {
- if errors.Is(err, network.ErrNotFound) {
- http.Error(w, "network not found", http.StatusNotFound)
- return
- }
- slog.Error("failed to set open stream capacity", "error", err, "network_id", networkID, "capacity", req.Capacity)
- http.Error(w, "internal server error", http.StatusInternalServerError)
- return
- }
-
- w.WriteHeader(http.StatusNoContent)
-}
-
-// ============================================================================
-// Particle Handlers
-// ============================================================================
-
-// ListParticles returns particles in a network
-func (h *Handler) ListParticles(w http.ResponseWriter, r *http.Request) {
- email, ok := middleware.EmailFromContext(r.Context())
- if !ok {
- http.Error(w, "unauthorized", http.StatusUnauthorized)
- return
- }
-
- networkID := r.PathValue("network_id")
- if networkID == "" {
- http.Error(w, "network id is required", http.StatusBadRequest)
- return
- }
-
- // Check network membership
- isMember, err := h.networkSvc.IsMember(r.Context(), networkID, email)
- if err != nil {
- slog.Error("failed to check network membership", "error", err, "network_id", networkID, "email", email)
- http.Error(w, "internal server error", http.StatusInternalServerError)
- return
- }
- if !isMember {
- http.Error(w, "access denied", http.StatusForbidden)
- return
- }
-
- // Parse query parameters
- parentID := r.URL.Query().Get("parent_id")
- var parentIDPtr *string
- if parentID != "" {
- parentIDPtr = &parentID
- }
-
- // Parse cursor
- var cursor *particle.Cursor
- cursorStr := r.URL.Query().Get("cursor")
- direction := r.URL.Query().Get("direction")
- if cursorStr != "" {
- cursor = &particle.Cursor{
- Position: cursorStr,
- Direction: direction,
- }
- if cursor.Direction == "" {
- cursor.Direction = "after"
- }
- }
-
- // Parse type filter
- filter := particle.ListFilter{}
- typeFilter := r.URL.Query()["type"]
- for _, t := range typeFilter {
- pt, err := particle.ParseParticleType(t)
- if err != nil {
- http.Error(w, "invalid particle type: "+t, http.StatusBadRequest)
- return
- }
- filter.Types = append(filter.Types, pt)
- }
-
- list, err := h.particleSvc.List(r.Context(), networkID, parentIDPtr, email, filter, cursor, 50)
- if err != nil {
- if errors.Is(err, particle.ErrNotFound) {
- http.Error(w, "parent not found", http.StatusNotFound)
- return
- }
- if errors.Is(err, particle.ErrAccessDenied) {
- http.Error(w, "access denied", http.StatusForbidden)
- return
- }
- slog.Error("failed to list particles", "error", err, "network_id", networkID, "email", email)
- http.Error(w, "internal server error", http.StatusInternalServerError)
- return
- }
-
- // Extract particle IDs for enrichment
- particleIDs := make([]string, len(list.Particles))
- streamIDs := make([]string, 0)
- for i, p := range list.Particles {
- particleIDs[i] = p.ID
- if p.Type == particle.TypeStream {
- streamIDs = append(streamIDs, p.ID)
- }
- }
-
- // Get seen map for enrichment
- seenMap, err := h.particleSvc.GetSeenMap(r.Context(), particleIDs, email)
- if err != nil {
- slog.Warn("failed to get seen map", "error", err)
- seenMap = make(map[string]bool)
- }
-
- // Get acks map for enrichment
- acksMap, err := h.particleSvc.GetAcksMap(r.Context(), particleIDs)
- if err != nil {
- slog.Warn("failed to get acks map", "error", err)
- acksMap = make(map[string][]particle.AckInfo)
- }
-
- // Get members map for enrichment
- membersMap, err := h.particleSvc.GetMembersMap(r.Context(), particleIDs)
- if err != nil {
- slog.Warn("failed to get members map", "error", err)
- membersMap = make(map[string][]string)
- }
-
- // Get unseen counts for streams
- var unseenCounts map[string]int
- if len(streamIDs) > 0 {
- unseenCounts, err = h.particleSvc.GetUnseenCounts(r.Context(), networkID, streamIDs, email)
- if err != nil {
- slog.Warn("failed to get unseen counts", "error", err)
- unseenCounts = make(map[string]int)
- }
- }
-
- resp := ParticleList{
- Particles: make([]Particle, 0, len(list.Particles)),
- HasMore: list.HasMore,
- }
-
- for _, p := range list.Particles {
- dto := h.particleToDTO(r.Context(), p)
-
- // Enrich with seen status
- seen, ok := seenMap[p.ID]
- if ok {
- dto.Seen = seen
- }
-
- // Enrich with acks
- if acks, ok := acksMap[p.ID]; ok && len(acks) > 0 {
- dto.Acks = make([]*AckInfo, len(acks))
- for i, a := range acks {
- dto.Acks[i] = &AckInfo{Email: a.Email, AckedAt: a.AckedAt}
- }
- }
-
- // Enrich with members
- if members, ok := membersMap[p.ID]; ok && len(members) > 0 {
- dto.Members = members
- }
-
- // Enrich with unseen count for streams
- if p.Type == particle.TypeStream {
- count := unseenCounts[p.ID]
- dto.UnseenCount = &count
- }
-
- resp.Particles = append(resp.Particles, dto)
- }
-
- if list.NextCursor != nil {
- encoded := list.NextCursor.Position + ":" + list.NextCursor.Direction
- resp.NextCursor = &encoded
- }
- if list.PrevCursor != nil {
- encoded := list.PrevCursor.Position + ":" + list.PrevCursor.Direction
- resp.PrevCursor = &encoded
- }
-
- w.Header().Set("Content-Type", "application/json")
- json.NewEncoder(w).Encode(resp)
-}
-
-// GetParticle gets the details of a particle
-func (h *Handler) GetParticle(w http.ResponseWriter, r *http.Request) {
- email, ok := middleware.EmailFromContext(r.Context())
- if !ok {
- http.Error(w, "unauthorized", http.StatusUnauthorized)
- return
- }
-
- particleID := r.PathValue("id")
- if particleID == "" {
- http.Error(w, "particle id is required", http.StatusBadRequest)
- return
- }
-
- p, err := h.particleSvc.GetByID(r.Context(), particleID, email)
- if err != nil {
- if errors.Is(err, particle.ErrNotFound) {
- http.Error(w, "particle not found", http.StatusNotFound)
- return
- }
- if errors.Is(err, particle.ErrAccessDenied) {
- http.Error(w, "access denied", http.StatusForbidden)
- return
- }
- slog.Error("failed to get particle", "error", err, "particle_id", particleID, "email", email)
- http.Error(w, "internal server error", http.StatusInternalServerError)
- return
- }
-
- // Verify network membership
- isMember, err := h.networkSvc.IsMember(r.Context(), p.NetworkID, email)
- if err != nil {
- slog.Error("failed to check network membership", "error", err, "network_id", p.NetworkID, "email", email)
- http.Error(w, "internal server error", http.StatusInternalServerError)
- return
- }
- if !isMember {
- http.Error(w, "access denied", http.StatusForbidden)
- return
- }
-
- resp := h.particleToDTO(r.Context(), p)
-
- w.Header().Set("Content-Type", "application/json")
- json.NewEncoder(w).Encode(resp)
-}
-
-// UpdateParticle updates the data of a particle
-func (h *Handler) UpdateParticle(w http.ResponseWriter, r *http.Request) {
- email, ok := middleware.EmailFromContext(r.Context())
- if !ok {
- http.Error(w, "unauthorized", http.StatusUnauthorized)
- return
- }
-
- particleID := r.PathValue("id")
- if particleID == "" {
- http.Error(w, "particle id is required", http.StatusBadRequest)
- return
- }
-
- var req UpdateParticleRequest
- if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
- http.Error(w, "invalid request body", http.StatusBadRequest)
- return
- }
-
- updated, err := h.particleSvc.Update(r.Context(), particleID, req.Data, email)
- if err != nil {
- if errors.Is(err, particle.ErrNotFound) {
- http.Error(w, "particle not found", http.StatusNotFound)
- return
- }
- if errors.Is(err, particle.ErrAccessDenied) {
- http.Error(w, "access denied", http.StatusForbidden)
- return
- }
- if errors.Is(err, particle.ErrInvalidData) {
- http.Error(w, err.Error(), http.StatusBadRequest)
- return
- }
- slog.Error("failed to update particle", "error", err, "particle_id", particleID, "email", email)
- http.Error(w, "internal server error", http.StatusInternalServerError)
- return
- }
-
- resp := h.particleToDTO(r.Context(), updated)
-
- w.Header().Set("Content-Type", "application/json")
- json.NewEncoder(w).Encode(resp)
-}
-
-// DeleteParticle deletes a particle and cascades to depot if applicable
-func (h *Handler) DeleteParticle(w http.ResponseWriter, r *http.Request) {
- email, ok := middleware.EmailFromContext(r.Context())
- if !ok {
- http.Error(w, "unauthorized", http.StatusUnauthorized)
- return
- }
-
- particleID := r.PathValue("id")
- if particleID == "" {
- http.Error(w, "particle id is required", http.StatusBadRequest)
- return
- }
-
- // Get particle first to check for object_id (for cascade delete)
- p, err := h.particleSvc.GetByID(r.Context(), particleID, email)
- if err != nil {
- if errors.Is(err, particle.ErrNotFound) {
- http.Error(w, "particle not found", http.StatusNotFound)
- return
- }
- if errors.Is(err, particle.ErrAccessDenied) {
- http.Error(w, "access denied", http.StatusForbidden)
- return
- }
- slog.Error("failed to get particle for deletion", "error", err, "particle_id", particleID, "email", email)
- http.Error(w, "internal server error", http.StatusInternalServerError)
- return
- }
-
- // Extract object_id if media/file
- objectID := extractObjectID(p)
-
- // Delete particle
- if err := h.particleSvc.Delete(r.Context(), particleID, email); err != nil {
- if errors.Is(err, particle.ErrNotFound) {
- http.Error(w, "particle not found", http.StatusNotFound)
- return
- }
- if errors.Is(err, particle.ErrAccessDenied) {
- http.Error(w, "access denied", http.StatusForbidden)
- return
- }
- slog.Error("failed to delete particle", "error", err, "particle_id", particleID, "email", email)
- http.Error(w, "internal server error", http.StatusInternalServerError)
- return
- }
-
- // Cascade delete depot object if applicable
- if objectID != "" {
- if err := h.depotSvc.Delete(r.Context(), objectID); err != nil {
- slog.Warn("failed to cascade delete depot object", "error", err, "object_id", objectID, "particle_id", particleID)
- }
- }
-
- w.WriteHeader(http.StatusNoContent)
-}
-
-// OpenStream opens a stream particle
-func (h *Handler) OpenStream(w http.ResponseWriter, r *http.Request) {
- email, ok := middleware.EmailFromContext(r.Context())
- if !ok {
- http.Error(w, "unauthorized", http.StatusUnauthorized)
- return
- }
-
- particleID := r.PathValue("id")
- if particleID == "" {
- http.Error(w, "particle id is required", http.StatusBadRequest)
- return
- }
-
- if err := h.particleSvc.OpenStream(r.Context(), particleID, email); err != nil {
- if errors.Is(err, particle.ErrNotFound) {
- http.Error(w, "particle not found", http.StatusNotFound)
- return
- }
- if errors.Is(err, particle.ErrAccessDenied) {
- http.Error(w, "access denied", http.StatusForbidden)
- return
- }
- if errors.Is(err, particle.ErrNotAStream) {
- http.Error(w, "particle is not a stream", http.StatusBadRequest)
- return
- }
- if errors.Is(err, particle.ErrStreamAlreadyOpen) {
- http.Error(w, "stream is already open", http.StatusConflict)
- return
- }
- if errors.Is(err, particle.ErrCapacityExceeded) {
- http.Error(w, "stream capacity exceeded", http.StatusConflict)
- return
- }
- slog.Error("failed to open stream", "error", err, "particle_id", particleID, "email", email)
- http.Error(w, "internal server error", http.StatusInternalServerError)
- return
- }
-
- w.WriteHeader(http.StatusNoContent)
-}
-
-// CloseStream closes a stream particle
-func (h *Handler) CloseStream(w http.ResponseWriter, r *http.Request) {
- email, ok := middleware.EmailFromContext(r.Context())
- if !ok {
- http.Error(w, "unauthorized", http.StatusUnauthorized)
- return
- }
-
- particleID := r.PathValue("id")
- if particleID == "" {
- http.Error(w, "particle id is required", http.StatusBadRequest)
- return
- }
-
- if err := h.particleSvc.CloseStream(r.Context(), particleID, email); err != nil {
- if errors.Is(err, particle.ErrNotFound) {
- http.Error(w, "particle not found", http.StatusNotFound)
- return
- }
- if errors.Is(err, particle.ErrAccessDenied) {
- http.Error(w, "access denied", http.StatusForbidden)
- return
- }
- if errors.Is(err, particle.ErrNotAStream) {
- http.Error(w, "particle is not a stream", http.StatusBadRequest)
- return
- }
- if errors.Is(err, particle.ErrStreamAlreadyClosed) {
- http.Error(w, "stream is already closed", http.StatusConflict)
- return
- }
- slog.Error("failed to close stream", "error", err, "particle_id", particleID, "email", email)
- http.Error(w, "internal server error", http.StatusInternalServerError)
- return
- }
-
- w.WriteHeader(http.StatusNoContent)
-}
-
-// AddMembers adds members to a particle with custom visibility
-func (h *Handler) AddMembers(w http.ResponseWriter, r *http.Request) {
- email, ok := middleware.EmailFromContext(r.Context())
- if !ok {
- http.Error(w, "unauthorized", http.StatusUnauthorized)
- return
- }
-
- particleID := r.PathValue("id")
- if particleID == "" {
- http.Error(w, "particle id is required", http.StatusBadRequest)
- return
- }
-
- var req MembersRequest
- if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
- http.Error(w, "invalid request body", http.StatusBadRequest)
- return
- }
-
- if len(req.Emails) == 0 {
- http.Error(w, "emails are required", http.StatusBadRequest)
- return
- }
-
- if err := h.particleSvc.AddMembers(r.Context(), particleID, req.Emails, email); err != nil {
- if errors.Is(err, particle.ErrNotFound) {
- http.Error(w, "particle not found", http.StatusNotFound)
- return
- }
- if errors.Is(err, particle.ErrAccessDenied) {
- http.Error(w, "access denied", http.StatusForbidden)
- return
- }
- if errors.Is(err, particle.ErrNotAContainer) {
- http.Error(w, "only streams can have members", http.StatusBadRequest)
- return
- }
- slog.Error("failed to add members to particle", "error", err, "particle_id", particleID, "email", email)
- http.Error(w, "internal server error", http.StatusInternalServerError)
- return
- }
-
- w.WriteHeader(http.StatusNoContent)
-}
-
-// RemoveMembers removes members from a particle with custom visibility
-func (h *Handler) RemoveMembers(w http.ResponseWriter, r *http.Request) {
- email, ok := middleware.EmailFromContext(r.Context())
- if !ok {
- http.Error(w, "unauthorized", http.StatusUnauthorized)
- return
- }
-
- particleID := r.PathValue("id")
- if particleID == "" {
- http.Error(w, "particle id is required", http.StatusBadRequest)
- return
- }
-
- var req MembersRequest
- if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
- http.Error(w, "invalid request body", http.StatusBadRequest)
- return
- }
-
- if len(req.Emails) == 0 {
- http.Error(w, "emails are required", http.StatusBadRequest)
- return
- }
-
- if err := h.particleSvc.RemoveMembers(r.Context(), particleID, req.Emails, email); err != nil {
- if errors.Is(err, particle.ErrNotFound) {
- http.Error(w, "particle not found", http.StatusNotFound)
- return
- }
- if errors.Is(err, particle.ErrAccessDenied) {
- http.Error(w, "access denied", http.StatusForbidden)
- return
- }
- if errors.Is(err, particle.ErrNotAContainer) {
- http.Error(w, "only streams can have members", http.StatusBadRequest)
- return
- }
- slog.Error("failed to remove members from particle", "error", err, "particle_id", particleID, "email", email)
- http.Error(w, "internal server error", http.StatusInternalServerError)
- return
- }
-
- w.WriteHeader(http.StatusNoContent)
-}
-
-// MarkSeen marks a particle as seen by the requester
-func (h *Handler) MarkSeen(w http.ResponseWriter, r *http.Request) {
- email, ok := middleware.EmailFromContext(r.Context())
- if !ok {
- http.Error(w, "unauthorized", http.StatusUnauthorized)
- return
- }
-
- particleID := r.PathValue("id")
- if particleID == "" {
- http.Error(w, "particle id is required", http.StatusBadRequest)
- return
- }
-
- if err := h.particleSvc.MarkSeen(r.Context(), particleID, email); err != nil {
- if errors.Is(err, particle.ErrNotFound) {
- http.Error(w, "particle not found", http.StatusNotFound)
- return
- }
- if errors.Is(err, particle.ErrAccessDenied) {
- http.Error(w, "access denied", http.StatusForbidden)
- return
- }
- slog.Error("failed to mark particle as seen", "error", err, "particle_id", particleID, "email", email)
- http.Error(w, "internal server error", http.StatusInternalServerError)
- return
- }
-
- w.WriteHeader(http.StatusNoContent)
-}
-
-// MarkSeenBatch marks multiple particles as seen by the requester
-func (h *Handler) MarkSeenBatch(w http.ResponseWriter, r *http.Request) {
- email, ok := middleware.EmailFromContext(r.Context())
- if !ok {
- http.Error(w, "unauthorized", http.StatusUnauthorized)
- return
- }
-
- var req MarkSeenBatchRequest
- if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
- http.Error(w, "invalid request body", http.StatusBadRequest)
- return
- }
-
- if len(req.ParticleIDs) == 0 {
- http.Error(w, "particle_ids are required", http.StatusBadRequest)
- return
- }
-
- if err := h.particleSvc.MarkSeenBatch(r.Context(), req.ParticleIDs, email); err != nil {
- slog.Error("failed to mark particles as seen", "error", err, "email", email)
- http.Error(w, "internal server error", http.StatusInternalServerError)
- return
- }
-
- w.WriteHeader(http.StatusNoContent)
-}
-
-// AckParticle acknowledges a particle (public, permanent)
-func (h *Handler) AckParticle(w http.ResponseWriter, r *http.Request) {
- email, ok := middleware.EmailFromContext(r.Context())
- if !ok {
- http.Error(w, "unauthorized", http.StatusUnauthorized)
- return
- }
-
- particleID := r.PathValue("id")
- if particleID == "" {
- http.Error(w, "particle id is required", http.StatusBadRequest)
- return
- }
-
- if err := h.particleSvc.Ack(r.Context(), particleID, email); err != nil {
- if errors.Is(err, particle.ErrNotFound) {
- http.Error(w, "particle not found", http.StatusNotFound)
- return
- }
- if errors.Is(err, particle.ErrAccessDenied) {
- http.Error(w, "access denied", http.StatusForbidden)
- return
- }
- slog.Error("failed to ack particle", "error", err, "particle_id", particleID, "email", email)
- http.Error(w, "internal server error", http.StatusInternalServerError)
- return
- }
-
- w.WriteHeader(http.StatusNoContent)
-}
-
-// DownloadParticle redirects to a fresh signed download URL for media/file particles
-func (h *Handler) DownloadParticle(w http.ResponseWriter, r *http.Request) {
- email, ok := middleware.EmailFromContext(r.Context())
- if !ok {
- http.Error(w, "unauthorized", http.StatusUnauthorized)
- return
- }
-
- particleID := r.PathValue("id")
- if particleID == "" {
- http.Error(w, "particle id is required", http.StatusBadRequest)
- return
- }
-
- p, err := h.particleSvc.GetByID(r.Context(), particleID, email)
- if err != nil {
- if errors.Is(err, particle.ErrNotFound) {
- http.Error(w, "particle not found", http.StatusNotFound)
- return
- }
- if errors.Is(err, particle.ErrAccessDenied) {
- http.Error(w, "access denied", http.StatusForbidden)
- return
- }
- slog.Error("failed to get particle for download", "error", err, "particle_id", particleID, "email", email)
- http.Error(w, "internal server error", http.StatusInternalServerError)
- return
- }
-
- objectID := extractObjectID(p)
- if objectID == "" {
- http.Error(w, "particle has no downloadable content", http.StatusBadRequest)
- return
- }
+ // TODO: integrate firebase to fetch particle, and verify visibility for this particle's media
+
+ objectID := r.PathValue("id")
+
+ // particleID := r.PathValue("id")
+ // if particleID == "" {
+ // http.Error(w, "object id is required", http.StatusBadRequest)
+ // return
+ // }
+
+ // p, err := h.particleSvc.GetByID(r.Context(), particleID, email)
+ // if err != nil {
+ // if errors.Is(err, particle.ErrNotFound) {
+ // http.Error(w, "particle not found", http.StatusNotFound)
+ // return
+ // }
+ // if errors.Is(err, particle.ErrAccessDenied) {
+ // http.Error(w, "access denied", http.StatusForbidden)
+ // return
+ // }
+ // slog.Error("failed to get particle for download", "error", err, "particle_id", particleID, "email", email)
+ // http.Error(w, "internal server error", http.StatusInternalServerError)
+ // return
+ // }
+ //
+ // objectID := extractObjectID(p)
+ // if objectID == "" {
+ // http.Error(w, "particle has no downloadable content", http.StatusBadRequest)
+ // return
+ // }
downloadURL, err := h.depotSvc.GetDownloadURL(r.Context(), objectID)
if err != nil {
- slog.Error("failed to get download URL", "error", err, "object_id", objectID, "particle_id", particleID)
+ slog.Error("failed to get download URL", "error", err, "object_id", objectID)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
@@ -1896,6 +594,7 @@ func (h *Handler) ConfirmUpload(w http.ResponseWriter, r *http.Request) {
ContentType: obj.ContentType,
ContentLength: obj.ContentLength,
ContainsContent: obj.ContainsContent,
+ DownloadURL: "",
CreatedAt: obj.CreatedAt,
}
@@ -1907,46 +606,6 @@ func (h *Handler) ConfirmUpload(w http.ResponseWriter, r *http.Request) {
// Helper Functions
// ============================================================================
-const listAllBatchSize = 100
-
-// listAllParticles fetches all particles matching the query by paginating in batches.
-// This keeps the particle service pagination contract intact.
-func (h *Handler) listAllParticles(ctx context.Context, networkID string, parentID *string, email string, filter particle.ListFilter) ([]*particle.Particle, error) {
- var all []*particle.Particle
- var cursor *particle.Cursor
-
- for {
- page, err := h.particleSvc.List(ctx, networkID, parentID, email, filter, cursor, listAllBatchSize)
- if err != nil {
- return nil, err
- }
-
- all = append(all, page.Particles...)
-
- if !page.HasMore || page.NextCursor == nil {
- break
- }
- cursor = page.NextCursor
- }
-
- return all, nil
-}
-
-// getStreamMembers returns the effective members for a stream.
-// For custom visibility, returns particle_members. For network_all, returns all network members.
-func (h *Handler) getStreamMembers(ctx context.Context, sp *particle.Particle, membersMap map[string][]string) []string {
- if sp.Visibility == particle.VisibilityCustom {
- return membersMap[sp.ID]
- }
- // network_all — return all network members
- net, err := h.networkSvc.GetByID(ctx, sp.NetworkID)
- if err != nil {
- slog.Warn("failed to get network for stream members", "error", err, "network_id", sp.NetworkID)
- return nil
- }
- return net.MemberEmails
-}
-
func humanToDTO(h *human.Human) Human {
return Human{
Id: utils.CreateOptionalString(h.ID),
@@ -1959,6 +618,7 @@ func humanToDTO(h *human.Human) Human {
func emailPrefix(email string) string {
return strings.Split(email, "@")[0]
}
+
func (h *Handler) networkToDTO(ctx context.Context, n *network.Network) (Network, error) {
adminHuman, err := h.humanSvc.GetByEmail(ctx, n.AdminEmail)
if err != nil {
@@ -1985,70 +645,14 @@ func (h *Handler) networkToDTO(ctx context.Context, n *network.Network) (Network
}
return Network{
- Id: n.ID,
- Name: n.Name,
- AdminHuman: humanToDTO(adminHuman),
- Humans: humans,
- OpenStreamCount: n.OpenStreamCount,
- OpenStreamCapacity: n.OpenStreamCapacity,
- CreatedAt: n.CreatedAt,
+ Id: n.ID,
+ Name: n.Name,
+ AdminHuman: humanToDTO(adminHuman),
+ Humans: humans,
+ CreatedAt: n.CreatedAt,
}, nil
}
-func (h *Handler) particleToDTO(ctx context.Context, p *particle.Particle) Particle {
- dto := Particle{
- Id: p.ID,
- Type: string(p.Type),
- CreatedByEmail: p.CreatedByEmail,
- Data: p.Data,
- UpdatedAt: p.UpdatedAt,
- CreatedAt: p.CreatedAt,
- Visibility: string(p.Visibility),
- Seen: false,
- Acks: []*AckInfo{},
- }
-
- return dto
-}
-
-func (h *Handler) streamParticleToDTO(ctx context.Context, p *particle.Particle) StreamParticle {
- dto := StreamParticle{
- Id: p.ID,
- Type: string(p.Type),
- CreatedByEmail: p.CreatedByEmail,
- Data: p.Data,
- UpdatedAt: p.UpdatedAt,
- CreatedAt: p.CreatedAt,
- }
-
- return dto
-}
-
-func parseStreamStatus(s string) StreamStatus {
- switch s {
- case "open":
- return STREAM_STATUS_OPEN
- case "closed":
- return STREAM_STATUS_CLOSED
- default:
- return STREAM_STATUS_UNSPECIFIED
- }
-}
-
-func extractObjectID(p *particle.Particle) string {
- if p.Type != particle.TypeMedia && p.Type != particle.TypeFile {
- return ""
- }
-
- var data struct {
- ObjectID string `json:"object_id"`
- }
- if err := json.Unmarshal(p.Data, &data); err == nil {
- return data.ObjectID
- }
- return ""
-}
-
func extractBearerToken(r *http.Request) string {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
diff --git a/js/package.json b/js/package.json
index 931e744..e7bc81a 100644
--- a/js/package.json
+++ b/js/package.json
@@ -31,6 +31,7 @@
"@electron-forge/plugin-vite": "^7.11.1",
"@electron/fuses": "^1.8.0",
"@tailwindcss/vite": "^4.2.0",
+ "@tanstack/eslint-plugin-query": "^5.91.4",
"@types/electron-squirrel-startup": "^1.0.2",
"@types/node": "^25.3.0",
"@types/react": "^19.2.14",
@@ -41,13 +42,15 @@
"electron": "40.6.0",
"eslint": "^8.57.1",
"eslint-plugin-import": "^2.32.0",
- "typescript": "~4.5.4",
+ "typescript": "^5.9.3",
"vite": "^5.4.21"
},
"dependencies": {
+ "@tanstack/react-query": "^5.90.21",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"electron-squirrel-startup": "^1.0.1",
+ "firebase": "^12.10.0",
"lucide-react": "^0.575.0",
"radix-ui": "^1.4.3",
"react": "^19.2.4",
@@ -57,6 +60,7 @@
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.0",
"tw-animate-css": "^1.4.0",
+ "zod": "^4.3.6",
"zustand": "^5.0.11"
}
}
diff --git a/js/src/App.tsx b/js/src/App.tsx
index c372cd3..72ffd96 100644
--- a/js/src/App.tsx
+++ b/js/src/App.tsx
@@ -1,39 +1,16 @@
import { useEffect } from "react";
import { HashRouter, Routes, Route } from "react-router-dom";
import { TooltipProvider } from "@/components/ui/tooltip";
-import { useAppStore } from "@/stores/app-store";
import { useAuthStore } from "@/stores/auth-store";
import { LoginPage } from "@/features/auth/login-page";
-import { StreamsPage } from "@/pages/streams-page";
-import { StreamPlayerPage } from "@/pages/stream-player-page";
+import { } from "@/firebase";
+import {
+ QueryClient,
+ QueryClientProvider,
+} from '@tanstack/react-query'
+import PathResolver from "./pages/path-resolver";
-function AuthenticatedApp() {
- const fetchStartup = useAppStore((s) => s.fetchStartup);
- const isLoading = useAppStore((s) => s.isLoading);
-
- useEffect(() => {
- fetchStartup();
- }, [fetchStartup]);
-
- if (isLoading) {
- return (
-
- );
- }
-
- return (
-
-
-
- } />
- } />
-
-
-
- );
-}
+const queryClient = new QueryClient();
const App = () => {
const status = useAuthStore((s) => s.status);
@@ -58,4 +35,23 @@ const App = () => {
return ;
};
-export default App;
+function AuthenticatedApp() {
+ // NOTE: Hash router provides history, despite using catch-all
+ return (
+
+
+ } />
+
+
+ );
+}
+
+const AppWithProviders = () => (
+
+
+
+
+
+);
+
+export default AppWithProviders;
diff --git a/js/src/api/client.ts b/js/src/api/client.ts
index f08f0d8..d27afa0 100644
--- a/js/src/api/client.ts
+++ b/js/src/api/client.ts
@@ -1,17 +1,19 @@
import { useSessionStore } from "@/stores/session-store";
+import type { z } from "zod";
+import {
+ DepotObjectSchema,
+ HumanSchema,
+ ListNetworksResponseSchema,
+ NetworkSchema,
+ PrepareUploadResponseSchema,
+ SignInResponseSchema,
+} from "./types";
import type {
- CreateStreamParticleRequest,
- CreateStreamRequest,
- Human,
- MarkSeenBatchRequest,
+ AddMembersRequest,
+ CreateNetworkRequest,
PrepareUploadRequest,
- PrepareUploadResponse,
RequestCodeRequest,
SignInRequest,
- SignInResponse,
- StartupResponse,
- Stream,
- StreamParticle,
} from "./types";
export class ApiError extends Error {
@@ -37,11 +39,11 @@ class ApiClient {
this.config = config;
}
- private async request(
+ private async fetch(
method: string,
path: string,
body?: unknown,
- ): Promise {
+ ): Promise {
const headers: Record = {
"Content-Type": "application/json",
};
@@ -67,109 +69,106 @@ class ApiClient {
throw new ApiError(response.status, text);
}
- if (response.status === 204) {
- return undefined as T;
- }
+ return response;
+ }
- return response.json() as Promise;
+ private async request(
+ schema: z.ZodType,
+ method: string,
+ path: string,
+ body?: unknown,
+ ): Promise {
+ const response = await this.fetch(method, path, body);
+ const json = await response.json();
+ return schema.parse(json);
+ }
+
+ private async requestVoid(
+ method: string,
+ path: string,
+ body?: unknown,
+ ): Promise {
+ await this.fetch(method, path, body);
}
// --- Auth ---
async requestCode(data: RequestCodeRequest): Promise {
- await this.request("POST", "/auth/request-code", data);
+ await this.requestVoid("POST", "/auth/request-code", data);
}
- async signIn(data: SignInRequest): Promise {
- return this.request("POST", "/auth/sign-in", data);
+ async signIn(data: SignInRequest) {
+ return this.request(SignInResponseSchema, "POST", "/auth/sign-in", data);
}
- async me(): Promise {
- return this.request("GET", "/auth/me");
+ async me() {
+ return this.request(HumanSchema, "GET", "/auth/me");
}
async signOut(): Promise {
- await this.request("POST", "/auth/sign-out");
+ await this.requestVoid("POST", "/auth/sign-out");
}
- // --- Startup ---
-
- async startup(): Promise {
- return this.request("GET", "/startup");
- }
-
- // --- Streams ---
-
- async createStream(
- networkId: string,
- data: CreateStreamRequest,
- ): Promise {
- return this.request(
- "POST",
- `/networks/${networkId}/streams`,
- data,
+ // TODO: security: require passing in the particle id once api deprecates this
+ async getParticleDownloadUrl(objectId: string): Promise {
+ const response = await this.fetch(
+ "GET",
+ `/particles/${objectId}/download`,
);
- }
-
- async createStreamParticle(
- streamId: string,
- data: CreateStreamParticleRequest,
- ): Promise {
- return this.request(
- "POST",
- `/streams/${streamId}/particles`,
- data,
- );
- }
-
- // --- Particles ---
-
- async markSeen(particleId: string): Promise {
- await this.request("POST", `/particles/${particleId}/seen`);
- }
-
- async ackParticle(particleId: string): Promise {
- await this.request("POST", `/particles/${particleId}/ack`);
- }
-
- async markSeenBatch(data: MarkSeenBatchRequest): Promise {
- await this.request("POST", "/particles/seen", data);
- }
-
- async getParticleDownloadUrl(particleId: string): Promise {
- const token = this.config.getToken();
- const headers: Record = {};
- if (token) {
- headers["Authorization"] = `Bearer ${token}`;
- }
-
- const response = await fetch(
- `${this.config.baseUrl}/particles/${particleId}/download`,
- { headers, redirect: "follow" },
- );
-
- if (response.status === 401) {
- this.config.onUnauthorized();
- throw new ApiError(401, "Unauthorized");
- }
-
- if (!response.ok) {
- throw new ApiError(response.status, "Failed to get download URL");
- }
-
return response.url;
}
// --- Depot ---
- async prepareUpload(
- data: PrepareUploadRequest,
- ): Promise {
- return this.request("POST", "/depot/upload", data);
+ async prepareUpload(data: PrepareUploadRequest) {
+ return this.request(
+ PrepareUploadResponseSchema,
+ "POST",
+ "/depot/upload",
+ data,
+ );
}
- async confirmUpload(objectId: string): Promise {
- await this.request("POST", `/depot/objects/${objectId}/confirm`);
+ async confirmUpload(objectId: string) {
+ return this.request(
+ DepotObjectSchema,
+ "POST",
+ `/depot/objects/${objectId}/confirm`,
+ );
+ }
+
+
+ // --- Networks ---
+
+ async listNetworks() {
+ return this.request(
+ ListNetworksResponseSchema,
+ "GET",
+ "/networks",
+ );
+ }
+
+ async createNetwork(data: CreateNetworkRequest) {
+ return this.request(NetworkSchema, "POST", "/networks", data);
+ }
+
+ async getNetwork(id: string) {
+ return this.request(NetworkSchema, "GET", `/networks/${id}`);
+ }
+
+ async addMembers(networkId: string, data: AddMembersRequest): Promise {
+ await this.requestVoid(
+ "POST",
+ `/networks/${networkId}/members`,
+ data,
+ );
+ }
+
+ async removeMember(networkId: string, email: string): Promise {
+ await this.requestVoid(
+ "DELETE",
+ `/networks/${networkId}/members/${email}`,
+ );
}
}
diff --git a/js/src/api/types.ts b/js/src/api/types.ts
index e0eb6fa..1583c24 100644
--- a/js/src/api/types.ts
+++ b/js/src/api/types.ts
@@ -1,168 +1,174 @@
-// --- Core entities ---
+import { z } from "zod";
-export interface Human {
- id: string | null;
- email: string;
- email_prefix: string;
- created_at: string | null;
-}
+export const HumanSchema = z.object({
+ id: z.string().nullable(),
+ created_at: z.coerce.date().nullable(),
+ email: z.string().email(),
+ email_prefix: z.string(),
+});
-export type StreamStatus = "open" | "closed" | "unspecified";
+export type Human = z.infer;
-export interface AckInfo {
- email: string;
- acked_at: string;
-}
+export const NetworkSchema = z.object({
+ id: z.string(),
+ name: z.string(),
+ admin_human: HumanSchema,
+ humans: z.array(HumanSchema),
+ created_at: z.coerce.date(),
+});
-// --- Particle types ---
+export type Network = z.infer;
-export type ParticleType =
- | "media"
- | "text"
- | "quest"
- | "paper"
- | "file"
- | "folder";
+export const ListNetworksResponseSchema = z.array(NetworkSchema);
+export type ListNetworksResponse = z.infer;
-export interface MediaParticleData {
- object_id: string;
- duration_ms: number;
- mime_type: string;
-}
+// --- Network request/response types ---
-export interface TextParticleData {
- content: string;
-}
+const CreateNetworkRequestSchema = z.object({
+ name: z.string(),
+});
+export type CreateNetworkRequest = z.infer;
-export interface QuestParticleData {
- title: string;
- description: string;
- status?: string;
- assigned_to?: string;
- due_date?: string;
-}
-
-export interface PaperParticleData {
- title: string;
- content: string;
-}
-
-export interface FileParticleData {
- object_id: string;
- filename: string;
- mime_type: string;
- size: number;
-}
-
-export interface FolderParticleData {
- name: string;
- color?: string;
-}
-
-export interface ParticleDataMap {
- media: MediaParticleData;
- text: TextParticleData;
- quest: QuestParticleData;
- paper: PaperParticleData;
- file: FileParticleData;
- folder: FolderParticleData;
-}
-
-export function getParticleData(
- particle: StreamParticle,
- type: T,
-): ParticleDataMap[T] {
- return particle.data as ParticleDataMap[T];
-}
-
-export interface StreamParticle {
- id: string;
- type: ParticleType;
- data: unknown;
- created_by_email: string;
- seen: boolean;
- acks: AckInfo[];
- updated_at: string;
- created_at: string;
-}
-
-export interface Stream {
- id: string;
- name: string;
- description: string;
- status: StreamStatus;
- members?: string[];
- particles: StreamParticle[];
- unseen_count: number;
- updated_at: string;
- created_at: string;
-}
-
-export interface Network {
- id: string;
- name: string;
- admin_human: Human;
- humans: Human[];
- open_stream_count: number;
- open_stream_capacity: number;
- created_at: string;
-}
-
-export interface NetworkWithStreams extends Network {
- streams: Stream[];
-}
+const AddMembersRequestSchema = z.object({
+ email_addresses: z.array(z.string().email()),
+});
+export type AddMembersRequest = z.infer;
// --- Depot types ---
-export interface PrepareUploadRequest {
- network_id: string;
- name: string;
- content_type: string;
- content_length: number;
+const PrepareUploadRequestSchema = z.object({
+ network_id: z.string(),
+ name: z.string(),
+ content_type: z.string(),
+ content_length: z.number(),
+});
+export type PrepareUploadRequest = z.infer;
+
+export const PrepareUploadResponseSchema = z.object({
+ object_id: z.string(),
+ upload_url: z.string(),
+ upload_headers: z.record(z.string(), z.string()),
+});
+export type PrepareUploadResponse = z.infer;
+
+export const DepotObjectSchema = z.object({
+ id: z.string(),
+ name: z.string(),
+ content_type: z.string(),
+ content_length: z.number(),
+ contains_content: z.boolean(),
+ created_at: z.coerce.date(),
+});
+export type DepotObject = z.infer;
+
+// --- Particle property schemas ---
+
+export const StreamPropertiesSchema = z.object({
+ name: z.string(),
+ status: z.enum(["open", "closed"]),
+ description: z.string().optional(),
+});
+export type StreamProperties = z.infer;
+
+export const FolderPropertiesSchema = z.object({
+ name: z.string(),
+ color: z.string().optional(),
+});
+export type FolderProperties = z.infer;
+
+export const MediaPropertiesSchema = z.object({
+ object_id: z.string(),
+ mime_type: z.string(),
+ duration_ms: z.number(),
+ size_bytes: z.number(),
+});
+export type MediaProperties = z.infer;
+
+export const FilePropertiesSchema = z.object({
+ object_id: z.string(),
+ filename: z.string(),
+ mime_type: z.string(),
+ size_bytes: z.number(),
+});
+export type FileProperties = z.infer;
+
+export const TextPropertiesSchema = z.object({
+ content: z.string(),
+});
+export type TextProperties = z.infer;
+
+export const QuestPropertiesSchema = z.object({
+ title: z.string(),
+ description: z.string(),
+ status: z.string().optional(),
+ assigned_to: z.string().email().optional(),
+});
+export type QuestProperties = z.infer;
+
+export const PaperPropertiesSchema = z.object({
+ title: z.string(),
+ content: z.string(),
+});
+export type PaperProperties = z.infer;
+
+export interface ParticlePropertiesMap {
+ stream: StreamProperties;
+ folder: FolderProperties;
+ media: MediaProperties;
+ file: FileProperties;
+ text: TextProperties;
+ quest: QuestProperties;
+ paper: PaperProperties;
}
-export interface PrepareUploadResponse {
- object_id: string;
- upload_url: string;
- upload_headers: Record;
-}
+// --- Unified Particle types ---
-// --- Stream mutation types ---
+const ParticleBaseSchema = z.object({
+ id: z.string(),
+ created_at: z.coerce.date(),
+ created_by_email: z.string().email(),
+ updated_at: z.coerce.date().optional(),
+ // e.g. ["human:aron@acme.com", "human:john@acme.com"] - visible only to Aron and John
+ // e.g. ["network:123"] - visible to everyone in the network
+ visible_to: z.array(z.string())
+});
-export interface CreateStreamRequest {
- name: string;
- description: string;
- visibility: "network_all" | "custom";
- member_emails?: string[];
-}
+export const ParticleSchema = z.discriminatedUnion("type", [
+ ParticleBaseSchema.extend({ type: z.literal("stream"), properties: StreamPropertiesSchema }),
+ ParticleBaseSchema.extend({ type: z.literal("folder"), properties: FolderPropertiesSchema }),
+ ParticleBaseSchema.extend({ type: z.literal("media"), properties: MediaPropertiesSchema }),
+ ParticleBaseSchema.extend({ type: z.literal("file"), properties: FilePropertiesSchema }),
+ ParticleBaseSchema.extend({ type: z.literal("text"), properties: TextPropertiesSchema }),
+ ParticleBaseSchema.extend({ type: z.literal("quest"), properties: QuestPropertiesSchema }),
+ ParticleBaseSchema.extend({ type: z.literal("paper"), properties: PaperPropertiesSchema }),
+]);
-export interface CreateStreamParticleRequest {
- type: ParticleType;
- data: unknown;
-}
+export type Particle = z.infer;
-export interface MarkSeenBatchRequest {
- particle_ids: string[];
+export type ParticleType = Particle["type"];
+
+/** Container types can have children subcollections */
+export const CONTAINER_TYPES: ReadonlySet = new Set(["stream", "folder"]);
+
+export function isContainerType(type: ParticleType): boolean {
+ return CONTAINER_TYPES.has(type);
}
// --- Auth types ---
-export interface RequestCodeRequest {
- email: string;
-}
+const RequestCodeRequestSchema = z.object({
+ email: z.string().email(),
+});
+export type RequestCodeRequest = z.infer;
-export interface SignInRequest {
- email: string;
- code: string;
-}
+const SignInRequestSchema = z.object({
+ email: z.string().email(),
+ code: z.string(),
+});
+export type SignInRequest = z.infer;
-export interface SignInResponse {
- human: Human;
- token: string;
-}
-
-// --- Startup ---
-
-export interface StartupResponse {
- networks: NetworkWithStreams[];
-}
+export const SignInResponseSchema = z.object({
+ human: HumanSchema,
+ token: z.string(),
+});
+export type SignInResponse = z.infer;
diff --git a/js/src/features/network-selector.tsx b/js/src/features/network-selector.tsx
new file mode 100644
index 0000000..2e8383a
--- /dev/null
+++ b/js/src/features/network-selector.tsx
@@ -0,0 +1,86 @@
+import { useNavigate } from "react-router-dom";
+import { LogOut } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Muted } from "@/components/ui/typography";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { useAuthStore } from "@/stores/auth-store";
+import { WindowControls } from "@/components/window-controls";
+import { useQuery } from "@tanstack/react-query";
+import { apiClient } from "@/api/client";
+import { Progress } from "@/components/ui/progress";
+
+export function NetworkSelector() {
+ const navigate = useNavigate();
+ const signOut = useAuthStore((s) => s.signOut);
+ const user = useAuthStore((s) => s.user);
+
+ const { data, isPending, error } = useQuery({
+ queryKey: ["networks"],
+ queryFn: () => apiClient.listNetworks(),
+ });
+
+ if (isPending) {
+ return ;
+ }
+
+ if (error) {
+ return (
+
+
Failed to load networks
+
{error.message}
+
+ );
+ }
+
+ if (data?.length === 0) {
+ return (
+
+
+ You don't have access to any networks yet. Please email us to get started.
+
+
+ team@flowylabs.ai
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ {user &&
{user.email_prefix} }
+
+
+
+
+
+
+ navigate(`/${value}`)}>
+
+
+
+
+ {data?.map((network) => (
+
+ {network.name}
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/js/src/features/particles/folder-view.tsx b/js/src/features/particles/folder-view.tsx
new file mode 100644
index 0000000..41866d4
--- /dev/null
+++ b/js/src/features/particles/folder-view.tsx
@@ -0,0 +1,20 @@
+import { Particle } from "@/api/types";
+import { useParticleChildren } from "@/hooks/use-particle-children";
+
+interface FolderViewProps {
+ folderParticle: Particle;
+ networkId: string;
+ particleSegments: string[];
+}
+
+export function FolderView({ networkId, particleSegments, folderParticle }: FolderViewProps) {
+ const { children, error, isLoading } = useParticleChildren(networkId, particleSegments);
+
+ return (
+
+
+ Folder view — {networkId}/{particleSegments.join("/")}
+
+
+ );
+}
diff --git a/js/src/features/particles/particle-list-view.tsx b/js/src/features/particles/particle-list-view.tsx
new file mode 100644
index 0000000..54f66cd
--- /dev/null
+++ b/js/src/features/particles/particle-list-view.tsx
@@ -0,0 +1,43 @@
+import { useParticleChildren } from "@/hooks/use-particle-children";
+
+interface ParticleListViewProps {
+ networkId: string;
+ particleSegments: string[];
+}
+
+/**
+ * Grid/list of child particles for a container (folder, stream root, or network root).
+ */
+export function ParticleListView({ networkId, particleSegments }: ParticleListViewProps) {
+ const { children, isLoading } = useParticleChildren(networkId, particleSegments);
+
+ if (isLoading) {
+ return (
+
+ );
+ }
+
+ if (children.length === 0) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {children.map((child) => (
+
+
{child.id}
+
{child.type}
+
+ ))}
+
+ );
+}
diff --git a/js/src/features/particles/particle-view-resolver.tsx b/js/src/features/particles/particle-view-resolver.tsx
new file mode 100644
index 0000000..7d05ea3
--- /dev/null
+++ b/js/src/features/particles/particle-view-resolver.tsx
@@ -0,0 +1,65 @@
+import { useParticle } from "@/hooks/use-particle";
+import { StreamView } from "./stream-view";
+import { FolderView } from "./folder-view";
+import { ParticleListView } from "./particle-list-view";
+import { isContainerType } from "@/api/types";
+
+interface ParticleViewResolverProps {
+ networkId: string;
+ particleSegments: string[];
+}
+
+/**
+ * Resolves a particle by its path segments and renders the appropriate view
+ * based on particle type (e.g. stream would show clips in story mode, folder would list files, etc.)
+ */
+export function ParticleViewResolver({ networkId, particleSegments }: ParticleViewResolverProps) {
+ const { particle, isLoading, error } = useParticle(networkId, particleSegments);
+
+ if (isLoading) {
+ return (
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
Failed to load particle
+
+ );
+ }
+
+ // While the hook is stubbed, particle will be null — show a placeholder
+ if (!particle) {
+ return (
+
+
+ Particle: {particleSegments.join(" / ")}
+
+
+ );
+ }
+
+ switch (particle.type) {
+ case "stream":
+ return ;
+ case "folder":
+ return ;
+ default:
+ // For container types we haven't built a view for, fall back to list
+ if (isContainerType(particle.type)) {
+ return ;
+ }
+ // Leaf particle — placeholder
+ return (
+
+
+ {particle.type} particle: {particle.id}
+
+
+ );
+ }
+}
diff --git a/js/src/features/particles/stream-view.tsx b/js/src/features/particles/stream-view.tsx
new file mode 100644
index 0000000..836ca16
--- /dev/null
+++ b/js/src/features/particles/stream-view.tsx
@@ -0,0 +1,36 @@
+import { Particle } from "@/api/types";
+import { useParticleChildren } from "@/hooks/use-particle-children";
+
+interface StreamViewProps {
+ streamParticle: Particle;
+ networkId: string;
+ particleSegments: string[];
+}
+
+export function StreamView({ networkId, particleSegments, streamParticle }: StreamViewProps) {
+ const { children, error, isLoading } = useParticleChildren(networkId, particleSegments);
+
+ return (
+
+
+ Stream view — {networkId}/{particleSegments.join("/")}
+
+
+ {isLoading &&
Loading stream data...
}
+ {error &&
Failed to load stream data
}
+
+ {!isLoading && !error && (
+
+
Stream Children:
+
+ {children.map((child) => (
+
+ {child.id} ({child.type})
+
+ ))}
+
+
+ )}
+
+ );
+}
diff --git a/js/src/features/streams/stream-list.tsx b/js/src/features/streams/stream-list.tsx
deleted file mode 100644
index 0124621..0000000
--- a/js/src/features/streams/stream-list.tsx
+++ /dev/null
@@ -1,98 +0,0 @@
-import { useNavigate } from "react-router-dom";
-import { Badge } from "@/components/ui/badge";
-import { Card } from "@/components/ui/card";
-import { Avatar, AvatarFallback } from "@/components/ui/avatar";
-import { useAppStore } from "@/stores/app-store";
-import { flattenStreams } from "@/lib/stream-utils";
-import { formatDistanceToNow } from "@/lib/time-utils";
-import { ParticlePreview } from "./particle-preview";
-
-function getInitials(email: string): string {
- const prefix = email.split("@")[0] ?? "";
- return prefix.slice(0, 2).toUpperCase();
-}
-
-export function StreamList() {
- const networks = useAppStore((s) => s.networks);
- const selectedNetworkId = useAppStore((s) => s.selectedNetworkId);
- const navigate = useNavigate();
-
- const streams = flattenStreams(networks, selectedNetworkId);
-
- if (streams.length === 0) {
- return (
-
- );
- }
-
- return (
-
- {streams.map((stream) => {
- const lastParticle =
- stream.particles.length > 0
- ? stream.particles[stream.particles.length - 1]
- : null;
-
- const timeSource = lastParticle?.created_at ?? stream.updated_at;
- const senderEmail = lastParticle?.created_by_email;
- const senderPrefix = senderEmail?.split("@")[0];
-
- return (
-
navigate(`/streams/${stream.id}`)}
- >
- {/* Preview hero area */}
-
- {lastParticle ? (
-
- ) : (
-
- )}
-
- {/* Unseen badge overlay */}
- {stream.unseen_count > 0 && (
-
- {stream.unseen_count}
-
- )}
-
-
- {/* Footer: avatar + stream info */}
-
- {senderEmail ? (
-
-
- {getInitials(senderEmail)}
-
-
- ) : (
-
- )}
-
-
-
{stream.name}
-
- {senderPrefix && {senderPrefix} }
- {senderPrefix && timeSource && · }
- {timeSource && {formatDistanceToNow(timeSource)} }
-
-
-
-
- );
- })}
-
- );
-}
diff --git a/js/src/firebase.ts b/js/src/firebase.ts
new file mode 100644
index 0000000..06e569b
--- /dev/null
+++ b/js/src/firebase.ts
@@ -0,0 +1,19 @@
+import { initializeApp } from 'firebase/app';
+import { initializeFirestore, persistentLocalCache, persistentMultipleTabManager } from "firebase/firestore";
+
+const firebaseConfig = {
+ apiKey: "AIzaSyDF_fbk7tDY9tNxUiOwwi--2nYZWZygMGk",
+ appId: "1:1006580076785:web:e2a0736d60a78e02b15950",
+ authDomain: "flowy-dev-440017.firebaseapp.com",
+ messagingSenderId: "1006580076785",
+ projectId: "flowy-dev-440017",
+ storageBucket: "flowy-dev-440017.firebasestorage.app",
+};
+
+export const firebaseApp = initializeApp(firebaseConfig);
+
+export const firestoreDb = initializeFirestore(firebaseApp,
+ {
+ localCache:
+ persistentLocalCache(/*settings*/{ tabManager: persistentMultipleTabManager() })
+ });
diff --git a/js/src/hooks/use-create-particle.ts b/js/src/hooks/use-create-particle.ts
new file mode 100644
index 0000000..d6df5b0
--- /dev/null
+++ b/js/src/hooks/use-create-particle.ts
@@ -0,0 +1,24 @@
+import { useMutation } from "@tanstack/react-query";
+import { createParticle } from "@/lib/firestore-particles";
+import type { ParticleType, ParticlePropertiesMap } from "@/api/types";
+
+interface CreateParticleParams {
+ collectionPath: string;
+ type: T;
+ properties: ParticlePropertiesMap[T];
+ createdByEmail: string;
+ visibleTo: string[];
+}
+
+export function useCreateParticle() {
+ return useMutation({
+ mutationFn: (params: CreateParticleParams) =>
+ createParticle(
+ params.collectionPath,
+ params.type,
+ params.properties,
+ params.createdByEmail,
+ params.visibleTo,
+ ),
+ });
+}
diff --git a/js/src/hooks/use-particle-children.ts b/js/src/hooks/use-particle-children.ts
new file mode 100644
index 0000000..03d3be9
--- /dev/null
+++ b/js/src/hooks/use-particle-children.ts
@@ -0,0 +1,46 @@
+import { useState, useEffect, useMemo } from "react";
+import { subscribeToParticleChildren } from "@/lib/firestore-particles";
+import { firestorePath } from "@/lib/firestore-paths";
+import type { Particle } from "@/api/types";
+
+interface UseParticleChildrenResult {
+ children: Particle[];
+ isLoading: boolean;
+ error: Error | null;
+}
+
+export function useParticleChildren(
+ networkId: string,
+ parentSegments: string[],
+): UseParticleChildrenResult {
+ const [children, setChildren] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ const collectionPath = useMemo(() => {
+ if (parentSegments.length === 0) return firestorePath(networkId, []);
+ return `${firestorePath(networkId, parentSegments)}/children`;
+ }, [networkId, parentSegments.join("/")]);
+
+ useEffect(() => {
+ setIsLoading(true);
+ setError(null);
+ setChildren([]);
+
+ const unsubscribe = subscribeToParticleChildren(
+ collectionPath,
+ (data) => {
+ setChildren(data);
+ setIsLoading(false);
+ },
+ (err) => {
+ setError(err);
+ setIsLoading(false);
+ },
+ );
+
+ return unsubscribe;
+ }, [collectionPath]);
+
+ return { children, isLoading, error };
+}
diff --git a/js/src/hooks/use-particle.ts b/js/src/hooks/use-particle.ts
new file mode 100644
index 0000000..6a04a1d
--- /dev/null
+++ b/js/src/hooks/use-particle.ts
@@ -0,0 +1,46 @@
+import { useState, useEffect, useMemo } from "react";
+import { subscribeToParticle } from "@/lib/firestore-particles";
+import { firestorePath } from "@/lib/firestore-paths";
+import type { Particle } from "@/api/types";
+
+interface UseParticleResult {
+ particle: Particle | null;
+ isLoading: boolean;
+ error: Error | null;
+}
+
+export function useParticle(
+ networkId: string,
+ segments: string[],
+): UseParticleResult {
+ const [particle, setParticle] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ const path = useMemo(
+ () => firestorePath(networkId, segments),
+ [networkId, segments.join("/")],
+ );
+
+ useEffect(() => {
+ setIsLoading(true);
+ setError(null);
+ setParticle(null);
+
+ const unsubscribe = subscribeToParticle(
+ path,
+ (data) => {
+ setParticle(data);
+ setIsLoading(false);
+ },
+ (err) => {
+ setError(err);
+ setIsLoading(false);
+ },
+ );
+
+ return unsubscribe;
+ }, [path]);
+
+ return { particle, isLoading, error };
+}
diff --git a/js/src/lib/firestore-particles.ts b/js/src/lib/firestore-particles.ts
new file mode 100644
index 0000000..961860b
--- /dev/null
+++ b/js/src/lib/firestore-particles.ts
@@ -0,0 +1,128 @@
+import {
+ collection,
+ doc,
+ onSnapshot,
+ addDoc,
+ updateDoc,
+ query,
+ orderBy,
+ serverTimestamp,
+ Timestamp,
+ type DocumentData,
+ type FirestoreDataConverter,
+ type QueryDocumentSnapshot,
+ type SnapshotOptions,
+ type Unsubscribe,
+} from "firebase/firestore";
+import { firestoreDb } from "@/firebase";
+import { ParticleSchema } from "@/api/types";
+import type { Particle, ParticleType, ParticlePropertiesMap } from "@/api/types";
+
+// --- Converter ---
+
+const particleConverter: FirestoreDataConverter = {
+ toFirestore(particle: Particle): DocumentData {
+ const { id: _id, created_at, updated_at, ...rest } = particle;
+ return {
+ ...rest,
+ created_at: Timestamp.fromDate(created_at),
+ ...(updated_at && { updated_at: Timestamp.fromDate(updated_at) }),
+ };
+ },
+ fromFirestore(
+ snap: QueryDocumentSnapshot,
+ options?: SnapshotOptions,
+ ): Particle {
+ const raw = snap.data(options);
+ return ParticleSchema.parse({
+ id: snap.id,
+ type: raw.type,
+ properties: raw.properties,
+ created_at: (raw.created_at as Timestamp).toDate(),
+ created_by_email: raw.created_by_email,
+ updated_at: raw.updated_at ? (raw.updated_at as Timestamp).toDate() : undefined,
+ visible_to: raw.visible_to,
+ });
+ },
+};
+
+// --- Typed reference helpers ---
+
+function typedDoc(path: string) {
+ return doc(firestoreDb, path).withConverter(particleConverter);
+}
+
+function typedCollection(path: string) {
+ return collection(firestoreDb, path).withConverter(particleConverter);
+}
+
+// --- Exported operations ---
+
+export function subscribeToParticle(
+ docPath: string,
+ onData: (particle: Particle | null) => void,
+ onError: (error: Error) => void,
+): Unsubscribe {
+ return onSnapshot(
+ typedDoc(docPath),
+ (snap) => {
+ onData(snap.exists() ? snap.data() : null);
+ },
+ onError,
+ );
+}
+
+export function subscribeToParticleChildren(
+ collectionPath: string,
+ onData: (children: Particle[]) => void,
+ onError: (error: Error) => void,
+): Unsubscribe {
+ const q = query(typedCollection(collectionPath), orderBy("created_at"));
+ return onSnapshot(
+ q,
+ (snap) => {
+ onData(snap.docs.map((d) => d.data()));
+ },
+ onError,
+ );
+}
+
+export async function createParticle(
+ collectionPath: string,
+ type: T,
+ properties: ParticlePropertiesMap[T],
+ createdByEmail: string,
+ visibleTo: string[],
+): Promise {
+ const particle: Particle = ParticleSchema.parse({
+ id: "", // ignored by toFirestore, but needed to satisfy the type
+ type,
+ properties,
+ created_at: new Date(),
+ created_by_email: createdByEmail,
+ updated_at: null,
+ visible_to: visibleTo,
+ });
+ const ref = await addDoc(typedCollection(collectionPath), particle);
+ return ref.id;
+}
+
+// This allows updating properties without overwriting the entire properties object
+export async function updateParticle(
+ docPath: string,
+ properties: Partial,
+ visibleTo?: string[],
+): Promise {
+ const particleRef = typedDoc(docPath);
+ // Take the partial and create a new object with dot notation
+ // e.g. { title: "New Title" } becomes { "properties.title": "New Title" }
+ const updatedProperties: Record = {};
+ for (const key in properties) {
+ updatedProperties[`properties.${key}`] = properties[key];
+ }
+ await updateDoc(particleRef, {
+ ...updatedProperties,
+ updated_at: serverTimestamp(),
+ ...(visibleTo ? { visible_to: visibleTo } : {}),
+ });
+}
diff --git a/js/src/lib/firestore-paths.ts b/js/src/lib/firestore-paths.ts
new file mode 100644
index 0000000..12e245c
--- /dev/null
+++ b/js/src/lib/firestore-paths.ts
@@ -0,0 +1,23 @@
+/**
+ * Map URL segments to Firestore paths.
+ *
+ * Firestore structure:
+ * networks/{networkId}/particles/{particleId}
+ * networks/{networkId}/particles/{particleId}/children/{childId}
+ * ...and so on for arbitrary depth.
+ *
+ * Examples:
+ * segments = [] → "networks/{nid}/particles"
+ * segments = ["p1"] → "networks/{nid}/particles/p1"
+ * segments = ["p1", "p2"] → "networks/{nid}/particles/p1/children/p2"
+ */
+export function firestorePath(networkId: string, segments: string[]): string {
+ const base = `networks/${networkId}/particles`;
+ if (segments.length === 0) return base;
+
+ const parts: string[] = [base, segments[0]];
+ for (let i = 1; i < segments.length; i++) {
+ parts.push("children", segments[i]);
+ }
+ return parts.join("/");
+}
diff --git a/js/src/lib/stream-utils.ts b/js/src/lib/stream-utils.ts
deleted file mode 100644
index fb95abf..0000000
--- a/js/src/lib/stream-utils.ts
+++ /dev/null
@@ -1,37 +0,0 @@
-import type { NetworkWithStreams, Stream } from "@/api/types";
-
-export interface FlatStream extends Stream {
- networkId: string;
- networkName: string;
-}
-
-export function flattenStreams(
- networks: NetworkWithStreams[],
- selectedNetworkId: string | null,
-): FlatStream[] {
- const filtered = selectedNetworkId
- ? networks.filter((n) => n.id === selectedNetworkId)
- : networks;
-
- const streams: FlatStream[] = filtered.flatMap((n) =>
- n.streams.map((s) => ({
- ...s,
- networkId: n.id,
- networkName: n.name,
- })),
- );
-
- return streams.sort((a, b) => {
- const aTime = getLatestParticleTime(a);
- const bTime = getLatestParticleTime(b);
- return bTime - aTime;
- });
-}
-
-function getLatestParticleTime(stream: Stream): number {
- if (stream.particles.length === 0) {
- return new Date(stream.updated_at).getTime() || 0;
- }
- const last = stream.particles[stream.particles.length - 1];
- return new Date(last.created_at).getTime();
-}
diff --git a/js/src/lib/utils.ts b/js/src/lib/utils.ts
index b178ee7..fd4498c 100644
--- a/js/src/lib/utils.ts
+++ b/js/src/lib/utils.ts
@@ -4,3 +4,8 @@ import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+
+export function getInitials(email: string): string {
+ const prefix = email.split("@")[0] ?? "";
+ return prefix.slice(0, 2).toUpperCase();
+}
diff --git a/js/src/pages/path-resolver.tsx b/js/src/pages/path-resolver.tsx
new file mode 100644
index 0000000..718a72e
--- /dev/null
+++ b/js/src/pages/path-resolver.tsx
@@ -0,0 +1,33 @@
+import { useLocation } from "react-router-dom";
+import { NetworkSelector } from "@/features/network-selector";
+import { ParticleListView } from "@/features/particles/particle-list-view";
+import { ParticleViewResolver } from "@/features/particles/particle-view-resolver";
+
+function parsePathSegments(path: string): string[] {
+ return path.split("/").filter(Boolean);
+}
+
+/**
+ * URL structure:
+ * / → network selector
+ * /:networkId → root particles for that network
+ * /:networkId/:p1/:p2/... → nested particle view (renders the parent which will use it's children)
+ */
+export default function PathResolver() {
+ const segments = parsePathSegments(useLocation().pathname);
+
+ // No segments → show network selector
+ if (segments.length === 0) {
+ return ;
+ }
+
+ const [networkId, ...particleSegments] = segments;
+
+ // /:networkId with no particle segments → root particle list
+ if (particleSegments.length === 0) {
+ return ;
+ }
+
+ // /:networkId/:p1/:p2/... → resolve and render the container particle
+ return ;
+}
diff --git a/js/src/pages/streams-page.tsx b/js/src/pages/streams-page.tsx
deleted file mode 100644
index 6b5dd23..0000000
--- a/js/src/pages/streams-page.tsx
+++ /dev/null
@@ -1,83 +0,0 @@
-import { Plus, LogOut } from "lucide-react";
-import { Button } from "@/components/ui/button";
-import { ScrollArea } from "@/components/ui/scroll-area";
-import { Muted } from "@/components/ui/typography";
-import { Progress } from "@/components/ui/progress";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select";
-import { useAppStore } from "@/stores/app-store";
-import { useAuthStore } from "@/stores/auth-store";
-import { StreamList } from "@/features/streams/stream-list";
-import { CreateStreamDialog } from "@/features/streams/create-stream-dialog";
-import { WindowControls } from "@/components/window-controls";
-
-export function StreamsPage() {
- const networks = useAppStore((s) => s.networks);
- const selectedNetworkId = useAppStore((s) => s.selectedNetworkId);
- const setSelectedNetwork = useAppStore((s) => s.setSelectedNetwork);
- const signOut = useAuthStore((s) => s.signOut);
- const user = useAuthStore((s) => s.user);
-
- const selectedNetwork = selectedNetworkId
- ? networks.find((n) => n.id === selectedNetworkId)
- : null;
-
- return (
-
- {/* Top bar — draggable for frameless window */}
-
-
-
-
setSelectedNetwork(value)}
- >
-
-
-
-
- {networks.map((network) => (
-
- {network.name}
-
- ))}
-
-
-
-
-
- {selectedNetworkId && (
-
-
-
- New Stream
-
-
- )}
-
- {user && (
-
{user.email_prefix}
- )}
-
-
-
-
-
-
- {/* Stream list */}
-
-
-
-
- );
-}
diff --git a/js/src/stores/app-store.ts b/js/src/stores/app-store.ts
index f0b3034..1c7e56a 100644
--- a/js/src/stores/app-store.ts
+++ b/js/src/stores/app-store.ts
@@ -1,107 +1,15 @@
import { create } from "zustand";
-import { apiClient } from "@/api/client";
-import type {
- AckInfo,
- NetworkWithStreams,
- Stream,
- StreamParticle,
-} from "@/api/types";
+/**
+ * Minimal app-level store. Navigation state is now URL-driven via PathResolver.
+ * Stream/particle state will move to Firestore hooks.
+ */
interface AppState {
- networks: NetworkWithStreams[];
selectedNetworkId: string | null;
- isLoading: boolean;
-
- fetchStartup: () => Promise;
setSelectedNetwork: (id: string | null) => void;
- addStream: (networkId: string, stream: Stream) => void;
- addParticleToStream: (streamId: string, particle: StreamParticle) => void;
- markParticlesSeen: (particleIds: string[]) => void;
- ackParticle: (particleId: string, email: string) => void;
}
-export const useAppStore = create((set, get) => ({
- networks: [],
+export const useAppStore = create((set) => ({
selectedNetworkId: null,
- isLoading: false,
-
- fetchStartup: async () => {
- set({ isLoading: true });
- try {
- const data = await apiClient.startup();
- const state = get();
- const shouldAutoSelect =
- !state.selectedNetworkId && data.networks.length > 0;
- set({
- networks: data.networks,
- ...(shouldAutoSelect
- ? { selectedNetworkId: data.networks[0].id }
- : {}),
- });
- } finally {
- set({ isLoading: false });
- }
- },
-
- setSelectedNetwork: (id) => {
- set({ selectedNetworkId: id });
- },
-
- addStream: (networkId, stream) => {
- set({
- networks: get().networks.map((n) =>
- n.id === networkId ? { ...n, streams: [stream, ...n.streams] } : n,
- ),
- });
- },
-
- addParticleToStream: (streamId, particle) => {
- set({
- networks: get().networks.map((n) => ({
- ...n,
- streams: n.streams.map((s) =>
- s.id === streamId
- ? { ...s, particles: [...s.particles, particle] }
- : s,
- ),
- })),
- });
- },
-
- ackParticle: (particleId, email) => {
- const ack: AckInfo = { email, acked_at: new Date().toISOString() };
- set({
- networks: get().networks.map((n) => ({
- ...n,
- streams: n.streams.map((s) => ({
- ...s,
- particles: s.particles.map((p) =>
- p.id === particleId ? { ...p, acks: [...p.acks, ack] } : p,
- ),
- })),
- })),
- });
- },
-
- markParticlesSeen: (particleIds) => {
- const idSet = new Set(particleIds);
- set({
- networks: get().networks.map((n) => ({
- ...n,
- streams: n.streams.map((s) => {
- const unseenMarked = s.particles.filter(
- (p) => !p.seen && idSet.has(p.id),
- ).length;
- if (unseenMarked === 0) return s;
- return {
- ...s,
- unseen_count: Math.max(0, s.unseen_count - unseenMarked),
- particles: s.particles.map((p) =>
- idSet.has(p.id) ? { ...p, seen: true } : p,
- ),
- };
- }),
- })),
- });
- },
+ setSelectedNetwork: (id) => set({ selectedNetworkId: id }),
}));
diff --git a/js/yarn.lock b/js/yarn.lock
index 219f197..40f0e72 100644
--- a/js/yarn.lock
+++ b/js/yarn.lock
@@ -814,7 +814,7 @@
resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz#acad351d582d157bb145535db2a6ff53dd514b5c"
integrity sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==
-"@eslint-community/eslint-utils@^4.2.0":
+"@eslint-community/eslint-utils@^4.2.0", "@eslint-community/eslint-utils@^4.9.1":
version "4.9.1"
resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz#4e90af67bc51ddee6cdef5284edf572ec376b595"
integrity sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==
@@ -846,6 +846,397 @@
resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.57.1.tgz#de633db3ec2ef6a3c89e2f19038063e8a122e2c2"
integrity sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==
+"@firebase/ai@2.9.0":
+ version "2.9.0"
+ resolved "https://registry.yarnpkg.com/@firebase/ai/-/ai-2.9.0.tgz#9e6f3546eb688e31488f3e081702773300d609f1"
+ integrity sha512-NPvBBuvdGo9x3esnABAucFYmqbBmXvyTMimBq2PCuLZbdANZoHzGlx7vfzbwNDaEtCBq4RGGNMliLIv6bZ+PtA==
+ dependencies:
+ "@firebase/app-check-interop-types" "0.3.3"
+ "@firebase/component" "0.7.1"
+ "@firebase/logger" "0.5.0"
+ "@firebase/util" "1.14.0"
+ tslib "^2.1.0"
+
+"@firebase/analytics-compat@0.2.26":
+ version "0.2.26"
+ resolved "https://registry.yarnpkg.com/@firebase/analytics-compat/-/analytics-compat-0.2.26.tgz#2ec74dc4d41d075d38fab7670c33464803214f2f"
+ integrity sha512-0j2ruLOoVSwwcXAF53AMoniJKnkwiTjGVfic5LDzqiRkR13vb5j6TXMeix787zbLeQtN/m1883Yv1TxI0gItbA==
+ dependencies:
+ "@firebase/analytics" "0.10.20"
+ "@firebase/analytics-types" "0.8.3"
+ "@firebase/component" "0.7.1"
+ "@firebase/util" "1.14.0"
+ tslib "^2.1.0"
+
+"@firebase/analytics-types@0.8.3":
+ version "0.8.3"
+ resolved "https://registry.yarnpkg.com/@firebase/analytics-types/-/analytics-types-0.8.3.tgz#d08cd39a6209693ca2039ba7a81570dfa6c1518f"
+ integrity sha512-VrIp/d8iq2g501qO46uGz3hjbDb8xzYMrbu8Tp0ovzIzrvJZ2fvmj649gTjge/b7cCCcjT0H37g1gVtlNhnkbg==
+
+"@firebase/analytics@0.10.20":
+ version "0.10.20"
+ resolved "https://registry.yarnpkg.com/@firebase/analytics/-/analytics-0.10.20.tgz#ec3aaacaa157b979b6e2c12ac5a30e6484b19ddf"
+ integrity sha512-adGTNVUWH5q66tI/OQuKLSN6mamPpfYhj0radlH2xt+3eL6NFPtXoOs+ulvs+UsmK27vNFx5FjRDfWk+TyduHg==
+ dependencies:
+ "@firebase/component" "0.7.1"
+ "@firebase/installations" "0.6.20"
+ "@firebase/logger" "0.5.0"
+ "@firebase/util" "1.14.0"
+ tslib "^2.1.0"
+
+"@firebase/app-check-compat@0.4.1":
+ version "0.4.1"
+ resolved "https://registry.yarnpkg.com/@firebase/app-check-compat/-/app-check-compat-0.4.1.tgz#2ff3f4b28fd4ee136e7ee12b99edac8cdc8cbbb1"
+ integrity sha512-yjSvSl5B1u4CirnxhzirN1uiTRCRfx+/qtfbyeyI+8Cx8Cw1RWAIO/OqytPSVwLYbJJ1vEC3EHfxazRaMoWKaA==
+ dependencies:
+ "@firebase/app-check" "0.11.1"
+ "@firebase/app-check-types" "0.5.3"
+ "@firebase/component" "0.7.1"
+ "@firebase/logger" "0.5.0"
+ "@firebase/util" "1.14.0"
+ tslib "^2.1.0"
+
+"@firebase/app-check-interop-types@0.3.3":
+ version "0.3.3"
+ resolved "https://registry.yarnpkg.com/@firebase/app-check-interop-types/-/app-check-interop-types-0.3.3.tgz#ed9c4a4f48d1395ef378f007476db3940aa5351a"
+ integrity sha512-gAlxfPLT2j8bTI/qfe3ahl2I2YcBQ8cFIBdhAQA4I2f3TndcO+22YizyGYuttLHPQEpWkhmpFW60VCFEPg4g5A==
+
+"@firebase/app-check-types@0.5.3":
+ version "0.5.3"
+ resolved "https://registry.yarnpkg.com/@firebase/app-check-types/-/app-check-types-0.5.3.tgz#38ba954acf4bffe451581a32fffa20337f11d8e5"
+ integrity sha512-hyl5rKSj0QmwPdsAxrI5x1otDlByQ7bvNvVt8G/XPO2CSwE++rmSVf3VEhaeOR4J8ZFaF0Z0NDSmLejPweZ3ng==
+
+"@firebase/app-check@0.11.1":
+ version "0.11.1"
+ resolved "https://registry.yarnpkg.com/@firebase/app-check/-/app-check-0.11.1.tgz#f327a2190b405eb566a93cd5c7eb8ebe7556032b"
+ integrity sha512-gmKfwQ2k8aUQlOyRshc+fOQLq0OwUmibIZvpuY1RDNu2ho0aTMlwxOuEiJeYOs7AxzhSx7gnXPFNsXCFbnvXUQ==
+ dependencies:
+ "@firebase/component" "0.7.1"
+ "@firebase/logger" "0.5.0"
+ "@firebase/util" "1.14.0"
+ tslib "^2.1.0"
+
+"@firebase/app-compat@0.5.9":
+ version "0.5.9"
+ resolved "https://registry.yarnpkg.com/@firebase/app-compat/-/app-compat-0.5.9.tgz#464efce323951283c6812893d251dddee15d61da"
+ integrity sha512-e5LzqjO69/N2z7XcJeuMzIp4wWnW696dQeaHAUpQvGk89gIWHAIvG6W+mA3UotGW6jBoqdppEJ9DnuwbcBByug==
+ dependencies:
+ "@firebase/app" "0.14.9"
+ "@firebase/component" "0.7.1"
+ "@firebase/logger" "0.5.0"
+ "@firebase/util" "1.14.0"
+ tslib "^2.1.0"
+
+"@firebase/app-types@0.9.3":
+ version "0.9.3"
+ resolved "https://registry.yarnpkg.com/@firebase/app-types/-/app-types-0.9.3.tgz#8408219eae9b1fb74f86c24e7150a148460414ad"
+ integrity sha512-kRVpIl4vVGJ4baogMDINbyrIOtOxqhkZQg4jTq3l8Lw6WSk0xfpEYzezFu+Kl4ve4fbPl79dvwRtaFqAC/ucCw==
+
+"@firebase/app@0.14.9":
+ version "0.14.9"
+ resolved "https://registry.yarnpkg.com/@firebase/app/-/app-0.14.9.tgz#b7f740904deee2889a3d6115736b16fdbdc853c7"
+ integrity sha512-3gtUX0e584MYkKBQMgSECMvE1Dwzg+eONefDQ0wxVSe5YMBsZwdN5pL7UapwWBlV8+i8QCztF9TP947tEjZAGA==
+ dependencies:
+ "@firebase/component" "0.7.1"
+ "@firebase/logger" "0.5.0"
+ "@firebase/util" "1.14.0"
+ idb "7.1.1"
+ tslib "^2.1.0"
+
+"@firebase/auth-compat@0.6.3":
+ version "0.6.3"
+ resolved "https://registry.yarnpkg.com/@firebase/auth-compat/-/auth-compat-0.6.3.tgz#8e085d98bd133081e7e7d37b7fb421b876694847"
+ integrity sha512-nHOkupcYuGVxI1AJJ/OBhLPaRokbP14Gq4nkkoVvf1yvuREEWqdnrYB/CdsSnPxHMAnn5wJIKngxBF9jNX7s/Q==
+ dependencies:
+ "@firebase/auth" "1.12.1"
+ "@firebase/auth-types" "0.13.0"
+ "@firebase/component" "0.7.1"
+ "@firebase/util" "1.14.0"
+ tslib "^2.1.0"
+
+"@firebase/auth-interop-types@0.2.4":
+ version "0.2.4"
+ resolved "https://registry.yarnpkg.com/@firebase/auth-interop-types/-/auth-interop-types-0.2.4.tgz#176a08686b0685596ff03d7879b7e4115af53de0"
+ integrity sha512-JPgcXKCuO+CWqGDnigBtvo09HeBs5u/Ktc2GaFj2m01hLarbxthLNm7Fk8iOP1aqAtXV+fnnGj7U28xmk7IwVA==
+
+"@firebase/auth-types@0.13.0":
+ version "0.13.0"
+ resolved "https://registry.yarnpkg.com/@firebase/auth-types/-/auth-types-0.13.0.tgz#ae6e0015e3bd4bfe18edd0942b48a0a118a098d9"
+ integrity sha512-S/PuIjni0AQRLF+l9ck0YpsMOdE8GO2KU6ubmBB7P+7TJUCQDa3R1dlgYm9UzGbbePMZsp0xzB93f2b/CgxMOg==
+
+"@firebase/auth@1.12.1":
+ version "1.12.1"
+ resolved "https://registry.yarnpkg.com/@firebase/auth/-/auth-1.12.1.tgz#5eb1c3bf99dfbe7025578a5f1439cc073a4183f0"
+ integrity sha512-nXKj7d5bMBlnq6XpcQQpmnSVwEeHBkoVbY/+Wk0P1ebLSICoH4XPtvKOFlXKfIHmcS84mLQ99fk3njlDGKSDtw==
+ dependencies:
+ "@firebase/component" "0.7.1"
+ "@firebase/logger" "0.5.0"
+ "@firebase/util" "1.14.0"
+ tslib "^2.1.0"
+
+"@firebase/component@0.7.1":
+ version "0.7.1"
+ resolved "https://registry.yarnpkg.com/@firebase/component/-/component-0.7.1.tgz#f16376146d77034ac5055834de25405e6c011491"
+ integrity sha512-mFzsm7CLHR60o08S23iLUY8m/i6kLpOK87wdEFPLhdlCahaxKmWOwSVGiWoENYSmFJJoDhrR3gKSCxz7ENdIww==
+ dependencies:
+ "@firebase/util" "1.14.0"
+ tslib "^2.1.0"
+
+"@firebase/data-connect@0.4.0":
+ version "0.4.0"
+ resolved "https://registry.yarnpkg.com/@firebase/data-connect/-/data-connect-0.4.0.tgz#957d2e0ee602d7120b4c5dbcb8494f911b8a2e47"
+ integrity sha512-vLXM6WHNIR3VtEeYNUb/5GTsUOyl3Of4iWNZHBe1i9f88sYFnxybJNWVBjvJ7flhCyF8UdxGpzWcUnv6F5vGfg==
+ dependencies:
+ "@firebase/auth-interop-types" "0.2.4"
+ "@firebase/component" "0.7.1"
+ "@firebase/logger" "0.5.0"
+ "@firebase/util" "1.14.0"
+ tslib "^2.1.0"
+
+"@firebase/database-compat@2.1.1":
+ version "2.1.1"
+ resolved "https://registry.yarnpkg.com/@firebase/database-compat/-/database-compat-2.1.1.tgz#8ab656d2f6b53d1645b86fa846295db4734b9ac5"
+ integrity sha512-heAEVZ9Z8c8PnBUcmGh91JHX0cXcVa1yESW/xkLuwaX7idRFyLiN8sl73KXpR8ZArGoPXVQDanBnk6SQiekRCQ==
+ dependencies:
+ "@firebase/component" "0.7.1"
+ "@firebase/database" "1.1.1"
+ "@firebase/database-types" "1.0.17"
+ "@firebase/logger" "0.5.0"
+ "@firebase/util" "1.14.0"
+ tslib "^2.1.0"
+
+"@firebase/database-types@1.0.17":
+ version "1.0.17"
+ resolved "https://registry.yarnpkg.com/@firebase/database-types/-/database-types-1.0.17.tgz#6b7a14d81655e9ee5e87c26dc853c24d9737e4fe"
+ integrity sha512-4eWaM5fW3qEIHjGzfi3cf0Jpqi1xQsAdT6rSDE1RZPrWu8oGjgrq6ybMjobtyHQFgwGCykBm4YM89qDzc+uG/w==
+ dependencies:
+ "@firebase/app-types" "0.9.3"
+ "@firebase/util" "1.14.0"
+
+"@firebase/database@1.1.1":
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/@firebase/database/-/database-1.1.1.tgz#591610b5087ffc25cc56486ad03749b09c887759"
+ integrity sha512-LwIXe8+mVHY5LBPulWECOOIEXDiatyECp/BOlu0gOhe+WOcKjWHROaCbLlkFTgHMY7RHr5MOxkLP/tltWAH3dA==
+ dependencies:
+ "@firebase/app-check-interop-types" "0.3.3"
+ "@firebase/auth-interop-types" "0.2.4"
+ "@firebase/component" "0.7.1"
+ "@firebase/logger" "0.5.0"
+ "@firebase/util" "1.14.0"
+ faye-websocket "0.11.4"
+ tslib "^2.1.0"
+
+"@firebase/firestore-compat@0.4.6":
+ version "0.4.6"
+ resolved "https://registry.yarnpkg.com/@firebase/firestore-compat/-/firestore-compat-0.4.6.tgz#30a20be30a72e80b0cfa32d5d693564daff6911a"
+ integrity sha512-NgVyR4hHHN2FvSNQOtbgBOuVsEdD/in30d9FKbEvvITiAChrBN2nBstmhfjI4EOTnHaP8zigwvkNYFI9yKGAkQ==
+ dependencies:
+ "@firebase/component" "0.7.1"
+ "@firebase/firestore" "4.12.0"
+ "@firebase/firestore-types" "3.0.3"
+ "@firebase/util" "1.14.0"
+ tslib "^2.1.0"
+
+"@firebase/firestore-types@3.0.3":
+ version "3.0.3"
+ resolved "https://registry.yarnpkg.com/@firebase/firestore-types/-/firestore-types-3.0.3.tgz#7d0c3dd8850c0193d8f5ee0cc8f11961407742c1"
+ integrity sha512-hD2jGdiWRxB/eZWF89xcK9gF8wvENDJkzpVFb4aGkzfEaKxVRD1kjz1t1Wj8VZEp2LCB53Yx1zD8mrhQu87R6Q==
+
+"@firebase/firestore@4.12.0":
+ version "4.12.0"
+ resolved "https://registry.yarnpkg.com/@firebase/firestore/-/firestore-4.12.0.tgz#3321155f66d70c749924c635bb1f0deb92254df3"
+ integrity sha512-PM47OyiiAAoAMB8kkq4Je14mTciaRoAPDd3ng3Ckqz9i2TX9D9LfxIRcNzP/OxzNV4uBKRq6lXoOggkJBQR3Gw==
+ dependencies:
+ "@firebase/component" "0.7.1"
+ "@firebase/logger" "0.5.0"
+ "@firebase/util" "1.14.0"
+ "@firebase/webchannel-wrapper" "1.0.5"
+ "@grpc/grpc-js" "~1.9.0"
+ "@grpc/proto-loader" "^0.7.8"
+ tslib "^2.1.0"
+
+"@firebase/functions-compat@0.4.2":
+ version "0.4.2"
+ resolved "https://registry.yarnpkg.com/@firebase/functions-compat/-/functions-compat-0.4.2.tgz#5788b9d33a700164eefd0b4e455de87cd62d635c"
+ integrity sha512-YNxgnezvZDkqxqXa6cT7/oTeD4WXbxgIP7qZp4LFnathQv5o2omM6EoIhXiT9Ie5AoQDcIhG9Y3/dj+DFJGaGQ==
+ dependencies:
+ "@firebase/component" "0.7.1"
+ "@firebase/functions" "0.13.2"
+ "@firebase/functions-types" "0.6.3"
+ "@firebase/util" "1.14.0"
+ tslib "^2.1.0"
+
+"@firebase/functions-types@0.6.3":
+ version "0.6.3"
+ resolved "https://registry.yarnpkg.com/@firebase/functions-types/-/functions-types-0.6.3.tgz#f5faf770248b13f45d256f614230da6a11bfb654"
+ integrity sha512-EZoDKQLUHFKNx6VLipQwrSMh01A1SaL3Wg6Hpi//x6/fJ6Ee4hrAeswK99I5Ht8roiniKHw4iO0B1Oxj5I4plg==
+
+"@firebase/functions@0.13.2":
+ version "0.13.2"
+ resolved "https://registry.yarnpkg.com/@firebase/functions/-/functions-0.13.2.tgz#2e7936898afcdfa391e564e39049e0e908282420"
+ integrity sha512-tHduUD+DeokM3NB1QbHCvEMoL16e8Z8JSkmuVA4ROoJKPxHn8ibnecHPO2e3nVCJR1D9OjuKvxz4gksfq92/ZQ==
+ dependencies:
+ "@firebase/app-check-interop-types" "0.3.3"
+ "@firebase/auth-interop-types" "0.2.4"
+ "@firebase/component" "0.7.1"
+ "@firebase/messaging-interop-types" "0.2.3"
+ "@firebase/util" "1.14.0"
+ tslib "^2.1.0"
+
+"@firebase/installations-compat@0.2.20":
+ version "0.2.20"
+ resolved "https://registry.yarnpkg.com/@firebase/installations-compat/-/installations-compat-0.2.20.tgz#f17bcd7623f1283937ac3192c3293dd68037fcdc"
+ integrity sha512-9C9pL/DIEGucmoPj8PlZTnztbX3nhNj5RTYVpUM7wQq/UlHywaYv99969JU/WHLvi9ptzIogXYS9d1eZ6XFe9g==
+ dependencies:
+ "@firebase/component" "0.7.1"
+ "@firebase/installations" "0.6.20"
+ "@firebase/installations-types" "0.5.3"
+ "@firebase/util" "1.14.0"
+ tslib "^2.1.0"
+
+"@firebase/installations-types@0.5.3":
+ version "0.5.3"
+ resolved "https://registry.yarnpkg.com/@firebase/installations-types/-/installations-types-0.5.3.tgz#cac8a14dd49f09174da9df8ae453f9b359c3ef2f"
+ integrity sha512-2FJI7gkLqIE0iYsNQ1P751lO3hER+Umykel+TkLwHj6plzWVxqvfclPUZhcKFVQObqloEBTmpi2Ozn7EkCABAA==
+
+"@firebase/installations@0.6.20":
+ version "0.6.20"
+ resolved "https://registry.yarnpkg.com/@firebase/installations/-/installations-0.6.20.tgz#a019da0e71d5a0bb59b58e43a8edef0153368b94"
+ integrity sha512-LOzvR7XHPbhS0YB5ANXhqXB5qZlntPpwU/4KFwhSNpXNsGk/sBQ9g5hepi0y0/MfenJLe2v7t644iGOOElQaHQ==
+ dependencies:
+ "@firebase/component" "0.7.1"
+ "@firebase/util" "1.14.0"
+ idb "7.1.1"
+ tslib "^2.1.0"
+
+"@firebase/logger@0.5.0":
+ version "0.5.0"
+ resolved "https://registry.yarnpkg.com/@firebase/logger/-/logger-0.5.0.tgz#a9e55b1c669a0983dc67127fa4a5964ce8ed5e1b"
+ integrity sha512-cGskaAvkrnh42b3BA3doDWeBmuHFO/Mx5A83rbRDYakPjO9bJtRL3dX7javzc2Rr/JHZf4HlterTW2lUkfeN4g==
+ dependencies:
+ tslib "^2.1.0"
+
+"@firebase/messaging-compat@0.2.24":
+ version "0.2.24"
+ resolved "https://registry.yarnpkg.com/@firebase/messaging-compat/-/messaging-compat-0.2.24.tgz#9ea9bf0d88d605c382dd416e231203310da7b867"
+ integrity sha512-wXH8FrKbJvFuFe6v98TBhAtvgknxKIZtGM/wCVsfpOGmaAE80bD8tBxztl+uochjnFb9plihkd6mC4y7sZXSpA==
+ dependencies:
+ "@firebase/component" "0.7.1"
+ "@firebase/messaging" "0.12.24"
+ "@firebase/util" "1.14.0"
+ tslib "^2.1.0"
+
+"@firebase/messaging-interop-types@0.2.3":
+ version "0.2.3"
+ resolved "https://registry.yarnpkg.com/@firebase/messaging-interop-types/-/messaging-interop-types-0.2.3.tgz#e647c9cd1beecfe6a6e82018a6eec37555e4da3e"
+ integrity sha512-xfzFaJpzcmtDjycpDeCUj0Ge10ATFi/VHVIvEEjDNc3hodVBQADZ7BWQU7CuFpjSHE+eLuBI13z5F/9xOoGX8Q==
+
+"@firebase/messaging@0.12.24":
+ version "0.12.24"
+ resolved "https://registry.yarnpkg.com/@firebase/messaging/-/messaging-0.12.24.tgz#ac586f68a038d8595ee8cbaea2a4b60e1886029a"
+ integrity sha512-UtKoubegAhHyehcB7iQjvQ8OVITThPbbWk3g2/2ze42PrQr6oe6OmCElYQkBrE5RDCeMTNucXejbdulrQ2XwVg==
+ dependencies:
+ "@firebase/component" "0.7.1"
+ "@firebase/installations" "0.6.20"
+ "@firebase/messaging-interop-types" "0.2.3"
+ "@firebase/util" "1.14.0"
+ idb "7.1.1"
+ tslib "^2.1.0"
+
+"@firebase/performance-compat@0.2.23":
+ version "0.2.23"
+ resolved "https://registry.yarnpkg.com/@firebase/performance-compat/-/performance-compat-0.2.23.tgz#e4e440878c5be1e11e01d5fe28e5e1fe73d36857"
+ integrity sha512-c7qOAGBUAOpIuUlHu1axWcrCVtIYKPMhH0lMnoCDWnPwn1HcPuPUBVTWETbC7UWw71RMJF8DpirfWXzMWJQfgA==
+ dependencies:
+ "@firebase/component" "0.7.1"
+ "@firebase/logger" "0.5.0"
+ "@firebase/performance" "0.7.10"
+ "@firebase/performance-types" "0.2.3"
+ "@firebase/util" "1.14.0"
+ tslib "^2.1.0"
+
+"@firebase/performance-types@0.2.3":
+ version "0.2.3"
+ resolved "https://registry.yarnpkg.com/@firebase/performance-types/-/performance-types-0.2.3.tgz#5ce64e90fa20ab5561f8b62a305010cf9fab86fb"
+ integrity sha512-IgkyTz6QZVPAq8GSkLYJvwSLr3LS9+V6vNPQr0x4YozZJiLF5jYixj0amDtATf1X0EtYHqoPO48a9ija8GocxQ==
+
+"@firebase/performance@0.7.10":
+ version "0.7.10"
+ resolved "https://registry.yarnpkg.com/@firebase/performance/-/performance-0.7.10.tgz#a282de63f064477a62cf0379c3374f3cc693ffa4"
+ integrity sha512-8nRFld+Ntzp5cLKzZuG9g+kBaSn8Ks9dmn87UQGNFDygbmR6ebd8WawauEXiJjMj1n70ypkvAOdE+lzeyfXtGA==
+ dependencies:
+ "@firebase/component" "0.7.1"
+ "@firebase/installations" "0.6.20"
+ "@firebase/logger" "0.5.0"
+ "@firebase/util" "1.14.0"
+ tslib "^2.1.0"
+ web-vitals "^4.2.4"
+
+"@firebase/remote-config-compat@0.2.22":
+ version "0.2.22"
+ resolved "https://registry.yarnpkg.com/@firebase/remote-config-compat/-/remote-config-compat-0.2.22.tgz#5d34d4e856c8a9010e77be5fc2dc183657ade58c"
+ integrity sha512-uW/eNKKtRBot2gnCC5mnoy5Voo2wMzZuQ7dwqqGHU176fO9zFgMwKiRzk+aaC99NLrFk1KOmr0ZVheD+zdJmjQ==
+ dependencies:
+ "@firebase/component" "0.7.1"
+ "@firebase/logger" "0.5.0"
+ "@firebase/remote-config" "0.8.1"
+ "@firebase/remote-config-types" "0.5.0"
+ "@firebase/util" "1.14.0"
+ tslib "^2.1.0"
+
+"@firebase/remote-config-types@0.5.0":
+ version "0.5.0"
+ resolved "https://registry.yarnpkg.com/@firebase/remote-config-types/-/remote-config-types-0.5.0.tgz#f0f503b32edda3384f5252f9900cd9613adbb99c"
+ integrity sha512-vI3bqLoF14L/GchtgayMiFpZJF+Ao3uR8WCde0XpYNkSokDpAKca2DxvcfeZv7lZUqkUwQPL2wD83d3vQ4vvrg==
+
+"@firebase/remote-config@0.8.1":
+ version "0.8.1"
+ resolved "https://registry.yarnpkg.com/@firebase/remote-config/-/remote-config-0.8.1.tgz#47309f3e623d358652878935ac90c880b97ef118"
+ integrity sha512-L86TReBnPiiJOWd7k9iaiE9f7rHtMpjAoYN0fH2ey2ZRzsOChHV0s5sYf1+IIUYzplzsE46pjlmAUNkRRKwHSQ==
+ dependencies:
+ "@firebase/component" "0.7.1"
+ "@firebase/installations" "0.6.20"
+ "@firebase/logger" "0.5.0"
+ "@firebase/util" "1.14.0"
+ tslib "^2.1.0"
+
+"@firebase/storage-compat@0.4.1":
+ version "0.4.1"
+ resolved "https://registry.yarnpkg.com/@firebase/storage-compat/-/storage-compat-0.4.1.tgz#94c105a416f949fd1552ced075d2df613e761faa"
+ integrity sha512-bgl3FHHfXAmBgzIK/Fps6Xyv2HiAQlSTov07CBL+RGGhrC5YIk4lruS8JVIC+UkujRdYvnf8cpQFGn2RCilJ/A==
+ dependencies:
+ "@firebase/component" "0.7.1"
+ "@firebase/storage" "0.14.1"
+ "@firebase/storage-types" "0.8.3"
+ "@firebase/util" "1.14.0"
+ tslib "^2.1.0"
+
+"@firebase/storage-types@0.8.3":
+ version "0.8.3"
+ resolved "https://registry.yarnpkg.com/@firebase/storage-types/-/storage-types-0.8.3.tgz#2531ef593a3452fc12c59117195d6485c6632d3d"
+ integrity sha512-+Muk7g9uwngTpd8xn9OdF/D48uiQ7I1Fae7ULsWPuKoCH3HU7bfFPhxtJYzyhjdniowhuDpQcfPmuNRAqZEfvg==
+
+"@firebase/storage@0.14.1":
+ version "0.14.1"
+ resolved "https://registry.yarnpkg.com/@firebase/storage/-/storage-0.14.1.tgz#2cdc6523bac9fd85bdd369c77e02a785866d4c02"
+ integrity sha512-uIpYgBBsv1vIET+5xV20XT7wwqV+H4GFp6PBzfmLUcEgguS4SWNFof56Z3uOC2lNDh0KDda1UflYq2VwD9Nefw==
+ dependencies:
+ "@firebase/component" "0.7.1"
+ "@firebase/util" "1.14.0"
+ tslib "^2.1.0"
+
+"@firebase/util@1.14.0":
+ version "1.14.0"
+ resolved "https://registry.yarnpkg.com/@firebase/util/-/util-1.14.0.tgz#e0a5998fc30a065fe5cba8bd7546ae8f095f3d3e"
+ integrity sha512-/gnejm7MKkVIXnSJGpc9L2CvvvzJvtDPeAEq5jAwgVlf/PeNxot+THx/bpD20wQ8uL5sz0xqgXy1nisOYMU+mw==
+ dependencies:
+ tslib "^2.1.0"
+
+"@firebase/webchannel-wrapper@1.0.5":
+ version "1.0.5"
+ resolved "https://registry.yarnpkg.com/@firebase/webchannel-wrapper/-/webchannel-wrapper-1.0.5.tgz#39cf5a600450cb42f1f0b507cc385459bf103b27"
+ integrity sha512-+uGNN7rkfn41HLO0vekTFhTxk61eKa8mTpRGLO0QSqlQdKvIoGAvLp3ppdVIWbTGYJWM6Kp0iN+PjMIOcnVqTw==
+
"@floating-ui/core@^1.7.4":
version "1.7.4"
resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.7.4.tgz#4a006a6e01565c0f87ba222c317b056a2cffd2f4"
@@ -878,6 +1269,24 @@
resolved "https://registry.yarnpkg.com/@gar/promisify/-/promisify-1.1.3.tgz#555193ab2e3bb3b6adc3d551c9c030d9e860daf6"
integrity sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==
+"@grpc/grpc-js@~1.9.0":
+ version "1.9.15"
+ resolved "https://registry.yarnpkg.com/@grpc/grpc-js/-/grpc-js-1.9.15.tgz#433d7ac19b1754af690ea650ab72190bd700739b"
+ integrity sha512-nqE7Hc0AzI+euzUwDAy0aY5hCp10r734gMGRdU+qOPX0XSceI2ULrcXB5U2xSc5VkWwalCj4M7GzCAygZl2KoQ==
+ dependencies:
+ "@grpc/proto-loader" "^0.7.8"
+ "@types/node" ">=12.12.47"
+
+"@grpc/proto-loader@^0.7.8":
+ version "0.7.15"
+ resolved "https://registry.yarnpkg.com/@grpc/proto-loader/-/proto-loader-0.7.15.tgz#4cdfbf35a35461fc843abe8b9e2c0770b5095e60"
+ integrity sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==
+ dependencies:
+ lodash.camelcase "^4.3.0"
+ long "^5.0.0"
+ protobufjs "^7.2.5"
+ yargs "^17.7.2"
+
"@hono/node-server@^1.19.9":
version "1.19.9"
resolved "https://registry.yarnpkg.com/@hono/node-server/-/node-server-1.19.9.tgz#8f37119b1acf283fd3f6035f3d1356fdb97a09ac"
@@ -1258,6 +1667,59 @@
resolved "https://registry.yarnpkg.com/@open-draft/until/-/until-2.1.0.tgz#0acf32f470af2ceaf47f095cdecd40d68666efda"
integrity sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==
+"@protobufjs/aspromise@^1.1.1", "@protobufjs/aspromise@^1.1.2":
+ version "1.1.2"
+ resolved "https://registry.yarnpkg.com/@protobufjs/aspromise/-/aspromise-1.1.2.tgz#9b8b0cc663d669a7d8f6f5d0893a14d348f30fbf"
+ integrity sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==
+
+"@protobufjs/base64@^1.1.2":
+ version "1.1.2"
+ resolved "https://registry.yarnpkg.com/@protobufjs/base64/-/base64-1.1.2.tgz#4c85730e59b9a1f1f349047dbf24296034bb2735"
+ integrity sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==
+
+"@protobufjs/codegen@^2.0.4":
+ version "2.0.4"
+ resolved "https://registry.yarnpkg.com/@protobufjs/codegen/-/codegen-2.0.4.tgz#7ef37f0d010fb028ad1ad59722e506d9262815cb"
+ integrity sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==
+
+"@protobufjs/eventemitter@^1.1.0":
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz#355cbc98bafad5978f9ed095f397621f1d066b70"
+ integrity sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==
+
+"@protobufjs/fetch@^1.1.0":
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/@protobufjs/fetch/-/fetch-1.1.0.tgz#ba99fb598614af65700c1619ff06d454b0d84c45"
+ integrity sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==
+ dependencies:
+ "@protobufjs/aspromise" "^1.1.1"
+ "@protobufjs/inquire" "^1.1.0"
+
+"@protobufjs/float@^1.0.2":
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/@protobufjs/float/-/float-1.0.2.tgz#5e9e1abdcb73fc0a7cb8b291df78c8cbd97b87d1"
+ integrity sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==
+
+"@protobufjs/inquire@^1.1.0":
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/@protobufjs/inquire/-/inquire-1.1.0.tgz#ff200e3e7cf2429e2dcafc1140828e8cc638f089"
+ integrity sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==
+
+"@protobufjs/path@^1.1.2":
+ version "1.1.2"
+ resolved "https://registry.yarnpkg.com/@protobufjs/path/-/path-1.1.2.tgz#6cc2b20c5c9ad6ad0dccfd21ca7673d8d7fbf68d"
+ integrity sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==
+
+"@protobufjs/pool@^1.1.0":
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/@protobufjs/pool/-/pool-1.1.0.tgz#09fd15f2d6d3abfa9b65bc366506d6ad7846ff54"
+ integrity sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==
+
+"@protobufjs/utf8@^1.1.0":
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/@protobufjs/utf8/-/utf8-1.1.0.tgz#a777360b5b39a1a2e5106f8e858f2fd2d060c570"
+ integrity sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==
+
"@radix-ui/number@1.1.1":
version "1.1.1"
resolved "https://registry.yarnpkg.com/@radix-ui/number/-/number-1.1.1.tgz#7b2c9225fbf1b126539551f5985769d0048d9090"
@@ -2187,6 +2649,25 @@
"@tailwindcss/oxide" "4.2.0"
tailwindcss "4.2.0"
+"@tanstack/eslint-plugin-query@^5.91.4":
+ version "5.91.4"
+ resolved "https://registry.yarnpkg.com/@tanstack/eslint-plugin-query/-/eslint-plugin-query-5.91.4.tgz#b12f35280379aef0787074932ad698fd9bc621cc"
+ integrity sha512-8a+GAeR7oxJ5laNyYBQ6miPK09Hi18o5Oie/jx8zioXODv/AUFLZQecKabPdpQSLmuDXEBPKFh+W5DKbWlahjQ==
+ dependencies:
+ "@typescript-eslint/utils" "^8.48.0"
+
+"@tanstack/query-core@5.90.20":
+ version "5.90.20"
+ resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.90.20.tgz#e12128e39210715d4ce4fb299c33498ac297771e"
+ integrity sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==
+
+"@tanstack/react-query@^5.90.21":
+ version "5.90.21"
+ resolved "https://registry.yarnpkg.com/@tanstack/react-query/-/react-query-5.90.21.tgz#e0eb40831a76510be438109435b8807ef63ab1b9"
+ integrity sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==
+ dependencies:
+ "@tanstack/query-core" "5.90.20"
+
"@tootallnate/once@2":
version "2.0.0"
resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-2.0.0.tgz#f544a148d3ab35801c1f633a7441fd87c2e484bf"
@@ -2320,6 +2801,13 @@
dependencies:
undici-types "~7.18.0"
+"@types/node@>=12.12.47", "@types/node@>=13.7.0":
+ version "25.5.0"
+ resolved "https://registry.yarnpkg.com/@types/node/-/node-25.5.0.tgz#5c99f37c443d9ccc4985866913f1ed364217da31"
+ integrity sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==
+ dependencies:
+ undici-types "~7.18.0"
+
"@types/node@^22.5.5":
version "22.19.11"
resolved "https://registry.yarnpkg.com/@types/node/-/node-22.19.11.tgz#7e1feaad24e4e36c52fa5558d5864bb4b272603e"
@@ -2406,6 +2894,15 @@
"@typescript-eslint/typescript-estree" "5.62.0"
debug "^4.3.4"
+"@typescript-eslint/project-service@8.57.1":
+ version "8.57.1"
+ resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.57.1.tgz#16af9fe16eedbd7085e4fdc29baa73715c0c55c5"
+ integrity sha512-vx1F37BRO1OftsYlmG9xay1TqnjNVlqALymwWVuYTdo18XuKxtBpCj1QlzNIEHlvlB27osvXFWptYiEWsVdYsg==
+ dependencies:
+ "@typescript-eslint/tsconfig-utils" "^8.57.1"
+ "@typescript-eslint/types" "^8.57.1"
+ debug "^4.4.3"
+
"@typescript-eslint/scope-manager@5.62.0":
version "5.62.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-5.62.0.tgz#d9457ccc6a0b8d6b37d0eb252a23022478c5460c"
@@ -2414,6 +2911,19 @@
"@typescript-eslint/types" "5.62.0"
"@typescript-eslint/visitor-keys" "5.62.0"
+"@typescript-eslint/scope-manager@8.57.1":
+ version "8.57.1"
+ resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.57.1.tgz#4524d7e7b420cb501807499684d435ae129aaf35"
+ integrity sha512-hs/QcpCwlwT2L5S+3fT6gp0PabyGk4Q0Rv2doJXA0435/OpnSR3VRgvrp8Xdoc3UAYSg9cyUjTeFXZEPg/3OKg==
+ dependencies:
+ "@typescript-eslint/types" "8.57.1"
+ "@typescript-eslint/visitor-keys" "8.57.1"
+
+"@typescript-eslint/tsconfig-utils@8.57.1", "@typescript-eslint/tsconfig-utils@^8.57.1":
+ version "8.57.1"
+ resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.1.tgz#9233443ec716882a6f9e240fd900a73f0235f3d7"
+ integrity sha512-0lgOZB8cl19fHO4eI46YUx2EceQqhgkPSuCGLlGi79L2jwYY1cxeYc1Nae8Aw1xjgW3PKVDLlr3YJ6Bxx8HkWg==
+
"@typescript-eslint/type-utils@5.62.0":
version "5.62.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-5.62.0.tgz#286f0389c41681376cdad96b309cedd17d70346a"
@@ -2429,6 +2939,11 @@
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.62.0.tgz#258607e60effa309f067608931c3df6fed41fd2f"
integrity sha512-87NVngcbVXUahrRTqIK27gD2t5Cu1yuCXxbLcFtCzZGlfyVWWh8mLHkoxzjsB6DDNnvdL+fW8MiwPEJyGJQDgQ==
+"@typescript-eslint/types@8.57.1", "@typescript-eslint/types@^8.57.1":
+ version "8.57.1"
+ resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.57.1.tgz#54b27a8a25a7b45b4f978c3f8e00c4c78f11142c"
+ integrity sha512-S29BOBPJSFUiblEl6RzPPjJt6w25A6XsBqRVDt53tA/tlL8q7ceQNZHTjPeONt/3S7KRI4quk+yP9jK2WjBiPQ==
+
"@typescript-eslint/typescript-estree@5.62.0":
version "5.62.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.62.0.tgz#7d17794b77fabcac615d6a48fb143330d962eb9b"
@@ -2442,6 +2957,21 @@
semver "^7.3.7"
tsutils "^3.21.0"
+"@typescript-eslint/typescript-estree@8.57.1":
+ version "8.57.1"
+ resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.1.tgz#a9fd28d4a0ec896aa9a9a7e0cead62ea24f99e76"
+ integrity sha512-ybe2hS9G6pXpqGtPli9Gx9quNV0TWLOmh58ADlmZe9DguLq0tiAKVjirSbtM1szG6+QH6rVXyU6GTLQbWnMY+g==
+ dependencies:
+ "@typescript-eslint/project-service" "8.57.1"
+ "@typescript-eslint/tsconfig-utils" "8.57.1"
+ "@typescript-eslint/types" "8.57.1"
+ "@typescript-eslint/visitor-keys" "8.57.1"
+ debug "^4.4.3"
+ minimatch "^10.2.2"
+ semver "^7.7.3"
+ tinyglobby "^0.2.15"
+ ts-api-utils "^2.4.0"
+
"@typescript-eslint/utils@5.62.0":
version "5.62.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.62.0.tgz#141e809c71636e4a75daa39faed2fb5f4b10df86"
@@ -2456,6 +2986,16 @@
eslint-scope "^5.1.1"
semver "^7.3.7"
+"@typescript-eslint/utils@^8.48.0":
+ version "8.57.1"
+ resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.57.1.tgz#e40f5a7fcff02fd24092a7b52bd6ec029fb50465"
+ integrity sha512-XUNSJ/lEVFttPMMoDVA2r2bwrl8/oPx8cURtczkSEswY5T3AeLmCy+EKWQNdL4u0MmAHOjcWrqJp2cdvgjn8dQ==
+ dependencies:
+ "@eslint-community/eslint-utils" "^4.9.1"
+ "@typescript-eslint/scope-manager" "8.57.1"
+ "@typescript-eslint/types" "8.57.1"
+ "@typescript-eslint/typescript-estree" "8.57.1"
+
"@typescript-eslint/visitor-keys@5.62.0":
version "5.62.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.62.0.tgz#2174011917ce582875954ffe2f6912d5931e353e"
@@ -2464,6 +3004,14 @@
"@typescript-eslint/types" "5.62.0"
eslint-visitor-keys "^3.3.0"
+"@typescript-eslint/visitor-keys@8.57.1":
+ version "8.57.1"
+ resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.1.tgz#3af4f88118924d3be983d4b8ae84803f11fe4563"
+ integrity sha512-YWnmJkXbofiz9KbnbbwuA2rpGkFPLbAIetcCNO6mJ8gdhdZ/v7WDXsoGFAJuM6ikUFKTlSQnjWnVO4ux+UzS6A==
+ dependencies:
+ "@typescript-eslint/types" "8.57.1"
+ eslint-visitor-keys "^5.0.0"
+
"@ungap/structured-clone@^1.2.0":
version "1.3.0"
resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.3.0.tgz#d06bbb384ebcf6c505fde1c3d0ed4ddffe0aaff8"
@@ -3906,6 +4454,11 @@ eslint-visitor-keys@^3.3.0, eslint-visitor-keys@^3.4.1, eslint-visitor-keys@^3.4
resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz#0cd72fe8550e3c2eae156a96a4dddcd1c8ac5800"
integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==
+eslint-visitor-keys@^5.0.0:
+ version "5.0.1"
+ resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz#9e3c9489697824d2d4ce3a8ad12628f91e9f59be"
+ integrity sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==
+
eslint@^8.57.1:
version "8.57.1"
resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.57.1.tgz#7df109654aba7e3bbe5c8eae533c5e461d3c6ca9"
@@ -4170,6 +4723,13 @@ fastq@^1.6.0:
dependencies:
reusify "^1.0.4"
+faye-websocket@0.11.4:
+ version "0.11.4"
+ resolved "https://registry.yarnpkg.com/faye-websocket/-/faye-websocket-0.11.4.tgz#7f0d9275cfdd86a1c963dc8b65fcc451edcbb1da"
+ integrity sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==
+ dependencies:
+ websocket-driver ">=0.5.1"
+
fd-slicer@~1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/fd-slicer/-/fd-slicer-1.1.0.tgz#25c7c89cb1f9077f8891bbe61d8f390eae256f1e"
@@ -4177,7 +4737,7 @@ fd-slicer@~1.1.0:
dependencies:
pend "~1.2.0"
-fdir@^6.2.0:
+fdir@^6.2.0, fdir@^6.5.0:
version "6.5.0"
resolved "https://registry.yarnpkg.com/fdir/-/fdir-6.5.0.tgz#ed2ab967a331ade62f18d077dae192684d50d350"
integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==
@@ -4252,6 +4812,40 @@ find-up@^5.0.0:
locate-path "^6.0.0"
path-exists "^4.0.0"
+firebase@^12.10.0:
+ version "12.10.0"
+ resolved "https://registry.yarnpkg.com/firebase/-/firebase-12.10.0.tgz#2c000e889e8b423ce37399b6a0497cadfba890fe"
+ integrity sha512-tAjHnEirksqWpa+NKDUSUMjulOnsTcsPC1X1rQ+gwPtjlhJS572na91CwaBXQJHXharIrfj7sw/okDkXOsphjA==
+ dependencies:
+ "@firebase/ai" "2.9.0"
+ "@firebase/analytics" "0.10.20"
+ "@firebase/analytics-compat" "0.2.26"
+ "@firebase/app" "0.14.9"
+ "@firebase/app-check" "0.11.1"
+ "@firebase/app-check-compat" "0.4.1"
+ "@firebase/app-compat" "0.5.9"
+ "@firebase/app-types" "0.9.3"
+ "@firebase/auth" "1.12.1"
+ "@firebase/auth-compat" "0.6.3"
+ "@firebase/data-connect" "0.4.0"
+ "@firebase/database" "1.1.1"
+ "@firebase/database-compat" "2.1.1"
+ "@firebase/firestore" "4.12.0"
+ "@firebase/firestore-compat" "0.4.6"
+ "@firebase/functions" "0.13.2"
+ "@firebase/functions-compat" "0.4.2"
+ "@firebase/installations" "0.6.20"
+ "@firebase/installations-compat" "0.2.20"
+ "@firebase/messaging" "0.12.24"
+ "@firebase/messaging-compat" "0.2.24"
+ "@firebase/performance" "0.7.10"
+ "@firebase/performance-compat" "0.2.23"
+ "@firebase/remote-config" "0.8.1"
+ "@firebase/remote-config-compat" "0.2.22"
+ "@firebase/storage" "0.14.1"
+ "@firebase/storage-compat" "0.4.1"
+ "@firebase/util" "1.14.0"
+
flat-cache@^3.0.4:
version "3.2.0"
resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-3.2.0.tgz#2c0c2d5040c99b1632771a9d105725c0115363ee"
@@ -4714,6 +5308,11 @@ http-errors@^2.0.0, http-errors@^2.0.1, http-errors@~2.0.1:
statuses "~2.0.2"
toidentifier "~1.0.1"
+http-parser-js@>=0.5.1:
+ version "0.5.10"
+ resolved "https://registry.yarnpkg.com/http-parser-js/-/http-parser-js-0.5.10.tgz#b3277bd6d7ed5588e20ea73bf724fcbe44609075"
+ integrity sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==
+
http-proxy-agent@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz#5129800203520d434f142bc78ff3c170800f2b43"
@@ -4785,6 +5384,11 @@ iconv-lite@^0.7.0, iconv-lite@~0.7.0:
dependencies:
safer-buffer ">= 2.1.2 < 3.0.0"
+idb@7.1.1:
+ version "7.1.1"
+ resolved "https://registry.yarnpkg.com/idb/-/idb-7.1.1.tgz#d910ded866d32c7ced9befc5bfdf36f572ced72b"
+ integrity sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==
+
ieee754@^1.1.13:
version "1.2.1"
resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352"
@@ -5432,6 +6036,11 @@ locate-path@^6.0.0:
dependencies:
p-locate "^5.0.0"
+lodash.camelcase@^4.3.0:
+ version "4.3.0"
+ resolved "https://registry.yarnpkg.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz#b28aa6288a2b9fc651035c7711f65ab6190331a6"
+ integrity sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==
+
lodash.get@^4.0.0:
version "4.4.2"
resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99"
@@ -5474,6 +6083,11 @@ log-update@^5.0.1:
strip-ansi "^7.0.1"
wrap-ansi "^8.0.1"
+long@^5.0.0:
+ version "5.3.2"
+ resolved "https://registry.yarnpkg.com/long/-/long-5.3.2.tgz#1d84463095999262d7d7b7f8bfd4a8cc55167f83"
+ integrity sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==
+
lowercase-keys@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-2.0.0.tgz#2603e78b7b4b0006cbca2fbcc8a3202558ac9479"
@@ -5632,6 +6246,13 @@ minimatch@^10.0.1:
dependencies:
brace-expansion "^5.0.2"
+minimatch@^10.2.2:
+ version "10.2.4"
+ resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.2.4.tgz#465b3accbd0218b8281f5301e27cedc697f96fde"
+ integrity sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==
+ dependencies:
+ brace-expansion "^5.0.2"
+
minimatch@^3.0.4, minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2:
version "3.1.2"
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b"
@@ -6242,7 +6863,7 @@ picomatch@^2.3.1:
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42"
integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==
-picomatch@^4.0.2:
+picomatch@^4.0.2, picomatch@^4.0.3:
version "4.0.3"
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.3.tgz#796c76136d1eead715db1e7bad785dedd695a042"
integrity sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==
@@ -6348,6 +6969,24 @@ prompts@^2.4.2:
kleur "^3.0.3"
sisteransi "^1.0.5"
+protobufjs@^7.2.5:
+ version "7.5.4"
+ resolved "https://registry.yarnpkg.com/protobufjs/-/protobufjs-7.5.4.tgz#885d31fe9c4b37f25d1bb600da30b1c5b37d286a"
+ integrity sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==
+ dependencies:
+ "@protobufjs/aspromise" "^1.1.2"
+ "@protobufjs/base64" "^1.1.2"
+ "@protobufjs/codegen" "^2.0.4"
+ "@protobufjs/eventemitter" "^1.1.0"
+ "@protobufjs/fetch" "^1.1.0"
+ "@protobufjs/float" "^1.0.2"
+ "@protobufjs/inquire" "^1.1.0"
+ "@protobufjs/path" "^1.1.2"
+ "@protobufjs/pool" "^1.1.0"
+ "@protobufjs/utf8" "^1.1.0"
+ "@types/node" ">=13.7.0"
+ long "^5.0.0"
+
proxy-addr@^2.0.7:
version "2.0.7"
resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025"
@@ -6786,7 +7425,7 @@ safe-array-concat@^1.1.3:
has-symbols "^1.1.0"
isarray "^2.0.5"
-safe-buffer@^5.1.0, safe-buffer@~5.2.0:
+safe-buffer@>=5.1.0, safe-buffer@^5.1.0, safe-buffer@~5.2.0:
version "5.2.1"
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6"
integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==
@@ -6843,7 +7482,7 @@ semver@^6.2.0, semver@^6.3.1:
resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4"
integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==
-semver@^7.1.1, semver@^7.1.3, semver@^7.2.1, semver@^7.3.2, semver@^7.3.5, semver@^7.3.7:
+semver@^7.1.1, semver@^7.1.3, semver@^7.2.1, semver@^7.3.2, semver@^7.3.5, semver@^7.3.7, semver@^7.7.3:
version "7.7.4"
resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.4.tgz#28464e36060e991fa7a11d0279d2d3f3b57a7e8a"
integrity sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==
@@ -7392,6 +8031,14 @@ tinyexec@^1.0.1:
resolved "https://registry.yarnpkg.com/tinyexec/-/tinyexec-1.0.2.tgz#bdd2737fe2ba40bd6f918ae26642f264b99ca251"
integrity sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==
+tinyglobby@^0.2.15:
+ version "0.2.15"
+ resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.15.tgz#e228dd1e638cea993d2fdb4fcd2d4602a79951c2"
+ integrity sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==
+ dependencies:
+ fdir "^6.5.0"
+ picomatch "^4.0.3"
+
tldts-core@^7.0.23:
version "7.0.23"
resolved "https://registry.yarnpkg.com/tldts-core/-/tldts-core-7.0.23.tgz#47bf18282a44641304a399d247703413b5d3e309"
@@ -7454,6 +8101,11 @@ trim-repeated@^1.0.0:
dependencies:
escape-string-regexp "^1.0.2"
+ts-api-utils@^2.4.0:
+ version "2.4.0"
+ resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-2.4.0.tgz#2690579f96d2790253bdcf1ca35d569ad78f9ad8"
+ integrity sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==
+
ts-morph@^26.0.0:
version "26.0.0"
resolved "https://registry.yarnpkg.com/ts-morph/-/ts-morph-26.0.0.tgz#d435ccac9421d4615fde8be86fee782f18cd9f73"
@@ -7591,10 +8243,10 @@ typed-array-length@^1.0.7:
possible-typed-array-names "^1.0.0"
reflect.getprototypeof "^1.0.6"
-typescript@~4.5.4:
- version "4.5.5"
- resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.5.5.tgz#d8c953832d28924a9e3d37c73d729c846c5896f3"
- integrity sha512-TCTIul70LyWe6IJWT8QSYeA54WQe8EjQFU4wY52Fasj5UKx88LNYKCgBEHcOMOrFF1rKGbD8v/xcNWVUq9SymA==
+typescript@^5.9.3:
+ version "5.9.3"
+ resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.3.tgz#5b4f59e15310ab17a216f5d6cf53ee476ede670f"
+ integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==
typescript@~5.4.5:
version "5.4.5"
@@ -7762,6 +8414,11 @@ web-streams-polyfill@^3.0.3:
resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz#2073b91a2fdb1fbfbd401e7de0ac9f8214cecb4b"
integrity sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==
+web-vitals@^4.2.4:
+ version "4.2.4"
+ resolved "https://registry.yarnpkg.com/web-vitals/-/web-vitals-4.2.4.tgz#1d20bc8590a37769bd0902b289550936069184b7"
+ integrity sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==
+
webidl-conversions@^3.0.0:
version "3.0.1"
resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871"
@@ -7803,6 +8460,20 @@ webpack@^5.69.1:
watchpack "^2.5.1"
webpack-sources "^3.3.3"
+websocket-driver@>=0.5.1:
+ version "0.7.4"
+ resolved "https://registry.yarnpkg.com/websocket-driver/-/websocket-driver-0.7.4.tgz#89ad5295bbf64b480abcba31e4953aca706f5760"
+ integrity sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==
+ dependencies:
+ http-parser-js ">=0.5.1"
+ safe-buffer ">=5.1.0"
+ websocket-extensions ">=0.1.1"
+
+websocket-extensions@>=0.1.1:
+ version "0.1.4"
+ resolved "https://registry.yarnpkg.com/websocket-extensions/-/websocket-extensions-0.1.4.tgz#7f8473bc839dfd87608adb95d7eb075211578a42"
+ integrity sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==
+
whatwg-url@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d"
@@ -8019,7 +8690,7 @@ zod@^3.24.1:
resolved "https://registry.yarnpkg.com/zod/-/zod-3.25.76.tgz#26841c3f6fd22a6a2760e7ccb719179768471e34"
integrity sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==
-"zod@^3.25 || ^4.0":
+"zod@^3.25 || ^4.0", zod@^4.3.6:
version "4.3.6"
resolved "https://registry.yarnpkg.com/zod/-/zod-4.3.6.tgz#89c56e0aa7d2b05107d894412227087885ab112a"
integrity sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==