@wtfalch/foundry 0.45.1 → 0.47.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 (78) hide show
  1. package/dist/build-json.d.ts +28 -0
  2. package/dist/build-json.js +95 -0
  3. package/dist/index.d.ts +10 -4
  4. package/dist/index.js +6 -2
  5. package/dist/migrations/0058_app_requests.sql +32 -0
  6. package/dist/org.d.ts +68 -5
  7. package/dist/org.js +287 -135
  8. package/dist/plan/agora.d.ts +14 -0
  9. package/dist/plan/agora.js +21 -0
  10. package/dist/plan/backup-gate.d.ts +15 -0
  11. package/dist/plan/backup-gate.js +38 -0
  12. package/dist/plan/boule-owners-http.d.ts +12 -0
  13. package/dist/plan/boule-owners-http.js +59 -0
  14. package/dist/plan/boule.d.ts +38 -2
  15. package/dist/plan/boule.js +72 -1
  16. package/dist/plan/company-backups.d.ts +7 -1
  17. package/dist/plan/company-backups.js +42 -2
  18. package/dist/plan/cookie-domain.d.ts +41 -0
  19. package/dist/plan/cookie-domain.js +71 -0
  20. package/dist/plan/desktop-auth.d.ts +58 -0
  21. package/dist/plan/desktop-auth.js +144 -0
  22. package/dist/plan/desktop-callback.d.ts +26 -0
  23. package/dist/plan/desktop-callback.js +59 -0
  24. package/dist/plan/diff.d.ts +13 -4
  25. package/dist/plan/diff.js +277 -88
  26. package/dist/plan/executor.d.ts +136 -6
  27. package/dist/plan/executor.js +732 -105
  28. package/dist/plan/health-gaps.js +5 -0
  29. package/dist/plan/health-targets.js +1 -2
  30. package/dist/plan/hostnames.d.ts +2 -2
  31. package/dist/plan/hostnames.js +6 -6
  32. package/dist/plan/index.d.ts +4 -1
  33. package/dist/plan/index.js +3 -1
  34. package/dist/plan/job-app.d.ts +23 -0
  35. package/dist/plan/job-app.js +41 -0
  36. package/dist/plan/live.d.ts +6 -0
  37. package/dist/plan/live.js +16 -0
  38. package/dist/plan/login-host.d.ts +28 -0
  39. package/dist/plan/login-host.js +36 -0
  40. package/dist/plan/org-names.js +9 -1
  41. package/dist/plan/org-postgres-job.d.ts +22 -0
  42. package/dist/plan/org-postgres-job.js +91 -0
  43. package/dist/plan/org-postgres-migrate.d.ts +115 -0
  44. package/dist/plan/org-postgres-migrate.js +466 -0
  45. package/dist/plan/org-postgres.d.ts +90 -0
  46. package/dist/plan/org-postgres.js +293 -0
  47. package/dist/plan/plan-apply.d.ts +60 -3
  48. package/dist/plan/plan-apply.js +174 -8
  49. package/dist/plan/platform-diff.d.ts +45 -0
  50. package/dist/plan/platform-diff.js +159 -4
  51. package/dist/plan/platform-org.d.ts +2 -0
  52. package/dist/plan/platform-org.js +4 -0
  53. package/dist/plan/platform-providers.d.ts +12 -2
  54. package/dist/plan/platform-providers.js +282 -9
  55. package/dist/plan/post-logout-uris.d.ts +22 -0
  56. package/dist/plan/post-logout-uris.js +43 -0
  57. package/dist/plan/providers-readers.d.ts +10 -1
  58. package/dist/plan/providers-readers.js +56 -5
  59. package/dist/plan/sign-in-bindings.d.ts +4 -0
  60. package/dist/plan/sign-in-bindings.js +10 -0
  61. package/dist/plan/sign-in-org.d.ts +19 -6
  62. package/dist/plan/sign-in-org.js +35 -10
  63. package/dist/plan/sign-in.d.ts +12 -5
  64. package/dist/plan/sign-in.js +12 -5
  65. package/dist/plan/signin-db-move.js +10 -34
  66. package/dist/plan/worker-app.js +3 -1
  67. package/dist/refuse-org.d.ts +7 -0
  68. package/dist/refuse-org.js +10 -0
  69. package/dist/requested-changes.d.ts +50 -4
  70. package/dist/requested-changes.js +9 -1
  71. package/dist/store/postgres.d.ts +5 -1
  72. package/dist/store/postgres.js +67 -8
  73. package/dist/store/schema.d.ts +72 -0
  74. package/dist/store/schema.js +4 -0
  75. package/dist/store.d.ts +24 -1
  76. package/dist/worker-app-config.d.ts +8 -2
  77. package/dist/worker-app-config.js +20 -6
  78. package/package.json +5 -5
@@ -2,6 +2,7 @@ import { ZitadelError } from '@wtfalch/providers/zitadel';
2
2
  import { APP_ENTRY_KEY } from '../org.js';
3
3
  import { BOULE_MAIL_TOKEN_ENV, BOULE_MANAGED_ENV_KEYS } from './boule.js';
4
4
  import { previewsOn } from './coolify-previews.js';
