@tokenoftrust/storefront-runner 1.4.0 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/apps/storefront/migrations-apps/0001_woozy_lyja.sql +22 -0
  2. package/apps/storefront/migrations-apps/meta/0001_snapshot.json +990 -0
  3. package/apps/storefront/migrations-apps/meta/_journal.json +7 -0
  4. package/apps/storefront/package.json +0 -1
  5. package/apps/storefront/src/components/admin/AdminPublishTab.astro +1735 -132
  6. package/apps/storefront/src/lib/activity/alerts.ts +428 -0
  7. package/apps/storefront/src/lib/activity/changeActorAttribution.ts +85 -0
  8. package/apps/storefront/src/lib/activity/deployVersion.ts +127 -0
  9. package/apps/storefront/src/lib/activity/ingest.ts +188 -0
  10. package/apps/storefront/src/lib/activity/ingestAuth.ts +105 -0
  11. package/apps/storefront/src/lib/activity/killSwitch.ts +80 -0
  12. package/apps/storefront/src/lib/activity/query.ts +403 -0
  13. package/apps/storefront/src/lib/activity/recordActivity.ts +105 -0
  14. package/apps/storefront/src/lib/activity/store.ts +122 -0
  15. package/apps/storefront/src/lib/activity/uiActor.ts +150 -0
  16. package/apps/storefront/src/lib/activity/workerCommit.ts +70 -0
  17. package/apps/storefront/src/lib/auth/mcpClientAssertion.ts +4 -0
  18. package/apps/storefront/src/lib/auth/route.ts +44 -4
  19. package/apps/storefront/src/lib/d1/schema-apps.ts +54 -0
  20. package/apps/storefront/src/lib/dev/vcBinding.ts +80 -0
  21. package/apps/storefront/src/lib/env.ts +51 -0
  22. package/apps/storefront/src/lib/publish/shipWorkspace.ts +15 -8
  23. package/apps/storefront/src/middleware/index.ts +17 -0
  24. package/apps/storefront/src/pages/admin/ops-timeline.astro +420 -0
  25. package/apps/storefront/src/pages/admin.astro +154 -6
  26. package/apps/storefront/src/pages/api/activity.ts +137 -0
  27. package/apps/storefront/src/pages/api/admin/activity-alerts.ts +73 -0
  28. package/apps/storefront/src/pages/api/apps/admin/credentials/rotate.ts +27 -1
  29. package/apps/storefront/src/pages/api/apps/admin/install.ts +26 -1
  30. package/apps/storefront/src/pages/api/apps/admin/resume.ts +24 -1
  31. package/apps/storefront/src/pages/api/apps/admin/suspend.ts +24 -1
  32. package/apps/storefront/src/pages/api/apps/admin/uninstall.ts +25 -1
  33. package/apps/storefront/src/pages/api/apps/admin/update.ts +24 -1
  34. package/apps/storefront/src/pages/api/apps/admin/webhooks/deliveries/[deliveryId]/replay.ts +11 -0
  35. package/apps/storefront/src/pages/api/apps/internal/order-forward.ts +11 -0
  36. package/apps/storefront/src/pages/api/apps/v1/attribution.ts +20 -0
  37. package/apps/storefront/src/pages/api/apps/v1/webhooks/deliveries/[deliveryId]/replay.ts +12 -0
  38. package/apps/storefront/src/pages/api/cache-purge.ts +11 -0
  39. package/apps/storefront/src/pages/api/dashboard/enter-vendor.ts +30 -0
  40. package/apps/storefront/src/pages/auth/login.astro +5 -4
  41. package/apps/storefront/src/pages/cockpit.astro +40 -3
  42. package/package.json +1 -1
  43. package/packages/public-runtime/src/activity/README.md +146 -0
  44. package/packages/public-runtime/src/activity/catalog.ts +501 -0
  45. package/packages/public-runtime/src/activity/event.ts +168 -0
  46. package/packages/public-runtime/src/activity/index.ts +16 -0
  47. package/packages/public-runtime/src/activity/redaction.ts +263 -0
  48. package/packages/public-runtime/src/candidate-index.ts +13 -0
  49. package/packages/public-runtime/src/index.ts +6 -0
  50. package/apps/storefront/src/lib/webhooks/signing.ts +0 -29
  51. package/apps/storefront/src/lib/webhooks/webhookSigningKey.ts +0 -146
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Shared emit helper for UI-ORIGINATED operational activity (card D6).
3
+ *
4
+ * The UI cohort's entry point onto the D0 actor×action telemetry contract. It sits
5
+ * ON TOP of D1's server emitter ({@link recordActivity}) — every admin/cockpit action
6
+ * handler already runs SERVER-SIDE in the same Worker, so a UI action reaches
7
+ * telemetry through the same in-process `recordActivity()` call D1 uses (one
8
+ * structured Workers-Logs line), NOT a client-side `fetch` to the D4 ingest endpoint.
9
+ * There is no browser-only signal to capture: an admin/cockpit "Publish / Connect /
10
+ * Invite / Approve / Rollback" click is a server-rendered form/JSON POST to an API
11
+ * route, so the route handler that already does the work is exactly where the emit
12
+ * belongs (zero extra HTTP round-trip, no new auth/trust boundary). The D4 HMAC
13
+ * ingest path exists for emitters that CAN'T call `recordActivity()` directly (the
14
+ * out-of-process CLI cohort, D3) — it is not this cohort's path.
15
+ *
16
+ * WHAT THIS ADDS over calling `recordActivity()` raw: it stamps `source: "ui"` and,
17
+ * critically, resolves the ACTOR KIND from the authenticated session so the timeline
18
+ * (D5) can attribute an action to the right cohort:
19
+ *
20
+ * • "admin" — a ToT-STAFF viewer (the session carries `staff[]` scope). ToT
21
+ * operators acting through the admin shell / staff vendor selection.
22
+ * • "merchant" — a store OWNER acting on THEIR OWN store (a signed-in viewer whose
23
+ * host-bound capability is owner/admin). The self-serve cohort.
24
+ * • "dev" — a signed-in DEVELOPER viewer who is neither staff nor owner (e.g. a
25
+ * ship-on-behalf developer invited onto someone else's store).
26
+ * • "agent" — a HEADLESS, capability-driven caller with no human viewer session
27
+ * (the Bearer/CLI operator seam) — automation acting on the surface.
28
+ *
29
+ * The classifier is a PURE function ({@link classifyActorKind}) so the cohort mapping
30
+ * is unit-tested without a live request. The opaque `actor.id` is the salted,
31
+ * non-reversible hash of the acting identity (never raw PII in the core zone) — the
32
+ * SAME {@link hashActorId} + `ACTIVITY_ACTOR_SALT` D1 established, so one person stays
33
+ * correlatable across the CLI, server, and UI cohorts.
34
+ *
35
+ * USAGE (mirrors D1's reject.ts — resolve the actor ONCE after auth, emit at each
36
+ * exit point, zero change to the handler's control flow):
37
+ *
38
+ * const actor = await resolveUiActor(context, { owner: auth.owner });
39
+ * // …do the work…
40
+ * recordUiActivity(actor, { action: "change.ship", outcome: { status: "succeeded" }, scope });
41
+ */
42
+ import type { APIContext } from "astro";
43
+ import type {
44
+ ActivityActor,
45
+ ActorKind,
46
+ ActivityOutcome,
47
+ ActivityScope,
48
+ ActivityPayload,
49
+ } from "@tot/public-runtime";
50
+ import type { ActionKey } from "@tot/public-runtime";
51
+ import { readViewerSession } from "@/lib/auth/route";
52
+ import { isStaffSession } from "@/lib/dashboard/staffAdmission";
53
+ import { readEnv } from "@/lib/env";
54
+ import { hashActorId, recordActivity } from "./recordActivity";
55
+
56
+ /** The host-bound capabilities that mark a viewer as the store OWNER (merchant). */
57
+ const OWNER_CAPABILITIES = new Set(["owner", "admin"]);
58
+
59
+ /** The minimal viewer chrome the middleware stamps on `locals.viewer`. */
60
+ interface ViewerLike {
61
+ email?: string;
62
+ capability?: string;
63
+ }
64
+
65
+ /**
66
+ * Classify the acting cohort from the authenticated session — PURE so the mapping is
67
+ * testable without a request. Order matters and is fail-safe:
68
+ *
69
+ * 1. no signed-in viewer → "agent" (headless Bearer/CLI operator — capability-
70
+ * driven automation; there is no human identity on this request).
71
+ * 2. ToT-staff session → "admin" (checked BEFORE ownership: a staff viewer who
72
+ * entered a vendor via a live selection carries an owner-ish host capability, but
73
+ * they are ToT staff, not the merchant).
74
+ * 3. owner/admin capability on this host → "merchant" (the store owner on their own store).
75
+ * 4. otherwise → "dev" (a signed-in developer without ownership, e.g.
76
+ * ship-on-behalf).
77
+ */
78
+ export function classifyActorKind(
79
+ viewer: ViewerLike | null | undefined,
80
+ session: { staff?: string[] } | null | undefined,
81
+ ): ActorKind {
82
+ if (!viewer?.email) return "agent";
83
+ if (isStaffSession(session)) return "admin";
84
+ if (viewer.capability && OWNER_CAPABILITIES.has(viewer.capability)) return "merchant";
85
+ return "dev";
86
+ }
87
+
88
+ export interface ResolveUiActorInput {
89
+ /**
90
+ * Fallback acting identity for the HEADLESS path (no signed-in viewer) — typically
91
+ * the resolved tenant owner (`auth.owner`). Only used to seed the opaque id when
92
+ * there is no viewer email; never stored raw (always hashed).
93
+ */
94
+ owner?: string;
95
+ }
96
+
97
+ /**
98
+ * Resolve the {@link ActivityActor} for a UI-originated action from the request's
99
+ * authenticated session. Reads the full viewer session ONCE (for the staff scope the
100
+ * host-bound `locals.viewer` chrome doesn't carry) and computes the opaque, salted
101
+ * actor id. Best-effort: any read fault degrades to a coarse non-PII actor rather
102
+ * than throwing — telemetry must never break the action it describes. Resolve this
103
+ * ONCE per request (after auth) and reuse the result across every emit exit point.
104
+ */
105
+ export async function resolveUiActor(
106
+ context: APIContext,
107
+ input: ResolveUiActorInput = {},
108
+ ): Promise<ActivityActor> {
109
+ // The host-bound `locals.viewer` chrome omits `staff[]`, so read the full session
110
+ // to tell a ToT-staff (admin) actor from a store owner (merchant). Best-effort.
111
+ const session = await readViewerSession(context).catch(() => null);
112
+ // Prefer the host-scoped `locals.viewer` (set by middleware for tenant-gated
113
+ // surfaces — the ship/grants routes); fall back to the raw session's
114
+ // email/capability for a route that never went through that host gate (e.g. the
115
+ // /dev cockpit's rendezvous-approval endpoint) but still has a real signed-in
116
+ // viewer. Both describe the SAME signed-in-or-not shape.
117
+ const viewer: ViewerLike | undefined =
118
+ (context.locals?.viewer as ViewerLike | undefined) ??
119
+ (session ? { email: session.email, capability: session.capability } : undefined);
120
+ const kind = classifyActorKind(viewer, session);
121
+ const identifier = viewer?.email ?? input.owner ?? "system";
122
+ const id = await hashActorId(identifier, await readEnv("ACTIVITY_ACTOR_SALT"));
123
+ return { kind, id };
124
+ }
125
+
126
+ /**
127
+ * Emit ONE UI-originated activity event with a PRE-RESOLVED actor. A thin, SYNC
128
+ * wrapper over D1's {@link recordActivity} that stamps `source: "ui"` — so a handler
129
+ * calls it at each exit point (succeeded / failed / refused) exactly as D1's server
130
+ * emitters do, with no control-flow change. Best-effort + never throws (inherited
131
+ * from `recordActivity`).
132
+ */
133
+ export function recordUiActivity(
134
+ actor: ActivityActor,
135
+ input: {
136
+ action: ActionKey;
137
+ outcome: ActivityOutcome;
138
+ scope?: ActivityScope;
139
+ payload?: ActivityPayload;
140
+ },
141
+ ): void {
142
+ recordActivity({
143
+ action: input.action,
144
+ actor,
145
+ source: "ui",
146
+ outcome: input.outcome,
147
+ ...(input.scope ? { scope: input.scope } : {}),
148
+ ...(input.payload ? { payload: input.payload } : {}),
149
+ });
150
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * worker_commit — build provenance for the running Worker (card D2).
3
+ *
4
+ * The commit SHA THIS Worker build was cut from, made available REPO-WIDE at
5
+ * runtime so every server-emitted ActivityEvent can carry it (see event.ts
6
+ * `ActivityScope.workerCommit`) and D5 can later derive **deploy skew** — "is the
7
+ * build serving this request == the pushed umbrella tip, or is a stale worker
8
+ * still live?".
9
+ *
10
+ * HOW IT GETS HERE. Workers have no filesystem/git at runtime, so the SHA is
11
+ * baked in at BUILD time: astro.config.mjs's Vite `define` replaces the
12
+ * `__GIT_SHA__` token with a string literal of the build's commit (CI-provided
13
+ * `GIT_SHA`/`WORKERS_CI_COMMIT_SHA`/`GITHUB_SHA`, else local `git rev-parse`,
14
+ * else "unknown"). This is the SAME source /health already reports (lib/health.ts)
15
+ * — we read it through the identical `typeof` guard so both agree by construction,
16
+ * and so the value is safe under vitest (where the define isn't applied → "unknown").
17
+ *
18
+ * This is the single accessor the whole repo threads through: D1's recordActivity()
19
+ * spreads serverActivityScope() into every server event; D2's deploy.version_observed
20
+ * emitter (deployVersion.ts) reads workerCommit() directly.
21
+ */
22
+ import type { ActivityScope } from "@tot/public-runtime";
23
+
24
+ /** Sentinel when the build had no git context (also the vitest value). */
25
+ export const UNKNOWN_COMMIT = "unknown";
26
+
27
+ /**
28
+ * The commit SHA this Worker build was cut from, or "unknown". Never throws; the
29
+ * `typeof` guard keeps it safe under vitest (the `__GIT_SHA__` define is only
30
+ * applied by the Astro/Vite build). Optionally overridable for tests.
31
+ */
32
+ export function workerCommit(override?: string): string {
33
+ if (override != null) return override;
34
+ return typeof __GIT_SHA__ !== "undefined" ? __GIT_SHA__ : UNKNOWN_COMMIT;
35
+ }
36
+
37
+ /** First 7 chars of the build commit for eyeballing against `git log`, or "unknown". */
38
+ export function workerCommitShort(override?: string): string {
39
+ const sha = workerCommit(override);
40
+ return sha === UNKNOWN_COMMIT ? UNKNOWN_COMMIT : sha.slice(0, 7);
41
+ }
42
+
43
+ /**
44
+ * True when the build has a real git commit (not the "unknown" sentinel). D5's
45
+ * skew derivation only compares against the umbrella tip when this holds — an
46
+ * "unknown" build can't be meaningfully skew-checked.
47
+ */
48
+ export function hasKnownWorkerCommit(override?: string): boolean {
49
+ return workerCommit(override) !== UNKNOWN_COMMIT;
50
+ }
51
+
52
+ /**
53
+ * Build the CORE scope for a SERVER-sourced ActivityEvent with `worker_commit`
54
+ * stamped in. This is the seam D1's recordActivity() spreads into every server
55
+ * event so provenance is present WITHOUT each call site remembering it:
56
+ *
57
+ * createActivityEvent({ ..., source: "server", scope: serverActivityScope({ tenantId }) })
58
+ *
59
+ * Pass the event's other scope fields (tenantId/changeId/traceId) as `extra`;
60
+ * workerCommit always wins for the `workerCommit` slot. Omits the field entirely
61
+ * when the build commit is "unknown" so a downstream skew query never mistakes the
62
+ * sentinel for a real deployed SHA.
63
+ */
64
+ export function serverActivityScope(extra?: ActivityScope): ActivityScope {
65
+ const sha = workerCommit();
66
+ return {
67
+ ...extra,
68
+ ...(sha !== UNKNOWN_COMMIT ? { workerCommit: sha } : {}),
69
+ };
70
+ }
@@ -30,6 +30,10 @@
30
30
  * header comment for the same rule stated from the gateway side). The key is a
31
31
  * PROVIDED secret (generated + synced via SSM/`.secrets[]`, see B3), never
32
32
  * autogenerated at runtime; tests/local-dev generate a throwaway keypair instead.
33
+ * This same rule applies ACROSS EXECUTION ENVIRONMENTS, not just within this Worker:
34
+ * a different runtime (e.g. a CI pipeline) needing MCP machine identity gets its OWN
35
+ * confidential-client registration + keypair, never a copy of this one — see tot-mcp
36
+ * `devbook/app-binding.md` Recipe A ("multiple execution environments").
33
37
  */
34
38
  import { importPKCS8, exportJWK, SignJWT, type CryptoKey, type JWK, type JSONWebKeySet } from "jose";
35
39
  import { readEnv } from "@/lib/env";
@@ -120,14 +120,54 @@ export function clearSessionCookie(context: APIContext): void {
120
120
  context.cookies.delete(SESSION_COOKIE, { path: "/" });
121
121
  }
122
122
 
123
+ /** True if `s` contains any C0 control char (0x00–0x1F) or DEL (0x7F) — CR/LF
124
+ * included, so a `next` value can never smuggle a header break or NUL. Written as
125
+ * a charCode scan (not a control-char regex literal) so the source stays printable. */
126
+ function hasControlChar(s: string): boolean {
127
+ for (let i = 0; i < s.length; i++) {
128
+ const c = s.charCodeAt(i);
129
+ if (c <= 0x1f || c === 0x7f) return true;
130
+ }
131
+ return false;
132
+ }
133
+
123
134
  /**
124
- * Sanitize a `next` redirect target to a same-origin PATH only (starts with a
125
- * single "/", not "//" or a scheme) closes the open-redirect hole. Falls back
126
- * to "/".
135
+ * Sanitize a post-sign-in `next`/returnTo redirect target to a same-origin,
136
+ * path-only reference — the single open-redirect chokepoint for the whole sign-in
137
+ * surface (the login gate, verify, and the shared `pr/<N>` reviewer link all funnel
138
+ * through here). Anything that could resolve off-origin falls back to "/".
139
+ *
140
+ * Rejected, because a browser would treat each as an ABSOLUTE/off-origin URL:
141
+ * - a scheme or bare host ("https://evil.com", "evil.com")
142
+ * - protocol-relative ("//evil.com")
143
+ * - backslash-smuggled ("/\evil.com", "\\evil.com") — browsers normalize
144
+ * "\" → "/", so "/\evil.com" resolves as "//evil.com"
145
+ * - encoded slash/backslash ("/%2F%2Fevil.com", "/%5Cevil.com") that DECODES
146
+ * to a protocol-relative form
147
+ * - control chars / newlines (header-injection belt-and-suspenders)
148
+ * - malformed percent-encoding (refuse rather than guess)
149
+ *
150
+ * The ORIGINAL (still-encoded) value is returned on success — decoding is only for
151
+ * the safety check, never for the redirect target.
127
152
  */
128
153
  export function safeNext(next: string | null | undefined): string {
129
154
  if (!next) return "/";
130
- if (!next.startsWith("/") || next.startsWith("//")) return "/";
155
+ if (!next.startsWith("/")) return "/";
156
+ // No control chars / CR / LF anywhere (header-injection belt-and-suspenders).
157
+ if (hasControlChar(next)) return "/";
158
+ // Normalize backslashes to slashes the way a browser would, then reject any
159
+ // protocol-relative ("//host") form — raw or backslash-smuggled.
160
+ if (next.replace(/\\/g, "/").startsWith("//")) return "/";
161
+ // Defeat encoded slash/backslash smuggling: decode once and re-check that it is
162
+ // still a single-slash, same-origin path. Malformed encoding → refuse.
163
+ let decoded: string;
164
+ try {
165
+ decoded = decodeURIComponent(next);
166
+ } catch {
167
+ return "/";
168
+ }
169
+ const decodedNormalized = decoded.replace(/\\/g, "/");
170
+ if (!decodedNormalized.startsWith("/") || decodedNormalized.startsWith("//")) return "/";
131
171
  return next;
132
172
  }
133
173
 
@@ -71,6 +71,60 @@ export const appAuditLog = sqliteTable(
71
71
  }),
72
72
  );
