@wtfalch/foundry 0.5.0 → 0.7.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 (66) hide show
  1. package/dist/bootstrap/bootstrap.d.ts +16 -1
  2. package/dist/bootstrap/bootstrap.js +40 -2
  3. package/dist/bootstrap/declare.d.ts +3 -1
  4. package/dist/bootstrap/declare.js +12 -3
  5. package/dist/bootstrap/versions.d.ts +17 -0
  6. package/dist/bootstrap/versions.js +12 -0
  7. package/dist/calls-sfu-deploy-support.d.ts +8 -0
  8. package/dist/calls-sfu-deploy-support.js +10 -1
  9. package/dist/calls-sfu.d.ts +36 -12
  10. package/dist/calls-sfu.js +42 -9
  11. package/dist/index.d.ts +10 -4
  12. package/dist/index.js +6 -3
  13. package/dist/migrations/0019_mail_owner_mailbox.sql +15 -0
  14. package/dist/migrations/0020_mail_apps.sql +7 -0
  15. package/dist/migrations/0021_mail_role_mailboxes.sql +6 -0
  16. package/dist/migrations/0022_chat_enabled.sql +6 -0
  17. package/dist/migrations/0023_hosting_app_env.sql +8 -0
  18. package/dist/migrations/0024_audit_events.sql +211 -0
  19. package/dist/migrations/0025_audit_display.sql +147 -0
  20. package/dist/migrations/0026_audit_erase_email_guard.sql +78 -0
  21. package/dist/migrations/0027_audit_chain.sql +170 -0
  22. package/dist/migrations/0028_audit_rls.sql +111 -0
  23. package/dist/org.d.ts +105 -0
  24. package/dist/org.js +101 -0
  25. package/dist/plan/agora.d.ts +21 -0
  26. package/dist/plan/agora.js +23 -0
  27. package/dist/plan/calls-matrix.d.ts +2 -2
  28. package/dist/plan/calls-matrix.js +3 -2
  29. package/dist/plan/cloudflared-connector.d.ts +3 -2
  30. package/dist/plan/cloudflared-connector.js +5 -3
  31. package/dist/plan/diff.d.ts +15 -2
  32. package/dist/plan/diff.js +399 -51
  33. package/dist/plan/executor.d.ts +307 -61
  34. package/dist/plan/executor.js +489 -129
  35. package/dist/plan/index.d.ts +9 -1
  36. package/dist/plan/index.js +5 -1
  37. package/dist/plan/login-host.d.ts +64 -0
  38. package/dist/plan/login-host.js +52 -0
  39. package/dist/plan/mail-copy.d.ts +125 -0
  40. package/dist/plan/mail-copy.js +166 -0
  41. package/dist/plan/mail-identity-allowlist.d.ts +50 -0
  42. package/dist/plan/mail-identity-allowlist.js +11 -0
  43. package/dist/plan/mail-identity.d.ts +68 -0
  44. package/dist/plan/mail-identity.js +17 -0
  45. package/dist/plan/plan-apply.d.ts +26 -8
  46. package/dist/plan/plan-apply.js +10 -9
  47. package/dist/plan/platform-diff.d.ts +207 -10
  48. package/dist/plan/platform-diff.js +403 -15
  49. package/dist/plan/platform-org.d.ts +3 -2
  50. package/dist/plan/platform-providers.d.ts +139 -25
  51. package/dist/plan/platform-providers.js +1531 -95
  52. package/dist/plan/platform-sfu.d.ts +54 -19
  53. package/dist/plan/platform-sfu.js +64 -22
  54. package/dist/plan/providers-readers.js +44 -1
  55. package/dist/plan/zitadel-machine-user.d.ts +60 -11
  56. package/dist/plan/zitadel-machine-user.js +182 -39
  57. package/dist/requested-changes.d.ts +17 -7
  58. package/dist/requested-changes.js +37 -7
  59. package/dist/store/audit.d.ts +40 -4
  60. package/dist/store/audit.js +114 -29
  61. package/dist/store/postgres.js +35 -3
  62. package/dist/store/schema.d.ts +109 -1
  63. package/dist/store/schema.js +20 -0
  64. package/dist/store.d.ts +7 -0
  65. package/package.json +5 -3
  66. package/versions.json +8 -0
@@ -11,7 +11,9 @@ import type { PlanDetail } from './plan/diff.js';
11
11
  * and safe; `domain` and `mailTier` are full self-service too, but both are
12
12
  * migrating steps (`plan/diff.ts`'s `planMail` moves live mail between
13
13
  * modes; a domain move is at least as disruptive) and always need approval,