5
+ import { APP_GONE_KIND, } from './live.js';
5
6
  import { SIGN_IN_UPSTREAM_ENV } from './sign-in.js';
6
7
  /**
7
8
  * A {@link ScopeResolver} backed by `AppliedState.serviceIds` (`../state.ts`)
@@ -279,12 +280,23 @@ async function withBouleEnvConfig(client, resource, recorded, liveKeys) {
279
280
  // Boule must hold too. In memory only, compared by `planBoule` and never
280
281
  // written to a step, description or log.
281
282
  const orgId = recorded[resource.key]?.slice('boule:'.length);
282
- const appUuid = Object.entries(recorded).find(([, value]) => value === `app:${orgId}:${APP_ENTRY_KEY}`)?.[0];
283
+ // The token sits on the org's main app, whatever its key; the reader does not know the
284
+ // declaration, so it tries the `app` entry first and then each other recorded app.
285
+ const appPrefix = `app:${orgId}:`;
286
+ const appUuids = Object.entries(recorded)
287
+ .filter(([, value]) => value.startsWith(appPrefix))
288
+ .sort(([, a], [, b]) => Number(b === `${appPrefix}${APP_ENTRY_KEY}`) - Number(a === `${appPrefix}${APP_ENTRY_KEY}`))
289
+ .map(([uuid]) => uuid);
283
290
  // A recorded app that is not in the live listing is gone: no app token, and
284
291
  // no read of its envs. A listed app whose read fails fails like any read.
285
- const appToken = appUuid && liveKeys.has(appUuid)
286
- ? (await client.listEnvs(appUuid)).find((e) => !e.is_preview && e.key === BOULE_MAIL_TOKEN_ENV)?.value
287
- : undefined;
292
+ let appToken;
293
+ for (const appUuid of appUuids) {
294
+ if (!liveKeys.has(appUuid))
295
+ continue;
296
+ appToken = (await client.listEnvs(appUuid)).find((e) => !e.is_preview && e.key === BOULE_MAIL_TOKEN_ENV)?.value;
297
+ if (appToken)
298
+ break;
299
+ }
288
300
  return {
289
301
  ...resource,
290
302
  config: { ...resource.config, env, ...(appToken ? { appMailToken: appToken } : {}) },
@@ -416,7 +428,46 @@ export class ProvidersZitadelReader {
416
428
  this.liveIssuer(orgId, zitadelOrgId),
417
429
  ]);
418
430
  const mapped = applyRecordedIds(toFoundryResources('zitadel', resources), recorded);
419
- return issuer ? [...mapped, issuer] : mapped;
431
+ const gone = await this.goneApps(zitadelOrgId, resources, recorded);
432
+ return issuer ? [...mapped, ...gone, issuer] : [...mapped, ...gone];
433
+ }
434
+ /**
435
+ * #557: a recorded app the listing does not return, proven gone by a direct
436
+ * read by app id in every project the listing shows. One `app-gone`
437
+ * resource per proven app, keyed by the app's name: `diff.ts` offers the
438
+ * replacement only for it. Nothing is proven when the client has no
439
+ * `getApp` (providers before 0.12.0), when the read throws, or when it
440
+ * finds the app, so the plan blocks instead.
441
+ */
442
+ async goneApps(zitadelOrgId, listed, recorded) {
443
+ // A host may pass a client older than providers 0.12.0, which has no `getApp`.
444
+ const getApp = typeof this.client.getApp === 'function' ? this.client.getApp.bind(this.client) : undefined;
445
+ const listedIds = new Set(listed.filter((r) => r.kind === 'app').map((r) => r.key));
446
+ const projectIds = listed.filter((r) => r.kind === 'project').map((r) => r.key);
447
+ if (!getApp || projectIds.length === 0)
448
+ return [];
449
+ const gone = [];
450
+ for (const [appId, resourceKey] of Object.entries(recorded)) {
451
+ if (!resourceKey.startsWith('app:') || listedIds.has(appId))
452
+ continue;
453
+ try {
454
+ const found = await Promise.all(projectIds.map((projectId) => getApp(zitadelOrgId, projectId, appId)));
455
+ if (found.every((app) => app === null || app.state === 'APP_STATE_REMOVED')) {
456
+ gone.push({
457
+ system: 'zitadel',
458
+ kind: APP_GONE_KIND,
459
+ key: resourceKey.slice('app:'.length),
460
+ config: {},
461
+ });
462
+ }
463
+ }
464
+ catch (error) {
465
+ // Unproven, not gone: the plan blocks this app. Only the error's name is kept.
466
+ if (!(error instanceof ZitadelError))
467
+ throw error;
468
+ }
469
+ }
470
+ return gone;
420
471
  }