73
73
 
74
+ // --- Operational activity timeline (activity-telemetry epic, card D4) --------
75
+ // The low-volume / high-value LIFECYCLE cohort of the actor×action telemetry
76
+ // spine (DECISIONS.md §8). Only actions whose catalog `spec.sinks` include
77
+ // "timeline" land here; the high-volume cohort goes to Cloudflare Analytics
78
+ // Engine (binding `ACTIVITY_ANALYTICS`), and EVERY action also goes to Workers
79
+ // Logs. Reuses the EXISTING STOREFRONT_APPS_DB (precedent: `app_audit_log`
80
+ // above) rather than a new binding — the cohort is small (~50k rows/month at
81
+ // 10× pilot) and the D5 query UI needs point queries + joins + arbitrary
82
+ // retention that Analytics Engine cannot give.
83
+ //
84
+ // `id` is the emitter-minted event UUID and the PRIMARY KEY, so a retried/
85
+ // replayed ingest is idempotent (INSERT OR IGNORE). `tenant_id` is written from
86
+ // the AUTHENTICATED ingest identity, never the client-supplied `scope.tenantId`
87
+ // (see api/activity.ts) — that is the multi-tenant isolation boundary. `payload`
88
+ // is the SERVER-redacted JSON (default-deny allowlist + canary scan re-applied
89
+ // at ingest, never trusting client-side redaction alone). The core columns
90
+ // mirror the `ActivityEvent` envelope's non-payload zone (packages/public-
91
+ // runtime/src/activity/event.ts), flattened for SQL point queries.
92
+ export const activityEvents = sqliteTable(
93
+ "activity_events",
94
+ {
95
+ id: text("id").primaryKey(),
96
+ tenantId: text("tenant_id").notNull(),
97
+ action: text("action").notNull(),
98
+ actorKind: text("actor_kind").notNull(),
99
+ // Opaque, pseudonymized actor id — NEVER raw email/PII (emitter's contract).
100
+ actorId: text("actor_id").notNull(),
101
+ actorRef: text("actor_ref"),
102
+ source: text("source").notNull(),
103
+ status: text("status").notNull(),
104
+ // Low-cardinality error CLASS, never a raw exception message.
105
+ errorClass: text("error_class"),
106
+ durationMs: integer("duration_ms"),
107
+ changeId: text("change_id"),
108
+ traceId: text("trace_id"),
109
+ // Server-redacted payload JSON (args + rendered lanes), default "{}".
110
+ payload: text("payload").notNull().default("{}"),
111
+ // Envelope contract version (ACTIVITY_SCHEMA_VERSION) so a reader can migrate.
112
+ schemaVersion: integer("schema_version").notNull(),
113
+ // Emit-clock ISO-8601 (the event's `at`).
114
+ at: text("at").notNull(),
115
+ // Server receive-clock ISO-8601 (when ingest stored it).
116
+ ingestedAt: text("ingested_at").notNull(),
117
+ },
118
+ (t) => ({
119
+ // The D5 timeline query: a tenant's events newest-first.
120
+ idxActivityTenantAt: index("idx_activity_tenant_at").on(t.tenantId, t.at),
121
+ // Filter a tenant's timeline by action family (dashboards glob the prefix).
122
+ idxActivityTenantAction: index("idx_activity_tenant_action").on(t.tenantId, t.action),
123
+ // Join a change's whole lifecycle span across actions.
124
+ idxActivityChange: index("idx_activity_change").on(t.changeId),
125
+ }),
126
+ );
127
+
74
128
  // --- Webhook event spine (PrivateApps epic, D4 Chunk A) ---------------------