14
- * never auto.
14
+ * never auto. #184 narrows `storageLimit`/`aiLimit`'s "small and safe" to
15
+ * lowering or leaving a limit unchanged -- raising either one needs
16
+ * approval too, until a billing field exists to say what the raise costs.
15
17
  */
16
18
  export type RequestableField = 'calls' | 'storageLimit' | 'aiLimit' | 'domain' | 'mailTier';
17
19
  export declare const REQUESTABLE_FIELDS: readonly RequestableField[];
@@ -85,12 +87,20 @@ export declare class RequestedChangeError extends Error {
85
87
  constructor(message: string);
86
88
  }
87
89
  /**
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.
90
+ * `auto` when `change` is small and safe (`calls`, and `storageLimit`/
91
+ * `aiLimit` in the lowering or unchanged direction), the plan it causes has
92
+ * no destructive or migrating step, and it causes no price change on
93
+ * `org`'s own billing fields; `approval` otherwise -- every `domain` or
94
+ * `mailTier` change classifies `approval` unconditionally, since #178
95
+ * "Decide" calls both migrating steps that always need approval, whatever
96
+ * direction the change moves.
97
+ *
98
+ * A `storageLimit` or `aiLimit` change that *raises* either limit
99
+ * ({@link isLimitRaise} against `org`'s current value) classifies
100
+ * `approval`, decided in #184: "a raise of the storage or AI limit
101
+ * classifies `approval` until billing fields exist. A lowering stays
102
+ * `auto`." `storageLimit` carries two independent limits (`limitBytes`,
103
+ * `limitMicros`); a raise on either one is enough.
94
104
  *
95
105
  * `plan` is the {@link PlanDetail} `change` causes -- the caller's own diff
96
106
  * against live reality, from the same `computePlanDetail` (`plan/diff.ts`)
@@ -53,12 +53,37 @@ function priceChanges(_before, _after) {
53
53
  return false;
54
54
  }
55
55
  /**
56
- * `auto` when `change` is small and safe (`calls`, `storageLimit`,
57
- * `aiLimit`), the plan it causes has no destructive or migrating step, and
58
- * it causes no price change on `org`'s own billing fields; `approval`
59
- * otherwise -- every `domain` or `mailTier` change classifies `approval`
60
- * unconditionally, since #178 "Decide" calls both migrating steps that
61
- * always need approval, whatever direction the change moves.
56
+ * Whether a `StorageConfig`/`AiConfig` limit field moved up from `before` to
57
+ * `after` -- both are `null`-or-bigint-string, `null` meaning uncapped
58
+ * (`org.ts`'s `StorageConfig`/`AiConfig` doc). `null` is the largest
59
+ * possible limit, so moving a capped `before` to an uncapped `after` is a
60
+ * raise; moving an already-uncapped `before` anywhere is never a raise
61
+ * (there is nothing higher to move to); two finite values compare as
62
+ * bigints, never as numbers, the same bigint-safety `org.ts` requires of
63
+ * these fields. Equal values are not a raise.
64
+ */
65
+ function isLimitRaise(before, after) {
66
+ if (before === null)
67
+ return false;
68
+ if (after === null)
69
+ return true;
70
+ return BigInt(after) > BigInt(before);
71
+ }
72
+ /**
73
+ * `auto` when `change` is small and safe (`calls`, and `storageLimit`/
74
+ * `aiLimit` in the lowering or unchanged direction), the plan it causes has
75
+ * no destructive or migrating step, and it causes no price change on
76
+ * `org`'s own billing fields; `approval` otherwise -- every `domain` or
77
+ * `mailTier` change classifies `approval` unconditionally, since #178
78
+ * "Decide" calls both migrating steps that always need approval, whatever
79
+ * direction the change moves.
80
+ *
81
+ * A `storageLimit` or `aiLimit` change that *raises* either limit
82
+ * ({@link isLimitRaise} against `org`'s current value) classifies
83
+ * `approval`, decided in #184: "a raise of the storage or AI limit
84
+ * classifies `approval` until billing fields exist. A lowering stays
85
+ * `auto`." `storageLimit` carries two independent limits (`limitBytes`,
86
+ * `limitMicros`); a raise on either one is enough.
62
87
  *
63
88
  * `plan` is the {@link PlanDetail} `change` causes -- the caller's own diff
64
89
  * against live reality, from the same `computePlanDetail` (`plan/diff.ts`)
@@ -85,9 +110,14 @@ export function classifyChange(org, change, plan) {
85
110
  return 'approval';
86
111
  switch (change.field) {
87
112
  case 'calls':
113
+ return 'auto';
88
114
  case 'storageLimit':
115
+ return isLimitRaise(org.storage.limitBytes, change.limitBytes) ||
116
+ isLimitRaise(org.storage.limitMicros, change.limitMicros)
117
+ ? 'approval'
118
+ : 'auto';
89
119
  case 'aiLimit':
90
- return 'auto';
120
+ return isLimitRaise(org.ai.limitMicros, change.limitMicros) ? 'approval' : 'auto';
91
121
  case 'domain':
92
122
  case 'mailTier':
93
123
  return 'approval';
@@ -1,8 +1,44 @@
1
1
  import type { AuditLog } from '../plan/plan-apply.js';
2
2
  import type { FoundryStoreDb } from './postgres.js';
3
3
  /**
4
- * foundry's own {@link AuditLog}, one row per plan, apply or destroy in
5
- * `foundry_audit_events` (#85). The CLI uses it before any archon exists; an
6
- * attached archon reads the same table. It only inserts.
4
+ * foundry's own {@link AuditLog}, backed by `@wtfalch/audit`'s ledger
5
+ * (#232) instead of the hand-rolled `foundry_audit_events` table
6
+ * (`migrations/0012_audit.sql`) this replaces. Every row: `tenantId: null`
7
+ * (foundry has no tenant of its own -- the org a plan is *for* is the
8
+ * `target`, not a tenant), `hashChain` on so a changed or deleted row is
9
+ * detectable (`verifyChain`), through migrations 0024-0028
10
+ * (`src/migrations/`, copied from `@wtfalch/audit` 0.6.0's own
11
+ * `migrations/0001-0005`, the same files its own `audit-migrations` CLI
12
+ * would copy into a host that has a `drizzle/` journal -- foundry does not,
13
+ * so they are hand-placed at the next free numbers instead).
14
+ *
15
+ * The `AuditLog` interface this satisfies is unchanged (`plan-apply.ts`),
16
+ * so `bootstrap.ts` and `redactingAudit` need no change either.
17
+ *
18
+ * Decide (#232, open): whether `foundry_audit_events`' existing rows are
19
+ * copied into the ledger or left as a closed, separate trail. Left as-is
20
+ * here -- the simpler default, and the one that forecloses nothing: a
21
+ * backfill can still be written later, where discarding the old rows
22
+ * outright could not be undone.
23
+ *
24
+ * `checkRuntimeRole` defaults to `false` here, the opposite of
25
+ * `createLedger`'s own default (#232, needs a human call -- flagged
26
+ * `ai:needs-human`, not decided here): that check requires the connection
27
+ * to be a real, restricted `<database>_rt` role, the one 0001's migration
28
+ * revokes UPDATE/DELETE/TRUNCATE from by name
29
+ * (`current_database() || '_rt'`). foundry has no such role for its own
30
+ * database -- `FOUNDRY_DATABASE_URL` is one connection, no owner/runtime
31
+ * split the way archon's, manage's or lokessmie's own `DATABASE_URL_OWNER`/
32
+ * `DATABASE_URL` pair is (see #239, the same split for a *hosted* app's
33
+ * database -- this is foundry's *own*). Leaving the default on would make
34
+ * every real call throw `UnsafeRuntimeRoleError`, since the revoke in
35
+ * 0001_audit.sql never ran against whatever role `FOUNDRY_DATABASE_URL`
36
+ * actually is; the append-only guard and the chain still hold at the
37
+ * database level regardless (0001's triggers, 0004's hashing), only the
38
+ * extra fail-closed check on the *connection* is off. Options, not a second
39
+ * positional argument (`CLAUDE.md` rule 7): a caller -- today only a test
40
+ * fixture -- can pass `true` once foundry's own database gets that split.
7
41
  */