421
472
  /**
422
473
  * #510: `client.listResources` reports only what is *inside* a ZITADEL org
@@ -13,4 +13,8 @@ export declare function editSignInBindings(coolify: Pick<CoolifyPort, 'listEnvs'
13
13
  instanceHost: string;
14
14
  upsert?: readonly LoginHostBinding[];
15
15
  remove?: readonly string[];
16
+ clientOrganizations?: {
17
+ upsert?: Readonly<Record<string, string>>;
18
+ remove?: readonly string[];
19
+ };
16
20
  }): Promise<void>;
@@ -36,6 +36,16 @@ export async function editSignInBindings(coolify, input) {
36
36
  ...doc.bindings.filter((b) => !touched.has(b.host)),
37
37
  ...(input.upsert ?? []).map((b) => ({ ...b, clientIds: [...b.clientIds].sort() })),
38
38
  ].sort((a, b) => (a.host < b.host ? -1 : 1));
39
+ const orgs = input.clientOrganizations;
40
+ if (orgs && (Object.keys(orgs.upsert ?? {}).length > 0 || (orgs.remove ?? []).length > 0)) {
41
+ const merged = { ...doc.clientOrganizations, ...orgs.upsert };
42
+ const gone = new Set(orgs.remove ?? []);
43
+ const sorted = Object.fromEntries(Object.entries(merged)
44
+ .filter(([id]) => !gone.has(id))
45
+ .sort(([a], [b]) => (a < b ? -1 : 1)));
46
+ // An empty map is left out of the document, as it was before the first write.
47
+ doc.clientOrganizations = Object.keys(sorted).length > 0 ? sorted : undefined;
48
+ }
39
49
  const next = JSON.stringify(doc);
40
50
  if (live?.value === next)
41
51
  return;
@@ -7,6 +7,12 @@ export declare const ZITADEL_ORG_DOMAIN_SCOPE_KEY = "zitadel_org_domain";
7
7
  * names another org, the company plan holds the close step and no owner invite.
8
8
  */
9
9
  export declare const ZITADEL_ORG_REGISTRATION_CLOSED_SCOPE_KEY = "zitadel_org_registration_closed";
10
+ /**
11
+ * Scope id: the id of the customers org whose self-registration foundry
12
+ * closed, recorded only after `closeSelfRegistration` returned. Foundry closes
13
+ * once and never opens: a company that turns sign-up on keeps it on.
14
+ */
15
+ export declare const ZITADEL_CUSTOMERS_REGISTRATION_CLOSED_SCOPE_KEY = "zitadel_customers_registration_closed";
10
16
  /** Closes self-registration on the org (idempotent) and records it under `scopeKey`, in that order. */
11
17
  export declare function closeSignInOrgRegistration(zitadel: Pick<ZitadelPort, 'closeSelfRegistration'>, orgId: string, recordScopeId: (key: string, id: string) => void, scopeKey?: string): Promise<void>;
12
18
  /** Scope id: the domain the sign-in server still refused to make primary after foundry set its domain policy and added the domain again. Set instead of failing; the planner reports it and plans the domain again. */
@@ -27,12 +33,17 @@ export declare function signInOrgDomain(org: {
27
33
  slug: string;
28
34
  domain: string | null;
29
35
  }): string;
36
+ /** The fixed text for a domain the sign-in server already holds on another organisation. */
37
+ export declare function signInDomainTakenText(domain: string): string;
30
38
  /**
31
- * Sets the server's domain policy ({@link SIGN_IN_DOMAIN_POLICY}), adds
32
- * `domain` to the ZITADEL org and makes it primary. Every call is idempotent.
33
- * A domain added while the policy still wanted proof stays unverified, so it
34
- * is removed and added again. A failure after the add leaves the domain on
35
- * the org but not primary, and nothing is recorded, so the next run repeats.
39
+ * Adds `domain` to the ZITADEL org, sets the server's domain policy
40
+ * ({@link SIGN_IN_DOMAIN_POLICY}) and makes the domain primary. Every call is
41
+ * idempotent. The add comes first, so foundry holds the domain before the
42
+ * policy stops asking for proof of ownership. A domain added while the policy
43
+ * still wanted proof stays unverified, so it is removed and added again (the
44
+ * remove-and-add gap: the domain is off the org for up to five seconds; known,
45
+ * left as is). A failure after the add leaves the domain on the org but not
46
+ * primary, and nothing is recorded, so the next run repeats.
36
47
  */
37
48
  export declare function registerSignInDomain(zitadel: Pick<ZitadelPort, 'ensureDomainPolicy' | 'addOrgDomain' | 'removeOrgDomain' | 'setPrimaryOrgDomain'>, orgId: string, domain: string, sleep?: (ms: number) => Promise<void>): Promise<void>;
38
49
  /**
@@ -41,7 +52,7 @@ export declare function registerSignInDomain(zitadel: Pick<ZitadelPort, 'ensureD
41
52
  * `blockedScopeKey` and returns `false` instead of throwing on every apply.
42
53
  * Any other failure throws. Returns `true` when the domain is primary.
43
54
  */
