@oxy.so/core 7.16.0 → 7.18.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.
Files changed (62) hide show
  1. package/README.md +24 -13
  2. package/dist/cjs/server/OxyServer.js +85 -3
  3. package/dist/cjs/server/appPermissions.js +14 -92
  4. package/dist/cjs/server/callerAccess.js +170 -0
  5. package/dist/cjs/server/callerAuthority.js +206 -0
  6. package/dist/cjs/server/capabilityTicket.js +39 -6
  7. package/dist/cjs/server/cors.js +41 -2
  8. package/dist/cjs/server/index.js +33 -5
  9. package/dist/cjs/server/liveCapabilityTicket.js +4 -2
  10. package/dist/cjs/server/middleware.js +104 -2
  11. package/dist/cjs/server/namespaces.js +15 -4
  12. package/dist/cjs/server/safeFetch.js +29 -1
  13. package/dist/cjs/server/userClient.js +127 -0
  14. package/dist/cjs/utils/cache.js +58 -22
  15. package/dist/esm/server/OxyServer.js +85 -3
  16. package/dist/esm/server/appPermissions.js +12 -89
  17. package/dist/esm/server/callerAccess.js +164 -0
  18. package/dist/esm/server/callerAuthority.js +194 -0
  19. package/dist/esm/server/capabilityTicket.js +38 -6
  20. package/dist/esm/server/cors.js +39 -2
  21. package/dist/esm/server/index.js +26 -3
  22. package/dist/esm/server/liveCapabilityTicket.js +5 -3
  23. package/dist/esm/server/middleware.js +104 -2
  24. package/dist/esm/server/namespaces.js +15 -4
  25. package/dist/esm/server/safeFetch.js +29 -1
  26. package/dist/esm/server/userClient.js +121 -0
  27. package/dist/esm/utils/cache.js +57 -22
  28. package/dist/types/api/accounts.d.ts +8 -1
  29. package/dist/types/api/apps.d.ts +22 -4
  30. package/dist/types/index.d.ts +1 -1
  31. package/dist/types/server/OxyServer.d.ts +74 -7
  32. package/dist/types/server/appPermissions.d.ts +3 -13
  33. package/dist/types/server/auth.d.ts +5 -0
  34. package/dist/types/server/callerAccess.d.ts +99 -0
  35. package/dist/types/server/callerAuthority.d.ts +127 -0
  36. package/dist/types/server/capabilityTicket.d.ts +23 -1
  37. package/dist/types/server/cors.d.ts +39 -0
  38. package/dist/types/server/index.d.ts +31 -7
  39. package/dist/types/server/liveCapabilityTicket.d.ts +6 -2
  40. package/dist/types/server/middleware.d.ts +34 -2
  41. package/dist/types/server/namespaces.d.ts +18 -2
  42. package/dist/types/server/safeFetch.d.ts +10 -0
  43. package/dist/types/server/userClient.d.ts +71 -0
  44. package/dist/types/utils/cache.d.ts +13 -0
  45. package/package.json +1 -1
  46. package/src/api/accounts.ts +8 -1
  47. package/src/api/apps.ts +23 -4
  48. package/src/index.ts +1 -0
  49. package/src/server/OxyServer.ts +126 -7
  50. package/src/server/appPermissions.ts +28 -105
  51. package/src/server/auth.ts +5 -0
  52. package/src/server/callerAccess.ts +264 -0
  53. package/src/server/callerAuthority.ts +267 -0
  54. package/src/server/capabilityTicket.ts +76 -17
  55. package/src/server/cors.ts +63 -2
  56. package/src/server/index.ts +38 -4
  57. package/src/server/liveCapabilityTicket.ts +12 -5
  58. package/src/server/middleware.ts +171 -4
  59. package/src/server/namespaces.ts +36 -5
  60. package/src/server/safeFetch.ts +31 -1
  61. package/src/server/userClient.ts +147 -0
  62. package/src/utils/cache.ts +56 -18
