@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,168 @@
1
+ // ─────────────────────────────────────────────────────────────────────────────
2
+ // ActivityEvent — the versioned actor×action telemetry envelope (card D0).
3
+ //
4
+ // One event = "an ACTOR performed an ACTION, here is the OUTCOME." It is the
5
+ // single shape every operational-activity emitter produces and every sink
6
+ // stores, across the whole storefront platform (dev/CLI first, then merchant/
7
+ // admin/agent). Downstream cards import this directly:
8
+ // D1 server recordActivity() · D2 deploy provenance · D3 CLI emitActivity
9
+ // D4 ingest+store · D5 query/timeline UI · D6/D7 alerts.
10
+ //
11
+ // TWO ZONES, and the split is load-bearing:
12
+ // 1. CORE fields (everything except `payload`) — always safe to store. They
13
+ // carry NO free-text and NO third-party PII by construction: ids are
14
+ // opaque, error is a low-cardinality CLASS, action is an allowlisted
15
+ // catalog key. A core field is never redacted because it can never hold a
16
+ // secret.
17
+ // 2. `payload` (args / rendered) — the extensible, ALWAYS-REDACTED zone.
18
+ // Whatever a call site puts here is default-DENIED: only the keys the
19
+ // action's allowlist names survive, and even those are canary-scanned.
20
+ // See redaction.ts. You cannot construct an event whose payload skipped
21
+ // redaction — use createActivityEvent() (redaction.ts), not a bare object.
22
+ //
23
+ // This package is the node-free, dependency-free edge base (see index.ts): the
24
+ // envelope stays pure data + pure functions so server, edge, and the JS CLI
25
+ // mirror can all consume it. ActionKey lives in catalog.ts (type-only import
26
+ // here — no runtime cycle).
27
+ // ─────────────────────────────────────────────────────────────────────────────
28
+ import type { ActionKey } from "./catalog.js";
29
+
30
+ /**
31
+ * Envelope contract version. Bump ONLY on a breaking change to a CORE field
32
+ * (add/remove/retype). Adding a new action key, sink, or allowlist entry is NOT
33
+ * breaking and does NOT bump this. Sinks store this so a reader can migrate old
34
+ * rows. Kept as a literal-typed const so `v` is `1`, not `number`.
35
+ */
36
+ export const ACTIVITY_SCHEMA_VERSION = 1 as const;
37
+ export type ActivitySchemaVersion = typeof ACTIVITY_SCHEMA_VERSION;
38
+
39
+ /** WHO acted. Deliberately a small closed set — ingest can reject a mismatch. */
40
+ export type ActorKind = "dev" | "merchant" | "admin" | "agent" | "system";
41
+
42
+ /** WHERE the event originated. */
43
+ export type ActivitySource = "cli" | "server" | "ui";
44
+
45
+ /**
46
+ * Invoked/result semantics. `invoked` = the action started (fire-and-forget or
47
+ * the open half of an invoke/result pair); the terminal statuses close it.
48
+ * `refused` is distinct from `failed`: the platform DECLINED the action (a
49
+ * capability/authz gate said no) — it never ran — vs it ran and errored.
50
+ */
51
+ export type ActivityStatus = "invoked" | "succeeded" | "failed" | "refused";
52
+
53
+ /** Which store(s) an action lands in. The store decision is per-action policy,
54
+ * declared on each catalog entry — see catalog.ts ActionSpec.sinks and
55
+ * DECISIONS.md §8. `logs` is universal; `analytics` = high-volume/query-light;
56
+ * `timeline` = low-volume/queryable ops UI. */
57
+ export type ActivitySink = "logs" | "analytics" | "timeline";
58
+
59
+ export interface ActivityActor {
60
+ kind: ActorKind;
61
+ /**
62
+ * OPAQUE, stable actor id. NEVER a raw email / phone / name — the core zone is
63
+ * unredacted, so a raw PII id here would defeat the whole contract. Emitters
64
+ * pseudonymize before constructing the event (a stable hash of the email, an
65
+ * install id, an app-domain, "system"). The canonical pseudonymization salt is
66
+ * an OPEN QUESTION owned by D1/D4 (see redaction.ts header); until it lands,
67
+ * emitters use an already-opaque handle (install id, hashed email, app domain).
68
+ */
69
+ id: string;
70
+ /**
71
+ * Optional secondary opaque reference (e.g. a hashed email used to correlate a
72
+ * dev across CLI + UI). Still core, still MUST be non-reversible / non-PII.
73
+ */
74
+ ref?: string;
75
+ }
76
+
77
+ export interface ActivityScope {
78
+ /** Tenant / store id (tenant_id or app domain). Core, non-secret. */
79
+ tenantId?: string;
80
+ /** ShipLoop change/candidate id, when the action is change-scoped. */
81
+ changeId?: string;
82
+ /**
83
+ * Correlation id spanning an invite→terminal→problem span. Matches the
84
+ * existing `traceId` already threaded through the dev bridge + runtime record,
85
+ * so a CLI event and the server event it triggered share one trace.
86
+ */
87
+ traceId?: string;
88
+ /**
89
+ * BUILD PROVENANCE (card D2). The git commit SHA the running server/edge build
90
+ * was cut from — stamped by the emitter onto every SERVER-sourced event so the
91
+ * query surface (D5) can derive **deploy skew**: is the build that produced this
92
+ * event == the pushed umbrella tip, or is a stale worker still serving? Core,
93
+ * non-secret by construction (a 40-hex git sha, never PII/free-text), so it is
94
+ * never redacted. Absent on `cli`/`ui`-sourced events and on any build with no
95
+ * git context (reported as "unknown" by the stamper, see the storefront's
96
+ * workerCommit() accessor). This is `worker_commit` in the D2 vocabulary — the
97
+ * value the standalone `deploy.version_observed` action also carries in payload.
98
+ */
99
+ workerCommit?: string;
100
+ }
101
+
102
+ export interface ActivityOutcome {
103
+ status: ActivityStatus;
104
+ /**
105
+ * Stable, LOW-CARDINALITY error class — never a raw exception message (which
106
+ * can carry secrets/PII and blows up cardinality). e.g. "capability_denied",
107
+ * "forge_5xx", "node_too_old", "not_found". Free-text detail, if truly needed,
108
+ * goes through payload.rendered and its allowlist, never here.
109
+ */
110
+ errorClass?: string;
111
+ /** Wall-clock duration of the action in ms, when known. */
112
+ durationMs?: number;
113
+ }
114
+
115
+ /**
116
+ * The extensible, ALWAYS-REDACTED payload zone. Both lanes are default-DENY:
117
+ * only keys named by the action's allowlist survive redaction; every other key
118
+ * is DROPPED (never stored, never "logged raw then redacted later").
119
+ */
120
+ export interface ActivityPayload {
121
+ /** Structured machine input to the action (e.g. { command, exitCode }). */
122
+ args?: Record<string, unknown>;
123
+ /** Human-readable strings for a UI timeline (e.g. { description, reason }). */
124
+ rendered?: Record<string, string>;
125
+ }
126
+
127
+ /**
128
+ * The wire + storage envelope. Field names are short because this is written at
129
+ * volume (Analytics Engine blobs, Workers Logs lines, D1 columns).
130
+ */
131
+ export interface ActivityEvent {
132
+ /** Envelope contract version (see ACTIVITY_SCHEMA_VERSION). */
133
+ v: ActivitySchemaVersion;
134
+ /** Unique event id (UUID v4). Idempotency key for the ingest/store path. */
135
+ id: string;
136
+ /** ISO 8601 UTC timestamp, emit clock. */
137
+ at: string;
138
+ actor: ActivityActor;
139
+ /** ALLOWLISTED catalog key — never a free string. See catalog.ts. */
140
+ action: ActionKey;
141
+ source: ActivitySource;
142
+ scope: ActivityScope;
143
+ outcome: ActivityOutcome;
144
+ /** Post-redaction payload. Absent when the action carries no allowlisted args. */
145
+ payload?: ActivityPayload;
146
+ }
147
+
148
+ /** True when `at` is a parseable ISO-8601 instant. Cheap ingest guard (D4). */
149
+ export function isIsoTimestamp(at: unknown): at is string {
150
+ return typeof at === "string" && !Number.isNaN(Date.parse(at));
151
+ }
152
+
153
+ /** Node-free UUID v4. Uses the platform crypto (Workers + Node ≥19 global), with
154
+ * a getRandomValues fallback so the edge bundle never imports `node:crypto`. */
155
+ export function newEventId(): string {
156
+ const c: Crypto | undefined = (globalThis as { crypto?: Crypto }).crypto;
157
+ if (c?.randomUUID) return c.randomUUID();
158
+ const b = new Uint8Array(16);
159
+ (c?.getRandomValues ? c.getRandomValues(b) : fillMathRandom(b));
160
+ b[6] = ((b[6] as number) & 0x0f) | 0x40;
161
+ b[8] = ((b[8] as number) & 0x3f) | 0x80;
162
+ const h = Array.from(b, (x) => x.toString(16).padStart(2, "0"));
163
+ return `${h[0]}${h[1]}${h[2]}${h[3]}-${h[4]}${h[5]}-${h[6]}${h[7]}-${h[8]}${h[9]}-${h[10]}${h[11]}${h[12]}${h[13]}${h[14]}${h[15]}`;
164
+ }
165
+
166
+ function fillMathRandom(b: Uint8Array): void {
167
+ for (let i = 0; i < b.length; i++) b[i] = Math.floor(Math.random() * 256);
168
+ }
@@ -0,0 +1,16 @@
1
+ // ─────────────────────────────────────────────────────────────────────────────
2
+ // @tot/public-runtime/activity — the operational activity telemetry contract.
3
+ //
4
+ // Card D0: the actor×action ActivityEvent envelope, the allowlisted action
5
+ // catalog, and the default-deny redaction contract. This is the FOUNDATION the
6
+ // downstream cards import directly — nothing here does I/O or picks a store; it
7
+ // is pure types + pure functions so server, edge, and the CLI's JS mirror can
8
+ // all consume it. The store DECISION and cost model live in the repo-root
9
+ // DECISIONS.md §8; the per-action routing lives on each catalog entry (sinks).
10
+ //
11
+ // See ./README.md for the envelope shape, catalog rules, redaction contract,
12
+ // and the CLI-mirror keep-in-step obligation for card D3.
13
+ // ─────────────────────────────────────────────────────────────────────────────
14
+ export * from "./event.js";
15
+ export * from "./catalog.js";
16
+ export * from "./redaction.js";
@@ -0,0 +1,263 @@
1
+ // ─────────────────────────────────────────────────────────────────────────────
2
+ // Redaction contract for the activity payload zone (card D0).
3
+ //
4
+ // This is the surface card D8 (adversarial PII/secret-leak review) will attack,
5
+ // so the posture is deliberately conservative. Three layers, in order:
6
+ //
7
+ // 1. DEFAULT-DENY ALLOWLIST. For a given action, ONLY the top-level payload
8
+ // keys named in its ActionSpec (argsAllow / renderedAllow) are eligible to
9
+ // be stored. Every other key is DROPPED — not stored, not logged, not
10
+ // "kept raw and redacted later". There is no path that persists an
11
+ // un-allowlisted field.
12
+ //
13
+ // 2. PRIMITIVE-ONLY VALUES. An allowlisted value must be a string / number /
14
+ // boolean. Objects, arrays, functions are DROPPED — nested structures are
15
+ // where secrets hide, and we will not walk them. (If a future action
16
+ // genuinely needs structured args, it flattens them at the call site into
17
+ // named primitive keys and allowlists each.)
18
+ //
19
+ // 3. CANARY SCAN. Even an allowlisted primitive is scanned for secret/PII
20
+ // shapes (JWTs, bearer tokens, API keys, private keys, emails, card/phone
21
+ // numbers) AND secret-looking KEY NAMES. A hit replaces the value with a
22
+ // redaction marker and is REPORTED (the fact of the hit, never the value) so
23
+ // D6/D7 can alert and D8 can audit. A secret-named key is dropped outright.
24
+ //
25
+ // The safe constructor createActivityEvent() runs all three before an event is
26
+ // ever returned — you cannot build an event whose payload skipped redaction.
27
+ //
28
+ // OPEN QUESTION (owned by D1/D4): the canonical actor-id pseudonymization salt.
29
+ // This module scrubs PII that leaks into the PAYLOAD, but actor.id in the CORE
30
+ // zone is the emitter's responsibility to pseudonymize. A shared, salted,
31
+ // non-reversible hash helper (with the salt as a Worker secret) should land with
32
+ // D1's recordActivity()/D4's ingest — do NOT bake a weak unsalted hash here.
33
+ // ─────────────────────────────────────────────────────────────────────────────
34
+ import {
35
+ ACTIVITY_SCHEMA_VERSION,
36
+ newEventId,
37
+ type ActivityEvent,
38
+ type ActivityActor,
39
+ type ActivityScope,
40
+ type ActivityOutcome,
41
+ type ActivityPayload,
42
+ type ActivitySource,
43
+ } from "./event.js";
44
+ import { getActionSpec, type ActionKey, type ActionSpec } from "./catalog.js";
45
+
46
+ /** Marker stored in place of a value that tripped a canary (never the raw value). */
47
+ export const REDACTION_MARKER = "«redacted»";
48
+ /** Cap on a stored rendered/arg string — a stored operational value is never long;
49
+ * capping bounds row/blob size and shrinks the blast radius of a missed secret. */
50
+ export const MAX_VALUE_LEN = 512;
51
+
52
+ /**
53
+ * Secret/PII VALUE canaries — deny-by-default even inside an allowlisted field.
54
+ * Conservative on purpose: a false positive costs one redacted ops field; a false
55
+ * negative is a leaked secret. Ordered roughly most- to least-specific. Named so
56
+ * a report can say WHICH canary fired (the label), never the matched text.
57
+ */
58
+ export const VALUE_CANARIES: readonly { label: string; re: RegExp }[] = [
59
+ { label: "private_key", re: /-----BEGIN(?: [A-Z]+)? PRIVATE KEY-----/ },
60
+ { label: "jwt", re: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]+/ },
61
+ { label: "bearer", re: /\bBearer\s+[A-Za-z0-9._~+/-]{12,}=*/i },
62
+ { label: "aws_access_key", re: /\bAKIA[0-9A-Z]{16}\b/ },
63
+ { label: "gh_token", re: /\b(?:ghp|gho|ghu|ghs|ghr|github_pat)_[A-Za-z0-9_]{20,}/ },
64
+ { label: "slack_token", re: /\bxox[baprs]-[A-Za-z0-9-]{10,}/ },
65
+ { label: "openai_key", re: /\bsk-[A-Za-z0-9]{20,}/ },
66
+ { label: "email", re: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/ },
67
+ { label: "card_number", re: /\b(?:\d[ -]?){13,19}\b/ },
68
+ { label: "phone", re: /\b\+?\d[\d ().-]{9,}\d\b/ },
69
+ // High-entropy long token: ≥28 chars of base64url with no spaces. Last resort.
70
+ { label: "high_entropy", re: /(?:^|[^A-Za-z0-9_-])[A-Za-z0-9_-]{28,}(?:$|[^A-Za-z0-9_-])/ },
71
+ ];
72
+
73
+ /**
74
+ * Secret-looking KEY NAMES — dropped outright even if (mistakenly) allowlisted.
75
+ * Defense in depth: an allowlist edit that names one of these can never leak.
76
+ */
77
+ // NB: matched as a substring of the key name, so entries must be specific enough
78
+ // not to eat legitimate fields — e.g. a bare `code` would swallow `exitCode`.
79
+ // Single-use codes are VALUES (caught by the value canaries + the CLI's own
80
+ // --code redaction), not key names, so `code` is deliberately NOT here.
81
+ export const KEY_NAME_CANARY = /(?:secret|token|passwd|password|authorization|api[_-]?key|private[_-]?key|credential|cookie|session|bearer|jwt)/i;
82
+
83
+ export interface RedactionFieldHit {
84
+ lane: "args" | "rendered";
85
+ key: string;
86
+ /** Why the field was scrubbed/dropped. */
87
+ reason: "not_allowlisted" | "non_primitive" | "key_name_canary" | "value_canary" | "unknown_action";
88
+ /** The canary label, when reason === "value_canary". Never the matched text. */
89
+ canary?: string;
90
+ }
91
+
92
+ export interface RedactionReport {
93
+ /** Fields kept (after passing all three layers). */
94
+ keptArgs: string[];
95
+ keptRendered: string[];
96
+ /** Fields dropped or value-redacted, with the reason (for D8 audit / D6 alerts). */
97
+ hits: RedactionFieldHit[];
98
+ /** True if any value-canary fired — the signal an alert rule watches. */
99
+ canaryTripped: boolean;
100
+ }
101
+
102
+ function isPrimitive(v: unknown): v is string | number | boolean {
103
+ const t = typeof v;
104
+ return t === "string" || t === "number" || t === "boolean";
105
+ }
106
+
107
+ /** Scan a stringified value against the value canaries. Returns the first label hit. */
108
+ export function scanValueCanary(value: string): string | undefined {
109
+ for (const c of VALUE_CANARIES) if (c.re.test(value)) return c.label;
110
+ return undefined;
111
+ }
112
+
113
+ /**
114
+ * CORE-zone identifier canaries — the STRUCTURED secret/PII shapes only.
115
+ *
116
+ * The core zone (actor.id / actor.ref, and the scope ids) is UNREDACTED by design and
117
+ * its contract is "opaque, never raw PII/secret". Unlike a payload value, a core id is
118
+ * SUPPOSED to be a high-entropy opaque handle (a salted hash `h_…`, a UUID install id,
119
+ * an app domain). So this scan deliberately EXCLUDES three VALUE_CANARIES that cannot
120
+ * distinguish a leak from a legitimate opaque id:
121
+ * • `high_entropy` — every opaque id is high-entropy by construction;
122
+ * • `card_number` / `phone` — a numeric hash / UUID segment is a bare digit run,
123
+ * structurally identical to a card/phone; scanning for them would reject legitimate
124
+ * numeric ids (e.g. an all-hex-digit hash, a digit-heavy UUID segment).
125
+ * What REMAINS are the STRUCTURED shapes that never occur in an opaque id but ARE the
126
+ * accidental-PII/secret an emitter most plausibly forgets to pseudonymize: an `@` email,
127
+ * a `eyJ…` JWT, a `Bearer …` header, `AKIA…`/`ghp_…`/`xox…`/`sk-…` cloud keys, a
128
+ * `-----BEGIN … PRIVATE KEY-----`. Used by D4 ingest to enforce the "core carries no
129
+ * PII by construction" invariant the payload contract already enforces.
130
+ */
131
+ const CORE_IDENTIFIER_EXCLUDED = new Set(["high_entropy", "card_number", "phone"]);
132
+ const CORE_IDENTIFIER_CANARIES: readonly { label: string; re: RegExp }[] = VALUE_CANARIES.filter(
133
+ (c) => !CORE_IDENTIFIER_EXCLUDED.has(c.label),
134
+ );
135
+
136
+ /**
137
+ * Scan a CORE-zone identifier (actor.id / actor.ref / scope id) for a RAW, STRUCTURED
138
+ * secret/PII SHAPE. Returns the canary label on a hit, else undefined. Excludes the
139
+ * high-entropy / card / phone canaries so a legitimate opaque id (hash / UUID / domain)
140
+ * is never flagged, while a raw email / JWT / bearer / cloud-key / private-key still is.
141
+ * Length-capped like a payload value.
142
+ */
143
+ export function scanCoreIdentifier(value: string): string | undefined {
144
+ const s = value.slice(0, MAX_VALUE_LEN);
145
+ for (const c of CORE_IDENTIFIER_CANARIES) if (c.re.test(s)) return c.label;
146
+ return undefined;
147
+ }
148
+
149
+ function redactLane(
150
+ lane: "args" | "rendered",
151
+ raw: Record<string, unknown> | undefined,
152
+ allow: readonly string[],
153
+ hits: RedactionFieldHit[],
154
+ ): Record<string, string | number | boolean> | undefined {
155
+ if (!raw) return undefined;
156
+ const allowSet = new Set(allow);
157
+ const out: Record<string, string | number | boolean> = {};
158
+ for (const [key, value] of Object.entries(raw)) {
159
+ // Layer 3b: secret-named key — drop regardless of allowlist.
160
+ if (KEY_NAME_CANARY.test(key)) {
161
+ hits.push({ lane, key, reason: "key_name_canary" });
162
+ continue;
163
+ }
164
+ // Layer 1: default-deny allowlist.
165
+ if (!allowSet.has(key)) {
166
+ hits.push({ lane, key, reason: "not_allowlisted" });
167
+ continue;
168
+ }
169
+ // Layer 2: primitives only.
170
+ if (!isPrimitive(value)) {
171
+ hits.push({ lane, key, reason: "non_primitive" });
172
+ continue;
173
+ }
174
+ // Layer 3a: value canary scan on the stringified, length-capped value.
175
+ const str = String(value).slice(0, MAX_VALUE_LEN);
176
+ const canary = scanValueCanary(str);
177
+ if (canary) {
178
+ hits.push({ lane, key, reason: "value_canary", canary });
179
+ out[key] = REDACTION_MARKER;
180
+ continue;
181
+ }
182
+ out[key] = typeof value === "string" ? str : value;
183
+ }
184
+ return Object.keys(out).length > 0 ? out : undefined;
185
+ }
186
+
187
+ /**
188
+ * Apply the redaction contract to a raw payload for a given action. Pure. Returns
189
+ * the redacted payload (safe to store) plus a report of what was kept/dropped and
190
+ * whether a canary tripped. An unknown action yields an empty payload (deny-all).
191
+ */
192
+ export function redactPayload(
193
+ action: string,
194
+ raw: ActivityPayload | undefined,
195
+ ): { payload: ActivityPayload | undefined; report: RedactionReport } {
196
+ const hits: RedactionFieldHit[] = [];
197
+ const spec: ActionSpec | undefined = getActionSpec(action);
198
+ if (!spec) {
199
+ if (raw?.args) for (const k of Object.keys(raw.args)) hits.push({ lane: "args", key: k, reason: "unknown_action" });
200
+ if (raw?.rendered) for (const k of Object.keys(raw.rendered)) hits.push({ lane: "rendered", key: k, reason: "unknown_action" });
201
+ return { payload: undefined, report: { keptArgs: [], keptRendered: [], hits, canaryTripped: false } };
202
+ }
203
+ const args = redactLane("args", raw?.args, spec.argsAllow, hits);
204
+ const renderedRaw = redactLane("rendered", raw?.rendered, spec.renderedAllow, hits);
205
+ // rendered is declared as string-valued; coerce (canary marker + capped strings
206
+ // are already strings, primitives stringify safely).
207
+ const rendered = renderedRaw
208
+ ? Object.fromEntries(Object.entries(renderedRaw).map(([k, v]) => [k, String(v)]))
209
+ : undefined;
210
+ const payload: ActivityPayload | undefined = args || rendered ? { ...(args ? { args } : {}), ...(rendered ? { rendered } : {}) } : undefined;
211
+ return {
212
+ payload,
213
+ report: {
214
+ keptArgs: args ? Object.keys(args) : [],
215
+ keptRendered: rendered ? Object.keys(rendered) : [],
216
+ hits,
217
+ canaryTripped: hits.some((h) => h.reason === "value_canary"),
218
+ },
219
+ };
220
+ }
221
+
222
+ export interface CreateActivityEventInput {
223
+ action: ActionKey;
224
+ actor: ActivityActor;
225
+ source: ActivitySource;
226
+ outcome: ActivityOutcome;
227
+ scope?: ActivityScope;
228
+ /** RAW payload — WILL be redacted before the event is returned. */
229
+ payload?: ActivityPayload;
230
+ /** Override the timestamp (tests / replaying a reported client event). */
231
+ at?: string;
232
+ /** Override the id (idempotent re-ingest). Defaults to a fresh UUID. */
233
+ id?: string;
234
+ }
235
+
236
+ /**
237
+ * The SAFE constructor — the only sanctioned way to build an ActivityEvent.
238
+ * Fills v/id/at, and runs the raw payload through redactPayload() so the returned
239
+ * event's payload is already default-denied + canary-scanned. Returns the event
240
+ * plus the redaction report (D4 ingest logs the report / emits
241
+ * `system.ingest.rejected` if a canary tripped; D1 recordActivity() can ignore it).
242
+ * Throws only on an out-of-catalog action (a programming error — deny by default).
243
+ */
244
+ export function createActivityEvent(input: CreateActivityEventInput): {
245
+ event: ActivityEvent;
246
+ report: RedactionReport;
247
+ } {
248
+ const spec = getActionSpec(input.action);
249
+ if (!spec) throw new Error(`activity: unknown action "${input.action}" (not in catalog)`);
250
+ const { payload, report } = redactPayload(input.action, input.payload);
251
+ const event: ActivityEvent = {
252
+ v: ACTIVITY_SCHEMA_VERSION,
253
+ id: input.id ?? newEventId(),
254
+ at: input.at ?? new Date().toISOString(),
255
+ actor: input.actor,
256
+ action: input.action,
257
+ source: input.source,
258
+ scope: input.scope ?? {},
259
+ outcome: input.outcome,
260
+ ...(payload ? { payload } : {}),
261
+ };
262
+ return { event, report };
263
+ }
@@ -102,6 +102,19 @@ export interface ReviewEnvironment {
102
102
  lastError: string | null;
103
103
  /** ISO 8601 timestamp of the last time this projection was recomputed. */
104
104
  updatedAt: string;
105
+ /**
106
+ * ISO 8601 instant of the last `candidate-submitted` ack this record was
107
+ * (re)born or reset from (see `@tot/private-controlplane`'s
108
+ * `applyCandidateSubmittedAck` — the sync-path "ack at submit" keystone that
109
+ * seeds this projection BEFORE the async reconcile webhook ever fires, so a
110
+ * dead/unregistered webhook shows as "queued since <submittedAt>, never
111
+ * picked up" instead of no record at all). Additive + optional: every OTHER
112
+ * writer of this projection (`reconcileCandidate`, `acceptCandidateAtomic`,
113
+ * retire/GC) predates this field and simply carries it forward unset/
114
+ * untouched — only the submit-ack path ever sets it. Deliberately distinct
115
+ * from `updatedAt`, which every writer bumps on any recompute for any reason.
116
+ */
117
+ submittedAt?: string;
105
118
  }
