@assembly-js/app-foundation 0.0.0 → 0.0.1-rc.2
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/CLAUDE.md +1119 -0
- package/dist/api-errors.d.ts +5 -0
- package/dist/api-errors.js +33 -0
- package/dist/app-storage.d.ts +7 -0
- package/dist/app-storage.js +22 -0
- package/dist/assembly.d.ts +49 -0
- package/dist/assembly.js +235 -0
- package/dist/auth.d.ts +17 -0
- package/dist/auth.js +118 -0
- package/dist/client-companies.d.ts +16 -0
- package/dist/client-companies.js +23 -0
- package/dist/client.d.ts +5 -2
- package/dist/client.js +14 -3
- package/dist/labels.d.ts +9 -0
- package/dist/labels.js +17 -0
- package/dist/mint-bearer.d.ts +18 -0
- package/dist/mint-bearer.js +152 -0
- package/dist/nango.d.ts +174 -0
- package/dist/nango.js +430 -0
- package/dist/network-capture.d.ts +36 -0
- package/dist/network-capture.js +473 -0
- package/dist/params.d.ts +3 -0
- package/dist/params.js +20 -0
- package/dist/resource-resolution.d.ts +89 -0
- package/dist/resource-resolution.js +142 -0
- package/dist/server.d.ts +9 -2
- package/dist/server.js +15 -5
- package/dist/session-exchange.d.ts +20 -0
- package/dist/session-exchange.js +101 -0
- package/dist/theme.d.ts +5 -0
- package/dist/theme.js +20 -0
- package/package.json +11 -4
- package/skills/assembly-ai/SKILL.md +133 -0
- package/skills/assembly-app-bridge-header/SKILL.md +88 -0
- package/skills/assembly-app-bridge-toast/SKILL.md +79 -0
- package/skills/assembly-avatar-sub/SKILL.md +103 -0
- package/skills/assembly-client-users/SKILL.md +45 -0
- package/skills/assembly-clients-companies/SKILL.md +143 -0
- package/skills/assembly-companies/SKILL.md +45 -0
- package/skills/assembly-custom-fields/SKILL.md +86 -0
- package/skills/assembly-db-transactions/SKILL.md +79 -0
- package/skills/assembly-dev-server-logs/SKILL.md +26 -0
- package/skills/assembly-forms/SKILL.md +103 -0
- package/skills/assembly-internal-users/SKILL.md +69 -0
- package/skills/assembly-mutations/SKILL.md +58 -0
- package/skills/assembly-native-resources/SKILL.md +117 -0
- package/skills/assembly-route-auth/SKILL.md +223 -0
- package/skills/assembly-route-errors/SKILL.md +81 -0
- package/skills/assembly-semantic-colors/SKILL.md +76 -0
- package/skills/assembly-session-token/SKILL.md +153 -0
- package/skills/assembly-storage/SKILL.md +157 -0
- package/skills/assembly-user-company-selector/SKILL.md +98 -0
- package/skills/assembly-workspace-labels/SKILL.md +50 -0
- package/skills/clearing-client-notifications/SKILL.md +100 -0
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server';
|
|
2
|
+
export declare function badRequest(message: string): NextResponse;
|
|
3
|
+
export declare function notFound(message?: string): NextResponse;
|
|
4
|
+
export declare function internalError(message?: string): NextResponse;
|
|
5
|
+
export declare function handleRouteError(err: unknown, fallback?: string): NextResponse;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server';
|
|
2
|
+
export function badRequest(message) {
|
|
3
|
+
return NextResponse.json({ error: message }, { status: 400 });
|
|
4
|
+
}
|
|
5
|
+
export function notFound(message = 'Not found') {
|
|
6
|
+
return NextResponse.json({ error: message }, { status: 404 });
|
|
7
|
+
}
|
|
8
|
+
export function internalError(message = 'Something went wrong') {
|
|
9
|
+
return NextResponse.json({ error: message }, { status: 500 });
|
|
10
|
+
}
|
|
11
|
+
// Postgres 22P02 — invalid_text_representation (e.g. "foo" against a uuid column).
|
|
12
|
+
const INVALID_INPUT_PG_CODE = '22P02';
|
|
13
|
+
function isInvalidInputDbError(err) {
|
|
14
|
+
if (!err || typeof err !== 'object')
|
|
15
|
+
return false;
|
|
16
|
+
const e = err;
|
|
17
|
+
if (e.code === INVALID_INPUT_PG_CODE)
|
|
18
|
+
return true;
|
|
19
|
+
if (typeof e.message === 'string' && e.message.includes('invalid input syntax for type uuid')) {
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
// Map known DB failures to safe client responses. Log the real error server-side;
|
|
25
|
+
// never return err.message from a catch block — Postgres and driver strings leak
|
|
26
|
+
// to the UI when handlers do that.
|
|
27
|
+
export function handleRouteError(err, fallback = 'Something went wrong') {
|
|
28
|
+
console.error(err);
|
|
29
|
+
if (isInvalidInputDbError(err)) {
|
|
30
|
+
return badRequest('Invalid request');
|
|
31
|
+
}
|
|
32
|
+
return internalError(fallback);
|
|
33
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { getAssembly } from './assembly.js';
|
|
2
|
+
// fileName must be a bare file name (e.g. 'invoice.pdf'), never a path:
|
|
3
|
+
// the platform rejects any value containing '/' or '..'. Do NOT prefix
|
|
4
|
+
// folders or construct the S3 key yourself — the platform scopes the key
|
|
5
|
+
// to the workspace and makes it unique. Store the key it returns.
|
|
6
|
+
export async function getUploadUrl(fileName) {
|
|
7
|
+
const assembly = await getAssembly();
|
|
8
|
+
const res = await assembly.fetch('/v1/app-storage/upload-url', {
|
|
9
|
+
method: 'POST',
|
|
10
|
+
body: JSON.stringify({ fileName }),
|
|
11
|
+
});
|
|
12
|
+
if (!res.ok)
|
|
13
|
+
throw new Error('Upload URL request failed: ' + res.status);
|
|
14
|
+
return res.json();
|
|
15
|
+
}
|
|
16
|
+
export async function getDownloadUrl(key) {
|
|
17
|
+
const assembly = await getAssembly();
|
|
18
|
+
const res = await assembly.fetch('/v1/app-storage/download-url?key=' + encodeURIComponent(key));
|
|
19
|
+
if (!res.ok)
|
|
20
|
+
throw new Error('Download URL request failed: ' + res.status);
|
|
21
|
+
return res.json();
|
|
22
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { type AssemblyAPI } from '@assembly-js/node-sdk';
|
|
2
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
3
|
+
import { type ExchangedSession } from './session-exchange.js';
|
|
4
|
+
type AssemblyInput = {
|
|
5
|
+
token?: string;
|
|
6
|
+
} | string | undefined;
|
|
7
|
+
export declare function getSessionIdentity(): Promise<ExchangedSession | null>;
|
|
8
|
+
/**
|
|
9
|
+
* Thrown when a session token is required but missing, or was supplied but
|
|
10
|
+
* could not be decoded (almost always expiry or truncation). Enforced here in
|
|
11
|
+
* the foundation (rather than the SDK) so a missing or bad token never yields a
|
|
12
|
+
* client that silently falls back to the bare, unscoped workspace API key.
|
|
13
|
+
* Recognizable by `name`/`code` in `lib/auth.ts` (same-server, pre-redaction);
|
|
14
|
+
* across the Server Action boundary in production those are redacted, so
|
|
15
|
+
* `assembly-context.tsx` recovers via re-exchange-on-any-error rather than by
|
|
16
|
+
* matching this error.
|
|
17
|
+
*/
|
|
18
|
+
export declare class TokenInvalidError extends Error {
|
|
19
|
+
readonly code = "TOKEN_INVALID";
|
|
20
|
+
constructor(message?: string);
|
|
21
|
+
}
|
|
22
|
+
export declare function getAssembly(input?: AssemblyInput): Promise<AssemblyAPI>;
|
|
23
|
+
/**
|
|
24
|
+
* OFFLINE / BACKGROUND ONLY — cron jobs, webhook handlers, and any other code
|
|
25
|
+
* path where there is NO user in the loop and thus no session token or bearer
|
|
26
|
+
* cookie to read. Do NOT use this for user-facing requests; those resolve the
|
|
27
|
+
* viewer's own bearer via {@link getAssembly} / {@link getAssemblyForRequest}.
|
|
28
|
+
*
|
|
29
|
+
* Mints a short-lived, APP-scoped bearer from the app API key alone (client-
|
|
30
|
+
* credentials) and returns a bearer-only SDK client: the bearer is sent as
|
|
31
|
+
* `Authorization: Bearer` on every call and NO X-API-Key is ever sent. The app
|
|
32
|
+
* key is used solely to mint the bearer — it never travels on a data call.
|
|
33
|
+
*
|
|
34
|
+
* Throws (rather than returning null) on a missing app key or a failed mint:
|
|
35
|
+
* this is server-side background code, so there is no client to re-exchange and
|
|
36
|
+
* proceeding without a credential would be a silent failure.
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* // app/api/cron/digest/route.ts (Vercel cron / webhook handler)
|
|
40
|
+
* export async function GET() {
|
|
41
|
+
* const assembly = await getOfflineAssembly();
|
|
42
|
+
* const clients = await assembly.listClients();
|
|
43
|
+
* // …send the digest…
|
|
44
|
+
* return Response.json({ ok: true });
|
|
45
|
+
* }
|
|
46
|
+
*/
|
|
47
|
+
export declare function getOfflineAssembly(): Promise<AssemblyAPI>;
|
|
48
|
+
export declare function getAssemblyForRequest(req: NextRequest): Promise<AssemblyAPI | NextResponse>;
|
|
49
|
+
export {};
|
package/dist/assembly.js
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { assemblyApi } from '@assembly-js/node-sdk';
|
|
2
|
+
import { cookies, headers } from 'next/headers';
|
|
3
|
+
import { NextResponse } from 'next/server';
|
|
4
|
+
import { BEARER_COOKIE_NAME, BEARER_HEADER_NAME, decodeBearer, isSessionExchangeEnabled, } from './session-exchange.js';
|
|
5
|
+
import { mintBearerFromAppKey } from './mint-bearer.js';
|
|
6
|
+
// Trusted API origin for this stack, supplied by the build environment — NOT by
|
|
7
|
+
// a token claim. Empty in prod/shared-staging (the SDK's env default is already
|
|
8
|
+
// correct); set to a per-dev private stack's platform-api URL otherwise. Passed
|
|
9
|
+
// to the SDK as `baseUrl` so tokenless (cookie-only) calls reach the right stack.
|
|
10
|
+
const trustedBaseUrl = process.env.ASSEMBLY_API_BASE_URL || undefined;
|
|
11
|
+
// Reads the viewer bearer for the current request, independent of the exchange
|
|
12
|
+
// flag. First the header the proxy forwards on first load (the exchange just
|
|
13
|
+
// minted it; the Set-Cookie isn't yet visible), then the HttpOnly cookie on every
|
|
14
|
+
// later request. Returns undefined outside a request scope (e.g. build-time
|
|
15
|
+
// prerender) — not an error. Used both to resolve identity and, in
|
|
16
|
+
// getOfflineAssembly, to detect that a viewer is present.
|
|
17
|
+
async function readViewerBearer() {
|
|
18
|
+
try {
|
|
19
|
+
const fromProxy = (await headers()).get(BEARER_HEADER_NAME);
|
|
20
|
+
if (fromProxy && decodeBearer(fromProxy))
|
|
21
|
+
return fromProxy;
|
|
22
|
+
const fromCookie = (await cookies()).get(BEARER_COOKIE_NAME)?.value;
|
|
23
|
+
if (fromCookie && decodeBearer(fromCookie))
|
|
24
|
+
return fromCookie;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
// Viewer bearer for request-scoped auth: only consulted when the exchange flow is
|
|
32
|
+
// on (the legacy path carries identity in proxy headers, not a bearer cookie).
|
|
33
|
+
async function resolveRequestBearer() {
|
|
34
|
+
if (!isSessionExchangeEnabled())
|
|
35
|
+
return undefined;
|
|
36
|
+
return readViewerBearer();
|
|
37
|
+
}
|
|
38
|
+
// Legacy (exchange-off) viewer signals. Route handlers carry the raw session
|
|
39
|
+
// token; the proxy stamps the workspace id onto every resolved legacy render.
|
|
40
|
+
// Both are absent in true offline/background contexts (cron, webhooks).
|
|
41
|
+
const LEGACY_TOKEN_HEADER = 'x-assembly-token';
|
|
42
|
+
const LEGACY_WORKSPACE_HEADER = 'x-assembly-workspace-id';
|
|
43
|
+
// True when the current request carries viewer identity in EITHER auth mode.
|
|
44
|
+
// Exchange path: the mere PRESENCE of the bearer header/cookie — even a stale or
|
|
45
|
+
// undecodable one — marks a user-triggered request, so the guard must reject it
|
|
46
|
+
// rather than fall through to the app-scoped mint (decoding is not required here;
|
|
47
|
+
// only detecting that a viewer credential accompanied the request). Legacy path:
|
|
48
|
+
// the session-token header or the proxy-injected workspace-id header. Returns
|
|
49
|
+
// false outside a request scope (e.g. cron/webhook), where headers() throws. Used
|
|
50
|
+
// by getOfflineAssembly to refuse to run inside a user-facing request regardless
|
|
51
|
+
// of which auth mode the app is built with.
|
|
52
|
+
async function hasViewerRequest() {
|
|
53
|
+
try {
|
|
54
|
+
const [requestHeaders, requestCookies] = await Promise.all([
|
|
55
|
+
headers(),
|
|
56
|
+
cookies(),
|
|
57
|
+
]);
|
|
58
|
+
return Boolean(requestHeaders.get(BEARER_HEADER_NAME) ||
|
|
59
|
+
requestCookies.get(BEARER_COOKIE_NAME)?.value ||
|
|
60
|
+
requestHeaders.get(LEGACY_TOKEN_HEADER) ||
|
|
61
|
+
requestHeaders.get(LEGACY_WORKSPACE_HEADER));
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
// Server-only: the current user's identity, resolved from the bearer (the
|
|
68
|
+
// proxy-forwarded header on first load, else the HttpOnly cookie). This is how
|
|
69
|
+
// the app learns who the viewer is — workspace, client/company, internal user —
|
|
70
|
+
// without ever handling the raw session token. Used to seed the client provider
|
|
71
|
+
// and by server components. Returns null when unauthenticated or when the
|
|
72
|
+
// exchange is disabled (the legacy path carries identity in proxy headers).
|
|
73
|
+
export async function getSessionIdentity() {
|
|
74
|
+
const bearer = await resolveRequestBearer();
|
|
75
|
+
return bearer ? decodeBearer(bearer) : null;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Thrown when a session token is required but missing, or was supplied but
|
|
79
|
+
* could not be decoded (almost always expiry or truncation). Enforced here in
|
|
80
|
+
* the foundation (rather than the SDK) so a missing or bad token never yields a
|
|
81
|
+
* client that silently falls back to the bare, unscoped workspace API key.
|
|
82
|
+
* Recognizable by `name`/`code` in `lib/auth.ts` (same-server, pre-redaction);
|
|
83
|
+
* across the Server Action boundary in production those are redacted, so
|
|
84
|
+
* `assembly-context.tsx` recovers via re-exchange-on-any-error rather than by
|
|
85
|
+
* matching this error.
|
|
86
|
+
*/
|
|
87
|
+
export class TokenInvalidError extends Error {
|
|
88
|
+
code = 'TOKEN_INVALID';
|
|
89
|
+
constructor(message = 'Assembly session token is invalid or expired') {
|
|
90
|
+
super(message);
|
|
91
|
+
this.name = 'TokenInvalidError';
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
export async function getAssembly(input) {
|
|
95
|
+
const token = typeof input === 'string' ? input : input?.token;
|
|
96
|
+
// Exchange enabled ⇒ this is a bearer-capable app, and it authenticates via
|
|
97
|
+
// the exchanged bearer ALONE. The raw session token is a one-time bootstrap
|
|
98
|
+
// credential the proxy already consumed for the cookie; it is never a request
|
|
99
|
+
// credential here (any `input` token is ignored). The SDK is initialised with
|
|
100
|
+
// the bearer only — no apiKey, no token — so every call carries
|
|
101
|
+
// `Authorization: Bearer` and NEVER an X-API-Key. No bearer means the request
|
|
102
|
+
// is unauthenticated, or is replaying a consumed token that still decrypts —
|
|
103
|
+
// either way refuse, so the client re-exchanges instead of rendering.
|
|
104
|
+
if (isSessionExchangeEnabled()) {
|
|
105
|
+
const bearer = await resolveRequestBearer();
|
|
106
|
+
if (!bearer) {
|
|
107
|
+
throw new TokenInvalidError('Assembly bearer is required');
|
|
108
|
+
}
|
|
109
|
+
const assembly = await assemblyApi({ bearer, baseUrl: trustedBaseUrl });
|
|
110
|
+
const payload = await assembly.getTokenPayload?.();
|
|
111
|
+
if (!payload?.workspaceId) {
|
|
112
|
+
throw new TokenInvalidError();
|
|
113
|
+
}
|
|
114
|
+
// The published SDK's bearer-mode payload maps only the identity claims,
|
|
115
|
+
// so overlay the embed-context claims here to keep getTokenPayload()'s
|
|
116
|
+
// documented contract (payload.notificationId marks the Notification
|
|
117
|
+
// Center surface) intact for exchange-capable apps. Becomes a harmless
|
|
118
|
+
// no-op once the SDK maps these claims natively.
|
|
119
|
+
const session = decodeBearer(bearer);
|
|
120
|
+
if (session?.notificationId || session?.instanceId) {
|
|
121
|
+
const merged = {
|
|
122
|
+
...payload,
|
|
123
|
+
notificationId: session.notificationId,
|
|
124
|
+
instanceId: session.instanceId,
|
|
125
|
+
};
|
|
126
|
+
assembly.getTokenPayload = () => Promise.resolve(merged);
|
|
127
|
+
}
|
|
128
|
+
return assembly;
|
|
129
|
+
}
|
|
130
|
+
// Exchange disabled (legacy apps built before this feature, or a rollback):
|
|
131
|
+
// the raw session token is the credential and is sent as X-API-Key, exactly
|
|
132
|
+
// as before. A generated app has no valid unauthenticated call, so a missing
|
|
133
|
+
// token is invalid — never let the SDK fall back to the bare, unscoped
|
|
134
|
+
// workspace API key.
|
|
135
|
+
if (!token) {
|
|
136
|
+
throw new TokenInvalidError('Assembly session token is required');
|
|
137
|
+
}
|
|
138
|
+
const assembly = await assemblyApi({
|
|
139
|
+
apiKey: process.env.ASSEMBLY_API_KEY ?? '',
|
|
140
|
+
token,
|
|
141
|
+
baseUrl: trustedBaseUrl,
|
|
142
|
+
});
|
|
143
|
+
const payload = await assembly.getTokenPayload?.();
|
|
144
|
+
if (!payload?.workspaceId) {
|
|
145
|
+
throw new TokenInvalidError();
|
|
146
|
+
}
|
|
147
|
+
return assembly;
|
|
148
|
+
}
|
|
149
|
+
// Duplicated from lib/auth.ts to avoid a circular import.
|
|
150
|
+
function tokenExpiredResponse() {
|
|
151
|
+
return NextResponse.json({ error: 'Unauthorized', code: 'token_expired' }, { status: 401, headers: { 'x-assembly-token-expired': '1' } });
|
|
152
|
+
}
|
|
153
|
+
// Builds a bearer-only SDK client. The SDK's native `bearer` option sends
|
|
154
|
+
// Authorization: Bearer on every request — generated service methods and fetch
|
|
155
|
+
// alike — and, with no apiKey supplied, sends NO X-API-Key. Identity comes from
|
|
156
|
+
// the bearer's own signed claims (getTokenPayload() reads them); the API origin
|
|
157
|
+
// comes from the trusted build-env baseUrl, never a token/bearer claim.
|
|
158
|
+
async function getBearerAssembly(bearer) {
|
|
159
|
+
return assemblyApi({ bearer, baseUrl: trustedBaseUrl });
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* OFFLINE / BACKGROUND ONLY — cron jobs, webhook handlers, and any other code
|
|
163
|
+
* path where there is NO user in the loop and thus no session token or bearer
|
|
164
|
+
* cookie to read. Do NOT use this for user-facing requests; those resolve the
|
|
165
|
+
* viewer's own bearer via {@link getAssembly} / {@link getAssemblyForRequest}.
|
|
166
|
+
*
|
|
167
|
+
* Mints a short-lived, APP-scoped bearer from the app API key alone (client-
|
|
168
|
+
* credentials) and returns a bearer-only SDK client: the bearer is sent as
|
|
169
|
+
* `Authorization: Bearer` on every call and NO X-API-Key is ever sent. The app
|
|
170
|
+
* key is used solely to mint the bearer — it never travels on a data call.
|
|
171
|
+
*
|
|
172
|
+
* Throws (rather than returning null) on a missing app key or a failed mint:
|
|
173
|
+
* this is server-side background code, so there is no client to re-exchange and
|
|
174
|
+
* proceeding without a credential would be a silent failure.
|
|
175
|
+
*
|
|
176
|
+
* @example
|
|
177
|
+
* // app/api/cron/digest/route.ts (Vercel cron / webhook handler)
|
|
178
|
+
* export async function GET() {
|
|
179
|
+
* const assembly = await getOfflineAssembly();
|
|
180
|
+
* const clients = await assembly.listClients();
|
|
181
|
+
* // …send the digest…
|
|
182
|
+
* return Response.json({ ok: true });
|
|
183
|
+
* }
|
|
184
|
+
*/
|
|
185
|
+
export async function getOfflineAssembly() {
|
|
186
|
+
// Boundary: refuse to run inside a user request that carries viewer identity,
|
|
187
|
+
// in EITHER auth mode — the exchange bearer OR legacy request identity. The
|
|
188
|
+
// app-scoped bearer acts as the app/installer, so using it on a user-facing
|
|
189
|
+
// path would bypass the viewer's own scoped claims. User-facing routes must use
|
|
190
|
+
// getAssembly() / getAssemblyForRequest() instead.
|
|
191
|
+
if (await hasViewerRequest()) {
|
|
192
|
+
throw new Error('getOfflineAssembly must not be called within a user request (viewer identity is present); use getAssembly() or getAssemblyForRequest() for user-facing routes');
|
|
193
|
+
}
|
|
194
|
+
if (!process.env.ASSEMBLY_API_KEY) {
|
|
195
|
+
throw new Error('getOfflineAssembly: ASSEMBLY_API_KEY is not set — cannot mint an app-scoped bearer');
|
|
196
|
+
}
|
|
197
|
+
const minted = await mintBearerFromAppKey();
|
|
198
|
+
if (!minted) {
|
|
199
|
+
throw new Error('getOfflineAssembly: failed to mint an app-scoped bearer from the app API key');
|
|
200
|
+
}
|
|
201
|
+
return getBearerAssembly(minted.bearer);
|
|
202
|
+
}
|
|
203
|
+
// Returns a request-scoped SDK client authenticated for the current user.
|
|
204
|
+
//
|
|
205
|
+
// Exchange enabled: bearer-only. A valid bearer (proxy header or cookie) yields
|
|
206
|
+
// a client that carries Authorization: Bearer on every request; a missing or
|
|
207
|
+
// undecodable bearer yields a 401 NextResponse so the client re-exchanges via
|
|
208
|
+
// the x-assembly-token-expired convention. There is no session-token fallback.
|
|
209
|
+
// Exchange disabled: the legacy session-token (X-API-Key) path.
|
|
210
|
+
export async function getAssemblyForRequest(req) {
|
|
211
|
+
if (isSessionExchangeEnabled()) {
|
|
212
|
+
// Bearer-only: the proxy-forwarded header on first load, else the HttpOnly
|
|
213
|
+
// cookie. Its signed claims carry identity for SDK init. No bearer (or an
|
|
214
|
+
// undecodable/expired one) ⇒ 401 marker so the client re-exchanges; there
|
|
215
|
+
// is no X-API-Key / session-token fallback for a bearer-capable app.
|
|
216
|
+
const bearer = req.headers.get(BEARER_HEADER_NAME) ??
|
|
217
|
+
req.cookies.get(BEARER_COOKIE_NAME)?.value;
|
|
218
|
+
if (!bearer || !decodeBearer(bearer)) {
|
|
219
|
+
return tokenExpiredResponse();
|
|
220
|
+
}
|
|
221
|
+
return getBearerAssembly(bearer);
|
|
222
|
+
}
|
|
223
|
+
// Legacy (exchange disabled): authenticate from the session-token header,
|
|
224
|
+
// sent as X-API-Key. Missing token ⇒ 401 marker.
|
|
225
|
+
const headerToken = req.headers.get('x-assembly-token');
|
|
226
|
+
if (!headerToken) {
|
|
227
|
+
return tokenExpiredResponse();
|
|
228
|
+
}
|
|
229
|
+
try {
|
|
230
|
+
return await getAssembly(headerToken);
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
return tokenExpiredResponse();
|
|
234
|
+
}
|
|
235
|
+
}
|
package/dist/auth.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { AssemblyAPI } from '@assembly-js/node-sdk';
|
|
2
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
3
|
+
export interface TokenPayload {
|
|
4
|
+
workspaceId: string;
|
|
5
|
+
internalUserId?: string;
|
|
6
|
+
clientId?: string;
|
|
7
|
+
companyId?: string;
|
|
8
|
+
notificationId?: string;
|
|
9
|
+
instanceId?: string;
|
|
10
|
+
baseUrl?: string;
|
|
11
|
+
tokenId?: string;
|
|
12
|
+
}
|
|
13
|
+
export declare function requireAuth(req: NextRequest): Promise<TokenPayload | NextResponse>;
|
|
14
|
+
export declare function requireCompanyScope(payload: TokenPayload, resource: {
|
|
15
|
+
companyId: string | null;
|
|
16
|
+
clientId?: string | null;
|
|
17
|
+
}, tokenOrAssembly?: string | AssemblyAPI): Promise<NextResponse | null>;
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server';
|
|
2
|
+
import { getAssembly } from './assembly.js';
|
|
3
|
+
import { BEARER_COOKIE_NAME, BEARER_HEADER_NAME, decodeBearer, isSessionExchangeEnabled, } from './session-exchange.js';
|
|
4
|
+
// Detect an expired/undecryptable-token error from the SDK by shape rather than
|
|
5
|
+
// by importing a concrete class — the foundation builds against whatever SDK
|
|
6
|
+
// version is published, which may not export the error type.
|
|
7
|
+
function isTokenInvalid(err) {
|
|
8
|
+
if (!err || typeof err !== 'object')
|
|
9
|
+
return false;
|
|
10
|
+
const e = err;
|
|
11
|
+
return e.name === 'TokenInvalidError' || e.code === 'TOKEN_INVALID';
|
|
12
|
+
}
|
|
13
|
+
function tokenExpiredResponse() {
|
|
14
|
+
return NextResponse.json({ error: 'Unauthorized', code: 'token_expired' }, { status: 401, headers: { 'x-assembly-token-expired': '1' } });
|
|
15
|
+
}
|
|
16
|
+
// Resolves the current viewer's identity for a route handler.
|
|
17
|
+
// Exchange on: bearer-only — decodes the bearer (proxy-forwarded header on first
|
|
18
|
+
// load, else the HttpOnly cookie); a missing/undecodable bearer returns a 401
|
|
19
|
+
// with the x-assembly-token-expired marker so the client re-exchanges. There is
|
|
20
|
+
// no session-token fallback for a bearer-capable app.
|
|
21
|
+
// Exchange off (legacy): reads and decodes the x-assembly-token header.
|
|
22
|
+
export async function requireAuth(req) {
|
|
23
|
+
if (isSessionExchangeEnabled()) {
|
|
24
|
+
const bearer = req.headers.get(BEARER_HEADER_NAME) ??
|
|
25
|
+
req.cookies.get(BEARER_COOKIE_NAME)?.value;
|
|
26
|
+
const session = bearer ? decodeBearer(bearer) : null;
|
|
27
|
+
if (!session) {
|
|
28
|
+
return tokenExpiredResponse();
|
|
29
|
+
}
|
|
30
|
+
return {
|
|
31
|
+
workspaceId: session.workspaceId,
|
|
32
|
+
clientId: session.clientId,
|
|
33
|
+
internalUserId: session.internalUserId,
|
|
34
|
+
companyId: session.companyId,
|
|
35
|
+
notificationId: session.notificationId,
|
|
36
|
+
instanceId: session.instanceId,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
const token = req.headers.get('x-assembly-token');
|
|
40
|
+
if (!token) {
|
|
41
|
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
42
|
+
}
|
|
43
|
+
try {
|
|
44
|
+
const assembly = await getAssembly(token);
|
|
45
|
+
// getTokenPayload is optional on the SDK instance; a falsy payload means
|
|
46
|
+
// the token didn't resolve — treat it as expired/invalid.
|
|
47
|
+
const payload = await assembly.getTokenPayload?.();
|
|
48
|
+
if (!payload) {
|
|
49
|
+
return tokenExpiredResponse();
|
|
50
|
+
}
|
|
51
|
+
if (!payload.workspaceId) {
|
|
52
|
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
53
|
+
}
|
|
54
|
+
return payload;
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
if (isTokenInvalid(err)) {
|
|
58
|
+
return tokenExpiredResponse();
|
|
59
|
+
}
|
|
60
|
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// Asserts the viewer can access a resource scoped to a company OR to a client.
|
|
64
|
+
// Internal users always pass. For a company-scoped resource (companyId set),
|
|
65
|
+
// clients must belong to that company: if payload.companyId is present it must
|
|
66
|
+
// match; otherwise membership is confirmed by fetching the client record.
|
|
67
|
+
// For a client-scoped resource (companyId null), pass the resource's clientId
|
|
68
|
+
// so the owning client can be matched against the viewer — without it a
|
|
69
|
+
// client-scoped record 404s for the very client it is assigned to.
|
|
70
|
+
// Pass a session token string (legacy path) or a bearer-authenticated AssemblyAPI
|
|
71
|
+
// instance from getAssemblyForRequest (bearer path) to enable the membership lookup.
|
|
72
|
+
// Returns null on success, or a 403/404 NextResponse on mismatch.
|
|
73
|
+
export async function requireCompanyScope(payload, resource, tokenOrAssembly) {
|
|
74
|
+
if (payload.internalUserId)
|
|
75
|
+
return null;
|
|
76
|
+
if (!payload.clientId) {
|
|
77
|
+
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
|
78
|
+
}
|
|
79
|
+
if (!resource.companyId) {
|
|
80
|
+
// Client-scoped resource: the viewer passes iff it is theirs.
|
|
81
|
+
return resource.clientId && resource.clientId === payload.clientId
|
|
82
|
+
? null
|
|
83
|
+
: NextResponse.json({ error: 'Not found' }, { status: 404 });
|
|
84
|
+
}
|
|
85
|
+
if (payload.companyId) {
|
|
86
|
+
return payload.companyId === resource.companyId
|
|
87
|
+
? null
|
|
88
|
+
: NextResponse.json({ error: 'Not found' }, { status: 404 });
|
|
89
|
+
}
|
|
90
|
+
// No companyId in payload: client has multiple companies, verify membership.
|
|
91
|
+
if (!tokenOrAssembly) {
|
|
92
|
+
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
// Bearer path: pass the bearer-authenticated client from getAssemblyForRequest.
|
|
96
|
+
// Legacy (exchange-off) path: pass the session token string, from which we
|
|
97
|
+
// build the client. Either way a service method authenticates correctly —
|
|
98
|
+
// the SDK sends Authorization: Bearer (or the legacy X-API-Key) on every call.
|
|
99
|
+
const assembly = typeof tokenOrAssembly === 'string'
|
|
100
|
+
? await getAssembly(tokenOrAssembly)
|
|
101
|
+
: tokenOrAssembly;
|
|
102
|
+
const client = await assembly.retrieveClient({ id: payload.clientId });
|
|
103
|
+
const ids = client.companyIds ?? (client.companyId ? [client.companyId] : []);
|
|
104
|
+
return ids.includes(resource.companyId)
|
|
105
|
+
? null
|
|
106
|
+
: NextResponse.json({ error: 'Not found' }, { status: 404 });
|
|
107
|
+
}
|
|
108
|
+
catch (err) {
|
|
109
|
+
// Distinguish 'client not found' from infrastructure failures so callers
|
|
110
|
+
// can retry on 500 rather than treating every error as a permanent 404.
|
|
111
|
+
// Propagate 401 as a token-expired signal so the client can re-exchange
|
|
112
|
+
// rather than receiving a confusing 500.
|
|
113
|
+
const status = typeof err === 'object' && err !== null ? err.status : undefined;
|
|
114
|
+
if (status === 401)
|
|
115
|
+
return tokenExpiredResponse();
|
|
116
|
+
return NextResponse.json({ error: status === 404 ? 'Not found' : 'Internal server error' }, { status: status === 404 ? 404 : 500 });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Expands clients into one row per company so each duplicated row can carry its
|
|
3
|
+
* own `AvatarSub` company badge.
|
|
4
|
+
*
|
|
5
|
+
* Generic over the caller's row type because callers render reshaped client
|
|
6
|
+
* records (joined, picked, extended) rather than raw SDK objects; the constraint
|
|
7
|
+
* names the only field this reads.
|
|
8
|
+
*
|
|
9
|
+
* `displayCompanyId` is null for clients with 0 or 1 companies: a single
|
|
10
|
+
* membership needs no disambiguation, so those rows render without a badge.
|
|
11
|
+
*/
|
|
12
|
+
export declare function expandClientsByCompany<T extends {
|
|
13
|
+
companyIds?: string[];
|
|
14
|
+
}>(clients: T[]): Array<T & {
|
|
15
|
+
displayCompanyId: string | null;
|
|
16
|
+
}>;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Expands clients into one row per company so each duplicated row can carry its
|
|
3
|
+
* own `AvatarSub` company badge.
|
|
4
|
+
*
|
|
5
|
+
* Generic over the caller's row type because callers render reshaped client
|
|
6
|
+
* records (joined, picked, extended) rather than raw SDK objects; the constraint
|
|
7
|
+
* names the only field this reads.
|
|
8
|
+
*
|
|
9
|
+
* `displayCompanyId` is null for clients with 0 or 1 companies: a single
|
|
10
|
+
* membership needs no disambiguation, so those rows render without a badge.
|
|
11
|
+
*/
|
|
12
|
+
export function expandClientsByCompany(clients) {
|
|
13
|
+
return clients.flatMap((client) => {
|
|
14
|
+
const companyIds = client.companyIds ?? [];
|
|
15
|
+
if (companyIds.length < 2) {
|
|
16
|
+
return [{ ...client, displayCompanyId: null }];
|
|
17
|
+
}
|
|
18
|
+
return companyIds.map((companyId) => ({
|
|
19
|
+
...client,
|
|
20
|
+
displayCompanyId: companyId,
|
|
21
|
+
}));
|
|
22
|
+
});
|
|
23
|
+
}
|
package/dist/client.d.ts
CHANGED
|
@@ -1,2 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
export
|
|
1
|
+
export * from './session-exchange.js';
|
|
2
|
+
export * from './network-capture.js';
|
|
3
|
+
export * from './client-companies.js';
|
|
4
|
+
export * from './theme.js';
|
|
5
|
+
export * from './resource-resolution.js';
|
package/dist/client.js
CHANGED
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
// Client-safe entry point: modules here must never touch secrets or
|
|
2
2
|
// `next/headers`, because they can be pulled into a browser bundle.
|
|
3
3
|
//
|
|
4
|
-
//
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
// `session-exchange` qualifies despite reading `process.env`: the read is a
|
|
5
|
+
// string compare against a build-time-inlined flag rather than a secret, and its
|
|
6
|
+
// JWT decode is written on Web APIs only (atob/TextDecoder) so the same module
|
|
7
|
+
// also runs in the Edge middleware runtime that proxy.ts uses.
|
|
8
|
+
export * from './session-exchange.js';
|
|
9
|
+
export * from './network-capture.js';
|
|
10
|
+
export * from './client-companies.js';
|
|
11
|
+
export * from './theme.js';
|
|
12
|
+
// Pure data-shaping over resolver responses — no imports, no secrets, no
|
|
13
|
+
// next/headers. It lives here rather than in ./server because <ResourcePicker>
|
|
14
|
+
// is a 'use client' component: today it imports only the types, which are erased
|
|
15
|
+
// at compile time, but a value import from ./server would trip the server-only
|
|
16
|
+
// guard for no reason.
|
|
17
|
+
export * from './resource-resolution.js';
|
package/dist/labels.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export interface WorkspaceLabels {
|
|
2
|
+
clientOne: string;
|
|
3
|
+
clientMany: string;
|
|
4
|
+
companyOne: string;
|
|
5
|
+
companyMany: string;
|
|
6
|
+
}
|
|
7
|
+
declare const DEFAULTS: WorkspaceLabels;
|
|
8
|
+
export declare function getLabels(): Promise<WorkspaceLabels>;
|
|
9
|
+
export { DEFAULTS as DEFAULT_LABELS };
|
package/dist/labels.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { headers } from 'next/headers';
|
|
2
|
+
const DEFAULTS = {
|
|
3
|
+
clientOne: 'Client',
|
|
4
|
+
clientMany: 'Clients',
|
|
5
|
+
companyOne: 'Company',
|
|
6
|
+
companyMany: 'Companies',
|
|
7
|
+
};
|
|
8
|
+
export async function getLabels() {
|
|
9
|
+
const hdrs = await headers();
|
|
10
|
+
return {
|
|
11
|
+
clientOne: hdrs.get('x-assembly-label-client-one') || DEFAULTS.clientOne,
|
|
12
|
+
clientMany: hdrs.get('x-assembly-label-client-many') || DEFAULTS.clientMany,
|
|
13
|
+
companyOne: hdrs.get('x-assembly-label-company-one') || DEFAULTS.companyOne,
|
|
14
|
+
companyMany: hdrs.get('x-assembly-label-company-many') || DEFAULTS.companyMany,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
export { DEFAULTS as DEFAULT_LABELS };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type ExchangedSession, type MintFailure } from './session-exchange.js';
|
|
2
|
+
export interface MintedBearer {
|
|
3
|
+
bearer: string;
|
|
4
|
+
bearerExpiresAt: number;
|
|
5
|
+
session: ExchangedSession;
|
|
6
|
+
}
|
|
7
|
+
export type MintOutcome = ({
|
|
8
|
+
ok: true;
|
|
9
|
+
} & MintedBearer) | {
|
|
10
|
+
ok: false;
|
|
11
|
+
reason: MintFailure;
|
|
12
|
+
};
|
|
13
|
+
export declare function mintBearerFromSessionToken(token: string): Promise<MintOutcome>;
|
|
14
|
+
export interface MintedAppBearer {
|
|
15
|
+
bearer: string;
|
|
16
|
+
bearerExpiresAt: number;
|
|
17
|
+
}
|
|
18
|
+
export declare function mintBearerFromAppKey(): Promise<MintedAppBearer | null>;
|