@wtfalch/foundry 0.8.0 → 0.10.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/declare.js +1 -5
- package/dist/index.d.ts +3 -3
- package/dist/index.js +2 -2
- package/dist/migrations/0029_hosting_entries.sql +109 -0
- package/dist/migrations/0030_hosting_entry_hostnames.sql +24 -0
- package/dist/org.d.ts +87 -29
- package/dist/org.js +180 -27
- package/dist/plan/agora.d.ts +7 -0
- package/dist/plan/agora.js +9 -0
- package/dist/plan/broker-entries.d.ts +104 -0
- package/dist/plan/broker-entries.js +214 -0
- package/dist/plan/diff.d.ts +2 -0
- package/dist/plan/diff.js +274 -50
- package/dist/plan/executor.d.ts +89 -29
- package/dist/plan/executor.js +495 -233
- package/dist/plan/hostnames.d.ts +24 -0
- package/dist/plan/hostnames.js +56 -0
- package/dist/plan/index.d.ts +3 -1
- package/dist/plan/index.js +2 -1
- package/dist/plan/locked-hostnames.d.ts +8 -0
- package/dist/plan/locked-hostnames.js +26 -0
- package/dist/plan/login-host.d.ts +1 -1
- package/dist/plan/login-host.js +5 -2
- package/dist/plan/plan-apply.d.ts +16 -0
- package/dist/plan/plan-apply.js +48 -11
- package/dist/plan/platform-diff.d.ts +69 -10
- package/dist/plan/platform-diff.js +133 -14
- package/dist/plan/platform-providers.d.ts +102 -2
- package/dist/plan/platform-providers.js +486 -40
- package/dist/plan/zitadel-system-api.d.ts +64 -0
- package/dist/plan/zitadel-system-api.js +125 -0
- package/dist/platform.d.ts +1 -1
- package/dist/store/postgres.js +12 -12
- package/dist/store/schema.d.ts +20 -1
- package/dist/store/schema.js +7 -1
- package/package.json +1 -1
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #283: foundry's way into a brand-new ZITADEL instance. ZITADEL's system API
|
|
3
|
+
* (`POST /system/v1/instances/_create`, permission `system.instance.write`,
|
|
4
|
+
* https://github.com/zitadel/zitadel/blob/main/proto/zitadel/system.proto)
|
|
5
|
+
* creates the instance with a machine owner (IAM_OWNER of that instance) and
|
|
6
|
+
* returns that owner's personal access token (`pat`) once, inline. foundry
|
|
7
|
+
* authenticates to the system API with a short-lived RS256 JWT signed by a
|
|
8
|
+
* keypair it generates: the public half goes into the container as a system
|
|
9
|
+
* API user, the private half stays in memory for one run. The calls follow
|
|
10
|
+
* ZITADEL's source and proto and have not been run against a live instance.
|
|
11
|
+
*/
|
|
12
|
+
/** The system API user's name; it is the JWT's `iss` and `sub`. */
|
|
13
|
+
export declare const SYSTEM_API_USER_NAME = "foundry";
|
|
14
|
+
/** A fresh RSA keypair: the public key (PEM) for ZITADEL, the private key (PKCS#8 PEM) for signing. */
|
|
15
|
+
export declare function generateSystemApiKeypair(): {
|
|
16
|
+
publicKey: string;
|
|
17
|
+
privateKey: string;
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* The value of `ZITADEL_SYSTEMAPIUSERS`: JSON in ZITADEL's `SystemAPIUsers`
|
|
21
|
+
* shape (`cmd/defaults.yaml`), `KeyData` being the base64 of the public key
|
|
22
|
+
* PEM. With no `Memberships` ZITADEL grants exactly System/SYSTEM_OWNER.
|
|
23
|
+
*/
|
|
24
|
+
export declare function systemApiUsersEnv(publicKeyPem: string): string;
|
|
25
|
+
/** A 1-minute RS256 JWT for the system API: `iss` = `sub` = the user name, `aud` = the instance URL. */
|
|
26
|
+
export declare function signSystemApiJwt(privateKeyPem: string, audience: string, nowMs: number): string;
|
|
27
|
+
/**
|
|
28
|
+
* How a create failed. `rejected`: ZITADEL refused it for good before doing
|
|
29
|
+
* anything (bad key, bad request), so nothing exists. `exists`: an instance
|
|
30
|
+
* for the domain already exists. `uncertain`: the request may have reached
|
|
31
|
+
* ZITADEL (timeout, network error, 5xx, an unreadable answer), so an instance
|
|
32
|
+
* may exist. The caller records a marker for the last two.
|
|
33
|
+
*/
|
|
34
|
+
export declare class CreateInstanceError extends Error {
|
|
35
|
+
readonly kind: 'rejected' | 'exists' | 'uncertain';
|
|
36
|
+
constructor(kind: 'rejected' | 'exists' | 'uncertain', message: string);
|
|
37
|
+
}
|
|
38
|
+
/** What a person does when an instance exists without a delivered bearer. Nothing is deleted automatically. */
|
|
39
|
+
export declare function instanceWithoutBearerMessage(hostname: string, detail: string): string;
|
|
40
|
+
export interface CreateInstanceRuntime {
|
|
41
|
+
fetch?: typeof fetch;
|
|
42
|
+
now: () => number;
|
|
43
|
+
/** Fetch timeout for the one create call (default 30s). */
|
|
44
|
+
timeoutMs?: number;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Creates the platform's ZITADEL instance on `customDomain` with one machine
|
|
48
|
+
* owner and returns its id and the owner's PAT. Sends exactly one request,
|
|
49
|
+
* never retries, and signs a fresh 60-second JWT for it. The PAT is returned
|
|
50
|
+
* to the caller only, never logged or put in an error.
|
|
51
|
+
*/
|
|
52
|
+
export declare function createZitadelInstance(input: {
|
|
53
|
+
customDomain: string;
|
|
54
|
+
privateKey: string;
|
|
55
|
+
instanceName: string;
|
|
56
|
+
firstOrgName: string;
|
|
57
|
+
owner: {
|
|
58
|
+
userName: string;
|
|
59
|
+
name: string;
|
|
60
|
+
};
|
|
61
|
+
}, runtime: CreateInstanceRuntime): Promise<{
|
|
62
|
+
instanceId: string;
|
|
63
|
+
pat: string;
|
|
64
|
+
}>;
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { createSign, generateKeyPairSync } from 'node:crypto';
|
|
2
|
+
/**
|
|
3
|
+
* #283: foundry's way into a brand-new ZITADEL instance. ZITADEL's system API
|
|
4
|
+
* (`POST /system/v1/instances/_create`, permission `system.instance.write`,
|
|
5
|
+
* https://github.com/zitadel/zitadel/blob/main/proto/zitadel/system.proto)
|
|
6
|
+
* creates the instance with a machine owner (IAM_OWNER of that instance) and
|
|
7
|
+
* returns that owner's personal access token (`pat`) once, inline. foundry
|
|
8
|
+
* authenticates to the system API with a short-lived RS256 JWT signed by a
|
|
9
|
+
* keypair it generates: the public half goes into the container as a system
|
|
10
|
+
* API user, the private half stays in memory for one run. The calls follow
|
|
11
|
+
* ZITADEL's source and proto and have not been run against a live instance.
|
|
12
|
+
*/
|
|
13
|
+
/** The system API user's name; it is the JWT's `iss` and `sub`. */
|
|
14
|
+
export const SYSTEM_API_USER_NAME = 'foundry';
|
|
15
|
+
/** A fresh RSA keypair: the public key (PEM) for ZITADEL, the private key (PKCS#8 PEM) for signing. */
|
|
16
|
+
export function generateSystemApiKeypair() {
|
|
17
|
+
return generateKeyPairSync('rsa', {
|
|
18
|
+
modulusLength: 2048,
|
|
19
|
+
publicKeyEncoding: { type: 'spki', format: 'pem' },
|
|
20
|
+
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* The value of `ZITADEL_SYSTEMAPIUSERS`: JSON in ZITADEL's `SystemAPIUsers`
|
|
25
|
+
* shape (`cmd/defaults.yaml`), `KeyData` being the base64 of the public key
|
|
26
|
+
* PEM. With no `Memberships` ZITADEL grants exactly System/SYSTEM_OWNER.
|
|
27
|
+
*/
|
|
28
|
+
export function systemApiUsersEnv(publicKeyPem) {
|
|
29
|
+
return JSON.stringify({
|
|
30
|
+
[SYSTEM_API_USER_NAME]: { KeyData: Buffer.from(publicKeyPem).toString('base64') },
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
const b64url = (input) => Buffer.from(input).toString('base64url');
|
|
34
|
+
/** A 1-minute RS256 JWT for the system API: `iss` = `sub` = the user name, `aud` = the instance URL. */
|
|
35
|
+
export function signSystemApiJwt(privateKeyPem, audience, nowMs) {
|
|
36
|
+
const iat = Math.floor(nowMs / 1000);
|
|
37
|
+
const head = b64url(JSON.stringify({ alg: 'RS256', typ: 'JWT' }));
|
|
38
|
+
const body = b64url(JSON.stringify({
|
|
39
|
+
iss: SYSTEM_API_USER_NAME,
|
|
40
|
+
sub: SYSTEM_API_USER_NAME,
|
|
41
|
+
aud: audience,
|
|
42
|
+
iat,
|
|
43
|
+
exp: iat + 60,
|
|
44
|
+
}));
|
|
45
|
+
const signature = createSign('RSA-SHA256').update(`${head}.${body}`).sign(privateKeyPem);
|
|
46
|
+
return `${head}.${body}.${b64url(signature)}`;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* How a create failed. `rejected`: ZITADEL refused it for good before doing
|
|
50
|
+
* anything (bad key, bad request), so nothing exists. `exists`: an instance
|
|
51
|
+
* for the domain already exists. `uncertain`: the request may have reached
|
|
52
|
+
* ZITADEL (timeout, network error, 5xx, an unreadable answer), so an instance
|
|
53
|
+
* may exist. The caller records a marker for the last two.
|
|
54
|
+
*/
|
|
55
|
+
export class CreateInstanceError extends Error {
|
|
56
|
+
kind;
|
|
57
|
+
constructor(kind, message) {
|
|
58
|
+
super(message);
|
|
59
|
+
this.kind = kind;
|
|
60
|
+
this.name = 'CreateInstanceError';
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/** What a person does when an instance exists without a delivered bearer. Nothing is deleted automatically. */
|
|
64
|
+
export function instanceWithoutBearerMessage(hostname, detail) {
|
|
65
|
+
return `platform:auth: a ZITADEL instance may already exist for ${hostname} without a delivered bearer (${detail}). To recover, delete the instance with the system API (DELETE /system/v1/instances/{id}) or redeploy the ZITADEL database, remove the recorded key zitadel_platform_instance_undelivered, then apply again`;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Creates the platform's ZITADEL instance on `customDomain` with one machine
|
|
69
|
+
* owner and returns its id and the owner's PAT. Sends exactly one request,
|
|
70
|
+
* never retries, and signs a fresh 60-second JWT for it. The PAT is returned
|
|
71
|
+
* to the caller only, never logged or put in an error.
|
|
72
|
+
*/
|
|
73
|
+
export async function createZitadelInstance(input, runtime) {
|
|
74
|
+
const fetchImpl = runtime.fetch ?? globalThis.fetch;
|
|
75
|
+
const url = `https://${input.customDomain}`;
|
|
76
|
+
let response;
|
|
77
|
+
try {
|
|
78
|
+
response = await fetchImpl(`${url}/system/v1/instances/_create`, {
|
|
79
|
+
method: 'POST',
|
|
80
|
+
signal: AbortSignal.timeout(runtime.timeoutMs ?? 30_000),
|
|
81
|
+
headers: {
|
|
82
|
+
authorization: `Bearer ${signSystemApiJwt(input.privateKey, url, runtime.now())}`,
|
|
83
|
+
'content-type': 'application/json',
|
|
84
|
+
},
|
|
85
|
+
body: JSON.stringify({
|
|
86
|
+
instanceName: input.instanceName,
|
|
87
|
+
firstOrgName: input.firstOrgName,
|
|
88
|
+
customDomain: input.customDomain,
|
|
89
|
+
machine: {
|
|
90
|
+
userName: input.owner.userName,
|
|
91
|
+
name: input.owner.name,
|
|
92
|
+
personalAccessToken: {
|
|
93
|
+
expirationDate: new Date(runtime.now() + 10 * 365 * 86_400_000).toISOString(),
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
}),
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
throw new CreateInstanceError('uncertain', instanceWithoutBearerMessage(input.customDomain, 'the create request timed out or failed'));
|
|
101
|
+
}
|
|
102
|
+
if (response.status === 401) {
|
|
103
|
+
throw new CreateInstanceError('rejected', 'platform:auth: system API key rejected (HTTP 401); nothing was created');
|
|
104
|
+
}
|
|
105
|
+
if (response.status === 409) {
|
|
106
|
+
throw new CreateInstanceError('exists', instanceWithoutBearerMessage(input.customDomain, 'ZITADEL answered 409, already exists'));
|
|
107
|
+
}
|
|
108
|
+
if (!response.ok) {
|
|
109
|
+
// A 4xx is a refusal before anything was created; a 5xx may not be.
|
|
110
|
+
throw new CreateInstanceError(response.status < 500 ? 'rejected' : 'uncertain', response.status < 500
|
|
111
|
+
? `platform:auth: ZITADEL refused to create the instance (HTTP ${response.status}); nothing was created`
|
|
112
|
+
: instanceWithoutBearerMessage(input.customDomain, `ZITADEL answered HTTP ${response.status}`));
|
|
113
|
+
}
|
|
114
|
+
let body = {};
|
|
115
|
+
try {
|
|
116
|
+
body = (await response.json());
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
// Handled below.
|
|
120
|
+
}
|
|
121
|
+
if (!body.instanceId || !body.pat) {
|
|
122
|
+
throw new CreateInstanceError('uncertain', instanceWithoutBearerMessage(input.customDomain, 'the answer had no instance id or token'));
|
|
123
|
+
}
|
|
124
|
+
return { instanceId: body.instanceId, pat: body.pat };
|
|
125
|
+
}
|
package/dist/platform.d.ts
CHANGED
|
@@ -4,7 +4,7 @@ import type { OrgId } from './org.js';
|
|
|
4
4
|
* {@link DeclaredPlatform}. `enabled` turns the service's own provisioning
|
|
5
5
|
* on; `hostname` is where it is served (default `<service>.<apex>`,
|
|
6
6
|
* {@link defaultPlatformHostname}); `gitRef` is the release it runs, the
|
|
7
|
-
* same "declared, not discovered" shape `
|
|
7
|
+
* same "declared, not discovered" shape `HostingEntry.branch` uses for
|
|
8
8
|
* an org's own app (`org.ts`).
|
|
9
9
|
*/
|
|
10
10
|
export interface PlatformService {
|
package/dist/store/postgres.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { asc, eq, sql } from 'drizzle-orm';
|
|
2
|
-
import { DEFAULT_MAIL_APPS, DEFAULT_NO_REPLY_LOCAL_PART, validateBuyerIdentity, validateDomain, validateHosting, validateMail, validateOwner, validateSite, } from '../org.js';
|
|
2
|
+
import { APP_ENTRY_KEY, DEFAULT_MAIL_APPS, DEFAULT_NO_REPLY_LOCAL_PART, validateBuyerIdentity, validateDomain, validateHosting, validateMail, validateOwner, validateSite, } from '../org.js';
|
|
3
|
+
import { lockedHostnames } from '../plan/locked-hostnames.js';
|
|
3
4
|
import { validatePlatform } from '../platform.js';
|
|
4
5
|
import { RequestedChangeError, classifyChange } from '../requested-changes.js';
|
|
5
6
|
import { validateSlug } from '../slug.js';
|
|
@@ -79,13 +80,13 @@ function rowToOrg(row) {
|
|
|
79
80
|
hosting: {
|
|
80
81
|
enabled: row.hostingEnabled,
|
|
81
82
|
usesDomain: row.hostingUsesDomain,
|
|
82
|
-
database: row.hostingDatabase,
|
|
83
83
|
tunnel: row.hostingTunnel,
|
|
84
|
-
gitBranch: row.hostingGitBranch,
|
|
85
|
-
buildPack: row.hostingBuildPack,
|
|
86
84
|
issuerApps: row.hostingIssuerApps,
|
|
87
|
-
|
|
88
|
-
|
|
85
|
+
// Migration 0030's default for an entry stored before `hostnames`.
|
|
86
|
+
entries: row.hostingEntries.map((entry) => ({
|
|
87
|
+
...entry,
|
|
88
|
+
hostnames: entry.hostnames ?? (entry.key === APP_ENTRY_KEY && row.hostingUsesDomain ? ['@'] : []),
|
|
89
|
+
})),
|
|
89
90
|
},
|
|
90
91
|
sso: { enabled: row.ssoEnabled },
|
|
91
92
|
guests: { enabled: row.guestsEnabled },
|
|
@@ -122,13 +123,9 @@ function orgToRow(org) {
|
|
|
122
123
|
siteUsesDomain: org.site.usesDomain,
|
|
123
124
|
hostingEnabled: org.hosting.enabled,
|
|
124
125
|
hostingUsesDomain: org.hosting.usesDomain,
|
|
125
|
-
hostingDatabase: org.hosting.database,
|
|
126
126
|
hostingTunnel: org.hosting.tunnel,
|
|
127
|
-
hostingGitBranch: org.hosting.gitBranch,
|
|
128
|
-
hostingBuildPack: org.hosting.buildPack,
|
|
129
127
|
hostingIssuerApps: org.hosting.issuerApps,
|
|
130
|
-
|
|
131
|
-
hostingAppEnv: org.hosting.appEnv ?? {},
|
|
128
|
+
hostingEntries: org.hosting.entries,
|
|
132
129
|
ssoEnabled: org.sso.enabled,
|
|
133
130
|
guestsEnabled: org.guests.enabled,
|
|
134
131
|
soloEnabled: org.solo.enabled,
|
|
@@ -257,7 +254,10 @@ export function createPostgresDeclaredOrgStore(db) {
|
|
|
257
254
|
// #71: same place `requireValidBuyer` checks the buyer identity (#67), on
|
|
258
255
|
// both `create` and the merged result of `update`.
|
|
259
256
|
function requireValidHosting(org) {
|
|
260
|
-
const reason = validateHosting(org.hosting
|
|
257
|
+
const reason = validateHosting(org.hosting, {
|
|
258
|
+
domain: org.domain,
|
|
259
|
+
locked: lockedHostnames(org, []),
|
|
260
|
+
});
|
|
261
261
|
if (reason)
|
|
262
262
|
throw new DeclaredOrgError(`hosting invalid: ${reason} (#71)`);
|
|
263
263
|
}
|
package/dist/store/schema.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AppEnvSource, IssuerApp } from '../org.js';
|
|
1
|
+
import type { AppEnvSource, HostingEntry, IssuerApp } from '../org.js';
|
|
2
2
|
import type { Plan } from '../plan.js';
|
|
3
3
|
import type { Checkpoint } from '../state.js';
|
|
4
4
|
/**
|
|
@@ -463,6 +463,25 @@ export declare const declaredOrgs: import("drizzle-orm/pg-core").PgTableWithColu
|
|
|
463
463
|
}, {}, {
|
|
464
464
|
$type: Record<string, AppEnvSource>;
|
|
465
465
|
}>;
|
|
466
|
+
hostingEntries: import("drizzle-orm/pg-core").PgColumn<{
|
|
467
|
+
name: "hosting_entries";
|
|
468
|
+
tableName: "foundry_declared_orgs";
|
|
469
|
+
dataType: "json";
|
|
470
|
+
columnType: "PgJsonb";
|
|
471
|
+
data: HostingEntry[];
|
|
472
|
+
driverParam: unknown;
|
|
473
|
+
notNull: true;
|
|
474
|
+
hasDefault: true;
|
|
475
|
+
isPrimaryKey: false;
|
|
476
|
+
isAutoincrement: false;
|
|
477
|
+
hasRuntimeDefault: false;
|
|
478
|
+
enumValues: undefined;
|
|
479
|
+
baseColumn: never;
|
|
480
|
+
identity: undefined;
|
|
481
|
+
generated: undefined;
|
|
482
|
+
}, {}, {
|
|
483
|
+
$type: HostingEntry[];
|
|
484
|
+
}>;
|
|
466
485
|
ssoEnabled: import("drizzle-orm/pg-core").PgColumn<{
|
|
467
486
|
name: "sso_enabled";
|
|
468
487
|
tableName: "foundry_declared_orgs";
|
package/dist/store/schema.js
CHANGED
|
@@ -43,11 +43,17 @@ export const declaredOrgs = pgTable('foundry_declared_orgs', {
|
|
|
43
43
|
hostingIssuerApps: jsonb('hosting_issuer_apps').notNull().default([]).$type(),
|
|
44
44
|
hostingAccessEmails: jsonb('hosting_access_emails').notNull().default([]).$type(),
|
|
45
45
|
// HostingConfig.appEnv (org.ts, #243). Maps the app's own env var names to
|
|
46
|
-
// values the plan already mints; empty by default.
|
|
46
|
+
// values the plan already mints; empty by default. Read only by migration
|
|
47
|
+
// 0029, which copies it into `hostingEntries`.
|
|
47
48
|
hostingAppEnv: jsonb('hosting_app_env')
|
|
48
49
|
.notNull()
|
|
49
50
|
.default({})
|
|
50
51
|
.$type(),
|
|
52
|
+
// HostingConfig.entries (org.ts): one entry per app. Replaces the flat
|
|
53
|
+
// hosting_database, hosting_git_branch, hosting_build_pack,
|
|
54
|
+
// hosting_access_emails and hosting_app_env columns, which stay for an older
|
|
55
|
+
// reader and are no longer written.
|
|
56
|
+
hostingEntries: jsonb('hosting_entries').notNull().default([]).$type(),
|
|
51
57
|
ssoEnabled: boolean('sso_enabled').notNull().default(false),
|
|
52
58
|
guestsEnabled: boolean('guests_enabled').notNull().default(false),
|
|
53
59
|
soloEnabled: boolean('solo_enabled').notNull().default(false),
|