@wtfalch/foundry 0.33.1 → 0.35.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 (106) hide show
  1. package/dist/bootstrap/bootstrap.d.ts +1 -1
  2. package/dist/bootstrap/bootstrap.js +49 -11
  3. package/dist/bootstrap/declare.d.ts +12 -5
  4. package/dist/bootstrap/declare.js +33 -18
  5. package/dist/bootstrap/versions.d.ts +24 -0
  6. package/dist/bootstrap/versions.js +46 -0
  7. package/dist/calls-sfu.d.ts +10 -0
  8. package/dist/calls-sfu.js +44 -3
  9. package/dist/index.d.ts +21 -8
  10. package/dist/index.js +11 -4
  11. package/dist/migrations/0051_org_services.sql +13 -0
  12. package/dist/migrations/0052_platform_mail_relay.sql +8 -0
  13. package/dist/migrations/0053_heartbeats_provider.sql +9 -0
  14. package/dist/migrations/0053_org_version_pin.sql +6 -0
  15. package/dist/org.d.ts +76 -12
  16. package/dist/org.js +136 -20
  17. package/dist/plan/backup-gate.d.ts +93 -0
  18. package/dist/plan/backup-gate.js +261 -0
  19. package/dist/plan/broker-entries.d.ts +9 -1
  20. package/dist/plan/broker-entries.js +20 -3
  21. package/dist/plan/build-record.d.ts +56 -0
  22. package/dist/plan/build-record.js +109 -0
  23. package/dist/plan/calls-matrix.d.ts +9 -0
  24. package/dist/plan/calls-matrix.js +18 -1
  25. package/dist/plan/cloudflared-connector.d.ts +7 -1
  26. package/dist/plan/cloudflared-connector.js +15 -2
  27. package/dist/plan/company-backups.d.ts +31 -0
  28. package/dist/plan/company-backups.js +101 -0
  29. package/dist/plan/deploy-health.d.ts +12 -2
  30. package/dist/plan/deploy-health.js +3 -1
  31. package/dist/plan/destinations.d.ts +73 -0
  32. package/dist/plan/destinations.js +217 -0
  33. package/dist/plan/diff.d.ts +46 -8
  34. package/dist/plan/diff.js +367 -126
  35. package/dist/plan/edge-access.d.ts +15 -0
  36. package/dist/plan/edge-access.js +21 -2
  37. package/dist/plan/executor.d.ts +276 -5
  38. package/dist/plan/executor.js +1111 -109
  39. package/dist/plan/health-check-settings.d.ts +28 -0
  40. package/dist/plan/health-check-settings.js +36 -0
  41. package/dist/plan/health-gaps.d.ts +33 -0
  42. package/dist/plan/health-gaps.js +89 -0
  43. package/dist/plan/health-targets.d.ts +1 -3
  44. package/dist/plan/health-targets.js +36 -36
  45. package/dist/plan/held-key-refs.d.ts +15 -0
  46. package/dist/plan/held-key-refs.js +15 -0
  47. package/dist/plan/hostnames.d.ts +10 -0
  48. package/dist/plan/hostnames.js +14 -0
  49. package/dist/plan/index.d.ts +19 -6
  50. package/dist/plan/index.js +10 -3
  51. package/dist/plan/live.d.ts +11 -2
  52. package/dist/plan/live.js +16 -2
  53. package/dist/plan/locked-hostnames.d.ts +7 -3
  54. package/dist/plan/locked-hostnames.js +25 -4
  55. package/dist/plan/login-host.d.ts +61 -12
  56. package/dist/plan/login-host.js +99 -11
  57. package/dist/plan/mail-relay.d.ts +139 -0
  58. package/dist/plan/mail-relay.js +400 -0
  59. package/dist/plan/network-move.d.ts +20 -0
  60. package/dist/plan/network-move.js +41 -0
  61. package/dist/plan/oikos-ingress.d.ts +111 -0
  62. package/dist/plan/oikos-ingress.js +231 -0
  63. package/dist/plan/org-names.d.ts +30 -0
  64. package/dist/plan/org-names.js +108 -0
  65. package/dist/plan/own-diff.d.ts +170 -0
  66. package/dist/plan/own-diff.js +440 -0
  67. package/dist/plan/own-dns.d.ts +6 -0
  68. package/dist/plan/own-dns.js +15 -0
  69. package/dist/plan/own-hostnames.d.ts +27 -0
  70. package/dist/plan/own-hostnames.js +24 -0
  71. package/dist/plan/own-limits.d.ts +213 -0
  72. package/dist/plan/own-limits.js +232 -0
  73. package/dist/plan/own-services.d.ts +193 -0
  74. package/dist/plan/own-services.js +169 -0
  75. package/dist/plan/own-sfu.d.ts +69 -0
  76. package/dist/plan/own-sfu.js +256 -0
  77. package/dist/plan/plan-apply.d.ts +121 -10
  78. package/dist/plan/plan-apply.js +357 -18
  79. package/dist/plan/platform-diff.d.ts +155 -11
  80. package/dist/plan/platform-diff.js +271 -66
  81. package/dist/plan/platform-org.d.ts +107 -4
  82. package/dist/plan/platform-org.js +175 -3
  83. package/dist/plan/platform-providers.d.ts +474 -81
  84. package/dist/plan/platform-providers.js +1407 -560
  85. package/dist/plan/proxy-labels.d.ts +57 -11
  86. package/dist/plan/proxy-labels.js +94 -14
  87. package/dist/plan/run-options.d.ts +19 -0
  88. package/dist/plan/run-options.js +46 -0
  89. package/dist/plan/sign-in-org.d.ts +1 -1
  90. package/dist/plan/sign-in-org.js +20 -2
  91. package/dist/plan/zitadel-login-policy.d.ts +1 -1
  92. package/dist/plan/zitadel-login-policy.js +77 -9
  93. package/dist/plan/zitadel-system-api.d.ts +22 -1
  94. package/dist/plan/zitadel-system-api.js +49 -8
  95. package/dist/platform.d.ts +40 -0
  96. package/dist/platform.js +16 -0
  97. package/dist/provision.d.ts +2 -0
  98. package/dist/provision.js +3 -1
  99. package/dist/slug.d.ts +3 -1
  100. package/dist/slug.js +26 -3
  101. package/dist/store/postgres.js +93 -2
  102. package/dist/store/schema.d.ts +87 -0
  103. package/dist/store/schema.js +9 -0
  104. package/package.json +3 -3
  105. package/versions.digests.json +11 -0
  106. package/versions.json +3 -1
