@assembly-js/app-foundation 0.1.3 → 0.2.1
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/bin/check-contract-routes.mjs +162 -0
- package/dist/scripts.d.ts +8 -0
- package/dist/scripts.js +24 -0
- package/package.json +5 -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.1
|
|
21
|
+
- [new] Add `@assembly-js/app-foundation/scripts`, which exports `getOfflineAssembly` without `server-only`, for seed and backfill scripts that run outside Next.
|
|
22
|
+
|
|
23
|
+
## 0.2.0
|
|
24
|
+
- [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.
|
|
25
|
+
|
|
20
26
|
## 0.1.3
|
|
21
27
|
- [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.
|
|
22
28
|
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type AssemblyAPI } from '@assembly-js/node-sdk';
|
|
2
|
+
/**
|
|
3
|
+
* The script-side counterpart of `getOfflineAssembly` from `/server`: mints a
|
|
4
|
+
* short-lived app-scoped bearer from `ASSEMBLY_API_KEY` and returns a bearer-only
|
|
5
|
+
* client. There is no request here, so the viewer-identity guard `/server` applies
|
|
6
|
+
* does not exist; use this only from scripts, never from code under `app/`.
|
|
7
|
+
*/
|
|
8
|
+
export declare function getOfflineAssembly(): Promise<AssemblyAPI>;
|
package/dist/scripts.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Entry point for standalone scripts (seed, backfill, one-off jobs) run with plain
|
|
2
|
+
// `bun`/`node` outside Next. It must not import './server.js', './assembly' or anything
|
|
3
|
+
// else that reaches `server-only` or `next/*`, which throw outside a Next bundle.
|
|
4
|
+
import { assemblyApi } from '@assembly-js/node-sdk';
|
|
5
|
+
import { mintBearerFromAppKey } from './mint-bearer.js';
|
|
6
|
+
/**
|
|
7
|
+
* The script-side counterpart of `getOfflineAssembly` from `/server`: mints a
|
|
8
|
+
* short-lived app-scoped bearer from `ASSEMBLY_API_KEY` and returns a bearer-only
|
|
9
|
+
* client. There is no request here, so the viewer-identity guard `/server` applies
|
|
10
|
+
* does not exist; use this only from scripts, never from code under `app/`.
|
|
11
|
+
*/
|
|
12
|
+
export async function getOfflineAssembly() {
|
|
13
|
+
if (!process.env.ASSEMBLY_API_KEY) {
|
|
14
|
+
throw new Error('getOfflineAssembly: ASSEMBLY_API_KEY is not set — cannot mint an app-scoped bearer');
|
|
15
|
+
}
|
|
16
|
+
const minted = await mintBearerFromAppKey();
|
|
17
|
+
if (!minted) {
|
|
18
|
+
throw new Error('getOfflineAssembly: failed to mint an app-scoped bearer from the app API key');
|
|
19
|
+
}
|
|
20
|
+
return assemblyApi({
|
|
21
|
+
bearer: minted.bearer,
|
|
22
|
+
baseUrl: process.env.ASSEMBLY_API_BASE_URL || undefined,
|
|
23
|
+
});
|
|
24
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@assembly-js/app-foundation",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Versioned foundation helpers and build context for Assembly generated apps",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -24,6 +24,10 @@
|
|
|
24
24
|
"types": "./dist/lib-client.d.ts",
|
|
25
25
|
"default": "./dist/lib-client.js"
|
|
26
26
|
},
|
|
27
|
+
"./scripts": {
|
|
28
|
+
"types": "./dist/scripts.d.ts",
|
|
29
|
+
"default": "./dist/scripts.js"
|
|
30
|
+
},
|
|
27
31
|
"./next": {
|
|
28
32
|
"types": "./dist/next.d.ts",
|
|
29
33
|
"default": "./dist/next.js"
|