@oxyhq/core 21.0.2 → 21.2.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.
@@ -180,6 +180,21 @@ export interface CreateAccountInput {
180
180
  };
181
181
  bio?: string;
182
182
  avatar?: string;
183
+ /**
184
+ * Named color preset KEY — `'blue'`, `'mint'`, … — never a hex value. The
185
+ * account graph's half of `User.color`, which every account DTO already
186
+ * carries; this is how one gets WRITTEN for an account you administer.
187
+ *
188
+ * Set it HERE rather than after the fact. For a managed account the colour is
189
+ * a visual identity, and an account that is discoverable without one and
190
+ * acquires it on a second request is a face that changes by itself.
191
+ *
192
+ * Omitted is not "no colour": the platform assigns a random preset, exactly as
193
+ * it did before this field existed. A reserved preset is refused unless the
194
+ * account has a claim to it — the administrator's own entitlements are not the
195
+ * ones weighed.
196
+ */
197
+ color?: string;
183
198
  /**
184
199
  * What the account is about. ORDERED — the FIRST element is the primary
185
200
  * category, so a picker must submit them in the order the user arranged them
@@ -191,6 +206,22 @@ export interface CreateAccountInput {
191
206
  * keep working. At most `MAX_ACCOUNT_CATEGORIES`, no duplicates.
192
207
  */
193
208
  accountCategories?: AccountCategoryId[];
209
+ /**
210
+ * Create the account already opted OUT of discovery — kept out of people
211
+ * search, the follow-graph lists, `/similar` and the recommendation pools,
212
+ * with non-public media follower-gated.
213
+ *
214
+ * Pass `true` when the account is not something its owner has published yet:
215
+ * an agent, an unlaunched project, an organization for something unannounced.
216
+ * OMITTED IS NOT `false` IN MEANING, only in effect — saying nothing leaves
217
+ * the platform default, which is discoverable, and that default is not
218
+ * changed by this option existing.
219
+ *
220
+ * Setting it later is `PUT /users/:userId/privacy`, which needs the ACCOUNT's
221
+ * own bearer. Passing it here is the only way to have the account never be
222
+ * discoverable at all, rather than discoverable until a second call lands.
223
+ */
224
+ isPrivateAccount?: boolean;
194
225
  }
195
226
  /** Input accepted by `updateAccount`. Tree placement changes go through `/move`. */
196
227
  export interface UpdateAccountInput {
@@ -208,6 +239,15 @@ export interface UpdateAccountInput {
208
239
  };
209
240
  bio?: string | null;
210
241
  avatar?: string | null;
242
+ /**
243
+ * Named color preset KEY, same vocabulary as `CreateAccountInput['color']`.
244
+ *
245
+ * NOT nullable, unlike `bio` and `avatar`: the column is `NOT NULL` with a
246
+ * default, so an account always HAS a colour and there is no "clear" to
247
+ * express. Sending the value the account already carries is always accepted,
248
+ * so a client may PATCH back the object it was served.
249
+ */
250
+ color?: string;
211
251
  /**
212
252
  * Replaces the WHOLE list, in the order given — there is no add/remove verb,
213
253
  * because a partial edit cannot express a re-ordering and the order is what
@@ -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
  *
@@ -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.2",
3
+ "version": "21.2.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.32.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",
@@ -201,6 +201,21 @@ export interface CreateAccountInput {
201
201
  name?: { first?: string; last?: string; displayName?: string };
202
202
  bio?: string;
203
203
  avatar?: string;
204
+ /**
205
+ * Named color preset KEY — `'blue'`, `'mint'`, … — never a hex value. The
206
+ * account graph's half of `User.color`, which every account DTO already
207
+ * carries; this is how one gets WRITTEN for an account you administer.
208
+ *
209
+ * Set it HERE rather than after the fact. For a managed account the colour is
210
+ * a visual identity, and an account that is discoverable without one and
211
+ * acquires it on a second request is a face that changes by itself.
212
+ *
213
+ * Omitted is not "no colour": the platform assigns a random preset, exactly as
214
+ * it did before this field existed. A reserved preset is refused unless the
215
+ * account has a claim to it — the administrator's own entitlements are not the
216
+ * ones weighed.
217
+ */
218
+ color?: string;
204
219
  /**
205
220
  * What the account is about. ORDERED — the FIRST element is the primary
206
221
  * category, so a picker must submit them in the order the user arranged them
@@ -212,6 +227,22 @@ export interface CreateAccountInput {
212
227
  * keep working. At most `MAX_ACCOUNT_CATEGORIES`, no duplicates.
213
228
  */
214
229
  accountCategories?: AccountCategoryId[];
230
+ /**
231
+ * Create the account already opted OUT of discovery — kept out of people
232
+ * search, the follow-graph lists, `/similar` and the recommendation pools,
233
+ * with non-public media follower-gated.
234
+ *
235
+ * Pass `true` when the account is not something its owner has published yet:
236
+ * an agent, an unlaunched project, an organization for something unannounced.
237
+ * OMITTED IS NOT `false` IN MEANING, only in effect — saying nothing leaves
238
+ * the platform default, which is discoverable, and that default is not
239
+ * changed by this option existing.
240
+ *
241
+ * Setting it later is `PUT /users/:userId/privacy`, which needs the ACCOUNT's
242
+ * own bearer. Passing it here is the only way to have the account never be
243
+ * discoverable at all, rather than discoverable until a second call lands.
244
+ */
245
+ isPrivateAccount?: boolean;
215
246
  }
