@agent-flow-board/bridge 0.1.0 → 0.3.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/README.md +29 -1
- package/dist/checks/appdir.js +105 -0
- package/dist/checks/approve.js +213 -0
- package/dist/checks/audit.js +126 -0
- package/dist/checks/bind.js +100 -0
- package/dist/checks/board.js +107 -0
- package/dist/checks/broker.js +439 -0
- package/dist/checks/browser.js +275 -0
- package/dist/checks/browserpool.js +221 -0
- package/dist/checks/cache.js +710 -0
- package/dist/checks/child.js +211 -0
- package/dist/checks/command.js +267 -0
- package/dist/checks/commands.js +219 -0
- package/dist/checks/connect.js +79 -0
- package/dist/checks/connections.js +42 -0
- package/dist/checks/copy.js +320 -0
- package/dist/checks/dbpool.js +710 -0
- package/dist/checks/doctor.js +649 -0
- package/dist/checks/envPolicy.js +212 -0
- package/dist/checks/envrules.js +322 -0
- package/dist/checks/evidence.js +114 -0
- package/dist/checks/fsretry.js +29 -0
- package/dist/checks/guard.js +188 -0
- package/dist/checks/guardrails.js +155 -0
- package/dist/checks/importlegacy.js +300 -0
- package/dist/checks/install.js +150 -0
- package/dist/checks/live.js +76 -0
- package/dist/checks/lock.js +207 -0
- package/dist/checks/login.js +203 -0
- package/dist/checks/mailguard-hook.cjs +216 -0
- package/dist/checks/mailguard.js +98 -0
- package/dist/checks/paths.js +85 -0
- package/dist/checks/preview.js +577 -0
- package/dist/checks/profiles.js +110 -0
- package/dist/checks/provision.js +175 -0
- package/dist/checks/recipe.js +246 -0
- package/dist/checks/redact.js +52 -0
- package/dist/checks/registry.js +548 -0
- package/dist/checks/repoenv.js +162 -0
- package/dist/checks/roots.js +144 -0
- package/dist/checks/routing.js +68 -0
- package/dist/checks/run.js +443 -0
- package/dist/checks/sandbox.js +208 -0
- package/dist/checks/scheduler.js +383 -0
- package/dist/checks/scope.js +383 -0
- package/dist/checks/sealedNames.js +39 -0
- package/dist/checks/secrets.js +452 -0
- package/dist/checks/secretscmd.js +80 -0
- package/dist/checks/service.js +589 -0
- package/dist/checks/signin.js +79 -0
- package/dist/checks/singleflight.js +307 -0
- package/dist/checks/store.js +92 -0
- package/dist/checks/surface.js +124 -0
- package/dist/checks/types.js +2 -0
- package/dist/checks/warm.js +342 -0
- package/dist/checks/watch.js +228 -0
- package/dist/checks/wire.js +87 -0
- package/dist/cli.js +18 -1
- package/dist/hooks/adapters.js +3 -1
- package/dist/hooks/call.js +6 -1
- package/dist/hooks/modVersion.js +26 -0
- package/package.json +2 -2
|
@@ -0,0 +1,548 @@
|
|
|
1
|
+
// THE REGISTRATIONS OF THIS MACHINE. A registration is a bare NAME per organisation that says which board project it serves, which repo folder (and app folder) builds it, or which live
|
|
2
|
+
// site it checks. One JSON file each in registrations/<org>/<NAME>.json, sealed with an HMAC so a hand edit (an adoptEnv switched on, a repo moved outside the roots) is refused.
|
|
3
|
+
// Sign-ins, sealed secrets and kept builds live in stateDirFor(org, NAME) and belong to that one registration.
|
|
4
|
+
// -- DEP-MODULES -- //
|
|
5
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
6
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
8
|
+
// -- MODULES -- //
|
|
9
|
+
import { appendAudit } from './audit.js';
|
|
10
|
+
import { appDirText, normalizeAppDir, walkAppDir } from './appdir.js';
|
|
11
|
+
import { Refusal, consumeApproval, peekApproval, sameMac, seal } from './approve.js';
|
|
12
|
+
import { FORBIDDEN_PORTS, checkRepoFolder } from './guardrails.js';
|
|
13
|
+
import { withLockSync } from './lock.js';
|
|
14
|
+
import { ORG_SLUG, ensureDir, registrationsDir, stateDirFor } from './paths.js';
|
|
15
|
+
import { renameRetry, withRetry } from './fsretry.js';
|
|
16
|
+
import { resolveProjectConfig, RECIPE_FILE } from './recipe.js';
|
|
17
|
+
import { insideOrSame, rootHolding } from './roots.js';
|
|
18
|
+
import { surfaceHash } from './surface.js';
|
|
19
|
+
// -- CONSTANTS -- //
|
|
20
|
+
/** A registration name: what the board knows it by. */
|
|
21
|
+
export const REGISTRATION_NAME = /^[A-Za-z0-9_-]{1,32}$/;
|
|
22
|
+
export const PROJECT_KEY = /^[A-Z][A-Z0-9]{1,9}$/;
|
|
23
|
+
const MAX_EXTRA_HOSTS = 16;
|
|
24
|
+
const HOSTNAME = /^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)*[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
25
|
+
const LOCAL_HOSTS = new Set(['localhost', 'host.docker.internal', 'ip6-localhost']);
|
|
26
|
+
// -- FUNCTIONS: SMALL HELPERS -- //
|
|
27
|
+
const fold = (v) => v.toLowerCase();
|
|
28
|
+
const same = (a, b) => fold(a) === fold(b);
|
|
29
|
+
const isRecord = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
30
|
+
function realOrResolved(p) {
|
|
31
|
+
try {
|
|
32
|
+
return realpathSync.native(resolve(p));
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return resolve(p);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** One folder inside the other (or the same), by real path, so a link, a junction, a short name or a `..` cannot hide it. */
|
|
39
|
+
const nested = (a, b) => {
|
|
40
|
+
const [ra, rb] = [realOrResolved(a), realOrResolved(b)];
|
|
41
|
+
return insideOrSame(ra, rb) || insideOrSame(rb, ra);
|
|
42
|
+
};
|
|
43
|
+
// -- FUNCTIONS: APP FOLDER -- //
|
|
44
|
+
/** `web\`, `./web` and `web/` are all `web`. Throws a Refusal for anything that is not a plain relative folder of at most four plain names (the rules are appdir.ts's). */
|
|
45
|
+
export function normaliseAppDir(raw) {
|
|
46
|
+
const text = normalizeAppDir(raw);
|
|
47
|
+
const problem = appDirText(text);
|
|
48
|
+
if (problem)
|
|
49
|
+
throw new Refusal(problem);
|
|
50
|
+
return text;
|
|
51
|
+
}
|
|
52
|
+
/** One plain sentence when the app folder is not a real folder inside the repo with a package.json, walked without following links (appdir.ts), else null. */
|
|
53
|
+
export function checkAppDir(repo, appDir) {
|
|
54
|
+
const walked = walkAppDir(repo, appDir);
|
|
55
|
+
if (walked.problem !== undefined)
|
|
56
|
+
return walked.problem;
|
|
57
|
+
try {
|
|
58
|
+
const pkg = lstatSync(join(walked.dir, 'package.json'));
|
|
59
|
+
if (!pkg.isFile())
|
|
60
|
+
return `${appDir}/package.json is not a plain file.`;
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return `The app folder ${appDir} has no package.json.`;
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
// -- FUNCTIONS: LIVE SITES -- //
|
|
68
|
+
const isIpLiteral = (h) => h.includes(':') || h.startsWith('[') || /^[0-9.]+$/.test(h) || /^0x/i.test(h);
|
|
69
|
+
const isLocalName = (h) => LOCAL_HOSTS.has(h) || h.endsWith('.localhost');
|
|
70
|
+
/** Exact host names only: no IP literal, no loopback name, no wildcard, no port. Returns the list lower cased and without repeats. */
|
|
71
|
+
export function cleanExtraHosts(raw) {
|
|
72
|
+
const out = [];
|
|
73
|
+
for (const item of raw) {
|
|
74
|
+
const h = String(item).trim().toLowerCase();
|
|
75
|
+
if (!HOSTNAME.test(h) || isIpLiteral(h) || isLocalName(h))
|
|
76
|
+
throw new Refusal(`"${h.slice(0, 60)}" is not an exact host name (no IP address, no localhost, no wildcard, no port).`);
|
|
77
|
+
if (!out.includes(h))
|
|
78
|
+
out.push(h);
|
|
79
|
+
}
|
|
80
|
+
if (out.length > MAX_EXTRA_HOSTS)
|
|
81
|
+
throw new Refusal(`At most ${MAX_EXTRA_HOSTS} extra hosts are allowed.`);
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
/** The live site url, cleaned, or a Refusal. A local url may not use a port a check never uses. */
|
|
85
|
+
export function cleanSiteUrl(raw) {
|
|
86
|
+
let u;
|
|
87
|
+
try {
|
|
88
|
+
u = new URL(String(raw ?? '').trim());
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
throw new Refusal('The live site address is not a url.');
|
|
92
|
+
}
|
|
93
|
+
if (u.protocol !== 'https:' && u.protocol !== 'http:')
|
|
94
|
+
throw new Refusal('A live site address starts with http:// or https://.');
|
|
95
|
+
if (u.username || u.password)
|
|
96
|
+
throw new Refusal('A live site address carries no user name or password.');
|
|
97
|
+
const host = u.hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
|
98
|
+
const loopback = isLocalName(host) ||
|
|
99
|
+
host === '::1' ||
|
|
100
|
+
host === '0.0.0.0' ||
|
|
101
|
+
/^127\./.test(host) ||
|
|
102
|
+
host === '::';
|
|
103
|
+
const port = Number(u.port || (u.protocol === 'https:' ? 443 : 80));
|
|
104
|
+
if (loopback && FORBIDDEN_PORTS.has(port))
|
|
105
|
+
throw new Refusal(`The live site address uses port ${port}, which a check never uses.`);
|
|
106
|
+
u.hash = '';
|
|
107
|
+
return u.href;
|
|
108
|
+
}
|
|
109
|
+
const canon = (r) => ({
|
|
110
|
+
org: r.org,
|
|
111
|
+
name: r.name,
|
|
112
|
+
boardProject: r.boardProject,
|
|
113
|
+
...(r.repo !== undefined ? { repo: r.repo } : {}),
|
|
114
|
+
...(r.appDir !== undefined ? { appDir: r.appDir } : {}),
|
|
115
|
+
recipe: r.recipe,
|
|
116
|
+
database: r.database,
|
|
117
|
+
...(r.url !== undefined ? { url: r.url } : {}),
|
|
118
|
+
...(r.extraHosts !== undefined ? { extraHosts: r.extraHosts } : {}),
|
|
119
|
+
adoptEnv: r.adoptEnv,
|
|
120
|
+
uploadSignedInPictures: r.uploadSignedInPictures,
|
|
121
|
+
...(r.pin !== undefined ? { pin: r.pin } : {}),
|
|
122
|
+
createdAt: r.createdAt
|
|
123
|
+
});
|
|
124
|
+
const macOf = (r) => seal('registration', JSON.stringify(canon(r)));
|
|
125
|
+
const fileOf = (org, name) => join(registrationsDir(), org, `${name}.json`);
|
|
126
|
+
function parseRegistration(v, org) {
|
|
127
|
+
if (!isRecord(v))
|
|
128
|
+
return null;
|
|
129
|
+
const str = (k) => (typeof v[k] === 'string' ? v[k] : undefined);
|
|
130
|
+
const name = str('name');
|
|
131
|
+
const boardProject = str('boardProject');
|
|
132
|
+
if (v.org !== org || !name || !REGISTRATION_NAME.test(name))
|
|
133
|
+
return null;
|
|
134
|
+
if (!boardProject || !PROJECT_KEY.test(boardProject))
|
|
135
|
+
return null;
|
|
136
|
+
if (typeof v.recipe !== 'boolean' || typeof v.adoptEnv !== 'boolean')
|
|
137
|
+
return null;
|
|
138
|
+
if (typeof v.uploadSignedInPictures !== 'boolean')
|
|
139
|
+
return null;
|
|
140
|
+
if (v.database !== 'none' && v.database !== 'tool')
|
|
141
|
+
return null;
|
|
142
|
+
const createdAt = str('createdAt');
|
|
143
|
+
if (!createdAt)
|
|
144
|
+
return null;
|
|
145
|
+
if (v.extraHosts !== undefined &&
|
|
146
|
+
!(Array.isArray(v.extraHosts) && v.extraHosts.every((h) => typeof h === 'string')))
|
|
147
|
+
return null;
|
|
148
|
+
const reg = {
|
|
149
|
+
org,
|
|
150
|
+
name,
|
|
151
|
+
boardProject,
|
|
152
|
+
recipe: v.recipe,
|
|
153
|
+
database: v.database,
|
|
154
|
+
adoptEnv: v.adoptEnv,
|
|
155
|
+
uploadSignedInPictures: v.uploadSignedInPictures,
|
|
156
|
+
createdAt
|
|
157
|
+
};
|
|
158
|
+
const repo = str('repo');
|
|
159
|
+
const appDir = str('appDir');
|
|
160
|
+
const url = str('url');
|
|
161
|
+
const pin = str('pin');
|
|
162
|
+
if (repo !== undefined)
|
|
163
|
+
reg.repo = repo;
|
|
164
|
+
if (appDir !== undefined)
|
|
165
|
+
reg.appDir = appDir;
|
|
166
|
+
if (url !== undefined)
|
|
167
|
+
reg.url = url;
|
|
168
|
+
if (pin !== undefined)
|
|
169
|
+
reg.pin = pin;
|
|
170
|
+
if (Array.isArray(v.extraHosts))
|
|
171
|
+
reg.extraHosts = v.extraHosts;
|
|
172
|
+
return reg;
|
|
173
|
+
}
|
|
174
|
+
// -- FILE ACCESS WITH RETRY -- //
|
|
175
|
+
/** The file calls of this module. A test replaces `rename` to simulate the errors Windows gives while another process holds the file. */
|
|
176
|
+
export const registryIo = {
|
|
177
|
+
rename: (from, to) => renameSync(from, to)
|
|
178
|
+
};
|
|
179
|
+
/** The lock of one registration file, by a hash of org and name so the lock name is always a plain name. */
|
|
180
|
+
const lockOf = (org, name) => `registration-${createHash('sha256').update(`${org}/${name.toLowerCase()}`).digest('hex').slice(0, 32)}`;
|
|
181
|
+
/** Writes a registration file with its seal. No rule is checked here (registerProject does that); the tests and the importer use this too. */
|
|
182
|
+
export function saveRegistration(reg) {
|
|
183
|
+
const stored = { ...canon(reg), mac: macOf(reg) };
|
|
184
|
+
const file = fileOf(reg.org, reg.name);
|
|
185
|
+
const text = JSON.stringify(stored, null, 2) + '\n';
|
|
186
|
+
ensureDir(dirname(file));
|
|
187
|
+
// One writer at a time per file, and the rename tried again while another process still has the file open
|
|
188
|
+
withLockSync(lockOf(reg.org, reg.name), () => {
|
|
189
|
+
const temp = `${file}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
|
|
190
|
+
try {
|
|
191
|
+
writeFileSync(temp, text, { mode: 0o600 });
|
|
192
|
+
renameRetry(temp, file, registryIo.rename);
|
|
193
|
+
}
|
|
194
|
+
catch (e) {
|
|
195
|
+
rmSync(temp, { force: true });
|
|
196
|
+
throw e;
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
/** Every registration on this machine (or of one organisation), sorted, and one sentence for each file that could not be trusted. */
|
|
201
|
+
export function readRegistrations(org) {
|
|
202
|
+
const registrations = [];
|
|
203
|
+
const problems = [];
|
|
204
|
+
const root = registrationsDir();
|
|
205
|
+
if (!existsSync(root))
|
|
206
|
+
return { registrations, problems };
|
|
207
|
+
let orgs;
|
|
208
|
+
try {
|
|
209
|
+
orgs = readdirSync(root).filter((d) => ORG_SLUG.test(d) && (!org || d === org));
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
return { registrations, problems };
|
|
213
|
+
}
|
|
214
|
+
for (const o of orgs.sort()) {
|
|
215
|
+
let files;
|
|
216
|
+
try {
|
|
217
|
+
files = readdirSync(join(root, o)).filter((f) => f.endsWith('.json'));
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
for (const f of files.sort()) {
|
|
223
|
+
try {
|
|
224
|
+
const doc = JSON.parse(withRetry(() => readFileSync(join(root, o, f), 'utf8')));
|
|
225
|
+
const reg = parseRegistration(doc, o);
|
|
226
|
+
const mac = isRecord(doc) && typeof doc.mac === 'string' ? doc.mac : '';
|
|
227
|
+
if (!reg || `${reg.name}.json` !== f)
|
|
228
|
+
problems.push(`${o}/${f} is not a registration this tool wrote.`);
|
|
229
|
+
else if (!sameMac(macOf(reg), mac))
|
|
230
|
+
problems.push(`${o}/${reg.name} was changed outside "check project", so it is not used. Register it again.`);
|
|
231
|
+
else
|
|
232
|
+
registrations.push(reg);
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
problems.push(`${o}/${f} cannot be read.`);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return { registrations, problems };
|
|
240
|
+
}
|
|
241
|
+
export const listRegistrations = (org) => readRegistrations(org).registrations;
|
|
242
|
+
/** A registration by name, without regard to case. */
|
|
243
|
+
export function findRegistration(org, name) {
|
|
244
|
+
return listRegistrations(org).find((r) => same(r.name, name)) ?? null;
|
|
245
|
+
}
|
|
246
|
+
/** True when the registration kept something (sign-ins, sealed secrets, builds). A registration with state cannot be pointed at another folder. */
|
|
247
|
+
export function hasState(org, name) {
|
|
248
|
+
try {
|
|
249
|
+
return readdirSync(stateDirFor(org, name)).length > 0;
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
return false;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
// -- FUNCTIONS: UNIQUENESS RULES -- //
|
|
256
|
+
/** The registration of ANOTHER organisation that holds this folder (one organisation per folder), or null. */
|
|
257
|
+
export function repoHeldElsewhere(org, repo) {
|
|
258
|
+
for (const r of listRegistrations())
|
|
259
|
+
if (r.org !== org && r.repo && nested(r.repo, repo))
|
|
260
|
+
return `${r.org}/${r.name}`;
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* The registration of the same organisation that already holds this folder, or null. Two registrations never share a folder, however it is spelled (the same real path, a
|
|
265
|
+
* folder inside the other, a link, a junction). The one exception is one repo holding two apps in different app folders.
|
|
266
|
+
*/
|
|
267
|
+
export function repoHeldBySibling(org, name, repo, appDir) {
|
|
268
|
+
for (const r of listRegistrations(org)) {
|
|
269
|
+
if (same(r.name, name) || !r.repo || !nested(r.repo, repo))
|
|
270
|
+
continue;
|
|
271
|
+
const identical = same(realOrResolved(r.repo), realOrResolved(repo));
|
|
272
|
+
if (identical && !same(r.appDir ?? '', appDir ?? ''))
|
|
273
|
+
continue;
|
|
274
|
+
return `${r.org}/${r.name}`;
|
|
275
|
+
}
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
/** A registration of the organisation whose name differs from this one only by letter case, or null. */
|
|
279
|
+
export function nameClash(org, name) {
|
|
280
|
+
const other = listRegistrations(org).find((r) => same(r.name, name) && r.name !== name);
|
|
281
|
+
return other ? `${org}/${other.name}` : null;
|
|
282
|
+
}
|
|
283
|
+
/** A registration whose name is the board project another registration serves while it serves a different one (ambiguous for a person typing the name), either way round. */
|
|
284
|
+
export function nameShadowsBoard(org, name, boardProject) {
|
|
285
|
+
for (const r of listRegistrations(org)) {
|
|
286
|
+
if (same(r.name, name))
|
|
287
|
+
continue;
|
|
288
|
+
if (same(name, r.boardProject) && !same(boardProject, r.boardProject))
|
|
289
|
+
return `${org}/${r.name}`;
|
|
290
|
+
if (same(r.name, boardProject) && !same(r.boardProject, boardProject))
|
|
291
|
+
return `${org}/${r.name}`;
|
|
292
|
+
}
|
|
293
|
+
return null;
|
|
294
|
+
}
|
|
295
|
+
// -- FUNCTIONS: THE PROJECT CONFIG OF A REGISTRATION -- //
|
|
296
|
+
/**
|
|
297
|
+
* What a run needs to know about a registration. For a repo it is the recipe or plain detection of the app folder (the repo itself without one), checked against the guardrails
|
|
298
|
+
* and the roots again every time, with the git root as `repo`. For a live site nothing is built or started. Throws one plain sentence when it cannot be used.
|
|
299
|
+
*/
|
|
300
|
+
export function configFor(reg) {
|
|
301
|
+
const registration = { org: reg.org, name: reg.name };
|
|
302
|
+
if (reg.url) {
|
|
303
|
+
return {
|
|
304
|
+
repo: '',
|
|
305
|
+
start: '',
|
|
306
|
+
needsDatabase: false,
|
|
307
|
+
env: {},
|
|
308
|
+
appPaths: [],
|
|
309
|
+
url: reg.url,
|
|
310
|
+
extraHosts: reg.extraHosts ?? [],
|
|
311
|
+
registration
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
if (!reg.repo)
|
|
315
|
+
throw new Refusal(`The registration ${reg.name} has neither a repo folder nor a live site.`);
|
|
316
|
+
if (!rootHolding(reg.repo))
|
|
317
|
+
throw new Refusal(`The repo of ${reg.name} is not inside a folder the owner approved (check roots list). Ask the owner to approve its parent folder.`);
|
|
318
|
+
let appRoot = reg.repo;
|
|
319
|
+
if (reg.appDir) {
|
|
320
|
+
const dir = normaliseAppDir(reg.appDir);
|
|
321
|
+
const bad = checkAppDir(reg.repo, dir);
|
|
322
|
+
if (bad)
|
|
323
|
+
throw new Refusal(bad);
|
|
324
|
+
if (!existsSync(join(reg.repo, '.git')))
|
|
325
|
+
throw new Refusal(`${reg.repo} is not the root of a git project (no .git there).`);
|
|
326
|
+
appRoot = join(reg.repo, dir);
|
|
327
|
+
}
|
|
328
|
+
let cfg;
|
|
329
|
+
try {
|
|
330
|
+
cfg = resolveProjectConfig(appRoot);
|
|
331
|
+
}
|
|
332
|
+
catch (e) {
|
|
333
|
+
throw new Refusal(e instanceof Error ? e.message : String(e));
|
|
334
|
+
}
|
|
335
|
+
cfg.repo = reg.repo;
|
|
336
|
+
if (reg.appDir)
|
|
337
|
+
cfg.appDir = normaliseAppDir(reg.appDir);
|
|
338
|
+
cfg.registration = registration;
|
|
339
|
+
if (reg.adoptEnv)
|
|
340
|
+
cfg.adoptEnv = true;
|
|
341
|
+
if (reg.extraHosts?.length)
|
|
342
|
+
cfg.extraHosts = reg.extraHosts;
|
|
343
|
+
return cfg;
|
|
344
|
+
}
|
|
345
|
+
// -- FUNCTIONS: PIN -- //
|
|
346
|
+
const appRootOf = (reg) => reg.repo && reg.appDir ? join(reg.repo, reg.appDir) : (reg.repo ?? '');
|
|
347
|
+
/** The surface hash of a repo registration: its deciding files, plus the git root's when an app folder is used. A live site has none. */
|
|
348
|
+
export function surfaceOf(reg) {
|
|
349
|
+
if (!reg.repo)
|
|
350
|
+
return null;
|
|
351
|
+
return surfaceHash(appRootOf(reg), reg.appDir ? reg.repo : null);
|
|
352
|
+
}
|
|
353
|
+
/** Whether the files that decide what runs are still the ones that were pinned. */
|
|
354
|
+
export function pinState(reg) {
|
|
355
|
+
const s = surfaceOf(reg);
|
|
356
|
+
if (!s)
|
|
357
|
+
return 'ok';
|
|
358
|
+
if (!reg.pin)
|
|
359
|
+
return 'unpinned';
|
|
360
|
+
return reg.pin === s.hash ? 'ok' : 'changed';
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Pins the current surface. The commands are checked against the guardrails again first (configFor), so a changed script that is no longer a plain build command is refused and
|
|
364
|
+
* the old pin stays. The change is audited and returned for the caller to print.
|
|
365
|
+
*/
|
|
366
|
+
export function pinRegistration(org, name) {
|
|
367
|
+
const reg = findRegistration(org, name);
|
|
368
|
+
if (!reg)
|
|
369
|
+
throw new Refusal(`There is no registration ${name} in ${org}.`);
|
|
370
|
+
const s = surfaceOf(reg);
|
|
371
|
+
if (!s)
|
|
372
|
+
throw new Refusal(`${reg.name} is a live site; it has no build files to pin.`);
|
|
373
|
+
configFor(reg);
|
|
374
|
+
const before = reg.pin ?? null;
|
|
375
|
+
if (before !== s.hash) {
|
|
376
|
+
saveRegistration({ ...reg, pin: s.hash });
|
|
377
|
+
appendAudit('surface pinned', {
|
|
378
|
+
registration: `${org}/${reg.name}`,
|
|
379
|
+
from: before ? before.slice(0, 12) : 'none',
|
|
380
|
+
to: s.hash.slice(0, 12),
|
|
381
|
+
files: s.files.length
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
return { name: reg.name, before, after: s.hash, changed: before !== s.hash, files: s.files };
|
|
385
|
+
}
|
|
386
|
+
// -- FUNCTIONS: REGISTER AND FORGET -- //
|
|
387
|
+
/**
|
|
388
|
+
* Registers (or updates) a registration. Every rule is checked before anything is written or any approval code is used. A repo registration needs a repo inside an owner-approved
|
|
389
|
+
* root; a live site, a whole-.env adoption and the upload of signed-in pictures each need the owner's single-use code.
|
|
390
|
+
*/
|
|
391
|
+
export function registerProject(input, approvals = {}) {
|
|
392
|
+
const org = String(input.org ?? '');
|
|
393
|
+
const name = String(input.name ?? '');
|
|
394
|
+
if (!ORG_SLUG.test(org))
|
|
395
|
+
throw new Refusal('The organisation is not a slug.');
|
|
396
|
+
if (!REGISTRATION_NAME.test(name))
|
|
397
|
+
throw new Refusal(`"${name.slice(0, 40)}" is not a registration name (1 to 32 letters, digits, dash or underscore).`);
|
|
398
|
+
const boardProject = input.boardProject ?? name;
|
|
399
|
+
if (!PROJECT_KEY.test(boardProject))
|
|
400
|
+
throw new Refusal(`The board project "${boardProject.slice(0, 20)}" must be 2 to 10 capital letters or digits, starting with a letter. Give --board-project.`);
|
|
401
|
+
const hasRepo = input.repo !== undefined && input.repo !== '';
|
|
402
|
+
const hasUrl = input.url !== undefined && input.url !== '';
|
|
403
|
+
if (hasRepo === hasUrl)
|
|
404
|
+
throw new Refusal('Give either a repo folder (--repo) or a live site (--url), not both and not neither.');
|
|
405
|
+
const clash = nameClash(org, name);
|
|
406
|
+
if (clash)
|
|
407
|
+
throw new Refusal(`${clash} already exists; names are unique without regard to letter case.`);
|
|
408
|
+
const shadow = nameShadowsBoard(org, name, boardProject);
|
|
409
|
+
if (shadow)
|
|
410
|
+
throw new Refusal(`The name ${name} and ${shadow} would shadow each other's board project. Pick another name.`);
|
|
411
|
+
const existing = findRegistration(org, name);
|
|
412
|
+
const reg = {
|
|
413
|
+
org,
|
|
414
|
+
name,
|
|
415
|
+
boardProject,
|
|
416
|
+
recipe: input.recipe === true,
|
|
417
|
+
database: 'none',
|
|
418
|
+
adoptEnv: input.adoptEnv === true,
|
|
419
|
+
uploadSignedInPictures: input.uploadSignedInPictures === true,
|
|
420
|
+
createdAt: existing?.createdAt ?? new Date().toISOString()
|
|
421
|
+
};
|
|
422
|
+
if (input.extraHosts?.length)
|
|
423
|
+
reg.extraHosts = cleanExtraHosts(input.extraHosts);
|
|
424
|
+
// What an existing registration already guards (an adopted .env, saved sign-ins, sealed secrets, kept builds) is not re-pointed without the owner
|
|
425
|
+
const hostsOf = (r) => [...(r?.extraHosts ?? [])].sort().join(',');
|
|
426
|
+
const guarded = existing !== null && (existing.adoptEnv || hasState(org, existing.name));
|
|
427
|
+
const hostsChanged = existing !== null && hostsOf(existing) !== hostsOf(reg);
|
|
428
|
+
let needRegister;
|
|
429
|
+
/** True when the registration now points at another repo, app folder or site than the one it had. */
|
|
430
|
+
let repointed;
|
|
431
|
+
if (hasUrl) {
|
|
432
|
+
if (input.appDir)
|
|
433
|
+
throw new Refusal('A live site has no app folder.');
|
|
434
|
+
if (input.adoptEnv)
|
|
435
|
+
throw new Refusal('A live site has no .env to adopt.');
|
|
436
|
+
reg.url = cleanSiteUrl(input.url);
|
|
437
|
+
repointed = existing?.url !== reg.url;
|
|
438
|
+
// A live site is approved for its host: the code names NAME@host, so a code for one site cannot register another
|
|
439
|
+
needRegister = repointed || (guarded && hostsChanged);
|
|
440
|
+
if (existing?.pin)
|
|
441
|
+
reg.pin = existing.pin;
|
|
442
|
+
}
|
|
443
|
+
else {
|
|
444
|
+
const given = String(input.repo);
|
|
445
|
+
const appDir = input.appDir ? normaliseAppDir(input.appDir) : undefined;
|
|
446
|
+
const folderProblem = appDir
|
|
447
|
+
? checkRepoFolder(join(resolve(given), appDir))
|
|
448
|
+
: checkRepoFolder(given);
|
|
449
|
+
if (folderProblem)
|
|
450
|
+
throw new Refusal(folderProblem);
|
|
451
|
+
const repo = realpathSync.native(resolve(given));
|
|
452
|
+
if (appDir) {
|
|
453
|
+
const bad = checkAppDir(repo, appDir);
|
|
454
|
+
if (bad)
|
|
455
|
+
throw new Refusal(bad);
|
|
456
|
+
if (!existsSync(join(repo, '.git')))
|
|
457
|
+
throw new Refusal(`${repo} is not the root of a git project (no .git there). Give the git root as --repo and the app folder as --app-dir.`);
|
|
458
|
+
}
|
|
459
|
+
if (!rootHolding(repo))
|
|
460
|
+
throw new Refusal(`${repo} is not inside a folder the owner approved. The owner approves its parent folder: check approve --action roots --target <folder>, then check roots add <folder> --approval <code>.`);
|
|
461
|
+
const elsewhere = repoHeldElsewhere(org, repo);
|
|
462
|
+
if (elsewhere)
|
|
463
|
+
throw new Refusal(`That folder is already held by ${elsewhere}, in another organisation. A folder belongs to one organisation.`);
|
|
464
|
+
const sibling = repoHeldBySibling(org, name, repo, appDir);
|
|
465
|
+
if (sibling)
|
|
466
|
+
throw new Refusal(`That folder is already held by ${sibling}. The same repo can be registered twice only with a different --app-dir.`);
|
|
467
|
+
if (input.recipe && !existsSync(join(appDir ? join(repo, appDir) : repo, RECIPE_FILE)))
|
|
468
|
+
throw new Refusal(`--recipe was given but there is no ${RECIPE_FILE} in the ${appDir ? 'app folder' : 'repo'}.`);
|
|
469
|
+
const moved = existing?.repo !== undefined &&
|
|
470
|
+
(!same(realOrResolved(existing.repo), repo) || !same(existing.appDir ?? '', appDir ?? ''));
|
|
471
|
+
if ((moved || (existing && !existing.repo)) && hasState(org, existing?.name ?? name))
|
|
472
|
+
throw new Refusal(`${name} holds saved state (sign-ins, sealed secrets or kept builds) that belongs to its old folder. Run "check project forget ${name} --org ${org}" first, then register it again.`);
|
|
473
|
+
repointed = moved || (existing !== null && !existing.repo);
|
|
474
|
+
needRegister = guarded && (repointed || hostsChanged);
|
|
475
|
+
reg.repo = repo;
|
|
476
|
+
if (appDir)
|
|
477
|
+
reg.appDir = appDir;
|
|
478
|
+
if (existing && !moved && existing.pin)
|
|
479
|
+
reg.pin = existing.pin;
|
|
480
|
+
reg.database = configFor({ ...reg, adoptEnv: false }).needsDatabase ? 'tool' : 'none';
|
|
481
|
+
}
|
|
482
|
+
// Owner approvals: every rule is checked and every code is looked at (not used) first; the codes are used last, so a refusal never burns one.
|
|
483
|
+
// Adopting the whole .env is per folder: an adoption that was approved for another repo, app folder or site does not carry over to the new one
|
|
484
|
+
const needAdopt = reg.adoptEnv && !(existing?.adoptEnv && !repointed);
|
|
485
|
+
const needPictures = reg.uploadSignedInPictures && !existing?.uploadSignedInPictures;
|
|
486
|
+
const registerTarget = reg.url ? `${name}@${new URL(reg.url).hostname}` : name;
|
|
487
|
+
if (needRegister && !approvals.register)
|
|
488
|
+
throw new Refusal(reg.url
|
|
489
|
+
? `Registering a live site, or changing the site or its hosts, needs a code from the owner: check approve --action register --target ${registerTarget}, on an interactive terminal, then --approval <code>.`
|
|
490
|
+
: `Changing the folder or hosts of ${name}, which keeps an adopted .env or saved state, needs a code from the owner: check approve --action register --target ${registerTarget}, on an interactive terminal, then --approval <code>.`);
|
|
491
|
+
if (needAdopt && !approvals.adoptEnv)
|
|
492
|
+
throw new Refusal(`Adopting the whole repo .env needs a code from the owner: check approve --action adopt-env --target ${name}, then --adopt-env-approval <code>.`);
|
|
493
|
+
if (needPictures && !approvals.pictures)
|
|
494
|
+
throw new Refusal(`Uploading pictures of a signed-in run needs a code from the owner: check approve --action pictures --target ${name}, then --pictures-approval <code>.`);
|
|
495
|
+
if (needRegister && !peekApproval('register', registerTarget, approvals.register ?? ''))
|
|
496
|
+
throw new Refusal('That approval code is wrong, used or expired. Nothing was changed.');
|
|
497
|
+
if (needAdopt && !peekApproval('adopt-env', name, approvals.adoptEnv ?? ''))
|
|
498
|
+
throw new Refusal('The adopt-env approval code is wrong, used or expired. Nothing was changed.');
|
|
499
|
+
if (needPictures && !peekApproval('pictures', name, approvals.pictures ?? ''))
|
|
500
|
+
throw new Refusal('The pictures approval code is wrong, used or expired. Nothing was changed.');
|
|
501
|
+
// Used last. A code that fails here was used or expired in the instant after the look: the others are already spent, and the sentence says so
|
|
502
|
+
const spent = [];
|
|
503
|
+
const use = (action, target, code, label) => {
|
|
504
|
+
if (!consumeApproval(action, target, code))
|
|
505
|
+
throw new Refusal(`The ${label} approval code was used or expired just now.${spent.length ? ` The code(s) already used for ${spent.join(' and ')} are spent; ask the owner for new ones.` : ''} Nothing was changed.`);
|
|
506
|
+
spent.push(label);
|
|
507
|
+
};
|
|
508
|
+
if (needRegister)
|
|
509
|
+
use('register', registerTarget, approvals.register ?? '', 'register');
|
|
510
|
+
if (needAdopt)
|
|
511
|
+
use('adopt-env', name, approvals.adoptEnv ?? '', 'adopt-env');
|
|
512
|
+
if (needPictures)
|
|
513
|
+
use('pictures', name, approvals.pictures ?? '', 'pictures');
|
|
514
|
+
ensureDir(join(registrationsDir(), org));
|
|
515
|
+
saveRegistration(reg);
|
|
516
|
+
appendAudit(existing ? 'registration updated' : 'registration added', {
|
|
517
|
+
registration: `${org}/${name}`,
|
|
518
|
+
boardProject,
|
|
519
|
+
...(reg.repo
|
|
520
|
+
? { folder: basename(reg.repo) }
|
|
521
|
+
: { site: new URL(reg.url ?? 'http://x').hostname }),
|
|
522
|
+
...(reg.appDir ? { appDir: reg.appDir } : {}),
|
|
523
|
+
adoptEnv: reg.adoptEnv,
|
|
524
|
+
pictures: reg.uploadSignedInPictures
|
|
525
|
+
});
|
|
526
|
+
return reg;
|
|
527
|
+
}
|
|
528
|
+
/** Lets go of what a registration kept (sign-ins, sealed secrets, kept builds). With `remove` the registration itself goes too. */
|
|
529
|
+
export function forgetRegistration(org, name, opts = {}) {
|
|
530
|
+
const reg = findRegistration(org, name);
|
|
531
|
+
if (!reg)
|
|
532
|
+
throw new Refusal(`There is no registration ${name} in ${org}.`);
|
|
533
|
+
const hadState = hasState(org, reg.name);
|
|
534
|
+
rmSync(stateDirFor(org, reg.name), { recursive: true, force: true });
|
|
535
|
+
if (opts.remove === true)
|
|
536
|
+
rmSync(fileOf(org, reg.name), { force: true });
|
|
537
|
+
appendAudit(opts.remove ? 'registration removed' : 'registration state forgotten', {
|
|
538
|
+
registration: `${org}/${reg.name}`,
|
|
539
|
+
hadState
|
|
540
|
+
});
|
|
541
|
+
return { name: reg.name, hadState, removed: opts.remove === true };
|
|
542
|
+
}
|
|
543
|
+
/** Makes the state folder of a registration (for the modules that keep sealed data there). */
|
|
544
|
+
export function ensureStateDir(org, name) {
|
|
545
|
+
const dir = stateDirFor(org, name);
|
|
546
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
547
|
+
return dir;
|
|
548
|
+
}
|