@tokenoftrust/storefront-runner 2.0.1 → 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,7 +4,9 @@ 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 } from "../../../scripts/lib/tenant-dirs.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";
8
10
  import { readFile } from "node:fs/promises";
9
11
 
10
12
  // Repo-root tenants/ dir (colocated tenant layout) + the generated static dest
@@ -15,6 +17,10 @@ const REPO_ROOT = fileURLToPath(new URL("../../..", import.meta.url));
15
17
  const TENANTS_DIR = join(REPO_ROOT, "tenants").replace(/\\/g, "/");
16
18
  const PUBLIC_TENANTS_DEST = fileURLToPath(new URL("../public/tenants", import.meta.url));
17
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
+
18
24
  /**
19
25
  * Load a TS module's exports for Node-side (plugin) use, reusing Vite's own
20
26
  * transform pipeline instead of a plain `import` (which can't parse `.ts`) or
@@ -265,6 +271,18 @@ export function tenantHotReload() {
265
271
  // matching reload is timestamped by dev-loop-monitor's ws/hot wrap.
266
272
  markSave(rest, "change");
267
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
+
268
286
  // Content HTML: the change invalidates the SSR module graph, so the
269
287
  // Cloudflare Vite plugin reloads the worker and — once it is ready — emits
270
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
+ }
@@ -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
  }
@@ -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>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "2.0.1",
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",