@volter/world-platform 2.0.40 → 3.0.1

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.
@@ -20,6 +20,8 @@ a.button.primary { background: var(--accent); border-color: var(--accent); color
20
20
  /* the sign-in card: the brand, one way in */
21
21
  body.portal #root { width: min(440px, 100%); }
22
22
  body.portal .card { width: 100%; }
23
+ /* the platform's own server pages (pages.ts pageShell: the CLI's approval, a refusal) are the same narrow card */
24
+ body.portal > main.card { width: min(480px, 100%); }
23
25
  body.portal .card .brand h1 { font-size: 15px; margin: 0; }
24
26
  body.portal .card .brand h1 a { color: var(--ink); text-decoration: none; }
25
27
  main.boot { max-width: 520px; display: grid; gap: 14px; padding-top: 18vh; }
@@ -79,7 +79,7 @@ type OrgWorld = { name: string; host: string; base: string; provisionedAt: strin
79
79
  /** An invitation to your address still pending, where the provider keeps them (GET /-/orgs). */
80
80
  type Invitation = { id: string; org: { id: string; slug: string | null; name: string }; role: string; expiresAt: string | null };
81
81
  type OrgRow = { id: string; slug: string | null; name: string; role: string; held?: string | null; flags?: Record<string, boolean>; worlds?: OrgWorld[]; security?: { membersCanInvite: boolean } };
82
- type WorldRow = { org: string; world: string; name: string; base: string; origin?: string; twins: Array<{ vendor: string }>; /** kept to named members */ restricted?: boolean };
82
+ type WorldRow = { org: string; world: string; name: string; base: string; origin?: string; twins: Array<{ vendor: string }>; /** kept to named members */ restricted?: boolean; /** its landed changesets, when the World answered */ landed?: number };
83
83
  const slugOf = (o: OrgRow): string => o.slug ?? o.id;
84
84
  const isAdmin = (o: OrgRow): boolean => o.role === 'admin' || o.role === 'org:admin';
85
85
  const NAME = /^[a-z0-9][a-z0-9-]{0,38}$/;
@@ -226,7 +226,8 @@ function Palette({ open, onClose, orgs, shown, worlds, pages, support }: { open:
226
226
  const items: Item[] = useMemo(() => {
227
227
  const all: Item[] = [];
228
228
  if (shown) for (const [k, label] of pages) all.push({ label, hint: shown.name, run: () => go({ kind: 'org', org: slugOf(shown), page: k }) });
229
- for (const w of worlds) all.push({ label: `Open ${w.name}`, hint: 'World', run: () => { window.location.assign(`/-/worlds/${w.name}/open`); } });
229
+ // a World opens at an origin of its own, where its host serves its dashboard; one without has nothing to open
230
+ for (const w of worlds) if (w.origin) all.push({ label: `Open ${w.name}`, hint: 'World', run: () => { window.location.assign(`/-/worlds/${w.name}/open`); } });
230
231
  for (const o of orgs) if (o.id !== shown?.id) all.push({ label: `Switch to ${o.name}`, hint: 'org', run: () => go({ kind: 'org', org: slugOf(o), page: 'worlds' }) });
231
232
  all.push({ label: 'Account and tokens', hint: 'you', run: () => go({ kind: 'you', page: 'account' }) });
232
233
  if (support) all.push({ label: 'Help', hint: 'you', run: () => go({ kind: 'you', page: 'help' }) });
@@ -303,7 +304,7 @@ function WorldsPage({ org, rows, load, orgs, allRows, version, readOnly, onChang
303
304
  return (
304
305
  <>
305
306
  <Head title="Worlds" count={load.state === 'ok' ? rows.length : undefined}>{rows.length > 0 ? <input type="search" className="toolbar-search" aria-label="Find a World" placeholder="Find a World…" value={q} onChange={(e) => setQ(e.target.value)} /> : null}{make}</Head>
306
- <GettingStarted orgs={orgs} worlds={allRows - rows.filter((r) => r.name.split('/')[1] === 'sample').length} firstWorld={rows.find((r) => r.name.split('/')[1] !== 'sample')?.name} version={version} />
307
+ <GettingStarted orgs={orgs} worlds={allRows - rows.filter((r) => r.name.split('/')[1] === 'sample').length} firstWorld={rows.find((r) => r.name.split('/')[1] !== 'sample')?.name} pushed={rows.some((r) => r.name.split('/')[1] !== 'sample' && (r.landed ?? 0) > 0)} version={version} refresh={onChanged} />
307
308
  {load.state !== 'ok' ? <Pending load={load} what="the org's Worlds" retry={onChanged} /> : rows.length === 0 ? (
308
309
  <Empty title="No Worlds yet" action={<div className="actions-row">{make}{!readOnly ? <button type="button" onClick={() => void trySample()} disabled={sampling}>{sampling ? 'Making the sample…' : 'Try a sample World'}</button> : null}</div>}>A World stands in for the services your app uses: your app talks to it as it would to Stripe or GitHub, nothing real happens, and you see everything it did on each vendor's own screens.{!readOnly ? <span className="mode sample-note">The sample is a small shop's Stripe and Slack, seeded as its app would leave them. It counts as one of the org's Worlds; delete it when you are done.</span> : null}</Empty>
309
310
  ) : (
@@ -312,11 +313,11 @@ function WorldsPage({ org, rows, load, orgs, allRows, version, readOnly, onChang
312
313
  <thead><tr><th>World</th><th>Stand-ins for</th><th>Host</th><th>Created</th><th className="num"><span className="sr">Actions</span></th></tr></thead>
313
314
  <tbody>{shown.map((w) => { const rec = record.get(w.name); const short = w.name.split('/')[1] ?? w.name; return (<React.Fragment key={w.name}>
314
315
  <tr>
315
- <td><span className="vendor-cell"><Initial of={short} small /><span className="who"><span className="name-line"><a href={`/-/worlds/${w.name}/open`}>{short}</a>{w.restricted ? <span className="badge restricted" title="Only some members of the org reach it">Restricted</span> : null}</span>{w.origin ? <span className="mode mono">{new URL(w.origin).host}</span> : null}</span></span></td>
316
+ <td><span className="vendor-cell"><Initial of={short} small /><span className="who"><span className="name-line">{w.origin ? <a href={`/-/worlds/${w.name}/open`}>{short}</a> : short}{w.restricted ? <span className="badge restricted" title="Only some members of the org reach it">Restricted</span> : null}</span>{w.origin ? <span className="mode mono">{new URL(w.origin).host}</span> : null}</span></span></td>
316
317
  <td><span className="marks" title={w.twins.map((t) => vendorName(t.vendor)).join(', ')}>{w.twins.slice(0, 5).map((t) => <Mark key={t.vendor} vendor={t.vendor} />)}{w.twins.length > 5 ? <span className="mode">+{w.twins.length - 5}</span> : null}<span className="mode">{w.twins.length === 1 ? vendorName(w.twins[0]!.vendor) : `${w.twins.length} vendors`}</span></span></td>
317
318
  <td className="mode">{rec?.host ?? '—'}</td>
318
319
  <td className="mode">{rec ? <span title={when(rec.provisionedAt)}>{ago(rec.provisionedAt)}</span> : '—'}</td>
319
- <td className="num row-actions"><a className="button primary" href={`/-/worlds/${w.name}/open`} aria-label={`Open ${w.name}`}>Open</a>
320
+ <td className="num row-actions">{w.origin ? <a className="button primary" href={`/-/worlds/${w.name}/open`} aria-label={`Open ${w.name}`}>Open</a> : <span className="mode" title="Its host serves no dashboard: the command and your app reach it at its address">No dashboard</span>}
320
321
  <Menu label={`More for ${w.name}`} items={[
321
322
  { label: 'Copy the command to use it', onSelect: () => { void navigator.clipboard?.writeText(`volter remote add origin ${w.name}`).then(() => toast('Copied: volter remote add origin …')); } },
322
323
  !readOnly ? { label: 'Make a key…', onSelect: () => setKeyFor(w.name) } : null,
@@ -1055,23 +1056,39 @@ function HelpPage({ orgs, onChanged }: { orgs: string[]; onChanged?: () => void
1055
1056
  }
1056
1057
 
1057
1058
  /** The first run: what a person does next, with the real commands, as a checklist with its progress; dismissed for good. */
1058
- function GettingStarted({ orgs, worlds, firstWorld, version }: { orgs: string[]; worlds: number; firstWorld?: string | undefined; version: number }): React.JSX.Element | null {
1059
+ function GettingStarted({ orgs, worlds, firstWorld, pushed, version, refresh }: { orgs: string[]; worlds: number; firstWorld?: string | undefined; pushed: boolean; version: number; refresh: () => void }): React.JSX.Element | null {
1059
1060
  const KEY = 'volter.console.onboarding';
1060
1061
  const [dismissed, setDismissed] = useState<boolean>(() => { try { return localStorage.getItem(KEY) === 'done'; } catch { return false; } });
1061
1062
  const [tokensMade, setTokensMade] = useState<boolean | null>(null);
1062
1063
  const mounted = useRef(true); useEffect(() => () => { mounted.current = false; }, []);
1063
1064
  useEffect(() => { void door('/-/tokens').then(async (r) => { if (mounted.current) setTokensMade(r.ok ? ((await r.json()) as { tokens: unknown[] }).tokens.length > 0 : false); }).catch(() => undefined); }, [version]);
1064
- if (dismissed) return null;
1065
1065
  const org = orgs[0];
1066
1066
  // the way in is the app's folder (docs/contributing/architecture.md): the command makes the World from the vendors
1067
1067
  // the app uses; Create World above is the way for a person without a terminal
1068
1068
  const steps: Array<[boolean, React.ReactNode]> = [
1069
1069
  [orgs.length > 0, 'Create or join an org'],
1070
- [tokensMade === true, <>Install the command and sign it in: <code>npm install -g @volter/world</code>, then <code>volter login {ORIGIN}</code></>],
1071
- [worlds > 0, <>In your app's folder, make its World here: <code>volter world init</code>, then <code>volter remote add origin {firstWorld ?? `${org ?? '<org>'}/<world>`}{firstWorld ? '' : ' --create'}</code></>],
1072
- [false, <>Send it what your app did: <code>volter world changeset -m "…"</code>, then <code>volter world push</code></>],
1070
+ [tokensMade === true, <React.Fragment key="command">Install the command and sign it in: <code>npm install -g @volter/world</code>, then <code>volter login {ORIGIN}</code></React.Fragment>],
1071
+ [worlds > 0, <React.Fragment key="world">In your app's folder, make its World here: <code>volter world init</code>, then <code>volter remote add origin {firstWorld ?? `${org ?? '<org>'}/<world>`}{firstWorld ? '' : ' --create'}</code></React.Fragment>],
1072
+ [pushed, <React.Fragment key="push">Send it what your app did: <code>volter world changeset -m "…"</code>, then <code>volter world push</code></React.Fragment>],
1073
1073
  ];
1074
1074
  const done = steps.filter(([d]) => d).length;
1075
+ // unfinished, the list re-reads what it ticks from while its tab is in view: its last steps happen in a terminal, and
1076
+ // a World made or a push landed there ticks here without a reload
1077
+ const again = useRef(refresh); again.current = refresh;
1078
+ useEffect(() => {
1079
+ if (dismissed || done === steps.length) return;
1080
+ const every = setInterval(() => { if (document.visibilityState === 'visible') again.current(); }, 5000);
1081
+ return () => clearInterval(every);
1082
+ }, [dismissed, done, steps.length]);
1083
+ if (dismissed) return null;
1084
+ const dismiss = (): void => { try { localStorage.setItem(KEY, 'done'); } catch { /* no storage */ } setDismissed(true); };
1085
+ // all four done: what comes next, in a line, and the card goes when you say
1086
+ if (done === steps.length) return (
1087
+ <section aria-labelledby="getting-started" className="onboarding">
1088
+ <div className="onboarding-head"><h3 id="getting-started">You are set up</h3><span className="mode">{done} of {steps.length}</span><div className="progress" aria-hidden="true"><span style={{ width: '100%' }} /></div><button type="button" onClick={dismiss}>Done</button></div>
1089
+ <p className="mode onboarding-alt">Your app's changes land in {firstWorld ?? 'its World'}. Next: {org ? <Go to={{ kind: 'org', org, page: 'members' }}>invite your team</Go> : 'invite your team'}, who open it as themselves, and give each pull request a preview World with the GitHub Action.</p>
1090
+ </section>
1091
+ );
1075
1092
  return (
1076
1093
  <section aria-labelledby="getting-started" className="onboarding">
1077
1094
  <div className="onboarding-head"><h3 id="getting-started">Get started</h3><span className="mode">{done} of {steps.length}</span><div className="progress" aria-hidden="true"><span style={{ width: `${(done / steps.length) * 100}%` }} /></div><button type="button" className="icon-button" aria-label="Dismiss getting started" onClick={() => { try { localStorage.setItem(KEY, 'done'); } catch { /* no storage */ } setDismissed(true); }}>✕</button></div>
@@ -1,6 +1,6 @@
1
1
  // The migrations as a module (./migrations.ts): what `drizzle-kit generate` wrote under ./migrations, split into
2
2
  // statements, so every driver (a Durable Object reads no files) applies the same SQL.
3
- // bun src/db/pack-migrations.ts --write after `drizzle-kit generate --dialect sqlite --schema ./src/db/schema.ts --out ./src/db/migrations`
3
+ // bun src/db/pack-migrations.ts --write after `bunx drizzle-kit@0.31.11 generate --dialect sqlite --schema ./src/db/schema.ts --out ./src/db/migrations`
4
4
  // bun src/db/pack-migrations.ts --check exits 1 when migrations.ts is behind the .sql files
5
5
  import { readdirSync, readFileSync, writeFileSync } from 'node:fs';
6
6
  import { join } from 'node:path';
package/dist/src/doors.js CHANGED
@@ -8,7 +8,7 @@ export const DOORS = [
8
8
  { method: 'POST', path: '/-/status/notice', who: 'operator', scope: null, summary: 'Set or clear the notice the status page shows.', body: '{ text | null }', answers: '{ notice }' },
9
9
  { method: 'GET', path: '/-/openapi.json', who: 'anyone', scope: null, summary: 'This table as an OpenAPI 3.1 document.', answers: 'OpenAPI' },
10
10
  { method: 'GET', path: '/-/vendors', who: 'person', scope: null, summary: 'The twins a new World may have: what the host Worlds are made on serves, as it answers.', answers: '{ host, vendors[] }' },
11
- { method: 'GET', path: '/-/worlds', who: 'person', scope: 'worlds:read', summary: 'The worlds your orgs hold, with their addresses and twins.', answers: '{ worlds[] }' },
11
+ { method: 'GET', path: '/-/worlds', who: 'person', scope: 'worlds:read', summary: 'The worlds your orgs hold that you reach, with their addresses, twins and how many changesets have landed in each (landed, when the World answered).', answers: '{ worlds[] }' },
12
12
  { method: 'POST', path: '/-/worlds', who: 'person', scope: 'worlds:write', summary: 'Provision a world into an org, on an enrolled host; where the platform bills, refused at the plan\'s limits with 402.', body: '{ org, world, vendors[] }', answers: '{ name, base, host }' },
13
13
  { method: 'POST', path: '/-/worlds/sample', who: 'person', scope: 'worlds:write', summary: 'Make the org\'s sample World (`<org>/sample`, Stripe and Slack where the host serves them) and seed it through the vendors\' own APIs: a World like any other, metered and deletable; 409 when it exists.', body: '{ org }', answers: '{ name, base, host, seeded: { <vendor>: { seeded[] } | { error } } }' },
14
14
  { method: 'DELETE', path: '/-/worlds/{org}/{world}', who: 'admin', scope: 'worlds:write', summary: 'Delete a world through its host.', answers: '{ deleted }' },
package/dist/src/mail.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // The platform's own mail (punchlist 3.6): what the identity service and Polar do not send (an
2
2
  // invitation is the identity service's mail, and a receipt Polar's) — a member added, a
3
3
  // world deleted, the quota warning, the grace notice. Resend's UNMODIFIED SDK through the world's
4
- // boundary: the resend twin in rehearsal (its inbox mirror shows every mail), real Resend in
4
+ // boundary: the resend twin in rehearsal (its inbox shows every mail), real Resend in
5
5
  // production with a verified sending domain (OWNER: the domain). No RESEND_API_KEY → the mail is
6
6
  // written to the log, never invented.
7
7
  import { Resend } from 'resend';
package/dist/src/pages.js CHANGED
@@ -5,7 +5,7 @@
5
5
  // own, so the platform and a World's pages read as one product. No third-party script is ever served here: the site's
6
6
  // tags stay on the site.
7
7
  import { readFileSync } from 'node:fs';
8
- import { bundleClient } from '@volter/world-core';
8
+ import { bundleClient } from '@volter/world-core/runtime';
9
9
  import { brandAsset, kitCss, WORLD_LOGO } from '@volter/world-console';
10
10
  import { RESERVED_ORG_NAMES } from "../client/reserved.js";
11
11
  export { RESERVED_ORG_NAMES };
@@ -653,7 +653,7 @@ export function createPlatform(opts) {
653
653
  if (!found)
654
654
  return shell('<p role="alert">That code has expired or was already used. Run <code>volter login</code> again.</p>');
655
655
  const [, grant] = found;
656
- return shell(`<p>Sign in the <code>volter</code> command on <b>${grant.name}</b> as <b>${(session.person.email ?? session.person.id).replace(/[<>&"]/g, '')}</b>?</p><p><b>Only approve if you just ran <code>volter login</code> yourself.</b> Someone who sent you this link could otherwise sign their command in as you.</p><p>Check this code matches the one in your terminal:</p><p style="font:600 22px var(--mono);letter-spacing:.12em">${grant.userCode}</p><form method="post" action="/-/cli/approve" style="display:flex;gap:8px;justify-content:center"><input type="hidden" name="code" value="${grant.userCode}"><button type="submit" name="decision" value="approve">Approve</button><button type="submit" name="decision" value="deny" formnovalidate style="background:none;color:inherit">Deny</button></form>`);
656
+ return shell(`<p>Sign in the <code>volter</code> command on <b>${grant.name}</b> as <b>${(session.person.email ?? session.person.id).replace(/[<>&"]/g, '')}</b>?</p><p><b>Only approve if you just ran <code>volter login</code> yourself.</b> Someone who sent you this link could otherwise sign their command in as you.</p><p>Check this code matches the one in your terminal:</p><p style="font:600 22px var(--mono);letter-spacing:.12em">${grant.userCode}</p><form method="post" action="/-/cli/approve" style="display:flex;gap:8px"><input type="hidden" name="code" value="${grant.userCode}"><button type="submit" name="decision" value="approve">Approve</button><button type="submit" name="decision" value="deny" formnovalidate style="background:none;color:inherit">Deny</button></form>`);
657
657
  }
658
658
  if (request.method === 'POST') {
659
659
  // only this page's own form: a page elsewhere cannot approve an attacker's code in a signed-in browser
@@ -737,7 +737,15 @@ export function createPlatform(opts) {
737
737
  if (!person)
738
738
  return unauthorized(request);
739
739
  // addresses and twins: a World's keys never go to a person's browser (a member opens a World with a pass)
740
- return json({ worlds: (await rowsForPerson(person)).map(({ token: _t, readToken: _r, ...row }) => row) });
740
+ // each World's landed changesets, counted with its read token: what the onboarding's last step ("send it what
741
+ // your app did") is done by, and what a row says about a World's history
742
+ const rows = await rowsForPerson(person);
743
+ const landed = await Promise.all(rows.map(async (r) => {
744
+ // bounded as the status page's host ping is: a host that does not answer leaves its row without a count, never the list waiting
745
+ const res = await fetch(`${new URL(r.base).origin}/-/${r.name}/changesets`, { headers: { 'x-volter-token': r.readToken }, signal: AbortSignal.timeout(2500) }).catch(() => null);
746
+ return res?.ok ? ((await res.json().catch(() => ({}))).changesets?.length ?? null) : null;
747
+ }));
748
+ return json({ worlds: rows.map(({ token: _t, readToken: _r, ...row }, i) => ({ ...row, ...(landed[i] !== null ? { landed: landed[i] } : {}) })) });
741
749
  }
742
750
  if (path === '/-/worlds' && request.method === 'POST') {
743
751
  const person = (await sessionOf(request))?.person;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/world-platform",
3
- "version": "2.0.40",
3
+ "version": "3.0.1",
4
4
  "description": "The Volter World platform: people and orgs through an access provider (Volter Identity, GitHub, or any OpenID Connect issuer), members and invitations, tokens, Worlds made on enrolled hosts, and opening a World for a person with a pass. Open and self-hostable; serves its own pages and never a twin.",
5
5
  "keywords": [
6
6
  "volter",
@@ -38,9 +38,9 @@
38
38
  "dependencies": {
39
39
  "@aws-sdk/client-s3": "3.1081.0",
40
40
  "@volter/identity": "^0.1.0",
41
- "@volter/world-access": "2.0.2",
42
- "@volter/world-console": "2.0.18",
43
- "@volter/world-core": "2.0.37",
41
+ "@volter/world-access": "3.0.1",
42
+ "@volter/world-console": "3.0.1",
43
+ "@volter/world-core": "3.0.1",
44
44
  "drizzle-orm": "0.45.3",
45
45
  "react": "^19.2.7",
46
46
  "react-dom": "^19.2.7",
@@ -51,11 +51,9 @@
51
51
  "@types/node": "^24.0.0",
52
52
  "@types/react": "^19.2.7",
53
53
  "@types/react-dom": "^19.2.7",
54
- "@volter/twin-volteridentity": "0.1.37",
55
- "@volter/world-host": "2.0.54",
56
- "@volter/world-runtime": "2.0.55",
54
+ "@volter/world-host": "3.0.1",
55
+ "@volter/world-runtime": "3.0.1",
57
56
  "@volter/world-tooling": "0.1.0",
58
- "drizzle-kit": "0.31.11",
59
57
  "playwright": "^1.61.0",
60
58
  "typescript": "^5.9.0"
61
59
  },
package/src/db/open.ts CHANGED
@@ -9,7 +9,7 @@ import { BaseSQLiteDatabase, SQLiteSyncDialect } from 'drizzle-orm/sqlite-core';
9
9
  import { BetterSQLiteSession } from 'drizzle-orm/better-sqlite3/session';
10
10
  import { MIGRATIONS } from './migrations.ts';
11
11
 
12
- // biome-ignore lint/suspicious/noExplicitAny: the three drivers' result types differ; every store reads rows the schema types
12
+ // `any`: the three drivers' result types differ; every store reads rows the schema types
13
13
  export type PlatformDb = BaseSQLiteDatabase<'sync', any>;
14
14
 
15
15
  type Statement = { run: (...p: unknown[]) => unknown; all: (...p: unknown[]) => unknown[]; get: (...p: unknown[]) => unknown; raw: () => { all: (...p: unknown[]) => unknown[]; get: (...p: unknown[]) => unknown } };
@@ -1,6 +1,6 @@
1
1
  // The migrations as a module (./migrations.ts): what `drizzle-kit generate` wrote under ./migrations, split into
2
2
  // statements, so every driver (a Durable Object reads no files) applies the same SQL.
3
- // bun src/db/pack-migrations.ts --write after `drizzle-kit generate --dialect sqlite --schema ./src/db/schema.ts --out ./src/db/migrations`
3
+ // bun src/db/pack-migrations.ts --write after `bunx drizzle-kit@0.31.11 generate --dialect sqlite --schema ./src/db/schema.ts --out ./src/db/migrations`
4
4
  // bun src/db/pack-migrations.ts --check exits 1 when migrations.ts is behind the .sql files
5
5
  import { readdirSync, readFileSync, writeFileSync } from 'node:fs';
6
6
  import { join } from 'node:path';
package/src/doors.ts CHANGED
@@ -23,7 +23,7 @@ export const DOORS: readonly Door[] = [
23
23
  { method: 'POST', path: '/-/status/notice', who: 'operator', scope: null, summary: 'Set or clear the notice the status page shows.', body: '{ text | null }', answers: '{ notice }' },
24
24
  { method: 'GET', path: '/-/openapi.json', who: 'anyone', scope: null, summary: 'This table as an OpenAPI 3.1 document.', answers: 'OpenAPI' },
25
25
  { method: 'GET', path: '/-/vendors', who: 'person', scope: null, summary: 'The twins a new World may have: what the host Worlds are made on serves, as it answers.', answers: '{ host, vendors[] }' },
26
- { method: 'GET', path: '/-/worlds', who: 'person', scope: 'worlds:read', summary: 'The worlds your orgs hold, with their addresses and twins.', answers: '{ worlds[] }' },
26
+ { method: 'GET', path: '/-/worlds', who: 'person', scope: 'worlds:read', summary: 'The worlds your orgs hold that you reach, with their addresses, twins and how many changesets have landed in each (landed, when the World answered).', answers: '{ worlds[] }' },
27
27
  { method: 'POST', path: '/-/worlds', who: 'person', scope: 'worlds:write', summary: 'Provision a world into an org, on an enrolled host; where the platform bills, refused at the plan\'s limits with 402.', body: '{ org, world, vendors[] }', answers: '{ name, base, host }' },
28
28
  { method: 'POST', path: '/-/worlds/sample', who: 'person', scope: 'worlds:write', summary: 'Make the org\'s sample World (`<org>/sample`, Stripe and Slack where the host serves them) and seed it through the vendors\' own APIs: a World like any other, metered and deletable; 409 when it exists.', body: '{ org }', answers: '{ name, base, host, seeded: { <vendor>: { seeded[] } | { error } } }' },
29
29
  { method: 'DELETE', path: '/-/worlds/{org}/{world}', who: 'admin', scope: 'worlds:write', summary: 'Delete a world through its host.', answers: '{ deleted }' },
package/src/mail.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  // The platform's own mail (punchlist 3.6): what the identity service and Polar do not send (an
2
2
  // invitation is the identity service's mail, and a receipt Polar's) — a member added, a
3
3
  // world deleted, the quota warning, the grace notice. Resend's UNMODIFIED SDK through the world's
4
- // boundary: the resend twin in rehearsal (its inbox mirror shows every mail), real Resend in
4
+ // boundary: the resend twin in rehearsal (its inbox shows every mail), real Resend in
5
5
  // production with a verified sending domain (OWNER: the domain). No RESEND_API_KEY → the mail is
6
6
  // written to the log, never invented.
7
7
  import { Resend } from 'resend';
package/src/pages.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  // own, so the platform and a World's pages read as one product. No third-party script is ever served here: the site's
6
6
  // tags stay on the site.
7
7
  import { readFileSync } from 'node:fs';
8
- import { bundleClient } from '@volter/world-core';
8
+ import { bundleClient } from '@volter/world-core/runtime';
9
9
  import { brandAsset, kitCss, WORLD_LOGO } from '@volter/world-console';
10
10
  import { RESERVED_ORG_NAMES } from '../client/reserved.ts';
11
11
 
package/src/platform.ts CHANGED
@@ -495,7 +495,7 @@ export function createPlatform(opts: PlatformOptions): { fetch: (request: Reques
495
495
  const found = pendingFor(code);
496
496
  if (!found) return shell('<p role="alert">That code has expired or was already used. Run <code>volter login</code> again.</p>');
497
497
  const [, grant] = found;
498
- return shell(`<p>Sign in the <code>volter</code> command on <b>${grant.name}</b> as <b>${(session.person.email ?? session.person.id).replace(/[<>&"]/g, '')}</b>?</p><p><b>Only approve if you just ran <code>volter login</code> yourself.</b> Someone who sent you this link could otherwise sign their command in as you.</p><p>Check this code matches the one in your terminal:</p><p style="font:600 22px var(--mono);letter-spacing:.12em">${grant.userCode}</p><form method="post" action="/-/cli/approve" style="display:flex;gap:8px;justify-content:center"><input type="hidden" name="code" value="${grant.userCode}"><button type="submit" name="decision" value="approve">Approve</button><button type="submit" name="decision" value="deny" formnovalidate style="background:none;color:inherit">Deny</button></form>`);
498
+ return shell(`<p>Sign in the <code>volter</code> command on <b>${grant.name}</b> as <b>${(session.person.email ?? session.person.id).replace(/[<>&"]/g, '')}</b>?</p><p><b>Only approve if you just ran <code>volter login</code> yourself.</b> Someone who sent you this link could otherwise sign their command in as you.</p><p>Check this code matches the one in your terminal:</p><p style="font:600 22px var(--mono);letter-spacing:.12em">${grant.userCode}</p><form method="post" action="/-/cli/approve" style="display:flex;gap:8px"><input type="hidden" name="code" value="${grant.userCode}"><button type="submit" name="decision" value="approve">Approve</button><button type="submit" name="decision" value="deny" formnovalidate style="background:none;color:inherit">Deny</button></form>`);
499
499
  }
500
500
  if (request.method === 'POST') {
501
501
  // only this page's own form: a page elsewhere cannot approve an attacker's code in a signed-in browser
@@ -555,7 +555,15 @@ export function createPlatform(opts: PlatformOptions): { fetch: (request: Reques
555
555
  const person = (await sessionOf(request))?.person;
556
556
  if (!person) return unauthorized(request);
557
557
  // addresses and twins: a World's keys never go to a person's browser (a member opens a World with a pass)
558
- return json({ worlds: (await rowsForPerson(person)).map(({ token: _t, readToken: _r, ...row }) => row) });
558
+ // each World's landed changesets, counted with its read token: what the onboarding's last step ("send it what
559
+ // your app did") is done by, and what a row says about a World's history
560
+ const rows = await rowsForPerson(person);
561
+ const landed = await Promise.all(rows.map(async (r) => {
562
+ // bounded as the status page's host ping is: a host that does not answer leaves its row without a count, never the list waiting
563
+ const res = await fetch(`${new URL(r.base).origin}/-/${r.name}/changesets`, { headers: { 'x-volter-token': r.readToken }, signal: AbortSignal.timeout(2500) }).catch(() => null);
564
+ return res?.ok ? (((await res.json().catch(() => ({}))) as { changesets?: unknown[] }).changesets?.length ?? null) : null;
565
+ }));
566
+ return json({ worlds: rows.map(({ token: _t, readToken: _r, ...row }, i) => ({ ...row, ...(landed[i] !== null ? { landed: landed[i] } : {}) })) });
559
567
  }
560
568
  if (path === '/-/worlds' && request.method === 'POST') {
561
569
  const person = (await sessionOf(request))?.person;