@wtfalch/foundry 0.76.0 → 0.77.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 (127) hide show
  1. package/dist/app-canary-worker.d.ts +35 -0
  2. package/dist/app-canary-worker.js +56 -0
  3. package/dist/audit/connector.d.ts +6 -2
  4. package/dist/audit/connector.js +3 -2
  5. package/dist/audit/evidence.d.ts +4 -0
  6. package/dist/audit/evidence.js +14 -2
  7. package/dist/audit/host-store.d.ts +58 -0
  8. package/dist/audit/host-store.js +147 -0
  9. package/dist/audit/host.d.ts +53 -0
  10. package/dist/audit/host.js +294 -0
  11. package/dist/audit/index.d.ts +2 -0
  12. package/dist/audit/index.js +2 -0
  13. package/dist/audit/registry.d.ts +14 -0
  14. package/dist/audit/sealer-role.d.ts +12 -0
  15. package/dist/audit/sealer-role.js +26 -2
  16. package/dist/audit/tick.d.ts +10 -2
  17. package/dist/audit/tick.js +15 -2
  18. package/dist/catalogue/copy-en.js +5 -0
  19. package/dist/catalogue/integrations.js +5 -0
  20. package/dist/entry-rules.d.ts +35 -0
  21. package/dist/entry-rules.js +35 -0
  22. package/dist/hosted-label.js +2 -1
  23. package/dist/index.d.ts +11 -3
  24. package/dist/index.js +6 -1
  25. package/dist/migrations/0072_audit_host.sql +46 -0
  26. package/dist/migrations/0074_platform_app_canary.sql +11 -0
  27. package/dist/modules/ai.js +15 -1
  28. package/dist/modules/assistants.js +3 -0
  29. package/dist/modules/email.d.ts +6 -1
  30. package/dist/modules/email.js +88 -5
  31. package/dist/modules/files.js +79 -2
  32. package/dist/modules/index.js +7 -0
  33. package/dist/modules/jobs.d.ts +5 -1
  34. package/dist/modules/jobs.js +74 -4
  35. package/dist/modules/reporting.d.ts +9 -0
  36. package/dist/modules/reporting.js +29 -0
  37. package/dist/modules/sign-in.js +20 -2
  38. package/dist/modules/types.d.ts +2 -0
  39. package/dist/modules/workspaces.js +3 -0
  40. package/dist/org.d.ts +114 -13
  41. package/dist/org.js +233 -20
  42. package/dist/plan/ai-boule-key-apply.d.ts +65 -0
  43. package/dist/plan/ai-boule-key-apply.js +169 -0
  44. package/dist/plan/ai-boule-key.d.ts +89 -0
  45. package/dist/plan/ai-boule-key.js +195 -0
  46. package/dist/plan/analytics.d.ts +8 -1
  47. package/dist/plan/analytics.js +8 -1
  48. package/dist/plan/app-canary-apply.d.ts +49 -0
  49. package/dist/plan/app-canary-apply.js +232 -0
  50. package/dist/plan/app-canary.d.ts +44 -0
  51. package/dist/plan/app-canary.js +110 -0
  52. package/dist/plan/blue-green-apply.d.ts +6 -2
  53. package/dist/plan/blue-green-apply.js +14 -3
  54. package/dist/plan/broker-entries.js +20 -5
  55. package/dist/plan/company-backups.js +2 -0
  56. package/dist/plan/desktop-auth.d.ts +17 -4
  57. package/dist/plan/desktop-auth.js +22 -5
  58. package/dist/plan/diff.d.ts +22 -0
  59. package/dist/plan/diff.js +231 -18
  60. package/dist/plan/entry-files-key.d.ts +39 -3
  61. package/dist/plan/entry-files-key.js +103 -6
  62. package/dist/plan/entry-jobs-boule-key-apply.d.ts +65 -0
  63. package/dist/plan/entry-jobs-boule-key-apply.js +192 -0
  64. package/dist/plan/entry-jobs-boule-key.d.ts +59 -0
  65. package/dist/plan/entry-jobs-boule-key.js +141 -0
  66. package/dist/plan/entry-jobs.d.ts +2 -0
  67. package/dist/plan/entry-reporting-key.d.ts +36 -0
  68. package/dist/plan/entry-reporting-key.js +136 -0
  69. package/dist/plan/executor.d.ts +108 -1
  70. package/dist/plan/executor.js +676 -24
  71. package/dist/plan/files-boule-key-apply.d.ts +73 -0
  72. package/dist/plan/files-boule-key-apply.js +223 -0
  73. package/dist/plan/files-boule-key.d.ts +92 -0
  74. package/dist/plan/files-boule-key.js +175 -0
  75. package/dist/plan/hostnames.js +4 -0
  76. package/dist/plan/index.d.ts +4 -0
  77. package/dist/plan/index.js +2 -0
  78. package/dist/plan/jobs-scheduler-http.d.ts +13 -0
  79. package/dist/plan/jobs-scheduler-http.js +39 -0
  80. package/dist/plan/live.d.ts +1 -1
  81. package/dist/plan/locked-hostnames.d.ts +1 -1
  82. package/dist/plan/locked-hostnames.js +4 -1
  83. package/dist/plan/login-host.js +2 -2
  84. package/dist/plan/org-names.js +3 -0
  85. package/dist/plan/own-diff.js +3 -2
  86. package/dist/plan/own-jobs-route.d.ts +5 -2
  87. package/dist/plan/own-jobs-route.js +6 -2
  88. package/dist/plan/own-limits.d.ts +6 -0
  89. package/dist/plan/own-limits.js +1 -0
  90. package/dist/plan/plan-apply.d.ts +24 -0
  91. package/dist/plan/plan-apply.js +39 -4
  92. package/dist/plan/platform-diff.d.ts +2 -0
  93. package/dist/plan/platform-diff.js +15 -1
  94. package/dist/plan/platform-providers.d.ts +9 -0
  95. package/dist/plan/platform-providers.js +21 -0
  96. package/dist/plan/providers-readers.js +25 -1
  97. package/dist/plan/registered-app-env.d.ts +31 -0
  98. package/dist/plan/registered-app-env.js +63 -0
  99. package/dist/plan/registered-app.d.ts +121 -0
  100. package/dist/plan/registered-app.js +262 -0
  101. package/dist/plan/release-host.js +2 -1
  102. package/dist/plan/reporting-boule.d.ts +18 -0
  103. package/dist/plan/reporting-boule.js +44 -0
  104. package/dist/plan/reporting.d.ts +90 -0
  105. package/dist/plan/reporting.js +135 -0
  106. package/dist/plan/stage-env.js +1 -0
  107. package/dist/plan/stage-isolation.d.ts +3 -1
  108. package/dist/plan/stage-isolation.js +20 -2
  109. package/dist/plan/stage-services.d.ts +3 -1
  110. package/dist/plan/stage-services.js +8 -1
  111. package/dist/plan/stage-sink.d.ts +2 -2
  112. package/dist/plan/stage-sink.js +2 -2
  113. package/dist/plan/workspace-client.d.ts +1 -1
  114. package/dist/plan/workspace-client.js +2 -1
  115. package/dist/plan/workspace-sign-in.d.ts +1 -1
  116. package/dist/plan/workspace-sign-in.js +39 -7
  117. package/dist/platform.d.ts +25 -0
  118. package/dist/platform.js +20 -0
  119. package/dist/service-labels.d.ts +8 -0
  120. package/dist/service-labels.js +8 -0
  121. package/dist/slug.d.ts +1 -1
  122. package/dist/slug.js +5 -2
  123. package/dist/store/postgres.js +13 -0
  124. package/dist/store/schema.d.ts +485 -2
  125. package/dist/store/schema.js +38 -1
  126. package/dist/worker-app-config.js +2 -2
  127. package/package.json +1 -1
