@tokenoftrust/storefront-runner 1.4.0 → 1.4.2-rc.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/storefront/astro.config.mjs +17 -1
- package/apps/storefront/integrations/materialize-guard.mjs +56 -0
- package/apps/storefront/migrations-apps/0001_woozy_lyja.sql +22 -0
- package/apps/storefront/migrations-apps/0002_good_switch.sql +26 -0
- package/apps/storefront/migrations-apps/0003_mute_marauders.sql +12 -0
- package/apps/storefront/migrations-apps/meta/0001_snapshot.json +990 -0
- package/apps/storefront/migrations-apps/meta/0002_snapshot.json +1160 -0
- package/apps/storefront/migrations-apps/meta/0003_snapshot.json +1235 -0
- package/apps/storefront/migrations-apps/meta/_journal.json +21 -0
- package/apps/storefront/package.json +0 -1
- package/apps/storefront/public/js/dashboard-team.js +38 -2
- package/apps/storefront/src/components/Seo.astro +65 -1
- package/apps/storefront/src/components/admin/AdminBlogDetailRail.astro +878 -0
- package/apps/storefront/src/components/admin/AdminBlogTab.astro +1432 -0
- package/apps/storefront/src/components/admin/AdminCustomersTab.astro +3 -7
- package/apps/storefront/src/components/admin/AdminPublishTab.astro +1756 -1382
- package/apps/storefront/src/components/admin/AdminSubscriptionsTab.astro +1 -3
- package/apps/storefront/src/components/blog/BlogPaywall.astro +87 -0
- package/apps/storefront/src/components/blog/PostAccessBadge.astro +43 -0
- package/apps/storefront/src/components/blog/PostGrid.astro +57 -0
- package/apps/storefront/src/components/blog/ReactionBar.astro +214 -0
- package/apps/storefront/src/layouts/Layout.astro +48 -3
- package/apps/storefront/src/lib/activity/alerts.ts +426 -0
- package/apps/storefront/src/lib/activity/changeActorAttribution.ts +85 -0
- package/apps/storefront/src/lib/activity/deployVersion.ts +120 -0
- package/apps/storefront/src/lib/activity/ingest.ts +188 -0
- package/apps/storefront/src/lib/activity/ingestAuth.ts +105 -0
- package/apps/storefront/src/lib/activity/killSwitch.ts +80 -0
- package/apps/storefront/src/lib/activity/query.ts +403 -0
- package/apps/storefront/src/lib/activity/recordActivity.ts +101 -0
- package/apps/storefront/src/lib/activity/store.ts +120 -0
- package/apps/storefront/src/lib/activity/uiActor.ts +150 -0
- package/apps/storefront/src/lib/activity/workerCommit.ts +70 -0
- package/apps/storefront/src/lib/admin/adminShell.ts +290 -0
- package/apps/storefront/src/lib/admin/ai/approvalMode.ts +2 -3
- package/apps/storefront/src/lib/admin/ai/audit.ts +3 -5
- package/apps/storefront/src/lib/admin/checkoutStyleInput.ts +7 -7
- package/apps/storefront/src/lib/admin/checkoutStyleTarget.ts +3 -3
- package/apps/storefront/src/lib/admin/orderClient.ts +3 -5
- package/apps/storefront/src/lib/admin/services/orders.ts +17 -19
- package/apps/storefront/src/lib/admin/viewmodels/aiWorkflows.ts +4 -6
- package/apps/storefront/src/lib/admin/viewmodels/settings.ts +1 -1
- package/apps/storefront/src/lib/apps/adminService.ts +4 -4
- package/apps/storefront/src/lib/apps/adminSession.ts +53 -44
- package/apps/storefront/src/lib/apps/apiAuth.ts +2 -2
- package/apps/storefront/src/lib/apps/apiRoute.ts +2 -2
- package/apps/storefront/src/lib/apps/catalogMapper.ts +1 -1
- package/apps/storefront/src/lib/apps/credentials.ts +2 -2
- package/apps/storefront/src/lib/apps/gatewayKeys.ts +4 -4
- package/apps/storefront/src/lib/apps/healthAggregate.ts +5 -5
- package/apps/storefront/src/lib/apps/orders/attributionService.ts +2 -2
- package/apps/storefront/src/lib/apps/orders/customerHash.ts +1 -1
- package/apps/storefront/src/lib/apps/orders/foxyOrderClient.ts +12 -12
- package/apps/storefront/src/lib/apps/orders/idempotency.ts +7 -7
- package/apps/storefront/src/lib/apps/orders/orderForwardReceiver.ts +1 -1
- package/apps/storefront/src/lib/apps/orders/orderMapper.ts +2 -2
- package/apps/storefront/src/lib/apps/orders/ordersStore.ts +6 -6
- package/apps/storefront/src/lib/apps/registryService.ts +6 -6
- package/apps/storefront/src/lib/apps/scopes.ts +2 -2
- package/apps/storefront/src/lib/apps/tokenIssuer.ts +4 -4
- package/apps/storefront/src/lib/apps/tokenVerifier.ts +6 -6
- package/apps/storefront/src/lib/apps/widgets/eligibility.ts +2 -2
- package/apps/storefront/src/lib/apps/widgets/frameProps.ts +3 -3
- package/apps/storefront/src/lib/apps/widgets/launchToken.ts +4 -4
- package/apps/storefront/src/lib/apps/widgets/placements.ts +2 -2
- package/apps/storefront/src/lib/apps/widgets/renderSlot.ts +1 -1
- package/apps/storefront/src/lib/auth/adminEntry.ts +9 -9
- package/apps/storefront/src/lib/auth/identityToken.ts +6 -14
- package/apps/storefront/src/lib/auth/loginGate.ts +75 -13
- package/apps/storefront/src/lib/auth/mcpClientAssertion.ts +6 -4
- package/apps/storefront/src/lib/auth/operatorSession.ts +113 -0
- package/apps/storefront/src/lib/auth/route.ts +44 -4
- package/apps/storefront/src/lib/auth/session.ts +7 -8
- package/apps/storefront/src/lib/auth/totAccessClient.ts +8 -18
- package/apps/storefront/src/lib/basePath.ts +9 -2
- package/apps/storefront/src/lib/blog/access.ts +29 -0
- package/apps/storefront/src/lib/blog/collection.ts +313 -0
- package/apps/storefront/src/lib/blog/markdown.ts +174 -0
- package/apps/storefront/src/lib/blog/provider.ts +5 -0
- package/apps/storefront/src/lib/blog/reactions.ts +87 -0
- package/apps/storefront/src/lib/blog/rss.ts +92 -0
- package/apps/storefront/src/lib/blog/teaser.ts +83 -0
- package/apps/storefront/src/lib/blog/types.ts +34 -0
- package/apps/storefront/src/lib/blog/viewCounts.ts +243 -0
- package/apps/storefront/src/lib/d1/catalog.ts +2 -2
- package/apps/storefront/src/lib/d1/schema-apps.ts +147 -14
- package/apps/storefront/src/lib/dev/activityIngestToken.ts +2 -4
- package/apps/storefront/src/lib/dev/activityStore.ts +3 -5
- package/apps/storefront/src/lib/dev/apiBase.ts +1 -4
- package/apps/storefront/src/lib/dev/cockpitStore.ts +1 -9
- package/apps/storefront/src/lib/dev/devDraft.ts +1 -1
- package/apps/storefront/src/lib/dev/previewStatus.ts +9 -21
- package/apps/storefront/src/lib/dev/rendezvousBroker.ts +9 -24
- package/apps/storefront/src/lib/edgeCache.ts +17 -0
- package/apps/storefront/src/lib/env.ts +51 -0
- package/apps/storefront/src/lib/http/fetchTransport.ts +63 -0
- package/apps/storefront/src/lib/i18n.ts +25 -0
- package/apps/storefront/src/lib/jsonld.ts +118 -0
- package/apps/storefront/src/lib/monitoring/manifest.ts +11 -11
- package/apps/storefront/src/lib/newsletter/confirmationEmail.ts +30 -0
- package/apps/storefront/src/lib/newsletter/consent.ts +23 -0
- package/apps/storefront/src/lib/newsletter/digest.ts +117 -0
- package/apps/storefront/src/lib/newsletter/resendClient.ts +79 -0
- package/apps/storefront/src/lib/newsletter/send.ts +265 -0
- package/apps/storefront/src/lib/newsletter/subscribers.ts +156 -0
- package/apps/storefront/src/lib/publish/adminPublishTab.ts +3519 -0
- package/apps/storefront/src/lib/publish/apex-readiness.ts +41 -18
- package/apps/storefront/src/lib/publish/dispatchHealth.ts +5 -7
- package/apps/storefront/src/lib/publish/domainState.ts +22 -13
- package/apps/storefront/src/lib/publish/panelPolling.ts +78 -0
- package/apps/storefront/src/lib/publish/shipWorkspace.ts +100 -42
- package/apps/storefront/src/lib/publish/stageBundleUpload.ts +215 -0
- package/apps/storefront/src/lib/rate/kvWindow.ts +53 -0
- package/apps/storefront/src/lib/search/index.ts +2 -2
- package/apps/storefront/src/lib/seo/alternates.ts +42 -0
- package/apps/storefront/src/lib/seo/meta.ts +65 -0
- package/apps/storefront/src/lib/social/golive.ts +212 -0
- package/apps/storefront/src/lib/social/notify.ts +32 -0
- package/apps/storefront/src/lib/social/tokenStore.ts +171 -0
- package/apps/storefront/src/lib/the-build/provider.ts +27 -0
- package/apps/storefront/src/lib/the-build/readingTime.ts +35 -0
- package/apps/storefront/src/lib/the-build/toc.ts +95 -0
- package/apps/storefront/src/lib/tot/ToTClient.ts +1 -1
- package/apps/storefront/src/lib/tot/d1Client.ts +2 -2
- package/apps/storefront/src/lib/tot/totClientInterface.ts +3 -4
- package/apps/storefront/src/lib/webhooks/deliveryStore.ts +16 -17
- package/apps/storefront/src/middleware/index.ts +130 -2
- package/apps/storefront/src/pages/admin/ops-timeline.astro +420 -0
- package/apps/storefront/src/pages/admin.astro +52 -1206
- package/apps/storefront/src/pages/api/activity.ts +137 -0
- package/apps/storefront/src/pages/api/admin/activity-alerts.ts +73 -0
- package/apps/storefront/src/pages/api/apps/admin/credentials/rotate.ts +27 -1
- package/apps/storefront/src/pages/api/apps/admin/install.ts +26 -1
- package/apps/storefront/src/pages/api/apps/admin/resume.ts +24 -1
- package/apps/storefront/src/pages/api/apps/admin/suspend.ts +24 -1
- package/apps/storefront/src/pages/api/apps/admin/uninstall.ts +25 -1
- package/apps/storefront/src/pages/api/apps/admin/update.ts +24 -1
- package/apps/storefront/src/pages/api/apps/admin/webhooks/deliveries/[deliveryId]/replay.ts +11 -0
- package/apps/storefront/src/pages/api/apps/internal/order-forward.ts +11 -0
- package/apps/storefront/src/pages/api/apps/v1/attribution.ts +20 -0
- package/apps/storefront/src/pages/api/apps/v1/webhooks/deliveries/[deliveryId]/replay.ts +12 -0
- package/apps/storefront/src/pages/api/blog/[collection]/[slug]/react.ts +104 -0
- package/apps/storefront/src/pages/api/cache-purge.ts +11 -0
- package/apps/storefront/src/pages/api/dashboard/enter-vendor.ts +30 -0
- package/apps/storefront/src/pages/api/newsletter/[collection]/subscribe.ts +146 -0
- package/apps/storefront/src/pages/api/newsletter/confirm.ts +75 -0
- package/apps/storefront/src/pages/api/newsletter/unsubscribe.ts +90 -0
- package/apps/storefront/src/pages/api/publish/stage-bundle.ts +70 -0
- package/apps/storefront/src/pages/api/rum/vitals.ts +21 -2
- package/apps/storefront/src/pages/auth/login.astro +5 -4
- package/apps/storefront/src/pages/blog/[slug].astro +96 -16
- package/apps/storefront/src/pages/blog/author/[author].astro +140 -0
- package/apps/storefront/src/pages/blog/category/[category].astro +67 -0
- package/apps/storefront/src/pages/blog/index.astro +2 -0
- package/apps/storefront/src/pages/blog/tag/[tag].astro +65 -0
- package/apps/storefront/src/pages/cockpit.astro +74 -12
- package/apps/storefront/src/pages/dashboard/[appDomain]/team.astro +15 -11
- package/apps/storefront/src/pages/search.astro +1 -1
- package/apps/storefront/src/pages/sitemap.xml.ts +20 -0
- package/apps/storefront/src/pages/the-build/[slug].astro +129 -0
- package/apps/storefront/src/pages/the-build/index.astro +146 -0
- package/apps/storefront/src/pages/the-build/rss.xml.ts +33 -0
- package/apps/storefront/src/styles/admin.css +1080 -0
- package/apps/storefront/tsconfig.json +5 -0
- package/package.json +1 -1
- package/packages/public-runtime/package.json +1 -0
- package/packages/public-runtime/src/activity/README.md +146 -0
- package/packages/public-runtime/src/activity/catalog.ts +553 -0
- package/packages/public-runtime/src/activity/event.ts +168 -0
- package/packages/public-runtime/src/activity/index.ts +16 -0
- package/packages/public-runtime/src/activity/redaction.ts +263 -0
- package/packages/public-runtime/src/candidate-index.ts +13 -0
- package/packages/public-runtime/src/catalog-d1.ts +1 -1
- package/packages/public-runtime/src/customization-preview.ts +25 -2
- package/packages/public-runtime/src/customization-runtime.ts +125 -3
- package/packages/public-runtime/src/customization-versioning.ts +10 -0
- package/packages/public-runtime/src/index.ts +7 -0
- package/packages/public-runtime/src/static-bundle.ts +357 -0
- package/packages/public-runtime/src/tenant.ts +36 -8
- package/scripts/build/copy-tenant-assets.mjs +14 -4
- package/scripts/dev/ai-edit.mjs +1 -1
- package/scripts/dev/checkout-watch.mjs +2 -2
- package/scripts/dev/file-browser.mjs +2 -4
- package/scripts/dev/file-writer.mjs +1 -1
- package/scripts/dev/git-status.mjs +2 -2
- package/scripts/dev/locate-handler.mjs +3 -3
- package/scripts/dev/port-check.mjs +3 -3
- package/scripts/dev/publish.mjs +5 -9
- package/scripts/dev/shot.mjs +1 -1
- package/scripts/dev/unified-diff.mjs +8 -3
- package/scripts/tot-dev.mjs +6 -9
- package/apps/storefront/src/lib/webhooks/signing.ts +0 -29
- package/apps/storefront/src/lib/webhooks/webhookSigningKey.ts +0 -146
|
@@ -0,0 +1,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
|