8
- export declare function createPostgresAuditLog(db: FoundryStoreDb): AuditLog;
42
+ export declare function createPostgresAuditLog(db: FoundryStoreDb, options?: {
43
+ checkRuntimeRole?: boolean;
44
+ }): AuditLog;
@@ -1,44 +1,129 @@
1
- import { foundryAuditEvents } from './schema.js';
1
+ import { createLedger, ledgerVocabulary } from '@wtfalch/audit';
2
2
  /**
3
- * foundry's own {@link AuditLog}, one row per plan, apply or destroy in
4
- * `foundry_audit_events` (#85). The CLI uses it before any archon exists; an
5
- * attached archon reads the same table. It only inserts.
3
+ * foundry's own closed vocabulary (#232): one event per `AuditLog` method,
4
+ * `outcome` carrying how it went rather than the action name repeating it
5
+ * (the same split `@wtfalch/audit`'s own "service with no tenant database"
6
+ * example uses: `action: 'plan.applied'`, `outcome: 'refused'`). `actor`
7
+ * has one class, `'operator'`, because `AuditActor` (`plan-apply.ts`) does
8
+ * not distinguish a human from a service caller -- nothing here guesses
9
+ * one. `'recorded'` sorts first so it is the default outcome
10
+ * `plan.computed` rows get (`recordPlan` has no outcome of its own to pass).
6
11
  */
