@tokenoftrust/storefront-runner 1.3.4-rc.4 → 1.4.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 +15 -0
- package/apps/storefront/dev-plugins/dev-publish.mjs +55 -0
- package/apps/storefront/dev-plugins/tenant-hot-reload.mjs +86 -7
- package/apps/storefront/drizzle.config.apps.ts +13 -0
- package/apps/storefront/env.d.ts +10 -0
- package/apps/storefront/migrations/README.md +13 -7
- package/apps/storefront/migrations-apps/0000_fast_millenium_guard.sql +129 -0
- package/apps/storefront/migrations-apps/meta/0000_snapshot.json +843 -0
- package/apps/storefront/migrations-apps/meta/_journal.json +13 -0
- package/apps/storefront/package.json +13 -1
- package/apps/storefront/perf/README.md +64 -0
- package/apps/storefront/perf/assert-budgets.ts +159 -0
- package/apps/storefront/playwright.config.ts +23 -0
- package/apps/storefront/public/js/dashboard-apps.js +173 -0
- package/apps/storefront/public/shared/commerce-marketing.css +221 -0
- package/apps/storefront/src/components/CollectionCard.astro +1 -0
- package/apps/storefront/src/components/ProductCard.astro +1 -0
- package/apps/storefront/src/components/admin/AdminPublishTab.astro +1959 -0
- package/apps/storefront/src/components/apps/AppWidgetFrame.astro +30 -0
- package/apps/storefront/src/components/chrome/NavDropdown.astro +6 -3
- package/apps/storefront/src/components/chrome/SiteFooter.astro +10 -0
- package/apps/storefront/src/components/chrome/SiteHeader.astro +10 -0
- package/apps/storefront/src/components/commerce/RatingStars.astro +3 -2
- package/apps/storefront/src/components/content/Callout.astro +75 -0
- package/apps/storefront/src/components/content/NeedsReviewCallout.astro +66 -0
- package/apps/storefront/src/components/content/ProseSections.astro +121 -0
- package/apps/storefront/src/components/content/ProseToc.astro +34 -0
- package/apps/storefront/src/components/content/RichText.astro +44 -0
- package/apps/storefront/src/components/content/TrustStrip.astro +46 -0
- package/apps/storefront/src/components/home/Hero.astro +14 -0
- package/apps/storefront/src/components/islands/CheckoutComplianceGate.tsx +295 -0
- package/apps/storefront/src/components/islands/ImageGallery.tsx +42 -21
- package/apps/storefront/src/components/islands/VariantSelector.tsx +62 -12
- package/apps/storefront/src/components/plp/FacetSidebar.astro +2 -2
- package/apps/storefront/src/components/subscription/ManageSubscriptionEntry.astro +1 -0
- package/apps/storefront/src/config/compliance/rulesets.ts +79 -0
- package/apps/storefront/src/config/storeName.ts +34 -0
- package/apps/storefront/src/layouts/Layout.astro +97 -23
- package/apps/storefront/src/lib/analytics/budgets.json +69 -0
- package/apps/storefront/src/lib/analytics/lighthouseReport.ts +109 -0
- package/apps/storefront/src/lib/analytics/perfBudgets.ts +452 -0
- package/apps/storefront/src/lib/analytics/rumAlert.ts +179 -0
- package/apps/storefront/src/lib/analytics/webVitals.ts +269 -0
- package/apps/storefront/src/lib/apps/adminService.ts +106 -0
- package/apps/storefront/src/lib/apps/adminSession.ts +205 -0
- package/apps/storefront/src/lib/apps/apiAuth.ts +91 -0
- package/apps/storefront/src/lib/apps/apiRoute.ts +35 -0
- package/apps/storefront/src/lib/apps/catalogMapper.ts +39 -0
- package/apps/storefront/src/lib/apps/credentials.ts +153 -0
- package/apps/storefront/src/lib/apps/gatewayKeys.ts +156 -0
- package/apps/storefront/src/lib/apps/healthAggregate.ts +66 -0
- package/apps/storefront/src/lib/apps/orders/attributionService.ts +206 -0
- package/apps/storefront/src/lib/apps/orders/customerHash.ts +28 -0
- package/apps/storefront/src/lib/apps/orders/foxyOrderClient.ts +203 -0
- package/apps/storefront/src/lib/apps/orders/idempotency.ts +100 -0
- package/apps/storefront/src/lib/apps/orders/orderForwardReceiver.ts +117 -0
- package/apps/storefront/src/lib/apps/orders/orderMapper.ts +91 -0
- package/apps/storefront/src/lib/apps/orders/ordersStore.ts +181 -0
- package/apps/storefront/src/lib/apps/registryService.ts +579 -0
- package/apps/storefront/src/lib/apps/scopes.ts +79 -0
- package/apps/storefront/src/lib/apps/tokenIssuer.ts +121 -0
- package/apps/storefront/src/lib/apps/tokenVerifier.ts +148 -0
- package/apps/storefront/src/lib/apps/widgets/eligibility.ts +18 -0
- package/apps/storefront/src/lib/apps/widgets/frameProps.ts +52 -0
- package/apps/storefront/src/lib/apps/widgets/launchToken.ts +84 -0
- package/apps/storefront/src/lib/apps/widgets/placements.ts +57 -0
- package/apps/storefront/src/lib/apps/widgets/renderSlot.ts +111 -0
- package/apps/storefront/src/lib/auth/adminEntry.ts +119 -0
- package/apps/storefront/src/lib/auth/identityToken.ts +21 -2
- package/apps/storefront/src/lib/auth/loginGate.ts +102 -18
- package/apps/storefront/src/lib/auth/mcpClientAssertion.ts +215 -0
- package/apps/storefront/src/lib/auth/route.ts +16 -1
- package/apps/storefront/src/lib/auth/session.ts +8 -0
- package/apps/storefront/src/lib/auth/stepUpChallenge.ts +107 -0
- package/apps/storefront/src/lib/auth/totAccessClient.ts +208 -0
- package/apps/storefront/src/lib/blog/provider.ts +39 -0
- package/apps/storefront/src/lib/blog/types.ts +26 -0
- package/apps/storefront/src/lib/checkoutCommerce.ts +39 -1
- package/apps/storefront/src/lib/chrome/model.ts +11 -0
- package/apps/storefront/src/lib/compliance/enforcement.ts +95 -0
- package/apps/storefront/src/lib/content/callout.ts +78 -0
- package/apps/storefront/src/lib/content/index.ts +27 -0
- package/apps/storefront/src/lib/content/needsReview.ts +58 -0
- package/apps/storefront/src/lib/content/prose.ts +186 -0
- package/apps/storefront/src/lib/content/richtext.ts +76 -0
- package/apps/storefront/src/lib/content/trustStrip.ts +74 -0
- package/apps/storefront/src/lib/content-edit/client.ts +70 -14
- package/apps/storefront/src/lib/d1/catalog.ts +12 -0
- package/apps/storefront/src/lib/d1/schema-apps.ts +213 -0
- package/apps/storefront/src/lib/dev/apiBase.ts +8 -2
- package/apps/storefront/src/lib/dev/cliSignInCode.ts +65 -115
- package/apps/storefront/src/lib/dev/cockpitStore.ts +65 -0
- package/apps/storefront/src/lib/dev/previewStatus.ts +112 -0
- package/apps/storefront/src/lib/dev/rendezvousBroker.ts +241 -0
- package/apps/storefront/src/lib/dev/subjectReissue.ts +67 -0
- package/apps/storefront/src/lib/email/magicLinkInviteEmail.ts +10 -10
- package/apps/storefront/src/lib/env.ts +12 -0
- package/apps/storefront/src/lib/jsonld.ts +12 -17
- package/apps/storefront/src/lib/membership/eligibility.ts +37 -0
- package/apps/storefront/src/lib/monitoring/manifest.ts +302 -0
- package/apps/storefront/src/lib/privacy/emailHint.ts +13 -5
- package/apps/storefront/src/lib/publish/apex-readiness.ts +337 -0
- package/apps/storefront/src/lib/publish/dispatchHealth.ts +269 -0
- package/apps/storefront/src/lib/publish/domainState.ts +351 -0
- package/apps/storefront/src/lib/publish/shipWorkspace.ts +362 -0
- package/apps/storefront/src/lib/rawChrome.ts +34 -3
- package/apps/storefront/src/lib/storyblok/content-model.ts +34 -2
- package/apps/storefront/src/lib/storyblok/provider.ts +11 -4
- package/apps/storefront/src/lib/subscription/model.ts +114 -0
- package/apps/storefront/src/lib/tot/ToTClient.ts +3 -3
- package/apps/storefront/src/lib/tot/query.ts +32 -0
- package/apps/storefront/src/lib/webhooks/cloudflareQueueDispatcher.ts +82 -0
- package/apps/storefront/src/lib/webhooks/deliveryEngine.ts +245 -0
- package/apps/storefront/src/lib/webhooks/deliveryMapper.ts +34 -0
- package/apps/storefront/src/lib/webhooks/deliveryStore.ts +668 -0
- package/apps/storefront/src/lib/webhooks/dispatcher.ts +168 -0
- package/apps/storefront/src/lib/webhooks/emit.ts +167 -0
- package/apps/storefront/src/lib/webhooks/endpointGuard.ts +135 -0
- package/apps/storefront/src/lib/webhooks/events.ts +98 -0
- package/apps/storefront/src/lib/webhooks/getDispatcher.ts +49 -0
- package/apps/storefront/src/lib/webhooks/signing.ts +29 -0
- package/apps/storefront/src/lib/webhooks/webhookSigningKey.ts +146 -0
- package/apps/storefront/src/middleware/index.ts +28 -13
- package/apps/storefront/src/pages/404.astro +21 -9
- package/apps/storefront/src/pages/[...slug].astro +36 -2
- package/apps/storefront/src/pages/admin.astro +29 -3
- package/apps/storefront/src/pages/api/apps/admin/credentials/rotate.ts +60 -0
- package/apps/storefront/src/pages/api/apps/admin/health.ts +44 -0
- package/apps/storefront/src/pages/api/apps/admin/install.ts +100 -0
- package/apps/storefront/src/pages/api/apps/admin/list.ts +26 -0
- package/apps/storefront/src/pages/api/apps/admin/resume.ts +56 -0
- package/apps/storefront/src/pages/api/apps/admin/suspend.ts +57 -0
- package/apps/storefront/src/pages/api/apps/admin/uninstall.ts +74 -0
- package/apps/storefront/src/pages/api/apps/admin/update.ts +103 -0
- package/apps/storefront/src/pages/api/apps/admin/webhooks/deliveries/[deliveryId]/replay.ts +59 -0
- package/apps/storefront/src/pages/api/apps/admin/webhooks/deliveries.ts +51 -0
- package/apps/storefront/src/pages/api/apps/internal/order-forward.ts +172 -0
- package/apps/storefront/src/pages/api/apps/oauth/token.ts +87 -0
- package/apps/storefront/src/pages/api/apps/v1/attribution.ts +142 -0
- package/apps/storefront/src/pages/api/apps/v1/catalog/products/[handle].ts +39 -0
- package/apps/storefront/src/pages/api/apps/v1/catalog/products.ts +49 -0
- package/apps/storefront/src/pages/api/apps/v1/health.ts +32 -0
- package/apps/storefront/src/pages/api/apps/v1/inventory.ts +59 -0
- package/apps/storefront/src/pages/api/apps/v1/orders/[id].ts +48 -0
- package/apps/storefront/src/pages/api/apps/v1/orders.ts +73 -0
- package/apps/storefront/src/pages/api/apps/v1/reports.ts +21 -0
- package/apps/storefront/src/pages/api/apps/v1/webhooks/deliveries/[deliveryId]/replay.ts +62 -0
- package/apps/storefront/src/pages/api/apps/v1/webhooks/deliveries.ts +47 -0
- package/apps/storefront/src/pages/api/auth/magic-exchange.ts +48 -1
- package/apps/storefront/src/pages/api/auth/step-up-send.ts +57 -0
- package/apps/storefront/src/pages/api/auth/step-up-verify.ts +129 -0
- package/apps/storefront/src/pages/api/auth/verify.ts +23 -0
- package/apps/storefront/src/pages/api/compliance/preflight.ts +206 -0
- package/apps/storefront/src/pages/api/rum/vitals.ts +54 -0
- package/apps/storefront/src/pages/api/test/dev-session.ts +133 -0
- package/apps/storefront/src/pages/auth/login.astro +197 -41
- package/apps/storefront/src/pages/auth/magic.astro +75 -43
- package/apps/storefront/src/pages/blog/[slug].astro +107 -0
- package/apps/storefront/src/pages/blog/index.astro +98 -0
- package/apps/storefront/src/pages/capabilities.astro +8 -0
- package/apps/storefront/src/pages/cockpit.astro +433 -69
- package/apps/storefront/src/pages/collections/[handle].astro +8 -0
- package/apps/storefront/src/pages/collections/index.astro +10 -2
- package/apps/storefront/src/pages/dashboard/[appDomain]/apps/index.astro +119 -0
- package/apps/storefront/src/pages/dashboard/[appDomain]/index.astro +5 -0
- package/apps/storefront/src/pages/index.astro +57 -0
- package/apps/storefront/src/pages/llms.txt.ts +31 -10
- package/apps/storefront/src/pages/products/[handle].astro +100 -9
- package/apps/storefront/src/pages/sitemap.xml.ts +21 -4
- package/apps/storefront/src/pages/style-guide/[tenant]/[theme].astro +198 -0
- package/apps/storefront/src/pages/style-guide/[tenant]/chrome/[theme].astro +7 -0
- package/apps/storefront/src/pages/style-guide/[tenant]/guide/[theme].astro +7 -0
- package/apps/storefront/src/pages/style-guide/[tenant]/index.astro +7 -0
- package/apps/storefront/src/pages/style-guide/index.astro +10 -0
- package/apps/storefront/src/styles/fonts.css +54 -0
- package/apps/storefront/src/styles/global.css +22 -2
- package/apps/storefront/src/themes/schema.ts +3 -25
- package/apps/storefront/tsconfig.json +1 -1
- package/apps/storefront/vitest.config.ts +4 -1
- package/package.json +1 -1
- package/packages/public-runtime/src/candidate-index.ts +311 -0
- package/packages/public-runtime/src/checkout.ts +94 -2
- package/packages/public-runtime/src/compliance/evaluate.ts +265 -0
- package/packages/public-runtime/src/compliance/evidence-signals.ts +81 -0
- package/packages/public-runtime/src/compliance/index.ts +22 -0
- package/packages/public-runtime/src/compliance/pact-report.ts +94 -0
- package/packages/public-runtime/src/compliance/profile.ts +198 -0
- package/packages/public-runtime/src/compliance/ruleset.ts +117 -0
- package/packages/public-runtime/src/compliance/verification.ts +81 -0
- package/packages/public-runtime/src/csp.ts +27 -3
- package/packages/public-runtime/src/customization-reconcile.ts +35 -0
- package/packages/public-runtime/src/customization-runtime.ts +8 -0
- package/packages/public-runtime/src/customization-versioning.ts +17 -0
- package/packages/public-runtime/src/extension-contract.ts +2 -1
- package/packages/public-runtime/src/hash.ts +25 -0
- package/packages/public-runtime/src/index.ts +6 -0
- package/packages/public-runtime/src/membership.ts +353 -0
- package/packages/public-runtime/src/product.ts +24 -2
- package/packages/public-runtime/src/review-trust-proof.ts +194 -0
- package/packages/public-runtime/src/tenant-assets.ts +40 -5
- package/packages/public-runtime/src/tenant.ts +236 -0
- package/packages/public-runtime/src/widget-postmessage.ts +205 -0
- package/scripts/dev/publish.mjs +158 -0
- package/scripts/dev/transient-files.mjs +2 -1
- package/tenants/home/public/fonts/inter-latin-400-normal.woff2 +0 -0
- package/tenants/home/public/fonts/inter-latin-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/inter-latin-600-normal.woff2 +0 -0
- package/tenants/home/public/fonts/inter-latin-ext-400-normal.woff2 +0 -0
- package/tenants/home/public/fonts/inter-latin-ext-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/inter-latin-ext-600-normal.woff2 +0 -0
- package/tenants/home/public/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
- package/tenants/home/public/fonts/jetbrains-mono-latin-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/jetbrains-mono-latin-ext-400-normal.woff2 +0 -0
- package/tenants/home/public/fonts/jetbrains-mono-latin-ext-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-600-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-700-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-800-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-600-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-700-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-800-normal.woff2 +0 -0
- package/tenants/home/public/pages/storefront.css +23 -0
- package/apps/storefront/src/lib/dev/hostedCockpit.ts +0 -169
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drizzle schema — the source of truth for `STOREFRONT_APPS_DB` *migration
|
|
3
|
+
* generation* (ADR-0002). Split out of `schema.ts` (which now covers only the
|
|
4
|
+
* catalog + customization tables on `CATALOG_DB`): these tables are the
|
|
5
|
+
* storefront-apps registry/webhooks/orders spine and belong to a DEDICATED
|
|
6
|
+
* D1, never the catalog.
|
|
7
|
+
*
|
|
8
|
+
* `drizzle-kit generate --config drizzle.config.apps.ts` diffs this into
|
|
9
|
+
* numbered SQL under `migrations-apps/`, applied by `wrangler d1 migrations
|
|
10
|
+
* apply STOREFRONT_APPS_DB`.
|
|
11
|
+
*/
|
|
12
|
+
import { sqliteTable, text, integer, real, index, uniqueIndex, primaryKey } from "drizzle-orm/sqlite-core";
|
|
13
|
+
|
|
14
|
+
// --- Private Apps registry (PrivateApps epic, D2) ----------------------------
|
|
15
|
+
// One install per (app, tenant, env). `installId` is the future JWT `sub`.
|
|
16
|
+
// Append-only `app_audit_log` is never pruned by the service layer.
|
|
17
|
+
export const appInstalls = sqliteTable(
|
|
18
|
+
"app_installs",
|
|
19
|
+
{
|
|
20
|
+
installId: text("install_id").primaryKey(),
|
|
21
|
+
appId: text("app_id").notNull(),
|
|
22
|
+
appVersion: text("app_version").notNull(),
|
|
23
|
+
tenantId: text("tenant_id").notNull(),
|
|
24
|
+
env: text("env").notNull(),
|
|
25
|
+
status: text("status").notNull().default("active"),
|
|
26
|
+
scopes: text("scopes").notNull().default("[]"),
|
|
27
|
+
manifest: text("manifest").notNull(),
|
|
28
|
+
webhookEndpoint: text("webhook_endpoint"),
|
|
29
|
+
widgetPlacements: text("widget_placements").notNull().default("[]"),
|
|
30
|
+
installActor: text("install_actor").notNull(),
|
|
31
|
+
createdAt: text("created_at").notNull(),
|
|
32
|
+
updatedAt: text("updated_at").notNull(),
|
|
33
|
+
},
|
|
34
|
+
(t) => ({
|
|
35
|
+
uqAppInstallsTenantEnv: uniqueIndex("uq_app_installs_app_tenant_env").on(
|
|
36
|
+
t.appId,
|
|
37
|
+
t.tenantId,
|
|
38
|
+
t.env,
|
|
39
|
+
),
|
|
40
|
+
idxAppInstallsTenant: index("idx_app_installs_tenant").on(t.tenantId),
|
|
41
|
+
}),
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
// Server-issued client-credentials for one install. The secret is never stored
|
|
45
|
+
// in plaintext — only its sha256 hash (@tot/public-runtime's `sha256Hex`).
|
|
46
|
+
export const appCredentials = sqliteTable("app_credentials", {
|
|
47
|
+
installId: text("install_id").primaryKey(),
|
|
48
|
+
clientId: text("client_id").notNull().unique(),
|
|
49
|
+
secretHash: text("secret_hash").notNull(),
|
|
50
|
+
secretCreatedAt: text("secret_created_at").notNull(),
|
|
51
|
+
rotatedAt: text("rotated_at"),
|
|
52
|
+
jwksKid: text("jwks_kid").notNull(),
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// Append-only audit trail. `actor` is an opaque id — never email/PII — and
|
|
56
|
+
// `detail` is redacted JSON (never secret material).
|
|
57
|
+
export const appAuditLog = sqliteTable(
|
|
58
|
+
"app_audit_log",
|
|
59
|
+
{
|
|
60
|
+
id: text("id").primaryKey(),
|
|
61
|
+
installId: text("install_id").notNull(),
|
|
62
|
+
tenantId: text("tenant_id").notNull(),
|
|
63
|
+
event: text("event").notNull(),
|
|
64
|
+
actor: text("actor").notNull(),
|
|
65
|
+
traceId: text("trace_id"),
|
|
66
|
+
detail: text("detail").notNull().default("{}"),
|
|
67
|
+
at: text("at").notNull(),
|
|
68
|
+
},
|
|
69
|
+
(t) => ({
|
|
70
|
+
idxAppAuditInstall: index("idx_app_audit_install").on(t.installId),
|
|
71
|
+
}),
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
// --- Webhook event spine (PrivateApps epic, D4 Chunk A) ---------------------
|
|
75
|
+
// Immutable event log. `id` is the semantic event id we mint — distinct from
|
|
76
|
+
// any per-delivery wire envelope id (see `webhookDeliveries.deliveryId` below).
|
|
77
|
+
export const webhookEvents = sqliteTable("webhook_events", {
|
|
78
|
+
id: text("id").primaryKey(),
|
|
79
|
+
tenantId: text("tenant_id").notNull(),
|
|
80
|
+
topic: text("topic").notNull(),
|
|
81
|
+
dataschemaVersion: text("dataschema_version").notNull(),
|
|
82
|
+
payload: text("payload").notNull(),
|
|
83
|
+
createdAt: text("created_at").notNull(),
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// One row per (event × target install). `deliveryId` is the on-wire
|
|
87
|
+
// CloudEvents `id` — stable across retries/replays so receivers can dedupe —
|
|
88
|
+
// which is why it, not `eventId`, is the primary key here.
|
|
89
|
+
export const webhookDeliveries = sqliteTable(
|
|
90
|
+
"webhook_deliveries",
|
|
91
|
+
{
|
|
92
|
+
deliveryId: text("delivery_id").primaryKey(),
|
|
93
|
+
// FK to webhookEvents.id, enforced in code only (no SQL FK — repo convention).
|
|
94
|
+
eventId: text("event_id").notNull(),
|
|
95
|
+
installId: text("install_id").notNull(),
|
|
96
|
+
tenantId: text("tenant_id").notNull(),
|
|
97
|
+
topic: text("topic").notNull(),
|
|
98
|
+
state: text("state").notNull().default("pending"),
|
|
99
|
+
attempts: integer("attempts").notNull().default(0),
|
|
100
|
+
lastStatus: integer("last_status"),
|
|
101
|
+
lastErrorClass: text("last_error_class"),
|
|
102
|
+
lastTraceId: text("last_trace_id"),
|
|
103
|
+
nextAttemptAt: text("next_attempt_at"),
|
|
104
|
+
createdAt: text("created_at").notNull(),
|
|
105
|
+
updatedAt: text("updated_at").notNull(),
|
|
106
|
+
},
|
|
107
|
+
(t) => ({
|
|
108
|
+
idxWebhookDeliveriesInstall: index("idx_webhook_deliveries_install").on(t.installId),
|
|
109
|
+
idxWebhookDeliveriesDue: index("idx_webhook_deliveries_due").on(t.state, t.nextAttemptAt),
|
|
110
|
+
}),
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
// Append-only per-HTTP-attempt log, never pruned by the service layer.
|
|
114
|
+
export const webhookDeliveryAttempts = sqliteTable(
|
|
115
|
+
"webhook_delivery_attempts",
|
|
116
|
+
{
|
|
117
|
+
id: text("id").primaryKey(),
|
|
118
|
+
deliveryId: text("delivery_id").notNull(),
|
|
119
|
+
attemptNo: integer("attempt_no").notNull(),
|
|
120
|
+
startedAt: text("started_at").notNull(),
|
|
121
|
+
durationMs: integer("duration_ms"),
|
|
122
|
+
httpStatus: integer("http_status"),
|
|
123
|
+
errorClass: text("error_class"),
|
|
124
|
+
traceId: text("trace_id"),
|
|
125
|
+
},
|
|
126
|
+
(t) => ({
|
|
127
|
+
idxWebhookDeliveryAttemptsDelivery: index("idx_webhook_delivery_attempts_delivery").on(t.deliveryId),
|
|
128
|
+
}),
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
// Rollup, one row per install — mirrors the openapi `InstallHealth` shape.
|
|
132
|
+
// `recentTraceIds` is a capped (~10) JSON array, newest first.
|
|
133
|
+
export const webhookInstallHealth = sqliteTable("webhook_install_health", {
|
|
134
|
+
installId: text("install_id").primaryKey(),
|
|
135
|
+
lastSuccessAt: text("last_success_at"),
|
|
136
|
+
failureStreak: integer("failure_streak").notNull().default(0),
|
|
137
|
+
dlqCount: integer("dlq_count").notNull().default(0),
|
|
138
|
+
lastStatus: integer("last_status"),
|
|
139
|
+
lastErrorClass: text("last_error_class"),
|
|
140
|
+
recentTraceIds: text("recent_trace_ids").notNull().default("[]"),
|
|
141
|
+
updatedAt: text("updated_at").notNull(),
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// --- Order ingestion (PrivateApps epic, D6 Chunk A) --------------------------
|
|
145
|
+
// The read-model `ordersStore.ts` diffs incoming Foxy orders against (created
|
|
146
|
+
// → fulfilled transitions, for a later chunk's event emission) and that the
|
|
147
|
+
// attribution route (also later) reads from for its cross-tenant ownership
|
|
148
|
+
// check. `(tenantId, externalOrderId)` is the composite PK — same "no
|
|
149
|
+
// synthetic id, the natural key IS the key" style as `customizationVersions`
|
|
150
|
+
// (schema.ts) — no SQL FK to `appAttributions.orderId` (repo convention — code-only).
|
|
151
|
+
export const orders = sqliteTable(
|
|
152
|
+
"orders",
|
|
153
|
+
{
|
|
154
|
+
tenantId: text("tenant_id").notNull(),
|
|
155
|
+
// Foxy's own transaction id — never generated here.
|
|
156
|
+
externalOrderId: text("external_order_id").notNull(),
|
|
157
|
+
env: text("env").notNull(),
|
|
158
|
+
status: text("status").notNull(),
|
|
159
|
+
currency: text("currency").notNull(),
|
|
160
|
+
total: real("total").notNull().default(0),
|
|
161
|
+
subtotal: real("subtotal"),
|
|
162
|
+
// Opaque, stable per (tenant, app-install) — see `orders/customerHash.ts`. Never raw PII.
|
|
163
|
+
customerHash: text("customer_hash"),
|
|
164
|
+
lineItems: text("line_items").notNull().default("[]"),
|
|
165
|
+
createdAt: text("created_at").notNull(),
|
|
166
|
+
updatedAt: text("updated_at").notNull(),
|
|
167
|
+
},
|
|
168
|
+
(t) => ({
|
|
169
|
+
pk: primaryKey({ columns: [t.tenantId, t.externalOrderId] }),
|
|
170
|
+
idxOrdersTenant: index("idx_orders_tenant").on(t.tenantId),
|
|
171
|
+
}),
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
// First idempotency ledger in the codebase (D6 Chunk A) — keyed per
|
|
175
|
+
// `(installId, idempotencyKey)` so a retried write (e.g. attribution POST)
|
|
176
|
+
// replays the stored `responseBody` instead of re-applying the mutation.
|
|
177
|
+
// `bodyHash` (sha256 of the canonical request body) lets a caller re-sending
|
|
178
|
+
// the SAME key with a DIFFERENT body be rejected rather than silently replayed.
|
|
179
|
+
export const idempotencyKeys = sqliteTable(
|
|
180
|
+
"idempotency_keys",
|
|
181
|
+
{
|
|
182
|
+
installId: text("install_id").notNull(),
|
|
183
|
+
idempotencyKey: text("idempotency_key").notNull(),
|
|
184
|
+
bodyHash: text("body_hash").notNull(),
|
|
185
|
+
responseBody: text("response_body").notNull(),
|
|
186
|
+
createdAt: text("created_at").notNull(),
|
|
187
|
+
},
|
|
188
|
+
(t) => ({ pk: primaryKey({ columns: [t.installId, t.idempotencyKey] }) }),
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
// App-owned attribution/commission records (`attribution:write`, D6 — later
|
|
192
|
+
// chunk owns the route). `orderId` references `orders.externalOrderId` in code
|
|
193
|
+
// only (no SQL FK, repo convention); the route chunk enforces the cross-tenant
|
|
194
|
+
// ownership check the contract requires before ever writing a row here.
|
|
195
|
+
export const appAttributions = sqliteTable(
|
|
196
|
+
"app_attributions",
|
|
197
|
+
{
|
|
198
|
+
installId: text("install_id").notNull(),
|
|
199
|
+
attributionId: text("attribution_id").notNull(),
|
|
200
|
+
tenantId: text("tenant_id").notNull(),
|
|
201
|
+
orderId: text("order_id").notNull(),
|
|
202
|
+
campaign: text("campaign"),
|
|
203
|
+
referralCode: text("referral_code"),
|
|
204
|
+
commissionAmount: real("commission_amount"),
|
|
205
|
+
commissionCurrency: text("commission_currency"),
|
|
206
|
+
customerHash: text("customer_hash"),
|
|
207
|
+
recordedAt: text("recorded_at").notNull(),
|
|
208
|
+
},
|
|
209
|
+
(t) => ({
|
|
210
|
+
pk: primaryKey({ columns: [t.installId, t.attributionId] }),
|
|
211
|
+
idxAppAttributionsTenantOrder: index("idx_app_attributions_tenant_order").on(t.tenantId, t.orderId),
|
|
212
|
+
}),
|
|
213
|
+
);
|
|
@@ -8,7 +8,13 @@
|
|
|
8
8
|
*
|
|
9
9
|
* See decision `architecture-environment-aware-cockpit`
|
|
10
10
|
* (workstream tot-lime-cloud-dev-cockpit).
|
|
11
|
+
*
|
|
12
|
+
* `basePath` is the request's tenant prefix ("" host-routed, "/<tenant>" path-routed).
|
|
13
|
+
* The HOSTED routes are tenant-scoped through the middleware's host/path resolution, so
|
|
14
|
+
* a `/<tenant>/cockpit` render must call `/<tenant>/api/dev/*` for `locals.tenant` to be
|
|
15
|
+
* that store on the API request (not the generic home host). The local `/__tot/*` dev
|
|
16
|
+
* plugins are single-tenant and take no prefix.
|
|
11
17
|
*/
|
|
12
|
-
export function resolveDevApiBase(isDev: boolean): string {
|
|
13
|
-
return isDev ? "/__tot" :
|
|
18
|
+
export function resolveDevApiBase(isDev: boolean, basePath = ""): string {
|
|
19
|
+
return isDev ? "/__tot" : `${basePath}/api/dev`;
|
|
14
20
|
}
|
|
@@ -1,133 +1,81 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Mint a browserless "get going" command for an ALREADY-AUTHENTICATED developer — the
|
|
3
3
|
* second half of the invite story. The developer accepted the magic link (OTP-free web
|
|
4
|
-
* sign-in) and landed on /
|
|
5
|
-
* the
|
|
6
|
-
* browser on the working site) — so they go from web sign-in to a running store
|
|
7
|
-
* second browser round-trip and NO OTP.
|
|
4
|
+
* sign-in) and landed on /cockpit; this hands them a single-copy, THREE-LINE paste — line 1
|
|
5
|
+
* installs the CLI, line 2 signs the terminal in, line 3 runs `start` (checkout → run →
|
|
6
|
+
* open the browser on the working site) — so they go from web sign-in to a running store
|
|
7
|
+
* with NO second browser round-trip and NO OTP.
|
|
8
8
|
*
|
|
9
|
-
* The
|
|
10
|
-
*
|
|
11
|
-
* —
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
9
|
+
* The paste carries a NON-SECRET rendezvous handle minted by the MCP's
|
|
10
|
+
* POST /oauth/device/rendezvous (RFC 8628 rendezvous), authenticated by the storefront's
|
|
11
|
+
* SIGNED CLIENT ASSERTION (private_key_jwt) — NOT a shared secret. `tot login --code <handle>`
|
|
12
|
+
* then ATTACHES the terminal's own PKCE key and the developer approves it by fingerprint
|
|
13
|
+
* back here in the cockpit (see the /api/dev/cli-signin-{status,approve} routes). The handle
|
|
14
|
+
* is not a credential: intercepting it grants nothing without the terminal's private key,
|
|
15
|
+
* and the developer must still approve the matching fingerprint.
|
|
16
16
|
*
|
|
17
|
-
* Line
|
|
18
|
-
* on prod the shortest form
|
|
17
|
+
* Line 2 appends `--mcp <origin>` ONLY when the MCP is not the CLI's built-in prod default —
|
|
18
|
+
* on prod the shortest form is emitted.
|
|
19
19
|
*
|
|
20
20
|
* `activityBridge` (optional) piggybacks the local→hosted activity-bridge credential
|
|
21
|
-
* (see lib/dev/activityIngestToken.ts) onto the SAME pasted line as two extra `tot
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
* dev-loop process so it can report file saves to the developer's own hosted /dev
|
|
25
|
-
* panel. Omitted entirely when the caller has no SESSION KV to mint against — the
|
|
26
|
-
* local loop then simply runs with no hosted signal, same as today.
|
|
21
|
+
* (see lib/dev/activityIngestToken.ts) onto the SAME pasted line as two extra `tot login`
|
|
22
|
+
* flags — no second browser round-trip. Omitted entirely when the caller has no SESSION KV
|
|
23
|
+
* to mint against — the local loop then simply runs with no hosted signal.
|
|
27
24
|
*/
|
|
28
25
|
import { readEnv } from "@/lib/env";
|
|
29
26
|
import { redactEmailForHint } from "@/lib/privacy/emailHint";
|
|
27
|
+
import { createRendezvous, type RendezvousDeps } from "@/lib/dev/rendezvousBroker";
|
|
30
28
|
|
|
31
29
|
// The CLI's built-in default MCP (packages/cli login.mjs DEFAULT_MCP_URL). When the derived
|
|
32
30
|
// MCP origin equals this, --mcp is redundant noise, so we omit it.
|
|
33
31
|
const PROD_MCP_ORIGIN = "https://mcp.tokenoftrust.com";
|
|
34
32
|
|
|
35
|
-
export interface HttpTransport {
|
|
36
|
-
post(
|
|
37
|
-
url: string,
|
|
38
|
-
headers: Record<string, string>,
|
|
39
|
-
body: unknown,
|
|
40
|
-
): Promise<{ status: number; json(): Promise<unknown> }>;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export class FetchHttpTransport implements HttpTransport {
|
|
44
|
-
async post(url: string, headers: Record<string, string>, body: unknown) {
|
|
45
|
-
const res = await fetch(url, {
|
|
46
|
-
method: "POST",
|
|
47
|
-
headers: { "Content-Type": "application/json", ...headers },
|
|
48
|
-
body: JSON.stringify(body),
|
|
49
|
-
});
|
|
50
|
-
return { status: res.status, json: () => res.json() };
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
|
|
54
33
|
export type CliSignInCodeResult =
|
|
55
|
-
| { ok: true; command: string; expiresAt: string | null }
|
|
34
|
+
| { ok: true; command: string; rendezvousCode: string; expiresAt: string | null }
|
|
56
35
|
| { ok: false; status: 502 | 503; reason: string };
|
|
57
36
|
|
|
58
37
|
/**
|
|
59
38
|
* Mint the ready-to-paste CLI sign-in command for `email`. The caller MUST pass the
|
|
60
39
|
* VERIFIED session email (never a client-supplied value) — a signed-in developer can only
|
|
61
|
-
*
|
|
62
|
-
* threaded
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
40
|
+
* start a rendezvous for themselves. `tenant` (the developer's own vendor, session-derived)
|
|
41
|
+
* is threaded so the CLI session lands already bound to that store (no client_list
|
|
42
|
+
* round-trip). Fail-closed: an unconfigured seam or a 401/403 (assertion rejected / client
|
|
43
|
+
* not entitled to broker) is a 503; any other fault is a 502. Never throws.
|
|
44
|
+
*
|
|
45
|
+
* Returns the pasteable `command` AND the `rendezvousCode` — the cockpit polls the latter
|
|
46
|
+
* for a terminal attaching, then shows the fingerprint approval prompt.
|
|
67
47
|
*/
|
|
68
48
|
export async function mintCliSignInCommand(
|
|
69
49
|
email: string,
|
|
70
50
|
tenant: string | null = null,
|
|
71
|
-
deps:
|
|
51
|
+
deps: RendezvousDeps = {},
|
|
72
52
|
activityBridge: { token: string; url: string } | null = null,
|
|
73
53
|
traceId: string | null = null,
|
|
74
54
|
): Promise<CliSignInCodeResult> {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
55
|
+
// MCP_BASE_URL is the MCP tier this storefront's client assertion is registered
|
|
56
|
+
// with (where the rendezvous is created), so the pasted `--mcp` host MUST match it
|
|
57
|
+
// — the terminal redeems the rendezvous against that same MCP. (The retired shim
|
|
58
|
+
// used MCP_APP_BINDING_URL, which may point at a different tier.)
|
|
59
|
+
const baseUrl = await readEnv("MCP_BASE_URL");
|
|
60
|
+
if (!baseUrl) {
|
|
78
61
|
return { ok: false, status: 503, reason: "not-configured" };
|
|
79
62
|
}
|
|
80
63
|
let mcpOrigin: string;
|
|
81
|
-
let endpoint: string;
|
|
82
64
|
try {
|
|
83
|
-
mcpOrigin = new URL(
|
|
84
|
-
// Mounted OUTSIDE /api on the MCP (the /api surface there is session-guarded); use the
|
|
85
|
-
// origin of MCP_APP_BINDING_URL, not its full path.
|
|
86
|
-
endpoint = `${mcpOrigin}/internal/dev-access/cli-signin-code`;
|
|
65
|
+
mcpOrigin = new URL(baseUrl).origin;
|
|
87
66
|
} catch {
|
|
88
67
|
return { ok: false, status: 503, reason: "bad-mcp-url" };
|
|
89
68
|
}
|
|
90
69
|
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
{ "x-mcp-service-secret": secret },
|
|
97
|
-
{
|
|
98
|
-
email: email.toLowerCase(),
|
|
99
|
-
...(tenant ? { tenant: tenant.toLowerCase() } : {}),
|
|
100
|
-
},
|
|
101
|
-
);
|
|
102
|
-
} catch (cause) {
|
|
103
|
-
return {
|
|
104
|
-
ok: false,
|
|
105
|
-
status: 502,
|
|
106
|
-
reason: "mcp request failed: " + (cause instanceof Error ? cause.message : String(cause)),
|
|
107
|
-
};
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
if (res.status < 200 || res.status >= 300) {
|
|
111
|
-
// 401 = MCP route unmounted or bad shared secret → treat as not-configured (503).
|
|
112
|
-
return {
|
|
113
|
-
ok: false,
|
|
114
|
-
status: res.status === 401 ? 503 : 502,
|
|
115
|
-
reason: `mcp cli-signin-code returned status ${res.status}`,
|
|
116
|
-
};
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
let payload: { token?: string; expiresAt?: string };
|
|
120
|
-
try {
|
|
121
|
-
payload = (await res.json()) as typeof payload;
|
|
122
|
-
} catch {
|
|
123
|
-
return { ok: false, status: 502, reason: "unparseable mcp response" };
|
|
124
|
-
}
|
|
125
|
-
if (!payload.token) {
|
|
126
|
-
return { ok: false, status: 502, reason: "mcp returned no token" };
|
|
70
|
+
const created = await createRendezvous(email, tenant, deps);
|
|
71
|
+
if (!created.ok) {
|
|
72
|
+
// 503 = seam not configured / assertion rejected / not entitled on this env;
|
|
73
|
+
// 502 = MCP fault. Surface neither internal detail nor any token.
|
|
74
|
+
return { ok: false, status: created.status, reason: created.reason };
|
|
127
75
|
}
|
|
128
76
|
|
|
129
77
|
const mcpFlag = mcpOrigin === PROD_MCP_ORIGIN ? "" : ` --mcp ${mcpOrigin}`;
|
|
130
|
-
// Display-only recovery hint for a stale one-use
|
|
78
|
+
// Display-only recovery hint for a stale one-use handle. If sign-in fails before
|
|
131
79
|
// anything is cached locally, `tot login` can still send the developer back to
|
|
132
80
|
// cockpit with the right address prefilled.
|
|
133
81
|
const emailHint = redactEmailForHint(email);
|
|
@@ -137,38 +85,40 @@ export async function mintCliSignInCommand(
|
|
|
137
85
|
const activityFlags = activityBridge
|
|
138
86
|
? ` --activity-token ${activityBridge.token} --activity-url ${activityBridge.url}`
|
|
139
87
|
: "";
|
|
140
|
-
// The invite→terminal→problem trace id, minted once with this
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
// the SAME id from the ingest-token claims, so runtime/activity writes stamp it.
|
|
88
|
+
// The invite→terminal→problem trace id, minted once with this rendezvous and baked
|
|
89
|
+
// into the activity-ingest-token claims. Emitted here too so `tot login` records it
|
|
90
|
+
// locally — additive, ignored by older CLI builds.
|
|
144
91
|
const traceFlag = traceId ? ` --trace-id ${traceId}` : "";
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
// 1. `npm i -g @tokenoftrust/cli` — install the `tot` command once.
|
|
149
|
-
// 2. `tot login --code <token>` — sign the terminal in with the single-use code.
|
|
150
|
-
// 3. `tot start` — checkout + run + open the browser on the working site, reusing the
|
|
151
|
-
// session step 2 just cached (so `start` never opens its own sign-in).
|
|
152
|
-
//
|
|
153
|
-
// The trailing `&&` chains them: each step runs ONLY if the previous succeeded. In
|
|
154
|
-
// particular a stale/burned code (single-use, short-TTL) fails step 2 and stops there
|
|
155
|
-
// with `login`'s cockpit recovery step — it never falls through to `start`'s browser
|
|
156
|
-
// sign-in (the surprise we're avoiding).
|
|
157
|
-
// The EXACT version this build was cut alongside — `@tokenoftrust/cli@<version>` — baked
|
|
158
|
-
// in at build time (astro.config Vite define) so the paste installs precisely the CLI this
|
|
159
|
-
// cockpit expects, never a floating dist-tag the terminal resolves (which can drift behind
|
|
160
|
-
// the build; that gap shipped stampless installs). `typeof` guard: the define isn't applied
|
|
161
|
-
// under vitest, where it falls back to the unpinned spec.
|
|
92
|
+
// The EXACT version this build was cut alongside — baked in at build time (astro.config
|
|
93
|
+
// Vite define) so the paste installs precisely the CLI this cockpit expects. `typeof`
|
|
94
|
+
// guard: the define isn't applied under vitest, where it falls back to the unpinned spec.
|
|
162
95
|
const cliInstallSpec =
|
|
163
96
|
typeof __CLI_INSTALL_SPEC__ !== "undefined" ? __CLI_INSTALL_SPEC__ : "@tokenoftrust/cli";
|
|
97
|
+
// `hash -r` after the install clears the shell's cached command path so the
|
|
98
|
+
// just-installed `tot` is the one that runs — without it, a shell that already
|
|
99
|
+
// resolved an OLDER `tot` (e.g. a stray Homebrew/other-prefix install earlier in
|
|
100
|
+
// this session) keeps running the stale binary, and `npm i -g` silently updates a
|
|
101
|
+
// file the shell never consults. Portable across bash/zsh/sh; fish can't run the
|
|
102
|
+
// `&&` chain anyway. This is what makes the pinned version actually take effect.
|
|
103
|
+
// A first-line no-op stamping which cockpit build generated this command (short git
|
|
104
|
+
// SHA — the same one /health reports) + the CLI it pins, so a paste can be matched to
|
|
105
|
+
// a deploy at a glance (pairs with `tot login`'s runtime version banner). Uses the
|
|
106
|
+
// `: '…'` null-command form, NOT a `#` comment — interactive zsh doesn't treat `#` as
|
|
107
|
+
// a comment (no INTERACTIVE_COMMENTS) and errors on it; `: '…'` is a portable no-op.
|
|
108
|
+
const cockpitBuild = (typeof __GIT_SHA__ !== "undefined" ? __GIT_SHA__ : "unknown").slice(0, 7);
|
|
164
109
|
const command = [
|
|
110
|
+
`: 'tot setup (cockpit ${cockpitBuild}, ${cliInstallSpec})'`,
|
|
165
111
|
`npm i -g ${cliInstallSpec} &&`,
|
|
166
|
-
`
|
|
167
|
-
`tot
|
|
112
|
+
`hash -r &&`,
|
|
113
|
+
`tot login --code ${created.rendezvousCode}${emailHintFlag}${mcpFlag}${traceFlag}${activityFlags} &&`,
|
|
114
|
+
// `start` must talk to the SAME MCP the session was minted on — it otherwise defaults
|
|
115
|
+
// to the prod MCP and won't see a qa session (→ "not signed in" → sample fallback).
|
|
116
|
+
`tot start${mcpFlag}`,
|
|
168
117
|
].join("\n");
|
|
169
118
|
return {
|
|
170
119
|
ok: true,
|
|
171
120
|
command,
|
|
172
|
-
|
|
121
|
+
rendezvousCode: created.rendezvousCode,
|
|
122
|
+
expiresAt: created.expiresIn != null ? new Date(Date.now() + created.expiresIn * 1000).toISOString() : null,
|
|
173
123
|
};
|
|
174
124
|
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Which store is this cockpit request about?" — the ONE resolver the hosted
|
|
3
|
+
* cockpit page, its /api/dev/* routes, and the CLI sign-in-code mint all share, so
|
|
4
|
+
* they never disagree about the developer's store.
|
|
5
|
+
*
|
|
6
|
+
* REGRESSION FIXED (8500 <tenant>/cockpit): the page and the mint used to derive
|
|
7
|
+
* the store purely from the session's FIRST membership
|
|
8
|
+
* (`session.tenants?.[0]?.resource ?? session.resource`), ignoring the store the
|
|
9
|
+
* request URL actually named. An invite to `/<tenantB>/cockpit` therefore rendered —
|
|
10
|
+
* and minted a `tot login --code … && tot start` for — whatever store happened to be
|
|
11
|
+
* listed first in the session, "losing track of the store" for any developer who
|
|
12
|
+
* belongs to more than one.
|
|
13
|
+
*
|
|
14
|
+
* Rule: the URL wins WHEN THE VIEWER CAN ACCESS IT. The middleware has already
|
|
15
|
+
* resolved the request's host/path to `locals.tenant` (the trusted, authenticated
|
|
16
|
+
* store seam — never client body input), so when that is a SPECIFIC store (not the
|
|
17
|
+
* public marketing home) and the signed-in developer holds a membership for it (or a
|
|
18
|
+
* live staff admission), that store is authoritative. Otherwise we fall back to the
|
|
19
|
+
* session's own first store (the generic-host `/cockpit` case, unchanged), and for an
|
|
20
|
+
* anonymous viewer to the path store's branding, else the caller-supplied `?tenant=`.
|
|
21
|
+
*
|
|
22
|
+
* Access is gated with the SAME primitive (`viewerCanAccess`) the vendor dashboard
|
|
23
|
+
* uses, so a developer can never steer the mint at a store they don't belong to; a
|
|
24
|
+
* store they can't access simply falls through to their own.
|
|
25
|
+
*/
|
|
26
|
+
import type { APIContext } from "astro";
|
|
27
|
+
import type { SessionRecord } from "@/lib/auth/session";
|
|
28
|
+
import { PUBLIC_HOME_TENANT_ID } from "@/lib/auth/loginGate";
|
|
29
|
+
import { viewerCanAccess, type StaffAdmission } from "@/lib/dashboard/tenantSelection";
|
|
30
|
+
|
|
31
|
+
/** The request's host/path-resolved store resource, or "" when it's the marketing home. */
|
|
32
|
+
function pathStoreResource(context: Pick<APIContext, "locals">): string {
|
|
33
|
+
const t = context.locals?.tenant;
|
|
34
|
+
if (!t || t.tenant_id === PUBLIC_HOME_TENANT_ID) return "";
|
|
35
|
+
return (t.appDomain || t.tenant_id || "").toLowerCase();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Resolve the store this cockpit request is scoped to, as a lowercased resource
|
|
40
|
+
* (appDomain/tenant_id). Empty string when nothing resolves (e.g. anonymous on the
|
|
41
|
+
* generic host with no `?tenant=`).
|
|
42
|
+
*/
|
|
43
|
+
export function resolveCockpitStoreResource(
|
|
44
|
+
context: Pick<APIContext, "locals" | "url">,
|
|
45
|
+
session: SessionRecord | null,
|
|
46
|
+
): string {
|
|
47
|
+
const pathStore = pathStoreResource(context);
|
|
48
|
+
|
|
49
|
+
if (session) {
|
|
50
|
+
// The URL wins when the signed-in developer can actually access that store.
|
|
51
|
+
if (pathStore) {
|
|
52
|
+
const staff: StaffAdmission | undefined = session.staffSelection
|
|
53
|
+
? { selection: session.staffSelection, nowSeconds: Math.floor(Date.now() / 1000) }
|
|
54
|
+
: undefined;
|
|
55
|
+
if (viewerCanAccess(session.tenants, pathStore, staff)) return pathStore;
|
|
56
|
+
}
|
|
57
|
+
// Generic host, or a store the viewer doesn't belong to: their own first store.
|
|
58
|
+
return (session.tenants?.[0]?.resource ?? session.resource ?? "").toLowerCase();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Anonymous: the path store is just branding (no privileged mint happens), else the
|
|
62
|
+
// caller-supplied preview hint. Never overrides a signed-in developer (handled above).
|
|
63
|
+
if (pathStore) return pathStore;
|
|
64
|
+
return (context.url?.searchParams.get("tenant") ?? "").toLowerCase();
|
|
65
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read whether a tenant appDomain's store is SET UP — its forge repo is provisioned —
|
|
3
|
+
* from the credential-free MCP preview-status seam (tot-mcp
|
|
4
|
+
* GET /internal/preview-status; legacy alias /internal/dev-access/preview-status). The
|
|
5
|
+
* seam answers `{ tenant, app, repo, provisioned, previewReleased }`.
|
|
6
|
+
*
|
|
7
|
+
* This is the cockpit's own small reader — self-contained, mirroring `cliSignInCode.ts`
|
|
8
|
+
* (same MCP-origin + service-secret seam) rather than importing another cluster's copy,
|
|
9
|
+
* so the two stay decoupled (the same reason `cliSignInCode` duplicates its transport).
|
|
10
|
+
* It gates the CLI sign-in-code mint + the /cockpit setup fastpath so a developer whose
|
|
11
|
+
* store isn't provisioned yet gets an honest "not set up" state instead of a
|
|
12
|
+
* `tot login --code … && tot start` command that would die at checkout on
|
|
13
|
+
* "Repo <owner>-main is not provisioned" (James's 2026-07-19 first-run failure).
|
|
14
|
+
*
|
|
15
|
+
* - MCP host ← the ORIGIN of MCP_APP_BINDING_URL (one source, so this read hits the
|
|
16
|
+
* SAME MCP tier the sign-in-code mint does).
|
|
17
|
+
* - auth ← header x-mcp-service-secret: MCP_SERVICE_SHARED_SECRET.
|
|
18
|
+
*
|
|
19
|
+
* Fail-closed but non-blocking by design: an UNCONFIGURED seam (no URL/secret) →
|
|
20
|
+
* { configured:false } so callers keep today's behavior (no regression); any non-2xx /
|
|
21
|
+
* transport / parse fault → { configured:true, ok:false } so a caller can distinguish
|
|
22
|
+
* "definitely not provisioned" (ok + provisioned:false) from "couldn't tell". Never throws.
|
|
23
|
+
*/
|
|
24
|
+
import { readEnv } from "@/lib/env";
|
|
25
|
+
|
|
26
|
+
export interface HttpGetTransport {
|
|
27
|
+
get(
|
|
28
|
+
url: string,
|
|
29
|
+
headers: Record<string, string>,
|
|
30
|
+
): Promise<{ status: number; json(): Promise<unknown> }>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class FetchGetTransport implements HttpGetTransport {
|
|
34
|
+
async get(url: string, headers: Record<string, string>) {
|
|
35
|
+
const res = await fetch(url, { method: "GET", headers });
|
|
36
|
+
return { status: res.status, json: () => res.json() };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface TenantPreviewStatus {
|
|
41
|
+
/** False when the seam isn't wired on this deploy (no URL/secret) — gate inactive. */
|
|
42
|
+
configured: boolean;
|
|
43
|
+
/** True only when we got a definitive answer back from the MCP. */
|
|
44
|
+
ok: boolean;
|
|
45
|
+
/** The forge repo is provisioned for this owner (checkout/`tot start` can succeed). */
|
|
46
|
+
provisioned?: boolean;
|
|
47
|
+
/** A released preview exists. TODAY equals `provisioned`; Track B.3 tightens it. */
|
|
48
|
+
previewReleased?: boolean;
|
|
49
|
+
status?: number;
|
|
50
|
+
reason?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function readTenantPreviewStatus(
|
|
54
|
+
tenant: string | null,
|
|
55
|
+
deps: { transport?: HttpGetTransport } = {},
|
|
56
|
+
): Promise<TenantPreviewStatus> {
|
|
57
|
+
const bindingUrl = await readEnv("MCP_APP_BINDING_URL");
|
|
58
|
+
const secret = await readEnv("MCP_SERVICE_SHARED_SECRET");
|
|
59
|
+
if (!bindingUrl || !secret || !tenant) return { configured: false, ok: false };
|
|
60
|
+
|
|
61
|
+
let endpoint: string;
|
|
62
|
+
try {
|
|
63
|
+
// Mounted OUTSIDE /api on the MCP (its /api surface is session-guarded); use the
|
|
64
|
+
// ORIGIN of MCP_APP_BINDING_URL, same as the cli-signin-code mint. Canonical path
|
|
65
|
+
// (namespace-neutral) — the MCP still serves the /internal/dev-access alias too.
|
|
66
|
+
endpoint = `${new URL(bindingUrl).origin}/internal/preview-status`;
|
|
67
|
+
} catch {
|
|
68
|
+
return { configured: false, ok: false, reason: "bad-mcp-url" };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const transport = deps.transport ?? new FetchGetTransport();
|
|
72
|
+
const full = `${endpoint}?tenant=${encodeURIComponent(tenant.toLowerCase())}`;
|
|
73
|
+
let res: { status: number; json(): Promise<unknown> };
|
|
74
|
+
try {
|
|
75
|
+
res = await transport.get(full, { "x-mcp-service-secret": secret });
|
|
76
|
+
} catch (cause) {
|
|
77
|
+
return {
|
|
78
|
+
configured: true,
|
|
79
|
+
ok: false,
|
|
80
|
+
reason: "mcp request failed: " + (cause instanceof Error ? cause.message : String(cause)),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
if (res.status < 200 || res.status >= 300) {
|
|
84
|
+
return { configured: true, ok: false, status: res.status, reason: `status ${res.status}` };
|
|
85
|
+
}
|
|
86
|
+
let payload: unknown;
|
|
87
|
+
try {
|
|
88
|
+
payload = await res.json();
|
|
89
|
+
} catch {
|
|
90
|
+
return { configured: true, ok: false, status: res.status, reason: "unparseable mcp response" };
|
|
91
|
+
}
|
|
92
|
+
const p = (payload ?? {}) as Record<string, unknown>;
|
|
93
|
+
return {
|
|
94
|
+
configured: true,
|
|
95
|
+
ok: true,
|
|
96
|
+
status: res.status,
|
|
97
|
+
provisioned: p.provisioned === true,
|
|
98
|
+
previewReleased: p.previewReleased === true,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The gate signal shared by /cockpit + the CLI sign-in-code mint: TRUE only when the
|
|
104
|
+
* seam gave a DEFINITIVE "this store's repo is NOT provisioned" — the exact case where
|
|
105
|
+
* `tot clone` / `tot start` would die locally on "Repo … is not provisioned". An
|
|
106
|
+
* unconfigured seam or one that couldn't answer returns FALSE — callers then keep today's
|
|
107
|
+
* behavior and lean on the CLI's own clean "not set up yet" error as the backstop, rather
|
|
108
|
+
* than blocking a possibly-fine developer on a transient MCP blip.
|
|
109
|
+
*/
|
|
110
|
+
export function isStoreNotSetUp(status: TenantPreviewStatus): boolean {
|
|
111
|
+
return status.configured && status.ok && status.provisioned === false;
|
|
112
|
+
}
|