@assembly-js/app-foundation 0.0.55 → 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 +10 -0
- package/bin/check-reexchange-route.mjs +67 -0
- package/bin/foundation-exports.mjs +4 -2
- package/bin/scan-client-calls.mjs +14 -7
- package/dist/reexchange.d.ts +2 -0
- package/dist/reexchange.js +53 -0
- package/dist/server.d.ts +1 -0
- package/dist/server.js +1 -0
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -14,6 +14,16 @@ 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
|
+
|
|
24
|
+
## 0.0.56
|
|
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.
|
|
26
|
+
|
|
17
27
|
## 0.0.55
|
|
18
28
|
- [new] Add a scan the platform can run on demand to find calls that use a signed-in viewer's access to the platform API from pages and routes clients can open. Nothing in the app changes and no file is added to it.
|
|
19
29
|
|
|
@@ -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',
|
|
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',
|
|
@@ -39,7 +39,7 @@ import {
|
|
|
39
39
|
wrapperName,
|
|
40
40
|
} from './scan-routes.mjs';
|
|
41
41
|
|
|
42
|
-
export const SCANNER_VERSION =
|
|
42
|
+
export const SCANNER_VERSION = 2;
|
|
43
43
|
|
|
44
44
|
export const CLASS_BREAKS = 'BREAKS';
|
|
45
45
|
export const CLASS_CLEAN = 'CLEAN';
|
|
@@ -878,15 +878,20 @@ export function createAnalysis(root) {
|
|
|
878
878
|
return { root, files, ts, read, facts: factsFor, classifyEntries: () => classifyEntries(ts, root, files, read) };
|
|
879
879
|
}
|
|
880
880
|
|
|
881
|
-
// Maps every file
|
|
881
|
+
// Maps every file to the byte-smallest entry that loads it (`entry`) and to every entry that
|
|
882
|
+
// does (`entries`): a file shared with an ungated entry stays exposed however the others are gated.
|
|
882
883
|
function reachFrom(analysis, entries) {
|
|
883
884
|
const reach = new Map();
|
|
884
885
|
for (const entry of entries) {
|
|
886
|
+
const visited = new Set();
|
|
885
887
|
const queue = [entry];
|
|
886
888
|
while (queue.length) {
|
|
887
889
|
const file = queue.pop();
|
|
888
|
-
if (
|
|
889
|
-
|
|
890
|
+
if (visited.has(file)) continue;
|
|
891
|
+
visited.add(file);
|
|
892
|
+
const seen = reach.get(file);
|
|
893
|
+
if (seen) seen.entries.add(entry);
|
|
894
|
+
else reach.set(file, { entry, entries: new Set([entry]) });
|
|
890
895
|
queue.push(...analysis.facts(file).imports);
|
|
891
896
|
}
|
|
892
897
|
}
|
|
@@ -904,14 +909,16 @@ export function projectClientReachable(analysis, allowlist = CLIENT_SAFE_SDK_MET
|
|
|
904
909
|
const reach = reachFrom(analysis, entries);
|
|
905
910
|
const findings = [];
|
|
906
911
|
const unresolved = [];
|
|
907
|
-
|
|
912
|
+
const gatedSet = new Set(gatedEntries);
|
|
913
|
+
for (const [file, { entry, entries: reaching }] of reach) {
|
|
914
|
+
const gated = [...reaching].every((e) => gatedSet.has(e));
|
|
908
915
|
const { calls, defeaters } = analysis.facts(file);
|
|
909
916
|
for (const c of calls) {
|
|
910
917
|
if (c.provenance === PROVENANCE_VIEWER && !allowed.has(c.method)) {
|
|
911
|
-
findings.push({ file, line: c.line, method: c.method, provenance: c.provenance, entry });
|
|
918
|
+
findings.push({ file, line: c.line, method: c.method, provenance: c.provenance, entry, gated });
|
|
912
919
|
}
|
|
913
920
|
}
|
|
914
|
-
for (const d of defeaters) unresolved.push({ file, line: d.line, reason: d.reason, entry });
|
|
921
|
+
for (const d of defeaters) unresolved.push({ file, line: d.line, reason: d.reason, entry, gated });
|
|
915
922
|
}
|
|
916
923
|
sortFindings(findings);
|
|
917
924
|
sortFindings(unresolved);
|
|
@@ -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
|
|
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"
|