@tokenoftrust/storefront-runner 2.0.0 → 2.0.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.
@@ -4,15 +4,23 @@ import { readdirSync, existsSync } from "node:fs";
4
4
  import { basename, dirname, join } from "node:path";
5
5
  import { markSave, reportServerUp } from "./dev-loop-state.mjs";
6
6
  import { isTransientWatchFile } from "../../../scripts/dev/transient-files.mjs";
7
+ import { E2E_TENANT_DIR, tenantDirSegments, tenantDirRelative } from "../../../scripts/lib/tenant-dirs.mjs";
8
+ import { validateSavedFile } from "./tenant-validate-on-save.mjs";
9
+ import { validateTenant } from "../../../scripts/tenant/validate.mjs";
7
10
  import { readFile } from "node:fs/promises";
8
11
 
9
12
  // Repo-root tenants/ dir (colocated tenant layout) + the generated static dest
10
13
  // the predev copy step mirrors public/ into. This module lives at
11
14
  // apps/storefront/dev-plugins/, so tenants/ is three levels up. Forward-slash
12
15
  // normalized to match Vite's ctx.file (Vite always reports posix-style paths).
13
- const TENANTS_DIR = fileURLToPath(new URL("../../../tenants", import.meta.url)).replace(/\\/g, "/");
16
+ const REPO_ROOT = fileURLToPath(new URL("../../..", import.meta.url));
17
+ const TENANTS_DIR = join(REPO_ROOT, "tenants").replace(/\\/g, "/");
14
18
  const PUBLIC_TENANTS_DEST = fileURLToPath(new URL("../public/tenants", import.meta.url));
15
19
 
