@tokenoftrust/storefront-runner 1.3.4-rc.1 → 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.
@@ -16,10 +16,19 @@ import cloudflare from "@astrojs/cloudflare";
16
16
  // npm before/as this storefront ships, else the pasted `npm i -g @tokenoftrust/cli@<v>` 404s.
17
17
  // The CLI publish workflow (.github/workflows/publish-cli.yml) is the publisher; there is no
18
18
  // deploy-time guard yet that asserts the embedded version exists on npm — a worthwhile add.
19
- const cliVersion = JSON.parse(
20
- readFileSync(fileURLToPath(new URL("../../packages/cli/package.json", import.meta.url)), "utf8"),
21
- ).version;
22
- const cliInstallSpec = `@tokenoftrust/cli@${cliVersion}`;
19
+ // NEVER THROWS (like gitSha below): the published RUNNER tree prunes packages/cli, and a
20
+ // bare read here crashed `astro dev` for every standalone invited-dev boot. The runner's
21
+ // local page falls back to the floating spec — only the HOSTED cockpit needs the pin.
22
+ const cliVersion = (() => {
23
+ try {
24
+ return JSON.parse(
25
+ readFileSync(fileURLToPath(new URL("../../packages/cli/package.json", import.meta.url)), "utf8"),
26
+ ).version;
27
+ } catch {
28
+ return null; // standalone runner tree — packages/cli isn't shipped
29
+ }
30
+ })();
31
+ const cliInstallSpec = cliVersion ? `@tokenoftrust/cli@${cliVersion}` : "@tokenoftrust/cli";
23
32
 
24
33
  // The commit THIS build was cut from, baked in at BUILD time so /health can report
25
34
  // exactly which code is live (see src/lib/health.ts + src/middleware). Prefer a CI-
@@ -21,7 +21,7 @@
21
21
  "@astrojs/preact": "^6.0.0",
22
22
  "@storyblok/astro": "^10.0.0",
23
23
  "@tailwindcss/vite": "^4.3.1",
24
- "@tot/public-runtime": "workspace:*",
24
+ "@tot/public-runtime": "*",
25
25
  "astro": "^7.0.3",
26
26
  "fuse.js": "^7.4.2",
27
27
  "jose": "^6.2.3",
@@ -1,16 +1,23 @@
1
1
  /**
2
- * Per-vendor "local dev runtime" latest-state — the LIVE half of the
2
+ * Per-DEVELOPER "local dev runtime" latest-state — the LIVE half of the
3
3
  * local→hosted activity bridge (G1). Where activityStore.ts holds a ring buffer
4
- * of file-SAVE events, this holds ONE record per vendor: the most recent
5
- * heartbeat the developer's LOCAL `tot dev`/`tot start` sent (see the CLI's
6
- * dev-heartbeat.mjs). The hosted cockpit reads it to show a LIVE, clickable
7
- * local-dev link + the running CLI version.
4
+ * of file-SAVE events, this holds the most recent heartbeat a developer's LOCAL
5
+ * `tot dev`/`tot start` sent (see the CLI's dev-heartbeat.mjs). The hosted cockpit
6
+ * reads it to show a LIVE, clickable local-dev link + the running CLI version.
7
+ *
8
+ * SCOPED PER (tenant, developer email), NOT per tenant: a store can have many
9
+ * team members, each running their OWN local loop. Keying on the tenant alone
10
+ * made every teammate's heartbeat collapse into one latest-wins slot, so the
11
+ * owner saw "connected" whenever ANYONE with access was running `tot dev` —
12
+ * never their own machine specifically. The key is now
13
+ * `dev-runtime:<tenant>:<email>`; the POST writes under the HEARTBEATING dev's
14
+ * verified email, and the GET reads the VIEWER's own — so "connected" means
15
+ * "YOUR loop", not "someone on this store".
8
16
  *
9
17
  * Deliberately SEPARATE from the file-activity buffer: a heartbeat is
10
- * latest-wins state, not an appended event, so it lives under its own
11
- * `dev-runtime:<tenant>` key and never touches the ring buffer. No file CONTENT
12
- * is ever stored — `url` is only the local `http://localhost:<port>/<tenant>/`
13
- * (low-sensitivity, per decision).
18
+ * latest-wins state, not an appended event, so it lives under its own key and
19
+ * never touches the ring buffer. No file CONTENT is ever stored — `url` is only
20
+ * the local `http://localhost:<port>/<tenant>/` (low-sensitivity, per decision).
14
21
  *
15
22
  * Liveness is computed SERVER-side from `aliveAt` (the ingest-time server clock,
16
23
  * NOT the client's `at`) so a skewed local clock can't make a dead loop look
@@ -73,11 +80,12 @@ export interface DevRuntimeView extends Omit<DevRuntime, "email"> {
73
80
 
74
81
  const KEY_PREFIX = "dev-runtime:";
75
82
  /**
76
- * Secondary index: traceId -> the runtime record's tenant key, so support can
77
- * look a developer's LIVE session up by the trace id minted at sign-in without
78
- * scanning. Mirrors the developer+tenant index in activityIngestToken.ts; a KV
79
- * store has no GSI, so the smallest correct discovery path is a maintained index
80
- * key (written on every heartbeat, cleared on reset). Same TTL as the record.
83
+ * Secondary index: traceId -> {appDomain, email} (JSON), so support can look a
84
+ * developer's LIVE session up by the trace id minted at sign-in without scanning.
85
+ * Stores BOTH components because the record key is now per (tenant, email).
86
+ * Mirrors the developer+tenant index in activityIngestToken.ts; a KV store has no
87
+ * GSI, so the smallest correct discovery path is a maintained index key (written
88
+ * on every heartbeat, cleared on reset). Same TTL as the record.
81
89
  */
