@tokenoftrust/storefront-runner 1.3.2 → 1.3.4-rc.2
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 +59 -0
- package/apps/storefront/env.d.ts +24 -8
- package/apps/storefront/package.json +1 -1
- package/apps/storefront/src/components/admin/{AdminFoxyCartSettingsPanel.astro → AdminCheckoutSettingsPanel.astro} +50 -51
- package/apps/storefront/src/components/admin/AdminSettingsTab.astro +2 -2
- package/apps/storefront/src/components/admin/AdminSubscriptionsTab.astro +613 -0
- package/apps/storefront/src/components/admin/orders/OrdersWorkspace.astro +3 -3
- package/apps/storefront/src/components/chrome/SiteHeader.astro +3 -4
- package/apps/storefront/src/components/commerce/{FoxySidecartStyles.astro → CartDrawerStyles.astro} +13 -14
- package/apps/storefront/src/components/commerce/CheckoutLoader.astro +21 -0
- package/apps/storefront/src/components/islands/VariantSelector.tsx +101 -20
- package/apps/storefront/src/config/adminTargetTenant.ts +2 -2
- package/apps/storefront/src/config/devTenantSeed.ts +65 -16
- package/apps/storefront/src/config/resolver.ts +48 -1
- package/apps/storefront/src/layouts/Layout.astro +14 -12
- package/apps/storefront/src/lib/admin/README.md +2 -2
- package/apps/storefront/src/lib/admin/checkoutStyleInput.ts +5 -5
- package/apps/storefront/src/lib/admin/orderClient.ts +20 -20
- package/apps/storefront/src/lib/admin/services/mock/{foxyCartSettings.ts → checkoutSettings.ts} +21 -22
- package/apps/storefront/src/lib/admin/services/mock/index.ts +3 -2
- package/apps/storefront/src/lib/admin/services/mock/settings.ts +1 -1
- package/apps/storefront/src/lib/admin/services/mock/subscriptions.ts +398 -0
- package/apps/storefront/src/lib/admin/services/orders.ts +21 -21
- package/apps/storefront/src/lib/admin/viewmodels/subscriptions.ts +106 -0
- package/apps/storefront/src/lib/auth/loginGate.ts +14 -3
- package/apps/storefront/src/lib/{foxyCommerce.ts → checkoutCommerce.ts} +20 -18
- package/apps/storefront/src/lib/commerce/{foxyCartSettings.ts → checkoutSettings.ts} +65 -66
- package/apps/storefront/src/lib/dev/activityIngestToken.ts +9 -0
- package/apps/storefront/src/lib/dev/activityStore.ts +12 -0
- package/apps/storefront/src/lib/dev/cliSignInCode.ts +23 -4
- package/apps/storefront/src/lib/dev/runtimeStore.ts +193 -22
- package/apps/storefront/src/lib/edgeCache.ts +7 -6
- package/apps/storefront/src/lib/health.ts +53 -0
- package/apps/storefront/src/lib/privacy/emailHint.ts +33 -0
- package/apps/storefront/src/lib/rawMarketingHtml.ts +1 -0
- package/apps/storefront/src/lib/sections.ts +19 -23
- package/apps/storefront/src/lib/securityHeaders.ts +1 -1
- package/apps/storefront/src/lib/storyblok/content-model.ts +3 -2
- package/apps/storefront/src/middleware/index.ts +29 -12
- package/apps/storefront/src/pages/[...slug].astro +5 -6
- package/apps/storefront/src/pages/admin.astro +10 -3
- package/apps/storefront/src/pages/api/admin/orders/[id].ts +4 -4
- package/apps/storefront/src/pages/api/admin/orders.ts +5 -5
- package/apps/storefront/src/pages/api/auth/magic-exchange.ts +14 -7
- package/apps/storefront/src/pages/api/checkout-style.css.ts +5 -5
- package/apps/storefront/src/pages/auth/login.astro +4 -1
- package/apps/storefront/src/pages/auth/magic.astro +263 -43
- package/apps/storefront/src/pages/capabilities.astro +1 -1
- package/apps/storefront/src/pages/cockpit.astro +2365 -0
- package/apps/storefront/src/pages/dashboard/[appDomain]/team.astro +19 -0
- package/apps/storefront/src/pages/index.astro +23 -29
- package/apps/storefront/src/pages/products/[handle].astro +50 -20
- package/package.json +6 -2
- package/packages/public-runtime/src/capabilities.ts +97 -9
- package/packages/public-runtime/src/{foxy-orders.ts → checkout-orders.ts} +28 -27
- package/packages/public-runtime/src/checkout-style-publish.ts +25 -25
- package/packages/public-runtime/src/checkout-style.ts +13 -13
- package/packages/public-runtime/src/{foxy.ts → checkout.ts} +111 -34
- package/packages/public-runtime/src/csp.ts +11 -11
- package/packages/public-runtime/src/customization-scripts.ts +28 -0
- package/packages/public-runtime/src/extension-contract.ts +73 -0
- package/packages/public-runtime/src/index.ts +4 -2
- package/packages/public-runtime/src/product.ts +57 -0
- package/packages/public-runtime/src/raw-html-policy.ts +35 -0
- package/packages/public-runtime/src/tenant.ts +14 -11
- package/pnpm-workspace.yaml +1 -0
- package/scripts/dev/checkout-watch.mjs +9 -0
- package/scripts/dev/file-browser.mjs +5 -1
- package/scripts/dev/transient-files.mjs +34 -0
- package/scripts/tot-dev.mjs +16 -3
- package/apps/storefront/src/components/commerce/FoxyLoader.astro +0 -23
- package/apps/storefront/src/pages/api/dev/activity.ts +0 -124
- package/apps/storefront/src/pages/api/dev/cli-signin-code.ts +0 -73
- package/apps/storefront/src/pages/api/dev/diff.ts +0 -68
- package/apps/storefront/src/pages/api/dev/file.ts +0 -57
- package/apps/storefront/src/pages/api/dev/locate.ts +0 -125
- package/apps/storefront/src/pages/api/dev/publish.ts +0 -128
- package/apps/storefront/src/pages/api/dev/tree.ts +0 -70
- package/apps/storefront/src/pages/api/dev/write.ts +0 -116
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* interface (resolveTenant) is intentionally async + many-ready so the same seam
|
|
6
6
|
* scales to Cloudflare-for-SaaS custom hostnames later without a rewrite.
|
|
7
7
|
*/
|
|
8
|
-
import type {
|
|
8
|
+
import type { ManagedCheckoutConfig } from "./checkout.js";
|
|
9
9
|
import type { CapabilityConfig } from "./capabilities.js";
|
|
10
10
|
|
|
11
11
|
/**
|
|
@@ -306,13 +306,15 @@ export function siteTypeDefaults(siteType: SiteType): TenantFeatures {
|
|
|
306
306
|
* preserving the platform's original behavior for existing tenants.
|
|
307
307
|
*/
|
|
308
308
|
export function resolveTenantFeatures(
|
|
309
|
-
tenant: Pick<TenantConfig, "siteType" | "features">,
|
|
309
|
+
tenant: Pick<TenantConfig, "siteType" | "features" | "compliance">,
|
|
310
310
|
): TenantFeatures {
|
|
311
311
|
const base = siteTypeDefaults(tenant.siteType ?? "commerce");
|
|
312
|
-
if (
|
|
313
|
-
|
|
314
|
-
|
|
312
|
+
if (tenant.features) {
|
|
313
|
+
for (const [k, v] of Object.entries(tenant.features)) {
|
|
314
|
+
if (v !== undefined) base[k as keyof TenantFeatures] = v;
|
|
315
|
+
}
|
|
315
316
|
}
|
|
317
|
+
if (tenant.compliance?.minAge) base.ageGate = true;
|
|
316
318
|
return base;
|
|
317
319
|
}
|
|
318
320
|
|
|
@@ -344,14 +346,15 @@ export interface TenantConfig {
|
|
|
344
346
|
/** Regulated-commerce affordances; undefined for unregulated tenants. */
|
|
345
347
|
compliance?: ComplianceConfig;
|
|
346
348
|
/**
|
|
347
|
-
* Cart/checkout engine wiring (
|
|
348
|
-
* that have a
|
|
349
|
-
* `storeSubDomain` here is a default — middleware may override it from
|
|
350
|
-
* (FOXY_STORE_SUBDOMAIN) so the demo store
|
|
351
|
-
* signing SECRET is never stored on the
|
|
349
|
+
* Cart/checkout engine wiring (Track A). Present on commerce tenants
|
|
350
|
+
* that have a checkout store; drives the loader + signed add-to-cart. The
|
|
351
|
+
* `storeSubDomain` here is a default — middleware may override it from the
|
|
352
|
+
* existing provider-named env binding (FOXY_STORE_SUBDOMAIN) so the demo store
|
|
353
|
+
* swaps with zero code change. The signing SECRET is never stored on the
|
|
354
|
+
* tenant; it's an env Worker binding.
|
|
352
355
|
*/
|
|
353
356
|
commerce?: {
|
|
354
|
-
|
|
357
|
+
checkout?: ManagedCheckoutConfig;
|
|
355
358
|
};
|
|
356
359
|
/**
|
|
357
360
|
* PR-gated capability switches (cart/checkout, age/identity verification,
|
package/pnpm-workspace.yaml
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
import { watch, readdirSync, statSync, existsSync, cpSync, rmSync, mkdirSync } from "node:fs";
|
|
3
3
|
import { join, dirname } from "node:path";
|
|
4
|
+
import { IGNORED_WATCH_DIRS, isTransientWatchFile } from "./transient-files.mjs";
|
|
5
|
+
|
|
6
|
+
// Re-exported so existing importers (checkout-watch.test.mjs) keep working after the
|
|
7
|
+
// predicate moved to its own typed module.
|
|
8
|
+
export { isTransientWatchFile };
|
|
4
9
|
|
|
5
10
|
// Standalone (`tot dev --workspace`) re-sync: the developer's checkout is COPIED
|
|
6
11
|
// into the storefront's in-tree tenants/<id>/ (Vite's server.fs.allow refuses files
|
|
@@ -68,9 +73,13 @@ export function startWatch(source, isDir, onChange) {
|
|
|
68
73
|
const rel = relDir ? join(relDir, e.name) : e.name;
|
|
69
74
|
const abs = join(absDir, e.name);
|
|
70
75
|
if (e.isDirectory()) {
|
|
76
|
+
if (IGNORED_WATCH_DIRS.has(e.name)) continue;
|
|
71
77
|
visit(abs, rel);
|
|
72
78
|
continue;
|
|
73
79
|
}
|
|
80
|
+
// Skip editor/OS transient files so a vim `.swp` (etc.) never counts as a
|
|
81
|
+
// content change (no resync, no reload, no "changed" report).
|
|
82
|
+
if (isTransientWatchFile(e.name)) continue;
|
|
74
83
|
present.add(rel);
|
|
75
84
|
let m;
|
|
76
85
|
try {
|
|
@@ -17,6 +17,7 @@ import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
|
17
17
|
import { join, extname, sep } from "node:path";
|
|
18
18
|
import { resolveSafePath, matchesWriteAllowlist } from "./safe-path.mjs";
|
|
19
19
|
import { resolveTenantSourceRoot } from "./tenant-source-root.mjs";
|
|
20
|
+
import { isTransientWatchFile } from "./transient-files.mjs";
|
|
20
21
|
|
|
21
22
|
/** Above this, the viewer degrades to "not shown" rather than shipping a huge string. */
|
|
22
23
|
const MAX_FILE_BYTES = 512 * 1024;
|
|
@@ -67,7 +68,10 @@ export function buildTree(sourceRoot, { ignore = IGNORE_NAMES } = {}) {
|
|
|
67
68
|
}
|
|
68
69
|
const nodes = [];
|
|
69
70
|
for (const e of entries) {
|
|
70
|
-
|
|
71
|
+
// Editor/OS transient files (vim .swp, emacs #autosave#, ~backups, .DS_Store)
|
|
72
|
+
// are not the store's content — keep them out of the source tree so they never
|
|
73
|
+
// show up as an "untracked change" the developer has to reason about.
|
|
74
|
+
if (ignore.has(e.name) || isTransientWatchFile(e.name)) continue;
|
|
71
75
|
const rel = relDir ? `${relDir}/${e.name}` : e.name;
|
|
72
76
|
if (e.isDirectory()) {
|
|
73
77
|
nodes.push({ name: e.name, path: rel, type: "dir", children: walk(join(absDir, e.name), rel) });
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* Editor/OS transient-file predicate — shared by the local dev loop's file WATCHER
|
|
4
|
+
* (checkout-watch.mjs) and the source-tree browser (file-browser.mjs) so both hide
|
|
5
|
+
* the same non-content noise: a vim swap (`.home.html.swp`), an emacs autosave
|
|
6
|
+
* (`#home.html#`) or lock (`.#home.html`), a `~` backup, `.DS_Store`/`Thumbs.db`, or
|
|
7
|
+
* vim's `4913` write-probe. Kept in its own tiny, fully-typed module so a consumer
|
|
8
|
+
* that only needs the predicate never drags the watcher's other internals into its
|
|
9
|
+
* typecheck graph.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Directories never worth descending into — VCS/dep/build noise that would churn a poll. */
|
|
13
|
+
export const IGNORED_WATCH_DIRS = new Set([".git", "node_modules", ".astro", ".cache", ".turbo"]);
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* True for editor/OS transient files that are NOT real content edits. A raw poll of
|
|
17
|
+
* a checkout subtree would otherwise treat one as a content change — re-syncing it,
|
|
18
|
+
* firing a spurious reload, and showing it as an untracked change. Matched on the
|
|
19
|
+
* BASENAME so it's independent of depth. "Official" changes track the store's real
|
|
20
|
+
* content; untracked editor junk is surfaced only at publish time, not here.
|
|
21
|
+
* @param {string} name the file basename
|
|
22
|
+
* @returns {boolean}
|
|
23
|
+
*/
|
|
24
|
+
export function isTransientWatchFile(name) {
|
|
25
|
+
return (
|
|
26
|
+
/\.(sw[a-z]{1,2}|tmp|temp|orig|bak)$/i.test(name) || // vim swap (.swp/.swo/.swx/.swpx…), temp/backup
|
|
27
|
+
/~$/.test(name) || // gedit/emacs/kate trailing-tilde backup
|
|
28
|
+
/^\.#/.test(name) || // emacs lockfile (.#file)
|
|
29
|
+
/^#.*#$/.test(name) || // emacs autosave (#file#)
|
|
30
|
+
name === ".DS_Store" ||
|
|
31
|
+
name === "Thumbs.db" ||
|
|
32
|
+
name === "4913" // vim's write-probe file
|
|
33
|
+
);
|
|
34
|
+
}
|
package/scripts/tot-dev.mjs
CHANGED
|
@@ -49,6 +49,7 @@
|
|
|
49
49
|
* Ctrl-C stops the server. Exit 1 = can't resolve tenant/domain/workspace; 2 = bad usage.
|
|
50
50
|
*/
|
|
51
51
|
import { spawn, spawnSync } from "node:child_process";
|
|
52
|
+
import { createRequire } from "node:module";
|
|
52
53
|
import {
|
|
53
54
|
readFileSync,
|
|
54
55
|
existsSync,
|
|
@@ -356,10 +357,22 @@ const devSourceRoot = resolveDevSourceRoot(workspacePath, repoRoot, tenant);
|
|
|
356
357
|
const spawnEnv = workspacePath
|
|
357
358
|
? { ...process.env, ASTRO_DEV_BACKGROUND: "1", TOT_DEV_TENANT: tenant, TOT_DEV_SOURCE_ROOT: devSourceRoot }
|
|
358
359
|
: { ...process.env, TOT_DEV_TENANT: tenant, TOT_DEV_SOURCE_ROOT: devSourceRoot };
|
|
360
|
+
// LAUNCHER-AGNOSTIC astro spawn: the published runner tree may have been
|
|
361
|
+
// installed by pnpm OR npm (npm ships with every Node — the runner must not
|
|
362
|
+
// require pnpm at RUNTIME), so resolve astro's real entry through Node's own
|
|
363
|
+
// resolver from the app package and exec it with our Node. No package-manager
|
|
364
|
+
// binary, no .bin shims, and no coupling to the package's name (the build
|
|
365
|
+
// renames @tot/storefront → @tot/storefront-runner in the shipped tree, which
|
|
366
|
+
// the old `pnpm --filter <name> exec` had to track).
|
|
367
|
+
const appDir = resolve(repoRoot, "apps/storefront");
|
|
368
|
+
const astroEntry = resolve(
|
|
369
|
+
dirname(createRequire(join(appDir, "package.json")).resolve("astro/package.json")),
|
|
370
|
+
"bin/astro.mjs", // astro's package.json bin entry
|
|
371
|
+
);
|
|
359
372
|
const child = spawn(
|
|
360
|
-
|
|
361
|
-
[
|
|
362
|
-
{ cwd:
|
|
373
|
+
process.execPath,
|
|
374
|
+
[astroEntry, "dev", "--port", port, "--host"],
|
|
375
|
+
{ cwd: appDir, stdio: "inherit", env: spawnEnv },
|
|
363
376
|
);
|
|
364
377
|
|
|
365
378
|
// Clean up grafts on every exit path so we never leave symlinks in the tree.
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
/**
|
|
3
|
-
* <FoxyLoader> — injects FoxyCart's loader.js (Track A).
|
|
4
|
-
*
|
|
5
|
-
* Foxy's loader turns our add-to-cart forms/links into the sidecart + hosted
|
|
6
|
-
* checkout ("Powered by FoxyCart"). It's loaded from the store's CDN path and
|
|
7
|
-
* must sit near the end of <body>. Rendered only when the tenant has a store
|
|
8
|
-
* (locals.commerce); marketing tenants ship nothing.
|
|
9
|
-
*
|
|
10
|
-
* Note: Foxy's loader is a mutable, versioned CDN artifact and does NOT support
|
|
11
|
-
* Subresource Integrity — pinning a hash would break it on Foxy's next release.
|
|
12
|
-
* It's constrained instead by the per-tenant CSP allowlist (cdn.foxycart.com +
|
|
13
|
-
* the store origin) set in middleware. It is loaded as a plain cross-origin
|
|
14
|
-
* script (NO `crossorigin` attribute) — cdn.foxycart.com serves it without CORS
|
|
15
|
-
* headers, so `crossorigin="anonymous"` would make the browser block it.
|
|
16
|
-
*/
|
|
17
|
-
const { commerce } = Astro.locals;
|
|
18
|
-
---
|
|
19
|
-
{
|
|
20
|
-
commerce && (
|
|
21
|
-
<script src={commerce.loaderSrc} async defer></script>
|
|
22
|
-
)
|
|
23
|
-
}
|
|
@@ -1,124 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* GET/POST /api/dev/activity — the hosted /dev panel's file-level activity
|
|
3
|
-
* feed (local→hosted activity bridge). See handoff
|
|
4
|
-
* 2026-07-14-hosted-dev-activity-bridge-and-stepper.
|
|
5
|
-
*
|
|
6
|
-
* GET: the signed-in developer's own browser polls this (same capability gate
|
|
7
|
-
* as every other /dev route) to render the activity log and feed the Step-2
|
|
8
|
-
* gate's completion signal. POLL, not a held-open stream — Cloudflare Workers
|
|
9
|
-
* are stateless per request, so a poll means a Worker redeploy mid-session
|
|
10
|
-
* needs no reconnect logic on either side.
|
|
11
|
-
*
|
|
12
|
-
* POST: the developer's LOCAL dev-loop process reports one file save,
|
|
13
|
-
* authorized by the opaque bearer minted alongside their cli-signin-code
|
|
14
|
-
* paste (lib/dev/activityIngestToken.ts) — NOT the browser session cookie,
|
|
15
|
-
* since the local process has no cookie.
|
|
16
|
-
*/
|
|
17
|
-
import type { APIContext } from "astro";
|
|
18
|
-
import { readViewerSession } from "@/lib/auth/route";
|
|
19
|
-
import { readKv } from "@/lib/env";
|
|
20
|
-
import { resolvePreviewTenant } from "@/config/previewTenantLookup";
|
|
21
|
-
import { verifyActivityIngestToken } from "@/lib/dev/activityIngestToken";
|
|
22
|
-
import {
|
|
23
|
-
appendActivity,
|
|
24
|
-
readActivity,
|
|
25
|
-
describeActivity,
|
|
26
|
-
type ActivityEvent,
|
|
27
|
-
type ActivityEventKind,
|
|
28
|
-
} from "@/lib/dev/activityStore";
|
|
29
|
-
import { readRuntime, writeRuntime, shapeRuntime, toRuntimeView } from "@/lib/dev/runtimeStore";
|
|
30
|
-
|
|
31
|
-
export const prerender = false;
|
|
32
|
-
|
|
33
|
-
function json(body: unknown, status = 200): Response {
|
|
34
|
-
return new Response(JSON.stringify(body), {
|
|
35
|
-
status,
|
|
36
|
-
headers: { "content-type": "application/json" },
|
|
37
|
-
});
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export async function GET(context: APIContext): Promise<Response> {
|
|
41
|
-
const session = await readViewerSession({ cookies: context.cookies });
|
|
42
|
-
if (!session) return json({ error: "sign in required" }, 401);
|
|
43
|
-
if (!session.capability || session.capability === "none") {
|
|
44
|
-
return json({ error: "no developer capability on this account" }, 403);
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
// Same tenant resolution every other /dev route uses — the session's own store,
|
|
48
|
-
// never caller input.
|
|
49
|
-
const wanted = (session.tenants?.[0]?.resource ?? session.resource ?? "").toLowerCase();
|
|
50
|
-
const tenant = wanted ? await resolvePreviewTenant(wanted) : null;
|
|
51
|
-
if (!tenant) return json({ error: "no store linked to this account" }, 403);
|
|
52
|
-
|
|
53
|
-
const kv = await readKv("TENANT_CACHE");
|
|
54
|
-
const events = kv ? await readActivity(kv, tenant.tenant_id) : [];
|
|
55
|
-
// The LIVE local-dev runtime (latest heartbeat), separate from the file-save
|
|
56
|
-
// feed — the cockpit renders it as a clickable local link + CLI version, with
|
|
57
|
-
// `live` computed server-side so a skewed local clock can't fake liveness.
|
|
58
|
-
const runtime = kv ? await readRuntime(kv, tenant.tenant_id) : null;
|
|
59
|
-
return json({
|
|
60
|
-
events: events.map((e) => ({ ...e, description: describeActivity(e.file, e.event) })),
|
|
61
|
-
runtime: runtime ? toRuntimeView(runtime) : null,
|
|
62
|
-
});
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
export async function POST(context: APIContext): Promise<Response> {
|
|
66
|
-
const auth = context.request.headers.get("authorization") || "";
|
|
67
|
-
const m = /^Bearer\s+(.+)$/i.exec(auth);
|
|
68
|
-
if (!m) return json({ error: "missing bearer token" }, 401);
|
|
69
|
-
|
|
70
|
-
const sessionKv = await readKv("SESSION");
|
|
71
|
-
if (!sessionKv) return json({ error: "activity bridge not configured" }, 503);
|
|
72
|
-
const claims = await verifyActivityIngestToken(sessionKv, m[1] ?? "");
|
|
73
|
-
if (!claims) return json({ error: "invalid or expired token" }, 401);
|
|
74
|
-
|
|
75
|
-
let body: {
|
|
76
|
-
file?: unknown;
|
|
77
|
-
event?: unknown;
|
|
78
|
-
at?: unknown;
|
|
79
|
-
cliVersion?: unknown;
|
|
80
|
-
runnerVersion?: unknown;
|
|
81
|
-
url?: unknown;
|
|
82
|
-
};
|
|
83
|
-
try {
|
|
84
|
-
body = (await context.request.json()) as typeof body;
|
|
85
|
-
} catch {
|
|
86
|
-
return json({ error: "invalid JSON body" }, 400);
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
// The token's own bound tenant, never a caller-supplied one — a stolen/expired
|
|
90
|
-
// token can't be redirected at a different vendor's runtime/activity feed.
|
|
91
|
-
const tenant = await resolvePreviewTenant(claims.tenant.toLowerCase());
|
|
92
|
-
if (!tenant) return json({ error: "no store linked to this token" }, 403);
|
|
93
|
-
const tenantCache = await readKv("TENANT_CACHE");
|
|
94
|
-
|
|
95
|
-
// Heartbeat: latest-wins local-dev runtime (CLI version + live localhost URL),
|
|
96
|
-
// stored SEPARATELY from the file-save ring buffer. Not a file event, so it
|
|
97
|
-
// takes no `file`; liveness is stamped from the SERVER clock (aliveAt), never
|
|
98
|
-
// the client's `at`.
|
|
99
|
-
if (body.event === "heartbeat") {
|
|
100
|
-
if (!tenantCache) return json({ ok: true, stored: false }); // no KV in this runtime (dev)
|
|
101
|
-
await writeRuntime(tenantCache, tenant.tenant_id, shapeRuntime(body, Date.now()));
|
|
102
|
-
return json({ ok: true, stored: true, kind: "runtime" });
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
const isUp = body.event === "up";
|
|
106
|
-
const file = typeof body.file === "string" ? body.file : "";
|
|
107
|
-
if (!file && !isUp) return json({ error: "file is required" }, 400);
|
|
108
|
-
// A file-relative path never needs to be long; cap it so a misbehaving or
|
|
109
|
-
// hostile bridge-token holder can't inflate the per-tenant KV ring-buffer
|
|
110
|
-
// entry toward KV's per-value size limit.
|
|
111
|
-
if (file.length > 512) return json({ error: "file path too long" }, 400);
|
|
112
|
-
const event: ActivityEventKind = isUp
|
|
113
|
-
? "up"
|
|
114
|
-
: body.event === "add" || body.event === "unlink"
|
|
115
|
-
? body.event
|
|
116
|
-
: "change";
|
|
117
|
-
const at = typeof body.at === "number" && Number.isFinite(body.at) ? body.at : Date.now();
|
|
118
|
-
|
|
119
|
-
if (!tenantCache) return json({ ok: true, stored: false }); // no KV in this runtime (dev) — accepted, not stored
|
|
120
|
-
|
|
121
|
-
const ev: ActivityEvent = { file, event, at };
|
|
122
|
-
await appendActivity(tenantCache, tenant.tenant_id, ev);
|
|
123
|
-
return json({ ok: true, stored: true });
|
|
124
|
-
}
|
|
@@ -1,73 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* POST /api/dev/cli-signin-code — mint a browserless CLI sign-in command for the
|
|
3
|
-
* SIGNED-IN developer (DevAccess #8425). The /dev getting-started page calls this to
|
|
4
|
-
* render a copyable `tot login --code <token>` so the developer's terminal signs in with
|
|
5
|
-
* no second browser round-trip and no OTP.
|
|
6
|
-
*
|
|
7
|
-
* AUTHORIZATION: the viewer's own session (the `tot_session` cookie). 401 if not signed
|
|
8
|
-
* in, 403 if the session carries no developer capability (nothing to sign a CLI into).
|
|
9
|
-
* The minted-for email is the VERIFIED session email — NEVER read from the body — so a
|
|
10
|
-
* developer can only mint a code for themselves.
|
|
11
|
-
*
|
|
12
|
-
* TENANT-SCOPED CODE (roadtoa3 postmortem, 2026-07-12): the mint also carries the
|
|
13
|
-
* developer's OWN tenant (their invite's vendor, the same resolution /dev renders with —
|
|
14
|
-
* first membership, else the session resource; never client input). The MCP stores it
|
|
15
|
-
* with the code so `tot login --code` lands the terminal ALREADY SCOPED to that vendor —
|
|
16
|
-
* `tot start` goes straight to their store's source, no client_list round-trip, no
|
|
17
|
-
* "no stores you can build on yet". See handoff
|
|
18
|
-
* `2026-07-12-tot-mcp-cli-code-tenant-binding` for the MCP half (store tenant with the
|
|
19
|
-
* code + write the durable subject-capability binding at mint).
|
|
20
|
-
*
|
|
21
|
-
* The response `command` CONTAINS the single-use token; it is returned only to this
|
|
22
|
-
* authenticated developer's own browser and is never logged server-side.
|
|
23
|
-
*/
|
|
24
|
-
import type { APIContext } from "astro";
|
|
25
|
-
import { readViewerSession } from "@/lib/auth/route";
|
|
26
|
-
import { mintCliSignInCommand } from "@/lib/dev/cliSignInCode";
|
|
27
|
-
import { mintActivityIngestToken } from "@/lib/dev/activityIngestToken";
|
|
28
|
-
import { readKv } from "@/lib/env";
|
|
29
|
-
|
|
30
|
-
export const prerender = false;
|
|
31
|
-
|
|
32
|
-
function json(body: unknown, status = 200): Response {
|
|
33
|
-
return new Response(JSON.stringify(body), {
|
|
34
|
-
status,
|
|
35
|
-
headers: { "content-type": "application/json" },
|
|
36
|
-
});
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export async function POST(context: APIContext): Promise<Response> {
|
|
40
|
-
const session = await readViewerSession(context);
|
|
41
|
-
if (!session) return json({ error: "not signed in" }, 401);
|
|
42
|
-
// Same gate the /dev page applies (hasStoreAccess): a real developer capability. A
|
|
43
|
-
// no-capability session has no scoped CLI session to mint.
|
|
44
|
-
if (!session.capability || session.capability === "none") {
|
|
45
|
-
return json({ error: "no developer access on this session", reason: "notEligible" }, 403);
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
// The developer's own vendor — the tenant their invite granted (same resolution /dev
|
|
49
|
-
// renders with). Rides with the code so the CLI session lands on this store directly.
|
|
50
|
-
const tenant = session.tenants?.[0]?.resource ?? session.resource ?? null;
|
|
51
|
-
|
|
52
|
-
// Mint the local→hosted activity-bridge credential alongside the CLI code (best
|
|
53
|
-
// effort — a runtime without the SESSION KV binding just omits it, and the local
|
|
54
|
-
// loop runs with no hosted signal, same as before this existed).
|
|
55
|
-
let activityBridge: { token: string; url: string } | null = null;
|
|
56
|
-
const sessionKv = await readKv("SESSION");
|
|
57
|
-
if (sessionKv && tenant) {
|
|
58
|
-
const { token } = await mintActivityIngestToken(sessionKv, {
|
|
59
|
-
email: session.email,
|
|
60
|
-
tenant,
|
|
61
|
-
capability: session.capability,
|
|
62
|
-
});
|
|
63
|
-
activityBridge = { token, url: context.url.origin };
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
const result = await mintCliSignInCommand(session.email, tenant, {}, activityBridge);
|
|
67
|
-
if (!result.ok) {
|
|
68
|
-
// 503 = seam not configured / MCP route unmounted; 502 = MCP fault. Surface neither
|
|
69
|
-
// the token nor internal detail beyond a terse reason.
|
|
70
|
-
return json({ error: "CLI sign-in code unavailable", reason: result.reason }, result.status);
|
|
71
|
-
}
|
|
72
|
-
return json({ command: result.command, expiresAt: result.expiresAt });
|
|
73
|
-
}
|
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* GET /api/dev/diff?path= — Phase 2 (cp-diff) of the hosted /dev cockpit
|
|
3
|
-
* (workstream tot-lime-cloud-dev-cockpit, decision
|
|
4
|
-
* architecture-environment-aware-cockpit). Diffs the developer's draft
|
|
5
|
-
* overlay against published for one path, returning the SAME shape as the
|
|
6
|
-
* local loop's `/__tot/diff` (scripts/dev/git-status.mjs handleDiff —
|
|
7
|
-
* {ok,path,status,shown,before,after}) so dev.astro's existing diff UI
|
|
8
|
-
* (doDiff/renderDiff) needs no client-side change.
|
|
9
|
-
*
|
|
10
|
-
* No git in the Worker — "status" derives from draft-present-vs-published
|
|
11
|
-
* instead of `git status`: "unchanged" (no draft, or draft equals
|
|
12
|
-
* published), "modified" (draft differs from an existing published value),
|
|
13
|
-
* "added" (a draft exists for a path with no published counterpart yet —
|
|
14
|
-
* e.g. a brand-new pages-html/<slug>.html the developer hasn't published).
|
|
15
|
-
* Both sides are the raw-HTML/JSON writable surface, always text — no binary
|
|
16
|
-
* degrade case exists here (unlike the local loop's arbitrary file tree).
|
|
17
|
-
*/
|
|
18
|
-
import type { APIContext } from "astro";
|
|
19
|
-
import { json } from "@/lib/grants/route.js";
|
|
20
|
-
import {
|
|
21
|
-
resolveCockpitContext,
|
|
22
|
-
isCockpitContext,
|
|
23
|
-
isWritablePath,
|
|
24
|
-
readPublishedPath,
|
|
25
|
-
resolveDraftState,
|
|
26
|
-
} from "@/lib/dev/hostedCockpit";
|
|
27
|
-
|
|
28
|
-
export const prerender = false;
|
|
29
|
-
|
|
30
|
-
const MAX_DIFF_BYTES = 512 * 1024;
|
|
31
|
-
|
|
32
|
-
export async function GET(context: APIContext): Promise<Response> {
|
|
33
|
-
const ctx = await resolveCockpitContext(context);
|
|
34
|
-
if (!isCockpitContext(ctx)) return ctx;
|
|
35
|
-
|
|
36
|
-
const path = new URL(context.request.url).searchParams.get("path");
|
|
37
|
-
if (!path) return json({ ok: false, status: "no_path", message: "path query param is required" }, 400);
|
|
38
|
-
if (!isWritablePath(path)) {
|
|
39
|
-
return json({ ok: false, status: "bad_path", message: `Not available on the hosted panel: ${path}` }, 400);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
const [published, { draftContent }] = await Promise.all([
|
|
43
|
-
readPublishedPath(ctx, path),
|
|
44
|
-
resolveDraftState(context, ctx, path),
|
|
45
|
-
]);
|
|
46
|
-
|
|
47
|
-
if (published === null && draftContent === null) {
|
|
48
|
-
return json({ ok: false, status: "not_found", message: `No such file: ${path}` }, 404);
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
const status =
|
|
52
|
-
draftContent === null
|
|
53
|
-
? "unchanged"
|
|
54
|
-
: published === null
|
|
55
|
-
? "added"
|
|
56
|
-
: draftContent === published
|
|
57
|
-
? "unchanged"
|
|
58
|
-
: "modified";
|
|
59
|
-
|
|
60
|
-
const before = published ?? "";
|
|
61
|
-
const after = draftContent ?? published ?? "";
|
|
62
|
-
const size = new TextEncoder().encode(before + after).length;
|
|
63
|
-
if (size > MAX_DIFF_BYTES) {
|
|
64
|
-
return json({ ok: true, path, status, shown: false, reason: "too_big" });
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
return json({ ok: true, path, status, shown: true, before, after });
|
|
68
|
-
}
|
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* GET /api/dev/file?path= — Phase 1 (cp-file) of the hosted /dev cockpit
|
|
3
|
-
* (workstream tot-lime-cloud-dev-cockpit, decision
|
|
4
|
-
* architecture-environment-aware-cockpit). Reads one file off the same
|
|
5
|
-
* synthesized tree cp-tree (/api/dev/tree) lists, mirroring the local loop's
|
|
6
|
-
* `/__tot/file` response contract (scripts/dev/file-browser.mjs readFile/
|
|
7
|
-
* handleFile) so dev.astro's filesScript viewer needs no client-side change.
|
|
8
|
-
*
|
|
9
|
-
* `path` is one of the three shapes cp-tree ever lists — content/home.html,
|
|
10
|
-
* content/pages-html/<slug>.html, or theme.json (scripts/dev/safe-path.mjs
|
|
11
|
-
* WRITE_ALLOWLIST) — so this route is a closed switch on those shapes, not a
|
|
12
|
-
* general path resolver; anything else is a clean 400, never a traversal risk.
|
|
13
|
-
*
|
|
14
|
-
* Reads the developer's OWN draft overlay first (readEffectivePath), falling
|
|
15
|
-
* back to published — so re-opening a file after a cp-write save shows the
|
|
16
|
-
* edit, and the `mtimeMs` it hands back is the SAME contentVersion() cp-write
|
|
17
|
-
* checks on the next save (the C6 stale-write guard).
|
|
18
|
-
*
|
|
19
|
-
* `writable:true` on all three is intentional now that cp-write exists: this
|
|
20
|
-
* is genuinely the editable surface.
|
|
21
|
-
*/
|
|
22
|
-
import type { APIContext } from "astro";
|
|
23
|
-
import { json } from "@/lib/grants/route.js";
|
|
24
|
-
import {
|
|
25
|
-
resolveCockpitContext,
|
|
26
|
-
isCockpitContext,
|
|
27
|
-
isWritablePath,
|
|
28
|
-
languageForPath,
|
|
29
|
-
readEffectivePath,
|
|
30
|
-
contentVersion,
|
|
31
|
-
} from "@/lib/dev/hostedCockpit";
|
|
32
|
-
|
|
33
|
-
export const prerender = false;
|
|
34
|
-
|
|
35
|
-
const MAX_FILE_BYTES = 512 * 1024;
|
|
36
|
-
|
|
37
|
-
export async function GET(context: APIContext): Promise<Response> {
|
|
38
|
-
const ctx = await resolveCockpitContext(context);
|
|
39
|
-
if (!isCockpitContext(ctx)) return ctx;
|
|
40
|
-
|
|
41
|
-
const path = new URL(context.request.url).searchParams.get("path");
|
|
42
|
-
if (!path) return json({ ok: false, status: "no_path", message: "path query param is required" }, 400);
|
|
43
|
-
if (!isWritablePath(path)) {
|
|
44
|
-
return json({ ok: false, status: "bad_path", message: `Not available on the hosted panel: ${path}` }, 400);
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
const { content } = await readEffectivePath(context, ctx, path);
|
|
48
|
-
if (content === null) return json({ ok: false, status: "not_found", message: `No such file: ${path}` }, 404);
|
|
49
|
-
|
|
50
|
-
const language = languageForPath(path);
|
|
51
|
-
const mtimeMs = contentVersion(content);
|
|
52
|
-
const size = new TextEncoder().encode(content).length;
|
|
53
|
-
if (size > MAX_FILE_BYTES) {
|
|
54
|
-
return json({ ok: true, status: "ok", path, tooBig: true, size, language, content: null, mtimeMs, writable: true });
|
|
55
|
-
}
|
|
56
|
-
return json({ ok: true, status: "ok", path, size, language, content, mtimeMs, writable: true });
|
|
57
|
-
}
|
|
@@ -1,125 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* POST /api/dev/locate — Phase 2 (cp-locate) of the hosted /dev cockpit
|
|
3
|
-
* (workstream tot-lime-cloud-dev-cockpit, decision
|
|
4
|
-
* architecture-environment-aware-cockpit). Click-to-source in the Worker:
|
|
5
|
-
* given a clicked preview URL + captured DOM path, resolve the source
|
|
6
|
-
* line:col in the developer's raw-HTML content, using the exact SAME pure
|
|
7
|
-
* parse5 walk as the local loop (scripts/dev/locate.mjs `locate()`) and the
|
|
8
|
-
* same URL -> content-path mapping (scripts/dev/locate-handler.mjs
|
|
9
|
-
* `resolvePageSource`), both imported directly (dependency-free besides
|
|
10
|
-
* parse5, safe in the Worker).
|
|
11
|
-
*
|
|
12
|
-
* Honors decision click-to-source-anchors-at-content-region from the
|
|
13
|
-
* local-loop board: a click resolves against the raw content region only; the
|
|
14
|
-
* storefront's own chrome (header/nav/footer, not in tenant source) refuses
|
|
15
|
-
* explicitly with `not_in_content`, never a coincidental miss.
|
|
16
|
-
*
|
|
17
|
-
* Unlike the local route, this reads the developer's DRAFT overlay first
|
|
18
|
-
* (readEffectivePath) — correct, since that's what their preview iframe is
|
|
19
|
-
* actually showing once they've saved an edit (cp-write). No open-in-editor
|
|
20
|
-
* equivalent: the jump lands in the in-cockpit editor via the existing
|
|
21
|
-
* tot:locate postMessage handler (c8Script), already wired and unchanged.
|
|
22
|
-
*
|
|
23
|
-
* Only `locate()` itself is imported from scripts/dev/locate.mjs (pure,
|
|
24
|
-
* parse5-only — Workers-safe). The URL->content-path mapping
|
|
25
|
-
* (resolvePageSource in scripts/dev/locate-handler.mjs) is reimplemented
|
|
26
|
-
* here rather than imported, because that module also pulls in node:fs +
|
|
27
|
-
* node:child_process (open-in-editor) which have no place in a Worker
|
|
28
|
-
* bundle; isWritablePath (hostedCockpit.ts) already IS this route's write
|
|
29
|
-
* allowlist, so the two definitions can't drift.
|
|
30
|
-
*
|
|
31
|
-
* NOTE (scope): this ships the server contract only. The client picker
|
|
32
|
-
* widget (src/lib/dev/ai-widget-client.js) that lets a developer click an
|
|
33
|
-
* element in the preview has no hosted injection path yet (dev-only today,
|
|
34
|
-
* see rawMarketingHtml.ts) — tracked separately as cp-locate-widget-injection
|
|
35
|
-
* so that security-sensitive gating gets its own review rather than riding
|
|
36
|
-
* in here.
|
|
37
|
-
*/
|
|
38
|
-
import type { APIContext } from "astro";
|
|
39
|
-
import { json } from "@/lib/grants/route.js";
|
|
40
|
-
import { locate } from "../../../../../../scripts/dev/locate.mjs";
|
|
41
|
-
import {
|
|
42
|
-
resolveCockpitContext,
|
|
43
|
-
isCockpitContext,
|
|
44
|
-
readEffectivePath,
|
|
45
|
-
isWritablePath,
|
|
46
|
-
} from "@/lib/dev/hostedCockpit";
|
|
47
|
-
|
|
48
|
-
export const prerender = false;
|
|
49
|
-
|
|
50
|
-
interface LocateBody {
|
|
51
|
-
path?: unknown;
|
|
52
|
-
domPath?: unknown;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Map a clicked preview URL to its raw-HTML content source, root-relative.
|
|
57
|
-
* Mirrors scripts/dev/locate-handler.mjs's resolvePageSource exactly (see
|
|
58
|
-
* that module's header for the mapping rules); duplicated rather than
|
|
59
|
-
* imported for the node:fs-free-Worker-bundle reason above.
|
|
60
|
-
*/
|
|
61
|
-
function resolvePreviewPageSource(pathname: string, appDomain: string): string | null {
|
|
62
|
-
let clean: string;
|
|
63
|
-
try {
|
|
64
|
-
clean = decodeURIComponent(pathname.split("?")[0]!.split("#")[0]!);
|
|
65
|
-
} catch {
|
|
66
|
-
return null;
|
|
67
|
-
}
|
|
68
|
-
const segments = clean.split("/").filter(Boolean);
|
|
69
|
-
if (segments.length && segments[0] === appDomain) segments.shift();
|
|
70
|
-
if (segments.some((s) => s === "..")) return null;
|
|
71
|
-
const rel = segments.length === 0 ? "content/home.html" : `content/pages-html/${segments.join("/")}.html`;
|
|
72
|
-
return isWritablePath(rel) ? rel : null;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
function isDomPath(domPath: unknown): domPath is Array<{ tag: string; index: number }> {
|
|
76
|
-
return (
|
|
77
|
-
Array.isArray(domPath) &&
|
|
78
|
-
domPath.length > 0 &&
|
|
79
|
-
domPath.every(
|
|
80
|
-
(s) => s && typeof s === "object" && typeof (s as any).tag === "string" && Number.isFinite(Number((s as any).index)),
|
|
81
|
-
)
|
|
82
|
-
);
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
export async function POST(context: APIContext): Promise<Response> {
|
|
86
|
-
const ctx = await resolveCockpitContext(context);
|
|
87
|
-
if (!isCockpitContext(ctx)) return ctx;
|
|
88
|
-
|
|
89
|
-
let body: LocateBody;
|
|
90
|
-
try {
|
|
91
|
-
body = (await context.request.json()) as LocateBody;
|
|
92
|
-
} catch {
|
|
93
|
-
return json({ ok: false, status: "bad_json", message: "invalid JSON body" }, 400);
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
if (!isDomPath(body.domPath)) {
|
|
97
|
-
return json({ ok: false, status: "bad_dom_path", message: "domPath must be a non-empty array of {tag,index}" }, 400);
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
const rel = typeof body.path === "string" ? resolvePreviewPageSource(body.path, ctx.tenant.appDomain) : null;
|
|
101
|
-
if (!rel) {
|
|
102
|
-
return json({
|
|
103
|
-
ok: false,
|
|
104
|
-
status: "not_raw_html",
|
|
105
|
-
message: "This page isn’t a raw-HTML content page — click-to-source is for content/*.html tenants.",
|
|
106
|
-
});
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
const { content } = await readEffectivePath(context, ctx, rel);
|
|
110
|
-
if (content === null) {
|
|
111
|
-
return json({ ok: false, status: "not_raw_html", message: "No raw-HTML source backs this page." });
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
const found = locate(content, body.domPath);
|
|
115
|
-
if (typeof (found as { line?: number }).line === "number") {
|
|
116
|
-
const { line, col } = found as { line: number; col: number };
|
|
117
|
-
return json({ ok: true, path: rel, line, col });
|
|
118
|
-
}
|
|
119
|
-
return json({
|
|
120
|
-
ok: false,
|
|
121
|
-
status: "not_in_content",
|
|
122
|
-
message:
|
|
123
|
-
"That element is part of the storefront frame (header, nav, or footer), not your content source. Click something inside your page content.",
|
|
124
|
-
});
|
|
125
|
-
}
|