@oxy.so/core 1.4.0 → 1.5.1

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.
@@ -59,6 +59,7 @@
59
59
  import { type LinkedHttpClient, type OxyConfig } from './OxyServices.base';
60
60
  import { AssetUrlResolutionError, OxyAuthenticationError, OxyAuthenticationTimeoutError, ServiceAssetMetadataError } from './OxyServices.errors';
61
61
  import { composeOxyServices } from './mixins';
62
+ import type { OxyAuthRefusal } from './mixins/OxyServices.utility';
62
63
  /**
63
64
  * OxyServices - Unified client library for interacting with the Oxy API
64
65
  *
@@ -105,6 +106,7 @@ export interface OxyServices extends InstanceType<ReturnType<typeof composeOxySe
105
106
  onError?: (error: unknown) => unknown;
106
107
  loadUser?: boolean;
107
108
  optional?: boolean;
109
+ onRefusal?: (refusal: OxyAuthRefusal) => void;
108
110
  serviceTokenJwksUrl?: string;
109
111
  jwtSecret?: string;
110
112
  expectedIssuer?: string;
@@ -115,6 +117,7 @@ export interface OxyServices extends InstanceType<ReturnType<typeof composeOxySe
115
117
  }): (socket: unknown, next: (err?: Error) => void) => Promise<void>;
116
118
  serviceAuth(options?: {
117
119
  debug?: boolean;
120
+ onRefusal?: (refusal: OxyAuthRefusal) => void;
118
121
  serviceTokenJwksUrl?: string;
119
122
  jwtSecret?: string;
120
123
  expectedIssuer?: string;
@@ -28,7 +28,7 @@ export type { CommonsDeliveryFacts, CommonsDeliveryPlatform, CommonsDeliveryRout
28
28
  export type { ServiceTokenResponse, OAuthUserInfoResponse, OAuthTokenExchangeResult, } from './mixins/OxyServices.auth';
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
- export type { ServiceApp, ServiceActingAsVerification } from './mixins/OxyServices.utility';
31
+ export type { ServiceApp, ServiceActingAsVerification, OxyAuthRefusal } from './mixins/OxyServices.utility';
32
32
  export type { ContactDiscoveryMatch, ContactDiscoveryResponse, } from './mixins/OxyServices.contacts';
33
33
  export type { InitDeviceTransferResult, DeviceTransferOutcome, } from './mixins/OxyServices.deviceTransfer';
34
34
  export type { BulkFollowEntry, BulkFollowResult, BulkUnfollowEntry, BulkUnfollowResult, FollowMutationResult, ViewerGraph, } from './mixins/OxyServices.user';
@@ -58,6 +58,43 @@ export interface ServiceApp {
58
58
  /** Test/live isolation (F2.0): which `ApplicationCredential.environment` minted this token. */
59
59
  environment: OxyServiceEnvironment;
60
60
  }
61
+ /**
62
+ * Why a request's credential was refused, recorded on the request itself.
63
+ *
64
+ * The middleware answers a fixed, deliberately uninformative body to the
65
+ * client; this is the other half of that trade. A refusal is ALWAYS observable
66
+ * to the HOST: it is logged at `warn` with a stable `code`, handed to
67
+ * `onRefusal`, and left on `req.oxyAuthRefusal` for the host's own logs.
68
+ *
69
+ * It exists because the opposite cost was measured: Oxy served
70
+ * `{"keys":[]}` from `/.well-known/jwks.json` with no Ed25519 signing key
71
+ * bound, every service token failed `Oxy service-token key set is unavailable`,
72
+ * and on the OPTIONAL path that failure fell straight through to `next()`.
73
+ * The host then answered its own generic 401 and logged nothing at all, so the
74
+ * one fact that named the fault — an empty key set — existed nowhere. Hours.
75
+ *
76
+ * **Nothing here is ever sent to a client, and `reason` never contains the
77
+ * token, a signature, a secret or a session id.** It carries the SDK's own
78
+ * failure message (`kid` unknown, audience mismatch, key set unavailable) plus
79
+ * the ids already considered safe to log elsewhere in this middleware.
80
+ */
81
+ export interface OxyAuthRefusal {
82
+ /** Stable, greppable code. Matches the `code` a non-optional refusal answers. */
83
+ code: string;
84
+ /** Which credential lane refused. */
85
+ stage: 'token' | 'service-token' | 'session';
86
+ /** Human-readable cause. Never a token, signature, secret or session id. */
87
+ reason: string;
88
+ /** The status a NON-optional mount would have answered with. */
89
+ status: number;
90
+ /**
91
+ * `true` when the middleware was mounted with `optional: true`, so this
92
+ * refusal did not produce a response — the request continued unauthenticated
93
+ * and whatever the host does next (typically its own generic 401) is the
94
+ * only thing the client sees.
95
+ */
96
+ optional: boolean;
97
+ }
61
98
  /**
62
99
  * Options for oxyClient.auth() middleware
63
100
  */
