@oxyhq/core 21.0.1 → 21.1.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.
@@ -191,6 +191,22 @@ export interface CreateAccountInput {
191
191
  * keep working. At most `MAX_ACCOUNT_CATEGORIES`, no duplicates.
192
192
  */
193
193
  accountCategories?: AccountCategoryId[];
194
+ /**
195
+ * Create the account already opted OUT of discovery — kept out of people
196
+ * search, the follow-graph lists, `/similar` and the recommendation pools,
197
+ * with non-public media follower-gated.
198
+ *
199
+ * Pass `true` when the account is not something its owner has published yet:
200
+ * an agent, an unlaunched project, an organization for something unannounced.
201
+ * OMITTED IS NOT `false` IN MEANING, only in effect — saying nothing leaves
202
+ * the platform default, which is discoverable, and that default is not
203
+ * changed by this option existing.
204
+ *
205
+ * Setting it later is `PUT /users/:userId/privacy`, which needs the ACCOUNT's
206
+ * own bearer. Passing it here is the only way to have the account never be
207
+ * discoverable at all, rather than discoverable until a second call lands.
208
+ */
209
+ isPrivateAccount?: boolean;
194
210
  }
195
211
  /** Input accepted by `updateAccount`. Tree placement changes go through `/move`. */
196
212
  export interface UpdateAccountInput {
@@ -6,8 +6,16 @@ import { type OxyServiceEnvironment } from '../utils/oxyServiceEnvironment';
6
6
  * Confirms that a given service app holds an active delegation grant for
7
7
  * the supplied user, along with the explicit scope list the grant covers.
8
8
  *
9
- * The api side persists these via the `ServiceActingAs` model:
10
- * { serviceAppId, userId, scopes: string[], grantedAt, expiresAt }
9
+ * The API side stores this as an ordinary `app_grants` row — the SAME revocable
10
+ * record the OAuth consent screen writes and the "Connected apps" UI lists and
11
+ * deletes — whose `scopes` name `acting-as:offline`. There is deliberately no
12
+ * separate delegation table: a second store would be a second revocation
13
+ * surface, and a user who disconnects an application in "Connected apps" means
14
+ * it, so one revoke has to end everything.
15
+ *
16
+ * `scopes` is what THAT USER consented to, not what the application may do in
17
+ * general. `requireScope` intersects it with the token's own app-wide scopes for
18
+ * a delegated request, and the intersection is the effective authority.
11
19
  *
12
20
  * The SDK never inspects the grant directly — it round-trips through
13
21
  * `GET /internal/service-acting-as/verify?appId=...&userId=...` so the
@@ -266,6 +274,20 @@ export declare function OxyServicesUtilityMixin<T extends typeof OxyServicesBase
266
274
  * service requests require the app scope. Delegated user requests require
267
275
  * BOTH the app scope and the per-user delegation scope.
268
276
  *
277
+ * The intersection is the point, not a redundancy, because the two scope
278
+ * lists answer different questions and neither implies the other:
279
+ *
280
+ * `serviceApp.scopes` what the PLATFORM allows this application to do
281
+ * (credential ∩ application ceiling, at mint time)
282
+ * `serviceActingAs.scopes` what THIS USER allowed it to do (`app_grants`)
283
+ *
284
+ * Requiring only the app scope would let an application do to a user
285
+ * something that user never consented to; requiring only the grant would let
286
+ * a user hand an application authority staff never gave it, so a revoked
287
+ * platform scope would keep working for every user who had already
288
+ * consented. Effective authority is the intersection, and this is where it
289
+ * is taken.
290
+ *
269
291
  * Requests authenticated as a regular user (no service token) are rejected
270
292
  * with 403 — scope-protected endpoints are service-to-service by design.
271
293
  *
@@ -15,6 +15,7 @@
15
15
  * one-label subdomains such as `auth.oxy.so`, `api.oxy.so`,
16
16
  * `accounts.oxy.so`, `console.oxy.so`, and `inbox.oxy.so`,
17
17
  * - allows the caller's explicit `appOrigins`,
18
+ * - REFUSES the opaque origin on both sides (see `OPAQUE_ORIGIN`),
18
19
  * - DENIES everything else (no reflection, never a wildcard with credentials),
19
20
  * - echoes back the EXACT matched origin (so credentialed requests work) and
20
21
  * sets `Vary: Origin` for correct caching,
@@ -29,6 +30,11 @@ export interface OxyCorsOptions {
29
30
  * `https://app.example.com`, `http://localhost:3000`). These are allowed IN
30
31
  * ADDITION TO the built-in HTTPS Oxy apex origin family. Each is normalized
31
32
  * via `new URL().origin`.
33
+ *
34
+ * An entry that is not a URL, or whose origin is the opaque origin
35
+ * (`exp://…`, `capacitor://…`, `chrome-extension://…`, `file:`, `data:`), is
36
+ * DROPPED with an error log rather than admitted — see `OPAQUE_ORIGIN` for
37
+ * why one such entry would otherwise admit every other one.
32
38
  */
33
39
  appOrigins?: string[];
34
40
  /**
@@ -46,6 +52,41 @@ export interface OxyCorsOptions {
46
52
  /** Preflight cache lifetime in seconds. Default 86400 (24h). */
47
53
  maxAgeSeconds?: number;
48
54
  }
