@wtfalch/foundry 0.32.0 → 0.34.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 (74) hide show
  1. package/dist/bootstrap/bootstrap.d.ts +18 -1
  2. package/dist/bootstrap/bootstrap.js +167 -28
  3. package/dist/bootstrap/declare.d.ts +5 -1
  4. package/dist/bootstrap/declare.js +3 -1
  5. package/dist/bootstrap/index.d.ts +1 -1
  6. package/dist/bootstrap/versions.d.ts +4 -0
  7. package/dist/calls-sfu.d.ts +10 -0
  8. package/dist/calls-sfu.js +41 -0
  9. package/dist/index.d.ts +18 -8
  10. package/dist/index.js +8 -3
  11. package/dist/migrations/0051_org_services.sql +13 -0
  12. package/dist/migrations/0052_platform_mail_relay.sql +8 -0
  13. package/dist/org.d.ts +24 -2
  14. package/dist/org.js +42 -0
  15. package/dist/plan/boule.d.ts +24 -1
  16. package/dist/plan/boule.js +29 -1
  17. package/dist/plan/calls-matrix.d.ts +9 -0
  18. package/dist/plan/calls-matrix.js +17 -0
  19. package/dist/plan/cloudflared-connector.d.ts +7 -1
  20. package/dist/plan/cloudflared-connector.js +14 -1
  21. package/dist/plan/diff.d.ts +32 -1
  22. package/dist/plan/diff.js +383 -26
  23. package/dist/plan/executor.d.ts +177 -7
  24. package/dist/plan/executor.js +738 -51
  25. package/dist/plan/health-check-settings.d.ts +28 -0
  26. package/dist/plan/health-check-settings.js +36 -0
  27. package/dist/plan/health-gaps.d.ts +33 -0
  28. package/dist/plan/health-gaps.js +89 -0
  29. package/dist/plan/health-targets.js +1 -1
  30. package/dist/plan/held-key-refs.d.ts +10 -0
  31. package/dist/plan/held-key-refs.js +10 -0
  32. package/dist/plan/index.d.ts +16 -6
  33. package/dist/plan/index.js +8 -3
  34. package/dist/plan/live.d.ts +5 -2
  35. package/dist/plan/live.js +15 -2
  36. package/dist/plan/locked-hostnames.d.ts +5 -2
  37. package/dist/plan/locked-hostnames.js +9 -1
  38. package/dist/plan/login-host.d.ts +62 -2
  39. package/dist/plan/login-host.js +104 -1
  40. package/dist/plan/mail-relay.d.ts +120 -0
  41. package/dist/plan/mail-relay.js +323 -0
  42. package/dist/plan/org-names.d.ts +24 -0
  43. package/dist/plan/org-names.js +52 -0
  44. package/dist/plan/own-diff.d.ts +169 -0
  45. package/dist/plan/own-diff.js +432 -0
  46. package/dist/plan/own-dns.d.ts +6 -0
  47. package/dist/plan/own-dns.js +15 -0
  48. package/dist/plan/own-limits.d.ts +22 -0
  49. package/dist/plan/own-limits.js +101 -0
  50. package/dist/plan/own-services.d.ts +193 -0
  51. package/dist/plan/own-services.js +167 -0
  52. package/dist/plan/own-sfu.d.ts +69 -0
  53. package/dist/plan/own-sfu.js +255 -0
  54. package/dist/plan/plan-apply.d.ts +101 -7
  55. package/dist/plan/plan-apply.js +216 -12
  56. package/dist/plan/platform-diff.d.ts +61 -12
  57. package/dist/plan/platform-diff.js +140 -34
  58. package/dist/plan/platform-org.d.ts +36 -0
  59. package/dist/plan/platform-org.js +96 -2
  60. package/dist/plan/platform-providers.d.ts +407 -12
  61. package/dist/plan/platform-providers.js +947 -422
  62. package/dist/plan/sign-in-org.d.ts +99 -0
  63. package/dist/plan/sign-in-org.js +151 -0
  64. package/dist/plan/zitadel-login-policy.js +1 -1
  65. package/dist/plan/zitadel-system-api.d.ts +22 -1
  66. package/dist/plan/zitadel-system-api.js +49 -8
  67. package/dist/plan.d.ts +18 -0
  68. package/dist/platform.d.ts +8 -0
  69. package/dist/slug.js +1 -0
  70. package/dist/store/postgres.js +51 -2
  71. package/dist/store/schema.d.ts +51 -0
  72. package/dist/store/schema.js +5 -0
  73. package/package.json +1 -1
  74. package/versions.json +3 -1