82
90
  const TRACE_INDEX_PREFIX = "dev-runtime-by-trace:";
83
91
  /**
@@ -107,8 +115,14 @@ const MAX_EMAIL_LEN = 320;
107
115
  /** A UUID; a touch of headroom for other id shapes. */
108
116
  const MAX_TRACE_LEN = 64;
109
117
 
110
- function key(appDomain: string): string {
111
- return KEY_PREFIX + appDomain;
118
+ /**
119
+ * The per-developer record key: `dev-runtime:<tenant>:<email>`. Email is the
120
+ * session principal (lowercased here for a stable key). An absent email degrades
121
+ * to a tenant-only suffix — never silently shared, just an unattributed slot the
122
+ * viewer's own-email read won't match.
123
+ */
124
+ function key(appDomain: string, email: string | undefined): string {
125
+ return `${KEY_PREFIX}${appDomain}:${(email ?? "").toLowerCase()}`;
112
126
  }
113
127
 
114
128
  function traceKey(traceId: string): string {
@@ -188,10 +202,15 @@ export function shapeRuntime(
188
202
  return rt;
189
203
  }
190
204
 
191
- /** Read the vendor's latest runtime, or null when none/malformed. Never throws. */
192
- export async function readRuntime(kv: KVNamespace, appDomain: string): Promise<DevRuntime | null> {
205
+ /** Read a developer's latest runtime (their tenant + email), or null when
206
+ * none/malformed. Never throws. */
207
+ export async function readRuntime(
208
+ kv: KVNamespace,
209
+ appDomain: string,
210
+ email: string | undefined,
211
+ ): Promise<DevRuntime | null> {
193
212
  try {
194
- const raw = await kv.get(key(appDomain));
213
+ const raw = await kv.get(key(appDomain, email));
195
214
  if (!raw) return null;
196
215
  const parsed = JSON.parse(raw);
197
216
  if (!parsed || typeof parsed !== "object" || typeof parsed.aliveAt !== "number") return null;
@@ -201,17 +220,21 @@ export async function readRuntime(kv: KVNamespace, appDomain: string): Promise<D
201
220
  }
202
221
  }
203
222
 
204
- /** Overwrite the vendor's runtime with the latest heartbeat (latest-wins, TTL'd).
205
- * When the record carries a traceId, also (re)write the by-trace index so support
206
- * can resolve trace -> tenant -> live runtime without a scan. */
223
+ /** Overwrite a developer's runtime with their latest heartbeat (latest-wins, TTL'd),
224
+ * scoped to (tenant, email). When the record carries a traceId, also (re)write the
225
+ * by-trace index (storing BOTH tenant + email) so support can resolve
226
+ * trace -> record without a scan. */
207
227
  export async function writeRuntime(
208
228
  kv: KVNamespace,
209
229
  appDomain: string,
230
+ email: string | undefined,
210
231
  rt: DevRuntime,
211
232
  ): Promise<void> {
212
- await kv.put(key(appDomain), JSON.stringify(rt), { expirationTtl: TTL_SECONDS });
233
+ await kv.put(key(appDomain, email), JSON.stringify(rt), { expirationTtl: TTL_SECONDS });
213
234
  if (rt.traceId) {
214
- await kv.put(traceKey(rt.traceId), appDomain, { expirationTtl: TTL_SECONDS });
235
+ await kv.put(traceKey(rt.traceId), JSON.stringify({ appDomain, email: email ?? "" }), {
236
+ expirationTtl: TTL_SECONDS,
237
+ });
215
238
  }
216
239
  }
217
240
 
@@ -227,17 +250,36 @@ export async function readRuntimeByTrace(
227
250
  traceId: string,
228
251
  ): Promise<DevRuntime | null> {
229
252
  if (!traceId) return null;
230
- const appDomain = await kv.get(traceKey(traceId));
253
+ const ptr = await kv.get(traceKey(traceId));
254
+ if (!ptr) return null;
255
+ // Pointer is JSON {appDomain, email}. A bare-string pointer is a legacy
256
+ // (pre per-developer) entry with no email to scope by — treat as unresolvable
257
+ // (it re-establishes on the next heartbeat, well within the 1h TTL).
258
+ let appDomain: string | undefined;
259
+ let email: string | undefined;
260
+ try {
261
+ const parsed = JSON.parse(ptr);
262
+ if (parsed && typeof parsed === "object") {
263
+ appDomain = parsed.appDomain;
264
+ email = parsed.email;
265
+ }
266
+ } catch {
267
+ return null;
268
+ }
231
269
  if (!appDomain) return null;
232
- return readRuntime(kv, appDomain);
270
+ return readRuntime(kv, appDomain, email);
233
271
  }
234
272
 
235
- /** Delete the vendor's runtime record — a "true reset" so a killed store stops
236
- * reading as up immediately instead of lingering until the TTL/live-window expires.
237
- * Also drops the by-trace index entry for its current trace, if any. */
238
- export async function clearRuntime(kv: KVNamespace, appDomain: string): Promise<void> {
239
- const existing = await readRuntime(kv, appDomain);
240
- await kv.delete(key(appDomain));
273
+ /** Delete a developer's runtime record (their tenant + email) — a "true reset" so a
274
+ * killed loop stops reading as up immediately instead of lingering until the
275
+ * TTL/live-window expires. Also drops the by-trace index entry for its current trace. */
276
+ export async function clearRuntime(
277
+ kv: KVNamespace,
278
+ appDomain: string,
279
+ email: string | undefined,
280
+ ): Promise<void> {
281
+ const existing = await readRuntime(kv, appDomain, email);
282
+ await kv.delete(key(appDomain, email));
241
283
  if (existing?.traceId) await kv.delete(traceKey(existing.traceId));
242
284
  }
243
285
 
@@ -1927,7 +1927,7 @@ const hostedActivityScript = `
1927
1927
  <h1>Get your store running.
1928
1928
  <span class="info" id="info1">
1929
1929
  <button class="info-btn" type="button" aria-label="What does this command do?">i</button>
1930
- <span class="info-pop" role="tooltip">Installs the <code>tot</code> command, signs your terminal in with a single-use code, checks out your store, runs it, and opens your browser. No Docker, no browser sign-in, nothing to provision. From then on it's just <code>tot start</code>.</span>
1930
+ <span class="info-pop" role="tooltip">Installs the <code>tot</code> command, signs your terminal in with a single-use code, checks out your store into a new <code>{appDomain}</code> folder, runs it, and opens your browser. No Docker, no browser sign-in, nothing to provision. From then on it's just <code>tot start</code>.</span>
1931
1931
  </span>
1932
1932
  </h1>
1933
1933
  <p class="goal">{hasStoreAccess
@@ -1951,10 +1951,10 @@ const hostedActivityScript = `
1951
1951
  </div>
1952
1952
  )}
1953
1953
  <p class="cmd-note" id="cli-cmd-note">{hasStoreAccess
1954
- ? <>This mints a <em>single-use, short-lived</em> sign-in code, shows the full command here, and copies it to your clipboard — paste it and press Enter. It signs you in, runs your store, and opens your browser. Nothing to retype.</>
1955
- : <>You'll need <em>Node.js and your invite</em>. Sign in through your invite link and this page hands you a one-paste setup command — no Docker, nothing to provision.</>}</p>
1954
+ ? <>This mints a <em>single-use, short-lived</em> sign-in code, shows the full command here, and copies it to your clipboard. Paste it <em>from the folder where you keep projects</em> — it creates a <code>{appDomain}</code> folder there with your store's code, signs you in, runs your store, and opens your browser. Nothing to retype.</>
1955
+ : <>You'll need <em>Node.js 22.12+ (24 LTS recommended) and your invite</em>. Sign in through your invite link and this page hands you a one-paste setup command — no Docker, nothing to provision.</>}</p>
1956
1956
 
1957
- <p class="cmd-steps">Prefer the steps? <code>npm i -g {cliInstallSpec}</code>, then <code>tot checkout {appDomain}</code>, <code>cd {appDomain}</code>, <code>tot dev</code>. New to Node? Get it at <a href="https://nodejs.org">nodejs.org</a> — it includes <code>npm</code>.</p>
1957
+ <p class="cmd-steps">Prefer the steps? <code>npm i -g {cliInstallSpec}</code>, then <code>tot checkout {appDomain}</code>, <code>cd {appDomain}</code>, <code>tot dev</code>. New to Node? Get Node 24 (LTS) at <a href="https://nodejs.org">nodejs.org</a> — it includes <code>npm</code>.</p>
1958
1958
 
1959
1959
  {isLocalDevLoop && (
1960
1960
  <div class="detect" id="detect" data-listening="true">
package/package.json CHANGED
@@ -1,12 +1,16 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "1.3.4-rc.1",
3
+ "version": "1.3.4-rc.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",
7
7
  "engines": {
8
- "node": ">=24"
8
+ "node": ">=22.12.0"
9
9
  },
10
+ "workspaces": [
11
+ "apps/*",
12
+ "packages/*"
13
+ ],
10
14
  "scripts": {
11
15
  "dev": "pnpm --filter @tot/storefront-runner dev",
12
16
  "build": "pnpm --filter @tot/storefront-runner build",
@@ -1,6 +1,7 @@
1
1
  packages:
2
2
  - "apps/*"
3
3
  - "packages/*"
4
+ linkWorkspacePackages: true
4
5
  onlyBuiltDependencies:
5
6
  - esbuild
6
7
  - sharp
@@ -1,36 +1,17 @@
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
7
12
  // outside its root), and each save is mirrored back so Vite's own watcher fires the
8
13
  // browser reload. This module owns detecting the save + doing the copy.
9
14
 
10
- /** Directories never worth descending into — VCS/dep/build noise that would churn the poll. */
11
- const IGNORED_WATCH_DIRS = new Set([".git", "node_modules", ".astro", ".cache", ".turbo"]);
12
-
13
- /**
14
- * True for editor/OS transient files that are NOT real content edits. A raw poll of
15
- * the checkout subtree would otherwise treat a vim swap (`.home.html.swp`), a Finder
16
- * `.DS_Store`, an emacs autosave (`#home.html#`) or lock (`.#home.html`), a `~` backup,
17
- * or vim's `4913` write-probe as a content change — re-syncing it into the in-tree copy
18
- * and firing a spurious reload / "changed" report (the ".swp shows up as a change" bug).
19
- * Matched on the BASENAME so it's independent of depth. "Official" changes track the
20
- * store's real content; untracked editor junk is surfaced only at publish time, not here.
21
- */
22
- export function isTransientWatchFile(name) {
23
- return (
24
- /\.(sw[a-z]{1,2}|tmp|temp|orig|bak)$/i.test(name) || // vim swap (.swp/.swo/.swx/.swpx…), temp/backup
25
- /~$/.test(name) || // gedit/emacs/kate trailing-tilde backup
26
- /^\.#/.test(name) || // emacs lockfile (.#file)
27
- /^#.*#$/.test(name) || // emacs autosave (#file#)
28
- name === ".DS_Store" ||
29
- name === "Thumbs.db" ||
30
- name === "4913" // vim's write-probe file
31
- );
32
- }
33
-
34
15
  /** Re-sync one changed checkout path into its in-tree copy (copy on save, remove on delete). */
35
16
  export function resync(sourceRoot, targetRoot, rel) {
36
17
  const from = rel ? join(sourceRoot, rel) : sourceRoot;
@@ -17,7 +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 "./checkout-watch.mjs";
20
+ import { isTransientWatchFile } from "./transient-files.mjs";
21
21
 
22
22
  /** Above this, the viewer degrades to "not shown" rather than shipping a huge string. */
23
23
  const MAX_FILE_BYTES = 512 * 1024;
@@ -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
+ }
@@ -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
- "pnpm",
361
- ["--filter", "@tot/storefront-runner", "exec", "astro", "dev", "--port", port, "--host"],
362
- { cwd: repoRoot, stdio: "inherit", env: spawnEnv },
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.