@oxy.so/core 7.10.5 → 7.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,6 +10,7 @@ exports.SessionApi = void 0;
10
10
  */
11
11
  const jwt_decode_1 = require("jwt-decode");
12
12
  const userIdentity_1 = require("../utils/userIdentity");
13
+ const OxyServices_errors_1 = require("../OxyServices.errors");
13
14
  class SessionApi {
14
15
  constructor(ctx) {
15
16
  this.ctx = ctx;
@@ -144,13 +145,31 @@ class SessionApi {
144
145
  * validates the session it was issued for.
145
146
  */
146
147
  async validate(sessionId, options = {}) {
148
+ if ('liveSession' in options)
149
+ throw new OxyServices_errors_1.OxyApiError('Live session validation requires OxyServer', {
150
+ status: 503,
151
+ code: 'SESSION_AUTHORITY_UNAVAILABLE',
152
+ });
153
+ return this.validateRequest(sessionId, options);
154
+ }
155
+ /** Server implementations use this transport after enforcing their own contract. */
156
+ async validateRequest(sessionId, options) {
147
157
  try {
158
+ if (options.liveSession && !options.accessToken)
159
+ throw new OxyServices_errors_1.OxyApiError('Live session authority requires a bearer', {
160
+ status: 503,
161
+ code: 'SESSION_AUTHORITY_UNAVAILABLE',
162
+ });
148
163
  const validation = options.accessToken
149
164
  ? await this.ctx.request('GET', `/session/validate-header/${encodeURIComponent(sessionId)}`, undefined, {
150
165
  cache: false,
151
166
  skipAuth: true,
167
+ ...(options.liveSession ? { deduplicate: false, retry: false } : {}),
152
168
  headers: {
153
169
  Authorization: `Bearer ${options.accessToken}`,
170
+ ...(options.liveSession
171
+ ? { 'x-oxy-session-authority': 'live-v1', 'cache-control': 'no-store' }
172
+ : {}),
154
173
  ...(options.deviceFingerprint
155
174
  ? { 'x-device-fingerprint': options.deviceFingerprint }
156
175
  : {}),
@@ -162,9 +181,36 @@ class SessionApi {
162
181
  : {}),
163
182
  ...(options.useHeaderValidation ? { useHeaderValidation: 'true' } : {}),
164
183
  }, { cache: false });
184
+ if (options.liveSession &&
185
+ (validation.liveSessionAuthority !== 'session-row-v1' ||
186
+ validation.valid !== true ||
187
+ validation.sessionId !== sessionId ||
188
+ typeof validation.expiresAt !== 'string' ||
189
+ !Number.isFinite(Date.parse(validation.expiresAt)) ||
190
+ Date.parse(validation.expiresAt) <= Date.now() ||
191
+ typeof validation.lastActivity !== 'string' ||
192
+ !Number.isFinite(Date.parse(validation.lastActivity))))
193
+ throw new OxyServices_errors_1.OxyApiError('Live session authority is unavailable', {
194
+ status: 503,
195
+ code: 'SESSION_AUTHORITY_UNAVAILABLE',
196
+ });
165
197
  return { ...validation, user: (0, userIdentity_1.normalizeUserIdentity)(validation.user) };
166
198
  }
167
199
  catch (error) {
200
+ if (options.liveSession) {
201
+ const candidate = error;
202
+ const invalid = candidate?.status === 401 && candidate.code === 'OXY_LIVE_SESSION_INVALID_V1';
203
+ const denied = candidate?.status === 403 && candidate.code === 'OXY_LIVE_SESSION_POLICY_V1';
204
+ if (invalid)
205
+ this.ctx.oxy.cache.delete(`GET:/session/user/${sessionId}`);
206
+ if (invalid || denied)
207
+ throw error;
208
+ throw new OxyServices_errors_1.OxyApiError('Live session authority is unavailable', {
209
+ status: 503,
210
+ code: 'SESSION_AUTHORITY_UNAVAILABLE',
211
+ cause: error,
212
+ });
213
+ }
168
214
  // The session is gone: drop any user cached for it (#196).
169
215
  this.ctx.oxy.cache.delete(`GET:/session/user/${sessionId}`);
170
216
  throw error;
@@ -530,6 +530,29 @@ class OxyServer extends OxyServices_1.OxyServices {
530
530
  throw new Error('Product grant response attribution differs');
531
531
  return snapshot;
532
532
  }
533
+ /**
534
+ * Register accounts this deployment provisioned as its own users, so they count
535
+ * toward {@link applicationAccountTotal}. Workload identity only (the
536
+ * application is the workload's); idempotent; at most
537
+ * `APPLICATION_ACCOUNT_REGISTRATION_BATCH_MAX` ids per call. Ids that are
538
+ * unknown, federated, not personal or archived are skipped and not reported.
539
+ * Returns how many became newly registered.
540
+ */
541
+ async registerApplicationAccounts(accountIds) {
542
+ const body = contracts_1.applicationAccountRegistrationRequestSchema.parse({ accountIds });
543
+ const answer = await this.serviceRequest('POST', '/application-accounts/self/registrations', body, { cache: false });
544
+ return contracts_1.applicationAccountRegistrationResponseSchema.parse(answer).registered;
545
+ }
546
+ /**
547
+ * This deployment's eligible registered-account total (local, personal, active,
548
+ * not being closed), from a bounded server snapshot: read `computedAt`,
549
+ * `stale` and `exact` before presenting it. Rejects when no total is available
550
+ * (503 before the first snapshot) — never resolve an unknown total to `0`.
551
+ */
552
+ async applicationAccountTotal() {
553
+ const answer = await this.serviceRequest('GET', '/application-accounts/self/total', undefined, { cache: false });
554
+ return contracts_1.applicationAccountTotalSchema.parse(answer);
555
+ }
533
556
  async serviceRequest(method, url, data, options = {}) {
534
557
  const target = new URL(/^https?:\/\//i.test(url)
535
558
  ? url
@@ -138,7 +138,7 @@ function createOxyMiddleware(host) {
138
138
  */
139
139
  function auth(options = {}) {
140
140
  const { debug = false, onError, onRefusal, loadUser = false, optional = false, serviceTokenJwksUrl = new URL('/.well-known/jwks.json', host.baseURL).toString(), expectedIssuer = exports.OXY_JWT_ISSUER, expectedAudience = OXY_JWT_AUDIENCE, } = options;
141
- const strictPresentedBearer = Boolean(options.supervisedAction || options.strictOptionalBearer);
141
+ const strictPresentedBearer = Boolean(options.supervisedAction || options.strictOptionalBearer || options.liveSession);
142
142
  /**
143
143
  * Make a refusal observable to the HOST, without telling the client
144
144
  * anything it is not already told.
@@ -247,6 +247,17 @@ function createOxyMiddleware(host) {
247
247
  // Handle service tokens (internal service-to-service auth)
248
248
  // Service tokens are stateless JWTs with type: 'service' — requires signature verification
249
249
  if (decoded.type === 'service') {
250
+ if (options.liveSession) {
251
+ const error = {
252
+ error: 'SESSION_REQUIRED',
253
+ message: 'A live user session is required',
254
+ code: 'SESSION_REQUIRED',
255
+ status: 403,
256
+ };
257
+ if (onError)
258
+ return onError(error);
259
+ return res.status(403).json(error);
260
+ }
250
261
  // Verify JWT signature, then audience / issuer / type / appId claims.
251
262
  //
252
263
  // Signature verification uses Node crypto rather than
@@ -659,6 +670,7 @@ function createOxyMiddleware(host) {
659
670
  accessToken: token,
660
671
  supervisedAction: options.supervisedAction,
661
672
  fresh: options.freshSession,
673
+ ...(options.liveSession ? { liveSession: true } : {}),
662
674
  });
663
675
  if (!validationResult || !validationResult.valid || !validationResult.user) {
664
676
  recordRefusal(req, {
@@ -19,10 +19,11 @@ exports.ServerSessionApi = exports.SESSION_VALIDATION_CACHE_TTL_MS = void 0;
19
19
  * bearer) — never the raw token — for {@link SESSION_VALIDATION_CACHE_TTL_MS}
20
20
  * (30 s) by default, and never past the token's own `exp`.
21
21
  *
22
- * THE SECURITY CONTRACT: the cache never keeps a revoked session alive. Oxy
23
- * announces every session it ends on its service-event feed
24
- * (`./serviceEvents.ts`; `@oxy.so/contracts` `sessionRevocation.ts`), and the
25
- * cache is used ONLY while that feed is live:
22
+ * Oxy announces ended sessions on its service-event feed (`./serviceEvents.ts`;
23
+ * `@oxy.so/contracts` `sessionRevocation.ts`), and the cache is used ONLY while
24
+ * that feed is live. Feed health alone cannot prove that every event was
25
+ * delivered. Routes requiring a fresh session-row read can opt in to
26
+ * `liveSession`; see `ServerSessionValidateOptions` for its scope.
26
27
  *
27
28
  * - no feed (this process has no service token: no credential and no attested
28
29
  * workload, as in a test or a local checkout), a feed not yet connected, a
@@ -49,6 +50,7 @@ const node_crypto_1 = require("node:crypto");
49
50
  const contracts_1 = require("@oxy.so/contracts");
50
51
  const jwt_decode_1 = require("jwt-decode");
51
52
  const session_1 = require("../api/session");
53
+ const OxyServices_errors_1 = require("../OxyServices.errors");
52
54
  const userIdentity_1 = require("../utils/userIdentity");
53
55
  /** Default lifetime of one cached validation. */
54
56
  exports.SESSION_VALIDATION_CACHE_TTL_MS = 30000;
@@ -81,7 +83,29 @@ class ServerSessionApi extends session_1.SessionApi {
81
83
  this.revoked = new Map();
82
84
  }
83
85
  async validate(sessionId, options = {}) {
84
- const { supervisedAction, fresh, ...rest } = options;
86
+ const { supervisedAction, fresh, liveSession, ...rest } = options;
87
+ if (liveSession) {
88
+ if (supervisedAction)
89
+ throw new OxyServices_errors_1.OxyApiError('Live supervised session authority is unavailable', {
90
+ status: 503,
91
+ code: 'SESSION_AUTHORITY_UNAVAILABLE',
92
+ });
93
+ try {
94
+ const result = await this.validateRequest(sessionId, { ...rest, liveSession: true });
95
+ const actor = contracts_1.accountActorChainSchema.safeParse(result.actor);
96
+ if (!actor.success || actor.data.effectiveAccountId !== result.user.id)
97
+ throw new OxyServices_errors_1.OxyApiError('Invalid live session authority response', {
98
+ status: 503,
99
+ code: 'SESSION_AUTHORITY_UNAVAILABLE',
100
+ });
101
+ return result;
102
+ }
103
+ catch (error) {
104
+ if (error?.status === 401)
105
+ this.refuse(sessionId);
106
+ throw error;
107
+ }
108
+ }
85
109
  if (supervisedAction)
86
110
  return this.validateSupervised(sessionId, supervisedAction, rest);
87
111
  if (!rest.accessToken || rest.deviceFingerprint || this.validationTtlMs <= 0) {
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import { jwtDecode } from 'jwt-decode';
9
9
  import { normalizeUserIdentity } from '../utils/userIdentity.js';
10
+ import { OxyApiError } from '../OxyServices.errors.js';
10
11
  export class SessionApi {
11
12
  constructor(ctx) {
12
13
  this.ctx = ctx;
@@ -141,13 +142,31 @@ export class SessionApi {
141
142
  * validates the session it was issued for.
142
143
  */
143
144
  async validate(sessionId, options = {}) {
145
+ if ('liveSession' in options)
146
+ throw new OxyApiError('Live session validation requires OxyServer', {
147
+ status: 503,
148
+ code: 'SESSION_AUTHORITY_UNAVAILABLE',
149
+ });
150
+ return this.validateRequest(sessionId, options);
151
+ }
152
+ /** Server implementations use this transport after enforcing their own contract. */
153
+ async validateRequest(sessionId, options) {
144
154
  try {
155
+ if (options.liveSession && !options.accessToken)
156
+ throw new OxyApiError('Live session authority requires a bearer', {
157
+ status: 503,
158
+ code: 'SESSION_AUTHORITY_UNAVAILABLE',
159
+ });
145
160
  const validation = options.accessToken
146
161
  ? await this.ctx.request('GET', `/session/validate-header/${encodeURIComponent(sessionId)}`, undefined, {
147
162
  cache: false,
148
163
  skipAuth: true,
164
+ ...(options.liveSession ? { deduplicate: false, retry: false } : {}),
149
165
  headers: {
150
166
  Authorization: `Bearer ${options.accessToken}`,
167
+ ...(options.liveSession
168
+ ? { 'x-oxy-session-authority': 'live-v1', 'cache-control': 'no-store' }
169
+ : {}),
151
170
  ...(options.deviceFingerprint
152
171
  ? { 'x-device-fingerprint': options.deviceFingerprint }
153
172
  : {}),
@@ -159,9 +178,36 @@ export class SessionApi {
159
178
  : {}),
160
179
  ...(options.useHeaderValidation ? { useHeaderValidation: 'true' } : {}),
161
180
  }, { cache: false });
181
+ if (options.liveSession &&
182
+ (validation.liveSessionAuthority !== 'session-row-v1' ||
183
+ validation.valid !== true ||
184
+ validation.sessionId !== sessionId ||
185
+ typeof validation.expiresAt !== 'string' ||
186
+ !Number.isFinite(Date.parse(validation.expiresAt)) ||
187
+ Date.parse(validation.expiresAt) <= Date.now() ||
188
+ typeof validation.lastActivity !== 'string' ||
189
+ !Number.isFinite(Date.parse(validation.lastActivity))))
190
+ throw new OxyApiError('Live session authority is unavailable', {
191
+ status: 503,
192
+ code: 'SESSION_AUTHORITY_UNAVAILABLE',
193
+ });
162
194
  return { ...validation, user: normalizeUserIdentity(validation.user) };
163
195
  }
164
196
  catch (error) {
197
+ if (options.liveSession) {
198
+ const candidate = error;
199
+ const invalid = candidate?.status === 401 && candidate.code === 'OXY_LIVE_SESSION_INVALID_V1';
200
+ const denied = candidate?.status === 403 && candidate.code === 'OXY_LIVE_SESSION_POLICY_V1';
201
+ if (invalid)
202
+ this.ctx.oxy.cache.delete(`GET:/session/user/${sessionId}`);
203
+ if (invalid || denied)
204
+ throw error;
205
+ throw new OxyApiError('Live session authority is unavailable', {
206
+ status: 503,
207
+ code: 'SESSION_AUTHORITY_UNAVAILABLE',
208
+ cause: error,
209
+ });
210
+ }
165
211
  // The session is gone: drop any user cached for it (#196).
166
212
  this.ctx.oxy.cache.delete(`GET:/session/user/${sessionId}`);
167
213
  throw error;
@@ -1,5 +1,5 @@
1
1
  import { OxyRequesterClient } from './requesterClient.js';
2
- import { serviceIntrospectionSchema, resourceIntrospectionSchema, serviceTokenOptionsSchema, subjectProductAccessQuerySchema, subjectProductGrantSnapshotSchema, } from '@oxy.so/contracts';
2
+ import { applicationAccountRegistrationRequestSchema, applicationAccountRegistrationResponseSchema, applicationAccountTotalSchema, serviceIntrospectionSchema, resourceIntrospectionSchema, serviceTokenOptionsSchema, subjectProductAccessQuerySchema, subjectProductGrantSnapshotSchema, } from '@oxy.so/contracts';
3
3
  /**
4
4
  * `OxyServer` — the Oxy client for a backend.
5
5
  *
@@ -526,6 +526,29 @@ export class OxyServer extends OxyServices {
526
526
  throw new Error('Product grant response attribution differs');
527
527
  return snapshot;
528
528
  }
529
+ /**
530
+ * Register accounts this deployment provisioned as its own users, so they count
531
+ * toward {@link applicationAccountTotal}. Workload identity only (the
532
+ * application is the workload's); idempotent; at most
533
+ * `APPLICATION_ACCOUNT_REGISTRATION_BATCH_MAX` ids per call. Ids that are
534
+ * unknown, federated, not personal or archived are skipped and not reported.
535
+ * Returns how many became newly registered.
536
+ */
537
+ async registerApplicationAccounts(accountIds) {
538
+ const body = applicationAccountRegistrationRequestSchema.parse({ accountIds });
539
+ const answer = await this.serviceRequest('POST', '/application-accounts/self/registrations', body, { cache: false });
540
+ return applicationAccountRegistrationResponseSchema.parse(answer).registered;
541
+ }
542
+ /**
543
+ * This deployment's eligible registered-account total (local, personal, active,
544
+ * not being closed), from a bounded server snapshot: read `computedAt`,
545
+ * `stale` and `exact` before presenting it. Rejects when no total is available
546
+ * (503 before the first snapshot) — never resolve an unknown total to `0`.
547
+ */
548
+ async applicationAccountTotal() {
549
+ const answer = await this.serviceRequest('GET', '/application-accounts/self/total', undefined, { cache: false });
550
+ return applicationAccountTotalSchema.parse(answer);
551
+ }
529
552
  async serviceRequest(method, url, data, options = {}) {
530
553
  const target = new URL(/^https?:\/\//i.test(url)
531
554
  ? url
@@ -131,7 +131,7 @@ export function createOxyMiddleware(host) {
131
131
  */
132
132
  function auth(options = {}) {
133
133
  const { debug = false, onError, onRefusal, loadUser = false, optional = false, serviceTokenJwksUrl = new URL('/.well-known/jwks.json', host.baseURL).toString(), expectedIssuer = OXY_JWT_ISSUER, expectedAudience = OXY_JWT_AUDIENCE, } = options;
134
- const strictPresentedBearer = Boolean(options.supervisedAction || options.strictOptionalBearer);
134
+ const strictPresentedBearer = Boolean(options.supervisedAction || options.strictOptionalBearer || options.liveSession);
135
135
  /**
136
136
  * Make a refusal observable to the HOST, without telling the client
137
137
  * anything it is not already told.
@@ -240,6 +240,17 @@ export function createOxyMiddleware(host) {
240
240
  // Handle service tokens (internal service-to-service auth)
241
241
  // Service tokens are stateless JWTs with type: 'service' — requires signature verification
242
242
  if (decoded.type === 'service') {
243
+ if (options.liveSession) {
244
+ const error = {
245
+ error: 'SESSION_REQUIRED',
246
+ message: 'A live user session is required',
247
+ code: 'SESSION_REQUIRED',
248
+ status: 403,
249
+ };
250
+ if (onError)
251
+ return onError(error);
252
+ return res.status(403).json(error);
253
+ }
243
254
  // Verify JWT signature, then audience / issuer / type / appId claims.
244
255
  //
245
256
  // Signature verification uses Node crypto rather than
@@ -652,6 +663,7 @@ export function createOxyMiddleware(host) {
652
663
  accessToken: token,
653
664
  supervisedAction: options.supervisedAction,
654
665
  fresh: options.freshSession,
666
+ ...(options.liveSession ? { liveSession: true } : {}),
655
667
  });
656
668
  if (!validationResult || !validationResult.valid || !validationResult.user) {
657
669
  recordRefusal(req, {
@@ -16,10 +16,11 @@
16
16
  * bearer) — never the raw token — for {@link SESSION_VALIDATION_CACHE_TTL_MS}
17
17
  * (30 s) by default, and never past the token's own `exp`.
18
18
  *
19
- * THE SECURITY CONTRACT: the cache never keeps a revoked session alive. Oxy
20
- * announces every session it ends on its service-event feed
21
- * (`./serviceEvents.ts`; `@oxy.so/contracts` `sessionRevocation.ts`), and the
22
- * cache is used ONLY while that feed is live:
19
+ * Oxy announces ended sessions on its service-event feed (`./serviceEvents.ts`;
20
+ * `@oxy.so/contracts` `sessionRevocation.ts`), and the cache is used ONLY while
21
+ * that feed is live. Feed health alone cannot prove that every event was
22
+ * delivered. Routes requiring a fresh session-row read can opt in to
23
+ * `liveSession`; see `ServerSessionValidateOptions` for its scope.
23
24
  *
24
25
  * - no feed (this process has no service token: no credential and no attested
25
26
  * workload, as in a test or a local checkout), a feed not yet connected, a
@@ -43,9 +44,10 @@
43
44
  * logout made through this client still drop entries too.
44
45
  */
45
46
  import { createHash } from 'node:crypto';
46
- import { safetySessionInputSchema, safetySessionValidationSchema, } from '@oxy.so/contracts';
47
+ import { accountActorChainSchema, safetySessionInputSchema, safetySessionValidationSchema, } from '@oxy.so/contracts';
47
48
  import { jwtDecode } from 'jwt-decode';
48
49
  import { SessionApi } from '../api/session.js';
50
+ import { OxyApiError } from '../OxyServices.errors.js';
49
51
  import { normalizeUserIdentity } from '../utils/userIdentity.js';
50
52
  /** Default lifetime of one cached validation. */
51
53
  export const SESSION_VALIDATION_CACHE_TTL_MS = 30000;
@@ -78,7 +80,29 @@ export class ServerSessionApi extends SessionApi {
78
80
  this.revoked = new Map();
79
81
  }
80
82
  async validate(sessionId, options = {}) {
81
- const { supervisedAction, fresh, ...rest } = options;
83
+ const { supervisedAction, fresh, liveSession, ...rest } = options;
84
+ if (liveSession) {
85
+ if (supervisedAction)
86
+ throw new OxyApiError('Live supervised session authority is unavailable', {
87
+ status: 503,
88
+ code: 'SESSION_AUTHORITY_UNAVAILABLE',
89
+ });
90
+ try {
91
+ const result = await this.validateRequest(sessionId, { ...rest, liveSession: true });
92
+ const actor = accountActorChainSchema.safeParse(result.actor);
93
+ if (!actor.success || actor.data.effectiveAccountId !== result.user.id)
94
+ throw new OxyApiError('Invalid live session authority response', {
95
+ status: 503,
96
+ code: 'SESSION_AUTHORITY_UNAVAILABLE',
97
+ });
98
+ return result;
99
+ }
100
+ catch (error) {
101
+ if (error?.status === 401)
102
+ this.refuse(sessionId);
103
+ throw error;
104
+ }
105
+ }
82
106
  if (supervisedAction)
83
107
  return this.validateSupervised(sessionId, supervisedAction, rest);
84
108
  if (!rest.accessToken || rest.deviceFingerprint || this.validationTtlMs <= 0) {
@@ -85,6 +85,10 @@ export declare class SessionApi {
85
85
  * validates the session it was issued for.
86
86
  */
87
87
  validate(sessionId: string, options?: SessionValidateOptions): Promise<SessionValidation>;
88
+ /** Server implementations use this transport after enforcing their own contract. */
89
+ protected validateRequest(sessionId: string, options: SessionValidateOptions & {
90
+ liveSession?: boolean;
91
+ }): Promise<SessionValidation>;
88
92
  /** The sessions that belong with `sessionId` (same account set). */
89
93
  list(sessionId: string): Promise<ClientSession[]>;
90
94
  /** Sign out `sessionId`, or — with `targetSessionId` — one of the sessions beside it. */
@@ -1,6 +1,6 @@
1
1
  import { OxyRequesterClient } from './requesterClient';
2
2
  import type { RequesterProof } from './requesterContext';
3
- import { type ResourceIntrospection, type ServiceIntrospection, type ServiceTokenOptions, type SubjectProductAccessQuery, type SubjectProductGrantSnapshot } from '@oxy.so/contracts';
3
+ import { type ApplicationAccountTotal, type ResourceIntrospection, type ServiceIntrospection, type ServiceTokenOptions, type SubjectProductAccessQuery, type SubjectProductGrantSnapshot } from '@oxy.so/contracts';
4
4
  import { OxyServices, type OxyConfig } from '../OxyServices';
5
5
  import type { HttpMethod } from '../client/context';
6
6
  import type { RequestOptions, ResponsePayload } from '../HttpService';
@@ -267,6 +267,22 @@ export declare class OxyServer extends OxyServices {
267
267
  productGrantSnapshotForUser(query: SubjectProductAccessQuery, accessToken: string): Promise<SubjectProductGrantSnapshot>;
268
268
  /** Offline metering read: the API requires this app's live consent for this exact subject. */
269
269
  productGrantSnapshotForService(query: SubjectProductAccessQuery): Promise<SubjectProductGrantSnapshot>;
270
+ /**
271
+ * Register accounts this deployment provisioned as its own users, so they count
272
+ * toward {@link applicationAccountTotal}. Workload identity only (the
273
+ * application is the workload's); idempotent; at most
274
+ * `APPLICATION_ACCOUNT_REGISTRATION_BATCH_MAX` ids per call. Ids that are
275
+ * unknown, federated, not personal or archived are skipped and not reported.
276
+ * Returns how many became newly registered.
277
+ */
278
+ registerApplicationAccounts(accountIds: readonly string[]): Promise<number>;
279
+ /**
280
+ * This deployment's eligible registered-account total (local, personal, active,
281
+ * not being closed), from a bounded server snapshot: read `computedAt`,
282
+ * `stale` and `exact` before presenting it. Rejects when no total is available
283
+ * (503 before the first snapshot) — never resolve an unknown total to `0`.
284
+ */
285
+ applicationAccountTotal(): Promise<ApplicationAccountTotal>;
270
286
  serviceRequest<T>(method: HttpMethod, url: string, data?: unknown, options?: RequestOptions & {
271
287
  actAs?: string;
272
288
  requesterToken?: string;
@@ -189,11 +189,13 @@ export interface AuthMiddlewareOptions {
189
189
  /** Keep anonymous access, but reject every supplied invalid credential or authority outage. */
190
190
  strictOptionalBearer?: boolean;
191
191
  /**
192
- * Ask Oxy about the session on every request instead of reusing a recent
193
- * successful validation (`OxyServerConfig.sessionValidationCacheTtlMs`, 30 s).
194
- * For routes where a just-revoked session must not act — deletion, payments.
192
+ * Ask Oxy instead of reusing this server's recent successful validation
193
+ * (`OxyServerConfig.sessionValidationCacheTtlMs`, 30 s). Oxy may itself
194
+ * serve a cached answer; use `liveSession` for a live session-row read.
195
195
  */
196
196
  freshSession?: boolean;
197
+ /** Require a live session-row read. Unsupported servers fail with 503. */
198
+ liveSession?: boolean;
197
199
  /**
198
200
  * Called whenever a PRESENTED credential is refused, on the optional path as
199
201
  * well as the blocking one. Runs before the response (if any) and after
@@ -237,6 +239,7 @@ export interface OxyMiddlewareHost {
237
239
  accessToken?: string;
238
240
  supervisedAction?: SupervisedAction;
239
241
  fresh?: boolean;
242
+ liveSession?: boolean;
240
243
  }): Promise<{
241
244
  valid: boolean;
242
245
  user?: User;
@@ -4,6 +4,17 @@ import { SessionApi, type SessionValidateOptions, type SessionValidation } from
4
4
  /** Default lifetime of one cached validation. */
5
5
  export declare const SESSION_VALIDATION_CACHE_TTL_MS = 30000;
6
6
  export interface ServerSessionValidateOptions extends SessionValidateOptions {
7
+ /**
8
+ * Require a session-row read on a PostgreSQL writer. Old servers and
9
+ * unavailable authority fail closed with 503. The read only reflects commits
10
+ * visible to its database snapshot; requests already authorized can finish.
11
+ * This does not make account, grant or supervision data fresh. An unconfirmed
12
+ * user, grant or policy negative returns 503 rather than invalidating a
13
+ * session. Expiry is checked against the consumer's clock; skew can cause
14
+ * unavailability. Each validation reaches Oxy and its activity bookkeeping;
15
+ * assess database and Redis load before enabling it on a route.
16
+ */
17
+ liveSession?: boolean;
7
18
  /**
8
19
  * A reviewed route category. Oxy then validates the bearer through this
9
20
  * service's live `safety:<action>:v1` authority, the only lane a supervised
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxy.so/core",
3
- "version": "7.10.5",
3
+ "version": "7.11.0",
4
4
  "description": "OxyHQ SDK Foundation — API client, authentication, cryptographic identity, and shared utilities",
5
5
  "main": "dist/cjs/index.js",
6
6
  "module": "dist/esm/index.js",
@@ -181,7 +181,7 @@
181
181
  "dependencies": {
182
182
  "@noble/ciphers": "^1.3.0",
183
183
  "@noble/hashes": "^1.8.0",
184
- "@oxy.so/contracts": "6.2.0",
184
+ "@oxy.so/contracts": "6.3.0",
185
185
  "@oxy.so/protocol": "^1.5.1",
186
186
  "@oxy.so/telemetry": "^2.1.0",
187
187
  "@scure/bip39": "^1.6.0",
@@ -11,6 +11,7 @@ import type { OxyContext } from '../client/context';
11
11
  import type { ClientSession } from '../models/session';
12
12
  import type { User } from '../models/interfaces';
13
13
  import { normalizeUserIdentity } from '../utils/userIdentity';
14
+ import { OxyApiError } from '../OxyServices.errors';
14
15
 
15
16
  /**
16
17
  * Reads the device credential this client holds, if any — `{ deviceId,
@@ -188,18 +189,40 @@ export class SessionApi {
188
189
  async validate(
189
190
  sessionId: string,
190
191
  options: SessionValidateOptions = {},
192
+ ): Promise<SessionValidation> {
193
+ if ('liveSession' in options)
194
+ throw new OxyApiError('Live session validation requires OxyServer', {
195
+ status: 503,
196
+ code: 'SESSION_AUTHORITY_UNAVAILABLE',
197
+ });
198
+ return this.validateRequest(sessionId, options);
199
+ }
200
+
201
+ /** Server implementations use this transport after enforcing their own contract. */
202
+ protected async validateRequest(
203
+ sessionId: string,
204
+ options: SessionValidateOptions & { liveSession?: boolean },
191
205
  ): Promise<SessionValidation> {
192
206
  try {
193
- const validation = options.accessToken
194
- ? await this.ctx.request<SessionValidation>(
207
+ if (options.liveSession && !options.accessToken)
208
+ throw new OxyApiError('Live session authority requires a bearer', {
209
+ status: 503,
210
+ code: 'SESSION_AUTHORITY_UNAVAILABLE',
211
+ });
212
+ const validation: SessionValidation & { liveSessionAuthority?: unknown } = options.accessToken
213
+ ? await this.ctx.request<SessionValidation & { liveSessionAuthority?: unknown }>(
195
214
  'GET',
196
215
  `/session/validate-header/${encodeURIComponent(sessionId)}`,
197
216
  undefined,
198
217
  {
199
218
  cache: false,
200
219
  skipAuth: true,
220
+ ...(options.liveSession ? { deduplicate: false, retry: false } : {}),
201
221
  headers: {
202
222
  Authorization: `Bearer ${options.accessToken}`,
223
+ ...(options.liveSession
224
+ ? { 'x-oxy-session-authority': 'live-v1', 'cache-control': 'no-store' }
225
+ : {}),
203
226
  ...(options.deviceFingerprint
204
227
  ? { 'x-device-fingerprint': options.deviceFingerprint }
205
228
  : {}),
@@ -217,8 +240,36 @@ export class SessionApi {
217
240
  },
218
241
  { cache: false },
219
242
  );
243
+ if (
244
+ options.liveSession &&
245
+ (validation.liveSessionAuthority !== 'session-row-v1' ||
246
+ validation.valid !== true ||
247
+ validation.sessionId !== sessionId ||
248
+ typeof validation.expiresAt !== 'string' ||
249
+ !Number.isFinite(Date.parse(validation.expiresAt)) ||
250
+ Date.parse(validation.expiresAt) <= Date.now() ||
251
+ typeof validation.lastActivity !== 'string' ||
252
+ !Number.isFinite(Date.parse(validation.lastActivity)))
253
+ )
254
+ throw new OxyApiError('Live session authority is unavailable', {
255
+ status: 503,
256
+ code: 'SESSION_AUTHORITY_UNAVAILABLE',
257
+ });
220
258
  return { ...validation, user: normalizeUserIdentity(validation.user) };
221
259
  } catch (error) {
260
+ if (options.liveSession) {
261
+ const candidate = error as { status?: unknown; code?: unknown } | null;
262
+ const invalid =
263
+ candidate?.status === 401 && candidate.code === 'OXY_LIVE_SESSION_INVALID_V1';
264
+ const denied = candidate?.status === 403 && candidate.code === 'OXY_LIVE_SESSION_POLICY_V1';
265
+ if (invalid) this.ctx.oxy.cache.delete(`GET:/session/user/${sessionId}`);
266
+ if (invalid || denied) throw error;
267
+ throw new OxyApiError('Live session authority is unavailable', {
268
+ status: 503,
269
+ code: 'SESSION_AUTHORITY_UNAVAILABLE',
270
+ cause: error,
271
+ });
272
+ }
222
273
  // The session is gone: drop any user cached for it (#196).
223
274
  this.ctx.oxy.cache.delete(`GET:/session/user/${sessionId}`);
224
275
  throw error;
@@ -1,6 +1,10 @@
1
1
  import { OxyRequesterClient } from './requesterClient';
2
2
  import type { RequesterProof } from './requesterContext';
3
3
  import {
4
+ applicationAccountRegistrationRequestSchema,
5
+ applicationAccountRegistrationResponseSchema,
6
+ applicationAccountTotalSchema,
7
+ type ApplicationAccountTotal,
4
8
  type OxySessionRevocationEvent,
5
9
  serviceIntrospectionSchema,
6
10
  resourceIntrospectionSchema,
@@ -786,6 +790,41 @@ export class OxyServer extends OxyServices {
786
790
  return snapshot;
787
791
  }
788
792
 
793
+ /**
794
+ * Register accounts this deployment provisioned as its own users, so they count
795
+ * toward {@link applicationAccountTotal}. Workload identity only (the
796
+ * application is the workload's); idempotent; at most
797
+ * `APPLICATION_ACCOUNT_REGISTRATION_BATCH_MAX` ids per call. Ids that are
798
+ * unknown, federated, not personal or archived are skipped and not reported.
799
+ * Returns how many became newly registered.
800
+ */
801
+ async registerApplicationAccounts(accountIds: readonly string[]): Promise<number> {
802
+ const body = applicationAccountRegistrationRequestSchema.parse({ accountIds });
803
+ const answer = await this.serviceRequest<unknown>(
804
+ 'POST',
805
+ '/application-accounts/self/registrations',
806
+ body,
807
+ { cache: false },
808
+ );
809
+ return applicationAccountRegistrationResponseSchema.parse(answer).registered;
810
+ }
811
+
812
+ /**
813
+ * This deployment's eligible registered-account total (local, personal, active,
814
+ * not being closed), from a bounded server snapshot: read `computedAt`,
815
+ * `stale` and `exact` before presenting it. Rejects when no total is available
816
+ * (503 before the first snapshot) — never resolve an unknown total to `0`.
817
+ */
818
+ async applicationAccountTotal(): Promise<ApplicationAccountTotal> {
819
+ const answer = await this.serviceRequest<unknown>(
820
+ 'GET',
821
+ '/application-accounts/self/total',
822
+ undefined,
823
+ { cache: false },
824
+ );
825
+ return applicationAccountTotalSchema.parse(answer);
826
+ }
827
+
789
828
  async serviceRequest<T>(
790
829
  method: HttpMethod,
791
830
  url: string,
@@ -278,11 +278,13 @@ export interface AuthMiddlewareOptions {
278
278
  /** Keep anonymous access, but reject every supplied invalid credential or authority outage. */
279
279
  strictOptionalBearer?: boolean;
280
280
  /**
281
- * Ask Oxy about the session on every request instead of reusing a recent
282
- * successful validation (`OxyServerConfig.sessionValidationCacheTtlMs`, 30 s).
283
- * For routes where a just-revoked session must not act — deletion, payments.
281
+ * Ask Oxy instead of reusing this server's recent successful validation
282
+ * (`OxyServerConfig.sessionValidationCacheTtlMs`, 30 s). Oxy may itself
283
+ * serve a cached answer; use `liveSession` for a live session-row read.
284
284
  */
285
285
  freshSession?: boolean;
286
+ /** Require a live session-row read. Unsupported servers fail with 503. */
287
+ liveSession?: boolean;
286
288
  /**
287
289
  * Called whenever a PRESENTED credential is refused, on the optional path as
288
290
  * well as the blocking one. Runs before the response (if any) and after
@@ -329,6 +331,7 @@ export interface OxyMiddlewareHost {
329
331
  accessToken?: string;
330
332
  supervisedAction?: SupervisedAction;
331
333
  fresh?: boolean;
334
+ liveSession?: boolean;
332
335
  },
333
336
  ): Promise<{ valid: boolean; user?: User; actor?: unknown } | null>;
334
337
  verifyActingAs(
@@ -443,7 +446,9 @@ export function createOxyMiddleware(host: OxyMiddlewareHost) {
443
446
  expectedIssuer = OXY_JWT_ISSUER,
444
447
  expectedAudience = OXY_JWT_AUDIENCE,
445
448
  } = options;
446
- const strictPresentedBearer = Boolean(options.supervisedAction || options.strictOptionalBearer);
449
+ const strictPresentedBearer = Boolean(
450
+ options.supervisedAction || options.strictOptionalBearer || options.liveSession,
451
+ );
447
452
 
448
453
  /**
449
454
  * Make a refusal observable to the HOST, without telling the client
@@ -567,6 +572,16 @@ export function createOxyMiddleware(host: OxyMiddlewareHost) {
567
572
  // Handle service tokens (internal service-to-service auth)
568
573
  // Service tokens are stateless JWTs with type: 'service' — requires signature verification
569
574
  if (decoded.type === 'service') {
575
+ if (options.liveSession) {
576
+ const error = {
577
+ error: 'SESSION_REQUIRED',
578
+ message: 'A live user session is required',
579
+ code: 'SESSION_REQUIRED',
580
+ status: 403,
581
+ };
582
+ if (onError) return onError(error);
583
+ return res.status(403).json(error);
584
+ }
570
585
  // Verify JWT signature, then audience / issuer / type / appId claims.
571
586
  //
572
587
  // Signature verification uses Node crypto rather than
@@ -1002,6 +1017,7 @@ export function createOxyMiddleware(host: OxyMiddlewareHost) {
1002
1017
  accessToken: token,
1003
1018
  supervisedAction: options.supervisedAction,
1004
1019
  fresh: options.freshSession,
1020
+ ...(options.liveSession ? { liveSession: true } : {}),
1005
1021
  });
1006
1022
 
1007
1023
  if (!validationResult || !validationResult.valid || !validationResult.user) {
@@ -16,10 +16,11 @@
16
16
  * bearer) — never the raw token — for {@link SESSION_VALIDATION_CACHE_TTL_MS}
17
17
  * (30 s) by default, and never past the token's own `exp`.
18
18
  *
19
- * THE SECURITY CONTRACT: the cache never keeps a revoked session alive. Oxy
20
- * announces every session it ends on its service-event feed
21
- * (`./serviceEvents.ts`; `@oxy.so/contracts` `sessionRevocation.ts`), and the
22
- * cache is used ONLY while that feed is live:
19
+ * Oxy announces ended sessions on its service-event feed (`./serviceEvents.ts`;
20
+ * `@oxy.so/contracts` `sessionRevocation.ts`), and the cache is used ONLY while
21
+ * that feed is live. Feed health alone cannot prove that every event was
22
+ * delivered. Routes requiring a fresh session-row read can opt in to
23
+ * `liveSession`; see `ServerSessionValidateOptions` for its scope.
23
24
  *
24
25
  * - no feed (this process has no service token: no credential and no attested
25
26
  * workload, as in a test or a local checkout), a feed not yet connected, a
@@ -46,12 +47,14 @@ import { createHash } from 'node:crypto';
46
47
  import {
47
48
  type OxySessionRevocationEvent,
48
49
  type SupervisedAction,
50
+ accountActorChainSchema,
49
51
  safetySessionInputSchema,
50
52
  safetySessionValidationSchema,
51
53
  } from '@oxy.so/contracts';
52
54
  import { jwtDecode } from 'jwt-decode';
53
55
  import type { OxyContext } from '../client/context';
54
56
  import { SessionApi, type SessionValidateOptions, type SessionValidation } from '../api/session';
57
+ import { OxyApiError } from '../OxyServices.errors';
55
58
  import type { User } from '../models/interfaces';
56
59
  import { normalizeUserIdentity } from '../utils/userIdentity';
57
60
 
@@ -61,6 +64,17 @@ export const SESSION_VALIDATION_CACHE_TTL_MS = 30_000;
61
64
  const SESSION_VALIDATION_CACHE_MAX = 5_000;
62
65
 
63
66
  export interface ServerSessionValidateOptions extends SessionValidateOptions {
67
+ /**
68
+ * Require a session-row read on a PostgreSQL writer. Old servers and
69
+ * unavailable authority fail closed with 503. The read only reflects commits
70
+ * visible to its database snapshot; requests already authorized can finish.
71
+ * This does not make account, grant or supervision data fresh. An unconfirmed
72
+ * user, grant or policy negative returns 503 rather than invalidating a
73
+ * session. Expiry is checked against the consumer's clock; skew can cause
74
+ * unavailability. Each validation reaches Oxy and its activity bookkeeping;
75
+ * assess database and Redis load before enabling it on a route.
76
+ */
77
+ liveSession?: boolean;
64
78
  /**
65
79
  * A reviewed route category. Oxy then validates the bearer through this
66
80
  * service's live `safety:<action>:v1` authority, the only lane a supervised
@@ -121,7 +135,27 @@ export class ServerSessionApi extends SessionApi {
121
135
  sessionId: string,
122
136
  options: ServerSessionValidateOptions = {},
123
137
  ): Promise<SessionValidation> {
124
- const { supervisedAction, fresh, ...rest } = options;
138
+ const { supervisedAction, fresh, liveSession, ...rest } = options;
139
+ if (liveSession) {
140
+ if (supervisedAction)
141
+ throw new OxyApiError('Live supervised session authority is unavailable', {
142
+ status: 503,
143
+ code: 'SESSION_AUTHORITY_UNAVAILABLE',
144
+ });
145
+ try {
146
+ const result = await this.validateRequest(sessionId, { ...rest, liveSession: true });
147
+ const actor = accountActorChainSchema.safeParse(result.actor);
148
+ if (!actor.success || actor.data.effectiveAccountId !== result.user.id)
149
+ throw new OxyApiError('Invalid live session authority response', {
150
+ status: 503,
151
+ code: 'SESSION_AUTHORITY_UNAVAILABLE',
152
+ });
153
+ return result;
154
+ } catch (error) {
155
+ if ((error as { status?: unknown } | null)?.status === 401) this.refuse(sessionId);
156
+ throw error;
157
+ }
158
+ }
125
159
  if (supervisedAction) return this.validateSupervised(sessionId, supervisedAction, rest);
126
160
  if (!rest.accessToken || rest.deviceFingerprint || this.validationTtlMs <= 0) {
127
161
  return super.validate(sessionId, rest);