@oxy.so/core 7.16.0 → 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.
- package/README.md +24 -13
- package/dist/cjs/server/OxyServer.js +85 -3
- package/dist/cjs/server/appPermissions.js +14 -92
- package/dist/cjs/server/callerAccess.js +151 -0
- package/dist/cjs/server/callerAuthority.js +206 -0
- package/dist/cjs/server/capabilityTicket.js +39 -6
- package/dist/cjs/server/cors.js +41 -2
- package/dist/cjs/server/index.js +33 -5
- package/dist/cjs/server/liveCapabilityTicket.js +4 -2
- package/dist/cjs/server/middleware.js +104 -2
- package/dist/cjs/server/namespaces.js +15 -4
- package/dist/cjs/server/safeFetch.js +29 -1
- package/dist/cjs/server/userClient.js +127 -0
- package/dist/cjs/utils/cache.js +58 -22
- package/dist/esm/server/OxyServer.js +85 -3
- package/dist/esm/server/appPermissions.js +12 -89
- package/dist/esm/server/callerAccess.js +145 -0
- package/dist/esm/server/callerAuthority.js +194 -0
- package/dist/esm/server/capabilityTicket.js +38 -6
- package/dist/esm/server/cors.js +39 -2
- package/dist/esm/server/index.js +26 -3
- package/dist/esm/server/liveCapabilityTicket.js +5 -3
- package/dist/esm/server/middleware.js +104 -2
- package/dist/esm/server/namespaces.js +15 -4
- package/dist/esm/server/safeFetch.js +29 -1
- package/dist/esm/server/userClient.js +121 -0
- package/dist/esm/utils/cache.js +57 -22
- package/dist/types/api/accounts.d.ts +8 -1
- package/dist/types/api/apps.d.ts +22 -4
- package/dist/types/index.d.ts +1 -1
- package/dist/types/server/OxyServer.d.ts +74 -7
- package/dist/types/server/appPermissions.d.ts +3 -13
- package/dist/types/server/auth.d.ts +5 -0
- package/dist/types/server/callerAccess.d.ts +86 -0
- package/dist/types/server/callerAuthority.d.ts +127 -0
- package/dist/types/server/capabilityTicket.d.ts +23 -1
- package/dist/types/server/cors.d.ts +39 -0
- package/dist/types/server/index.d.ts +31 -7
- package/dist/types/server/liveCapabilityTicket.d.ts +6 -2
- package/dist/types/server/middleware.d.ts +34 -2
- package/dist/types/server/namespaces.d.ts +18 -2
- package/dist/types/server/safeFetch.d.ts +10 -0
- package/dist/types/server/userClient.d.ts +71 -0
- package/dist/types/utils/cache.d.ts +13 -0
- package/package.json +1 -1
- package/src/api/accounts.ts +8 -1
- package/src/api/apps.ts +23 -4
- package/src/index.ts +1 -0
- package/src/server/OxyServer.ts +126 -7
- package/src/server/appPermissions.ts +28 -105
- package/src/server/auth.ts +5 -0
- package/src/server/callerAccess.ts +232 -0
- package/src/server/callerAuthority.ts +267 -0
- package/src/server/capabilityTicket.ts +76 -17
- package/src/server/cors.ts +63 -2
- package/src/server/index.ts +38 -4
- package/src/server/liveCapabilityTicket.ts +12 -5
- package/src/server/middleware.ts +171 -4
- package/src/server/namespaces.ts +36 -5
- package/src/server/safeFetch.ts +31 -1
- package/src/server/userClient.ts +147 -0
- 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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
239
|
-
|
|
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
|
-
|
|
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
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
|
|
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> {
|
package/src/server/auth.ts
CHANGED
|
@@ -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
|
+
}
|