@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
|
@@ -86,6 +86,12 @@ export interface AccessPort {
|
|
|
86
86
|
id: string;
|
|
87
87
|
}>;
|
|
88
88
|
replaceGateHostnames(gateId: string, hostnames: string[]): Promise<void>;
|
|
89
|
+
/**
|
|
90
|
+
* Replaces the whole list of people who pass a gate; nothing else about the gate changes. Edge's platform
|
|
91
|
+
* client has no such call yet, so {@link createEdgeAccess} does not implement it: a host that wants a
|
|
92
|
+
* workspace's testers to change supplies a port that does (edge's company `gates.setPeople`).
|
|
93
|
+
*/
|
|
94
|
+
setGatePeople?(gateId: string, emails: string[]): Promise<void>;
|
|
89
95
|
/** Deletes the gate with its application, policy and list; a gate already gone is done. */
|
|
90
96
|
deleteGate(gateId: string): Promise<void>;
|
|
91
97
|
/** The Cloudflare Access team address (`<team>.cloudflareaccess.com`); rejects with `not_configured` while the Cloudflare token lacks an Access right. */
|
package/dist/plan/edge-access.js
CHANGED
|
@@ -35,6 +35,7 @@ export function createEdgeAccess(client, options = {}) {
|
|
|
35
35
|
createGate: (input) => retrying(() => client.createGate(input)),
|
|
36
36
|
replaceGateHostnames: (id, input) => retrying(() => client.replaceGateHostnames(id, input)),
|
|
37
37
|
deleteGate: (id) => retrying(() => client.deleteGate(id)),
|
|
38
|
+
setGatePeople: (id, input) => retrying(() => client.setGatePeople(id, input)),
|
|
38
39
|
};
|
|
39
40
|
const writeSlot = options.writeSlot ?? limiter(EDGE_DNS_WRITE_CONCURRENCY);
|
|
40
41
|
/** app id -> a seen live app with no edge row yet. */
|
package/dist/plan/edge-dns.d.ts
CHANGED
|
@@ -15,6 +15,12 @@ export interface DnsRecordInput {
|
|
|
15
15
|
ttl?: number;
|
|
16
16
|
/** Required for an `MX` record. */
|
|
17
17
|
priority?: number;
|
|
18
|
+
/**
|
|
19
|
+
* The company the record is written for, sent on create and on the adopt that
|
|
20
|
+
* precedes an update. Edge ties a path gate on a platform-zone host to the
|
|
21
|
+
* organisation its record was written for. Omitted: the record is the platform's.
|
|
22
|
+
*/
|
|
23
|
+
organisationId?: string;
|
|
18
24
|
}
|
|
19
25
|
/**
|
|
20
26
|
* Foundry's DNS calls. Every record call in foundry goes through this; the one
|
package/dist/plan/edge-dns.js
CHANGED
|
@@ -129,9 +129,10 @@ export function createEdgeDns(client, options = {}) {
|
|
|
129
129
|
}
|
|
130
130
|
unadopted.set(zone, pending);
|
|
131
131
|
};
|
|
132
|
-
const adopt = async (zone, record) => {
|
|
132
|
+
const adopt = async (zone, record, organisationId) => {
|
|
133
133
|
const result = await writeSlot(() => dns.adopt({
|
|
134
134
|
zone,
|
|
135
|
+
...(organisationId === undefined ? {} : { organisationId }),
|
|
135
136
|
records: [
|
|
136
137
|
{
|
|
137
138
|
name: record.name,
|
|
@@ -150,14 +151,14 @@ export function createEdgeDns(client, options = {}) {
|
|
|
150
151
|
unadopted.get(zone)?.delete(record.id);
|
|
151
152
|
};
|
|
152
153
|
/** Adopts `recordId` when it was seen without an edge row. */
|
|
153
|
-
const ensureAdopted = async (zone, recordId) => {
|
|
154
|
+
const ensureAdopted = async (zone, recordId, organisationId) => {
|
|
154
155
|
const record = unadopted.get(zone)?.get(recordId);
|
|
155
156
|
if (record)
|
|
156
|
-
await adopt(zone, record);
|
|
157
|
+
await adopt(zone, record, organisationId);
|
|
157
158
|
};
|
|
158
159
|
/** Runs a write; on `not_found` adopts the live record once and retries. */
|
|
159
|
-
const writeExisting = async (zone, recordId, write) => {
|
|
160
|
-
await ensureAdopted(zone, recordId);
|
|
160
|
+
const writeExisting = async (zone, recordId, write, organisationId) => {
|
|
161
|
+
await ensureAdopted(zone, recordId, organisationId);
|
|
161
162
|
// `forbidden` also answers a revoked or under-scoped platform key, so the
|
|
162
163
|
// record is listed again and the company message is used only when its
|
|
163
164
|
// owner really is an organisation; anything else is the original error.
|
|
@@ -192,7 +193,7 @@ export function createEdgeDns(client, options = {}) {
|
|
|
192
193
|
const record = live.find((r) => r.id === recordId);
|
|
193
194
|
if (!record || record.owner !== null)
|
|
194
195
|
throw error;
|
|
195
|
-
await adopt(zone, record);
|
|
196
|
+
await adopt(zone, record, organisationId);
|
|
196
197
|
try {
|
|
197
198
|
return await writeSlot(write);
|
|
198
199
|
}
|
|
@@ -225,11 +226,12 @@ export function createEdgeDns(client, options = {}) {
|
|
|
225
226
|
name: rec.name,
|
|
226
227
|
type: rec.type,
|
|
227
228
|
...body(rec),
|
|
229
|
+
...(rec.organisationId === undefined ? {} : { organisationId: rec.organisationId }),
|
|
228
230
|
}));
|
|
229
231
|
return { id: created.id, name: created.name, content: summary(created).content };
|
|
230
232
|
},
|
|
231
233
|
async updateDnsRecord(zone, recordId, rec) {
|
|
232
|
-
const updated = await writeExisting(zone, recordId, () => dns.update(zone, recordId, body(rec)));
|
|
234
|
+
const updated = await writeExisting(zone, recordId, () => dns.update(zone, recordId, body(rec)), rec.organisationId);
|
|
233
235
|
return { id: updated.id, name: updated.name, content: summary(updated).content };
|
|
234
236
|
},
|
|
235
237
|
async deleteDnsRecord(zone, recordId) {
|
|
@@ -37,7 +37,7 @@ export declare const entryAiKeyName: (org: DeclaredOrg, entry: HostingEntry) =>
|
|
|
37
37
|
export interface EntryAiWant {
|
|
38
38
|
permissions: string[];
|
|
39
39
|
offerings: string[];
|
|
40
|
-
/** `https://<
|
|
40
|
+
/** `https://<serving host><webhookPath>` (the live host, else the workspace host), or null without `webhookPath` or a host. */
|
|
41
41
|
webhook: string | null;
|
|
42
42
|
aiUrl: string;
|
|
43
43
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { AI_ENTRY_PERMISSIONS } from '../org.js';
|
|
2
|
-
import {
|
|
2
|
+
import { entryServingHost } from './hostnames.js';
|
|
3
3
|
import { appStepKey } from './platform-diff.js';
|
|
4
4
|
/**
|
|
5
5
|
* F6a: one ai key per entry with `uses.ai`, minted below the org's own ai key
|
|
@@ -45,7 +45,7 @@ export function parseIdList(raw) {
|
|
|
45
45
|
export const entryAiKeyName = (org, entry) => `${org.slug}-${entry.key}`;
|
|
46
46
|
export function wantedEntryAi(org, entry, aiUrl) {
|
|
47
47
|
const ai = entry.uses?.ai;
|
|
48
|
-
const host =
|
|
48
|
+
const host = entryServingHost(org, entry);
|
|
49
49
|
return {
|
|
50
50
|
permissions: [...(ai?.permissions ?? AI_ENTRY_PERMISSIONS)],
|
|
51
51
|
offerings: [...(ai?.offerings ?? [])],
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { DeclaredOrg } from '../org.js';
|
|
2
|
+
import type { DraftStep } from './diff.js';
|
|
3
|
+
import type { OrgPlatformReadiness } from './platform-org.js';
|
|
4
|
+
/**
|
|
5
|
+
* `uses.audit` (foundry#929, audit#56): one `audit:ledger` step per entry, keyed `<orgId>:<entry>`:
|
|
6
|
+
*
|
|
7
|
+
* 1. makes the platform's checkpoint signer if none exists (one Ed25519 key for every ledger, its seed held at
|
|
8
|
+
* `foundry:audit-signer:seed`, never in an app, an env, a plan or a log) and records its public key at
|
|
9
|
+
* {@link AUDIT_SIGNER_PUBLIC_KEY_SCOPE_KEY} (not a secret: it goes in the trusted-keys file a verifier is given);
|
|
10
|
+
* 2. registers the entry's ledger for sealing by recording `audit_ledger.<entry>` = the ledger name, last.
|
|
11
|
+
*
|
|
12
|
+
* Only registered ledgers are sealed (`audit/registry.ts`), and the registry is read from the records, so the
|
|
13
|
+
* sealing tick (`audit/seal.ts`, run by the host every `AUDIT_SEAL_EVERY_MINUTES`) never touches a database whose
|
|
14
|
+
* Worker app and migrations have not applied.
|
|
15
|
+
*
|
|
16
|
+
* Planned after the entry's Worker app applied or is planned in the same run: the app's database holds the audit
|
|
17
|
+
* tables from then on. Blocked, not failing (a plan line each): the held keys cannot put the signer's seed, the
|
|
18
|
+
* Worker app is not applied yet. Dropping `uses.audit` plans nothing: the record stays, the registry stops
|
|
19
|
+
* listing the ledger, and the ledger itself is never touched (append-only, nothing pruned).
|
|
20
|
+
*/
|
|
21
|
+
/** The platform signer's public key, 64 hex characters. */
|
|
22
|
+
export declare const AUDIT_SIGNER_PUBLIC_KEY_SCOPE_KEY = "audit_signer_public_key";
|
|
23
|
+
type Blocked = Array<{
|
|
24
|
+
description: string;
|
|
25
|
+
key: string;
|
|
26
|
+
}>;
|
|
27
|
+
export declare function planEntryAudit(org: DeclaredOrg, recordedIds: Readonly<Record<string, string>>, plannedSteps: readonly DraftStep[], readiness: Pick<OrgPlatformReadiness, 'canHoldSecrets'>): {
|
|
28
|
+
steps: DraftStep[];
|
|
29
|
+
blocked: Blocked;
|
|
30
|
+
};
|
|
31
|
+
export interface EntryAuditDeps {
|
|
32
|
+
heldKeys: {
|
|
33
|
+
resolve(ref: string): Promise<string | null | undefined>;
|
|
34
|
+
put(ref: string, value: string): Promise<void>;
|
|
35
|
+
};
|
|
36
|
+
org: {
|
|
37
|
+
id: string;
|
|
38
|
+
slug: string;
|
|
39
|
+
};
|
|
40
|
+
entryKey: string;
|
|
41
|
+
recorded: () => Readonly<Record<string, string>>;
|
|
42
|
+
record: (key: string, value: string) => void;
|
|
43
|
+
persist: () => Promise<void>;
|
|
44
|
+
/** Registers a value so no log, error or result ever carries it. */
|
|
45
|
+
secret: (value: string) => void;
|
|
46
|
+
mint?: () => string;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Runs the step. The seed is registered as a secret the moment it is made, so a failure after that never
|
|
50
|
+
* prints it. Safe to repeat: a held seed is reused, and the records are written last. When a public key is
|
|
51
|
+
* already recorded and the held seed reads empty, nothing is made and nothing is put (see `ensurePlatformSigner`).
|
|
52
|
+
*/
|
|
53
|
+
export declare function applyEntryAudit(deps: EntryAuditDeps): Promise<{
|
|
54
|
+
resourceId: string;
|
|
55
|
+
}>;
|
|
56
|
+
/** The entry keys with a registration record, for status pages. */
|
|
57
|
+
export declare function recordedAuditEntries(recordedIds: Readonly<Record<string, string>>): string[];
|
|
58
|
+
export {};
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { AUDIT_LEDGER_PREFIX, appLedgerName, auditLedgerScopeKey } from '../audit/registry.js';
|
|
2
|
+
import { auditSealerScopeKey, auditSealerUndeliveredScopeKey } from '../audit/sealer-role.js';
|
|
3
|
+
import { AUDIT_SIGNER_REF, ensurePlatformSigner, mintSignerSeed } from '../audit/signer.js';
|
|
4
|
+
import { workerAppAppliedScopeKey } from './worker-app.js';
|
|
5
|
+
/**
|
|
6
|
+
* `uses.audit` (foundry#929, audit#56): one `audit:ledger` step per entry, keyed `<orgId>:<entry>`:
|
|
7
|
+
*
|
|
8
|
+
* 1. makes the platform's checkpoint signer if none exists (one Ed25519 key for every ledger, its seed held at
|
|
9
|
+
* `foundry:audit-signer:seed`, never in an app, an env, a plan or a log) and records its public key at
|
|
10
|
+
* {@link AUDIT_SIGNER_PUBLIC_KEY_SCOPE_KEY} (not a secret: it goes in the trusted-keys file a verifier is given);
|
|
11
|
+
* 2. registers the entry's ledger for sealing by recording `audit_ledger.<entry>` = the ledger name, last.
|
|
12
|
+
*
|
|
13
|
+
* Only registered ledgers are sealed (`audit/registry.ts`), and the registry is read from the records, so the
|
|
14
|
+
* sealing tick (`audit/seal.ts`, run by the host every `AUDIT_SEAL_EVERY_MINUTES`) never touches a database whose
|
|
15
|
+
* Worker app and migrations have not applied.
|
|
16
|
+
*
|
|
17
|
+
* Planned after the entry's Worker app applied or is planned in the same run: the app's database holds the audit
|
|
18
|
+
* tables from then on. Blocked, not failing (a plan line each): the held keys cannot put the signer's seed, the
|
|
19
|
+
* Worker app is not applied yet. Dropping `uses.audit` plans nothing: the record stays, the registry stops
|
|
20
|
+
* listing the ledger, and the ledger itself is never touched (append-only, nothing pruned).
|
|
21
|
+
*/
|
|
22
|
+
/** The platform signer's public key, 64 hex characters. */
|
|
23
|
+
export const AUDIT_SIGNER_PUBLIC_KEY_SCOPE_KEY = 'audit_signer_public_key';
|
|
24
|
+
export function planEntryAudit(org, recordedIds, plannedSteps, readiness) {
|
|
25
|
+
const steps = [];
|
|
26
|
+
const blocked = [];
|
|
27
|
+
if (!org.hosting.enabled)
|
|
28
|
+
return { steps, blocked };
|
|
29
|
+
for (const entry of org.hosting.entries) {
|
|
30
|
+
if (entry.uses?.audit !== true)
|
|
31
|
+
continue;
|
|
32
|
+
const key = `${org.id}:${entry.key}`;
|
|
33
|
+
const ledger = appLedgerName(org, entry.key);
|
|
34
|
+
if (recordedIds[auditLedgerScopeKey(entry.key)] === ledger &&
|
|
35
|
+
recordedIds[AUDIT_SIGNER_PUBLIC_KEY_SCOPE_KEY]) {
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
const block = (why) => blocked.push({ description: `${org.slug}'s ${entry.key} audit log is blocked: ${why}`, key });
|
|
39
|
+
if (readiness.canHoldSecrets !== true) {
|
|
40
|
+
block("the host's held keys cannot hold the platform signer's key");
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
const undelivered = recordedIds[auditSealerUndeliveredScopeKey(entry.key)];
|
|
44
|
+
if (undelivered) {
|
|
45
|
+
block(`the sealing password for role ${undelivered} was minted and never held; clear the recorded ${auditSealerUndeliveredScopeKey(entry.key)} to mint it again`);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const appPlanned = plannedSteps.some((p) => p.action.system === 'cloudflare' &&
|
|
49
|
+
p.action.kind === 'worker-app' &&
|
|
50
|
+
p.action.key === key &&
|
|
51
|
+
p.action.desired.mode === 'apply');
|
|
52
|
+
const appApplied = Boolean(recordedIds[workerAppAppliedScopeKey(entry.key)]);
|
|
53
|
+
if (appApplied && !appPlanned && !recordedIds[auditSealerScopeKey(entry.key)]) {
|
|
54
|
+
block("the sealing role has not been made: the host's database port cannot make it, or the Worker app has not applied since uses.audit was turned on");
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (!appApplied && !appPlanned) {
|
|
58
|
+
block("the entry's Worker app, which carries the database, is not applied yet");
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
steps.push({
|
|
62
|
+
action: { system: 'audit', kind: 'ledger', key, desired: { ledger } },
|
|
63
|
+
step: {
|
|
64
|
+
description: `register ${org.slug}'s ${entry.key} audit log as ${ledger} for platform sealing, making the platform signer first if none exists`,
|
|
65
|
+
destroys: false,
|
|
66
|
+
migrates: false,
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
return { steps, blocked };
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Runs the step. The seed is registered as a secret the moment it is made, so a failure after that never
|
|
74
|
+
* prints it. Safe to repeat: a held seed is reused, and the records are written last. When a public key is
|
|
75
|
+
* already recorded and the held seed reads empty, nothing is made and nothing is put (see `ensurePlatformSigner`).
|
|
76
|
+
*/
|
|
77
|
+
export async function applyEntryAudit(deps) {
|
|
78
|
+
const at = `audit:ledger for ${deps.org.slug} entry '${deps.entryKey}'`;
|
|
79
|
+
const mint = deps.mint ?? mintSignerSeed;
|
|
80
|
+
const { signer } = await ensurePlatformSigner({
|
|
81
|
+
resolveSeed: async () => {
|
|
82
|
+
const seed = (await deps.heldKeys.resolve(AUDIT_SIGNER_REF)) || null;
|
|
83
|
+
if (seed)
|
|
84
|
+
deps.secret(seed);
|
|
85
|
+
return seed;
|
|
86
|
+
},
|
|
87
|
+
// Create-if-absent: a held value is never overwritten, however it came to be read as empty before.
|
|
88
|
+
deliverSeed: async (seed) => {
|
|
89
|
+
deps.secret(seed);
|
|
90
|
+
const held = (await deps.heldKeys.resolve(AUDIT_SIGNER_REF)) || null;
|
|
91
|
+
if (held) {
|
|
92
|
+
deps.secret(held);
|
|
93
|
+
throw new Error(`${AUDIT_SIGNER_REF} already holds a key; it was not overwritten, nothing was changed, apply again`);
|
|
94
|
+
}
|
|
95
|
+
await deps.heldKeys.put(AUDIT_SIGNER_REF, seed);
|
|
96
|
+
},
|
|
97
|
+
}, mint, deps.recorded()[AUDIT_SIGNER_PUBLIC_KEY_SCOPE_KEY] || undefined).catch((error) => {
|
|
98
|
+
// Our own messages name the ref, never the value; anything else is dropped in case it echoes the seed.
|
|
99
|
+
const own = error instanceof Error && error.message.includes(AUDIT_SIGNER_REF);
|
|
100
|
+
throw new Error(own
|
|
101
|
+
? `${at}: ${error.message}`
|
|
102
|
+
: `${at}: the platform signer's key could not be held or read`);
|
|
103
|
+
});
|
|
104
|
+
if (!deps.recorded()[auditSealerScopeKey(deps.entryKey)]) {
|
|
105
|
+
throw new Error(`${at}: the sealing role is not recorded; the entry's Worker app makes it, and the host's database port must be able to`);
|
|
106
|
+
}
|
|
107
|
+
const existing = deps.recorded()[AUDIT_SIGNER_PUBLIC_KEY_SCOPE_KEY];
|
|
108
|
+
if (existing && existing !== signer.publicKey) {
|
|
109
|
+
throw new Error(`${at}: the held signer key is not the one recorded as the platform signer; checkpoints already sealed would no longer verify against it. Restore the held key at ${AUDIT_SIGNER_REF} or clear ${AUDIT_SIGNER_PUBLIC_KEY_SCOPE_KEY} on purpose`);
|
|
110
|
+
}
|
|
111
|
+
deps.record(AUDIT_SIGNER_PUBLIC_KEY_SCOPE_KEY, signer.publicKey);
|
|
112
|
+
await deps.persist();
|
|
113
|
+
const ledger = appLedgerName(deps.org, deps.entryKey);
|
|
114
|
+
deps.record(auditLedgerScopeKey(deps.entryKey), ledger);
|
|
115
|
+
return { resourceId: ledger };
|
|
116
|
+
}
|
|
117
|
+
/** The entry keys with a registration record, for status pages. */
|
|
118
|
+
export function recordedAuditEntries(recordedIds) {
|
|
119
|
+
return Object.keys(recordedIds)
|
|
120
|
+
.filter((k) => k.startsWith(AUDIT_LEDGER_PREFIX))
|
|
121
|
+
.map((k) => k.slice(AUDIT_LEDGER_PREFIX.length));
|
|
122
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { DeclaredOrg, HostingEntry } from '../org.js';
|
|
2
|
+
/**
|
|
3
|
+
* `uses.customerGrants`: the app implements Boule's customer-grants routes. Foundry makes one credential per
|
|
4
|
+
* entry (`<base64url>.<base64url>`), holds it, and gives it to two places: the app's env
|
|
5
|
+
* (`CUSTOMER_GRANTS_CREDENTIAL`) and the company's Boule, inside its `CUSTOMER_GRANTS` JSON. The plan never
|
|
6
|
+
* holds the value; it compares the JSON's shape (tenant key, organisation, app names and urls) and the
|
|
7
|
+
* presence of every credential.
|
|
8
|
+
*/
|
|
9
|
+
export declare const CUSTOMER_GRANTS_CREDENTIAL_ENV = "CUSTOMER_GRANTS_CREDENTIAL";
|
|
10
|
+
/** The env Boule reads the grants from (`src/lib/customer-grants/config.ts`). */
|
|
11
|
+
export declare const CUSTOMER_GRANTS_ENV = "CUSTOMER_GRANTS";
|
|
12
|
+
/** Recorded (no secret) once an entry's credential was held: its ref. Cleared when the flag goes. */
|
|
13
|
+
export declare const customerGrantsMintedScopeKey: (entryKey: string) => string;
|
|
14
|
+
export declare const CUSTOMER_GRANTS_MINTED_PREFIX = "customer_grants_minted.";
|
|
15
|
+
/** Recorded once foundry has written Boule's `CUSTOMER_GRANTS`: the JSON's shape hash input, no secret. Cleared when foundry takes it away. */
|
|
16
|
+
export declare const CUSTOMER_GRANTS_WRITTEN_SCOPE_KEY = "boule_customer_grants_written";
|
|
17
|
+
/** The held-key ref of an entry's credential. */
|
|
18
|
+
export declare const customerGrantsRef: (orgId: string, entryKey: string) => string;
|
|
19
|
+
/** `<id>.<secret>`, both base64url, 32 random bytes each. */
|
|
20
|
+
export declare const mintCustomerGrantsCredential: () => string;
|
|
21
|
+
interface HeldKeys {
|
|
22
|
+
resolve(ref: string): Promise<string | null | undefined>;
|
|
23
|
+
put?(ref: string, value: string): Promise<void>;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The entry's credential: read from the held keys, else made and held first, and the ref recorded and
|
|
27
|
+
* persisted before the value goes anywhere, so a crash after the `put` hands the same value out on the next
|
|
28
|
+
* apply. A ref that is recorded but no longer held stops here and mints nothing: a second value would leave
|
|
29
|
+
* the app and Boule disagreeing. Errors name the ref, never the value. `secret` registers it for redaction.
|
|
30
|
+
* Both the app step and the Boule step call this, so either may run first.
|
|
31
|
+
*/
|
|
32
|
+
export declare function ensureCustomerGrantsCredential(input: {
|
|
33
|
+
orgId: string;
|
|
34
|
+
slug: string;
|
|
35
|
+
entryKey: string;
|
|
36
|
+
heldKeys: HeldKeys | undefined;
|
|
37
|
+
recorded: () => Readonly<Record<string, string>>;
|
|
38
|
+
record: (key: string, value: string) => void;
|
|
39
|
+
persist: () => Promise<void>;
|
|
40
|
+
secret: (value: string) => void;
|
|
41
|
+
}): Promise<string>;
|
|
42
|
+
/** The entries that declare `uses.customerGrants`, in declared order. */
|
|
43
|
+
export declare const customerGrantsEntries: (org: Pick<DeclaredOrg, "hosting">) => HostingEntry[];
|
|
44
|
+
/** Entry keys that were given a credential and no longer declare the flag (or are gone). */
|
|
45
|
+
export declare function customerGrantsToDrop(org: Pick<DeclaredOrg, 'hosting'>, recorded: Readonly<Record<string, string>>): string[];
|
|
46
|
+
/** What the JSON says without any secret. */
|
|
47
|
+
export interface CustomerGrantsShape {
|
|
48
|
+
/** The key in Boule's `CUSTOMER_GRANTS`: the company's issuer organisation id (`AUTH_ORGANIZATION_ID` of its Boule). */
|
|
49
|
+
tenantKey: string;
|
|
50
|
+
customersOrganisationId: string;
|
|
51
|
+
apps: Record<string, {
|
|
52
|
+
name: string;
|
|
53
|
+
url: string;
|
|
54
|
+
}>;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* What Boule's `CUSTOMER_GRANTS` should say, or `null` with the plan lines for each reason it says nothing
|
|
58
|
+
* yet. The key is the issuer organisation id because a company's Boule has one deploy per organisation and
|
|
59
|
+
* the Boule tenant row is made at its first start, after which foundry could not know its id.
|
|
60
|
+
*/
|
|
61
|
+
export declare function wantedCustomerGrants(org: DeclaredOrg, recorded: Readonly<Record<string, string>>, boule: {
|
|
62
|
+
live: boolean;
|
|
63
|
+
}): {
|
|
64
|
+
shape: CustomerGrantsShape | null;
|
|
65
|
+
notes: string[];
|
|
66
|
+
};
|
|
67
|
+
/** Boule's `CUSTOMER_GRANTS` value: stable key order, so an unchanged value is byte-identical. */
|
|
68
|
+
export declare function customerGrantsJson(shape: CustomerGrantsShape, directoryCredential: string, credentials: Readonly<Record<string, string>>): string;
|
|
69
|
+
/**
|
|
70
|
+
* Whether the live value already says what `shape` says: the same single tenant key, organisation and apps
|
|
71
|
+
* (names, urls), with a credential present for each and for the directory. Credentials are compared by presence
|
|
72
|
+
* only, in memory.
|
|
73
|
+
*/
|
|
74
|
+
export declare function customerGrantsShapeMatches(live: string | undefined, shape: CustomerGrantsShape): boolean;
|
|
75
|
+
export {};
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { ZITADEL_ORG_SCOPE_KEY } from './agora.js';
|
|
3
|
+
import { customersOrgIdOf } from './boule.js';
|
|
4
|
+
import { entryServingHost } from './hostnames.js';
|
|
5
|
+
/**
|
|
6
|
+
* `uses.customerGrants`: the app implements Boule's customer-grants routes. Foundry makes one credential per
|
|
7
|
+
* entry (`<base64url>.<base64url>`), holds it, and gives it to two places: the app's env
|
|
8
|
+
* (`CUSTOMER_GRANTS_CREDENTIAL`) and the company's Boule, inside its `CUSTOMER_GRANTS` JSON. The plan never
|
|
9
|
+
* holds the value; it compares the JSON's shape (tenant key, organisation, app names and urls) and the
|
|
10
|
+
* presence of every credential.
|
|
11
|
+
*/
|
|
12
|
+
export const CUSTOMER_GRANTS_CREDENTIAL_ENV = 'CUSTOMER_GRANTS_CREDENTIAL';
|
|
13
|
+
/** The env Boule reads the grants from (`src/lib/customer-grants/config.ts`). */
|
|
14
|
+
export const CUSTOMER_GRANTS_ENV = 'CUSTOMER_GRANTS';
|
|
15
|
+
/** Recorded (no secret) once an entry's credential was held: its ref. Cleared when the flag goes. */
|
|
16
|
+
export const customerGrantsMintedScopeKey = (entryKey) => `customer_grants_minted.${entryKey}`;
|
|
17
|
+
export const CUSTOMER_GRANTS_MINTED_PREFIX = 'customer_grants_minted.';
|
|
18
|
+
/** Recorded once foundry has written Boule's `CUSTOMER_GRANTS`: the JSON's shape hash input, no secret. Cleared when foundry takes it away. */
|
|
19
|
+
export const CUSTOMER_GRANTS_WRITTEN_SCOPE_KEY = 'boule_customer_grants_written';
|
|
20
|
+
/** The held-key ref of an entry's credential. */
|
|
21
|
+
export const customerGrantsRef = (orgId, entryKey) => `foundry:customer-grants:${orgId}:${entryKey}`;
|
|
22
|
+
/** `<id>.<secret>`, both base64url, 32 random bytes each. */
|
|
23
|
+
export const mintCustomerGrantsCredential = () => `${randomBytes(32).toString('base64url')}.${randomBytes(32).toString('base64url')}`;
|
|
24
|
+
/**
|
|
25
|
+
* The entry's credential: read from the held keys, else made and held first, and the ref recorded and
|
|
26
|
+
* persisted before the value goes anywhere, so a crash after the `put` hands the same value out on the next
|
|
27
|
+
* apply. A ref that is recorded but no longer held stops here and mints nothing: a second value would leave
|
|
28
|
+
* the app and Boule disagreeing. Errors name the ref, never the value. `secret` registers it for redaction.
|
|
29
|
+
* Both the app step and the Boule step call this, so either may run first.
|
|
30
|
+
*/
|
|
31
|
+
export async function ensureCustomerGrantsCredential(input) {
|
|
32
|
+
const ref = customerGrantsRef(input.orgId, input.entryKey);
|
|
33
|
+
const at = `customer grants for ${input.slug}`;
|
|
34
|
+
const held = await input.heldKeys?.resolve(ref);
|
|
35
|
+
if (held) {
|
|
36
|
+
input.secret(held);
|
|
37
|
+
return held;
|
|
38
|
+
}
|
|
39
|
+
if (input.recorded()[customerGrantsMintedScopeKey(input.entryKey)]) {
|
|
40
|
+
throw new Error(`${at}: the credential of entry '${input.entryKey}' was made but is no longer held (ref ${ref}); restore the held value`);
|
|
41
|
+
}
|
|
42
|
+
if (typeof input.heldKeys?.put !== 'function') {
|
|
43
|
+
throw new Error(`${at}: the held keys cannot put the credential of entry '${input.entryKey}'`);
|
|
44
|
+
}
|
|
45
|
+
const credential = mintCustomerGrantsCredential();
|
|
46
|
+
input.secret(credential);
|
|
47
|
+
await input.heldKeys.put(ref, credential);
|
|
48
|
+
input.record(customerGrantsMintedScopeKey(input.entryKey), ref);
|
|
49
|
+
await input.persist();
|
|
50
|
+
return credential;
|
|
51
|
+
}
|
|
52
|
+
/** The entries that declare `uses.customerGrants`, in declared order. */
|
|
53
|
+
export const customerGrantsEntries = (org) => org.hosting.enabled ? org.hosting.entries.filter((e) => e.uses?.customerGrants) : [];
|
|
54
|
+
/** Entry keys that were given a credential and no longer declare the flag (or are gone). */
|
|
55
|
+
export function customerGrantsToDrop(org, recorded) {
|
|
56
|
+
const wanted = new Set(customerGrantsEntries(org).map((e) => e.key));
|
|
57
|
+
return Object.keys(recorded)
|
|
58
|
+
.filter((k) => k.startsWith(CUSTOMER_GRANTS_MINTED_PREFIX) && recorded[k])
|
|
59
|
+
.map((k) => k.slice(CUSTOMER_GRANTS_MINTED_PREFIX.length))
|
|
60
|
+
.filter((key) => !wanted.has(key));
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* What Boule's `CUSTOMER_GRANTS` should say, or `null` with the plan lines for each reason it says nothing
|
|
64
|
+
* yet. The key is the issuer organisation id because a company's Boule has one deploy per organisation and
|
|
65
|
+
* the Boule tenant row is made at its first start, after which foundry could not know its id.
|
|
66
|
+
*/
|
|
67
|
+
export function wantedCustomerGrants(org, recorded, boule) {
|
|
68
|
+
const entries = customerGrantsEntries(org);
|
|
69
|
+
const notes = [];
|
|
70
|
+
if (entries.length === 0)
|
|
71
|
+
return { shape: null, notes };
|
|
72
|
+
const at = `${org.slug}'s customer grants`;
|
|
73
|
+
const tenantKey = recorded[ZITADEL_ORG_SCOPE_KEY];
|
|
74
|
+
const customersOrganisationId = customersOrgIdOf(org, recorded);
|
|
75
|
+
if (!org.customers?.enabled) {
|
|
76
|
+
notes.push(`${at} are not planned: ${entries.map((e) => `'${e.key}'`).join(', ')} declare customerGrants but the company has customers off, so there is no customers organisation to list`);
|
|
77
|
+
}
|
|
78
|
+
else if (!customersOrganisationId) {
|
|
79
|
+
notes.push(`${at} wait: the customers organisation is not made yet; Boule gets its entry on the apply after it is`);
|
|
80
|
+
}
|
|
81
|
+
if (!tenantKey) {
|
|
82
|
+
notes.push(`${at} wait: the company's sign-in organisation is not recorded yet`);
|
|
83
|
+
}
|
|
84
|
+
if (!boule.live) {
|
|
85
|
+
notes.push(`${at} wait: ${org.slug} has no Boule yet; it gets its entry once Boule is running`);
|
|
86
|
+
}
|
|
87
|
+
const apps = {};
|
|
88
|
+
for (const entry of entries) {
|
|
89
|
+
const host = entryServingHost(org, entry);
|
|
90
|
+
if (!host) {
|
|
91
|
+
notes.push(`${at}: entry '${entry.key}' has no address Boule can call yet, so it is left out of Boule's entry`);
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
apps[entry.key] = { name: entry.key, url: `https://${host}/api` };
|
|
95
|
+
}
|
|
96
|
+
if (!org.customers?.enabled ||
|
|
97
|
+
!tenantKey ||
|
|
98
|
+
!customersOrganisationId ||
|
|
99
|
+
Object.keys(apps).length === 0) {
|
|
100
|
+
return { shape: null, notes };
|
|
101
|
+
}
|
|
102
|
+
return { shape: { tenantKey, customersOrganisationId, apps }, notes };
|
|
103
|
+
}
|
|
104
|
+
/** Boule's `CUSTOMER_GRANTS` value: stable key order, so an unchanged value is byte-identical. */
|
|
105
|
+
export function customerGrantsJson(shape, directoryCredential, credentials) {
|
|
106
|
+
const apps = {};
|
|
107
|
+
for (const key of Object.keys(shape.apps).sort()) {
|
|
108
|
+
const app = shape.apps[key];
|
|
109
|
+
apps[key] = { name: app.name, url: app.url, credential: credentials[key] };
|
|
110
|
+
}
|
|
111
|
+
return JSON.stringify({
|
|
112
|
+
[shape.tenantKey]: {
|
|
113
|
+
customersOrganisationId: shape.customersOrganisationId,
|
|
114
|
+
directoryCredential,
|
|
115
|
+
apps,
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Whether the live value already says what `shape` says: the same single tenant key, organisation and apps
|
|
121
|
+
* (names, urls), with a credential present for each and for the directory. Credentials are compared by presence
|
|
122
|
+
* only, in memory.
|
|
123
|
+
*/
|
|
124
|
+
export function customerGrantsShapeMatches(live, shape) {
|
|
125
|
+
if (!live)
|
|
126
|
+
return false;
|
|
127
|
+
try {
|
|
128
|
+
const all = JSON.parse(live);
|
|
129
|
+
const keys = Object.keys(all);
|
|
130
|
+
if (keys.length !== 1 || keys[0] !== shape.tenantKey)
|
|
131
|
+
return false;
|
|
132
|
+
const entry = all[shape.tenantKey];
|
|
133
|
+
if (entry.customersOrganisationId !== shape.customersOrganisationId)
|
|
134
|
+
return false;
|
|
135
|
+
if (typeof entry.directoryCredential !== 'string' || !entry.directoryCredential)
|
|
136
|
+
return false;
|
|
137
|
+
const apps = entry.apps ?? {};
|
|
138
|
+
const liveKeys = Object.keys(apps).sort();
|
|
139
|
+
const wantedKeys = Object.keys(shape.apps).sort();
|
|
140
|
+
if (liveKeys.join('\n') !== wantedKeys.join('\n'))
|
|
141
|
+
return false;
|
|
142
|
+
return wantedKeys.every((key) => {
|
|
143
|
+
const have = apps[key];
|
|
144
|
+
const want = shape.apps[key];
|
|
145
|
+
return (have?.name === want?.name &&
|
|
146
|
+
have?.url === want?.url &&
|
|
147
|
+
typeof have?.credential === 'string' &&
|
|
148
|
+
have.credential !== '');
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type DeclaredOrg, type HostingEntry } from '../org.js';
|
|
2
2
|
import type { DraftStep } from './diff.js';
|
|
3
3
|
import type { OrgPlatformReadiness } from './platform-org.js';
|
|
4
4
|
/**
|
|
@@ -43,6 +43,21 @@ export declare const EDGE_RESERVED_LABELS_APPLIED_SCOPE_KEY = "edge_reserved_lab
|
|
|
43
43
|
* so it never collides with a declared entry.
|
|
44
44
|
*/
|
|
45
45
|
export declare const BOULE_EDGE_ENTRY_KEY = "_boule";
|
|
46
|
+
/**
|
|
47
|
+
* F3 (#924): Boule's edge key is wanted when the org has its own domain (DNS records) or its Boule runs apps
|
|
48
|
+
* (`org.boule.apps`: it deploys the company's own Workers through edge, `workers:deploy`). Edge gives every
|
|
49
|
+
* company key every customer permission in its own organisation, `workers:deploy` and `workers:read` among them
|
|
50
|
+
* (`apps/host/src/lib/edge-host.ts` `buildAccess`), and scopes a deploy to the script names
|
|
51
|
+
* `<organisationId>--<app>` and `<organisationId>--<app>-work` from the key's own organisation, so the key
|
|
52
|
+
* carries no scope of its own and a key minted before the permission existed gains it without a re-mint.
|
|
53
|
+
*/
|
|
54
|
+
export declare const bouleEdgeKeyWanted: (org: DeclaredOrg) => boolean;
|
|
55
|
+
/**
|
|
56
|
+
* The domain edge binds a company's key to (`mintKey` needs a lowercase hostname and refuses one another
|
|
57
|
+
* organisation's key overlaps). A company without its own domain is on `<slug>.oikos.host`, the address
|
|
58
|
+
* `plan/diff.ts` already gives its apps.
|
|
59
|
+
*/
|
|
60
|
+
export declare const edgeKeyDomain: (org: Pick<DeclaredOrg, "domain" | "slug">) => string;
|
|
46
61
|
/** The recorded-id key of the Coolify app an edge key's env is written to. */
|
|
47
62
|
export declare function edgeKeyAppRecordKey(org: DeclaredOrg, entryKey: string): string;
|
|
48
63
|
export declare const entryEdgeKeyName: (org: DeclaredOrg, entry: HostingEntry) => string;
|
|
@@ -59,6 +74,16 @@ export declare const entryEdgeKeyName: (org: DeclaredOrg, entry: HostingEntry) =
|
|
|
59
74
|
* domain), so it reserves `www` only.
|
|
60
75
|
*/
|
|
61
76
|
export declare function edgeReservedLabels(org: DeclaredOrg, platformHostnames?: readonly string[]): string[];
|
|
77
|
+
/** The slug last recorded in edge for the org, so a changed slug is sent again and an unchanged one is not. */
|
|
78
|
+
export declare const EDGE_SLUG_APPLIED_SCOPE_KEY = "edge_slug_applied";
|
|
79
|
+
/**
|
|
80
|
+
* F3: edge accepts a company's platform hostnames (`<slug>-<app>.<platform domain>`,
|
|
81
|
+
* `work-<slug>-<app>.<workspace domain>`) when the company is on its own domain only once it knows the
|
|
82
|
+
* company's slug (`PUT /v1/platform/organisations/:orgId/slug`). Planned for a company with Apps on, once
|
|
83
|
+
* the platform has an edge service, whenever the slug edge holds is not the declared one. A platform
|
|
84
|
+
* without an edge service is quiet, like Boule's key: nothing is declared that could not be done.
|
|
85
|
+
*/
|
|
86
|
+
export declare function planEdgeSlug(org: DeclaredOrg, recordedIds: Readonly<Record<string, string>>, readiness: OrgPlatformReadiness): DraftStep | null;
|
|
62
87
|
type Blocked = Array<{
|
|
63
88
|
description: string;
|
|
64
89
|
key: string;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { appsOn } from '../org.js';
|
|
1
2
|
import { tunnelIngressHostnames } from './hostnames.js';
|
|
2
3
|
import { lockedHostnames } from './locked-hostnames.js';
|
|
3
4
|
import { appStepKey } from './platform-diff.js';
|
|
@@ -44,6 +45,21 @@ export const EDGE_RESERVED_LABELS_APPLIED_SCOPE_KEY = 'edge_reserved_labels_appl
|
|
|
44
45
|
* so it never collides with a declared entry.
|
|
45
46
|
*/
|
|
46
47
|
export const BOULE_EDGE_ENTRY_KEY = '_boule';
|
|
48
|
+
/**
|
|
49
|
+
* F3 (#924): Boule's edge key is wanted when the org has its own domain (DNS records) or its Boule runs apps
|
|
50
|
+
* (`org.boule.apps`: it deploys the company's own Workers through edge, `workers:deploy`). Edge gives every
|
|
51
|
+
* company key every customer permission in its own organisation, `workers:deploy` and `workers:read` among them
|
|
52
|
+
* (`apps/host/src/lib/edge-host.ts` `buildAccess`), and scopes a deploy to the script names
|
|
53
|
+
* `<organisationId>--<app>` and `<organisationId>--<app>-work` from the key's own organisation, so the key
|
|
54
|
+
* carries no scope of its own and a key minted before the permission existed gains it without a re-mint.
|
|
55
|
+
*/
|
|
56
|
+
export const bouleEdgeKeyWanted = (org) => Boolean(org.domain) || appsOn(org);
|
|
57
|
+
/**
|
|
58
|
+
* The domain edge binds a company's key to (`mintKey` needs a lowercase hostname and refuses one another
|
|
59
|
+
* organisation's key overlaps). A company without its own domain is on `<slug>.oikos.host`, the address
|
|
60
|
+
* `plan/diff.ts` already gives its apps.
|
|
61
|
+
*/
|
|
62
|
+
export const edgeKeyDomain = (org) => org.domain ?? `${org.slug}.oikos.host`;
|
|
47
63
|
/** The recorded-id key of the Coolify app an edge key's env is written to. */
|
|
48
64
|
export function edgeKeyAppRecordKey(org, entryKey) {
|
|
49
65
|
return entryKey === BOULE_EDGE_ENTRY_KEY
|
|
@@ -89,6 +105,29 @@ export function edgeReservedLabels(org, platformHostnames = []) {
|
|
|
89
105
|
}
|
|
90
106
|
return [...labels].sort();
|
|
91
107
|
}
|
|
108
|
+
/** The slug last recorded in edge for the org, so a changed slug is sent again and an unchanged one is not. */
|
|
109
|
+
export const EDGE_SLUG_APPLIED_SCOPE_KEY = 'edge_slug_applied';
|
|
110
|
+
/**
|
|
111
|
+
* F3: edge accepts a company's platform hostnames (`<slug>-<app>.<platform domain>`,
|
|
112
|
+
* `work-<slug>-<app>.<workspace domain>`) when the company is on its own domain only once it knows the
|
|
113
|
+
* company's slug (`PUT /v1/platform/organisations/:orgId/slug`). Planned for a company with Apps on, once
|
|
114
|
+
* the platform has an edge service, whenever the slug edge holds is not the declared one. A platform
|
|
115
|
+
* without an edge service is quiet, like Boule's key: nothing is declared that could not be done.
|
|
116
|
+
*/
|
|
117
|
+
export function planEdgeSlug(org, recordedIds, readiness) {
|
|
118
|
+
if (!appsOn(org) || !readiness.edgeReady)
|
|
119
|
+
return null;
|
|
120
|
+
if (recordedIds[EDGE_SLUG_APPLIED_SCOPE_KEY] === org.slug)
|
|
121
|
+
return null;
|
|
122
|
+
return {
|
|
123
|
+
action: { system: 'edge', kind: 'slug', key: org.id, desired: { slug: org.slug } },
|
|
124
|
+
step: {
|
|
125
|
+
description: `record ${org.slug}'s slug in edge, so edge accepts its platform hostnames`,
|
|
126
|
+
destroys: false,
|
|
127
|
+
migrates: false,
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
|
92
131
|
/**
|
|
93
132
|
* One step per entry whose edge key is missing, plus one retire step per
|
|
94
133
|
* entry whose `uses.edge` was removed but still has a recorded key.
|
|
@@ -111,12 +150,12 @@ export function planEntryEdgeKeys(org, recordedIds, plannedSteps, readiness, now
|
|
|
111
150
|
// throws before `runSteps` sees a `resourceId`), so scanning the main
|
|
112
151
|
// prefix alone would never find it to revoke once its entry is gone
|
|
113
152
|
// (#317 review).
|
|
114
|
-
// Boule's key is wanted whenever the org has its own domain
|
|
115
|
-
//
|
|
153
|
+
// Boule's key is wanted whenever the org has its own domain, or its Boule runs apps
|
|
154
|
+
// (`bouleEdgeKeyWanted`): a company on oikos.host without apps gets none.
|
|
116
155
|
const holders = org.hosting.entries
|
|
117
156
|
.filter((e) => e.uses?.edge)
|
|
118
157
|
.map((e) => ({ entryKey: e.key, name: entryEdgeKeyName(org, e) }));
|
|
119
|
-
if (org
|
|
158
|
+
if (bouleEdgeKeyWanted(org)) {
|
|
120
159
|
holders.push({ entryKey: BOULE_EDGE_ENTRY_KEY, name: `${org.slug}-boule-edge` });
|
|
121
160
|
}
|
|
122
161
|
const declared = new Set(holders.map((h) => h.entryKey));
|