@wtfalch/foundry 0.34.0 → 0.35.2

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 (88) 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 +20 -0
  6. package/dist/bootstrap/versions.js +46 -0
  7. package/dist/calls-sfu.js +3 -3
  8. package/dist/index.d.ts +9 -4
  9. package/dist/index.js +5 -2
  10. package/dist/migrations/0053_heartbeats_provider.sql +9 -0
  11. package/dist/migrations/0053_org_version_pin.sql +6 -0
  12. package/dist/org.d.ts +60 -14
  13. package/dist/org.js +118 -28
  14. package/dist/plan/backup-gate.d.ts +93 -0
  15. package/dist/plan/backup-gate.js +261 -0
  16. package/dist/plan/broker-entries.d.ts +9 -1
  17. package/dist/plan/broker-entries.js +20 -3
  18. package/dist/plan/build-record.d.ts +56 -0
  19. package/dist/plan/build-record.js +109 -0
  20. package/dist/plan/calls-matrix.js +3 -3
  21. package/dist/plan/cloudflared-connector.js +2 -2
  22. package/dist/plan/company-backups.d.ts +31 -0
  23. package/dist/plan/company-backups.js +101 -0
  24. package/dist/plan/deploy-health.d.ts +12 -2
  25. package/dist/plan/deploy-health.js +3 -1
  26. package/dist/plan/destinations.d.ts +73 -0
  27. package/dist/plan/destinations.js +217 -0
  28. package/dist/plan/diff.d.ts +17 -8
  29. package/dist/plan/diff.js +131 -131
  30. package/dist/plan/edge-access.d.ts +15 -0
  31. package/dist/plan/edge-access.js +21 -2
  32. package/dist/plan/executor.d.ts +166 -7
  33. package/dist/plan/executor.js +547 -106
  34. package/dist/plan/health-targets.d.ts +1 -3
  35. package/dist/plan/health-targets.js +36 -36
  36. package/dist/plan/held-key-refs.d.ts +5 -0
  37. package/dist/plan/held-key-refs.js +5 -0
  38. package/dist/plan/hostnames.d.ts +10 -0
  39. package/dist/plan/hostnames.js +14 -0
  40. package/dist/plan/index.d.ts +5 -0
  41. package/dist/plan/index.js +3 -0
  42. package/dist/plan/live.d.ts +6 -0
  43. package/dist/plan/live.js +3 -2
  44. package/dist/plan/locked-hostnames.d.ts +2 -1
  45. package/dist/plan/locked-hostnames.js +16 -3
  46. package/dist/plan/login-host.d.ts +9 -20
  47. package/dist/plan/login-host.js +10 -25
  48. package/dist/plan/mail-relay.d.ts +48 -29
  49. package/dist/plan/mail-relay.js +145 -68
  50. package/dist/plan/network-move.d.ts +20 -0
  51. package/dist/plan/network-move.js +41 -0
  52. package/dist/plan/oikos-ingress.d.ts +111 -0
  53. package/dist/plan/oikos-ingress.js +231 -0
  54. package/dist/plan/org-names.d.ts +8 -2
  55. package/dist/plan/org-names.js +57 -1
  56. package/dist/plan/own-diff.d.ts +9 -8
  57. package/dist/plan/own-diff.js +42 -34
  58. package/dist/plan/own-hostnames.d.ts +27 -0
  59. package/dist/plan/own-hostnames.js +24 -0
  60. package/dist/plan/own-limits.d.ts +198 -7
  61. package/dist/plan/own-limits.js +163 -32
  62. package/dist/plan/own-services.js +3 -1
  63. package/dist/plan/own-sfu.js +3 -2
  64. package/dist/plan/plan-apply.d.ts +47 -7
  65. package/dist/plan/plan-apply.js +184 -38
  66. package/dist/plan/platform-diff.d.ts +133 -17
  67. package/dist/plan/platform-diff.js +231 -68
  68. package/dist/plan/platform-org.d.ts +79 -10
  69. package/dist/plan/platform-org.js +93 -13
  70. package/dist/plan/platform-providers.d.ts +118 -73
  71. package/dist/plan/platform-providers.js +799 -241
  72. package/dist/plan/proxy-labels.d.ts +57 -11
  73. package/dist/plan/proxy-labels.js +94 -14
  74. package/dist/plan/run-options.d.ts +19 -0
  75. package/dist/plan/run-options.js +46 -0
  76. package/dist/plan/zitadel-login-policy.d.ts +1 -1
  77. package/dist/plan/zitadel-login-policy.js +76 -8
  78. package/dist/platform.d.ts +32 -0
  79. package/dist/platform.js +16 -0
  80. package/dist/provision.d.ts +2 -0
  81. package/dist/provision.js +3 -1
  82. package/dist/slug.d.ts +3 -1
  83. package/dist/slug.js +25 -3
  84. package/dist/store/postgres.js +43 -1
  85. package/dist/store/schema.d.ts +36 -0
  86. package/dist/store/schema.js +4 -0
  87. package/package.json +3 -3
  88. package/versions.digests.json +11 -0