package/README.md CHANGED
@@ -31,7 +31,7 @@ The package exposes two public entry points:
31
31
  - `@oxy.so/core/session` — `SessionClient`, the account dialog controller, auth-state stores, cold boot
32
32
  - `@oxy.so/core/crypto` — `KeyManager`, `SignatureService`, `RecoveryPhraseService` and the primitives under them
33
33
  - `@oxy.so/core/civic`, `@oxy.so/core/inference`, `@oxy.so/core/logger`
34
- - `@oxy.so/core/server` — `OxyServer` (service tokens, `middleware.*`) and the Express helpers (`createOxyRateLimit`, `createOxyAuthMiddleware`, `requireOxyAuth`, `getOxyUserId`, `getRequiredOxyUserId`, `createOxyCors`, `createOxySecurityHeaders`, `buildOxyCspDirectives`, `safeFetch`, `verifySecret`, and request types)
34
+ - `@oxy.so/core/server` — `OxyServer` (workload service tokens, `middleware.*`, `withUser` / `asUser`, `appPermissionsFor`, `accountAccessFor`, `applicationAccessFor`) and the Express helpers (`createOxyRateLimit`, `createOxyAuthMiddleware`, `getOxyUserId`, `getRequiredOxyUserId`, `createOxyCors`, `createOxySocketCors`, `createOxySecurityHeaders`, `buildOxyCspDirectives`, `safeFetch`, `verifySecret`, and request types)
35
35
 
36
36
  Heavy pieces have their own entries so an app ships only what it uses; React Native and browser bundles never import Express. The full API map is [docs/api.mdx](./docs/api.mdx).
37
37
 
@@ -136,34 +136,45 @@ Caching is left to the consumer's own layer (React Query / stores). Pass
136
136
  `oxy.createLinkedClient({ baseURL, enableCache: true })` to opt back in when the
137
137
  consumer accepts responsibility for invalidation.
138
138
 
139
- ## Backend Auth Middleware
139
+ ## Backends
140
140
 
141
- Backends should use the SDK server helpers instead of local auth request types
142
- or `requireAuth` copies.
141
+ A backend uses ONE `OxyServer` per process. An Oxy-owned backend authenticates
142
+ as its workload (ADR 0026/0049): no api key, no client secret.
143
143
 
144
144
  ```ts
145
- import { OxyServices } from '@oxy.so/core';
146
145
  import {
146
+ OxyServer,
147
+ createOxyAuthMiddleware,
147
148
  createOxyRateLimit,
148
- requireOxyAuth,
149
149
  getRequiredOxyUserId,
150
150
  type OxyAuthRequest,
151
151
  } from '@oxy.so/core/server';
152
152
 
153
- const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
153
+ export const oxy = new OxyServer({
154
+ baseURL: 'https://api.oxy.so',
155
+ applicationId: process.env.OXY_APPLICATION_ID,
156
+ serviceIdentity: 'when-anonymous',
157
+ });
154
158
 
155
159
  app.use(createOxyRateLimit(oxy, { store: redisStore }));
156
- router.use(requireOxyAuth);
160
+ app.use('/api', createOxyAuthMiddleware(oxy));
157
161
 
158
162
  router.get('/me', (req: OxyAuthRequest, res) => {
159
- const userId = getRequiredOxyUserId(req);
160
- res.json({ userId });
163
+ res.json({ userId: getRequiredOxyUserId(req) });
164
+ });
165
+
166
+ // As the person behind the request — never on the shared client:
167
+ router.get('/orgs/:accountId', async (req, res) => {
168
+ const access = await oxy.accountAccessFor(req, req.params.accountId);
169
+ res.json({ canEdit: access.has('account:update') });
161
170
  });
162
171
  ```
163
172
 
164
- For routers that are not mounted after `createOxyRateLimit`, use
165
- `createOxyAuthMiddleware(oxy)` to resolve the bearer session and require a user
166
- in one middleware.
173
+ `createOxyAuthMiddleware(oxy)` resolves the bearer session and requires a user
174
+ in one middleware; `createOptionalOxyAuth(oxy)` is the optional form. The
175
+ permission questions (`appPermissionsFor`, `accountAccessFor`,
176
+ `applicationAccessFor`), user clients (`withUser`), files and outbound HTTP are
177
+ in [Building a backend](./docs/backend.mdx).
167
178
 
