@wtfalch/foundry 0.29.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,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.29.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",