@assembly-js/app-foundation 0.0.47 → 0.0.48

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/CHANGELOG.md CHANGED
@@ -5,18 +5,21 @@ lists what that version means for an app that already exists, one kind per bulle
5
5
 
6
6
  - `[fix]` — fixed on install; the app's code doesn't change.
7
7
  - `[behavior]` — the app behaves differently on install with no code change; its owner should know.
8
- - `[action: <id>]` — the app's own code must change; `<id>` names the `fixes.json` entry that
9
- detects the gap and tells the update agent how to close it.
8
+ - `[action]` — the app's own code must change. A release with an `[action]` bullet ships a
9
+ `bin/check-*.mjs` script that detects the gap and prints how to close it.
10
10
  - `[new]` — something the app can adopt; nothing changes until it does.
11
11
  - `[guidance]` — changes what the build agent is taught for future work, not existing code.
12
12
 
13
13
  A foundation update reads the sections between an app's version and the target: owner-facing copy
14
- from every bullet except `[guidance]`, agent work from the `fixes.json` entries those `[action]`
15
- bullets name. Write `[fix]`, `[behavior]` and `[action]` bullets for the app's owner.
14
+ from every bullet except `[guidance]`, agent work from the check scripts those `[action]`
15
+ bullets ship. Write `[fix]`, `[behavior]` and `[action]` bullets for the app's owner.
16
+
17
+ ## 0.0.48
18
+ - [guidance] Builds now warn when an app lacks the retry screen for platform outages, calls the child-row batch detached from the database, or imports a PDF library on the server that breaks after publishing, with the exact steps to fix each; nothing in an existing app changes until its code is updated.
16
19
 
17
20
  ## 0.0.47
18
21
  - [new] Apps can read the text of uploaded PDFs with a built-in parser that keeps working after publishing.
19
- - [action: server-pdf-text-extraction] PDF uploads that parsed in the preview but failed after publishing now parse correctly; apps that read PDFs with their own parser switch to the built-in one.
22
+ - [action] PDF uploads that parsed in the preview but failed after publishing now parse correctly; apps that read PDFs with their own parser switch to the built-in one.
20
23
  - [guidance] Tell the build agent why PDF and similar libraries break after deploy (they look up worker scripts and native bindings by their own path), to use the built-in PDF text helper, and to preview PDFs in a new tab or on a canvas rather than in an iframe.
21
24
 
22
25
  ## 0.0.46
@@ -32,14 +35,14 @@ bullets name. Write `[fix]`, `[behavior]` and `[action]` bullets for the app's o
32
35
  - [guidance] The build agent's SDK method tables are now generated from the SDK instead of hand-written.
33
36
 
34
37
  ## 0.0.42
35
- - [action: package-proxy] Sign-in and cross-site request protection now come from the package instead of code the app owns, and are checked by sending real requests through the app.
38
+ - [action] Sign-in and cross-site request protection now come from the package instead of code the app owns, and are checked by sending real requests through the app.
36
39
 
37
40
  ## 0.0.41
38
41
  - [guidance] Tell the build agent where an owner updates an API key that has expired or been rejected (App secrets in the builder chat, then republish), instead of sending them to support.
39
42
 
40
43
  ## 0.0.40
41
44
  - [behavior] Confirm each viewer's session with the platform instead of trusting the token alone, cached briefly per server. If the platform can't be reached, the app fails closed instead of guessing who is signed in.
42
- - [action: identity-outage-states] Show a retry screen when the platform can't confirm the viewer instead of an error page, and stop a failing app from renewing its session over and over.
45
+ - [action] Show a retry screen when the platform can't confirm the viewer instead of an error page, and stop a failing app from renewing its session over and over.
43
46
 
44
47
  ## 0.0.39
45
48
  - [fix] Let an app keep a scheduled job it already runs more often than every 30 minutes: the cron check now warns about that schedule instead of failing the build.
@@ -49,7 +52,7 @@ bullets name. Write `[fix]`, `[behavior]` and `[action]` bullets for the app's o
49
52
 
50
53
  ## 0.0.37
51
54
  - [fix] Fix saves of records with nested rows (a proposal's packages, a retainer's allowances) failing with a 500 and leaving the parent without its children.
52
- - [action: inlined-child-rows-batch] Apps that copied the child-row helper into their own code carry the same bug and need the package's helper instead.
55
+ - [action] Apps that copied the child-row helper into their own code carry the same bug and need the package's helper instead.
53
56
 
54
57
  ## 0.0.36
55
58
  - [fix] Accept notification category ids with underscores (`past_due`), so a build no longer fails on the id the user asked for.