7
- export function createPostgresAuditLog(db) {
12
+ const FOUNDRY_LEDGER_VOCABULARY = ledgerVocabulary({
13
+ events: {
14
+ 'plan.computed': { tenantVisible: false },
15
+ 'apply.recorded': { tenantVisible: false },
16
+ 'destroy.recorded': { tenantVisible: false },
17
+ },
18
+ actorClasses: ['operator'],
19
+ contexts: ['operator'],
20
+ outcomes: ['recorded', 'applied', 'destroyed', 'refused', 'failed'],
21
+ });
22
+ /**
23
+ * The ledger table's CHECKs (migration 0024): reason <= 512 chars, `after`
24
+ * <= 65536 bytes as jsonb text, actor_display 1-256 chars. A write that
25
+ * breaks one throws and would hide the apply error being recorded, so each
26
+ * value is bounded here. Postgres' jsonb text adds a space after every `:`
27
+ * and `,`, so the plan is measured against half the limit.
28
+ */
29
+ const MAX_REASON = 512;
30
+ const MAX_AFTER_BYTES = 32768;
31
+ const MAX_DISPLAY = 256;
32
+ function boundReason(reason) {
33
+ return reason === undefined ? null : reason.slice(0, MAX_REASON);
34
+ }
35
+ function boundDisplay(actor) {
36
+ return (actor.display || actor.id).slice(0, MAX_DISPLAY);
37
+ }
38
+ function boundPlan(plan) {
39
+ return Buffer.byteLength(JSON.stringify(plan)) <= MAX_AFTER_BYTES
40
+ ? plan
41
+ : { truncated: true, steps: plan.steps.length, unmanaged: plan.unmanaged.length };
42
+ }
43
+ /**
44
+ * foundry's own {@link AuditLog}, backed by `@wtfalch/audit`'s ledger
45
+ * (#232) instead of the hand-rolled `foundry_audit_events` table
46
+ * (`migrations/0012_audit.sql`) this replaces. Every row: `tenantId: null`
47
+ * (foundry has no tenant of its own -- the org a plan is *for* is the
48
+ * `target`, not a tenant), `hashChain` on so a changed or deleted row is
49
+ * detectable (`verifyChain`), through migrations 0024-0028
50
+ * (`src/migrations/`, copied from `@wtfalch/audit` 0.6.0's own
51
+ * `migrations/0001-0005`, the same files its own `audit-migrations` CLI
52
+ * would copy into a host that has a `drizzle/` journal -- foundry does not,
53
+ * so they are hand-placed at the next free numbers instead).
54
+ *
55
+ * The `AuditLog` interface this satisfies is unchanged (`plan-apply.ts`),
56
+ * so `bootstrap.ts` and `redactingAudit` need no change either.
57
+ *
58
+ * Decide (#232, open): whether `foundry_audit_events`' existing rows are
59
+ * copied into the ledger or left as a closed, separate trail. Left as-is
60
+ * here -- the simpler default, and the one that forecloses nothing: a
61
+ * backfill can still be written later, where discarding the old rows
62
+ * outright could not be undone.
63
+ *
64
+ * `checkRuntimeRole` defaults to `false` here, the opposite of
65
+ * `createLedger`'s own default (#232, needs a human call -- flagged
66
+ * `ai:needs-human`, not decided here): that check requires the connection
67
+ * to be a real, restricted `<database>_rt` role, the one 0001's migration
68
+ * revokes UPDATE/DELETE/TRUNCATE from by name
69
+ * (`current_database() || '_rt'`). foundry has no such role for its own
70
+ * database -- `FOUNDRY_DATABASE_URL` is one connection, no owner/runtime
71
+ * split the way archon's, manage's or lokessmie's own `DATABASE_URL_OWNER`/
72
+ * `DATABASE_URL` pair is (see #239, the same split for a *hosted* app's
73
+ * database -- this is foundry's *own*). Leaving the default on would make
74
+ * every real call throw `UnsafeRuntimeRoleError`, since the revoke in
75
+ * 0001_audit.sql never ran against whatever role `FOUNDRY_DATABASE_URL`
76
+ * actually is; the append-only guard and the chain still hold at the
77
+ * database level regardless (0001's triggers, 0004's hashing), only the
78
+ * extra fail-closed check on the *connection* is off. Options, not a second
79
+ * positional argument (`CLAUDE.md` rule 7): a caller -- today only a test
80
+ * fixture -- can pass `true` once foundry's own database gets that split.
81
+ */
82
+ export function createPostgresAuditLog(db, options = {}) {
83
+ const ledger = createLedger({
84
+ vocabulary: FOUNDRY_LEDGER_VOCABULARY,
85
+ hashChain: true,
86
+ checkRuntimeRole: options.checkRuntimeRole ?? false,
87
+ });
88
+ const targetFor = (orgId, target) => ({
89
+ type: target === 'platform' ? 'platform' : 'org',
90
+ id: orgId,
91
+ });
8
92
  return {
9
93
  async recordPlan({ actor, orgId, plan, target, at }) {
10
- await db.insert(foundryAuditEvents).values({
11
- at,
12
- kind: 'plan',
13
- actorId: actor.id,
14
- actorDisplay: actor.display,
15
- orgId,
16
- plan,
17
- target: target ?? null,
94
+ await ledger.sign(db, {
95
+ action: 'plan.computed',
96
+ tenantId: null,
97
+ actor: { class: 'operator', id: actor.id, display: boundDisplay(actor) },
98
+ context: 'operator',
99
+ target: targetFor(orgId, target),
100
+ after: boundPlan(plan),
101
+ occurredAt: at,
18
102
  });
19
103
  },
20
104
  async recordApply({ actor, orgId, plan, outcome, reason, target, at }) {
21
- await db.insert(foundryAuditEvents).values({
22
- at,
23
- kind: 'apply',
24
- actorId: actor.id,
25
- actorDisplay: actor.display,
26
- orgId,
105
+ await ledger.sign(db, {
106
+ action: 'apply.recorded',
107
+ tenantId: null,
108
+ actor: { class: 'operator', id: actor.id, display: boundDisplay(actor) },
109
+ context: 'operator',
110
+ target: targetFor(orgId, target),
27
111
  outcome,
28
- reason: reason ?? null,
29
- plan,
30
- target: target ?? null,
112
+ reason: boundReason(reason),
113
+ after: boundPlan(plan),
114
+ occurredAt: at,
31
115
  });
32
116
  },
33
117
  async recordDestroy({ actor, orgId, outcome, reason, at }) {
34
- await db.insert(foundryAuditEvents).values({
35
- at,
36
- kind: 'destroy',
37
- actorId: actor.id,
38
- actorDisplay: actor.display,
39
- orgId,
118
+ await ledger.sign(db, {
119
+ action: 'destroy.recorded',
120
+ tenantId: null,
121
+ actor: { class: 'operator', id: actor.id, display: boundDisplay(actor) },
122
+ context: 'operator',
123
+ target: targetFor(orgId),
40
124
  outcome,
41
- reason: reason ?? null,
125
+ reason: boundReason(reason),
126
+ occurredAt: at,
42
127
  });
43
128
  },
44
129
  };
@@ -1,5 +1,5 @@
1
1
  import { asc, eq, sql } from 'drizzle-orm';
2
- import { validateBuyerIdentity, validateDomain, validateHosting, validateOwner, validateSite, } from '../org.js';
2
+ import { DEFAULT_MAIL_APPS, DEFAULT_NO_REPLY_LOCAL_PART, validateBuyerIdentity, validateDomain, validateHosting, validateMail, validateOwner, validateSite, } from '../org.js';
3
3
  import { validatePlatform } from '../platform.js';
4
4
  import { RequestedChangeError, classifyChange } from '../requested-changes.js';
5
5
  import { validateSlug } from '../slug.js';
@@ -59,8 +59,15 @@ function rowToOrg(row) {
59
59
  slug: row.slug,
60
60
  displayName: row.displayName,
61
61
  domain: row.domain,
62
- mail: { mode: row.mailMode, usesDomain: row.mailUsesDomain },
63
- chat: { usesDomain: row.chatUsesDomain },
62
+ mail: {
63
+ mode: row.mailMode,
64
+ usesDomain: row.mailUsesDomain,
65
+ ownerMailboxAddress: row.ownerMailboxAddress,
66
+ noReplyLocalPart: row.noReplyLocalPart,
67
+ mailApps: row.mailApps,
68
+ roleMailboxes: row.roleMailboxes,
69
+ },
70
+ chat: { enabled: row.chatEnabled, usesDomain: row.chatUsesDomain },
64
71
  calls: { enabled: row.callsEnabled, usesDomain: row.callsUsesDomain },
65
72
  issuer: { usesDomain: row.issuerUsesDomain },
66
73
  site: { enabled: row.siteEnabled, usesDomain: row.siteUsesDomain },
@@ -73,6 +80,7 @@ function rowToOrg(row) {
73
80
  buildPack: row.hostingBuildPack,
74
81
  issuerApps: row.hostingIssuerApps,
75
82
  accessEmails: row.hostingAccessEmails,
83
+ appEnv: row.hostingAppEnv,
76
84
  },
77
85
  sso: { enabled: row.ssoEnabled },
78
86
  guests: { enabled: row.guestsEnabled },
@@ -96,6 +104,11 @@ function orgToRow(org) {
96
104
  domain: org.domain,
97
105
  mailMode: org.mail.mode,
98
106
  mailUsesDomain: org.mail.usesDomain,
107
+ ownerMailboxAddress: org.mail.ownerMailboxAddress ?? null,
108
+ noReplyLocalPart: org.mail.noReplyLocalPart ?? DEFAULT_NO_REPLY_LOCAL_PART,
109
+ mailApps: org.mail.mailApps ?? [...DEFAULT_MAIL_APPS],
110
+ roleMailboxes: org.mail.roleMailboxes ?? [],
111
+ chatEnabled: org.chat.enabled,
99
112
  chatUsesDomain: org.chat.usesDomain,
100
113
  callsEnabled: org.calls.enabled,
101
114
  callsUsesDomain: org.calls.usesDomain,
@@ -110,6 +123,7 @@ function orgToRow(org) {
110
123
  hostingBuildPack: org.hosting.buildPack,
111
124
  hostingIssuerApps: org.hosting.issuerApps,
112
125
  hostingAccessEmails: org.hosting.accessEmails,
126
+ hostingAppEnv: org.hosting.appEnv ?? {},
113
127
  ssoEnabled: org.sso.enabled,
114
128
  guestsEnabled: org.guests.enabled,
115
129
  soloEnabled: org.solo.enabled,
@@ -256,6 +270,16 @@ export function createPostgresDeclaredOrgStore(db) {
256
270
  if (reason)
257
271
  throw new DeclaredOrgError(`owner invalid: ${reason}`);
258
272
  }
273
+ // #162: same place `requireValidOwner` checks the owner, on both `create`
274
+ // and the merged result of `update`. Deliberately does not require
275
+ // `mail.ownerMailboxAddress` just because mail is on -- `validateMail`'s
276
+ // own doc comment says why. Also covers #34's `mailApps` check, folded
277
+ // into the same `validateMail`.
278
+ function requireValidMail(org) {
279
+ const reason = validateMail(org);
280
+ if (reason)
281
+ throw new DeclaredOrgError(`mail invalid: ${reason} (#162)`);
282
+ }
259
283
  return {
260
284
  async create(org) {
261
285
  requireValidSlug(org.slug);
@@ -265,6 +289,7 @@ export function createPostgresDeclaredOrgStore(db) {
265
289
  requireValidHosting(org);
266
290
  requireValidSite(org);
267
291
  requireValidOwner(org);
292
+ requireValidMail(org);
268
293
  try {
269
294
  await db.insert(declaredOrgs).values(orgToRow(org));
270
295
  }
@@ -289,6 +314,12 @@ export function createPostgresDeclaredOrgStore(db) {
289
314
  const [row] = await db.select().from(declaredOrgs).where(eq(declaredOrgs.id, id)).limit(1);
290
315
  return row ? rowToOrg(row) : null;
291
316
  },
317
+ // #133: ordered by display name, the same `asc(...)` shape
318
+ // `createPostgresRequestedChangeStore.listForOrg` already uses below.
319
+ async list() {
320
+ const rows = await db.select().from(declaredOrgs).orderBy(asc(declaredOrgs.displayName));
321
+ return rows.map(rowToOrg);
322
+ },
292
323
  async slugTaken(slug) {
293
324
  const [row] = await db
294
325
  .select({ id: declaredOrgs.id })
@@ -338,6 +369,7 @@ export function createPostgresDeclaredOrgStore(db) {
338
369
  requireValidHosting(next);
339
370
  requireValidSite(next);
340
371
  requireValidOwner(next);
372
+ requireValidMail(next);
341
373
  try {
342
374
  await db
343
375
  .update(declaredOrgs)
@@ -1,4 +1,4 @@
1
- import type { IssuerApp } from '../org.js';
1
+ import type { AppEnvSource, IssuerApp } from '../org.js';
2
2
  import type { Plan } from '../plan.js';
3
3
  import type { Checkpoint } from '../state.js';
4
4
  /**
@@ -113,6 +113,95 @@ export declare const declaredOrgs: import("drizzle-orm/pg-core").PgTableWithColu
113
113
  identity: undefined;
114
114
  generated: undefined;
115
115
  }, {}, {}>;
116
+ ownerMailboxAddress: import("drizzle-orm/pg-core").PgColumn<{
117
+ name: "owner_mailbox_address";
118
+ tableName: "foundry_declared_orgs";
119
+ dataType: "string";
120
+ columnType: "PgText";
121
+ data: string;
122
+ driverParam: string;
123
+ notNull: false;
124
+ hasDefault: false;
125
+ isPrimaryKey: false;
126
+ isAutoincrement: false;
127
+ hasRuntimeDefault: false;
128
+ enumValues: [string, ...string[]];
129
+ baseColumn: never;
130
+ identity: undefined;
131
+ generated: undefined;
132
+ }, {}, {}>;
133
+ noReplyLocalPart: import("drizzle-orm/pg-core").PgColumn<{
134
+ name: "no_reply_local_part";
135
+ tableName: "foundry_declared_orgs";
136
+ dataType: "string";
137
+ columnType: "PgText";
138
+ data: string;
139
+ driverParam: string;
140
+ notNull: true;
141
+ hasDefault: true;
142
+ isPrimaryKey: false;
143
+ isAutoincrement: false;
144
+ hasRuntimeDefault: false;
145
+ enumValues: [string, ...string[]];
146
+ baseColumn: never;
147
+ identity: undefined;
148
+ generated: undefined;
149
+ }, {}, {}>;
150
+ mailApps: import("drizzle-orm/pg-core").PgColumn<{
151
+ name: "mail_apps";
152
+ tableName: "foundry_declared_orgs";
153
+ dataType: "json";
154
+ columnType: "PgJsonb";
155
+ data: string[];
156
+ driverParam: unknown;
157
+ notNull: true;
158
+ hasDefault: true;
159
+ isPrimaryKey: false;
160
+ isAutoincrement: false;
161
+ hasRuntimeDefault: false;
162
+ enumValues: undefined;
163
+ baseColumn: never;
164
+ identity: undefined;
165
+ generated: undefined;
166
+ }, {}, {
167
+ $type: string[];
168
+ }>;
169
+ roleMailboxes: import("drizzle-orm/pg-core").PgColumn<{
170
+ name: "role_mailboxes";
171
+ tableName: "foundry_declared_orgs";
172
+ dataType: "json";
173
+ columnType: "PgJsonb";
174
+ data: string[];
175
+ driverParam: unknown;
176
+ notNull: true;
177
+ hasDefault: true;
178
+ isPrimaryKey: false;
179
+ isAutoincrement: false;
180
+ hasRuntimeDefault: false;
181
+ enumValues: undefined;
182
+ baseColumn: never;
183
+ identity: undefined;
184
+ generated: undefined;
185
+ }, {}, {
186
+ $type: string[];
187
+ }>;
188
+ chatEnabled: import("drizzle-orm/pg-core").PgColumn<{
189
+ name: "chat_enabled";
190
+ tableName: "foundry_declared_orgs";
191
+ dataType: "boolean";
192
+ columnType: "PgBoolean";
193
+ data: boolean;
194
+ driverParam: boolean;
195
+ notNull: true;
196
+ hasDefault: true;
197
+ isPrimaryKey: false;
198
+ isAutoincrement: false;
199
+ hasRuntimeDefault: false;
200
+ enumValues: undefined;
201
+ baseColumn: never;
202
+ identity: undefined;
203
+ generated: undefined;
204
+ }, {}, {}>;
116
205
  chatUsesDomain: import("drizzle-orm/pg-core").PgColumn<{
117
206
  name: "chat_uses_domain";
118
207
  tableName: "foundry_declared_orgs";
@@ -355,6 +444,25 @@ export declare const declaredOrgs: import("drizzle-orm/pg-core").PgTableWithColu
355
444
  }, {}, {
356
445
  $type: string[];
357
446
  }>;
447
+ hostingAppEnv: import("drizzle-orm/pg-core").PgColumn<{
448
+ name: "hosting_app_env";
449
+ tableName: "foundry_declared_orgs";
450
+ dataType: "json";
451
+ columnType: "PgJsonb";
452
+ data: Record<string, AppEnvSource>;
453
+ driverParam: unknown;
454
+ notNull: true;
455
+ hasDefault: true;
456
+ isPrimaryKey: false;
457
+ isAutoincrement: false;
458
+ hasRuntimeDefault: false;
459
+ enumValues: undefined;
460
+ baseColumn: never;
461
+ identity: undefined;
462
+ generated: undefined;
463
+ }, {}, {
464
+ $type: Record<string, AppEnvSource>;
465
+ }>;
358
466
  ssoEnabled: import("drizzle-orm/pg-core").PgColumn<{
359
467
  name: "sso_enabled";
360
468
  tableName: "foundry_declared_orgs";
@@ -12,6 +12,20 @@ export const declaredOrgs = pgTable('foundry_declared_orgs', {
12
12
  domain: text('domain'),
13
13
  mailMode: text('mail_mode').notNull().default('off'),
14
14
  mailUsesDomain: boolean('mail_uses_domain').notNull().default(false),
15
+ // MailConfig (org.ts, #162). null until an operator declares one -- the
16
+ // mailbox itself is blocked, not guessed, until it is.
17
+ ownerMailboxAddress: text('owner_mailbox_address'),
18
+ // Defaults to 'no-reply', the same literal `0019_mail_owner_mailbox.sql`
19
+ // and org.ts's `DEFAULT_NO_REPLY_LOCAL_PART` use.
20
+ noReplyLocalPart: text('no_reply_local_part').notNull().default('no-reply'),
21
+ // MailConfig.mailApps (org.ts, #34). Which ZITADEL apps in the org's
22
+ // audience project may read a person's mail; defaults to DEFAULT_MAIL_APPS
23
+ // ('agora', 'webmail') so every existing org keeps its current behaviour.
24
+ mailApps: jsonb('mail_apps').notNull().default(['agora', 'webmail']).$type(),
25
+ // MailConfig.roleMailboxes (org.ts, #230). Local parts of the role mailboxes
26
+ // pre-created on the org's mail domain; empty by default.
27
+ roleMailboxes: jsonb('role_mailboxes').notNull().default([]).$type(),
28
+ chatEnabled: boolean('chat_enabled').notNull().default(false),
15
29
  chatUsesDomain: boolean('chat_uses_domain').notNull().default(false),
16
30
  callsEnabled: boolean('calls_enabled').notNull().default(false),
17
31
  callsUsesDomain: boolean('calls_uses_domain').notNull().default(false),
@@ -28,6 +42,12 @@ export const declaredOrgs = pgTable('foundry_declared_orgs', {
28
42
  hostingBuildPack: text('hosting_build_pack').notNull().default('nixpacks'),
29
43
  hostingIssuerApps: jsonb('hosting_issuer_apps').notNull().default([]).$type(),
30
44
  hostingAccessEmails: jsonb('hosting_access_emails').notNull().default([]).$type(),
45
+ // HostingConfig.appEnv (org.ts, #243). Maps the app's own env var names to
46
+ // values the plan already mints; empty by default.
47
+ hostingAppEnv: jsonb('hosting_app_env')
48
+ .notNull()
49
+ .default({})
50
+ .$type(),
31
51
  ssoEnabled: boolean('sso_enabled').notNull().default(false),
32
52
  guestsEnabled: boolean('guests_enabled').notNull().default(false),
33
53
  soloEnabled: boolean('solo_enabled').notNull().default(false),
package/dist/store.d.ts CHANGED
@@ -14,6 +14,13 @@ import type { AppliedState } from './state.js';
14
14
  export interface DeclaredOrgStore {
15
15
  create(org: DeclaredOrg): Promise<void>;
16
16
  get(id: OrgId): Promise<DeclaredOrg | null>;
17
+ /**
18
+ * Every declared org, ordered by display name (#133). The read path a
19
+ * billing admin area (or anything else that just wants the roster) uses
20
+ * instead of reading the store's own table by hand -- Archon's
21
+ * `listCustomers` was doing exactly that before this existed.
22
+ */
23
+ list(): Promise<DeclaredOrg[]>;
17
24
  /** Whether any org already holds this slug. `slugFromName` asks it. */
18
25
  slugTaken(slug: string): Promise<boolean>;
19
26
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/foundry",
3
- "version": "0.5.0",
3
+ "version": "0.7.1",
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",
@@ -9,7 +9,7 @@
9
9
  },
10
10
  "license": "MIT",
11
11
  "type": "module",
12
- "files": ["dist"],
12
+ "files": ["dist", "versions.json"],
13
13
  "exports": {
14
14
  ".": {
15
15
  "types": "./dist/index.d.ts",
@@ -37,10 +37,12 @@
37
37
  "drizzle-orm": ">=0.45.2"
38
38
  },
39
39
  "dependencies": {
40
+ "@wtfalch/audit": "0.6.0",
40
41
  "@wtfalch/auth": "0.16.0",
41
42
  "@wtfalch/email": "0.15.0",
42
43
  "@wtfalch/providers": "0.6.0",
43
- "ssh2": "1.17.0"
44
+ "ssh2": "1.17.0",
45
+ "zod": "4.6.5"
44
46
  },
45
47
  "devDependencies": {
46
48
  "@electric-sql/pglite": "^0.5.8",
package/versions.json ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "zitadel": "v4.17.3",
3
+ "synapse": "v1.161.0",
4
+ "mas": "1.24.0",
5
+ "livekit": "latest",
6
+ "cloudflared": "2026.9.3",
7
+ "stalwart": "v0.16.24"
8
+ }