@assembly-js/app-foundation 0.0.30 → 0.0.32

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 CHANGED
@@ -43,7 +43,7 @@ Assembly-specific gotchas live in `.claude/skills/<name>/SKILL.md`. Invoke them
43
43
  - `assembly-app-surfaces` — REQUIRED before writing `.assembly/app.json`: declare which surfaces (dashboard, client portal, notification) the app exposes; omitting the client surface keeps the app off client portal nav entirely
44
44
  - `assembly-app-bridge-header` — breadcrumbs (don't include app name), CTAs
45
45
  - `assembly-app-bridge-toast` — show toast notifications in the parent dashboard
46
- - `assembly-ai` — AI features via Assembly's OpenAI proxy (no API keys needed)
46
+ - `assembly-ai` — AI features, including speech-to-text, via Assembly's OpenAI proxy (no API keys needed)
47
47
  - `assembly-storage` — app-private file uploads/downloads (NOT the portal Files module). The storage helpers come from `@assembly-js/app-foundation/server` — import them, don't rewrite them. POST only `{ fileName }` (a bare name) and store the key the platform returns; never build the S3 key yourself.
48
48
  - `assembly-integrations` (present only when third-party integrations are enabled for this workspace) — third-party providers (Slack, etc.) via `requiredIntegrations` + `getIntegration()`. If this skill is present, read it and `.claude/skills/assembly-integrations/providers/<slug>.md` before declaring an integration or writing any provider call — the skill lists what's connectable, the provider doc has that provider's gotchas — and read them first when provider data displays wrongly (wrong format, raw IDs). If it is absent, managed OAuth integrations are not available here; use `requiredSecrets` for any external service
49
49
  - `assembly-avatar-sub` — AvatarSub company badge wherever a client is rendered, including tables of your own records that reference a client; row duplication only for lists OF clients
@@ -1162,7 +1162,7 @@ Use `AssemblyBridge.header.*` for breadcrumbs, CTAs, and actions menu. See `.cla
1162
1162
  <!-- /feature:app-owned-header -->
1163
1163
  - ❌ Don't let two mounted components call `useAssemblyHeader` at once — e.g. a parent shell's scaffolding-era stub CTA left in place after a child took real ownership. Strict Mode hides the race in dev; the hook throws in dev and ignores the second caller in production. Delete the parent's stub the moment a child owns the slot.
1164
1164
  - ❌ Don't `window.open(url, '_self')` or set `window.location` for external links — use `<a target="_blank" rel="noreferrer">`.
1165
- - ❌ Don't open one of your own route handlers by plain navigation for a download or export — `<a href="/api/…/pdf" target="_blank">`, `window.open('/api/…')`. The bearer cookie is partitioned to the portal iframe, so a new tab arrives signed out and `requireAuth` rejects it; the build passes and the link only fails in a live test. Fetch it with `authFetch`, read the response with `res.blob()`, and click a programmatic anchor on `URL.createObjectURL(blob)` with a `download` name. (`assembly-storage` shows the same flow for a route that returns a pre-signed URL as JSON rather than the bytes.)
1165
+ - ❌ Don't open one of your own route handlers by plain navigation for a download or export — `<a href="/api/…/pdf" target="_blank">`, `window.open('/api/…')`. The bearer cookie is partitioned to the portal iframe, so a new tab arrives signed out and `requireAuth` rejects it; the build passes and the link only fails in a live test. Use `downloadViaBlob(url, filename)` from `app/components/lib-client.ts` — it calls `authFetch`, reads `res.blob()`, and triggers the download via a programmatic anchor on `URL.createObjectURL(blob)`. (`assembly-storage` shows the same flow for a route that returns a pre-signed URL as JSON rather than the bytes.)
1166
1166
  - ❌ Don't hardcode brand colors. The portal applies the workspace theme.
1167
1167
  - ❌ Don't add dark mode support (`dark:` classes, `.dark` class toggling, `next-themes`, `ThemeProvider`, or `prefers-color-scheme` media queries). The assembly-ui registry is light-only and the layout forces `colorScheme: 'light'`.
1168
1168
  - ❌ Don't handle the session token at all — no cookie you set, no `useAssembly().token`, no `x-assembly-token` header, no `searchParams.token`. The middleware already exchanged it for a bearer cookie; call `getAssembly()` / `getAssemblyForRequest(req)` server-side and the call is authenticated.
@@ -1,9 +1,21 @@
1
1
  import { getAssembly } from './assembly.js';
2
+ // A bearer token is a JWT: three base64url segments separated by dots, the
3
+ // first always decoding to a JSON header (so it starts with 'eyJ'). Matching
4
+ // the whole-string shape — not just the 'eyJ' prefix — avoids rejecting a
5
+ // legitimate file name or storage key that happens to start with 'eyJ'.
6
+ const JWT_SHAPE = /^eyJ[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+$/;
2
7
  // fileName must be a bare file name (e.g. 'invoice.pdf'), never a path:
3
8
  // the platform rejects any value containing '/' or '..'. Do NOT prefix
4
9
  // folders or construct the S3 key yourself — the platform scopes the key
5
10
  // to the workspace and makes it unique. Store the key it returns.
11
+ // NOTE: takes ONE argument. If you are calling this as getUploadUrl(token, fileName)
12
+ // (the old two-arg shape), remove the token — auth is in the bearer cookie.
6
13
  export async function getUploadUrl(fileName) {
14
+ // Catches the old (token, fileName) call shape where a JWT ends up as the file name.
15
+ if (JWT_SHAPE.test(fileName)) {
16
+ throw new Error('getUploadUrl: first argument looks like a session token. ' +
17
+ 'The token argument was removed — call getUploadUrl(fileName) with the file name only.');
18
+ }
7
19
  const assembly = await getAssembly();
8
20
  const res = await assembly.fetch('/v1/app-storage/upload-url', {
9
21
  method: 'POST',
@@ -13,7 +25,14 @@ export async function getUploadUrl(fileName) {
13
25
  throw new Error('Upload URL request failed: ' + res.status);
14
26
  return res.json();
15
27
  }
28
+ // NOTE: takes ONE argument. If you are calling this as getDownloadUrl(token, key)
29
+ // (the old two-arg shape), remove the token — auth is in the bearer cookie.
16
30
  export async function getDownloadUrl(key) {
31
+ // Catches the old (token, key) call shape where a JWT ends up as the storage key.
32
+ if (JWT_SHAPE.test(key)) {
33
+ throw new Error('getDownloadUrl: first argument looks like a session token. ' +
34
+ 'The token argument was removed — call getDownloadUrl(key) with the storage key only.');
35
+ }
17
36
  const assembly = await getAssembly();
18
37
  const res = await assembly.fetch('/v1/app-storage/download-url?key=' + encodeURIComponent(key));
19
38
  if (!res.ok)
@@ -0,0 +1,73 @@
1
+ import '@assembly-js/app-bridge';
2
+ export type NavigateDestination = {
3
+ to: 'home';
4
+ } | {
5
+ to: 'client';
6
+ clientId: string;
7
+ tab?: string;
8
+ } | {
9
+ to: 'company';
10
+ companyId: string;
11
+ tab?: string;
12
+ } | {
13
+ to: 'channel';
14
+ channelId?: string;
15
+ } | {
16
+ to: 'files';
17
+ channelId?: string;
18
+ } | {
19
+ to: 'contract';
20
+ contractId: string;
21
+ } | {
22
+ to: 'form';
23
+ formId: string;
24
+ } | {
25
+ to: 'formResponse';
26
+ formId: string;
27
+ responseId: string;
28
+ } | {
29
+ to: 'invoice';
30
+ invoiceId: string;
31
+ } | {
32
+ to: 'subscription';
33
+ subscriptionId: string;
34
+ } | {
35
+ to: 'product';
36
+ } | {
37
+ to: 'helpdesk';
38
+ } | {
39
+ to: 'article';
40
+ articleId: string;
41
+ } | {
42
+ to: 'notifications';
43
+ } | {
44
+ to: 'automations';
45
+ } | {
46
+ to: 'settings';
47
+ page?: string;
48
+ } | {
49
+ to: 'app';
50
+ installId: string;
51
+ };
52
+ export type NavigateToOptions = {
53
+ replace?: boolean;
54
+ newTab?: boolean;
55
+ timeout?: number;
56
+ };
57
+ export type NavigateResult = {
58
+ ok: true;
59
+ } | {
60
+ ok: false;
61
+ reason: string;
62
+ };
63
+ declare module '@assembly-js/app-bridge' {
64
+ interface AssemblyBridgeApi {
65
+ postToParent(message: {
66
+ type: string;
67
+ [key: string]: unknown;
68
+ }, options?: {
69
+ droppable?: boolean;
70
+ }): void;
71
+ navigateTo(destination: NavigateDestination, options?: NavigateToOptions): Promise<NavigateResult>;
72
+ }
73
+ }
@@ -0,0 +1,13 @@
1
+ // The published @assembly-js/app-bridge types this package typechecks against
2
+ // (peer >=1.3.0) predate the public postToParent and navigateTo. Both land on
3
+ // the 2.0.0 set, where they're required interface members. Augment rather
4
+ // than depending on an unpublished tarball; call sites guard with `?.` /
5
+ // truthiness checks since older peers lack these at runtime.
6
+ //
7
+ // Exported as real types (not just declared ambiently) because an ambient
8
+ // .d.ts that nothing imports never reaches a published package's declaration
9
+ // output — tsc only emits/carries declarations that are part of the module
10
+ // graph. lib-client.ts imports these types from this module instead of from
11
+ // '@assembly-js/app-bridge' so the augmentation travels with it to consumers
12
+ // on older peers, which don't export these names at all.
13
+ import '@assembly-js/app-bridge';
@@ -1,5 +1,7 @@
1
+ import type { NavigateDestination, NavigateToOptions } from './bridge-vnext.js';
1
2
  export declare function reexchangeBearer(): Promise<boolean>;
2
3
  export declare function authFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
4
+ export declare function downloadViaBlob(url: string, filename: string): Promise<void>;
3
5
  /**
4
6
  * Mint an in-memory bearer at load when the browser is refusing to store the
5
7
  * cookie, so the first client-side navigation is already authenticated instead
@@ -8,3 +10,13 @@ export declare function authFetch(input: RequestInfo | URL, init?: RequestInit):
8
10
  * Exported for tests; runs once at module load in the browser.
9
11
  */
10
12
  export declare function bootstrapCookielessBearer(): Promise<boolean>;
13
+ /**
14
+ * Returns an onClick handler that navigates the parent Assembly portal to the
15
+ * given typed destination. Use in button or anchor onClick props.
16
+ *
17
+ * @example
18
+ * <button onClick={linkTo({ to: 'invoice', invoiceId: invoice.id })}>
19
+ * View invoice
20
+ * </button>
21
+ */
22
+ export declare function linkTo(destination: NavigateDestination, options?: NavigateToOptions): () => void;
@@ -126,6 +126,28 @@ export async function authFetch(input, init) {
126
126
  }
127
127
  return fetch(input, { ...init, headers });
128
128
  }
129
+ // Downloads a file from url via authFetch (bearer cookie, same-origin), then
130
+ // triggers a browser download without opening a new tab. Use this instead of
131
+ // window.open or <a download href=url>: a new top-level tab is a different cookie
132
+ // partition under CHIPS, so the bearer is absent and the route 401s.
133
+ export async function downloadViaBlob(url, filename) {
134
+ const res = await authFetch(url);
135
+ if (!res.ok)
136
+ throw new Error(`Download failed: ${res.status}`);
137
+ const blob = await res.blob();
138
+ const objectUrl = URL.createObjectURL(blob);
139
+ try {
140
+ const a = document.createElement('a');
141
+ a.href = objectUrl;
142
+ a.download = filename;
143
+ document.body.appendChild(a);
144
+ a.click();
145
+ document.body.removeChild(a);
146
+ }
147
+ finally {
148
+ URL.revokeObjectURL(objectUrl);
149
+ }
150
+ }
129
151
  /**
130
152
  * Mint an in-memory bearer at load when the browser is refusing to store the
131
153
  * cookie, so the first client-side navigation is already authenticated instead
@@ -144,3 +166,22 @@ export function bootstrapCookielessBearer() {
144
166
  // Self-running, mirroring origin-bootstrap: module evaluation precedes
145
167
  // hydration, so the bearer is in hand before any component's first fetch.
146
168
  void bootstrapCookielessBearer();
169
+ /**
170
+ * Returns an onClick handler that navigates the parent Assembly portal to the
171
+ * given typed destination. Use in button or anchor onClick props.
172
+ *
173
+ * @example
174
+ * <button onClick={linkTo({ to: 'invoice', invoiceId: invoice.id })}>
175
+ * View invoice
176
+ * </button>
177
+ */
178
+ export function linkTo(destination, options) {
179
+ return () => {
180
+ // navigateTo rejects when the host never answers (e.g. the nav v2 flag is
181
+ // off) — catch it here, since the caller only gets a void onClick handler
182
+ // back and has no promise of its own to attach a handler to.
183
+ AssemblyBridge.navigateTo?.(destination, options)?.catch((err) => {
184
+ console.error('linkTo: navigateTo failed', err);
185
+ });
186
+ };
187
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assembly-js/app-foundation",
3
- "version": "0.0.30",
3
+ "version": "0.0.32",
4
4
  "description": "Versioned foundation helpers and build context for Assembly generated apps",
5
5
  "type": "module",
6
6
  "files": [
@@ -1,6 +1,6 @@
1
1
  ---
2
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.
3
+ description: Use when the app needs AI features like text generation, summarization, analysis, vision, speech-to-text, or any LLM capability. Apps call Assembly's AI proxy — no API keys or external SDKs needed.
4
4
  ---
5
5
 
6
6
  ## How it works
@@ -96,6 +96,29 @@ const toDataUrl = (file: File): Promise<string> =>
96
96
 
97
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
98
 
99
+ ## Audio (speech-to-text)
100
+
101
+ Record in the browser with `MediaRecorder` in the browser's native container: `audio/webm;codecs=opus`, or `audio/mp4` on Safari. Use a speech bitrate such as 24 kbps. Do not decode the recording to PCM or WAV in the browser.
102
+
103
+ Transcribe on the server. Post the file as `multipart/form-data` to `/v1/ai/openai/audio/transcriptions` through `assembly.fetch()`. Use `gpt-4o-mini-transcribe` (`whisper-1` is the alternative). Do not set a JSON `Content-Type` on this call. Pass the `FormData` as the body and let the runtime set the multipart header.
104
+
105
+ ```typescript
106
+ export async function transcribeAudio(file: File) {
107
+ const assembly = await getAssembly();
108
+ const body = new FormData();
109
+ body.append('file', file);
110
+ body.append('model', 'gpt-4o-mini-transcribe');
111
+ const response = await assembly.fetch('/v1/ai/openai/audio/transcriptions', {
112
+ method: 'POST',
113
+ body,
114
+ });
115
+ if (!response.ok) throw new Error(`AI request failed: ${response.status}`);
116
+ return response;
117
+ }
118
+ ```
119
+
120
+ Keep each uploaded file under 4 MB. The platform API rejects request bodies above roughly 4.5 MB with HTTP 413 before the proxy runs, and there is no richer error to catch. Keep each file under about five minutes so the upstream call also finishes inside the proxy's 27 second first-byte timeout. For longer recordings, split the recording into files that meet both bounds and persist each transcript piece as it completes.
121
+
99
122
  ## Model selection
100
123
 
101
124
  - Default to `gpt-4o-mini` for most tasks (fast, cheap, good enough)
@@ -13,8 +13,8 @@ Use app-storage for files that belong to YOUR app. For files the user expects to
13
13
 
14
14
  **Both helpers ship in `@assembly-js/app-foundation/server`, an installed dependency** — import them; do NOT recreate or rewrite them:
15
15
 
16
- - `getUploadUrl(fileName)` → `{ key, uploadUrl }` — POSTs `{ fileName }` to the platform.
17
- - `getDownloadUrl(key)` → `{ downloadUrl }` — mints a fresh pre-signed GET.
16
+ - `getUploadUrl(fileName)` → `{ key, uploadUrl }` — POSTs `{ fileName }` to the platform. Takes **one argument** (the file name only). If you were calling it as `getUploadUrl(token, fileName)` (an old two-arg shape), remove the token — auth is in the bearer cookie and the function will throw if a token is passed as the file name.
17
+ - `getDownloadUrl(key)` → `{ downloadUrl }` — mints a fresh pre-signed GET. Takes **one argument** (the storage key only). Same note: remove any leading token argument.
18
18
 
19
19
  Store the `key` it returns; never construct an S3 key yourself, and never parse the original file name back out of the key — the platform normalizes the name when forming the key, so the stored `key` is not guaranteed to contain your exact `fileName`. Keep the display name as your own metadata (see "Storing and downloading"). `fileName` must be a bare file name (`invoice.pdf`), never a path — the platform rejects any fileName containing `/` or `..`. Don't prefix folders or namespaces onto it; the platform already scopes the key to the workspace and makes it unique. If you need grouping, store that in your own database alongside the key.
20
20
 
@@ -26,7 +26,7 @@ The pre-signed URL does not lock the `Content-Type`, so any value works — set
26
26
 
27
27
  ```typescript
28
28
  'use client';
29
- import { authFetch } from './lib-client';
29
+ import { authFetch } from '@/app/components/lib-client';
30
30
 
31
31
  export async function uploadFile(file: File) {
32
32
  const res = await authFetch('/api/app-storage/upload-url', {
@@ -70,7 +70,9 @@ export async function POST(request: NextRequest) {
70
70
 
71
71
  Save the returned `key` (plus any metadata like `fileName`/`size`) in your app's own database. Pre-signed download URLs expire within hours, so generate them on demand — never embed one in rendered HTML or a cached server component.
72
72
 
73
- Create a route that returns the pre-signed URL as JSON, then trigger the download from a client component via `authFetch` + a programmatic anchor click. `authFetch` adds the CSRF header and the bearer rides the cookie, so the download route stays authenticated without any token in the URL.
73
+ ### Pattern A — route returns a pre-signed S3 URL (no proxy of bytes)
74
+
75
+ Create a route that returns the pre-signed URL as JSON, then open that URL via a programmatic anchor click. The pre-signed URL carries S3 credentials directly, so no auth cookie is needed to access S3. Use `authFetch` only for the initial request to get the URL.
74
76
 
75
77
  **Route handler** (`app/api/app-storage/download/route.ts`):
76
78
 
@@ -92,6 +94,8 @@ export async function GET(request: NextRequest) {
92
94
  **Client-side download trigger:**
93
95
 
94
96
  ```typescript
97
+ import { authFetch } from '@/app/components/lib-client';
98
+
95
99
  async function downloadFile(key: string) {
96
100
  const res = await authFetch(`/api/app-storage/download?key=${encodeURIComponent(key)}`);
97
101
  if (!res.ok) throw new Error('Failed to get download URL');
@@ -106,7 +110,18 @@ async function downloadFile(key: string) {
106
110
  }
107
111
  ```
108
112
 
109
- Do NOT use `<a href>` links to the download route — plain links can't send the CSRF header `authFetch` adds, so the route rejects them with 403. Always use a button with an `onClick` that calls `authFetch`.
113
+ ### Pattern B — route returns bytes directly (use `downloadViaBlob`)
114
+
115
+ When your route fetches from S3 and streams the bytes back instead of a pre-signed URL, use `downloadViaBlob` from `app/components/lib-client.ts`. It calls `authFetch`, reads `res.blob()`, and triggers the download via a programmatic anchor — the bearer cookie is sent because the fetch is same-origin, avoiding the CHIPS partition problem that breaks `window.open` and `<a download href=…>` to a new tab.
116
+
117
+ ```typescript
118
+ import { downloadViaBlob } from '@/app/components/lib-client';
119
+
120
+ // In a button onClick handler:
121
+ await downloadViaBlob(`/api/items/${id}/download`, 'report.pdf');
122
+ ```
123
+
124
+ Do NOT use `<a href>` links to the download route — plain links can't send the CSRF header `authFetch` adds, so the route rejects them with 403. Always use a button with an `onClick` that calls `authFetch` or `downloadViaBlob`.
110
125
 
111
126
  ## Server needs the bytes (parse / process an uploaded file)
112
127