@407dev/site-services
v0.1.0
Published
Per-site runtime data plane for client sites: a publishable Hono sub-app, mounted at `/_api/*` (and `/_ev` for analytics ingest) inside a site's own Astro-on-Cloudflare deployment, backed by that site's own D1 + KV. Provides contact forms, first-party dua
Readme
@407dev/site-services
Purpose
Per-site runtime data plane for client sites: a publishable Hono sub-app, mounted at /_api/* (and /_ev for analytics ingest)
inside a site's own Astro-on-Cloudflare deployment, backed by that site's own D1 + KV. Provides contact forms, first-party
dual-tier web analytics, email subscriber management & campaign dispatch, SMS subscriber capture & quiet-hours campaign dispatch, and end-user authentication with gated routes. Raw end-user/visitor data never enters Supabase — see
docs/site-services/site-services.md and docs/site-services/sms-campaigns.md for the
cross-cutting designs.
Surface
createSiteServices(config)— builds the Hono app (src/index.ts).siteServices(config)— Astro integration, injects/_api/[...path]and/_evroutes, enforces two-phase non-prerender on gated routes, and injects auth middleware (src/astro/integration.ts).<ContactForm formKey honeypotField? turnstileSiteKey? successMessage? errorMessage? />—src/astro/ContactForm.astro. Namedsubmitslot overrides the default submit button.<SmsSignUpForm brandName? programDescription? messageFrequency? honeypotField? />—src/astro/SmsSignUpForm.astro. Renders visible PEWC disclosure and captures phone numbers intosms_subscribers.<SignUpForm />,<SignInForm />,<ForgotPasswordForm />,<ResetPasswordForm />,<SignOutButton />— unstyled native form components insrc/astro/*.astro.<MemberVideo uid poster? controls? autoplay? />— custom HLS player with self-signed Stream token fetch and lazyhls.jsfallback (src/astro/MemberVideo.astro).<MemberAudio assetId title? chapters? controls? />— progressive lossless R2 audio player with chapter seeking (src/astro/MemberAudio.astro).requireAuth(Astro)— programmatic in-page gating helper (src/auth/requireAuth.ts).resolveEntitlement(member)— entitlement resolution seam for gated media (src/media/entitlement.ts).signStreamToken({ uid, keyId, jwk })— RS256 JWT self-signing helper (src/media/streamToken.ts).streamPrivateObject(bucket, key, request)— 206 Partial Content, HEAD, and If-Range streaming handler (src/media/range.ts).renderPodcastRss(feed)— RSS 2.0 / iTunes XML renderer with immutable episode UUIDs as guids (src/media/rss.ts).encodeAndCount(body)— GSM-7 & UCS-2 segment counter, re-exported from@407dev/sms-text(src/sms/segments.ts).isQuietHours(tzId, stateOverride?, now?)/sendableTzIds(now?, stateOverrides?)— TCPA/CTIA quiet-hours engine (src/sms/quietHours.ts).formAction(formKey)— action URL helper for hand-rolled forms.site-services migrate [--local|--remote] [--config <path>] [--binding <name>]— CLI (bin/site-services.mjs).- Public Routes:
GET /_api/health(never gated),POST /_ev(analytics ingest, returns 204),GET /_ev/vitals.<hash>.js(immutable cached vitals bundle),GET /_ev/vitals.js(revalidating vitals bundle),POST /_api/forms/:formKey,POST /_api/subscribers,GET /_api/subscribers/confirm,GET|POST /_api/subscribers/unsubscribe,POST /_api/webhooks/resend,POST /_api/sms/subscribe,GET|POST /_api/sms/unsubscribe,POST /_api/webhooks/twilio,ALL /_api/auth/*(sign-up, sign-in, sign-out, forget-password, reset-password, get-session),GET /_api/media/stream-token/:uid,GET /_api/media/audio/:assetId,GET /_api/media/feed-token,POST /_api/media/feed-token/revoke. - Admin Routes (service-token only):
GET|PATCH /_api/admin/submissions[/:id],POST /_api/admin/analytics/salt,GET /_api/admin/analytics/query,GET /_api/admin/analytics/drift,GET /_api/admin/marketing/config,GET /_api/admin/subscribers[/stats],POST /_api/admin/campaigns/sends[/:id/dispatch],GET /_api/admin/campaigns/sends[/:id],POST /_api/admin/campaigns/test-send,GET /_api/admin/sms/config,GET /_api/admin/sms/subscribers[/stats],POST /_api/admin/sms/sends[/:id/dispatch],GET /_api/admin/sms/sends[/stats][/:id],POST /_api/admin/sms/test-send,GET /_api/admin/members[/stats],DELETE /_api/admin/members/:id.
Configuration
SiteServicesConfig: siteId, forms?: Record<string, { recipient, subject?, successUrl?, honeypotField? }>,
turnstile?: { siteKey }, retention?: { submissionsDays: number | null } (default null = off),
notify?: { threshold, digestIntervalMs } (defaults 5 / 15 min), analytics?: { enabled?: boolean, customEvents?: string[], collapse?: { pattern: string, as: string }[] }, marketing?: { enabled?: boolean, fromAddress?: string, replyTo?: string, physicalAddress?: string, publicUrl?: string, doubleOptIn?: boolean, confirmedUrl?: string, unsubscribedUrl?: string }, sms?: { enabled?: boolean, brandName?: string, programDescription?: string, messageFrequency?: string, publicUrl?: string, subscribedUrl?: string, unsubscribedUrl?: string, welcomeTemplate?: string }, contentUrl?: string, auth?: { enabled?: boolean, publicUrl?: string, gatedRoutes?: string[], loginUrl?: string, verifyPendingUrl?: string, verifiedUrl?: string, resetUrl?: string, fromAddress?: string, requireEmailVerification?: boolean, sessionMaxAgeSec?: number, turnstile?: boolean, previewSecret?: string }. Valid CMS editor preview tokens (?preview_token=/x-preview-token) skip the login redirect on gated routes without a member session by verifying remotely with content-worker (GET /preview/:siteId/verify, cached in-isolate). Site hosts hold zero preview secrets; auth.previewSecret is deprecated. Resolution + validation in src/config.ts.
Runtime bindings/secrets (src/env.ts): SITE_DB (D1), SITE_KV (KV), SITE_SERVICE_PUBLIC_JWK
SITE_SERVICE_KEY_ID(vars),SITE_AUTH_SECRET,TURNSTILE_SECRET_KEY,RESEND_API_KEY,RESEND_FROM_ADDRESS,RESEND_WEBHOOK_SECRET,RATE_LIMIT_SALT(secrets),ANALYTICS(Analytics Engine),ANALYTICS_QUEUE(Queue),ANALYTICS_ARCHIVE(R2),MEDIA_PRIVATE(R2),STREAM_SIGNING_KEY_ID,STREAM_SIGNING_JWK,STREAM_CUSTOMER_CODE,TWILIO_ACCOUNT_SID,TWILIO_API_KEY_SID,TWILIO_API_KEY_SECRET,TWILIO_AUTH_TOKEN,TWILIO_MESSAGING_SERVICE_SID.
Migrations
src/migrations/0000_init.sql, src/migrations/0001_analytics.sql, src/migrations/0002_marketing.sql, src/migrations/0003_auth.sql, src/migrations/0004_analytics_v2.sql, src/migrations/0005_member_media.sql, src/migrations/0006_member_feed_key.sql, and src/migrations/0007_sms.sql are source SQL; src/migrations/index.ts embeds the same SQL as
string literals so dist needs no filesystem access at runtime — keep them in sync.
EXPECTED_SCHEMA_VERSION = 8 (src/schema/version.ts) is the version the running package expects.
src/db/gate.ts reads _schema_version once per isolate and 503s /forms/*, /subscribers/*, /sms/*, /auth/*, and /admin/* (not
/health or /_ev) when the DB is behind. The gate is one-directional: DB ahead of the package (e.g. after
a rollback) serves normally.
Patterns
- Ingest pipeline (
src/analytics/ingest.ts): snapshots headers/CF, classifies bots, checks Sec-GPC/DNT, normalizes path, derives visitor & tumbling session hash with daily salt, writes hot and archive sinks, returns 204. - Forms pipeline (
src/forms/submit.ts): honeypot → KV rate limit → Turnstile (verified when present, never required) → insert row →ctx.waitUntil(notify + purge). Form-encoded posts get a303redirect (no-JS path); JSON posts get200 { ok: true }. - SMS Quiet Hours (
src/sms/quietHours.ts): derived timezone and state overrides at claim time; recipients in quiet hours remain pending and are never force-sent early or late. - Notification batching (
src/forms/notify.ts) is a pure state machine (nextNotifyDecision) wrapped by a D1-backed persister — the pure function is what's unit tested. - Admin auth (
src/admin/auth.ts) verifies EdDSA-signed, single-audience, 10-minute-max service tokens; mutating routes burn the token'sjtiinto KV so it can't be replayed.
Integrations
- Minted and consumed only by
apps/api(apps/api/src/serviceToken.ts,siteServices.ts,sms.ts,insights.ts) — this package never talks to Supabase or accepts a user JWT. examples/demo-sitewiressiteServices()intoastro.config.mjswith analytics enabled.
Constraints
- Nothing here may import from
apps/*(repo-wide rule). /admin/*and/_evnever have CORS middleware applied — noAccess-Control-Allow-Originheader is ever emitted.- The Astro integration throws at config time if no adapter is configured — the injected route would otherwise silently prerender to nothing.
Gotchas
astro devneedsplatformProxy: { enabled: true }on the Cloudflare adapter to exposeSITE_DB/SITE_KVviaAstro.locals.runtime.envlocally. Miniflare creates stubs forANALYTICSandANALYTICS_QUEUE; in dev modewriteHotwrites to the D1analytics_hotmirror so admin insights queries work locally.site-services migrateis idempotent (IF NOT EXISTS/INSERT OR IGNOREthroughout) — safe to run on every deploy, which is the point: run it immediately beforewrangler deploy.- KV-based rate limiting is read-then-write, not atomic; treat the limits as a soft anti-spam budget, not a hard guarantee.
