@tokenoftrust/storefront-runner 1.4.1 → 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/0002_good_switch.sql +26 -0
- package/apps/storefront/migrations-apps/0003_mute_marauders.sql +12 -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 +14 -0
- 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 +1105 -2334
- 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 +17 -19
- package/apps/storefront/src/lib/activity/changeActorAttribution.ts +3 -3
- package/apps/storefront/src/lib/activity/deployVersion.ts +19 -26
- package/apps/storefront/src/lib/activity/ingest.ts +8 -8
- package/apps/storefront/src/lib/activity/ingestAuth.ts +1 -1
- package/apps/storefront/src/lib/activity/killSwitch.ts +6 -6
- package/apps/storefront/src/lib/activity/query.ts +9 -9
- package/apps/storefront/src/lib/activity/recordActivity.ts +11 -15
- package/apps/storefront/src/lib/activity/store.ts +4 -6
- package/apps/storefront/src/lib/activity/uiActor.ts +12 -12
- package/apps/storefront/src/lib/activity/workerCommit.ts +6 -6
- 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 +2 -4
- package/apps/storefront/src/lib/auth/operatorSession.ts +113 -0
- 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 +96 -17
- 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/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 -49
- 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 +113 -2
- package/apps/storefront/src/pages/admin.astro +10 -1312
- package/apps/storefront/src/pages/api/blog/[collection]/[slug]/react.ts +104 -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/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 +37 -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/catalog.ts +52 -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 +1 -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/dev/vcBinding.ts +0 -80
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Encrypted per-`(tenant, network)` outbound social OAuth token store
|
|
3
|
+
* (social-autopost epic, S2). The one RETRIEVABLE secret store in the
|
|
4
|
+
* storefront: unlike `app_credentials` (one-way `sha256Hex` for INBOUND auth),
|
|
5
|
+
* these are access/refresh tokens we must REPLAY to X/LinkedIn, so they are
|
|
6
|
+
* encrypted-at-rest and decrypted at post time — never hashed.
|
|
7
|
+
*
|
|
8
|
+
* Envelope encryption, Workers-native Web Crypto `AES-GCM` (no Node `crypto`):
|
|
9
|
+
* - The data-encrypting step runs per write with a fresh random 96-bit IV.
|
|
10
|
+
* - The key-encrypting key (KEK) is a Worker env binding (`SOCIAL_TOKEN_KEK`,
|
|
11
|
+
* a base64-encoded 256-bit key), imported to a non-extractable `CryptoKey`.
|
|
12
|
+
* It is NEVER a column — mirroring how every other real secret here is an
|
|
13
|
+
* env binding, not row data (see the `social_tokens` note in schema-apps.ts).
|
|
14
|
+
*
|
|
15
|
+
* PURE over injected seams (a `Queryable` for D1, a `CryptoKey` for the KEK) so
|
|
16
|
+
* the same code serves the real Worker bindings and an in-memory test — the
|
|
17
|
+
* activity/store.ts convention. `resolveSocialTokenStore()` is the composition
|
|
18
|
+
* root that wires the real bindings (mirrors `getDispatcher()`); it returns
|
|
19
|
+
* `null` when either binding is absent so callers degrade rather than throw.
|
|
20
|
+
*/
|
|
21
|
+
import type { Queryable } from "../d1/catalog.js";
|
|
22
|
+
import { readD1, readEnv } from "../env.js";
|
|
23
|
+
|
|
24
|
+
/** The decrypted token material we persist as one JSON blob under AES-GCM. */
|
|
25
|
+
export interface SocialTokenPayload {
|
|
26
|
+
accessToken: string;
|
|
27
|
+
/** OAuth refresh token, when the network issues one (X app-passwords don't). */
|
|
28
|
+
refreshToken?: string;
|
|
29
|
+
/** Token expiry, ISO-8601. Absent for long-lived manual creds. */
|
|
30
|
+
expiresAt?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Injected seams a store operation runs over. */
|
|
34
|
+
export interface SocialTokenStoreDeps {
|
|
35
|
+
db: Queryable;
|
|
36
|
+
/** AES-GCM key imported from the `SOCIAL_TOKEN_KEK` binding via {@link importKek}. */
|
|
37
|
+
kek: CryptoKey;
|
|
38
|
+
/** Clock seam (ISO-8601), overridable in tests. Defaults to `Date.now`. */
|
|
39
|
+
now?: () => string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const IV_BYTES = 12; // AES-GCM standard 96-bit nonce.
|
|
43
|
+
|
|
44
|
+
function nowIso(deps: SocialTokenStoreDeps): string {
|
|
45
|
+
return (deps.now ?? (() => new Date().toISOString()))();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function toBase64(bytes: Uint8Array): string {
|
|
49
|
+
let s = "";
|
|
50
|
+
for (const b of bytes) s += String.fromCharCode(b);
|
|
51
|
+
return btoa(s);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function fromBase64(b64: string): Uint8Array<ArrayBuffer> {
|
|
55
|
+
const s = atob(b64);
|
|
56
|
+
const bytes = new Uint8Array(s.length);
|
|
57
|
+
for (let i = 0; i < s.length; i++) bytes[i] = s.charCodeAt(i);
|
|
58
|
+
return bytes;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Import the raw `SOCIAL_TOKEN_KEK` secret (base64 of a 256-bit key) into a
|
|
63
|
+
* non-extractable AES-GCM `CryptoKey`. Throws if the decoded key isn't 32 bytes
|
|
64
|
+
* — a misprovisioned KEK must fail loudly at the composition root, not silently
|
|
65
|
+
* produce unopenable ciphertext.
|
|
66
|
+
*/
|
|
67
|
+
export async function importKek(rawBase64Key: string): Promise<CryptoKey> {
|
|
68
|
+
const raw = fromBase64(rawBase64Key.trim());
|
|
69
|
+
if (raw.length !== 32) {
|
|
70
|
+
throw new Error(`SOCIAL_TOKEN_KEK must decode to 32 bytes (got ${raw.length})`);
|
|
71
|
+
}
|
|
72
|
+
return crypto.subtle.importKey("raw", raw, { name: "AES-GCM" }, false, ["encrypt", "decrypt"]);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Encrypt a token payload → `{ ciphertext, iv }` (both base64). Fresh IV each call. */
|
|
76
|
+
export async function encryptTokenPayload(
|
|
77
|
+
kek: CryptoKey,
|
|
78
|
+
payload: SocialTokenPayload,
|
|
79
|
+
): Promise<{ ciphertext: string; iv: string }> {
|
|
80
|
+
const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES));
|
|
81
|
+
const plaintext = new TextEncoder().encode(JSON.stringify(payload));
|
|
82
|
+
const ct = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, kek, plaintext);
|
|
83
|
+
return { ciphertext: toBase64(new Uint8Array(ct)), iv: toBase64(iv) };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Decrypt a stored `{ ciphertext, iv }` back to the token payload. */
|
|
87
|
+
export async function decryptTokenPayload(
|
|
88
|
+
kek: CryptoKey,
|
|
89
|
+
ciphertext: string,
|
|
90
|
+
iv: string,
|
|
91
|
+
): Promise<SocialTokenPayload> {
|
|
92
|
+
const pt = await crypto.subtle.decrypt(
|
|
93
|
+
{ name: "AES-GCM", iv: fromBase64(iv) },
|
|
94
|
+
kek,
|
|
95
|
+
fromBase64(ciphertext),
|
|
96
|
+
);
|
|
97
|
+
return JSON.parse(new TextDecoder().decode(pt)) as SocialTokenPayload;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Store (or overwrite) the token for `(tenantId, network)`. `INSERT OR REPLACE`
|
|
102
|
+
* on the composite PK, so this is an idempotent upsert; `created_at` follows the
|
|
103
|
+
* row's replacement (the natural key is the identity, not a birth timestamp).
|
|
104
|
+
*/
|
|
105
|
+
export async function putSocialToken(
|
|
106
|
+
deps: SocialTokenStoreDeps,
|
|
107
|
+
tenantId: string,
|
|
108
|
+
network: string,
|
|
109
|
+
payload: SocialTokenPayload,
|
|
110
|
+
): Promise<void> {
|
|
111
|
+
const { ciphertext, iv } = await encryptTokenPayload(deps.kek, payload);
|
|
112
|
+
const at = nowIso(deps);
|
|
113
|
+
await deps.db.run(
|
|
114
|
+
`INSERT OR REPLACE INTO social_tokens
|
|
115
|
+
(tenant_id, network, ciphertext, iv, expires_at, created_at, updated_at)
|
|
116
|
+
VALUES (?,?,?,?,?,?,?)`,
|
|
117
|
+
tenantId,
|
|
118
|
+
network,
|
|
119
|
+
ciphertext,
|
|
120
|
+
iv,
|
|
121
|
+
payload.expiresAt ?? null,
|
|
122
|
+
at,
|
|
123
|
+
at,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Decrypt and return the token for `(tenantId, network)`, or `null` if none stored. */
|
|
128
|
+
export async function getSocialToken(
|
|
129
|
+
deps: SocialTokenStoreDeps,
|
|
130
|
+
tenantId: string,
|
|
131
|
+
network: string,
|
|
132
|
+
): Promise<SocialTokenPayload | null> {
|
|
133
|
+
const row = await deps.db.first<{ ciphertext: string; iv: string }>(
|
|
134
|
+
`SELECT ciphertext, iv FROM social_tokens WHERE tenant_id = ? AND network = ?`,
|
|
135
|
+
tenantId,
|
|
136
|
+
network,
|
|
137
|
+
);
|
|
138
|
+
if (!row) return null;
|
|
139
|
+
return decryptTokenPayload(deps.kek, row.ciphertext, row.iv);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Overwrite the token for `(tenantId, network)` with rotated material — the
|
|
144
|
+
* explicit name for the refresh-token-rotation path. Same write as
|
|
145
|
+
* {@link putSocialToken}; distinct export so a caller's intent reads at the seam.
|
|
146
|
+
*/
|
|
147
|
+
export async function rotateSocialToken(
|
|
148
|
+
deps: SocialTokenStoreDeps,
|
|
149
|
+
tenantId: string,
|
|
150
|
+
network: string,
|
|
151
|
+
newPayload: SocialTokenPayload,
|
|
152
|
+
): Promise<void> {
|
|
153
|
+
await putSocialToken(deps, tenantId, network, newPayload);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Composition root: wire the real Worker bindings into a `SocialTokenStoreDeps`.
|
|
158
|
+
* Returns `null` when the D1 binding or the `SOCIAL_TOKEN_KEK` secret is absent
|
|
159
|
+
* (unprovisioned runtime / dev), so a caller degrades to "social posting
|
|
160
|
+
* unavailable" rather than throwing — the KEK is a runtime secret that must be
|
|
161
|
+
* provisioned out of band (`wrangler secret put SOCIAL_TOKEN_KEK`).
|
|
162
|
+
*/
|
|
163
|
+
export async function resolveSocialTokenStore(): Promise<SocialTokenStoreDeps | null> {
|
|
164
|
+
const [{ fromD1 }, d1, rawKek] = await Promise.all([
|
|
165
|
+
import("../d1/catalog.js"),
|
|
166
|
+
readD1("STOREFRONT_APPS_DB"),
|
|
167
|
+
readEnv("SOCIAL_TOKEN_KEK"),
|
|
168
|
+
]);
|
|
169
|
+
if (!d1 || !rawKek) return null;
|
|
170
|
+
return { db: fromD1(d1), kek: await importKek(rawKek) };
|
|
171
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Thin wrapper binding the generic blog engine to the "the-build" collection.
|
|
2
|
+
// The engine (src/lib/blog/collection.ts) is the single implementation; this
|
|
3
|
+
// module only fixes the collection id so existing/downstream callers can import
|
|
4
|
+
// collection-bound helpers.
|
|
5
|
+
import { getPost, listPosts, resolveRedirect, type Collection } from "../blog/collection.js";
|
|
6
|
+
import type { BlogPost, BlogPostSummary } from "../blog/types.js";
|
|
7
|
+
|
|
8
|
+
export const THE_BUILD: Collection = { id: "the-build", base: "/the-build" };
|
|
9
|
+
|
|
10
|
+
export function listBuildPosts(
|
|
11
|
+
tenantId: string,
|
|
12
|
+
opts?: { includeUnpublished?: boolean; now?: string },
|
|
13
|
+
): Promise<BlogPostSummary[]> {
|
|
14
|
+
return listPosts(tenantId, THE_BUILD.id, opts);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function getBuildPost(
|
|
18
|
+
tenantId: string,
|
|
19
|
+
slug: string,
|
|
20
|
+
opts?: { includeUnpublished?: boolean; now?: string },
|
|
21
|
+
): Promise<BlogPost | null> {
|
|
22
|
+
return getPost(tenantId, THE_BUILD.id, slug, opts);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function resolveBuildRedirect(tenantId: string, slug: string): Promise<string | null> {
|
|
26
|
+
return resolveRedirect(tenantId, THE_BUILD.id, slug);
|
|
27
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Estimated reading time for a post's rendered HTML (B1.5). Pure + Node-free
|
|
2
|
+
// (parse5 is Workers-safe), so it's unit-testable and usable on any render path.
|
|
3
|
+
import { parseFragment } from "parse5";
|
|
4
|
+
|
|
5
|
+
/** Minimal structural view of a parse5 node (duck-typed, as scripts/dev/locate.mjs does). */
|
|
6
|
+
interface P5Node {
|
|
7
|
+
nodeName: string;
|
|
8
|
+
tagName?: string;
|
|
9
|
+
value?: string;
|
|
10
|
+
childNodes?: P5Node[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const WORDS_PER_MINUTE = 225;
|
|
14
|
+
|
|
15
|
+
function collectText(node: P5Node, out: string[]): void {
|
|
16
|
+
if (node.nodeName === "#text") {
|
|
17
|
+
if (node.value) out.push(node.value);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
// Non-visible/text-irrelevant subtrees never count toward reading time.
|
|
21
|
+
if (node.tagName === "script" || node.tagName === "style") return;
|
|
22
|
+
for (const child of node.childNodes ?? []) collectText(child, out);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Reading time in whole minutes: visible word count / 225 wpm, rounded up, min 1.
|
|
27
|
+
* Ignores markup, script, and style content.
|
|
28
|
+
*/
|
|
29
|
+
export function readingTime(html: string): number {
|
|
30
|
+
const frag = parseFragment(html ?? "") as unknown as P5Node;
|
|
31
|
+
const parts: string[] = [];
|
|
32
|
+
collectText(frag, parts);
|
|
33
|
+
const words = parts.join(" ").trim().split(/\s+/).filter(Boolean);
|
|
34
|
+
return Math.max(1, Math.ceil(words.length / WORDS_PER_MINUTE));
|
|
35
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Table of contents for a post's rendered HTML (B1.5): parse h2/h3 headings and
|
|
2
|
+
// derive stable anchor ids. Pure + Node-free (parse5 is Workers-safe).
|
|
3
|
+
//
|
|
4
|
+
// `toc()` and `htmlWithHeadingIds()` derive ids from the SAME deterministic pass
|
|
5
|
+
// (`annotate`), so a render path can feed `htmlWithHeadingIds(post.contentHtml)`
|
|
6
|
+
// to `set:html` and `toc(post.contentHtml)` to the aside and the anchors line up.
|
|
7
|
+
import { parseFragment, serialize } from "parse5";
|
|
8
|
+
import { slugifyHeading } from "../content/prose.js";
|
|
9
|
+
|
|
10
|
+
/** Minimal structural view of a parse5 node (duck-typed, as scripts/dev/locate.mjs does). */
|
|
11
|
+
interface P5Node {
|
|
12
|
+
nodeName: string;
|
|
13
|
+
tagName?: string;
|
|
14
|
+
value?: string;
|
|
15
|
+
attrs?: { name: string; value: string }[];
|
|
16
|
+
childNodes?: P5Node[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface TocEntry {
|
|
20
|
+
id: string;
|
|
21
|
+
text: string;
|
|
22
|
+
level: 2 | 3;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function textOf(node: P5Node): string {
|
|
26
|
+
if (node.nodeName === "#text") return node.value ?? "";
|
|
27
|
+
return (node.childNodes ?? []).map(textOf).join("");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function getId(node: P5Node): string | undefined {
|
|
31
|
+
return node.attrs?.find((a) => a.name === "id")?.value?.trim() || undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function* headings(node: P5Node): Generator<P5Node> {
|
|
35
|
+
for (const child of node.childNodes ?? []) {
|
|
36
|
+
if (child.tagName === "h2" || child.tagName === "h3") yield child;
|
|
37
|
+
yield* headings(child);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface AnnotatedHeading {
|
|
42
|
+
node: P5Node;
|
|
43
|
+
id: string;
|
|
44
|
+
text: string;
|
|
45
|
+
level: 2 | 3;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Walk headings in document order, assigning each a unique anchor id: an
|
|
50
|
+
* author-supplied `id` is kept as-is; otherwise the slugified text, de-duped
|
|
51
|
+
* with a numeric suffix on collision. Empty headings are skipped.
|
|
52
|
+
*/
|
|
53
|
+
function* annotate(root: P5Node): Generator<AnnotatedHeading> {
|
|
54
|
+
const seen = new Map<string, number>();
|
|
55
|
+
for (const node of headings(root)) {
|
|
56
|
+
const text = textOf(node).replace(/\s+/g, " ").trim();
|
|
57
|
+
if (!text) continue;
|
|
58
|
+
const level: 2 | 3 = node.tagName === "h2" ? 2 : 3;
|
|
59
|
+
const explicit = getId(node);
|
|
60
|
+
let id: string;
|
|
61
|
+
if (explicit) {
|
|
62
|
+
id = explicit;
|
|
63
|
+
seen.set(id, (seen.get(id) ?? 0) + 1);
|
|
64
|
+
} else {
|
|
65
|
+
const base = slugifyHeading(text) || "section";
|
|
66
|
+
const n = seen.get(base) ?? 0;
|
|
67
|
+
seen.set(base, n + 1);
|
|
68
|
+
id = n === 0 ? base : `${base}-${n + 1}`;
|
|
69
|
+
}
|
|
70
|
+
yield { node, id, text, level };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** TOC entries (one per h2/h3) with stable anchor ids, in document order. */
|
|
75
|
+
export function toc(html: string): TocEntry[] {
|
|
76
|
+
const frag = parseFragment(html ?? "") as unknown as P5Node;
|
|
77
|
+
return [...annotate(frag)].map(({ id, text, level }) => ({ id, text, level }));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The same HTML with anchor ids added to any h2/h3 that lacks one (author ids
|
|
82
|
+
* untouched). Returns the input unchanged when every heading already has an id,
|
|
83
|
+
* so posts without generated anchors are byte-for-byte preserved.
|
|
84
|
+
*/
|
|
85
|
+
export function htmlWithHeadingIds(html: string): string {
|
|
86
|
+
const frag = parseFragment(html ?? "") as unknown as P5Node;
|
|
87
|
+
let changed = false;
|
|
88
|
+
for (const { node, id } of annotate(frag)) {
|
|
89
|
+
if (!getId(node)) {
|
|
90
|
+
node.attrs = [...(node.attrs ?? []), { name: "id", value: id }];
|
|
91
|
+
changed = true;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return changed ? serialize(frag as never) : (html ?? "");
|
|
95
|
+
}
|
|
@@ -166,7 +166,7 @@ export async function createToTClient(
|
|
|
166
166
|
tenant: TenantConfig,
|
|
167
167
|
d1?: D1Like,
|
|
168
168
|
): Promise<ToTClient> {
|
|
169
|
-
// Edge read replica
|
|
169
|
+
// Edge read replica: when a D1 binding is wired,
|
|
170
170
|
// serve reads from it (FTS5 search + SQL facets). Otherwise the bundled fixture
|
|
171
171
|
// snapshot renders the site offline. The tot20 HTTP catalog API has been retired,
|
|
172
172
|
// so the read path is fully edge-native.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* D1-backed ToTClient — the edge read-replica path
|
|
2
|
+
* D1-backed ToTClient — the edge read-replica path.
|
|
3
3
|
*
|
|
4
4
|
* Implements the same ToTClient interface as Fixture/Http, but every read is a D1
|
|
5
5
|
* query (FTS5 search, SQL faceted listing, row-blob retrieval) instead of an
|
|
@@ -56,7 +56,7 @@ export class D1ToTClient implements ToTClient {
|
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
async getReviews(_handle: string): Promise<Review[]> {
|
|
59
|
-
//
|
|
59
|
+
// Reviews are display-via-metafields; the D1 catalog carries none.
|
|
60
60
|
return [];
|
|
61
61
|
}
|
|
62
62
|
}
|
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The ToTClient interface — the storefront's single product-truth contract.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Lives in its own module so the concrete backends (FixtureToTClient here,
|
|
5
5
|
* D1ToTClient in d1Client.ts) can both `implements ToTClient` without a circular
|
|
6
6
|
* module edge: d1Client imports only this contract, while ToTClient.ts imports
|
|
7
|
-
* D1ToTClient for its factory. ToTClient.ts re-exports this type
|
|
8
|
-
* `import type { ToTClient } from "./ToTClient"` call sites are unchanged.
|
|
7
|
+
* D1ToTClient for its factory. ToTClient.ts re-exports this type.
|
|
9
8
|
*/
|
|
10
9
|
import type {
|
|
11
10
|
CatalogCollection,
|
|
@@ -25,6 +24,6 @@ export interface ToTClient {
|
|
|
25
24
|
getFeatured(limit?: number): Promise<CatalogProduct[]>;
|
|
26
25
|
/** Full active catalog — used to build the per-tenant search index. */
|
|
27
26
|
getAllActiveProducts(): Promise<CatalogProduct[]>;
|
|
28
|
-
/** Full review content for a product
|
|
27
|
+
/** Full review content for a product. */
|
|
29
28
|
getReviews(handle: string): Promise<Review[]>;
|
|
30
29
|
}
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Webhook event/delivery storage
|
|
3
|
-
* only). Owns the `webhook_events` / `webhook_deliveries` /
|
|
2
|
+
* Webhook event/delivery storage. Owns the `webhook_events` / `webhook_deliveries` /
|
|
4
3
|
* `webhook_delivery_attempts` / `webhook_install_health` rows described in
|
|
5
|
-
* `../d1/schema.ts`. Signing
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* `../d1/schema.ts`. Signing, the retry/backoff dispatcher, event
|
|
5
|
+
* emission/targeting, and the `/webhooks/*` routes all build on this store; none
|
|
6
|
+
* of that policy lives here.
|
|
8
7
|
*
|
|
9
8
|
* Pure over the `Queryable` seam (see `../d1/catalog.ts`) — the same shape
|
|
10
9
|
* `AppRegistryService` uses, so this runs unchanged against real D1 in the
|
|
@@ -16,12 +15,12 @@
|
|
|
16
15
|
* - `deliveryId` (`webhook_deliveries.delivery_id`) — the CloudEvents
|
|
17
16
|
* envelope `id` for one (event × target install) delivery. This is what
|
|
18
17
|
* goes on the wire and is what receivers dedupe on; it stays stable
|
|
19
|
-
* across that delivery's own retries AND across a manual replay
|
|
20
|
-
*
|
|
18
|
+
* across that delivery's own retries AND across a manual replay
|
|
19
|
+
* (`requeueForReplay` resets state/attempts on the SAME row rather
|
|
21
20
|
* than minting a new one — a receiver that already acked a delivered
|
|
22
21
|
* delivery must still dedupe a replayed resend of it).
|
|
23
22
|
*
|
|
24
|
-
* Responsibility split for the two mutating paths a dispatcher
|
|
23
|
+
* Responsibility split for the two mutating paths a dispatcher
|
|
25
24
|
* drives per attempt:
|
|
26
25
|
* - `recordAttempt` is dumb bookkeeping of ONE HTTP attempt's outcome: it
|
|
27
26
|
* appends to `webhook_delivery_attempts`, bumps the delivery's
|
|
@@ -117,7 +116,7 @@ export interface CreateEventResult {
|
|
|
117
116
|
|
|
118
117
|
export interface ListDeliveriesOptions {
|
|
119
118
|
state?: DeliveryState;
|
|
120
|
-
/** Cap the result, newest first. Used internally by {@link WebhookDeliveryStore.getInstallHealth} to bound its p95 sample; unset means unbounded
|
|
119
|
+
/** Cap the result, newest first. Used internally by {@link WebhookDeliveryStore.getInstallHealth} to bound its p95 sample; unset means unbounded. */
|
|
121
120
|
limit?: number;
|
|
122
121
|
}
|
|
123
122
|
|
|
@@ -165,8 +164,8 @@ const MAX_RECENT_TRACE_IDS = 10;
|
|
|
165
164
|
const P95_SAMPLE_SIZE = 50;
|
|
166
165
|
|
|
167
166
|
// A delivery only ever moves forward via `transitionState`; `delivered`/
|
|
168
|
-
// `dead-lettered` are terminal THERE. A manual replay (
|
|
169
|
-
//
|
|
167
|
+
// `dead-lettered` are terminal THERE. A manual replay (`requeueForReplay`)
|
|
168
|
+
// is the one deliberate exception: it resets a delivery
|
|
170
169
|
// out of a terminal state back to `pending` on the SAME row, bypassing this
|
|
171
170
|
// table entirely — see that method's own doc comment for why.
|
|
172
171
|
const ALLOWED_TRANSITIONS: Record<DeliveryState, readonly DeliveryState[]> = {
|
|
@@ -287,7 +286,7 @@ export class WebhookDeliveryStore {
|
|
|
287
286
|
/**
|
|
288
287
|
* Write one immutable event, then fan it out to one `pending` delivery per
|
|
289
288
|
* target install. Targeting (which installs get the event) is the caller's
|
|
290
|
-
* job
|
|
289
|
+
* job — this just persists whatever target list it's handed.
|
|
291
290
|
*/
|
|
292
291
|
async createEvent(input: CreateEventInput, targets: readonly DeliveryTarget[]): Promise<CreateEventResult> {
|
|
293
292
|
const eventId = input.eventId ?? crypto.randomUUID();
|
|
@@ -345,8 +344,8 @@ export class WebhookDeliveryStore {
|
|
|
345
344
|
}
|
|
346
345
|
|
|
347
346
|
/**
|
|
348
|
-
* Read one event by id, or `null` if it doesn't exist.
|
|
349
|
-
*
|
|
347
|
+
* Read one event by id, or `null` if it doesn't exist. The dispatcher/delivery
|
|
348
|
+
* engine uses it: `attemptDelivery` resolves a
|
|
350
349
|
* delivery's `eventId` via `getDelivery`, then needs this to fetch the
|
|
351
350
|
* `payload`/`dataschemaVersion` that only `webhook_events` (not
|
|
352
351
|
* `webhook_deliveries`) carries, in order to build the CloudEvents
|
|
@@ -381,7 +380,7 @@ export class WebhookDeliveryStore {
|
|
|
381
380
|
* All deliveries for a tenant, across every install (the ops/support admin
|
|
382
381
|
* view — an install only ever sees its OWN deliveries via
|
|
383
382
|
* {@link listDeliveriesByInstall}; this is the tenant-wide superset), newest
|
|
384
|
-
* first.
|
|
383
|
+
* first.
|
|
385
384
|
*/
|
|
386
385
|
async listDeliveriesByTenant(
|
|
387
386
|
tenantId: string,
|
|
@@ -443,7 +442,7 @@ export class WebhookDeliveryStore {
|
|
|
443
442
|
|
|
444
443
|
/**
|
|
445
444
|
* Move a delivery's `state` forward per `ALLOWED_TRANSITIONS`. The
|
|
446
|
-
* caller (
|
|
445
|
+
* caller (the dispatcher) decides WHEN to retry vs. give up; this
|
|
447
446
|
* only records that decision. Dead-lettering bumps the install's
|
|
448
447
|
* `dlqCount` — the one rollup field {@link recordAttempt} doesn't own.
|
|
449
448
|
*/
|
|
@@ -478,7 +477,7 @@ export class WebhookDeliveryStore {
|
|
|
478
477
|
}
|
|
479
478
|
|
|
480
479
|
/**
|
|
481
|
-
*
|
|
480
|
+
* Replay support: force a delivery back to `pending` with a
|
|
482
481
|
* fresh attempt budget — from ANY current state, INCLUDING the terminal
|
|
483
482
|
* `delivered`/`dead-lettered` states `transitionState`/`ALLOWED_TRANSITIONS`
|
|
484
483
|
* refuse to leave. This is deliberately a separate write path, not a call
|
|
@@ -11,7 +11,7 @@ import { getTenantResolver } from "@/config/resolver";
|
|
|
11
11
|
import { referenceTheme } from "@/themes/reference";
|
|
12
12
|
import { mergeTheme, themeToCssVars } from "@/themes/schema";
|
|
13
13
|
import { createToTClient } from "@/lib/tot/ToTClient";
|
|
14
|
-
import { readEnv, readKv, readD1 } from "@/lib/env";
|
|
14
|
+
import { readEnv, readKv, readD1, readR2 } from "@/lib/env";
|
|
15
15
|
import { resolveTenantRouting } from "@/lib/tenantRouting";
|
|
16
16
|
import { createContentProvider, DevDraftContentProvider } from "@/lib/storyblok/provider";
|
|
17
17
|
import { DEV_DRAFT_COOKIE, devDraftKey, verifyDevDraftToken } from "@/lib/dev/devDraft";
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
readCacheVersion,
|
|
22
22
|
getCachedPage,
|
|
23
23
|
putCachedPage,
|
|
24
|
+
staticBundleCacheKey,
|
|
24
25
|
} from "@/lib/edgeCache";
|
|
25
26
|
import { STATIC_SCRIPT_HASHES, STATIC_STYLE_HASHES } from "@/lib/cspStaticHashes";
|
|
26
27
|
import { applySecurityHeaders } from "@/lib/securityHeaders";
|
|
@@ -38,8 +39,12 @@ import {
|
|
|
38
39
|
resolveTenantFeatures,
|
|
39
40
|
resolveCapabilities,
|
|
40
41
|
capabilitiesArtifactPath,
|
|
42
|
+
resolveChannelDigest,
|
|
43
|
+
resolveServableChannelVersion,
|
|
44
|
+
channelFromEnv,
|
|
45
|
+
serveStaticBundle,
|
|
41
46
|
} from "@tot/public-runtime";
|
|
42
|
-
import type { CapabilityConfig } from "@tot/public-runtime";
|
|
47
|
+
import type { CapabilityConfig, StaticBundleR2BucketLike } from "@tot/public-runtime";
|
|
43
48
|
import { resolveTenantCommerce } from "@/lib/checkoutCommerce";
|
|
44
49
|
import { evaluateDemoGate, hostInGateList } from "@/lib/demoGate";
|
|
45
50
|
import {
|
|
@@ -55,6 +60,7 @@ import { readSession, KvSessionStore, SESSION_COOKIE } from "@/lib/auth/session"
|
|
|
55
60
|
import { isMaintenanceOn, maintenanceResponse } from "@/lib/maintenance";
|
|
56
61
|
import { HEALTH_PATH, healthResponse } from "@/lib/health";
|
|
57
62
|
import { observeDeployVersion } from "@/lib/activity/deployVersion";
|
|
63
|
+
import { recordActivity } from "@/lib/activity/recordActivity";
|
|
58
64
|
import { primeActivityKillSwitch } from "@/lib/activity/killSwitch";
|
|
59
65
|
import type { DeepPartial, ThemeTokens } from "@tot/public-runtime";
|
|
60
66
|
|
|
@@ -164,6 +170,14 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
|
|
164
170
|
}
|
|
165
171
|
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
166
172
|
const admitted = sessionAdmitsHost(viewerRecord, tenant.appDomain, nowSeconds);
|
|
173
|
+
// QA content-bypass: only when the server has configured DEMO_GATE_BYPASS_TOKEN
|
|
174
|
+
// (never in a prod slot) AND the request presents a matching X-Demo-Gate-Bypass
|
|
175
|
+
// header. Unset token ⇒ false ⇒ fully inert; a client cannot self-grant. The
|
|
176
|
+
// public-content-only restriction (admin/cockpit/api excluded) is enforced
|
|
177
|
+
// inside evaluateLoginGate via isBypassPrivilegedPath.
|
|
178
|
+
const bypassToken = await readEnv("DEMO_GATE_BYPASS_TOKEN");
|
|
179
|
+
const qaBypass =
|
|
180
|
+
!!bypassToken && request.headers.get("x-demo-gate-bypass") === bypassToken;
|
|
167
181
|
const decision = evaluateLoginGate({
|
|
168
182
|
host,
|
|
169
183
|
pathname: url.pathname,
|
|
@@ -172,6 +186,7 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
|
|
172
186
|
gatedHosts: demoGateHosts,
|
|
173
187
|
hasValidSession: admitted,
|
|
174
188
|
tenantExempt,
|
|
189
|
+
qaBypass,
|
|
175
190
|
});
|
|
176
191
|
if (decision.action === "redirect") {
|
|
177
192
|
// Withhold content but offer sign-in (carrying the intended destination).
|
|
@@ -205,6 +220,79 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
|
|
205
220
|
// signature, expiry, tenant binding — before the overlay is applied further down.
|
|
206
221
|
const draftCookie = context.cookies.get(DEV_DRAFT_COOKIE)?.value ?? null;
|
|
207
222
|
|
|
223
|
+
// ---- Digest-addressed static-bundle serving (ADR 0012) --------------------
|
|
224
|
+
// For a static-publish tenant's PRODUCTION reads only (never preview/test — the
|
|
225
|
+
// "Worker serves interactive preview, R2 serves prod" split), read the live
|
|
226
|
+
// bundle straight out of R2 instead of falling through to SSR. Deliberately
|
|
227
|
+
// BEFORE the edge page cache / catalog / theme / customization machinery below:
|
|
228
|
+
// none of that applies to bundle bytes served verbatim, and this path must
|
|
229
|
+
// never SSR-fallthrough once it's eligible (a missing digest/object fails
|
|
230
|
+
// closed, see `serveStaticBundle`), so it has to fully own the response, not
|
|
231
|
+
// degrade into the normal render.
|
|
232
|
+
// Host-resolved reads only (`basePath === ""`): bundle bytes are served
|
|
233
|
+
// VERBATIM with root-absolute links, so a path-prefixed view
|
|
234
|
+
// (/<domain>/… on the shared .store host) would escape its prefix on every
|
|
235
|
+
// href — those views keep SSR, which rewrites hrefs through basePath.
|
|
236
|
+
const staticBundleEligible =
|
|
237
|
+
tenant.staticPublish != null &&
|
|
238
|
+
tenant.staticPublish.enabled !== false &&
|
|
239
|
+
basePath === "" &&
|
|
240
|
+
request.method.toUpperCase() === "GET" &&
|
|
241
|
+
!isPreviewHost &&
|
|
242
|
+
!queryToken &&
|
|
243
|
+
!cookieToken &&
|
|
244
|
+
!draftCookie &&
|
|
245
|
+
!url.pathname.startsWith("/api/");
|
|
246
|
+
if (staticBundleEligible) {
|
|
247
|
+
const bundleBucket = await readR2("STATIC_BUNDLES");
|
|
248
|
+
if (bundleBucket) {
|
|
249
|
+
const kvGet = kv ? (key: string) => kv.get(key) : async () => null;
|
|
250
|
+
// The R2 bundle key is the `treeDigest`, NOT the `live` channel versionId
|
|
251
|
+
// (the Gitea sha). Read the reconciled digest companion (`channelDigests.live`)
|
|
252
|
+
// — `gatedPromote`/`rollbackCandidateAtomic` write it in the same CAS flip as
|
|
253
|
+
// the versionId. No digest = this tenant has never staged a bundle — SSR
|
|
254
|
+
// serves it (a static-publish tenant must be able to bootstrap, and its
|
|
255
|
+
// app surfaces must never be downed by an unstaged static plane).
|
|
256
|
+
const digest = await resolveChannelDigest(kvGet, tenant.tenant_id, "live");
|
|
257
|
+
if (digest) {
|
|
258
|
+
const bundleCacheKey = cache
|
|
259
|
+
? staticBundleCacheKey(tenant.tenant_id, digest, url.pathname, url.search)
|
|
260
|
+
: null;
|
|
261
|
+
if (bundleCacheKey && cache) {
|
|
262
|
+
const hit = await getCachedPage(cache, bundleCacheKey);
|
|
263
|
+
if (hit) return hit;
|
|
264
|
+
}
|
|
265
|
+
const bundleResponse = await serveStaticBundle(
|
|
266
|
+
{
|
|
267
|
+
bucket: bundleBucket as unknown as StaticBundleR2BucketLike,
|
|
268
|
+
digest,
|
|
269
|
+
onFailClosed: (detail) => {
|
|
270
|
+
console.error(
|
|
271
|
+
`[static-bundle] fail-closed for ${tenant.tenant_id} ${url.pathname}: ${detail}`,
|
|
272
|
+
);
|
|
273
|
+
},
|
|
274
|
+
},
|
|
275
|
+
url.pathname,
|
|
276
|
+
);
|
|
277
|
+
// `null` = the manifest proves this path is outside the bundle's static
|
|
278
|
+
// surface (an app surface like /admin) — SSR owns it below.
|
|
279
|
+
if (bundleResponse) {
|
|
280
|
+
if (bundleCacheKey && cache) {
|
|
281
|
+
await putCachedPage(cache, bundleCacheKey, bundleResponse);
|
|
282
|
+
}
|
|
283
|
+
return bundleResponse;
|
|
284
|
+
}
|
|
285
|
+
} else {
|
|
286
|
+
console.error(
|
|
287
|
+
`[static-bundle] no live digest for ${tenant.tenant_id} — SSR until a bundle is staged`,
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
// No STATIC_BUNDLES binding (plain `astro dev`, or unconfigured) —
|
|
292
|
+
// degrading to SSR here rather than a hard failure keeps `astro dev`/tests
|
|
293
|
+
// usable without provisioning R2.
|
|
294
|
+
}
|
|
295
|
+
|
|
208
296
|
// ---- Edge page cache (D1): short-circuit production browse reads on a hit,
|
|
209
297
|
// BEFORE building the client / theme / rendering. Misses store the final
|
|
210
298
|
// response at the end of this middleware.
|
|
@@ -262,6 +350,7 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
|
|
262
350
|
token: previewToken,
|
|
263
351
|
secret: previewSecret,
|
|
264
352
|
nowSeconds,
|
|
353
|
+
pathname: url.pathname,
|
|
265
354
|
});
|
|
266
355
|
if (customization.reject) {
|
|
267
356
|
return new Response(customization.reject.message, {
|
|
@@ -290,6 +379,28 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
|
|
290
379
|
// pointer fall back to the legacy per-path selection — no behavior change.
|
|
291
380
|
const kvGet = kv ? (key: string) => kv.get(key) : async () => null;
|
|
292
381
|
|
|
382
|
+
// Self-heal a DANGLING channel pointer: the pointer for this env's channel may
|
|
383
|
+
// name a version that was never built (or whose manifest was evicted).
|
|
384
|
+
// `readVersionedArtifact` already serves last-built content instead of emptying
|
|
385
|
+
// the surface; here we do the once-per-request detection to ENQUEUE a rebuild of
|
|
386
|
+
// the dangling version. Fail-open: a KV hiccup never blocks the render (the reads
|
|
387
|
+
// below still run).
|
|
388
|
+
if (kv) {
|
|
389
|
+
const channel = channelFromEnv(customization.env);
|
|
390
|
+
await resolveServableChannelVersion(kvGet, tenant.tenant_id, channel, ({ versionId }) => {
|
|
391
|
+
recordActivity({
|
|
392
|
+
action: "preview.build.requested",
|
|
393
|
+
actor: { kind: "system", id: "storefront" },
|
|
394
|
+
source: "server",
|
|
395
|
+
outcome: { status: "succeeded" },
|
|
396
|
+
scope: { tenantId: tenant.tenant_id },
|
|
397
|
+
payload: { args: { changeId: versionId } },
|
|
398
|
+
});
|
|
399
|
+
}).catch(() => {
|
|
400
|
+
/* fail-open: self-heal detection never breaks the request */
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
|
|
293
404
|
// Verify the developer-draft cookie (signature + expiry + tenant binding) — the
|
|
294
405
|
// overlay below only engages for the developer who minted the draft, on THIS
|
|
295
406
|
// tenant. Same secret + trust model as the preview token; invalid/expired/foreign
|