@wtfalch/foundry 0.28.0 → 0.30.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.
@@ -0,0 +1,200 @@
1
+ import type { OrgId } from '../org.js';
2
+ import type { VersionChangeItem } from '../plan.js';
3
+ /**
4
+ * #263: a read-only view of which `@wtfalch/*` versions each org's hosted
5
+ * app is actually running, beside the platform's own version-change lines
6
+ * (`plan.ts`'s {@link VersionChangeItem}, #251) -- the data behind Archon's
7
+ * "Shared platform page" (wtfalch/archon#133). Nothing here mutates
8
+ * anything; it only reads.
9
+ *
10
+ * The problem this exists for: a shared service (ZITADEL, Synapse, the
11
+ * jobs scheduler, ...) has one declared version on `DeclaredPlatform`, but
12
+ * each org's own hosted app pulls its `@wtfalch/*` dependencies from
13
+ * whatever its own `pnpm-lock.yaml` pinned the day it was last deployed --
14
+ * and nothing before this listed who is actually on what.
15
+ */
16
+ /**
17
+ * One org app this reads status for. The caller resolves `repo` and
18
+ * `coolifyApplicationUuid` itself, the same way every other reader in this
19
+ * package is handed an already-resolved scope id rather than resolving one
20
+ * itself (`providers-readers.ts`'s `ScopeResolver` doc comment) --
21
+ * `coolifyApplicationUuid` is the id `plan/diff.ts`'s `appStepKey` keys an
22
+ * org's hosting entry under in `AppliedState.serviceIds`
23
+ * (`` `coolify:app:${appStepKey(org, entry.key)}` ``, `org.ts`); `repo` is
24
+ * `HostingEntry.repo`, falling back to whatever default an app with none
25
+ * declared resolves to (`plan/executor.ts`'s own `DEFAULT_APP_REPO`).
26
+ */
27
+ export interface OrgAppVersionRef {
28
+ orgId: OrgId;
29
+ orgSlug: string;
30
+ /** The hosting entry's own key, e.g. `'app'` (`org.ts`'s `APP_ENTRY_KEY`). */
31
+ entryKey: string;
32
+ /** The repo under `wtfalch` this app builds from, e.g. `'wtfalch/lokessmie'`. */
33
+ repo: string;
34
+ coolifyApplicationUuid: string;
35
+ }
36
+ /** One org app's read status: what is actually deployed, and what it depends on. */
37
+ export interface OrgAppVersionStatus {
38
+ orgId: OrgId;
39
+ orgSlug: string;
40
+ entryKey: string;
41
+ repo: string;
42
+ /**
43
+ * The full 40-character commit sha Coolify's own deployment history
44
+ * reports as currently deployed (Choice A, #263's "Decide": Coolify's
45
+ * application record is live truth over foundry's own last-applied
46
+ * record, at the cost of a Coolify call this function always makes).
47
+ * `null` when there is no finished deployment yet -- a brand new app, or
48
+ * one whose only deploy so far failed.
49
+ */
50
+ deployedSha: string | null;
51
+ /**
52
+ * `@wtfalch/*` package name to resolved version, read from this app's
53
+ * `pnpm-lock.yaml` at {@link deployedSha}. `null` when there is nothing to
54
+ * read it from yet (`deployedSha` is `null`) or the read itself failed --
55
+ * {@link error} says which.
56
+ */
57
+ wtfalchVersions: Record<string, string> | null;
58
+ /** Set when this app's status could not be fully read; `wtfalchVersions` is `null` in this case too. */
59
+ error: string | null;
60
+ }
61
+ /** Across every app a call named, one `@wtfalch/*` package's spread of versions. */
62
+ export interface WtfalchPackageDrift {
63
+ package: string;
64
+ /** Every distinct version seen, each with the org slugs running it, so a human reads who's on what with no cross-reference against `apps`. */
65
+ versionsBySlug: Record<string, string[]>;
66
+ /**
67
+ * The version a caller declared as the intended one for this package, if
68
+ * it passed one in `declaredVersions` (#263's "or from the declared
69
+ * one") -- never guessed here: this package has no fixed mapping from a
70
+ * `PlatformServiceKey` to an npm package name, so a caller that has one
71
+ * (or none) decides.
72
+ */
73
+ declaredVersion?: string;
74
+ /** True once more than one version is in use across `apps`, or an app's version differs from `declaredVersion`. */
75
+ drifted: boolean;
76
+ }
77
+ /** The combined view {@link readPlatformVersionStatus} returns. */
78
+ export interface PlatformVersionStatus {
79
+ apps: OrgAppVersionStatus[];
80
+ /** Sorted by package name. */
81
+ packageDrift: WtfalchPackageDrift[];
82
+ /**
83
+ * The platform's own version-change lines (#251), carried through
84
+ * unchanged so a caller can render them on the same page with one call
85
+ * instead of two -- `toPlatformPlan`'s own `Plan.versionChanges`, or
86
+ * `PlatformPlanDetail.versionChanges` directly.
87
+ */
88
+ platformVersionChanges: VersionChangeItem[];
89
+ }
90
+ /**
91
+ * Reads one application's currently deployed commit sha.
92
+ *
93
+ * Narrow on purpose, the same shape every other port in `executor.ts`
94
+ * takes (`CoolifyPort`, `CloudflarePort`, ...): a real implementation is
95
+ * {@link providersStatusCoolifyPort}, over `@wtfalch/providers/coolify`'s
96
+ * `CoolifyClient`; a fake satisfies this directly in a test.
97
+ */
98
+ export interface StatusCoolifyPort {
99
+ /**
100
+ * `null` when there is no *finished* deployment yet
101
+ * (`@wtfalch/providers/coolify`'s own doc comment on
102
+ * `CoolifyDeploymentRecord`: an application's bare `status` field reports
103
+ * on the container that is currently *running*, not on whether the last
104
+ * deploy actually landed, so a failed build leaves the previous image
105
+ * running and healthy with nothing in that field to say so -- this reads
106
+ * the deployment history instead, exactly as that doc comment advises).
107
+ */
108
+ deployedSha(applicationUuid: string): Promise<string | null>;
109
+ }
110
+ /**
111
+ * Reads one file's text contents in a GitHub repo, at a git ref.
112
+ *
113
+ * `@wtfalch/providers` 0.6.0's `GitHubClient` (`dist/github.d.ts`) has no
114
+ * such method yet, and this repo cannot add one to that published package
115
+ * from here -- so this is foundry's own minimal port for exactly the one
116
+ * capability #263 needs, built the same injectable way `GitHubClient`
117
+ * itself is (its own `GhExec`), so a host can later swap in a real
118
+ * `providers` method with no change to anything that depends on this
119
+ * interface.
120
+ */
121
+ export interface StatusGithubPort {
122
+ /** `null` when `path` does not exist at `ref` (or `ref` itself does not exist). */
123
+ getFileContents(repo: string, path: string, ref: string): Promise<string | null>;
124
+ }
125
+ /**
126
+ * Extracts every `@wtfalch/*` package's resolved version out of a pnpm
127
+ * lockfile's text, without parsing YAML in general.
128
+ *
129
+ * pnpm lockfile v9 (`lockfileVersion: '9.0'`, this estate's own) writes each
130
+ * resolved package as a two-space-indented, single-quoted
131
+ * `'<name>@<version>'` key in its `packages:` section, and again --
132
+ * peer-qualified, e.g. `'<name>@<version>(<peer>@<peer-version>...)'` -- in
133
+ * its `snapshots:` section. Both carry the same base version for a given
134
+ * package, so reading either (or both, as this does) and keeping the last
135
+ * value seen per package name is lossless for what this exists to compare:
136
+ * *which version* an app is on, never which peer graph it resolved to.
137
+ * Verified against this very repo's own `pnpm-lock.yaml`.
138
+ */
139
+ export declare function wtfalchVersionsFromPnpmLockfile(lockfile: string): Record<string, string>;
140
+ export interface ReadPlatformVersionStatusOptions {
141
+ /** Default `'pnpm-lock.yaml'`. */
142
+ lockfilePath?: string;
143
+ /**
144
+ * The intended version for a package, keyed by its `@wtfalch/*` name --
145
+ * #263's "or from the declared one". Optional: without it, drift is
146
+ * computed across `apps` alone.
147
+ */
148
+ declaredVersions?: Readonly<Record<string, string>>;
149
+ /** Carried straight through onto {@link PlatformVersionStatus.platformVersionChanges}; defaults to none. */
150
+ platformVersionChanges?: readonly VersionChangeItem[];
151
+ }
152
+ /**
153
+ * Reads every named app's deployed sha and the `@wtfalch/*` versions its
154
+ * lockfile pins at that sha, then flags which packages have drifted --
155
+ * across the named apps, or against a declared version when the caller
156
+ * passes one (#263's "Done when": two orgs on different `@wtfalch/auth`
157
+ * versions is flagged).
158
+ *
159
+ * One app's read failing (its sha cannot be found, or its lockfile fetch
160
+ * errors) is recorded on that app alone (`OrgAppVersionStatus.error`) and
161
+ * never stops another app's read, or the drift computed from whichever
162
+ * apps did resolve.
163
+ */
164
+ export declare function readPlatformVersionStatus(apps: readonly OrgAppVersionRef[], ports: {
165
+ coolify: StatusCoolifyPort;
166
+ github: StatusGithubPort;
167
+ }, options?: ReadPlatformVersionStatusOptions): Promise<PlatformVersionStatus>;
168
+ /**
169
+ * The real {@link StatusCoolifyPort}, over `@wtfalch/providers/coolify`'s
170
+ * `CoolifyClient` (Choice A, #263's "Decide"). Narrowed to the one method
171
+ * this needs, the same `Pick`-shaped narrowing nothing else in this package
172
+ * happened to need before -- `CoolifyClient` itself is never imported as a
173
+ * type here, so a fake in a test needs no part of its surface this doesn't use.
174
+ */
175
+ export declare function providersStatusCoolifyPort(client: {
176
+ listApplicationDeployments(uuid: string, take?: number): Promise<Array<{
177
+ commit: string;
178
+ status: string;
179
+ }>>;
180
+ }): StatusCoolifyPort;
181
+ /**
182
+ * The `gh` CLI call {@link ghCliStatusGithubPort} makes -- same shape as
183
+ * `@wtfalch/providers/github`'s own `GhExec`, so a host that already has a
184
+ * real one wired for `GitHubClient` can hand the identical function to
185
+ * both. Injectable so a test never spawns a real process.
186
+ */
187
+ export type GhExec = (args: string[]) => Promise<{
188
+ stdout: string;
189
+ stderr: string;
190
+ }>;
191
+ /**
192
+ * The real {@link StatusGithubPort}, built on `gh api repos/{repo}/contents/{path}`
193
+ * the way GitHub's own REST API serves a file's contents at a ref --
194
+ * `{ content: '<base64>' }` on success, a non-zero exit with `404` in `gh`'s
195
+ * own error text when `path` or `ref` doesn't exist (checked against a real
196
+ * `gh` CLI, not GitHub's docs, matching this package's `coolify.ts`'s own
197
+ * "verified against a live instance, not the docs" standard for an API this
198
+ * cannot run against the network in CI).
199
+ */
200
+ export declare function ghCliStatusGithubPort(exec?: GhExec): StatusGithubPort;
@@ -0,0 +1,213 @@
1
+ import { execFile } from 'node:child_process';
2
+ /**
3
+ * Extracts every `@wtfalch/*` package's resolved version out of a pnpm
4
+ * lockfile's text, without parsing YAML in general.
5
+ *
6
+ * pnpm lockfile v9 (`lockfileVersion: '9.0'`, this estate's own) writes each
7
+ * resolved package as a two-space-indented, single-quoted
8
+ * `'<name>@<version>'` key in its `packages:` section, and again --
9
+ * peer-qualified, e.g. `'<name>@<version>(<peer>@<peer-version>...)'` -- in
10
+ * its `snapshots:` section. Both carry the same base version for a given
11
+ * package, so reading either (or both, as this does) and keeping the last
12
+ * value seen per package name is lossless for what this exists to compare:
13
+ * *which version* an app is on, never which peer graph it resolved to.
14
+ * Verified against this very repo's own `pnpm-lock.yaml`.
15
+ */
16
+ export function wtfalchVersionsFromPnpmLockfile(lockfile) {
17
+ const PACKAGE_LINE = /^ {2}'(@wtfalch\/[^@']+)@([^'()]+)(?:\(.*\))?':$/;
18
+ const versions = {};
19
+ for (const line of lockfile.split('\n')) {
20
+ const match = PACKAGE_LINE.exec(line);
21
+ const name = match?.[1];
22
+ const version = match?.[2];
23
+ if (name !== undefined && version !== undefined) {
24
+ versions[name] = version;
25
+ }
26
+ }
27
+ return versions;
28
+ }
29
+ /** `pnpm-lock.yaml` at the repo root -- every repo in this estate pins with pnpm (root `CLAUDE.md`). */
30
+ const DEFAULT_LOCKFILE_PATH = 'pnpm-lock.yaml';
31
+ /**
32
+ * Reads every named app's deployed sha and the `@wtfalch/*` versions its
33
+ * lockfile pins at that sha, then flags which packages have drifted --
34
+ * across the named apps, or against a declared version when the caller
35
+ * passes one (#263's "Done when": two orgs on different `@wtfalch/auth`
36
+ * versions is flagged).
37
+ *
38
+ * One app's read failing (its sha cannot be found, or its lockfile fetch
39
+ * errors) is recorded on that app alone (`OrgAppVersionStatus.error`) and
40
+ * never stops another app's read, or the drift computed from whichever
41
+ * apps did resolve.
42
+ */
43
+ export async function readPlatformVersionStatus(apps, ports, options = {}) {
44
+ const lockfilePath = options.lockfilePath ?? DEFAULT_LOCKFILE_PATH;
45
+ const statuses = await Promise.all(apps.map((app) => readOneAppVersionStatus(app, ports, lockfilePath)));
46
+ return {
47
+ apps: statuses,
48
+ packageDrift: computeWtfalchPackageDrift(statuses, options.declaredVersions ?? {}),
49
+ platformVersionChanges: options.platformVersionChanges
50
+ ? [...options.platformVersionChanges]
51
+ : [],
52
+ };
53
+ }
54
+ async function readOneAppVersionStatus(app, ports, lockfilePath) {
55
+ const base = {
56
+ orgId: app.orgId,
57
+ orgSlug: app.orgSlug,
58
+ entryKey: app.entryKey,
59
+ repo: app.repo,
60
+ };
61
+ try {
62
+ const deployedSha = await ports.coolify.deployedSha(app.coolifyApplicationUuid);
63
+ if (!deployedSha) {
64
+ return { ...base, deployedSha: null, wtfalchVersions: null, error: null };
65
+ }
66
+ const lockfile = await ports.github.getFileContents(app.repo, lockfilePath, deployedSha);
67
+ if (lockfile === null) {
68
+ return {
69
+ ...base,
70
+ deployedSha,
71
+ wtfalchVersions: null,
72
+ error: `${lockfilePath} not found in ${app.repo} at ${deployedSha}`,
73
+ };
74
+ }
75
+ return {
76
+ ...base,
77
+ deployedSha,
78
+ wtfalchVersions: wtfalchVersionsFromPnpmLockfile(lockfile),
79
+ error: null,
80
+ };
81
+ }
82
+ catch (error) {
83
+ return {
84
+ ...base,
85
+ deployedSha: null,
86
+ wtfalchVersions: null,
87
+ error: `read failed: ${error instanceof Error ? error.name : 'unknown error'}`,
88
+ };
89
+ }
90
+ }
91
+ function computeWtfalchPackageDrift(statuses, declaredVersions) {
92
+ const byPackage = new Map();
93
+ for (const status of statuses) {
94
+ if (!status.wtfalchVersions)
95
+ continue;
96
+ for (const [pkg, version] of Object.entries(status.wtfalchVersions)) {
97
+ const versions = byPackage.get(pkg) ?? new Map();
98
+ byPackage.set(pkg, versions);
99
+ const slugs = versions.get(version) ?? new Set();
100
+ versions.set(version, slugs);
101
+ slugs.add(status.orgSlug);
102
+ }
103
+ }
104
+ // A package only named in `declaredVersions`, with no app running it yet,
105
+ // is still worth a row: it is the simplest drift there is -- declared,
106
+ // and nobody on it.
107
+ for (const pkg of Object.keys(declaredVersions)) {
108
+ if (!byPackage.has(pkg))
109
+ byPackage.set(pkg, new Map());
110
+ }
111
+ const packages = [...byPackage.keys()].sort();
112
+ return packages.map((pkg) => {
113
+ const versions = byPackage.get(pkg) ?? new Map();
114
+ const versionsBySlug = {};
115
+ for (const [version, slugs] of versions) {
116
+ versionsBySlug[version] = [...slugs].sort();
117
+ }
118
+ const declaredVersion = declaredVersions[pkg];
119
+ const distinct = new Set(versions.keys());
120
+ if (declaredVersion !== undefined)
121
+ distinct.add(declaredVersion);
122
+ return {
123
+ package: pkg,
124
+ versionsBySlug,
125
+ ...(declaredVersion !== undefined ? { declaredVersion } : {}),
126
+ drifted: distinct.size > 1,
127
+ };
128
+ });
129
+ }
130
+ /**
131
+ * The real {@link StatusCoolifyPort}, over `@wtfalch/providers/coolify`'s
132
+ * `CoolifyClient` (Choice A, #263's "Decide"). Narrowed to the one method
133
+ * this needs, the same `Pick`-shaped narrowing nothing else in this package
134
+ * happened to need before -- `CoolifyClient` itself is never imported as a
135
+ * type here, so a fake in a test needs no part of its surface this doesn't use.
136
+ */
137
+ export function providersStatusCoolifyPort(client) {
138
+ return {
139
+ async deployedSha(applicationUuid) {
140
+ const deployments = await client.listApplicationDeployments(applicationUuid);
141
+ const finished = deployments.find((d) => d.status === 'finished');
142
+ return finished?.commit ?? null;
143
+ },
144
+ };
145
+ }
146
+ const GH_TIMEOUT_MS = 30_000;
147
+ const REPO_PATTERN = /^[\w.-]+\/[\w.-]+$/;
148
+ const REF_PATTERN = /^[\w./-]+$/;
149
+ /**
150
+ * The default {@link GhExec}: shells out to the real `gh` CLI, exactly the
151
+ * dependency `@wtfalch/providers/github`'s own `GitHubClient` already takes
152
+ * on. Real process I/O, left untested here the same as `ssh2`'s `Client` in
153
+ * `scripts/calls-sfu-deploy.mjs` -- {@link ghCliStatusGithubPort}'s own
154
+ * decision logic (404 -> `null`, base64 decode) is what is unit-tested,
155
+ * against a fake `GhExec`.
156
+ */
157
+ function spawnGh(args) {
158
+ return new Promise((resolve, reject) => {
159
+ execFile('gh', args, { maxBuffer: 32 * 1024 * 1024, timeout: GH_TIMEOUT_MS }, (error, stdout, stderr) => {
160
+ if (error) {
161
+ reject(Object.assign(error, { stdout, stderr }));
162
+ return;
163
+ }
164
+ resolve({ stdout, stderr });
165
+ });
166
+ });
167
+ }
168
+ /**
169
+ * The real {@link StatusGithubPort}, built on `gh api repos/{repo}/contents/{path}`
170
+ * the way GitHub's own REST API serves a file's contents at a ref --
171
+ * `{ content: '<base64>' }` on success, a non-zero exit with `404` in `gh`'s
172
+ * own error text when `path` or `ref` doesn't exist (checked against a real
173
+ * `gh` CLI, not GitHub's docs, matching this package's `coolify.ts`'s own
174
+ * "verified against a live instance, not the docs" standard for an API this
175
+ * cannot run against the network in CI).
176
+ */
177
+ export function ghCliStatusGithubPort(exec = spawnGh) {
178
+ return {
179
+ async getFileContents(repo, path, ref) {
180
+ if (!REPO_PATTERN.test(repo) || !REF_PATTERN.test(ref) || path.includes('..')) {
181
+ throw new Error('invalid repo, path or ref');
182
+ }
183
+ try {
184
+ const { stdout } = await exec([
185
+ 'api',
186
+ `repos/${repo}/contents/${path}`,
187
+ '--method',
188
+ 'GET',
189
+ '-f',
190
+ `ref=${ref}`,
191
+ '--jq',
192
+ '.content',
193
+ ]);
194
+ const base64 = stdout.trim();
195
+ if (!base64 || base64 === 'null')
196
+ return null;
197
+ return Buffer.from(base64, 'base64').toString('utf8');
198
+ }
199
+ catch (error) {
200
+ if (isGhNotFoundError(error))
201
+ return null;
202
+ throw error;
203
+ }
204
+ },
205
+ };
206
+ }
207
+ function isGhNotFoundError(error) {
208
+ const record = error;
209
+ const text = [record?.message, record?.stderr]
210
+ .filter((value) => typeof value === 'string')
211
+ .join('\n');
212
+ return /\b404\b|not found/i.test(text);
213
+ }
@@ -0,0 +1,23 @@
1
+ import type { ZitadelPort } from './executor.js';
2
+ export interface ZitadelLoginPolicyOptions {
3
+ /** The ZITADEL instance's base URL. */
4
+ url: string;
5
+ /** Bearer token allowed to read and write an org's login policy. */
6
+ token: string;
7
+ fetchImpl?: typeof fetch;
8
+ instanceHost?: string;
9
+ }
10
+ /**
11
+ * The real {@link ZitadelPort.closeSelfRegistration}: the mirror of
12
+ * `ZitadelClient.allowSelfRegistration` in `@wtfalch/providers`, with
13
+ * `allowRegister` forced off instead of on. It speaks ZITADEL's v1 management
14
+ * API (`GET`/`POST`/`PUT /management/v1/policies/login`, org-scoped by
15
+ * `x-zitadel-orgid`) and has not been run against a live instance.
16
+ *
17
+ * Neither write merges: a field left out resets to false or empty. So every
18
+ * setting of the policy in force is copied back and only `allowRegister`
19
+ * changes. An org that inherits the instance default always gets its own
20
+ * policy (POST), so a later change to the default cannot turn registration
21
+ * back on. A custom policy keeps its own factors and IdPs (PUT).
22
+ */
23
+ export declare function createZitadelLoginPolicy(options: ZitadelLoginPolicyOptions): Pick<ZitadelPort, 'closeSelfRegistration'>;
@@ -0,0 +1,103 @@
1
+ import { ZitadelHttpError } from './zitadel-machine-user.js';
2
+ /** The settings both AddCustomLoginPolicy and UpdateCustomLoginPolicy accept. */
3
+ const LOGIN_POLICY_SETTINGS = [
4
+ 'allowUsernamePassword',
5
+ 'allowRegister',
6
+ 'allowExternalIdp',
7
+ 'forceMfa',
8
+ 'passwordlessType',
9
+ 'hidePasswordReset',
10
+ 'ignoreUnknownUsernames',
11
+ 'defaultRedirectUri',
12
+ 'passwordCheckLifetime',
13
+ 'externalLoginCheckLifetime',
14
+ 'mfaInitSkipLifetime',
15
+ 'secondFactorCheckLifetime',
16
+ 'multiFactorCheckLifetime',
17
+ 'allowDomainDiscovery',
18
+ 'disableLoginWithEmail',
19
+ 'disableLoginWithPhone',
20
+ 'forceMfaLocalOnly',
21
+ ];
22
+ const CALL_TIMEOUT_MS = 30_000;
23
+ /**
24
+ * The real {@link ZitadelPort.closeSelfRegistration}: the mirror of
25
+ * `ZitadelClient.allowSelfRegistration` in `@wtfalch/providers`, with
26
+ * `allowRegister` forced off instead of on. It speaks ZITADEL's v1 management
27
+ * API (`GET`/`POST`/`PUT /management/v1/policies/login`, org-scoped by
28
+ * `x-zitadel-orgid`) and has not been run against a live instance.
29
+ *
30
+ * Neither write merges: a field left out resets to false or empty. So every
31
+ * setting of the policy in force is copied back and only `allowRegister`
32
+ * changes. An org that inherits the instance default always gets its own
33
+ * policy (POST), so a later change to the default cannot turn registration
34
+ * back on. A custom policy keeps its own factors and IdPs (PUT).
35
+ */
36
+ export function createZitadelLoginPolicy(options) {
37
+ const fetchImpl = options.fetchImpl ?? globalThis.fetch;
38
+ async function call(method, orgId, body) {
39
+ const path = '/management/v1/policies/login';
40
+ const response = await fetchImpl(`${options.url}${path}`, {
41
+ method,
42
+ signal: AbortSignal.timeout(CALL_TIMEOUT_MS),
43
+ headers: {
44
+ authorization: `Bearer ${options.token}`,
45
+ 'content-type': 'application/json',
46
+ 'x-zitadel-orgid': orgId,
47
+ ...(options.instanceHost
48
+ ? {
49
+ 'x-zitadel-instance-host': options.instanceHost,
50
+ 'x-forwarded-host': options.instanceHost,
51
+ 'x-forwarded-proto': 'https',
52
+ }
53
+ : {}),
54
+ },
55
+ body: body === undefined ? undefined : JSON.stringify(body),
56
+ });
57
+ const text = await response.text();
58
+ let data = {};
59
+ try {
60
+ data = text ? JSON.parse(text) : {};
61
+ }
62
+ catch {
63
+ throw new ZitadelHttpError(response.status, `non-JSON response from ${path}`);
64
+ }
65
+ if (!response.ok) {
66
+ throw new ZitadelHttpError(response.status, String(data.message ?? response.statusText), typeof data.code === 'number' ? data.code : undefined);
67
+ }
68
+ return data;
69
+ }
70
+ return {
71
+ async closeSelfRegistration(orgId) {
72
+ if (!orgId)
73
+ throw new Error('closeSelfRegistration: orgId is required');
74
+ const data = await call('GET', orgId, undefined);
75
+ const policy = data.policy ?? {};
76
+ const isDefault = data.isDefault === true || policy.isDefault === true;
77
+ if (!isDefault && policy.allowRegister === false) {
78
+ return { changed: false };
79
+ }
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;
86
+ if (isDefault) {
87
+ await call('POST', orgId, {
88
+ ...settings,
89
+ secondFactors: policy.secondFactors ?? [],
90
+ multiFactors: policy.multiFactors ?? [],
91
+ idps: (policy.idps ?? []).map((idp) => ({
92
+ idpId: idp.idpId,
93
+ ownerType: 'IDP_OWNER_TYPE_SYSTEM',
94
+ })),
95
+ });
96
+ }
97
+ else {
98
+ await call('PUT', orgId, settings);
99
+ }
100
+ return { changed: true };
101
+ },
102
+ };
103
+ }
@@ -84,4 +84,4 @@ export interface ZitadelMinterOptions {
84
84
  fetchImpl?: typeof fetch;
85
85
  instanceHost?: string;
86
86
  }
87
- export declare function createZitadelAdminMinter(options: ZitadelMinterOptions): Pick<ZitadelPort, 'mintAdminMachineUser' | 'mintLoginClientMachineUser' | 'listLoginClientPats'>;
87
+ export declare function createZitadelAdminMinter(options: ZitadelMinterOptions): Pick<ZitadelPort, 'mintAdminMachineUser' | 'mintLoginClientMachineUser' | 'listLoginClientPats' | 'reserveUserName'>;
@@ -230,6 +230,39 @@ export function createZitadelAdminMinter(options) {
230
230
  }
231
231
  return { userId, token: pat.token, patId: patId ?? '', revokedPriorPat };
232
232
  }
