@wtfalch/foundry 0.14.0 → 0.16.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/bootstrap/declare.d.ts +2 -2
- package/dist/bootstrap/declare.js +6 -6
- package/dist/index.d.ts +4 -4
- package/dist/index.js +3 -3
- package/dist/migrations/0034_owners.sql +42 -0
- package/dist/migrations/0035_chat_on_by_default.sql +10 -0
- package/dist/org.d.ts +80 -17
- package/dist/org.js +221 -18
- package/dist/plan/agora.d.ts +19 -1
- package/dist/plan/agora.js +37 -2
- package/dist/plan/chat-matrix.d.ts +99 -0
- package/dist/plan/chat-matrix.js +194 -0
- package/dist/plan/diff.d.ts +5 -3
- package/dist/plan/diff.js +143 -83
- package/dist/plan/entry-ai-key.d.ts +58 -0
- package/dist/plan/entry-ai-key.js +209 -0
- package/dist/plan/entry-files-key.d.ts +82 -0
- package/dist/plan/entry-files-key.js +327 -0
- package/dist/plan/executor.d.ts +244 -16
- package/dist/plan/executor.js +860 -78
- package/dist/plan/hostnames.d.ts +8 -1
- package/dist/plan/hostnames.js +18 -1
- package/dist/plan/index.d.ts +2 -1
- package/dist/plan/index.js +2 -1
- package/dist/plan/plan-apply.d.ts +2 -0
- package/dist/plan/plan-apply.js +42 -2
- package/dist/plan/platform-diff.d.ts +8 -0
- package/dist/plan/platform-diff.js +8 -0
- package/dist/plan/platform-org.d.ts +17 -5
- package/dist/plan/platform-org.js +15 -7
- package/dist/plan/platform-providers.d.ts +38 -12
- package/dist/plan/platform-providers.js +99 -16
- package/dist/plan/proxy-labels.d.ts +42 -0
- package/dist/plan/proxy-labels.js +73 -0
- package/dist/plan/zitadel-machine-user.d.ts +5 -1
- package/dist/plan/zitadel-machine-user.js +5 -1
- package/dist/plan/zitadel-project-roles.d.ts +25 -0
- package/dist/plan/zitadel-project-roles.js +104 -0
- package/dist/store/postgres.js +4 -17
- package/dist/store/schema.d.ts +13 -45
- package/dist/store/schema.js +5 -8
- package/package.json +2 -2
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { AI_ENTRY_PERMISSIONS } from '../org.js';
|
|
2
|
+
import { entryHostnames } from './hostnames.js';
|
|
3
|
+
import { appStepKey } from './platform-diff.js';
|
|
4
|
+
/**
|
|
5
|
+
* F6a: one ai key per entry with `uses.ai`, minted below the org's own ai key
|
|
6
|
+
* (`ai:key:<orgId>`), delivered into that entry's env and nowhere else.
|
|
7
|
+
*/
|
|
8
|
+
/** How long an entry's key lives. ai refuses more than 365 and anything past its minter's own expiry. */
|
|
9
|
+
export const AI_ENTRY_KEY_DAYS = 365;
|
|
10
|
+
/** A key with fewer days left than this is renewed. */
|
|
11
|
+
export const AI_ENTRY_RENEW_WINDOW_DAYS = 30;
|
|
12
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
13
|
+
/** Flat scope ids (no `:`) per entry. Entry keys hold neither `:` nor `.`. */
|
|
14
|
+
export const entryAiUndeliveredScopeKey = (entryKey) => `ai_key_undelivered_${entryKey}`;
|
|
15
|
+
/** The ISO time the entry's live key expires. */
|
|
16
|
+
export const entryAiExpiresScopeKey = (entryKey) => `ai_key_expires_${entryKey}`;
|
|
17
|
+
/** What the last apply wrote: permissions, offerings, webhook address, ai address. Never a secret. */
|
|
18
|
+
export const entryAiAppliedScopeKey = (entryKey) => `ai_key_applied_${entryKey}`;
|
|
19
|
+
/** Set before the webhook is registered and cleared once its secret is in the env. */
|
|
20
|
+
export const entryAiWebhookPendingScopeKey = (entryKey) => `ai_webhook_pending_${entryKey}`;
|
|
21
|
+
/** The id of the key whose env write finished, before the app restart that completes the step. */
|
|
22
|
+
export const entryAiWrittenScopeKey = (entryKey) => `ai_key_env_written_${entryKey}`;
|
|
23
|
+
/** The expiry ai returned for the key above. */
|
|
24
|
+
export const entryAiPendingExpiresScopeKey = (entryKey) => `ai_key_pending_expires_${entryKey}`;
|
|
25
|
+
/** `{ expiresAt, minter }` of a key ai clamped under the renewal window; renewing it again under the same minter only repeats. */
|
|
26
|
+
export const entryAiClampedScopeKey = (entryKey) => `ai_key_clamped_${entryKey}`;
|
|
27
|
+
/** The id of a key that was live when its entry stopped declaring `uses.ai`, once handled. */
|
|
28
|
+
export const entryAiRetiredScopeKey = (entryKey) => `ai_key_retired_${entryKey}`;
|
|
29
|
+
/**
|
|
30
|
+
* REVOKE SEAM (ai#157, A1b, not built): the ids of keys left live because
|
|
31
|
+
* `AiOrganisationPort.revokeKey` is absent or failed, as a JSON array. They
|
|
32
|
+
* expire on their own; the plan lists each.
|
|
33
|
+
*/
|
|
34
|
+
export const entryAiUnrevokedScopeKey = (entryKey) => `ai_key_unrevoked_${entryKey}`;
|
|
35
|
+
export const ENTRY_AI_UNREVOKED_PREFIX = 'ai_key_unrevoked_';
|
|
36
|
+
export function parseIdList(raw) {
|
|
37
|
+
try {
|
|
38
|
+
const parsed = raw ? JSON.parse(raw) : [];
|
|
39
|
+
return Array.isArray(parsed) ? parsed.filter((v) => typeof v === 'string') : [];
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return [];
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export const entryAiKeyName = (org, entry) => `${org.slug}-${entry.key}`;
|
|
46
|
+
export function wantedEntryAi(org, entry, aiUrl) {
|
|
47
|
+
const ai = entry.uses?.ai;
|
|
48
|
+
const host = entryHostnames(org, entry)[0];
|
|
49
|
+
return {
|
|
50
|
+
permissions: [...(ai?.permissions ?? AI_ENTRY_PERMISSIONS)],
|
|
51
|
+
offerings: [...(ai?.offerings ?? [])],
|
|
52
|
+
webhook: ai?.webhookPath !== undefined && host ? `https://${host}${ai.webhookPath}` : null,
|
|
53
|
+
aiUrl,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* One step per entry whose key is missing, near expiry or whose declared
|
|
58
|
+
* settings changed. Blocked, not planned (rule 2), while the org's key, the
|
|
59
|
+
* entry's app or the ai address is not recorded, or after a mint whose
|
|
60
|
+
* delivery failed: a second mint would leave two live keys.
|
|
61
|
+
*/
|
|
62
|
+
export function planEntryAiKeys(org, recordedIds, plannedSteps, readiness, now) {
|
|
63
|
+
const steps = [];
|
|
64
|
+
const blocked = [];
|
|
65
|
+
// Keys listed as left live, whichever entry they came from.
|
|
66
|
+
for (const [name, raw] of Object.entries(recordedIds)) {
|
|
67
|
+
if (!name.startsWith(ENTRY_AI_UNREVOKED_PREFIX))
|
|
68
|
+
continue;
|
|
69
|
+
const entryKey = name.slice(ENTRY_AI_UNREVOKED_PREFIX.length);
|
|
70
|
+
for (const id of parseIdList(raw)) {
|
|
71
|
+
blocked.push({
|
|
72
|
+
description: `warning: ${org.slug}'s ${entryKey} ai key ${id} stays live until it expires; ai has no revoke route yet (https://github.com/wtfalch/ai/issues/157)`,
|
|
73
|
+
key: `ai:key:${org.id}:${entryKey}:unrevoked:${id}`,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const declared = new Set(org.hosting.entries.filter((e) => e.uses?.ai).map((e) => e.key));
|
|
78
|
+
const keyPrefix = `ai:key:${org.id}:`;
|
|
79
|
+
// A key whose entry is gone, or no longer declares `uses.ai`, is revoked.
|
|
80
|
+
if (readiness.aiReady) {
|
|
81
|
+
for (const [name, id] of Object.entries(recordedIds)) {
|
|
82
|
+
if (!name.startsWith(keyPrefix) || !id)
|
|
83
|
+
continue;
|
|
84
|
+
const entryKey = name.slice(keyPrefix.length);
|
|
85
|
+
if (declared.has(entryKey) || recordedIds[entryAiRetiredScopeKey(entryKey)] === id)
|
|
86
|
+
continue;
|
|
87
|
+
steps.push({
|
|
88
|
+
action: {
|
|
89
|
+
system: 'ai',
|
|
90
|
+
kind: 'key',
|
|
91
|
+
key: `${org.id}:${entryKey}`,
|
|
92
|
+
desired: { mode: 'retire' },
|
|
93
|
+
},
|
|
94
|
+
step: {
|
|
95
|
+
description: `revoke ${org.slug}'s ${entryKey} ai key ${id}: the entry no longer declares uses.ai`,
|
|
96
|
+
destroys: true,
|
|
97
|
+
migrates: false,
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
for (const entry of org.hosting.entries) {
|
|
103
|
+
if (!entry.uses?.ai)
|
|
104
|
+
continue;
|
|
105
|
+
const at = `${org.slug}'s ${entry.key} ai key`;
|
|
106
|
+
const stepKey = appStepKey(org, entry.key);
|
|
107
|
+
const key = `ai:key:${stepKey}`;
|
|
108
|
+
const block = (why) => blocked.push({ description: `${at} is blocked: ${why}`, key });
|
|
109
|
+
if (!readiness.aiReady)
|
|
110
|
+
continue; // `ai:organisation` already reports it.
|
|
111
|
+
// A key retired earlier is dead even though its id is still recorded.
|
|
112
|
+
const liveKeyId = recordedIds[key] && recordedIds[entryAiRetiredScopeKey(entry.key)] !== recordedIds[key]
|
|
113
|
+
? recordedIds[key]
|
|
114
|
+
: undefined;
|
|
115
|
+
const undelivered = recordedIds[entryAiUndeliveredScopeKey(entry.key)];
|
|
116
|
+
const finishing = Boolean(undelivered && recordedIds[entryAiWrittenScopeKey(entry.key)] === undelivered);
|
|
117
|
+
if (undelivered && !finishing) {
|
|
118
|
+
block(`key ${undelivered} was minted but not delivered, and no new key is minted until it is revoked in ai`);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (recordedIds[entryAiWebhookPendingScopeKey(entry.key)]) {
|
|
122
|
+
block('its webhook was registered but the secret was not delivered; rotate it in ai');
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (!readiness.aiUrl) {
|
|
126
|
+
block("the platform has not recorded ai's address yet");
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
const planned = (system, kind, k) => plannedSteps.some((p) => p.action.system === system && p.action.kind === kind && p.action.key === k);
|
|
130
|
+
if (!recordedIds[`ai:key:${org.id}`] && !planned('ai', 'key', org.id)) {
|
|
131
|
+
block("the org's own ai key is not recorded yet");
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (!recordedIds[`coolify:app:${stepKey}`] && !planned('coolify', 'app', stepKey)) {
|
|
135
|
+
block("the entry's app is not recorded yet");
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
const want = wantedEntryAi(org, entry, readiness.aiUrl);
|
|
139
|
+
const applied = recordedIds[entryAiAppliedScopeKey(entry.key)];
|
|
140
|
+
const wantJson = JSON.stringify(want);
|
|
141
|
+
let mode = null;
|
|
142
|
+
if (finishing) {
|
|
143
|
+
// The env holds the new key; only the restart and the records are left.
|
|
144
|
+
mode = 'finish';
|
|
145
|
+
}
|
|
146
|
+
else if (!liveKeyId) {
|
|
147
|
+
mode = 'mint';
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
const expiresRaw = recordedIds[entryAiExpiresScopeKey(entry.key)];
|
|
151
|
+
const expires = Date.parse(expiresRaw ?? '');
|
|
152
|
+
let appliedWant = {};
|
|
153
|
+
try {
|
|
154
|
+
appliedWant = applied ? JSON.parse(applied) : {};
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
// An unreadable marker plans the settings step again.
|
|
158
|
+
}
|
|
159
|
+
if (!(expires - now >= AI_ENTRY_RENEW_WINDOW_DAYS * DAY_MS)) {
|
|
160
|
+
// ai clamps an entry key to the org key's expiry. A key already
|
|
161
|
+
// clamped under this window by this same minter would only be
|
|
162
|
+
// replaced by another as short: the org key must be renewed first.
|
|
163
|
+
let clamped = {};
|
|
164
|
+
try {
|
|
165
|
+
clamped = JSON.parse(recordedIds[entryAiClampedScopeKey(entry.key)] || '{}');
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
// Unreadable: treated as not clamped.
|
|
169
|
+
}
|
|
170
|
+
if (clamped.expiresAt === expiresRaw &&
|
|
171
|
+
clamped.minter === recordedIds[`ai:key:${org.id}`]) {
|
|
172
|
+
block(`the key expires ${expiresRaw} because the org's own ai key expires then; renew the org's own ai key, then this one is renewed`);
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
mode = 'renew';
|
|
176
|
+
}
|
|
177
|
+
else if (JSON.stringify(appliedWant.permissions) !== JSON.stringify(want.permissions)) {
|
|
178
|
+
mode = 'renew';
|
|
179
|
+
}
|
|
180
|
+
else if (applied !== wantJson)
|
|
181
|
+
mode = 'settings';
|
|
182
|
+
}
|
|
183
|
+
if (!mode)
|
|
184
|
+
continue;
|
|
185
|
+
steps.push({
|
|
186
|
+
action: {
|
|
187
|
+
system: 'ai',
|
|
188
|
+
kind: 'key',
|
|
189
|
+
key: stepKey,
|
|
190
|
+
desired: {
|
|
191
|
+
mode,
|
|
192
|
+
name: entryAiKeyName(org, entry),
|
|
193
|
+
expiresDays: AI_ENTRY_KEY_DAYS,
|
|
194
|
+
...want,
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
step: {
|
|
198
|
+
description: mode === 'settings'
|
|
199
|
+
? `update ${at}'s AI_* env and webhook`
|
|
200
|
+
: mode === 'finish'
|
|
201
|
+
? `finish ${at}: restart the app that already holds the new key`
|
|
202
|
+
: `${mode === 'mint' ? 'mint' : 'renew'} ${at} below the org's own key, write AI_* env, restart the app`,
|
|
203
|
+
destroys: false,
|
|
204
|
+
migrates: false,
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
return { steps, blocked };
|
|
209
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { type DeclaredOrg, type HostingEntry } from '../org.js';
|
|
2
|
+
import type { PlannedStep } from './diff.js';
|
|
3
|
+
import type { OrgPlatformReadiness } from './platform-org.js';
|
|
4
|
+
/**
|
|
5
|
+
* F6b: the org's own files key, minted below the files organisation's
|
|
6
|
+
* first owner and renewed before it expires, and one files key per entry with `uses.files`, minted below
|
|
7
|
+
* it. A secret goes to `deliverSecret` (the org key) or into the entry's env
|
|
8
|
+
* (an entry key) and nowhere else.
|
|
9
|
+
*/
|
|
10
|
+
/** How long a key lives. files refuses more than 365 and anything past its minter's own expiry. */
|
|
11
|
+
export declare const FILES_KEY_DAYS = 365;
|
|
12
|
+
/** A key with fewer days left than this is renewed. */
|
|
13
|
+
export declare const FILES_KEY_RENEW_WINDOW_DAYS = 30;
|
|
14
|
+
/** What the org's own key holds: it mints and revokes, and holds what its entries may hold. */
|
|
15
|
+
export declare const FILES_ROOT_PERMISSIONS: readonly ["credentials:create", "credentials:revoke", "files.collections:read", "files.files:read", "files.files:download", "files.files:upload", "files.files:replace", "files.files:delete"];
|
|
16
|
+
/**
|
|
17
|
+
* Flat scope ids (no `:`). The org's own key: the id minted last (it blocks
|
|
18
|
+
* only while it differs from the recorded key, which means it was never
|
|
19
|
+
* delivered) and when the recorded key expires.
|
|
20
|
+
*/
|
|
21
|
+
export declare const FILES_ROOT_UNDELIVERED_SCOPE_KEY = "files_key_undelivered";
|
|
22
|
+
export declare const FILES_ROOT_EXPIRES_SCOPE_KEY = "files_key_expires";
|
|
23
|
+
/** Ids of org keys replaced by a renewal, as a JSON array, revoked once every entry holds a key below the new one. */
|
|
24
|
+
export declare const FILES_ROOT_PREVIOUS_SCOPE_KEY = "files_key_previous";
|
|
25
|
+
/** Ids of replaced org keys whose revoke failed, as a JSON array. They expire on their own; the plan lists each. */
|
|
26
|
+
export declare const FILES_ROOT_UNREVOKED_SCOPE_KEY = "files_key_unrevoked";
|
|
27
|
+
/** The sign-in user id files made the organisation's owner when foundry provisioned it with one; the org key is minted below it. */
|
|
28
|
+
export declare const FILES_ORG_OWNER_SCOPE_KEY = "files_org_owner";
|
|
29
|
+
/** Agora's files key, which files returns once when it onboards Agora at provisioning: its id once delivered, and an id minted but not yet delivered. */
|
|
30
|
+
export declare const FILES_AGORA_KEY_SCOPE_KEY = "files_agora_key";
|
|
31
|
+
export declare const FILES_AGORA_UNDELIVERED_SCOPE_KEY = "files_agora_key_undelivered";
|
|
32
|
+
/** Per entry. Entry keys hold neither `:` nor `.`. */
|
|
33
|
+
export declare const entryFilesUndeliveredScopeKey: (entryKey: string) => string;
|
|
34
|
+
/** The id of the org key the entry's live key was minted below. */
|
|
35
|
+
export declare const entryFilesMinterScopeKey: (entryKey: string) => string;
|
|
36
|
+
/** The ISO time the entry's live key expires. */
|
|
37
|
+
export declare const entryFilesExpiresScopeKey: (entryKey: string) => string;
|
|
38
|
+
/** What the last apply wrote: permissions, collection names, files address. Never a secret. */
|
|
39
|
+
export declare const entryFilesAppliedScopeKey: (entryKey: string) => string;
|
|
40
|
+
/** The id of the key whose env write finished, before the app restart that completes the step. */
|
|
41
|
+
export declare const entryFilesWrittenScopeKey: (entryKey: string) => string;
|
|
42
|
+
/** The expiry files returned for the key above. */
|
|
43
|
+
export declare const entryFilesPendingExpiresScopeKey: (entryKey: string) => string;
|
|
44
|
+
/** The id of a key that was live when its entry stopped declaring `uses.files`, once handled. */
|
|
45
|
+
export declare const entryFilesRetiredScopeKey: (entryKey: string) => string;
|
|
46
|
+
/** The ids of keys left live because a revoke failed, as a JSON array. They expire on their own; the plan lists each. */
|
|
47
|
+
export declare const entryFilesUnrevokedScopeKey: (entryKey: string) => string;
|
|
48
|
+
export declare const ENTRY_FILES_UNREVOKED_PREFIX = "files_entry_unrevoked_";
|
|
49
|
+
export declare const ENTRY_FILES_UNDELIVERED_PREFIX = "files_entry_undelivered_";
|
|
50
|
+
export declare function parseIdList(raw: string | undefined): string[];
|
|
51
|
+
export declare const filesRootKeyName: (org: DeclaredOrg) => string;
|
|
52
|
+
export declare const entryFilesKeyName: (org: DeclaredOrg, entry: HostingEntry) => string;
|
|
53
|
+
/** The recorded ZITADEL user id of the org's first owner, the files organisation's first member. */
|
|
54
|
+
export declare function filesOwnerUserId(org: DeclaredOrg, recordedIds: Readonly<Record<string, string>>): string | undefined;
|
|
55
|
+
export interface EntryFilesWant {
|
|
56
|
+
permissions: string[];
|
|
57
|
+
collections: string[];
|
|
58
|
+
filesUrl: string;
|
|
59
|
+
}
|
|
60
|
+
export declare function wantedEntryFiles(entry: HostingEntry, filesUrl: string): EntryFilesWant;
|
|
61
|
+
export type EntryFilesMode = 'mint' | 'renew' | 'settings' | 'finish' | 'retire';
|
|
62
|
+
type Blocked = Array<{
|
|
63
|
+
description: string;
|
|
64
|
+
key: string;
|
|
65
|
+
}>;
|
|
66
|
+
/**
|
|
67
|
+
* The org key's steps, then one step per entry whose key is missing, near
|
|
68
|
+
* expiry, minted below a replaced org key or whose declared settings changed.
|
|
69
|
+
* Blocked, not planned (rule 2), while the owner, the org key, the entry's
|
|
70
|
+
* app or the files address is not recorded, or after a mint whose delivery
|
|
71
|
+
* failed: a second mint would leave two live keys.
|
|
72
|
+
*
|
|
73
|
+
* Renewing the org key: under 30 days left (or an expiry nobody recorded),
|
|
74
|
+
* `renew` mints a new one and delivers it. Each entry is then re-minted below
|
|
75
|
+
* it by its own `renew` step, and only then does `revoke-previous` revoke the
|
|
76
|
+
* old org key, which revokes everything still below it.
|
|
77
|
+
*/
|
|
78
|
+
export declare function planFilesKeys(org: DeclaredOrg, recordedIds: Readonly<Record<string, string>>, plannedSteps: readonly PlannedStep[], readiness: OrgPlatformReadiness, now: number): {
|
|
79
|
+
steps: PlannedStep[];
|
|
80
|
+
blocked: Blocked;
|
|
81
|
+
};
|
|
82
|
+
export {};
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import { FILES_ENTRY_PERMISSIONS } from '../org.js';
|
|
2
|
+
import { appStepKey } from './platform-diff.js';
|
|
3
|
+
/**
|
|
4
|
+
* F6b: the org's own files key, minted below the files organisation's
|
|
5
|
+
* first owner and renewed before it expires, and one files key per entry with `uses.files`, minted below
|
|
6
|
+
* it. A secret goes to `deliverSecret` (the org key) or into the entry's env
|
|
7
|
+
* (an entry key) and nowhere else.
|
|
8
|
+
*/
|
|
9
|
+
/** How long a key lives. files refuses more than 365 and anything past its minter's own expiry. */
|
|
10
|
+
export const FILES_KEY_DAYS = 365;
|
|
11
|
+
/** A key with fewer days left than this is renewed. */
|
|
12
|
+
export const FILES_KEY_RENEW_WINDOW_DAYS = 30;
|
|
13
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
14
|
+
/** What the org's own key holds: it mints and revokes, and holds what its entries may hold. */
|
|
15
|
+
export const FILES_ROOT_PERMISSIONS = [
|
|
16
|
+
'credentials:create',
|
|
17
|
+
'credentials:revoke',
|
|
18
|
+
...FILES_ENTRY_PERMISSIONS,
|
|
19
|
+
];
|
|
20
|
+
/**
|
|
21
|
+
* Flat scope ids (no `:`). The org's own key: the id minted last (it blocks
|
|
22
|
+
* only while it differs from the recorded key, which means it was never
|
|
23
|
+
* delivered) and when the recorded key expires.
|
|
24
|
+
*/
|
|
25
|
+
export const FILES_ROOT_UNDELIVERED_SCOPE_KEY = 'files_key_undelivered';
|
|
26
|
+
export const FILES_ROOT_EXPIRES_SCOPE_KEY = 'files_key_expires';
|
|
27
|
+
/** Ids of org keys replaced by a renewal, as a JSON array, revoked once every entry holds a key below the new one. */
|
|
28
|
+
export const FILES_ROOT_PREVIOUS_SCOPE_KEY = 'files_key_previous';
|
|
29
|
+
/** Ids of replaced org keys whose revoke failed, as a JSON array. They expire on their own; the plan lists each. */
|
|
30
|
+
export const FILES_ROOT_UNREVOKED_SCOPE_KEY = 'files_key_unrevoked';
|
|
31
|
+
/** The sign-in user id files made the organisation's owner when foundry provisioned it with one; the org key is minted below it. */
|
|
32
|
+
export const FILES_ORG_OWNER_SCOPE_KEY = 'files_org_owner';
|
|
33
|
+
/** Agora's files key, which files returns once when it onboards Agora at provisioning: its id once delivered, and an id minted but not yet delivered. */
|
|
34
|
+
export const FILES_AGORA_KEY_SCOPE_KEY = 'files_agora_key';
|
|
35
|
+
export const FILES_AGORA_UNDELIVERED_SCOPE_KEY = 'files_agora_key_undelivered';
|
|
36
|
+
/** Per entry. Entry keys hold neither `:` nor `.`. */
|
|
37
|
+
export const entryFilesUndeliveredScopeKey = (entryKey) => `files_entry_undelivered_${entryKey}`;
|
|
38
|
+
/** The id of the org key the entry's live key was minted below. */
|
|
39
|
+
export const entryFilesMinterScopeKey = (entryKey) => `files_entry_minter_${entryKey}`;
|
|
40
|
+
/** The ISO time the entry's live key expires. */
|
|
41
|
+
export const entryFilesExpiresScopeKey = (entryKey) => `files_entry_expires_${entryKey}`;
|
|
42
|
+
/** What the last apply wrote: permissions, collection names, files address. Never a secret. */
|
|
43
|
+
export const entryFilesAppliedScopeKey = (entryKey) => `files_entry_applied_${entryKey}`;
|
|
44
|
+
/** The id of the key whose env write finished, before the app restart that completes the step. */
|
|
45
|
+
export const entryFilesWrittenScopeKey = (entryKey) => `files_entry_written_${entryKey}`;
|
|
46
|
+
/** The expiry files returned for the key above. */
|
|
47
|
+
export const entryFilesPendingExpiresScopeKey = (entryKey) => `files_entry_pending_expires_${entryKey}`;
|
|
48
|
+
/** The id of a key that was live when its entry stopped declaring `uses.files`, once handled. */
|
|
49
|
+
export const entryFilesRetiredScopeKey = (entryKey) => `files_entry_retired_${entryKey}`;
|
|
50
|
+
/** The ids of keys left live because a revoke failed, as a JSON array. They expire on their own; the plan lists each. */
|
|
51
|
+
export const entryFilesUnrevokedScopeKey = (entryKey) => `files_entry_unrevoked_${entryKey}`;
|
|
52
|
+
export const ENTRY_FILES_UNREVOKED_PREFIX = 'files_entry_unrevoked_';
|
|
53
|
+
export const ENTRY_FILES_UNDELIVERED_PREFIX = 'files_entry_undelivered_';
|
|
54
|
+
export function parseIdList(raw) {
|
|
55
|
+
try {
|
|
56
|
+
const parsed = raw ? JSON.parse(raw) : [];
|
|
57
|
+
return Array.isArray(parsed) ? parsed.filter((v) => typeof v === 'string') : [];
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
export const filesRootKeyName = (org) => `${org.slug}-files`;
|
|
64
|
+
export const entryFilesKeyName = (org, entry) => `${org.slug}-${entry.key}-files`;
|
|
65
|
+
/** The recorded ZITADEL user id of the org's first owner, the files organisation's first member. */
|
|
66
|
+
export function filesOwnerUserId(org, recordedIds) {
|
|
67
|
+
const first = org.owners[0];
|
|
68
|
+
return first ? recordedIds[`zitadel:owner:${org.id}:${first.email.toLowerCase()}`] : undefined;
|
|
69
|
+
}
|
|
70
|
+
export function wantedEntryFiles(entry, filesUrl) {
|
|
71
|
+
const files = entry.uses?.files;
|
|
72
|
+
return {
|
|
73
|
+
permissions: [...(files?.permissions ?? FILES_ENTRY_PERMISSIONS)],
|
|
74
|
+
collections: [...(files?.collections ?? [])],
|
|
75
|
+
filesUrl,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* The org key's steps, then one step per entry whose key is missing, near
|
|
80
|
+
* expiry, minted below a replaced org key or whose declared settings changed.
|
|
81
|
+
* Blocked, not planned (rule 2), while the owner, the org key, the entry's
|
|
82
|
+
* app or the files address is not recorded, or after a mint whose delivery
|
|
83
|
+
* failed: a second mint would leave two live keys.
|
|
84
|
+
*
|
|
85
|
+
* Renewing the org key: under 30 days left (or an expiry nobody recorded),
|
|
86
|
+
* `renew` mints a new one and delivers it. Each entry is then re-minted below
|
|
87
|
+
* it by its own `renew` step, and only then does `revoke-previous` revoke the
|
|
88
|
+
* old org key, which revokes everything still below it.
|
|
89
|
+
*/
|
|
90
|
+
export function planFilesKeys(org, recordedIds, plannedSteps, readiness, now) {
|
|
91
|
+
const steps = [];
|
|
92
|
+
const blocked = [];
|
|
93
|
+
const planned = (system, kind, k) => plannedSteps.some((p) => p.action.system === system && p.action.kind === kind && p.action.key === k);
|
|
94
|
+
// Keys listed as left live, whichever entry they came from.
|
|
95
|
+
for (const [name, raw] of Object.entries(recordedIds)) {
|
|
96
|
+
if (!name.startsWith(ENTRY_FILES_UNREVOKED_PREFIX))
|
|
97
|
+
continue;
|
|
98
|
+
const entryKey = name.slice(ENTRY_FILES_UNREVOKED_PREFIX.length);
|
|
99
|
+
for (const id of parseIdList(raw)) {
|
|
100
|
+
blocked.push({
|
|
101
|
+
description: `warning: ${org.slug}'s ${entryKey} files key ${id} stays live until it expires; its revoke failed`,
|
|
102
|
+
key: `files:key:${org.id}:${entryKey}:unrevoked:${id}`,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
for (const id of parseIdList(recordedIds[FILES_ROOT_UNREVOKED_SCOPE_KEY])) {
|
|
107
|
+
blocked.push({
|
|
108
|
+
description: `warning: ${org.slug}'s replaced own files key ${id} stays live until it expires; its revoke failed`,
|
|
109
|
+
key: `files:key:${org.id}:unrevoked:${id}`,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
// Agora's key came back once and was not delivered.
|
|
113
|
+
const agoraUndelivered = recordedIds[FILES_AGORA_UNDELIVERED_SCOPE_KEY];
|
|
114
|
+
if (agoraUndelivered && agoraUndelivered !== recordedIds[FILES_AGORA_KEY_SCOPE_KEY]) {
|
|
115
|
+
blocked.push({
|
|
116
|
+
description: `warning: ${org.slug}'s Agora files key ${agoraUndelivered} was created but not delivered; revoke it in files`,
|
|
117
|
+
key: `files:agora-key:${org.id}`,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
const entries = org.hosting.entries.filter((e) => e.uses?.files);
|
|
121
|
+
const declared = new Set(entries.map((e) => e.key));
|
|
122
|
+
const rootKeyId = recordedIds[`files:key:${org.id}`];
|
|
123
|
+
// A key whose entry is gone, or no longer declares `uses.files`, is revoked:
|
|
124
|
+
// the recorded one and any minted but not delivered or not yet restarted.
|
|
125
|
+
if (readiness.filesReady) {
|
|
126
|
+
const prefix = `files:key:${org.id}:`;
|
|
127
|
+
const gone = new Set();
|
|
128
|
+
for (const name of Object.keys(recordedIds)) {
|
|
129
|
+
if (name.startsWith(prefix) && recordedIds[name])
|
|
130
|
+
gone.add(name.slice(prefix.length));
|
|
131
|
+
if (name.startsWith(ENTRY_FILES_UNDELIVERED_PREFIX) && recordedIds[name]) {
|
|
132
|
+
gone.add(name.slice(ENTRY_FILES_UNDELIVERED_PREFIX.length));
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
for (const entryKey of gone) {
|
|
136
|
+
if (declared.has(entryKey))
|
|
137
|
+
continue;
|
|
138
|
+
const liveId = recordedIds[`${prefix}${entryKey}`];
|
|
139
|
+
const stale = liveId && recordedIds[entryFilesRetiredScopeKey(entryKey)] !== liveId;
|
|
140
|
+
const loose = recordedIds[entryFilesUndeliveredScopeKey(entryKey)];
|
|
141
|
+
if (!stale && !loose)
|
|
142
|
+
continue;
|
|
143
|
+
steps.push({
|
|
144
|
+
action: {
|
|
145
|
+
system: 'files',
|
|
146
|
+
kind: 'key',
|
|
147
|
+
key: `${org.id}:${entryKey}`,
|
|
148
|
+
desired: { mode: 'retire' },
|
|
149
|
+
},
|
|
150
|
+
step: {
|
|
151
|
+
description: `revoke ${org.slug}'s ${entryKey} files key ${[stale ? liveId : '', loose ?? ''].filter(Boolean).join(' and ')}: the entry no longer declares uses.files`,
|
|
152
|
+
destroys: true,
|
|
153
|
+
migrates: false,
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (entries.length === 0 || !readiness.filesReady)
|
|
159
|
+
return { steps, blocked };
|
|
160
|
+
for (const entry of entries) {
|
|
161
|
+
if (entry.uses?.files?.shareHost) {
|
|
162
|
+
blocked.push({
|
|
163
|
+
description: `${org.slug}'s ${entry.key} share host is blocked: the platform's files host has no route for share.<domain> yet, so FILES_SHARE_ORIGIN is not delivered`,
|
|
164
|
+
key: `files:share:${org.id}:${entry.key}`,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
// The org's own key: minted, or renewed when it is short or its expiry is unknown.
|
|
169
|
+
const rootBlock = (why) => blocked.push({
|
|
170
|
+
description: `${org.slug}'s own files key is blocked: ${why}`,
|
|
171
|
+
key: `files:key:${org.id}`,
|
|
172
|
+
});
|
|
173
|
+
const rootExpiresRaw = recordedIds[FILES_ROOT_EXPIRES_SCOPE_KEY];
|
|
174
|
+
const rootExpires = Date.parse(rootExpiresRaw ?? '');
|
|
175
|
+
const rootShort = Boolean(rootKeyId) &&
|
|
176
|
+
(Number.isNaN(rootExpires) || rootExpires - now < FILES_KEY_RENEW_WINDOW_DAYS * DAY_MS);
|
|
177
|
+
const rootUndelivered = recordedIds[FILES_ROOT_UNDELIVERED_SCOPE_KEY];
|
|
178
|
+
let rootMode = null;
|
|
179
|
+
if (!rootKeyId || rootShort) {
|
|
180
|
+
const ownerKey = org.owners[0] ? `${org.id}:${org.owners[0].email.toLowerCase()}` : '';
|
|
181
|
+
const orgPlanned = planned('files', 'organisation', org.id);
|
|
182
|
+
if (rootUndelivered && rootUndelivered !== rootKeyId) {
|
|
183
|
+
rootBlock(`key ${rootUndelivered} was minted but not delivered, and no new key is minted until it is revoked in files`);
|
|
184
|
+
}
|
|
185
|
+
else if (!org.owners[0]) {
|
|
186
|
+
rootBlock('the org names no owner; the key is minted below the files organisation owner');
|
|
187
|
+
}
|
|
188
|
+
else if (!recordedIds[FILES_ORG_OWNER_SCOPE_KEY] && !orgPlanned) {
|
|
189
|
+
rootBlock("the files organisation exists without an owner, and files can mint its key only below one; make the org's first owner an owner member of it in files, then record that person's sign-in user id as the scope id files_org_owner");
|
|
190
|
+
}
|
|
191
|
+
else if (!recordedIds[FILES_ORG_OWNER_SCOPE_KEY] &&
|
|
192
|
+
!filesOwnerUserId(org, recordedIds) &&
|
|
193
|
+
!planned('zitadel', 'owner', ownerKey)) {
|
|
194
|
+
rootBlock("the first owner's sign-in user id is not recorded yet");
|
|
195
|
+
}
|
|
196
|
+
else {
|
|
197
|
+
rootMode = rootKeyId ? 'renew' : 'mint';
|
|
198
|
+
steps.push({
|
|
199
|
+
action: {
|
|
200
|
+
system: 'files',
|
|
201
|
+
kind: 'key',
|
|
202
|
+
key: org.id,
|
|
203
|
+
desired: {
|
|
204
|
+
mode: rootMode,
|
|
205
|
+
name: filesRootKeyName(org),
|
|
206
|
+
expiresDays: FILES_KEY_DAYS,
|
|
207
|
+
permissions: [...FILES_ROOT_PERMISSIONS],
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
step: {
|
|
211
|
+
description: rootMode === 'renew'
|
|
212
|
+
? `renew ${org.slug}'s own files key and re-mint its entry keys below it`
|
|
213
|
+
: `mint ${org.slug}'s own files key below its owner`,
|
|
214
|
+
destroys: false,
|
|
215
|
+
migrates: false,
|
|
216
|
+
},
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
const entryStepKeys = [];
|
|
221
|
+
for (const entry of entries) {
|
|
222
|
+
const at = `${org.slug}'s ${entry.key} files key`;
|
|
223
|
+
const stepKey = appStepKey(org, entry.key);
|
|
224
|
+
const key = `files:key:${stepKey}`;
|
|
225
|
+
const block = (why) => blocked.push({ description: `${at} is blocked: ${why}`, key });
|
|
226
|
+
const liveKeyId = recordedIds[key] && recordedIds[entryFilesRetiredScopeKey(entry.key)] !== recordedIds[key]
|
|
227
|
+
? recordedIds[key]
|
|
228
|
+
: undefined;
|
|
229
|
+
const undelivered = recordedIds[entryFilesUndeliveredScopeKey(entry.key)];
|
|
230
|
+
const finishing = Boolean(undelivered && recordedIds[entryFilesWrittenScopeKey(entry.key)] === undelivered);
|
|
231
|
+
if (undelivered && !finishing) {
|
|
232
|
+
block(`key ${undelivered} was minted but not delivered, and no new key is minted until it is revoked in files`);
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
if (!readiness.filesUrl) {
|
|
236
|
+
block("the platform has not recorded files' address yet");
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
if (!rootKeyId && !rootMode) {
|
|
240
|
+
block("the org's own files key is not recorded yet");
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
if (!recordedIds[`coolify:app:${stepKey}`] && !planned('coolify', 'app', stepKey)) {
|
|
244
|
+
block("the entry's app is not recorded yet");
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
const want = wantedEntryFiles(entry, readiness.filesUrl);
|
|
248
|
+
const applied = recordedIds[entryFilesAppliedScopeKey(entry.key)];
|
|
249
|
+
const wantJson = JSON.stringify(want);
|
|
250
|
+
let mode = null;
|
|
251
|
+
if (finishing) {
|
|
252
|
+
mode = 'finish';
|
|
253
|
+
}
|
|
254
|
+
else {
|
|
255
|
+
let appliedWant = {};
|
|
256
|
+
try {
|
|
257
|
+
appliedWant = applied ? JSON.parse(applied) : {};
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
260
|
+
// An unreadable marker plans the settings step again.
|
|
261
|
+
}
|
|
262
|
+
if (!liveKeyId) {
|
|
263
|
+
mode = 'mint';
|
|
264
|
+
}
|
|
265
|
+
else if (
|
|
266
|
+
// Below an org key that is being, or was, replaced.
|
|
267
|
+
rootMode === 'renew' ||
|
|
268
|
+
(rootKeyId && recordedIds[entryFilesMinterScopeKey(entry.key)] !== rootKeyId) ||
|
|
269
|
+
!(Date.parse(recordedIds[entryFilesExpiresScopeKey(entry.key)] ?? '') - now >=
|
|
270
|
+
FILES_KEY_RENEW_WINDOW_DAYS * DAY_MS) ||
|
|
271
|
+
JSON.stringify(appliedWant.permissions) !== JSON.stringify(want.permissions)) {
|
|
272
|
+
mode = 'renew';
|
|
273
|
+
}
|
|
274
|
+
else if (applied !== wantJson) {
|
|
275
|
+
mode = 'settings';
|
|
276
|
+
}
|
|
277
|
+
if ((mode === 'mint' || mode === 'renew') && !rootMode && rootShort) {
|
|
278
|
+
block(`the org's own files key expires ${rootExpiresRaw || 'at an unknown time'}, and its renewal is blocked (see the org key's item); renew it first`);
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
if (!mode)
|
|
283
|
+
continue;
|
|
284
|
+
entryStepKeys.push(stepKey);
|
|
285
|
+
steps.push({
|
|
286
|
+
action: {
|
|
287
|
+
system: 'files',
|
|
288
|
+
kind: 'key',
|
|
289
|
+
key: stepKey,
|
|
290
|
+
desired: { mode, name: entryFilesKeyName(org, entry), ...want },
|
|
291
|
+
},
|
|
292
|
+
step: {
|
|
293
|
+
description: mode === 'settings'
|
|
294
|
+
? `update ${at}'s FILES_* env`
|
|
295
|
+
: mode === 'finish'
|
|
296
|
+
? `finish ${at}: restart the app that already holds the new key`
|
|
297
|
+
: `${mode === 'mint' ? 'mint' : 'renew'} ${at} below the org's own key, write FILES_* env, restart the app`,
|
|
298
|
+
destroys: false,
|
|
299
|
+
migrates: false,
|
|
300
|
+
},
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
// The replaced org key goes last, once every entry holds a key below the new one.
|
|
304
|
+
const previous = parseIdList(recordedIds[FILES_ROOT_PREVIOUS_SCOPE_KEY]);
|
|
305
|
+
const consistent = entries.every((e) => {
|
|
306
|
+
const stepKey = appStepKey(org, e.key);
|
|
307
|
+
return (recordedIds[`files:key:${stepKey}`] &&
|
|
308
|
+
recordedIds[entryFilesMinterScopeKey(e.key)] === rootKeyId &&
|
|
309
|
+
!recordedIds[entryFilesUndeliveredScopeKey(e.key)]);
|
|
310
|
+
});
|
|
311
|
+
if (rootMode === 'renew' || (previous.length > 0 && consistent)) {
|
|
312
|
+
steps.push({
|
|
313
|
+
action: {
|
|
314
|
+
system: 'files',
|
|
315
|
+
kind: 'key',
|
|
316
|
+
key: org.id,
|
|
317
|
+
desired: { mode: 'revoke-previous' },
|
|
318
|
+
},
|
|
319
|
+
step: {
|
|
320
|
+
description: `revoke ${org.slug}'s replaced own files key once every entry key is below the new one`,
|
|
321
|
+
destroys: true,
|
|
322
|
+
migrates: false,
|
|
323
|
+
},
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
return { steps, blocked };
|
|
327
|
+
}
|