@wtfalch/foundry 0.3.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.
Files changed (71) hide show
  1. package/dist/bootstrap/bootstrap.d.ts +96 -0
  2. package/dist/bootstrap/bootstrap.js +285 -0
  3. package/dist/bootstrap/declare.d.ts +36 -0
  4. package/dist/bootstrap/declare.js +94 -0
  5. package/dist/bootstrap/drive.d.ts +40 -0
  6. package/dist/bootstrap/drive.js +51 -0
  7. package/dist/bootstrap/index.d.ts +3 -0
  8. package/dist/bootstrap/index.js +1 -0
  9. package/dist/calls-sfu-deploy-support.d.ts +9 -6
  10. package/dist/calls-sfu-deploy-support.js +9 -6
  11. package/dist/calls-sfu-keys.d.ts +21 -0
  12. package/dist/calls-sfu-keys.js +27 -0
  13. package/dist/calls-sfu-ssh-adapter.d.ts +55 -0
  14. package/dist/calls-sfu-ssh-adapter.js +78 -0
  15. package/dist/calls-sfu-ssh-client.d.ts +49 -0
  16. package/dist/calls-sfu-ssh-client.js +82 -0
  17. package/dist/calls-sfu.d.ts +22 -4
  18. package/dist/calls-sfu.js +22 -4
  19. package/dist/index.d.ts +31 -6
  20. package/dist/index.js +16 -3
  21. package/dist/migrations/0014_calls_enabled.sql +7 -0
  22. package/dist/migrations/0015_domain_unique.sql +39 -0
  23. package/dist/migrations/0016_platform.sql +61 -0
  24. package/dist/migrations/0017_requested_changes.sql +42 -0
  25. package/dist/migrations/0018_platform_apply.sql +19 -0
  26. package/dist/org.d.ts +18 -2
  27. package/dist/org.js +56 -0
  28. package/dist/plan/agora.d.ts +77 -0
  29. package/dist/plan/agora.js +127 -0
  30. package/dist/plan/calls-matrix.d.ts +135 -0
  31. package/dist/plan/calls-matrix.js +275 -0
  32. package/dist/plan/cloudflared-connector.d.ts +70 -0
  33. package/dist/plan/cloudflared-connector.js +106 -0
  34. package/dist/plan/diff.d.ts +26 -5
  35. package/dist/plan/diff.js +720 -106
  36. package/dist/plan/executor.d.ts +731 -36
  37. package/dist/plan/executor.js +1180 -41
  38. package/dist/plan/index.d.ts +22 -5
  39. package/dist/plan/index.js +12 -2
  40. package/dist/plan/jobs-scheduler-http.d.ts +29 -0
  41. package/dist/plan/jobs-scheduler-http.js +50 -0
  42. package/dist/plan/live.d.ts +51 -2
  43. package/dist/plan/live.js +8 -0
  44. package/dist/plan/plan-apply.d.ts +192 -3
  45. package/dist/plan/plan-apply.js +290 -14
  46. package/dist/plan/platform-diff.d.ts +312 -0
  47. package/dist/plan/platform-diff.js +867 -0
  48. package/dist/plan/platform-org-ports.d.ts +50 -0
  49. package/dist/plan/platform-org-ports.js +70 -0
  50. package/dist/plan/platform-org.d.ts +120 -0
  51. package/dist/plan/platform-org.js +115 -0
  52. package/dist/plan/platform-providers.d.ts +501 -0
  53. package/dist/plan/platform-providers.js +3029 -0
  54. package/dist/plan/platform-sfu.d.ts +93 -0
  55. package/dist/plan/platform-sfu.js +100 -0
  56. package/dist/plan/providers-readers.js +22 -1
  57. package/dist/plan/zitadel-machine-user.d.ts +34 -0
  58. package/dist/plan/zitadel-machine-user.js +95 -0
  59. package/dist/plan.d.ts +6 -0
  60. package/dist/platform.d.ts +55 -0
  61. package/dist/platform.js +46 -0
  62. package/dist/requested-changes.d.ts +108 -0
  63. package/dist/requested-changes.js +95 -0
  64. package/dist/slug.js +21 -2
  65. package/dist/store/audit.js +4 -2
  66. package/dist/store/postgres.d.ts +40 -7
  67. package/dist/store/postgres.js +257 -29
  68. package/dist/store/schema.d.ts +700 -0
  69. package/dist/store/schema.js +58 -0
  70. package/dist/store.d.ts +56 -0
  71. package/package.json +2 -2
@@ -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
- return applyRecordedIds(withLogConfig, recorded);
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;
@@ -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;