@assembly-js/app-foundation 0.0.30 → 0.0.31

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
@@ -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)
@@ -1,5 +1,6 @@
1
1
  export declare function reexchangeBearer(): Promise<boolean>;
2
2
  export declare function authFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
3
+ export declare function downloadViaBlob(url: string, filename: string): Promise<void>;
3
4
  /**
4
5
  * Mint an in-memory bearer at load when the browser is refusing to store the
5
6
  * cookie, so the first client-side navigation is already authenticated instead
@@ -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
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.31",
4
4
  "description": "Versioned foundation helpers and build context for Assembly generated apps",
5
5
  "type": "module",
6
6
  "files": [
@@ -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