@assembly-js/app-foundation 0.3.10 → 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 CHANGED
@@ -19,6 +19,13 @@ 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
+
22
29
  ## 0.3.10
23
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.
24
31
 
@@ -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). There the wrapper form was never the contract, so this check
16
- // exits 0 — those apps are protected by the boot/restart patch scripts instead.
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 copies predate the Safari fallback
4
- // (a bearer returned in the body for browsers that refuse the partitioned
5
- // cookie), so an app carrying one signs its Safari users out after the first
6
- // hour. Dependency-free on purpose: it runs inside the generated app's sandbox
7
- // before any deps beyond Node exist.
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('A hand-written renewal route does not return the bearer in the response body, so Safari');
44
- out('sessions end after the first hour. Replace the whole file with exactly this:');
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
- violations.push(`${relative(ROOT, file).split(sep).join('/')} (route ${route})`);
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): Promise<PdfText>;
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
- const { text, totalPages } = await extractText(pdf, { mergePages: true });
14
- return { text, pages: totalPages };
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
- // Distinct/workspace headers are legacy-only — bearer path reads them from
542
- // the self-describing bearer via getSessionIdentity().
543
- if (!exchangeEnabled) {
544
- requestHeaders.set('x-assembly-distinct-id', identity.clientId ?? '');
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
@@ -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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assembly-js/app-foundation",
3
- "version": "0.3.10",
3
+ "version": "0.4.0",
4
4
  "description": "Versioned foundation helpers and build context for Assembly generated apps",
5
5
  "type": "module",
6
6
  "files": [