55
+ /**
56
+ * Normalize the configured `appOrigins` into the exact-match set — the
57
+ * CONFIGURE-SIDE half of the opaque-origin guard.
58
+ *
59
+ * An entry that is not a URL, or whose origin is opaque, is dropped and named
60
+ * in an error log. Dropped rather than thrown on because `appOrigins` is
61
+ * deployment configuration — at least one Oxy backend reads it from the
62
+ * environment — and a typo there must cost that one origin its CORS headers,
63
+ * never the whole service its boot. Both failure modes are equally SAFE (the
64
+ * entry is absent from the set either way), so the choice is purely about
65
+ * blast radius, and dropping keeps it to one origin whose requests then fail
66
+ * visibly in the browser.
67
+ *
68
+ * Exported for `__tests__/cors.socket.test.ts` and NOT re-exported from
69
+ * `server/index.ts`, so it is not part of the package's public surface. The
70
+ * two halves of the guard are separately exported because they are separately
71
+ * testable only that way: with this half in place the match-side half is
72
+ * unreachable through `createOxyCors`, so a test driving the public API alone
73
+ * would measure this function twice and the other one never.
74
+ */
75
+ export declare function normalizeAppOrigins(appOrigins: string[]): Set<string>;
76
+ /**
77
+ * Whether `origin` may be echoed back: it is in the built-in HTTPS Oxy apex
78
+ * family, or it exactly matches one of the configured app origins.
79
+ *
80
+ * The opaque-origin refusal here is the MATCH-SIDE half of the guard, and it
81
+ * is what makes the property hold regardless of how `explicit` was built — a
82
+ * set that somehow contains `"null"` still matches nothing, because no
83
+ * incoming origin ever normalizes past this line. `normalizeAppOrigins` is
84
+ * what stops such a set existing today; this is what stops it mattering.
85
+ *
86
+ * Exported for the same reason as `normalizeAppOrigins`, and likewise absent
87
+ * from `server/index.ts`.
88
+ */
89
+ export declare function matchesAllowedOrigin(explicit: ReadonlySet<string>, origin: string): boolean;
49
90
  /**
50
91
  * Create a strict Oxy CORS middleware. See module docs.
51
92
  *
@@ -22,7 +22,7 @@ export { assertSafePublicUrl, isBlockedIp, safeFetch, SsrfRejection, UpstreamErr
22
22
  export type { SafeFetchOptions, SafeFetchResult, SsrfCheckFail, SsrfCheckOk, SsrfCheckResult, } from './safeFetch';
23
23
  export { createOxyCors } from './cors';
24
24
  export type { OxyCorsOptions } from './cors';
25
- export { buildOxyCspDirectives, buildOxyPagesHeaders, createOxySecurityHeaders, formatOxyCspPolicy, OXY_CSP_BASELINE, } from './securityHeaders';
25
+ export { buildOxyCspDirectives, buildOxyPagesHeaders, createOxySecurityHeaders, cspSourcesFor, extractInlineScripts, formatOxyCspPolicy, inlineScriptCspHash, OXY_CSP_BASELINE, } from './securityHeaders';
26
26
  export type { OxyCspDirective, OxyCspExtensions, OxyPagesHeadersOptions, OxySecurityHeadersOptions, } from './securityHeaders';
27
27
  export { verifySecret } from './verifySecret';
28
28
  export { createOxyUserInvalidationHandler, publishOxyUserInvalidation, } from './userInvalidation';
@@ -33,6 +33,19 @@
33
33
  * cannot pass their own `contentSecurityPolicy` through to Helmet at all
34
34
  * (the option is typed `never`).
35
35
  *
36
+ * 3. A STATIC EXPO EXPORT SHIPS AN INLINE SCRIPT THE BASELINE FORBIDS.
37
+ * `web.output: 'static'` makes Expo Router emit
38
+ * `<script type="module">globalThis.__EXPO_ROUTER_HYDRATE__=true;</script>`,
39
+ * which is what tells the client entry to call `hydrateRoot` instead of
40
+ * `createRoot().render()`. Nothing in app code puts it there, so — like the
41
+ * Cloudflare beacon above — an app cannot allowlist it from the app side.
42
+ * Measured on `accounts.oxy.so` 2026-08-21: blocked, so every visit threw
43
+ * away the server-rendered markup and re-rendered from scratch, with only a
44
+ * console error to show for it. The hashes are therefore DERIVED from the
45
+ * built output rather than hand-written (see {@link extractInlineScripts}):
46
+ * a hash pasted into config is correct exactly until the build changes one
47
+ * byte, and then it fails the same silent way.
48
+ *
36
49
  * WHAT IT PROVIDES
37
50
  * ----------------
38
51
  * `createOxySecurityHeaders(options)` returns the Helmet middleware with the
@@ -102,6 +115,43 @@ export declare function buildOxyCspDirectives(extensions?: OxyCspExtensions): Re
102
115
  * `upgrade-insecure-requests`) emit the name alone.
103
116
  */
