@assembly-js/app-foundation 0.2.3 → 0.2.5
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/CHANGELOG.md +9 -1
- package/bin/client-safe-sdk-methods.mjs +30 -20
- package/bin/foundation-exports.mjs +6 -6
- package/bin/scan-client-calls.mjs +66 -8
- package/dist/assembly.d.ts +17 -0
- package/dist/assembly.js +53 -19
- package/dist/codegen.d.ts +14 -0
- package/dist/codegen.js +178 -0
- package/package.json +8 -2
package/CHANGELOG.md
CHANGED
|
@@ -15,7 +15,15 @@ from every bullet except `[guidance]`, agent work from the check scripts those `
|
|
|
15
15
|
bullets ship. Write `[fix]`, `[behavior]` and `[action]` bullets for the app's owner.
|
|
16
16
|
|
|
17
17
|
A patch release never changes what an app's build requires; a minor release may add or change a
|
|
18
|
-
`bin/check-*.mjs` script.
|
|
18
|
+
`bin/check-*.mjs` script. Sections are written by the release workflow from the files in `changes/`,
|
|
19
|
+
not by hand; see `changes/README.md`. CI fails a PR that changes a check script without a
|
|
20
|
+
`bump: minor` change file.
|
|
21
|
+
|
|
22
|
+
## 0.2.5
|
|
23
|
+
- [new] The client-call scan no longer reports reading the session's token details, a client's allowed fetch routes, or a harmless local namespace import as breaking; nothing in the app changes.
|
|
24
|
+
|
|
25
|
+
## 0.2.4
|
|
26
|
+
- [new] Add `@assembly-js/app-foundation/codegen`, which turns another app's API description into a typed client the app can call by verb and path, and `appProxyOptions` in `/server`, which that client uses to reach the other app through the platform. Nothing in the app changes until a client is generated for it.
|
|
19
27
|
|
|
20
28
|
## 0.2.3
|
|
21
29
|
- [new] The client-call scan now treats a client's own notification reads (list, mark read, mark unread) as safe, matching the routes the platform opens to clients. Nothing in the app changes.
|
|
@@ -1,28 +1,38 @@
|
|
|
1
|
-
// The routes a client bearer may keep calling once clients stop acting as admin
|
|
2
|
-
// as SDK method names (the SDK is flat: `retrieveClient`, not `clients.retrieve`).
|
|
1
|
+
// The routes a client bearer may keep calling once clients stop acting as admin.
|
|
3
2
|
// Mirrors every route the platform marks with ClientAccessOpen, ClientAccessSelf or
|
|
4
3
|
// ClientAccessGuarded under backend/platform-api; scan-client-calls.test.mjs fails when
|
|
5
|
-
// the marker count drifts from this list
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// GET /v1/clients/{id} -> retrieveClient (the client's own record only)
|
|
9
|
-
// GET /v1/notifications -> listNotifications
|
|
10
|
-
// POST /v1/notifications/{id}/read -> markNotificationRead
|
|
11
|
-
// POST /v1/notifications/{id}/unread -> markNotificationUnread
|
|
12
|
-
// GET /v1/session/flags -> no SDK method yet, so nothing to list; add it
|
|
13
|
-
// here when the SDK gains one.
|
|
4
|
+
// the marker count drifts from this table. The SDK method list, the count of routes with
|
|
5
|
+
// no SDK method and the `fetch` route check are all derived from the one table, so they
|
|
6
|
+
// cannot disagree. The SDK is flat: `retrieveClient`, not `clients.retrieve`.
|
|
14
7
|
//
|
|
8
|
+
// A route with no SDK method yet (`sdkMethod: null`) is reachable only through `fetch`;
|
|
9
|
+
// give it a method here when the SDK gains one.
|
|
10
|
+
export const CLIENT_SAFE_ROUTES = Object.freeze([
|
|
11
|
+
{ method: 'GET', path: '/v1/clients/{id}', sdkMethod: 'retrieveClient' }, // the client's own record only
|
|
12
|
+
{ method: 'GET', path: '/v1/me', sdkMethod: 'retrieveMe' },
|
|
13
|
+
{ method: 'GET', path: '/v1/notifications', sdkMethod: 'listNotifications' },
|
|
14
|
+
{ method: 'POST', path: '/v1/notifications/{id}/read', sdkMethod: 'markNotificationRead' },
|
|
15
|
+
{ method: 'POST', path: '/v1/notifications/{id}/unread', sdkMethod: 'markNotificationUnread' },
|
|
16
|
+
{ method: 'GET', path: '/v1/session/flags', sdkMethod: null },
|
|
17
|
+
].map(Object.freeze));
|
|
18
|
+
|
|
15
19
|
// retrieveClient is allowlisted by name: whether the id is the viewer's own is a runtime
|
|
16
20
|
// fact a static scan cannot see. The notification routes are open to clients, and the
|
|
17
21
|
// platform pins a client caller to their own notifications. Creating, retrieving and
|
|
18
22
|
// deleting a notification stay off the list: those routes are not open to clients.
|
|
19
|
-
export const CLIENT_SAFE_SDK_METHODS = Object.freeze(
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
export const CLIENT_SAFE_SDK_METHODS = Object.freeze(
|
|
24
|
+
CLIENT_SAFE_ROUTES.flatMap((r) => (r.sdkMethod ? [r.sdkMethod] : [])),
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
// Marked client-safe routes that no SDK method reaches yet.
|
|
28
|
+
export const CLIENT_SAFE_ROUTES_WITHOUT_SDK_METHOD = CLIENT_SAFE_ROUTES.filter((r) => !r.sdkMethod).length;
|
|
29
|
+
|
|
30
|
+
// `{id}` stands for exactly one non-empty path segment.
|
|
31
|
+
// The id segment excludes `.` and `%` so `.`, `..` and `%2e` forms that URL normalisation resolves elsewhere fail closed.
|
|
32
|
+
const routePattern = (route) =>
|
|
33
|
+
new RegExp(`^${route.path.split('/').map((seg) => (seg === '{id}' ? '[A-Za-z0-9_-]+' : seg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))).join('/')}$`);
|
|
34
|
+
const ROUTE_MATCHERS = CLIENT_SAFE_ROUTES.map((route) => ({ method: route.method, pattern: routePattern(route) }));
|
|
26
35
|
|
|
27
|
-
//
|
|
28
|
-
export const
|
|
36
|
+
// Whether `fetch(path, { method })` hits a client-safe route; `path` has no query string.
|
|
37
|
+
export const isClientSafeRoute = (method, path) =>
|
|
38
|
+
ROUTE_MATCHERS.some((m) => m.method === method.toUpperCase() && m.pattern.test(path));
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Every name `@assembly-js/app-foundation/server` and `/
|
|
1
|
+
// Every name `@assembly-js/app-foundation/server`, `/client` and `/codegen` export that is NOT a way to
|
|
2
2
|
// obtain an Assembly client or request context for the person using the app. The scan
|
|
3
3
|
// follows viewer clients only from the sources it knows (SDK_FACTORIES, afterAssembly,
|
|
4
4
|
// getRequestContext in scan-client-calls.mjs); an import from those entry points that is in
|
|
@@ -18,15 +18,15 @@
|
|
|
18
18
|
// renews the bearer cookie; it never hands out a client.
|
|
19
19
|
export const NON_CLIENT_EXPORTS = new Set([
|
|
20
20
|
'APP_CSRF_HEADER', 'AUTH_UNAVAILABLE_CODE', 'ActionContext', 'ActionError',
|
|
21
|
-
'ActionErrorStatus', 'AirtableFieldSchema', 'AirtableFormattedField',
|
|
21
|
+
'ActionErrorStatus', 'AirtableFieldSchema', 'AppProxyCaller', 'AppProxyOptions', 'AirtableFormattedField',
|
|
22
22
|
'AirtableFormattedRecord', 'AirtableRecord', 'AirtableSchemaResult', 'AirtableTableSchema',
|
|
23
23
|
'AssemblyProxyForwardContext', 'AssemblyProxyOptions', 'AuthUnavailableError',
|
|
24
24
|
'BEARER_COOKIE_NAME', 'BEARER_HEADER_NAME', 'BEARER_IN_BODY_HEADER', 'BEARER_TTL_MS',
|
|
25
25
|
'CLIENT_BEARER_HEADER', 'CLIENT_FLAGS_COOKIE_NAME', 'COOKIE_OUTCOME_EVENT',
|
|
26
26
|
'COOKIE_PROBE_NAME', 'Caller', 'ClearClientNotificationsInput', 'ClientAccessScope',
|
|
27
|
-
'ConnectionStatus', 'ConnectorError', 'ConnectorErrorCode', 'CookieOutcome', 'CronHandler',
|
|
27
|
+
'ConnectionStatus', 'ConnectorError', 'ContractDocumentError', 'ConnectorErrorCode', 'CookieOutcome', 'CronHandler',
|
|
28
28
|
'DEFAULT_LABELS', 'EXCHANGE_REJECTION_HEADER', 'ExchangeRejectedViewer', 'ExchangedSession',
|
|
29
|
-
'FormatAirtableRecordsOpts', 'GmailSendArgs', 'GmailSendResult', 'IntegrationClient',
|
|
29
|
+
'FormatAirtableRecordsOpts', 'GenerateAppClientInput', 'GeneratedAppClient', 'GmailSendArgs', 'GmailSendResult', 'IntegrationClient',
|
|
30
30
|
'InternalUserAccess', 'LEGACY_APP_IDS', 'MintFailure', 'MintOutcome', 'MintSessionOptions', 'MintedAppBearer',
|
|
31
31
|
'MintedBearer', 'NETWORK_CAPTURE_MESSAGE_TYPE', 'NetworkCaptureHeaderFingerprint',
|
|
32
32
|
'NetworkCaptureRecord', 'PAINTED_BEACON_EVENT', 'PAINTED_BEACON_PATH',
|
|
@@ -37,13 +37,13 @@ export const NON_CLIENT_EXPORTS = new Set([
|
|
|
37
37
|
'SlackPostMessageResult', 'TOKEN_EXPIRED_HEADER', 'TokenInvalidError', 'TokenPayload',
|
|
38
38
|
'TtlCache', 'VERIFY_OUTCOME_EVENT', 'VERIFY_PATH', 'VIEWER_BEARER_HEADER',
|
|
39
39
|
'VerifiedIdentity', 'VerifyResult', 'Viewer', 'WorkspaceLabels', 'authUnavailableResponse',
|
|
40
|
-
'automationActionRoute', 'badRequest', 'bootstrapPortalOrigin', 'buildMimeMessage',
|
|
40
|
+
'appProxyOptions', 'automationActionRoute', 'badRequest', 'bootstrapPortalOrigin', 'buildMimeMessage',
|
|
41
41
|
'canAccessClient', 'canAccessCompanies', 'classifyExchangeFailure',
|
|
42
42
|
'clearClientNotifications', 'collectHeaders', 'collectXhrResponseHeaders',
|
|
43
43
|
'cookielessReexchangeHeaders', 'cookiesAreBlocked', 'createAssemblyProxy',
|
|
44
44
|
'createAssemblyReexchangeHandler', 'cronRoute',
|
|
45
45
|
'decodeBearer', 'expandClientsByCompany', 'extractPdfText', 'fetchSessionFlags',
|
|
46
|
-
'findResource', 'formatAirtableRecords', 'getAirtableTableSchema', 'getCapturedRecords',
|
|
46
|
+
'findResource', 'formatAirtableRecords', 'generateAppClient', 'getAirtableTableSchema', 'getCapturedRecords',
|
|
47
47
|
'getClientAccessScope', 'getCookielessBearer', 'getDownloadUrl', 'getInstalledAppIds', 'getIntegration',
|
|
48
48
|
'getInternalUserAccess', 'getLabels', 'getSessionIdentity', 'getUploadUrl',
|
|
49
49
|
'getVerifiedIdentity', 'gmailSendEmail', 'handlePaintedBeacon', 'handleRouteError',
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
import { readdirSync, readFileSync } from 'node:fs';
|
|
24
24
|
import { createRequire } from 'node:module';
|
|
25
25
|
import path from 'node:path';
|
|
26
|
-
import { CLIENT_SAFE_SDK_METHODS } from './client-safe-sdk-methods.mjs';
|
|
26
|
+
import { CLIENT_SAFE_SDK_METHODS, isClientSafeRoute } from './client-safe-sdk-methods.mjs';
|
|
27
27
|
import { NON_CLIENT_EXPORTS } from './foundation-exports.mjs';
|
|
28
28
|
import {
|
|
29
29
|
EXCLUDING_WRAPPERS,
|
|
@@ -94,6 +94,11 @@ export const SDK_METHOD_SHAPE = /^(?:(?:list|retrieve|create|update|delete|send|
|
|
|
94
94
|
// `fetch` and `sendWebhook` are the flat client methods the codegen services do not declare, and
|
|
95
95
|
// the verb pattern cannot match; `fetch` is how apps reach non-SDK endpoints such as the AI proxy.
|
|
96
96
|
export const SDK_METHOD_EXTRAS = new Set(['fetch', 'sendWebhook']);
|
|
97
|
+
// SDK methods that only read the token already in hand, so a viewer calling them reaches no endpoint.
|
|
98
|
+
// Skipped at projection, not collection, so per-file facts stay reusable.
|
|
99
|
+
const NON_NETWORK_SDK_METHODS = new Set(['getTokenPayload']);
|
|
100
|
+
const FETCH_METHOD = 'fetch';
|
|
101
|
+
const DEFAULT_FETCH_HTTP_METHOD = 'GET';
|
|
97
102
|
const SDK_SERVICES_DIR = '../codegen/api/services';
|
|
98
103
|
const SDK_SERVICE_FILE = /Service\.d\.ts$/;
|
|
99
104
|
const SDK_STATIC_METHOD = /static (\w+)\(/g;
|
|
@@ -178,7 +183,9 @@ function scriptKind(ts, file) {
|
|
|
178
183
|
// `calls` are every method call on a tagged receiver, `defeaters` are the
|
|
179
184
|
// constructs that stop the scan following a viewer receiver, `specifiers` the
|
|
180
185
|
// module specifiers it imports.
|
|
181
|
-
|
|
186
|
+
// `localExportsFactory(spec)` says whether a local module exports an SDK factory; without it every
|
|
187
|
+
// local namespace import is tracked.
|
|
188
|
+
export function collectFileFacts(ts, file, text, sdkMethods = null, localExportsFactory = null) {
|
|
182
189
|
const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, scriptKind(ts, file));
|
|
183
190
|
const calls = [];
|
|
184
191
|
const defeaters = [];
|
|
@@ -194,6 +201,26 @@ export function collectFileFacts(ts, file, text, sdkMethods = null) {
|
|
|
194
201
|
|
|
195
202
|
const isLiteralText = (n) => n && (ts.isStringLiteral(n) || ts.isNoSubstitutionTemplateLiteral(n));
|
|
196
203
|
|
|
204
|
+
// The literal route and HTTP method of a `fetch(path, init?)` call, or null when either is not
|
|
205
|
+
// literal, so the projection fails closed. A missing method option is a GET.
|
|
206
|
+
const fetchRoute = (call) => {
|
|
207
|
+
const [pathArg, init] = call.arguments;
|
|
208
|
+
if (!isLiteralText(pathArg)) return null;
|
|
209
|
+
let method = DEFAULT_FETCH_HTTP_METHOD;
|
|
210
|
+
if (init) {
|
|
211
|
+
if (!ts.isObjectLiteralExpression(init)) return null;
|
|
212
|
+
for (const prop of init.properties) {
|
|
213
|
+
if (ts.isSpreadAssignment(prop) || (prop.name && ts.isComputedPropertyName(prop.name))) return null;
|
|
214
|
+
if (prop.name?.text !== 'method') continue;
|
|
215
|
+
if (!ts.isPropertyAssignment(prop) || !isLiteralText(prop.initializer)) return null;
|
|
216
|
+
method = prop.initializer.text;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
// The SDK prepends the slash a path is missing, so `v1/me` requests `/v1/me`.
|
|
220
|
+
const routePath = pathArg.text.startsWith('/') ? pathArg.text : `/${pathArg.text}`;
|
|
221
|
+
return { path: routePath.replace(/[?#].*$/, ''), method };
|
|
222
|
+
};
|
|
223
|
+
|
|
197
224
|
// --- imports ------------------------------------------------------------
|
|
198
225
|
const acceptsFactories = (spec) => LOCAL_SPECIFIER.test(spec) || SDK_SPECIFIER.test(spec);
|
|
199
226
|
for (const stmt of sf.statements) {
|
|
@@ -214,7 +241,12 @@ export function collectFileFacts(ts, file, text, sdkMethods = null) {
|
|
|
214
241
|
}
|
|
215
242
|
}
|
|
216
243
|
if (!bindings || !acceptsFactories(spec)) continue;
|
|
217
|
-
if (ts.isNamespaceImport(bindings))
|
|
244
|
+
if (ts.isNamespaceImport(bindings)) {
|
|
245
|
+
// A namespace of a module with no factory (a drizzle schema) cannot hand out a client.
|
|
246
|
+
if (!localExportsFactory || !LOCAL_SPECIFIER.test(spec) || localExportsFactory(spec)) {
|
|
247
|
+
namespaceLocals.add(bindings.name.text);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
218
250
|
else {
|
|
219
251
|
for (const el of bindings.elements) {
|
|
220
252
|
const imported = (el.propertyName ?? el.name).text;
|
|
@@ -337,7 +369,12 @@ export function collectFileFacts(ts, file, text, sdkMethods = null) {
|
|
|
337
369
|
const parent = cur.parent;
|
|
338
370
|
if (!parent) return;
|
|
339
371
|
|
|
340
|
-
const recordCall = (method, at) => calls.push({
|
|
372
|
+
const recordCall = (method, at, call) => calls.push({
|
|
373
|
+
line: lineOf(sf, at),
|
|
374
|
+
method,
|
|
375
|
+
provenance,
|
|
376
|
+
...(method === FETCH_METHOD ? { route: fetchRoute(call) } : {}),
|
|
377
|
+
});
|
|
341
378
|
|
|
342
379
|
if (ts.isPropertyAccessExpression(parent) && parent.expression === cur) {
|
|
343
380
|
const call = parent.parent;
|
|
@@ -352,14 +389,14 @@ export function collectFileFacts(ts, file, text, sdkMethods = null) {
|
|
|
352
389
|
if (viewer) defeat(parent, DEFEATER.PROMISE_CHAIN);
|
|
353
390
|
return;
|
|
354
391
|
}
|
|
355
|
-
recordCall(name, parent.name);
|
|
392
|
+
recordCall(name, parent.name, call);
|
|
356
393
|
return;
|
|
357
394
|
}
|
|
358
395
|
if (ts.isElementAccessExpression(parent) && parent.expression === cur) {
|
|
359
396
|
const arg = parent.argumentExpression;
|
|
360
397
|
const call = parent.parent;
|
|
361
398
|
if (isLiteralText(arg) && call && ts.isCallExpression(call) && call.expression === parent) {
|
|
362
|
-
recordCall(arg.text, arg);
|
|
399
|
+
recordCall(arg.text, arg, call);
|
|
363
400
|
} else if (viewer) defeat(parent, DEFEATER.DYNAMIC_ACCESS);
|
|
364
401
|
return;
|
|
365
402
|
}
|
|
@@ -880,9 +917,28 @@ export function createAnalysis(root) {
|
|
|
880
917
|
return null;
|
|
881
918
|
};
|
|
882
919
|
|
|
920
|
+
// Over-approximates (a mention plus any `export`): tracking too much only costs a defeater.
|
|
921
|
+
const FACTORY_NAME = new RegExp(`\\b(?:${[...Object.keys(SDK_FACTORIES), AFTER_ASSEMBLY, REQUEST_CONTEXT].join('|')})\\b`);
|
|
922
|
+
const STAR_REEXPORT = /export\s*(?:\*(?:\s*as\s+\w+)?|\{[^}]*\})\s*from\s*['"]([^'"]+)['"]/g;
|
|
923
|
+
const exportsFactory = (file, seen = new Set()) => {
|
|
924
|
+
if (seen.has(file)) return false;
|
|
925
|
+
seen.add(file);
|
|
926
|
+
const text = read(file);
|
|
927
|
+
if (/\bexport\b/.test(text) && FACTORY_NAME.test(text)) return true;
|
|
928
|
+
return [...text.matchAll(STAR_REEXPORT)].some(([, spec]) => {
|
|
929
|
+
if (SDK_SPECIFIER.test(spec)) return true;
|
|
930
|
+
const target = LOCAL_SPECIFIER.test(spec) ? resolve(file, spec) : null;
|
|
931
|
+
return target ? exportsFactory(target, seen) : false;
|
|
932
|
+
});
|
|
933
|
+
};
|
|
934
|
+
|
|
883
935
|
const factsFor = (file) => {
|
|
884
936
|
if (!facts.has(file)) {
|
|
885
|
-
const
|
|
937
|
+
const localExportsFactory = (spec) => {
|
|
938
|
+
const target = resolve(file, spec);
|
|
939
|
+
return target ? exportsFactory(target) : true;
|
|
940
|
+
};
|
|
941
|
+
const raw = collectFileFacts(ts, file, read(file), sdkMethods, localExportsFactory);
|
|
886
942
|
const imports = [];
|
|
887
943
|
const defeaters = [...raw.defeaters];
|
|
888
944
|
for (const spec of raw.specifiers) {
|
|
@@ -938,7 +994,9 @@ export function projectClientReachable(analysis, allowlist = CLIENT_SAFE_SDK_MET
|
|
|
938
994
|
const gated = [...reaching].every((e) => gatedSet.has(e));
|
|
939
995
|
const { calls, defeaters } = analysis.facts(file);
|
|
940
996
|
for (const c of calls) {
|
|
941
|
-
if (c.provenance
|
|
997
|
+
if (c.provenance !== PROVENANCE_VIEWER || NON_NETWORK_SDK_METHODS.has(c.method)) continue;
|
|
998
|
+
if (c.method === FETCH_METHOD && c.route && isClientSafeRoute(c.route.method, c.route.path)) continue;
|
|
999
|
+
if (!allowed.has(c.method)) {
|
|
942
1000
|
findings.push({ file, line: c.line, method: c.method, provenance: c.provenance, entry, gated });
|
|
943
1001
|
}
|
|
944
1002
|
}
|
package/dist/assembly.d.ts
CHANGED
|
@@ -102,5 +102,22 @@ export declare function getOfflineAssembly(): Promise<AssemblyAPI>;
|
|
|
102
102
|
* in the same request reuses that bearer.
|
|
103
103
|
*/
|
|
104
104
|
export declare function getInstallerAssembly(): Promise<AssemblyAPI>;
|
|
105
|
+
export type AppProxyCaller = 'viewer' | 'installer';
|
|
106
|
+
export interface AppProxyOptions {
|
|
107
|
+
baseUrl: string;
|
|
108
|
+
fetch: (input: Request) => Promise<Response>;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Options for calling another app's API through the platform, shaped for an
|
|
112
|
+
* `openapi-fetch` client: the generated `client.ts` passes them straight to
|
|
113
|
+
* `createClient`. The bearer is resolved on every call, so a client built at
|
|
114
|
+
* module scope still authenticates as the current request.
|
|
115
|
+
*
|
|
116
|
+
* `installer` calls as this app, with the same per-request mint as
|
|
117
|
+
* getInstallerAssembly. `viewer` forwards the signed-in user's verified bearer,
|
|
118
|
+
* which the platform refuses with `viewer_not_allowed` until it can prove which
|
|
119
|
+
* person a cross-app call is for.
|
|
120
|
+
*/
|
|
121
|
+
export declare function appProxyOptions(appId: string, caller: AppProxyCaller): AppProxyOptions;
|
|
105
122
|
export declare function getAssemblyForRequest(req: NextRequest): Promise<AssemblyAPI | NextResponse>;
|
|
106
123
|
export {};
|
package/dist/assembly.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { assemblyApi } from '@assembly-js/node-sdk';
|
|
1
|
+
import { assemblyApi, OpenAPI } from '@assembly-js/node-sdk';
|
|
2
2
|
import { cookies, headers } from 'next/headers';
|
|
3
3
|
import { after, NextResponse } from 'next/server';
|
|
4
4
|
import { BEARER_COOKIE_NAME, BEARER_HEADER_NAME, decodeBearer, isSessionExchangeEnabled, TOKEN_EXPIRED_HEADER, } from './session-exchange.js';
|
|
@@ -332,8 +332,8 @@ export async function getOfflineAssembly() {
|
|
|
332
332
|
// for the request, so keying on it shares a mint across the page without
|
|
333
333
|
// keeping the bearer after the request ends. A process-wide cache would hand
|
|
334
334
|
// one viewer's app bearer to the next request.
|
|
335
|
-
const
|
|
336
|
-
async function
|
|
335
|
+
const installerBearerByRequest = new WeakMap();
|
|
336
|
+
async function mintInstallerBearer() {
|
|
337
337
|
if (!process.env.ASSEMBLY_API_KEY) {
|
|
338
338
|
throw new Error('getInstallerAssembly: ASSEMBLY_API_KEY is not set — cannot mint an app-scoped bearer');
|
|
339
339
|
}
|
|
@@ -341,18 +341,11 @@ async function mintInstallerAssembly() {
|
|
|
341
341
|
if (!minted) {
|
|
342
342
|
throw new Error('getInstallerAssembly: failed to mint an app-scoped bearer from the app API key');
|
|
343
343
|
}
|
|
344
|
-
return
|
|
344
|
+
return minted.bearer;
|
|
345
345
|
}
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
* shown to a client must be filtered by the caller. Unlike getOfflineAssembly,
|
|
350
|
-
* a viewer cookie does not make this throw — that is the point of it.
|
|
351
|
-
*
|
|
352
|
-
* Does not attach the viewer's bearer. Each request mints once; a second call
|
|
353
|
-
* in the same request reuses that bearer.
|
|
354
|
-
*/
|
|
355
|
-
export async function getInstallerAssembly() {
|
|
346
|
+
// Shared by getInstallerAssembly and the installer app proxy, so a request that
|
|
347
|
+
// uses both still mints once.
|
|
348
|
+
async function getInstallerBearer() {
|
|
356
349
|
let headerStore;
|
|
357
350
|
try {
|
|
358
351
|
headerStore = await headers();
|
|
@@ -361,21 +354,62 @@ export async function getInstallerAssembly() {
|
|
|
361
354
|
headerStore = undefined;
|
|
362
355
|
}
|
|
363
356
|
if (!headerStore)
|
|
364
|
-
return
|
|
365
|
-
const existing =
|
|
357
|
+
return mintInstallerBearer();
|
|
358
|
+
const existing = installerBearerByRequest.get(headerStore);
|
|
366
359
|
if (existing)
|
|
367
360
|
return existing;
|
|
368
|
-
const pending =
|
|
369
|
-
|
|
361
|
+
const pending = mintInstallerBearer();
|
|
362
|
+
installerBearerByRequest.set(headerStore, pending);
|
|
370
363
|
try {
|
|
371
364
|
return await pending;
|
|
372
365
|
}
|
|
373
366
|
catch (err) {
|
|
374
367
|
// A failed mint must not stick for the rest of the request.
|
|
375
|
-
|
|
368
|
+
installerBearerByRequest.delete(headerStore);
|
|
376
369
|
throw err;
|
|
377
370
|
}
|
|
378
371
|
}
|
|
372
|
+
/**
|
|
373
|
+
* App-scoped client for use inside a user request (pages, server actions,
|
|
374
|
+
* route handlers). Acts as the installing admin: anything it returns that is
|
|
375
|
+
* shown to a client must be filtered by the caller. Unlike getOfflineAssembly,
|
|
376
|
+
* a viewer cookie does not make this throw — that is the point of it.
|
|
377
|
+
*
|
|
378
|
+
* Does not attach the viewer's bearer. Each request mints once; a second call
|
|
379
|
+
* in the same request reuses that bearer.
|
|
380
|
+
*/
|
|
381
|
+
export async function getInstallerAssembly() {
|
|
382
|
+
return getBearerAssembly(await getInstallerBearer());
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Options for calling another app's API through the platform, shaped for an
|
|
386
|
+
* `openapi-fetch` client: the generated `client.ts` passes them straight to
|
|
387
|
+
* `createClient`. The bearer is resolved on every call, so a client built at
|
|
388
|
+
* module scope still authenticates as the current request.
|
|
389
|
+
*
|
|
390
|
+
* `installer` calls as this app, with the same per-request mint as
|
|
391
|
+
* getInstallerAssembly. `viewer` forwards the signed-in user's verified bearer,
|
|
392
|
+
* which the platform refuses with `viewer_not_allowed` until it can prove which
|
|
393
|
+
* person a cross-app call is for.
|
|
394
|
+
*/
|
|
395
|
+
export function appProxyOptions(appId, caller) {
|
|
396
|
+
const origin = (trustedBaseUrl ?? OpenAPI.BASE).replace(/\/+$/, '');
|
|
397
|
+
return {
|
|
398
|
+
baseUrl: `${origin}/v1/apps/${encodeURIComponent(appId)}/api`,
|
|
399
|
+
fetch: async (input) => {
|
|
400
|
+
const bearer = caller === 'installer' ? await getInstallerBearer() : await requireViewerBearer();
|
|
401
|
+
const request = new Request(input);
|
|
402
|
+
request.headers.set('Authorization', `Bearer ${bearer}`);
|
|
403
|
+
return fetch(request);
|
|
404
|
+
},
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
async function requireViewerBearer() {
|
|
408
|
+
const viewer = await resolveRequestViewer();
|
|
409
|
+
if (!viewer)
|
|
410
|
+
throw new TokenInvalidError('Assembly bearer is required');
|
|
411
|
+
return viewer.bearer;
|
|
412
|
+
}
|
|
379
413
|
// Returns a request-scoped SDK client authenticated for the current user.
|
|
380
414
|
//
|
|
381
415
|
// Exchange enabled: bearer-only. A bearer the platform verifies (proxy header or
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export declare class ContractDocumentError extends Error {
|
|
2
|
+
constructor(message: string);
|
|
3
|
+
}
|
|
4
|
+
export interface GenerateAppClientInput {
|
|
5
|
+
appId: string;
|
|
6
|
+
document: unknown;
|
|
7
|
+
contractHash: string;
|
|
8
|
+
}
|
|
9
|
+
export interface GeneratedAppClient {
|
|
10
|
+
typesDts: string;
|
|
11
|
+
clientTs: string;
|
|
12
|
+
readmeMd: string;
|
|
13
|
+
}
|
|
14
|
+
export declare function generateAppClient({ appId, document, contractHash, }: GenerateAppClientInput): Promise<GeneratedAppClient>;
|
package/dist/codegen.js
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// Generates a typed client for another app's API from its OpenAPI contract.
|
|
2
|
+
// The sandbox runs this with plain node, so nothing here may import next/* or
|
|
3
|
+
// server-only, and it never touches the network or the app's repo.
|
|
4
|
+
import { createRequire } from 'node:module';
|
|
5
|
+
import openapiTS, { astToString } from 'openapi-typescript';
|
|
6
|
+
// The package version is the generator version: the same document and the same
|
|
7
|
+
// foundation version always produce byte-identical files.
|
|
8
|
+
const FOUNDATION_VERSION = createRequire(import.meta.url)('../package.json').version;
|
|
9
|
+
const HTTP_METHODS = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace'];
|
|
10
|
+
// Contracts are written as served (`/api/...`) while the client's base URL
|
|
11
|
+
// already ends in `/api`. Same rule as contractTemplate in bin/check-contract-routes.mjs.
|
|
12
|
+
const API_PREFIX = '/api';
|
|
13
|
+
export class ContractDocumentError extends Error {
|
|
14
|
+
constructor(message) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.name = 'ContractDocumentError';
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
function contractTemplate(contractPath) {
|
|
20
|
+
let p = contractPath.replace(/\/+$/, '');
|
|
21
|
+
if (p === API_PREFIX || p.startsWith(`${API_PREFIX}/`))
|
|
22
|
+
p = p.slice(API_PREFIX.length);
|
|
23
|
+
return p || '/';
|
|
24
|
+
}
|
|
25
|
+
const isObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
26
|
+
function forEachRef(node, at, visit) {
|
|
27
|
+
if (Array.isArray(node)) {
|
|
28
|
+
node.forEach((child, i) => forEachRef(child, `${at}/${i}`, visit));
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (!isObject(node))
|
|
32
|
+
return;
|
|
33
|
+
if (typeof node.$ref === 'string')
|
|
34
|
+
visit(node, at);
|
|
35
|
+
for (const [key, value] of Object.entries(node))
|
|
36
|
+
forEachRef(value, `${at}/${key}`, visit);
|
|
37
|
+
}
|
|
38
|
+
const pointerToken = (key) => key.replace(/~/g, '~0').replace(/\//g, '~1');
|
|
39
|
+
function normalizeDocument(document) {
|
|
40
|
+
if (!isObject(document))
|
|
41
|
+
throw new ContractDocumentError('contract document must be a JSON object');
|
|
42
|
+
if (typeof document.openapi !== 'string') {
|
|
43
|
+
throw new ContractDocumentError('contract document is missing "openapi"');
|
|
44
|
+
}
|
|
45
|
+
if (!document.openapi.startsWith('3.')) {
|
|
46
|
+
throw new ContractDocumentError(`contract document "openapi" must be 3.x, got "${document.openapi}"`);
|
|
47
|
+
}
|
|
48
|
+
if (!isObject(document.paths))
|
|
49
|
+
throw new ContractDocumentError('contract document is missing "paths"');
|
|
50
|
+
let external;
|
|
51
|
+
forEachRef(document, '#', (holder, at) => {
|
|
52
|
+
if (!external && !holder.$ref.startsWith('#'))
|
|
53
|
+
external = `${at}/$ref`;
|
|
54
|
+
});
|
|
55
|
+
if (external)
|
|
56
|
+
throw new ContractDocumentError(`contract document has an external "$ref" at ${external}`);
|
|
57
|
+
const copy = structuredClone(document);
|
|
58
|
+
const paths = {};
|
|
59
|
+
const renamed = new Map();
|
|
60
|
+
for (const [documented, item] of Object.entries(copy.paths)) {
|
|
61
|
+
const template = contractTemplate(documented);
|
|
62
|
+
if (template in paths) {
|
|
63
|
+
throw new ContractDocumentError(`contract document "paths" has two entries for ${template}`);
|
|
64
|
+
}
|
|
65
|
+
paths[template] = item;
|
|
66
|
+
if (template !== documented) {
|
|
67
|
+
renamed.set(`#/paths/${pointerToken(documented)}`, `#/paths/${pointerToken(template)}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
// A $ref into a renamed path item would otherwise point at a key that no longer exists.
|
|
71
|
+
forEachRef(copy, '#', (holder) => {
|
|
72
|
+
for (const [from, to] of renamed) {
|
|
73
|
+
if (holder.$ref === from || holder.$ref.startsWith(`${from}/`)) {
|
|
74
|
+
holder.$ref = to + holder.$ref.slice(from.length);
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
return { ...copy, paths };
|
|
80
|
+
}
|
|
81
|
+
// openapi-typescript names an `operations` entry after each operationId; without
|
|
82
|
+
// it the operation is inlined under its path, so the (verb, path) pair stays the
|
|
83
|
+
// only identity the types expose.
|
|
84
|
+
function stripOperationIds(paths) {
|
|
85
|
+
for (const item of Object.values(paths)) {
|
|
86
|
+
if (!isObject(item))
|
|
87
|
+
continue;
|
|
88
|
+
for (const method of HTTP_METHODS) {
|
|
89
|
+
const op = item[method];
|
|
90
|
+
if (isObject(op))
|
|
91
|
+
delete op.operationId;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const PATHS_POINTER = '#/paths/';
|
|
96
|
+
const decodePointerToken = (token) => token.replace(/~1/g, '/').replace(/~0/g, '~');
|
|
97
|
+
// A path item that is a $ref into another path still serves that path's operations under its own key.
|
|
98
|
+
function resolvePathItem(paths, item) {
|
|
99
|
+
if (!isObject(item) || typeof item.$ref !== 'string' || !item.$ref.startsWith(PATHS_POINTER))
|
|
100
|
+
return item;
|
|
101
|
+
return paths[decodePointerToken(item.$ref.slice(PATHS_POINTER.length))];
|
|
102
|
+
}
|
|
103
|
+
function operationsOf(paths) {
|
|
104
|
+
const ops = [];
|
|
105
|
+
for (const [path, entry] of Object.entries(paths)) {
|
|
106
|
+
const item = resolvePathItem(paths, entry);
|
|
107
|
+
if (!isObject(item))
|
|
108
|
+
continue;
|
|
109
|
+
for (const method of HTTP_METHODS) {
|
|
110
|
+
const op = item[method];
|
|
111
|
+
if (!isObject(op))
|
|
112
|
+
continue;
|
|
113
|
+
ops.push({
|
|
114
|
+
method: method.toUpperCase(),
|
|
115
|
+
path,
|
|
116
|
+
operationId: typeof op.operationId === 'string' ? op.operationId : undefined,
|
|
117
|
+
summary: typeof op.summary === 'string' ? op.summary : undefined,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return ops;
|
|
122
|
+
}
|
|
123
|
+
const header = (contractHash) => `// Generated by @assembly-js/app-foundation ${FOUNDATION_VERSION} from contract ${contractHash}. Do not edit.\n`;
|
|
124
|
+
// Fixed apart from the app id, so every generated client behaves the same way
|
|
125
|
+
// and all auth mechanics stay in the package.
|
|
126
|
+
function renderClient(appId) {
|
|
127
|
+
return `// Generated by @assembly-js/app-foundation ${FOUNDATION_VERSION}. Do not edit.
|
|
128
|
+
import createClient from 'openapi-fetch';
|
|
129
|
+
import { appProxyOptions } from '@assembly-js/app-foundation/server';
|
|
130
|
+
import type { paths } from './types.js';
|
|
131
|
+
|
|
132
|
+
export const APP_ID = ${JSON.stringify(appId)};
|
|
133
|
+
|
|
134
|
+
// Calls as this app. Use it from pages, server actions, routes, cron and webhooks.
|
|
135
|
+
export const installerClient = createClient<paths>(appProxyOptions(APP_ID, 'installer'));
|
|
136
|
+
|
|
137
|
+
// Calls as the signed-in person. Refused with viewer_not_allowed for now.
|
|
138
|
+
export const viewerClient = createClient<paths>(appProxyOptions(APP_ID, 'viewer'));
|
|
139
|
+
`;
|
|
140
|
+
}
|
|
141
|
+
const cell = (s) => s.replace(/\\/g, '\\\\').replace(/\|/g, '\\|').replace(/\r?\n/g, ' ');
|
|
142
|
+
function renderReadme(appId, contractHash, paths) {
|
|
143
|
+
const rows = operationsOf(paths).map((op) => `| \`${op.method}\` | \`${cell(op.path)}\` | ${cell(op.operationId ?? '')} | ${cell(op.summary ?? '')} |`);
|
|
144
|
+
return `# Client for app \`${appId}\`
|
|
145
|
+
|
|
146
|
+
Generated by \`@assembly-js/app-foundation\` ${FOUNDATION_VERSION} from contract \`${contractHash}\`. Do not edit these files; they are regenerated when the contract changes.
|
|
147
|
+
|
|
148
|
+
Call an operation by its verb and path, exactly as listed below. The path and its parameters are type-checked against the contract.
|
|
149
|
+
|
|
150
|
+
\`\`\`ts
|
|
151
|
+
import { installerClient } from './client.js';
|
|
152
|
+
|
|
153
|
+
const { data, error } = await installerClient.GET('/projects/{id}', {
|
|
154
|
+
params: { path: { id } },
|
|
155
|
+
});
|
|
156
|
+
\`\`\`
|
|
157
|
+
|
|
158
|
+
- \`installerClient\` calls as this app. Use it from pages, server actions, routes, cron and webhooks, and filter anything you show to a client.
|
|
159
|
+
- \`viewerClient\` calls as the signed-in person. The platform refuses it with \`viewer_not_allowed\` until it can prove which person the call is for, so use \`installerClient\` for now.
|
|
160
|
+
|
|
161
|
+
## Operations
|
|
162
|
+
|
|
163
|
+
| Verb | Path | Operation | Summary |
|
|
164
|
+
|---|---|---|---|
|
|
165
|
+
${rows.join('\n')}
|
|
166
|
+
`;
|
|
167
|
+
}
|
|
168
|
+
export async function generateAppClient({ appId, document, contractHash, }) {
|
|
169
|
+
const normalized = normalizeDocument(document);
|
|
170
|
+
const readmeMd = renderReadme(appId, contractHash, normalized.paths);
|
|
171
|
+
stripOperationIds(normalized.paths);
|
|
172
|
+
const ast = await openapiTS(normalized, { silent: true });
|
|
173
|
+
return {
|
|
174
|
+
typesDts: header(contractHash) + astToString(ast),
|
|
175
|
+
clientTs: renderClient(appId),
|
|
176
|
+
readmeMd,
|
|
177
|
+
};
|
|
178
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@assembly-js/app-foundation",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.5",
|
|
4
4
|
"description": "Versioned foundation helpers and build context for Assembly generated apps",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -31,11 +31,15 @@
|
|
|
31
31
|
"./next": {
|
|
32
32
|
"types": "./dist/next.d.ts",
|
|
33
33
|
"default": "./dist/next.js"
|
|
34
|
+
},
|
|
35
|
+
"./codegen": {
|
|
36
|
+
"types": "./dist/codegen.d.ts",
|
|
37
|
+
"default": "./dist/codegen.js"
|
|
34
38
|
}
|
|
35
39
|
},
|
|
36
40
|
"scripts": {
|
|
37
41
|
"build": "tsc && tsc-alias && esbuild src/scripts.ts --bundle --platform=node --format=esm --target=node18 --outfile=dist/scripts.js",
|
|
38
|
-
"test": "npm run build && bun test --path-ignore-patterns='**/proxy.test.ts' --path-ignore-patterns='**/reexchange.test.ts' && bun test ./src/proxy.test.ts && bun test ./src/reexchange.test.ts && tsc -p tsconfig.replace-rows.json",
|
|
42
|
+
"test": "npm run build && bun test --path-ignore-patterns='**/proxy.test.ts' --path-ignore-patterns='**/reexchange.test.ts' && bun test ./src/proxy.test.ts && bun test ./src/reexchange.test.ts && tsc -p tsconfig.replace-rows.json && node scripts/emit-codegen-fixture.mjs && tsc -p tsconfig.codegen.json",
|
|
39
43
|
"prepublishOnly": "npm run build && npm run verify:dist && npm run verify:bin",
|
|
40
44
|
"verify:dist": "node scripts/verify-dist-resolves.mjs",
|
|
41
45
|
"verify:bin": "node scripts/verify-bin.mjs"
|
|
@@ -71,6 +75,8 @@
|
|
|
71
75
|
"author": "Assembly",
|
|
72
76
|
"license": "MIT",
|
|
73
77
|
"dependencies": {
|
|
78
|
+
"openapi-fetch": "^0.17.0",
|
|
79
|
+
"openapi-typescript": "^7.13.0",
|
|
74
80
|
"unpdf": "^1.9.1"
|
|
75
81
|
}
|
|
76
82
|
}
|