@@ -70,6 +107,17 @@ interface AuthMiddlewareOptions {
70
107
  loadUser?: boolean;
71
108
  /** Optional auth - attach user if token present but don't block (default: false) */
72
109
  optional?: boolean;
110
+ /**
111
+ * Called whenever a PRESENTED credential is refused, on the optional path as
112
+ * well as the blocking one. Runs before the response (if any) and after
113
+ * `req.oxyAuthRefusal` is set, so a host can raise its own alert or carry the
114
+ * code into its request log. Throwing from it is swallowed — an observer must
115
+ * not be able to turn a refusal into a 500.
116
+ *
117
+ * Refusal is about a credential that was offered and rejected: a request with
118
+ * NO `Authorization` header is not refused and does not reach this.
119
+ */
120
+ onRefusal?: (refusal: OxyAuthRefusal) => void;
73
121
  /**
74
122
  * Public JWKS endpoint used for Ed25519 service-token verification. Defaults
75
123
  * to `/.well-known/jwks.json` on this Oxy client's configured API origin.
@@ -242,6 +290,8 @@ export declare function OxyServicesUtilityMixin<T extends typeof OxyServicesBase
242
290
  */
243
291
  serviceAuth(options?: {
244
292
  debug?: boolean;
293
+ /** Observe refusals on the service lane; forwarded straight to `auth()`. */
294
+ onRefusal?: (refusal: OxyAuthRefusal) => void;
245
295
  /** @deprecated Oxy-API-only HS256 transition; external verifiers use JWKS. */
246
296
  jwtSecret?: string;
247
297
  serviceTokenJwksUrl?: string;
@@ -362,6 +412,7 @@ interface AuthReq {
362
412
  userId: string;
363
413
  scopes: string[];
364
414
  };
415
+ oxyAuthRefusal?: OxyAuthRefusal;
365
416
  }
366
417
  interface AuthRes {
367
418
  status(code: number): AuthRes;
@@ -1,8 +1,9 @@
1
1
  import type { NextFunction, Request, RequestHandler, Response } from 'express';
2
2
  import type { OxyServices } from '../OxyServices';
3
+ import type { OxyAuthRefusal } from '../mixins/OxyServices.utility';
3
4
  import { OXY_SERVICE_ENVIRONMENTS, type OxyServiceEnvironment } from '../utils/oxyServiceEnvironment';
4
5
  export { OXY_SERVICE_ENVIRONMENTS };
5
- export type { OxyServiceEnvironment };
6
+ export type { OxyServiceEnvironment, OxyAuthRefusal };
6
7
  export interface OxyRequestUser {
7
8
  id: string;
8
9
  _id?: string;
@@ -35,6 +36,12 @@ export interface OxyAuthRequest extends Request {
35
36
  sessionId?: string | null;
36
37
  serviceApp?: OxyServiceAppContext;
37
38
  serviceActingAs?: OxyServiceActingAsContext;
39
+ /**
40
+ * Why the presented credential was refused, when one was and the request
41
+ * still reached the handler (the optional path). Read it with
42
+ * {@link getOxyAuthRefusal} — never put it in a response body.
43
+ */
44
+ oxyAuthRefusal?: OxyAuthRefusal;
38
45
  }
39
46
  export interface OxyAuthenticatedRequest extends OxyAuthRequest {
40
47
  userId: string;
@@ -125,6 +132,17 @@ export declare function getOxyDelegatedUserId(req: Request): string | null;
125
132
  */
126
133
  export declare function getOxyRequestAttribution(req: Request): OxyRequestAttribution | null;
127
134
  export declare function getRequiredOxyUserId(req: Request): string;
135
+ /**
136
+ * Why the credential on this request was refused, or `null` when none was
137
+ * presented (or it was accepted).
138
+ *
139
+ * Set by `oxy.auth()` — including the `optional: true` mount the composed
140
+ * helpers use, where a refusal otherwise leaves no trace: the request simply
141
+ * arrives unauthenticated and the host answers its own generic 401. Hosts log
142
+ * this beside their own 401; it is diagnostic and **must not be put into a
143
+ * response body**, which is exactly what it is kept out of.
144
+ */
145
+ export declare function getOxyAuthRefusal(req: Request): OxyAuthRefusal | null;
128
146
  export declare function requireOxyAuth(req: Request, res: Response, next: NextFunction): void;
129
147
  export declare function createOptionalOxyAuth(oxy: OxyServices, options?: OxyAuthMiddlewareOptions): RequestHandler;
130
148
  export declare function createOxyAuthMiddleware(oxy: OxyServices, options?: OxyAuthMiddlewareOptions): RequestHandler;
@@ -14,8 +14,8 @@
14
14
  * app.use(createOxyRateLimit(oxy, { store: redisStore }));
15
15
  * ```
16
16
  */
17
- export { createOptionalOxyAuth, createOxyAuthMiddleware, getOxyBillingPrincipal, getOxyDelegatedUserId, getOxyRequestAttribution, getOxyUserId, getRequiredOxyBillingPrincipal, getRequiredOxyUserId, isOxyAuthenticated, requireOxyAuth, OXY_SERVICE_ENVIRONMENTS, } from './auth';
18
- export type { OxyAuthenticatedRequest, OxyAuthMiddlewareOptions, OxyAuthRequest, OxyBillingPrincipal, OxyRequestAttribution, OxyRequestUser, OxyServiceActingAsContext, OxyServiceAppContext, OxyServiceEnvironment, } from './auth';
17
+ export { createOptionalOxyAuth, createOxyAuthMiddleware, getOxyAuthRefusal, getOxyBillingPrincipal, getOxyDelegatedUserId, getOxyRequestAttribution, getOxyUserId, getRequiredOxyBillingPrincipal, getRequiredOxyUserId, isOxyAuthenticated, requireOxyAuth, OXY_SERVICE_ENVIRONMENTS, } from './auth';
18
+ export type { OxyAuthenticatedRequest, OxyAuthRefusal, OxyAuthMiddlewareOptions, OxyAuthRequest, OxyBillingPrincipal, OxyRequestAttribution, OxyRequestUser, OxyServiceActingAsContext, OxyServiceAppContext, OxyServiceEnvironment, } from './auth';
19
19
  export { createOxyRateLimit } from './rateLimit';
20
20
  export type { OxyRateLimitOptions } from './rateLimit';
21
21
  export { assertSafePublicUrl, isBlockedIp, safeFetch, SsrfRejection, UpstreamError, ALLOWED_PORTS, ALLOWED_PROTOCOLS, BLOCKED_HOSTNAMES, DEFAULT_USER_AGENT, MAX_REDIRECTS, MAX_URL_LENGTH, UPSTREAM_HEADERS_TIMEOUT_MS, } from './safeFetch';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxy.so/core",
3
- "version": "1.4.0",
3
+ "version": "1.5.1",
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",
@@ -61,6 +61,7 @@ import { AssetUrlResolutionError, OxyAuthenticationError, OxyAuthenticationTimeo
61
61
 
62
62
  // Import mixin composition helper
63
63
  import { composeOxyServices } from './mixins';
64
+ import type { OxyAuthRefusal } from './mixins/OxyServices.utility';
64
65
 
65
66
  /**
66
67
  * OxyServices - Unified client library for interacting with the Oxy API
@@ -120,11 +121,18 @@ export interface OxyServices extends InstanceType<ReturnType<typeof composeOxySe
120
121
  createLinkedClient(config: OxyConfig): LinkedHttpClient;
121
122
 
122
123
  // Express.js middleware
124
+ //
125
+ // This list is the one CONSUMERS see: `OxyAuthMiddlewareOptions` in
126
+ // `server/auth.ts` is `Parameters<OxyServices['auth']>[0]`, so an option that
127
+ // exists on the mixin and not here does not exist for anybody outside this
128
+ // package. `onRefusal` shipped in 1.5.0 exactly that way — present in the
129
+ // implementation, invisible to every host that tried to pass it.
123
130
  auth(options?: {
124
131
  debug?: boolean;
125
132
  onError?: (error: unknown) => unknown;
126
133
  loadUser?: boolean;
127
134
  optional?: boolean;
135
+ onRefusal?: (refusal: OxyAuthRefusal) => void;
128
136
  serviceTokenJwksUrl?: string;
129
137
  jwtSecret?: string;
130
138
  expectedIssuer?: string;
@@ -139,6 +147,7 @@ export interface OxyServices extends InstanceType<ReturnType<typeof composeOxySe
139
147
  // Service-token-only middleware (delegates to auth() internally)
140
148
  serviceAuth(options?: {
141
149
  debug?: boolean;
150
+ onRefusal?: (refusal: OxyAuthRefusal) => void;
142
151
  serviceTokenJwksUrl?: string;
143
152
  jwtSecret?: string;
144
153
  expectedIssuer?: string;
@@ -0,0 +1,75 @@
1
+ /**
2
+ * The middleware options a CONSUMER can pass must be the ones the middleware
3
+ * actually takes.
4
+ *
5
+ * `src/OxyServices.ts` re-declares `auth()` and `serviceAuth()` by hand, with
6
+ * their option objects written out inline, because declaration emit for the
7
+ * mixin pipeline does not carry them. That hand-written interface is what
8
+ * downstream packages see — `OxyAuthMiddlewareOptions` in `server/auth.ts` is
9
+ * literally `Parameters<OxyServices['auth']>[0]` — so an option that exists on
10
+ * the mixin and not there exists for this package and for nobody else.
11
+ *
12
+ * `onRefusal` shipped in 1.5.0 exactly that way: implemented, tested, exported,
13
+ * and rejected by the first host that tried to pass it
14
+ * (`TS2345 … has no properties in common with`). Nothing in this package could
15
+ * see it, because everything in this package resolves the mixin's own
16
+ * declaration, where the option is present. Only the published `.d.ts` loses it.
17
+ *
18
+ * So the check is on the SOURCE TEXT of the two declarations, which is the
19
+ * thing that diverges. It is not a type test: a type test here reads the merged
20
+ * interface and passes either way — measured, by deleting the option and
21
+ * watching `tsc --noEmit` stay green.
22
+ */
23
+
24
+ import { readFileSync } from 'node:fs';
25
+ import { join } from 'node:path';
26
+
27
+ const SRC = join(__dirname, '..');
28
+
29
+ /** Every `name?:` key declared directly inside one brace-balanced block. */
30
+ function optionKeys(source: string, startPattern: RegExp): string[] {
31
+ const match = startPattern.exec(source);
32
+ if (!match) throw new Error(`declaration not found: ${String(startPattern)}`);
33
+ let depth = 0;
34
+ let index = source.indexOf('{', match.index);
35
+ const open = index;
36
+ do {
37
+ const char = source[index];
38
+ if (char === '{') depth += 1;
39
+ else if (char === '}') depth -= 1;
40
+ index += 1;
41
+ } while (depth > 0 && index < source.length);
42
+ const body = source.slice(open + 1, index - 1);
43
+ // Only keys at THIS level: a nested object literal would otherwise contribute
44
+ // its own keys. None exist today; the guard keeps that from becoming silent.
45
+ const withoutNested = body.replace(/\{[^{}]*\}/g, '');
46
+ return [...withoutNested.matchAll(/^\s*(\w+)\??\s*:/gm)].map((m) => m[1] as string).sort();
47
+ }
48
+
49
+ describe('the public OxyServices interface declares the options the mixin takes', () => {
50
+ const mixin = readFileSync(join(SRC, 'mixins/OxyServices.utility.ts'), 'utf8');
51
+ const publicInterface = readFileSync(join(SRC, 'OxyServices.ts'), 'utf8');
52
+
53
+ it('auth() — same option names on both declarations', () => {
54
+ const declared = optionKeys(mixin, /interface AuthMiddlewareOptions/);
55
+ const published = optionKeys(publicInterface, /^ {2}auth\(options\?: \{/m);
56
+
57
+ expect(declared).toContain('onRefusal');
58
+ expect(published).toEqual(declared);
59
+ });
60
+
61
+ it('serviceAuth() — same option names on both declarations', () => {
62
+ const declared = optionKeys(mixin, /^ {4}serviceAuth\(options: \{/m);
63
+ const published = optionKeys(publicInterface, /^ {2}serviceAuth\(options\?: \{/m);
64
+
65
+ expect(declared).toContain('onRefusal');
66
+ expect(published).toEqual(declared);
67
+ });
68
+
69
+ it('reads real declarations rather than matching nothing', () => {
70
+ // The floor. A regex that stopped matching would make both cases above
71
+ // compare two empty lists and pass.
72
+ expect(optionKeys(mixin, /interface AuthMiddlewareOptions/).length).toBeGreaterThan(5);
73
+ expect(optionKeys(publicInterface, /^ {2}auth\(options\?: \{/m).length).toBeGreaterThan(5);
74
+ });
75
+ });
package/src/index.ts CHANGED
@@ -81,7 +81,7 @@ export type {
81
81
  PushTokenPlatform,
82
82
  RegisterPushTokenInput,
83
83
  } from './mixins/OxyServices.notifications';
84
- export type { ServiceApp, ServiceActingAsVerification } from './mixins/OxyServices.utility';
84
+ export type { ServiceApp, ServiceActingAsVerification, OxyAuthRefusal } from './mixins/OxyServices.utility';
85
85
  export type {
86
86
  ContactDiscoveryMatch,
87
87
  ContactDiscoveryResponse,