168
179
  ## Security Headers And CSP
169
180
 
@@ -55,6 +55,8 @@ const workloadIdentity_1 = require("./workloadIdentity");
55
55
  const middleware_1 = require("./middleware");
56
56
  const appPermissions_1 = require("./appPermissions");
57
57
  const namespaces_1 = require("./namespaces");
58
+ const callerAccess_1 = require("./callerAccess");
59
+ const userClient_1 = require("./userClient");
58
60
  const safety_1 = require("./safety");
59
61
  const session_1 = require("./session");
60
62
  const serviceEvents_1 = require("./serviceEvents");
@@ -170,15 +172,24 @@ class OxyServer extends OxyServices_1.OxyServices {
170
172
  defaultApplicationId: () => this.defaultApplicationId(),
171
173
  fetchRolesMe: (applicationId, accessToken) => this.fetchRolesMe(applicationId, accessToken),
172
174
  });
175
+ this.callerAccess = new callerAccess_1.OxyCallerAccessResolver({
176
+ ttlMs: ttl,
177
+ readAccount: (accountId, accessToken) => this.withUser(accessToken, (user) => user.accounts.get(accountId)),
178
+ readApplication: (applicationId, accessToken) => this.withUser(accessToken, (user) => user.apps.get(applicationId)),
179
+ });
180
+ const defaultAudience = config.expectedAudience?.trim() || undefined;
173
181
  this.middleware = (0, middleware_1.createOxyMiddleware)({
174
182
  get baseURL() {
175
183
  return server.baseURL;
176
184
  },
185
+ defaultAudience,
177
186
  jwksCache: this.jwksCache,
178
187
  validateSession: (sessionId, options) => this.session.validate(sessionId, options),
179
188
  verifyActingAs: (appId, userId, options) => this.verifyActingAs(appId, userId, options),
180
189
  introspectServiceToken: (token) => this.introspectServiceToken(token),
181
190
  appPermissionsFor: (req, options) => this.appPermissionsFor(req, options),
191
+ accountAccessFor: (req, accountId, options) => this.accountAccessFor(req, accountId, options),
192
+ applicationAccessFor: (req, applicationId, options) => this.applicationAccessFor(req, applicationId, options),
182
193
  });
183
194
  }
184
195
  /** A product SDK transport authenticated only by this process's workload. */
@@ -605,12 +616,83 @@ class OxyServer extends OxyServices_1.OxyServices {
605
616
  return this.appPermissions.resolve(req, options);
606
617
  }
607
618
  /**
608
- * Drop cached application-permission answers — all of them, or one
609
- * person's (as the session user or the resolved operator). For a backend
610
- * that learns of a revocation out of band and will not wait out the TTL.
619
+ * Drop cached caller-authority answers — application permissions and
620
+ * account / application access — all of them, or one person's (as the
621
+ * session user or the resolved operator). For a backend that learns of a
622
+ * revocation out of band and will not wait out the TTL.
611
623
  */