44
- export declare function registerSignInDomainOrBlock(zitadel: Pick<ZitadelPort, 'ensureDomainPolicy' | 'addOrgDomain' | 'removeOrgDomain' | 'setPrimaryOrgDomain'>, orgId: string, domain: string, recordScopeId: (key: string, id: string) => void, blockedScopeKey?: string): Promise<boolean>;
55
+ export declare function registerSignInDomainOrBlock(zitadel: Pick<ZitadelPort, 'ensureDomainPolicy' | 'addOrgDomain' | 'removeOrgDomain' | 'setPrimaryOrgDomain'>, orgId: string, domain: string, recordScopeId: (key: string, id: string) => void, blockedScopeKey?: string, sleep?: (ms: number) => Promise<void>): Promise<boolean>;
45
56
  export interface EnsureSignInOrgOptions {
46
57
  zitadel: Pick<ZitadelPort, 'findOrCreateOrg' | 'ensureDomainPolicy' | 'addOrgDomain' | 'removeOrgDomain' | 'setPrimaryOrgDomain' | 'closeSelfRegistration'>;
47
58
  /** Where the successful close is recorded (the org id). Absent: not recorded; the caller closes on every run. */
@@ -60,6 +71,8 @@ export interface EnsureSignInOrgOptions {
60
71
  orgScopeKey?: string;
61
72
  /** Defaults to {@link ZITADEL_ORG_DOMAIN_SCOPE_KEY}. */
62
73
  domainScopeKey?: string;
74
+ /** Waits between tries of the domain calls; a test passes a no-op. */
75
+ sleep?: (ms: number) => Promise<void>;
63
76
  }
64
77
  /**
65
78
  * Finds or creates the company's sign-in organisation by name, registers
@@ -6,6 +6,12 @@ export const ZITADEL_ORG_DOMAIN_SCOPE_KEY = 'zitadel_org_domain';
6
6
  * names another org, the company plan holds the close step and no owner invite.
7
7
  */
8
8
  export const ZITADEL_ORG_REGISTRATION_CLOSED_SCOPE_KEY = 'zitadel_org_registration_closed';
9
+ /**
10
+ * Scope id: the id of the customers org whose self-registration foundry
11
+ * closed, recorded only after `closeSelfRegistration` returned. Foundry closes
12
+ * once and never opens: a company that turns sign-up on keeps it on.
13
+ */
14
+ export const ZITADEL_CUSTOMERS_REGISTRATION_CLOSED_SCOPE_KEY = 'zitadel_customers_registration_closed';
9
15
  /** Closes self-registration on the org (idempotent) and records it under `scopeKey`, in that order. */
10
16
  export async function closeSignInOrgRegistration(zitadel, orgId, recordScopeId, scopeKey = ZITADEL_ORG_REGISTRATION_CLOSED_SCOPE_KEY) {
11
17
  await zitadel.closeSelfRegistration(orgId);
@@ -51,16 +57,35 @@ async function untilListed(work, retry, sleep) {
51
57
  }
52
58
  }
53
59
  }
60
+ /** The fixed text for a domain the sign-in server already holds on another organisation. */
61
+ export function signInDomainTakenText(domain) {
62
+ return `the sign-in domain ${domain} already exists on another organisation of the sign-in server, so foundry cannot add it; remove it there and apply again`;
63
+ }
64
+ /** Adds the domain; an "already exists" answer becomes {@link signInDomainTakenText}, which holds no cause. */
65
+ async function addDomain(zitadel, orgId, domain) {
66
+ try {
67
+ await zitadel.addOrgDomain(orgId, domain);
68
+ }
69
+ catch (error) {
70
+ if (error instanceof Error && /already[ .]?exists/i.test(error.message)) {
71
+ throw new Error(signInDomainTakenText(domain));
72
+ }
73
+ throw error;
74
+ }
75
+ }
54
76
  /**
55
- * Sets the server's domain policy ({@link SIGN_IN_DOMAIN_POLICY}), adds
56
- * `domain` to the ZITADEL org and makes it primary. Every call is idempotent.
57
- * A domain added while the policy still wanted proof stays unverified, so it
58
- * is removed and added again. A failure after the add leaves the domain on
59
- * the org but not primary, and nothing is recorded, so the next run repeats.
77
+ * Adds `domain` to the ZITADEL org, sets the server's domain policy
78
+ * ({@link SIGN_IN_DOMAIN_POLICY}) and makes the domain primary. Every call is
79
+ * idempotent. The add comes first, so foundry holds the domain before the
80
+ * policy stops asking for proof of ownership. A domain added while the policy
81
+ * still wanted proof stays unverified, so it is removed and added again (the
82
+ * remove-and-add gap: the domain is off the org for up to five seconds; known,
83
+ * left as is). A failure after the add leaves the domain on the org but not
84
+ * primary, and nothing is recorded, so the next run repeats.
60
85
  */