20
+ /** Per-file finding signatures, so an unchanged warning is not reprinted on every
21
+ * keystroke-save. Lives for the dev server's lifetime. */
22
+ const savedFileFindings = new Map();
23
+
16
24
  /**
17
25
  * Load a TS module's exports for Node-side (plugin) use, reusing Vite's own
18
26
  * transform pipeline instead of a plain `import` (which can't parse `.ts`) or
@@ -53,7 +61,10 @@ async function loadTsModule(server, url) {
53
61
  export function classifyTenantFile(file) {
54
62
  const f = String(file).replace(/\\/g, "/");
55
63
  if (!f.startsWith(TENANTS_DIR + "/")) return null;
56
- const rel = f.slice(TENANTS_DIR.length + 1); // "<id>/<...>"
64
+ // "<id>/<...>" for a real tenant, "e2e/<id>/<...>" for a platform test tenant —
65
+ // consume the nesting segment so `id` is always the tenant's own id.
66
+ let rel = f.slice(TENANTS_DIR.length + 1);
67
+ if (rel.startsWith(E2E_TENANT_DIR + "/")) rel = rel.slice(E2E_TENANT_DIR.length + 1);
57
68
  const slash = rel.indexOf("/");
58
69
  if (slash < 0) return null;
59
70
  const rest = rel.slice(slash + 1);
@@ -97,7 +108,7 @@ export function isVersionedTenant(id) {
97
108
  .map((s) => s.trim())
98
109
  .filter(Boolean);
99
110
  if (envList.includes(id)) return true;
100
- return existsSync(join(TENANTS_DIR, id, ".tot", "assets-versioned"));
111
+ return existsSync(join(REPO_ROOT, ...tenantDirSegments(id), ".tot", "assets-versioned"));
101
112
  }
102
113
 
103
114
  /**
@@ -260,6 +271,18 @@ export function tenantHotReload() {
260
271
  // matching reload is timestamped by dev-loop-monitor's ws/hot wrap.
261
272
  markSave(rest, "change");
262
273
 
274
+ // Validate the save — deliberately NOT awaited. The reload branches below
275
+ // dispatch exactly as they did before, and the findings arrive in the
276
+ // terminal a beat later. Putting this in the reload path would tax every
277
+ // save to report something that is almost always empty.
278
+ void validateSavedFile({
279
+ tenantDir: join(REPO_ROOT, tenantDirRelative(id)),
280
+ tenantId: id,
281
+ rest,
282
+ memo: savedFileFindings,
283
+ validate: validateTenant,
284
+ });
285
+
263
286
  // Content HTML: the change invalidates the SSR module graph, so the
264
287
  // Cloudflare Vite plugin reloads the worker and — once it is ready — emits
265
288
  // its own correctly-timed full-reload (which the injected Vite HMR client
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Validate a tenant file on save, OFF the reload path.
3
+ *
4
+ * The validator's warnings used to reach a developer only if they remembered to
5
+ * run `tot validate`, or at `tot submit` — both after the fact. The earliest
6
+ * moment is the save itself, and `tot dev` already watches every tenant file.
7
+ *
8
+ * The constraint is that the dev loop's whole value is save→HMR speed, so this
9
+ * must never sit in that path: `handleHotUpdate` fires this and does not await it.
10
+ * The reload is dispatched exactly as before and the findings land a beat later in
11
+ * the terminal. A whole-tenant pass measures ~13ms, so there is no need to scope
12
+ * the validator itself — only to keep it off the critical path.
13
+ *
14
+ * Errors are NOT reported here. An error already blocks `tot submit`, and the dev
15
+ * loop is where a page is half-written by definition — a fragment mid-edit
16
+ * legitimately fails checks it will pass a keystroke later. Warnings are the ones
17
+ * that otherwise ship silently, which is the gap this closes.
18
+ */
19
+
20
+ /** Findings worth interrupting a save for: warnings on the file just saved. */
21
+ export function findingsForSavedFile(findings, rest) {
22
+ const target = String(rest || "").replace(/\\/g, "/");
23
+ return (findings || []).filter(
24
+ (f) => f.level !== "error" && String(f.file || "").replace(/\\/g, "/").endsWith(target),
25
+ );
26
+ }
27
+
28
+ /** A stable identity for a file's finding set, so an unchanged set is not reprinted. */
29
+ export function findingsSignature(findings) {
30
+ return findings.map((f) => `${f.rule}:${f.message}`).sort().join("|");
31
+ }
32
+
33
+ /**
34
+ * Decide what to print for one save. Pure: the caller owns the memo and the
35
+ * console. Returns null when there is nothing new to say — an unchanged finding
36
+ * set on every keystroke-save is noise that trains people to ignore the channel.
37
+ *
38
+ * @param {{ findings: any[], rest: string, previousSignature: string | undefined }} input
39
+ * @returns {{ signature: string, lines: string[] } | null}
40
+ */
41
+ export function reportForSave({ findings, rest, previousSignature }) {
42
+ const mine = findingsForSavedFile(findings, rest);
43
+ const signature = findingsSignature(mine);
44
+ if (signature === (previousSignature ?? "")) return null;
45
+ // Newly clean: say so once, so a fixed warning visibly clears rather than just
46
+ // never being mentioned again.
47
+ if (!mine.length) return { signature, lines: [` ✓ ${rest} — validation warnings cleared`] };
48
+ return {
49
+ signature,
50
+ lines: [
51
+ ` ⚠ ${rest} — ${mine.length} validation warning(s)`,
52
+ ...mine.map((f) => ` ⚠ [${f.rule}] ${f.message}`),
53
+ ],
54
+ };
55
+ }
56
+
57
+ /**
58
+ * Run the validator for the tenant owning the saved file and print anything new.
59
+ * Never throws and never returns a rejected promise: a validator fault must not
60
+ * break the dev loop, which is the thing the developer is actually using.
61
+ *
62
+ * @param {{ tenantDir: string, tenantId: string, rest: string,
63
+ * memo: Map<string, string>, validate: (dir: string, opts?: any) => any,
64
+ * log?: (line: string) => void }} input
65
+ */
66
+ export async function validateSavedFile({ tenantDir, tenantId, rest, memo, validate, log = console.error }) {
67
+ let findings;
68
+ try {
69
+ ({ findings } = validate(tenantDir, { tenantId }));
70
+ } catch {
71
+ return; // a validator fault is never worth breaking the loop over
72
+ }
73
+ const key = `${tenantId}/${rest}`;
74
+ const report = reportForSave({ findings, rest, previousSignature: memo.get(key) });
75
+ if (!report) return;
76
+ memo.set(key, report.signature);
77
+ for (const line of report.lines) log(line);
78
+ }
@@ -10639,7 +10639,7 @@ if (invokedPath2) {
10639
10639
  // apps/storefront/scripts/v84CleanCloneGraphPin.ts
10640
10640
  var V84_CLEAN_CLONE_LAUNCHER_ENTRY = "scripts/diag/rerun-v84-clean-clone.sh";
10641
10641
  var V84_CLEAN_CLONE_GRAPH_COUNT = 191;
10642
- var V84_CLEAN_CLONE_GRAPH_SHA256 = "e284ebf9bd5f0795bee4126c5b2e36cf7cbade05fcbcc9b716a35fa9800fa377";
10642
+ var V84_CLEAN_CLONE_GRAPH_SHA256 = "dc9fb2e28b7d8041ca901ae2df204ab34da1eb957cd88f75437361e42491e566";
10643
10643
 
10644
10644
  // apps/storefront/scripts/replay-v84-clean-clone-pass.ts
10645
10645
  var SOURCE3 = "tokenoftrust.com";
@@ -12,7 +12,7 @@ export const V84_CLEAN_CLONE_BUILD_ENTRY =
12
12
  "apps/storefront/scripts/build-v84-clean-clone-standalone.mjs" as const;
13
13
  export const V84_CLEAN_CLONE_GRAPH_COUNT = 191;
14
14
  export const V84_CLEAN_CLONE_GRAPH_SHA256 =
15
- "e284ebf9bd5f0795bee4126c5b2e36cf7cbade05fcbcc9b716a35fa9800fa377" as const;
15
+ "dc9fb2e28b7d8041ca901ae2df204ab34da1eb957cd88f75437361e42491e566" as const;
16
16
 
17
17
  export interface V84CleanCloneGraphIdentity {
18
18
  readonly paths: readonly string[];
@@ -42,11 +42,16 @@ const { tenant, basePath } = Astro.locals;
42
42
 
43
43
  // Viewer capability, mirrored from the same session model the API routes gate
44
44
  // on (resolveOwnerSession admits capability owner|admin). UX-only: the server
45
- // re-authorizes every action.
45
+ // re-authorizes every action. Checked against `envelope.targetTenant`, not the
46
+ // routed `tenant.appDomain` — a ToT-staff viewer operating a store other than
47
+ // the one that served this admin route must be checked against the tenant
48
+ // they SELECTED, else a real grant on the selected tenant reads as "none".
46
49
  const viewerSession = await readViewerSession(Astro);
47
- // Session capability counts only when the session's resource IS this tenant.
50
+ // Session capability counts only when the session's resource IS the selected tenant.
48
51
  const sessionCapability =
49
- viewerSession?.resource === tenant.appDomain ? viewerSession.capability : undefined;
52
+ viewerSession && viewerSession.resource === envelope.targetTenant
53
+ ? viewerSession.capability
54
+ : undefined;
50
55
  const viewerCapability =
51
56
  Astro.locals.viewer?.capability ?? sessionCapability ?? "none";
52
57
  // A signed-in DEVELOPER (not owner/admin) may hold an explicit, owner-revocable
@@ -58,7 +63,7 @@ let viewerCanShip = viewerCapability === "owner" || viewerCapability === "admin"
58
63
  if (!viewerCanShip && Astro.locals.viewer?.email) {
59
64
  const shipGrant = await resolveViewerShipCapability(
60
65
  Astro.locals.viewer.email,
61
- tenant.appDomain,
66
+ envelope.targetTenant,
62
67
  );
63
68
  if (shipGrant === "ship-on-behalf") viewerCanShip = true;
64
69
  }
@@ -20,7 +20,7 @@
20
20
  * Changing that value flips which tenant a bare/unknown host serves — proof the
21
21
  * default is configurable, not hardcoded.
22
22
  */
23
- import type { TenantResolver } from "@tot/public-runtime";
23
+ import { tenantIdFromPath, type TenantResolver } from "@tot/public-runtime";
24
24
  import { readEnv, readKv } from "@/lib/env";
25
25
  import {
26
26
  tenantResolver as staticResolver,
@@ -32,24 +32,32 @@ import { getTenantTheme } from "@/themes/tenants";
32
32
  import { devTenantsToSeed } from "@/config/devTenantSeed";
33
33
  import { MemoryKv } from "@/lib/memoryKv";
34
34
 
35
- // LOCAL dev loop discovery: enumerate the colocated `tenants/<id>/` dirs on disk,
35
+ // LOCAL dev loop discovery: enumerate the colocated tenant dirs on disk,
36
36
  // INCLUDING a developer's checkout grafted in by `tot dev` (scripts/tot-dev.mjs).
37
37
  // Keys only — the loaders are never invoked. Vite resolves this glob at
38
38
  // dev-server boot (after the graft) and re-resolves on HMR, so a newly grafted
39
39
  // tenant dir is discovered. Mirrors the content/theme dir globs the app already
40
40
  // uses (provider.ts, themes/tenants/index.ts). Depth: from src/config/ the repo
41
41
  // root is four levels up (config→src→storefront→apps→root).
42
+ //
43
+ // Both tiers are globbed: real tenants at `tenants/<id>/`, platform test tenants
44
+ // one level deeper at `tenants/e2e/<id>/` (tenantDirRelative). Vite needs literal
45
+ // patterns, so the nesting is spelled out here and `tenantIdFromPath` — the one
46
+ // inverse of that mapping — recovers the id from either shape.
42
47
  const localTenantFiles = import.meta.glob([
43
48
  "../../../../tenants/*/theme.json",
44
49
  "../../../../tenants/*/content/home.html",
45
50
  "../../../../tenants/*/content/home.json",
51
+ "../../../../tenants/e2e/*/theme.json",
52
+ "../../../../tenants/e2e/*/content/home.html",
53
+ "../../../../tenants/e2e/*/content/home.json",
46
54
  ]);
47
55
 
48
- /** Dir names under `tenants/*` (dir == tenant id), from the discovery glob keys. */
56
+ /** Tenant ids with a local dir (dir == tenant id), from the discovery glob keys. */
49
57
  function localTenantDirIds(): string[] {
50
58
  const ids = new Set<string>();
51
59
  for (const key of Object.keys(localTenantFiles)) {
52
- const id = /\/tenants\/([^/]+)\//.exec(key)?.[1];
60
+ const id = tenantIdFromPath(key);
53
61
  if (id) ids.add(id);
54
62
  }
55
63
  return [...ids];
@@ -80,7 +88,7 @@ function catalogScopes(): Set<string> {
80
88
  // it. The explicit `.tot` literal in the pattern matches the dotdir; if the glob
81
89
  // sees nothing, synthesis simply falls back to the colocation convention.
82
90
  const tenantMarkerFiles = import.meta.glob<{ scope?: string }>(
83
- "../../../../tenants/*/.tot/config.json",
91
+ ["../../../../tenants/*/.tot/config.json", "../../../../tenants/e2e/*/.tot/config.json"],
84
92
  { eager: true, import: "default" },
85
93
  );
86
94
 
@@ -88,7 +96,7 @@ const tenantMarkerFiles = import.meta.glob<{ scope?: string }>(
88
96
  function slugScopes(): Map<string, string> {
89
97
  const map = new Map<string, string>();
90
98
  for (const [key, cfg] of Object.entries(tenantMarkerFiles)) {
91
- const id = /\/tenants\/([^/]+)\//.exec(key)?.[1];
99
+ const id = tenantIdFromPath(key);
92
100
  const scope = cfg?.scope;
93
101
  // A hostname scope that DIFFERS from the dir is the slug override; a scope
94
102
  // equal to the dir (the colocation norm) needs no override.
@@ -1,3 +1,4 @@
1
+ import { tenantDirSegment } from "@tot/public-runtime";
1
2
  import type { AuthorBio, BlogPost, BlogPostSummary, PostStatus } from "./types.js";
2
3
  import type { ContentProvider } from "../storyblok/provider.js";
3
4
 
@@ -21,15 +22,24 @@ export interface Collection {
21
22
  // collection id are both path segments filtered at call time. The derived index
22
23
  // is the set of `posts/*.json` files — there is no hand-maintained index.json.
23
24
  const postGlobs = import.meta.glob<BlogPost>(
24
- "../../../../../tenants/*/content/*/posts/*.json",
25
+ [
26
+ "../../../../../tenants/*/content/*/posts/*.json",
27
+ "../../../../../tenants/e2e/*/content/*/posts/*.json",
28
+ ],
25
29
  { import: "default" },
26
30
  );
27
31
  const redirectGlobs = import.meta.glob<Record<string, string>>(
28
- "../../../../../tenants/*/content/*/redirects.json",
32
+ [
33
+ "../../../../../tenants/*/content/*/redirects.json",
34
+ "../../../../../tenants/e2e/*/content/*/redirects.json",
35
+ ],
29
36
  { import: "default" },
30
37
  );
31
38
  const authorsGlobs = import.meta.glob<Record<string, AuthorBio>>(
32
- "../../../../../tenants/*/content/*/authors.json",
39
+ [
40
+ "../../../../../tenants/*/content/*/authors.json",
41
+ "../../../../../tenants/e2e/*/content/*/authors.json",
42
+ ],
33
43
  { import: "default" },
34
44
  );
35
45
 
@@ -128,7 +138,7 @@ function normalize(raw: BlogPost): BlogPost {
128
138
  }
129
139
 
130
140
  function postsPrefix(tenantId: string, collection: string): string {
131
- return `/tenants/${tenantId}/content/${collection}/posts/`;
141
+ return `${tenantDirSegment(tenantId)}content/${collection}/posts/`;
132
142
  }
133
143
 
134
144
  function toSummary(p: BlogPost): BlogPostSummary {
@@ -162,7 +172,7 @@ export async function listCollections(
162
172
  tenantId: string,
163
173
  content?: ArtifactSource,
164
174
  ): Promise<string[]> {
165
- const marker = `/tenants/${tenantId}/content/`;
175
+ const marker = `${tenantDirSegment(tenantId)}content/`;
166
176
  const collections = new Set<string>();
167
177
  const runtimePaths = canReadArtifacts(content)
168
178
  ? await content.listArtifactPaths("")
@@ -360,7 +370,7 @@ export async function getRedirects(
360
370
  }
361
371
  const key = Object.keys(redirectGlobs).find(
362
372
  (candidate) =>
363
- candidate.includes(`/tenants/${tenantId}/content/${collection}/`) &&
373
+ candidate.includes(`${tenantDirSegment(tenantId)}content/${collection}/`) &&
364
374
  candidate.endsWith("/redirects.json"),
365
375
  );
366
376
  if (!key) return {};
@@ -392,7 +402,7 @@ export async function getAuthors(
392
402
  }
393
403
  const key = Object.keys(authorsGlobs).find(
394
404
  (candidate) =>
395
- candidate.includes(`/tenants/${tenantId}/content/${collection}/`) &&
405
+ candidate.includes(`${tenantDirSegment(tenantId)}content/${collection}/`) &&
396
406
  candidate.endsWith("/authors.json"),
397
407
  );
398
408
  if (!key) return {};
@@ -3,6 +3,7 @@
3
3
  // derived engine lives in ./collection.ts and powers `the-build` + any new
4
4
  // collection; switching `/blog` onto it is a later migration once order is
5
5
  // encoded as data (see B0.1 §"/blog migration caution").
6
+ import { tenantDirSegment } from "@tot/public-runtime";
6
7
  import type { BlogPost, BlogPostSummary } from "./types.js";
7
8
  import type { ContentProvider } from "../storyblok/provider.js";
8
9
 
@@ -12,21 +13,33 @@ function canReadArtifacts(value: ArtifactSource | undefined): value is ArtifactS
12
13
  return typeof value?.listArtifactPaths === "function" && typeof value.readArtifact === "function";
13
14
  }
14
15
 
16
+ // Both tenant tiers: real at `tenants/<id>/`, platform test one level deeper at
17
+ // `tenants/e2e/<id>/`. `tenantDirSegment` is the one inverse used to look a
18
+ // tenant's keys back up.
15
19
  const indexGlobs = import.meta.glob<BlogPostSummary[]>(
16
- "../../../../../tenants/*/content/blog/index.json",
20
+ [
21
+ "../../../../../tenants/*/content/blog/index.json",
22
+ "../../../../../tenants/e2e/*/content/blog/index.json",
23
+ ],
17
24
  { import: "default" },
18
25
  );
19
26
  const postGlobs = import.meta.glob<BlogPost>(
20
- "../../../../../tenants/*/content/blog/posts/*.json",
27
+ [
28
+ "../../../../../tenants/*/content/blog/posts/*.json",
29
+ "../../../../../tenants/e2e/*/content/blog/posts/*.json",
30
+ ],
21
31
  { import: "default" },
22
32
  );
23
33
  const redirectGlobs = import.meta.glob<Record<string, string>>(
24
- "../../../../../tenants/*/content/blog/redirects.json",
34
+ [
35
+ "../../../../../tenants/*/content/blog/redirects.json",
36
+ "../../../../../tenants/e2e/*/content/blog/redirects.json",
37
+ ],
25
38
  { import: "default" },
26
39
  );
27
40
 
28
41
  function tenantKey(keys: string[], tenantId: string, suffix: string): string | undefined {
29
- const segment = `/tenants/${tenantId}/content/blog/`;
42
+ const segment = `${tenantDirSegment(tenantId)}content/blog/`;
30
43
  return keys.find((key) => key.includes(segment) && key.endsWith(suffix));
31
44
  }
32
45
 
@@ -47,7 +60,7 @@ export async function listBlogPosts(
47
60
  if (paths !== null) return [];
48
61
  }
49
62
  const key = tenantKey(Object.keys(indexGlobs), tenantId, "/index.json")
50
- ?? Object.keys(indexGlobs).find((candidate) => candidate.includes(`/tenants/${tenantId}/content/blog/index.json`));
63
+ ?? Object.keys(indexGlobs).find((candidate) => candidate.includes(`${tenantDirSegment(tenantId)}content/blog/index.json`));
51
64
  return key ? indexGlobs[key]!() : [];
52
65
  }
53
66
 
@@ -91,7 +104,7 @@ export async function resolveBlogRedirect(
91
104
  if (paths !== null) return null;
92
105
  }
93
106
  const key = tenantKey(Object.keys(redirectGlobs), tenantId, "/redirects.json")
94
- ?? Object.keys(redirectGlobs).find((candidate) => candidate.includes(`/tenants/${tenantId}/content/blog/redirects.json`));
107
+ ?? Object.keys(redirectGlobs).find((candidate) => candidate.includes(`${tenantDirSegment(tenantId)}content/blog/redirects.json`));
95
108
  if (!key) return null;
96
109
  const redirects = await redirectGlobs[key]!();
97
110
  return redirects[slug] ?? null;
@@ -24,6 +24,8 @@ import {
24
24
  contentArtifactPath,
25
25
  listPublishedArtifactPaths,
26
26
  readPublishedArtifact,
27
+ tenantDirRelative,
28
+ tenantDirSegment,
27
29
  } from "@tot/public-runtime";
28
30
  import { renderRawChrome } from "../rawChrome.js";
29
31
  import { parseChromeConfig } from "../chrome/parseConfig.js";
@@ -86,41 +88,65 @@ export interface ContentProvider {
86
88
  // time; a file missing for a tenant falls back to the default scope, so a
87
89
  // partially-adopted tenant still renders. Depth: from src/lib/storyblok/ the
88
90
  // repo root is five levels up (storyblok→lib→src→storefront→apps→root).
89
- const homeGlobs = import.meta.glob<HomeContent>("../../../../../tenants/*/content/home.json", {
91
+ const homeGlobs = import.meta.glob<HomeContent>([
92
+ "../../../../../tenants/*/content/home.json",
93
+ "../../../../../tenants/e2e/*/content/home.json",
94
+ ], {
90
95
  import: "default",
91
96
  });
92
- const chromeGlobs = import.meta.glob<unknown>("../../../../../tenants/*/content/chrome.json", {
97
+ const chromeGlobs = import.meta.glob<unknown>([
98
+ "../../../../../tenants/*/content/chrome.json",
99
+ "../../../../../tenants/e2e/*/content/chrome.json",
100
+ ], {
93
101
  import: "default",
94
102
  });
95
103
  // Per-page header/footer variant overrides for the raw-HTML chrome path (see
96
104
  // lib/chrome/assignments.ts). Tenant-owned, no cross-tenant fallback — same
97
105
  // convention as chromeGlobs above.
98
106
  const chromeAssignmentGlobs = import.meta.glob<unknown>(
99
- "../../../../../tenants/*/content/chrome-assignments.json",
107
+ [
108
+ "../../../../../tenants/*/content/chrome-assignments.json",
109
+ "../../../../../tenants/e2e/*/content/chrome-assignments.json",
110
+ ],
100
111
  { import: "default" },
101
112
  );
102
- const pageGlobs = import.meta.glob<EditorialPage>("../../../../../tenants/*/content/pages/*.json", {
113
+ const pageGlobs = import.meta.glob<EditorialPage>([
114
+ "../../../../../tenants/*/content/pages/*.json",
115
+ "../../../../../tenants/e2e/*/content/pages/*.json",
116
+ ], {
103
117
  import: "default",
104
118
  });
105
119
  // Raw self-authored home documents (marketing tenants). `?raw` bundles the file
106
120
  // as a string; a tenant with no home.html simply has no entry here.
107
- const homeHtmlGlobs = import.meta.glob<string>("../../../../../tenants/*/content/home.html", {
121
+ const homeHtmlGlobs = import.meta.glob<string>([
122
+ "../../../../../tenants/*/content/home.html",
123
+ "../../../../../tenants/e2e/*/content/home.html",
124
+ ], {
108
125
  query: "?raw",
109
126
  import: "default",
110
127
  });
111
128
  // Raw self-authored SUBPAGE documents: tenants/<tenant>/content/pages-html/<slug>.html
112
129
  // (e.g. pages-html/pricing.html -> /pricing/). `**` supports nested slugs.
113
- const pageHtmlGlobs = import.meta.glob<string>("../../../../../tenants/*/content/pages-html/**/*.html", {
130
+ const pageHtmlGlobs = import.meta.glob<string>([
131
+ "../../../../../tenants/*/content/pages-html/**/*.html",
132
+ "../../../../../tenants/e2e/*/content/pages-html/**/*.html",
133
+ ], {
114
134
  query: "?raw",
115
135
  import: "default",
116
136
  });
117
137
  // Shared-chrome wrapper document (marketing tenants that author body-only page
118
138
  // fragments). One per tenant; a tenant without it keeps serving full documents.
119
- const chromeHtmlGlobs = import.meta.glob<string>("../../../../../tenants/*/content/chrome.html", {
139
+ const chromeHtmlGlobs = import.meta.glob<string>([
140
+ "../../../../../tenants/*/content/chrome.html",
141
+ "../../../../../tenants/e2e/*/content/chrome.html",
142
+ ], {
120
143
  query: "?raw",
121
144
  import: "default",
122
145
  });
123
- const artifactGlobs = import.meta.glob<string>("../../../../../tenants/*/content/**/*", {
146
+ const artifactGlobs = import.meta.glob<string>([
147
+ "../../../../../tenants/*/content/**/*",
148
+ "../../../../../tenants/e2e/*/content/**/*",
149
+ ], {
124
150
  query: "?raw",
125
151
  import: "default",
126
152
  });
@@ -131,13 +157,13 @@ function pickContent<T>(
131
157
  globs: Record<string, () => Promise<T>>,
132
158
  scope: string,
133
159
  ): (() => Promise<T>) | null {
134
- const seg = `/tenants/${scope}/content/`;
160
+ const seg = `${tenantDirSegment(scope)}content/`;
135
161
  const key = Object.keys(globs).find((k) => k.includes(seg));
136
162
  return (key ? globs[key] : null) ?? null;
137
163
  }
138
164
 
139
165
  function pickPages(scope: string): Array<() => Promise<EditorialPage>> {
140
- const seg = `/tenants/${scope}/content/pages/`;
166
+ const seg = `${tenantDirSegment(scope)}content/pages/`;
141
167
  return Object.keys(pageGlobs)
142
168
  .filter((k) => k.includes(seg))
143
169
  .map((k) => pageGlobs[k]!);
@@ -150,7 +176,7 @@ function normalizeRawPageSlug(slug: string): string {
150
176
 
151
177
  /** The subpage slug a glob key encodes for `scope`, or null if it's not one. */
152
178
  function rawPageSlugForKey(scope: string, key: string): string | null {
153
- const seg = `/tenants/${scope}/content/pages-html/`;
179
+ const seg = `${tenantDirSegment(scope)}content/pages-html/`;
154
180
  const start = key.indexOf(seg);
155
181
  if (start < 0 || !key.endsWith(".html")) return null;
156
182
  return key.slice(start + seg.length, -".html".length);
@@ -187,7 +213,7 @@ async function readRawDevFile(scope: string, relative: string): Promise<string |
187
213
  // provider.ts lives at apps/storefront/src/lib/storyblok/ — the repo root is five
188
214
  // levels up, mirroring the import.meta.glob prefix ("../../../../../tenants/...").
189
215
  const here = dirname(fileURLToPath(import.meta.url));
190
- const abs = resolve(here, "../../../../..", "tenants", scope, "content", relative);
216
+ const abs = resolve(here, "../../../../..", ...tenantDirRelative(scope).split("/"), "content", relative);
191
217
  return await readFile(abs, "utf8");
192
218
  } catch {
193
219
  return null;
@@ -306,13 +332,13 @@ export class LocalContentProvider implements ContentProvider {
306
332
 
307
333
  async readArtifact(relative: string): Promise<string | null> {
308
334
  const normalized = relative.replace(/^\/+/, "");
309
- const marker = `/tenants/${this.scope}/content/${normalized}`;
335
+ const marker = `${tenantDirSegment(this.scope)}content/${normalized}`;
310
336
  const key = Object.keys(artifactGlobs).find((candidate) => candidate.endsWith(marker));
311
337
  return key ? artifactGlobs[key]!() : null;
312
338
  }
313
339
 
314
340
  async listArtifactPaths(prefix = ""): Promise<string[]> {
315
- const marker = `/tenants/${this.scope}/content/`;
341
+ const marker = `${tenantDirSegment(this.scope)}content/`;
316
342
  return Object.keys(artifactGlobs)
317
343
  .flatMap((key) => {
318
344
  const at = key.indexOf(marker);
@@ -69,6 +69,7 @@ import {
69
69
  PUBLIC_HOME_TENANT_ID,
70
70
  } from "@/lib/auth/loginGate";
71
71
  import { resolveAdminLanding, isAdminSelectPath } from "@/lib/auth/adminLanding";
72
+ import { resolveAdminTenantMode } from "@/lib/adminTenantEnvelope";
72
73
  import { gatedHoldingResponse } from "@/lib/auth/gatePage";
73
74
  import { readSession, KvSessionStore, SESSION_COOKIE } from "@/lib/auth/session";
74
75
  import { isMaintenanceOn, maintenanceResponse } from "@/lib/maintenance";
@@ -258,7 +259,22 @@ export const onRequest = defineMiddleware(async (context, next) => {
258
259
  // an explicit vendor selection sees the staffRoles-scoped capability. The
259
260
  // ship gates (decideIsOwner/resolveShipPrincipal) read this capability,
260
261
  // so binding it here keeps owner-resolution server-side + tenant-correct.
261
- const hostCapability = sessionHostCapability(viewerRecord, tenant.appDomain, nowSeconds);
262
+ //
263
+ // On an admin path, "the host" a staff viewer is really acting on is the
264
+ // ?asTenant= impersonation target (mirrors admin.astro/AdminPublishTab.astro's
265
+ // own resolveAdminTenantMode call) — resolving capability against the routed
266
+ // host instead left a staff viewer who selected another tenant reading as
267
+ // "not an owner" of it even while holding a real grant, because their
268
+ // staffSelection is bound to the SELECTED tenant, never the host app.
269
+ const capabilityHost = isAdminPath(url.pathname)
270
+ ? resolveAdminTenantMode({
271
+ session: viewerRecord,
272
+ adminAppTenant: tenant.appDomain,
273
+ requestedTarget: url.searchParams.get("asTenant"),
274
+ nowSeconds,
275
+ }).envelope.targetTenant
276
+ : tenant.appDomain;
277
+ const hostCapability = sessionHostCapability(viewerRecord, capabilityHost, nowSeconds);
262
278
  locals.viewer = {
263
279
  email: viewerRecord.email,
264
280
  roles: hostCapability ? [hostCapability] : viewerRecord.roles,
@@ -54,25 +54,14 @@ const viewerEmail = (Astro.locals.viewer?.email ?? viewerSession?.email ?? "").t
54
54
  const viewerDomain = viewerEmail.split("@").at(-1) ?? "";
55
55
  const canViewInternalGuide = viewerDomain === "tokenoftrust.com";
56
56
 
57
- // dg6 — admin session ENTRY resolved SERVER-SIDE against the resolved tenant's
58
- // appDomain (never client input, never a bearer secret): an authenticated
59
- // owner/team member for THIS tenant reaches the admin context; a non-member /
60
- // anonymous visitor was already held at the sign-in gate (middleware). Consumes
61
- // u10's owner + ship-capability model; the go-live tabs (AdminPublishTab) read
62
- // the same host-bound `Astro.locals.viewer.capability`, so the shell just stamps
63
- // the authoritative principal for chrome + tests — it does not re-gate.
64
- const adminEntry = resolveAdminEntry({
65
- record: viewerSession,
66
- hostResource: tenant.appDomain,
67
- nowSeconds: Math.floor(Date.now() / 1000),
68
- });
69
-
70
57
  // Internal admin-app tenant mode (baseline doc §Admin App As Tenant,
71
58
  // tenant-envelope-contract): every mounted tab receives an explicit
72
59
  // { adminAppTenant, targetTenant } envelope. Only a real ToT-staff session
73
60
  // may move targetTenant away from the routed tenant, and only to a tenant
74
61
  // they already have real access to — see lib/adminTenantEnvelope.ts for the
75
- // authorization rule (deny-by-default, refused not hidden-only).
62
+ // authorization rule (deny-by-default, refused not hidden-only). Resolved
63
+ // BEFORE adminEntry below: a staff viewer's capability must be checked
64
+ // against the tenant they selected, not the app's routed host.
76
65
  const adminTenantMode = resolveAdminTenantMode({
77
66
  session: viewerSession,
78
67
  adminAppTenant: tenant.appDomain,
@@ -80,6 +69,19 @@ const adminTenantMode = resolveAdminTenantMode({
80
69
  nowSeconds: Math.floor(Date.now() / 1000),
81
70
  });
82
71
  const { envelope: tenantEnvelope, isStaff, pickerTenants, deniedTarget } = adminTenantMode;
72
+
73
+ // dg6 — admin session ENTRY resolved SERVER-SIDE against the SELECTED tenant's
74
+ // appDomain (never client input, never a bearer secret): an authenticated
75
+ // owner/team member for THIS tenant reaches the admin context; a non-member /
76
+ // anonymous visitor was already held at the sign-in gate (middleware). Consumes
77
+ // u10's owner + ship-capability model; the go-live tabs (AdminPublishTab) read
78
+ // the same tenant-bound `Astro.locals.viewer.capability`, so the shell just stamps
79
+ // the authoritative principal for chrome + tests — it does not re-gate.
80
+ const adminEntry = resolveAdminEntry({
81
+ record: viewerSession,
82
+ hostResource: tenantEnvelope.targetTenant,
83
+ nowSeconds: Math.floor(Date.now() / 1000),
84
+ });
83
85
  ---
84
86
 
85
87
  <!doctype html>
@@ -20,6 +20,7 @@ export const prerender = false;
20
20
  // utilities + reference theme-token fallbacks so the (token-styled) components
21
21
  // render here without the app Layout. The `.sg-commerce` wrapper pins the
22
22
  // reference palette so they look coherent regardless of the marketing theme CSS.
23
+ import { tenantDirSegment } from "@tot/public-runtime";
23
24
  import "@/styles/global.css";
24
25
  import PriceDisplay from "@/components/commerce/PriceDisplay.astro";
25
26
  import RatingStars from "@/components/commerce/RatingStars.astro";
@@ -459,10 +460,13 @@ const sampleMarketingBlocks: MarketingBlock[] = [
459
460
  // Colocated repo-root layout: from src/pages/style-guide/[tenant]/ the repo
460
461
  // root is six levels up.
461
462
  const themeModules = import.meta.glob<Record<string, any>>(
462
- "../../../../../../tenants/*/content/themes/*.json",
463
+ [
464
+ "../../../../../../tenants/*/content/themes/*.json",
465
+ "../../../../../../tenants/e2e/*/content/themes/*.json",
466
+ ],
463
467
  { eager: true, import: "default" },
464
468
  );
465
- const seg = `/tenants/${tenant}/content/themes/`;
469
+ const seg = `${tenantDirSegment(tenant ?? "")}content/themes/`;
466
470
  const t = Object.entries(themeModules)
467
471
  .filter(([path]) => path.includes(seg))
468
472
  .map(([, mod]) => mod)
@@ -475,10 +479,13 @@ if (!t) return new Response(null, { status: 404 });
475
479
  // platform /shared/commerce-chrome.css instead so the marketing-component
476
480
  // samples below still render in-brand.
477
481
  const chromeModules = import.meta.glob<Record<string, any>>(
478
- "../../../../../../tenants/*/content/chrome.json",
482
+ [
483
+ "../../../../../../tenants/*/content/chrome.json",
484
+ "../../../../../../tenants/e2e/*/content/chrome.json",
485
+ ],
479
486
  { eager: true, import: "default" },
480
487
  );
481
- const chromeSeg = `/tenants/${tenant}/content/chrome.json`;
488
+ const chromeSeg = `${tenantDirSegment(tenant ?? "")}content/chrome.json`;
482
489
  const chromeConfig = Object.entries(chromeModules).find(([path]) => path.includes(chromeSeg))?.[1];
483
490
  // Adoption is now "does chrome.json carry the consolidated header/footer
484
491
  // shape" (lib/chrome/model.ts ChromeConfig) — the old `sharedChrome: true`
@@ -30,11 +30,16 @@ const cspNonce = Astro.locals.cspNonce;
30
30
 
31
31
  // Tenant-local theme descriptors. From src/pages/style-guide/[tenant]/chrome/ the
32
32
  // repo root is seven levels up.
33
+ import { tenantDirSegment } from "@tot/public-runtime";
34
+
33
35
  const themeModules = import.meta.glob<Record<string, any>>(
34
- "../../../../../../../tenants/*/content/themes/*.json",
36
+ [
37
+ "../../../../../../../tenants/*/content/themes/*.json",
38
+ "../../../../../../../tenants/e2e/*/content/themes/*.json",
39
+ ],
35
40
  { eager: true, import: "default" },
36
41
  );
37
- const seg = `/tenants/${tenant}/content/themes/`;
42
+ const seg = `${tenantDirSegment(tenant ?? "")}content/themes/`;
38
43
  const t = Object.entries(themeModules)
39
44
  .filter(([path]) => path.includes(seg))
40
45
  .map(([, mod]) => mod)
@@ -17,6 +17,7 @@
17
17
  */
18
18
  export const prerender = false;
19
19
 
20
+ import { tenantDirSegment } from "@tot/public-runtime";
20
21
  import "@/styles/global.css";
21
22
  import type { MarkdownInstance } from "astro";
22
23
  import StyleGuideNav from "@/components/style-guide/StyleGuideNav.astro";
@@ -27,10 +28,13 @@ const cspNonce = Astro.locals.cspNonce;
27
28
  // Tenant-local theme descriptors. From src/pages/style-guide/[tenant]/guide/ the
28
29
  // repo root is seven levels up (same depth as the chrome guide).
29
30
  const themeModules = import.meta.glob<Record<string, any>>(
30
- "../../../../../../../tenants/*/content/themes/*.json",
31
+ [
32
+ "../../../../../../../tenants/*/content/themes/*.json",
33
+ "../../../../../../../tenants/e2e/*/content/themes/*.json",
34
+ ],
31
35
  { eager: true, import: "default" },
32
36
  );
33
- const seg = `/tenants/${tenant}/content/themes/`;
37
+ const seg = `${tenantDirSegment(tenant ?? "")}content/themes/`;
34
38
  const t = Object.entries(themeModules)
35
39
  .filter(([path]) => path.includes(seg))
36
40
  .map(([, mod]) => mod)
@@ -16,6 +16,7 @@
16
16
  */
17
17
  export const prerender = false;
18
18
 
19
+ import { tenantDirSegment } from "@tot/public-runtime";
19
20
  import StyleGuideNav from "@/components/style-guide/StyleGuideNav.astro";
20
21
 
21
22
  const { tenant } = Astro.params;
@@ -24,10 +25,13 @@ const cspNonce = Astro.locals.cspNonce;
24
25
  // Tenant-local theme descriptors. Colocated repo-root layout: from
25
26
  // src/pages/style-guide/[tenant]/ the repo root is six levels up.
26
27
  const themeModules = import.meta.glob<Record<string, any>>(
27
- "../../../../../../tenants/*/content/themes/*.json",
28
+ [
29
+ "../../../../../../tenants/*/content/themes/*.json",
30
+ "../../../../../../tenants/e2e/*/content/themes/*.json",
31
+ ],
28
32
  { eager: true, import: "default" },
29
33
  );
30
- const seg = `/tenants/${tenant}/content/themes/`;
34
+ const seg = `${tenantDirSegment(tenant ?? "")}content/themes/`;
31
35
  const themes = Object.entries(themeModules)
32
36
  .filter(([path]) => path.includes(seg))
33
37
  .map(([, mod]) => mod);
@@ -2,8 +2,11 @@
2
2
  /**
3
3
  * Preview/dev-only STYLE-GUIDE ROOT INDEX — `/_style-guide/`.
4
4
  *
5
- * Lists EVERY tenant and each of its theme guides, so all clients' guides are
6
- * discoverable from one place. Review surface only: noindex, never in the
5
+ * Lists every REAL tenant and each of its theme guides, so all clients' guides
6
+ * are discoverable from one place. Platform test tenants (the `.e2e.test` class,
7
+ * colocated under tenants/e2e/) are NOT listed — a generalized listing shows
8
+ * clients, not fixtures. `?includeTestTenants=1` opts them back in for someone
9
+ * who came here to look at one. Review surface only: noindex, never in the
7
10
  * sitemap, never part of production marketing publishing. Tenant-agnostic —
8
11
  * theme data is tenant-local (tenants/<tenant>/content/themes/*.json).
9
12
  *
@@ -17,25 +20,33 @@
17
20
  */
18
21
  export const prerender = false;
19
22
 
23
+ import { listedTenantIds } from "@tot/public-runtime";
24
+
20
25
  const cspNonce = Astro.locals.cspNonce;
26
+ const includeTestTenants = Astro.url.searchParams.get("includeTestTenants") === "1";
21
27
 
22
28
  // All tenant-local theme descriptors, grouped by tenant. Colocated repo-root
23
29
  // layout: from src/pages/style-guide/ the repo root is five levels up.
24
30
  const themeModules = import.meta.glob<Record<string, any>>(
25
- "../../../../../tenants/*/content/themes/*.json",
31
+ [
32
+ "../../../../../tenants/*/content/themes/*.json",
33
+ "../../../../../tenants/e2e/*/content/themes/*.json",
34
+ ],
26
35
  { eager: true, import: "default" },
27
36
  );
28
37
 
29
38
  const ROLE_ORDER: Record<string, number> = { current: 0, candidate: 1, legacy: 2 };
30
39
  const byTenant = new Map<string, any[]>();
31
40
  for (const [path, mod] of Object.entries(themeModules)) {
32
- const m = /\/tenants\/([^/]+)\/content\/themes\//.exec(path);
41
+ const m = /\/tenants\/(?:e2e\/)?([^/]+)\/content\/themes\//.exec(path);
33
42
  const tenant = m?.[1];
34
43
  if (!tenant) continue;
35
44
  if (!byTenant.has(tenant)) byTenant.set(tenant, []);
36
45
  byTenant.get(tenant)!.push(mod);
37
46
  }
47
+ const listedIds = new Set(listedTenantIds([...byTenant.keys()], { includeTestTenants }));
38
48
  const tenants = [...byTenant.entries()]
49
+ .filter(([tenant]) => listedIds.has(tenant))
39
50
  .map(([tenant, themes]) => ({
40
51
  tenant,
41
52
  themes: themes.sort((a, b) => (ROLE_ORDER[a.role] ?? 9) - (ROLE_ORDER[b.role] ?? 9)),
@@ -77,7 +88,10 @@ const tenants = [...byTenant.entries()]
77
88
  <div class="wrap">
78
89
  <span class="flag">Preview · dev only · noindex</span>
79
90
  <h1>Client style guides</h1>
80
- <p class="sub">Every tenant and its theme guides. Open a guide to preview the standard components and page sections re-skinned under that theme. Nothing here re-skins a live site.</p>
91
+ <p class="sub">Every client tenant and its theme guides. Open a guide to preview the standard components and page sections re-skinned under that theme. Nothing here re-skins a live site.</p>
92
+ <p class="sub">{includeTestTenants
93
+ ? <span>Showing platform test tenants too — <a href="/_style-guide/">hide them</a>.</span>
94
+ : <span>Platform test tenants are hidden — <a href="/_style-guide/?includeTestTenants=1">show them</a>.</span>}</p>
81
95
 
82
96
  {tenants.map(({ tenant, themes }) => (
83
97
  <section class="tenant">
@@ -13,10 +13,19 @@
13
13
  * Depth: from src/themes/tenants/ the repo root is five levels up
14
14
  * (tenants→themes→src→storefront→apps→root).
15
15
  */
16
- import type { DeepPartial, ThemeTokens } from "@tot/public-runtime";
16
+ import {
17
+ listedTenantIds,
18
+ tenantIdFromPath,
19
+ type DeepPartial,
20
+ type TenantListingOptions,
21
+ type ThemeTokens,
22
+ } from "@tot/public-runtime";
17
23
 
24
+ // Both tiers: real tenants at `tenants/<id>/`, platform test tenants one level
25
+ // deeper at `tenants/e2e/<id>/`. Vite needs literal patterns, so the nesting is
26
+ // spelled out; `tenantIdFromPath` recovers the id from either shape.
18
27
  const modules = import.meta.glob<Record<string, unknown>>(
19
- "../../../../../tenants/*/theme.json",
28
+ ["../../../../../tenants/*/theme.json", "../../../../../tenants/e2e/*/theme.json"],
20
29
  {
21
30
  eager: true,
22
31
  import: "default",
@@ -34,8 +43,7 @@ function stripMeta(obj: Record<string, unknown>): DeepPartial<ThemeTokens> {
34
43
 
35
44
  const THEMES: Record<string, DeepPartial<ThemeTokens>> = {};
36
45
  for (const [path, mod] of Object.entries(modules)) {
37
- // Path looks like ".../tenants/<id>/theme.json" the id is the dir segment.
38
- const id = /\/tenants\/([^/]+)\/theme\.json$/.exec(path)?.[1];
46
+ const id = tenantIdFromPath(path);
39
47
  if (!id) continue;
40
48
  THEMES[id] = stripMeta(mod as Record<string, unknown>);
41
49
  }
@@ -45,7 +53,11 @@ export function getTenantTheme(tenantId: string): DeepPartial<ThemeTokens> {
45
53
  return THEMES[tenantId] ?? {};
46
54
  }
47
55
 
48
- /** Tenant ids that ship a theme file (for diagnostics / listing). */
49
- export function tenantsWithThemes(): string[] {
50
- return Object.keys(THEMES);
56
+ /**
57
+ * Tenant ids that ship a theme file (for diagnostics / listing). Platform test
58
+ * tenants are excluded by default — pass `{ includeTestTenants: true }` to see
59
+ * the whole set.
60
+ */
61
+ export function tenantsWithThemes(opts: TenantListingOptions = {}): string[] {
62
+ return listedTenantIds(Object.keys(THEMES), opts);
51
63
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "2.0.0",
3
+ "version": "2.0.2",
4
4
  "license": "SEE LICENSE IN LICENSE",
5
5
  "description": "World-shareable storefront runner: multi-tenant renderer on Astro/Cloudflare. No control plane.",
6
6
  "packageManager": "pnpm@11.9.0",
@@ -546,6 +546,97 @@ export function isInternalTestTenant(
546
546
  return t.classification === "internal-test" || t.appDomain.endsWith(".e2e.test");
547
547
  }
548
548
 
549
+ // ---------------------------------------------------------------------------
550
+ // Where a tenant's colocated directory lives.
551
+ //
552
+ // A tenant IS a directory. Real tenants sit directly under `tenants/<id>/`.
553
+ // PLATFORM TEST tenants — every id ending `.e2e.test`, the single greppable
554
+ // class — sit one level deeper, under `tenants/e2e/<id>/`, so `ls tenants/`
555
+ // shows only real tenants and a generalized listing can drop the whole test
556
+ // class with one predicate instead of a hand-maintained denylist.
557
+ //
558
+ // The id → directory mapping is TOTAL and mechanical: nothing else decides it,
559
+ // so a glob, an fs walk, and a KV artifact path can never disagree about where
560
+ // a tenant lives.
561
+ // ---------------------------------------------------------------------------
562
+
563
+ /** The directory segment platform test tenants are nested under. */
564
+ export const E2E_TENANT_DIR = "e2e";
565
+
566
+ /**
567
+ * True for a PLATFORM TEST tenant id — the `<scenario>.e2e.test` class. This is
568
+ * the id-only form of {@link isInternalTestTenant} (which also honours an
569
+ * explicit `classification`), for the many call sites that hold an id string
570
+ * and no config: globs, fs walks, CLI arguments, listing filters.
571
+ */
572
+ export function isE2ETenantId(tenantId: string): boolean {
573
+ return /\.e2e\.test$/.test(tenantId);
574
+ }
575
+
576
+ /**
577
+ * The repo-relative directory for a tenant id: `tenants/e2e/<id>` for a test
578
+ * tenant, `tenants/<id>` for a real one. Always POSIX-separated — callers that
579
+ * need an OS path join its segments themselves.
580
+ */
581
+ export function tenantDirRelative(tenantId: string): string {
582
+ return isE2ETenantId(tenantId)
583
+ ? `tenants/${E2E_TENANT_DIR}/${tenantId}`
584
+ : `tenants/${tenantId}`;
585
+ }
586
+
587
+ /**
588
+ * The `/tenants/…/<id>/` substring that identifies one tenant inside an
589
+ * absolute path or a Vite glob key — leading AND trailing slash, so a bare
590
+ * suffix match can never mistake `a.e2e.test` for `extra-a.e2e.test`.
591
+ */
592
+ export function tenantDirSegment(tenantId: string): string {
593
+ return `/${tenantDirRelative(tenantId)}/`;
594
+ }
595
+
596
+ /**
597
+ * The tenant id a `…/tenants/[e2e/]<id>/…` path encodes, or null when the path
598
+ * names no tenant. The inverse of {@link tenantDirRelative}: the optional `e2e/`
599
+ * segment is consumed, so a nested test tenant yields its OWN id and never the
600
+ * literal `e2e`.
601
+ */
602
+ export function tenantIdFromPath(path: string): string | null {
603
+ return /\/tenants\/(?:e2e\/)?([^/]+)\//.exec(path)?.[1] ?? null;
604
+ }
605
+
606
+ /** Opt back IN to platform test tenants on a generalized listing. */
607
+ export interface TenantListingOptions {
608
+ /**
609
+ * Include platform test tenants. Default `false`: a GENERALIZED listing —
610
+ * one that enumerates "all tenants" for a human to look at or a bulk job to
611
+ * walk — shows only real tenants. A caller that genuinely wants the test
612
+ * class asks for it here, explicitly, at the call site.
613
+ */
614
+ includeTestTenants?: boolean;
615
+ }
616
+
617
+ /**
618
+ * Narrow a set of tenant ids to what a generalized listing should show.
619
+ * Platform test tenants are dropped unless explicitly requested.
620
+ */
621
+ export function listedTenantIds(
622
+ ids: readonly string[],
623
+ opts: TenantListingOptions = {},
624
+ ): string[] {
625
+ return opts.includeTestTenants ? [...ids] : ids.filter((id) => !isE2ETenantId(id));
626
+ }
627
+
628
+ /**
629
+ * The listing counterpart for full configs — drops anything
630
+ * {@link isInternalTestTenant} recognizes (the `.e2e.test` class AND an
631
+ * explicitly marked clone), unless explicitly requested.
632
+ */
633
+ export function listedTenants<T extends Pick<TenantConfig, "classification" | "appDomain">>(
634
+ tenants: readonly T[],
635
+ opts: TenantListingOptions = {},
636
+ ): T[] {
637
+ return opts.includeTestTenants ? [...tenants] : tenants.filter((t) => !isInternalTestTenant(t));
638
+ }
639
+
549
640
  // ---------------------------------------------------------------------------
550
641
  // Non-premium (self-serve) tenant identity.
551
642
  //
@@ -31,10 +31,11 @@
31
31
  * reference files that don't exist. The Worker-plane build never sets this, so
32
32
  * R2 shadowing semantics are unchanged there.
33
33
  */
34
- import { cp, mkdir, readdir, rm } from "node:fs/promises";
35
- import { existsSync } from "node:fs";
34
+ import { cp, mkdir, rm } from "node:fs/promises";
35
+ import { existsSync, readdirSync, statSync } from "node:fs";
36
36
  import { join, resolve, dirname } from "node:path";
37
37
  import { fileURLToPath } from "node:url";
38
+ import { listTenantDirIds, tenantDirSegments } from "../lib/tenant-dirs.mjs";
38
39
 
39
40
  const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
40
41
  const src = resolve(repoRoot, "tenants");
@@ -53,15 +54,21 @@ const versionedEnv = new Set(
53
54
  .filter(Boolean),
54
55
  );
55
56
  const bakeVersioned = process.env.TENANT_ASSETS_BAKE_VERSIONED === "1";
57
+ const tenantDir = (id) => join(repoRoot, ...tenantDirSegments(id));
56
58
  const isVersioned = (id) =>
57
59
  !bakeVersioned &&
58
60
  (versionedEnv.has(id) ||
59
- existsSync(join(src, id, ".tot", "assets-versioned")));
61
+ existsSync(join(tenantDir(id), ".tot", "assets-versioned")));
62
+
63
+ // Both tiers: real tenants directly under tenants/, platform test tenants one
64
+ // level deeper under tenants/e2e/. The destination stays FLAT — the served URL
65
+ // is `/tenants/<id>/…` for every tenant, whichever tier its source sits in.
66
+ const ids = listTenantDirIds(src, readdirSync, (p) => statSync(p).isDirectory(), join);
60
67
 
61
68
  let copied = 0;
62
69
  let skipped = 0;
63
- for (const id of await readdir(src)) {
64
- const pub = join(src, id, "public");
70
+ for (const id of ids) {
71
+ const pub = join(tenantDir(id), "public");
65
72
  if (!existsSync(pub)) continue;
66
73
  // Versioned tenants are served from R2 — baking would shadow the R2 route.
67
74
  if (isVersioned(id)) {
@@ -24,12 +24,13 @@ import { join, dirname } from "node:path";
24
24
  import { createMcpClient } from "../../packages/cli/src/mcp.mjs";
25
25
  import { resolveDeveloperSession, AuthUnavailableError } from "../../packages/cli/src/auth.mjs";
26
26
  import { applyUnifiedDiff } from "./unified-diff.mjs";
27
+ import { tenantDirSegments } from "../lib/tenant-dirs.mjs";
27
28
 
28
29
  const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
29
30
 
30
31
  /** Where a tenant's editable files + AI-edit history live (monorepo layout). */
31
32
  export function tenantPaths(repoRoot, tenantId) {
32
- const dir = join(repoRoot, "tenants", tenantId);
33
+ const dir = join(repoRoot, ...tenantDirSegments(tenantId));
33
34
  return {
34
35
  dir,
35
36
  home: join(dir, "content", "home.html"),
@@ -19,6 +19,7 @@
19
19
  */
20
20
  import { existsSync } from "node:fs";
21
21
  import { resolve } from "node:path";
22
+ import { tenantDirSegments } from "../lib/tenant-dirs.mjs";
22
23
 
23
24
  /**
24
25
  * @returns {{ok:true, tenantId:string, sourceRoot:string, gitCwd:string}
@@ -72,5 +73,5 @@ export function resolveTenantSourceRoot(env = process.env) {
72
73
  export function resolveDevSourceRoot(workspacePath, repoRoot, tenant) {
73
74
  return workspacePath
74
75
  ? resolve(workspacePath)
75
- : resolve(repoRoot, "tenants", tenant);
76
+ : resolve(repoRoot, ...tenantDirSegments(tenant));
76
77
  }
@@ -62,6 +62,7 @@ import {
62
62
  import { dirname, resolve, join } from "node:path";
63
63
  import { fileURLToPath } from "node:url";
64
64
  import { resolveDevSourceRoot } from "./dev/tenant-source-root.mjs";
65
+ import { tenantDirRelative, tenantDirSegments } from "./lib/tenant-dirs.mjs";
65
66
  import { firstFreePort } from "./dev/port-check.mjs";
66
67
  import { resync, startWatch } from "./dev/checkout-watch.mjs";
67
68
 
@@ -244,7 +245,7 @@ if (workspacePath) {
244
245
  // structurally (ignoring any legacy `repo` field in the checkout's .tot/config.json).
245
246
  const wsRel = String(map.workspace ?? "").replace(/\/+$/, "");
246
247
  if (!wsRel) continue;
247
- const repoRel = join("tenants", tenant, wsRel);
248
+ const repoRel = join(tenantDirRelative(tenant), wsRel);
248
249
 
249
250
  const source = resolve(wsRoot, wsRel); // real files in the checkout
250
251
  const target = resolve(repoRoot, repoRel); // where the app globs them
@@ -288,7 +289,7 @@ if (workspacePath) {
288
289
  cleanupGrafts();
289
290
  process.exit(1);
290
291
  }
291
- } else if (!existsSync(resolve(repoRoot, "tenants", tenant, "content"))) {
292
+ } else if (!existsSync(resolve(repoRoot, ...tenantDirSegments(tenant), "content"))) {
292
293
  console.warn(`⚠ no tenants/${tenant}/content/ found — the tenant may not be registered yet.`);
293
294
  }
294
295