@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.
- package/dist/calls-sfu-ssh-client.d.ts +11 -0
- package/dist/calls-sfu-ssh-client.js +65 -11
- package/dist/index.d.ts +7 -5
- package/dist/index.js +4 -3
- package/dist/org.d.ts +88 -0
- package/dist/org.js +68 -0
- package/dist/plan/agora.d.ts +13 -0
- package/dist/plan/agora.js +15 -0
- package/dist/plan/boule.d.ts +27 -3
- package/dist/plan/boule.js +39 -1
- package/dist/plan/chat-matrix.d.ts +31 -2
- package/dist/plan/chat-matrix.js +40 -6
- package/dist/plan/diff.d.ts +45 -2
- package/dist/plan/diff.js +211 -16
- package/dist/plan/executor.d.ts +132 -0
- package/dist/plan/executor.js +510 -31
- package/dist/plan/index.d.ts +5 -2
- package/dist/plan/index.js +3 -1
- package/dist/plan/plan-apply.js +1 -0
- package/dist/plan/platform-diff.d.ts +9 -0
- package/dist/plan/platform-diff.js +15 -3
- package/dist/plan/platform-org.d.ts +2 -0
- package/dist/plan/platform-org.js +4 -1
- package/dist/plan/platform-providers.d.ts +1 -1
- package/dist/plan/platform-providers.js +79 -22
- package/dist/plan/providers-readers.d.ts +15 -2
- package/dist/plan/providers-readers.js +44 -6
- package/dist/plan/step-system.js +1 -0
- package/dist/plan/version-status.d.ts +200 -0
- package/dist/plan/version-status.js +213 -0
- package/dist/plan/zitadel-login-policy.d.ts +23 -0
- package/dist/plan/zitadel-login-policy.js +103 -0
- package/dist/plan/zitadel-machine-user.d.ts +1 -1
- package/dist/plan/zitadel-machine-user.js +34 -0
- package/dist/platform.d.ts +1 -1
- package/package.json +3 -2
|
@@ -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
|
}
|
package/dist/platform.d.ts
CHANGED
|
@@ -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
|
|
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.
|
|
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",
|