openvibers/openvibe.community Commits
Community: a JWT without subject_id is no identity; censor takes a slug only (C-15) (#62)
Changes
@@@ -87,21 +87,6 @@ function createNetworkIdentity({ config, db, fetchImpl = globalThis.fetch } = {}return map;}-- /**-- * The subject of a Network account id, for user JWTs issued before tokens carried-- * `subject_id`. Cached durably in legacy_id_map (network/user/<id> → subject).-- */-- async function subjectForNetworkUser(networkUserId) {-- if (networkUserId == null || networkUserId === '') return null;-- const hit = await store.mapGet(db, 'network', 'user', networkUserId);-- if (hit && hit.target_type === 'subject') return hit.target_id;-- const map = await resolveLegacy('network', [networkUserId]);-- const p = map.get(String(networkUserId));-- if (!p || !p.subject || p.subject.type !== 'user') return null;-- await store.mapSet(db, 'network', 'user', networkUserId, 'subject', p.subject.id);-- return p.subject.id;-- }--/** What a verified user JWT says about its holder is a fresh projection too. */async function rememberClaims(subjectId, claims) {if (!subjectId || !claims) return;@@@ -173,7 +158,7 @@ function createNetworkIdentity({ config, db, fetchImpl = globalThis.fetch } = {}return map;}-- return { resolveSubjects, resolveLegacy, subjectForNetworkUser, subjectForUsername, rememberClaims, projections, tokens };++ return { resolveSubjects, resolveLegacy, subjectForUsername, rememberClaims, projections, tokens };}module.exports = { createNetworkIdentity, PROJECTION_TTL_MS };@@@ -109,13 +109,13 @@ function createViewerResolver({ auth, config, network, revocations = null }) {if (!claims || (typeof claims.sub === 'string' && PRINCIPAL_SUB.test(claims.sub))) return null;// Issued before Network's cutoff for this person (signed out everywhere, password changed, banned).if (revocations && revocations.isRevoked(claims)) return null;-- let subject = ids.isSubjectId('user', claims.subject_id) ? claims.subject_id : null;-- if (!subject && network && claims.sub != null) {-- try { subject = await network.subjectForNetworkUser(claims.sub); } catch (err) { console.warn('[Identity] subject lookup failed:', err.message); }-- }-- if (subject && network) { try { await network.rememberClaims(subject, claims); } catch { /* display cache only */ } }++ // Every Network user token carries subject_id (since Wave 1, 2026-09-22; access tokens live 7 days), so one++ // without it is no usable identity: the caller is anonymous, as with an expired token. (The lookup of a++ // pre-subject Network id went with compatibility register C-15.)++ const subject = ids.isSubjectId('user', claims.subject_id) ? claims.subject_id : null;++ if (!subject) return null;++ if (network) { try { await network.rememberClaims(subject, claims); } catch { /* display cache only */ } }const user = claimsToUser(claims);-- if (subject && !user.subject_id) user.subject_id = subject;// Staff powers come from the contracts staff map (ADR-022): the role, or Network's issued staff_caps.const staff = staffMap.can(claims, 'staff.moderation.pastes');const discussionStaff = staffMap.can(claims, 'staff.moderation.discussions');@@@ -683,10 +683,9 @@ function createPasteService({ db, network = null, media = null, config = {}, limreturn { done, skipped };},-- /** POST /api/pastes/:slug/censor — replace a screenshot's image (slug or numeric id). */-- async censor(v, slugOrId, file) {-- let p = await store.getBySlug(db, String(slugOrId));-- if (!p && /^\d+$/.test(String(slugOrId))) p = await store.getById(db, parseInt(slugOrId, 10));++ /** POST /api/pastes/:slug/censor — replace a screenshot's image. (The numeric-id fallback went with C-15.) */++ async censor(v, slug, file) {++ const p = await store.getBySlug(db, String(slug));if (!p) fail(404, 'Paste not found');if (p.type !== 'screenshot' || !p.screenshot_url) fail(400, 'Not a screenshot paste');if (!file || !file.buffer) fail(400, 'Censored image is required');@@@ -18,6 +18,7 @@ const { boot, check, done } = require('./helpers/app');const kim = net.addUser({ network_user_id: 11, username: 'kim', display_name: 'Kim', avatar_url: '/avatar/kim' });const alexJwt = net.sign({ id: 7, subject_id: alex.subject_id, username: 'alex', display_name: 'Alex', role: 'user', profile_color: '#0af' });const samOldJwt = net.sign({ id: 9, username: 'sam', display_name: 'Sam', role: 'user' }); // pre-subject_id token++ const samJwt = net.sign({ id: 9, subject_id: sam.subject_id, username: 'sam', display_name: 'Sam', role: 'user' });const adminJwt = net.sign({ id: 1, subject_id: ids.newId('user'), username: 'boss', display_name: 'Boss', role: 'admin' });const svc = (cap, extra = {}) => net.signService({ cap, ...extra });const CREATE = 'community.paste.create', WRITE = 'community.paste.write', MOD = 'community.paste.moderate';@@@ -50,17 +51,12 @@ const { boot, check, done } = require('./helpers/app');alexSlug = out.slug;});-- await check('an older JWT without subject_id is resolved through the Network once, then cached', async () => {++ await check('a JWT without subject_id is no identity (C-15): the paste is anonymous and nothing is looked up', async () => {const before = net.resolveCalls.length;const r = await call('/api/pastes', { method: 'POST', token: samOldJwt, json: { content: 'hello from sam' } });assert.strictEqual(r.status, 201, r.text);-- assert.strictEqual(r.json().paste.owner_subject, sam.subject_id);-- const lookups = net.resolveCalls.slice(before);-- assert.deepStrictEqual(lookups[0], { system: 'network', type: 'user', ids: ['9'] });-- await call('/api/pastes', { method: 'POST', token: samOldJwt, json: { content: 'again' } });-- assert.strictEqual(net.resolveCalls.slice(before).filter((b) => b.system === 'network').length, 1, 'cached in legacy_id_map');-- const grant = net.grants.find((g) => g.grant_type === 'client_credentials' && g.audience === 'openvibe.network');-- assert.strictEqual(grant.scope, 'identity.subject.resolve');++ assert.strictEqual(r.json().paste.owner_subject, null, 'filed under nobody, as with an expired token');++ assert.strictEqual(net.resolveCalls.slice(before).filter((b) => b.system === 'network').length, 0, 'no pre-subject id lookup');});await check('service token: create as the acting subject (X-OV-Subject)', async () => {@@@ -138,7 +134,7 @@ const { boot, check, done } = require('./helpers/app');});await check('staff: X-OV-Staff needs community.paste.moderate; with it a service may delete anyone\'s comment', async () => {-- const c = (await call(`/api/pastes/${alexSlug}/comments`, { method: 'POST', token: samOldJwt, json: { message: 'spam spam' } })).json().comment;++ const c = (await call(`/api/pastes/${alexSlug}/comments`, { method: 'POST', token: samJwt, json: { message: 'spam spam' } })).json().comment;assert.strictEqual(c.username, 'sam');const notStaff = await call(`/api/pastes/${alexSlug}/comments/${c.id}`, { method: 'DELETE', token: svc([WRITE]), headers: { 'x-ov-subject': kim.subject_id } });assert.strictEqual(notStaff.status, 403);@@@ -345,7 +341,7 @@ const { boot, check, done } = require('./helpers/app');const like = await call(`/api/pastes/${alexSlug}/like`, { method: 'POST' });assert.strictEqual(like.status, 401);assert.deepStrictEqual(like.json(), { error: 'Authentication required' });-- const liked = await call(`/api/pastes/${alexSlug}/like`, { method: 'POST', token: samOldJwt });++ const liked = await call(`/api/pastes/${alexSlug}/like`, { method: 'POST', token: samJwt });assert.deepStrictEqual(liked.json(), { liked: true, likes: 1 });const viaSvc = await call(`/api/pastes/${alexSlug}/like`, { method: 'POST', token: svc([WRITE]), headers: { 'x-ov-subject': kim.subject_id } });assert.deepStrictEqual(viaSvc.json(), { liked: true, likes: 2 });
@@ -87,21 +87,6 @@ function createNetworkIdentity({ config, db, fetchImpl = globalThis.fetch } = {}
@@ -87,21 +87,6 @@ function createNetworkIdentity({ config, db, fetchImpl = globalThis.fetch } = {}
return map;
return map;
}
}
- /**
- * The subject of a Network account id, for user JWTs issued before tokens carried
- * `subject_id`. Cached durably in legacy_id_map (network/user/<id> → subject).
- */
- async function subjectForNetworkUser(networkUserId) {
- if (networkUserId == null || networkUserId === '') return null;
- const hit = await store.mapGet(db, 'network', 'user', networkUserId);
- if (hit && hit.target_type === 'subject') return hit.target_id;
- const map = await resolveLegacy('network', [networkUserId]);
- const p = map.get(String(networkUserId));
- if (!p || !p.subject || p.subject.type !== 'user') return null;
- await store.mapSet(db, 'network', 'user', networkUserId, 'subject', p.subject.id);
- return p.subject.id;
- }
-
/** What a verified user JWT says about its holder is a fresh projection too. */
/** What a verified user JWT says about its holder is a fresh projection too. */
async function rememberClaims(subjectId, claims) {
async function rememberClaims(subjectId, claims) {
if (!subjectId || !claims) return;
if (!subjectId || !claims) return;
@@ -173,7 +158,7 @@ function createNetworkIdentity({ config, db, fetchImpl = globalThis.fetch } = {}
@@ -173,7 +158,7 @@ function createNetworkIdentity({ config, db, fetchImpl = globalThis.fetch } = {}
return map;
return map;
}
}
- return { resolveSubjects, resolveLegacy, subjectForNetworkUser, subjectForUsername, rememberClaims, projections, tokens };
+ return { resolveSubjects, resolveLegacy, subjectForUsername, rememberClaims, projections, tokens };
}
}
module.exports = { createNetworkIdentity, PROJECTION_TTL_MS };
module.exports = { createNetworkIdentity, PROJECTION_TTL_MS };
@@ -109,13 +109,13 @@ function createViewerResolver({ auth, config, network, revocations = null }) {
@@ -109,13 +109,13 @@ function createViewerResolver({ auth, config, network, revocations = null }) {
if (!claims || (typeof claims.sub === 'string' && PRINCIPAL_SUB.test(claims.sub))) return null;
if (!claims || (typeof claims.sub === 'string' && PRINCIPAL_SUB.test(claims.sub))) return null;
// Issued before Network's cutoff for this person (signed out everywhere, password changed, banned).
// Issued before Network's cutoff for this person (signed out everywhere, password changed, banned).
if (revocations && revocations.isRevoked(claims)) return null;
if (revocations && revocations.isRevoked(claims)) return null;
- let subject = ids.isSubjectId('user', claims.subject_id) ? claims.subject_id : null;
- if (!subject && network && claims.sub != null) {
- try { subject = await network.subjectForNetworkUser(claims.sub); } catch (err) { console.warn('[Identity] subject lookup failed:', err.message); }
- }
- if (subject && network) { try { await network.rememberClaims(subject, claims); } catch { /* display cache only */ } }
+ // Every Network user token carries subject_id (since Wave 1, 2026-09-22; access tokens live 7 days), so one
+ // without it is no usable identity: the caller is anonymous, as with an expired token. (The lookup of a
+ // pre-subject Network id went with compatibility register C-15.)
+ const subject = ids.isSubjectId('user', claims.subject_id) ? claims.subject_id : null;
+ if (!subject) return null;
+ if (network) { try { await network.rememberClaims(subject, claims); } catch { /* display cache only */ } }
const user = claimsToUser(claims);
const user = claimsToUser(claims);
- if (subject && !user.subject_id) user.subject_id = subject;
// Staff powers come from the contracts staff map (ADR-022): the role, or Network's issued staff_caps.
// Staff powers come from the contracts staff map (ADR-022): the role, or Network's issued staff_caps.
const staff = staffMap.can(claims, 'staff.moderation.pastes');
const staff = staffMap.can(claims, 'staff.moderation.pastes');
const discussionStaff = staffMap.can(claims, 'staff.moderation.discussions');
const discussionStaff = staffMap.can(claims, 'staff.moderation.discussions');
@@ -683,10 +683,9 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
@@ -683,10 +683,9 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
return { done, skipped };
return { done, skipped };
},
},
- /** POST /api/pastes/:slug/censor — replace a screenshot's image (slug or numeric id). */
- async censor(v, slugOrId, file) {
- let p = await store.getBySlug(db, String(slugOrId));
- if (!p && /^\d+$/.test(String(slugOrId))) p = await store.getById(db, parseInt(slugOrId, 10));
+ /** POST /api/pastes/:slug/censor — replace a screenshot's image. (The numeric-id fallback went with C-15.) */
+ async censor(v, slug, file) {
+ const p = await store.getBySlug(db, String(slug));
if (!p) fail(404, 'Paste not found');
if (!p) fail(404, 'Paste not found');
if (p.type !== 'screenshot' || !p.screenshot_url) fail(400, 'Not a screenshot paste');
if (p.type !== 'screenshot' || !p.screenshot_url) fail(400, 'Not a screenshot paste');
if (!file || !file.buffer) fail(400, 'Censored image is required');
if (!file || !file.buffer) fail(400, 'Censored image is required');
@@ -18,6 +18,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -18,6 +18,7 @@ const { boot, check, done } = require('./helpers/app');
const kim = net.addUser({ network_user_id: 11, username: 'kim', display_name: 'Kim', avatar_url: '/avatar/kim' });
const kim = net.addUser({ network_user_id: 11, username: 'kim', display_name: 'Kim', avatar_url: '/avatar/kim' });
const alexJwt = net.sign({ id: 7, subject_id: alex.subject_id, username: 'alex', display_name: 'Alex', role: 'user', profile_color: '#0af' });
const alexJwt = net.sign({ id: 7, subject_id: alex.subject_id, username: 'alex', display_name: 'Alex', role: 'user', profile_color: '#0af' });
const samOldJwt = net.sign({ id: 9, username: 'sam', display_name: 'Sam', role: 'user' }); // pre-subject_id token
const samOldJwt = net.sign({ id: 9, username: 'sam', display_name: 'Sam', role: 'user' }); // pre-subject_id token
+ const samJwt = net.sign({ id: 9, subject_id: sam.subject_id, username: 'sam', display_name: 'Sam', role: 'user' });
const adminJwt = net.sign({ id: 1, subject_id: ids.newId('user'), username: 'boss', display_name: 'Boss', role: 'admin' });
const adminJwt = net.sign({ id: 1, subject_id: ids.newId('user'), username: 'boss', display_name: 'Boss', role: 'admin' });
const svc = (cap, extra = {}) => net.signService({ cap, ...extra });
const svc = (cap, extra = {}) => net.signService({ cap, ...extra });
const CREATE = 'community.paste.create', WRITE = 'community.paste.write', MOD = 'community.paste.moderate';
const CREATE = 'community.paste.create', WRITE = 'community.paste.write', MOD = 'community.paste.moderate';
@@ -50,17 +51,12 @@ const { boot, check, done } = require('./helpers/app');
@@ -50,17 +51,12 @@ const { boot, check, done } = require('./helpers/app');
alexSlug = out.slug;
alexSlug = out.slug;
});
});
- await check('an older JWT without subject_id is resolved through the Network once, then cached', async () => {
+ await check('a JWT without subject_id is no identity (C-15): the paste is anonymous and nothing is looked up', async () => {
const before = net.resolveCalls.length;
const before = net.resolveCalls.length;
const r = await call('/api/pastes', { method: 'POST', token: samOldJwt, json: { content: 'hello from sam' } });
const r = await call('/api/pastes', { method: 'POST', token: samOldJwt, json: { content: 'hello from sam' } });
assert.strictEqual(r.status, 201, r.text);
assert.strictEqual(r.status, 201, r.text);
- assert.strictEqual(r.json().paste.owner_subject, sam.subject_id);
- const lookups = net.resolveCalls.slice(before);
- assert.deepStrictEqual(lookups[0], { system: 'network', type: 'user', ids: ['9'] });
- await call('/api/pastes', { method: 'POST', token: samOldJwt, json: { content: 'again' } });
- assert.strictEqual(net.resolveCalls.slice(before).filter((b) => b.system === 'network').length, 1, 'cached in legacy_id_map');
- const grant = net.grants.find((g) => g.grant_type === 'client_credentials' && g.audience === 'openvibe.network');
- assert.strictEqual(grant.scope, 'identity.subject.resolve');
+ assert.strictEqual(r.json().paste.owner_subject, null, 'filed under nobody, as with an expired token');
+ assert.strictEqual(net.resolveCalls.slice(before).filter((b) => b.system === 'network').length, 0, 'no pre-subject id lookup');
});
});
await check('service token: create as the acting subject (X-OV-Subject)', async () => {
await check('service token: create as the acting subject (X-OV-Subject)', async () => {
@@ -138,7 +134,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -138,7 +134,7 @@ const { boot, check, done } = require('./helpers/app');
});
});
await check('staff: X-OV-Staff needs community.paste.moderate; with it a service may delete anyone\'s comment', async () => {
await check('staff: X-OV-Staff needs community.paste.moderate; with it a service may delete anyone\'s comment', async () => {
- const c = (await call(`/api/pastes/${alexSlug}/comments`, { method: 'POST', token: samOldJwt, json: { message: 'spam spam' } })).json().comment;
+ const c = (await call(`/api/pastes/${alexSlug}/comments`, { method: 'POST', token: samJwt, json: { message: 'spam spam' } })).json().comment;
assert.strictEqual(c.username, 'sam');
assert.strictEqual(c.username, 'sam');
const notStaff = await call(`/api/pastes/${alexSlug}/comments/${c.id}`, { method: 'DELETE', token: svc([WRITE]), headers: { 'x-ov-subject': kim.subject_id } });
const notStaff = await call(`/api/pastes/${alexSlug}/comments/${c.id}`, { method: 'DELETE', token: svc([WRITE]), headers: { 'x-ov-subject': kim.subject_id } });
assert.strictEqual(notStaff.status, 403);
assert.strictEqual(notStaff.status, 403);
@@ -345,7 +341,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -345,7 +341,7 @@ const { boot, check, done } = require('./helpers/app');
const like = await call(`/api/pastes/${alexSlug}/like`, { method: 'POST' });
const like = await call(`/api/pastes/${alexSlug}/like`, { method: 'POST' });
assert.strictEqual(like.status, 401);
assert.strictEqual(like.status, 401);
assert.deepStrictEqual(like.json(), { error: 'Authentication required' });
assert.deepStrictEqual(like.json(), { error: 'Authentication required' });
- const liked = await call(`/api/pastes/${alexSlug}/like`, { method: 'POST', token: samOldJwt });
+ const liked = await call(`/api/pastes/${alexSlug}/like`, { method: 'POST', token: samJwt });
assert.deepStrictEqual(liked.json(), { liked: true, likes: 1 });
assert.deepStrictEqual(liked.json(), { liked: true, likes: 1 });
const viaSvc = await call(`/api/pastes/${alexSlug}/like`, { method: 'POST', token: svc([WRITE]), headers: { 'x-ov-subject': kim.subject_id } });
const viaSvc = await call(`/api/pastes/${alexSlug}/like`, { method: 'POST', token: svc([WRITE]), headers: { 'x-ov-subject': kim.subject_id } });
assert.deepStrictEqual(viaSvc.json(), { liked: true, likes: 2 });
assert.deepStrictEqual(viaSvc.json(), { liked: true, likes: 2 });