@@ -12,11 +12,17 @@ export const FILES_KEY_DAYS = 365;
12
12
  /** A key with fewer days left than this is renewed. */
13
13
  export const FILES_KEY_RENEW_WINDOW_DAYS = 30;
14
14
  const DAY_MS = 24 * 60 * 60 * 1000;
15
- /** What the org's own key holds: it mints and revokes, and holds what its entries may hold. */
15
+ /**
16
+ * What the org's own key holds: it mints and revokes, and holds what its entries and Boule's key may hold
17
+ * (foundry#1121: `files.usage:read`, `files.shares:read` and `files.files:share` for Boule's Files page).
18
+ */
16
19
  export const FILES_ROOT_PERMISSIONS = [
17
20
  'credentials:create',
18
21
  'credentials:revoke',
19
22
  ...FILES_ENTRY_PERMISSIONS,
23
+ 'files.usage:read',
24
+ 'files.shares:read',
25
+ 'files.files:share',
20
26
  ];
21
27
  /**
22
28
  * Flat scope ids (no `:`). The org's own key: the id minted last (it blocks
@@ -25,6 +31,12 @@ export const FILES_ROOT_PERMISSIONS = [
25
31
  */
26
32
  export const FILES_ROOT_UNDELIVERED_SCOPE_KEY = 'files_key_undelivered';
27
33
  export const FILES_ROOT_EXPIRES_SCOPE_KEY = 'files_key_expires';
34
+ /**
35
+ * foundry#1121: the permissions the recorded org key was minted with, as JSON. A key minted before this was
36
+ * recorded, or with fewer permissions than {@link FILES_ROOT_PERMISSIONS}, is renewed: a key below it can
37
+ * hold only what it holds.
38
+ */
39
+ export const FILES_ROOT_PERMISSIONS_SCOPE_KEY = 'files_key_permissions';
28
40
  /** Ids of org keys replaced by a renewal, as a JSON array, revoked once every entry holds a key below the new one. */
29
41
  export const FILES_ROOT_PREVIOUS_SCOPE_KEY = 'files_key_previous';
30
42
  /** Ids of replaced org keys whose revoke failed, as a JSON array. They expire on their own; the plan lists each. */