75
129
  // Immutable event log. `id` is the semantic event id we mint — distinct from
76
130
  // any per-delivery wire envelope id (see `webhookDeliveries.deliveryId` below).
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Cockpit safety-net for the 08-18 vc-app-binding gap (§5): ensure the signed-in
3
+ * developer is bound to storefront's version-control app so their `tot start`
4
+ * checkout (`tenant_checkout`) and the repo tools stop 403-ing "No version-control
5
+ * app is bound to your session."
6
+ *
7
+ * WHY a net at all: the ROOT fix binds every entitled human at the authority seams
8
+ * the MCP owns — grant issuance, rendezvous `/approve`, native OAuth token issuance
9
+ * — plus a one-shot backfill. Those cover the terminal path. This net covers the
10
+ * BROWSER-FIRST developer who reached /cockpit via the storefront's own tot20
11
+ * sign-in (a `tot_session` cookie) WITHOUT ever crossing an MCP binding seam, so
12
+ * they'd otherwise land at cockpit with a `tot start` command that 403s.
13
+ *
14
+ * HOW (mirrors cli-signin-approve): there is NO per-human MCP session server-side —
15
+ * the cockpit reaches the MCP only as the APP (private_key_jwt). So we re-issue a
16
+ * FRESH tot20-signed id_token for the VERIFIED session email (via the storefront's
17
+ * core credential — {@link reissueSubjectToken}, the same proof `/approve` sends)
18
+ * and pass it as the `subject_token` the MCP binds the enrollment to. The app can't
19
+ * assert an arbitrary subject; the MCP verifies the proof + the subject's LIVE
20
+ * entitlement and authors the binding under its own forge authority (ADR-0001).
21
+ *
22
+ * ALWAYS FAIL-OPEN: any gap (proof re-issue failed, MCP cold/unreachable, the
23
+ * endpoint not yet deployed) returns a typed `{ ok:false }` and NEVER throws — the
24
+ * cockpit must render regardless, and the authority-seam binding + backfill are the
25
+ * real guarantees. This is strictly a best-effort accelerator.
26
+ */
27
+ import { createForgeClient } from "@/lib/forge";
28
+ import { reissueSubjectToken } from "@/lib/dev/subjectReissue";
29
+
30
+ export type EnsureCockpitBindingResult =
31
+ | { ok: true; bound: boolean; created: boolean }
32
+ | { ok: false; reason: string };
33
+
34
+ export interface EnsureCockpitBindingDeps {
35
+ /** Override the subject-proof minter (tests). */
36
+ reissue?: typeof reissueSubjectToken;
37
+ /** Override the forge-client factory (tests) — returns something with ensureVcAppBinding. */
38
+ makeForgeClient?: () => Promise<{
39
+ ensureVcAppBinding(opts: {
40
+ subjectToken?: string;
41
+ }): Promise<{ bound: boolean; created: boolean }>;
42
+ }>;
43
+ }
44
+
45
+ /**
46
+ * Ensure the developer identified by their VERIFIED session `email` is bound to the
47
+ * storefront VC app. Best-effort + fail-closed-to-typed-error: never throws.
48
+ */
49
+ export async function ensureCockpitVcBinding(
50
+ email: string,
51
+ deps: EnsureCockpitBindingDeps = {},
52
+ ): Promise<EnsureCockpitBindingResult> {
53
+ if (!email) return { ok: false, reason: "missing-identity" };
54
+
55
+ const reissue = deps.reissue ?? reissueSubjectToken;
56
+ let subjectToken: string;
57
+ try {
58
+ const proof = await reissue(email);
59
+ if (!proof.ok) return { ok: false, reason: `reissue-${proof.reason}` };
60
+ subjectToken = proof.idToken;
61
+ } catch {
62
+ // A re-issue fault must not surface as a cockpit 500.
63
+ return { ok: false, reason: "reissue-threw" };
64
+ }
65
+
66
+ try {
67
+ const client = deps.makeForgeClient
68
+ ? await deps.makeForgeClient()
69
+ : await createForgeClient();
70
+ const res = await client.ensureVcAppBinding({ subjectToken });
71
+ return { ok: true, bound: res.bound, created: res.created };
72
+ } catch (cause) {
73
+ // Fail-open: the ensure endpoint may not be deployed yet, or the MCP may be
74
+ // cold/unreachable. The proof itself is never logged; the reason is a terse code.
75
+ return {
76
+ ok: false,
77
+ reason: "mcp-" + (cause instanceof Error ? cause.name : "error"),
78
+ };
79
+ }
80
+ }
@@ -40,6 +40,44 @@ export async function readEnv(key: string): Promise<string | undefined> {
40
40
  return fromBuild(key);
41
41
  }
