@wtfalch/foundry 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,43 @@
1
+ import { foundryAuditEvents } from './schema.js';
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.
6
+ */
7
+ export function createPostgresAuditLog(db) {
8
+ return {
9
+ async recordPlan({ actor, orgId, plan, 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
+ });
18
+ },
19
+ async recordApply({ actor, orgId, plan, outcome, reason, at }) {
20
+ await db.insert(foundryAuditEvents).values({
21
+ at,
22
+ kind: 'apply',
23
+ actorId: actor.id,
24
+ actorDisplay: actor.display,
25
+ orgId,
26
+ outcome,
27
+ reason: reason ?? null,
28
+ plan,
29
+ });
30
+ },
31
+ async recordDestroy({ actor, orgId, outcome, reason, at }) {
32
+ await db.insert(foundryAuditEvents).values({
33
+ at,
34
+ kind: 'destroy',
35
+ actorId: actor.id,
36
+ actorDisplay: actor.display,
37
+ orgId,
38
+ outcome,
39
+ reason: reason ?? null,
40
+ });
41
+ },
42
+ };
43
+ }
@@ -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,12 @@ 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;
@@ -1,25 +1,34 @@
1
1
  import { eq, sql } from 'drizzle-orm';
2
- import { validateBuyerIdentity, validateHosting, validateOwner } from '../org.js';
2
+ import { validateBuyerIdentity, validateDomain, validateHosting, validateOwner, validateSite, } from '../org.js';
3
3
  import { validateSlug } from '../slug.js';
4
4
  import { declaredOrgs, foundryAppliedState, } from './schema.js';
5
5
  const UNIQUE_VIOLATION = '23505';
6
+ /** `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. */
7
+ const SLUG_UNIQUE_INDEX = 'foundry_declared_orgs_slug_idx';
8
+ const DOMAIN_UNIQUE_INDEX = 'foundry_declared_orgs_domain_idx';
6
9
  /** #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
10
  const NON_NEGATIVE_INTEGER = /^(0|[1-9]\d*)$/;
8
11
  /**
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
12
+ * The Postgres unique-violation constraint name, however the driver hands the
13
+ * error back -- directly (PGlite in this package's own tests) or wrapped in
14
+ * drizzle-orm's own `DrizzleQueryError`, whose `cause` carries the raw driver
15
+ * error (postgres-js in a host) -- or `undefined` when this is not a unique
16
+ * violation. Checked by Postgres error code, never by message text, since
17
+ * message text is not a contract. Same code-check pattern as
14
18
  * `../runner/runner.ts`'s own `isUniqueViolation` -- kept as a local copy
15
- * here rather than shared, since neither module depends on the other.
19
+ * here rather than shared, since neither module depends on the other; this
20
+ * one additionally reads `constraint` so `create`/`update` can tell a slug
21
+ * collision from a domain collision (#131) rather than reporting both the
22
+ * same way.
16
23
  */
