@wtfalch/foundry 0.4.0 → 0.5.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/bootstrap/bootstrap.d.ts +96 -0
- package/dist/bootstrap/bootstrap.js +285 -0
- package/dist/bootstrap/declare.d.ts +36 -0
- package/dist/bootstrap/declare.js +94 -0
- package/dist/bootstrap/drive.d.ts +40 -0
- package/dist/bootstrap/drive.js +51 -0
- package/dist/bootstrap/index.d.ts +3 -0
- package/dist/bootstrap/index.js +1 -0
- package/dist/calls-sfu-deploy-support.d.ts +9 -6
- package/dist/calls-sfu-deploy-support.js +9 -6
- package/dist/calls-sfu-keys.d.ts +21 -0
- package/dist/calls-sfu-keys.js +27 -0
- package/dist/calls-sfu-ssh-adapter.d.ts +55 -0
- package/dist/calls-sfu-ssh-adapter.js +78 -0
- package/dist/calls-sfu-ssh-client.d.ts +49 -0
- package/dist/calls-sfu-ssh-client.js +82 -0
- package/dist/calls-sfu.d.ts +5 -4
- package/dist/calls-sfu.js +5 -4
- package/dist/index.d.ts +31 -6
- package/dist/index.js +16 -3
- package/dist/migrations/0016_platform.sql +61 -0
- package/dist/migrations/0017_requested_changes.sql +42 -0
- package/dist/migrations/0018_platform_apply.sql +19 -0
- package/dist/org.js +8 -0
- package/dist/plan/agora.d.ts +77 -0
- package/dist/plan/agora.js +127 -0
- package/dist/plan/cloudflared-connector.d.ts +70 -0
- package/dist/plan/cloudflared-connector.js +106 -0
- package/dist/plan/diff.d.ts +20 -3
- package/dist/plan/diff.js +631 -116
- package/dist/plan/executor.d.ts +475 -168
- package/dist/plan/executor.js +543 -175
- package/dist/plan/index.d.ts +22 -5
- package/dist/plan/index.js +12 -2
- package/dist/plan/jobs-scheduler-http.d.ts +29 -0
- package/dist/plan/jobs-scheduler-http.js +50 -0
- package/dist/plan/live.d.ts +51 -2
- package/dist/plan/live.js +8 -0
- package/dist/plan/plan-apply.d.ts +192 -3
- package/dist/plan/plan-apply.js +278 -9
- package/dist/plan/platform-diff.d.ts +312 -0
- package/dist/plan/platform-diff.js +867 -0
- package/dist/plan/platform-org-ports.d.ts +50 -0
- package/dist/plan/platform-org-ports.js +70 -0
- package/dist/plan/platform-org.d.ts +120 -0
- package/dist/plan/platform-org.js +115 -0
- package/dist/plan/platform-providers.d.ts +501 -0
- package/dist/plan/platform-providers.js +3029 -0
- package/dist/plan/platform-sfu.d.ts +93 -0
- package/dist/plan/platform-sfu.js +100 -0
- package/dist/plan/providers-readers.js +22 -1
- package/dist/plan/zitadel-machine-user.d.ts +34 -0
- package/dist/plan/zitadel-machine-user.js +95 -0
- package/dist/plan.d.ts +6 -0
- package/dist/platform.d.ts +55 -0
- package/dist/platform.js +46 -0
- package/dist/requested-changes.d.ts +108 -0
- package/dist/requested-changes.js +95 -0
- package/dist/slug.js +15 -2
- package/dist/store/audit.js +4 -2
- package/dist/store/postgres.d.ts +29 -1
- package/dist/store/postgres.js +172 -2
- package/dist/store/schema.d.ts +683 -0
- package/dist/store/schema.js +57 -0
- package/dist/store.d.ts +46 -0
- package/package.json +1 -1
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The platform's `sfu` step (#170): the shared calls SFU (LiveKit +
|
|
3
|
+
* cloudflared, `../calls-sfu.ts`) applied from `applyPlatform` instead of by
|
|
4
|
+
* hand running `scripts/calls-sfu-deploy.mjs`. Mirrors #167's
|
|
5
|
+
* `platform-providers.ts` shape -- narrow ports, a host (archon) supplies the
|
|
6
|
+
* real implementation -- for the two things this step needs beyond
|
|
7
|
+
* `applyCallsSfu` itself:
|
|
8
|
+
*
|
|
9
|
+
* - {@link CallsSfuConnectionPort}: the SFU VM's address, SSH credentials and
|
|
10
|
+
* host key fingerprint. A human stores these as held keys ahead of time
|
|
11
|
+
* (#170's "Human steps") -- this package only ever reads them back, the
|
|
12
|
+
* same way #122's original script did from the environment.
|
|
13
|
+
* - {@link CallsSfuKeysPort}: the LiveKit API key/secret pair. Unlike the
|
|
14
|
+
* connection details, this step *mints* the pair itself, the first time,
|
|
15
|
+
* and delivers it to held keys -- every apply after that reads the same
|
|
16
|
+
* pair back rather than minting a second one. `executor.ts`'s
|
|
17
|
+
* `CallsSfuPort.resolveCredentials` (the org-level calls-matrix step) reads
|
|
18
|
+
* the same held-keys entry this delivers.
|
|
19
|
+
*
|
|
20
|
+
* {@link CallsSfuSshConnector} is the third port: opening the actual SSH
|
|
21
|
+
* session. The real one is `calls-sfu-ssh-client.ts`'s `connectCallsSfuSsh`
|
|
22
|
+
* (over `ssh2`); a host passes it as `ssh`. {@link CallsSfuConnectionPort} and
|
|
23
|
+
* {@link CallsSfuKeysPort} stay host-implemented on purpose: they read and
|
|
24
|
+
* write held keys (`@wtfalch/keys`), which only the host (archon) can reach,
|
|
25
|
+
* the same way `CoolifyPort`/`CloudflarePort`/`ZitadelPort`'s real clients
|
|
26
|
+
* live outside this package. `calls-sfu-ssh-adapter.ts`'s {@link CallsSfuSshSession} is the narrow shape
|
|
27
|
+
* a test fakes instead.
|
|
28
|
+
*/
|
|
29
|
+
import { type LivekitCredentials } from '../calls-sfu-keys.js';
|
|
30
|
+
import { type CallsSfuSshSession } from '../calls-sfu-ssh-adapter.js';
|
|
31
|
+
/** The SFU VM's own address, SSH credentials and host key fingerprint -- held keys a human stored ahead of time. */
|
|
32
|
+
export interface CallsSfuConnection {
|
|
33
|
+
address: string;
|
|
34
|
+
sshUser: string;
|
|
35
|
+
sshPort: number;
|
|
36
|
+
sshPrivateKey: string;
|
|
37
|
+
/** `SHA256:...`, `hostKeyFingerprint`'s own format -- checked by `toCallsSfuHostPort`/`applyCallsSfuOverSession`. */
|
|
38
|
+
sshHostFingerprint: string;
|
|
39
|
+
/** #127's own held key, read back here rather than minted -- unrelated to the LiveKit pair this step mints. */
|
|
40
|
+
cloudflaredTunnelToken: string;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Read-only: `null` before a human has stored the SFU VM's connection
|
|
44
|
+
* details as held keys (#170's "Human steps"). This step never invents an
|
|
45
|
+
* address or a private key.
|
|
46
|
+
*/
|
|
47
|
+
export interface CallsSfuConnectionPort {
|
|
48
|
+
resolveConnection(): Promise<CallsSfuConnection | null>;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Where this step mints and stores the LiveKit API key/secret pair, as held
|
|
52
|
+
* keys. `resolveLivekitKeys` returns `null` before the first apply;
|
|
53
|
+
* `deliverLivekitKeys` is called exactly once, the first time this step
|
|
54
|
+
* finds nothing stored -- mirrors `FoundryStepExecutorPorts.deliverSecret`'s
|
|
55
|
+
* own "mint once, deliver, never re-mint" shape (`zitadel:app`'s
|
|
56
|
+
* `createApiApp`/`deliverSecret` pair in `executor.ts`).
|
|
57
|
+
*/
|
|
58
|
+
export interface CallsSfuKeysPort {
|
|
59
|
+
resolveLivekitKeys(): Promise<LivekitCredentials | null>;
|
|
60
|
+
deliverLivekitKeys(pair: LivekitCredentials): Promise<void>;
|
|
61
|
+
}
|
|
62
|
+
/** Opens the actual SSH session; `connectCallsSfuSsh` is the real implementation. */
|
|
63
|
+
export interface CallsSfuSshConnector {
|
|
64
|
+
connect(options: {
|
|
65
|
+
address: string;
|
|
66
|
+
sshUser: string;
|
|
67
|
+
sshPort: number;
|
|
68
|
+
sshPrivateKey: string;
|
|
69
|
+
/** Verified inside the handshake, before authentication; the connector refuses a mismatch. */
|
|
70
|
+
sshHostFingerprint: string;
|
|
71
|
+
}): Promise<CallsSfuSshSession & {
|
|
72
|
+
close(): void;
|
|
73
|
+
}>;
|
|
74
|
+
}
|
|
75
|
+
export declare class MissingCallsSfuConnectionError extends Error {
|
|
76
|
+
constructor();
|
|
77
|
+
}
|
|
78
|
+
export interface CallsSfuPlatformDeps {
|
|
79
|
+
connection: CallsSfuConnectionPort;
|
|
80
|
+
keys: CallsSfuKeysPort;
|
|
81
|
+
ssh: CallsSfuSshConnector;
|
|
82
|
+
/** Overridable so a test never needs a real CSPRNG call to assert on the minted pair. */
|
|
83
|
+
mintLivekitCredentials?: () => LivekitCredentials;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Runs the platform's `sfu` step: resolve the connection, mint the LiveKit
|
|
87
|
+
* pair the first time (read it back on every apply after that), open the
|
|
88
|
+
* SSH session, and apply. Every part of this is safe to re-run -- minting
|
|
89
|
+
* only happens once the stored pair comes back `null`, and
|
|
90
|
+
* `applyCallsSfuOverSession` is itself idempotent -- so a second apply with
|
|
91
|
+
* the same held keys changes nothing.
|
|
92
|
+
*/
|
|
93
|
+
export declare function applyCallsSfuPlatformStep(deps: CallsSfuPlatformDeps): Promise<void>;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The platform's `sfu` step (#170): the shared calls SFU (LiveKit +
|
|
3
|
+
* cloudflared, `../calls-sfu.ts`) applied from `applyPlatform` instead of by
|
|
4
|
+
* hand running `scripts/calls-sfu-deploy.mjs`. Mirrors #167's
|
|
5
|
+
* `platform-providers.ts` shape -- narrow ports, a host (archon) supplies the
|
|
6
|
+
* real implementation -- for the two things this step needs beyond
|
|
7
|
+
* `applyCallsSfu` itself:
|
|
8
|
+
*
|
|
9
|
+
* - {@link CallsSfuConnectionPort}: the SFU VM's address, SSH credentials and
|
|
10
|
+
* host key fingerprint. A human stores these as held keys ahead of time
|
|
11
|
+
* (#170's "Human steps") -- this package only ever reads them back, the
|
|
12
|
+
* same way #122's original script did from the environment.
|
|
13
|
+
* - {@link CallsSfuKeysPort}: the LiveKit API key/secret pair. Unlike the
|
|
14
|
+
* connection details, this step *mints* the pair itself, the first time,
|
|
15
|
+
* and delivers it to held keys -- every apply after that reads the same
|
|
16
|
+
* pair back rather than minting a second one. `executor.ts`'s
|
|
17
|
+
* `CallsSfuPort.resolveCredentials` (the org-level calls-matrix step) reads
|
|
18
|
+
* the same held-keys entry this delivers.
|
|
19
|
+
*
|
|
20
|
+
* {@link CallsSfuSshConnector} is the third port: opening the actual SSH
|
|
21
|
+
* session. The real one is `calls-sfu-ssh-client.ts`'s `connectCallsSfuSsh`
|
|
22
|
+
* (over `ssh2`); a host passes it as `ssh`. {@link CallsSfuConnectionPort} and
|
|
23
|
+
* {@link CallsSfuKeysPort} stay host-implemented on purpose: they read and
|
|
24
|
+
* write held keys (`@wtfalch/keys`), which only the host (archon) can reach,
|
|
25
|
+
* the same way `CoolifyPort`/`CloudflarePort`/`ZitadelPort`'s real clients
|
|
26
|
+
* live outside this package. `calls-sfu-ssh-adapter.ts`'s {@link CallsSfuSshSession} is the narrow shape
|
|
27
|
+
* a test fakes instead.
|
|
28
|
+
*/
|
|
29
|
+
import { redactSecrets } from '../calls-sfu-deploy-support.js';
|
|
30
|
+
import { mintLivekitCredentials } from '../calls-sfu-keys.js';
|
|
31
|
+
import { applyCallsSfuOverSession } from '../calls-sfu-ssh-adapter.js';
|
|
32
|
+
export class MissingCallsSfuConnectionError extends Error {
|
|
33
|
+
constructor() {
|
|
34
|
+
super('No SSH connection details are held for the calls SFU. Store the SFU VM’s ' +
|
|
35
|
+
'SSH key and host key fingerprint as held keys before applying the ' +
|
|
36
|
+
'platform’s sfu step (#170’s "Human steps").');
|
|
37
|
+
this.name = 'MissingCallsSfuConnectionError';
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Runs the platform's `sfu` step: resolve the connection, mint the LiveKit
|
|
42
|
+
* pair the first time (read it back on every apply after that), open the
|
|
43
|
+
* SSH session, and apply. Every part of this is safe to re-run -- minting
|
|
44
|
+
* only happens once the stored pair comes back `null`, and
|
|
45
|
+
* `applyCallsSfuOverSession` is itself idempotent -- so a second apply with
|
|
46
|
+
* the same held keys changes nothing.
|
|
47
|
+
*/
|
|
48
|
+
export async function applyCallsSfuPlatformStep(deps) {
|
|
49
|
+
const connection = await deps.connection.resolveConnection();
|
|
50
|
+
if (!connection)
|
|
51
|
+
throw new MissingCallsSfuConnectionError();
|
|
52
|
+
// Every value below can echo back in an error (remote stdout/stderr, an
|
|
53
|
+
// ssh2 connect error), and the message reaches the platform audit row, so
|
|
54
|
+
// it is redacted before it leaves this function.
|
|
55
|
+
const secrets = [connection.sshPrivateKey, connection.cloudflaredTunnelToken];
|
|
56
|
+
try {
|
|
57
|
+
let pair = await deps.keys.resolveLivekitKeys();
|
|
58
|
+
if (!pair) {
|
|
59
|
+
pair = (deps.mintLivekitCredentials ?? mintLivekitCredentials)();
|
|
60
|
+
secrets.push(pair.apiKey, pair.apiSecret);
|
|
61
|
+
await deps.keys.deliverLivekitKeys(pair);
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
secrets.push(pair.apiKey, pair.apiSecret);
|
|
65
|
+
}
|
|
66
|
+
const session = await deps.ssh.connect({
|
|
67
|
+
address: connection.address,
|
|
68
|
+
sshUser: connection.sshUser,
|
|
69
|
+
sshPort: connection.sshPort,
|
|
70
|
+
sshPrivateKey: connection.sshPrivateKey,
|
|
71
|
+
sshHostFingerprint: connection.sshHostFingerprint,
|
|
72
|
+
});
|
|
73
|
+
try {
|
|
74
|
+
const config = {
|
|
75
|
+
address: connection.address,
|
|
76
|
+
livekitApiKey: pair.apiKey,
|
|
77
|
+
livekitApiSecret: pair.apiSecret,
|
|
78
|
+
cloudflaredTunnelToken: connection.cloudflaredTunnelToken,
|
|
79
|
+
};
|
|
80
|
+
await applyCallsSfuOverSession(session, config, connection.sshHostFingerprint);
|
|
81
|
+
}
|
|
82
|
+
finally {
|
|
83
|
+
session.close();
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
throw redactedError(error, secrets);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
function redactedError(error, secrets) {
|
|
91
|
+
const original = error instanceof Error ? error : new Error(String(error));
|
|
92
|
+
const message = redactSecrets(original.message, secrets);
|
|
93
|
+
const stack = redactSecrets(original.stack ?? message, secrets);
|
|
94
|
+
if (message === original.message && stack === original.stack)
|
|
95
|
+
return original;
|
|
96
|
+
const clean = new Error(message);
|
|
97
|
+
clean.name = original.name;
|
|
98
|
+
clean.stack = stack;
|
|
99
|
+
return clean;
|
|
100
|
+
}
|
|
@@ -130,6 +130,26 @@ async function withLogVolumeConfig(client, resource, recorded) {
|
|
|
130
130
|
return resource;
|
|
131
131
|
}
|
|
132
132
|
}
|
|
133
|
+
/**
|
|
134
|
+
* #164: the org's own recorded Agora app's live `AGORA_*` env vars, as
|
|
135
|
+
* `config.env`, so `diff.ts`'s `planAgora` can compare them with what
|
|
136
|
+
* `executor.ts` would write. Gated on `recorded` mapping this exact uuid onto
|
|
137
|
+
* the `agora` kind, like {@link withLogVolumeConfig}. Unlike that read, a
|
|
138
|
+
* failure here is not swallowed: a plan that cannot see the live config must
|
|
139
|
+
* fail, not report it as missing.
|
|
140
|
+
*/
|
|
141
|
+
async function withAgoraEnvConfig(client, resource, recorded) {
|
|
142
|
+
if (resource.kind !== 'application' || !recorded[resource.key]?.startsWith('agora:')) {
|
|
143
|
+
return resource;
|
|
144
|
+
}
|
|
145
|
+
const envs = await client.listEnvs(resource.key);
|
|
146
|
+
const env = {};
|
|
147
|
+
for (const e of envs) {
|
|
148
|
+
if (!e.is_preview && e.key.startsWith('AGORA_'))
|
|
149
|
+
env[e.key] = e.value;
|
|
150
|
+
}
|
|
151
|
+
return { ...resource, config: { ...resource.config, env } };
|
|
152
|
+
}
|
|
133
153
|
/** `@wtfalch/providers`' own `LiveResource` (`resource.ts`) has no `system` field — foundry's adds it at this boundary. */
|
|
134
154
|
function toFoundryResources(system, resources) {
|
|
135
155
|
return resources.map((r) => ({ system, kind: r.kind, key: r.key, config: r.config }));
|
|
@@ -157,7 +177,8 @@ export class ProvidersCoolifyReader {
|
|
|
157
177
|
this.resolveRecordedIds(orgId),
|
|
158
178
|
]);
|
|
159
179
|
const withLogConfig = await Promise.all(toFoundryResources('coolify', resources).map((r) => withLogVolumeConfig(this.client, r, recorded)));
|
|
160
|
-
|
|
180
|
+
const withAgoraConfig = await Promise.all(withLogConfig.map((r) => withAgoraEnvConfig(this.client, r, recorded)));
|
|
181
|
+
return applyRecordedIds(withAgoraConfig, recorded);
|
|
161
182
|
}
|
|
162
183
|
}
|
|
163
184
|
/**
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { ZitadelPort } from './executor.js';
|
|
2
|
+
/**
|
|
3
|
+
* #168: the real {@link ZitadelPort.mintAdminMachineUser}. `@wtfalch/providers`
|
|
4
|
+
* has no machine-user support, so this speaks ZITADEL's v1 management and
|
|
5
|
+
* admin APIs directly, with the same request shape as providers'
|
|
6
|
+
* `ZitadelClient.call` (bearer token, JSON in and out, `x-zitadel-orgid` for
|
|
7
|
+
* the target org, optional instance-host headers, a typed error on a non-ok
|
|
8
|
+
* response). The calls follow ZITADEL's published API reference and have not
|
|
9
|
+
* been run against a live instance.
|
|
10
|
+
*
|
|
11
|
+
* 1. `POST /management/v1/users/_search` finds the machine user by user name.
|
|
12
|
+
* 2. `POST /management/v1/users/machine` creates it when absent.
|
|
13
|
+
* 3. `POST /admin/v1/members` grants `IAM_OWNER` (409 means already granted).
|
|
14
|
+
* 4. `POST /management/v1/users/{id}/pats` adds a token, which ZITADEL
|
|
15
|
+
* returns once.
|
|
16
|
+
*
|
|
17
|
+
* `token` is the bearer for the new instance, so it must belong to a caller
|
|
18
|
+
* allowed to create users and instance members (for example the instance's
|
|
19
|
+
* bootstrap machine user). The minted token is returned to the caller and
|
|
20
|
+
* never logged or put in an error.
|
|
21
|
+
*/
|
|
22
|
+
export declare class ZitadelHttpError extends Error {
|
|
23
|
+
readonly status: number;
|
|
24
|
+
constructor(status: number, message: string);
|
|
25
|
+
}
|
|
26
|
+
export interface ZitadelMinterOptions {
|
|
27
|
+
/** The new instance's base URL, e.g. `https://auth.companyincluded.com`. */
|
|
28
|
+
url: string;
|
|
29
|
+
/** Bearer token allowed to create users and add instance members there. */
|
|
30
|
+
token: string;
|
|
31
|
+
fetchImpl?: typeof fetch;
|
|
32
|
+
instanceHost?: string;
|
|
33
|
+
}
|
|
34
|
+
export declare function createZitadelAdminMinter(options: ZitadelMinterOptions): Pick<ZitadelPort, 'mintAdminMachineUser'>;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #168: the real {@link ZitadelPort.mintAdminMachineUser}. `@wtfalch/providers`
|
|
3
|
+
* has no machine-user support, so this speaks ZITADEL's v1 management and
|
|
4
|
+
* admin APIs directly, with the same request shape as providers'
|
|
5
|
+
* `ZitadelClient.call` (bearer token, JSON in and out, `x-zitadel-orgid` for
|
|
6
|
+
* the target org, optional instance-host headers, a typed error on a non-ok
|
|
7
|
+
* response). The calls follow ZITADEL's published API reference and have not
|
|
8
|
+
* been run against a live instance.
|
|
9
|
+
*
|
|
10
|
+
* 1. `POST /management/v1/users/_search` finds the machine user by user name.
|
|
11
|
+
* 2. `POST /management/v1/users/machine` creates it when absent.
|
|
12
|
+
* 3. `POST /admin/v1/members` grants `IAM_OWNER` (409 means already granted).
|
|
13
|
+
* 4. `POST /management/v1/users/{id}/pats` adds a token, which ZITADEL
|
|
14
|
+
* returns once.
|
|
15
|
+
*
|
|
16
|
+
* `token` is the bearer for the new instance, so it must belong to a caller
|
|
17
|
+
* allowed to create users and instance members (for example the instance's
|
|
18
|
+
* bootstrap machine user). The minted token is returned to the caller and
|
|
19
|
+
* never logged or put in an error.
|
|
20
|
+
*/
|
|
21
|
+
export class ZitadelHttpError extends Error {
|
|
22
|
+
status;
|
|
23
|
+
constructor(status, message) {
|
|
24
|
+
super(message);
|
|
25
|
+
this.status = status;
|
|
26
|
+
this.name = 'ZitadelHttpError';
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export function createZitadelAdminMinter(options) {
|
|
30
|
+
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
31
|
+
async function call(method, path, body, orgId) {
|
|
32
|
+
const response = await fetchImpl(`${options.url}${path}`, {
|
|
33
|
+
method,
|
|
34
|
+
headers: {
|
|
35
|
+
authorization: `Bearer ${options.token}`,
|
|
36
|
+
'content-type': 'application/json',
|
|
37
|
+
...(options.instanceHost
|
|
38
|
+
? {
|
|
39
|
+
'x-zitadel-instance-host': options.instanceHost,
|
|
40
|
+
'x-forwarded-host': options.instanceHost,
|
|
41
|
+
'x-forwarded-proto': 'https',
|
|
42
|
+
}
|
|
43
|
+
: {}),
|
|
44
|
+
...(orgId ? { 'x-zitadel-orgid': orgId } : {}),
|
|
45
|
+
},
|
|
46
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
47
|
+
});
|
|
48
|
+
const text = await response.text();
|
|
49
|
+
let data = {};
|
|
50
|
+
try {
|
|
51
|
+
data = text ? JSON.parse(text) : {};
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
// Never echo the body: on a PAT call it could hold the token.
|
|
55
|
+
throw new ZitadelHttpError(response.status, `non-JSON response from ${path}`);
|
|
56
|
+
}
|
|
57
|
+
if (!response.ok) {
|
|
58
|
+
throw new ZitadelHttpError(response.status, String(data.message ?? response.statusText));
|
|
59
|
+
}
|
|
60
|
+
return data;
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
async mintAdminMachineUser(orgId, input) {
|
|
64
|
+
const found = await call('POST', '/management/v1/users/_search', {
|
|
65
|
+
queries: [
|
|
66
|
+
{ userNameQuery: { userName: input.userName, method: 'TEXT_QUERY_METHOD_EQUALS' } },
|
|
67
|
+
],
|
|
68
|
+
}, orgId);
|
|
69
|
+
const existing = found.result?.[0]?.id;
|
|
70
|
+
let userId = existing;
|
|
71
|
+
if (!userId) {
|
|
72
|
+
const created = await call('POST', '/management/v1/users/machine', {
|
|
73
|
+
userName: input.userName,
|
|
74
|
+
name: input.name,
|
|
75
|
+
accessTokenType: 'ACCESS_TOKEN_TYPE_BEARER',
|
|
76
|
+
}, orgId);
|
|
77
|
+
userId = typeof created.userId === 'string' ? created.userId : undefined;
|
|
78
|
+
}
|
|
79
|
+
if (!userId)
|
|
80
|
+
throw new Error('ZITADEL did not return the machine user id');
|
|
81
|
+
try {
|
|
82
|
+
await call('POST', '/admin/v1/members', { userId, roles: ['IAM_OWNER'] });
|
|
83
|
+
}
|
|
84
|
+
catch (e) {
|
|
85
|
+
if (!(e instanceof ZitadelHttpError && e.status === 409))
|
|
86
|
+
throw e;
|
|
87
|
+
}
|
|
88
|
+
const pat = await call('POST', `/management/v1/users/${userId}/pats`, {}, orgId);
|
|
89
|
+
if (typeof pat.token !== 'string' || pat.token === '') {
|
|
90
|
+
throw new Error(`ZITADEL returned no token for machine user ${userId}`);
|
|
91
|
+
}
|
|
92
|
+
return { userId, token: pat.token };
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
package/dist/plan.d.ts
CHANGED
|
@@ -35,6 +35,12 @@ export interface PlanStep {
|
|
|
35
35
|
*/
|
|
36
36
|
export interface UnmanagedItem {
|
|
37
37
|
description: string;
|
|
38
|
+
/**
|
|
39
|
+
* Set on a platform service that is blocked, not planned: the service's name
|
|
40
|
+
* (`auth`, `mail`, `integrations`, `files`, `ai`, `jobs`, `archon`, `sfu`).
|
|
41
|
+
* Absent on a live-but-undeclared resource.
|
|
42
|
+
*/
|
|
43
|
+
key?: string;
|
|
38
44
|
}
|
|
39
45
|
/**
|
|
40
46
|
* The diff between a {@link DeclaredOrg} and live reality, computed by the
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { OrgId } from './org.js';
|
|
2
|
+
/**
|
|
3
|
+
* A shared service the declared platform runs, one entry per key on
|
|
4
|
+
* {@link DeclaredPlatform}. `enabled` turns the service's own provisioning
|
|
5
|
+
* on; `hostname` is where it is served (default `<service>.<apex>`,
|
|
6
|
+
* {@link defaultPlatformHostname}); `gitRef` is the release it runs, the
|
|
7
|
+
* same "declared, not discovered" shape `HostingConfig.gitBranch` uses for
|
|
8
|
+
* an org's own app (`org.ts`).
|
|
9
|
+
*/
|
|
10
|
+
export interface PlatformService {
|
|
11
|
+
enabled: boolean;
|
|
12
|
+
hostname: string;
|
|
13
|
+
gitRef: string;
|
|
14
|
+
}
|
|
15
|
+
/** The eight shared services a declared platform names, decided in #165. */
|
|
16
|
+
export type PlatformServiceKey = 'auth' | 'mail' | 'sfu' | 'files' | 'ai' | 'jobs' | 'integrations' | 'archon';
|
|
17
|
+
export declare const PLATFORM_SERVICE_KEYS: readonly PlatformServiceKey[];
|
|
18
|
+
/**
|
|
19
|
+
* The one shared platform every org's services run on -- decided in #165 as
|
|
20
|
+
* one declared platform, owned by Company Included, with its own plan and
|
|
21
|
+
* apply, mirroring `DeclaredOrg` (`org.ts`) for the shape a human declares
|
|
22
|
+
* and foundry drives live state to match.
|
|
23
|
+
*
|
|
24
|
+
* `ownerOrgId` is Company Included's own {@link OrgId} -- the platform is not
|
|
25
|
+
* a level-1 org itself, but its resources are billed and owned by one. There
|
|
26
|
+
* is exactly one declared platform (`store/postgres.ts`'s
|
|
27
|
+
* `createPostgresPlatformStore`, `migrations/0016_platform.sql`'s singleton
|
|
28
|
+
* table); a second one is refused at the database level, not by convention.
|
|
29
|
+
*/
|
|
30
|
+
export interface DeclaredPlatform {
|
|
31
|
+
ownerOrgId: OrgId;
|
|
32
|
+
/** The shared apex every service defaults its hostname under, e.g. `companyincluded.com`. */
|
|
33
|
+
apex: string;
|
|
34
|
+
auth: PlatformService;
|
|
35
|
+
mail: PlatformService;
|
|
36
|
+
sfu: PlatformService;
|
|
37
|
+
files: PlatformService;
|
|
38
|
+
ai: PlatformService;
|
|
39
|
+
jobs: PlatformService;
|
|
40
|
+
integrations: PlatformService;
|
|
41
|
+
archon: PlatformService;
|
|
42
|
+
}
|
|
43
|
+
/** `<service>.<apex>` -- the default `hostname` for a platform service, before an operator overrides it. */
|
|
44
|
+
export declare function defaultPlatformHostname(service: PlatformServiceKey, apex: string): string;
|
|
45
|
+
/**
|
|
46
|
+
* `null` when `platform` is valid; otherwise the reason it is not, same
|
|
47
|
+
* shape as `org.ts`'s `validateBuyerIdentity`.
|
|
48
|
+
*
|
|
49
|
+
* - `apex` is a valid domain (`org.ts`'s `validateDomain`).
|
|
50
|
+
* - Every service's `hostname` is the apex itself or a subdomain of it -- a
|
|
51
|
+
* platform service does not live outside the platform's own apex.
|
|
52
|
+
* - Every service's `gitRef` is non-empty once that service is `enabled` --
|
|
53
|
+
* an enabled service with nothing to deploy is not yet declared.
|
|
54
|
+
*/
|
|
55
|
+
export declare function validatePlatform(platform: DeclaredPlatform): string | null;
|
package/dist/platform.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { validateDomain } from './org.js';
|
|
2
|
+
export const PLATFORM_SERVICE_KEYS = [
|
|
3
|
+
'auth',
|
|
4
|
+
'mail',
|
|
5
|
+
'sfu',
|
|
6
|
+
'files',
|
|
7
|
+
'ai',
|
|
8
|
+
'jobs',
|
|
9
|
+
'integrations',
|
|
10
|
+
'archon',
|
|
11
|
+
];
|
|
12
|
+
/** `<service>.<apex>` -- the default `hostname` for a platform service, before an operator overrides it. */
|
|
13
|
+
export function defaultPlatformHostname(service, apex) {
|
|
14
|
+
return `${service}.${apex}`;
|
|
15
|
+
}
|
|
16
|
+
function serviceOf(platform, key) {
|
|
17
|
+
return platform[key];
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* `null` when `platform` is valid; otherwise the reason it is not, same
|
|
21
|
+
* shape as `org.ts`'s `validateBuyerIdentity`.
|
|
22
|
+
*
|
|
23
|
+
* - `apex` is a valid domain (`org.ts`'s `validateDomain`).
|
|
24
|
+
* - Every service's `hostname` is the apex itself or a subdomain of it -- a
|
|
25
|
+
* platform service does not live outside the platform's own apex.
|
|
26
|
+
* - Every service's `gitRef` is non-empty once that service is `enabled` --
|
|
27
|
+
* an enabled service with nothing to deploy is not yet declared.
|
|
28
|
+
*/
|
|
29
|
+
export function validatePlatform(platform) {
|
|
30
|
+
const apexReason = validateDomain(platform.apex);
|
|
31
|
+
if (apexReason)
|
|
32
|
+
return `apex invalid: ${apexReason}`;
|
|
33
|
+
for (const key of PLATFORM_SERVICE_KEYS) {
|
|
34
|
+
const service = serviceOf(platform, key);
|
|
35
|
+
if (!service.hostname.trim()) {
|
|
36
|
+
return `${key}.hostname must not be empty`;
|
|
37
|
+
}
|
|
38
|
+
if (service.hostname !== platform.apex && !service.hostname.endsWith(`.${platform.apex}`)) {
|
|
39
|
+
return `${key}.hostname '${service.hostname}' must be '${platform.apex}' or a subdomain of it`;
|
|
40
|
+
}
|
|
41
|
+
if (service.enabled && !service.gitRef.trim()) {
|
|
42
|
+
return `${key}.gitRef must not be empty once ${key}.enabled is true`;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { DeclaredOrg, MailMode, OrgId } from './org.js';
|
|
2
|
+
import type { PlanDetail } from './plan/diff.js';
|
|
3
|
+
/**
|
|
4
|
+
* The whitelist of `DeclaredOrg` fields a company's own Boule can request a
|
|
5
|
+
* change to (#178, map #165: "a company's Boule can request setting changes
|
|
6
|
+
* on its own declared org, and Archon applies them"). Everything else is
|
|
7
|
+
* refused by {@link classifyChange} -- there is no field this module accepts
|
|
8
|
+
* outside this list.
|
|
9
|
+
*
|
|
10
|
+
* Decided in #178 "Decide": `calls`, `storageLimit` and `aiLimit` are small
|
|
11
|
+
* and safe; `domain` and `mailTier` are full self-service too, but both are
|
|
12
|
+
* migrating steps (`plan/diff.ts`'s `planMail` moves live mail between
|
|
13
|
+
* modes; a domain move is at least as disruptive) and always need approval,
|
|
14
|
+
* never auto.
|
|
15
|
+
*/
|
|
16
|
+
export type RequestableField = 'calls' | 'storageLimit' | 'aiLimit' | 'domain' | 'mailTier';
|
|
17
|
+
export declare const REQUESTABLE_FIELDS: readonly RequestableField[];
|
|
18
|
+
/** Turn the org's own calls Matrix stack (`org.ts`'s `CallsConfig.enabled`) on or off. */
|
|
19
|
+
export interface CallsChange {
|
|
20
|
+
field: 'calls';
|
|
21
|
+
enabled: boolean;
|
|
22
|
+
}
|
|
23
|
+
/** Change the org's own spend limit on Files (`org.ts`'s `StorageConfig`). */
|
|
24
|
+
export interface StorageLimitChange {
|
|
25
|
+
field: 'storageLimit';
|
|
26
|
+
limitBytes: string | null;
|
|
27
|
+
limitMicros: string | null;
|
|
28
|
+
}
|
|
29
|
+
/** Change the org's own spend limit on AI (`org.ts`'s `AiConfig`). */
|
|
30
|
+
export interface AiLimitChange {
|
|
31
|
+
field: 'aiLimit';
|
|
32
|
+
limitMicros: string | null;
|
|
33
|
+
}
|
|
34
|
+
/** Change the org's own domain fact (`org.ts`'s `DeclaredOrg.domain`). */
|
|
35
|
+
export interface DomainChange {
|
|
36
|
+
field: 'domain';
|
|
37
|
+
domain: string | null;
|
|
38
|
+
}
|
|
39
|
+
/** Move the org's mail between levels (`org.ts`'s `MailConfig.mode`). */
|
|
40
|
+
export interface MailTierChange {
|
|
41
|
+
field: 'mailTier';
|
|
42
|
+
mode: MailMode;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* One company-requested change to its own declared org, keyed by
|
|
46
|
+
* {@link RequestableField}. `classifyChange` and the store both switch on
|
|
47
|
+
* `.field`.
|
|
48
|
+
*/
|
|
49
|
+
export type RequestedChangeValue = CallsChange | StorageLimitChange | AiLimitChange | DomainChange | MailTierChange;
|
|
50
|
+
/**
|
|
51
|
+
* `auto`: nothing further is required -- archon may carry the request
|
|
52
|
+
* straight into a plan and apply it. `approval`: a human must approve the
|
|
53
|
+
* request before archon applies it.
|
|
54
|
+
*/
|
|
55
|
+
export type ChangeClassification = 'auto' | 'approval';
|
|
56
|
+
/**
|
|
57
|
+
* A stored {@link RequestedChangeValue}'s own lifecycle. `auto` and
|
|
58
|
+
* `needs_approval` are the two states {@link classifyChange} can produce at
|
|
59
|
+
* create time -- never chosen directly; `approved`, `rejected` and
|
|
60
|
+
* `applied` are archon's own workflow carrying a `needs_approval` request
|
|
61
|
+
* onward. Foundry only stores and classifies (#178 "Why"); nothing in this
|
|
62
|
+
* package transitions a state past its initial value.
|
|
63
|
+
*/
|
|
64
|
+
export type RequestedChangeState = 'auto' | 'needs_approval' | 'approved' | 'rejected' | 'applied';
|
|
65
|
+
/**
|
|
66
|
+
* One company-requested change to its own declared org, as stored --
|
|
67
|
+
* `migrations/0017_requested_changes.sql`'s `foundry_requested_changes`
|
|
68
|
+
* row, keyed by `id` and scoped to `orgId`.
|
|
69
|
+
*/
|
|
70
|
+
export interface RequestedChange {
|
|
71
|
+
id: number;
|
|
72
|
+
orgId: OrgId;
|
|
73
|
+
value: RequestedChangeValue;
|
|
74
|
+
requestedBy: string;
|
|
75
|
+
createdAt: string;
|
|
76
|
+
state: RequestedChangeState;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Thrown by {@link classifyChange} (and by the requested-change store's
|
|
80
|
+
* `create`, which calls it) for a `change.field` outside
|
|
81
|
+
* {@link REQUESTABLE_FIELDS} -- same "throw with a reason" shape
|
|
82
|
+
* `store/postgres.ts`'s `DeclaredOrgError` and `PlatformError` use.
|
|
83
|
+
*/
|
|
84
|
+
export declare class RequestedChangeError extends Error {
|
|
85
|
+
constructor(message: string);
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* `auto` when `change` is small and safe (`calls`, `storageLimit`,
|
|
89
|
+
* `aiLimit`), the plan it causes has no destructive or migrating step, and
|
|
90
|
+
* it causes no price change on `org`'s own billing fields; `approval`
|
|
91
|
+
* otherwise -- every `domain` or `mailTier` change classifies `approval`
|
|
92
|
+
* unconditionally, since #178 "Decide" calls both migrating steps that
|
|
93
|
+
* always need approval, whatever direction the change moves.
|
|
94
|
+
*
|
|
95
|
+
* `plan` is the {@link PlanDetail} `change` causes -- the caller's own diff
|
|
96
|
+
* against live reality, from the same `computePlanDetail` (`plan/diff.ts`)
|
|
97
|
+
* every other apply goes through. Any `plannedSteps` entry marked
|
|
98
|
+
* `destroys` or `migrates` classifies `approval`, whatever `change.field`
|
|
99
|
+
* says: a field-name rule only ever knows what a change usually does, not
|
|
100
|
+
* what it does to this org's actual live state today (#184 "Why"). `plan`
|
|
101
|
+
* is optional because not every caller has live state to diff against yet
|
|
102
|
+
* (`store/postgres.ts`'s `create` classifies from `org` alone); omitting it
|
|
103
|
+
* falls back to the field-name rule below, unchanged from #178.
|
|
104
|
+
*
|
|
105
|
+
* Throws {@link RequestedChangeError} for a `change.field` outside
|
|
106
|
+
* {@link REQUESTABLE_FIELDS} -- "everything else is refused" (#178 "How").
|
|
107
|
+
*/
|
|
108
|
+
export declare function classifyChange(org: DeclaredOrg, change: RequestedChangeValue, plan?: PlanDetail): ChangeClassification;
|