@wtfalch/foundry 0.15.0 → 0.16.1

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 (39) hide show
  1. package/dist/bootstrap/declare.d.ts +2 -2
  2. package/dist/bootstrap/declare.js +6 -6
  3. package/dist/index.d.ts +3 -3
  4. package/dist/index.js +2 -2
  5. package/dist/migrations/0034_owners.sql +42 -0
  6. package/dist/migrations/0035_chat_on_by_default.sql +10 -0
  7. package/dist/org.d.ts +80 -17
  8. package/dist/org.js +221 -18
  9. package/dist/plan/agora.d.ts +19 -1
  10. package/dist/plan/agora.js +37 -2
  11. package/dist/plan/chat-matrix.d.ts +99 -0
  12. package/dist/plan/chat-matrix.js +194 -0
  13. package/dist/plan/diff.d.ts +5 -3
  14. package/dist/plan/diff.js +143 -83
  15. package/dist/plan/entry-ai-key.d.ts +58 -0
  16. package/dist/plan/entry-ai-key.js +209 -0
  17. package/dist/plan/entry-files-key.d.ts +82 -0
  18. package/dist/plan/entry-files-key.js +327 -0
  19. package/dist/plan/executor.d.ts +244 -16
  20. package/dist/plan/executor.js +866 -84
  21. package/dist/plan/hostnames.d.ts +14 -1
  22. package/dist/plan/hostnames.js +26 -1
  23. package/dist/plan/index.d.ts +1 -0
  24. package/dist/plan/index.js +1 -0
  25. package/dist/plan/plan-apply.d.ts +2 -0
  26. package/dist/plan/plan-apply.js +42 -2
  27. package/dist/plan/platform-org.d.ts +17 -5
  28. package/dist/plan/platform-org.js +15 -7
  29. package/dist/plan/platform-providers.js +4 -3
  30. package/dist/plan/proxy-labels.d.ts +42 -0
  31. package/dist/plan/proxy-labels.js +73 -0
  32. package/dist/plan/zitadel-machine-user.d.ts +5 -1
  33. package/dist/plan/zitadel-machine-user.js +5 -1
  34. package/dist/plan/zitadel-project-roles.d.ts +25 -0
  35. package/dist/plan/zitadel-project-roles.js +104 -0
  36. package/dist/store/postgres.js +4 -17
  37. package/dist/store/schema.d.ts +13 -45
  38. package/dist/store/schema.js +5 -8
  39. package/package.json +1 -1
