@oxy.so/core 1.12.0 → 1.13.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.
@@ -29,6 +29,8 @@ export type { ServiceTokenResponse, OAuthUserInfoResponse, OAuthTokenExchangeRes
29
29
  export type { CommonsSignInHandle, CommonsSignInStatus, CommonsSignInPurpose, CommonsOAuthContext, CommonsApprovalInfo, CommonsApprovalSubjectAccount, CommonsSignInActionResult, CommonsOAuthFinalizeResult, CommonsDeliveryResult, } from './mixins/OxyServices.auth';
30
30
  export type { PushTokenPlatform, RegisterPushTokenInput, } from './mixins/OxyServices.notifications';
31
31
  export type { ServiceApp, ServiceActingAsVerification, OxyAuthRefusal } from './mixins/OxyServices.utility';
32
+ export type { OxyAccountEvent, OxyAccountEventFeedItem, OxyAccountEventFeedPage, VerifyAccountEventOptions, } from './mixins/OxyServices.utility';
33
+ export { OxyAccountEventError, OXY_ACCOUNT_DELETED_EVENT_URI } from './mixins/OxyServices.utility';
32
34
  export type { ContactDiscoveryMatch, ContactDiscoveryResponse, } from './mixins/OxyServices.contacts';
33
35
  export type { BulkFollowEntry, BulkFollowResult, BulkUnfollowEntry, BulkUnfollowResult, FollowMutationResult, ViewerGraph, } from './mixins/OxyServices.user';
34
36
  export { OxyAppDataIdentifierError } from './mixins/OxyServices.appData';
@@ -97,6 +97,13 @@ export declare function OxyServicesUserMixin<T extends typeof OxyServicesBase>(B
97
97
  */
98
98
  _serviceApiKey: string | null;
99
99
  _serviceApiSecret: string | null;
100
+ /**
101
+ * Whether this process can mint a service token by attesting what it is
102
+ * (ADR 0026), defined on the auth mixin. A host with no key pair that CAN
103
+ * attest is a first-party backend exactly like one with a key pair, and
104
+ * `getUsersByIds` must treat it as one.
105
+ */
106
+ _canUseWorkloadIdentity: () => Promise<boolean>;
100
107
  /**
101
108
  * Get profile by username.
102
109
  *
@@ -229,8 +236,10 @@ export declare function OxyServicesUserMixin<T extends typeof OxyServicesBase>(B
229
236
  * caller, and returns the SAME public `{ data: PublicUserProfile[] }`
230
237
  * payload (canonical `name.displayName` + `_count`) in every case — no
231
238
  * viewer-specific fields. This method picks the path automatically:
232
- * - **Service-configured host (backend):** when `configureServiceAuth(apiKey,
233
- * apiSecret)` has been called, the chunk is fetched via `makeServiceRequest`
239
+ * - **Service host (backend):** when `configureServiceAuth(apiKey,
240
+ * apiSecret)` has been called, OR the process can attest its workload
241
+ * identity (ADR 0026 — an ECS task with no key pair), the chunk is fetched
242
+ * via `makeServiceRequest`
234
243
  * (attaches `Authorization: Bearer <serviceToken>`). This is the
235
244
  * server-to-server feed/notification hydration path (e.g. Mention's
236
245
  * `PostHydrationService`) and is unchanged.
@@ -25,6 +25,64 @@ export interface ServiceActingAsVerification {
25
25
  authorized: boolean;
26
26
  scopes: string[];
27
27
  }
28
+ /** The SET `events` member Oxy uses for an account deletion. */
29
+ export declare const OXY_ACCOUNT_DELETED_EVENT_URI = "https://oxy.so/events/account.deleted";
30
+ /**
31
+ * A verified account event from Oxy (OxyHQ/Mention#1169): a person deleted
32
+ * their Oxy account and every relying application must erase what it holds
33
+ * for them. Returned by `verifyAccountEvent` only after the signature, `typ`,
34
+ * issuer and audience all check out.
35
+ */
36
+ export interface OxyAccountEvent {
37
+ /** The event id (`jti`). Stable across webhook retries and the pull feed: dedupe on it. */
38
+ eventId: string;
39
+ type: 'account.deleted';
40
+ /** The deleted Oxy account. */
41
+ userId: string;
42
+ /**
43
+ * The account's handle at deletion time, for relying parties that address
44
+ * the person by handle (an ActivityPub actor URI). `null` when the account had
45
+ * none, or when the token predates the field.
46
+ */
47
+ username: string | null;
48
+ /** ISO-8601 time the deletion committed. */
49
+ occurredAt: string;
50
+ /** `true` when Oxy archived the row to keep financial records. Erase either way. */
51
+ retained: boolean;
52
+ /** The application the event was addressed to (`aud`). */
53
+ applicationId: string;
54
+ /** Seconds since the epoch the token was issued at (`iat`). */
55
+ issuedAt: number;
56
+ }
57
+ /** One entry of the account-event pull feed (`GET /account-events`). */
58
+ export interface OxyAccountEventFeedItem {
59
+ eventId: string;
60
+ type: 'account.deleted';
61
+ userId: string;
62
+ username: string | null;
63
+ occurredAt: string;
64
+ retained: boolean;
65
+ /** The same signed token the webhook carries. Verify it with `verifyAccountEvent`. */
66
+ token: string;
67
+ }
68
+ export interface OxyAccountEventFeedPage {
69
+ events: OxyAccountEventFeedItem[];
70
+ /** Pass back as `after` to continue; unchanged when the page is empty. */
71
+ nextCursor: string | null;
72
+ }
73
+ export interface VerifyAccountEventOptions {
74
+ /**
75
+ * The application id the token must be addressed to. Defaults to the `appId`
76
+ * of this client's configured service credential.
77
+ */
78
+ audience?: string;
79
+ /** Defaults to `/.well-known/jwks.json` on this client's API origin. */
80
+ jwksUrl?: string;
81
+ }
82
+ /** Why an account event token was refused. `message` never contains the token. */
83
+ export declare class OxyAccountEventError extends Error {
84
+ constructor(message: string);
85
+ }
28
86
  /**
29
87
  * Service app metadata attached to requests authenticated with service tokens.
30
88
  *
@@ -177,6 +235,31 @@ export declare function OxyServicesUtilityMixin<T extends typeof OxyServicesBase
177
235
  * @internal Used by the auth() middleware — not part of the public API
178
236
  */
179
237
  verifyServiceActingAs(appId: string, userId: string): Promise<ServiceActingAsVerification | null>;
238
+ /**
239
+ * Verify an account event token from Oxy — the body of an account-event
240
+ * webhook, or an entry's `token` from `listAccountEvents` — and return the
241
+ * event. Throws {@link OxyAccountEventError} unless the token is an
242
+ * EdDSA-signed `secevent+jwt` from Oxy's published key set, issued by
243
+ * `oxy-auth`, addressed to this application, carrying exactly one known
244
+ * event. A service token (`typ: JWT`) is refused here, as this token is
245
+ * refused as a service token.
246
+ *
247
+ * Node only (it needs `node:crypto`). The caller must still be idempotent on
248
+ * `eventId`: Oxy delivers at least once, by push and by pull.
249
+ */
250
+ verifyAccountEvent(token: string, options?: VerifyAccountEventOptions): Promise<OxyAccountEvent>;
251
+ /**
252
+ * One page of account events addressed to this application, oldest first,
253
+ * from Oxy's pull feed — the reconciliation path behind the webhook. Needs
254
+ * the service credential (`configureServiceAuth`). Verify each entry's
255
+ * `token` with {@link verifyAccountEvent} before acting on it.
256
+ */
257
+ listAccountEvents(options?: {
258
+ after?: string;
259
+ limit?: number;
260
+ }): Promise<OxyAccountEventFeedPage>;
261
+ /** @internal The `appId` claim of this client's own service token. */
262
+ _configuredServiceAppId(): Promise<string>;
180
263
  /**
181
264
  * Express.js authentication middleware
182
265
  *
@@ -380,7 +463,7 @@ export declare function OxyServicesUtilityMixin<T extends typeof OxyServicesBase
380
463
  withAuthRetry<T_1>(operation: () => Promise<T_1>, operationName: string, options?: {
381
464
  maxRetries?: number;
382
465
  retryDelay?: number;
383
- authTimeoutMs?: number;
466
+ authTimeoutMs? /** @internal Per-client public-key cache; never contains private material. */: number;
384
467
  }): Promise<T_1>;