@@ -52,6 +64,72 @@ export const entryFilesRetiredScopeKey = (entryKey) => `files_entry_retired_${en
52
64
  export const entryFilesUnrevokedScopeKey = (entryKey) => `files_entry_unrevoked_${entryKey}`;
53
65
  export const ENTRY_FILES_UNREVOKED_PREFIX = 'files_entry_unrevoked_';
54
66
  export const ENTRY_FILES_UNDELIVERED_PREFIX = 'files_entry_undelivered_';
67
+ /** foundry#1121 (`files-boule-key.ts`). Boule's key is minted, not held yet: its id, never the key. */
68
+ export const FILES_BOULE_UNDELIVERED_SCOPE_KEY = 'files_boule_key_undelivered';
69
+ /** The id of Boule's key once it is held. */
70
+ export const FILES_BOULE_KEY_SCOPE_KEY = 'files_boule_key';
71
+ /** The id of the org's own files key Boule's key was minted below. */
72
+ export const FILES_BOULE_MINTER_SCOPE_KEY = 'files_boule_key_minter';
73
+ /** Ids of Boule keys a renewal replaced, as a JSON array, revoked once the new one is in Boule's env. */
74
+ export const FILES_BOULE_PREVIOUS_SCOPE_KEY = 'files_boule_key_previous';
75
+ /** Per entry: label to collection name in files, every name the entry ever had. */
76
+ export const entryFilesCollectionsScopeKey = (entryKey) => `files_entry_collections_${entryKey}`;
77
+ /** The name a new collection of an entry gets in files. */
78
+ export const entryCollectionName = (entryKey, label) => `${entryKey}.${label}`;
79
+ function parseNames(raw) {
80
+ if (!raw)
81
+ return null;
82
+ try {
83
+ const parsed = JSON.parse(raw);
84
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
85
+ return null;
86
+ const out = {};
87
+ for (const [label, name] of Object.entries(parsed)) {
88
+ if (typeof name === 'string' && name)
89
+ out[label] = name;
90
+ }
91
+ return out;
92
+ }
93
+ catch {
94
+ return null;
95
+ }
96
+ }
97
+ /**
98
+ * Every collection name the entry has had, label to name: the recorded map, or, before one is recorded, each
99
+ * label of the entry's last applied files marker under its own name (the name files was given before
100
+ * collections were named per app).
101
+ */
102
+ export function knownCollectionNames(entryKey, recordedIds) {
103
+ const recorded = parseNames(recordedIds[entryFilesCollectionsScopeKey(entryKey)]);
104
+ if (recorded)
105
+ return recorded;
106
+ const legacy = {};
107
+ try {
108
+ const applied = JSON.parse(recordedIds[entryFilesAppliedScopeKey(entryKey)] ?? '{}');
109
+ const named = parseNames(JSON.stringify(applied.collectionNames ?? null));
110
+ if (named)
111
+ return named;
112
+ if (Array.isArray(applied.collections)) {
113
+ for (const label of applied.collections) {
114
+ if (typeof label === 'string' && label)
115
+ legacy[label] = label;
116
+ }
117
+ }
118
+ }
119
+ catch {
120
+ // An unreadable marker has no collections to keep.
121
+ }
122
+ return legacy;
123
+ }
124
+ /** The files name of each collection the entry declares now, label to name, in the declared order. */
125
+ export function entryCollectionNames(entry, recordedIds) {
126
+ const known = knownCollectionNames(entry.key, recordedIds);
127
+ const out = {};
128
+ for (const label of entry.uses?.files?.collections ?? []) {
129
+ out[label] = known[label] ?? entryCollectionName(entry.key, label);
130
+ }
131
+ return out;
132
+ }
55
133
  export function parseIdList(raw) {
56
134
  try {
57
135
  const parsed = raw ? JSON.parse(raw) : [];
@@ -68,15 +146,29 @@ export function filesOwnerUserId(org, recordedIds) {
68
146
  const first = org.owners[0];
69
147
  return first ? recordedIds[`zitadel:owner:${org.id}:${first.email.toLowerCase()}`] : undefined;
70
148
  }
71
- export function wantedEntryFiles(entry, filesUrl, shareHostname = null) {
149
+ export function wantedEntryFiles(entry, filesUrl, shareHostname = null, recordedIds = {}) {
72
150
  const files = entry.uses?.files;
73
151
  return {
74
152
  permissions: [...(files?.permissions ?? FILES_ENTRY_PERMISSIONS)],
75
153
  collections: [...(files?.collections ?? [])],
76
154
  filesUrl,
77
155
  ...(files?.shareHost && shareHostname ? { shareOrigin: `https://${shareHostname}` } : {}),
156
+ collectionNames: entryCollectionNames(entry, recordedIds),
78
157
  };
79
158
  }