612
624
  invalidateAppPermissions(filter = {}) {
613
625
  this.appPermissions.invalidate(filter);
626
+ this.callerAccess.invalidate(filter);
627
+ }
628
+ // ── The requesting person ────────────────────────────────────────────────
629
+ /**
630
+ * An isolated Oxy client authenticated as the person behind `source` — a
631
+ * request `middleware.auth()` resolved to a user session, or a bearer you
632
+ * already verified. Every `OxyServices` namespace, with no response cache,
633
+ * no token refresh and no fallback to this server's service identity; this
634
+ * server's own session is never touched.
635
+ *
636
+ * Prefer {@link withUser}, which disposes it for you. A client from here
637
+ * should be disposed when the request ends (`client.dispose()`) so a stray
638
+ * reference cannot keep using the bearer; one that is not holds no global
639
+ * resource and is collected normally.
640
+ *
641
+ * Throws `OxyAccessError` (`ACCESS_SESSION_REQUIRED`) for a request with no
642
+ * user session, including one authenticated by a service token.
643
+ */
644
+ asUser(source) {
645
+ const config = (0, userClient_1.userClientConfig)(this.config);
646
+ return new userClient_1.OxyUserClient({ ...config, baseURL: this.baseURL, cloudURL: this.cloudURL }, (0, userClient_1.userBearerOf)(source));
647
+ }
648
+ /**
649
+ * Run `use` with a client authenticated as the person behind `source`
650
+ * ({@link asUser}), and dispose it when `use` settles — success or failure.
651
+ *
652
+ * ```ts
653
+ * const accounts = await oxy.withUser(req, (user) => user.accounts.list());
654
+ * ```
655
+ */
656
+ async withUser(source, use) {
657
+ const user = this.asUser(source);
658
+ try {
659
+ return await use(user);
660
+ }
661
+ finally {
662
+ user.dispose();
663
+ }
664
+ }
665
+ /**
666
+ * What the person behind `req` may do in Oxy account `accountId` — an
667
+ * organization, store, channel or their own personal account: relationship,
668
+ * role, source and the EFFECTIVE permissions (inheritance, grants and
669
+ * revokes applied). Read with the request's own verified bearer.
670
+ *
671
+ * Cached per session for `appPermissionsCacheTtlMs` (30 s), positive
672
+ * answers only; pass `{ fresh: true }` before a write. Throws
673
+ * `OxyAccessError`: `ACCESS_SESSION_REQUIRED` (no session, a service token,
674
+ * or Oxy answered 401), `ACCESS_DENIED` (Oxy answered 403/404 — no access,
675
+ * or no such account), `ACCESS_UNAVAILABLE` (anything else; never a "no").
676
+ * Map it with `accessErrorStatus`. Gate on `has(permission)`, never on
677
+ * `role`.
678
+ */
679
+ accountAccessFor(req, accountId, options = {}) {
680
+ return this.callerAccess.account(req, accountId, options);
681
+ }
682
+ /**
683
+ * What the person behind `req` may do to Oxy application `applicationId`,
684
+ * through their membership of the account that owns it (developer RBAC:
685
+ * `app:read`, `app:update`, `webhooks:update`, `billing:manage`, …), plus
686
+ * its `ownerAccountId` and whether it is `official`. Read with the
687
+ * request's own verified bearer.
688
+ *
689
+ * Not {@link appPermissionsFor}: that one answers which of THIS backend's
690
+ * own declared operator keys the person holds. Same cache, options and
691
+ * errors as {@link accountAccessFor}; Oxy refuses (403) a caller with no
692
+ * `app:read`, so a successful answer always includes it.
693
+ */
694
+ applicationAccessFor(req, applicationId, options = {}) {
695
+ return this.callerAccess.application(req, applicationId, options);
614
696
  }
615
697
  /**
616
698
  * Drop cached session validations — all of them, one user's or one
@@ -45,16 +45,14 @@
45
45
  * Node only; reachable solely from `@oxy.so/core/server`.
46
46
  */
47
47
  Object.defineProperty(exports, "__esModule", { value: true });
48
- exports.OxyAppPermissionsResolver = exports.OxyAppPermissionError = exports.APP_PERMISSIONS_CACHE_TTL_MS = void 0;
48
+ exports.OxyAppPermissionsResolver = exports.appPermissionErrorBody = exports.OxyAppPermissionError = exports.APP_PERMISSIONS_CACHE_TTL_MS = void 0;
49
49
  exports.appPermissionErrorStatus = appPermissionErrorStatus;
50
- exports.appPermissionErrorBody = appPermissionErrorBody;
51
50
  exports.assertApplicationPermissionKey = assertApplicationPermissionKey;
52
51
  const contracts_1 = require("@oxy.so/contracts");
