@wtfalch/foundry 0.61.0 → 0.63.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/dist/bootstrap/declare.js +4 -1
- package/dist/bootstrap/versions.d.ts +2 -0
- package/dist/index.d.ts +9 -1
- package/dist/index.js +3 -0
- package/dist/migrations/0065_native_build_runs.sql +32 -0
- package/dist/migrations/0066_org_kosmos.sql +10 -0
- package/dist/migrations/0067_requested_changes_kosmos.sql +6 -0
- package/dist/migrations/0068_platform_registry.sql +17 -0
- package/dist/modules/sign-in.js +1 -1
- package/dist/native-build.d.ts +50 -0
- package/dist/native-build.js +72 -0
- package/dist/org.d.ts +22 -3
- package/dist/org.js +25 -2
- package/dist/plan/agora.d.ts +1 -1
- package/dist/plan/agora.js +4 -0
- package/dist/plan/archon-blue-green.d.ts +5 -0
- package/dist/plan/archon-blue-green.js +2 -0
- package/dist/plan/blue-green-apply.d.ts +5 -0
- package/dist/plan/blue-green-apply.js +197 -22
- package/dist/plan/blue-green.d.ts +80 -9
- package/dist/plan/blue-green.js +115 -14
- package/dist/plan/boule.d.ts +1 -1
- package/dist/plan/boule.js +2 -0
- package/dist/plan/broker-entries.d.ts +9 -0
- package/dist/plan/broker-entries.js +29 -0
- package/dist/plan/deploy-health.js +9 -2
- package/dist/plan/desktop-callback.js +4 -1
- package/dist/plan/diff.d.ts +5 -0
- package/dist/plan/diff.js +184 -63
- package/dist/plan/edge-access.d.ts +2 -3
- package/dist/plan/edge-access.js +8 -0
- package/dist/plan/executor.d.ts +111 -19
- package/dist/plan/executor.js +578 -72
- package/dist/plan/index.d.ts +2 -0
- package/dist/plan/index.js +1 -0
- package/dist/plan/jobs-scheduler-http.d.ts +2 -0
- package/dist/plan/jobs-scheduler-http.js +11 -0
- package/dist/plan/kosmos.d.ts +56 -0
- package/dist/plan/kosmos.js +84 -0
- package/dist/plan/locked-hostnames.js +5 -2
- package/dist/plan/org-names.d.ts +3 -3
- package/dist/plan/org-names.js +3 -1
- package/dist/plan/org-postgres-migrate.js +1 -0
- package/dist/plan/own-limits.d.ts +12 -0
- package/dist/plan/own-limits.js +2 -0
- package/dist/plan/plan-apply.d.ts +51 -1
- package/dist/plan/plan-apply.js +298 -25
- package/dist/plan/platform-diff.d.ts +6 -0
- package/dist/plan/platform-diff.js +74 -0
- package/dist/plan/platform-org.d.ts +2 -0
- package/dist/plan/platform-providers.d.ts +46 -1
- package/dist/plan/platform-providers.js +162 -1
- package/dist/plan/platform-registry-keys.d.ts +58 -0
- package/dist/plan/platform-registry-keys.js +69 -0
- package/dist/plan/platform-registry.d.ts +67 -0
- package/dist/plan/platform-registry.js +298 -0
- package/dist/plan/post-logout-uris.d.ts +1 -1
- package/dist/plan/post-logout-uris.js +5 -2
- package/dist/plan/providers-readers.js +20 -1
- package/dist/plan/release-host.d.ts +31 -2
- package/dist/plan/release-host.js +114 -6
- package/dist/plan/stage-copy-host.d.ts +104 -0
- package/dist/plan/stage-copy-host.js +259 -0
- package/dist/plan/stage-copy.js +44 -0
- package/dist/plan/stage-env.d.ts +9 -0
- package/dist/plan/stage-env.js +38 -0
- package/dist/plan/stage-guards.d.ts +57 -1
- package/dist/plan/stage-guards.js +106 -0
- package/dist/plan/stage-isolation.js +56 -0
- package/dist/plan/stage-services.d.ts +93 -0
- package/dist/plan/stage-services.js +153 -0
- package/dist/plan/stage-signin.d.ts +152 -0
- package/dist/plan/stage-signin.js +133 -0
- package/dist/plan/stage-sink.d.ts +2 -0
- package/dist/plan/stage-sink.js +6 -1
- package/dist/platform.d.ts +7 -1
- package/dist/platform.js +1 -0
- package/dist/release/decide.d.ts +12 -0
- package/dist/release/decide.js +37 -2
- package/dist/release/migration-compatibility.d.ts +66 -0
- package/dist/release/migration-compatibility.js +340 -0
- package/dist/release/run.d.ts +71 -1
- package/dist/release/run.js +92 -8
- package/dist/release/state.d.ts +14 -1
- package/dist/release/state.js +43 -0
- package/dist/release/types.d.ts +20 -0
- package/dist/release/types.js +2 -0
- package/dist/requested-changes.d.ts +11 -2
- package/dist/requested-changes.js +13 -1
- package/dist/slug.d.ts +1 -1
- package/dist/slug.js +11 -2
- package/dist/store/postgres.js +21 -0
- package/dist/store/schema.d.ts +87 -0
- package/dist/store/schema.js +10 -0
- package/package.json +3 -2
- package/versions.digests.json +1 -0
- package/versions.json +1 -0
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { bgKey } from './blue-green.js';
|
|
3
3
|
import { envClass } from './stage-isolation.js';
|
|
4
|
+
import { stageServicesReady } from './stage-services.js';
|
|
5
|
+
import { stageSignInReady } from './stage-signin.js';
|
|
4
6
|
/**
|
|
5
7
|
* The guards that must hold before stage gets an exact copy of production (campaign T3 section 5, G2 and G3).
|
|
6
8
|
* Each is checked here and recorded under a `bg.<id>.…` scope key only after it was verified live. Nothing in
|
|
@@ -77,6 +79,11 @@ export function buildStageProbes(input) {
|
|
|
77
79
|
run: call ? () => call({ url: jobsUrl, syncKey, projectId }) : null,
|
|
78
80
|
});
|
|
79
81
|
}
|
|
82
|
+
else if (jobsUrl && syncKey) {
|
|
83
|
+
// Stage holds a jobs key but production's project is unknown: foundry never writes `JOBS_PROJECT_ID` into
|
|
84
|
+
// an app's env, so the probe cannot be built. It is kept as not run, never passed.
|
|
85
|
+
probes.push({ what: 'jobs:prod-project-unknown', run: null });
|
|
86
|
+
}
|
|
80
87
|
const customersOrgId = input.liveCustomersOrgId;
|
|
81
88
|
const workspacesUrl = stage.get('WORKSPACES_URL');
|
|
82
89
|
const workspacesKey = stage.get('WORKSPACES_API_KEY');
|
|
@@ -166,8 +173,107 @@ export function stageGuardsReady(recorded, id) {
|
|
|
166
173
|
missing.push('stagegate');
|
|
167
174
|
if (!recorded[bgKey.stageProbes(id)]?.startsWith('ok:'))
|
|
168
175
|
missing.push('stageprobes');
|
|
176
|
+
if (!stageSignInReady(recorded[bgKey.stageSignIn(id)]))
|
|
177
|
+
missing.push('stagesignin');
|
|
178
|
+
if (!stageServicesReady(recorded[bgKey.stageServices(id)]))
|
|
179
|
+
missing.push('stageservices');
|
|
169
180
|
// An older ok: record outlives a later block; the stage state says what the last apply found.
|
|
170
181
|
if (recorded[bgKey.stageState(id)] !== 'ok')
|
|
171
182
|
missing.push('stagestate');
|
|
172
183
|
return { ready: missing.length === 0, missing };
|
|
173
184
|
}
|
|
185
|
+
/** Milliseconds the host may take to name the people holding `releases:preview`. */
|
|
186
|
+
export const STAGE_MEMBERS_TIMEOUT_MS = 10_000;
|
|
187
|
+
const EMAIL = /^[^\s@,;<>()"]+@[^\s@,;<>()"]+\.[^\s@,;<>()"]+$/;
|
|
188
|
+
/** Trimmed, lower-case, unique and sorted; anything that is not an email address is dropped. */
|
|
189
|
+
export function normaliseEmails(list) {
|
|
190
|
+
const out = new Set();
|
|
191
|
+
for (const item of list) {
|
|
192
|
+
if (typeof item !== 'string')
|
|
193
|
+
continue;
|
|
194
|
+
const email = item.trim().toLowerCase();
|
|
195
|
+
if (email.length <= 254 && EMAIL.test(email))
|
|
196
|
+
out.add(email);
|
|
197
|
+
}
|
|
198
|
+
return [...out].sort();
|
|
199
|
+
}
|
|
200
|
+
/** The hash recorded for a sorted list. */
|
|
201
|
+
export const membersHash = (sorted) => sha256(sorted.join('\n'));
|
|
202
|
+
const errorName = (error) => (error instanceof Error ? error.name : 'unknown');
|
|
203
|
+
/**
|
|
204
|
+
* The stage gate's list: `owners` always, plus the people `read` names. `members` is false when the read failed,
|
|
205
|
+
* timed out or the host has no port; the list is then the owners alone. One fixed log line names the subject and
|
|
206
|
+
* the error name, never an address.
|
|
207
|
+
*/
|
|
208
|
+
export async function stageGateEmails(input) {
|
|
209
|
+
const owners = normaliseEmails(input.owners);
|
|
210
|
+
if (!input.read) {
|
|
211
|
+
console.error(`${input.label}: stage gate members of ${input.subjectId} not read (NoMembersPort); the gate stays as it is`);
|
|
212
|
+
return { emails: owners, members: false };
|
|
213
|
+
}
|
|
214
|
+
let timer;
|
|
215
|
+
try {
|
|
216
|
+
const timeout = new Promise((_, reject) => {
|
|
217
|
+
timer = setTimeout(() => {
|
|
218
|
+
const error = new Error('timed out');
|
|
219
|
+
error.name = 'MembersTimeout';
|
|
220
|
+
reject(error);
|
|
221
|
+
}, input.timeoutMs ?? STAGE_MEMBERS_TIMEOUT_MS);
|
|
222
|
+
});
|
|
223
|
+
const answer = await Promise.race([input.read({ orgId: input.orgId }), timeout]);
|
|
224
|
+
if (!answer || !Array.isArray(answer.emails)) {
|
|
225
|
+
const error = new Error('bad answer');
|
|
226
|
+
error.name = 'MembersInvalid';
|
|
227
|
+
throw error;
|
|
228
|
+
}
|
|
229
|
+
return { emails: normaliseEmails([...owners, ...answer.emails]), members: true };
|
|
230
|
+
}
|
|
231
|
+
catch (error) {
|
|
232
|
+
console.error(`${input.label}: stage gate members of ${input.subjectId} not read (${errorName(error)}); the gate stays as it is`);
|
|
233
|
+
return { emails: owners, members: false };
|
|
234
|
+
}
|
|
235
|
+
finally {
|
|
236
|
+
if (timer)
|
|
237
|
+
clearTimeout(timer);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* One release tick for one subject whose stage gate is recorded: the gate's list becomes owners plus the people
|
|
242
|
+
* holding `releases:preview`. Nothing is done while the read fails, the list is unchanged since the last write
|
|
243
|
+
* (`bg.<id>.stagegatemembers`) or the Access call fails; the hash is recorded only after the Access call
|
|
244
|
+
* succeeded. Never throws.
|
|
245
|
+
*/
|
|
246
|
+
export async function syncStageGateMembers(input) {
|
|
247
|
+
const gateId = input.recorded[bgKey.stageGate(input.subjectId)];
|
|
248
|
+
if (!gateId)
|
|
249
|
+
return;
|
|
250
|
+
const { emails, members } = await stageGateEmails(input);
|
|
251
|
+
if (!members)
|
|
252
|
+
return;
|
|
253
|
+
if (emails.length === 0) {
|
|
254
|
+
console.error(`${input.label}: stage gate members of ${input.subjectId} not written (NoEmails)`);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
const hash = membersHash(emails);
|
|
258
|
+
if (input.recorded[bgKey.stageGateMembers(input.subjectId)] === hash)
|
|
259
|
+
return;
|
|
260
|
+
try {
|
|
261
|
+
if (!input.setPeople) {
|
|
262
|
+
const error = new Error('no setPeople');
|
|
263
|
+
error.name = 'NoGatePeoplePort';
|
|
264
|
+
throw error;
|
|
265
|
+
}
|
|
266
|
+
await input.setPeople(gateId, emails);
|
|
267
|
+
}
|
|
268
|
+
catch (error) {
|
|
269
|
+
console.error(`${input.label}: updating the stage gate of ${input.subjectId} failed (${errorName(error)}); the gate stays as it is`);
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
input.record(bgKey.stageGateMembers(input.subjectId), hash);
|
|
273
|
+
try {
|
|
274
|
+
await input.persist();
|
|
275
|
+
}
|
|
276
|
+
catch (error) {
|
|
277
|
+
console.error(`${input.label}: recording the stage gate members of ${input.subjectId} failed (${errorName(error)})`);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
@@ -173,6 +173,57 @@ const LIVE_MODE_KEY = /(^|[^a-z0-9])(sk|rk)_live_|^live_/;
|
|
|
173
173
|
const sha256 = (value) => createHash('sha256').update(value).digest('hex');
|
|
174
174
|
const URL_HOST = /^[a-z][a-z0-9+.-]*:\/\/(?:[^@/?#]*@)?([^/?#:]+)/i;
|
|
175
175
|
const hostOf = (value) => URL_HOST.exec(value)?.[1]?.toLowerCase() ?? null;
|
|
176
|
+
/** Every string inside a parsed JSON value. */
|
|
177
|
+
function* strings(value) {
|
|
178
|
+
if (typeof value === 'string')
|
|
179
|
+
yield value;
|
|
180
|
+
else if (Array.isArray(value))
|
|
181
|
+
for (const v of value)
|
|
182
|
+
yield* strings(v);
|
|
183
|
+
else if (value && typeof value === 'object')
|
|
184
|
+
for (const v of Object.values(value))
|
|
185
|
+
yield* strings(v);
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* What a stage `AGORA_CONFIG` must not hold: production's client id, or production Agora's own host (its
|
|
189
|
+
* `AGORA_CANONICAL_HOST`, or an internal host). The issuer is shared on purpose and is not a production host
|
|
190
|
+
* unless it is one of those. Codes only; the value is parsed here and never returned.
|
|
191
|
+
*/
|
|
192
|
+
function agoraConfigViolations(value, prodByName, prodHosts) {
|
|
193
|
+
let stage;
|
|
194
|
+
try {
|
|
195
|
+
stage = JSON.parse(value);
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
return ['config_unreadable'];
|
|
199
|
+
}
|
|
200
|
+
const out = [];
|
|
201
|
+
let prodClientId;
|
|
202
|
+
try {
|
|
203
|
+
const prodValue = prodByName.get('AGORA_CONFIG')?.value;
|
|
204
|
+
prodClientId = prodValue
|
|
205
|
+
? JSON.parse(prodValue).clientId
|
|
206
|
+
: undefined;
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
prodClientId = undefined;
|
|
210
|
+
}
|
|
211
|
+
const clientId = stage?.clientId;
|
|
212
|
+
if (typeof clientId === 'string' && clientId !== '' && clientId === prodClientId) {
|
|
213
|
+
out.push('value_shared');
|
|
214
|
+
}
|
|
215
|
+
const canonical = prodByName.get('AGORA_CANONICAL_HOST')?.value?.toLowerCase();
|
|
216
|
+
const hosts = new Set([...prodHosts, ...(canonical ? [canonical] : [])]);
|
|
217
|
+
for (const text of strings(stage)) {
|
|
218
|
+
const lower = text.toLowerCase();
|
|
219
|
+
const host = hostOf(text);
|
|
220
|
+
if (hosts.has(lower) || (host && hosts.has(host))) {
|
|
221
|
+
out.push('host_is_prod_internal');
|
|
222
|
+
break;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return out;
|
|
226
|
+
}
|
|
176
227
|
/**
|
|
177
228
|
* The violations of stage isolation, as `<code>:<name>`; empty when stage is isolated. Names only: an env
|
|
178
229
|
* name or a resource kind, never a value.
|
|
@@ -239,6 +290,11 @@ export function assertStageIsolated(input) {
|
|
|
239
290
|
out.add(`live_mcp_resource:${name}`);
|
|
240
291
|
}
|
|
241
292
|
}
|
|
293
|
+
if (kind === 'agora' && name === 'AGORA_CONFIG' && item.value !== undefined) {
|
|
294
|
+
for (const code of agoraConfigViolations(item.value, prodByName, prodHosts)) {
|
|
295
|
+
out.add(`${code}:${name}`);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
242
298
|
if (item.class === 'literal')
|
|
243
299
|
continue;
|
|
244
300
|
const shared = isSharedWithProd(name) && !(input.signIn?.customers && name === 'AUTH_ORGANIZATION_ID');
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { type StageKind } from './stage-isolation.js';
|
|
2
|
+
/**
|
|
3
|
+
* Stage's own files organisation, ai organisation and jobs project (campaign T3 section 5, G6): each service
|
|
4
|
+
* production's app uses gets an organisation or project of its own, named `<production name>-stage`, with keys
|
|
5
|
+
* minted for it only. A stage key is worthless on production, and production's key never reaches stage. Nothing
|
|
6
|
+
* here returns, logs or throws a secret: errors carry fixed text, ids and refs (CLAUDE.md, Platform steps).
|
|
7
|
+
*/
|
|
8
|
+
export type StageService = 'files' | 'ai' | 'jobs';
|
|
9
|
+
export declare const STAGE_SERVICES: readonly StageService[];
|
|
10
|
+
/** One credential a service shows once: the env name stage holds it under, and its value. */
|
|
11
|
+
export interface StageSecret {
|
|
12
|
+
env: string;
|
|
13
|
+
value: string;
|
|
14
|
+
}
|
|
15
|
+
/** How one service is made for stage. The executor builds this from the platform's own files, ai and jobs ports. */
|
|
16
|
+
export interface StageServicePort {
|
|
17
|
+
/** Whether the host can reach the service now and has what it needs. `false`: recorded absent, nothing minted. */
|
|
18
|
+
available(): Promise<boolean>;
|
|
19
|
+
/**
|
|
20
|
+
* Finds or makes stage's organisation (files, ai) or project (jobs), by its `<production name>-stage` name.
|
|
21
|
+
* Jobs shows its credentials with the first create; they come back here, once.
|
|
22
|
+
*/
|
|
23
|
+
create(): Promise<{
|
|
24
|
+
id: string;
|
|
25
|
+
name?: string;
|
|
26
|
+
keyIds?: string[];
|
|
27
|
+
secrets?: StageSecret[];
|
|
28
|
+
}>;
|
|
29
|
+
/** Files and ai: mints stage's key in the organisation. The secret comes back once. */
|
|
30
|
+
mintKey?(id: string): Promise<{
|
|
31
|
+
keyIds: string[];
|
|
32
|
+
secrets: StageSecret[];
|
|
33
|
+
}>;
|
|
34
|
+
/** The env name that carries the organisation id, when the app reads one. */
|
|
35
|
+
idEnv?: string;
|
|
36
|
+
/** The held store the keys live in. A ref is an id, not a value. */
|
|
37
|
+
held: {
|
|
38
|
+
refFor(envName: string): string;
|
|
39
|
+
resolve(ref: string): Promise<string | null>;
|
|
40
|
+
put(ref: string, value: string): Promise<void>;
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
export interface StageServicesPort {
|
|
44
|
+
files?: StageServicePort;
|
|
45
|
+
ai?: StageServicePort;
|
|
46
|
+
jobs?: StageServicePort;
|
|
47
|
+
/** The id of production's jobs project, so stage's jobs key can be tried against it. */
|
|
48
|
+
prodJobsProjectId?: string;
|
|
49
|
+
}
|
|
50
|
+
/** Fixed text and ids only: safe to log, record and return. */
|
|
51
|
+
export declare class StageServiceError extends Error {
|
|
52
|
+
readonly name = "StageServiceError";
|
|
53
|
+
}
|
|
54
|
+
/** The fixed reasons a used service is recorded absent. */
|
|
55
|
+
export declare const STAGE_SERVICE_ABSENT: {
|
|
56
|
+
readonly noPort: "no-port";
|
|
57
|
+
readonly notForKind: "not-for-kind";
|
|
58
|
+
};
|
|
59
|
+
export interface StageServicesResult {
|
|
60
|
+
/** The env stage gets: urls and ids, and the keys. */
|
|
61
|
+
env: Array<{
|
|
62
|
+
key: string;
|
|
63
|
+
value: string;
|
|
64
|
+
}>;
|
|
65
|
+
/** `bg.<id>.stageservices`. */
|
|
66
|
+
record: string;
|
|
67
|
+
/** Per service: `ok:<hash>` or `absent:<service>:<reason>`. */
|
|
68
|
+
services: Partial<Record<StageService, string>>;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Makes stage's organisation or project in every service production's app uses (judged by the names in its env),
|
|
72
|
+
* mints its keys once and returns the env stage holds. Order per service: the organisation (its id recorded and
|
|
73
|
+
* persisted at once), then the key (its ids and held refs recorded and persisted before the secret is put), then
|
|
74
|
+
* the put. A run that failed halfway finds both and does the rest; a key is never minted twice, and when its ref
|
|
75
|
+
* is recorded but the held store no longer has it, this stops. A service the app does not use is left alone; one
|
|
76
|
+
* the kind does not take or the host cannot provide is recorded `absent:<service>:<reason>` and nothing is minted.
|
|
77
|
+
*/
|
|
78
|
+
export declare function ensureStageServices(input: {
|
|
79
|
+
id: string;
|
|
80
|
+
kind: StageKind;
|
|
81
|
+
/** The env of the live app. */
|
|
82
|
+
prod: ReadonlyMap<string, string>;
|
|
83
|
+
port: StageServicesPort | undefined;
|
|
84
|
+
recorded: () => Readonly<Record<string, string>>;
|
|
85
|
+
record: (key: string, value: string) => void;
|
|
86
|
+
persist: () => Promise<void>;
|
|
87
|
+
/** Registers a value for redaction before it is written anywhere. */
|
|
88
|
+
secret: (value: string) => void;
|
|
89
|
+
/** A fixed-text line for a service left absent. */
|
|
90
|
+
log?: (line: string) => void;
|
|
91
|
+
}): Promise<StageServicesResult>;
|
|
92
|
+
/** Whether the recorded `bg.<id>.stageservices` lets the copy go on. */
|
|
93
|
+
export declare const stageServicesReady: (value: string | undefined) => boolean;
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { bgKey } from './blue-green.js';
|
|
3
|
+
import { isAllowed } from './stage-isolation.js';
|
|
4
|
+
export const STAGE_SERVICES = ['files', 'ai', 'jobs'];
|
|
5
|
+
/** Fixed text and ids only: safe to log, record and return. */
|
|
6
|
+
export class StageServiceError extends Error {
|
|
7
|
+
name = 'StageServiceError';
|
|
8
|
+
}
|
|
9
|
+
/** The env names that show production's app uses a service, and the name of its URL. */
|
|
10
|
+
const SERVICE_ENV = {
|
|
11
|
+
files: { evidence: ['FILES_URL', 'FILES_API_KEY', 'FILES_ORGANISATION_ID'], url: 'FILES_URL' },
|
|
12
|
+
ai: { evidence: ['AI_URL', 'AI_API_KEY', 'AI_ORGANISATION_ID'], url: 'AI_URL' },
|
|
13
|
+
jobs: {
|
|
14
|
+
evidence: ['JOBS_SCHEDULER_URL', 'JOBS_SYNC_KEY', 'JOBS_SIGNING_SECRET'],
|
|
15
|
+
url: 'JOBS_SCHEDULER_URL',
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
/** The fixed reasons a used service is recorded absent. */
|
|
19
|
+
export const STAGE_SERVICE_ABSENT = {
|
|
20
|
+
noPort: 'no-port',
|
|
21
|
+
notForKind: 'not-for-kind',
|
|
22
|
+
};
|
|
23
|
+
const sha256 = (value) => createHash('sha256').update(value).digest('hex');
|
|
24
|
+
function parseKeys(raw) {
|
|
25
|
+
if (!raw)
|
|
26
|
+
return undefined;
|
|
27
|
+
try {
|
|
28
|
+
const parsed = JSON.parse(raw);
|
|
29
|
+
return Array.isArray(parsed.ids) && parsed.refs ? parsed : undefined;
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Makes stage's organisation or project in every service production's app uses (judged by the names in its env),
|
|
37
|
+
* mints its keys once and returns the env stage holds. Order per service: the organisation (its id recorded and
|
|
38
|
+
* persisted at once), then the key (its ids and held refs recorded and persisted before the secret is put), then
|
|
39
|
+
* the put. A run that failed halfway finds both and does the rest; a key is never minted twice, and when its ref
|
|
40
|
+
* is recorded but the held store no longer has it, this stops. A service the app does not use is left alone; one
|
|
41
|
+
* the kind does not take or the host cannot provide is recorded `absent:<service>:<reason>` and nothing is minted.
|
|
42
|
+
*/
|
|
43
|
+
export async function ensureStageServices(input) {
|
|
44
|
+
const { id, kind, prod } = input;
|
|
45
|
+
const env = [];
|
|
46
|
+
const services = {};
|
|
47
|
+
for (const service of STAGE_SERVICES) {
|
|
48
|
+
const spec = SERVICE_ENV[service];
|
|
49
|
+
if (!spec.evidence.some((name) => prod.has(name)))
|
|
50
|
+
continue;
|
|
51
|
+
const at = `stage ${service} for ${id}`;
|
|
52
|
+
const absent = async (reason) => {
|
|
53
|
+
const value = `absent:${service}:${reason}`;
|
|
54
|
+
input.log?.(`${at}: left absent (${reason}); nothing was made`);
|
|
55
|
+
services[service] = value;
|
|
56
|
+
input.record(bgKey.stageService(id, service), value);
|
|
57
|
+
await input.persist();
|
|
58
|
+
};
|
|
59
|
+
if (!isAllowed(kind, spec.url)) {
|
|
60
|
+
await absent(STAGE_SERVICE_ABSENT.notForKind);
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
const port = input.port?.[service];
|
|
64
|
+
let available = false;
|
|
65
|
+
try {
|
|
66
|
+
available = port ? await port.available() : false;
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
// Not reachable: the same as no port.
|
|
70
|
+
}
|
|
71
|
+
if (!port || !available) {
|
|
72
|
+
await absent(STAGE_SERVICE_ABSENT.noPort);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
const orgKey = bgKey.stageServiceOrg(id, service);
|
|
76
|
+
const keysKey = bgKey.stageServiceKeys(id, service);
|
|
77
|
+
const step = async (what, run) => {
|
|
78
|
+
try {
|
|
79
|
+
return await run();
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
// The cause can echo a credential; the step's name is all a reader needs.
|
|
83
|
+
throw new StageServiceError(`${at}: ${what} failed`);
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
let orgId = input.recorded()[orgKey];
|
|
87
|
+
let keys = parseKeys(input.recorded()[keysKey]);
|
|
88
|
+
let fresh;
|
|
89
|
+
const keep = async (ids, secrets) => {
|
|
90
|
+
const refs = {};
|
|
91
|
+
for (const s of secrets)
|
|
92
|
+
refs[s.env] = port.held.refFor(s.env);
|
|
93
|
+
keys = { ids, refs };
|
|
94
|
+
fresh = secrets;
|
|
95
|
+
for (const s of secrets)
|
|
96
|
+
input.secret(s.value);
|
|
97
|
+
input.record(keysKey, JSON.stringify(keys));
|
|
98
|
+
await input.persist();
|
|
99
|
+
};
|
|
100
|
+
if (!orgId) {
|
|
101
|
+
const made = await step('making the organisation', () => port.create());
|
|
102
|
+
orgId = made.id;
|
|
103
|
+
input.record(orgKey, orgId);
|
|
104
|
+
if (made.name)
|
|
105
|
+
input.record(bgKey.stageServiceName(id, service), made.name);
|
|
106
|
+
if (made.secrets && made.secrets.length > 0)
|
|
107
|
+
await keep(made.keyIds ?? [orgId], made.secrets);
|
|
108
|
+
else
|
|
109
|
+
await input.persist();
|
|
110
|
+
}
|
|
111
|
+
if (!keys) {
|
|
112
|
+
if (!port.mintKey) {
|
|
113
|
+
throw new StageServiceError(`${at}: ${orgId} exists but its credentials were never kept; remove it in the service and clear ${orgKey} before making it again`);
|
|
114
|
+
}
|
|
115
|
+
const mint = port.mintKey.bind(port);
|
|
116
|
+
const minted = await step('minting the key', () => mint(orgId));
|
|
117
|
+
await keep(minted.keyIds, minted.secrets);
|
|
118
|
+
}
|
|
119
|
+
const kept = keys;
|
|
120
|
+
// Put what was just minted; resolve the rest. A ref recorded with no held value is never minted again.
|
|
121
|
+
for (const [name, ref] of Object.entries(kept.refs)) {
|
|
122
|
+
const minted = fresh?.find((s) => s.env === name);
|
|
123
|
+
if (minted) {
|
|
124
|
+
await step('holding the key', () => port.held.put(ref, minted.value));
|
|
125
|
+
env.push({ key: name, value: minted.value });
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const held = await step('reading the held key', () => port.held.resolve(ref));
|
|
129
|
+
if (!held) {
|
|
130
|
+
throw new StageServiceError(`${at}: key ${kept.ids.join(',')} was minted but ${ref} is no longer held; restore the held value`);
|
|
131
|
+
}
|
|
132
|
+
input.secret(held);
|
|
133
|
+
env.push({ key: name, value: held });
|
|
134
|
+
}
|
|
135
|
+
const url = prod.get(spec.url);
|
|
136
|
+
if (url)
|
|
137
|
+
env.push({ key: spec.url, value: url });
|
|
138
|
+
if (port.idEnv)
|
|
139
|
+
env.push({ key: port.idEnv, value: orgId });
|
|
140
|
+
const value = `ok:${sha256(JSON.stringify([service, orgId, kept.ids]))}`;
|
|
141
|
+
services[service] = value;
|
|
142
|
+
input.record(bgKey.stageService(id, service), value);
|
|
143
|
+
await input.persist();
|
|
144
|
+
}
|
|
145
|
+
const entries = Object.entries(services).sort(([a], [b]) => a.localeCompare(b));
|
|
146
|
+
return {
|
|
147
|
+
env,
|
|
148
|
+
services,
|
|
149
|
+
record: entries.length === 0 ? 'none' : `ok:${sha256(JSON.stringify(entries))}`,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
/** Whether the recorded `bg.<id>.stageservices` lets the copy go on. */
|
|
153
|
+
export const stageServicesReady = (value) => value === 'none' || value?.startsWith('ok:') === true;
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stage signs in the same people through the same sign-in service, with a client, a broker entry key, an app
|
|
3
|
+
* key and a cookie secret of its own (campaign T3 section 5, G4). A stage session is worthless on production,
|
|
4
|
+
* and stage never holds a production credential. Nothing here returns, logs or throws a secret: errors name
|
|
5
|
+
* ids and refs only (CLAUDE.md, Platform steps, rules 1 and 6).
|
|
6
|
+
*/
|
|
7
|
+
/** What the stage client of a public SPA says: the client alone. */
|
|
8
|
+
export interface StagePublicClientSpec {
|
|
9
|
+
/** `<production client name>-stage`. */
|
|
10
|
+
clientName: string;
|
|
11
|
+
organizationId: string;
|
|
12
|
+
/** Only on the stage host. */
|
|
13
|
+
redirectUris: string[];
|
|
14
|
+
postLogoutRedirectUris: string[];
|
|
15
|
+
/** `https://stage-<id>.<domain>`. */
|
|
16
|
+
origins: string[];
|
|
17
|
+
}
|
|
18
|
+
/** What the stage client and its broker entry say. Everything here is an id, a name or an address, never a key. */
|
|
19
|
+
export interface StageSignInSpec {
|
|
20
|
+
/** `<production client name>-stage`. */
|
|
21
|
+
clientName: string;
|
|
22
|
+
/** The sign-in organisation production's users are in: the same one, so the same people can sign in. */
|
|
23
|
+
organizationId: string;
|
|
24
|
+
/** Only on the stage host. */
|
|
25
|
+
redirectUris: string[];
|
|
26
|
+
postLogoutRedirectUris: string[];
|
|
27
|
+
/** `<production broker entry name>-stage`. */
|
|
28
|
+
brokerName: string;
|
|
29
|
+
/** `https://stage-<id>.<domain>`. */
|
|
30
|
+
origins: string[];
|
|
31
|
+
from: string;
|
|
32
|
+
registration: boolean;
|
|
33
|
+
emailLink?: boolean;
|
|
34
|
+
}
|
|
35
|
+
/** What the broker entry of the stage client is asked to say (no key: the key is handed over on its own). */
|
|
36
|
+
export interface StageBrokerEntry {
|
|
37
|
+
name: string;
|
|
38
|
+
organizationId: string;
|
|
39
|
+
clientId: string;
|
|
40
|
+
origins: string[];
|
|
41
|
+
from: string;
|
|
42
|
+
registration: boolean;
|
|
43
|
+
emailLink?: boolean;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The host's side of stage sign-in. `required: false` is a subject that does not sign in (recorded `none`);
|
|
47
|
+
* a subject that signs in but has no port here stays unrecorded, so the copy waits.
|
|
48
|
+
*/
|
|
49
|
+
export type StageSignInPort = {
|
|
50
|
+
required: false;
|
|
51
|
+
} | {
|
|
52
|
+
/** A client only: no app key, no broker entry and no cookie secret (Agora is a static SPA). */
|
|
53
|
+
required: true;
|
|
54
|
+
public: true;
|
|
55
|
+
spec: StagePublicClientSpec;
|
|
56
|
+
findClient(name: string): Promise<{
|
|
57
|
+
appId: string;
|
|
58
|
+
clientId?: string;
|
|
59
|
+
} | undefined>;
|
|
60
|
+
createClient(spec: StagePublicClientSpec): Promise<{
|
|
61
|
+
appId: string;
|
|
62
|
+
clientId: string;
|
|
63
|
+
}>;
|
|
64
|
+
} | {
|
|
65
|
+
required: true;
|
|
66
|
+
public?: undefined;
|
|
67
|
+
spec: StageSignInSpec;
|
|
68
|
+
/** The client of that name in production's sign-in project, to adopt after a create whose answer was lost. */
|
|
69
|
+
findClient(name: string): Promise<{
|
|
70
|
+
appId: string;
|
|
71
|
+
clientId?: string;
|
|
72
|
+
} | undefined>;
|
|
73
|
+
/** Makes the client in production's sign-in organisation and project. */
|
|
74
|
+
createClient(spec: StageSignInSpec): Promise<{
|
|
75
|
+
appId: string;
|
|
76
|
+
clientId: string;
|
|
77
|
+
}>;
|
|
78
|
+
/** Writes the broker entry with the key, and deploys the broker when its entries changed. */
|
|
79
|
+
writeBrokerEntry(entry: StageBrokerEntry, appKey: string): Promise<void>;
|
|
80
|
+
/** The held store the app key lives in. `ref` is an id, not a value. */
|
|
81
|
+
held: {
|
|
82
|
+
ref: string;
|
|
83
|
+
resolve(): Promise<string | null>;
|
|
84
|
+
put(value: string): Promise<void>;
|
|
85
|
+
};
|
|
86
|
+
};
|
|
87
|
+
/** Tries the broker write this many times (CLAUDE.md, Platform steps, rule 1). */
|
|
88
|
+
export declare const STAGE_SIGNIN_DELIVERY_TRIES = 3;
|
|
89
|
+
/** The guard value for a subject that does not sign in. */
|
|
90
|
+
export declare const STAGE_SIGNIN_NONE = "none";
|
|
91
|
+
/** Fixed text and ids only: safe to log, record and return. */
|
|
92
|
+
export declare class StageSignInError extends Error {
|
|
93
|
+
readonly name = "StageSignInError";
|
|
94
|
+
}
|
|
95
|
+
export interface StageSignInResult {
|
|
96
|
+
/** The env stage gets: the client id and the app key. The cookie secret is stage's own, minted with the rest of its env. */
|
|
97
|
+
env: Array<{
|
|
98
|
+
key: string;
|
|
99
|
+
value: string;
|
|
100
|
+
}>;
|
|
101
|
+
/** `bg.<id>.stagesignin`: `ok:<hash of the client, app and entry ids>`. Never a value. */
|
|
102
|
+
guard: string;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Makes stage's client, mints its app key once and writes its broker entry. Order: the client first (its ids
|
|
106
|
+
* recorded and persisted at once, a lost answer is adopted by name), then the key (held, its ref recorded under
|
|
107
|
+
* {@link bgKey.stageSignInKey} and persisted before anything is delivered), then the broker entry (retried).
|
|
108
|
+
* A run that failed halfway finds both and does the rest; the key is never minted twice. When the ref is
|
|
109
|
+
* recorded but the held store no longer has the key, this stops: it never mints another.
|
|
110
|
+
*/
|
|
111
|
+
export declare function ensureStageSignIn(input: {
|
|
112
|
+
id: string;
|
|
113
|
+
port: Extract<StageSignInPort, {
|
|
114
|
+
required: true;
|
|
115
|
+
public?: undefined;
|
|
116
|
+
}>;
|
|
117
|
+
recorded: () => Readonly<Record<string, string>>;
|
|
118
|
+
record: (key: string, value: string) => void;
|
|
119
|
+
persist: () => Promise<void>;
|
|
120
|
+
/** Registers a value for redaction before it is written anywhere. */
|
|
121
|
+
secret: (value: string) => void;
|
|
122
|
+
generate: () => string;
|
|
123
|
+
sleep?: (ms: number) => Promise<void>;
|
|
124
|
+
}): Promise<StageSignInResult>;
|
|
125
|
+
/** `ok:<sha256 of the ids>`: the client, the app, the broker entry's name and its origins. No value goes in. */
|
|
126
|
+
export declare function stageSignInGuard(input: {
|
|
127
|
+
appId: string;
|
|
128
|
+
clientId: string;
|
|
129
|
+
brokerName: string;
|
|
130
|
+
origins: readonly string[];
|
|
131
|
+
}): string;
|
|
132
|
+
/** Whether the recorded `bg.<id>.stagesignin` lets the copy go on. */
|
|
133
|
+
export declare const stageSignInReady: (value: string | undefined) => boolean;
|
|
134
|
+
export interface StagePublicClientResult {
|
|
135
|
+
clientId: string;
|
|
136
|
+
/** `bg.<id>.stagesignin`: `ok:<hash of the client and app ids and the origins>`. Never a value. */
|
|
137
|
+
guard: string;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Makes the public client of a static SPA's stage (Agora): found by name or created, its ids recorded and
|
|
141
|
+
* persisted at once. There is no key and no broker entry, so nothing secret exists. The guard hashes the app id,
|
|
142
|
+
* the client id, the client name and the origins.
|
|
143
|
+
*/
|
|
144
|
+
export declare function ensureStagePublicClient(input: {
|
|
145
|
+
id: string;
|
|
146
|
+
port: Extract<StageSignInPort, {
|
|
147
|
+
public: true;
|
|
148
|
+
}>;
|
|
149
|
+
recorded: () => Readonly<Record<string, string>>;
|
|
150
|
+
record: (key: string, value: string) => void;
|
|
151
|
+
persist: () => Promise<void>;
|
|
152
|
+
}): Promise<StagePublicClientResult>;
|