@assembly-js/app-foundation 0.0.0 → 0.0.1-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/CLAUDE.md +1119 -0
  2. package/dist/api-errors.d.ts +5 -0
  3. package/dist/api-errors.js +33 -0
  4. package/dist/app-storage.d.ts +7 -0
  5. package/dist/app-storage.js +22 -0
  6. package/dist/assembly.d.ts +49 -0
  7. package/dist/assembly.js +235 -0
  8. package/dist/auth.d.ts +17 -0
  9. package/dist/auth.js +118 -0
  10. package/dist/client-companies.d.ts +16 -0
  11. package/dist/client-companies.js +23 -0
  12. package/dist/client.d.ts +5 -2
  13. package/dist/client.js +14 -3
  14. package/dist/labels.d.ts +9 -0
  15. package/dist/labels.js +17 -0
  16. package/dist/mint-bearer.d.ts +18 -0
  17. package/dist/mint-bearer.js +152 -0
  18. package/dist/nango.d.ts +174 -0
  19. package/dist/nango.js +430 -0
  20. package/dist/network-capture.d.ts +36 -0
  21. package/dist/network-capture.js +473 -0
  22. package/dist/params.d.ts +3 -0
  23. package/dist/params.js +20 -0
  24. package/dist/resource-resolution.d.ts +89 -0
  25. package/dist/resource-resolution.js +142 -0
  26. package/dist/server.d.ts +9 -2
  27. package/dist/server.js +15 -5
  28. package/dist/session-exchange.d.ts +20 -0
  29. package/dist/session-exchange.js +101 -0
  30. package/dist/theme.d.ts +5 -0
  31. package/dist/theme.js +20 -0
  32. package/package.json +9 -3
  33. package/skills/assembly-ai/SKILL.md +133 -0
  34. package/skills/assembly-app-bridge-header/SKILL.md +88 -0
  35. package/skills/assembly-app-bridge-toast/SKILL.md +79 -0
  36. package/skills/assembly-avatar-sub/SKILL.md +103 -0
  37. package/skills/assembly-client-users/SKILL.md +45 -0
  38. package/skills/assembly-clients-companies/SKILL.md +143 -0
  39. package/skills/assembly-companies/SKILL.md +45 -0
  40. package/skills/assembly-custom-fields/SKILL.md +86 -0
  41. package/skills/assembly-db-transactions/SKILL.md +79 -0
  42. package/skills/assembly-dev-server-logs/SKILL.md +26 -0
  43. package/skills/assembly-forms/SKILL.md +103 -0
  44. package/skills/assembly-internal-users/SKILL.md +69 -0
  45. package/skills/assembly-mutations/SKILL.md +58 -0
  46. package/skills/assembly-native-resources/SKILL.md +117 -0
  47. package/skills/assembly-route-auth/SKILL.md +223 -0
  48. package/skills/assembly-route-errors/SKILL.md +81 -0
  49. package/skills/assembly-semantic-colors/SKILL.md +76 -0
  50. package/skills/assembly-session-token/SKILL.md +153 -0
  51. package/skills/assembly-storage/SKILL.md +157 -0
  52. package/skills/assembly-user-company-selector/SKILL.md +98 -0
  53. package/skills/assembly-workspace-labels/SKILL.md +50 -0
  54. 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,7 @@
1
+ export declare function getUploadUrl(fileName: string): Promise<{
2
+ key: string;
3
+ uploadUrl: string;
4
+ }>;
5
+ export declare function getDownloadUrl(key: string): Promise<{
6
+ downloadUrl: string;
7
+ }>;
@@ -0,0 +1,22 @@
1
+ import { getAssembly } from './assembly';
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';
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 {};
@@ -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';
5
+ import { mintBearerFromAppKey } from './mint-bearer';
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';
3
+ import { BEARER_COOKIE_NAME, BEARER_HEADER_NAME, decodeBearer, isSessionExchangeEnabled, } from './session-exchange';
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
- /** Version of this package, for provenance checks in the sandbox. */
2
- export declare const APP_FOUNDATION_VERSION = "0.0.0";
1
+ export * from './session-exchange';
2
+ export * from './network-capture';
3
+ export * from './client-companies';
4
+ export * from './theme';
5
+ export * from './resource-resolution';
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
- // Empty for now — see ./server.ts.
5
- /** Version of this package, for provenance checks in the sandbox. */
6
- export const APP_FOUNDATION_VERSION = '0.0.0';
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';
9
+ export * from './network-capture';
10
+ export * from './client-companies';
11
+ export * from './theme';
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';
@@ -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';
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>;