@wtfalch/foundry 0.43.0 → 0.44.1

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 (40) hide show
  1. package/dist/index.d.ts +3 -3
  2. package/dist/index.js +1 -1
  3. package/dist/migrations/0057_org_customers.sql +29 -0
  4. package/dist/org.d.ts +42 -0
  5. package/dist/org.js +52 -26
  6. package/dist/plan/boule.d.ts +43 -2
  7. package/dist/plan/boule.js +48 -1
  8. package/dist/plan/broker-entries.d.ts +28 -0
  9. package/dist/plan/broker-entries.js +46 -1
  10. package/dist/plan/diff.d.ts +14 -0
  11. package/dist/plan/diff.js +176 -18
  12. package/dist/plan/executor.d.ts +100 -2
  13. package/dist/plan/executor.js +273 -16
  14. package/dist/plan/hostnames.d.ts +7 -0
  15. package/dist/plan/hostnames.js +17 -2
  16. package/dist/plan/locked-hostnames.d.ts +11 -0
  17. package/dist/plan/locked-hostnames.js +13 -0
  18. package/dist/plan/mcp.d.ts +57 -0
  19. package/dist/plan/mcp.js +131 -0
  20. package/dist/plan/org-names.d.ts +2 -2
  21. package/dist/plan/org-names.js +16 -1
  22. package/dist/plan/plan-apply.d.ts +26 -0
  23. package/dist/plan/plan-apply.js +45 -5
  24. package/dist/plan/platform-providers.d.ts +1 -1
  25. package/dist/plan/platform-providers.js +2 -1
  26. package/dist/plan/proxy-labels.js +2 -0
  27. package/dist/plan/worker-app-database.d.ts +148 -0
  28. package/dist/plan/worker-app-database.js +424 -0
  29. package/dist/plan/worker-app.d.ts +320 -0
  30. package/dist/plan/worker-app.js +792 -0
  31. package/dist/plan/zitadel-machine-user.d.ts +13 -1
  32. package/dist/plan/zitadel-machine-user.js +80 -0
  33. package/dist/requested-changes.d.ts +12 -3
  34. package/dist/requested-changes.js +22 -0
  35. package/dist/store/postgres.js +12 -6
  36. package/dist/store/schema.d.ts +34 -0
  37. package/dist/store/schema.js +3 -0
  38. package/dist/worker-app-config.d.ts +151 -0
  39. package/dist/worker-app-config.js +438 -0
  40. 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 { DeclaredOrg, MailMode, OrgId } from './org.js';
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) ||
@@ -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 { lockedHostnames } from '../plan/locked-hostnames.js';
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;
@@ -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";
@@ -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;