@wtfalch/foundry 0.61.0 → 0.62.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 (40) hide show
  1. package/dist/index.d.ts +5 -1
  2. package/dist/index.js +2 -0
  3. package/dist/migrations/0065_native_build_runs.sql +32 -0
  4. package/dist/migrations/0066_org_kosmos.sql +10 -0
  5. package/dist/modules/sign-in.js +1 -1
  6. package/dist/native-build.d.ts +70 -0
  7. package/dist/native-build.js +92 -0
  8. package/dist/org.d.ts +22 -3
  9. package/dist/org.js +25 -2
  10. package/dist/plan/agora.d.ts +1 -1
  11. package/dist/plan/agora.js +4 -0
  12. package/dist/plan/blue-green.d.ts +19 -7
  13. package/dist/plan/blue-green.js +35 -8
  14. package/dist/plan/deploy-health.js +9 -2
  15. package/dist/plan/desktop-callback.js +4 -1
  16. package/dist/plan/diff.js +180 -63
  17. package/dist/plan/edge-access.js +1 -0
  18. package/dist/plan/executor.d.ts +40 -18
  19. package/dist/plan/executor.js +153 -49
  20. package/dist/plan/kosmos.d.ts +56 -0
  21. package/dist/plan/kosmos.js +84 -0
  22. package/dist/plan/locked-hostnames.js +5 -2
  23. package/dist/plan/org-names.d.ts +3 -3
  24. package/dist/plan/org-names.js +3 -1
  25. package/dist/plan/org-postgres-migrate.js +1 -0
  26. package/dist/plan/own-limits.d.ts +6 -0
  27. package/dist/plan/own-limits.js +1 -0
  28. package/dist/plan/platform-providers.js +6 -0
  29. package/dist/plan/post-logout-uris.d.ts +1 -1
  30. package/dist/plan/post-logout-uris.js +5 -2
  31. package/dist/plan/providers-readers.js +20 -1
  32. package/dist/release/migration-compatibility.d.ts +66 -0
  33. package/dist/release/migration-compatibility.js +340 -0
  34. package/dist/requested-changes.js +2 -1
  35. package/dist/slug.d.ts +1 -1
  36. package/dist/slug.js +11 -2
  37. package/dist/store/postgres.js +13 -0
  38. package/dist/store/schema.d.ts +36 -0
  39. package/dist/store/schema.js +6 -0
  40. package/package.json +2 -2
@@ -2,6 +2,7 @@ import { ZitadelError } from '@wtfalch/providers/zitadel';
2
2
  import { APP_ENTRY_KEY } from '../org.js';
3
3
  import { BOULE_MAIL_TOKEN_ENV, BOULE_MANAGED_ENV_KEYS } from './boule.js';
4
4
  import { previewsOn } from './coolify-previews.js';
5
+ import { KOSMOS_MANAGED_ENV_KEYS } from './kosmos.js';
5
6
  import { APP_GONE_KIND, } from './live.js';
6
7
  import { SIGN_IN_UPSTREAM_ENV } from './sign-in.js';
7
8
  /**
@@ -302,6 +303,23 @@ async function withBouleEnvConfig(client, resource, recorded, liveKeys) {
302
303
  config: { ...resource.config, env, ...(appToken ? { appMailToken: appToken } : {}) },
303
304
  };
304
305
  }
306
+ /**
307
+ * The org's own recorded kosmos app's live sign-in env vars, as `config.env`, so `diff.ts`'s `planKosmos`
308
+ * can compare them with what `executor.ts` would write. Gated and failing exactly like
309
+ * {@link withAgoraEnvConfig}.
310
+ */
311
+ async function withKosmosEnvConfig(client, resource, recorded) {
312
+ if (resource.kind !== 'application' || !recorded[resource.key]?.startsWith('kosmos:')) {
313
+ return resource;
314
+ }
315
+ const managed = KOSMOS_MANAGED_ENV_KEYS;
316
+ const env = {};
317
+ for (const e of await client.listEnvs(resource.key)) {
318
+ if (!e.is_preview && managed.includes(e.key))
319
+ env[e.key] = e.value;
320
+ }
321
+ return { ...resource, config: { ...resource.config, env } };
322
+ }
305
323
  /** `@wtfalch/providers`' own `LiveResource` (`resource.ts`) has no `system` field — foundry's adds it at this boundary. */
