@wtfalch/foundry 0.67.0 → 0.68.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.
@@ -5,6 +5,17 @@ import { type WorkerAppConfig } from './worker-app-config.js';
5
5
  * app. Same shapes as `WorkerAppConfig`; `repo` and `commit` say where the build came from.
6
6
  * Whether `artifactUrl` is an asset of that release is checked by the reader that fetched it.
7
7
  */
8
+ /**
9
+ * A container's image in a build.json: a registry image (`repository` and `digest`), or an asset of the
10
+ * release (`asset` is the file name, `sha256` its hex digest) that the reader resolves to a registry image.
11
+ */
12
+ export type BuildJsonContainerImage = NonNullable<WorkerAppConfig['containers']>[number]['image'] | {
13
+ asset: string;
14
+ sha256: string;
15
+ };
16
+ export type BuildJsonContainer = Omit<NonNullable<WorkerAppConfig['containers']>[number], 'image'> & {
17
+ image: BuildJsonContainerImage;
18
+ };
8
19
  export interface BuildJson {
9
20
  repo: string;
10
21
  commit: string;
@@ -13,7 +24,11 @@ export interface BuildJson {
13
24
  compatibilityDate?: string;
14
25
  compatibilityFlags?: 'nodejs_compat'[];
15
26
  durableObjects: WorkerAppConfig['durableObjects'];
16
- containers?: WorkerAppConfig['containers'];
27
+ containers?: BuildJsonContainer[];
28
+ /** Cloudflare cron triggers: at most 3 standard five-field cron expressions, each once. */
29
+ triggers?: {
30
+ crons: string[];
31
+ };
17
32
  migrationsSha256?: string;
18
33
  }
19
34
  export type BuildJsonErrorCode = 'unknown_field' | 'missing_field' | 'wrong_type' | 'not_https' | 'bad_sha256';
@@ -1,6 +1,7 @@
1
1
  import { z } from 'zod';
2
+ import { workerAppCronProblem } from './entry-rules.js';
2
3
  import { ENTRY_IMAGE_REPOSITORY } from './org.js';
3
- import { WORKER_APP_COMPATIBILITY_FLAGS, WORKER_APP_INSTANCE_TYPES, WORKER_APP_MAX_INSTANCES, } from './worker-app-config.js';
4
+ import { WORKER_APP_COMPATIBILITY_FLAGS, WORKER_APP_CRONS_MAX, WORKER_APP_INSTANCE_TYPES, WORKER_APP_MAX_INSTANCES, } from './worker-app-config.js';
4
5
  /** Thrown by {@link parseBuildJson}. `path` is dotted into the build.json; the message never carries a value. */
5
6
  export class BuildJsonError extends Error {
6
7
  code;
@@ -39,15 +40,29 @@ const durableObjects = z.strictObject({
39
40
  });
40
41
  const container = z.strictObject({
41
42
  className: z.string(),
42
- image: z.strictObject({
43
- repository: z.string(),
44
- digest: z.string().regex(DIGEST, { message: 'wrong_type' }),
45
- }),
43
+ image: z.union([
44
+ z.strictObject({
45
+ repository: z.string(),
46
+ digest: z.string().regex(DIGEST, { message: 'wrong_type' }),
47
+ }),
48
+ z.strictObject({ asset: text, sha256 }),
49
+ ]),
46
50
  instanceType: z.string().refine((v) => WORKER_APP_INSTANCE_TYPES.has(v), {
47
51
  message: 'wrong_type',
48
52
  }),
49
53
  maxInstances: z.number().int().min(1).max(WORKER_APP_MAX_INSTANCES),
50
54
  });
55
+ const cron = z.string().superRefine((value, ctx) => {
56
+ if (workerAppCronProblem(value) !== null)
57
+ ctx.addIssue({ code: 'custom', message: 'wrong_type' });
58
+ });
59
+ const triggers = z
60
+ .strictObject({ crons: z.array(cron).max(WORKER_APP_CRONS_MAX) })
61
+ .superRefine((value, ctx) => {
62
+ if (new Set(value.crons).size !== value.crons.length) {
63
+ ctx.addIssue({ code: 'custom', path: ['crons'], message: 'wrong_type' });
64
+ }
65
+ });
51
66
  export const buildJsonSchema = z.strictObject({
52
67
  repo: text,
53
68
  commit: text,
@@ -59,6 +74,7 @@ export const buildJsonSchema = z.strictObject({
59
74
  .optional(),
60
75
  durableObjects,
61
76
  containers: z.array(container).optional(),
77
+ triggers: triggers.optional(),
62
78
  migrationsSha256: sha256.optional(),
63
79
  });
64
80
  function valueAt(input, path) {
@@ -39,8 +39,8 @@ export const assistants = {
39
39
  },
40
40
  check(values, modules) {
41
41
  const signIn = modules['sign-in'];
42
- if (signIn?.users !== 'customers' || signIn?.registration !== 'open') {
43
- return problem(null, 'Help for AI assistants needs Sign-in for your customers, where anyone can make an account: each customer connects their own assistant. In Sign-in, choose "Your customers" and "Anyone".');
42
+ if (signIn?.users !== 'customers') {
43
+ return problem(null, 'Help for AI assistants needs Sign-in for your customers: each customer connects their own assistant. In Sign-in, choose "Your customers". They can sign up themselves or be invited.');
44
44
  }
45
45
  const onboarding = text(values, 'onboardingPath');
46
46
  if (onboarding !== undefined && !isPagePath(onboarding)) {
package/dist/org.js CHANGED
@@ -922,8 +922,8 @@ function validateEntryAuth(entry, hosting, domain) {
922
922
  return refusal('auth_needs_exact_hostname', `hosting.entries.${entry.key}.hostnames`, `${at} needs an exact hostname to sign in at`);
923
923
  }
924
924
  if (auth.mcp !== undefined) {
925
- if (auth.users !== 'customers' || auth.registration !== 'open') {
926
- return refusal('mcp_needs_open_customers', `hosting.entries.${entry.key}.uses.auth.mcp`, `${at} mcp needs users 'customers' and registration 'open': an MCP client signs the customer in, and only customers register themselves`);
925
+ if (auth.users !== 'customers') {
926
+ return refusal('mcp_needs_open_customers', `hosting.entries.${entry.key}.uses.auth.mcp`, `${at} mcp needs users 'customers': an MCP client signs the customer in, and registration may be open or closed`);
927
927
  }
928
928
  if (typeof auth.mcp?.path !== 'string' ||
929
929
  !MCP_PATH.test(auth.mcp.path) ||
@@ -379,6 +379,12 @@ export interface PlanBlueGreenInput {
379
379
  probes: boolean;
380
380
  access: boolean;
381
381
  };
382
+ /**
383
+ * The platform's own company (`DeclaredPlatform.ownerOrgId`). Boule and Agora are our own products,
384
+ * so they plan blue/green only for this company (#1019); any other company keeps them as one plain
385
+ * app instead. Absent: not gated, for a caller with no platform in scope.
386
+ */
387
+ ownerOrgId?: string | null;
382
388
  }
383
389
  /**
384
390
  * Plans the blue/green parts of an org: per usable server, one structure step (the apps, the balancer, the
@@ -589,6 +589,13 @@ export function planBlueGreen(input) {
589
589
  description: `${org.slug}'s ${id} app blue/green is blocked, not planned: ${why}`,
590
590
  key: `bg:${id}:${part}`,
591
591
  });
592
+ if (input.ownerOrgId !== undefined &&
593
+ builtin &&
594
+ (id === 'boule' || id === 'agora') &&
595
+ org.id !== input.ownerOrgId) {
596
+ blocked("only the platform's own company runs Boule and Agora blue/green; it runs as one plain app", 'owner');
597
+ continue;
598
+ }
592
599
  if (entry?.buildPack === 'dockercompose')
593
600
  continue;
594
601
  if (colorAppName(subject.base, 'green').length > MAX_NAME) {
package/dist/plan/diff.js CHANGED
@@ -571,6 +571,7 @@ export function computePlanDetail(declaredOrg, live, recordedIds = {}, agoraHost
571
571
  servers: options.blueGreenServers,
572
572
  canHoldSecrets: platformReadiness.canHoldSecrets === true,
573
573
  accessProviderId: platformReadiness.accessProviderId ?? null,
574
+ ownerOrgId: platformReadiness.platformOwnerOrgId,
574
575
  ...(options.stageSinkDown ? { stageSinkDown: true } : {}),
575
576
  ...(options.stageGuardPorts ? { stageGuardPorts: options.stageGuardPorts } : {}),
576
577
  appPlanned: (entryKey) => plannedSteps.some((p) => p.action.system === 'coolify' &&
@@ -1179,7 +1179,12 @@ export class FoundryPlanApply {
1179
1179
  const primaryUuid = context.recordedIds()[PRIMARY_SERVER_SCOPE_KEY];
1180
1180
  if (!store || !this.executor.releaseHost || !primaryUuid)
1181
1181
  return out;
1182
- const subjects = blueGreenSubjects(org, context.recordedIds());
1182
+ // #1019: Boule and Agora are our own products, so their blue/green release only runs for the
1183
+ // platform's own company; any other company's declaration was never planned as a structure, and a
1184
+ // leftover one is not run here either.
1185
+ const declaredPlatform = await this.platformDeps?.store.get();
1186
+ const ownerOrgId = declaredPlatform?.ownerOrgId ?? null;
1187
+ const subjects = blueGreenSubjects(org, context.recordedIds()).filter((s) => (s.builtin !== 'boule' && s.builtin !== 'agora') || ownerOrgId === org.id);
1183
1188
  // A copy waits while any subject of the org has a release running: the runner must stay free to roll back.
1184
1189
  if (copies === 'claim' && releaseRunning(subjects, context.recordedIds()))
1185
1190
  return out;
@@ -2147,6 +2147,13 @@ export async function provisionAuthStack(scope, deps, target, input) {
2147
2147
  const heldMailKey = await deps.heldKeys?.resolve(target.refs.brokerMailKey);
2148
2148
  if (heldMailKey)
2149
2149
  runtime.onSecret?.(heldMailKey);
2150
+ // Auth 0.35.0: the login app mails verification and invite codes itself (it throws without
2151
+ // `LETTERMINT_API_TOKEN` and sends nothing without `LOGIN_MAIL_FROM`). Both the login and the
2152
+ // in-app login app read `loginEnvs`, so both get the held key and this stack's sender address.
2153
+ if (heldMailKey)
2154
+ loginEnvs.push({ key: 'LETTERMINT_API_TOKEN', value: heldMailKey });
2155
+ if (input.smtpSender)
2156
+ loginEnvs.push({ key: 'LOGIN_MAIL_FROM', value: input.smtpSender });
2150
2157
  // `AUTH_APPS` and `ZITADEL_SERVICE_USER_TOKEN`/`_FILE` are not pushed here
2151
2158
  // -- this function's own doc comment, "Known gaps". `LETTERMINT_API_TOKEN`
2152
2159
  // is, once held (#210): pushing it costs nothing even while the broker
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/foundry",
3
- "version": "0.67.0",
3
+ "version": "0.68.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",