@wtfalch/foundry 0.76.1 → 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.
- package/dist/app-canary-worker.d.ts +35 -0
- package/dist/app-canary-worker.js +56 -0
- package/dist/audit/connector.d.ts +6 -2
- package/dist/audit/connector.js +3 -2
- package/dist/audit/evidence.d.ts +4 -0
- package/dist/audit/evidence.js +14 -2
- package/dist/audit/host-store.d.ts +58 -0
- package/dist/audit/host-store.js +147 -0
- package/dist/audit/host.d.ts +53 -0
- package/dist/audit/host.js +294 -0
- package/dist/audit/index.d.ts +2 -0
- package/dist/audit/index.js +2 -0
- package/dist/audit/registry.d.ts +14 -0
- package/dist/audit/sealer-role.d.ts +12 -0
- package/dist/audit/sealer-role.js +26 -2
- package/dist/audit/tick.d.ts +10 -2
- package/dist/audit/tick.js +15 -2
- package/dist/catalogue/copy-en.js +5 -0
- package/dist/catalogue/integrations.js +5 -0
- package/dist/entry-rules.d.ts +35 -0
- package/dist/entry-rules.js +35 -0
- package/dist/hosted-label.js +2 -1
- package/dist/index.d.ts +11 -3
- package/dist/index.js +6 -1
- package/dist/migrations/0072_audit_host.sql +46 -0
- package/dist/migrations/0074_platform_app_canary.sql +11 -0
- package/dist/modules/ai.js +15 -1
- package/dist/modules/assistants.js +3 -0
- package/dist/modules/email.d.ts +6 -1
- package/dist/modules/email.js +88 -5
- package/dist/modules/files.js +79 -2
- package/dist/modules/index.js +7 -0
- package/dist/modules/jobs.d.ts +5 -1
- package/dist/modules/jobs.js +74 -4
- package/dist/modules/reporting.d.ts +9 -0
- package/dist/modules/reporting.js +29 -0
- package/dist/modules/sign-in.js +20 -2
- package/dist/modules/types.d.ts +2 -0
- package/dist/modules/workspaces.js +3 -0
- package/dist/org.d.ts +114 -13
- package/dist/org.js +233 -20
- package/dist/plan/ai-boule-key-apply.d.ts +65 -0
- package/dist/plan/ai-boule-key-apply.js +169 -0
- package/dist/plan/ai-boule-key.d.ts +89 -0
- package/dist/plan/ai-boule-key.js +195 -0
- package/dist/plan/analytics.d.ts +8 -1
- package/dist/plan/analytics.js +8 -1
- package/dist/plan/app-canary-apply.d.ts +49 -0
- package/dist/plan/app-canary-apply.js +232 -0
- package/dist/plan/app-canary.d.ts +44 -0
- package/dist/plan/app-canary.js +110 -0
- package/dist/plan/blue-green-apply.d.ts +6 -2
- package/dist/plan/blue-green-apply.js +14 -3
- package/dist/plan/broker-entries.js +20 -5
- package/dist/plan/company-backups.js +2 -0
- package/dist/plan/diff.d.ts +22 -0
- package/dist/plan/diff.js +231 -18
- package/dist/plan/entry-files-key.d.ts +39 -3
- package/dist/plan/entry-files-key.js +103 -6
- package/dist/plan/entry-jobs-boule-key-apply.d.ts +65 -0
- package/dist/plan/entry-jobs-boule-key-apply.js +192 -0
- package/dist/plan/entry-jobs-boule-key.d.ts +59 -0
- package/dist/plan/entry-jobs-boule-key.js +141 -0
- package/dist/plan/entry-jobs.d.ts +2 -0
- package/dist/plan/entry-reporting-key.d.ts +36 -0
- package/dist/plan/entry-reporting-key.js +136 -0
- package/dist/plan/executor.d.ts +108 -1
- package/dist/plan/executor.js +669 -23
- package/dist/plan/files-boule-key-apply.d.ts +73 -0
- package/dist/plan/files-boule-key-apply.js +223 -0
- package/dist/plan/files-boule-key.d.ts +92 -0
- package/dist/plan/files-boule-key.js +175 -0
- package/dist/plan/hostnames.js +4 -0
- package/dist/plan/index.d.ts +4 -0
- package/dist/plan/index.js +2 -0
- package/dist/plan/jobs-scheduler-http.d.ts +13 -0
- package/dist/plan/jobs-scheduler-http.js +39 -0
- package/dist/plan/live.d.ts +1 -1
- package/dist/plan/locked-hostnames.d.ts +1 -1
- package/dist/plan/locked-hostnames.js +4 -1
- package/dist/plan/login-host.js +2 -2
- package/dist/plan/org-names.js +3 -0
- package/dist/plan/own-diff.js +3 -2
- package/dist/plan/own-jobs-route.d.ts +5 -2
- package/dist/plan/own-jobs-route.js +6 -2
- package/dist/plan/own-limits.d.ts +6 -0
- package/dist/plan/own-limits.js +1 -0
- package/dist/plan/plan-apply.d.ts +24 -0
- package/dist/plan/plan-apply.js +39 -4
- package/dist/plan/platform-diff.d.ts +2 -0
- package/dist/plan/platform-diff.js +15 -1
- package/dist/plan/platform-providers.d.ts +9 -0
- package/dist/plan/platform-providers.js +21 -0
- package/dist/plan/providers-readers.js +25 -1
- package/dist/plan/registered-app-env.d.ts +31 -0
- package/dist/plan/registered-app-env.js +63 -0
- package/dist/plan/registered-app.d.ts +121 -0
- package/dist/plan/registered-app.js +262 -0
- package/dist/plan/release-host.js +2 -1
- package/dist/plan/reporting-boule.d.ts +18 -0
- package/dist/plan/reporting-boule.js +44 -0
- package/dist/plan/reporting.d.ts +90 -0
- package/dist/plan/reporting.js +135 -0
- package/dist/plan/stage-env.js +1 -0
- package/dist/plan/stage-isolation.d.ts +3 -1
- package/dist/plan/stage-isolation.js +20 -2
- package/dist/plan/stage-services.d.ts +3 -1
- package/dist/plan/stage-services.js +8 -1
- package/dist/plan/stage-sink.d.ts +2 -2
- package/dist/plan/stage-sink.js +2 -2
- package/dist/plan/workspace-client.d.ts +1 -1
- package/dist/plan/workspace-client.js +2 -1
- package/dist/plan/workspace-sign-in.d.ts +1 -1
- package/dist/plan/workspace-sign-in.js +39 -7
- package/dist/platform.d.ts +25 -0
- package/dist/platform.js +20 -0
- package/dist/service-labels.d.ts +8 -0
- package/dist/service-labels.js +8 -0
- package/dist/slug.d.ts +1 -1
- package/dist/slug.js +5 -2
- package/dist/store/postgres.js +13 -0
- package/dist/store/schema.d.ts +485 -2
- package/dist/store/schema.js +38 -1
- package/dist/worker-app-config.js +2 -2
- package/package.json +1 -1
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { type AiBouleMode } from './ai-boule-key.js';
|
|
2
|
+
/** Every call this module makes (ai, held keys, Coolify) is cut off after this long. */
|
|
3
|
+
export declare const AI_BOULE_CALL_TIMEOUT_MS = 30000;
|
|
4
|
+
/** How many times Boule's key is tried against the held keys. */
|
|
5
|
+
export declare const AI_BOULE_HOLD_ATTEMPTS = 3;
|
|
6
|
+
/** The ai calls this needs, as `AiOrganisationPort` offers them. */
|
|
7
|
+
export interface AiBouleAiPort {
|
|
8
|
+
mintNestedKey(input: {
|
|
9
|
+
organisationId: string;
|
|
10
|
+
minterKeyId: string;
|
|
11
|
+
name: string;
|
|
12
|
+
expiresDays: number;
|
|
13
|
+
permissions: string[];
|
|
14
|
+
}): Promise<{
|
|
15
|
+
organisationId: string;
|
|
16
|
+
id: string;
|
|
17
|
+
secret: string;
|
|
18
|
+
expiresAt: string;
|
|
19
|
+
}>;
|
|
20
|
+
/** Optional until ai#157: without it a replaced key stays live until it expires and is listed. */
|
|
21
|
+
revokeKey?(organisationId: string, keyId: string): Promise<void>;
|
|
22
|
+
}
|
|
23
|
+
export interface AiBouleKeyDeps {
|
|
24
|
+
ai: AiBouleAiPort;
|
|
25
|
+
heldKeys: {
|
|
26
|
+
resolve(ref: string): Promise<string | null | undefined>;
|
|
27
|
+
put(ref: string, value: string): Promise<void>;
|
|
28
|
+
};
|
|
29
|
+
coolify: {
|
|
30
|
+
bulkUpsertEnvs(appId: string, envs: Array<{
|
|
31
|
+
key: string;
|
|
32
|
+
value: string;
|
|
33
|
+
literal?: boolean;
|
|
34
|
+
}>): Promise<unknown>;
|
|
35
|
+
restart(appId: string): Promise<unknown>;
|
|
36
|
+
};
|
|
37
|
+
org: {
|
|
38
|
+
id: string;
|
|
39
|
+
slug: string;
|
|
40
|
+
};
|
|
41
|
+
/** Boule's Coolify app, when it is recorded. */
|
|
42
|
+
bouleAppId: string | undefined;
|
|
43
|
+
recorded: () => Readonly<Record<string, string>>;
|
|
44
|
+
record: (key: string, value: string) => void;
|
|
45
|
+
forget: (key: string) => void;
|
|
46
|
+
persist: () => Promise<void>;
|
|
47
|
+
/** Registers a value for redaction before anything else can see it. */
|
|
48
|
+
secret: (value: string) => void;
|
|
49
|
+
now?: () => number;
|
|
50
|
+
timeoutMs?: number;
|
|
51
|
+
}
|
|
52
|
+
/** What `ensureAiBouleKey` is given from the plan. */
|
|
53
|
+
export interface AiBouleStep {
|
|
54
|
+
mode: AiBouleMode;
|
|
55
|
+
name: string;
|
|
56
|
+
expiresDays: number;
|
|
57
|
+
permissions: string[];
|
|
58
|
+
url: string;
|
|
59
|
+
organisationId: string;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Mints (or reuses the held) Boule key and writes `AI_URL`, `AI_ORGANISATION_ID` and `AI_API_KEY` to Boule's
|
|
63
|
+
* env. See `ai-boule-key.ts` for the order. Errors name the key id and the ref, never the key.
|
|
64
|
+
*/
|
|
65
|
+
export declare function ensureAiBouleKey(deps: AiBouleKeyDeps, step: AiBouleStep): Promise<void>;
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { AI_BOULE_APPLIED_SCOPE_KEY, AI_BOULE_CLAMPED_SCOPE_KEY, AI_BOULE_ENV, AI_BOULE_EXPIRES_SCOPE_KEY, AI_BOULE_KEY_SCOPE_KEY, AI_BOULE_MINTER_SCOPE_KEY, AI_BOULE_PREVIOUS_SCOPE_KEY, AI_BOULE_RENEW_WINDOW_DAYS, AI_BOULE_UNDELIVERED_SCOPE_KEY, AI_BOULE_UNREVOKED_SCOPE_KEY, aiBouleKeyRef, parseIds, } from './ai-boule-key.js';
|
|
2
|
+
/** Every call this module makes (ai, held keys, Coolify) is cut off after this long. */
|
|
3
|
+
export const AI_BOULE_CALL_TIMEOUT_MS = 30_000;
|
|
4
|
+
/** How many times Boule's key is tried against the held keys. */
|
|
5
|
+
export const AI_BOULE_HOLD_ATTEMPTS = 3;
|
|
6
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
7
|
+
async function timed(work, ms) {
|
|
8
|
+
let timer;
|
|
9
|
+
try {
|
|
10
|
+
return await Promise.race([
|
|
11
|
+
work(),
|
|
12
|
+
new Promise((_, reject) => {
|
|
13
|
+
timer = setTimeout(() => reject(new Error('timeout')), ms);
|
|
14
|
+
}),
|
|
15
|
+
]);
|
|
16
|
+
}
|
|
17
|
+
finally {
|
|
18
|
+
clearTimeout(timer);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
const statusOf = (error) => {
|
|
22
|
+
const status = error?.status;
|
|
23
|
+
return typeof status === 'number' ? ` (${status})` : '';
|
|
24
|
+
};
|
|
25
|
+
const isNotFound = (error) => error?.status === 404;
|
|
26
|
+
/** Revokes one key; `false` when the port has no revoke or the call failed (a 404 counts as revoked). */
|
|
27
|
+
async function revoke(deps, id, ms) {
|
|
28
|
+
const call = deps.ai.revokeKey?.bind(deps.ai);
|
|
29
|
+
if (!call)
|
|
30
|
+
return false;
|
|
31
|
+
try {
|
|
32
|
+
await timed(() => call(deps.org.id, id), ms);
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
return isNotFound(error);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function listUnrevoked(deps, ids) {
|
|
40
|
+
if (ids.length === 0)
|
|
41
|
+
return;
|
|
42
|
+
const list = parseIds(deps.recorded()[AI_BOULE_UNREVOKED_SCOPE_KEY]);
|
|
43
|
+
for (const id of ids)
|
|
44
|
+
if (!list.includes(id))
|
|
45
|
+
list.push(id);
|
|
46
|
+
deps.record(AI_BOULE_UNREVOKED_SCOPE_KEY, JSON.stringify(list));
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Mints (or reuses the held) Boule key and writes `AI_URL`, `AI_ORGANISATION_ID` and `AI_API_KEY` to Boule's
|
|
50
|
+
* env. See `ai-boule-key.ts` for the order. Errors name the key id and the ref, never the key.
|
|
51
|
+
*/
|
|
52
|
+
export async function ensureAiBouleKey(deps, step) {
|
|
53
|
+
const ms = deps.timeoutMs ?? AI_BOULE_CALL_TIMEOUT_MS;
|
|
54
|
+
const now = deps.now ?? Date.now;
|
|
55
|
+
const at = `ai:boule-key for ${deps.org.slug}`;
|
|
56
|
+
const ref = aiBouleKeyRef(deps.org.id);
|
|
57
|
+
if (!deps.bouleAppId)
|
|
58
|
+
throw new Error(`${at}: Boule is not recorded yet`);
|
|
59
|
+
const bouleAppId = deps.bouleAppId;
|
|
60
|
+
const recorded = deps.recorded();
|
|
61
|
+
const undelivered = recorded[AI_BOULE_UNDELIVERED_SCOPE_KEY];
|
|
62
|
+
const guard = recorded[AI_BOULE_KEY_SCOPE_KEY];
|
|
63
|
+
if (undelivered && undelivered !== guard) {
|
|
64
|
+
throw new Error(`${at}: key ${undelivered} was minted but never held, and ai shows a key once; revoke it in ai and remove the recorded key ${AI_BOULE_UNDELIVERED_SCOPE_KEY}`);
|
|
65
|
+
}
|
|
66
|
+
const minterKeyId = recorded[`ai:key:${deps.org.id}`];
|
|
67
|
+
let key = null;
|
|
68
|
+
if (guard && step.mode === 'renew') {
|
|
69
|
+
// The key in Boule's env stays until the new one is written; it is revoked after.
|
|
70
|
+
const previous = parseIds(recorded[AI_BOULE_PREVIOUS_SCOPE_KEY]);
|
|
71
|
+
if (!previous.includes(guard))
|
|
72
|
+
previous.push(guard);
|
|
73
|
+
deps.record(AI_BOULE_PREVIOUS_SCOPE_KEY, JSON.stringify(previous));
|
|
74
|
+
deps.forget(AI_BOULE_KEY_SCOPE_KEY);
|
|
75
|
+
deps.forget(AI_BOULE_UNDELIVERED_SCOPE_KEY);
|
|
76
|
+
await deps.persist();
|
|
77
|
+
}
|
|
78
|
+
else if (guard) {
|
|
79
|
+
try {
|
|
80
|
+
key = (await timed(() => deps.heldKeys.resolve(ref), ms)) || null;
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
throw new Error(`${at}: the held key ${ref} could not be read`);
|
|
84
|
+
}
|
|
85
|
+
if (key) {
|
|
86
|
+
deps.secret(key);
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
// The held value is gone: the key Boule had is revoked (or listed) before a new one is minted.
|
|
90
|
+
if (!(await revoke(deps, guard, ms)))
|
|
91
|
+
listUnrevoked(deps, [guard]);
|
|
92
|
+
deps.forget(AI_BOULE_KEY_SCOPE_KEY);
|
|
93
|
+
deps.forget(AI_BOULE_UNDELIVERED_SCOPE_KEY);
|
|
94
|
+
await deps.persist();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (!key) {
|
|
98
|
+
if (!minterKeyId)
|
|
99
|
+
throw new Error(`${at}: the org's own ai key is not recorded yet`);
|
|
100
|
+
let minted;
|
|
101
|
+
try {
|
|
102
|
+
minted = await timed(() => deps.ai.mintNestedKey({
|
|
103
|
+
organisationId: deps.org.id,
|
|
104
|
+
minterKeyId,
|
|
105
|
+
name: step.name,
|
|
106
|
+
expiresDays: step.expiresDays,
|
|
107
|
+
permissions: [...step.permissions],
|
|
108
|
+
}), ms);
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
throw new Error(`${at}: ai refused or did not answer the key${statusOf(error)}`);
|
|
112
|
+
}
|
|
113
|
+
if (typeof minted?.secret === 'string' && minted.secret)
|
|
114
|
+
deps.secret(minted.secret);
|
|
115
|
+
if (typeof minted?.id !== 'string' || !minted.id) {
|
|
116
|
+
throw new Error(`${at}: ai answered the key with no id`);
|
|
117
|
+
}
|
|
118
|
+
deps.record(AI_BOULE_UNDELIVERED_SCOPE_KEY, minted.id);
|
|
119
|
+
await deps.persist();
|
|
120
|
+
if (typeof minted.secret !== 'string' || !minted.secret) {
|
|
121
|
+
throw new Error(`${at}: key ${minted.id} was minted but ai returned no key, which it shows once; revoke it in ai and remove the recorded key ${AI_BOULE_UNDELIVERED_SCOPE_KEY}`);
|
|
122
|
+
}
|
|
123
|
+
let stored = false;
|
|
124
|
+
for (let attempt = 1; attempt <= AI_BOULE_HOLD_ATTEMPTS && !stored; attempt++) {
|
|
125
|
+
try {
|
|
126
|
+
await timed(() => deps.heldKeys.put(ref, minted.secret), ms);
|
|
127
|
+
stored = true;
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
// Retried; the error is dropped in case it echoes the key.
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (!stored) {
|
|
134
|
+
throw new Error(`${at}: key ${minted.id} was minted but could not be held at ${ref} after ${AI_BOULE_HOLD_ATTEMPTS} attempts; revoke it in ai and remove the recorded key ${AI_BOULE_UNDELIVERED_SCOPE_KEY}`);
|
|
135
|
+
}
|
|
136
|
+
deps.record(AI_BOULE_KEY_SCOPE_KEY, minted.id);
|
|
137
|
+
deps.record(AI_BOULE_MINTER_SCOPE_KEY, minterKeyId);
|
|
138
|
+
deps.record(AI_BOULE_EXPIRES_SCOPE_KEY, minted.expiresAt);
|
|
139
|
+
// ai clamps the expiry to the org key's: one already inside the window is not renewed below the same key.
|
|
140
|
+
const clamped = !(Date.parse(minted.expiresAt) - now() >= AI_BOULE_RENEW_WINDOW_DAYS * DAY_MS);
|
|
141
|
+
deps.record(AI_BOULE_CLAMPED_SCOPE_KEY, clamped ? minterKeyId : '');
|
|
142
|
+
await deps.persist();
|
|
143
|
+
key = minted.secret;
|
|
144
|
+
}
|
|
145
|
+
try {
|
|
146
|
+
await timed(() => deps.coolify.bulkUpsertEnvs(bouleAppId, [
|
|
147
|
+
{ key: AI_BOULE_ENV.url, value: step.url, literal: true },
|
|
148
|
+
{ key: AI_BOULE_ENV.organisation, value: step.organisationId, literal: true },
|
|
149
|
+
{ key: AI_BOULE_ENV.key, value: key, literal: true },
|
|
150
|
+
]), ms);
|
|
151
|
+
await timed(() => deps.coolify.restart(bouleAppId), ms);
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
// The error is dropped in case Coolify echoes the request body, which holds the key.
|
|
155
|
+
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`);
|
|
156
|
+
}
|
|
157
|
+
deps.forget(AI_BOULE_UNDELIVERED_SCOPE_KEY);
|
|
158
|
+
deps.record(AI_BOULE_APPLIED_SCOPE_KEY, JSON.stringify({ url: step.url, organisationId: step.organisationId }));
|
|
159
|
+
// The keys a renewal replaced: Boule no longer holds them.
|
|
160
|
+
const previous = parseIds(deps.recorded()[AI_BOULE_PREVIOUS_SCOPE_KEY]);
|
|
161
|
+
if (previous.length > 0) {
|
|
162
|
+
const left = [];
|
|
163
|
+
for (const id of previous)
|
|
164
|
+
if (!(await revoke(deps, id, ms)))
|
|
165
|
+
left.push(id);
|
|
166
|
+
listUnrevoked(deps, left);
|
|
167
|
+
deps.forget(AI_BOULE_PREVIOUS_SCOPE_KEY);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
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#1122: Boule's ai key. Boule's AI page (boule#651) reads one app's month, days, tasks, runs and budget
|
|
6
|
+
* through ai's `/v1/apps/:app/*` routes (ai#190) and sets the app's budget, with a key in the company's own ai
|
|
7
|
+
* organisation. One `ai:boule-key` step, keyed by the org id, after Boule and after the org's own ai key:
|
|
8
|
+
*
|
|
9
|
+
* 1. mints a key below the org's own ai key (`ai:key:<orgId>`) through `POST /v1/keys` on the company's behalf
|
|
10
|
+
* (`mintNestedKey`), holding {@link AI_BOULE_PERMISSIONS} over the organisation and bound to no app; ai shows
|
|
11
|
+
* it once and clamps its expiry to the org key's;
|
|
12
|
+
* 2. records its id as `ai_boule_key_undelivered` and persists it before anything else;
|
|
13
|
+
* 3. holds the key at `ai:boule-key:<orgId>` (3 tries), then records the id as `ai_boule_key` (the held guard),
|
|
14
|
+
* the org key it is below as `ai_boule_key_minter` and its expiry, and persists them;
|
|
15
|
+
* 4. writes `AI_URL`, `AI_ORGANISATION_ID` and `AI_API_KEY` to Boule's env and restarts Boule;
|
|
16
|
+
* 5. only then clears the undelivered marker and records what it wrote as `ai_boule_key_applied` (no key).
|
|
17
|
+
*
|
|
18
|
+
* A marker equal to the guard is a delivery that did not finish: the step is planned again and reuses the held
|
|
19
|
+
* key. A marker without it (minted, never held) is blocked and never minted again. A guard whose held value is
|
|
20
|
+
* gone is repaired by the step itself: that key is revoked first (`revokeKey`, ai#157; without it, or when it
|
|
21
|
+
* fails, the id is listed as left live), then a new one is minted. A renewal (under 30 days left, or below an
|
|
22
|
+
* org key that was replaced) moves the guard to `ai_boule_key_previous` and persists that before it mints, so
|
|
23
|
+
* the old key stays in Boule's env until the new one is written, and is revoked after. A key ai clamped under
|
|
24
|
+
* the window by the same org key is not renewed again: the plan says the org key must be renewed first.
|
|
25
|
+
*
|
|
26
|
+
* **The company's organisation has to offer the five permissions first**, and the org's own key has to hold
|
|
27
|
+
* them, because a key below it can hold only what it holds. ai marks all five `offered: false` today (ai#170
|
|
28
|
+
* offers one). So all of it waits for the host's word (`aiBouleReads`): with it, `ai:organisation` offers
|
|
29
|
+
* {@link aiOffers} (recorded as `ai_offers_applied`), the org key is minted again with them when the recorded
|
|
30
|
+
* one holds fewer (`ai_key_permissions`), and Boule's key is minted below it. Without it nothing changes and
|
|
31
|
+
* the plan says why.
|
|
32
|
+
*
|
|
33
|
+
* The key reaches the held keys and Boule's env and nothing else: not a plan, a log, an error or a recorded id.
|
|
34
|
+
*/
|
|
35
|
+
/** What Boule's key holds over the company's organisation. */
|
|
36
|
+
export declare const AI_BOULE_PERMISSIONS: readonly ["ai.usage:read", "ai.runs:read", "ai.budgets:read", "ai.budgets:update", "ai.offerings:read"];
|
|
37
|
+
/** What every organisation is offered, and its own key holds, without Boule's reads. */
|
|
38
|
+
export declare const AI_DEFAULT_OFFERS: readonly ["ai.offerings:invoke", "ai.runs:create"];
|
|
39
|
+
/** The organisation's offers, and the permissions its own key is minted with. */
|
|
40
|
+
export declare function aiOffers(bouleReads: boolean): string[];
|
|
41
|
+
/** The offers `ai:organisation` last set, as JSON. */
|
|
42
|
+
export declare const AI_OFFERS_SCOPE_KEY = "ai_offers_applied";
|
|
43
|
+
/** The permissions the recorded org key was minted with, as JSON. Absent: the default offers it was always minted with. */
|
|
44
|
+
export declare const AI_KEY_PERMISSIONS_SCOPE_KEY = "ai_key_permissions";
|
|
45
|
+
/** Ids of org keys replaced by a wider one, as a JSON array. They stay live until they expire: ai has no revoke route yet. */
|
|
46
|
+
export declare const AI_KEY_PREVIOUS_SCOPE_KEY = "ai_key_previous";
|
|
47
|
+
/** The permissions the recorded org key holds. */
|
|
48
|
+
export declare function aiKeyPermissions(recordedIds: Readonly<Record<string, string>>): string;
|
|
49
|
+
/** Boule's env names. */
|
|
50
|
+
export declare const AI_BOULE_ENV: {
|
|
51
|
+
readonly url: "AI_URL";
|
|
52
|
+
readonly organisation: "AI_ORGANISATION_ID";
|
|
53
|
+
readonly key: "AI_API_KEY";
|
|
54
|
+
};
|
|
55
|
+
export declare const AI_BOULE_UNDELIVERED_SCOPE_KEY = "ai_boule_key_undelivered";
|
|
56
|
+
export declare const AI_BOULE_KEY_SCOPE_KEY = "ai_boule_key";
|
|
57
|
+
export declare const AI_BOULE_MINTER_SCOPE_KEY = "ai_boule_key_minter";
|
|
58
|
+
export declare const AI_BOULE_EXPIRES_SCOPE_KEY = "ai_boule_key_expires";
|
|
59
|
+
/** The minter of a key ai clamped under the renewal window: renewing again below it only repeats. */
|
|
60
|
+
export declare const AI_BOULE_CLAMPED_SCOPE_KEY = "ai_boule_key_clamped";
|
|
61
|
+
/** What Boule's env was last written with (`{ url, organisationId }`); never a key. */
|
|
62
|
+
export declare const AI_BOULE_APPLIED_SCOPE_KEY = "ai_boule_key_applied";
|
|
63
|
+
/** Ids of Boule keys a renewal replaced, as a JSON array, revoked once the new one is in Boule's env. */
|
|
64
|
+
export declare const AI_BOULE_PREVIOUS_SCOPE_KEY = "ai_boule_key_previous";
|
|
65
|
+
/** Ids left live because `revokeKey` is absent or failed, as a JSON array. They expire on their own; the plan lists each. */
|
|
66
|
+
export declare const AI_BOULE_UNREVOKED_SCOPE_KEY = "ai_boule_key_unrevoked";
|
|
67
|
+
export declare const aiBouleKeyRef: (orgId: string) => string;
|
|
68
|
+
export declare const aiBouleKeyName: (org: DeclaredOrg) => string;
|
|
69
|
+
/** How long Boule's key is asked for; ai clamps it to the org key's. */
|
|
70
|
+
export declare const AI_BOULE_KEY_DAYS = 365;
|
|
71
|
+
/** A key with fewer days left than this is renewed. */
|
|
72
|
+
export declare const AI_BOULE_RENEW_WINDOW_DAYS = 30;
|
|
73
|
+
export declare function parseIds(raw: string | undefined): string[];
|
|
74
|
+
export type AiBouleMode = 'mint' | 'renew' | 'deliver';
|
|
75
|
+
type Blocked = Array<{
|
|
76
|
+
description: string;
|
|
77
|
+
key: string;
|
|
78
|
+
}>;
|
|
79
|
+
/**
|
|
80
|
+
* Boule's key step, or nothing; nothing at all until the host says ai offers Boule's reads. Blocked, not
|
|
81
|
+
* planned (rule 2), while the held keys cannot hold the key, ai's address is not recorded, Boule is not recorded or planned, the
|
|
82
|
+
* org's own key does not hold the five permissions and is not being minted with them, or after a mint whose key
|
|
83
|
+
* was never held.
|
|
84
|
+
*/
|
|
85
|
+
export declare function planAiBouleKey(org: DeclaredOrg, recordedIds: Readonly<Record<string, string>>, plannedSteps: readonly DraftStep[], readiness: OrgPlatformReadiness, now: number, bouleReads: boolean): {
|
|
86
|
+
steps: DraftStep[];
|
|
87
|
+
blocked: Blocked;
|
|
88
|
+
};
|
|
89
|
+
export {};
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* foundry#1122: Boule's ai key. Boule's AI page (boule#651) reads one app's month, days, tasks, runs and budget
|
|
3
|
+
* through ai's `/v1/apps/:app/*` routes (ai#190) and sets the app's budget, with a key in the company's own ai
|
|
4
|
+
* organisation. One `ai:boule-key` step, keyed by the org id, after Boule and after the org's own ai key:
|
|
5
|
+
*
|
|
6
|
+
* 1. mints a key below the org's own ai key (`ai:key:<orgId>`) through `POST /v1/keys` on the company's behalf
|
|
7
|
+
* (`mintNestedKey`), holding {@link AI_BOULE_PERMISSIONS} over the organisation and bound to no app; ai shows
|
|
8
|
+
* it once and clamps its expiry to the org key's;
|
|
9
|
+
* 2. records its id as `ai_boule_key_undelivered` and persists it before anything else;
|
|
10
|
+
* 3. holds the key at `ai:boule-key:<orgId>` (3 tries), then records the id as `ai_boule_key` (the held guard),
|
|
11
|
+
* the org key it is below as `ai_boule_key_minter` and its expiry, and persists them;
|
|
12
|
+
* 4. writes `AI_URL`, `AI_ORGANISATION_ID` and `AI_API_KEY` to Boule's env and restarts Boule;
|
|
13
|
+
* 5. only then clears the undelivered marker and records what it wrote as `ai_boule_key_applied` (no key).
|
|
14
|
+
*
|
|
15
|
+
* A marker equal to the guard is a delivery that did not finish: the step is planned again and reuses the held
|
|
16
|
+
* key. A marker without it (minted, never held) is blocked and never minted again. A guard whose held value is
|
|
17
|
+
* gone is repaired by the step itself: that key is revoked first (`revokeKey`, ai#157; without it, or when it
|
|
18
|
+
* fails, the id is listed as left live), then a new one is minted. A renewal (under 30 days left, or below an
|
|
19
|
+
* org key that was replaced) moves the guard to `ai_boule_key_previous` and persists that before it mints, so
|
|
20
|
+
* the old key stays in Boule's env until the new one is written, and is revoked after. A key ai clamped under
|
|
21
|
+
* the window by the same org key is not renewed again: the plan says the org key must be renewed first.
|
|
22
|
+
*
|
|
23
|
+
* **The company's organisation has to offer the five permissions first**, and the org's own key has to hold
|
|
24
|
+
* them, because a key below it can hold only what it holds. ai marks all five `offered: false` today (ai#170
|
|
25
|
+
* offers one). So all of it waits for the host's word (`aiBouleReads`): with it, `ai:organisation` offers
|
|
26
|
+
* {@link aiOffers} (recorded as `ai_offers_applied`), the org key is minted again with them when the recorded
|
|
27
|
+
* one holds fewer (`ai_key_permissions`), and Boule's key is minted below it. Without it nothing changes and
|
|
28
|
+
* the plan says why.
|
|
29
|
+
*
|
|
30
|
+
* The key reaches the held keys and Boule's env and nothing else: not a plan, a log, an error or a recorded id.
|
|
31
|
+
*/
|
|
32
|
+
/** What Boule's key holds over the company's organisation. */
|
|
33
|
+
export const AI_BOULE_PERMISSIONS = [
|
|
34
|
+
'ai.usage:read',
|
|
35
|
+
'ai.runs:read',
|
|
36
|
+
'ai.budgets:read',
|
|
37
|
+
'ai.budgets:update',
|
|
38
|
+
'ai.offerings:read',
|
|
39
|
+
];
|
|
40
|
+
/** What every organisation is offered, and its own key holds, without Boule's reads. */
|
|
41
|
+
export const AI_DEFAULT_OFFERS = ['ai.offerings:invoke', 'ai.runs:create'];
|
|
42
|
+
/** The organisation's offers, and the permissions its own key is minted with. */
|
|
43
|
+
export function aiOffers(bouleReads) {
|
|
44
|
+
return bouleReads ? [...AI_DEFAULT_OFFERS, ...AI_BOULE_PERMISSIONS] : [...AI_DEFAULT_OFFERS];
|
|
45
|
+
}
|
|
46
|
+
/** The offers `ai:organisation` last set, as JSON. */
|
|
47
|
+
export const AI_OFFERS_SCOPE_KEY = 'ai_offers_applied';
|
|
48
|
+
/** The permissions the recorded org key was minted with, as JSON. Absent: the default offers it was always minted with. */
|
|
49
|
+
export const AI_KEY_PERMISSIONS_SCOPE_KEY = 'ai_key_permissions';
|
|
50
|
+
/** Ids of org keys replaced by a wider one, as a JSON array. They stay live until they expire: ai has no revoke route yet. */
|
|
51
|
+
export const AI_KEY_PREVIOUS_SCOPE_KEY = 'ai_key_previous';
|
|
52
|
+
/** The permissions the recorded org key holds. */
|
|
53
|
+
export function aiKeyPermissions(recordedIds) {
|
|
54
|
+
return recordedIds[AI_KEY_PERMISSIONS_SCOPE_KEY] || JSON.stringify(AI_DEFAULT_OFFERS);
|
|
55
|
+
}
|
|
56
|
+
/** Boule's env names. */
|
|
57
|
+
export const AI_BOULE_ENV = {
|
|
58
|
+
url: 'AI_URL',
|
|
59
|
+
organisation: 'AI_ORGANISATION_ID',
|
|
60
|
+
key: 'AI_API_KEY',
|
|
61
|
+
};
|
|
62
|
+
export const AI_BOULE_UNDELIVERED_SCOPE_KEY = 'ai_boule_key_undelivered';
|
|
63
|
+
export const AI_BOULE_KEY_SCOPE_KEY = 'ai_boule_key';
|
|
64
|
+
export const AI_BOULE_MINTER_SCOPE_KEY = 'ai_boule_key_minter';
|
|
65
|
+
export const AI_BOULE_EXPIRES_SCOPE_KEY = 'ai_boule_key_expires';
|
|
66
|
+
/** The minter of a key ai clamped under the renewal window: renewing again below it only repeats. */
|
|
67
|
+
export const AI_BOULE_CLAMPED_SCOPE_KEY = 'ai_boule_key_clamped';
|
|
68
|
+
/** What Boule's env was last written with (`{ url, organisationId }`); never a key. */
|
|
69
|
+
export const AI_BOULE_APPLIED_SCOPE_KEY = 'ai_boule_key_applied';
|
|
70
|
+
/** Ids of Boule keys a renewal replaced, as a JSON array, revoked once the new one is in Boule's env. */
|
|
71
|
+
export const AI_BOULE_PREVIOUS_SCOPE_KEY = 'ai_boule_key_previous';
|
|
72
|
+
/** Ids left live because `revokeKey` is absent or failed, as a JSON array. They expire on their own; the plan lists each. */
|
|
73
|
+
export const AI_BOULE_UNREVOKED_SCOPE_KEY = 'ai_boule_key_unrevoked';
|
|
74
|
+
export const aiBouleKeyRef = (orgId) => `ai:boule-key:${orgId}`;
|
|
75
|
+
export const aiBouleKeyName = (org) => `${org.slug}-boule`;
|
|
76
|
+
/** How long Boule's key is asked for; ai clamps it to the org key's. */
|
|
77
|
+
export const AI_BOULE_KEY_DAYS = 365;
|
|
78
|
+
/** A key with fewer days left than this is renewed. */
|
|
79
|
+
export const AI_BOULE_RENEW_WINDOW_DAYS = 30;
|
|
80
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
81
|
+
export function parseIds(raw) {
|
|
82
|
+
try {
|
|
83
|
+
const parsed = raw ? JSON.parse(raw) : [];
|
|
84
|
+
return Array.isArray(parsed) ? parsed.filter((v) => typeof v === 'string') : [];
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return [];
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Boule's key step, or nothing; nothing at all until the host says ai offers Boule's reads. Blocked, not
|
|
92
|
+
* planned (rule 2), while the held keys cannot hold the key, ai's address is not recorded, Boule is not recorded or planned, the
|
|
93
|
+
* org's own key does not hold the five permissions and is not being minted with them, or after a mint whose key
|
|
94
|
+
* was never held.
|
|
95
|
+
*/
|
|
96
|
+
export function planAiBouleKey(org, recordedIds, plannedSteps, readiness, now, bouleReads) {
|
|
97
|
+
const steps = [];
|
|
98
|
+
const blocked = [];
|
|
99
|
+
const key = `ai:boule-key:${org.id}`;
|
|
100
|
+
const at = `${org.slug}'s Boule ai key`;
|
|
101
|
+
const block = (why) => blocked.push({ description: `${at} is blocked: ${why}`, key });
|
|
102
|
+
const planned = (system, kind, k) => plannedSteps.some((p) => p.action.system === system && p.action.kind === kind && p.action.key === k);
|
|
103
|
+
for (const id of parseIds(recordedIds[AI_BOULE_UNREVOKED_SCOPE_KEY])) {
|
|
104
|
+
blocked.push({
|
|
105
|
+
description: `warning: ${org.slug}'s replaced Boule ai key ${id} stays live until it expires; it could not be revoked (https://github.com/wtfalch/ai/issues/157)`,
|
|
106
|
+
key: `${key}:unrevoked:${id}`,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
for (const id of parseIds(recordedIds[AI_KEY_PREVIOUS_SCOPE_KEY])) {
|
|
110
|
+
blocked.push({
|
|
111
|
+
description: `warning: ${org.slug}'s replaced own ai key ${id} stays live until it expires, with the entry keys minted below it; ai has no revoke route yet (https://github.com/wtfalch/ai/issues/157)`,
|
|
112
|
+
key: `ai:key:${org.id}:previous:${id}`,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
// `ai:organisation` reports an ai host that is not ready.
|
|
116
|
+
if (!readiness.aiReady)
|
|
117
|
+
return { steps, blocked };
|
|
118
|
+
const undelivered = recordedIds[AI_BOULE_UNDELIVERED_SCOPE_KEY];
|
|
119
|
+
const guard = recordedIds[AI_BOULE_KEY_SCOPE_KEY];
|
|
120
|
+
// Off until the host says ai offers the five permissions: a feature not turned on, not a blocked step, so
|
|
121
|
+
// every company's plan does not carry a line about it.
|
|
122
|
+
if (!bouleReads)
|
|
123
|
+
return { steps, blocked };
|
|
124
|
+
if (undelivered && undelivered !== guard) {
|
|
125
|
+
block(`key ${undelivered} was minted but never held, and ai shows a key once; revoke it in ai and remove the recorded key ${AI_BOULE_UNDELIVERED_SCOPE_KEY}`);
|
|
126
|
+
return { steps, blocked };
|
|
127
|
+
}
|
|
128
|
+
if (readiness.canHoldSecrets !== true) {
|
|
129
|
+
block("the host's held keys cannot hold Boule's key");
|
|
130
|
+
return { steps, blocked };
|
|
131
|
+
}
|
|
132
|
+
if (!readiness.aiUrl) {
|
|
133
|
+
block("the platform has not recorded ai's address yet");
|
|
134
|
+
return { steps, blocked };
|
|
135
|
+
}
|
|
136
|
+
if (!recordedIds[`coolify:boule:${org.id}`] && !planned('coolify', 'boule', org.id)) {
|
|
137
|
+
block("the company's Boule is not recorded yet");
|
|
138
|
+
return { steps, blocked };
|
|
139
|
+
}
|
|
140
|
+
const orgKey = recordedIds[`ai:key:${org.id}`];
|
|
141
|
+
const orgKeyPlanned = planned('ai', 'key', org.id);
|
|
142
|
+
const orgKeyHolds = Boolean(orgKey) && aiKeyPermissions(recordedIds) === JSON.stringify(aiOffers(true));
|
|
143
|
+
if (!orgKeyHolds && !orgKeyPlanned) {
|
|
144
|
+
block("the org's own ai key does not hold Boule's permissions yet, and is not being minted with them");
|
|
145
|
+
return { steps, blocked };
|
|
146
|
+
}
|
|
147
|
+
const want = { url: readiness.aiUrl, organisationId: org.id };
|
|
148
|
+
const previous = parseIds(recordedIds[AI_BOULE_PREVIOUS_SCOPE_KEY]);
|
|
149
|
+
const expiresRaw = recordedIds[AI_BOULE_EXPIRES_SCOPE_KEY];
|
|
150
|
+
const short = !(Date.parse(expiresRaw ?? '') - now >= AI_BOULE_RENEW_WINDOW_DAYS * DAY_MS);
|
|
151
|
+
const minter = recordedIds[AI_BOULE_MINTER_SCOPE_KEY];
|
|
152
|
+
let mode = null;
|
|
153
|
+
if (!guard) {
|
|
154
|
+
mode = 'mint';
|
|
155
|
+
}
|
|
156
|
+
else if (orgKeyPlanned || minter !== orgKey) {
|
|
157
|
+
mode = 'renew';
|
|
158
|
+
}
|
|
159
|
+
else if (short) {
|
|
160
|
+
if (recordedIds[AI_BOULE_CLAMPED_SCOPE_KEY] === orgKey) {
|
|
161
|
+
block(`the key expires ${expiresRaw || 'at an unknown time'} because the org's own ai key expires then; renew the org's own ai key, then this one is renewed`);
|
|
162
|
+
return { steps, blocked };
|
|
163
|
+
}
|
|
164
|
+
mode = 'renew';
|
|
165
|
+
}
|
|
166
|
+
else if (undelivered === guard ||
|
|
167
|
+
previous.length > 0 ||
|
|
168
|
+
recordedIds[AI_BOULE_APPLIED_SCOPE_KEY] !== JSON.stringify(want)) {
|
|
169
|
+
mode = 'deliver';
|
|
170
|
+
}
|
|
171
|
+
if (!mode)
|
|
172
|
+
return { steps, blocked };
|
|
173
|
+
steps.push({
|
|
174
|
+
action: {
|
|
175
|
+
system: 'ai',
|
|
176
|
+
kind: 'boule-key',
|
|
177
|
+
key: org.id,
|
|
178
|
+
desired: {
|
|
179
|
+
mode,
|
|
180
|
+
name: aiBouleKeyName(org),
|
|
181
|
+
expiresDays: AI_BOULE_KEY_DAYS,
|
|
182
|
+
permissions: [...AI_BOULE_PERMISSIONS],
|
|
183
|
+
...want,
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
step: {
|
|
187
|
+
description: mode === 'deliver'
|
|
188
|
+
? `write Boule's ai key for ${org.slug} to Boule's env, restart Boule`
|
|
189
|
+
: `${mode === 'mint' ? 'mint' : 'renew'} Boule's ai key for ${org.slug} below the org's own key, hold it, write AI_URL, AI_ORGANISATION_ID and AI_API_KEY to Boule's env, restart Boule`,
|
|
190
|
+
destroys: false,
|
|
191
|
+
migrates: false,
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
return { steps, blocked };
|
|
195
|
+
}
|
package/dist/plan/analytics.d.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { type DeclaredOrg } from '../org.js';
|
|
|
5
5
|
* entry declares `uses.analytics`, or the company's Boule runs apps (`org.boule.apps`).
|
|
6
6
|
*
|
|
7
7
|
* What the host (`apps/host`) reads to start: `DATABASE_URL` and `DATABASE_URL_OWNER` (written by
|
|
8
|
-
* `pushDatabaseUrl`), `ANALYTICS_OPERATOR_KEY` (generated once here) and `AUTH_ORGANIZATION_ID` (the company's
|
|
8
|
+
* `pushDatabaseUrl`), `ANALYTICS_OPERATOR_KEY` and `ANALYTICS_PERSON_KEY` (each generated once here) and `AUTH_ORGANIZATION_ID` (the company's
|
|
9
9
|
* sign-in organisation; the tenant id of every key and row derives from it, and without it minting answers 503
|
|
10
10
|
* and the release series answers `not_configured`). It reads no other `AUTH_*` name yet, so no sign-in client is
|
|
11
11
|
* made for it. When its screens need `AUTH_ISSUER` and `AUTH_CLIENT_ID`, add the client then.
|
|
@@ -22,6 +22,13 @@ export declare const ANALYTICS_DATABASE_SCOPE_KEY = "coolify_analytics_database"
|
|
|
22
22
|
export declare const ANALYTICS_DEPLOYED_SCOPE_KEY = "coolify_analytics_deployed";
|
|
23
23
|
/** The env name the backend reads its operator key from; also Boule's name for it (C2). */
|
|
24
24
|
export declare const ANALYTICS_OPERATOR_KEY_ENV = "ANALYTICS_OPERATOR_KEY";
|
|
25
|
+
/**
|
|
26
|
+
* The env name the backend derives each site's person key from (analytics#109, at least 32 characters). Made
|
|
27
|
+
* once, in the backend's own env only, and never rotated: a new value would count every signed-in person twice.
|
|
28
|
+
*/
|
|
29
|
+
export declare const ANALYTICS_PERSON_KEY_ENV = "ANALYTICS_PERSON_KEY";
|
|
30
|
+
/** 32 random bytes, written as 64 hex characters. */
|
|
31
|
+
export declare const ANALYTICS_PERSON_KEY_BYTES = 32;
|
|
25
32
|
/** The env names `desiredAnalyticsEnv` writes, so the live reader knows which to read back. */
|
|
26
33
|
export declare const ANALYTICS_MANAGED_ENV_KEYS: readonly ["APP_URL", "AUTH_ORGANIZATION_ID"];
|
|
27
34
|
export declare function desiredAnalyticsEnv(org: DeclaredOrg, recorded: Readonly<Record<string, string>>): {
|
package/dist/plan/analytics.js
CHANGED
|
@@ -6,7 +6,7 @@ import { ZITADEL_ORG_SCOPE_KEY } from './agora.js';
|
|
|
6
6
|
* entry declares `uses.analytics`, or the company's Boule runs apps (`org.boule.apps`).
|
|
7
7
|
*
|
|
8
8
|
* What the host (`apps/host`) reads to start: `DATABASE_URL` and `DATABASE_URL_OWNER` (written by
|
|
9
|
-
* `pushDatabaseUrl`), `ANALYTICS_OPERATOR_KEY` (generated once here) and `AUTH_ORGANIZATION_ID` (the company's
|
|
9
|
+
* `pushDatabaseUrl`), `ANALYTICS_OPERATOR_KEY` and `ANALYTICS_PERSON_KEY` (each generated once here) and `AUTH_ORGANIZATION_ID` (the company's
|
|
10
10
|
* sign-in organisation; the tenant id of every key and row derives from it, and without it minting answers 503
|
|
11
11
|
* and the release series answers `not_configured`). It reads no other `AUTH_*` name yet, so no sign-in client is
|
|
12
12
|
* made for it. When its screens need `AUTH_ISSUER` and `AUTH_CLIENT_ID`, add the client then.
|
|
@@ -25,6 +25,13 @@ export const ANALYTICS_DATABASE_SCOPE_KEY = 'coolify_analytics_database';
|
|
|
25
25
|
export const ANALYTICS_DEPLOYED_SCOPE_KEY = 'coolify_analytics_deployed';
|
|
26
26
|
/** The env name the backend reads its operator key from; also Boule's name for it (C2). */
|
|
27
27
|
export const ANALYTICS_OPERATOR_KEY_ENV = 'ANALYTICS_OPERATOR_KEY';
|
|
28
|
+
/**
|
|
29
|
+
* The env name the backend derives each site's person key from (analytics#109, at least 32 characters). Made
|
|
30
|
+
* once, in the backend's own env only, and never rotated: a new value would count every signed-in person twice.
|
|
31
|
+
*/
|
|
32
|
+
export const ANALYTICS_PERSON_KEY_ENV = 'ANALYTICS_PERSON_KEY';
|
|
33
|
+
/** 32 random bytes, written as 64 hex characters. */
|
|
34
|
+
export const ANALYTICS_PERSON_KEY_BYTES = 32;
|
|
28
35
|
/** The env names `desiredAnalyticsEnv` writes, so the live reader knows which to read back. */
|
|
29
36
|
export const ANALYTICS_MANAGED_ENV_KEYS = ['APP_URL', 'AUTH_ORGANIZATION_ID'];
|
|
30
37
|
export function desiredAnalyticsEnv(org, recorded) {
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { DnsPort } from './edge-dns.js';
|
|
2
|
+
import type { ZonePort } from './edge-zones.js';
|
|
3
|
+
import type { WorkersPort } from './executor.js';
|
|
4
|
+
import type { PlatformStepExecutionContext } from './plan-apply.js';
|
|
5
|
+
/**
|
|
6
|
+
* What the canary step needs, each built at edge's recorded address: the
|
|
7
|
+
* platform's Workers calls (the host builds them with `createEdgeWorkers` and
|
|
8
|
+
* a `fetchArtifact` that serves the package's own bytes,
|
|
9
|
+
* `fetchAppCanaryArtifact`), its zone lookup and its DNS calls. These are the
|
|
10
|
+
* calls a managed app's deploy makes: a Worker upload and a route on
|
|
11
|
+
* `<label>.companyincluded.app`, plus the proxied record edge's company route
|
|
12
|
+
* writes for its host. No Access call: the canary has no gate.
|
|
13
|
+
*/
|
|
14
|
+
export interface PlatformAppCanaryDeps {
|
|
15
|
+
workers: (edgeUrl: string) => WorkersPort | Promise<WorkersPort>;
|
|
16
|
+
zones: (edgeUrl: string) => Pick<ZonePort, 'findZoneByApex'> | Promise<Pick<ZonePort, 'findZoneByApex'>>;
|
|
17
|
+
dns: (edgeUrl: string) => DnsPort | Promise<DnsPort>;
|
|
18
|
+
/** `GET <url>` and its status. The global `fetch`, with no redirects followed, by default. */
|
|
19
|
+
probe?: (url: string) => Promise<number>;
|
|
20
|
+
/** Tries of the probe, {@link APP_CANARY_PROBE_ATTEMPTS} by default. */
|
|
21
|
+
probeAttempts?: number;
|
|
22
|
+
/** Waits between probes; a real timer by default. */
|
|
23
|
+
sleep?: (ms: number) => Promise<void>;
|
|
24
|
+
/** One outside call's limit in milliseconds; 60 s by default. */
|
|
25
|
+
callTimeoutMs?: number;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Why the canary cannot be planned yet, read live (rule 2), or `undefined`.
|
|
29
|
+
* A script, route or DNS record on the canary's name that foundry did not
|
|
30
|
+
* make is never taken over: the plan says so and plans nothing, rather than a
|
|
31
|
+
* step that throws on every apply. A missing apps zone is the same.
|
|
32
|
+
*/
|
|
33
|
+
export declare function appCanaryBlockedReason(deps: PlatformAppCanaryDeps | undefined, platform: {
|
|
34
|
+
appCanary?: {
|
|
35
|
+
enabled: boolean;
|
|
36
|
+
tag: string;
|
|
37
|
+
};
|
|
38
|
+
}, recordedIds: Readonly<Record<string, string>>): Promise<string | undefined>;
|
|
39
|
+
/**
|
|
40
|
+
* foundry#1133: deploys the canary on the managed-app layer and records the
|
|
41
|
+
* hostname Archon's uptime probes, last, once it answers 200. The script is
|
|
42
|
+
* recorded right after its upload (a later failure leaves a half-made canary
|
|
43
|
+
* foundry owns, which the next apply repairs); the record and the route are
|
|
44
|
+
* recorded as each is made. Disabled, it clears the hostname first, so Archon
|
|
45
|
+
* stops probing, then removes the route, the record and the script.
|
|
46
|
+
*/
|
|
47
|
+
export declare function provisionPlatformAppCanary(context: PlatformStepExecutionContext, deps: PlatformAppCanaryDeps): Promise<{
|
|
48
|
+
resourceId?: string;
|
|
49
|
+
}>;
|