@wtfalch/foundry 0.59.0 → 0.61.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 +10 -5
- package/dist/index.js +5 -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/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/org.d.ts +163 -22
- package/dist/org.js +419 -47
- 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 +41 -1
- package/dist/plan/blue-green.js +80 -2
- 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/desktop-callback.js +12 -7
- package/dist/plan/diff.d.ts +8 -2
- package/dist/plan/diff.js +373 -42
- package/dist/plan/edge-access.d.ts +6 -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 +191 -1
- package/dist/plan/executor.js +1505 -90
- 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/live.d.ts +1 -1
- package/dist/plan/locked-hostnames.d.ts +2 -2
- package/dist/plan/locked-hostnames.js +2 -2
- 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.js +12 -1
- 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-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 +7 -2
- package/dist/plan/post-logout-uris.js +7 -1
- package/dist/plan/private-pages.d.ts +26 -0
- package/dist/plan/private-pages.js +39 -0
- 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 +47 -40
- package/dist/plan/worker-app-database.js +150 -152
- package/dist/plan/worker-app.d.ts +6 -65
- package/dist/plan/worker-app.js +29 -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/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 +11 -0
- 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 +10 -6
- package/dist/store/postgres.js +12 -2
- package/dist/store/schema.d.ts +37 -1
- package/dist/store/schema.js +4 -0
- package/dist/worker-app-config.d.ts +6 -10
- package/dist/worker-app-config.js +22 -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 +18 -1
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
import { randomBytes, randomUUID } from 'node:crypto';
|
|
2
|
+
import { ZITADEL_ORG_SCOPE_KEY } from './agora.js';
|
|
3
|
+
import { AUTH_REGISTRAR_KEY_ENV, AUTH_REGISTRAR_URL_ENV, customersOrgIdOf, registrarWanted, } from './boule.js';
|
|
4
|
+
import { loginHostnameFor } from './login-host.js';
|
|
5
|
+
import { brokerDeployRecordValue, missingBrokerInputs } from './platform-providers.js';
|
|
6
|
+
import { testCustomersOrgKey } from './workspace-client.js';
|
|
7
|
+
/**
|
|
8
|
+
* T3 (campaign 2026-10-20, apps-modules): a company with customers gets one registrar key at the sign-in
|
|
9
|
+
* broker, so its Boule can register the company's own apps there (`PUT /apps/:organizationId/:name`, auth ADR
|
|
10
|
+
* 0001 and 0003) without Archon. Foundry makes the key, holds it at {@link registrarRef}, writes the
|
|
11
|
+
* company's record into the broker's `AUTH_REGISTRARS` (`broker/src/apps.ts`'s `parseRegistrars`: the key
|
|
12
|
+
* itself, the company's sign-in organisation, its customers organisations, its login host) and gives Boule
|
|
13
|
+
* `AUTH_REGISTRAR_URL` (the broker's base, `<issuer>/api`, the address `@wtfalch/auth` already calls) and
|
|
14
|
+
* `AUTH_REGISTRAR_KEY`.
|
|
15
|
+
*
|
|
16
|
+
* Two steps deliver it, and either may run first: the Boule step (`coolify:boule`) puts the key in Boule's
|
|
17
|
+
* env, the registrar step (`coolify:broker-registrar`) puts the record in the broker's. Each mint gets an id
|
|
18
|
+
* (never the key) recorded at {@link REGISTRAR_MINTED_SCOPE_KEY}; each step records which mint it delivered,
|
|
19
|
+
* so a key made again reaches both places. The plan never holds the key.
|
|
20
|
+
*/
|
|
21
|
+
export const AUTH_REGISTRARS_ENV = 'AUTH_REGISTRARS';
|
|
22
|
+
export { AUTH_REGISTRAR_KEY_ENV, AUTH_REGISTRAR_URL_ENV, registrarUrl, registrarWanted, } from './boule.js';
|
|
23
|
+
/** The id of the held key's mint (a random id, not the key), set once it is held. Cleared when the key is forgotten. */
|
|
24
|
+
export const REGISTRAR_MINTED_SCOPE_KEY = 'registrar_minted';
|
|
25
|
+
/** The mint id whose key the last successful Boule env write carried. */
|
|
26
|
+
export const REGISTRAR_DELIVERED_SCOPE_KEY = 'registrar_delivered';
|
|
27
|
+
/** What the last successful broker write said (JSON of {@link RegistrarRecord} plus the mint id). Never the key. */
|
|
28
|
+
export const REGISTRAR_APPLIED_SCOPE_KEY = 'broker_registrar_applied';
|
|
29
|
+
/** The broker inputs that were missing when the record was written, so the broker was not deployed with it. */
|
|
30
|
+
export const REGISTRAR_DEPLOY_WAITS_SCOPE_KEY = 'broker_registrar_deploy_waits';
|
|
31
|
+
/** Why the last removal wrote nothing (another company's record the broker refuses), so the key was kept. Never a key. */
|
|
32
|
+
export const REGISTRAR_REMOVAL_HELD_SCOPE_KEY = 'broker_registrar_removal_held';
|
|
33
|
+
/** The held-key ref of a company's registrar key. */
|
|
34
|
+
export const registrarRef = (orgId) => `foundry:registrar:${orgId}`;
|
|
35
|
+
/** 32 random bytes, base64url: 43 characters, over the broker's 32-character floor. */
|
|
36
|
+
export const mintRegistrarKey = () => randomBytes(32).toString('base64url');
|
|
37
|
+
/**
|
|
38
|
+
* The company's registrar key: read from the held keys, else made and held first, and its mint id recorded
|
|
39
|
+
* and persisted before the key goes anywhere, so a crash after the `put` hands the same key out next time. A
|
|
40
|
+
* mint recorded but no longer held (F5, Q36: the held value was lost) is made again by this step, never by a
|
|
41
|
+
* person removing a record: the new mint id is recorded and persisted BEFORE the new key is held, so a crash
|
|
42
|
+
* between the two leaves a mint nobody was given and the next run makes another, and a crash after the `put`
|
|
43
|
+
* hands the held key out. The mint id differs from the lost one, so the Boule step and the broker step (each
|
|
44
|
+
* records which mint it delivered) both deliver the new key; the broker's write replaces the company's old
|
|
45
|
+
* record by its organisation. Errors name the ref, never the key. `secret` registers it for redaction; `log`
|
|
46
|
+
* gets one plain line when a lost key is made again.
|
|
47
|
+
*/
|
|
48
|
+
export async function ensureRegistrarKey(input) {
|
|
49
|
+
const ref = registrarRef(input.orgId);
|
|
50
|
+
const at = `the registrar key of ${input.slug}`;
|
|
51
|
+
const held = await input.heldKeys?.resolve(ref);
|
|
52
|
+
const recordedMint = input.recorded()[REGISTRAR_MINTED_SCOPE_KEY];
|
|
53
|
+
if (held) {
|
|
54
|
+
input.secret(held);
|
|
55
|
+
if (recordedMint)
|
|
56
|
+
return { key: held, mint: recordedMint };
|
|
57
|
+
// Held by a run that stopped before it recorded the mint: record one now, the key stays.
|
|
58
|
+
const mint = randomUUID();
|
|
59
|
+
input.record(REGISTRAR_MINTED_SCOPE_KEY, mint);
|
|
60
|
+
await input.persist();
|
|
61
|
+
return { key: held, mint };
|
|
62
|
+
}
|
|
63
|
+
if (typeof input.heldKeys?.put !== 'function') {
|
|
64
|
+
throw new Error(`${at}: the held keys cannot put it (ref ${ref})`);
|
|
65
|
+
}
|
|
66
|
+
const key = mintRegistrarKey();
|
|
67
|
+
input.secret(key);
|
|
68
|
+
const mint = randomUUID();
|
|
69
|
+
if (recordedMint) {
|
|
70
|
+
input.log?.(`${at} was made but is no longer held (ref ${ref}); making a new one, which Boule and the broker are given again`);
|
|
71
|
+
input.record(REGISTRAR_MINTED_SCOPE_KEY, mint);
|
|
72
|
+
await input.persist();
|
|
73
|
+
await input.heldKeys.put(ref, key);
|
|
74
|
+
return { key, mint };
|
|
75
|
+
}
|
|
76
|
+
await input.heldKeys.put(ref, key);
|
|
77
|
+
input.record(REGISTRAR_MINTED_SCOPE_KEY, mint);
|
|
78
|
+
await input.persist();
|
|
79
|
+
return { key, mint };
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* What the company's record says, or each input still missing and the step that records it. The customers
|
|
83
|
+
* organisation is an input only while `customers.enabled`: a company whose Boule runs apps (F5) without
|
|
84
|
+
* customers has a record with no customers, and the broker registers its apps against the sign-in organisation.
|
|
85
|
+
*/
|
|
86
|
+
export function wantedRegistrar(org, recorded) {
|
|
87
|
+
const organizationId = recorded[ZITADEL_ORG_SCOPE_KEY];
|
|
88
|
+
const hasCustomers = org.customers?.enabled === true;
|
|
89
|
+
const customersOrgId = hasCustomers ? customersOrgIdOf(org, recorded) : undefined;
|
|
90
|
+
const missing = [];
|
|
91
|
+
if (!organizationId) {
|
|
92
|
+
missing.push({
|
|
93
|
+
what: "the company's sign-in organisation id",
|
|
94
|
+
producedBy: ['zitadel', 'issuer'],
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
if (hasCustomers && !customersOrgId) {
|
|
98
|
+
missing.push({ what: 'the customers organisation id', producedBy: ['zitadel', 'customers'] });
|
|
99
|
+
}
|
|
100
|
+
if (!organizationId || (hasCustomers && !customersOrgId))
|
|
101
|
+
return { missing };
|
|
102
|
+
const testCustomersOrgId = hasCustomers ? recorded[testCustomersOrgKey(org.id)] : undefined;
|
|
103
|
+
return {
|
|
104
|
+
want: {
|
|
105
|
+
organizationId,
|
|
106
|
+
customers: [customersOrgId, testCustomersOrgId].filter((id) => Boolean(id)),
|
|
107
|
+
loginHost: loginHostnameFor(org),
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
/** The applied marker: the record and the mint it carries the key of. */
|
|
112
|
+
export const registrarAppliedValue = (record, mint) => JSON.stringify({ ...record, mint });
|
|
113
|
+
/** The organisation id the last broker write named, or null. */
|
|
114
|
+
export function appliedRegistrarOrganization(recorded) {
|
|
115
|
+
const raw = recorded[REGISTRAR_APPLIED_SCOPE_KEY];
|
|
116
|
+
if (!raw)
|
|
117
|
+
return null;
|
|
118
|
+
try {
|
|
119
|
+
const value = JSON.parse(raw);
|
|
120
|
+
return typeof value.organizationId === 'string' && value.organizationId
|
|
121
|
+
? value.organizationId
|
|
122
|
+
: null;
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const HOSTNAME_RE = /^(?=.{1,253}$)[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*(?::\d{1,5})?$/;
|
|
129
|
+
const LONE_SURROGATE = /\p{Surrogate}/u;
|
|
130
|
+
/**
|
|
131
|
+
* `null` when the broker's `parseRegistrars` takes `list`, else why not, naming the entry by its place and
|
|
132
|
+
* organisation, never its key. The broker stops at boot on a list it refuses, and then nobody signs in, so
|
|
133
|
+
* nothing is written that it would refuse.
|
|
134
|
+
*/
|
|
135
|
+
export function registrarsProblem(list,
|
|
136
|
+
/** How an entry is named; by default its place in `list`. */
|
|
137
|
+
labelOf = (index) => `AUTH_REGISTRARS[${index}]`) {
|
|
138
|
+
const seenOrganizations = new Set();
|
|
139
|
+
const seenKeys = new Set();
|
|
140
|
+
for (const [index, entry] of list.entries()) {
|
|
141
|
+
const label = labelOf(index);
|
|
142
|
+
if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
|
|
143
|
+
return `${label} is not an object`;
|
|
144
|
+
}
|
|
145
|
+
const r = entry;
|
|
146
|
+
if (typeof r.organizationId !== 'string' || !r.organizationId) {
|
|
147
|
+
return `${label} has no organizationId`;
|
|
148
|
+
}
|
|
149
|
+
const named = `${label} (organisation ${r.organizationId.slice(0, 64)})`;
|
|
150
|
+
if (typeof r.key !== 'string' || r.key.length < 32 || LONE_SURROGATE.test(r.key)) {
|
|
151
|
+
return `${named} has no usable key`;
|
|
152
|
+
}
|
|
153
|
+
if (r.customers !== undefined &&
|
|
154
|
+
(!Array.isArray(r.customers) || r.customers.some((id) => typeof id !== 'string' || !id))) {
|
|
155
|
+
return `${named} has customers that are not a list of organisation ids`;
|
|
156
|
+
}
|
|
157
|
+
if (r.loginHost !== undefined &&
|
|
158
|
+
(typeof r.loginHost !== 'string' || !HOSTNAME_RE.test(r.loginHost))) {
|
|
159
|
+
return `${named} has a loginHost that is not a bare host`;
|
|
160
|
+
}
|
|
161
|
+
const covered = [r.organizationId, ...(r.customers ?? [])];
|
|
162
|
+
if (new Set(covered).size !== covered.length)
|
|
163
|
+
return `${named} repeats an organisation`;
|
|
164
|
+
const clash = covered.find((id) => seenOrganizations.has(id));
|
|
165
|
+
if (clash)
|
|
166
|
+
return `${named} covers organisation ${clash}, which an earlier entry covers`;
|
|
167
|
+
if (seenKeys.has(r.key))
|
|
168
|
+
return `${named} repeats a key another entry uses`;
|
|
169
|
+
for (const id of covered)
|
|
170
|
+
seenOrganizations.add(id);
|
|
171
|
+
seenKeys.add(r.key);
|
|
172
|
+
}
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Puts the company's record (or, with `record` null, takes it away) in the broker's `AUTH_REGISTRARS`,
|
|
177
|
+
* beside every other company's record, which is copied back as it was. The company's own record is the one
|
|
178
|
+
* naming its organisation, the organisation the last write named, any of `ownOrganizationIds`, or the one
|
|
179
|
+
* carrying `ownKey`. The read, change, write and deploy run under the `platform` lease (`BrokerPort`'s
|
|
180
|
+
* `withPlatformLease`), and the value is read again after the write: a record not as written fails the
|
|
181
|
+
* step. Deploys the broker when the value changed and the broker has its other inputs; otherwise returns
|
|
182
|
+
* the inputs it waits for, and the broker reads the record on its next deploy. Errors name the company and
|
|
183
|
+
* the reason, never a key.
|
|
184
|
+
*/
|
|
185
|
+
export async function writeRegistrar(broker, input) {
|
|
186
|
+
const label = `${input.slug}'s registrar at the broker`;
|
|
187
|
+
if (!broker.setEnv) {
|
|
188
|
+
throw new Error(`${label}: the broker port cannot set ${AUTH_REGISTRARS_ENV}`);
|
|
189
|
+
}
|
|
190
|
+
if (!broker.withPlatformLease) {
|
|
191
|
+
input.log?.(`${label}: the broker port takes no platform lease, so ${AUTH_REGISTRARS_ENV} is written unleased and only checked after`);
|
|
192
|
+
return writeRegistrarHeld(broker, input, label, undefined);
|
|
193
|
+
}
|
|
194
|
+
const ran = await broker.withPlatformLease((fence) => writeRegistrarHeld(broker, input, label, fence));
|
|
195
|
+
if ('heldUntil' in ran) {
|
|
196
|
+
throw new Error(`${label}: another platform change holds the broker's env until ${ran.heldUntil.toISOString()}, so nothing was written; apply again`);
|
|
197
|
+
}
|
|
198
|
+
return ran.value;
|
|
199
|
+
}
|
|
200
|
+
/** The registrar list as read: the array, or why it is not one. */
|
|
201
|
+
function parseRegistrars(raw, label) {
|
|
202
|
+
let current = [];
|
|
203
|
+
if (raw) {
|
|
204
|
+
try {
|
|
205
|
+
current = JSON.parse(raw);
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
throw new Error(`${label}: the broker's ${AUTH_REGISTRARS_ENV} is not JSON, so nothing was written`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
if (!Array.isArray(current)) {
|
|
212
|
+
throw new Error(`${label}: the broker's ${AUTH_REGISTRARS_ENV} is not a JSON array, so nothing was written`);
|
|
213
|
+
}
|
|
214
|
+
return current;
|
|
215
|
+
}
|
|
216
|
+
const sameStrings = (a, b) => Array.isArray(a) && a.length === b.length && a.every((id, i) => id === b[i]);
|
|
217
|
+
async function writeRegistrarHeld(broker, input, label, fence) {
|
|
218
|
+
// Checked by the caller; bound to the port, which may be a class.
|
|
219
|
+
const setEnv = (key, value) => broker.setEnv.call(broker, key, value);
|
|
220
|
+
const brokerAppId = await broker.appId();
|
|
221
|
+
const envs = await broker.listEnvs();
|
|
222
|
+
const raw = envs.find((e) => e.key === AUTH_REGISTRARS_ENV && !e.is_preview)?.value;
|
|
223
|
+
const current = parseRegistrars(raw, label);
|
|
224
|
+
for (const existing of current) {
|
|
225
|
+
const key = existing?.key;
|
|
226
|
+
if (typeof key === 'string' && key)
|
|
227
|
+
input.secret?.(key);
|
|
228
|
+
}
|
|
229
|
+
if (input.record)
|
|
230
|
+
input.secret?.(input.record.key);
|
|
231
|
+
if (input.ownKey)
|
|
232
|
+
input.secret?.(input.ownKey);
|
|
233
|
+
const own = new Set([
|
|
234
|
+
input.record?.organizationId,
|
|
235
|
+
input.previousOrganizationId,
|
|
236
|
+
...(input.ownOrganizationIds ?? []),
|
|
237
|
+
].filter((id) => Boolean(id)));
|
|
238
|
+
const ownKeys = new Set([input.record?.key, input.ownKey].filter((key) => Boolean(key)));
|
|
239
|
+
const isOwn = (entry) => {
|
|
240
|
+
if (typeof entry !== 'object' || entry === null)
|
|
241
|
+
return false;
|
|
242
|
+
const { organizationId, key } = entry;
|
|
243
|
+
return ((typeof organizationId === 'string' && own.has(organizationId)) ||
|
|
244
|
+
(typeof key === 'string' && ownKeys.has(key)));
|
|
245
|
+
};
|
|
246
|
+
const entry = input.record
|
|
247
|
+
? {
|
|
248
|
+
organizationId: input.record.organizationId,
|
|
249
|
+
key: input.record.key,
|
|
250
|
+
customers: [...input.record.customers],
|
|
251
|
+
loginHost: input.record.loginHost,
|
|
252
|
+
}
|
|
253
|
+
: null;
|
|
254
|
+
const next = [];
|
|
255
|
+
/** Each kept entry's place in the live list; null for the company's own new record. */
|
|
256
|
+
const places = [];
|
|
257
|
+
let placed = false;
|
|
258
|
+
for (const [index, existing] of current.entries()) {
|
|
259
|
+
if (!isOwn(existing)) {
|
|
260
|
+
next.push(existing);
|
|
261
|
+
places.push(index);
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
const existingKey = existing.key;
|
|
265
|
+
if (typeof existingKey !== 'string' || !ownKeys.has(existingKey)) {
|
|
266
|
+
const organizationId = String(existing.organizationId).slice(0, 64);
|
|
267
|
+
input.log?.(`${label}: ${entry ? 'replaced' : 'took away'} ${AUTH_REGISTRARS_ENV}[${index}] (organisation ${organizationId}), a record whose key is not the company's held key`);
|
|
268
|
+
}
|
|
269
|
+
if (entry && !placed) {
|
|
270
|
+
next.push(entry);
|
|
271
|
+
places.push(null);
|
|
272
|
+
}
|
|
273
|
+
placed = true;
|
|
274
|
+
}
|
|
275
|
+
if (entry && !placed) {
|
|
276
|
+
next.push(entry);
|
|
277
|
+
places.push(null);
|
|
278
|
+
}
|
|
279
|
+
const labelOf = (index) => {
|
|
280
|
+
const place = places[index];
|
|
281
|
+
return place === null || place === undefined
|
|
282
|
+
? `${input.slug}'s own record`
|
|
283
|
+
: `${AUTH_REGISTRARS_ENV}[${place}]`;
|
|
284
|
+
};
|
|
285
|
+
const problem = registrarsProblem(next, labelOf);
|
|
286
|
+
if (problem !== null) {
|
|
287
|
+
// A removal leaves only other companies' records, so the problem is theirs: the broker refuses that list
|
|
288
|
+
// with or without this record. Nothing is written (a removal must not be what makes it worse), and the
|
|
289
|
+
// caller keeps the key, since the broker list still holds the record.
|
|
290
|
+
if (!entry) {
|
|
291
|
+
return {
|
|
292
|
+
changed: false,
|
|
293
|
+
waits: [],
|
|
294
|
+
removalHeld: `the broker's ${AUTH_REGISTRARS_ENV} holds a record it refuses, so nothing was written: ${problem}`,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
throw new Error(`${label} is not written: the broker would refuse its ${AUTH_REGISTRARS_ENV} and not start (${problem})`);
|
|
298
|
+
}
|
|
299
|
+
const nextRaw = next.length === 0 ? '[]' : JSON.stringify(next);
|
|
300
|
+
const changed = nextRaw !== (raw || '[]');
|
|
301
|
+
if (!changed)
|
|
302
|
+
return { changed, waits: [] };
|
|
303
|
+
try {
|
|
304
|
+
await setEnv(AUTH_REGISTRARS_ENV, nextRaw);
|
|
305
|
+
}
|
|
306
|
+
catch {
|
|
307
|
+
// Fixed text: a Coolify error can echo the value, which holds every company's key.
|
|
308
|
+
throw new Error(`${label}: the broker's ${AUTH_REGISTRARS_ENV} could not be written; apply again`);
|
|
309
|
+
}
|
|
310
|
+
// Defence in depth beside the lease: a writer that holds no lease (a person, an older foundry) may have
|
|
311
|
+
// written over this one. The company's own record must read back as written.
|
|
312
|
+
const after = await broker.listEnvs();
|
|
313
|
+
const written = after.find((e) => e.key === AUTH_REGISTRARS_ENV && !e.is_preview)?.value;
|
|
314
|
+
let readBack = null;
|
|
315
|
+
try {
|
|
316
|
+
readBack = parseRegistrars(written, label);
|
|
317
|
+
}
|
|
318
|
+
catch {
|
|
319
|
+
readBack = null;
|
|
320
|
+
}
|
|
321
|
+
const mine = readBack?.filter(isOwn) ?? null;
|
|
322
|
+
const asWritten = mine !== null &&
|
|
323
|
+
(entry === null
|
|
324
|
+
? mine.length === 0
|
|
325
|
+
: mine.length === 1 &&
|
|
326
|
+
mine[0].organizationId === entry.organizationId &&
|
|
327
|
+
mine[0].key === entry.key &&
|
|
328
|
+
mine[0].loginHost === entry.loginHost &&
|
|
329
|
+
sameStrings(mine[0].customers, entry.customers));
|
|
330
|
+
if (!asWritten) {
|
|
331
|
+
throw new Error(`${label}: the broker's ${AUTH_REGISTRARS_ENV} did not read back with the company's record ${entry ? 'as written' : 'taken away'} (something else wrote it at the same time), so the broker was not deployed; apply again`);
|
|
332
|
+
}
|
|
333
|
+
// The broker reads AUTH_REGISTRARS at start only. It cannot start without its other inputs; then the
|
|
334
|
+
// platform's own deploy, once they land, reads the record.
|
|
335
|
+
const waits = (await missingBrokerInputs({ listEnvs: async () => after }, brokerAppId)).filter((missing) => missing !== 'AUTH_APPS');
|
|
336
|
+
if (waits.length > 0)
|
|
337
|
+
return { changed, waits };
|
|
338
|
+
await broker.deploy();
|
|
339
|
+
const authApps = after.find((e) => e.key === 'AUTH_APPS' && !e.is_preview)?.value;
|
|
340
|
+
// This deploy carried the live AUTH_APPS too: record it, as an AUTH_APPS write does, so the platform's
|
|
341
|
+
// next apply does not read it as drift and deploy the broker again. Under the lease held here.
|
|
342
|
+
await broker.recordDeploy(brokerDeployRecordValue(brokerAppId, authApps), fence);
|
|
343
|
+
return { changed, waits };
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* The registrar for an org: whether the Boule step delivers the key (`deliver`), the broker step, and every
|
|
347
|
+
* reason either is held back as a plan line. Nothing at all for a company that never had customers, so
|
|
348
|
+
* its plan is unchanged.
|
|
349
|
+
*/
|
|
350
|
+
export function planBrokerRegistrar(org, recorded, plannedSteps, host) {
|
|
351
|
+
const at = `${org.slug}'s registrar key at the sign-in broker`;
|
|
352
|
+
const blocked = [];
|
|
353
|
+
const draft = (desired, description) => ({
|
|
354
|
+
action: { system: 'coolify', kind: 'broker-registrar', key: org.id, desired },
|
|
355
|
+
step: { description, destroys: false, migrates: false },
|
|
356
|
+
});
|
|
357
|
+
const waits = recorded[REGISTRAR_DEPLOY_WAITS_SCOPE_KEY];
|
|
358
|
+
if (waits) {
|
|
359
|
+
blocked.push(`${at} is in the broker's ${AUTH_REGISTRARS_ENV}, but the broker was not deployed with it: it has no ${waits} yet; it reads the record on its next deploy`);
|
|
360
|
+
}
|
|
361
|
+
if (!registrarWanted(org)) {
|
|
362
|
+
const left = Boolean(recorded[REGISTRAR_APPLIED_SCOPE_KEY]) ||
|
|
363
|
+
Boolean(recorded[REGISTRAR_MINTED_SCOPE_KEY]);
|
|
364
|
+
if (!left)
|
|
365
|
+
return { deliver: false, keepBouleEnv: false, step: null, blocked };
|
|
366
|
+
if (!host.brokerWired) {
|
|
367
|
+
blocked.push(`${at} is not removed: the company has no customers and its Boule runs no apps, but the host has not wired the broker port; Boule keeps its registrar env until it is`);
|
|
368
|
+
return { deliver: false, keepBouleEnv: true, step: null, blocked };
|
|
369
|
+
}
|
|
370
|
+
const held = recorded[REGISTRAR_REMOVAL_HELD_SCOPE_KEY];
|
|
371
|
+
if (held) {
|
|
372
|
+
blocked.push(`${at} is not removed and its key is kept: ${held}; fix or remove that record, and the next apply takes this one away`);
|
|
373
|
+
}
|
|
374
|
+
return {
|
|
375
|
+
deliver: false,
|
|
376
|
+
// Boule keeps the key while the broker still holds the record the removal could not take away.
|
|
377
|
+
keepBouleEnv: Boolean(held),
|
|
378
|
+
step: draft({ remove: true }, `remove ${org.slug}'s registrar from the sign-in broker and forget its key (no customers and no apps)`),
|
|
379
|
+
blocked,
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
if (!host.canHoldSecrets) {
|
|
383
|
+
blocked.push(`${at} is blocked, not planned: it needs a held-key store that can put, and the host has none`);
|
|
384
|
+
return { deliver: false, keepBouleEnv: false, step: null, blocked };
|
|
385
|
+
}
|
|
386
|
+
if (!host.brokerWired) {
|
|
387
|
+
blocked.push(`${at} is blocked, not planned: the host has not wired the broker port`);
|
|
388
|
+
return { deliver: false, keepBouleEnv: false, step: null, blocked };
|
|
389
|
+
}
|
|
390
|
+
// Boule is given the key only with the broker's address (`AUTH_REGISTRAR_URL`, from the issuer).
|
|
391
|
+
const deliver = host.issuerKnown;
|
|
392
|
+
if (!deliver) {
|
|
393
|
+
blocked.push(`${at} is not given to Boule yet: the issuer is not known, so ${AUTH_REGISTRAR_URL_ENV} cannot be given with it`);
|
|
394
|
+
}
|
|
395
|
+
const wanted = wantedRegistrar(org, recorded);
|
|
396
|
+
if ('missing' in wanted) {
|
|
397
|
+
const unplanned = wanted.missing.filter(({ producedBy: [system, kind] }) => !plannedSteps.some((p) => p.action.system === system && p.action.kind === kind));
|
|
398
|
+
if (unplanned.length > 0) {
|
|
399
|
+
blocked.push(`${at} is blocked, not planned: ${unplanned.map((m) => m.what).join(', ')} not recorded yet`);
|
|
400
|
+
return { deliver, keepBouleEnv: false, step: null, blocked };
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
else {
|
|
404
|
+
const mint = recorded[REGISTRAR_MINTED_SCOPE_KEY];
|
|
405
|
+
if (mint &&
|
|
406
|
+
recorded[REGISTRAR_APPLIED_SCOPE_KEY] === registrarAppliedValue(wanted.want, mint)) {
|
|
407
|
+
return { deliver, keepBouleEnv: false, step: null, blocked };
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
return {
|
|
411
|
+
deliver,
|
|
412
|
+
keepBouleEnv: false,
|
|
413
|
+
step: draft({}, `write ${org.slug}'s registrar into the sign-in broker's ${AUTH_REGISTRARS_ENV}`),
|
|
414
|
+
blocked,
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* The Boule env drift the registrar adds: the key missing, or not the mint the held key is (made again since
|
|
419
|
+
* Boule got it). And, once customers are off, the rows foundry wrote, which the Boule step takes away.
|
|
420
|
+
*/
|
|
421
|
+
export function bouleRegistrarDrift(org, recorded, liveEnv, deliver,
|
|
422
|
+
/** {@link planBrokerRegistrar}'s `keepBouleEnv`: the record stays at the broker, so Boule keeps the key. */
|
|
423
|
+
keep = false) {
|
|
424
|
+
if (deliver) {
|
|
425
|
+
const mint = recorded[REGISTRAR_MINTED_SCOPE_KEY];
|
|
426
|
+
if (!liveEnv[AUTH_REGISTRAR_KEY_ENV] ||
|
|
427
|
+
!mint ||
|
|
428
|
+
recorded[REGISTRAR_DELIVERED_SCOPE_KEY] !== mint) {
|
|
429
|
+
return [AUTH_REGISTRAR_KEY_ENV];
|
|
430
|
+
}
|
|
431
|
+
return [];
|
|
432
|
+
}
|
|
433
|
+
if (!registrarWanted(org) &&
|
|
434
|
+
!keep &&
|
|
435
|
+
recorded[REGISTRAR_DELIVERED_SCOPE_KEY] &&
|
|
436
|
+
(liveEnv[AUTH_REGISTRAR_KEY_ENV] !== undefined || liveEnv[AUTH_REGISTRAR_URL_ENV] !== undefined)) {
|
|
437
|
+
return [AUTH_REGISTRAR_KEY_ENV];
|
|
438
|
+
}
|
|
439
|
+
return [];
|
|
440
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { CoolifyPort } from './executor.js';
|
|
2
|
+
import type { ServiceTarget } from './own-services.js';
|
|
3
|
+
/**
|
|
4
|
+
* The database of the auth broker's customer workspaces (`WORKSPACES_DATABASE_URL`), made by foundry on the
|
|
5
|
+
* Coolify server of the broker's own stack. It is a Coolify Postgres database `<owner>-workspaces-db` in the
|
|
6
|
+
* broker's project; its URL is held at `ServiceTarget.refs.brokerWorkspacesDatabase`, where an operator-held
|
|
7
|
+
* URL always was, and the broker-entry step writes it to the broker from there. A URL that is already held is
|
|
8
|
+
* adopted: nothing is made.
|
|
9
|
+
*/
|
|
10
|
+
/** One Coolify call of this step: the client has no timeout of its own. */
|
|
11
|
+
export declare const WORKSPACES_DATABASE_CALL_TIMEOUT_MS = 60000;
|
|
12
|
+
/** The wait for a new database to start (`getDatabaseStarted` polls up to two minutes, then a grace). */
|
|
13
|
+
export declare const WORKSPACES_DATABASE_START_TIMEOUT_MS = 300000;
|
|
14
|
+
/** The org-scope id that records the database's Coolify id (the id only; never the URL). */
|
|
15
|
+
export declare const BROKER_WORKSPACES_DATABASE_SCOPE_KEY = "broker_workspaces_database";
|
|
16
|
+
/** The Postgres database and role names inside it. */
|
|
17
|
+
export declare const WORKSPACES_DATABASE_NAME = "workspaces";
|
|
18
|
+
export type WorkspacesDatabaseCoolify = Pick<CoolifyPort, 'findOrCreateEnvironment' | 'listServers' | 'createPostgresDatabase' | 'getDatabase' | 'raw'> &
|
|
19
|
+
/** Absent on a company's own Coolify slice: the recorded id is then the only way back to a database made before. */
|
|
20
|
+
Partial<Pick<CoolifyPort, 'listDatabases'>>;
|
|
21
|
+
export interface WorkspacesDatabaseHeldKeys {
|
|
22
|
+
resolve(ref: string): Promise<string | null | undefined>;
|
|
23
|
+
put?(ref: string, value: string): Promise<void>;
|
|
24
|
+
}
|
|
25
|
+
/** Whether the host can hold a URL: only then does foundry make the database, otherwise the operator holds one. */
|
|
26
|
+
export declare const canHoldWorkspacesDatabase: (heldKeys: WorkspacesDatabaseHeldKeys | undefined) => heldKeys is WorkspacesDatabaseHeldKeys & Required<Pick<WorkspacesDatabaseHeldKeys, "put">>;
|
|
27
|
+
/**
|
|
28
|
+
* Makes the database when no URL is held at the ref, and holds its URL there. Idempotent: a held URL is left
|
|
29
|
+
* alone (adopted), a database already made under the name is found, not made again, and a crash between the
|
|
30
|
+
* create and the hold leaves an empty database the next run finds and holds. Errors name the ref and the
|
|
31
|
+
* database's id, never the URL. `secret` registers the URL and its password for redaction before either leaves
|
|
32
|
+
* this function.
|
|
33
|
+
*/
|
|
34
|
+
export declare function ensureBrokerWorkspacesDatabase(input: {
|
|
35
|
+
target: ServiceTarget;
|
|
36
|
+
coolify: WorkspacesDatabaseCoolify;
|
|
37
|
+
/** The broker's Coolify project, or `null` when there is none (the stack is not there yet). */
|
|
38
|
+
projectUuid: () => Promise<string | null>;
|
|
39
|
+
heldKeys: WorkspacesDatabaseHeldKeys | undefined;
|
|
40
|
+
/** The database's Coolify id as recorded by an earlier run, if any. */
|
|
41
|
+
knownUuid?: string;
|
|
42
|
+
/** Records the id of a database just made, and persists it, before anything else is done with it. */
|
|
43
|
+
recordUuid: (uuid: string) => Promise<void>;
|
|
44
|
+
secret: (value: string) => void;
|
|
45
|
+
startTimeoutMs?: number;
|
|
46
|
+
}): Promise<{
|
|
47
|
+
adopted: boolean;
|
|
48
|
+
}>;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { withTimeout } from './call-timeout.js';
|
|
2
|
+
import { heldWorkspacesDatabase } from './entry-workspaces.js';
|
|
3
|
+
import { getDatabaseStarted } from './executor.js';
|
|
4
|
+
import { resolvePlatformServer } from './platform-providers.js';
|
|
5
|
+
/**
|
|
6
|
+
* The database of the auth broker's customer workspaces (`WORKSPACES_DATABASE_URL`), made by foundry on the
|
|
7
|
+
* Coolify server of the broker's own stack. It is a Coolify Postgres database `<owner>-workspaces-db` in the
|
|
8
|
+
* broker's project; its URL is held at `ServiceTarget.refs.brokerWorkspacesDatabase`, where an operator-held
|
|
9
|
+
* URL always was, and the broker-entry step writes it to the broker from there. A URL that is already held is
|
|
10
|
+
* adopted: nothing is made.
|
|
11
|
+
*/
|
|
12
|
+
/** One Coolify call of this step: the client has no timeout of its own. */
|
|
13
|
+
export const WORKSPACES_DATABASE_CALL_TIMEOUT_MS = 60_000;
|
|
14
|
+
/** The wait for a new database to start (`getDatabaseStarted` polls up to two minutes, then a grace). */
|
|
15
|
+
export const WORKSPACES_DATABASE_START_TIMEOUT_MS = 300_000;
|
|
16
|
+
/** The org-scope id that records the database's Coolify id (the id only; never the URL). */
|
|
17
|
+
export const BROKER_WORKSPACES_DATABASE_SCOPE_KEY = 'broker_workspaces_database';
|
|
18
|
+
/** The Postgres database and role names inside it. */
|
|
19
|
+
export const WORKSPACES_DATABASE_NAME = 'workspaces';
|
|
20
|
+
/** Whether the host can hold a URL: only then does foundry make the database, otherwise the operator holds one. */
|
|
21
|
+
export const canHoldWorkspacesDatabase = (heldKeys) => typeof heldKeys?.put === 'function';
|
|
22
|
+
/**
|
|
23
|
+
* Makes the database when no URL is held at the ref, and holds its URL there. Idempotent: a held URL is left
|
|
24
|
+
* alone (adopted), a database already made under the name is found, not made again, and a crash between the
|
|
25
|
+
* create and the hold leaves an empty database the next run finds and holds. Errors name the ref and the
|
|
26
|
+
* database's id, never the URL. `secret` registers the URL and its password for redaction before either leaves
|
|
27
|
+
* this function.
|
|
28
|
+
*/
|
|
29
|
+
export async function ensureBrokerWorkspacesDatabase(input) {
|
|
30
|
+
const { target, coolify, heldKeys } = input;
|
|
31
|
+
const ref = target.refs.brokerWorkspacesDatabase;
|
|
32
|
+
const at = `coolify:workspaces-database for ${target.id}`;
|
|
33
|
+
if (!canHoldWorkspacesDatabase(heldKeys)) {
|
|
34
|
+
throw new Error(`${at}: the held keys cannot put the database URL at ${ref}`);
|
|
35
|
+
}
|
|
36
|
+
const held = await withTimeout(`${at}: read ${ref}`, WORKSPACES_DATABASE_CALL_TIMEOUT_MS, () => heldWorkspacesDatabase(heldKeys, ref));
|
|
37
|
+
if (held) {
|
|
38
|
+
input.secret(held);
|
|
39
|
+
return { adopted: true };
|
|
40
|
+
}
|
|
41
|
+
const call = (what, work) => withTimeout(`${at}: ${what}`, WORKSPACES_DATABASE_CALL_TIMEOUT_MS, work);
|
|
42
|
+
const projectUuid = await call('find the project', input.projectUuid);
|
|
43
|
+
if (!projectUuid)
|
|
44
|
+
throw new Error(`${at}: the broker's Coolify project does not exist yet`);
|
|
45
|
+
const { environment } = await call('find the environment', () => coolify.findOrCreateEnvironment(projectUuid, 'production'));
|
|
46
|
+
const name = target.names.workspacesDb;
|
|
47
|
+
let uuid = input.knownUuid;
|
|
48
|
+
if (!uuid && coolify.listDatabases) {
|
|
49
|
+
const list = coolify.listDatabases.bind(coolify);
|
|
50
|
+
uuid = (await call('list the databases', () => list())).find((d) => d.name === name && d.environment_id === environment.id)?.uuid;
|
|
51
|
+
}
|
|
52
|
+
if (!uuid) {
|
|
53
|
+
const server = await call('list the servers', () => resolvePlatformServer(coolify));
|
|
54
|
+
uuid = (await call('create the database', () => coolify.createPostgresDatabase({
|
|
55
|
+
project_uuid: projectUuid,
|
|
56
|
+
server_uuid: server.uuid,
|
|
57
|
+
environment_name: environment.name,
|
|
58
|
+
environment_uuid: environment.uuid,
|
|
59
|
+
name,
|
|
60
|
+
postgres_db: WORKSPACES_DATABASE_NAME,
|
|
61
|
+
postgres_user: WORKSPACES_DATABASE_NAME,
|
|
62
|
+
}))).uuid;
|
|
63
|
+
}
|
|
64
|
+
if (uuid !== input.knownUuid)
|
|
65
|
+
await input.recordUuid(uuid);
|
|
66
|
+
const db = await withTimeout(`${at}: start database ${uuid}`, input.startTimeoutMs ?? WORKSPACES_DATABASE_START_TIMEOUT_MS, () => getDatabaseStarted(coolify, uuid));
|
|
67
|
+
const url = db.internal_db_url;
|
|
68
|
+
if (!url) {
|
|
69
|
+
throw new Error(`${at}: database ${uuid} has no internal_db_url yet; apply again once Coolify reports one`);
|
|
70
|
+
}
|
|
71
|
+
input.secret(url);
|
|
72
|
+
let protocol = '';
|
|
73
|
+
try {
|
|
74
|
+
const parsed = new URL(url);
|
|
75
|
+
protocol = parsed.protocol;
|
|
76
|
+
if (parsed.password)
|
|
77
|
+
input.secret(decodeURIComponent(parsed.password));
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
protocol = '';
|
|
81
|
+
}
|
|
82
|
+
if (protocol !== 'postgres:' && protocol !== 'postgresql:') {
|
|
83
|
+
throw new Error(`${at}: database ${uuid} reports no postgres:// URL`);
|
|
84
|
+
}
|
|
85
|
+
await call(`hold the URL at ${ref}`, () => heldKeys.put(ref, url));
|
|
86
|
+
return { adopted: false };
|
|
87
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { agoraHostnameFor } from './agora.js';
|
|
2
2
|
import { bouleHostnameFor } from './boule.js';
|
|
3
3
|
import { appHostname, cmsStudioHostnameFor, entryHostnames } from './hostnames.js';
|
|
4
|
+
import { workspaceAuthEntryOf, workspaceClientHosts } from './workspace-client.js';
|
|
4
5
|
/**
|
|
5
6
|
* #339: the desktop app signs in through the company's web app, so every
|
|
6
7
|
* `@wtfalch/auth` web client foundry makes registers this path beside
|
|
@@ -24,15 +25,19 @@ export function signInRedirectUris(org, appKey) {
|
|
|
24
25
|
if (appKey === 'agora')
|
|
25
26
|
return [`https://${agoraHostnameFor(org)}/callback`];
|
|
26
27
|
const authEntry = org.hosting.entries.find((e) => e.key === appKey && e.uses?.auth);
|
|
28
|
+
// A workspace's own client `<key>-work` signs in at the workspace host.
|
|
29
|
+
const workspaceEntry = authEntry ? undefined : workspaceAuthEntryOf(org, appKey);
|
|
27
30
|
const hosts = authEntry
|
|
28
31
|
? entryHostnames(org, authEntry)
|
|
29
|
-
:
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
?
|
|
34
|
-
:
|
|
35
|
-
|
|
32
|
+
: workspaceEntry
|
|
33
|
+
? workspaceClientHosts(org, workspaceEntry)
|
|
34
|
+
: [
|
|
35
|
+
appKey === 'cms' && org.site.enabled
|
|
36
|
+
? cmsStudioHostnameFor(org)
|
|
37
|
+
: appKey === 'boule'
|
|
38
|
+
? bouleHostnameFor(org)
|
|
39
|
+
: appHostname(org),
|
|
40
|
+
];
|
|
36
41
|
return [
|
|
37
42
|
...hosts.map((host) => `https://${host}/auth/callback`),
|
|
38
43
|
...hosts.map(desktopCallbackUri),
|
package/dist/plan/diff.d.ts
CHANGED
|
@@ -65,6 +65,10 @@ export interface PlanOrgOptions {
|
|
|
65
65
|
zitadelOrgMetadata?: boolean;
|
|
66
66
|
/** F5: the executor was built with a `broker` port. */
|
|
67
67
|
broker?: boolean;
|
|
68
|
+
/** The broker the org's entries are written to has `WORKSPACES_DATABASE_URL`, or a URL is held to give it. Absent reads as false. */
|
|
69
|
+
brokerWorkspacesDatabase?: boolean;
|
|
70
|
+
/** F5b: the broker has none, and the host can make the database and hold its URL (`canProvisionWorkspacesDatabase`). Absent reads as false. */
|
|
71
|
+
provisionWorkspacesDatabase?: boolean;
|
|
68
72
|
/** #161: the slugs of the other declared orgs, so a Boule whose hostname another org's slug would take is blocked. */
|
|
69
73
|
otherSlugs?: readonly string[];
|
|
70
74
|
/** F6a, F6b: the clock `planEntryAiKeys` and `planFilesKeys` read for the renewal window. Default `Date.now`. */
|
|
@@ -77,6 +81,8 @@ export interface PlanOrgOptions {
|
|
|
77
81
|
workerApps?: boolean;
|
|
78
82
|
/** ADR 0002: the keys service address, when the executor has a keys port. */
|
|
79
83
|
workerAppKeysUrl?: string;
|
|
84
|
+
/** F7: the keys service's registrar address, when the executor has a `KeysRegistrarPort`. */
|
|
85
|
+
keysRegistrarUrl?: string;
|
|
80
86
|
/** C2: true when the executor was built with a `WorkerAppDatabasePort`; a `workerApp.database` is blocked without it. */
|
|
81
87
|
workerAppDatabase?: boolean;
|
|
82
88
|
/** ADR 0002: the platform's Cloudflare account id; a container image must live in its registry. */
|
|
@@ -212,7 +218,7 @@ export declare function toPlan(detail: PlanDetail): Plan;
|
|
|
212
218
|
* Company Factory end to end", which wires real readers and executors
|
|
213
219
|
* against this seam.
|
|
214
220
|
*/
|
|
215
|
-
export declare function computePlanDetail(
|
|
221
|
+
export declare function computePlanDetail(declaredOrg: DeclaredOrg, live: LiveResource[], recordedIds?: Readonly<Record<string, string>>, agoraHost?: AgoraHostReading, jobsAvailable?: boolean, platformReadiness?: OrgPlatformReadiness, options?: PlanOrgOptions): PlanDetail;
|
|
216
222
|
/** True when `org` is the platform's owner company. */
|
|
217
223
|
export declare function isPlatformOwnOrg(org: Pick<DeclaredOrg, 'id'>, platformOwnerOrgId: string | null): boolean;
|
|
218
224
|
/**
|
|
@@ -280,7 +286,7 @@ export declare const DEFAULT_AI_LIMIT_MICROS = "112250000";
|
|
|
280
286
|
* record blocks it (rule 2). A foreign broker entry the last write found
|
|
281
287
|
* invalid is listed as a warning: the broker drops it, foundry keeps it.
|
|
282
288
|
*/
|
|
283
|
-
export declare function planBrokerEntries(org: DeclaredOrg, recordedIds: Readonly<Record<string, string>>, plannedSteps: readonly DraftStep[], brokerWired: boolean): {
|
|
289
|
+
export declare function planBrokerEntries(org: DeclaredOrg, recordedIds: Readonly<Record<string, string>>, plannedSteps: readonly DraftStep[], brokerWired: boolean, brokerWorkspacesDatabase: boolean): {
|
|
284
290
|
steps: DraftStep[];
|
|
285
291
|
blocked: UnmanagedItem[];
|
|
286
292
|
};
|