@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.
- package/apps/storefront/migrations-apps/0001_woozy_lyja.sql +22 -0
- package/apps/storefront/migrations-apps/meta/0001_snapshot.json +990 -0
- package/apps/storefront/migrations-apps/meta/_journal.json +7 -0
- package/apps/storefront/package.json +0 -1
- package/apps/storefront/src/components/admin/AdminPublishTab.astro +1735 -132
- package/apps/storefront/src/lib/activity/alerts.ts +428 -0
- package/apps/storefront/src/lib/activity/changeActorAttribution.ts +85 -0
- package/apps/storefront/src/lib/activity/deployVersion.ts +127 -0
- package/apps/storefront/src/lib/activity/ingest.ts +188 -0
- package/apps/storefront/src/lib/activity/ingestAuth.ts +105 -0
- package/apps/storefront/src/lib/activity/killSwitch.ts +80 -0
- package/apps/storefront/src/lib/activity/query.ts +403 -0
- package/apps/storefront/src/lib/activity/recordActivity.ts +105 -0
- package/apps/storefront/src/lib/activity/store.ts +122 -0
- package/apps/storefront/src/lib/activity/uiActor.ts +150 -0
- package/apps/storefront/src/lib/activity/workerCommit.ts +70 -0
- package/apps/storefront/src/lib/auth/mcpClientAssertion.ts +4 -0
- package/apps/storefront/src/lib/auth/route.ts +44 -4
- package/apps/storefront/src/lib/d1/schema-apps.ts +54 -0
- package/apps/storefront/src/lib/dev/vcBinding.ts +80 -0
- package/apps/storefront/src/lib/env.ts +51 -0
- package/apps/storefront/src/lib/publish/shipWorkspace.ts +15 -8
- package/apps/storefront/src/middleware/index.ts +17 -0
- package/apps/storefront/src/pages/admin/ops-timeline.astro +420 -0
- package/apps/storefront/src/pages/admin.astro +154 -6
- package/apps/storefront/src/pages/api/activity.ts +137 -0
- package/apps/storefront/src/pages/api/admin/activity-alerts.ts +73 -0
- package/apps/storefront/src/pages/api/apps/admin/credentials/rotate.ts +27 -1
- package/apps/storefront/src/pages/api/apps/admin/install.ts +26 -1
- package/apps/storefront/src/pages/api/apps/admin/resume.ts +24 -1
- package/apps/storefront/src/pages/api/apps/admin/suspend.ts +24 -1
- package/apps/storefront/src/pages/api/apps/admin/uninstall.ts +25 -1
- package/apps/storefront/src/pages/api/apps/admin/update.ts +24 -1
- package/apps/storefront/src/pages/api/apps/admin/webhooks/deliveries/[deliveryId]/replay.ts +11 -0
- package/apps/storefront/src/pages/api/apps/internal/order-forward.ts +11 -0
- package/apps/storefront/src/pages/api/apps/v1/attribution.ts +20 -0
- package/apps/storefront/src/pages/api/apps/v1/webhooks/deliveries/[deliveryId]/replay.ts +12 -0
- package/apps/storefront/src/pages/api/cache-purge.ts +11 -0
- package/apps/storefront/src/pages/api/dashboard/enter-vendor.ts +30 -0
- package/apps/storefront/src/pages/auth/login.astro +5 -4
- package/apps/storefront/src/pages/cockpit.astro +40 -3
- package/package.json +1 -1
- package/packages/public-runtime/src/activity/README.md +146 -0
- package/packages/public-runtime/src/activity/catalog.ts +501 -0
- package/packages/public-runtime/src/activity/event.ts +168 -0
- package/packages/public-runtime/src/activity/index.ts +16 -0
- package/packages/public-runtime/src/activity/redaction.ts +263 -0
- package/packages/public-runtime/src/candidate-index.ts +13 -0
- package/packages/public-runtime/src/index.ts +6 -0
- package/apps/storefront/src/lib/webhooks/signing.ts +0 -29
- 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
|
|
125
|
-
*
|
|
126
|
-
*
|
|
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("/")
|
|
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
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
* a
|
|
50
|
-
*
|
|
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-publishes — reporting `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
|
-
*
|
|
103
|
-
*
|
|
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
|