@assembly-js/app-foundation 0.0.45 → 0.0.46
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 +6 -0
- package/CLAUDE.md +8 -3
- package/dist/action-route.d.ts +46 -0
- package/dist/action-route.js +206 -0
- package/dist/app-storage.js +4 -3
- package/dist/assembly.d.ts +12 -0
- package/dist/assembly.js +67 -2
- package/dist/auth.d.ts +13 -2
- package/dist/auth.js +47 -4
- package/dist/installer-caller.d.ts +14 -0
- package/dist/installer-caller.js +12 -0
- package/dist/mint-bearer.js +3 -0
- package/dist/nango.d.ts +4 -4
- package/dist/nango.js +6 -6
- package/dist/server.d.ts +1 -0
- package/dist/server.js +1 -0
- package/dist/session-exchange.d.ts +2 -0
- package/dist/session-exchange.js +1 -0
- package/package.json +1 -1
- package/skills/assembly-automations/SKILL.md +13 -107
- package/skills/assembly-internal-users/SKILL.md +2 -0
- package/skills/assembly-notifications/SKILL.md +2 -0
- package/skills/assembly-route-auth/SKILL.md +4 -0
- package/skills/assembly-storage/SKILL.md +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -14,6 +14,12 @@ A foundation update reads the sections between an app's version and the target:
|
|
|
14
14
|
from every bullet except `[guidance]`, agent work from the `fixes.json` entries those `[action]`
|
|
15
15
|
bullets name. Write `[fix]`, `[behavior]` and `[action]` bullets for the app's owner.
|
|
16
16
|
|
|
17
|
+
## 0.0.46
|
|
18
|
+
- [new] Pages, server actions, and routes can use an installer client that acts as the installing admin while a person is in the app.
|
|
19
|
+
- [behavior] The action wrapper takes the actor from the verified bearer and ignores a userId query parameter.
|
|
20
|
+
- [behavior] A session the platform verifies as coming from another app is admitted as an app; a human on the same session stays a human. A route can list the kinds of caller it accepts.
|
|
21
|
+
- [guidance] Teach the build agent when to use the installer client and how to declare which callers a route accepts.
|
|
22
|
+
|
|
17
23
|
## 0.0.45
|
|
18
24
|
- [guidance] The generated SDK method tables no longer list the session and token methods the foundation calls itself, and the module notes name each module's SDK groups.
|
|
19
25
|
|
package/CLAUDE.md
CHANGED
|
@@ -443,6 +443,10 @@ Relationships:
|
|
|
443
443
|
**Listing companies for users.** `listCompanies` excludes **placeholder** companies (nameless solo-client stand-ins) by default for app-builder apps, so company dropdowns, tables, and filters won't show blank "ghost" rows — you don't need to filter them yourself. To *include* placeholders (e.g. to surface solo clients), pass `listCompanies({ isPlaceholder: true })` explicitly. See `assembly-companies` skill.
|
|
444
444
|
|
|
445
445
|
**App isolation.** `listAppInstalls`/`retrieveAppInstall` tell you which apps are installed (id, appId, type, icon, and other install metadata), and `listAppConnections`/`createAppConnection` manage manual embed and link connections between installed apps — that's real, supported SDK surface, and requests to inspect an install or set up one of those connections are not out of bounds. None of it reaches another app's own data, though: you have no access to another installed app's database, tables, or any other internal data, and there is no SDK call that reads or writes another app's data store. If a request needs another app's private data, say only that it isn't possible and stop — no alternatives and no follow-up question. In user-facing chat, call them apps, not custom apps. Do not propose a bridge: no endpoint or API on the other app, no webhook, push, or sync, and nothing the user has to paste between chats. Do not volunteer automations, clients, companies, custom fields, or an Assembly module as a substitute. If the user explicitly asks to call an existing registered API exposed by another app, that is supported API access rather than database access; never suggest it unprompted. Likewise, if the request itself explicitly asks for automations, CRM data, or an installed module's supported data (for example invoices), that is a separate supported request. Never reach another app's database directly.
|
|
446
|
+
<!-- feature:api-resources -->
|
|
447
|
+
|
|
448
|
+
If the user names another app, read its file in `.assembly/docs/apps/` and follow the call recipe at the top of that file. If that folder is absent, the app is not callable (it may still need the Security update). Cross-app calls from pages, server actions and routes use `getInstallerAssembly()`; cron and webhooks use `getOfflineAssembly()`. Never return another app's response to a client unfiltered — scope any client data you forward. While a person is using the app, those calls use the installer client, because the platform does not yet prove which person the call is for. Once it can, use the viewer client for that person's data and keep the installer client for staff-wide reads.
|
|
449
|
+
<!-- /feature:api-resources -->
|
|
446
450
|
|
|
447
451
|
---
|
|
448
452
|
|
|
@@ -880,9 +884,10 @@ Internalize these consequences — they are exactly what generated apps get wron
|
|
|
880
884
|
- **Never pass a token to any function.** `getAssembly()`, server actions, and route handlers all take **no** token argument.
|
|
881
885
|
- **Never call `assemblyApi` yourself, and never authenticate on the client.** Every API call is server-side through `getAssembly()` / `getAssemblyForRequest()`.
|
|
882
886
|
|
|
883
|
-
Every authenticated call your app makes is one of exactly
|
|
884
|
-
1. **User bearer** — the default
|
|
885
|
-
2. **
|
|
887
|
+
Every authenticated call your app makes is one of exactly three kinds:
|
|
888
|
+
1. **User bearer** — the default. `getAssembly()` acts as the current viewer. Use it for anything shown to or done for the person using the app.
|
|
889
|
+
2. **Installer client** — `getInstallerAssembly()`, allowed inside a user request. Use it for staff-wide reads and writes the app is entitled to on its own, including notification helpers, app storage and connectors. It acts as the installing admin, so filter anything from it before showing it to a client. A viewer cookie does not make it refuse; `getOfflineAssembly()` does.
|
|
890
|
+
3. **Offline bearer** — for background work with no user in the loop (cron, webhooks). Call `getOfflineAssembly()` (see "Offline bearer" below); it mints an app-scoped bearer from your API key server-side. Only needed outside a request; it refuses when a viewer is present.
|
|
886
891
|
|
|
887
892
|
#### `getAssembly()` — the only auth helper you need
|
|
888
893
|
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { type AssemblyAPI } from '@assembly-js/node-sdk';
|
|
2
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
3
|
+
declare const SURFACED_STATUSES: readonly [400, 401, 404, 422, 429, 500];
|
|
4
|
+
export type ActionErrorStatus = (typeof SURFACED_STATUSES)[number];
|
|
5
|
+
export declare class ActionError extends Error {
|
|
6
|
+
readonly status: ActionErrorStatus;
|
|
7
|
+
constructor(message: string, status?: ActionErrorStatus);
|
|
8
|
+
}
|
|
9
|
+
export type Viewer = {
|
|
10
|
+
kind: 'internal';
|
|
11
|
+
internalUserId: string;
|
|
12
|
+
} | {
|
|
13
|
+
kind: 'client';
|
|
14
|
+
clientId: string;
|
|
15
|
+
companyId?: string;
|
|
16
|
+
} | {
|
|
17
|
+
kind: 'app';
|
|
18
|
+
};
|
|
19
|
+
export type RequestSource = 'automation' | 'user' | 'app';
|
|
20
|
+
export interface Caller {
|
|
21
|
+
appId: string;
|
|
22
|
+
chain: string[];
|
|
23
|
+
hops: number;
|
|
24
|
+
}
|
|
25
|
+
export interface ActionContext {
|
|
26
|
+
workspaceId: string;
|
|
27
|
+
viewer: Viewer;
|
|
28
|
+
caller?: Caller;
|
|
29
|
+
request: {
|
|
30
|
+
source: RequestSource;
|
|
31
|
+
sandbox?: boolean;
|
|
32
|
+
};
|
|
33
|
+
assembly: AssemblyAPI;
|
|
34
|
+
readonly installer: Promise<AssemblyAPI>;
|
|
35
|
+
/** @deprecated read viewer.internalUserId */
|
|
36
|
+
internalUserId?: string;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Who is calling a page, server action, or route handler, from the identity
|
|
40
|
+
* the platform verified for this request's bearer.
|
|
41
|
+
*/
|
|
42
|
+
export declare function getRequestContext(): Promise<ActionContext>;
|
|
43
|
+
export declare function automationActionRoute<TInput>(handler: (input: TInput, context: ActionContext) => Promise<unknown>): (request: NextRequest) => Promise<NextResponse>;
|
|
44
|
+
/** Builds an ActionContext for tests. Production code uses the producers. */
|
|
45
|
+
export declare function makeTestActionContext(overrides: Partial<ActionContext> & Pick<ActionContext, 'assembly' | 'workspaceId'>): ActionContext;
|
|
46
|
+
export {};
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { assemblyApi } from '@assembly-js/node-sdk';
|
|
2
|
+
import { NextResponse } from 'next/server';
|
|
3
|
+
import { principalKind } from './auth.js';
|
|
4
|
+
import { AuthUnavailableError, TokenInvalidError, authUnavailableResponse, getAssembly, getInstallerAssembly, getVerifiedIdentity, } from './assembly.js';
|
|
5
|
+
import { BEARER_HEADER_NAME } from './session-exchange.js';
|
|
6
|
+
import { verifyBearer } from './verify-bearer.js';
|
|
7
|
+
// The only statuses whose response body the platform reads back.
|
|
8
|
+
const SURFACED_STATUSES = [400, 401, 404, 422, 429, 500];
|
|
9
|
+
export class ActionError extends Error {
|
|
10
|
+
status;
|
|
11
|
+
constructor(message, status = 400) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = 'ActionError';
|
|
14
|
+
this.status = status;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
// One hop until the bearer carries a real chain. Not a measured depth.
|
|
18
|
+
const PLACEHOLDER_HOPS = 1;
|
|
19
|
+
const trustedBaseUrl = process.env.ASSEMBLY_API_BASE_URL || undefined;
|
|
20
|
+
function callerFrom(callerAppId) {
|
|
21
|
+
if (!callerAppId)
|
|
22
|
+
return undefined;
|
|
23
|
+
return { appId: callerAppId, chain: [callerAppId], hops: PLACEHOLDER_HOPS };
|
|
24
|
+
}
|
|
25
|
+
// A calling app is the viewer only when the bearer names no person. A human
|
|
26
|
+
// id wins so a staff or client session that also carries a caller stays human.
|
|
27
|
+
function viewerFrom(identity) {
|
|
28
|
+
// Same precedence as route admission, so a request and its guard agree.
|
|
29
|
+
const kind = principalKind(identity);
|
|
30
|
+
if (kind === 'client' && identity.clientId) {
|
|
31
|
+
return {
|
|
32
|
+
kind: 'client',
|
|
33
|
+
clientId: identity.clientId,
|
|
34
|
+
...(identity.companyId ? { companyId: identity.companyId } : {}),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
if (kind === 'internal' && identity.internalUserId) {
|
|
38
|
+
return { kind: 'internal', internalUserId: identity.internalUserId };
|
|
39
|
+
}
|
|
40
|
+
if (kind === 'app')
|
|
41
|
+
return { kind: 'app' };
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
function installerSlot() {
|
|
45
|
+
return getInstallerAssembly();
|
|
46
|
+
}
|
|
47
|
+
function contextFrom(args) {
|
|
48
|
+
return {
|
|
49
|
+
workspaceId: args.workspaceId,
|
|
50
|
+
viewer: args.viewer,
|
|
51
|
+
...(args.caller ? { caller: args.caller } : {}),
|
|
52
|
+
request: { source: args.source },
|
|
53
|
+
assembly: args.assembly,
|
|
54
|
+
get installer() {
|
|
55
|
+
return installerSlot();
|
|
56
|
+
},
|
|
57
|
+
// Hidden once a caller is present: the deprecated alias is the human actor
|
|
58
|
+
// of a request that has no calling app, not a second identity channel.
|
|
59
|
+
...(args.viewer.kind === 'internal' && !args.caller && args.internalUserId
|
|
60
|
+
? { internalUserId: args.internalUserId }
|
|
61
|
+
: {}),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Who is calling a page, server action, or route handler, from the identity
|
|
66
|
+
* the platform verified for this request's bearer.
|
|
67
|
+
*/
|
|
68
|
+
export async function getRequestContext() {
|
|
69
|
+
let identity;
|
|
70
|
+
try {
|
|
71
|
+
identity = await getVerifiedIdentity();
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
if (err instanceof AuthUnavailableError)
|
|
75
|
+
throw err;
|
|
76
|
+
throw err;
|
|
77
|
+
}
|
|
78
|
+
if (!identity?.workspaceId) {
|
|
79
|
+
throw new TokenInvalidError('Assembly bearer is required');
|
|
80
|
+
}
|
|
81
|
+
const caller = callerFrom(identity.callerAppId);
|
|
82
|
+
const viewer = viewerFrom(identity);
|
|
83
|
+
if (!viewer) {
|
|
84
|
+
throw new TokenInvalidError('Assembly bearer is required');
|
|
85
|
+
}
|
|
86
|
+
const assembly = viewer.kind === 'app' ? await getInstallerAssembly() : await getAssembly();
|
|
87
|
+
return contextFrom({
|
|
88
|
+
workspaceId: identity.workspaceId,
|
|
89
|
+
viewer,
|
|
90
|
+
caller,
|
|
91
|
+
source: viewer.kind === 'app' ? 'app' : 'user',
|
|
92
|
+
assembly,
|
|
93
|
+
internalUserId: identity.internalUserId,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
function tokenExpired() {
|
|
97
|
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
98
|
+
}
|
|
99
|
+
// Bearer path: the platform already exchanged the single-use token and the
|
|
100
|
+
// middleware forwarded the result. Identity is the verify response, never a
|
|
101
|
+
// query parameter — the platform still sends userId, and so can anyone else.
|
|
102
|
+
async function assemblyFromAutomationRequest(request) {
|
|
103
|
+
const token = request.nextUrl.searchParams.get('token') ??
|
|
104
|
+
request.headers.get('x-assembly-token');
|
|
105
|
+
const bearer = request.headers.get(BEARER_HEADER_NAME);
|
|
106
|
+
if (!token && !bearer)
|
|
107
|
+
return tokenExpired();
|
|
108
|
+
try {
|
|
109
|
+
if (bearer) {
|
|
110
|
+
const result = await verifyBearer(bearer);
|
|
111
|
+
if (!result.ok) {
|
|
112
|
+
return result.reason === 'rejected'
|
|
113
|
+
? tokenExpired()
|
|
114
|
+
: authUnavailableResponse();
|
|
115
|
+
}
|
|
116
|
+
const assembly = await assemblyApi({ bearer, baseUrl: trustedBaseUrl });
|
|
117
|
+
const { identity } = result;
|
|
118
|
+
assembly.getTokenPayload = () => Promise.resolve({
|
|
119
|
+
workspaceId: identity.workspaceId,
|
|
120
|
+
clientId: identity.clientId,
|
|
121
|
+
companyId: identity.companyId,
|
|
122
|
+
internalUserId: identity.internalUserId,
|
|
123
|
+
...(identity.callerAppId ? { callerAppId: identity.callerAppId } : {}),
|
|
124
|
+
});
|
|
125
|
+
return {
|
|
126
|
+
assembly,
|
|
127
|
+
workspaceId: identity.workspaceId,
|
|
128
|
+
internalUserId: identity.internalUserId,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
const assembly = await assemblyApi({
|
|
132
|
+
apiKey: process.env.ASSEMBLY_API_KEY ?? '',
|
|
133
|
+
token: token ?? undefined,
|
|
134
|
+
baseUrl: trustedBaseUrl,
|
|
135
|
+
});
|
|
136
|
+
const payload = await assembly.getTokenPayload?.();
|
|
137
|
+
if (!payload?.workspaceId)
|
|
138
|
+
return tokenExpired();
|
|
139
|
+
return {
|
|
140
|
+
assembly,
|
|
141
|
+
workspaceId: payload.workspaceId,
|
|
142
|
+
internalUserId: payload.internalUserId,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
return tokenExpired();
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
export function automationActionRoute(handler) {
|
|
150
|
+
return async (request) => {
|
|
151
|
+
const resolved = await assemblyFromAutomationRequest(request);
|
|
152
|
+
if (resolved instanceof NextResponse)
|
|
153
|
+
return resolved;
|
|
154
|
+
let input;
|
|
155
|
+
try {
|
|
156
|
+
const raw = await request.text();
|
|
157
|
+
input = (raw.trim() === '' ? {} : JSON.parse(raw));
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
return NextResponse.json({ error: 'Request body is not valid JSON' }, { status: 400 });
|
|
161
|
+
}
|
|
162
|
+
const context = contextFrom({
|
|
163
|
+
workspaceId: resolved.workspaceId,
|
|
164
|
+
viewer: { kind: 'app' },
|
|
165
|
+
source: 'automation',
|
|
166
|
+
assembly: resolved.assembly,
|
|
167
|
+
// Automation has no calling app. The actor, when the platform minted
|
|
168
|
+
// one onto the bearer, stays on the deprecated alias.
|
|
169
|
+
internalUserId: resolved.internalUserId,
|
|
170
|
+
});
|
|
171
|
+
// contextFrom only copies internalUserId for an internal viewer. An
|
|
172
|
+
// automation's viewer is the app, and handlers still attribute writes to
|
|
173
|
+
// the staff user the platform named on the bearer.
|
|
174
|
+
if (resolved.internalUserId) {
|
|
175
|
+
context.internalUserId = resolved.internalUserId;
|
|
176
|
+
}
|
|
177
|
+
try {
|
|
178
|
+
const result = await handler(input, context);
|
|
179
|
+
if (result instanceof NextResponse)
|
|
180
|
+
return result;
|
|
181
|
+
return NextResponse.json(result ?? {});
|
|
182
|
+
}
|
|
183
|
+
catch (err) {
|
|
184
|
+
if (err instanceof ActionError) {
|
|
185
|
+
return NextResponse.json({ error: err.message }, { status: err.status });
|
|
186
|
+
}
|
|
187
|
+
console.error(err);
|
|
188
|
+
return NextResponse.json({ error: 'Something went wrong' }, { status: 500 });
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
/** Builds an ActionContext for tests. Production code uses the producers. */
|
|
193
|
+
export function makeTestActionContext(overrides) {
|
|
194
|
+
// Not a spread of a live context: that would invoke the installer getter.
|
|
195
|
+
return {
|
|
196
|
+
workspaceId: overrides.workspaceId,
|
|
197
|
+
viewer: overrides.viewer ?? { kind: 'app' },
|
|
198
|
+
...(overrides.caller ? { caller: overrides.caller } : {}),
|
|
199
|
+
request: overrides.request ?? { source: 'user' },
|
|
200
|
+
assembly: overrides.assembly,
|
|
201
|
+
installer: overrides.installer ?? Promise.resolve(overrides.assembly),
|
|
202
|
+
...(overrides.internalUserId
|
|
203
|
+
? { internalUserId: overrides.internalUserId }
|
|
204
|
+
: {}),
|
|
205
|
+
};
|
|
206
|
+
}
|
package/dist/app-storage.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { getInstallerAssemblyForCaller } from './installer-caller.js';
|
|
2
2
|
// A bearer token is a JWT: three base64url segments separated by dots, the
|
|
3
3
|
// first always decoding to a JSON header (so it starts with 'eyJ'). Matching
|
|
4
4
|
// the whole-string shape — not just the 'eyJ' prefix — avoids rejecting a
|
|
@@ -16,7 +16,8 @@ export async function getUploadUrl(fileName) {
|
|
|
16
16
|
throw new Error('getUploadUrl: first argument looks like a session token. ' +
|
|
17
17
|
'The token argument was removed — call getUploadUrl(fileName) with the file name only.');
|
|
18
18
|
}
|
|
19
|
-
|
|
19
|
+
// App-owned storage uses the app's authority, but only once a caller is verified.
|
|
20
|
+
const assembly = await getInstallerAssemblyForCaller();
|
|
20
21
|
const res = await assembly.fetch('/v1/app-storage/upload-url', {
|
|
21
22
|
method: 'POST',
|
|
22
23
|
body: JSON.stringify({ fileName }),
|
|
@@ -33,7 +34,7 @@ export async function getDownloadUrl(key) {
|
|
|
33
34
|
throw new Error('getDownloadUrl: first argument looks like a session token. ' +
|
|
34
35
|
'The token argument was removed — call getDownloadUrl(key) with the storage key only.');
|
|
35
36
|
}
|
|
36
|
-
const assembly = await
|
|
37
|
+
const assembly = await getInstallerAssemblyForCaller();
|
|
37
38
|
const res = await assembly.fetch('/v1/app-storage/download-url?key=' + encodeURIComponent(key));
|
|
38
39
|
if (!res.ok)
|
|
39
40
|
throw new Error('Download URL request failed: ' + res.status);
|
package/dist/assembly.d.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { type AssemblyAPI } from '@assembly-js/node-sdk';
|
|
2
2
|
import { NextRequest, NextResponse } from 'next/server';
|
|
3
3
|
import { type ExchangedSession } from './session-exchange.js';
|
|
4
|
+
import { type VerifiedIdentity } from './verify-bearer.js';
|
|
4
5
|
type AssemblyInput = {
|
|
5
6
|
token?: string;
|
|
6
7
|
} | string | undefined;
|
|
7
8
|
export declare function getSessionIdentity(): Promise<ExchangedSession | null>;
|
|
9
|
+
export declare function getVerifiedIdentity(): Promise<VerifiedIdentity | null>;
|
|
8
10
|
/**
|
|
9
11
|
* Thrown when a session token is required but missing, or was supplied but
|
|
10
12
|
* could not be decoded (almost always expiry or truncation). Enforced here in
|
|
@@ -90,5 +92,15 @@ export declare function authUnavailableResponse(): NextResponse;
|
|
|
90
92
|
* }
|
|
91
93
|
*/
|
|
92
94
|
export declare function getOfflineAssembly(): Promise<AssemblyAPI>;
|
|
95
|
+
/**
|
|
96
|
+
* App-scoped client for use inside a user request (pages, server actions,
|
|
97
|
+
* route handlers). Acts as the installing admin: anything it returns that is
|
|
98
|
+
* shown to a client must be filtered by the caller. Unlike getOfflineAssembly,
|
|
99
|
+
* a viewer cookie does not make this throw — that is the point of it.
|
|
100
|
+
*
|
|
101
|
+
* Does not attach the viewer's bearer. Each request mints once; a second call
|
|
102
|
+
* in the same request reuses that bearer.
|
|
103
|
+
*/
|
|
104
|
+
export declare function getInstallerAssembly(): Promise<AssemblyAPI>;
|
|
93
105
|
export declare function getAssemblyForRequest(req: NextRequest): Promise<AssemblyAPI | NextResponse>;
|
|
94
106
|
export {};
|
package/dist/assembly.js
CHANGED
|
@@ -100,8 +100,24 @@ export async function getSessionIdentity() {
|
|
|
100
100
|
return null;
|
|
101
101
|
// Project to the documented shape so layouts that pass the whole object to a
|
|
102
102
|
// client provider don't serialize server-side fields to the browser.
|
|
103
|
-
const { workspaceId, clientId, internalUserId, companyId, notificationId, instanceId, sessionId } = viewer.identity;
|
|
104
|
-
return {
|
|
103
|
+
const { workspaceId, clientId, internalUserId, companyId, notificationId, instanceId, sessionId, callerAppId, } = viewer.identity;
|
|
104
|
+
return {
|
|
105
|
+
workspaceId,
|
|
106
|
+
clientId,
|
|
107
|
+
internalUserId,
|
|
108
|
+
companyId,
|
|
109
|
+
notificationId,
|
|
110
|
+
instanceId,
|
|
111
|
+
sessionId,
|
|
112
|
+
...(callerAppId ? { callerAppId } : {}),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
// The platform-verified identity for the current request, or null when there
|
|
116
|
+
// is no bearer. Throws AuthUnavailableError when the platform could not be
|
|
117
|
+
// reached. Identity for authorization comes from here, never from decoded claims.
|
|
118
|
+
export async function getVerifiedIdentity() {
|
|
119
|
+
const viewer = await resolveRequestViewer();
|
|
120
|
+
return viewer?.identity ?? null;
|
|
105
121
|
}
|
|
106
122
|
/**
|
|
107
123
|
* Thrown when a session token is required but missing, or was supplied but
|
|
@@ -264,6 +280,7 @@ async function getBearerAssembly(bearer, identity) {
|
|
|
264
280
|
internalUserId: identity.internalUserId,
|
|
265
281
|
notificationId: identity.notificationId,
|
|
266
282
|
instanceId: identity.instanceId,
|
|
283
|
+
...(identity.callerAppId ? { callerAppId: identity.callerAppId } : {}),
|
|
267
284
|
};
|
|
268
285
|
assembly.getTokenPayload = () => Promise.resolve(payload);
|
|
269
286
|
}
|
|
@@ -311,6 +328,54 @@ export async function getOfflineAssembly() {
|
|
|
311
328
|
}
|
|
312
329
|
return getBearerAssembly(minted.bearer);
|
|
313
330
|
}
|
|
331
|
+
// One minted installer bearer per request. Next memoizes the headers() object
|
|
332
|
+
// for the request, so keying on it shares a mint across the page without
|
|
333
|
+
// keeping the bearer after the request ends. A process-wide cache would hand
|
|
334
|
+
// one viewer's app bearer to the next request.
|
|
335
|
+
const installerByRequest = new WeakMap();
|
|
336
|
+
async function mintInstallerAssembly() {
|
|
337
|
+
if (!process.env.ASSEMBLY_API_KEY) {
|
|
338
|
+
throw new Error('getInstallerAssembly: ASSEMBLY_API_KEY is not set — cannot mint an app-scoped bearer');
|
|
339
|
+
}
|
|
340
|
+
const minted = await mintBearerFromAppKey();
|
|
341
|
+
if (!minted) {
|
|
342
|
+
throw new Error('getInstallerAssembly: failed to mint an app-scoped bearer from the app API key');
|
|
343
|
+
}
|
|
344
|
+
return getBearerAssembly(minted.bearer);
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* App-scoped client for use inside a user request (pages, server actions,
|
|
348
|
+
* route handlers). Acts as the installing admin: anything it returns that is
|
|
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() {
|
|
356
|
+
let headerStore;
|
|
357
|
+
try {
|
|
358
|
+
headerStore = await headers();
|
|
359
|
+
}
|
|
360
|
+
catch {
|
|
361
|
+
headerStore = undefined;
|
|
362
|
+
}
|
|
363
|
+
if (!headerStore)
|
|
364
|
+
return mintInstallerAssembly();
|
|
365
|
+
const existing = installerByRequest.get(headerStore);
|
|
366
|
+
if (existing)
|
|
367
|
+
return existing;
|
|
368
|
+
const pending = mintInstallerAssembly();
|
|
369
|
+
installerByRequest.set(headerStore, pending);
|
|
370
|
+
try {
|
|
371
|
+
return await pending;
|
|
372
|
+
}
|
|
373
|
+
catch (err) {
|
|
374
|
+
// A failed mint must not stick for the rest of the request.
|
|
375
|
+
installerByRequest.delete(headerStore);
|
|
376
|
+
throw err;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
314
379
|
// Returns a request-scoped SDK client authenticated for the current user.
|
|
315
380
|
//
|
|
316
381
|
// Exchange enabled: bearer-only. A bearer the platform verifies (proxy header or
|
package/dist/auth.d.ts
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
import type { AssemblyAPI } from '@assembly-js/node-sdk';
|
|
2
2
|
import { NextRequest, NextResponse } from 'next/server';
|
|
3
|
+
export type PrincipalKind = 'internal' | 'client' | 'app';
|
|
4
|
+
export interface PrincipalAllow {
|
|
5
|
+
allow?: readonly PrincipalKind[];
|
|
6
|
+
}
|
|
7
|
+
export declare function principalKind(payload: {
|
|
8
|
+
clientId?: string;
|
|
9
|
+
internalUserId?: string;
|
|
10
|
+
callerAppId?: string;
|
|
11
|
+
}): PrincipalKind | undefined;
|
|
3
12
|
export interface TokenPayload {
|
|
4
13
|
workspaceId: string;
|
|
5
14
|
internalUserId?: string;
|
|
@@ -7,11 +16,13 @@ export interface TokenPayload {
|
|
|
7
16
|
companyId?: string;
|
|
8
17
|
notificationId?: string;
|
|
9
18
|
instanceId?: string;
|
|
19
|
+
/** Calling app, when this session was opened by another app. */
|
|
20
|
+
callerAppId?: string;
|
|
10
21
|
baseUrl?: string;
|
|
11
22
|
tokenId?: string;
|
|
12
23
|
}
|
|
13
|
-
export declare function requireAuth(req: NextRequest): Promise<TokenPayload | NextResponse>;
|
|
24
|
+
export declare function requireAuth(req: NextRequest, options?: PrincipalAllow): Promise<TokenPayload | NextResponse>;
|
|
14
25
|
export declare function requireCompanyScope(payload: TokenPayload, resource: {
|
|
15
26
|
companyId: string | null;
|
|
16
27
|
clientId?: string | null;
|
|
17
|
-
}, tokenOrAssembly?: string | AssemblyAPI): Promise<NextResponse | null>;
|
|
28
|
+
}, tokenOrAssembly?: string | AssemblyAPI, options?: PrincipalAllow): Promise<NextResponse | null>;
|
package/dist/auth.js
CHANGED
|
@@ -11,6 +11,25 @@ function isTokenInvalid(err) {
|
|
|
11
11
|
const e = err;
|
|
12
12
|
return e.name === 'TokenInvalidError' || e.code === 'TOKEN_INVALID';
|
|
13
13
|
}
|
|
14
|
+
// A person wins over a calling app. A caller with no human id is an app.
|
|
15
|
+
// Undefined is an unauthenticated shape, not a fourth kind.
|
|
16
|
+
export function principalKind(payload) {
|
|
17
|
+
if (payload.clientId)
|
|
18
|
+
return 'client';
|
|
19
|
+
if (payload.internalUserId)
|
|
20
|
+
return 'internal';
|
|
21
|
+
if (payload.callerAppId)
|
|
22
|
+
return 'app';
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
function principalRefused(payload, allow) {
|
|
26
|
+
if (!allow)
|
|
27
|
+
return null;
|
|
28
|
+
const kind = principalKind(payload);
|
|
29
|
+
if (kind && allow.includes(kind))
|
|
30
|
+
return null;
|
|
31
|
+
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
|
32
|
+
}
|
|
14
33
|
function tokenExpiredResponse() {
|
|
15
34
|
return NextResponse.json({ error: 'Unauthorized', code: 'token_expired' }, { status: 401, headers: { [TOKEN_EXPIRED_HEADER]: '1' } });
|
|
16
35
|
}
|
|
@@ -23,7 +42,7 @@ function tokenExpiredResponse() {
|
|
|
23
42
|
// without the marker, so nothing re-exchanges. There is no session-token
|
|
24
43
|
// fallback for a bearer-capable app.
|
|
25
44
|
// Exchange off (legacy): reads and decodes the x-assembly-token header.
|
|
26
|
-
export async function requireAuth(req) {
|
|
45
|
+
export async function requireAuth(req, options) {
|
|
27
46
|
if (isSessionExchangeEnabled()) {
|
|
28
47
|
const bearer = req.headers.get(BEARER_HEADER_NAME) ??
|
|
29
48
|
req.cookies.get(BEARER_COOKIE_NAME)?.value;
|
|
@@ -37,14 +56,21 @@ export async function requireAuth(req) {
|
|
|
37
56
|
: authUnavailableResponse();
|
|
38
57
|
}
|
|
39
58
|
const { identity } = result;
|
|
40
|
-
|
|
59
|
+
const payload = {
|
|
41
60
|
workspaceId: identity.workspaceId,
|
|
42
61
|
clientId: identity.clientId,
|
|
43
62
|
internalUserId: identity.internalUserId,
|
|
44
63
|
companyId: identity.companyId,
|
|
45
64
|
notificationId: identity.notificationId,
|
|
46
65
|
instanceId: identity.instanceId,
|
|
66
|
+
// Only the platform's verify response. The proxy copies a warm cookie
|
|
67
|
+
// onto the bearer header, so the header is not a separate trust path.
|
|
68
|
+
...(identity.callerAppId ? { callerAppId: identity.callerAppId } : {}),
|
|
47
69
|
};
|
|
70
|
+
const refused = principalRefused(payload, options?.allow);
|
|
71
|
+
if (refused)
|
|
72
|
+
return refused;
|
|
73
|
+
return payload;
|
|
48
74
|
}
|
|
49
75
|
const token = req.headers.get('x-assembly-token');
|
|
50
76
|
if (!token) {
|
|
@@ -61,7 +87,11 @@ export async function requireAuth(req) {
|
|
|
61
87
|
if (!payload.workspaceId) {
|
|
62
88
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
63
89
|
}
|
|
64
|
-
|
|
90
|
+
const tokenPayload = payload;
|
|
91
|
+
const refused = principalRefused(tokenPayload, options?.allow);
|
|
92
|
+
if (refused)
|
|
93
|
+
return refused;
|
|
94
|
+
return tokenPayload;
|
|
65
95
|
}
|
|
66
96
|
catch (err) {
|
|
67
97
|
if (isTokenInvalid(err)) {
|
|
@@ -82,9 +112,17 @@ export async function requireAuth(req) {
|
|
|
82
112
|
// Pass a session token string (legacy path) or a bearer-authenticated AssemblyAPI
|
|
83
113
|
// instance from getAssemblyForRequest (bearer path) to enable the membership lookup.
|
|
84
114
|
// Returns null on success, or a 403/404 NextResponse on mismatch.
|
|
85
|
-
export async function requireCompanyScope(payload, resource, tokenOrAssembly) {
|
|
115
|
+
export async function requireCompanyScope(payload, resource, tokenOrAssembly, options) {
|
|
116
|
+
const refused = principalRefused(payload, options?.allow);
|
|
117
|
+
if (refused)
|
|
118
|
+
return refused;
|
|
86
119
|
if (payload.internalUserId)
|
|
87
120
|
return null;
|
|
121
|
+
// Workspace-wide, same as cron. A caller id only counts when verify put it
|
|
122
|
+
// on the payload: the proxy mirrors a warm cookie onto the bearer header,
|
|
123
|
+
// so the header cannot prove where the bearer came from.
|
|
124
|
+
if (payload.callerAppId && !payload.clientId)
|
|
125
|
+
return null;
|
|
88
126
|
if (!payload.clientId) {
|
|
89
127
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
|
90
128
|
}
|
|
@@ -133,6 +171,11 @@ export async function requireCompanyScope(payload, resource, tokenOrAssembly) {
|
|
|
133
171
|
const status = typeof err === 'object' && err !== null ? err.status : undefined;
|
|
134
172
|
if (status === 401)
|
|
135
173
|
return tokenExpiredResponse();
|
|
174
|
+
// The client's own record can 403. That is an authorization answer, not a
|
|
175
|
+
// broken server — mapping it to 500 makes the client retry a refusal.
|
|
176
|
+
if (status === 403) {
|
|
177
|
+
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
|
178
|
+
}
|
|
136
179
|
return NextResponse.json({ error: status === 404 ? 'Not found' : 'Internal server error' }, { status: status === 404 ? 404 : 500 });
|
|
137
180
|
}
|
|
138
181
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { AssemblyAPI } from '@assembly-js/node-sdk';
|
|
2
|
+
interface InstallerCallerDeps {
|
|
3
|
+
getAssembly: () => Promise<unknown>;
|
|
4
|
+
getInstallerAssembly: () => Promise<AssemblyAPI>;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Installer client for app-owned resources (storage, connectors), handed out
|
|
8
|
+
* only once the platform has verified a caller. Routes and actions that reach
|
|
9
|
+
* these helpers without their own auth check would otherwise expose the
|
|
10
|
+
* installer's workspace-wide authority to anyone. getAssembly() throws when
|
|
11
|
+
* there is no verified viewer, so the gate runs before any installer mint.
|
|
12
|
+
*/
|
|
13
|
+
export declare function getInstallerAssemblyForCaller(deps?: InstallerCallerDeps): Promise<AssemblyAPI>;
|
|
14
|
+
export {};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { getAssembly, getInstallerAssembly } from './assembly.js';
|
|
2
|
+
/**
|
|
3
|
+
* Installer client for app-owned resources (storage, connectors), handed out
|
|
4
|
+
* only once the platform has verified a caller. Routes and actions that reach
|
|
5
|
+
* these helpers without their own auth check would otherwise expose the
|
|
6
|
+
* installer's workspace-wide authority to anyone. getAssembly() throws when
|
|
7
|
+
* there is no verified viewer, so the gate runs before any installer mint.
|
|
8
|
+
*/
|
|
9
|
+
export async function getInstallerAssemblyForCaller(deps = { getAssembly, getInstallerAssembly }) {
|
|
10
|
+
await deps.getAssembly();
|
|
11
|
+
return deps.getInstallerAssembly();
|
|
12
|
+
}
|
package/dist/mint-bearer.js
CHANGED
|
@@ -95,6 +95,9 @@ export async function mintBearerFromSessionToken(token, options) {
|
|
|
95
95
|
companyId: json.session?.companyId || undefined,
|
|
96
96
|
notificationId: json.session?.notificationId || undefined,
|
|
97
97
|
instanceId: json.session?.instanceId || undefined,
|
|
98
|
+
...(json.session?.callerAppId
|
|
99
|
+
? { callerAppId: json.session.callerAppId }
|
|
100
|
+
: {}),
|
|
98
101
|
sessionId: payload.tokenId,
|
|
99
102
|
flags: json.flags?.length ? json.flags : undefined,
|
|
100
103
|
},
|
package/dist/nango.d.ts
CHANGED
|
@@ -34,14 +34,14 @@ export interface ProxyOpts {
|
|
|
34
34
|
/**
|
|
35
35
|
* Returns a thin, server-side-only client for a connected provider. Every method
|
|
36
36
|
* POSTs to the Assembly connector broker (`/v1/connectors/:provider/...`) via
|
|
37
|
-
*
|
|
37
|
+
* getInstallerAssemblyForCaller().fetch — there is no Nango client and no provider credentials in
|
|
38
38
|
* the app. The broker resolves the workspace's connection server-side and
|
|
39
39
|
* relays the provider response transparently.
|
|
40
40
|
*
|
|
41
41
|
* MUST be called from server-side code only (API routes, server components,
|
|
42
|
-
* server actions)
|
|
43
|
-
*
|
|
44
|
-
*
|
|
42
|
+
* server actions). The connection belongs to the app, so the call uses the
|
|
43
|
+
* installer client rather than the viewer's bearer. There is no
|
|
44
|
+
* `x-assembly-token` header and no token argument. Background/cron
|
|
45
45
|
* calls with no viewer are out of scope for v1 — see connector Phase-2.
|
|
46
46
|
*/
|
|
47
47
|
export declare function getIntegration(provider: Provider): Promise<{
|
package/dist/nango.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { getInstallerAssemblyForCaller } from './installer-caller.js';
|
|
2
2
|
import { findResource, normalizeResourcePage, TtlCache, } from './resource-resolution.js';
|
|
3
3
|
// Thrown when the broker returns a structured `{ error, provider }` fail-closed
|
|
4
4
|
// response. Catch it server-side and branch on `code` to render the right empty
|
|
@@ -67,18 +67,18 @@ async function throwIfBrokerError(res, provider) {
|
|
|
67
67
|
/**
|
|
68
68
|
* Returns a thin, server-side-only client for a connected provider. Every method
|
|
69
69
|
* POSTs to the Assembly connector broker (`/v1/connectors/:provider/...`) via
|
|
70
|
-
*
|
|
70
|
+
* getInstallerAssemblyForCaller().fetch — there is no Nango client and no provider credentials in
|
|
71
71
|
* the app. The broker resolves the workspace's connection server-side and
|
|
72
72
|
* relays the provider response transparently.
|
|
73
73
|
*
|
|
74
74
|
* MUST be called from server-side code only (API routes, server components,
|
|
75
|
-
* server actions)
|
|
76
|
-
*
|
|
77
|
-
*
|
|
75
|
+
* server actions). The connection belongs to the app, so the call uses the
|
|
76
|
+
* installer client rather than the viewer's bearer. There is no
|
|
77
|
+
* `x-assembly-token` header and no token argument. Background/cron
|
|
78
78
|
* calls with no viewer are out of scope for v1 — see connector Phase-2.
|
|
79
79
|
*/
|
|
80
80
|
export async function getIntegration(provider) {
|
|
81
|
-
const assembly = await
|
|
81
|
+
const assembly = await getInstallerAssemblyForCaller();
|
|
82
82
|
// Relays a provider HTTP call through the broker's universal proxy. The broker
|
|
83
83
|
// endpoint is always POST-ed (so a body carries through); the real provider
|
|
84
84
|
// verb travels in X-Connector-Method (default GET). The provider response is
|
package/dist/server.d.ts
CHANGED
package/dist/server.js
CHANGED
|
@@ -40,6 +40,8 @@ export interface ExchangedSession {
|
|
|
40
40
|
companyId?: string;
|
|
41
41
|
notificationId?: string;
|
|
42
42
|
instanceId?: string;
|
|
43
|
+
/** Set when another app called this one. Absent on a human session. */
|
|
44
|
+
callerAppId?: string;
|
|
43
45
|
/** Session token this bearer was exchanged from — lets the proxy tell a
|
|
44
46
|
* reload of an already-exchanged token apart from a new open. */
|
|
45
47
|
sessionId?: string;
|
package/dist/session-exchange.js
CHANGED
|
@@ -202,5 +202,6 @@ export function decodeBearer(bearer) {
|
|
|
202
202
|
notificationId: claims.notification_id || undefined,
|
|
203
203
|
instanceId: claims.instance_id || undefined,
|
|
204
204
|
sessionId: claims.session_id || undefined,
|
|
205
|
+
...(claims.caller_app_id ? { callerAppId: claims.caller_app_id } : {}),
|
|
205
206
|
};
|
|
206
207
|
}
|
package/package.json
CHANGED
|
@@ -269,116 +269,18 @@ Every event also lands in the workspace's audit log, where a workspace admin rea
|
|
|
269
269
|
|
|
270
270
|
**Every api with `exposeToAutomations: true` requires two things in the app: `lib/assembly-actions.ts` exactly as written below, and a route serving it at exactly that record's `baseUrl`.** This is not optional infrastructure. Skipping the wrapper and hand-rolling the route is the single most damaging mistake in this file: the platform's call carries no cookie and no viewer bearer, so nothing about it *looks* unauthenticated, and a route that forgets to verify the token becomes a public endpoint that mutates workspace data under the app's own privileges. Serving it at the wrong path is silent too — the automation just fails with an opaque error and the handler never runs.
|
|
271
271
|
|
|
272
|
-
The platform runs the action by calling the record's `baseUrl`, and `requireAuth` cannot authenticate that call.
|
|
272
|
+
The platform runs the action by calling the record's `baseUrl`, and `requireAuth` cannot authenticate that call. `lib/assembly-actions.ts` re-exports the wrapper from the foundation package — write that re-export, don't paste a second copy of the handler.
|
|
273
|
+
|
|
274
|
+
Use `getAssembly()` for anything shown to or done for the person using the app, and `getInstallerAssembly()` for staff-wide reads and writes the app is entitled to on its own, including notification helpers.
|
|
273
275
|
|
|
274
276
|
```ts
|
|
275
277
|
// lib/assembly-actions.ts
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
export class ActionError extends Error {
|
|
284
|
-
readonly status: ActionErrorStatus;
|
|
285
|
-
constructor(message: string, status: ActionErrorStatus = 400) {
|
|
286
|
-
super(message);
|
|
287
|
-
this.name = 'ActionError';
|
|
288
|
-
this.status = status;
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
export interface ActionContext {
|
|
293
|
-
workspaceId: string;
|
|
294
|
-
internalUserId?: string;
|
|
295
|
-
assembly: AssemblyAPI;
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
export function automationActionRoute<TInput>(
|
|
299
|
-
handler: (input: TInput, context: ActionContext) => Promise<unknown>,
|
|
300
|
-
): (request: NextRequest) => Promise<NextResponse> {
|
|
301
|
-
return async (request) => {
|
|
302
|
-
// The platform sends the app token as a query param; the header is the
|
|
303
|
-
// transport the public API moves to. Never a cookie or a viewer bearer.
|
|
304
|
-
const token =
|
|
305
|
-
request.nextUrl.searchParams.get('token') ??
|
|
306
|
-
request.headers.get('x-assembly-token');
|
|
307
|
-
// The proxy middleware exchanges that single-use token before this handler
|
|
308
|
-
// runs and forwards the minted bearer on this header (inbound copies are
|
|
309
|
-
// stripped, so it is always the middleware's own). By handler time the raw
|
|
310
|
-
// token is spent — platform calls must ride the bearer whenever one is
|
|
311
|
-
// present; the token path below serves exchange-disabled apps.
|
|
312
|
-
const bearer = request.headers.get('x-assembly-request-bearer');
|
|
313
|
-
if (!token && !bearer) {
|
|
314
|
-
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
let assembly: AssemblyAPI;
|
|
318
|
-
let workspaceId: string | undefined;
|
|
319
|
-
let internalUserId: string | undefined;
|
|
320
|
-
try {
|
|
321
|
-
// With a bearer, identity comes from its platform-verified claims. On
|
|
322
|
-
// the token path, the app API key derives the key the token was
|
|
323
|
-
// encrypted with; the SDK swallows a decrypt failure and leaves the
|
|
324
|
-
// payload unset — an absent workspaceId is how a forged or stale
|
|
325
|
-
// credential surfaces either way.
|
|
326
|
-
assembly = await assemblyApi({
|
|
327
|
-
apiKey: process.env.ASSEMBLY_API_KEY ?? '',
|
|
328
|
-
...(bearer ? { bearer } : { token: token ?? undefined }),
|
|
329
|
-
baseUrl: process.env.ASSEMBLY_API_BASE_URL || undefined,
|
|
330
|
-
});
|
|
331
|
-
const payload = await assembly.getTokenPayload?.();
|
|
332
|
-
workspaceId = payload?.workspaceId;
|
|
333
|
-
internalUserId = payload?.internalUserId;
|
|
334
|
-
} catch {
|
|
335
|
-
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
336
|
-
}
|
|
337
|
-
if (!workspaceId) {
|
|
338
|
-
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
let input: TInput;
|
|
342
|
-
try {
|
|
343
|
-
const raw = await request.text();
|
|
344
|
-
input = (raw.trim() === '' ? {} : JSON.parse(raw)) as TInput;
|
|
345
|
-
} catch {
|
|
346
|
-
return NextResponse.json(
|
|
347
|
-
{ error: 'Request body is not valid JSON' },
|
|
348
|
-
{ status: 400 },
|
|
349
|
-
);
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
try {
|
|
353
|
-
const result = await handler(input, {
|
|
354
|
-
workspaceId,
|
|
355
|
-
// The token names the actor only when the platform minted it with one;
|
|
356
|
-
// an automation always names them on the query string.
|
|
357
|
-
internalUserId:
|
|
358
|
-
internalUserId ||
|
|
359
|
-
request.nextUrl.searchParams.get('userId') ||
|
|
360
|
-
undefined,
|
|
361
|
-
assembly,
|
|
362
|
-
});
|
|
363
|
-
// A handler that returns a NextResponse (anything out of
|
|
364
|
-
// lib/api-errors.ts) already carries its own status — pass it straight
|
|
365
|
-
// through, or JSON-wrapping it would serialize the response object into
|
|
366
|
-
// a 200 body and the automation would record a success.
|
|
367
|
-
if (result instanceof NextResponse) return result;
|
|
368
|
-
// Otherwise always a JSON body: the platform parses the response even
|
|
369
|
-
// for a void operation, so a 204 or an empty body fails the run step.
|
|
370
|
-
return NextResponse.json(result ?? {});
|
|
371
|
-
} catch (err) {
|
|
372
|
-
if (err instanceof ActionError) {
|
|
373
|
-
return NextResponse.json({ error: err.message }, { status: err.status });
|
|
374
|
-
}
|
|
375
|
-
// Never return err.message from a catch — driver and Postgres strings
|
|
376
|
-
// leak to the caller. Same rule as lib/api-errors.ts.
|
|
377
|
-
console.error(err);
|
|
378
|
-
return NextResponse.json({ error: 'Something went wrong' }, { status: 500 });
|
|
379
|
-
}
|
|
380
|
-
};
|
|
381
|
-
}
|
|
278
|
+
export {
|
|
279
|
+
automationActionRoute,
|
|
280
|
+
ActionError,
|
|
281
|
+
type ActionContext,
|
|
282
|
+
type ActionErrorStatus,
|
|
283
|
+
} from '@assembly-js/app-foundation/server';
|
|
382
284
|
```
|
|
383
285
|
|
|
384
286
|
Each action route is then just the handler, served at the record's `baseUrl`:
|
|
@@ -481,6 +383,10 @@ export const GET = automationActionRoute(async (_input, { workspaceId }) => {
|
|
|
481
383
|
- **`trigger.icon`, when set, must be one of the names in the icon list above.** Unknown names fail the publish; an omitted icon renders a generic default.
|
|
482
384
|
- **Renaming breaks saved automations.** An action's identity derives from the api's `name` and a trigger's from `eventName` — including the `~<appId>.` prefix, which is part of that identity. Renaming either, or adding/removing the prefix on one you already shipped, orphans every automation a workspace built on it. Pick names as if they're permanent.
|
|
483
385
|
|
|
386
|
+
## Calling another app
|
|
387
|
+
|
|
388
|
+
If the user names another app, read its file in `.assembly/docs/apps/` and follow the call recipe at the top of that file. If that folder is absent, the app is not callable (it may still need the Security update). Cross-app calls from pages, server actions and routes use `getInstallerAssembly()`; cron and webhooks use `getOfflineAssembly()`. Never return another app's response to a client unfiltered — scope any client data you forward. While a person is using the app, those calls use the installer client, because the platform does not yet prove which person the call is for. Once it can, use the viewer client for that person's data and keep the installer client for staff-wide reads.
|
|
389
|
+
|
|
484
390
|
## Telling the user
|
|
485
391
|
|
|
486
392
|
**Call it an automation, not a webhook.** In the product this feature is Automations; "webhook" is the wire format underneath it. `sendWebhook` and the `webhooks` manifest key keep their names in code — this is about what you say in chat. "I added a *Time entry logged* trigger you can build automations on" lands; "I set up a webhook" reads as plumbing nobody asked for.
|
|
@@ -18,6 +18,8 @@ Never call `assembly.getTokenPayload()` for this — see `assembly-session-token
|
|
|
18
18
|
|
|
19
19
|
If `internalUserId` is set, the current viewer is staff. (`clientId` is set for client viewers — see `assembly-client-users` skill.)
|
|
20
20
|
|
|
21
|
+
Use `getAssembly()` for anything shown to or done for the person using the app, and `getInstallerAssembly()` for staff-wide reads and writes the app is entitled to on its own, including notification helpers.
|
|
22
|
+
|
|
21
23
|
## SDK methods
|
|
22
24
|
|
|
23
25
|
- `listInternalUsers({ nextToken, limit })` — paged list of all staff in the workspace
|
|
@@ -16,6 +16,8 @@ For a required condition alert (a retainer reaching 90%, going over its allowanc
|
|
|
16
16
|
|
|
17
17
|
Every notification is three decisions: **audience** (internal users and/or clients), **channel** (in-product, email, or both), and **context on arrival** (what the recipient sees and can do). Get those right; the rest is mechanics.
|
|
18
18
|
|
|
19
|
+
Use `getAssembly()` for anything shown to or done for the person using the app, and `getInstallerAssembly()` for staff-wide reads and writes the app is entitled to on its own, including notification helpers.
|
|
20
|
+
|
|
19
21
|
### Internal vs client experience
|
|
20
22
|
|
|
21
23
|
The audience changes the experience and the code:
|
|
@@ -7,6 +7,10 @@ Every `app/api/.../[id]/route.ts` handler — **GET included** — must call `re
|
|
|
7
7
|
|
|
8
8
|
Routes listed in the `privateRoutes` array of `.assembly/app.json` (globs such as `/internal/**`, matched against the route template, not the file path; `*` stays within a segment, `**` crosses segments) are not offered to other apps.
|
|
9
9
|
|
|
10
|
+
Use `getAssembly()` for anything shown to or done for the person using the app, and `getInstallerAssembly()` for staff-wide reads and writes the app is entitled to on its own, including notification helpers.
|
|
11
|
+
|
|
12
|
+
A route that must stay human-only passes `{ allow: ['internal', 'client'] }` to `requireAuth` and `requireCompanyScope`. With no option, an app caller is allowed too; an app caller on a route that does not list `'app'` gets 403. When you branch on `viewer.kind`, keep a default branch — the set of kinds can grow.
|
|
13
|
+
|
|
10
14
|
```ts
|
|
11
15
|
import { requireAuth, requireCompanyScope, handleRouteError } from '@assembly-js/app-foundation/server';
|
|
12
16
|
```
|
|
@@ -18,7 +18,7 @@ Use app-storage for files that belong to YOUR app. For files the user expects to
|
|
|
18
18
|
|
|
19
19
|
Store the `key` it returns; never construct an S3 key yourself, and never parse the original file name back out of the key — the platform normalizes the name when forming the key, so the stored `key` is not guaranteed to contain your exact `fileName`. Keep the display name as your own metadata (see "Storing and downloading"). `fileName` must be a bare file name (`invoice.pdf`), never a path — the platform rejects any fileName containing `/` or `..`. Don't prefix folders or namespaces onto it; the platform already scopes the key to the workspace and makes it unique. If you need grouping, store that in your own database alongside the key.
|
|
20
20
|
|
|
21
|
-
The helpers
|
|
21
|
+
The helpers verify the caller (as `getAssembly()` does) and then use the installer client — app storage is the app's own resource, so it does not ride the viewer's bearer. They take no token. `assembly.fetch()` prepends the base URL and injects auth headers — you only pass the path and body. Wrap these in a server API route (e.g. `app/api/app-storage/upload-url/route.ts`); never call `getAssembly` from a client component.
|
|
22
22
|
|
|
23
23
|
## Client uploader — PUT directly to S3
|
|
24
24
|
|