233
+ /**
234
+ * Find-or-create only: the first two steps of the sequence above, with no
235
+ * IAM role grant and no PAT. The user holds no secret; it exists so
236
+ * ZITADEL's per-org username uniqueness refuses the name to anyone later.
237
+ */
238
+ async function reserveUserName(orgId, userName) {
239
+ if (!orgId || !userName.trim()) {
240
+ throw new Error('reserveUserName: orgId and userName are required');
241
+ }
242
+ const found = await call('POST', '/management/v1/users/_search', {
243
+ queries: [
244
+ {
245
+ userNameQuery: {
246
+ userName,
247
+ method: 'TEXT_QUERY_METHOD_EQUALS_IGNORE_CASE',
248
+ },
249
+ },
250
+ ],
251
+ }, orgId);
252
+ const existing = found.result?.[0]?.id;
253
+ if (existing)
254
+ return { userId: existing };
255
+ const created = await call('POST', '/management/v1/users/machine', {
256
+ userName,
257
+ name: `Foundry (reserved: ${userName})`,
258
+ accessTokenType: 'ACCESS_TOKEN_TYPE_BEARER',
259
+ }, orgId);
260
+ const userId = typeof created.userId === 'string' ? created.userId : undefined;
261
+ if (!userId) {
262
+ throw new Error(`ZITADEL did not return the machine user id reserving username ${userName}`);
263
+ }
264
+ return { userId };
265
+ }
233
266
  return {
234
267
  mintAdminMachineUser: (orgId, input) => mintMachineUser(orgId, input, 'IAM_OWNER').then(({ userId, token }) => ({ userId, token })),
235
268
  mintLoginClientMachineUser: (orgId, input) => mintMachineUser(orgId, input, 'IAM_LOGIN_CLIENT'),
@@ -238,5 +271,6 @@ export function createZitadelAdminMinter(options) {
238
271
  // the user -- see `ZitadelPort.listLoginClientPats`'s own doc comment for
239
272
  // the race this closes.
240
273
  listLoginClientPats: (orgId, userId) => listPats(orgId, userId),
274
+ reserveUserName,
241
275
  };
242
276
  }
