@wtfalch/foundry 0.1.0 → 0.3.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 (41) hide show
  1. package/dist/apply.d.ts +14 -0
  2. package/dist/calls-sfu-deploy-support.d.ts +57 -0
  3. package/dist/calls-sfu-deploy-support.js +82 -0
  4. package/dist/calls-sfu.d.ts +175 -0
  5. package/dist/calls-sfu.js +239 -0
  6. package/dist/index.d.ts +11 -6
  7. package/dist/index.js +7 -4
  8. package/dist/migrate.d.ts +38 -0
  9. package/dist/migrate.js +47 -0
  10. package/dist/migrations/0008_hosting.sql +24 -0
  11. package/dist/migrations/0009_hosting_git_branch_build_pack.sql +14 -0
  12. package/dist/migrations/0010_owner.sql +13 -0
  13. package/dist/migrations/0011_test_org.sql +12 -0
  14. package/dist/migrations/0012_audit.sql +21 -0
  15. package/dist/migrations/0013_access_emails.sql +6 -0
  16. package/dist/org.d.ts +130 -0
  17. package/dist/org.js +102 -0
  18. package/dist/plan/diff.d.ts +20 -5
  19. package/dist/plan/diff.js +533 -9
  20. package/dist/plan/executor.d.ts +823 -0
  21. package/dist/plan/executor.js +1242 -0
  22. package/dist/plan/index.d.ts +6 -4
  23. package/dist/plan/index.js +3 -2
  24. package/dist/plan/live.d.ts +8 -4
  25. package/dist/plan/plan-apply.d.ts +114 -4
  26. package/dist/plan/plan-apply.js +266 -14
  27. package/dist/plan/providers-readers.d.ts +102 -14
  28. package/dist/plan/providers-readers.js +245 -16
  29. package/dist/provision.d.ts +56 -0
  30. package/dist/provision.js +69 -0
  31. package/dist/slug.d.ts +12 -18
  32. package/dist/slug.js +66 -0
  33. package/dist/state.d.ts +26 -5
  34. package/dist/store/audit.d.ts +8 -0
  35. package/dist/store/audit.js +43 -0
  36. package/dist/store/postgres.d.ts +3 -1
  37. package/dist/store/postgres.js +84 -2
  38. package/dist/store/schema.d.ts +378 -0
  39. package/dist/store/schema.js +32 -1
  40. package/dist/store.d.ts +12 -0
  41. package/package.json +15 -11
