@wtfalch/foundry 0.43.0 → 0.44.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/index.d.ts +3 -3
- package/dist/index.js +1 -1
- package/dist/migrations/0057_org_customers.sql +29 -0
- package/dist/org.d.ts +42 -0
- package/dist/org.js +52 -26
- package/dist/plan/boule.d.ts +43 -2
- package/dist/plan/boule.js +48 -1
- package/dist/plan/broker-entries.d.ts +28 -0
- package/dist/plan/broker-entries.js +46 -1
- package/dist/plan/diff.d.ts +14 -0
- package/dist/plan/diff.js +176 -18
- package/dist/plan/executor.d.ts +100 -2
- package/dist/plan/executor.js +273 -16
- package/dist/plan/hostnames.d.ts +7 -0
- package/dist/plan/hostnames.js +17 -2
- package/dist/plan/locked-hostnames.d.ts +11 -0
- package/dist/plan/locked-hostnames.js +13 -0
- package/dist/plan/mcp.d.ts +57 -0
- package/dist/plan/mcp.js +131 -0
- package/dist/plan/org-names.d.ts +2 -2
- package/dist/plan/org-names.js +16 -1
- package/dist/plan/plan-apply.d.ts +26 -0
- package/dist/plan/plan-apply.js +45 -5
- package/dist/plan/proxy-labels.js +2 -0
- package/dist/plan/worker-app-database.d.ts +148 -0
- package/dist/plan/worker-app-database.js +424 -0
- package/dist/plan/worker-app.d.ts +320 -0
- package/dist/plan/worker-app.js +792 -0
- package/dist/plan/zitadel-machine-user.d.ts +13 -1
- package/dist/plan/zitadel-machine-user.js +80 -0
- package/dist/requested-changes.d.ts +12 -3
- package/dist/requested-changes.js +22 -0
- package/dist/store/postgres.js +12 -6
- package/dist/store/schema.d.ts +34 -0
- package/dist/store/schema.js +3 -0
- package/dist/worker-app-config.d.ts +151 -0
- package/dist/worker-app-config.js +438 -0
- package/package.json +1 -1
|
@@ -76,6 +76,18 @@ export declare class ZitadelLoginClientRoleConflictError extends Error {
|
|
|
76
76
|
readonly roles: readonly string[];
|
|
77
77
|
constructor(userId: string, roles: readonly string[]);
|
|
78
78
|
}
|
|
79
|
+
/** The read-only organisation role a directory reader holds. It lists the organisation's users and changes nothing. */
|
|
80
|
+
export declare const ORG_DIRECTORY_READER_ROLE = "ORG_OWNER_VIEWER";
|
|
81
|
+
/**
|
|
82
|
+
* C3: thrown by `mintOrgDirectoryReader` when the user already exists with an organisation role other
|
|
83
|
+
* than the read-only one. That user was not made by foundry; granting it more or handing out its PAT
|
|
84
|
+
* would widen access nobody asked for. A person resolves it by hand.
|
|
85
|
+
*/
|
|
86
|
+
export declare class ZitadelDirectoryRoleConflictError extends Error {
|
|
87
|
+
readonly userId: string;
|
|
88
|
+
readonly roles: readonly string[];
|
|
89
|
+
constructor(userId: string, roles: readonly string[]);
|
|
90
|
+
}
|
|
79
91
|
export interface ZitadelMinterOptions {
|
|
80
92
|
/** The new instance's base URL, e.g. `https://auth.companyincluded.com`. */
|
|
81
93
|
url: string;
|
|
@@ -84,4 +96,4 @@ export interface ZitadelMinterOptions {
|
|
|
84
96
|
fetchImpl?: typeof fetch;
|
|
85
97
|
instanceHost?: string;
|
|
86
98
|
}
|
|
87
|
-
export declare function createZitadelAdminMinter(options: ZitadelMinterOptions): Pick<ZitadelPort, 'mintAdminMachineUser' | 'mintLoginClientMachineUser' | 'listLoginClientPats' | 'reserveUserName'>;
|
|
99
|
+
export declare function createZitadelAdminMinter(options: ZitadelMinterOptions): Pick<ZitadelPort, 'mintAdminMachineUser' | 'mintLoginClientMachineUser' | 'listLoginClientPats' | 'reserveUserName' | 'mintOrgDirectoryReader' | 'revokeOrgDirectoryReader'>;
|
|
@@ -84,6 +84,23 @@ export class ZitadelLoginClientRoleConflictError extends Error {
|
|
|
84
84
|
this.name = 'ZitadelLoginClientRoleConflictError';
|
|
85
85
|
}
|
|
86
86
|
}
|
|
87
|
+
/** The read-only organisation role a directory reader holds. It lists the organisation's users and changes nothing. */
|
|
88
|
+
export const ORG_DIRECTORY_READER_ROLE = 'ORG_OWNER_VIEWER';
|
|
89
|
+
/**
|
|
90
|
+
* C3: thrown by `mintOrgDirectoryReader` when the user already exists with an organisation role other
|
|
91
|
+
* than the read-only one. That user was not made by foundry; granting it more or handing out its PAT
|
|
92
|
+
* would widen access nobody asked for. A person resolves it by hand.
|
|
93
|
+
*/
|
|
94
|
+
export class ZitadelDirectoryRoleConflictError extends Error {
|
|
95
|
+
userId;
|
|
96
|
+
roles;
|
|
97
|
+
constructor(userId, roles) {
|
|
98
|
+
super(`ZITADEL machine user ${userId} already exists with organisation role(s) [${roles.join(', ') || 'none'}], not only [${ORG_DIRECTORY_READER_ROLE}] -- refusing to reuse it as a directory reader`);
|
|
99
|
+
this.userId = userId;
|
|
100
|
+
this.roles = roles;
|
|
101
|
+
this.name = 'ZitadelDirectoryRoleConflictError';
|
|
102
|
+
}
|
|
103
|
+
}
|
|
87
104
|
export function createZitadelAdminMinter(options) {
|
|
88
105
|
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
89
106
|
async function call(method, path, body, orgId) {
|
|
@@ -263,7 +280,70 @@ export function createZitadelAdminMinter(options) {
|
|
|
263
280
|
}
|
|
264
281
|
return { userId };
|
|
265
282
|
}
|
|
283
|
+
/**
|
|
284
|
+
* C3: a machine user inside one ZITADEL organisation with the read-only org role
|
|
285
|
+
* {@link ORG_DIRECTORY_READER_ROLE} and nothing else, plus a fresh PAT. Every PAT it already holds is
|
|
286
|
+
* revoked first, so a rerun leaves exactly one live token. An existing user that holds any other
|
|
287
|
+
* org role is refused, not reused. The calls follow ZITADEL's published v1 management API and have
|
|
288
|
+
* not been run against a live instance.
|
|
289
|
+
*/
|
|
290
|
+
async function mintOrgDirectoryReader(orgId, input) {
|
|
291
|
+
const found = await call('POST', '/management/v1/users/_search', {
|
|
292
|
+
queries: [
|
|
293
|
+
{ userNameQuery: { userName: input.userName, method: 'TEXT_QUERY_METHOD_EQUALS' } },
|
|
294
|
+
],
|
|
295
|
+
}, orgId);
|
|
296
|
+
const existing = found.result?.[0]?.id;
|
|
297
|
+
let userId = existing;
|
|
298
|
+
if (!userId) {
|
|
299
|
+
const created = await call('POST', '/management/v1/users/machine', {
|
|
300
|
+
userName: input.userName,
|
|
301
|
+
name: input.name,
|
|
302
|
+
accessTokenType: 'ACCESS_TOKEN_TYPE_BEARER',
|
|
303
|
+
}, orgId);
|
|
304
|
+
userId = typeof created.userId === 'string' ? created.userId : undefined;
|
|
305
|
+
}
|
|
306
|
+
if (!userId)
|
|
307
|
+
throw new Error('ZITADEL did not return the machine user id');
|
|
308
|
+
if (existing) {
|
|
309
|
+
const members = await call('POST', '/management/v1/orgs/me/members/_search', { queries: [{ userIdQuery: { userId } }] }, orgId);
|
|
310
|
+
const roles = (members.result ?? [])
|
|
311
|
+
.filter((m) => m.userId === userId)
|
|
312
|
+
.flatMap((m) => m.roles ?? []);
|
|
313
|
+
if (roles.some((role) => role !== ORG_DIRECTORY_READER_ROLE)) {
|
|
314
|
+
throw new ZitadelDirectoryRoleConflictError(userId, roles);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
try {
|
|
318
|
+
await call('POST', '/management/v1/orgs/me/members', { userId, roles: [ORG_DIRECTORY_READER_ROLE] }, orgId);
|
|
319
|
+
}
|
|
320
|
+
catch (e) {
|
|
321
|
+
if (!(e instanceof ZitadelHttpError && e.status === 409))
|
|
322
|
+
throw e;
|
|
323
|
+
}
|
|
324
|
+
await revokeAllPats(orgId, userId);
|
|
325
|
+
const pat = await call('POST', `/management/v1/users/${userId}/pats`, {}, orgId);
|
|
326
|
+
const patId = typeof pat.tokenId === 'string' && pat.tokenId !== '' ? pat.tokenId : undefined;
|
|
327
|
+
if (typeof pat.token !== 'string' || pat.token === '' || !patId) {
|
|
328
|
+
throw new Error(`ZITADEL returned no token for machine user ${userId}`);
|
|
329
|
+
}
|
|
330
|
+
return { userId, token: pat.token, patId };
|
|
331
|
+
}
|
|
332
|
+
/** Deletes every PAT of a user; a PAT already gone (404) is the outcome wanted. */
|
|
333
|
+
async function revokeAllPats(orgId, userId) {
|
|
334
|
+
for (const patId of await listPats(orgId, userId)) {
|
|
335
|
+
try {
|
|
336
|
+
await call('DELETE', `/management/v1/users/${userId}/pats/${patId}`, undefined, orgId);
|
|
337
|
+
}
|
|
338
|
+
catch (e) {
|
|
339
|
+
if (!(e instanceof ZitadelHttpError && e.status === 404))
|
|
340
|
+
throw e;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
266
344
|
return {
|
|
345
|
+
mintOrgDirectoryReader,
|
|
346
|
+
revokeOrgDirectoryReader: revokeAllPats,
|
|
267
347
|
mintAdminMachineUser: (orgId, input) => mintMachineUser(orgId, input, 'IAM_OWNER').then(({ userId, token }) => ({ userId, token })),
|
|
268
348
|
mintLoginClientMachineUser: (orgId, input) => mintMachineUser(orgId, input, 'IAM_LOGIN_CLIENT'),
|
|
269
349
|
// #216 round 2: lets the caller (`platform-providers.ts`) re-check, after
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type DeclaredOrg, type MailMode, type OrgId } from './org.js';
|
|
2
2
|
import type { PlanDetail } from './plan/diff.js';
|
|
3
3
|
/**
|
|
4
4
|
* The whitelist of `DeclaredOrg` fields a company's own Boule can request a
|
|
@@ -15,13 +15,22 @@ import type { PlanDetail } from './plan/diff.js';
|
|
|
15
15
|
* lowering or leaving a limit unchanged -- raising either one needs
|
|
16
16
|
* approval too, until a billing field exists to say what the raise costs.
|
|
17
17
|
*/
|
|
18
|
-
export type RequestableField = 'calls' | 'storageLimit' | 'aiLimit' | 'domain' | 'mailTier';
|
|
18
|
+
export type RequestableField = 'calls' | 'customers' | 'storageLimit' | 'aiLimit' | 'domain' | 'mailTier';
|
|
19
19
|
export declare const REQUESTABLE_FIELDS: readonly RequestableField[];
|
|
20
20
|
/** Turn the org's own calls Matrix stack (`org.ts`'s `CallsConfig.enabled`) on or off. */
|
|
21
21
|
export interface CallsChange {
|
|
22
22
|
field: 'calls';
|
|
23
23
|
enabled: boolean;
|
|
24
24
|
}
|
|
25
|
+
/**
|
|
26
|
+
* Turn the org's customers on or off and let them register themselves
|
|
27
|
+
* (`org.ts`'s `CustomersConfig`). `signUp` needs `enabled`: the request is refused otherwise.
|
|
28
|
+
*/
|
|
29
|
+
export interface CustomersChange {
|
|
30
|
+
field: 'customers';
|
|
31
|
+
enabled: boolean;
|
|
32
|
+
signUp: boolean;
|
|
33
|
+
}
|
|
25
34
|
/** Change the org's own spend limit on Files (`org.ts`'s `StorageConfig`). */
|
|
26
35
|
export interface StorageLimitChange {
|
|
27
36
|
field: 'storageLimit';
|
|
@@ -48,7 +57,7 @@ export interface MailTierChange {
|
|
|
48
57
|
* {@link RequestableField}. `classifyChange` and the store both switch on
|
|
49
58
|
* `.field`.
|
|
50
59
|
*/
|
|
51
|
-
export type RequestedChangeValue = CallsChange | StorageLimitChange | AiLimitChange | DomainChange | MailTierChange;
|
|
60
|
+
export type RequestedChangeValue = CallsChange | CustomersChange | StorageLimitChange | AiLimitChange | DomainChange | MailTierChange;
|
|
52
61
|
/**
|
|
53
62
|
* `auto`: nothing further is required -- archon may carry the request
|
|
54
63
|
* straight into a plan and apply it. `approval`: a human must approve the
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { validateCustomers } from './org.js';
|
|
1
2
|
export const REQUESTABLE_FIELDS = [
|
|
2
3
|
'calls',
|
|
4
|
+
'customers',
|
|
3
5
|
'storageLimit',
|
|
4
6
|
'aiLimit',
|
|
5
7
|
'domain',
|
|
@@ -27,6 +29,8 @@ function applyRequestedChange(org, change) {
|
|
|
27
29
|
switch (change.field) {
|
|
28
30
|
case 'calls':
|
|
29
31
|
return { ...org, calls: { ...org.calls, enabled: change.enabled } };
|
|
32
|
+
case 'customers':
|
|
33
|
+
return { ...org, customers: { enabled: change.enabled, signUp: change.signUp } };
|
|
30
34
|
case 'storageLimit':
|
|
31
35
|
return {
|
|
32
36
|
...org,
|
|
@@ -69,6 +73,15 @@ function isLimitRaise(before, after) {
|
|
|
69
73
|
return true;
|
|
70
74
|
return BigInt(after) > BigInt(before);
|
|
71
75
|
}
|
|
76
|
+
/** Throws {@link RequestedChangeError} unless both switches are booleans and sign-up comes with customers on. */
|
|
77
|
+
function validateCustomersChange(change) {
|
|
78
|
+
if (typeof change.enabled !== 'boolean' || typeof change.signUp !== 'boolean') {
|
|
79
|
+
throw new RequestedChangeError('customers needs enabled and signUp, both true or false');
|
|
80
|
+
}
|
|
81
|
+
if (change.signUp && !change.enabled) {
|
|
82
|
+
throw new RequestedChangeError('customers.signUp needs customers.enabled');
|
|
83
|
+
}
|
|
84
|
+
}
|
|
72
85
|
/**
|
|
73
86
|
* `auto` when `change` is small and safe (`calls`, and `storageLimit`/
|
|
74
87
|
* `aiLimit` in the lowering or unchanged direction), the plan it causes has
|
|
@@ -102,6 +115,14 @@ export function classifyChange(org, change, plan) {
|
|
|
102
115
|
if (!REQUESTABLE_FIELDS.includes(change.field)) {
|
|
103
116
|
throw new RequestedChangeError(`'${change.field}' is not a requestable field`);
|
|
104
117
|
}
|
|
118
|
+
if (change.field === 'customers') {
|
|
119
|
+
validateCustomersChange(change);
|
|
120
|
+
// The org after the change must still pass the rule apply checks, for example open registration
|
|
121
|
+
// on a hosting entry needs customers on; otherwise the request is stored and fails at apply.
|
|
122
|
+
const invalid = validateCustomers(applyRequestedChange(org, change));
|
|
123
|
+
if (invalid)
|
|
124
|
+
throw new RequestedChangeError(invalid);
|
|
125
|
+
}
|
|
105
126
|
if (plan?.plannedSteps.some((planned) => planned.step.destroys || planned.step.migrates)) {
|
|
106
127
|
return 'approval';
|
|
107
128
|
}
|
|
@@ -110,6 +131,7 @@ export function classifyChange(org, change, plan) {
|
|
|
110
131
|
return 'approval';
|
|
111
132
|
switch (change.field) {
|
|
112
133
|
case 'calls':
|
|
134
|
+
case 'customers':
|
|
113
135
|
return 'auto';
|
|
114
136
|
case 'storageLimit':
|
|
115
137
|
return isLimitRaise(org.storage.limitBytes, change.limitBytes) ||
|
package/dist/store/postgres.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { and, asc, desc, eq, inArray, isNull, ne, sql } from 'drizzle-orm';
|
|
2
|
-
import { APP_ENTRY_KEY, DEFAULT_MAIL_APPS, DEFAULT_NO_REPLY_LOCAL_PART, validateBlueGreen, validateBuyerIdentity, validateCalls, validateDomain, validateDomainClaim, validateHosting, validateMail, validateOwners, validateServices, validateSignIn, validateSite, validateVersionPin, } from '../org.js';
|
|
3
|
-
import {
|
|
2
|
+
import { APP_ENTRY_KEY, DEFAULT_MAIL_APPS, DEFAULT_NO_REPLY_LOCAL_PART, validateBlueGreen, validateBuyerIdentity, validateCalls, validateCustomers, validateDomain, validateDomainClaim, validateHosting, validateMail, validateOwners, validateServices, validateSignIn, validateSite, validateVersionPin, } from '../org.js';
|
|
3
|
+
import { hostingNames } from '../plan/locked-hostnames.js';
|
|
4
4
|
import { findNameClashes, nameClashMessage } from '../plan/org-names.js';
|
|
5
5
|
import { validatePlatform } from '../platform.js';
|
|
6
6
|
import { releaseActionSchema } from '../release/state.js';
|
|
@@ -149,6 +149,10 @@ function rowToOrg(row) {
|
|
|
149
149
|
: {}),
|
|
150
150
|
// Omitted while off.
|
|
151
151
|
...(row.signInInApp ? { signIn: { inApp: true } } : {}),
|
|
152
|
+
// Omitted while off.
|
|
153
|
+
...(row.customersEnabled || row.customersSignUp
|
|
154
|
+
? { customers: { enabled: row.customersEnabled, signUp: row.customersSignUp } }
|
|
155
|
+
: {}),
|
|
152
156
|
// Omitted while unset. Validated on read: a stored value is not trusted.
|
|
153
157
|
...(row.versionPin ? { versionPin: readVersionPin(row) } : {}),
|
|
154
158
|
site: { enabled: row.siteEnabled, usesDomain: row.siteUsesDomain },
|
|
@@ -202,6 +206,8 @@ function orgToRow(org) {
|
|
|
202
206
|
callsVersionPinMas: org.calls.versionPin?.mas ?? null,
|
|
203
207
|
issuerUsesDomain: org.issuer.usesDomain,
|
|
204
208
|
signInInApp: org.signIn?.inApp ?? false,
|
|
209
|
+
customersEnabled: org.customers?.enabled ?? false,
|
|
210
|
+
customersSignUp: org.customers?.signUp ?? false,
|
|
205
211
|
servicesOwn: org.services?.own ?? false,
|
|
206
212
|
servicesSfuSlot: org.services?.sfuSlot ?? null,
|
|
207
213
|
versionPin: org.versionPin && Object.keys(org.versionPin).length > 0 ? org.versionPin : null,
|
|
@@ -351,10 +357,7 @@ export function createPostgresDeclaredOrgStore(db) {
|
|
|
351
357
|
// #71: same place `requireValidBuyer` checks the buyer identity (#67), on
|
|
352
358
|
// both `create` and the merged result of `update`.
|
|
353
359
|
function requireValidHosting(org) {
|
|
354
|
-
const reason = validateHosting(org.hosting,
|
|
355
|
-
domain: org.domain,
|
|
356
|
-
locked: lockedHostnames(org, []),
|
|
357
|
-
});
|
|
360
|
+
const reason = validateHosting(org.hosting, hostingNames(org, []));
|
|
358
361
|
if (reason)
|
|
359
362
|
throw new DeclaredOrgError(`hosting invalid: ${reason} (#71)`);
|
|
360
363
|
}
|
|
@@ -408,6 +411,9 @@ export function createPostgresDeclaredOrgStore(db) {
|
|
|
408
411
|
const signInReason = validateSignIn(org);
|
|
409
412
|
if (signInReason)
|
|
410
413
|
throw new DeclaredOrgError(`signIn invalid: ${signInReason}`);
|
|
414
|
+
const customersReason = validateCustomers(org);
|
|
415
|
+
if (customersReason)
|
|
416
|
+
throw new DeclaredOrgError(`customers invalid: ${customersReason}`);
|
|
411
417
|
const slot = org.services?.sfuSlot ?? null;
|
|
412
418
|
if (slot === null)
|
|
413
419
|
return;
|
package/dist/store/schema.d.ts
CHANGED
|
@@ -372,6 +372,40 @@ export declare const declaredOrgs: import("drizzle-orm/pg-core").PgTableWithColu
|
|
|
372
372
|
identity: undefined;
|
|
373
373
|
generated: undefined;
|
|
374
374
|
}, {}, {}>;
|
|
375
|
+
customersEnabled: import("drizzle-orm/pg-core").PgColumn<{
|
|
376
|
+
name: "customers_enabled";
|
|
377
|
+
tableName: "foundry_declared_orgs";
|
|
378
|
+
dataType: "boolean";
|
|
379
|
+
columnType: "PgBoolean";
|
|
380
|
+
data: boolean;
|
|
381
|
+
driverParam: boolean;
|
|
382
|
+
notNull: true;
|
|
383
|
+
hasDefault: true;
|
|
384
|
+
isPrimaryKey: false;
|
|
385
|
+
isAutoincrement: false;
|
|
386
|
+
hasRuntimeDefault: false;
|
|
387
|
+
enumValues: undefined;
|
|
388
|
+
baseColumn: never;
|
|
389
|
+
identity: undefined;
|
|
390
|
+
generated: undefined;
|
|
391
|
+
}, {}, {}>;
|
|
392
|
+
customersSignUp: import("drizzle-orm/pg-core").PgColumn<{
|
|
393
|
+
name: "customers_sign_up";
|
|
394
|
+
tableName: "foundry_declared_orgs";
|
|
395
|
+
dataType: "boolean";
|
|
396
|
+
columnType: "PgBoolean";
|
|
397
|
+
data: boolean;
|
|
398
|
+
driverParam: boolean;
|
|
399
|
+
notNull: true;
|
|
400
|
+
hasDefault: true;
|
|
401
|
+
isPrimaryKey: false;
|
|
402
|
+
isAutoincrement: false;
|
|
403
|
+
hasRuntimeDefault: false;
|
|
404
|
+
enumValues: undefined;
|
|
405
|
+
baseColumn: never;
|
|
406
|
+
identity: undefined;
|
|
407
|
+
generated: undefined;
|
|
408
|
+
}, {}, {}>;
|
|
375
409
|
versionPin: import("drizzle-orm/pg-core").PgColumn<{
|
|
376
410
|
name: "version_pin";
|
|
377
411
|
tableName: "foundry_declared_orgs";
|
package/dist/store/schema.js
CHANGED
|
@@ -40,6 +40,9 @@ export const declaredOrgs = pgTable('foundry_declared_orgs', {
|
|
|
40
40
|
servicesSfuSlot: integer('services_sfu_slot'),
|
|
41
41
|
// SignInConfig.inApp (org.ts); migrations/0054.
|
|
42
42
|
signInInApp: boolean('signin_in_app').notNull().default(false),
|
|
43
|
+
// CustomersConfig (org.ts); migrations/0057.
|
|
44
|
+
customersEnabled: boolean('customers_enabled').notNull().default(false),
|
|
45
|
+
customersSignUp: boolean('customers_sign_up').notNull().default(false),
|
|
43
46
|
// VersionPin (org.ts): null follows the platform. `migrations/0053_org_version_pin.sql`.
|
|
44
47
|
versionPin: jsonb('version_pin').$type(),
|
|
45
48
|
// SiteConfig (org.ts, T10). A brand new org has no cms site.
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import type { HostingConfig, HostingEntry } from './org.js';
|
|
2
|
+
/**
|
|
3
|
+
* A hosting entry that runs as a Cloudflare Worker with Durable Objects and,
|
|
4
|
+
* optionally, Containers (foundry ADR 0002, `docs/adr/0002-worker-app-hosting-option.md`).
|
|
5
|
+
* The entry has no Coolify app, no volumes and no gate. The org's repo CI builds
|
|
6
|
+
* the module and the image; foundry fetches the module, checks its sha256 and
|
|
7
|
+
* uploads it, and never builds or checks out source.
|
|
8
|
+
*/
|
|
9
|
+
export interface WorkerAppConfig {
|
|
10
|
+
/** Built module (https URL) and its sha256, as in `WorkerConfig`. */
|
|
11
|
+
artifactUrl: string;
|
|
12
|
+
artifactSha256: string;
|
|
13
|
+
/** Defaults to `WORKER_COMPATIBILITY_DATE`. Flags come from an allowlist in foundry. */
|
|
14
|
+
compatibilityDate?: string;
|
|
15
|
+
compatibilityFlags?: 'nodejs_compat'[];
|
|
16
|
+
durableObjects: {
|
|
17
|
+
/** Binding name -> exported class. */
|
|
18
|
+
classes: Record<string, string>;
|
|
19
|
+
/** Append-only. Foundry sends only tags not yet applied. */
|
|
20
|
+
migrations: {
|
|
21
|
+
tag: string;
|
|
22
|
+
newSqliteClasses?: string[];
|
|
23
|
+
deletedClasses?: string[];
|
|
24
|
+
renamedClasses?: {
|
|
25
|
+
from: string;
|
|
26
|
+
to: string;
|
|
27
|
+
}[];
|
|
28
|
+
}[];
|
|
29
|
+
};
|
|
30
|
+
containers?: {
|
|
31
|
+
/** Must be a durable object class above. */
|
|
32
|
+
className: string;
|
|
33
|
+
/** Image pushed by the app's CI to the platform registry, pinned by digest. */
|
|
34
|
+
image: {
|
|
35
|
+
repository: string;
|
|
36
|
+
digest: `sha256:${string}`;
|
|
37
|
+
};
|
|
38
|
+
instanceType: 'lite' | 'basic' | 'standard-1' | 'standard-2' | 'standard-3' | 'standard-4';
|
|
39
|
+
maxInstances: number;
|
|
40
|
+
}[];
|
|
41
|
+
/**
|
|
42
|
+
* Name -> source. Delivered as Worker secrets. `keysCustody` (named
|
|
43
|
+
* `KEYS_CREDENTIAL`, at most one) delivers a keys custody Worker credential
|
|
44
|
+
* for the app's own host id (`workerAppHostId`), with `KEYS_URL` as a var.
|
|
45
|
+
* `keys` (an issued API key) is refused: custody does not accept one.
|
|
46
|
+
*/
|
|
47
|
+
secrets?: Record<string, {
|
|
48
|
+
from: 'generated';
|
|
49
|
+
} | {
|
|
50
|
+
from: 'keysCustody';
|
|
51
|
+
}>;
|
|
52
|
+
/** True delivers the entry's sign-in env as Worker vars. Needs `uses.auth`. */
|
|
53
|
+
authEnv?: boolean;
|
|
54
|
+
/**
|
|
55
|
+
* A Postgres database in the org's Coolify database, reached by the Worker
|
|
56
|
+
* through a Hyperdrive config bound as `HYPERDRIVE` (connect as `<db>_rt`).
|
|
57
|
+
* The release may carry `migrations.json` (`[{ id, sql }]`, ordered); foundry
|
|
58
|
+
* applies the unapplied ids before the Worker deploys. `appendOnly` names
|
|
59
|
+
* tables the runtime role may insert into and read but never update, delete
|
|
60
|
+
* or truncate. `migrationsSha256` (64 lowercase hex) pins the release's
|
|
61
|
+
* `migrations.json`: required when the release has one, and foundry refuses
|
|
62
|
+
* to parse bytes whose sha256 differs. Omit it only for a release with no
|
|
63
|
+
* `migrations.json`; one that has the file is then refused.
|
|
64
|
+
*/
|
|
65
|
+
database?: {
|
|
66
|
+
appendOnly?: string[];
|
|
67
|
+
migrationsSha256?: string;
|
|
68
|
+
};
|
|
69
|
+
/** Plain vars. Never secrets. */
|
|
70
|
+
vars?: Record<string, string>;
|
|
71
|
+
/** Host label: '@' for the apex, else a label. Exact host, never a wildcard. */
|
|
72
|
+
host: string;
|
|
73
|
+
}
|
|
74
|
+
/** The compatibility flags a Worker app may ask for. */
|
|
75
|
+
export declare const WORKER_APP_COMPATIBILITY_FLAGS: ReadonlySet<string>;
|
|
76
|
+
export declare const WORKER_APP_INSTANCE_TYPES: ReadonlySet<string>;
|
|
77
|
+
/** The most instances one container class may scale to. */
|
|
78
|
+
export declare const WORKER_APP_MAX_INSTANCES = 100;
|
|
79
|
+
/** The secret that carries a `keysCustody` source: the custody Worker credential. */
|
|
80
|
+
export declare const WORKER_APP_KEYS_SECRET = "KEYS_CREDENTIAL";
|
|
81
|
+
export declare const WORKER_APP_KEYS_URL_VAR = "KEYS_URL";
|
|
82
|
+
/**
|
|
83
|
+
* Names foundry writes into a Worker app's environment itself, so a
|
|
84
|
+
* declaration may not use them as a binding, secret or var.
|
|
85
|
+
*/
|
|
86
|
+
export declare const WORKER_APP_RESERVED_NAMES: RegExp;
|
|
87
|
+
/** True when the entry needs the org's Postgres database: a Coolify app with `database`, or a Worker app with `workerApp.database`. */
|
|
88
|
+
export declare const entryHasDatabase: (entry: {
|
|
89
|
+
database: boolean;
|
|
90
|
+
workerApp?: {
|
|
91
|
+
database?: unknown;
|
|
92
|
+
} | undefined;
|
|
93
|
+
}) => boolean;
|
|
94
|
+
/** The Hyperdrive binding of a Worker app with `database`. */
|
|
95
|
+
export declare const WORKER_APP_HYPERDRIVE_BINDING = "HYPERDRIVE";
|
|
96
|
+
/** The most tables `database.appendOnly` may name, and the shape of one name (`table` or `schema.table`). */
|
|
97
|
+
export declare const WORKER_APP_APPEND_ONLY_MAX = 100;
|
|
98
|
+
/** Hex characters of the org id's sha256 in a host id. */
|
|
99
|
+
export declare const WORKER_APP_HOST_ID_HASH_LENGTH = 12;
|
|
100
|
+
/**
|
|
101
|
+
* The custody host id of a Worker app: `app-<12 hex of sha256(org id)>-<entry key>`.
|
|
102
|
+
* It derives from the org id, never the slug, so a slug rename keeps it, and
|
|
103
|
+
* two orgs cannot share one (their hashes differ). The entry key is unique
|
|
104
|
+
* within an org. Null when the result breaks the custody pattern
|
|
105
|
+
* `^app-[a-z0-9]+(-[a-z0-9]+)*$`, 64 characters, or ends in `-next`.
|
|
106
|
+
*/
|
|
107
|
+
export declare function workerAppHostId(orgId: string, entryKey: string): string | null;
|
|
108
|
+
/** The script name ends in this; the entry key's budget against Cloudflare's 63 characters follows from it. */
|
|
109
|
+
export declare const WORKER_APP_NAME_SUFFIX = "-app";
|
|
110
|
+
/**
|
|
111
|
+
* Checks an artifact reference: an https URL with no userinfo, query or
|
|
112
|
+
* fragment (those end up verbatim in recorded scope ids) and a lowercase hex
|
|
113
|
+
* sha256. Shared by `worker` and `workerApp`.
|
|
114
|
+
*/
|
|
115
|
+
export declare function validateArtifact(at: string, artifactUrl: string, sha256: string, repo?: string | null): string | null;
|
|
116
|
+
/**
|
|
117
|
+
* The one exact name a Worker app is served at: `<host>.<domain>` or the apex
|
|
118
|
+
* on the org's own domain, else `<slug>-<key>.oikos.host` (one label under
|
|
119
|
+
* `oikos.host`, so the free Universal certificate covers it), and
|
|
120
|
+
* `<slug>.oikos.host` for the entry keyed `app`.
|
|
121
|
+
*/
|
|
122
|
+
export declare function workerAppHostnameOf(org: {
|
|
123
|
+
slug: string;
|
|
124
|
+
domain: string | null;
|
|
125
|
+
usesDomain: boolean;
|
|
126
|
+
}, key: string, host: string): string;
|
|
127
|
+
/**
|
|
128
|
+
* The Durable Object classes the migrations leave behind, folded in order:
|
|
129
|
+
* created, then removed, then renamed. A rename of a class that does not exist
|
|
130
|
+
* at that point is an error.
|
|
131
|
+
*/
|
|
132
|
+
export declare function foldMigrationClasses(migrations: WorkerAppConfig['durableObjects']['migrations']): {
|
|
133
|
+
classes: Set<string>;
|
|
134
|
+
} | {
|
|
135
|
+
error: string;
|
|
136
|
+
};
|
|
137
|
+
/**
|
|
138
|
+
* `workerApp`: exclusive with `worker` and a Coolify repo, no Coolify-only
|
|
139
|
+
* setting, a Durable Object and container declaration Cloudflare will accept,
|
|
140
|
+
* names that cannot clash with what foundry delivers, and one exact host.
|
|
141
|
+
* `null` when valid. Whether a migration was already applied is a fact of the
|
|
142
|
+
* live system; the planner checks that (`plan/worker-app.ts`).
|
|
143
|
+
*/
|
|
144
|
+
export declare function validateEntryWorkerApp(entry: HostingEntry, hosting: HostingConfig, names?: {
|
|
145
|
+
domain: string | null;
|
|
146
|
+
locked: ReadonlySet<string>;
|
|
147
|
+
/** Without it the domain-less host `<slug>-<key>.oikos.host` cannot be checked. */
|
|
148
|
+
slug?: string;
|
|
149
|
+
/** True when the site is served at the apex of the org's domain. */
|
|
150
|
+
siteOwnsApex?: boolean;
|
|
151
|
+
}): string | null;
|