openvibers/openvibe.run Commits

Keep Actor browser sign-ins across computer sessions (#217)

OP OpenVibers committed 4 days ago · 6821f1a718 · 8 files, +208 −13

* Keep one encrypted browser profile per Actor across Run sessions

* Integrate saved Actor profiles with blind fill and reserve migration 0016

* Integrate Link address support and reserve profile migration 0017

* Keep take-over history with each Actor computer session

* Signing out of a site signs out of its subdomains

Opus's review of #217: forget() matched the cookie domain exactly, so signing out of google.com left accounts.google.com
signed in. A cookie or storage origin on the site or under it goes now; the desk's sign-out (the test's stand-in, and
OpenVibe.Node #144) does the same.

Changes

ddiff --git a/migrations/0017_actor_browser_profiles.sql b/migrations/0017_actor_browser_profiles.sql
nnew file mode 100644
iindex 0000000..6566c89
---- /dev/null
++++ b/migrations/0017_actor_browser_profiles.sql
@@@ -0,0 +1,10 @@
++-- phase: expand
++CREATE TABLE run_actor_profiles (
++ owner TEXT NOT NULL,
++ actor_id TEXT NOT NULL,
++ sealed TEXT,
++ updated_at BIGINT NOT NULL,
++ PRIMARY KEY (owner, actor_id)
++);
++ALTER TABLE run_sessions ADD COLUMN actor_id TEXT;
++CREATE UNIQUE INDEX run_one_actor_browser ON run_sessions(owner, actor_id) WHERE actor_id IS NOT NULL AND state <> 'ended';
ddiff --git a/server/account-data.js b/server/account-data.js
iindex 8398683..aa15289 100644
---- a/server/account-data.js
++++ b/server/account-data.js
@@@ -16,6 +16,7 @@
const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');
const TABLES = [
++ { table: 'run_actor_profiles', subject: 'owner', value: usr => `user:${usr}`, file: 'saved-sites.json', columns: ['actor_id', 'updated_at'], erase: 'delete' },
{
table: 'run_sessions', subject: 'owner', value: (usr) => `user:${usr}`, file: 'sessions.json', order: 'created_at',
columns: ['id', 'via', 'kind', 'state', 'width', 'height', 'ttl_s', 'idle_s', 'machine_asked', 'requirements', 'machine', 'created_at', 'started_at', 'last_used_at', 'ended_at', 'end_reason', 'end_detail', 'metered_minutes', 'updated_at'],
ddiff --git a/server/api/sessions.js b/server/api/sessions.js
iindex 43f106d..ff5ba5d 100644
---- a/server/api/sessions.js
++++ b/server/api/sessions.js
@@@ -80,9 +80,17 @@ function sessionsRouter({ sessions, verifyFillToken = async () => null }) {
r.post('/sessions', wrap(async (req, res) => {
const w = who(req, res, 'run.session.create');
if (!w) return;
-- const { row, created } = await sessions.create(w.owner, w.via, req.body && typeof req.body === 'object' ? req.body : {}, { pool: req.query.pool, strategy: req.query.strategy, template: req.query.template });
++ const { row, created } = await sessions.create(w.owner, w.via, req.body && typeof req.body === 'object' ? req.body : {}, { pool: req.query.pool, strategy: req.query.strategy, template: req.query.template, actor: req.query.actor });
res.status(created ? 201 : 200).json(sessions.present(row));
}));
++ r.get('/actor-profiles/:actor/sites', wrap(async (req, res) => {
++ const w = who(req, res, 'run.session.read');
++ if (w) res.json(await sessions.profileSites(w.owner, req.params.actor));
++ }));
++ r.delete('/actor-profiles/:actor/sites/:site', wrap(async (req, res) => {
++ const w = who(req, res, 'run.session.control');
++ if (w) res.json(await sessions.signOut(w.owner, req.params.actor, req.params.site));
++ }));
r.get('/sessions', wrap(async (req, res) => {
const w = who(req, res, 'run.session.read');
if (!w) return;
@@@ -102,6 +110,21 @@ function sessionsRouter({ sessions, verifyFillToken = async () => null }) {
if (!w) return;
res.json(await sessions.act(row, req.body && typeof req.body === 'object' ? req.body : {}, w.actor));
}));
++ // Actor proxies the person's scoped page controls; the model's commands use the normal AI holder.
++ r.post('/sessions/:id/person-commands', wrap(async (req, res) => {
++ const row = await mine(req, res, 'run.session.control');
++ if (!row) return;
++ const w = who(req, res, 'run.session.control');
++ if (w.via && w.via !== 'service:actor') return problem(res, req, 403, 'capability.denied', 'Only Actor may pass on its person’s controls.');
++ if (!req.body || req.body.kind !== 'input') return problem(res, req, 422, 'run.session.invalid', 'Use the mouse or keyboard.');
++ res.json(await sessions.act(row, req.body, { kind: 'person', subject: w.owner.slice(5) }));
++ }));
++ r.post('/sessions/:id/profile/save', wrap(async (req, res) => {
++ const row = await mine(req, res, 'run.session.control');
++ if (!row) return;
++ await sessions.saveProfile(row);
++ res.json({ saved: true });
++ }));
r.post('/sessions/:id/fill-requests', wrap(async (req, res) => {
const row = await mine(req, res, 'run.session.control', true);
if (!row) return;
ddiff --git a/server/sessions/profiles.js b/server/sessions/profiles.js
nnew file mode 100644
iindex 0000000..330e76c
---- /dev/null
++++ b/server/sessions/profiles.js
@@@ -0,0 +1,57 @@
++'use strict';
++const crypto = require('crypto');
++const { createStreamTickets } = require('../jobs/ticket');
++const error = (status, message) => Object.assign(new Error(message), { status, code: 'run.profile.unavailable' });
++function createProfiles({ config, db, now = Date.now, log = console }) {
++ const tickets = createStreamTickets({ config, log, now });
++ tickets.start();
++ // Use Run's existing durable private key; no extra secret and no plaintext profile on disk.
++ const key = !tickets.status().ephemeral && tickets.get() ? crypto.hkdfSync('sha256', tickets.get().export({ type: 'pkcs8', format: 'der' }), Buffer.from('run-actor-profiles-v1'), Buffer.from('browser-storage'), 32) : null;
++ const aad = (owner, actor) => Buffer.from(JSON.stringify([owner, actor]));
++ function seal(owner, actor, value) {
++ if (!key) throw error(503, 'Saved sign-ins need Run’s durable signing key.');
++ const bytes = Buffer.from(JSON.stringify(value));
++ if (bytes.length > 47000) throw error(422, 'Saved sign-ins exceed this computer’s limit.');
++ const nonce = crypto.randomBytes(12), cipher = crypto.createCipheriv('aes-256-gcm', key, nonce);
++ cipher.setAAD(aad(owner, actor));
++ return Buffer.concat([nonce, cipher.update(bytes), cipher.final(), cipher.getAuthTag()]).toString('base64');
++ }
++ function open(owner, actor, text) {
++ if (!text) return { cookies: [], origins: [] };
++ if (!key) throw error(503, 'Saved sign-ins need Run’s durable signing key.');
++ const bytes = Buffer.from(text, 'base64'), decipher = crypto.createDecipheriv('aes-256-gcm', key, bytes.subarray(0, 12));
++ decipher.setAAD(aad(owner, actor)); decipher.setAuthTag(bytes.subarray(-16));
++ return JSON.parse(Buffer.concat([decipher.update(bytes.subarray(12, -16)), decipher.final()]).toString());
++ }
++ async function read(owner, actor) {
++ const row = await db.maybe('SELECT sealed FROM run_actor_profiles WHERE owner=$1 AND actor_id=$2', [owner, actor]);
++ return open(owner, actor, row && row.sealed);
++ }
++ async function write(owner, actor, value) {
++ if (!value || !Array.isArray(value.cookies) || !Array.isArray(value.origins)) throw error(502, 'This computer could not save its sign-ins.');
++ await db.exec(`INSERT INTO run_actor_profiles(owner,actor_id,sealed,updated_at) VALUES($1,$2,$3,$4)
++ ON CONFLICT(owner,actor_id) DO UPDATE SET sealed=EXCLUDED.sealed,updated_at=EXCLUDED.updated_at`, [owner, actor, seal(owner, actor, value), now()]);
++ }
++ async function sites(owner, actor) {
++ const p = await read(owner, actor);
++ return [...new Set([...p.cookies.map(c => String(c.domain || '').replace(/^\./, '').toLowerCase()), ...p.origins.map(o => { try { return new URL(o.origin).hostname; } catch { return ''; } })].filter(Boolean))].sort();
++ }
++ async function forget(owner, actor, site) {
++ const p = await read(owner, actor);
++ // Signing out of a site signs out of its subdomains too (accounts.google.com when the person signs out of
++ // google.com): a cookie or a storage origin on the site or under it goes.
++ const under = (host) => { const h = String(host || '').replace(/^\./, '').toLowerCase(); return h === site || h.endsWith(`.${site}`); };
++ p.cookies = p.cookies.filter(c => !under(c.domain));
++ p.origins = p.origins.filter(o => { try { return !under(new URL(o.origin).hostname); } catch { return true; } });
++ await write(owner, actor, p);
++ }
++ async function lock(owner, actor, fn) {
++ return db.tx(async t => {
++ await t.exec('INSERT INTO run_actor_profiles(owner,actor_id,updated_at) VALUES($1,$2,$3) ON CONFLICT DO NOTHING', [owner, actor, now()]);
++ await t.one('SELECT actor_id FROM run_actor_profiles WHERE owner=$1 AND actor_id=$2 FOR UPDATE', [owner, actor]);
++ return fn(t);
++ });
++ }
++ return { lock, enabled: Boolean(key), read, write, sites, forget };
++}
++module.exports = { createProfiles };
ddiff --git a/server/sessions/service.js b/server/sessions/service.js
iindex 00db5b4..64196b3 100644
---- a/server/sessions/service.js
++++ b/server/sessions/service.js
@@@ -44,6 +44,7 @@ class SessionError extends Error {
}
function createSessions({ config, db, desks, outbox, now = () => Date.now(), log = console, broker = null, notifyFill = async () => {} }) {
++ const profiles = require('./profiles').createProfiles({ config, db, now, log });
const cfg = config.sessions || {};
const tickMs = cfg.tickMs || 2000;
const leaseCheckMs = cfg.leaseCheckMs || 30_000;
@@@ -95,13 +96,16 @@ function createSessions({ config, db, desks, outbox, now = () => Date.now(), log
const strategy = compute.strategy || null;
const template = compute.template || 'browser';
if (!['browser', 'kept-browser', 'throwaway'].includes(template)) throw new SessionError(422, 'run.session.template', 'template must be browser, kept-browser or throwaway');
-- if (template === 'kept-browser') throw new SessionError(501, 'run.session.template_unavailable', 'Kept browser profiles are not available on this machine.');
++ const actorId = compute.actor || null;
++ if (actorId && !/^act_[0-9A-HJKMNP-TV-Z]{26}$/.test(actorId)) throw new SessionError(422, 'run.profile.invalid', 'Choose a valid Actor.');
++ if (template === 'kept-browser' && (!actorId || !profiles.enabled)) throw new SessionError(503, 'run.profile.unavailable', 'Saved sign-ins need an Actor and Run’s durable signing key.');
++ if (actorId && template !== 'kept-browser') throw new SessionError(422, 'run.profile.invalid', 'An Actor uses a kept browser.');
if (strategy && !require('openvibe-sdk/compute').STRATEGIES.includes(strategy)) throw new SessionError(422, 'run.compute.strategy', 'unknown compute strategy');
if (strategy && !pool) throw new SessionError(422, 'run.compute.pool', 'a strategy requires a compute pool');
if (pool) { if (!broker) throw new SessionError(503, 'run.compute.off'); await broker.poolConfig(pool); }
const v = contracts.validate('run.session-create-request@1', body);
if (!v.valid) throw new SessionError(422, 'run.session.invalid', `invalid session request: ${(v.errors && v.errors[0] && v.errors[0].message) || 'bad value'}`.slice(0, 300));
-- const digest = store.digestOf(pool ? { ...body, compute_pool: pool, compute_strategy: strategy } : body);
++ const digest = store.digestOf({ ...body, compute_pool: pool, compute_strategy: strategy, template, actor_id: actorId });
if (body.idempotency_key) {
const earlier = await db.maybe('SELECT * FROM run_sessions WHERE owner = $1 AND idempotency_key = $2 AND created_at > $3', [owner, body.idempotency_key, now() - 86_400_000]);
if (earlier) {
@@@ -115,19 +119,28 @@ function createSessions({ config, db, desks, outbox, now = () => Date.now(), log
const row = {
id, owner, via: via || null, kind: 'browser', state: 'placing',
width: body.width || 1280, height: body.height || 800, ttl_s: body.ttl_s || 3600, idle_s: body.idle_s || 900,
-- compute_pool: pool, compute_strategy: strategy, compute_decision: null, template, machine_asked: body.machine || null, requirements: body.requirements || null, idempotency_key: body.idempotency_key || null,
++ compute_pool: pool, compute_strategy: strategy, compute_decision: null, template, actor_id: actorId, machine_asked: body.machine || null, requirements: body.requirements || null, idempotency_key: body.idempotency_key || null,
request_digest: digest, reasons: [], task_id: body.task_id || null, curtain: false,
control_lease: { thing: `run.session:${id}`, holder: { kind: 'person', subject: bare(owner) }, since_ms: at, expires_ms: at + 60_000, watchers: [], seq: 1 },
created_at: at, updated_at: at,
};
-- const out = await store.insert(db, row);
++ let out;
++ try { out = await store.insert(db, row); }
++ catch (e) { if (e.code === '23505' && actorId) throw new SessionError(409, 'run.profile.busy', 'This Actor already has an open computer.'); throw e; }
++ if (!out.row && actorId) throw new SessionError(409, 'run.profile.busy', 'This Actor already has an open computer.');
if (out.created) setImmediate(() => { tick().catch((e) => log.warn(`[Run] session tick: ${e.message}`)); });
return out;
}
-- async function finish(row, reason, detail = null, { release = true } = {}) {
++ async function finish(row, reason, detail = null, options = {}) {
++ return row && row.actor_id ? profiles.lock(row.owner, row.actor_id, async () => finishLocked(await store.get(db, row.id), reason, detail, options)) : finishLocked(row, reason, detail, options);
++ }
++ async function finishLocked(row, reason, detail = null, { release = true } = {}) {
if (!row || row.state === 'ended') return row;
if (release && row.machine && row.desk && desks.enabled) {
++ if (row.actor_id) {
++ try { await saveProfile(row, true); } catch { detail = 'The last sign-in changes could not be saved; the earlier saved copy is kept.'; }
++ }
try { await desks.end(row.machine, row.desk, bare(row.owner)); } catch (e) { log.warn(`[Run] session ${row.id}: the desk did not end cleanly (${e.code || e.message})`); }
}
return db.tx(async (t) => {
@@@ -167,6 +180,8 @@ function createSessions({ config, db, desks, outbox, now = () => Date.now(), log
contracts.assertValid('events.event-envelope@1', prepared);
await t.maybe(`INSERT INTO ${EVENTS_TABLE} (event_id, envelope, created_at) VALUES ($1, $2, $3) ON CONFLICT (event_id) DO NOTHING RETURNING id`,
[`run:${row.id}:control-lease:${lease.seq}`, JSON.stringify(prepared), now()]);
++ await t.maybe(`INSERT INTO run_session_actions (session_id,command_id,kind,action,actor_kind,actor_subject,result,created_at)
++ VALUES ($1,$2,'control',$3,$4,$5,'ack',$6) RETURNING id`, [row.id,`control:${lease.seq}`,reason,lease.holder.kind,lease.holder.subject || '',now()]);
return saved;
});
}
@@@ -247,6 +262,35 @@ function createSessions({ config, db, desks, outbox, now = () => Date.now(), log
return out;
}
++ async function profileCommand(row, action, value = {}) {
++ const result = await desks.act(row.machine, row.desk, bare(row.owner), { id: `profile-${require('crypto').randomUUID()}`, kind: 'browser', value: { action, ...value } });
++ if (!result || result.result !== 'ack') throw new SessionError(502, 'run.profile.unavailable', 'This computer could not save its sign-ins.');
++ return result.data;
++ }
++ async function saveProfile(row, locked = false) {
++ if (!row.actor_id) return;
++ if (!locked) return profiles.lock(row.owner, row.actor_id, () => saveProfile(row, true));
++ const fresh = await store.get(db, row.id);
++ if (!fresh || fresh.state === 'ended') return;
++ await profiles.write(row.owner, row.actor_id, await profileCommand(fresh, 'profile_export'));
++ }
++ async function profileSites(owner, actorId) {
++ const row = await db.maybe("SELECT * FROM run_sessions WHERE owner=$1 AND actor_id=$2 AND state='running'", [owner, actorId]);
++ if (row) await saveProfile(row);
++ return { sites: await profiles.sites(owner, actorId) };
++ }
++ async function signOut(owner, actorId, site) {
++ if (!/^[a-z0-9.-]{1,253}$/.test(site || '')) throw new SessionError(422, 'run.profile.invalid', 'Choose a site.');
++ // Lock the session row against lifecycle changes while clearing the live and saved copies.
++ return profiles.lock(owner, actorId, async t => {
++ const row = await t.maybe("SELECT * FROM run_sessions WHERE owner=$1 AND actor_id=$2 AND state<>'ended' FOR UPDATE", [owner, actorId]);
++ if (row && row.state !== 'running') throw new SessionError(409, 'run.profile.busy', 'Wait for the computer to open.');
++ if (row) { await profileCommand(row, 'profile_sign_out', { site }); await saveProfile(row, true); }
++ await profiles.forget(owner, actorId, site);
++ return { sites: await profiles.sites(owner, actorId) };
++ });
++ }
++
const EXACT_HTTPS_ORIGIN = /^https:\/\/[a-z0-9.-]+(:[0-9]{1,5})?$/;
const exactOrigin = (value) => {
try { if (typeof value !== 'string' || !EXACT_HTTPS_ORIGIN.test(value)) return null; const u = new URL(value); if (u.protocol !== 'https:' || u.origin !== value) return null; return u.origin; }
@@@ -447,6 +491,11 @@ function createSessions({ config, db, desks, outbox, now = () => Date.now(), log
continue;
}
if (!lease) { await store.update(db, row.id, { state: 'placing', machine: null, desk: null, trust: null, offer_id: null, reasons: [], updated_at: now() }); continue; }
++ if (row.actor_id) {
++ const current = await store.get(db, row.id);
++ try { await profileCommand(current, 'profile_import', { profile: await profiles.read(row.owner, row.actor_id) }); }
++ catch (e) { await desks.end(h.machine_id, desk, bare(row.owner)); return finish(current, 'unplaceable', 'Saved sign-ins could not be opened on this computer.', { release: false }); }
++ }
return db.tx(async (t) => {
const saved = await store.update(t, row.id, { state: 'running', started_at: now(), last_used_at: now(), lease_checked_at: now(), updated_at: now() });
await emit(t, saved, 'run.session.started');
@@@ -471,8 +520,10 @@ function createSessions({ config, db, desks, outbox, now = () => Date.now(), log
if (now() - row.updated_at < STARTING_STALE_MS) continue;
const lease = row.machine && row.desk ? await desks.get(row.machine, row.desk, bare(row.owner)).catch(() => undefined) : null;
if (lease === undefined) continue;
-- if (lease) await db.tx(async (t) => { const s = await store.update(t, row.id, { state: 'running', started_at: row.started_at || now(), last_used_at: now(), lease_checked_at: now(), updated_at: now() }); await emit(t, s, 'run.session.started'); });
-- else await store.update(db, row.id, { state: 'placing', machine: null, desk: null, trust: null, offer_id: null, reasons: [], updated_at: now() });
++ if (lease) {
++ if (row.actor_id) await profileCommand(row, 'profile_import', { profile: await profiles.read(row.owner, row.actor_id) });
++ await db.tx(async (t) => { const s = await store.update(t, row.id, { state: 'running', started_at: row.started_at || now(), last_used_at: now(), lease_checked_at: now(), updated_at: now() }); await emit(t, s, 'run.session.started'); });
++ } else await store.update(db, row.id, { state: 'placing', machine: null, desk: null, trust: null, offer_id: null, reasons: [], updated_at: now() });
continue;
}
// running
@@@ -482,7 +533,10 @@ function createSessions({ config, db, desks, outbox, now = () => Date.now(), log
if (now() - (row.lease_checked_at || 0) >= leaseCheckMs) {
const lease = await desks.get(row.machine, row.desk, bare(row.owner)).catch(() => undefined);
if (lease === null) { await finish(row, 'host_lost', 'its machine no longer holds the desk', { release: false }); continue; }
-- if (lease) await store.update(db, row.id, { lease_checked_at: now() });
++ if (lease) {
++ if (row.actor_id) await saveProfile(row).catch(() => log.warn('[Run] saved sign-ins could not be refreshed'));
++ await store.update(db, row.id, { lease_checked_at: now() });
++ }
}
await db.tx(async (t) => {
const fresh = await store.get(t, row.id);
@@@ -503,7 +557,7 @@ function createSessions({ config, db, desks, outbox, now = () => Date.now(), log
}
return {
-- create, act, finish, still, setCurtain, requestControl, grantControl, takeControl, controlLease, timeline, isGuest, snapshot, tick, SessionError,
++ create, act, finish, profileSites, signOut, saveProfile, still, setCurtain, requestControl, grantControl, takeControl, controlLease, timeline, isGuest, snapshot, tick, SessionError,
createFillRequest, approveFill, decideFill, listFillRequests, getFillRequest,
get: (id) => store.get(db, id),
list: (owner, opts) => store.listOf(db, owner, opts),
ddiff --git a/server/sessions/store.js b/server/sessions/store.js
iindex 0d23946..566e949 100644
---- a/server/sessions/store.js
++++ b/server/sessions/store.js
@@@ -14,7 +14,7 @@ const { ids } = require('openvibe-contracts');
const COLUMNS = ['id', 'owner', 'via', 'kind', 'state', 'width', 'height', 'ttl_s', 'idle_s', 'machine_asked', 'requirements', 'idempotency_key',
'compute_pool', 'compute_strategy', 'compute_decision', 'request_digest', 'machine', 'desk', 'trust', 'offer_id', 'reasons', 'created_at', 'started_at', 'last_used_at', 'ended_at', 'end_reason',
-- 'end_detail', 'metered_minutes', 'lease_checked_at', 'task_id', 'curtain', 'control_lease', 'template', 'egress_label', 'updated_at'];
++ 'end_detail', 'metered_minutes', 'lease_checked_at', 'task_id', 'curtain', 'control_lease', 'template', 'actor_id', 'egress_label', 'updated_at'];
const JSON_COLUMNS = new Set(['requirements', 'reasons', 'compute_decision', 'control_lease']);
const iso = (ms) => (ms == null ? null : new Date(Number(ms)).toISOString());
ddiff --git a/test/actor-profiles.test.js b/test/actor-profiles.test.js
nnew file mode 100644
iindex 0000000..209d03b
---- /dev/null
++++ b/test/actor-profiles.test.js
@@@ -0,0 +1,50 @@
++'use strict';
++const assert = require('assert');
++const { ids } = require('openvibe-contracts');
++const { boot, check, done } = require('./helpers/app');
++(async () => {
++ const machine = 'dev_01J8Z4M2Q0R7T9YV3K6N8P1W2A';
++ const browsers = new Map();
++ const desks = { enabled: true, hosts: async () => [{ machine_id: machine, trust: 'first-party', online: true, free: 4, max_browser_desks: 4 }],
++ lease: async (_m, _s, b) => { browsers.set(b.desk, { cookies: [], origins: [] }); return { desk: b.desk }; },
++ get: async (_m, d) => browsers.has(d) ? {} : null,
++ end: async (_m, d) => browsers.delete(d),
++ act: async (_m, d, _s, cmd) => {
++ if (!browsers.has(d)) return null;
++ const v = cmd.value;
++ if (v.action === 'profile_import') browsers.set(d, structuredClone(v.profile));
++ if (v.action === 'profile_sign_out') { const p = browsers.get(d); p.cookies = p.cookies.filter(c => { const h = c.domain.replace(/^\./, ''); return h !== v.site && !h.endsWith('.' + v.site); }); p.origins = p.origins.filter(o => new URL(o.origin).hostname !== v.site); }
++ return { result: 'ack', data: v.action === 'profile_export' ? structuredClone(browsers.get(d)) : { done: true } };
++ } };
++ const t = await boot({ appOpts: { desks } });
++ const owner = 'user:' + ids.newId('user'), actor = 'act_01J8Z4M2Q0R7T9YV3K6N8P1W2A';
++ let sessions = t.app.locals.sessions;
++ try {
++ await check('encrypted Actor profile survives a session and service restart; sign-out is site-specific', async () => {
++ const start = async () => { const r = await sessions.create(owner, 'service:actor', { kind: 'browser' }, { template: 'kept-browser', actor }); await sessions.tick(); return sessions.get(r.row.id); };
++ const one = await start(); assert.equal(one.state, 'running');
++ await sessions.grantControl(one,{kind:'ai',subject:'svc_actor'});
++ await sessions.takeControl(await sessions.get(one.id));
++ const history=await sessions.timeline(one);
++ assert.equal(history.actions[0].action,'owner_take_back');
++ assert.equal(history.actions[0].actor.kind,'person');
++ await assert.rejects(() => sessions.create(owner, 'service:actor', { kind: 'browser' }, { template: 'kept-browser', actor }), e => e.status === 409);
++ browsers.set(one.desk, { cookies: [{ name: 'login', value: 'synthetic-signin-a', domain: '.a.example', path: '/' }, { name: 'login', value: 'synthetic-signin-b', domain: 'b.example', path: '/' }, { name: 'sso', value: 'synthetic-signin-c', domain: 'accounts.a.example', path: '/' }], origins: [{ origin: 'https://a.example', entries: [['theme', 'dark']] }] });
++ await sessions.finish(one, 'ended');
++ const stored = await t.db.one('SELECT sealed FROM run_actor_profiles WHERE owner=$1', [owner]);
++ assert.ok(!stored.sealed.includes('synthetic-signin') && !stored.sealed.includes('a.example'));
++ sessions = require('../server/sessions/service').createSessions({ config: t.config, db: t.db, desks, outbox: t.outbox, log: { warn() {} } });
++ const two = await start();
++ assert.equal(browsers.get(two.desk).cookies.length, 3);
++ assert.deepEqual((await sessions.profileSites(owner, actor)).sites.sort(), ['a.example', 'accounts.a.example', 'b.example']);
++ assert.deepEqual(await sessions.profileSites('user:' + ids.newId('user'), actor), { sites: [] });
++ await sessions.signOut(owner, actor, 'a.example');
++ assert.deepEqual(browsers.get(two.desk).cookies.map(c => c.domain), ['b.example'], 'signing out of a.example also signs out of accounts.a.example');
++ assert.deepEqual(browsers.get(two.desk).origins, []);
++ await sessions.finish(two, 'ended');
++ const three = await start();
++ assert.deepEqual(browsers.get(three.desk).cookies.map(c => c.domain), ['b.example']);
++ await sessions.finish(three, 'ended');
++ });
++ } finally { await t.close(); }
++})().then(done).catch(e => { console.error(e); process.exit(1); });
ddiff --git a/test/sessions.test.js b/test/sessions.test.js
iindex 404565e..7b32bd6 100644
---- a/test/sessions.test.js
++++ b/test/sessions.test.js
@@@ -83,8 +83,8 @@ function fakeDesks() {
await check('browser and throwaway presets are accepted; kept profiles and snapshots say plainly when unavailable', async () => {
const unavailable = await t.call('POST', '/api/v1/sessions?template=kept-browser', { ...as(bea), body: { kind: 'browser' } });
-- assert.strictEqual(unavailable.status, 501);
-- assert.match(unavailable.json.detail, /not available on this machine/);
++ assert.strictEqual(unavailable.status, 503);
++ assert.match(unavailable.json.detail, /Saved sign-ins need/);
const throwaway = await t.call('POST', '/api/v1/sessions?template=throwaway', { ...as(bea), body: { kind: 'browser' } });
assert.strictEqual(throwaway.status, 201, throwaway.text);
await sessions.finish(await sessions.get(throwaway.json.id), 'ended', 'test complete');