openvibers/openvibe.network Commits
Add Companion blind fill authorization and targets (#697)
Changes
nnew file mode 100644@@@ -0,0 +1,4 @@++-- phase: expand++-- Companion approval uses a separate user token scope; existing families keep their old scope until re-sign-in.++UPDATE oauth_clients SET allowed_scopes = concat(allowed_scopes, ' run.fill.approve')++WHERE client_id = 'companion' AND position('run.fill.approve' in allowed_scopes) = 0;@@@ -20,7 +20,7 @@ const jwt = require('jsonwebtoken');const CLIENT_ID = 'companion';const CLIENT_NAME = 'OpenVibe extension';--const SCOPES = Object.freeze(['dock', 'activity', 'chat.read', 'agent.view', 'mail.mask.manage', 'locker.login.manage']);++const SCOPES = Object.freeze(['dock', 'activity', 'chat.read', 'agent.view', 'mail.mask.manage', 'locker.login.manage', 'run.fill.approve']);const AUDIENCES = Object.freeze(['openvibe.network', 'openvibe.chat']);const FAMILY_RE = /^fam_[0-9a-f]{24}$/;const SCOPE_RE = /^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/;@@@ -197,7 +197,7 @@ async function seedDb(db, { log = console } = {}) {// the add-on id (extension id -> getRedirectURL). Both are public values.// `agent.view` (2026-11-07) is the panel's read of Codes' agent view; Codes accepts it, and only it.{ client_id: 'companion', name: 'OpenVibe extension', public: true,-- allowed_scopes: 'dock activity chat.read agent.view mail.mask.manage locker.login.manage', access_token_ttl_s: 3600,++ allowed_scopes: 'dock activity chat.read agent.view mail.mask.manage locker.login.manage run.fill.approve', access_token_ttl_s: 3600,redirect_uris: ['https://bjcjcfcecpkkmfndegkjekoipdmbgakd.chromiumapp.org/','https://bb6593a68b1dcfbb7acd9650f04a169f4c5b57a0.extensions.allizom.org/',@@@ -420,6 +420,7 @@ const DEFAULT_GRANTS = [['watch', 'run.job.submit', 'openvibe.run', []],['watch', 'network.project.member.read', SELF_AUDIENCE, []],['run', 'network.project.member.read', SELF_AUDIENCE, []],++ ['run', 'network.notifications.push', SELF_AUDIENCE, []],// Actor and Media read a person's project role before a project admin's control request (contracts 0.169.0).['actor', 'network.project.member.read', SELF_AUDIENCE, []],['media', 'network.project.member.read', SELF_AUDIENCE, []],@@@ -864,7 +864,7 @@ require('./developer/confirmations').start(db);const locker = require('./locker/keys');// Writes come from Network itself or from an OpenVibe site's exact origin (ov-locker.js), never from *.openvibe.tools.const isFirstPartyOrigin = (o) => { const n = normalizeOriginForCors(o); return !!n && buildAllowedOriginsSet().has(n); };-- app.use('/api/v1/me/keys', locker.meRouter(requireAuth, { notifications: notificationService, isFirstPartyOrigin, requireCompanion: requireDock('locker.login.manage') }));++app.use('/api/v1/me/keys', locker.meRouter(requireAuth, { notifications: notificationService, isFirstPartyOrigin, requireCompanion: requireDock('locker.login.manage'), requireFill: requireDock('run.fill.approve') }));app.use('/api/v1/people', locker.peopleRouter(requireAuth));}@@@ -267,7 +267,7 @@ function send(req, res, fn) {/*** The person's own locker: PUT /api/v1/me/keys, GET /api/v1/me/keys/backup, DELETE /api/v1/me/keys.*/--function meRouter(requireAuth, { notifications = null, isFirstPartyOrigin = null, requireCompanion = requireAuth } = {}) {++function meRouter(requireAuth, { notifications = null, isFirstPartyOrigin = null, requireCompanion = requireAuth, requireFill = requireCompanion } = {}) {const router = express.Router();router.use(http.middleware());@@@ -280,6 +280,19 @@ function meRouter(requireAuth, { notifications = null, isFirstPartyOrigin = nullres.json({ subject: me, backups: Object.fromEntries(rows.map((row) => [row.kind, row.blob])) });}));++ // Link Send public keys for Companion's blind-fill picker. Only the person's active machines and++ // OpenVibe's own pool are eligible; project and community machine keys never leave Network.++ router.get('/fill-targets', requireFill, async (req, res) => await send(req, res, async (db) => {++ const me = await meOf(db, req.user);++ const rows = await db.prepare(`SELECT n.id, n.name, n.trust, k.public_key, k.key_thumbprint++ FROM platform_node_principals n JOIN link_send_keys k ON k.node_id = n.id++ WHERE n.status = 'active' AND ((n.owner_kind = 'user' AND n.owner_subject = ? AND n.trust = 'user-owned')++ OR (n.owner_kind = 'platform' AND n.trust = 'first-party'))++ ORDER BY n.owner_kind, n.name, n.id`).all(me);++ res.set('Cache-Control', 'private, no-store');++ res.json({ machines: rows.map((r) => ({ id: r.id, name: r.name || 'OpenVibe machine', trust: r.trust, public_key: r.public_key, thumbprint: r.key_thumbprint })) });++ }));++// Set or rotate my keys. The same keys again change nothing and announce nothing.router.put('/', requireAuth, async (req, res) => await send(req, res, async (db) => {if (crossSite(req, isFirstPartyOrigin)) throw new LockerError(403, 'locker.cross_site', 'this write came from another site');@@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };// The real directory: 0016 applies on top of 0015, once.const first = await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }));-- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand'], ['0049', 'oauth_session_public_clients', 'expand'], ['0050', 'oauth_token_session_families', 'expand'], ['0051', 'outbox_rejected_index', 'expand'], ['0052', 'public_person_pages', 'expand'], ['0053', 'link_send_relay', 'expand'], ['0054', 'account_emails', 'expand'], ['0055', 'companion_mask_scope', 'expand'], ['0056', 'companion_locker_scope', 'expand']]);++ assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand'], ['0049', 'oauth_session_public_clients', 'expand'], ['0050', 'oauth_token_session_families', 'expand'], ['0051', 'outbox_rejected_index', 'expand'], ['0052', 'public_person_pages', 'expand'], ['0053', 'link_send_relay', 'expand'], ['0054', 'account_emails', 'expand'], ['0055', 'companion_mask_scope', 'expand'], ['0056', 'companion_locker_scope', 'expand'], ['0057', 'companion_run_fill_scope', 'expand']]);assert.ok(await hasAgents());assert.deepStrictEqual((await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');// Expand only, IF NOT EXISTS throughout: running the file again changes nothing and fails nothing.@@@ -50,7 +50,7 @@ const challenge = (v) => b64(crypto.createHash('sha256').update(v).digest());Object.assign(app.locals, { db, config, privateKey: keys.privateKey, publicKey: keys.publicKey });app.use('/oauth', oauthRoutes);app.use('/api/v1/me/dock', dockMod.router({ dock, requireAuth: requireDock('dock') }));-- app.use('/api/v1/me/keys', require('../server/locker/keys').meRouter(requireAuth, { requireCompanion: requireDock('locker.login.manage') }));++ app.use('/api/v1/me/keys', require('../server/locker/keys').meRouter(requireAuth, { requireCompanion: requireDock('locker.login.manage'), requireFill: requireDock('run.fill.approve') }));app.get('/api/v1/me/private', requireAuth, (req, res) => res.json({ ok: true }));app.get('/api/v1/me/actions', requireDock('activity'), (req, res) => res.json({ ok: true, scope: req.dockScope }));app.post('/api/v1/me/actions', requireAuth, (req, res) => res.json({ ok: true }));@@@ -144,6 +144,15 @@ const challenge = (v) => b64(crypto.createHash('sha256').update(v).digest());const lockerToken = await token({ grant_type: 'authorization_code', client_id: 'companion', code: new URL(lockerCx.body.redirect).searchParams.get('code'), redirect_uri: CHROME, code_verifier: lockerVerifier });assert.strictEqual(lockerToken.status, 200);const lk = lockerToken.body.access_token;++ const fillVerifier = verifier();++ const fillCx = await confirm({ client_id: 'companion', redirect_uri: CHROME, response_type: 'code', scope: 'run.fill.approve locker.login.manage', state: 'fill-test', code_challenge: challenge(fillVerifier), code_challenge_method: 'S256' });++ assert.strictEqual(fillCx.status, 200);++ const fillToken = await token({ grant_type: 'authorization_code', client_id: 'companion', code: new URL(fillCx.body.redirect).searchParams.get('code'), redirect_uri: CHROME, code_verifier: fillVerifier });++ assert.strictEqual(fillToken.status, 200);++ assert.strictEqual(fillToken.body.scope, 'run.fill.approve locker.login.manage');++ const targets = await api('GET', '/api/v1/me/keys/fill-targets', fillToken.body.access_token);++ assert.strictEqual(targets.status, 200);++ assert.deepStrictEqual(targets.body.machines, []);assert.strictEqual((await api('GET', '/api/v1/me/keys/companion', maskToken.body.access_token)).status, 403);const backup = await api('GET', '/api/v1/me/keys/companion', lk);assert.strictEqual(backup.status, 200);@@@ -162,6 +171,7 @@ const challenge = (v) => b64(crypto.createHash('sha256').update(v).digest());assert.strictEqual((await authorize({ client_id: 'companion-other', redirect_uri: 'https://other.example/cb', response_type: 'code', state: 'o', scope: 'mail.mask.manage', code_challenge: challenge(verifier()), code_challenge_method: 'S256' })).status, 400, 'mail.mask.manage is not its scope');assert.strictEqual((await authorize({ client_id: 'companion-other', redirect_uri: 'https://other.example/cb', response_type: 'code', state: 'o', scope: 'locker.login.manage', code_challenge: challenge(verifier()), code_challenge_method: 'S256' })).status, 400, 'Locker scope belongs to Companion');++ assert.strictEqual((await authorize({ client_id: 'companion-other', redirect_uri: 'https://other.example/cb', response_type: 'code', state: 'o', scope: 'run.fill.approve', code_challenge: challenge(verifier()), code_challenge_method: 'S256' })).status, 400, 'fill approval scope belongs to Companion');// The Dock accepts it; a normal account route refuses it. Activity reads are open, action// writes are not, and a token that only has `dock` is refused the activity route.@@@ -52,7 +52,7 @@ const quiet = { log() {}, warn() {}, error() {} };// ── 0020 applies on top of 0019, once, and rewrites nothing.const first = await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }));-- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand'], ['0049', 'oauth_session_public_clients', 'expand'], ['0050', 'oauth_token_session_families', 'expand'], ['0051', 'outbox_rejected_index', 'expand'], ['0052', 'public_person_pages', 'expand'], ['0053', 'link_send_relay', 'expand'], ['0054', 'account_emails', 'expand'], ['0055', 'companion_mask_scope', 'expand'], ['0056', 'companion_locker_scope', 'expand']]);++ assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand'], ['0049', 'oauth_session_public_clients', 'expand'], ['0050', 'oauth_token_session_families', 'expand'], ['0051', 'outbox_rejected_index', 'expand'], ['0052', 'public_person_pages', 'expand'], ['0053', 'link_send_relay', 'expand'], ['0054', 'account_emails', 'expand'], ['0055', 'companion_mask_scope', 'expand'], ['0056', 'companion_locker_scope', 'expand'], ['0057', 'companion_run_fill_scope', 'expand']]);assert.deepStrictEqual((await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');await asOwner((o) => o.exec(fs.readFileSync(path.join(MIGRATIONS, '0020_user_owned_trust.sql'), 'utf8'))); // safe to run twiceassert.deepStrictEqual(await rows(), seeded, 'no existing row changes');
new file mode 100644
new file mode 100644
@@ -0,0 +1,4 @@
@@ -0,0 +1,4 @@
+-- phase: expand
+-- Companion approval uses a separate user token scope; existing families keep their old scope until re-sign-in.
+UPDATE oauth_clients SET allowed_scopes = concat(allowed_scopes, ' run.fill.approve')
+WHERE client_id = 'companion' AND position('run.fill.approve' in allowed_scopes) = 0;
@@ -20,7 +20,7 @@ const jwt = require('jsonwebtoken');
@@ -20,7 +20,7 @@ const jwt = require('jsonwebtoken');
const CLIENT_ID = 'companion';
const CLIENT_ID = 'companion';
const CLIENT_NAME = 'OpenVibe extension';
const CLIENT_NAME = 'OpenVibe extension';
-const SCOPES = Object.freeze(['dock', 'activity', 'chat.read', 'agent.view', 'mail.mask.manage', 'locker.login.manage']);
+const SCOPES = Object.freeze(['dock', 'activity', 'chat.read', 'agent.view', 'mail.mask.manage', 'locker.login.manage', 'run.fill.approve']);
const AUDIENCES = Object.freeze(['openvibe.network', 'openvibe.chat']);
const AUDIENCES = Object.freeze(['openvibe.network', 'openvibe.chat']);
const FAMILY_RE = /^fam_[0-9a-f]{24}$/;
const FAMILY_RE = /^fam_[0-9a-f]{24}$/;
const SCOPE_RE = /^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/;
const SCOPE_RE = /^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/;
@@ -197,7 +197,7 @@ async function seedDb(db, { log = console } = {}) {
@@ -197,7 +197,7 @@ async function seedDb(db, { log = console } = {}) {
// the add-on id (extension id -> getRedirectURL). Both are public values.
// the add-on id (extension id -> getRedirectURL). Both are public values.
// `agent.view` (2026-11-07) is the panel's read of Codes' agent view; Codes accepts it, and only it.
// `agent.view` (2026-11-07) is the panel's read of Codes' agent view; Codes accepts it, and only it.
{ client_id: 'companion', name: 'OpenVibe extension', public: true,
{ client_id: 'companion', name: 'OpenVibe extension', public: true,
- allowed_scopes: 'dock activity chat.read agent.view mail.mask.manage locker.login.manage', access_token_ttl_s: 3600,
+ allowed_scopes: 'dock activity chat.read agent.view mail.mask.manage locker.login.manage run.fill.approve', access_token_ttl_s: 3600,
redirect_uris: [
redirect_uris: [
'https://bjcjcfcecpkkmfndegkjekoipdmbgakd.chromiumapp.org/',
'https://bjcjcfcecpkkmfndegkjekoipdmbgakd.chromiumapp.org/',
'https://bb6593a68b1dcfbb7acd9650f04a169f4c5b57a0.extensions.allizom.org/',
'https://bb6593a68b1dcfbb7acd9650f04a169f4c5b57a0.extensions.allizom.org/',
@@ -420,6 +420,7 @@ const DEFAULT_GRANTS = [
@@ -420,6 +420,7 @@ const DEFAULT_GRANTS = [
['watch', 'run.job.submit', 'openvibe.run', []],
['watch', 'run.job.submit', 'openvibe.run', []],
['watch', 'network.project.member.read', SELF_AUDIENCE, []],
['watch', 'network.project.member.read', SELF_AUDIENCE, []],
['run', 'network.project.member.read', SELF_AUDIENCE, []],
['run', 'network.project.member.read', SELF_AUDIENCE, []],
+ ['run', 'network.notifications.push', SELF_AUDIENCE, []],
// Actor and Media read a person's project role before a project admin's control request (contracts 0.169.0).
// Actor and Media read a person's project role before a project admin's control request (contracts 0.169.0).
['actor', 'network.project.member.read', SELF_AUDIENCE, []],
['actor', 'network.project.member.read', SELF_AUDIENCE, []],
['media', 'network.project.member.read', SELF_AUDIENCE, []],
['media', 'network.project.member.read', SELF_AUDIENCE, []],
@@ -864,7 +864,7 @@ require('./developer/confirmations').start(db);
@@ -864,7 +864,7 @@ require('./developer/confirmations').start(db);
const locker = require('./locker/keys');
const locker = require('./locker/keys');
// Writes come from Network itself or from an OpenVibe site's exact origin (ov-locker.js), never from *.openvibe.tools.
// Writes come from Network itself or from an OpenVibe site's exact origin (ov-locker.js), never from *.openvibe.tools.
const isFirstPartyOrigin = (o) => { const n = normalizeOriginForCors(o); return !!n && buildAllowedOriginsSet().has(n); };
const isFirstPartyOrigin = (o) => { const n = normalizeOriginForCors(o); return !!n && buildAllowedOriginsSet().has(n); };
- app.use('/api/v1/me/keys', locker.meRouter(requireAuth, { notifications: notificationService, isFirstPartyOrigin, requireCompanion: requireDock('locker.login.manage') }));
+app.use('/api/v1/me/keys', locker.meRouter(requireAuth, { notifications: notificationService, isFirstPartyOrigin, requireCompanion: requireDock('locker.login.manage'), requireFill: requireDock('run.fill.approve') }));
app.use('/api/v1/people', locker.peopleRouter(requireAuth));
app.use('/api/v1/people', locker.peopleRouter(requireAuth));
}
}
@@ -267,7 +267,7 @@ function send(req, res, fn) {
@@ -267,7 +267,7 @@ function send(req, res, fn) {
/**
/**
* The person's own locker: PUT /api/v1/me/keys, GET /api/v1/me/keys/backup, DELETE /api/v1/me/keys.
* The person's own locker: PUT /api/v1/me/keys, GET /api/v1/me/keys/backup, DELETE /api/v1/me/keys.
*/
*/
-function meRouter(requireAuth, { notifications = null, isFirstPartyOrigin = null, requireCompanion = requireAuth } = {}) {
+function meRouter(requireAuth, { notifications = null, isFirstPartyOrigin = null, requireCompanion = requireAuth, requireFill = requireCompanion } = {}) {
const router = express.Router();
const router = express.Router();
router.use(http.middleware());
router.use(http.middleware());
@@ -280,6 +280,19 @@ function meRouter(requireAuth, { notifications = null, isFirstPartyOrigin = null
@@ -280,6 +280,19 @@ function meRouter(requireAuth, { notifications = null, isFirstPartyOrigin = null
res.json({ subject: me, backups: Object.fromEntries(rows.map((row) => [row.kind, row.blob])) });
res.json({ subject: me, backups: Object.fromEntries(rows.map((row) => [row.kind, row.blob])) });
}));
}));
+ // Link Send public keys for Companion's blind-fill picker. Only the person's active machines and
+ // OpenVibe's own pool are eligible; project and community machine keys never leave Network.
+ router.get('/fill-targets', requireFill, async (req, res) => await send(req, res, async (db) => {
+ const me = await meOf(db, req.user);
+ const rows = await db.prepare(`SELECT n.id, n.name, n.trust, k.public_key, k.key_thumbprint
+ FROM platform_node_principals n JOIN link_send_keys k ON k.node_id = n.id
+ WHERE n.status = 'active' AND ((n.owner_kind = 'user' AND n.owner_subject = ? AND n.trust = 'user-owned')
+ OR (n.owner_kind = 'platform' AND n.trust = 'first-party'))
+ ORDER BY n.owner_kind, n.name, n.id`).all(me);
+ res.set('Cache-Control', 'private, no-store');
+ res.json({ machines: rows.map((r) => ({ id: r.id, name: r.name || 'OpenVibe machine', trust: r.trust, public_key: r.public_key, thumbprint: r.key_thumbprint })) });
+ }));
+
// Set or rotate my keys. The same keys again change nothing and announce nothing.
// Set or rotate my keys. The same keys again change nothing and announce nothing.
router.put('/', requireAuth, async (req, res) => await send(req, res, async (db) => {
router.put('/', requireAuth, async (req, res) => await send(req, res, async (db) => {
if (crossSite(req, isFirstPartyOrigin)) throw new LockerError(403, 'locker.cross_site', 'this write came from another site');
if (crossSite(req, isFirstPartyOrigin)) throw new LockerError(403, 'locker.cross_site', 'this write came from another site');
@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };
@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// The real directory: 0016 applies on top of 0015, once.
// The real directory: 0016 applies on top of 0015, once.
const first = await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }));
const first = await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }));
- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand'], ['0049', 'oauth_session_public_clients', 'expand'], ['0050', 'oauth_token_session_families', 'expand'], ['0051', 'outbox_rejected_index', 'expand'], ['0052', 'public_person_pages', 'expand'], ['0053', 'link_send_relay', 'expand'], ['0054', 'account_emails', 'expand'], ['0055', 'companion_mask_scope', 'expand'], ['0056', 'companion_locker_scope', 'expand']]);
+ assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand'], ['0049', 'oauth_session_public_clients', 'expand'], ['0050', 'oauth_token_session_families', 'expand'], ['0051', 'outbox_rejected_index', 'expand'], ['0052', 'public_person_pages', 'expand'], ['0053', 'link_send_relay', 'expand'], ['0054', 'account_emails', 'expand'], ['0055', 'companion_mask_scope', 'expand'], ['0056', 'companion_locker_scope', 'expand'], ['0057', 'companion_run_fill_scope', 'expand']]);
assert.ok(await hasAgents());
assert.ok(await hasAgents());
assert.deepStrictEqual((await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
assert.deepStrictEqual((await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
// Expand only, IF NOT EXISTS throughout: running the file again changes nothing and fails nothing.
// Expand only, IF NOT EXISTS throughout: running the file again changes nothing and fails nothing.
@@ -50,7 +50,7 @@ const challenge = (v) => b64(crypto.createHash('sha256').update(v).digest());
@@ -50,7 +50,7 @@ const challenge = (v) => b64(crypto.createHash('sha256').update(v).digest());
Object.assign(app.locals, { db, config, privateKey: keys.privateKey, publicKey: keys.publicKey });
Object.assign(app.locals, { db, config, privateKey: keys.privateKey, publicKey: keys.publicKey });
app.use('/oauth', oauthRoutes);
app.use('/oauth', oauthRoutes);
app.use('/api/v1/me/dock', dockMod.router({ dock, requireAuth: requireDock('dock') }));
app.use('/api/v1/me/dock', dockMod.router({ dock, requireAuth: requireDock('dock') }));
- app.use('/api/v1/me/keys', require('../server/locker/keys').meRouter(requireAuth, { requireCompanion: requireDock('locker.login.manage') }));
+ app.use('/api/v1/me/keys', require('../server/locker/keys').meRouter(requireAuth, { requireCompanion: requireDock('locker.login.manage'), requireFill: requireDock('run.fill.approve') }));
app.get('/api/v1/me/private', requireAuth, (req, res) => res.json({ ok: true }));
app.get('/api/v1/me/private', requireAuth, (req, res) => res.json({ ok: true }));
app.get('/api/v1/me/actions', requireDock('activity'), (req, res) => res.json({ ok: true, scope: req.dockScope }));
app.get('/api/v1/me/actions', requireDock('activity'), (req, res) => res.json({ ok: true, scope: req.dockScope }));
app.post('/api/v1/me/actions', requireAuth, (req, res) => res.json({ ok: true }));
app.post('/api/v1/me/actions', requireAuth, (req, res) => res.json({ ok: true }));
@@ -144,6 +144,15 @@ const challenge = (v) => b64(crypto.createHash('sha256').update(v).digest());
@@ -144,6 +144,15 @@ const challenge = (v) => b64(crypto.createHash('sha256').update(v).digest());
const lockerToken = await token({ grant_type: 'authorization_code', client_id: 'companion', code: new URL(lockerCx.body.redirect).searchParams.get('code'), redirect_uri: CHROME, code_verifier: lockerVerifier });
const lockerToken = await token({ grant_type: 'authorization_code', client_id: 'companion', code: new URL(lockerCx.body.redirect).searchParams.get('code'), redirect_uri: CHROME, code_verifier: lockerVerifier });
assert.strictEqual(lockerToken.status, 200);
assert.strictEqual(lockerToken.status, 200);
const lk = lockerToken.body.access_token;
const lk = lockerToken.body.access_token;
+ const fillVerifier = verifier();
+ const fillCx = await confirm({ client_id: 'companion', redirect_uri: CHROME, response_type: 'code', scope: 'run.fill.approve locker.login.manage', state: 'fill-test', code_challenge: challenge(fillVerifier), code_challenge_method: 'S256' });
+ assert.strictEqual(fillCx.status, 200);
+ const fillToken = await token({ grant_type: 'authorization_code', client_id: 'companion', code: new URL(fillCx.body.redirect).searchParams.get('code'), redirect_uri: CHROME, code_verifier: fillVerifier });
+ assert.strictEqual(fillToken.status, 200);
+ assert.strictEqual(fillToken.body.scope, 'run.fill.approve locker.login.manage');
+ const targets = await api('GET', '/api/v1/me/keys/fill-targets', fillToken.body.access_token);
+ assert.strictEqual(targets.status, 200);
+ assert.deepStrictEqual(targets.body.machines, []);
assert.strictEqual((await api('GET', '/api/v1/me/keys/companion', maskToken.body.access_token)).status, 403);
assert.strictEqual((await api('GET', '/api/v1/me/keys/companion', maskToken.body.access_token)).status, 403);
const backup = await api('GET', '/api/v1/me/keys/companion', lk);
const backup = await api('GET', '/api/v1/me/keys/companion', lk);
assert.strictEqual(backup.status, 200);
assert.strictEqual(backup.status, 200);
@@ -162,6 +171,7 @@ const challenge = (v) => b64(crypto.createHash('sha256').update(v).digest());
@@ -162,6 +171,7 @@ const challenge = (v) => b64(crypto.createHash('sha256').update(v).digest());
assert.strictEqual((await authorize({ client_id: 'companion-other', redirect_uri: 'https://other.example/cb', response_type: 'code', state: 'o', scope: 'mail.mask.manage', code_challenge: challenge(verifier()), code_challenge_method: 'S256' })).status, 400, 'mail.mask.manage is not its scope');
assert.strictEqual((await authorize({ client_id: 'companion-other', redirect_uri: 'https://other.example/cb', response_type: 'code', state: 'o', scope: 'mail.mask.manage', code_challenge: challenge(verifier()), code_challenge_method: 'S256' })).status, 400, 'mail.mask.manage is not its scope');
assert.strictEqual((await authorize({ client_id: 'companion-other', redirect_uri: 'https://other.example/cb', response_type: 'code', state: 'o', scope: 'locker.login.manage', code_challenge: challenge(verifier()), code_challenge_method: 'S256' })).status, 400, 'Locker scope belongs to Companion');
assert.strictEqual((await authorize({ client_id: 'companion-other', redirect_uri: 'https://other.example/cb', response_type: 'code', state: 'o', scope: 'locker.login.manage', code_challenge: challenge(verifier()), code_challenge_method: 'S256' })).status, 400, 'Locker scope belongs to Companion');
+ assert.strictEqual((await authorize({ client_id: 'companion-other', redirect_uri: 'https://other.example/cb', response_type: 'code', state: 'o', scope: 'run.fill.approve', code_challenge: challenge(verifier()), code_challenge_method: 'S256' })).status, 400, 'fill approval scope belongs to Companion');
// The Dock accepts it; a normal account route refuses it. Activity reads are open, action
// The Dock accepts it; a normal account route refuses it. Activity reads are open, action
// writes are not, and a token that only has `dock` is refused the activity route.
// writes are not, and a token that only has `dock` is refused the activity route.
@@ -52,7 +52,7 @@ const quiet = { log() {}, warn() {}, error() {} };
@@ -52,7 +52,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// ── 0020 applies on top of 0019, once, and rewrites nothing.
// ── 0020 applies on top of 0019, once, and rewrites nothing.
const first = await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }));
const first = await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }));
- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand'], ['0049', 'oauth_session_public_clients', 'expand'], ['0050', 'oauth_token_session_families', 'expand'], ['0051', 'outbox_rejected_index', 'expand'], ['0052', 'public_person_pages', 'expand'], ['0053', 'link_send_relay', 'expand'], ['0054', 'account_emails', 'expand'], ['0055', 'companion_mask_scope', 'expand'], ['0056', 'companion_locker_scope', 'expand']]);
+ assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand'], ['0049', 'oauth_session_public_clients', 'expand'], ['0050', 'oauth_token_session_families', 'expand'], ['0051', 'outbox_rejected_index', 'expand'], ['0052', 'public_person_pages', 'expand'], ['0053', 'link_send_relay', 'expand'], ['0054', 'account_emails', 'expand'], ['0055', 'companion_mask_scope', 'expand'], ['0056', 'companion_locker_scope', 'expand'], ['0057', 'companion_run_fill_scope', 'expand']]);
assert.deepStrictEqual((await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
assert.deepStrictEqual((await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
await asOwner((o) => o.exec(fs.readFileSync(path.join(MIGRATIONS, '0020_user_owned_trust.sql'), 'utf8'))); // safe to run twice
await asOwner((o) => o.exec(fs.readFileSync(path.join(MIGRATIONS, '0020_user_owned_trust.sql'), 'utf8'))); // safe to run twice
assert.deepStrictEqual(await rows(), seeded, 'no existing row changes');
assert.deepStrictEqual(await rows(), seeded, 'no existing row changes');