61
86
  export async function registerSignInDomain(zitadel, orgId, domain, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))) {
87
+ await addDomain(zitadel, orgId, domain);
62
88
  await zitadel.ensureDomainPolicy(SIGN_IN_DOMAIN_POLICY);
63
- await zitadel.addOrgDomain(orgId, domain);
64
89
  // The sign-in server lists a domain a moment after it accepts the add, and
65
90
  // `setPrimaryOrgDomain` reads that list first: ask again, for five seconds.
66
91
  try {
@@ -75,7 +100,7 @@ export async function registerSignInDomain(zitadel, orgId, domain, sleep = (ms)
75
100
  // Until the server lists the removal, the add sees the old unverified
76
101
  // domain and skips: both answers are a moment late, so ask again.
77
102
  await untilListed(async () => {
78
- await zitadel.addOrgDomain(orgId, domain);
103
+ await addDomain(zitadel, orgId, domain);
79
104
  await zitadel.setPrimaryOrgDomain(orgId, domain);
80
105
  }, (error) => isDomainNotListed(error) || isPrimaryDomainRefused(error), sleep);
81
106
  }
@@ -85,9 +110,9 @@ export async function registerSignInDomain(zitadel, orgId, domain, sleep = (ms)
85
110
  * `blockedScopeKey` and returns `false` instead of throwing on every apply.
86
111
  * Any other failure throws. Returns `true` when the domain is primary.
87
112
  */
88
- export async function registerSignInDomainOrBlock(zitadel, orgId, domain, recordScopeId, blockedScopeKey = ZITADEL_ORG_DOMAIN_BLOCKED_SCOPE_KEY) {
113
+ export async function registerSignInDomainOrBlock(zitadel, orgId, domain, recordScopeId, blockedScopeKey = ZITADEL_ORG_DOMAIN_BLOCKED_SCOPE_KEY, sleep) {
89
114
  try {
90
- await registerSignInDomain(zitadel, orgId, domain);
115
+ await registerSignInDomain(zitadel, orgId, domain, sleep);
91
116
  return true;
92
117
  }
93
118
  catch (error) {
@@ -115,7 +140,7 @@ export async function ensureSignInOrg(options) {
115
140
  if (options.registrationClosedScopeKey) {
116
141
  recordScopeId(options.registrationClosedScopeKey, org.id);
117
142
  }
118
- const registered = await registerSignInDomainOrBlock(zitadel, org.id, domain, recordScopeId, options.domainBlockedScopeKey);
143
+ const registered = await registerSignInDomainOrBlock(zitadel, org.id, domain, recordScopeId, options.domainBlockedScopeKey, options.sleep);
119
144
  if (!registered)
120
145
  return { orgId: org.id, created, domainBlocked: true };
121
146
  recordScopeId(options.domainScopeKey ?? ZITADEL_ORG_DOMAIN_SCOPE_KEY, domain);
@@ -31,10 +31,13 @@ export declare const SIGN_IN_LOGIN_APP_SCOPE_KEY = "coolify_platform_auth_login_
31
31
  * 1. on: the login-host step trusts each app host ({@link SIGN_IN_TRUSTED_SCOPE_KEY}).
32
32
  * 2. on: the app step writes and deploys `SIGNIN_UPSTREAM` and records it live ({@link SIGN_IN_ENV_LIVE_SCOPE_PREFIX}).
33
33
  * 3. on: the login-host step binds each app host and points the base URI at `https://<app host>/sign-in/`
34
- * ({@link SIGN_IN_IN_APP_SCOPE_KEY}). The login refuses a client id held by two bindings, so the bind moves
35
- * the app's client id off the company's binding at the same write: that write and the base URI are the only
36
- * moment the old path can fail, and they are the last two calls.
37
- * Off: the login-host step moves the client id back and points the base URI back at the login host; the app
34
+ * ({@link SIGN_IN_IN_APP_SCOPE_KEY}). From auth 0.28.0 (one client may sit in two bindings of one company)
35
+ * this is three writes: the app host's binding with the client id kept on the company's, the base URI, then
36
+ * the company's binding without the id; sign-in works at every point. Before that release the login refuses
37
+ * a client id held by two bindings, so one write moves the id off the company's binding: that write and the
38
+ * base URI are the only moment the old path can fail, and they are the last two calls.
39
+ * Off: the login-host step moves the client id back and points the base URI back at the login host (0.28.0 and
40
+ * later: the company binding takes the id first and the app binding goes after the base URI); the app
38
41
  * step then drops `SIGNIN_UPSTREAM`; the login-host step then gives up the trusted domain.
39
42
  */
40
43
  /**
@@ -70,7 +73,11 @@ export declare const SIGN_IN_RATE_LIMIT_EXPRESSION = "(starts_with(http.request.
70
73
  * today's flow even when the switch is set (the declaration is refused: `org.ts`'s `validateSignIn`).
71
74
  */
72
75
  export declare function inAppSignIn(org: Pick<DeclaredOrg, 'signIn' | 'domain' | 'issuer' | 'services'>): boolean;
73
- /** True when an app's live env holds `SIGNIN_UPSTREAM` and the desired env does not: it is to be removed. */
76
+ /**
77
+ * True when an app's live env holds `SIGNIN_UPSTREAM` and the desired env does not: it is to be removed.
78
+ * Accepted behaviour: a `SIGNIN_UPSTREAM` added by hand on Agora or Boule is drift like any other env foundry
79
+ * owns, so the next apply removes it. Foundry writes the value itself (`signInUpstream`) while the switch is on.
80
+ */
74
81
  export declare function staleSignInUpstream(desired: Readonly<Record<string, string>>, live: Readonly<Record<string, string>>): boolean;
75
82
  /** The build argument (a build-time env in Coolify) that fixes the login's base path. */
76
83
  export declare const SIGN_IN_BUILD_ARG_ENV = "NEXT_PUBLIC_BASE_PATH";
@@ -31,10 +31,13 @@ export const SIGN_IN_LOGIN_APP_SCOPE_KEY = 'coolify_platform_auth_login_sign_in_
31
31
  * 1. on: the login-host step trusts each app host ({@link SIGN_IN_TRUSTED_SCOPE_KEY}).
32
32
  * 2. on: the app step writes and deploys `SIGNIN_UPSTREAM` and records it live ({@link SIGN_IN_ENV_LIVE_SCOPE_PREFIX}).
33
33
  * 3. on: the login-host step binds each app host and points the base URI at `https://<app host>/sign-in/`
34
- * ({@link SIGN_IN_IN_APP_SCOPE_KEY}). The login refuses a client id held by two bindings, so the bind moves
35
- * the app's client id off the company's binding at the same write: that write and the base URI are the only
36
- * moment the old path can fail, and they are the last two calls.
37
- * Off: the login-host step moves the client id back and points the base URI back at the login host; the app
34
+ * ({@link SIGN_IN_IN_APP_SCOPE_KEY}). From auth 0.28.0 (one client may sit in two bindings of one company)
35
+ * this is three writes: the app host's binding with the client id kept on the company's, the base URI, then
36
+ * the company's binding without the id; sign-in works at every point. Before that release the login refuses
37
+ * a client id held by two bindings, so one write moves the id off the company's binding: that write and the
38
+ * base URI are the only moment the old path can fail, and they are the last two calls.
39
+ * Off: the login-host step moves the client id back and points the base URI back at the login host (0.28.0 and
40
+ * later: the company binding takes the id first and the app binding goes after the base URI); the app
38
41
  * step then drops `SIGNIN_UPSTREAM`; the login-host step then gives up the trusted domain.
39
42
  */
40
43
  /**
@@ -76,7 +79,11 @@ export const SIGN_IN_RATE_LIMIT_EXPRESSION = '(starts_with(http.request.uri.path
76
79
  export function inAppSignIn(org) {
77
80
  return org.signIn?.inApp === true && Boolean(org.domain) && org.issuer.usesDomain;
78
81
  }
79
- /** True when an app's live env holds `SIGNIN_UPSTREAM` and the desired env does not: it is to be removed. */
82
+ /**
83
+ * True when an app's live env holds `SIGNIN_UPSTREAM` and the desired env does not: it is to be removed.
84
+ * Accepted behaviour: a `SIGNIN_UPSTREAM` added by hand on Agora or Boule is drift like any other env foundry
85
+ * owns, so the next apply removes it. Foundry writes the value itself (`signInUpstream`) while the switch is on.
86
+ */
80
87
  export function staleSignInUpstream(desired, live) {
81
88
  const held = live[SIGN_IN_UPSTREAM_ENV];
82
89
  return desired[SIGN_IN_UPSTREAM_ENV] === undefined && held !== undefined && held !== '';
@@ -1,4 +1,5 @@
1
1
  import { pinnedRef } from '../bootstrap/versions.js';
2
+ import { JobFailure, waitForJob as waitForJobApp } from './job-app.js';
2
3
  import { PLATFORM_AUTH_DB_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_SCOPE_KEY, findOrCreatePlatformApp, } from './platform-providers.js';
3
4
  import { SIGNIN_DB_TLS_SCOPE_KEY, SIGNIN_DB_ZITADEL_PENDING_SCOPE_KEY } from './signin-db-tls.js';
4
5
  import { SIGNIN_DB_NAME, SIGNIN_DB_PORT, SIGNIN_DB_USER, boundedCall, dockerfileAppCreator, ensureSigninDbCaFile, provisionSigninDb, waitHealthy, withCallTimeout, } from './signin-db.js';
@@ -94,41 +95,16 @@ export function signinDbMoveNewUrl(host, password, database) {
94
95
  class MoveFailure extends Error {
95
96
  }
96
97
  const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
97
- /** Polls of an `exited` job with no `running` seen before it counts as a stop (an `exited` read can be the container before the deploy). */
98
- const JOB_EXITED_POLLS = 6;
99
- /**
100
- * Waits for the copy job to report healthy, which it does only after the counts match. The job never
101
- * exits on success, so any exit is final: `restarting` at once, `exited` once the job was seen running
102
- * or stayed exited for {@link JOB_EXITED_POLLS} polls. It has failed too at the deadline. A healthy
103
- * status read before any other could be the container before the stop, so it counts only after a
104
- * different status was seen.
105
- */
98
+ /** Waits for the copy job to report healthy, which it does only after the counts match. */
106
99
  async function waitForJob(coolify, appId, runtime) {
107
- const now = runtime.now ?? Date.now;
108
- const sleep = runtime.sleep ?? defaultSleep;
109
- const timeout = runtime.signinDbMoveTimeoutMs ?? 1_800_000;
110
- const interval = runtime.signinDbMovePollMs ?? 10_000;
111
- const deadline = now() + timeout;
112
- let left = false;
113
- let running = false;
114
- let exitedPolls = 0;
115
- for (;;) {
116
- const { status } = await coolify.getApplication(appId);
117
- if (status === 'running:healthy' && left)
118
- return;
119
- if (status !== 'running:healthy')
120
- left = true;
121
- if (status?.startsWith('running'))
122
- running = true;
123
- exitedPolls = status?.startsWith('exited') ? exitedPolls + 1 : 0;
124
- if (status?.startsWith('restarting') ||
125
- (status?.startsWith('exited') && (running || exitedPolls >= JOB_EXITED_POLLS))) {
126
- throw new MoveFailure('the copy job stopped before the row counts matched');
127
- }
128
- if (now() >= deadline) {
129
- throw new MoveFailure(`the copy job did not finish within ${Math.round(timeout / 1000)}s (last status: ${status ?? 'unknown'})`);
130
- }
131
- await sleep(interval);
100
+ try {
101
+ await waitForJobApp(coolify, appId, runtime, {
102
+ job: 'the copy job',
103
+ stopped: 'the copy job stopped before the row counts matched',
104
+ });
105
+ }
106
+ catch (error) {
107
+ throw error instanceof JobFailure ? new MoveFailure(error.message) : error;
132
108
  }
133
109
  }
134
110
  /** Coolify queues a stop. Waits until it reports the app stopped; `pg_dump` must not run while ZITADEL can still write. */
@@ -1,5 +1,5 @@
1
1
  import { createHash, randomBytes } from 'node:crypto';
2
- import { WORKER_APP_HYPERDRIVE_BINDING, WORKER_APP_KEYS_SECRET, WORKER_APP_KEYS_URL_VAR, workerAppHostId, } from '../worker-app-config.js';
2
+ import { WORKER_APP_HYPERDRIVE_BINDING, WORKER_APP_KEYS_SECRET, WORKER_APP_KEYS_URL_VAR, workerAppHasBuild, workerAppHostId, } from '../worker-app-config.js';
3
3
  import { entryAuthEnv } from './broker-entries.js';
4
4
  import { workerAppHostname } from './hostnames.js';
5
5
  import { appStepKey } from './platform-diff.js';
@@ -315,6 +315,8 @@ export function planWorkerApps(org, recordedIds, input) {
315
315
  description: `${at} is blocked, not planned: ${why}`,
316
316
  key,
317
317
  });
318
+ if (!workerAppHasBuild(app))
319
+ continue;
318
320
  if (!input.available) {
319
321
  block('the host supplies no WorkerAppPort');
320
322
  continue;
@@ -0,0 +1,7 @@
1
+ import { type DeclaredOrg, type OrgRefusal } from './org.js';
2
+ /**
3
+ * The first refusal of the whole-company validation a store write runs for
4
+ * hosting (every entry, its sign-in included) and customers, or `null`. Locked
5
+ * names are the org's own; the platform's service names are not known here.
6
+ */
7
+ export declare function refuseOrg(org: DeclaredOrg): OrgRefusal | null;
@@ -0,0 +1,10 @@
1
+ import { refuseCustomers, refuseHosting } from './org.js';
2
+ import { hostingNames } from './plan/locked-hostnames.js';
3
+ /**
4
+ * The first refusal of the whole-company validation a store write runs for
5
+ * hosting (every entry, its sign-in included) and customers, or `null`. Locked
6
+ * names are the org's own; the platform's service names are not known here.
7
+ */
8
+ export function refuseOrg(org) {
9
+ return refuseHosting(org.hosting, hostingNames(org, [])) ?? refuseCustomers(org);
10
+ }
@@ -1,4 +1,5 @@
1
- import { type DeclaredOrg, type MailMode, type OrgId } from './org.js';
1
+ import type { BuildJson } from './build-json.js';
2
+ import { type CustomersConfig, type DeclaredOrg, type HostingEntry, type MailMode, type OrgId } from './org.js';
2
3
  import type { PlanDetail } from './plan/diff.js';
3
4
  /**
4
5
  * The whitelist of `DeclaredOrg` fields a company's own Boule can request a
@@ -15,7 +16,7 @@ import type { PlanDetail } from './plan/diff.js';
15
16
  * lowering or leaving a limit unchanged -- raising either one needs
16
17
  * approval too, until a billing field exists to say what the raise costs.
17
18
  */
18
- export type RequestableField = 'calls' | 'customers' | 'storageLimit' | 'aiLimit' | 'domain' | 'mailTier';
19
+ export type RequestableField = 'calls' | 'customers' | 'storageLimit' | 'aiLimit' | 'domain' | 'mailTier' | 'app' | 'build';
19
20
  export declare const REQUESTABLE_FIELDS: readonly RequestableField[];
20
21
  /** Turn the org's own calls Matrix stack (`org.ts`'s `CallsConfig.enabled`) on or off. */
21
22
  export interface CallsChange {
@@ -52,12 +53,34 @@ export interface MailTierChange {
52
53
  field: 'mailTier';
53
54
  mode: MailMode;
54
55
  }
56
+ /**
57
+ * Put or remove one hosting entry of the company's own app (`org.ts`'s `HostingEntry`).
58
+ * `before` and `after` are the entry as the requester saw it and as it should be (null when absent).
59
+ * `customers` is the switches after the change when the request changes them, else null.
60
+ * Archon validates the whole company; {@link classifyChange} does not look at the org for this kind.
61
+ */
62
+ export interface AppChange {
63
+ field: 'app';
64
+ key: string;
65
+ op: 'put' | 'remove';
66
+ before: HostingEntry | null;
67
+ after: HostingEntry | null;
68
+ customers: CustomersConfig | null;
69
+ }
70
+ /** Deploy one release of the company's app from its `build.json` (`build-json.ts`). */
71
+ export interface BuildChange {
72
+ field: 'build';
73
+ key: string;
74
+ repo: string;
75
+ release: string;
76
+ build: BuildJson;
77
+ }
55
78
  /**
56
79
  * One company-requested change to its own declared org, keyed by
57
80
  * {@link RequestableField}. `classifyChange` and the store both switch on
58
81
  * `.field`.
59
82
  */
60
- export type RequestedChangeValue = CallsChange | CustomersChange | StorageLimitChange | AiLimitChange | DomainChange | MailTierChange;
83
+ export type RequestedChangeValue = CallsChange | CustomersChange | StorageLimitChange | AiLimitChange | DomainChange | MailTierChange | AppChange | BuildChange;
61
84
  /**
62
85
  * `auto`: nothing further is required -- archon may carry the request
63
86
  * straight into a plan and apply it. `approval`: a human must approve the
@@ -72,7 +95,21 @@ export type ChangeClassification = 'auto' | 'approval';
72
95
  * onward. Foundry only stores and classifies (#178 "Why"); nothing in this
73
96
  * package transitions a state past its initial value.
74
97
  */
75
- export type RequestedChangeState = 'auto' | 'needs_approval' | 'approved' | 'rejected' | 'applied';
98
+ export type RequestedChangeState = 'auto' | 'needs_approval' | 'approved' | 'rejected' | 'applied' | 'applying' | 'failed';
99
+ /** Why an `app` or `build` request is `failed`. */
100
+ export type FailureCode = 'replaced' | 'refused' | 'busy' | 'timed_out' | 'apply_error';
101
+ /** Who asked: a person in Boule, or a release build. `requestedBy` keeps the raw principal id for audit. */
102
+ export type RequestedBy = {
103
+ via: 'boule';
104
+ person: {
105
+ id: string;
106
+ name: string;
107
+ };
108
+ } | {
109
+ via: 'build';
110
+ repo: string;
111
+ release: string;
112
+ };
76
113
  /**
77
114
  * One company-requested change to its own declared org, as stored --
78
115
  * `migrations/0017_requested_changes.sql`'s `foundry_requested_changes`
@@ -85,6 +122,15 @@ export interface RequestedChange {
85
122
  requestedBy: string;
86
123
  createdAt: string;
87
124
  state: RequestedChangeState;
125
+ /** Null for rows made before `app` and `build` requests. */
126
+ requester: RequestedBy | null;
127
+ idempotencyKey: string | null;
128
+ reason: {
129
+ code: FailureCode;
130
+ message: string;
131
+ replacedBy?: number;
132
+ } | null;
133
+ updatedAt: string;
88
134
  }
89
135
  /**
90
136
  * Thrown by {@link classifyChange} (and by the requested-change store's
@@ -1,4 +1,4 @@
1
- import { validateCustomers } from './org.js';
1
+ import { validateCustomers, } from './org.js';
2
2
  export const REQUESTABLE_FIELDS = [
3
3
  'calls',
4
4
  'customers',
@@ -6,6 +6,8 @@ export const REQUESTABLE_FIELDS = [
6
6
  'aiLimit',
7
7
  'domain',
8
8
  'mailTier',
9
+ 'app',
10
+ 'build',
9
11
  ];
10
12
  /**
11
13
  * Thrown by {@link classifyChange} (and by the requested-change store's
@@ -42,6 +44,9 @@ function applyRequestedChange(org, change) {
42
44
  return { ...org, domain: change.domain };
43
45
  case 'mailTier':
44
46
  return { ...org, mail: { ...org.mail, mode: change.mode } };
47
+ case 'app':
48
+ case 'build':
49
+ return org;
45
50
  }
46
51
  }
47
52
  /**
@@ -115,6 +120,9 @@ export function classifyChange(org, change, plan) {
115
120
  if (!REQUESTABLE_FIELDS.includes(change.field)) {
116
121
  throw new RequestedChangeError(`'${change.field}' is not a requestable field`);
117
122
  }
123
+ // Archon validates the whole company for these; nothing waits for approval.
124
+ if (change.field === 'app' || change.field === 'build')
125
+ return 'auto';
118
126
  if (change.field === 'customers') {
119
127
  validateCustomersChange(change);
120
128
  // The org after the change must still pass the rule apply checks, for example open registration
@@ -1,4 +1,5 @@
1
1
  import type { PgDatabase, PgQueryResultHKT } from 'drizzle-orm/pg-core';
2
+ import type { OrgRefusal, OrgRefusalCode } from '../org.js';
2
3
  import { type ReleaseStore } from '../release/state.js';
3
4
  import { type AppliedVersionsStore, type DeclaredOrgStore, type PlatformStore, type RequestedChangeStore } from '../store.js';
4
5
  /**
@@ -38,7 +39,10 @@ export declare function uniqueViolationConstraint(error: unknown): string | unde
38
39
  * is that throw.
39
40
  */
40
41
  export declare class DeclaredOrgError extends Error {
41
- constructor(message: string);
42
+ /** Set when a hosting or customers validator refused the write: which rule, and where in the org. */
43
+ readonly code?: OrgRefusalCode;
44
+ readonly path?: string;
45
+ constructor(message: string, refusal?: Pick<OrgRefusal, 'code' | 'path'>);
42
46
  }
43
47
  /**
44
48
  * `DeclaredOrgStore` (`../store.ts`) on Postgres, for #18: create, read and