Mobile notifications for iOS #210

Merged
talksik merged 9 commits from mobile-notifications into master 2026-05-18 19:44:31 +00:00
61 changed files with 1682 additions and 531 deletions
+1 -1
View File
@@ -46,7 +46,7 @@ migrate-prod:
SKAFFOLD_DEFAULT_REPO=$(PROD_REPO) skaffold run -p migrations --kube-context prod --tail SKAFFOLD_DEFAULT_REPO=$(PROD_REPO) skaffold run -p migrations --kube-context prod --tail
# ---- Deploy ---- # ---- 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 # make deploy-dev MODULE=orion
.PHONY: deploy-dev .PHONY: deploy-dev
+2 -2
View File
@@ -1,6 +1,6 @@
# Orion # Orion
API server and worker services for llink. API server, jobs, and worker services for llink.
## Deploy ## Deploy
@@ -11,7 +11,7 @@ make deploy-prod
# Deploy a single service # Deploy a single service
make deploy-dev MODULE=orion make deploy-dev MODULE=orion
make deploy-dev MODULE=worker make deploy-dev MODULE=particleprocessor
make deploy-dev MODULE=pusher make deploy-dev MODULE=pusher
make deploy-dev MODULE=emailnotifierjob make deploy-dev MODULE=emailnotifierjob
``` ```
+7 -61
View File
@@ -5,7 +5,6 @@ import (
"fmt" "fmt"
"log/slog" "log/slog"
"os" "os"
"strings"
"time" "time"
"cloud.google.com/go/firestore" "cloud.google.com/go/firestore"
@@ -21,22 +20,17 @@ import (
) )
const ( const (
// Only notify about streams with activity in the last 24 hours maxActivityAge = 24 * time.Hour // ignore streams idle longer than this
maxActivityAge = 24 * time.Hour unreadThreshold = 10 * time.Minute // grace window before a message is "unread"
// Minimum time a message must be unread before we consider notifying emailCooldown = 12 * time.Hour // min gap between emails to the same user
unreadThreshold = 10 * time.Minute
// Minimum time between emails to the same user
emailCooldown = 12 * time.Hour
) )
func main() { func main() {
ctx := context.Background() ctx := context.Background()
// Initialize Postgres
db.Init() db.Init()
defer db.Cleanup() defer db.Cleanup()
// Initialize Firestore
gcpProject := utils.MustGetEnv("GCP_PROJECT") gcpProject := utils.MustGetEnv("GCP_PROJECT")
firestoreClient, err := firestore.NewClient(ctx, gcpProject) firestoreClient, err := firestore.NewClient(ctx, gcpProject)
if err != nil { if err != nil {
@@ -45,7 +39,6 @@ func main() {
} }
defer firestoreClient.Close() defer firestoreClient.Close()
// Initialize aero (email) gRPC client
aeroAddr := utils.MustGetEnv("AERO_ADDR") aeroAddr := utils.MustGetEnv("AERO_ADDR")
aeroConn, err := grpc.NewClient(aeroAddr, grpc.WithTransportCredentials(insecure.NewCredentials())) aeroConn, err := grpc.NewClient(aeroAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil { if err != nil {
@@ -55,7 +48,6 @@ func main() {
defer aeroConn.Close() defer aeroConn.Close()
aeroSvc := pbaero.NewPrimaryClient(aeroConn) aeroSvc := pbaero.NewPrimaryClient(aeroConn)
// Initialize pusher gRPC client
pusherAddr := utils.MustGetEnv("PUSHER_GRPC_ADDR") pusherAddr := utils.MustGetEnv("PUSHER_GRPC_ADDR")
pusherConn, err := grpc.NewClient(pusherAddr, grpc.WithTransportCredentials(insecure.NewCredentials())) pusherConn, err := grpc.NewClient(pusherAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil { if err != nil {
@@ -65,7 +57,6 @@ func main() {
defer pusherConn.Close() defer pusherConn.Close()
pusherSvc := pbpusher.NewPusherServiceClient(pusherConn) pusherSvc := pbpusher.NewPusherServiceClient(pusherConn)
// Initialize services
humanSvc := human.NewService(db.Pool()) humanSvc := human.NewService(db.Pool())
networkSvc := network.NewReader(db.Pool()) networkSvc := network.NewReader(db.Pool())
@@ -87,13 +78,11 @@ func runNotificationCycle(
) error { ) error {
now := time.Now() now := time.Now()
// Load all networks
networks, err := networkReader.ListAll(ctx) networks, err := networkReader.ListAll(ctx)
if err != nil { if err != nil {
return fmt.Errorf("listing networks: %w", err) return fmt.Errorf("listing networks: %w", err)
} }
// Load all humans into a lookup map
allHumans, err := humanSvc.ListAll(ctx) allHumans, err := humanSvc.ListAll(ctx)
if err != nil { if err != nil {
return fmt.Errorf("listing humans: %w", err) return fmt.Errorf("listing humans: %w", err)
@@ -103,11 +92,9 @@ func runNotificationCycle(
humansById[h.ID] = h humansById[h.ID] = h
} }
// Track which streams each human is behind on, and the latest activity across those streams
behindCounts := map[string]int{} behindCounts := map[string]int{}
latestActivity := map[string]time.Time{} latestActivity := map[string]time.Time{}
// Get all currently connected humans
allOnline := map[string]bool{} allOnline := map[string]bool{}
onlineResp, err := pusherSvc.GetOnlineHumanIds(ctx, &pbpusher.GetOnlineHumanIdsRequest{}) onlineResp, err := pusherSvc.GetOnlineHumanIds(ctx, &pbpusher.GetOnlineHumanIdsRequest{})
if err != nil { if err != nil {
@@ -119,42 +106,29 @@ func runNotificationCycle(
} }
for _, net := range networks { 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) streams, err := getOpenStreams(ctx, fsClient, net.ID)
if err != nil { if err != nil {
slog.Error("failed to query streams", "networkId", net.ID, "error", err) slog.Error("failed to query streams", "networkId", net.ID, "error", err)
continue continue
} }
// net.MemberHumanIds already includes the admin.
for _, stream := range streams { for _, stream := range streams {
if stream.LastChildCreatedAt == nil { if stream.LastChildCreatedAt == nil {
continue continue
} }
// Skip streams with no recent activity
if now.Sub(*stream.LastChildCreatedAt) > maxActivityAge { if now.Sub(*stream.LastChildCreatedAt) > maxActivityAge {
continue continue
} }
// Skip if the latest message is too fresh (within threshold)
if now.Sub(*stream.LastChildCreatedAt) < unreadThreshold { if now.Sub(*stream.LastChildCreatedAt) < unreadThreshold {
continue continue
} }
// Resolve members from visible_to for _, humanId := range network.ResolveVisibility(stream.VisibleTo, net.MemberHumanIds) {
members := resolveMembers(stream.VisibleTo, networkMembers)
for humanId := range members {
marker, hasMarker := stream.PlaybackMarkers[humanId] marker, hasMarker := stream.PlaybackMarkers[humanId]
if hasMarker && !marker.Before(*stream.LastChildCreatedAt) { 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]++ behindCounts[humanId]++
if stream.LastChildCreatedAt.After(latestActivity[humanId]) { if stream.LastChildCreatedAt.After(latestActivity[humanId]) {
latestActivity[humanId] = *stream.LastChildCreatedAt latestActivity[humanId] = *stream.LastChildCreatedAt
@@ -164,10 +138,8 @@ func runNotificationCycle(
} }
// Send notifications
sentCount := 0 sentCount := 0
for humanId, count := range behindCounts { for humanId, count := range behindCounts {
// Skip online users
if allOnline[humanId] { if allOnline[humanId] {
slog.Info("human online...skipping email", "humanId", humanId) slog.Info("human online...skipping email", "humanId", humanId)
continue continue
@@ -178,28 +150,24 @@ func runNotificationCycle(
continue continue
} }
// Skip if notifications disabled
if !h.EmailNotificationsEnabled { if !h.EmailNotificationsEnabled {
continue 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) { if h.LastEmailNotificationSentAt != nil && !latestActivity[humanId].After(*h.LastEmailNotificationSentAt) {
continue continue
} }
// Enforce cooldown between emails to the same user
if h.LastEmailNotificationSentAt != nil && now.Sub(*h.LastEmailNotificationSentAt) < emailCooldown { if h.LastEmailNotificationSentAt != nil && now.Sub(*h.LastEmailNotificationSentAt) < emailCooldown {
continue continue
} }
// Send email
if err := sendNotificationEmail(ctx, aeroSvc, h, count); err != nil { if err := sendNotificationEmail(ctx, aeroSvc, h, count); err != nil {
slog.Error("failed to send email", "humanId", humanId, "error", err) slog.Error("failed to send email", "humanId", humanId, "error", err)
continue continue
} }
// Update last sent timestamp
if err := humanSvc.UpdateLastEmailNotificationSentAt(ctx, humanId, now); err != nil { if err := humanSvc.UpdateLastEmailNotificationSentAt(ctx, humanId, now); err != nil {
slog.Error("failed to update last_email_notification_sent_at", "humanId", humanId, "error", err) slog.Error("failed to update last_email_notification_sent_at", "humanId", humanId, "error", err)
} }
@@ -215,7 +183,6 @@ func runNotificationCycle(
return nil 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) { func getOpenStreams(ctx context.Context, client *firestore.Client, networkId string) ([]particle.FirestoreStreamParticle, error) {
collPath := fmt.Sprintf("networks/%s/children", networkId) collPath := fmt.Sprintf("networks/%s/children", networkId)
docs, err := client.Collection(collPath). docs, err := client.Collection(collPath).
@@ -239,27 +206,6 @@ func getOpenStreams(ctx context.Context, client *firestore.Client, networkId str
return streams, nil 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 { func sendNotificationEmail(ctx context.Context, aeroSvc pbaero.PrimaryClient, h *human.Human, streamCount int) error {
streamsWord := "stream" streamsWord := "stream"
if streamCount != 1 { if streamCount != 1 {
+7 -15
View File
@@ -1,13 +1,7 @@
// memberreconciler is a one-shot job (also safe to run on a cron) that makes // memberreconciler reconciles the Firestore membership mirror
// the Firestore membership mirror (humans/{humanId}.networks) match the // (humans/{humanId}.networks) against the authoritative Postgres
// authoritative Postgres network_members table. // 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.
// 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.
package main package main
import ( import (
@@ -98,8 +92,7 @@ func reconcile(
return written, scanned, nil return written, scanned, nil
} }
// snapshotMirror streams the humans collection once and returns a map of // One iterator, N billed reads — returns humanId → mirrored networks.
// humanId -> current networks array. One iterator, N billed reads.
func snapshotMirror(ctx context.Context, fs *firestore.Client) (map[string][]string, error) { func snapshotMirror(ctx context.Context, fs *firestore.Client) (map[string][]string, error) {
out := map[string][]string{} out := map[string][]string{}
iter := fs.Collection("humans").Documents(ctx) iter := fs.Collection("humans").Documents(ctx)
@@ -124,9 +117,8 @@ func snapshotMirror(ctx context.Context, fs *firestore.Client) (map[string][]str
return out, nil return out, nil
} }
// sameSet reports whether a and b contain the same elements, ignoring order // Set equality (order- and duplicate-insensitive); Firestore array ops don't
// and duplicates. Firestore array ops don't preserve order, so set equality is // preserve order.
// the right comparison for the networks array.
func sameSet(a, b []string) bool { func sameSet(a, b []string) bool {
if len(a) == 0 && len(b) == 0 { if len(a) == 0 && len(b) == 0 {
return true return true
+8 -3
View File
@@ -18,6 +18,7 @@ import (
"github.com/flowy-live/llink/internal/depot" "github.com/flowy-live/llink/internal/depot"
"github.com/flowy-live/llink/internal/handler" "github.com/flowy-live/llink/internal/handler"
"github.com/flowy-live/llink/internal/human" "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/livekit"
"github.com/flowy-live/llink/internal/livestore" "github.com/flowy-live/llink/internal/livestore"
"github.com/flowy-live/llink/internal/middleware" "github.com/flowy-live/llink/internal/middleware"
@@ -106,9 +107,10 @@ func main() {
BucketName: gcsBucket, BucketName: gcsBucket,
}) })
waitlistSvc := waitlist.NewService(db.Pool(), aeroSvc) waitlistSvc := waitlist.NewService(db.Pool(), aeroSvc)
pushTokenSvc := pushnotify.NewService(db.Pool())
livekitClient := livekit.NewClient() 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 { withAuth := func(hf http.HandlerFunc) http.Handler {
return middleware.Auth(authSvc)(http.HandlerFunc(hf)) return middleware.Auth(authSvc)(http.HandlerFunc(hf))
@@ -143,6 +145,10 @@ func main() {
// Settings // Settings
mux.Handle("PATCH /humans/me/settings", withAuth(h.UpdateSettings)) 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 // Networks
mux.Handle("POST /networks", withAuth(h.CreateNetwork)) mux.Handle("POST /networks", withAuth(h.CreateNetwork))
mux.Handle("GET /networks", withAuth(h.ListNetworks)) mux.Handle("GET /networks", withAuth(h.ListNetworks))
@@ -179,8 +185,7 @@ func main() {
mux.Handle("GET /waitlist/{email}", withAuth(h.GetWaitlistEntry)) mux.Handle("GET /waitlist/{email}", withAuth(h.GetWaitlistEntry))
mux.Handle("POST /waitlist/invite", withAuth(h.InviteWaitlistEntrant)) mux.Handle("POST /waitlist/invite", withAuth(h.InviteWaitlistEntrant))
// Apply middleware // nil = allow all origins (Electron app needs it).
// nil allows all origins (required for electron app)
muxWithCors := middleware.CORS(nil)(mux) muxWithCors := middleware.CORS(nil)(mux)
addr := fmt.Sprintf("0.0.0.0:%s", port) addr := fmt.Sprintf("0.0.0.0:%s", port)
+192 -40
View File
@@ -6,11 +6,15 @@ import (
"log" "log"
"log/slog" "log/slog"
"os" "os"
"strings"
"time" "time"
"github.com/flowy-live/llink/internal/billing" "github.com/flowy-live/llink/internal/billing"
"github.com/flowy-live/llink/internal/db" "github.com/flowy-live/llink/internal/db"
"github.com/flowy-live/llink/internal/depot" "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/particle"
"github.com/flowy-live/llink/internal/speech" "github.com/flowy-live/llink/internal/speech"
"github.com/flowy-live/llink/internal/utils" "github.com/flowy-live/llink/internal/utils"
@@ -31,13 +35,9 @@ func createClient(ctx context.Context) *firestore.Client {
return client return client
} }
// The purpose of the particle processor worker is to listen for new particles // Listens for new particles and runs per-particle side effects: transcripts,
// across all streams and perform side effects such as // transcode, parent stream's last_child_created_at, freemium usage, and push
// - generate transcript if the particle is of type media // notifications for offline recipients.
// - 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
func main() { func main() {
ctx := context.Background() ctx := context.Background()
@@ -61,6 +61,14 @@ func main() {
speechSvc := speech.NewSpeechService(ctx) 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) client := createClient(ctx)
defer client.Close() defer client.Close()
@@ -102,13 +110,14 @@ func main() {
slog.Debug("processing particle", "particleID", particleID, "data", change.Doc.Data()) slog.Debug("processing particle", "particleID", particleID, "data", change.Doc.Data())
// --- Perform side effects --- // Side effects below are best-effort — failures don't prevent
// All of them do not stop us from marking the particle as processed // marking the particle as processed.
parentDoc := loadParentParticle(ctx, change.Doc)
updateParentLastChildCreatedAt(ctx, change.Doc) updateParentLastChildCreatedAt(ctx, change.Doc, parentDoc)
transcribeMediaParticle(ctx, depotSvc, speechSvc, change.Doc) transcript := transcribeMediaParticle(ctx, depotSvc, speechSvc, change.Doc)
particle.Transcode(ctx, depotSvc, change.Doc) particle.Transcode(ctx, depotSvc, change.Doc)
recordFreemiumUsage(ctx, billingSvc, change.Doc) recordFreemiumUsage(ctx, billingSvc, change.Doc)
notifyForParticle(ctx, notifier, humanSvc, change.Doc, parentDoc, transcript)
if err := processingRepo.MarkProcessed(ctx, particleID); err != nil { if err := processingRepo.MarkProcessed(ctx, particleID); err != nil {
slog.Error("failed to mark particle as processed", "particleID", particleID, "error", err) 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 var mediaParticle particle.FirestoreMediaParticle
err := doc.DataTo(&mediaParticle) err := doc.DataTo(&mediaParticle)
if err != nil { if err != nil {
slog.Error("unable to marshal particle data", "error", err) slog.Error("unable to marshal particle data", "error", err)
return return ""
} }
particleType, err := particle.ParseParticleType(mediaParticle.Type) particleType, err := particle.ParseParticleType(mediaParticle.Type)
if err != nil { if err != nil {
slog.Error("invalid particle type", "error", err) slog.Error("invalid particle type", "error", err)
return return ""
} }
if particleType != particle.TypeMedia { if particleType != particle.TypeMedia {
slog.Info("received a particle of type", "particle type", particleType) slog.Info("received a particle of type", "particle type", particleType)
return return ""
} }
downloadURL, err := depotSvc.GetDownloadURL(ctx, mediaParticle.Properties.ObjectId) downloadURL, err := depotSvc.GetDownloadURL(ctx, mediaParticle.Properties.ObjectId)
if err != nil { if err != nil {
slog.Error("failed to get download URL", "error", err, "object_id", mediaParticle.Properties.ObjectId) slog.Error("failed to get download URL", "error", err, "object_id", mediaParticle.Properties.ObjectId)
return return ""
} }
result, err := speechSvc.Transcribe(ctx, downloadURL) result, err := speechSvc.Transcribe(ctx, downloadURL)
if err != nil { if err != nil {
slog.Error("failed to transcribe media", "error", err, "particleID", doc.Ref.ID) slog.Error("failed to transcribe media", "error", err, "particleID", doc.Ref.ID)
return return ""
} }
transcript := toFirestoreTranscript(result) transcript := toFirestoreTranscript(result)
@@ -157,10 +168,11 @@ func transcribeMediaParticle(ctx context.Context, depotSvc depot.Service, speech
}, firestore.MergeAll) }, firestore.MergeAll)
if err != nil { if err != nil {
slog.Error("failed to update transcript in firestore", "error", err, "particleID", doc.Ref.ID) 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) slog.Info("transcribed media particle", "particleID", doc.Ref.ID)
return transcript.Transcript
} }
func toFirestoreTranscript(result *speech.TranscriptResult) particle.FirestoreTranscript { 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 // Bumps the network's daily message counter for non-container particles.
// particles. Idempotent via the surrounding processed_particles guard: the worker // The surrounding processed_particles guard keeps this idempotent across
// only reaches this path on first-seen particles, so a crash/restart won't // crashes/restarts.
// double-count.
func recordFreemiumUsage(ctx context.Context, billingSvc billing.Service, doc *firestore.DocumentSnapshot) { func recordFreemiumUsage(ctx context.Context, billingSvc billing.Service, doc *firestore.DocumentSnapshot) {
rawType, err := doc.DataAt("type") rawType, err := doc.DataAt("type")
if err != nil { 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) slog.Error("invalid particle type", "error", err, "particleID", doc.Ref.ID)
return 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 { if particleType == particle.TypeStream || particleType == particle.TypeFolder {
return 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 // Returns nil (and logs) if the path has no parent or the read fails.
// to the child's actual created_at timestamp, so it stays directly comparable with func loadParentParticle(ctx context.Context, doc *firestore.DocumentSnapshot) *firestore.DocumentSnapshot {
// playback markers (which also store child created_at values).
func updateParentLastChildCreatedAt(ctx context.Context, doc *firestore.DocumentSnapshot) {
parentChildrenCollectionRef := doc.Ref.Parent parentChildrenCollectionRef := doc.Ref.Parent
if parentChildrenCollectionRef == nil { if parentChildrenCollectionRef == nil {
return return nil
} }
parentParticleDocRef := parentChildrenCollectionRef.Parent parentParticleDocRef := parentChildrenCollectionRef.Parent
if parentParticleDocRef == nil { if parentParticleDocRef == nil {
slog.Error("particle has no parent document", "particleID", doc.Ref.ID) slog.Error("particle has no parent document", "particleID", doc.Ref.ID)
return return nil
} }
parentParticleDoc, err := parentParticleDocRef.Get(ctx) parentParticleDoc, err := parentParticleDocRef.Get(ctx)
if err != nil { 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 return
} }
slog.Info("parent particle is", "parent particle id", parentParticleDoc.Ref.ID)
var streamParticle particle.FirestoreStreamParticle 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) slog.Error("failed to parse stream particle", "error", err)
return return
} }
@@ -272,21 +286,159 @@ func updateParentLastChildCreatedAt(ctx context.Context, doc *firestore.Document
return return
} }
// Read the child's created_at — this is the same value that playback markers store
childCreatedAt, err := doc.DataAt("created_at") childCreatedAt, err := doc.DataAt("created_at")
if err != nil { if err != nil {
slog.Error("failed to read child created_at", "error", err, "particleID", doc.Ref.ID) slog.Error("failed to read child created_at", "error", err, "particleID", doc.Ref.ID)
return return
} }
slog.Info("going to update the last_child_created_at for parent particle") _, err = parent.Ref.Update(ctx, []firestore.Update{
_, err = parentParticleDocRef.Update(ctx, []firestore.Update{
{ {
Path: "last_child_created_at", Path: "last_child_created_at",
Value: childCreatedAt, Value: childCreatedAt,
}, },
}) })
if err != nil { 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] + "…"
}
+7 -19
View File
@@ -25,29 +25,22 @@ func main() {
port := utils.MustGetEnv("PORT") port := utils.MustGetEnv("PORT")
grpcPort := utils.MustGetEnv("GRPC_PORT") grpcPort := utils.MustGetEnv("GRPC_PORT")
// Initialize database (for network membership checks)
db.Init() db.Init()
defer db.Cleanup() defer db.Cleanup()
// Redis for auth session validation (same DB as orion) authRedis := internal.ConnectAndTestRedis(db.RedisDBAuth) // shared with orion
authRedis := internal.ConnectAndTestRedis(db.RedisDBAuth) pusherRedis := internal.ConnectAndTestRedis(db.RedisDBPusher) // presence + pub/sub
// Redis for pusher state (presence hashes, pub/sub) sessionReader := auth.NewSessionReader(authRedis)
pusherRedis := internal.ConnectAndTestRedis(db.RedisDBPusher) networkReader := network.NewReader(db.Pool())
// Services // Hostname is the k8s pod name.
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)
podID, err := os.Hostname() podID, err := os.Hostname()
if err != nil { if err != nil {
podID = fmt.Sprintf("pod-%d", os.Getpid()) podID = fmt.Sprintf("pod-%d", os.Getpid())
} }
// Pusher core
bridge := pusher.NewRedisBridge(pusherRedis, podID) bridge := pusher.NewRedisBridge(pusherRedis, podID)
// Context for graceful shutdown
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()
@@ -56,16 +49,11 @@ func main() {
bridge.SetHub(hub) bridge.SetHub(hub)
server := pusher.NewServer(ctx, hub, bridge, sessionReader) server := pusher.NewServer(ctx, hub, bridge, sessionReader)
// Start hub event loop
go hub.Run(ctx) go hub.Run(ctx)
// Start Redis Pub/Sub listener
go bridge.Listen(ctx) go bridge.Listen(ctx)
// Start pod heartbeat + stale pod cleanup
go bridge.Heartbeat(ctx) 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)) grpcListener, err := net.Listen("tcp", fmt.Sprintf("0.0.0.0:%s", grpcPort))
if err != nil { if err != nil {
slog.Error("failed to listen for gRPC", "port", grpcPort, "error", err) slog.Error("failed to listen for gRPC", "port", grpcPort, "error", err)
@@ -104,7 +92,7 @@ func main() {
<-sigCh <-sigCh
slog.Info("shutting down...") slog.Info("shutting down...")
cancel() // stops hub, bridge listener, heartbeat cancel()
grpcServer.GracefulStop() grpcServer.GracefulStop()
httpServer.Shutdown(context.Background()) httpServer.Shutdown(context.Background())
+4 -12
View File
@@ -1,12 +1,6 @@
// transcodebackfill is a one-shot job that scans every doc under the // transcodebackfill walks every doc under the "children" collection group and
// "children" Firestore collection group, identifies media particles missing a // re-runs transcode for media particles missing a transcoded variant.
// transcoded variant, and re-runs the transcode + upload + Firestore-update // Idempotent: particle.Transcode short-circuits on transcoded_object_id != "".
// 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.
package main package main
import ( import (
@@ -104,9 +98,7 @@ func run(ctx context.Context, fs *firestore.Client, depotSvc depot.Service) (sta
s.scanned++ s.scanned++
// Cheap pre-filter: most docs under the "children" collection group // DataAt avoids unmarshalling the full doc; most children aren't media.
// are not media particles. DataAt avoids unmarshalling the full
// document for those.
rawType, err := doc.DataAt("type") rawType, err := doc.DataAt("type")
if err != nil { if err != nil {
s.skippedNonMedia++ s.skippedNonMedia++
+2 -3
View File
@@ -18,9 +18,8 @@ type sessionReaderImpl struct {
redisClient *redis.Client redisClient *redis.Client
} }
// newSessionReader returns the concrete reader. Used by NewAuthService to // Exposes the concrete type so authServiceImpl can embed it without
// embed without going through the SessionReader interface (which would hide // hiding redisClient behind the SessionReader interface.
// redisClient from the rest of authServiceImpl).
func newSessionReader(redisClient *redis.Client) *sessionReaderImpl { func newSessionReader(redisClient *redis.Client) *sessionReaderImpl {
return &sessionReaderImpl{redisClient: redisClient} return &sessionReaderImpl{redisClient: redisClient}
} }
+5 -6
View File
@@ -47,17 +47,16 @@ type Session struct {
type AuthService interface { type AuthService interface {
SessionReader SessionReader
// RequestSignInCode generates a code and emails it to the provided email. // RequestSignInCode emails a one-time code; the client redeems it via VerifySignInCode.
// To retrieve a session, client must verify with VerifySignInCode.
RequestSignInCode(ctx context.Context, email string) error RequestSignInCode(ctx context.Context, email string) error
// VerifySignInCode returns ErrInvalidCode if incorrect code, otherwise creates a session. // VerifySignInCode returns ErrInvalidCode on a wrong code, otherwise creates
// humanId is stored in the session alongside the email. // a session keyed to (email, humanId).
VerifySignInCode(ctx context.Context, email, code, humanId string) (sessionToken string, err error) 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 ExtendSession(ctx context.Context, sessionToken string) error
SignOut(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) MintFirebaseCustomToken(ctx context.Context, humanId string) (string, error)
IsSystemAdmin(ctx context.Context, email string) bool IsSystemAdmin(ctx context.Context, email string) bool
+2 -3
View File
@@ -73,9 +73,8 @@ func NewService(ctx context.Context, pool *pgxpool.Pool, cfg Config) (Service, e
}, nil }, nil
} }
// NewServiceForWorker builds a minimal billing Service suitable for the // NewServiceForWorker skips Stripe client setup since workers only exercise
// particle processor worker: only the usage-tracking path is exercised, so // the usage-tracking path. No API key required.
// we skip Stripe client setup (no API key required).
func NewServiceForWorker(pool *pgxpool.Pool) Service { func NewServiceForWorker(pool *pgxpool.Pool) Service {
return &serviceImpl{ return &serviceImpl{
usageRepo: newUsageRepository(pool), usageRepo: newUsageRepository(pool),
+1 -3
View File
@@ -2,11 +2,9 @@ package billing
import "time" import "time"
// FreemiumDailyLimit is the per-network daily cap on usage, // Per-network daily cap on the free plan. Unit-agnostic.
// agnostic of the units that this refer to. This is only relevant for the "free" plan.
const FreemiumDailyLimit = 50 const FreemiumDailyLimit = 50
// Usage describes a network's current freemium quota state for today.
type Usage struct { type Usage struct {
Plan Plan `json:"plan"` Plan Plan `json:"plan"`
Used int `json:"used"` Used int `json:"used"`
+3 -3
View File
@@ -1,8 +1,8 @@
package db package db
// Shared database namespaces used across services // FIX: move to a dedicated Redis instance. The high DB numbers exist because
// FIX: Use separate redis instance. We start with higher number because use this same instance in helios. // this instance is shared with helios.
const ( const (
RedisDBAuth = 4 // auth sessions RedisDBAuth = 4 // auth sessions
RedisDBPusher = 5 // dedicated to pusher state (presence, pub/sub) RedisDBPusher = 5 // presence, pub/sub
) )
+3 -9
View File
@@ -2,7 +2,6 @@ package depot
import "time" import "time"
// Object represents a stored object in the depot
type Object struct { type Object struct {
ID string ID string
Name string Name string
@@ -14,31 +13,26 @@ type Object struct {
CreatedAt time.Time CreatedAt time.Time
} }
// PrepareUploadInput represents the input for preparing an upload
type PrepareUploadInput struct { type PrepareUploadInput struct {
Prefix string // Optional prefix for organizing objects (e.g., network_id) Prefix string // optional, e.g. network_id
Name string Name string
ContentType string ContentType string
ContentLength int64 ContentLength int64
} }
// PrepareUploadResult represents the result of preparing an upload
type PrepareUploadResult struct { type PrepareUploadResult struct {
ObjectID string ObjectID string
UploadURL string UploadURL string
UploadHeaders map[string]string UploadHeaders map[string]string
} }
// CreateFromReaderInput is for server-side direct uploads (no presigned URL). // 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.
type CreateFromReaderInput struct { type CreateFromReaderInput struct {
Prefix string // Optional prefix for organizing objects (e.g., network_id) Prefix string // optional, e.g. network_id
Name string Name string
ContentType string ContentType string
} }
// Config holds configuration for the depot service
type Config struct { type Config struct {
GoogleServiceAccountEmail string GoogleServiceAccountEmail string
BucketName string BucketName string
+10 -18
View File
@@ -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")) 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) 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{ obj := &Object{
Name: input.Name, Name: input.Name,
ContentType: input.ContentType, ContentType: input.ContentType,
@@ -92,8 +92,7 @@ func (s *serviceImpl) PrepareUpload(ctx context.Context, input PrepareUploadInpu
return nil, err return nil, err
} }
// Generate a signed URL for uploading with Content-Length enforcement // Content-Length is part of the signature, so the client must send it verbatim.
// The Headers field specifies headers that MUST be included in the upload request
contentLengthHeader := fmt.Sprintf("Content-Length:%d", input.ContentLength) contentLengthHeader := fmt.Sprintf("Content-Length:%d", input.ContentLength)
uploadURL, err := s.storageClient.Bucket(s.bucketName).SignedURL(objectKey, &storage.SignedURLOptions{ uploadURL, err := s.storageClient.Bucket(s.bucketName).SignedURL(objectKey, &storage.SignedURLOptions{
GoogleAccessID: s.googleServiceAccountEmail, GoogleAccessID: s.googleServiceAccountEmail,
@@ -104,7 +103,7 @@ func (s *serviceImpl) PrepareUpload(ctx context.Context, input PrepareUploadInpu
}) })
if err != nil { if err != nil {
slog.Error("failed to generate signed upload URL", "error", err, "bucket", s.bucketName, "object_key", objectKey) 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 { 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) 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 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) attrs, err := s.storageClient.Bucket(obj.BucketName).Object(obj.ObjectKey).Attrs(ctx)
if err != nil { if err != nil {
if errors.Is(err, storage.ErrObjectNotExist) { if errors.Is(err, storage.ErrObjectNotExist) {
@@ -140,12 +138,10 @@ func (s *serviceImpl) ConfirmUpload(ctx context.Context, objectID string) (*Obje
return nil, err return nil, err
} }
// Verify content length matches what was declared
if attrs.Size != obj.ContentLength { if attrs.Size != obj.ContentLength {
return nil, errors.Join(ErrInvalidInput, fmt.Errorf("content length mismatch: expected %d, got %d", obj.ContentLength, attrs.Size)) 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 err := s.repo.setContainsContent(ctx, objectID, true); err != nil {
if errors.Is(err, errNotFound) { if errors.Is(err, errNotFound) {
return nil, ErrNotFound return nil, ErrNotFound
@@ -153,14 +149,12 @@ func (s *serviceImpl) ConfirmUpload(ctx context.Context, objectID string) (*Obje
return nil, err return nil, err
} }
// Fetch and return the updated object
return s.repo.getByID(ctx, objectID) return s.repo.getByID(ctx, objectID)
} }
// CreateFromReader streams bytes directly to GCS using the storage client and // CreateFromReader streams bytes straight to GCS and writes the row in one
// records the depot_objects row in one shot. Unlike PrepareUpload, there is no // shot — no signed URL, no client round-trip. For server-side flows that
// signed URL or client round-trip — the caller already has the bytes. Intended // already have the bytes (e.g. transcoded variants).
// for worker-side flows (e.g. transcoded media variants).
func (s *serviceImpl) CreateFromReader(ctx context.Context, input CreateFromReaderInput, body io.Reader) (*Object, error) { func (s *serviceImpl) CreateFromReader(ctx context.Context, input CreateFromReaderInput, body io.Reader) (*Object, error) {
if input.Name == "" { if input.Name == "" {
return nil, errors.Join(ErrInvalidInput, errors.New("name is required")) 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 := s.storageClient.Bucket(s.bucketName).Object(objectKey).NewWriter(ctx)
w.ContentType = input.ContentType w.ContentType = input.ContentType
if _, err := io.Copy(w, body); err != nil { 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 { if cerr := w.Close(); cerr != nil {
slog.Warn("failed to close GCS writer after copy failure", "error", cerr, "object_key", objectKey) 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) created, err := s.repo.create(ctx, obj)
if err != nil { 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 { 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) 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 return "", err
} }
// Generate a signed URL for downloading
downloadURL, err := s.storageClient.Bucket(obj.BucketName).SignedURL(obj.ObjectKey, &storage.SignedURLOptions{ downloadURL, err := s.storageClient.Bucket(obj.BucketName).SignedURL(obj.ObjectKey, &storage.SignedURLOptions{
GoogleAccessID: s.googleServiceAccountEmail, GoogleAccessID: s.googleServiceAccountEmail,
Method: "GET", Method: "GET",
@@ -250,14 +243,13 @@ func (s *serviceImpl) Delete(ctx context.Context, objectID string) error {
return err 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) gcsErr := s.storageClient.Bucket(obj.BucketName).Object(obj.ObjectKey).Delete(ctx)
if gcsErr != nil && !errors.Is(gcsErr, storage.ErrObjectNotExist) { 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) slog.Error("failed to delete object from GCS", "error", gcsErr, "bucket", obj.BucketName, "object_key", obj.ObjectKey)
return gcsErr return gcsErr
} }
// Delete from database
if err := s.repo.delete(ctx, objectID); err != nil { if err := s.repo.delete(ctx, objectID); err != nil {
if errors.Is(err, errNotFound) { if errors.Is(err, errNotFound) {
return ErrNotFound return ErrNotFound
+4 -8
View File
@@ -25,12 +25,8 @@ type PortalSessionResponse struct {
URL string `json:"url"` URL string `json:"url"`
} }
// GetNetworkUsage returns the freemium quota state for the authenticated // GetNetworkUsage reports today's usage, daily limit (nil on pro), and reset
// caller's current network: how many messages they've used today, the daily // time. Open to any network member since the UI surfaces it to every sender.
// 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.
func (h *Handler) GetNetworkUsage(w http.ResponseWriter, r *http.Request) { func (h *Handler) GetNetworkUsage(w http.ResponseWriter, r *http.Request) {
humanId, ok := middleware.HumanIdFromContext(r.Context()) humanId, ok := middleware.HumanIdFromContext(r.Context())
if !ok { if !ok {
@@ -170,8 +166,8 @@ func (h *Handler) HandleStripeWebhook(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
} }
// loadNetworkForAdmin resolves the {id} path param and verifies the caller // Resolves {id}, verifies the caller is admin. On failure writes the HTTP
// is the network's admin. On failure it writes the HTTP error and returns ok=false. // error and returns ok=false.
func (h *Handler) loadNetworkForAdmin(w http.ResponseWriter, r *http.Request) (*network.Network, string, bool) { func (h *Handler) loadNetworkForAdmin(w http.ResponseWriter, r *http.Request) (*network.Network, string, bool) {
humanId, ok := middleware.HumanIdFromContext(r.Context()) humanId, ok := middleware.HumanIdFromContext(r.Context())
if !ok { if !ok {
+24 -41
View File
@@ -15,6 +15,7 @@ import (
"github.com/flowy-live/llink/internal/billing" "github.com/flowy-live/llink/internal/billing"
"github.com/flowy-live/llink/internal/depot" "github.com/flowy-live/llink/internal/depot"
"github.com/flowy-live/llink/internal/human" "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/livekit"
"github.com/flowy-live/llink/internal/middleware" "github.com/flowy-live/llink/internal/middleware"
"github.com/flowy-live/llink/internal/network" "github.com/flowy-live/llink/internal/network"
@@ -32,6 +33,7 @@ type Handler struct {
depotSvc depot.Service depotSvc depot.Service
waitlistSvc waitlist.Service waitlistSvc waitlist.Service
billingSvc billing.Service billingSvc billing.Service
pushTokenSvc pushnotify.Service
livekitClient livekit.Client livekitClient livekit.Client
firestoreClient *firestore.Client firestoreClient *firestore.Client
} }
@@ -44,6 +46,7 @@ func NewHandler(
depotSvc depot.Service, depotSvc depot.Service,
waitlistSvc waitlist.Service, waitlistSvc waitlist.Service,
billingSvc billing.Service, billingSvc billing.Service,
pushTokenSvc pushnotify.Service,
livekitClient livekit.Client, livekitClient livekit.Client,
firestoreClient *firestore.Client, firestoreClient *firestore.Client,
) *Handler { ) *Handler {
@@ -55,6 +58,7 @@ func NewHandler(
depotSvc: depotSvc, depotSvc: depotSvc,
waitlistSvc: waitlistSvc, waitlistSvc: waitlistSvc,
billingSvc: billingSvc, billingSvc: billingSvc,
pushTokenSvc: pushTokenSvc,
livekitClient: livekitClient, livekitClient: livekitClient,
firestoreClient: firestoreClient, firestoreClient: firestoreClient,
} }
@@ -168,7 +172,7 @@ type DepotObject struct {
// Auth Handlers // 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) { func (h *Handler) RequestSignInCode(w http.ResponseWriter, r *http.Request) {
var req RequestSignInCodeRequest var req RequestSignInCodeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 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 return
} }
// Auto-create human if doesn't exist
_, err := h.humanSvc.GetOrCreateByEmail(r.Context(), req.Email) _, err := h.humanSvc.GetOrCreateByEmail(r.Context(), req.Email)
if err != nil { if err != nil {
slog.Error("failed to get or create human", "error", err, "email", req.Email) 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 return
} }
// Request sign-in code
if err := h.authSvc.RequestSignInCode(r.Context(), req.Email); err != nil { if err := h.authSvc.RequestSignInCode(r.Context(), req.Email); err != nil {
slog.Error("failed to request sign-in code", "error", err, "email", req.Email) slog.Error("failed to request sign-in code", "error", err, "email", req.Email)
http.Error(w, "internal server error", http.StatusInternalServerError) 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) w.WriteHeader(http.StatusNoContent)
} }
// SignIn verifies the code and returns a session token
func (h *Handler) SignIn(w http.ResponseWriter, r *http.Request) { func (h *Handler) SignIn(w http.ResponseWriter, r *http.Request) {
var req SignInRequest var req SignInRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 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 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) hum, err := h.humanSvc.GetByEmail(r.Context(), req.Email)
if err != nil { if err != nil {
if errors.Is(err, human.ErrNotFound) { 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) json.NewEncoder(w).Encode(resp)
} }
// FirebaseToken mints a Firebase custom token for the authenticated human so // FirebaseToken mints a custom token so the client can signInWithCustomToken
// the client can signInWithCustomToken and have request.auth.uid populated in // and have request.auth.uid populated in Firestore security rules.
// Firestore security rules.
func (h *Handler) FirebaseToken(w http.ResponseWriter, r *http.Request) { func (h *Handler) FirebaseToken(w http.ResponseWriter, r *http.Request) {
humanId, ok := middleware.HumanIdFromContext(r.Context()) humanId, ok := middleware.HumanIdFromContext(r.Context())
if !ok { if !ok {
@@ -265,7 +265,6 @@ func (h *Handler) FirebaseToken(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(FirebaseTokenResponse{Token: token}) 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) { func (h *Handler) SignOut(w http.ResponseWriter, r *http.Request) {
token := extractBearerToken(r) token := extractBearerToken(r)
if token == "" { if token == "" {
@@ -282,7 +281,6 @@ func (h *Handler) SignOut(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
// GetCurrentHuman returns the authenticated human
func (h *Handler) GetCurrentHuman(w http.ResponseWriter, r *http.Request) { func (h *Handler) GetCurrentHuman(w http.ResponseWriter, r *http.Request) {
email, ok := middleware.EmailFromContext(r.Context()) email, ok := middleware.EmailFromContext(r.Context())
if !ok { if !ok {
@@ -310,7 +308,6 @@ type UpdateSettingsRequest struct {
EmailNotificationsEnabled *bool `json:"email_notifications_enabled"` EmailNotificationsEnabled *bool `json:"email_notifications_enabled"`
} }
// UpdateSettings updates the authenticated human's settings
func (h *Handler) UpdateSettings(w http.ResponseWriter, r *http.Request) { func (h *Handler) UpdateSettings(w http.ResponseWriter, r *http.Request) {
humanId, ok := middleware.HumanIdFromContext(r.Context()) humanId, ok := middleware.HumanIdFromContext(r.Context())
if !ok { if !ok {
@@ -339,7 +336,6 @@ func (h *Handler) UpdateSettings(w http.ResponseWriter, r *http.Request) {
// Network Handlers // Network Handlers
// ============================================================================ // ============================================================================
// CreateNetwork creates a new network
func (h *Handler) CreateNetwork(w http.ResponseWriter, r *http.Request) { func (h *Handler) CreateNetwork(w http.ResponseWriter, r *http.Request) {
humanId, ok := middleware.HumanIdFromContext(r.Context()) humanId, ok := middleware.HumanIdFromContext(r.Context())
if !ok { if !ok {
@@ -376,7 +372,6 @@ func (h *Handler) CreateNetwork(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(resp) json.NewEncoder(w).Encode(resp)
} }
// ListNetworks retrieves networks for the authenticated human
func (h *Handler) ListNetworks(w http.ResponseWriter, r *http.Request) { func (h *Handler) ListNetworks(w http.ResponseWriter, r *http.Request) {
humanId, ok := middleware.HumanIdFromContext(r.Context()) humanId, ok := middleware.HumanIdFromContext(r.Context())
if !ok { if !ok {
@@ -405,7 +400,6 @@ func (h *Handler) ListNetworks(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(resp) json.NewEncoder(w).Encode(resp)
} }
// GetNetwork retrieves a specific network
func (h *Handler) GetNetwork(w http.ResponseWriter, r *http.Request) { func (h *Handler) GetNetwork(w http.ResponseWriter, r *http.Request) {
humanId, ok := middleware.HumanIdFromContext(r.Context()) humanId, ok := middleware.HumanIdFromContext(r.Context())
if !ok { if !ok {
@@ -452,8 +446,8 @@ func (h *Handler) GetNetwork(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(resp) json.NewEncoder(w).Encode(resp)
} }
// AddMembersToNetwork adds members to a network. Registered users are added as members, // AddMembersToNetwork routes registered users into membership and emails an
// unregistered users receive email invitations. // invitation to the rest.
func (h *Handler) AddMembersToNetwork(w http.ResponseWriter, r *http.Request) { func (h *Handler) AddMembersToNetwork(w http.ResponseWriter, r *http.Request) {
humanId, ok := middleware.HumanIdFromContext(r.Context()) humanId, ok := middleware.HumanIdFromContext(r.Context())
if !ok { if !ok {
@@ -489,7 +483,6 @@ func (h *Handler) AddMembersToNetwork(w http.ResponseWriter, r *http.Request) {
return return
} }
// Resolve emails: registered users become members, unregistered get invitations
var memberHumanIds []string var memberHumanIds []string
var inviteEmails []string var inviteEmails []string
for _, email := range req.EmailAddresses { 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) net, err := h.networkSvc.GetByID(r.Context(), networkID)
if err != nil { if err != nil {
slog.Error("failed to get network after adding members", "error", err, "network_id", networkID) 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) json.NewEncoder(w).Encode(resp)
} }
// RemoveMemberFromNetwork removes a member from a network. Admin-only. // RemoveMemberFromNetwork is admin-only. Admins cannot remove themselves
// Admins cannot remove themselves — doing so would leave networks.admin_human_id // (would orphan networks.admin_human_id); removing a non-member is a no-op (204).
// dangling. Removal of a non-member is a no-op (204).
func (h *Handler) RemoveMemberFromNetwork(w http.ResponseWriter, r *http.Request) { func (h *Handler) RemoveMemberFromNetwork(w http.ResponseWriter, r *http.Request) {
net, _, ok := h.loadNetworkForAdmin(w, r) net, _, ok := h.loadNetworkForAdmin(w, r)
if !ok { if !ok {
@@ -575,7 +566,6 @@ func (h *Handler) RemoveMemberFromNetwork(w http.ResponseWriter, r *http.Request
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
// ListInvitationsForNetwork returns pending invitations for a network
func (h *Handler) ListInvitationsForNetwork(w http.ResponseWriter, r *http.Request) { func (h *Handler) ListInvitationsForNetwork(w http.ResponseWriter, r *http.Request) {
humanId, ok := middleware.HumanIdFromContext(r.Context()) humanId, ok := middleware.HumanIdFromContext(r.Context())
if !ok { if !ok {
@@ -621,7 +611,6 @@ func (h *Handler) ListInvitationsForNetwork(w http.ResponseWriter, r *http.Reque
json.NewEncoder(w).Encode(resp) json.NewEncoder(w).Encode(resp)
} }
// ListMyInvitations returns pending invitations for the authenticated user
func (h *Handler) ListMyInvitations(w http.ResponseWriter, r *http.Request) { func (h *Handler) ListMyInvitations(w http.ResponseWriter, r *http.Request) {
email, ok := middleware.EmailFromContext(r.Context()) email, ok := middleware.EmailFromContext(r.Context())
if !ok { if !ok {
@@ -650,7 +639,6 @@ func (h *Handler) ListMyInvitations(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(resp) 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) { func (h *Handler) AcceptInvitation(w http.ResponseWriter, r *http.Request) {
email, ok := middleware.EmailFromContext(r.Context()) email, ok := middleware.EmailFromContext(r.Context())
if !ok { if !ok {
@@ -683,7 +671,6 @@ func (h *Handler) AcceptInvitation(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
// RevokeInvitation revokes a pending invitation from a network
func (h *Handler) RevokeInvitation(w http.ResponseWriter, r *http.Request) { func (h *Handler) RevokeInvitation(w http.ResponseWriter, r *http.Request) {
humanId, ok := middleware.HumanIdFromContext(r.Context()) humanId, ok := middleware.HumanIdFromContext(r.Context())
if !ok { if !ok {
@@ -728,7 +715,7 @@ func (h *Handler) RevokeInvitation(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent) 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) { func (h *Handler) DownloadParticleMedia(w http.ResponseWriter, r *http.Request) {
_, ok := middleware.EmailFromContext(r.Context()) _, ok := middleware.EmailFromContext(r.Context())
if !ok { if !ok {
@@ -755,7 +742,7 @@ func (h *Handler) DownloadParticleMedia(w http.ResponseWriter, r *http.Request)
// Depot Handlers // 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) { func (h *Handler) PrepareUpload(w http.ResponseWriter, r *http.Request) {
humanId, ok := middleware.HumanIdFromContext(r.Context()) humanId, ok := middleware.HumanIdFromContext(r.Context())
if !ok { if !ok {
@@ -813,7 +800,6 @@ func (h *Handler) PrepareUpload(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(resp) json.NewEncoder(w).Encode(resp)
} }
// ConfirmUpload confirms that an upload has been completed
func (h *Handler) ConfirmUpload(w http.ResponseWriter, r *http.Request) { func (h *Handler) ConfirmUpload(w http.ResponseWriter, r *http.Request) {
_, ok := middleware.EmailFromContext(r.Context()) _, ok := middleware.EmailFromContext(r.Context())
if !ok { if !ok {
@@ -881,7 +867,7 @@ type InviteWaitlistEntrantRequest struct {
// Waitlist Handlers // 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) { func (h *Handler) AddToWaitlist(w http.ResponseWriter, r *http.Request) {
var req AddToWaitlistRequest var req AddToWaitlistRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 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) 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) { func (h *Handler) GetWaitlist(w http.ResponseWriter, r *http.Request) {
if !middleware.IsAdminFromContext(r.Context()) { if !middleware.IsAdminFromContext(r.Context()) {
http.Error(w, "forbidden", http.StatusForbidden) 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) 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) { func (h *Handler) GetWaitlistEntry(w http.ResponseWriter, r *http.Request) {
if !middleware.IsAdminFromContext(r.Context()) { if !middleware.IsAdminFromContext(r.Context()) {
http.Error(w, "forbidden", http.StatusForbidden) 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)) 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) { func (h *Handler) InviteWaitlistEntrant(w http.ResponseWriter, r *http.Request) {
if !middleware.IsAdminFromContext(r.Context()) { if !middleware.IsAdminFromContext(r.Context()) {
http.Error(w, "forbidden", http.StatusForbidden) http.Error(w, "forbidden", http.StatusForbidden)
@@ -1079,7 +1065,7 @@ func (h *Handler) GetLivekitToken(w http.ResponseWriter, r *http.Request) {
return 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 roomName := req.NetworkId + "/" + req.StreamId
token, err := h.livekitClient.GetJoinToken(roomName, humanId, humanEmail) 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()}) json.NewEncoder(w).Encode(GetLivekitTokenResponse{Token: token, ServerUrl: h.livekitClient.ServerUrl()})
} }
// HandleLivekitWebhook processes LiveKit webhook events for huddle presence. // HandleLivekitWebhook verifies the webhook signature (not user auth) and
// It verifies the webhook signature (not user auth), then updates the stream // reconciles huddle_active_participants on the stream particle in Firestore.
// particle's huddle_active_participants field in Firestore.
func (h *Handler) HandleLivekitWebhook(w http.ResponseWriter, r *http.Request) { func (h *Handler) HandleLivekitWebhook(w http.ResponseWriter, r *http.Request) {
event, err := webhook.ReceiveWebhookEvent(r, h.livekitClient.KeyProvider()) event, err := webhook.ReceiveWebhookEvent(r, h.livekitClient.KeyProvider())
if err != nil { if err != nil {
@@ -1109,13 +1094,12 @@ func (h *Handler) HandleLivekitWebhook(w http.ResponseWriter, r *http.Request) {
switch eventType { switch eventType {
case "participant_joined", "participant_left", "room_finished": case "participant_joined", "participant_left", "room_finished":
// Handle these events // fall through
default: default:
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
return return
} }
// Parse room name to extract networkId and streamId
roomName := event.GetRoom().GetName() roomName := event.GetRoom().GetName()
parts := strings.SplitN(roomName, "/", 2) parts := strings.SplitN(roomName, "/", 2)
if len(parts) != 2 { if len(parts) != 2 {
@@ -1131,14 +1115,13 @@ func (h *Handler) HandleLivekitWebhook(w http.ResponseWriter, r *http.Request) {
var participantIds []string var participantIds []string
if eventType == "room_finished" { if eventType == "room_finished" {
// Room is done — clear the participants
participantIds = []string{} participantIds = []string{}
} else { } 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) participants, err := h.livekitClient.ListParticipants(ctx, roomName)
if err != nil { if err != nil {
slog.Error("failed to list participants", "error", err, "room", roomName) 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) w.WriteHeader(http.StatusOK)
return return
} }
+83
View File
@@ -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)
}
+138
View File
@@ -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] + "…"
}
+180
View File
@@ -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
}
@@ -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()
}
+66
View File
@@ -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)
}
+43
View File
@@ -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[")
}
+2 -5
View File
@@ -13,15 +13,12 @@ var ErrNotFound = errors.New("human not found")
type Service interface { type Service interface {
GetOrCreateByEmail(ctx context.Context, email string) (*Human, error) 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) 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) GetByID(ctx context.Context, id string) (*Human, error)
// ListAll returns all humans
ListAll(ctx context.Context) ([]*Human, error) ListAll(ctx context.Context) ([]*Human, error)
// UpdateEmailNotificationsEnabled toggles email notification preference
UpdateEmailNotificationsEnabled(ctx context.Context, id string, enabled bool) error 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 UpdateLastEmailNotificationSentAt(ctx context.Context, id string, t time.Time) error
} }
+2 -4
View File
@@ -11,13 +11,11 @@ import (
) )
type Client interface { type Client interface {
// GetJoinToken generates a JWT for a participant to join a room. // GetJoinToken mints a participant JWT; name surfaces as the display name.
// Name will show up in the participant data.
GetJoinToken(roomId string, humanId string, name string) (string, error) GetJoinToken(roomId string, humanId string, name string) (string, error)
ServerUrl() string ServerUrl() string
// ListParticipants returns the current participants in a room.
ListParticipants(ctx context.Context, roomName string) ([]*livekit.ParticipantInfo, error) 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 KeyProvider() auth.KeyProvider
} }
+3 -4
View File
@@ -8,10 +8,9 @@ import (
//go:generate go tool mockgen -source ./membershippublisher.go -destination ./mocks/membershippublisher.go //go:generate go tool mockgen -source ./membershippublisher.go -destination ./mocks/membershippublisher.go
// MembershipPublisher publishes network membership changes to the live store // MembershipPublisher fans network membership changes out to Firestore.
// (Firestore) that clients subscribe to. Postgres remains the source of truth; // Postgres is the source of truth; the reconciler heals drift, so publish
// the membership reconciler heals any drift, so callers may log and ignore // failures are safe to log and ignore.
// publish failures.
type MembershipPublisher interface { type MembershipPublisher interface {
Add(ctx context.Context, humanId, networkID string) error Add(ctx context.Context, humanId, networkID string) error
Remove(ctx context.Context, humanId, networkID string) error Remove(ctx context.Context, humanId, networkID string) error
+6 -10
View File
@@ -30,17 +30,15 @@ type TranscodeInput struct {
type TranscodeOutput struct { type TranscodeOutput struct {
TempLocalFilePath string TempLocalFilePath string
OutputMimeType string OutputMimeType string
// Extension such as ".m4a" or ".mp4" OutputExt string // e.g. ".m4a" or ".mp4"
OutputExt string
} }
var ( var (
ErrInvalidInput error = errors.New("invalid input") ErrInvalidInput error = errors.New("invalid input")
) )
// TranscodeToMp4 takes in any audio or video source URL and // TranscodeToMp4 writes the result to a temp file; caller is responsible
// returns the filepath of the transcoded media // for deleting TempLocalFilePath.
// WARNING: caller responsible for deleting TempLocalFilePath
func TranscodeToMp4(ctx context.Context, input TranscodeInput) (*TranscodeOutput, error) { func TranscodeToMp4(ctx context.Context, input TranscodeInput) (*TranscodeOutput, error) {
if input.SourceURL == "" || input.MimeType == "" { if input.SourceURL == "" || input.MimeType == "" {
return nil, ErrInvalidInput return nil, ErrInvalidInput
@@ -74,11 +72,9 @@ func TranscodeToMp4(ctx context.Context, input TranscodeInput) (*TranscodeOutput
tmpPath, tmpPath,
} }
} else { } else {
// Cap encoder parallelism and lookahead to keep memory bounded — screen // 1440p4K screen recordings + libx264's lookahead buffers can OOM the
// recordings come in at native display resolution (often 1440p4K) and // worker. Bound parallelism/lookahead and downscale to 1080p; the
// libx264's per-thread lookahead/reference buffers blow past the worker's // original WebM stays in GCS untouched.
// memory limit otherwise. Output is also downscaled to 1080p max, which
// mobile playback won't notice; the original WebM stays in GCS untouched.
args = []string{ args = []string{
"-y", "-i", input.SourceURL, "-y", "-i", input.SourceURL,
"-vf", "scale='min(1920,iw)':-2:flags=lanczos", "-vf", "scale='min(1920,iw)':-2:flags=lanczos",
+2 -9
View File
@@ -17,40 +17,35 @@ const (
isAdminContextKey contextKey = "isAdmin" isAdminContextKey contextKey = "isAdmin"
) )
// WithEmail adds the email to the context
func WithEmail(ctx context.Context, email string) context.Context { func WithEmail(ctx context.Context, email string) context.Context {
return context.WithValue(ctx, emailContextKey, email) return context.WithValue(ctx, emailContextKey, email)
} }
// EmailFromContext extracts the email from the context
func EmailFromContext(ctx context.Context) (string, bool) { func EmailFromContext(ctx context.Context) (string, bool) {
email, ok := ctx.Value(emailContextKey).(string) email, ok := ctx.Value(emailContextKey).(string)
return email, ok return email, ok
} }
// WithHumanId adds the humanId to the context
func WithHumanId(ctx context.Context, humanId string) context.Context { func WithHumanId(ctx context.Context, humanId string) context.Context {
return context.WithValue(ctx, humanIdContextKey, humanId) return context.WithValue(ctx, humanIdContextKey, humanId)
} }
// HumanIdFromContext extracts the id from the context
func HumanIdFromContext(ctx context.Context) (string, bool) { func HumanIdFromContext(ctx context.Context) (string, bool) {
humanId, ok := ctx.Value(humanIdContextKey).(string) humanId, ok := ctx.Value(humanIdContextKey).(string)
return humanId, ok return humanId, ok
} }
// WithIsAdmin adds the admin flag to the context
func WithIsAdmin(ctx context.Context, isAdmin bool) context.Context { func WithIsAdmin(ctx context.Context, isAdmin bool) context.Context {
return context.WithValue(ctx, isAdminContextKey, isAdmin) return context.WithValue(ctx, isAdminContextKey, isAdmin)
} }
// IsAdminFromContext extracts the admin flag from the context
func IsAdminFromContext(ctx context.Context) bool { func IsAdminFromContext(ctx context.Context) bool {
isAdmin, ok := ctx.Value(isAdminContextKey).(bool) isAdmin, ok := ctx.Value(isAdminContextKey).(bool)
return ok && isAdmin 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 { func Auth(authSvc auth.AuthService) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 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 return
} }
// Auto-extend session
if err := authSvc.ExtendSession(r.Context(), token); err != nil { if err := authSvc.ExtendSession(r.Context(), token); err != nil {
slog.Warn("failed to extend session", "error", err) 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 { func extractBearerToken(r *http.Request) string {
authHeader := r.Header.Get("Authorization") authHeader := r.Header.Get("Authorization")
if authHeader == "" { if authHeader == "" {
+2 -3
View File
@@ -2,7 +2,7 @@ package middleware
import "net/http" 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 { func CORS(allowedOrigins []string) func(http.Handler) http.Handler {
originSet := make(map[string]struct{}, len(allowedOrigins)) originSet := make(map[string]struct{}, len(allowedOrigins))
for _, o := range 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) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin") 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 allowed := len(originSet) == 0
if !allowed { if !allowed {
_, allowed = originSet[origin] _, allowed = originSet[origin]
@@ -27,7 +27,6 @@ func CORS(allowedOrigins []string) func(http.Handler) http.Handler {
w.Header().Set("Access-Control-Max-Age", "86400") w.Header().Set("Access-Control-Max-Age", "86400")
} }
// Handle preflight
if r.Method == http.MethodOptions { if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
return return
+10 -4
View File
@@ -16,11 +16,11 @@ type Reader interface {
ListForHuman(ctx context.Context, humanId string) ([]*Network, error) ListForHuman(ctx context.Context, humanId string) ([]*Network, error)
// IsMember returns ErrInvalidHumanId if humanId is empty. // IsMember returns ErrInvalidHumanId if humanId is empty.
IsMember(ctx context.Context, networkID, humanId string) (bool, error) 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) ListAll(ctx context.Context) ([]*Network, error)
// ListAllMemberships returns humanId -> networkIds for every human with at // ListAllMemberships returns humanId -> networkIds for every human with at
// least one membership. Humans with zero memberships are absent from the map. // 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) ListAllMemberships(ctx context.Context) (map[string][]string, error)
CountSeats(ctx context.Context, networkID string) (int, error) CountSeats(ctx context.Context, networkID string) (int, error)
@@ -34,8 +34,8 @@ type readerImpl struct {
repo repository repo repository
} }
// newReader returns the concrete reader. Used by NewService to embed without // newReader exposes the concrete type so the service can embed it without
// going through the Reader interface (which would hide pool/repo). // hiding pool/repo behind the Reader interface.
func newReader(pool *pgxpool.Pool) *readerImpl { func newReader(pool *pgxpool.Pool) *readerImpl {
return &readerImpl{ return &readerImpl{
pool: pool, pool: pool,
@@ -69,6 +69,12 @@ func (r *readerImpl) IsMember(ctx context.Context, networkID, humanId string) (b
return r.repo.isMember(ctx, networkID, humanId) 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) { func (r *readerImpl) ListAll(ctx context.Context) ([]*Network, error) {
return r.repo.listAll(ctx) return r.repo.listAll(ctx)
} }
+3 -7
View File
@@ -10,9 +10,8 @@ import (
"go.jetify.com/typeid" "go.jetify.com/typeid"
) )
// dbtx is the subset of pgx's query API shared by *pgxpool.Pool and pgx.Tx. // 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 // so repository helpers can run standalone or inside a transaction.
// (against the pool) or inside a transaction.
type dbtx interface { type dbtx interface {
Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error) Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)
Query(ctx context.Context, sql string, args ...any) (pgx.Rows, 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 deleteInvitation(ctx context.Context, db dbtx, networkID, email string) error
} }
// networkColumns lists every column selected when hydrating a Network. // Centralized so SELECTs and scanNetwork stay in sync.
// Centralized to keep SELECTs and Scan() calls in sync.
const networkColumns = `id, name, admin_human_id, created_at` const networkColumns = `id, name, admin_human_id, created_at`
func scanNetwork(row pgx.Row, n *Network) error { func scanNetwork(row pgx.Row, n *Network) error {
@@ -283,8 +281,6 @@ func (r *repositoryImpl) listAll(ctx context.Context) ([]*Network, error) {
return networks, nil return networks, nil
} }
// Invitation methods
func (r *repositoryImpl) createInvitation(ctx context.Context, networkID, email string) error { func (r *repositoryImpl) createInvitation(ctx context.Context, networkID, email string) error {
_, err := r.pool.Exec(ctx, _, err := r.pool.Exec(ctx,
`INSERT INTO network_invitations (network_id, email) VALUES ($1, $2) `INSERT INTO network_invitations (network_id, email) VALUES ($1, $2)
+4 -7
View File
@@ -28,7 +28,7 @@ var ErrInvalidRetentionHours = errors.New("message retention hours must be betwe
type Service interface { type Service interface {
Reader 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) Create(ctx context.Context, name, adminHumanId string) (*Network, error)
// SetName returns ErrNotFound or ErrInvalidName. // SetName returns ErrNotFound or ErrInvalidName.
SetName(ctx context.Context, id, name string) error SetName(ctx context.Context, id, name string) error
@@ -145,10 +145,8 @@ func (s *serviceImpl) RemoveMember(ctx context.Context, networkID, humanId strin
return nil return nil
} }
// mirrorAddMembership / mirrorRemoveMembership keep the live store membership // Mirror the live store membership projection (humans/{humanId}.networks).
// projection (humans/{humanId}.networks) in sync with Postgres. Called after // Postgres is the source of truth: failures are logged and the reconciler heals drift.
// the Postgres transaction commits. Failures are logged but not returned:
// Postgres is the source of truth and the reconciler will heal drift.
func (s *serviceImpl) mirrorAddMembership(ctx context.Context, humanId, networkID string) { func (s *serviceImpl) mirrorAddMembership(ctx context.Context, humanId, networkID string) {
if err := s.pub.Add(ctx, humanId, networkID); err != nil { if err := s.pub.Add(ctx, humanId, networkID); err != nil {
slog.Error("membership publish add failed", "error", err, "humanId", humanId, "networkID", networkID) 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, // Runs fn in a tx and syncs seats to billing atomically. Any error rolls back.
// and commits. Any error rolls the membership change back.
func (s *serviceImpl) mutateMembers(ctx context.Context, networkID string, fn func(pgx.Tx) error) error { func (s *serviceImpl) mutateMembers(ctx context.Context, networkID string, fn func(pgx.Tx) error) error {
tx, err := s.pool.Begin(ctx) tx, err := s.pool.Begin(ctx)
if err != nil { if err != nil {
+41
View File
@@ -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
}
+4 -23
View File
@@ -6,7 +6,6 @@ import (
"time" "time"
) )
// ParticleType represents the type of particle
type ParticleType string type ParticleType string
const ( const (
@@ -20,7 +19,6 @@ const (
// TypeThink ParticleType = "think" // TypeThink ParticleType = "think"
) )
// VisibilityMode represents how access to a particle is determined
type VisibilityMode string type VisibilityMode string
const ( const (
@@ -32,7 +30,6 @@ const (
var ErrInvalidParticleType = errors.New("invalid particle type") var ErrInvalidParticleType = errors.New("invalid particle type")
var ErrInvalidVisibilityMode = errors.New("invalid visibility mode") var ErrInvalidVisibilityMode = errors.New("invalid visibility mode")
// ParseParticleType parses a string into a ParticleType
func ParseParticleType(s string) (ParticleType, error) { func ParseParticleType(s string) (ParticleType, error) {
switch s { switch s {
case string(TypeStream): 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) { func ParseVisibilityMode(s string) (VisibilityMode, error) {
switch s { switch s {
case "", string(VisibilityNetworkAll): case "", string(VisibilityNetworkAll):
@@ -68,7 +64,6 @@ func ParseVisibilityMode(s string) (VisibilityMode, error) {
} }
} }
// Stream status values
type StreamStatus string type StreamStatus string
const ( const (
@@ -76,7 +71,6 @@ const (
StreamStatusClosed StreamStatus = "closed" StreamStatusClosed StreamStatus = "closed"
) )
// Particle represents a content particle in the system
type Particle struct { type Particle struct {
ID string ID string
Type ParticleType Type ParticleType
@@ -89,7 +83,6 @@ type Particle struct {
CreatedAt time.Time CreatedAt time.Time
} }
// CreateInput represents the input for creating a new particle
type CreateInput struct { type CreateInput struct {
Type ParticleType Type ParticleType
NetworkID string NetworkID string
@@ -99,18 +92,15 @@ type CreateInput struct {
Visibility VisibilityMode Visibility VisibilityMode
} }
// ListFilter represents filtering options for listing particles
type ListFilter struct { type ListFilter struct {
Types []ParticleType Types []ParticleType
} }
// Cursor represents a pagination cursor for bidirectional pagination
type Cursor struct { type Cursor struct {
Position string // particle ID or timestamp Position string // particle ID or timestamp
Direction string // "before" or "after" Direction string // "before" or "after"
} }
// ParticleList represents a paginated list of particles
type ParticleList struct { type ParticleList struct {
Particles []*Particle Particles []*Particle
HasMore bool HasMore bool
@@ -118,57 +108,48 @@ type ParticleList struct {
PrevCursor *Cursor PrevCursor *Cursor
} }
// StreamData represents the data stored for stream particles
type StreamData struct { type StreamData struct {
Name string `json:"name"` Name string `json:"name"`
Status string `json:"status"` // "open" or "closed" Status string `json:"status"` // "open" or "closed"
Description *string `json:"description"` Description *string `json:"description"`
} }
// FolderData represents the data stored for folder particles
type FolderData struct { type FolderData struct {
Name string `json:"name"` Name string `json:"name"`
Color *string `json:"color"` Color *string `json:"color"`
} }
// MediaData represents the data stored for media particles
type MediaData struct { type MediaData struct {
ObjectID string `json:"object_id"` // reference to storage object ObjectID string `json:"object_id"`
MimeType string `json:"mime_type"` MimeType string `json:"mime_type"`
DurationMs int `json:"duration_ms"` DurationMs int `json:"duration_ms"`
// Caption *string `json:"caption"`
} }
// FileData represents the data stored for file particles
type FileData struct { type FileData struct {
ObjectID string `json:"object_id"` // reference to storage object ObjectID string `json:"object_id"`
Filename string `json:"filename"` Filename string `json:"filename"`
MimeType string `json:"mime_type"` 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 { type TextData struct {
Content string `json:"content"` Content string `json:"content"`
} }
// QuestData represents the data stored for quest particles
type QuestData struct { type QuestData struct {
Title string `json:"title"` Title string `json:"title"`
Description string `json:"description"` Description string `json:"description"`
Done bool `json:"done"` Done bool `json:"done"`
Status *string `json:"status"` Status *string `json:"status"`
AssignedTo *string `json:"assigned_to,omitempty"` // email 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 { type PaperData struct {
Title string `json:"title"` Title string `json:"title"`
Content string `json:"content"` // markdown Content string `json:"content"` // markdown
} }
// AckInfo represents an acknowledgment record
type AckInfo struct { type AckInfo struct {
Email string Email string
AckedAt time.Time AckedAt time.Time
@@ -5,6 +5,5 @@ import "context"
//go:generate go tool mockgen -source ./networkmembershipchecker.go -destination ./mocks/networkmembershipchecker.go //go:generate go tool mockgen -source ./networkmembershipchecker.go -destination ./mocks/networkmembershipchecker.go
type NetworkMembershipChecker interface { type NetworkMembershipChecker interface {
// IsMember returns true if the humanId is a member of the network.
IsMember(ctx context.Context, networkID, humanId string) (bool, error) IsMember(ctx context.Context, networkID, humanId string) (bool, error)
} }
+1 -1
View File
@@ -40,7 +40,7 @@ type repository interface {
getMembers(ctx context.Context, particleID string) ([]string, error) getMembers(ctx context.Context, particleID string) ([]string, error)
getMembersMap(ctx context.Context, particleIDs []string) (map[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) getAncestorChain(ctx context.Context, particleID string) ([]*Particle, error)
isMemberOf(ctx context.Context, particleID, email string) (bool, error) isMemberOf(ctx context.Context, particleID, email string) (bool, error)
+39 -82
View File
@@ -13,45 +13,46 @@ import (
const defaultPageSize = 50 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 { type Service interface {
// Create creates a new particle. Caller must be a network member (verified by handler). // Create returns ErrInvalidType, ErrInvalidData, ErrMembersRequired,
// Returns ErrInvalidType, ErrInvalidData, ErrMembersRequired, ErrInvalidParent, ErrAccessDenied, or ErrCapacityExceeded. // ErrInvalidParent, ErrAccessDenied, or ErrCapacityExceeded. Network
// membership is verified by the handler.
Create(ctx context.Context, input CreateInput, requesterEmail string) (*Particle, error) Create(ctx context.Context, input CreateInput, requesterEmail string) (*Particle, error)
// GetByID returns ErrNotFound or ErrAccessDenied. // GetByID returns ErrNotFound or ErrAccessDenied.
GetByID(ctx context.Context, id, requesterEmail string) (*Particle, error) 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) Update(ctx context.Context, id string, data json.RawMessage, requesterEmail string) (*Particle, error)
// Delete returns ErrNotFound or ErrAccessDenied. // Delete returns ErrNotFound or ErrAccessDenied.
Delete(ctx context.Context, id, requesterEmail string) error Delete(ctx context.Context, id, requesterEmail string) error
// List returns particles in a network. Use parentID=nil for root particles. // List uses parentID=nil for root particles. Returns ErrNotFound or
// Returns ErrNotFound or ErrAccessDenied if parentID is specified and inaccessible. // 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) 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 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 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 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 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 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 MarkSeen(ctx context.Context, id, requesterEmail string) error
MarkSeenBatch(ctx context.Context, ids []string, requesterEmail string) error MarkSeenBatch(ctx context.Context, ids []string, requesterEmail string) error
// Ack tracking (public, permanent)
Ack(ctx context.Context, id, requesterEmail string) error 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) 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) GetSeenMap(ctx context.Context, particleIDs []string, requesterEmail string) (map[string]bool, error)
GetAcksMap(ctx context.Context, particleIDs []string) (map[string][]AckInfo, error) GetAcksMap(ctx context.Context, particleIDs []string) (map[string][]AckInfo, error)
GetMembersMap(ctx context.Context, particleIDs []string) (map[string][]string, 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. // Walks the ancestor chain when visibility is inherited, stopping at the
// Assumes the caller is already verified as a network member (handler responsibility). // first network_all or custom node. Assumes network membership is already
// Walks up the ancestor chain only when visibility is inherited, stopping at the first // verified by the handler.
// network_all or custom node.
func (s *serviceImpl) checkAccess(ctx context.Context, particleID, email string) (bool, error) { func (s *serviceImpl) checkAccess(ctx context.Context, particleID, email string) (bool, error) {
ancestors, err := s.repo.getAncestorChain(ctx, particleID) ancestors, err := s.repo.getAncestorChain(ctx, particleID)
if err != nil { if err != nil {
@@ -83,13 +83,12 @@ func (s *serviceImpl) checkAccess(ctx context.Context, particleID, email string)
return false, errNotFound return false, errNotFound
} }
// Build lookup map by ID
byID := make(map[string]*Particle, len(ancestors)) byID := make(map[string]*Particle, len(ancestors))
for _, p := range ancestors { for _, p := range ancestors {
byID[p.ID] = p 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] current := ancestors[0]
for { for {
switch current.Visibility { 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) return s.repo.isMemberOf(ctx, current.ID, email)
case VisibilityInherited: case VisibilityInherited:
if current.ParentID == nil { if current.ParentID == nil {
// inherited at root is invalid state, deny access // inherited-at-root is invalid; deny.
return false, nil return false, nil
} }
parent, ok := byID[*current.ParentID] parent, ok := byID[*current.ParentID]
@@ -119,30 +118,24 @@ func (s *serviceImpl) Create(ctx context.Context, input CreateInput, requesterEm
return nil, err return nil, err
} }
// Validate particle type
if !isValidParticleType(input.Type) { if !isValidParticleType(input.Type) {
return nil, ErrInvalidType return nil, ErrInvalidType
} }
// Validate data matches type requirements
if err := validateParticleData(input.Type, input.Data); err != nil { if err := validateParticleData(input.Type, input.Data); err != nil {
return nil, err return nil, err
} }
// MVP visibility rules: // MVP visibility: children always inherit; roots cannot inherit and
// - Child particles (have parent) → always inherited // default to network_all. Streams/folders are root-only.
// - Root particles (no parent) → cannot be inherited, default network_all
if input.ParentID != nil { if input.ParentID != nil {
// Children always inherit from parent
input.Visibility = VisibilityInherited 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 { if input.Type == TypeStream || input.Type == TypeFolder {
return nil, ErrInvalidParent return nil, ErrInvalidParent
} }
} else { } else {
// Root particles cannot be inherited
if input.Visibility == VisibilityInherited { if input.Visibility == VisibilityInherited {
return nil, ErrInheritedAtRoot 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 var customMembers []string
if input.Visibility == VisibilityCustom { if input.Visibility == VisibilityCustom {
if input.Type != TypeStream { if input.Type != TypeStream {
@@ -161,7 +153,7 @@ func (s *serviceImpl) Create(ctx context.Context, input CreateInput, requesterEm
return nil, ErrMembersRequired 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 = make([]string, 0, len(input.Members)+1)
customMembers = append(customMembers, requesterEmail) customMembers = append(customMembers, requesterEmail)
seen := map[string]bool{requesterEmail: true} 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 // Network membership is verified by the handler; only particle visibility is checked here.
// If parent specified, check parent access (visibility-based)
if input.ParentID != nil { if input.ParentID != nil {
hasAccess, err := s.checkAccess(ctx, *input.ParentID, requesterEmail) hasAccess, err := s.checkAccess(ctx, *input.ParentID, requesterEmail)
if err != nil { if err != nil {
@@ -201,7 +192,6 @@ func (s *serviceImpl) Create(ctx context.Context, input CreateInput, requesterEm
} }
} }
// Build the particle
p := &Particle{ p := &Particle{
Type: input.Type, Type: input.Type,
NetworkID: input.NetworkID, NetworkID: input.NetworkID,
@@ -215,9 +205,8 @@ func (s *serviceImpl) Create(ctx context.Context, input CreateInput, requesterEm
p.Data = json.RawMessage("{}") p.Data = json.RawMessage("{}")
} }
// For streams, set initial status to open and check capacity // New streams default to open.
if input.Type == TypeStream { if input.Type == TypeStream {
// Set status to open in the data JSON
data, err := setStreamStatus(p.Data, string(StreamStatusOpen)) data, err := setStreamStatus(p.Data, string(StreamStatusOpen))
if err != nil { if err != nil {
return nil, err return nil, err
@@ -225,13 +214,11 @@ func (s *serviceImpl) Create(ctx context.Context, input CreateInput, requesterEm
p.Data = data p.Data = data
} }
// Create the particle
created, err := s.repo.create(ctx, p) created, err := s.repo.create(ctx, p)
if err != nil { if err != nil {
return nil, err return nil, err
} }
// Add the pre-validated member list for custom visibility.
if len(customMembers) > 0 { if len(customMembers) > 0 {
if err := s.repo.addMembers(ctx, created.ID, customMembers); err != nil { if err := s.repo.addMembers(ctx, created.ID, customMembers); err != nil {
return nil, err return nil, err
@@ -247,7 +234,6 @@ func (s *serviceImpl) GetByID(ctx context.Context, id, requesterEmail string) (*
return nil, err return nil, err
} }
// Check access
hasAccess, err := s.checkAccess(ctx, id, requesterEmail) hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
if err != nil { if err != nil {
if errors.Is(err, errNotFound) { if errors.Is(err, errNotFound) {
@@ -275,7 +261,6 @@ func (s *serviceImpl) Update(ctx context.Context, id string, data json.RawMessag
return nil, err return nil, err
} }
// Check access
hasAccess, err := s.checkAccess(ctx, id, requesterEmail) hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
if err != nil { if err != nil {
if errors.Is(err, errNotFound) { if errors.Is(err, errNotFound) {
@@ -287,7 +272,6 @@ func (s *serviceImpl) Update(ctx context.Context, id string, data json.RawMessag
return nil, ErrAccessDenied return nil, ErrAccessDenied
} }
// Get the particle to validate data against its type
p, err := s.repo.getByID(ctx, id) p, err := s.repo.getByID(ctx, id)
if err != nil { if err != nil {
if errors.Is(err, errNotFound) { if errors.Is(err, errNotFound) {
@@ -296,7 +280,6 @@ func (s *serviceImpl) Update(ctx context.Context, id string, data json.RawMessag
return nil, err return nil, err
} }
// Validate data matches type requirements
if err := validateParticleData(p.Type, data); err != nil { if err := validateParticleData(p.Type, data); err != nil {
return nil, err return nil, err
} }
@@ -318,7 +301,6 @@ func (s *serviceImpl) Delete(ctx context.Context, id, requesterEmail string) err
return err return err
} }
// Check access
hasAccess, err := s.checkAccess(ctx, id, requesterEmail) hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
if err != nil { if err != nil {
if errors.Is(err, errNotFound) { if errors.Is(err, errNotFound) {
@@ -330,7 +312,6 @@ func (s *serviceImpl) Delete(ctx context.Context, id, requesterEmail string) err
return ErrAccessDenied return ErrAccessDenied
} }
// Get the particle to check if it's an open stream
_, err = s.repo.getByID(ctx, id) _, err = s.repo.getByID(ctx, id)
if err != nil { if err != nil {
if errors.Is(err, errNotFound) { if errors.Is(err, errNotFound) {
@@ -352,8 +333,7 @@ func (s *serviceImpl) List(ctx context.Context, networkID string, parentID *stri
return nil, err return nil, err
} }
// Network membership is verified by handler - we only check particle visibility // Network membership is verified by the handler; only particle visibility is checked here.
// If parentID specified, check access to parent (visibility-based)
if parentID != nil { if parentID != nil {
hasAccess, err := s.checkAccess(ctx, *parentID, requesterEmail) hasAccess, err := s.checkAccess(ctx, *parentID, requesterEmail)
if err != nil { 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 // Fetch limit+1 to detect a next page; visibility filtering lives in the query.
// Access filtering is done in the query itself (network_all OR user is member)
if limit == 0 { if limit == 0 {
limit = defaultPageSize limit = defaultPageSize
} }
@@ -415,7 +394,6 @@ func (s *serviceImpl) OpenStream(ctx context.Context, id, requesterEmail string)
return err return err
} }
// Check access
hasAccess, err := s.checkAccess(ctx, id, requesterEmail) hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
if err != nil { if err != nil {
if errors.Is(err, errNotFound) { if errors.Is(err, errNotFound) {
@@ -427,7 +405,6 @@ func (s *serviceImpl) OpenStream(ctx context.Context, id, requesterEmail string)
return ErrAccessDenied return ErrAccessDenied
} }
// Get the particle
p, err := s.repo.getByID(ctx, id) p, err := s.repo.getByID(ctx, id)
if err != nil { if err != nil {
if errors.Is(err, errNotFound) { if errors.Is(err, errNotFound) {
@@ -444,7 +421,6 @@ func (s *serviceImpl) OpenStream(ctx context.Context, id, requesterEmail string)
return ErrStreamAlreadyOpen return ErrStreamAlreadyOpen
} }
// Update stream status in data
newData, err := setStreamStatus(p.Data, string(StreamStatusOpen)) newData, err := setStreamStatus(p.Data, string(StreamStatusOpen))
if err != nil { if err != nil {
return err return err
@@ -467,7 +443,6 @@ func (s *serviceImpl) CloseStream(ctx context.Context, id, requesterEmail string
return err return err
} }
// Check access
hasAccess, err := s.checkAccess(ctx, id, requesterEmail) hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
if err != nil { if err != nil {
if errors.Is(err, errNotFound) { if errors.Is(err, errNotFound) {
@@ -479,7 +454,6 @@ func (s *serviceImpl) CloseStream(ctx context.Context, id, requesterEmail string
return ErrAccessDenied return ErrAccessDenied
} }
// Get the particle
p, err := s.repo.getByID(ctx, id) p, err := s.repo.getByID(ctx, id)
if err != nil { if err != nil {
if errors.Is(err, errNotFound) { if errors.Is(err, errNotFound) {
@@ -496,7 +470,6 @@ func (s *serviceImpl) CloseStream(ctx context.Context, id, requesterEmail string
return ErrStreamAlreadyClosed return ErrStreamAlreadyClosed
} }
// Update stream status in data
newData, err := setStreamStatus(p.Data, string(StreamStatusClosed)) newData, err := setStreamStatus(p.Data, string(StreamStatusClosed))
if err != nil { if err != nil {
return err return err
@@ -519,7 +492,6 @@ func (s *serviceImpl) SetVisibility(ctx context.Context, id string, mode Visibil
return err return err
} }
// Check access
hasAccess, err := s.checkAccess(ctx, id, requesterEmail) hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
if err != nil { if err != nil {
if errors.Is(err, errNotFound) { if errors.Is(err, errNotFound) {
@@ -531,7 +503,6 @@ func (s *serviceImpl) SetVisibility(ctx context.Context, id string, mode Visibil
return ErrAccessDenied return ErrAccessDenied
} }
// Get the particle to check constraints
p, err := s.repo.getByID(ctx, id) p, err := s.repo.getByID(ctx, id)
if err != nil { if err != nil {
if errors.Is(err, errNotFound) { if errors.Is(err, errNotFound) {
@@ -540,12 +511,11 @@ func (s *serviceImpl) SetVisibility(ctx context.Context, id string, mode Visibil
return err return err
} }
// Root particles cannot be inherited
if mode == VisibilityInherited && p.ParentID == nil { if mode == VisibilityInherited && p.ParentID == nil {
return ErrInheritedAtRoot 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 { if mode == VisibilityNetworkAll && p.ParentID != nil {
parentVis, err := s.getEffectiveVisibility(ctx, *p.ParentID) parentVis, err := s.getEffectiveVisibility(ctx, *p.ParentID)
if err != nil { if err != nil {
@@ -563,7 +533,7 @@ func (s *serviceImpl) SetVisibility(ctx context.Context, id string, mode Visibil
return err 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) { func (s *serviceImpl) getEffectiveVisibility(ctx context.Context, particleID string) (VisibilityMode, error) {
ancestors, err := s.repo.getAncestorChain(ctx, particleID) ancestors, err := s.repo.getAncestorChain(ctx, particleID)
if err != nil { if err != nil {
@@ -600,7 +570,6 @@ func (s *serviceImpl) AddMembers(ctx context.Context, id string, emails []string
return err return err
} }
// Check access
hasAccess, err := s.checkAccess(ctx, id, requesterEmail) hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
if err != nil { if err != nil {
if errors.Is(err, errNotFound) { if errors.Is(err, errNotFound) {
@@ -612,7 +581,6 @@ func (s *serviceImpl) AddMembers(ctx context.Context, id string, emails []string
return ErrAccessDenied return ErrAccessDenied
} }
// Get the particle to check type and parent access
p, err := s.repo.getByID(ctx, id) p, err := s.repo.getByID(ctx, id)
if err != nil { if err != nil {
if errors.Is(err, errNotFound) { if errors.Is(err, errNotFound) {
@@ -621,14 +589,11 @@ func (s *serviceImpl) AddMembers(ctx context.Context, id string, emails []string
return err return err
} }
// Only streams can have members
if p.Type != TypeStream { if p.Type != TypeStream {
return ErrNotAContainer return ErrNotAContainer
} }
// Validate and normalize emails, checking network membership upfront. // Validate every email upfront so any failure aborts before DB writes.
// Strict: a normalize failure, checker error, or non-member aborts the
// whole operation before any rows are written.
normalizedEmails := make([]string, 0, len(emails)) normalizedEmails := make([]string, 0, len(emails))
seen := make(map[string]bool, len(emails)) seen := make(map[string]bool, len(emails))
for _, email := range emails { for _, email := range emails {
@@ -664,7 +629,6 @@ func (s *serviceImpl) RemoveMembers(ctx context.Context, id string, emails []str
return err return err
} }
// Check access
hasAccess, err := s.checkAccess(ctx, id, requesterEmail) hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
if err != nil { if err != nil {
if errors.Is(err, errNotFound) { if errors.Is(err, errNotFound) {
@@ -676,7 +640,6 @@ func (s *serviceImpl) RemoveMembers(ctx context.Context, id string, emails []str
return ErrAccessDenied return ErrAccessDenied
} }
// Get the particle to check type
p, err := s.repo.getByID(ctx, id) p, err := s.repo.getByID(ctx, id)
if err != nil { if err != nil {
if errors.Is(err, errNotFound) { if errors.Is(err, errNotFound) {
@@ -685,7 +648,6 @@ func (s *serviceImpl) RemoveMembers(ctx context.Context, id string, emails []str
return err return err
} }
// Only streams can have members
if p.Type != TypeStream { if p.Type != TypeStream {
return ErrNotAContainer return ErrNotAContainer
} }
@@ -712,7 +674,6 @@ func (s *serviceImpl) MarkSeen(ctx context.Context, id, requesterEmail string) e
return err return err
} }
// Check access
hasAccess, err := s.checkAccess(ctx, id, requesterEmail) hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
if err != nil { if err != nil {
if errors.Is(err, errNotFound) { if errors.Is(err, errNotFound) {
@@ -733,17 +694,17 @@ func (s *serviceImpl) MarkSeenBatch(ctx context.Context, ids []string, requester
return err return err
} }
// Check access for each particle and mark seen // Silently skip particles that are missing or inaccessible.
for _, id := range ids { for _, id := range ids {
hasAccess, err := s.checkAccess(ctx, id, requesterEmail) hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
if err != nil { if err != nil {
if errors.Is(err, errNotFound) { if errors.Is(err, errNotFound) {
continue // Skip non-existent particles continue
} }
return err return err
} }
if !hasAccess { if !hasAccess {
continue // Skip inaccessible particles continue
} }
if err := s.repo.markSeen(ctx, id, requesterEmail); err != nil { 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 return err
} }
// Check access
hasAccess, err := s.checkAccess(ctx, id, requesterEmail) hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
if err != nil { if err != nil {
if errors.Is(err, errNotFound) { if errors.Is(err, errNotFound) {
@@ -772,7 +732,7 @@ func (s *serviceImpl) Ack(ctx context.Context, id, requesterEmail string) error
return ErrAccessDenied return ErrAccessDenied
} }
// Ack also marks as seen // Ack implies seen.
if err := s.repo.markSeen(ctx, id, requesterEmail); err != nil { if err := s.repo.markSeen(ctx, id, requesterEmail); err != nil {
return err 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 { func getStreamStatus(data json.RawMessage) string {
var d StreamData var d StreamData
if err := json.Unmarshal(data, &d); err != nil { if err := json.Unmarshal(data, &d); err != nil {
@@ -824,7 +783,6 @@ func getStreamStatus(data json.RawMessage) string {
return d.Status return d.Status
} }
// setStreamStatus updates the status in a stream particle's data
func setStreamStatus(data json.RawMessage, status string) (json.RawMessage, error) { func setStreamStatus(data json.RawMessage, status string) (json.RawMessage, error) {
var d StreamData var d StreamData
if err := json.Unmarshal(data, &d); err != nil { 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) return json.Marshal(d)
} }
// validateParticleData validates that the data field contains valid JSON // Returns ErrInvalidData if data is not valid JSON or is missing required
// and has required fields for the given particle type. // fields for pType. Empty/null data is allowed and treated as {}.
func validateParticleData(pType ParticleType, data json.RawMessage) error { 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) == "{}" { if len(data) == 0 || string(data) == "null" || string(data) == "{}" {
return nil return nil
} }
+7 -10
View File
@@ -10,20 +10,19 @@ import (
var ErrUnauthorized = errors.New("unauthorized") var ErrUnauthorized = errors.New("unauthorized")
// Authorizer validates whether a user can access a given channel.
type Authorizer struct { type Authorizer struct {
networkReader network.Reader networkReader network.Reader
} }
// NewAuthorizer creates a new channel authorizer.
func NewAuthorizer(networkReader network.Reader) *Authorizer { func NewAuthorizer(networkReader network.Reader) *Authorizer {
return &Authorizer{networkReader: networkReader} return &Authorizer{networkReader: networkReader}
} }
// Authorize checks if the given humanID is allowed to subscribe to the channel. // Authorize accepts channel IDs of the form:
// Channel formats: //
// - network:{networkId} // network:{networkId}
// - stream:{networkId}:{streamId} // stream:{networkId}:{streamId}
// _presence:{humanId}
func (a *Authorizer) Authorize(ctx context.Context, channelID, humanID string) error { func (a *Authorizer) Authorize(ctx context.Context, channelID, humanID string) error {
parts := strings.SplitN(channelID, ":", 2) parts := strings.SplitN(channelID, ":", 2)
if len(parts) < 2 { if len(parts) < 2 {
@@ -39,8 +38,7 @@ func (a *Authorizer) Authorize(ctx context.Context, channelID, humanID string) e
case "stream": case "stream":
return a.authorizeStream(ctx, rest, humanID) return a.authorizeStream(ctx, rest, humanID)
case "_presence": case "_presence":
// Always allowed — used for global online presence tracking. // Only the owning human may subscribe to their presence channel.
// The channel ID is _presence:{humanId}, so verify the humanId matches.
if rest != humanID { if rest != humanID {
return ErrUnauthorized return ErrUnauthorized
} }
@@ -61,8 +59,7 @@ func (a *Authorizer) authorizeNetwork(ctx context.Context, networkID, humanID st
return nil return nil
} }
// authorizeStream expects rest to be "{networkId}:{streamId}". // rest is "{networkId}:{streamId}"; stream-level visibility is enforced by network access.
// We only check network membership — stream visibility is handled by network access.
func (a *Authorizer) authorizeStream(ctx context.Context, rest, humanID string) error { func (a *Authorizer) authorizeStream(ctx context.Context, rest, humanID string) error {
parts := strings.SplitN(rest, ":", 2) parts := strings.SplitN(rest, ":", 2)
if len(parts) < 2 { if len(parts) < 2 {
+3 -5
View File
@@ -1,7 +1,7 @@
package pusher package pusher
// Channel tracks the local connections subscribed to a channel on this pod. // Channel tracks the local connections subscribed on this pod.
// All methods are only called from the Hub goroutine no locks needed. // State is only mutated by the Hub goroutine, so no locks are needed.
type Channel struct { type Channel struct {
id string id string
members map[*Conn]string // conn → humanID members map[*Conn]string // conn → humanID
@@ -26,7 +26,7 @@ func (ch *Channel) isEmpty() bool {
return len(ch.members) == 0 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 { func (ch *Channel) localHumanIDs() []string {
seen := make(map[string]bool, len(ch.members)) seen := make(map[string]bool, len(ch.members))
ids := make([]string, 0, len(ch.members)) ids := make([]string, 0, len(ch.members))
@@ -39,7 +39,6 @@ func (ch *Channel) localHumanIDs() []string {
return ids return ids
} }
// hasHumanID returns true if the given humanID has at least one local connection.
func (ch *Channel) hasHumanID(humanID string) bool { func (ch *Channel) hasHumanID(humanID string) bool {
for _, hid := range ch.members { for _, hid := range ch.members {
if hid == humanID { if hid == humanID {
@@ -49,7 +48,6 @@ func (ch *Channel) hasHumanID(humanID string) bool {
return false return false
} }
// broadcast sends a message to all local connections except the excluded one.
func (ch *Channel) broadcast(msg ServerMessage, exclude *Conn) { func (ch *Channel) broadcast(msg ServerMessage, exclude *Conn) {
for conn := range ch.members { for conn := range ch.members {
if conn != exclude { if conn != exclude {
+4 -9
View File
@@ -11,13 +11,12 @@ import (
const sendBufferSize = 256 const sendBufferSize = 256
// Conn wraps a WebSocket connection with identity and a send buffer.
type Conn struct { type Conn struct {
id string id string
humanID string humanID string
ws *websocket.Conn ws *websocket.Conn
send chan []byte 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 { 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. // ReadPump forwards inbound frames to the hub; blocks until the connection
// It blocks until the connection is closed or the context is cancelled. // closes or ctx is cancelled.
func (c *Conn) ReadPump(ctx context.Context, hub *Hub) { func (c *Conn) ReadPump(ctx context.Context, hub *Hub) {
defer hub.disconnect(c) defer hub.disconnect(c)
@@ -45,7 +44,6 @@ func (c *Conn) ReadPump(ctx context.Context, hub *Hub) {
return return
} }
// Respond to keep-alive pings
if string(data) == "ping" { if string(data) == "ping" {
if err := c.ws.Write(ctx, websocket.MessageText, []byte("pong")); err != nil { if err := c.ws.Write(ctx, websocket.MessageText, []byte("pong")); err != nil {
slog.Warn("websocket pong write error", "connId", c.id, "error", err) 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) { func (c *Conn) WritePump(ctx context.Context) {
for { for {
select { select {
@@ -102,8 +99,7 @@ func (c *Conn) WritePump(ctx context.Context) {
} }
} }
// Send enqueues a ServerMessage to be written to the WebSocket. // A full send buffer closes the connection (slow client policy).
// If the send buffer is full, the connection is closed (slow client).
func (c *Conn) Send(msg ServerMessage) { func (c *Conn) Send(msg ServerMessage) {
data, err := json.Marshal(msg) data, err := json.Marshal(msg)
if err != nil { 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() { func (c *Conn) Close() {
c.once.Do(func() { c.once.Do(func() {
c.ws.Close(websocket.StatusNormalClosure, "closing") c.ws.Close(websocket.StatusNormalClosure, "closing")
+6 -21
View File
@@ -43,7 +43,6 @@ type Hub struct {
remoteEventCh chan *remoteEvent remoteEventCh chan *remoteEvent
} }
// NewHub creates a new Hub.
func NewHub(bridge *RedisBridge, authorizer *Authorizer) *Hub { func NewHub(bridge *RedisBridge, authorizer *Authorizer) *Hub {
return &Hub{ return &Hub{
channels: make(map[string]*Channel), 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) { 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 { if err := h.authorizer.Authorize(ctx, req.channelID, req.conn.humanID); err != nil {
req.conn.Send(ServerMessage{ req.conn.Send(ServerMessage{
Type: TypeError, Type: TypeError,
@@ -94,7 +92,6 @@ func (h *Hub) handleSubscribe(ctx context.Context, req *subscribeRequest) {
return return
} }
// Get or create local channel
ch, ok := h.channels[req.channelID] ch, ok := h.channels[req.channelID]
if !ok { if !ok {
ch = newChannel(req.channelID) 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. // Capture before addMember so multi-tab joins don't emit a spurious join.
wasPresentLocally := ch.hasHumanID(req.conn.humanID) wasPresentLocally := ch.hasHumanID(req.conn.humanID)
// Add to local channel
ch.addMember(req.conn, req.conn.humanID) ch.addMember(req.conn, req.conn.humanID)
// Track in reverse index
if h.connChannels[req.conn] == nil { if h.connChannels[req.conn] == nil {
h.connChannels[req.conn] = make(map[string]bool) h.connChannels[req.conn] = make(map[string]bool)
} }
h.connChannels[req.conn][req.channelID] = true 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) presence, err := h.bridge.Subscribe(ctx, req.channelID, req.conn.id, req.conn.humanID)
if err != nil { if err != nil {
slog.Error("redis subscribe failed", "channelId", req.channelID, "error", err) 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() presence = ch.localHumanIDs()
} }
// Send subscribed ack with presence snapshot
req.conn.Send(ServerMessage{ req.conn.Send(ServerMessage{
Type: TypeSubscribed, Type: TypeSubscribed,
Channel: req.channelID, Channel: req.channelID,
Presence: presence, Presence: presence,
}) })
// Notify other local members. The Redis self-filter drops our own echo, // Redis self-filter drops our own echo, so same-pod peers need a direct nudge.
// so same-pod peers would otherwise never hear about this join.
if !wasPresentLocally { if !wasPresentLocally {
ch.broadcast(ServerMessage{ ch.broadcast(ServerMessage{
Type: TypeJoin, Type: TypeJoin,
@@ -147,18 +139,15 @@ func (h *Hub) handleUnsubscribe(ctx context.Context, req *unsubscribeRequest) {
ch.removeMember(req.conn) ch.removeMember(req.conn)
// Remove from reverse index
if chans, ok := h.connChannels[req.conn]; ok { if chans, ok := h.connChannels[req.conn]; ok {
delete(chans, req.channelID) delete(chans, req.channelID)
} }
// Update Redis
if err := h.bridge.Unsubscribe(ctx, req.channelID, req.conn.id, req.conn.humanID); err != nil { 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) slog.Error("redis unsubscribe failed", "channelId", req.channelID, "error", err)
} }
// Notify other local members iff the humanID is fully gone from this pod // Only emit leave once the humanID has no remaining tabs on this pod.
// (multi-tab: other conns keep them present, so no leave fires).
if !ch.hasHumanID(req.conn.humanID) { if !ch.hasHumanID(req.conn.humanID) {
ch.broadcast(ServerMessage{ ch.broadcast(ServerMessage{
Type: TypeLeave, Type: TypeLeave,
@@ -167,7 +156,6 @@ func (h *Hub) handleUnsubscribe(ctx context.Context, req *unsubscribeRequest) {
}, req.conn) }, req.conn)
} }
// Clean up empty local channel
if ch.isEmpty() { if ch.isEmpty() {
delete(h.channels, req.channelID) delete(h.channels, req.channelID)
} }
@@ -179,13 +167,12 @@ func (h *Hub) handleBroadcast(ctx context.Context, req *broadcastRequest) {
return return
} }
// Check that the sender is actually in the channel
if _, isMember := ch.members[req.conn]; !isMember { if _, isMember := ch.members[req.conn]; !isMember {
req.conn.sendError("not subscribed to channel: " + req.channelID) req.conn.sendError("not subscribed to channel: " + req.channelID)
return return
} }
// Deliver to local connections (except sender) // Local fanout (excluding sender), then publish for other pods.
ch.broadcast(ServerMessage{ ch.broadcast(ServerMessage{
Type: TypeMessage, Type: TypeMessage,
Channel: req.channelID, Channel: req.channelID,
@@ -193,7 +180,6 @@ func (h *Hub) handleBroadcast(ctx context.Context, req *broadcastRequest) {
Payload: req.payload, Payload: req.payload,
}, req.conn) }, req.conn)
// Publish to Redis for other pods
h.bridge.Broadcast(ctx, req.channelID, req.conn.humanID, req.payload) 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) { func (h *Hub) handleRemoteEvent(evt *remoteEvent) {
ch, ok := h.channels[evt.channelID] ch, ok := h.channels[evt.channelID]
if !ok { if !ok {
return // no local connections care about this channel // No local subscribers — drop the event.
return
} }
switch evt.event.Type { 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) { func (h *Hub) Subscribe(conn *Conn, channelID string) {
h.subscribeCh <- &subscribeRequest{conn: conn, channelID: channelID} h.subscribeCh <- &subscribeRequest{conn: conn, channelID: channelID}
} }
// disconnect sends a connection to the disconnect channel.
func (h *Hub) disconnect(conn *Conn) { func (h *Hub) disconnect(conn *Conn) {
h.disconnectCh <- conn h.disconnectCh <- conn
} }
+23 -36
View File
@@ -23,22 +23,22 @@ const (
pubsubPrefix = "pusher:events:" 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 redisEvent struct {
Type string `json:"type"` // "join", "leave", "message" Type string `json:"type"` // "join", "leave", "message"
HumanID string `json:"humanId,omitempty"` // who triggered the event HumanID string `json:"humanId,omitempty"` // who triggered the event
PodID string `json:"podId,omitempty"` // originating pod 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 { type RedisBridge struct {
client *redis.Client client *redis.Client
podID string 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 { func NewRedisBridge(client *redis.Client, podID string) *RedisBridge {
return &RedisBridge{ return &RedisBridge{
client: client, 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) { func (rb *RedisBridge) SetHub(hub *Hub) {
rb.hub = hub rb.hub = hub
} }
// --- Presence management (called by hub goroutine) --- // --- Presence management ---
// Subscribe adds a connection to a channel in Redis. // Subscribe records the connection in Redis and returns the channel's
// Returns the current presence set for the channel. // current deduplicated presence set.
func (rb *RedisBridge) Subscribe(ctx context.Context, channelID, connID, humanID string) ([]string, error) { func (rb *RedisBridge) Subscribe(ctx context.Context, channelID, connID, humanID string) ([]string, error) {
key := channelConnsKey(channelID) key := channelConnsKey(channelID)
field := rb.connField(connID) 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() existingMembers, err := rb.client.HVals(ctx, key).Result()
if err != nil && err != redis.Nil { if err != nil && err != redis.Nil {
return nil, fmt.Errorf("failed to get channel members: %w", err) 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) wasPresent := containsString(existingMembers, humanID)
// Add this connection
if err := rb.client.HSet(ctx, key, field, humanID).Err(); err != nil { if err := rb.client.HSet(ctx, key, field, humanID).Err(); err != nil {
return nil, fmt.Errorf("failed to add connection to channel: %w", err) 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 { if !wasPresent {
rb.publishEvent(ctx, channelID, redisEvent{ rb.publishEvent(ctx, channelID, redisEvent{
Type: TypeJoin, 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() allMembers, err := rb.client.HVals(ctx, key).Result()
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get channel members: %w", err) 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 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 { func (rb *RedisBridge) Unsubscribe(ctx context.Context, channelID, connID, humanID string) error {
key := channelConnsKey(channelID) key := channelConnsKey(channelID)
field := rb.connField(connID) 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) 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() remainingMembers, err := rb.client.HVals(ctx, key).Result()
if err != nil && err != redis.Nil { if err != nil && err != redis.Nil {
return fmt.Errorf("failed to get remaining members: %w", err) 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 { if len(remainingMembers) == 0 {
rb.client.Del(ctx, key) rb.client.Del(ctx, key)
} }
@@ -120,7 +115,6 @@ func (rb *RedisBridge) Unsubscribe(ctx context.Context, channelID, connID, human
return nil return nil
} }
// Broadcast publishes a message event to all pods.
func (rb *RedisBridge) Broadcast(ctx context.Context, channelID, humanID string, payload json.RawMessage) { func (rb *RedisBridge) Broadcast(ctx context.Context, channelID, humanID string, payload json.RawMessage) {
rb.publishEvent(ctx, channelID, redisEvent{ rb.publishEvent(ctx, channelID, redisEvent{
Type: TypeMessage, 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) { func (rb *RedisBridge) GetPresence(ctx context.Context, channelIDs []string) (map[string][]string, error) {
result := make(map[string][]string, len(channelIDs)) result := make(map[string][]string, len(channelIDs))
for _, chID := range channelIDs { for _, chID := range channelIDs {
@@ -143,8 +136,7 @@ func (rb *RedisBridge) GetPresence(ctx context.Context, channelIDs []string) (ma
return result, nil return result, nil
} }
// GetAllConnectedHumanIDs scans all channel connection hashes in Redis and returns // Returns every humanID with at least one active connection cluster-wide.
// the deduplicated set of all humanIDs that have at least one active connection.
func (rb *RedisBridge) GetAllConnectedHumanIDs(ctx context.Context) ([]string, error) { func (rb *RedisBridge) GetAllConnectedHumanIDs(ctx context.Context) ([]string, error) {
allHumanIDs := make(map[string]bool) allHumanIDs := make(map[string]bool)
var cursor uint64 var cursor uint64
@@ -178,10 +170,9 @@ func (rb *RedisBridge) GetAllConnectedHumanIDs(ctx context.Context) ([]string, e
return result, nil 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. // Listen forwards Redis Pub/Sub events to the local hub; blocks until ctx is cancelled.
// Blocks until the context is cancelled.
func (rb *RedisBridge) Listen(ctx context.Context) { func (rb *RedisBridge) Listen(ctx context.Context) {
pubsub := rb.client.PSubscribe(ctx, pubsubPrefix+"*") pubsub := rb.client.PSubscribe(ctx, pubsubPrefix+"*")
defer pubsub.Close() defer pubsub.Close()
@@ -201,7 +192,7 @@ func (rb *RedisBridge) Listen(ctx context.Context) {
} }
func (rb *RedisBridge) handlePubSubMessage(msg *redis.Message) { 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) channelID := strings.TrimPrefix(msg.Channel, pubsubPrefix)
if channelID == "" { if channelID == "" {
return return
@@ -213,7 +204,7 @@ func (rb *RedisBridge) handlePubSubMessage(msg *redis.Message) {
return 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 { if event.PodID == rb.podID {
return return
} }
@@ -222,20 +213,18 @@ func (rb *RedisBridge) handlePubSubMessage(msg *redis.Message) {
return return
} }
// Forward to local hub for delivery to local WebSocket connections
rb.hub.remoteEventCh <- &remoteEvent{ rb.hub.remoteEventCh <- &remoteEvent{
channelID: channelID, channelID: channelID,
event: event, 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) { func (rb *RedisBridge) Heartbeat(ctx context.Context) {
podKey := podKeyPrefix + rb.podID podKey := podKeyPrefix + rb.podID
// Initial heartbeat
rb.client.Set(ctx, podKey, "alive", podHeartbeatTTL) rb.client.Set(ctx, podKey, "alive", podHeartbeatTTL)
heartbeatTicker := time.NewTicker(podHeartbeatInterval) heartbeatTicker := time.NewTicker(podHeartbeatInterval)
@@ -246,7 +235,7 @@ func (rb *RedisBridge) Heartbeat(ctx context.Context) {
for { for {
select { select {
case <-ctx.Done(): 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.client.Del(context.Background(), podKey)
rb.cleanupPod(context.Background(), rb.podID) rb.cleanupPod(context.Background(), rb.podID)
return return
@@ -259,7 +248,8 @@ func (rb *RedisBridge) Heartbeat(ctx context.Context) {
} }
func (rb *RedisBridge) cleanupStalePods(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 var cursor uint64
knownPods := make(map[string]bool) knownPods := make(map[string]bool)
alivePods := 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 { for podID := range knownPods {
exists, err := rb.client.Exists(ctx, podKeyPrefix+podID).Result() exists, err := rb.client.Exists(ctx, podKeyPrefix+podID).Result()
if err != nil { if err != nil {
@@ -301,7 +290,6 @@ func (rb *RedisBridge) cleanupStalePods(ctx context.Context) {
} }
} }
// Clean up dead pods
for podID := range knownPods { for podID := range knownPods {
if !alivePods[podID] { if !alivePods[podID] {
slog.Info("cleaning up stale pod", "podId", 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 { for field, humanID := range fields {
if extractPodID(field) == podID { if extractPodID(field) == podID {
rb.client.HDel(ctx, key, field) rb.client.HDel(ctx, key, field)
// Check if this humanID is now gone from the channel
remaining, _ := rb.client.HVals(ctx, key).Result() remaining, _ := rb.client.HVals(ctx, key).Result()
if !containsString(remaining, humanID) { if !containsString(remaining, humanID) {
rb.publishEvent(ctx, channelID, redisEvent{ rb.publishEvent(ctx, channelID, redisEvent{
@@ -369,15 +356,15 @@ func channelConnsKey(channelID string) string {
return channelConnsPrefix + channelID + channelConnsSuffix return channelConnsPrefix + channelID + channelConnsSuffix
} }
// "pusher:ch:{channelID}:conns" → channelID
func extractChannelID(redisKey string) string { func extractChannelID(redisKey string) string {
// "pusher:ch:{channelID}:conns" → channelID
s := strings.TrimPrefix(redisKey, channelConnsPrefix) s := strings.TrimPrefix(redisKey, channelConnsPrefix)
s = strings.TrimSuffix(s, channelConnsSuffix) s = strings.TrimSuffix(s, channelConnsSuffix)
return s return s
} }
// "{podID}:{connID}" → podID
func extractPodID(field string) string { func extractPodID(field string) string {
// "{podID}:{connID}" → podID
parts := strings.SplitN(field, ":", 2) parts := strings.SplitN(field, ":", 2)
if len(parts) == 2 { if len(parts) == 2 {
return parts[0] return parts[0]
+7 -17
View File
@@ -15,14 +15,12 @@ import (
type Server struct { type Server struct {
pbpusher.UnimplementedPusherServiceServer pbpusher.UnimplementedPusherServiceServer
ctx context.Context // server-scoped context for graceful shutdown ctx context.Context // server-scoped; cancelling closes all WebSockets gracefully
hub *Hub hub *Hub
bridge *RedisBridge bridge *RedisBridge
authSvc auth.SessionReader 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 { func NewServer(ctx context.Context, hub *Hub, bridge *RedisBridge, authSvc auth.SessionReader) *Server {
return &Server{ return &Server{
ctx: ctx, 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) { 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") token := r.URL.Query().Get("token")
if token == "" { if token == "" {
http.Error(w, "token required", http.StatusUnauthorized) http.Error(w, "token required", http.StatusUnauthorized)
@@ -47,9 +44,8 @@ func (s *Server) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
return return
} }
// Accept WebSocket upgrade
ws, err := websocket.Accept(w, r, &websocket.AcceptOptions{ 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, InsecureSkipVerify: true,
}) })
if err != nil { 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) slog.Info("websocket connected", "connId", connID, "humanId", session.HumanId)
// Use server context, NOT r.Context(). After WebSocket upgrade, the HTTP // Use the server context, not r.Context(): after upgrade the HTTP request
// request context can be cancelled by load balancers or Go's HTTP server, // context can be cancelled by load balancers and nhooyr/websocket would
// and nhooyr/websocket permanently closes the conn on any context error. // then permanently close the conn.
ctx, cancel := context.WithCancel(s.ctx) ctx, cancel := context.WithCancel(s.ctx)
defer cancel() 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) s.hub.Subscribe(conn, "_presence:"+session.HumanId)
// WritePump runs in a separate goroutine
go conn.WritePump(ctx) go conn.WritePump(ctx)
// ReadPump blocks until the connection closes
conn.ReadPump(ctx, s.hub) conn.ReadPump(ctx, s.hub)
slog.Info("websocket disconnected", "connId", connID, "humanId", session.HumanId) 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) { func (s *Server) GetOnlineHumanIds(ctx context.Context, _ *pbpusher.GetOnlineHumanIdsRequest) (*pbpusher.GetOnlineHumanIdsResponse, error) {
humanIDs, err := s.bridge.GetAllConnectedHumanIDs(ctx) humanIDs, err := s.bridge.GetAllConnectedHumanIDs(ctx)
if err != nil { if err != nil {
@@ -89,7 +81,6 @@ func (s *Server) GetOnlineHumanIds(ctx context.Context, _ *pbpusher.GetOnlineHum
return &pbpusher.GetOnlineHumanIdsResponse{HumanIds: humanIDs}, nil 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) { func (s *Server) IsOnline(ctx context.Context, req *pbpusher.IsOnlineRequest) (*pbpusher.IsOnlineResponse, error) {
allOnline, err := s.bridge.GetAllConnectedHumanIDs(ctx) allOnline, err := s.bridge.GetAllConnectedHumanIDs(ctx)
if err != nil { if err != nil {
@@ -106,7 +97,6 @@ func (s *Server) IsOnline(ctx context.Context, req *pbpusher.IsOnlineRequest) (*
return &pbpusher.IsOnlineResponse{Online: result}, nil 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) { func (s *Server) GetChannelPresence(ctx context.Context, req *pbpusher.GetChannelPresenceRequest) (*pbpusher.GetChannelPresenceResponse, error) {
presence, err := s.bridge.GetPresence(ctx, req.ChannelIds) presence, err := s.bridge.GetPresence(ctx, req.ChannelIds)
if err != nil { if err != nil {
-2
View File
@@ -18,14 +18,12 @@ const (
TypeError = "error" TypeError = "error"
) )
// ClientMessage is a message sent from a WebSocket client to the server.
type ClientMessage struct { type ClientMessage struct {
Type string `json:"type"` Type string `json:"type"`
Channel string `json:"channel,omitempty"` Channel string `json:"channel,omitempty"`
Payload json.RawMessage `json:"payload,omitempty"` Payload json.RawMessage `json:"payload,omitempty"`
} }
// ServerMessage is a message sent from the server to a WebSocket client.
type ServerMessage struct { type ServerMessage struct {
Type string `json:"type"` Type string `json:"type"`
Channel string `json:"channel,omitempty"` Channel string `json:"channel,omitempty"`
+1 -1
View File
@@ -19,7 +19,7 @@ func ConnectAndTestRedis(db int) *redis.Client {
redisAddr := fmt.Sprintf("%s:%s", redisHost, "6379") redisAddr := fmt.Sprintf("%s:%s", redisHost, "6379")
rdb := redis.NewClient(&redis.Options{ rdb := redis.NewClient(&redis.Options{
Addr: redisAddr, Addr: redisAddr,
Password: "", // no password set Password: "",
DB: db, DB: db,
}) })
+3 -5
View File
@@ -6,13 +6,12 @@ import (
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
) )
// enum of environment variables // EnvVar enumerates the env vars referenced via this package.
type EnvVar string type EnvVar string
const () const ()
// MustGetEnv returns the value of the environment variable with the given key. // MustGetEnv panics if the variable is unset.
// panics if the variable is not set.
func MustGetEnv[T string | EnvVar](key T) string { func MustGetEnv[T string | EnvVar](key T) string {
keyString := string(key) keyString := string(key)
value := os.Getenv(keyString) value := os.Getenv(keyString)
@@ -24,8 +23,7 @@ func MustGetEnv[T string | EnvVar](key T) string {
return value return value
} }
// GetEnv returns the value of the environment variable with the given key. // GetEnv returns "" if the variable is unset (and logs a warning).
// returns an empty string if the variable is not set.
func GetEnv(key string) string { func GetEnv(key string) string {
value := os.Getenv(key) value := os.Getenv(key)
if value == "" { if value == "" {
+4 -10
View File
@@ -21,7 +21,6 @@ func CreateOptionalBool(input bool) *bool {
return &input return &input
} }
// OptionalString converts a non-nil *string to the respective string or returns "".
func OptionalString(input *string) string { func OptionalString(input *string) string {
if input == nil { if input == nil {
return "" return ""
@@ -30,7 +29,6 @@ func OptionalString(input *string) string {
return *input return *input
} }
// OptionalInt converts a non-nil *int to the respective int, otherwise returns 0.
func OptionalInt(input *int) int { func OptionalInt(input *int) int {
if input == nil { if input == nil {
return 0 return 0
@@ -39,8 +37,7 @@ func OptionalInt(input *int) int {
return *input return *input
} }
// CreateOptionalInt when given a zero value int (0), it returns a nil *int. // Zero values become nil; the inverse of OptionalInt.
// Otherwise, it gives a proper *int with valid value.
func CreateOptionalInt(input int) *int { func CreateOptionalInt(input int) *int {
if input == 0 { if input == 0 {
return nil return nil
@@ -49,8 +46,7 @@ func CreateOptionalInt(input int) *int {
return &input return &input
} }
// CreateOptionalString when given an empty string, it returns a nil *string. // Empty string becomes nil; the inverse of OptionalString.
// Otherwise, it gives a proper *string with valid value.
func CreateOptionalString(input string) *string { func CreateOptionalString(input string) *string {
if input == "" { if input == "" {
return nil return nil
@@ -59,8 +55,7 @@ func CreateOptionalString(input string) *string {
return &input return &input
} }
// GetNumberFromString converts a string to a number. // Returns an error if input contains non-digit characters or parses to <= 0.
// Returns error if the query is not a number.
func GetNumberFromString(input string) (int, error) { func GetNumberFromString(input string) (int, error) {
for _, c := range input { for _, c := range input {
if c < '0' || c > '9' { if c < '0' || c > '9' {
@@ -80,7 +75,6 @@ type Number interface {
int | int8 | int16 | int32 | int64 | uint | uint8 | uint16 | uint32 | uint64 | float32 | float64 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 { func OptionalNumber[T Number](input *T) T {
if input == nil { if input == nil {
return 0 return 0
@@ -89,7 +83,7 @@ func OptionalNumber[T Number](input *T) T {
return *input 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 { func CreateOptionalNumber[T Number](input T) *T {
if input == 0 { if input == 0 {
return nil return nil
-2
View File
@@ -10,7 +10,6 @@ const (
charsetNumbers = "0123456789" charsetNumbers = "0123456789"
) )
// RandomString generates a random string of length n based on self defined charset
func RandomString(length int) string { func RandomString(length int) string {
sb := strings.Builder{} sb := strings.Builder{}
sb.Grow(length) sb.Grow(length)
@@ -20,7 +19,6 @@ func RandomString(length int) string {
return sb.String() return sb.String()
} }
// RandomStringNumbers
func RandomStringNumbers(length int) string { func RandomStringNumbers(length int) string {
sb := strings.Builder{} sb := strings.Builder{}
sb.Grow(length) sb.Grow(length)
+2 -3
View File
@@ -34,11 +34,10 @@ const (
) )
type Service interface { type Service interface {
// returns AlreadyInWaitlistError if already in the waitlist // AddToWaitlist returns AlreadyInWaitlistError if the email is already present.
// any other error is a failure
AddToWaitlist(ctx context.Context, email string, metadata map[string]string) error AddToWaitlist(ctx context.Context, email string, metadata map[string]string) error
GetWaitlist(ctx context.Context, filter GetWaitlistFilter) ([]*WaitlistEntry, 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) GetWaitlistEntryByEmail(ctx context.Context, email string) (*WaitlistEntry, error)
MarkWaitlistEntryInvited(ctx context.Context, email string) error MarkWaitlistEntryInvited(ctx context.Context, email string) error
} }
+7
View File
@@ -43,3 +43,10 @@ spec:
secretKeyRef: secretKeyRef:
name: shared-secrets name: shared-secrets
key: DEEPGRAM_SECRET key: DEEPGRAM_SECRET
- name: "PUSHER_GRPC_ADDR"
value: "pusher:50051"
- name: "EXPO_ACCESS_TOKEN"
valueFrom:
secretKeyRef:
name: shared-secrets
key: EXPO_ACCESS_TOKEN
+7
View File
@@ -40,3 +40,10 @@ spec:
secretKeyRef: secretKeyRef:
name: shared-secrets name: shared-secrets
key: DEEPGRAM_SECRET key: DEEPGRAM_SECRET
- name: "PUSHER_GRPC_ADDR"
value: "pusher:50051"
- name: "EXPO_ACCESS_TOKEN"
valueFrom:
secretKeyRef:
name: shared-secrets
key: EXPO_ACCESS_TOKEN
@@ -0,0 +1 @@
DROP TABLE IF EXISTS push_tokens;
+10
View File
@@ -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);
+7
View File
@@ -50,6 +50,13 @@ const config: ExpoConfig = {
"Flowy uses your microphone to record voice messages.", "Flowy uses your microphone to record voice messages.",
}, },
], ],
[
"expo-notifications",
{
icon: "./assets/icon.png",
color: "#000000",
},
],
], ],
experiments: { experiments: {
typedRoutes: false, typedRoutes: false,
+3 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "flowy-mobile", "name": "flowy-mobile",
"version": "0.2.2", "version": "0.3.0",
"private": true, "private": true,
"main": "index.ts", "main": "index.ts",
"scripts": { "scripts": {
@@ -22,8 +22,10 @@
"expo-audio": "~1.0.13", "expo-audio": "~1.0.13",
"expo-camera": "~17.0.10", "expo-camera": "~17.0.10",
"expo-constants": "~18.0.13", "expo-constants": "~18.0.13",
"expo-device": "~8.0.10",
"expo-file-system": "~19.0.16", "expo-file-system": "~19.0.16",
"expo-haptics": "~15.0.7", "expo-haptics": "~15.0.7",
"expo-notifications": "~0.32.17",
"expo-secure-store": "~15.0.8", "expo-secure-store": "~15.0.8",
"expo-status-bar": "~3.0.9", "expo-status-bar": "~3.0.9",
"expo-video": "~3.0.10", "expo-video": "~3.0.10",
+12 -1
View File
@@ -9,12 +9,20 @@ import {
} from "react-native-safe-area-context"; } from "react-native-safe-area-context";
import { Toaster } from "sonner-native"; import { Toaster } from "sonner-native";
import { createQueryClient } from "@/lib/query-client"; 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 { PusherProvider } from "@/lib/pusher-provider";
import { RootNavigator } from "@/navigation/RootNavigator"; import { RootNavigator } from "@/navigation/RootNavigator";
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from "@/stores/auth-store";
const queryClient = createQueryClient(); const queryClient = createQueryClient();
// One-time setup: foreground handler + push-token rotation listener. Idempotent.
configureNotifications();
export default function App() { export default function App() {
const restoreSession = useAuthStore((s) => s.restoreSession); const restoreSession = useAuthStore((s) => s.restoreSession);
@@ -27,7 +35,10 @@ export default function App() {
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<PusherProvider> <PusherProvider>
<SafeAreaProvider initialMetrics={initialWindowMetrics}> <SafeAreaProvider initialMetrics={initialWindowMetrics}>
<NavigationContainer> <NavigationContainer
ref={navigationRef}
onReady={flushPendingNavigation}
>
<RootNavigator /> <RootNavigator />
</NavigationContainer> </NavigationContainer>
<Toaster /> <Toaster />
+14
View File
@@ -139,6 +139,20 @@ class ApiClient {
await this.requestVoid("PATCH", "/humans/me/settings", data); await this.requestVoid("PATCH", "/humans/me/settings", data);
} }
// --- Push notification tokens ---
async registerPushToken(data: {
token: string;
platform: "ios" | "android";
app_version: string;
}): Promise<void> {
await this.requestVoid("POST", "/humans/me/push-tokens", data);
}
async unregisterPushToken(token: string): Promise<void> {
await this.requestVoid("DELETE", "/humans/me/push-tokens", { token });
}
// --- Depot --- // --- Depot ---
async prepareUpload(data: PrepareUploadRequest) { async prepareUpload(data: PrepareUploadRequest) {
+73
View File
@@ -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,
});
}
+160
View File
@@ -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<string | null> {
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<string | null> {
try {
return await SecureStore.getItemAsync(STORED_TOKEN_KEY);
} catch {
return null;
}
}
async function setStoredToken(token: string): Promise<void> {
try {
await SecureStore.setItemAsync(STORED_TOKEN_KEY, token);
} catch (err) {
logError(err, { scope: "push.store" });
}
}
async function clearStoredToken(): Promise<void> {
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<void> {
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<void> {
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;
}
+9
View File
@@ -7,6 +7,10 @@ import { apiClient } from "@/api/client";
import type { Human } from "@/api/types"; import type { Human } from "@/api/types";
import { firebaseAuth } from "@/firebase"; import { firebaseAuth } from "@/firebase";
import { logError, ApiError } from "@/lib/errors"; import { logError, ApiError } from "@/lib/errors";
import {
syncPushToken,
unregisterPushToken,
} from "@/lib/push-notifications";
import { hydrateSession, useSessionStore } from "./session-store"; import { hydrateSession, useSessionStore } from "./session-store";
async function signInToFirebase(): Promise<void> { async function signInToFirebase(): Promise<void> {
@@ -60,6 +64,7 @@ export const useAuthStore = create<AuthState>((set) => ({
const user = await apiClient.me(); const user = await apiClient.me();
await signInToFirebase(); await signInToFirebase();
set({ status: "authenticated", user }); set({ status: "authenticated", user });
void syncPushToken();
} catch (err) { } catch (err) {
// Expected on expired/invalid tokens — fall back to the login screen. // Expected on expired/invalid tokens — fall back to the login screen.
logError(err, { scope: "auth.restore" }); logError(err, { scope: "auth.restore" });
@@ -89,6 +94,7 @@ export const useAuthStore = create<AuthState>((set) => ({
await useSessionStore.getState().setToken(token); await useSessionStore.getState().setToken(token);
await signInToFirebase(); await signInToFirebase();
set({ status: "authenticated", user: human }); set({ status: "authenticated", user: human });
void syncPushToken();
} catch (e) { } catch (e) {
const message = e instanceof ApiError ? e.message : "Failed to sign in"; const message = e instanceof ApiError ? e.message : "Failed to sign in";
set({ error: message }); set({ error: message });
@@ -100,6 +106,9 @@ export const useAuthStore = create<AuthState>((set) => ({
signOut: async () => { signOut: async () => {
set({ isSigningOut: true }); 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 { try {
await apiClient.signOut(); await apiClient.signOut();
} catch (err) { } catch (err) {
+313 -1
View File
@@ -1526,6 +1526,11 @@
protobufjs "^7.2.5" protobufjs "^7.2.5"
yargs "^17.7.2" 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": "@isaacs/fs-minipass@^4.0.0":
version "4.0.1" version "4.0.1"
resolved "https://registry.yarnpkg.com/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz#2d59ae3ab4b38fb4270bfa23d30f8e2e86c7fe32" 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" resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46"
integrity sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA== 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: async-limiter@~1.0.0:
version "1.0.1" version "1.0.1"
resolved "https://registry.yarnpkg.com/async-limiter/-/async-limiter-1.0.1.tgz#dd379e94f0db8310b08291f9d64c3209766617fd" resolved "https://registry.yarnpkg.com/async-limiter/-/async-limiter-1.0.1.tgz#dd379e94f0db8310b08291f9d64c3209766617fd"
integrity sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ== 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: babel-jest@^29.7.0:
version "29.7.0" version "29.7.0"
resolved "https://registry.yarnpkg.com/babel-jest/-/babel-jest-29.7.0.tgz#f4369919225b684c56085998ac63dbd05be020d5" 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-plugin-jest-hoist "^29.6.3"
babel-preset-current-node-syntax "^1.0.0" 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: balanced-match@^1.0.0:
version "1.0.2" version "1.0.2"
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
@@ -2487,6 +2515,32 @@ [email protected]:
resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5" resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5"
integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg== 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: camelcase-css@^2.0.1:
version "2.0.1" version "2.0.1"
resolved "https://registry.yarnpkg.com/camelcase-css/-/camelcase-css-2.0.1.tgz#ee978f6947914cc30c6b44741b6ed1df7f043fd5" resolved "https://registry.yarnpkg.com/camelcase-css/-/camelcase-css-2.0.1.tgz#ee978f6947914cc30c6b44741b6ed1df7f043fd5"
@@ -2808,11 +2862,29 @@ defaults@^1.0.3:
dependencies: dependencies:
clone "^1.0.2" 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: define-lazy-prop@^2.0.0:
version "2.0.0" version "2.0.0"
resolved "https://registry.yarnpkg.com/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz#3f7ae421129bcaaac9bc74905c98a0009ec9ee7f" resolved "https://registry.yarnpkg.com/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz#3f7ae421129bcaaac9bc74905c98a0009ec9ee7f"
integrity sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og== 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"
[email protected], depd@~2.0.0: [email protected], depd@~2.0.0:
version "2.0.0" version "2.0.0"
resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" 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" resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.4.7.tgz#0e20c5b82950140aa99be360a8a5f52335f53c26"
integrity sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ== 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"
[email protected]: [email protected]:
version "1.1.1" version "1.1.1"
resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" 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: dependencies:
stackframe "^1.3.4" 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: es-errors@^1.3.0:
version "1.3.0" version "1.3.0"
resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f" resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f"
integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw== 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: escalade@^3.1.1, escalade@^3.2.0:
version "3.2.0" version "3.2.0"
resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.2.0.tgz#011a3f69856ba189dffa7dc8fcce99d2a87903e5" 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" 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== 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: expo-asset@~12.0.13:
version "12.0.13" version "12.0.13"
resolved "https://registry.yarnpkg.com/expo-asset/-/expo-asset-12.0.13.tgz#1974ed7abee2ad987a519dbdcbf7f0c647dddf5b" 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/config" "~12.0.13"
"@expo/env" "~2.0.8" "@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: expo-file-system@~19.0.16, expo-file-system@~19.0.22:
version "19.0.22" version "19.0.22"
resolved "https://registry.yarnpkg.com/expo-file-system/-/expo-file-system-19.0.22.tgz#8e8f892b2e89a78102b2b90fc1af5bb6bad4f21b" resolved "https://registry.yarnpkg.com/expo-file-system/-/expo-file-system-19.0.22.tgz#8e8f892b2e89a78102b2b90fc1af5bb6bad4f21b"
@@ -3053,6 +3158,19 @@ [email protected]:
dependencies: dependencies:
invariant "^2.2.4" 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: expo-secure-store@~15.0.8:
version "15.0.8" version "15.0.8"
resolved "https://registry.yarnpkg.com/expo-secure-store/-/expo-secure-store-15.0.8.tgz#678065599bb76061b5a85b15b9426bf7a11089ae" 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" resolved "https://registry.yarnpkg.com/fontfaceobserver/-/fontfaceobserver-2.3.0.tgz#5fb392116e75d5024b7ec8e4f2ce92106d1488c8"
integrity sha512-6FPvD/IVyT4ZlNe7Wcn5Fb/4ChigpucKYSvD6a+0iMoLn2inpo711eyIcKjmDtE5XNcgAkSH9uN/nfAeZzHEfg== 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: freeport-async@^2.0.0:
version "2.0.0" version "2.0.0"
resolved "https://registry.yarnpkg.com/freeport-async/-/freeport-async-2.0.0.tgz#6adf2ec0c629d11abff92836acd04b399135bab4" 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" resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c"
integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== 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: gensync@^1.0.0-beta.2:
version "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" 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" resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e"
integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== 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: get-package-type@^0.1.0:
version "0.1.0" version "0.1.0"
resolved "https://registry.yarnpkg.com/get-package-type/-/get-package-type-0.1.0.tgz#8de2d803cff44df3bc6c456e6668b36c3926e11a" resolved "https://registry.yarnpkg.com/get-package-type/-/get-package-type-0.1.0.tgz#8de2d803cff44df3bc6c456e6668b36c3926e11a"
integrity sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q== 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: getenv@^2.0.0:
version "2.0.0" version "2.0.0"
resolved "https://registry.yarnpkg.com/getenv/-/getenv-2.0.0.tgz#b1698c7b0f29588f4577d06c42c73a5b475c69e0" 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" once "^1.3.0"
path-is-absolute "^1.0.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: graceful-fs@^4.2.4, graceful-fs@^4.2.9:
version "4.2.11" version "4.2.11"
resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" 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" resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b"
integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== 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: hasown@^2.0.2:
version "2.0.3" version "2.0.3"
resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.3.tgz#5e5c2b15b60370a4c7930c383dfb76bf17bc403c" resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.3.tgz#5e5c2b15b60370a4c7930c383dfb76bf17bc403c"
@@ -3447,7 +3625,7 @@ inflight@^1.0.4:
once "^1.3.0" once "^1.3.0"
wrappy "1" 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" version "2.0.4"
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
@@ -3464,6 +3642,14 @@ invariant@^2.2.4:
dependencies: dependencies:
loose-envify "^1.0.0" 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: is-arrayish@^0.3.1:
version "0.3.4" version "0.3.4"
resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.3.4.tgz#1ee5553818511915685d33bb13d31bf854e5059d" 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: dependencies:
binary-extensions "^2.0.0" 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: is-core-module@^2.16.1:
version "2.16.1" version "2.16.1"
resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.16.1.tgz#2a98801a849f43e2add644fbb6bc6229b19a4ef4" 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" resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d"
integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== 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: is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1:
version "4.0.3" version "4.0.3"
resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" 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: dependencies:
is-extglob "^2.1.1" 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: is-number@^7.0.0:
version "7.0.0" version "7.0.0"
resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" 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" resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-2.1.0.tgz#45e42e37fccf1f40da8e5f76ee21515840c09287"
integrity sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA== 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: is-wsl@^2.1.1, is-wsl@^2.2.0:
version "2.2.0" version "2.2.0"
resolved "https://registry.yarnpkg.com/is-wsl/-/is-wsl-2.2.0.tgz#74a4c76e77ca9fd3f932f290c17ea326cd157271" 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" resolved "https://registry.yarnpkg.com/marky/-/marky-1.3.0.tgz#422b63b0baf65022f02eda61a238eccdbbc14997"
integrity sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ== 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==
[email protected]: [email protected]:
version "2.0.14" version "2.0.14"
resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.14.tgz#7113fc4281917d63ce29b43446f701e68c25ba50" 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" resolved "https://registry.yarnpkg.com/object-hash/-/object-hash-3.0.0.tgz#73f97f753e7baffc0e2cc9d6e079079744ac82e9"
integrity sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw== 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: on-finished@~2.3.0:
version "2.3.0" version "2.3.0"
resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947" 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" resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-3.4.0.tgz#99ca7d725965fb655814eaf65f38f12bbdbf555f"
integrity sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w== 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: postcss-import@^15.1.0:
version "15.1.0" version "15.1.0"
resolved "https://registry.yarnpkg.com/postcss-import/-/postcss-import-15.1.0.tgz#41c64ed8cc0e23735a9698b3249ffdbf704adc70" resolved "https://registry.yarnpkg.com/postcss-import/-/postcss-import-15.1.0.tgz#41c64ed8cc0e23735a9698b3249ffdbf704adc70"
@@ -5180,6 +5442,15 @@ [email protected], safe-buffer@>=5.1.0:
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6"
integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== 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: sax@>=0.6.0:
version "1.6.0" version "1.6.0"
resolved "https://registry.yarnpkg.com/sax/-/sax-1.6.0.tgz#da59637629307b97e7c4cb28e080a7bc38560d5b" 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" parseurl "~1.3.3"
send "~0.19.1" 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: setprototypeof@~1.2.0:
version "1.2.0" version "1.2.0"
resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424" 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" resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.3.tgz#5b4f59e15310ab17a216f5d6cf53ee476ede670f"
integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw== 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: undici-types@~7.19.0:
version "7.19.2" version "7.19.2"
resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.19.2.tgz#1b67fc26d0f157a0cba3a58a5b5c1e2276b8ba2a" 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" resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== 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"
[email protected]: [email protected]:
version "1.0.1" version "1.0.1"
resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713"
@@ -5747,6 +6046,19 @@ [email protected]:
punycode "^2.1.1" punycode "^2.1.1"
webidl-conversions "^5.0.0" 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: which@^2.0.1:
version "2.0.2" version "2.0.2"
resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1"