53
52
  const logger_1 = require("../logger");
53
+ const callerAuthority_1 = require("./callerAuthority");
54
54
  /** Default cache lifetime of one answer. */
55
55
  exports.APP_PERMISSIONS_CACHE_TTL_MS = 30000;
56
- /** Entries kept per server; the oldest goes first. */
57
- const APP_PERMISSIONS_CACHE_MAX = 1000;
58
56
  /** The typed failure of {@link OxyAppPermissionsResolver.resolve} and the middleware. */
59
57
  class OxyAppPermissionError extends Error {
60
58
  constructor(code, message, options = {}) {
@@ -80,20 +78,7 @@ function appPermissionErrorStatus(error, deny = 404) {
80
78
  }
81
79
  }
82
80
  /** The response body for a status: the API error envelope (`docs/api-conventions.md`). */
83
- function appPermissionErrorBody(status) {
84
- switch (status) {
85
- case 401:
86
- return { error: { code: 'unauthorized', message: 'Authentication required' } };
87
- case 403:
88
- return { error: { code: 'forbidden', message: 'Not permitted' } };
89
- case 404:
90
- return { error: { code: 'not_found', message: 'Not found' } };
91
- case 503:
92
- return { error: { code: 'service_unavailable', message: 'Temporarily unavailable' } };
93
- default:
94
- return { error: { code: 'internal_error', message: 'Internal error' } };
95
- }
96
- }
81
+ exports.appPermissionErrorBody = callerAuthority_1.authorityErrorBody;
97
82
  /** Throws at MOUNT time on a key that could never be held — a typo is a deploy error. */
98
83
  function assertApplicationPermissionKey(permission) {
99
84
  if (!(0, contracts_1.isApplicationPermissionKey)(permission)) {
@@ -101,18 +86,7 @@ function assertApplicationPermissionKey(permission) {
101
86
  }
102
87
  }
103
88
  function heldPermissions(applicationId, operatorId, permissions) {
104
- const sorted = Object.freeze([...new Set(permissions)].sort());
105
- const set = new Set(sorted);
106
- return Object.freeze({
107
- applicationId,
108
- operatorId,
109
- permissions: sorted,
110
- has: (permission) => set.has(permission),
111
- });
112
- }
113
- function errorStatus(error) {
114
- const status = error?.status;
115
- return typeof status === 'number' ? status : undefined;
89
+ return Object.freeze({ applicationId, operatorId, ...(0, callerAuthority_1.permissionSet)(permissions) });
116
90
  }
117
91
  /**
118
92
  * The cache and the lookup behind `OxyServer.appPermissionsFor` and
@@ -121,22 +95,18 @@ function errorStatus(error) {
121
95
  class OxyAppPermissionsResolver {
122
96
  constructor(host) {
123
97
  this.host = host;
124
- this.cache = new Map();
125
- this.pending = new Map();
98
+ this.cache = new callerAuthority_1.CallerAuthorityCache(host.ttlMs, (value, userId) => value.operatorId === userId);
126
99
  }
127
100
  /**
128
101
  * What the person behind `req` holds. Throws {@link OxyAppPermissionError};
129
102
  * never answers "nothing held" for a failure.
130
103
  */
131
104
  async resolve(req, options = {}) {
132
- if (req.serviceApp) {
133
- throw new OxyAppPermissionError('APP_PERMISSION_SESSION_REQUIRED', 'Application permissions are held by a person in a session; this request is authenticated by a service token');
134
- }
135
- const userId = typeof req.userId === 'string' && req.userId.length > 0 ? req.userId : null;
136
- const sessionId = typeof req.sessionId === 'string' && req.sessionId.length > 0 ? req.sessionId : null;
137
- const accessToken = typeof req.accessToken === 'string' && req.accessToken.length > 0 ? req.accessToken : null;
138
- if (!userId || !sessionId || !accessToken) {
139
- throw new OxyAppPermissionError('APP_PERMISSION_SESSION_REQUIRED', 'No verified user session on this request; mount middleware.auth() first');
105
+ const caller = (0, callerAuthority_1.readCallerSession)(req);
106
+ if (!caller.ok) {
107
+ throw new OxyAppPermissionError('APP_PERMISSION_SESSION_REQUIRED', caller.reason === 'service'
108
+ ? 'Application permissions are held by a person in a session; this request is authenticated by a service token'
109
+ : 'No verified user session on this request; mount middleware.auth() first');
140
110
  }
141
111
  let applicationId = options.applicationId ?? null;
142
112
  if (!applicationId) {
@@ -150,63 +120,15 @@ class OxyAppPermissionsResolver {
150
120
  if (!applicationId) {
151
121
  throw new OxyAppPermissionError('APP_PERMISSION_NOT_CONFIGURED', 'No application id: set OxyServerConfig.applicationId, configure service auth, or pass { applicationId }');
152
122
  }
153
- const key = JSON.stringify([applicationId, sessionId, userId]);
154
- const ttl = this.host.ttlMs;
155
- if (options.fresh !== true && ttl > 0) {
156
- const cached = this.cache.get(key);
157
- if (cached && cached.expiresAt > Date.now()) {
158
- return cached.value;
159
- }
160
- const inFlight = this.pending.get(key);
161
- if (inFlight)
162
- return inFlight;
163
- }
164
- const startedAt = Date.now();
165
- const lookup = this.lookup(applicationId, accessToken).then((value) => {
166
- if (ttl > 0)
167
- this.remember(key, value, startedAt, ttl);
168
- return value;
169
- });
170
- if (options.fresh === true || ttl <= 0)
171
- return lookup;
172
- this.pending.set(key, lookup);
173
- try {
174
- return await lookup;
175
- }
176
- finally {
177
- if (this.pending.get(key) === lookup)
178
- this.pending.delete(key);
179
- }
123
+ const appId = applicationId;
124
+ return this.cache.read([appId], caller.session, options.fresh === true, () => this.lookup(appId, caller.session.accessToken));
180
125
  }
181
126
  /**
182
127
  * Forget cached answers: every one, or those about one person (matched as
183
128
  * either the session's user or the resolved operator).
184
129
  */
185
130
  invalidate(filter = {}) {
186
- if (!filter.userId) {
187
- this.cache.clear();
188
- return;
189
- }
190
- for (const [key, entry] of this.cache) {
191
- const [, , userId] = JSON.parse(key);
192
- if (userId === filter.userId || entry.value.operatorId === filter.userId) {
193
- this.cache.delete(key);
194
- }
195
- }
196
- }
197
- remember(key, value, startedAt, ttl) {
198
- // A slower, OLDER read must not overwrite a newer one — the newer one may
199
- // be the fresh read that saw a revocation.
200
- const existing = this.cache.get(key);
201
- if (existing && existing.startedAt > startedAt)
202
- return;
203
- this.cache.delete(key);
204
- this.cache.set(key, { value, startedAt, expiresAt: startedAt + ttl });
205
- if (this.cache.size > APP_PERMISSIONS_CACHE_MAX) {
206
- const oldest = this.cache.keys().next().value;
207
- if (oldest !== undefined)
208
- this.cache.delete(oldest);
209
- }
131
+ this.cache.invalidate(filter);
210
132
  }
211
133
  async lookup(applicationId, accessToken) {
212
134
  let answer;
@@ -214,7 +136,7 @@ class OxyAppPermissionsResolver {
214
136
  answer = await this.host.fetchRolesMe(applicationId, accessToken);
215
137
  }
216
138
  catch (error) {
217
- const status = errorStatus(error);
139
+ const status = (0, callerAuthority_1.errorStatus)(error);
218
140
  if (status === 401) {
219
141
  throw new OxyAppPermissionError('APP_PERMISSION_SESSION_REQUIRED', 'Oxy refused the session while resolving application permissions', { cause: error });
220
142
  }
@@ -0,0 +1,170 @@
1
+ "use strict";
2
+ /**
3
+ * The person behind a request, in an Oxy account and over an Oxy application:
4
+ * `OxyServer.accountAccessFor` and `OxyServer.applicationAccessFor`.
5
+ *
6
+ * Both read Oxy with the request's OWN verified bearer (an `OxyUserClient`),
7
+ * so Oxy resolves the HUMAN operator from the session — a person acting as
8
+ * their organization is answered for themselves — with inheritance, per-member
9
+ * grants and revokes applied. Nothing else the client sends is consulted.
10
+ *
11
+ * ```ts
12
+ * // Can the caller update this store (an Oxy account) in my app?
13
+ * const access = await oxy.accountAccessFor(req, storeAccountId, { fresh: true });
14
+ * if (!access.has('account:update')) throw new OxyAccessError('ACCESS_DENIED', '…');
15
+ *
16
+ * // Can the caller manage webhooks of application A (developer RBAC)?
17
+ * const app = await oxy.applicationAccessFor(req, applicationId);
18
+ * if (!app.has('webhooks:update')) …
19
+ * ```
20
+ *
21
+ * Which reader answers which question is in `./callerAuthority`; the short
22
+ * version: `appPermissionsFor` is about THIS backend's own declared operator
23
+ * keys, `applicationAccessFor` about any application through the account that
24
+ * owns it, `accountAccessFor` about an account. For work with no person
25
+ * present (a background import), `oxy.accounts.accessFor(accountId, userId)`
26
+ * asks with the service's own token instead.
27
+ *
28
+ * Node only; reachable solely from `@oxy.so/core/server`.
29
+ */
30
+ Object.defineProperty(exports, "__esModule", { value: true });
31
+ exports.OxyCallerAccessResolver = void 0;
32
+ exports.parseAccountAccess = parseAccountAccess;
33
+ exports.parseApplicationAccess = parseApplicationAccess;
34
+ const contracts_1 = require("@oxy.so/contracts");
35
+ const callerAuthority_1 = require("./callerAuthority");
36
+ function callerSession(req) {
37
+ const caller = (0, callerAuthority_1.readCallerSession)(req);
38
+ if (caller.ok)
39
+ return caller.session;
40
+ throw new callerAuthority_1.OxyAccessError('ACCESS_SESSION_REQUIRED', caller.reason === 'service'
41
+ ? 'This request is authenticated by a service token; account and application access belong to a person in a session'
42
+ : 'No verified user session on this request; mount middleware.auth() first');
43
+ }
44
+ function requireId(id, what) {
45
+ if (typeof id !== 'string' || id.trim() === '') {
46
+ throw new TypeError(`${what} must be a non-empty string`);
47
+ }
48
+ return id;
49
+ }
50
+ function record(value) {
51
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
52
+ ? value
53
+ : null;
54
+ }
55
+ function stringArray(value) {
56
+ return Array.isArray(value) && value.every((entry) => typeof entry === 'string')
57
+ ? value
58
+ : null;
59
+ }
60
+ const RELATIONSHIPS = ['self', 'owner', 'member'];
61
+ const APPLICATION_TYPES = [
62
+ 'first_party',
63
+ 'third_party',
64
+ 'internal',
65
+ 'system',
66
+ ];
67
+ const OFFICIAL_TYPES = ['first_party', 'internal', 'system'];
68
+ /** `deleted` is absent on purpose: Oxy answers a deleted application 404. */
69
+ const APPLICATION_STATUSES = [
70
+ 'active',
71
+ 'suspended',
72
+ 'pending_review',
73
+ ];
74
+ /** `GET /accounts/:id` → the caller's effective access, or `null` for a malformed answer. */
75
+ function parseAccountAccess(accountId, answer) {
76
+ const node = record(answer);
77
+ if (!node || node.accountId !== accountId)
78
+ return null;
79
+ const relationship = node.relationship;
80
+ if (!RELATIONSHIPS.includes(relationship) || typeof node.kind !== 'string')
81
+ return null;
82
+ const grant = contracts_1.accountAccessGrantSchema.safeParse(node.callerAccess);
83
+ if (!grant.success)
84
+ return null;
85
+ return Object.freeze({
86
+ accountId,
87
+ kind: node.kind,
88
+ relationship,
89
+ role: grant.data.role,
90
+ source: grant.data.source,
91
+ ...(0, callerAuthority_1.permissionSet)(grant.data.permissions),
92
+ });
93
+ }
94
+ /** `GET /applications/:id` → the caller's access over it, or `null` for a malformed answer. */
95
+ function parseApplicationAccess(applicationId, answer) {
96
+ const application = record(answer);
97
+ if (!application || application._id !== applicationId)
98
+ return null;
99
+ const ownerAccountId = application.ownerAccountId;
100
+ const type = application.type;
101
+ const status = application.status;
102
+ const membership = record(application.callerMembership);
103
+ const permissions = stringArray(membership?.permissions);
104
+ if (typeof ownerAccountId !== 'string' ||
105
+ ownerAccountId === '' ||
106
+ typeof application.isOfficial !== 'boolean' ||
107
+ typeof application.isInternal !== 'boolean' ||
108
+ !APPLICATION_TYPES.includes(type) ||
109
+ !APPLICATION_STATUSES.includes(status) ||
110
+ !membership ||
111
+ typeof membership.role !== 'string' ||
112
+ membership.role === '' ||
113
+ membership.ownerAccountId !== ownerAccountId ||
114
+ !permissions) {
115
+ return null;
116
+ }
117
+ return Object.freeze({
118
+ applicationId,
119
+ ownerAccountId,
120
+ official: application.isOfficial || application.isInternal || OFFICIAL_TYPES.includes(type),
121
+ status,
122
+ role: membership.role,
123
+ ...(0, callerAuthority_1.permissionSet)(permissions),
124
+ });
125
+ }
126
+ /** The caches and lookups behind `accountAccessFor` / `applicationAccessFor`. One per server. */
127
+ class OxyCallerAccessResolver {
128
+ constructor(host) {
129
+ this.host = host;
130
+ this.accounts = new callerAuthority_1.CallerAuthorityCache(host.ttlMs);
131
+ this.applications = new callerAuthority_1.CallerAuthorityCache(host.ttlMs);
132
+ }
133
+ async account(req, accountId, options = {}) {
134
+ const id = requireId(accountId, 'accountId');
135
+ const session = callerSession(req);
136
+ return this.accounts.read([id], session, options.fresh === true, async () => {
137
+ const answer = await this.host
138
+ .readAccount(id, session.accessToken)
139
+ .catch((error) => {
140
+ throw (0, callerAuthority_1.accessFailure)(error, `account ${id}`);
141
+ });
142
+ const access = parseAccountAccess(id, answer);
143
+ if (!access) {
144
+ throw new callerAuthority_1.OxyAccessError('ACCESS_UNAVAILABLE', `Oxy answered account ${id} without the caller's access`);
145
+ }
146
+ return access;
147
+ });
148
+ }
149
+ async application(req, applicationId, options = {}) {
150
+ const id = requireId(applicationId, 'applicationId');
151
+ const session = callerSession(req);
152
+ return this.applications.read([id], session, options.fresh === true, async () => {
153
+ const answer = await this.host
154
+ .readApplication(id, session.accessToken)
155
+ .catch((error) => {
156
+ throw (0, callerAuthority_1.accessFailure)(error, `application ${id}`);
157
+ });
158
+ const access = parseApplicationAccess(id, answer);
159
+ if (!access) {
160
+ throw new callerAuthority_1.OxyAccessError('ACCESS_UNAVAILABLE', `Oxy answered application ${id} without the caller's access`);
161
+ }
162
+ return access;
163
+ });
164
+ }
165
+ invalidate(filter = {}) {
166
+ this.accounts.invalidate(filter);
167
+ this.applications.invalidate(filter);
168
+ }
169
+ }
170
+ exports.OxyCallerAccessResolver = OxyCallerAccessResolver;