@wtfalch/foundry 0.60.0 → 0.62.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/apps/cloudflare.d.ts +173 -0
- package/dist/apps/cloudflare.js +17 -0
- package/dist/apps/deploy.d.ts +116 -0
- package/dist/apps/deploy.js +791 -0
- package/dist/apps/github.d.ts +47 -0
- package/dist/apps/github.js +201 -0
- package/dist/apps/index.d.ts +9 -0
- package/dist/apps/index.js +9 -0
- package/dist/apps/migrations.d.ts +110 -0
- package/dist/apps/migrations.js +458 -0
- package/dist/audit/connector.d.ts +43 -0
- package/dist/audit/connector.js +22 -0
- package/dist/audit/evidence.d.ts +49 -0
- package/dist/audit/evidence.js +56 -0
- package/dist/audit/index.d.ts +9 -0
- package/dist/audit/index.js +9 -0
- package/dist/audit/migrations.d.ts +5 -0
- package/dist/audit/migrations.js +5 -0
- package/dist/audit/registry.d.ts +49 -0
- package/dist/audit/registry.js +44 -0
- package/dist/audit/seal.d.ts +49 -0
- package/dist/audit/seal.js +131 -0
- package/dist/audit/sealer-role.d.ts +33 -0
- package/dist/audit/sealer-role.js +56 -0
- package/dist/audit/signer.d.ts +40 -0
- package/dist/audit/signer.js +78 -0
- package/dist/audit/tick.d.ts +36 -0
- package/dist/audit/tick.js +87 -0
- package/dist/catalogue/copy-en.d.ts +1 -0
- package/dist/catalogue/copy-en.js +129 -0
- package/dist/catalogue/index.d.ts +9 -0
- package/dist/catalogue/index.js +21 -0
- package/dist/catalogue/integrations.d.ts +4 -0
- package/dist/catalogue/integrations.js +104 -0
- package/dist/catalogue/systems.d.ts +7 -0
- package/dist/catalogue/systems.js +31 -0
- package/dist/catalogue/types.d.ts +110 -0
- package/dist/catalogue/types.js +1 -0
- package/dist/entry-rules.d.ts +53 -0
- package/dist/entry-rules.js +71 -0
- package/dist/hosted-label.d.ts +13 -0
- package/dist/hosted-label.js +81 -0
- package/dist/index.d.ts +14 -5
- package/dist/index.js +7 -3
- package/dist/migrations/0062_hosting_github.sql +6 -0
- package/dist/migrations/0063_boule_apps.sql +7 -0
- package/dist/migrations/0064_requested_changes_boule_apps.sql +6 -0
- package/dist/migrations/0065_native_build_runs.sql +32 -0
- package/dist/migrations/0066_org_kosmos.sql +10 -0
- package/dist/modules/ai.d.ts +3 -0
- package/dist/modules/ai.js +64 -0
- package/dist/modules/assistants.d.ts +3 -0
- package/dist/modules/assistants.js +72 -0
- package/dist/modules/audit-ledger.d.ts +29 -0
- package/dist/modules/audit-ledger.js +49 -0
- package/dist/modules/audit.d.ts +7 -0
- package/dist/modules/audit.js +44 -0
- package/dist/modules/database.d.ts +23 -0
- package/dist/modules/database.js +189 -0
- package/dist/modules/email.d.ts +3 -0
- package/dist/modules/email.js +18 -0
- package/dist/modules/entry-parts.d.ts +23 -0
- package/dist/modules/entry-parts.js +63 -0
- package/dist/modules/files.d.ts +3 -0
- package/dist/modules/files.js +64 -0
- package/dist/modules/index.d.ts +74 -0
- package/dist/modules/index.js +774 -0
- package/dist/modules/jobs.d.ts +3 -0
- package/dist/modules/jobs.js +23 -0
- package/dist/modules/organisations.d.ts +3 -0
- package/dist/modules/organisations.js +20 -0
- package/dist/modules/outputs.d.ts +9 -0
- package/dist/modules/outputs.js +38 -0
- package/dist/modules/sign-in.d.ts +3 -0
- package/dist/modules/sign-in.js +197 -0
- package/dist/modules/signed-records.d.ts +3 -0
- package/dist/modules/signed-records.js +32 -0
- package/dist/modules/types.d.ts +194 -0
- package/dist/modules/types.js +1 -0
- package/dist/modules/workspaces.d.ts +3 -0
- package/dist/modules/workspaces.js +75 -0
- package/dist/native-build.d.ts +70 -0
- package/dist/native-build.js +92 -0
- package/dist/org.d.ts +184 -24
- package/dist/org.js +444 -49
- package/dist/plan/agora.d.ts +1 -1
- package/dist/plan/agora.js +4 -0
- package/dist/plan/apps-db-route.d.ts +90 -0
- package/dist/plan/apps-db-route.js +168 -0
- package/dist/plan/apps-zones.d.ts +56 -0
- package/dist/plan/apps-zones.js +118 -0
- package/dist/plan/archon-blue-green.js +11 -1
- package/dist/plan/blue-green-apply.d.ts +37 -1
- package/dist/plan/blue-green-apply.js +55 -15
- package/dist/plan/blue-green.d.ts +60 -8
- package/dist/plan/blue-green.js +115 -10
- package/dist/plan/boule.d.ts +23 -1
- package/dist/plan/boule.js +35 -2
- package/dist/plan/broker-entries.d.ts +28 -3
- package/dist/plan/broker-entries.js +74 -3
- package/dist/plan/broker-registrar.d.ts +168 -0
- package/dist/plan/broker-registrar.js +440 -0
- package/dist/plan/broker-workspaces-database.d.ts +48 -0
- package/dist/plan/broker-workspaces-database.js +87 -0
- package/dist/plan/deploy-health.js +9 -2
- package/dist/plan/desktop-callback.js +15 -7
- package/dist/plan/diff.d.ts +8 -2
- package/dist/plan/diff.js +552 -104
- package/dist/plan/edge-access.d.ts +6 -0
- package/dist/plan/edge-access.js +1 -0
- package/dist/plan/edge-dns.d.ts +6 -0
- package/dist/plan/edge-dns.js +9 -7
- package/dist/plan/entry-ai-key.d.ts +1 -1
- package/dist/plan/entry-ai-key.js +2 -2
- package/dist/plan/entry-audit.d.ts +58 -0
- package/dist/plan/entry-audit.js +122 -0
- package/dist/plan/entry-customer-grants.d.ts +75 -0
- package/dist/plan/entry-customer-grants.js +154 -0
- package/dist/plan/entry-edge-key.d.ts +26 -1
- package/dist/plan/entry-edge-key.js +42 -3
- package/dist/plan/entry-jobs.d.ts +64 -0
- package/dist/plan/entry-jobs.js +92 -0
- package/dist/plan/entry-repo-key-apply.d.ts +55 -0
- package/dist/plan/entry-repo-key-apply.js +185 -0
- package/dist/plan/entry-repo-key.d.ts +92 -0
- package/dist/plan/entry-repo-key.js +235 -0
- package/dist/plan/entry-workspaces.d.ts +55 -0
- package/dist/plan/entry-workspaces.js +90 -0
- package/dist/plan/executor.d.ts +231 -19
- package/dist/plan/executor.js +1648 -129
- package/dist/plan/gates.d.ts +1 -1
- package/dist/plan/gates.js +62 -9
- package/dist/plan/github-deploy-keys.d.ts +78 -0
- package/dist/plan/github-deploy-keys.js +305 -0
- package/dist/plan/github-source.d.ts +53 -0
- package/dist/plan/github-source.js +140 -0
- package/dist/plan/held-key-refs.d.ts +12 -0
- package/dist/plan/held-key-refs.js +12 -0
- package/dist/plan/hostnames.d.ts +9 -0
- package/dist/plan/hostnames.js +28 -1
- package/dist/plan/index.d.ts +9 -0
- package/dist/plan/index.js +6 -0
- package/dist/plan/jobs-scheduler-http.js +3 -0
- package/dist/plan/keys-registrar-http.d.ts +32 -0
- package/dist/plan/keys-registrar-http.js +105 -0
- package/dist/plan/keys-registrar.d.ts +67 -0
- package/dist/plan/keys-registrar.js +103 -0
- package/dist/plan/kosmos.d.ts +56 -0
- package/dist/plan/kosmos.js +84 -0
- package/dist/plan/live.d.ts +1 -1
- package/dist/plan/locked-hostnames.d.ts +2 -2
- package/dist/plan/locked-hostnames.js +7 -4
- package/dist/plan/login-host.js +11 -5
- package/dist/plan/machine-paths.d.ts +36 -0
- package/dist/plan/machine-paths.js +137 -0
- package/dist/plan/mcp.d.ts +1 -1
- package/dist/plan/mcp.js +49 -17
- package/dist/plan/org-apps-admin.d.ts +108 -0
- package/dist/plan/org-apps-admin.js +434 -0
- package/dist/plan/org-names.d.ts +3 -3
- package/dist/plan/org-names.js +15 -2
- package/dist/plan/org-postgres-migrate.js +1 -0
- package/dist/plan/own-diff.d.ts +23 -0
- package/dist/plan/own-diff.js +53 -0
- package/dist/plan/own-hostnames.d.ts +2 -0
- package/dist/plan/own-hostnames.js +1 -0
- package/dist/plan/own-jobs-route.d.ts +47 -0
- package/dist/plan/own-jobs-route.js +91 -0
- package/dist/plan/own-limits.d.ts +6 -0
- package/dist/plan/own-limits.js +1 -0
- package/dist/plan/own-services.d.ts +4 -0
- package/dist/plan/own-services.js +4 -0
- package/dist/plan/plan-apply.d.ts +39 -0
- package/dist/plan/plan-apply.js +163 -9
- package/dist/plan/platform-diff.d.ts +2 -2
- package/dist/plan/platform-diff.js +7 -2
- package/dist/plan/platform-org.d.ts +30 -0
- package/dist/plan/platform-org.js +12 -1
- package/dist/plan/platform-providers.d.ts +2 -0
- package/dist/plan/platform-providers.js +13 -2
- package/dist/plan/post-logout-uris.d.ts +1 -1
- package/dist/plan/post-logout-uris.js +12 -3
- package/dist/plan/private-pages.d.ts +26 -0
- package/dist/plan/private-pages.js +39 -0
- package/dist/plan/providers-readers.js +20 -1
- package/dist/plan/proxy-labels.d.ts +12 -1
- package/dist/plan/proxy-labels.js +32 -2
- package/dist/plan/release-host.d.ts +6 -2
- package/dist/plan/release-host.js +36 -8
- package/dist/plan/stage-env.d.ts +19 -1
- package/dist/plan/stage-env.js +25 -1
- package/dist/plan/stage-guards.d.ts +20 -0
- package/dist/plan/stage-guards.js +24 -0
- package/dist/plan/stage-isolation.d.ts +24 -0
- package/dist/plan/stage-isolation.js +29 -1
- package/dist/plan/worker-app-database.d.ts +46 -42
- package/dist/plan/worker-app-database.js +150 -153
- package/dist/plan/worker-app.d.ts +5 -65
- package/dist/plan/worker-app.js +28 -105
- package/dist/plan/worker.d.ts +2 -2
- package/dist/plan/worker.js +2 -1
- package/dist/plan/workspace-client.d.ts +25 -0
- package/dist/plan/workspace-client.js +48 -0
- package/dist/plan/workspace-sign-in.d.ts +55 -0
- package/dist/plan/workspace-sign-in.js +273 -0
- package/dist/plan/workspace-worker.d.ts +4 -0
- package/dist/plan/workspace-worker.js +13 -0
- package/dist/plan/workspace.d.ts +101 -0
- package/dist/plan/workspace.js +305 -0
- package/dist/release/decide.d.ts +30 -1
- package/dist/release/decide.js +73 -11
- package/dist/release/health.d.ts +8 -3
- package/dist/release/health.js +58 -5
- package/dist/release/migration-compatibility.d.ts +66 -0
- package/dist/release/migration-compatibility.js +340 -0
- package/dist/release/run.d.ts +2 -5
- package/dist/release/run.js +4 -0
- package/dist/release/state.d.ts +3 -1
- package/dist/release/state.js +6 -0
- package/dist/release/types.d.ts +20 -0
- package/dist/requested-changes.d.ts +11 -2
- package/dist/requested-changes.js +13 -1
- package/dist/service-labels.d.ts +4 -0
- package/dist/service-labels.js +11 -0
- package/dist/slug.d.ts +2 -0
- package/dist/slug.js +20 -7
- package/dist/store/postgres.js +25 -2
- package/dist/store/schema.d.ts +73 -1
- package/dist/store/schema.js +10 -0
- package/dist/worker-app-config.d.ts +1 -11
- package/dist/worker-app-config.js +4 -17
- package/dist/worker-app-migrations.d.ts +105 -0
- package/dist/worker-app-migrations.js +166 -0
- package/dist/worker-app-upload.d.ts +103 -0
- package/dist/worker-app-upload.js +128 -0
- package/package.json +19 -2
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type DeclaredOrg, type PrivatePage } from '../org.js';
|
|
2
|
+
/**
|
|
3
|
+
* Private pages (`HostingEntry.privatePages`): a path of an app that only the company's people may open. Each is
|
|
4
|
+
* a path-scoped Cloudflare Access application (`<host><path>`, a self-hosted app whose domain carries a path)
|
|
5
|
+
* made by `planGates` like any other gate, with the company sign-in claim, over the entry's live hosts (`pp-`)
|
|
6
|
+
* and over its workspace host (`wp-`). Cloudflare applies the most specific application, so the page's gate
|
|
7
|
+
* wins over the workspace's whole-host gate, and a tester who may open the workspace is refused here.
|
|
8
|
+
*
|
|
9
|
+
* Foundry cannot turn a role into people: `roles` are the company's role ids, and the host that declares the
|
|
10
|
+
* org puts the email addresses of the people holding them in `people`. The gate admits the owners and those.
|
|
11
|
+
*/
|
|
12
|
+
export type PrivatePageScope = 'live' | 'workspace';
|
|
13
|
+
/** The gate key of one private page: a hash of the scope, entry key and path, so it fits a DNS label, never clashes with `ws-<key>` or a declared gate key, and needs no escaping. */
|
|
14
|
+
export declare function privatePageGateKey(scope: PrivatePageScope, entryKey: string, path: string): string;
|
|
15
|
+
/** Who passes a private page: the owners and the page's `people`, lowercased, distinct, sorted. */
|
|
16
|
+
export declare function privatePagePeople(org: Pick<DeclaredOrg, 'owners'>, page: PrivatePage): string[];
|
|
17
|
+
export interface PrivatePageGate {
|
|
18
|
+
entry: string;
|
|
19
|
+
hostnames: string[];
|
|
20
|
+
people: string[];
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* The private page gates by key: the live ones from `org` (an entry that is not live has no live host) and the
|
|
24
|
+
* workspace ones from `declared` (the org with its dark entries).
|
|
25
|
+
*/
|
|
26
|
+
export declare function privatePageGates(org: DeclaredOrg, declared: DeclaredOrg): Map<string, PrivatePageGate>;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { normalizeTesters } from '../org.js';
|
|
3
|
+
import { entryHostnames, workspaceHost } from './hostnames.js';
|
|
4
|
+
import { companyPeople, workspaceEntries } from './workspace.js';
|
|
5
|
+
/** The gate key of one private page: a hash of the scope, entry key and path, so it fits a DNS label, never clashes with `ws-<key>` or a declared gate key, and needs no escaping. */
|
|
6
|
+
export function privatePageGateKey(scope, entryKey, path) {
|
|
7
|
+
const hash = createHash('sha256').update(`${scope}\0${entryKey}\0${path}`).digest('hex');
|
|
8
|
+
return `${scope === 'live' ? 'pp' : 'wp'}-${hash.slice(0, 16)}`;
|
|
9
|
+
}
|
|
10
|
+
/** Who passes a private page: the owners and the page's `people`, lowercased, distinct, sorted. */
|
|
11
|
+
export function privatePagePeople(org, page) {
|
|
12
|
+
return normalizeTesters([...companyPeople(org), ...(page.people ?? [])]).sort();
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* The private page gates by key: the live ones from `org` (an entry that is not live has no live host) and the
|
|
16
|
+
* workspace ones from `declared` (the org with its dark entries).
|
|
17
|
+
*/
|
|
18
|
+
export function privatePageGates(org, declared) {
|
|
19
|
+
const gates = new Map();
|
|
20
|
+
if (!org.hosting.enabled)
|
|
21
|
+
return gates;
|
|
22
|
+
const add = (scope, entry, hosts) => {
|
|
23
|
+
if (hosts.length === 0)
|
|
24
|
+
return;
|
|
25
|
+
for (const page of entry.privatePages ?? []) {
|
|
26
|
+
gates.set(privatePageGateKey(scope, entry.key, page.path), {
|
|
27
|
+
entry: entry.key,
|
|
28
|
+
hostnames: hosts.map((host) => `${host}${page.path}`).sort(),
|
|
29
|
+
people: privatePagePeople(declared, page),
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
for (const entry of org.hosting.entries)
|
|
34
|
+
add('live', entry, entryHostnames(org, entry));
|
|
35
|
+
for (const entry of workspaceEntries(declared)) {
|
|
36
|
+
add('workspace', entry, [workspaceHost(declared.slug, entry.key)]);
|
|
37
|
+
}
|
|
38
|
+
return gates;
|
|
39
|
+
}
|
|
@@ -2,6 +2,7 @@ import { ZitadelError } from '@wtfalch/providers/zitadel';
|
|
|
2
2
|
import { APP_ENTRY_KEY } from '../org.js';
|
|
3
3
|
import { BOULE_MAIL_TOKEN_ENV, BOULE_MANAGED_ENV_KEYS } from './boule.js';
|
|
4
4
|
import { previewsOn } from './coolify-previews.js';
|
|
5
|
+
import { KOSMOS_MANAGED_ENV_KEYS } from './kosmos.js';
|
|
5
6
|
import { APP_GONE_KIND, } from './live.js';
|
|
6
7
|
import { SIGN_IN_UPSTREAM_ENV } from './sign-in.js';
|
|
7
8
|
/**
|
|
@@ -302,6 +303,23 @@ async function withBouleEnvConfig(client, resource, recorded, liveKeys) {
|
|
|
302
303
|
config: { ...resource.config, env, ...(appToken ? { appMailToken: appToken } : {}) },
|
|
303
304
|
};
|
|
304
305
|
}
|
|
306
|
+
/**
|
|
307
|
+
* The org's own recorded kosmos app's live sign-in env vars, as `config.env`, so `diff.ts`'s `planKosmos`
|
|
308
|
+
* can compare them with what `executor.ts` would write. Gated and failing exactly like
|
|
309
|
+
* {@link withAgoraEnvConfig}.
|
|
310
|
+
*/
|
|
311
|
+
async function withKosmosEnvConfig(client, resource, recorded) {
|
|
312
|
+
if (resource.kind !== 'application' || !recorded[resource.key]?.startsWith('kosmos:')) {
|
|
313
|
+
return resource;
|
|
314
|
+
}
|
|
315
|
+
const managed = KOSMOS_MANAGED_ENV_KEYS;
|
|
316
|
+
const env = {};
|
|
317
|
+
for (const e of await client.listEnvs(resource.key)) {
|
|
318
|
+
if (!e.is_preview && managed.includes(e.key))
|
|
319
|
+
env[e.key] = e.value;
|
|
320
|
+
}
|
|
321
|
+
return { ...resource, config: { ...resource.config, env } };
|
|
322
|
+
}
|
|
305
323
|
/** `@wtfalch/providers`' own `LiveResource` (`resource.ts`) has no `system` field — foundry's adds it at this boundary. */
|
|
306
324
|
function toFoundryResources(system, resources) {
|
|
307
325
|
return resources.map((r) => ({ system, kind: r.kind, key: r.key, config: r.config }));
|
|
@@ -335,7 +353,8 @@ export class ProvidersCoolifyReader {
|
|
|
335
353
|
const withAgoraConfig = await Promise.all(withDbUrl.map((r) => withAgoraEnvConfig(this.client, r, recorded)));
|
|
336
354
|
const liveKeys = new Set(withAgoraConfig.map((r) => r.key));
|
|
337
355
|
const withBouleConfig = await Promise.all(withAgoraConfig.map((r) => withBouleEnvConfig(this.client, r, recorded, liveKeys)));
|
|
338
|
-
|
|
356
|
+
const withKosmosConfig = await Promise.all(withBouleConfig.map((r) => withKosmosEnvConfig(this.client, r, recorded)));
|
|
357
|
+
return applyRecordedIds(withKosmosConfig, recorded);
|
|
339
358
|
}
|
|
340
359
|
async liveEnvEquals(orgId, appKey, name, value) {
|
|
341
360
|
const recorded = await this.resolveRecordedIds(orgId);
|
|
@@ -26,6 +26,8 @@ export declare const CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY = "cloudflare_tunn
|
|
|
26
26
|
* last written for, comma-joined. Recorded only once every record is in place.
|
|
27
27
|
*/
|
|
28
28
|
export declare const CLOUDFLARE_TUNNEL_OIKOS_HOSTNAMES_SCOPE_KEY = "cloudflare_tunnel_oikos_hostnames";
|
|
29
|
+
/** The same for the entry names in the platform's companyincluded.app zone ({@link entryAppsHostnames}). */
|
|
30
|
+
export declare const CLOUDFLARE_TUNNEL_APPS_HOSTNAMES_SCOPE_KEY = "cloudflare_tunnel_apps_hostnames";
|
|
29
31
|
/**
|
|
30
32
|
* The run label every customer hosting-entry container gets. A run label overrides the image's own
|
|
31
33
|
* `LABEL` of the same key, so Traefik ignores the container whatever its image says: a customer image
|
|
@@ -92,9 +94,18 @@ export declare function sameLabelSets(a: string, b: string): boolean;
|
|
|
92
94
|
* The entry names that live in the platform's `oikos.host` zone (a company with no domain of its own, or with
|
|
93
95
|
* `usesDomain` off): the names the company's tunnel gets a proxied CNAME for. Sorted.
|
|
94
96
|
*/
|
|
95
|
-
export declare function entryOikosHostnames(org: DeclaredOrg): string[];
|
|
97
|
+
export declare function entryOikosHostnames(org: DeclaredOrg, recorded?: Readonly<Record<string, string>>): string[];
|
|
96
98
|
/**
|
|
97
99
|
* Whether `name` is one the company may write in the platform's `oikos.host` zone: exactly its own
|
|
98
100
|
* `<slug>.oikos.host`. A company writes no other company's name, whatever an entry declares.
|
|
99
101
|
*/
|
|
100
102
|
export declare function ownsOikosName(org: DeclaredOrg, name: string): boolean;
|
|
103
|
+
/**
|
|
104
|
+
* The entry names that live in the platform's `companyincluded.app` zone: an entry's `hostedName`, and the
|
|
105
|
+
* workspace host of each server workspace that is applied. Sorted.
|
|
106
|
+
*/
|
|
107
|
+
export declare function entryAppsHostnames(org: DeclaredOrg, recorded?: Readonly<Record<string, string>>): string[];
|
|
108
|
+
/** Whether `name` is one this org may write in the `companyincluded.app` zone: a hosted name or its workspace host. */
|
|
109
|
+
export declare const ownsAppsZoneName: (org: DeclaredOrg, name: string) => boolean;
|
|
110
|
+
/** Whether `name` is one label directly under `companyincluded.app`. */
|
|
111
|
+
export declare function isAppsEntryName(name: string): boolean;
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
+
import { APPS_DOMAIN } from '../org.js';
|
|
2
3
|
import { BUILTIN_APPS, PRIMARY_SERVER_SCOPE_KEY, balancerApplied, builtinSubject, lbAppName, lbOrigin, liveAppNameFor, } from './blue-green.js';
|
|
3
4
|
import { entryHostnames, wildcardHostname } from './hostnames.js';
|
|
4
5
|
import { NO_DOMAIN_ZONE } from './own-hostnames.js';
|
|
5
6
|
import { appNameFor } from './platform-diff.js';
|
|
7
|
+
import { ownsWorkspaceName, workspaceRoutes } from './workspace.js';
|
|
6
8
|
/**
|
|
7
9
|
* The label every routed container carries, with the install's token as its value. Traefik is told
|
|
8
10
|
* (`--providers.docker.constraints=Label(`foundry.edge`,`<token>`)`) to route only containers that carry
|
|
@@ -29,6 +31,8 @@ export const CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY = 'cloudflare_tunnel_entry
|
|
|
29
31
|
* last written for, comma-joined. Recorded only once every record is in place.
|
|
30
32
|
*/
|
|
31
33
|
export const CLOUDFLARE_TUNNEL_OIKOS_HOSTNAMES_SCOPE_KEY = 'cloudflare_tunnel_oikos_hostnames';
|
|
34
|
+
/** The same for the entry names in the platform's companyincluded.app zone ({@link entryAppsHostnames}). */
|
|
35
|
+
export const CLOUDFLARE_TUNNEL_APPS_HOSTNAMES_SCOPE_KEY = 'cloudflare_tunnel_apps_hostnames';
|
|
32
36
|
/**
|
|
33
37
|
* The run label every customer hosting-entry container gets. A run label overrides the image's own
|
|
34
38
|
* `LABEL` of the same key, so Traefik ignores the container whatever its image says: a customer image
|
|
@@ -65,6 +69,8 @@ export function entryRoutes(org, recorded = {}) {
|
|
|
65
69
|
for (const entry of org.hosting.entries) {
|
|
66
70
|
if (entry.workerApp)
|
|
67
71
|
continue; // Served by its Worker route, not the tunnel.
|
|
72
|
+
if (entry.live === false)
|
|
73
|
+
continue; // Only its workspace exists (below).
|
|
68
74
|
// A blue/green entry is reached through its balancer, which each server's connector finds on its own
|
|
69
75
|
// network, once the balancer exists on the primary. Until then it keeps the origin it had.
|
|
70
76
|
const target = !entry.blueGreen
|
|
@@ -81,6 +87,10 @@ export function entryRoutes(org, recorded = {}) {
|
|
|
81
87
|
if (wildcardName && !wildcard)
|
|
82
88
|
wildcard = { hostname: wildcardName, service };
|
|
83
89
|
}
|
|
90
|
+
for (const route of workspaceRoutes(org, recorded)) {
|
|
91
|
+
if (!routes.some((r) => r.hostname === route.hostname))
|
|
92
|
+
routes.push(route);
|
|
93
|
+
}
|
|
84
94
|
if (wildcard)
|
|
85
95
|
routes.push(wildcard);
|
|
86
96
|
return routes;
|
|
@@ -171,8 +181,8 @@ export function sameLabelSets(a, b) {
|
|
|
171
181
|
* The entry names that live in the platform's `oikos.host` zone (a company with no domain of its own, or with
|
|
172
182
|
* `usesDomain` off): the names the company's tunnel gets a proxied CNAME for. Sorted.
|
|
173
183
|
*/
|
|
174
|
-
export function entryOikosHostnames(org) {
|
|
175
|
-
return entryRoutes(org)
|
|
184
|
+
export function entryOikosHostnames(org, recorded = {}) {
|
|
185
|
+
return entryRoutes(org, recorded)
|
|
176
186
|
.map((r) => r.hostname)
|
|
177
187
|
.filter((h) => h.endsWith(`.${NO_DOMAIN_ZONE}`))
|
|
178
188
|
.sort();
|
|
@@ -184,3 +194,23 @@ export function entryOikosHostnames(org) {
|
|
|
184
194
|
export function ownsOikosName(org, name) {
|
|
185
195
|
return name.toLowerCase() === `${org.slug}.${NO_DOMAIN_ZONE}`;
|
|
186
196
|
}
|
|
197
|
+
/**
|
|
198
|
+
* The entry names that live in the platform's `companyincluded.app` zone: an entry's `hostedName`, and the
|
|
199
|
+
* workspace host of each server workspace that is applied. Sorted.
|
|
200
|
+
*/
|
|
201
|
+
export function entryAppsHostnames(org, recorded = {}) {
|
|
202
|
+
return entryRoutes(org, recorded)
|
|
203
|
+
.map((r) => r.hostname)
|
|
204
|
+
.filter((name) => isAppsEntryName(name) || ownsWorkspaceName(org, name))
|
|
205
|
+
.sort();
|
|
206
|
+
}
|
|
207
|
+
/** Whether `name` is one this org may write in the `companyincluded.app` zone: a hosted name or its workspace host. */
|
|
208
|
+
export const ownsAppsZoneName = (org, name) => isAppsEntryName(name) || ownsWorkspaceName(org, name);
|
|
209
|
+
/** Whether `name` is one label directly under `companyincluded.app`. */
|
|
210
|
+
export function isAppsEntryName(name) {
|
|
211
|
+
const suffix = `.${APPS_DOMAIN}`;
|
|
212
|
+
const lower = name.toLowerCase();
|
|
213
|
+
return (lower.endsWith(suffix) &&
|
|
214
|
+
lower.length > suffix.length &&
|
|
215
|
+
!lower.slice(0, -suffix.length).includes('.'));
|
|
216
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type ReleaseDeps } from '../release/run.js';
|
|
2
2
|
import type { ReleaseStore } from '../release/state.js';
|
|
3
|
-
import { type BlueGreenSubject } from './blue-green.js';
|
|
3
|
+
import { type BlueGreenSubject, type PinHeldKeys } from './blue-green.js';
|
|
4
4
|
import { type DeployHealthRuntime } from './deploy-health.js';
|
|
5
5
|
import type { CoolifyPort } from './executor.js';
|
|
6
6
|
/** The recorded ids a release run reads and writes: an org's apply context, or the platform's. */
|
|
@@ -21,13 +21,17 @@ export interface ReleaseHost {
|
|
|
21
21
|
export interface ReleaseHostInput {
|
|
22
22
|
subject: BlueGreenSubject;
|
|
23
23
|
context: ReleaseContext;
|
|
24
|
-
coolify: Pick<CoolifyPort, 'getApplication' | 'updateApplication' | 'bulkUpsertEnvs' | 'listEnvs' | 'deployAndWait' | 'raw' | 'listStorages' | 'createFileStorage' | 'updateFileStorage'> & {
|
|
24
|
+
coolify: Pick<CoolifyPort, 'getApplication' | 'updateApplication' | 'bulkUpsertEnvs' | 'listEnvs' | 'deleteEnv' | 'deployAndWait' | 'raw' | 'listStorages' | 'createFileStorage' | 'updateFileStorage'> & {
|
|
25
25
|
listApplicationDeployments?: CoolifyPort['listApplicationDeployments'];
|
|
26
26
|
};
|
|
27
27
|
/** Reads the entry's health token (`sharedSecretRef(org.id, healthTokenName(id))`). Never mints. */
|
|
28
28
|
healthToken: () => Promise<string | null>;
|
|
29
29
|
/** Registers a value for redaction before it is written anywhere. */
|
|
30
30
|
secret: (value: string) => void;
|
|
31
|
+
/** The held ref of the balancer's pin key (`sharedSecretRef(owner, pinKeyName(id))`). */
|
|
32
|
+
pinRef: string;
|
|
33
|
+
/** The held store. Without it the pin key cannot be read, so a check through the balancer is inconclusive. */
|
|
34
|
+
heldKeys?: PinHeldKeys;
|
|
31
35
|
primaryUuid: string;
|
|
32
36
|
/** The Coolify app of the blue copy on the primary (the entry's `coolify:app`, or the running archon app). */
|
|
33
37
|
primaryBlueAppId: string;
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
1
2
|
import { checkColorAddress, readReleaseHealth } from '../release/health.js';
|
|
2
3
|
import { runRelease } from '../release/run.js';
|
|
3
4
|
import { applyBlueGreenWeights, copiedEnv } from './blue-green-apply.js';
|
|
4
|
-
import { DEFAULT_WEIGHTS, LB_COLOR_HEADER, LB_PIN_HEADER, bgKey, formatWeights, parseWeights, reportingEnv, } from './blue-green.js';
|
|
5
|
+
import { ACTIONS_KEY_ENV, DEFAULT_WEIGHTS, LB_COLOR_HEADER, LB_PIN_HEADER, bgKey, formatWeights, mintPinKey, parseWeights, readPinKey, reportingEnv, } from './blue-green.js';
|
|
5
6
|
import { waitForNewDeployment } from './deploy-health.js';
|
|
6
7
|
import { isNotFoundError } from './executor.js';
|
|
7
8
|
const COMMIT = /^[0-9a-f]{40}$/;
|
|
@@ -41,13 +42,20 @@ export function createReleaseHost(input) {
|
|
|
41
42
|
* Where the runner reaches a color: the entry's first public hostname, pinned to the color by the balancer's
|
|
42
43
|
* header router. The balancer routes the pinned request to that color whatever its weight.
|
|
43
44
|
*/
|
|
44
|
-
const
|
|
45
|
+
const pinInput = () => ({
|
|
46
|
+
id,
|
|
47
|
+
ref: input.pinRef,
|
|
48
|
+
recorded,
|
|
49
|
+
secret: input.secret,
|
|
50
|
+
heldKeys: input.heldKeys ?? { resolve: async () => null },
|
|
51
|
+
});
|
|
52
|
+
const addressOf = async (color) => {
|
|
45
53
|
const hostname = subject.hostnames.find((name) => !name.startsWith('*'));
|
|
46
54
|
if (!hostname)
|
|
47
55
|
throw new Error(`${input.label}: the app has no public hostname to check through`);
|
|
48
|
-
const pinKey =
|
|
56
|
+
const pinKey = await readPinKey(pinInput());
|
|
49
57
|
if (!pinKey)
|
|
50
|
-
throw new Error(`${input.label}: the balancer's pin key is not
|
|
58
|
+
throw new Error(`${input.label}: the balancer's pin key is not held`);
|
|
51
59
|
return {
|
|
52
60
|
address: `https://${hostname}`,
|
|
53
61
|
headers: { [LB_COLOR_HEADER]: color, [LB_PIN_HEADER]: pinKey },
|
|
@@ -101,6 +109,18 @@ export function createReleaseHost(input) {
|
|
|
101
109
|
if (envs.length > 0) {
|
|
102
110
|
await coolify.bulkUpsertEnvs(appId, envs.map((e) => ({ ...e, literal: true })));
|
|
103
111
|
}
|
|
112
|
+
// A key removed from blue (a rotated-out credential) must not stay on the slot.
|
|
113
|
+
if (appId !== input.primaryBlueAppId) {
|
|
114
|
+
const keep = new Set([...source.map((e) => e.key), ...envs.map((e) => e.key)]);
|
|
115
|
+
for (const env of await coolify.listEnvs(appId)) {
|
|
116
|
+
if (env.uuid &&
|
|
117
|
+
!keep.has(env.key) &&
|
|
118
|
+
!env.key.startsWith('REPORTING_') &&
|
|
119
|
+
env.key !== ACTIONS_KEY_ENV) {
|
|
120
|
+
await coolify.deleteEnv(appId, env.uuid);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
104
124
|
}
|
|
105
125
|
catch {
|
|
106
126
|
// A Coolify error can echo the request body, which holds the token.
|
|
@@ -129,7 +149,7 @@ export function createReleaseHost(input) {
|
|
|
129
149
|
return null;
|
|
130
150
|
input.secret(token);
|
|
131
151
|
return readReleaseHealth({
|
|
132
|
-
...addressOf(healthColor()),
|
|
152
|
+
...(await addressOf(healthColor())),
|
|
133
153
|
token,
|
|
134
154
|
...range,
|
|
135
155
|
...(input.fetch ? { fetchImpl: input.fetch } : {}),
|
|
@@ -146,9 +166,10 @@ export function createReleaseHost(input) {
|
|
|
146
166
|
return {
|
|
147
167
|
status,
|
|
148
168
|
address: await checkColorAddress({
|
|
149
|
-
...addressOf(color),
|
|
169
|
+
...(await addressOf(color)),
|
|
150
170
|
healthPath: subject.healthPath,
|
|
151
|
-
|
|
171
|
+
// A server answers JSON on its health path; a static app answers HTML, which must not be an Access page.
|
|
172
|
+
expectJson: subject.reporting,
|
|
152
173
|
...(input.fetch ? { fetchImpl: input.fetch } : {}),
|
|
153
174
|
}),
|
|
154
175
|
};
|
|
@@ -156,8 +177,9 @@ export function createReleaseHost(input) {
|
|
|
156
177
|
async effect(effect) {
|
|
157
178
|
if (effect.kind === 'deploy')
|
|
158
179
|
await deployTo(effect.color, effect.digest);
|
|
159
|
-
else
|
|
180
|
+
else if (effect.kind === 'stop')
|
|
160
181
|
await stop(effect.color);
|
|
182
|
+
// deploy_stage is executed by the stage deploy task (F4); until then it is passed on and ignored here.
|
|
161
183
|
},
|
|
162
184
|
};
|
|
163
185
|
return {
|
|
@@ -187,6 +209,12 @@ export function createReleaseHost(input) {
|
|
|
187
209
|
for (const uuid of servers) {
|
|
188
210
|
await input.renew();
|
|
189
211
|
await applyBlueGreenWeights({
|
|
212
|
+
pinKey: () => mintPinKey({
|
|
213
|
+
...pinInput(),
|
|
214
|
+
record: (key, value) => context.recordScopeId(key, value),
|
|
215
|
+
persist: () => context.persist(),
|
|
216
|
+
generate: () => randomBytes(32).toString('hex'),
|
|
217
|
+
}),
|
|
190
218
|
coolify,
|
|
191
219
|
recorded,
|
|
192
220
|
record: (key, value) => context.recordScopeId(key, value),
|
package/dist/plan/stage-env.d.ts
CHANGED
|
@@ -1,4 +1,20 @@
|
|
|
1
|
-
import { type StageKind } from './stage-isolation.js';
|
|
1
|
+
import { type LiveSignIn, type StageKind } from './stage-isolation.js';
|
|
2
|
+
/** A stage with its own sign-in: a workspace. Its values are the stage's, never the live app's. */
|
|
3
|
+
export interface StageSignIn {
|
|
4
|
+
/** Issuer, organisation id, client id and, with workspaces, the broker URL and the stage's own key. */
|
|
5
|
+
env: ReadonlyArray<{
|
|
6
|
+
key: string;
|
|
7
|
+
value: string;
|
|
8
|
+
}>;
|
|
9
|
+
/** Stage signs in the company's test customers organisation. */
|
|
10
|
+
customers: boolean;
|
|
11
|
+
/** The redirect URI registered for the stage client. */
|
|
12
|
+
redirectUri: string;
|
|
13
|
+
/** What stage must never hold. */
|
|
14
|
+
live: LiveSignIn;
|
|
15
|
+
}
|
|
16
|
+
/** The names a stage's own sign-in sets; a change to one of them redeploys stage. */
|
|
17
|
+
export declare const STAGE_SIGN_IN_NAMES: readonly string[];
|
|
2
18
|
export interface StageEnvInput {
|
|
3
19
|
kind: StageKind;
|
|
4
20
|
/** Where the stage app is reached: its own container address, since stage has no hostname. */
|
|
@@ -22,6 +38,8 @@ export interface StageEnvInput {
|
|
|
22
38
|
is_preview?: boolean;
|
|
23
39
|
}>;
|
|
24
40
|
mint: () => string;
|
|
41
|
+
/** Stage's own sign-in. Its values override any copied from the live app; its cookie secret and app key are minted. */
|
|
42
|
+
signIn?: StageSignIn;
|
|
25
43
|
}
|
|
26
44
|
export interface StageEnv {
|
|
27
45
|
env: Array<{
|
package/dist/plan/stage-env.js
CHANGED
|
@@ -5,9 +5,26 @@ const WAITS_FOR_STAGE_SERVICE = new Set([
|
|
|
5
5
|
'AI_URL',
|
|
6
6
|
'JOBS_SCHEDULER_URL',
|
|
7
7
|
'ANALYTICS_URL',
|
|
8
|
+
'WORKSPACES_URL',
|
|
9
|
+
'MCP_ISSUER',
|
|
8
10
|
]);
|
|
9
11
|
/** Secrets stage mints for itself, when the app uses them. A copy of prod's value would be a prod credential. */
|
|
10
12
|
const MINTED = ['AUTH_COOKIE_SECRET', 'KEYSTORE_KEK'];
|
|
13
|
+
/** The names a stage's own sign-in sets; a change to one of them redeploys stage. */
|
|
14
|
+
export const STAGE_SIGN_IN_NAMES = [
|
|
15
|
+
'AUTH_ISSUER',
|
|
16
|
+
'AUTH_ORGANIZATION_ID',
|
|
17
|
+
'AUTH_CLIENT_ID',
|
|
18
|
+
'AUTH_APP_KEY',
|
|
19
|
+
'AUTH_COOKIE_SECRET',
|
|
20
|
+
'WORKSPACES_URL',
|
|
21
|
+
'WORKSPACES_API_KEY',
|
|
22
|
+
'MCP_AUDIENCE',
|
|
23
|
+
'MCP_RESOURCE',
|
|
24
|
+
'MCP_ISSUER',
|
|
25
|
+
];
|
|
26
|
+
/** The shortest `AUTH_APP_KEY` the broker accepts. */
|
|
27
|
+
const MIN_APP_KEY_LENGTH = 32;
|
|
11
28
|
/**
|
|
12
29
|
* The env of a stage app, built from the kind's allowlist and never from a copy of the live app's env. Every
|
|
13
30
|
* name is allowed and none is denied, or this throws a fixed text with the name.
|
|
@@ -41,10 +58,17 @@ export function buildStageEnv(input) {
|
|
|
41
58
|
set(key, value);
|
|
42
59
|
}
|
|
43
60
|
}
|
|
61
|
+
for (const { key, value } of input.signIn?.env ?? [])
|
|
62
|
+
set(key, value);
|
|
44
63
|
for (const key of MINTED) {
|
|
45
|
-
|
|
64
|
+
const wanted = prod.has(key) || (input.signIn !== undefined && key === 'AUTH_COOKIE_SECRET');
|
|
65
|
+
if (wanted && isAllowed(kind, key))
|
|
46
66
|
set(key, existing.get(key) ?? input.mint());
|
|
47
67
|
}
|
|
68
|
+
if (input.signIn) {
|
|
69
|
+
const kept = existing.get('AUTH_APP_KEY');
|
|
70
|
+
set('AUTH_APP_KEY', kept && kept.length >= MIN_APP_KEY_LENGTH ? kept : input.mint());
|
|
71
|
+
}
|
|
48
72
|
const absent = STAGE_ALLOWLIST[kind]
|
|
49
73
|
.filter((name) => !name.endsWith('*') && prod.has(name) && !env.has(name))
|
|
50
74
|
.sort();
|
|
@@ -37,6 +37,22 @@ export interface StageProbePort {
|
|
|
37
37
|
syncKey: string;
|
|
38
38
|
projectId: string;
|
|
39
39
|
}): Promise<boolean>;
|
|
40
|
+
/** Uses the stage workspaces key on the live customers organisation's workspaces in the broker. */
|
|
41
|
+
workspacesRejects?(input: {
|
|
42
|
+
url: string;
|
|
43
|
+
apiKey: string;
|
|
44
|
+
organisationId: string;
|
|
45
|
+
}): Promise<boolean>;
|
|
46
|
+
/**
|
|
47
|
+
* Starts a real authorization request at the issuer for the live customers organisation with the stage client
|
|
48
|
+
* id and the redirect URI registered for that client, and answers `true` when the live sign-in refuses it.
|
|
49
|
+
*/
|
|
50
|
+
signInRejects?(input: {
|
|
51
|
+
issuer: string;
|
|
52
|
+
clientId: string;
|
|
53
|
+
organisationId: string;
|
|
54
|
+
redirectUri: string;
|
|
55
|
+
}): Promise<boolean>;
|
|
40
56
|
}
|
|
41
57
|
/** One probe. `run` is `null` when the host has no port for it: the probe is then reported as not run. */
|
|
42
58
|
export interface StageProbe {
|
|
@@ -52,6 +68,10 @@ export interface StageProbeInput {
|
|
|
52
68
|
prod: ReadonlyMap<string, string>;
|
|
53
69
|
/** Whether stage has a database: if no database probe can be built, one is kept as not run. */
|
|
54
70
|
database?: boolean;
|
|
71
|
+
/** The live customers organisation's id, where the company has one: the sign-in probes aim at it. */
|
|
72
|
+
liveCustomersOrgId?: string;
|
|
73
|
+
/** The redirect URI registered for the stage client: the sign-in probe sends it. */
|
|
74
|
+
signInRedirectUri?: string;
|
|
55
75
|
}
|
|
56
76
|
/**
|
|
57
77
|
* The probes for what stage holds: its database role against production's database host, and its files, ai and
|
|
@@ -77,6 +77,30 @@ export function buildStageProbes(input) {
|
|
|
77
77
|
run: call ? () => call({ url: jobsUrl, syncKey, projectId }) : null,
|
|
78
78
|
});
|
|
79
79
|
}
|
|
80
|
+
const customersOrgId = input.liveCustomersOrgId;
|
|
81
|
+
const workspacesUrl = stage.get('WORKSPACES_URL');
|
|
82
|
+
const workspacesKey = stage.get('WORKSPACES_API_KEY');
|
|
83
|
+
if (customersOrgId && workspacesUrl && workspacesKey) {
|
|
84
|
+
const call = port?.workspacesRejects?.bind(port);
|
|
85
|
+
probes.push({
|
|
86
|
+
what: 'workspaces:stage-key-on-live-customers',
|
|
87
|
+
run: call
|
|
88
|
+
? () => call({ url: workspacesUrl, apiKey: workspacesKey, organisationId: customersOrgId })
|
|
89
|
+
: null,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
const issuer = stage.get('AUTH_ISSUER');
|
|
93
|
+
const clientId = stage.get('AUTH_CLIENT_ID');
|
|
94
|
+
const redirectUri = input.signInRedirectUri;
|
|
95
|
+
if (customersOrgId && issuer && clientId && redirectUri) {
|
|
96
|
+
const call = port?.signInRejects?.bind(port);
|
|
97
|
+
probes.push({
|
|
98
|
+
what: 'sign-in:stage-client-on-live-customers',
|
|
99
|
+
run: call
|
|
100
|
+
? () => call({ issuer, clientId, organisationId: customersOrgId, redirectUri })
|
|
101
|
+
: null,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
80
104
|
return probes;
|
|
81
105
|
}
|
|
82
106
|
/** Runs every probe once, each under a timeout. A throw and a timeout count as failed, never as rejected. */
|
|
@@ -36,10 +36,31 @@ export declare function isAllowed(kind: StageKind, name: string): boolean;
|
|
|
36
36
|
export declare const SHARED_WITH_PROD: Readonly<Record<string, string>>;
|
|
37
37
|
/** Whether `name` may equal production's value. */
|
|
38
38
|
export declare const isSharedWithProd: (name: string) => boolean;
|
|
39
|
+
/** The live app's own sign-in values. Stage must hold none of them. */
|
|
40
|
+
export interface LiveSignIn {
|
|
41
|
+
/** The live customers organisation's id. */
|
|
42
|
+
customersOrgId?: string;
|
|
43
|
+
/** The live app's client ids. */
|
|
44
|
+
clientIds: readonly string[];
|
|
45
|
+
/** The live workspaces keys. */
|
|
46
|
+
workspacesKeys: readonly string[];
|
|
47
|
+
/** The live apps' MCP audiences (the ZITADEL project ids). */
|
|
48
|
+
mcpAudiences?: readonly string[];
|
|
49
|
+
/** The live apps' MCP resources (their public MCP addresses). */
|
|
50
|
+
mcpResources?: readonly string[];
|
|
51
|
+
}
|
|
52
|
+
/** What a stage with its own sign-in says about it, for the isolation check. */
|
|
53
|
+
export interface StageSignInGuard {
|
|
54
|
+
/** Stage signs in its own test customers organisation, so its organisation id may differ from production's. */
|
|
55
|
+
customers: boolean;
|
|
56
|
+
live: LiveSignIn;
|
|
57
|
+
}
|
|
39
58
|
export interface StageIsolationInput {
|
|
40
59
|
kind: StageKind;
|
|
41
60
|
prod: Surface;
|
|
42
61
|
stage: Surface;
|
|
62
|
+
/** Present for a stage with its own sign-in (a workspace). */
|
|
63
|
+
signIn?: StageSignInGuard;
|
|
43
64
|
/** The mail env stage is allowed to hold: the sink's wiring. Empty: stage holds no mail env. */
|
|
44
65
|
sinkMail?: Readonly<Record<string, string>>;
|
|
45
66
|
}
|
|
@@ -51,6 +72,9 @@ export interface StageIsolationInput {
|
|
|
51
72
|
* {@link SHARED_WITH_PROD} differs from production's), `live_mode_key`.
|
|
52
73
|
* - A4 names: `resource_shared`, `host_is_prod_internal`.
|
|
53
74
|
* - A5 mail: `mail_not_sink`, `mail_token`.
|
|
75
|
+
* - A6 sign-in (with `signIn`): `live_customers_org`, `live_client_id`, `live_workspaces_key`,
|
|
76
|
+
* `live_mcp_audience`, `live_mcp_resource` when stage holds the live customers organisation id, a live client
|
|
77
|
+
* id, the live workspaces key, or a live MCP audience or resource.
|
|
54
78
|
*/
|
|
55
79
|
export declare function assertStageIsolated(input: StageIsolationInput): string[];
|
|
56
80
|
/** The sha256 of the stage env name set plus the resource set: what `bg.<id>.stageisolation` records. */
|
|
@@ -87,6 +87,11 @@ export const STAGE_ALLOWLIST = {
|
|
|
87
87
|
'JOBS_SYNC_KEY',
|
|
88
88
|
'ANALYTICS_URL',
|
|
89
89
|
'ANALYTICS_SITE_KEY',
|
|
90
|
+
'WORKSPACES_URL',
|
|
91
|
+
'WORKSPACES_API_KEY',
|
|
92
|
+
'MCP_AUDIENCE',
|
|
93
|
+
'MCP_RESOURCE',
|
|
94
|
+
'MCP_ISSUER',
|
|
90
95
|
],
|
|
91
96
|
};
|
|
92
97
|
/** Names no stage holds, whatever the app. */
|
|
@@ -159,6 +164,8 @@ export const SHARED_WITH_PROD = {
|
|
|
159
164
|
AI_URL: 'the ai service is shared; stage reaches it only with its own organisation and key',
|
|
160
165
|
JOBS_SCHEDULER_URL: 'the jobs service is shared; stage reaches it only with its own project and signing secret',
|
|
161
166
|
ANALYTICS_URL: 'the analytics collector is shared; stage reports under its own site key',
|
|
167
|
+
MCP_ISSUER: 'the sign-in service is shared by decision; the issuer is a public address, not a credential',
|
|
168
|
+
WORKSPACES_URL: "the broker is shared; stage's own workspaces key opens its own product tenant, which holds no live customers' workspaces",
|
|
162
169
|
};
|
|
163
170
|
/** Whether `name` may equal production's value. */
|
|
164
171
|
export const isSharedWithProd = (name) => Object.keys(SHARED_WITH_PROD).some((p) => globMatches(p, name));
|
|
@@ -174,6 +181,9 @@ const hostOf = (value) => URL_HOST.exec(value)?.[1]?.toLowerCase() ?? null;
|
|
|
174
181
|
* {@link SHARED_WITH_PROD} differs from production's), `live_mode_key`.
|
|
175
182
|
* - A4 names: `resource_shared`, `host_is_prod_internal`.
|
|
176
183
|
* - A5 mail: `mail_not_sink`, `mail_token`.
|
|
184
|
+
* - A6 sign-in (with `signIn`): `live_customers_org`, `live_client_id`, `live_workspaces_key`,
|
|
185
|
+
* `live_mcp_audience`, `live_mcp_resource` when stage holds the live customers organisation id, a live client
|
|
186
|
+
* id, the live workspaces key, or a live MCP audience or resource.
|
|
177
187
|
*/
|
|
178
188
|
export function assertStageIsolated(input) {
|
|
179
189
|
const { kind, prod, stage } = input;
|
|
@@ -211,9 +221,27 @@ export function assertStageIsolated(input) {
|
|
|
211
221
|
out.add(`env_not_allowed:${name}`);
|
|
212
222
|
if (item.value !== undefined && LIVE_MODE_KEY.test(item.value))
|
|
213
223
|
out.add(`live_mode_key:${name}`);
|
|
224
|
+
if (input.signIn && item.value !== undefined) {
|
|
225
|
+
const { live } = input.signIn;
|
|
226
|
+
if (name === 'AUTH_ORGANIZATION_ID' && item.value === live.customersOrgId) {
|
|
227
|
+
out.add(`live_customers_org:${name}`);
|
|
228
|
+
}
|
|
229
|
+
if (name === 'AUTH_CLIENT_ID' && live.clientIds.includes(item.value)) {
|
|
230
|
+
out.add(`live_client_id:${name}`);
|
|
231
|
+
}
|
|
232
|
+
if (name === 'WORKSPACES_API_KEY' && live.workspacesKeys.includes(item.value)) {
|
|
233
|
+
out.add(`live_workspaces_key:${name}`);
|
|
234
|
+
}
|
|
235
|
+
if (name === 'MCP_AUDIENCE' && live.mcpAudiences?.includes(item.value)) {
|
|
236
|
+
out.add(`live_mcp_audience:${name}`);
|
|
237
|
+
}
|
|
238
|
+
if (name === 'MCP_RESOURCE' && live.mcpResources?.includes(item.value)) {
|
|
239
|
+
out.add(`live_mcp_resource:${name}`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
214
242
|
if (item.class === 'literal')
|
|
215
243
|
continue;
|
|
216
|
-
const shared = isSharedWithProd(name);
|
|
244
|
+
const shared = isSharedWithProd(name) && !(input.signIn?.customers && name === 'AUTH_ORGANIZATION_ID');
|
|
217
245
|
const counterpart = prodByName.get(name);
|
|
218
246
|
if (shared) {
|
|
219
247
|
const same = item.value !== undefined && counterpart?.value !== undefined
|