216
247
 
217
248
  /** Input accepted by `updateAccount`. Tree placement changes go through `/move`. */
@@ -226,6 +257,15 @@ export interface UpdateAccountInput {
226
257
  name?: { first?: string; last?: string; displayName?: string };
227
258
  bio?: string | null;
228
259
  avatar?: string | null;
260
+ /**
261
+ * Named color preset KEY, same vocabulary as `CreateAccountInput['color']`.
262
+ *
263
+ * NOT nullable, unlike `bio` and `avatar`: the column is `NOT NULL` with a
264
+ * default, so an account always HAS a colour and there is no "clear" to
265
+ * express. Sending the value the account already carries is always accepted,
266
+ * so a client may PATCH back the object it was served.
267
+ */
268
+ color?: string;
229
269
  /**
230
270
  * Replaces the WHOLE list, in the order given — there is no add/remove verb,
231
271
  * because a partial edit cannot express a re-ordering and the order is what
@@ -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 },
@@ -255,13 +255,20 @@ describe('OxyServices.accounts', () => {
255
255
  it('posts the payload, unwraps `account`, and busts every list', async () => {
256
256
  makeRequestSpy.mockResolvedValue({ account: accountNodeFixture });
257
257
 
258
- const result = await oxy.createAccount({ kind: 'organization', username: 'oxy-org' });
258
+ const result = await oxy.createAccount({
259
+ kind: 'organization',
260
+ username: 'oxy-org',
261
+ color: 'purple',
262
+ });
259
263
 
260
264
  expect(result).toEqual(accountNodeFixture);
265
+ // `color` rides the CREATE body, not a follow-up patch: an account that is
266
+ // discoverable without its colour and acquires one on a second request is
267
+ // a face that changes by itself.
261
268
  expect(makeRequestSpy).toHaveBeenCalledWith(
262
269
  'POST',
263
270
  '/accounts',
264
- { kind: 'organization', username: 'oxy-org' },
271
+ { kind: 'organization', username: 'oxy-org', color: 'purple' },
265
272
  expect.objectContaining({ cache: false }),
266
273
  );
267
274
  expect(clearEntrySpy).toHaveBeenCalledWith('GET:/accounts');
@@ -273,13 +280,13 @@ describe('OxyServices.accounts', () => {
273
280
  it('patches, unwraps `account`, and busts the detail + lists', async () => {
274
281
  makeRequestSpy.mockResolvedValue({ account: accountNodeFixture });
275
282
 
276
- const result = await oxy.updateAccount('acc1', { bio: 'hello' });
283
+ const result = await oxy.updateAccount('acc1', { bio: 'hello', color: 'mint' });
277
284
 
278
285
  expect(result).toEqual(accountNodeFixture);
279
286
  expect(makeRequestSpy).toHaveBeenCalledWith(
280
287
  'PATCH',
281
288
  '/accounts/acc1',
282
- { bio: 'hello' },
289
+ { bio: 'hello', color: 'mint' },
283
290
  expect.objectContaining({ cache: false }),
284
291
  );
285
292
  expect(clearEntrySpy).toHaveBeenCalledWith('GET:/accounts/acc1');
@@ -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
+ });