@@ -84,6 +84,7 @@ export interface CoolifyPort {
84
84
  getApplication(uuid: string): Promise<{
85
85
  uuid: string;
86
86
  status?: string;
87
+ custom_labels?: string | null;
87
88
  }>;
88
89
  createGithubAppApplication(input: {
89
90
  project_uuid: string;
@@ -457,6 +458,8 @@ export interface CloudflarePort {
457
458
  content: string;
458
459
  proxied?: boolean;
459
460
  ttl?: number;
461
+ /** Required by Cloudflare for an `MX` record. */
462
+ priority?: number;
460
463
  }): Promise<{
461
464
  id: string;
462
465
  name: string;
@@ -468,6 +471,7 @@ export interface CloudflarePort {
468
471
  content: string;
469
472
  proxied?: boolean;
470
473
  ttl?: number;
474
+ priority?: number;
471
475
  }): Promise<{
472
476
  id: string;
473
477
  name: string;
@@ -800,6 +804,24 @@ export interface ZitadelPort {
800
804
  userId: string;
801
805
  created: boolean;
802
806
  }>;
807
+ /**
808
+ * Adds a role to a project. Idempotent: a role that already exists is not
809
+ * an error. `@wtfalch/providers`' `ZitadelClient` has no project-role API,
810
+ * so `zitadel-project-roles.ts` is the real implementation.
811
+ */
812
+ ensureProjectRole(orgId: string, projectId: string, role: {
813
+ key: string;
814
+ displayName: string;
815
+ }): Promise<{
816
+ created: boolean;
817
+ }>;
818
+ /**
819
+ * Grants a user (in `orgId`) project roles. Idempotent: roles the user
820
+ * already holds on the project are kept and the missing ones added.
821
+ */
822
+ grantProjectRoles(orgId: string, userId: string, projectId: string, roleKeys: string[]): Promise<{
823
+ changed: boolean;
824
+ }>;
803
825
  /** Lets anyone sign up to the org (plan gap A9). Idempotent. */
804
826
  allowSelfRegistration(orgId: string): Promise<{
805
827
  changed: boolean;
@@ -1008,6 +1030,9 @@ export interface FilesOrganisationPort {
1008
1030
  ceilingBytes: string | null;
1009
1031
  ceilingMicros: string | null;
1010
1032
  shareHost?: string | null;
1033
+ /** F6b: with `owner`, files creates the organisation's owner membership, which its own key is minted below. */
1034
+ name?: string;
1035
+ owner?: string;
1011
1036
  }): Promise<{
1012
1037
  organisationId: string;
1013
1038
  ceilingBytes: string | null;
@@ -1018,10 +1043,65 @@ export interface FilesOrganisationPort {
1018
1043
  suspended: boolean;
1019
1044
  shareHost: string | null;
1020
1045
  created: boolean;
1046
+ /**
1047
+ * F6b: files' service key for Agora, returned once on a 201 that passed
1048
+ * `name` and `owner` (the SDK's `agoraKey`). `filesOrganisation` delivers it.
1049
+ */
1050
+ agoraKey?: {
1051
+ id: string;
1052
+ secret: string;
1053
+ };
1021
1054
  }>;
1022
1055
  /** #27, #177: the org's own spend limit on Files -- see this interface's own doc comment for why `organisationId` is explicit here. */
1023
1056
  setQuota(organisationId: string, limitBytes: string | null): Promise<unknown>;
1024
1057
  setBudget(organisationId: string, limitMicros: string | null): Promise<unknown>;
1058
+ /**
1059
+ * F6b: mints the organisation's own key, matching files'
1060
+ * `POST /v1/manage/organisations/:organisationId/keys` (SDK
1061
+ * `issueOrganisationKey`, wtfalch/files#121). `issuer` is a live human
1062
+ * member the key's permissions are copied from. The secret comes back
1063
+ * once; `filesRootKey` hands it to `deliverSecret` and returns only the id.
1064
+ */
1065
+ issueOrganisationKey(organisationId: string, input: {
1066
+ name: string;
1067
+ kind: 'api_key' | 'agent' | 'service';
1068
+ issuer: string;
1069
+ permissions: string[];
1070
+ expiresDays: number;
1071
+ }): Promise<{
1072
+ id: string;
1073
+ secret: string;
1074
+ expiresAt: string;
1075
+ }>;
1076
+ /**
1077
+ * F6b: mints an entry's key below the organisation's own key without that
1078
+ * key's secret, matching `POST /v1/manage/organisations/:organisationId/
1079
+ * keys/:minterId/keys` (SDK `issueKeyBelow`, wtfalch/files#127).
1080
+ * `permissions` is a subset of the minter's, and files refuses an expiry
1081
+ * past the minter's. The secret comes back once; `filesEntryKey` hands it
1082
+ * to `bulkUpsertEnvs` and returns only the id.
1083
+ */
1084
+ issueKeyBelow(organisationId: string, minterId: string, input: {
1085
+ name: string;
1086
+ kind: 'api_key' | 'agent' | 'service';
1087
+ permissions: string[];
1088
+ expiresDays: number;
1089
+ }): Promise<{
1090
+ id: string;
1091
+ secret: string;
1092
+ expiresAt: string;
1093
+ }>;
1094
+ /** F6b: revokes the key and every key it minted (SDK `revokeOrganisationKey`); a second call is a no-op. */
1095
+ revokeOrganisationKey(organisationId: string, keyId: string): Promise<{
1096
+ id: string;
1097
+ revoked: string[];
1098
+ }>;
1099
+ /** F6b: the organisation's collection of that name, created when missing (SDK `ensureCollection`, wtfalch/files#127). */
1100
+ ensureCollection(organisationId: string, name: string): Promise<{
1101
+ id: string;
1102
+ name: string;
1103
+ created: boolean;
1104
+ }>;
1025
1105
  }
1026
1106
  /**
1027
1107
  * Provisions one org's organisation on AI and switches its AI use on (#80),
@@ -1077,6 +1157,45 @@ export interface AiOrganisationPort {
1077
1157
  id: string;
1078
1158
  secret: string;
1079
1159
  }>;
1160
+ /**
1161
+ * F6a: mints an entry's key below the org's own key, matching ai's
1162
+ * `POST /v1/keys` (wtfalch/ai#162, SDK `mintKey`), which the management key
1163
+ * calls with `minter` set to `minterKeyId` (the recorded id of
1164
+ * `ai:key:<orgId>`). `permissions` is a subset of the minter's. ai clamps
1165
+ * the expiry to the minter's, so the returned `expiresAt` can be sooner
1166
+ * than `expiresDays`; `aiEntryKey` records that value. The child's issuer
1167
+ * is the minter, so revoking the org key reaches it. The secret comes back
1168
+ * once; `aiEntryKey` hands it to `bulkUpsertEnvs` and returns only the id.
1169
+ */
1170
+ mintNestedKey(input: {
1171
+ organisationId: string;
1172
+ minterKeyId: string;
1173
+ name: string;
1174
+ expiresDays: number;
1175
+ permissions: string[];
1176
+ }): Promise<{
1177
+ organisationId: string;
1178
+ id: string;
1179
+ secret: string;
1180
+ expiresAt: string;
1181
+ }>;
1182
+ /**
1183
+ * F6a: registers `address` as the org's webhook, matching `PUT /v1/webhooks`
1184
+ * (`setWebhookAddress`). `secret` is present only when ai created the
1185
+ * webhook; an address change on an existing one returns none.
1186
+ */
1187
+ setWebhookAddress(organisationId: string, address: string): Promise<{
1188
+ address: string;
1189
+ secret?: string;
1190
+ }>;
1191
+ /**
1192
+ * REVOKE SEAM (wtfalch/ai#157, A1b, not built): revokes one key and its
1193
+ * credential row. Optional until that route exists; while a port has none,
1194
+ * a replaced entry key stays live until it expires and its id is recorded
1195
+ * under `entryAiUnrevokedScopeKey` for the plan to list. Nothing else
1196
+ * depends on it.
1197
+ */
1198
+ revokeKey?(organisationId: string, keyId: string): Promise<void>;
1080
1199
  /**
1081
1200
  * #27, #177: the org's own budget on AI, matching
1082
1201
  * `ai/packages/sdk/src/client.ts:94-101`'s `updateBudget` (`UpdateBudget`
@@ -1207,8 +1326,8 @@ export interface FoundryStepExecutorPorts {
1207
1326
  }
1208
1327
  /**
1209
1328
  * Thrown by name for a step this executor deliberately does not implement:
1210
- * chat (no admin API exists yet), plus moving the issuer onto or off the
1211
- * org's domain (built, but not in v1 — domain verification and passkey
1329
+ * moving chat or the issuer onto or off the org's domain (chat's re-key is
1330
+ * not built; the issuer's is built, but not in v1 — domain verification and passkey
1212
1331
  * invalidation need their own design). Never carries a token, secret or key
1213
1332
  * (task brief: "errors carry the step and the provider's status only").
1214
1333
  */
@@ -1278,7 +1397,7 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
1278
1397
  * `plan/diff.ts`'s `planOwner`: invites the org's owner into its ZITADEL
1279
1398
  * org by email. Never logs, returns or throws the email itself -- only the
1280
1399
  * invited user id, handed back as `resourceId` so `runSteps` records it
1281
- * under `zitadel:owner:<orgId>`, which is exactly what makes the next
1400
+ * under `zitadel:owner:<orgId>:<email>`, which is exactly what makes the next
1282
1401
  * `plan()` call idempotent (`planOwner` plans nothing once that key is
1283
1402
  * recorded).
1284
1403
  */
@@ -1441,9 +1560,9 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
1441
1560
  * `zitadel:issuer` (`AUTH_ORGANIZATION_ID`), `zitadel:owner`
1442
1561
  * (`OWNER_USER_ID`, gap P3) and every `zitadel:app` step
1443
1562
  * (`AUTH_CLIENT_ID`), all ordered before `coolify:app` for exactly this
1444
- * reason. `AUTH_APP_KEY` (#206 -- which broker an org's app registers with
1445
- * is its own design question, left out of #204's own "How" rather than
1446
- * guessed) is out of scope here (task brief). `DATABASE_URL_OWNER`/
1563
+ * reason. `AUTH_APP_KEY` is pushed here too, for an entry that declares
1564
+ * `uses: [auth]` (`ensureAuthAppKey`); the host's `BrokerPort` decides which
1565
+ * broker the org's app registers with (#206). `DATABASE_URL_OWNER`/
1447
1566
  * `DATABASE_URL` are pushed by `pushDatabaseUrl` instead, not here --
1448
1567
  * that call runs only once the `app` entry's `database` is declared and
1449
1568
  * provisioned, where this one is unconditional (#239).
@@ -1753,6 +1872,31 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
1753
1872
  * so an existing one is always re-written.
1754
1873
  */
1755
1874
  private ensureCallsFileStorage;
1875
+ /**
1876
+ * Creates the `chat.admin` role on the org's messaging project and grants
1877
+ * it to each owner whose invite is recorded (`zitadel:owner:<orgId>:<email>`,
1878
+ * email lowercased).
1879
+ */
1880
+ private ensureChatAdmins;
1881
+ /**
1882
+ * `plan/diff.ts`'s `planChat`: the org's chat stack at level 1 -- three
1883
+ * Coolify-managed Postgres databases (Synapse, MAS, chat), Synapse with the
1884
+ * `wtfalch_chat_gate` module and MAS (both with a config file this step
1885
+ * writes, `chat-matrix.ts`), and the chat service. All four apps are
1886
+ * internal: Agora is the one public origin and proxies to them
1887
+ * (`AGORA_CHAT_UPSTREAM`, `AGORA_MATRIX_UPSTREAM`, `AGORA_MAS_UPSTREAM`).
1888
+ * It also finds or creates the org's ZITADEL messaging project, the
1889
+ * audience the chat service requires, and records its id for Agora.
1890
+ *
1891
+ * Idempotent: every resource is found by name or recorded id before it is
1892
+ * created, minted secrets are read back from the MAS app's envs, and an app
1893
+ * is deployed when it is new, its config was freshly minted, or it is not running. A
1894
+ * recorded chat resource means the stack already exists, so a second
1895
+ * `coolify:chat` step for it is a re-key (onto the company domain, or back)
1896
+ * and neither is built: both are refused by name, before anything is touched.
1897
+ * No secret reaches an error: errors name the org and the missing input only.
1898
+ */
1899
+ private coolifyChat;
1756
1900
  /**
1757
1901
  * `plan/diff.ts`'s `planCallsService`: the org's own calls service --
1758
1902
  * `wtfalch/calls`'s `packages/service`, wired to the calls Matrix stack
@@ -1821,10 +1965,12 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
1821
1965
  * `AGORA_FILES_UPSTREAM` are pushed once the platform's own mail/files
1822
1966
  * hosts are on and applied (#177's `platform-org.ts`'s
1823
1967
  * `agoraHostValuesFromPlatform`, read by `plan-apply.ts` when no host
1824
- * `agora` port overrides it) -- absent otherwise. The chat upstreams are
1825
- * still never pushed: chat has no admin API yet
1826
- * (https://github.com/wtfalch/chat/issues/40). Agora answers 502 on those
1827
- * routes rather than serving anything wrong. `AGORA_CALLS_UPSTREAM` is
1968
+ * `agora` port overrides it) -- absent otherwise. `AGORA_CHAT_UPSTREAM`,
1969
+ * `AGORA_MATRIX_UPSTREAM` and `AGORA_MAS_UPSTREAM` are pushed together once
1970
+ * chat is on and `coolifyChat` has recorded that its apps carry their
1971
+ * internal names (`CHAT_SERVICE_URL_SCOPE_KEY`); absent otherwise, and Agora
1972
+ * answers 502 on those routes rather than serving anything wrong.
1973
+ * `messagingProjectId` falls back to the project `coolifyChat` recorded. `AGORA_CALLS_UPSTREAM` is
1828
1974
  * pushed once calls are on and this org's calls service app carries its
1829
1975
  * internal name (`desiredAgoraEnv` reads that marker from the recorded ids,
1830
1976
  * not the host values).
@@ -2018,7 +2164,7 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
2018
2164
  * when the org has no owner yet, when no address is declared yet
2019
2165
  * (`plan/diff.ts`'s `planMail` reports that case as blocked, not planned,
2020
2166
  * so this step's own mode/domain work still runs), or once
2021
- * {@link MAIL_OWNER_MAILBOX_SCOPE_KEY} is already recorded -- checked last
2167
+ * `mail_owner_mailbox` is already recorded -- checked last
2022
2168
  * so a second apply never calls `MailPort.createMailbox` again, rather
2023
2169
  * than relying on that port method's own duplicate refusal.
2024
2170
  */
@@ -2045,13 +2191,32 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
2045
2191
  */
2046
2192
  private deleteStaleLettermintDomain;
2047
2193
  /**
2048
- * Upserts each DNS record `applyOrgMail` says foundry must publish, found
2049
- * by an exact name+type match (`CloudflarePort.findDnsRecord`'s own doc
2050
- * comment) so update never mistakes a stale record of a different type for
2051
- * a match. A `DKIM` record can be absent on the first call, since Stalwart
2052
- * mints the key on a background task; the next apply publishes it.
2194
+ * Upserts each DNS record `applyOrgMail` says foundry must publish through
2195
+ * {@link upsertMailDnsRecord}. A `DKIM` record can be absent on the first
2196
+ * call, since Stalwart mints the key on a background task; the next apply
2197
+ * publishes it.
2053
2198
  */
2054
2199
  private publishMailDnsRecords;
2200
+ /**
2201
+ * Creates or updates one mail record (Stalwart's or Lettermint's).
2202
+ *
2203
+ * Always DNS-only: Cloudflare proxies only `A`, `AAAA` and `CNAME`, and a
2204
+ * proxied `CNAME` answers with Cloudflare's own addresses instead of its
2205
+ * target, so Lettermint's return-path and DKIM names would stop resolving
2206
+ * to Lettermint. `@wtfalch/providers`' `createDnsRecord` proxies by
2207
+ * default, so `proxied: false` is sent on every write, update included, so
2208
+ * a record someone proxied by hand is put back. An `MX` record carries the
2209
+ * priority it was given, which Cloudflare requires.
2210
+ *
2211
+ * Found by an exact name+type match (`CloudflarePort.findDnsRecord`'s own
2212
+ * doc comment) so a stale record of a different type is never taken for a
2213
+ * match. A name can hold several `TXT` records (an apex often holds a site
2214
+ * verification beside SPF), so a `TXT` record only replaces one that
2215
+ * starts with the same tag (`v=spf1`, `v=DKIM1`); with none, it is added
2216
+ * beside them. Before this, the first `TXT` on the name was overwritten,
2217
+ * whatever it was.
2218
+ */
2219
+ private upsertMailDnsRecord;
2055
2220
  /**
2056
2221
  * Parking mail (2026-09-28 lettermint-org-mail swap, "Core rule": "Parking
2057
2222
  * mail deletes the org's projects and then the domain itself, so no
@@ -2096,6 +2261,14 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
2096
2261
  * idempotent by find-or-create, not by checking `recordedIds` first.
2097
2262
  */
2098
2263
  private filesOrganisation;
2264
+ /**
2265
+ * Files onboards Agora when it provisions an organisation with an owner and
2266
+ * returns Agora's service key once. Foundry holds no other files key for
2267
+ * Agora, so this is the only one: its secret goes straight to
2268
+ * `deliverSecret`, its id is recorded as undelivered first, and a delivery
2269
+ * that fails revokes the key again instead of leaving a live key nobody holds.
2270
+ */
2271
+ private deliverFilesAgoraKey;
2099
2272
  private filesStorage;
2100
2273
  /**
2101
2274
  * #80: provisions the org's own organisation on AI and switches its AI use
@@ -2116,6 +2289,61 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
2116
2289
  * way to check for an already-minted key.
2117
2290
  */
2118
2291
  private aiKey;
2292
+ /**
2293
+ * F6a: mints, renews, finishes, re-writes or retires one entry's ai key.
2294
+ *
2295
+ * Order keeps the app on a valid key throughout: the new key is minted below
2296
+ * the org's key, its id is recorded and persisted as undelivered, the env is
2297
+ * written (up to three tries), that write is recorded, the app is restarted,
2298
+ * and only then is the old key revoked. A failed write keeps the old key in
2299
+ * use and the planner blocks instead of minting again. A failed restart
2300
+ * after the write plans `finish`, which restarts and records, minting
2301
+ * nothing. The webhook secret, which ai shows once, is written the same way
2302
+ * before the mint. A secret reaches `bulkUpsertEnvs` and nothing else:
2303
+ * errors name ids, never the cause, which can echo one.
2304
+ */
2305
+ private aiEntryKey;
2306
+ /**
2307
+ * REVOKE SEAM (ai#157): revokes through `revokeKey` when the port has one.
2308
+ * Without it, or when it fails, the id joins the entry's list of keys left
2309
+ * live, which the plan shows, and the step goes on: the app already has its
2310
+ * new key.
2311
+ */
2312
+ private revokeEntryKey;
2313
+ /**
2314
+ * F6b: mints or renews the org's own files key, or revokes the ones a
2315
+ * renewal replaced. The key is minted below the files organisation's owner
2316
+ * (the member `files_org_owner` records) and its one-time secret goes
2317
+ * straight to {@link FoundryStepExecutorPorts.deliverSecret}. The id is
2318
+ * recorded as undelivered first, so a delivery that fails never leads to a
2319
+ * second mint. Entries are minted below it with `issueKeyBelow`, which needs
2320
+ * no secret.
2321
+ *
2322
+ * A renewal keeps the old key live: its id goes to `files_key_previous`, the
2323
+ * entries are re-minted below the new key by their own steps, and
2324
+ * `revoke-previous` revokes the old key only when every entry holds a key
2325
+ * below the new one. A failed revoke is listed, not retried.
2326
+ */
2327
+ private filesRootKey;
2328
+ /**
2329
+ * F6b: mints, renews, finishes, re-writes or retires one entry's files key.
2330
+ *
2331
+ * Same order as the ai entry key: the collections are ensured first (they
2332
+ * hold no secret and can be repeated), the new key is minted below the
2333
+ * org's own key, its id is recorded and persisted as undelivered, the env
2334
+ * is written (up to three tries), that write is recorded, the app is
2335
+ * restarted, and only then is the old key revoked. A failed write keeps the
2336
+ * old key in use and the planner blocks instead of minting again. A failed
2337
+ * restart after the write plans `finish`, which restarts and records,
2338
+ * minting nothing. A secret reaches `bulkUpsertEnvs` and nothing else.
2339
+ */
2340
+ private filesEntryKey;
2341
+ /**
2342
+ * Revokes one entry key. When the call fails the id joins the entry's list
2343
+ * of keys left live, which the plan shows, and the step goes on: the app
2344
+ * already has its new key.
2345
+ */
2346
+ private revokeFilesEntryKey;
2119
2347
  /**
2120
2348
  * {@link StepDestroyer.destroy}: deletes the one resource
2121
2349
  * `FoundryPlanApply.destroy` names. A provider 404 (already gone) counts as