openvibers/openvibe.network Commits

Canonical subject ids: usr_/gst_ ULIDs alongside integer ids, identity_legacy_map, /internal/identity, subject_id in tokens

OP OpenVibers committed 22 Sept 2026 · 139fb4434a · 13 files, +451 −17

Roadmap Wave 1 (ADR-001), contract identity.subject-ref@1 from openvibe-contracts v0.1.0.

- users.subject_id / anon_users.subject_id added and backfilled at boot (ULID time from
  created_at / first_seen, so ids sort by account age); unique-indexed; every insert path
  sets one and ensureUserSubject() fixes any row that slips through
- identity_legacy_map (source_system, source_type, source_id) -> subject_id, seeded from
  Network's own ids and users.legacy_source/legacy_id. Rows are never repointed; a conflicting
  write is reported back
- GET /internal/identity/resolve and POST /internal/identity/legacy-map (X-Internal-Key),
  errors as problem+json
- access tokens (password, OAuth, FedCM) and /api/auth/me carry subject_id; sub/id stay the
  integer id so every existing consumer keeps working
- openvibe-contracts pinned by release tarball; deploy.sh runs npm ci when package-lock changes

Changes

ddiff --git a/deploy/scripts/deploy.sh b/deploy/scripts/deploy.sh
iindex f298dfea..7dc43968 100755
---- a/deploy/scripts/deploy.sh
++++ b/deploy/scripts/deploy.sh
@@@ -39,6 +39,12 @@ git pull "$GIT_REMOTE" "$GIT_BRANCH" --ff-only
NEW_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 one
UNIT_UPDATED=false
if [ -f "$SERVICE_UNIT_SOURCE" ]; then
ddiff --git a/package-lock.json b/package-lock.json
iindex 2f243fb1..cb9b9b13 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex bb68de38..5c78e761 100644
---- a/package.json
++++ b/package.json
@@@ -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"
}
ddiff --git a/server/auth/fedcm.js b/server/auth/fedcm.js
iindex 8a69f319..1232aa54 100644
---- a/server/auth/fedcm.js
++++ b/server/auth/fedcm.js
@@@ -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 }
);
ddiff --git a/server/auth/oauth-routes.js b/server/auth/oauth-routes.js
iindex 81f4dd67..16ed22c6 100644
---- a/server/auth/oauth-routes.js
++++ b/server/auth/oauth-routes.js
@@@ -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,
ddiff --git a/server/auth/routes.js b/server/auth/routes.js
iindex 27b3dd92..e98eafab 100644
---- a/server/auth/routes.js
++++ b/server/auth/routes.js
@@@ -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 preferences
db.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);
ddiff --git a/server/db/database.js b/server/db/database.js
iindex d73e506c..8da07914 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -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;
}
ddiff --git a/server/identity/internal-routes.js b/server/identity/internal-routes.js
nnew file mode 100644
iindex 00000000..2942117b
---- /dev/null
++++ b/server/identity/internal-routes.js
@@@ -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;
ddiff --git a/server/identity/subjects.js b/server/identity/subjects.js
nnew file mode 100644
iindex 00000000..5feba61e
---- /dev/null
++++ b/server/identity/subjects.js
@@@ -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 };
ddiff --git a/server/index.js b/server/index.js
iindex ec7c4427..1b9aeef8 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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}`);
}
ddiff --git a/server/internal/routes.js b/server/internal/routes.js
iindex 895933bf..0465c6f8 100644
---- a/server/internal/routes.js
++++ b/server/internal/routes.js
@@@ -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 IP
try {
ddiff --git a/server/setup/routes.js b/server/setup/routes.js
iindex 4dc42312..1cb974e0 100644
---- a/server/setup/routes.js
++++ b/server/setup/routes.js
@@@ -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 SET
password_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 });
ddiff --git a/test/identity-subjects.test.js b/test/identity-subjects.test.js
nnew file mode 100644
iindex 00000000..04970b27
---- /dev/null
++++ b/test/identity-subjects.test.js
@@@ -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); });