openvibers/openvibe.network Commits
Canonical subject ids: usr_/gst_ ULIDs alongside integer ids, identity_legacy_map, /internal/identity, subject_id in tokens
Changes
@@@ -39,6 +39,12 @@ git pull "$GIT_REMOTE" "$GIT_BRANCH" --ff-onlyNEW_HASH=$(git rev-parse HEAD)echo "[Deploy] New commit: ${NEW_HASH:0:8}"++# 2b. Install dependencies when the lockfile changed (e.g. a new openvibe-contracts release).++if [ "$OLD_HASH" = "none" ] || ! git diff --quiet "$OLD_HASH" "$NEW_HASH" -- package-lock.json; then++ echo "[Deploy] package-lock.json changed: npm ci --omit=dev"++ npm ci --omit=dev --no-audit --no-fund++fi++# 3. Update systemd service config if the repo includes oneUNIT_UPDATED=falseif [ -f "$SERVICE_UNIT_SOURCE" ]; then@@@ -20,6 +20,7 @@"helmet": "^8.1.0","jsonwebtoken": "^9.0.0","multer": "^1.4.5-lts.1",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.1.0","openvibe-shared": "file:./packages/openvibe-shared","uuid": "^11.1.0","web-push": "^3.6.7"@@@ -238,6 +239,39 @@"node": ">= 14"}},++ "node_modules/ajv": {++ "version": "8.20.0",++ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",++ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",++ "license": "MIT",++ "dependencies": {++ "fast-deep-equal": "^3.1.3",++ "fast-uri": "^3.0.1",++ "json-schema-traverse": "^1.0.0",++ "require-from-string": "^2.0.2"++ },++ "funding": {++ "type": "github",++ "url": "https://github.com/sponsors/epoberezkin"++ }++ },++ "node_modules/ajv-formats": {++ "version": "3.0.1",++ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",++ "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",++ "license": "MIT",++ "dependencies": {++ "ajv": "^8.0.0"++ },++ "peerDependencies": {++ "ajv": "^8.0.0"++ },++ "peerDependenciesMeta": {++ "ajv": {++ "optional": true++ }++ }++ },"node_modules/append-field": {"version": "1.0.0","resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",@@@ -818,6 +852,22 @@"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==","license": "MIT"},++ "node_modules/fast-uri": {++ "version": "3.1.8",++ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",++ "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/fastify"++ },++ {++ "type": "opencollective",++ "url": "https://opencollective.com/fastify"++ }++ ],++ "license": "BSD-3-Clause"++ },"node_modules/file-uri-to-path": {"version": "1.0.0","resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",@@@ -1090,6 +1140,12 @@"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==","license": "MIT"},++ "node_modules/json-schema-traverse": {++ "version": "1.0.0",++ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",++ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",++ "license": "MIT"++ },"node_modules/jsonwebtoken": {"version": "9.0.3","resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",@@@ -1407,6 +1463,19 @@"wrappy": "1"}},++ "node_modules/openvibe-contracts": {++ "version": "0.1.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.1.0",++ "integrity": "sha512-HfrmCKJe6WuleFUo2FGBQl+6ABEh07gxyH+QG+n42TK/nRBuSPo5jbt7FMAvrwB1JlQ4BbqfNbcoTU02qt8Jsw==",++ "license": "AGPL-3.0-only",++ "dependencies": {++ "ajv": "^8.17.1",++ "ajv-formats": "^3.0.1"++ },++ "engines": {++ "node": ">=20"++ }++ },"node_modules/openvibe-shared": {"resolved": "packages/openvibe-shared","link": true@@@ -1558,6 +1627,15 @@"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==","license": "MIT"},++ "node_modules/require-from-string": {++ "version": "2.0.2",++ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",++ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",++ "license": "MIT",++ "engines": {++ "node": ">=0.10.0"++ }++ },"node_modules/safe-buffer": {"version": "5.2.1","resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",@@@ -1,12 +1,12 @@{"name": "openvibe-network","version": "1.0.0",-- "description": "OpenVibe.Network — Identity, SSO, Accounts, Themes, Notifications, and OpenCoins Wallet",++ "description": "OpenVibe.Network \u2014 Identity, SSO, Accounts, Themes, Notifications, and OpenCoins Wallet","main": "server/index.js","scripts": {"start": "node server/index.js","dev": "NODE_ENV=development node server/index.js",-- "test": "node test/registry-bootstrap.test.js && node packages/openvibe-shared/test/theme-parity.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node packages/openvibe-shared/test/url-resolver.test.js && node packages/openvibe-shared/test/url-resolver-deploy.test.js && node packages/openvibe-shared/test/navbar-auth.test.js && node packages/openvibe-shared/test/navbar-brand.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node packages/openvibe-shared/test/shared-modules.test.js && node test/chrome.test.js && node packages/openvibe-shared/test/panels.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js",++ "test": "node test/registry-bootstrap.test.js && node packages/openvibe-shared/test/theme-parity.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node packages/openvibe-shared/test/url-resolver.test.js && node packages/openvibe-shared/test/url-resolver-deploy.test.js && node packages/openvibe-shared/test/navbar-auth.test.js && node packages/openvibe-shared/test/navbar-brand.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node packages/openvibe-shared/test/shared-modules.test.js && node test/chrome.test.js && node packages/openvibe-shared/test/panels.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js && node test/identity-subjects.test.js","reset-db": "node server/reset-db.js","grant-admin": "node server/grant-admin.js"},@@@ -21,9 +21,10 @@"express": "^4.21.2","express-rate-limit": "^7.5.0","helmet": "^8.1.0",-- "openvibe-shared": "file:./packages/openvibe-shared","jsonwebtoken": "^9.0.0","multer": "^1.4.5-lts.1",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.1.0",++ "openvibe-shared": "file:./packages/openvibe-shared","uuid": "^11.1.0","web-push": "^3.6.7"}@@@ -53,7 +53,7 @@ function accountOf(user, base) {/** Sign the FedCM assertion for one RP. */function signAssertion(user, origin, nonce, ctx) {return jwt.sign(-- { sub: user.id, id: user.id, username: user.username, display_name: user.display_name || user.username, avatar_url: user.avatar_url || null, nonce: nonce || null, typ: 'fedcm', jti: crypto.randomBytes(16).toString('hex') },++ { sub: user.id, id: user.id, subject_id: user.subject_id || undefined, username: user.username, display_name: user.display_name || user.username, avatar_url: user.avatar_url || null, nonce: nonce || null, typ: 'fedcm', jti: crypto.randomBytes(16).toString('hex') },ctx.privateKey,{ algorithm: ctx.privateKey.includes('BEGIN') ? 'RS256' : 'HS256', issuer: ctx.config.jwt.issuer, audience: origin, expiresIn: ASSERTION_TTL_S });@@@ -8,6 +8,7 @@const express = require('express');const crypto = require('crypto');const jwt = require('jsonwebtoken');++const subjects = require('../identity/subjects');const router = express.Router();function getDb(req) { return req.app.locals.db; }@@@ -274,6 +275,7 @@ function issueTokenPair(db, config, req, user, client) {{sub: user.id,id: user.id,++ subject_id: subjects.ensureUserSubject(db, user) || undefined,username: user.username,display_name: user.display_name || user.username,role: user.role,@@@ -10,6 +10,7 @@ const bcrypt = require('bcryptjs');const crypto = require('crypto');const jwt = require('jsonwebtoken');const { v4: uuidv4 } = require('uuid');++const subjects = require('../identity/subjects');const router = express.Router();@@@ -55,6 +56,7 @@ function signToken(user, privateKey, config) {{sub: user.id,id: user.id,++ subject_id: user.subject_id || undefined,username: user.username,display_name: user.display_name || user.username,role: user.role,@@@ -141,9 +143,9 @@ router.post('/register', (req, res) => {const passwordHash = bcrypt.hashSync(password, 10);const result = db.prepare(`-- INSERT INTO users (username, email, password_hash, display_name, profile_color)-- VALUES (?, ?, ?, ?, '#8b5cf6')-- `).run(username, email || null, passwordHash, username);++ INSERT INTO users (username, email, password_hash, display_name, profile_color, subject_id)++ VALUES (?, ?, ?, ?, '#8b5cf6', ?)++ `).run(username, email || null, passwordHash, username, subjects.newUserSubjectId());// Create default preferencesdb.prepare('INSERT OR IGNORE INTO user_preferences (user_id) VALUES (?)').run(result.lastInsertRowid);@@@ -362,6 +364,7 @@ router.post('/refresh', (req, res) => {router.get('/me', requireAuth, (req, res) => {const db = getDb(req);const prefs = db.prepare('SELECT * FROM user_preferences WHERE user_id = ?').get(req.user.id);++ subjects.ensureUserSubject(db, req.user);res.json({user: sanitizeUser(req.user),preferences: prefs || { theme_id: 'vibe' },@@@ -553,8 +556,8 @@ router.post('/anon-session', (req, res) => {const anonNumber = maxNum + 1;const result = db.prepare(-- 'INSERT INTO anon_users (anon_number, fingerprint, session_token, ip) VALUES (?, ?, ?, ?)'-- ).run(anonNumber, fingerprint, sessionToken, ip);++ 'INSERT INTO anon_users (anon_number, fingerprint, session_token, ip, subject_id) VALUES (?, ?, ?, ?, ?)'++ ).run(anonNumber, fingerprint, sessionToken, ip, subjects.newGuestSubjectId());logAnonIp(db, result.lastInsertRowid, ip);@@@ -491,6 +491,9 @@ function initDb(dbPath) {// Cross-site history can be paused by the user (server/history/routes.js).{ table: 'user_preferences', column: 'display_prefs', sql: "ALTER TABLE user_preferences ADD COLUMN display_prefs TEXT" },{ table: 'users', column: 'history_paused', sql: "ALTER TABLE users ADD COLUMN history_paused INTEGER DEFAULT 0" },++ // Canonical subject ids (roadmap Wave 1): usr_/gst_ ULIDs alongside the integer ids.++ { table: 'users', column: 'subject_id', sql: "ALTER TABLE users ADD COLUMN subject_id TEXT" },++ { table: 'anon_users', column: 'subject_id', sql: "ALTER TABLE anon_users ADD COLUMN subject_id TEXT" },];for (const m of migrations) {const cols = db.prepare(`PRAGMA table_info(${m.table})`).all();@@@ -778,6 +781,9 @@ function initDb(dbPath) {return !!vk;};++ // Subject ids + identity_legacy_map (server/identity/subjects.js): backfill and seed, idempotent.++ require('../identity/subjects').ensureSchema(db);++console.log('[DB] Central database initialized');return db;}nnew file mode 100644@@@ -0,0 +1,40 @@++'use strict';++/**++ * /internal/identity/* — subject resolution for first-party services (behind X-Internal-Key,++ * mounted by server/internal/routes.js). Errors are RFC 9457 problems (errors.problem@1).++ *++ * GET /internal/identity/resolve?subject_id=usr_...++ * GET /internal/identity/resolve?system=live&type=user&id=123++ * POST /internal/identity/legacy-map { entries: [{ network_user_id | subject_id, source_system, source_type, source_id, verified? }] }++ */++const express = require('express');++const { http } = require('openvibe-contracts');++const subjects = require('./subjects');++++const router = express.Router();++router.use(http.middleware());++const MAX_ENTRIES = 1000;++++router.get('/resolve', (req, res) => {++ const db = req.app.locals.db;++ const q = req.query;++ if (!q.subject_id && !(q.system && q.id)) {++ return http.sendProblem(res, 400, 'identity.bad_request', { detail: 'pass subject_id, or system + id (+ type, default user)', ctx: req.ov });++ }++ const out = subjects.resolve(db, { subject_id: q.subject_id, source_system: q.system, source_type: q.type || 'user', source_id: q.id });++ if (!out) return http.sendProblem(res, 404, 'identity.subject_not_found', { detail: 'no subject for that id', ctx: req.ov });++ res.json(out);++});++++router.post('/legacy-map', express.json({ limit: '512kb' }), (req, res) => {++ const entries = req.body && req.body.entries;++ if (!Array.isArray(entries) || !entries.length) {++ return http.sendProblem(res, 400, 'identity.bad_request', { detail: 'entries must be a non-empty array', ctx: req.ov });++ }++ if (entries.length > MAX_ENTRIES) {++ return http.sendProblem(res, 413, 'identity.too_many_entries', { detail: `at most ${MAX_ENTRIES} entries per call`, ctx: req.ov });++ }++ res.json(subjects.upsertLegacy(req.app.locals.db, entries));++});++++module.exports = router;nnew file mode 100644@@@ -0,0 +1,177 @@++'use strict';++/**++ * Canonical subjects (roadmap Wave 1, ADR-001; contract identity.subject-ref@1).++ *++ * Every Network account gets a stable `subject_id` (usr_<ULID>) and every anonymous session a++ * `gst_<ULID>`. They are added ALONGSIDE the integer ids, which stay the primary keys and stay in++ * tokens as `sub`/`id`; nothing that reads those today changes.++ *++ * identity_legacy_map links service-local ids (Live's users.id, Media's user_id, Games players, ...)++ * to a subject, so a service can ask "who is live user 123?" without calling Live. A row is never++ * repointed to a different subject: a conflicting write is reported, not applied.++ */++const { ids, validate } = require('openvibe-contracts');++++const SELF = 'network';++++/** SQLite CURRENT_TIMESTAMP ('YYYY-MM-DD HH:MM:SS', UTC) -> ms, so backfilled ULIDs sort by account age. */++function createdMs(value) {++ const t = value ? Date.parse(String(value).replace(' ', 'T') + (/[zZ]|[+-]\d\d:?\d\d$/.test(value) ? '' : 'Z')) : NaN;++ return Number.isFinite(t) ? t : Date.now();++}++++function ensureSchema(db) {++ db.exec(`++ CREATE TABLE IF NOT EXISTS identity_legacy_map (++ source_system TEXT NOT NULL,++ source_type TEXT NOT NULL,++ source_id TEXT NOT NULL,++ subject_id TEXT NOT NULL,++ first_seen_at DATETIME DEFAULT CURRENT_TIMESTAMP,++ verified_at DATETIME,++ metadata TEXT,++ PRIMARY KEY (source_system, source_type, source_id)++ );++ CREATE INDEX IF NOT EXISTS idx_identity_legacy_subject ON identity_legacy_map(subject_id);++ CREATE UNIQUE INDEX IF NOT EXISTS idx_users_subject_id ON users(subject_id);++ CREATE UNIQUE INDEX IF NOT EXISTS idx_anon_users_subject_id ON anon_users(subject_id);++ `);++ const filled = backfill(db);++ const seeded = seedLegacyMap(db);++ if (filled.users || filled.guests || seeded) {++ console.log(`[Identity] subject ids: ${filled.users} users, ${filled.guests} guests assigned; ${seeded} legacy map rows seeded`);++ }++}++++/** Give every row without a subject id one. Idempotent; runs at boot. */++function backfill(db) {++ const out = { users: 0, guests: 0 };++ const run = db.transaction(() => {++ for (const [table, kind, key, born] of [['users', 'user', 'users', 'created_at'], ['anon_users', 'guest', 'guests', 'first_seen']]) {++ const rows = db.prepare(`SELECT id, ${born} AS created_at FROM ${table} WHERE subject_id IS NULL`).all();++ const set = db.prepare(`UPDATE ${table} SET subject_id = ? WHERE id = ? AND subject_id IS NULL`);++ for (const r of rows) out[key] += set.run(ids.newId(kind, createdMs(r.created_at)), r.id).changes;++ }++ });++ run();++ return out;++}++++/**++ * 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).++ */++function seedLegacyMap(db) {++ const ins = db.prepare(`INSERT OR IGNORE INTO identity_legacy_map (source_system, source_type, source_id, subject_id, verified_at)++ VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)`);++ let n = 0;++ db.transaction(() => {++ for (const u of db.prepare('SELECT id, subject_id, legacy_source, legacy_id FROM users WHERE subject_id IS NOT NULL').all()) {++ n += ins.run(SELF, 'user', String(u.id), u.subject_id).changes;++ if (u.legacy_source && u.legacy_id != null) n += ins.run(String(u.legacy_source), 'user', String(u.legacy_id), u.subject_id).changes;++ }++ 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;++ }++ })();++ return n;++}++++/** Subject id for a users row, assigning one if an insert path skipped it. */++function ensureUserSubject(db, user) {++ if (!user) return null;++ if (user.subject_id) return user.subject_id;++ const row = db.prepare('SELECT subject_id, created_at FROM users WHERE id = ?').get(user.id);++ if (!row) return null;++ if (!row.subject_id) {++ const sid = ids.newId('user', createdMs(row.created_at));++ db.prepare('UPDATE users SET subject_id = ? WHERE id = ? AND subject_id IS NULL').run(sid, user.id);++ db.prepare(`INSERT OR IGNORE INTO identity_legacy_map (source_system, source_type, source_id, subject_id, verified_at)++ VALUES (?, 'user', ?, ?, CURRENT_TIMESTAMP)`).run(SELF, String(user.id), sid);++ row.subject_id = db.prepare('SELECT subject_id FROM users WHERE id = ?').get(user.id).subject_id;++ }++ user.subject_id = row.subject_id;++ return row.subject_id;++}++++/** New subject id for an insert: `INSERT INTO users (..., subject_id) VALUES (..., newUserSubjectId())`. */++const newUserSubjectId = () => ids.newId('user');++const newGuestSubjectId = () => ids.newId('guest');++++/** Public projection of a subject. Never includes email, role internals or secrets. */++function projection(db, subjectId) {++ if (typeof subjectId !== 'string') return null;++ if (subjectId.startsWith('usr_')) {++ const u = db.prepare('SELECT id, subject_id, username, display_name, avatar_url, is_banned FROM users WHERE subject_id = ?').get(subjectId);++ if (!u) return null;++ return { subject: { type: 'user', id: u.subject_id }, network_user_id: u.id, username: u.username,++ display_name: u.display_name || u.username, avatar_url: u.avatar_url || null, banned: Boolean(u.is_banned) };++ }++ if (subjectId.startsWith('gst_')) {++ const g = db.prepare('SELECT id, subject_id, anon_number, display_name FROM anon_users WHERE subject_id = ?').get(subjectId);++ if (!g) return null;++ return { subject: { type: 'guest', id: g.subject_id }, network_anon_id: g.id, username: `anon${g.anon_number}`,++ display_name: g.display_name || `anon${g.anon_number}`, avatar_url: null, banned: false };++ }++ return null;++}++++/** Resolve by subject id or by (system, type, id). Returns the projection plus the matching legacy ids. */++function resolve(db, { subject_id, source_system, source_type, source_id }) {++ let sid = subject_id;++ if (!sid) {++ if (source_system === SELF && source_type === 'user') {++ const u = db.prepare('SELECT id, subject_id, created_at FROM users WHERE id = ?').get(Number(source_id));++ sid = u ? ensureUserSubject(db, u) : null;++ } else {++ const row = db.prepare('SELECT subject_id FROM identity_legacy_map WHERE source_system = ? AND source_type = ? AND source_id = ?')++ .get(String(source_system), String(source_type || 'user'), String(source_id));++ sid = row && row.subject_id;++ }++ }++ const p = sid ? projection(db, sid) : null;++ if (!p) return null;++ p.legacy_ids = db.prepare('SELECT source_system, source_type, source_id FROM identity_legacy_map WHERE subject_id = ? ORDER BY source_system, source_type')++ .all(p.subject.id);++ return p;++}++++/**++ * Record service-local ids for existing subjects. Each entry names the Network account either by++ * `subject_id` or `network_user_id` (what services store today). Rows for source_system 'network'++ * are refused: Network is the authority for its own ids.++ * Returns { inserted, unchanged, conflicts: [...], rejected: [...] }.++ */++function upsertLegacy(db, entries) {++ const out = { inserted: 0, unchanged: 0, conflicts: [], rejected: [] };++ const get = db.prepare('SELECT subject_id FROM identity_legacy_map WHERE source_system = ? AND source_type = ? AND source_id = ?');++ const ins = db.prepare(`INSERT INTO identity_legacy_map (source_system, source_type, source_id, subject_id, verified_at, metadata)++ VALUES (?, ?, ?, ?, CASE WHEN ? THEN CURRENT_TIMESTAMP END, ?)`);++ const byNetworkId = db.prepare('SELECT id, subject_id, created_at FROM users WHERE id = ?');++ db.transaction(() => {++ entries.forEach((e, i) => {++ let subjectId = e.subject_id;++ if (!subjectId && e.network_user_id != null) {++ const u = byNetworkId.get(Number(e.network_user_id));++ subjectId = u ? ensureUserSubject(db, u) : null;++ }++ const row = { subject_id: subjectId, source_system: e.source_system, source_type: e.source_type || 'user', source_id: e.source_id == null ? '' : String(e.source_id) };++ const v = validate('identity.legacy-identity-map@1', row);++ if (!subjectId || !v.valid || row.source_system === SELF || !projection(db, subjectId)) {++ out.rejected.push({ index: i, reason: !subjectId ? 'unknown network account' : row.source_system === SELF ? 'network ids are not writable' : !v.valid ? v.errors.map(x => `${x.path} ${x.message}`).join('; ') : 'unknown subject' });++ return;++ }++ const existing = get.get(row.source_system, row.source_type, row.source_id);++ if (existing) {++ if (existing.subject_id === subjectId) out.unchanged++;++ else out.conflicts.push({ index: i, source: `${row.source_system}:${row.source_type}:${row.source_id}`, mapped_to: existing.subject_id, requested: subjectId });++ return;++ }++ ins.run(row.source_system, row.source_type, row.source_id, subjectId, e.verified ? 1 : 0, e.metadata ? JSON.stringify(e.metadata) : null);++ out.inserted++;++ });++ })();++ return out;++}++++module.exports = { ensureSchema, backfill, seedLegacyMap, ensureUserSubject, newUserSubjectId, newGuestSubjectId, projection, resolve, upsertLegacy, createdMs };@@@ -49,10 +49,10 @@ function ensureAdminUser(db, config) {}const passwordHash = bcrypt.hashSync(password, 10);db.prepare(`-- INSERT INTO users (username, email, password_hash, display_name, role, profile_color)-- VALUES (?, ?, ?, ?, 'admin', '#8b5cf6')++ INSERT INTO users (username, email, password_hash, display_name, role, profile_color, subject_id)++ VALUES (?, ?, ?, ?, 'admin', '#8b5cf6', ?)ON CONFLICT(username) DO UPDATE SET role = 'admin', password_hash = excluded.password_hash-- `).run(username, null, passwordHash, username);++ `).run(username, null, passwordHash, username, require('./identity/subjects').newUserSubjectId());console.log(`[Setup] Admin user created or elevated: ${username}`);}@@@ -27,6 +27,7 @@ function requireInternalKey(req, res, next) {}router.use(requireInternalKey);++router.use('/identity', require('../identity/internal-routes'));// ── Verify Token ─────────────────────────────────────────────// Other services call this to validate an access token and get user data.@@@ -589,8 +590,8 @@ router.post('/resolve-anon', (req, res) => {const sessionToken = uuidv4();const result = db.prepare(-- 'INSERT INTO anon_users (anon_number, session_token, ip) VALUES (?, ?, ?)'-- ).run(anonNumber, sessionToken, ip);++ 'INSERT INTO anon_users (anon_number, session_token, ip, subject_id) VALUES (?, ?, ?, ?)'++ ).run(anonNumber, sessionToken, ip, require('../identity/subjects').newGuestSubjectId());// Log IPtry {@@@ -121,12 +121,12 @@ function createSetupRoutes(db, config) {const normalizedUsername = String(username).trim().toLowerCase();const passwordHash = bcrypt.hashSync(String(password), 10);db.prepare(`-- INSERT INTO users (username, email, password_hash, display_name, role, profile_color)-- VALUES (?, ?, ?, ?, 'admin', '#8b5cf6')++ INSERT INTO users (username, email, password_hash, display_name, role, profile_color, subject_id)++ VALUES (?, ?, ?, ?, 'admin', '#8b5cf6', ?)ON CONFLICT(username) DO UPDATE SETpassword_hash = excluded.password_hash,role = 'admin'-- `).run(normalizedUsername, null, passwordHash, normalizedUsername);++ `).run(normalizedUsername, null, passwordHash, normalizedUsername, require('../identity/subjects').newUserSubjectId());return res.json({ ok: true, message: 'Admin account created' });} catch (err) {res.status(500).json({ ok: false, error: err.message });nnew file mode 100644@@@ -0,0 +1,120 @@++'use strict';++// Canonical subject ids + identity_legacy_map (server/identity/*), roadmap Wave 1.++// node test/identity-subjects.test.js++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++const express = require('express');++const jwt = require('jsonwebtoken');++const contracts = require('openvibe-contracts');++const { initDb } = require('../server/db/database');++const subjects = require('../server/identity/subjects');++++const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-subjects-'));++const dbPath = path.join(dir, 'network.db');++let db = initDb(dbPath);++const quiet = console.log; console.log = () => {};++++// ── An existing database: rows without subject ids, a Live-migrated account ──++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.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++console.log = quiet;++++const alex = db.prepare('SELECT * FROM users WHERE id = 1').get();++const beth = db.prepare('SELECT * FROM users WHERE id = 2').get();++const guest = db.prepare('SELECT * FROM anon_users WHERE id = 5').get();++assert.ok(contracts.validate('identity.subject-ref', { type: 'user', id: alex.subject_id }).valid, 'backfilled user subject is a valid SubjectRef');++assert.ok(contracts.validate('identity.subject-ref', { type: 'guest', id: guest.subject_id }).valid, 'backfilled guest subject is gst_');++assert.ok(alex.subject_id < beth.subject_id, 'backfilled ids sort by account age');++assert.strictEqual(alex.subject_id.slice(4, 14), contracts.ids.ulid(Date.parse('2026-01-02T03:04:05Z')).slice(0, 10), 'ULID time part comes from created_at');++++// Rebooting changes nothing.++const before = alex.subject_id;++console.log = () => {}; db.close(); db = initDb(dbPath); console.log = quiet;++assert.strictEqual(db.prepare('SELECT subject_id FROM users WHERE id = 1').get().subject_id, before, 'subject ids are stable across boots');++++// Seeded map: network self ids and the Live legacy id.++assert.strictEqual(subjects.resolve(db, { source_system: 'network', source_type: 'user', source_id: 1 }).subject.id, before);++const viaLive = subjects.resolve(db, { source_system: 'live', source_type: 'user', source_id: '77' });++assert.strictEqual(viaLive.subject.id, before, 'Live-migrated account resolves by its Live id');++assert.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: '999' }), null);++++// A row inserted by a path that forgot subject_id is fixed lazily.++db.prepare("INSERT INTO users (id, username, password_hash) VALUES (3, 'cat', 'x')").run();++db.prepare('UPDATE users SET subject_id = NULL WHERE id = 3').run();++const cat = db.prepare('SELECT * FROM users WHERE id = 3').get();++const catSid = subjects.ensureUserSubject(db, cat);++assert.ok(/^usr_/.test(catSid) && cat.subject_id === catSid);++assert.strictEqual(subjects.ensureUserSubject(db, cat), catSid, 'idempotent');++++// ── Legacy map writes ──++let r = subjects.upsertLegacy(db, [++ { network_user_id: 2, source_system: 'live', source_type: 'user', source_id: 88, verified: true },++ { network_user_id: 2, source_system: 'live', source_type: 'user', source_id: 88 }, // repeat++ { network_user_id: 1, source_system: 'live', source_type: 'user', source_id: 88 }, // someone else's id++ { network_user_id: 1, source_system: 'network', source_type: 'user', source_id: 2 }, // not writable++ { network_user_id: 404, source_system: 'live', source_type: 'user', source_id: 1 }, // unknown account++ { subject_id: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ', source_system: 'live', source_type: 'user', source_id: 2 }, // unknown subject++ { network_user_id: 1, source_system: 'Live!', source_type: 'user', source_id: 3 }, // fails the contract++]);++assert.strictEqual(r.inserted, 1);++assert.strictEqual(r.unchanged, 1);++assert.deepStrictEqual(r.conflicts.map(c => c.index), [2], 'a mapped id is never repointed');++assert.deepStrictEqual(r.rejected.map(c => c.index), [3, 4, 5, 6]);++assert.strictEqual(subjects.resolve(db, { source_system: 'live', source_id: '88' }).subject.id, beth.subject_id);++++// ── HTTP: /internal/identity behind the internal key ──++const app = express();++app.locals.db = db;++app.locals.config = { internalKey: 'k-test' };++app.use('/internal', require('../server/internal/routes'));++const server = http.createServer(app);++++// Tokens carry subject_id next to the integer sub.++const { privateKey } = require('crypto').generateKeyPairSync('rsa', { modulusLength: 2048, privateKeyEncoding: { type: 'pkcs1', format: 'pem' }, publicKeyEncoding: { type: 'spki', format: 'pem' } });++const { signToken } = require('../server/auth/routes');++++(async () => {++ await new Promise(res => server.listen(0, '127.0.0.1', res));++ const base = `http://127.0.0.1:${server.address().port}/internal/identity`;++ const call = (p, opts = {}) => fetch(base + p, { ...opts, headers: { 'x-internal-key': 'k-test', 'content-type': 'application/json', ...(opts.headers || {}) } })++ .then(async x => ({ status: x.status, type: x.headers.get('content-type'), body: await x.json() }));++++ let h = await call('/resolve?system=live&id=77');++ assert.strictEqual(h.status, 200);++ assert.strictEqual(h.body.subject.id, before);++ assert.ok(h.body.legacy_ids.some(l => l.source_system === 'live' && l.source_id === '77'));++ h = await call('/resolve?system=live&id=12345');++ assert.strictEqual(h.status, 404);++ assert.ok(h.type.startsWith('application/problem+json') && contracts.validate('errors.problem', h.body).valid, '404 is a problem+json');++ assert.strictEqual(h.body.code, 'identity.subject_not_found');++ h = await call('/resolve');++ 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 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 }));++ assert.strictEqual(h.status, 403, 'no internal key, no identity data');++++ if (signToken) {++ const token = signToken(db.prepare('SELECT * FROM users WHERE id = 1').get(), privateKey, { jwt: { issuer: 'https://openvibe.network', accessTokenExpiry: '1h' } });++ const claims = jwt.decode(token);++ assert.strictEqual(claims.sub, 1, 'sub stays the integer id');++ assert.strictEqual(claims.subject_id, before, 'token carries the subject id');++ }++++ server.close();++ db.close();++ fs.rmSync(dir, { recursive: true, force: true });++ console.log('identity subjects: all checks passed');++})().catch(err => { console.error(err); process.exit(1); });
@@ -39,6 +39,12 @@ git pull "$GIT_REMOTE" "$GIT_BRANCH" --ff-only
@@ -39,6 +39,12 @@ git pull "$GIT_REMOTE" "$GIT_BRANCH" --ff-only
NEW_HASH=$(git rev-parse HEAD)
NEW_HASH=$(git rev-parse HEAD)
echo "[Deploy] New commit: ${NEW_HASH:0:8}"
echo "[Deploy] New commit: ${NEW_HASH:0:8}"
+# 2b. Install dependencies when the lockfile changed (e.g. a new openvibe-contracts release).
+if [ "$OLD_HASH" = "none" ] || ! git diff --quiet "$OLD_HASH" "$NEW_HASH" -- package-lock.json; then
+ echo "[Deploy] package-lock.json changed: npm ci --omit=dev"
+ npm ci --omit=dev --no-audit --no-fund
+fi
+
# 3. Update systemd service config if the repo includes one
# 3. Update systemd service config if the repo includes one
UNIT_UPDATED=false
UNIT_UPDATED=false
if [ -f "$SERVICE_UNIT_SOURCE" ]; then
if [ -f "$SERVICE_UNIT_SOURCE" ]; then
@@ -20,6 +20,7 @@
@@ -20,6 +20,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.1.0",
"openvibe-shared": "file:./packages/openvibe-shared",
"openvibe-shared": "file:./packages/openvibe-shared",
"uuid": "^11.1.0",
"uuid": "^11.1.0",
"web-push": "^3.6.7"
"web-push": "^3.6.7"
@@ -238,6 +239,39 @@
@@ -238,6 +239,39 @@
"node": ">= 14"
"node": ">= 14"
}
}
},
},
+ "node_modules/ajv": {
+ "version": "8.20.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
+ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.3",
+ "fast-uri": "^3.0.1",
+ "json-schema-traverse": "^1.0.0",
+ "require-from-string": "^2.0.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/ajv-formats": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
+ "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
+ "license": "MIT",
+ "dependencies": {
+ "ajv": "^8.0.0"
+ },
+ "peerDependencies": {
+ "ajv": "^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "ajv": {
+ "optional": true
+ }
+ }
+ },
"node_modules/append-field": {
"node_modules/append-field": {
"version": "1.0.0",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
@@ -818,6 +852,22 @@
@@ -818,6 +852,22 @@
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"license": "MIT"
"license": "MIT"
},
},
+ "node_modules/fast-uri": {
+ "version": "3.1.8",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
+ "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
"node_modules/file-uri-to-path": {
"node_modules/file-uri-to-path": {
"version": "1.0.0",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
@@ -1090,6 +1140,12 @@
@@ -1090,6 +1140,12 @@
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
"license": "MIT"
},
},
+ "node_modules/json-schema-traverse": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+ "license": "MIT"
+ },
"node_modules/jsonwebtoken": {
"node_modules/jsonwebtoken": {
"version": "9.0.3",
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
@@ -1407,6 +1463,19 @@
@@ -1407,6 +1463,19 @@
"wrappy": "1"
"wrappy": "1"
}
}
},
},
+ "node_modules/openvibe-contracts": {
+ "version": "0.1.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.1.0",
+ "integrity": "sha512-HfrmCKJe6WuleFUo2FGBQl+6ABEh07gxyH+QG+n42TK/nRBuSPo5jbt7FMAvrwB1JlQ4BbqfNbcoTU02qt8Jsw==",
+ "license": "AGPL-3.0-only",
+ "dependencies": {
+ "ajv": "^8.17.1",
+ "ajv-formats": "^3.0.1"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/openvibe-shared": {
"node_modules/openvibe-shared": {
"resolved": "packages/openvibe-shared",
"resolved": "packages/openvibe-shared",
"link": true
"link": true
@@ -1558,6 +1627,15 @@
@@ -1558,6 +1627,15 @@
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
"license": "MIT"
},
},
+ "node_modules/require-from-string": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/safe-buffer": {
"node_modules/safe-buffer": {
"version": "5.2.1",
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-network",
"name": "openvibe-network",
"version": "1.0.0",
"version": "1.0.0",
- "description": "OpenVibe.Network — Identity, SSO, Accounts, Themes, Notifications, and OpenCoins Wallet",
+ "description": "OpenVibe.Network \u2014 Identity, SSO, Accounts, Themes, Notifications, and OpenCoins Wallet",
"main": "server/index.js",
"main": "server/index.js",
"scripts": {
"scripts": {
"start": "node server/index.js",
"start": "node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
- "test": "node test/registry-bootstrap.test.js && node packages/openvibe-shared/test/theme-parity.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node packages/openvibe-shared/test/url-resolver.test.js && node packages/openvibe-shared/test/url-resolver-deploy.test.js && node packages/openvibe-shared/test/navbar-auth.test.js && node packages/openvibe-shared/test/navbar-brand.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node packages/openvibe-shared/test/shared-modules.test.js && node test/chrome.test.js && node packages/openvibe-shared/test/panels.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js",
+ "test": "node test/registry-bootstrap.test.js && node packages/openvibe-shared/test/theme-parity.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node packages/openvibe-shared/test/url-resolver.test.js && node packages/openvibe-shared/test/url-resolver-deploy.test.js && node packages/openvibe-shared/test/navbar-auth.test.js && node packages/openvibe-shared/test/navbar-brand.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node packages/openvibe-shared/test/shared-modules.test.js && node test/chrome.test.js && node packages/openvibe-shared/test/panels.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js && node test/identity-subjects.test.js",
"reset-db": "node server/reset-db.js",
"reset-db": "node server/reset-db.js",
"grant-admin": "node server/grant-admin.js"
"grant-admin": "node server/grant-admin.js"
},
},
@@ -21,9 +21,10 @@
@@ -21,9 +21,10 @@
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
- "openvibe-shared": "file:./packages/openvibe-shared",
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.1.0",
+ "openvibe-shared": "file:./packages/openvibe-shared",
"uuid": "^11.1.0",
"uuid": "^11.1.0",
"web-push": "^3.6.7"
"web-push": "^3.6.7"
}
}
@@ -53,7 +53,7 @@ function accountOf(user, base) {
@@ -53,7 +53,7 @@ function accountOf(user, base) {
/** Sign the FedCM assertion for one RP. */
/** Sign the FedCM assertion for one RP. */
function signAssertion(user, origin, nonce, ctx) {
function signAssertion(user, origin, nonce, ctx) {
return jwt.sign(
return jwt.sign(
- { sub: user.id, id: user.id, username: user.username, display_name: user.display_name || user.username, avatar_url: user.avatar_url || null, nonce: nonce || null, typ: 'fedcm', jti: crypto.randomBytes(16).toString('hex') },
+ { sub: user.id, id: user.id, subject_id: user.subject_id || undefined, username: user.username, display_name: user.display_name || user.username, avatar_url: user.avatar_url || null, nonce: nonce || null, typ: 'fedcm', jti: crypto.randomBytes(16).toString('hex') },
ctx.privateKey,
ctx.privateKey,
{ algorithm: ctx.privateKey.includes('BEGIN') ? 'RS256' : 'HS256', issuer: ctx.config.jwt.issuer, audience: origin, expiresIn: ASSERTION_TTL_S }
{ algorithm: ctx.privateKey.includes('BEGIN') ? 'RS256' : 'HS256', issuer: ctx.config.jwt.issuer, audience: origin, expiresIn: ASSERTION_TTL_S }
);
);
@@ -8,6 +8,7 @@
@@ -8,6 +8,7 @@
const express = require('express');
const express = require('express');
const crypto = require('crypto');
const crypto = require('crypto');
const jwt = require('jsonwebtoken');
const jwt = require('jsonwebtoken');
+const subjects = require('../identity/subjects');
const router = express.Router();
const router = express.Router();
function getDb(req) { return req.app.locals.db; }
function getDb(req) { return req.app.locals.db; }
@@ -274,6 +275,7 @@ function issueTokenPair(db, config, req, user, client) {
@@ -274,6 +275,7 @@ function issueTokenPair(db, config, req, user, client) {
{
{
sub: user.id,
sub: user.id,
id: user.id,
id: user.id,
+ subject_id: subjects.ensureUserSubject(db, user) || undefined,
username: user.username,
username: user.username,
display_name: user.display_name || user.username,
display_name: user.display_name || user.username,
role: user.role,
role: user.role,
@@ -10,6 +10,7 @@ const bcrypt = require('bcryptjs');
@@ -10,6 +10,7 @@ const bcrypt = require('bcryptjs');
const crypto = require('crypto');
const crypto = require('crypto');
const jwt = require('jsonwebtoken');
const jwt = require('jsonwebtoken');
const { v4: uuidv4 } = require('uuid');
const { v4: uuidv4 } = require('uuid');
+const subjects = require('../identity/subjects');
const router = express.Router();
const router = express.Router();
@@ -55,6 +56,7 @@ function signToken(user, privateKey, config) {
@@ -55,6 +56,7 @@ function signToken(user, privateKey, config) {
{
{
sub: user.id,
sub: user.id,
id: user.id,
id: user.id,
+ subject_id: user.subject_id || undefined,
username: user.username,
username: user.username,
display_name: user.display_name || user.username,
display_name: user.display_name || user.username,
role: user.role,
role: user.role,
@@ -141,9 +143,9 @@ router.post('/register', (req, res) => {
@@ -141,9 +143,9 @@ router.post('/register', (req, res) => {
const passwordHash = bcrypt.hashSync(password, 10);
const passwordHash = bcrypt.hashSync(password, 10);
const result = db.prepare(`
const result = db.prepare(`
- INSERT INTO users (username, email, password_hash, display_name, profile_color)
- VALUES (?, ?, ?, ?, '#8b5cf6')
- `).run(username, email || null, passwordHash, username);
+ INSERT INTO users (username, email, password_hash, display_name, profile_color, subject_id)
+ VALUES (?, ?, ?, ?, '#8b5cf6', ?)
+ `).run(username, email || null, passwordHash, username, subjects.newUserSubjectId());
// Create default preferences
// Create default preferences
db.prepare('INSERT OR IGNORE INTO user_preferences (user_id) VALUES (?)').run(result.lastInsertRowid);
db.prepare('INSERT OR IGNORE INTO user_preferences (user_id) VALUES (?)').run(result.lastInsertRowid);
@@ -362,6 +364,7 @@ router.post('/refresh', (req, res) => {
@@ -362,6 +364,7 @@ router.post('/refresh', (req, res) => {
router.get('/me', requireAuth, (req, res) => {
router.get('/me', requireAuth, (req, res) => {
const db = getDb(req);
const db = getDb(req);
const prefs = db.prepare('SELECT * FROM user_preferences WHERE user_id = ?').get(req.user.id);
const prefs = db.prepare('SELECT * FROM user_preferences WHERE user_id = ?').get(req.user.id);
+ subjects.ensureUserSubject(db, req.user);
res.json({
res.json({
user: sanitizeUser(req.user),
user: sanitizeUser(req.user),
preferences: prefs || { theme_id: 'vibe' },
preferences: prefs || { theme_id: 'vibe' },
@@ -553,8 +556,8 @@ router.post('/anon-session', (req, res) => {
@@ -553,8 +556,8 @@ router.post('/anon-session', (req, res) => {
const anonNumber = maxNum + 1;
const anonNumber = maxNum + 1;
const result = db.prepare(
const result = db.prepare(
- 'INSERT INTO anon_users (anon_number, fingerprint, session_token, ip) VALUES (?, ?, ?, ?)'
- ).run(anonNumber, fingerprint, sessionToken, ip);
+ 'INSERT INTO anon_users (anon_number, fingerprint, session_token, ip, subject_id) VALUES (?, ?, ?, ?, ?)'
+ ).run(anonNumber, fingerprint, sessionToken, ip, subjects.newGuestSubjectId());
logAnonIp(db, result.lastInsertRowid, ip);
logAnonIp(db, result.lastInsertRowid, ip);
@@ -491,6 +491,9 @@ function initDb(dbPath) {
@@ -491,6 +491,9 @@ function initDb(dbPath) {
// Cross-site history can be paused by the user (server/history/routes.js).
// Cross-site history can be paused by the user (server/history/routes.js).
{ table: 'user_preferences', column: 'display_prefs', sql: "ALTER TABLE user_preferences ADD COLUMN display_prefs TEXT" },
{ table: 'user_preferences', column: 'display_prefs', sql: "ALTER TABLE user_preferences ADD COLUMN display_prefs TEXT" },
{ table: 'users', column: 'history_paused', sql: "ALTER TABLE users ADD COLUMN history_paused INTEGER DEFAULT 0" },
{ table: 'users', column: 'history_paused', sql: "ALTER TABLE users ADD COLUMN history_paused INTEGER DEFAULT 0" },
+ // Canonical subject ids (roadmap Wave 1): usr_/gst_ ULIDs alongside the integer ids.
+ { table: 'users', column: 'subject_id', sql: "ALTER TABLE users ADD COLUMN subject_id TEXT" },
+ { table: 'anon_users', column: 'subject_id', sql: "ALTER TABLE anon_users ADD COLUMN subject_id TEXT" },
];
];
for (const m of migrations) {
for (const m of migrations) {
const cols = db.prepare(`PRAGMA table_info(${m.table})`).all();
const cols = db.prepare(`PRAGMA table_info(${m.table})`).all();
@@ -778,6 +781,9 @@ function initDb(dbPath) {
@@ -778,6 +781,9 @@ function initDb(dbPath) {
return !!vk;
return !!vk;
};
};
+ // Subject ids + identity_legacy_map (server/identity/subjects.js): backfill and seed, idempotent.
+ require('../identity/subjects').ensureSchema(db);
+
console.log('[DB] Central database initialized');
console.log('[DB] Central database initialized');
return db;
return db;
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,40 @@
@@ -0,0 +1,40 @@
+'use strict';
+/**
+ * /internal/identity/* — subject resolution for first-party services (behind X-Internal-Key,
+ * mounted by server/internal/routes.js). Errors are RFC 9457 problems (errors.problem@1).
+ *
+ * GET /internal/identity/resolve?subject_id=usr_...
+ * GET /internal/identity/resolve?system=live&type=user&id=123
+ * POST /internal/identity/legacy-map { entries: [{ network_user_id | subject_id, source_system, source_type, source_id, verified? }] }
+ */
+const express = require('express');
+const { http } = require('openvibe-contracts');
+const subjects = require('./subjects');
+
+const router = express.Router();
+router.use(http.middleware());
+const MAX_ENTRIES = 1000;
+
+router.get('/resolve', (req, res) => {
+ const db = req.app.locals.db;
+ const q = req.query;
+ if (!q.subject_id && !(q.system && q.id)) {
+ return http.sendProblem(res, 400, 'identity.bad_request', { detail: 'pass subject_id, or system + id (+ type, default user)', ctx: req.ov });
+ }
+ const out = subjects.resolve(db, { subject_id: q.subject_id, source_system: q.system, source_type: q.type || 'user', source_id: q.id });
+ if (!out) return http.sendProblem(res, 404, 'identity.subject_not_found', { detail: 'no subject for that id', ctx: req.ov });
+ res.json(out);
+});
+
+router.post('/legacy-map', express.json({ limit: '512kb' }), (req, res) => {
+ const entries = req.body && req.body.entries;
+ if (!Array.isArray(entries) || !entries.length) {
+ return http.sendProblem(res, 400, 'identity.bad_request', { detail: 'entries must be a non-empty array', ctx: req.ov });
+ }
+ if (entries.length > MAX_ENTRIES) {
+ return http.sendProblem(res, 413, 'identity.too_many_entries', { detail: `at most ${MAX_ENTRIES} entries per call`, ctx: req.ov });
+ }
+ res.json(subjects.upsertLegacy(req.app.locals.db, entries));
+});
+
+module.exports = router;
new file mode 100644
new file mode 100644
@@ -0,0 +1,177 @@
@@ -0,0 +1,177 @@
+'use strict';
+/**
+ * Canonical subjects (roadmap Wave 1, ADR-001; contract identity.subject-ref@1).
+ *
+ * Every Network account gets a stable `subject_id` (usr_<ULID>) and every anonymous session a
+ * `gst_<ULID>`. They are added ALONGSIDE the integer ids, which stay the primary keys and stay in
+ * tokens as `sub`/`id`; nothing that reads those today changes.
+ *
+ * identity_legacy_map links service-local ids (Live's users.id, Media's user_id, Games players, ...)
+ * to a subject, so a service can ask "who is live user 123?" without calling Live. A row is never
+ * repointed to a different subject: a conflicting write is reported, not applied.
+ */
+const { ids, validate } = require('openvibe-contracts');
+
+const SELF = 'network';
+
+/** SQLite CURRENT_TIMESTAMP ('YYYY-MM-DD HH:MM:SS', UTC) -> ms, so backfilled ULIDs sort by account age. */
+function createdMs(value) {
+ const t = value ? Date.parse(String(value).replace(' ', 'T') + (/[zZ]|[+-]\d\d:?\d\d$/.test(value) ? '' : 'Z')) : NaN;
+ return Number.isFinite(t) ? t : Date.now();
+}
+
+function ensureSchema(db) {
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS identity_legacy_map (
+ source_system TEXT NOT NULL,
+ source_type TEXT NOT NULL,
+ source_id TEXT NOT NULL,
+ subject_id TEXT NOT NULL,
+ first_seen_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ verified_at DATETIME,
+ metadata TEXT,
+ PRIMARY KEY (source_system, source_type, source_id)
+ );
+ CREATE INDEX IF NOT EXISTS idx_identity_legacy_subject ON identity_legacy_map(subject_id);
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_users_subject_id ON users(subject_id);
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_anon_users_subject_id ON anon_users(subject_id);
+ `);
+ const filled = backfill(db);
+ const seeded = seedLegacyMap(db);
+ if (filled.users || filled.guests || seeded) {
+ console.log(`[Identity] subject ids: ${filled.users} users, ${filled.guests} guests assigned; ${seeded} legacy map rows seeded`);
+ }
+}
+
+/** Give every row without a subject id one. Idempotent; runs at boot. */
+function backfill(db) {
+ const out = { users: 0, guests: 0 };
+ const run = db.transaction(() => {
+ for (const [table, kind, key, born] of [['users', 'user', 'users', 'created_at'], ['anon_users', 'guest', 'guests', 'first_seen']]) {
+ const rows = db.prepare(`SELECT id, ${born} AS created_at FROM ${table} WHERE subject_id IS NULL`).all();
+ const set = db.prepare(`UPDATE ${table} SET subject_id = ? WHERE id = ? AND subject_id IS NULL`);
+ for (const r of rows) out[key] += set.run(ids.newId(kind, createdMs(r.created_at)), r.id).changes;
+ }
+ });
+ run();
+ return out;
+}
+
+/**
+ * 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).
+ */
+function seedLegacyMap(db) {
+ const ins = db.prepare(`INSERT OR IGNORE INTO identity_legacy_map (source_system, source_type, source_id, subject_id, verified_at)
+ VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)`);
+ let n = 0;
+ db.transaction(() => {
+ for (const u of db.prepare('SELECT id, subject_id, legacy_source, legacy_id FROM users WHERE subject_id IS NOT NULL').all()) {
+ n += ins.run(SELF, 'user', String(u.id), u.subject_id).changes;
+ if (u.legacy_source && u.legacy_id != null) n += ins.run(String(u.legacy_source), 'user', String(u.legacy_id), u.subject_id).changes;
+ }
+ 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;
+ }
+ })();
+ return n;
+}
+
+/** Subject id for a users row, assigning one if an insert path skipped it. */
+function ensureUserSubject(db, user) {
+ if (!user) return null;
+ if (user.subject_id) return user.subject_id;
+ const row = db.prepare('SELECT subject_id, created_at FROM users WHERE id = ?').get(user.id);
+ if (!row) return null;
+ if (!row.subject_id) {
+ const sid = ids.newId('user', createdMs(row.created_at));
+ db.prepare('UPDATE users SET subject_id = ? WHERE id = ? AND subject_id IS NULL').run(sid, user.id);
+ db.prepare(`INSERT OR IGNORE INTO identity_legacy_map (source_system, source_type, source_id, subject_id, verified_at)
+ VALUES (?, 'user', ?, ?, CURRENT_TIMESTAMP)`).run(SELF, String(user.id), sid);
+ row.subject_id = db.prepare('SELECT subject_id FROM users WHERE id = ?').get(user.id).subject_id;
+ }
+ user.subject_id = row.subject_id;
+ return row.subject_id;
+}
+
+/** New subject id for an insert: `INSERT INTO users (..., subject_id) VALUES (..., newUserSubjectId())`. */
+const newUserSubjectId = () => ids.newId('user');
+const newGuestSubjectId = () => ids.newId('guest');
+
+/** Public projection of a subject. Never includes email, role internals or secrets. */
+function projection(db, subjectId) {
+ if (typeof subjectId !== 'string') return null;
+ if (subjectId.startsWith('usr_')) {
+ const u = db.prepare('SELECT id, subject_id, username, display_name, avatar_url, is_banned FROM users WHERE subject_id = ?').get(subjectId);
+ if (!u) return null;
+ return { subject: { type: 'user', id: u.subject_id }, network_user_id: u.id, username: u.username,
+ display_name: u.display_name || u.username, avatar_url: u.avatar_url || null, banned: Boolean(u.is_banned) };
+ }
+ if (subjectId.startsWith('gst_')) {
+ const g = db.prepare('SELECT id, subject_id, anon_number, display_name FROM anon_users WHERE subject_id = ?').get(subjectId);
+ if (!g) return null;
+ return { subject: { type: 'guest', id: g.subject_id }, network_anon_id: g.id, username: `anon${g.anon_number}`,
+ display_name: g.display_name || `anon${g.anon_number}`, avatar_url: null, banned: false };
+ }
+ return null;
+}
+
+/** Resolve by subject id or by (system, type, id). Returns the projection plus the matching legacy ids. */
+function resolve(db, { subject_id, source_system, source_type, source_id }) {
+ let sid = subject_id;
+ if (!sid) {
+ if (source_system === SELF && source_type === 'user') {
+ const u = db.prepare('SELECT id, subject_id, created_at FROM users WHERE id = ?').get(Number(source_id));
+ sid = u ? ensureUserSubject(db, u) : null;
+ } else {
+ const row = db.prepare('SELECT subject_id FROM identity_legacy_map WHERE source_system = ? AND source_type = ? AND source_id = ?')
+ .get(String(source_system), String(source_type || 'user'), String(source_id));
+ sid = row && row.subject_id;
+ }
+ }
+ const p = sid ? projection(db, sid) : null;
+ if (!p) return null;
+ p.legacy_ids = db.prepare('SELECT source_system, source_type, source_id FROM identity_legacy_map WHERE subject_id = ? ORDER BY source_system, source_type')
+ .all(p.subject.id);
+ return p;
+}
+
+/**
+ * Record service-local ids for existing subjects. Each entry names the Network account either by
+ * `subject_id` or `network_user_id` (what services store today). Rows for source_system 'network'
+ * are refused: Network is the authority for its own ids.
+ * Returns { inserted, unchanged, conflicts: [...], rejected: [...] }.
+ */
+function upsertLegacy(db, entries) {
+ const out = { inserted: 0, unchanged: 0, conflicts: [], rejected: [] };
+ const get = db.prepare('SELECT subject_id FROM identity_legacy_map WHERE source_system = ? AND source_type = ? AND source_id = ?');
+ const ins = db.prepare(`INSERT INTO identity_legacy_map (source_system, source_type, source_id, subject_id, verified_at, metadata)
+ VALUES (?, ?, ?, ?, CASE WHEN ? THEN CURRENT_TIMESTAMP END, ?)`);
+ const byNetworkId = db.prepare('SELECT id, subject_id, created_at FROM users WHERE id = ?');
+ db.transaction(() => {
+ entries.forEach((e, i) => {
+ let subjectId = e.subject_id;
+ if (!subjectId && e.network_user_id != null) {
+ const u = byNetworkId.get(Number(e.network_user_id));
+ subjectId = u ? ensureUserSubject(db, u) : null;
+ }
+ const row = { subject_id: subjectId, source_system: e.source_system, source_type: e.source_type || 'user', source_id: e.source_id == null ? '' : String(e.source_id) };
+ const v = validate('identity.legacy-identity-map@1', row);
+ if (!subjectId || !v.valid || row.source_system === SELF || !projection(db, subjectId)) {
+ out.rejected.push({ index: i, reason: !subjectId ? 'unknown network account' : row.source_system === SELF ? 'network ids are not writable' : !v.valid ? v.errors.map(x => `${x.path} ${x.message}`).join('; ') : 'unknown subject' });
+ return;
+ }
+ const existing = get.get(row.source_system, row.source_type, row.source_id);
+ if (existing) {
+ if (existing.subject_id === subjectId) out.unchanged++;
+ else out.conflicts.push({ index: i, source: `${row.source_system}:${row.source_type}:${row.source_id}`, mapped_to: existing.subject_id, requested: subjectId });
+ return;
+ }
+ ins.run(row.source_system, row.source_type, row.source_id, subjectId, e.verified ? 1 : 0, e.metadata ? JSON.stringify(e.metadata) : null);
+ out.inserted++;
+ });
+ })();
+ return out;
+}
+
+module.exports = { ensureSchema, backfill, seedLegacyMap, ensureUserSubject, newUserSubjectId, newGuestSubjectId, projection, resolve, upsertLegacy, createdMs };
@@ -49,10 +49,10 @@ function ensureAdminUser(db, config) {
@@ -49,10 +49,10 @@ function ensureAdminUser(db, config) {
}
}
const passwordHash = bcrypt.hashSync(password, 10);
const passwordHash = bcrypt.hashSync(password, 10);
db.prepare(`
db.prepare(`
- INSERT INTO users (username, email, password_hash, display_name, role, profile_color)
- VALUES (?, ?, ?, ?, 'admin', '#8b5cf6')
+ INSERT INTO users (username, email, password_hash, display_name, role, profile_color, subject_id)
+ VALUES (?, ?, ?, ?, 'admin', '#8b5cf6', ?)
ON CONFLICT(username) DO UPDATE SET role = 'admin', password_hash = excluded.password_hash
ON CONFLICT(username) DO UPDATE SET role = 'admin', password_hash = excluded.password_hash
- `).run(username, null, passwordHash, username);
+ `).run(username, null, passwordHash, username, require('./identity/subjects').newUserSubjectId());
console.log(`[Setup] Admin user created or elevated: ${username}`);
console.log(`[Setup] Admin user created or elevated: ${username}`);
}
}
@@ -27,6 +27,7 @@ function requireInternalKey(req, res, next) {
@@ -27,6 +27,7 @@ function requireInternalKey(req, res, next) {
}
}
router.use(requireInternalKey);
router.use(requireInternalKey);
+router.use('/identity', require('../identity/internal-routes'));
// ── Verify Token ─────────────────────────────────────────────
// ── Verify Token ─────────────────────────────────────────────
// Other services call this to validate an access token and get user data.
// Other services call this to validate an access token and get user data.
@@ -589,8 +590,8 @@ router.post('/resolve-anon', (req, res) => {
@@ -589,8 +590,8 @@ router.post('/resolve-anon', (req, res) => {
const sessionToken = uuidv4();
const sessionToken = uuidv4();
const result = db.prepare(
const result = db.prepare(
- 'INSERT INTO anon_users (anon_number, session_token, ip) VALUES (?, ?, ?)'
- ).run(anonNumber, sessionToken, ip);
+ 'INSERT INTO anon_users (anon_number, session_token, ip, subject_id) VALUES (?, ?, ?, ?)'
+ ).run(anonNumber, sessionToken, ip, require('../identity/subjects').newGuestSubjectId());
// Log IP
// Log IP
try {
try {
@@ -121,12 +121,12 @@ function createSetupRoutes(db, config) {
@@ -121,12 +121,12 @@ function createSetupRoutes(db, config) {
const normalizedUsername = String(username).trim().toLowerCase();
const normalizedUsername = String(username).trim().toLowerCase();
const passwordHash = bcrypt.hashSync(String(password), 10);
const passwordHash = bcrypt.hashSync(String(password), 10);
db.prepare(`
db.prepare(`
- INSERT INTO users (username, email, password_hash, display_name, role, profile_color)
- VALUES (?, ?, ?, ?, 'admin', '#8b5cf6')
+ INSERT INTO users (username, email, password_hash, display_name, role, profile_color, subject_id)
+ VALUES (?, ?, ?, ?, 'admin', '#8b5cf6', ?)
ON CONFLICT(username) DO UPDATE SET
ON CONFLICT(username) DO UPDATE SET
password_hash = excluded.password_hash,
password_hash = excluded.password_hash,
role = 'admin'
role = 'admin'
- `).run(normalizedUsername, null, passwordHash, normalizedUsername);
+ `).run(normalizedUsername, null, passwordHash, normalizedUsername, require('../identity/subjects').newUserSubjectId());
return res.json({ ok: true, message: 'Admin account created' });
return res.json({ ok: true, message: 'Admin account created' });
} catch (err) {
} catch (err) {
res.status(500).json({ ok: false, error: err.message });
res.status(500).json({ ok: false, error: err.message });
new file mode 100644
new file mode 100644
@@ -0,0 +1,120 @@
@@ -0,0 +1,120 @@
+'use strict';
+// Canonical subject ids + identity_legacy_map (server/identity/*), roadmap Wave 1.
+// node test/identity-subjects.test.js
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+const express = require('express');
+const jwt = require('jsonwebtoken');
+const contracts = require('openvibe-contracts');
+const { initDb } = require('../server/db/database');
+const subjects = require('../server/identity/subjects');
+
+const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-subjects-'));
+const dbPath = path.join(dir, 'network.db');
+let db = initDb(dbPath);
+const quiet = console.log; console.log = () => {};
+
+// ── An existing database: rows without subject ids, a Live-migrated account ──
+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.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
+console.log = quiet;
+
+const alex = db.prepare('SELECT * FROM users WHERE id = 1').get();
+const beth = db.prepare('SELECT * FROM users WHERE id = 2').get();
+const guest = db.prepare('SELECT * FROM anon_users WHERE id = 5').get();
+assert.ok(contracts.validate('identity.subject-ref', { type: 'user', id: alex.subject_id }).valid, 'backfilled user subject is a valid SubjectRef');
+assert.ok(contracts.validate('identity.subject-ref', { type: 'guest', id: guest.subject_id }).valid, 'backfilled guest subject is gst_');
+assert.ok(alex.subject_id < beth.subject_id, 'backfilled ids sort by account age');
+assert.strictEqual(alex.subject_id.slice(4, 14), contracts.ids.ulid(Date.parse('2026-01-02T03:04:05Z')).slice(0, 10), 'ULID time part comes from created_at');
+
+// Rebooting changes nothing.
+const before = alex.subject_id;
+console.log = () => {}; db.close(); db = initDb(dbPath); console.log = quiet;
+assert.strictEqual(db.prepare('SELECT subject_id FROM users WHERE id = 1').get().subject_id, before, 'subject ids are stable across boots');
+
+// Seeded map: network self ids and the Live legacy id.
+assert.strictEqual(subjects.resolve(db, { source_system: 'network', source_type: 'user', source_id: 1 }).subject.id, before);
+const viaLive = subjects.resolve(db, { source_system: 'live', source_type: 'user', source_id: '77' });
+assert.strictEqual(viaLive.subject.id, before, 'Live-migrated account resolves by its Live id');
+assert.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: '999' }), null);
+
+// A row inserted by a path that forgot subject_id is fixed lazily.
+db.prepare("INSERT INTO users (id, username, password_hash) VALUES (3, 'cat', 'x')").run();
+db.prepare('UPDATE users SET subject_id = NULL WHERE id = 3').run();
+const cat = db.prepare('SELECT * FROM users WHERE id = 3').get();
+const catSid = subjects.ensureUserSubject(db, cat);
+assert.ok(/^usr_/.test(catSid) && cat.subject_id === catSid);
+assert.strictEqual(subjects.ensureUserSubject(db, cat), catSid, 'idempotent');
+
+// ── Legacy map writes ──
+let r = subjects.upsertLegacy(db, [
+ { network_user_id: 2, source_system: 'live', source_type: 'user', source_id: 88, verified: true },
+ { network_user_id: 2, source_system: 'live', source_type: 'user', source_id: 88 }, // repeat
+ { network_user_id: 1, source_system: 'live', source_type: 'user', source_id: 88 }, // someone else's id
+ { network_user_id: 1, source_system: 'network', source_type: 'user', source_id: 2 }, // not writable
+ { network_user_id: 404, source_system: 'live', source_type: 'user', source_id: 1 }, // unknown account
+ { subject_id: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ', source_system: 'live', source_type: 'user', source_id: 2 }, // unknown subject
+ { network_user_id: 1, source_system: 'Live!', source_type: 'user', source_id: 3 }, // fails the contract
+]);
+assert.strictEqual(r.inserted, 1);
+assert.strictEqual(r.unchanged, 1);
+assert.deepStrictEqual(r.conflicts.map(c => c.index), [2], 'a mapped id is never repointed');
+assert.deepStrictEqual(r.rejected.map(c => c.index), [3, 4, 5, 6]);
+assert.strictEqual(subjects.resolve(db, { source_system: 'live', source_id: '88' }).subject.id, beth.subject_id);
+
+// ── HTTP: /internal/identity behind the internal key ──
+const app = express();
+app.locals.db = db;
+app.locals.config = { internalKey: 'k-test' };
+app.use('/internal', require('../server/internal/routes'));
+const server = http.createServer(app);
+
+// Tokens carry subject_id next to the integer sub.
+const { privateKey } = require('crypto').generateKeyPairSync('rsa', { modulusLength: 2048, privateKeyEncoding: { type: 'pkcs1', format: 'pem' }, publicKeyEncoding: { type: 'spki', format: 'pem' } });
+const { signToken } = require('../server/auth/routes');
+
+(async () => {
+ await new Promise(res => server.listen(0, '127.0.0.1', res));
+ const base = `http://127.0.0.1:${server.address().port}/internal/identity`;
+ const call = (p, opts = {}) => fetch(base + p, { ...opts, headers: { 'x-internal-key': 'k-test', 'content-type': 'application/json', ...(opts.headers || {}) } })
+ .then(async x => ({ status: x.status, type: x.headers.get('content-type'), body: await x.json() }));
+
+ let h = await call('/resolve?system=live&id=77');
+ assert.strictEqual(h.status, 200);
+ assert.strictEqual(h.body.subject.id, before);
+ assert.ok(h.body.legacy_ids.some(l => l.source_system === 'live' && l.source_id === '77'));
+ h = await call('/resolve?system=live&id=12345');
+ assert.strictEqual(h.status, 404);
+ assert.ok(h.type.startsWith('application/problem+json') && contracts.validate('errors.problem', h.body).valid, '404 is a problem+json');
+ assert.strictEqual(h.body.code, 'identity.subject_not_found');
+ h = await call('/resolve');
+ 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 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 }));
+ assert.strictEqual(h.status, 403, 'no internal key, no identity data');
+
+ if (signToken) {
+ const token = signToken(db.prepare('SELECT * FROM users WHERE id = 1').get(), privateKey, { jwt: { issuer: 'https://openvibe.network', accessTokenExpiry: '1h' } });
+ const claims = jwt.decode(token);
+ assert.strictEqual(claims.sub, 1, 'sub stays the integer id');
+ assert.strictEqual(claims.subject_id, before, 'token carries the subject id');
+ }
+
+ server.close();
+ db.close();
+ fs.rmSync(dir, { recursive: true, force: true });
+ console.log('identity subjects: all checks passed');
+})().catch(err => { console.error(err); process.exit(1); });