@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.
- package/apps/storefront/dev-plugins/tenant-hot-reload.mjs +19 -1
- package/apps/storefront/dev-plugins/tenant-validate-on-save.mjs +78 -0
- package/apps/storefront/src/components/admin/AdminPublishTab.astro +9 -4
- package/apps/storefront/src/middleware/index.ts +17 -1
- package/apps/storefront/src/pages/admin.astro +16 -14
- package/package.json +1 -1
|
@@ -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
|
|
50
|
+
// Session capability counts only when the session's resource IS the selected tenant.
|
|
48
51
|
const sessionCapability =
|
|
49
|
-
viewerSession
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|