package/dist/slug.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { checkName } from '@wtfalch/auth/names';
1
2
  /**
2
3
  * The declared org's slug: a pure validator, no I/O, no store access.
3
4
  *
@@ -139,3 +140,68 @@ export function validateSlug(slug) {
139
140
  }
140
141
  return null;
141
142
  }
143
+ /** Letters a plain accent strip gets wrong, mostly Norwegian. */
144
+ const SPELLED_OUT = {
145
+ æ: 'ae',
146
+ ø: 'o',
147
+ å: 'a',
148
+ ß: 'ss',
149
+ œ: 'oe',
150
+ þ: 'th',
151
+ ð: 'd',
152
+ ı: 'i',
153
+ ł: 'l',
154
+ đ: 'd',
155
+ };
156
+ /** Room left for `-xxxx` after the base, so a suffixed slug stays one DNS label. */
157
+ const BASE_MAX = 58;
158
+ function randomSuffix() {
159
+ const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789';
160
+ let out = '';
161
+ for (let i = 0; i < 4; i++)
162
+ out += alphabet[Math.floor(Math.random() * alphabet.length)];
163
+ return out;
164
+ }
165
+ /**
166
+ * The sign-in broker's name check, with the length and character rules
167
+ * loosened: a display name has spaces and accents, and a slug may be 63 long.
168
+ * Only its `reserved` and `disallowed` verdicts apply here.
169
+ */
170
+ const ANY_TEXT = { minLength: 0, maxLength: Number.MAX_SAFE_INTEGER, pattern: /^/ };
171
+ /**
172
+ * The slug for a new org, derived from its first display name (William,
173
+ * 2026-09-26): "Good Company" becomes `good-company`, and `good-company.oikos.host`
174
+ * is its address. When that slug is taken, reserved or has an environment
175
+ * suffix, a short random suffix is added: `bruh-7k3q`. The display name stays
176
+ * separate, so an org can rename itself without moving its address.
177
+ *
178
+ * An offensive name is refused outright, by the same check the sign-in broker
179
+ * runs on usernames (https://github.com/wtfalch/foundry/issues/103, N1b). A
180
+ * name that could pass as infra (`admin`, `help`) only costs the slug a suffix.
181
+ */
182
+ export async function slugFromName(name, slugTaken, suffix = randomSuffix) {
183
+ const base = name
184
+ .toLowerCase()
185
+ .replace(/[æøåßœþðıłđ]/g, (c) => SPELLED_OUT[c] ?? c)
186
+ .normalize('NFKD')
187
+ .replace(/\p{M}/gu, '')
188
+ .replace(/[^a-z0-9]+/g, '-')
189
+ .replace(/^-+|-+$/g, '')
190
+ .slice(0, BASE_MAX)
191
+ .replace(/-+$/, '') || 'org';
192
+ const nameVerdict = checkName(name, ANY_TEXT);
193
+ const baseVerdict = checkName(base, ANY_TEXT);
194
+ if ((!nameVerdict.ok && nameVerdict.reason === 'disallowed') ||
195
+ (!baseVerdict.ok && baseVerdict.reason === 'disallowed')) {
196
+ throw new Error(`org name '${name}' is not allowed`);
197
+ }
198
+ const free = async (slug) => validateSlug(slug) === null && checkName(slug, ANY_TEXT).ok && !(await slugTaken(slug));
199
+ if (await free(base))
200
+ return base;
201
+ for (let attempt = 0; attempt < 10; attempt++) {
202
+ const candidate = `${base}-${suffix()}`;
203
+ if (await free(candidate))
204
+ return candidate;
205
+ }
206
+ throw new Error(`no free slug found for '${name}' after 10 tries`);
207
+ }
package/dist/state.d.ts CHANGED
@@ -34,17 +34,38 @@ export interface LastApplied {
34
34
  export interface AppliedState {
35
35
  orgId: OrgId;
36
36
  /**
37
- * One id per service that takes jobs from many orgs (Files, AI,
38
- * integrations — https://github.com/wtfalch/foundry/issues/14's resolved
39
- * research #4) plus the per-org services (chat, mail), each keyed by that
40
- * service's own name for the org.
37
+ * One id per resource `plan/plan-apply.ts`'s `runSteps` has made true,
38
+ * keyed `system:kind:key` — the same resource key `plan/diff.ts` plans
39
+ * against (e.g. `coolify:app:<orgId>`, `zitadel:app:<name>`), so two
40
+ * resources that share a `kind` (an org's several declared ZITADEL apps,
41
+ * #71) never overwrite each other's id the way a bare `kind` key did.
42
+ * `plan/providers-readers.ts`'s `storeRecordedIds` reads this map back to
43
+ * match a live provider resource to the foundry resource it was recorded
44
+ * for, instead of reporting it unmanaged (design:
45
+ * `.claude/campaign/2026-09-24-keys-and-memberships.md`, "Design for
46
+ * (1)").
47
+ *
48
+ * A provider *scope* id — `coolify_project`, `zitadel_org`,
49
+ * `zitadel_project`, a Cloudflare zone id (`plan/providers-readers.ts`'s
50
+ * `ScopeResolver`, https://github.com/wtfalch/foundry/issues/14's resolved
51
+ * research #4) — is a different id space and keeps its own plain key; a
52
+ * scope key has no `:` in it, so it can never collide with the
53
+ * `system:kind:key` form. `plan/executor.ts`'s `FoundryStepExecutor` is
54
+ * what writes these today, through `plan-apply.ts`'s
55
+ * `StepExecutionContext.recordScopeId` — backed by the very `serviceIds`
56
+ * map `runSteps` builds here and persists in one write at the end of a
57
+ * run, not a separate store write of its own (`StepExecutionContext`'s doc
58
+ * comment explains why: a second writer would race `runSteps`' own final
59
+ * one).
41
60
  */
42
61
  serviceIds: Record<string, string>;
43
62
  /** Checkpoints from the current or most recent apply. */
44
63
  checkpoints: Checkpoint[];
45
64
  /**
46
65
  * The name of every {@link DeclaredOrg} field the most recent apply could
47
- * not make true, because nothing implements it yet.
66
+ * not make true: because nothing implements it yet, or because its step
67
+ * had not completed when the apply failed. `apply` recomputes it every
68
+ * time (#25), in `plan/plan-apply.ts`'s `failedFields`.
48
69
  *
49
70
  * Decision (#18): this field did not exist on the type this ticket found.
50
71
  * #7 ("Shape") and #18's "Done when" both require `apply` to "fail loudly
@@ -0,0 +1,8 @@
1
+ import type { AuditLog } from '../plan/plan-apply.js';
2
+ import type { FoundryStoreDb } from './postgres.js';
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.
7
+ */
8
+ export declare function createPostgresAuditLog(db: FoundryStoreDb): AuditLog;
@@ -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
+ }
@@ -34,7 +34,9 @@ export declare class DeclaredOrgError extends Error {
34
34
  * on both `create` and `update`. #27 adds a third, narrower rule: storage and
35
35
  * AI spend limits are bigint-safe decimal strings of non-negative integers
36
36
  * (or `null` for uncapped), checked the same way on both `create` and the
37
- * merged result of `update`. The broader "combinations foundry knows are
37
+ * merged result of `update`. #67 adds a fourth (`org.ts`'s
38
+ * `validateBuyerIdentity`) and #71 a fifth (`org.ts`'s `validateHosting`),
39
+ * checked the same way. The broader "combinations foundry knows are
38
40
  * impossible are rejected with a reason" line needs a ruleset (domain
39
41
  * requirements per service, `adoptedApp.form`, mail-level transitions…)