42
42
 
43
+ /** The slice of the Workers ExecutionContext runAfterResponse needs — the
44
+ * @astrojs/cloudflare adapter exposes it to routes as `locals.cfContext`. */
45
+ export interface CfExecutionContextLike {
46
+ waitUntil(promise: Promise<unknown>): void;
47
+ }
48
+
49
+ /**
50
+ * Run `work` PAST the response — the request's `ExecutionContext.waitUntil`
51
+ * when the caller passes it, else a detached floating promise (dev/Node). For
52
+ * webhook RECEIVERS that must ACK fast: the upstream forwarder (tot-mcp
53
+ * gitea-webhook proxy) aborts its forward at 15s and Gitea bounds its delivery
54
+ * wait too, while a real reconcile takes tens of seconds — running it inside
55
+ * the request meant every healthy reconcile was killed mid-flight and recorded
56
+ * as `forward_failed` (a layer of Trello 13075, behind the auth fix).
57
+ *
58
+ * CALLERS MUST PASS `locals.cfContext` (the @astrojs/cloudflare adapter's
59
+ * per-request ExecutionContext, `createLocals: { cfContext: ctx }`): in the
60
+ * Workers runtime a merely-floating promise is CANCELLED the moment the
61
+ * response returns — an earlier revision here imported `waitUntil` from
62
+ * `cloudflare:workers` (not exported by this adapter/runtime version), silently
63
+ * degraded to the floating promise, and the async reconcile died unobserved
64
+ * right after its 202. The floating-promise branch is a DEV-ONLY fallback.
65
+ * Errors are the caller's to handle inside `work` (this never throws).
66
+ */
67
+ export function runAfterResponse(
68
+ work: Promise<unknown>,
69
+ cfContext?: CfExecutionContextLike | null,
70
+ ): void {
71
+ const swallowed = work.catch(() => {});
72
+ if (cfContext && typeof cfContext.waitUntil === "function") {
73
+ cfContext.waitUntil(swallowed);
74
+ return;
75
+ }
76
+ // dev/Node only — in the Workers runtime this promise would be cancelled at
77
+ // response end, which is exactly the bug the parameter above exists to avoid.
78
+ void swallowed;
79
+ }
80
+
43
81
  /** KV binding for the newsletter stub / tenant cache, if present in the runtime. */