@@ -114,13 +117,13 @@ bullets name. Write `[fix]`, `[behavior]` and `[action]` bullets for the app's o
114
117
 
115
118
  ## 0.0.22
116
119
  - [new] Ship vNext content: origin bootstrap and a cookie-outcome log line.
117
- - [action: paint-beacon] The builder preview now tells a page that rendered from one that only loaded, which needs the paint beacon in the app.
120
+ - [action] The builder preview now tells a page that rendered from one that only loaded, which needs the paint beacon in the app.
118
121
 
119
122
  ## 0.0.21
120
123
  - [new] Add Slack error-code and channel-list helpers, and a tripwire against an accidental `useServerAction` fetch loop, ahead of connector GA.
121
124
 
122
125
  ## 0.0.20
123
- - [action: next-config-wrapper] The live preview no longer hangs on networks that block its dev connection, which needs the app's Next config split into the platform wrapper and the app's own settings.
126
+ - [action] The live preview no longer hangs on networks that block its dev connection, which needs the app's Next config split into the platform wrapper and the app's own settings.
124
127
 
125
128
  ## 0.0.19
126
129
  - [guidance] Align the connector action catalog's signatures with the vendored scripts' actual behavior.
@@ -0,0 +1,99 @@
1
+ #!/usr/bin/env node
2
+ // Fails the build when app code calls drizzle's batch detached from `db`.
3
+ // Dependency-free on purpose: it runs inside the generated app's sandbox
4
+ // before any deps beyond Node exist.
5
+ //
6
+ // Apps that copied the child-row replace helper before foundation 0.0.37 call
7
+ // `const batch = db.batch …; await batch([...])`. drizzle's batch reads
8
+ // this.session, so detached it throws and every save that writes nested rows
9
+ // 500s, leaving the parent without its children. The package's
10
+ // replaceChildRows is the fix; the minimal alternative is `batch.call(db, …)`.
11
+ //
12
+ // An app with no such call (including every app that never copied the helper)
13
+ // passes, so there is no generation marker to check.
14
+ //
15
+ // A hit is a warning on an ordinary turn and fails the build with --strict
16
+ // (the update acceptance) or when ASSEMBLY_TURN_BASELINE_SHA says this turn
17
+ // changed the offending file.
18
+ import { readdirSync, readFileSync } from 'node:fs';
19
+ import { spawnSync } from 'node:child_process';
20
+ import { join, relative } from 'node:path';
21
+
22
+ const ROOT = process.cwd();
23
+ const STRICT = process.argv.includes('--strict');
24
+ const TURN_BASELINE_ENV = 'ASSEMBLY_TURN_BASELINE_SHA';
25
+
26
+ const SOURCE_FILE = /\.(?:ts|tsx|mts|js|jsx|mjs)$/;
27
+ const SKIPPED_DIRS = new Set(['node_modules', '.next', '.git', '.assembly']);
28
+ // A bare `batch([` — not `db.batch([` or `foo_batch([`, which keep their receiver.
29
+ const DETACHED_BATCH = /(^|[^.\w])batch\(\[/;
30
+
31
+ const hits = scan(ROOT);
32
+ if (hits.length === 0) process.exit(0);
33
+
34
+ const wasChanged = changedThisTurn();
35
+ const fatal = STRICT || hits.some((hit) => wasChanged(hit.file));
36
+ const out = fatal ? console.error : console.warn;
37
+ out('');
38
+ out(
39
+ fatal
40
+ ? 'check-child-rows-batch: drizzle batch is called detached from db:'
41
+ : 'check-child-rows-batch: drizzle batch is called detached from db (pre-existing; this build is not failed for it):',
42
+ );
43
+ out('');
44
+ for (const { file, line } of hits) out(` - ${file}:${line}`);
45
+ out('');
46
+ out("drizzle's batch reads this.session, so in a deployed app every save that writes nested rows");
47
+ out('fails with a 500 and leaves the parent without its children. Either:');
48
+ out('');
49
+ out(" - delete the app's copy of the child-row helper and import the package's:");
50
+ out(" import { replaceChildRows } from '@assembly-js/app-foundation/server';");
51
+ out(' Its signature is the one the copy was made from, so call sites keep their arguments; or');
52
+ out(' - if the copy has drifted and cannot be swapped cleanly, change only the call to');
53
+ out(' await batch.call(db, [first, ...rest]);');
54
+ out('');
55
+ out('Do not change what the helper writes or the order it writes in.');
56
+ if (fatal) process.exit(1);
57
+
58
+ function scan(dir) {
59
+ const found = [];
60
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
61
+ const path = join(dir, entry.name);
62
+ if (entry.isDirectory()) {
63
+ if (!SKIPPED_DIRS.has(entry.name)) found.push(...scan(path));
64
+ } else if (entry.isFile() && SOURCE_FILE.test(entry.name)) {
65
+ const file = relative(ROOT, path);
66
+ readFileSync(path, 'utf8')
67
+ .split('\n')
68
+ .forEach((text, i) => {
69
+ // Whole-line comments only: a block-comment strip would eat string literals.
70
+ if (!/^\s*(\/\/|\*|\/\*)/.test(text) && DETACHED_BATCH.test(text)) {
71
+ found.push({ file, line: i + 1 });
72
+ }
73
+ });
74
+ }
75
+ }
76
+ return found;
77
+ }
78
+
79
+ // Returns a predicate over repo-relative paths changed since the turn baseline.
80
+ function changedThisTurn() {
81
+ const baseline = (process.env[TURN_BASELINE_ENV] ?? '').trim();
82
+ if (!baseline) return () => false;
83
+ const changed = new Set();
84
+ const listings = [
85
+ { args: ['diff', '--name-only', `${baseline}..HEAD`], parse: (line) => line },
86
+ // Porcelain rows are "XY path", or "XY old -> new" for a rename.
87
+ { args: ['status', '--porcelain', '--untracked-files=all'], parse: (line) => line.slice(3).split(' -> ').pop() },
88
+ ];
89
+ for (const { args, parse } of listings) {
90
+ const result = spawnSync('git', args, { cwd: ROOT, encoding: 'utf8' });
91
+ // Unknown history: treat every file as changed rather than let a broken app through.
92
+ if (result.status !== 0) return () => true;
93
+ for (const line of result.stdout.split('\n')) {
94
+ const file = parse(line).trim();
95
+ if (file) changed.add(file);
96
+ }
97
+ }
98
+ return (file) => changed.has(file);
99
+ }
@@ -0,0 +1,150 @@
1
+ #!/usr/bin/env node
2
+ // Checks that the app handles the platform being unable to confirm the viewer.
3
+ // Dependency-free on purpose: it runs inside the generated app's sandbox
4
+ // before any deps beyond Node exist.
5
+ //
6
+ // Since foundation 0.0.40, getSessionIdentity() throws AuthUnavailableError
7
+ // when the platform can't be reached. The app must catch it in the root layout
8
+ // and render a retry screen, and its client context must bound session renewal
9
+ // with createRenewalGate — otherwise an outage fails every page and every
10
+ // failed action renews the session without limit.
11
+ //
12
+ // An app whose root layout never calls getSessionIdentity predates identity
13
+ // confirmation, so the contract never applied and this check exits 0.
14
+ //
15
+ // A gap is a warning on an ordinary turn (no agent there can act on an app
16
+ // that was already behind) and fails the build with --strict (the update
17
+ // acceptance) or when ASSEMBLY_TURN_BASELINE_SHA says this turn changed one of
18
+ // the contract files.
19
+ import { existsSync, readFileSync } from 'node:fs';
20
+ import { spawnSync } from 'node:child_process';
21
+ import { join } from 'node:path';
22
+
23
+ const ROOT = process.cwd();
24
+ const STRICT = process.argv.includes('--strict');
25
+ const TURN_BASELINE_ENV = 'ASSEMBLY_TURN_BASELINE_SHA';
26
+
27
+ const LAYOUT_FILE = 'app/layout.tsx';
28
+ const CONTEXT_FILE = 'app/components/assembly-context.tsx';
29
+ const RETRY_SCREEN_FILE = 'app/components/auth-unavailable.tsx';
30
+ const RENEWAL_GATE_FILE = 'app/components/renewal-gate.ts';
31
+ const CONTRACT_FILES = [LAYOUT_FILE, CONTEXT_FILE, RETRY_SCREEN_FILE, RENEWAL_GATE_FILE];
32
+
33
+ const read = (file) => (existsSync(join(ROOT, file)) ? readFileSync(join(ROOT, file), 'utf8') : null);
34
+
35
+ const layout = read(LAYOUT_FILE);
36
+ if (layout === null || !/\bgetSessionIdentity\b/.test(layout)) {
37
+ process.exit(0); // pre-identity app: not this check's contract
38
+ }
39
+
40
+ const context = read(CONTEXT_FILE);
41
+ const problems = [];
42
+ if (read(RETRY_SCREEN_FILE) === null) problems.push(`${RETRY_SCREEN_FILE} is missing`);
43
+ if (!/AuthUnavailableError/.test(layout)) {
44
+ problems.push(`${LAYOUT_FILE} does not handle AuthUnavailableError from getSessionIdentity()`);
45
+ }
46
+ if (read(RENEWAL_GATE_FILE) === null) problems.push(`${RENEWAL_GATE_FILE} is missing`);
47
+ if (context !== null && !/createRenewalGate\s*\(/.test(context)) {
48
+ problems.push(`${CONTEXT_FILE} does not bound session renewal with createRenewalGate(`);
49
+ }
50
+
51
+ if (problems.length === 0) process.exit(0);
52
+
53
+ const fatal = STRICT || contractChangedThisTurn();
54
+ const out = fatal ? console.error : console.warn;
55
+ out('');
56
+ out(
57
+ fatal
58
+ ? 'check-identity-outage-states: the app does not handle the platform being unable to confirm the viewer:'
59
+ : 'check-identity-outage-states: the app does not handle the platform being unable to confirm the viewer (pre-existing; this build is not failed for it):',
60
+ );
61
+ out('');
62
+ for (const problem of problems) out(` - ${problem}`);
63
+ out('');
64
+ out('Since @assembly-js/app-foundation 0.0.40, getSessionIdentity() throws AuthUnavailableError when the');
65
+ out('platform cannot be reached. Bring these app-owned files to the foundation shape:');
66
+ out('');
67
+ out(`1. Create ${RETRY_SCREEN_FILE}:`);
68
+ out('');
69
+ out(" 'use client';");
70
+ out('');
71
+ out(' export default function AuthUnavailable() {');
72
+ out(' return (');
73
+ out(' <main className="flex min-h-screen flex-col items-center justify-center gap-3 px-4 text-center">');
74
+ out(' <p className="text-sm text-muted-foreground">');
75
+ out(' We couldn&apos;t load this right now. Please try again.');
76
+ out(' </p>');
77
+ out(' <button');
78
+ out(' type="button"');
79
+ out(' className="rounded-md border px-3 py-1.5 text-sm"');
80
+ out(' onClick={() => window.location.reload()}');
81
+ out(' >');
82
+ out(' Try again');
83
+ out(' </button>');
84
+ out(' </main>');
85
+ out(' );');
86
+ out(' }');
87
+ out('');
88
+ out(`2. In ${LAYOUT_FILE}, import it (import AuthUnavailable from '@/app/components/auth-unavailable';)`);
89
+ out(' and replace `const identity = await getSessionIdentity();` with:');
90
+ out('');
91
+ out(' let identity: Awaited<ReturnType<typeof getSessionIdentity>> = null;');
92
+ out(' let authUnavailable = false;');
93
+ out(' try {');
94
+ out(' identity = await getSessionIdentity();');
95
+ out(' } catch (err) {');
96
+ out(" if (!(err instanceof Error && err.name === 'AuthUnavailableError')) throw err;");
97
+ out(' authUnavailable = true;');
98
+ out(' }');
99
+ out(' if (authUnavailable) {');
100
+ out(' return (');
101
+ out(" <html lang=\"en\" style={{ colorScheme: 'light' }}>");
102
+ out(' <body className="font-sans antialiased">');
103
+ out(' <AuthUnavailable />');
104
+ out(' </body>');
105
+ out(' </html>');
106
+ out(' );');
107
+ out(' }');
108
+ out('');
109
+ out(`3. Create ${RENEWAL_GATE_FILE}:`);
110
+ out('');
111
+ out(' export function createRenewalGate(');
112
+ out(' renew: () => Promise<boolean>,');
113
+ out(' windowMs: number,');
114
+ out(' now: () => number = Date.now,');
115
+ out(' ): () => Promise<boolean> {');
116
+ out(' let inFlight: Promise<boolean> | null = null;');
117
+ out(' let lastStartedAt = Number.NEGATIVE_INFINITY;');
118
+ out(' return () => {');
119
+ out(' if (inFlight) return inFlight;');
120
+ out(' if (now() - lastStartedAt < windowMs) return Promise.resolve(false);');
121
+ out(' lastStartedAt = now();');
122
+ out(' inFlight = renew().finally(() => {');
123
+ out(' inFlight = null;');
124
+ out(' });');
125
+ out(' return inFlight;');
126
+ out(' };');
127
+ out(' }');
128
+ out('');
129
+ out(` Then in ${CONTEXT_FILE} add \`import { createRenewalGate } from './renewal-gate';\`, define`);
130
+ out(' `const renewOnce = createRenewalGate(reexchangeBearer, 30_000);` at module scope, and change the');
131
+ out(" retry path's `await reexchangeBearer()` to `await renewOnce()`. Change nothing else in that file.");
132
+ if (fatal) process.exit(1);
133
+
134
+ function contractChangedThisTurn() {
135
+ const baseline = (process.env[TURN_BASELINE_ENV] ?? '').trim();
136
+ if (!baseline) return false;
137
+ const listings = [
138
+ { args: ['diff', '--name-only', `${baseline}..HEAD`], parse: (line) => line },
139
+ // Porcelain rows are "XY path", or "XY old -> new" for a rename.
140
+ { args: ['status', '--porcelain', '--untracked-files=all'], parse: (line) => line.slice(3).split(' -> ').pop() },
141
+ ];
142
+ for (const { args, parse } of listings) {
143
+ const result = spawnSync('git', args, { cwd: ROOT, encoding: 'utf8' });
144
+ // Unknown history: treat as changed rather than let a broken app through.
145
+ if (result.status !== 0) return true;
146
+ const changed = result.stdout.split('\n').map((line) => parse(line).trim()).filter(Boolean);
147
+ if (changed.some((file) => CONTRACT_FILES.includes(file))) return true;
148
+ }
149
+ return false;
150
+ }
@@ -0,0 +1,102 @@
1
+ #!/usr/bin/env node
2
+ // Fails the build when app code imports pdf-parse or pdfjs-dist on the server.
3
+ // Dependency-free on purpose: it runs inside the generated app's sandbox
4
+ // before any deps beyond Node exist.
5
+ //
6
+ // Since foundation 0.0.47, PDF text extraction uses the built-in
7
+ // extractPdfText helper from @assembly-js/app-foundation/server. The old
8
+ // libraries locate their worker script and canvas binding at runtime by paths
9
+ // computed from their own location; the preview has the full node_modules so
10
+ // they work there, but the deployed function only ships files reachable from
11
+ // literal imports — every PDF parse fails after publishing with
12
+ // "DOMMatrix is not defined" or "Setting up fake worker failed".
13
+ //
14
+ // An app with no such import passes, so there is no generation marker to check.
15
+ //
16
+ // A hit is a warning on an ordinary turn and fails the build with --strict
17
+ // (the update acceptance) or when ASSEMBLY_TURN_BASELINE_SHA says this turn
18
+ // changed the offending file.
19
+ import { readdirSync, readFileSync } from 'node:fs';
20
+ import { spawnSync } from 'node:child_process';
21
+ import { join, relative } from 'node:path';
22
+
23
+ const ROOT = process.cwd();
24
+ const STRICT = process.argv.includes('--strict');
25
+ const TURN_BASELINE_ENV = 'ASSEMBLY_TURN_BASELINE_SHA';
26
+
27
+ const SOURCE_FILE = /\.(?:ts|tsx|mts|js|jsx|mjs)$/;
28
+ const SKIPPED_DIRS = new Set(['node_modules', '.next', '.git', '.assembly']);
29
+ // Server-side imports of pdf-parse or pdfjs-dist (any subpath).
30
+ const PDF_IMPORT = /(from|import\()\s*['"](pdf-parse|pdfjs-dist)\b/;
31
+ // 'use client' files may legitimately import pdfjs-dist for browser-side
32
+ // rendering (canvas, next/dynamic with ssr: false). The server-side breakage
33
+ // this check guards against does not apply to client components.
34
+ const USE_CLIENT = /^\s*['"]use client['"]/;
35
+
36
+ const hits = scan(ROOT);
37
+ if (hits.length === 0) process.exit(0);
38
+
39
+ const wasChanged = changedThisTurn();
40
+ const fatal = STRICT || hits.some((hit) => wasChanged(hit.file));
41
+ const out = fatal ? console.error : console.warn;
42
+ out('');
43
+ out(
44
+ fatal
45
+ ? 'check-server-pdf-imports: server code imports pdf-parse or pdfjs-dist:'
46
+ : 'check-server-pdf-imports: server code imports pdf-parse or pdfjs-dist (pre-existing; this build is not failed for it):',
47
+ );
48
+ out('');
49
+ for (const { file, line } of hits) out(` - ${file}:${line}`);
50
+ out('');
51
+ out('Those libraries locate their worker script and canvas binding at runtime by paths');
52
+ out('computed from their own location. The preview has the full node_modules so they');
53
+ out('work there, but the deployed function only ships files reachable from literal');
54
+ out('imports — every PDF parse fails after publishing.');
55
+ out('');
56
+ out("Replace each server-side use with the foundation's helper:");
57
+ out(" import { extractPdfText } from '@assembly-js/app-foundation/server';");
58
+ out(' const { text, pages } = await extractPdfText(bytes);');
59
+ out('');
60
+ out('Then remove the pdf-parse / pdfjs-dist / @napi-rs/canvas dependencies, any');
61
+ out('GlobalWorkerOptions.workerSrc setup, and their entries in serverExternalPackages');
62
+ out('in app.config.ts. Do not touch client-side PDF rendering code.');
63
+ if (fatal) process.exit(1);
64
+
65
+ function scan(dir) {
66
+ const found = [];
67
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
68
+ const path = join(dir, entry.name);
69
+ if (entry.isDirectory()) {
70
+ if (!SKIPPED_DIRS.has(entry.name)) found.push(...scan(path));
71
+ } else if (entry.isFile() && SOURCE_FILE.test(entry.name)) {
72
+ const file = relative(ROOT, path);
73
+ const lines = readFileSync(path, 'utf8').split('\n');
74
+ if (lines.some((line) => USE_CLIENT.test(line))) continue;
75
+ lines.forEach((text, i) => {
76
+ if (!/^\s*(\/\/|\*|\/\*)/.test(text) && PDF_IMPORT.test(text)) {
77
+ found.push({ file, line: i + 1 });
78
+ }
79
+ });
80
+ }
81
+ }
82
+ return found;
83
+ }
84
+
85
+ function changedThisTurn() {
86
+ const baseline = (process.env[TURN_BASELINE_ENV] ?? '').trim();
87
+ if (!baseline) return () => false;
88
+ const changed = new Set();
89
+ const listings = [
90
+ { args: ['diff', '--name-only', `${baseline}..HEAD`], parse: (line) => line },
91
+ { args: ['status', '--porcelain', '--untracked-files=all'], parse: (line) => line.slice(3).split(' -> ').pop() },
92
+ ];
93
+ for (const { args, parse } of listings) {
94
+ const result = spawnSync('git', args, { cwd: ROOT, encoding: 'utf8' });
95
+ if (result.status !== 0) return () => true;
96
+ for (const line of result.stdout.split('\n')) {
97
+ const file = parse(line).trim();
98
+ if (file) changed.add(file);
99
+ }
100
+ }
101
+ return (file) => changed.has(file);
102
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assembly-js/app-foundation",
3
- "version": "0.0.47",
3
+ "version": "0.0.48",
4
4
  "description": "Versioned foundation helpers and build context for Assembly generated apps",
5
5
  "type": "module",
6
6
  "files": [
@@ -9,7 +9,6 @@
9
9
  "skills",
10
10
  "CLAUDE.md",
11
11
  "render-context.mjs",
12
- "fixes.json",
13
12
  "CHANGELOG.md"
14
13
  ],
15
14
  "exports": {
package/fixes.json DELETED
@@ -1,78 +0,0 @@
1
- [
2
- {
3
- "id": "next-config-wrapper",
4
- "version": "0.0.20",
5
- "title": "Your app's live preview no longer hangs on networks that block its dev connection.",
6
- "check": "Your app's Next config must be split so next.config.ts is the platform-owned wrapper and your own settings live in app.config.ts. The end state: next.config.ts contains exactly:\n\nimport { withAssemblyDev } from \"@assembly-js/app-foundation/next\";\nimport appConfig from \"./app.config\";\n\nexport default withAssemblyDev(appConfig);\n\nTo get there: create app.config.ts and move whatever next.config.ts currently exports (serverExternalPackages, experimental flags, redirects, headers, etc.) into it verbatim, keeping the export shape the app already used (e.g. `export default { ... }` or `export default appConfig satisfies NextConfig`). Then replace next.config.ts's contents with the wrapper above. If a sibling next.config.js or next.config.mjs exists, delete it — Next loads whichever config file it finds first, and a leftover unwrapped copy would silently run instead of the wrapper. Do not add new Next options while you're at it, and do not touch anything outside these two files — this fix is a relocation, not a config change.",
7
- "detector": {
8
- "absent": ["next.config.js", "next.config.mjs"],
9
- "present": [
10
- { "path": "next.config.ts", "contains": ["withAssemblyDev("] },
11
- { "path": "app.config.ts" }
12
- ]
13
- }
14
- },
15
- {
16
- "id": "paint-beacon",
17
- "version": "0.0.22",
18
- "title": "Your app's preview now accurately reports when a page has finished rendering, not just loaded.",
19
- "check": "Your app is missing the post-paint beacon the builder preview uses to tell \"the page executed\" from \"the page actually rendered.\" Add the component exactly as the foundation ships it, at app/components/paint-beacon.tsx:\n\n'use client';\n\nimport { useEffect } from 'react';\nimport { installPaintBeacon } from '@assembly-js/app-foundation/client';\n\nexport default function PaintBeacon(): null {\n useEffect(() => {\n installPaintBeacon();\n }, []);\n return null;\n}\n\nRender it once, self-closing and prop-less, in the root layout's <body> (app/layout.tsx): import it as `import PaintBeacon from '@/app/components/paint-beacon';` and add `<PaintBeacon />` alongside the other foundation-owned providers already rendered there.\n\nThe beacon's POST must be routed before any CSRF or session-exchange handling, because sendBeacon is a headerless POST that 403s on the normal path. The platform sign-in proxy already does this, so proxy.ts's proxy export should be the package's:\n\nimport { createAssemblyProxy } from '@assembly-js/app-foundation/server';\n\nexport const proxy = createAssemblyProxy();\n\nIf proxy.ts has been rewritten away from that, restore that export (keep the file's `config` matcher as it is) rather than adding a beacon route by hand. Make sure @assembly-js/app-foundation is a declared dependency (it should already be installed). Do not post app.painted or hit /__assembly/painted yourself — the component already does both.",
20
- "detector": {
21
- "present": [
22
- { "path": "app/components/paint-beacon.tsx" },
23
- { "path": "app/layout.tsx", "contains": ["<PaintBeacon"] }
24
- ],
25
- "dependencies": ["@assembly-js/app-foundation"],
26
- "checks": [{ "of": "@assembly-js/app-foundation", "script": "check-paint-beacon.mjs" }]
27
- }
28
- },
29
- {
30
- "id": "inlined-child-rows-batch",
31
- "version": "0.0.37",
32
- "title": "Saving a record with nested rows no longer fails and drops the rows.",
33
- "check": "Your app has its own copy of the child-row replace helper, and that copy calls drizzle's batch detached from db (`const batch = db.batch …; await batch([...])`). drizzle's batch reads this.session, so in a deployed app every save that writes nested rows (a proposal's packages, a retainer's allowances) fails with a 500 and leaves the parent without its children.\n\nDelete the app's copy and import the package's helper instead: `import { replaceChildRows } from '@assembly-js/app-foundation/server';`. Its signature is the one the copy was made from, so call sites keep their arguments. If the copy has drifted from the original and can't be swapped cleanly, keep it and change only the call to `await batch.call(db, [first, ...rest]);`. Do not change what the helper writes or the order it writes in.",
34
- "detector": {
35
- "forbidden": [{ "id": "detached-db-batch", "pattern": "(^|[^.\\w])batch\\(\\[" }]
36
- }
37
- },
38
- {
39
- "id": "identity-outage-states",
40
- "version": "0.0.40",
41
- "title": "If Assembly can't confirm who is signed in, your app now shows a retry screen instead of an error.",
42
- "check": "Since 0.0.40, getSessionIdentity() asks the platform who the viewer is and throws an AuthUnavailableError when the platform can't be reached. Your root layout calls it without a catch, so during an outage every page fails instead of showing a retry screen, and every failed action renews the session without a bound. Bring three app-owned files to the foundation's current shape:\n\n1. Create app/components/auth-unavailable.tsx:\n\n'use client';\n\nexport default function AuthUnavailable() {\n return (\n <main className=\"flex min-h-screen flex-col items-center justify-center gap-3 px-4 text-center\">\n <p className=\"text-sm text-muted-foreground\">\n We couldn&apos;t load this right now. Please try again.\n </p>\n <button\n type=\"button\"\n className=\"rounded-md border px-3 py-1.5 text-sm\"\n onClick={() => window.location.reload()}\n >\n Try again\n </button>\n </main>\n );\n}\n\n2. In app/layout.tsx, import it (`import AuthUnavailable from '@/app/components/auth-unavailable';`) and replace `const identity = await getSessionIdentity();` with:\n\nlet identity: Awaited<ReturnType<typeof getSessionIdentity>> = null;\nlet authUnavailable = false;\ntry {\n identity = await getSessionIdentity();\n} catch (err) {\n if (!(err instanceof Error && err.name === 'AuthUnavailableError')) throw err;\n authUnavailable = true;\n}\nif (authUnavailable) {\n return (\n <html lang=\"en\" style={{ colorScheme: 'light' }}>\n <body className=\"font-sans antialiased\">\n <AuthUnavailable />\n </body>\n </html>\n );\n}\n\nKeep everything else in the layout as it is.\n\n3. Create app/components/renewal-gate.ts:\n\nexport function createRenewalGate(\n renew: () => Promise<boolean>,\n windowMs: number,\n now: () => number = Date.now,\n): () => Promise<boolean> {\n let inFlight: Promise<boolean> | null = null;\n let lastStartedAt = Number.NEGATIVE_INFINITY;\n return () => {\n if (inFlight) return inFlight;\n if (now() - lastStartedAt < windowMs) return Promise.resolve(false);\n lastStartedAt = now();\n inFlight = renew().finally(() => {\n inFlight = null;\n });\n return inFlight;\n };\n}\n\nThen in app/components/assembly-context.tsx add `import { createRenewalGate } from './renewal-gate';`, define `const renewOnce = createRenewalGate(reexchangeBearer, 30_000);` at module scope, and change the retry path's `await reexchangeBearer()` to `await renewOnce()`. Change nothing else in that file.",
43
- "detector": {
44
- "present": [
45
- { "path": "app/components/auth-unavailable.tsx" },
46
- { "path": "app/layout.tsx", "contains": ["AuthUnavailableError"] },
47
- { "path": "app/components/renewal-gate.ts" },
48
- { "path": "app/components/assembly-context.tsx", "contains": ["createRenewalGate("] }
49
- ]
50
- }
51
- },
52
- {
53
- "id": "package-proxy",
54
- "version": "0.0.42",
55
- "title": "Your app's sign-in and cross-site request protection now come from Assembly and are tested by it.",
56
- "check": "Your app's sign-in proxy is still code the app owns. Since 0.0.42 the package provides all of it — the token exchange, the bearer cookie, the CSRF gate and the re-exchange pass-through — and proxy.ts only calls it:\n\nimport { createAssemblyProxy } from '@assembly-js/app-foundation/server';\n\nexport const proxy = createAssemblyProxy();\n\nReplace the proxy function in proxy.ts with that export and delete a root middleware.ts if one exists. Keep the file's `config` matcher, including any exclusions the app added for its own paths; `api/assembly-reexchange` must stay excluded. Carry over only what the app added on top of the foundation's proxy: request headers it set for its own pages go in `createAssemblyProxy({ onForward({ request, headers, session }) { headers.set(…) } })`. Do not keep, port or rewrite any token, cookie or CSRF handling. Verify with `node node_modules/@assembly-js/app-foundation/bin/check-proxy-csrf.mjs --strict`; if it fails, the fix is the file above, not a change to request handling.",
57
- "detector": {
58
- "absent": ["middleware.ts"],
59
- "present": [{ "path": "proxy.ts", "contains": ["@assembly-js/app-foundation/server", "createAssemblyProxy("] }],
60
- "dependencies": ["@assembly-js/app-foundation"],
61
- "checks": [{ "of": "@assembly-js/app-foundation", "script": "check-proxy-csrf.mjs" }]
62
- }
63
- },
64
- {
65
- "id": "server-pdf-text-extraction",
66
- "version": "0.0.47",
67
- "title": "PDF uploads that parsed in the preview but failed after publishing now parse correctly.",
68
- "check": "Your app reads PDF text on the server with pdf-parse or pdfjs-dist. Those libraries locate their worker script and the optional canvas binding at runtime by paths computed from their own location; the preview has the full node_modules so it works there, but the deployed function only ships the files reachable from literal imports, so every PDF parse fails after publishing with \"DOMMatrix is not defined\" or \"Setting up fake worker failed\".\n\nReplace each server-side use with the foundation's helper: `import { extractPdfText } from '@assembly-js/app-foundation/server';` then `const { text, pages } = await extractPdfText(bytes);` where `bytes` is the Buffer, Uint8Array or ArrayBuffer the code already has (read back from S3 or from the request). `text` is the whole document with pages separated by newlines and is empty for a scanned PDF with no text layer, so keep whatever fallback the app had for that case. Keep what the app does with the text unchanged.\n\nThen remove what only existed to support the old parser: the pdf-parse, pdfjs-dist and @napi-rs/canvas dependencies, any GlobalWorkerOptions.workerSrc setup, any static `import '@napi-rs/canvas'` or worker import, and their entries in serverExternalPackages in app.config.ts. Do not touch client-side PDF rendering code.",
69
- "detector": {
70
- "forbidden": [
71
- {
72
- "id": "server-pdf-parser-import",
73
- "pattern": "(from|import\\()\\s*['\"](pdf-parse|pdfjs-dist/legacy/build/pdf(\\.mjs)?)['\"]"
74
- }
75
- ]
76
- }
77
- }
78
- ]