@assembly-js/app-foundation 0.0.56 → 0.1.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
@@ -14,6 +14,13 @@ A foundation update reads the sections between an app's version and the target:
14
14
  from every bullet except `[guidance]`, agent work from the check scripts those `[action]`
15
15
  bullets ship. Write `[fix]`, `[behavior]` and `[action]` bullets for the app's owner.
16
16
 
17
+ A patch release never changes what an app's build requires; a minor release may add or change a
18
+ `bin/check-*.mjs` script. CI fails a PR that changes a check script without a minor bump over main.
19
+
20
+ ## 0.1.0
21
+ - [fix] Signed-in sessions in Safari keep working after the hour-long sign-in expires, instead of leaving the user signed out.
22
+ - [action] An app with a hand-written silent-renewal route still signs Safari users out an hour after they sign in; the build now flags that route, and it moves to the foundation's handler.
23
+
17
24
  ## 0.0.56
18
25
  - [new] The client-call scan now marks each finding as gated only when every page or route that can load its file requires a staff user. Nothing in the app changes.
19
26
 
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env node
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.
8
+ //
9
+ // An app with no such route passes; check-proxy-csrf owns the missing-route
10
+ // finding.
11
+ //
12
+ // A hit is a warning on an ordinary turn and fails the build with --strict
13
+ // (the update acceptance), on a platform update turn, or when
14
+ // ASSEMBLY_TURN_BASELINE_SHA says this turn changed the route.
15
+ import { existsSync, readFileSync } from 'node:fs';
16
+ import { spawnSync } from 'node:child_process';
17
+ import { join } from 'node:path';
18
+
19
+ const ROOT = process.cwd();
20
+ const STRICT = process.argv.includes('--strict') || process.env.ASSEMBLY_UPDATE_TURN === '1';
21
+ const TURN_BASELINE_ENV = 'ASSEMBLY_TURN_BASELINE_SHA';
22
+
23
+ const ROUTE_FILE = 'app/api/assembly-reexchange/route.ts';
24
+ const PACKAGE_HANDLER = /createAssemblyReexchangeHandler\s*\(/;
25
+ // A comment mentioning the factory must not satisfy the check.
26
+ const COMMENT_LINE = /^\s*(\/\/|\*|\/\*)/;
27
+
28
+ if (!existsSync(join(ROOT, ROUTE_FILE))) process.exit(0);
29
+ const usesPackageHandler = readFileSync(join(ROOT, ROUTE_FILE), 'utf8')
30
+ .split('\n')
31
+ .some((line) => !COMMENT_LINE.test(line) && PACKAGE_HANDLER.test(line));
32
+ if (usesPackageHandler) process.exit(0);
33
+
34
+ const fatal = STRICT || changedThisTurn();
35
+ const out = fatal ? console.error : console.warn;
36
+ out('');
37
+ out(
38
+ fatal
39
+ ? `check-reexchange-route: ${ROUTE_FILE} is not the package handler:`
40
+ : `check-reexchange-route: ${ROUTE_FILE} is not the package handler (pre-existing; this build is not failed for it):`,
41
+ );
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:');
45
+ out('');
46
+ out(" import { createAssemblyReexchangeHandler } from '@assembly-js/app-foundation/server';");
47
+ out('');
48
+ out(' export const POST = createAssemblyReexchangeHandler();');
49
+ out('');
50
+ out('Do not add logic to the route; the handler owns the CSRF check and the renewal.');
51
+ if (fatal) process.exit(1);
52
+
53
+ function changedThisTurn() {
54
+ const baseline = (process.env[TURN_BASELINE_ENV] ?? '').trim();
55
+ if (!baseline) return false;
56
+ const listings = [
57
+ { args: ['diff', '--name-only', `${baseline}..HEAD`], parse: (line) => line },
58
+ { args: ['status', '--porcelain', '--untracked-files=all'], parse: (line) => line.slice(3).split(' -> ').pop() },
59
+ ];
60
+ for (const { args, parse } of listings) {
61
+ const result = spawnSync('git', args, { cwd: ROOT, encoding: 'utf8' });
62
+ if (result.status !== 0) return true;
63
+ const files = result.stdout.split('\n').map((line) => parse(line).trim());
64
+ if (files.includes(ROUTE_FILE)) return true;
65
+ }
66
+ return false;
67
+ }
@@ -14,7 +14,8 @@
14
14
  // automation caller (an app), never a viewer. makeTestActionContext builds a fake context for
15
15
  // tests that no request can reach. getClientAccessScope, getInternalUserAccess,
16
16
  // requireCompanyScope, getInstalledAppIds and isAppInstalled take a client as an argument;
17
- // none of them returns one.
17
+ // none of them returns one. createAssemblyReexchangeHandler returns a route handler that
18
+ // renews the bearer cookie; it never hands out a client.
18
19
  export const NON_CLIENT_EXPORTS = new Set([
19
20
  'APP_CSRF_HEADER', 'AUTH_UNAVAILABLE_CODE', 'ActionContext', 'ActionError',
20
21
  'ActionErrorStatus', 'AirtableFieldSchema', 'AirtableFormattedField',
@@ -39,7 +40,8 @@ export const NON_CLIENT_EXPORTS = new Set([
39
40
  'automationActionRoute', 'badRequest', 'bootstrapPortalOrigin', 'buildMimeMessage',
40
41
  'canAccessClient', 'canAccessCompanies', 'classifyExchangeFailure',
41
42
  'clearClientNotifications', 'collectHeaders', 'collectXhrResponseHeaders',
42
- 'cookielessReexchangeHeaders', 'cookiesAreBlocked', 'createAssemblyProxy', 'cronRoute',
43
+ 'cookielessReexchangeHeaders', 'cookiesAreBlocked', 'createAssemblyProxy',
44
+ 'createAssemblyReexchangeHandler', 'cronRoute',
43
45
  'decodeBearer', 'expandClientsByCompany', 'extractPdfText', 'fetchSessionFlags',
44
46
  'findResource', 'formatAirtableRecords', 'getAirtableTableSchema', 'getCapturedRecords',
45
47
  'getClientAccessScope', 'getCookielessBearer', 'getDownloadUrl', 'getInstalledAppIds', 'getIntegration',
@@ -0,0 +1,2 @@
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+ export declare function createAssemblyReexchangeHandler(): (req: NextRequest) => Promise<NextResponse>;
@@ -0,0 +1,53 @@
1
+ // The app's silent bearer renewal route. It lives in the package rather than in
2
+ // each app because it is security-critical code an agent must never re-author;
3
+ // an app's route file only calls createAssemblyReexchangeHandler.
4
+ import { NextResponse } from 'next/server';
5
+ import { APP_CSRF_HEADER, BEARER_IN_BODY_HEADER, isSessionExchangeEnabled, serializeBearerCookie, serializeCookieProbe, } from './session-exchange.js';
6
+ import { mintBearerFromSessionToken } from './mint-bearer.js';
7
+ // The only header any route may read a session token from.
8
+ const SESSION_TOKEN_HEADER = 'x-assembly-token';
9
+ // When the 60-minute bearer expires, the client posts the current
10
+ // (bridge-kept-fresh) session token here; we re-run the /session exchange
11
+ // server-side and set a fresh HttpOnly bearer cookie, so the original call
12
+ // retries with no page reload. The proxy matcher excludes this route so its
13
+ // stale-cookie clear can't race the fresh cookie, which is why CSRF is enforced
14
+ // here instead.
15
+ export function createAssemblyReexchangeHandler() {
16
+ return async function POST(req) {
17
+ // Dormant unless the exchange is enabled — legacy (exchange-off) apps never
18
+ // re-exchange; they keep using the raw session token per request.
19
+ if (!isSessionExchangeEnabled()) {
20
+ return NextResponse.json({ error: 'Not found' }, { status: 404 });
21
+ }
22
+ // A cross-site request cannot set the custom header without a CORS
23
+ // preflight, which this app never grants.
24
+ if (!req.headers.has(APP_CSRF_HEADER)) {
25
+ return new NextResponse(null, { status: 403 });
26
+ }
27
+ const token = req.headers.get(SESSION_TOKEN_HEADER);
28
+ if (!token) {
29
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
30
+ }
31
+ const minted = await mintBearerFromSessionToken(token);
32
+ if (!minted.ok) {
33
+ // The fresh session token also failed to exchange — the parent (Cognito)
34
+ // session is gone, or the exchange is unavailable. Either way there is no
35
+ // bearer to set, so signal expiry and let the client retry.
36
+ return NextResponse.json({ error: 'Unauthorized', code: 'token_expired' }, { status: 401, headers: { 'x-assembly-token-expired': '1' } });
37
+ }
38
+ // A caller that has proven its browser drops the cookie cannot receive the
39
+ // bearer any other way, so hand it back in the body. Gated on the caller
40
+ // asking, so a cookie-capable browser's response never carries a bearer a
41
+ // script could read.
42
+ const wantsBearerInBody = req.headers.has(BEARER_IN_BODY_HEADER);
43
+ const response = NextResponse.json(wantsBearerInBody ? { ok: true, bearer: minted.bearer } : { ok: true });
44
+ const maxAge = Math.max(0, Math.floor((minted.bearerExpiresAt - Date.now()) / 1_000));
45
+ // headers.append, not response.cookies.set, which keys by name and would
46
+ // collapse the clear and the set of the same-named cookie. The legacy
47
+ // unpartitioned record is cleared so it can't shadow the fresh bearer.
48
+ response.headers.append('Set-Cookie', serializeBearerCookie('', 0, false));
49
+ response.headers.append('Set-Cookie', serializeBearerCookie(minted.bearer, maxAge, true));
50
+ response.headers.append('Set-Cookie', serializeCookieProbe(true));
51
+ return response;
52
+ };
53
+ }
package/dist/server.d.ts CHANGED
@@ -15,6 +15,7 @@ export * from './notifications.js';
15
15
  export * from './cron.js';
16
16
  export * from './replace-rows.js';
17
17
  export * from './proxy.js';
18
+ export * from './reexchange.js';
18
19
  export * from './pdf.js';
19
20
  export * from './api-errors.js';
20
21
  export * from './params.js';
package/dist/server.js CHANGED
@@ -19,6 +19,7 @@ export * from './notifications.js';
19
19
  export * from './cron.js';
20
20
  export * from './replace-rows.js';
21
21
  export * from './proxy.js';
22
+ export * from './reexchange.js';
22
23
  // Lazy-loads its parser, so importing the barrel costs a route nothing until it parses a PDF.
23
24
  export * from './pdf.js';
24
25
  // Returns NextResponse, so route handlers only — but nothing here reads a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assembly-js/app-foundation",
3
- "version": "0.0.56",
3
+ "version": "0.1.0",
4
4
  "description": "Versioned foundation helpers and build context for Assembly generated apps",
5
5
  "type": "module",
6
6
  "files": [
@@ -31,7 +31,7 @@
31
31
  },
32
32
  "scripts": {
33
33
  "build": "tsc && tsc-alias",
34
- "test": "npm run build && bun test --path-ignore-patterns='**/proxy.test.ts' && bun test ./src/proxy.test.ts && tsc -p tsconfig.replace-rows.json",
34
+ "test": "npm run build && bun test --path-ignore-patterns='**/proxy.test.ts' --path-ignore-patterns='**/reexchange.test.ts' && bun test ./src/proxy.test.ts && bun test ./src/reexchange.test.ts && tsc -p tsconfig.replace-rows.json",
35
35
  "prepublishOnly": "npm run build && npm run verify:dist && npm run verify:bin",
36
36
  "verify:dist": "node scripts/verify-dist-resolves.mjs",
37
37
  "verify:bin": "node scripts/verify-bin.mjs"