@wtfalch/foundry 0.45.1 → 0.47.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 (78) hide show
  1. package/dist/build-json.d.ts +28 -0
  2. package/dist/build-json.js +95 -0
  3. package/dist/index.d.ts +10 -4
  4. package/dist/index.js +6 -2
  5. package/dist/migrations/0058_app_requests.sql +32 -0
  6. package/dist/org.d.ts +68 -5
  7. package/dist/org.js +287 -135
  8. package/dist/plan/agora.d.ts +14 -0
  9. package/dist/plan/agora.js +21 -0
  10. package/dist/plan/backup-gate.d.ts +15 -0
  11. package/dist/plan/backup-gate.js +38 -0
  12. package/dist/plan/boule-owners-http.d.ts +12 -0
  13. package/dist/plan/boule-owners-http.js +59 -0
  14. package/dist/plan/boule.d.ts +38 -2
  15. package/dist/plan/boule.js +72 -1
  16. package/dist/plan/company-backups.d.ts +7 -1
  17. package/dist/plan/company-backups.js +42 -2
  18. package/dist/plan/cookie-domain.d.ts +41 -0
  19. package/dist/plan/cookie-domain.js +71 -0
  20. package/dist/plan/desktop-auth.d.ts +58 -0
  21. package/dist/plan/desktop-auth.js +144 -0
  22. package/dist/plan/desktop-callback.d.ts +26 -0
  23. package/dist/plan/desktop-callback.js +59 -0
  24. package/dist/plan/diff.d.ts +13 -4
  25. package/dist/plan/diff.js +277 -88
  26. package/dist/plan/executor.d.ts +136 -6
  27. package/dist/plan/executor.js +732 -105
  28. package/dist/plan/health-gaps.js +5 -0
  29. package/dist/plan/health-targets.js +1 -2
  30. package/dist/plan/hostnames.d.ts +2 -2
  31. package/dist/plan/hostnames.js +6 -6
  32. package/dist/plan/index.d.ts +4 -1
  33. package/dist/plan/index.js +3 -1
  34. package/dist/plan/job-app.d.ts +23 -0
  35. package/dist/plan/job-app.js +41 -0
  36. package/dist/plan/live.d.ts +6 -0
  37. package/dist/plan/live.js +16 -0
  38. package/dist/plan/login-host.d.ts +28 -0
  39. package/dist/plan/login-host.js +36 -0
  40. package/dist/plan/org-names.js +9 -1
  41. package/dist/plan/org-postgres-job.d.ts +22 -0
  42. package/dist/plan/org-postgres-job.js +91 -0
  43. package/dist/plan/org-postgres-migrate.d.ts +115 -0
  44. package/dist/plan/org-postgres-migrate.js +466 -0
  45. package/dist/plan/org-postgres.d.ts +90 -0
  46. package/dist/plan/org-postgres.js +293 -0
  47. package/dist/plan/plan-apply.d.ts +60 -3
  48. package/dist/plan/plan-apply.js +174 -8
  49. package/dist/plan/platform-diff.d.ts +45 -0
  50. package/dist/plan/platform-diff.js +159 -4
  51. package/dist/plan/platform-org.d.ts +2 -0
  52. package/dist/plan/platform-org.js +4 -0
  53. package/dist/plan/platform-providers.d.ts +12 -2
  54. package/dist/plan/platform-providers.js +282 -9
  55. package/dist/plan/post-logout-uris.d.ts +22 -0
  56. package/dist/plan/post-logout-uris.js +43 -0
  57. package/dist/plan/providers-readers.d.ts +10 -1
  58. package/dist/plan/providers-readers.js +56 -5
  59. package/dist/plan/sign-in-bindings.d.ts +4 -0
  60. package/dist/plan/sign-in-bindings.js +10 -0
  61. package/dist/plan/sign-in-org.d.ts +19 -6
  62. package/dist/plan/sign-in-org.js +35 -10
  63. package/dist/plan/sign-in.d.ts +12 -5
  64. package/dist/plan/sign-in.js +12 -5
  65. package/dist/plan/signin-db-move.js +10 -34
  66. package/dist/plan/worker-app.js +3 -1
  67. package/dist/refuse-org.d.ts +7 -0
  68. package/dist/refuse-org.js +10 -0
  69. package/dist/requested-changes.d.ts +50 -4
  70. package/dist/requested-changes.js +9 -1
  71. package/dist/store/postgres.d.ts +5 -1
  72. package/dist/store/postgres.js +67 -8
  73. package/dist/store/schema.d.ts +72 -0
  74. package/dist/store/schema.js +4 -0
  75. package/dist/store.d.ts +24 -1
  76. package/dist/worker-app-config.d.ts +8 -2
  77. package/dist/worker-app-config.js +20 -6
  78. package/package.json +5 -5
