@oxy.so/core 7.15.1 → 7.17.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 +151 -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 +145 -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 +86 -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 +5 -5
  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 +232 -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
@@ -46,11 +46,17 @@
46
46
 
47
47
  import { applicationRolesMeSchema, isApplicationPermissionKey } from '@oxy.so/contracts';
48
48
  import { logger } from '../logger';
49
+ import {
50
+ authorityErrorBody,
51
+ CallerAuthorityCache,
52
+ errorStatus,
53
+ permissionSet,
54
+ readCallerSession,
55
+ type OxyCallerRequest,
56
+ } from './callerAuthority';
49
57
 
50
58
  /** Default cache lifetime of one answer. */
51
59
  export const APP_PERMISSIONS_CACHE_TTL_MS = 30_000;
52
- /** Entries kept per server; the oldest goes first. */
53
- const APP_PERMISSIONS_CACHE_MAX = 1000;
54
60
 
55
61
  /** What a refused request is answered with. */
56
62
  export type OxyAppPermissionDenyStatus = 403 | 404;
@@ -130,11 +136,7 @@ export interface RequireAppPermissionOptions extends AppPermissionsOptions {
130
136
  }
131
137
 
132
138
  /** The request fields the resolver reads — all set by `middleware.auth()`. */
133
- export interface AppPermissionsRequest {
134
- userId?: string | null;
135
- sessionId?: string | null;
136
- accessToken?: string;
137
- serviceApp?: unknown;
139
+ export interface AppPermissionsRequest extends OxyCallerRequest {
138
140
  oxyAppPermissions?: OxyAppPermissions;
139
141
  }
140
142
 
@@ -156,22 +158,7 @@ export function appPermissionErrorStatus(
156
158
  }
157
159
 
158
160
  /** The response body for a status: the API error envelope (`docs/api-conventions.md`). */
159
- export function appPermissionErrorBody(status: number): {
160
- error: { code: string; message: string };
161
- } {
162
- switch (status) {
163
- case 401:
164
- return { error: { code: 'unauthorized', message: 'Authentication required' } };
165
- case 403:
166
- return { error: { code: 'forbidden', message: 'Not permitted' } };
167
- case 404:
168
- return { error: { code: 'not_found', message: 'Not found' } };
169
- case 503:
170
- return { error: { code: 'service_unavailable', message: 'Temporarily unavailable' } };
171
- default:
172
- return { error: { code: 'internal_error', message: 'Internal error' } };
173
- }
174
- }
161
+ export const appPermissionErrorBody = authorityErrorBody;
175
162
 
176
163
  /** Throws at MOUNT time on a key that could never be held — a typo is a deploy error. */
177
164
  export function assertApplicationPermissionKey(permission: unknown): asserts permission is string {
@@ -187,20 +174,7 @@ function heldPermissions(
187
174
  operatorId: string,
188
175
  permissions: readonly string[],
189
176
  ): OxyAppPermissions {
190
- const sorted = Object.freeze([...new Set(permissions)].sort());
191
- const set = new Set(sorted);
192
- return Object.freeze({
193
- applicationId,
194
- operatorId,
195
- permissions: sorted,
196
- has: (permission: string) => set.has(permission),
197
- });
198
- }
199
-
200
- interface CacheEntry {
201
- value: OxyAppPermissions;
202
- startedAt: number;
203
- expiresAt: number;
177
+ return Object.freeze({ applicationId, operatorId, ...permissionSet(permissions) });
204
178
  }
205
179
 
206
180
  /** What the resolver needs from the server it belongs to. */
@@ -212,20 +186,19 @@ export interface AppPermissionsHost {
212
186
  readonly ttlMs: number;
213
187
  }
214
188
 
215
- function errorStatus(error: unknown): number | undefined {
216
- const status = (error as { status?: unknown } | null)?.status;
217
- return typeof status === 'number' ? status : undefined;
218
- }
219
-
220
189
  /**
221
190
  * The cache and the lookup behind `OxyServer.appPermissionsFor` and
222
191
  * `middleware.requireAppPermission`. One per server.
223
192
  */
224
193
  export class OxyAppPermissionsResolver {
225
- private readonly cache = new Map<string, CacheEntry>();
226
- private readonly pending = new Map<string, Promise<OxyAppPermissions>>();
194
+ private readonly cache: CallerAuthorityCache<OxyAppPermissions>;
227
195
 
228
- constructor(private readonly host: AppPermissionsHost) {}
196
+ constructor(private readonly host: AppPermissionsHost) {
197
+ this.cache = new CallerAuthorityCache<OxyAppPermissions>(
198
+ host.ttlMs,
199
+ (value, userId) => value.operatorId === userId,
200
+ );
201
+ }
229
202
 
230
203
  /**
231
204
  * What the person behind `req` holds. Throws {@link OxyAppPermissionError};
@@ -235,21 +208,13 @@ export class OxyAppPermissionsResolver {
235
208
  req: AppPermissionsRequest,
236
209
  options: AppPermissionsOptions = {},
237
210
  ): Promise<OxyAppPermissions> {
238
- if (req.serviceApp) {
239
- throw new OxyAppPermissionError(
240
- 'APP_PERMISSION_SESSION_REQUIRED',
241
- 'Application permissions are held by a person in a session; this request is authenticated by a service token',
242
- );
243
- }
244
- const userId = typeof req.userId === 'string' && req.userId.length > 0 ? req.userId : null;
245
- const sessionId =
246
- typeof req.sessionId === 'string' && req.sessionId.length > 0 ? req.sessionId : null;
247
- const accessToken =
248
- typeof req.accessToken === 'string' && req.accessToken.length > 0 ? req.accessToken : null;
249
- if (!userId || !sessionId || !accessToken) {
211
+ const caller = readCallerSession(req);
212
+ if (!caller.ok) {
250
213
  throw new OxyAppPermissionError(
251
214
  'APP_PERMISSION_SESSION_REQUIRED',
252
- 'No verified user session on this request; mount middleware.auth() first',
215
+ caller.reason === 'service'
216
+ ? 'Application permissions are held by a person in a session; this request is authenticated by a service token'
217
+ : 'No verified user session on this request; mount middleware.auth() first',
253
218
  );
254
219
  }
255
220
 
@@ -272,30 +237,10 @@ export class OxyAppPermissionsResolver {
272
237
  );
273
238
  }
274
239
 
275
- const key = JSON.stringify([applicationId, sessionId, userId]);
276
- const ttl = this.host.ttlMs;
277
- if (options.fresh !== true && ttl > 0) {
278
- const cached = this.cache.get(key);
279
- if (cached && cached.expiresAt > Date.now()) {
280
- return cached.value;
281
- }
282
- const inFlight = this.pending.get(key);
283
- if (inFlight) return inFlight;
284
- }
285
-
286
- const startedAt = Date.now();
287
- const lookup = this.lookup(applicationId, accessToken).then((value) => {
288
- if (ttl > 0) this.remember(key, value, startedAt, ttl);
289
- return value;
290
- });
291
- if (options.fresh === true || ttl <= 0) return lookup;
292
-
293
- this.pending.set(key, lookup);
294
- try {
295
- return await lookup;
296
- } finally {
297
- if (this.pending.get(key) === lookup) this.pending.delete(key);
298
- }
240
+ const appId = applicationId;
241
+ return this.cache.read([appId], caller.session, options.fresh === true, () =>
242
+ this.lookup(appId, caller.session.accessToken),
243
+ );
299
244
  }
300
245
 
301
246
  /**
@@ -303,29 +248,7 @@ export class OxyAppPermissionsResolver {
303
248
  * either the session's user or the resolved operator).
304
249
  */
305
250
  invalidate(filter: { userId?: string } = {}): void {
306
- if (!filter.userId) {
307
- this.cache.clear();
308
- return;
309
- }
310
- for (const [key, entry] of this.cache) {
311
- const [, , userId] = JSON.parse(key) as [string, string, string];
312
- if (userId === filter.userId || entry.value.operatorId === filter.userId) {
313
- this.cache.delete(key);
314
- }
315
- }
316
- }
317
-
318
- private remember(key: string, value: OxyAppPermissions, startedAt: number, ttl: number): void {
319
- // A slower, OLDER read must not overwrite a newer one — the newer one may
320
- // be the fresh read that saw a revocation.
321
- const existing = this.cache.get(key);
322
- if (existing && existing.startedAt > startedAt) return;
323
- this.cache.delete(key);
324
- this.cache.set(key, { value, startedAt, expiresAt: startedAt + ttl });
325
- if (this.cache.size > APP_PERMISSIONS_CACHE_MAX) {
326
- const oldest = this.cache.keys().next().value;
327
- if (oldest !== undefined) this.cache.delete(oldest);
328
- }
251
+ this.cache.invalidate(filter);
329
252
  }
330
253
 
331
254
  private async lookup(applicationId: string, accessToken: string): Promise<OxyAppPermissions> {
@@ -7,6 +7,7 @@ import type {
7
7
  } from './middleware';
8
8
  import type { AccountActorChain } from '@oxy.so/contracts';
9
9
  import type { OxyAppPermissions } from './appPermissions';
10
+ import type { OxyAccountAccess, OxyApplicationAccess } from './callerAccess';
10
11
  import { logger } from '../logger';
11
12
  import {
12
13
  OXY_SERVICE_ENVIRONMENTS,
@@ -56,6 +57,10 @@ export interface OxyAuthRequest extends Request {
56
57
  * request. Its `operatorId` is the human to record as the actor.
57
58
  */
58
59
  oxyAppPermissions?: OxyAppPermissions;
60
+ /** Set by `middleware.requireAccountAccess` once it admits the request. */
61
+ oxyAccountAccess?: OxyAccountAccess;
62
+ /** Set by `middleware.requireApplicationAccess` once it admits the request. */
63
+ oxyApplicationAccess?: OxyApplicationAccess;
59
64
  }
60
65
 
61
66
  export interface OxyAuthenticatedRequest extends OxyAuthRequest {
@@ -0,0 +1,232 @@
1
+ /**
2
+ * The person behind a request, in an Oxy account and over an Oxy application:
3
+ * `OxyServer.accountAccessFor` and `OxyServer.applicationAccessFor`.
4
+ *
5
+ * Both read Oxy with the request's OWN verified bearer (an `OxyUserClient`),
6
+ * so Oxy resolves the HUMAN operator from the session — a person acting as
7
+ * their organization is answered for themselves — with inheritance, per-member
8
+ * grants and revokes applied. Nothing else the client sends is consulted.
9
+ *
10
+ * ```ts
11
+ * // Can the caller update this store (an Oxy account) in my app?
12
+ * const access = await oxy.accountAccessFor(req, storeAccountId, { fresh: true });
13
+ * if (!access.has('account:update')) throw new OxyAccessError('ACCESS_DENIED', '…');
14
+ *
15
+ * // Can the caller manage webhooks of application A (developer RBAC)?
16
+ * const app = await oxy.applicationAccessFor(req, applicationId);
17
+ * if (!app.has('webhooks:update')) …
18
+ * ```
19
+ *
20
+ * Which reader answers which question is in `./callerAuthority`; the short
21
+ * version: `appPermissionsFor` is about THIS backend's own declared operator
22
+ * keys, `applicationAccessFor` about any application through the account that
23
+ * owns it, `accountAccessFor` about an account. For work with no person
24
+ * present (a background import), `oxy.accounts.accessFor(accountId, userId)`
25
+ * asks with the service's own token instead.
26
+ *
27
+ * Node only; reachable solely from `@oxy.so/core/server`.
28
+ */
29
+
30
+ import {
31
+ accountAccessGrantSchema,
32
+ type AccountAccessSource,
33
+ type AccountKind,
34
+ } from '@oxy.so/contracts';
35
+ import type { AccountRelationship } from '../api/accounts';
36
+ import {
37
+ accessFailure,
38
+ CallerAuthorityCache,
39
+ OxyAccessError,
40
+ permissionSet,
41
+ readCallerSession,
42
+ type OxyCallerRequest,
43
+ type OxyCallerSession,
44
+ } from './callerAuthority';
45
+
46
+ /** What the person behind a request may do in one Oxy account. */
47
+ export interface OxyAccountAccess {
48
+ readonly accountId: string;
49
+ readonly kind: AccountKind;
50
+ /** `self` for their own personal account, `owner`, or `member` (direct or inherited). */
51
+ readonly relationship: AccountRelationship;
52
+ /** Informational; gate on `permissions` / `has`, never on the role name. */
53
+ readonly role: string;
54
+ /** Where the access comes from: `self`, a `direct` membership, or one `inherited` from an ancestor. */
55
+ readonly source: AccountAccessSource;
56
+ /** The EFFECTIVE account permissions (role baseline, grants, revokes), sorted. */
57
+ readonly permissions: readonly string[];
58
+ has(permission: string): boolean;
59
+ }
60
+
61
+ /** What the person behind a request may do to one Oxy application, through its owning account. */
62
+ export interface OxyApplicationAccess {
63
+ readonly applicationId: string;
64
+ /** The account that owns the application; the access derives from the caller's membership there. */
65
+ readonly ownerAccountId: string;
66
+ /** Whether the application is an official Oxy application. */
67
+ readonly official: boolean;
68
+ /** Informational; gate on `permissions` / `has`, never on the role name. */
69
+ readonly role: string;
70
+ /** The application permissions (`app:read`, `app:update`, `webhooks:update`, …), sorted. */
71
+ readonly permissions: readonly string[];
72
+ has(permission: string): boolean;
73
+ }
74
+
75
+ export interface CallerAccessOptions {
76
+ /** Skip the cache and ask Oxy (the answer still refreshes the cache). Use before a write. */
77
+ fresh?: boolean;
78
+ }
79
+
80
+ /** What the readers need from the server they belong to. */
81
+ export interface CallerAccessHost {
82
+ /** `GET /accounts/:id` with this bearer, uncached. Rejects with the API error. */
83
+ readAccount(accountId: string, accessToken: string): Promise<unknown>;
84
+ /** `GET /applications/:id` with this bearer, uncached. Rejects with the API error. */
85
+ readApplication(applicationId: string, accessToken: string): Promise<unknown>;
86
+ readonly ttlMs: number;
87
+ }
88
+
89
+ function callerSession(req: OxyCallerRequest): OxyCallerSession {
90
+ const caller = readCallerSession(req);
91
+ if (caller.ok) return caller.session;
92
+ throw new OxyAccessError(
93
+ 'ACCESS_SESSION_REQUIRED',
94
+ caller.reason === 'service'
95
+ ? 'This request is authenticated by a service token; account and application access belong to a person in a session'
96
+ : 'No verified user session on this request; mount middleware.auth() first',
97
+ );
98
+ }
99
+
100
+ function requireId(id: unknown, what: string): string {
101
+ if (typeof id !== 'string' || id.trim() === '') {
102
+ throw new TypeError(`${what} must be a non-empty string`);
103
+ }
104
+ return id;
105
+ }
106
+
107
+ function record(value: unknown): Record<string, unknown> | null {
108
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
109
+ ? (value as Record<string, unknown>)
110
+ : null;
111
+ }
112
+
113
+ function stringArray(value: unknown): string[] | null {
114
+ return Array.isArray(value) && value.every((entry) => typeof entry === 'string')
115
+ ? (value as string[])
116
+ : null;
117
+ }
118
+
119
+ const RELATIONSHIPS: readonly AccountRelationship[] = ['self', 'owner', 'member'];
120
+
121
+ /** `GET /accounts/:id` → the caller's effective access, or `null` for a malformed answer. */
122
+ export function parseAccountAccess(accountId: string, answer: unknown): OxyAccountAccess | null {
123
+ const node = record(answer);
124
+ if (!node || node.accountId !== accountId) return null;
125
+ const relationship = node.relationship as AccountRelationship;
126
+ if (!RELATIONSHIPS.includes(relationship) || typeof node.kind !== 'string') return null;
127
+ const grant = accountAccessGrantSchema.safeParse(node.callerAccess);
128
+ if (!grant.success) return null;
129
+ return Object.freeze({
130
+ accountId,
131
+ kind: node.kind as AccountKind,
132
+ relationship,
133
+ role: grant.data.role,
134
+ source: grant.data.source,
135
+ ...permissionSet(grant.data.permissions),
136
+ });
137
+ }
138
+
139
+ /** `GET /applications/:id` → the caller's access over it, or `null` for a malformed answer. */
140
+ export function parseApplicationAccess(
141
+ applicationId: string,
142
+ answer: unknown,
143
+ ): OxyApplicationAccess | null {
144
+ const application = record(answer);
145
+ if (!application || application._id !== applicationId) return null;
146
+ const ownerAccountId = application.ownerAccountId;
147
+ const membership = record(application.callerMembership);
148
+ const permissions = stringArray(membership?.permissions);
149
+ if (
150
+ typeof ownerAccountId !== 'string' ||
151
+ ownerAccountId === '' ||
152
+ typeof application.isOfficial !== 'boolean' ||
153
+ !membership ||
154
+ typeof membership.role !== 'string' ||
155
+ membership.role === '' ||
156
+ membership.ownerAccountId !== ownerAccountId ||
157
+ !permissions
158
+ ) {
159
+ return null;
160
+ }
161
+ return Object.freeze({
162
+ applicationId,
163
+ ownerAccountId,
164
+ official: application.isOfficial,
165
+ role: membership.role,
166
+ ...permissionSet(permissions),
167
+ });
168
+ }
169
+
170
+ /** The caches and lookups behind `accountAccessFor` / `applicationAccessFor`. One per server. */
171
+ export class OxyCallerAccessResolver {
172
+ private readonly accounts: CallerAuthorityCache<OxyAccountAccess>;
173
+ private readonly applications: CallerAuthorityCache<OxyApplicationAccess>;
174
+
175
+ constructor(private readonly host: CallerAccessHost) {
176
+ this.accounts = new CallerAuthorityCache<OxyAccountAccess>(host.ttlMs);
177
+ this.applications = new CallerAuthorityCache<OxyApplicationAccess>(host.ttlMs);
178
+ }
179
+
180
+ async account(
181
+ req: OxyCallerRequest,
182
+ accountId: string,
183
+ options: CallerAccessOptions = {},
184
+ ): Promise<OxyAccountAccess> {
185
+ const id = requireId(accountId, 'accountId');
186
+ const session = callerSession(req);
187
+ return this.accounts.read([id], session, options.fresh === true, async () => {
188
+ const answer = await this.host
189
+ .readAccount(id, session.accessToken)
190
+ .catch((error: unknown) => {
191
+ throw accessFailure(error, `account ${id}`);
192
+ });
193
+ const access = parseAccountAccess(id, answer);
194
+ if (!access) {
195
+ throw new OxyAccessError(
196
+ 'ACCESS_UNAVAILABLE',
197
+ `Oxy answered account ${id} without the caller's access`,
198
+ );
199
+ }
200
+ return access;
201
+ });
202
+ }
203
+
204
+ async application(
205
+ req: OxyCallerRequest,
206
+ applicationId: string,
207
+ options: CallerAccessOptions = {},
208
+ ): Promise<OxyApplicationAccess> {
209
+ const id = requireId(applicationId, 'applicationId');
210
+ const session = callerSession(req);
211
+ return this.applications.read([id], session, options.fresh === true, async () => {
212
+ const answer = await this.host
213
+ .readApplication(id, session.accessToken)
214
+ .catch((error: unknown) => {
215
+ throw accessFailure(error, `application ${id}`);
216
+ });
217
+ const access = parseApplicationAccess(id, answer);
218
+ if (!access) {
219
+ throw new OxyAccessError(
220
+ 'ACCESS_UNAVAILABLE',
221
+ `Oxy answered application ${id} without the caller's access`,
222
+ );
223
+ }
224
+ return access;
225
+ });
226
+ }
227
+
228
+ invalidate(filter: { userId?: string } = {}): void {
229
+ this.accounts.invalidate(filter);
230
+ this.applications.invalidate(filter);
231
+ }
232
+ }