@assembly-js/app-foundation 0.9.0 → 0.11.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-app-only-routes.mjs +104 -0
- package/bin/check-record-urls.mjs +154 -30
- package/bin/foundation-exports.mjs +3 -3
- package/dist/action-route.js +14 -6
- package/dist/assembly.d.ts +9 -5
- package/dist/assembly.js +169 -11
- package/dist/mint-bearer.d.ts +28 -1
- package/dist/mint-bearer.js +50 -11
- package/dist/proxy.js +1 -1
- package/dist/scripts.js +42 -9
- package/dist/verify-bearer.d.ts +7 -1
- package/dist/verify-bearer.js +35 -1
- package/package.json +1 -1
- package/skills/assembly-app-inner-location/SKILL.md +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.11.0
|
|
23
|
+
- [behavior] `check:record-urls` now checks each surface on its own: a record opened on the client portal needs its own `/c` route or `useUrlState`, and an internal dynamic route no longer exempts it. It also catches a list/detail view switch held in `useState`, so a client's request page keeps its own URL that can be reloaded or shared.
|
|
24
|
+
- [guidance] `assembly-app-inner-location` says the record URL rule applies to the client portal too, with `app/c/[id]/page.tsx` or `?id=` through `useUrlState` as the two valid shapes.
|
|
25
|
+
|
|
26
|
+
## 0.10.0
|
|
27
|
+
- [behavior] A call to another app through the installer client or a raw `fetch` to `/v1/apps/...` made inside a client's request now carries that client, so the platform checks the client could open the other app and the other app sees a client whose calling app is this one. A client who cannot open it gets the platform's `viewer_cannot_open_app` refusal as an error value; staff requests, cron, webhooks and queues are unchanged and send no parent.
|
|
28
|
+
- [behavior] A cross-app call from a client's request whose session cannot be verified (exchange off, a rejected header or expired cookie) now throws the token-invalid error instead of running as the installing admin, and a cross-app call where the request cannot be read (`'use cache'`, prerender, `after()`) throws. Storage, connector, notification and AI proxy installer calls are untouched.
|
|
29
|
+
- [action] A route that allows app callers but not clients (`allow: ['app']` or `['internal', 'app']`) now answers 403 to a client-originated installer call, because the person wins over the calling app. Add `'client'` to the list if clients should reach the route, and scope what it returns to that client; `bin/check-app-only-routes.mjs` lists the routes; mark one that should stay app-only with a `check-app-only-routes: intended` comment.
|
|
30
|
+
- [behavior] The action context's `caller.chain` and `caller.hops` now come from the verified bearer instead of a fixed single hop; they are advisory.
|
|
31
|
+
|
|
22
32
|
## 0.9.0
|
|
23
33
|
- [guidance] The build agent's auth guidance is one `## Auth` section and one `assembly-auth` skill instead of two sections and two skills that retold the same rules: about 3.1k tokens instead of 10k. A page or server action that reads one scoped record now checks it with `requireCompanyScope`, like an `[id]` route.
|
|
24
34
|
- [guidance] `check-token-usage` points at the `assembly-auth` skill.
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Flags routes whose allow list admits app callers but not clients. A client who
|
|
3
|
+
// reaches this app through another app's installer call carries the client, and
|
|
4
|
+
// a person wins over a calling app, so such a route now answers 403 to that
|
|
5
|
+
// call. Dependency-free on purpose: it runs inside the generated app's sandbox
|
|
6
|
+
// before any deps beyond Node exist.
|
|
7
|
+
//
|
|
8
|
+
// A route that should stay app-only says so with a comment on the list's line or
|
|
9
|
+
// the line above, `check-app-only-routes: intended`, and passes.
|
|
10
|
+
//
|
|
11
|
+
// A hit is a warning on an ordinary turn and fails the build with --strict (the
|
|
12
|
+
// update acceptance), on a platform update turn, or when
|
|
13
|
+
// ASSEMBLY_TURN_BASELINE_SHA says this turn changed the file. An app with no
|
|
14
|
+
// such list passes.
|
|
15
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
16
|
+
import { spawnSync } from 'node:child_process';
|
|
17
|
+
import { join, relative } 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
|
+
// Route code lives under app/ and shared auth wrappers under lib/.
|
|
24
|
+
const SCAN_DIRS = ['app', 'lib'];
|
|
25
|
+
const SOURCE_FILE = /\.(ts|tsx|js|jsx|mjs)$/;
|
|
26
|
+
const SKIP_DIRS = new Set(['node_modules', '.next', '.git', 'generated']);
|
|
27
|
+
const ALLOW_LIST = /\ballow\s*:\s*\[([^\]]*)\]/g;
|
|
28
|
+
const ENTRY = /['"`]([a-z]+)['"`]/g;
|
|
29
|
+
const LINE_COMMENT = /^\s*\/\//;
|
|
30
|
+
const ACKNOWLEDGED = /check-app-only-routes:\s*intended/;
|
|
31
|
+
|
|
32
|
+
function appOnlyAllowLists(source) {
|
|
33
|
+
// Whole-line comments only: stripping a trailing `//` would eat the `//` in a URL.
|
|
34
|
+
const code = source
|
|
35
|
+
.split('\n')
|
|
36
|
+
.map((line) => (LINE_COMMENT.test(line) ? '' : line))
|
|
37
|
+
.join('\n');
|
|
38
|
+
const lines = source.split('\n');
|
|
39
|
+
const hits = [];
|
|
40
|
+
for (const match of code.matchAll(ALLOW_LIST)) {
|
|
41
|
+
const kinds = [...match[1].matchAll(ENTRY)].map((m) => m[1]);
|
|
42
|
+
if (kinds.includes('app') && !kinds.includes('client')) {
|
|
43
|
+
const line = code.slice(0, match.index).split('\n').length;
|
|
44
|
+
if (ACKNOWLEDGED.test(`${lines[line - 2] ?? ''}\n${lines[line - 1] ?? ''}`)) continue;
|
|
45
|
+
hits.push({ list: match[0].replace(/\s+/g, ' '), line });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return hits;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function* sourceFiles(dir) {
|
|
52
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
53
|
+
if (entry.isDirectory()) {
|
|
54
|
+
if (!SKIP_DIRS.has(entry.name)) yield* sourceFiles(join(dir, entry.name));
|
|
55
|
+
} else if (SOURCE_FILE.test(entry.name)) {
|
|
56
|
+
yield join(dir, entry.name);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function changedFiles() {
|
|
62
|
+
const baseline = (process.env[TURN_BASELINE_ENV] ?? '').trim();
|
|
63
|
+
if (!baseline) return new Set();
|
|
64
|
+
const listings = [
|
|
65
|
+
{ args: ['diff', '--name-only', `${baseline}..HEAD`], parse: (line) => line },
|
|
66
|
+
{ args: ['status', '--porcelain', '--untracked-files=all'], parse: (line) => line.slice(3).split(' -> ').pop() },
|
|
67
|
+
];
|
|
68
|
+
const changed = new Set();
|
|
69
|
+
for (const { args, parse } of listings) {
|
|
70
|
+
const result = spawnSync('git', args, { cwd: ROOT, encoding: 'utf8' });
|
|
71
|
+
// Cannot tell what changed: treat every file as changed rather than pass quietly.
|
|
72
|
+
if (result.status !== 0) return null;
|
|
73
|
+
for (const line of result.stdout.split('\n')) changed.add(parse(line).trim());
|
|
74
|
+
}
|
|
75
|
+
return changed;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const findings = [];
|
|
79
|
+
for (const dir of SCAN_DIRS) {
|
|
80
|
+
if (!existsSync(join(ROOT, dir))) continue;
|
|
81
|
+
for (const file of sourceFiles(join(ROOT, dir))) {
|
|
82
|
+
const hits = appOnlyAllowLists(readFileSync(file, 'utf8'));
|
|
83
|
+
for (const hit of hits) findings.push({ file: relative(ROOT, file), ...hit });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (findings.length === 0) process.exit(0);
|
|
87
|
+
|
|
88
|
+
const changed = changedFiles();
|
|
89
|
+
const fatal = STRICT || changed === null || findings.some((f) => changed.has(f.file));
|
|
90
|
+
const out = fatal ? console.error : console.warn;
|
|
91
|
+
out('');
|
|
92
|
+
out(
|
|
93
|
+
fatal
|
|
94
|
+
? 'check-app-only-routes: these allow lists admit app callers but not clients:'
|
|
95
|
+
: 'check-app-only-routes: these allow lists admit app callers but not clients (pre-existing; this build is not failed for them):',
|
|
96
|
+
);
|
|
97
|
+
out('');
|
|
98
|
+
for (const f of findings) out(` ${f.file}:${f.line} ${f.list}`);
|
|
99
|
+
out('');
|
|
100
|
+
out('A client who reaches this app through another app now arrives as that client, and a person');
|
|
101
|
+
out('wins over a calling app, so a route that omits `client` answers 403 to the call. If clients');
|
|
102
|
+
out("may reach the route, add 'client' to the list and scope what it returns to that client;");
|
|
103
|
+
out('if only the app should reach it, add a comment `check-app-only-routes: intended` on the list or the line above.');
|
|
104
|
+
if (fatal) process.exit(1);
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// Fails the build when a record opened from a list (a Dialog, Sheet, Drawer
|
|
3
|
-
// detail panel) is driven by React state alone: the
|
|
4
|
-
// reload or a shared link lands on the list instead
|
|
5
|
-
// either `useUrlState('id')` from
|
|
6
|
-
// dynamic route for the record on the
|
|
7
|
-
// heuristic on a `selected*`-style
|
|
2
|
+
// Fails the build when a record opened from a list (a Dialog, Sheet, Drawer,
|
|
3
|
+
// detail panel or a list/detail view switch) is driven by React state alone: the
|
|
4
|
+
// host URL never changes, so a reload or a shared link lands on the list instead
|
|
5
|
+
// of the record. The fix is either `useUrlState('id')` from
|
|
6
|
+
// `@assembly-js/app-foundation/client` or a dynamic route for the record on the
|
|
7
|
+
// surface that renders it. The match is a heuristic on a `selected*`-style
|
|
8
|
+
// `useState` that feeds a panel-like element, or a `useState<View>` whose type
|
|
9
|
+
// has a detail-like variant carrying an `id`.
|
|
8
10
|
// Dependency free on purpose: it runs inside the generated app's sandbox before
|
|
9
11
|
// any deps beyond Node are guaranteed.
|
|
10
12
|
//
|
|
@@ -14,7 +16,7 @@
|
|
|
14
16
|
// On a platform update turn (ASSEMBLY_UPDATE_TURN=1) it only warns, because an
|
|
15
17
|
// update never rewrites how an app navigates.
|
|
16
18
|
import { readFileSync, readdirSync, existsSync } from 'node:fs';
|
|
17
|
-
import { join, relative, sep } from 'node:path';
|
|
19
|
+
import { dirname, join, relative, sep } from 'node:path';
|
|
18
20
|
|
|
19
21
|
const ROOT = process.cwd();
|
|
20
22
|
const UPDATE_TURN = process.env.ASSEMBLY_UPDATE_TURN === '1';
|
|
@@ -23,13 +25,24 @@ const MANIFEST = join(ROOT, '.assembly', 'app.json');
|
|
|
23
25
|
const INNER_LOCATION_SKILL = join(ROOT, '.claude', 'skills', 'assembly-app-inner-location', 'SKILL.md');
|
|
24
26
|
const SOURCE_FILE = /\.(tsx|jsx)$/;
|
|
25
27
|
const PAGE_FILE = /^page\.(tsx|ts|jsx|js)$/;
|
|
28
|
+
const RESOLVE_EXTENSIONS = ['.tsx', '.ts', '.jsx', '.js'];
|
|
29
|
+
const IMPORT_SPECIFIER = /(?:\bfrom\s*|\bimport\s*\(\s*)['"]([^'"]+)['"]/g;
|
|
26
30
|
|
|
27
31
|
// `const [selectedRequest, setSelectedRequest] = useState(...)`; the setter name is not trusted.
|
|
28
32
|
const USE_STATE = /const\s+\[\s*([A-Za-z_$][\w$]*)\s*,\s*[A-Za-z_$][\w$]*\s*\]\s*=\s*useState\b/g;
|
|
29
33
|
const RECORD_SELECTION = /^(?:selected|active|viewing|viewed|current|detail|open(?:ed)?)[A-Z_]/;
|
|
30
34
|
// Selection of something that is not a record, or that is genuinely transient.
|
|
31
|
-
const
|
|
35
|
+
const TRANSIENT_NAME = /^(?:confirm|delete|remove|menu|pending)/i;
|
|
36
|
+
const NOT_A_RECORD = new RegExp(
|
|
37
|
+
`(?:Tab|Filter|Status|Sort|View|Option|Date|Range|Column|Mode|Step|Page|Menu)s?$|${TRANSIENT_NAME.source}`,
|
|
38
|
+
'i',
|
|
39
|
+
);
|
|
32
40
|
const PANEL_ELEMENT = /<\w*(?:Dialog|Sheet|Drawer|Panel|Modal|Detail)\w*[\s>/]/;
|
|
41
|
+
// `const [view, setView] = useState<View>(...)`; the generic is read by scanning, since it can nest.
|
|
42
|
+
const TYPED_USE_STATE = /const\s+\[\s*([A-Za-z_$][\w$]*)\s*,\s*[A-Za-z_$][\w$]*\s*\]\s*=\s*useState\s*</g;
|
|
43
|
+
// A view switch that can show one record: a variant naming a detail-like view, and an `id` field.
|
|
44
|
+
const DETAIL_VARIANT = /['"`](?:detail|show|view|open|record|item)['"`]/i;
|
|
45
|
+
const ID_FIELD = /\bid\??\s*:/;
|
|
33
46
|
const OPT_OUT = /record-urls:\s*transient/;
|
|
34
47
|
|
|
35
48
|
function sourceFiles(dir) {
|
|
@@ -47,15 +60,15 @@ function sourceFiles(dir) {
|
|
|
47
60
|
return out;
|
|
48
61
|
}
|
|
49
62
|
|
|
50
|
-
// Prefixes of the client and notification surfaces; a dynamic route
|
|
51
|
-
// not give
|
|
52
|
-
function
|
|
53
|
-
const prefixes = new Set(['/c', '/n']);
|
|
63
|
+
// Prefixes of the client and notification surfaces; a dynamic route on one
|
|
64
|
+
// surface does not give another surface a record URL.
|
|
65
|
+
function surfacePrefixes() {
|
|
66
|
+
const prefixes = { client: new Set(['/c']), notification: new Set(['/n']) };
|
|
54
67
|
try {
|
|
55
68
|
const manifest = JSON.parse(readFileSync(MANIFEST, 'utf8'));
|
|
56
69
|
for (const key of ['client', 'notification']) {
|
|
57
70
|
const p = manifest?.surfaces?.[key]?.path;
|
|
58
|
-
if (typeof p === 'string' && p.startsWith('/') && p !== '/') prefixes.add(p.replace(/\/+$/, ''));
|
|
71
|
+
if (typeof p === 'string' && p.startsWith('/') && p !== '/') prefixes[key].add(p.replace(/\/+$/, ''));
|
|
59
72
|
}
|
|
60
73
|
} catch {
|
|
61
74
|
// defaults
|
|
@@ -63,6 +76,10 @@ function nonInternalPrefixes() {
|
|
|
63
76
|
return prefixes;
|
|
64
77
|
}
|
|
65
78
|
|
|
79
|
+
function underPrefix(route, prefixes) {
|
|
80
|
+
return [...prefixes].some((p) => route === p || route.startsWith(`${p}/`));
|
|
81
|
+
}
|
|
82
|
+
|
|
66
83
|
function routeForPageFile(file) {
|
|
67
84
|
const segments = relative(APP_DIR, file).split(sep).slice(0, -1);
|
|
68
85
|
const urlSegments = segments.filter(
|
|
@@ -78,31 +95,133 @@ const contents = new Map(files.map((f) => [f, readFileSync(f, 'utf8')]));
|
|
|
78
95
|
|
|
79
96
|
if (!existsSync(INNER_LOCATION_SKILL)) process.exit(0);
|
|
80
97
|
|
|
81
|
-
const prefixes = nonInternalPrefixes();
|
|
82
|
-
const hasInternalRecordRoute = files.some((f) => {
|
|
83
|
-
if (!PAGE_FILE.test(f.split(sep).pop())) return false;
|
|
84
|
-
const route = routeForPageFile(f);
|
|
85
|
-
if (!route.includes('[')) return false;
|
|
86
|
-
return ![...prefixes].some((p) => route === p || route.startsWith(`${p}/`));
|
|
87
|
-
});
|
|
88
|
-
if (hasInternalRecordRoute) process.exit(0);
|
|
89
|
-
|
|
90
98
|
function escapeRe(s) {
|
|
91
99
|
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
92
100
|
}
|
|
93
101
|
|
|
102
|
+
function relPath(file) {
|
|
103
|
+
return relative(ROOT, file).split(sep).join('/');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function resolveImport(from, spec) {
|
|
107
|
+
let base;
|
|
108
|
+
if (spec.startsWith('./') || spec.startsWith('../')) base = join(dirname(from), spec);
|
|
109
|
+
else if (spec.startsWith('@/')) base = join(ROOT, spec.slice(2));
|
|
110
|
+
else return null;
|
|
111
|
+
const candidates = [
|
|
112
|
+
base,
|
|
113
|
+
...RESOLVE_EXTENSIONS.map((ext) => base + ext),
|
|
114
|
+
...RESOLVE_EXTENSIONS.map((ext) => join(base, 'index' + ext)),
|
|
115
|
+
];
|
|
116
|
+
return candidates.find((c) => contents.has(c)) ?? null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Files reachable from `roots` through relative and `@/` imports. An import the
|
|
120
|
+
// regex misses (a barrel re-export, a computed path) only shrinks the set.
|
|
121
|
+
function reachableFrom(roots) {
|
|
122
|
+
const seen = new Set(roots);
|
|
123
|
+
const queue = [...roots];
|
|
124
|
+
while (queue.length) {
|
|
125
|
+
const file = queue.pop();
|
|
126
|
+
for (const m of contents.get(file).matchAll(IMPORT_SPECIFIER)) {
|
|
127
|
+
const target = resolveImport(file, m[1]);
|
|
128
|
+
if (target && !seen.has(target)) {
|
|
129
|
+
seen.add(target);
|
|
130
|
+
queue.push(target);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return seen;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// The text between the `<` before `start` and its matching `>`, skipping `=>`.
|
|
138
|
+
function genericArgument(src, start) {
|
|
139
|
+
let depth = 1;
|
|
140
|
+
for (let i = start; i < src.length; i++) {
|
|
141
|
+
const ch = src[i];
|
|
142
|
+
if (ch === '<') depth++;
|
|
143
|
+
else if (ch === '>' && src[i - 1] !== '=' && --depth === 0) return src.slice(start, i);
|
|
144
|
+
}
|
|
145
|
+
return '';
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// The body of `type Name = …` or `interface Name {…}` declared in this file.
|
|
149
|
+
function localTypeBody(src, name) {
|
|
150
|
+
const n = escapeRe(name);
|
|
151
|
+
const decl = new RegExp(`\\btype\\s+${n}\\b(?:<[^=]*>)?\\s*=|\\binterface\\s+${n}\\b[^{]*`).exec(src);
|
|
152
|
+
if (!decl) return '';
|
|
153
|
+
const isInterface = decl[0].startsWith('interface');
|
|
154
|
+
const start = decl.index + decl[0].length;
|
|
155
|
+
let depth = 0;
|
|
156
|
+
for (let i = start; i < src.length; i++) {
|
|
157
|
+
const ch = src[i];
|
|
158
|
+
if ('{(['.includes(ch)) depth++;
|
|
159
|
+
else if ('})]'.includes(ch)) {
|
|
160
|
+
depth--;
|
|
161
|
+
if (depth === 0 && isInterface) return src.slice(start, i + 1);
|
|
162
|
+
} else if (depth === 0 && !isInterface && ch === ';') {
|
|
163
|
+
return src.slice(start, i);
|
|
164
|
+
} else if (depth === 0 && !isInterface && ch === '\n' && src.slice(start, i).trim() && !/^\s*[|&]/.test(src.slice(i + 1))) {
|
|
165
|
+
return src.slice(start, i);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return src.slice(start);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function isDetailViewType(src, typeText) {
|
|
172
|
+
let text = typeText;
|
|
173
|
+
for (const ident of new Set(typeText.match(/\b[A-Z][\w$]*\b/g) ?? [])) text += '\n' + localTypeBody(src, ident);
|
|
174
|
+
return ID_FIELD.test(text) && DETAIL_VARIANT.test(text);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const prefixes = surfacePrefixes();
|
|
178
|
+
const clientPages = [];
|
|
179
|
+
const internalPages = [];
|
|
180
|
+
let hasClientRecordRoute = false;
|
|
181
|
+
let hasInternalRecordRoute = false;
|
|
182
|
+
for (const f of files) {
|
|
183
|
+
if (!PAGE_FILE.test(f.split(sep).pop())) continue;
|
|
184
|
+
const route = routeForPageFile(f);
|
|
185
|
+
if (underPrefix(route, prefixes.client)) {
|
|
186
|
+
clientPages.push(f);
|
|
187
|
+
if (route.includes('[')) hasClientRecordRoute = true;
|
|
188
|
+
} else if (!underPrefix(route, prefixes.notification)) {
|
|
189
|
+
internalPages.push(f);
|
|
190
|
+
if (route.includes('[')) hasInternalRecordRoute = true;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
const clientReachable = reachableFrom(clientPages);
|
|
194
|
+
const internalReachable = reachableFrom(internalPages);
|
|
195
|
+
|
|
196
|
+
// A route exempts a file only when every surface that renders it has one. A file
|
|
197
|
+
// no page reaches is judged as internal, as it was before the check followed imports.
|
|
198
|
+
function exemptByRoute(file) {
|
|
199
|
+
const onClient = clientReachable.has(file);
|
|
200
|
+
const onInternal = internalReachable.has(file) || !onClient;
|
|
201
|
+
return (!onClient || hasClientRecordRoute) && (!onInternal || hasInternalRecordRoute);
|
|
202
|
+
}
|
|
203
|
+
|
|
94
204
|
const violations = [];
|
|
95
205
|
for (const file of files) {
|
|
96
206
|
if (!SOURCE_FILE.test(file)) continue;
|
|
97
207
|
const src = contents.get(file);
|
|
98
|
-
if (/\buseUrlState\b/.test(src) || OPT_OUT.test(src) ||
|
|
99
|
-
|
|
208
|
+
if (/\buseUrlState\b/.test(src) || OPT_OUT.test(src) || exemptByRoute(file)) continue;
|
|
209
|
+
const where = clientReachable.has(file) ? `${relPath(file)} [client surface]` : relPath(file);
|
|
210
|
+
if (PANEL_ELEMENT.test(src)) {
|
|
211
|
+
for (const match of src.matchAll(USE_STATE)) {
|
|
212
|
+
const name = match[1];
|
|
213
|
+
if (!RECORD_SELECTION.test(name) || NOT_A_RECORD.test(name)) continue;
|
|
214
|
+
// Declared and read somewhere else in the file, i.e. it feeds the panel.
|
|
215
|
+
const uses = src.match(new RegExp(`(?<![\\w$.])${escapeRe(name)}(?![\\w$])`, 'g')) ?? [];
|
|
216
|
+
if (uses.length < 3) continue;
|
|
217
|
+
violations.push(`${where} (useState: ${name})`);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
for (const match of src.matchAll(TYPED_USE_STATE)) {
|
|
100
221
|
const name = match[1];
|
|
101
|
-
if (
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
if (uses.length < 3) continue;
|
|
105
|
-
violations.push(`${relative(ROOT, file).split(sep).join('/')} (useState: ${name})`);
|
|
222
|
+
if (TRANSIENT_NAME.test(name)) continue;
|
|
223
|
+
if (!isDetailViewType(src, genericArgument(src, match.index + match[0].length))) continue;
|
|
224
|
+
violations.push(`${where} (useState view switch: ${name})`);
|
|
106
225
|
}
|
|
107
226
|
}
|
|
108
227
|
|
|
@@ -121,7 +240,12 @@ if (violations.length > 0 && UPDATE_TURN) {
|
|
|
121
240
|
console.error(" - Replace the useState with `const [id, setId] = useUrlState('id')` from");
|
|
122
241
|
console.error(" '@assembly-js/app-foundation/client' and render the record from `id`;");
|
|
123
242
|
console.error(' a cold load with ?id= must open it, and Back must close it.');
|
|
124
|
-
console.error(' - Give the record its own route
|
|
243
|
+
console.error(' - Give the record its own route on the surface that lists it and link to it:');
|
|
244
|
+
console.error(' app/requests/[id]/page.tsx on the internal surface.');
|
|
245
|
+
console.error('A file marked [client surface] needs the same fix there: call');
|
|
246
|
+
console.error("`useUrlState('requestId')` in the client page component instead of a view state,");
|
|
247
|
+
console.error('or add app/c/[id]/page.tsx and link to it from the list. A cold load of that URL');
|
|
248
|
+
console.error('must open the record and Back must close it.');
|
|
125
249
|
console.error('A genuinely transient UI (confirm dialog, menu) can add the comment');
|
|
126
250
|
console.error('`// record-urls: transient` to the file.');
|
|
127
251
|
process.exit(1);
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
// renews the bearer cookie; it never hands out a client.
|
|
19
19
|
export const NON_CLIENT_EXPORTS = new Set([
|
|
20
20
|
'APP_CSRF_HEADER', 'AUTH_UNAVAILABLE_CODE', 'AUTH_UNAVAILABLE_DIGEST', 'ActionContext', 'ActionError',
|
|
21
|
-
'ActionErrorStatus', 'AirtableFieldSchema', 'AppProxyCaller', 'AppProxyOptions', 'AirtableFormattedField',
|
|
21
|
+
'ActionErrorStatus', 'AirtableFieldSchema', 'AppKeyMintOutcome', 'AppProxyCaller', 'AppProxyOptions', 'AirtableFormattedField',
|
|
22
22
|
'AirtableFormattedRecord', 'AirtableRecord', 'AirtableSchemaResult', 'AirtableTableSchema',
|
|
23
23
|
'AssemblyProxyForwardContext', 'AssemblyProxyOptions', 'AuthUnavailableError',
|
|
24
24
|
'BEARER_COOKIE_NAME', 'BEARER_EXPIRY_COOKIE_NAME', 'BEARER_HEADER_NAME', 'BEARER_IN_BODY_HEADER', 'BEARER_TTL_MS',
|
|
@@ -27,8 +27,8 @@ export const NON_CLIENT_EXPORTS = new Set([
|
|
|
27
27
|
'ConnectionStatus', 'ConnectorError', 'ContractDocumentError', 'ConnectorErrorCode', 'CookieOutcome', 'CronHandler',
|
|
28
28
|
'DEFAULT_LABELS', 'EXCHANGE_REJECTION_HEADER', 'ExchangeRejectedViewer', 'ExchangedSession',
|
|
29
29
|
'FormatAirtableRecordsOpts', 'GenerateAppClientInput', 'GeneratedAppClient', 'GmailSendArgs', 'GmailSendResult', 'IntegrationClient',
|
|
30
|
-
'InternalUserAccess', 'LEGACY_APP_IDS', 'LocationReporter', 'MintFailure', 'MintOutcome', 'MintSessionOptions', 'MintedAppBearer',
|
|
31
|
-
'MintedBearer', 'NETWORK_CAPTURE_MESSAGE_TYPE', 'NetworkCaptureHeaderFingerprint',
|
|
30
|
+
'InternalUserAccess', 'LEGACY_APP_IDS', 'LocationReporter', 'MintAppKeyOptions', 'MintFailure', 'MintOutcome', 'MintSessionOptions', 'MintedAppBearer',
|
|
31
|
+
'MintedBearer', 'MintedOnBehalfOf', 'NETWORK_CAPTURE_MESSAGE_TYPE', 'NetworkCaptureHeaderFingerprint',
|
|
32
32
|
'NetworkCaptureRecord', 'NotifyInternalUsersInput', 'PAINTED_BEACON_EVENT', 'PAINTED_BEACON_PATH',
|
|
33
33
|
'PORTAL_ORIGIN_COOKIE_NAME', 'PdfText', 'DetectedPdfField', 'PdfFieldDetection', 'PdfFieldDetectionMethod', 'PdfFieldKind', 'PdfUnreadableError', 'PrincipalAllow', 'PrincipalKind',
|
|
34
34
|
'Provider', 'ProxyOpts', 'RENEW_OUTCOME_EVENT', 'RenewOutcome', 'ReplaceChildRowsResult',
|
package/dist/action-route.js
CHANGED
|
@@ -14,13 +14,21 @@ export class ActionError extends Error {
|
|
|
14
14
|
this.status = status;
|
|
15
15
|
}
|
|
16
16
|
}
|
|
17
|
-
//
|
|
18
|
-
|
|
17
|
+
// Used only when the verified bearer names a calling app but carries no chain
|
|
18
|
+
// (a bearer minted before the platform stamped one). Not a measured depth.
|
|
19
|
+
const FALLBACK_HOPS = 1;
|
|
19
20
|
const trustedBaseUrl = process.env.ASSEMBLY_API_BASE_URL || undefined;
|
|
20
|
-
|
|
21
|
-
|
|
21
|
+
// Chain and hops come from the verified bearer and are advisory: they label the
|
|
22
|
+
// request, they never gate it.
|
|
23
|
+
function callerFrom(identity) {
|
|
24
|
+
if (!identity.callerAppId)
|
|
22
25
|
return undefined;
|
|
23
|
-
|
|
26
|
+
const chain = identity.chain?.length ? identity.chain : [identity.callerAppId];
|
|
27
|
+
return {
|
|
28
|
+
appId: identity.callerAppId,
|
|
29
|
+
chain,
|
|
30
|
+
hops: identity.hops ?? (identity.chain?.length ? chain.length : FALLBACK_HOPS),
|
|
31
|
+
};
|
|
24
32
|
}
|
|
25
33
|
// A calling app is the viewer only when the bearer names no person. A human
|
|
26
34
|
// id wins so a staff or client session that also carries a caller stays human.
|
|
@@ -78,7 +86,7 @@ export async function getRequestContext() {
|
|
|
78
86
|
if (!identity?.workspaceId) {
|
|
79
87
|
throw new TokenInvalidError('Assembly bearer is required');
|
|
80
88
|
}
|
|
81
|
-
const caller = callerFrom(identity
|
|
89
|
+
const caller = callerFrom(identity);
|
|
82
90
|
const viewer = viewerFrom(identity);
|
|
83
91
|
if (!viewer) {
|
|
84
92
|
throw new TokenInvalidError('Assembly bearer is required');
|
package/dist/assembly.d.ts
CHANGED
|
@@ -99,8 +99,10 @@ export declare function getOfflineAssembly(): Promise<AssemblyAPI>;
|
|
|
99
99
|
* shown to a client must be filtered by the caller. Unlike getOfflineAssembly,
|
|
100
100
|
* a viewer cookie does not make this throw — that is the point of it.
|
|
101
101
|
*
|
|
102
|
-
* Does not attach the viewer's bearer. Each request mints once;
|
|
103
|
-
* in the same request reuses that bearer.
|
|
102
|
+
* Does not attach the viewer's bearer to its own calls. Each request mints once;
|
|
103
|
+
* a second call in the same request reuses that bearer. A call through `fetch` to
|
|
104
|
+
* another app (`/v1/apps/...`) is the exception: in a client's request it is made
|
|
105
|
+
* as that client (see getCrossAppBearer), in a staff request as this app.
|
|
104
106
|
*/
|
|
105
107
|
export declare function getInstallerAssembly(): Promise<AssemblyAPI>;
|
|
106
108
|
export type AppProxyCaller = 'viewer' | 'installer';
|
|
@@ -114,9 +116,11 @@ export interface AppProxyOptions {
|
|
|
114
116
|
* `createClient`. The bearer is resolved on every call, so a client built at
|
|
115
117
|
* module scope still authenticates as the current request.
|
|
116
118
|
*
|
|
117
|
-
* `installer` calls as this app
|
|
118
|
-
*
|
|
119
|
-
*
|
|
119
|
+
* `installer` calls as this app. In a client's request it also carries that
|
|
120
|
+
* client, so the platform checks the client could open the other app and the
|
|
121
|
+
* other app sees a client; in a staff request it is this app alone. `viewer`
|
|
122
|
+
* forwards the signed-in user's verified bearer, which the platform scopes to
|
|
123
|
+
* that contact and refuses for a team member.
|
|
120
124
|
*/
|
|
121
125
|
export declare function appProxyOptions(appId: string, caller: AppProxyCaller): AppProxyOptions;
|
|
122
126
|
export declare function getAssemblyForRequest(req: NextRequest): Promise<AssemblyAPI | NextResponse>;
|
package/dist/assembly.js
CHANGED
|
@@ -270,8 +270,10 @@ export function authUnavailableResponse() {
|
|
|
270
270
|
// from the trusted build-env baseUrl, never a token/bearer claim. When the
|
|
271
271
|
// caller has a verified identity, getTokenPayload() answers with it rather than
|
|
272
272
|
// with the SDK's read of the bearer's claims.
|
|
273
|
-
async function getBearerAssembly(bearer, identity) {
|
|
273
|
+
async function getBearerAssembly(bearer, identity, options) {
|
|
274
274
|
const assembly = await assemblyApi({ bearer, baseUrl: trustedBaseUrl });
|
|
275
|
+
if (options?.crossAppAsInstaller)
|
|
276
|
+
routeCrossAppFetchThroughInstaller(assembly);
|
|
275
277
|
if (identity) {
|
|
276
278
|
// Includes the embed-context ids the SDK's bearer-mode payload omits, so
|
|
277
279
|
// payload.notificationId still marks the Notification Center surface.
|
|
@@ -325,7 +327,7 @@ export async function getOfflineAssembly() {
|
|
|
325
327
|
throw new Error('getOfflineAssembly: ASSEMBLY_API_KEY is not set — cannot mint an app-scoped bearer');
|
|
326
328
|
}
|
|
327
329
|
const minted = await mintBearerFromAppKey();
|
|
328
|
-
if (!minted) {
|
|
330
|
+
if (!minted.ok) {
|
|
329
331
|
throw new Error('getOfflineAssembly: failed to mint an app-scoped bearer from the app API key');
|
|
330
332
|
}
|
|
331
333
|
return getBearerAssembly(minted.bearer);
|
|
@@ -340,7 +342,7 @@ async function mintInstallerBearer() {
|
|
|
340
342
|
throw new Error('getInstallerAssembly: ASSEMBLY_API_KEY is not set — cannot mint an app-scoped bearer');
|
|
341
343
|
}
|
|
342
344
|
const minted = await mintBearerFromAppKey();
|
|
343
|
-
if (!minted) {
|
|
345
|
+
if (!minted.ok) {
|
|
344
346
|
throw new Error('getInstallerAssembly: failed to mint an app-scoped bearer from the app API key');
|
|
345
347
|
}
|
|
346
348
|
return minted.bearer;
|
|
@@ -377,11 +379,156 @@ async function getInstallerBearer() {
|
|
|
377
379
|
* shown to a client must be filtered by the caller. Unlike getOfflineAssembly,
|
|
378
380
|
* a viewer cookie does not make this throw — that is the point of it.
|
|
379
381
|
*
|
|
380
|
-
* Does not attach the viewer's bearer. Each request mints once;
|
|
381
|
-
* in the same request reuses that bearer.
|
|
382
|
+
* Does not attach the viewer's bearer to its own calls. Each request mints once;
|
|
383
|
+
* a second call in the same request reuses that bearer. A call through `fetch` to
|
|
384
|
+
* another app (`/v1/apps/...`) is the exception: in a client's request it is made
|
|
385
|
+
* as that client (see getCrossAppBearer), in a staff request as this app.
|
|
382
386
|
*/
|
|
383
387
|
export async function getInstallerAssembly() {
|
|
384
|
-
return getBearerAssembly(await getInstallerBearer()
|
|
388
|
+
return getBearerAssembly(await getInstallerBearer(), undefined, {
|
|
389
|
+
crossAppAsInstaller: true,
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
// The platform's cross-app proxy lives under this prefix; nothing else gets a parent.
|
|
393
|
+
const CROSS_APP_PATH_PREFIX = '/v1/apps/';
|
|
394
|
+
// The platform's code for a mint whose parent bearer it refused outright, as
|
|
395
|
+
// opposed to one that merely expired (which a re-exchange fixes).
|
|
396
|
+
const PARENT_INVALID_CODE = 'parent_invalid';
|
|
397
|
+
const PARENT_EXPIRED_CODE = 'parent_expired';
|
|
398
|
+
// One cross-app bearer per request, keyed on the memoized headers object like the
|
|
399
|
+
// installer bearer: a process-wide cache would hand one client's bearer to the next.
|
|
400
|
+
const crossAppBearerByRequest = new WeakMap();
|
|
401
|
+
// Whether `path`, as the SDK will concatenate it onto the base, lands on the
|
|
402
|
+
// cross-app proxy. Resolved with URL first so `/v1/apps/../session` and encoded
|
|
403
|
+
// dot-segments are judged by where they actually go.
|
|
404
|
+
function isCrossAppPath(path) {
|
|
405
|
+
const base = (trustedBaseUrl ?? OpenAPI.BASE).replace(/\/+$/, '');
|
|
406
|
+
try {
|
|
407
|
+
const resolved = new URL(`${base}${path.startsWith('/') ? path : `/${path}`}`);
|
|
408
|
+
const baseUrl = new URL(base);
|
|
409
|
+
if (resolved.origin !== baseUrl.origin)
|
|
410
|
+
return false;
|
|
411
|
+
const basePath = baseUrl.pathname.replace(/\/+$/, '');
|
|
412
|
+
return resolved.pathname.slice(basePath.length).startsWith(CROSS_APP_PATH_PREFIX);
|
|
413
|
+
}
|
|
414
|
+
catch {
|
|
415
|
+
return false;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
// The viewer bearer to present as the mint's parent, or undefined when this
|
|
419
|
+
// request has no person to speak for. The decision rests only on what the
|
|
420
|
+
// platform verifies for the one credential the request carries: the header when
|
|
421
|
+
// present, else the cookie, never the cookie after a rejected header. A viewer
|
|
422
|
+
// that is present but cannot be verified throws instead of quietly becoming app
|
|
423
|
+
// authority.
|
|
424
|
+
async function resolveCrossAppParent(headerStore, cookieStore) {
|
|
425
|
+
const candidate = headerStore.get(BEARER_HEADER_NAME) ||
|
|
426
|
+
cookieStore.get(BEARER_COOKIE_NAME)?.value ||
|
|
427
|
+
undefined;
|
|
428
|
+
const legacyViewer = Boolean(headerStore.get(LEGACY_TOKEN_HEADER) || headerStore.get(LEGACY_WORKSPACE_HEADER));
|
|
429
|
+
if (!candidate) {
|
|
430
|
+
if (legacyViewer)
|
|
431
|
+
throw new TokenInvalidError('Assembly bearer is required');
|
|
432
|
+
return undefined;
|
|
433
|
+
}
|
|
434
|
+
// Exchange off: a viewer is present and nothing can verify it.
|
|
435
|
+
if (!isSessionExchangeEnabled()) {
|
|
436
|
+
throw new TokenInvalidError('Assembly bearer is required');
|
|
437
|
+
}
|
|
438
|
+
const result = await verifyBearer(candidate);
|
|
439
|
+
if (!result.ok) {
|
|
440
|
+
if (result.reason === 'unavailable')
|
|
441
|
+
throw new AuthUnavailableError();
|
|
442
|
+
throw new TokenInvalidError('Assembly bearer is required');
|
|
443
|
+
}
|
|
444
|
+
const { identity } = result;
|
|
445
|
+
// The platform refuses an impersonation or assistant-run bearer as a parent.
|
|
446
|
+
if (identity.tokenKind !== 'viewer')
|
|
447
|
+
return undefined;
|
|
448
|
+
// Staff stay app authority; only a client is forwarded.
|
|
449
|
+
if (!identity.clientId)
|
|
450
|
+
return undefined;
|
|
451
|
+
return candidate;
|
|
452
|
+
}
|
|
453
|
+
async function mintCrossAppBearer(headerStore, cookieStore) {
|
|
454
|
+
const parent = await resolveCrossAppParent(headerStore, cookieStore);
|
|
455
|
+
// Without a person this is the plain installer bearer, so a request that also
|
|
456
|
+
// calls the installer's other endpoints still mints once.
|
|
457
|
+
if (!parent)
|
|
458
|
+
return { bearer: await getInstallerBearer() };
|
|
459
|
+
if (!process.env.ASSEMBLY_API_KEY) {
|
|
460
|
+
throw new Error('getInstallerAssembly: ASSEMBLY_API_KEY is not set — cannot mint an app-scoped bearer');
|
|
461
|
+
}
|
|
462
|
+
const minted = await mintBearerFromAppKey({ parent });
|
|
463
|
+
if (minted.ok)
|
|
464
|
+
return { bearer: minted.bearer };
|
|
465
|
+
// Never retried without the parent: that would be the fallback to app authority.
|
|
466
|
+
if (minted.code === PARENT_EXPIRED_CODE) {
|
|
467
|
+
throw new TokenInvalidError('Assembly bearer expired');
|
|
468
|
+
}
|
|
469
|
+
if (minted.code === PARENT_INVALID_CODE && minted.status !== undefined) {
|
|
470
|
+
return {
|
|
471
|
+
refusal: {
|
|
472
|
+
status: minted.status,
|
|
473
|
+
body: minted.body ?? JSON.stringify({ code: PARENT_INVALID_CODE }),
|
|
474
|
+
},
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
// A throttled mint says nothing about the parent, so it is an outage, not a refusal.
|
|
478
|
+
if (minted.status === undefined || minted.status >= 500 || minted.status === 429) {
|
|
479
|
+
throw new AuthUnavailableError();
|
|
480
|
+
}
|
|
481
|
+
throw new Error('getInstallerAssembly: failed to mint a cross-app bearer from the app API key');
|
|
482
|
+
}
|
|
483
|
+
// The bearer for a call to another app: the installer's, carrying the client when
|
|
484
|
+
// this is a client's request. Throws where the request cannot be read (`'use cache'`,
|
|
485
|
+
// prerender, `after()`), because a bearer minted there would be unattributed while
|
|
486
|
+
// the page may still be serving a client.
|
|
487
|
+
async function getCrossAppBearer() {
|
|
488
|
+
let headerStore;
|
|
489
|
+
let cookieStore;
|
|
490
|
+
try {
|
|
491
|
+
[headerStore, cookieStore] = await Promise.all([headers(), cookies()]);
|
|
492
|
+
}
|
|
493
|
+
catch {
|
|
494
|
+
throw new Error('A call to another app needs the current request, which cannot be read here (a cached component, prerender or after()). Make the call from a page, server action or route handler.');
|
|
495
|
+
}
|
|
496
|
+
const existing = crossAppBearerByRequest.get(headerStore);
|
|
497
|
+
if (existing)
|
|
498
|
+
return existing;
|
|
499
|
+
const pending = mintCrossAppBearer(headerStore, cookieStore);
|
|
500
|
+
crossAppBearerByRequest.set(headerStore, pending);
|
|
501
|
+
try {
|
|
502
|
+
return await pending;
|
|
503
|
+
}
|
|
504
|
+
catch (err) {
|
|
505
|
+
// A failed mint must not stick for the rest of the request.
|
|
506
|
+
crossAppBearerByRequest.delete(headerStore);
|
|
507
|
+
throw err;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
function refusalResponse(refusal) {
|
|
511
|
+
return new Response(refusal.body, {
|
|
512
|
+
status: refusal.status,
|
|
513
|
+
headers: { 'Content-Type': 'application/json' },
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
// Wraps `fetch` on an installer SDK object so a raw call to another app uses the
|
|
517
|
+
// cross-app bearer, and every other path is left exactly as the SDK sends it.
|
|
518
|
+
function routeCrossAppFetchThroughInstaller(assembly) {
|
|
519
|
+
const original = assembly.fetch;
|
|
520
|
+
if (typeof original !== 'function')
|
|
521
|
+
return;
|
|
522
|
+
assembly.fetch = async (path, init) => {
|
|
523
|
+
if (!isCrossAppPath(path))
|
|
524
|
+
return original.call(assembly, path, init);
|
|
525
|
+
const outcome = await getCrossAppBearer();
|
|
526
|
+
if ('refusal' in outcome)
|
|
527
|
+
return refusalResponse(outcome.refusal);
|
|
528
|
+
const headers = new Headers(init?.headers);
|
|
529
|
+
headers.set('Authorization', `Bearer ${outcome.bearer}`);
|
|
530
|
+
return original.call(assembly, path, { ...init, headers, cache: 'no-store' });
|
|
531
|
+
};
|
|
385
532
|
}
|
|
386
533
|
/**
|
|
387
534
|
* Options for calling another app's API through the platform, shaped for an
|
|
@@ -389,19 +536,30 @@ export async function getInstallerAssembly() {
|
|
|
389
536
|
* `createClient`. The bearer is resolved on every call, so a client built at
|
|
390
537
|
* module scope still authenticates as the current request.
|
|
391
538
|
*
|
|
392
|
-
* `installer` calls as this app
|
|
393
|
-
*
|
|
394
|
-
*
|
|
539
|
+
* `installer` calls as this app. In a client's request it also carries that
|
|
540
|
+
* client, so the platform checks the client could open the other app and the
|
|
541
|
+
* other app sees a client; in a staff request it is this app alone. `viewer`
|
|
542
|
+
* forwards the signed-in user's verified bearer, which the platform scopes to
|
|
543
|
+
* that contact and refuses for a team member.
|
|
395
544
|
*/
|
|
396
545
|
export function appProxyOptions(appId, caller) {
|
|
397
546
|
const origin = (trustedBaseUrl ?? OpenAPI.BASE).replace(/\/+$/, '');
|
|
398
547
|
return {
|
|
399
548
|
baseUrl: `${origin}/v1/apps/${encodeURIComponent(appId)}/api`,
|
|
400
549
|
fetch: async (input) => {
|
|
401
|
-
|
|
550
|
+
let bearer;
|
|
551
|
+
if (caller === 'installer') {
|
|
552
|
+
const outcome = await getCrossAppBearer();
|
|
553
|
+
if ('refusal' in outcome)
|
|
554
|
+
return refusalResponse(outcome.refusal);
|
|
555
|
+
bearer = outcome.bearer;
|
|
556
|
+
}
|
|
557
|
+
else {
|
|
558
|
+
bearer = await requireViewerBearer();
|
|
559
|
+
}
|
|
402
560
|
const request = new Request(input);
|
|
403
561
|
request.headers.set('Authorization', `Bearer ${bearer}`);
|
|
404
|
-
return fetch(request);
|
|
562
|
+
return fetch(request, { cache: 'no-store' });
|
|
405
563
|
},
|
|
406
564
|
};
|
|
407
565
|
}
|
package/dist/mint-bearer.d.ts
CHANGED
|
@@ -22,8 +22,35 @@ export interface MintSessionOptions {
|
|
|
22
22
|
}
|
|
23
23
|
export declare function mintBearerFromSessionToken(token: string, options?: MintSessionOptions): Promise<MintOutcome>;
|
|
24
24
|
export declare function fetchSessionFlags(bearer: string): Promise<string[] | null>;
|
|
25
|
+
/** The person a minted bearer is attributed to, echoed by the platform. */
|
|
26
|
+
export interface MintedOnBehalfOf {
|
|
27
|
+
/** "client" or "internal_user". */
|
|
28
|
+
kind: string;
|
|
29
|
+
clientId?: string;
|
|
30
|
+
internalUserId?: string;
|
|
31
|
+
companyId?: string;
|
|
32
|
+
}
|
|
25
33
|
export interface MintedAppBearer {
|
|
26
34
|
bearer: string;
|
|
27
35
|
bearerExpiresAt: number;
|
|
36
|
+
/** Present only when the mint carried a parent the platform accepted. */
|
|
37
|
+
onBehalfOf?: MintedOnBehalfOf;
|
|
38
|
+
}
|
|
39
|
+
export type AppKeyMintOutcome = ({
|
|
40
|
+
ok: true;
|
|
41
|
+
} & MintedAppBearer) | {
|
|
42
|
+
ok: false;
|
|
43
|
+
/** HTTP status of a refusal; absent when no response arrived (no key,
|
|
44
|
+
* network failure, timeout) or the 2xx body was unusable. */
|
|
45
|
+
status?: number;
|
|
46
|
+
/** The platform's error code, when its body carried one. */
|
|
47
|
+
code?: string;
|
|
48
|
+
/** The platform's raw error body, so a refusal can be handed on verbatim. */
|
|
49
|
+
body?: string;
|
|
50
|
+
};
|
|
51
|
+
export interface MintAppKeyOptions {
|
|
52
|
+
/** Viewer bearer of the person whose request this app is serving. The platform
|
|
53
|
+
* refuses a present-but-invalid parent; it is never silently dropped. */
|
|
54
|
+
parent?: string;
|
|
28
55
|
}
|
|
29
|
-
export declare function mintBearerFromAppKey(): Promise<
|
|
56
|
+
export declare function mintBearerFromAppKey(options?: MintAppKeyOptions): Promise<AppKeyMintOutcome>;
|
package/dist/mint-bearer.js
CHANGED
|
@@ -145,20 +145,38 @@ export async function fetchSessionFlags(bearer) {
|
|
|
145
145
|
clearTimeout(timeoutId);
|
|
146
146
|
}
|
|
147
147
|
}
|
|
148
|
+
function parseOnBehalfOf(value) {
|
|
149
|
+
if (!value || typeof value !== 'object')
|
|
150
|
+
return undefined;
|
|
151
|
+
const v = value;
|
|
152
|
+
if (typeof v.kind !== 'string' || v.kind === '')
|
|
153
|
+
return undefined;
|
|
154
|
+
const str = (x) => (typeof x === 'string' && x !== '' ? x : undefined);
|
|
155
|
+
return {
|
|
156
|
+
kind: v.kind,
|
|
157
|
+
clientId: str(v.clientId),
|
|
158
|
+
internalUserId: str(v.internalUserId),
|
|
159
|
+
companyId: str(v.companyId),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
148
162
|
// Mints a short-lived, APP-scoped bearer from the app API key ALONE by calling
|
|
149
163
|
// platform-api POST /v1/token (client-credentials). There is NO session token
|
|
150
164
|
// and NO user in the loop: this is the OFFLINE/background path (cron, webhooks)
|
|
151
165
|
// where no iframe ever handed us a session token to exchange. The app key mints
|
|
152
166
|
// the bearer once; every subsequent data call then uses that bearer alone.
|
|
153
167
|
//
|
|
168
|
+
// With `parent`, the mint is attributed to the person that parent names (see
|
|
169
|
+
// MintAppKeyOptions). Callers decide whether a parent exists; this only sends it.
|
|
170
|
+
//
|
|
154
171
|
// Uses a runtime-agnostic fetch (safe in BOTH the Edge middleware and Node route-
|
|
155
172
|
// handler runtimes) with the same abort-based timeout as the session exchange,
|
|
156
|
-
// and fails closed
|
|
157
|
-
// never proceeds with a missing or bad credential.
|
|
158
|
-
|
|
173
|
+
// and fails closed on any error — including a malformed 2xx — so a caller
|
|
174
|
+
// never proceeds with a missing or bad credential. The failure carries the
|
|
175
|
+
// platform's status and code so a caller can tell a refused parent from an outage.
|
|
176
|
+
export async function mintBearerFromAppKey(options) {
|
|
159
177
|
const apiKey = process.env.ASSEMBLY_API_KEY;
|
|
160
178
|
if (!apiKey)
|
|
161
|
-
return
|
|
179
|
+
return { ok: false };
|
|
162
180
|
// Client-credentials mint: POST /v1/token authenticated by the app key sent as
|
|
163
181
|
// X-API-Key. Unlike mintBearerFromSessionToken (which decrypts a session token
|
|
164
182
|
// and lets the SDK derive the header), there is no token here, and the SDK only
|
|
@@ -174,16 +192,31 @@ export async function mintBearerFromAppKey() {
|
|
|
174
192
|
// Present only in the app's sandbox (written to .env.local, never to the deployed
|
|
175
193
|
// env); it lets the platform tell a sandbox call from a deployed one.
|
|
176
194
|
const sandboxGrant = process.env.ASSEMBLY_SANDBOX_GRANT;
|
|
195
|
+
const body = {
|
|
196
|
+
...(sandboxGrant ? { sandboxGrant } : {}),
|
|
197
|
+
...(options?.parent ? { parent: options.parent } : {}),
|
|
198
|
+
};
|
|
199
|
+
const hasBody = Object.keys(body).length > 0;
|
|
177
200
|
const resp = await fetch(`${base}/v1/token`, {
|
|
178
201
|
method: 'POST',
|
|
179
|
-
headers:
|
|
202
|
+
headers: hasBody
|
|
180
203
|
? { 'X-API-Key': apiKey, 'Content-Type': 'application/json' }
|
|
181
204
|
: { 'X-API-Key': apiKey },
|
|
182
|
-
...(
|
|
205
|
+
...(hasBody ? { body: JSON.stringify(body) } : {}),
|
|
183
206
|
signal: controller.signal,
|
|
184
207
|
});
|
|
185
|
-
if (!resp.ok)
|
|
186
|
-
|
|
208
|
+
if (!resp.ok) {
|
|
209
|
+
let text;
|
|
210
|
+
let code;
|
|
211
|
+
try {
|
|
212
|
+
text = await resp.text();
|
|
213
|
+
const parsed = JSON.parse(text);
|
|
214
|
+
if (typeof parsed?.code === 'string')
|
|
215
|
+
code = parsed.code;
|
|
216
|
+
}
|
|
217
|
+
catch { }
|
|
218
|
+
return { ok: false, status: resp.status, code, body: text };
|
|
219
|
+
}
|
|
187
220
|
const json = (await resp.json());
|
|
188
221
|
// Fail closed on a malformed or already-expired 2xx: a missing/mistyped token
|
|
189
222
|
// would yield a client that sends `Authorization: Bearer undefined`, a
|
|
@@ -195,13 +228,19 @@ export async function mintBearerFromAppKey() {
|
|
|
195
228
|
typeof json.tokenExpiresAt !== 'number' ||
|
|
196
229
|
!Number.isFinite(json.tokenExpiresAt) ||
|
|
197
230
|
json.tokenExpiresAt * 1_000 <= now) {
|
|
198
|
-
return
|
|
231
|
+
return { ok: false, status: resp.status };
|
|
199
232
|
}
|
|
200
233
|
const bearerExpiresAt = Math.min(json.tokenExpiresAt * 1_000, now + BEARER_TTL_MS);
|
|
201
|
-
|
|
234
|
+
const onBehalfOf = parseOnBehalfOf(json.onBehalfOf);
|
|
235
|
+
return {
|
|
236
|
+
ok: true,
|
|
237
|
+
bearer: json.token,
|
|
238
|
+
bearerExpiresAt,
|
|
239
|
+
...(onBehalfOf ? { onBehalfOf } : {}),
|
|
240
|
+
};
|
|
202
241
|
}
|
|
203
242
|
catch {
|
|
204
|
-
return
|
|
243
|
+
return { ok: false };
|
|
205
244
|
}
|
|
206
245
|
finally {
|
|
207
246
|
clearTimeout(timeoutId);
|
package/dist/proxy.js
CHANGED
|
@@ -121,7 +121,7 @@ async function fetchWorkspaceThemeForIdentity(workspaceId, bearer) {
|
|
|
121
121
|
// or future change surfaces an exception.
|
|
122
122
|
try {
|
|
123
123
|
const minted = await mintBearerFromAppKey();
|
|
124
|
-
if (!minted
|
|
124
|
+
if (!minted.ok)
|
|
125
125
|
return null;
|
|
126
126
|
return await fetchWorkspaceThemeWithBearer(minted.bearer, workspaceId);
|
|
127
127
|
}
|
package/dist/scripts.js
CHANGED
|
@@ -4644,33 +4644,66 @@ var COOKIE_PROBE_TTL_SECONDS = 24 * 60 * 60;
|
|
|
4644
4644
|
// src/mint-bearer.ts
|
|
4645
4645
|
var mintBaseUrl = process.env.ASSEMBLY_API_BASE_URL || void 0;
|
|
4646
4646
|
var EXCHANGE_TIMEOUT_MS = 15e3;
|
|
4647
|
-
|
|
4647
|
+
function parseOnBehalfOf(value) {
|
|
4648
|
+
if (!value || typeof value !== "object") return void 0;
|
|
4649
|
+
const v = value;
|
|
4650
|
+
if (typeof v.kind !== "string" || v.kind === "") return void 0;
|
|
4651
|
+
const str = (x) => typeof x === "string" && x !== "" ? x : void 0;
|
|
4652
|
+
return {
|
|
4653
|
+
kind: v.kind,
|
|
4654
|
+
clientId: str(v.clientId),
|
|
4655
|
+
internalUserId: str(v.internalUserId),
|
|
4656
|
+
companyId: str(v.companyId)
|
|
4657
|
+
};
|
|
4658
|
+
}
|
|
4659
|
+
async function mintBearerFromAppKey(options) {
|
|
4648
4660
|
const apiKey = process.env.ASSEMBLY_API_KEY;
|
|
4649
|
-
if (!apiKey) return
|
|
4661
|
+
if (!apiKey) return { ok: false };
|
|
4650
4662
|
const base = (mintBaseUrl ?? OpenAPI2.BASE).replace(/\/$/, "");
|
|
4651
4663
|
const now = Date.now();
|
|
4652
4664
|
const controller = new AbortController();
|
|
4653
4665
|
const timeoutId = setTimeout(() => controller.abort(), EXCHANGE_TIMEOUT_MS);
|
|
4654
4666
|
try {
|
|
4655
4667
|
const sandboxGrant = process.env.ASSEMBLY_SANDBOX_GRANT;
|
|
4668
|
+
const body = {
|
|
4669
|
+
...sandboxGrant ? { sandboxGrant } : {},
|
|
4670
|
+
...options?.parent ? { parent: options.parent } : {}
|
|
4671
|
+
};
|
|
4672
|
+
const hasBody = Object.keys(body).length > 0;
|
|
4656
4673
|
const resp = await fetch(`${base}/v1/token`, {
|
|
4657
4674
|
method: "POST",
|
|
4658
|
-
headers:
|
|
4659
|
-
...
|
|
4675
|
+
headers: hasBody ? { "X-API-Key": apiKey, "Content-Type": "application/json" } : { "X-API-Key": apiKey },
|
|
4676
|
+
...hasBody ? { body: JSON.stringify(body) } : {},
|
|
4660
4677
|
signal: controller.signal
|
|
4661
4678
|
});
|
|
4662
|
-
if (!resp.ok)
|
|
4679
|
+
if (!resp.ok) {
|
|
4680
|
+
let text;
|
|
4681
|
+
let code;
|
|
4682
|
+
try {
|
|
4683
|
+
text = await resp.text();
|
|
4684
|
+
const parsed = JSON.parse(text);
|
|
4685
|
+
if (typeof parsed?.code === "string") code = parsed.code;
|
|
4686
|
+
} catch {
|
|
4687
|
+
}
|
|
4688
|
+
return { ok: false, status: resp.status, code, body: text };
|
|
4689
|
+
}
|
|
4663
4690
|
const json = await resp.json();
|
|
4664
4691
|
if (typeof json.token !== "string" || json.token.length === 0 || typeof json.tokenExpiresAt !== "number" || !Number.isFinite(json.tokenExpiresAt) || json.tokenExpiresAt * 1e3 <= now) {
|
|
4665
|
-
return
|
|
4692
|
+
return { ok: false, status: resp.status };
|
|
4666
4693
|
}
|
|
4667
4694
|
const bearerExpiresAt = Math.min(
|
|
4668
4695
|
json.tokenExpiresAt * 1e3,
|
|
4669
4696
|
now + BEARER_TTL_MS
|
|
4670
4697
|
);
|
|
4671
|
-
|
|
4698
|
+
const onBehalfOf = parseOnBehalfOf(json.onBehalfOf);
|
|
4699
|
+
return {
|
|
4700
|
+
ok: true,
|
|
4701
|
+
bearer: json.token,
|
|
4702
|
+
bearerExpiresAt,
|
|
4703
|
+
...onBehalfOf ? { onBehalfOf } : {}
|
|
4704
|
+
};
|
|
4672
4705
|
} catch {
|
|
4673
|
-
return
|
|
4706
|
+
return { ok: false };
|
|
4674
4707
|
} finally {
|
|
4675
4708
|
clearTimeout(timeoutId);
|
|
4676
4709
|
}
|
|
@@ -4689,7 +4722,7 @@ async function getOfflineAssembly() {
|
|
|
4689
4722
|
);
|
|
4690
4723
|
}
|
|
4691
4724
|
const minted = await mintBearerFromAppKey();
|
|
4692
|
-
if (!minted) {
|
|
4725
|
+
if (!minted.ok) {
|
|
4693
4726
|
throw new Error(
|
|
4694
4727
|
"getOfflineAssembly: failed to mint an app-scoped bearer from the app API key"
|
|
4695
4728
|
);
|
package/dist/verify-bearer.d.ts
CHANGED
|
@@ -9,6 +9,11 @@ export interface VerifiedIdentity {
|
|
|
9
9
|
callerAppId?: string;
|
|
10
10
|
installerUserId?: string;
|
|
11
11
|
tokenKind?: string;
|
|
12
|
+
/** Apps an app-to-app call passed through to reach this request, read from the
|
|
13
|
+
* verified bearer's own claims. Advisory: display and logging only, never a
|
|
14
|
+
* decision (the platform's depth cap is the control). */
|
|
15
|
+
chain?: string[];
|
|
16
|
+
hops?: number;
|
|
12
17
|
/** Epoch SECONDS at which the verified bearer expires. */
|
|
13
18
|
expiresAt: number;
|
|
14
19
|
}
|
|
@@ -24,6 +29,7 @@ export declare const VIEWER_BEARER_HEADER = "X-Assembly-Viewer-Bearer";
|
|
|
24
29
|
export declare const VERIFY_OUTCOME_EVENT = "assembly.verify_outcome";
|
|
25
30
|
/**
|
|
26
31
|
* Resolves a viewer bearer to the identity the platform vouches for. Identity
|
|
27
|
-
* must come from here, never from the bearer's decoded claims.
|
|
32
|
+
* must come from here, never from the bearer's decoded claims. The one
|
|
33
|
+
* exception is the advisory call chain, which only the bearer carries.
|
|
28
34
|
*/
|
|
29
35
|
export declare function verifyBearer(bearer: string): Promise<VerifyResult>;
|
package/dist/verify-bearer.js
CHANGED
|
@@ -156,11 +156,45 @@ async function verifyUncached(bearer, key, startedAt) {
|
|
|
156
156
|
logOutcome(result.reason, startedAt);
|
|
157
157
|
return result;
|
|
158
158
|
}
|
|
159
|
+
// Reads the advisory call-chain claims off a bearer the platform has just
|
|
160
|
+
// verified. Never used to decide anything: a bearer that fails to decode simply
|
|
161
|
+
// has no chain.
|
|
162
|
+
function readChainClaims(bearer) {
|
|
163
|
+
try {
|
|
164
|
+
const segment = bearer.split('.')[1];
|
|
165
|
+
const b64 = segment.replace(/-/g, '+').replace(/_/g, '/');
|
|
166
|
+
const padded = b64 + '='.repeat((4 - (b64.length % 4)) % 4);
|
|
167
|
+
const claims = JSON.parse(new TextDecoder().decode(Uint8Array.from(atob(padded), (ch) => ch.charCodeAt(0))));
|
|
168
|
+
const chain = Array.isArray(claims.chain)
|
|
169
|
+
? claims.chain.filter((a) => typeof a === 'string' && a !== '')
|
|
170
|
+
: [];
|
|
171
|
+
const hops = typeof claims.hops === 'number' && Number.isInteger(claims.hops) && claims.hops > 0
|
|
172
|
+
? claims.hops
|
|
173
|
+
: undefined;
|
|
174
|
+
return {
|
|
175
|
+
...(chain.length ? { chain } : {}),
|
|
176
|
+
...(hops ? { hops } : {}),
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
return {};
|
|
181
|
+
}
|
|
182
|
+
}
|
|
159
183
|
/**
|
|
160
184
|
* Resolves a viewer bearer to the identity the platform vouches for. Identity
|
|
161
|
-
* must come from here, never from the bearer's decoded claims.
|
|
185
|
+
* must come from here, never from the bearer's decoded claims. The one
|
|
186
|
+
* exception is the advisory call chain, which only the bearer carries.
|
|
162
187
|
*/
|
|
163
188
|
export async function verifyBearer(bearer) {
|
|
189
|
+
const result = await verifyBearerWithPlatform(bearer);
|
|
190
|
+
if (!result.ok)
|
|
191
|
+
return result;
|
|
192
|
+
return {
|
|
193
|
+
ok: true,
|
|
194
|
+
identity: Object.freeze({ ...result.identity, ...readChainClaims(bearer) }),
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
async function verifyBearerWithPlatform(bearer) {
|
|
164
198
|
const startedAt = Date.now();
|
|
165
199
|
// The decode is a no-network precheck: shape, expiry and workspace claim.
|
|
166
200
|
if (!decodeBearer(bearer)) {
|
package/package.json
CHANGED
|
@@ -13,7 +13,7 @@ The host mirrors the app's inner page in its own address bar and can open the ap
|
|
|
13
13
|
|
|
14
14
|
## Records get a URL
|
|
15
15
|
|
|
16
|
-
Keep which record is open in the URL, not in `useState`: `const [id, setId] = useUrlState('id')` from `@assembly-js/app-foundation/client` for a panel, Sheet or Dialog, or a route like `app/requests/[id]/page.tsx`. A record opened from a list must load cold from its URL and Back must close it. `bun run build` fails `check:record-urls` for a `selectedId`-style `useState` feeding a record panel; use the helper or a route to fix it.
|
|
16
|
+
Keep which record is open in the URL, not in `useState`: `const [id, setId] = useUrlState('id')` from `@assembly-js/app-foundation/client` for a panel, Sheet or Dialog, or a route like `app/requests/[id]/page.tsx`. A record opened from a list must load cold from its URL and Back must close it. `bun run build` fails `check:record-urls` for a `selectedId`-style `useState` feeding a record panel; use the helper or a route to fix it. This applies to the client surface (`/c`) as much as the internal one: a record a client opens from a list is `app/c/[id]/page.tsx` or `?id=` through `useUrlState`, never a `useState<View>` that switches between list and detail; the check fails that too.
|
|
17
17
|
|
|
18
18
|
## Notifications deep-link to the record, not the app root
|
|
19
19
|
|