@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,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. */
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* D1 (edge SQLite) catalog read-model — schema + row projections (the one-store
|
|
3
|
-
* target
|
|
3
|
+
* target). The storefront reads search, faceted
|
|
4
4
|
* listing, and full products from D1; the ETL emits the rows. This is the shared
|
|
5
5
|
* contract both sides agree on.
|
|
6
6
|
*
|
|
@@ -142,6 +142,8 @@ export interface EnvResolutionInput {
|
|
|
142
142
|
/** Platform preview-signing secret (env), if configured. */
|
|
143
143
|
secret?: string | null;
|
|
144
144
|
nowSeconds: number;
|
|
145
|
+
/** Request pathname — used only to exempt the token dispenser itself (see {@link isPreviewDispatcherPath}). */
|
|
146
|
+
pathname?: string;
|
|
145
147
|
}
|
|
146
148
|
|
|
147
149
|
export interface EnvResolution {
|
|
@@ -152,16 +154,37 @@ export interface EnvResolution {
|
|
|
152
154
|
reject?: { status: number; message: string };
|
|
153
155
|
}
|
|
154
156
|
|
|
157
|
+
/**
|
|
158
|
+
* The bare tenant-preview DISPATCHER path (`/<tenant>/preview` or
|
|
159
|
+
* `/<tenant>/preview/`, e.g. `pages/preview/index.astro`, tenant-first) — the page
|
|
160
|
+
* whose ENTIRE job is to mint a fresh `__preview` token and hand it to the
|
|
161
|
+
* visitor. On a preview-prefixed host this path would otherwise hit the SAME
|
|
162
|
+
* "no token -> 401" gate `resolveCustomizationEnv` enforces for every other
|
|
163
|
+
* path, deadlocking a signed-in-but-token-less visitor: they can never reach
|
|
164
|
+
* the one page that gives them a token. Distinct from `isCandidatePreviewPath`
|
|
165
|
+
* (loginGate.ts) — that exempts several preview routes from the LOGIN wall; this
|
|
166
|
+
* exempts exactly ONE route from the TOKEN wall. `<tenant>` is required to look
|
|
167
|
+
* like a real tenant id (contains a `.`) so this never accidentally matches a
|
|
168
|
+
* sibling route. Matched on the pre-strip request path.
|
|
169
|
+
*/
|
|
170
|
+
export function isPreviewDispatcherPath(pathname: string): boolean {
|
|
171
|
+
const m = pathname.match(/^\/([^/]+)\/preview\/?$/);
|
|
172
|
+
return m != null && m[1]!.includes(".");
|
|
173
|
+
}
|
|
174
|
+
|
|
155
175
|
/**
|
|
156
176
|
* Decide which customization environment to serve. Production hosts always get
|
|
157
177
|
* `live` and never consult a token. Preview hosts are auth-gated: a valid,
|
|
158
178
|
* unexpired, tenant-matching token yields `test` (noindex); anything else is
|
|
159
|
-
* rejected.
|
|
179
|
+
* rejected. The one exception is the token-dispenser path itself (see
|
|
180
|
+
* {@link isPreviewDispatcherPath}) — it renders the dispatcher's own minimal
|
|
181
|
+
* page, never tenant content, so it's let through as `live`/no-token rather
|
|
182
|
+
* than 401ing on the very request that would otherwise fetch it a token.
|
|
160
183
|
*/
|
|
161
184
|
export async function resolveCustomizationEnv(
|
|
162
185
|
input: EnvResolutionInput,
|
|
163
186
|
): Promise<EnvResolution> {
|
|
164
|
-
if (!input.isPreviewHost) {
|
|
187
|
+
if (!input.isPreviewHost || isPreviewDispatcherPath(input.pathname ?? "")) {
|
|
165
188
|
return { env: "live", noindex: false };
|
|
166
189
|
}
|
|
167
190
|
if (!input.secret) {
|