159
+ /**
160
+ * foundry#1121: Boule's files key, when it has one, is below `rootKeyId` and delivered, so revoking the org
161
+ * key it replaced revokes nothing Boule still uses.
162
+ */
163
+ export function bouleKeyBelow(recordedIds, rootKeyId) {
164
+ // A renewal's old key is still in Boule's env until the new one is written.
165
+ if (parseIdList(recordedIds[FILES_BOULE_PREVIOUS_SCOPE_KEY]).length > 0)
166
+ return false;
167
+ if (!recordedIds[FILES_BOULE_KEY_SCOPE_KEY] && !recordedIds[FILES_BOULE_UNDELIVERED_SCOPE_KEY])
168
+ return true;
169
+ return (recordedIds[FILES_BOULE_MINTER_SCOPE_KEY] === rootKeyId &&
170
+ !recordedIds[FILES_BOULE_UNDELIVERED_SCOPE_KEY]);
171
+ }
80
172
  /**
81
173
  * The org key's steps, then one step per entry whose key is missing, near
82
174
  * expiry, minted below a replaced org key or whose declared settings changed.
@@ -171,9 +263,12 @@ export function planFilesKeys(org, recordedIds, plannedSteps, readiness, now) {
171
263
  const rootExpires = Date.parse(rootExpiresRaw ?? '');
172
264
  const rootShort = Boolean(rootKeyId) &&
173
265
  (Number.isNaN(rootExpires) || rootExpires - now < FILES_KEY_RENEW_WINDOW_DAYS * DAY_MS);
266
+ // foundry#1121: a key minted with fewer permissions than the org key now holds is renewed.
267
+ const rootNarrow = Boolean(rootKeyId) &&
268
+ recordedIds[FILES_ROOT_PERMISSIONS_SCOPE_KEY] !== JSON.stringify(FILES_ROOT_PERMISSIONS);
174
269
  const rootUndelivered = recordedIds[FILES_ROOT_UNDELIVERED_SCOPE_KEY];
175
270
  let rootMode = null;
176
- if (!rootKeyId || rootShort) {
271
+ if (!rootKeyId || rootShort || rootNarrow) {
177
272
  const ownerKey = org.owners[0] ? `${org.id}:${org.owners[0].email.toLowerCase()}` : '';
178
273
  const orgPlanned = planned('files', 'organisation', org.id);
179
274
  if (rootUndelivered && rootUndelivered !== rootKeyId) {
@@ -206,7 +301,9 @@ export function planFilesKeys(org, recordedIds, plannedSteps, readiness, now) {
206
301
  },
207
302
  step: {
208
303
  description: rootMode === 'renew'
209
- ? `renew ${org.slug}'s own files key and re-mint its entry keys below it`
304
+ ? rootNarrow && !rootShort
305
+ ? `renew ${org.slug}'s own files key with the permissions Boule's files key needs, and re-mint its entry keys below it`
306
+ : `renew ${org.slug}'s own files key and re-mint its entry keys below it`
210
307
  : `mint ${org.slug}'s own files key below its owner`,
211
308
  destroys: false,
212
309
  migrates: false,
@@ -241,7 +338,7 @@ export function planFilesKeys(org, recordedIds, plannedSteps, readiness, now) {
241
338
  block("the entry's app is not recorded yet");
242
339
  continue;
243
340
  }
244
- const want = wantedEntryFiles(entry, readiness.filesUrl, shareHostname);
341
+ const want = wantedEntryFiles(entry, readiness.filesUrl, shareHostname, recordedIds);
245
342
  const applied = recordedIds[entryFilesAppliedScopeKey(entry.key)];
246
343
  const wantJson = JSON.stringify(want);
247
344
  let mode = null;
@@ -304,7 +401,7 @@ export function planFilesKeys(org, recordedIds, plannedSteps, readiness, now) {
304
401
  return (recordedIds[`files:key:${stepKey}`] &&
305
402
  recordedIds[entryFilesMinterScopeKey(e.key)] === rootKeyId &&
306
403
  !recordedIds[entryFilesUndeliveredScopeKey(e.key)]);
307
- });
404
+ }) && bouleKeyBelow(recordedIds, rootKeyId);
308
405
  if (rootMode === 'renew' || (previous.length > 0 && consistent)) {
309
406
  steps.push({
310
407
  action: {
@@ -0,0 +1,65 @@
1
+ /** Every call this module makes (scheduler, held keys, Coolify) is cut off after this long. */
2
+ export declare const JOBS_BOULE_KEY_CALL_TIMEOUT_MS = 30000;
3
+ /** How many times Boule's key is tried against the held keys. */
4
+ export declare const JOBS_BOULE_KEY_HOLD_ATTEMPTS = 3;
5
+ /** The scheduler's project-key calls (jobs#79), as `JobsSchedulerPort` offers them. */
6
+ export interface JobsProjectKeysPort {
7
+ mintProjectKey(project: string, grant: {
8
+ principal: string;
9
+ scopes: readonly string[];
10
+ }): Promise<{
11
+ id: string;
12
+ key: string;
13
+ }>;
14
+ /** Revokes one key; a key or project the scheduler does not know throws with `status: 404`. */
15
+ revokeProjectKey(project: string, keyId: string): Promise<void>;
16
+ }
17
+ export interface JobsBouleKeyDeps {
18
+ scheduler: JobsProjectKeysPort;
19
+ heldKeys: {
20
+ resolve(ref: string): Promise<string | null | undefined>;
21
+ put(ref: string, value: string): Promise<void>;
22
+ };
23
+ coolify: {
24
+ bulkUpsertEnvs(appId: string, envs: Array<{
25
+ key: string;
26
+ value: string;
27
+ literal?: boolean;
28
+ }>): Promise<unknown>;
29
+ listEnvs(appId: string): Promise<Array<{
30
+ uuid?: string;
31
+ key: string;
32
+ value: string;
33
+ is_preview: boolean;
34
+ }>>;
35
+ deleteEnv(appId: string, envUuid: string): Promise<unknown>;
36
+ restart(appId: string): Promise<unknown>;
37
+ };
38
+ org: {
39
+ id: string;
40
+ slug: string;
41
+ };
42
+ entryKey: string;
43
+ /** The scheduler's name for the entry's project, `<slug>-<entry>`. */
44
+ project: string;
45
+ /** Boule's Coolify app, when it is recorded. */
46
+ bouleAppId: string | undefined;
47
+ recorded: () => Readonly<Record<string, string>>;
48
+ record: (key: string, value: string) => void;
49
+ forget: (key: string) => void;
50
+ persist: () => Promise<void>;
51
+ /** Registers a value for redaction before anything else can see it. */
52
+ secret: (value: string) => void;
53
+ timeoutMs?: number;
54
+ }
55
+ /**
56
+ * Mints (or reuses the held) key for one entry's project and puts it in Boule's `JOBS_PROJECTS` at `url`.
57
+ * See `entry-jobs-boule-key.ts` for the order. Errors name the project, the key id and the ref, never the key.
58
+ */
59
+ export declare function ensureJobsBouleKey(deps: JobsBouleKeyDeps, url: string): Promise<void>;
60
+ /**
61
+ * Revokes Boule's key for an entry that no longer uses jobs (a held one, or one minted and never held), takes
62
+ * the entry out of `JOBS_PROJECTS` and restarts Boule, then clears the records. A key the scheduler no longer
63
+ * knows counts as revoked.
64
+ */
65
+ export declare function retireJobsBouleKey(deps: JobsBouleKeyDeps): Promise<void>;
@@ -0,0 +1,192 @@
1
+ import { JOBS_BOULE_KEY_GRANT, JOBS_PROJECTS_ENV, jobsBouleKeyAppliedScopeKey, jobsBouleKeyRef, jobsBouleKeyScopeKey, jobsBouleKeyUndeliveredScopeKey, } from './entry-jobs-boule-key.js';
2
+ /** Every call this module makes (scheduler, held keys, Coolify) is cut off after this long. */
3
+ export const JOBS_BOULE_KEY_CALL_TIMEOUT_MS = 30_000;
4
+ /** How many times Boule's key is tried against the held keys. */
5
+ export const JOBS_BOULE_KEY_HOLD_ATTEMPTS = 3;
6
+ async function timed(work, ms) {
7
+ let timer;
8
+ try {
9
+ return await Promise.race([
10
+ work(),
11
+ new Promise((_, reject) => {
12
+ timer = setTimeout(() => reject(new Error('timeout')), ms);
13
+ }),
14
+ ]);
15
+ }
16
+ finally {
17
+ clearTimeout(timer);
18
+ }
19
+ }
20
+ const isNotFound = (error) => error?.status === 404;
21
+ /** Boule's `JOBS_PROJECTS` as it is now, keeping only well-formed entries; every key in it is registered for redaction. */
22
+ async function readProjects(deps, appId, at, ms) {
23
+ let rows;
24
+ try {
25
+ rows = await timed(() => deps.coolify.listEnvs(appId), ms);
26
+ }
27
+ catch {
28
+ throw new Error(`${at}: Boule's env could not be read`);
29
+ }
30
+ const row = rows.find((e) => e.key === JOBS_PROJECTS_ENV && !e.is_preview);
31
+ const projects = {};
32
+ if (row?.value) {
33
+ let parsed;
34
+ try {
35
+ parsed = JSON.parse(row.value);
36
+ }
37
+ catch {
38
+ parsed = null;
39
+ }
40
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
41
+ for (const [entry, value] of Object.entries(parsed)) {
42
+ const v = value;
43
+ if (v && typeof v.url === 'string' && typeof v.key === 'string') {
44
+ deps.secret(v.key);
45
+ projects[entry] = { url: v.url, key: v.key };
46
+ }
47
+ }
48
+ }
49
+ }
50
+ return { projects, uuid: row?.uuid };
51
+ }
52
+ /** Writes `JOBS_PROJECTS` (or removes the row when it is empty) and restarts Boule. The error is fixed text. */
53
+ async function writeProjects(deps, appId, projects, uuid, at, ms, failure) {
54
+ try {
55
+ if (Object.keys(projects).length > 0) {
56
+ await timed(() => deps.coolify.bulkUpsertEnvs(appId, [
57
+ { key: JOBS_PROJECTS_ENV, value: JSON.stringify(projects), literal: true },
58
+ ]), ms);
59
+ }
60
+ else if (uuid) {
61
+ await timed(() => deps.coolify.deleteEnv(appId, uuid), ms);
62
+ }
63
+ await timed(() => deps.coolify.restart(appId), ms);
64
+ }
65
+ catch {
66
+ // The error is dropped in case Coolify echoes the request body, which holds keys.
67
+ throw new Error(`${at}: ${failure}`);
68
+ }
69
+ }
70
+ /**
71
+ * Mints (or reuses the held) key for one entry's project and puts it in Boule's `JOBS_PROJECTS` at `url`.
72
+ * See `entry-jobs-boule-key.ts` for the order. Errors name the project, the key id and the ref, never the key.
73
+ */
74
+ export async function ensureJobsBouleKey(deps, url) {
75
+ const ms = deps.timeoutMs ?? JOBS_BOULE_KEY_CALL_TIMEOUT_MS;
76
+ const at = `jobs:boule-key for ${deps.org.slug} entry '${deps.entryKey}'`;
77
+ const ref = jobsBouleKeyRef(deps.org.id, deps.entryKey);
78
+ const undeliveredKey = jobsBouleKeyUndeliveredScopeKey(deps.entryKey);
79
+ const guardKey = jobsBouleKeyScopeKey(deps.entryKey);
80
+ if (!deps.bouleAppId)
81
+ throw new Error(`${at}: Boule is not recorded yet`);
82
+ const recorded = deps.recorded();
83
+ const undelivered = recorded[undeliveredKey];
84
+ let guard = recorded[guardKey];
85
+ if (undelivered && undelivered !== guard) {
86
+ throw new Error(`${at}: key ${undelivered} was minted but never held, and the scheduler shows a key once; revoke it (DELETE /admin/projects/${deps.project}/keys/${undelivered}) and remove the recorded key ${undeliveredKey}`);
87
+ }
88
+ let key = null;
89
+ if (guard) {
90
+ try {
91
+ key = (await timed(() => deps.heldKeys.resolve(ref), ms)) || null;
92
+ }
93
+ catch {
94
+ throw new Error(`${at}: the held key ${ref} could not be read`);
95
+ }
96
+ if (key) {
97
+ deps.secret(key);
98
+ }
99
+ else {
100
+ // The held value is gone: the key Boule had is revoked before a new one is minted.
101
+ try {
102
+ await timed(() => deps.scheduler.revokeProjectKey(deps.project, guard), ms);
103
+ }
104
+ catch (error) {
105
+ if (!isNotFound(error)) {
106
+ throw new Error(`${at}: the held key ${ref} is missing and key ${guard} could not be revoked before a new one is minted; the next apply tries again`);
107
+ }
108
+ }
109
+ deps.forget(guardKey);
110
+ deps.forget(undeliveredKey);
111
+ await deps.persist();
112
+ guard = undefined;
113
+ }
114
+ }
115
+ if (!key) {
116
+ let minted;
117
+ try {
118
+ minted = await timed(() => deps.scheduler.mintProjectKey(deps.project, {
119
+ principal: JOBS_BOULE_KEY_GRANT.principal,
120
+ scopes: [...JOBS_BOULE_KEY_GRANT.scopes],
121
+ }), ms);
122
+ }
123
+ catch (error) {
124
+ const status = error?.status;
125
+ throw new Error(`${at}: the scheduler refused or did not answer the key for project ${deps.project}${typeof status === 'number' ? ` (${status})` : ''}`);
126
+ }
127
+ if (typeof minted?.key === 'string' && minted.key)
128
+ deps.secret(minted.key);
129
+ if (typeof minted?.id !== 'string' || !minted.id) {
130
+ throw new Error(`${at}: the scheduler answered the key for project ${deps.project} with no id`);
131
+ }
132
+ deps.record(undeliveredKey, minted.id);
133
+ await deps.persist();
134
+ if (typeof minted.key !== 'string' || !minted.key) {
135
+ throw new Error(`${at}: key ${minted.id} was minted but the scheduler returned no key, which it shows once; revoke it (DELETE /admin/projects/${deps.project}/keys/${minted.id}) and remove the recorded key ${undeliveredKey}`);
136
+ }
137
+ let stored = false;
138
+ for (let attempt = 1; attempt <= JOBS_BOULE_KEY_HOLD_ATTEMPTS && !stored; attempt++) {
139
+ try {
140
+ await timed(() => deps.heldKeys.put(ref, minted.key), ms);
141
+ stored = true;
142
+ }
143
+ catch {
144
+ // Retried; the error is dropped in case it echoes the key.
145
+ }
146
+ }
147
+ if (!stored) {
148
+ throw new Error(`${at}: key ${minted.id} was minted but could not be held at ${ref} after ${JOBS_BOULE_KEY_HOLD_ATTEMPTS} attempts; revoke it (DELETE /admin/projects/${deps.project}/keys/${minted.id}) and remove the recorded key ${undeliveredKey}`);
149
+ }
150
+ deps.record(guardKey, minted.id);
151
+ await deps.persist();
152
+ key = minted.key;
153
+ }
154
+ const { projects, uuid } = await readProjects(deps, deps.bouleAppId, at, ms);
155
+ projects[deps.entryKey] = { url, key };
156
+ await writeProjects(deps, deps.bouleAppId, projects, uuid, at, ms, `the key is held at ${ref} but could not be written to Boule's env; the next apply writes it again`);
157
+ deps.forget(undeliveredKey);
158
+ deps.record(jobsBouleKeyAppliedScopeKey(deps.entryKey), url);
159
+ }
160
+ /**
161
+ * Revokes Boule's key for an entry that no longer uses jobs (a held one, or one minted and never held), takes
162
+ * the entry out of `JOBS_PROJECTS` and restarts Boule, then clears the records. A key the scheduler no longer
163
+ * knows counts as revoked.
164
+ */
165
+ export async function retireJobsBouleKey(deps) {
166
+ const ms = deps.timeoutMs ?? JOBS_BOULE_KEY_CALL_TIMEOUT_MS;
167
+ const at = `jobs:boule-key for ${deps.org.slug} entry '${deps.entryKey}'`;
168
+ const undeliveredKey = jobsBouleKeyUndeliveredScopeKey(deps.entryKey);
169
+ const guardKey = jobsBouleKeyScopeKey(deps.entryKey);
170
+ const recorded = deps.recorded();
171
+ const ids = [...new Set([recorded[guardKey], recorded[undeliveredKey]].filter(Boolean))];
172
+ for (const id of ids) {
173
+ try {
174
+ await timed(() => deps.scheduler.revokeProjectKey(deps.project, id), ms);
175
+ }
176
+ catch (error) {
177
+ if (!isNotFound(error)) {
178
+ throw new Error(`${at}: key ${id} of project ${deps.project} could not be revoked; the next apply tries again`);
179
+ }
180
+ }
181
+ }
182
+ if (deps.bouleAppId) {
183
+ const { projects, uuid } = await readProjects(deps, deps.bouleAppId, at, ms);
184
+ if (deps.entryKey in projects) {
185
+ delete projects[deps.entryKey];
186
+ await writeProjects(deps, deps.bouleAppId, projects, uuid, at, ms, "the key is revoked but could not be taken out of Boule's env; the next apply tries again");
187
+ }
188
+ }
189
+ deps.forget(guardKey);
190
+ deps.forget(undeliveredKey);
191
+ deps.forget(jobsBouleKeyAppliedScopeKey(deps.entryKey));
192
+ }
@@ -0,0 +1,59 @@
1
+ import type { DeclaredOrg } from '../org.js';
2
+ import type { DraftStep } from './diff.js';
3
+ import type { OrgPlatformReadiness } from './platform-org.js';
4
+ /**
5
+ * foundry#1119: Boule's key for one `uses.jobs` entry's project. Boule's Jobs page (boule#651) reads and
6
+ * presses one app's jobs through the scheduler's `/project/*` routes (jobs#79) with a key scoped to that
7
+ * project. One `jobs:boule-key` step per entry, keyed `<orgId>:<entry>`, after the entry's `jobs:project`:
8
+ *
9
+ * 1. mints `{ principal: 'boule', scopes: ['jobs:read', 'jobs:control'] }` through
10
+ * `POST /admin/projects/<slug>-<entry>/keys`, which shows the key once;
11
+ * 2. records its id as `jobs_boule_key_undelivered.<entry>` and persists it before anything else;
12
+ * 3. holds the key at `jobs:project:<orgId>:<entry>:bouleKey` (3 tries), then records the id as
13
+ * `jobs_boule_key.<entry>` (the held guard) and persists it;
14
+ * 4. sets the entry in Boule's `JOBS_PROJECTS` (JSON: entry key to `{ url, key }`, `url` the scheduler's
15
+ * public origin) and restarts Boule;
16
+ * 5. only then clears the undelivered marker and records the address as `jobs_boule_key_applied.<entry>`.
17
+ *
18
+ * A marker with the held guard equal to it is a delivery that did not finish: the step is planned again and
19
+ * reuses the held key. A marker without it (minted, never held) is blocked and never minted again. A held guard
20
+ * whose held value is gone is repaired by the step itself: the old key is revoked first, then a new one is
21
+ * minted the same way, so at most one key of Boule's is live per project.
22
+ *
23
+ * Removing `uses.jobs` (or the entry) plans the retire form: revoke the key (`DELETE …/keys/<id>`), a delivered
24
+ * one or one minted and never held, take the entry out of `JOBS_PROJECTS`, restart Boule, clear the records.
25
+ * The held value stays (the held store has no delete); it is never read again without a recorded guard.
26
+ *
27
+ * The key reaches the held keys and Boule's env and nothing else: not a plan, a log, an error or a recorded id.
28
+ */
29
+ /** Boule's key is minted, not held yet: its id, never the key. */
30
+ export declare const jobsBouleKeyUndeliveredScopeKey: (entryKey: string) => string;
31
+ /** The id of Boule's key once it is held. */
32
+ export declare const jobsBouleKeyScopeKey: (entryKey: string) => string;
33
+ /** The scheduler address Boule's env was last written with for the entry; a change writes again. */
34
+ export declare const jobsBouleKeyAppliedScopeKey: (entryKey: string) => string;
35
+ /** The held-key ref of Boule's key for an entry's project. */
36
+ export declare const jobsBouleKeyRef: (orgId: string, entryKey: string) => string;
37
+ /** Boule's env name: JSON, entry key to `{ url, key }`. */
38
+ export declare const JOBS_PROJECTS_ENV = "JOBS_PROJECTS";
39
+ /** What Boule's key is minted as. */
40
+ export declare const JOBS_BOULE_KEY_GRANT: {
41
+ readonly principal: "boule";
42
+ readonly scopes: readonly ["jobs:read", "jobs:control"];
43
+ };
44
+ type Blocked = Array<{
45
+ description: string;
46
+ key: string;
47
+ }>;
48
+ /**
49
+ * Mint or deliver one step per `uses.jobs` entry whose key is not in Boule's env at the scheduler's current
50
+ * address; retire one per entry that no longer declares it and still has a key recorded. Blocked, not planned
51
+ * (rule 2), while the host's scheduler port has no key calls (`projectKeys`), the held keys cannot hold the
52
+ * key, the scheduler's public address is not recorded, Boule is not recorded or planned, or the entry's project
53
+ * is not registered or planned.
54
+ */
55
+ export declare function planEntryJobsBouleKeys(org: DeclaredOrg, recordedIds: Readonly<Record<string, string>>, plannedSteps: readonly DraftStep[], readiness: OrgPlatformReadiness, projectKeys: boolean): {
56
+ steps: DraftStep[];
57
+ blocked: Blocked;
58
+ };
59
+ export {};
@@ -0,0 +1,141 @@
1
+ import { entryJobsAppliedScopeKey, entryJobsProjectName } from './entry-jobs.js';
2
+ import { appStepKey } from './platform-diff.js';
3
+ /**
4
+ * foundry#1119: Boule's key for one `uses.jobs` entry's project. Boule's Jobs page (boule#651) reads and
5
+ * presses one app's jobs through the scheduler's `/project/*` routes (jobs#79) with a key scoped to that
6
+ * project. One `jobs:boule-key` step per entry, keyed `<orgId>:<entry>`, after the entry's `jobs:project`:
7
+ *
8
+ * 1. mints `{ principal: 'boule', scopes: ['jobs:read', 'jobs:control'] }` through
9
+ * `POST /admin/projects/<slug>-<entry>/keys`, which shows the key once;
10
+ * 2. records its id as `jobs_boule_key_undelivered.<entry>` and persists it before anything else;
11
+ * 3. holds the key at `jobs:project:<orgId>:<entry>:bouleKey` (3 tries), then records the id as
12
+ * `jobs_boule_key.<entry>` (the held guard) and persists it;
13
+ * 4. sets the entry in Boule's `JOBS_PROJECTS` (JSON: entry key to `{ url, key }`, `url` the scheduler's
14
+ * public origin) and restarts Boule;
15
+ * 5. only then clears the undelivered marker and records the address as `jobs_boule_key_applied.<entry>`.
16
+ *
17
+ * A marker with the held guard equal to it is a delivery that did not finish: the step is planned again and
18
+ * reuses the held key. A marker without it (minted, never held) is blocked and never minted again. A held guard
19
+ * whose held value is gone is repaired by the step itself: the old key is revoked first, then a new one is
20
+ * minted the same way, so at most one key of Boule's is live per project.
21
+ *
22
+ * Removing `uses.jobs` (or the entry) plans the retire form: revoke the key (`DELETE …/keys/<id>`), a delivered
23
+ * one or one minted and never held, take the entry out of `JOBS_PROJECTS`, restart Boule, clear the records.
24
+ * The held value stays (the held store has no delete); it is never read again without a recorded guard.
25
+ *
26
+ * The key reaches the held keys and Boule's env and nothing else: not a plan, a log, an error or a recorded id.
27
+ */
28
+ /** Boule's key is minted, not held yet: its id, never the key. */
29
+ export const jobsBouleKeyUndeliveredScopeKey = (entryKey) => `jobs_boule_key_undelivered.${entryKey}`;
30
+ /** The id of Boule's key once it is held. */
31
+ export const jobsBouleKeyScopeKey = (entryKey) => `jobs_boule_key.${entryKey}`;
32
+ /** The scheduler address Boule's env was last written with for the entry; a change writes again. */
33
+ export const jobsBouleKeyAppliedScopeKey = (entryKey) => `jobs_boule_key_applied.${entryKey}`;
34
+ /** The held-key ref of Boule's key for an entry's project. */
35
+ export const jobsBouleKeyRef = (orgId, entryKey) => `jobs:project:${orgId}:${entryKey}:bouleKey`;
36
+ /** Boule's env name: JSON, entry key to `{ url, key }`. */
37
+ export const JOBS_PROJECTS_ENV = 'JOBS_PROJECTS';
38
+ /** What Boule's key is minted as. */
39
+ export const JOBS_BOULE_KEY_GRANT = {
40
+ principal: 'boule',
41
+ scopes: ['jobs:read', 'jobs:control'],
42
+ };
43
+ const usesJobs = (entry) => entry.uses?.jobs === true;
44
+ /**
45
+ * Mint or deliver one step per `uses.jobs` entry whose key is not in Boule's env at the scheduler's current
46
+ * address; retire one per entry that no longer declares it and still has a key recorded. Blocked, not planned
47
+ * (rule 2), while the host's scheduler port has no key calls (`projectKeys`), the held keys cannot hold the
48
+ * key, the scheduler's public address is not recorded, Boule is not recorded or planned, or the entry's project
49
+ * is not registered or planned.
50
+ */
51
+ export function planEntryJobsBouleKeys(org, recordedIds, plannedSteps, readiness, projectKeys) {
52
+ const steps = [];
53
+ const blocked = [];
54
+ const planned = (system, kind, key) => plannedSteps.some((p) => p.action.system === system && p.action.kind === kind && p.action.key === key);
55
+ const wanted = new Set(org.hosting.enabled ? org.hosting.entries.filter(usesJobs).map((e) => e.key) : []);
56
+ // Keys of entries that dropped uses.jobs or are gone: a held one, or one minted and never held.
57
+ const retiring = new Set();
58
+ for (const prefix of [jobsBouleKeyScopeKey(''), jobsBouleKeyUndeliveredScopeKey('')]) {
59
+ for (const [name, id] of Object.entries(recordedIds)) {
60
+ if (!id || !name.startsWith(prefix))
61
+ continue;
62
+ const entryKey = name.slice(prefix.length);
63
+ if (!entryKey || wanted.has(entryKey) || retiring.has(entryKey))
64
+ continue;
65
+ retiring.add(entryKey);
66
+ const key = `jobs:boule-key:${org.id}:${entryKey}`;
67
+ if (!projectKeys) {
68
+ blocked.push({
69
+ description: `${org.slug}'s ${entryKey} Boule jobs key is not revoked, not planned: the host's jobs scheduler port cannot revoke project keys`,
70
+ key,
71
+ });
72
+ continue;
73
+ }
74
+ steps.push({
75
+ action: {
76
+ system: 'jobs',
77
+ kind: 'boule-key',
78
+ key: `${org.id}:${entryKey}`,
79
+ desired: { mode: 'retire', name: entryJobsProjectName(org, entryKey) },
80
+ },
81
+ step: {
82
+ description: `revoke Boule's key for ${org.slug}'s ${entryKey} jobs and take it out of Boule's JOBS_PROJECTS, restart Boule: the entry no longer uses jobs`,
83
+ destroys: true,
84
+ migrates: false,
85
+ },
86
+ });
87
+ }
88
+ }
89
+ for (const entryKey of wanted) {
90
+ const stepKey = appStepKey(org, entryKey);
91
+ const key = `jobs:boule-key:${stepKey}`;
92
+ const at = `${org.slug}'s ${entryKey} Boule jobs key`;
93
+ const block = (why) => blocked.push({ description: `${at} is blocked: ${why}`, key });
94
+ const name = entryJobsProjectName(org, entryKey);
95
+ const undelivered = recordedIds[jobsBouleKeyUndeliveredScopeKey(entryKey)];
96
+ const held = recordedIds[jobsBouleKeyScopeKey(entryKey)];
97
+ if (undelivered && undelivered !== held) {
98
+ block(`key ${undelivered} was minted but never held, and the scheduler shows a key once; revoke it (DELETE /admin/projects/${name}/keys/${undelivered}) and remove the recorded key ${jobsBouleKeyUndeliveredScopeKey(entryKey)}`);
99
+ continue;
100
+ }
101
+ const url = readiness.jobsPublicUrl;
102
+ if (!undelivered && held && url && recordedIds[jobsBouleKeyAppliedScopeKey(entryKey)] === url)
103
+ continue;
104
+ if (!projectKeys) {
105
+ block("the host's jobs scheduler port cannot mint project keys");
106
+ continue;
107
+ }
108
+ if (readiness.canHoldSecrets !== true) {
109
+ block("the host's held keys cannot hold Boule's key");
110
+ continue;
111
+ }
112
+ if (!url) {
113
+ block("the jobs scheduler's public address is not recorded yet");
114
+ continue;
115
+ }
116
+ if (!recordedIds[`coolify:boule:${org.id}`] && !planned('coolify', 'boule', org.id)) {
117
+ block("the company's Boule is not recorded yet");
118
+ continue;
119
+ }
120
+ if (!recordedIds[entryJobsAppliedScopeKey(entryKey)] && !planned('jobs', 'project', stepKey)) {
121
+ block("the entry's jobs project is not registered yet");
122
+ continue;
123
+ }
124
+ steps.push({
125
+ action: {
126
+ system: 'jobs',
127
+ kind: 'boule-key',
128
+ key: stepKey,
129
+ desired: { mode: 'ensure', name, url },
130
+ },
131
+ step: {
132
+ description: held
133
+ ? `write Boule's key for ${org.slug}'s ${entryKey} jobs to Boule's JOBS_PROJECTS, restart Boule`
134
+ : `mint Boule a key for jobs project ${name}, hold it, write it to Boule's JOBS_PROJECTS, restart Boule`,
135
+ destroys: false,
136
+ migrates: false,
137
+ },
138
+ });
139
+ }
140
+ return { steps, blocked };
141
+ }
@@ -17,6 +17,8 @@ import type { OrgPlatformReadiness } from './platform-org.js';
17
17
  * error or a recorded id. A workspace (stage) gets no project: its copy of the app is built without
18
18
  * `JOBS_*` (`stage-env.ts`), so a stage never schedules or receives a live job.
19
19
  *
20
+ * Boule's own key for the project (its Jobs page) is the next step, `jobs:boule-key` (`entry-jobs-boule-key.ts`).
21
+ *
20
22
  * Nothing removes the project when the entry drops `uses.jobs`: the admin API can deregister, but the
21
23
  * scheduler port has no such call yet, so the project and its held credentials stay until a person
22
24
  * deregisters it.