@wtfalch/foundry 0.78.1 → 0.79.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.
- package/dist/catalogue/copy-en.js +5 -0
- package/dist/catalogue/integrations.js +5 -0
- package/dist/mcp-resources/client.d.ts +16 -0
- package/dist/mcp-resources/client.js +69 -0
- package/dist/mcp-resources/index.d.ts +3 -0
- package/dist/mcp-resources/index.js +3 -0
- package/dist/mcp-resources/schema.d.ts +1707 -0
- package/dist/mcp-resources/schema.js +253 -0
- package/dist/mcp-resources/validation.d.ts +21 -0
- package/dist/mcp-resources/validation.js +97 -0
- package/dist/modules/ai.js +14 -3
- package/dist/modules/customer-grants.js +1 -1
- package/dist/modules/email.d.ts +2 -1
- package/dist/modules/email.js +3 -2
- package/dist/modules/files.js +4 -1
- package/dist/modules/index.js +4 -1
- package/dist/modules/jobs.js +1 -1
- package/dist/modules/mcp-resources.d.ts +2 -0
- package/dist/modules/mcp-resources.js +61 -0
- package/dist/modules/outputs.js +1 -0
- package/dist/modules/repository.d.ts +2 -0
- package/dist/modules/repository.js +3 -1
- package/dist/modules/workspaces.js +1 -1
- package/dist/org.d.ts +3 -0
- package/dist/org.js +10 -7
- package/dist/plan/ai-apps-minter.d.ts +79 -0
- package/dist/plan/ai-apps-minter.js +263 -0
- package/dist/plan/ai-boule-key.d.ts +1 -1
- package/dist/plan/ai-boule-key.js +10 -3
- package/dist/plan/broker-entries.js +4 -4
- package/dist/plan/diff.d.ts +6 -0
- package/dist/plan/diff.js +33 -13
- package/dist/plan/entry-ai-key.d.ts +2 -0
- package/dist/plan/entry-ai-key.js +4 -2
- package/dist/plan/entry-customer-grants.d.ts +1 -1
- package/dist/plan/entry-customer-grants.js +4 -2
- package/dist/plan/entry-files-key.d.ts +10 -0
- package/dist/plan/entry-files-key.js +6 -1
- package/dist/plan/entry-jobs-boule-key.js +1 -1
- package/dist/plan/entry-jobs.js +1 -1
- package/dist/plan/entry-repo-key.js +2 -2
- package/dist/plan/entry-workspaces.d.ts +5 -0
- package/dist/plan/entry-workspaces.js +3 -1
- package/dist/plan/executor.d.ts +27 -0
- package/dist/plan/executor.js +125 -4
- package/dist/plan/files-apps-minter.d.ts +131 -0
- package/dist/plan/files-apps-minter.js +336 -0
- package/dist/plan/files-boule-key.js +2 -2
- package/dist/plan/files-space-alert.js +3 -3
- package/dist/plan/jobs-apps-key.d.ts +29 -0
- package/dist/plan/jobs-apps-key.js +228 -0
- package/dist/plan/jobs-project-settings.js +2 -0
- package/dist/plan/jobs-scheduler-http.d.ts +7 -0
- package/dist/plan/jobs-scheduler-http.js +32 -1
- package/dist/plan/own-jobs-route.d.ts +1 -1
- package/dist/plan/own-jobs-route.js +1 -1
- package/dist/plan/plan-apply.d.ts +6 -0
- package/dist/plan/plan-apply.js +45 -3
- package/dist/plan/platform-diff.d.ts +1 -1
- package/dist/plan/platform-diff.js +3 -2
- package/dist/release/state.d.ts +1 -1
- package/dist/store/schema.d.ts +2 -2
- package/dist/worker-app-config.js +11 -2
- package/package.json +5 -1
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { type DeclaredOrg } from '../org.js';
|
|
2
|
+
import type { DraftStep } from './diff.js';
|
|
3
|
+
import type { OrgPlatformReadiness } from './platform-org.js';
|
|
4
|
+
/**
|
|
5
|
+
* Files for Boule's own apps (a Worker app's `files` module). Boule's apps are not hosting entries, so foundry
|
|
6
|
+
* cannot mint their keys the way it does an entry's (`entry-files-key.ts`). Instead the company's Boule gets one
|
|
7
|
+
* key that may mint and revoke keys below itself and make collections: `files:apps-minter`, keyed by the org id,
|
|
8
|
+
* planned while `org.boule.apps` is on.
|
|
9
|
+
*
|
|
10
|
+
* Why not below the org's own key, like Boule's read key (`files-boule-key.ts`): files never lets a key minted
|
|
11
|
+
* below another hold `credentials:create` (`manage-keys.ts`, NEVER_DELEGATED), so a minter has to come from
|
|
12
|
+
* the operator route, `issueOrganisationKey`, with the files organisation's recorded owner as issuer, as the
|
|
13
|
+
* org's own key does. It holds only {@link FILES_APPS_MINTER_PERMISSIONS}: what an app's key may hold, plus
|
|
14
|
+
* minting, revoking and making collections. Every key Boule mints with it is below it, so revoking it stops
|
|
15
|
+
* them all.
|
|
16
|
+
*
|
|
17
|
+
* Order (rule 1): mint; record the id as `files_apps_minter_undelivered` and persist; hold the key at
|
|
18
|
+
* `files:apps-minter:<orgId>` (3 tries); record the id as `files_apps_minter` (the held guard) and its expiry,
|
|
19
|
+
* persist; write `FILES_APPS_URL`, `FILES_APPS_ORGANISATION_ID`, `FILES_APPS_KEY_ID`, `FILES_APPS_KEY_EXPIRES` and
|
|
20
|
+
* `FILES_APPS_KEY` to
|
|
21
|
+
* Boule's env and restart Boule; only then clear the marker and record `files_apps_minter_applied`.
|
|
22
|
+
*
|
|
23
|
+
* Renewal (under 30 days left) mints a new minter and does **not** revoke the old one: revoking it would revoke
|
|
24
|
+
* every app key below it at once. The old one is listed in `files_apps_minter_previous` and expires by itself;
|
|
25
|
+
* Boule sees the new `FILES_APPS_KEY_ID` and mints each app's key again below the new minter before then.
|
|
26
|
+
*
|
|
27
|
+
* Apps off plans the retire form (`destroys: true`): revoke every minter (so every app key with it), take the
|
|
28
|
+
* names out of Boule's env, restart Boule, clear the records. The held value stays (no delete); it is never read
|
|
29
|
+
* without a guard. The key reaches the held keys and Boule's env and nothing else.
|
|
30
|
+
*/
|
|
31
|
+
/** What Boule's minter holds: an app key's permissions, and minting, revoking and making collections. */
|
|
32
|
+
export declare const FILES_APPS_MINTER_PERMISSIONS: readonly ["credentials:create", "credentials:revoke", "files.collections:create", "files.collections:read", "files.files:read", "files.files:download", "files.files:upload", "files.files:replace", "files.files:delete"];
|
|
33
|
+
export declare const FILES_APPS_MINTER_SCOPE_KEY = "files_apps_minter";
|
|
34
|
+
export declare const FILES_APPS_MINTER_UNDELIVERED_SCOPE_KEY = "files_apps_minter_undelivered";
|
|
35
|
+
export declare const FILES_APPS_MINTER_EXPIRES_SCOPE_KEY = "files_apps_minter_expires";
|
|
36
|
+
export declare const FILES_APPS_MINTER_APPLIED_SCOPE_KEY = "files_apps_minter_applied";
|
|
37
|
+
/** Minters a renewal replaced, as a JSON array; never revoked by a renewal, only by the retire form. */
|
|
38
|
+
export declare const FILES_APPS_MINTER_PREVIOUS_SCOPE_KEY = "files_apps_minter_previous";
|
|
39
|
+
/** Boule's env names. */
|
|
40
|
+
export declare const FILES_APPS_ENV: {
|
|
41
|
+
readonly url: "FILES_APPS_URL";
|
|
42
|
+
readonly organisationId: "FILES_APPS_ORGANISATION_ID";
|
|
43
|
+
readonly keyId: "FILES_APPS_KEY_ID";
|
|
44
|
+
/** When the minter expires: a key below it cannot outlive it, so Boule mints below it for at most that long. */
|
|
45
|
+
readonly keyExpires: "FILES_APPS_KEY_EXPIRES";
|
|
46
|
+
readonly key: "FILES_APPS_KEY";
|
|
47
|
+
};
|
|
48
|
+
export declare const filesAppsMinterRef: (orgId: string) => string;
|
|
49
|
+
export declare const filesAppsMinterName: (org: Pick<DeclaredOrg, "slug">) => string;
|
|
50
|
+
export interface FilesAppsMinterWant {
|
|
51
|
+
url: string;
|
|
52
|
+
organisationId: string;
|
|
53
|
+
}
|
|
54
|
+
export interface FilesAppsMinterStep extends FilesAppsMinterWant {
|
|
55
|
+
mode: 'mint' | 'renew' | 'deliver';
|
|
56
|
+
name: string;
|
|
57
|
+
expiresDays: number;
|
|
58
|
+
permissions: string[];
|
|
59
|
+
}
|
|
60
|
+
type Blocked = Array<{
|
|
61
|
+
description: string;
|
|
62
|
+
key: string;
|
|
63
|
+
}>;
|
|
64
|
+
/**
|
|
65
|
+
* Boule's apps minter step, or nothing. Blocked, not planned (rule 2), while the held keys cannot hold the key,
|
|
66
|
+
* files' address or the files organisation's owner is not recorded, Boule is not recorded or planned, or after a
|
|
67
|
+
* mint whose key was never held.
|
|
68
|
+
*/
|
|
69
|
+
export declare function planFilesAppsMinter(org: DeclaredOrg, recordedIds: Readonly<Record<string, string>>, plannedSteps: readonly DraftStep[], readiness: OrgPlatformReadiness, now: number): {
|
|
70
|
+
steps: DraftStep[];
|
|
71
|
+
blocked: Blocked;
|
|
72
|
+
};
|
|
73
|
+
/** Every call is cut off after this long. */
|
|
74
|
+
export declare const FILES_APPS_MINTER_TIMEOUT_MS = 30000;
|
|
75
|
+
export declare const FILES_APPS_MINTER_HOLD_ATTEMPTS = 3;
|
|
76
|
+
export interface FilesAppsMinterDeps {
|
|
77
|
+
files: {
|
|
78
|
+
issueOrganisationKey(organisationId: string, input: {
|
|
79
|
+
name: string;
|
|
80
|
+
kind: 'api_key' | 'agent' | 'service';
|
|
81
|
+
issuer: string;
|
|
82
|
+
permissions: string[];
|
|
83
|
+
expiresDays: number;
|
|
84
|
+
}): Promise<{
|
|
85
|
+
id: string;
|
|
86
|
+
secret: string;
|
|
87
|
+
expiresAt: string;
|
|
88
|
+
}>;
|
|
89
|
+
revokeOrganisationKey(organisationId: string, keyId: string): Promise<unknown>;
|
|
90
|
+
};
|
|
91
|
+
heldKeys: {
|
|
92
|
+
resolve(ref: string): Promise<string | null | undefined>;
|
|
93
|
+
put(ref: string, value: string): Promise<void>;
|
|
94
|
+
};
|
|
95
|
+
coolify: {
|
|
96
|
+
bulkUpsertEnvs(appId: string, envs: Array<{
|
|
97
|
+
key: string;
|
|
98
|
+
value: string;
|
|
99
|
+
literal?: boolean;
|
|
100
|
+
}>): Promise<unknown>;
|
|
101
|
+
listEnvs(appId: string): Promise<Array<{
|
|
102
|
+
uuid?: string;
|
|
103
|
+
key: string;
|
|
104
|
+
value: string;
|
|
105
|
+
is_preview: boolean;
|
|
106
|
+
}>>;
|
|
107
|
+
deleteEnv(appId: string, envUuid: string): Promise<unknown>;
|
|
108
|
+
restart(appId: string): Promise<unknown>;
|
|
109
|
+
};
|
|
110
|
+
org: {
|
|
111
|
+
id: string;
|
|
112
|
+
slug: string;
|
|
113
|
+
};
|
|
114
|
+
bouleAppId: string | undefined;
|
|
115
|
+
recorded: () => Readonly<Record<string, string>>;
|
|
116
|
+
record: (key: string, value: string) => void;
|
|
117
|
+
forget: (key: string) => void;
|
|
118
|
+
persist: () => Promise<void>;
|
|
119
|
+
/** Registers a value for redaction before anything else can see it. */
|
|
120
|
+
secret: (value: string) => void;
|
|
121
|
+
timeoutMs?: number;
|
|
122
|
+
}
|
|
123
|
+
/** Mints (or reuses the held) minter and writes it to Boule's env. Errors name the key id and the ref, never the key. */
|
|
124
|
+
export declare function ensureFilesAppsMinter(deps: FilesAppsMinterDeps, step: FilesAppsMinterStep): Promise<void>;
|
|
125
|
+
/**
|
|
126
|
+
* Apps are off: revokes every minter (held, minted and never held, and those renewals kept), which revokes every
|
|
127
|
+
* app key below them, takes the names out of Boule's env, restarts Boule and clears the records. A key files no
|
|
128
|
+
* longer knows counts as revoked.
|
|
129
|
+
*/
|
|
130
|
+
export declare function retireFilesAppsMinter(deps: FilesAppsMinterDeps): Promise<void>;
|
|
131
|
+
export {};
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import { FILES_ENTRY_PERMISSIONS } from '../entry-rules.js';
|
|
2
|
+
import { appsOn } from '../org.js';
|
|
3
|
+
import { FILES_KEY_DAYS, FILES_KEY_RENEW_WINDOW_DAYS, FILES_ORG_OWNER_SCOPE_KEY, parseIdList, } from './entry-files-key.js';
|
|
4
|
+
/**
|
|
5
|
+
* Files for Boule's own apps (a Worker app's `files` module). Boule's apps are not hosting entries, so foundry
|
|
6
|
+
* cannot mint their keys the way it does an entry's (`entry-files-key.ts`). Instead the company's Boule gets one
|
|
7
|
+
* key that may mint and revoke keys below itself and make collections: `files:apps-minter`, keyed by the org id,
|
|
8
|
+
* planned while `org.boule.apps` is on.
|
|
9
|
+
*
|
|
10
|
+
* Why not below the org's own key, like Boule's read key (`files-boule-key.ts`): files never lets a key minted
|
|
11
|
+
* below another hold `credentials:create` (`manage-keys.ts`, NEVER_DELEGATED), so a minter has to come from
|
|
12
|
+
* the operator route, `issueOrganisationKey`, with the files organisation's recorded owner as issuer, as the
|
|
13
|
+
* org's own key does. It holds only {@link FILES_APPS_MINTER_PERMISSIONS}: what an app's key may hold, plus
|
|
14
|
+
* minting, revoking and making collections. Every key Boule mints with it is below it, so revoking it stops
|
|
15
|
+
* them all.
|
|
16
|
+
*
|
|
17
|
+
* Order (rule 1): mint; record the id as `files_apps_minter_undelivered` and persist; hold the key at
|
|
18
|
+
* `files:apps-minter:<orgId>` (3 tries); record the id as `files_apps_minter` (the held guard) and its expiry,
|
|
19
|
+
* persist; write `FILES_APPS_URL`, `FILES_APPS_ORGANISATION_ID`, `FILES_APPS_KEY_ID`, `FILES_APPS_KEY_EXPIRES` and
|
|
20
|
+
* `FILES_APPS_KEY` to
|
|
21
|
+
* Boule's env and restart Boule; only then clear the marker and record `files_apps_minter_applied`.
|
|
22
|
+
*
|
|
23
|
+
* Renewal (under 30 days left) mints a new minter and does **not** revoke the old one: revoking it would revoke
|
|
24
|
+
* every app key below it at once. The old one is listed in `files_apps_minter_previous` and expires by itself;
|
|
25
|
+
* Boule sees the new `FILES_APPS_KEY_ID` and mints each app's key again below the new minter before then.
|
|
26
|
+
*
|
|
27
|
+
* Apps off plans the retire form (`destroys: true`): revoke every minter (so every app key with it), take the
|
|
28
|
+
* names out of Boule's env, restart Boule, clear the records. The held value stays (no delete); it is never read
|
|
29
|
+
* without a guard. The key reaches the held keys and Boule's env and nothing else.
|
|
30
|
+
*/
|
|
31
|
+
/** What Boule's minter holds: an app key's permissions, and minting, revoking and making collections. */
|
|
32
|
+
export const FILES_APPS_MINTER_PERMISSIONS = [
|
|
33
|
+
'credentials:create',
|
|
34
|
+
'credentials:revoke',
|
|
35
|
+
'files.collections:create',
|
|
36
|
+
...FILES_ENTRY_PERMISSIONS,
|
|
37
|
+
];
|
|
38
|
+
export const FILES_APPS_MINTER_SCOPE_KEY = 'files_apps_minter';
|
|
39
|
+
export const FILES_APPS_MINTER_UNDELIVERED_SCOPE_KEY = 'files_apps_minter_undelivered';
|
|
40
|
+
export const FILES_APPS_MINTER_EXPIRES_SCOPE_KEY = 'files_apps_minter_expires';
|
|
41
|
+
export const FILES_APPS_MINTER_APPLIED_SCOPE_KEY = 'files_apps_minter_applied';
|
|
42
|
+
/** Minters a renewal replaced, as a JSON array; never revoked by a renewal, only by the retire form. */
|
|
43
|
+
export const FILES_APPS_MINTER_PREVIOUS_SCOPE_KEY = 'files_apps_minter_previous';
|
|
44
|
+
/** Boule's env names. */
|
|
45
|
+
export const FILES_APPS_ENV = {
|
|
46
|
+
url: 'FILES_APPS_URL',
|
|
47
|
+
organisationId: 'FILES_APPS_ORGANISATION_ID',
|
|
48
|
+
keyId: 'FILES_APPS_KEY_ID',
|
|
49
|
+
/** When the minter expires: a key below it cannot outlive it, so Boule mints below it for at most that long. */
|
|
50
|
+
keyExpires: 'FILES_APPS_KEY_EXPIRES',
|
|
51
|
+
key: 'FILES_APPS_KEY',
|
|
52
|
+
};
|
|
53
|
+
export const filesAppsMinterRef = (orgId) => `files:apps-minter:${orgId}`;
|
|
54
|
+
export const filesAppsMinterName = (org) => `${org.slug}-boule-apps-files`;
|
|
55
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
56
|
+
/**
|
|
57
|
+
* Boule's apps minter step, or nothing. Blocked, not planned (rule 2), while the held keys cannot hold the key,
|
|
58
|
+
* files' address or the files organisation's owner is not recorded, Boule is not recorded or planned, or after a
|
|
59
|
+
* mint whose key was never held.
|
|
60
|
+
*/
|
|
61
|
+
export function planFilesAppsMinter(org, recordedIds, plannedSteps, readiness, now) {
|
|
62
|
+
const steps = [];
|
|
63
|
+
const blocked = [];
|
|
64
|
+
const key = `files:apps-minter:${org.id}`;
|
|
65
|
+
const at = `${org.slug}'s Boule apps files key`;
|
|
66
|
+
const block = (why) => blocked.push({ description: `${at} is blocked: ${why}`, key });
|
|
67
|
+
const guard = recordedIds[FILES_APPS_MINTER_SCOPE_KEY];
|
|
68
|
+
const undelivered = recordedIds[FILES_APPS_MINTER_UNDELIVERED_SCOPE_KEY];
|
|
69
|
+
const previous = parseIdList(recordedIds[FILES_APPS_MINTER_PREVIOUS_SCOPE_KEY]);
|
|
70
|
+
if (!appsOn(org)) {
|
|
71
|
+
if (readiness.filesReady && (guard || undelivered || previous.length > 0)) {
|
|
72
|
+
steps.push({
|
|
73
|
+
action: {
|
|
74
|
+
system: 'files',
|
|
75
|
+
kind: 'apps-minter',
|
|
76
|
+
key: org.id,
|
|
77
|
+
desired: { mode: 'retire' },
|
|
78
|
+
},
|
|
79
|
+
step: {
|
|
80
|
+
description: `revoke Boule's apps files key for ${org.slug}, and with it every app's files key, take FILES_APPS_* out of Boule's env, restart Boule: apps are off`,
|
|
81
|
+
destroys: true,
|
|
82
|
+
migrates: false,
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
return { steps, blocked };
|
|
87
|
+
}
|
|
88
|
+
if (!readiness.filesReady)
|
|
89
|
+
return { steps, blocked };
|
|
90
|
+
if (undelivered && undelivered !== guard) {
|
|
91
|
+
block(`key ${undelivered} was minted but never held, and files shows a key once; revoke it in files and remove the recorded key ${FILES_APPS_MINTER_UNDELIVERED_SCOPE_KEY}`);
|
|
92
|
+
return { steps, blocked };
|
|
93
|
+
}
|
|
94
|
+
if (readiness.canHoldSecrets !== true) {
|
|
95
|
+
block("the host's held keys cannot hold the key");
|
|
96
|
+
return { steps, blocked };
|
|
97
|
+
}
|
|
98
|
+
if (!readiness.filesUrl) {
|
|
99
|
+
block("the platform has not recorded files' address yet");
|
|
100
|
+
return { steps, blocked };
|
|
101
|
+
}
|
|
102
|
+
if (!recordedIds[FILES_ORG_OWNER_SCOPE_KEY]) {
|
|
103
|
+
block('the files organisation has no recorded owner to mint the key for');
|
|
104
|
+
return { steps, blocked };
|
|
105
|
+
}
|
|
106
|
+
const boulePlanned = plannedSteps.some((p) => p.action.system === 'coolify' && p.action.kind === 'boule' && p.action.key === org.id);
|
|
107
|
+
if (!recordedIds[`coolify:boule:${org.id}`] && !boulePlanned) {
|
|
108
|
+
block("the company's Boule is not recorded yet");
|
|
109
|
+
return { steps, blocked };
|
|
110
|
+
}
|
|
111
|
+
const want = { url: readiness.filesUrl, organisationId: org.id };
|
|
112
|
+
let mode = null;
|
|
113
|
+
if (!guard)
|
|
114
|
+
mode = 'mint';
|
|
115
|
+
else if (!(Date.parse(recordedIds[FILES_APPS_MINTER_EXPIRES_SCOPE_KEY] ?? '') - now >=
|
|
116
|
+
FILES_KEY_RENEW_WINDOW_DAYS * DAY_MS))
|
|
117
|
+
mode = 'renew';
|
|
118
|
+
else if (undelivered === guard ||
|
|
119
|
+
recordedIds[FILES_APPS_MINTER_APPLIED_SCOPE_KEY] !== JSON.stringify(want))
|
|
120
|
+
mode = 'deliver';
|
|
121
|
+
if (!mode)
|
|
122
|
+
return { steps, blocked };
|
|
123
|
+
steps.push({
|
|
124
|
+
action: {
|
|
125
|
+
system: 'files',
|
|
126
|
+
kind: 'apps-minter',
|
|
127
|
+
key: org.id,
|
|
128
|
+
desired: {
|
|
129
|
+
mode,
|
|
130
|
+
name: filesAppsMinterName(org),
|
|
131
|
+
expiresDays: FILES_KEY_DAYS,
|
|
132
|
+
permissions: [...FILES_APPS_MINTER_PERMISSIONS],
|
|
133
|
+
...want,
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
step: {
|
|
137
|
+
description: mode === 'deliver'
|
|
138
|
+
? `write Boule's apps files key for ${org.slug} to Boule's env, restart Boule`
|
|
139
|
+
: `${mode === 'mint' ? 'mint' : 'renew'} Boule's apps files key for ${org.slug} (it mints each app's files key), hold it, write FILES_APPS_* to Boule's env, restart Boule`,
|
|
140
|
+
destroys: false,
|
|
141
|
+
migrates: false,
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
return { steps, blocked };
|
|
145
|
+
}
|
|
146
|
+
/** Every call is cut off after this long. */
|
|
147
|
+
export const FILES_APPS_MINTER_TIMEOUT_MS = 30_000;
|
|
148
|
+
export const FILES_APPS_MINTER_HOLD_ATTEMPTS = 3;
|
|
149
|
+
async function timed(work, ms) {
|
|
150
|
+
let timer;
|
|
151
|
+
try {
|
|
152
|
+
return await Promise.race([
|
|
153
|
+
work(),
|
|
154
|
+
new Promise((_, reject) => {
|
|
155
|
+
timer = setTimeout(() => reject(new Error('timeout')), ms);
|
|
156
|
+
}),
|
|
157
|
+
]);
|
|
158
|
+
}
|
|
159
|
+
finally {
|
|
160
|
+
clearTimeout(timer);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
const statusOf = (error) => {
|
|
164
|
+
const status = error?.status;
|
|
165
|
+
return typeof status === 'number' ? ` (${status})` : '';
|
|
166
|
+
};
|
|
167
|
+
const isNotFound = (error) => error?.status === 404;
|
|
168
|
+
/** Mints (or reuses the held) minter and writes it to Boule's env. Errors name the key id and the ref, never the key. */
|
|
169
|
+
export async function ensureFilesAppsMinter(deps, step) {
|
|
170
|
+
const ms = deps.timeoutMs ?? FILES_APPS_MINTER_TIMEOUT_MS;
|
|
171
|
+
const at = `files:apps-minter for ${deps.org.slug}`;
|
|
172
|
+
const ref = filesAppsMinterRef(deps.org.id);
|
|
173
|
+
if (!deps.bouleAppId)
|
|
174
|
+
throw new Error(`${at}: Boule is not recorded yet`);
|
|
175
|
+
const bouleAppId = deps.bouleAppId;
|
|
176
|
+
const recorded = deps.recorded();
|
|
177
|
+
const undelivered = recorded[FILES_APPS_MINTER_UNDELIVERED_SCOPE_KEY];
|
|
178
|
+
let guard = recorded[FILES_APPS_MINTER_SCOPE_KEY];
|
|
179
|
+
if (undelivered && undelivered !== guard) {
|
|
180
|
+
throw new Error(`${at}: key ${undelivered} was minted but never held, and files shows a key once; revoke it in files and remove the recorded key ${FILES_APPS_MINTER_UNDELIVERED_SCOPE_KEY}`);
|
|
181
|
+
}
|
|
182
|
+
let key = null;
|
|
183
|
+
if (guard && step.mode === 'renew') {
|
|
184
|
+
// The old minter is kept, not revoked: every app key below it would stop at once. It expires by itself.
|
|
185
|
+
const previous = parseIdList(recorded[FILES_APPS_MINTER_PREVIOUS_SCOPE_KEY]);
|
|
186
|
+
if (!previous.includes(guard))
|
|
187
|
+
previous.push(guard);
|
|
188
|
+
deps.record(FILES_APPS_MINTER_PREVIOUS_SCOPE_KEY, JSON.stringify(previous));
|
|
189
|
+
deps.forget(FILES_APPS_MINTER_SCOPE_KEY);
|
|
190
|
+
deps.forget(FILES_APPS_MINTER_UNDELIVERED_SCOPE_KEY);
|
|
191
|
+
await deps.persist();
|
|
192
|
+
guard = undefined;
|
|
193
|
+
}
|
|
194
|
+
else if (guard) {
|
|
195
|
+
try {
|
|
196
|
+
key = (await timed(() => deps.heldKeys.resolve(ref), ms)) || null;
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
throw new Error(`${at}: the held key ${ref} could not be read`);
|
|
200
|
+
}
|
|
201
|
+
if (key)
|
|
202
|
+
deps.secret(key);
|
|
203
|
+
else {
|
|
204
|
+
// The held value is gone: that minter is revoked (with its app keys) before a new one is minted.
|
|
205
|
+
try {
|
|
206
|
+
await timed(() => deps.files.revokeOrganisationKey(deps.org.id, guard), ms);
|
|
207
|
+
}
|
|
208
|
+
catch (error) {
|
|
209
|
+
if (!isNotFound(error)) {
|
|
210
|
+
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`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
deps.forget(FILES_APPS_MINTER_SCOPE_KEY);
|
|
214
|
+
deps.forget(FILES_APPS_MINTER_UNDELIVERED_SCOPE_KEY);
|
|
215
|
+
await deps.persist();
|
|
216
|
+
guard = undefined;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
let keyId = guard;
|
|
220
|
+
if (!key) {
|
|
221
|
+
const issuer = recorded[FILES_ORG_OWNER_SCOPE_KEY];
|
|
222
|
+
if (!issuer)
|
|
223
|
+
throw new Error(`${at}: the files organisation has no recorded owner`);
|
|
224
|
+
let minted;
|
|
225
|
+
try {
|
|
226
|
+
minted = await timed(() => deps.files.issueOrganisationKey(deps.org.id, {
|
|
227
|
+
name: step.name,
|
|
228
|
+
kind: 'service',
|
|
229
|
+
issuer,
|
|
230
|
+
permissions: [...step.permissions],
|
|
231
|
+
expiresDays: step.expiresDays,
|
|
232
|
+
}), ms);
|
|
233
|
+
}
|
|
234
|
+
catch (error) {
|
|
235
|
+
throw new Error(`${at}: files refused or did not answer the key${statusOf(error)}`);
|
|
236
|
+
}
|
|
237
|
+
if (typeof minted?.secret === 'string' && minted.secret)
|
|
238
|
+
deps.secret(minted.secret);
|
|
239
|
+
if (typeof minted?.id !== 'string' || !minted.id) {
|
|
240
|
+
throw new Error(`${at}: files answered the key with no id`);
|
|
241
|
+
}
|
|
242
|
+
deps.record(FILES_APPS_MINTER_UNDELIVERED_SCOPE_KEY, minted.id);
|
|
243
|
+
await deps.persist();
|
|
244
|
+
if (typeof minted.secret !== 'string' || !minted.secret) {
|
|
245
|
+
throw new Error(`${at}: key ${minted.id} was minted but files returned no key, which it shows once; revoke it in files and remove the recorded key ${FILES_APPS_MINTER_UNDELIVERED_SCOPE_KEY}`);
|
|
246
|
+
}
|
|
247
|
+
let stored = false;
|
|
248
|
+
for (let attempt = 1; attempt <= FILES_APPS_MINTER_HOLD_ATTEMPTS && !stored; attempt++) {
|
|
249
|
+
try {
|
|
250
|
+
await timed(() => deps.heldKeys.put(ref, minted.secret), ms);
|
|
251
|
+
stored = true;
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
// Retried; the error is dropped in case it echoes the key.
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
if (!stored) {
|
|
258
|
+
throw new Error(`${at}: key ${minted.id} was minted but could not be held at ${ref} after ${FILES_APPS_MINTER_HOLD_ATTEMPTS} attempts; revoke it in files and remove the recorded key ${FILES_APPS_MINTER_UNDELIVERED_SCOPE_KEY}`);
|
|
259
|
+
}
|
|
260
|
+
deps.record(FILES_APPS_MINTER_SCOPE_KEY, minted.id);
|
|
261
|
+
deps.record(FILES_APPS_MINTER_EXPIRES_SCOPE_KEY, minted.expiresAt);
|
|
262
|
+
await deps.persist();
|
|
263
|
+
key = minted.secret;
|
|
264
|
+
keyId = minted.id;
|
|
265
|
+
}
|
|
266
|
+
try {
|
|
267
|
+
await timed(() => deps.coolify.bulkUpsertEnvs(bouleAppId, [
|
|
268
|
+
{ key: FILES_APPS_ENV.url, value: step.url, literal: true },
|
|
269
|
+
{ key: FILES_APPS_ENV.organisationId, value: step.organisationId, literal: true },
|
|
270
|
+
{ key: FILES_APPS_ENV.keyId, value: keyId, literal: true },
|
|
271
|
+
{
|
|
272
|
+
key: FILES_APPS_ENV.keyExpires,
|
|
273
|
+
value: deps.recorded()[FILES_APPS_MINTER_EXPIRES_SCOPE_KEY] ?? '',
|
|
274
|
+
literal: true,
|
|
275
|
+
},
|
|
276
|
+
{ key: FILES_APPS_ENV.key, value: key, literal: true },
|
|
277
|
+
]), ms);
|
|
278
|
+
await timed(() => deps.coolify.restart(bouleAppId), ms);
|
|
279
|
+
}
|
|
280
|
+
catch {
|
|
281
|
+
// The error is dropped in case Coolify echoes the request body, which holds the key.
|
|
282
|
+
throw new Error(`${at}: the key is held at ${ref} but could not be written to Boule's env; the next apply writes it again`);
|
|
283
|
+
}
|
|
284
|
+
deps.forget(FILES_APPS_MINTER_UNDELIVERED_SCOPE_KEY);
|
|
285
|
+
deps.record(FILES_APPS_MINTER_APPLIED_SCOPE_KEY, JSON.stringify({ url: step.url, organisationId: step.organisationId }));
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Apps are off: revokes every minter (held, minted and never held, and those renewals kept), which revokes every
|
|
289
|
+
* app key below them, takes the names out of Boule's env, restarts Boule and clears the records. A key files no
|
|
290
|
+
* longer knows counts as revoked.
|
|
291
|
+
*/
|
|
292
|
+
export async function retireFilesAppsMinter(deps) {
|
|
293
|
+
const ms = deps.timeoutMs ?? FILES_APPS_MINTER_TIMEOUT_MS;
|
|
294
|
+
const at = `files:apps-minter for ${deps.org.slug}`;
|
|
295
|
+
const recorded = deps.recorded();
|
|
296
|
+
const ids = [
|
|
297
|
+
...new Set([
|
|
298
|
+
recorded[FILES_APPS_MINTER_SCOPE_KEY],
|
|
299
|
+
recorded[FILES_APPS_MINTER_UNDELIVERED_SCOPE_KEY],
|
|
300
|
+
...parseIdList(recorded[FILES_APPS_MINTER_PREVIOUS_SCOPE_KEY]),
|
|
301
|
+
].filter((id) => Boolean(id))),
|
|
302
|
+
];
|
|
303
|
+
for (const id of ids) {
|
|
304
|
+
try {
|
|
305
|
+
await timed(() => deps.files.revokeOrganisationKey(deps.org.id, id), ms);
|
|
306
|
+
}
|
|
307
|
+
catch (error) {
|
|
308
|
+
if (!isNotFound(error)) {
|
|
309
|
+
throw new Error(`${at}: key ${id} could not be revoked; the next apply tries again`);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
if (deps.bouleAppId) {
|
|
314
|
+
const appId = deps.bouleAppId;
|
|
315
|
+
const names = new Set(Object.values(FILES_APPS_ENV));
|
|
316
|
+
try {
|
|
317
|
+
const rows = await timed(() => deps.coolify.listEnvs(appId), ms);
|
|
318
|
+
const gone = rows.filter((e) => !e.is_preview && e.uuid && names.has(e.key));
|
|
319
|
+
for (const row of gone)
|
|
320
|
+
await timed(() => deps.coolify.deleteEnv(appId, row.uuid), ms);
|
|
321
|
+
if (gone.length > 0)
|
|
322
|
+
await timed(() => deps.coolify.restart(appId), ms);
|
|
323
|
+
}
|
|
324
|
+
catch {
|
|
325
|
+
throw new Error(`${at}: the key is revoked but could not be taken out of Boule's env; the next apply tries again`);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
for (const name of [
|
|
329
|
+
FILES_APPS_MINTER_SCOPE_KEY,
|
|
330
|
+
FILES_APPS_MINTER_UNDELIVERED_SCOPE_KEY,
|
|
331
|
+
FILES_APPS_MINTER_EXPIRES_SCOPE_KEY,
|
|
332
|
+
FILES_APPS_MINTER_APPLIED_SCOPE_KEY,
|
|
333
|
+
FILES_APPS_MINTER_PREVIOUS_SCOPE_KEY,
|
|
334
|
+
])
|
|
335
|
+
deps.forget(name);
|
|
336
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { FILES_BOULE_KEY_SCOPE_KEY, FILES_BOULE_MINTER_SCOPE_KEY, FILES_BOULE_PREVIOUS_SCOPE_KEY, FILES_BOULE_UNDELIVERED_SCOPE_KEY, FILES_KEY_DAYS, FILES_KEY_RENEW_WINDOW_DAYS, entryCollectionNames, parseIdList, } from './entry-files-key.js';
|
|
1
|
+
import { FILES_BOULE_KEY_SCOPE_KEY, FILES_BOULE_MINTER_SCOPE_KEY, FILES_BOULE_PREVIOUS_SCOPE_KEY, FILES_BOULE_UNDELIVERED_SCOPE_KEY, FILES_KEY_DAYS, FILES_KEY_RENEW_WINDOW_DAYS, entryCollectionNames, parseIdList, usesServerFiles, } from './entry-files-key.js';
|
|
2
2
|
export { entryCollectionName, entryCollectionNames, entryFilesCollectionsScopeKey, FILES_BOULE_KEY_SCOPE_KEY, FILES_BOULE_MINTER_SCOPE_KEY, FILES_BOULE_PREVIOUS_SCOPE_KEY, FILES_BOULE_UNDELIVERED_SCOPE_KEY, knownCollectionNames, } from './entry-files-key.js';
|
|
3
3
|
/**
|
|
4
4
|
* foundry#1121: Boule's files key, and each app's collections named apart.
|
|
@@ -81,7 +81,7 @@ export function planFilesBouleKey(org, recordedIds, plannedSteps, readiness, now
|
|
|
81
81
|
key: `${key}:unrevoked:${id}`,
|
|
82
82
|
});
|
|
83
83
|
}
|
|
84
|
-
const entries = org.hosting.enabled ? org.hosting.entries.filter(
|
|
84
|
+
const entries = org.hosting.enabled ? org.hosting.entries.filter(usesServerFiles) : [];
|
|
85
85
|
const undelivered = recordedIds[FILES_BOULE_UNDELIVERED_SCOPE_KEY];
|
|
86
86
|
const guard = recordedIds[FILES_BOULE_KEY_SCOPE_KEY];
|
|
87
87
|
const previous = parseIdList(recordedIds[FILES_BOULE_PREVIOUS_SCOPE_KEY]);
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { isLiveEntry } from '../org.js';
|
|
3
3
|
import { withTimeout } from './call-timeout.js';
|
|
4
|
-
import { entryCollectionNames } from './entry-files-key.js';
|
|
4
|
+
import { entryCollectionNames, usesServerFiles } from './entry-files-key.js';
|
|
5
5
|
const PREFIX = 'files_space_alert_';
|
|
6
6
|
export const filesSpaceAlertScopeKey = (key) => `${PREFIX}${key}`;
|
|
7
7
|
/** Addresses are resolved only during apply. Plans and recorded ids hold their hash. */
|
|
8
8
|
export function filesSpaceAlertInput(org, entryKey, ids) {
|
|
9
9
|
const entry = org.hosting.enabled
|
|
10
|
-
? org.hosting.entries.find((e) => e.key === entryKey && isLiveEntry(e) && e
|
|
10
|
+
? org.hosting.entries.find((e) => e.key === entryKey && isLiveEntry(e) && usesServerFiles(e))
|
|
11
11
|
: undefined;
|
|
12
12
|
if (!entry?.uses?.files)
|
|
13
13
|
return null;
|
|
@@ -25,7 +25,7 @@ export function planFilesSpaceAlerts(org, ids, ready, capable) {
|
|
|
25
25
|
const steps = [];
|
|
26
26
|
const blocked = [];
|
|
27
27
|
const keys = new Set([
|
|
28
|
-
...org.hosting.entries.filter((e) => isLiveEntry(e) && e
|
|
28
|
+
...org.hosting.entries.filter((e) => isLiveEntry(e) && usesServerFiles(e)).map((e) => e.key),
|
|
29
29
|
...Object.keys(ids)
|
|
30
30
|
.filter((key) => key.startsWith(PREFIX))
|
|
31
31
|
.map((key) => key.slice(PREFIX.length)),
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { type DeclaredOrg } from '../org.js';
|
|
2
|
+
import type { DraftStep } from './diff.js';
|
|
3
|
+
import type { JobsBouleKeyDeps } from './entry-jobs-boule-key-apply.js';
|
|
4
|
+
import type { JobsSchedulerPort } from './executor.js';
|
|
5
|
+
import type { OrgPlatformReadiness } from './platform-org.js';
|
|
6
|
+
export declare const JOBS_APPS_PROJECT = "jobs_apps_project";
|
|
7
|
+
export declare const JOBS_APPS_KEY = "jobs_apps_key";
|
|
8
|
+
export declare const JOBS_APPS_UNDELIVERED = "jobs_apps_key_undelivered";
|
|
9
|
+
export declare const JOBS_APPS_APPLIED = "jobs_apps_key_applied";
|
|
10
|
+
export declare const JOBS_APPS_EXPIRES = "jobs_apps_key_expires";
|
|
11
|
+
export declare const jobsAppsRef: (org: string) => string;
|
|
12
|
+
export declare const jobsAppsProject: (org: string) => string;
|
|
13
|
+
export declare function planJobsAppsKey(org: DeclaredOrg, ids: Readonly<Record<string, string>>, steps: readonly DraftStep[], ready: OrgPlatformReadiness, usable: boolean, capable: boolean, now: number): {
|
|
14
|
+
steps: DraftStep[];
|
|
15
|
+
blocked: {
|
|
16
|
+
key: string;
|
|
17
|
+
description: string;
|
|
18
|
+
}[];
|
|
19
|
+
};
|
|
20
|
+
type Deps = Omit<JobsBouleKeyDeps, 'entryKey' | 'project' | 'scheduler'> & {
|
|
21
|
+
scheduler: JobsSchedulerPort;
|
|
22
|
+
};
|
|
23
|
+
/** Every scheduler/held-key/Coolify call is bounded, and no provider error text can carry a secret out. */
|
|
24
|
+
export declare function applyJobsAppsKey(deps: Deps, desired: {
|
|
25
|
+
mode: 'ensure' | 'retire';
|
|
26
|
+
url?: string;
|
|
27
|
+
expiresAt?: string;
|
|
28
|
+
}): Promise<void>;
|
|
29
|
+
export {};
|