17
- function isUniqueViolation(error) {
18
- const code = error?.code;
19
- if (code === UNIQUE_VIOLATION)
20
- return true;
24
+ function uniqueViolationConstraint(error) {
25
+ const direct = error;
26
+ if (direct?.code === UNIQUE_VIOLATION)
27
+ return direct.constraint;
21
28
  const cause = error?.cause;
22
- return cause?.code === UNIQUE_VIOLATION;
29
+ if (cause?.code === UNIQUE_VIOLATION)
30
+ return cause.constraint;
31
+ return undefined;
23
32
  }
24
33
  /**
25
34
  * Thrown by `create` and `update` when the store refuses a write: an id
@@ -28,7 +37,10 @@ function isUniqueViolation(error) {
28
37
  * environment-suffix shape), a slug already taken by another org (#28:
29
38
  * checked by the unique index `0004_slug_unique.sql` adds, whether caught
30
39
  * 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
40
+ * same error), a domain that fails `../org.js`'s `validateDomain` or is
41
+ * already taken by another org (#131, the same shape as the slug: format
42
+ * checked ahead of time, uniqueness checked by `0015_domain_unique.sql`'s
43
+ * index) or — the one impossible combination #18 implements — a slug
32
44
  * change once the org has mail. `store.ts`'s `update` doc requires a caller
33
45
  * to be "ready for this to throw rather than silently drop a field"; this
34
46
  * is that throw.
@@ -47,7 +59,7 @@ function rowToOrg(row) {
47
59
  domain: row.domain,
48
60
  mail: { mode: row.mailMode, usesDomain: row.mailUsesDomain },
49
61
  chat: { usesDomain: row.chatUsesDomain },
50
- calls: { usesDomain: row.callsUsesDomain },
62
+ calls: { enabled: row.callsEnabled, usesDomain: row.callsUsesDomain },
51
63
  issuer: { usesDomain: row.issuerUsesDomain },
52
64
  site: { enabled: row.siteEnabled, usesDomain: row.siteUsesDomain },
53
65
  hosting: {
@@ -58,6 +70,7 @@ function rowToOrg(row) {
58
70
  gitBranch: row.hostingGitBranch,
59
71
  buildPack: row.hostingBuildPack,
60
72
  issuerApps: row.hostingIssuerApps,
73
+ accessEmails: row.hostingAccessEmails,
61
74
  },
62
75
  sso: { enabled: row.ssoEnabled },
63
76
  guests: { enabled: row.guestsEnabled },
@@ -82,6 +95,7 @@ function orgToRow(org) {
82
95
  mailMode: org.mail.mode,
83
96
  mailUsesDomain: org.mail.usesDomain,
84
97
  chatUsesDomain: org.chat.usesDomain,
98
+ callsEnabled: org.calls.enabled,
85
99
  callsUsesDomain: org.calls.usesDomain,
86
100
  issuerUsesDomain: org.issuer.usesDomain,
87
101
  siteEnabled: org.site.enabled,
@@ -93,6 +107,7 @@ function orgToRow(org) {
93
107
  hostingGitBranch: org.hosting.gitBranch,
94
108
  hostingBuildPack: org.hosting.buildPack,
95
109
  hostingIssuerApps: org.hosting.issuerApps,
110
+ hostingAccessEmails: org.hosting.accessEmails,
96
111
  ssoEnabled: org.sso.enabled,
97
112
  guestsEnabled: org.guests.enabled,
98
113
  soloEnabled: org.solo.enabled,
@@ -164,11 +179,13 @@ function rowToOwner(row) {
164
179
  * (or `null` for uncapped), checked the same way on both `create` and the
165
180
  * merged result of `update`. #67 adds a fourth (`org.ts`'s
166
181
  * `validateBuyerIdentity`) and #71 a fifth (`org.ts`'s `validateHosting`),
167
- * checked the same way. The broader "combinations foundry knows are
168
- * impossible are rejected with a reason" line needs a ruleset (domain
169
- * requirements per service, `adoptedApp.form`, mail-level transitions…)
170
- * that later tickets, not #18, #27 or #28, decide; `update` still merges and
171
- * stores any other patch as given.
182
+ * checked the same way. #131 adds a sixth, in the same shape as the slug: the
183
+ * domain, when set, is a validated (`org.ts`'s `validateDomain`), globally
184
+ * unique DNS name, checked on both `create` and `update`. The broader
185
+ * "combinations foundry knows are impossible are rejected with a reason"
186
+ * line needs a ruleset (domain requirements per service, `adoptedApp.form`,
187
+ * mail-level transitions…) that later tickets, not #18, #27, #28 or #131,
188
+ * decide; `update` still merges and stores any other patch as given.
172
189
  */
173
190
  export function createPostgresDeclaredOrgStore(db) {
174
191
  async function requireRow(id) {
@@ -182,6 +199,18 @@ export function createPostgresDeclaredOrgStore(db) {
182
199
  if (rejection)
183
200
  throw new DeclaredOrgError(rejection.message);
184
201
  }
202
+ // #131: same place `requireValidSlug` checks the slug's format (#28) --
203
+ // `domain` is nullable (a brand new org has none), so this is only called
204
+ // once it is set. Uniqueness itself is not pre-checked here, the same
205
+ // choice `requireValidSlug` makes for the slug: the unique index
206
+ // `0015_domain_unique.sql` adds is the source of truth, caught below.
207
+ function requireValidDomain(domain) {
208
+ if (domain === null)
209
+ return;
210
+ const reason = validateDomain(domain);
211
+ if (reason)
212
+ throw new DeclaredOrgError(`domain invalid: ${reason} (#131)`);
213
+ }
185
214
  function requireValidLimit(fieldName, value) {
186
215
  if (value !== null && !NON_NEGATIVE_INTEGER.test(value)) {
187
216
  throw new DeclaredOrgError(`${fieldName} '${value}' must be a bigint-safe decimal string of a non-negative integer, or null for uncapped (#27)`);
@@ -211,6 +240,13 @@ export function createPostgresDeclaredOrgStore(db) {
211
240
  if (reason)
212
241
  throw new DeclaredOrgError(`hosting invalid: ${reason} (#71)`);
213
242
  }
243
+ // #101: same place `requireValidHosting` checks hosting, on both `create`
244
+ // and the merged result of `update`.
245
+ function requireValidSite(org) {
246
+ const reason = validateSite(org);
247
+ if (reason)
248
+ throw new DeclaredOrgError(`site invalid: ${reason} (#101)`);
249
+ }
214
250
  // Same place `requireValidHosting` checks hosting, on both `create` and
215
251
  // the merged result of `update`.
216
252
  function requireValidOwner(org) {
@@ -221,21 +257,29 @@ export function createPostgresDeclaredOrgStore(db) {
221
257
  return {
222
258
  async create(org) {
223
259
  requireValidSlug(org.slug);
260
+ requireValidDomain(org.domain);
224
261
  requireValidLimits(org);
225
262
  requireValidBuyer(org);
226
263
  requireValidHosting(org);
264
+ requireValidSite(org);
227
265
  requireValidOwner(org);
228
266
  try {
229
267
  await db.insert(declaredOrgs).values(orgToRow(org));
230
268
  }
231
269
  catch (error) {
232
- // The pre-check above already refuses a slug this process knows is
233
- // taken (nothing does, on a fresh insert) -- this catches the race
234
- // the pre-check cannot: two concurrent creates for the same slug,
235
- // caught by `0004_slug_unique.sql`'s unique index and reported
236
- // through the same error a pre-check would have thrown (#28).
237
- if (isUniqueViolation(error))
270
+ // The pre-checks above already refuse a slug or domain this process
271
+ // knows is taken (nothing does, on a fresh insert) -- this catches
272
+ // the race they cannot: two concurrent creates for the same slug or
273
+ // domain, caught by `0004_slug_unique.sql`/`0015_domain_unique.sql`'s
274
+ // unique indexes and reported through the same error a pre-check
275
+ // would have thrown (#28, #131).
276
+ const constraint = uniqueViolationConstraint(error);
277
+ if (constraint === DOMAIN_UNIQUE_INDEX) {
278
+ throw new DeclaredOrgError(`domain '${org.domain}' is already taken by another org (#131)`);
279
+ }
280
+ if (constraint === SLUG_UNIQUE_INDEX || constraint !== undefined) {
238
281
  throw new DeclaredOrgError(`slug '${org.slug}' is already taken by another org`);
282
+ }
239
283
  throw error;
240
284
  }
241
285
  },
@@ -243,6 +287,31 @@ export function createPostgresDeclaredOrgStore(db) {
243
287
  const [row] = await db.select().from(declaredOrgs).where(eq(declaredOrgs.id, id)).limit(1);
244
288
  return row ? rowToOrg(row) : null;
245
289
  },
290
+ async slugTaken(slug) {
291
+ const [row] = await db
292
+ .select({ id: declaredOrgs.id })
293
+ .from(declaredOrgs)
294
+ .where(eq(declaredOrgs.slug, slug))
295
+ .limit(1);
296
+ return row !== undefined;
297
+ },
298
+ // #131: a plain scan, not an indexed lookup -- `serviceIds` is jsonb
299
+ // keyed by resource, not by provider id, and foundry's own org count is
300
+ // small enough that this never needs to be more than that (same
301
+ // reasoning `getAppliedState`/`putAppliedState` already lean on: one row
302
+ // per org).
303
+ async findOrgIdForZone(zoneId) {
304
+ const rows = await db
305
+ .select({ orgId: foundryAppliedState.orgId, serviceIds: foundryAppliedState.serviceIds })
306
+ .from(foundryAppliedState);
307
+ for (const row of rows) {
308
+ for (const [key, id] of Object.entries(row.serviceIds)) {
309
+ if (id === zoneId && key.startsWith('cloudflare:zone:'))
310
+ return row.orgId;
311
+ }
312
+ }
313
+ return null;
314
+ },
246
315
  async update(id, patch) {
247
316
  const current = rowToOrg(await requireRow(id));
248
317
  // The slug freeze (#7 corrections 3): once mail exists, the mail
@@ -261,9 +330,11 @@ export function createPostgresDeclaredOrgStore(db) {
261
330
  if (patch.slug !== undefined && patch.slug !== current.slug)
262
331
  requireValidSlug(patch.slug);
263
332
  const next = { ...current, ...patch, id: current.id };
333
+ requireValidDomain(next.domain);
264
334
  requireValidLimits(next);
265
335
  requireValidBuyer(next);
266
336
  requireValidHosting(next);
337
+ requireValidSite(next);
267
338
  requireValidOwner(next);
268
339
  try {
269
340
  await db
@@ -273,9 +344,15 @@ export function createPostgresDeclaredOrgStore(db) {
273
344
  }
274
345
  catch (error) {
275
346
  // Same race as `create`: another org's concurrent write took this
276
- // slug between the pre-check above and this statement (#28).
277
- if (isUniqueViolation(error))
347
+ // slug or domain between the pre-check above and this statement
348
+ // (#28, #131).
349
+ const constraint = uniqueViolationConstraint(error);
350
+ if (constraint === DOMAIN_UNIQUE_INDEX) {
351
+ throw new DeclaredOrgError(`domain '${next.domain}' is already taken by another org (#131)`);
352
+ }
353
+ if (constraint === SLUG_UNIQUE_INDEX || constraint !== undefined) {
278
354
  throw new DeclaredOrgError(`slug '${next.slug}' is already taken by another org`);
355
+ }
279
356
  throw error;
280
357
  }
281
358
  return next;
@@ -1,4 +1,5 @@
1
1
  import type { IssuerApp } from '../org.js';
2
+ import type { Plan } from '../plan.js';
2
3
  import type { Checkpoint } from '../state.js';
3
4
  /**
4
5
  * Mirrors `migrations/0001_declared_org.sql`, which is the source of truth:
@@ -129,6 +130,23 @@ export declare const declaredOrgs: import("drizzle-orm/pg-core").PgTableWithColu
129
130
  identity: undefined;
130
131
  generated: undefined;
131
132
  }, {}, {}>;
133
+ callsEnabled: import("drizzle-orm/pg-core").PgColumn<{
134
+ name: "calls_enabled";
135
+ tableName: "foundry_declared_orgs";
136
+ dataType: "boolean";
137
+ columnType: "PgBoolean";
138
+ data: boolean;
139
+ driverParam: boolean;
140
+ notNull: true;
141
+ hasDefault: true;
142
+ isPrimaryKey: false;
143
+ isAutoincrement: false;
144
+ hasRuntimeDefault: false;
145
+ enumValues: undefined;
146
+ baseColumn: never;
147
+ identity: undefined;
148
+ generated: undefined;
149
+ }, {}, {}>;
132
150
  callsUsesDomain: import("drizzle-orm/pg-core").PgColumn<{
133
151
  name: "calls_uses_domain";
134
152
  tableName: "foundry_declared_orgs";
@@ -318,6 +336,25 @@ export declare const declaredOrgs: import("drizzle-orm/pg-core").PgTableWithColu
318
336
  }, {}, {
319
337
  $type: IssuerApp[];
320
338
  }>;
339
+ hostingAccessEmails: import("drizzle-orm/pg-core").PgColumn<{
340
+ name: "hosting_access_emails";
341
+ tableName: "foundry_declared_orgs";
342
+ dataType: "json";
343
+ columnType: "PgJsonb";
344
+ data: string[];
345
+ driverParam: unknown;
346
+ notNull: true;
347
+ hasDefault: true;
348
+ isPrimaryKey: false;
349
+ isAutoincrement: false;
350
+ hasRuntimeDefault: false;
351
+ enumValues: undefined;
352
+ baseColumn: never;
353
+ identity: undefined;
354
+ generated: undefined;
355
+ }, {}, {
356
+ $type: string[];
357
+ }>;
321
358
  ssoEnabled: import("drizzle-orm/pg-core").PgColumn<{
322
359
  name: "sso_enabled";
323
360
  tableName: "foundry_declared_orgs";
@@ -799,7 +836,175 @@ export declare const foundryAppliedState: import("drizzle-orm/pg-core").PgTableW
799
836
  };
800
837
  dialect: "pg";
801
838
  }>;
839
+ /** Mirrors `migrations/0012_audit.sql`, the source of truth (#85). */
840
+ export declare const foundryAuditEvents: import("drizzle-orm/pg-core").PgTableWithColumns<{
841
+ name: "foundry_audit_events";
842
+ schema: undefined;
843
+ columns: {
844
+ id: import("drizzle-orm/pg-core").PgColumn<{
845
+ name: "id";
846
+ tableName: "foundry_audit_events";
847
+ dataType: "number";
848
+ columnType: "PgBigSerial53";
849
+ data: number;
850
+ driverParam: number;
851
+ notNull: true;
852
+ hasDefault: true;
853
+ isPrimaryKey: true;
854
+ isAutoincrement: false;
855
+ hasRuntimeDefault: false;
856
+ enumValues: undefined;
857
+ baseColumn: never;
858
+ identity: undefined;
859
+ generated: undefined;
860
+ }, {}, {}>;
861
+ at: import("drizzle-orm/pg-core").PgColumn<{
862
+ name: "at";
863
+ tableName: "foundry_audit_events";
864
+ dataType: "date";
865
+ columnType: "PgTimestamp";
866
+ data: Date;
867
+ driverParam: string;
868
+ notNull: true;
869
+ hasDefault: false;
870
+ isPrimaryKey: false;
871
+ isAutoincrement: false;
872
+ hasRuntimeDefault: false;
873
+ enumValues: undefined;
874
+ baseColumn: never;
875
+ identity: undefined;
876
+ generated: undefined;
877
+ }, {}, {}>;
878
+ kind: import("drizzle-orm/pg-core").PgColumn<{
879
+ name: "kind";
880
+ tableName: "foundry_audit_events";
881
+ dataType: "string";
882
+ columnType: "PgText";
883
+ data: "plan" | "apply" | "destroy";
884
+ driverParam: string;
885
+ notNull: true;
886
+ hasDefault: false;
887
+ isPrimaryKey: false;
888
+ isAutoincrement: false;
889
+ hasRuntimeDefault: false;
890
+ enumValues: [string, ...string[]];
891
+ baseColumn: never;
892
+ identity: undefined;
893
+ generated: undefined;
894
+ }, {}, {
895
+ $type: "plan" | "apply" | "destroy";
896
+ }>;
897
+ actorId: import("drizzle-orm/pg-core").PgColumn<{
898
+ name: "actor_id";
899
+ tableName: "foundry_audit_events";
900
+ dataType: "string";
901
+ columnType: "PgText";
902
+ data: string;
903
+ driverParam: string;
904
+ notNull: true;
905
+ hasDefault: false;
906
+ isPrimaryKey: false;
907
+ isAutoincrement: false;
908
+ hasRuntimeDefault: false;
909
+ enumValues: [string, ...string[]];
910
+ baseColumn: never;
911
+ identity: undefined;
912
+ generated: undefined;
913
+ }, {}, {}>;
914
+ actorDisplay: import("drizzle-orm/pg-core").PgColumn<{
915
+ name: "actor_display";
916
+ tableName: "foundry_audit_events";
917
+ dataType: "string";
918
+ columnType: "PgText";
919
+ data: string;
920
+ driverParam: string;
921
+ notNull: true;
922
+ hasDefault: false;
923
+ isPrimaryKey: false;
924
+ isAutoincrement: false;
925
+ hasRuntimeDefault: false;
926
+ enumValues: [string, ...string[]];
927
+ baseColumn: never;
928
+ identity: undefined;
929
+ generated: undefined;
930
+ }, {}, {}>;
931
+ orgId: import("drizzle-orm/pg-core").PgColumn<{
932
+ name: "org_id";
933
+ tableName: "foundry_audit_events";
934
+ dataType: "string";
935
+ columnType: "PgText";
936
+ data: string;
937
+ driverParam: string;
938
+ notNull: true;
939
+ hasDefault: false;
940
+ isPrimaryKey: false;
941
+ isAutoincrement: false;
942
+ hasRuntimeDefault: false;
943
+ enumValues: [string, ...string[]];
944
+ baseColumn: never;
945
+ identity: undefined;
946
+ generated: undefined;
947
+ }, {}, {}>;
948
+ outcome: import("drizzle-orm/pg-core").PgColumn<{
949
+ name: "outcome";
950
+ tableName: "foundry_audit_events";
951
+ dataType: "string";
952
+ columnType: "PgText";
953
+ data: "applied" | "refused" | "failed" | "destroyed";
954
+ driverParam: string;
955
+ notNull: false;
956
+ hasDefault: false;
957
+ isPrimaryKey: false;
958
+ isAutoincrement: false;
959
+ hasRuntimeDefault: false;
960
+ enumValues: [string, ...string[]];
961
+ baseColumn: never;
962
+ identity: undefined;
963
+ generated: undefined;
964
+ }, {}, {
965
+ $type: "applied" | "refused" | "failed" | "destroyed";
966
+ }>;
967
+ reason: import("drizzle-orm/pg-core").PgColumn<{
968
+ name: "reason";
969
+ tableName: "foundry_audit_events";
970
+ dataType: "string";
971
+ columnType: "PgText";
972
+ data: string;
973
+ driverParam: string;
974
+ notNull: false;
975
+ hasDefault: false;
976
+ isPrimaryKey: false;
977
+ isAutoincrement: false;
978
+ hasRuntimeDefault: false;
979
+ enumValues: [string, ...string[]];
980
+ baseColumn: never;
981
+ identity: undefined;
982
+ generated: undefined;
983
+ }, {}, {}>;
984
+ plan: import("drizzle-orm/pg-core").PgColumn<{
985
+ name: "plan";
986
+ tableName: "foundry_audit_events";
987
+ dataType: "json";
988
+ columnType: "PgJsonb";
989
+ data: Plan;
990
+ driverParam: unknown;
991
+ notNull: false;
992
+ hasDefault: false;
993
+ isPrimaryKey: false;
994
+ isAutoincrement: false;
995
+ hasRuntimeDefault: false;
996
+ enumValues: undefined;
997
+ baseColumn: never;
998
+ identity: undefined;
999
+ generated: undefined;
1000
+ }, {}, {
1001
+ $type: Plan;
1002
+ }>;
1003
+ };
1004
+ dialect: "pg";
1005
+ }>;
802
1006
  export type DeclaredOrgRow = typeof declaredOrgs.$inferSelect;
803
1007
  export type NewDeclaredOrgRow = typeof declaredOrgs.$inferInsert;
804
1008
  export type AppliedStateRow = typeof foundryAppliedState.$inferSelect;
805
1009
  export type NewAppliedStateRow = typeof foundryAppliedState.$inferInsert;
1010
+ export type AuditEventRow = typeof foundryAuditEvents.$inferSelect;
@@ -1,4 +1,4 @@
1
- import { boolean, jsonb, pgTable, text, timestamp } from 'drizzle-orm/pg-core';
1
+ import { bigserial, boolean, jsonb, pgTable, text, timestamp } from 'drizzle-orm/pg-core';
2
2
  /**
3
3
  * Mirrors `migrations/0001_declared_org.sql`, which is the source of truth:
4
4
  * the SQL carries the CHECKs drizzle-kit does not generate here (this
@@ -13,6 +13,7 @@ export const declaredOrgs = pgTable('foundry_declared_orgs', {
13
13
  mailMode: text('mail_mode').notNull().default('off'),
14
14
  mailUsesDomain: boolean('mail_uses_domain').notNull().default(false),
15
15
  chatUsesDomain: boolean('chat_uses_domain').notNull().default(false),
16
+ callsEnabled: boolean('calls_enabled').notNull().default(false),
16
17
  callsUsesDomain: boolean('calls_uses_domain').notNull().default(false),
17
18
  issuerUsesDomain: boolean('issuer_uses_domain').notNull().default(false),
18
19
  // SiteConfig (org.ts, T10). A brand new org has no cms site.
@@ -26,6 +27,7 @@ export const declaredOrgs = pgTable('foundry_declared_orgs', {
26
27
  hostingGitBranch: text('hosting_git_branch').notNull().default('main'),
27
28
  hostingBuildPack: text('hosting_build_pack').notNull().default('nixpacks'),
28
29
  hostingIssuerApps: jsonb('hosting_issuer_apps').notNull().default([]).$type(),
30
+ hostingAccessEmails: jsonb('hosting_access_emails').notNull().default([]).$type(),
29
31
  ssoEnabled: boolean('sso_enabled').notNull().default(false),
30
32
  guestsEnabled: boolean('guests_enabled').notNull().default(false),
31
33
  soloEnabled: boolean('solo_enabled').notNull().default(false),
@@ -75,3 +77,15 @@ export const foundryAppliedState = pgTable('foundry_applied_state', {
75
77
  lastAppliedAt: timestamp('last_applied_at', { withTimezone: true }),
76
78
  lastAppliedDeclaredOrgVersion: text('last_applied_declared_org_version'),
77
79
  });
80
+ /** Mirrors `migrations/0012_audit.sql`, the source of truth (#85). */
81
+ export const foundryAuditEvents = pgTable('foundry_audit_events', {
82
+ id: bigserial('id', { mode: 'number' }).primaryKey(),
83
+ at: timestamp('at', { withTimezone: true }).notNull(),
84
+ kind: text('kind').notNull().$type(),
85
+ actorId: text('actor_id').notNull(),
86
+ actorDisplay: text('actor_display').notNull(),
87
+ orgId: text('org_id').notNull(),
88
+ outcome: text('outcome').$type(),
89
+ reason: text('reason'),
90
+ plan: jsonb('plan').$type(),
91
+ });
package/dist/store.d.ts CHANGED
@@ -12,6 +12,18 @@ import type { AppliedState } from './state.js';
12
12
  export interface DeclaredOrgStore {
13
13
  create(org: DeclaredOrg): Promise<void>;
14
14
  get(id: OrgId): Promise<DeclaredOrg | null>;
15
+ /** Whether any org already holds this slug. `slugFromName` asks it. */
16
+ slugTaken(slug: string): Promise<boolean>;
17
+ /**
18
+ * The org id, if any, whose applied state already records `zoneId` under a
19
+ * `cloudflare:zone:*` key (#131). `plan/executor.ts`'s `cloudflareZone`
20
+ * asks this before adopting a zone `findZoneByApex` finds, the same
21
+ * foreign-resource refusal `cloudflareAccess` makes by reading Cloudflare's
22
+ * own Access-app name -- a Cloudflare zone carries no org-identifying
23
+ * field of its own for that live check, so this asks the store's own
24
+ * record instead.
25
+ */
26
+ findOrgIdForZone(zoneId: string): Promise<OrgId | null>;
15
27
  /**
16
28
  * Combinations foundry knows are impossible are rejected with a reason
17
29
  * (#18 "Done when"), so a caller must be ready for this to throw rather
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/foundry",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Foundry's SDK: the declared org's types, the plan and apply contracts, and the state store's interface.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -28,13 +28,19 @@
28
28
  "scripts": {
29
29
  "build": "rm -rf dist && tsc -p tsconfig.build.json && mkdir -p dist/migrations && find src/migrations -maxdepth 1 -name '*.sql' -exec cp {} dist/migrations/ \\;",
30
30
  "typecheck": "tsc --noEmit",
31
- "test": "vitest run"
31
+ "test": "vitest run",
32
+ "db:provision": "node scripts/foundry-db-provision.mjs",
33
+ "db:migrate": "node scripts/foundry-db-migrate.mjs",
34
+ "calls-sfu:deploy": "node scripts/calls-sfu-deploy.mjs"
32
35
  },
33
36
  "peerDependencies": {
34
37
  "drizzle-orm": ">=0.45.2"
35
38
  },
36
39
  "dependencies": {
37
- "@wtfalch/providers": "0.5.0"
40
+ "@wtfalch/auth": "0.16.0",
41
+ "@wtfalch/email": "0.15.0",
42
+ "@wtfalch/providers": "0.6.0",
43
+ "ssh2": "1.17.0"
38
44
  },
39
45
  "devDependencies": {
40
46
  "@electric-sql/pglite": "^0.5.8",