104
117
  export declare function formatOxyCspPolicy(directives: Record<string, string[]>): string;
118
+ /**
119
+ * The source list one directive carries in a serialized policy, or `[]` when
120
+ * the policy does not name that directive. The inverse of
121
+ * {@link formatOxyCspPolicy}, and the reason it lives here rather than beside
122
+ * either caller: the post-deploy gate parses the policy the ORIGIN serves while
123
+ * the unit test parses the one the middleware renders, so a copy in each would
124
+ * let the header shape change with the test still green and the gate reading
125
+ * `[]` — reporting every script blocked, which reads as a broken app rather
126
+ * than as a broken parser.
127
+ *
128
+ * A directive present with no sources (`upgrade-insecure-requests`) and a
129
+ * directive absent entirely both answer `[]`. Callers that need to tell those
130
+ * apart are asking a different question than "what is allowed here".
131
+ */
132
+ export declare function cspSourcesFor(policy: string, directive: string): string[];
133
+ /**
134
+ * Every inline `<script>` body in an HTML document, in document order. A
135
+ * `<script src=…>` is a URL the source list already governs and is skipped.
136
+ *
137
+ * Scanned rather than matched with one regex because the two failure modes are
138
+ * not symmetric: an EXTRA body costs a redundant hash nobody notices, while a
139
+ * MISSED body silently reinstates the exact breakage this exists to prevent.
140
+ * So the scan errs toward finding them — it walks the open tag quote-aware
141
+ * instead of letting a `>` inside an attribute value truncate it.
142
+ *
143
+ * The type attribute is deliberately not consulted. Whether a given `type`
144
+ * executes is a browser decision (and it changes: `importmap` and
145
+ * `speculationrules` were both once inert), and pinning the exact bytes of a
146
+ * data block we ship ourselves weakens nothing.
147
+ */
148
+ export declare function extractInlineScripts(html: string): string[];
149
+ /**
150
+ * The `'sha256-…'` source that allows one inline script, hashed over its exact
151
+ * bytes as CSP specifies — no trimming, no normalization. One byte of
152
+ * whitespace either way is a different hash and the script stays blocked.
153
+ */
154
+ export declare function inlineScriptCspHash(source: string): string;
105
155
  export interface OxyPagesHeadersOptions {
106
156
  /** Per-app additions merged into {@link OXY_CSP_BASELINE}. */
107
157
  csp?: OxyCspExtensions;
@@ -110,11 +160,28 @@ export interface OxyPagesHeadersOptions {
110
160
  * HTTPS only, so static deploys should keep this on.
111
161
  */
112
162
  hsts?: boolean;
163
+ /**
164
+ * The BUILT HTML documents this `_headers` will be served alongside. Every
165
+ * inline script found in them is allowed by hash, added to `script-src`.
166
+ *
167
+ * Passing the built output — rather than hand-writing a hash into
168
+ * `oxy.pages-headers.json` — is the whole point: a pasted hash is correct
169
+ * until the generator changes one byte of that script, and then the script is
170
+ * blocked again with nothing but a console error to show for it.
171
+ */
172
+ html?: readonly string[];
113
173
  }
114
174
  /**
115
175
  * Build a Cloudflare Pages `_headers` block for an Oxy HTML origin. Uses the
116
176
  * same CSP resolution as {@link createOxySecurityHeaders} plus the non-CSP
117
177
  * hardening headers Helmet would add on an Express HTML backend.
178
+ *
179
+ * Adding a hash to `script-src` does not narrow it: per CSP Level 3 a hash is
180
+ * an additional source, so `'self'` and the beacon host keep matching external
181
+ * scripts. (It WOULD neutralize `'unsafe-inline'` in the same directive — which
182
+ * is why this hashes scripts only. `style-src` keeps `'unsafe-inline'` for
183
+ * react-native-web's runtime stylesheet, and a style hash would silently switch
184
+ * that off and render every Oxy web app unstyled.)
118
185
  */
119
186
  export declare function buildOxyPagesHeaders(options?: OxyPagesHeadersOptions): string;
120
187
  export interface OxySecurityHeadersOptions {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/core",
3
- "version": "21.0.1",
3
+ "version": "21.1.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",
@@ -116,7 +116,7 @@
116
116
  "dependencies": {
117
117
  "@noble/ciphers": "^1.3.0",
118
118
  "@noble/hashes": "^1.8.0",
119
- "@oxyhq/contracts": "^0.30.0",
119
+ "@oxyhq/contracts": "^0.31.0",
120
120
  "@oxyhq/protocol": "^0.2.0",
121
121
  "@scure/bip39": "^1.6.0",
122
122
  "@types/elliptic": "^6.4.18",
@@ -153,7 +153,7 @@
153
153
  "devDependencies": {
154
154
  "@biomejs/biome": "^1.9.4",
155
155
  "@react-native-async-storage/async-storage": "^2.2.0",
156
- "@types/express": "^4.17.21",
156
+ "@types/express": "^4.17.25",
157
157
  "@types/invariant": "^2.2.34",
158
158
  "@types/node": "^20.19.43",
159
159
  "expo-crypto": "~56.0.3",
@@ -212,6 +212,22 @@ export interface CreateAccountInput {
212
212
  * keep working. At most `MAX_ACCOUNT_CATEGORIES`, no duplicates.
213
213
  */
214
214
  accountCategories?: AccountCategoryId[];
215
+ /**
216
+ * Create the account already opted OUT of discovery — kept out of people
217
+ * search, the follow-graph lists, `/similar` and the recommendation pools,
218
+ * with non-public media follower-gated.
219
+ *
220
+ * Pass `true` when the account is not something its owner has published yet:
221
+ * an agent, an unlaunched project, an organization for something unannounced.
222
+ * OMITTED IS NOT `false` IN MEANING, only in effect — saying nothing leaves
223
+ * the platform default, which is discoverable, and that default is not
224
+ * changed by this option existing.
225
+ *
226
+ * Setting it later is `PUT /users/:userId/privacy`, which needs the ACCOUNT's
227
+ * own bearer. Passing it here is the only way to have the account never be
228
+ * discoverable at all, rather than discoverable until a second call lands.
229
+ */
230
+ isPrivateAccount?: boolean;
215
231
  }
216
232
 
217
233
  /** Input accepted by `updateAccount`. Tree placement changes go through `/move`. */
@@ -36,8 +36,16 @@ interface JwtPayload {
36
36
  * Confirms that a given service app holds an active delegation grant for
37
37
  * the supplied user, along with the explicit scope list the grant covers.
38
38
  *
39
- * The api side persists these via the `ServiceActingAs` model:
40
- * { serviceAppId, userId, scopes: string[], grantedAt, expiresAt }
39
+ * The API side stores this as an ordinary `app_grants` row — the SAME revocable
40
+ * record the OAuth consent screen writes and the "Connected apps" UI lists and
41
+ * deletes — whose `scopes` name `acting-as:offline`. There is deliberately no
42
+ * separate delegation table: a second store would be a second revocation
43
+ * surface, and a user who disconnects an application in "Connected apps" means
44
+ * it, so one revoke has to end everything.
45
+ *
46
+ * `scopes` is what THAT USER consented to, not what the application may do in
47
+ * general. `requireScope` intersects it with the token's own app-wide scopes for
48
+ * a delegated request, and the intersection is the effective authority.
41
49
  *
42
50
  * The SDK never inspects the grant directly — it round-trips through
43
51
  * `GET /internal/service-acting-as/verify?appId=...&userId=...` so the
@@ -217,11 +225,31 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
217
225
  }
218
226
 
219
227
  try {
228
+ // The verify endpoint is service-to-service and admits only a
229
+ // platform-TRUSTED calling application, so this call must carry the
230
+ // VERIFIER's own service token. Sent explicitly rather than through
231
+ // `makeServiceRequest`, which would drop `retry: false` and the timeout
232
+ // — and those two are not incidental: this runs inside request-handling
233
+ // middleware, so an inner retry loop multiplies the latency of every
234
+ // delegated request by the number of attempts.
235
+ //
236
+ // A verifier with no service credentials configured throws here and
237
+ // lands in the catch below, which is the correct outcome. A host that
238
+ // cannot prove who it is has no business being told which users have
239
+ // delegated to which applications, and the 60s negative cache stops a
240
+ // misconfigured deployment from turning every request into a round trip.
241
+ const serviceToken = await (this as unknown as OxyAuthInstance).getServiceToken();
242
+
220
243
  const result = await this.makeRequest<ServiceActingAsVerification>(
221
244
  'GET',
222
245
  '/internal/service-acting-as/verify',
223
246
  { appId, userId },
224
- { cache: false, retry: false, timeout: 5000 },
247
+ {
248
+ cache: false,
249
+ retry: false,
250
+ timeout: 5000,
251
+ headers: { Authorization: `Bearer ${serviceToken}` },
252
+ },
225
253
  );
226
254
 
227
255
  const authorized = Boolean(result && result.authorized);
@@ -973,6 +1001,20 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
973
1001
  * service requests require the app scope. Delegated user requests require
974
1002
  * BOTH the app scope and the per-user delegation scope.
975
1003
  *
1004
+ * The intersection is the point, not a redundancy, because the two scope
1005
+ * lists answer different questions and neither implies the other:
1006
+ *
1007
+ * `serviceApp.scopes` what the PLATFORM allows this application to do
1008
+ * (credential ∩ application ceiling, at mint time)
1009
+ * `serviceActingAs.scopes` what THIS USER allowed it to do (`app_grants`)
1010
+ *
1011
+ * Requiring only the app scope would let an application do to a user
1012
+ * something that user never consented to; requiring only the grant would let
1013
+ * a user hand an application authority staff never gave it, so a revoked
1014
+ * platform scope would keep working for every user who had already
1015
+ * consented. Effective authority is the intersection, and this is where it
1016
+ * is taken.
1017
+ *
976
1018
  * Requests authenticated as a regular user (no service token) are rejected
977
1019
  * with 403 — scope-protected endpoints are service-to-service by design.
978
1020
  *
@@ -1155,6 +1197,7 @@ interface SocketLike {
1155
1197
 
1156
1198
  interface OxyAuthInstance {
1157
1199
  verifyServiceActingAs(appId: string, userId: string): Promise<ServiceActingAsVerification | null>;
1200
+ getServiceToken(apiKey?: string, apiSecret?: string): Promise<string>;
1158
1201
  validateSession(
1159
1202
  sessionId: string,
1160
1203
  options?: { deviceFingerprint?: string; useHeaderValidation?: boolean },
@@ -0,0 +1,167 @@
1
+ /**
2
+ * `verifyServiceActingAs` — the SDK half of the delegation check.
3
+ *
4
+ * `serviceAuth.test.ts` covers what the MIDDLEWARE does with this method's
5
+ * answer, and it does so by stubbing the method out. So nothing there exercises
6
+ * the method itself: how it authenticates, what it sends, and what it does when
7
+ * the answer is no or never arrives. That is this file.
8
+ *
9
+ * The property under test throughout is fail-closed. `null` is the only value
10
+ * this method may return when it is not certain, because the middleware turns
11
+ * `null` into a 403 and anything else into an attached `req.userId`.
12
+ */
13
+
14
+ import { OxyServices } from '../../OxyServices';
15
+ import type { RequestOptions } from '../../types';
16
+
17
+ const APP = 'delegating-app';
18
+ const USER = 'subject-user';
19
+
20
+ interface CapturedCall {
21
+ method: string;
22
+ url: string;
23
+ data: unknown;
24
+ options: RequestOptions | undefined;
25
+ }
26
+
27
+ /**
28
+ * Stub `makeRequest` and record what it was handed.
29
+ *
30
+ * Deliberately not a network mock: the assertion that matters is the exact
31
+ * request the SDK composes — an unauthenticated one now gets a 403 from the API
32
+ * rather than an answer, so the Authorization header is part of the contract and
33
+ * not an implementation detail.
34
+ */
35
+ function captureRequests(oxy: OxyServices, result: unknown) {
36
+ const calls: CapturedCall[] = [];
37
+ jest
38
+ .spyOn(oxy, 'makeRequest')
39
+ .mockImplementation(async (method, url, data, options) => {
40
+ calls.push({ method, url, data, options });
41
+ if (result instanceof Error) throw result;
42
+ return result as never;
43
+ });
44
+ return calls;
45
+ }
46
+
47
+ describe('verifyServiceActingAs', () => {
48
+ let oxy: OxyServices;
49
+
50
+ beforeEach(() => {
51
+ oxy = new OxyServices({ baseURL: 'http://test.invalid' });
52
+ jest.spyOn(oxy, 'getServiceToken').mockResolvedValue('verifier-service-token');
53
+ });
54
+
55
+ afterEach(() => {
56
+ jest.restoreAllMocks();
57
+ });
58
+
59
+ it("authenticates with the VERIFIER's own service token", async () => {
60
+ const calls = captureRequests(oxy, { authorized: true, scopes: ['podcasts:write'] });
61
+
62
+ await oxy.verifyServiceActingAs(APP, USER);
63
+
64
+ expect(calls).toHaveLength(1);
65
+ expect(calls[0].options?.headers).toEqual({
66
+ Authorization: 'Bearer verifier-service-token',
67
+ });
68
+ });
69
+
70
+ it('sends the pair as query params, with retries off and a bounded timeout', async () => {
71
+ // This runs inside request-handling middleware. A retry loop here multiplies
72
+ // the latency of every delegated request by the number of attempts, and a
73
+ // cached GET would serve a revoked grant.
74
+ const calls = captureRequests(oxy, { authorized: true, scopes: [] });
75
+
76
+ await oxy.verifyServiceActingAs(APP, USER);
77
+
78
+ expect(calls[0].method).toBe('GET');
79
+ expect(calls[0].url).toBe('/internal/service-acting-as/verify');
80
+ expect(calls[0].data).toEqual({ appId: APP, userId: USER });
81
+ expect(calls[0].options).toMatchObject({ cache: false, retry: false, timeout: 5000 });
82
+ });
83
+
84
+ it('returns the grant when the API authorizes, carrying the scopes through', async () => {
85
+ captureRequests(oxy, { authorized: true, scopes: ['acting-as:offline', 'podcasts:write'] });
86
+
87
+ const grant = await oxy.verifyServiceActingAs(APP, USER);
88
+
89
+ expect(grant).toEqual({
90
+ authorized: true,
91
+ scopes: ['acting-as:offline', 'podcasts:write'],
92
+ });
93
+ });
94
+
95
+ it('returns null when the API answers authorized:false', async () => {
96
+ captureRequests(oxy, { authorized: false, scopes: [] });
97
+
98
+ await expect(oxy.verifyServiceActingAs(APP, USER)).resolves.toBeNull();
99
+ });
100
+
101
+ it('returns null when the API answers with scopes but NO authorized flag', async () => {
102
+ // A truthy `scopes` array must never stand in for authorization: a caller
103
+ // reading the array and skipping the boolean is the mistake, and the SDK
104
+ // refuses to produce a value that would reward it.
105
+ captureRequests(oxy, { scopes: ['podcasts:write'] });
106
+
107
+ await expect(oxy.verifyServiceActingAs(APP, USER)).resolves.toBeNull();
108
+ });
109
+
110
+ it('returns null when the endpoint is UNREACHABLE — there is no fail-open path', async () => {
111
+ captureRequests(oxy, new Error('ECONNREFUSED'));
112
+
113
+ await expect(oxy.verifyServiceActingAs(APP, USER)).resolves.toBeNull();
114
+ });
115
+
116
+ it('returns null, and never calls the endpoint, when the verifier has no service credentials', async () => {
117
+ // A host that cannot prove who it is has no business being told which users
118
+ // delegated to which applications. `getServiceToken()` throws, and that is
119
+ // the whole outcome.
120
+ jest
121
+ .spyOn(oxy, 'getServiceToken')
122
+ .mockRejectedValue(new Error('Service credentials not provided.'));
123
+ const calls = captureRequests(oxy, { authorized: true, scopes: ['podcasts:write'] });
124
+
125
+ await expect(oxy.verifyServiceActingAs(APP, USER)).resolves.toBeNull();
126
+ expect(calls).toHaveLength(0);
127
+ });
128
+
129
+ describe('caching', () => {
130
+ it('serves a positive grant from cache rather than re-asking', async () => {
131
+ const calls = captureRequests(oxy, { authorized: true, scopes: ['podcasts:write'] });
132
+
133
+ await oxy.verifyServiceActingAs(APP, USER);
134
+ await oxy.verifyServiceActingAs(APP, USER);
135
+
136
+ expect(calls).toHaveLength(1);
137
+ });
138
+
139
+ it('caches a REFUSAL too, so a misconfigured caller cannot hammer the endpoint', async () => {
140
+ const calls = captureRequests(oxy, { authorized: false, scopes: [] });
141
+
142
+ await expect(oxy.verifyServiceActingAs(APP, USER)).resolves.toBeNull();
143
+ await expect(oxy.verifyServiceActingAs(APP, USER)).resolves.toBeNull();
144
+
145
+ expect(calls).toHaveLength(1);
146
+ });
147
+
148
+ it('keys the cache on BOTH app and user — one grant never answers for another', async () => {
149
+ // The cache key is the whole security boundary of this method. Keyed on
150
+ // the user alone, one application's grant would authorize every other
151
+ // application for that user; keyed on the app alone, one user's grant
152
+ // would authorize acting as everybody.
153
+ const calls = captureRequests(oxy, { authorized: true, scopes: ['podcasts:write'] });
154
+
155
+ await oxy.verifyServiceActingAs(APP, USER);
156
+ await oxy.verifyServiceActingAs('other-app', USER);
157
+ await oxy.verifyServiceActingAs(APP, 'other-user');
158
+
159
+ expect(calls).toHaveLength(3);
160
+ expect(calls.map((c) => c.data)).toEqual([
161
+ { appId: APP, userId: USER },
162
+ { appId: 'other-app', userId: USER },
163
+ { appId: APP, userId: 'other-user' },
164
+ ]);
165
+ });
166
+ });
167
+ });