diff --git a/.env.quickstart-demo b/.env.quickstart-demo new file mode 100644 index 0000000..61a38c8 --- /dev/null +++ b/.env.quickstart-demo @@ -0,0 +1,34 @@ +# BeeBuzz quickstart screencast/demo environment. +# +# This file is intentionally separate from .env so the demo can run against +# disposable state without touching the normal local development database. + +BEEBUZZ_ENV=development +BEEBUZZ_PRIVATE_BETA=true +BEEBUZZ_BOOTSTRAP_ADMIN_EMAIL=demo-quickstart@beebuzz.local +BEEBUZZ_PUSH_STUB=1 + +# Derived by .mise/setup-dev.sh when empty or not already a lancert.dev host. +BEEBUZZ_DOMAIN=example.com + +# Keep demo state disposable and isolated from ./data. +BEEBUZZ_DB_DIR=/tmp/beebuzz-quickstart-demo/db +BEEBUZZ_ATTACHMENTS_DIR=/tmp/beebuzz-quickstart-demo/attachments + +BEEBUZZ_MAILER_SENDER=noreply@beebuzz.local +BEEBUZZ_MAILER_REPLY_TO=support@beebuzz.local +BEEBUZZ_MAILER_SMTP_ADDRESS=localhost:1025 +BEEBUZZ_MAILER_SMTP_USER=dev +BEEBUZZ_MAILER_SMTP_PASSWORD=dev + +# Leave empty to let the demo runner generate temporary local VAPID keys. +BEEBUZZ_VAPID_PRIVATE_KEY= +BEEBUZZ_VAPID_PUBLIC_KEY= + +VITE_BEEBUZZ_DEBUG=true +VITE_BEEBUZZ_DEPLOYMENT_MODE=self_hosted + +# Demo runner inputs. +DEMO_EMAIL=demo-quickstart@beebuzz.local +DEMO_OUTPUT_DIR=/tmp/beebuzz-quickstart-demo/output +MAILPIT_API=http://localhost:8025/api/v1 diff --git a/.gitignore b/.gitignore index a018ef6..7a82368 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,6 @@ /public /tmp .env +.DS_Store .mise/certs/ AGENTS.local.md - diff --git a/README.md b/README.md index 13b4ace..46ca746 100644 --- a/README.md +++ b/README.md @@ -1,107 +1,135 @@ -# BeeBuzz +
+ BeeBuzz logo +
-Simple, private push notifications for servers, scripts, apps, and webhooks. +

Private push notifications for your own devices

-BeeBuzz is a focused Web Push delivery system for alerts that should reach your own -paired devices without becoming another chat surface. It supports both fast -server-trusted notifications and real end-to-end encrypted delivery, where message -content is encrypted before it reaches BeeBuzz and the server stores ciphertext -instead of plaintext. +

+ Send alerts from servers, scripts, apps, and webhooks without turning notification delivery into a chat system. +

-## Why BeeBuzz +BeeBuzz is a focused Web Push delivery system for machine-to-person alerts. It +supports two delivery modes: -- **Private alerting**: send high-signal machine-to-person notifications to paired devices. -- **Two delivery modes**: start quickly with trusted delivery, or use E2E mode when content privacy matters. -- **Real E2E push flow**: in E2E mode, the CLI encrypts locally for paired device keys and Hive decrypts locally on the receiving device. -- **Small auditable stack**: Go, SQLite, SvelteKit, Web Push, and a Hive PWA receiver. -- **Focused scope**: BeeBuzz is not a team chat, inbox, or general messaging platform. +- **Server-trusted notifications** for fast HTTP, webhook, and third-party service integrations. +- **End-to-end encrypted notifications** for senders you control, where the CLI encrypts locally and BeeBuzz stores only ciphertext. -## Architecture +BeeBuzz is built around paired personal devices, short-lived delivery state, and +a small auditable stack: Go, SQLite, SvelteKit, Web Push, and Hive, its PWA +receiver. -BeeBuzz is split into a few small pieces: +## Quickstart Demo -- **Server**: Go + SQLite API for accounts, topics, API tokens, devices, attachments, and Web Push dispatch. -- **Site**: SvelteKit web app for sign-in, device pairing, API tokens, webhook setup, and administration. -- **Hive**: PWA receiver that handles Web Push, stores pairing state locally, and decrypts E2E notifications on-device. -- **CLI**: sender for end-to-end encrypted notifications from terminals, scripts, and automation. +Hosted beta flow, shown on the real stack with Site and Hive side by side. -## Delivery Modes + -### Server-trusted +

+ Sign in, create a pairing code, pair Hive, create a token, and deliver the first notification. +

