diff --git a/go/Makefile b/go/Makefile index f7403a5..d6d55c6 100644 --- a/go/Makefile +++ b/go/Makefile @@ -46,7 +46,7 @@ migrate-prod: SKAFFOLD_DEFAULT_REPO=$(PROD_REPO) skaffold run -p migrations --kube-context prod --tail # ---- Deploy ---- -# Use MODULE=orion or MODULE=worker or MODULE=pusher or MODULE=emailnotifierjob to deploy a single service, e.g.: +# Use MODULE=orion or MODULE=particleprocessor or MODULE=pusher or MODULE=emailnotifierjob to deploy a single service, e.g.: # make deploy-dev MODULE=orion .PHONY: deploy-dev diff --git a/go/README.md b/go/README.md index 2100f92..490aa88 100644 --- a/go/README.md +++ b/go/README.md @@ -1,6 +1,6 @@ # Orion -API server and worker services for llink. +API server, jobs, and worker services for llink. ## Deploy @@ -11,7 +11,7 @@ make deploy-prod # Deploy a single service make deploy-dev MODULE=orion -make deploy-dev MODULE=worker +make deploy-dev MODULE=particleprocessor make deploy-dev MODULE=pusher make deploy-dev MODULE=emailnotifierjob ``` diff --git a/go/cmd/emailnotifierjob/main.go b/go/cmd/emailnotifierjob/main.go index 0cfeab8..14be3e6 100644 --- a/go/cmd/emailnotifierjob/main.go +++ b/go/cmd/emailnotifierjob/main.go @@ -5,7 +5,6 @@ import ( "fmt" "log/slog" "os" - "strings" "time" "cloud.google.com/go/firestore" @@ -21,22 +20,17 @@ import ( ) const ( - // Only notify about streams with activity in the last 24 hours - maxActivityAge = 24 * time.Hour - // Minimum time a message must be unread before we consider notifying - unreadThreshold = 10 * time.Minute - // Minimum time between emails to the same user - emailCooldown = 12 * time.Hour + maxActivityAge = 24 * time.Hour // ignore streams idle longer than this + unreadThreshold = 10 * time.Minute // grace window before a message is "unread" + emailCooldown = 12 * time.Hour // min gap between emails to the same user ) func main() { ctx := context.Background() - // Initialize Postgres db.Init() defer db.Cleanup() - // Initialize Firestore gcpProject := utils.MustGetEnv("GCP_PROJECT") firestoreClient, err := firestore.NewClient(ctx, gcpProject) if err != nil { @@ -45,7 +39,6 @@ func main() { } defer firestoreClient.Close() - // Initialize aero (email) gRPC client aeroAddr := utils.MustGetEnv("AERO_ADDR") aeroConn, err := grpc.NewClient(aeroAddr, grpc.WithTransportCredentials(insecure.NewCredentials())) if err != nil { @@ -55,7 +48,6 @@ func main() { defer aeroConn.Close() aeroSvc := pbaero.NewPrimaryClient(aeroConn) - // Initialize pusher gRPC client pusherAddr := utils.MustGetEnv("PUSHER_GRPC_ADDR") pusherConn, err := grpc.NewClient(pusherAddr, grpc.WithTransportCredentials(insecure.NewCredentials())) if err != nil { @@ -65,7 +57,6 @@ func main() { defer pusherConn.Close() pusherSvc := pbpusher.NewPusherServiceClient(pusherConn) - // Initialize services humanSvc := human.NewService(db.Pool()) networkSvc := network.NewReader(db.Pool()) @@ -87,13 +78,11 @@ func runNotificationCycle( ) error { now := time.Now() - // Load all networks networks, err := networkReader.ListAll(ctx) if err != nil { return fmt.Errorf("listing networks: %w", err) } - // Load all humans into a lookup map allHumans, err := humanSvc.ListAll(ctx) if err != nil { return fmt.Errorf("listing humans: %w", err) @@ -103,11 +92,9 @@ func runNotificationCycle( humansById[h.ID] = h } - // Track which streams each human is behind on, and the latest activity across those streams behindCounts := map[string]int{} latestActivity := map[string]time.Time{} - // Get all currently connected humans allOnline := map[string]bool{} onlineResp, err := pusherSvc.GetOnlineHumanIds(ctx, &pbpusher.GetOnlineHumanIdsRequest{}) if err != nil { @@ -119,42 +106,29 @@ func runNotificationCycle( } for _, net := range networks { - // Build set of all member humanIds for this network (members + admin) - networkMembers := make(map[string]bool, len(net.MemberHumanIds)+1) - for _, id := range net.MemberHumanIds { - networkMembers[id] = true - } - networkMembers[net.AdminHumanId] = true - - // Query Firestore for open streams in this network streams, err := getOpenStreams(ctx, fsClient, net.ID) if err != nil { slog.Error("failed to query streams", "networkId", net.ID, "error", err) continue } + // net.MemberHumanIds already includes the admin. for _, stream := range streams { if stream.LastChildCreatedAt == nil { continue } - // Skip streams with no recent activity if now.Sub(*stream.LastChildCreatedAt) > maxActivityAge { continue } - // Skip if the latest message is too fresh (within threshold) if now.Sub(*stream.LastChildCreatedAt) < unreadThreshold { continue } - // Resolve members from visible_to - members := resolveMembers(stream.VisibleTo, networkMembers) - - for humanId := range members { + for _, humanId := range network.ResolveVisibility(stream.VisibleTo, net.MemberHumanIds) { marker, hasMarker := stream.PlaybackMarkers[humanId] if hasMarker && !marker.Before(*stream.LastChildCreatedAt) { - continue // up to date + continue } - // No marker or marker is behind → this human is behind on this stream behindCounts[humanId]++ if stream.LastChildCreatedAt.After(latestActivity[humanId]) { latestActivity[humanId] = *stream.LastChildCreatedAt @@ -164,10 +138,8 @@ func runNotificationCycle( } - // Send notifications sentCount := 0 for humanId, count := range behindCounts { - // Skip online users if allOnline[humanId] { slog.Info("human online...skipping email", "humanId", humanId) continue @@ -178,28 +150,24 @@ func runNotificationCycle( continue } - // Skip if notifications disabled if !h.EmailNotificationsEnabled { continue } - // Skip if no new activity since last notification + // Skip if nothing new since the previous email. if h.LastEmailNotificationSentAt != nil && !latestActivity[humanId].After(*h.LastEmailNotificationSentAt) { continue } - // Enforce cooldown between emails to the same user if h.LastEmailNotificationSentAt != nil && now.Sub(*h.LastEmailNotificationSentAt) < emailCooldown { continue } - // Send email if err := sendNotificationEmail(ctx, aeroSvc, h, count); err != nil { slog.Error("failed to send email", "humanId", humanId, "error", err) continue } - // Update last sent timestamp if err := humanSvc.UpdateLastEmailNotificationSentAt(ctx, humanId, now); err != nil { slog.Error("failed to update last_email_notification_sent_at", "humanId", humanId, "error", err) } @@ -215,7 +183,6 @@ func runNotificationCycle( return nil } -// getOpenStreams queries Firestore for all open stream particles in a network. func getOpenStreams(ctx context.Context, client *firestore.Client, networkId string) ([]particle.FirestoreStreamParticle, error) { collPath := fmt.Sprintf("networks/%s/children", networkId) docs, err := client.Collection(collPath). @@ -239,27 +206,6 @@ func getOpenStreams(ctx context.Context, client *firestore.Client, networkId str return streams, nil } -// resolveMembers expands visible_to entries into a set of humanIds. -// "human:{id}" adds that id directly. "network:{id}" expands to all network members. -func resolveMembers(visibleTo []string, networkMembers map[string]bool) map[string]bool { - members := map[string]bool{} - for _, entry := range visibleTo { - if strings.HasPrefix(entry, "human:") { - humanId := strings.TrimPrefix(entry, "human:") - // human can be in visible_to, but no longer a member of the network - if _, ok := networkMembers[humanId]; ok { - members[humanId] = true - } - } else if strings.HasPrefix(entry, "network:") { - // Expand to all network members - for id := range networkMembers { - members[id] = true - } - } - } - return members -} - func sendNotificationEmail(ctx context.Context, aeroSvc pbaero.PrimaryClient, h *human.Human, streamCount int) error { streamsWord := "stream" if streamCount != 1 { diff --git a/go/cmd/memberreconciler/main.go b/go/cmd/memberreconciler/main.go index a1d1c7c..02b4528 100644 --- a/go/cmd/memberreconciler/main.go +++ b/go/cmd/memberreconciler/main.go @@ -1,13 +1,7 @@ -// memberreconciler is a one-shot job (also safe to run on a cron) that makes -// the Firestore membership mirror (humans/{humanId}.networks) match the -// authoritative Postgres network_members table. -// -// Run on a cron to heal any drift from a dropped mirror -// write in network.Service. -// -// The reconciler reads the current Firestore state and only writes humans -// whose mirrored networks differ from Postgres. Writes cost ~3x reads, and in -// steady state drift is rare, so read-first keeps the cron nearly free. +// memberreconciler reconciles the Firestore membership mirror +// (humans/{humanId}.networks) against the authoritative Postgres +// network_members table. Safe to run on a cron — only humans whose mirrored +// set differs from Postgres are written, so a steady-state run is nearly free. package main import ( @@ -98,8 +92,7 @@ func reconcile( return written, scanned, nil } -// snapshotMirror streams the humans collection once and returns a map of -// humanId -> current networks array. One iterator, N billed reads. +// One iterator, N billed reads — returns humanId → mirrored networks. func snapshotMirror(ctx context.Context, fs *firestore.Client) (map[string][]string, error) { out := map[string][]string{} iter := fs.Collection("humans").Documents(ctx) @@ -124,9 +117,8 @@ func snapshotMirror(ctx context.Context, fs *firestore.Client) (map[string][]str return out, nil } -// sameSet reports whether a and b contain the same elements, ignoring order -// and duplicates. Firestore array ops don't preserve order, so set equality is -// the right comparison for the networks array. +// Set equality (order- and duplicate-insensitive); Firestore array ops don't +// preserve order. func sameSet(a, b []string) bool { if len(a) == 0 && len(b) == 0 { return true diff --git a/go/cmd/orion/main.go b/go/cmd/orion/main.go index 205c324..d6a5be6 100644 --- a/go/cmd/orion/main.go +++ b/go/cmd/orion/main.go @@ -18,6 +18,7 @@ import ( "github.com/flowy-live/llink/internal/depot" "github.com/flowy-live/llink/internal/handler" "github.com/flowy-live/llink/internal/human" + "github.com/flowy-live/llink/internal/human/pushnotify" "github.com/flowy-live/llink/internal/livekit" "github.com/flowy-live/llink/internal/livestore" "github.com/flowy-live/llink/internal/middleware" @@ -106,9 +107,10 @@ func main() { BucketName: gcsBucket, }) waitlistSvc := waitlist.NewService(db.Pool(), aeroSvc) + pushTokenSvc := pushnotify.NewService(db.Pool()) livekitClient := livekit.NewClient() - h := handler.NewHandler(authSvc, humanSvc, networkSvc, particleSvc, depotSvc, waitlistSvc, billingSvc, livekitClient, firestoreClient) + h := handler.NewHandler(authSvc, humanSvc, networkSvc, particleSvc, depotSvc, waitlistSvc, billingSvc, pushTokenSvc, livekitClient, firestoreClient) withAuth := func(hf http.HandlerFunc) http.Handler { return middleware.Auth(authSvc)(http.HandlerFunc(hf)) @@ -143,6 +145,10 @@ func main() { // Settings mux.Handle("PATCH /humans/me/settings", withAuth(h.UpdateSettings)) + // Push notification tokens (per-device) + mux.Handle("POST /humans/me/push-tokens", withAuth(h.RegisterPushToken)) + mux.Handle("DELETE /humans/me/push-tokens", withAuth(h.UnregisterPushToken)) + // Networks mux.Handle("POST /networks", withAuth(h.CreateNetwork)) mux.Handle("GET /networks", withAuth(h.ListNetworks)) @@ -179,8 +185,7 @@ func main() { mux.Handle("GET /waitlist/{email}", withAuth(h.GetWaitlistEntry)) mux.Handle("POST /waitlist/invite", withAuth(h.InviteWaitlistEntrant)) - // Apply middleware - // nil allows all origins (required for electron app) + // nil = allow all origins (Electron app needs it). muxWithCors := middleware.CORS(nil)(mux) addr := fmt.Sprintf("0.0.0.0:%s", port) diff --git a/go/cmd/particleprocessorworker/main.go b/go/cmd/particleprocessorworker/main.go index f7f34bd..51e5da0 100644 --- a/go/cmd/particleprocessorworker/main.go +++ b/go/cmd/particleprocessorworker/main.go @@ -6,11 +6,15 @@ import ( "log" "log/slog" "os" + "strings" "time" "github.com/flowy-live/llink/internal/billing" "github.com/flowy-live/llink/internal/db" "github.com/flowy-live/llink/internal/depot" + "github.com/flowy-live/llink/internal/human" + "github.com/flowy-live/llink/internal/human/pushnotify" + "github.com/flowy-live/llink/internal/network" "github.com/flowy-live/llink/internal/particle" "github.com/flowy-live/llink/internal/speech" "github.com/flowy-live/llink/internal/utils" @@ -31,13 +35,9 @@ func createClient(ctx context.Context) *firestore.Client { return client } -// The purpose of the particle processor worker is to listen for new particles -// across all streams and perform side effects such as -// - generate transcript if the particle is of type media -// - send mobile notifications if a client is offline -// - update the parent stream's `last_child_created_at` -// - generate vector embedding -// - synthesize and decide whether ai should generate a particle as a response +// Listens for new particles and runs per-particle side effects: transcripts, +// transcode, parent stream's last_child_created_at, freemium usage, and push +// notifications for offline recipients. func main() { ctx := context.Background() @@ -61,6 +61,14 @@ func main() { speechSvc := speech.NewSpeechService(ctx) + humanSvc := human.NewService(db.Pool()) + networkReader := network.NewReader(db.Pool()) + pushTokenSvc := pushnotify.NewService(db.Pool()) + // EXPO_ACCESS_TOKEN is required: Enhanced Security is on for our Expo + // project (otherwise anyone holding one of our push tokens could spam users). + expoClient := pushnotify.NewExpoClient(utils.MustGetEnv("EXPO_ACCESS_TOKEN")) + notifier := pushnotify.NewNotifier(networkReader, pushTokenSvc, expoClient) + client := createClient(ctx) defer client.Close() @@ -102,13 +110,14 @@ func main() { slog.Debug("processing particle", "particleID", particleID, "data", change.Doc.Data()) - // --- Perform side effects --- - // All of them do not stop us from marking the particle as processed - - updateParentLastChildCreatedAt(ctx, change.Doc) - transcribeMediaParticle(ctx, depotSvc, speechSvc, change.Doc) + // Side effects below are best-effort — failures don't prevent + // marking the particle as processed. + parentDoc := loadParentParticle(ctx, change.Doc) + updateParentLastChildCreatedAt(ctx, change.Doc, parentDoc) + transcript := transcribeMediaParticle(ctx, depotSvc, speechSvc, change.Doc) particle.Transcode(ctx, depotSvc, change.Doc) recordFreemiumUsage(ctx, billingSvc, change.Doc) + notifyForParticle(ctx, notifier, humanSvc, change.Doc, parentDoc, transcript) if err := processingRepo.MarkProcessed(ctx, particleID); err != nil { slog.Error("failed to mark particle as processed", "particleID", particleID, "error", err) @@ -117,35 +126,37 @@ func main() { } } -func transcribeMediaParticle(ctx context.Context, depotSvc depot.Service, speechSvc speech.SpeechService, doc *firestore.DocumentSnapshot) { +// Writes the structured transcript to Firestore and returns the raw text; +// returns "" for non-media particles or on any error (logged internally). +func transcribeMediaParticle(ctx context.Context, depotSvc depot.Service, speechSvc speech.SpeechService, doc *firestore.DocumentSnapshot) string { var mediaParticle particle.FirestoreMediaParticle err := doc.DataTo(&mediaParticle) if err != nil { slog.Error("unable to marshal particle data", "error", err) - return + return "" } particleType, err := particle.ParseParticleType(mediaParticle.Type) if err != nil { slog.Error("invalid particle type", "error", err) - return + return "" } if particleType != particle.TypeMedia { slog.Info("received a particle of type", "particle type", particleType) - return + return "" } downloadURL, err := depotSvc.GetDownloadURL(ctx, mediaParticle.Properties.ObjectId) if err != nil { slog.Error("failed to get download URL", "error", err, "object_id", mediaParticle.Properties.ObjectId) - return + return "" } result, err := speechSvc.Transcribe(ctx, downloadURL) if err != nil { slog.Error("failed to transcribe media", "error", err, "particleID", doc.Ref.ID) - return + return "" } transcript := toFirestoreTranscript(result) @@ -157,10 +168,11 @@ func transcribeMediaParticle(ctx context.Context, depotSvc depot.Service, speech }, firestore.MergeAll) if err != nil { slog.Error("failed to update transcript in firestore", "error", err, "particleID", doc.Ref.ID) - return + return "" } slog.Info("transcribed media particle", "particleID", doc.Ref.ID) + return transcript.Transcript } func toFirestoreTranscript(result *speech.TranscriptResult) particle.FirestoreTranscript { @@ -197,10 +209,9 @@ func toFirestoreTranscript(result *speech.TranscriptResult) particle.FirestoreTr } } -// recordFreemiumUsage bumps the network's daily message counter for non-container -// particles. Idempotent via the surrounding processed_particles guard: the worker -// only reaches this path on first-seen particles, so a crash/restart won't -// double-count. +// Bumps the network's daily message counter for non-container particles. +// The surrounding processed_particles guard keeps this idempotent across +// crashes/restarts. func recordFreemiumUsage(ctx context.Context, billingSvc billing.Service, doc *firestore.DocumentSnapshot) { rawType, err := doc.DataAt("type") if err != nil { @@ -217,7 +228,7 @@ func recordFreemiumUsage(ctx context.Context, billingSvc billing.Service, doc *f slog.Error("invalid particle type", "error", err, "particleID", doc.Ref.ID) return } - // Containers (stream/folder) don't count as "messages" for the daily cap. + // Containers don't count toward the daily message cap. if particleType == particle.TypeStream || particleType == particle.TypeFolder { return } @@ -233,31 +244,34 @@ func recordFreemiumUsage(ctx context.Context, billingSvc billing.Service, doc *f } } -// updateParentLastChildCreatedAt updates the parent stream's last_child_created_at -// to the child's actual created_at timestamp, so it stays directly comparable with -// playback markers (which also store child created_at values). -func updateParentLastChildCreatedAt(ctx context.Context, doc *firestore.DocumentSnapshot) { +// Returns nil (and logs) if the path has no parent or the read fails. +func loadParentParticle(ctx context.Context, doc *firestore.DocumentSnapshot) *firestore.DocumentSnapshot { parentChildrenCollectionRef := doc.Ref.Parent if parentChildrenCollectionRef == nil { - return + return nil } - parentParticleDocRef := parentChildrenCollectionRef.Parent if parentParticleDocRef == nil { slog.Error("particle has no parent document", "particleID", doc.Ref.ID) - return + return nil } - parentParticleDoc, err := parentParticleDocRef.Get(ctx) if err != nil { - slog.Error("failed to get parent particle", "error", err) + slog.Error("failed to get parent particle", "error", err, "particleID", doc.Ref.ID) + return nil + } + return parentParticleDoc +} + +// Sets last_child_created_at to the child's created_at so it stays directly +// comparable with playback markers (which also store child created_at values). +func updateParentLastChildCreatedAt(ctx context.Context, doc *firestore.DocumentSnapshot, parent *firestore.DocumentSnapshot) { + if parent == nil { return } - slog.Info("parent particle is", "parent particle id", parentParticleDoc.Ref.ID) - var streamParticle particle.FirestoreStreamParticle - if err := parentParticleDoc.DataTo(&streamParticle); err != nil { + if err := parent.DataTo(&streamParticle); err != nil { slog.Error("failed to parse stream particle", "error", err) return } @@ -272,21 +286,159 @@ func updateParentLastChildCreatedAt(ctx context.Context, doc *firestore.Document return } - // Read the child's created_at — this is the same value that playback markers store childCreatedAt, err := doc.DataAt("created_at") if err != nil { slog.Error("failed to read child created_at", "error", err, "particleID", doc.Ref.ID) return } - slog.Info("going to update the last_child_created_at for parent particle") - _, err = parentParticleDocRef.Update(ctx, []firestore.Update{ + _, err = parent.Ref.Update(ctx, []firestore.Update{ { Path: "last_child_created_at", Value: childCreatedAt, }, }) if err != nil { - slog.Error("unable to update parent particle `last_child_created_at`") + slog.Error("unable to update parent particle `last_child_created_at`", "error", err) } } + +// Skips containers and particles whose parent isn't a stream — notifications +// are scoped to stream messages today. The transcript arg becomes the preview +// body for media particles when available. +func notifyForParticle( + ctx context.Context, + notifier *pushnotify.Notifier, + humanSvc human.Service, + doc *firestore.DocumentSnapshot, + parent *firestore.DocumentSnapshot, + transcript string, +) { + if parent == nil { + slog.Info("notify: skip — no parent", "particleID", doc.Ref.ID) + return + } + + typeStr, _ := doc.DataAt("type") + typeName, _ := typeStr.(string) + pType, err := particle.ParseParticleType(typeName) + if err != nil { + slog.Info("notify: skip — unparseable particle type", + "particleID", doc.Ref.ID, "type", typeName, "error", err) + return + } + if pType == particle.TypeStream || pType == particle.TypeFolder { + slog.Info("notify: skip — container particle", + "particleID", doc.Ref.ID, "type", pType) + return + } + + var parentStream particle.FirestoreStreamParticle + if err := parent.DataTo(&parentStream); err != nil { + slog.Error("notify: failed to parse parent stream", "error", err) + return + } + parentType, err := particle.ParseParticleType(parentStream.Type) + if err != nil || parentType != particle.TypeStream { + slog.Info("notify: skip — parent isn't a stream", + "particleID", doc.Ref.ID, "parentType", parentType, "parseErr", err) + return + } + + networkID, err := particle.NetworkIDFromParticlePath(doc.Ref.Path) + if err != nil { + slog.Error("notify: failed to derive network id", "error", err, "path", doc.Ref.Path) + return + } + + senderHumanID := parentStream.CreatedByHumanId + if v, err := doc.DataAt("created_by_human_id"); err == nil { + if s, ok := v.(string); ok && s != "" { + senderHumanID = s + } + } + + streamName := "" + if v, err := parent.DataAt("properties.name"); err == nil { + if s, ok := v.(string); ok { + streamName = s + } + } + + senderEmailPrefix := "" + if senderHumanID != "" { + if sender, err := humanSvc.GetByID(ctx, senderHumanID); err == nil { + senderEmailPrefix = sender.EmailPrefix + } else { + slog.Warn("notify: failed to look up sender", "error", err, "humanID", senderHumanID) + } + } + + if err := notifier.NotifyParticleCreated(ctx, pushnotify.NotifyInput{ + NetworkID: networkID, + SenderHumanID: senderHumanID, + SenderEmailPrefix: senderEmailPrefix, + ParticleID: doc.Ref.ID, + ParticleKind: string(pType), + StreamID: parent.Ref.ID, + StreamName: streamName, + StreamVisibleTo: parentStream.VisibleTo, + Body: previewForParticle(pType, doc, transcript), + }); err != nil { + slog.Error("notify: dispatch failed", "error", err, "particleID", doc.Ref.ID, "networkID", networkID) + } +} + +// Builds the notification body. Kept short — lockscreens truncate aggressively. +// Media prefers transcript text and falls back to a generic "Sent a …" line. +func previewForParticle(pType particle.ParticleType, doc *firestore.DocumentSnapshot, transcript string) string { + switch pType { + case particle.TypeText: + if v, err := doc.DataAt("properties.content"); err == nil { + if s, ok := v.(string); ok { + return truncatePreview(s, 140) + } + } + return "Sent a message" + case particle.TypeMedia: + if t := strings.TrimSpace(transcript); t != "" { + return truncatePreview(t, 140) + } + mime := "" + if v, err := doc.DataAt("properties.mime_type"); err == nil { + if s, ok := v.(string); ok { + mime = s + } + } + if strings.HasPrefix(mime, "video/") { + return "Sent a video" + } + return "Sent a voice message" + case particle.TypeFile: + return "Sent a file" + case particle.TypeQuest: + if v, err := doc.DataAt("properties.title"); err == nil { + if s, ok := v.(string); ok && s != "" { + return "Quest: " + truncatePreview(s, 120) + } + } + return "Added a quest" + case particle.TypePaper: + if v, err := doc.DataAt("properties.title"); err == nil { + if s, ok := v.(string); ok && s != "" { + return "Paper: " + truncatePreview(s, 120) + } + } + return "Added a paper" + default: + return "New activity" + } +} + +func truncatePreview(s string, n int) string { + s = strings.TrimSpace(s) + if len(s) <= n { + return s + } + return s[:n] + "…" +} diff --git a/go/cmd/pusherservice/main.go b/go/cmd/pusherservice/main.go index 53a0ae3..2dee5ca 100644 --- a/go/cmd/pusherservice/main.go +++ b/go/cmd/pusherservice/main.go @@ -25,29 +25,22 @@ func main() { port := utils.MustGetEnv("PORT") grpcPort := utils.MustGetEnv("GRPC_PORT") - // Initialize database (for network membership checks) db.Init() defer db.Cleanup() - // Redis for auth session validation (same DB as orion) - authRedis := internal.ConnectAndTestRedis(db.RedisDBAuth) + authRedis := internal.ConnectAndTestRedis(db.RedisDBAuth) // shared with orion + pusherRedis := internal.ConnectAndTestRedis(db.RedisDBPusher) // presence + pub/sub - // Redis for pusher state (presence hashes, pub/sub) - pusherRedis := internal.ConnectAndTestRedis(db.RedisDBPusher) + sessionReader := auth.NewSessionReader(authRedis) + networkReader := network.NewReader(db.Pool()) - // Services - sessionReader := auth.NewSessionReader(authRedis) // pusher only validates sessions - networkReader := network.NewReader(db.Pool()) // pusher only checks membership - - // Pod identity (use hostname in k8s, which is the pod name) + // Hostname is the k8s pod name. podID, err := os.Hostname() if err != nil { podID = fmt.Sprintf("pod-%d", os.Getpid()) } - // Pusher core bridge := pusher.NewRedisBridge(pusherRedis, podID) - // Context for graceful shutdown ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -56,16 +49,11 @@ func main() { bridge.SetHub(hub) server := pusher.NewServer(ctx, hub, bridge, sessionReader) - // Start hub event loop go hub.Run(ctx) - - // Start Redis Pub/Sub listener go bridge.Listen(ctx) - - // Start pod heartbeat + stale pod cleanup go bridge.Heartbeat(ctx) - // --- gRPC server (internal, for presence queries) --- + // --- gRPC server (internal presence queries) --- grpcListener, err := net.Listen("tcp", fmt.Sprintf("0.0.0.0:%s", grpcPort)) if err != nil { slog.Error("failed to listen for gRPC", "port", grpcPort, "error", err) @@ -104,7 +92,7 @@ func main() { <-sigCh slog.Info("shutting down...") - cancel() // stops hub, bridge listener, heartbeat + cancel() grpcServer.GracefulStop() httpServer.Shutdown(context.Background()) diff --git a/go/cmd/transcodebackfill/main.go b/go/cmd/transcodebackfill/main.go index 258069e..df931df 100644 --- a/go/cmd/transcodebackfill/main.go +++ b/go/cmd/transcodebackfill/main.go @@ -1,12 +1,6 @@ -// transcodebackfill is a one-shot job that scans every doc under the -// "children" Firestore collection group, identifies media particles missing a -// transcoded variant, and re-runs the transcode + upload + Firestore-update -// flow against the configured GCS bucket. Idempotent — re-running the Job -// after a partial failure picks up where it left off via the existing -// `transcoded_object_id != ""` short-circuit inside particle.Transcode. -// -// Intended to back-fill the production iOS playback backlog created before we -// launched mobile / particle processor worker only transcodes new media. +// transcodebackfill walks every doc under the "children" collection group and +// re-runs transcode for media particles missing a transcoded variant. +// Idempotent: particle.Transcode short-circuits on transcoded_object_id != "". package main import ( @@ -104,9 +98,7 @@ func run(ctx context.Context, fs *firestore.Client, depotSvc depot.Service) (sta s.scanned++ - // Cheap pre-filter: most docs under the "children" collection group - // are not media particles. DataAt avoids unmarshalling the full - // document for those. + // DataAt avoids unmarshalling the full doc; most children aren't media. rawType, err := doc.DataAt("type") if err != nil { s.skippedNonMedia++ diff --git a/go/internal/auth/reader.go b/go/internal/auth/reader.go index 037b637..0a5a716 100644 --- a/go/internal/auth/reader.go +++ b/go/internal/auth/reader.go @@ -18,9 +18,8 @@ type sessionReaderImpl struct { redisClient *redis.Client } -// newSessionReader returns the concrete reader. Used by NewAuthService to -// embed without going through the SessionReader interface (which would hide -// redisClient from the rest of authServiceImpl). +// Exposes the concrete type so authServiceImpl can embed it without +// hiding redisClient behind the SessionReader interface. func newSessionReader(redisClient *redis.Client) *sessionReaderImpl { return &sessionReaderImpl{redisClient: redisClient} } diff --git a/go/internal/auth/service.go b/go/internal/auth/service.go index 40b12a9..244e35c 100644 --- a/go/internal/auth/service.go +++ b/go/internal/auth/service.go @@ -47,17 +47,16 @@ type Session struct { type AuthService interface { SessionReader - // RequestSignInCode generates a code and emails it to the provided email. - // To retrieve a session, client must verify with VerifySignInCode. + // RequestSignInCode emails a one-time code; the client redeems it via VerifySignInCode. RequestSignInCode(ctx context.Context, email string) error - // VerifySignInCode returns ErrInvalidCode if incorrect code, otherwise creates a session. - // humanId is stored in the session alongside the email. + // VerifySignInCode returns ErrInvalidCode on a wrong code, otherwise creates + // a session keyed to (email, humanId). VerifySignInCode(ctx context.Context, email, code, humanId string) (sessionToken string, err error) - // ExtendSession returns ErrSessionNotFound if no valid session + // ExtendSession returns ErrSessionNotFound if no valid session. ExtendSession(ctx context.Context, sessionToken string) error SignOut(ctx context.Context, sessionToken string) error - // MintFirebaseCustomToken returns a Firebase custom token with uid=humanId and no custom claims. + // MintFirebaseCustomToken issues a Firebase custom token with uid=humanId and no claims. MintFirebaseCustomToken(ctx context.Context, humanId string) (string, error) IsSystemAdmin(ctx context.Context, email string) bool diff --git a/go/internal/billing/service.go b/go/internal/billing/service.go index fabea1a..9bb1164 100644 --- a/go/internal/billing/service.go +++ b/go/internal/billing/service.go @@ -73,9 +73,8 @@ func NewService(ctx context.Context, pool *pgxpool.Pool, cfg Config) (Service, e }, nil } -// NewServiceForWorker builds a minimal billing Service suitable for the -// particle processor worker: only the usage-tracking path is exercised, so -// we skip Stripe client setup (no API key required). +// NewServiceForWorker skips Stripe client setup since workers only exercise +// the usage-tracking path. No API key required. func NewServiceForWorker(pool *pgxpool.Pool) Service { return &serviceImpl{ usageRepo: newUsageRepository(pool), diff --git a/go/internal/billing/usage_models.go b/go/internal/billing/usage_models.go index 6e8ec00..0787edc 100644 --- a/go/internal/billing/usage_models.go +++ b/go/internal/billing/usage_models.go @@ -2,11 +2,9 @@ package billing import "time" -// FreemiumDailyLimit is the per-network daily cap on usage, -// agnostic of the units that this refer to. This is only relevant for the "free" plan. +// Per-network daily cap on the free plan. Unit-agnostic. const FreemiumDailyLimit = 50 -// Usage describes a network's current freemium quota state for today. type Usage struct { Plan Plan `json:"plan"` Used int `json:"used"` diff --git a/go/internal/db/redis.go b/go/internal/db/redis.go index d663685..84a847e 100644 --- a/go/internal/db/redis.go +++ b/go/internal/db/redis.go @@ -1,8 +1,8 @@ package db -// Shared database namespaces used across services -// FIX: Use separate redis instance. We start with higher number because use this same instance in helios. +// FIX: move to a dedicated Redis instance. The high DB numbers exist because +// this instance is shared with helios. const ( RedisDBAuth = 4 // auth sessions - RedisDBPusher = 5 // dedicated to pusher state (presence, pub/sub) + RedisDBPusher = 5 // presence, pub/sub ) diff --git a/go/internal/depot/models.go b/go/internal/depot/models.go index 8042a72..219e921 100644 --- a/go/internal/depot/models.go +++ b/go/internal/depot/models.go @@ -2,7 +2,6 @@ package depot import "time" -// Object represents a stored object in the depot type Object struct { ID string Name string @@ -14,31 +13,26 @@ type Object struct { CreatedAt time.Time } -// PrepareUploadInput represents the input for preparing an upload type PrepareUploadInput struct { - Prefix string // Optional prefix for organizing objects (e.g., network_id) + Prefix string // optional, e.g. network_id Name string ContentType string ContentLength int64 } -// PrepareUploadResult represents the result of preparing an upload type PrepareUploadResult struct { ObjectID string UploadURL string UploadHeaders map[string]string } -// CreateFromReaderInput is for server-side direct uploads (no presigned URL). -// Used by background workers that already have the bytes on hand and don't -// need a client round-trip. +// For server-side direct uploads (no presigned URL). type CreateFromReaderInput struct { - Prefix string // Optional prefix for organizing objects (e.g., network_id) + Prefix string // optional, e.g. network_id Name string ContentType string } -// Config holds configuration for the depot service type Config struct { GoogleServiceAccountEmail string BucketName string diff --git a/go/internal/depot/service.go b/go/internal/depot/service.go index 4026552..c87221d 100644 --- a/go/internal/depot/service.go +++ b/go/internal/depot/service.go @@ -74,10 +74,10 @@ func (s *serviceImpl) PrepareUpload(ctx context.Context, input PrepareUploadInpu return nil, errors.Join(ErrInvalidInput, errors.New("content_length must be positive")) } - // Generate object key: {prefix}/{uuid}/{filename} + // {prefix}/{uuid}/{filename} objectKey := fmt.Sprintf("%s/%s/%s", input.Prefix, uuid.New().String(), input.Name) - // Create the database record (contains_content = false initially) + // Row is written first with contains_content=false; ConfirmUpload flips it. obj := &Object{ Name: input.Name, ContentType: input.ContentType, @@ -92,8 +92,7 @@ func (s *serviceImpl) PrepareUpload(ctx context.Context, input PrepareUploadInpu return nil, err } - // Generate a signed URL for uploading with Content-Length enforcement - // The Headers field specifies headers that MUST be included in the upload request + // Content-Length is part of the signature, so the client must send it verbatim. contentLengthHeader := fmt.Sprintf("Content-Length:%d", input.ContentLength) uploadURL, err := s.storageClient.Bucket(s.bucketName).SignedURL(objectKey, &storage.SignedURLOptions{ GoogleAccessID: s.googleServiceAccountEmail, @@ -104,7 +103,7 @@ func (s *serviceImpl) PrepareUpload(ctx context.Context, input PrepareUploadInpu }) if err != nil { slog.Error("failed to generate signed upload URL", "error", err, "bucket", s.bucketName, "object_key", objectKey) - // Clean up the database record if we can't generate the URL + // Roll back the placeholder row. if delErr := s.repo.delete(ctx, created.ID); delErr != nil { slog.Warn("failed to cleanup db record after signed URL failure", "error", delErr, "object_id", created.ID) } @@ -130,7 +129,6 @@ func (s *serviceImpl) ConfirmUpload(ctx context.Context, objectID string) (*Obje return nil, err } - // Verify the object exists in GCS and check its size matches expected attrs, err := s.storageClient.Bucket(obj.BucketName).Object(obj.ObjectKey).Attrs(ctx) if err != nil { if errors.Is(err, storage.ErrObjectNotExist) { @@ -140,12 +138,10 @@ func (s *serviceImpl) ConfirmUpload(ctx context.Context, objectID string) (*Obje return nil, err } - // Verify content length matches what was declared if attrs.Size != obj.ContentLength { return nil, errors.Join(ErrInvalidInput, fmt.Errorf("content length mismatch: expected %d, got %d", obj.ContentLength, attrs.Size)) } - // Mark as containing content if err := s.repo.setContainsContent(ctx, objectID, true); err != nil { if errors.Is(err, errNotFound) { return nil, ErrNotFound @@ -153,14 +149,12 @@ func (s *serviceImpl) ConfirmUpload(ctx context.Context, objectID string) (*Obje return nil, err } - // Fetch and return the updated object return s.repo.getByID(ctx, objectID) } -// CreateFromReader streams bytes directly to GCS using the storage client and -// records the depot_objects row in one shot. Unlike PrepareUpload, there is no -// signed URL or client round-trip — the caller already has the bytes. Intended -// for worker-side flows (e.g. transcoded media variants). +// CreateFromReader streams bytes straight to GCS and writes the row in one +// shot — no signed URL, no client round-trip. For server-side flows that +// already have the bytes (e.g. transcoded variants). func (s *serviceImpl) CreateFromReader(ctx context.Context, input CreateFromReaderInput, body io.Reader) (*Object, error) { if input.Name == "" { return nil, errors.Join(ErrInvalidInput, errors.New("name is required")) @@ -174,7 +168,7 @@ func (s *serviceImpl) CreateFromReader(ctx context.Context, input CreateFromRead w := s.storageClient.Bucket(s.bucketName).Object(objectKey).NewWriter(ctx) w.ContentType = input.ContentType if _, err := io.Copy(w, body); err != nil { - // Close to release resources, then surface the original copy error. + // Always release the writer; surface the copy error, not Close's. if cerr := w.Close(); cerr != nil { slog.Warn("failed to close GCS writer after copy failure", "error", cerr, "object_key", objectKey) } @@ -197,7 +191,7 @@ func (s *serviceImpl) CreateFromReader(ctx context.Context, input CreateFromRead created, err := s.repo.create(ctx, obj) if err != nil { - // Best-effort: clean up the GCS object since we can't track it in the DB. + // Best-effort: drop the now-untracked GCS object. if delErr := s.storageClient.Bucket(s.bucketName).Object(objectKey).Delete(ctx); delErr != nil { slog.Warn("failed to clean up GCS object after db create failure", "error", delErr, "object_key", objectKey) } @@ -227,7 +221,6 @@ func (s *serviceImpl) GetDownloadURL(ctx context.Context, objectID string) (stri return "", err } - // Generate a signed URL for downloading downloadURL, err := s.storageClient.Bucket(obj.BucketName).SignedURL(obj.ObjectKey, &storage.SignedURLOptions{ GoogleAccessID: s.googleServiceAccountEmail, Method: "GET", @@ -250,14 +243,13 @@ func (s *serviceImpl) Delete(ctx context.Context, objectID string) error { return err } - // Delete from GCS (ignore not found errors) + // GCS first so we don't strand an object after the row vanishes; missing object is fine. gcsErr := s.storageClient.Bucket(obj.BucketName).Object(obj.ObjectKey).Delete(ctx) if gcsErr != nil && !errors.Is(gcsErr, storage.ErrObjectNotExist) { slog.Error("failed to delete object from GCS", "error", gcsErr, "bucket", obj.BucketName, "object_key", obj.ObjectKey) return gcsErr } - // Delete from database if err := s.repo.delete(ctx, objectID); err != nil { if errors.Is(err, errNotFound) { return ErrNotFound diff --git a/go/internal/handler/billing.go b/go/internal/handler/billing.go index 2d17206..23423ec 100644 --- a/go/internal/handler/billing.go +++ b/go/internal/handler/billing.go @@ -25,12 +25,8 @@ type PortalSessionResponse struct { URL string `json:"url"` } -// GetNetworkUsage returns the freemium quota state for the authenticated -// caller's current network: how many messages they've used today, the daily -// limit (null for pro), and when the counter resets. -// -// Authorization: any network member may read (not admin-only) since the UI -// surfaces this to every sender. +// GetNetworkUsage reports today's usage, daily limit (nil on pro), and reset +// time. Open to any network member since the UI surfaces it to every sender. func (h *Handler) GetNetworkUsage(w http.ResponseWriter, r *http.Request) { humanId, ok := middleware.HumanIdFromContext(r.Context()) if !ok { @@ -170,8 +166,8 @@ func (h *Handler) HandleStripeWebhook(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) } -// loadNetworkForAdmin resolves the {id} path param and verifies the caller -// is the network's admin. On failure it writes the HTTP error and returns ok=false. +// Resolves {id}, verifies the caller is admin. On failure writes the HTTP +// error and returns ok=false. func (h *Handler) loadNetworkForAdmin(w http.ResponseWriter, r *http.Request) (*network.Network, string, bool) { humanId, ok := middleware.HumanIdFromContext(r.Context()) if !ok { diff --git a/go/internal/handler/handler.go b/go/internal/handler/handler.go index 51a8970..d87a62c 100644 --- a/go/internal/handler/handler.go +++ b/go/internal/handler/handler.go @@ -15,6 +15,7 @@ import ( "github.com/flowy-live/llink/internal/billing" "github.com/flowy-live/llink/internal/depot" "github.com/flowy-live/llink/internal/human" + "github.com/flowy-live/llink/internal/human/pushnotify" "github.com/flowy-live/llink/internal/livekit" "github.com/flowy-live/llink/internal/middleware" "github.com/flowy-live/llink/internal/network" @@ -32,6 +33,7 @@ type Handler struct { depotSvc depot.Service waitlistSvc waitlist.Service billingSvc billing.Service + pushTokenSvc pushnotify.Service livekitClient livekit.Client firestoreClient *firestore.Client } @@ -44,6 +46,7 @@ func NewHandler( depotSvc depot.Service, waitlistSvc waitlist.Service, billingSvc billing.Service, + pushTokenSvc pushnotify.Service, livekitClient livekit.Client, firestoreClient *firestore.Client, ) *Handler { @@ -55,6 +58,7 @@ func NewHandler( depotSvc: depotSvc, waitlistSvc: waitlistSvc, billingSvc: billingSvc, + pushTokenSvc: pushTokenSvc, livekitClient: livekitClient, firestoreClient: firestoreClient, } @@ -168,7 +172,7 @@ type DepotObject struct { // Auth Handlers // ============================================================================ -// RequestSignInCode creates a human account if not already existent and sends a sign-in code +// RequestSignInCode auto-creates the human if missing, then emails a one-time code. func (h *Handler) RequestSignInCode(w http.ResponseWriter, r *http.Request) { var req RequestSignInCodeRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -181,7 +185,6 @@ func (h *Handler) RequestSignInCode(w http.ResponseWriter, r *http.Request) { return } - // Auto-create human if doesn't exist _, err := h.humanSvc.GetOrCreateByEmail(r.Context(), req.Email) if err != nil { slog.Error("failed to get or create human", "error", err, "email", req.Email) @@ -189,7 +192,6 @@ func (h *Handler) RequestSignInCode(w http.ResponseWriter, r *http.Request) { return } - // Request sign-in code if err := h.authSvc.RequestSignInCode(r.Context(), req.Email); err != nil { slog.Error("failed to request sign-in code", "error", err, "email", req.Email) http.Error(w, "internal server error", http.StatusInternalServerError) @@ -199,7 +201,6 @@ func (h *Handler) RequestSignInCode(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } -// SignIn verifies the code and returns a session token func (h *Handler) SignIn(w http.ResponseWriter, r *http.Request) { var req SignInRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -212,7 +213,7 @@ func (h *Handler) SignIn(w http.ResponseWriter, r *http.Request) { return } - // Look up human first so we can store humanId in the session + // humanId is captured into the session so later requests don't re-resolve email → id. hum, err := h.humanSvc.GetByEmail(r.Context(), req.Email) if err != nil { if errors.Is(err, human.ErrNotFound) { @@ -244,9 +245,8 @@ func (h *Handler) SignIn(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(resp) } -// FirebaseToken mints a Firebase custom token for the authenticated human so -// the client can signInWithCustomToken and have request.auth.uid populated in -// Firestore security rules. +// FirebaseToken mints a custom token so the client can signInWithCustomToken +// and have request.auth.uid populated in Firestore security rules. func (h *Handler) FirebaseToken(w http.ResponseWriter, r *http.Request) { humanId, ok := middleware.HumanIdFromContext(r.Context()) if !ok { @@ -265,7 +265,6 @@ func (h *Handler) FirebaseToken(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(FirebaseTokenResponse{Token: token}) } -// SignOut deletes the session from the token in headers func (h *Handler) SignOut(w http.ResponseWriter, r *http.Request) { token := extractBearerToken(r) if token == "" { @@ -282,7 +281,6 @@ func (h *Handler) SignOut(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } -// GetCurrentHuman returns the authenticated human func (h *Handler) GetCurrentHuman(w http.ResponseWriter, r *http.Request) { email, ok := middleware.EmailFromContext(r.Context()) if !ok { @@ -310,7 +308,6 @@ type UpdateSettingsRequest struct { EmailNotificationsEnabled *bool `json:"email_notifications_enabled"` } -// UpdateSettings updates the authenticated human's settings func (h *Handler) UpdateSettings(w http.ResponseWriter, r *http.Request) { humanId, ok := middleware.HumanIdFromContext(r.Context()) if !ok { @@ -339,7 +336,6 @@ func (h *Handler) UpdateSettings(w http.ResponseWriter, r *http.Request) { // Network Handlers // ============================================================================ -// CreateNetwork creates a new network func (h *Handler) CreateNetwork(w http.ResponseWriter, r *http.Request) { humanId, ok := middleware.HumanIdFromContext(r.Context()) if !ok { @@ -376,7 +372,6 @@ func (h *Handler) CreateNetwork(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(resp) } -// ListNetworks retrieves networks for the authenticated human func (h *Handler) ListNetworks(w http.ResponseWriter, r *http.Request) { humanId, ok := middleware.HumanIdFromContext(r.Context()) if !ok { @@ -405,7 +400,6 @@ func (h *Handler) ListNetworks(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(resp) } -// GetNetwork retrieves a specific network func (h *Handler) GetNetwork(w http.ResponseWriter, r *http.Request) { humanId, ok := middleware.HumanIdFromContext(r.Context()) if !ok { @@ -452,8 +446,8 @@ func (h *Handler) GetNetwork(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(resp) } -// AddMembersToNetwork adds members to a network. Registered users are added as members, -// unregistered users receive email invitations. +// AddMembersToNetwork routes registered users into membership and emails an +// invitation to the rest. func (h *Handler) AddMembersToNetwork(w http.ResponseWriter, r *http.Request) { humanId, ok := middleware.HumanIdFromContext(r.Context()) if !ok { @@ -489,7 +483,6 @@ func (h *Handler) AddMembersToNetwork(w http.ResponseWriter, r *http.Request) { return } - // Resolve emails: registered users become members, unregistered get invitations var memberHumanIds []string var inviteEmails []string for _, email := range req.EmailAddresses { @@ -527,7 +520,6 @@ func (h *Handler) AddMembersToNetwork(w http.ResponseWriter, r *http.Request) { } } - // Return updated network net, err := h.networkSvc.GetByID(r.Context(), networkID) if err != nil { slog.Error("failed to get network after adding members", "error", err, "network_id", networkID) @@ -546,9 +538,8 @@ func (h *Handler) AddMembersToNetwork(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(resp) } -// RemoveMemberFromNetwork removes a member from a network. Admin-only. -// Admins cannot remove themselves — doing so would leave networks.admin_human_id -// dangling. Removal of a non-member is a no-op (204). +// RemoveMemberFromNetwork is admin-only. Admins cannot remove themselves +// (would orphan networks.admin_human_id); removing a non-member is a no-op (204). func (h *Handler) RemoveMemberFromNetwork(w http.ResponseWriter, r *http.Request) { net, _, ok := h.loadNetworkForAdmin(w, r) if !ok { @@ -575,7 +566,6 @@ func (h *Handler) RemoveMemberFromNetwork(w http.ResponseWriter, r *http.Request w.WriteHeader(http.StatusNoContent) } -// ListInvitationsForNetwork returns pending invitations for a network func (h *Handler) ListInvitationsForNetwork(w http.ResponseWriter, r *http.Request) { humanId, ok := middleware.HumanIdFromContext(r.Context()) if !ok { @@ -621,7 +611,6 @@ func (h *Handler) ListInvitationsForNetwork(w http.ResponseWriter, r *http.Reque json.NewEncoder(w).Encode(resp) } -// ListMyInvitations returns pending invitations for the authenticated user func (h *Handler) ListMyInvitations(w http.ResponseWriter, r *http.Request) { email, ok := middleware.EmailFromContext(r.Context()) if !ok { @@ -650,7 +639,6 @@ func (h *Handler) ListMyInvitations(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(resp) } -// AcceptInvitation accepts a pending network invitation for the authenticated user func (h *Handler) AcceptInvitation(w http.ResponseWriter, r *http.Request) { email, ok := middleware.EmailFromContext(r.Context()) if !ok { @@ -683,7 +671,6 @@ func (h *Handler) AcceptInvitation(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } -// RevokeInvitation revokes a pending invitation from a network func (h *Handler) RevokeInvitation(w http.ResponseWriter, r *http.Request) { humanId, ok := middleware.HumanIdFromContext(r.Context()) if !ok { @@ -728,7 +715,7 @@ func (h *Handler) RevokeInvitation(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } -// DownloadParticleMedia returns a fresh signed download URL for media/file particles +// DownloadParticleMedia returns a fresh signed URL for media/file particles. func (h *Handler) DownloadParticleMedia(w http.ResponseWriter, r *http.Request) { _, ok := middleware.EmailFromContext(r.Context()) if !ok { @@ -755,7 +742,7 @@ func (h *Handler) DownloadParticleMedia(w http.ResponseWriter, r *http.Request) // Depot Handlers // ============================================================================ -// PrepareUpload prepares an upload and returns a signed URL for direct upload to GCS +// PrepareUpload returns a signed URL for direct upload to GCS. func (h *Handler) PrepareUpload(w http.ResponseWriter, r *http.Request) { humanId, ok := middleware.HumanIdFromContext(r.Context()) if !ok { @@ -813,7 +800,6 @@ func (h *Handler) PrepareUpload(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(resp) } -// ConfirmUpload confirms that an upload has been completed func (h *Handler) ConfirmUpload(w http.ResponseWriter, r *http.Request) { _, ok := middleware.EmailFromContext(r.Context()) if !ok { @@ -881,7 +867,7 @@ type InviteWaitlistEntrantRequest struct { // Waitlist Handlers // ============================================================================ -// AddToWaitlist adds an email to the waitlist (public, no auth) +// AddToWaitlist is public — no auth required. func (h *Handler) AddToWaitlist(w http.ResponseWriter, r *http.Request) { var req AddToWaitlistRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -908,7 +894,7 @@ func (h *Handler) AddToWaitlist(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusCreated) } -// GetWaitlist returns all waitlist entries (admin-only) +// GetWaitlist is admin-only. func (h *Handler) GetWaitlist(w http.ResponseWriter, r *http.Request) { if !middleware.IsAdminFromContext(r.Context()) { http.Error(w, "forbidden", http.StatusForbidden) @@ -940,7 +926,7 @@ func (h *Handler) GetWaitlist(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(resp) } -// GetWaitlistEntry returns a single waitlist entry by email (admin-only) +// GetWaitlistEntry is admin-only. func (h *Handler) GetWaitlistEntry(w http.ResponseWriter, r *http.Request) { if !middleware.IsAdminFromContext(r.Context()) { http.Error(w, "forbidden", http.StatusForbidden) @@ -968,7 +954,7 @@ func (h *Handler) GetWaitlistEntry(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(waitlistEntryToDTO(entry)) } -// InviteWaitlistEntrant marks a waitlist entry as invited (admin-only) +// InviteWaitlistEntrant is admin-only. func (h *Handler) InviteWaitlistEntrant(w http.ResponseWriter, r *http.Request) { if !middleware.IsAdminFromContext(r.Context()) { http.Error(w, "forbidden", http.StatusForbidden) @@ -1079,7 +1065,7 @@ func (h *Handler) GetLivekitToken(w http.ResponseWriter, r *http.Request) { return } - // Compose room name encoding both network and stream IDs for webhook resolution + // Encode both IDs in the room name so the webhook handler can resolve them. roomName := req.NetworkId + "/" + req.StreamId token, err := h.livekitClient.GetJoinToken(roomName, humanId, humanEmail) @@ -1093,9 +1079,8 @@ func (h *Handler) GetLivekitToken(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(GetLivekitTokenResponse{Token: token, ServerUrl: h.livekitClient.ServerUrl()}) } -// HandleLivekitWebhook processes LiveKit webhook events for huddle presence. -// It verifies the webhook signature (not user auth), then updates the stream -// particle's huddle_active_participants field in Firestore. +// HandleLivekitWebhook verifies the webhook signature (not user auth) and +// reconciles huddle_active_participants on the stream particle in Firestore. func (h *Handler) HandleLivekitWebhook(w http.ResponseWriter, r *http.Request) { event, err := webhook.ReceiveWebhookEvent(r, h.livekitClient.KeyProvider()) if err != nil { @@ -1109,13 +1094,12 @@ func (h *Handler) HandleLivekitWebhook(w http.ResponseWriter, r *http.Request) { switch eventType { case "participant_joined", "participant_left", "room_finished": - // Handle these events + // fall through default: w.WriteHeader(http.StatusOK) return } - // Parse room name to extract networkId and streamId roomName := event.GetRoom().GetName() parts := strings.SplitN(roomName, "/", 2) if len(parts) != 2 { @@ -1131,14 +1115,13 @@ func (h *Handler) HandleLivekitWebhook(w http.ResponseWriter, r *http.Request) { var participantIds []string if eventType == "room_finished" { - // Room is done — clear the participants participantIds = []string{} } else { - // Use ListParticipants for authoritative state (avoids drift from missed webhooks) + // Authoritative list avoids drift from missed/out-of-order webhooks. participants, err := h.livekitClient.ListParticipants(ctx, roomName) if err != nil { slog.Error("failed to list participants", "error", err, "room", roomName) - // Return 200 so LiveKit doesn't retry + // 200 to suppress LiveKit retries. w.WriteHeader(http.StatusOK) return } diff --git a/go/internal/handler/push_token.go b/go/internal/handler/push_token.go new file mode 100644 index 0000000..edff810 --- /dev/null +++ b/go/internal/handler/push_token.go @@ -0,0 +1,83 @@ +package handler + +import ( + "encoding/json" + "errors" + "log/slog" + "net/http" + + "github.com/flowy-live/llink/internal/human/pushnotify" + "github.com/flowy-live/llink/internal/middleware" +) + +type RegisterPushTokenRequest struct { + Token string `json:"token"` + Platform string `json:"platform"` + AppVersion string `json:"app_version"` +} + +type UnregisterPushTokenRequest struct { + Token string `json:"token"` +} + +// RegisterPushToken upserts an Expo token; ON CONFLICT transparently re-binds +// a token to a new human after a device-level account switch. +func (h *Handler) RegisterPushToken(w http.ResponseWriter, r *http.Request) { + humanId, ok := middleware.HumanIdFromContext(r.Context()) + if !ok { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + var req RegisterPushTokenRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + + err := h.pushTokenSvc.Register(r.Context(), humanId, pushnotify.RegisterInput{ + Token: req.Token, + Platform: pushnotify.Platform(req.Platform), + AppVersion: req.AppVersion, + }) + if err != nil { + if errors.Is(err, pushnotify.ErrInvalidPlatform) || errors.Is(err, pushnotify.ErrInvalidToken) { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + slog.Error("failed to register push token", "error", err, "humanId", humanId) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// UnregisterPushToken returns 204 whether or not the token existed (idempotent). +func (h *Handler) UnregisterPushToken(w http.ResponseWriter, r *http.Request) { + humanId, ok := middleware.HumanIdFromContext(r.Context()) + if !ok { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + var req UnregisterPushTokenRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + + if req.Token == "" { + http.Error(w, "token is required", http.StatusBadRequest) + return + } + + err := h.pushTokenSvc.Unregister(r.Context(), humanId, req.Token) + if err != nil && !errors.Is(err, pushnotify.ErrNotFound) { + slog.Error("failed to unregister push token", "error", err, "humanId", humanId) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/go/internal/human/pushnotify/expo.go b/go/internal/human/pushnotify/expo.go new file mode 100644 index 0000000..62dc0dd --- /dev/null +++ b/go/internal/human/pushnotify/expo.go @@ -0,0 +1,138 @@ +package pushnotify + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +const ( + expoPushAPIURL = "https://exp.host/--/api/v2/push/send" + // expoMaxBatchSize is the documented per-request cap on push messages. + expoMaxBatchSize = 100 + + // Ticket error codes returned by Expo Push API. The only one we act on is + // DeviceNotRegistered — others are logged but not retried (per product call). + ExpoErrorDeviceNotRegistered = "DeviceNotRegistered" +) + +// Sound defaults to "default" when empty (set in Send). +type Message struct { + To string `json:"to"` + Title string `json:"title,omitempty"` + Body string `json:"body,omitempty"` + Data map[string]any `json:"data,omitempty"` + Sound string `json:"sound,omitempty"` +} + +// Status is "ok" or "error". On error, Details["error"] carries the code +// (e.g. "DeviceNotRegistered", "MessageTooBig", "InvalidCredentials"). +type Ticket struct { + Status string `json:"status"` + ID string `json:"id,omitempty"` + Message string `json:"message,omitempty"` + Details map[string]any `json:"details,omitempty"` +} + +// ExpoClient does NOT poll receipts and does NOT retry — fire-and-forget, +// with DeviceNotRegistered handled out-of-band by the notifier. +type ExpoClient struct { + http *http.Client + accessToken string +} + +func NewExpoClient(accessToken string) *ExpoClient { + return &ExpoClient{ + http: &http.Client{Timeout: 15 * time.Second}, + accessToken: accessToken, + } +} + +type expoSendResponse struct { + Data []Ticket `json:"data"` + Errors []map[string]any `json:"errors,omitempty"` +} + +// Send batches msgs (cap expoMaxBatchSize) and preserves input order: +// tickets[i] corresponds to msgs[i]. A request-level failure aborts the +// remaining batches; tickets already collected are returned with the error. +func (c *ExpoClient) Send(ctx context.Context, msgs []Message) ([]Ticket, error) { + if len(msgs) == 0 { + return nil, nil + } + + for i := range msgs { + if msgs[i].Sound == "" { + msgs[i].Sound = "default" + } + } + + tickets := make([]Ticket, 0, len(msgs)) + for start := 0; start < len(msgs); start += expoMaxBatchSize { + end := start + expoMaxBatchSize + if end > len(msgs) { + end = len(msgs) + } + + batch := msgs[start:end] + batchTickets, err := c.sendBatch(ctx, batch) + tickets = append(tickets, batchTickets...) + if err != nil { + return tickets, fmt.Errorf("expo push batch [%d:%d]: %w", start, end, err) + } + } + return tickets, nil +} + +func (c *ExpoClient) sendBatch(ctx context.Context, batch []Message) ([]Ticket, error) { + body, err := json.Marshal(batch) + if err != nil { + return nil, fmt.Errorf("marshal batch: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, expoPushAPIURL, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("build request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("Accept-Encoding", "gzip, deflate") + if c.accessToken != "" { + req.Header.Set("Authorization", "Bearer "+c.accessToken) + } + + resp, err := c.http.Do(req) + if err != nil { + return nil, fmt.Errorf("do request: %w", err) + } + defer resp.Body.Close() + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read response: %w", err) + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("expo push api returned %d: %s", resp.StatusCode, truncate(string(raw), 512)) + } + + var parsed expoSendResponse + if err := json.Unmarshal(raw, &parsed); err != nil { + return nil, fmt.Errorf("decode response: %w", err) + } + if len(parsed.Data) != len(batch) { + return parsed.Data, fmt.Errorf("expo returned %d tickets for %d messages", len(parsed.Data), len(batch)) + } + return parsed.Data, nil +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "…" +} diff --git a/go/internal/human/pushnotify/notifier.go b/go/internal/human/pushnotify/notifier.go new file mode 100644 index 0000000..ee5be37 --- /dev/null +++ b/go/internal/human/pushnotify/notifier.go @@ -0,0 +1,180 @@ +package pushnotify + +import ( + "context" + "errors" + "fmt" + "log/slog" + + "github.com/flowy-live/llink/internal/network" +) + +type NotifyInput struct { + NetworkID string + SenderHumanID string + SenderEmailPrefix string + + ParticleID string + // One of "text", "media", "file", "quest", "paper". Containers (stream, + // folder) are dropped by the caller before reaching the notifier. + ParticleKind string + + // Parent stream context — drives the title and the recipient set. + StreamID string + StreamName string + StreamVisibleTo []string + + // Body — already formatted by the caller (e.g. truncated text, "Sent a + // voice message"). Title is derived inside the notifier. + Body string +} + +// Notifier fans out one particle to Expo: +// 1. Resolve recipients (visibility ∩ network members, minus sender). +// 2. Send a batched Expo request for every recipient's tokens. +// 3. Prune tokens Expo reports as DeviceNotRegistered. +// +// Online/offline presence is intentionally NOT consulted: a live WebSocket +// is a poor proxy for "user is actively consuming this particle right now" +// (backgrounded apps, idle desktops, etc. all look online), and the resulting +// false-negatives outweigh the duplicate-notification cost on a focused +// device, which the OS handles via Focus modes and per-app settings. +type Notifier struct { + networkR network.Reader + tokens Service + expo *ExpoClient +} + +func NewNotifier(networkR network.Reader, tokens Service, expo *ExpoClient) *Notifier { + return &Notifier{ + networkR: networkR, + tokens: tokens, + expo: expo, + } +} + +func (n *Notifier) NotifyParticleCreated(ctx context.Context, in NotifyInput) error { + if in.NetworkID == "" || in.ParticleID == "" { + slog.Info("pushnotify: skip — missing ids", + "networkID", in.NetworkID, + "particleID", in.ParticleID, + ) + return nil + } + + members, err := n.networkR.ListMembers(ctx, in.NetworkID) + if err != nil { + return fmt.Errorf("list network members: %w", err) + } + + recipients := network.ResolveVisibility(in.StreamVisibleTo, members) + recipientsBeforeSenderFilter := len(recipients) + recipients = filterOut(recipients, in.SenderHumanID) + if len(recipients) == 0 { + slog.Info("pushnotify: skip — no recipients", + "networkID", in.NetworkID, + "particleID", in.ParticleID, + "senderHumanID", in.SenderHumanID, + "members", len(members), + "visibleTo", in.StreamVisibleTo, + "resolved", recipientsBeforeSenderFilter, + ) + return nil + } + + tokens, err := n.tokens.ListForHumans(ctx, recipients) + if err != nil { + return fmt.Errorf("token lookup: %w", err) + } + if len(tokens) == 0 { + slog.Info("pushnotify: skip — no tokens for recipients", + "networkID", in.NetworkID, + "particleID", in.ParticleID, + "recipients", len(recipients), + "recipientIDs", recipients, + ) + return nil + } + + msgs := buildMessages(tokens, in) + tickets, sendErr := n.expo.Send(ctx, msgs) + slog.Info("pushnotify: dispatch", + "networkID", in.NetworkID, + "particleID", in.ParticleID, + "recipients", len(recipients), + "tokens", len(tokens), + "sent", len(tickets), + ) + + n.cleanupDeadTokens(ctx, msgs, tickets) + + if sendErr != nil { + return fmt.Errorf("expo send: %w", sendErr) + } + return nil +} + +// DeviceNotRegistered is the one feedback signal we honor; other ticket +// errors (MessageTooBig, RateLimit, …) are logged and dropped. +func (n *Notifier) cleanupDeadTokens(ctx context.Context, msgs []Message, tickets []Ticket) { + for i, t := range tickets { + if i >= len(msgs) { + break + } + if t.Status != "error" || t.Details == nil { + continue + } + code, _ := t.Details["error"].(string) + if code != ExpoErrorDeviceNotRegistered { + if t.Status == "error" { + slog.Warn("pushnotify: ticket error", "code", code, "message", t.Message, "to", msgs[i].To) + } + continue + } + if err := n.tokens.DeleteByToken(ctx, msgs[i].To); err != nil && !errors.Is(err, ErrNotFound) { + slog.Error("pushnotify: failed to delete dead token", "error", err, "token", msgs[i].To) + } else { + slog.Info("pushnotify: removed unregistered token", "token", msgs[i].To) + } + } +} + +func buildMessages(tokens []*PushToken, in NotifyInput) []Message { + title := in.SenderEmailPrefix + if in.StreamName != "" { + title = in.SenderEmailPrefix + " in " + in.StreamName + } + + data := map[string]any{ + "kind": "particle_created", + "network_id": in.NetworkID, + "stream_id": in.StreamID, + "particle_id": in.ParticleID, + "sender_human_id": in.SenderHumanID, + "particle_kind": in.ParticleKind, + } + + msgs := make([]Message, 0, len(tokens)) + for _, t := range tokens { + msgs = append(msgs, Message{ + To: t.Token, + Title: title, + Body: in.Body, + Data: data, + }) + } + return msgs +} + +func filterOut(ids []string, exclude string) []string { + if exclude == "" { + return ids + } + out := ids[:0:len(ids)] + for _, id := range ids { + if id != exclude { + out = append(out, id) + } + } + return out +} diff --git a/go/internal/human/pushnotify/repository.go b/go/internal/human/pushnotify/repository.go new file mode 100644 index 0000000..7f24bba --- /dev/null +++ b/go/internal/human/pushnotify/repository.go @@ -0,0 +1,95 @@ +package pushnotify + +import ( + "context" + "errors" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +type repository interface { + upsert(ctx context.Context, t *PushToken) error + deleteForHuman(ctx context.Context, humanID, token string) error + deleteByToken(ctx context.Context, token string) error + listForHumans(ctx context.Context, humanIDs []string) ([]*PushToken, error) +} + +type repositoryImpl struct { + pool *pgxpool.Pool +} + +func newRepository(pool *pgxpool.Pool) repository { + return &repositoryImpl{pool: pool} +} + +func (r *repositoryImpl) upsert(ctx context.Context, t *PushToken) error { + _, err := r.pool.Exec(ctx, + `INSERT INTO push_tokens (token, human_id, platform, app_version) + VALUES ($1, $2, $3, NULLIF($4, '')) + ON CONFLICT (token) DO UPDATE SET + human_id = EXCLUDED.human_id, + platform = EXCLUDED.platform, + app_version = EXCLUDED.app_version, + last_seen_at = NOW()`, + t.Token, t.HumanID, string(t.Platform), t.AppVersion, + ) + return err +} + +func (r *repositoryImpl) deleteForHuman(ctx context.Context, humanID, token string) error { + result, err := r.pool.Exec(ctx, + `DELETE FROM push_tokens WHERE human_id = $1 AND token = $2`, + humanID, token, + ) + if err != nil { + return err + } + if result.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + +func (r *repositoryImpl) deleteByToken(ctx context.Context, token string) error { + _, err := r.pool.Exec(ctx, + `DELETE FROM push_tokens WHERE token = $1`, + token, + ) + return err +} + +func (r *repositoryImpl) listForHumans(ctx context.Context, humanIDs []string) ([]*PushToken, error) { + if len(humanIDs) == 0 { + return nil, nil + } + rows, err := r.pool.Query(ctx, + `SELECT token, human_id, platform, app_version, created_at, last_seen_at + FROM push_tokens + WHERE human_id = ANY($1)`, + humanIDs, + ) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + return nil, err + } + defer rows.Close() + + var tokens []*PushToken + for rows.Next() { + var t PushToken + var appVersion *string + var platform string + if err := rows.Scan(&t.Token, &t.HumanID, &platform, &appVersion, &t.CreatedAt, &t.LastSeenAt); err != nil { + return nil, err + } + t.Platform = Platform(platform) + if appVersion != nil { + t.AppVersion = *appVersion + } + tokens = append(tokens, &t) + } + return tokens, rows.Err() +} diff --git a/go/internal/human/pushnotify/service.go b/go/internal/human/pushnotify/service.go new file mode 100644 index 0000000..d049fdb --- /dev/null +++ b/go/internal/human/pushnotify/service.go @@ -0,0 +1,66 @@ +package pushnotify + +import ( + "context" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// Service stores per-device Expo push tokens and exposes the operations +// needed by both the HTTP handlers and the worker-side notifier. +type Service interface { + // Register returns ErrInvalidToken / ErrInvalidPlatform on bad input. + Register(ctx context.Context, humanID string, in RegisterInput) error + // Unregister is scoped to humanID so a user can't delete another user's + // token. Returns ErrNotFound if the token isn't owned by humanID. + Unregister(ctx context.Context, humanID, token string) error + // ListForHumans returns an empty slice when nothing matches. + ListForHumans(ctx context.Context, humanIDs []string) ([]*PushToken, error) + // DeleteByToken removes a token regardless of owner — used to prune after + // Expo reports DeviceNotRegistered. + DeleteByToken(ctx context.Context, token string) error +} + +type RegisterInput struct { + Token string + Platform Platform + AppVersion string +} + +type serviceImpl struct { + repo repository +} + +func NewService(pool *pgxpool.Pool) Service { + return &serviceImpl{repo: newRepository(pool)} +} + +func (s *serviceImpl) Register(ctx context.Context, humanID string, in RegisterInput) error { + if !in.Platform.Valid() { + return ErrInvalidPlatform + } + if !IsValidExpoToken(in.Token) { + return ErrInvalidToken + } + return s.repo.upsert(ctx, &PushToken{ + Token: in.Token, + HumanID: humanID, + Platform: in.Platform, + AppVersion: in.AppVersion, + }) +} + +func (s *serviceImpl) Unregister(ctx context.Context, humanID, token string) error { + if token == "" { + return ErrInvalidToken + } + return s.repo.deleteForHuman(ctx, humanID, token) +} + +func (s *serviceImpl) ListForHumans(ctx context.Context, humanIDs []string) ([]*PushToken, error) { + return s.repo.listForHumans(ctx, humanIDs) +} + +func (s *serviceImpl) DeleteByToken(ctx context.Context, token string) error { + return s.repo.deleteByToken(ctx, token) +} diff --git a/go/internal/human/pushnotify/token.go b/go/internal/human/pushnotify/token.go new file mode 100644 index 0000000..0bdc0f3 --- /dev/null +++ b/go/internal/human/pushnotify/token.go @@ -0,0 +1,43 @@ +// Package pushnotify owns mobile push notification delivery: storage of per-device +// Expo push tokens, and the worker-side orchestration of sending notifications +// to offline recipients via the Expo Push API. +package pushnotify + +import ( + "errors" + "strings" + "time" +) + +type Platform string + +const ( + PlatformIOS Platform = "ios" + PlatformAndroid Platform = "android" +) + +func (p Platform) Valid() bool { + return p == PlatformIOS || p == PlatformAndroid +} + +type PushToken struct { + Token string + HumanID string + Platform Platform + AppVersion string + CreatedAt time.Time + LastSeenAt time.Time +} + +var ( + ErrInvalidPlatform = errors.New("invalid platform") + ErrInvalidToken = errors.New("invalid expo push token") + ErrNotFound = errors.New("push token not found") +) + +// IsValidExpoToken matches the two prefix formats Expo currently uses. +// We don't validate the inner contents — Expo's server will reject malformed +// tokens with a per-message error and we'll clean those up via DeviceNotRegistered. +func IsValidExpoToken(token string) bool { + return strings.HasPrefix(token, "ExponentPushToken[") || strings.HasPrefix(token, "ExpoPushToken[") +} diff --git a/go/internal/human/service.go b/go/internal/human/service.go index 211805c..2180443 100644 --- a/go/internal/human/service.go +++ b/go/internal/human/service.go @@ -13,15 +13,12 @@ var ErrNotFound = errors.New("human not found") type Service interface { GetOrCreateByEmail(ctx context.Context, email string) (*Human, error) - // GetByEmail returns ErrNotFound if no human found + // GetByEmail returns ErrNotFound if no human found. GetByEmail(ctx context.Context, email string) (*Human, error) - // GetByID returns ErrNotFound if no human found + // GetByID returns ErrNotFound if no human found. GetByID(ctx context.Context, id string) (*Human, error) - // ListAll returns all humans ListAll(ctx context.Context) ([]*Human, error) - // UpdateEmailNotificationsEnabled toggles email notification preference UpdateEmailNotificationsEnabled(ctx context.Context, id string, enabled bool) error - // UpdateLastEmailNotificationSentAt records when the last notification email was sent UpdateLastEmailNotificationSentAt(ctx context.Context, id string, t time.Time) error } diff --git a/go/internal/livekit/client.go b/go/internal/livekit/client.go index 8cce5eb..1d278b9 100644 --- a/go/internal/livekit/client.go +++ b/go/internal/livekit/client.go @@ -11,13 +11,11 @@ import ( ) type Client interface { - // GetJoinToken generates a JWT for a participant to join a room. - // Name will show up in the participant data. + // GetJoinToken mints a participant JWT; name surfaces as the display name. GetJoinToken(roomId string, humanId string, name string) (string, error) ServerUrl() string - // ListParticipants returns the current participants in a room. ListParticipants(ctx context.Context, roomName string) ([]*livekit.ParticipantInfo, error) - // KeyProvider returns the key provider for verifying webhook signatures. + // KeyProvider is used by handlers to verify webhook signatures. KeyProvider() auth.KeyProvider } diff --git a/go/internal/livestore/membershippublisher.go b/go/internal/livestore/membershippublisher.go index a9d47f3..e4c2f72 100644 --- a/go/internal/livestore/membershippublisher.go +++ b/go/internal/livestore/membershippublisher.go @@ -8,10 +8,9 @@ import ( //go:generate go tool mockgen -source ./membershippublisher.go -destination ./mocks/membershippublisher.go -// MembershipPublisher publishes network membership changes to the live store -// (Firestore) that clients subscribe to. Postgres remains the source of truth; -// the membership reconciler heals any drift, so callers may log and ignore -// publish failures. +// MembershipPublisher fans network membership changes out to Firestore. +// Postgres is the source of truth; the reconciler heals drift, so publish +// failures are safe to log and ignore. type MembershipPublisher interface { Add(ctx context.Context, humanId, networkID string) error Remove(ctx context.Context, humanId, networkID string) error diff --git a/go/internal/media/helpers.go b/go/internal/media/helpers.go index 08eb06b..d75c33c 100644 --- a/go/internal/media/helpers.go +++ b/go/internal/media/helpers.go @@ -30,17 +30,15 @@ type TranscodeInput struct { type TranscodeOutput struct { TempLocalFilePath string OutputMimeType string - // Extension such as ".m4a" or ".mp4" - OutputExt string + OutputExt string // e.g. ".m4a" or ".mp4" } var ( ErrInvalidInput error = errors.New("invalid input") ) -// TranscodeToMp4 takes in any audio or video source URL and -// returns the filepath of the transcoded media -// WARNING: caller responsible for deleting TempLocalFilePath +// TranscodeToMp4 writes the result to a temp file; caller is responsible +// for deleting TempLocalFilePath. func TranscodeToMp4(ctx context.Context, input TranscodeInput) (*TranscodeOutput, error) { if input.SourceURL == "" || input.MimeType == "" { return nil, ErrInvalidInput @@ -74,11 +72,9 @@ func TranscodeToMp4(ctx context.Context, input TranscodeInput) (*TranscodeOutput tmpPath, } } else { - // Cap encoder parallelism and lookahead to keep memory bounded — screen - // recordings come in at native display resolution (often 1440p–4K) and - // libx264's per-thread lookahead/reference buffers blow past the worker's - // memory limit otherwise. Output is also downscaled to 1080p max, which - // mobile playback won't notice; the original WebM stays in GCS untouched. + // 1440p–4K screen recordings + libx264's lookahead buffers can OOM the + // worker. Bound parallelism/lookahead and downscale to 1080p; the + // original WebM stays in GCS untouched. args = []string{ "-y", "-i", input.SourceURL, "-vf", "scale='min(1920,iw)':-2:flags=lanczos", diff --git a/go/internal/middleware/auth.go b/go/internal/middleware/auth.go index f061586..7dedfec 100644 --- a/go/internal/middleware/auth.go +++ b/go/internal/middleware/auth.go @@ -17,40 +17,35 @@ const ( isAdminContextKey contextKey = "isAdmin" ) -// WithEmail adds the email to the context func WithEmail(ctx context.Context, email string) context.Context { return context.WithValue(ctx, emailContextKey, email) } -// EmailFromContext extracts the email from the context func EmailFromContext(ctx context.Context) (string, bool) { email, ok := ctx.Value(emailContextKey).(string) return email, ok } -// WithHumanId adds the humanId to the context func WithHumanId(ctx context.Context, humanId string) context.Context { return context.WithValue(ctx, humanIdContextKey, humanId) } -// HumanIdFromContext extracts the id from the context func HumanIdFromContext(ctx context.Context) (string, bool) { humanId, ok := ctx.Value(humanIdContextKey).(string) return humanId, ok } -// WithIsAdmin adds the admin flag to the context func WithIsAdmin(ctx context.Context, isAdmin bool) context.Context { return context.WithValue(ctx, isAdminContextKey, isAdmin) } -// IsAdminFromContext extracts the admin flag from the context func IsAdminFromContext(ctx context.Context) bool { isAdmin, ok := ctx.Value(isAdminContextKey).(bool) return ok && isAdmin } -// Auth returns a middleware that validates the session token and adds the email to the context +// Auth validates the bearer session token and populates email/humanId/isAdmin +// into the request context for downstream handlers. func Auth(authSvc auth.AuthService) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -66,7 +61,6 @@ func Auth(authSvc auth.AuthService) func(http.Handler) http.Handler { return } - // Auto-extend session if err := authSvc.ExtendSession(r.Context(), token); err != nil { slog.Warn("failed to extend session", "error", err) } @@ -79,7 +73,6 @@ func Auth(authSvc auth.AuthService) func(http.Handler) http.Handler { } } -// extractBearerToken extracts the token from the Authorization header func extractBearerToken(r *http.Request) string { authHeader := r.Header.Get("Authorization") if authHeader == "" { diff --git a/go/internal/middleware/cors.go b/go/internal/middleware/cors.go index f790338..f435de5 100644 --- a/go/internal/middleware/cors.go +++ b/go/internal/middleware/cors.go @@ -2,7 +2,7 @@ package middleware import "net/http" -// CORS wraps a handler to add CORS headers and handle preflight requests. +// CORS adds CORS headers and short-circuits preflight requests. func CORS(allowedOrigins []string) func(http.Handler) http.Handler { originSet := make(map[string]struct{}, len(allowedOrigins)) for _, o := range allowedOrigins { @@ -13,7 +13,7 @@ func CORS(allowedOrigins []string) func(http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { origin := r.Header.Get("Origin") - // Check if the origin is allowed (empty allowedOrigins means allow all) + // Empty allowedOrigins means allow all. allowed := len(originSet) == 0 if !allowed { _, allowed = originSet[origin] @@ -27,7 +27,6 @@ func CORS(allowedOrigins []string) func(http.Handler) http.Handler { w.Header().Set("Access-Control-Max-Age", "86400") } - // Handle preflight if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) return diff --git a/go/internal/network/reader.go b/go/internal/network/reader.go index d886533..8068c00 100644 --- a/go/internal/network/reader.go +++ b/go/internal/network/reader.go @@ -16,11 +16,11 @@ type Reader interface { ListForHuman(ctx context.Context, humanId string) ([]*Network, error) // IsMember returns ErrInvalidHumanId if humanId is empty. IsMember(ctx context.Context, networkID, humanId string) (bool, error) - // ListAll returns all networks with their members + // ListMembers returns an empty slice if the network doesn't exist. + ListMembers(ctx context.Context, networkID string) ([]string, error) ListAll(ctx context.Context) ([]*Network, error) // ListAllMemberships returns humanId -> networkIds for every human with at // least one membership. Humans with zero memberships are absent from the map. - // Used by the membership reconciler to diff the Firestore mirror. ListAllMemberships(ctx context.Context) (map[string][]string, error) CountSeats(ctx context.Context, networkID string) (int, error) @@ -34,8 +34,8 @@ type readerImpl struct { repo repository } -// newReader returns the concrete reader. Used by NewService to embed without -// going through the Reader interface (which would hide pool/repo). +// newReader exposes the concrete type so the service can embed it without +// hiding pool/repo behind the Reader interface. func newReader(pool *pgxpool.Pool) *readerImpl { return &readerImpl{ pool: pool, @@ -69,6 +69,12 @@ func (r *readerImpl) IsMember(ctx context.Context, networkID, humanId string) (b return r.repo.isMember(ctx, networkID, humanId) } +func (r *readerImpl) ListMembers(ctx context.Context, networkID string) ([]string, error) { + // Admin is guaranteed to be in network_members: Create() calls AddMembers + // for the admin, and RemoveMemberFromNetwork rejects admin removal. + return r.repo.getMemberHumanIds(ctx, networkID) +} + func (r *readerImpl) ListAll(ctx context.Context) ([]*Network, error) { return r.repo.listAll(ctx) } diff --git a/go/internal/network/repository.go b/go/internal/network/repository.go index f7a5544..78359a9 100644 --- a/go/internal/network/repository.go +++ b/go/internal/network/repository.go @@ -10,9 +10,8 @@ import ( "go.jetify.com/typeid" ) -// dbtx is the subset of pgx's query API shared by *pgxpool.Pool and pgx.Tx. -// Used by repository helpers that the service layer may run either standalone -// (against the pool) or inside a transaction. +// dbtx is the subset of pgx's query API shared by *pgxpool.Pool and pgx.Tx, +// so repository helpers can run standalone or inside a transaction. type dbtx interface { Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error) Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error) @@ -57,8 +56,7 @@ type repository interface { deleteInvitation(ctx context.Context, db dbtx, networkID, email string) error } -// networkColumns lists every column selected when hydrating a Network. -// Centralized to keep SELECTs and Scan() calls in sync. +// Centralized so SELECTs and scanNetwork stay in sync. const networkColumns = `id, name, admin_human_id, created_at` func scanNetwork(row pgx.Row, n *Network) error { @@ -283,8 +281,6 @@ func (r *repositoryImpl) listAll(ctx context.Context) ([]*Network, error) { return networks, nil } -// Invitation methods - func (r *repositoryImpl) createInvitation(ctx context.Context, networkID, email string) error { _, err := r.pool.Exec(ctx, `INSERT INTO network_invitations (network_id, email) VALUES ($1, $2) diff --git a/go/internal/network/service.go b/go/internal/network/service.go index 8cacac9..734a7bd 100644 --- a/go/internal/network/service.go +++ b/go/internal/network/service.go @@ -28,7 +28,7 @@ var ErrInvalidRetentionHours = errors.New("message retention hours must be betwe type Service interface { Reader - // Create creates a network and adds adminHumanId as the first member. Returns ErrInvalidName if name is empty. + // Create adds adminHumanId as the first member. Returns ErrInvalidName if name is empty. Create(ctx context.Context, name, adminHumanId string) (*Network, error) // SetName returns ErrNotFound or ErrInvalidName. SetName(ctx context.Context, id, name string) error @@ -145,10 +145,8 @@ func (s *serviceImpl) RemoveMember(ctx context.Context, networkID, humanId strin return nil } -// mirrorAddMembership / mirrorRemoveMembership keep the live store membership -// projection (humans/{humanId}.networks) in sync with Postgres. Called after -// the Postgres transaction commits. Failures are logged but not returned: -// Postgres is the source of truth and the reconciler will heal drift. +// Mirror the live store membership projection (humans/{humanId}.networks). +// Postgres is the source of truth: failures are logged and the reconciler heals drift. func (s *serviceImpl) mirrorAddMembership(ctx context.Context, humanId, networkID string) { if err := s.pub.Add(ctx, humanId, networkID); err != nil { slog.Error("membership publish add failed", "error", err, "humanId", humanId, "networkID", networkID) @@ -161,8 +159,7 @@ func (s *serviceImpl) mirrorRemoveMembership(ctx context.Context, humanId, netwo } } -// mutateMembers runs fn in a tx, recounts seats, calls billing.SyncSeats, -// and commits. Any error rolls the membership change back. +// Runs fn in a tx and syncs seats to billing atomically. Any error rolls back. func (s *serviceImpl) mutateMembers(ctx context.Context, networkID string, fn func(pgx.Tx) error) error { tx, err := s.pool.Begin(ctx) if err != nil { diff --git a/go/internal/network/visibility.go b/go/internal/network/visibility.go new file mode 100644 index 0000000..d5c172b --- /dev/null +++ b/go/internal/network/visibility.go @@ -0,0 +1,41 @@ +package network + +import "strings" + +// ResolveVisibility expands a stream particle's visible_to entries into the set +// of human IDs that should see (and thus be notified about) activity in that +// stream. Entries are formatted as `human:{id}` for a specific human or +// `network:{id}` to expand to every member of the surrounding network. +// +// networkMembers must contain every human currently in the network (members + +// admin). visible_to entries that point to humans no longer in the network are +// dropped — they may have been removed since the stream was created. +// +// Returns a deduped slice; ordering is not stable. +func ResolveVisibility(visibleTo []string, networkMembers []string) []string { + memberSet := make(map[string]bool, len(networkMembers)) + for _, id := range networkMembers { + memberSet[id] = true + } + + result := make(map[string]bool) + for _, entry := range visibleTo { + switch { + case strings.HasPrefix(entry, "human:"): + id := strings.TrimPrefix(entry, "human:") + if memberSet[id] { + result[id] = true + } + case strings.HasPrefix(entry, "network:"): + for id := range memberSet { + result[id] = true + } + } + } + + out := make([]string, 0, len(result)) + for id := range result { + out = append(out, id) + } + return out +} diff --git a/go/internal/particle/models.go b/go/internal/particle/models.go index 26e6a1b..0263e4b 100644 --- a/go/internal/particle/models.go +++ b/go/internal/particle/models.go @@ -6,7 +6,6 @@ import ( "time" ) -// ParticleType represents the type of particle type ParticleType string const ( @@ -20,7 +19,6 @@ const ( // TypeThink ParticleType = "think" ) -// VisibilityMode represents how access to a particle is determined type VisibilityMode string const ( @@ -32,7 +30,6 @@ const ( var ErrInvalidParticleType = errors.New("invalid particle type") var ErrInvalidVisibilityMode = errors.New("invalid visibility mode") -// ParseParticleType parses a string into a ParticleType func ParseParticleType(s string) (ParticleType, error) { switch s { case string(TypeStream): @@ -54,7 +51,6 @@ func ParseParticleType(s string) (ParticleType, error) { } } -// ParseVisibilityMode parses a string into a VisibilityMode func ParseVisibilityMode(s string) (VisibilityMode, error) { switch s { case "", string(VisibilityNetworkAll): @@ -68,7 +64,6 @@ func ParseVisibilityMode(s string) (VisibilityMode, error) { } } -// Stream status values type StreamStatus string const ( @@ -76,7 +71,6 @@ const ( StreamStatusClosed StreamStatus = "closed" ) -// Particle represents a content particle in the system type Particle struct { ID string Type ParticleType @@ -89,7 +83,6 @@ type Particle struct { CreatedAt time.Time } -// CreateInput represents the input for creating a new particle type CreateInput struct { Type ParticleType NetworkID string @@ -99,18 +92,15 @@ type CreateInput struct { Visibility VisibilityMode } -// ListFilter represents filtering options for listing particles type ListFilter struct { Types []ParticleType } -// Cursor represents a pagination cursor for bidirectional pagination type Cursor struct { Position string // particle ID or timestamp Direction string // "before" or "after" } -// ParticleList represents a paginated list of particles type ParticleList struct { Particles []*Particle HasMore bool @@ -118,57 +108,48 @@ type ParticleList struct { PrevCursor *Cursor } -// StreamData represents the data stored for stream particles type StreamData struct { Name string `json:"name"` Status string `json:"status"` // "open" or "closed" Description *string `json:"description"` } -// FolderData represents the data stored for folder particles type FolderData struct { Name string `json:"name"` Color *string `json:"color"` } -// MediaData represents the data stored for media particles type MediaData struct { - ObjectID string `json:"object_id"` // reference to storage object + ObjectID string `json:"object_id"` MimeType string `json:"mime_type"` DurationMs int `json:"duration_ms"` - // Caption *string `json:"caption"` } -// FileData represents the data stored for file particles type FileData struct { - ObjectID string `json:"object_id"` // reference to storage object + ObjectID string `json:"object_id"` Filename string `json:"filename"` MimeType string `json:"mime_type"` - Size int64 `json:"size"` // in bytes + Size int64 `json:"size"` // bytes } -// TextData represents the data stored for text particles type TextData struct { Content string `json:"content"` } -// QuestData represents the data stored for quest particles type QuestData struct { Title string `json:"title"` Description string `json:"description"` Done bool `json:"done"` Status *string `json:"status"` AssignedTo *string `json:"assigned_to,omitempty"` // email - DueDate *string `json:"due_date,omitempty"` // ISO date string + DueDate *string `json:"due_date,omitempty"` // ISO date } -// PaperData represents the data stored for paper particles type PaperData struct { Title string `json:"title"` Content string `json:"content"` // markdown } -// AckInfo represents an acknowledgment record type AckInfo struct { Email string AckedAt time.Time diff --git a/go/internal/particle/networkmembershipchecker.go b/go/internal/particle/networkmembershipchecker.go index 7886d1e..bb317b5 100644 --- a/go/internal/particle/networkmembershipchecker.go +++ b/go/internal/particle/networkmembershipchecker.go @@ -5,6 +5,5 @@ import "context" //go:generate go tool mockgen -source ./networkmembershipchecker.go -destination ./mocks/networkmembershipchecker.go type NetworkMembershipChecker interface { - // IsMember returns true if the humanId is a member of the network. IsMember(ctx context.Context, networkID, humanId string) (bool, error) } diff --git a/go/internal/particle/repository.go b/go/internal/particle/repository.go index 7b8089f..8eee46d 100644 --- a/go/internal/particle/repository.go +++ b/go/internal/particle/repository.go @@ -40,7 +40,7 @@ type repository interface { getMembers(ctx context.Context, particleID string) ([]string, error) getMembersMap(ctx context.Context, particleIDs []string) (map[string][]string, error) - // getAncestorChain returns the particle and all its ancestors (for access checks) + // getAncestorChain returns the particle followed by its ancestors, in order. getAncestorChain(ctx context.Context, particleID string) ([]*Particle, error) isMemberOf(ctx context.Context, particleID, email string) (bool, error) diff --git a/go/internal/particle/service.go b/go/internal/particle/service.go index 80b2d92..0d1c882 100644 --- a/go/internal/particle/service.go +++ b/go/internal/particle/service.go @@ -13,45 +13,46 @@ import ( const defaultPageSize = 50 -// NOTE: this service is deprecated as we use firestore for particle data +// Deprecated: particle data now lives in Firestore. The Postgres-backed +// service is retained only for legacy paths. type Service interface { - // Create creates a new particle. Caller must be a network member (verified by handler). - // Returns ErrInvalidType, ErrInvalidData, ErrMembersRequired, ErrInvalidParent, ErrAccessDenied, or ErrCapacityExceeded. + // Create returns ErrInvalidType, ErrInvalidData, ErrMembersRequired, + // ErrInvalidParent, ErrAccessDenied, or ErrCapacityExceeded. Network + // membership is verified by the handler. Create(ctx context.Context, input CreateInput, requesterEmail string) (*Particle, error) // GetByID returns ErrNotFound or ErrAccessDenied. GetByID(ctx context.Context, id, requesterEmail string) (*Particle, error) - // Update updates the particle's data. Returns ErrNotFound, ErrAccessDenied, or ErrInvalidData. + // Update returns ErrNotFound, ErrAccessDenied, or ErrInvalidData. Update(ctx context.Context, id string, data json.RawMessage, requesterEmail string) (*Particle, error) // Delete returns ErrNotFound or ErrAccessDenied. Delete(ctx context.Context, id, requesterEmail string) error - // List returns particles in a network. Use parentID=nil for root particles. - // Returns ErrNotFound or ErrAccessDenied if parentID is specified and inaccessible. + // List uses parentID=nil for root particles. Returns ErrNotFound or + // ErrAccessDenied when parentID is given but inaccessible. List(ctx context.Context, networkID string, parentID *string, requesterEmail string, filter ListFilter, cursor *Cursor, limit int) (*ParticleList, error) - // OpenStream opens a closed stream. Returns ErrNotFound, ErrAccessDenied, ErrNotAStream, ErrStreamAlreadyOpen, or ErrCapacityExceeded. + // OpenStream returns ErrNotFound, ErrAccessDenied, ErrNotAStream, + // ErrStreamAlreadyOpen, or ErrCapacityExceeded. OpenStream(ctx context.Context, id, requesterEmail string) error - // CloseStream closes an open stream. Returns ErrNotFound, ErrAccessDenied, ErrNotAStream, or ErrStreamAlreadyClosed. + // CloseStream returns ErrNotFound, ErrAccessDenied, ErrNotAStream, or + // ErrStreamAlreadyClosed. CloseStream(ctx context.Context, id, requesterEmail string) error - // SetVisibility changes the particle's visibility mode. Returns ErrNotFound, ErrAccessDenied, or ErrAccessExpansion. + // SetVisibility returns ErrNotFound, ErrAccessDenied, or ErrAccessExpansion. SetVisibility(ctx context.Context, id string, mode VisibilityMode, requesterEmail string) error - // AddMembers adds members to a custom visibility particle. Returns ErrNotFound or ErrAccessDenied. + // AddMembers / RemoveMembers operate on custom-visibility streams only. + // Both return ErrNotFound or ErrAccessDenied. AddMembers(ctx context.Context, id string, emails []string, requesterEmail string) error - // RemoveMembers removes members from a custom visibility particle. Returns ErrNotFound or ErrAccessDenied. RemoveMembers(ctx context.Context, id string, emails []string, requesterEmail string) error - // Seen tracking (private) + // Seen tracking is private per human; Ack is public and permanent. MarkSeen(ctx context.Context, id, requesterEmail string) error MarkSeenBatch(ctx context.Context, ids []string, requesterEmail string) error - - // Ack tracking (public, permanent) Ack(ctx context.Context, id, requesterEmail string) error - // Unseen counts for stream list view GetUnseenCounts(ctx context.Context, networkID string, streamIDs []string, requesterEmail string) (map[string]int, error) - // Bulk lookups for handler enrichment + // Bulk lookups for batch hydration. GetSeenMap(ctx context.Context, particleIDs []string, requesterEmail string) (map[string]bool, error) GetAcksMap(ctx context.Context, particleIDs []string) (map[string][]AckInfo, error) GetMembersMap(ctx context.Context, particleIDs []string) (map[string][]string, error) @@ -69,10 +70,9 @@ func NewService(pool *pgxpool.Pool, networkReader NetworkMembershipChecker) Serv } } -// checkAccess verifies that the email has access to the particle based on visibility. -// Assumes the caller is already verified as a network member (handler responsibility). -// Walks up the ancestor chain only when visibility is inherited, stopping at the first -// network_all or custom node. +// Walks the ancestor chain when visibility is inherited, stopping at the +// first network_all or custom node. Assumes network membership is already +// verified by the handler. func (s *serviceImpl) checkAccess(ctx context.Context, particleID, email string) (bool, error) { ancestors, err := s.repo.getAncestorChain(ctx, particleID) if err != nil { @@ -83,13 +83,12 @@ func (s *serviceImpl) checkAccess(ctx context.Context, particleID, email string) return false, errNotFound } - // Build lookup map by ID byID := make(map[string]*Particle, len(ancestors)) for _, p := range ancestors { byID[p.ID] = p } - // Start from the target particle (first in chain) and walk up on inherited + // ancestors[0] is the target; walk up only on inherited. current := ancestors[0] for { switch current.Visibility { @@ -99,7 +98,7 @@ func (s *serviceImpl) checkAccess(ctx context.Context, particleID, email string) return s.repo.isMemberOf(ctx, current.ID, email) case VisibilityInherited: if current.ParentID == nil { - // inherited at root is invalid state, deny access + // inherited-at-root is invalid; deny. return false, nil } parent, ok := byID[*current.ParentID] @@ -119,30 +118,24 @@ func (s *serviceImpl) Create(ctx context.Context, input CreateInput, requesterEm return nil, err } - // Validate particle type if !isValidParticleType(input.Type) { return nil, ErrInvalidType } - // Validate data matches type requirements if err := validateParticleData(input.Type, input.Data); err != nil { return nil, err } - // MVP visibility rules: - // - Child particles (have parent) → always inherited - // - Root particles (no parent) → cannot be inherited, default network_all + // MVP visibility: children always inherit; roots cannot inherit and + // default to network_all. Streams/folders are root-only. if input.ParentID != nil { - // Children always inherit from parent input.Visibility = VisibilityInherited - input.Members = nil // no members on inherited particles + input.Members = nil - // Reject streams and folders as children (MVP: streams are root-level only) if input.Type == TypeStream || input.Type == TypeFolder { return nil, ErrInvalidParent } } else { - // Root particles cannot be inherited if input.Visibility == VisibilityInherited { return nil, ErrInheritedAtRoot } @@ -151,7 +144,6 @@ func (s *serviceImpl) Create(ctx context.Context, input CreateInput, requesterEm } } - // Custom visibility requires at least one member and must be a stream var customMembers []string if input.Visibility == VisibilityCustom { if input.Type != TypeStream { @@ -161,7 +153,7 @@ func (s *serviceImpl) Create(ctx context.Context, input CreateInput, requesterEm return nil, ErrMembersRequired } - // Validate every supplied member against the network checker before touching the DB. + // Validate every member upfront so DB writes are all-or-nothing. customMembers = make([]string, 0, len(input.Members)+1) customMembers = append(customMembers, requesterEmail) seen := map[string]bool{requesterEmail: true} @@ -186,8 +178,7 @@ func (s *serviceImpl) Create(ctx context.Context, input CreateInput, requesterEm } } - // Network membership is verified by handler - we only check particle visibility - // If parent specified, check parent access (visibility-based) + // Network membership is verified by the handler; only particle visibility is checked here. if input.ParentID != nil { hasAccess, err := s.checkAccess(ctx, *input.ParentID, requesterEmail) if err != nil { @@ -201,7 +192,6 @@ func (s *serviceImpl) Create(ctx context.Context, input CreateInput, requesterEm } } - // Build the particle p := &Particle{ Type: input.Type, NetworkID: input.NetworkID, @@ -215,9 +205,8 @@ func (s *serviceImpl) Create(ctx context.Context, input CreateInput, requesterEm p.Data = json.RawMessage("{}") } - // For streams, set initial status to open and check capacity + // New streams default to open. if input.Type == TypeStream { - // Set status to open in the data JSON data, err := setStreamStatus(p.Data, string(StreamStatusOpen)) if err != nil { return nil, err @@ -225,13 +214,11 @@ func (s *serviceImpl) Create(ctx context.Context, input CreateInput, requesterEm p.Data = data } - // Create the particle created, err := s.repo.create(ctx, p) if err != nil { return nil, err } - // Add the pre-validated member list for custom visibility. if len(customMembers) > 0 { if err := s.repo.addMembers(ctx, created.ID, customMembers); err != nil { return nil, err @@ -247,7 +234,6 @@ func (s *serviceImpl) GetByID(ctx context.Context, id, requesterEmail string) (* return nil, err } - // Check access hasAccess, err := s.checkAccess(ctx, id, requesterEmail) if err != nil { if errors.Is(err, errNotFound) { @@ -275,7 +261,6 @@ func (s *serviceImpl) Update(ctx context.Context, id string, data json.RawMessag return nil, err } - // Check access hasAccess, err := s.checkAccess(ctx, id, requesterEmail) if err != nil { if errors.Is(err, errNotFound) { @@ -287,7 +272,6 @@ func (s *serviceImpl) Update(ctx context.Context, id string, data json.RawMessag return nil, ErrAccessDenied } - // Get the particle to validate data against its type p, err := s.repo.getByID(ctx, id) if err != nil { if errors.Is(err, errNotFound) { @@ -296,7 +280,6 @@ func (s *serviceImpl) Update(ctx context.Context, id string, data json.RawMessag return nil, err } - // Validate data matches type requirements if err := validateParticleData(p.Type, data); err != nil { return nil, err } @@ -318,7 +301,6 @@ func (s *serviceImpl) Delete(ctx context.Context, id, requesterEmail string) err return err } - // Check access hasAccess, err := s.checkAccess(ctx, id, requesterEmail) if err != nil { if errors.Is(err, errNotFound) { @@ -330,7 +312,6 @@ func (s *serviceImpl) Delete(ctx context.Context, id, requesterEmail string) err return ErrAccessDenied } - // Get the particle to check if it's an open stream _, err = s.repo.getByID(ctx, id) if err != nil { if errors.Is(err, errNotFound) { @@ -352,8 +333,7 @@ func (s *serviceImpl) List(ctx context.Context, networkID string, parentID *stri return nil, err } - // Network membership is verified by handler - we only check particle visibility - // If parentID specified, check access to parent (visibility-based) + // Network membership is verified by the handler; only particle visibility is checked here. if parentID != nil { hasAccess, err := s.checkAccess(ctx, *parentID, requesterEmail) if err != nil { @@ -367,8 +347,7 @@ func (s *serviceImpl) List(ctx context.Context, networkID string, parentID *stri } } - // Fetch one extra to determine if there are more - // Access filtering is done in the query itself (network_all OR user is member) + // Fetch limit+1 to detect a next page; visibility filtering lives in the query. if limit == 0 { limit = defaultPageSize } @@ -415,7 +394,6 @@ func (s *serviceImpl) OpenStream(ctx context.Context, id, requesterEmail string) return err } - // Check access hasAccess, err := s.checkAccess(ctx, id, requesterEmail) if err != nil { if errors.Is(err, errNotFound) { @@ -427,7 +405,6 @@ func (s *serviceImpl) OpenStream(ctx context.Context, id, requesterEmail string) return ErrAccessDenied } - // Get the particle p, err := s.repo.getByID(ctx, id) if err != nil { if errors.Is(err, errNotFound) { @@ -444,7 +421,6 @@ func (s *serviceImpl) OpenStream(ctx context.Context, id, requesterEmail string) return ErrStreamAlreadyOpen } - // Update stream status in data newData, err := setStreamStatus(p.Data, string(StreamStatusOpen)) if err != nil { return err @@ -467,7 +443,6 @@ func (s *serviceImpl) CloseStream(ctx context.Context, id, requesterEmail string return err } - // Check access hasAccess, err := s.checkAccess(ctx, id, requesterEmail) if err != nil { if errors.Is(err, errNotFound) { @@ -479,7 +454,6 @@ func (s *serviceImpl) CloseStream(ctx context.Context, id, requesterEmail string return ErrAccessDenied } - // Get the particle p, err := s.repo.getByID(ctx, id) if err != nil { if errors.Is(err, errNotFound) { @@ -496,7 +470,6 @@ func (s *serviceImpl) CloseStream(ctx context.Context, id, requesterEmail string return ErrStreamAlreadyClosed } - // Update stream status in data newData, err := setStreamStatus(p.Data, string(StreamStatusClosed)) if err != nil { return err @@ -519,7 +492,6 @@ func (s *serviceImpl) SetVisibility(ctx context.Context, id string, mode Visibil return err } - // Check access hasAccess, err := s.checkAccess(ctx, id, requesterEmail) if err != nil { if errors.Is(err, errNotFound) { @@ -531,7 +503,6 @@ func (s *serviceImpl) SetVisibility(ctx context.Context, id string, mode Visibil return ErrAccessDenied } - // Get the particle to check constraints p, err := s.repo.getByID(ctx, id) if err != nil { if errors.Is(err, errNotFound) { @@ -540,12 +511,11 @@ func (s *serviceImpl) SetVisibility(ctx context.Context, id string, mode Visibil return err } - // Root particles cannot be inherited if mode == VisibilityInherited && p.ParentID == nil { return ErrInheritedAtRoot } - // If expanding to network_all, check that parent's effective visibility allows it + // Expanding to network_all is rejected if any ancestor restricts to custom. if mode == VisibilityNetworkAll && p.ParentID != nil { parentVis, err := s.getEffectiveVisibility(ctx, *p.ParentID) if err != nil { @@ -563,7 +533,7 @@ func (s *serviceImpl) SetVisibility(ctx context.Context, id string, mode Visibil return err } -// getEffectiveVisibility walks up the inherited chain to find the concrete visibility mode. +// Walks up the inherited chain to the concrete visibility node. func (s *serviceImpl) getEffectiveVisibility(ctx context.Context, particleID string) (VisibilityMode, error) { ancestors, err := s.repo.getAncestorChain(ctx, particleID) if err != nil { @@ -600,7 +570,6 @@ func (s *serviceImpl) AddMembers(ctx context.Context, id string, emails []string return err } - // Check access hasAccess, err := s.checkAccess(ctx, id, requesterEmail) if err != nil { if errors.Is(err, errNotFound) { @@ -612,7 +581,6 @@ func (s *serviceImpl) AddMembers(ctx context.Context, id string, emails []string return ErrAccessDenied } - // Get the particle to check type and parent access p, err := s.repo.getByID(ctx, id) if err != nil { if errors.Is(err, errNotFound) { @@ -621,14 +589,11 @@ func (s *serviceImpl) AddMembers(ctx context.Context, id string, emails []string return err } - // Only streams can have members if p.Type != TypeStream { return ErrNotAContainer } - // Validate and normalize emails, checking network membership upfront. - // Strict: a normalize failure, checker error, or non-member aborts the - // whole operation before any rows are written. + // Validate every email upfront so any failure aborts before DB writes. normalizedEmails := make([]string, 0, len(emails)) seen := make(map[string]bool, len(emails)) for _, email := range emails { @@ -664,7 +629,6 @@ func (s *serviceImpl) RemoveMembers(ctx context.Context, id string, emails []str return err } - // Check access hasAccess, err := s.checkAccess(ctx, id, requesterEmail) if err != nil { if errors.Is(err, errNotFound) { @@ -676,7 +640,6 @@ func (s *serviceImpl) RemoveMembers(ctx context.Context, id string, emails []str return ErrAccessDenied } - // Get the particle to check type p, err := s.repo.getByID(ctx, id) if err != nil { if errors.Is(err, errNotFound) { @@ -685,7 +648,6 @@ func (s *serviceImpl) RemoveMembers(ctx context.Context, id string, emails []str return err } - // Only streams can have members if p.Type != TypeStream { return ErrNotAContainer } @@ -712,7 +674,6 @@ func (s *serviceImpl) MarkSeen(ctx context.Context, id, requesterEmail string) e return err } - // Check access hasAccess, err := s.checkAccess(ctx, id, requesterEmail) if err != nil { if errors.Is(err, errNotFound) { @@ -733,17 +694,17 @@ func (s *serviceImpl) MarkSeenBatch(ctx context.Context, ids []string, requester return err } - // Check access for each particle and mark seen + // Silently skip particles that are missing or inaccessible. for _, id := range ids { hasAccess, err := s.checkAccess(ctx, id, requesterEmail) if err != nil { if errors.Is(err, errNotFound) { - continue // Skip non-existent particles + continue } return err } if !hasAccess { - continue // Skip inaccessible particles + continue } if err := s.repo.markSeen(ctx, id, requesterEmail); err != nil { @@ -760,7 +721,6 @@ func (s *serviceImpl) Ack(ctx context.Context, id, requesterEmail string) error return err } - // Check access hasAccess, err := s.checkAccess(ctx, id, requesterEmail) if err != nil { if errors.Is(err, errNotFound) { @@ -772,7 +732,7 @@ func (s *serviceImpl) Ack(ctx context.Context, id, requesterEmail string) error return ErrAccessDenied } - // Ack also marks as seen + // Ack implies seen. if err := s.repo.markSeen(ctx, id, requesterEmail); err != nil { return err } @@ -815,7 +775,6 @@ func isValidParticleType(t ParticleType) bool { } } -// getStreamStatus extracts the status from a stream particle's data func getStreamStatus(data json.RawMessage) string { var d StreamData if err := json.Unmarshal(data, &d); err != nil { @@ -824,7 +783,6 @@ func getStreamStatus(data json.RawMessage) string { return d.Status } -// setStreamStatus updates the status in a stream particle's data func setStreamStatus(data json.RawMessage, status string) (json.RawMessage, error) { var d StreamData if err := json.Unmarshal(data, &d); err != nil { @@ -834,10 +792,9 @@ func setStreamStatus(data json.RawMessage, status string) (json.RawMessage, erro return json.Marshal(d) } -// validateParticleData validates that the data field contains valid JSON -// and has required fields for the given particle type. +// Returns ErrInvalidData if data is not valid JSON or is missing required +// fields for pType. Empty/null data is allowed and treated as {}. func validateParticleData(pType ParticleType, data json.RawMessage) error { - // Empty or null data is allowed - will default to {} if len(data) == 0 || string(data) == "null" || string(data) == "{}" { return nil } diff --git a/go/internal/pusher/authorizer.go b/go/internal/pusher/authorizer.go index a67f4c6..62e8040 100644 --- a/go/internal/pusher/authorizer.go +++ b/go/internal/pusher/authorizer.go @@ -10,20 +10,19 @@ import ( var ErrUnauthorized = errors.New("unauthorized") -// Authorizer validates whether a user can access a given channel. type Authorizer struct { networkReader network.Reader } -// NewAuthorizer creates a new channel authorizer. func NewAuthorizer(networkReader network.Reader) *Authorizer { return &Authorizer{networkReader: networkReader} } -// Authorize checks if the given humanID is allowed to subscribe to the channel. -// Channel formats: -// - network:{networkId} -// - stream:{networkId}:{streamId} +// Authorize accepts channel IDs of the form: +// +// network:{networkId} +// stream:{networkId}:{streamId} +// _presence:{humanId} func (a *Authorizer) Authorize(ctx context.Context, channelID, humanID string) error { parts := strings.SplitN(channelID, ":", 2) if len(parts) < 2 { @@ -39,8 +38,7 @@ func (a *Authorizer) Authorize(ctx context.Context, channelID, humanID string) e case "stream": return a.authorizeStream(ctx, rest, humanID) case "_presence": - // Always allowed — used for global online presence tracking. - // The channel ID is _presence:{humanId}, so verify the humanId matches. + // Only the owning human may subscribe to their presence channel. if rest != humanID { return ErrUnauthorized } @@ -61,8 +59,7 @@ func (a *Authorizer) authorizeNetwork(ctx context.Context, networkID, humanID st return nil } -// authorizeStream expects rest to be "{networkId}:{streamId}". -// We only check network membership — stream visibility is handled by network access. +// rest is "{networkId}:{streamId}"; stream-level visibility is enforced by network access. func (a *Authorizer) authorizeStream(ctx context.Context, rest, humanID string) error { parts := strings.SplitN(rest, ":", 2) if len(parts) < 2 { diff --git a/go/internal/pusher/channel.go b/go/internal/pusher/channel.go index e1025c1..054f3f6 100644 --- a/go/internal/pusher/channel.go +++ b/go/internal/pusher/channel.go @@ -1,7 +1,7 @@ package pusher -// Channel tracks the local connections subscribed to a channel on this pod. -// All methods are only called from the Hub goroutine — no locks needed. +// Channel tracks the local connections subscribed on this pod. +// State is only mutated by the Hub goroutine, so no locks are needed. type Channel struct { id string members map[*Conn]string // conn → humanID @@ -26,7 +26,7 @@ func (ch *Channel) isEmpty() bool { return len(ch.members) == 0 } -// localHumanIDs returns the deduplicated set of humanIDs connected on this pod. +// Deduplicated set; the same human may have multiple connections. func (ch *Channel) localHumanIDs() []string { seen := make(map[string]bool, len(ch.members)) ids := make([]string, 0, len(ch.members)) @@ -39,7 +39,6 @@ func (ch *Channel) localHumanIDs() []string { return ids } -// hasHumanID returns true if the given humanID has at least one local connection. func (ch *Channel) hasHumanID(humanID string) bool { for _, hid := range ch.members { if hid == humanID { @@ -49,7 +48,6 @@ func (ch *Channel) hasHumanID(humanID string) bool { return false } -// broadcast sends a message to all local connections except the excluded one. func (ch *Channel) broadcast(msg ServerMessage, exclude *Conn) { for conn := range ch.members { if conn != exclude { diff --git a/go/internal/pusher/conn.go b/go/internal/pusher/conn.go index 3cdf2c0..6a73912 100644 --- a/go/internal/pusher/conn.go +++ b/go/internal/pusher/conn.go @@ -11,13 +11,12 @@ import ( const sendBufferSize = 256 -// Conn wraps a WebSocket connection with identity and a send buffer. type Conn struct { id string humanID string ws *websocket.Conn send chan []byte - once sync.Once // ensures close logic runs once + once sync.Once // guards Close } func newConn(id, humanID string, ws *websocket.Conn) *Conn { @@ -29,8 +28,8 @@ func newConn(id, humanID string, ws *websocket.Conn) *Conn { } } -// ReadPump reads messages from the WebSocket and forwards them to the hub. -// It blocks until the connection is closed or the context is cancelled. +// ReadPump forwards inbound frames to the hub; blocks until the connection +// closes or ctx is cancelled. func (c *Conn) ReadPump(ctx context.Context, hub *Hub) { defer hub.disconnect(c) @@ -45,7 +44,6 @@ func (c *Conn) ReadPump(ctx context.Context, hub *Hub) { return } - // Respond to keep-alive pings if string(data) == "ping" { if err := c.ws.Write(ctx, websocket.MessageText, []byte("pong")); err != nil { slog.Warn("websocket pong write error", "connId", c.id, "error", err) @@ -84,7 +82,6 @@ func (c *Conn) ReadPump(ctx context.Context, hub *Hub) { } } -// WritePump drains the send buffer and writes messages to the WebSocket. func (c *Conn) WritePump(ctx context.Context) { for { select { @@ -102,8 +99,7 @@ func (c *Conn) WritePump(ctx context.Context) { } } -// Send enqueues a ServerMessage to be written to the WebSocket. -// If the send buffer is full, the connection is closed (slow client). +// A full send buffer closes the connection (slow client policy). func (c *Conn) Send(msg ServerMessage) { data, err := json.Marshal(msg) if err != nil { @@ -119,7 +115,6 @@ func (c *Conn) Send(msg ServerMessage) { } } -// Close closes the WebSocket connection and the send channel. func (c *Conn) Close() { c.once.Do(func() { c.ws.Close(websocket.StatusNormalClosure, "closing") diff --git a/go/internal/pusher/hub.go b/go/internal/pusher/hub.go index 1de54e4..3ecc608 100644 --- a/go/internal/pusher/hub.go +++ b/go/internal/pusher/hub.go @@ -43,7 +43,6 @@ type Hub struct { remoteEventCh chan *remoteEvent } -// NewHub creates a new Hub. func NewHub(bridge *RedisBridge, authorizer *Authorizer) *Hub { return &Hub{ channels: make(map[string]*Channel), @@ -84,7 +83,6 @@ func (h *Hub) Run(ctx context.Context) { } func (h *Hub) handleSubscribe(ctx context.Context, req *subscribeRequest) { - // Authorize channel access if err := h.authorizer.Authorize(ctx, req.channelID, req.conn.humanID); err != nil { req.conn.Send(ServerMessage{ Type: TypeError, @@ -94,7 +92,6 @@ func (h *Hub) handleSubscribe(ctx context.Context, req *subscribeRequest) { return } - // Get or create local channel ch, ok := h.channels[req.channelID] if !ok { ch = newChannel(req.channelID) @@ -104,32 +101,27 @@ func (h *Hub) handleSubscribe(ctx context.Context, req *subscribeRequest) { // Capture before addMember so multi-tab joins don't emit a spurious join. wasPresentLocally := ch.hasHumanID(req.conn.humanID) - // Add to local channel ch.addMember(req.conn, req.conn.humanID) - // Track in reverse index if h.connChannels[req.conn] == nil { h.connChannels[req.conn] = make(map[string]bool) } h.connChannels[req.conn][req.channelID] = true - // Register in Redis and get global presence presence, err := h.bridge.Subscribe(ctx, req.channelID, req.conn.id, req.conn.humanID) if err != nil { slog.Error("redis subscribe failed", "channelId", req.channelID, "error", err) - // Still send local presence as fallback + // Fall back to local-only presence. presence = ch.localHumanIDs() } - // Send subscribed ack with presence snapshot req.conn.Send(ServerMessage{ Type: TypeSubscribed, Channel: req.channelID, Presence: presence, }) - // Notify other local members. The Redis self-filter drops our own echo, - // so same-pod peers would otherwise never hear about this join. + // Redis self-filter drops our own echo, so same-pod peers need a direct nudge. if !wasPresentLocally { ch.broadcast(ServerMessage{ Type: TypeJoin, @@ -147,18 +139,15 @@ func (h *Hub) handleUnsubscribe(ctx context.Context, req *unsubscribeRequest) { ch.removeMember(req.conn) - // Remove from reverse index if chans, ok := h.connChannels[req.conn]; ok { delete(chans, req.channelID) } - // Update Redis if err := h.bridge.Unsubscribe(ctx, req.channelID, req.conn.id, req.conn.humanID); err != nil { slog.Error("redis unsubscribe failed", "channelId", req.channelID, "error", err) } - // Notify other local members iff the humanID is fully gone from this pod - // (multi-tab: other conns keep them present, so no leave fires). + // Only emit leave once the humanID has no remaining tabs on this pod. if !ch.hasHumanID(req.conn.humanID) { ch.broadcast(ServerMessage{ Type: TypeLeave, @@ -167,7 +156,6 @@ func (h *Hub) handleUnsubscribe(ctx context.Context, req *unsubscribeRequest) { }, req.conn) } - // Clean up empty local channel if ch.isEmpty() { delete(h.channels, req.channelID) } @@ -179,13 +167,12 @@ func (h *Hub) handleBroadcast(ctx context.Context, req *broadcastRequest) { return } - // Check that the sender is actually in the channel if _, isMember := ch.members[req.conn]; !isMember { req.conn.sendError("not subscribed to channel: " + req.channelID) return } - // Deliver to local connections (except sender) + // Local fanout (excluding sender), then publish for other pods. ch.broadcast(ServerMessage{ Type: TypeMessage, Channel: req.channelID, @@ -193,7 +180,6 @@ func (h *Hub) handleBroadcast(ctx context.Context, req *broadcastRequest) { Payload: req.payload, }, req.conn) - // Publish to Redis for other pods h.bridge.Broadcast(ctx, req.channelID, req.conn.humanID, req.payload) } @@ -234,7 +220,8 @@ func (h *Hub) handleDisconnect(ctx context.Context, conn *Conn) { func (h *Hub) handleRemoteEvent(evt *remoteEvent) { ch, ok := h.channels[evt.channelID] if !ok { - return // no local connections care about this channel + // No local subscribers — drop the event. + return } switch evt.event.Type { @@ -262,12 +249,10 @@ func (h *Hub) handleRemoteEvent(evt *remoteEvent) { } } -// Subscribe enqueues a subscribe request for the given connection and channel. func (h *Hub) Subscribe(conn *Conn, channelID string) { h.subscribeCh <- &subscribeRequest{conn: conn, channelID: channelID} } -// disconnect sends a connection to the disconnect channel. func (h *Hub) disconnect(conn *Conn) { h.disconnectCh <- conn } diff --git a/go/internal/pusher/redis_bridge.go b/go/internal/pusher/redis_bridge.go index 2212f94..403b15f 100644 --- a/go/internal/pusher/redis_bridge.go +++ b/go/internal/pusher/redis_bridge.go @@ -23,22 +23,22 @@ const ( pubsubPrefix = "pusher:events:" ) -// redisEvent is published/received via Redis Pub/Sub for cross-pod communication. +// Wire format for cross-pod Pub/Sub. type redisEvent struct { Type string `json:"type"` // "join", "leave", "message" HumanID string `json:"humanId,omitempty"` // who triggered the event PodID string `json:"podId,omitempty"` // originating pod - Payload json.RawMessage `json:"payload,omitempty"` // for message events + Payload json.RawMessage `json:"payload,omitempty"` // message events only } -// RedisBridge handles cross-pod coordination via Redis Pub/Sub and presence tracking. +// RedisBridge handles cross-pod coordination via Redis Pub/Sub and presence +// tracking. type RedisBridge struct { client *redis.Client podID string - hub *Hub // set after hub is created + hub *Hub // wired post-construction; see SetHub } -// NewRedisBridge creates a new Redis bridge for cross-pod coordination. func NewRedisBridge(client *redis.Client, podID string) *RedisBridge { return &RedisBridge{ client: client, @@ -46,20 +46,20 @@ func NewRedisBridge(client *redis.Client, podID string) *RedisBridge { } } -// SetHub sets the hub reference. Called during initialization. +// SetHub resolves the circular dependency between Hub and RedisBridge. func (rb *RedisBridge) SetHub(hub *Hub) { rb.hub = hub } -// --- Presence management (called by hub goroutine) --- +// --- Presence management --- -// Subscribe adds a connection to a channel in Redis. -// Returns the current presence set for the channel. +// Subscribe records the connection in Redis and returns the channel's +// current deduplicated presence set. func (rb *RedisBridge) Subscribe(ctx context.Context, channelID, connID, humanID string) ([]string, error) { key := channelConnsKey(channelID) field := rb.connField(connID) - // Check if humanID was already present before adding + // Snapshot before the insert so multi-tab joins don't double-emit. existingMembers, err := rb.client.HVals(ctx, key).Result() if err != nil && err != redis.Nil { return nil, fmt.Errorf("failed to get channel members: %w", err) @@ -67,12 +67,10 @@ func (rb *RedisBridge) Subscribe(ctx context.Context, channelID, connID, humanID wasPresent := containsString(existingMembers, humanID) - // Add this connection if err := rb.client.HSet(ctx, key, field, humanID).Err(); err != nil { return nil, fmt.Errorf("failed to add connection to channel: %w", err) } - // Publish join event if this is a new humanID in the channel if !wasPresent { rb.publishEvent(ctx, channelID, redisEvent{ Type: TypeJoin, @@ -81,7 +79,6 @@ func (rb *RedisBridge) Subscribe(ctx context.Context, channelID, connID, humanID }) } - // Return deduplicated presence set allMembers, err := rb.client.HVals(ctx, key).Result() if err != nil { return nil, fmt.Errorf("failed to get channel members: %w", err) @@ -89,7 +86,6 @@ func (rb *RedisBridge) Subscribe(ctx context.Context, channelID, connID, humanID return deduplicateStrings(allMembers), nil } -// Unsubscribe removes a connection from a channel in Redis. func (rb *RedisBridge) Unsubscribe(ctx context.Context, channelID, connID, humanID string) error { key := channelConnsKey(channelID) field := rb.connField(connID) @@ -98,7 +94,7 @@ func (rb *RedisBridge) Unsubscribe(ctx context.Context, channelID, connID, human return fmt.Errorf("failed to remove connection from channel: %w", err) } - // Check if this humanID is still present via other connections + // Only emit leave once this humanID has no tabs left in the channel. remainingMembers, err := rb.client.HVals(ctx, key).Result() if err != nil && err != redis.Nil { return fmt.Errorf("failed to get remaining members: %w", err) @@ -112,7 +108,6 @@ func (rb *RedisBridge) Unsubscribe(ctx context.Context, channelID, connID, human }) } - // Clean up empty channel hash if len(remainingMembers) == 0 { rb.client.Del(ctx, key) } @@ -120,7 +115,6 @@ func (rb *RedisBridge) Unsubscribe(ctx context.Context, channelID, connID, human return nil } -// Broadcast publishes a message event to all pods. func (rb *RedisBridge) Broadcast(ctx context.Context, channelID, humanID string, payload json.RawMessage) { rb.publishEvent(ctx, channelID, redisEvent{ Type: TypeMessage, @@ -130,7 +124,6 @@ func (rb *RedisBridge) Broadcast(ctx context.Context, channelID, humanID string, }) } -// GetPresence returns the deduplicated humanIDs for the given channels. func (rb *RedisBridge) GetPresence(ctx context.Context, channelIDs []string) (map[string][]string, error) { result := make(map[string][]string, len(channelIDs)) for _, chID := range channelIDs { @@ -143,8 +136,7 @@ func (rb *RedisBridge) GetPresence(ctx context.Context, channelIDs []string) (ma return result, nil } -// GetAllConnectedHumanIDs scans all channel connection hashes in Redis and returns -// the deduplicated set of all humanIDs that have at least one active connection. +// Returns every humanID with at least one active connection cluster-wide. func (rb *RedisBridge) GetAllConnectedHumanIDs(ctx context.Context) ([]string, error) { allHumanIDs := make(map[string]bool) var cursor uint64 @@ -178,10 +170,9 @@ func (rb *RedisBridge) GetAllConnectedHumanIDs(ctx context.Context) ([]string, e return result, nil } -// --- Pub/Sub listener (runs in its own goroutine) --- +// --- Pub/Sub listener --- -// Listen subscribes to Redis Pub/Sub and forwards events to the local hub. -// Blocks until the context is cancelled. +// Listen forwards Redis Pub/Sub events to the local hub; blocks until ctx is cancelled. func (rb *RedisBridge) Listen(ctx context.Context) { pubsub := rb.client.PSubscribe(ctx, pubsubPrefix+"*") defer pubsub.Close() @@ -201,7 +192,7 @@ func (rb *RedisBridge) Listen(ctx context.Context) { } func (rb *RedisBridge) handlePubSubMessage(msg *redis.Message) { - // Extract channel ID from topic: "pusher:events:{channelID}" + // Topic: "pusher:events:{channelID}". channelID := strings.TrimPrefix(msg.Channel, pubsubPrefix) if channelID == "" { return @@ -213,7 +204,7 @@ func (rb *RedisBridge) handlePubSubMessage(msg *redis.Message) { return } - // Skip events originating from this pod — the local hub already handled them + // Same-pod events were already handled by the local hub. if event.PodID == rb.podID { return } @@ -222,20 +213,18 @@ func (rb *RedisBridge) handlePubSubMessage(msg *redis.Message) { return } - // Forward to local hub for delivery to local WebSocket connections rb.hub.remoteEventCh <- &remoteEvent{ channelID: channelID, event: event, } } -// --- Heartbeat + cleanup (runs in its own goroutine) --- +// --- Heartbeat + cleanup --- -// Heartbeat maintains this pod's liveness key and cleans up stale pods. +// Heartbeat refreshes this pod's liveness key and reaps stale pods on a tick. func (rb *RedisBridge) Heartbeat(ctx context.Context) { podKey := podKeyPrefix + rb.podID - // Initial heartbeat rb.client.Set(ctx, podKey, "alive", podHeartbeatTTL) heartbeatTicker := time.NewTicker(podHeartbeatInterval) @@ -246,7 +235,7 @@ func (rb *RedisBridge) Heartbeat(ctx context.Context) { for { select { case <-ctx.Done(): - // On shutdown, remove our pod key and clean up our connections + // On shutdown, drop our pod key and reclaim our connection slots. rb.client.Del(context.Background(), podKey) rb.cleanupPod(context.Background(), rb.podID) return @@ -259,7 +248,8 @@ func (rb *RedisBridge) Heartbeat(ctx context.Context) { } func (rb *RedisBridge) cleanupStalePods(ctx context.Context) { - // Scan all channel conn hashes for pod IDs, then check if those pods are still alive + // Collect every pod referenced in channel-conn hashes, then drop those + // whose liveness key has expired. var cursor uint64 knownPods := make(map[string]bool) alivePods := make(map[string]bool) @@ -290,7 +280,6 @@ func (rb *RedisBridge) cleanupStalePods(ctx context.Context) { } } - // Check which pods are still alive for podID := range knownPods { exists, err := rb.client.Exists(ctx, podKeyPrefix+podID).Result() if err != nil { @@ -301,7 +290,6 @@ func (rb *RedisBridge) cleanupStalePods(ctx context.Context) { } } - // Clean up dead pods for podID := range knownPods { if !alivePods[podID] { slog.Info("cleaning up stale pod", "podId", podID) @@ -328,7 +316,6 @@ func (rb *RedisBridge) cleanupPod(ctx context.Context, podID string) { for field, humanID := range fields { if extractPodID(field) == podID { rb.client.HDel(ctx, key, field) - // Check if this humanID is now gone from the channel remaining, _ := rb.client.HVals(ctx, key).Result() if !containsString(remaining, humanID) { rb.publishEvent(ctx, channelID, redisEvent{ @@ -369,15 +356,15 @@ func channelConnsKey(channelID string) string { return channelConnsPrefix + channelID + channelConnsSuffix } +// "pusher:ch:{channelID}:conns" → channelID func extractChannelID(redisKey string) string { - // "pusher:ch:{channelID}:conns" → channelID s := strings.TrimPrefix(redisKey, channelConnsPrefix) s = strings.TrimSuffix(s, channelConnsSuffix) return s } +// "{podID}:{connID}" → podID func extractPodID(field string) string { - // "{podID}:{connID}" → podID parts := strings.SplitN(field, ":", 2) if len(parts) == 2 { return parts[0] diff --git a/go/internal/pusher/server.go b/go/internal/pusher/server.go index 53349f3..cc32816 100644 --- a/go/internal/pusher/server.go +++ b/go/internal/pusher/server.go @@ -15,14 +15,12 @@ import ( type Server struct { pbpusher.UnimplementedPusherServiceServer - ctx context.Context // server-scoped context for graceful shutdown + ctx context.Context // server-scoped; cancelling closes all WebSockets gracefully hub *Hub bridge *RedisBridge authSvc auth.SessionReader } -// NewServer creates a new pusher server. The ctx controls the lifetime of all -// WebSocket connections — when cancelled, all connections are closed gracefully. func NewServer(ctx context.Context, hub *Hub, bridge *RedisBridge, authSvc auth.SessionReader) *Server { return &Server{ ctx: ctx, @@ -32,9 +30,8 @@ func NewServer(ctx context.Context, hub *Hub, bridge *RedisBridge, authSvc auth. } } -// HandleWebSocket handles the WebSocket upgrade and connection lifecycle. func (s *Server) HandleWebSocket(w http.ResponseWriter, r *http.Request) { - // Authenticate via query param (WebSocket upgrade can't use custom headers) + // Token rides in the query string — WebSocket upgrades can't carry custom headers. token := r.URL.Query().Get("token") if token == "" { http.Error(w, "token required", http.StatusUnauthorized) @@ -47,9 +44,8 @@ func (s *Server) HandleWebSocket(w http.ResponseWriter, r *http.Request) { return } - // Accept WebSocket upgrade ws, err := websocket.Accept(w, r, &websocket.AcceptOptions{ - // Allow all origins for now — CORS is handled at the gateway level + // CORS is enforced at the gateway. InsecureSkipVerify: true, }) if err != nil { @@ -62,25 +58,21 @@ func (s *Server) HandleWebSocket(w http.ResponseWriter, r *http.Request) { slog.Info("websocket connected", "connId", connID, "humanId", session.HumanId) - // Use server context, NOT r.Context(). After WebSocket upgrade, the HTTP - // request context can be cancelled by load balancers or Go's HTTP server, - // and nhooyr/websocket permanently closes the conn on any context error. + // Use the server context, not r.Context(): after upgrade the HTTP request + // context can be cancelled by load balancers and nhooyr/websocket would + // then permanently close the conn. ctx, cancel := context.WithCancel(s.ctx) defer cancel() - // Auto-subscribe to presence channel so this user appears online + // Auto-subscribe to the presence channel so this user appears online. s.hub.Subscribe(conn, "_presence:"+session.HumanId) - // WritePump runs in a separate goroutine go conn.WritePump(ctx) - - // ReadPump blocks until the connection closes conn.ReadPump(ctx, s.hub) slog.Info("websocket disconnected", "connId", connID, "humanId", session.HumanId) } -// GetOnlineHumanIds returns all currently connected human IDs. func (s *Server) GetOnlineHumanIds(ctx context.Context, _ *pbpusher.GetOnlineHumanIdsRequest) (*pbpusher.GetOnlineHumanIdsResponse, error) { humanIDs, err := s.bridge.GetAllConnectedHumanIDs(ctx) if err != nil { @@ -89,7 +81,6 @@ func (s *Server) GetOnlineHumanIds(ctx context.Context, _ *pbpusher.GetOnlineHum return &pbpusher.GetOnlineHumanIdsResponse{HumanIds: humanIDs}, nil } -// IsOnline checks whether specific humans are currently online. func (s *Server) IsOnline(ctx context.Context, req *pbpusher.IsOnlineRequest) (*pbpusher.IsOnlineResponse, error) { allOnline, err := s.bridge.GetAllConnectedHumanIDs(ctx) if err != nil { @@ -106,7 +97,6 @@ func (s *Server) IsOnline(ctx context.Context, req *pbpusher.IsOnlineRequest) (* return &pbpusher.IsOnlineResponse{Online: result}, nil } -// GetChannelPresence returns presence (human IDs) for specific channels. func (s *Server) GetChannelPresence(ctx context.Context, req *pbpusher.GetChannelPresenceRequest) (*pbpusher.GetChannelPresenceResponse, error) { presence, err := s.bridge.GetPresence(ctx, req.ChannelIds) if err != nil { diff --git a/go/internal/pusher/types.go b/go/internal/pusher/types.go index a518046..3c92990 100644 --- a/go/internal/pusher/types.go +++ b/go/internal/pusher/types.go @@ -18,14 +18,12 @@ const ( TypeError = "error" ) -// ClientMessage is a message sent from a WebSocket client to the server. type ClientMessage struct { Type string `json:"type"` Channel string `json:"channel,omitempty"` Payload json.RawMessage `json:"payload,omitempty"` } -// ServerMessage is a message sent from the server to a WebSocket client. type ServerMessage struct { Type string `json:"type"` Channel string `json:"channel,omitempty"` diff --git a/go/internal/redis.go b/go/internal/redis.go index 473f389..2fb052f 100644 --- a/go/internal/redis.go +++ b/go/internal/redis.go @@ -19,7 +19,7 @@ func ConnectAndTestRedis(db int) *redis.Client { redisAddr := fmt.Sprintf("%s:%s", redisHost, "6379") rdb := redis.NewClient(&redis.Options{ Addr: redisAddr, - Password: "", // no password set + Password: "", DB: db, }) diff --git a/go/internal/utils/env.go b/go/internal/utils/env.go index 64dafb7..125c241 100644 --- a/go/internal/utils/env.go +++ b/go/internal/utils/env.go @@ -6,13 +6,12 @@ import ( "github.com/sirupsen/logrus" ) -// enum of environment variables +// EnvVar enumerates the env vars referenced via this package. type EnvVar string const () -// MustGetEnv returns the value of the environment variable with the given key. -// panics if the variable is not set. +// MustGetEnv panics if the variable is unset. func MustGetEnv[T string | EnvVar](key T) string { keyString := string(key) value := os.Getenv(keyString) @@ -24,8 +23,7 @@ func MustGetEnv[T string | EnvVar](key T) string { return value } -// GetEnv returns the value of the environment variable with the given key. -// returns an empty string if the variable is not set. +// GetEnv returns "" if the variable is unset (and logs a warning). func GetEnv(key string) string { value := os.Getenv(key) if value == "" { diff --git a/go/internal/utils/optionals.go b/go/internal/utils/optionals.go index 84b58e8..f75979f 100644 --- a/go/internal/utils/optionals.go +++ b/go/internal/utils/optionals.go @@ -21,7 +21,6 @@ func CreateOptionalBool(input bool) *bool { return &input } -// OptionalString converts a non-nil *string to the respective string or returns "". func OptionalString(input *string) string { if input == nil { return "" @@ -30,7 +29,6 @@ func OptionalString(input *string) string { return *input } -// OptionalInt converts a non-nil *int to the respective int, otherwise returns 0. func OptionalInt(input *int) int { if input == nil { return 0 @@ -39,8 +37,7 @@ func OptionalInt(input *int) int { return *input } -// CreateOptionalInt when given a zero value int (0), it returns a nil *int. -// Otherwise, it gives a proper *int with valid value. +// Zero values become nil; the inverse of OptionalInt. func CreateOptionalInt(input int) *int { if input == 0 { return nil @@ -49,8 +46,7 @@ func CreateOptionalInt(input int) *int { return &input } -// CreateOptionalString when given an empty string, it returns a nil *string. -// Otherwise, it gives a proper *string with valid value. +// Empty string becomes nil; the inverse of OptionalString. func CreateOptionalString(input string) *string { if input == "" { return nil @@ -59,8 +55,7 @@ func CreateOptionalString(input string) *string { return &input } -// GetNumberFromString converts a string to a number. -// Returns error if the query is not a number. +// Returns an error if input contains non-digit characters or parses to <= 0. func GetNumberFromString(input string) (int, error) { for _, c := range input { if c < '0' || c > '9' { @@ -80,7 +75,6 @@ type Number interface { int | int8 | int16 | int32 | int64 | uint | uint8 | uint16 | uint32 | uint64 | float32 | float64 } -// OptionalNumber converts a non-nil *NUMBER to the respective number value or returns 0. func OptionalNumber[T Number](input *T) T { if input == nil { return 0 @@ -89,7 +83,7 @@ func OptionalNumber[T Number](input *T) T { return *input } -// CreateOptionalNumber when given an zero value NUMBER (0), it returns a nil *NUMBER, otherwise, it gives a proper *NUMBER with valid value. +// Zero values become nil; the inverse of OptionalNumber. func CreateOptionalNumber[T Number](input T) *T { if input == 0 { return nil diff --git a/go/internal/utils/random_generator.go b/go/internal/utils/random_generator.go index 022f091..6def086 100644 --- a/go/internal/utils/random_generator.go +++ b/go/internal/utils/random_generator.go @@ -10,7 +10,6 @@ const ( charsetNumbers = "0123456789" ) -// RandomString generates a random string of length n based on self defined charset func RandomString(length int) string { sb := strings.Builder{} sb.Grow(length) @@ -20,7 +19,6 @@ func RandomString(length int) string { return sb.String() } -// RandomStringNumbers func RandomStringNumbers(length int) string { sb := strings.Builder{} sb.Grow(length) diff --git a/go/internal/waitlist/service.go b/go/internal/waitlist/service.go index fddce24..4711079 100644 --- a/go/internal/waitlist/service.go +++ b/go/internal/waitlist/service.go @@ -34,11 +34,10 @@ const ( ) type Service interface { - // returns AlreadyInWaitlistError if already in the waitlist - // any other error is a failure + // AddToWaitlist returns AlreadyInWaitlistError if the email is already present. AddToWaitlist(ctx context.Context, email string, metadata map[string]string) error GetWaitlist(ctx context.Context, filter GetWaitlistFilter) ([]*WaitlistEntry, error) - // returns error if not found + // GetWaitlistEntryByEmail returns EntryNotFoundError if missing. GetWaitlistEntryByEmail(ctx context.Context, email string) (*WaitlistEntry, error) MarkWaitlistEntryInvited(ctx context.Context, email string) error } diff --git a/go/k8s/dev/particleprocessorworker.yaml b/go/k8s/dev/particleprocessorworker.yaml index 7a69497..6c632ff 100644 --- a/go/k8s/dev/particleprocessorworker.yaml +++ b/go/k8s/dev/particleprocessorworker.yaml @@ -43,3 +43,10 @@ spec: secretKeyRef: name: shared-secrets key: DEEPGRAM_SECRET + - name: "PUSHER_GRPC_ADDR" + value: "pusher:50051" + - name: "EXPO_ACCESS_TOKEN" + valueFrom: + secretKeyRef: + name: shared-secrets + key: EXPO_ACCESS_TOKEN diff --git a/go/k8s/prod/particleprocessorworker.yaml b/go/k8s/prod/particleprocessorworker.yaml index ca6f3e0..26705fc 100644 --- a/go/k8s/prod/particleprocessorworker.yaml +++ b/go/k8s/prod/particleprocessorworker.yaml @@ -40,3 +40,10 @@ spec: secretKeyRef: name: shared-secrets key: DEEPGRAM_SECRET + - name: "PUSHER_GRPC_ADDR" + value: "pusher:50051" + - name: "EXPO_ACCESS_TOKEN" + valueFrom: + secretKeyRef: + name: shared-secrets + key: EXPO_ACCESS_TOKEN diff --git a/go/migrations/000016_push_tokens.down.sql b/go/migrations/000016_push_tokens.down.sql new file mode 100644 index 0000000..a159f1c --- /dev/null +++ b/go/migrations/000016_push_tokens.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS push_tokens; diff --git a/go/migrations/000016_push_tokens.up.sql b/go/migrations/000016_push_tokens.up.sql new file mode 100644 index 0000000..4b0c4ca --- /dev/null +++ b/go/migrations/000016_push_tokens.up.sql @@ -0,0 +1,10 @@ +CREATE TABLE push_tokens ( + token TEXT PRIMARY KEY, + human_id TEXT NOT NULL REFERENCES humans(id) ON DELETE CASCADE, + platform TEXT NOT NULL CHECK (platform IN ('ios', 'android')), + app_version TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX push_tokens_human_id_idx ON push_tokens (human_id); diff --git a/js/mobile/app.config.ts b/js/mobile/app.config.ts index 2c99236..0069c71 100644 --- a/js/mobile/app.config.ts +++ b/js/mobile/app.config.ts @@ -50,6 +50,13 @@ const config: ExpoConfig = { "Flowy uses your microphone to record voice messages.", }, ], + [ + "expo-notifications", + { + icon: "./assets/icon.png", + color: "#000000", + }, + ], ], experiments: { typedRoutes: false, diff --git a/js/mobile/package.json b/js/mobile/package.json index 40aaa5a..ff7d1cd 100644 --- a/js/mobile/package.json +++ b/js/mobile/package.json @@ -1,6 +1,6 @@ { "name": "flowy-mobile", - "version": "0.2.2", + "version": "0.3.0", "private": true, "main": "index.ts", "scripts": { @@ -22,8 +22,10 @@ "expo-audio": "~1.0.13", "expo-camera": "~17.0.10", "expo-constants": "~18.0.13", + "expo-device": "~8.0.10", "expo-file-system": "~19.0.16", "expo-haptics": "~15.0.7", + "expo-notifications": "~0.32.17", "expo-secure-store": "~15.0.8", "expo-status-bar": "~3.0.9", "expo-video": "~3.0.10", diff --git a/js/mobile/src/App.tsx b/js/mobile/src/App.tsx index 23be821..927f81b 100644 --- a/js/mobile/src/App.tsx +++ b/js/mobile/src/App.tsx @@ -9,12 +9,20 @@ import { } from "react-native-safe-area-context"; import { Toaster } from "sonner-native"; import { createQueryClient } from "@/lib/query-client"; +import { + flushPendingNavigation, + navigationRef, +} from "@/lib/notification-routing"; +import { configureNotifications } from "@/lib/push-notifications"; import { PusherProvider } from "@/lib/pusher-provider"; import { RootNavigator } from "@/navigation/RootNavigator"; import { useAuthStore } from "@/stores/auth-store"; const queryClient = createQueryClient(); +// One-time setup: foreground handler + push-token rotation listener. Idempotent. +configureNotifications(); + export default function App() { const restoreSession = useAuthStore((s) => s.restoreSession); @@ -27,7 +35,10 @@ export default function App() { - + diff --git a/js/mobile/src/api/client.ts b/js/mobile/src/api/client.ts index 2095d79..529333a 100644 --- a/js/mobile/src/api/client.ts +++ b/js/mobile/src/api/client.ts @@ -139,6 +139,20 @@ class ApiClient { await this.requestVoid("PATCH", "/humans/me/settings", data); } + // --- Push notification tokens --- + + async registerPushToken(data: { + token: string; + platform: "ios" | "android"; + app_version: string; + }): Promise { + await this.requestVoid("POST", "/humans/me/push-tokens", data); + } + + async unregisterPushToken(token: string): Promise { + await this.requestVoid("DELETE", "/humans/me/push-tokens", { token }); + } + // --- Depot --- async prepareUpload(data: PrepareUploadRequest) { diff --git a/js/mobile/src/lib/notification-routing.ts b/js/mobile/src/lib/notification-routing.ts new file mode 100644 index 0000000..071abe4 --- /dev/null +++ b/js/mobile/src/lib/notification-routing.ts @@ -0,0 +1,73 @@ +import { createNavigationContainerRef } from "@react-navigation/native"; +import type { Notification } from "expo-notifications"; +import { logError } from "@/lib/errors"; + +// Shared ref so non-component code (notification handlers, deep links) can +// drive navigation without prop-drilling. Typed via the global +// ReactNavigation.RootParamList augmentation in navigation/types.ts. +export const navigationRef = createNavigationContainerRef(); + +// Shape the worker (go/internal/human/pushnotify/notifier.go::buildMessages) +// puts in `Notifications.notification.request.content.data`. +type ParticleCreatedData = { + kind: "particle_created"; + network_id: string; + stream_id: string; + particle_id: string; + sender_human_id: string; + particle_kind: string; +}; + +function isParticleCreatedData(data: unknown): data is ParticleCreatedData { + return ( + typeof data === "object" && + data !== null && + (data as { kind?: unknown }).kind === "particle_created" && + typeof (data as { network_id?: unknown }).network_id === "string" && + typeof (data as { stream_id?: unknown }).stream_id === "string" + ); +} + +// If a tap arrives before the navigator has mounted (cold start), stash it and +// replay as soon as the container reports ready. +let pendingNavigation: ParticleCreatedData | null = null; + +/** + * Routes a single notification tap to the appropriate screen. Safe to call + * before the navigation container is ready — it queues the route and replays + * it once `navigationRef.isReady()` flips true. + */ +export function routeNotificationTap(notification: Notification): void { + try { + const data = notification.request.content.data; + if (!isParticleCreatedData(data)) return; + + if (!navigationRef.isReady()) { + pendingNavigation = data; + return; + } + navigateToStream(data); + } catch (err) { + logError(err, { scope: "push.route" }); + } +} + +/** + * Called once by App.tsx when the NavigationContainer mounts. Drains any + * cold-start tap that arrived before navigation was ready. + */ +export function flushPendingNavigation(): void { + if (!pendingNavigation) return; + const data = pendingNavigation; + pendingNavigation = null; + if (navigationRef.isReady()) { + navigateToStream(data); + } +} + +function navigateToStream(data: ParticleCreatedData): void { + navigationRef.navigate("StreamView", { + networkId: data.network_id, + streamId: data.stream_id, + }); +} diff --git a/js/mobile/src/lib/push-notifications.ts b/js/mobile/src/lib/push-notifications.ts new file mode 100644 index 0000000..5d4dca5 --- /dev/null +++ b/js/mobile/src/lib/push-notifications.ts @@ -0,0 +1,160 @@ +import Constants from "expo-constants"; +import * as Device from "expo-device"; +import * as Notifications from "expo-notifications"; +import * as SecureStore from "expo-secure-store"; +import { Platform } from "react-native"; +import { apiClient } from "@/api/client"; +import { logError } from "@/lib/errors"; +import { routeNotificationTap } from "@/lib/notification-routing"; + +const STORED_TOKEN_KEY = "expo_push_token"; + +let configured = false; +let tokenListenerSubscription: Notifications.Subscription | null = null; + +/** + * Sets the foreground notification handler so banners show while the app is + * open, and subscribes to Expo's token-rotation listener so the backend stays + * in sync without the user needing to re-launch. Safe to call multiple times. + */ +export function configureNotifications(): void { + if (configured) return; + configured = true; + + Notifications.setNotificationHandler({ + handleNotification: async () => ({ + shouldShowBanner: true, + shouldShowList: true, + shouldPlaySound: true, + shouldSetBadge: false, + }), + }); + + tokenListenerSubscription = Notifications.addPushTokenListener((event) => { + // Token rotated server-side by Expo or APNs. Sync immediately so we don't + // keep pushing to a dead token. + void syncPushToken(event.data); + }); + + // Warm-state taps (app in background or foreground). Cold-start taps are + // drained separately via getLastNotificationResponseAsync; see + // flushPendingNavigation in notification-routing.ts. + Notifications.addNotificationResponseReceivedListener((response) => { + routeNotificationTap(response.notification); + }); + + void Notifications.getLastNotificationResponseAsync().then((response) => { + if (response) routeNotificationTap(response.notification); + }); +} + +/** + * Acquires (or returns the cached) Expo push token for this device. Returns + * null on simulators, when permission is denied, or when any step fails — the + * caller should treat that as "no push, no further action". + */ +async function acquirePushToken(): Promise { + if (!Device.isDevice) return null; + + const existing = await Notifications.getPermissionsAsync(); + let status = existing.status; + if (status !== "granted") { + const requested = await Notifications.requestPermissionsAsync(); + status = requested.status; + } + if (status !== "granted") return null; + + const projectId = + Constants.expoConfig?.extra?.eas?.projectId ?? + Constants.easConfig?.projectId; + if (!projectId) { + logError(new Error("EAS projectId missing — cannot fetch push token"), { + scope: "push.acquire", + }); + return null; + } + + const tokenResult = await Notifications.getExpoPushTokenAsync({ projectId }); + return tokenResult.data; +} + +async function getStoredToken(): Promise { + try { + return await SecureStore.getItemAsync(STORED_TOKEN_KEY); + } catch { + return null; + } +} + +async function setStoredToken(token: string): Promise { + try { + await SecureStore.setItemAsync(STORED_TOKEN_KEY, token); + } catch (err) { + logError(err, { scope: "push.store" }); + } +} + +async function clearStoredToken(): Promise { + try { + await SecureStore.deleteItemAsync(STORED_TOKEN_KEY); + } catch { + // ignore + } +} + +/** + * Compares the freshly-fetched token to whatever we last sent to Orion and + * only POSTs on a delta. Never throws — push registration is best-effort and + * must never block the auth path. + */ +export async function syncPushToken(token?: string | null): Promise { + try { + const next = token ?? (await acquirePushToken()); + if (!next) return; + + const stored = await getStoredToken(); + if (stored === next) return; + + const platform = Platform.OS === "ios" ? "ios" : "android"; + const appVersion = Constants.expoConfig?.version ?? ""; + + await apiClient.registerPushToken({ + token: next, + platform, + app_version: appVersion, + }); + await setStoredToken(next); + } catch (err) { + logError(err, { scope: "push.sync" }); + } +} + +/** + * Best-effort unregister at sign-out. Wipes the stored token even if the + * server call fails so the next signed-in user re-registers cleanly. + */ +export async function unregisterPushToken(): Promise { + try { + const stored = await getStoredToken(); + if (stored) { + try { + await apiClient.unregisterPushToken(stored); + } catch (err) { + logError(err, { scope: "push.unregister" }); + } + } + } finally { + await clearStoredToken(); + } +} + +/** + * Test-only: tears down the module-level token listener. Not normally needed + * in the app lifecycle — Notifications subscriptions live as long as the JS + * runtime does. + */ +export function _resetPushNotificationsModule(): void { + tokenListenerSubscription?.remove(); + tokenListenerSubscription = null; + configured = false; +} diff --git a/js/mobile/src/stores/auth-store.ts b/js/mobile/src/stores/auth-store.ts index c291498..8ebc435 100644 --- a/js/mobile/src/stores/auth-store.ts +++ b/js/mobile/src/stores/auth-store.ts @@ -7,6 +7,10 @@ import { apiClient } from "@/api/client"; import type { Human } from "@/api/types"; import { firebaseAuth } from "@/firebase"; import { logError, ApiError } from "@/lib/errors"; +import { + syncPushToken, + unregisterPushToken, +} from "@/lib/push-notifications"; import { hydrateSession, useSessionStore } from "./session-store"; async function signInToFirebase(): Promise { @@ -60,6 +64,7 @@ export const useAuthStore = create((set) => ({ const user = await apiClient.me(); await signInToFirebase(); set({ status: "authenticated", user }); + void syncPushToken(); } catch (err) { // Expected on expired/invalid tokens — fall back to the login screen. logError(err, { scope: "auth.restore" }); @@ -89,6 +94,7 @@ export const useAuthStore = create((set) => ({ await useSessionStore.getState().setToken(token); await signInToFirebase(); set({ status: "authenticated", user: human }); + void syncPushToken(); } catch (e) { const message = e instanceof ApiError ? e.message : "Failed to sign in"; set({ error: message }); @@ -100,6 +106,9 @@ export const useAuthStore = create((set) => ({ signOut: async () => { set({ isSigningOut: true }); + // Unregister the push token first — once the session token is cleared the + // backend call would 401. Best-effort: failures must not block sign-out. + await unregisterPushToken(); try { await apiClient.signOut(); } catch (err) { diff --git a/js/mobile/yarn.lock b/js/mobile/yarn.lock index 2945124..1e90d04 100644 --- a/js/mobile/yarn.lock +++ b/js/mobile/yarn.lock @@ -1526,6 +1526,11 @@ protobufjs "^7.2.5" yargs "^17.7.2" +"@ide/backoff@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@ide/backoff/-/backoff-1.0.0.tgz#466842c25bd4a4833e0642fab41ccff064010176" + integrity sha512-F0YfUDjvT+Mtt/R4xdl2X0EYCHMMiJqNLdxHD++jDT5ydEFIyqbCHh51Qx2E211dgZprPKhV7sHmnXKpLuvc5g== + "@isaacs/fs-minipass@^4.0.0": version "4.0.1" resolved "https://registry.yarnpkg.com/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz#2d59ae3ab4b38fb4270bfa23d30f8e2e86c7fe32" @@ -2213,11 +2218,29 @@ asap@~2.0.6: resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46" integrity sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA== +assert@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/assert/-/assert-2.1.0.tgz#6d92a238d05dc02e7427c881fb8be81c8448b2dd" + integrity sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw== + dependencies: + call-bind "^1.0.2" + is-nan "^1.3.2" + object-is "^1.1.5" + object.assign "^4.1.4" + util "^0.12.5" + async-limiter@~1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/async-limiter/-/async-limiter-1.0.1.tgz#dd379e94f0db8310b08291f9d64c3209766617fd" integrity sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ== +available-typed-arrays@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz#a5cc375d6a03c2efc87a553f3e0b1522def14846" + integrity sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ== + dependencies: + possible-typed-array-names "^1.0.0" + babel-jest@^29.7.0: version "29.7.0" resolved "https://registry.yarnpkg.com/babel-jest/-/babel-jest-29.7.0.tgz#f4369919225b684c56085998ac63dbd05be020d5" @@ -2359,6 +2382,11 @@ babel-preset-jest@^29.6.3: babel-plugin-jest-hoist "^29.6.3" babel-preset-current-node-syntax "^1.0.0" +badgin@^1.1.5: + version "1.2.3" + resolved "https://registry.yarnpkg.com/badgin/-/badgin-1.2.3.tgz#994b5f519827d7d5422224825b2c8faea2bc43ad" + integrity sha512-NQGA7LcfCpSzIbGRbkgjgdWkjy7HI+Th5VLxTJfW5EeaAf3fnS+xWQaQOCYiny+q6QSvxqoSO04vCx+4u++EJw== + balanced-match@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" @@ -2487,6 +2515,32 @@ bytes@3.1.2: resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5" integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg== +call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz#4b5428c222be985d79c3d82657479dbe0b59b2d6" + integrity sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ== + dependencies: + es-errors "^1.3.0" + function-bind "^1.1.2" + +call-bind@^1.0.0, call-bind@^1.0.2, call-bind@^1.0.7, call-bind@^1.0.8: + version "1.0.9" + resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.9.tgz#39a644700c80bc7d0ca9102fc6d1d43b2fd7eee7" + integrity sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ== + dependencies: + call-bind-apply-helpers "^1.0.2" + es-define-property "^1.0.1" + get-intrinsic "^1.3.0" + set-function-length "^1.2.2" + +call-bound@^1.0.2, call-bound@^1.0.3, call-bound@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/call-bound/-/call-bound-1.0.4.tgz#238de935d2a2a692928c538c7ccfa91067fd062a" + integrity sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg== + dependencies: + call-bind-apply-helpers "^1.0.2" + get-intrinsic "^1.3.0" + camelcase-css@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/camelcase-css/-/camelcase-css-2.0.1.tgz#ee978f6947914cc30c6b44741b6ed1df7f043fd5" @@ -2808,11 +2862,29 @@ defaults@^1.0.3: dependencies: clone "^1.0.2" +define-data-property@^1.0.1, define-data-property@^1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/define-data-property/-/define-data-property-1.1.4.tgz#894dc141bb7d3060ae4366f6a0107e68fbe48c5e" + integrity sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A== + dependencies: + es-define-property "^1.0.0" + es-errors "^1.3.0" + gopd "^1.0.1" + define-lazy-prop@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz#3f7ae421129bcaaac9bc74905c98a0009ec9ee7f" integrity sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og== +define-properties@^1.1.3, define-properties@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.2.1.tgz#10781cc616eb951a80a034bafcaa7377f6af2b6c" + integrity sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg== + dependencies: + define-data-property "^1.0.1" + has-property-descriptors "^1.0.0" + object-keys "^1.1.1" + depd@2.0.0, depd@~2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" @@ -2890,6 +2962,15 @@ dotenv@~16.4.5: resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.4.7.tgz#0e20c5b82950140aa99be360a8a5f52335f53c26" integrity sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ== +dunder-proto@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/dunder-proto/-/dunder-proto-1.0.1.tgz#d7ae667e1dc83482f8b70fd0f6eefc50da30f58a" + integrity sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A== + dependencies: + call-bind-apply-helpers "^1.0.1" + es-errors "^1.3.0" + gopd "^1.2.0" + ee-first@1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" @@ -2932,11 +3013,23 @@ error-stack-parser@^2.0.6: dependencies: stackframe "^1.3.4" +es-define-property@^1.0.0, es-define-property@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.1.tgz#983eb2f9a6724e9303f61addf011c72e09e0b0fa" + integrity sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g== + es-errors@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f" integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw== +es-object-atoms@^1.0.0, es-object-atoms@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz#1c4f2c4837327597ce69d2ca190a7fdd172338c1" + integrity sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA== + dependencies: + es-errors "^1.3.0" + escalade@^3.1.1, escalade@^3.2.0: version "3.2.0" resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.2.0.tgz#011a3f69856ba189dffa7dc8fcce99d2a87903e5" @@ -2977,6 +3070,11 @@ event-target-shim@^5.0.0: resolved "https://registry.yarnpkg.com/event-target-shim/-/event-target-shim-5.0.1.tgz#5d4d3ebdf9583d63a5333ce2deb7480ab2b05789" integrity sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ== +expo-application@~7.0.8: + version "7.0.8" + resolved "https://registry.yarnpkg.com/expo-application/-/expo-application-7.0.8.tgz#320af0d6c39b331456d3bc833b25763c702d23db" + integrity sha512-qFGyxk7VJbrNOQWBbE09XUuGuvkOgFS9QfToaK2FdagM2aQ+x3CvGV2DuVgl/l4ZxPgIf3b/MNh9xHpwSwn74Q== + expo-asset@~12.0.13: version "12.0.13" resolved "https://registry.yarnpkg.com/expo-asset/-/expo-asset-12.0.13.tgz#1974ed7abee2ad987a519dbdcbf7f0c647dddf5b" @@ -3013,6 +3111,13 @@ expo-constants@~18.0.13: "@expo/config" "~12.0.13" "@expo/env" "~2.0.8" +expo-device@~8.0.10: + version "8.0.10" + resolved "https://registry.yarnpkg.com/expo-device/-/expo-device-8.0.10.tgz#88be854d6de5568392ed814b44dad0e19d1d50f8" + integrity sha512-jd5BxjaF7382JkDMaC+P04aXXknB2UhWaVx5WiQKA05ugm/8GH5uaz9P9ckWdMKZGQVVEOC8MHaUADoT26KmFA== + dependencies: + ua-parser-js "^0.7.33" + expo-file-system@~19.0.16, expo-file-system@~19.0.22: version "19.0.22" resolved "https://registry.yarnpkg.com/expo-file-system/-/expo-file-system-19.0.22.tgz#8e8f892b2e89a78102b2b90fc1af5bb6bad4f21b" @@ -3053,6 +3158,19 @@ expo-modules-core@3.0.30: dependencies: invariant "^2.2.4" +expo-notifications@~0.32.17: + version "0.32.17" + resolved "https://registry.yarnpkg.com/expo-notifications/-/expo-notifications-0.32.17.tgz#7c9786f167da39d504edc450a84bcb5489c1a54e" + integrity sha512-lwwzn7tImuzTzn9PAglZlS2VfZEvsfFGJTK9Eb8I4cqkGh2DI23YJFJH+WPEIu4QhDvk5JeBjklenJ8IZbmA4A== + dependencies: + "@expo/image-utils" "^0.8.8" + "@ide/backoff" "^1.0.0" + abort-controller "^3.0.0" + assert "^2.0.0" + badgin "^1.1.5" + expo-application "~7.0.8" + expo-constants "~18.0.13" + expo-secure-store@~15.0.8: version "15.0.8" resolved "https://registry.yarnpkg.com/expo-secure-store/-/expo-secure-store-15.0.8.tgz#678065599bb76061b5a85b15b9426bf7a11089ae" @@ -3236,6 +3354,13 @@ fontfaceobserver@^2.1.0: resolved "https://registry.yarnpkg.com/fontfaceobserver/-/fontfaceobserver-2.3.0.tgz#5fb392116e75d5024b7ec8e4f2ce92106d1488c8" integrity sha512-6FPvD/IVyT4ZlNe7Wcn5Fb/4ChigpucKYSvD6a+0iMoLn2inpo711eyIcKjmDtE5XNcgAkSH9uN/nfAeZzHEfg== +for-each@^0.3.5: + version "0.3.5" + resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.5.tgz#d650688027826920feeb0af747ee7b9421a41d47" + integrity sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg== + dependencies: + is-callable "^1.2.7" + freeport-async@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/freeport-async/-/freeport-async-2.0.0.tgz#6adf2ec0c629d11abff92836acd04b399135bab4" @@ -3261,6 +3386,11 @@ function-bind@^1.1.2: resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c" integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== +generator-function@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/generator-function/-/generator-function-2.0.1.tgz#0e75dd410d1243687a0ba2e951b94eedb8f737a2" + integrity sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g== + gensync@^1.0.0-beta.2: version "1.0.0-beta.2" resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0" @@ -3271,11 +3401,35 @@ get-caller-file@^2.0.5: resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== +get-intrinsic@^1.2.4, get-intrinsic@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz#743f0e3b6964a93a5491ed1bffaae054d7f98d01" + integrity sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ== + dependencies: + call-bind-apply-helpers "^1.0.2" + es-define-property "^1.0.1" + es-errors "^1.3.0" + es-object-atoms "^1.1.1" + function-bind "^1.1.2" + get-proto "^1.0.1" + gopd "^1.2.0" + has-symbols "^1.1.0" + hasown "^2.0.2" + math-intrinsics "^1.1.0" + get-package-type@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/get-package-type/-/get-package-type-0.1.0.tgz#8de2d803cff44df3bc6c456e6668b36c3926e11a" integrity sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q== +get-proto@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/get-proto/-/get-proto-1.0.1.tgz#150b3f2743869ef3e851ec0c49d15b1d14d00ee1" + integrity sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g== + dependencies: + dunder-proto "^1.0.1" + es-object-atoms "^1.0.0" + getenv@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/getenv/-/getenv-2.0.0.tgz#b1698c7b0f29588f4577d06c42c73a5b475c69e0" @@ -3316,6 +3470,11 @@ glob@^7.1.1, glob@^7.1.3, glob@^7.1.4: once "^1.3.0" path-is-absolute "^1.0.0" +gopd@^1.0.1, gopd@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.2.0.tgz#89f56b8217bdbc8802bd299df6d7f1081d7e51a1" + integrity sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg== + graceful-fs@^4.2.4, graceful-fs@^4.2.9: version "4.2.11" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" @@ -3331,6 +3490,25 @@ has-flag@^4.0.0: resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== +has-property-descriptors@^1.0.0, has-property-descriptors@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz#963ed7d071dc7bf5f084c5bfbe0d1b6222586854" + integrity sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg== + dependencies: + es-define-property "^1.0.0" + +has-symbols@^1.0.3, has-symbols@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.1.0.tgz#fc9c6a783a084951d0b971fe1018de813707a338" + integrity sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ== + +has-tostringtag@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz#2cdc42d40bef2e5b4eeab7c01a73c54ce7ab5abc" + integrity sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw== + dependencies: + has-symbols "^1.0.3" + hasown@^2.0.2: version "2.0.3" resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.3.tgz#5e5c2b15b60370a4c7930c383dfb76bf17bc403c" @@ -3447,7 +3625,7 @@ inflight@^1.0.4: once "^1.3.0" wrappy "1" -inherits@2, inherits@~2.0.3, inherits@~2.0.4: +inherits@2, inherits@^2.0.3, inherits@~2.0.3, inherits@~2.0.4: version "2.0.4" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== @@ -3464,6 +3642,14 @@ invariant@^2.2.4: dependencies: loose-envify "^1.0.0" +is-arguments@^1.0.4: + version "1.2.0" + resolved "https://registry.yarnpkg.com/is-arguments/-/is-arguments-1.2.0.tgz#ad58c6aecf563b78ef2bf04df540da8f5d7d8e1b" + integrity sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA== + dependencies: + call-bound "^1.0.2" + has-tostringtag "^1.0.2" + is-arrayish@^0.3.1: version "0.3.4" resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.3.4.tgz#1ee5553818511915685d33bb13d31bf854e5059d" @@ -3476,6 +3662,11 @@ is-binary-path@~2.1.0: dependencies: binary-extensions "^2.0.0" +is-callable@^1.2.7: + version "1.2.7" + resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.7.tgz#3bc2a85ea742d9e36205dcacdd72ca1fdc51b055" + integrity sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA== + is-core-module@^2.16.1: version "2.16.1" resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.16.1.tgz#2a98801a849f43e2add644fbb6bc6229b19a4ef4" @@ -3498,6 +3689,17 @@ is-fullwidth-code-point@^3.0.0: resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== +is-generator-function@^1.0.7: + version "1.1.2" + resolved "https://registry.yarnpkg.com/is-generator-function/-/is-generator-function-1.1.2.tgz#ae3b61e3d5ea4e4839b90bad22b02335051a17d5" + integrity sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA== + dependencies: + call-bound "^1.0.4" + generator-function "^2.0.0" + get-proto "^1.0.1" + has-tostringtag "^1.0.2" + safe-regex-test "^1.1.0" + is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1: version "4.0.3" resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" @@ -3505,6 +3707,14 @@ is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1: dependencies: is-extglob "^2.1.1" +is-nan@^1.3.2: + version "1.3.2" + resolved "https://registry.yarnpkg.com/is-nan/-/is-nan-1.3.2.tgz#043a54adea31748b55b6cd4e09aadafa69bd9e1d" + integrity sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w== + dependencies: + call-bind "^1.0.0" + define-properties "^1.1.3" + is-number@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" @@ -3515,6 +3725,23 @@ is-plain-obj@^2.1.0: resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-2.1.0.tgz#45e42e37fccf1f40da8e5f76ee21515840c09287" integrity sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA== +is-regex@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.2.1.tgz#76d70a3ed10ef9be48eb577887d74205bf0cad22" + integrity sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g== + dependencies: + call-bound "^1.0.2" + gopd "^1.2.0" + has-tostringtag "^1.0.2" + hasown "^2.0.2" + +is-typed-array@^1.1.3: + version "1.1.15" + resolved "https://registry.yarnpkg.com/is-typed-array/-/is-typed-array-1.1.15.tgz#4bfb4a45b61cee83a5a46fba778e4e8d59c0ce0b" + integrity sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ== + dependencies: + which-typed-array "^1.1.16" + is-wsl@^2.1.1, is-wsl@^2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/is-wsl/-/is-wsl-2.2.0.tgz#74a4c76e77ca9fd3f932f290c17ea326cd157271" @@ -3942,6 +4169,11 @@ marky@^1.2.2: resolved "https://registry.yarnpkg.com/marky/-/marky-1.3.0.tgz#422b63b0baf65022f02eda61a238eccdbbc14997" integrity sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ== +math-intrinsics@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9" + integrity sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g== + mdn-data@2.0.14: version "2.0.14" resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.14.tgz#7113fc4281917d63ce29b43446f701e68c25ba50" @@ -4558,6 +4790,31 @@ object-hash@^3.0.0: resolved "https://registry.yarnpkg.com/object-hash/-/object-hash-3.0.0.tgz#73f97f753e7baffc0e2cc9d6e079079744ac82e9" integrity sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw== +object-is@^1.1.5: + version "1.1.6" + resolved "https://registry.yarnpkg.com/object-is/-/object-is-1.1.6.tgz#1a6a53aed2dd8f7e6775ff870bea58545956ab07" + integrity sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q== + dependencies: + call-bind "^1.0.7" + define-properties "^1.2.1" + +object-keys@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e" + integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA== + +object.assign@^4.1.4: + version "4.1.7" + resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.7.tgz#8c14ca1a424c6a561b0bb2a22f66f5049a945d3d" + integrity sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw== + dependencies: + call-bind "^1.0.8" + call-bound "^1.0.3" + define-properties "^1.2.1" + es-object-atoms "^1.0.0" + has-symbols "^1.1.0" + object-keys "^1.1.1" + on-finished@~2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947" @@ -4725,6 +4982,11 @@ pngjs@^3.3.0: resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-3.4.0.tgz#99ca7d725965fb655814eaf65f38f12bbdbf555f" integrity sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w== +possible-typed-array-names@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz#93e3582bc0e5426586d9d07b79ee40fc841de4ae" + integrity sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg== + postcss-import@^15.1.0: version "15.1.0" resolved "https://registry.yarnpkg.com/postcss-import/-/postcss-import-15.1.0.tgz#41c64ed8cc0e23735a9698b3249ffdbf704adc70" @@ -5180,6 +5442,15 @@ safe-buffer@5.2.1, safe-buffer@>=5.1.0: resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== +safe-regex-test@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/safe-regex-test/-/safe-regex-test-1.1.0.tgz#7f87dfb67a3150782eaaf18583ff5d1711ac10c1" + integrity sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw== + dependencies: + call-bound "^1.0.2" + es-errors "^1.3.0" + is-regex "^1.2.1" + sax@>=0.6.0: version "1.6.0" resolved "https://registry.yarnpkg.com/sax/-/sax-1.6.0.tgz#da59637629307b97e7c4cb28e080a7bc38560d5b" @@ -5239,6 +5510,18 @@ serve-static@^1.16.2: parseurl "~1.3.3" send "~0.19.1" +set-function-length@^1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/set-function-length/-/set-function-length-1.2.2.tgz#aac72314198eaed975cf77b2c3b6b880695e5449" + integrity sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg== + dependencies: + define-data-property "^1.1.4" + es-errors "^1.3.0" + function-bind "^1.1.2" + get-intrinsic "^1.2.4" + gopd "^1.0.1" + has-property-descriptors "^1.0.2" + setprototypeof@~1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424" @@ -5604,6 +5887,11 @@ typescript@~5.9.0: resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.3.tgz#5b4f59e15310ab17a216f5d6cf53ee476ede670f" integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw== +ua-parser-js@^0.7.33: + version "0.7.41" + resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.41.tgz#9f6dee58c389e8afababa62a4a2dc22edb69a452" + integrity sha512-O3oYyCMPYgNNHuO7Jjk3uacJWZF8loBgwrfd/5LE/HyZ3lUIOdniQ7DNXJcIgZbwioZxk0fLfI4EVnetdiX5jg== + undici-types@~7.19.0: version "7.19.2" resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.19.2.tgz#1b67fc26d0f157a0cba3a58a5b5c1e2276b8ba2a" @@ -5665,6 +5953,17 @@ util-deprecate@^1.0.2: resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== +util@^0.12.5: + version "0.12.5" + resolved "https://registry.yarnpkg.com/util/-/util-0.12.5.tgz#5f17a6059b73db61a875668781a1c2b136bd6fbc" + integrity sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA== + dependencies: + inherits "^2.0.3" + is-arguments "^1.0.4" + is-generator-function "^1.0.7" + is-typed-array "^1.1.3" + which-typed-array "^1.1.2" + utils-merge@1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" @@ -5747,6 +6046,19 @@ whatwg-url-without-unicode@8.0.0-3: punycode "^2.1.1" webidl-conversions "^5.0.0" +which-typed-array@^1.1.16, which-typed-array@^1.1.2: + version "1.1.20" + resolved "https://registry.yarnpkg.com/which-typed-array/-/which-typed-array-1.1.20.tgz#3fdb7adfafe0ea69157b1509f3a1cd892bd1d122" + integrity sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg== + dependencies: + available-typed-arrays "^1.0.7" + call-bind "^1.0.8" + call-bound "^1.0.4" + for-each "^0.3.5" + get-proto "^1.0.1" + gopd "^1.2.0" + has-tostringtag "^1.0.2" + which@^2.0.1: version "2.0.2" resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1"