306
324
  function toFoundryResources(system, resources) {
307
325
  return resources.map((r) => ({ system, kind: r.kind, key: r.key, config: r.config }));
@@ -335,7 +353,8 @@ export class ProvidersCoolifyReader {
335
353
  const withAgoraConfig = await Promise.all(withDbUrl.map((r) => withAgoraEnvConfig(this.client, r, recorded)));
336
354
  const liveKeys = new Set(withAgoraConfig.map((r) => r.key));
337
355
  const withBouleConfig = await Promise.all(withAgoraConfig.map((r) => withBouleEnvConfig(this.client, r, recorded, liveKeys)));
338
- return applyRecordedIds(withBouleConfig, recorded);
356
+ const withKosmosConfig = await Promise.all(withBouleConfig.map((r) => withKosmosEnvConfig(this.client, r, recorded)));
357
+ return applyRecordedIds(withKosmosConfig, recorded);
339
358
  }
340
359
  async liveEnvEquals(orgId, appKey, name, value) {
341
360
  const recorded = await this.resolveRecordedIds(orgId);
@@ -0,0 +1,66 @@
1
+ import { type WorkerAppConfig } from '../worker-app-config.js';
2
+ /**
3
+ * Whether a release's database changes may go to idle (foundry#934, estate DECISIONS.md,
4
+ * 2026-10-21 "Releases and versions"). Idle shares the live database with whatever release is
5
+ * live, so a release may only add -- never drop, narrow, rename or retype something the live
6
+ * release still reads or writes, and never delete a Durable Object class the live release still
7
+ * uses. A removal needs two releases: one that stops using the object, a later one -- once that
8
+ * first release is no longer on live, idle or draining anywhere -- that removes it.
9
+ *
10
+ * Pure: no database connection, no network call, no data read. The caller supplies everything it
11
+ * already has: what the previous live release applied (`liveMigrationIds`, `liveDoTag`), what
12
+ * release tags are deployed anywhere right now (`deployedReleases`), and which removals this
13
+ * release declares as the drained second step of an earlier one (`declaredRemovals`). There is no
14
+ * override beyond that declaration: a destructive step with no declaration, or whose declared
15
+ * release has not drained, is refused every time.
16
+ *
17
+ * A SQL statement declares its own removal with a `-- removal-since: <release>` comment directly
18
+ * above it (the same convention `@wtfalch/db`'s migration safety check uses for
19
+ * `-- destructive: <reason>`); a Durable Object class removal is declared in `declaredRemovals`,
20
+ * since `deleted_classes` carries no comment of its own.
21
+ *
22
+ * The SQL classifier is pattern-based, not a real SQL parser: it is a safety rail against an
23
+ * accidental incompatible release, not a boundary against someone deliberately hiding a drop
24
+ * inside a string. A statement it cannot classify is refused rather than guessed as safe.
25
+ */
26
+ /** A Durable Object class removal this release declares as the drained second step of an earlier one. */
27
+ export interface DeclaredClassRemoval {
28
+ /** The class name a `deleted_classes` step of this release removes. */
29
+ className: string;
30
+ /** The earlier release that stopped using the class. Must have fully drained before this passes. */
31
+ sinceRelease: string;
32
+ }
33
+ export interface MigrationCompatibilityInput {
34
+ /** This release's own `migrations.json` (SQL), or `null` for a release with none. */
35
+ migrationsJson: string | null;
36
+ /** The SQL migration ids the previous live release already applied, in order. Default: none. */
37
+ liveMigrationIds?: readonly string[];
38
+ /** This release's full, declared Durable Object migrations, in order. Default: none. */
39
+ doMigrations?: WorkerAppConfig['durableObjects']['migrations'];
40
+ /** The Durable Object migration tag the previous live release holds, if any. */
41
+ liveDoTag?: string;
42
+ /** Durable Object class removals this release declares. Default: none. */
43
+ declaredRemovals?: readonly DeclaredClassRemoval[];
44
+ /** Release tags that are, right now, on live, idle or draining anywhere for this app. Default: none. */
45
+ deployedReleases?: readonly string[];
46
+ }
47
+ export interface MigrationCompatibilityProblem {
48
+ /** The SQL migration id or Durable Object migration tag the statement is in. */
49
+ migration: string;
50
+ /** The statement or step that is refused, trimmed to one line. */
51
+ statement: string;
52
+ /** Why it is refused, and what to do about it, in plain words. */
53
+ reason: string;
54
+ }
55
+ export type MigrationCompatibilityResult = {
56
+ ok: true;
57
+ } | {
58
+ ok: false;
59
+ problems: MigrationCompatibilityProblem[];
60
+ };
61
+ /**
62
+ * Whether a release's migrations (SQL and Durable Object) may go to idle, given what the
63
+ * previous live release already applied and which releases are deployed anywhere right now. See
64
+ * the module docstring for the rule and the removal convention.
65
+ */
66
+ export declare function checkMigrationCompatibility(input: MigrationCompatibilityInput): MigrationCompatibilityResult;
@@ -0,0 +1,340 @@
1
+ import { foldMigrationClasses } from '../worker-app-config.js';
2
+ import { parseMigrations, unappliedDatabaseMigrations, } from '../worker-app-migrations.js';
3
+ const REMOVAL_MARKER = /^--\s*removal-since:\s*(\S.*)$/i;
4
+ const STATEMENT_MAX_CHARS = 160;
5
+ /** Collapses whitespace and cuts a statement down to a readable, one-line length for a message. */
6
+ function oneLine(text) {
7
+ const collapsed = text.replace(/\s+/g, ' ').trim();
8
+ return collapsed.length > STATEMENT_MAX_CHARS
9
+ ? `${collapsed.slice(0, STATEMENT_MAX_CHARS - 1)}…`
10
+ : collapsed;
11
+ }
12
+ /**
13
+ * Splits `sql` into standalone statements at a semicolon that sits outside a line comment, a
14
+ * block comment, a single- or double-quoted literal, and a dollar-quoted body (a function's
15
+ * `$$ ... $$`, which may itself hold semicolons). Each statement keeps any comment lines directly
16
+ * above it, so a `-- removal-since:` marker travels with the statement it excuses.
17
+ */
18
+ function splitStatements(sql) {
19
+ const statements = [];
20
+ let current = '';
21
+ let i = 0;
22
+ const n = sql.length;
23
+ while (i < n) {
24
+ const ch = sql[i];
25
+ if (ch === '-' && sql[i + 1] === '-') {
26
+ const end = sql.indexOf('\n', i);
27
+ const end2 = end === -1 ? n : end + 1;
28
+ current += sql.slice(i, end2);
29
+ i = end2;
30
+ continue;
31
+ }
32
+ if (ch === '/' && sql[i + 1] === '*') {
33
+ const end = sql.indexOf('*/', i + 2);
34
+ const end2 = end === -1 ? n : end + 2;
35
+ current += sql.slice(i, end2);
36
+ i = end2;
37
+ continue;
38
+ }
39
+ if (ch === "'" || ch === '"') {
40
+ let j = i + 1;
41
+ while (j < n) {
42
+ if (sql[j] === ch) {
43
+ if (sql[j + 1] === ch) {
44
+ j += 2;
45
+ continue;
46
+ }
47
+ j += 1;
48
+ break;
49
+ }
50
+ j += 1;
51
+ }
52
+ current += sql.slice(i, j);
53
+ i = j;
54
+ continue;
55
+ }
56
+ if (ch === '$') {
57
+ const tagMatch = /^\$[A-Za-z_][A-Za-z0-9_]*\$|^\$\$/.exec(sql.slice(i));
58
+ if (tagMatch) {
59
+ const tag = tagMatch[0];
60
+ const end = sql.indexOf(tag, i + tag.length);
61
+ const end2 = end === -1 ? n : end + tag.length;
62
+ current += sql.slice(i, end2);
63
+ i = end2;
64
+ continue;
65
+ }
66
+ }
67
+ if (ch === ';') {
68
+ statements.push(current);
69
+ current = '';
70
+ i += 1;
71
+ continue;
72
+ }
73
+ current += ch;
74
+ i += 1;
75
+ }
76
+ if (current.trim() !== '')
77
+ statements.push(current);
78
+ return statements;
79
+ }
80
+ /**
81
+ * Splits `code` at a top-level comma (outside a quoted literal or parentheses), so each element
82
+ * of an `ALTER TABLE`'s comma-separated action list -- `ADD COLUMN a text DEFAULT 'x', ADD COLUMN
83
+ * b integer NOT NULL` -- can be judged on its own. Without this, a `DEFAULT` on one added column
84
+ * hid a `NOT NULL` with no default on a different one, because the two regexes ran against the
85
+ * whole statement (foundry#941 review).
86
+ */
87
+ function splitTopLevelCommas(code) {
88
+ const parts = [];
89
+ let current = '';
90
+ let depth = 0;
91
+ let i = 0;
92
+ const n = code.length;
93
+ while (i < n) {
94
+ const ch = code[i];
95
+ if (ch === "'" || ch === '"') {
96
+ let j = i + 1;
97
+ while (j < n) {
98
+ if (code[j] === ch) {
99
+ if (code[j + 1] === ch) {
100
+ j += 2;
101
+ continue;
102
+ }
103
+ j += 1;
104
+ break;
105
+ }
106
+ j += 1;
107
+ }
108
+ current += code.slice(i, j);
109
+ i = j;
110
+ continue;
111
+ }
112
+ if (ch === '(') {
113
+ depth += 1;
114
+ current += ch;
115
+ i += 1;
116
+ continue;
117
+ }
118
+ if (ch === ')') {
119
+ depth = Math.max(0, depth - 1);
120
+ current += ch;
121
+ i += 1;
122
+ continue;
123
+ }
124
+ if (ch === ',' && depth === 0) {
125
+ parts.push(current);
126
+ current = '';
127
+ i += 1;
128
+ continue;
129
+ }
130
+ current += ch;
131
+ i += 1;
132
+ }
133
+ parts.push(current);
134
+ return parts;
135
+ }
136
+ /** The statement with its leading blank and comment lines removed, so classification reads code, not a marker. */
137
+ function withoutLeadingComments(statement) {
138
+ const lines = statement.split('\n');
139
+ let i = 0;
140
+ while (i < lines.length) {
141
+ const trimmed = lines[i].trim();
142
+ if (trimmed === '' || trimmed.startsWith('--')) {
143
+ i += 1;
144
+ continue;
145
+ }
146
+ break;
147
+ }
148
+ return lines.slice(i).join('\n').trim();
149
+ }
150
+ /** The release named in a `-- removal-since: <release>` line directly above the statement, or `null`. */
151
+ function declaredSince(statement) {
152
+ for (const line of statement.split('\n')) {
153
+ const trimmed = line.trim();
154
+ if (trimmed === '')
155
+ continue;
156
+ const match = REMOVAL_MARKER.exec(trimmed);
157
+ if (match)
158
+ return match[1].trim();
159
+ if (!trimmed.startsWith('--'))
160
+ break;
161
+ }
162
+ return null;
163
+ }
164
+ /**
165
+ * Classifies one statement's code (comments already stripped) as additive or destructive, or
166
+ * `null` when it cannot tell: an unrecognised statement is never assumed to be additive.
167
+ */
168
+ function classifyStatement(code) {
169
+ if (code === '')
170
+ return null;
171
+ if (/^CREATE\s+(OR\s+REPLACE\s+)?(TABLE|SCHEMA|EXTENSION|SEQUENCE|VIEW)\b/i.test(code)) {
172
+ return { destructive: false, reason: 'creates something new' };
173
+ }
174
+ if (/^CREATE\s+(UNIQUE\s+)?INDEX(\s+CONCURRENTLY)?\b/i.test(code)) {
175
+ return { destructive: false, reason: 'adds an index' };
176
+ }
177
+ if (/^CREATE\s+(OR\s+REPLACE\s+)?(FUNCTION|PROCEDURE)\b/i.test(code)) {
178
+ return { destructive: false, reason: 'adds or replaces a function' };
179
+ }
180
+ if (/^CREATE\s+(OR\s+REPLACE\s+)?TRIGGER\b/i.test(code)) {
181
+ return { destructive: false, reason: 'adds a trigger' };
182
+ }
183
+ if (/^(COMMENT\s+ON|GRANT)\b/i.test(code)) {
184
+ return {
185
+ destructive: false,
186
+ reason: 'changes neither data nor structure a running release reads',
187
+ };
188
+ }
189
+ if (/^ALTER\s+TABLE\b/i.test(code)) {
190
+ if (/\bDROP\s+COLUMN\b/i.test(code))
191
+ return { destructive: true, reason: 'drops a column' };
192
+ if (/\bDROP\s+CONSTRAINT\b/i.test(code))
193
+ return { destructive: true, reason: 'drops a constraint' };
194
+ if (/\bRENAME\b/i.test(code))
195
+ return { destructive: true, reason: 'renames a table or column' };
196
+ if (/\bALTER\s+COLUMN\b[\s\S]*\b(SET\s+DATA\s+)?TYPE\b/i.test(code)) {
197
+ return { destructive: true, reason: "changes a column's type" };
198
+ }
199
+ if (/\bSET\s+NOT\s+NULL\b/i.test(code)) {
200
+ return { destructive: true, reason: 'adds a NOT NULL constraint to an existing column' };
201
+ }
202
+ if (/\bADD\s+COLUMN\b/i.test(code)) {
203
+ for (const clause of splitTopLevelCommas(code)) {
204
+ if (!/\bADD\s+COLUMN\b/i.test(clause))
205
+ continue;
206
+ if (/\bNOT\s+NULL\b/i.test(clause) && !/\bDEFAULT\b/i.test(clause)) {
207
+ return { destructive: true, reason: 'adds a NOT NULL column with no default' };
208
+ }
209
+ }
210
+ return { destructive: false, reason: 'adds a column' };
211
+ }
212
+ if (/\bADD\s+CONSTRAINT\b/i.test(code))
213
+ return { destructive: false, reason: 'adds a constraint' };
214
+ return null;
215
+ }
216
+ if (/^DROP\s+(TABLE|INDEX|TYPE|SCHEMA|FUNCTION|PROCEDURE|TRIGGER|VIEW|MATERIALIZED\s+VIEW|EXTENSION|SEQUENCE)\b/i.test(code)) {
217
+ return { destructive: true, reason: 'drops something the live release may still read' };
218
+ }
219
+ if (/^(TRUNCATE|DELETE\s+FROM|UPDATE\s+\S+\s+SET)\b/i.test(code)) {
220
+ return { destructive: true, reason: 'removes or rewrites existing rows' };
221
+ }
222
+ if (/^ALTER\s+(INDEX|SEQUENCE|VIEW|TYPE|SCHEMA)\b[\s\S]*\bRENAME\b/i.test(code)) {
223
+ return { destructive: true, reason: 'renames something' };
224
+ }
225
+ return null;
226
+ }
227
+ const splitHelp = (what) => `${what}; split this into two releases: stop using it in one, remove it in a later release once that one is no longer on live, idle or draining`;
228
+ /** A destructive statement or step named `since` is drained once that release deploys nowhere. */
229
+ function hasDrained(since, deployedReleases) {
230
+ return !deployedReleases.includes(since);
231
+ }
232
+ function sqlProblems(migrationsJson, liveMigrationIds, deployedReleases) {
233
+ let declared;
234
+ try {
235
+ declared = parseMigrations(migrationsJson);
236
+ }
237
+ catch (error) {
238
+ return [{ migration: '', statement: '', reason: error.message }];
239
+ }
240
+ let pending;
241
+ try {
242
+ pending = unappliedDatabaseMigrations(declared, liveMigrationIds);
243
+ }
244
+ catch (error) {
245
+ return [{ migration: '', statement: '', reason: error.message }];
246
+ }
247
+ const problems = [];
248
+ for (const migration of pending) {
249
+ for (const statement of splitStatements(migration.sql)) {
250
+ const code = withoutLeadingComments(statement);
251
+ const classified = classifyStatement(code);
252
+ if (classified === null) {
253
+ problems.push({
254
+ migration: migration.id,
255
+ statement: oneLine(code),
256
+ reason: 'this statement is not one foundry recognises as additive; split it into its own release and ask a person',
257
+ });
258
+ continue;
259
+ }
260
+ if (!classified.destructive)
261
+ continue;
262
+ const since = declaredSince(statement);
263
+ if (since === null) {
264
+ problems.push({
265
+ migration: migration.id,
266
+ statement: oneLine(code),
267
+ reason: splitHelp(classified.reason),
268
+ });
269
+ continue;
270
+ }
271
+ if (!hasDrained(since, deployedReleases)) {
272
+ problems.push({
273
+ migration: migration.id,
274
+ statement: oneLine(code),
275
+ reason: `${classified.reason}; declared as the second step after release ${since}, but ${since} is still on live, idle or draining -- wait for it to fully drain, then release this one`,
276
+ });
277
+ }
278
+ }
279
+ }
280
+ return problems;
281
+ }
282
+ function doProblems(doMigrations, liveDoTag, declaredRemovals, deployedReleases) {
283
+ if (doMigrations.length === 0)
284
+ return [];
285
+ let appliedCount = 0;
286
+ if (liveDoTag !== undefined) {
287
+ const index = doMigrations.findIndex((m) => m.tag === liveDoTag);
288
+ if (index < 0) {
289
+ return [
290
+ {
291
+ migration: liveDoTag,
292
+ statement: '',
293
+ reason: "the live release holds a Durable Object migration that is not in this release's declared list; migrations are append-only",
294
+ },
295
+ ];
296
+ }
297
+ appliedCount = index + 1;
298
+ }
299
+ const folded = foldMigrationClasses(doMigrations.slice(0, appliedCount));
300
+ if ('error' in folded)
301
+ return [{ migration: '', statement: '', reason: folded.error }];
302
+ const problems = [];
303
+ for (const migration of doMigrations.slice(appliedCount)) {
304
+ for (const className of migration.deletedClasses ?? []) {
305
+ if (!folded.classes.has(className))
306
+ continue; // never reached live: nothing running still uses it
307
+ const removal = declaredRemovals.find((r) => r.className === className);
308
+ const statement = `deleted_classes: ${className}`;
309
+ if (!removal) {
310
+ problems.push({
311
+ migration: migration.tag,
312
+ statement,
313
+ reason: splitHelp(`deletes the Durable Object class '${className}', which the live release still uses`),
314
+ });
315
+ continue;
316
+ }
317
+ if (!hasDrained(removal.sinceRelease, deployedReleases)) {
318
+ problems.push({
319
+ migration: migration.tag,
320
+ statement,
321
+ reason: `deletes the Durable Object class '${className}', declared as the second step after release ${removal.sinceRelease}, but ${removal.sinceRelease} is still on live, idle or draining -- wait for it to fully drain, then release this one`,
322
+ });
323
+ }
324
+ }
325
+ }
326
+ return problems;
327
+ }
328
+ /**
329
+ * Whether a release's migrations (SQL and Durable Object) may go to idle, given what the
330
+ * previous live release already applied and which releases are deployed anywhere right now. See
331
+ * the module docstring for the rule and the removal convention.
332
+ */
333
+ export function checkMigrationCompatibility(input) {
334
+ const deployedReleases = input.deployedReleases ?? [];
335
+ const problems = [
336
+ ...sqlProblems(input.migrationsJson, input.liveMigrationIds ?? [], deployedReleases),
337
+ ...doProblems(input.doMigrations ?? [], input.liveDoTag, input.declaredRemovals ?? [], deployedReleases),
338
+ ];
339
+ return problems.length === 0 ? { ok: true } : { ok: false, problems };
340
+ }
@@ -124,8 +124,9 @@ export function classifyChange(org, change, plan) {
124
124
  throw new RequestedChangeError(`'${change.field}' is not a requestable field`);
125
125
  }
126
126
  // Archon validates the whole company for these; nothing waits for approval.
127
- if (change.field === 'app' || change.field === 'build')
127
+ if (change.field === 'app' || change.field === 'build') {
128
128
  return 'auto';
129
+ }
129
130
  if (change.field === 'bouleApps') {
130
131
  if (typeof change.enabled !== 'boolean') {
131
132
  throw new RequestedChangeError('bouleApps needs enabled, true or false');
package/dist/slug.d.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  export declare const RESERVED_SLUGS: ReadonlySet<string>;
3
3
  /** Every platform app, tunnel, project and database name starts `platform-`, so no company slug may. */
4
4
  export declare const RESERVED_SLUG_PREFIX = "platform-";
5
- /** Whether `label` ends in a suffix reserved for another org's own hostname (`-stage`, `-pr-<n>`, `-calls`, `-agora`, `-boule`, `-auth`, `-mail`, `-files`, `-sfu`, `-cms`). */
5
+ /** Whether `label` ends in a suffix reserved for another org's own hostname (`-stage`, `-pr-<n>`, `-calls`, `-agora`, `-boule`, `-kosmos`, `-auth`, `-mail`, `-files`, `-sfu`, `-cms`). */
6
6
  export declare function hasBarredSlugSuffix(label: string): boolean;
7
7
  /**
8
8
  * Why {@link validateSlug} refused a candidate slug, by name so a caller —
package/dist/slug.js CHANGED
@@ -138,6 +138,14 @@ const AGORA_SUFFIX = /-agora$/;
138
138
  * one that opts in.
139
139
  */
140
140
  const BOULE_SUFFIX = /-boule$/;
141
+ /**
142
+ * `<something>-kosmos` — a level-1 org's own kosmos hostname
143
+ * (`<slug>-kosmos.oikos.host`, foundry#949). Kosmos is opt-in, the same way
144
+ * {@link CALLS_SUFFIX} guards a feature an org can decline: a slug ending
145
+ * `-kosmos` is only barred because it collides with that one org's own
146
+ * hostname once kosmos is on, not because every org gets one.
147
+ */
148
+ const KOSMOS_SUFFIX = /-kosmos$/;
141
149
  /** `<something>-auth|-mail|-files|-sfu|-cms` — an own company's service hostnames on oikos.host (`plan/own-hostnames.ts`). */
142
150
  const OWN_SERVICE_SUFFIX = /-(auth|mail|files|sfu|cms)$/;
143
151
  /**
@@ -151,13 +159,14 @@ const OWN_SERVICE_SUFFIX = /-(auth|mail|files|sfu|cms)$/;
151
159
  * `names-`.
152
160
  */
153
161
  const NAMES_PREFIX = /^names(-|$)/;
154
- /** Whether `label` ends in a suffix reserved for another org's own hostname (`-stage`, `-pr-<n>`, `-calls`, `-agora`, `-boule`, `-auth`, `-mail`, `-files`, `-sfu`, `-cms`). */
162
+ /** Whether `label` ends in a suffix reserved for another org's own hostname (`-stage`, `-pr-<n>`, `-calls`, `-agora`, `-boule`, `-kosmos`, `-auth`, `-mail`, `-files`, `-sfu`, `-cms`). */
155
163
  export function hasBarredSlugSuffix(label) {
156
164
  return (STAGE_SUFFIX.test(label) ||
157
165
  PR_SUFFIX.test(label) ||
158
166
  CALLS_SUFFIX.test(label) ||
159
167
  AGORA_SUFFIX.test(label) ||
160
168
  BOULE_SUFFIX.test(label) ||
169
+ KOSMOS_SUFFIX.test(label) ||
161
170
  OWN_SERVICE_SUFFIX.test(label));
162
171
  }
163
172
  /**
@@ -189,7 +198,7 @@ export function validateSlug(slug) {
189
198
  if (hasBarredSlugSuffix(slug)) {
190
199
  return {
191
200
  reason: 'environment-suffix',
192
- message: `slug '${slug}' ends with an environment suffix ('-stage', '-pr-<digits>', '-calls', '-agora', '-boule', '-auth', '-mail', '-files', '-sfu' or '-cms') reserved for another org's own hostname (#28, #145, #164, #161) — 'acme-calls' is only legal as acme's calls hostname, 'acme-agora' only as acme's Agora hostname, 'acme-boule' only as acme's Boule hostname, never as an org slug in its own right`,
201
+ message: `slug '${slug}' ends with an environment suffix ('-stage', '-pr-<digits>', '-calls', '-agora', '-boule', '-kosmos', '-auth', '-mail', '-files', '-sfu' or '-cms') reserved for another org's own hostname (#28, #145, #164, #161, #949) — 'acme-calls' is only legal as acme's calls hostname, 'acme-agora' only as acme's Agora hostname, 'acme-boule' only as acme's Boule hostname, 'acme-kosmos' only as acme's kosmos hostname, never as an org slug in its own right`,
193
202
  };
194
203
  }
195
204
  return null;
@@ -174,6 +174,17 @@ function rowToOrg(row) {
174
174
  ...(row.agoraBlueGreen
175
175
  ? { agora: { blueGreen: readBlueGreen(row.agoraBlueGreen, 'agora') } }
176
176
  : {}),
177
+ // Omitted while unset, as `boule`/`agora` are. Validated on read, the same way.
178
+ ...(row.kosmosBlueGreen || row.kosmosEnabled
179
+ ? {
180
+ kosmos: {
181
+ ...(row.kosmosBlueGreen
182
+ ? { blueGreen: readBlueGreen(row.kosmosBlueGreen, 'kosmos') }
183
+ : {}),
184
+ ...(row.kosmosEnabled ? { enabled: true } : {}),
185
+ },
186
+ }
187
+ : {}),
177
188
  ...(row.versionPin ? { versionPin: readVersionPin(row) } : {}),
178
189
  site: { enabled: row.siteEnabled, usesDomain: row.siteUsesDomain },
179
190
  hosting: {
@@ -240,6 +251,8 @@ function orgToRow(org) {
240
251
  bouleBlueGreen: org.boule?.blueGreen ?? null,
241
252
  bouleApps: org.boule?.apps ?? false,
242
253
  agoraBlueGreen: org.agora?.blueGreen ?? null,
254
+ kosmosEnabled: org.kosmos?.enabled ?? false,
255
+ kosmosBlueGreen: org.kosmos?.blueGreen ?? null,
243
256
  servicesOwn: org.services?.own ?? false,
244
257
  servicesSfuSlot: org.services?.sfuSlot ?? null,
245
258
  versionPin: org.versionPin && Object.keys(org.versionPin).length > 0 ? org.versionPin : null,
@@ -461,6 +461,42 @@ export declare const declaredOrgs: import("drizzle-orm/pg-core").PgTableWithColu
461
461
  identity: undefined;
462
462
  generated: undefined;
463
463
  }, {}, {}>;
464
+ kosmosEnabled: import("drizzle-orm/pg-core").PgColumn<{
465
+ name: "kosmos_enabled";
466
+ tableName: "foundry_declared_orgs";
467
+ dataType: "boolean";
468
+ columnType: "PgBoolean";
469
+ data: boolean;
470
+ driverParam: boolean;
471
+ notNull: true;
472
+ hasDefault: true;
473
+ isPrimaryKey: false;
474
+ isAutoincrement: false;
475
+ hasRuntimeDefault: false;
476
+ enumValues: undefined;
477
+ baseColumn: never;
478
+ identity: undefined;
479
+ generated: undefined;
480
+ }, {}, {}>;
481
+ kosmosBlueGreen: import("drizzle-orm/pg-core").PgColumn<{
482
+ name: "kosmos_blue_green";
483
+ tableName: "foundry_declared_orgs";
484
+ dataType: "json";
485
+ columnType: "PgJsonb";
486
+ data: BlueGreenConfig | null;
487
+ driverParam: unknown;
488
+ notNull: false;
489
+ hasDefault: false;
490
+ isPrimaryKey: false;
491
+ isAutoincrement: false;
492
+ hasRuntimeDefault: false;
493
+ enumValues: undefined;
494
+ baseColumn: never;
495
+ identity: undefined;
496
+ generated: undefined;
497
+ }, {}, {
498
+ $type: BlueGreenConfig | null;
499
+ }>;
464
500
  versionPin: import("drizzle-orm/pg-core").PgColumn<{
465
501
  name: "version_pin";
466
502
  tableName: "foundry_declared_orgs";
@@ -48,6 +48,9 @@ export const declaredOrgs = pgTable('foundry_declared_orgs', {
48
48
  agoraBlueGreen: jsonb('agora_blue_green').$type(),
49
49
  // BouleApp.apps (org.ts): Boule runs apps for the company; `migrations/0063_boule_apps.sql`.
50
50
  bouleApps: boolean('boule_apps').notNull().default(false),
51
+ // KosmosApp (org.ts, foundry#949): the company's own kosmos, set up only when asked; `migrations/0066_org_kosmos.sql`.
52
+ kosmosEnabled: boolean('kosmos_enabled').notNull().default(false),
53
+ kosmosBlueGreen: jsonb('kosmos_blue_green').$type(),
51
54
  // VersionPin (org.ts): null follows the platform. `migrations/0053_org_version_pin.sql`.
52
55
  versionPin: jsonb('version_pin').$type(),
53
56
  // SiteConfig (org.ts, T10). A brand new org has no cms site.
@@ -80,6 +83,9 @@ export const declaredOrgs = pgTable('foundry_declared_orgs', {
80
83
  hostingRepos: jsonb('hosting_repos').notNull().default([]).$type(),
81
84
  // HostingConfig.github (org.ts); migrations/0062. Null: the company has no GitHub App installation.
82
85
  hostingGithub: jsonb('hosting_github').$type(),
86
+ // migrations/0065 added `hosting_native_apps` for foundry#948; foundry#951 dropped the column from
87
+ // use (Boule owns native settings now, estate#262) but not from the database -- a migration only
88
+ // adds in this release, since idle and live share one database. The column stays, unread.
83
89
  ssoEnabled: boolean('sso_enabled').notNull().default(false),
84
90
  guestsEnabled: boolean('guests_enabled').notNull().default(false),
85
91
  soloEnabled: boolean('solo_enabled').notNull().default(false),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/foundry",
3
- "version": "0.61.0",
3
+ "version": "0.62.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",
@@ -61,7 +61,7 @@
61
61
  "@wtfalch/audit": "0.6.0",
62
62
  "@wtfalch/audit-sealing": "npm:@wtfalch/audit@0.14.0",
63
63
  "@wtfalch/auth": "^0.26.0",
64
- "@wtfalch/edge": "0.15.1",
64
+ "@wtfalch/edge": "0.21.0",
65
65
  "@wtfalch/email": "^0.20.0",
66
66
  "@wtfalch/providers": "0.12.0",
67
67
  "ssh2": "1.17.0",