openvibers/openvibe.watch Commits
Watch: accept a person's Network session token on the API (#153)
Changes
@@@ -3,6 +3,14 @@What shipped on OpenVibe.Watch, newest first. The plan is T18 ("Watch complete"); each entry namesthe step it landed.++## 2026-10-31 — a person's Network session token on the API++++- The watch API accepts a signed-in person's Network session token (`aud: openvibe.network`) as a Bearer, the same++ token the site's SSO verifies, as well as a person/agent token minted for `openvibe.watch`. A first-party service++ such as Shoes can therefore create a watch with the person's own token instead of a service token. The watch is++ owned by the token's canonical `usr_…`/`agt_…` `subject_id` (a Network session token's `sub` is the numeric++ account id), so project delegation and the person-only actions keep working. `server/auth.js`, `test/api.test.js`.++## 2026-10-13 — check-operation usage readings (plan T5 step 14, Watch slice)- A completed check queues one `platform.usage-sample@1` reading (metric `check-operation`, quantity 1, unit `check`,@@@ -313,11 +313,12 @@ retention.## API (bearer tokens, one capability per route)--Callers present an OpenVibe.Network token for audience `openvibe.watch`: a service's--client-credentials token (judged by its `cap` claim with the contracts grant rule) or a signed-in--person's or agent's token (`usr_…`, `agt_…` — a watch is theirs, and ownership scoping is the--authority). A first-party service (`svc:…`) may present `X-OV-Subject: usr_…` to act for a person;--an app or module may not.++Callers present an OpenVibe.Network token: a service's client-credentials token for audience++`openvibe.watch` (judged by its `cap` claim with the contracts grant rule), or a signed-in person's++or agent's token — either a token minted for `openvibe.watch` or the Network session token the++signed-in site holds (`aud: openvibe.network`); a watch is theirs, owned by the canonical++`usr_…`/`agt_…` `subject_id`, and ownership scoping is the authority. A first-party service++(`svc:…`) may present `X-OV-Subject: usr_…` to act for a person; an app or module may not.| Capability | Routes ||---|---|@@@ -11,11 +11,15 @@* watch.* capabilities are released, so the library decides every one of them.** a person the bearer token of a signed-in user (sub usr_…) or agent (sub agt_…), verified-- * with openvibe-sdk/auth verifyUserToken for the same issuer and audience. Such a-- * token carries no capability list: watches are user-defined resources and the guard-- * that matters is ownership — every route scopes to the acting subject, and another-- * owner's watch answers 404 (registry.js). A person may therefore use all four watch-- * capabilities, on their own watches only.++ * with openvibe-sdk/auth verifyUserToken for the same issuer. A token may be minted++ * for this service's audience (openvibe.watch, as a CLI or app token is) or be the++ * Network session token a signed-in site holds (openvibe.network); both prove the++ * same person. The canonical usr_…/agt_… subject_id is the owner (a Network session++ * token's `sub` is the numeric account id). Such a token carries no capability list:++ * watches are user-defined resources and the guard that matters is ownership — every++ * route scopes to the acting subject, and another owner's watch answers 404++ * (registry.js). A person may therefore use all four watch capabilities, on their++ * own watches only.** X-OV-Subject, presented by a first-party service (svc:…) that acts for a person (the public site* going through Network/Services, or an agent's runtime), names the subject a route acts for; the@@@ -68,8 +72,10 @@ function createAuth({ config, log = console }) {/** { ok, claims } or { ok: false, code, reason, status }. Every token rule is the SDK's verifyUserToken. */async function verifyUser(token) {++ // A person's token is minted either for this service or by Network sign-in (aud openvibe.network).++ const audience = [...new Set([config.audience, config.oauth.sessionAudience].filter(Boolean))];try {-- const claims = await verifyUserToken(token, { jwks: config.jwksUrl, issuer: config.issuer, audience: config.audience });++ const claims = await verifyUserToken(token, { jwks: config.jwksUrl, issuer: config.issuer, audience });return { ok: true, claims };} catch (err) {const code = (err && err.code) || 'token.malformed';@@@ -89,9 +95,11 @@ function createAuth({ config, log = console }) {if (!h.startsWith('Bearer ')) return http.sendProblem(res, 401, 'token.missing', { detail: 'a service or user token is required', ctx });const token = h.slice(7).trim();const payload = tokenPayload(token);-- if (!payload || typeof payload.sub !== 'string') return http.sendProblem(res, 401, 'token.malformed', { detail: 'not a signed JWT', ctx });++ // A service principal's sub is svc:…/app:…/mod:…; a Network session token's is the numeric account id,++ // and its canonical usr_…/agt_… subject_id is the person. Anything else is not a token this API knows.++ if (!payload || (typeof payload.sub !== 'string' && typeof payload.sub !== 'number')) return http.sendProblem(res, 401, 'token.malformed', { detail: 'not a signed JWT', ctx });-- if (SERVICE_SUB.test(payload.sub)) {++ if (typeof payload.sub === 'string' && SERVICE_SUB.test(payload.sub)) {// The keys come from the SDK's JWKS client (one per URL, started at boot: the last good keys through a// Network outage, a rotation honoured on an unknown kid); every token rule is openvibe-contracts'// verifyServiceToken (identity.service-token-claims@1, sandbox refused, issuer, audience, expiry).@@@ -108,7 +116,12 @@ function createAuth({ config, log = console }) {const r = await verifyUser(token);if (!r.ok) return http.sendProblem(res, r.status === 503 ? 503 : 401, r.code, { detail: r.reason, ctx });const claims = r.claims;-- req.principal = { sub: claims.sub, kind: 'person', role: claims.role || null, cap: null, jti: claims.jti || null, subject: null };++ // Network session tokens name the account in `sub` (numeric) and the person in `subject_id`.++ const subject = PERSON_SUB.test(String(claims.subject_id || '')) ? String(claims.subject_id) : String(claims.sub);++ // The owner is always a canonical usr_…/agt_… id: a token naming only a bare account number (or anything++ // else) owns nothing here, so no watch is ever filed under a name another sign-in could also carry.++ if (!PERSON_SUB.test(subject)) return http.sendProblem(res, 401, 'token.subject_unknown', { detail: 'the token names no usr_… or agt_… subject', ctx });++ req.principal = { sub: subject, kind: 'person', role: claims.role || null, cap: null, jti: claims.jti || null, subject: null };}// X-OV-Subject: only a first-party service may name the person it acts for, and only a@@@ -53,6 +53,22 @@ t('every route needs a token, and the one capability it serves', async () => {assert.strictEqual((await api('GET', `/api/v1/watches/${ghost}/checks`, { token: OBSERVER })).status, 404);});++t('a person\'s Network session token works, and owns the watch by its canonical subject', async () => {++ const who = newUser();++ // A Network sign-in token: aud openvibe.network, `sub` the numeric account id, `subject_id` the usr_ subject.++ const session = userToken(who, { aud: 'openvibe.network', extra: { sub: 4242 } });++ const created = await api('POST', '/api/v1/watches', { token: session, body: mine({ name: 'a session-token watch' }) });++ assert.strictEqual(created.status, 201, created.text);++ assert.strictEqual(created.body.watch.owner, who, 'the canonical usr_ subject is the owner, not the numeric account id');++ const list = await api('GET', '/api/v1/watches', { token: session });++ assert.ok(list.body.watches.some((w) => w.id === created.body.watch.id), 'the watch is listed for that session');++ assert.strictEqual((await api('GET', `/api/v1/watches/${created.body.watch.id}`, { token: userToken(other, { aud: 'openvibe.network' }) })).status, 404, 'another person cannot read it');++ assert.strictEqual((await api('GET', '/api/v1/watches', { token: userToken(who, { aud: 'openvibe.deals' }) })).status, 401, 'a token for another service is still refused');++ const bare = userToken(who, { aud: 'openvibe.network', extra: { sub: 4242, subject_id: null } });++ const refused = await api('GET', '/api/v1/watches', { token: bare });++ assert.strictEqual(refused.status, 401, 'a session token with no usr_ subject owns nothing');++});++t('create, read, list: the released contracts on both sides, and the view is the contract', async () => {const created = await api('POST', '/api/v1/watches', { token: ME, body: mine({ labels: { room: 'kitchen' } }) });assert.strictEqual(created.status, 201, created.text);
@@ -3,6 +3,14 @@
@@ -3,6 +3,14 @@
What shipped on OpenVibe.Watch, newest first. The plan is T18 ("Watch complete"); each entry names
What shipped on OpenVibe.Watch, newest first. The plan is T18 ("Watch complete"); each entry names
the step it landed.
the step it landed.
+## 2026-10-31 — a person's Network session token on the API
+
+- The watch API accepts a signed-in person's Network session token (`aud: openvibe.network`) as a Bearer, the same
+ token the site's SSO verifies, as well as a person/agent token minted for `openvibe.watch`. A first-party service
+ such as Shoes can therefore create a watch with the person's own token instead of a service token. The watch is
+ owned by the token's canonical `usr_…`/`agt_…` `subject_id` (a Network session token's `sub` is the numeric
+ account id), so project delegation and the person-only actions keep working. `server/auth.js`, `test/api.test.js`.
+
## 2026-10-13 — check-operation usage readings (plan T5 step 14, Watch slice)
## 2026-10-13 — check-operation usage readings (plan T5 step 14, Watch slice)
- A completed check queues one `platform.usage-sample@1` reading (metric `check-operation`, quantity 1, unit `check`,
- A completed check queues one `platform.usage-sample@1` reading (metric `check-operation`, quantity 1, unit `check`,
@@ -313,11 +313,12 @@ retention.
@@ -313,11 +313,12 @@ retention.
## API (bearer tokens, one capability per route)
## API (bearer tokens, one capability per route)
-Callers present an OpenVibe.Network token for audience `openvibe.watch`: a service's
-client-credentials token (judged by its `cap` claim with the contracts grant rule) or a signed-in
-person's or agent's token (`usr_…`, `agt_…` — a watch is theirs, and ownership scoping is the
-authority). A first-party service (`svc:…`) may present `X-OV-Subject: usr_…` to act for a person;
-an app or module may not.
+Callers present an OpenVibe.Network token: a service's client-credentials token for audience
+`openvibe.watch` (judged by its `cap` claim with the contracts grant rule), or a signed-in person's
+or agent's token — either a token minted for `openvibe.watch` or the Network session token the
+signed-in site holds (`aud: openvibe.network`); a watch is theirs, owned by the canonical
+`usr_…`/`agt_…` `subject_id`, and ownership scoping is the authority. A first-party service
+(`svc:…`) may present `X-OV-Subject: usr_…` to act for a person; an app or module may not.
| Capability | Routes |
| Capability | Routes |
|---|---|
|---|---|
@@ -11,11 +11,15 @@
@@ -11,11 +11,15 @@
* watch.* capabilities are released, so the library decides every one of them.
* watch.* capabilities are released, so the library decides every one of them.
*
*
* a person the bearer token of a signed-in user (sub usr_…) or agent (sub agt_…), verified
* a person the bearer token of a signed-in user (sub usr_…) or agent (sub agt_…), verified
- * with openvibe-sdk/auth verifyUserToken for the same issuer and audience. Such a
- * token carries no capability list: watches are user-defined resources and the guard
- * that matters is ownership — every route scopes to the acting subject, and another
- * owner's watch answers 404 (registry.js). A person may therefore use all four watch
- * capabilities, on their own watches only.
+ * with openvibe-sdk/auth verifyUserToken for the same issuer. A token may be minted
+ * for this service's audience (openvibe.watch, as a CLI or app token is) or be the
+ * Network session token a signed-in site holds (openvibe.network); both prove the
+ * same person. The canonical usr_…/agt_… subject_id is the owner (a Network session
+ * token's `sub` is the numeric account id). Such a token carries no capability list:
+ * watches are user-defined resources and the guard that matters is ownership — every
+ * route scopes to the acting subject, and another owner's watch answers 404
+ * (registry.js). A person may therefore use all four watch capabilities, on their
+ * own watches only.
*
*
* X-OV-Subject, presented by a first-party service (svc:…) that acts for a person (the public site
* X-OV-Subject, presented by a first-party service (svc:…) that acts for a person (the public site
* going through Network/Services, or an agent's runtime), names the subject a route acts for; the
* going through Network/Services, or an agent's runtime), names the subject a route acts for; the
@@ -68,8 +72,10 @@ function createAuth({ config, log = console }) {
@@ -68,8 +72,10 @@ function createAuth({ config, log = console }) {
/** { ok, claims } or { ok: false, code, reason, status }. Every token rule is the SDK's verifyUserToken. */
/** { ok, claims } or { ok: false, code, reason, status }. Every token rule is the SDK's verifyUserToken. */
async function verifyUser(token) {
async function verifyUser(token) {
+ // A person's token is minted either for this service or by Network sign-in (aud openvibe.network).
+ const audience = [...new Set([config.audience, config.oauth.sessionAudience].filter(Boolean))];
try {
try {
- const claims = await verifyUserToken(token, { jwks: config.jwksUrl, issuer: config.issuer, audience: config.audience });
+ const claims = await verifyUserToken(token, { jwks: config.jwksUrl, issuer: config.issuer, audience });
return { ok: true, claims };
return { ok: true, claims };
} catch (err) {
} catch (err) {
const code = (err && err.code) || 'token.malformed';
const code = (err && err.code) || 'token.malformed';
@@ -89,9 +95,11 @@ function createAuth({ config, log = console }) {
@@ -89,9 +95,11 @@ function createAuth({ config, log = console }) {
if (!h.startsWith('Bearer ')) return http.sendProblem(res, 401, 'token.missing', { detail: 'a service or user token is required', ctx });
if (!h.startsWith('Bearer ')) return http.sendProblem(res, 401, 'token.missing', { detail: 'a service or user token is required', ctx });
const token = h.slice(7).trim();
const token = h.slice(7).trim();
const payload = tokenPayload(token);
const payload = tokenPayload(token);
- if (!payload || typeof payload.sub !== 'string') return http.sendProblem(res, 401, 'token.malformed', { detail: 'not a signed JWT', ctx });
+ // A service principal's sub is svc:…/app:…/mod:…; a Network session token's is the numeric account id,
+ // and its canonical usr_…/agt_… subject_id is the person. Anything else is not a token this API knows.
+ if (!payload || (typeof payload.sub !== 'string' && typeof payload.sub !== 'number')) return http.sendProblem(res, 401, 'token.malformed', { detail: 'not a signed JWT', ctx });
- if (SERVICE_SUB.test(payload.sub)) {
+ if (typeof payload.sub === 'string' && SERVICE_SUB.test(payload.sub)) {
// The keys come from the SDK's JWKS client (one per URL, started at boot: the last good keys through a
// The keys come from the SDK's JWKS client (one per URL, started at boot: the last good keys through a
// Network outage, a rotation honoured on an unknown kid); every token rule is openvibe-contracts'
// Network outage, a rotation honoured on an unknown kid); every token rule is openvibe-contracts'
// verifyServiceToken (identity.service-token-claims@1, sandbox refused, issuer, audience, expiry).
// verifyServiceToken (identity.service-token-claims@1, sandbox refused, issuer, audience, expiry).
@@ -108,7 +116,12 @@ function createAuth({ config, log = console }) {
@@ -108,7 +116,12 @@ function createAuth({ config, log = console }) {
const r = await verifyUser(token);
const r = await verifyUser(token);
if (!r.ok) return http.sendProblem(res, r.status === 503 ? 503 : 401, r.code, { detail: r.reason, ctx });
if (!r.ok) return http.sendProblem(res, r.status === 503 ? 503 : 401, r.code, { detail: r.reason, ctx });
const claims = r.claims;
const claims = r.claims;
- req.principal = { sub: claims.sub, kind: 'person', role: claims.role || null, cap: null, jti: claims.jti || null, subject: null };
+ // Network session tokens name the account in `sub` (numeric) and the person in `subject_id`.
+ const subject = PERSON_SUB.test(String(claims.subject_id || '')) ? String(claims.subject_id) : String(claims.sub);
+ // The owner is always a canonical usr_…/agt_… id: a token naming only a bare account number (or anything
+ // else) owns nothing here, so no watch is ever filed under a name another sign-in could also carry.
+ if (!PERSON_SUB.test(subject)) return http.sendProblem(res, 401, 'token.subject_unknown', { detail: 'the token names no usr_… or agt_… subject', ctx });
+ req.principal = { sub: subject, kind: 'person', role: claims.role || null, cap: null, jti: claims.jti || null, subject: null };
}
}
// X-OV-Subject: only a first-party service may name the person it acts for, and only a
// X-OV-Subject: only a first-party service may name the person it acts for, and only a
@@ -53,6 +53,22 @@ t('every route needs a token, and the one capability it serves', async () => {
@@ -53,6 +53,22 @@ t('every route needs a token, and the one capability it serves', async () => {
assert.strictEqual((await api('GET', `/api/v1/watches/${ghost}/checks`, { token: OBSERVER })).status, 404);
assert.strictEqual((await api('GET', `/api/v1/watches/${ghost}/checks`, { token: OBSERVER })).status, 404);
});
});
+t('a person\'s Network session token works, and owns the watch by its canonical subject', async () => {
+ const who = newUser();
+ // A Network sign-in token: aud openvibe.network, `sub` the numeric account id, `subject_id` the usr_ subject.
+ const session = userToken(who, { aud: 'openvibe.network', extra: { sub: 4242 } });
+ const created = await api('POST', '/api/v1/watches', { token: session, body: mine({ name: 'a session-token watch' }) });
+ assert.strictEqual(created.status, 201, created.text);
+ assert.strictEqual(created.body.watch.owner, who, 'the canonical usr_ subject is the owner, not the numeric account id');
+ const list = await api('GET', '/api/v1/watches', { token: session });
+ assert.ok(list.body.watches.some((w) => w.id === created.body.watch.id), 'the watch is listed for that session');
+ assert.strictEqual((await api('GET', `/api/v1/watches/${created.body.watch.id}`, { token: userToken(other, { aud: 'openvibe.network' }) })).status, 404, 'another person cannot read it');
+ assert.strictEqual((await api('GET', '/api/v1/watches', { token: userToken(who, { aud: 'openvibe.deals' }) })).status, 401, 'a token for another service is still refused');
+ const bare = userToken(who, { aud: 'openvibe.network', extra: { sub: 4242, subject_id: null } });
+ const refused = await api('GET', '/api/v1/watches', { token: bare });
+ assert.strictEqual(refused.status, 401, 'a session token with no usr_ subject owns nothing');
+});
+
t('create, read, list: the released contracts on both sides, and the view is the contract', async () => {
t('create, read, list: the released contracts on both sides, and the view is the contract', async () => {
const created = await api('POST', '/api/v1/watches', { token: ME, body: mine({ labels: { room: 'kitchen' } }) });
const created = await api('POST', '/api/v1/watches', { token: ME, body: mine({ labels: { room: 'kitchen' } }) });
assert.strictEqual(created.status, 201, created.text);
assert.strictEqual(created.status, 201, created.text);