@@ -8,22 +8,55 @@ export interface EntryLabelSpec {
8
8
  wildcardDomain: string | null;
9
9
  port: number;
10
10
  }
11
+ /**
12
+ * The label every routed container carries, with the install's token as its value. Traefik is told
13
+ * (`--providers.docker.constraints=Label(`foundry.edge`,`<token>`)`) to route only containers that carry
14
+ * it, so a route an image declares in its own `LABEL`s, which cannot know the token, is ignored.
15
+ */
16
+ export declare const EDGE_LABEL = "foundry.edge";
11
17
  /**
12
18
  * The scope key holding the hash of the label set last patched onto an
13
19
  * entry's app. Present only for an entry that has had a custom label set.
14
20
  */
15
21
  export declare function appLabelsAppliedScopeKey(entryKey: string): string;
22
+ /** The entry routes ({@link entryRoutesMarker}) the company tunnel's ingress was last written with. */
23
+ export declare const CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY = "cloudflare_tunnel_entry_routes";
24
+ /**
25
+ * The entry names in the platform's oikos.host zone ({@link entryOikosHostnames}) the company tunnel's records were
26
+ * last written for, comma-joined. Recorded only once every record is in place.
27
+ */
28
+ export declare const CLOUDFLARE_TUNNEL_OIKOS_HOSTNAMES_SCOPE_KEY = "cloudflare_tunnel_oikos_hostnames";
29
+ /**
30
+ * The run label every customer hosting-entry container gets. A run label overrides the image's own
31
+ * `LABEL` of the same key, so Traefik ignores the container whatever its image says: a customer image
32
+ * cannot route a hostname it does not own. The company's tunnel reaches the container directly
33
+ * ({@link entryRoutes}); no Traefik router and no Coolify `domains` are involved.
34
+ */
35
+ export declare const ENTRY_CONTAINER_LABELS = "traefik.enable=false";
36
+ /**
37
+ * What an entry's routing is built from: the names it is served at, the wildcard domain when it
38
+ * declares `'*'`, and the port its container listens on. Every entry has one, because every entry
39
+ * carries {@link ENTRY_CONTAINER_LABELS}. Coolify's default port for an app is 80.
40
+ */
41
+ export declare function entryLabelSpec(org: DeclaredOrg, entry: HostingEntry): EntryLabelSpec;
42
+ /**
43
+ * The company tunnel's ingress for the hosting entries: each name, and the wildcard last, goes straight to
44
+ * `http://<container name>:<port>` on the company network, never through `coolify-proxy`. A wildcard
45
+ * entry gets a wildcard hostname rule; the first entry that declares `'*'` owns it, and the first entry
46
+ * that declares a name owns it. An entry with no name (internal) has no rule. This holds for a company
47
+ * without a domain too: its `<slug>.oikos.host` name is routed by the company's own tunnel
48
+ * ({@link entryOikosHostnames}), not by the platform's shared ingress.
49
+ */
50
+ export declare function entryRoutes(org: DeclaredOrg): Array<{
51
+ hostname: string;
52
+ service: string;
53
+ }>;
54
+ /** The scope value recording the routes the tunnel step last wrote; the planner compares it to {@link entryRoutes}. */
55
+ export declare function entryRoutesMarker(org: DeclaredOrg): string;
16
56
  /**
17
- * The label set an entry's app needs, or `null` when it needs none. Coolify
18
- * replaces the labels it generates from `domains` once `custom_labels` is
19
- * set, so an entry with `'*'` carries its exact `Host` rules as well. An entry
20
- * that already has a custom set (`hadLabels`) keeps one, without the wildcard
21
- * rule, so a removed `'*'` stops matching and a later name change still
22
- * reaches the app: an empty `custom_labels` is refused by Coolify's API.
23
- * Coolify's default port for an app is 80.
57
+ * A short digest of the spec; the same spec always gives the same hash. The token is left out: the
58
+ * planner has none, and a changed token reaches a live app through the comparison of its live labels.
24
59
  */
25
- export declare function entryLabelSpec(org: DeclaredOrg, entry: HostingEntry, hadLabels: boolean): EntryLabelSpec | null;
26
- /** A short digest of the spec; the same spec always gives the same hash. */
27
60
  export declare function entryLabelHash(spec: EntryLabelSpec): string;
28
61
  /**
29
62
  * The Traefik labels for an entry's app, one per line. Coolify reaches an app
@@ -32,11 +65,24 @@ export declare function entryLabelHash(spec: EntryLabelSpec): string;
32
65
  * `Host(name) && PathPrefix(/)`, default priority. The wildcard is one
33
66
  * `HostRegexp` router at `priority=1`, so any exact name, this app's or
34
67
  * another's, wins over it. The regex matches one label (`[a-z0-9-]+`), the
35
- * same depth Cloudflare's Universal certificate covers.
68
+ * same depth Cloudflare's Universal certificate covers. A set that routes
69
+ * anything carries {@link EDGE_LABEL}; the "no route" set does not need it.
36
70
  */
