@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.
@@ -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
+ }
@@ -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 `HostingConfig.gitBranch` uses for
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 {
@@ -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
- accessEmails: row.hostingAccessEmails,
88
- appEnv: row.hostingAppEnv,
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
- hostingAccessEmails: org.hosting.accessEmails,
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
  }
@@ -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";
@@ -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),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/foundry",
3
- "version": "0.8.0",
3
+ "version": "0.10.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",