@assembly-js/app-foundation 0.3.9 → 0.4.0
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 +10 -0
- package/CLAUDE.md +6 -0
- package/bin/check-next-config.mjs +22 -3
- package/bin/check-reexchange-route.mjs +8 -7
- package/bin/check-route-reachability.mjs +55 -1
- package/bin/check-server-pdf-imports.mjs +3 -0
- package/dist/pdf.d.ts +7 -2
- package/dist/pdf.js +31 -3
- package/dist/proxy.js +4 -6
- package/dist/reexchange.js +2 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -19,6 +19,16 @@ A patch release never changes what an app's build requires; a minor release may
|
|
|
19
19
|
not by hand; see `changes/README.md`. CI fails a PR that changes a check script without a
|
|
20
20
|
`bump: minor` change file.
|
|
21
21
|
|
|
22
|
+
## 0.4.0
|
|
23
|
+
- [fix] The renewal response is never cached.
|
|
24
|
+
- [fix] Analytics identify the viewer again in migrated apps.
|
|
25
|
+
- [new] `extractPdfText` takes `maxPages` to cap how many pages are read.
|
|
26
|
+
- [action] An app without `app.config.ts` moves its Next options there on a platform update.
|
|
27
|
+
- [guidance] The checks' text changed: the PDF page cap, the renewal route wording, and route reachability on update turns.
|
|
28
|
+
|
|
29
|
+
## 0.3.10
|
|
30
|
+
- [guidance] Build agents are told to reuse an existing provider module and warned that adding a new requiredSecrets entry pauses a published app at the setup form.
|
|
31
|
+
|
|
22
32
|
## 0.3.9
|
|
23
33
|
- [fix] `getOfflineAssembly` from `@assembly-js/app-foundation/scripts` now refuses to run inside Next, so a route under `app/` can no longer use it to act with the installer's access. The entry shipped in 0.2.1 for standalone scripts only and nothing the build agent is taught imports it from a route, so no existing app's code needs to change.
|
|
24
34
|
|
package/CLAUDE.md
CHANGED
|
@@ -535,6 +535,12 @@ Two paths, decided by whether an `assembly-integrations` skill is present and, i
|
|
|
535
535
|
1. **Read the credential only from `process.env.<KEY>`, server-side only.** Route handlers, server components, and server actions can read it. Client components, `'use client'` modules, and anything that ends up in the browser bundle cannot. Never hardcode a value, never persist one to the database, never read from the URL, never write `.env*` files.
|
|
536
536
|
2. **Declare every secret you read in `.assembly/app.json`.** Add an entry under a top-level `requiredSecrets` array in the same change that introduces the call. The Assembly platform reads this manifest after the build, prompts the user for each value, and injects them as Vercel env vars at deploy.
|
|
537
537
|
|
|
538
|
+
### Adding a secret to an app that already calls the provider
|
|
539
|
+
|
|
540
|
+
- If the app already calls this provider, extend that module rather than declaring a second credential for the same provider.
|
|
541
|
+
- If that module reads its credential from the app database, keep using it for this change, tell the user once, and offer moving it to `requiredSecrets` as a separate step (they re-enter the value). Do not read or move the values. This does not relax rule 1: never store a new credential in the database.
|
|
542
|
+
- Adding a new `requiredSecrets` entry to a published app pauses it at the setup form until the value is supplied.
|
|
543
|
+
|
|
538
544
|
### Manifest shape
|
|
539
545
|
|
|
540
546
|
```json
|
|
@@ -12,17 +12,36 @@
|
|
|
12
12
|
// wrapper spreads. Prose rules get skipped; a failing build doesn't.
|
|
13
13
|
//
|
|
14
14
|
// An app without app.config.ts predates the wrapper split (cloned from an older
|
|
15
|
-
// foundation).
|
|
16
|
-
// exits 0 — those apps are protected by the boot/restart patch
|
|
15
|
+
// foundation). Off an update turn the wrapper form was never the contract, so
|
|
16
|
+
// this check exits 0 — those apps are protected by the boot/restart patch
|
|
17
|
+
// scripts instead. On a platform update turn (or --strict) it asks for the
|
|
18
|
+
// split, because those apps otherwise pass the build and fail acceptance.
|
|
17
19
|
import { readFileSync, existsSync } from 'node:fs';
|
|
18
20
|
import { join } from 'node:path';
|
|
19
21
|
|
|
20
22
|
const ROOT = process.cwd();
|
|
23
|
+
const STRICT = process.argv.includes('--strict') || process.env.ASSEMBLY_UPDATE_TURN === '1';
|
|
21
24
|
const CONFIG_FILES = ['next.config.ts', 'next.config.mjs', 'next.config.js'];
|
|
22
25
|
const APP_CONFIG_FILES = ['app.config.ts', 'app.config.mjs', 'app.config.js'];
|
|
23
26
|
|
|
24
27
|
if (!APP_CONFIG_FILES.some((f) => existsSync(join(ROOT, f)))) {
|
|
25
|
-
process.exit(0); // pre-wrapper app: not this check's contract
|
|
28
|
+
if (!STRICT) process.exit(0); // pre-wrapper app: not this check's contract
|
|
29
|
+
console.error(
|
|
30
|
+
'check-next-config: this app has no app.config.ts, so its Next options still live in next.config.*.\n\n' +
|
|
31
|
+
'Split them:\n' +
|
|
32
|
+
' 1. Create app.config.ts exporting the app\'s current Next options:\n\n' +
|
|
33
|
+
' import type { NextConfig } from "next";\n' +
|
|
34
|
+
' const appConfig: NextConfig = { /* the options from next.config */ };\n' +
|
|
35
|
+
' export default appConfig;\n\n' +
|
|
36
|
+
' Leave out allowedDevOrigins, devIndicators and experimental.reactDebugChannel: the platform\n' +
|
|
37
|
+
' injects those and withAssemblyDev applies them.\n' +
|
|
38
|
+
' 2. Replace next.config.ts with the platform wrapper:\n\n' +
|
|
39
|
+
' import { withAssemblyDev } from "@assembly-js/app-foundation/next";\n' +
|
|
40
|
+
' import appConfig from "./app.config";\n' +
|
|
41
|
+
' export default withAssemblyDev(appConfig);\n\n' +
|
|
42
|
+
' 3. Delete any other next.config.* file.\n',
|
|
43
|
+
);
|
|
44
|
+
process.exit(1);
|
|
26
45
|
}
|
|
27
46
|
|
|
28
47
|
const present = CONFIG_FILES.filter((f) => existsSync(join(ROOT, f)));
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// Fails the build when the app's silent-renewal route is a hand-written copy
|
|
3
|
-
// instead of the package's handler. The
|
|
4
|
-
// (
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
3
|
+
// instead of the package's handler. The handler is the one copy the platform
|
|
4
|
+
// maintains (CSRF check, cookie clear-and-set, cookie probe, Safari body
|
|
5
|
+
// fallback); a hand-written copy may be missing any of them. Dependency-free
|
|
6
|
+
// on purpose: it runs inside the generated app's sandbox before any deps
|
|
7
|
+
// beyond Node exist.
|
|
8
8
|
//
|
|
9
9
|
// An app with no such route passes; check-proxy-csrf owns the missing-route
|
|
10
10
|
// finding.
|
|
@@ -40,8 +40,9 @@ out(
|
|
|
40
40
|
: `check-reexchange-route: ${ROUTE_FILE} is not the package handler (pre-existing; this build is not failed for it):`,
|
|
41
41
|
);
|
|
42
42
|
out('');
|
|
43
|
-
out('
|
|
44
|
-
out('
|
|
43
|
+
out('The package handler is the one copy the platform maintains (CSRF check, cookie clear-and-set,');
|
|
44
|
+
out('cookie probe, Safari body fallback). A hand-written copy may be missing any of those, so');
|
|
45
|
+
out('replace the whole file with exactly this:');
|
|
45
46
|
out('');
|
|
46
47
|
out(" import { createAssemblyReexchangeHandler } from '@assembly-js/app-foundation/server';");
|
|
47
48
|
out('');
|
|
@@ -9,10 +9,19 @@
|
|
|
9
9
|
// is statically evident string references — fully dynamic path construction
|
|
10
10
|
// is out of scope (the gate guards against pages the agent forgot to link,
|
|
11
11
|
// not obfuscation).
|
|
12
|
+
//
|
|
13
|
+
// On a platform update turn (ASSEMBLY_UPDATE_TURN=1) a page that was already
|
|
14
|
+
// unreferenced at ASSEMBLY_TURN_BASELINE_SHA only warns: an update never adds
|
|
15
|
+
// links or removes pages, so failing it would push the agent to delete code.
|
|
16
|
+
// A page added this turn, or orphaned this turn, still fails.
|
|
17
|
+
import { spawnSync } from 'node:child_process';
|
|
12
18
|
import { readFileSync, readdirSync, existsSync } from 'node:fs';
|
|
13
19
|
import { join, relative, sep } from 'node:path';
|
|
14
20
|
|
|
15
21
|
const ROOT = process.cwd();
|
|
22
|
+
const UPDATE_TURN = process.env.ASSEMBLY_UPDATE_TURN === '1';
|
|
23
|
+
const BASELINE = (process.env.ASSEMBLY_TURN_BASELINE_SHA ?? '').trim();
|
|
24
|
+
const SOURCE_FILE = /\.(tsx|ts|jsx|js|mjs)$/;
|
|
16
25
|
const APP_DIR = join(ROOT, 'app');
|
|
17
26
|
const MANIFEST = join(ROOT, '.assembly', 'app.json');
|
|
18
27
|
|
|
@@ -93,7 +102,37 @@ function referencePattern(route) {
|
|
|
93
102
|
return new RegExp(`['"\`]${escapeRe(prefix)}`);
|
|
94
103
|
}
|
|
95
104
|
|
|
105
|
+
function git(args) {
|
|
106
|
+
const result = spawnSync('git', args, { cwd: ROOT, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
|
107
|
+
return result.status === 0 ? result.stdout : null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Sources at the baseline commit, or null when there is no baseline or git
|
|
111
|
+
// cannot answer. Matched in JS rather than `git grep` so the JS reference
|
|
112
|
+
// regex needs no translation to another dialect.
|
|
113
|
+
function baselineSources() {
|
|
114
|
+
if (!BASELINE) return null;
|
|
115
|
+
const listing = git(['ls-tree', '-r', '--name-only', BASELINE, '--', 'app', 'components', 'lib']);
|
|
116
|
+
if (listing === null) return null;
|
|
117
|
+
const sources = new Map();
|
|
118
|
+
for (const path of listing.split('\n').filter((p) => SOURCE_FILE.test(p))) {
|
|
119
|
+
const content = git(['show', `${BASELINE}:${path}`]);
|
|
120
|
+
if (content === null) return null;
|
|
121
|
+
sources.set(path, content);
|
|
122
|
+
}
|
|
123
|
+
return sources;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// True only for a page that existed at the baseline and was already
|
|
127
|
+
// unreferenced there, so the agent did not cause the orphan.
|
|
128
|
+
function wasOrphanedAtBaseline(sources, path, pattern) {
|
|
129
|
+
if (!sources || !sources.has(path)) return false;
|
|
130
|
+
return ![...sources].some(([other, content]) => other !== path && pattern.test(content));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const sources = UPDATE_TURN ? baselineSources() : null;
|
|
96
134
|
const violations = [];
|
|
135
|
+
const preExisting = [];
|
|
97
136
|
for (const file of pageFiles) {
|
|
98
137
|
const route = routeForPageFile(file);
|
|
99
138
|
if (allowed.has(route)) continue;
|
|
@@ -103,10 +142,25 @@ for (const file of pageFiles) {
|
|
|
103
142
|
(other) => other !== file && pattern.test(contents.get(other)),
|
|
104
143
|
);
|
|
105
144
|
if (!referenced) {
|
|
106
|
-
|
|
145
|
+
const rel = relative(ROOT, file).split(sep).join('/');
|
|
146
|
+
const entry = `${rel} (route ${route})`;
|
|
147
|
+
if (UPDATE_TURN && (!BASELINE || sources === null || wasOrphanedAtBaseline(sources, rel, pattern))) {
|
|
148
|
+
preExisting.push(entry);
|
|
149
|
+
} else {
|
|
150
|
+
violations.push(entry);
|
|
151
|
+
}
|
|
107
152
|
}
|
|
108
153
|
}
|
|
109
154
|
|
|
155
|
+
if (preExisting.length > 0) {
|
|
156
|
+
console.warn('');
|
|
157
|
+
console.warn(
|
|
158
|
+
'check-route-reachability: unreferenced pages (pre-existing; this build is not failed for it — a platform update never adds links or removes pages):',
|
|
159
|
+
);
|
|
160
|
+
for (const v of preExisting) console.warn(' - ' + v);
|
|
161
|
+
console.warn('');
|
|
162
|
+
}
|
|
163
|
+
|
|
110
164
|
if (violations.length > 0) {
|
|
111
165
|
console.error('');
|
|
112
166
|
console.error('Unreachable page routes — nothing in the app links to them:');
|
|
@@ -57,6 +57,9 @@ out("Replace each server-side use with the foundation's helper:");
|
|
|
57
57
|
out(" import { extractPdfText } from '@assembly-js/app-foundation/server';");
|
|
58
58
|
out(' const { text, pages } = await extractPdfText(bytes);');
|
|
59
59
|
out('');
|
|
60
|
+
out('Keep whatever page limit the old code applied by passing `{ maxPages: N }`, and keep any');
|
|
61
|
+
out('byte-size guard in front of the call.');
|
|
62
|
+
out('');
|
|
60
63
|
out('Then remove the pdf-parse / pdfjs-dist / @napi-rs/canvas dependencies, any');
|
|
61
64
|
out('GlobalWorkerOptions.workerSrc setup, and their entries in serverExternalPackages');
|
|
62
65
|
out('in app.config.ts. Do not touch client-side PDF rendering code.');
|
package/dist/pdf.d.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
export interface PdfText {
|
|
2
|
-
/** Every page's text, pages separated by a newline. Empty when the PDF has no text layer (a scan). */
|
|
2
|
+
/** Every extracted page's text, pages separated by a newline. Empty when the PDF has no text layer (a scan). */
|
|
3
3
|
text: string;
|
|
4
|
+
/** The document's total page count, even when `maxPages` truncated the text. */
|
|
4
5
|
pages: number;
|
|
6
|
+
/** True when `maxPages` cut the extraction short of the document's last page. */
|
|
7
|
+
truncated: boolean;
|
|
5
8
|
}
|
|
6
|
-
export declare function extractPdfText(data: ArrayBuffer | Uint8Array
|
|
9
|
+
export declare function extractPdfText(data: ArrayBuffer | Uint8Array, opts?: {
|
|
10
|
+
maxPages?: number;
|
|
11
|
+
}): Promise<PdfText>;
|
package/dist/pdf.js
CHANGED
|
@@ -3,13 +3,41 @@
|
|
|
3
3
|
// runtime by paths computed from their own location, which the deploy tracer
|
|
4
4
|
// can't follow, so they pass in the sandbox and 500 once published. Loaded
|
|
5
5
|
// lazily so routes that never parse a PDF don't carry the bundle.
|
|
6
|
-
export async function extractPdfText(data) {
|
|
6
|
+
export async function extractPdfText(data, opts) {
|
|
7
7
|
const { getDocumentProxy, extractText } = await import('unpdf');
|
|
8
8
|
// pdf.js rejects a Node Buffer outright, so hand it a plain Uint8Array view.
|
|
9
9
|
const bytes = data instanceof ArrayBuffer
|
|
10
10
|
? new Uint8Array(data)
|
|
11
11
|
: new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
12
12
|
const pdf = await getDocumentProxy(bytes);
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
try {
|
|
14
|
+
const total = pdf.numPages;
|
|
15
|
+
const maxPages = opts?.maxPages;
|
|
16
|
+
if (maxPages === undefined || maxPages >= total) {
|
|
17
|
+
const { text, totalPages } = await extractText(pdf, { mergePages: true });
|
|
18
|
+
return { text, pages: totalPages, truncated: false };
|
|
19
|
+
}
|
|
20
|
+
const pageTexts = [];
|
|
21
|
+
for (let n = 1; n <= Math.max(0, maxPages); n++) {
|
|
22
|
+
const content = await (await pdf.getPage(n)).getTextContent();
|
|
23
|
+
pageTexts.push(content.items
|
|
24
|
+
.filter((item) => 'str' in item && item.str != null)
|
|
25
|
+
.map((item) => {
|
|
26
|
+
const { str, hasEOL } = item;
|
|
27
|
+
return str + (hasEOL ? '\n' : '');
|
|
28
|
+
})
|
|
29
|
+
.join(''));
|
|
30
|
+
}
|
|
31
|
+
// Mirrors unpdf's own normalisation so truncated and full output agree.
|
|
32
|
+
const text = pageTexts
|
|
33
|
+
.join('\n')
|
|
34
|
+
.replace(/[^\S\n]+/g, ' ')
|
|
35
|
+
.replace(/ ?\n ?/g, '\n')
|
|
36
|
+
.replace(/\n{3,}/g, '\n\n');
|
|
37
|
+
return { text, pages: total, truncated: true };
|
|
38
|
+
}
|
|
39
|
+
finally {
|
|
40
|
+
// Same teardown unpdf's own helpers use; the bundled proxy has no destroy().
|
|
41
|
+
await pdf.loadingTask.destroy();
|
|
42
|
+
}
|
|
15
43
|
}
|
package/dist/proxy.js
CHANGED
|
@@ -538,12 +538,10 @@ async function handleRequest(request, options) {
|
|
|
538
538
|
// isClient gates accent/font in layout SSR. Always forward it so layout can
|
|
539
539
|
// fall back when getSessionIdentity() cannot read the bearer on first paint.
|
|
540
540
|
requestHeaders.set('x-assembly-is-client', identity.clientId ? 'true' : 'false');
|
|
541
|
-
//
|
|
542
|
-
//
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
requestHeaders.set('x-assembly-workspace-id', identity.workspaceId ?? '');
|
|
546
|
-
}
|
|
541
|
+
// Set in both auth modes: migrated apps' layouts still read these for
|
|
542
|
+
// analytics identity, and inbound copies are stripped so they can't be forged.
|
|
543
|
+
requestHeaders.set('x-assembly-distinct-id', identity.clientId ?? '');
|
|
544
|
+
requestHeaders.set('x-assembly-workspace-id', identity.workspaceId ?? '');
|
|
547
545
|
}
|
|
548
546
|
// Hand the active bearer to the render as a request header. On first load the
|
|
549
547
|
// bearer exists nowhere else the render can see it (the Set-Cookie below isn't
|
package/dist/reexchange.js
CHANGED
|
@@ -41,6 +41,8 @@ export function createAssemblyReexchangeHandler() {
|
|
|
41
41
|
// script could read.
|
|
42
42
|
const wantsBearerInBody = req.headers.has(BEARER_IN_BODY_HEADER);
|
|
43
43
|
const response = NextResponse.json(wantsBearerInBody ? { ok: true, bearer: minted.bearer } : { ok: true });
|
|
44
|
+
// The body can carry a live bearer, so no cache may keep or replay it.
|
|
45
|
+
response.headers.set('Cache-Control', 'no-store');
|
|
44
46
|
const maxAge = Math.max(0, Math.floor((minted.bearerExpiresAt - Date.now()) / 1_000));
|
|
45
47
|
// headers.append, not response.cookies.set, which keys by name and would
|
|
46
48
|
// collapse the clear and the set of the same-named cookie. The legacy
|