@wtfalch/foundry 0.63.0 → 0.65.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.
@@ -7,3 +7,4 @@ export { type ApplyWorkerAppMigrationsDeps, type ApplyWorkerAppMigrationsInput,
7
7
  export { type ReleaseAsset, ReleaseAssetError, fetchReleaseAsset, parseAppRepo, releaseAssetOf, } from './github.js';
8
8
  export { type WorkerAppMigrationRunner, migrationsUrl } from '../worker-app-migrations.js';
9
9
  export { APPS_CLOUDFLARE_TOKEN_PERMISSIONS, type AppsCloudflare, type HyperdriveConnection, } from './cloudflare.js';
10
+ export { type ServerEntryContext, ServerEntryError, processUrlEnvName, serverEntriesOf, } from './server-entry.js';
@@ -7,3 +7,4 @@ export { RELEASE_MIGRATION_SOURCE, applyWorkerAppMigrations, migrationChecksum,
7
7
  export { ReleaseAssetError, fetchReleaseAsset, parseAppRepo, releaseAssetOf, } from './github.js';
8
8
  export { migrationsUrl } from '../worker-app-migrations.js';
9
9
  export { APPS_CLOUDFLARE_TOKEN_PERMISSIONS, } from './cloudflare.js';
10
+ export { ServerEntryError, processUrlEnvName, serverEntriesOf, } from './server-entry.js';
@@ -0,0 +1,43 @@
1
+ import { type ServerBuildJson } from '../build-json.js';
2
+ import { type AppManifest, type AppModules } from '../modules/index.js';
3
+ import { type EnvValue, type HostingEntry } from '../org.js';
4
+ /** Thrown by {@link serverEntriesOf}. The message is plain words for the company and never carries a secret. */
5
+ export declare class ServerEntryError extends Error {
6
+ constructor(message: string);
7
+ }
8
+ /** What {@link serverEntriesOf} needs to know about the company and where the app runs. */
9
+ export interface ServerEntryContext {
10
+ /** The company's slug: an entry reaches another on the company network as `<slug>-<entry key>` (`<slug>` for `app`). */
11
+ slug: string;
12
+ /** The platform registry host (`registry.<platform domain>`), with a port when it has one. */
13
+ registryHost: string;
14
+ /** The app's repository, `<github owner>/<repo>`. The images must sit at `<registryHost>/<owner>/<repo>[-<process>]`, lowercased. */
15
+ repo: string;
16
+ /** The entry key of the app. Default `app`. A process entry is keyed `<appKey>-<process key>`. */
17
+ appKey?: string;
18
+ /** The names the app entry is served at (see `HostingEntry.hostnames`). Processes are internal. */
19
+ hostnames: string[];
20
+ /** The port the app listens on. */
21
+ port: number;
22
+ healthPath?: string | null;
23
+ /** The app copy's own public origin (`https://host`), which each process gets as `APP_URL` to call the app back. */
24
+ appUrl: string;
25
+ /** The manifest `values` as env, resolved by the caller. Every entry gets the same map. */
26
+ env?: Record<string, EnvValue>;
27
+ /** The modules with the company's answers applied (`checkManifestAnswers`). Default: the manifest's own. */
28
+ modules?: AppModules;
29
+ }
30
+ /** `PROCESS_<KEY>_URL`: the key uppercased, `-` as `_`. */
31
+ export declare const processUrlEnvName: (key: string) => string;
32
+ /**
33
+ * The hosting entries of one server app: the `app` entry and one internal entry per declared process, each
34
+ * running its image from `build`. Throws {@link ServerEntryError} in plain words when the build does not match
35
+ * the manifest, when an image is not at the app's own registry path (a company never points at another's
36
+ * image), or when a module refuses the entry.
37
+ *
38
+ * - The app entry is served at `ctx.hostnames`, gets the modules, `ctx.env`, and `PROCESS_<KEY>_URL` for each
39
+ * process that has a port (`http://<slug>-<entry key>:<port>`).
40
+ * - A process entry has no hostnames. It gets the same modules and `ctx.env`, and `APP_URL`. Its `database`
41
+ * stays false: foundry allows one database entry per company, so a process cannot hold a second one.
42
+ */
43
+ export declare function serverEntriesOf(manifest: AppManifest, build: ServerBuildJson, ctx: ServerEntryContext): HostingEntry[];
@@ -0,0 +1,99 @@
1
+ import { checkServerBuild } from '../build-json.js';
2
+ import { applyModules } from '../modules/index.js';
3
+ import { APP_ENTRY_KEY, ENTRY_IMAGE_REPOSITORY } from '../org.js';
4
+ /** Thrown by {@link serverEntriesOf}. The message is plain words for the company and never carries a secret. */
5
+ export class ServerEntryError extends Error {
6
+ constructor(message) {
7
+ super(message);
8
+ this.name = 'ServerEntryError';
9
+ }
10
+ }
11
+ const HOST = /^[a-z0-9]([a-z0-9.-]*[a-z0-9])?(:[0-9]{1,5})?$/;
12
+ const SLUG = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
13
+ const OWNER_REPO = /^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?\/[A-Za-z0-9._-]+$/;
14
+ const ENTRY_KEY_MAX = 63;
15
+ /** `PROCESS_<KEY>_URL`: the key uppercased, `-` as `_`. */
16
+ export const processUrlEnvName = (key) => `PROCESS_${key.toUpperCase().replace(/-/g, '_')}_URL`;
17
+ const refuse = (message) => {
18
+ throw new ServerEntryError(message);
19
+ };
20
+ /**
21
+ * The hosting entries of one server app: the `app` entry and one internal entry per declared process, each
22
+ * running its image from `build`. Throws {@link ServerEntryError} in plain words when the build does not match
23
+ * the manifest, when an image is not at the app's own registry path (a company never points at another's
24
+ * image), or when a module refuses the entry.
25
+ *
26
+ * - The app entry is served at `ctx.hostnames`, gets the modules, `ctx.env`, and `PROCESS_<KEY>_URL` for each
27
+ * process that has a port (`http://<slug>-<entry key>:<port>`).
28
+ * - A process entry has no hostnames. It gets the same modules and `ctx.env`, and `APP_URL`. Its `database`
29
+ * stays false: foundry allows one database entry per company, so a process cannot hold a second one.
30
+ */
31
+ export function serverEntriesOf(manifest, build, ctx) {
32
+ if (manifest.runtime !== 'server')
33
+ refuse('This app does not run on a server.');
34
+ const processes = manifest.processes ?? [];
35
+ const problem = checkServerBuild(build, processes);
36
+ if (problem)
37
+ refuse(`The build does not match the app: ${problem}`);
38
+ const appKey = ctx.appKey ?? APP_ENTRY_KEY;
39
+ if (!SLUG.test(ctx.slug) || !HOST.test(ctx.registryHost) || !OWNER_REPO.test(ctx.repo)) {
40
+ refuse('The company name, registry or repository for this app is not written the way it must be.');
41
+ }
42
+ if (!/^https:\/\/[^/\s]+$/.test(ctx.appUrl)) {
43
+ refuse('The app address must be an https address with no path.');
44
+ }
45
+ const [owner = '', name = ''] = ctx.repo.split('/');
46
+ const base = `${ctx.registryHost}/${owner.toLowerCase()}/${name.toLowerCase()}`;
47
+ const imageFor = (processKey, suffix) => {
48
+ const image = build.images.find((i) => i.process === processKey);
49
+ if (!image)
50
+ return refuse(`The build has no image for "${processKey}".`);
51
+ // Exactly the app's own path: another owner's, another repo's or another host's image is never run.
52
+ if (image.repository !== `${base}${suffix}` || !ENTRY_IMAGE_REPOSITORY.test(image.repository)) {
53
+ return refuse(`The image for "${processKey}" is not at this app's own place in the registry, so it is not used.`);
54
+ }
55
+ return { repository: image.repository, digest: image.digest };
56
+ };
57
+ const internalName = (entryKey) => entryKey === APP_ENTRY_KEY ? ctx.slug : `${ctx.slug}-${entryKey}`;
58
+ const modules = ctx.modules ?? manifest.modules;
59
+ const make = (key, image, port, hostnames, env, healthPath) => {
60
+ if (key.length > ENTRY_KEY_MAX)
61
+ refuse(`The name "${key}" is too long.`);
62
+ const base = {
63
+ key,
64
+ // The app's source, as metadata: the image is what runs, and `uses.repo` makes its deploy key from this.
65
+ repo: ctx.repo,
66
+ folder: null,
67
+ branch: 'main',
68
+ buildPack: 'nixpacks',
69
+ port,
70
+ healthPath,
71
+ hostnames,
72
+ database: false,
73
+ autoDeploy: false,
74
+ gates: [],
75
+ env: { ...(ctx.env ?? {}), ...env },
76
+ ...(image ? { image } : {}),
77
+ };
78
+ return base;
79
+ };
80
+ const processEntryKey = (processKey) => `${appKey}-${processKey}`;
81
+ const processEnv = {};
82
+ for (const p of processes) {
83
+ if (p.port !== undefined) {
84
+ processEnv[processUrlEnvName(p.key)] =
85
+ `http://${internalName(processEntryKey(p.key))}:${p.port}`;
86
+ }
87
+ }
88
+ const draft = [
89
+ make(appKey, imageFor('app', ''), ctx.port, ctx.hostnames, processEnv, ctx.healthPath ?? null),
90
+ ...processes.map((p) => make(processEntryKey(p.key), imageFor(p.key, `-${p.key}`), p.port ?? null, [], { APP_URL: ctx.appUrl }, null)),
91
+ ];
92
+ return draft.map((entry, index) => {
93
+ const applied = applyModules(entry, modules, { runtime: 'server' });
94
+ if (!applied.ok)
95
+ return refuse(applied.message);
96
+ // One database entry per company: a process reaches the app's, never a second one.
97
+ return index === 0 ? applied.entry : { ...applied.entry, database: false };
98
+ });
99
+ }
@@ -26,3 +26,28 @@ export declare class BuildJsonError extends Error {
26
26
  export declare const buildJsonSchema: z.ZodType<BuildJson>;
27
27
  /** Parses a build.json. Throws {@link BuildJsonError} for the first problem. */
28
28
  export declare function parseBuildJson(input: unknown): BuildJson;
29
+ /** One image of a server app's build: `process` is `app` or the key of a declared helper process. */
30
+ export interface ServerBuildImage {
31
+ process: string;
32
+ repository: string;
33
+ digest: `sha256:${string}`;
34
+ }
35
+ /** The `build.json` of a server app: the images its GitHub built, chosen by `runtime: 'server'`. */
36
+ export interface ServerBuildJson {
37
+ runtime: 'server';
38
+ repo: string;
39
+ commit: string;
40
+ images: ServerBuildImage[];
41
+ }
42
+ export declare const serverBuildJsonSchema: z.ZodType<ServerBuildJson>;
43
+ /** Parses a server build.json. Throws {@link BuildJsonError} for the first problem; never carries a value. */
44
+ export declare function parseServerBuildJson(input: unknown): ServerBuildJson;
45
+ /** Parses either shape: `runtime: 'server'` picks the server one, anything else the Worker one. */
46
+ export declare function parseAnyBuildJson(input: unknown): BuildJson | ServerBuildJson;
47
+ /**
48
+ * Null when the build holds exactly one `app` image and one image per declared process, no more.
49
+ * Else a plain message naming the process key, never an image value.
50
+ */
51
+ export declare function checkServerBuild(build: ServerBuildJson, processes: readonly {
52
+ key: string;
53
+ }[]): string | null;
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import { ENTRY_IMAGE_REPOSITORY } from './org.js';
2
3
  import { WORKER_APP_COMPATIBILITY_FLAGS, WORKER_APP_INSTANCE_TYPES, WORKER_APP_MAX_INSTANCES, } from './worker-app-config.js';
3
4
  /** Thrown by {@link parseBuildJson}. `path` is dotted into the build.json; the message never carries a value. */
4
5
  export class BuildJsonError extends Error {
@@ -93,3 +94,63 @@ export function parseBuildJson(input) {
93
94
  throw new BuildJsonError('missing_field', path);
94
95
  throw new BuildJsonError('wrong_type', path);
95
96
  }
97
+ const IMAGE_REPOSITORY = ENTRY_IMAGE_REPOSITORY;
98
+ export const serverBuildJsonSchema = z.strictObject({
99
+ runtime: z.literal('server'),
100
+ repo: text,
101
+ commit: text,
102
+ images: z.array(z.strictObject({
103
+ process: z.string().regex(/^[a-z][a-z0-9-]{0,30}$/, { message: 'wrong_type' }),
104
+ repository: z.string().regex(IMAGE_REPOSITORY, { message: 'wrong_type' }),
105
+ digest: z.string().regex(DIGEST, { message: 'wrong_type' }),
106
+ })),
107
+ });
108
+ function refuseBuildJson(input, error) {
109
+ const issue = error.issues[0];
110
+ if (!issue)
111
+ throw new BuildJsonError('wrong_type', '');
112
+ if (issue.code === 'unrecognized_keys') {
113
+ const base = dotted(issue.path);
114
+ const key = issue.keys[0] ?? '';
115
+ throw new BuildJsonError('unknown_field', base ? `${base}.${key}` : key);
116
+ }
117
+ const path = dotted(issue.path);
118
+ if (valueAt(input, issue.path) === undefined)
119
+ throw new BuildJsonError('missing_field', path);
120
+ throw new BuildJsonError('wrong_type', path);
121
+ }
122
+ /** Parses a server build.json. Throws {@link BuildJsonError} for the first problem; never carries a value. */
123
+ export function parseServerBuildJson(input) {
124
+ const result = serverBuildJsonSchema.safeParse(input);
125
+ if (result.success)
126
+ return result.data;
127
+ return refuseBuildJson(input, result.error);
128
+ }
129
+ /** Parses either shape: `runtime: 'server'` picks the server one, anything else the Worker one. */
130
+ export function parseAnyBuildJson(input) {
131
+ const runtime = input !== null && typeof input === 'object'
132
+ ? input.runtime
133
+ : undefined;
134
+ return runtime === 'server' ? parseServerBuildJson(input) : parseBuildJson(input);
135
+ }
136
+ /**
137
+ * Null when the build holds exactly one `app` image and one image per declared process, no more.
138
+ * Else a plain message naming the process key, never an image value.
139
+ */
140
+ export function checkServerBuild(build, processes) {
141
+ const wanted = new Set(['app', ...processes.map((p) => p.key)]);
142
+ const seen = new Set();
143
+ for (const image of build.images) {
144
+ if (!wanted.has(image.process)) {
145
+ return `the build has an image for "${image.process}", which the app does not declare.`;
146
+ }
147
+ if (seen.has(image.process))
148
+ return `the build has two images for "${image.process}".`;
149
+ seen.add(image.process);
150
+ }
151
+ for (const key of wanted) {
152
+ if (!seen.has(key))
153
+ return `the build has no image for "${key}".`;
154
+ }
155
+ return null;
156
+ }
@@ -82,6 +82,16 @@ const INTEGRATION_COPY = {
82
82
  summary: 'Companies sign their own people in to the app.',
83
83
  ours: 'Run by us',
84
84
  },
85
+ 'customer-grants': {
86
+ name: 'Permissions for customers',
87
+ summary: 'Your team gives each customer the permissions they need in the app, and we keep them in step.',
88
+ ours: 'Run by us',
89
+ },
90
+ repository: {
91
+ name: 'Repository',
92
+ summary: 'The app reads or updates the files in its repository, such as the pages it keeps there.',
93
+ ours: 'Run by us',
94
+ },
85
95
  };
86
96
  const SHARED_OUTPUTS = {
87
97
  app: { url: "The app's web address.", host: "The app's host name.", name: "The app's name." },
@@ -81,6 +81,16 @@ const EXTRA = {
81
81
  price: { class: 'free', meters: [] },
82
82
  advanced: [],
83
83
  },
84
+ 'customer-grants': {
85
+ providers: [ours('customer-grants')],
86
+ price: { class: 'free', meters: [] },
87
+ advanced: [],
88
+ },
89
+ repository: {
90
+ providers: [ours('repository')],
91
+ price: { class: 'free', meters: [] },
92
+ advanced: [],
93
+ },
84
94
  };
85
95
  /** Every integration, one per app module, in the order modules are applied. */
86
96
  export function integrations() {
package/dist/index.d.ts CHANGED
@@ -8,8 +8,8 @@ import { type PinnedVersions } from './bootstrap/versions.js';
8
8
  export type { PinnedVersions };
9
9
  export declare const PINNED_VERSIONS: Readonly<PinnedVersions>;
10
10
  export type { RequestableField, CallsChange, CustomersChange, StorageLimitChange, AiLimitChange, DomainChange, MailTierChange, RequestedChangeValue, ChangeClassification, RequestedChangeState, RequestedChange, AppChange, BuildChange, FailureCode, RequestedBy, } from './requested-changes.js';
11
- export type { BuildJson, BuildJsonErrorCode } from './build-json.js';
12
- export { buildJsonSchema, parseBuildJson, BuildJsonError } from './build-json.js';
11
+ export type { BuildJson, BuildJsonErrorCode, ServerBuildImage, ServerBuildJson, } from './build-json.js';
12
+ export { buildJsonSchema, parseBuildJson, BuildJsonError, serverBuildJsonSchema, parseServerBuildJson, parseAnyBuildJson, checkServerBuild, } from './build-json.js';
13
13
  export { REQUESTABLE_FIELDS, classifyChange, RequestedChangeError } from './requested-changes.js';
14
14
  export type { SlugRejectionReason, SlugRejection } from './slug.js';
15
15
  export { validateSlug, slugFromName, RESERVED_SLUGS } from './slug.js';
package/dist/index.js CHANGED
@@ -7,7 +7,7 @@ export { PLATFORM_SERVICE_KEYS, HEARTBEATS_PROVIDERS, defaultPlatformHostname, v
7
7
  // object, not a copy.
8
8
  import { VERSIONS } from './bootstrap/versions.js';
9
9
  export const PINNED_VERSIONS = VERSIONS;
10
- export { buildJsonSchema, parseBuildJson, BuildJsonError } from './build-json.js';
10
+ export { buildJsonSchema, parseBuildJson, BuildJsonError, serverBuildJsonSchema, parseServerBuildJson, parseAnyBuildJson, checkServerBuild, } from './build-json.js';
11
11
  export { REQUESTABLE_FIELDS, classifyChange, RequestedChangeError } from './requested-changes.js';
12
12
  export { validateSlug, slugFromName, RESERVED_SLUGS } from './slug.js';
13
13
  export { NATIVE_BUILD_REPO, NATIVE_BUILD_WORKFLOW_FILE, NATIVE_BUILD_WORKFLOW_REF, NATIVE_BUILD_DISPATCH_TIMEOUT_MS, NATIVE_BUILD_KEY_ENV, NATIVE_BUILD_KEY_BYTES, NativeBuildDispatchError, dispatchNativeBuild, } from './native-build.js';
@@ -0,0 +1,3 @@
1
+ import type { AppModule } from './types.js';
2
+ /** `uses.customerGrants`: the company's team gives customers permissions in the app. */
3
+ export declare const customerGrants: AppModule;
@@ -0,0 +1,28 @@
1
+ import { problem, setUse } from './entry-parts.js';
2
+ /** `uses.customerGrants`: the company's team gives customers permissions in the app. */
3
+ export const customerGrants = {
4
+ view: {
5
+ id: 'customer-grants',
6
+ name: 'Permissions for customers',
7
+ line: 'Your team gives each customer the permissions they need in the app, and we keep them in step.',
8
+ category: 'People',
9
+ icon: 'shield',
10
+ runtimes: ['server'],
11
+ requires: ['sign-in'],
12
+ availability: 'ready',
13
+ fields: [],
14
+ adds: [
15
+ {
16
+ name: 'Permissions your team manages',
17
+ hint: 'Your team gives and takes back customer permissions without changing the app.',
18
+ },
19
+ ],
20
+ },
21
+ toEntry: (entry, values) => setUse(entry, 'customerGrants', values === null ? undefined : true),
22
+ fromEntry: (entry) => (entry.uses?.customerGrants === true ? {} : null),
23
+ check(_values, modules) {
24
+ if (modules['sign-in']?.users === 'customers')
25
+ return null;
26
+ return problem(null, 'Permissions for customers are for an app whose customers sign in. Set Sign-in to "Your customers".');
27
+ },
28
+ };
@@ -2,6 +2,7 @@ import { SECRET_LIKE_NAME, WORKER_APP_ENV_NAME, WORKER_APP_RESERVED_NAMES, } fro
2
2
  import { ai } from './ai.js';
3
3
  import { assistants } from './assistants.js';
4
4
  import { audit } from './audit.js';
5
+ import { customerGrants } from './customer-grants.js';
5
6
  import { database } from './database.js';
6
7
  import { email } from './email.js';
7
8
  import { clone, isProblem } from './entry-parts.js';
@@ -9,6 +10,7 @@ import { files } from './files.js';
9
10
  import { jobs } from './jobs.js';
10
11
  import { organisations } from './organisations.js';
11
12
  import { SECRET_OUTPUTS, referenceProblem } from './outputs.js';
13
+ import { repository } from './repository.js';
12
14
  import { signIn } from './sign-in.js';
13
15
  import { signedRecords } from './signed-records.js';
14
16
  import { workspaces } from './workspaces.js';
@@ -29,6 +31,8 @@ export const MODULES = [
29
31
  ai,
30
32
  organisations,
31
33
  audit,
34
+ customerGrants,
35
+ repository,
32
36
  ];
33
37
  const byId = new Map(MODULES.map((module) => [module.view.id, module]));
34
38
  /** The views of every module, JSON-safe, for Boule to render. */
@@ -340,7 +344,7 @@ function manifestV1AsV2(v1) {
340
344
  }
341
345
  return { v: 2, runtime: v1.runtime, modules: v1.modules, values };
342
346
  }
343
- const MANIFEST_V2_KEYS = ['v', 'runtime', 'modules', 'values', 'access'];
347
+ const MANIFEST_V2_KEYS = ['v', 'runtime', 'modules', 'values', 'access', 'processes'];
344
348
  const VALUE_KEYS = {
345
349
  generate: ['source', 'kind'],
346
350
  reference: ['source', 'to', 'secret'],
@@ -368,11 +372,21 @@ const COPY_KEY = /^[a-z][A-Za-z0-9_-]*(\.[A-Za-z0-9_-]+)+$/;
368
372
  const REFERENCE = /^[a-z][a-z0-9-]*\.[A-Za-z][A-Za-z0-9-]*$/;
369
373
  const HOST = /^(\*\.)?([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/;
370
374
  const HOST_MAX = 20;
375
+ const PROCESS_MAX = 4;
376
+ const PROCESS_KEY = /^[a-z][a-z0-9-]{0,30}$/;
371
377
  const WHY_MAX = 200;
372
378
  const CHOICES_MAX = 50;
373
379
  const ASKABLE_KINDS = ['text', 'longtext', 'path', 'choice', 'list'];
374
380
  const isText = (value, max) => typeof value === 'string' && value.trim() !== '' && value.length <= max;
375
381
  /** A plain sentence when `copy` is not a good label, help or copy key, else null. */
382
+ function isProcessFolder(folder) {
383
+ return (typeof folder === 'string' &&
384
+ folder.length > 0 &&
385
+ folder.length <= 200 &&
386
+ !folder.startsWith('/') &&
387
+ !folder.startsWith('\\') &&
388
+ !folder.split(/[\\/]/).includes('..'));
389
+ }
376
390
  function copyProblem(name, raw, labelRequired) {
377
391
  const { label, help, labelKey, helpKey } = raw;
378
392
  if (labelRequired
@@ -670,9 +684,43 @@ function parseManifestV2(json) {
670
684
  }
671
685
  hosts.push({ host, why });
672
686
  }
687
+ const processes = [];
688
+ if (json.processes !== undefined) {
689
+ if (runtime !== 'server') {
690
+ return refuse('"processes" is only for an app that runs on a server, not a Worker app.');
691
+ }
692
+ const raw = json.processes;
693
+ if (!Array.isArray(raw) || raw.length > PROCESS_MAX) {
694
+ return refuse(`"processes" must list at most ${PROCESS_MAX} helper processes.`);
695
+ }
696
+ const keys = new Set();
697
+ for (const item of raw) {
698
+ if (!isRecord(item) ||
699
+ Object.keys(item).some((k) => k !== 'key' && k !== 'folder' && k !== 'port')) {
700
+ return refuse('every helper process needs a "key" and a "folder", and may have a "port".');
701
+ }
702
+ const { key, folder, port } = item;
703
+ if (typeof key !== 'string' || !PROCESS_KEY.test(key) || key === 'app') {
704
+ return refuse('every helper process "key" must be 1 to 31 lowercase letters, digits or dashes, start with a letter, and not be "app".');
705
+ }
706
+ if (keys.has(key))
707
+ return refuse(`"processes" lists the key "${key}" twice.`);
708
+ keys.add(key);
709
+ if (!isProcessFolder(folder)) {
710
+ return refuse(`the "folder" of helper process "${key}" must be a path inside the repository, without "..", and not start with "/" or "\\".`);
711
+ }
712
+ if (port !== undefined &&
713
+ !(Number.isInteger(port) && port >= 1 && port <= 65535)) {
714
+ return refuse(`the "port" of helper process "${key}" must be a whole number from 1 to 65535.`);
715
+ }
716
+ processes.push(port === undefined ? { key, folder } : { key, folder, port: port });
717
+ }
718
+ }
673
719
  const manifest = { v: 2, runtime, modules: clone(modules), values };
674
720
  if (json.access !== undefined)
675
721
  manifest.access = { hosts };
722
+ if (json.processes !== undefined)
723
+ manifest.processes = processes;
676
724
  return { ok: true, manifest };
677
725
  }
678
726
  /** `company-app.json` (v1 or v2) read and checked: what the app's code asks for. */
@@ -0,0 +1,6 @@
1
+ import type { AppModule } from './types.js';
2
+ /**
3
+ * `uses.repo`: the app gets a key for its own repository. The entry's `repo` names the repository (it comes
4
+ * from the install, not from this module); `access` says whether the app may only read or also push.
5
+ */
6
+ export declare const repository: AppModule;
@@ -0,0 +1,40 @@
1
+ import { setUse } from './entry-parts.js';
2
+ /**
3
+ * `uses.repo`: the app gets a key for its own repository. The entry's `repo` names the repository (it comes
4
+ * from the install, not from this module); `access` says whether the app may only read or also push.
5
+ */
6
+ export const repository = {
7
+ view: {
8
+ id: 'repository',
9
+ name: 'Repository',
10
+ line: 'The app reads or updates the files in its repository, such as the pages it keeps there.',
11
+ category: 'Data',
12
+ icon: 'git-branch',
13
+ runtimes: ['server'],
14
+ requires: [],
15
+ availability: 'ready',
16
+ fields: [
17
+ {
18
+ key: 'access',
19
+ label: 'What the app may do',
20
+ kind: 'choice',
21
+ default: 'read',
22
+ choices: [
23
+ { value: 'read', label: 'Read', help: 'The app reads the files.' },
24
+ { value: 'write', label: 'Read and update', help: 'The app also saves changes.' },
25
+ ],
26
+ },
27
+ ],
28
+ adds: [
29
+ {
30
+ name: 'A key for the repository',
31
+ hint: 'Only this app holds it, and it opens only this repository.',
32
+ },
33
+ ],
34
+ },
35
+ toEntry: (entry, values) => setUse(entry, 'repo', values === null ? undefined : { access: values.access === 'write' ? 'write' : 'read' }),
36
+ fromEntry(entry) {
37
+ const used = entry.uses?.repo;
38
+ return used ? { access: used.access } : null;
39
+ },
40
+ };
@@ -181,6 +181,12 @@ export interface ManifestHost {
181
181
  export interface ManifestAccess {
182
182
  hosts: ManifestHost[];
183
183
  }
184
+ /** A helper process of a server app: built from `<folder>/Dockerfile`, next to the app itself. */
185
+ export interface ManifestProcess {
186
+ key: string;
187
+ folder: string;
188
+ port?: number;
189
+ }
184
190
  /** `company-app.json` v2, and what every manifest (v1 too) is read as. */
185
191
  export interface AppManifest {
186
192
  v: 2;
@@ -189,6 +195,8 @@ export interface AppManifest {
189
195
  values: Record<string, ManifestValue>;
190
196
  /** Optional. Absent means the app may connect anywhere. */
191
197
  access?: ManifestAccess;
198
+ /** Optional, server apps only: at most 4 helper processes. */
199
+ processes?: ManifestProcess[];
192
200
  }
193
201
  /** An answer to an `ask`: text, or a list of texts for `multiple`. */
194
202
  export type ManifestAnswer = string | string[];
package/dist/org.d.ts CHANGED
@@ -301,7 +301,7 @@ export type AccessRule = {
301
301
  /** The key of the entry that is today's one app: the Coolify app named `<slug>`. */
302
302
  export declare const APP_ENTRY_KEY = "app";
303
303
  /** Why a validator refused. `code` is stable and never reworded; `message` is for people. */
304
- export type OrgRefusalCode = 'hosting_tunnel_needs_hosting' | 'issuer_app_name_missing' | 'issuer_app_name_not_unique' | 'issuer_app_kind_unknown' | 'issuer_app_name_reserved_agora' | 'issuer_app_name_reserved_boule' | 'issuer_app_name_reserved_kosmos' | 'hosting_needs_entry' | 'hosting_main_count' | 'entry_key_not_unique' | 'ai_webhook_path_not_unique' | 'worker_route_not_unique' | 'database_not_unique' | 'access_hostname_not_unique' | 'access_hostname_invalid' | 'access_needs_hosting' | 'access_kind_unknown' | 'access_service_token_name_missing' | 'access_hostname_locked' | 'access_hostname_not_served' | 'gate_limit_invalid' | 'hosting_repo_invalid' | 'hosting_github_invalid' | 'gate_needs_domain' | 'gate_key_invalid' | 'gate_key_not_unique' | 'gate_needs_hostname' | 'gate_hostname_not_entry_hostname' | 'gate_hostname_held_twice' | 'gate_hostname_locked' | 'gate_limit_exceeded' | 'wildcard_needs_domain' | 'wildcard_held_twice' | 'hostnames_need_domain' | 'hostname_needed' | 'hostname_invalid' | 'hostname_not_unique' | 'hostname_locked' | 'hosted_name_invalid' | 'hosted_name_not_unique' | 'hosted_name_worker_app' | 'entry_workspace_invalid' | 'entry_workspace_unsupported' | 'entry_live_needs_workspace' | 'entry_live_commit_invalid' | 'gate_key_taken_by_workspace' | 'gate_key_taken_by_private_page' | 'private_page_invalid' | 'private_page_path_invalid' | 'private_page_duplicate' | 'private_page_limit' | 'private_page_needs_hosting' | 'private_page_roles_invalid' | 'private_page_people_invalid' | 'entry_key_invalid' | 'entry_key_reserved' | 'entry_repo_invalid' | 'entry_folder_invalid' | 'entry_branch_empty' | 'entry_build_pack_unknown' | 'entry_build_pack_unavailable' | 'entry_port_invalid' | 'entry_health_path_invalid' | 'entry_capabilities_invalid' | 'entry_database_needs_hosting' | 'blue_green_needs_hostname' | 'env_name_empty' | 'env_name_reserved' | 'env_source_platform_mail_key' | 'env_source_unknown' | 'env_shared_secret_name_invalid' | 'env_held_secret_name_mismatch' | 'env_held_secret_name_invalid' | 'env_looks_like_secret' | 'builtin_app_invalid' | 'blue_green_not_object' | 'blue_green_steps_length' | 'blue_green_steps_range' | 'blue_green_steps_order' | 'blue_green_steps_end' | 'blue_green_hold_invalid' | 'blue_green_advance_invalid' | 'blue_green_min_requests_invalid' | 'blue_green_error_margin_invalid' | 'blue_green_staff_first_invalid' | 'volumes_not_list' | 'volumes_too_many' | 'volume_name_invalid' | 'volume_name_logs' | 'volume_name_not_unique' | 'volume_mount_path_invalid' | 'volume_mount_path_too_long' | 'volume_mount_path_log_dir' | 'volume_mount_path_forbidden' | 'volume_mount_path_not_unique' | 'auth_needs_hosting' | 'auth_users_invalid' | 'auth_registration_invalid' | 'registration_open_needs_customers' | 'auth_replaces_broker_entry_invalid' | 'auth_replaces_broker_entry_owned' | 'auth_needs_exact_hostname' | 'mcp_needs_open_customers' | 'mcp_path_invalid' | 'mcp_onboarding_path_invalid' | 'auth_sign_in_invalid' | 'email_code_needs_customers' | 'auth_room_invalid' | 'auth_room_needs_customers' | 'auth_room_not_unique' | 'client_name_taken' | 'workspaces_needs_customers' | 'workspaces_needs_server' | 'workspaces_roles_invalid' | 'workspaces_role_reserved' | 'workspaces_invite_path_invalid' | 'workspaces_env_clash' | 'ai_needs_hosting' | 'ai_offerings_invalid' | 'ai_permissions_invalid' | 'ai_webhook_path_invalid' | 'ai_webhook_needs_exact_hostname' | 'ai_env_clash' | 'files_needs_hosting' | 'files_collections_invalid' | 'files_permissions_invalid' | 'files_share_host_invalid' | 'files_share_host_needs_domain' | 'files_env_clash' | 'worker_needs_hosting' | 'worker_needs_repo' | 'worker_needs_own_repo' | 'worker_key_too_long' | 'worker_artifact_invalid' | 'worker_route_invalid' | 'worker_route_off_domain' | 'worker_rate_limit_name_missing' | 'worker_rate_limit_namespace_missing' | 'worker_rate_limit_invalid' | 'worker_rate_limit_period_invalid' | 'worker_app_invalid' | 'edge_not_true' | 'edge_needs_hosting' | 'edge_needs_domain' | 'edge_env_clash' | 'mail_not_true' | 'mail_needs_hosting' | 'mail_env_clash' | 'jobs_not_true' | 'jobs_needs_hosting' | 'jobs_env_clash' | 'customer_grants_not_true' | 'customer_grants_needs_customers' | 'customer_grants_env_clash' | 'repo_use_invalid' | 'repo_use_needs_repo' | 'repo_use_env_clash' | 'audit_not_true' | 'audit_needs_hosting' | 'audit_needs_worker_database' | 'customers_invalid' | 'customers_sign_up_needs_enabled' | 'registration_open_needs_sign_up';
304
+ export type OrgRefusalCode = 'hosting_tunnel_needs_hosting' | 'issuer_app_name_missing' | 'issuer_app_name_not_unique' | 'issuer_app_kind_unknown' | 'issuer_app_name_reserved_agora' | 'issuer_app_name_reserved_boule' | 'issuer_app_name_reserved_kosmos' | 'hosting_needs_entry' | 'hosting_main_count' | 'entry_key_not_unique' | 'entry_image_invalid' | 'ai_webhook_path_not_unique' | 'worker_route_not_unique' | 'database_not_unique' | 'access_hostname_not_unique' | 'access_hostname_invalid' | 'access_needs_hosting' | 'access_kind_unknown' | 'access_service_token_name_missing' | 'access_hostname_locked' | 'access_hostname_not_served' | 'gate_limit_invalid' | 'hosting_repo_invalid' | 'hosting_github_invalid' | 'gate_needs_domain' | 'gate_key_invalid' | 'gate_key_not_unique' | 'gate_needs_hostname' | 'gate_hostname_not_entry_hostname' | 'gate_hostname_held_twice' | 'gate_hostname_locked' | 'gate_limit_exceeded' | 'wildcard_needs_domain' | 'wildcard_held_twice' | 'hostnames_need_domain' | 'hostname_needed' | 'hostname_invalid' | 'hostname_not_unique' | 'hostname_locked' | 'hosted_name_invalid' | 'hosted_name_not_unique' | 'hosted_name_worker_app' | 'entry_workspace_invalid' | 'entry_workspace_unsupported' | 'entry_live_needs_workspace' | 'entry_live_commit_invalid' | 'gate_key_taken_by_workspace' | 'gate_key_taken_by_private_page' | 'private_page_invalid' | 'private_page_path_invalid' | 'private_page_duplicate' | 'private_page_limit' | 'private_page_needs_hosting' | 'private_page_roles_invalid' | 'private_page_people_invalid' | 'entry_key_invalid' | 'entry_key_reserved' | 'entry_repo_invalid' | 'entry_folder_invalid' | 'entry_branch_empty' | 'entry_build_pack_unknown' | 'entry_build_pack_unavailable' | 'entry_port_invalid' | 'entry_health_path_invalid' | 'entry_capabilities_invalid' | 'entry_database_needs_hosting' | 'blue_green_needs_hostname' | 'env_name_empty' | 'env_name_reserved' | 'env_source_platform_mail_key' | 'env_source_unknown' | 'env_shared_secret_name_invalid' | 'env_held_secret_name_mismatch' | 'env_held_secret_name_invalid' | 'env_looks_like_secret' | 'builtin_app_invalid' | 'blue_green_not_object' | 'blue_green_steps_length' | 'blue_green_steps_range' | 'blue_green_steps_order' | 'blue_green_steps_end' | 'blue_green_hold_invalid' | 'blue_green_advance_invalid' | 'blue_green_min_requests_invalid' | 'blue_green_error_margin_invalid' | 'blue_green_staff_first_invalid' | 'volumes_not_list' | 'volumes_too_many' | 'volume_name_invalid' | 'volume_name_logs' | 'volume_name_not_unique' | 'volume_mount_path_invalid' | 'volume_mount_path_too_long' | 'volume_mount_path_log_dir' | 'volume_mount_path_forbidden' | 'volume_mount_path_not_unique' | 'auth_needs_hosting' | 'auth_users_invalid' | 'auth_registration_invalid' | 'registration_open_needs_customers' | 'auth_replaces_broker_entry_invalid' | 'auth_replaces_broker_entry_owned' | 'auth_needs_exact_hostname' | 'mcp_needs_open_customers' | 'mcp_path_invalid' | 'mcp_onboarding_path_invalid' | 'auth_sign_in_invalid' | 'email_code_needs_customers' | 'auth_room_invalid' | 'auth_room_needs_customers' | 'auth_room_not_unique' | 'client_name_taken' | 'workspaces_needs_customers' | 'workspaces_needs_server' | 'workspaces_roles_invalid' | 'workspaces_role_reserved' | 'workspaces_invite_path_invalid' | 'workspaces_env_clash' | 'ai_needs_hosting' | 'ai_offerings_invalid' | 'ai_permissions_invalid' | 'ai_webhook_path_invalid' | 'ai_webhook_needs_exact_hostname' | 'ai_env_clash' | 'files_needs_hosting' | 'files_collections_invalid' | 'files_permissions_invalid' | 'files_share_host_invalid' | 'files_share_host_needs_domain' | 'files_env_clash' | 'worker_needs_hosting' | 'worker_needs_repo' | 'worker_needs_own_repo' | 'worker_key_too_long' | 'worker_artifact_invalid' | 'worker_route_invalid' | 'worker_route_off_domain' | 'worker_rate_limit_name_missing' | 'worker_rate_limit_namespace_missing' | 'worker_rate_limit_invalid' | 'worker_rate_limit_period_invalid' | 'worker_app_invalid' | 'edge_not_true' | 'edge_needs_hosting' | 'edge_needs_domain' | 'edge_env_clash' | 'mail_not_true' | 'mail_needs_hosting' | 'mail_env_clash' | 'jobs_not_true' | 'jobs_needs_hosting' | 'jobs_env_clash' | 'customer_grants_not_true' | 'customer_grants_needs_customers' | 'customer_grants_env_clash' | 'repo_use_invalid' | 'repo_use_needs_repo' | 'repo_use_env_clash' | 'audit_not_true' | 'audit_needs_hosting' | 'audit_needs_worker_database' | 'customers_invalid' | 'customers_sign_up_needs_enabled' | 'registration_open_needs_sign_up';
305
305
  /**
306
306
  * One refusal: a stable `code`, the dotted `path` into {@link DeclaredOrg} it is about
307
307
  * (`hosting.entries.terminal.uses.auth.registration`) and the sentence for people.
@@ -311,6 +311,19 @@ export interface OrgRefusal {
311
311
  path: string;
312
312
  message: string;
313
313
  }
314
+ /**
315
+ * A built image a server entry runs instead of building from a repository: `repository` is
316
+ * `<host>[:port]/<owner>/<name>`, `digest` is `sha256:` and 64 lowercase hex digits. Same shapes as a server
317
+ * `build.json` (`build-json.ts`).
318
+ */
319
+ export interface EntryImage {
320
+ repository: string;
321
+ digest: `sha256:${string}`;
322
+ }
323
+ /** The registry path of an {@link EntryImage}: `<host>[:port]/<owner>/<name>`, any host. */
324
+ export declare const ENTRY_IMAGE_REPOSITORY: RegExp;
325
+ /** The digest of an {@link EntryImage}. */
326
+ export declare const ENTRY_IMAGE_DIGEST: RegExp;
314
327
  /**
315
328
  * One app the org runs. `database` and `gates` are meaningless without
316
329
  * `HostingConfig.enabled`, so `validateHosting` requires it before either is
@@ -334,6 +347,11 @@ export interface HostingEntry {
334
347
  buildPack: HostingBuildPack;
335
348
  /** The port the app listens on. `null` is Coolify's default. */
336
349
  port: number | null;
350
+ /**
351
+ * A built image the app runs, pulled as `repository@digest`, instead of a build from `repo`. Then `folder` is `null`, `branch` and `buildPack` stay at their defaults;
352
+ * `validateEntryImage` states the rest. A new digest redeploys the app.
353
+ */
354
+ image?: EntryImage;
337
355
  /**
338
356
  * The app's live address on the platform's apps domain: `<hostedName>.companyincluded.app`. A
339
357
  * label {@link hostedLabelRefusal} accepts; unique within the org (the host checks it across orgs).
package/dist/org.js CHANGED
@@ -16,6 +16,10 @@ export const APP_ENTRY_KEY = 'app';
16
16
  function refusal(code, path, message) {
17
17
  return { code, path, message };
18
18
  }
19
+ /** The registry path of an {@link EntryImage}: `<host>[:port]/<owner>/<name>`, any host. */
20
+ export const ENTRY_IMAGE_REPOSITORY = /^[a-z0-9]([a-z0-9.-]*[a-z0-9])?(:[0-9]{1,5})?\/[a-z0-9]([a-z0-9-]*[a-z0-9])?\/[a-z0-9][a-z0-9._-]*$/;
21
+ /** The digest of an {@link EntryImage}. */
22
+ export const ENTRY_IMAGE_DIGEST = /^sha256:[0-9a-f]{64}$/;
19
23
  export const MAX_PRIVATE_PAGES = 20;
20
24
  export const MAX_PRIVATE_PAGE_ROLES = 50;
21
25
  export const MAX_PRIVATE_PAGE_PEOPLE = 200;
@@ -211,6 +215,9 @@ export function refuseHosting(hosting, names) {
211
215
  return refusal('entry_key_not_unique', `hosting.entries.${entry.key}.key`, `hosting.entries key '${entry.key}' is not unique`);
212
216
  }
213
217
  seenKeys.add(entry.key);
218
+ const invalidImage = validateEntryImage(entry);
219
+ if (invalidImage)
220
+ return invalidImage;
214
221
  const invalid = validateHostingEntry(entry, hosting.enabled, reserveKeys, hosting.github);
215
222
  if (invalid)
216
223
  return invalid;
@@ -513,8 +520,8 @@ export function companyGithubFor(hosting, repo) {
513
520
  const github = hosting.github;
514
521
  return github && repo !== null && repoUnderAccount(repo, github.account) ? github : undefined;
515
522
  }
516
- // The env names `pushAppEnv` and `pushDatabaseUrl` set themselves.
517
- const FOUNDRY_ENV_NAME = /^(APP_URL|LOG_DIR|OWNER_USER_ID|AUTH_.*|DATABASE_URL.*)$/;
523
+ // The env names `pushAppEnv` and `pushDatabaseUrl` set themselves. `APP_URL` is also set, but only on the main entry.
524
+ const FOUNDRY_ENV_NAME = /^(LOG_DIR|OWNER_USER_ID|AUTH_.*|DATABASE_URL.*)$/;
518
525
  // Set only on the colors of a blueGreen entry; any other entry may declare them.
519
526
  const BLUE_GREEN_ENV_NAME = /^(REPORTING_(COLOR|RELEASE|HEALTH_TOKEN)|NEXT_SERVER_ACTIONS_ENCRYPTION_KEY)$/;
520
527
  const SECRET_ENV_NAME = /_(KEY|SECRET|TOKEN|PASSWORD)$/;
@@ -626,6 +633,39 @@ function validateEntryPrivatePages(entry, at, hostingEnabled) {
626
633
  }
627
634
  return null;
628
635
  }
636
+ /** Why an entry's `image` is refused, or `null`. Read wherever hosting is read, so a stored entry is checked too. */
637
+ function validateEntryImage(entry) {
638
+ const value = entry.image;
639
+ if (value === undefined)
640
+ return null;
641
+ const path = `hosting.entries.${entry.key}.image`;
642
+ const at = `hosting.entries '${entry.key}'`;
643
+ const invalid = (message) => refusal('entry_image_invalid', path, `${at} ${message}`);
644
+ const isObject = typeof value === 'object' && value !== null && !Array.isArray(value);
645
+ const { repository, digest } = (isObject ? value : {});
646
+ if (!isObject ||
647
+ Object.keys(value).some((k) => k !== 'repository' && k !== 'digest') ||
648
+ typeof repository !== 'string' ||
649
+ !ENTRY_IMAGE_REPOSITORY.test(repository) ||
650
+ repository.split('/').some((part) => /^\.+$/.test(part)) ||
651
+ typeof digest !== 'string' ||
652
+ !ENTRY_IMAGE_DIGEST.test(digest)) {
653
+ return invalid('image must be { repository, digest }: a registry path like registry.example.com/owner/name, and a digest of sha256: and 64 lowercase letters a-f or digits');
654
+ }
655
+ if (entry.folder !== null || entry.branch !== 'main' || entry.buildPack !== 'nixpacks') {
656
+ return invalid("runs a built image, so it cannot also build from a repository: remove the image, or set folder to null and keep the default branch and build pack (repo may stay, as the app's source)");
657
+ }
658
+ if (entry.worker !== undefined || entry.workerApp !== undefined) {
659
+ return invalid('runs a built image on a server, so it cannot also be a Worker or a Worker app');
660
+ }
661
+ if (entry.blueGreen !== undefined ||
662
+ entry.workspace !== undefined ||
663
+ entry.live === false ||
664
+ entry.liveCommit !== undefined) {
665
+ return invalid('runs a built image, which does not support a workspace, blue/green releases or a promote yet: remove them, or build from a repository');
666
+ }
667
+ return null;
668
+ }
629
669
  function validateHostingEntry(entry, enabled, reserveKeys, github) {
630
670
  const at = `hosting.entries '${entry.key}'`;
631
671
  if (!ENTRY_KEY.test(entry.key)) {
@@ -689,7 +729,9 @@ function validateHostingEntry(entry, enabled, reserveKeys, github) {
689
729
  if (!name) {
690
730
  return refusal('env_name_empty', `hosting.entries.${entry.key}.env`, `${at} env needs a non-empty env var name`);
691
731
  }
692
- if (FOUNDRY_ENV_NAME.test(name) || (entry.blueGreen && BLUE_GREEN_ENV_NAME.test(name))) {
732
+ if (FOUNDRY_ENV_NAME.test(name) ||
733
+ (name === 'APP_URL' && isMainEntry(entry)) ||
734
+ (entry.blueGreen && BLUE_GREEN_ENV_NAME.test(name))) {
693
735
  return refusal('env_name_reserved', `hosting.entries.${entry.key}.env.${name}`, `${at} env '${name}' is set by foundry and cannot be declared`);
694
736
  }
695
737
  if (typeof value !== 'string') {
package/dist/plan/diff.js CHANGED
@@ -43,7 +43,7 @@ import { OWN_AUTH_BLOCKED_KEY, OWN_FILES_BLOCKED_KEY, OWN_FILES_R2_BLOCKED_KEY,
43
43
  import { ownDnsHostnames } from './own-dns.js';
44
44
  import { ownHostnames } from './own-hostnames.js';
45
45
  import { platformMailMxHost } from './platform-diff.js';
46
- import { CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY, CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY, ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, ORG_MAIL_APPLIED_MODE_SCOPE_KEY, ORG_MAIL_APPLIED_USES_DOMAIN_SCOPE_KEY, PLATFORM_AUTH_MCP_SCOPE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, appEnvAppliedScopeKey, appFolderDiffers, appSecretAvailability, appSettingsOf, appStepKey, appliedAppDomains, appliedAppSettings, dbEnvAppliedScopeKey, resolveAppEnv, serviceDbEnvAppliedScopeKey, versionNotAppliedLine, zitadelAppIdTokenUserinfoAppliedScopeKey, } from './platform-diff.js';
46
+ import { CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY, CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY, ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, ORG_MAIL_APPLIED_MODE_SCOPE_KEY, ORG_MAIL_APPLIED_USES_DOMAIN_SCOPE_KEY, PLATFORM_AUTH_MCP_SCOPE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, appEnvAppliedScopeKey, appFolderDiffers, appImageAppliedScopeKey, appSecretAvailability, appSettingsOf, appStepKey, appliedAppDomains, appliedAppSettings, dbEnvAppliedScopeKey, entryImageRef, resolveAppEnv, serviceDbEnvAppliedScopeKey, versionNotAppliedLine, zitadelAppIdTokenUserinfoAppliedScopeKey, } from './platform-diff.js';
47
47
  import { DEFAULT_ORG_PLATFORM_READINESS, } from './platform-org.js';
48
48
  import { PLATFORM_AUTH_BROKER_MAIL_KEY_REF } from './platform-providers.js';
49
49
  import { companyAppPostLogoutUris, postLogoutUpdateOwed, zitadelAppPostLogoutAppliedScopeKey, } from './post-logout-uris.js';
@@ -3566,7 +3566,7 @@ function planApp(org, entryKey, byKey, claimed, recordedIds, plannedSteps, authI
3566
3566
  // #447: no shared default app any more. Migration 0029 wrote `repo: null`
3567
3567
  // for every org without a bespoke app and nothing rewrites that data, so
3568
3568
  // the entry stays declared but is blocked here and never provisioned.
3569
- const noRepo = entry !== undefined && entry.repo === null;
3569
+ const noRepo = entry !== undefined && entry.repo === null && entry.image === undefined;
3570
3570
  if (noRepo) {
3571
3571
  blocked.push({
3572
3572
  description: `${org.slug}'s ${label} repo is blocked, not planned: there is no shared default app any more (#447)`,
@@ -3819,6 +3819,21 @@ function planApp(org, entryKey, byKey, claimed, recordedIds, plannedSteps, authI
3819
3819
  blocked,
3820
3820
  };
3821
3821
  }
3822
+ // A new image digest (or repository): the app is pointed at it and redeployed. The line names no secret.
3823
+ if (entry.image &&
3824
+ recordedIds[appImageAppliedScopeKey(entryKey)] !== entryImageRef(entry.image)) {
3825
+ return {
3826
+ step: {
3827
+ action,
3828
+ step: {
3829
+ description: `run ${org.slug}'s ${label} on image ${entry.image.repository} at ${entry.image.digest.slice(0, 19)} and redeploy the app`,
3830
+ destroys: false,
3831
+ migrates: false,
3832
+ },
3833
+ },
3834
+ blocked,
3835
+ };
3836
+ }
3822
3837
  // A changed `folder`, `port` or `autoDeploy` reaches an app that is already live.
3823
3838
  const want = appSettingsOf(entry);
3824
3839
  const have = appliedAppSettings(recordedIds, entryKey);
@@ -1765,7 +1765,7 @@ export interface FoundryStepExecutorPorts {
1765
1765
  /**
1766
1766
  * The stage copy (`copy_prod_to_stage`, run by the release tick). The host supplies the dump download and the
1767
1767
  * backup storage; foundry builds the restore port itself and reads production's address live from Coolify.
1768
- * Without it a copy request is refused with `no-copy-port`. The host needs `pg_restore` (Postgres 16) on PATH.
1768
+ * Without it a copy request is refused with `no-copy-port`. The host needs `pg_restore` on PATH, of the production database's Postgres version or newer (platform-archon-db runs 18).
1769
1769
  */
1770
1770
  stageCopy?: StageCopyHostConfig;
1771
1771
  /**
@@ -68,7 +68,7 @@ import { LIMITS, limited } from './own-limits.js';
68
68
  import { OWN_FILES_R2_SCOPE_KEY, OWN_FILES_R2_UNDELIVERED_SCOPE_KEY, OWN_SERVICES_APPLIED_SCOPE_KEY, OwnServicesBlockedError, orgNetwork, orgTarget, platformTarget, scopeFromOrg, } from './own-services.js';
69
69
  import { ownSfuUrls, provisionSfuApp } from './own-sfu.js';
70
70
  import { JOBS_INTERNAL_URL_SCOPE_KEY, PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, } from './platform-diff.js';
71
- import { CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY, CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY, ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, ORG_MAIL_APPLIED_MODE_SCOPE_KEY, ORG_MAIL_APPLIED_USES_DOMAIN_SCOPE_KEY, appDomainsAppliedScopeKey, appEnvAppliedScopeKey, appFolderDiffers, appNameFor, appSettingsAppliedScopeKey, appSettingsOf, appStepKey, appliedAppDomains, appliedAppSettings, dbEnvAppliedScopeKey, orgDeclaresMcp, resolveAppEnv, serviceDbEnvAppliedScopeKey, zitadelAppIdTokenUserinfoAppliedScopeKey, } from './platform-diff.js';
71
+ import { CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY, CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY, ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, ORG_MAIL_APPLIED_MODE_SCOPE_KEY, ORG_MAIL_APPLIED_USES_DOMAIN_SCOPE_KEY, appDomainsAppliedScopeKey, appEnvAppliedScopeKey, appFolderDiffers, appImageAppliedScopeKey, appNameFor, appSettingsAppliedScopeKey, appSettingsOf, appStepKey, appliedAppDomains, appliedAppSettings, dbEnvAppliedScopeKey, entryImageFields, entryImageRef, orgDeclaresMcp, resolveAppEnv, serviceDbEnvAppliedScopeKey, zitadelAppIdTokenUserinfoAppliedScopeKey, } from './platform-diff.js';
72
72
  import { AUTH_LOGIN_PORT, PLATFORM_AUTH_BROKER_MAIL_KEY_REF, PLATFORM_AUTH_LOGIN_INTERNAL_NAME, PLATFORM_AUTH_LOGIN_SCOPE_KEY, PLATFORM_AUTH_PROJECT, PLATFORM_MAIL_SERVER_SCOPE_KEY, defaultStalwartForInstance, filesDepsInvalid, filesShareDomains, provisionAuthStack, provisionDatabaseBackup, provisionFilesHost, provisionJobsHost, provisionMailServer, resolveOwnFilesKeys, } from './platform-providers.js';
73
73
  import { companyAppPostLogoutUris, postLogoutAppliedValue, postLogoutUrisFor, zitadelAppPostLogoutAppliedScopeKey, } from './post-logout-uris.js';
74
74
  import { CLOUDFLARE_TUNNEL_APPS_HOSTNAMES_SCOPE_KEY, CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY, CLOUDFLARE_TUNNEL_OIKOS_HOSTNAMES_SCOPE_KEY, ENTRY_CONTAINER_LABELS, appLabelsAppliedScopeKey, builtinRoutes, entryAppsHostnames, entryLabelHash, entryLabelSpec, entryOikosHostnames, entryProxyLabels, entryRoutes, entryRoutesMarker, ownsAppsZoneName, ownsOikosName, routesWithoutToken, sameLabelSets, } from './proxy-labels.js';
@@ -3963,7 +3963,7 @@ export class FoundryStepExecutor {
3963
3963
  throw new Error(`coolify:app for ${org.slug}: no hosting entry for ${action.key}`);
3964
3964
  // `planApp` blocks a `repo: null` entry at plan time; reaching here with
3965
3965
  // one means a stale or hand-built plan.
3966
- if (entry.repo === null) {
3966
+ if (entry.repo === null && entry.image === undefined) {
3967
3967
  throw new Error(`coolify:app for ${org.slug}: entry '${entry.key}' has no repo`);
3968
3968
  }
3969
3969
  const appRepo = entry.repo;
@@ -4052,35 +4052,55 @@ export class FoundryStepExecutor {
4052
4052
  }
4053
4053
  else {
4054
4054
  const server = await this.resolveServer(org, context);
4055
- const githubAppUuid = await this.githubAppUuidFor(org, appRepo, context.recordedIds());
4056
- if (isServerWorkspace(entry)) {
4057
- pinnedCommit =
4058
- entry.liveCommit ?? (await this.workspaceCommit(org, entry, server.uuid, context));
4055
+ if (entry.image) {
4056
+ // A built image: pulled by digest, nothing to build. Coolify never deploys it by itself on create.
4057
+ const created = await this.ports.coolify.createDockerImageApplication({
4058
+ project_uuid: projectUuid,
4059
+ server_uuid: server.uuid,
4060
+ environment_name: environment.name,
4061
+ environment_uuid: environment.uuid,
4062
+ ...entryImageFields(entry.image),
4063
+ // Coolify needs a port to create the app; a helper with none listens on none, so 80 is only its label.
4064
+ ports_exposes: String(entry.port ?? 80),
4065
+ name: appName,
4066
+ description: `${org.slug}'s ${entry.key} app, run from a built image`,
4067
+ instant_deploy: false,
4068
+ });
4069
+ appId = created.uuid;
4070
+ have = appSettingsOf(entry);
4071
+ haveDomains = wantDomains;
4072
+ }
4073
+ else {
4074
+ const githubAppUuid = await this.githubAppUuidFor(org, appRepo, context.recordedIds());
4075
+ if (isServerWorkspace(entry)) {
4076
+ pinnedCommit =
4077
+ entry.liveCommit ?? (await this.workspaceCommit(org, entry, server.uuid, context));
4078
+ }
4079
+ const created = await this.ports.coolify.createGithubAppApplication({
4080
+ is_preview_deployments_enabled: false,
4081
+ project_uuid: projectUuid,
4082
+ server_uuid: server.uuid,
4083
+ environment_name: environment.name,
4084
+ environment_uuid: environment.uuid,
4085
+ github_app_uuid: githubAppUuid,
4086
+ git_repository: appRepo,
4087
+ git_branch: entry.branch,
4088
+ build_pack: entry.buildPack,
4089
+ name: appName,
4090
+ autogenerate_domain: false,
4091
+ // A static site is served by Coolify's own web server on port 80.
4092
+ ports_exposes: isStatic ? '80' : entry.port === null ? undefined : String(entry.port),
4093
+ is_auto_deploy_enabled: appSettingsOf(entry).autoDeploy,
4094
+ // Also the folder a static entry serves: Coolify's static build
4095
+ // copies `base_directory`, and reads `publish_directory` only for a
4096
+ // built app.
4097
+ base_directory: entry.folder === null ? undefined : `/${entry.folder}`,
4098
+ });
4099
+ appId = created.uuid;
4100
+ // Create sent every setting, so nothing is left to patch.
4101
+ have = appSettingsOf(entry);
4102
+ haveDomains = wantDomains;
4059
4103
  }
4060
- const created = await this.ports.coolify.createGithubAppApplication({
4061
- is_preview_deployments_enabled: false,
4062
- project_uuid: projectUuid,
4063
- server_uuid: server.uuid,
4064
- environment_name: environment.name,
4065
- environment_uuid: environment.uuid,
4066
- github_app_uuid: githubAppUuid,
4067
- git_repository: appRepo,
4068
- git_branch: entry.branch,
4069
- build_pack: entry.buildPack,
4070
- name: appName,
4071
- autogenerate_domain: false,
4072
- // A static site is served by Coolify's own web server on port 80.
4073
- ports_exposes: isStatic ? '80' : entry.port === null ? undefined : String(entry.port),
4074
- is_auto_deploy_enabled: appSettingsOf(entry).autoDeploy,
4075
- // Also the folder a static entry serves: Coolify's static build
4076
- // copies `base_directory`, and reads `publish_directory` only for a
4077
- // built app.
4078
- base_directory: entry.folder === null ? undefined : `/${entry.folder}`,
4079
- });
4080
- appId = created.uuid;
4081
- // Create sent every setting, so nothing is left to patch.
4082
- have = appSettingsOf(entry);
4083
- haveDomains = wantDomains;
4084
4104
  }
4085
4105
  // Patch what differs from what the app has. A crash before the marker is
4086
4106
  // recorded below leaves the old one, so the retry patches the same
@@ -4100,6 +4120,13 @@ export class FoundryStepExecutor {
4100
4120
  patch.is_preview_deployments_enabled = false;
4101
4121
  if (pinnedCommit)
4102
4122
  patch.git_commit_sha = pinnedCommit;
4123
+ // A new image reference reaches a live app: the digest is patched and the app deploys below.
4124
+ const imageRef = entry.image ? entryImageRef(entry.image) : null;
4125
+ const imageChanged = entry.image !== undefined &&
4126
+ existing !== null &&
4127
+ context.recordedIds()[appImageAppliedScopeKey(entry.key)] !== imageRef;
4128
+ if (entry.image && imageChanged)
4129
+ Object.assign(patch, entryImageFields(entry.image));
4103
4130
  // #543: the health-check quartet too, every run -- the same reason
4104
4131
  // Archon's own doc comment gives (without one, Coolify's rolling update
4105
4132
  // has nothing to wait on and stops the old container immediately). The
@@ -4148,7 +4175,8 @@ export class FoundryStepExecutor {
4148
4175
  if (Object.keys(patch).length > 0)
4149
4176
  await this.ports.coolify.updateApplication(appId, patch);
4150
4177
  // The container builds and listens from these only at its next deploy.
4151
- const settingsNeedDeploy = Boolean(patch.base_directory ||
4178
+ const settingsNeedDeploy = Boolean(imageChanged ||
4179
+ patch.base_directory ||
4152
4180
  patch.ports_exposes ||
4153
4181
  patch.domains !== undefined ||
4154
4182
  patch.custom_internal_name ||
@@ -4218,7 +4246,10 @@ export class FoundryStepExecutor {
4218
4246
  entryRoutesMarker(org, context.recordedIds())
4219
4247
  ? publicHealthCheck(org, context.recordedIds(), hostname, healthPath)
4220
4248
  : { kind: 'deployment-only' }, runtime, runtime.sleep ?? defaultSleep, `coolify:app:${entry.key}`, `${org.slug}'s ${entry.key === APP_ENTRY_KEY ? 'app' : `${entry.key} app`} stays at its previous deploy`);
4221
- await recordBuiltCommit(this.ports.coolify, { appId: appId, deploymentUuid: built.deploymentUuid, name: entry.key }, (key, value) => context.recordScopeId(key, value));
4249
+ // An image app has no commit to read: its digest is the build.
4250
+ if (!entry.image) {
4251
+ await recordBuiltCommit(this.ports.coolify, { appId: appId, deploymentUuid: built.deploymentUuid, name: entry.key }, (key, value) => context.recordScopeId(key, value));
4252
+ }
4222
4253
  }
4223
4254
  else if ((appEnv.changed || dbNeedsRestart) && existing) {
4224
4255
  await this.ports.coolify.restart(appId);
@@ -4230,6 +4261,8 @@ export class FoundryStepExecutor {
4230
4261
  context.recordScopeId(settingsKey, JSON.stringify({ ...want, port: want.port ?? have.port }));
4231
4262
  context.recordScopeId(domainsKey, JSON.stringify(wantDomains));
4232
4263
  context.recordScopeId(labelsKey, wantLabelsHash);
4264
+ if (imageRef)
4265
+ context.recordScopeId(appImageAppliedScopeKey(entry.key), imageRef);
4233
4266
  if (pinnedCommit && pinnedCommit === entry.liveCommit) {
4234
4267
  context.recordScopeId(wsKey.liveCommit(entry.key), pinnedCommit);
4235
4268
  }
@@ -1,4 +1,4 @@
1
- import type { DeclaredOrg, EnvSharedSecret, HostingEntry, OrgId } from '../org.js';
1
+ import type { DeclaredOrg, EntryImage, EnvSharedSecret, HostingEntry, OrgId } from '../org.js';
2
2
  import { type Plan, type PlanNote, type UnmanagedItem, type VersionChangeItem } from '../plan.js';
3
3
  import type { DeclaredPlatform } from '../platform.js';
4
4
  import { type BlueGreenServer } from './blue-green.js';
@@ -725,6 +725,22 @@ export declare const DEFAULT_APP_SETTINGS: AppSettings;
725
725
  export declare function appSettingsAppliedScopeKey(entryKey: string): string;
726
726
  /** The settings the app has: the marker, or the defaults when there is none. */
727
727
  export declare function appliedAppSettings(recorded: Readonly<Record<string, string>>, entryKey: string): AppSettings;
728
+ /**
729
+ * The image an entry's app last ran, as `repository@digest` (no secret). `planApp` plans the step again, with a
730
+ * redeploy, when the entry's `image` differs from it.
731
+ */
732
+ export declare function appImageAppliedScopeKey(entryKey: string): string;
733
+ /** An entry image as one pull reference. */
734
+ export declare function entryImageRef(image: EntryImage): string;
735
+ /**
736
+ * The two image fields Coolify accepts for an entry image. Its tag rule has no `@` or `:`, so the digest goes in
737
+ * the name as `<repository>@sha256` and the tag holds the bare 64 hex digits (Coolify pulls `<name>:<tag>`),
738
+ * the same way `coolifyImageFor` writes a pinned platform image.
739
+ */
740
+ export declare function entryImageFields(image: EntryImage): {
741
+ docker_registry_image_name: string;
742
+ docker_registry_image_tag: string;
743
+ };
728
744
  /**
729
745
  * The URLs `coolifyApp` last put in the entry's Coolify `domains`
730
746
  * (`tunnelDomainList`), as a JSON array (`[]` for an internal entry). `planApp`
@@ -812,6 +812,28 @@ export function appliedAppSettings(recorded, entryKey) {
812
812
  const marker = recorded[appSettingsAppliedScopeKey(entryKey)];
813
813
  return marker ? JSON.parse(marker) : DEFAULT_APP_SETTINGS;
814
814
  }
815
+ /**
816
+ * The image an entry's app last ran, as `repository@digest` (no secret). `planApp` plans the step again, with a
817
+ * redeploy, when the entry's `image` differs from it.
818
+ */
819
+ export function appImageAppliedScopeKey(entryKey) {
820
+ return `app_image_applied.${entryKey}`;
821
+ }
822
+ /** An entry image as one pull reference. */
823
+ export function entryImageRef(image) {
824
+ return `${image.repository}@${image.digest}`;
825
+ }
826
+ /**
827
+ * The two image fields Coolify accepts for an entry image. Its tag rule has no `@` or `:`, so the digest goes in
828
+ * the name as `<repository>@sha256` and the tag holds the bare 64 hex digits (Coolify pulls `<name>:<tag>`),
829
+ * the same way `coolifyImageFor` writes a pinned platform image.
830
+ */
831
+ export function entryImageFields(image) {
832
+ return {
833
+ docker_registry_image_name: `${image.repository}@sha256`,
834
+ docker_registry_image_tag: image.digest.replace(/^sha256:/, ''),
835
+ };
836
+ }
815
837
  /**
816
838
  * The URLs `coolifyApp` last put in the entry's Coolify `domains`
817
839
  * (`tunnelDomainList`), as a JSON array (`[]` for an internal entry). `planApp`
@@ -6237,7 +6237,7 @@ function assertSameDatabaseHost(key, liveUrl, dbInternalUrl) {
6237
6237
  /**
6238
6238
  * Throws when `liveUrl`, kept as it is on the app, names another database than
6239
6239
  * {@link ARCHON_POSTGRES_DB}: a hand-set URL on the wrong path, or a platform
6240
- * still on the retired `archon_v2`. Names the two databases only, never the URL.
6240
+ * still on a retired database. Names the two databases only, never the URL.
6241
6241
  */
6242
6242
  function assertArchonDatabasePath(key, liveUrl) {
6243
6243
  let database;
@@ -1,5 +1,5 @@
1
1
  import { decide, healthRange, } from './decide.js';
2
- import { formatWeights, formatWindow, recordedFrom, releaseKeys, } from './state.js';
2
+ import { formatRefusals, formatWeights, formatWindow, recordedFrom, releaseKeys, } from './state.js';
3
3
  import { isStageCopyAction } from './types.js';
4
4
  const REFUSAL_TEXT = {
5
5
  'no-copy-port': 'copy refused: this host cannot restore',
@@ -79,6 +79,10 @@ export async function runRelease(input) {
79
79
  [k.window]: formatWindow(next.window, next.release),
80
80
  [k.signal]: decision.signal ? JSON.stringify(decision.signal) : null,
81
81
  };
82
+ // Part of the run's writes: persisted with them, so a failed persist leaves neither. A run with none keeps it.
83
+ const lines = [...refused, ...decision.blocked];
84
+ if (lines.length > 0)
85
+ writes[k.refused] = formatRefusals(stamp, lines);
82
86
  for (const d of recorded.held)
83
87
  if (!next.held.includes(d))
84
88
  writes[k.held(d)] = null;
@@ -132,6 +136,7 @@ export function settleStageCopy(input) {
132
136
  writes[k.stageCopy] = `${stamp}|refused|${result.reason}`;
133
137
  writes[k.stageCopyRetry] = null;
134
138
  refused.push(REFUSAL_TEXT[result.reason] ?? `copy refused: ${result.reason}`);
139
+ writes[k.refused] = formatRefusals(stamp, refused);
135
140
  return { writes, refused, consume: true };
136
141
  }
137
142
  writes[k.stageCopy] = `${stamp}|failed|${result.step}`;
@@ -48,6 +48,8 @@ export declare function releaseKeys(entry: string): {
48
48
  stageCopyHandled: string;
49
49
  /** The id of a request whose copy failed once; a second failure consumes it. */
50
50
  stageCopyRetry: string;
51
+ /** JSON `{ at, lines }`: the latest run that refused or blocked something (see {@link formatRefusals}). */
52
+ refused: string;
51
53
  };
52
54
  export declare function formatWeights(w: Record<Color, number>): string;
53
55
  export declare function parseWeights(value: string | undefined): Record<Color, number> | null;
@@ -60,6 +62,13 @@ export declare function formatWindow(window: WindowState, release: Recorded['rel
60
62
  export declare function recordedFrom(ids: Record<string, string>, entryKey: string): Recorded | null;
61
63
  /** Reads `bg.<id>.stagecopy`; a damaged value reads as absent. */
62
64
  export declare function parseStageCopy(value: string | undefined): StageCopyState | undefined;
65
+ /**
66
+ * The value of `bg.<id>.refused`: the run time and its refused lines, then its blocked lines. Capped at 20 lines
67
+ * of 300 characters so a bad run cannot grow the row. Callers write it only when `lines` is not empty.
68
+ */
69
+ export declare function formatRefusals(at: string, lines: readonly string[]): string;
70
+ /** Reads `bg.<id>.refused`; a damaged value reads as absent. */
71
+ export declare function parseRefusals(value: string | undefined): ReleaseState['refused'];
63
72
  export declare function readSignal(ids: Record<string, string>, entryKey: string): ReleaseSignal | null;
64
73
  /** C2: the release state of one hosting entry, or null before blue/green recorded anything. */
65
74
  export declare function readReleaseState(store: ReleaseStore, input: {
@@ -25,6 +25,8 @@ export function releaseKeys(entry) {
25
25
  stageCopyHandled: `${p}.stagecopyhandled`,
26
26
  /** The id of a request whose copy failed once; a second failure consumes it. */
27
27
  stageCopyRetry: `${p}.stagecopyretry`,
28
+ /** JSON `{ at, lines }`: the latest run that refused or blocked something (see {@link formatRefusals}). */
29
+ refused: `${p}.refused`,
28
30
  };
29
31
  }
30
32
  export function formatWeights(w) {
@@ -122,6 +124,26 @@ export function parseStageCopy(value) {
122
124
  }
123
125
  return undefined;
124
126
  }
127
+ const REFUSED_MAX_LINES = 20;
128
+ const REFUSED_MAX_LINE = 300;
129
+ const refusedSchema = z.object({ at: z.string(), lines: z.array(z.string()) });
130
+ /**
131
+ * The value of `bg.<id>.refused`: the run time and its refused lines, then its blocked lines. Capped at 20 lines
132
+ * of 300 characters so a bad run cannot grow the row. Callers write it only when `lines` is not empty.
133
+ */
134
+ export function formatRefusals(at, lines) {
135
+ return JSON.stringify({
136
+ at,
137
+ lines: lines.slice(0, REFUSED_MAX_LINES).map((l) => l.slice(0, REFUSED_MAX_LINE)),
138
+ });
139
+ }
140
+ /** Reads `bg.<id>.refused`; a damaged value reads as absent. */
141
+ export function parseRefusals(value) {
142
+ const parsed = refusedSchema.safeParse(parseJson(value));
143
+ if (!parsed.success || Number.isNaN(Date.parse(parsed.data.at)))
144
+ return undefined;
145
+ return parsed.data;
146
+ }
125
147
  export function readSignal(ids, entryKey) {
126
148
  const parsed = signalSchema.safeParse(parseJson(ids[releaseKeys(entryKey).signal]));
127
149
  return parsed.success ? parsed.data : null;
@@ -133,6 +155,7 @@ export async function readReleaseState(store, input) {
133
155
  if (!r)
134
156
  return null;
135
157
  const stageCopy = parseStageCopy(ids[releaseKeys(input.entryKey).stageCopy]);
158
+ const refused = parseRefusals(ids[releaseKeys(input.entryKey).refused]);
136
159
  return {
137
160
  entryKey: input.entryKey,
138
161
  live: r.live,
@@ -144,6 +167,7 @@ export async function readReleaseState(store, input) {
144
167
  held: r.held,
145
168
  config: resolveReleaseConfig(input.entry),
146
169
  ...(stageCopy ? { stageCopy } : {}),
170
+ ...(refused ? { refused } : {}),
147
171
  ...stageDeployOf(ids[releaseKeys(input.entryKey).stageDeploy]),
148
172
  };
149
173
  }
@@ -59,6 +59,14 @@ export interface ReleaseState {
59
59
  outcome: 'ok' | 'failed';
60
60
  detail: string;
61
61
  };
62
+ /**
63
+ * The latest run that refused or blocked something (`bg.<id>.refused`): its time and its refused lines then its
64
+ * blocked lines, as decide's fixed texts (at most 20 lines of 300 characters). A run with none leaves it as is.
65
+ */
66
+ refused?: {
67
+ at: string;
68
+ lines: string[];
69
+ };
62
70
  }
63
71
  /** C3: what the last `copy_prod_to_stage` or `undo_stage_copy` request did. `detail` is fixed text or an id. */
64
72
  export interface StageCopyState {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/foundry",
3
- "version": "0.63.0",
3
+ "version": "0.65.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",