@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.
Files changed (218) hide show
  1. package/dist/apps/cloudflare.d.ts +173 -0
  2. package/dist/apps/cloudflare.js +17 -0
  3. package/dist/apps/deploy.d.ts +116 -0
  4. package/dist/apps/deploy.js +791 -0
  5. package/dist/apps/github.d.ts +47 -0
  6. package/dist/apps/github.js +201 -0
  7. package/dist/apps/index.d.ts +9 -0
  8. package/dist/apps/index.js +9 -0
  9. package/dist/apps/migrations.d.ts +110 -0
  10. package/dist/apps/migrations.js +458 -0
  11. package/dist/audit/connector.d.ts +43 -0
  12. package/dist/audit/connector.js +22 -0
  13. package/dist/audit/evidence.d.ts +49 -0
  14. package/dist/audit/evidence.js +56 -0
  15. package/dist/audit/index.d.ts +9 -0
  16. package/dist/audit/index.js +9 -0
  17. package/dist/audit/migrations.d.ts +5 -0
  18. package/dist/audit/migrations.js +5 -0
  19. package/dist/audit/registry.d.ts +49 -0
  20. package/dist/audit/registry.js +44 -0
  21. package/dist/audit/seal.d.ts +49 -0
  22. package/dist/audit/seal.js +131 -0
  23. package/dist/audit/sealer-role.d.ts +33 -0
  24. package/dist/audit/sealer-role.js +56 -0
  25. package/dist/audit/signer.d.ts +40 -0
  26. package/dist/audit/signer.js +78 -0
  27. package/dist/audit/tick.d.ts +36 -0
  28. package/dist/audit/tick.js +87 -0
  29. package/dist/catalogue/copy-en.d.ts +1 -0
  30. package/dist/catalogue/copy-en.js +129 -0
  31. package/dist/catalogue/index.d.ts +9 -0
  32. package/dist/catalogue/index.js +21 -0
  33. package/dist/catalogue/integrations.d.ts +4 -0
  34. package/dist/catalogue/integrations.js +104 -0
  35. package/dist/catalogue/systems.d.ts +7 -0
  36. package/dist/catalogue/systems.js +31 -0
  37. package/dist/catalogue/types.d.ts +110 -0
  38. package/dist/catalogue/types.js +1 -0
  39. package/dist/entry-rules.d.ts +53 -0
  40. package/dist/entry-rules.js +71 -0
  41. package/dist/hosted-label.d.ts +13 -0
  42. package/dist/hosted-label.js +81 -0
  43. package/dist/index.d.ts +10 -5
  44. package/dist/index.js +5 -3
  45. package/dist/migrations/0062_hosting_github.sql +6 -0
  46. package/dist/migrations/0063_boule_apps.sql +7 -0
  47. package/dist/migrations/0064_requested_changes_boule_apps.sql +6 -0
  48. package/dist/modules/ai.d.ts +3 -0
  49. package/dist/modules/ai.js +64 -0
  50. package/dist/modules/assistants.d.ts +3 -0
  51. package/dist/modules/assistants.js +72 -0
  52. package/dist/modules/audit-ledger.d.ts +29 -0
  53. package/dist/modules/audit-ledger.js +49 -0
  54. package/dist/modules/audit.d.ts +7 -0
  55. package/dist/modules/audit.js +44 -0
  56. package/dist/modules/database.d.ts +23 -0
  57. package/dist/modules/database.js +189 -0
  58. package/dist/modules/email.d.ts +3 -0
  59. package/dist/modules/email.js +18 -0
  60. package/dist/modules/entry-parts.d.ts +23 -0
  61. package/dist/modules/entry-parts.js +63 -0
  62. package/dist/modules/files.d.ts +3 -0
  63. package/dist/modules/files.js +64 -0
  64. package/dist/modules/index.d.ts +74 -0
  65. package/dist/modules/index.js +774 -0
  66. package/dist/modules/jobs.d.ts +3 -0
  67. package/dist/modules/jobs.js +23 -0
  68. package/dist/modules/organisations.d.ts +3 -0
  69. package/dist/modules/organisations.js +20 -0
  70. package/dist/modules/outputs.d.ts +9 -0
  71. package/dist/modules/outputs.js +38 -0
  72. package/dist/modules/sign-in.d.ts +3 -0
  73. package/dist/modules/sign-in.js +197 -0
  74. package/dist/modules/signed-records.d.ts +3 -0
  75. package/dist/modules/signed-records.js +32 -0
  76. package/dist/modules/types.d.ts +194 -0
  77. package/dist/modules/types.js +1 -0
  78. package/dist/modules/workspaces.d.ts +3 -0
  79. package/dist/modules/workspaces.js +75 -0
  80. package/dist/org.d.ts +163 -22
  81. package/dist/org.js +419 -47
  82. package/dist/plan/apps-db-route.d.ts +90 -0
  83. package/dist/plan/apps-db-route.js +168 -0
  84. package/dist/plan/apps-zones.d.ts +56 -0
  85. package/dist/plan/apps-zones.js +118 -0
  86. package/dist/plan/archon-blue-green.js +11 -1
  87. package/dist/plan/blue-green-apply.d.ts +37 -1
  88. package/dist/plan/blue-green-apply.js +55 -15
  89. package/dist/plan/blue-green.d.ts +41 -1
  90. package/dist/plan/blue-green.js +80 -2
  91. package/dist/plan/boule.d.ts +23 -1
  92. package/dist/plan/boule.js +35 -2
  93. package/dist/plan/broker-entries.d.ts +28 -3
  94. package/dist/plan/broker-entries.js +74 -3
  95. package/dist/plan/broker-registrar.d.ts +168 -0
  96. package/dist/plan/broker-registrar.js +440 -0
  97. package/dist/plan/broker-workspaces-database.d.ts +48 -0
  98. package/dist/plan/broker-workspaces-database.js +87 -0
  99. package/dist/plan/desktop-callback.js +12 -7
  100. package/dist/plan/diff.d.ts +8 -2
  101. package/dist/plan/diff.js +373 -42
  102. package/dist/plan/edge-access.d.ts +6 -0
  103. package/dist/plan/edge-dns.d.ts +6 -0
  104. package/dist/plan/edge-dns.js +9 -7
  105. package/dist/plan/entry-ai-key.d.ts +1 -1
  106. package/dist/plan/entry-ai-key.js +2 -2
  107. package/dist/plan/entry-audit.d.ts +58 -0
  108. package/dist/plan/entry-audit.js +122 -0
  109. package/dist/plan/entry-customer-grants.d.ts +75 -0
  110. package/dist/plan/entry-customer-grants.js +154 -0
  111. package/dist/plan/entry-edge-key.d.ts +26 -1
  112. package/dist/plan/entry-edge-key.js +42 -3
  113. package/dist/plan/entry-jobs.d.ts +64 -0
  114. package/dist/plan/entry-jobs.js +92 -0
  115. package/dist/plan/entry-repo-key-apply.d.ts +55 -0
  116. package/dist/plan/entry-repo-key-apply.js +185 -0
  117. package/dist/plan/entry-repo-key.d.ts +92 -0
  118. package/dist/plan/entry-repo-key.js +235 -0
  119. package/dist/plan/entry-workspaces.d.ts +55 -0
  120. package/dist/plan/entry-workspaces.js +90 -0
  121. package/dist/plan/executor.d.ts +191 -1
  122. package/dist/plan/executor.js +1505 -90
  123. package/dist/plan/gates.d.ts +1 -1
  124. package/dist/plan/gates.js +62 -9
  125. package/dist/plan/github-deploy-keys.d.ts +78 -0
  126. package/dist/plan/github-deploy-keys.js +305 -0
  127. package/dist/plan/github-source.d.ts +53 -0
  128. package/dist/plan/github-source.js +140 -0
  129. package/dist/plan/held-key-refs.d.ts +12 -0
  130. package/dist/plan/held-key-refs.js +12 -0
  131. package/dist/plan/hostnames.d.ts +9 -0
  132. package/dist/plan/hostnames.js +28 -1
  133. package/dist/plan/index.d.ts +9 -0
  134. package/dist/plan/index.js +6 -0
  135. package/dist/plan/jobs-scheduler-http.js +3 -0
  136. package/dist/plan/keys-registrar-http.d.ts +32 -0
  137. package/dist/plan/keys-registrar-http.js +105 -0
  138. package/dist/plan/keys-registrar.d.ts +67 -0
  139. package/dist/plan/keys-registrar.js +103 -0
  140. package/dist/plan/live.d.ts +1 -1
  141. package/dist/plan/locked-hostnames.d.ts +2 -2
  142. package/dist/plan/locked-hostnames.js +2 -2
  143. package/dist/plan/login-host.js +11 -5
  144. package/dist/plan/machine-paths.d.ts +36 -0
  145. package/dist/plan/machine-paths.js +137 -0
  146. package/dist/plan/mcp.d.ts +1 -1
  147. package/dist/plan/mcp.js +49 -17
  148. package/dist/plan/org-apps-admin.d.ts +108 -0
  149. package/dist/plan/org-apps-admin.js +434 -0
  150. package/dist/plan/org-names.js +12 -1
  151. package/dist/plan/own-diff.d.ts +23 -0
  152. package/dist/plan/own-diff.js +53 -0
  153. package/dist/plan/own-hostnames.d.ts +2 -0
  154. package/dist/plan/own-hostnames.js +1 -0
  155. package/dist/plan/own-jobs-route.d.ts +47 -0
  156. package/dist/plan/own-jobs-route.js +91 -0
  157. package/dist/plan/own-services.d.ts +4 -0
  158. package/dist/plan/own-services.js +4 -0
  159. package/dist/plan/plan-apply.d.ts +39 -0
  160. package/dist/plan/plan-apply.js +163 -9
  161. package/dist/plan/platform-diff.d.ts +2 -2
  162. package/dist/plan/platform-diff.js +7 -2
  163. package/dist/plan/platform-org.d.ts +30 -0
  164. package/dist/plan/platform-org.js +12 -1
  165. package/dist/plan/platform-providers.d.ts +2 -0
  166. package/dist/plan/platform-providers.js +7 -2
  167. package/dist/plan/post-logout-uris.js +7 -1
  168. package/dist/plan/private-pages.d.ts +26 -0
  169. package/dist/plan/private-pages.js +39 -0
  170. package/dist/plan/proxy-labels.d.ts +12 -1
  171. package/dist/plan/proxy-labels.js +32 -2
  172. package/dist/plan/release-host.d.ts +6 -2
  173. package/dist/plan/release-host.js +36 -8
  174. package/dist/plan/stage-env.d.ts +19 -1
  175. package/dist/plan/stage-env.js +25 -1
  176. package/dist/plan/stage-guards.d.ts +20 -0
  177. package/dist/plan/stage-guards.js +24 -0
  178. package/dist/plan/stage-isolation.d.ts +24 -0
  179. package/dist/plan/stage-isolation.js +29 -1
  180. package/dist/plan/worker-app-database.d.ts +47 -40
  181. package/dist/plan/worker-app-database.js +150 -152
  182. package/dist/plan/worker-app.d.ts +6 -65
  183. package/dist/plan/worker-app.js +29 -105
  184. package/dist/plan/worker.d.ts +2 -2
  185. package/dist/plan/worker.js +2 -1
  186. package/dist/plan/workspace-client.d.ts +25 -0
  187. package/dist/plan/workspace-client.js +48 -0
  188. package/dist/plan/workspace-sign-in.d.ts +55 -0
  189. package/dist/plan/workspace-sign-in.js +273 -0
  190. package/dist/plan/workspace-worker.d.ts +4 -0
  191. package/dist/plan/workspace-worker.js +13 -0
  192. package/dist/plan/workspace.d.ts +101 -0
  193. package/dist/plan/workspace.js +305 -0
  194. package/dist/release/decide.d.ts +30 -1
  195. package/dist/release/decide.js +73 -11
  196. package/dist/release/health.d.ts +8 -3
  197. package/dist/release/health.js +58 -5
  198. package/dist/release/run.d.ts +2 -5
  199. package/dist/release/run.js +4 -0
  200. package/dist/release/state.d.ts +3 -1
  201. package/dist/release/state.js +6 -0
  202. package/dist/release/types.d.ts +20 -0
  203. package/dist/requested-changes.d.ts +11 -2
  204. package/dist/requested-changes.js +11 -0
  205. package/dist/service-labels.d.ts +4 -0
  206. package/dist/service-labels.js +11 -0
  207. package/dist/slug.d.ts +2 -0
  208. package/dist/slug.js +10 -6
  209. package/dist/store/postgres.js +12 -2
  210. package/dist/store/schema.d.ts +37 -1
  211. package/dist/store/schema.js +4 -0
  212. package/dist/worker-app-config.d.ts +6 -10
  213. package/dist/worker-app-config.js +22 -17
  214. package/dist/worker-app-migrations.d.ts +105 -0
  215. package/dist/worker-app-migrations.js +166 -0
  216. package/dist/worker-app-upload.d.ts +103 -0
  217. package/dist/worker-app-upload.js +128 -0
  218. package/package.json +18 -1
@@ -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. */
@@ -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
@@ -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://<first hostname><webhookPath>`, or null without `webhookPath`. */
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 { entryHostnames } from './hostnames.js';
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 = entryHostnames(org, entry)[0];
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 { DeclaredOrg, HostingEntry } from '../org.js';
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 (with-domain
115
- // mode): edge refuses a company on oikos.host, so none is minted there.
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.domain) {
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));