openvibers/openvibe.live Commits
Service tokens to Network on link-account, identity legacy-map and resolve-batch (daily identity sync), coins/stats and resolve-anon, which Network guards by capability since 2026-09-24; a refused token falls back to the internal key once (compatibility register C-50..C-57)
Changes
@@@ -38,6 +38,22 @@ function subjectOf(liveUserId) {return row ? row.subject_id || null : null;}++/** POST to Network with a service token where it accepts one (else the internal key); a refused token falls back once. */++async function networkPost(path, body) {++ const principal = require('../net/network-principal');++ for (let attempt = 0; attempt < 2; attempt++) {++ const auth = await principal.headersFor(path);++ const res = await fetch(`${OV_NETWORK_INTERNAL_URL}${path}`, {++ method: 'POST',++ headers: { 'Content-Type': 'application/json', ...auth },++ body: JSON.stringify(body),++ signal: AbortSignal.timeout(15_000),++ });++ if (res.status === 401 && auth.Authorization && attempt === 0) { principal.tokenRejected('401'); continue; }++ return res;++ }++}++async function syncLegacyMap() {if (!INTERNAL_API_KEY) return { skipped: 'no INTERNAL_API_KEY' };const rows = db.getDb().prepare("SELECT user_id, service_user_id FROM linked_accounts WHERE service = 'network' AND service_user_id GLOB '[0-9]*' ORDER BY user_id").all();@@@ -46,12 +62,7 @@ async function syncLegacyMap() {const entries = rows.slice(i, i + BATCH).map(r => ({network_user_id: Number(r.service_user_id), source_system: 'live', source_type: 'user', source_id: String(r.user_id), verified: true,}));-- const res = await fetch(`${OV_NETWORK_INTERNAL_URL}/internal/identity/legacy-map`, {-- method: 'POST',-- headers: { 'Content-Type': 'application/json', 'X-Internal-Key': INTERNAL_API_KEY },-- body: JSON.stringify({ entries }),-- signal: AbortSignal.timeout(15_000),-- });++ const res = await networkPost('/internal/identity/legacy-map', { entries });if (res.status === 404) return { skipped: 'network has no /internal/identity yet' };if (!res.ok) throw new Error(`legacy-map ${res.status}`);const out = await res.json();@@@ -70,12 +81,7 @@ async function backfillSubjects() {const store = db.getDb().prepare("UPDATE linked_accounts SET subject_id = ? WHERE service = 'network' AND user_id = ? AND service_user_id = ? AND subject_id IS NULL");for (let i = 0; i < rows.length; i += BATCH) {const batch = rows.slice(i, i + BATCH);-- const res = await fetch(`${OV_NETWORK_INTERNAL_URL}/internal/identity/resolve-batch`, {-- method: 'POST',-- headers: { 'Content-Type': 'application/json', 'X-Internal-Key': INTERNAL_API_KEY },-- body: JSON.stringify({ system: 'network', type: 'user', ids: batch.map(r => String(r.service_user_id)) }),-- signal: AbortSignal.timeout(15_000),-- });++ const res = await networkPost('/internal/identity/resolve-batch', { system: 'network', type: 'user', ids: batch.map(r => String(r.service_user_id)) });if (res.status === 404) return { ...total, skipped: 'network has no /internal/identity yet' };if (!res.ok) throw new Error(`resolve-batch ${res.status}`);const { results = {} } = await res.json();@@@ -149,7 +149,7 @@ async function networkCoinStats() {try {const ctl = AbortSignal.timeout ? AbortSignal.timeout(2500) : undefined;const res = await fetch(`${NETWORK_INTERNAL_URL}/internal/coins/stats`, {-- headers: { 'X-Internal-Key': INTERNAL_API_KEY, Accept: 'application/json' },++ headers: { ...(await principal.headersFor('/internal/coins/stats')), Accept: 'application/json' },signal: ctl,});if (!res.ok) return _statsCache.data;@@@ -18,7 +18,9 @@ const CLIENT_SECRET = process.env.OV_OAUTH_CLIENT_SECRET || '';const PAUSE_MS = 5 * 60 * 1000;// Network routes that accept a service token (OpenVibe.Network server/internal/routes.js TOKEN_ROUTES).--const TOKEN_PATHS = new Set(['/internal/coins/credit', '/internal/coins/debit', '/internal/coins/transfer', '/internal/notifications/push', '/internal/notifications/push-bulk', '/internal/events/stream-live']);++const TOKEN_PATHS = new Set(['/internal/coins/credit', '/internal/coins/debit', '/internal/coins/transfer', '/internal/notifications/push', '/internal/notifications/push-bulk', '/internal/events/stream-live',++ // Network accepts a service token on these since 2026-09-24 (identity.subject.resolve / network.coins.credit).++ '/internal/link-account', '/internal/identity/legacy-map', '/internal/identity/resolve-batch', '/internal/coins/stats', '/internal/resolve-anon']);const _clients = new Map();/** One cached client-credentials token client per audience (openvibe.network, openvibe.community, ...). */@@@ -64,10 +64,11 @@ const network = http.createServer((req, res) => {assert.deepStrictEqual(seen.map((s) => s.auth), ['token', 'token']);assert.strictEqual(tokenCalls, 1, 'one token for both calls');-- // 2. Routes Network doesn't guard by capability keep the key.++ // 2. link-account takes a service token too since Network guards it (identity.subject.resolve, 2026-09-24).seen.length = 0;await (async () => { notify.reportLinkedAccount({ id: uid, username: 'p' }); await new Promise((x) => setTimeout(x, 150)); })();-- assert.deepStrictEqual(seen.map((s) => `${s.url}:${s.auth}`), ['/internal/link-account:key']);++ assert.deepStrictEqual(seen.map((s) => `${s.url}:${s.auth}`), ['/internal/link-account:token']);++ assert.ok(!principal.TOKEN_PATHS.has('/internal/notifications/mark-read'), 'routes Network does not guard by capability keep the key');// 3. A refused token is retried once with the key, and the key is used until the pause ends.seen.length = 0; mode.coins = 'refuse';
@@ -38,6 +38,22 @@ function subjectOf(liveUserId) {
@@ -38,6 +38,22 @@ function subjectOf(liveUserId) {
return row ? row.subject_id || null : null;
return row ? row.subject_id || null : null;
}
}
+/** POST to Network with a service token where it accepts one (else the internal key); a refused token falls back once. */
+async function networkPost(path, body) {
+ const principal = require('../net/network-principal');
+ for (let attempt = 0; attempt < 2; attempt++) {
+ const auth = await principal.headersFor(path);
+ const res = await fetch(`${OV_NETWORK_INTERNAL_URL}${path}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', ...auth },
+ body: JSON.stringify(body),
+ signal: AbortSignal.timeout(15_000),
+ });
+ if (res.status === 401 && auth.Authorization && attempt === 0) { principal.tokenRejected('401'); continue; }
+ return res;
+ }
+}
+
async function syncLegacyMap() {
async function syncLegacyMap() {
if (!INTERNAL_API_KEY) return { skipped: 'no INTERNAL_API_KEY' };
if (!INTERNAL_API_KEY) return { skipped: 'no INTERNAL_API_KEY' };
const rows = db.getDb().prepare("SELECT user_id, service_user_id FROM linked_accounts WHERE service = 'network' AND service_user_id GLOB '[0-9]*' ORDER BY user_id").all();
const rows = db.getDb().prepare("SELECT user_id, service_user_id FROM linked_accounts WHERE service = 'network' AND service_user_id GLOB '[0-9]*' ORDER BY user_id").all();
@@ -46,12 +62,7 @@ async function syncLegacyMap() {
@@ -46,12 +62,7 @@ async function syncLegacyMap() {
const entries = rows.slice(i, i + BATCH).map(r => ({
const entries = rows.slice(i, i + BATCH).map(r => ({
network_user_id: Number(r.service_user_id), source_system: 'live', source_type: 'user', source_id: String(r.user_id), verified: true,
network_user_id: Number(r.service_user_id), source_system: 'live', source_type: 'user', source_id: String(r.user_id), verified: true,
}));
}));
- const res = await fetch(`${OV_NETWORK_INTERNAL_URL}/internal/identity/legacy-map`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json', 'X-Internal-Key': INTERNAL_API_KEY },
- body: JSON.stringify({ entries }),
- signal: AbortSignal.timeout(15_000),
- });
+ const res = await networkPost('/internal/identity/legacy-map', { entries });
if (res.status === 404) return { skipped: 'network has no /internal/identity yet' };
if (res.status === 404) return { skipped: 'network has no /internal/identity yet' };
if (!res.ok) throw new Error(`legacy-map ${res.status}`);
if (!res.ok) throw new Error(`legacy-map ${res.status}`);
const out = await res.json();
const out = await res.json();
@@ -70,12 +81,7 @@ async function backfillSubjects() {
@@ -70,12 +81,7 @@ async function backfillSubjects() {
const store = db.getDb().prepare("UPDATE linked_accounts SET subject_id = ? WHERE service = 'network' AND user_id = ? AND service_user_id = ? AND subject_id IS NULL");
const store = db.getDb().prepare("UPDATE linked_accounts SET subject_id = ? WHERE service = 'network' AND user_id = ? AND service_user_id = ? AND subject_id IS NULL");
for (let i = 0; i < rows.length; i += BATCH) {
for (let i = 0; i < rows.length; i += BATCH) {
const batch = rows.slice(i, i + BATCH);
const batch = rows.slice(i, i + BATCH);
- const res = await fetch(`${OV_NETWORK_INTERNAL_URL}/internal/identity/resolve-batch`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json', 'X-Internal-Key': INTERNAL_API_KEY },
- body: JSON.stringify({ system: 'network', type: 'user', ids: batch.map(r => String(r.service_user_id)) }),
- signal: AbortSignal.timeout(15_000),
- });
+ const res = await networkPost('/internal/identity/resolve-batch', { system: 'network', type: 'user', ids: batch.map(r => String(r.service_user_id)) });
if (res.status === 404) return { ...total, skipped: 'network has no /internal/identity yet' };
if (res.status === 404) return { ...total, skipped: 'network has no /internal/identity yet' };
if (!res.ok) throw new Error(`resolve-batch ${res.status}`);
if (!res.ok) throw new Error(`resolve-batch ${res.status}`);
const { results = {} } = await res.json();
const { results = {} } = await res.json();
@@ -149,7 +149,7 @@ async function networkCoinStats() {
@@ -149,7 +149,7 @@ async function networkCoinStats() {
try {
try {
const ctl = AbortSignal.timeout ? AbortSignal.timeout(2500) : undefined;
const ctl = AbortSignal.timeout ? AbortSignal.timeout(2500) : undefined;
const res = await fetch(`${NETWORK_INTERNAL_URL}/internal/coins/stats`, {
const res = await fetch(`${NETWORK_INTERNAL_URL}/internal/coins/stats`, {
- headers: { 'X-Internal-Key': INTERNAL_API_KEY, Accept: 'application/json' },
+ headers: { ...(await principal.headersFor('/internal/coins/stats')), Accept: 'application/json' },
signal: ctl,
signal: ctl,
});
});
if (!res.ok) return _statsCache.data;
if (!res.ok) return _statsCache.data;
@@ -18,7 +18,9 @@ const CLIENT_SECRET = process.env.OV_OAUTH_CLIENT_SECRET || '';
@@ -18,7 +18,9 @@ const CLIENT_SECRET = process.env.OV_OAUTH_CLIENT_SECRET || '';
const PAUSE_MS = 5 * 60 * 1000;
const PAUSE_MS = 5 * 60 * 1000;
// Network routes that accept a service token (OpenVibe.Network server/internal/routes.js TOKEN_ROUTES).
// Network routes that accept a service token (OpenVibe.Network server/internal/routes.js TOKEN_ROUTES).
-const TOKEN_PATHS = new Set(['/internal/coins/credit', '/internal/coins/debit', '/internal/coins/transfer', '/internal/notifications/push', '/internal/notifications/push-bulk', '/internal/events/stream-live']);
+const TOKEN_PATHS = new Set(['/internal/coins/credit', '/internal/coins/debit', '/internal/coins/transfer', '/internal/notifications/push', '/internal/notifications/push-bulk', '/internal/events/stream-live',
+ // Network accepts a service token on these since 2026-09-24 (identity.subject.resolve / network.coins.credit).
+ '/internal/link-account', '/internal/identity/legacy-map', '/internal/identity/resolve-batch', '/internal/coins/stats', '/internal/resolve-anon']);
const _clients = new Map();
const _clients = new Map();
/** One cached client-credentials token client per audience (openvibe.network, openvibe.community, ...). */
/** One cached client-credentials token client per audience (openvibe.network, openvibe.community, ...). */
@@ -64,10 +64,11 @@ const network = http.createServer((req, res) => {
@@ -64,10 +64,11 @@ const network = http.createServer((req, res) => {
assert.deepStrictEqual(seen.map((s) => s.auth), ['token', 'token']);
assert.deepStrictEqual(seen.map((s) => s.auth), ['token', 'token']);
assert.strictEqual(tokenCalls, 1, 'one token for both calls');
assert.strictEqual(tokenCalls, 1, 'one token for both calls');
- // 2. Routes Network doesn't guard by capability keep the key.
+ // 2. link-account takes a service token too since Network guards it (identity.subject.resolve, 2026-09-24).
seen.length = 0;
seen.length = 0;
await (async () => { notify.reportLinkedAccount({ id: uid, username: 'p' }); await new Promise((x) => setTimeout(x, 150)); })();
await (async () => { notify.reportLinkedAccount({ id: uid, username: 'p' }); await new Promise((x) => setTimeout(x, 150)); })();
- assert.deepStrictEqual(seen.map((s) => `${s.url}:${s.auth}`), ['/internal/link-account:key']);
+ assert.deepStrictEqual(seen.map((s) => `${s.url}:${s.auth}`), ['/internal/link-account:token']);
+ assert.ok(!principal.TOKEN_PATHS.has('/internal/notifications/mark-read'), 'routes Network does not guard by capability keep the key');
// 3. A refused token is retried once with the key, and the key is used until the pause ends.
// 3. A refused token is retried once with the key, and the key is used until the pause ends.
seen.length = 0; mode.coins = 'refuse';
seen.length = 0; mode.coins = 'refuse';