openvibers/openvibe.community Commits

Community: a JWT without subject_id is no identity; censor takes a slug only (C-15) (#62)

OP OpenVibers committed 11 Oct 2026 · eafef6c5ee · 4 files, +16 −36

This closes compatibility register C-15. The JWT fallback and the censor route's numeric-id fallback go; the legacy columns and `legacy_id_map` stay as import provenance.

**JWT fallback:** a user token without `subject_id` used to be resolved by its Network account id through Network's legacy lookup and cached in `legacy_id_map`. The register's condition is met:
- Every Network user token has carried `subject_id` since Wave 1 (2026-09-22).
- Access tokens live 7 days (`accessTokenExpiry: '7d'`), so the last token without one expired by 2026-09-29.
- A refresh mints a token with `subject_id`.

`viewer.js` now treats a user token without `subject_id` as no identity, so the caller is anonymous, as with an expired token. `network.js` drops `subjectForNetworkUser`.

**Censor route:** `POST /api/pastes/:slug/censor` took a slug or a numeric row id. Live's staff UI, the only caller, sends the slug (`public/js/pastes.js`), so the route takes a slug only.

**Kept:** `legacy_id_map` itself (the paste-comment map and import provenance) and the import columns, as the register says.

**Tests:** in `pastes-api.test.js`, the old-JWT check now asserts a token without `subject_id` creates an anonymous paste and makes no Network lookup. The later comment and like checks use a token that carries Sam's subject.

`OV_ALLOW_SUITE=1 ov test --all`: 57 passed.

Changes

ddiff --git a/server/identity/network.js b/server/identity/network.js
iindex 5fe4530..30aeefa 100644
---- a/server/identity/network.js
++++ b/server/identity/network.js
@@@ -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 };
ddiff --git a/server/identity/viewer.js b/server/identity/viewer.js
iindex fa3a32e..5e8e7e4 100644
---- a/server/identity/viewer.js
++++ b/server/identity/viewer.js
@@@ -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');
ddiff --git a/server/pastes/service.js b/server/pastes/service.js
iindex 4336408..65b85c0 100644
---- a/server/pastes/service.js
++++ b/server/pastes/service.js
@@@ -683,10 +683,9 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
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.type !== 'screenshot' || !p.screenshot_url) fail(400, 'Not a screenshot paste');
if (!file || !file.buffer) fail(400, 'Censored image is required');
ddiff --git a/test/pastes-api.test.js b/test/pastes-api.test.js
iindex 9438a09..43e2a9a 100644
---- a/test/pastes-api.test.js
++++ b/test/pastes-api.test.js
@@@ -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 });