@tokenoftrust/storefront-runner 2.0.16 → 2.0.18
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/env.d.ts +18 -0
- package/apps/storefront/migrations-apps/0012_test_destinations.sql +29 -0
- package/apps/storefront/src/components/blog/ReactionBar.astro +1 -1
- package/apps/storefront/src/components/islands/CheckoutComplianceGate.tsx +365 -49
- package/apps/storefront/src/components/islands/checkoutVerificationFlow.ts +251 -0
- package/apps/storefront/src/config/compliance/rulesets.ts +18 -7
- package/apps/storefront/src/config/devTenantSeed.ts +24 -1
- package/apps/storefront/src/config/evidenceTenantRoster.ts +25 -0
- package/apps/storefront/src/config/publicationSpaceConfig.ts +25 -0
- package/apps/storefront/src/config/resolver.ts +5 -0
- package/apps/storefront/src/config/tenantAnalyticsFiles.ts +43 -0
- package/apps/storefront/src/config/tenants.ts +11 -0
- package/apps/storefront/src/layouts/Layout.astro +5 -1
- package/apps/storefront/src/lib/activity/killSwitch.ts +1 -1
- package/apps/storefront/src/lib/admin/consentSettingsClient.ts +113 -0
- package/apps/storefront/src/lib/apps/consentConfigRead.ts +24 -0
- package/apps/storefront/src/lib/apps/gtmMigrationDraft.ts +103 -0
- package/apps/storefront/src/lib/apps/routeParams.ts +45 -0
- package/apps/storefront/src/lib/auth/loginGate.ts +25 -0
- package/apps/storefront/src/lib/checkoutCommerce.ts +1 -1
- package/apps/storefront/src/lib/compliance/enforcement.ts +25 -3
- package/apps/storefront/src/lib/d1/schema-apps.ts +44 -0
- package/apps/storefront/src/lib/env.ts +13 -111
- package/apps/storefront/src/lib/fixtures/regulated-commerce-truth/index.ts +540 -0
- package/apps/storefront/src/lib/fixtures/ws3-readonly-clone-preflight/index.ts +495 -0
- package/apps/storefront/src/lib/integration/tenantAnalyticsDeclarations.ts +198 -0
- package/apps/storefront/src/lib/monitoring/manifest.ts +1 -1
- package/apps/storefront/src/lib/rawMarketingHtml.ts +9 -1
- package/apps/storefront/src/lib/runtimeEnv.ts +124 -0
- package/apps/storefront/src/lib/verification/totVerificationClient.ts +485 -0
- package/apps/storefront/src/middleware/candidatePin.ts +35 -0
- package/apps/storefront/src/middleware/controlPlane.ts +270 -0
- package/apps/storefront/src/middleware/index.ts +100 -796
- package/apps/storefront/src/middleware/publicRender.ts +43 -0
- package/apps/storefront/src/middleware/renderChain.ts +685 -0
- package/apps/storefront/src/middleware/routeBranch.ts +160 -0
- package/apps/storefront/src/middleware/staticBundle.ts +109 -0
- package/apps/storefront/src/pages/[...slug].astro +1 -0
- package/apps/storefront/src/pages/api/compliance/preflight.ts +179 -2
- package/apps/storefront/src/pages/index.astro +1 -0
- package/apps/storefront/vitest.config.ts +5 -0
- package/package.json +1 -1
- package/packages/public-runtime/package.json +1 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/deliver-test-event-request.schema.json +122 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/get-test-destination-delivery-request.schema.json +79 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/register-test-destination-request.schema.json +93 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/test-destination-delivery.schema.json +106 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/test-destination.schema.json +77 -0
- package/packages/public-runtime/schemas/publication-space/v1/manifest.schema.json +195 -0
- package/packages/public-runtime/schemas/publication-space/v1/normalized-mount.schema.json +80 -0
- package/packages/public-runtime/schemas/publication-space/v1/ref.schema.json +20 -0
- package/packages/public-runtime/schemas/publication-version/v1/pointer.schema.json +28 -0
- package/packages/public-runtime/schemas/publication-version/v1/transition-request.schema.json +44 -0
- package/packages/public-runtime/schemas/publication-version/v1/transition.schema.json +47 -0
- package/packages/public-runtime/schemas/publication-version/v1/version.schema.json +58 -0
- package/packages/public-runtime/src/activity/catalog.ts +1 -1
- package/packages/public-runtime/src/candidate-index.ts +6 -0
- package/packages/public-runtime/src/customization-reconcile.ts +8 -0
- package/packages/public-runtime/src/html-cache-policy.ts +57 -0
- package/packages/public-runtime/src/index.ts +7 -0
- package/packages/public-runtime/src/integration/consent-broker.ts +115 -0
- package/packages/public-runtime/src/integration/interim-bridge.ts +111 -27
- package/packages/public-runtime/src/integration/managed-analytics-api-v1.schemas.ts +65 -0
- package/packages/public-runtime/src/integration/managed-analytics-api-v1.ts +81 -0
- package/packages/public-runtime/src/integration/render-phases.ts +8 -3
- package/packages/public-runtime/src/publication-artifact.ts +203 -0
- package/packages/public-runtime/src/publication-space.ts +853 -0
- package/packages/public-runtime/src/publication-version.ts +587 -0
- package/packages/public-runtime/src/route-tier.ts +257 -0
- package/packages/public-runtime/src/static-bundle.ts +18 -5
- package/packages/public-runtime/src/tenant-assets.ts +85 -17
- package/packages/public-runtime/src/tenant.ts +70 -0
- package/packages/public-runtime/tests/conformance/fake-destination.ts +402 -0
- package/packages/public-runtime/tests/fixtures/publication-spaces/internal.json +32 -0
- package/packages/public-runtime/tests/fixtures/publication-spaces/mounted-public.json +38 -0
- package/packages/public-runtime/tests/fixtures/publication-spaces/normalized-mounts.json +39 -0
- package/packages/public-runtime/tests/fixtures/publication-spaces/ref.json +5 -0
- package/packages/public-runtime/tests/fixtures/publication-spaces/root-public.json +31 -0
- package/packages/public-runtime/tests/fixtures/publication-versions/articles-v1.json +20 -0
- package/packages/public-runtime/tests/fixtures/publication-versions/articles-v2.json +20 -0
- package/packages/public-runtime/tests/fixtures/publication-versions/internal-v1.json +20 -0
- package/packages/public-runtime/tests/fixtures/publication-versions/live-pointer.json +11 -0
- package/packages/public-runtime/tests/fixtures/publication-versions/live-transition-request.json +14 -0
- package/packages/public-runtime/tests/fixtures/publication-versions/live-transition.json +17 -0
- package/packages/public-runtime/tests/fixtures/publication-versions/root-v1.json +20 -0
- package/packages/public-runtime/tests/fixtures/publication-versions/root-v2.json +20 -0
- package/scripts/dev/ai-edit.mjs +16 -3
- package/scripts/dev/port-check.mjs +18 -3
- package/scripts/dev/publish.mjs +26 -3
- package/scripts/tenant/validate.mjs +14 -1
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure, transport-free bridge from an uploaded GTM export to a REVIEWABLE
|
|
3
|
+
* migration-draft PROPOSAL — the connective tissue that makes the self-serve
|
|
4
|
+
* import→draft→review→install flow usable by a tenant developer.
|
|
5
|
+
*
|
|
6
|
+
* The analyze surface returns only a REDACTED report, so a developer can never
|
|
7
|
+
* reconstruct the `createMigrationDraft` body (proposedAdapterId /
|
|
8
|
+
* proposedConfiguration / findings) from it — those derive from the UNREDACTED
|
|
9
|
+
* container. This helper runs the same safe pipeline analyze uses
|
|
10
|
+
* (`parseGtmExport` → `buildMigrationGraph` → `classifyMigration`) and then the
|
|
11
|
+
* pure `generateDisabledMigrationDraft`, returning exactly the
|
|
12
|
+
* `CreateMigrationDraftBody` the developer submits, plus an inert review summary.
|
|
13
|
+
*
|
|
14
|
+
* SAFE-BY-CONSTRUCTION: the produced draft is `enabled:false, publishes:false`;
|
|
15
|
+
* the proposal carries only validator-safe values (the generator validates its
|
|
16
|
+
* own output against the pinned schema, and throws rather than emit an invalid
|
|
17
|
+
* one). Nothing here writes, enables, or publishes — it returns a plain data
|
|
18
|
+
* object, like the analyze builder.
|
|
19
|
+
*/
|
|
20
|
+
import {
|
|
21
|
+
parseGtmExport,
|
|
22
|
+
buildMigrationGraph,
|
|
23
|
+
classifyMigration,
|
|
24
|
+
generateDisabledMigrationDraft,
|
|
25
|
+
NO_ADAPTER_SELECTED_ID,
|
|
26
|
+
type CreateMigrationDraftBody,
|
|
27
|
+
type GtmViolation,
|
|
28
|
+
type MigrationFindingCode,
|
|
29
|
+
} from "@tot/public-runtime";
|
|
30
|
+
|
|
31
|
+
/** The inert, review-friendly summary of what installing the proposal would stage. */
|
|
32
|
+
export interface GtmDraftProposalSummary {
|
|
33
|
+
/** Whether a managed-analytics adapter was matched (false = nothing auto-portable). */
|
|
34
|
+
readonly adapterSelected: boolean;
|
|
35
|
+
/** Count of GTM objects that mapped confidently into the managed adapter. */
|
|
36
|
+
readonly mappedCount: number;
|
|
37
|
+
/** Count of GTM objects that could not be automatically ported (needs review). */
|
|
38
|
+
readonly unresolvedCount: number;
|
|
39
|
+
/** Whether a destination id was carried into the proposed configuration. */
|
|
40
|
+
readonly destinationConfigured: boolean;
|
|
41
|
+
/** Stable redacted finding codes (no source text). */
|
|
42
|
+
readonly findings: MigrationFindingCode[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface GtmProposeOk {
|
|
46
|
+
readonly ok: true;
|
|
47
|
+
readonly correlationId: string;
|
|
48
|
+
/** The exact body a caller POSTs to `createMigrationDraft`. */
|
|
49
|
+
readonly proposal: CreateMigrationDraftBody;
|
|
50
|
+
readonly summary: GtmDraftProposalSummary;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface GtmProposeErr {
|
|
54
|
+
readonly ok: false;
|
|
55
|
+
readonly correlationId: string;
|
|
56
|
+
readonly error: string;
|
|
57
|
+
readonly violations: GtmViolation[];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Parse + graph + classify an uploaded export into a disabled migration-draft
|
|
62
|
+
* PROPOSAL, or return the structured parse violations with a stable, safe error.
|
|
63
|
+
* NEVER throws on bad input, NEVER executes any part of the export.
|
|
64
|
+
*
|
|
65
|
+
* `draftId` is a placeholder the pure generator requires for its own provenance
|
|
66
|
+
* stamp; the authoritative draft id is assigned server-side by
|
|
67
|
+
* `createMigrationDraft`, so the caller only reads the returned `proposal`.
|
|
68
|
+
*/
|
|
69
|
+
export function proposeGtmMigrationDraft(
|
|
70
|
+
input: string | Uint8Array,
|
|
71
|
+
opts: { correlationId: string; generatedAt: string; draftId: string },
|
|
72
|
+
): GtmProposeOk | GtmProposeErr {
|
|
73
|
+
const parsed = parseGtmExport(input);
|
|
74
|
+
if (!parsed.ok) {
|
|
75
|
+
return {
|
|
76
|
+
ok: false,
|
|
77
|
+
correlationId: opts.correlationId,
|
|
78
|
+
error: "The uploaded file is not a valid GTM container export.",
|
|
79
|
+
violations: parsed.violations,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
const graph = buildMigrationGraph(parsed.container);
|
|
83
|
+
const disposition = classifyMigration(graph);
|
|
84
|
+
const result = generateDisabledMigrationDraft({
|
|
85
|
+
container: parsed.container,
|
|
86
|
+
graph,
|
|
87
|
+
disposition,
|
|
88
|
+
provenance: { draftId: opts.draftId, generatedAt: opts.generatedAt },
|
|
89
|
+
});
|
|
90
|
+
const { proposedAdapterId, proposedConfiguration, findings } = result.draft;
|
|
91
|
+
return {
|
|
92
|
+
ok: true,
|
|
93
|
+
correlationId: opts.correlationId,
|
|
94
|
+
proposal: { proposedAdapterId, proposedConfiguration, findings },
|
|
95
|
+
summary: {
|
|
96
|
+
adapterSelected: proposedAdapterId !== NO_ADAPTER_SELECTED_ID,
|
|
97
|
+
mappedCount: result.mappedConcepts.length,
|
|
98
|
+
unresolvedCount: result.unresolvedInventory.length,
|
|
99
|
+
destinationConfigured: findings.includes("ga4-destination-id-configured"),
|
|
100
|
+
findings,
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* operationParam — resolve the managed-analytics v1 `[operation]` path param
|
|
3
|
+
* robustly on BOTH admin surfaces.
|
|
4
|
+
*
|
|
5
|
+
* On the host-routed surface Astro populates `context.params.operation` normally.
|
|
6
|
+
* On the TENANT surface (`/<domain>/api/…`) the middleware selects the tenant by
|
|
7
|
+
* path prefix and rewrites via `next(renderPath)` — and across that rewrite this
|
|
8
|
+
* flat dynamic route comes back with an EMPTY `context.params` too (mirrors the
|
|
9
|
+
* identical rewrite defect `changeIdParam` solves for the change routes;
|
|
10
|
+
* `[operation].ts` is single-segment, so it has no nested-route case to worry
|
|
11
|
+
* about).
|
|
12
|
+
*
|
|
13
|
+
* Fallback: parse the operation from the pathname tail, which is stable on both
|
|
14
|
+
* surfaces and both sides of the rewrite (`request.url` is the original request,
|
|
15
|
+
* unaffected by the rewrite — see tenantPreview.ts's `stripBasePath`). No static
|
|
16
|
+
* sibling reaches this handler — Astro matches static routes first — so the tail
|
|
17
|
+
* capture is unambiguous wherever this helper runs.
|
|
18
|
+
*/
|
|
19
|
+
import type { APIContext } from "astro";
|
|
20
|
+
|
|
21
|
+
const TAIL = /\/api\/integrations\/v1\/([^/]+)\/?$/;
|
|
22
|
+
|
|
23
|
+
function fromPathname(pathname: string | undefined): string | undefined {
|
|
24
|
+
if (!pathname) return undefined;
|
|
25
|
+
const m = pathname.match(TAIL);
|
|
26
|
+
if (!m?.[1]) return undefined;
|
|
27
|
+
let id: string = m[1];
|
|
28
|
+
try {
|
|
29
|
+
id = decodeURIComponent(id);
|
|
30
|
+
} catch {
|
|
31
|
+
// keep the raw segment — the allowlist lookup downstream will refuse garbage
|
|
32
|
+
}
|
|
33
|
+
const trimmed = id.trim();
|
|
34
|
+
return trimmed || undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function operationParam(context: APIContext): string | undefined {
|
|
38
|
+
const fromParams = context.params.operation?.trim();
|
|
39
|
+
if (fromParams) return fromParams;
|
|
40
|
+
try {
|
|
41
|
+
return fromPathname(new URL(context.request.url).pathname);
|
|
42
|
+
} catch {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -244,6 +244,11 @@ export function isAdminPath(pathname: string): boolean {
|
|
|
244
244
|
* - every `/api/` route (tenant-state mutation). Genuinely-public API endpoints
|
|
245
245
|
* are already gate-exempt via {@link isPublicPath}/{@link isAuthPath}, so
|
|
246
246
|
* excluding all of `/api/` here costs nothing and fails safe.
|
|
247
|
+
*
|
|
248
|
+
* Every entry is matched in BOTH addressing forms — bare (`/api/...`) and
|
|
249
|
+
* tenant-prefixed (`/<tenant>/api/...`) — because the storefront serves the same
|
|
250
|
+
* route under both, so matching only one leaves the other reachable on a token or
|
|
251
|
+
* bypass header alone.
|
|
247
252
|
*/
|
|
248
253
|
export function isBypassPrivilegedPath(pathname: string): boolean {
|
|
249
254
|
if (isAdminPath(pathname)) return true;
|
|
@@ -252,6 +257,7 @@ export function isBypassPrivilegedPath(pathname: string): boolean {
|
|
|
252
257
|
if (pathname === "/dev" || pathname.startsWith("/dev/")) return true;
|
|
253
258
|
if (/^\/[^/]+\/dev(?:\/|$)/.test(pathname)) return true;
|
|
254
259
|
if (pathname === "/api" || pathname.startsWith("/api/")) return true;
|
|
260
|
+
if (/^\/[^/]+\/api(?:\/|$)/.test(pathname)) return true;
|
|
255
261
|
return false;
|
|
256
262
|
}
|
|
257
263
|
|
|
@@ -281,6 +287,19 @@ export interface LoginGateInput {
|
|
|
281
287
|
* (admin, cockpit, /api) are never bypassed regardless of this flag.
|
|
282
288
|
*/
|
|
283
289
|
qaBypass?: boolean;
|
|
290
|
+
/**
|
|
291
|
+
* True when the request presented a signed, unexpired customization-preview
|
|
292
|
+
* token (`?__preview=` or the `tot_preview` cookie) whose `tenantId` claim is
|
|
293
|
+
* THIS request's tenant. The token is HMAC-signed with
|
|
294
|
+
* `CUSTOMIZATION_PREVIEW_SECRET` and tenant-bound, so it is not client-forgeable
|
|
295
|
+
* and one tenant's token never admits a viewer to another's content.
|
|
296
|
+
*
|
|
297
|
+
* Available to EVERY tenant: previewing an unpublished storefront is platform
|
|
298
|
+
* functionality, not a property of which tenant is being viewed. Passes the gate
|
|
299
|
+
* for PUBLIC CONTENT paths only; {@link isBypassPrivilegedPath} paths (admin,
|
|
300
|
+
* cockpit, /api) still require a real session.
|
|
301
|
+
*/
|
|
302
|
+
previewTokenValid?: boolean;
|
|
284
303
|
}
|
|
285
304
|
|
|
286
305
|
/**
|
|
@@ -305,6 +324,12 @@ export function evaluateLoginGate(input: LoginGateInput): LoginGateDecision {
|
|
|
305
324
|
// public content only. NEVER for admin/cockpit/api — that guard lives here (not
|
|
306
325
|
// just in middleware) so /admin stays gated even if the flag is set upstream.
|
|
307
326
|
if (input.qaBypass && !isBypassPrivilegedPath(input.pathname)) return { action: "pass" };
|
|
327
|
+
// Customization preview: a signed, tenant-bound preview token admits a viewer to
|
|
328
|
+
// that tenant's public content on any tenant. Same privileged-path exclusion as
|
|
329
|
+
// the QA bypass — admin/cockpit/api are never reachable on a token alone.
|
|
330
|
+
if (input.previewTokenValid && !isBypassPrivilegedPath(input.pathname)) {
|
|
331
|
+
return { action: "pass" };
|
|
332
|
+
}
|
|
308
333
|
if (input.hasValidSession) return { action: "pass" };
|
|
309
334
|
const next = input.pathname + (input.search ?? "");
|
|
310
335
|
return { action: "redirect", location: `/auth/login?next=${encodeURIComponent(next)}` };
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import type { TenantConfig, ResolvedManagedCheckout } from "@tot/public-runtime";
|
|
16
16
|
import { resolveManagedCheckout } from "@tot/public-runtime";
|
|
17
|
-
import { readEnv } from "@/lib/
|
|
17
|
+
import { readEnv } from "@/lib/runtimeEnv";
|
|
18
18
|
|
|
19
19
|
/**
|
|
20
20
|
* Resolve the effective checkout facts for a tenant, or null when the tenant has
|
|
@@ -19,7 +19,7 @@ import type {
|
|
|
19
19
|
} from "@tot/public-runtime";
|
|
20
20
|
import { enforcementSignals } from "@tot/public-runtime";
|
|
21
21
|
import { readEnv } from "@/lib/env";
|
|
22
|
-
import {
|
|
22
|
+
import { COMPLIANCE_RULE_PROFILES } from "@/config/compliance/rulesets";
|
|
23
23
|
|
|
24
24
|
/** True when the tenant sells age/nicotine-restricted goods (drives fail-closed). */
|
|
25
25
|
export function isRegulatedTenant(
|
|
@@ -55,6 +55,26 @@ export function isSandboxVerificationProvider(
|
|
|
55
55
|
return !!provider && SANDBOX_VERIFICATION_PROVIDERS.has(provider.toLowerCase());
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
/**
|
|
59
|
+
* The LIVE Token of Trust verification provider. When `TOT_VERIFICATION_PROVIDER`
|
|
60
|
+
* resolves to this, `/api/compliance/preflight` calls the real ToT verification
|
|
61
|
+
* API server-side (`@/lib/verification/totVerificationClient`) and derives the
|
|
62
|
+
* verification state from that call ALONE — never from a client-supplied
|
|
63
|
+
* `verification` field and never from the front-door affirmation.
|
|
64
|
+
*
|
|
65
|
+
* Deliberately DISJOINT from {@link SANDBOX_VERIFICATION_PROVIDERS}: the
|
|
66
|
+
* affirmation-simulates-verification shortcut must be unreachable under the live
|
|
67
|
+
* provider (asserted in enforcement.test.ts).
|
|
68
|
+
*/
|
|
69
|
+
export const LIVE_TOT_VERIFICATION_PROVIDER = "tot";
|
|
70
|
+
|
|
71
|
+
/** True when the resolved provider is the live ToT verification integration. */
|
|
72
|
+
export function isLiveTotVerificationProvider(
|
|
73
|
+
provider: string | undefined | null,
|
|
74
|
+
): boolean {
|
|
75
|
+
return !!provider && provider.toLowerCase() === LIVE_TOT_VERIFICATION_PROVIDER;
|
|
76
|
+
}
|
|
77
|
+
|
|
58
78
|
/**
|
|
59
79
|
* The PACT reporting CAPTURE sink status. The record BUILDER
|
|
60
80
|
* (`buildPactReportRecord`) ships in this unit and the capture point is wired at
|
|
@@ -73,8 +93,10 @@ const PACT_REPORTING_CAPTURE_WIRED = true;
|
|
|
73
93
|
export async function resolveTenantRuleset(
|
|
74
94
|
tenant: Pick<TenantConfig, "tenant_id" | "compliance">,
|
|
75
95
|
): Promise<ComplianceRuleset | null> {
|
|
76
|
-
const
|
|
77
|
-
|
|
96
|
+
const profile = tenant.compliance?.ruleProfile;
|
|
97
|
+
const buildRuleset = profile ? COMPLIANCE_RULE_PROFILES[profile] : undefined;
|
|
98
|
+
if (!buildRuleset) return null;
|
|
99
|
+
const base = buildRuleset(tenant.tenant_id);
|
|
78
100
|
const provider = (await readEnv("TOT_VERIFICATION_PROVIDER")) || undefined;
|
|
79
101
|
return {
|
|
80
102
|
...base,
|
|
@@ -206,6 +206,50 @@ export const trackingTenantDatabases = sqliteTable(
|
|
|
206
206
|
}),
|
|
207
207
|
);
|
|
208
208
|
|
|
209
|
+
// --- Managed-analytics test destinations -------------------------------------
|
|
210
|
+
// A tenant-owned, NON-LIVE-ONLY destination a developer registers to prove
|
|
211
|
+
// exactly-once delivery for a new marketing action before wiring a real vendor
|
|
212
|
+
// adapter (GA4/Meta/Amplitude fire directly to the vendor with no server-side
|
|
213
|
+
// readback). `environment` is never `live` — enforced by the managed-analytics
|
|
214
|
+
// service, not by this table. Deliberately its OWN table, not `app_installs` —
|
|
215
|
+
// this is not an integration install (no adapter, no manifest, no CSP/consent
|
|
216
|
+
// plan); it is a disposable proving fixture for the action registry.
|
|
217
|
+
export const testDestinations = sqliteTable(
|
|
218
|
+
"test_destinations",
|
|
219
|
+
{
|
|
220
|
+
destinationId: text("destination_id").primaryKey(),
|
|
221
|
+
tenantId: text("tenant_id").notNull(),
|
|
222
|
+
environment: text("environment").notNull(),
|
|
223
|
+
createdAt: text("created_at").notNull(),
|
|
224
|
+
},
|
|
225
|
+
(t) => ({
|
|
226
|
+
idxTestDestinationsTenant: index(
|
|
227
|
+
"idx_test_destinations_tenant",
|
|
228
|
+
).on(t.tenantId),
|
|
229
|
+
}),
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
// The delivered-event ledger a test destination reads back. Keyed by
|
|
233
|
+
// (destination_id, event_id): a retried/duplicate delivery of the same event id
|
|
234
|
+
// is `INSERT OR IGNORE`, so the row a caller reads back always reflects the
|
|
235
|
+
// FIRST delivery only — the proof that delivery landed exactly once.
|
|
236
|
+
export const testDestinationDeliveries = sqliteTable(
|
|
237
|
+
"test_destination_deliveries",
|
|
238
|
+
{
|
|
239
|
+
destinationId: text("destination_id").notNull(),
|
|
240
|
+
eventId: text("event_id").notNull(),
|
|
241
|
+
tenantId: text("tenant_id").notNull(),
|
|
242
|
+
actionName: text("action_name").notNull(),
|
|
243
|
+
deliveredAt: text("delivered_at").notNull(),
|
|
244
|
+
},
|
|
245
|
+
(t) => ({
|
|
246
|
+
pk: primaryKey({ columns: [t.destinationId, t.eventId] }),
|
|
247
|
+
idxTestDestinationDeliveriesTenant: index(
|
|
248
|
+
"idx_test_destination_deliveries_tenant",
|
|
249
|
+
).on(t.tenantId),
|
|
250
|
+
}),
|
|
251
|
+
);
|
|
252
|
+
|
|
209
253
|
// --- Webhook event spine ----------------------------------------------------
|
|
210
254
|
// Immutable event log. `id` is the semantic event id we mint — distinct from
|
|
211
255
|
// any per-delivery wire envelope id (see `webhookDeliveries.deliveryId` below).
|
|
@@ -1,123 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
* - Cloudflare Workers (prod): reads the `cloudflare:workers` env module.
|
|
2
|
+
* The FULL runtime binding surface: everything in `./runtimeEnv.js` plus the D1
|
|
3
|
+
* binding. Control-plane code imports this one and gets all of it.
|
|
5
4
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
5
|
+
* The D1 accessor lives HERE rather than in `runtimeEnv.js` because ADR 0013
|
|
6
|
+
* keeps a live control-plane read off a tenant's public path, and a
|
|
7
|
+
* dependency-cruiser fence can only forbid a MODULE — never a symbol. Splitting
|
|
8
|
+
* `readD1` onto its own import graph is what gives
|
|
9
|
+
* `public-render-no-request-time-d1` (.dependency-cruiser.cjs) something to
|
|
10
|
+
* name: the public-render branch reaches `runtimeEnv.js` for the bindings that
|
|
11
|
+
* serve published bytes (KV cache, R2 bundles) and cannot reach this module at
|
|
12
|
+
* all, so a request-time D1 handle is unobtainable there rather than merely
|
|
13
|
+
* unused.
|
|
10
14
|
*/
|
|
11
15
|
import type { D1Like } from "./d1/catalog.js";
|
|
12
16
|
|
|
13
|
-
|
|
17
|
+
export * from "./runtimeEnv.js";
|
|
14
18
|
|
|
15
|
-
|
|
16
|
-
if (workerEnv !== undefined) return workerEnv;
|
|
17
|
-
try {
|
|
18
|
-
// @ts-expect-error — virtual module, only resolvable in the Workers runtime.
|
|
19
|
-
const mod = await import("cloudflare:workers");
|
|
20
|
-
workerEnv = (mod.env ?? null) as Record<string, unknown> | null;
|
|
21
|
-
} catch {
|
|
22
|
-
workerEnv = null;
|
|
23
|
-
}
|
|
24
|
-
return workerEnv;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
function fromBuild(key: string): string | undefined {
|
|
28
|
-
const meta = import.meta.env as Record<string, string | undefined>;
|
|
29
|
-
if (meta[key] != null) return meta[key];
|
|
30
|
-
if (typeof process !== "undefined" && process.env && process.env[key] != null) {
|
|
31
|
-
return process.env[key];
|
|
32
|
-
}
|
|
33
|
-
return undefined;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export async function readEnv(key: string): Promise<string | undefined> {
|
|
37
|
-
const w = await getWorkerEnv();
|
|
38
|
-
const wv = w?.[key];
|
|
39
|
-
if (typeof wv === "string" && wv.length) return wv;
|
|
40
|
-
return fromBuild(key);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/** The slice of the Workers ExecutionContext runAfterResponse needs — the
|
|
44
|
-
* @astrojs/cloudflare adapter exposes it to routes as `locals.cfContext`. */
|
|
45
|
-
export interface CfExecutionContextLike {
|
|
46
|
-
waitUntil(promise: Promise<unknown>): void;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Run `work` PAST the response — the request's `ExecutionContext.waitUntil`
|
|
51
|
-
* when the caller passes it, else a detached floating promise (dev/Node). For
|
|
52
|
-
* webhook RECEIVERS that must ACK fast: the upstream forwarder (tot-mcp
|
|
53
|
-
* gitea-webhook proxy) aborts its forward at 15s and Gitea bounds its delivery
|
|
54
|
-
* wait too, while a real reconcile takes tens of seconds — running it inside
|
|
55
|
-
* the request meant every healthy reconcile was killed mid-flight and recorded
|
|
56
|
-
* as `forward_failed` (a layer of Trello 13075, behind the auth fix).
|
|
57
|
-
*
|
|
58
|
-
* CALLERS MUST PASS `locals.cfContext` (the @astrojs/cloudflare adapter's
|
|
59
|
-
* per-request ExecutionContext, `createLocals: { cfContext: ctx }`): in the
|
|
60
|
-
* Workers runtime a merely-floating promise is CANCELLED the moment the
|
|
61
|
-
* response returns — an earlier revision here imported `waitUntil` from
|
|
62
|
-
* `cloudflare:workers` (not exported by this adapter/runtime version), silently
|
|
63
|
-
* degraded to the floating promise, and the async reconcile died unobserved
|
|
64
|
-
* right after its 202. The floating-promise branch is a DEV-ONLY fallback.
|
|
65
|
-
* Errors are the caller's to handle inside `work` (this never throws).
|
|
66
|
-
*/
|
|
67
|
-
export function runAfterResponse(
|
|
68
|
-
work: Promise<unknown>,
|
|
69
|
-
cfContext?: CfExecutionContextLike | null,
|
|
70
|
-
): void {
|
|
71
|
-
const swallowed = work.catch(() => {});
|
|
72
|
-
if (cfContext && typeof cfContext.waitUntil === "function") {
|
|
73
|
-
cfContext.waitUntil(swallowed);
|
|
74
|
-
return;
|
|
75
|
-
}
|
|
76
|
-
// dev/Node only — in the Workers runtime this promise would be cancelled at
|
|
77
|
-
// response end, which is exactly the bug the parameter above exists to avoid.
|
|
78
|
-
void swallowed;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/** KV binding for the newsletter stub / tenant cache, if present in the runtime. */
|
|
82
|
-
export async function readKv(binding: string): Promise<KVNamespace | undefined> {
|
|
83
|
-
const w = await getWorkerEnv();
|
|
84
|
-
const b = w?.[binding];
|
|
85
|
-
return (b as KVNamespace | undefined) ?? undefined;
|
|
86
|
-
}
|
|
19
|
+
import { getWorkerEnv } from "./runtimeEnv.js";
|
|
87
20
|
|
|
88
21
|
/** D1 catalog binding for the edge read replica, if present in the runtime. */
|
|
89
22
|
export async function readD1(binding: string): Promise<D1Like | undefined> {
|
|
90
23
|
const w = await getWorkerEnv();
|
|
91
24
|
return (w?.[binding] as D1Like | undefined) ?? undefined;
|
|
92
25
|
}
|
|
93
|
-
|
|
94
|
-
/** R2 bucket binding (e.g. TENANT_ASSETS, PRODUCT_IMAGES), if present. */
|
|
95
|
-
export async function readR2(binding: string): Promise<R2Bucket | undefined> {
|
|
96
|
-
const w = await getWorkerEnv();
|
|
97
|
-
return (w?.[binding] as R2Bucket | undefined) ?? undefined;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/**
|
|
101
|
-
* Cloudflare Analytics Engine dataset binding (e.g. ACTIVITY_ANALYTICS), if
|
|
102
|
-
* present. Untyped (`unknown`) deliberately — the caller casts to its own
|
|
103
|
-
* minimal `writeDataPoint` shape (see `activity/store.ts`'s
|
|
104
|
-
* `AnalyticsEngineLike`) rather than this module taking a types dep. Absent in
|
|
105
|
-
* dev/Node and until the binding is provisioned; callers treat undefined as
|
|
106
|
-
* "analytics sink unavailable, skip it" (never an error).
|
|
107
|
-
*/
|
|
108
|
-
export async function readAnalytics(binding: string): Promise<unknown | undefined> {
|
|
109
|
-
const w = await getWorkerEnv();
|
|
110
|
-
return w?.[binding] ?? undefined;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* Cloudflare Queue producer binding (e.g. WEBHOOK_DELIVERY_QUEUE), if present.
|
|
115
|
-
* Untyped (`unknown`) deliberately — callers cast to their own minimal
|
|
116
|
-
* queue-shape interface (see `webhooks/cloudflareQueueDispatcher.ts`'s
|
|
117
|
-
* `CloudflareQueueSender`) rather than this module taking a types dep on a
|
|
118
|
-
* specific binding's shape.
|
|
119
|
-
*/
|
|
120
|
-
export async function readQueue(binding: string): Promise<unknown | undefined> {
|
|
121
|
-
const w = await getWorkerEnv();
|
|
122
|
-
return w?.[binding] ?? undefined;
|
|
123
|
-
}
|