@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,142 @@
|
|
|
1
|
+
// Transport-free core of provider resource resolution: the option shape, the
|
|
2
|
+
// page normalizer, and the paged search. No imports, so it is safe in both
|
|
3
|
+
// server code (lib/nango.ts) and client components (ResourcePicker) — keep it
|
|
4
|
+
// that way; anything that talks to the broker belongs in lib/nango.ts.
|
|
5
|
+
// Cursor keys used by the resolver actions in the catalog, most common first.
|
|
6
|
+
// 'offset' and 'cursor' can echo the request's own cursor back (Airtable,
|
|
7
|
+
// Dropbox); the requestCursor guard in normalizeResourcePage handles that.
|
|
8
|
+
const DEFAULT_CURSOR_KEYS = [
|
|
9
|
+
'next_cursor',
|
|
10
|
+
'nextCursor',
|
|
11
|
+
'nextPageToken',
|
|
12
|
+
'next_page_token',
|
|
13
|
+
'nextOffset',
|
|
14
|
+
'offset',
|
|
15
|
+
'cursor',
|
|
16
|
+
];
|
|
17
|
+
function getPath(value, path) {
|
|
18
|
+
let current = value;
|
|
19
|
+
for (const key of path.split('.')) {
|
|
20
|
+
if (current === null || typeof current !== 'object')
|
|
21
|
+
return undefined;
|
|
22
|
+
current = current[key];
|
|
23
|
+
}
|
|
24
|
+
return current;
|
|
25
|
+
}
|
|
26
|
+
function asIdOrLabel(value) {
|
|
27
|
+
if (typeof value === 'string')
|
|
28
|
+
return value.trim();
|
|
29
|
+
if (typeof value === 'number' && Number.isFinite(value))
|
|
30
|
+
return String(value);
|
|
31
|
+
return '';
|
|
32
|
+
}
|
|
33
|
+
function extractItems(raw, itemsPath) {
|
|
34
|
+
if (itemsPath) {
|
|
35
|
+
const items = getPath(raw, itemsPath);
|
|
36
|
+
return Array.isArray(items) ? items : [];
|
|
37
|
+
}
|
|
38
|
+
if (Array.isArray(raw))
|
|
39
|
+
return raw;
|
|
40
|
+
if (raw !== null && typeof raw === 'object') {
|
|
41
|
+
for (const value of Object.values(raw)) {
|
|
42
|
+
if (Array.isArray(value))
|
|
43
|
+
return value;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
48
|
+
function extractNextCursor(raw, fields, requestCursor) {
|
|
49
|
+
const paths = fields.nextCursor ? [fields.nextCursor] : DEFAULT_CURSOR_KEYS;
|
|
50
|
+
for (const path of paths) {
|
|
51
|
+
const cursor = asIdOrLabel(getPath(raw, path));
|
|
52
|
+
// A cursor identical to the one just used means the provider echoed the
|
|
53
|
+
// request back (Airtable's offset does this on the last page) — following
|
|
54
|
+
// it would loop forever, so treat it as the end of the list.
|
|
55
|
+
if (cursor && cursor !== requestCursor)
|
|
56
|
+
return cursor;
|
|
57
|
+
}
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Normalize one raw resolver-action result into `{ id, label }` options plus
|
|
62
|
+
* the next-page cursor. Items without a usable id are dropped; a missing label
|
|
63
|
+
* falls back to the id. When `query` is given, options are filtered by
|
|
64
|
+
* case-insensitive label substring — the client-side fallback for resolver
|
|
65
|
+
* actions with no server-side search (it narrows each fetched page, so paging
|
|
66
|
+
* through "Load more" still walks the full list).
|
|
67
|
+
*/
|
|
68
|
+
export function normalizeResourcePage(raw, fields, opts = {}) {
|
|
69
|
+
const needle = opts.query?.trim().toLowerCase();
|
|
70
|
+
const options = [];
|
|
71
|
+
for (const item of extractItems(raw, fields.items)) {
|
|
72
|
+
if (item === null || typeof item !== 'object')
|
|
73
|
+
continue;
|
|
74
|
+
const id = asIdOrLabel(getPath(item, fields.id));
|
|
75
|
+
if (!id)
|
|
76
|
+
continue;
|
|
77
|
+
const label = asIdOrLabel(getPath(item, fields.label)) || id;
|
|
78
|
+
if (needle && !label.toLowerCase().includes(needle))
|
|
79
|
+
continue;
|
|
80
|
+
options.push({ id, label });
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
options,
|
|
84
|
+
nextCursor: extractNextCursor(raw, fields, opts.requestCursor),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
const FIND_RESOURCE_MAX_PAGES = 100;
|
|
88
|
+
/**
|
|
89
|
+
* Walk normalized pages until an option matches. The generic half of "resolve
|
|
90
|
+
* a name to an id": the caller's `fetchPage` owns transport and provider
|
|
91
|
+
* quirks (retries, scope fallbacks); this owns the cursor walk, the page
|
|
92
|
+
* bound, and the echoed-cursor stop.
|
|
93
|
+
*/
|
|
94
|
+
export async function findResource(fetchPage, match, opts = {}) {
|
|
95
|
+
const maxPages = opts.maxPages ?? FIND_RESOURCE_MAX_PAGES;
|
|
96
|
+
let cursor;
|
|
97
|
+
for (let page = 0; page < maxPages; page++) {
|
|
98
|
+
const result = await fetchPage(cursor);
|
|
99
|
+
const hit = result.options.find(match);
|
|
100
|
+
if (hit)
|
|
101
|
+
return hit;
|
|
102
|
+
if (!result.nextCursor || result.nextCursor === cursor)
|
|
103
|
+
return null;
|
|
104
|
+
cursor = result.nextCursor;
|
|
105
|
+
}
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Small process-lifetime memo with a TTL and a size cap, for resolver results
|
|
110
|
+
* that are safe to share across requests. A deployed app serves exactly one
|
|
111
|
+
* workspace and connections are workspace-scoped, so entries never leak across
|
|
112
|
+
* tenants; the TTL just keeps freshly created resources from being invisible
|
|
113
|
+
* for long.
|
|
114
|
+
*/
|
|
115
|
+
export class TtlCache {
|
|
116
|
+
maxEntries;
|
|
117
|
+
entries = new Map();
|
|
118
|
+
constructor(maxEntries) {
|
|
119
|
+
this.maxEntries = maxEntries;
|
|
120
|
+
}
|
|
121
|
+
get(key) {
|
|
122
|
+
const entry = this.entries.get(key);
|
|
123
|
+
if (!entry)
|
|
124
|
+
return undefined;
|
|
125
|
+
if (Date.now() >= entry.expiresAt) {
|
|
126
|
+
this.entries.delete(key);
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
return entry.value;
|
|
130
|
+
}
|
|
131
|
+
set(key, value, ttlMs) {
|
|
132
|
+
if (ttlMs <= 0)
|
|
133
|
+
return;
|
|
134
|
+
if (!this.entries.has(key) && this.entries.size >= this.maxEntries) {
|
|
135
|
+
// Evict the oldest insertion — enough LRU-ness for a picker cache.
|
|
136
|
+
const oldest = this.entries.keys().next().value;
|
|
137
|
+
if (oldest !== undefined)
|
|
138
|
+
this.entries.delete(oldest);
|
|
139
|
+
}
|
|
140
|
+
this.entries.set(key, { value, expiresAt: Date.now() + ttlMs });
|
|
141
|
+
}
|
|
142
|
+
}
|
package/dist/server.d.ts
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
1
|
import 'server-only';
|
|
2
|
-
|
|
3
|
-
export
|
|
2
|
+
export * from './assembly.js';
|
|
3
|
+
export * from './auth.js';
|
|
4
|
+
export * from './mint-bearer.js';
|
|
5
|
+
export * from './nango.js';
|
|
6
|
+
export * from './app-storage.js';
|
|
7
|
+
export * from './labels.js';
|
|
8
|
+
export * from './api-errors.js';
|
|
9
|
+
export * from './params.js';
|
|
10
|
+
export * from './client.js';
|
package/dist/server.js
CHANGED
|
@@ -2,9 +2,19 @@
|
|
|
2
2
|
// here; the explicit ./server subpath (rather than the `react-server` export
|
|
3
3
|
// condition) is what makes a client bundle importing them fail at build time
|
|
4
4
|
// instead of silently shipping ASSEMBLY_API_KEY to the browser.
|
|
5
|
-
//
|
|
6
|
-
// Empty for now: this 0.0.0 exists to prove the publish path end to end before
|
|
7
|
-
// any module moves into it. Real helpers arrive with the migration.
|
|
8
5
|
import 'server-only';
|
|
9
|
-
|
|
10
|
-
export
|
|
6
|
+
export * from './assembly.js';
|
|
7
|
+
export * from './auth.js';
|
|
8
|
+
export * from './mint-bearer.js';
|
|
9
|
+
export * from './nango.js';
|
|
10
|
+
export * from './app-storage.js';
|
|
11
|
+
export * from './labels.js';
|
|
12
|
+
// Returns NextResponse, so route handlers only — but nothing here reads a
|
|
13
|
+
// secret, which is why a client file may still `import type` from them.
|
|
14
|
+
export * from './api-errors.js';
|
|
15
|
+
export * from './params.js';
|
|
16
|
+
// The client-safe surface is re-exported so a server file needs one import, not
|
|
17
|
+
// two. Importing './client' rather than the package's own ./client subpath keeps
|
|
18
|
+
// this a single module graph — a self-import would resolve through node_modules
|
|
19
|
+
// and duplicate every constant.
|
|
20
|
+
export * from './client.js';
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export declare const BEARER_COOKIE_NAME = "x-assembly-bearer";
|
|
2
|
+
export declare const BEARER_TTL_MS: number;
|
|
3
|
+
export type MintFailure = 'rejected' | 'unavailable';
|
|
4
|
+
export declare function classifyExchangeFailure(status: number): MintFailure;
|
|
5
|
+
export declare function serializeBearerCookie(value: string, maxAgeSeconds: number, partitioned: boolean): string;
|
|
6
|
+
export declare const BEARER_HEADER_NAME = "x-assembly-request-bearer";
|
|
7
|
+
export declare const APP_CSRF_HEADER = "x-assembly-app";
|
|
8
|
+
export declare function isSessionExchangeEnabled(): boolean;
|
|
9
|
+
export interface ExchangedSession {
|
|
10
|
+
workspaceId: string;
|
|
11
|
+
clientId?: string;
|
|
12
|
+
internalUserId?: string;
|
|
13
|
+
companyId?: string;
|
|
14
|
+
notificationId?: string;
|
|
15
|
+
instanceId?: string;
|
|
16
|
+
/** Session token this bearer was exchanged from — lets the proxy tell a
|
|
17
|
+
* reload of an already-exchanged token apart from a new open. */
|
|
18
|
+
sessionId?: string;
|
|
19
|
+
}
|
|
20
|
+
export declare function decodeBearer(bearer: string): ExchangedSession | null;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// Stateless bearer helpers for the token-exchange flow (AI-260).
|
|
2
|
+
// The proxy exchanges the session token once and sets the returned bearer as an
|
|
3
|
+
// HttpOnly cookie. That bearer is a signed JWT carrying the caller's identity as
|
|
4
|
+
// claims, so requireAuth()/getAssemblyForRequest() resolve identity by DECODING
|
|
5
|
+
// the cookie — no server-side cache. A cache can't work here anyway: on Vercel
|
|
6
|
+
// the middleware that would populate it and the route handlers that read it run
|
|
7
|
+
// in separate runtimes/instances and never share memory.
|
|
8
|
+
//
|
|
9
|
+
// decodeBearer does NOT verify the signature. The platform-api authorizer
|
|
10
|
+
// re-validates the token's signature and scope on every real API call, which is
|
|
11
|
+
// the security boundary; these decoded claims are advisory to the app (SDK init,
|
|
12
|
+
// early authz rejects) and never the sole gate.
|
|
13
|
+
export const BEARER_COOKIE_NAME = 'x-assembly-bearer';
|
|
14
|
+
export const BEARER_TTL_MS = 60 * 60 * 1_000; // 60 minutes, matches bearer TTL
|
|
15
|
+
// The only statuses that prove the session token ITSELF is spent or invalid:
|
|
16
|
+
// 404 (unknown, or already consumed), 401 (expired, or minted for another app)
|
|
17
|
+
// and 400 (malformed, or no user on the session).
|
|
18
|
+
const TOKEN_REFUSED_STATUSES = new Set([400, 401, 404]);
|
|
19
|
+
// Classifies a non-2xx /v1/session response. Anything outside the allowlist says
|
|
20
|
+
// nothing about the token and usually leaves it unspent — a 403 is our own API
|
|
21
|
+
// key failing, a 429 is the platform throttle firing before the handler, a 5xx is
|
|
22
|
+
// platform-api unable to complete (its errTokenIssueFailed path deliberately
|
|
23
|
+
// keeps the token intact for a retry). Defaulting those to 'unavailable' means an
|
|
24
|
+
// unrecognised status is never mistaken for a replay.
|
|
25
|
+
export function classifyExchangeFailure(status) {
|
|
26
|
+
return TOKEN_REFUSED_STATUSES.has(status) ? 'rejected' : 'unavailable';
|
|
27
|
+
}
|
|
28
|
+
// Serialize a `Set-Cookie` header value for the bearer cookie. Callers append
|
|
29
|
+
// these as raw headers (response.headers.append) rather than response.cookies
|
|
30
|
+
// .set(): the partitioned and unpartitioned records share the cookie name, and
|
|
31
|
+
// ResponseCookies keys its store by name, so a second set() replaces the first
|
|
32
|
+
// and collapses to a single header. During the CHIPS rollout we must emit BOTH
|
|
33
|
+
// the partitioned record and a clear for the legacy unpartitioned one, so we
|
|
34
|
+
// serialize the headers ourselves. `''` value + maxAgeSeconds 0 clears a record.
|
|
35
|
+
export function serializeBearerCookie(value, maxAgeSeconds, partitioned) {
|
|
36
|
+
let cookie = `${BEARER_COOKIE_NAME}=${encodeURIComponent(value)}; Path=/; HttpOnly; Secure; SameSite=None; Max-Age=${maxAgeSeconds}`;
|
|
37
|
+
if (partitioned)
|
|
38
|
+
cookie += '; Partitioned';
|
|
39
|
+
return cookie;
|
|
40
|
+
}
|
|
41
|
+
// Internal request header the proxy uses to hand the freshly-minted bearer to
|
|
42
|
+
// the first-load render, when the Set-Cookie it issues isn't yet visible to the
|
|
43
|
+
// same request. The proxy strips any inbound value before setting it, so it
|
|
44
|
+
// cannot be spoofed. Every later request carries the HttpOnly cookie instead.
|
|
45
|
+
export const BEARER_HEADER_NAME = 'x-assembly-request-bearer';
|
|
46
|
+
// Custom header for CSRF mitigation on state-changing requests. Its mere
|
|
47
|
+
// presence is sufficient: a cross-site request physically cannot carry a
|
|
48
|
+
// custom header without a CORS preflight, and this app grants none.
|
|
49
|
+
export const APP_CSRF_HEADER = 'x-assembly-app';
|
|
50
|
+
// Master switch for the AI-260 server-side token exchange (bearer cookie flow).
|
|
51
|
+
// Defaults OFF so the exchange stays dormant in every foundation snapshot:
|
|
52
|
+
// merging this code — or any coworker rebuilding the snapshot for unrelated
|
|
53
|
+
// reasons — ships inert plumbing, never a behavior change. While off, proxy.ts
|
|
54
|
+
// and requireAuth() behave exactly as the pre-AI-260 legacy path (direct token
|
|
55
|
+
// decrypt per request). To turn it on: set the env var in the generated-app
|
|
56
|
+
// build environment and rebuild the snapshot — one deliberate, reviewed switch.
|
|
57
|
+
export function isSessionExchangeEnabled() {
|
|
58
|
+
return process.env.ENABLE_APP_SESSION_TOKEN_EXCHANGE === 'true';
|
|
59
|
+
}
|
|
60
|
+
// Decodes a base64url JWT segment to a UTF-8 string using only Web APIs (atob,
|
|
61
|
+
// TextDecoder), so decodeBearer runs in BOTH the Edge middleware runtime (proxy)
|
|
62
|
+
// and the Node route-handler runtime (requireAuth / getAssemblyForRequest) —
|
|
63
|
+
// Buffer is unavailable on Edge.
|
|
64
|
+
function decodeBase64UrlJson(segment) {
|
|
65
|
+
const b64 = segment.replace(/-/g, '+').replace(/_/g, '/');
|
|
66
|
+
const padded = b64 + '='.repeat((4 - (b64.length % 4)) % 4);
|
|
67
|
+
const binary = atob(padded);
|
|
68
|
+
const bytes = Uint8Array.from(binary, (ch) => ch.charCodeAt(0));
|
|
69
|
+
return JSON.parse(new TextDecoder().decode(bytes));
|
|
70
|
+
}
|
|
71
|
+
// Decodes the identity claims from a bearer JWT. Signature is NOT verified here
|
|
72
|
+
// (the platform-api authorizer re-validates it on every real call — the true
|
|
73
|
+
// boundary); these claims are advisory for SDK init and early authz. Returns
|
|
74
|
+
// null when the token is malformed, expired, or carries no workspace — callers
|
|
75
|
+
// then return 401 / re-exchange.
|
|
76
|
+
export function decodeBearer(bearer) {
|
|
77
|
+
const parts = bearer.split('.');
|
|
78
|
+
if (parts.length !== 3)
|
|
79
|
+
return null;
|
|
80
|
+
let claims;
|
|
81
|
+
try {
|
|
82
|
+
claims = decodeBase64UrlJson(parts[1]);
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
if (typeof claims.exp === 'number' && claims.exp * 1_000 <= Date.now()) {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
if (!claims.workspace_id)
|
|
91
|
+
return null;
|
|
92
|
+
return {
|
|
93
|
+
workspaceId: claims.workspace_id,
|
|
94
|
+
clientId: claims.client_id || undefined,
|
|
95
|
+
internalUserId: claims.internal_user_id || undefined,
|
|
96
|
+
companyId: claims.company_id || undefined,
|
|
97
|
+
notificationId: claims.notification_id || undefined,
|
|
98
|
+
instanceId: claims.instance_id || undefined,
|
|
99
|
+
sessionId: claims.session_id || undefined,
|
|
100
|
+
};
|
|
101
|
+
}
|
package/dist/theme.d.ts
ADDED
package/dist/theme.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
const CDN_BASE_URL = 'https://cdn.assembly.com';
|
|
2
|
+
const DEFAULT_FONT_VALUE = 'Inter';
|
|
3
|
+
// Custom (non-Google) fonts hosted on CDN. Everything else is treated as a Google Font.
|
|
4
|
+
const CUSTOM_FONTS = {
|
|
5
|
+
Satoshi: { cssPath: '/fonts/satoshi/satoshi.css' },
|
|
6
|
+
};
|
|
7
|
+
const SAFE_FONT_NAME = /^[A-Za-z0-9 +\-]+$/;
|
|
8
|
+
export function resolveFont(fontValue) {
|
|
9
|
+
if (!fontValue || !SAFE_FONT_NAME.test(fontValue))
|
|
10
|
+
return { fontFamily: DEFAULT_FONT_VALUE, fontCssUrl: null };
|
|
11
|
+
const fontFamily = fontValue.replace(/\+/g, ' ');
|
|
12
|
+
const customFont = CUSTOM_FONTS[fontValue];
|
|
13
|
+
if (customFont) {
|
|
14
|
+
return { fontFamily, fontCssUrl: CDN_BASE_URL + customFont.cssPath };
|
|
15
|
+
}
|
|
16
|
+
return { fontFamily, fontCssUrl: 'https://fonts.googleapis.com/css2?family=' + fontValue + ':wght@300;400;500;600;700;800;900&display=swap' };
|
|
17
|
+
}
|
|
18
|
+
export function isValidHexColor(color) {
|
|
19
|
+
return /^#[0-9a-fA-F]{3,8}$/.test(color);
|
|
20
|
+
}
|
package/package.json
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@assembly-js/app-foundation",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.1-rc.2",
|
|
4
4
|
"description": "Versioned foundation helpers and build context for Assembly generated apps",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
7
7
|
"dist",
|
|
8
|
-
"skills"
|
|
8
|
+
"skills",
|
|
9
|
+
"CLAUDE.md"
|
|
9
10
|
],
|
|
10
11
|
"exports": {
|
|
11
12
|
"./server": {
|
|
@@ -19,14 +20,20 @@
|
|
|
19
20
|
},
|
|
20
21
|
"scripts": {
|
|
21
22
|
"build": "tsc",
|
|
22
|
-
"test": "
|
|
23
|
-
"prepublishOnly": "npm run build"
|
|
23
|
+
"test": "bun test",
|
|
24
|
+
"prepublishOnly": "npm run build && npm run verify:dist",
|
|
25
|
+
"verify:dist": "node scripts/verify-dist-resolves.mjs"
|
|
24
26
|
},
|
|
25
27
|
"peerDependencies": {
|
|
28
|
+
"@assembly-js/node-sdk": ">=4.2.3-beta.2",
|
|
29
|
+
"next": ">=15",
|
|
26
30
|
"server-only": "*"
|
|
27
31
|
},
|
|
28
32
|
"devDependencies": {
|
|
33
|
+
"@assembly-js/node-sdk": "4.2.3-beta.2",
|
|
34
|
+
"@types/bun": "^1.1.0",
|
|
29
35
|
"@types/node": "^22",
|
|
36
|
+
"next": "^15.5.0",
|
|
30
37
|
"server-only": "^0.0.1",
|
|
31
38
|
"typescript": "^5.3.3"
|
|
32
39
|
},
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: assembly-ai
|
|
3
|
+
description: Use when the app needs AI features like text generation, summarization, analysis, or any LLM capability. Apps call Assembly's AI proxy — no API keys or external SDKs needed.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## How it works
|
|
7
|
+
|
|
8
|
+
Assembly provides an AI proxy that forwards requests to OpenAI. Your app calls the Assembly API using `assembly.fetch()` from the SDK — auth headers and base URL are handled automatically. No extra API keys, no extra env vars, no OpenAI SDK.
|
|
9
|
+
|
|
10
|
+
The proxy mirrors OpenAI's API shape. Call any OpenAI endpoint path via:
|
|
11
|
+
`assembly.fetch('/v1/ai/openai/chat/completions', { ... })`
|
|
12
|
+
`assembly.fetch('/v1/ai/openai/embeddings', { ... })`
|
|
13
|
+
|
|
14
|
+
## Setup
|
|
15
|
+
|
|
16
|
+
Create a helper in `lib/ai.ts` that uses the existing Assembly SDK instance:
|
|
17
|
+
|
|
18
|
+
```typescript
|
|
19
|
+
import { getAssembly } from './assembly';
|
|
20
|
+
|
|
21
|
+
export async function chatCompletion(messages: Array<{ role: string; content: string }>, options?: { model?: string; stream?: boolean }) {
|
|
22
|
+
const assembly = await getAssembly();
|
|
23
|
+
const response = await assembly.fetch('/v1/ai/openai/chat/completions', {
|
|
24
|
+
method: 'POST',
|
|
25
|
+
body: JSON.stringify({
|
|
26
|
+
model: options?.model || 'gpt-4o-mini',
|
|
27
|
+
messages,
|
|
28
|
+
stream: options?.stream || false,
|
|
29
|
+
}),
|
|
30
|
+
});
|
|
31
|
+
if (!response.ok) throw new Error(`AI request failed: ${response.status}`);
|
|
32
|
+
return response;
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
`assembly.fetch()` automatically prepends the base URL and injects auth headers (`Authorization: Bearer` from `getAssembly()`, plus `X-Assembly-SDK-Version`). It never sends `X-API-Key` on data calls. You only provide the path and request body.
|
|
37
|
+
|
|
38
|
+
## Server-side only
|
|
39
|
+
|
|
40
|
+
ALL AI calls MUST happen server-side (API routes, server components, server actions). Never call the AI proxy from client components.
|
|
41
|
+
|
|
42
|
+
Example Next.js API route:
|
|
43
|
+
|
|
44
|
+
```typescript
|
|
45
|
+
// app/api/ai/summarize/route.ts
|
|
46
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
47
|
+
import { chatCompletion } from '@/lib/ai';
|
|
48
|
+
|
|
49
|
+
export async function POST(request: NextRequest) {
|
|
50
|
+
const { text } = await request.json();
|
|
51
|
+
const response = await chatCompletion([
|
|
52
|
+
{ role: 'user', content: `Summarize the following:\n\n${text}` },
|
|
53
|
+
]);
|
|
54
|
+
const data = await response.json();
|
|
55
|
+
return NextResponse.json({ summary: data.choices[0].message.content });
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Vision (image inputs)
|
|
60
|
+
|
|
61
|
+
The proxy supports OpenAI's vision API. Use `gpt-4o` with the multi-part content format to analyze images. The `content` field becomes an array of typed parts instead of a plain string:
|
|
62
|
+
|
|
63
|
+
```typescript
|
|
64
|
+
export async function analyzeImage(base64DataUrl: string, prompt: string) {
|
|
65
|
+
const assembly = await getAssembly();
|
|
66
|
+
const response = await assembly.fetch('/v1/ai/openai/chat/completions', {
|
|
67
|
+
method: 'POST',
|
|
68
|
+
body: JSON.stringify({
|
|
69
|
+
model: 'gpt-4o',
|
|
70
|
+
messages: [{
|
|
71
|
+
role: 'user',
|
|
72
|
+
content: [
|
|
73
|
+
{ type: 'text', text: prompt },
|
|
74
|
+
{ type: 'image_url', image_url: { url: base64DataUrl } },
|
|
75
|
+
],
|
|
76
|
+
}],
|
|
77
|
+
stream: false,
|
|
78
|
+
}),
|
|
79
|
+
});
|
|
80
|
+
if (!response.ok) throw new Error(`AI request failed: ${response.status}`);
|
|
81
|
+
return response;
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
The `base64DataUrl` must be a full data URL: `data:image/png;base64,iVBOR...`. Convert uploaded files on the client before sending to the API route:
|
|
86
|
+
|
|
87
|
+
```typescript
|
|
88
|
+
const toDataUrl = (file: File): Promise<string> =>
|
|
89
|
+
new Promise((resolve, reject) => {
|
|
90
|
+
const reader = new FileReader();
|
|
91
|
+
reader.onload = () => resolve(reader.result as string);
|
|
92
|
+
reader.onerror = reject;
|
|
93
|
+
reader.readAsDataURL(file);
|
|
94
|
+
});
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Keep images under ~4 MB to stay within the 6 MB Lambda payload limit (base64 adds ~33% overhead). For larger images, resize on the client before converting.
|
|
98
|
+
|
|
99
|
+
## Model selection
|
|
100
|
+
|
|
101
|
+
- Default to `gpt-4o-mini` for most tasks (fast, cheap, good enough)
|
|
102
|
+
- Use `gpt-4o` for vision (image inputs) or when the task requires complex reasoning
|
|
103
|
+
- For embeddings, use `text-embedding-3-small`
|
|
104
|
+
|
|
105
|
+
## Streaming
|
|
106
|
+
|
|
107
|
+
For chat or long-form generation, pass `stream: true` and forward the SSE stream:
|
|
108
|
+
|
|
109
|
+
```typescript
|
|
110
|
+
export async function POST(request: NextRequest) {
|
|
111
|
+
const { messages } = await request.json();
|
|
112
|
+
const response = await chatCompletion(messages, { stream: true });
|
|
113
|
+
return new Response(response.body, {
|
|
114
|
+
headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' },
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
The proxy forwards OpenAI's SSE stream directly. Parse `data:` lines on the client as usual.
|
|
120
|
+
|
|
121
|
+
`assembly.fetch()` returns a standard `Response`, so `response.body` is a `ReadableStream` you can pipe directly.
|
|
122
|
+
|
|
123
|
+
## Quota
|
|
124
|
+
|
|
125
|
+
Usage is metered against the workspace's monthly AI quota. Do not implement your own rate limiting — Assembly handles this automatically. If the quota is exceeded, the proxy returns HTTP 429.
|
|
126
|
+
|
|
127
|
+
## What NOT to do
|
|
128
|
+
|
|
129
|
+
- Do NOT install the `openai` npm package — use `assembly.fetch()` via the Assembly proxy
|
|
130
|
+
- Do NOT set or reference `OPENAI_API_KEY` or `OPENAI_BASE_URL` — these do not exist
|
|
131
|
+
- Do NOT call `api.openai.com` directly — always go through the Assembly proxy
|
|
132
|
+
- Do NOT call the AI proxy from client-side code
|
|
133
|
+
- Do NOT implement your own API key management or manually set auth headers — `assembly.fetch()` handles this
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: assembly-app-bridge-header
|
|
3
|
+
description: Use any time the app sets breadcrumbs, primary CTA, secondary CTA, or actions menu in the Assembly header chrome. Always drive the header through the useAssemblyHeader hook — never call AssemblyBridge.header.set* from your own useEffect. Critical: the parent automatically prepends the app's name as breadcrumb 0 — never include it yourself, or it renders twice.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
The Assembly header (breadcrumbs, primary/secondary CTA buttons, overflow actions menu) is controlled from your app. **Drive it with the `useAssemblyHeader` hook** — it ships in the foundation at `app/components/use-assembly-header.ts`.
|
|
7
|
+
|
|
8
|
+
## The one rule: use the hook, not raw `set*`
|
|
9
|
+
|
|
10
|
+
```tsx
|
|
11
|
+
'use client';
|
|
12
|
+
import { useAssemblyHeader } from '@/app/components/use-assembly-header';
|
|
13
|
+
|
|
14
|
+
export default function ReportPage() {
|
|
15
|
+
useAssemblyHeader({
|
|
16
|
+
onRootClick: () => router.push('/'),
|
|
17
|
+
breadcrumbs: [{ label: 'Reports' }, { label: 'Q4 2026' }],
|
|
18
|
+
primaryCta: { label: 'New report', icon: 'Plus', onClick: () => setCreating(true) },
|
|
19
|
+
secondaryCta: { label: 'Export', icon: 'Archive', onClick: exportData },
|
|
20
|
+
actionsMenu: [
|
|
21
|
+
{ label: 'Settings', onClick: openSettings },
|
|
22
|
+
{ label: 'Delete', icon: 'Trash', color: 'red', onClick: remove },
|
|
23
|
+
],
|
|
24
|
+
});
|
|
25
|
+
// ...
|
|
26
|
+
}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Call it **once per page** with exactly the chrome that page needs. The hook owns the whole header surface for that page: anything you omit is cleared, and everything is torn down automatically on unmount. Pass freshly-built objects/handlers every render — that's fine, the hook keys re-renders on the visible *values* (labels/icons), not on handler identity, and reads the latest `onClick` from a ref. To hide a slot, omit it (or pass `primaryCta: null`).
|
|
30
|
+
|
|
31
|
+
### ❌ Never hand-roll the effect
|
|
32
|
+
|
|
33
|
+
```tsx
|
|
34
|
+
// ❌ WRONG — do not call these from your own useEffect.
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
AssemblyBridge.header.setPrimaryCta({ label: 'Save', onClick: save });
|
|
37
|
+
return () => AssemblyBridge.header.setPrimaryCta(null);
|
|
38
|
+
}, [...]);
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Hand-rolling the imperative API is the cause of the "the button disappears after I start typing" class of bug: splitting set + teardown across two effects, or listing an unstable dependency (e.g. an inline closure or object created fresh every render) makes one effect's cleanup `null` a CTA that another effect owns, and the button never comes back after the first re-render. `useAssemblyHeader` exists precisely so you never write this. Use it.
|
|
42
|
+
|
|
43
|
+
### ❌ Never let two mounted components call the hook at once
|
|
44
|
+
|
|
45
|
+
```tsx
|
|
46
|
+
// ❌ WRONG — AppShell scaffolds a stub CTA for a tab...
|
|
47
|
+
function AppShell() {
|
|
48
|
+
useAssemblyHeader({ primaryCta: { label: 'New service', onClick: () => {} } });
|
|
49
|
+
return <CatalogTab />;
|
|
50
|
+
}
|
|
51
|
+
// ...and CatalogTab, built later, owns the REAL one. Two live owners.
|
|
52
|
+
function CatalogTab() {
|
|
53
|
+
useAssemblyHeader({ primaryCta: { label: 'New service', onClick: openDialog } });
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
The classic way this happens: a parent shell stubs a placeholder CTA during scaffolding, a child component later takes real ownership, and the stub never gets deleted. **The moment a child calls `useAssemblyHeader`, delete the parent's stub.** This race is invisible in local dev — Strict Mode's double-invoked effects happen to leave the child as last writer — and only misbehaves in the published app. The hook now enforces it at runtime: a second simultaneous caller **throws in dev** and is ignored (with a `console.error`) in production. If your preview crashes with "a second mounted component tried to drive the header," find and delete the leftover stub.
|
|
58
|
+
|
|
59
|
+
## Breadcrumbs: never include the app name
|
|
60
|
+
|
|
61
|
+
The parent **automatically prepends the app's name as breadcrumb 0**. Whatever you pass is appended *after* index 0. If your app needs to control what happens when the user clicks that root crumb, pass `onRootClick` to `useAssemblyHeader`.
|
|
62
|
+
|
|
63
|
+
```tsx
|
|
64
|
+
// ❌ WRONG — renders as "MyApp / MyApp / Reports"
|
|
65
|
+
useAssemblyHeader({ breadcrumbs: [{ label: 'MyApp', onClick: goHome }, { label: 'Reports' }] });
|
|
66
|
+
|
|
67
|
+
// ✅ RIGHT — renders as "MyApp / Reports"
|
|
68
|
+
useAssemblyHeader({ onRootClick: goHome, breadcrumbs: [{ label: 'Reports' }] });
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Use breadcrumbs only for hierarchical navigation, not branding. A flat one-page app omits `breadcrumbs` (or passes `[]`). A multi-level app uses one entry per level: `[{ label: 'Reports' }, { label: 'Q4 2026' }]`. A breadcrumb's `onClick` makes it clickable; omit it for the current/leaf level.
|
|
72
|
+
|
|
73
|
+
## Icon and color constraints
|
|
74
|
+
|
|
75
|
+
- **`icon` must be a `CtaIcon`** — the only valid values are `'Archive' | 'Plus' | 'Templates' | 'Trash' | 'Check'`. Any other string (e.g. `'Download'`, `'Settings'`, `'Edit'`) is a type error — omit the icon (it's optional) when none fit.
|
|
76
|
+
- **`color`** (actions-menu items only) is optional; the only explicit value is `'red'` (for destructive actions). Omit it for the default color.
|
|
77
|
+
|
|
78
|
+
`AssemblyBridge` postMessages to the parent, so everything here must live in a `'use client'` component.
|
|
79
|
+
|
|
80
|
+
## Exception: the notification surface (`/n`)
|
|
81
|
+
|
|
82
|
+
The Notification Center embeds the notification surface in a plain `<iframe>` **without app bridge header support**. `useAssemblyHeader` calls are silently ignored — `primaryCta`, `secondaryCta`, `actionsMenu`, and `breadcrumbs` never render. The user sees an empty header.
|
|
83
|
+
|
|
84
|
+
**On the notification surface, render primary actions as inline `<Button>` elements in the page body.** This is the ONE exception to the "always use the hook" rule. Detect the notification surface by the route itself — code under `app/n/` IS the surface. For a component shared with another surface, pass an explicit prop (see below) from the `/n` page rather than inferring from `payload.notificationId`: that claim is best-effort under the session-exchange flow, so a component keying off it can silently fall back to header CTAs that never render inside the Notification Center iframe.
|
|
85
|
+
|
|
86
|
+
**This exception applies ONLY to the `/n` route.** All other surfaces (dashboard, client portal, CRM tab) MUST continue using `useAssemblyHeader`.
|
|
87
|
+
|
|
88
|
+
**Common mistake: reusing a component that calls `useAssemblyHeader` on `/n`.** If you share a form or action view between the notification surface and another surface, and the component calls `useAssemblyHeader` while also rendering an inline `<Button>`, both the header CTA and the inline button appear on non-notification surfaces — a duplicate. Fix by either creating a dedicated component for `/n` that uses only inline buttons, or by accepting a prop (e.g. `useInlineActions`) that switches between the header hook and inline buttons.
|