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
+
+
+
-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.
+
+
+
+
+
+
+
+
+
+
+
-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 @@
+
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 @@
+
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