@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
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
StaffVendorAccessClientError,
|
|
30
30
|
} from "@/lib/dashboard/staffVendorAccess";
|
|
31
31
|
import { mintBrokerAssertion, BrokerAssertionError } from "@/lib/auth/brokerAssertion";
|
|
32
|
+
import { resolveUiActor, recordUiActivity } from "@/lib/activity/uiActor";
|
|
32
33
|
|
|
33
34
|
export const prerender = false;
|
|
34
35
|
|
|
@@ -57,6 +58,11 @@ export async function POST(context: APIContext): Promise<Response> {
|
|
|
57
58
|
typeof body.vendorAppDomain === "string" ? body.vendorAppDomain.trim() : "";
|
|
58
59
|
if (!vendorAppDomain) return json({ error: "vendorAppDomain is required" }, 400);
|
|
59
60
|
|
|
61
|
+
// D11: staff-impersonation audit event. Resolve the actor AFTER the staff gate above,
|
|
62
|
+
// so the cohort is always `admin` (a ToT-staff session); the staff email is PII and is
|
|
63
|
+
// never stored (actor.id is its salted hash). Reused at each emit exit point below.
|
|
64
|
+
const activityActor = await resolveUiActor(context, {});
|
|
65
|
+
|
|
60
66
|
let client;
|
|
61
67
|
try {
|
|
62
68
|
client = await createStaffVendorAccessClient();
|
|
@@ -84,6 +90,12 @@ export async function POST(context: APIContext): Promise<Response> {
|
|
|
84
90
|
// capability, and writes the audit row. We admit ONLY on its ok.
|
|
85
91
|
const result = await client.grantStaffAccess(brokerAssertion, vendorAppDomain);
|
|
86
92
|
if (!result.ok) {
|
|
93
|
+
recordUiActivity(activityActor, {
|
|
94
|
+
action: "dashboard.vendor_entered",
|
|
95
|
+
outcome: { status: "refused", errorClass: result.reason ?? `http_${result.status}` },
|
|
96
|
+
scope: { tenantId: vendorAppDomain },
|
|
97
|
+
payload: { args: { resource: vendorAppDomain } },
|
|
98
|
+
});
|
|
87
99
|
return json(
|
|
88
100
|
{ error: result.message, reason: result.reason, code: result.code },
|
|
89
101
|
staffAccessHttpStatus(result.reason, result.status),
|
|
@@ -103,6 +115,12 @@ export async function POST(context: APIContext): Promise<Response> {
|
|
|
103
115
|
// No SESSION KV / no cookie (e.g. plain `astro dev`) — the audit row is
|
|
104
116
|
// written but we can't persist the marker. Fail closed so the viewer isn't
|
|
105
117
|
// told they're admitted when the guard can't see it.
|
|
118
|
+
recordUiActivity(activityActor, {
|
|
119
|
+
action: "dashboard.vendor_entered",
|
|
120
|
+
outcome: { status: "failed", errorClass: "session_store_unavailable" },
|
|
121
|
+
scope: { tenantId: vendorAppDomain },
|
|
122
|
+
payload: { args: { resource: vendorAppDomain, capability: result.capability } },
|
|
123
|
+
});
|
|
106
124
|
return json({ error: "session store unavailable; cannot persist admission" }, 503);
|
|
107
125
|
}
|
|
108
126
|
const updated = await updateSession(
|
|
@@ -112,9 +130,21 @@ export async function POST(context: APIContext): Promise<Response> {
|
|
|
112
130
|
nowSeconds,
|
|
113
131
|
);
|
|
114
132
|
if (!updated) {
|
|
133
|
+
recordUiActivity(activityActor, {
|
|
134
|
+
action: "dashboard.vendor_entered",
|
|
135
|
+
outcome: { status: "failed", errorClass: "session_expired" },
|
|
136
|
+
scope: { tenantId: vendorAppDomain },
|
|
137
|
+
payload: { args: { resource: vendorAppDomain, capability: result.capability } },
|
|
138
|
+
});
|
|
115
139
|
return json({ error: "session expired; sign in again" }, 401);
|
|
116
140
|
}
|
|
117
141
|
|
|
142
|
+
recordUiActivity(activityActor, {
|
|
143
|
+
action: "dashboard.vendor_entered",
|
|
144
|
+
outcome: { status: "succeeded" },
|
|
145
|
+
scope: { tenantId: vendorAppDomain },
|
|
146
|
+
payload: { args: { resource: vendorAppDomain, capability: result.capability } },
|
|
147
|
+
});
|
|
118
148
|
return json({
|
|
119
149
|
ok: true,
|
|
120
150
|
appDomain: vendorAppDomain,
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* reach the browser. Inline style carries the per-request CSP nonce.
|
|
12
12
|
*/
|
|
13
13
|
import { normalizeEmailHint } from "@/lib/privacy/emailHint";
|
|
14
|
+
import { safeNext } from "@/lib/auth/route";
|
|
14
15
|
// Self-hosted web fonts (audit finding 6) — same-origin @fontsource, replacing
|
|
15
16
|
// the render-blocking fonts.googleapis.com stylesheet. Covers Plus Jakarta Sans,
|
|
16
17
|
// Inter, and JetBrains Mono used by this page's inline styles.
|
|
@@ -24,10 +25,10 @@ const sent = url.searchParams.get("sent") === "1";
|
|
|
24
25
|
const email = (url.searchParams.get("email") ?? "").trim();
|
|
25
26
|
const emailHint = normalizeEmailHint(url.searchParams.get("email_hint")) ?? "";
|
|
26
27
|
const errorCode = url.searchParams.get("error");
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
28
|
+
// Same open-redirect chokepoint the verify endpoint + login gate use — never a
|
|
29
|
+
// parallel weaker copy (this value is threaded into the sign-in form's hidden
|
|
30
|
+
// `next` and back to /api/auth/verify, which re-checks it with the same guard).
|
|
31
|
+
const next = safeNext(url.searchParams.get("next"));
|
|
31
32
|
|
|
32
33
|
// RFC 9470 step-up: tot core told /api/auth/verify (or /api/auth/magic-exchange,
|
|
33
34
|
// which redirects to /auth/magic instead) that it needs a fresher credential
|
|
@@ -27,6 +27,7 @@ import { readViewerSession } from "@/lib/auth/route";
|
|
|
27
27
|
import { staticTenants } from "@/config/tenants";
|
|
28
28
|
import { resolvePreviewTenant } from "@/config/previewTenantLookup";
|
|
29
29
|
import { readTenantPreviewStatus, isStoreNotSetUp } from "@/lib/dev/previewStatus";
|
|
30
|
+
import { ensureCockpitVcBinding } from "@/lib/dev/vcBinding";
|
|
30
31
|
import { resolveDevApiBase } from "@/lib/dev/apiBase";
|
|
31
32
|
import { resolveCockpitStoreResource } from "@/lib/dev/cockpitStore";
|
|
32
33
|
import { normalizeEmailHint, redactEmailForHint } from "@/lib/privacy/emailHint";
|
|
@@ -132,6 +133,30 @@ const storeNotSetUp =
|
|
|
132
133
|
// hasStoreAccess) for anything that mints a command or starts the activity poll.
|
|
133
134
|
const canSetupStore = hasStoreAccess && !noResolvableStore && !storeNotSetUp;
|
|
134
135
|
|
|
136
|
+
// vc-app-binding safety-net (08-18, §5): a BROWSER-FIRST developer reached this page
|
|
137
|
+
// via the storefront's own sign-in (a tot_session cookie) without ever crossing an
|
|
138
|
+
// MCP binding seam (rendezvous approve / native OAuth), so `appForCaller` doesn't
|
|
139
|
+
// resolve them and the `tot start` command we're about to hand them would 403 with
|
|
140
|
+
// "No version-control app is bound." Ensure the binding now — on-behalf of the
|
|
141
|
+
// verified session identity, via the app's private_key_jwt channel. Best-effort +
|
|
142
|
+
// fail-open (never blocks render), throttled to once per browser session so it stays
|
|
143
|
+
// off the hot render path; the authority-seam binding + backfill remain the real
|
|
144
|
+
// guarantee. Gated on canSetupStore (entitled + resolvable + provisioned), the exact
|
|
145
|
+
// condition under which we render a runnable command.
|
|
146
|
+
if (canSetupStore && session && !Astro.cookies.has("tot_vc_bound")) {
|
|
147
|
+
const vcBind = await ensureCockpitVcBinding(session.email);
|
|
148
|
+
if (vcBind.ok) {
|
|
149
|
+
// Throttle only on a real success — a fail-open miss retries on the next load.
|
|
150
|
+
Astro.cookies.set("tot_vc_bound", "1", {
|
|
151
|
+
path: "/",
|
|
152
|
+
httpOnly: true,
|
|
153
|
+
sameSite: "lax",
|
|
154
|
+
secure: true,
|
|
155
|
+
maxAge: 3600, // re-check hourly so a lost/revoked binding self-heals
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
135
160
|
// Signed in, but either no developer capability on any store OR (staff bypass) no store
|
|
136
161
|
// resolves for this viewer/host — an honest recovery state (not a 401): show what to
|
|
137
162
|
// do, not the fastpath. NOTE: storeNotSetUp is deliberately NOT folded in here — it is
|
|
@@ -2651,6 +2676,17 @@ const hostedActivityScript = `
|
|
|
2651
2676
|
var rzvTimer = null, rzvCode = null, rzvDecided = false;
|
|
2652
2677
|
function rzvShow(el) { if (el) el.hidden = false; }
|
|
2653
2678
|
function rzvHide(el) { if (el) el.hidden = true; }
|
|
2679
|
+
// Once the terminal has ATTACHED, the command has plainly been pasted and
|
|
2680
|
+
// run — hide the copy affordances so the approve prompt is the page's
|
|
2681
|
+
// single focus (DZ 2026-08-18). An EXPIRED sign-in restores the generate
|
|
2682
|
+
// button (a fresh mint is needed); the stale command's copy icon stays gone.
|
|
2683
|
+
function rzvHideCopyCtas() {
|
|
2684
|
+
if (btn) btn.hidden = true;
|
|
2685
|
+
if (copyIcon) copyIcon.hidden = true;
|
|
2686
|
+
}
|
|
2687
|
+
function rzvRestoreGenerate() {
|
|
2688
|
+
if (btn) btn.hidden = false;
|
|
2689
|
+
}
|
|
2654
2690
|
function rzvStopPoll() { if (rzvTimer) { clearTimeout(rzvTimer); rzvTimer = null; } }
|
|
2655
2691
|
function rzvFinish(msg) {
|
|
2656
2692
|
rzvStopPoll(); rzvDecided = true;
|
|
@@ -2669,13 +2705,14 @@ const hostedActivityScript = `
|
|
|
2669
2705
|
} catch (e) { rzvTimer = setTimeout(rzvPoll, 2800); return; }
|
|
2670
2706
|
if (!r || !r.ok) { rzvTimer = setTimeout(rzvPoll, 3200); return; }
|
|
2671
2707
|
if (b.state === "attached" && b.fingerprint) {
|
|
2708
|
+
rzvHideCopyCtas();
|
|
2672
2709
|
rzvHide(rzvWait); rzvShow(rzvConfirm);
|
|
2673
2710
|
if (rzvFp) { rzvFp.textContent = b.fingerprint; rzvFp.setAttribute("data-fp", b.fingerprint); }
|
|
2674
2711
|
return; // stop polling — wait for the human decision
|
|
2675
2712
|
}
|
|
2676
|
-
if (b.state === "approved") { rzvFinish("Signed in ✓ — your terminal is good to go."); return; }
|
|
2677
|
-
if (b.state === "denied") { rzvFinish("Rejected — that terminal wasn’t approved. You’re safe; generate a fresh command to try again."); return; }
|
|
2678
|
-
if (b.state === "expired") { rzvFinish("This sign-in expired. Generate a fresh setup command above."); return; }
|
|
2713
|
+
if (b.state === "approved") { rzvHideCopyCtas(); rzvFinish("Signed in ✓ — your terminal is good to go."); return; }
|
|
2714
|
+
if (b.state === "denied") { rzvHideCopyCtas(); rzvFinish("Rejected — that terminal wasn’t approved. You’re safe; generate a fresh command to try again."); return; }
|
|
2715
|
+
if (b.state === "expired") { rzvRestoreGenerate(); rzvFinish("This sign-in expired. Generate a fresh setup command above."); return; }
|
|
2679
2716
|
rzvTimer = setTimeout(rzvPoll, 2500); // pending → keep watching
|
|
2680
2717
|
}
|
|
2681
2718
|
function rzvStart(code) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/storefront-runner",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.1",
|
|
4
4
|
"license": "SEE LICENSE IN LICENSE",
|
|
5
5
|
"description": "World-shareable storefront runner: multi-tenant renderer on Astro/Cloudflare. No control plane.",
|
|
6
6
|
"packageManager": "pnpm@11.9.0",
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# Operational activity telemetry — the actor×action contract (card D0)
|
|
2
|
+
|
|
3
|
+
The foundation the D1–D8 cards build on. Everything here is **pure types + pure
|
|
4
|
+
functions** in `@tot/public-runtime` (the node-free, dependency-free edge base),
|
|
5
|
+
so the storefront server, the Cloudflare Worker edge bundle, and the CLI's JS
|
|
6
|
+
mirror can all consume it. Nothing here does I/O or picks a store — the store
|
|
7
|
+
**decision** and cost model live in the repo-root `DECISIONS.md` §8; the
|
|
8
|
+
per-action store **routing** lives on each catalog entry (`sinks`).
|
|
9
|
+
|
|
10
|
+
Files:
|
|
11
|
+
|
|
12
|
+
| File | What |
|
|
13
|
+
|------|------|
|
|
14
|
+
| `event.ts` | The `ActivityEvent` envelope, `ACTIVITY_SCHEMA_VERSION`, `newEventId()`. |
|
|
15
|
+
| `catalog.ts` | The allowlisted `ACTION_CATALOG` registry + `ActionKey` + guards. |
|
|
16
|
+
| `redaction.ts` | Default-deny redaction contract + `createActivityEvent()` (the safe constructor). |
|
|
17
|
+
|
|
18
|
+
## The envelope
|
|
19
|
+
|
|
20
|
+
One event says: **an ACTOR performed an ACTION, here is the OUTCOME**, scoped to
|
|
21
|
+
a tenant/change, from a source, at a time. The design invariant is a hard split
|
|
22
|
+
between two zones:
|
|
23
|
+
|
|
24
|
+
- **Core fields** (`v`, `id`, `at`, `actor`, `action`, `source`, `scope`,
|
|
25
|
+
`outcome`) — *always safe to store, never redacted*. They carry no free text
|
|
26
|
+
and no third-party PII **by construction**: `actor.id` is opaque, `action` is
|
|
27
|
+
an allowlisted catalog key, `outcome.errorClass` is a low-cardinality class
|
|
28
|
+
(never a raw message).
|
|
29
|
+
- **`payload`** (`args` + `rendered`) — the *extensible, always-redacted* zone.
|
|
30
|
+
Whatever a call site puts here is default-**denied**.
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
import { createActivityEvent } from "@tot/public-runtime";
|
|
34
|
+
|
|
35
|
+
const { event, report } = createActivityEvent({
|
|
36
|
+
action: "cli.command.result", // must be a catalog key (compile-checked)
|
|
37
|
+
actor: { kind: "dev", id: hashedDevId },
|
|
38
|
+
source: "cli",
|
|
39
|
+
scope: { tenantId, traceId },
|
|
40
|
+
outcome:{ status: "succeeded", durationMs: 812 },
|
|
41
|
+
payload:{ args: { command: "ship", cliVersion: "1.4.0", exitCode: 0,
|
|
42
|
+
secretToken: "…" } }, // ← dropped: not allowlisted / key canary
|
|
43
|
+
});
|
|
44
|
+
// event.payload.args === { command: "ship", cliVersion: "1.4.0", exitCode: 0 }
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`createActivityEvent()` is the **only** sanctioned constructor: it fills
|
|
48
|
+
`v`/`id`/`at` and runs the raw payload through the redaction contract, so you
|
|
49
|
+
**cannot** produce an event whose payload skipped redaction.
|
|
50
|
+
|
|
51
|
+
## The action catalog
|
|
52
|
+
|
|
53
|
+
`action` is never a free string — it is a key of `ACTION_CATALOG`. Each entry
|
|
54
|
+
declares `actors` (who may emit it), `source`, `sinks` (store routing), and the
|
|
55
|
+
default-deny `argsAllow` / `renderedAllow` payload allowlists. Adding a new
|
|
56
|
+
action = a deliberate edit to `catalog.ts` (+ the CLI mirror, below), never an
|
|
57
|
+
ad-hoc string at a call site. This is what keeps the D5 query surface and the
|
|
58
|
+
D6/D7 alert rules tractable.
|
|
59
|
+
|
|
60
|
+
### The naming standard (two tiers)
|
|
61
|
+
|
|
62
|
+
Every key is dot-namespaced and its **first segment is a `<domain>`** — there are
|
|
63
|
+
**no un-namespaced keys**. The domain prefix is stable so dashboards and alert rules
|
|
64
|
+
can glob it (`change.*`, `preview.*`, …). Within that, the catalog uses **two shapes**,
|
|
65
|
+
chosen by whether the action is atomic or two-phase:
|
|
66
|
+
|
|
67
|
+
- **`<domain>.<verb>` (imperative)** — an **atomic** action that happens in one shot.
|
|
68
|
+
Examples: `change.ship`, `change.revert`, `change.go_live`, `grant.invite`,
|
|
69
|
+
`grant.revoke`, `store.connect`, `preview.override`.
|
|
70
|
+
- **`<domain>.<entity>.<verb|state>` (participle/state)** — a **two-phase / async**
|
|
71
|
+
action that **opens then closes** (an invoke/result pair, or a received→terminal span).
|
|
72
|
+
Examples: `cli.command.invoked` + `cli.command.result`, `preview.build.requested` +
|
|
73
|
+
`preview.build.completed`, `webhook.reconcile.received` + `reconcile.terminal`.
|
|
74
|
+
|
|
75
|
+
The **domain must be one of the closed set**: `cli`, `dev`, `preview`, `webhook`,
|
|
76
|
+
`reconcile`, `deploy`, `change`, `content`, `grant`, `store`, `app`, `agent`, `system`.
|
|
77
|
+
Adding a key with a new domain is a deliberate decision — widen this list here first.
|
|
78
|
+
|
|
79
|
+
**Two-phase pairing going forward:** a **new** two-phase action pairs `.invoked` /
|
|
80
|
+
`.result`. The pre-existing `.requested` / `.completed` (`preview.build.*`) and
|
|
81
|
+
`.received` / `.terminal` (`webhook.reconcile.received` + `reconcile.terminal`) pairs are
|
|
82
|
+
**grandfathered as-is — do not rename them** (`alerts.ts` hardcodes some of these literals,
|
|
83
|
+
so a rename would silently break the D7 detectors).
|
|
84
|
+
|
|
85
|
+
The initial catalog was reverse-engineered from the activity surfaces that **already
|
|
86
|
+
exist informally** in this repo (CLI dispatch + `~/.tot/activity.log`, the obstacle
|
|
87
|
+
beacon, the local→hosted dev bridge, preview/reconcile/webhook, deploy provenance, the
|
|
88
|
+
ShipLoop `CHANGE_STATES` + admin ship routes, app installs), so D1–D3 map onto it 1:1.
|
|
89
|
+
|
|
90
|
+
## The redaction contract (the D8 attack surface)
|
|
91
|
+
|
|
92
|
+
Applied to `payload` for every event, in order:
|
|
93
|
+
|
|
94
|
+
1. **Default-deny allowlist** — only the top-level keys named by the action's
|
|
95
|
+
`argsAllow`/`renderedAllow` are eligible. Everything else is **dropped**: not
|
|
96
|
+
stored, not logged, never "kept raw and redacted later".
|
|
97
|
+
2. **Primitive-only values** — an allowlisted value must be string/number/bool.
|
|
98
|
+
Objects/arrays are dropped (nested structures are where secrets hide; we do
|
|
99
|
+
not walk them). Structured args flatten into named primitive keys at the call
|
|
100
|
+
site.
|
|
101
|
+
3. **Canary scan** — allowlisted primitives are scanned for secret/PII **shapes**
|
|
102
|
+
(JWT, bearer, AWS/GitHub/Slack/OpenAI keys, private keys, email, card, phone,
|
|
103
|
+
high-entropy tokens) and secret-looking **key names** (`token`, `secret`,
|
|
104
|
+
`password`, `authorization`, …). A value hit → replaced with `«redacted»` and
|
|
105
|
+
reported (`report.canaryTripped`); a key-name hit → dropped outright.
|
|
106
|
+
|
|
107
|
+
The `RedactionReport` returned alongside the event carries **the fact of** each
|
|
108
|
+
drop/hit (lane, key, reason, canary label) — never the offending value. D4's
|
|
109
|
+
ingest logs it and emits `system.ingest.rejected` when a canary trips; D6/D7
|
|
110
|
+
alert on `canaryTripped`.
|
|
111
|
+
|
|
112
|
+
## Consuming this from the downstream cards
|
|
113
|
+
|
|
114
|
+
- **D1 server `recordActivity()`** — import `createActivityEvent`, build the
|
|
115
|
+
event, fan out to the sinks named on the action's `spec.sinks` (start with
|
|
116
|
+
`logs`; supersede the interim `apps/storefront/src/lib/activity/recordActivity.ts`
|
|
117
|
+
slice, whose `candidate.reject` becomes `change.reject`).
|
|
118
|
+
- **D2 deploy provenance** — emit `deploy.version_observed`.
|
|
119
|
+
- **D3 CLI `emitActivity`** — the CLI is **pure JS and does not import TS
|
|
120
|
+
packages** (it publishes to npm standalone and mirrors TS contracts with
|
|
121
|
+
keep-in-step tests, e.g. `no-gitea-links.test.mjs`). So D3 ships a small JS
|
|
122
|
+
mirror of the `ActionKey` list + the safe-constructor shape, plus a
|
|
123
|
+
keep-in-step test that fails the build if the mirror drifts from
|
|
124
|
+
`catalog.ts`. **Do not** make the CLI depend on `@tot/public-runtime`.
|
|
125
|
+
- **D4 ingest + store** — validate with `isActionKey` + `isActorAllowed`, run
|
|
126
|
+
`redactPayload` (belt-and-suspenders even though emitters already redacted),
|
|
127
|
+
route by `spec.sinks`. See `DECISIONS.md` §8 for the D1 table (`activity_events`
|
|
128
|
+
in `STOREFRONT_APPS_DB`, alongside the existing `app_audit_log`) and the
|
|
129
|
+
Analytics Engine binding to provision.
|
|
130
|
+
- **D5 query/timeline UI** — reads the `timeline`-sink rows from D1.
|
|
131
|
+
|
|
132
|
+
## Open questions deliberately left for downstream
|
|
133
|
+
|
|
134
|
+
- **Actor-id pseudonymization salt** (D1/D4). This module scrubs PII that leaks
|
|
135
|
+
into the *payload*, but `actor.id` in the core zone is the emitter's job to
|
|
136
|
+
pseudonymize. A shared salted non-reversible hash helper (salt as a Worker
|
|
137
|
+
secret) should land with D1/D4 — do **not** bake a weak unsalted hash into this
|
|
138
|
+
edge-shipped package.
|
|
139
|
+
- **Analytics Engine binding + D1 `activity_events` schema** (D4). Named in
|
|
140
|
+
`DECISIONS.md` §8; the actual `wrangler.toml` binding and the Drizzle migration
|
|
141
|
+
are D4's to write.
|
|
142
|
+
- **Retention/pruning job for the D1 timeline** (D4/D5). Arbitrary retention is
|
|
143
|
+
the reason the queryable cohort is in D1 not Analytics Engine; the pruning
|
|
144
|
+
cron is downstream.
|
|
145
|
+
- **Agent cohort payload** — `agent.action.*` keys are a placeholder spine with
|
|
146
|
+
minimal args until the agent-cohort card defines what an agent may record.
|