44
82
  export async function readKv(binding: string): Promise<KVNamespace | undefined> {
45
83
  const w = await getWorkerEnv();
@@ -59,6 +97,19 @@ export async function readR2(binding: string): Promise<R2Bucket | undefined> {
59
97
  return (w?.[binding] as R2Bucket | undefined) ?? undefined;
60
98
  }
61
99
 
100
+ /**
101
+ * Cloudflare Analytics Engine dataset binding (e.g. ACTIVITY_ANALYTICS), if
102
+ * present. Untyped (`unknown`) deliberately — the caller casts to its own
103
+ * minimal `writeDataPoint` shape (see `activity/store.ts`'s
104
+ * `AnalyticsEngineLike`) rather than this module taking a types dep. Absent in
105
+ * dev/Node and until the binding is provisioned; callers treat undefined as
106
+ * "analytics sink unavailable, skip it" (never an error).
107
+ */
108
+ export async function readAnalytics(binding: string): Promise<unknown | undefined> {
109
+ const w = await getWorkerEnv();
110
+ return w?.[binding] ?? undefined;
111
+ }
112
+
62
113
  /**
63
114
  * Cloudflare Queue producer binding (e.g. WEBHOOK_DELIVERY_QUEUE), if present.
64
115
  * Untyped (`unknown`) deliberately — callers cast to their own minimal
@@ -43,11 +43,15 @@
43
43
  * live only in S3 — enumerable by the Node-only `listRollbackTargets`
44
44
  * (`scripts/publish/lib/static-rollback.mjs`), NOT by the Worker. So the console
45
45
  * can list a static tenant's promotion history + `/rev` links + the CURRENT live
46
- * digest, but it cannot resolve an ARBITRARY prior version's archive digest, which
47
- * `/api/changes/rollback` fail-closed requires (`targetDigest`). `rollbackMode`
48
- * names this: a `control-plane` tenant rolls back one-click (targetVersionId only);
49
- * a `static-archive` tenant's per-version rollback is honestly deferred to the ship
50
- * CLI / CI that enumerates the archive never a one-click that would 400.
46
+ * digest, but it cannot resolve an ARBITRARY prior version's archive digest. It
47
+ * therefore rolls a static tenant back by an HONEST ONE-DISPATCH (rux13):
48
+ * `/api/changes/rollback` accepts the `targetVersionId` alone, restores the pointer,
49
+ * and dispatches a CI job that resolves the digest from the archive by sourceSha
50
+ * (`rollback-s3.mjs --source-sha`) and re-publishesreporting `publish_pending`
51
+ * until the re-publish is verified live (never a false "rolled back"). `rollbackMode`
52
+ * names the two paths: a `control-plane` tenant rolls back one-click via the pointer
53
+ * flip (instant); a `static-archive` tenant's rollback DISPATCHES via CI and
54
+ * completes asynchronously — the surface must say so, never imply an instant flip.
51
55
  */
52
56
  import { reviewDashboardRevisionHref } from "@/lib/preview/reviewDashboard";
53
57
  import {
@@ -98,9 +102,12 @@ export interface VersionsView {
98
102
  /** Whether the tenant publishes its public site to an external static target. */
99
103
  staticPublish: boolean;
100
104
  /**
101
- * `control-plane` → a prior version rolls back one-click via `targetVersionId`.
102
- * `static-archive` per-version rollback is deferred to the ship CLI / CI that
103
- * enumerates the `versions/` archive (the Worker can't resolve the archive digest).
105
+ * `control-plane` → a prior version rolls back one-click via `targetVersionId`
106
+ * (instant pointer flip).
107
+ * `static-archive` the console rolls back by an honest ONE-DISPATCH: it sends
108
+ * `targetVersionId` alone and CI resolves the archive digest + re-publishes
109
+ * asynchronously (`publish_pending` until verified live) — the surface must say
110
+ * "completes via CI", never imply an instant flip.
104
111
  */
105
112
  rollbackMode: RollbackMode;
106
113
  }
@@ -54,6 +54,8 @@ import { gatedHoldingResponse } from "@/lib/auth/gatePage";
54
54
  import { readSession, KvSessionStore, SESSION_COOKIE } from "@/lib/auth/session";
55
55
  import { isMaintenanceOn, maintenanceResponse } from "@/lib/maintenance";
56
56
  import { HEALTH_PATH, healthResponse } from "@/lib/health";
57
+ import { observeDeployVersion } from "@/lib/activity/deployVersion";
58
+ import { primeActivityKillSwitch } from "@/lib/activity/killSwitch";
57
59
  import type { DeepPartial, ThemeTokens } from "@tot/public-runtime";
58
60
 
59
61
  export const onRequest = defineMiddleware(async (context, next) => {
@@ -62,6 +64,21 @@ export const onRequest = defineMiddleware(async (context, next) => {
62
64
  const host =
63
65
  request.headers.get("host") ?? url.host ?? "localhost";
64
66
 
67
+ // ---- Activity telemetry kill switch (card D8) -----------------------------
68
+ // Prime the ONE `ACTIVITY_TELEMETRY_DISABLED` flag from the (async) Worker env
69
+ // once per isolate, BEFORE the first emit, so every telemetry chokepoint in this
70
+ // isolate (deploy-provenance, recordActivity, ingest) observes it. Fail-open,
71
+ // never throws. Off by default (unset ⇒ telemetry on).
72
+ await primeActivityKillSwitch();
73
+
74
+ // ---- Deploy provenance (card D2) — worker_commit / deploy.version_observed -
75
+ // Once per worker isolate (cold start), emit `deploy.version_observed` carrying
76
+ // this build's commit SHA so D5 can derive deploy skew (running build vs umbrella
77
+ // tip). Idempotent per isolate, best-effort, never throws — so it's safe here at
78
+ // the very front door, ahead of /health, and fires regardless of Host/tenant/gate.
79
+ // A no-op when the D8 kill switch above is set.
80
+ observeDeployVersion();
81
+
65
82
  // ---- /health — liveness + deploy-identity probe ---------------------------
66
83
  // Answered FIRST: before tenant resolution, the access gate, the maintenance
67
84
  // kill-switch, and the edge cache — so it reports the running build's commit SHA