Files
puter/src/puter-js/tests/api/suites/system.suite.ts
T
Juan Fernando Castro d1d55a6bc3 SDK auth plumbing: cache scope, dedupe keys and the token listener (#4112)
* fix: pin the token sender, scope the grant flag, and stop two stragglers

Five of the SDK auth-plumbing items.

**The global `puter.token` handler pinned origin but not source.** Its three
siblings all pin both, and each says why: the GUI origin is shared by every
frame on that domain, so origin alone identifies a domain, not a sender.
`signIn` pins its popup and binds `msg_id`, the consent dialog pins its popup,
and the reauth wait pins the embedder. This one pinned the embedder only when
`env === 'app'`. Since `authenticateWithPuter` now hands off to `signIn`, which
adopts the token itself, the embedder is the only sender this handler is left
serving, so it pins in every environment.

**`is_grant_user_app_permission` was a request-wide flag held across an await.**
It tells the `app-root-dir` rewriter that a row is being written, and while it
is set that rewriter resolves to a real `fs:` permission instead of
`PERMISSION_FOR_NOTHING_IN_PARTICULAR`. Anything evaluating an `app-root-dir:`
permission beside the grant -- the grant route runs one under `Promise.all`
with fs work -- read the same flag. It now lives in a scope derived for that
call, so what the write needs to say about itself is not also said to its
neighbours. `runInDerivedContext` is new: `runWithContext` starts an empty
store, which would drop the `actor` the rewriter reads.

**`fs.stat` and `fs.readdir` deduped on parameters alone**, so two identities
in one realm could share a result. They now key on origin and token as
`os/user.js` does, which documents the reason.

**`signIn`'s relay poll never stopped.** Closing the popup settles the promise;
the loop kept asking `/login/wait` every second for the life of the page.

**`triggerReauth`'s five-minute timeout was never cleared**, so a successful
reauth left a timer to fire into a settled promise.

The concurrency test parks the rewrite and reads the flag while it is held --
my first attempt read before the flag was even set and passed either way.

* fix: scope the fs cache per identity, and retarget two paths that never ran

The remaining SDK auth-plumbing items.

**`puter._cache` outlived the identity that filled it.** `_clearAuthToken`
dropped the token and the stored session but left the cache and `whoami` as
they were, and its keys were raw paths -- `item:/alice/Documents` and a
`~`-relative key alike, identical whoever asked. A GUI user switch without a
reload could be served the previous user's stat or readdir. Keys now carry the
origin and token they were read as, built in one place so the writers, the
readers, the invalidations and the warm-up probes cannot drift apart, and
signing out drops the cache and `whoami` with the token.

**The driver permission prompt could not fire.** Both paths waited for a 200
carrying `{success:false, error:{code:'permission_denied'}}`. The driver route
answers `success: true` or throws, so a refusal arrives as a 403 -- the shape
the retry now reads. Its two tests synthesised the old envelope, so they passed
against a response the API does not send; they use the real one now, and the
prompt goes from never called to called once.

This is a behaviour change: a driver call refused for permissions will now
prompt, which is what the path was written to do. Happy to delete it instead if
the prompt is not wanted.

The half-written copy of the same idea in `utils.js` is gone -- it prompted for
one hard-coded interface and its retry was a `// todo`.

**The verification-gate single-flight was keyed on nothing**, so a phone-gated
request in flight beside an email-gated one took the email answer, replayed,
failed again and surfaced raw without ever asking. Keyed per gate: same gate
still shares one dialog, a different gate gets its own.

* test: ask the cache the same way the code does

The API suites built `item:<path>` keys by hand, so they stopped finding
anything once the real keys carried the identity they were read as. They go
through `fsCacheKey` now, which is the point: a test that spells the key itself
passes until the moment the spelling changes, and then fails for a reason that
has nothing to do with the behaviour it is guarding.

These run against a live server, so a plain `vitest run` does not reach them --
they were green locally and red in CI.

* fix: the review's findings on this branch

Eight, and three of them were regressions this branch introduced.

**Pinning the token sender to `globalThis.parent` in every environment broke
the case it was meant to protect.** On a top-level page `parent` is the page
itself, and the token arrives from a popup, so the handler stopped accepting
anything. The GUI posts `puter.token` to `window.opener` for the file pickers
too, not only for sign-in, and this handler is their only consumer -- a site
calling `showOpenFilePicker()` would have signed the user in, dropped the token
and run the following read with none. It now pins the embedder when framed and
a window this SDK opened when not; `UI.js` already tracked its picker popups
for the same reason, so that set is shared rather than duplicated.

**The GUI still spelled a cache key by hand.** `updateSubdomainsForItems` built
`item:<path>`, which no longer matches what the SDK writes, so its `get` could
not hit and its `set` wrote an orphan nothing reads and nothing evicts -- a
published website's badge quietly stopped appearing on a cached render. It goes
through `fsCacheKey` now, and that is the last hand-spelled key in the tree.

**Retargeting the driver permission prompt to 403 was wrong and is reverted to
the deletion the ticket asked for.** The route refuses with `forbidden`, not
`permission_denied`, so the retarget still missed it; meanwhile a driver that
raises `permission_denied` for its own reasons would have opened a prompt that
had nothing to do with the refusal. And the prompt asks for
`driver:<iface>:<method>` while the route checks `service:<driver>:ii:<iface>`,
so granting it changes nothing. The path cannot work as written.

The rest were pre-existing or latent:

`cacheWhoami_` wrote its answer without checking the token it was issued under
was still current, so a reauth landing mid-flight could repopulate `whoami`
with the previous identity. `_dropIdentityCaches` also left `whoamiCache_`,
which other call sites await.

`setAuthToken` now drops the identity caches when the token actually changes:
keys carry the token and the store is persisted with no TTL, so a rotation that
did not go through `_clearAuthToken` orphaned a whole key space with nothing to
reclaim it.

The verification-gate entry was registered after the body could already have
deleted it -- a synchronous throw stranded a resolved "not verified" in the map
for the life of the page. It registers first and clears on a microtask.

`signIn`'s poll is bounded as well as settle-checked: on a cross-origin
isolated page opened from a gesture, nothing watches the popup, so closing it
never settled the promise and the loop ran on exactly as before.

`runInDerivedContext` says in its docblock that callee writes do not survive;
only the one flag needs the scope, and a future caller should not expect to
hand a value back through it.

* fix: the review's findings on the SDK auth plumbing

**The relay bound stopped asking without saying so.** The deadline left the
`signIn` promise pending and its listener attached. On an isolated page opened
from a gesture the relay is the only thing that can settle it -- nothing watches
the popup there -- so a user who took longer than the deadline was left with a
promise that never resolved, and `authenticateWithPuter` queues every later
implicit-auth call behind it. It rejects now.

**Pinning the token source left `signIn`'s own popup untracked.** Only the
picker popups registered, so on a top-level page the global handler bailed on a
sign-in it had opened itself: `puter.onAuth` never fired, and
`puterAuthState.authGranted` was never set. `openAuthPopup` is the one place
auth popups are opened, so they register there.

**A closed popup is ours too.** Rejecting one that had already closed loses a
message from a popup that posts and then closes itself, which is why `UI.js`
tolerates the same case. Kept, with the set bounded instead.

**The gate key carried the factors**, so one route naming them and another not
opened two dialogs for the same gate. It keys on the gate, as `ctx.done` does.

**`fsCacheKey` left `~` unresolved**, so a writer and an invalidation spelling
the same place differently missed each other -- the drift the one key builder
was meant to end.

The dead `opts.permission` went with the path that used to read it.

Four workerd-runner failures are left: `ai > chat stream adapter supports
cancellation`, `kv > a GUI boot key written by another client reads fresh` and
two cache-warmer ones. All four fail the same way on `origin/main`, and the node
and browser runners pass.

* chore: one-line the comments added here

* chore: reflow the comments the removed retry cause left ragged
2026-10-08 15:04:49 -04:00

501 lines
19 KiB
TypeScript

import { suite } from '../harness/types.ts';
import type { TestContext } from '../harness/types.ts';
/** SDK internals the public `.d.ts` does not declare. */
type PuterInternals = {
env: string;
whoami: { username: string } | undefined;
_cache: {
get: (key: string) => unknown;
flushall: () => void;
};
checkAndUpdateGUIFScache: () => void;
fsCacheKey: (kind: string, path: string) => string;
request_rao_: () => Promise<unknown>;
};
const internals = (t: TestContext) => t.puter as unknown as PuterInternals;
/** Wait until `read` returns something, or give up. */
const settle = async (read: () => unknown, timeoutMs = 15_000) => {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const value = read();
if (value) return value;
await new Promise((resolve) => setTimeout(resolve, 50));
}
return undefined;
};
export default suite('system', {
'os.version reports a version': async (t) => {
const version = await t.puter.os.version();
t.assert.ok(
version && typeof version === 'object',
'version should be an object',
);
},
'os.user returns the authenticated user': async (t) => {
const user = await t.puter.os.user();
t.assert.equal(user.username, t.env.users.user.username);
},
'drivers.list includes the core interfaces': async (t) => {
const interfaces = await t.puter.drivers.list();
for (const expected of [
'puter-kvstore',
'puter-apps',
'puter-subdomains',
'puter-chat-completion',
]) {
t.assert.ok(
Object.prototype.hasOwnProperty.call(interfaces, expected),
`interfaces should include ${expected}`,
);
}
},
'drivers.call reaches a driver method generically': async (t) => {
const result = await t.puter.drivers.call(
'puter-kvstore',
'set',
{ key: 'system-suite-driver-call', value: 'via drivers.call' },
);
t.assert.ok(result.success, 'driver call should succeed');
t.assert.equal(
await t.puter.kv.get('system-suite-driver-call'),
'via drivers.call',
);
},
'drivers.call on an unknown interface reports failure': async (t) => {
const result = await t.puter.drivers.call(
'system-suite-no-such-interface',
'nope',
{},
);
t.assert.ok(
!result?.success,
`unknown interface should not succeed: ${JSON.stringify(result)}`,
);
},
'drivers.call on an unknown method reports failure': async (t) => {
const result = await t.puter.drivers.call(
'puter-kvstore',
'system-suite-no-such-method',
{},
);
t.assert.ok(
!result?.success,
`unknown method should not succeed: ${JSON.stringify(result)}`,
);
},
'drivers.get hands back the same handle per interface': async (t) => {
const first = await t.puter.drivers.get('puter-kvstore');
const second = await t.puter.drivers.get('puter-kvstore');
t.assert.ok(first === second, 'the handle should be cached');
const other = await t.puter.drivers.get('puter-apps');
t.assert.ok(first !== other, 'a different interface gets its own handle');
},
'a driver handle resolves the response envelope as sent': async (t) => {
const driver = await t.puter.drivers.get('puter-kvstore');
const set = (await driver.call('set', {
key: 'system-suite-driver-handle',
value: 'handle value',
})) as { success: boolean; result: unknown };
t.assert.equal(set.success, true);
t.assert.equal(set.result, true);
const got = (await driver.call('get', {
key: 'system-suite-driver-handle',
})) as { success: boolean; result: unknown };
t.assert.equal(got.success, true);
t.assert.equal(got.result, 'handle value');
},
// The implementation slot predates interface-level defaults; the backend
// resolves it, so the SDK accepts and ignores it.
'drivers.call still accepts the legacy four-argument form': async (t) => {
await t.puter.kv.set('system-suite-four-arg', 'four');
const result = (await t.puter.drivers.call(
'puter-kvstore',
'puter-kvstore',
'get',
{ key: 'system-suite-four-arg' },
)) as { success: boolean; result: unknown };
t.assert.equal(result.success, true);
t.assert.equal(result.result, 'four');
},
'drivers.call with only an interface calls the same-named method': async (t) => {
const result = (await t.puter.drivers.call('puter-kvstore', {
key: 'system-suite-two-arg',
})) as { code?: string; message?: string };
t.assert.equal(result.code, 'not_found');
t.assert.equal(
result.message,
"Method 'puter-kvstore' not found on driver 'puter-kvstore'",
);
},
// Recording an app open is idempotent per SDK instance: setAuthToken runs
// more than once during sign-in and must not re-record each time.
'the app-open record is only requested once per SDK instance': async (t) => {
const puter = t.puter as unknown as {
request_rao_: () => Promise<unknown>;
};
await puter.request_rao_();
t.assert.equal(
await puter.request_rao_(),
undefined,
'a repeat request should short-circuit',
);
},
// -- Email --
// Two unrelated surfaces that happen to live on the same module.
// `sendTransactional` is an app sending through the
// `puter-transactional-email` driver; `send` is a user sending their own
// mail, composed to RFC822 client-side and posted to `/email/send`.
// Neither ships with this deployment, so what these pin is how each one
// reports that — and that the two no longer share a path.
'email.sendTransactional passes a driver error through unchanged': async (
t,
) => {
const error = (await t.assert.rejects(() =>
t.puter.email.sendTransactional(
'nobody@example.com',
'subject',
'body',
),
)) as { code?: string; message?: string };
t.assert.equal(error.code, 'not_found');
t.assert.equal(
error.message,
'Driver not found: puter-transactional-email:(no default)',
);
},
// `send` hands back the endpoint's answer as-is rather than rejecting,
// so an absent endpoint reads as the 404 body it replied with.
'email.send resolves the send endpoint\'s answer': async (t) => {
const result = (await t.puter.email.send({
to: 'nobody@example.com',
subject: 'subject',
text: 'body',
})) as { code?: string };
t.assert.equal(result.code, 'not_found');
},
// Composition runs client-side, so a message that can't be built is
// refused before anything reaches the network.
'email.send rejects a message with no recipient': async (t) => {
const error = (await t.assert.rejects(() =>
t.puter.email.send({ subject: 'subject', text: 'body' }),
)) as Error;
t.assert.equal(
error.message,
'Cannot compose email without recipient!',
);
},
// `send` takes options only — the positional form is `sendTransactional`'s
// alone, and lands here as a message with nothing to address it to.
'email.send does not accept the positional form': async (t) => {
const error = (await t.assert.rejects(() =>
(t.puter.email.send as (...args: unknown[]) => Promise<unknown>)(
'nobody@example.com',
'subject',
'body',
),
)) as Error;
t.assert.equal(
error.message,
'Cannot compose email without recipient!',
);
},
// -- Transport failures --
// Reads are retried on a fixed backoff before giving up, and a transport
// failure surfaces as a TypeError (fetch semantics) rather than an
// `ok: false` response.
'an unreachable API origin fails as a network error after retrying': async (
t,
) => {
const realOrigin = t.puter.APIOrigin;
const deadOrigin = 'http://system-suite-unreachable.invalid';
try {
t.puter.setAPIOrigin(deadOrigin);
const started = Date.now();
const error = (await t.assert.rejects(() =>
t.puter.auth.whoami(),
)) as Error;
t.assert.ok(
error instanceof TypeError,
`expected a TypeError, got ${error}`,
);
t.assert.equal(
error.message,
`Network request to ${deadOrigin}/whoami failed`,
);
// The fixed schedule ramps to a 2s ceiling, so a run that gave up
// immediately would mean the retry loop never engaged.
t.assert.ok(
Date.now() - started > 3_000,
'the request should have been retried before failing',
);
} finally {
t.puter.setAPIOrigin(realOrigin);
}
},
// A driver call is a POST, so it is never retried; both driver result
// shapes have to report the transport failure to their caller rather
// than resolving something that looks like a response.
'driver calls surface a transport failure instead of a response': async (t) => {
const realOrigin = t.puter.APIOrigin;
try {
t.puter.setAPIOrigin('http://system-suite-driver-unreachable.invalid');
// `puter.drivers.call` resolves the envelope, so a transport
// failure is the one thing it throws.
const envelopeError = (await t.assert.rejects(() =>
t.puter.drivers.call('puter-kvstore', 'set', {
key: 'system-suite-dead-origin',
value: 'x',
}),
)) as Error;
t.assert.ok(
envelopeError instanceof TypeError,
`expected a TypeError, got ${envelopeError}`,
);
// The module-level driver helpers reject with the failed request
// itself, which carries no HTTP status.
const callError = (await t.assert.rejects(() =>
t.puter.email.sendTransactional('nobody@example.com', 's', 'b'),
)) as { status?: number };
t.assert.equal(callError.status, 0);
} finally {
t.puter.setAPIOrigin(realOrigin);
}
},
// -- Logger --
'the logger prefixes messages with the fields it was given': async (t) => {
const logger = (
t.puter as unknown as {
logger: {
fields: (extra: Record<string, unknown>) => unknown;
info: (...args: unknown[]) => void;
warn: (...args: unknown[]) => void;
error: (...args: unknown[]) => void;
debug: (...args: unknown[]) => void;
on: (category: string) => void;
};
}
).logger;
const captured: unknown[][] = [];
const real = {
log: console.log,
warn: console.warn,
error: console.error,
debug: console.debug,
};
const record = (...args: unknown[]) => captured.push(args);
try {
console.log = record;
console.warn = record;
console.error = record;
console.debug = record;
logger.info('plain');
const scoped = logger.fields({ area: 'system-suite' }) as typeof logger;
scoped.warn('warned');
scoped.error('errored');
scoped.debug('debugged');
// Enabling a log category must not disturb the field prefix.
scoped.on('system-suite-category');
scoped.info('after-on');
} finally {
console.log = real.log;
console.warn = real.warn;
console.error = real.error;
console.debug = real.debug;
}
// A logger with no fields prints no prefix at all.
t.assert.deepEqual(captured[0], ['plain']);
t.assert.deepEqual(captured[1], ['[area=system-suite]', 'warned']);
t.assert.deepEqual(captured[2], ['[area=system-suite]', 'errored']);
t.assert.deepEqual(captured[3], ['[area=system-suite]', 'debugged']);
t.assert.deepEqual(captured[4], ['[area=system-suite]', 'after-on']);
},
'the developer console CTA can be silenced with puter.quiet': async (t) => {
const p = t.puter as unknown as {
quiet: boolean;
printDevCTA: () => void;
};
const wasQuiet = p.quiet;
const realLog = console.log;
let calls = 0;
try {
console.log = () => {
calls++;
};
p.quiet = false;
p.printDevCTA();
const whenLoud = calls;
calls = 0;
p.quiet = true;
p.printDevCTA();
console.log = realLog;
t.assert.ok(whenLoud > 0, 'the CTA should be printed by default');
t.assert.equal(calls, 0, 'puter.quiet must silence the CTA');
} finally {
console.log = realLog;
p.quiet = wasQuiet;
}
},
// -- GUI filesystem cache warming --
'the GUI cache warmer only runs inside the GUI for a known user': async (t) => {
const p = internals(t);
const realEnv = p.env;
const realWhoami = p.whoami;
const home = `/${t.env.users.user.username}`;
try {
p._cache.flushall();
// Outside the GUI the desktop owns no cache to warm.
p.checkAndUpdateGUIFScache();
t.assert.equal(p._cache.get(p.fsCacheKey('item', home)), undefined);
// Inside the GUI but before the user is known there is nothing
// to build the paths from.
p.env = 'gui';
p.whoami = undefined;
p.checkAndUpdateGUIFScache();
t.assert.equal(p._cache.get(p.fsCacheKey('item', home)), undefined);
} finally {
p.env = realEnv;
p.whoami = realWhoami;
}
},
'the GUI cache warmer fetches the common directories once': async (t) => {
const p = internals(t);
const realEnv = p.env;
const realWhoami = p.whoami;
const home = `/${t.env.users.user.username}`;
try {
p._cache.flushall();
p.env = 'gui';
p.whoami = { username: t.env.users.user.username };
p.checkAndUpdateGUIFScache();
const homeItem = (await settle(() =>
p._cache.get(p.fsCacheKey('item', home)),
)) as { name?: string; is_dir?: boolean } | undefined;
t.assert.ok(homeItem, 'the home item should be cached');
t.assert.equal(homeItem!.name, t.env.users.user.username);
t.assert.equal(homeItem!.is_dir, true);
const desktopItem = (await settle(() =>
p._cache.get(p.fsCacheKey('item', `${home}/Desktop`)),
)) as { name?: string } | undefined;
t.assert.equal(desktopItem?.name, 'Desktop');
const homeListing = (await settle(() =>
p._cache.get(p.fsCacheKey('readdir', home)),
)) as Array<{ name: string }> | undefined;
t.assert.ok(
Array.isArray(homeListing),
'the home listing should be cached as an array',
);
t.assert.ok(
homeListing!.some((entry) => entry.name === 'Desktop'),
'the cached home listing should contain Desktop',
);
// A second pass finds everything warm and leaves it in place.
// The seeded user has no Public folder, so this also covers the
// warmer surviving a directory it cannot read.
p.checkAndUpdateGUIFScache();
await new Promise((resolve) => setTimeout(resolve, 250));
t.assert.equal(
(p._cache.get(p.fsCacheKey('item', home)) as { name?: string }).name,
t.env.users.user.username,
);
} finally {
p.env = realEnv;
p.whoami = realWhoami;
p._cache.flushall();
}
},
// Losing connectivity purges the cache so a reconnect doesn't serve
// entries that went stale while offline. Only a browser has the
// online/offline events and the `navigator.onLine` the watcher reads.
'losing connectivity purges the filesystem cache': {
platforms: ['browser'],
fn: async (t) => {
const p = internals(t);
const home = `/${t.env.users.user.username}`;
const onLine = Object.getOwnPropertyDescriptor(
Navigator.prototype,
'onLine',
);
try {
await t.puter.fs.stat({ path: home });
t.assert.ok(
p._cache.get(p.fsCacheKey('item', home)),
'the stat should have been cached',
);
Object.defineProperty(navigator, 'onLine', {
get: () => false,
configurable: true,
});
globalThis.dispatchEvent(new Event('offline'));
t.assert.equal(
p._cache.get(p.fsCacheKey('item', home)),
undefined,
'going offline should have purged the cache',
);
} finally {
delete (navigator as unknown as Record<string, unknown>).onLine;
if (onLine) {
Object.defineProperty(Navigator.prototype, 'onLine', onLine);
}
// Restore the watcher's own view of the connection.
globalThis.dispatchEvent(new Event('online'));
}
},
},
// -- App identity --
'setAppID records the app and derives its AppData path': async (t) => {
const original = t.puter.appID;
try {
t.puter.setAppID('app-system-suite-0001');
t.assert.equal(t.puter.appID, 'app-system-suite-0001');
t.assert.equal(
t.puter.appDataPath,
'~/AppData/app-system-suite-0001',
);
} finally {
t.puter.setAppID(original as string);
}
t.assert.equal(t.puter.appID, original);
},
});