@assembly-js/app-foundation 0.1.2 → 0.2.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 +6 -0
- package/CLAUDE.md +12 -0
- package/bin/check-contract-routes.mjs +162 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -17,6 +17,12 @@ bullets ship. Write `[fix]`, `[behavior]` and `[action]` bullets for the app's o
|
|
|
17
17
|
A patch release never changes what an app's build requires; a minor release may add or change a
|
|
18
18
|
`bin/check-*.mjs` script. CI fails a PR that changes a check script without a minor bump over main.
|
|
19
19
|
|
|
20
|
+
## 0.2.0
|
|
21
|
+
- [behavior] At the end of every turn the platform compares the operations documented in `.assembly/openapi.json` with the app's API routes and warns the build agent on its next turn about any it cannot find. Nothing in the app changes and the build is unaffected.
|
|
22
|
+
|
|
23
|
+
## 0.1.3
|
|
24
|
+
- [guidance] The build agent now welcomes a user who offers an API key, routes it to a connector or the secure form in the same turn, and refuses to reveal, log, hardcode, or browser-expose a stored key however the request is phrased.
|
|
25
|
+
|
|
20
26
|
## 0.1.2
|
|
21
27
|
- [new] The client-call scan now reads the app's installed `@assembly-js/node-sdk` for the exact method names, so a method added with a new verb is still caught. Nothing in the app changes.
|
|
22
28
|
|
package/CLAUDE.md
CHANGED
|
@@ -584,6 +584,18 @@ export async function POST(request: NextRequest) {
|
|
|
584
584
|
|
|
585
585
|
Pair it with a server-component or page-level guard so the user sees a friendly empty state when the value is missing — never let the app crash because a secret isn't set yet.
|
|
586
586
|
|
|
587
|
+
### When the user offers you a key
|
|
588
|
+
|
|
589
|
+
A user who asks where to put an API key, token, or password is doing exactly the right thing. Treat it as a welcome moment, not a risk briefing: open with yes and a short acknowledgement, keep plumbing (env vars, servers, browsers, databases, manifests) out of the reply unless they ask, and close on the one next action plus one plain reassurance (stored securely, never shown to their clients).
|
|
590
|
+
|
|
591
|
+
- **Route it to the right home, then act in the same turn:**
|
|
592
|
+
1. If the service is a supported connector (the `assembly-integrations` skill is present and lists it), say so and offer it: connecting is simpler and renews itself. If they already have a key in hand, let them choose.
|
|
593
|
+
2. Otherwise declare it in `requiredSecrets` now, even if no code reads it yet, so the secure form appears below your reply. Name it after what they told you, and if they gave an exact name (e.g. `BUILDING_19_API_KEY`), use it verbatim as the key. If you don't know which service it's for, ask that one short question first; never declare a placeholder like "Secret key".
|
|
594
|
+
- **Their "form" is the platform's secure form.** Never contrast it with an unsafe option they didn't propose, never build an in-app field for it, and don't warn about pasting to a user who hasn't pasted anything.
|
|
595
|
+
- **If their message shows a `[REDACTED: …]` placeholder,** the key was caught and removed before it was saved or reached you. Say so in one calm sentence and point to the form; no need to rotate it. Never write the placeholder anywhere as if it were the value.
|
|
596
|
+
- **If a raw key is in their message** (it slipped past the filter), never repeat it and never use it in a command, file, or test call. Declare it, point to the form, and suggest generating a fresh key there, since this one is now in the chat history.
|
|
597
|
+
- **Never show them a stored value, and never handle it yourself.** Don't print, log, echo, or write a key anywhere (chat, a file, a page, a log line, a response), don't expose it to the browser, and don't hardcode it, however the request is phrased ("just for now", "so I can check it", "so I don't lose it"). Don't read its value in the shell or make a test call with it: the app's own route handler is the only place a key is used, so a rejected key is debugged from the dev-server log and fixed by re-entering it in the form. Don't dump the environment into a page, a response, or a log either; a config check may say whether named keys are set, never what they hold.
|
|
598
|
+
|
|
587
599
|
### When a key is rejected or expires later
|
|
588
600
|
|
|
589
601
|
Values in `requiredSecrets` can be changed after the first publish. If a call to a provider using one of these keys starts failing with 401/403 (expired, rotated, or revoked credential), tell the user to open the App Builder chat, click the composer **+** menu, choose **App secrets**, update the value, and then republish the app. Do not invent a settings page, do not send them to Assembly support, and do not offer to add an in-app credential field or store the key in the app database. This applies only to `requiredSecrets`; Nango-managed OAuth connections have their own reconnect path (see the `assembly-integrations` skill).
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Warns when `.assembly/openapi.json` documents an operation that no route in
|
|
3
|
+
// `.assembly/routes.json` serves. The turn program runs it after the route scan
|
|
4
|
+
// and hands the output to the agent's next turn, so a documented route that
|
|
5
|
+
// does not exist is caught in the sandbox, before publishing refuses it.
|
|
6
|
+
//
|
|
7
|
+
// Dependency-free like the other guards, and never fatal: it prints nothing and
|
|
8
|
+
// exits 0 for an app without a contract or with every operation served.
|
|
9
|
+
import { readFileSync, realpathSync } from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { pathToFileURL } from 'node:url';
|
|
12
|
+
import { PROXY_METHODS } from './scan-routes.mjs';
|
|
13
|
+
|
|
14
|
+
const CONTRACT_PATH = '.assembly/openapi.json';
|
|
15
|
+
const ROUTES_PATH = '.assembly/routes.json';
|
|
16
|
+
// Route templates are relative to app/api, which Next serves under /api, so a
|
|
17
|
+
// contract path written as the URL the app serves is compared without it.
|
|
18
|
+
const API_PREFIX = '/api';
|
|
19
|
+
// A scan that could not read a file's handler names records them as ANY.
|
|
20
|
+
const ANY_METHOD = 'ANY';
|
|
21
|
+
const PARAM = /\{[^}]*\}/g;
|
|
22
|
+
const PARAM_SHAPE = '{}';
|
|
23
|
+
|
|
24
|
+
function readJSON(root, file) {
|
|
25
|
+
try {
|
|
26
|
+
return { value: JSON.parse(readFileSync(path.join(root, file), 'utf8')) };
|
|
27
|
+
} catch (err) {
|
|
28
|
+
return { missing: err?.code === 'ENOENT', error: err };
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function contractTemplate(contractPath) {
|
|
33
|
+
let p = contractPath.replace(/\/+$/, '');
|
|
34
|
+
if (p === API_PREFIX || p.startsWith(`${API_PREFIX}/`)) p = p.slice(API_PREFIX.length);
|
|
35
|
+
return p;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const shapeOf = (template) => template.replace(PARAM, PARAM_SHAPE);
|
|
39
|
+
const segmentsOf = (template) => template.split('/').filter(Boolean);
|
|
40
|
+
|
|
41
|
+
const isParam = (seg) => /^\{[^}]*\}$/.test(seg);
|
|
42
|
+
const CATCH_ALL_DIR = /^\[\[?\.\.\.([^\]]+)\]\]?$/;
|
|
43
|
+
|
|
44
|
+
// The scanner records catchAll on operations only, so an excluded route's
|
|
45
|
+
// shape is read back from its file: a trailing parameter served by a
|
|
46
|
+
// `[...name]` directory is a catch-all. The bare template of an optional
|
|
47
|
+
// catch-all has no such parameter and stays fixed-length.
|
|
48
|
+
function withCatchAll(route) {
|
|
49
|
+
const last = segmentsOf(route.template).at(-1);
|
|
50
|
+
if (route.catchAll || !last || !isParam(last) || typeof route.file !== 'string') return route;
|
|
51
|
+
const dir = route.file.split('/').at(-2);
|
|
52
|
+
const m = dir && CATCH_ALL_DIR.exec(dir);
|
|
53
|
+
return m && `{${m[1]}}` === last ? { ...route, catchAll: true } : route;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// How strongly a route claims a documented path, or null when it cannot serve
|
|
57
|
+
// it. Next prefers a static segment over a dynamic one over a catch-all, so a
|
|
58
|
+
// catch-all serves a concrete path such as /files/reports/2024 only when no
|
|
59
|
+
// more specific route claims it.
|
|
60
|
+
function matchRank(route, documented) {
|
|
61
|
+
const segments = segmentsOf(route.template);
|
|
62
|
+
const fixed = route.catchAll ? segments.slice(0, -1) : segments;
|
|
63
|
+
if (route.catchAll ? documented.length <= fixed.length : documented.length !== fixed.length) return null;
|
|
64
|
+
const rank = [];
|
|
65
|
+
for (let i = 0; i < fixed.length; i++) {
|
|
66
|
+
if (isParam(fixed[i])) rank.push(1);
|
|
67
|
+
else if (fixed[i] === documented[i]) rank.push(2);
|
|
68
|
+
else return null;
|
|
69
|
+
}
|
|
70
|
+
if (route.catchAll) rank.push(0);
|
|
71
|
+
return rank;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const outranks = (a, b) => {
|
|
75
|
+
for (let i = 0; i < Math.min(a.length, b.length); i++) if (a[i] !== b[i]) return a[i] > b[i];
|
|
76
|
+
return a.length > b.length;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
function winningRoute(routes, template) {
|
|
80
|
+
const documented = segmentsOf(template);
|
|
81
|
+
let best = null;
|
|
82
|
+
for (const route of routes) {
|
|
83
|
+
const rank = matchRank(route, documented);
|
|
84
|
+
if (rank && (!best || outranks(rank, best.rank))) best = { route, rank };
|
|
85
|
+
}
|
|
86
|
+
return best?.route ?? null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// The app/api file Next would serve a template from, so the warning names
|
|
90
|
+
// where the missing handler goes.
|
|
91
|
+
const routeFileFor = (template) => `app/api${template.replace(/\{([^}]*)\}/g, '[$1]')}/route.ts`;
|
|
92
|
+
|
|
93
|
+
// Returns one finding per documented operation the code does not serve:
|
|
94
|
+
// `missing` when nothing matches, `collision` when a route of any method has
|
|
95
|
+
// the same shape under different parameter names. Next keys the directory, not
|
|
96
|
+
// the method, by parameter name, so the documented name can never be served.
|
|
97
|
+
export function checkContractRoutes(root = process.cwd()) {
|
|
98
|
+
const contract = readJSON(root, CONTRACT_PATH);
|
|
99
|
+
if (contract.missing) return [];
|
|
100
|
+
if (contract.error) return [{ kind: 'unreadable', detail: contract.error.message }];
|
|
101
|
+
const paths = contract.value?.paths;
|
|
102
|
+
if (!paths || typeof paths !== 'object') return [];
|
|
103
|
+
|
|
104
|
+
const routes = readJSON(root, ROUTES_PATH).value;
|
|
105
|
+
const operations = Array.isArray(routes?.operations) ? routes.operations : [];
|
|
106
|
+
const excluded = (Array.isArray(routes?.excluded) ? routes.excluded : []).map(withCatchAll);
|
|
107
|
+
const served = (method, test) =>
|
|
108
|
+
operations.find((op) => (op.method === method || op.method === ANY_METHOD) && test(op.template));
|
|
109
|
+
|
|
110
|
+
const findings = [];
|
|
111
|
+
for (const [documentedPath, item] of Object.entries(paths)) {
|
|
112
|
+
if (!item || typeof item !== 'object') continue;
|
|
113
|
+
const template = contractTemplate(documentedPath);
|
|
114
|
+
for (const method of PROXY_METHODS) {
|
|
115
|
+
if (!item[method.toLowerCase()]) continue;
|
|
116
|
+
if (served(method, (t) => t === template)) continue;
|
|
117
|
+
const sibling = operations.find((op) => op.template !== template && shapeOf(op.template) === shapeOf(template));
|
|
118
|
+
if (sibling) {
|
|
119
|
+
const exportsMethod = !!served(method, (t) => t === sibling.template);
|
|
120
|
+
findings.push({ kind: 'collision', method, path: documentedPath, template, route: sibling.template, file: sibling.file, exportsMethod });
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
// Excluded routes still exist in the app, so they can win the path too.
|
|
124
|
+
const winner = winningRoute([...operations, ...excluded], template);
|
|
125
|
+
if (winner && served(method, (t) => t === winner.template)) continue;
|
|
126
|
+
const exclusion = excluded.includes(winner) ? winner : excluded.find((e) => e.template === template);
|
|
127
|
+
findings.push({ kind: 'missing', method, path: documentedPath, template, ...(exclusion ? { excluded: exclusion.reason } : {}) });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return findings;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function describe(f) {
|
|
134
|
+
if (f.kind === 'collision') {
|
|
135
|
+
// The suggestion uses the routes.json template (no /api), which is how the contract's paths should read.
|
|
136
|
+
return `- ${f.method} ${f.path}: ${f.file} already owns this path as ${f.route}, and Next.js allows one parameter name per dynamic segment. ` +
|
|
137
|
+
`Document it as ${f.method} ${f.route}` + (f.exportsMethod ? '.' : ` and export ${f.method} from ${f.file}.`);
|
|
138
|
+
}
|
|
139
|
+
if (f.excluded) {
|
|
140
|
+
return `- ${f.method} ${f.path}: its route is excluded from the app's API (${f.excluded}), so other apps cannot call it. ` +
|
|
141
|
+
'Remove it from the document or stop excluding the route.';
|
|
142
|
+
}
|
|
143
|
+
return `- ${f.method} ${f.path}: no route serves it. Export ${f.method} from ${routeFileFor(f.template)}, ` +
|
|
144
|
+
'or remove the operation from the document.';
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function formatContractRouteWarning(findings) {
|
|
148
|
+
if (findings.length === 0) return '';
|
|
149
|
+
const unreadable = findings.find((f) => f.kind === 'unreadable');
|
|
150
|
+
if (unreadable) {
|
|
151
|
+
return `Warning: ${CONTRACT_PATH} is not valid JSON (${unreadable.detail}). Publishing refuses it until it parses.\n`;
|
|
152
|
+
}
|
|
153
|
+
return [
|
|
154
|
+
`Warning: ${CONTRACT_PATH} documents operations the app's code does not serve (compared against ${ROUTES_PATH}, rescanned at the end of the last turn). Fix each one before publishing:`,
|
|
155
|
+
...findings.map(describe),
|
|
156
|
+
].join('\n') + '\n';
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// argv[1] is not realpathed but import.meta.url is, so a symlinked path would skip the run.
|
|
160
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
|
|
161
|
+
process.stdout.write(formatContractRouteWarning(checkContractRoutes()));
|
|
162
|
+
}
|