@wtfalch/foundry 0.3.0 → 0.5.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.
Files changed (71) hide show
  1. package/dist/bootstrap/bootstrap.d.ts +96 -0
  2. package/dist/bootstrap/bootstrap.js +285 -0
  3. package/dist/bootstrap/declare.d.ts +36 -0
  4. package/dist/bootstrap/declare.js +94 -0
  5. package/dist/bootstrap/drive.d.ts +40 -0
  6. package/dist/bootstrap/drive.js +51 -0
  7. package/dist/bootstrap/index.d.ts +3 -0
  8. package/dist/bootstrap/index.js +1 -0
  9. package/dist/calls-sfu-deploy-support.d.ts +9 -6
  10. package/dist/calls-sfu-deploy-support.js +9 -6
  11. package/dist/calls-sfu-keys.d.ts +21 -0
  12. package/dist/calls-sfu-keys.js +27 -0
  13. package/dist/calls-sfu-ssh-adapter.d.ts +55 -0
  14. package/dist/calls-sfu-ssh-adapter.js +78 -0
  15. package/dist/calls-sfu-ssh-client.d.ts +49 -0
  16. package/dist/calls-sfu-ssh-client.js +82 -0
  17. package/dist/calls-sfu.d.ts +22 -4
  18. package/dist/calls-sfu.js +22 -4
  19. package/dist/index.d.ts +31 -6
  20. package/dist/index.js +16 -3
  21. package/dist/migrations/0014_calls_enabled.sql +7 -0
  22. package/dist/migrations/0015_domain_unique.sql +39 -0
  23. package/dist/migrations/0016_platform.sql +61 -0
  24. package/dist/migrations/0017_requested_changes.sql +42 -0
  25. package/dist/migrations/0018_platform_apply.sql +19 -0
  26. package/dist/org.d.ts +18 -2
  27. package/dist/org.js +56 -0
  28. package/dist/plan/agora.d.ts +77 -0
  29. package/dist/plan/agora.js +127 -0
  30. package/dist/plan/calls-matrix.d.ts +135 -0
  31. package/dist/plan/calls-matrix.js +275 -0
  32. package/dist/plan/cloudflared-connector.d.ts +70 -0
  33. package/dist/plan/cloudflared-connector.js +106 -0
  34. package/dist/plan/diff.d.ts +26 -5
  35. package/dist/plan/diff.js +720 -106
  36. package/dist/plan/executor.d.ts +731 -36
  37. package/dist/plan/executor.js +1180 -41
  38. package/dist/plan/index.d.ts +22 -5
  39. package/dist/plan/index.js +12 -2
  40. package/dist/plan/jobs-scheduler-http.d.ts +29 -0
  41. package/dist/plan/jobs-scheduler-http.js +50 -0
  42. package/dist/plan/live.d.ts +51 -2
  43. package/dist/plan/live.js +8 -0
  44. package/dist/plan/plan-apply.d.ts +192 -3
  45. package/dist/plan/plan-apply.js +290 -14
  46. package/dist/plan/platform-diff.d.ts +312 -0
  47. package/dist/plan/platform-diff.js +867 -0
  48. package/dist/plan/platform-org-ports.d.ts +50 -0
  49. package/dist/plan/platform-org-ports.js +70 -0
  50. package/dist/plan/platform-org.d.ts +120 -0
  51. package/dist/plan/platform-org.js +115 -0
  52. package/dist/plan/platform-providers.d.ts +501 -0
  53. package/dist/plan/platform-providers.js +3029 -0
  54. package/dist/plan/platform-sfu.d.ts +93 -0
  55. package/dist/plan/platform-sfu.js +100 -0
  56. package/dist/plan/providers-readers.js +22 -1
  57. package/dist/plan/zitadel-machine-user.d.ts +34 -0
  58. package/dist/plan/zitadel-machine-user.js +95 -0
  59. package/dist/plan.d.ts +6 -0
  60. package/dist/platform.d.ts +55 -0
  61. package/dist/platform.js +46 -0
  62. package/dist/requested-changes.d.ts +108 -0
  63. package/dist/requested-changes.js +95 -0
  64. package/dist/slug.js +21 -2
  65. package/dist/store/audit.js +4 -2
  66. package/dist/store/postgres.d.ts +40 -7
  67. package/dist/store/postgres.js +257 -29
  68. package/dist/store/schema.d.ts +700 -0
  69. package/dist/store/schema.js +58 -0
  70. package/dist/store.d.ts +56 -0
  71. package/package.json +2 -2