40
42
  * that later tickets, not #18, #27 or #28, decide; `update` still merges and
@@ -1,5 +1,5 @@
1
1
  import { eq, sql } from 'drizzle-orm';
2
- import { validateBuyerIdentity } from '../org.js';
2
+ import { validateBuyerIdentity, 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';
@@ -50,6 +50,16 @@ function rowToOrg(row) {
50
50
  calls: { usesDomain: row.callsUsesDomain },
51
51
  issuer: { usesDomain: row.issuerUsesDomain },
52
52
  site: { enabled: row.siteEnabled, usesDomain: row.siteUsesDomain },
53
+ hosting: {
54
+ enabled: row.hostingEnabled,
55
+ usesDomain: row.hostingUsesDomain,
56
+ database: row.hostingDatabase,
57
+ tunnel: row.hostingTunnel,
58
+ gitBranch: row.hostingGitBranch,
59
+ buildPack: row.hostingBuildPack,
60
+ issuerApps: row.hostingIssuerApps,
61
+ accessEmails: row.hostingAccessEmails,
62
+ },
53
63
  sso: { enabled: row.ssoEnabled },
54
64
  guests: { enabled: row.guestsEnabled },
55
65
  solo: { enabled: row.soloEnabled },
@@ -60,6 +70,8 @@ function rowToOrg(row) {
60
70
  adoptedApp: row.adoptedAppForm
61
71
  ? { form: row.adoptedAppForm }
62
72
  : null,
73
+ owner: rowToOwner(row),
74
+ test: row.isTest,
63
75
  };
64
76
  }
65
77
  function orgToRow(org) {
@@ -75,6 +87,14 @@ function orgToRow(org) {
75
87
  issuerUsesDomain: org.issuer.usesDomain,
76
88
  siteEnabled: org.site.enabled,
77
89
  siteUsesDomain: org.site.usesDomain,
90
+ hostingEnabled: org.hosting.enabled,
91
+ hostingUsesDomain: org.hosting.usesDomain,
92
+ hostingDatabase: org.hosting.database,
93
+ hostingTunnel: org.hosting.tunnel,
94
+ hostingGitBranch: org.hosting.gitBranch,
95
+ hostingBuildPack: org.hosting.buildPack,
96
+ hostingIssuerApps: org.hosting.issuerApps,
97
+ hostingAccessEmails: org.hosting.accessEmails,
78
98
  ssoEnabled: org.sso.enabled,
79
99
  guestsEnabled: org.guests.enabled,
80
100
  soloEnabled: org.solo.enabled,
@@ -90,6 +110,10 @@ function orgToRow(org) {
90
110
  vatRegistered: org.buyer.vatRegistered,
91
111
  bespokeAppRepo: org.bespokeAppRepo,
92
112
  adoptedAppForm: org.adoptedApp?.form ?? null,
113
+ ownerEmail: org.owner?.email ?? null,
114
+ ownerGivenName: org.owner?.givenName ?? null,
115
+ ownerFamilyName: org.owner?.familyName ?? null,
116
+ isTest: org.test,
93
117
  };
94
118
  }
95
119
  /**
@@ -117,6 +141,17 @@ function rowToBuyer(row) {
117
141
  vatRegistered: row.vatRegistered,
118
142
  };
119
143
  }
144
+ /**
145
+ * Reassembles `DeclaredOrg.owner` from the row's three separate nullable
146
+ * columns -- present only when all three are, same "all parts or none"
147
+ * pattern {@link rowToBuyer} uses for the buyer's address.
148
+ */
149
+ function rowToOwner(row) {
150
+ const { ownerEmail, ownerGivenName, ownerFamilyName } = row;
151
+ if (ownerEmail === null || ownerGivenName === null || ownerFamilyName === null)
152
+ return null;
153
+ return { email: ownerEmail, givenName: ownerGivenName, familyName: ownerFamilyName };
154
+ }
120
155
  /**
121
156
  * `DeclaredOrgStore` (`../store.ts`) on Postgres, for #18: create, read and
122
157
  * edit a {@link DeclaredOrg}, and read and write the {@link AppliedState}
@@ -129,7 +164,9 @@ function rowToBuyer(row) {
129
164
  * on both `create` and `update`. #27 adds a third, narrower rule: storage and
130
165
  * AI spend limits are bigint-safe decimal strings of non-negative integers
131
166
  * (or `null` for uncapped), checked the same way on both `create` and the
132
- * merged result of `update`. The broader "combinations foundry knows are
167
+ * merged result of `update`. #67 adds a fourth (`org.ts`'s
168
+ * `validateBuyerIdentity`) and #71 a fifth (`org.ts`'s `validateHosting`),
169
+ * checked the same way. The broader "combinations foundry knows are
133
170
  * impossible are rejected with a reason" line needs a ruleset (domain
134
171
  * requirements per service, `adoptedApp.form`, mail-level transitions…)
135
172
  * that later tickets, not #18, #27 or #28, decide; `update` still merges and
@@ -169,11 +206,35 @@ export function createPostgresDeclaredOrgStore(db) {
169
206
  if (reason)
170
207
  throw new DeclaredOrgError(`buyer identity invalid: ${reason} (#67)`);
171
208
  }
209
+ // #71: same place `requireValidBuyer` checks the buyer identity (#67), on
210
+ // both `create` and the merged result of `update`.
211
+ function requireValidHosting(org) {
212
+ const reason = validateHosting(org.hosting);
213
+ if (reason)
214
+ throw new DeclaredOrgError(`hosting invalid: ${reason} (#71)`);
215
+ }
216
+ // #101: same place `requireValidHosting` checks hosting, on both `create`
217
+ // and the merged result of `update`.
218
+ function requireValidSite(org) {
219
+ const reason = validateSite(org);
220
+ if (reason)
221
+ throw new DeclaredOrgError(`site invalid: ${reason} (#101)`);
222
+ }
223
+ // Same place `requireValidHosting` checks hosting, on both `create` and
224
+ // the merged result of `update`.
225
+ function requireValidOwner(org) {
226
+ const reason = validateOwner(org.owner);
227
+ if (reason)
228
+ throw new DeclaredOrgError(`owner invalid: ${reason}`);
229
+ }
172
230
  return {
173
231
  async create(org) {
174
232
  requireValidSlug(org.slug);
175
233
  requireValidLimits(org);
176
234
  requireValidBuyer(org);
235
+ requireValidHosting(org);
236
+ requireValidSite(org);
237
+ requireValidOwner(org);
177
238
  try {
178
239
  await db.insert(declaredOrgs).values(orgToRow(org));
179
240
  }
@@ -192,6 +253,14 @@ export function createPostgresDeclaredOrgStore(db) {
192
253
  const [row] = await db.select().from(declaredOrgs).where(eq(declaredOrgs.id, id)).limit(1);
193
254
  return row ? rowToOrg(row) : null;
194
255
  },
256
+ async slugTaken(slug) {
257
+ const [row] = await db
258
+ .select({ id: declaredOrgs.id })
259
+ .from(declaredOrgs)
260
+ .where(eq(declaredOrgs.slug, slug))
261
+ .limit(1);
262
+ return row !== undefined;
263
+ },
195
264
  async update(id, patch) {
196
265
  const current = rowToOrg(await requireRow(id));
197
266
  // The slug freeze (#7 corrections 3): once mail exists, the mail
@@ -212,6 +281,9 @@ export function createPostgresDeclaredOrgStore(db) {
212
281
  const next = { ...current, ...patch, id: current.id };
213
282
  requireValidLimits(next);
214
283
  requireValidBuyer(next);
284
+ requireValidHosting(next);
285
+ requireValidSite(next);
286
+ requireValidOwner(next);
215
287
  try {
216
288
  await db
217
289
  .update(declaredOrgs)
@@ -263,5 +335,15 @@ export function createPostgresDeclaredOrgStore(db) {
263
335
  .values(values)
264
336
  .onConflictDoUpdate({ target: foundryAppliedState.orgId, set: values });
265
337
  },
338
+ async delete(id) {
339
+ // Applied state first: `0002_applied_state.sql`'s
340
+ // `foundry_applied_state_org_fkey` references the declared org with no
341
+ // cascade, so deleting the org first would fail while its applied
342
+ // state row still points at it. Both deletes are no-ops (idempotent)
343
+ // when the row is already gone, matching this method's own doc
344
+ // comment on `../store.ts`.
345
+ await db.delete(foundryAppliedState).where(eq(foundryAppliedState.orgId, id));
346
+ await db.delete(declaredOrgs).where(eq(declaredOrgs.id, id));
347
+ },
266
348
  };
267
349
  }