@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.
- package/client/platform.css +2 -0
- package/client/platform.tsx +27 -10
- package/dist/client/platform.bundle.js +47 -47
- package/dist/client/platform.css +2 -0
- package/dist/client/platform.tsx +27 -10
- package/dist/src/db/pack-migrations.js +1 -1
- package/dist/src/doors.js +1 -1
- package/dist/src/mail.js +1 -1
- package/dist/src/pages.js +1 -1
- package/dist/src/platform.js +10 -2
- package/package.json +6 -8
- package/src/db/open.ts +1 -1
- package/src/db/pack-migrations.ts +1 -1
- package/src/doors.ts +1 -1
- package/src/mail.ts +1 -1
- package/src/pages.ts +1 -1
- package/src/platform.ts +10 -2
package/dist/client/platform.css
CHANGED
|
@@ -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; }
|
package/dist/client/platform.tsx
CHANGED
|
@@ -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
|
-
|
|
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"
|
|
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"
|
|
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,
|
|
1071
|
-
[worlds > 0,
|
|
1072
|
-
[
|
|
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
|
|
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
|
|
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 };
|
package/dist/src/platform.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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": "
|
|
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": "
|
|
42
|
-
"@volter/world-console": "
|
|
43
|
-
"@volter/world-core": "
|
|
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/
|
|
55
|
-
"@volter/world-
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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;
|