@@ -95,7 +95,7 @@ export interface DeclaredPlatform {
95
95
  /**
96
96
  * #540: a self-hosted heartbeat server (Healthchecks) for the platform,
97
97
  * for now -- whether it stays the platform's heartbeat server long-term,
98
- * instead of or alongside the hand-set-up `hc.wtfalch.dev` instance, and
98
+ * instead of or alongside the hand-set-up heartbeat instance, and
99
99
  * what happens to that instance's own data, are both still open "Decide"
100
100
  * items on issue #540's own thread, not settled by this field. `gitRef`
101
101
  * is the Docker image tag it runs (the same overload `mail.gitRef`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/foundry",
3
- "version": "0.28.0",
3
+ "version": "0.30.0",
4
4
  "description": "Foundry's SDK: the declared org's types, the plan and apply contracts, and the state store's interface.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -34,7 +34,8 @@
34
34
  "typecheck": "tsc --noEmit",
35
35
  "test": "vitest run",
36
36
  "db:provision": "node scripts/foundry-db-provision.mjs",
37
- "calls-sfu:deploy": "node scripts/calls-sfu-deploy.mjs"
37
+ "calls-sfu:deploy": "node scripts/calls-sfu-deploy.mjs",
38
+ "status": "node scripts/status.mjs"
38
39
  },
39
40
  "peerDependencies": {
40
41
  "@wtfalch/providers": ">=0.7.4",