385
468
  validate(): Promise<boolean>;
386
469
  handleError(error: unknown): Error;
@@ -40,8 +40,8 @@ export declare const oxyRequesterAssertionClaimsSchema: z.ZodObject<{
40
40
  sub: string;
41
41
  aud: string;
42
42
  iss: string;
43
- iat: number;
44
43
  jti: string;
44
+ iat: number;
45
45
  azp: string;
46
46
  cid: string;
47
47
  agentId: string;
@@ -50,8 +50,8 @@ export declare const oxyRequesterAssertionClaimsSchema: z.ZodObject<{
50
50
  sub: string;
51
51
  aud: string;
52
52
  iss: string;
53
- iat: number;
54
53
  jti: string;
54
+ iat: number;
55
55
  azp: string;
56
56
  cid: string;
57
57
  agentId: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxy.so/core",
3
- "version": "1.12.0",
3
+ "version": "1.13.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",
@@ -1934,7 +1934,17 @@
1934
1934
  "warning": "This action cannot be undone. Your account and all associated data will be permanently deleted.",
1935
1935
  "confirmLabel": "Type \"{{username}}\" to confirm",
1936
1936
  "confirm": "Delete Forever",
1937
- "error": "Failed to delete account"
1937
+ "error": "Failed to delete account",
1938
+ "handoff": {
1939
+ "commonsTitle": "Delete your account in Oxy Commons",
1940
+ "commonsMessage": "Your identity key is kept by Oxy Commons on this device, and deleting your account needs it. Commons will open on Settings > Delete account to finish.",
1941
+ "openCommons": "Open Oxy Commons",
1942
+ "commonsOpenFailed": "Couldn't open Oxy Commons. Open it yourself and go to Settings > Delete account.",
1943
+ "elsewhereTitle": "Delete your account where your identity is",
1944
+ "elsewhereMessage": "Deleting your account needs your identity key, and it isn't on this device. Open Oxy Commons on the device that holds your identity and go to Settings > Delete account, or open {{site}} in a browser that holds it.",
1945
+ "gotIt": "Got it",
1946
+ "identityUnreadable": "Couldn't read this device's identity storage. Unlock your device and try again."
1947
+ }
1938
1948
  },
1939
1949
  "manageAccount": {
1940
1950
  "title": "Manage your Oxy Account",
@@ -1934,7 +1934,17 @@
1934
1934
  "warning": "Esta acción no se puede deshacer. Tu cuenta y todos los datos asociados se eliminarán de forma permanente.",
1935
1935
  "confirmLabel": "Escribe \"{{username}}\" para confirmar",
1936
1936
  "confirm": "Eliminar para siempre",
1937
- "error": "No se pudo eliminar la cuenta"
1937
+ "error": "No se pudo eliminar la cuenta",
1938
+ "handoff": {
1939
+ "commonsTitle": "Elimina tu cuenta en Oxy Commons",
1940
+ "commonsMessage": "Tu clave de identidad la guarda Oxy Commons en este dispositivo, y eliminar tu cuenta la necesita. Commons se abrirá en Ajustes > Eliminar cuenta para terminar.",
1941
+ "openCommons": "Abrir Oxy Commons",
1942
+ "commonsOpenFailed": "No se pudo abrir Oxy Commons. Ábrelo tú y ve a Ajustes > Eliminar cuenta.",
1943
+ "elsewhereTitle": "Elimina tu cuenta donde está tu identidad",
1944
+ "elsewhereMessage": "Eliminar tu cuenta necesita tu clave de identidad, y no está en este dispositivo. Abre Oxy Commons en el dispositivo que guarda tu identidad y ve a Ajustes > Eliminar cuenta, o abre {{site}} en un navegador que la guarde.",
1945
+ "gotIt": "Entendido",
1946
+ "identityUnreadable": "No se pudo leer el almacén de identidad de este dispositivo. Desbloquea el dispositivo e inténtalo de nuevo."
1947
+ }
1938
1948
  },
1939
1949
  "manageAccount": {
1940
1950
  "title": "Gestiona tu cuenta de Oxy",
package/src/index.ts CHANGED
@@ -85,6 +85,13 @@ export type {
85
85
  RegisterPushTokenInput,
86
86
  } from './mixins/OxyServices.notifications';
87
87
  export type { ServiceApp, ServiceActingAsVerification, OxyAuthRefusal } from './mixins/OxyServices.utility';
88
+ export type {
89
+ OxyAccountEvent,
90
+ OxyAccountEventFeedItem,
91
+ OxyAccountEventFeedPage,
92
+ VerifyAccountEventOptions,
93
+ } from './mixins/OxyServices.utility';
94
+ export { OxyAccountEventError, OXY_ACCOUNT_DELETED_EVENT_URI } from './mixins/OxyServices.utility';
88
95
  export type {
89
96
  ContactDiscoveryMatch,
90
97
  ContactDiscoveryResponse,
@@ -155,6 +155,14 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
155
155
  declare _serviceApiKey: string | null;
156
156
  declare _serviceApiSecret: string | null;
157
157
 
158
+ /**
159
+ * Whether this process can mint a service token by attesting what it is
160
+ * (ADR 0026), defined on the auth mixin. A host with no key pair that CAN
161
+ * attest is a first-party backend exactly like one with a key pair, and
162
+ * `getUsersByIds` must treat it as one.
163
+ */
164
+ declare _canUseWorkloadIdentity: () => Promise<boolean>;
165
+
158
166
  /**
159
167
  * Get profile by username.
160
168
  *
@@ -464,8 +472,10 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
464
472
  * caller, and returns the SAME public `{ data: PublicUserProfile[] }`
465
473
  * payload (canonical `name.displayName` + `_count`) in every case — no
466
474
  * viewer-specific fields. This method picks the path automatically:
467
- * - **Service-configured host (backend):** when `configureServiceAuth(apiKey,
468
- * apiSecret)` has been called, the chunk is fetched via `makeServiceRequest`
475
+ * - **Service host (backend):** when `configureServiceAuth(apiKey,
476
+ * apiSecret)` has been called, OR the process can attest its workload
477
+ * identity (ADR 0026 — an ECS task with no key pair), the chunk is fetched
478
+ * via `makeServiceRequest`
469
479
  * (attaches `Authorization: Bearer <serviceToken>`). This is the
470
480
  * server-to-server feed/notification hydration path (e.g. Mention's
471
481
  * `PostHydrationService`) and is unchanged.
@@ -500,10 +510,17 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
500
510
  chunks.push(uniqueIds.slice(i, i + USERS_BY_IDS_CHUNK_SIZE));
501
511
  }
502
512
 
503
- // A backend that called configureServiceAuth() uses the bearer-service
504
- // path; any other caller (browser / RN with a user session) uses the
505
- // user-bearer path.
506
- const useServiceAuth = Boolean(this._serviceApiKey && this._serviceApiSecret);
513
+ // A backend uses the bearer-service path; any other caller (browser / RN
514
+ // with a user session) uses the user-bearer path. "A backend" means one
515
+ // that can get a service token at all — a key pair, OR a task role it can
516
+ // attest. Asking only about the key pair sent every attested backend down
517
+ // the user path with no bearer: an anonymous request, charged to the
518
+ // shared NAT address's per-IP budget and slowed by its 500ms penalty.
519
+ // Measured from Mention's task (2026-09-25): 520ms per anonymous chunk
520
+ // against 20ms with the service token, enough to put its 1.5s hydration
521
+ // deadline out of reach on every cache miss.
522
+ const useServiceAuth = Boolean(this._serviceApiKey && this._serviceApiSecret)
523
+ || await this._canUseWorkloadIdentity();
507
524
 
508
525
  // Run chunks concurrently; a single chunk failure must not sink the rest.
509
526
  const settled = await Promise.all(
@@ -57,6 +57,73 @@ export interface ServiceActingAsVerification {
57
57
  scopes: string[];
58
58
  }
59
59
 
60
+ /** The SET `events` member Oxy uses for an account deletion. */
61
+ export const OXY_ACCOUNT_DELETED_EVENT_URI = 'https://oxy.so/events/account.deleted';
62
+
63
+ /**
64
+ * A verified account event from Oxy (OxyHQ/Mention#1169): a person deleted
65
+ * their Oxy account and every relying application must erase what it holds
66
+ * for them. Returned by `verifyAccountEvent` only after the signature, `typ`,
67
+ * issuer and audience all check out.
68
+ */
69
+ export interface OxyAccountEvent {
70
+ /** The event id (`jti`). Stable across webhook retries and the pull feed: dedupe on it. */
71
+ eventId: string;
72
+ type: 'account.deleted';
73
+ /** The deleted Oxy account. */
74
+ userId: string;
75
+ /**
76
+ * The account's handle at deletion time, for relying parties that address
77
+ * the person by handle (an ActivityPub actor URI). `null` when the account had
78
+ * none, or when the token predates the field.
79
+ */
80
+ username: string | null;
81
+ /** ISO-8601 time the deletion committed. */
82
+ occurredAt: string;
83
+ /** `true` when Oxy archived the row to keep financial records. Erase either way. */
84
+ retained: boolean;
85
+ /** The application the event was addressed to (`aud`). */
86
+ applicationId: string;
87
+ /** Seconds since the epoch the token was issued at (`iat`). */
88
+ issuedAt: number;
89
+ }
90
+
91
+ /** One entry of the account-event pull feed (`GET /account-events`). */
92
+ export interface OxyAccountEventFeedItem {
93
+ eventId: string;
94
+ type: 'account.deleted';
95
+ userId: string;
96
+ username: string | null;
97
+ occurredAt: string;
98
+ retained: boolean;
99
+ /** The same signed token the webhook carries. Verify it with `verifyAccountEvent`. */
100
+ token: string;
101
+ }
102
+
103
+ export interface OxyAccountEventFeedPage {
104
+ events: OxyAccountEventFeedItem[];
105
+ /** Pass back as `after` to continue; unchanged when the page is empty. */
106
+ nextCursor: string | null;
107
+ }
108
+
109
+ export interface VerifyAccountEventOptions {
110
+ /**
111
+ * The application id the token must be addressed to. Defaults to the `appId`
112
+ * of this client's configured service credential.
113
+ */
114
+ audience?: string;
115
+ /** Defaults to `/.well-known/jwks.json` on this client's API origin. */
116
+ jwksUrl?: string;
117
+ }
118
+
119
+ /** Why an account event token was refused. `message` never contains the token. */
120
+ export class OxyAccountEventError extends Error {
121
+ constructor(message: string) {
122
+ super(message);
123
+ this.name = 'OxyAccountEventError';
124
+ }
125
+ }
126
+
60
127
  /**
61
128
  * Service app metadata attached to requests authenticated with service tokens.
62
129
  *
@@ -334,6 +401,147 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
334
401
  }
335
402
  }
336
403
 
404
+ /**
405
+ * Verify an account event token from Oxy — the body of an account-event
406
+ * webhook, or an entry's `token` from `listAccountEvents` — and return the
407
+ * event. Throws {@link OxyAccountEventError} unless the token is an
408
+ * EdDSA-signed `secevent+jwt` from Oxy's published key set, issued by
409
+ * `oxy-auth`, addressed to this application, carrying exactly one known
410
+ * event. A service token (`typ: JWT`) is refused here, as this token is
411
+ * refused as a service token.
412
+ *
413
+ * Node only (it needs `node:crypto`). The caller must still be idempotent on
414
+ * `eventId`: Oxy delivers at least once, by push and by pull.
415
+ */
416
+ async verifyAccountEvent(
417
+ token: string,
418
+ options: VerifyAccountEventOptions = {},
419
+ ): Promise<OxyAccountEvent> {
420
+ if (typeof token !== 'string' || token.length === 0 || token.length > 16 * 1024) {
421
+ throw new OxyAccountEventError('Account event token is missing or oversized');
422
+ }
423
+ const parts = token.split('.');
424
+ if (parts.length !== 3 || parts.some((part) => part.length === 0)) {
425
+ throw new OxyAccountEventError('Account event token is not a compact JWS');
426
+ }
427
+ const [headerB64, payloadB64, signatureB64] = parts as [string, string, string];
428
+ let header: Record<string, unknown>;
429
+ let payload: Record<string, unknown>;
430
+ try {
431
+ header = parseJsonSegment(headerB64);
432
+ payload = parseJsonSegment(payloadB64);
433
+ } catch {
434
+ throw new OxyAccountEventError('Account event token is malformed');
435
+ }
436
+ if (
437
+ Object.keys(header).length !== 3
438
+ || header.alg !== 'EdDSA'
439
+ || header.typ !== 'secevent+jwt'
440
+ || typeof header.kid !== 'string'
441
+ || !/^[A-Za-z0-9._-]{1,128}$/.test(header.kid)
442
+ ) {
443
+ throw new OxyAccountEventError('Account event token header is not supported');
444
+ }
445
+
446
+ const nodeCrypto = await loadNodeCrypto();
447
+ let jwk: ServiceTokenPublicJwk;
448
+ try {
449
+ jwk = await resolveServiceTokenPublicKey(
450
+ header.kid,
451
+ options.jwksUrl ?? new URL('/.well-known/jwks.json', this.getBaseURL()).toString(),
452
+ this._serviceTokenJwksCache,
453
+ );
454
+ } catch (error) {
455
+ throw new OxyAccountEventError(
456
+ error instanceof Error ? error.message.replace('Service token', 'Account event') : 'Signing key is unavailable',
457
+ );
458
+ }
459
+ const signature = Buffer.from(signatureB64, 'base64url');
460
+ if (signature.toString('base64url') !== signatureB64 || signature.length !== 64) {
461
+ throw new OxyAccountEventError('Account event token signature is malformed');
462
+ }
463
+ let verified = false;
464
+ try {
465
+ const publicKey = nodeCrypto.createPublicKey({ key: jwk as unknown as JsonWebKey, format: 'jwk' });
466
+ verified = nodeCrypto.verify(null, Buffer.from(`${headerB64}.${payloadB64}`), publicKey, signature);
467
+ } catch {
468
+ verified = false;
469
+ }
470
+ if (!verified) throw new OxyAccountEventError('Account event token signature is invalid');
471
+
472
+ if (payload.iss !== OXY_JWT_ISSUER) {
473
+ throw new OxyAccountEventError('Account event token issuer is not Oxy');
474
+ }
475
+ const audience = options.audience ?? await this._configuredServiceAppId();
476
+ if (typeof payload.aud !== 'string' || payload.aud !== audience) {
477
+ throw new OxyAccountEventError('Account event token is addressed to another application');
478
+ }
479
+ if (typeof payload.jti !== 'string' || payload.jti.length === 0 || payload.jti.length > 128) {
480
+ throw new OxyAccountEventError('Account event token has no event id');
481
+ }
482
+ if (typeof payload.iat !== 'number' || !Number.isFinite(payload.iat)) {
483
+ throw new OxyAccountEventError('Account event token has no issue time');
484
+ }
485
+ const events = payload.events;
486
+ if (typeof events !== 'object' || events === null || Array.isArray(events)) {
487
+ throw new OxyAccountEventError('Account event token carries no events');
488
+ }
489
+ const entries = Object.entries(events as Record<string, unknown>);
490
+ const deleted = (events as Record<string, unknown>)[OXY_ACCOUNT_DELETED_EVENT_URI];
491
+ if (entries.length !== 1 || typeof deleted !== 'object' || deleted === null || Array.isArray(deleted)) {
492
+ throw new OxyAccountEventError('Account event token carries an unknown event');
493
+ }
494
+ const body = deleted as Record<string, unknown>;
495
+ if (typeof body.userId !== 'string' || body.userId.length === 0 || body.userId.length > 128) {
496
+ throw new OxyAccountEventError('Account event names no account');
497
+ }
498
+ if (typeof body.occurredAt !== 'string' || Number.isNaN(Date.parse(body.occurredAt))) {
499
+ throw new OxyAccountEventError('Account event has no occurrence time');
500
+ }
501
+ if (body.username !== undefined && body.username !== null && typeof body.username !== 'string') {
502
+ throw new OxyAccountEventError('Account event username is malformed');
503
+ }
504
+ return {
505
+ eventId: payload.jti,
506
+ type: 'account.deleted',
507
+ userId: body.userId,
508
+ username: typeof body.username === 'string' ? body.username : null,
509
+ occurredAt: body.occurredAt,
510
+ retained: body.retained === true,
511
+ applicationId: payload.aud,
512
+ issuedAt: payload.iat,
513
+ };
514
+ }
515
+
516
+ /**
517
+ * One page of account events addressed to this application, oldest first,
518
+ * from Oxy's pull feed — the reconciliation path behind the webhook. Needs
519
+ * the service credential (`configureServiceAuth`). Verify each entry's
520
+ * `token` with {@link verifyAccountEvent} before acting on it.
521
+ */
522
+ async listAccountEvents(
523
+ options: { after?: string; limit?: number } = {},
524
+ ): Promise<OxyAccountEventFeedPage> {
525
+ const serviceToken = await (this as unknown as OxyAuthInstance).getServiceToken();
526
+ const query: Record<string, string> = {};
527
+ if (options.after) query.after = options.after;
528
+ if (options.limit !== undefined) query.limit = String(options.limit);
529
+ return this.makeRequest<OxyAccountEventFeedPage>('GET', '/account-events', query, {
530
+ cache: false,
531
+ headers: { Authorization: `Bearer ${serviceToken}` },
532
+ });
533
+ }
534
+
535
+ /** @internal The `appId` claim of this client's own service token. */
536
+ async _configuredServiceAppId(): Promise<string> {
537
+ const serviceToken = await (this as unknown as OxyAuthInstance).getServiceToken();
538
+ const appId = jwtDecode<JwtPayload>(serviceToken).appId;
539
+ if (typeof appId !== 'string' || appId.length === 0) {
540
+ throw new OxyAccountEventError('No audience given and the service credential names no application');
541
+ }
542
+ return appId;
543
+ }
544
+
337
545
  /**
338
546
  * Express.js authentication middleware
339
547
  *
@@ -0,0 +1,158 @@
1
+ import { generateKeyPairSync, sign as signBytes, type KeyObject } from 'node:crypto';
2
+ import { OxyServices } from '../../OxyServices';
3
+ import { OXY_ACCOUNT_DELETED_EVENT_URI, OxyAccountEventError } from '../OxyServices.utility';
4
+
5
+ const b64url = (value: string | Uint8Array): string => Buffer.from(value).toString('base64url');
6
+
7
+ function sign(
8
+ privateKey: KeyObject,
9
+ header: Record<string, unknown>,
10
+ payload: Record<string, unknown>,
11
+ ): string {
12
+ const signingInput = `${b64url(JSON.stringify(header))}.${b64url(JSON.stringify(payload))}`;
13
+ return `${signingInput}.${b64url(signBytes(null, Buffer.from(signingInput), privateKey))}`;
14
+ }
15
+
16
+ const KID = 'oxy-service-test';
17
+
18
+ function eventToken(
19
+ privateKey: KeyObject,
20
+ overrides: { header?: Record<string, unknown>; payload?: Record<string, unknown>; event?: Record<string, unknown> } = {},
21
+ ): string {
22
+ return sign(
23
+ privateKey,
24
+ { alg: 'EdDSA', typ: 'secevent+jwt', kid: KID, ...overrides.header },
25
+ {
26
+ iss: 'oxy-auth',
27
+ aud: 'app-mention',
28
+ iat: 1_790_000_000,
29
+ jti: '019a0000-0000-7000-8000-000000000001',
30
+ events: {
31
+ [OXY_ACCOUNT_DELETED_EVENT_URI]: {
32
+ userId: 'user-deleted',
33
+ username: 'qatest0925',
34
+ occurredAt: '2026-09-26T09:14:00.000Z',
35
+ retained: false,
36
+ ...overrides.event,
37
+ },
38
+ },
39
+ ...overrides.payload,
40
+ },
41
+ );
42
+ }
43
+
44
+ describe('verifyAccountEvent', () => {
45
+ const oxyKey = generateKeyPairSync('ed25519');
46
+ const strangerKey = generateKeyPairSync('ed25519');
47
+ const jwk = { ...oxyKey.publicKey.export({ format: 'jwk' }), use: 'sig', alg: 'EdDSA', kid: KID };
48
+
49
+ let fetchSpy: jest.SpyInstance;
50
+ beforeEach(() => {
51
+ fetchSpy = jest.spyOn(globalThis, 'fetch').mockImplementation(async () => new Response(
52
+ JSON.stringify({ keys: [jwk] }),
53
+ { status: 200, headers: { 'content-type': 'application/json' } },
54
+ ));
55
+ });
56
+ afterEach(() => jest.restoreAllMocks());
57
+
58
+ const client = () => new OxyServices({ baseURL: 'https://api.oxy.test' });
59
+
60
+ it('returns the event from a genuine Oxy-signed token, verified against the public JWKS', async () => {
61
+ const event = await client().verifyAccountEvent(eventToken(oxyKey.privateKey), { audience: 'app-mention' });
62
+
63
+ expect(event).toEqual({
64
+ eventId: '019a0000-0000-7000-8000-000000000001',
65
+ type: 'account.deleted',
66
+ userId: 'user-deleted',
67
+ username: 'qatest0925',
68
+ occurredAt: '2026-09-26T09:14:00.000Z',
69
+ retained: false,
70
+ applicationId: 'app-mention',
71
+ issuedAt: 1_790_000_000,
72
+ });
73
+ expect(fetchSpy).toHaveBeenCalledWith(
74
+ 'https://api.oxy.test/.well-known/jwks.json',
75
+ expect.objectContaining({ method: 'GET', redirect: 'error' }),
76
+ );
77
+ });
78
+
79
+ it('reads a token without `username` (issued before the field existed) as username null', async () => {
80
+ const token = eventToken(oxyKey.privateKey, { event: { username: undefined } });
81
+ const event = await client().verifyAccountEvent(token, { audience: 'app-mention' });
82
+ expect(event.username).toBeNull();
83
+ });
84
+
85
+ it('carries `retained` through for an archived account', async () => {
86
+ const token = eventToken(oxyKey.privateKey, { event: { retained: true } });
87
+ await expect(client().verifyAccountEvent(token, { audience: 'app-mention' }))
88
+ .resolves.toMatchObject({ retained: true });
89
+ });
90
+
91
+ it('defaults the audience to the appId of the configured service credential', async () => {
92
+ const oxy = client();
93
+ const serviceToken = `${b64url(JSON.stringify({ alg: 'EdDSA' }))}.${b64url(JSON.stringify({ appId: 'app-mention' }))}.sig`;
94
+ jest.spyOn(oxy, 'getServiceToken').mockResolvedValue(serviceToken);
95
+
96
+ await expect(oxy.verifyAccountEvent(eventToken(oxyKey.privateKey))).resolves.toMatchObject({
97
+ applicationId: 'app-mention',
98
+ });
99
+ await expect(oxy.verifyAccountEvent(eventToken(oxyKey.privateKey, { payload: { aud: 'app-other' } })))
100
+ .rejects.toThrow('addressed to another application');
101
+ });
102
+
103
+ const refusals: Array<[string, () => string, string]> = [
104
+ ['a token signed by a key Oxy did not publish', () => eventToken(strangerKey.privateKey), 'signature is invalid'],
105
+ ['a service token (typ JWT) replayed as an event', () => eventToken(oxyKey.privateKey, { header: { typ: 'JWT' } }), 'header is not supported'],
106
+ ['a token for another application', () => eventToken(oxyKey.privateKey, { payload: { aud: 'app-other' } }), 'addressed to another application'],
107
+ ['a token from another issuer', () => eventToken(oxyKey.privateKey, { payload: { iss: 'someone-else' } }), 'issuer is not Oxy'],
108
+ ['a token without an event id', () => eventToken(oxyKey.privateKey, { payload: { jti: undefined } }), 'no event id'],
109
+ ['a token with an unknown event', () => eventToken(oxyKey.privateKey, { payload: { events: { 'https://oxy.so/events/other': {} } } }), 'unknown event'],
110
+ ['a token naming no account', () => eventToken(oxyKey.privateKey, { event: { userId: '' } }), 'names no account'],
111
+ ['a token whose username is not a string', () => eventToken(oxyKey.privateKey, { event: { username: 42 } }), 'username is malformed'],
112
+ ['a token with an unknown key id', () => eventToken(oxyKey.privateKey, { header: { kid: 'rotated-away' } }), 'unknown'],
113
+ ];
114
+
115
+ it.each(refusals)('refuses %s', async (_label, build, message) => {
116
+ const attempt = client().verifyAccountEvent(build(), { audience: 'app-mention' });
117
+ await expect(attempt).rejects.toBeInstanceOf(OxyAccountEventError);
118
+ await expect(client().verifyAccountEvent(build(), { audience: 'app-mention' })).rejects.toThrow(message);
119
+ });
120
+
121
+ it('refuses a tampered payload', async () => {
122
+ const [header, , signature] = eventToken(oxyKey.privateKey).split('.');
123
+ const forged = b64url(JSON.stringify({
124
+ iss: 'oxy-auth',
125
+ aud: 'app-mention',
126
+ iat: 1,
127
+ jti: 'x',
128
+ events: { [OXY_ACCOUNT_DELETED_EVENT_URI]: { userId: 'someone-else', occurredAt: '2026-09-26T00:00:00Z' } },
129
+ }));
130
+ await expect(client().verifyAccountEvent(`${header}.${forged}.${signature}`, { audience: 'app-mention' }))
131
+ .rejects.toThrow('signature is invalid');
132
+ });
133
+
134
+ it('refuses garbage without fetching keys', async () => {
135
+ await expect(client().verifyAccountEvent('not-a-token', { audience: 'app-mention' }))
136
+ .rejects.toThrow('not a compact JWS');
137
+ expect(fetchSpy).not.toHaveBeenCalled();
138
+ });
139
+ });
140
+
141
+ describe('listAccountEvents', () => {
142
+ afterEach(() => jest.restoreAllMocks());
143
+
144
+ it('reads the pull feed with the service token and passes the cursor', async () => {
145
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.test' });
146
+ jest.spyOn(oxy, 'getServiceToken').mockResolvedValue('service-token');
147
+ const page = { events: [], nextCursor: 'cursor-1' };
148
+ const request = jest.spyOn(oxy, 'makeRequest').mockResolvedValue(page as never);
149
+
150
+ await expect(oxy.listAccountEvents({ after: 'cursor-1', limit: 50 })).resolves.toEqual(page);
151
+ expect(request).toHaveBeenCalledWith(
152
+ 'GET',
153
+ '/account-events',
154
+ { after: 'cursor-1', limit: '50' },
155
+ expect.objectContaining({ cache: false, headers: { Authorization: 'Bearer service-token' } }),
156
+ );
157
+ });
158
+ });