37
- export declare function entryProxyLabels(appUuid: string, spec: EntryLabelSpec): string;
71
+ export declare function entryProxyLabels(appUuid: string, spec: EntryLabelSpec, token: string): string;
72
+ /** Whether a label set routes something but does not carry the token: Traefik would ignore it once the constraint is on. */
73
+ export declare function routesWithoutToken(labels: string, token: string): boolean;
38
74
  /**
39
75
  * Whether two label sets hold the same labels. Order and the `coolify.*`
40
76
  * lines Coolify adds and strips itself do not count.
41
77
  */
42
78
  export declare function sameLabelSets(a: string, b: string): boolean;
79
+ /**
80
+ * The entry names that live in the platform's `oikos.host` zone (a company with no domain of its own, or with
81
+ * `usesDomain` off): the names the company's tunnel gets a proxied CNAME for. Sorted.
82
+ */
83
+ export declare function entryOikosHostnames(org: DeclaredOrg): string[];
84
+ /**
85
+ * Whether `name` is one the company may write in the platform's `oikos.host` zone: exactly its own
86
+ * `<slug>.oikos.host`. A company writes no other company's name, whatever an entry declares.
87
+ */
88
+ export declare function ownsOikosName(org: DeclaredOrg, name: string): boolean;
@@ -1,5 +1,19 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { entryHostnames, wildcardHostname } from './hostnames.js';
3
+ import { NO_DOMAIN_ZONE } from './own-hostnames.js';
4
+ import { appNameFor } from './platform-diff.js';
5
+ /**
6
+ * The label every routed container carries, with the install's token as its value. Traefik is told
7
+ * (`--providers.docker.constraints=Label(`foundry.edge`,`<token>`)`) to route only containers that carry
8
+ * it, so a route an image declares in its own `LABEL`s, which cannot know the token, is ignored.
9
+ */
10
+ export const EDGE_LABEL = 'foundry.edge';
11
+ /** The token is a base64url string; anything else would not survive a label line or Traefik's constraint syntax. */
12
+ const TOKEN_SHAPE = /^[A-Za-z0-9_-]{32,128}$/;
13
+ function checkToken(token) {
14
+ if (!TOKEN_SHAPE.test(token))
15
+ throw new Error('proxy labels: the label token is malformed');
16
+ }
3
17
  /**
4
18
  * The scope key holding the hash of the label set last patched onto an
5
19
  * entry's app. Present only for an entry that has had a custom label set.
@@ -7,26 +21,68 @@ import { entryHostnames, wildcardHostname } from './hostnames.js';
7
21
  export function appLabelsAppliedScopeKey(entryKey) {
8
22
  return `app_labels_applied.${entryKey}`;
9
23
  }
24
+ /** The entry routes ({@link entryRoutesMarker}) the company tunnel's ingress was last written with. */
25
+ export const CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY = 'cloudflare_tunnel_entry_routes';
10
26
  /**
11
- * The label set an entry's app needs, or `null` when it needs none. Coolify
12
- * replaces the labels it generates from `domains` once `custom_labels` is
13
- * set, so an entry with `'*'` carries its exact `Host` rules as well. An entry
14
- * that already has a custom set (`hadLabels`) keeps one, without the wildcard
15
- * rule, so a removed `'*'` stops matching and a later name change still
16
- * reaches the app: an empty `custom_labels` is refused by Coolify's API.
17
- * Coolify's default port for an app is 80.
27
+ * The entry names in the platform's oikos.host zone ({@link entryOikosHostnames}) the company tunnel's records were
28
+ * last written for, comma-joined. Recorded only once every record is in place.
18
29
  */