@@ -0,0 +1,95 @@
1
+ export const REQUESTABLE_FIELDS = [
2
+ 'calls',
3
+ 'storageLimit',
4
+ 'aiLimit',
5
+ 'domain',
6
+ 'mailTier',
7
+ ];
8
+ /**
9
+ * Thrown by {@link classifyChange} (and by the requested-change store's
10
+ * `create`, which calls it) for a `change.field` outside
11
+ * {@link REQUESTABLE_FIELDS} -- same "throw with a reason" shape
12
+ * `store/postgres.ts`'s `DeclaredOrgError` and `PlatformError` use.
13
+ */
14
+ export class RequestedChangeError extends Error {
15
+ constructor(message) {
16
+ super(message);
17
+ this.name = 'RequestedChangeError';
18
+ }
19
+ }
20
+ /**
21
+ * `org` patched by `change`, scoped to exactly the one field it names --
22
+ * the same `{ ...current, ...patch }` shape `store/postgres.ts`'s `update`
23
+ * uses for a `DeclaredOrg` patch, narrowed to a single field here since a
24
+ * requested change only ever touches one.
25
+ */
26
+ function applyRequestedChange(org, change) {
27
+ switch (change.field) {
28
+ case 'calls':
29
+ return { ...org, calls: { ...org.calls, enabled: change.enabled } };
30
+ case 'storageLimit':
31
+ return {
32
+ ...org,
33
+ storage: { limitBytes: change.limitBytes, limitMicros: change.limitMicros },
34
+ };
35
+ case 'aiLimit':
36
+ return { ...org, ai: { limitMicros: change.limitMicros } };
37
+ case 'domain':
38
+ return { ...org, domain: change.domain };
39
+ case 'mailTier':
40
+ return { ...org, mail: { ...org.mail, mode: change.mode } };
41
+ }
42
+ }
43
+ /**
44
+ * Whether `before` and `after` differ on the org's own billing fields --
45
+ * "take price changes from the org's billing fields" (#178 "How").
46
+ * `DeclaredOrg` carries no price field yet (billing itself is
47
+ * `@wtfalch/billing`, a separate package this one does not depend on), so
48
+ * this always returns `false` today. It exists as the one place
49
+ * `classifyChange` asks the question, so a later billing field lands here
50
+ * rather than reshaping `classifyChange`'s own contract.
51
+ */
52
+ function priceChanges(_before, _after) {
53
+ return false;
54
+ }
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.
62
+ *
63
+ * `plan` is the {@link PlanDetail} `change` causes -- the caller's own diff
64
+ * against live reality, from the same `computePlanDetail` (`plan/diff.ts`)
65
+ * every other apply goes through. Any `plannedSteps` entry marked
66
+ * `destroys` or `migrates` classifies `approval`, whatever `change.field`
67
+ * says: a field-name rule only ever knows what a change usually does, not
68
+ * what it does to this org's actual live state today (#184 "Why"). `plan`
69
+ * is optional because not every caller has live state to diff against yet
70
+ * (`store/postgres.ts`'s `create` classifies from `org` alone); omitting it
71
+ * falls back to the field-name rule below, unchanged from #178.
72
+ *
73
+ * Throws {@link RequestedChangeError} for a `change.field` outside
74
+ * {@link REQUESTABLE_FIELDS} -- "everything else is refused" (#178 "How").
75
+ */
76
+ export function classifyChange(org, change, plan) {
77
+ if (!REQUESTABLE_FIELDS.includes(change.field)) {
78
+ throw new RequestedChangeError(`'${change.field}' is not a requestable field`);
79
+ }
80
+ if (plan?.plannedSteps.some((planned) => planned.step.destroys || planned.step.migrates)) {
81
+ return 'approval';
82
+ }
83
+ const next = applyRequestedChange(org, change);
84
+ if (priceChanges(org, next))
85
+ return 'approval';
86
+ switch (change.field) {
87
+ case 'calls':
88
+ case 'storageLimit':
89
+ case 'aiLimit':
90
+ return 'auto';
91
+ case 'domain':
92
+ case 'mailTier':
93
+ return 'approval';
94
+ }
95
+ }
package/dist/slug.js CHANGED
@@ -32,11 +32,15 @@ const RESERVED_ESTATE_SERVICES = [
32
32
  'keys',
33
33
  'chat',
34
34
  'calls',
35
+ 'calls-sfu',
35
36
  'calendar',
36
37
  'threads',
37
38
  'audit',
38
39
  'authz',
40
+ // Manage was renamed Boule on every surface (#147); 'manage' stays
41
+ // reserved so no org can take it while the old hostname still redirects.
39
42
  'manage',
43
+ 'boule',
40
44
  'operator',
41
45
  'mailbox',
42
46
  'oikos',
@@ -112,6 +116,18 @@ const DNS_LABEL = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
112
116
  const PR_SUFFIX = /-pr-\d+$/;
113
117
  /** `<something>-stage` — the stage-hostname shape (#28). */
114
118
  const STAGE_SUFFIX = /-stage$/;
119
+ /** `<something>-calls` — a level-1 org's own calls service hostname (foundry#145's "Decided", 2026-09-27). */
120
+ const CALLS_SUFFIX = /-calls$/;
121
+ /**
122
+ * `<something>-agora` — a level-1 org's own Agora hostname
123
+ * (`<slug>-agora.oikos.host`, foundry#164, the 2026-09-24 layout,
124
+ * foundry#14). Every org's Agora is always on (#164 "How": no new field on
125
+ * `DeclaredOrg` to opt out of it), so this is not an opt-in reservation the
126
+ * way `CALLS_SUFFIX` guards a feature an org can decline -- any slug ending
127
+ * `-agora` collides with *every* org's own Agora hostname the moment it is
128
+ * taken, not just one that turns a feature on.
129
+ */
130
+ const AGORA_SUFFIX = /-agora$/;
115
131
  /**
116
132
  * Validates a candidate org slug against every rule #28 decided. Returns
117
133
  * `null` when the slug is acceptable, or a {@link SlugRejection} naming
@@ -132,10 +148,13 @@ export function validateSlug(slug) {
132
148
  message: `slug '${slug}' is reserved by the estate (#28) and cannot be taken by an org`,
133
149
  };
134
150
  }
135
- if (STAGE_SUFFIX.test(slug) || PR_SUFFIX.test(slug)) {
151
+ if (STAGE_SUFFIX.test(slug) ||
152
+ PR_SUFFIX.test(slug) ||
153
+ CALLS_SUFFIX.test(slug) ||
154
+ AGORA_SUFFIX.test(slug)) {
136
155
  return {
137
156
  reason: 'environment-suffix',
138
- message: `slug '${slug}' ends with an environment suffix ('-stage' or '-pr-<digits>') reserved for another org's own hostname (#28) — 'acme-stage' is only legal as acme's stage hostname, never as an org slug in its own right`,
157
+ message: `slug '${slug}' ends with an environment suffix ('-stage', '-pr-<digits>', '-calls' or '-agora') reserved for another org's own hostname (#28, #145, #164) — 'acme-calls' is only legal as acme's calls hostname, 'acme-agora' only as acme's Agora hostname, never as an org slug in its own right`,
139
158
  };
140
159
  }
141
160
  return null;
@@ -6,7 +6,7 @@ import { foundryAuditEvents } from './schema.js';
6
6
  */
7
7
  export function createPostgresAuditLog(db) {
8
8
  return {
9
- async recordPlan({ actor, orgId, plan, at }) {
9
+ async recordPlan({ actor, orgId, plan, target, at }) {
10
10
  await db.insert(foundryAuditEvents).values({
11
11
  at,
12
12
  kind: 'plan',
@@ -14,9 +14,10 @@ export function createPostgresAuditLog(db) {
14
14
  actorDisplay: actor.display,
15
15
  orgId,
16
16
  plan,
17
+ target: target ?? null,
17
18
  });
18
19
  },
19
- async recordApply({ actor, orgId, plan, outcome, reason, at }) {
20
+ async recordApply({ actor, orgId, plan, outcome, reason, target, at }) {
20
21
  await db.insert(foundryAuditEvents).values({
21
22
  at,
22
23
  kind: 'apply',
@@ -26,6 +27,7 @@ export function createPostgresAuditLog(db) {
26
27
  outcome,
27
28
  reason: reason ?? null,
28
29
  plan,
30
+ target: target ?? null,
29
31
  });
30
32
  },
31
33
  async recordDestroy({ actor, orgId, outcome, reason, at }) {
@@ -1,5 +1,5 @@
1
1
  import type { PgDatabase, PgQueryResultHKT } from 'drizzle-orm/pg-core';
2
- import type { DeclaredOrgStore } from '../store.js';
2
+ import type { DeclaredOrgStore, PlatformStore, RequestedChangeStore } from '../store.js';
3
3
  /**
4
4
  * The host's own drizzle handle or a transaction open on it, same shape
5
5
  * `@wtfalch/keys`'s `HeldKeysDb` uses: postgres-js in a real host, PGlite in
@@ -14,7 +14,10 @@ export type FoundryStoreDb = PgDatabase<PgQueryResultHKT, any, any>;
14
14
  * environment-suffix shape), a slug already taken by another org (#28:
15
15
  * checked by the unique index `0004_slug_unique.sql` adds, whether caught
16
16
  * ahead of time by a pre-check or by a race this store maps back to the
17
- * same error) or — the one impossible combination #18 implements — a slug
17
+ * same error), a domain that fails `../org.js`'s `validateDomain` or is
18
+ * already taken by another org (#131, the same shape as the slug: format
19
+ * checked ahead of time, uniqueness checked by `0015_domain_unique.sql`'s
20
+ * index) or — the one impossible combination #18 implements — a slug
18
21
  * change once the org has mail. `store.ts`'s `update` doc requires a caller
19
22
  * to be "ready for this to throw rather than silently drop a field"; this
20
23
  * is that throw.
@@ -36,10 +39,40 @@ export declare class DeclaredOrgError extends Error {
36
39
  * (or `null` for uncapped), checked the same way on both `create` and the
37
40
  * merged result of `update`. #67 adds a fourth (`org.ts`'s
38
41
  * `validateBuyerIdentity`) and #71 a fifth (`org.ts`'s `validateHosting`),
39
- * checked the same way. The broader "combinations foundry knows are
40
- * impossible are rejected with a reason" line needs a ruleset (domain
41
- * requirements per service, `adoptedApp.form`, mail-level transitions…)
42
- * that later tickets, not #18, #27 or #28, decide; `update` still merges and
43
- * stores any other patch as given.
42
+ * checked the same way. #131 adds a sixth, in the same shape as the slug: the
43
+ * domain, when set, is a validated (`org.ts`'s `validateDomain`), globally
44
+ * unique DNS name, checked on both `create` and `update`. The broader
45
+ * "combinations foundry knows are impossible are rejected with a reason"
46
+ * line needs a ruleset (domain requirements per service, `adoptedApp.form`,
47
+ * mail-level transitions…) that later tickets, not #18, #27, #28 or #131,
48
+ * decide; `update` still merges and stores any other patch as given.
44
49
  */
45
50
  export declare function createPostgresDeclaredOrgStore(db: FoundryStoreDb): DeclaredOrgStore;
51
+ /**
52
+ * Thrown by `put` when `validatePlatform` (`../platform.js`) refuses the
53
+ * platform given to it -- same "throw with a reason" shape
54
+ * {@link DeclaredOrgError} gives `create`/`update`.
55
+ */
56
+ export declare class PlatformError extends Error {
57
+ constructor(message: string);
58
+ }
59
+ /**
60
+ * `PlatformStore` (`../store.ts`) on Postgres, for #166: get and put the one
61
+ * {@link DeclaredPlatform}, beside `createPostgresDeclaredOrgStore` above.
62
+ * `put` validates with `../platform.js`'s `validatePlatform` and then
63
+ * upserts onto the fixed singleton row `migrations/0016_platform.sql`
64
+ * declares -- the first call creates it, every later call replaces it, and
65
+ * the table's own `check (id)` constraint is what actually refuses a second
66
+ * row, not this function.
67
+ */
68
+ export declare function createPostgresPlatformStore(db: FoundryStoreDb): PlatformStore;
69
+ /**
70
+ * `RequestedChangeStore` (`../store.ts`) on Postgres, for #178: classify and
71
+ * store a company's own requested setting change on its declared org, beside
72
+ * `createPostgresDeclaredOrgStore` and `createPostgresPlatformStore` above.
73
+ * `create` classifies with `../requested-changes.js`'s `classifyChange`
74
+ * against the `org` given to it -- this store never loads the org itself,
75
+ * the same "caller already has it" choice `PlatformStore.put`'s `platform`
76
+ * argument makes -- and stores the result as the request's initial `state`.
77
+ */
78
+ export declare function createPostgresRequestedChangeStore(db: FoundryStoreDb): RequestedChangeStore;
@@ -1,25 +1,36 @@
1
- import { eq, sql } from 'drizzle-orm';
2
- import { validateBuyerIdentity, validateHosting, validateOwner, validateSite } from '../org.js';
1
+ import { asc, eq, sql } from 'drizzle-orm';
2
+ import { validateBuyerIdentity, validateDomain, validateHosting, validateOwner, validateSite, } from '../org.js';
3
+ import { validatePlatform } from '../platform.js';
4
+ import { RequestedChangeError, classifyChange } from '../requested-changes.js';
3
5
  import { validateSlug } from '../slug.js';
4
- import { declaredOrgs, foundryAppliedState, } from './schema.js';
6
+ import { declaredOrgs, foundryAppliedState, foundryDeclaredPlatform, foundryRequestedChanges, } from './schema.js';
5
7
  const UNIQUE_VIOLATION = '23505';
8
+ /** `migrations/0004_slug_unique.sql` / `migrations/0015_domain_unique.sql`'s index names, read back off a caught unique-violation to tell which one fired. */
9
+ const SLUG_UNIQUE_INDEX = 'foundry_declared_orgs_slug_idx';
10
+ const DOMAIN_UNIQUE_INDEX = 'foundry_declared_orgs_domain_idx';
6
11
  /** #27: a bigint-safe decimal string of a non-negative integer -- no sign, no decimal point, no leading zeros (so "010" never diffs against a live "10"). */
7
12
  const NON_NEGATIVE_INTEGER = /^(0|[1-9]\d*)$/;
8
13
  /**
9
- * True for a Postgres unique-violation, however the driver hands it back --
10
- * directly (PGlite in this package's own tests) or wrapped in drizzle-orm's
11
- * own `DrizzleQueryError`, whose `cause` carries the raw driver error
12
- * (postgres-js in a host). Checked by Postgres error code, never by message
13
- * text, since message text is not a contract. Same pattern as
14
+ * The Postgres unique-violation constraint name, however the driver hands the
15
+ * error back -- directly (PGlite in this package's own tests) or wrapped in
16
+ * drizzle-orm's own `DrizzleQueryError`, whose `cause` carries the raw driver
17
+ * error (postgres-js in a host) -- or `undefined` when this is not a unique
18
+ * violation. Checked by Postgres error code, never by message text, since
19
+ * message text is not a contract. Same code-check pattern as
14
20
  * `../runner/runner.ts`'s own `isUniqueViolation` -- kept as a local copy
15
- * here rather than shared, since neither module depends on the other.
21
+ * here rather than shared, since neither module depends on the other; this
22
+ * one additionally reads `constraint` so `create`/`update` can tell a slug
23
+ * collision from a domain collision (#131) rather than reporting both the
24
+ * same way.
16
25
  */
17
- function isUniqueViolation(error) {
18
- const code = error?.code;
19
- if (code === UNIQUE_VIOLATION)
20
- return true;
26
+ function uniqueViolationConstraint(error) {
27
+ const direct = error;
28
+ if (direct?.code === UNIQUE_VIOLATION)
29
+ return direct.constraint;
21
30
  const cause = error?.cause;
22
- return cause?.code === UNIQUE_VIOLATION;
31
+ if (cause?.code === UNIQUE_VIOLATION)
32
+ return cause.constraint;
33
+ return undefined;
23
34
  }
24
35
  /**
25
36
  * Thrown by `create` and `update` when the store refuses a write: an id
@@ -28,7 +39,10 @@ function isUniqueViolation(error) {
28
39
  * environment-suffix shape), a slug already taken by another org (#28:
29
40
  * checked by the unique index `0004_slug_unique.sql` adds, whether caught
30
41
  * ahead of time by a pre-check or by a race this store maps back to the
31
- * same error) or — the one impossible combination #18 implements — a slug
42
+ * same error), a domain that fails `../org.js`'s `validateDomain` or is
43
+ * already taken by another org (#131, the same shape as the slug: format
44
+ * checked ahead of time, uniqueness checked by `0015_domain_unique.sql`'s
45
+ * index) or — the one impossible combination #18 implements — a slug
32
46
  * change once the org has mail. `store.ts`'s `update` doc requires a caller
33
47
  * to be "ready for this to throw rather than silently drop a field"; this
34
48
  * is that throw.
@@ -47,7 +61,7 @@ function rowToOrg(row) {
47
61
  domain: row.domain,
48
62
  mail: { mode: row.mailMode, usesDomain: row.mailUsesDomain },
49
63
  chat: { usesDomain: row.chatUsesDomain },
50
- calls: { usesDomain: row.callsUsesDomain },
64
+ calls: { enabled: row.callsEnabled, usesDomain: row.callsUsesDomain },
51
65
  issuer: { usesDomain: row.issuerUsesDomain },
52
66
  site: { enabled: row.siteEnabled, usesDomain: row.siteUsesDomain },
53
67
  hosting: {
@@ -83,6 +97,7 @@ function orgToRow(org) {
83
97
  mailMode: org.mail.mode,
84
98
  mailUsesDomain: org.mail.usesDomain,
85
99
  chatUsesDomain: org.chat.usesDomain,
100
+ callsEnabled: org.calls.enabled,
86
101
  callsUsesDomain: org.calls.usesDomain,
87
102
  issuerUsesDomain: org.issuer.usesDomain,
88
103
  siteEnabled: org.site.enabled,
@@ -166,11 +181,13 @@ function rowToOwner(row) {
166
181
  * (or `null` for uncapped), checked the same way on both `create` and the
167
182
  * merged result of `update`. #67 adds a fourth (`org.ts`'s
168
183
  * `validateBuyerIdentity`) and #71 a fifth (`org.ts`'s `validateHosting`),
169
- * checked the same way. The broader "combinations foundry knows are
170
- * impossible are rejected with a reason" line needs a ruleset (domain
171
- * requirements per service, `adoptedApp.form`, mail-level transitions…)
172
- * that later tickets, not #18, #27 or #28, decide; `update` still merges and
173
- * stores any other patch as given.
184
+ * checked the same way. #131 adds a sixth, in the same shape as the slug: the
185
+ * domain, when set, is a validated (`org.ts`'s `validateDomain`), globally
186
+ * unique DNS name, checked on both `create` and `update`. The broader
187
+ * "combinations foundry knows are impossible are rejected with a reason"
188
+ * line needs a ruleset (domain requirements per service, `adoptedApp.form`,
189
+ * mail-level transitions…) that later tickets, not #18, #27, #28 or #131,
190
+ * decide; `update` still merges and stores any other patch as given.
174
191
  */
175
192
  export function createPostgresDeclaredOrgStore(db) {
176
193
  async function requireRow(id) {
@@ -184,6 +201,18 @@ export function createPostgresDeclaredOrgStore(db) {
184
201
  if (rejection)
185
202
  throw new DeclaredOrgError(rejection.message);
186
203
  }
204
+ // #131: same place `requireValidSlug` checks the slug's format (#28) --
205
+ // `domain` is nullable (a brand new org has none), so this is only called
206
+ // once it is set. Uniqueness itself is not pre-checked here, the same
207
+ // choice `requireValidSlug` makes for the slug: the unique index
208
+ // `0015_domain_unique.sql` adds is the source of truth, caught below.
209
+ function requireValidDomain(domain) {
210
+ if (domain === null)
211
+ return;
212
+ const reason = validateDomain(domain);
213
+ if (reason)
214
+ throw new DeclaredOrgError(`domain invalid: ${reason} (#131)`);
215
+ }
187
216
  function requireValidLimit(fieldName, value) {
188
217
  if (value !== null && !NON_NEGATIVE_INTEGER.test(value)) {
189
218
  throw new DeclaredOrgError(`${fieldName} '${value}' must be a bigint-safe decimal string of a non-negative integer, or null for uncapped (#27)`);
@@ -230,6 +259,7 @@ export function createPostgresDeclaredOrgStore(db) {
230
259
  return {
231
260
  async create(org) {
232
261
  requireValidSlug(org.slug);
262
+ requireValidDomain(org.domain);
233
263
  requireValidLimits(org);
234
264
  requireValidBuyer(org);
235
265
  requireValidHosting(org);
@@ -239,13 +269,19 @@ export function createPostgresDeclaredOrgStore(db) {
239
269
  await db.insert(declaredOrgs).values(orgToRow(org));
240
270
  }
241
271
  catch (error) {
242
- // The pre-check above already refuses a slug this process knows is
243
- // taken (nothing does, on a fresh insert) -- this catches the race
244
- // the pre-check cannot: two concurrent creates for the same slug,
245
- // caught by `0004_slug_unique.sql`'s unique index and reported
246
- // through the same error a pre-check would have thrown (#28).
247
- if (isUniqueViolation(error))
272
+ // The pre-checks above already refuse a slug or domain this process
273
+ // knows is taken (nothing does, on a fresh insert) -- this catches
274
+ // the race they cannot: two concurrent creates for the same slug or
275
+ // domain, caught by `0004_slug_unique.sql`/`0015_domain_unique.sql`'s
276
+ // unique indexes and reported through the same error a pre-check
277
+ // would have thrown (#28, #131).
278
+ const constraint = uniqueViolationConstraint(error);
279
+ if (constraint === DOMAIN_UNIQUE_INDEX) {
280
+ throw new DeclaredOrgError(`domain '${org.domain}' is already taken by another org (#131)`);
281
+ }
282
+ if (constraint === SLUG_UNIQUE_INDEX || constraint !== undefined) {
248
283
  throw new DeclaredOrgError(`slug '${org.slug}' is already taken by another org`);
284
+ }
249
285
  throw error;
250
286
  }
251
287
  },
@@ -261,6 +297,23 @@ export function createPostgresDeclaredOrgStore(db) {
261
297
  .limit(1);
262
298
  return row !== undefined;
263
299
  },
300
+ // #131: a plain scan, not an indexed lookup -- `serviceIds` is jsonb
301
+ // keyed by resource, not by provider id, and foundry's own org count is
302
+ // small enough that this never needs to be more than that (same
303
+ // reasoning `getAppliedState`/`putAppliedState` already lean on: one row
304
+ // per org).
305
+ async findOrgIdForZone(zoneId) {
306
+ const rows = await db
307
+ .select({ orgId: foundryAppliedState.orgId, serviceIds: foundryAppliedState.serviceIds })
308
+ .from(foundryAppliedState);
309
+ for (const row of rows) {
310
+ for (const [key, id] of Object.entries(row.serviceIds)) {
311
+ if (id === zoneId && key.startsWith('cloudflare:zone:'))
312
+ return row.orgId;
313
+ }
314
+ }
315
+ return null;
316
+ },
264
317
  async update(id, patch) {
265
318
  const current = rowToOrg(await requireRow(id));
266
319
  // The slug freeze (#7 corrections 3): once mail exists, the mail
@@ -279,6 +332,7 @@ export function createPostgresDeclaredOrgStore(db) {
279
332
  if (patch.slug !== undefined && patch.slug !== current.slug)
280
333
  requireValidSlug(patch.slug);
281
334
  const next = { ...current, ...patch, id: current.id };
335
+ requireValidDomain(next.domain);
282
336
  requireValidLimits(next);
283
337
  requireValidBuyer(next);
284
338
  requireValidHosting(next);
@@ -292,9 +346,15 @@ export function createPostgresDeclaredOrgStore(db) {
292
346
  }
293
347
  catch (error) {
294
348
  // Same race as `create`: another org's concurrent write took this
295
- // slug between the pre-check above and this statement (#28).
296
- if (isUniqueViolation(error))
349
+ // slug or domain between the pre-check above and this statement
350
+ // (#28, #131).
351
+ const constraint = uniqueViolationConstraint(error);
352
+ if (constraint === DOMAIN_UNIQUE_INDEX) {
353
+ throw new DeclaredOrgError(`domain '${next.domain}' is already taken by another org (#131)`);
354
+ }
355
+ if (constraint === SLUG_UNIQUE_INDEX || constraint !== undefined) {
297
356
  throw new DeclaredOrgError(`slug '${next.slug}' is already taken by another org`);
357
+ }
298
358
  throw error;
299
359
  }
300
360
  return next;
@@ -347,3 +407,171 @@ export function createPostgresDeclaredOrgStore(db) {
347
407
  },
348
408
  };
349
409
  }
410
+ /**
411
+ * Thrown by `put` when `validatePlatform` (`../platform.js`) refuses the
412
+ * platform given to it -- same "throw with a reason" shape
413
+ * {@link DeclaredOrgError} gives `create`/`update`.
414
+ */
415
+ export class PlatformError extends Error {
416
+ constructor(message) {
417
+ super(message);
418
+ this.name = 'PlatformError';
419
+ }
420
+ }
421
+ /** `migrations/0016_platform.sql`'s singleton row -- `id` is always `true`, the only value its `check (id)` constraint allows. */
422
+ const PLATFORM_ROW_ID = true;
423
+ function rowToPlatform(row) {
424
+ return {
425
+ ownerOrgId: row.ownerOrgId,
426
+ apex: row.apex,
427
+ auth: { enabled: row.authEnabled, hostname: row.authHostname, gitRef: row.authGitRef },
428
+ mail: { enabled: row.mailEnabled, hostname: row.mailHostname, gitRef: row.mailGitRef },
429
+ sfu: { enabled: row.sfuEnabled, hostname: row.sfuHostname, gitRef: row.sfuGitRef },
430
+ files: { enabled: row.filesEnabled, hostname: row.filesHostname, gitRef: row.filesGitRef },
431
+ ai: { enabled: row.aiEnabled, hostname: row.aiHostname, gitRef: row.aiGitRef },
432
+ jobs: { enabled: row.jobsEnabled, hostname: row.jobsHostname, gitRef: row.jobsGitRef },
433
+ integrations: {
434
+ enabled: row.integrationsEnabled,
435
+ hostname: row.integrationsHostname,
436
+ gitRef: row.integrationsGitRef,
437
+ },
438
+ archon: {
439
+ enabled: row.archonEnabled,
440
+ hostname: row.archonHostname,
441
+ gitRef: row.archonGitRef,
442
+ },
443
+ };
444
+ }
445
+ function platformToRow(platform) {
446
+ return {
447
+ id: PLATFORM_ROW_ID,
448
+ ownerOrgId: platform.ownerOrgId,
449
+ apex: platform.apex,
450
+ authEnabled: platform.auth.enabled,
451
+ authHostname: platform.auth.hostname,
452
+ authGitRef: platform.auth.gitRef,
453
+ mailEnabled: platform.mail.enabled,
454
+ mailHostname: platform.mail.hostname,
455
+ mailGitRef: platform.mail.gitRef,
456
+ sfuEnabled: platform.sfu.enabled,
457
+ sfuHostname: platform.sfu.hostname,
458
+ sfuGitRef: platform.sfu.gitRef,
459
+ filesEnabled: platform.files.enabled,
460
+ filesHostname: platform.files.hostname,
461
+ filesGitRef: platform.files.gitRef,
462
+ aiEnabled: platform.ai.enabled,
463
+ aiHostname: platform.ai.hostname,
464
+ aiGitRef: platform.ai.gitRef,
465
+ jobsEnabled: platform.jobs.enabled,
466
+ jobsHostname: platform.jobs.hostname,
467
+ jobsGitRef: platform.jobs.gitRef,
468
+ integrationsEnabled: platform.integrations.enabled,
469
+ integrationsHostname: platform.integrations.hostname,
470
+ integrationsGitRef: platform.integrations.gitRef,
471
+ archonEnabled: platform.archon.enabled,
472
+ archonHostname: platform.archon.hostname,
473
+ archonGitRef: platform.archon.gitRef,
474
+ };
475
+ }
476
+ /**
477
+ * `PlatformStore` (`../store.ts`) on Postgres, for #166: get and put the one
478
+ * {@link DeclaredPlatform}, beside `createPostgresDeclaredOrgStore` above.
479
+ * `put` validates with `../platform.js`'s `validatePlatform` and then
480
+ * upserts onto the fixed singleton row `migrations/0016_platform.sql`
481
+ * declares -- the first call creates it, every later call replaces it, and
482
+ * the table's own `check (id)` constraint is what actually refuses a second
483
+ * row, not this function.
484
+ */
485
+ export function createPostgresPlatformStore(db) {
486
+ return {
487
+ async get() {
488
+ const [row] = await db.select().from(foundryDeclaredPlatform).limit(1);
489
+ return row ? rowToPlatform(row) : null;
490
+ },
491
+ async put(platform) {
492
+ const reason = validatePlatform(platform);
493
+ if (reason)
494
+ throw new PlatformError(reason);
495
+ const row = platformToRow(platform);
496
+ await db
497
+ .insert(foundryDeclaredPlatform)
498
+ .values(row)
499
+ .onConflictDoUpdate({ target: foundryDeclaredPlatform.id, set: row });
500
+ },
501
+ async getServiceIds() {
502
+ const [row] = await db
503
+ .select({ serviceIds: foundryDeclaredPlatform.serviceIds })
504
+ .from(foundryDeclaredPlatform)
505
+ .limit(1);
506
+ return row?.serviceIds ?? {};
507
+ },
508
+ async putServiceIds(serviceIds) {
509
+ await db.update(foundryDeclaredPlatform).set({ serviceIds });
510
+ },
511
+ };
512
+ }
513
+ /**
514
+ * `value`'s own field-specific payload, stripped of its `field` -- that is
515
+ * a column of its own (`foundryRequestedChanges.field`), not repeated
516
+ * inside the jsonb.
517
+ */
518
+ function requestedChangePayload(value) {
519
+ const { field, ...payload } = value;
520
+ return payload;
521
+ }
522
+ function rowToRequestedChange(row) {
523
+ return {
524
+ id: row.id,
525
+ orgId: row.orgId,
526
+ value: { field: row.field, ...row.value },
527
+ requestedBy: row.requestedBy,
528
+ createdAt: row.createdAt.toISOString(),
529
+ state: row.state,
530
+ };
531
+ }
532
+ /**
533
+ * `RequestedChangeStore` (`../store.ts`) on Postgres, for #178: classify and
534
+ * store a company's own requested setting change on its declared org, beside
535
+ * `createPostgresDeclaredOrgStore` and `createPostgresPlatformStore` above.
536
+ * `create` classifies with `../requested-changes.js`'s `classifyChange`
537
+ * against the `org` given to it -- this store never loads the org itself,
538
+ * the same "caller already has it" choice `PlatformStore.put`'s `platform`
539
+ * argument makes -- and stores the result as the request's initial `state`.
540
+ */
541
+ export function createPostgresRequestedChangeStore(db) {
542
+ return {
543
+ async create({ orgId, org, value, requestedBy }) {
544
+ const classification = classifyChange(org, value);
545
+ const state = classification === 'auto' ? 'auto' : 'needs_approval';
546
+ const [row] = await db
547
+ .insert(foundryRequestedChanges)
548
+ .values({
549
+ orgId,
550
+ field: value.field,
551
+ value: requestedChangePayload(value),
552
+ requestedBy,
553
+ state,
554
+ })
555
+ .returning();
556
+ if (!row)
557
+ throw new RequestedChangeError('insert returned no row');
558
+ return rowToRequestedChange(row);
559
+ },
560
+ async get(id) {
561
+ const [row] = await db
562
+ .select()
563
+ .from(foundryRequestedChanges)
564
+ .where(eq(foundryRequestedChanges.id, id))
565
+ .limit(1);
566
+ return row ? rowToRequestedChange(row) : null;
567
+ },
568
+ async listForOrg(orgId) {
569
+ const rows = await db
570
+ .select()
571
+ .from(foundryRequestedChanges)
572
+ .where(eq(foundryRequestedChanges.orgId, orgId))
573
+ .orderBy(asc(foundryRequestedChanges.createdAt));
574
+ return rows.map(rowToRequestedChange);
575
+ },
576
+ };
577
+ }