@@ -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
+ }
@@ -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,7 +81,66 @@ 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');
@@ -77,12 +150,7 @@ export function createZitadelLoginPolicy(options) {
77
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,
@@ -121,13 +121,44 @@ export interface DeclaredPlatform {
121
121
  coolify: PlatformService;
122
122
  /** foundry#542: the outside prober Worker; see {@link PlatformUptimeWorker}. */
123
123
  uptime: PlatformUptimeWorker;
124
+ /**
125
+ * foundry#544: which Healthchecks instance manages the heartbeat checks
126
+ * for every piece of scheduled work foundry sets up (the uptime Worker,
127
+ * each platform database backup), decoupled from whether foundry also
128
+ * deploys the self-hosted server ({@link hc} above). `'self-hosted'`
129
+ * (the default, and today's only behaviour before #544) still requires
130
+ * `hc.enabled` and reads the project API key under
131
+ * `PLATFORM_HC_API_KEY_REF`. `'hosted'` needs no self-hosted server at
132
+ * all -- not `hc.enabled`, not `hc` applied -- and reads its own API key
133
+ * under `PLATFORM_HEARTBEATS_HOSTED_API_KEY_REF` instead; the host's own
134
+ * `HeartbeatsPort` implementation is what actually points at the outside
135
+ * service (healthchecks.io or otherwise) or the self-hosted instance --
136
+ * this field only says which one foundry expects behind it. Changing it
137
+ * re-plans `heartbeats:reconcile`, which creates every desired check
138
+ * fresh on the newly-chosen provider and delivers its ping URLs; a check
139
+ * left behind on the provider no longer chosen is not deleted (foundry
140
+ * never had it in view the moment the switch is declared) -- see
141
+ * issue #544's own "Human steps". Which to use at all, and whether the
142
+ * self-hosted server stays as a standing option, are both still open
143
+ * "Decide" items on issue #544's own thread, not settled by this field.
144
+ */
145
+ heartbeatsProvider: HeartbeatsProvider;
124
146
  }
147
+ /**
148
+ * foundry#544: see {@link DeclaredPlatform.heartbeatsProvider}'s own doc
149
+ * comment.
150
+ */
151
+ export type HeartbeatsProvider = 'self-hosted' | 'hosted';
152
+ /** The only values {@link DeclaredPlatform.heartbeatsProvider} may hold. */
153
+ export declare const HEARTBEATS_PROVIDERS: ReadonlySet<HeartbeatsProvider>;
125
154
  /** `<service>.<apex>` -- the default `hostname` for a platform service, before an operator overrides it. */
126
155
  export declare function defaultPlatformHostname(service: PlatformServiceKey, apex: string): string;
127
156
  /**
128
157
  * `null` when `platform` is valid; otherwise the reason it is not, same
129
158
  * shape as `org.ts`'s `validateBuyerIdentity`.
130
159
  *
160
+ * - `auth`, `mail`, `files`, `jobs` and `sfu` are not `enabled`: the owner company
161
+ * runs them as its own services.
131
162
  * - `apex` is a valid domain (`org.ts`'s `validateDomain`).
132
163
  * - `serverIpv4`, when declared, is a dotted-quad IPv4 address.
133
164
  * - Every service's `hostname` is the apex itself or a subdomain of it -- a
@@ -137,4 +168,5 @@ export declare function defaultPlatformHostname(service: PlatformServiceKey, ape
137
168
  * - Every service's `gitRef` is non-empty once that service is `enabled` --
138
169
  * an enabled service with nothing to deploy is not yet declared.
139
170
  */
171
+ export declare const OWNER_RUNS_OWN_SERVICES_REASON = "auth, mail, files, jobs and sfu cannot be enabled on the platform: the owner company runs these as its own services";
140
172
  export declare function validatePlatform(platform: DeclaredPlatform): string | null;
package/dist/platform.js CHANGED
@@ -17,6 +17,11 @@ export const PLATFORM_SERVICE_KEYS = [
17
17
  'hc',
18
18
  'coolify',
19
19
  ];
20
+ /** The only values {@link DeclaredPlatform.heartbeatsProvider} may hold. */
21
+ export const HEARTBEATS_PROVIDERS = new Set([
22
+ 'self-hosted',
23
+ 'hosted',
24
+ ]);
20
25
  /** `<service>.<apex>` -- the default `hostname` for a platform service, before an operator overrides it. */
21
26
  export function defaultPlatformHostname(service, apex) {
22
27
  return `${service}.${apex}`;
@@ -28,6 +33,8 @@ function serviceOf(platform, key) {
28
33
  * `null` when `platform` is valid; otherwise the reason it is not, same
29
34
  * shape as `org.ts`'s `validateBuyerIdentity`.
30
35
  *
36
+ * - `auth`, `mail`, `files`, `jobs` and `sfu` are not `enabled`: the owner company
37
+ * runs them as its own services.
31
38
  * - `apex` is a valid domain (`org.ts`'s `validateDomain`).
32
39
  * - `serverIpv4`, when declared, is a dotted-quad IPv4 address.
33
40
  * - Every service's `hostname` is the apex itself or a subdomain of it -- a
@@ -37,7 +44,13 @@ function serviceOf(platform, key) {
37
44
  * - Every service's `gitRef` is non-empty once that service is `enabled` --
38
45
  * an enabled service with nothing to deploy is not yet declared.
39
46
  */
47
+ export const OWNER_RUNS_OWN_SERVICES_REASON = 'auth, mail, files, jobs and sfu cannot be enabled on the platform: the owner company runs these as its own services';
48
+ /** The platform services the owner company runs as its own, which the platform keeps off. */
49
+ const OWNER_RUN_SERVICE_KEYS = ['auth', 'mail', 'files', 'jobs', 'sfu'];
40
50
  export function validatePlatform(platform) {
51
+ if (OWNER_RUN_SERVICE_KEYS.some((key) => platform[key].enabled)) {
52
+ return OWNER_RUNS_OWN_SERVICES_REASON;
53
+ }
41
54
  const apexReason = validateDomain(platform.apex);
42
55
  if (apexReason)
43
56
  return `apex invalid: ${apexReason}`;
@@ -75,5 +88,8 @@ export function validatePlatform(platform) {
75
88
  if (platform.mail.lettermintProjectId != null && !platform.mail.lettermintProjectId.trim()) {
76
89
  return 'mail.lettermintProjectId must not be empty when set';
77
90
  }
91
+ if (!HEARTBEATS_PROVIDERS.has(platform.heartbeatsProvider)) {
92
+ return `heartbeatsProvider '${platform.heartbeatsProvider}' must be one of ${[...HEARTBEATS_PROVIDERS].join(', ')}`;
93
+ }
78
94
  return null;
79
95
  }
@@ -10,6 +10,8 @@ export declare const DATABASE_NAME = "foundry";
10
10
  * still satisfied with no adapter.
11
11
  */
12
12
  export interface ProvisionCoolifyPort extends Pick<CoolifyPort, 'findProjectByName' | 'createProject' | 'findOrCreateEnvironment' | 'listServers' | 'listDatabases' | 'createPostgresDatabase'> {
13
+ /** With `raw`, the database is created on the platform destination (`plan/destinations.ts`). */
14
+ raw?: CoolifyPort['raw'];
13
15
  listEnvironments(projectUuid: string): Promise<Array<{
14
16
  id: number;
15
17
  uuid: string;
package/dist/provision.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { placedOnPlatform } from './plan/destinations.js';
1
2
  // #R2 (map gap: foundry's own Postgres, two commands): find-or-create the
2
3
  // Postgres database `scripts/foundry-db-provision.mjs` migrates into.
3
4
  // Project, environment and database names are fixed here rather than taken
@@ -56,7 +57,8 @@ export async function applyProvision(coolify, plan) {
56
57
  // `createPostgresDatabase` also returns `internal_db_url` -- a live
57
58
  // credential -- which is deliberately never read off this result: only
58
59
  // `uuid` (and the `name` we already know) leaves this function.
59
- const created = await coolify.createPostgresDatabase({
60
+ const creator = placedOnPlatform(coolify);
61
+ const created = await creator.createPostgresDatabase({
60
62
  project_uuid: project.uuid,
61
63
  server_uuid: server.uuid,
62
64
  environment_name: environment.name,
package/dist/slug.d.ts CHANGED
@@ -1,11 +1,13 @@
1
1
  /** The flattened set {@link validateSlug} checks a candidate slug against. */
2
2
  export declare const RESERVED_SLUGS: ReadonlySet<string>;
3
+ /** Every platform app, tunnel, project and database name starts `platform-`, so no company slug may. */
4
+ export declare const RESERVED_SLUG_PREFIX = "platform-";
3
5
  /**
4
6
  * Why {@link validateSlug} refused a candidate slug, by name so a caller —
5
7
  * `store/postgres.ts`'s `DeclaredOrgError`, eventually the operator page —
6
8
  * can report it without parsing `message` text.
7
9
  */
8
- export type SlugRejectionReason = 'invalid-label' | 'reserved' | 'environment-suffix';
10
+ export type SlugRejectionReason = 'invalid-label' | 'reserved' | 'environment-suffix' | 'reserved-prefix';
9
11
  export interface SlugRejection {
10
12
  reason: SlugRejectionReason;
11
13
  message: string;
package/dist/slug.js CHANGED
@@ -105,6 +105,8 @@ export const RESERVED_SLUGS = new Set([
105
105
  ...RESERVED_INFRASTRUCTURE_AND_MAIL,
106
106
  ...RESERVED_AUTH_AND_ENVIRONMENTS,
107
107
  ]);
108
+ /** Every platform app, tunnel, project and database name starts `platform-`, so no company slug may. */
109
+ export const RESERVED_SLUG_PREFIX = 'platform-';
108
110
  /**
109
111
  * A DNS label: 1-63 characters, lowercase `a-z`, `0-9` and hyphen, no
110
112
  * leading or trailing hyphen. Consecutive hyphens are legal DNS (RFC 1035
@@ -136,6 +138,19 @@ const AGORA_SUFFIX = /-agora$/;
136
138
  * one that opts in.
137
139
  */
138
140
  const BOULE_SUFFIX = /-boule$/;
141
+ /** `<something>-auth|-mail|-files|-sfu|-cms` — an own company's service hostnames on oikos.host (`plan/own-hostnames.ts`). */
142
+ const OWN_SERVICE_SUFFIX = /-(auth|mail|files|sfu|cms)$/;
143
+ /**
144
+ * `names` itself, or `names-<anything>` — foundry names the company tunnel
145
+ * `${slug}-tunnel` (`plan/executor.ts`), so a slug of `names` or starting
146
+ * `names-` produces a tunnel name starting `names-`. Edge refuses to create
147
+ * a platform tunnel whose name starts with `names-` and reads a live
148
+ * `names-*` tunnel as its own, not a company's (foundry#535; `edge`'s
149
+ * `packages/service/src/platform-tunnels.ts`). `namesake` is not a near
150
+ * miss: its tunnel name is `namesake-tunnel`, which does not start with
151
+ * `names-`.
152
+ */
153
+ const NAMES_PREFIX = /^names(-|$)/;
139
154
  /**
140
155
  * Validates a candidate org slug against every rule #28 decided. Returns
141
156
  * `null` when the slug is acceptable, or a {@link SlugRejection} naming
@@ -150,20 +165,27 @@ export function validateSlug(slug) {
150
165
  message: `slug '${slug}' is not a valid DNS label: must be 1-63 characters, lowercase a-z, 0-9 and hyphen, and may not start or end with a hyphen`,
151
166
  };
152
167
  }
153
- if (RESERVED_SLUGS.has(slug)) {
168
+ if (RESERVED_SLUGS.has(slug) || slug.startsWith(RESERVED_SLUG_PREFIX)) {
154
169
  return {
155
170
  reason: 'reserved',
156
171
  message: `slug '${slug}' is reserved by the estate (#28) and cannot be taken by an org`,
157
172
  };
158
173
  }
174
+ if (NAMES_PREFIX.test(slug)) {
175
+ return {
176
+ reason: 'reserved-prefix',
177
+ message: `slug '${slug}' is 'names' or starts with 'names-', a prefix edge reserves for its own platform tunnels and refuses to create a company tunnel under (#535)`,
178
+ };
179
+ }
159
180
  if (STAGE_SUFFIX.test(slug) ||
160
181
  PR_SUFFIX.test(slug) ||
161
182
  CALLS_SUFFIX.test(slug) ||
162
183
  AGORA_SUFFIX.test(slug) ||
163
- BOULE_SUFFIX.test(slug)) {
184
+ BOULE_SUFFIX.test(slug) ||
185
+ OWN_SERVICE_SUFFIX.test(slug)) {
164
186
  return {
165
187
  reason: 'environment-suffix',
166
- message: `slug '${slug}' ends with an environment suffix ('-stage', '-pr-<digits>', '-calls', '-agora' or '-boule') reserved for another org's own hostname (#28, #145, #164, #161) — 'acme-calls' is only legal as acme's calls hostname, 'acme-agora' only as acme's Agora hostname, 'acme-boule' only as acme's Boule hostname, never as an org slug in its own right`,
188
+ message: `slug '${slug}' ends with an environment suffix ('-stage', '-pr-<digits>', '-calls', '-agora', '-boule', '-auth', '-mail', '-files', '-sfu' or '-cms') reserved for another org's own hostname (#28, #145, #164, #161) — 'acme-calls' is only legal as acme's calls hostname, 'acme-agora' only as acme's Agora hostname, 'acme-boule' only as acme's Boule hostname, never as an org slug in its own right`,
167
189
  };
168
190
  }
169
191
  return null;