106
119
 
107
120
  /** KV key for one tenant-change `ReviewEnvironment` projection. */
@@ -40,3 +40,9 @@ export * from "./binary-path.js";
40
40
  export * from "./hot-standby-readiness.js";
41
41
  export * from "./hash.js";
42
42
  export * from "./widget-postmessage.js";
43
+
44
+ // Operational activity telemetry contract (card D0): the actor×action
45
+ // ActivityEvent envelope, the allowlisted action catalog, and the default-deny
46
+ // redaction contract. Pure types + pure functions; the store decision + cost
47
+ // model live in the repo-root DECISIONS.md §8. See ./activity/README.md.
48
+ export * from "./activity/index.js";
@@ -1,29 +0,0 @@
1
- /**
2
- * HTTP Message Signatures (RFC 9421) + Content-Digest (RFC 9530) signer for
3
- * outbound webhook delivery (PrivateApps epic, D4 Chunk B).
4
- *
5
- * The implementation MOVED to `@tokenoftrust/private-apps-devkit`
6
- * (PrivateApps epic D6 Chunk B) so the `tot app` CLI harness (D6 Chunk D) can
7
- * verify webhook signatures without importing Astro-internal code — without
8
- * forking security-critical crypto into a second, drifting copy. This file
9
- * is now a thin re-export; the devkit package is the ONE source of truth.
10
- * See `packages/private-apps-devkit/src/signing.ts` for the full scope
11
- * notes (hand-rolled/narrowly-scoped, not a general httpsig implementation)
12
- * and `packages/private-apps-devkit/tests/signing.test.ts` for the RFC-vector
13
- * proofs. `signing.test.ts` alongside this file still imports from here —
14
- * unchanged — so it now exercises those same functions through this
15
- * re-export.
16
- */
17
- export {
18
- SIGNATURE_LABEL,
19
- WEBHOOK_SIGNATURE_COVERED_COMPONENTS,
20
- computeContentDigestSha256,
21
- buildSignatureParamsValue,
22
- buildSignatureBase,
23
- signWebhookRequest,
24
- verifyWebhookSignature,
25
- type SignWebhookRequestInput,
26
- type SignWebhookRequestResult,
27
- type WebhookVerifyResult,
28
- type VerifyWebhookSignatureInput,
29
- } from "@tokenoftrust/private-apps-devkit";
@@ -1,146 +0,0 @@
1
- /**
2
- * RS256 signing-key loader for outbound webhook delivery (PrivateApps epic,
3
- * D4 Chunk B) — the key `signing.ts` uses to attach HTTP Message Signatures
4
- * (RFC 9421) to every CloudEvents webhook POST.
5
- *
6
- * Mirrors `../apps/gatewayKeys.ts`'s `loadGatewaySigningConfig`/
7
- * `loadGatewayJwks` structure EXACTLY (import cache keyed on the base64
8
- * source, JWKS derivation that strips every private RSA field), but over a
9
- * SEPARATE key (`WEBHOOK_SIGNING_PRIVATE_KEY_B64`) with a separate kid
10
- * (`webhook-signing-1`) — per `d4-design-resolutions`, this key must never be
11
- * the same as the D2 gateway key. The gateway key attests requests an app
12
- * makes INTO the storefront (app → gateway trust); the webhook signing key
13
- * attests events the storefront pushes OUT to an app (gateway → app trust,
14
- * the opposite direction). Sharing a key would let a receiver's verification
15
- * key double as the ability to (offline) verify gateway tokens, and rotating
16
- * one would force rotating the other.
17
- *
18
- * The private key never leaves this module. `loadWebhookJwks` derives the
19
- * PUBLIC-only JWKS from it, published (Chunk B) via a new field on
20
- * `/.well-known/tot-app-authorization-metadata`.
21
- */
22
- import { importPKCS8, exportJWK, type CryptoKey, type JWK, type JSONWebKeySet } from "jose";
23
- import { readEnv } from "@/lib/env";
24
- // Re-exported (not re-defined) — `@tokenoftrust/private-apps-devkit`'s
25
- // signing.ts is the ONE source of truth for this constant since D6 Chunk B
26
- // (it's the `alg` its sign/verify functions produce/accept); this module
27
- // just keeps the historical import path (`./webhookSigningKey.js`) working.
28
- export { WEBHOOK_SIGNING_HTTPSIG_ALG } from "@tokenoftrust/private-apps-devkit";
29
-
30
- /** The only algorithm the webhook signer signs with (verifiers reject anything else). */
31
- export const WEBHOOK_SIGNING_ALG = "RS256";
32
-
33
- /** Default key id (overridable via `WEBHOOK_SIGNING_KID`). Names the published key. */
34
- export const DEFAULT_WEBHOOK_SIGNING_KID = "webhook-signing-1";
35
-
36
- /** Env keys the loader reads (server-only). */
37
- export const WEBHOOK_SIGNING_PRIVATE_KEY_ENV = "WEBHOOK_SIGNING_PRIVATE_KEY_B64";
38
- export const WEBHOOK_SIGNING_KID_ENV = "WEBHOOK_SIGNING_KID";
39
-
40
- /**
41
- * Thrown when the webhook signer can't load its signing key — the private
42
- * key env is absent/malformed. Callers FAIL CLOSED: with no key, no delivery
43
- * can be signed, and the metadata route omits the webhook JWKS field rather
44
- * than publish a key that will never actually sign anything.
45
- */
46
- export class WebhookSigningKeyError extends Error {}
47
-
48
- /** Optional test/DI overrides (avoids touching process env in unit tests). */
49
- export interface WebhookSigningKeyOverrides {
50
- /** base64 of the PKCS8 PEM private key (defaults to the env value). */
51
- privateKeyB64?: string;
52
- kid?: string;
53
- }
54
-
55
- export interface WebhookSigningConfig {
56
- key: CryptoKey;
57
- kid: string;
58
- }
59
-
60
- // Same cache shape as gatewayKeys.ts's signingKeyCache/publicJwkCache, keyed
61
- // on the base64 string so a rotated key transparently re-imports/re-derives.
62
- const signingKeyCache = new Map<string, CryptoKey>();
63
- const publicJwkCache = new Map<string, JWK>();
64
-
65
- async function importPrivateKey(privateKeyB64: string): Promise<CryptoKey> {
66
- const cached = signingKeyCache.get(privateKeyB64);
67
- if (cached) return cached;
68
- let pem: string;
69
- try {
70
- pem = Buffer.from(privateKeyB64, "base64").toString("utf8");
71
- } catch (cause) {
72
- throw new WebhookSigningKeyError(
73
- `webhook signing: ${WEBHOOK_SIGNING_PRIVATE_KEY_ENV} is not valid base64 ` +
74
- (cause instanceof Error ? cause.message : String(cause)),
75
- );
76
- }
77
- if (!/-----BEGIN (RSA )?PRIVATE KEY-----/.test(pem)) {
78
- throw new WebhookSigningKeyError(
79
- `webhook signing: decoded ${WEBHOOK_SIGNING_PRIVATE_KEY_ENV} is not a PEM private key`,
80
- );
81
- }
82
- let key: CryptoKey;
83
- try {
84
- // extractable: true — required so `loadWebhookJwks` below can derive the
85
- // public JWK from this same key, and so `signing.ts` can sign raw bytes
86
- // with `crypto.subtle.sign` directly (no JWS wrapping needed for RFC
87
- // 9421 — the signature is over the derived signature base, not a JWT).
88
- key = (await importPKCS8(pem, WEBHOOK_SIGNING_ALG, { extractable: true })) as CryptoKey;
89
- } catch (cause) {
90
- throw new WebhookSigningKeyError(
91
- `webhook signing: failed to import the RS256 private key ` +
92
- (cause instanceof Error ? cause.message : String(cause)),
93
- );
94
- }
95
- signingKeyCache.set(privateKeyB64, key);
96
- return key;
97
- }
98
-
99
- /**
100
- * Resolve the active signing config (key + kid) from env, or `overrides` in
101
- * tests. FAILS CLOSED: throws {@link WebhookSigningKeyError} when the private
102
- * key env is absent/malformed.
103
- */
104
- export async function loadWebhookSigningConfig(
105
- overrides: WebhookSigningKeyOverrides = {},
106
- ): Promise<WebhookSigningConfig> {
107
- const privateKeyB64 =
108
- overrides.privateKeyB64 ?? (await readEnv(WEBHOOK_SIGNING_PRIVATE_KEY_ENV));
109
- if (!privateKeyB64) {
110
- throw new WebhookSigningKeyError(
111
- `webhook signing: ${WEBHOOK_SIGNING_PRIVATE_KEY_ENV} is not configured (fail closed)`,
112
- );
113
- }
114
- const kid = overrides.kid ?? (await readEnv(WEBHOOK_SIGNING_KID_ENV)) ?? DEFAULT_WEBHOOK_SIGNING_KID;
115
- const key = await importPrivateKey(privateKeyB64);
116
- return { key, kid };
117
- }
118
-
119
- /**
120
- * Derive the PUBLIC JWKS for the active webhook signing key — sanitized
121
- * (every private RSA field stripped) and cached by the source private-key
122
- * base64. V1 always returns exactly one key; the envelope supports more
123
- * without a shape change later (rotation-with-overlap).
124
- */
125
- export async function loadWebhookJwks(
126
- overrides: WebhookSigningKeyOverrides = {},
127
- ): Promise<JSONWebKeySet> {
128
- const privateKeyB64 =
129
- overrides.privateKeyB64 ?? (await readEnv(WEBHOOK_SIGNING_PRIVATE_KEY_ENV));
130
- if (!privateKeyB64) {
131
- throw new WebhookSigningKeyError(
132
- `webhook signing: ${WEBHOOK_SIGNING_PRIVATE_KEY_ENV} is not configured (fail closed)`,
133
- );
134
- }
135
- const { key, kid } = await loadWebhookSigningConfig(overrides);
136
- let jwk = publicJwkCache.get(privateKeyB64);
137
- if (!jwk) {
138
- const full = (await exportJWK(key)) as Record<string, unknown>;
139
- // Strip every private RSA field (RFC 7518 §6.3.2) — only the public parts
140
- // (kty, n, e) are ever published. This line is the one place that matters.
141
- const { d: _d, p: _p, q: _q, dp: _dp, dq: _dq, qi: _qi, oth: _oth, ...publicOnly } = full;
142
- jwk = { ...publicOnly, kid, alg: WEBHOOK_SIGNING_ALG, use: "sig" };
143
- publicJwkCache.set(privateKeyB64, jwk);
144
- }
145
- return { keys: [jwk] };
146
- }