@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,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
|
-
}
|