@@ -0,0 +1,99 @@
1
+ import type { ZitadelPort } from './executor.js';
2
+ /** Scope id: the domain registered as primary on the company's sign-in org. */
3
+ export declare const ZITADEL_ORG_DOMAIN_SCOPE_KEY = "zitadel_org_domain";
4
+ /**
5
+ * Scope id: the id of the sign-in org whose self-registration foundry closed,
6
+ * recorded only after `closeSelfRegistration` returned. While it is missing or
7
+ * names another org, the company plan holds the close step and no owner invite.
8
+ */
9
+ export declare const ZITADEL_ORG_REGISTRATION_CLOSED_SCOPE_KEY = "zitadel_org_registration_closed";
10
+ /** Closes self-registration on the org (idempotent) and records it under `scopeKey`, in that order. */
11
+ export declare function closeSignInOrgRegistration(zitadel: Pick<ZitadelPort, 'closeSelfRegistration'>, orgId: string, recordScopeId: (key: string, id: string) => void, scopeKey?: string): Promise<void>;
12
+ /** Scope id: the domain the sign-in server refused to make primary (its domain policy wants proof). Set instead of failing; the planner reports it as blocked. */
13
+ export declare const ZITADEL_ORG_DOMAIN_BLOCKED_SCOPE_KEY = "zitadel_org_domain_blocked";
14
+ /** The fixed text of the blocked item for a refused sign-in domain. No cause, no value but names. */
15
+ export declare function signInDomainBlockedText(orgName: string, domain: string): string;
16
+ /** The domain an org's owners sign in on: its own, or `<slug>.oikos.host` when it has none. */
17
+ export declare function signInOrgDomain(org: {
18
+ slug: string;
19
+ domain: string | null;
20
+ }): string;
21
+ /**
22
+ * Adds `domain` to the ZITADEL org and makes it primary. Both calls are
23
+ * idempotent. A failure after the add leaves the domain on the org but not
24
+ * primary, and nothing is recorded, so the next run repeats both.
25
+ */
26
+ export declare function registerSignInDomain(zitadel: Pick<ZitadelPort, 'addOrgDomain' | 'setPrimaryOrgDomain'>, orgId: string, domain: string, sleep?: (ms: number) => Promise<void>): Promise<void>;
27
+ /**
28
+ * {@link registerSignInDomain}, except that a refusal to make the domain
29
+ * primary (the sign-in server's domain policy wants proof) is recorded under
30
+ * `blockedScopeKey` and returns `false` instead of throwing on every apply.
31
+ * Any other failure throws. Returns `true` when the domain is primary.
32
+ */
33
+ export declare function registerSignInDomainOrBlock(zitadel: Pick<ZitadelPort, 'addOrgDomain' | 'setPrimaryOrgDomain'>, orgId: string, domain: string, recordScopeId: (key: string, id: string) => void, blockedScopeKey?: string): Promise<boolean>;
34
+ export interface EnsureSignInOrgOptions {
35
+ zitadel: Pick<ZitadelPort, 'findOrCreateOrg' | 'addOrgDomain' | 'setPrimaryOrgDomain' | 'closeSelfRegistration'>;
36
+ /** Where the successful close is recorded (the org id). Absent: not recorded; the caller closes on every run. */
37
+ registrationClosedScopeKey?: string;
38
+ /** Defaults to {@link ZITADEL_ORG_DOMAIN_BLOCKED_SCOPE_KEY}. */
39
+ domainBlockedScopeKey?: string;
40
+ /** The ZITADEL org's name. A company uses its slug, so every caller finds the same org. */
41
+ name: string;
42
+ /** The domain to make primary, from {@link signInOrgDomain}. */
43
+ domain: string;
44
+ /** Where the ids are recorded. */
45
+ recordScopeId(key: string, id: string): void;
46
+ /** Called once the org id is recorded and before the domain is registered, so a crash between them does not lose the org. */
47
+ persistScopeIds?(): Promise<void>;
48
+ /** Defaults to `zitadel_org`. */
49
+ orgScopeKey?: string;
50
+ /** Defaults to {@link ZITADEL_ORG_DOMAIN_SCOPE_KEY}. */
51
+ domainScopeKey?: string;
52
+ }
53
+ /**
54
+ * Finds or creates the company's sign-in organisation by name, registers
55
+ * `domain` on it and makes it primary, then records the org id and the domain.
56
+ * The domain is recorded last, only after both calls succeeded.
57
+ */
58
+ export declare function ensureSignInOrg(options: EnsureSignInOrgOptions): Promise<{
59
+ orgId: string;
60
+ created: boolean;
61
+ domainBlocked?: true;
62
+ }>;
63
+ /** The user metadata key that holds an owner's personal address. */
64
+ export declare const PERSONAL_EMAIL_METADATA_KEY = "personal_email";
65
+ export interface InviteOwnerOptions {
66
+ zitadel: Pick<ZitadelPort, 'inviteOrgOwner' | 'setUserMetadata' | 'getUserMetadata'>;
67
+ mail: {
68
+ send(to: string, link: string): Promise<void>;
69
+ };
70
+ /** The sign-in org the account is made in. */
71
+ orgId: string;
72
+ /** The platform's login app, where the link lands (it serves `/verify`). */
73
+ authIssuer: string;
74
+ owner: {
75
+ givenName: string;
76
+ familyName: string;
77
+ /** The owner's work address: the account signs in as its local part. */
78
+ mailboxAddress: string;
79
+ /** Where the first-sign-in link is mailed and what the `personal_email` metadata holds. */
80
+ personalEmail: string;
81
+ };
82
+ /** The user id foundry recorded when it invited this owner before. An existing account with another id, and no personal_email metadata of the owner's, is refused. */
83
+ invitedUserId?: string;
84
+ /** Called with the invite code and the whole link before they are mailed, so the caller can mask them. */
85
+ onSecret?: (value: string) => void;
86
+ /** Names the step in errors, for example `zitadel:owner for acme`. */
87
+ label: string;
88
+ }
89
+ /**
90
+ * Makes the owner's account (username = local part of the work address, email =
91
+ * the work address), keeps the personal address as metadata, and mails the
92
+ * first-sign-in link to it. The invite code ZITADEL returns goes only into that
93
+ * mail (30 second timeout); no error holds it. A failure after the account
94
+ * exists leaves the account, and the next run finds it by username and mints a
95
+ * new code.
96
+ */
97
+ export declare function inviteOwnerWithLink(options: InviteOwnerOptions): Promise<{
98
+ userId: string;
99
+ }>;
@@ -0,0 +1,151 @@
1
+ /** Scope id: the domain registered as primary on the company's sign-in org. */
2
+ export const ZITADEL_ORG_DOMAIN_SCOPE_KEY = 'zitadel_org_domain';
3
+ /**
4
+ * Scope id: the id of the sign-in org whose self-registration foundry closed,
5
+ * recorded only after `closeSelfRegistration` returned. While it is missing or
6
+ * names another org, the company plan holds the close step and no owner invite.
7
+ */
8
+ export const ZITADEL_ORG_REGISTRATION_CLOSED_SCOPE_KEY = 'zitadel_org_registration_closed';
9
+ /** Closes self-registration on the org (idempotent) and records it under `scopeKey`, in that order. */
10
+ export async function closeSignInOrgRegistration(zitadel, orgId, recordScopeId, scopeKey = ZITADEL_ORG_REGISTRATION_CLOSED_SCOPE_KEY) {
11
+ await zitadel.closeSelfRegistration(orgId);
12
+ recordScopeId(scopeKey, orgId);
13
+ }
14
+ /** Scope id: the domain the sign-in server refused to make primary (its domain policy wants proof). Set instead of failing; the planner reports it as blocked. */
15
+ export const ZITADEL_ORG_DOMAIN_BLOCKED_SCOPE_KEY = 'zitadel_org_domain_blocked';
16
+ /** The fixed text of the blocked item for a refused sign-in domain. No cause, no value but names. */
17
+ export function signInDomainBlockedText(orgName, domain) {
18
+ return `${orgName}'s sign-in domain ${domain} is blocked, not planned: the sign-in server refused to make it primary because its domain policy wants proof of ownership. Set the policy ValidateOrgDomains to false (and UserLoginMustBeDomain to true) on the sign-in server, then remove the recorded key ${ZITADEL_ORG_DOMAIN_BLOCKED_SCOPE_KEY}`;
19
+ }
20
+ /** True for the providers' "domain is not verified" refusal of `setPrimaryOrgDomain`. */
21
+ function isPrimaryDomainRefused(error) {
22
+ return error instanceof Error && /is not verified/.test(error.message);
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;
30
+ /** The domain an org's owners sign in on: its own, or `<slug>.oikos.host` when it has none. */
31
+ export function signInOrgDomain(org) {
32
+ return (org.domain ?? `${org.slug}.oikos.host`).toLowerCase();
33
+ }
34
+ /**
35
+ * Adds `domain` to the ZITADEL org and makes it primary. Both calls are
36
+ * idempotent. A failure after the add leaves the domain on the org but not
37
+ * primary, and nothing is recorded, so the next run repeats both.
38
+ */
39
+ export async function registerSignInDomain(zitadel, orgId, domain, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))) {
40
+ await zitadel.addOrgDomain(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
+ }
54
+ }
55
+ /**
56
+ * {@link registerSignInDomain}, except that a refusal to make the domain
57
+ * primary (the sign-in server's domain policy wants proof) is recorded under
58
+ * `blockedScopeKey` and returns `false` instead of throwing on every apply.
59
+ * Any other failure throws. Returns `true` when the domain is primary.
60
+ */
61
+ export async function registerSignInDomainOrBlock(zitadel, orgId, domain, recordScopeId, blockedScopeKey = ZITADEL_ORG_DOMAIN_BLOCKED_SCOPE_KEY) {
62
+ try {
63
+ await registerSignInDomain(zitadel, orgId, domain);
64
+ return true;
65
+ }
66
+ catch (error) {
67
+ if (!isPrimaryDomainRefused(error))
68
+ throw error;
69
+ recordScopeId(blockedScopeKey, domain);
70
+ return false;
71
+ }
72
+ }
73
+ /**
74
+ * Finds or creates the company's sign-in organisation by name, registers
75
+ * `domain` on it and makes it primary, then records the org id and the domain.
76
+ * The domain is recorded last, only after both calls succeeded.
77
+ */
78
+ export async function ensureSignInOrg(options) {
79
+ const { zitadel, name, domain, recordScopeId } = options;
80
+ const { org, created } = await zitadel.findOrCreateOrg(name);
81
+ recordScopeId(options.orgScopeKey ?? 'zitadel_org', org.id);
82
+ await options.persistScopeIds?.();
83
+ // Before any owner invite can run: a stranger must not register the owner's
84
+ // username on an org that still inherits the instance's open registration.
85
+ // A failure here stops the step with the org recorded and no close recorded:
86
+ // the company plan then holds the close step and no invite until it succeeds.
87
+ await zitadel.closeSelfRegistration(org.id);
88
+ if (options.registrationClosedScopeKey) {
89
+ recordScopeId(options.registrationClosedScopeKey, org.id);
90
+ }
91
+ const registered = await registerSignInDomainOrBlock(zitadel, org.id, domain, recordScopeId, options.domainBlockedScopeKey);
92
+ if (!registered)
93
+ return { orgId: org.id, created, domainBlocked: true };
94
+ recordScopeId(options.domainScopeKey ?? ZITADEL_ORG_DOMAIN_SCOPE_KEY, domain);
95
+ return { orgId: org.id, created };
96
+ }
97
+ /** The user metadata key that holds an owner's personal address. */
98
+ export const PERSONAL_EMAIL_METADATA_KEY = 'personal_email';
99
+ /** How long the owner invite mail may take before the step gives up on it. */
100
+ const OWNER_INVITE_MAIL_TIMEOUT_MS = 30_000;
101
+ async function withTimeout(work, ms) {
102
+ let timer;
103
+ const timeout = new Promise((_, reject) => {
104
+ timer = setTimeout(() => reject(new Error(`timed out after ${ms}ms`)), ms);
105
+ });
106
+ try {
107
+ return await Promise.race([work, timeout]);
108
+ }
109
+ finally {
110
+ clearTimeout(timer);
111
+ }
112
+ }
113
+ /**
114
+ * Makes the owner's account (username = local part of the work address, email =
115
+ * the work address), keeps the personal address as metadata, and mails the
116
+ * first-sign-in link to it. The invite code ZITADEL returns goes only into that
117
+ * mail (30 second timeout); no error holds it. A failure after the account
118
+ * exists leaves the account, and the next run finds it by username and mints a
119
+ * new code.
120
+ */
121
+ export async function inviteOwnerWithLink(options) {
122
+ const { zitadel, mail, orgId, authIssuer, owner, label } = options;
123
+ const { userId, created, inviteCode } = await zitadel.inviteOrgOwner(orgId, {
124
+ givenName: owner.givenName,
125
+ familyName: owner.familyName,
126
+ email: owner.mailboxAddress,
127
+ username: owner.mailboxAddress.slice(0, owner.mailboxAddress.indexOf('@')),
128
+ }, undefined, true);
129
+ if (!created && !inviteCode && userId !== options.invitedUserId) {
130
+ // An account that already has a sign-in method and no record of ours: not adopted.
131
+ // `personal_email` is written only by this function, through the admin API.
132
+ const metadata = await zitadel.getUserMetadata(orgId, userId, PERSONAL_EMAIL_METADATA_KEY);
133
+ if (metadata?.toLowerCase() !== owner.personalEmail.toLowerCase()) {
134
+ throw new Error(`${label}: in ZITADEL org ${orgId} an account with this username already exists and was not invited by foundry`);
135
+ }
136
+ }
137
+ await zitadel.setUserMetadata(orgId, userId, PERSONAL_EMAIL_METADATA_KEY, owner.personalEmail);
138
+ if (inviteCode) {
139
+ const link = `${authIssuer}/verify?userId=${encodeURIComponent(userId)}&code=${encodeURIComponent(inviteCode)}&organization=${encodeURIComponent(orgId)}&invite=true`;
140
+ options.onSecret?.(inviteCode);
141
+ options.onSecret?.(link);
142
+ try {
143
+ await withTimeout(mail.send(owner.personalEmail, link), OWNER_INVITE_MAIL_TIMEOUT_MS);
144
+ }
145
+ catch (_cause) {
146
+ // The mail port's own error may carry the link, and the link carries the code.
147
+ throw new Error(`${label}: the invite mail could not be sent; the account ${userId} exists and the next apply sends a new link`);
148
+ }
149
+ }
150
+ return { userId };
151
+ }
@@ -74,7 +74,7 @@ export function createZitadelLoginPolicy(options) {
74
74
  const data = await call('GET', orgId, undefined);
75
75
  const policy = data.policy ?? {};
76
76
  const isDefault = data.isDefault === true || policy.isDefault === true;
77
- if (!isDefault && policy.allowRegister === false) {
77
+ if (!isDefault && policy.allowRegister !== true) {
78
78
  return { changed: false };
79
79
  }
80
80
  const 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
  }
package/dist/plan.d.ts CHANGED
@@ -62,6 +62,24 @@ export interface Plan {
62
62
  * this type with none.
63
63
  */
64
64
  versionChanges?: VersionChangeItem[];
65
+ /**
66
+ * Things a person should see that block no step: an optional credential not
67
+ * held yet. Never in `unmanaged`, so a plan with no steps and no blocked
68
+ * item still reads as settled. Optional: a consumer that does not know it
69
+ * loses only the notes.
70
+ */
71
+ notes?: PlanNote[];
72
+ }
73
+ /**
74
+ * One advisory line of a {@link Plan}. It never stops a step and is never a
75
+ * {@link PlanStep}. It names a credential, never a value.
76
+ */
77
+ export interface PlanNote {
78
+ /** Stable, e.g. `uptime:CHAT_ACCESS_TOKEN`. */
79
+ key: string;
80
+ /** The service or step this is about, e.g. `uptime`. */
81
+ service: string;
82
+ description: string;
65
83
  }
66
84
  /**
67
85
  * The action kind a {@link VersionChangeItem} carries -- a distinct, typed
@@ -31,6 +31,14 @@ export interface PlatformSfuService extends PlatformService {
31
31
  */
32
32
  export interface PlatformMailService extends PlatformService {
33
33
  lettermintProjectId?: string | null;
34
+ /**
35
+ * true: one shared relay container owns host port 25 and routes each
36
+ * message by recipient domain to a company's own mail server or to the
37
+ * platform's; the platform Stalwart then stops publishing port 25.
38
+ * false or unset (the default): the platform Stalwart binds host port 25
39
+ * directly, as it always has.
40
+ */
41
+ relay?: boolean;
34
42
  }
35
43
  /**
36
44
  * The uptime Worker (foundry#542): the outside prober Archon publishes as a
package/dist/slug.js CHANGED
@@ -42,6 +42,7 @@ const RESERVED_ESTATE_SERVICES = [
42
42
  'manage',
43
43
  'boule',
44
44
  'operator',
45
+ 'platform',
45
46
  'mailbox',
46
47
  'oikos',
47
48
  'cms',
@@ -1,6 +1,7 @@
1
- import { and, asc, desc, eq, sql } from 'drizzle-orm';
2
- import { APP_ENTRY_KEY, DEFAULT_MAIL_APPS, DEFAULT_NO_REPLY_LOCAL_PART, validateBuyerIdentity, validateCalls, validateDomain, validateHosting, validateMail, validateOwners, validateSite, } from '../org.js';
1
+ import { and, asc, desc, eq, ne, sql } from 'drizzle-orm';
2
+ import { APP_ENTRY_KEY, DEFAULT_MAIL_APPS, DEFAULT_NO_REPLY_LOCAL_PART, validateBuyerIdentity, validateCalls, validateDomain, validateHosting, validateMail, validateOwners, validateServices, validateSite, } from '../org.js';
3
3
  import { lockedHostnames } from '../plan/locked-hostnames.js';
4
+ import { findNameClashes, nameClashMessage } from '../plan/org-names.js';
4
5
  import { validatePlatform } from '../platform.js';
5
6
  import { RequestedChangeError, classifyChange } from '../requested-changes.js';
6
7
  import { validateSlug } from '../slug.js';
@@ -29,6 +30,7 @@ async function writeFenced(db, fence, write) {
29
30
  /** `migrations/0004_slug_unique.sql` / `migrations/0015_domain_unique.sql`'s index names, read back off a caught unique-violation to tell which one fired. */
30
31
  const SLUG_UNIQUE_INDEX = 'foundry_declared_orgs_slug_idx';
31
32
  const DOMAIN_UNIQUE_INDEX = 'foundry_declared_orgs_domain_idx';
33
+ const SFU_SLOT_UNIQUE_INDEX = 'foundry_declared_orgs_sfu_slot_idx';
32
34
  /** #27: a bigint-safe decimal string of a non-negative integer -- no sign, no decimal point, no leading zeros (so "010" never diffs against a live "10"). */
33
35
  const NON_NEGATIVE_INTEGER = /^(0|[1-9]\d*)$/;
34
36
  /**
@@ -135,6 +137,15 @@ function rowToOrg(row) {
135
137
  : {}),
136
138
  },
137
139
  issuer: { usesDomain: row.issuerUsesDomain },
140
+ // Omitted while shared, as `calls.versionPin` is.
141
+ ...(row.servicesOwn || row.servicesSfuSlot !== null
142
+ ? {
143
+ services: {
144
+ own: row.servicesOwn,
145
+ ...(row.servicesSfuSlot !== null ? { sfuSlot: row.servicesSfuSlot } : {}),
146
+ },
147
+ }
148
+ : {}),
138
149
  site: { enabled: row.siteEnabled, usesDomain: row.siteUsesDomain },
139
150
  hosting: {
140
151
  enabled: row.hostingEnabled,
@@ -177,6 +188,8 @@ function orgToRow(org) {
177
188
  callsVersionPinSynapse: org.calls.versionPin?.synapse ?? null,
178
189
  callsVersionPinMas: org.calls.versionPin?.mas ?? null,
179
190
  issuerUsesDomain: org.issuer.usesDomain,
191
+ servicesOwn: org.services?.own ?? false,
192
+ servicesSfuSlot: org.services?.sfuSlot ?? null,
180
193
  siteEnabled: org.site.enabled,
181
194
  siteUsesDomain: org.site.usesDomain,
182
195
  hostingEnabled: org.hosting.enabled,
@@ -345,6 +358,30 @@ export function createPostgresDeclaredOrgStore(db) {
345
358
  if (reason)
346
359
  throw new DeclaredOrgError(`calls invalid: ${reason} (#259)`);
347
360
  }
361
+ // Own-service rules, on `create` and on the merged result of `update`; no
362
+ // two orgs share an `sfuSlot` (the unique index catches the race this misses).
363
+ async function requireValidServices(org) {
364
+ const reason = validateServices(org);
365
+ if (reason)
366
+ throw new DeclaredOrgError(`services invalid: ${reason}`);
367
+ const slot = org.services?.sfuSlot ?? null;
368
+ if (slot === null)
369
+ return;
370
+ const [taken] = await db
371
+ .select({ id: declaredOrgs.id })
372
+ .from(declaredOrgs)
373
+ .where(and(eq(declaredOrgs.servicesSfuSlot, slot), ne(declaredOrgs.id, org.id)))
374
+ .limit(1);
375
+ if (taken)
376
+ throw new DeclaredOrgError(`services invalid: sfuSlot ${slot} is already taken by another org`);
377
+ }
378
+ // No name foundry derives for this org may be one another org or the platform derives.
379
+ async function requireNoNameClash(org) {
380
+ const rows = await db.select().from(declaredOrgs).where(ne(declaredOrgs.id, org.id));
381
+ const [clash] = findNameClashes(org, rows.map(rowToOrg));
382
+ if (clash)
383
+ throw new DeclaredOrgError(nameClashMessage(org.slug, clash));
384
+ }
348
385
  // `as`: `update` is overloaded in the interface (#351), which one object-literal method cannot express.
349
386
  return {
350
387
  async create(org) {
@@ -357,6 +394,8 @@ export function createPostgresDeclaredOrgStore(db) {
357
394
  requireValidOwner(org);
358
395
  requireValidMail(org);
359
396
  requireValidCalls(org);
397
+ await requireValidServices(org);
398
+ await requireNoNameClash(org);
360
399
  try {
361
400
  await db.insert(declaredOrgs).values(orgToRow(org));
362
401
  }
@@ -371,6 +410,9 @@ export function createPostgresDeclaredOrgStore(db) {
371
410
  if (constraint === DOMAIN_UNIQUE_INDEX) {
372
411
  throw new DeclaredOrgError(`domain '${org.domain}' is already taken by another org (#131)`);
373
412
  }
413
+ if (constraint === SFU_SLOT_UNIQUE_INDEX) {
414
+ throw new DeclaredOrgError(`services invalid: sfuSlot ${org.services?.sfuSlot} is already taken by another org`);
415
+ }
374
416
  if (constraint === SLUG_UNIQUE_INDEX || constraint !== undefined) {
375
417
  throw new DeclaredOrgError(`slug '${org.slug}' is already taken by another org`);
376
418
  }
@@ -461,6 +503,8 @@ export function createPostgresDeclaredOrgStore(db) {
461
503
  const wasTier2 = current.domain !== null && current.mail.mode === 'received';
462
504
  requireValidMail(next, { allowExistingTier2: wasTier2 });
463
505
  requireValidCalls(next);
506
+ await requireValidServices(next);
507
+ await requireNoNameClash(next);
464
508
  try {
465
509
  // #351: with `expectedVersion` the version check is part of this one
466
510
  // statement, so an edit that lands after the read above makes it match
@@ -489,6 +533,9 @@ export function createPostgresDeclaredOrgStore(db) {
489
533
  if (constraint === DOMAIN_UNIQUE_INDEX) {
490
534
  throw new DeclaredOrgError(`domain '${next.domain}' is already taken by another org (#131)`);
491
535
  }
536
+ if (constraint === SFU_SLOT_UNIQUE_INDEX) {
537
+ throw new DeclaredOrgError(`services invalid: sfuSlot ${next.services?.sfuSlot} is already taken by another org`);
538
+ }
492
539
  if (constraint === SLUG_UNIQUE_INDEX || constraint !== undefined) {
493
540
  throw new DeclaredOrgError(`slug '${next.slug}' is already taken by another org`);
494
541
  }
@@ -605,6 +652,7 @@ function rowToPlatform(row) {
605
652
  hostname: row.mailHostname,
606
653
  gitRef: row.mailGitRef,
607
654
  ...(row.mailLettermintProjectId ? { lettermintProjectId: row.mailLettermintProjectId } : {}),
655
+ ...(row.mailRelay ? { relay: true } : {}),
608
656
  },
609
657
  sfu: {
610
658
  enabled: row.sfuEnabled,
@@ -656,6 +704,7 @@ function platformToRow(platform) {
656
704
  mailHostname: platform.mail.hostname,
657
705
  mailGitRef: platform.mail.gitRef,
658
706
  mailLettermintProjectId: platform.mail.lettermintProjectId ?? null,
707
+ mailRelay: platform.mail.relay === true,
659
708
  sfuEnabled: platform.sfu.enabled,
660
709
  sfuHostname: platform.sfu.hostname,
661
710
  sfuGitRef: platform.sfu.gitRef,
@@ -321,6 +321,40 @@ export declare const declaredOrgs: import("drizzle-orm/pg-core").PgTableWithColu
321
321
  identity: undefined;
322
322
  generated: undefined;
323
323
  }, {}, {}>;
324
+ servicesOwn: import("drizzle-orm/pg-core").PgColumn<{
325
+ name: "services_own";
326
+ tableName: "foundry_declared_orgs";
327
+ dataType: "boolean";
328
+ columnType: "PgBoolean";
329
+ data: boolean;
330
+ driverParam: boolean;
331
+ notNull: true;
332
+ hasDefault: true;
333
+ isPrimaryKey: false;
334
+ isAutoincrement: false;
335
+ hasRuntimeDefault: false;
336
+ enumValues: undefined;
337
+ baseColumn: never;
338
+ identity: undefined;
339
+ generated: undefined;
340
+ }, {}, {}>;
341
+ servicesSfuSlot: import("drizzle-orm/pg-core").PgColumn<{
342
+ name: "services_sfu_slot";
343
+ tableName: "foundry_declared_orgs";
344
+ dataType: "number";
345
+ columnType: "PgInteger";
346
+ data: number;
347
+ driverParam: string | number;
348
+ notNull: false;
349
+ hasDefault: false;
350
+ isPrimaryKey: false;
351
+ isAutoincrement: false;
352
+ hasRuntimeDefault: false;
353
+ enumValues: undefined;
354
+ baseColumn: never;
355
+ identity: undefined;
356
+ generated: undefined;
357
+ }, {}, {}>;
324
358
  siteEnabled: import("drizzle-orm/pg-core").PgColumn<{
325
359
  name: "site_enabled";
326
360
  tableName: "foundry_declared_orgs";
@@ -1592,6 +1626,23 @@ export declare const foundryDeclaredPlatform: import("drizzle-orm/pg-core").PgTa
1592
1626
  identity: undefined;
1593
1627
  generated: undefined;
1594
1628
  }, {}, {}>;
1629
+ mailRelay: import("drizzle-orm/pg-core").PgColumn<{
1630
+ name: "mail_relay";
1631
+ tableName: "foundry_declared_platform";
1632
+ dataType: "boolean";
1633
+ columnType: "PgBoolean";
1634
+ data: boolean;
1635
+ driverParam: boolean;
1636
+ notNull: true;
1637
+ hasDefault: true;
1638
+ isPrimaryKey: false;
1639
+ isAutoincrement: false;
1640
+ hasRuntimeDefault: false;
1641
+ enumValues: undefined;
1642
+ baseColumn: never;
1643
+ identity: undefined;
1644
+ generated: undefined;
1645
+ }, {}, {}>;
1595
1646
  sfuEnabled: import("drizzle-orm/pg-core").PgColumn<{
1596
1647
  name: "sfu_enabled";
1597
1648
  tableName: "foundry_declared_platform";
@@ -35,6 +35,9 @@ export const declaredOrgs = pgTable('foundry_declared_orgs', {
35
35
  callsVersionPinSynapse: text('calls_version_pin_synapse'),
36
36
  callsVersionPinMas: text('calls_version_pin_mas'),
37
37
  issuerUsesDomain: boolean('issuer_uses_domain').notNull().default(false),
38
+ // ServicesConfig (org.ts): false and null are the platform's shared services.
39
+ servicesOwn: boolean('services_own').notNull().default(false),
40
+ servicesSfuSlot: integer('services_sfu_slot'),
38
41
  // SiteConfig (org.ts, T10). A brand new org has no cms site.
39
42
  siteEnabled: boolean('site_enabled').notNull().default(false),
40
43
  siteUsesDomain: boolean('site_uses_domain').notNull().default(false),
@@ -162,6 +165,8 @@ export const foundryDeclaredPlatform = pgTable('foundry_declared_platform', {
162
165
  mailGitRef: text('mail_git_ref').notNull().default(''),
163
166
  /** #491, `migrations/0044_platform_mail_lettermint_project.sql`. Declared, never discovered. */
164
167
  mailLettermintProjectId: text('mail_lettermint_project_id'),
168
+ /** `migrations/0052_platform_mail_relay.sql`. */
169
+ mailRelay: boolean('mail_relay').notNull().default(false),
165
170
  sfuEnabled: boolean('sfu_enabled').notNull().default(false),
166
171
  sfuHostname: text('sfu_hostname').notNull(),
167
172
  sfuGitRef: text('sfu_git_ref').notNull().default(''),