@tokenoftrust/storefront-runner 1.3.4-rc.4 → 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/astro.config.mjs +15 -0
- package/apps/storefront/dev-plugins/dev-publish.mjs +55 -0
- package/apps/storefront/dev-plugins/tenant-hot-reload.mjs +86 -7
- package/apps/storefront/drizzle.config.apps.ts +13 -0
- package/apps/storefront/env.d.ts +10 -0
- package/apps/storefront/migrations/README.md +13 -7
- package/apps/storefront/migrations-apps/0000_fast_millenium_guard.sql +129 -0
- package/apps/storefront/migrations-apps/0001_woozy_lyja.sql +22 -0
- package/apps/storefront/migrations-apps/meta/0000_snapshot.json +843 -0
- package/apps/storefront/migrations-apps/meta/0001_snapshot.json +990 -0
- package/apps/storefront/migrations-apps/meta/_journal.json +20 -0
- package/apps/storefront/package.json +12 -1
- package/apps/storefront/perf/README.md +64 -0
- package/apps/storefront/perf/assert-budgets.ts +159 -0
- package/apps/storefront/playwright.config.ts +23 -0
- package/apps/storefront/public/js/dashboard-apps.js +173 -0
- package/apps/storefront/public/shared/commerce-marketing.css +221 -0
- package/apps/storefront/src/components/CollectionCard.astro +1 -0
- package/apps/storefront/src/components/ProductCard.astro +1 -0
- package/apps/storefront/src/components/admin/AdminPublishTab.astro +3562 -0
- package/apps/storefront/src/components/apps/AppWidgetFrame.astro +30 -0
- package/apps/storefront/src/components/chrome/NavDropdown.astro +6 -3
- package/apps/storefront/src/components/chrome/SiteFooter.astro +10 -0
- package/apps/storefront/src/components/chrome/SiteHeader.astro +10 -0
- package/apps/storefront/src/components/commerce/RatingStars.astro +3 -2
- package/apps/storefront/src/components/content/Callout.astro +75 -0
- package/apps/storefront/src/components/content/NeedsReviewCallout.astro +66 -0
- package/apps/storefront/src/components/content/ProseSections.astro +121 -0
- package/apps/storefront/src/components/content/ProseToc.astro +34 -0
- package/apps/storefront/src/components/content/RichText.astro +44 -0
- package/apps/storefront/src/components/content/TrustStrip.astro +46 -0
- package/apps/storefront/src/components/home/Hero.astro +14 -0
- package/apps/storefront/src/components/islands/CheckoutComplianceGate.tsx +295 -0
- package/apps/storefront/src/components/islands/ImageGallery.tsx +42 -21
- package/apps/storefront/src/components/islands/VariantSelector.tsx +62 -12
- package/apps/storefront/src/components/plp/FacetSidebar.astro +2 -2
- package/apps/storefront/src/components/subscription/ManageSubscriptionEntry.astro +1 -0
- package/apps/storefront/src/config/compliance/rulesets.ts +79 -0
- package/apps/storefront/src/config/storeName.ts +34 -0
- package/apps/storefront/src/layouts/Layout.astro +97 -23
- 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/analytics/budgets.json +69 -0
- package/apps/storefront/src/lib/analytics/lighthouseReport.ts +109 -0
- package/apps/storefront/src/lib/analytics/perfBudgets.ts +452 -0
- package/apps/storefront/src/lib/analytics/rumAlert.ts +179 -0
- package/apps/storefront/src/lib/analytics/webVitals.ts +269 -0
- package/apps/storefront/src/lib/apps/adminService.ts +106 -0
- package/apps/storefront/src/lib/apps/adminSession.ts +205 -0
- package/apps/storefront/src/lib/apps/apiAuth.ts +91 -0
- package/apps/storefront/src/lib/apps/apiRoute.ts +35 -0
- package/apps/storefront/src/lib/apps/catalogMapper.ts +39 -0
- package/apps/storefront/src/lib/apps/credentials.ts +153 -0
- package/apps/storefront/src/lib/apps/gatewayKeys.ts +156 -0
- package/apps/storefront/src/lib/apps/healthAggregate.ts +66 -0
- package/apps/storefront/src/lib/apps/orders/attributionService.ts +206 -0
- package/apps/storefront/src/lib/apps/orders/customerHash.ts +28 -0
- package/apps/storefront/src/lib/apps/orders/foxyOrderClient.ts +203 -0
- package/apps/storefront/src/lib/apps/orders/idempotency.ts +100 -0
- package/apps/storefront/src/lib/apps/orders/orderForwardReceiver.ts +117 -0
- package/apps/storefront/src/lib/apps/orders/orderMapper.ts +91 -0
- package/apps/storefront/src/lib/apps/orders/ordersStore.ts +181 -0
- package/apps/storefront/src/lib/apps/registryService.ts +579 -0
- package/apps/storefront/src/lib/apps/scopes.ts +79 -0
- package/apps/storefront/src/lib/apps/tokenIssuer.ts +121 -0
- package/apps/storefront/src/lib/apps/tokenVerifier.ts +148 -0
- package/apps/storefront/src/lib/apps/widgets/eligibility.ts +18 -0
- package/apps/storefront/src/lib/apps/widgets/frameProps.ts +52 -0
- package/apps/storefront/src/lib/apps/widgets/launchToken.ts +84 -0
- package/apps/storefront/src/lib/apps/widgets/placements.ts +57 -0
- package/apps/storefront/src/lib/apps/widgets/renderSlot.ts +111 -0
- package/apps/storefront/src/lib/auth/adminEntry.ts +119 -0
- package/apps/storefront/src/lib/auth/identityToken.ts +21 -2
- package/apps/storefront/src/lib/auth/loginGate.ts +102 -18
- package/apps/storefront/src/lib/auth/mcpClientAssertion.ts +219 -0
- package/apps/storefront/src/lib/auth/route.ts +60 -5
- package/apps/storefront/src/lib/auth/session.ts +8 -0
- package/apps/storefront/src/lib/auth/stepUpChallenge.ts +107 -0
- package/apps/storefront/src/lib/auth/totAccessClient.ts +208 -0
- package/apps/storefront/src/lib/blog/provider.ts +39 -0
- package/apps/storefront/src/lib/blog/types.ts +26 -0
- package/apps/storefront/src/lib/checkoutCommerce.ts +39 -1
- package/apps/storefront/src/lib/chrome/model.ts +11 -0
- package/apps/storefront/src/lib/compliance/enforcement.ts +95 -0
- package/apps/storefront/src/lib/content/callout.ts +78 -0
- package/apps/storefront/src/lib/content/index.ts +27 -0
- package/apps/storefront/src/lib/content/needsReview.ts +58 -0
- package/apps/storefront/src/lib/content/prose.ts +186 -0
- package/apps/storefront/src/lib/content/richtext.ts +76 -0
- package/apps/storefront/src/lib/content/trustStrip.ts +74 -0
- package/apps/storefront/src/lib/content-edit/client.ts +70 -14
- package/apps/storefront/src/lib/d1/catalog.ts +12 -0
- package/apps/storefront/src/lib/d1/schema-apps.ts +267 -0
- package/apps/storefront/src/lib/dev/apiBase.ts +8 -2
- package/apps/storefront/src/lib/dev/cliSignInCode.ts +65 -115
- package/apps/storefront/src/lib/dev/cockpitStore.ts +65 -0
- package/apps/storefront/src/lib/dev/previewStatus.ts +112 -0
- package/apps/storefront/src/lib/dev/rendezvousBroker.ts +241 -0
- package/apps/storefront/src/lib/dev/subjectReissue.ts +67 -0
- package/apps/storefront/src/lib/dev/vcBinding.ts +80 -0
- package/apps/storefront/src/lib/email/magicLinkInviteEmail.ts +10 -10
- package/apps/storefront/src/lib/env.ts +63 -0
- package/apps/storefront/src/lib/jsonld.ts +12 -17
- package/apps/storefront/src/lib/membership/eligibility.ts +37 -0
- package/apps/storefront/src/lib/monitoring/manifest.ts +302 -0
- package/apps/storefront/src/lib/privacy/emailHint.ts +13 -5
- package/apps/storefront/src/lib/publish/apex-readiness.ts +337 -0
- package/apps/storefront/src/lib/publish/dispatchHealth.ts +269 -0
- package/apps/storefront/src/lib/publish/domainState.ts +351 -0
- package/apps/storefront/src/lib/publish/shipWorkspace.ts +369 -0
- package/apps/storefront/src/lib/rawChrome.ts +34 -3
- package/apps/storefront/src/lib/storyblok/content-model.ts +34 -2
- package/apps/storefront/src/lib/storyblok/provider.ts +11 -4
- package/apps/storefront/src/lib/subscription/model.ts +114 -0
- package/apps/storefront/src/lib/tot/ToTClient.ts +3 -3
- package/apps/storefront/src/lib/tot/query.ts +32 -0
- package/apps/storefront/src/lib/webhooks/cloudflareQueueDispatcher.ts +82 -0
- package/apps/storefront/src/lib/webhooks/deliveryEngine.ts +245 -0
- package/apps/storefront/src/lib/webhooks/deliveryMapper.ts +34 -0
- package/apps/storefront/src/lib/webhooks/deliveryStore.ts +668 -0
- package/apps/storefront/src/lib/webhooks/dispatcher.ts +168 -0
- package/apps/storefront/src/lib/webhooks/emit.ts +167 -0
- package/apps/storefront/src/lib/webhooks/endpointGuard.ts +135 -0
- package/apps/storefront/src/lib/webhooks/events.ts +98 -0
- package/apps/storefront/src/lib/webhooks/getDispatcher.ts +49 -0
- package/apps/storefront/src/middleware/index.ts +45 -13
- package/apps/storefront/src/pages/404.astro +21 -9
- package/apps/storefront/src/pages/[...slug].astro +36 -2
- package/apps/storefront/src/pages/admin/ops-timeline.astro +420 -0
- package/apps/storefront/src/pages/admin.astro +183 -9
- 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 +86 -0
- package/apps/storefront/src/pages/api/apps/admin/health.ts +44 -0
- package/apps/storefront/src/pages/api/apps/admin/install.ts +125 -0
- package/apps/storefront/src/pages/api/apps/admin/list.ts +26 -0
- package/apps/storefront/src/pages/api/apps/admin/resume.ts +79 -0
- package/apps/storefront/src/pages/api/apps/admin/suspend.ts +80 -0
- package/apps/storefront/src/pages/api/apps/admin/uninstall.ts +98 -0
- package/apps/storefront/src/pages/api/apps/admin/update.ts +126 -0
- package/apps/storefront/src/pages/api/apps/admin/webhooks/deliveries/[deliveryId]/replay.ts +70 -0
- package/apps/storefront/src/pages/api/apps/admin/webhooks/deliveries.ts +51 -0
- package/apps/storefront/src/pages/api/apps/internal/order-forward.ts +183 -0
- package/apps/storefront/src/pages/api/apps/oauth/token.ts +87 -0
- package/apps/storefront/src/pages/api/apps/v1/attribution.ts +162 -0
- package/apps/storefront/src/pages/api/apps/v1/catalog/products/[handle].ts +39 -0
- package/apps/storefront/src/pages/api/apps/v1/catalog/products.ts +49 -0
- package/apps/storefront/src/pages/api/apps/v1/health.ts +32 -0
- package/apps/storefront/src/pages/api/apps/v1/inventory.ts +59 -0
- package/apps/storefront/src/pages/api/apps/v1/orders/[id].ts +48 -0
- package/apps/storefront/src/pages/api/apps/v1/orders.ts +73 -0
- package/apps/storefront/src/pages/api/apps/v1/reports.ts +21 -0
- package/apps/storefront/src/pages/api/apps/v1/webhooks/deliveries/[deliveryId]/replay.ts +74 -0
- package/apps/storefront/src/pages/api/apps/v1/webhooks/deliveries.ts +47 -0
- package/apps/storefront/src/pages/api/auth/magic-exchange.ts +48 -1
- package/apps/storefront/src/pages/api/auth/step-up-send.ts +57 -0
- package/apps/storefront/src/pages/api/auth/step-up-verify.ts +129 -0
- package/apps/storefront/src/pages/api/auth/verify.ts +23 -0
- package/apps/storefront/src/pages/api/cache-purge.ts +11 -0
- package/apps/storefront/src/pages/api/compliance/preflight.ts +206 -0
- package/apps/storefront/src/pages/api/dashboard/enter-vendor.ts +30 -0
- package/apps/storefront/src/pages/api/rum/vitals.ts +54 -0
- package/apps/storefront/src/pages/api/test/dev-session.ts +133 -0
- package/apps/storefront/src/pages/auth/login.astro +202 -45
- package/apps/storefront/src/pages/auth/magic.astro +75 -43
- package/apps/storefront/src/pages/blog/[slug].astro +107 -0
- package/apps/storefront/src/pages/blog/index.astro +98 -0
- package/apps/storefront/src/pages/capabilities.astro +8 -0
- package/apps/storefront/src/pages/cockpit.astro +470 -69
- package/apps/storefront/src/pages/collections/[handle].astro +8 -0
- package/apps/storefront/src/pages/collections/index.astro +10 -2
- package/apps/storefront/src/pages/dashboard/[appDomain]/apps/index.astro +119 -0
- package/apps/storefront/src/pages/dashboard/[appDomain]/index.astro +5 -0
- package/apps/storefront/src/pages/index.astro +57 -0
- package/apps/storefront/src/pages/llms.txt.ts +31 -10
- package/apps/storefront/src/pages/products/[handle].astro +100 -9
- package/apps/storefront/src/pages/sitemap.xml.ts +21 -4
- package/apps/storefront/src/pages/style-guide/[tenant]/[theme].astro +198 -0
- package/apps/storefront/src/pages/style-guide/[tenant]/chrome/[theme].astro +7 -0
- package/apps/storefront/src/pages/style-guide/[tenant]/guide/[theme].astro +7 -0
- package/apps/storefront/src/pages/style-guide/[tenant]/index.astro +7 -0
- package/apps/storefront/src/pages/style-guide/index.astro +10 -0
- package/apps/storefront/src/styles/fonts.css +54 -0
- package/apps/storefront/src/styles/global.css +22 -2
- package/apps/storefront/src/themes/schema.ts +3 -25
- package/apps/storefront/tsconfig.json +1 -1
- package/apps/storefront/vitest.config.ts +4 -1
- 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 +324 -0
- package/packages/public-runtime/src/checkout.ts +94 -2
- package/packages/public-runtime/src/compliance/evaluate.ts +265 -0
- package/packages/public-runtime/src/compliance/evidence-signals.ts +81 -0
- package/packages/public-runtime/src/compliance/index.ts +22 -0
- package/packages/public-runtime/src/compliance/pact-report.ts +94 -0
- package/packages/public-runtime/src/compliance/profile.ts +198 -0
- package/packages/public-runtime/src/compliance/ruleset.ts +117 -0
- package/packages/public-runtime/src/compliance/verification.ts +81 -0
- package/packages/public-runtime/src/csp.ts +27 -3
- package/packages/public-runtime/src/customization-reconcile.ts +35 -0
- package/packages/public-runtime/src/customization-runtime.ts +8 -0
- package/packages/public-runtime/src/customization-versioning.ts +17 -0
- package/packages/public-runtime/src/extension-contract.ts +2 -1
- package/packages/public-runtime/src/hash.ts +25 -0
- package/packages/public-runtime/src/index.ts +12 -0
- package/packages/public-runtime/src/membership.ts +353 -0
- package/packages/public-runtime/src/product.ts +24 -2
- package/packages/public-runtime/src/review-trust-proof.ts +194 -0
- package/packages/public-runtime/src/tenant-assets.ts +40 -5
- package/packages/public-runtime/src/tenant.ts +236 -0
- package/packages/public-runtime/src/widget-postmessage.ts +205 -0
- package/scripts/dev/publish.mjs +158 -0
- package/scripts/dev/transient-files.mjs +2 -1
- package/tenants/home/public/fonts/inter-latin-400-normal.woff2 +0 -0
- package/tenants/home/public/fonts/inter-latin-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/inter-latin-600-normal.woff2 +0 -0
- package/tenants/home/public/fonts/inter-latin-ext-400-normal.woff2 +0 -0
- package/tenants/home/public/fonts/inter-latin-ext-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/inter-latin-ext-600-normal.woff2 +0 -0
- package/tenants/home/public/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
- package/tenants/home/public/fonts/jetbrains-mono-latin-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/jetbrains-mono-latin-ext-400-normal.woff2 +0 -0
- package/tenants/home/public/fonts/jetbrains-mono-latin-ext-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-600-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-700-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-800-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-600-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-700-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-800-normal.woff2 +0 -0
- package/tenants/home/public/pages/storefront.css +23 -0
- package/apps/storefront/src/lib/dev/hostedCockpit.ts +0 -169
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Owner-facing "who did this" attribution for a candidate change or aggregate —
|
|
3
|
+
* DELIBERATELY separate from the anonymized activity spine. `ActivityActor.id`
|
|
4
|
+
* is a salted, non-reversible hash by design (see `recordActivity.ts`'s header:
|
|
5
|
+
* a raw PII id in the core zone would defeat the D0 contract), so it can never
|
|
6
|
+
* answer "who submitted/accepted/rejected/shipped this" for an owner reviewing
|
|
7
|
+
* `/admin#publish`. This persists the real email (never joined into the general
|
|
8
|
+
* `queryActivity()` surface, never cross-tenant) alongside the existing hashed
|
|
9
|
+
* event, so the two concerns stay separate: analytics stays anonymized, and
|
|
10
|
+
* this stays a narrow, owner-only-readable accountability record.
|
|
11
|
+
*
|
|
12
|
+
* `sub` (the tot20-issued, stable user id) would be a better key than email —
|
|
13
|
+
* email is a mutable account attribute, not the identity itself — but `sub`
|
|
14
|
+
* is dropped before `SessionRecord` today (see `auth/session.ts`). Threading
|
|
15
|
+
* it through is a real follow-up, not done here; email is what every call site
|
|
16
|
+
* already has.
|
|
17
|
+
*/
|
|
18
|
+
import { displayNameFromEmail } from "./recordActivity";
|
|
19
|
+
|
|
20
|
+
export type ChangeActorAction = "submit" | "reject" | "accept" | "ship" | "build";
|
|
21
|
+
|
|
22
|
+
export interface ChangeActorRecord {
|
|
23
|
+
email: string;
|
|
24
|
+
/** Best-effort display name — no real name is captured anywhere today, so
|
|
25
|
+
* this is always {@link displayNameFromEmail}'s local-part guess. Swap the
|
|
26
|
+
* source here (not the callers) if a real name ever becomes available. */
|
|
27
|
+
name: string;
|
|
28
|
+
at: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ChangeActorKv {
|
|
32
|
+
get(key: string): Promise<string | null>;
|
|
33
|
+
put(key: string, value: string): Promise<void>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function attributionKey(tenantId: string, subjectId: string, action: ChangeActorAction): string {
|
|
37
|
+
return `cust:change-actor:${tenantId}:${subjectId}:${action}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Persist who took `action` on `subjectId` (a `changeId` for reject/accept/build,
|
|
42
|
+
* or the aggregate id for ship). Best-effort — never blocks or fails the
|
|
43
|
+
* mutation it describes; a write fault is swallowed, mirroring `recordActivity`.
|
|
44
|
+
*/
|
|
45
|
+
export async function recordChangeActor(
|
|
46
|
+
kv: ChangeActorKv,
|
|
47
|
+
tenantId: string,
|
|
48
|
+
subjectId: string,
|
|
49
|
+
action: ChangeActorAction,
|
|
50
|
+
email: string,
|
|
51
|
+
): Promise<void> {
|
|
52
|
+
try {
|
|
53
|
+
const record: ChangeActorRecord = {
|
|
54
|
+
email,
|
|
55
|
+
name: displayNameFromEmail(email),
|
|
56
|
+
at: new Date().toISOString(),
|
|
57
|
+
};
|
|
58
|
+
await kv.put(attributionKey(tenantId, subjectId, action), JSON.stringify(record));
|
|
59
|
+
} catch {
|
|
60
|
+
/* best-effort — the mutation it describes already succeeded */
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Read every recorded action's attribution for one subject. Best-effort per
|
|
65
|
+
* action: a missing/corrupt record is simply absent from the result, never
|
|
66
|
+
* a thrown error. */
|
|
67
|
+
export async function readChangeActors(
|
|
68
|
+
kv: ChangeActorKv,
|
|
69
|
+
tenantId: string,
|
|
70
|
+
subjectId: string,
|
|
71
|
+
): Promise<Partial<Record<ChangeActorAction, ChangeActorRecord>>> {
|
|
72
|
+
const actions: ChangeActorAction[] = ["submit", "reject", "accept", "ship", "build"];
|
|
73
|
+
const out: Partial<Record<ChangeActorAction, ChangeActorRecord>> = {};
|
|
74
|
+
await Promise.all(
|
|
75
|
+
actions.map(async (action) => {
|
|
76
|
+
try {
|
|
77
|
+
const raw = await kv.get(attributionKey(tenantId, subjectId, action));
|
|
78
|
+
if (raw) out[action] = JSON.parse(raw) as ChangeActorRecord;
|
|
79
|
+
} catch {
|
|
80
|
+
/* a corrupt/missing record just isn't shown */
|
|
81
|
+
}
|
|
82
|
+
}),
|
|
83
|
+
);
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* deploy.version_observed — the deploy-provenance / deploy-skew signal (card D2).
|
|
3
|
+
*
|
|
4
|
+
* "A deployed worker version/commit was observed." This is the DERIVED signal D5's
|
|
5
|
+
* query surface reads to answer *deploy skew* — is the build serving traffic the
|
|
6
|
+
* pushed umbrella tip, or is a stale isolate still live? D2's job is just to make
|
|
7
|
+
* `worker_commit` reliably present on server events and to EMIT this observation at
|
|
8
|
+
* least once per running build; the actual "== umbrella tip?" comparison and the
|
|
9
|
+
* timeline query are D5's (this module intentionally does no comparison — it has no
|
|
10
|
+
* notion of what the tip is, only what THIS build is).
|
|
11
|
+
*
|
|
12
|
+
* WHAT IT PRODUCES. A catalog `deploy.version_observed` event (ACTION_CATALOG:
|
|
13
|
+
* actors [system|dev], source server, sinks logs+analytics+timeline). The FULL
|
|
14
|
+
* 40-char commit rides `scope.workerCommit` — the CORE zone, which createActivityEvent
|
|
15
|
+
* never redacts — because that is where the authoritative, unscrubbed sha belongs.
|
|
16
|
+
*
|
|
17
|
+
* DELIBERATELY the full sha does NOT go in `payload`. The action's argsAllow names
|
|
18
|
+
* `commit`, but a 40-char hex sha trips D0's `high_entropy` VALUE canary (redaction.ts:
|
|
19
|
+
* ≥28 chars of base64url) — it would be scrubbed to «redacted» AND set
|
|
20
|
+
* `report.canaryTripped`, which D6/D7 alert on, firing a FALSE PII alarm on every cold
|
|
21
|
+
* start. So payload carries only the SHORT `version` (7 chars, safe) plus `env` +
|
|
22
|
+
* `observedFrom`; a reader wanting the full sha reads scope.workerCommit (a git sha is
|
|
23
|
+
* public, so the core zone is its correct, alert-free home). See the D2 report note for
|
|
24
|
+
* D5/D8: treat scope.workerCommit as the authoritative provenance, payload.version as a
|
|
25
|
+
* human-eyeball convenience.
|
|
26
|
+
*
|
|
27
|
+
* WHERE IT'S EMITTED. observeDeployVersion() fires ONCE PER WORKER ISOLATE (guarded
|
|
28
|
+
* by a module-level flag) from the middleware's front door — a cold start emits one
|
|
29
|
+
* observation, not one per request, so the stream is low-volume and honest. Until
|
|
30
|
+
* D1's recordActivity()/D4's ingest+store land, the emission goes to the universal
|
|
31
|
+
* `logs` sink (structured `[activity]` JSON to Workers Logs — the same pipeline the
|
|
32
|
+
* interim recordActivity.ts uses, already provisioned via `[observability]`). When
|
|
33
|
+
* D1's recordActivity() exists, swap `emitToLogs` for it (it will fan out to the
|
|
34
|
+
* analytics+timeline sinks the action declares); the EVENT this module builds is
|
|
35
|
+
* unchanged.
|
|
36
|
+
*/
|
|
37
|
+
import { createActivityEvent } from "@tot/public-runtime";
|
|
38
|
+
import type { ActivityEvent, RedactionReport } from "@tot/public-runtime";
|
|
39
|
+
import { workerCommit, workerCommitShort, UNKNOWN_COMMIT } from "./workerCommit.js";
|
|
40
|
+
import { isActivityDisabled } from "./killSwitch.js";
|
|
41
|
+
|
|
42
|
+
export interface DeployVersionObservedInput {
|
|
43
|
+
/** Deploy env/target name (e.g. "preview", "production"), when known. Optional. */
|
|
44
|
+
env?: string;
|
|
45
|
+
/** What surfaced the observation (e.g. "startup", "health"). Defaults "startup". */
|
|
46
|
+
observedFrom?: string;
|
|
47
|
+
/** Opaque actor id. Defaults "system" (the platform observed its own build). */
|
|
48
|
+
actorId?: string;
|
|
49
|
+
/** Override the build commit — tests only. Real callers omit (reads __GIT_SHA__). */
|
|
50
|
+
commit?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Build (do not emit) the `deploy.version_observed` ActivityEvent for the running
|
|
55
|
+
* build. Pure + side-effect-free — the piece D5 ultimately consumes and the piece
|
|
56
|
+
* tests assert against. Returns the safe-constructed event plus its redaction report.
|
|
57
|
+
*/
|
|
58
|
+
export function buildDeployVersionObservedEvent(
|
|
59
|
+
input: DeployVersionObservedInput = {},
|
|
60
|
+
): { event: ActivityEvent; report: RedactionReport } {
|
|
61
|
+
const commit = workerCommit(input.commit);
|
|
62
|
+
const version = workerCommitShort(input.commit);
|
|
63
|
+
return createActivityEvent({
|
|
64
|
+
action: "deploy.version_observed",
|
|
65
|
+
actor: { kind: "system", id: input.actorId ?? "system" },
|
|
66
|
+
source: "server",
|
|
67
|
+
outcome: { status: "succeeded" },
|
|
68
|
+
// worker_commit (full sha) in the CORE zone — never redacted; the authoritative
|
|
69
|
+
// provenance every server event carries and D5 derives skew from.
|
|
70
|
+
scope: commit !== UNKNOWN_COMMIT ? { workerCommit: commit } : {},
|
|
71
|
+
// Payload carries the SHORT version (safe from the high_entropy canary that would
|
|
72
|
+
// scrub a full sha) + env + observedFrom. Unknown builds omit `version` rather than
|
|
73
|
+
// store the sentinel, but still emit the observation.
|
|
74
|
+
payload: {
|
|
75
|
+
args: {
|
|
76
|
+
...(commit !== UNKNOWN_COMMIT ? { version } : {}),
|
|
77
|
+
...(input.env ? { env: input.env } : {}),
|
|
78
|
+
observedFrom: input.observedFrom ?? "startup",
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Serialize an ActivityEvent to the universal `logs` sink (Workers Logs). Interim
|
|
86
|
+
* transport until D1's recordActivity() fans out to all of the action's sinks;
|
|
87
|
+
* mirrors the `[activity]` tag the existing recordActivity.ts slice already emits so
|
|
88
|
+
* one grep finds both. Best-effort, never throws.
|
|
89
|
+
*/
|
|
90
|
+
function emitToLogs(event: ActivityEvent): void {
|
|
91
|
+
try {
|
|
92
|
+
console.log(`[activity] ${JSON.stringify(event)}`);
|
|
93
|
+
} catch {
|
|
94
|
+
// best-effort only — telemetry must never break a request
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Per-isolate guard: emit the observation once per cold start, not per request. */
|
|
99
|
+
let observedThisIsolate = false;
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Emit `deploy.version_observed` ONCE for this worker isolate. Idempotent within an
|
|
103
|
+
* isolate (subsequent calls are no-ops), best-effort, never throws — safe to call on
|
|
104
|
+
* every request from the middleware front door. Returns the event it emitted (or
|
|
105
|
+
* `undefined` if it already fired this isolate) so callers/tests can inspect it.
|
|
106
|
+
*/
|
|
107
|
+
export function observeDeployVersion(
|
|
108
|
+
input: DeployVersionObservedInput = {},
|
|
109
|
+
): ActivityEvent | undefined {
|
|
110
|
+
if (observedThisIsolate) return undefined;
|
|
111
|
+
// Kill switch (D8): telemetry off ⇒ skip the observation entirely (fail-open). Do
|
|
112
|
+
// NOT set the per-isolate guard, so re-enabling emits the observation on the next call.
|
|
113
|
+
if (isActivityDisabled()) return undefined;
|
|
114
|
+
observedThisIsolate = true;
|
|
115
|
+
try {
|
|
116
|
+
const { event } = buildDeployVersionObservedEvent(input);
|
|
117
|
+
emitToLogs(event);
|
|
118
|
+
return event;
|
|
119
|
+
} catch {
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Test-only: reset the per-isolate guard so a test can re-exercise the first-fire path. */
|
|
125
|
+
export function __resetDeployVersionObservedForTest(): void {
|
|
126
|
+
observedThisIsolate = false;
|
|
127
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ingest validation + server-side redaction for `POST /api/activity` (card D4).
|
|
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 (CLI cohort D3, UI
|
|
6
|
+
* cohort D6 — 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 (D8's future attack surface). 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 D6/D7 alert on. */
|
|
65
|
+
canaryTripped: boolean;
|
|
66
|
+
/** The redaction hits (fact-of, never the value) for D8 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 D5 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 (D8 hardening). 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` (card D4) — 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 (D8).
|
|
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()` (D1 server emitter) — and therefore `recordUiActivity()`
|
|
7
|
+
* (D6 UI cohort), which is a thin wrapper over it;
|
|
8
|
+
* • `observeDeployVersion()` (D2 deploy-provenance observer);
|
|
9
|
+
* • `POST /api/activity` (D4 ingest) — accepts-and-drops so emitters never error.
|
|
10
|
+
* The out-of-process CLI (`emitActivity`, D3) 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
|
+
}
|