-Use JSON or multipart requests when the sender trusts the BeeBuzz server with the -notification payload. +## BeeBuzz.app -```text -sender -> BeeBuzz API -> Web Push -> Hive -``` +[BeeBuzz.app](https://beebuzz.app) is the hosted BeeBuzz SaaS. -BeeBuzz authenticates the API token, reads and validates the payload, optionally -handles an attachment, then sends a Web Push notification to subscribed devices. -This is the fastest path for tests, simple integrations, and webhooks. +Hosted access is currently a beta for approved users. During the beta you can: -### End-to-end encrypted +- sign in after approval +- pair a device with [Hive](https://hive.beebuzz.app) +- create API tokens scoped to topics +- send trusted-mode notifications over HTTP +- create webhook URLs for external services +- install the CLI and send end-to-end encrypted notifications -Use the CLI or an `application/octet-stream` request when notification content -should stay opaque to BeeBuzz. +Hosted access is free during beta. After beta, the hosted service is expected to +move to a single paid plan so the project can stay sustainable. Self-hosting +remains free, open source, and available under the AGPL license. -```text -CLI -> encrypt locally for paired devices -> BeeBuzz stores ciphertext -> Hive fetches and decrypts locally -``` +Start here: [BeeBuzz quickstart](https://beebuzz.app/docs/quickstart). + +## How It Works + +BeeBuzz has two delivery modes because not every sender can encrypt before +calling the server. + + + + + + +
+ Server-trusted BeeBuzz delivery flow + + End-to-end encrypted BeeBuzz delivery flow +
-The CLI fetches paired device public keys, encrypts the notification locally with -age/X25519, and sends only ciphertext to BeeBuzz. The server stores the opaque -blob temporarily and pushes a small envelope containing the notification ID, -attachment token, and server acceptance time. Hive receives the envelope, fetches -the blob, and decrypts the final notification locally. +In both modes, Web Push transport is encrypted in transit between BeeBuzz and +the receiving browser. The difference is what the BeeBuzz server can read: +trusted mode gives BeeBuzz plaintext notification content; E2E mode gives +BeeBuzz only ciphertext plus routing and delivery metadata. In trusted mode, +BeeBuzz validates the payload, handles short-lived attachment data when present, +and dispatches the notification to paired devices. ## Try It -- Read the docs: -- Use the hosted BeeBuzz beta: -- Run BeeBuzz locally for development: +Use trusted mode when the sender can trust BeeBuzz with the notification content: -Install the CLI from a [GitHub release](https://github.com/lucor/beebuzz/releases) (no Go required) or with Go: +```bash +curl https://push.beebuzz.app \ + -H "Authorization: Bearer $TOKEN" \ + -F title="Hello from BeeBuzz" \ + -F body="Trusted mode test" +``` + +Install the CLI from a [GitHub release](https://github.com/lucor/beebuzz/releases) +or with Go: ```bash go install lucor.dev/beebuzz/cmd/beebuzz@latest ``` -Send an encrypted notification after connecting the CLI: +Then connect the CLI and send an encrypted notification: ```bash +beebuzz connect beebuzz send "Hello from BeeBuzz" ``` -## Security Model +In E2E mode, the CLI fetches paired device public keys, encrypts the payload +locally with [age](https://age-encryption.org), and uploads ciphertext as +`application/octet-stream`. Hive fetches and decrypts the notification on the +receiving device. -In E2E mode: +## What's Inside -- BeeBuzz should not recover notification plaintext from stored blobs alone. -- BeeBuzz stores paired device public recipients, not device private identities. -- A database compromise alone should not reveal stored E2E message plaintext or device private keys. +- **Server**: Go + SQLite API for accounts, topics, API tokens, devices, attachments, and Web Push dispatch. +- **Site**: SvelteKit web app for sign-in, device pairing, API tokens, webhook setup, and administration. +- **Hive**: PWA receiver that handles Web Push, stores pairing state locally, and decrypts E2E notifications on-device. +- **CLI**: sender for end-to-end encrypted notifications from terminals, scripts, and automation. -E2E protects message content, not metadata. BeeBuzz still sees operational metadata -such as users, topics, device mappings, timestamps, delivery results, and whether -E2E mode was used. It also does not protect against a compromised endpoint or an -actively malicious server serving malicious client code or replacing recipient keys. +## Documentation -See [docs/E2E_ENCRYPTION.md](docs/E2E_ENCRYPTION.md) and -[docs/THREAT_MODEL.md](docs/THREAT_MODEL.md) for the full model. +- [Quickstart](https://beebuzz.app/docs/quickstart) +- [Browser support](https://beebuzz.app/docs/browser-support) +- [Local development](https://beebuzz.app/docs/local-dev) +- [Webhooks](https://beebuzz.app/docs/webhooks) +- [E2E encryption model](docs/E2E_ENCRYPTION.md) +- [Threat model](docs/THREAT_MODEL.md) +- [OpenAPI contract](docs/openapi.yaml) +- [Development posts](https://lucor.dev/tags/beebuzz) ## Project Status BeeBuzz is currently optimized for two workflows: -1. get approved for the hosted beta and send your first notification in seconds +1. get approved for the hosted beta and send your first notification quickly 2. run the stack locally with a fast development loop Detailed production self-hosting docs will come later. -Hosted access is free during beta. After beta, the hosted service will move to a -single paid plan, priced to keep the project sustainable. Self-hosting remains -free, open source, and available under the AGPL license. - ## License -BeeBuzz is licensed under the GNU Affero General Public License v3.0 only. +BeeBuzz is licensed under the GNU Affero General Public License v3.0 only. See +[LICENSE](LICENSE). + +Third-party dependencies are tracked in the Go and frontend dependency manifests. diff --git a/cmd/beebuzz-server/router.go b/cmd/beebuzz-server/router.go index 598f45e..778df99 100644 --- a/cmd/beebuzz-server/router.go +++ b/cmd/beebuzz-server/router.go @@ -65,6 +65,7 @@ func NewRouter( webhookHandler *webhook.Handler, attachmentHandler *attachment.Handler, tokenHandler *token.Handler, + pushStubHandler http.HandlerFunc, cfg *config.Config, log *slog.Logger, ) http.Handler { @@ -158,6 +159,10 @@ func NewRouter( }) }) + if pushStubHandler != nil { + r.Get("/_stub/push/next", pushStubHandler) + } + return r } diff --git a/cmd/beebuzz-server/serve.go b/cmd/beebuzz-server/serve.go index 46e7658..ba0f8a1 100644 --- a/cmd/beebuzz-server/serve.go +++ b/cmd/beebuzz-server/serve.go @@ -63,6 +63,7 @@ type appServices struct { topicSvc *topic.Service userSvc *user.Service webhookSvc *webhook.Service + pushStubBroker *notification.PushStubBroker } // runServe bootstraps and runs the HTTP server lifecycle. @@ -171,6 +172,12 @@ func buildServices(db *sqlx.DB, cfg *config.Config, log *slog.Logger, m mailer.M notifEventTracker := ¬ificationEventTrackerAdapter{eventSvc: eventSvc} notifSvc := notification.NewService(notifDeviceAdapter, notifAttachmentAdapter, notifEventTracker, vapidKeys, cfg.VAPIDSubject, log) + var pushStubBroker *notification.PushStubBroker + if cfg.PushStub && cfg.Env != config.EnvProduction { + pushStubBroker = notification.NewPushStubBroker(log) + notifSvc.SetPushStubBroker(pushStubBroker) + } + systemNotifRepo := systemnotifications.NewRepository(db) systemNotifTopics := &systemNotificationTopicProviderAdapter{topicSvc: topicSvc} systemNotifDelivery := &systemNotificationDeliveryAdapter{notifSvc: notifSvc, log: log} @@ -197,6 +204,7 @@ func buildServices(db *sqlx.DB, cfg *config.Config, log *slog.Logger, m mailer.M topicSvc: topicSvc, userSvc: userSvc, webhookSvc: webhookSvc, + pushStubBroker: pushStubBroker, }, nil } @@ -230,6 +238,11 @@ func buildHTTPHandler(services *appServices, cfg *config.Config, log *slog.Logge attachmentHandler := attachment.NewHandler(services.attachmentSvc, log) tokenHandler := token.NewHandler(services.tokenSvc, log) + var pushStubHandler http.HandlerFunc + if services.pushStubBroker != nil { + pushStubHandler = notification.NewPushStubHandler(services.pushStubBroker, log) + } + realIP := middleware.NewRealIP(cfg.ProxySubnet) ipHasher := middleware.NewIPHasher(cfg.IPHashSalt) requestID := middleware.NewRequestID(cfg.RequestIDHeader) @@ -248,6 +261,7 @@ func buildHTTPHandler(services *appServices, cfg *config.Config, log *slog.Logge webhookHandler, attachmentHandler, tokenHandler, + pushStubHandler, cfg, log, ) diff --git a/docs/MESSAGING.md b/docs/MESSAGING.md index 4929769..5398fe8 100644 --- a/docs/MESSAGING.md +++ b/docs/MESSAGING.md @@ -277,6 +277,16 @@ During push delivery: - attachment retrieval is token-based and does not require a session cookie - E2E mode depends on paired device age public keys +## Push-Stub Mode (Development Only) + +When `BEEBUZZ_PUSH_STUB` is enabled (non-production environments only), the server does not dispatch notifications through real Web Push providers. Instead, it captures the raw push payload in an in-memory broker and exposes it via a long-polling endpoint: + +- `GET /_stub/push/next` + +This endpoint is restricted to loopback clients as defense-in-depth. It returns `200 OK` with a `PushStubEvent` when a payload is available, or `204 No Content` after a short timeout so clients can retry. Test drivers use this flow to inject pushes directly into the Hive service worker via Chrome DevTools Protocol, bypassing FCM/VAPID entirely. + +**Never enable push-stub in production.** + ## Agent Maintenance Rule If you change any of the following, update the relevant section of this document in the same task: @@ -287,3 +297,4 @@ If you change any of the following, update the relevant section of this document - change webhook payload modes, dispatch, or priority handling - change push delivery failure handling or subscription cleanup - change the Hive service worker notification receive or decrypt flow +- change push-stub capture behavior, broker limits, or the `/_stub/push/next` endpoint diff --git a/docs/assets/readme/beebuzz-logo.svg b/docs/assets/readme/beebuzz-logo.svg new file mode 100644 index 0000000..264fc6d --- /dev/null +++ b/docs/assets/readme/beebuzz-logo.svg @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + diff --git a/docs/assets/readme/e2e-flow.svg b/docs/assets/readme/e2e-flow.svg new file mode 100644 index 0000000..ca6dd79 --- /dev/null +++ b/docs/assets/readme/e2e-flow.svg @@ -0,0 +1,47 @@ + + BeeBuzz end-to-end encrypted mode flow + A vertical flow showing a local CLI sender, BeeBuzz server storing ciphertext, Web Push provider, and Hive decrypting on the receiving device. + + + + END-TO-END MODE + + + + + + CLI + Sender + Fetches paired device public keys and encrypts + the payload locally before upload. + + ENCRYPTS LOCALLY + + + + + BB + BeeBuzz server + Stores opaque ciphertext and sends a minimal + delivery envelope through Web Push. + + OPAQUE CIPHERTEXT + + + + + WP + Web Push provider + Push transport remains encrypted in transit; + the provider sees only opaque bytes. + + VAPID · WEB PUSH + + + + + HV + Hive device + Downloads ciphertext and decrypts on device. + + diff --git a/docs/assets/readme/trusted-flow.svg b/docs/assets/readme/trusted-flow.svg new file mode 100644 index 0000000..52b01ed --- /dev/null +++ b/docs/assets/readme/trusted-flow.svg @@ -0,0 +1,47 @@ + + BeeBuzz server-trusted mode flow + A vertical flow showing an HTTP or webhook sender, BeeBuzz server, Web Push provider, and receiving device. + + + + SERVER-TRUSTED MODE + + + + + + IN + API sender + Sends a notification from HTTP, webhook, CI, + monitoring, or another external service. + + PLAINTEXT + + + + + BB + BeeBuzz server + Validates the payload, handles short-lived + attachment data, and dispatches to devices. + + VALIDATES · DISPATCHES + + + + + WP + Web Push provider + Notification transport is encrypted in transit + between BeeBuzz and the browser. + + VAPID · WEB PUSH + + + + + HV + Hive device + Receives and displays the notification. + + diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 9e47fed..04485de 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -1279,6 +1279,30 @@ paths: $ref: "#/components/responses/ValidationError" "500": $ref: "#/components/responses/InternalError" + /_stub/push/next: + get: + tags: [Push] + operationId: getNextPushStubEvent + summary: Long-poll the next captured push event (push-stub mode) + description: | + Returns the next push event captured by the push-stub broker. + Intended for local development and automated test flows only. + Rejects non-loopback clients. + x-audience: [internal] + x-stability: internal + responses: + "200": + description: A captured push event + content: + application/json: + schema: + $ref: "#/components/schemas/PushStubEvent" + "204": + description: No event available within the long-poll timeout + "403": + $ref: "#/components/responses/Forbidden" + "500": + $ref: "#/components/responses/InternalError" components: securitySchemes: cookieAuth: @@ -2252,3 +2276,16 @@ components: type: string signup_created_enabled: type: boolean + PushStubEvent: + type: object + required: [endpoint, device_id, data] + properties: + endpoint: + type: string + description: The push endpoint that would have received the notification. + device_id: + type: string + description: The target device identifier. + data: + type: string + description: The JS-visible payload (post-transport-decryption). diff --git a/internal/config/config.go b/internal/config/config.go index d80801d..60cd9f9 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -37,6 +37,7 @@ const ( envMailerSender = "BEEBUZZ_MAILER_SENDER" envMailerReplyTo = "BEEBUZZ_MAILER_REPLY_TO" envSentryDSN = "BEEBUZZ_SENTRY_DSN" + envPushStub = "BEEBUZZ_PUSH_STUB" ) // defaults @@ -85,6 +86,7 @@ type Config struct { PushURL string // Base URL of the push endpoint (https://push.{domain}) HookURL string // Base URL of the webhook endpoint (https://hook.{domain}) SentryDSN string // Sentry/GlitchTip DSN (empty = disabled) + PushStub bool // Enable push-stub mode for local/dev testing (NEVER in production) } // Load reads the .env file (if present) and loads configuration from environment variables. @@ -126,6 +128,7 @@ func Load() (*Config, error) { BootstrapAdminEmail: getEnv(envBootstrapAdminEmail, ""), Env: getEnv(envEnv, defaultEnv), SentryDSN: getEnv(envSentryDSN, ""), + PushStub: getEnvBool(envPushStub, false), Mailer: &Mailer{ SMTPAddress: getEnv(envMailerSMTPAddress, ""), SMTPUser: getEnv(envMailerSMTPUser, ""), diff --git a/internal/notification/pushstub.go b/internal/notification/pushstub.go new file mode 100644 index 0000000..5cdcd5c --- /dev/null +++ b/internal/notification/pushstub.go @@ -0,0 +1,64 @@ +package notification + +import ( + "context" + "log/slog" +) + +// PushStubEvent is a captured push payload for push-stub mode. +// It carries the exact bytes that would have been sent to the push provider, +// so a test driver can deliver them directly into the service worker via CDP. +type PushStubEvent struct { + Endpoint string `json:"endpoint"` + DeviceID string `json:"device_id"` + // Data is the JS-visible payload (post-transport-decryption). + // For BeeBuzz this is the JSON envelope or notification payload. + Data string `json:"data"` +} + +// pushStubBufferSize bounds the in-memory queue. The stub flow is single-consumer +// and short-lived; older events are dropped on overflow. +const pushStubBufferSize = 16 + +// PushStubBroker is a tiny in-memory queue used by push-stub mode to capture +// outbound push payloads instead of dispatching them to a real push provider. +// +// Never enable this in production. +type PushStubBroker struct { + ch chan PushStubEvent + log *slog.Logger +} + +// NewPushStubBroker returns a broker with a small bounded buffer. The logger +// may be nil; in that case overflow drops are silent. +func NewPushStubBroker(log *slog.Logger) *PushStubBroker { + return &PushStubBroker{ + ch: make(chan PushStubEvent, pushStubBufferSize), + log: log, + } +} + +// Publish enqueues an event. If the buffer is full, the event is dropped and +// a warning is logged so the operator can spot mismatches in the demo flow. +func (b *PushStubBroker) Publish(ev PushStubEvent) { + select { + case b.ch <- ev: + default: + if b.log != nil { + b.log.Warn("push stub broker overflow, dropping event", + "device_id", ev.DeviceID, + "buffer_size", pushStubBufferSize, + ) + } + } +} + +// Next blocks until an event is available or the context is cancelled. +func (b *PushStubBroker) Next(ctx context.Context) (PushStubEvent, error) { + select { + case ev := <-b.ch: + return ev, nil + case <-ctx.Done(): + return PushStubEvent{}, ctx.Err() + } +} diff --git a/internal/notification/pushstub_handler.go b/internal/notification/pushstub_handler.go new file mode 100644 index 0000000..c6c0d31 --- /dev/null +++ b/internal/notification/pushstub_handler.go @@ -0,0 +1,56 @@ +package notification + +import ( + "context" + "log/slog" + "net" + "net/http" + "time" + + "lucor.dev/beebuzz/internal/core" +) + +const pushStubLongPollTimeout = 30 * time.Second + +// NewPushStubHandler returns a handler that long-polls the push stub broker for +// captured push events. It is intended for local development and test flows only +// and must never be exposed in production. +// +// The handler rejects non-loopback clients as defense-in-depth. +func NewPushStubHandler(broker *PushStubBroker, log *slog.Logger) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if !isLoopback(r) { + log.Warn("push stub request from non-loopback address rejected", "remote_addr", r.RemoteAddr) + core.WriteForbidden(w, "access_denied", "push stub is only available from loopback") + return + } + + ctx, cancel := context.WithTimeout(r.Context(), pushStubLongPollTimeout) + defer cancel() + + ev, err := broker.Next(ctx) + if err != nil { + // Next only returns ctx.Err(), so a non-nil error here is always a + // timeout or client cancellation. Long-poll convention: 204 means + // "no event yet, retry". + w.WriteHeader(http.StatusNoContent) + return + } + + core.WriteJSON(w, http.StatusOK, ev) + } +} + +// isLoopback reports whether the request originates from a loopback address. +func isLoopback(r *http.Request) bool { + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + // RemoteAddr may not contain a port in some test setups. + host = r.RemoteAddr + } + ip := net.ParseIP(host) + if ip == nil { + return false + } + return ip.IsLoopback() +} diff --git a/internal/notification/pushstub_test.go b/internal/notification/pushstub_test.go new file mode 100644 index 0000000..ad32b8e --- /dev/null +++ b/internal/notification/pushstub_test.go @@ -0,0 +1,89 @@ +package notification + +import ( + "bytes" + "context" + "errors" + "log/slog" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func TestPushStubBroker_PublishNext(t *testing.T) { + b := NewPushStubBroker(nil) + + ev := PushStubEvent{Endpoint: "https://example/push", DeviceID: "dev-1", Data: `{"k":"v"}`} + b.Publish(ev) + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + got, err := b.Next(ctx) + if err != nil { + t.Fatalf("Next returned error: %v", err) + } + if got != ev { + t.Fatalf("got %+v, want %+v", got, ev) + } +} + +func TestPushStubBroker_NextRespectsContextCancellation(t *testing.T) { + b := NewPushStubBroker(nil) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := b.Next(ctx) + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected context.Canceled, got %v", err) + } +} + +func TestPushStubBroker_OverflowDropsAndLogs(t *testing.T) { + var logBuf bytes.Buffer + log := slog.New(slog.NewTextHandler(&logBuf, &slog.HandlerOptions{Level: slog.LevelWarn})) + b := NewPushStubBroker(log) + + for i := 0; i < pushStubBufferSize+5; i++ { + b.Publish(PushStubEvent{DeviceID: "dev"}) + } + + if !strings.Contains(logBuf.String(), "push stub broker overflow") { + t.Fatalf("expected overflow warning, got log: %q", logBuf.String()) + } + + // Drain exactly pushStubBufferSize events without blocking. + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + for i := 0; i < pushStubBufferSize; i++ { + if _, err := b.Next(ctx); err != nil { + t.Fatalf("Next %d returned error: %v", i, err) + } + } +} + +func TestIsLoopback(t *testing.T) { + tests := []struct { + name string + remoteAddr string + want bool + }{ + {"ipv4 loopback with port", "127.0.0.1:54321", true}, + {"ipv4 loopback without port", "127.0.0.1", true}, + {"ipv6 loopback", "[::1]:8080", true}, + {"ipv4 lan", "192.168.1.10:1234", false}, + {"ipv4 public", "8.8.8.8:443", false}, + {"empty", "", false}, + {"garbage", "not-an-ip", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest("GET", "/_stub/push/next", nil) + req.RemoteAddr = tt.remoteAddr + if got := isLoopback(req); got != tt.want { + t.Fatalf("isLoopback(%q) = %v, want %v", tt.remoteAddr, got, tt.want) + } + }) + } +} diff --git a/internal/notification/service.go b/internal/notification/service.go index 7b63384..4d309f8 100644 --- a/internal/notification/service.go +++ b/internal/notification/service.go @@ -11,6 +11,7 @@ import ( "net/http" "net/url" "strconv" + "sync/atomic" "time" "filippo.io/age" @@ -46,6 +47,21 @@ type Service struct { vapidKeys *VAPIDKeys subject string // VAPID subject per RFC 8292 (https://... or mailto:...) log *slog.Logger + // pushStubBroker, when non-nil, short-circuits real Web Push delivery and + // publishes the raw payload to a local broker. Used by test drivers to + // inject pushes directly into the service worker via CDP. + // MUST stay nil in production. + // + // Stored via atomic.Pointer so SetPushStubBroker can be called from a + // goroutine other than the one running sendRawPush without races. + pushStubBroker atomic.Pointer[PushStubBroker] +} + +// SetPushStubBroker enables push-stub capture. Pass nil to disable. +// When set, sendRawPush publishes payloads to the broker instead of contacting +// the real push provider. +func (s *Service) SetPushStubBroker(b *PushStubBroker) { + s.pushStubBroker.Store(b) } // NewService creates a new notification service. @@ -415,6 +431,15 @@ func (s *Service) sendPush(ctx context.Context, sub PushSub, payload Notificatio // sendRawPush sends raw bytes as a web push notification to a single subscription. func (s *Service) sendRawPush(ctx context.Context, sub PushSub, data []byte, urgency webpush.Urgency) (int, error) { + if broker := s.pushStubBroker.Load(); broker != nil { + broker.Publish(PushStubEvent{ + Endpoint: sub.Endpoint, + DeviceID: sub.DeviceID, + Data: string(data), + }) + return http.StatusCreated, nil + } + subscription := &webpush.Subscription{ Endpoint: sub.Endpoint, Keys: webpush.Keys{ diff --git a/scripts/quickstart-demo.sh b/scripts/quickstart-demo.sh new file mode 100644 index 0000000..fcdce0d --- /dev/null +++ b/scripts/quickstart-demo.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +ENV_FILE="${ENV_FILE:-.env.quickstart-demo}" + +if [ ! -f "$ENV_FILE" ]; then + echo "missing $ENV_FILE" >&2 + exit 1 +fi + +set -a +# shellcheck disable=SC1090 +source "$ENV_FILE" +set +a + +detect_local_ip() { + if command -v ip >/dev/null 2>&1; then + ip route get 1.1.1.1 | awk '{for(i=1;i<=NF;i++) if($i=="src") {print $(i+1); exit}}' + return + fi + + local iface + iface="$(route -n get default 2>/dev/null | awk '/interface:/{print $2}')" + ipconfig getifaddr "$iface" 2>/dev/null +} + +if [ -z "${BEEBUZZ_DOMAIN:-}" ] || [[ "$BEEBUZZ_DOMAIN" != *.lancert.dev ]]; then + LOCAL_IP="$(detect_local_ip)" + if [ -z "${LOCAL_IP:-}" ]; then + echo "could not detect LAN IP for lancert.dev domain" >&2 + exit 1 + fi + export BEEBUZZ_DOMAIN="$(echo "$LOCAL_IP" | tr '.' '-').lancert.dev" +fi + +export VITE_BEEBUZZ_DOMAIN="$BEEBUZZ_DOMAIN" + +if [ -z "${BEEBUZZ_VAPID_PRIVATE_KEY:-}" ] || [ -z "${BEEBUZZ_VAPID_PUBLIC_KEY:-}" ]; then + eval "$(mise x -- go run ./cmd/beebuzz-server vapid generate)" + export BEEBUZZ_VAPID_PRIVATE_KEY + export BEEBUZZ_VAPID_PUBLIC_KEY +fi + +rm -rf "${BEEBUZZ_DB_DIR:?}" "${BEEBUZZ_ATTACHMENTS_DIR:?}" +mkdir -p "$BEEBUZZ_DB_DIR" "$BEEBUZZ_ATTACHMENTS_DIR" "${DEMO_OUTPUT_DIR:-docs/assets/readme}" + +# setup-dev.sh fetches/reuses lancert.dev certificates and exports Caddy/Vite domain env. +# It will not prompt because BEEBUZZ_DOMAIN is already a lancert.dev host. +# shellcheck disable=SC1091 +source .mise/setup-dev.sh + +cleanup() { + if [ -n "${STACK_PID:-}" ]; then + kill "$STACK_PID" >/dev/null 2>&1 || true + wait "$STACK_PID" >/dev/null 2>&1 || true + fi +} +trap cleanup EXIT + +( + cd .mise + mise x -- goreman -f Procfile start +) & +STACK_PID=$! + +SITE_URL="https://${BEEBUZZ_DOMAIN}" +HIVE_URL="https://hive.${BEEBUZZ_DOMAIN}" +API_URL="https://api.${BEEBUZZ_DOMAIN}" + +echo "[quickstart-demo] site: $SITE_URL" +echo "[quickstart-demo] hive: $HIVE_URL" +echo "[quickstart-demo] api: $API_URL" + +mise x -- node web/tests/demo/quickstart-demo.mjs diff --git a/web/package.json b/web/package.json index f1e34b6..47a9900 100644 --- a/web/package.json +++ b/web/package.json @@ -24,7 +24,8 @@ "format": "prettier --write .", "test": "vitest run", "test:watch": "vitest", - "test:e2e": "playwright test" + "test:e2e": "playwright test", + "demo:quickstart": "cd .. && bash scripts/quickstart-demo.sh" }, "devDependencies": { "@eslint/compat": "^2.0.5", diff --git a/web/tests/demo/quickstart-demo.mjs b/web/tests/demo/quickstart-demo.mjs new file mode 100644 index 0000000..06547ec --- /dev/null +++ b/web/tests/demo/quickstart-demo.mjs @@ -0,0 +1,521 @@ +/* global console, crypto, document, Event, fetch, navigator, process, requestAnimationFrame, URL, window */ +import { chromium, expect } from '@playwright/test'; +import { mkdir, rename, rm } from 'node:fs/promises'; +import path from 'node:path'; +import { setTimeout as delay } from 'node:timers/promises'; + +const demoEmail = process.env.DEMO_EMAIL || 'demo-quickstart@beebuzz.local'; +const domain = mustEnv('BEEBUZZ_DOMAIN'); +const siteURL = `https://${domain}`; +const hiveURL = `https://hive.${domain}`; +const apiURL = `https://api.${domain}`; +const mailpitAPI = process.env.MAILPIT_API || 'http://localhost:8025/api/v1'; +const outputDir = process.env.DEMO_OUTPUT_DIR || 'docs/assets/readme'; +const userDataRoot = '/tmp/beebuzz-quickstart-demo/browser'; +const actionDelayMs = Number(process.env.DEMO_ACTION_DELAY_MS || 700); +const typingDelayMs = Number(process.env.DEMO_TYPING_DELAY_MS || 55); +const siteVideoDir = path.join(outputDir, 'site-video'); +const hiveVideoDir = path.join(outputDir, 'hive-video'); +const siteVideoOutput = path.join(siteVideoDir, 'quickstart-demo-site.webm'); +const hiveVideoOutput = path.join(hiveVideoDir, 'quickstart-demo-hive.webm'); + +function mustEnv(name) { + const value = process.env[name]; + if (!value) { + throw new Error(`${name} is required`); + } + return value; +} + +async function waitForHTTP(url, label) { + for (let attempt = 0; attempt < 90; attempt += 1) { + try { + const response = await fetch(url); + if (response.ok || response.status < 500) return; + } catch { + // keep polling + } + await delay(1000); + } + throw new Error(`${label} did not become ready: ${url}`); +} + +async function latestOTP() { + for (let attempt = 0; attempt < 60; attempt += 1) { + const messagesResponse = await fetch(`${mailpitAPI}/messages`); + if (!messagesResponse.ok) { + await delay(500); + continue; + } + + const messagesBody = await messagesResponse.json(); + const messages = Array.isArray(messagesBody.messages) ? messagesBody.messages : []; + + for (const message of messages) { + const id = message.ID ?? message.Id ?? message.id; + if (!id) continue; + + const detailResponse = await fetch(`${mailpitAPI}/message/${id}`); + if (!detailResponse.ok) continue; + + const detail = await detailResponse.json(); + const searchable = `${detail.To?.map?.((to) => to.Address).join(' ') ?? ''}\n${detail.Text ?? ''}`; + if (!searchable.includes(demoEmail)) continue; + + const otp = String(detail.Text ?? '').match(/\b\d{6}\b/)?.[0]; + if (otp) return otp; + } + + await delay(500); + } + + throw new Error(`OTP for ${demoEmail} not found in Mailpit`); +} + +async function launchWindow(name, x, userDataDir) { + await rm(userDataDir, { force: true, recursive: true }); + await rm(path.join(outputDir, `${name}-video`), { force: true, recursive: true }); + + return chromium.launchPersistentContext(userDataDir, { + headless: false, + slowMo: 120, + acceptDownloads: false, + ignoreHTTPSErrors: true, + recordVideo: { + dir: path.join(outputDir, `${name}-video`), + size: { width: 820, height: 920 } + }, + viewport: { width: 820, height: 920 }, + args: [ + `--window-position=${x},80`, + '--window-size=820,980', + '--touch-events=enabled', + '--disable-features=Translate,AutomationControlled', + '--no-first-run' + ] + }); +} + +async function persistVideo(video, destination) { + if (!video) { + throw new Error(`Missing video handle for ${destination}`); + } + + const source = await video.path(); + await mkdir(path.dirname(destination), { recursive: true }); + await rm(destination, { force: true }); + await rename(source, destination); +} + +async function pause(multiplier = 1) { + await delay(actionDelayMs * multiplier); +} + +function formatError(err) { + return err instanceof Error ? err.message : String(err); +} + +async function showClick(locator) { + await locator.scrollIntoViewIfNeeded(); + await locator.evaluate((element) => { + const rect = element.getBoundingClientRect(); + const x = rect.left + rect.width / 2; + const y = rect.top + rect.height / 2; + const ring = document.createElement('div'); + ring.setAttribute('aria-hidden', 'true'); + ring.style.position = 'fixed'; + ring.style.left = `${x}px`; + ring.style.top = `${y}px`; + ring.style.width = '28px'; + ring.style.height = '28px'; + ring.style.marginLeft = '-14px'; + ring.style.marginTop = '-14px'; + ring.style.border = '3px solid #f59e0b'; + ring.style.borderRadius = '9999px'; + ring.style.background = 'rgba(245, 158, 11, 0.22)'; + ring.style.boxShadow = '0 0 0 8px rgba(245, 158, 11, 0.14)'; + ring.style.zIndex = '2147483647'; + ring.style.pointerEvents = 'none'; + ring.style.transform = 'scale(0.7)'; + ring.style.transition = 'transform 260ms ease-out, opacity 420ms ease-out'; + document.body.appendChild(ring); + requestAnimationFrame(() => { + ring.style.transform = 'scale(1.9)'; + ring.style.opacity = '0'; + }); + window.setTimeout(() => ring.remove(), 520); + }); + await delay(120); +} + +async function humanFill(locator, value) { + await showClick(locator); + await locator.click(); + await locator.press(process.platform === 'darwin' ? 'Meta+A' : 'Control+A'); + await locator.pressSequentially(value, { delay: typingDelayMs }); + await pause(0.5); +} + +async function humanClick(locator) { + await pause(0.5); + await showClick(locator); + await locator.click(); + await pause(); +} + +async function readPairingCode(pairingDialog) { + // Two separate persistent contexts don't share a clipboard reliably, + // so read the code straight from the visible dialog. Target the + // element that contains exactly six digits to avoid matching any + // other numeric content that might appear in the dialog. + const codeNode = pairingDialog.getByText(/^\s*\d{6}\s*$/).first(); + await expect(codeNode).toBeVisible(); + + const pairingCode = (await codeNode.textContent())?.replace(/\D/g, ''); + if (pairingCode?.length === 6) return pairingCode; + + throw new Error('Pairing code was not visible in the dialog'); +} + +async function pastePairingCode(pairingCode, hivePage) { + const pairingInput = hivePage.getByLabel('Pairing code'); + const connectButton = hivePage.getByRole('button', { name: /connect device/i }); + + await showClick(pairingInput); + await pause(0.5); + + // The hive pairing input is visually hidden (opacity-0) and listens + // for the `input` event to update its bound state. Synthetic Meta+V + // across separate Chromium contexts is unreliable, so simulate the + // paste directly: set the value and dispatch the input event. + await pairingInput.evaluate((input, value) => { + const target = /** @type {HTMLInputElement} */ (input); + target.focus(); + target.value = value; + target.dispatchEvent(new Event('input', { bubbles: true })); + }, pairingCode); + + // Verify Svelte state updated (button becomes enabled when length === 6). + // Fall back to per-character typing if the synthetic event didn't take. + try { + await expect(connectButton).toBeEnabled({ timeout: 3000 }); + } catch { + await humanFill(pairingInput, pairingCode); + await expect(connectButton).toBeEnabled({ timeout: 3000 }); + } +} + +/** + * Creates a push injector that delivers push messages directly to a Hive + * service worker via Chrome DevTools Protocol, bypassing the real push + * transport (FCM / VAPID). + */ +async function createPushInjector(page, origin) { + const context = page.context(); + const browser = context.browser(); + if (!browser) { + throw new Error('Page must be attached to a browser'); + } + + let cdp; + try { + cdp = await context.newCDPSession(page); + await cdp.send('ServiceWorker.enable'); + } catch { + cdp = await browser.newBrowserCDPSession(); + await cdp.send('ServiceWorker.enable'); + } + + /** @type {Map} */ + const registrations = new Map(); + + cdp.on('ServiceWorker.workerRegistrationUpdated', (params) => { + for (const reg of params.registrations) { + registrations.set(reg.registrationId, { scopeURL: reg.scopeURL }); + } + }); + + await page.waitForTimeout(500); + + let registrationId; + for (const [id, reg] of registrations) { + if (reg.scopeURL === origin || reg.scopeURL === origin + '/') { + registrationId = id; + break; + } + } + + if (!registrationId) { + // The service worker may not have reported yet; nudge it. + await page.evaluate(() => navigator.serviceWorker.ready); + await page.waitForTimeout(500); + + for (const [id, reg] of registrations) { + if (reg.scopeURL === origin || reg.scopeURL === origin + '/') { + registrationId = id; + break; + } + } + } + + if (!registrationId) { + const known = [...registrations.values()].map((r) => r.scopeURL).join(', '); + throw new Error( + `No service worker registration found for ${origin}. Known scopes: ${known || '(none)'}` + ); + } + + return { + /** @param {string} data */ + async deliver(data) { + await cdp.send('ServiceWorker.deliverPushMessage', { + origin, + registrationId, + data + }); + } + }; +} + +function logStep(message) { + console.log(`[quickstart-demo] ${message}`); +} + +async function useHiveBrowserFallback(page) { + const pairingInput = page.getByLabel('Pairing code'); + const fallbackButtons = [ + page.getByRole('button', { name: /can't install/i }), + page.getByRole('button', { name: /continue in browser/i }), + page.getByText(/continue in browser/i) + ]; + + for (let attempt = 0; attempt < 60; attempt += 1) { + if (await pairingInput.isVisible().catch(() => false)) { + return; + } + + for (const fallbackButton of fallbackButtons) { + if (await fallbackButton.isVisible().catch(() => false)) { + await humanClick(fallbackButton); + break; + } + } + + if ( + await page + .getByText(/not supported|unable to pair|something went wrong/i) + .isVisible() + .catch(() => false) + ) { + throw new Error('Hive did not reach the browser pairing screen'); + } + + await pause(0.5); + } + + await expect(pairingInput).toBeVisible({ timeout: 15000 }); +} + +await waitForHTTP(siteURL, 'site'); +await waitForHTTP(hiveURL, 'Hive'); +await waitForHTTP(`${apiURL}/health`, 'API'); +await waitForHTTP(`${mailpitAPI}/messages`, 'Mailpit'); + +const siteContext = await launchWindow('site', 40, path.join(userDataRoot, 'site')); +const hiveContext = await launchWindow('hive', 900, path.join(userDataRoot, 'hive')); + +// Playwright push subscriptions use *.google.com endpoints that the backend +// rejects as unsupported. Rewrite them to a fake FCM endpoint so pairing +// succeeds while keeping the real allowlist strict. +await hiveContext.route('**/v1/pairing', async (route, request) => { + if (request.method() !== 'POST') { + await route.continue(); + return; + } + const body = JSON.parse(request.postData() || '{}'); + if (body.endpoint) { + const url = new URL(body.endpoint); + if (url.hostname.endsWith('.google.com') && url.hostname !== 'fcm.googleapis.com') { + body.endpoint = `https://fcm.googleapis.com/fcm/send/demo-${crypto.randomUUID()}`; + } + } + await route.continue({ postData: JSON.stringify(body) }); +}); + +await siteContext.grantPermissions(['clipboard-read', 'clipboard-write'], { origin: siteURL }); +await hiveContext.grantPermissions(['clipboard-read', 'clipboard-write', 'notifications'], { + origin: hiveURL +}); + +const sitePage = siteContext.pages()[0] ?? (await siteContext.newPage()); +const hivePage = hiveContext.pages()[0] ?? (await hiveContext.newPage()); + +// Diagnostics: surface API failures from the Hive page so we can debug +// pairing/push issues that only show up in this Playwright environment. +hivePage.on('console', (message) => { + if (message.type() === 'error' || message.type() === 'warning') { + console.log(`[hive console:${message.type()}] ${message.text()}`); + } +}); +hivePage.on('response', async (response) => { + const url = response.url(); + if (!url.includes('/v1/') || response.ok()) return; + let body = ''; + try { + body = await response.text(); + } catch { + // ignore + } + console.log(`[hive api error] ${response.status()} ${url} ${body}`.trim()); +}); +hivePage.on('request', (request) => { + if (!request.url().includes('/v1/pairing')) return; + const method = request.method(); + if (method !== 'POST') return; + const post = request.postData(); + if (!post) return; + try { + const parsed = JSON.parse(post); + const safe = { + pairing_code: parsed.pairing_code ? '' : null, + endpoint: parsed.endpoint, + p256dh_len: parsed.p256dh?.length, + auth_len: parsed.auth?.length, + age_recipient: parsed.age_recipient + }; + console.log(`[hive pair request] ${JSON.stringify(safe)}`); + } catch { + console.log(`[hive pair request] (unparseable body, ${post.length} bytes)`); + } +}); + +try { + logStep('opening Site and Hive'); + await Promise.all([sitePage.goto(`${siteURL}/login`), hivePage.goto(`${hiveURL}/pair`)]); + await pause(1.5); + + logStep('switching Hive to browser fallback'); + await useHiveBrowserFallback(hivePage); + + logStep('signing in on Site'); + await humanFill(sitePage.getByLabel('Email address'), demoEmail); + await humanClick(sitePage.getByRole('button', { name: /continue/i })); + + await expect(sitePage).toHaveURL(/\/verify/); + const otp = await latestOTP(); + await pause(); + logStep('entering OTP'); + await humanFill(sitePage.getByLabel('One-time code'), otp); + await humanClick(sitePage.getByRole('button', { name: /verify code/i })); + await expect(sitePage).toHaveURL(/\/account\/overview/, { timeout: 15000 }); + + logStep('creating pairing code'); + await pause(1.2); + await sitePage.goto(`${siteURL}/account/devices`); + await pause(1.2); + await humanClick(sitePage.getByRole('button', { name: /add device/i })); + await humanFill(sitePage.getByLabel('Device Name'), 'Demo Chrome Hive'); + await humanClick(sitePage.getByRole('button', { name: /generate pairing code/i })); + + const pairingDialog = sitePage.getByRole('dialog', { name: /pairing code generated/i }); + await expect(pairingDialog).toBeVisible(); + await pause(); + await humanClick(pairingDialog.getByTitle('Copy code')); + const pairingCode = await readPairingCode(pairingDialog); + console.log(`[quickstart-demo] pairing code = ${pairingCode}`); + + logStep('pasting pairing code in Hive'); + await pastePairingCode(pairingCode, hivePage); + await humanClick(hivePage.getByRole('button', { name: /connect device/i })); + await expect(hivePage.getByText(/no notifications yet/i)).toBeVisible({ timeout: 30000 }); + console.log('[quickstart-demo] Hive paired and ready'); + + await pause(1.2); + await humanClick(pairingDialog.getByRole('button', { name: /done/i })); + await expect(sitePage.getByText(/pairing done/i)).toBeVisible({ timeout: 15000 }); + await humanClick(sitePage.getByRole('button', { name: /go to api tokens/i })); + + logStep('creating API token'); + await expect(sitePage).toHaveURL(/\/account\/api-tokens/); + await pause(1.2); + await humanClick(sitePage.getByRole('button', { name: /create token/i }).first()); + await humanFill(sitePage.getByLabel('Token Name'), 'Quickstart demo token'); + + // The submit button lives outside the
(in the dialog footer) and is + // linked via form="create-token-form". Scope the click to the dialog. + const createDialog = sitePage.getByRole('dialog', { name: /create api token/i }); + await expect(createDialog).toBeVisible(); + const submitButton = createDialog.getByRole('button', { name: /^create token$/i }); + await expect(submitButton).toBeEnabled({ timeout: 5000 }); + console.log('[quickstart-demo] submit button is enabled'); + await humanClick(submitButton); + + const tokenDialog = sitePage.getByRole('dialog', { name: /api token created/i }); + await expect(tokenDialog).toBeVisible(); + console.log('[quickstart-demo] API token created'); + await pause(1.5); + + logStep('creating push injector for CDP delivery'); + const injector = await createPushInjector(hivePage, hiveURL); + console.log('[quickstart-demo] Push injector ready'); + + logStep('sending test notification'); + await humanClick(tokenDialog.getByRole('button', { name: /send test notification now/i })); + await expect(tokenDialog.getByText(/test sent to/i)).toBeVisible({ timeout: 30000 }); + console.log('[quickstart-demo] Test notification sent via API'); + + logStep('polling push stub bridge'); + /** @type {{ data: string } | undefined} */ + let pushEvent; + for (let attempt = 0; attempt < 10; attempt += 1) { + const stubResponse = await fetch(`${apiURL}/_stub/push/next`); + console.log(`[quickstart-demo] push stub attempt ${attempt + 1}: ${stubResponse.status}`); + if (stubResponse.status === 204) { + await delay(500); + continue; + } + if (!stubResponse.ok) { + throw new Error(`Push stub bridge returned ${stubResponse.status}`); + } + pushEvent = await stubResponse.json(); + console.log( + `[quickstart-demo] push event captured: device_id=${pushEvent.device_id}, data_len=${pushEvent.data?.length}` + ); + break; + } + if (!pushEvent) { + throw new Error('No push event captured from stub bridge after multiple attempts'); + } + + logStep('injecting push via CDP'); + await injector.deliver(pushEvent.data); + console.log('[quickstart-demo] Push delivered via CDP'); + + logStep('waiting for notification in Hive'); + await expect(hivePage.getByText('BeeBuzz test notification')).toBeVisible({ timeout: 10000 }); + console.log('[quickstart-demo] Notification visible in Hive'); + + await pause(2); + await sitePage.screenshot({ path: path.join(outputDir, 'quickstart-demo-site-final.png') }); + await hivePage.screenshot({ path: path.join(outputDir, 'quickstart-demo-hive-final.png') }); + await delay(2500); +} catch (err) { + console.error(`[quickstart-demo] FAILED: ${formatError(err)}`); + throw err; +} finally { + console.log('[quickstart-demo] closing browser contexts'); + const siteVideo = sitePage.video(); + const hiveVideo = hivePage.video(); + await siteContext + .close() + .catch((e) => console.error(`[quickstart-demo] site close error: ${formatError(e)}`)); + await hiveContext + .close() + .catch((e) => console.error(`[quickstart-demo] hive close error: ${formatError(e)}`)); + await persistVideo(siteVideo, siteVideoOutput).catch((e) => + console.error(`[quickstart-demo] site video move error: ${formatError(e)}`) + ); + await persistVideo(hiveVideo, hiveVideoOutput).catch((e) => + console.error(`[quickstart-demo] hive video move error: ${formatError(e)}`) + ); +} diff --git a/web/tests/e2e/helpers/push-injector.ts b/web/tests/e2e/helpers/push-injector.ts new file mode 100644 index 0000000..d92a36b --- /dev/null +++ b/web/tests/e2e/helpers/push-injector.ts @@ -0,0 +1,79 @@ +import type { Page } from '@playwright/test'; + +export interface PushInjector { + /** Delivers a push payload to the Hive service worker via CDP. */ + deliver(data: string): Promise; +} + +/** + * Creates a push injector that can deliver push messages directly to a Hive + * service worker using Chrome DevTools Protocol. + * + * This bypasses the real push transport (FCM / VAPID) and is intended for + * local development and automated end-to-end tests only. + */ +export async function createPushInjector(page: Page, origin: string): Promise { + const context = page.context(); + const browser = context.browser(); + if (!browser) { + throw new Error('Page must be attached to a browser'); + } + + let cdp; + try { + cdp = await context.newCDPSession(page); + await cdp.send('ServiceWorker.enable'); + } catch { + cdp = await browser.newBrowserCDPSession(); + await cdp.send('ServiceWorker.enable'); + } + + const registrations = new Map(); + + cdp.on('ServiceWorker.workerRegistrationUpdated', (params: unknown) => { + const p = params as { registrations: Array<{ registrationId: string; scopeURL: string }> }; + for (const reg of p.registrations) { + registrations.set(reg.registrationId, { scopeURL: reg.scopeURL }); + } + }); + + await page.waitForTimeout(500); + + let registrationId: string | undefined; + for (const [id, reg] of registrations) { + if (reg.scopeURL === origin || reg.scopeURL === origin + '/') { + registrationId = id; + break; + } + } + + if (!registrationId) { + // The service worker may not have reported yet; nudge it. + await page.evaluate(() => navigator.serviceWorker.ready); + await page.waitForTimeout(500); + + for (const [id, reg] of registrations) { + if (reg.scopeURL === origin || reg.scopeURL === origin + '/') { + registrationId = id; + break; + } + } + } + + if (!registrationId) { + const known = [...registrations.values()].map((r) => r.scopeURL).join(', '); + throw new Error( + `No service worker registration found for ${origin}. Known scopes: ${known || '(none)'}` + ); + } + + return { + async deliver(data: string) { + await cdp.send('ServiceWorker.deliverPushMessage', { + origin, + registrationId, + data + }); + } + }; +} diff --git a/web/tests/e2e/push-stub.spec.ts b/web/tests/e2e/push-stub.spec.ts new file mode 100644 index 0000000..79c73ef --- /dev/null +++ b/web/tests/e2e/push-stub.spec.ts @@ -0,0 +1,189 @@ +import { test, expect } from '@playwright/test'; +import { createPushInjector } from './helpers/push-injector'; + +const domain = process.env.BEEBUZZ_DOMAIN || 'localhost'; +const siteURL = `https://${domain}`; +const hiveURL = `https://hive.${domain}`; +const apiURL = `https://api.${domain}`; +const mailpitAPI = process.env.MAILPIT_API || 'http://localhost:8025/api/v1'; +const demoEmail = process.env.DEMO_EMAIL || 'test-push-stub@beebuzz.local'; + +async function latestOTP() { + for (let attempt = 0; attempt < 60; attempt++) { + const messagesResponse = await fetch(`${mailpitAPI}/messages`); + if (!messagesResponse.ok) { + await new Promise((r) => setTimeout(r, 500)); + continue; + } + + const messagesBody = await messagesResponse.json(); + const messages = Array.isArray(messagesBody.messages) ? messagesBody.messages : []; + + for (const message of messages) { + const id = message.ID ?? message.Id ?? message.id; + if (!id) continue; + + const detailResponse = await fetch(`${mailpitAPI}/message/${id}`); + if (!detailResponse.ok) continue; + + const detail = await detailResponse.json(); + const toList = Array.isArray(detail.To) ? (detail.To as Array<{ Address: string }>) : []; + const searchable = `${toList.map((to) => to.Address).join(' ')}\n${detail.Text ?? ''}`; + if (!searchable.includes(demoEmail)) continue; + + const otp = String(detail.Text ?? '').match(/\b\d{6}\b/)?.[0]; + if (otp) return otp; + } + + await new Promise((r) => setTimeout(r, 500)); + } + + throw new Error(`OTP for ${demoEmail} not found in Mailpit`); +} + +async function waitForHTTP(url: string, label: string) { + for (let attempt = 0; attempt < 90; attempt++) { + try { + const response = await fetch(url); + if (response.ok || response.status < 500) return; + } catch { + // keep polling + } + await new Promise((r) => setTimeout(r, 1000)); + } + throw new Error(`${label} did not become ready: ${url}`); +} + +test.describe.configure({ mode: 'serial' }); + +test.describe('Push Stub E2E', () => { + test.beforeAll(async () => { + await waitForHTTP(siteURL, 'site'); + await waitForHTTP(hiveURL, 'Hive'); + await waitForHTTP(`${apiURL}/health`, 'API'); + await waitForHTTP(`${mailpitAPI}/messages`, 'Mailpit'); + }); + + test('complete signup, pairing, and receive push via stub bridge', async ({ browser }) => { + // Hive context + const hiveContext = await browser.newContext({ ignoreHTTPSErrors: true }); + + // Playwright push subscriptions use *.google.com endpoints that the backend + // rejects as unsupported. Rewrite them to a fake FCM endpoint so pairing + // succeeds while keeping the real allowlist strict. + await hiveContext.route('**/v1/pairing', async (route, request) => { + if (request.method() !== 'POST') { + await route.continue(); + return; + } + const body = JSON.parse(request.postData() || '{}'); + if (body.endpoint) { + const url = new URL(body.endpoint); + if (url.hostname.endsWith('.google.com') && url.hostname !== 'fcm.googleapis.com') { + body.endpoint = `https://fcm.googleapis.com/fcm/send/demo-${crypto.randomUUID()}`; + } + } + await route.continue({ postData: JSON.stringify(body) }); + }); + + await hiveContext.grantPermissions(['clipboard-read', 'clipboard-write', 'notifications'], { + origin: hiveURL + }); + const hivePage = await hiveContext.newPage(); + + // Site context + const siteContext = await browser.newContext({ ignoreHTTPSErrors: true }); + await siteContext.grantPermissions(['clipboard-read', 'clipboard-write'], { origin: siteURL }); + const sitePage = await siteContext.newPage(); + + // Diagnostics + hivePage.on('console', (message) => { + if (message.type() === 'error' || message.type() === 'warning') { + console.log(`[hive console:${message.type()}] ${message.text()}`); + } + }); + + // Step 1: open Site and Hive + await Promise.all([sitePage.goto(`${siteURL}/login`), hivePage.goto(`${hiveURL}/pair`)]); + + // Step 2: skip install fallback on Hive + const fallbackButton = hivePage.getByRole('button', { name: /continue in browser/i }); + if (await fallbackButton.isVisible().catch(() => false)) { + await fallbackButton.click(); + } + + // Step 3: sign in on Site + await sitePage.getByLabel('Email address').fill(demoEmail); + await sitePage.getByRole('button', { name: /continue/i }).click(); + await expect(sitePage).toHaveURL(/\/verify/); + + const otp = await latestOTP(); + await sitePage.getByLabel('One-time code').fill(otp); + await sitePage.getByRole('button', { name: /verify code/i }).click(); + await expect(sitePage).toHaveURL(/\/account\/overview/, { timeout: 15000 }); + + // Step 4: create pairing code + await sitePage.goto(`${siteURL}/account/devices`); + await sitePage.getByRole('button', { name: /add device/i }).click(); + await sitePage.getByLabel('Device Name').fill('Push Stub Test Device'); + await sitePage.getByRole('button', { name: /generate pairing code/i }).click(); + + const pairingDialog = sitePage.getByRole('dialog', { name: /pairing code generated/i }); + await expect(pairingDialog).toBeVisible(); + + const codeNode = pairingDialog.getByText(/^\s*\d{6}\s*$/).first(); + await expect(codeNode).toBeVisible(); + const pairingCode = (await codeNode.textContent())?.replace(/\D/g, ''); + if (!pairingCode || pairingCode.length !== 6) { + throw new Error('Pairing code was not visible'); + } + + // Step 5: paste pairing code in Hive + const pairingInput = hivePage.getByLabel('Pairing code'); + await pairingInput.evaluate((input: unknown, value: string) => { + const target = input as HTMLInputElement; + target.focus(); + target.value = value; + target.dispatchEvent(new Event('input', { bubbles: true })); + }, pairingCode); + + await hivePage.getByRole('button', { name: /connect device/i }).click(); + await expect(hivePage.getByText(/no notifications yet/i)).toBeVisible({ timeout: 30000 }); + + await pairingDialog.getByRole('button', { name: /done/i }).click(); + await expect(sitePage.getByText(/pairing done/i)).toBeVisible({ timeout: 15000 }); + await sitePage.getByRole('button', { name: /go to api tokens/i }).click(); + + // Step 6: create API token + await expect(sitePage).toHaveURL(/\/account\/api-tokens/); + await sitePage + .getByRole('button', { name: /create token/i }) + .first() + .click(); + await sitePage.getByLabel('Token Name').fill('Push Stub Test Token'); + await sitePage.getByRole('button', { name: /^create token$/i }).click(); + + const tokenDialog = sitePage.getByRole('dialog', { name: /api token created/i }); + await expect(tokenDialog).toBeVisible(); + + // Step 7: send test notification via UI + await sitePage.getByRole('button', { name: /send test notification now/i }).click(); + await expect(tokenDialog.getByText(/test sent to/i)).toBeVisible({ timeout: 30000 }); + + // Step 8: poll push stub bridge for the captured payload + const stubResponse = await fetch(`${apiURL}/_stub/push/next`); + expect(stubResponse.ok).toBe(true); + const pushEvent = await stubResponse.json(); + expect(pushEvent.data).toBeDefined(); + + // Step 9: inject the push into Hive via CDP + const injector = await createPushInjector(hivePage, hiveURL); + await injector.deliver(pushEvent.data); + + // Step 10: assert notification visible in Hive + await expect(hivePage.getByText('BeeBuzz test notification')).toBeVisible({ timeout: 10000 }); + + await siteContext.close(); + await hiveContext.close(); + }); +});