@@ -1,5 +1,5 @@
1
1
  import { and, asc, desc, eq, inArray, isNull, ne, sql } from 'drizzle-orm';
2
- import { APP_ENTRY_KEY, DEFAULT_MAIL_APPS, DEFAULT_NO_REPLY_LOCAL_PART, validateBlueGreen, validateBuyerIdentity, validateCalls, validateCustomers, validateDomain, validateDomainClaim, validateHosting, validateMail, validateOwners, validateServices, validateSignIn, validateSite, validateVersionPin, } from '../org.js';
2
+ import { APP_ENTRY_KEY, DEFAULT_MAIL_APPS, DEFAULT_NO_REPLY_LOCAL_PART, refuseCustomers, refuseHosting, validateBlueGreen, validateBuyerIdentity, validateCalls, validateDomain, validateDomainClaim, validateMail, validateOwners, validateServices, validateSignIn, validateSite, validateVersionPin, } from '../org.js';
3
3
  import { hostingNames } from '../plan/locked-hostnames.js';
4
4
  import { findNameClashes, nameClashMessage } from '../plan/org-names.js';
5
5
  import { validatePlatform } from '../platform.js';
@@ -77,9 +77,16 @@ export function uniqueViolationConstraint(error) {
77
77
  * is that throw.
78
78
  */
79
79
  export class DeclaredOrgError extends Error {
80
- constructor(message) {
80
+ /** Set when a hosting or customers validator refused the write: which rule, and where in the org. */
81
+ code;
82
+ path;
83
+ constructor(message, refusal) {
81
84
  super(message);
82
85
  this.name = 'DeclaredOrgError';
86
+ if (refusal) {
87
+ this.code = refusal.code;
88
+ this.path = refusal.path;
89
+ }
83
90
  }
84
91
  }
85
92
  /**
@@ -357,9 +364,9 @@ export function createPostgresDeclaredOrgStore(db) {
357
364
  // #71: same place `requireValidBuyer` checks the buyer identity (#67), on
358
365
  // both `create` and the merged result of `update`.
359
366
  function requireValidHosting(org) {
360
- const reason = validateHosting(org.hosting, hostingNames(org, []));
361
- if (reason)
362
- throw new DeclaredOrgError(`hosting invalid: ${reason} (#71)`);
367
+ const refusal = refuseHosting(org.hosting, hostingNames(org, []));
368
+ if (refusal)
369
+ throw new DeclaredOrgError(`hosting invalid: ${refusal.message} (#71)`, refusal);
363
370
  }
364
371
  // #101: same place `requireValidHosting` checks hosting, on both `create`
365
372
  // and the merged result of `update`.
@@ -411,9 +418,10 @@ export function createPostgresDeclaredOrgStore(db) {
411
418
  const signInReason = validateSignIn(org);
412
419
  if (signInReason)
413
420
  throw new DeclaredOrgError(`signIn invalid: ${signInReason}`);
414
- const customersReason = validateCustomers(org);
415
- if (customersReason)
416
- throw new DeclaredOrgError(`customers invalid: ${customersReason}`);
421
+ const customersRefusal = refuseCustomers(org);
422
+ if (customersRefusal) {
423
+ throw new DeclaredOrgError(`customers invalid: ${customersRefusal.message}`, customersRefusal);
424
+ }
417
425
  const slot = org.services?.sfuSlot ?? null;
418
426
  if (slot === null)
419
427
  return;
@@ -855,6 +863,10 @@ function rowToRequestedChange(row) {
855
863
  requestedBy: row.requestedBy,
856
864
  createdAt: row.createdAt.toISOString(),
857
865
  state: row.state,
866
+ requester: row.requester ?? null,
867
+ idempotencyKey: row.idempotencyKey ?? null,
868
+ reason: row.reason ?? null,
869
+ updatedAt: row.updatedAt.toISOString(),
858
870
  };
859
871
  }
860
872
  /**
@@ -885,6 +897,53 @@ export function createPostgresRequestedChangeStore(db) {
885
897
  throw new RequestedChangeError('insert returned no row');
886
898
  return rowToRequestedChange(row);
887
899
  },
900
+ async createOrGet({ orgId, value, requestedBy, requester, idempotencyKey }) {
901
+ if (value.field !== 'app' && value.field !== 'build') {
902
+ throw new RequestedChangeError(`'${value.field}' is not an app or build request`);
903
+ }
904
+ if (idempotencyKey === '')
905
+ throw new RequestedChangeError('an idempotency key is required');
906
+ const [inserted] = await db
907
+ .insert(foundryRequestedChanges)
908
+ .values({
909
+ orgId,
910
+ field: value.field,
911
+ value: requestedChangePayload(value),
912
+ requestedBy,
913
+ state: 'applying',
914
+ requester: { ...requester },
915
+ idempotencyKey,
916
+ })
917
+ .onConflictDoNothing()
918
+ .returning();
919
+ if (inserted)
920
+ return { request: rowToRequestedChange(inserted), created: true };
921
+ const [existing] = await db
922
+ .select()
923
+ .from(foundryRequestedChanges)
924
+ .where(and(eq(foundryRequestedChanges.orgId, orgId), eq(foundryRequestedChanges.idempotencyKey, idempotencyKey)))
925
+ .limit(1);
926
+ if (!existing)
927
+ throw new RequestedChangeError('insert returned no row');
928
+ return { request: rowToRequestedChange(existing), created: false };
929
+ },
930
+ async transition(id, from, to, reason) {
931
+ const [row] = await db
932
+ .update(foundryRequestedChanges)
933
+ .set({ state: to, reason: reason ? { ...reason } : null, updatedAt: new Date() })
934
+ .where(and(eq(foundryRequestedChanges.id, id), inArray(foundryRequestedChanges.state, [...from])))
935
+ .returning();
936
+ return row ? rowToRequestedChange(row) : null;
937
+ },
938
+ async openForApp(orgId, key) {
939
+ const [row] = await db
940
+ .select()
941
+ .from(foundryRequestedChanges)
942
+ .where(and(eq(foundryRequestedChanges.orgId, orgId), eq(foundryRequestedChanges.state, 'applying'), inArray(foundryRequestedChanges.field, ['app', 'build']), sql `${foundryRequestedChanges.value} ->> 'key' = ${key}`))
943
+ .orderBy(desc(foundryRequestedChanges.id))
944
+ .limit(1);
945
+ return row ? rowToRequestedChange(row) : null;
946
+ },
888
947
  async get(id) {
889
948
  const [row] = await db
890
949
  .select()
@@ -2470,6 +2470,78 @@ export declare const foundryRequestedChanges: import("drizzle-orm/pg-core").PgTa
2470
2470
  identity: undefined;
2471
2471
  generated: undefined;
2472
2472
  }, {}, {}>;
2473
+ requester: import("drizzle-orm/pg-core").PgColumn<{
2474
+ name: "requester";
2475
+ tableName: "foundry_requested_changes";
2476
+ dataType: "json";
2477
+ columnType: "PgJsonb";
2478
+ data: Record<string, unknown>;
2479
+ driverParam: unknown;
2480
+ notNull: false;
2481
+ hasDefault: false;
2482
+ isPrimaryKey: false;
2483
+ isAutoincrement: false;
2484
+ hasRuntimeDefault: false;
2485
+ enumValues: undefined;
2486
+ baseColumn: never;
2487
+ identity: undefined;
2488
+ generated: undefined;
2489
+ }, {}, {
2490
+ $type: Record<string, unknown>;
2491
+ }>;
2492
+ idempotencyKey: import("drizzle-orm/pg-core").PgColumn<{
2493
+ name: "idempotency_key";
2494
+ tableName: "foundry_requested_changes";
2495
+ dataType: "string";
2496
+ columnType: "PgText";
2497
+ data: string;
2498
+ driverParam: string;
2499
+ notNull: false;
2500
+ hasDefault: false;
2501
+ isPrimaryKey: false;
2502
+ isAutoincrement: false;
2503
+ hasRuntimeDefault: false;
2504
+ enumValues: [string, ...string[]];
2505
+ baseColumn: never;
2506
+ identity: undefined;
2507
+ generated: undefined;
2508
+ }, {}, {}>;
2509
+ reason: import("drizzle-orm/pg-core").PgColumn<{
2510
+ name: "reason";
2511
+ tableName: "foundry_requested_changes";
2512
+ dataType: "json";
2513
+ columnType: "PgJsonb";
2514
+ data: Record<string, unknown>;
2515
+ driverParam: unknown;
2516
+ notNull: false;
2517
+ hasDefault: false;
2518
+ isPrimaryKey: false;
2519
+ isAutoincrement: false;
2520
+ hasRuntimeDefault: false;
2521
+ enumValues: undefined;
2522
+ baseColumn: never;
2523
+ identity: undefined;
2524
+ generated: undefined;
2525
+ }, {}, {
2526
+ $type: Record<string, unknown>;
2527
+ }>;
2528
+ updatedAt: import("drizzle-orm/pg-core").PgColumn<{
2529
+ name: "updated_at";
2530
+ tableName: "foundry_requested_changes";
2531
+ dataType: "date";
2532
+ columnType: "PgTimestamp";
2533
+ data: Date;
2534
+ driverParam: string;
2535
+ notNull: true;
2536
+ hasDefault: true;
2537
+ isPrimaryKey: false;
2538
+ isAutoincrement: false;
2539
+ hasRuntimeDefault: false;
2540
+ enumValues: undefined;
2541
+ baseColumn: never;
2542
+ identity: undefined;
2543
+ generated: undefined;
2544
+ }, {}, {}>;
2473
2545
  };
2474
2546
  dialect: "pg";
2475
2547
  }>;
@@ -234,6 +234,10 @@ export const foundryRequestedChanges = pgTable('foundry_requested_changes', {
234
234
  requestedBy: text('requested_by').notNull(),
235
235
  createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
236
236
  state: text('state').notNull().default('needs_approval'),
237
+ requester: jsonb('requester').$type(),
238
+ idempotencyKey: text('idempotency_key'),
239
+ reason: jsonb('reason').$type(),
240
+ updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
237
241
  });
238
242
  /** Mirrors `migrations/0055_release_requests.sql`: an operator's release action, consumed once by the runner. */
239
243
  export const foundryReleaseRequests = pgTable('foundry_release_requests', {
package/dist/store.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { DeclaredOrg, OrgId } from './org.js';
2
2
  import type { DeclaredPlatform } from './platform.js';
3
- import type { RequestedChange, RequestedChangeValue } from './requested-changes.js';
3
+ import type { RequestedBy, RequestedChange, RequestedChangeState, RequestedChangeValue } from './requested-changes.js';
4
4
  import type { AppliedState } from './state.js';
5
5
  /** `DeclaredOrgStore.update`'s options (#351). */
6
6
  export interface UpdateOptions {
@@ -190,6 +190,29 @@ export interface RequestedChangeStore {
190
190
  value: RequestedChangeValue;
191
191
  requestedBy: string;
192
192
  }): Promise<RequestedChange>;
193
+ /**
194
+ * Stores an `app` or `build` request in `applying`, or returns the one already stored under the
195
+ * same (orgId, idempotencyKey) with `created: false` and writes nothing. Safe under a race.
196
+ * Throws `RequestedChangeError` for any other kind.
197
+ */
198
+ createOrGet(input: {
199
+ orgId: OrgId;
200
+ org: DeclaredOrg;
201
+ value: RequestedChangeValue;
202
+ requestedBy: string;
203
+ requester: RequestedBy;
204
+ idempotencyKey: string;
205
+ }): Promise<{
206
+ request: RequestedChange;
207
+ created: boolean;
208
+ }>;
209
+ /**
210
+ * Compare-and-set: moves request `id` to `to` only when it is in one of `from`, and returns the
211
+ * new row; null when it was not (or does not exist). `reason` is stored (null when omitted).
212
+ */
213
+ transition(id: number, from: readonly RequestedChangeState[], to: RequestedChangeState, reason?: RequestedChange['reason']): Promise<RequestedChange | null>;
214
+ /** The `applying` app or build request for entry `key` of that org, or null. */
215
+ openForApp(orgId: OrgId, key: string): Promise<RequestedChange | null>;
193
216
  get(id: number): Promise<RequestedChange | null>;
194
217
  /** Every request an org has made, oldest first. */
195
218
  listForOrg(orgId: OrgId): Promise<RequestedChange[]>;
@@ -117,13 +117,19 @@ export declare function validateArtifact(at: string, artifactUrl: string, sha256
117
117
  * The one exact name a Worker app is served at: `<host>.<domain>` or the apex
118
118
  * on the org's own domain, else `<slug>-<key>.oikos.host` (one label under
119
119
  * `oikos.host`, so the free Universal certificate covers it), and
120
- * `<slug>.oikos.host` for the entry keyed `app`.
120
+ * `<slug>.oikos.host` for the main entry.
121
121
  */
122
122
  export declare function workerAppHostnameOf(org: {
123
123
  slug: string;
124
124
  domain: string | null;
125
125
  usesDomain: boolean;
126
- }, key: string, host: string): string;
126
+ }, entry: Pick<HostingEntry, 'key' | 'main'>, host: string): string;
127
+ /**
128
+ * A Worker app declared before its first build has an empty `artifactUrl` and an empty
129
+ * `artifactSha256`. It validates, and the planner deploys nothing until both are set. One empty
130
+ * and one set is still refused.
131
+ */
132
+ export declare function workerAppHasBuild(app: Pick<WorkerAppConfig, 'artifactUrl' | 'artifactSha256'>): boolean;
127
133
  /**
128
134
  * The Durable Object classes the migrations leave behind, folded in order:
129
135
  * created, then removed, then renamed. A rename of a class that does not exist
@@ -106,12 +106,24 @@ export function validateArtifact(at, artifactUrl, sha256, repo = null) {
106
106
  * The one exact name a Worker app is served at: `<host>.<domain>` or the apex
107
107
  * on the org's own domain, else `<slug>-<key>.oikos.host` (one label under
108
108
  * `oikos.host`, so the free Universal certificate covers it), and
109
- * `<slug>.oikos.host` for the entry keyed `app`.
109
+ * `<slug>.oikos.host` for the main entry.
110
110
  */
111
- export function workerAppHostnameOf(org, key, host) {
111
+ export function workerAppHostnameOf(org, entry, host) {
112
112
  if (org.usesDomain && org.domain)
113
113
  return host === '@' ? org.domain : `${host}.${org.domain}`;
114
- return key === 'app' ? `${org.slug}.oikos.host` : `${org.slug}-${key}.oikos.host`;
114
+ return isMain(entry) ? `${org.slug}.oikos.host` : `${org.slug}-${entry.key}.oikos.host`;
115
+ }
116
+ /** `org.ts`'s `isMainEntry`, restated: that file imports this one. */
117
+ function isMain(entry) {
118
+ return entry.main ?? entry.key === 'app';
119
+ }
120
+ /**
121
+ * A Worker app declared before its first build has an empty `artifactUrl` and an empty
122
+ * `artifactSha256`. It validates, and the planner deploys nothing until both are set. One empty
123
+ * and one set is still refused.
124
+ */
125
+ export function workerAppHasBuild(app) {
126
+ return app.artifactUrl !== '' || app.artifactSha256 !== '';
115
127
  }
116
128
  /** True for a real calendar date written `YYYY-MM-DD`. */
117
129
  function isCalendarDate(value) {
@@ -197,7 +209,9 @@ export function validateEntryWorkerApp(entry, hosting, names) {
197
209
  if (entry.key.length > WORKER_NAME_MAX_LENGTH - WORKER_NAME_FIXED_LENGTH) {
198
210
  return `${at} entry key is too long: the deployed Worker's name (an 8-character org hash, the key, and "${WORKER_APP_NAME_SUFFIX}") must fit Cloudflare's ${WORKER_NAME_MAX_LENGTH}-character limit`;
199
211
  }
200
- const invalidArtifact = validateArtifact(at, app.artifactUrl, app.artifactSha256, entry.repo);
212
+ const invalidArtifact = workerAppHasBuild(app)
213
+ ? validateArtifact(at, app.artifactUrl, app.artifactSha256, entry.repo)
214
+ : null;
201
215
  if (invalidArtifact)
202
216
  return invalidArtifact;
203
217
  if (app.compatibilityDate !== undefined && !isCalendarDate(app.compatibilityDate)) {
@@ -224,14 +238,14 @@ export function validateEntryWorkerApp(entry, hosting, names) {
224
238
  slug: names.slug,
225
239
  domain: names.domain,
226
240
  usesDomain: hosting.usesDomain,
227
- }, entry.key, app.host);
241
+ }, entry, app.host);
228
242
  if (names.locked.has(served))
229
243
  return `${at} host is a name foundry owns: ${served}`;
230
244
  // Without a domain the name is `<slug>-<key>.oikos.host`, the shape of
231
245
  // every environment and service host of the org (`-auth`, `-agora`,
232
246
  // `-boule`, `-stage`, `-pr-<n>`, ...). `slug.ts` already refuses a slug
233
247
  // that ends in one of those suffixes, so the same check refuses the key.
234
- if (!domain && entry.key !== 'app' && validateSlug(`${names.slug}-${entry.key}`)) {
248
+ if (!domain && !isMain(entry) && validateSlug(`${names.slug}-${entry.key}`)) {
235
249
  return `${at} entry key '${entry.key}' would serve ${served}, a name foundry reserves for the org's own services and environments`;
236
250
  }
237
251
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/foundry",
3
- "version": "0.45.1",
3
+ "version": "0.47.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",
@@ -38,15 +38,15 @@
38
38
  "status": "node scripts/status.mjs"
39
39
  },
40
40
  "peerDependencies": {
41
- "@wtfalch/providers": ">=0.10.0",
41
+ "@wtfalch/providers": ">=0.12.0",
42
42
  "drizzle-orm": ">=0.45.2"
43
43
  },
44
44
  "dependencies": {
45
45
  "@wtfalch/audit": "0.6.0",
46
- "@wtfalch/auth": "0.16.0",
47
- "@wtfalch/edge": "0.13.0",
46
+ "@wtfalch/auth": "^0.26.0",
47
+ "@wtfalch/edge": "0.15.1",
48
48
  "@wtfalch/email": "^0.20.0",
49
- "@wtfalch/providers": "0.10.0",
49
+ "@wtfalch/providers": "0.12.0",
50
50
  "ssh2": "1.17.0",
51
51
  "zod": "4.6.5"
52
52
  },