openvibers/openvibe.network Commits
Legacy identity map also records site ids reported through /internal/link-account (and seeds from existing reports)
Changes
@@@ -58,8 +58,9 @@ function backfill(db) {}/**-- * Seed the map from what Network already knows: its own ids (so resolution is uniform) and the-- * Live ids of accounts migrated from Live (users.legacy_source/legacy_id).++ * Seed the map from what Network already knows: its own ids (so resolution is uniform), the ids of++ * accounts migrated from an older platform (users.legacy_source/legacy_id), and site-local ids++ * reported through /internal/link-account.*/function seedLegacyMap(db) {const ins = db.prepare(`INSERT OR IGNORE INTO identity_legacy_map (source_system, source_type, source_id, subject_id, verified_at)@@@ -73,6 +74,12 @@ function seedLegacyMap(db) {for (const g of db.prepare('SELECT id, subject_id FROM anon_users WHERE subject_id IS NOT NULL').all()) {n += ins.run(SELF, 'anon_user', String(g.id), g.subject_id).changes;}++ // Sites that report their own local id through /internal/link-account (Live does). Rows written by++ // the OAuth exchange hold 'network:<id>' instead and say nothing about the site's ids.++ for (const l of db.prepare(`SELECT la.service, la.service_user_id, u.subject_id FROM linked_accounts la JOIN users u ON u.id = la.user_id++ WHERE u.subject_id IS NOT NULL AND la.service_user_id NOT LIKE 'network:%'`).all()) {++ if (/^[a-z][a-z0-9-]{1,39}$/.test(l.service)) n += ins.run(l.service, 'user', String(l.service_user_id), l.subject_id).changes;++ }})();return n;}@@@ -121,6 +121,15 @@ router.post('/link-account', (req, res) => {linked_at = CURRENT_TIMESTAMP`).run(user_id, service, service_user_id, service_username || null, service_user_id, service_username || null);++ // The site told us its own id for this account: record it against the canonical subject too, so++ // other services can resolve "<service> user N" without asking that site (Wave 1 legacy map).++ if (!String(service_user_id).startsWith('network:')) {++ try {++ const r = require('../identity/subjects').upsertLegacy(db, [{ network_user_id: user_id, source_system: String(service), source_type: 'user', source_id: String(service_user_id), verified: true }]);++ if (r.conflicts.length) console.warn(`[Identity] ${service} user ${service_user_id} is already mapped to another subject; not repointed`);++ } catch (err) { console.warn('[Identity] legacy map from link-account failed:', err.message); }++ }++// People set their picture on the site they use (usually Live). When the network account has none, adopt it,// so every other site shows the same face. Never overwrites a picture or name the user set here, and only// accepts https URLs on our own sites.@@@ -21,6 +21,7 @@ const quiet = console.log; console.log = () => {};db.prepare("INSERT INTO users (id, username, password_hash, created_at, legacy_source, legacy_id) VALUES (1, 'alex', 'x', '2026-01-02 03:04:05', 'live', 77)").run();db.prepare("INSERT INTO users (id, username, password_hash, created_at) VALUES (2, 'beth', 'x', '2026-05-01 00:00:00')").run();db.prepare("INSERT INTO anon_users (id, anon_number, session_token) VALUES (5, 9, 'tok')").run();++db.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id) VALUES (2, 'live', '321'), (1, 'tools', 'network:1')").run();db.exec("UPDATE users SET subject_id = NULL; UPDATE anon_users SET subject_id = NULL; DROP TABLE identity_legacy_map;");db.close();db = initDb(dbPath); // boot again: backfill + seed@@@ -46,6 +47,8 @@ assert.strictEqual(viaLive.subject.id, before, 'Live-migrated account resolves bassert.strictEqual(viaLive.username, 'alex');assert.ok(!('email' in viaLive) && !('password_hash' in viaLive), 'projection carries no private fields');assert.strictEqual(subjects.resolve(db, { subject_id: guest.subject_id }).subject.type, 'guest');++assert.strictEqual(subjects.resolve(db, { source_system: 'live', source_id: '321' }).subject.id, beth.subject_id, 'seeded from a reported Live link');++assert.strictEqual(db.prepare("SELECT COUNT(*) n FROM identity_legacy_map WHERE source_system = 'tools'").get().n, 0, "OAuth 'network:<id>' links say nothing about site ids");assert.strictEqual(subjects.resolve(db, { source_system: 'live', source_id: '999' }), null);// A row inserted by a path that forgot subject_id is fixed lazily.@@@ -76,6 +79,7 @@ assert.strictEqual(subjects.resolve(db, { source_system: 'live', source_id: '88'const app = express();app.locals.db = db;app.locals.config = { internalKey: 'k-test' };++app.use(express.json());app.use('/internal', require('../server/internal/routes'));const server = http.createServer(app);@@@ -101,6 +105,9 @@ const { signToken } = require('../server/auth/routes');assert.strictEqual(h.status, 400);h = await call('/legacy-map', { method: 'POST', body: JSON.stringify({ entries: [{ network_user_id: 3, source_system: 'live', source_id: 99 }] }) });assert.strictEqual(h.status, 200); assert.strictEqual(h.body.inserted, 1);++ h = await fetch(base.replace('/identity', '/link-account'), { method: 'POST', headers: { 'x-internal-key': 'k-test', 'content-type': 'application/json' }, body: JSON.stringify({ user_id: 3, service: 'live', service_user_id: '555' }) });++ assert.strictEqual(h.status, 200);++ assert.strictEqual(subjects.resolve(db, { source_system: 'live', source_id: '555' }).network_user_id, 3, '/internal/link-account also writes the legacy map');h = await call('/legacy-map', { method: 'POST', body: JSON.stringify({ entries: [] }) });assert.strictEqual(h.status, 400);h = await fetch(base + '/resolve?system=live&id=77').then(x => ({ status: x.status }));
@@ -58,8 +58,9 @@ function backfill(db) {
@@ -58,8 +58,9 @@ function backfill(db) {
}
}
/**
/**
- * Seed the map from what Network already knows: its own ids (so resolution is uniform) and the
- * Live ids of accounts migrated from Live (users.legacy_source/legacy_id).
+ * Seed the map from what Network already knows: its own ids (so resolution is uniform), the ids of
+ * accounts migrated from an older platform (users.legacy_source/legacy_id), and site-local ids
+ * reported through /internal/link-account.
*/
*/
function seedLegacyMap(db) {
function seedLegacyMap(db) {
const ins = db.prepare(`INSERT OR IGNORE INTO identity_legacy_map (source_system, source_type, source_id, subject_id, verified_at)
const ins = db.prepare(`INSERT OR IGNORE INTO identity_legacy_map (source_system, source_type, source_id, subject_id, verified_at)
@@ -73,6 +74,12 @@ function seedLegacyMap(db) {
@@ -73,6 +74,12 @@ function seedLegacyMap(db) {
for (const g of db.prepare('SELECT id, subject_id FROM anon_users WHERE subject_id IS NOT NULL').all()) {
for (const g of db.prepare('SELECT id, subject_id FROM anon_users WHERE subject_id IS NOT NULL').all()) {
n += ins.run(SELF, 'anon_user', String(g.id), g.subject_id).changes;
n += ins.run(SELF, 'anon_user', String(g.id), g.subject_id).changes;
}
}
+ // Sites that report their own local id through /internal/link-account (Live does). Rows written by
+ // the OAuth exchange hold 'network:<id>' instead and say nothing about the site's ids.
+ for (const l of db.prepare(`SELECT la.service, la.service_user_id, u.subject_id FROM linked_accounts la JOIN users u ON u.id = la.user_id
+ WHERE u.subject_id IS NOT NULL AND la.service_user_id NOT LIKE 'network:%'`).all()) {
+ if (/^[a-z][a-z0-9-]{1,39}$/.test(l.service)) n += ins.run(l.service, 'user', String(l.service_user_id), l.subject_id).changes;
+ }
})();
})();
return n;
return n;
}
}
@@ -121,6 +121,15 @@ router.post('/link-account', (req, res) => {
@@ -121,6 +121,15 @@ router.post('/link-account', (req, res) => {
linked_at = CURRENT_TIMESTAMP
linked_at = CURRENT_TIMESTAMP
`).run(user_id, service, service_user_id, service_username || null, service_user_id, service_username || null);
`).run(user_id, service, service_user_id, service_username || null, service_user_id, service_username || null);
+ // The site told us its own id for this account: record it against the canonical subject too, so
+ // other services can resolve "<service> user N" without asking that site (Wave 1 legacy map).
+ if (!String(service_user_id).startsWith('network:')) {
+ try {
+ const r = require('../identity/subjects').upsertLegacy(db, [{ network_user_id: user_id, source_system: String(service), source_type: 'user', source_id: String(service_user_id), verified: true }]);
+ if (r.conflicts.length) console.warn(`[Identity] ${service} user ${service_user_id} is already mapped to another subject; not repointed`);
+ } catch (err) { console.warn('[Identity] legacy map from link-account failed:', err.message); }
+ }
+
// People set their picture on the site they use (usually Live). When the network account has none, adopt it,
// People set their picture on the site they use (usually Live). When the network account has none, adopt it,
// so every other site shows the same face. Never overwrites a picture or name the user set here, and only
// so every other site shows the same face. Never overwrites a picture or name the user set here, and only
// accepts https URLs on our own sites.
// accepts https URLs on our own sites.
@@ -21,6 +21,7 @@ const quiet = console.log; console.log = () => {};
@@ -21,6 +21,7 @@ const quiet = console.log; console.log = () => {};
db.prepare("INSERT INTO users (id, username, password_hash, created_at, legacy_source, legacy_id) VALUES (1, 'alex', 'x', '2026-01-02 03:04:05', 'live', 77)").run();
db.prepare("INSERT INTO users (id, username, password_hash, created_at, legacy_source, legacy_id) VALUES (1, 'alex', 'x', '2026-01-02 03:04:05', 'live', 77)").run();
db.prepare("INSERT INTO users (id, username, password_hash, created_at) VALUES (2, 'beth', 'x', '2026-05-01 00:00:00')").run();
db.prepare("INSERT INTO users (id, username, password_hash, created_at) VALUES (2, 'beth', 'x', '2026-05-01 00:00:00')").run();
db.prepare("INSERT INTO anon_users (id, anon_number, session_token) VALUES (5, 9, 'tok')").run();
db.prepare("INSERT INTO anon_users (id, anon_number, session_token) VALUES (5, 9, 'tok')").run();
+db.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id) VALUES (2, 'live', '321'), (1, 'tools', 'network:1')").run();
db.exec("UPDATE users SET subject_id = NULL; UPDATE anon_users SET subject_id = NULL; DROP TABLE identity_legacy_map;");
db.exec("UPDATE users SET subject_id = NULL; UPDATE anon_users SET subject_id = NULL; DROP TABLE identity_legacy_map;");
db.close();
db.close();
db = initDb(dbPath); // boot again: backfill + seed
db = initDb(dbPath); // boot again: backfill + seed
@@ -46,6 +47,8 @@ assert.strictEqual(viaLive.subject.id, before, 'Live-migrated account resolves b
@@ -46,6 +47,8 @@ assert.strictEqual(viaLive.subject.id, before, 'Live-migrated account resolves b
assert.strictEqual(viaLive.username, 'alex');
assert.strictEqual(viaLive.username, 'alex');
assert.ok(!('email' in viaLive) && !('password_hash' in viaLive), 'projection carries no private fields');
assert.ok(!('email' in viaLive) && !('password_hash' in viaLive), 'projection carries no private fields');
assert.strictEqual(subjects.resolve(db, { subject_id: guest.subject_id }).subject.type, 'guest');
assert.strictEqual(subjects.resolve(db, { subject_id: guest.subject_id }).subject.type, 'guest');
+assert.strictEqual(subjects.resolve(db, { source_system: 'live', source_id: '321' }).subject.id, beth.subject_id, 'seeded from a reported Live link');
+assert.strictEqual(db.prepare("SELECT COUNT(*) n FROM identity_legacy_map WHERE source_system = 'tools'").get().n, 0, "OAuth 'network:<id>' links say nothing about site ids");
assert.strictEqual(subjects.resolve(db, { source_system: 'live', source_id: '999' }), null);
assert.strictEqual(subjects.resolve(db, { source_system: 'live', source_id: '999' }), null);
// A row inserted by a path that forgot subject_id is fixed lazily.
// A row inserted by a path that forgot subject_id is fixed lazily.
@@ -76,6 +79,7 @@ assert.strictEqual(subjects.resolve(db, { source_system: 'live', source_id: '88'
@@ -76,6 +79,7 @@ assert.strictEqual(subjects.resolve(db, { source_system: 'live', source_id: '88'
const app = express();
const app = express();
app.locals.db = db;
app.locals.db = db;
app.locals.config = { internalKey: 'k-test' };
app.locals.config = { internalKey: 'k-test' };
+app.use(express.json());
app.use('/internal', require('../server/internal/routes'));
app.use('/internal', require('../server/internal/routes'));
const server = http.createServer(app);
const server = http.createServer(app);
@@ -101,6 +105,9 @@ const { signToken } = require('../server/auth/routes');
@@ -101,6 +105,9 @@ const { signToken } = require('../server/auth/routes');
assert.strictEqual(h.status, 400);
assert.strictEqual(h.status, 400);
h = await call('/legacy-map', { method: 'POST', body: JSON.stringify({ entries: [{ network_user_id: 3, source_system: 'live', source_id: 99 }] }) });
h = await call('/legacy-map', { method: 'POST', body: JSON.stringify({ entries: [{ network_user_id: 3, source_system: 'live', source_id: 99 }] }) });
assert.strictEqual(h.status, 200); assert.strictEqual(h.body.inserted, 1);
assert.strictEqual(h.status, 200); assert.strictEqual(h.body.inserted, 1);
+ h = await fetch(base.replace('/identity', '/link-account'), { method: 'POST', headers: { 'x-internal-key': 'k-test', 'content-type': 'application/json' }, body: JSON.stringify({ user_id: 3, service: 'live', service_user_id: '555' }) });
+ assert.strictEqual(h.status, 200);
+ assert.strictEqual(subjects.resolve(db, { source_system: 'live', source_id: '555' }).network_user_id, 3, '/internal/link-account also writes the legacy map');
h = await call('/legacy-map', { method: 'POST', body: JSON.stringify({ entries: [] }) });
h = await call('/legacy-map', { method: 'POST', body: JSON.stringify({ entries: [] }) });
assert.strictEqual(h.status, 400);
assert.strictEqual(h.status, 400);
h = await fetch(base + '/resolve?system=live&id=77').then(x => ({ status: x.status }));
h = await fetch(base + '/resolve?system=live&id=77').then(x => ({ status: x.status }));