@wtfalch/foundry 0.76.0 → 0.77.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/desktop-auth.d.ts +17 -4
- package/dist/plan/desktop-auth.js +22 -5
- 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 +676 -24
- 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,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADR 0003 (auth ADR 0008): what an entry whose customers and team both sign in was answered by the sign-in
|
|
3
|
+
* broker's registrar, as recorded. A leaf module: the readers of the app's sign-in env (`broker-entries.ts`,
|
|
4
|
+
* `workspace-sign-in.ts`) and the step itself (`registered-app.ts`) all read it here, so they agree.
|
|
5
|
+
*/
|
|
6
|
+
import { signsInBoth } from '../org.js';
|
|
7
|
+
/** The env names the broker's answer gives the app beside `AUTH_ISSUER` and `AUTH_APP_KEY`. */
|
|
8
|
+
export const REGISTERED_APP_ENV_NAMES = [
|
|
9
|
+
'AUTH_ORGANIZATION_ID',
|
|
10
|
+
'AUTH_CLIENT_ID',
|
|
11
|
+
'AUTH_TEAM_CLIENT_ID',
|
|
12
|
+
'AUTH_TEAM_ORGANIZATION_ID',
|
|
13
|
+
];
|
|
14
|
+
/** The team's two env names, which only an app both sign in to holds. */
|
|
15
|
+
export const TEAM_ENV_NAMES = ['AUTH_TEAM_CLIENT_ID', 'AUTH_TEAM_ORGANIZATION_ID'];
|
|
16
|
+
/**
|
|
17
|
+
* Scope id holding {@link RegisteredAppEnv} as JSON for one copy (`<entry>` live, `<entry>-work` the
|
|
18
|
+
* workspace), recorded once the app's env holds it.
|
|
19
|
+
*/
|
|
20
|
+
export const registeredAppEnvScopeKey = (clientKey) => `registered_app_env.${clientKey}`;
|
|
21
|
+
/** Scope id holding the registration last sent for one copy (JSON, no key); a different want plans the step again. */
|
|
22
|
+
export const registeredAppAppliedScopeKey = (clientKey) => `registered_app_applied.${clientKey}`;
|
|
23
|
+
/** The recorded answer of one copy, or `null` when none is recorded or it does not read as one. */
|
|
24
|
+
export function registeredAppEnv(recorded, clientKey) {
|
|
25
|
+
const raw = recorded[registeredAppEnvScopeKey(clientKey)];
|
|
26
|
+
if (!raw)
|
|
27
|
+
return null;
|
|
28
|
+
let parsed;
|
|
29
|
+
try {
|
|
30
|
+
parsed = JSON.parse(raw);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
if (typeof parsed !== 'object' || parsed === null)
|
|
36
|
+
return null;
|
|
37
|
+
const values = parsed;
|
|
38
|
+
for (const name of REGISTERED_APP_ENV_NAMES) {
|
|
39
|
+
if (typeof values[name] !== 'string' || values[name] === '')
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
AUTH_ORGANIZATION_ID: values.AUTH_ORGANIZATION_ID,
|
|
44
|
+
AUTH_CLIENT_ID: values.AUTH_CLIENT_ID,
|
|
45
|
+
AUTH_TEAM_CLIENT_ID: values.AUTH_TEAM_CLIENT_ID,
|
|
46
|
+
AUTH_TEAM_ORGANIZATION_ID: values.AUTH_TEAM_ORGANIZATION_ID,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
/** The client ids (the customers' and the team's) of the live copy of every entry both sign in to, as recorded. */
|
|
50
|
+
export function liveRegisteredClientIds(org, recorded) {
|
|
51
|
+
const clientIds = [];
|
|
52
|
+
const teamClientIds = [];
|
|
53
|
+
for (const entry of org.hosting.entries) {
|
|
54
|
+
if (!signsInBoth(entry.uses?.auth))
|
|
55
|
+
continue;
|
|
56
|
+
const env = registeredAppEnv(recorded, entry.key);
|
|
57
|
+
if (!env)
|
|
58
|
+
continue;
|
|
59
|
+
clientIds.push(env.AUTH_CLIENT_ID);
|
|
60
|
+
teamClientIds.push(env.AUTH_TEAM_CLIENT_ID);
|
|
61
|
+
}
|
|
62
|
+
return { clientIds: clientIds.sort(), teamClientIds: teamClientIds.sort() };
|
|
63
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { type DeclaredOrg, type HostingEntry } from '../org.js';
|
|
2
|
+
import type { UnmanagedItem } from '../plan.js';
|
|
3
|
+
import type { BrokerEntryInput } from './broker-entries.js';
|
|
4
|
+
import type { DraftStep } from './diff.js';
|
|
5
|
+
import { type RegisteredAppEnv } from './registered-app-env.js';
|
|
6
|
+
/**
|
|
7
|
+
* ADR 0003 (auth ADR 0008, wtfalch/foundry#1109): an entry whose customers and team both sign in
|
|
8
|
+
* (`uses.auth.users: ['customers', 'staff']`) is not an `AUTH_APPS` entry with a client foundry makes. It is
|
|
9
|
+
* registered at the sign-in broker through the company's registrar (`PUT <registrar>/apps/<organisation>/<name>`,
|
|
10
|
+
* the key foundry holds at `registrarRef(org.id)`), in the customers organisation, with
|
|
11
|
+
* `users: ['customers', 'staff']`. The broker makes both clients (the customers' in that organisation, the team's
|
|
12
|
+
* in the company's own) and answers the app's env, `AUTH_TEAM_CLIENT_ID` and `AUTH_TEAM_ORGANIZATION_ID`
|
|
13
|
+
* included. A workspace copy (`<key>-work`) is registered the same way in the test customers organisation, with
|
|
14
|
+
* its own two clients. The team signs in at the registrar record's `loginHost`, which is the company's own sign-in
|
|
15
|
+
* host, bound to the company's organisation; until that host's binding is applied the login pins the team's client
|
|
16
|
+
* to the company's organisation on the shared host.
|
|
17
|
+
*
|
|
18
|
+
* Every PUT mints a new `AUTH_APP_KEY` and the broker keeps the one it replaces valid for a window, so a repeat
|
|
19
|
+
* PUT is how a key that did not reach the app is delivered: there is nothing to record before the call.
|
|
20
|
+
*/
|
|
21
|
+
/** The step's kind: `coolify:registered-app`, keyed `<org id>:<entry>` (live) or `<org id>:<entry>-work`. */
|
|
22
|
+
export declare const REGISTERED_APP_KIND = "registered-app";
|
|
23
|
+
/** Milliseconds one registrar call may take. */
|
|
24
|
+
export declare const REGISTRAR_API_TIMEOUT_MS = 20000;
|
|
25
|
+
export type RegisteredCopy = 'live' | 'workspace';
|
|
26
|
+
/** The body of the registration: what `PUT /apps/:organizationId/:name` takes (auth ADR 0003, 0008). */
|
|
27
|
+
export interface RegisteredAppBody {
|
|
28
|
+
name: string;
|
|
29
|
+
origins: string[];
|
|
30
|
+
from: string;
|
|
31
|
+
registration: boolean;
|
|
32
|
+
users: ['customers', 'staff'];
|
|
33
|
+
/** Present when the entry declares `signIn`, as its broker entry would say. */
|
|
34
|
+
emailLink?: boolean;
|
|
35
|
+
}
|
|
36
|
+
/** What one copy's registration must say, without any key. Recorded as JSON once the app holds the answer. */
|
|
37
|
+
export interface RegisteredAppWant {
|
|
38
|
+
copy: RegisteredCopy;
|
|
39
|
+
/** `<entry>` or `<entry>-work`: the name of the recorded answer. */
|
|
40
|
+
clientKey: string;
|
|
41
|
+
/** The organisation the app is registered in: the customers organisation, or the test customers one. */
|
|
42
|
+
organizationId: string;
|
|
43
|
+
/** The company's own organisation, where the broker makes the team's client. */
|
|
44
|
+
teamOrganizationId: string;
|
|
45
|
+
/** The registered name, the path's last part. */
|
|
46
|
+
name: string;
|
|
47
|
+
/** The registrar record's login host: where the team signs in (the company's own sign-in host). */
|
|
48
|
+
loginHost: string;
|
|
49
|
+
body: RegisteredAppBody;
|
|
50
|
+
}
|
|
51
|
+
/** The registered name of a copy: the `AUTH_APPS` name it would have had. */
|
|
52
|
+
export declare const registeredAppName: (org: DeclaredOrg, clientKey: string) => string;
|
|
53
|
+
/** The copies an entry has: live while it is live, a workspace copy while it has a server workspace. */
|
|
54
|
+
export declare function registeredCopies(entry: HostingEntry): RegisteredCopy[];
|
|
55
|
+
export declare const copyClientKey: (entry: HostingEntry, copy: RegisteredCopy) => string;
|
|
56
|
+
/**
|
|
57
|
+
* Where the team of a company signs in: its own sign-in host once that host is bound to the company's
|
|
58
|
+
* organisation (an own org's sign-in applied, or a shared org's login-host step applied at it), else `null`:
|
|
59
|
+
* the login then pins the team's client to the company's organisation on the shared host.
|
|
60
|
+
*/
|
|
61
|
+
export declare function teamSignInHost(org: DeclaredOrg, recorded: Readonly<Record<string, string>>): string | null;
|
|
62
|
+
/** What one copy's registration says, or each value still missing and the step that records it. */
|
|
63
|
+
export declare function wantedRegisteredApp(org: DeclaredOrg, entry: HostingEntry, copy: RegisteredCopy, recorded: Readonly<Record<string, string>>): {
|
|
64
|
+
want: RegisteredAppWant;
|
|
65
|
+
} | {
|
|
66
|
+
missing: BrokerEntryInput[];
|
|
67
|
+
};
|
|
68
|
+
/**
|
|
69
|
+
* One `coolify:registered-app` step per copy of an entry both sign in to, after the copy's app (live: the entry's
|
|
70
|
+
* `coolify:app`; workspace: the stage the workspace step made) and once the company's registrar is at the broker.
|
|
71
|
+
* Idempotent on the recorded want. Blocked, not planned (rule 2), while the host has not wired the registrar port,
|
|
72
|
+
* the issuer is unknown, the registrar is neither applied nor planned, or a value is neither recorded nor produced
|
|
73
|
+
* by a step in this plan.
|
|
74
|
+
*/
|
|
75
|
+
export declare function planRegisteredApps(org: DeclaredOrg, recordedIds: Readonly<Record<string, string>>, plannedSteps: readonly DraftStep[], host: {
|
|
76
|
+
registrarApi: boolean;
|
|
77
|
+
authIssuer: string | null;
|
|
78
|
+
}): {
|
|
79
|
+
steps: DraftStep[];
|
|
80
|
+
blocked: UnmanagedItem[];
|
|
81
|
+
};
|
|
82
|
+
/** The company's registrar at the broker, as the step calls it. Foundry defines it; the host wires it. */
|
|
83
|
+
export interface RegistrarApiPort {
|
|
84
|
+
/**
|
|
85
|
+
* `PUT <url>` with `Authorization: Bearer <registrarKey>` and the JSON body; answers the status and the parsed
|
|
86
|
+
* JSON body (or `null`). Must not log the key, the body or the answer, which holds the app's key.
|
|
87
|
+
*/
|
|
88
|
+
putApp(input: {
|
|
89
|
+
url: string;
|
|
90
|
+
registrarKey: string;
|
|
91
|
+
body: RegisteredAppBody;
|
|
92
|
+
}): Promise<{
|
|
93
|
+
status: number;
|
|
94
|
+
body: unknown;
|
|
95
|
+
}>;
|
|
96
|
+
}
|
|
97
|
+
/** {@link RegistrarApiPort} over `fetch`, with a {@link REGISTRAR_API_TIMEOUT_MS} limit and no redirects. */
|
|
98
|
+
export declare function httpRegistrarApi(options?: {
|
|
99
|
+
fetch?: typeof fetch;
|
|
100
|
+
timeoutMs?: number;
|
|
101
|
+
}): RegistrarApiPort;
|
|
102
|
+
/** `<registrar>/apps/<organisation>/<name>`, each part encoded. */
|
|
103
|
+
export declare const registeredAppUrl: (registrarUrl: string, want: RegisteredAppWant) => string;
|
|
104
|
+
/**
|
|
105
|
+
* Sends one copy's registration and reads the answer. Answers the ids to record and the app's key, which is
|
|
106
|
+
* registered for redaction (`secret`) before it is returned. The answer must be for the organisations asked for,
|
|
107
|
+
* carry the key twice and name two different clients, neither of them one of `refusedClientIds` (a workspace copy
|
|
108
|
+
* passes the live ones). Every error is fixed text with the status at most: never the key or the answer.
|
|
109
|
+
*/
|
|
110
|
+
export declare function sendRegistration(input: {
|
|
111
|
+
want: RegisteredAppWant;
|
|
112
|
+
registrarUrl: string;
|
|
113
|
+
registrarKey: string;
|
|
114
|
+
api: RegistrarApiPort;
|
|
115
|
+
secret: (value: string) => void;
|
|
116
|
+
refusedClientIds?: readonly string[];
|
|
117
|
+
label: string;
|
|
118
|
+
}): Promise<{
|
|
119
|
+
env: RegisteredAppEnv;
|
|
120
|
+
appKey: string;
|
|
121
|
+
}>;
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { isLiveEntry, ownsServices, signsInBoth, } from '../org.js';
|
|
2
|
+
import { ZITADEL_ORG_SCOPE_KEY } from './agora.js';
|
|
3
|
+
import { bgKey } from './blue-green.js';
|
|
4
|
+
import { REGISTRAR_APPLIED_SCOPE_KEY } from './broker-registrar.js';
|
|
5
|
+
import { entryHostnames, workspaceHost } from './hostnames.js';
|
|
6
|
+
import { loginHostnameFor, ownAuthApplied } from './login-host.js';
|
|
7
|
+
import { appStepKey } from './platform-diff.js';
|
|
8
|
+
import { REGISTERED_APP_ENV_NAMES, registeredAppAppliedScopeKey, } from './registered-app-env.js';
|
|
9
|
+
import { testCustomersOrgKey, workspaceClientKey } from './workspace-client.js';
|
|
10
|
+
import { isServerWorkspace } from './workspace.js';
|
|
11
|
+
/**
|
|
12
|
+
* ADR 0003 (auth ADR 0008, wtfalch/foundry#1109): an entry whose customers and team both sign in
|
|
13
|
+
* (`uses.auth.users: ['customers', 'staff']`) is not an `AUTH_APPS` entry with a client foundry makes. It is
|
|
14
|
+
* registered at the sign-in broker through the company's registrar (`PUT <registrar>/apps/<organisation>/<name>`,
|
|
15
|
+
* the key foundry holds at `registrarRef(org.id)`), in the customers organisation, with
|
|
16
|
+
* `users: ['customers', 'staff']`. The broker makes both clients (the customers' in that organisation, the team's
|
|
17
|
+
* in the company's own) and answers the app's env, `AUTH_TEAM_CLIENT_ID` and `AUTH_TEAM_ORGANIZATION_ID`
|
|
18
|
+
* included. A workspace copy (`<key>-work`) is registered the same way in the test customers organisation, with
|
|
19
|
+
* its own two clients. The team signs in at the registrar record's `loginHost`, which is the company's own sign-in
|
|
20
|
+
* host, bound to the company's organisation; until that host's binding is applied the login pins the team's client
|
|
21
|
+
* to the company's organisation on the shared host.
|
|
22
|
+
*
|
|
23
|
+
* Every PUT mints a new `AUTH_APP_KEY` and the broker keeps the one it replaces valid for a window, so a repeat
|
|
24
|
+
* PUT is how a key that did not reach the app is delivered: there is nothing to record before the call.
|
|
25
|
+
*/
|
|
26
|
+
/** The step's kind: `coolify:registered-app`, keyed `<org id>:<entry>` (live) or `<org id>:<entry>-work`. */
|
|
27
|
+
export const REGISTERED_APP_KIND = 'registered-app';
|
|
28
|
+
/** Milliseconds one registrar call may take. */
|
|
29
|
+
export const REGISTRAR_API_TIMEOUT_MS = 20_000;
|
|
30
|
+
/** The registered name of a copy: the `AUTH_APPS` name it would have had. */
|
|
31
|
+
export const registeredAppName = (org, clientKey) => `${org.slug}-${clientKey}`;
|
|
32
|
+
/** The copies an entry has: live while it is live, a workspace copy while it has a server workspace. */
|
|
33
|
+
export function registeredCopies(entry) {
|
|
34
|
+
if (!signsInBoth(entry.uses?.auth))
|
|
35
|
+
return [];
|
|
36
|
+
return [
|
|
37
|
+
...(isLiveEntry(entry) ? ['live'] : []),
|
|
38
|
+
...(isServerWorkspace(entry) ? ['workspace'] : []),
|
|
39
|
+
];
|
|
40
|
+
}
|
|
41
|
+
export const copyClientKey = (entry, copy) => copy === 'live' ? entry.key : workspaceClientKey(entry.key);
|
|
42
|
+
/**
|
|
43
|
+
* Where the team of a company signs in: its own sign-in host once that host is bound to the company's
|
|
44
|
+
* organisation (an own org's sign-in applied, or a shared org's login-host step applied at it), else `null`:
|
|
45
|
+
* the login then pins the team's client to the company's organisation on the shared host.
|
|
46
|
+
*/
|
|
47
|
+
export function teamSignInHost(org, recorded) {
|
|
48
|
+
const host = loginHostnameFor(org);
|
|
49
|
+
if (ownsServices(org))
|
|
50
|
+
return ownAuthApplied(org, recorded) ? host : null;
|
|
51
|
+
return recorded[`zitadel:login-host:${org.id}`] === host ? host : null;
|
|
52
|
+
}
|
|
53
|
+
/** What one copy's registration says, or each value still missing and the step that records it. */
|
|
54
|
+
export function wantedRegisteredApp(org, entry, copy, recorded) {
|
|
55
|
+
const auth = entry.uses?.auth;
|
|
56
|
+
const clientKey = copyClientKey(entry, copy);
|
|
57
|
+
const organizationId = copy === 'live'
|
|
58
|
+
? recorded[`zitadel:customers:${org.id}`]
|
|
59
|
+
: recorded[testCustomersOrgKey(org.id)];
|
|
60
|
+
const teamOrganizationId = recorded[ZITADEL_ORG_SCOPE_KEY];
|
|
61
|
+
const mailDomain = recorded.mail_domain;
|
|
62
|
+
const origins = copy === 'live'
|
|
63
|
+
? entryHostnames(org, entry).map((host) => `https://${host}`)
|
|
64
|
+
: [`https://${workspaceHost(org.slug, entry.key)}`];
|
|
65
|
+
const missing = [];
|
|
66
|
+
if (!organizationId) {
|
|
67
|
+
missing.push(copy === 'live'
|
|
68
|
+
? { what: 'the customers organisation id', producedBy: ['zitadel', 'customers'] }
|
|
69
|
+
: { what: 'the test customers organisation id', producedBy: ['zitadel', 'customers-test'] });
|
|
70
|
+
}
|
|
71
|
+
if (!teamOrganizationId) {
|
|
72
|
+
missing.push({ what: "the org's ZITADEL org id", producedBy: ['zitadel', 'issuer'] });
|
|
73
|
+
}
|
|
74
|
+
if (!mailDomain)
|
|
75
|
+
missing.push({ what: 'the org mail domain', producedBy: ['coolify', 'mail'] });
|
|
76
|
+
if (origins.length === 0) {
|
|
77
|
+
missing.push({ what: 'its public address', producedBy: ['coolify', 'app'] });
|
|
78
|
+
}
|
|
79
|
+
if (!auth || !organizationId || !teamOrganizationId || !mailDomain || origins.length === 0) {
|
|
80
|
+
return { missing };
|
|
81
|
+
}
|
|
82
|
+
const name = registeredAppName(org, clientKey);
|
|
83
|
+
return {
|
|
84
|
+
want: {
|
|
85
|
+
copy,
|
|
86
|
+
clientKey,
|
|
87
|
+
organizationId,
|
|
88
|
+
teamOrganizationId,
|
|
89
|
+
name,
|
|
90
|
+
loginHost: loginHostnameFor(org),
|
|
91
|
+
body: {
|
|
92
|
+
name,
|
|
93
|
+
origins,
|
|
94
|
+
from: `${org.mail.noReplyLocalPart || 'noreply'}@${mailDomain}`,
|
|
95
|
+
registration: auth.registration === 'open',
|
|
96
|
+
users: ['customers', 'staff'],
|
|
97
|
+
...(auth.signIn ? { emailLink: auth.signIn === 'email-code' } : {}),
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* One `coolify:registered-app` step per copy of an entry both sign in to, after the copy's app (live: the entry's
|
|
104
|
+
* `coolify:app`; workspace: the stage the workspace step made) and once the company's registrar is at the broker.
|
|
105
|
+
* Idempotent on the recorded want. Blocked, not planned (rule 2), while the host has not wired the registrar port,
|
|
106
|
+
* the issuer is unknown, the registrar is neither applied nor planned, or a value is neither recorded nor produced
|
|
107
|
+
* by a step in this plan.
|
|
108
|
+
*/
|
|
109
|
+
export function planRegisteredApps(org, recordedIds, plannedSteps, host) {
|
|
110
|
+
const steps = [];
|
|
111
|
+
const blocked = [];
|
|
112
|
+
const planned = (system, kind, key) => plannedSteps.some((p) => p.action.system === system &&
|
|
113
|
+
p.action.kind === kind &&
|
|
114
|
+
(key === undefined || p.action.key === key));
|
|
115
|
+
for (const entry of org.hosting.entries) {
|
|
116
|
+
for (const copy of registeredCopies(entry)) {
|
|
117
|
+
const clientKey = copyClientKey(entry, copy);
|
|
118
|
+
const key = appStepKey(org, clientKey);
|
|
119
|
+
const at = `${org.slug}'s ${clientKey} registration at the sign-in broker`;
|
|
120
|
+
const block = (why) => blocked.push({
|
|
121
|
+
description: `${at} is blocked, not planned: ${why}`,
|
|
122
|
+
key: `coolify:${REGISTERED_APP_KIND}:${clientKey}`,
|
|
123
|
+
});
|
|
124
|
+
if (!host.registrarApi) {
|
|
125
|
+
block('the host has not wired the registrar API port (registrarApi)');
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (!host.authIssuer) {
|
|
129
|
+
block('the sign-in issuer is not known yet');
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (!recordedIds[REGISTRAR_APPLIED_SCOPE_KEY] && !planned('coolify', 'broker-registrar')) {
|
|
133
|
+
block("the company's registrar is not at the sign-in broker yet");
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
const appReady = copy === 'live'
|
|
137
|
+
? Boolean(recordedIds[`coolify:app:${appStepKey(org, entry.key)}`]) ||
|
|
138
|
+
planned('coolify', 'app', appStepKey(org, entry.key))
|
|
139
|
+
: recordedIds[bgKey.stageState(entry.key)] === 'ok' ||
|
|
140
|
+
plannedSteps.some((p) => p.action.system === 'coolify' &&
|
|
141
|
+
p.action.kind === 'workspace' &&
|
|
142
|
+
p.action.desired.entry === entry.key);
|
|
143
|
+
if (!appReady) {
|
|
144
|
+
block(copy === 'live' ? 'its app is not made yet' : "the workspace's app is not made yet");
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
// An app made again holds no key the broker gave: its copy is registered again.
|
|
148
|
+
const appRecorded = copy === 'live'
|
|
149
|
+
? Boolean(recordedIds[`coolify:app:${appStepKey(org, entry.key)}`])
|
|
150
|
+
: recordedIds[bgKey.stageState(entry.key)] === 'ok';
|
|
151
|
+
const wanted = wantedRegisteredApp(org, entry, copy, recordedIds);
|
|
152
|
+
if ('want' in wanted) {
|
|
153
|
+
if (appRecorded &&
|
|
154
|
+
recordedIds[registeredAppAppliedScopeKey(clientKey)] === JSON.stringify(wanted.want)) {
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
const unplanned = wanted.missing.filter(({ producedBy: [system, kind] }) => !planned(system, kind));
|
|
160
|
+
if (unplanned.length > 0) {
|
|
161
|
+
block(`${unplanned.map((m) => m.what).join(', ')} not recorded yet`);
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
const where = teamSignInHost(org, recordedIds);
|
|
166
|
+
steps.push({
|
|
167
|
+
action: {
|
|
168
|
+
system: 'coolify',
|
|
169
|
+
kind: REGISTERED_APP_KIND,
|
|
170
|
+
key,
|
|
171
|
+
desired: { copy, entry: entry.key, authIssuer: host.authIssuer },
|
|
172
|
+
},
|
|
173
|
+
step: {
|
|
174
|
+
description: `register ${org.slug}'s ${clientKey} at the sign-in broker for its customers and its team (the team signs in at ${where ?? `the shared sign-in host, pinned to ${org.slug}'s organisation`}), and give the app its sign-in env`,
|
|
175
|
+
destroys: false,
|
|
176
|
+
migrates: false,
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return { steps, blocked };
|
|
182
|
+
}
|
|
183
|
+
/** {@link RegistrarApiPort} over `fetch`, with a {@link REGISTRAR_API_TIMEOUT_MS} limit and no redirects. */
|
|
184
|
+
export function httpRegistrarApi(options = {}) {
|
|
185
|
+
return {
|
|
186
|
+
async putApp({ url, registrarKey, body }) {
|
|
187
|
+
const res = await (options.fetch ?? fetch)(url, {
|
|
188
|
+
method: 'PUT',
|
|
189
|
+
headers: {
|
|
190
|
+
Authorization: `Bearer ${registrarKey}`,
|
|
191
|
+
'Content-Type': 'application/json',
|
|
192
|
+
Accept: 'application/json',
|
|
193
|
+
},
|
|
194
|
+
body: JSON.stringify(body),
|
|
195
|
+
redirect: 'error',
|
|
196
|
+
signal: AbortSignal.timeout(options.timeoutMs ?? REGISTRAR_API_TIMEOUT_MS),
|
|
197
|
+
});
|
|
198
|
+
return { status: res.status, body: await res.json().catch(() => null) };
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
/** `<registrar>/apps/<organisation>/<name>`, each part encoded. */
|
|
203
|
+
export const registeredAppUrl = (registrarUrl, want) => `${registrarUrl.replace(/\/+$/, '')}/apps/${encodeURIComponent(want.organizationId)}/${encodeURIComponent(want.name)}`;
|
|
204
|
+
/**
|
|
205
|
+
* Sends one copy's registration and reads the answer. Answers the ids to record and the app's key, which is
|
|
206
|
+
* registered for redaction (`secret`) before it is returned. The answer must be for the organisations asked for,
|
|
207
|
+
* carry the key twice and name two different clients, neither of them one of `refusedClientIds` (a workspace copy
|
|
208
|
+
* passes the live ones). Every error is fixed text with the status at most: never the key or the answer.
|
|
209
|
+
*/
|
|
210
|
+
export async function sendRegistration(input) {
|
|
211
|
+
const { want, label } = input;
|
|
212
|
+
let answer;
|
|
213
|
+
try {
|
|
214
|
+
answer = await input.api.putApp({
|
|
215
|
+
url: registeredAppUrl(input.registrarUrl, want),
|
|
216
|
+
registrarKey: input.registrarKey,
|
|
217
|
+
body: want.body,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
throw new Error(`${label}: the sign-in broker did not answer the registration; apply again`);
|
|
222
|
+
}
|
|
223
|
+
if (answer.status !== 200) {
|
|
224
|
+
throw new Error(`${label}: the sign-in broker refused the registration (status ${answer.status})`);
|
|
225
|
+
}
|
|
226
|
+
const body = answer.body;
|
|
227
|
+
const key = body?.key;
|
|
228
|
+
const env = body?.env;
|
|
229
|
+
if (typeof key !== 'string' || key.length < 32 || typeof env !== 'object' || env === null) {
|
|
230
|
+
throw new Error(`${label}: the sign-in broker's answer has no key or env`);
|
|
231
|
+
}
|
|
232
|
+
input.secret(key);
|
|
233
|
+
const values = env;
|
|
234
|
+
if (values.AUTH_APP_KEY !== key) {
|
|
235
|
+
throw new Error(`${label}: the sign-in broker's answer is not for the key it gave`);
|
|
236
|
+
}
|
|
237
|
+
for (const name of REGISTERED_APP_ENV_NAMES) {
|
|
238
|
+
if (typeof values[name] !== 'string' || values[name] === '') {
|
|
239
|
+
throw new Error(`${label}: the sign-in broker's answer has no ${name}`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
const answered = {
|
|
243
|
+
AUTH_ORGANIZATION_ID: values.AUTH_ORGANIZATION_ID,
|
|
244
|
+
AUTH_CLIENT_ID: values.AUTH_CLIENT_ID,
|
|
245
|
+
AUTH_TEAM_CLIENT_ID: values.AUTH_TEAM_CLIENT_ID,
|
|
246
|
+
AUTH_TEAM_ORGANIZATION_ID: values.AUTH_TEAM_ORGANIZATION_ID,
|
|
247
|
+
};
|
|
248
|
+
if (answered.AUTH_ORGANIZATION_ID !== want.organizationId) {
|
|
249
|
+
throw new Error(`${label}: the sign-in broker's answer is for another organisation`);
|
|
250
|
+
}
|
|
251
|
+
if (answered.AUTH_TEAM_ORGANIZATION_ID !== want.teamOrganizationId) {
|
|
252
|
+
throw new Error(`${label}: the sign-in broker's answer puts the team in another organisation than the company's own`);
|
|
253
|
+
}
|
|
254
|
+
if (answered.AUTH_CLIENT_ID === answered.AUTH_TEAM_CLIENT_ID) {
|
|
255
|
+
throw new Error(`${label}: the sign-in broker's answer gives the customers and the team one client`);
|
|
256
|
+
}
|
|
257
|
+
const refused = input.refusedClientIds ?? [];
|
|
258
|
+
if (refused.includes(answered.AUTH_CLIENT_ID) || refused.includes(answered.AUTH_TEAM_CLIENT_ID)) {
|
|
259
|
+
throw new Error(`${label}: the sign-in broker's answer names a client of the live app`);
|
|
260
|
+
}
|
|
261
|
+
return { env: answered, appKey: key };
|
|
262
|
+
}
|
|
@@ -7,6 +7,7 @@ import { applyBlueGreenWeights, copiedEnv } from './blue-green-apply.js';
|
|
|
7
7
|
import { ACTIONS_KEY_ENV, DEFAULT_WEIGHTS, LB_COLOR_HEADER, LB_PIN_HEADER, bgKey, formatWeights, mintPinKey, parseWeights, readPinKey, reportingEnv, } from './blue-green.js';
|
|
8
8
|
import { waitForDeploymentFinished, waitForNewDeployment, } from './deploy-health.js';
|
|
9
9
|
import { isNotFoundError } from './executor.js';
|
|
10
|
+
import { isColorReportingEnv } from './reporting.js';
|
|
10
11
|
import { runStageCopyRequest } from './stage-copy-host.js';
|
|
11
12
|
const COMMIT = /^[0-9a-f]{40}$/;
|
|
12
13
|
const APP_RELEASE_ENV = ['APP_TAG', 'APP_RELEASE', 'APP_MINIMUM_RELEASE'];
|
|
@@ -172,7 +173,7 @@ export function createReleaseHost(input) {
|
|
|
172
173
|
if (env.uuid &&
|
|
173
174
|
!keep.has(env.key) &&
|
|
174
175
|
(appId !== input.primaryBlueAppId || env.key === 'APP_MINIMUM_RELEASE') &&
|
|
175
|
-
!env.key
|
|
176
|
+
!isColorReportingEnv(env.key) &&
|
|
176
177
|
env.key !== ACTIONS_KEY_ENV) {
|
|
177
178
|
await coolify.deleteEnv(appId, env.uuid);
|
|
178
179
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type DeclaredOrg } from '../org.js';
|
|
2
|
+
import type { DraftStep } from './diff.js';
|
|
3
|
+
/**
|
|
4
|
+
* While the company's Boule runs apps (`org.boule.apps`) and its reporting backend is applied, Boule's env
|
|
5
|
+
* holds `REPORTING_URL` and `REPORTING_OPERATOR_KEY` (the two names Boule's errors reader reads, wtfalch/boule
|
|
6
|
+
* PR 571). Boule mints a site key for each app copy with the operator key (site = the app key, environment
|
|
7
|
+
* `production` for live and `stage` for the workspace) and reads the app's errors with it. The key is read from
|
|
8
|
+
* the backend's own env and written to Boule's env; it is never held, recorded or planned. Without the backend
|
|
9
|
+
* Boule installs apps without error reporting.
|
|
10
|
+
*
|
|
11
|
+
* One `coolify:reporting-boule` step keyed by the org id. `reporting_boule_applied` holds
|
|
12
|
+
* `<address>|<backend app id>` and is recorded last, so a new backend app (a new key) writes again. Turning
|
|
13
|
+
* apps off takes both names out of Boule's env.
|
|
14
|
+
*/
|
|
15
|
+
export declare const REPORTING_BOULE_APPLIED_SCOPE_KEY = "reporting_boule_applied";
|
|
16
|
+
export declare const REPORTING_BOULE_ENV_NAMES: readonly ["REPORTING_URL", "REPORTING_OPERATOR_KEY"];
|
|
17
|
+
export declare const reportingBouleMarker: (org: DeclaredOrg, backendAppId: string) => string;
|
|
18
|
+
export declare function planReportingBoule(org: DeclaredOrg, recordedIds: Readonly<Record<string, string>>, plannedSteps: readonly DraftStep[]): DraftStep[];
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { appsOn } from '../org.js';
|
|
2
|
+
import { REPORTING_DEPLOYED_SCOPE_KEY, reportingUrlFor } from './reporting.js';
|
|
3
|
+
/**
|
|
4
|
+
* While the company's Boule runs apps (`org.boule.apps`) and its reporting backend is applied, Boule's env
|
|
5
|
+
* holds `REPORTING_URL` and `REPORTING_OPERATOR_KEY` (the two names Boule's errors reader reads, wtfalch/boule
|
|
6
|
+
* PR 571). Boule mints a site key for each app copy with the operator key (site = the app key, environment
|
|
7
|
+
* `production` for live and `stage` for the workspace) and reads the app's errors with it. The key is read from
|
|
8
|
+
* the backend's own env and written to Boule's env; it is never held, recorded or planned. Without the backend
|
|
9
|
+
* Boule installs apps without error reporting.
|
|
10
|
+
*
|
|
11
|
+
* One `coolify:reporting-boule` step keyed by the org id. `reporting_boule_applied` holds
|
|
12
|
+
* `<address>|<backend app id>` and is recorded last, so a new backend app (a new key) writes again. Turning
|
|
13
|
+
* apps off takes both names out of Boule's env.
|
|
14
|
+
*/
|
|
15
|
+
export const REPORTING_BOULE_APPLIED_SCOPE_KEY = 'reporting_boule_applied';
|
|
16
|
+
export const REPORTING_BOULE_ENV_NAMES = ['REPORTING_URL', 'REPORTING_OPERATOR_KEY'];
|
|
17
|
+
export const reportingBouleMarker = (org, backendAppId) => `${reportingUrlFor(org)}|${backendAppId}`;
|
|
18
|
+
export function planReportingBoule(org, recordedIds, plannedSteps) {
|
|
19
|
+
const draft = (mode, description) => ({
|
|
20
|
+
action: { system: 'coolify', kind: 'reporting-boule', key: org.id, desired: { mode } },
|
|
21
|
+
step: { description, destroys: mode === 'retire', migrates: false },
|
|
22
|
+
});
|
|
23
|
+
const planned = (kind) => plannedSteps.some((p) => p.action.system === 'coolify' && p.action.kind === kind && p.action.key === org.id);
|
|
24
|
+
const bouleKnown = Boolean(recordedIds[`coolify:boule:${org.id}`]) || planned('boule');
|
|
25
|
+
const marker = recordedIds[REPORTING_BOULE_APPLIED_SCOPE_KEY];
|
|
26
|
+
if (!appsOn(org)) {
|
|
27
|
+
if (!marker || !recordedIds[`coolify:boule:${org.id}`])
|
|
28
|
+
return [];
|
|
29
|
+
return [
|
|
30
|
+
draft('retire', `take the reporting address and key out of ${org.slug}'s Boule env (the company's Boule runs no apps)`),
|
|
31
|
+
];
|
|
32
|
+
}
|
|
33
|
+
if (!bouleKnown)
|
|
34
|
+
return [];
|
|
35
|
+
const backendApp = recordedIds[`coolify:reporting:${org.id}`];
|
|
36
|
+
const applied = Boolean(backendApp) && Boolean(recordedIds[REPORTING_DEPLOYED_SCOPE_KEY]);
|
|
37
|
+
if (!applied && !planned('reporting'))
|
|
38
|
+
return [];
|
|
39
|
+
if (applied && marker === reportingBouleMarker(org, backendApp))
|
|
40
|
+
return [];
|
|
41
|
+
return [
|
|
42
|
+
draft('write', `write REPORTING_URL and REPORTING_OPERATOR_KEY to ${org.slug}'s Boule env, restart Boule`),
|
|
43
|
+
];
|
|
44
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { type DeclaredOrg } from '../org.js';
|
|
2
|
+
/**
|
|
3
|
+
* A company's own reporting backend (https://github.com/wtfalch/reporting, ADR 0002, `docs/plans/backend.md`
|
|
4
|
+
* slice 5): one Coolify app and one database per company, built from that repo's root Dockerfile, the same shape
|
|
5
|
+
* as the analytics backend (`plan/analytics.ts`). It is planned only while something uses it: a hosting entry
|
|
6
|
+
* declares `uses.reporting`, or the company's Boule runs apps (`org.boule.apps`).
|
|
7
|
+
*
|
|
8
|
+
* What the host (`apps/host`) reads to start: `DATABASE_URL` and `DATABASE_URL_OWNER` (written by
|
|
9
|
+
* `pushDatabaseUrl`), `REPORTING_OPERATOR_KEY` (generated once here), `AUTH_ORGANIZATION_ID` (the company's
|
|
10
|
+
* sign-in organisation; the tenant id of every key and row derives from it, and without it minting answers 503)
|
|
11
|
+
* and `APP_URL`. It reads no other `AUTH_*` name yet, so no sign-in client is made for it. When its screens need
|
|
12
|
+
* `AUTH_ISSUER` and `AUTH_CLIENT_ID`, add the client then.
|
|
13
|
+
*/
|
|
14
|
+
/** True while some entry declares `uses.reporting`, or Boule runs apps: the backend is wanted. */
|
|
15
|
+
export declare const reportingWanted: (org: Pick<DeclaredOrg, "boule" | "hosting">) => boolean;
|
|
16
|
+
/** `reporting.<domain>`, or `<slug>-reporting.oikos.host` for a company without a domain. */
|
|
17
|
+
export declare function reportingHostnameFor(org: Pick<DeclaredOrg, 'domain' | 'slug'>): string;
|
|
18
|
+
/** The public address apps and Boule send to, `REPORTING_URL`. */
|
|
19
|
+
export declare const reportingUrlFor: (org: Pick<DeclaredOrg, "domain" | "slug">) => string;
|
|
20
|
+
/** The backend's database uuid, recorded beside the app's own id. */
|
|
21
|
+
export declare const REPORTING_DATABASE_SCOPE_KEY = "coolify_reporting_database";
|
|
22
|
+
/** Recorded once the app was deployed, as `coolify_boule_deployed` is for Boule. */
|
|
23
|
+
export declare const REPORTING_DEPLOYED_SCOPE_KEY = "coolify_reporting_deployed";
|
|
24
|
+
/** The env name the backend reads its operator key from; also Boule's name for it. */
|
|
25
|
+
export declare const REPORTING_OPERATOR_KEY_ENV = "REPORTING_OPERATOR_KEY";
|
|
26
|
+
/** The env names `desiredReportingEnv` writes, so the live reader knows which to read back. */
|
|
27
|
+
export declare const REPORTING_MANAGED_ENV_KEYS: readonly ["APP_URL", "AUTH_ORGANIZATION_ID", "MAIL_FROM"];
|
|
28
|
+
/**
|
|
29
|
+
* The Lettermint token the backend sends error alerts with (reporting#103): the company's `<slug>-app` token,
|
|
30
|
+
* joined as a consumer like a `uses.mail` entry. A secret: the executor writes it, the live reader says only
|
|
31
|
+
* whether it is there (`config.mailTokenHeld`), and no plan carries its value.
|
|
32
|
+
*/
|
|
33
|
+
export declare const REPORTING_MAIL_TOKEN_ENV = "LETTERMINT_API_TOKEN";
|
|
34
|
+
/**
|
|
35
|
+
* The backend's sender, `Name <address>`: the company's display name and the same no-reply address the main
|
|
36
|
+
* app and Boule send from. The name is one line, and quoted when it holds a character an address header
|
|
37
|
+
* gives a meaning to.
|
|
38
|
+
*/
|
|
39
|
+
export declare function reportingMailFrom(org: DeclaredOrg, mailDomain: string): string;
|
|
40
|
+
/**
|
|
41
|
+
* The `REPORTING_*` names foundry's reporting steps write: an entry's address and site key, and Boule's
|
|
42
|
+
* address and operator key. Every other `REPORTING_*` name on a blue/green color is the color's own
|
|
43
|
+
* (`REPORTING_COLOR`, `REPORTING_RELEASE`, `REPORTING_HEALTH_TOKEN`), so these are the ones a color copies from
|
|
44
|
+
* blue like any other env.
|
|
45
|
+
*/
|
|
46
|
+
export declare const REPORTING_SHARED_ENV_NAMES: readonly string[];
|
|
47
|
+
/** Whether a `REPORTING_*` name belongs to one blue/green color and is never copied or removed by the copy. */
|
|
48
|
+
export declare const isColorReportingEnv: (key: string) => boolean;
|
|
49
|
+
/**
|
|
50
|
+
* What the backend's env must hold. `mailDomain` (the company's recorded mail domain, given only while its
|
|
51
|
+
* mail is on and not blocked) adds `MAIL_FROM`; the token is never here.
|
|
52
|
+
*/
|
|
53
|
+
export declare function desiredReportingEnv(org: DeclaredOrg, recorded: Readonly<Record<string, string>>, mailDomain?: string | null): {
|
|
54
|
+
env: Record<string, string>;
|
|
55
|
+
missing: string[];
|
|
56
|
+
};
|
|
57
|
+
export declare function reportingEnvDrift(desired: Readonly<Record<string, string>>, live: Readonly<Record<string, string>>): string[];
|
|
58
|
+
export type ReportingEnvironment = 'production' | 'stage';
|
|
59
|
+
/** The backend's operator API. Every call carries the company's own base URL and operator key. */
|
|
60
|
+
export interface ReportingPort {
|
|
61
|
+
/**
|
|
62
|
+
* Mints a site key bound to `site` and `environment`; the secret is shown once. The same `name` again
|
|
63
|
+
* replaces the older key.
|
|
64
|
+
*/
|
|
65
|
+
mintSiteKey(input: {
|
|
66
|
+
baseUrl: string;
|
|
67
|
+
operatorKey: string;
|
|
68
|
+
name: string;
|
|
69
|
+
site: string;
|
|
70
|
+
environment: ReportingEnvironment;
|
|
71
|
+
}): Promise<{
|
|
72
|
+
id: string;
|
|
73
|
+
secret: string;
|
|
74
|
+
}>;
|
|
75
|
+
/** Revokes a key. An unknown id is not an error. */
|
|
76
|
+
revokeSiteKey(input: {
|
|
77
|
+
baseUrl: string;
|
|
78
|
+
operatorKey: string;
|
|
79
|
+
keyId: string;
|
|
80
|
+
}): Promise<void>;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* The real {@link ReportingPort}: `POST <baseUrl>/v1/site-keys` with `{ name, site, environment }` answers 201
|
|
84
|
+
* `{ id, secret, site, environment }`; `DELETE <baseUrl>/v1/site-keys/<id>` answers 204. A 201 that binds the
|
|
85
|
+
* key to another site or environment than asked is refused, so a key never reports where Boule does not read.
|
|
86
|
+
* Errors carry the status only, never a body, the key or the secret.
|
|
87
|
+
*/
|
|
88
|
+
export declare function httpReporting(options?: {
|
|
89
|
+
fetch?: typeof fetch;
|
|
90
|
+
}): ReportingPort;
|