@tokenoftrust/storefront-runner 1.4.0 → 1.4.2-rc.0
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/astro.config.mjs +17 -1
- package/apps/storefront/integrations/materialize-guard.mjs +56 -0
- package/apps/storefront/migrations-apps/0001_woozy_lyja.sql +22 -0
- package/apps/storefront/migrations-apps/0002_good_switch.sql +26 -0
- package/apps/storefront/migrations-apps/0003_mute_marauders.sql +12 -0
- package/apps/storefront/migrations-apps/meta/0001_snapshot.json +990 -0
- package/apps/storefront/migrations-apps/meta/0002_snapshot.json +1160 -0
- package/apps/storefront/migrations-apps/meta/0003_snapshot.json +1235 -0
- package/apps/storefront/migrations-apps/meta/_journal.json +21 -0
- package/apps/storefront/package.json +0 -1
- package/apps/storefront/public/js/dashboard-team.js +38 -2
- package/apps/storefront/src/components/Seo.astro +65 -1
- package/apps/storefront/src/components/admin/AdminBlogDetailRail.astro +878 -0
- package/apps/storefront/src/components/admin/AdminBlogTab.astro +1432 -0
- package/apps/storefront/src/components/admin/AdminCustomersTab.astro +3 -7
- package/apps/storefront/src/components/admin/AdminPublishTab.astro +1756 -1382
- package/apps/storefront/src/components/admin/AdminSubscriptionsTab.astro +1 -3
- package/apps/storefront/src/components/blog/BlogPaywall.astro +87 -0
- package/apps/storefront/src/components/blog/PostAccessBadge.astro +43 -0
- package/apps/storefront/src/components/blog/PostGrid.astro +57 -0
- package/apps/storefront/src/components/blog/ReactionBar.astro +214 -0
- package/apps/storefront/src/layouts/Layout.astro +48 -3
- package/apps/storefront/src/lib/activity/alerts.ts +426 -0
- package/apps/storefront/src/lib/activity/changeActorAttribution.ts +85 -0
- package/apps/storefront/src/lib/activity/deployVersion.ts +120 -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 +101 -0
- package/apps/storefront/src/lib/activity/store.ts +120 -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/admin/adminShell.ts +290 -0
- package/apps/storefront/src/lib/admin/ai/approvalMode.ts +2 -3
- package/apps/storefront/src/lib/admin/ai/audit.ts +3 -5
- package/apps/storefront/src/lib/admin/checkoutStyleInput.ts +7 -7
- package/apps/storefront/src/lib/admin/checkoutStyleTarget.ts +3 -3
- package/apps/storefront/src/lib/admin/orderClient.ts +3 -5
- package/apps/storefront/src/lib/admin/services/orders.ts +17 -19
- package/apps/storefront/src/lib/admin/viewmodels/aiWorkflows.ts +4 -6
- package/apps/storefront/src/lib/admin/viewmodels/settings.ts +1 -1
- package/apps/storefront/src/lib/apps/adminService.ts +4 -4
- package/apps/storefront/src/lib/apps/adminSession.ts +53 -44
- package/apps/storefront/src/lib/apps/apiAuth.ts +2 -2
- package/apps/storefront/src/lib/apps/apiRoute.ts +2 -2
- package/apps/storefront/src/lib/apps/catalogMapper.ts +1 -1
- package/apps/storefront/src/lib/apps/credentials.ts +2 -2
- package/apps/storefront/src/lib/apps/gatewayKeys.ts +4 -4
- package/apps/storefront/src/lib/apps/healthAggregate.ts +5 -5
- package/apps/storefront/src/lib/apps/orders/attributionService.ts +2 -2
- package/apps/storefront/src/lib/apps/orders/customerHash.ts +1 -1
- package/apps/storefront/src/lib/apps/orders/foxyOrderClient.ts +12 -12
- package/apps/storefront/src/lib/apps/orders/idempotency.ts +7 -7
- package/apps/storefront/src/lib/apps/orders/orderForwardReceiver.ts +1 -1
- package/apps/storefront/src/lib/apps/orders/orderMapper.ts +2 -2
- package/apps/storefront/src/lib/apps/orders/ordersStore.ts +6 -6
- package/apps/storefront/src/lib/apps/registryService.ts +6 -6
- package/apps/storefront/src/lib/apps/scopes.ts +2 -2
- package/apps/storefront/src/lib/apps/tokenIssuer.ts +4 -4
- package/apps/storefront/src/lib/apps/tokenVerifier.ts +6 -6
- package/apps/storefront/src/lib/apps/widgets/eligibility.ts +2 -2
- package/apps/storefront/src/lib/apps/widgets/frameProps.ts +3 -3
- package/apps/storefront/src/lib/apps/widgets/launchToken.ts +4 -4
- package/apps/storefront/src/lib/apps/widgets/placements.ts +2 -2
- package/apps/storefront/src/lib/apps/widgets/renderSlot.ts +1 -1
- package/apps/storefront/src/lib/auth/adminEntry.ts +9 -9
- package/apps/storefront/src/lib/auth/identityToken.ts +6 -14
- package/apps/storefront/src/lib/auth/loginGate.ts +75 -13
- package/apps/storefront/src/lib/auth/mcpClientAssertion.ts +6 -4
- package/apps/storefront/src/lib/auth/operatorSession.ts +113 -0
- package/apps/storefront/src/lib/auth/route.ts +44 -4
- package/apps/storefront/src/lib/auth/session.ts +7 -8
- package/apps/storefront/src/lib/auth/totAccessClient.ts +8 -18
- package/apps/storefront/src/lib/basePath.ts +9 -2
- package/apps/storefront/src/lib/blog/access.ts +29 -0
- package/apps/storefront/src/lib/blog/collection.ts +313 -0
- package/apps/storefront/src/lib/blog/markdown.ts +174 -0
- package/apps/storefront/src/lib/blog/provider.ts +5 -0
- package/apps/storefront/src/lib/blog/reactions.ts +87 -0
- package/apps/storefront/src/lib/blog/rss.ts +92 -0
- package/apps/storefront/src/lib/blog/teaser.ts +83 -0
- package/apps/storefront/src/lib/blog/types.ts +34 -0
- package/apps/storefront/src/lib/blog/viewCounts.ts +243 -0
- package/apps/storefront/src/lib/d1/catalog.ts +2 -2
- package/apps/storefront/src/lib/d1/schema-apps.ts +147 -14
- package/apps/storefront/src/lib/dev/activityIngestToken.ts +2 -4
- package/apps/storefront/src/lib/dev/activityStore.ts +3 -5
- package/apps/storefront/src/lib/dev/apiBase.ts +1 -4
- package/apps/storefront/src/lib/dev/cockpitStore.ts +1 -9
- package/apps/storefront/src/lib/dev/devDraft.ts +1 -1
- package/apps/storefront/src/lib/dev/previewStatus.ts +9 -21
- package/apps/storefront/src/lib/dev/rendezvousBroker.ts +9 -24
- package/apps/storefront/src/lib/edgeCache.ts +17 -0
- package/apps/storefront/src/lib/env.ts +51 -0
- package/apps/storefront/src/lib/http/fetchTransport.ts +63 -0
- package/apps/storefront/src/lib/i18n.ts +25 -0
- package/apps/storefront/src/lib/jsonld.ts +118 -0
- package/apps/storefront/src/lib/monitoring/manifest.ts +11 -11
- package/apps/storefront/src/lib/newsletter/confirmationEmail.ts +30 -0
- package/apps/storefront/src/lib/newsletter/consent.ts +23 -0
- package/apps/storefront/src/lib/newsletter/digest.ts +117 -0
- package/apps/storefront/src/lib/newsletter/resendClient.ts +79 -0
- package/apps/storefront/src/lib/newsletter/send.ts +265 -0
- package/apps/storefront/src/lib/newsletter/subscribers.ts +156 -0
- package/apps/storefront/src/lib/publish/adminPublishTab.ts +3519 -0
- package/apps/storefront/src/lib/publish/apex-readiness.ts +41 -18
- package/apps/storefront/src/lib/publish/dispatchHealth.ts +5 -7
- package/apps/storefront/src/lib/publish/domainState.ts +22 -13
- package/apps/storefront/src/lib/publish/panelPolling.ts +78 -0
- package/apps/storefront/src/lib/publish/shipWorkspace.ts +100 -42
- package/apps/storefront/src/lib/publish/stageBundleUpload.ts +215 -0
- package/apps/storefront/src/lib/rate/kvWindow.ts +53 -0
- package/apps/storefront/src/lib/search/index.ts +2 -2
- package/apps/storefront/src/lib/seo/alternates.ts +42 -0
- package/apps/storefront/src/lib/seo/meta.ts +65 -0
- package/apps/storefront/src/lib/social/golive.ts +212 -0
- package/apps/storefront/src/lib/social/notify.ts +32 -0
- package/apps/storefront/src/lib/social/tokenStore.ts +171 -0
- package/apps/storefront/src/lib/the-build/provider.ts +27 -0
- package/apps/storefront/src/lib/the-build/readingTime.ts +35 -0
- package/apps/storefront/src/lib/the-build/toc.ts +95 -0
- package/apps/storefront/src/lib/tot/ToTClient.ts +1 -1
- package/apps/storefront/src/lib/tot/d1Client.ts +2 -2
- package/apps/storefront/src/lib/tot/totClientInterface.ts +3 -4
- package/apps/storefront/src/lib/webhooks/deliveryStore.ts +16 -17
- package/apps/storefront/src/middleware/index.ts +130 -2
- package/apps/storefront/src/pages/admin/ops-timeline.astro +420 -0
- package/apps/storefront/src/pages/admin.astro +52 -1206
- 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/blog/[collection]/[slug]/react.ts +104 -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/api/newsletter/[collection]/subscribe.ts +146 -0
- package/apps/storefront/src/pages/api/newsletter/confirm.ts +75 -0
- package/apps/storefront/src/pages/api/newsletter/unsubscribe.ts +90 -0
- package/apps/storefront/src/pages/api/publish/stage-bundle.ts +70 -0
- package/apps/storefront/src/pages/api/rum/vitals.ts +21 -2
- package/apps/storefront/src/pages/auth/login.astro +5 -4
- package/apps/storefront/src/pages/blog/[slug].astro +96 -16
- package/apps/storefront/src/pages/blog/author/[author].astro +140 -0
- package/apps/storefront/src/pages/blog/category/[category].astro +67 -0
- package/apps/storefront/src/pages/blog/index.astro +2 -0
- package/apps/storefront/src/pages/blog/tag/[tag].astro +65 -0
- package/apps/storefront/src/pages/cockpit.astro +74 -12
- package/apps/storefront/src/pages/dashboard/[appDomain]/team.astro +15 -11
- package/apps/storefront/src/pages/search.astro +1 -1
- package/apps/storefront/src/pages/sitemap.xml.ts +20 -0
- package/apps/storefront/src/pages/the-build/[slug].astro +129 -0
- package/apps/storefront/src/pages/the-build/index.astro +146 -0
- package/apps/storefront/src/pages/the-build/rss.xml.ts +33 -0
- package/apps/storefront/src/styles/admin.css +1080 -0
- package/apps/storefront/tsconfig.json +5 -0
- package/package.json +1 -1
- package/packages/public-runtime/package.json +1 -0
- package/packages/public-runtime/src/activity/README.md +146 -0
- package/packages/public-runtime/src/activity/catalog.ts +553 -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/catalog-d1.ts +1 -1
- package/packages/public-runtime/src/customization-preview.ts +25 -2
- package/packages/public-runtime/src/customization-runtime.ts +125 -3
- package/packages/public-runtime/src/customization-versioning.ts +10 -0
- package/packages/public-runtime/src/index.ts +7 -0
- package/packages/public-runtime/src/static-bundle.ts +357 -0
- package/packages/public-runtime/src/tenant.ts +36 -8
- package/scripts/build/copy-tenant-assets.mjs +14 -4
- package/scripts/dev/ai-edit.mjs +1 -1
- package/scripts/dev/checkout-watch.mjs +2 -2
- package/scripts/dev/file-browser.mjs +2 -4
- package/scripts/dev/file-writer.mjs +1 -1
- package/scripts/dev/git-status.mjs +2 -2
- package/scripts/dev/locate-handler.mjs +3 -3
- package/scripts/dev/port-check.mjs +3 -3
- package/scripts/dev/publish.mjs +5 -9
- package/scripts/dev/shot.mjs +1 -1
- package/scripts/dev/unified-diff.mjs +8 -3
- package/scripts/tot-dev.mjs +6 -9
- package/apps/storefront/src/lib/webhooks/signing.ts +0 -29
- package/apps/storefront/src/lib/webhooks/webhookSigningKey.ts +0 -146
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ingest validation + server-side redaction for `POST /api/activity`.
|
|
3
|
+
*
|
|
4
|
+
* PURE (no I/O) so it is unit-testable and the route stays a thin auth/parse/
|
|
5
|
+
* respond shell. Takes the raw JSON events an emitter posted (the CLI and UI
|
|
6
|
+
* cohorts — both send JSON matching the `ActivityEvent` shape) plus the
|
|
7
|
+
* AUTHENTICATED tenant id, and returns the accepted events (already rebuilt
|
|
8
|
+
* through `createActivityEvent`, so re-redacted server-side) and the rejections.
|
|
9
|
+
*
|
|
10
|
+
* DEFENSE IN DEPTH. Even though every emitter is
|
|
11
|
+
* expected to have run the redaction contract itself, ingest NEVER trusts that:
|
|
12
|
+
* it rebuilds each event through `createActivityEvent()`, which re-applies the
|
|
13
|
+
* default-deny allowlist + canary scan. A malicious/careless client that posts a
|
|
14
|
+
* secret in an un-allowlisted (or even allowlisted) payload field has it dropped/
|
|
15
|
+
* redacted here regardless of what it claimed to send.
|
|
16
|
+
*
|
|
17
|
+
* TENANT ISOLATION. `scope.tenantId` on the stored event is ALWAYS the
|
|
18
|
+
* authenticated tenant (from the HMAC-bound header), never the client-supplied
|
|
19
|
+
* body value — a client cannot write into another tenant's scope.
|
|
20
|
+
*/
|
|
21
|
+
import {
|
|
22
|
+
createActivityEvent,
|
|
23
|
+
isActionKey,
|
|
24
|
+
isActorAllowed,
|
|
25
|
+
isIsoTimestamp,
|
|
26
|
+
getActionSpec,
|
|
27
|
+
scanCoreIdentifier,
|
|
28
|
+
type ActivityEvent,
|
|
29
|
+
type ActorKind,
|
|
30
|
+
type ActivitySource,
|
|
31
|
+
type ActivityStatus,
|
|
32
|
+
type ActivityPayload,
|
|
33
|
+
type RedactionFieldHit,
|
|
34
|
+
} from "@tot/public-runtime";
|
|
35
|
+
|
|
36
|
+
const ACTOR_KINDS = new Set<ActorKind>(["dev", "merchant", "admin", "agent", "system"]);
|
|
37
|
+
const SOURCES = new Set<ActivitySource>(["cli", "server", "ui"]);
|
|
38
|
+
const STATUSES = new Set<ActivityStatus>(["invoked", "succeeded", "failed", "refused"]);
|
|
39
|
+
|
|
40
|
+
export interface IngestRejection {
|
|
41
|
+
/** Index of the offending event in the posted batch. */
|
|
42
|
+
index: number;
|
|
43
|
+
/** Coarse machine reason — mirrors the `system.ingest.rejected` catalog args. */
|
|
44
|
+
reason:
|
|
45
|
+
| "not_object"
|
|
46
|
+
| "unknown_action"
|
|
47
|
+
| "actor_invalid"
|
|
48
|
+
| "actor_not_allowed"
|
|
49
|
+
| "source_invalid"
|
|
50
|
+
| "source_mismatch"
|
|
51
|
+
| "status_invalid"
|
|
52
|
+
| "bad_timestamp"
|
|
53
|
+
/** A client-supplied CORE-zone id (actor.id/ref, scope.changeId/traceId) carried a
|
|
54
|
+
* raw PII/secret SHAPE. The core zone is unredacted, so this is refused outright
|
|
55
|
+
* (never stored) rather than scrubbed — see `scanCoreIdentifier`. */
|
|
56
|
+
| "core_pii";
|
|
57
|
+
/** The action string the client attempted (for self-telemetry), when present. */
|
|
58
|
+
attemptedAction?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface AcceptedActivity {
|
|
62
|
+
event: ActivityEvent;
|
|
63
|
+
/** True when a value-canary fired during server-side redaction — the signal
|
|
64
|
+
* the route emits self-telemetry for and alerting reads. */
|
|
65
|
+
canaryTripped: boolean;
|
|
66
|
+
/** The redaction hits (fact-of, never the value) for audit / logging. */
|
|
67
|
+
redactionHits: RedactionFieldHit[];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface IngestOutcome {
|
|
71
|
+
accepted: AcceptedActivity[];
|
|
72
|
+
rejected: IngestRejection[];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function asString(v: unknown): string | undefined {
|
|
76
|
+
return typeof v === "string" && v.length > 0 ? v : undefined;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Validate + redact one posted event under the authenticated tenant. Returns the
|
|
81
|
+
* accepted (rebuilt) event or a rejection. The rebuild forces the tenant scope
|
|
82
|
+
* and re-runs redaction; it preserves the client's `id` (idempotency key) and
|
|
83
|
+
* `at` (emit clock) when they are well-formed.
|
|
84
|
+
*/
|
|
85
|
+
export function ingestOne(index: number, raw: unknown, tenantId: string): AcceptedActivity | IngestRejection {
|
|
86
|
+
if (!raw || typeof raw !== "object") return { index, reason: "not_object" };
|
|
87
|
+
const e = raw as Record<string, unknown>;
|
|
88
|
+
|
|
89
|
+
const action = e.action;
|
|
90
|
+
if (typeof action !== "string" || !isActionKey(action)) {
|
|
91
|
+
return { index, reason: "unknown_action", attemptedAction: typeof action === "string" ? action : undefined };
|
|
92
|
+
}
|
|
93
|
+
const spec = getActionSpec(action)!;
|
|
94
|
+
|
|
95
|
+
const rawActor = e.actor;
|
|
96
|
+
if (!rawActor || typeof rawActor !== "object") return { index, reason: "actor_invalid", attemptedAction: action };
|
|
97
|
+
const actorKind = (rawActor as Record<string, unknown>).kind;
|
|
98
|
+
const actorId = (rawActor as Record<string, unknown>).id;
|
|
99
|
+
const actorRef = (rawActor as Record<string, unknown>).ref;
|
|
100
|
+
if (typeof actorKind !== "string" || !ACTOR_KINDS.has(actorKind as ActorKind) || typeof actorId !== "string" || !actorId) {
|
|
101
|
+
return { index, reason: "actor_invalid", attemptedAction: action };
|
|
102
|
+
}
|
|
103
|
+
// Spoof guard: a `dev`-cohort caller cannot claim an `admin` lifecycle action.
|
|
104
|
+
if (!isActorAllowed(action, actorKind as ActorKind)) {
|
|
105
|
+
return { index, reason: "actor_not_allowed", attemptedAction: action };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const source = e.source;
|
|
109
|
+
if (typeof source !== "string" || !SOURCES.has(source as ActivitySource)) {
|
|
110
|
+
return { index, reason: "source_invalid", attemptedAction: action };
|
|
111
|
+
}
|
|
112
|
+
// The catalog declares exactly one legitimate source per action (1:1 grounding).
|
|
113
|
+
if (source !== spec.source) return { index, reason: "source_mismatch", attemptedAction: action };
|
|
114
|
+
|
|
115
|
+
const rawOutcome = e.outcome;
|
|
116
|
+
const status = rawOutcome && typeof rawOutcome === "object" ? (rawOutcome as Record<string, unknown>).status : undefined;
|
|
117
|
+
if (typeof status !== "string" || !STATUSES.has(status as ActivityStatus)) {
|
|
118
|
+
return { index, reason: "status_invalid", attemptedAction: action };
|
|
119
|
+
}
|
|
120
|
+
const errorClass = asString((rawOutcome as Record<string, unknown>).errorClass);
|
|
121
|
+
const durationRaw = (rawOutcome as Record<string, unknown>).durationMs;
|
|
122
|
+
const durationMs = typeof durationRaw === "number" && Number.isFinite(durationRaw) ? durationRaw : undefined;
|
|
123
|
+
|
|
124
|
+
// `at`: keep the emit clock when well-formed; a PRESENT-but-malformed `at` is a
|
|
125
|
+
// reject (a broken clock corrupts the timeline ordering), a MISSING one is
|
|
126
|
+
// stamped server-side by createActivityEvent.
|
|
127
|
+
const at = e.at;
|
|
128
|
+
if (at !== undefined && !isIsoTimestamp(at)) return { index, reason: "bad_timestamp", attemptedAction: action };
|
|
129
|
+
|
|
130
|
+
const rawScope = (e.scope && typeof e.scope === "object" ? e.scope : {}) as Record<string, unknown>;
|
|
131
|
+
|
|
132
|
+
// CORE-ZONE PII GUARD. actor.id / actor.ref and the client-supplied
|
|
133
|
+
// scope ids land UNREDACTED in the core zone, whose contract is "opaque, never raw
|
|
134
|
+
// PII/secret". The payload contract is enforced by re-redaction below, but the core
|
|
135
|
+
// ids were NOT checked — a buggy/malicious (though authenticated) emitter could
|
|
136
|
+
// smuggle a raw email/token into actor.id and have it stored in D1/Analytics/logs
|
|
137
|
+
// verbatim. Refuse any core id with a raw PII/secret SHAPE (opaque hashes/UUIDs pass;
|
|
138
|
+
// see `scanCoreIdentifier`). Rejected → self-telemetried as `system.ingest.rejected`.
|
|
139
|
+
const changeId = asString(rawScope.changeId);
|
|
140
|
+
const traceId = asString(rawScope.traceId);
|
|
141
|
+
if (
|
|
142
|
+
scanCoreIdentifier(actorId) ||
|
|
143
|
+
(typeof actorRef === "string" && actorRef && scanCoreIdentifier(actorRef)) ||
|
|
144
|
+
(changeId && scanCoreIdentifier(changeId)) ||
|
|
145
|
+
(traceId && scanCoreIdentifier(traceId))
|
|
146
|
+
) {
|
|
147
|
+
return { index, reason: "core_pii", attemptedAction: action };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const built = createActivityEvent({
|
|
151
|
+
action,
|
|
152
|
+
actor: {
|
|
153
|
+
kind: actorKind as ActorKind,
|
|
154
|
+
id: actorId,
|
|
155
|
+
...(typeof actorRef === "string" && actorRef ? { ref: actorRef } : {}),
|
|
156
|
+
},
|
|
157
|
+
source: source as ActivitySource,
|
|
158
|
+
outcome: {
|
|
159
|
+
status: status as ActivityStatus,
|
|
160
|
+
...(errorClass ? { errorClass } : {}),
|
|
161
|
+
...(durationMs !== undefined ? { durationMs } : {}),
|
|
162
|
+
},
|
|
163
|
+
scope: {
|
|
164
|
+
// AUTHENTICATED tenant only — the client's scope.tenantId is DISCARDED.
|
|
165
|
+
tenantId,
|
|
166
|
+
...(changeId ? { changeId } : {}),
|
|
167
|
+
...(traceId ? { traceId } : {}),
|
|
168
|
+
},
|
|
169
|
+
...(typeof at === "string" ? { at } : {}),
|
|
170
|
+
...(asString(e.id) ? { id: e.id as string } : {}),
|
|
171
|
+
// RAW payload — createActivityEvent re-redacts it (defense in depth).
|
|
172
|
+
...(e.payload && typeof e.payload === "object" ? { payload: e.payload as ActivityPayload } : {}),
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
return { event: built.event, canaryTripped: built.report.canaryTripped, redactionHits: built.report.hits };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Validate + redact a whole posted batch under the authenticated tenant. */
|
|
179
|
+
export function ingestEvents(events: unknown[], tenantId: string): IngestOutcome {
|
|
180
|
+
const accepted: AcceptedActivity[] = [];
|
|
181
|
+
const rejected: IngestRejection[] = [];
|
|
182
|
+
events.forEach((raw, i) => {
|
|
183
|
+
const r = ingestOne(i, raw, tenantId);
|
|
184
|
+
if ("event" in r) accepted.push(r);
|
|
185
|
+
else rejected.push(r);
|
|
186
|
+
});
|
|
187
|
+
return { accepted, rejected };
|
|
188
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Service-auth for `POST /api/activity` — HMAC-SHA256 over the raw
|
|
3
|
+
* body, with the authenticated TENANT bound into the signed material.
|
|
4
|
+
*
|
|
5
|
+
* WHY HMAC (not the bare `x-mcp-service-secret` shared-secret seam the
|
|
6
|
+
* webhook receivers use). Those receivers authenticate ONE trusted forwarder
|
|
7
|
+
* (tot-mcp) and read the tenant from a body the forwarder itself constructed —
|
|
8
|
+
* fine there. Activity ingest is different in two ways that make a plain shared
|
|
9
|
+
* secret insufficient:
|
|
10
|
+
* 1. It is fed by MANY emitters (the CLI dev cohort, the UI cohort) whose
|
|
11
|
+
* payloads must not be tamperable in flight — the redaction canaries key
|
|
12
|
+
* off byte-exact values, so body INTEGRITY is load-bearing.
|
|
13
|
+
* 2. Every row is TENANT-SCOPED and the scope must come from the authenticated
|
|
14
|
+
* identity, not a client-set field. So the tenant is part of the SIGNED
|
|
15
|
+
* material (`<tenant>\n<body>`): a caller cannot assert a tenant it was not
|
|
16
|
+
* issued a signature for without holding the ingest secret, and cannot swap
|
|
17
|
+
* the tenant without invalidating the signature. The route then writes
|
|
18
|
+
* `scope.tenantId` from THIS verified header and discards any body value.
|
|
19
|
+
*
|
|
20
|
+
* The signing key is the `ACTIVITY_INGEST_SECRET` Worker secret, shared with the
|
|
21
|
+
* emitters' gateway (the CLI/UI post through a signer that holds it — the
|
|
22
|
+
* per-emitter key-distribution/rotation story is a downstream concern, noted as
|
|
23
|
+
* a TODO). Everything here is WebCrypto (`crypto.subtle`) so the edge bundle
|
|
24
|
+
* never imports `node:crypto`, matching the node-free posture of the contract.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** Header carrying the authenticated tenant assertion (bound into the HMAC). */
|
|
28
|
+
export const TENANT_HEADER = "x-tot-activity-tenant";
|
|
29
|
+
/** Header carrying the hex HMAC-SHA256 signature over `<tenant>\n<rawBody>`. */
|
|
30
|
+
export const SIGNATURE_HEADER = "x-tot-activity-signature";
|
|
31
|
+
|
|
32
|
+
export type IngestAuthResult =
|
|
33
|
+
| { ok: true; tenantId: string }
|
|
34
|
+
| { ok: false; status: 401 | 503; reason: string };
|
|
35
|
+
|
|
36
|
+
/** Length-independent constant-time string compare — mirrors the shared-secret
|
|
37
|
+
* seam in `api/changes/candidate-reconcile.ts`; avoids leaking the signature by
|
|
38
|
+
* timing. */
|
|
39
|
+
export function timingSafeEqual(a: string, b: string): boolean {
|
|
40
|
+
const len = Math.max(a.length, b.length);
|
|
41
|
+
let diff = a.length ^ b.length;
|
|
42
|
+
for (let i = 0; i < len; i++) {
|
|
43
|
+
diff |= (a.charCodeAt(i) || 0) ^ (b.charCodeAt(i) || 0);
|
|
44
|
+
}
|
|
45
|
+
return diff === 0;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const encoder = new TextEncoder();
|
|
49
|
+
|
|
50
|
+
function toHex(buf: ArrayBuffer): string {
|
|
51
|
+
const b = new Uint8Array(buf);
|
|
52
|
+
let out = "";
|
|
53
|
+
for (let i = 0; i < b.length; i++) out += (b[i] as number).toString(16).padStart(2, "0");
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Hex HMAC-SHA256 of `message` under `secret`. Exported so the tests (and any
|
|
59
|
+
* server-side signer/gateway) sign with the exact same construction the verifier
|
|
60
|
+
* checks — a single source of truth for the wire format.
|
|
61
|
+
*/
|
|
62
|
+
export async function hmacSha256Hex(secret: string, message: string): Promise<string> {
|
|
63
|
+
const key = await crypto.subtle.importKey(
|
|
64
|
+
"raw",
|
|
65
|
+
encoder.encode(secret),
|
|
66
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
67
|
+
false,
|
|
68
|
+
["sign"],
|
|
69
|
+
);
|
|
70
|
+
const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(message));
|
|
71
|
+
return toHex(sig);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The exact bytes signed: the authenticated tenant, then the raw request body,
|
|
75
|
+
* separated by a newline the tenant can never contain (it is validated as a
|
|
76
|
+
* single-line id upstream). Binding both means neither can be swapped
|
|
77
|
+
* independently of the signature. */
|
|
78
|
+
export function signingMaterial(tenantId: string, rawBody: string): string {
|
|
79
|
+
return `${tenantId}\n${rawBody}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Verify an inbound ingest request. `secret` is the resolved
|
|
84
|
+
* `ACTIVITY_INGEST_SECRET` (the route returns 503 BEFORE calling this when it is
|
|
85
|
+
* unset — an unconfigured seam is not an auth failure). Returns the authenticated
|
|
86
|
+
* `tenantId` on success; a 401 with a coarse reason otherwise (never echoes the
|
|
87
|
+
* expected signature or the secret).
|
|
88
|
+
*/
|
|
89
|
+
export async function verifyIngestAuth(
|
|
90
|
+
headers: Headers,
|
|
91
|
+
rawBody: string,
|
|
92
|
+
secret: string,
|
|
93
|
+
): Promise<IngestAuthResult> {
|
|
94
|
+
const tenantId = (headers.get(TENANT_HEADER) ?? "").trim();
|
|
95
|
+
const presented = (headers.get(SIGNATURE_HEADER) ?? "").trim();
|
|
96
|
+
if (!tenantId) return { ok: false, status: 401, reason: "missing tenant" };
|
|
97
|
+
// A newline in the tenant id would let a caller forge the material framing.
|
|
98
|
+
if (/[\r\n]/.test(tenantId)) return { ok: false, status: 401, reason: "invalid tenant" };
|
|
99
|
+
if (!presented) return { ok: false, status: 401, reason: "missing signature" };
|
|
100
|
+
const expected = await hmacSha256Hex(secret, signingMaterial(tenantId, rawBody));
|
|
101
|
+
if (!timingSafeEqual(presented.toLowerCase(), expected)) {
|
|
102
|
+
return { ok: false, status: 401, reason: "bad signature" };
|
|
103
|
+
}
|
|
104
|
+
return { ok: true, tenantId };
|
|
105
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The single operational KILL SWITCH for the whole activity-telemetry pipeline.
|
|
3
|
+
*
|
|
4
|
+
* ONE flag — the `ACTIVITY_TELEMETRY_DISABLED` env var / Worker secret — switches
|
|
5
|
+
* EVERY server-side emit path to a no-op in one place:
|
|
6
|
+
* • `recordActivity()` (the server emitter) — and therefore `recordUiActivity()`
|
|
7
|
+
* (the UI cohort), which is a thin wrapper over it;
|
|
8
|
+
* • `observeDeployVersion()` (the deploy-provenance observer);
|
|
9
|
+
* • `POST /api/activity` (ingest) — accepts-and-drops so emitters never error.
|
|
10
|
+
* The out-of-process CLI (`emitActivity`) honours the SAME flag name in its own
|
|
11
|
+
* JS mirror. Wiring it at these SHARED chokepoints means no call site checks it.
|
|
12
|
+
*
|
|
13
|
+
* FAIL-OPEN — this gates ONLY the observability layer. The underlying operation
|
|
14
|
+
* (reconcile, preview-serve, ship, invite, a CLI command) never depends on telemetry
|
|
15
|
+
* and is never blocked by it; flipping the switch turns emits into no-ops while
|
|
16
|
+
* everything else runs unchanged. An unset/unknown flag ⇒ telemetry ENABLED (the
|
|
17
|
+
* normal state). Reading the flag can never throw.
|
|
18
|
+
*
|
|
19
|
+
* SYNC + PRIMED — `recordActivity()` is a synchronous, fire-and-forget best-effort
|
|
20
|
+
* call, so the switch must be checkable WITHOUT awaiting. `isActivityDisabled()`
|
|
21
|
+
* consults (a) a value PRIMED once per isolate from the async Worker env
|
|
22
|
+
* (`primeActivityKillSwitch()`, called at the middleware front door) and (b) the
|
|
23
|
+
* synchronously-available build/process env — so a build-time var takes effect in
|
|
24
|
+
* dev/tests immediately, and a Worker secret takes effect from the first primed
|
|
25
|
+
* request of each isolate.
|
|
26
|
+
*/
|
|
27
|
+
import { readEnv } from "../env.js";
|
|
28
|
+
|
|
29
|
+
/** The one canonical flag name — shared verbatim by the server and the CLI mirror. */
|
|
30
|
+
export const ACTIVITY_KILL_SWITCH_FLAG = "ACTIVITY_TELEMETRY_DISABLED";
|
|
31
|
+
|
|
32
|
+
const TRUTHY = new Set(["1", "true", "yes", "on"]);
|
|
33
|
+
|
|
34
|
+
function truthy(v: string | null | undefined): boolean {
|
|
35
|
+
return typeof v === "string" && TRUTHY.has(v.trim().toLowerCase());
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Primed once per isolate from the async Worker env. `undefined` until primed. */
|
|
39
|
+
let primedDisabled: boolean | undefined;
|
|
40
|
+
|
|
41
|
+
/** Synchronously-available env only (build-time `import.meta.env` + `process.env`) —
|
|
42
|
+
* does NOT see Worker-only secrets, which arrive via {@link primeActivityKillSwitch}. */
|
|
43
|
+
function fromSyncEnv(): boolean {
|
|
44
|
+
try {
|
|
45
|
+
const meta = (import.meta as unknown as { env?: Record<string, string | undefined> }).env;
|
|
46
|
+
if (meta && truthy(meta[ACTIVITY_KILL_SWITCH_FLAG])) return true;
|
|
47
|
+
} catch {
|
|
48
|
+
/* import.meta.env unavailable — ignore */
|
|
49
|
+
}
|
|
50
|
+
if (typeof process !== "undefined" && process.env && truthy(process.env[ACTIVITY_KILL_SWITCH_FLAG])) {
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* True when the entire activity pipeline is switched off. Sync + never throws.
|
|
58
|
+
* Fail-open: an unknown/unset flag ⇒ `false` (telemetry enabled).
|
|
59
|
+
*/
|
|
60
|
+
export function isActivityDisabled(): boolean {
|
|
61
|
+
return primedDisabled === true || fromSyncEnv();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Prime the kill switch from the (async) Worker env, once per isolate. Best-effort:
|
|
66
|
+
* a read fault leaves telemetry ENABLED (fail-open). Call at the middleware front door
|
|
67
|
+
* so every subsequent route emit in the isolate observes a Worker-secret flag.
|
|
68
|
+
*/
|
|
69
|
+
export async function primeActivityKillSwitch(): Promise<void> {
|
|
70
|
+
try {
|
|
71
|
+
primedDisabled = truthy(await readEnv(ACTIVITY_KILL_SWITCH_FLAG));
|
|
72
|
+
} catch {
|
|
73
|
+
primedDisabled = false;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Test-only: clear the primed cache so a test can re-exercise the prime path. */
|
|
78
|
+
export function __resetActivityKillSwitchForTest(): void {
|
|
79
|
+
primedDisabled = undefined;
|
|
80
|
+
}
|