19
- export function entryLabelSpec(org, entry, hadLabels) {
30
+ export const CLOUDFLARE_TUNNEL_OIKOS_HOSTNAMES_SCOPE_KEY = 'cloudflare_tunnel_oikos_hostnames';
31
+ /**
32
+ * The run label every customer hosting-entry container gets. A run label overrides the image's own
33
+ * `LABEL` of the same key, so Traefik ignores the container whatever its image says: a customer image
34
+ * cannot route a hostname it does not own. The company's tunnel reaches the container directly
35
+ * ({@link entryRoutes}); no Traefik router and no Coolify `domains` are involved.
36
+ */
37
+ export const ENTRY_CONTAINER_LABELS = 'traefik.enable=false';
38
+ /**
39
+ * What an entry's routing is built from: the names it is served at, the wildcard domain when it
40
+ * declares `'*'`, and the port its container listens on. Every entry has one, because every entry
41
+ * carries {@link ENTRY_CONTAINER_LABELS}. Coolify's default port for an app is 80.
42
+ */
43
+ export function entryLabelSpec(org, entry) {
20
44
  const wildcard = entry.hostnames.includes('*') ? wildcardHostname(org) : null;
21
- if (!wildcard && !hadLabels)
22
- return null;
23
45
  return {
24
46
  names: entryHostnames(org, entry),
25
47
  wildcardDomain: wildcard ? wildcard.slice(2) : null,
26
48
  port: entry.buildPack === 'static' ? 80 : (entry.port ?? 80),
27
49
  };
28
50
  }
29
- /** A short digest of the spec; the same spec always gives the same hash. */
51
+ /**
52
+ * The company tunnel's ingress for the hosting entries: each name, and the wildcard last, goes straight to
53
+ * `http://<container name>:<port>` on the company network, never through `coolify-proxy`. A wildcard
54
+ * entry gets a wildcard hostname rule; the first entry that declares `'*'` owns it, and the first entry
55
+ * that declares a name owns it. An entry with no name (internal) has no rule. This holds for a company
56
+ * without a domain too: its `<slug>.oikos.host` name is routed by the company's own tunnel
57
+ * ({@link entryOikosHostnames}), not by the platform's shared ingress.
58
+ */
59
+ export function entryRoutes(org) {
60
+ if (!org.hosting.enabled)
61
+ return [];
62
+ const routes = [];
63
+ let wildcard;
64
+ for (const entry of org.hosting.entries) {
65
+ const service = `http://${appNameFor(org, entry)}:${entryLabelSpec(org, entry).port}`;
66
+ for (const hostname of entryHostnames(org, entry)) {
67
+ if (!routes.some((r) => r.hostname === hostname))
68
+ routes.push({ hostname, service });
69
+ }
70
+ const wildcardName = entry.hostnames.includes('*') ? wildcardHostname(org) : null;
71
+ if (wildcardName && !wildcard)
72
+ wildcard = { hostname: wildcardName, service };
73
+ }
74
+ if (wildcard)
75
+ routes.push(wildcard);
76
+ return routes;
77
+ }
78
+ /** The scope value recording the routes the tunnel step last wrote; the planner compares it to {@link entryRoutes}. */
79
+ export function entryRoutesMarker(org) {
80
+ return JSON.stringify(entryRoutes(org));
81
+ }
82
+ /**
83
+ * A short digest of the spec; the same spec always gives the same hash. The token is left out: the
84
+ * planner has none, and a changed token reaches a live app through the comparison of its live labels.
85
+ */
30
86
  export function entryLabelHash(spec) {
31
87
  return createHash('sha256').update(JSON.stringify(spec)).digest('hex').slice(0, 16);
32
88
  }
@@ -37,9 +93,11 @@ export function entryLabelHash(spec) {
37
93
  * `Host(name) && PathPrefix(/)`, default priority. The wildcard is one
38
94
  * `HostRegexp` router at `priority=1`, so any exact name, this app's or
39
95
  * another's, wins over it. The regex matches one label (`[a-z0-9-]+`), the
40
- * same depth Cloudflare's Universal certificate covers.
96
+ * same depth Cloudflare's Universal certificate covers. A set that routes
97
+ * anything carries {@link EDGE_LABEL}; the "no route" set does not need it.
41
98
  */
42
- export function entryProxyLabels(appUuid, spec) {
99
+ export function entryProxyLabels(appUuid, spec, token) {
100
+ checkToken(token);
43
101
  const labels = [];
44
102
  const route = (id, rule, priority) => {
45
103
  const router = `traefik.http.routers.${id}`;
@@ -56,9 +114,14 @@ export function entryProxyLabels(appUuid, spec) {
56
114
  }
57
115
  if (labels.length === 0)
58
116
  return 'traefik.enable=false';
59
- labels.push('traefik.enable=true', 'traefik.http.middlewares.gzip.compress=true');
117
+ labels.push('traefik.enable=true', 'traefik.http.middlewares.gzip.compress=true', `${EDGE_LABEL}=${token}`);
60
118
  return labels.sort().join('\n');
61
119
  }
120
+ /** Whether a label set routes something but does not carry the token: Traefik would ignore it once the constraint is on. */
121
+ export function routesWithoutToken(labels, token) {
122
+ const lines = labels.split(/\r\n|\n|\r/);
123
+ return lines.includes('traefik.enable=true') && !lines.includes(`${EDGE_LABEL}=${token}`);
124
+ }
62
125
  /**
63
126
  * Whether two label sets hold the same labels. Order and the `coolify.*`
64
127
  * lines Coolify adds and strips itself do not count.
@@ -71,3 +134,20 @@ export function sameLabelSets(a, b) {
71
134
  .join('\n');
72
135
  return lines(a) === lines(b);
73
136
  }
137
+ /**
138
+ * The entry names that live in the platform's `oikos.host` zone (a company with no domain of its own, or with
139
+ * `usesDomain` off): the names the company's tunnel gets a proxied CNAME for. Sorted.
140
+ */
141
+ export function entryOikosHostnames(org) {
142
+ return entryRoutes(org)
143
+ .map((r) => r.hostname)
144
+ .filter((h) => h.endsWith(`.${NO_DOMAIN_ZONE}`))
145
+ .sort();
146
+ }
147
+ /**
148
+ * Whether `name` is one the company may write in the platform's `oikos.host` zone: exactly its own
149
+ * `<slug>.oikos.host`. A company writes no other company's name, whatever an entry declares.
150
+ */
151
+ export function ownsOikosName(org, name) {
152
+ return name.toLowerCase() === `${org.slug}.${NO_DOMAIN_ZONE}`;
153
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * The Linux capabilities of every container foundry makes through an application call, sent in
3
+ * Coolify's `custom_docker_run_options`. Coolify turns `--cap-drop`, `--cap-add` and `--security-opt`
4
+ * into compose keys; its parser reads a value up to the first dash, so `no-new-privileges:true` is
5
+ * not sent. A Coolify database has no such field and keeps Docker's defaults.
6
+ */
7
+ /** What no foundry service needs: raw sockets, device nodes, file capabilities, chroot, audit writes. */
8
+ export declare const CAPS_TIER_A: readonly string[];
9
+ /** A customer's own app: nothing, then back what nginx, node and `gosu` style entrypoints need. */
10
+ export declare const CAPS_TIER_B: readonly string[];
11
+ export type CapabilityTier = 'A' | 'B';
12
+ /**
13
+ * `existing` with its capability options replaced by the tier's. Everything else in it (the
14
+ * `--entrypoint "..."`) stays as it was, so a step that sends its own entrypoint and the capability
15
+ * wrapper can both write the field. Running it twice gives the same string.
16
+ */
17
+ export declare function withCapabilities(existing: string | null | undefined, tier: CapabilityTier): string;
18
+ /** Whether `existing` holds exactly the tier's capability options. */
19
+ export declare function hasCapabilities(existing: string | null | undefined, tier: CapabilityTier): boolean;
@@ -0,0 +1,46 @@
1
+ /**
2
+ * The Linux capabilities of every container foundry makes through an application call, sent in
3
+ * Coolify's `custom_docker_run_options`. Coolify turns `--cap-drop`, `--cap-add` and `--security-opt`
4
+ * into compose keys; its parser reads a value up to the first dash, so `no-new-privileges:true` is
5
+ * not sent. A Coolify database has no such field and keeps Docker's defaults.
6
+ */
7
+ /** What no foundry service needs: raw sockets, device nodes, file capabilities, chroot, audit writes. */
8
+ export const CAPS_TIER_A = [
9
+ '--cap-drop=NET_RAW',
10
+ '--cap-drop=MKNOD',
11
+ '--cap-drop=SETFCAP',
12
+ '--cap-drop=SYS_CHROOT',
13
+ '--cap-drop=AUDIT_WRITE',
14
+ ];
15
+ /** A customer's own app: nothing, then back what nginx, node and `gosu` style entrypoints need. */
16
+ export const CAPS_TIER_B = [
17
+ '--cap-drop=ALL',
18
+ '--cap-add=CHOWN',
19
+ '--cap-add=DAC_OVERRIDE',
20
+ '--cap-add=FOWNER',
21
+ '--cap-add=SETGID',
22
+ '--cap-add=SETUID',
23
+ '--cap-add=NET_BIND_SERVICE',
24
+ ];
25
+ const tokens = (tier) => tier === 'B' ? CAPS_TIER_B : CAPS_TIER_A;
26
+ /** One `--cap-drop` or `--cap-add` option, with the space or equals sign and its value. */
27
+ const CAP_OPTION = /\s*--cap-(?:drop|add)(?:=|\s+)\S+/g;
28
+ /** The cap options of a run options string, in the order they stand. */
29
+ function capOptionsOf(options) {
30
+ return (options ?? '').match(CAP_OPTION)?.map((o) => o.trim().replace(/\s+/, '=')) ?? [];
31
+ }
32
+ /**
33
+ * `existing` with its capability options replaced by the tier's. Everything else in it (the
34
+ * `--entrypoint "..."`) stays as it was, so a step that sends its own entrypoint and the capability
35
+ * wrapper can both write the field. Running it twice gives the same string.
36
+ */
37
+ export function withCapabilities(existing, tier) {
38
+ const rest = (existing ?? '').replace(CAP_OPTION, '').trim();
39
+ return [rest, ...tokens(tier)].filter(Boolean).join(' ');
40
+ }
41
+ /** Whether `existing` holds exactly the tier's capability options. */
42
+ export function hasCapabilities(existing, tier) {
43
+ const have = capOptionsOf(existing);
44
+ const want = tokens(tier);
45
+ return have.length === want.length && want.every((t) => have.includes(t));
46
+ }
@@ -23,7 +23,7 @@ export declare function signInOrgDomain(org: {
23
23
  * idempotent. A failure after the add leaves the domain on the org but not
24
24
  * primary, and nothing is recorded, so the next run repeats both.
25
25
  */
26
- export declare function registerSignInDomain(zitadel: Pick<ZitadelPort, 'addOrgDomain' | 'setPrimaryOrgDomain'>, orgId: string, domain: string): Promise<void>;
26
+ export declare function registerSignInDomain(zitadel: Pick<ZitadelPort, 'addOrgDomain' | 'setPrimaryOrgDomain'>, orgId: string, domain: string, sleep?: (ms: number) => Promise<void>): Promise<void>;
27
27
  /**
28
28
  * {@link registerSignInDomain}, except that a refusal to make the domain
29
29
  * primary (the sign-in server's domain policy wants proof) is recorded under
@@ -21,6 +21,12 @@ export function signInDomainBlockedText(orgName, domain) {
21
21
  function isPrimaryDomainRefused(error) {
22
22
  return error instanceof Error && /is not verified/.test(error.message);
23
23
  }
24
+ /** True for the providers' "domain is not on org" answer of `setPrimaryOrgDomain`. */
25
+ function isDomainNotListed(error) {
26
+ return error instanceof Error && /is not on org/.test(error.message);
27
+ }
28
+ const DOMAIN_LISTED_TRIES = 10;
29
+ const DOMAIN_LISTED_WAIT_MS = 500;
24
30
  /** The domain an org's owners sign in on: its own, or `<slug>.oikos.host` when it has none. */
25
31
  export function signInOrgDomain(org) {
26
32
  return (org.domain ?? `${org.slug}.oikos.host`).toLowerCase();
@@ -30,9 +36,21 @@ export function signInOrgDomain(org) {
30
36
  * idempotent. A failure after the add leaves the domain on the org but not
31
37
  * primary, and nothing is recorded, so the next run repeats both.
32
38
  */
33
- export async function registerSignInDomain(zitadel, orgId, domain) {
39
+ export async function registerSignInDomain(zitadel, orgId, domain, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))) {
34
40
  await zitadel.addOrgDomain(orgId, domain);
35
- await zitadel.setPrimaryOrgDomain(orgId, domain);
41
+ // The sign-in server lists a domain a moment after it accepts the add, and
42
+ // `setPrimaryOrgDomain` reads that list first: ask again, for five seconds.
43
+ for (let attempt = 1;; attempt += 1) {
44
+ try {
45
+ await zitadel.setPrimaryOrgDomain(orgId, domain);
46
+ return;
47
+ }
48
+ catch (error) {
49
+ if (attempt >= DOMAIN_LISTED_TRIES || !isDomainNotListed(error))
50
+ throw error;
51
+ await sleep(DOMAIN_LISTED_WAIT_MS);
52
+ }
53
+ }
36
54
  }
37
55
  /**
38
56
  * {@link registerSignInDomain}, except that a refusal to make the domain
@@ -20,4 +20,4 @@ export interface ZitadelLoginPolicyOptions {
20
20
  * policy (POST), so a later change to the default cannot turn registration
21
21
  * back on. A custom policy keeps its own factors and IdPs (PUT).
22
22
  */
23
- export declare function createZitadelLoginPolicy(options: ZitadelLoginPolicyOptions): Pick<ZitadelPort, 'closeSelfRegistration'>;
23
+ export declare function createZitadelLoginPolicy(options: ZitadelLoginPolicyOptions): Pick<ZitadelPort, 'closeSelfRegistration' | 'enforceSecondFactor'>;
@@ -20,6 +20,21 @@ const LOGIN_POLICY_SETTINGS = [
20
20
  'forceMfaLocalOnly',
21
21
  ];
22
22
  const CALL_TIMEOUT_MS = 30_000;
23
+ /** What {@link ZitadelPort.enforceSecondFactor} sets. The two lists are exact: any other factor (email or SMS code) is removed. */
24
+ const SECOND_FACTOR_SETTINGS = {
25
+ forceMfa: true,
26
+ allowRegister: false,
27
+ allowExternalIdp: false,
28
+ allowUsernamePassword: true,
29
+ passwordlessType: 'PASSWORDLESS_TYPE_ALLOWED',
30
+ mfaInitSkipLifetime: '0s',
31
+ };
32
+ const SECOND_FACTORS = ['SECOND_FACTOR_TYPE_OTP', 'SECOND_FACTOR_TYPE_U2F'];
33
+ const MULTI_FACTORS = ['MULTI_FACTOR_TYPE_U2F_WITH_VERIFICATION'];
34
+ /** ZITADEL leaves false booleans and zero durations out of a read. */
35
+ const isZeroDuration = (value) => value === undefined || (typeof value === 'string' && /^0+(\.0+)?s$/.test(value));
36
+ const stringList = (value) => Array.isArray(value) ? value.filter((v) => typeof v === 'string') : [];
37
+ const sameSet = (a, b) => a.length === b.length && b.every((v) => a.includes(v));
23
38
  /**
24
39
  * The real {@link ZitadelPort.closeSelfRegistration}: the mirror of
25
40
  * `ZitadelClient.allowSelfRegistration` in `@wtfalch/providers`, with
@@ -35,8 +50,7 @@ const CALL_TIMEOUT_MS = 30_000;
35
50
  */
36
51
  export function createZitadelLoginPolicy(options) {
37
52
  const fetchImpl = options.fetchImpl ?? globalThis.fetch;
38
- async function call(method, orgId, body) {
39
- const path = '/management/v1/policies/login';
53
+ async function call(method, orgId, body, path = '/management/v1/policies/login') {
40
54
  const response = await fetchImpl(`${options.url}${path}`, {
41
55
  method,
42
56
  signal: AbortSignal.timeout(CALL_TIMEOUT_MS),
@@ -67,22 +81,76 @@ export function createZitadelLoginPolicy(options) {
67
81
  }
68
82
  return data;
69
83
  }
84
+ /** Every setting of the policy in force, as the write bodies take them. */
85
+ const settingsOf = (policy) => {
86
+ const settings = {};
87
+ for (const key of LOGIN_POLICY_SETTINGS) {
88
+ if (policy[key] !== undefined)
89
+ settings[key] = policy[key];
90
+ }
91
+ return settings;
92
+ };
70
93
  return {
94
+ async enforceSecondFactor(orgId) {
95
+ if (!orgId)
96
+ throw new Error('enforceSecondFactor: orgId is required');
97
+ const data = await call('GET', orgId, undefined);
98
+ const policy = data.policy ?? {};
99
+ const isDefault = data.isDefault === true || policy.isDefault === true;
100
+ const secondFactors = stringList(policy.secondFactors);
101
+ const multiFactors = stringList(policy.multiFactors);
102
+ const want = SECOND_FACTOR_SETTINGS;
103
+ const settingsDiffer = (policy.forceMfa === true) !== want.forceMfa ||
104
+ (policy.allowRegister === true) !== want.allowRegister ||
105
+ (policy.allowExternalIdp === true) !== want.allowExternalIdp ||
106
+ (policy.allowUsernamePassword === true) !== want.allowUsernamePassword ||
107
+ policy.passwordlessType !== want.passwordlessType ||
108
+ !isZeroDuration(policy.mfaInitSkipLifetime);
109
+ const factorsDiffer = !sameSet(secondFactors, SECOND_FACTORS) || !sameSet(multiFactors, MULTI_FACTORS);
110
+ if (!isDefault && !settingsDiffer && !factorsDiffer)
111
+ return { changed: false };
112
+ const settings = { ...settingsOf(policy), ...want };
113
+ if (isDefault) {
114
+ // The org's own policy, written whole: the factor lists go in the same body.
115
+ await call('POST', orgId, {
116
+ ...settings,
117
+ secondFactors: SECOND_FACTORS,
118
+ multiFactors: MULTI_FACTORS,
119
+ idps: (policy.idps ?? []).map((idp) => ({
120
+ idpId: idp.idpId,
121
+ ownerType: 'IDP_OWNER_TYPE_SYSTEM',
122
+ })),
123
+ });
124
+ return { changed: true };
125
+ }
126
+ if (settingsDiffer)
127
+ await call('PUT', orgId, settings);
128
+ // Add before remove, so the org is never left with no second factor.
129
+ const factorPath = '/management/v1/policies/login';
130
+ for (const type of SECOND_FACTORS.filter((t) => !secondFactors.includes(t))) {
131
+ await call('POST', orgId, { type }, `${factorPath}/second_factors`);
132
+ }
133
+ for (const type of MULTI_FACTORS.filter((t) => !multiFactors.includes(t))) {
134
+ await call('POST', orgId, { type }, `${factorPath}/multi_factors`);
135
+ }
136
+ for (const type of secondFactors.filter((t) => !SECOND_FACTORS.includes(t))) {
137
+ await call('DELETE', orgId, undefined, `${factorPath}/second_factors/${encodeURIComponent(type)}`);
138
+ }
139
+ for (const type of multiFactors.filter((t) => !MULTI_FACTORS.includes(t))) {
140
+ await call('DELETE', orgId, undefined, `${factorPath}/multi_factors/${encodeURIComponent(type)}`);
141
+ }
142
+ return { changed: true };
143
+ },
71
144
  async closeSelfRegistration(orgId) {
72
145
  if (!orgId)
73
146
  throw new Error('closeSelfRegistration: orgId is required');
74
147
  const data = await call('GET', orgId, undefined);
75
148
  const policy = data.policy ?? {};
76
149
  const isDefault = data.isDefault === true || policy.isDefault === true;
77
- if (!isDefault && policy.allowRegister === false) {
150
+ if (!isDefault && policy.allowRegister !== true) {
78
151
  return { changed: false };
79
152
  }
80
- const settings = {};
81
- for (const key of LOGIN_POLICY_SETTINGS) {
82
- if (policy[key] !== undefined)
83
- settings[key] = policy[key];
84
- }
85
- settings.allowRegister = false;
153
+ const settings = { ...settingsOf(policy), allowRegister: false };
86
154
  if (isDefault) {
87
155
  await call('POST', orgId, {
88
156
  ...settings,
@@ -36,13 +36,32 @@ export declare class CreateInstanceError extends Error {
36
36
  constructor(kind: 'rejected' | 'exists' | 'uncertain', message: string);
37
37
  }
38
38
  /** What a person does when an instance exists without a delivered bearer. Nothing is deleted automatically. */
39
- export declare function instanceWithoutBearerMessage(hostname: string, detail: string): string;
39
+ export declare function instanceWithoutBearerMessage(hostname: string, detail: string, label?: string): string;
40
40
  export interface CreateInstanceRuntime {
41
41
  fetch?: typeof fetch;
42
42
  now: () => number;
43
43
  /** Fetch timeout for the one create call (default 30s). */
44
44
  timeoutMs?: number;
45
45
  }
46
+ /**
47
+ * Waits until ZITADEL's system API answers, before the one create call. `/debug/healthz` and `/debug/ready`
48
+ * answer as soon as the HTTP server listens, but the gateway behind the system API reaches its gRPC server a
49
+ * moment later and answers 503 until then (seen: `POST /instances/_create` 503 about 100 ms after "server is
50
+ * listening"). A 503 on the create leaves the instance marked as possibly created, so the wait asks first with a
51
+ * read that changes nothing: `POST /system/v1/instances/_search`. Any answer other than a failed request or a
52
+ * 5xx counts (a 401 is the key being refused, which the create reports itself). Times out with an error that
53
+ * names the last answer.
54
+ */
55
+ export declare function waitForSystemApi(input: {
56
+ customDomain: string;
57
+ privateKey: string;
58
+ label?: string;
59
+ }, runtime: CreateInstanceRuntime & {
60
+ sleep: (ms: number) => Promise<void>;
61
+ timeoutMs?: number;
62
+ intervalMs?: number;
63
+ requestTimeoutMs?: number;
64
+ }): Promise<void>;
46
65
  /**
47
66
  * Creates the platform's ZITADEL instance on `customDomain` with one machine
48
67
  * owner and returns its id and the owner's PAT. Sends exactly one request,
@@ -54,6 +73,8 @@ export declare function createZitadelInstance(input: {
54
73
  privateKey: string;
55
74
  instanceName: string;
56
75
  firstOrgName: string;
76
+ /** Names the target in error texts (default `platform:auth`). */
77
+ label?: string;
57
78
  owner: {
58
79
  userName: string;
59
80
  name: string;
@@ -61,8 +61,48 @@ export class CreateInstanceError extends Error {
61
61
  }
62
62
  }
63
63
  /** What a person does when an instance exists without a delivered bearer. Nothing is deleted automatically. */
64
- export function instanceWithoutBearerMessage(hostname, detail) {
65
- return `platform:auth: a ZITADEL instance may already exist for ${hostname} without a delivered bearer (${detail}). To recover, delete the instance with the system API (DELETE /system/v1/instances/{id}) or redeploy the ZITADEL database, remove the recorded key zitadel_platform_instance_undelivered, then apply again`;
64
+ export function instanceWithoutBearerMessage(hostname, detail, label = 'platform:auth') {
65
+ return `${label}: a ZITADEL instance may already exist for ${hostname} without a delivered bearer (${detail}). To recover, delete the instance with the system API (DELETE /system/v1/instances/{id}) or redeploy the ZITADEL database, remove the recorded key zitadel_platform_instance_undelivered, then apply again`;
66
+ }
67
+ /**
68
+ * Waits until ZITADEL's system API answers, before the one create call. `/debug/healthz` and `/debug/ready`
69
+ * answer as soon as the HTTP server listens, but the gateway behind the system API reaches its gRPC server a
70
+ * moment later and answers 503 until then (seen: `POST /instances/_create` 503 about 100 ms after "server is
71
+ * listening"). A 503 on the create leaves the instance marked as possibly created, so the wait asks first with a
72
+ * read that changes nothing: `POST /system/v1/instances/_search`. Any answer other than a failed request or a
73
+ * 5xx counts (a 401 is the key being refused, which the create reports itself). Times out with an error that
74
+ * names the last answer.
75
+ */
76
+ export async function waitForSystemApi(input, runtime) {
77
+ const fetchImpl = runtime.fetch ?? globalThis.fetch;
78
+ const url = `https://${input.customDomain}`;
79
+ const timeout = runtime.timeoutMs ?? 300_000;
80
+ const interval = runtime.intervalMs ?? 2_000;
81
+ const deadline = runtime.now() + timeout;
82
+ let last = 'no answer';
83
+ for (;;) {
84
+ try {
85
+ const response = await fetchImpl(`${url}/system/v1/instances/_search`, {
86
+ method: 'POST',
87
+ signal: AbortSignal.timeout(runtime.requestTimeoutMs ?? 10_000),
88
+ headers: {
89
+ authorization: `Bearer ${signSystemApiJwt(input.privateKey, url, runtime.now())}`,
90
+ 'content-type': 'application/json',
91
+ },
92
+ body: '{}',
93
+ });
94
+ if (response.status < 500)
95
+ return;
96
+ last = `HTTP ${response.status}`;
97
+ }
98
+ catch {
99
+ last = 'the request failed';
100
+ }
101
+ if (runtime.now() >= deadline) {
102
+ throw new Error(`${input.label ?? 'platform:auth'}: ZITADEL's system API did not answer at ${url} within ${Math.round(timeout / 1000)}s (${last}); nothing was created, retry this apply`);
103
+ }
104
+ await runtime.sleep(interval);
105
+ }
66
106
  }
67
107
  /**
68
108
  * Creates the platform's ZITADEL instance on `customDomain` with one machine
@@ -73,6 +113,7 @@ export function instanceWithoutBearerMessage(hostname, detail) {
73
113
  export async function createZitadelInstance(input, runtime) {
74
114
  const fetchImpl = runtime.fetch ?? globalThis.fetch;
75
115
  const url = `https://${input.customDomain}`;
116
+ const label = input.label ?? 'platform:auth';
76
117
  let response;
77
118
  try {
78
119
  response = await fetchImpl(`${url}/system/v1/instances/_create`, {
@@ -97,19 +138,19 @@ export async function createZitadelInstance(input, runtime) {
97
138
  });
98
139
  }
99
140
  catch {
100
- throw new CreateInstanceError('uncertain', instanceWithoutBearerMessage(input.customDomain, 'the create request timed out or failed'));
141
+ throw new CreateInstanceError('uncertain', instanceWithoutBearerMessage(input.customDomain, 'the create request timed out or failed', label));
101
142
  }
102
143
  if (response.status === 401) {
103
- throw new CreateInstanceError('rejected', 'platform:auth: system API key rejected (HTTP 401); nothing was created');
144
+ throw new CreateInstanceError('rejected', `${label}: system API key rejected (HTTP 401); nothing was created`);
104
145
  }
105
146
  if (response.status === 409) {
106
- throw new CreateInstanceError('exists', instanceWithoutBearerMessage(input.customDomain, 'ZITADEL answered 409, already exists'));
147
+ throw new CreateInstanceError('exists', instanceWithoutBearerMessage(input.customDomain, 'ZITADEL answered 409, already exists', label));
107
148
  }
108
149
  if (!response.ok) {
109
150
  // A 4xx is a refusal before anything was created; a 5xx may not be.
110
151
  throw new CreateInstanceError(response.status < 500 ? 'rejected' : 'uncertain', response.status < 500
111
- ? `platform:auth: ZITADEL refused to create the instance (HTTP ${response.status}); nothing was created`
112
- : instanceWithoutBearerMessage(input.customDomain, `ZITADEL answered HTTP ${response.status}`));
152
+ ? `${label}: ZITADEL refused to create the instance (HTTP ${response.status}); nothing was created`
153
+ : instanceWithoutBearerMessage(input.customDomain, `ZITADEL answered HTTP ${response.status}`, label));
113
154
  }
114
155
  let body = {};
115
156
  try {
@@ -119,7 +160,7 @@ export async function createZitadelInstance(input, runtime) {
119
160
  // Handled below.
120
161
  }
121
162
  if (!body.instanceId || !body.pat) {
122
- throw new CreateInstanceError('uncertain', instanceWithoutBearerMessage(input.customDomain, 'the answer had no instance id or token'));
163
+ throw new CreateInstanceError('uncertain', instanceWithoutBearerMessage(input.customDomain, 'the answer had no instance id or token', label));
123
164
  }
124
165
  return { instanceId: body.instanceId, pat: body.pat };
125
166
  }