@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
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
type ChannelName,
|
|
16
16
|
type PointerRecord,
|
|
17
17
|
type SiteVersionManifest,
|
|
18
|
+
type SiteVersionSummary,
|
|
18
19
|
} from "./customization-versioning.js";
|
|
19
20
|
import type { CustomizationEnv } from "./customization-preview.js";
|
|
20
21
|
|
|
@@ -93,6 +94,27 @@ export async function resolveChannelVersion(
|
|
|
93
94
|
return pointer?.channels?.[channel] ?? null;
|
|
94
95
|
}
|
|
95
96
|
|
|
97
|
+
/**
|
|
98
|
+
* The static-bundle `treeDigest` a channel currently serves (ADR 0012), or null
|
|
99
|
+
* when the tenant has no digest companion for that channel (a hosted tenant — it
|
|
100
|
+
* sets only `channels`, never `channelDigests`).
|
|
101
|
+
*
|
|
102
|
+
* This is the RECONCILED read the digest-addressed serving path
|
|
103
|
+
* (`static-bundle.ts`), the pointer verify (`createPointerVerifyTarget`), and
|
|
104
|
+
* the pointer-served "current" read (`resolveStaticLiveDigestFromPointer`) use
|
|
105
|
+
* INSTEAD of `resolveChannelVersion` for a static tenant: the versionId
|
|
106
|
+
* (`channels[ch]`, the Gitea sha) is NOT the R2 key — the treeDigest is, and it
|
|
107
|
+
* lives here. Reads the pointer fresh; never throws.
|
|
108
|
+
*/
|
|
109
|
+
export async function resolveChannelDigest(
|
|
110
|
+
get: KvGet,
|
|
111
|
+
tenantId: string,
|
|
112
|
+
channel: ChannelName,
|
|
113
|
+
): Promise<string | null> {
|
|
114
|
+
const pointer = await readPointer(get, tenantId);
|
|
115
|
+
return pointer?.channelDigests?.[channel] ?? null;
|
|
116
|
+
}
|
|
117
|
+
|
|
96
118
|
/** Read one immutable site-version manifest, or null. Never throws. */
|
|
97
119
|
export async function readSiteVersion(
|
|
98
120
|
get: KvGet,
|
|
@@ -108,12 +130,112 @@ export async function readSiteVersion(
|
|
|
108
130
|
}
|
|
109
131
|
}
|
|
110
132
|
|
|
133
|
+
/** A manifest is BUILT (servable) when it exists AND materialized ≥1 artifact. */
|
|
134
|
+
function isBuiltManifest(m: SiteVersionManifest | null): m is SiteVersionManifest {
|
|
135
|
+
return !!m && Object.keys(m.artifacts ?? {}).length > 0;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Read a tenant's version-history index (list of summaries), NEWEST first — the
|
|
140
|
+
* runtime read-side view of the same `siteVersionIndexKey` the KV store appends
|
|
141
|
+
* to (stored oldest-first). Never throws — malformed/absent → `[]`.
|
|
142
|
+
*/
|
|
143
|
+
export async function readSiteVersionIndex(
|
|
144
|
+
get: KvGet,
|
|
145
|
+
tenantId: string,
|
|
146
|
+
): Promise<SiteVersionSummary[]> {
|
|
147
|
+
const raw = await get(siteVersionIndexKey(tenantId));
|
|
148
|
+
if (!raw) return [];
|
|
149
|
+
try {
|
|
150
|
+
return (JSON.parse(raw) as SiteVersionSummary[]).slice().reverse();
|
|
151
|
+
} catch {
|
|
152
|
+
return [];
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The outcome of self-healing a channel pointer to a SERVABLE version. */
|
|
157
|
+
export interface ServableChannelVersion {
|
|
158
|
+
/**
|
|
159
|
+
* The versionId to actually SERVE from: the pointed version when it is built,
|
|
160
|
+
* else the newest BUILT version in history (self-heal), else `null` (nothing
|
|
161
|
+
* built at all — the caller falls back to build-time, still never a bare 404).
|
|
162
|
+
*/
|
|
163
|
+
versionId: string | null;
|
|
164
|
+
/** The versionId the channel pointer NAMES (may dangle at an unbuilt version). */
|
|
165
|
+
pointedVersionId: string | null;
|
|
166
|
+
/**
|
|
167
|
+
* True when the pointer named a version with no built manifest — so the read
|
|
168
|
+
* was self-healed onto an older built version (or found none). Signals the
|
|
169
|
+
* caller to enqueue a rebuild of the pointed version.
|
|
170
|
+
*/
|
|
171
|
+
dangling: boolean;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Optional seam so a self-healing read can enqueue a rebuild of the dangling
|
|
175
|
+
* version. Best-effort/fail-open: never awaited into the serve path, never
|
|
176
|
+
* throws through it. Absent ⇒ pure resolution (no side effect). */
|
|
177
|
+
export type EnqueueRebuild = (info: {
|
|
178
|
+
tenantId: string;
|
|
179
|
+
channel: ChannelName;
|
|
180
|
+
versionId: string;
|
|
181
|
+
}) => void;
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Resolve the SERVABLE version for a channel, self-healing a dangling pointer.
|
|
185
|
+
*
|
|
186
|
+
* A channel pointer can name a version that was never built (or whose manifest
|
|
187
|
+
* was evicted). Serving that naively empties/404s the whole surface. Instead: if
|
|
188
|
+
* the pointed version has no built manifest, fall back to the newest version in
|
|
189
|
+
* history that DOES, and flag `dangling` so the caller can enqueue a rebuild.
|
|
190
|
+
* Only kicks in when the pointed
|
|
191
|
+
* manifest is entirely absent/unbuilt — a present manifest that merely omits one
|
|
192
|
+
* path stays authoritative (unchanged behavior). Reads fresh; never throws.
|
|
193
|
+
*/
|
|
194
|
+
export async function resolveServableChannelVersion(
|
|
195
|
+
get: KvGet,
|
|
196
|
+
tenantId: string,
|
|
197
|
+
channel: ChannelName,
|
|
198
|
+
enqueueRebuild?: EnqueueRebuild,
|
|
199
|
+
): Promise<ServableChannelVersion> {
|
|
200
|
+
const pointedVersionId = await resolveChannelVersion(get, tenantId, channel);
|
|
201
|
+
if (!pointedVersionId) {
|
|
202
|
+
return { versionId: null, pointedVersionId: null, dangling: false };
|
|
203
|
+
}
|
|
204
|
+
const pointed = await readSiteVersion(get, tenantId, pointedVersionId);
|
|
205
|
+
if (isBuiltManifest(pointed)) {
|
|
206
|
+
return { versionId: pointedVersionId, pointedVersionId, dangling: false };
|
|
207
|
+
}
|
|
208
|
+
// Dangling: the pointer names an unbuilt/absent version. Self-heal onto the
|
|
209
|
+
// newest BUILT version in history so the surface keeps rendering last-good.
|
|
210
|
+
let healed: string | null = null;
|
|
211
|
+
for (const summary of await readSiteVersionIndex(get, tenantId)) {
|
|
212
|
+
if (summary.versionId === pointedVersionId) continue;
|
|
213
|
+
if (isBuiltManifest(await readSiteVersion(get, tenantId, summary.versionId))) {
|
|
214
|
+
healed = summary.versionId;
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
if (enqueueRebuild) {
|
|
219
|
+
try {
|
|
220
|
+
enqueueRebuild({ tenantId, channel, versionId: pointedVersionId });
|
|
221
|
+
} catch {
|
|
222
|
+
/* fail-open: enqueue is best-effort, never breaks the read */
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return { versionId: healed, pointedVersionId, dangling: true };
|
|
226
|
+
}
|
|
227
|
+
|
|
111
228
|
/**
|
|
112
229
|
* Resolve a single artifact's content via the VERSIONED path:
|
|
113
230
|
* channel → versionId → site-version manifest → content hash → bytes. Returns
|
|
114
231
|
* null (so the caller falls back to build-time) when the tenant has no channel
|
|
115
|
-
* pointer, the
|
|
116
|
-
*
|
|
232
|
+
* pointer, the path isn't in the snapshot, or the content is gone. Never throws.
|
|
233
|
+
*
|
|
234
|
+
* Self-healing (u3): when the channel pointer DANGLES (names an unbuilt/absent
|
|
235
|
+
* version), the artifact is read from the newest built version instead of
|
|
236
|
+
* returning null for every path — so a dangling `preview`/`live` pointer serves
|
|
237
|
+
* the last built site rather than 404ing the whole surface. See
|
|
238
|
+
* {@link resolveServableChannelVersion}.
|
|
117
239
|
*/
|
|
118
240
|
export async function readVersionedArtifact(
|
|
119
241
|
get: KvGet,
|
|
@@ -121,7 +243,7 @@ export async function readVersionedArtifact(
|
|
|
121
243
|
channel: ChannelName,
|
|
122
244
|
path: string,
|
|
123
245
|
): Promise<string | null> {
|
|
124
|
-
const versionId = await
|
|
246
|
+
const { versionId } = await resolveServableChannelVersion(get, tenantId, channel);
|
|
125
247
|
if (!versionId) return null;
|
|
126
248
|
const manifest = await readSiteVersion(get, tenantId, versionId);
|
|
127
249
|
const contentHash = manifest?.artifacts?.[path];
|
|
@@ -37,6 +37,15 @@ export type ChannelPointers = Partial<Record<ChannelName, string>>;
|
|
|
37
37
|
* - `channels`: the versioned-publish channel pointers (`live`/`preview` →
|
|
38
38
|
* a single site `versionId`). Additive + optional; when present it is
|
|
39
39
|
* authoritative for that channel and shadows the legacy selection.
|
|
40
|
+
* - `channelDigests`: (ADR 0012) the RECONCILED static-serving companion to
|
|
41
|
+
* `channels`. For a static-publish tenant, `channels[ch]` still holds the site
|
|
42
|
+
* `versionId` (the tenant Gitea commit sha — the hosted contract), while
|
|
43
|
+
* `channelDigests[ch]` holds that same publish's bundle `treeDigest` (the
|
|
44
|
+
* content-addressed R2 key prefix `versions/<digest>/` `static-bundle.ts`
|
|
45
|
+
* reads). The two are DISTINCT namespaces — the Gitea sha is NOT the treeDigest
|
|
46
|
+
* — and are written in the SAME CAS pointer write (`setChannel`), so they can
|
|
47
|
+
* never drift. Absent for a hosted tenant (which only ever sets/reads
|
|
48
|
+
* `channels`).
|
|
40
49
|
* - `rev`: optimistic-concurrency token, incremented on every successful write.
|
|
41
50
|
*/
|
|
42
51
|
export interface PointerRecord {
|
|
@@ -45,6 +54,7 @@ export interface PointerRecord {
|
|
|
45
54
|
live: Selection;
|
|
46
55
|
test: Selection;
|
|
47
56
|
channels?: ChannelPointers;
|
|
57
|
+
channelDigests?: ChannelPointers;
|
|
48
58
|
}
|
|
49
59
|
|
|
50
60
|
export function emptyPointer(tenantId: string): PointerRecord {
|
|
@@ -36,7 +36,14 @@ export * from "./raw-html-policy.js";
|
|
|
36
36
|
export * from "./review-trust-proof.js";
|
|
37
37
|
export * from "./compliance/index.js";
|
|
38
38
|
export * from "./tenant-assets.js";
|
|
39
|
+
export * from "./static-bundle.js";
|
|
39
40
|
export * from "./binary-path.js";
|
|
40
41
|
export * from "./hot-standby-readiness.js";
|
|
41
42
|
export * from "./hash.js";
|
|
42
43
|
export * from "./widget-postmessage.js";
|
|
44
|
+
|
|
45
|
+
// Operational activity telemetry contract (card D0): the actor×action
|
|
46
|
+
// ActivityEvent envelope, the allowlisted action catalog, and the default-deny
|
|
47
|
+
// redaction contract. Pure types + pure functions; the store decision + cost
|
|
48
|
+
// model live in the repo-root DECISIONS.md §8. See ./activity/README.md.
|
|
49
|
+
export * from "./activity/index.js";
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Digest-addressed static-bundle serving (T2, ADR 0012 —
|
|
3
|
+
* `docs/architecture/decisions/0012-instant-static-rollback-pointer-substrate.md`),
|
|
4
|
+
* T6-aware (per-file content-addressed asset dedup,
|
|
5
|
+
* `docs/epics/proposed/storefront-instant-static-rollback.md` Addendum §A).
|
|
6
|
+
*
|
|
7
|
+
* Reads an already-materialized, already-staged static bundle
|
|
8
|
+
* (`scripts/publish/materialize-tenant.mjs` output, staged by
|
|
9
|
+
* `scripts/publish/stage-bundle.mjs` to `versions/<treeDigest>/…` PLUS the
|
|
10
|
+
* shared `assets/<sha256>` pool, T6) straight out of R2 and serves the bytes
|
|
11
|
+
* VERBATIM — no re-render, no per-request CSP-nonce middleware (the bundle
|
|
12
|
+
* carries one pinned nonce for its whole tree; see the ADR's "Consequences"
|
|
13
|
+
* section). This is the read half: the write path (what PUTs a bundle into this
|
|
14
|
+
* R2 bucket) is `scripts/publish/lib/r2-runner.mjs`, which stages every
|
|
15
|
+
* `versions/<digest>/` bundle at candidate-prebuild time. Every static-publish
|
|
16
|
+
* tenant's production reads are served through here.
|
|
17
|
+
*
|
|
18
|
+
* DIGEST CONTRACT (RECONCILED, T14 — ADR 0012): the caller resolves "what to
|
|
19
|
+
* serve" via `resolveChannelDigest(get, tenantId, "live")` — the `channelDigests`
|
|
20
|
+
* companion to the `live` channel pointer, NOT `resolveChannelVersion` (which
|
|
21
|
+
* returns the versionId / Gitea sha, a DIFFERENT namespace than the treeDigest).
|
|
22
|
+
* `gatedPromote` (go-live) and `rollbackCandidateAtomic` (rollback) write the
|
|
23
|
+
* staged bundle's real `treeDigest` to `channelDigests.live` in the SAME CAS flip
|
|
24
|
+
* that moves the versionId, so this module gets the exact R2 key prefix
|
|
25
|
+
* `versions/<treeDigest>/`. A static tenant whose `live` has a versionId but no
|
|
26
|
+
* digest companion yet resolves to `null` here and fails closed (see below)
|
|
27
|
+
* rather than serving the wrong bytes under the versionId.
|
|
28
|
+
*
|
|
29
|
+
* T6 KEY RESOLUTION (`kind: "route"` vs `kind: "asset"`, `scripts/publish/lib/
|
|
30
|
+
* asset-pool.mjs`'s classification, carried verbatim in `manifest.files`): the
|
|
31
|
+
* manifest is now read on EVERY request (not just HTML, as before T6) because
|
|
32
|
+
* it's the ONLY source of truth for which R2 key a bundle-relative path
|
|
33
|
+
* resolves to — `kind: "route"` files still live at `versions/<digest>/<rel>`
|
|
34
|
+
* (rendered fresh per version), `kind: "asset"` files live at the SHARED,
|
|
35
|
+
* content-addressed `assets/<sha256><ext>` pool (`staticBundleAssetPoolKey`,
|
|
36
|
+
* kept in lockstep with `scripts/publish/lib/asset-pool.mjs`'s `assetPoolKey`
|
|
37
|
+
* — that module can't be imported here, this is the Worker bundle, that one
|
|
38
|
+
* runs under plain node). A `rel` absent from `manifest.files` (a genuinely
|
|
39
|
+
* unexpected path, or a pre-T6 manifest with no `files` classification) falls
|
|
40
|
+
* back to the OLD `versions/<digest>/<rel>` lookup — fail-closed on a miss
|
|
41
|
+
* either way, never a guess.
|
|
42
|
+
*/
|
|
43
|
+
import { assetContentType } from "./tenant-assets.js";
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// R2 seam — same structural subset tenant-assets.ts uses (no workers-types dep)
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
export interface StaticBundleR2ObjectLike {
|
|
50
|
+
body: ReadableStream<Uint8Array> | null;
|
|
51
|
+
arrayBuffer(): Promise<ArrayBuffer>;
|
|
52
|
+
size?: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface StaticBundleR2BucketLike {
|
|
56
|
+
get(key: string): Promise<StaticBundleR2ObjectLike | null>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// Path mapping — request pathname -> bundle-relative object path
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Reject empty, absolute-outside-bundle, or traversal-bearing relative paths
|
|
65
|
+
* (mirrors `tenant-assets.ts`'s `isSafeRel`, duplicated here to keep this
|
|
66
|
+
* module import-independent of the assets pointer model).
|
|
67
|
+
*/
|
|
68
|
+
function isSafeRel(rel: string): boolean {
|
|
69
|
+
if (!rel) return false;
|
|
70
|
+
return !rel.split("/").some((seg) => seg === ".." || seg === ".");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Map a request pathname to the bundle-relative file the materializer wrote
|
|
75
|
+
* for it — the SAME transform `scripts/publish/lib/materialize.mjs`'s
|
|
76
|
+
* `bundlePathForRoute` (routes) and `assetFilePath` (assets) apply, ported
|
|
77
|
+
* here as one pure function since `scripts/` is not importable from the
|
|
78
|
+
* Worker bundle:
|
|
79
|
+
* "/" -> "index.html"
|
|
80
|
+
* "/about" -> "about/index.html" (directory-index for pretty URLs)
|
|
81
|
+
* "/a/b/" -> "a/b/index.html"
|
|
82
|
+
* "/sitemap.xml" -> "sitemap.xml" (a real file extension: as-is)
|
|
83
|
+
* "/_astro/x.js" -> "_astro/x.js" (asset: as-is)
|
|
84
|
+
* Returns null for an unsafe path (traversal, decode failure).
|
|
85
|
+
*/
|
|
86
|
+
export function staticBundleRelPath(pathname: string): string | null {
|
|
87
|
+
let decoded: string;
|
|
88
|
+
try {
|
|
89
|
+
decoded = decodeURIComponent(pathname);
|
|
90
|
+
} catch {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
const clean = decoded.replace(/^\/+/, "").replace(/\/+$/, "");
|
|
94
|
+
if (clean === "") return "index.html";
|
|
95
|
+
if (!isSafeRel(clean)) return null;
|
|
96
|
+
const last = clean.split("/").pop() || "";
|
|
97
|
+
if (/\.[a-z0-9]+$/i.test(last)) return clean;
|
|
98
|
+
return `${clean}/index.html`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Full R2 object key for a `route`-kind bundle-relative path at a given digest. */
|
|
102
|
+
export function staticBundleObjectKey(digest: string, rel: string): string {
|
|
103
|
+
return `versions/${digest}/${rel}`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** R2 key for a digest's bundle manifest (materialize-tenant.mjs's `manifest.json`). */
|
|
107
|
+
export function staticBundleManifestKey(digest: string): string {
|
|
108
|
+
return `versions/${digest}/manifest.json`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* T6: the shared content-addressed pool key for one asset's hash — MUST stay
|
|
113
|
+
* in lockstep with `scripts/publish/lib/asset-pool.mjs`'s `assetPoolKey`
|
|
114
|
+
* (same extension-preserving `assets/<sha256hex><ext>` shape; that module
|
|
115
|
+
* can't be imported here since this is the Worker bundle and that one runs
|
|
116
|
+
* under plain node — both sides' tests assert the exact key shape).
|
|
117
|
+
*/
|
|
118
|
+
export function staticBundleAssetPoolKey(hash: string, rel: string): string {
|
|
119
|
+
const dot = rel.lastIndexOf(".");
|
|
120
|
+
const slash = rel.lastIndexOf("/");
|
|
121
|
+
const ext = dot > slash ? rel.slice(dot) : "";
|
|
122
|
+
return `assets/${hash}${ext}`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Resolve the R2 key to fetch for a `rel` bundle path, using the manifest's
|
|
127
|
+
* T6 file classification: `kind: "asset"` -> the shared pool key (by content
|
|
128
|
+
* hash, NOT the version); anything else (`kind: "route"`, or `rel` absent
|
|
129
|
+
* from `files` entirely — a pre-T6 manifest, or a genuinely unlisted path) ->
|
|
130
|
+
* the per-version `versions/<digest>/<rel>` key, same as before T6.
|
|
131
|
+
*/
|
|
132
|
+
export function resolveStaticBundleObjectKey(
|
|
133
|
+
digest: string,
|
|
134
|
+
rel: string,
|
|
135
|
+
files: StaticBundleManifest["files"] | undefined,
|
|
136
|
+
): string {
|
|
137
|
+
const entry = files?.[rel];
|
|
138
|
+
if (entry?.kind === "asset" && entry.hash) {
|
|
139
|
+
return staticBundleAssetPoolKey(entry.hash, rel);
|
|
140
|
+
}
|
|
141
|
+
return staticBundleObjectKey(digest, rel);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
// Manifest (CSP pinned-nonce header) — read once per hit for an HTML response
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
/** One `manifest.files` entry (T6, `scripts/publish/lib/asset-pool.mjs`'s `classifyBundleFiles` shape). */
|
|
149
|
+
export interface StaticBundleFileEntry {
|
|
150
|
+
hash: string;
|
|
151
|
+
kind: "route" | "asset";
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The slice of `materialize-tenant.mjs`'s manifest this read path needs. */
|
|
155
|
+
export interface StaticBundleManifest {
|
|
156
|
+
treeDigest: string;
|
|
157
|
+
pinnedNonce: string;
|
|
158
|
+
csp: { headerName: string; value: string } | null;
|
|
159
|
+
/** T6 classification: relPath -> {hash, kind}. Absent on a pre-T6 manifest — every lookup then falls back to the old versions/<digest>/<rel> key (see `resolveStaticBundleObjectKey`). */
|
|
160
|
+
files?: Record<string, StaticBundleFileEntry>;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function parseManifest(raw: string): StaticBundleManifest | null {
|
|
164
|
+
try {
|
|
165
|
+
const m = JSON.parse(raw) as Partial<StaticBundleManifest>;
|
|
166
|
+
if (typeof m.treeDigest !== "string" || typeof m.pinnedNonce !== "string") return null;
|
|
167
|
+
const csp =
|
|
168
|
+
m.csp &&
|
|
169
|
+
typeof (m.csp as { headerName?: unknown }).headerName === "string" &&
|
|
170
|
+
typeof (m.csp as { value?: unknown }).value === "string"
|
|
171
|
+
? (m.csp as { headerName: string; value: string })
|
|
172
|
+
: null;
|
|
173
|
+
let files: StaticBundleManifest["files"];
|
|
174
|
+
if (m.files && typeof m.files === "object") {
|
|
175
|
+
files = {};
|
|
176
|
+
for (const [rel, entry] of Object.entries(m.files as Record<string, unknown>)) {
|
|
177
|
+
const e = entry as Partial<StaticBundleFileEntry> | null;
|
|
178
|
+
if (
|
|
179
|
+
e &&
|
|
180
|
+
typeof e.hash === "string" &&
|
|
181
|
+
(e.kind === "route" || e.kind === "asset")
|
|
182
|
+
) {
|
|
183
|
+
files[rel] = { hash: e.hash, kind: e.kind };
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return { treeDigest: m.treeDigest, pinnedNonce: m.pinnedNonce, csp, files };
|
|
188
|
+
} catch {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Read+parse a digest's `manifest.json` from R2. Null on any miss/malformed data. */
|
|
194
|
+
export async function readStaticBundleManifest(
|
|
195
|
+
bucket: StaticBundleR2BucketLike,
|
|
196
|
+
digest: string,
|
|
197
|
+
): Promise<StaticBundleManifest | null> {
|
|
198
|
+
const object = await bucket.get(staticBundleManifestKey(digest));
|
|
199
|
+
if (!object) return null;
|
|
200
|
+
const text = new TextDecoder().decode(await object.arrayBuffer());
|
|
201
|
+
return parseManifest(text);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
// Serve — resolve digest, stream the object from R2, fail closed on a miss
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
export interface ServeStaticBundleDeps {
|
|
209
|
+
bucket: StaticBundleR2BucketLike;
|
|
210
|
+
/**
|
|
211
|
+
* The digest to serve, already resolved by the caller (typically
|
|
212
|
+
* `resolveChannelVersion(get, tenantId, "live")` — see the module
|
|
213
|
+
* docstring's DIGEST CONTRACT). `null` means "pointer-serving is on for
|
|
214
|
+
* this tenant but nothing is live yet" — fails closed, same as a missing
|
|
215
|
+
* object, per the ADR: this path is only ever reached for a confirmed
|
|
216
|
+
* pointer-serving tenant, so an unresolved digest is itself a fault, not a
|
|
217
|
+
* "fall through to SSR" signal.
|
|
218
|
+
*/
|
|
219
|
+
digest: string | null;
|
|
220
|
+
/** Best-effort diagnostic sink (never throws, never blocks the response). */
|
|
221
|
+
onFailClosed?: (detail: string) => void;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Structured detail for the one fail-closed shape this module can produce. */
|
|
225
|
+
export class StaticBundleUnavailableError extends Error {
|
|
226
|
+
readonly code = "static-bundle-unavailable" as const;
|
|
227
|
+
constructor(detail: string) {
|
|
228
|
+
super(detail);
|
|
229
|
+
this.name = "StaticBundleUnavailableError";
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function failClosed(deps: ServeStaticBundleDeps, detail: string): Response {
|
|
234
|
+
try {
|
|
235
|
+
deps.onFailClosed?.(detail);
|
|
236
|
+
} catch {
|
|
237
|
+
/* diagnostic sink must never break the response path */
|
|
238
|
+
}
|
|
239
|
+
// 503, not a 200 masquerading as content and not a 404 (which would read as
|
|
240
|
+
// "this route doesn't exist" — it does, the bundle is just unreachable) and
|
|
241
|
+
// never an SSR fallthrough: this route is ONLY reached for a tenant
|
|
242
|
+
// confirmed to be on the pointer-serving model, so an unreadable bundle is
|
|
243
|
+
// an infra fault to surface (verify/monitoring), not a content gap.
|
|
244
|
+
return new Response("Static bundle unavailable", {
|
|
245
|
+
status: 503,
|
|
246
|
+
headers: {
|
|
247
|
+
"cache-control": "no-store",
|
|
248
|
+
"x-tot-static-bundle": "fail-closed",
|
|
249
|
+
},
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Cache-Control for a `route`-kind file (HTML/xml/txt — rendered fresh per version, stable non-digest url). */
|
|
254
|
+
const ROUTE_CACHE_CONTROL = "public, max-age=0, s-maxage=300, stale-while-revalidate=86400";
|
|
255
|
+
/** Cache-Control for an `asset`-kind file — the pool key IS the content hash, so caching forever is always correct (epic §A). */
|
|
256
|
+
const ASSET_CACHE_CONTROL = "public, max-age=31536000, immutable";
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Serve `pathname` from the digest-addressed static bundle in R2.
|
|
260
|
+
*
|
|
261
|
+
* Returns `null` — the ONE "fall through to SSR" signal — when the manifest's
|
|
262
|
+
* `files` map proves `pathname` is not part of this bundle's static surface:
|
|
263
|
+
* app surfaces (/admin, /cockpit, checkout, …) share the tenant's host but are
|
|
264
|
+
* never materialized into a bundle, so the caller must render them. Only a
|
|
265
|
+
* manifest WITH `files` can prove non-membership; a pre-T6 manifest keeps the
|
|
266
|
+
* fetch-then-fail-closed path.
|
|
267
|
+
*
|
|
268
|
+
* Every OTHER outcome (hit, unsafe path, unresolved digest, missing manifest,
|
|
269
|
+
* missing object for a LISTED file) terminates the request here rather than
|
|
270
|
+
* falling through — a staged digest's listed route must never silently degrade
|
|
271
|
+
* into SSR (wrong/divergent bytes), per the ADR's fail-closed guidance.
|
|
272
|
+
*
|
|
273
|
+
* T6: the manifest is now read FIRST, on every request (not just HTML, as
|
|
274
|
+
* before T6) — it's the only source of truth for whether `rel` resolves to
|
|
275
|
+
* the per-version `versions/<digest>/<rel>` key or the shared content-addressed
|
|
276
|
+
* `assets/<sha256>` pool key (`resolveStaticBundleObjectKey`). A missing
|
|
277
|
+
* manifest fails closed the same way a missing object always has: the
|
|
278
|
+
* manifest is deliberately the LAST thing `stage-bundle.mjs` writes for a
|
|
279
|
+
* digest (see `s3-publish.mjs`'s `buildManifestPromoteStep`), so its absence
|
|
280
|
+
* means this digest was never fully staged — serving from it would be wrong
|
|
281
|
+
* before T6 too, this just makes the fail-closed check unconditional.
|
|
282
|
+
*
|
|
283
|
+
* On a hit, serves the R2 bytes VERBATIM with a Cache-Control keyed by the
|
|
284
|
+
* manifest's file `kind` (long-lived immutable for the shared `asset` pool —
|
|
285
|
+
* safe because the key itself is the content hash; short/revalidating for a
|
|
286
|
+
* `route` doc, rendered fresh per version at a STABLE, non-digest url) and,
|
|
287
|
+
* for an HTML response, the bundle's PINNED CSP header read back from the
|
|
288
|
+
* same manifest read — never the Worker's per-request nonce middleware, per
|
|
289
|
+
* the ADR's CSP-consequences note.
|
|
290
|
+
*/
|
|
291
|
+
export async function serveStaticBundle(
|
|
292
|
+
deps: ServeStaticBundleDeps,
|
|
293
|
+
pathname: string,
|
|
294
|
+
): Promise<Response | null> {
|
|
295
|
+
const { bucket, digest } = deps;
|
|
296
|
+
if (!digest) {
|
|
297
|
+
return failClosed(deps, "pointer-serving tenant has no resolved live digest");
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const rel = staticBundleRelPath(pathname);
|
|
301
|
+
if (!rel) {
|
|
302
|
+
return failClosed(deps, `unsafe/unparseable path: ${JSON.stringify(pathname)}`);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const manifest = await readStaticBundleManifest(bucket, digest);
|
|
306
|
+
if (!manifest) {
|
|
307
|
+
return failClosed(deps, `missing/unreadable manifest for digest ${digest}`);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const entry = manifest.files?.[rel];
|
|
311
|
+
if (manifest.files && !entry) {
|
|
312
|
+
// Provably not in this bundle's static surface — an app surface (/admin,
|
|
313
|
+
// /cockpit, …) or a route this version never materialized. SSR owns it.
|
|
314
|
+
return null;
|
|
315
|
+
}
|
|
316
|
+
const objectKey = resolveStaticBundleObjectKey(digest, rel, manifest.files);
|
|
317
|
+
const object = await bucket.get(objectKey);
|
|
318
|
+
if (!object) {
|
|
319
|
+
return failClosed(deps, `missing R2 object ${objectKey}`);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const isHtml = rel.endsWith(".html");
|
|
323
|
+
// T6 cache tier: use the manifest's `kind` classification when present.
|
|
324
|
+
// A pre-T6 manifest (no `files` at all) falls back to the ORIGINAL
|
|
325
|
+
// isHtml-based heuristic — every non-html path used to be treated as a
|
|
326
|
+
// long-cacheable asset, which this preserves exactly for an old bundle
|
|
327
|
+
// this Worker version might still encounter mid-rollout.
|
|
328
|
+
const cacheControl = entry
|
|
329
|
+
? entry.kind === "asset"
|
|
330
|
+
? ASSET_CACHE_CONTROL
|
|
331
|
+
: ROUTE_CACHE_CONTROL
|
|
332
|
+
: isHtml
|
|
333
|
+
? ROUTE_CACHE_CONTROL
|
|
334
|
+
: ASSET_CACHE_CONTROL;
|
|
335
|
+
const headers: Record<string, string> = {
|
|
336
|
+
"content-type": assetContentType(rel),
|
|
337
|
+
// Cache-key-by-digest lives in the caller's edge-cache layer for `route`
|
|
338
|
+
// keys (versions/<digest>/<rel> is digest-scoped — a pointer flip is a
|
|
339
|
+
// clean cache miss on the NEW digest's keys, never a stale serve of the
|
|
340
|
+
// old one); `asset` keys are ADDITIONALLY digest-independent (content-
|
|
341
|
+
// addressed by hash), so they're safe to cache forever downstream too.
|
|
342
|
+
// This header governs downstream/browser caching of the STABLE URL:
|
|
343
|
+
"cache-control": cacheControl,
|
|
344
|
+
"x-tot-static-bundle": "hit",
|
|
345
|
+
"x-tot-static-bundle-digest": digest,
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
if (isHtml && manifest.csp) {
|
|
349
|
+
headers[manifest.csp.headerName] = manifest.csp.value;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (typeof object.size === "number") {
|
|
353
|
+
headers["content-length"] = String(object.size);
|
|
354
|
+
}
|
|
355
|
+
const body: BodyInit = object.body ?? (await object.arrayBuffer());
|
|
356
|
+
return new Response(body, { status: 200, headers });
|
|
357
|
+
}
|
|
@@ -392,6 +392,34 @@ export interface TenantConfig {
|
|
|
392
392
|
features?: Partial<TenantFeatures>;
|
|
393
393
|
/** Regulated-commerce affordances; undefined for unregulated tenants. */
|
|
394
394
|
compliance?: ComplianceConfig;
|
|
395
|
+
/**
|
|
396
|
+
* Per-tenant newsletter compliance/sender config. Sourced when a digest is
|
|
397
|
+
* built and sent (the send pipeline is a later unit). Distinct from the
|
|
398
|
+
* {@link TenantFeatures} `newsletter` flag, which only gates the CAPTURE form:
|
|
399
|
+
* this object supplies the CAN-SPAM physical postal address rendered in every
|
|
400
|
+
* email footer, plus the sender identity. Absent for tenants that don't send a
|
|
401
|
+
* newsletter; a sender falls back to a platform default when a field is unset.
|
|
402
|
+
*/
|
|
403
|
+
newsletter?: {
|
|
404
|
+
/**
|
|
405
|
+
* Physical postal mailing address rendered in every newsletter email footer
|
|
406
|
+
* (CAN-SPAM §5(a)(5) requirement). Plain text; multi-line allowed.
|
|
407
|
+
*/
|
|
408
|
+
postalAddress: string;
|
|
409
|
+
/** Display name on the `From:` header; falls back to `displayName`. */
|
|
410
|
+
fromName?: string;
|
|
411
|
+
/** `Reply-To:` address for the newsletter; falls back to a platform default. */
|
|
412
|
+
replyTo?: string;
|
|
413
|
+
/**
|
|
414
|
+
* Verified sending address on the `From:` header, e.g.
|
|
415
|
+
* `the-build@mail.tokenoftrust.store`. MUST be an address on a domain that is
|
|
416
|
+
* SPF/DKIM/DMARC-verified with the ESP (Resend) — NOT the tenant's own store
|
|
417
|
+
* domain, which is not a verified sending domain and would fail alignment.
|
|
418
|
+
* Unset → the send path derives `<collection>@mail.tokenoftrust.store` (the
|
|
419
|
+
* shared verified domain). Override only to use a per-tenant verified sender.
|
|
420
|
+
*/
|
|
421
|
+
fromAddress?: string;
|
|
422
|
+
};
|
|
395
423
|
/**
|
|
396
424
|
* Cart/checkout engine wiring (Track A). Present on commerce tenants
|
|
397
425
|
* that have a checkout store; drives the loader + signed add-to-cart. The
|
|
@@ -426,14 +454,14 @@ export interface TenantConfig {
|
|
|
426
454
|
*/
|
|
427
455
|
capabilities?: Partial<CapabilityConfig>;
|
|
428
456
|
/**
|
|
429
|
-
* Static-publish target
|
|
430
|
-
*
|
|
431
|
-
*
|
|
432
|
-
* PROPERTY, not a global dark flag: presence opts the tenant in
|
|
433
|
-
* `enabled: false` opts back out without dropping the target
|
|
434
|
-
* dispatch credentials/coordinates live in env/secrets, NEVER
|
|
435
|
-
* an opaque, TARGET-NEUTRAL label
|
|
436
|
-
* publish job so ONE reconcile publish-sink drives any target.
|
|
457
|
+
* Static-publish target. This tenant's PUBLIC site is served by the Worker+R2
|
|
458
|
+
* pointer/digest plane (ADR 0012): go-live and rollback are instant, in-request
|
|
459
|
+
* KV `live` pointer flips over an already-staged, content-addressed bundle in
|
|
460
|
+
* R2. A per-tenant PROPERTY, not a global dark flag: presence opts the tenant in
|
|
461
|
+
* (default ON); `enabled: false` opts back out without dropping the target
|
|
462
|
+
* coordinates. The dispatch credentials/coordinates live in env/secrets, NEVER
|
|
463
|
+
* here. `target` is an opaque, TARGET-NEUTRAL label forwarded verbatim into the
|
|
464
|
+
* background stage/publish job so ONE reconcile publish-sink drives any target.
|
|
437
465
|
*/
|
|
438
466
|
staticPublish?: {
|
|
439
467
|
/** Target-neutral label, e.g. "www-s3". Forwarded into the dispatch payload. */
|
|
@@ -16,12 +16,20 @@
|
|
|
16
16
|
* Idempotent: it wipes+rewrites the destination on every run. `TENANT_ASSETS_DEST`
|
|
17
17
|
* overrides the destination (used by tests / spikes).
|
|
18
18
|
*
|
|
19
|
-
* VERSIONED TENANTS
|
|
19
|
+
* VERSIONED TENANTS: a tenant whose assets are
|
|
20
20
|
* served version-scoped from R2 (src/pages/tenants/[id]/[...path].ts) must NOT be
|
|
21
21
|
* baked here — otherwise the static file would shadow the R2 route. Opt a tenant
|
|
22
22
|
* out by adding a `tenants/<id>/.tot/assets-versioned` marker file, or by listing
|
|
23
23
|
* its id in the `TENANT_ASSETS_VERSIONED` env (comma-separated). Non-versioned
|
|
24
|
-
* tenants
|
|
24
|
+
* tenants are baked here.
|
|
25
|
+
*
|
|
26
|
+
* TENANT_ASSETS_BAKE_VERSIONED=1 overrides ALL of the above and bakes every
|
|
27
|
+
* tenant's public/ regardless of versioned-ness. Set by the STATIC-plane bundle
|
|
28
|
+
* build (scripts/publish/materialize-tenant.mjs): the S3/CloudFront static site
|
|
29
|
+
* has no R2 asset route — a bundle is self-contained by definition — so
|
|
30
|
+
* skipping a versioned tenant's assets there just produces a bundle whose pages
|
|
31
|
+
* reference files that don't exist. The Worker-plane build never sets this, so
|
|
32
|
+
* R2 shadowing semantics are unchanged there.
|
|
25
33
|
*/
|
|
26
34
|
import { cp, mkdir, readdir, rm } from "node:fs/promises";
|
|
27
35
|
import { existsSync } from "node:fs";
|
|
@@ -44,9 +52,11 @@ const versionedEnv = new Set(
|
|
|
44
52
|
.map((s) => s.trim())
|
|
45
53
|
.filter(Boolean),
|
|
46
54
|
);
|
|
55
|
+
const bakeVersioned = process.env.TENANT_ASSETS_BAKE_VERSIONED === "1";
|
|
47
56
|
const isVersioned = (id) =>
|
|
48
|
-
|
|
49
|
-
|
|
57
|
+
!bakeVersioned &&
|
|
58
|
+
(versionedEnv.has(id) ||
|
|
59
|
+
existsSync(join(src, id, ".tot", "assets-versioned")));
|
|
50
60
|
|
|
51
61
|
let copied = 0;
|
|
52
62
|
let skipped = 0;
|
package/scripts/dev/ai-edit.mjs
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* `tot` CLI, so `~/.tot/credentials.json` is directly readable. No token
|
|
16
16
|
* plumbing needed between the CLI and the dev server.
|
|
17
17
|
*
|
|
18
|
-
* Scope
|
|
18
|
+
* Scope: content + theme.json ONLY.
|
|
19
19
|
* The MCP tool enforces this server-side; nothing here widens it.
|
|
20
20
|
*/
|
|
21
21
|
import { readFileSync, writeFileSync, existsSync, mkdirSync, appendFileSync } from "node:fs";
|
|
@@ -35,8 +35,8 @@ export function resync(sourceRoot, targetRoot, rel) {
|
|
|
35
35
|
* RELIABILITY-FIRST: defaults to an mtime POLL, not native `fs.watch`. Native watch
|
|
36
36
|
* is event-driven but LOSSY on macOS — editors that save via write-temp-then-rename
|
|
37
37
|
* (VS Code, vim, JetBrains, …) routinely deliver NO change event on the original
|
|
38
|
-
* path, so the save silently fails to hot-reload (the
|
|
39
|
-
*
|
|
38
|
+
* path, so the save silently fails to hot-reload. The poll stat()s the tree on a
|
|
39
|
+
* short interval and never misses an edit.
|
|
40
40
|
*
|
|
41
41
|
* Opt into event-driven native watch with TOT_DEV_NATIVE_WATCH=1 (it still falls
|
|
42
42
|
* back to polling where recursive watch is unavailable, e.g. Linux). Dependency-free
|