@wtfalch/foundry 0.23.0 → 0.25.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.js +6 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.js +1 -1
- package/dist/migrations/0048_hosting_gate_limit.sql +7 -0
- package/dist/migrations/0049_platform_uptime.sql +11 -0
- package/dist/org.d.ts +27 -23
- package/dist/org.js +73 -29
- package/dist/plan/diff.js +36 -28
- package/dist/plan/edge-access.d.ts +48 -1
- package/dist/plan/edge-access.js +51 -2
- package/dist/plan/edge-workers.d.ts +16 -1
- package/dist/plan/edge-workers.js +8 -0
- package/dist/plan/executor.d.ts +50 -17
- package/dist/plan/executor.js +93 -47
- package/dist/plan/gates.d.ts +42 -0
- package/dist/plan/gates.js +126 -0
- package/dist/plan/health-targets.js +3 -4
- package/dist/plan/index.d.ts +2 -1
- package/dist/plan/plan-apply.d.ts +6 -0
- package/dist/plan/plan-apply.js +8 -1
- package/dist/plan/platform-diff.d.ts +102 -0
- package/dist/plan/platform-diff.js +288 -0
- package/dist/plan/platform-org.d.ts +2 -0
- package/dist/plan/platform-org.js +4 -0
- package/dist/plan/platform-providers.d.ts +47 -0
- package/dist/plan/platform-providers.js +271 -14
- package/dist/plan/uptime-worker.d.ts +50 -0
- package/dist/plan/uptime-worker.js +229 -0
- package/dist/platform.d.ts +18 -0
- package/dist/platform.js +14 -0
- package/dist/store/postgres.js +31 -5
- package/dist/store/schema.d.ts +68 -0
- package/dist/store/schema.js +6 -0
- package/package.json +2 -2
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { randomBytes, randomUUID } from 'node:crypto';
|
|
2
|
+
import { EdgeApiError } from '@wtfalch/edge';
|
|
3
|
+
import { uptimeArtifactUrl } from '../platform.js';
|
|
4
|
+
import { PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_UPTIME_ACCESS_CLIENT_ID_REF, PLATFORM_UPTIME_ACCESS_CLIENT_SECRET_REF, PLATFORM_UPTIME_ACCESS_TOKEN_ID_SCOPE_KEY, PLATFORM_UPTIME_ACCESS_TOKEN_NAME, PLATFORM_UPTIME_ACCESS_UNDELIVERED_SCOPE_KEY, PLATFORM_UPTIME_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_UPTIME_APPLIED_SECRETS_SCOPE_KEY, PLATFORM_UPTIME_APPLIED_SHA256_SCOPE_KEY, PLATFORM_UPTIME_APPLIED_TAG_SCOPE_KEY, PLATFORM_UPTIME_APPLIED_VARS_SCOPE_KEY, PLATFORM_UPTIME_HEARTBEAT_URL_REF, PLATFORM_UPTIME_KEY, PLATFORM_UPTIME_KV_TITLE, PLATFORM_UPTIME_OPTIONAL_SECRETS, PLATFORM_UPTIME_REMOVED_SCOPE_KEY, PLATFORM_UPTIME_SCRIPT_NAME, PLATFORM_UPTIME_TOKEN_ID_SCOPE_KEY, PLATFORM_UPTIME_TOKEN_REF, PLATFORM_UPTIME_TOKEN_UNDELIVERED_SCOPE_KEY, uptimeAccessTokenConflict, uptimeSecretNames, uptimeVarsDigest, } from './platform-diff.js';
|
|
5
|
+
/** The Worker's cron trigger, from Archon's `workers/uptime/manifest.json`. */
|
|
6
|
+
const UPTIME_CRONS = ['* * * * *'];
|
|
7
|
+
/** The date Archon's own Worker config used. */
|
|
8
|
+
const UPTIME_COMPATIBILITY_DATE = '2026-09-27';
|
|
9
|
+
/** How many times a delivery or a secrets write is tried before the step fails. */
|
|
10
|
+
const UPTIME_ATTEMPTS = 3;
|
|
11
|
+
/** The optional plain text variables the Worker reads, supplied by the host (not secrets). */
|
|
12
|
+
export const UPTIME_OPTIONAL_VARS = [
|
|
13
|
+
'CHAT_HOMESERVER_URL',
|
|
14
|
+
'CHAT_ROOM_ID',
|
|
15
|
+
'ALERT_EMAIL_FROM',
|
|
16
|
+
'ALERT_EMAIL_TO',
|
|
17
|
+
];
|
|
18
|
+
/**
|
|
19
|
+
* What is held now: the optional secrets, and `UPTIME_HEARTBEAT_URL` (a ping
|
|
20
|
+
* URL that works as a capability, so a secret too), name to value. Callers
|
|
21
|
+
* that report or record take the names only.
|
|
22
|
+
*/
|
|
23
|
+
export async function heldUptimeValues(deps) {
|
|
24
|
+
const refs = {
|
|
25
|
+
...PLATFORM_UPTIME_OPTIONAL_SECRETS,
|
|
26
|
+
UPTIME_HEARTBEAT_URL: PLATFORM_UPTIME_HEARTBEAT_URL_REF,
|
|
27
|
+
};
|
|
28
|
+
const held = {};
|
|
29
|
+
for (const [name, ref] of Object.entries(refs)) {
|
|
30
|
+
const value = await deps.heldKeys.resolve(ref);
|
|
31
|
+
if (value)
|
|
32
|
+
held[name] = value;
|
|
33
|
+
}
|
|
34
|
+
return held;
|
|
35
|
+
}
|
|
36
|
+
/** Retries `task`; the cause is dropped in case it echoes a secret, and the error names `what` only. */
|
|
37
|
+
async function attempt(what, task) {
|
|
38
|
+
for (let n = 1; n <= UPTIME_ATTEMPTS; n++) {
|
|
39
|
+
try {
|
|
40
|
+
await task();
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
// Retried; the error is dropped.
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
throw new Error(`platform:uptime: ${what} failed after ${UPTIME_ATTEMPTS} attempts`);
|
|
48
|
+
}
|
|
49
|
+
/** Removes an Access token edge made; one already gone is success. */
|
|
50
|
+
async function removeAccessToken(access, id, what) {
|
|
51
|
+
try {
|
|
52
|
+
await access.removeServiceToken(id);
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
if (error instanceof EdgeApiError && error.code === 'not_found')
|
|
56
|
+
return;
|
|
57
|
+
// The cause is dropped; edge's text is not ours to pass on.
|
|
58
|
+
throw new Error(`platform:uptime: ${what} ${id} could not be removed through edge`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* foundry#542: makes the uptime Worker true, in an order where every failure
|
|
63
|
+
* is repaired by the next apply and none needs a hand edit. Upload the pinned
|
|
64
|
+
* Archon release (the host fetches and verifies it) and record the script at
|
|
65
|
+
* once, so a later failure is a half-made Worker foundry owns. Then hold the
|
|
66
|
+
* Worker token and the Access pair (minted once each, each guard recorded as
|
|
67
|
+
* soon as its value is held), set every held secret on the Worker, and last
|
|
68
|
+
* the cron trigger, so the Worker never runs a round without its token. The
|
|
69
|
+
* applied values are recorded only when all of it succeeded.
|
|
70
|
+
*/
|
|
71
|
+
export async function provisionPlatformUptime(context, deps) {
|
|
72
|
+
const { platform } = context;
|
|
73
|
+
const { uptime } = platform;
|
|
74
|
+
const recorded = context.recordedIds();
|
|
75
|
+
const edgeUrl = recorded[PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY];
|
|
76
|
+
if (!edgeUrl) {
|
|
77
|
+
throw new Error(`platform:uptime: edge has not been applied yet (no recorded ${PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY}); the Worker is managed through it`);
|
|
78
|
+
}
|
|
79
|
+
const workers = await deps.workers(edgeUrl);
|
|
80
|
+
if (!uptime.enabled) {
|
|
81
|
+
// Whatever of a (possibly half-made) deploy exists is removed; what does not exist is tolerated.
|
|
82
|
+
await workers.deleteScript(PLATFORM_UPTIME_SCRIPT_NAME);
|
|
83
|
+
const accessTokenId = recorded[PLATFORM_UPTIME_ACCESS_TOKEN_ID_SCOPE_KEY] ||
|
|
84
|
+
recorded[PLATFORM_UPTIME_ACCESS_UNDELIVERED_SCOPE_KEY];
|
|
85
|
+
if (accessTokenId) {
|
|
86
|
+
await removeAccessToken(await deps.access(edgeUrl), accessTokenId, 'Access token');
|
|
87
|
+
}
|
|
88
|
+
for (const ref of [
|
|
89
|
+
PLATFORM_UPTIME_TOKEN_REF,
|
|
90
|
+
PLATFORM_UPTIME_ACCESS_CLIENT_ID_REF,
|
|
91
|
+
PLATFORM_UPTIME_ACCESS_CLIENT_SECRET_REF,
|
|
92
|
+
]) {
|
|
93
|
+
await attempt('clearing a held uptime value', () => deps.deliverSecret(ref, ''));
|
|
94
|
+
}
|
|
95
|
+
for (const key of [
|
|
96
|
+
PLATFORM_UPTIME_ACCESS_TOKEN_ID_SCOPE_KEY,
|
|
97
|
+
PLATFORM_UPTIME_ACCESS_UNDELIVERED_SCOPE_KEY,
|
|
98
|
+
PLATFORM_UPTIME_TOKEN_ID_SCOPE_KEY,
|
|
99
|
+
PLATFORM_UPTIME_TOKEN_UNDELIVERED_SCOPE_KEY,
|
|
100
|
+
]) {
|
|
101
|
+
context.recordScopeId(key, '');
|
|
102
|
+
}
|
|
103
|
+
context.recordScopeId(PLATFORM_UPTIME_REMOVED_SCOPE_KEY, '1');
|
|
104
|
+
return {};
|
|
105
|
+
}
|
|
106
|
+
if (!uptime.tag || !uptime.sha256) {
|
|
107
|
+
throw new Error('platform:uptime: no artifact is pinned (uptime.tag, uptime.sha256)');
|
|
108
|
+
}
|
|
109
|
+
const hostname = platform.archon.hostname;
|
|
110
|
+
const held = await heldUptimeValues(deps);
|
|
111
|
+
const bindings = [
|
|
112
|
+
{ type: 'kv_namespace', name: 'UPTIME_KV', title: PLATFORM_UPTIME_KV_TITLE },
|
|
113
|
+
{ type: 'plain_text', name: 'ARCHON_BASE_URL', text: `https://${hostname}` },
|
|
114
|
+
...UPTIME_OPTIONAL_VARS.flatMap((name) => {
|
|
115
|
+
const text = deps.vars?.[name];
|
|
116
|
+
return text ? [{ type: 'plain_text', name, text }] : [];
|
|
117
|
+
}),
|
|
118
|
+
];
|
|
119
|
+
await workers.uploadScript({
|
|
120
|
+
name: PLATFORM_UPTIME_SCRIPT_NAME,
|
|
121
|
+
artifactUrl: uptimeArtifactUrl(uptime.tag),
|
|
122
|
+
artifactSha256: uptime.sha256,
|
|
123
|
+
compatibilityDate: UPTIME_COMPATIBILITY_DATE,
|
|
124
|
+
bindings,
|
|
125
|
+
});
|
|
126
|
+
// The script is foundry's from here on. Recorded now, not at the end: after
|
|
127
|
+
// a later failure the next plan must repair this Worker, not take it for a
|
|
128
|
+
// foreign one. "Fully applied" is the applied values recorded at the end.
|
|
129
|
+
context.recordScopeId(`cloudflare:worker:${PLATFORM_UPTIME_KEY}`, PLATFORM_UPTIME_SCRIPT_NAME);
|
|
130
|
+
context.recordScopeId(PLATFORM_UPTIME_REMOVED_SCOPE_KEY, '');
|
|
131
|
+
await context.persist?.();
|
|
132
|
+
// The Worker token. It is generated here and registered nowhere else until
|
|
133
|
+
// it is held (the Worker and Archon get it from the held value, later), so a
|
|
134
|
+
// marker without its guard means the value never left this process, and a
|
|
135
|
+
// guard with nothing held means it is gone: either way a new one is
|
|
136
|
+
// generated and the old id is dropped. Nothing is blocked and nothing needs
|
|
137
|
+
// a hand edit.
|
|
138
|
+
let tokenId = recorded[PLATFORM_UPTIME_TOKEN_ID_SCOPE_KEY];
|
|
139
|
+
let token = tokenId ? await deps.heldKeys.resolve(PLATFORM_UPTIME_TOKEN_REF) : null;
|
|
140
|
+
if (!token) {
|
|
141
|
+
const newId = randomUUID();
|
|
142
|
+
const minted = randomBytes(32).toString('hex');
|
|
143
|
+
context.recordScopeId(PLATFORM_UPTIME_TOKEN_UNDELIVERED_SCOPE_KEY, newId);
|
|
144
|
+
await context.persist?.();
|
|
145
|
+
await attempt(`holding token ${newId}`, () => deps.deliverSecret(PLATFORM_UPTIME_TOKEN_REF, minted));
|
|
146
|
+
// Guard as soon as the value is held.
|
|
147
|
+
context.recordScopeId(PLATFORM_UPTIME_TOKEN_ID_SCOPE_KEY, newId);
|
|
148
|
+
context.recordScopeId(PLATFORM_UPTIME_TOKEN_UNDELIVERED_SCOPE_KEY, '');
|
|
149
|
+
await context.persist?.();
|
|
150
|
+
tokenId = newId;
|
|
151
|
+
token = minted;
|
|
152
|
+
}
|
|
153
|
+
// The Access service token pair, minted through edge. The id is recorded
|
|
154
|
+
// and persisted before the pair is held, the guard once both are held. A
|
|
155
|
+
// pair that may be lost (marker without guard, or guard with nothing held)
|
|
156
|
+
// is repaired here: edge made that token, so it is removed through edge and
|
|
157
|
+
// minted again, never two live tokens. A token of the name with no id
|
|
158
|
+
// recorded is not ours to remove: edge answers `conflict` and the planner
|
|
159
|
+
// reports it as blocked; the throw here is only a race with that report.
|
|
160
|
+
let accessId = recorded[PLATFORM_UPTIME_ACCESS_TOKEN_ID_SCOPE_KEY];
|
|
161
|
+
let clientId = accessId
|
|
162
|
+
? await deps.heldKeys.resolve(PLATFORM_UPTIME_ACCESS_CLIENT_ID_REF)
|
|
163
|
+
: null;
|
|
164
|
+
let clientSecret = accessId
|
|
165
|
+
? await deps.heldKeys.resolve(PLATFORM_UPTIME_ACCESS_CLIENT_SECRET_REF)
|
|
166
|
+
: null;
|
|
167
|
+
if (!accessId || !clientId || !clientSecret) {
|
|
168
|
+
const access = await deps.access(edgeUrl);
|
|
169
|
+
const lost = accessId || recorded[PLATFORM_UPTIME_ACCESS_UNDELIVERED_SCOPE_KEY];
|
|
170
|
+
if (lost) {
|
|
171
|
+
await removeAccessToken(access, lost, 'lost Access token');
|
|
172
|
+
context.recordScopeId(PLATFORM_UPTIME_ACCESS_TOKEN_ID_SCOPE_KEY, '');
|
|
173
|
+
context.recordScopeId(PLATFORM_UPTIME_ACCESS_UNDELIVERED_SCOPE_KEY, '');
|
|
174
|
+
await context.persist?.();
|
|
175
|
+
}
|
|
176
|
+
let minted;
|
|
177
|
+
try {
|
|
178
|
+
minted = await access.createServiceToken(PLATFORM_UPTIME_ACCESS_TOKEN_NAME);
|
|
179
|
+
}
|
|
180
|
+
catch (error) {
|
|
181
|
+
if (error instanceof EdgeApiError && error.code === 'conflict') {
|
|
182
|
+
throw new Error(`platform:uptime: ${uptimeAccessTokenConflict()}`);
|
|
183
|
+
}
|
|
184
|
+
throw error;
|
|
185
|
+
}
|
|
186
|
+
context.recordScopeId(PLATFORM_UPTIME_ACCESS_UNDELIVERED_SCOPE_KEY, minted.id);
|
|
187
|
+
await context.persist?.();
|
|
188
|
+
if (!minted.clientId || !minted.clientSecret) {
|
|
189
|
+
await removeAccessToken(access, minted.id, 'incomplete Access token');
|
|
190
|
+
context.recordScopeId(PLATFORM_UPTIME_ACCESS_UNDELIVERED_SCOPE_KEY, '');
|
|
191
|
+
await context.persist?.();
|
|
192
|
+
throw new Error(`platform:uptime: edge returned Access token ${minted.id} without a client id or secret; it was removed`);
|
|
193
|
+
}
|
|
194
|
+
const newId = minted.id;
|
|
195
|
+
const idToHold = minted.clientId;
|
|
196
|
+
const secretToHold = minted.clientSecret;
|
|
197
|
+
await attempt(`holding Access token ${newId} client id`, () => deps.deliverSecret(PLATFORM_UPTIME_ACCESS_CLIENT_ID_REF, idToHold));
|
|
198
|
+
await attempt(`holding Access token ${newId} client secret`, () => deps.deliverSecret(PLATFORM_UPTIME_ACCESS_CLIENT_SECRET_REF, secretToHold));
|
|
199
|
+
context.recordScopeId(PLATFORM_UPTIME_ACCESS_TOKEN_ID_SCOPE_KEY, newId);
|
|
200
|
+
context.recordScopeId(PLATFORM_UPTIME_ACCESS_UNDELIVERED_SCOPE_KEY, '');
|
|
201
|
+
await context.persist?.();
|
|
202
|
+
accessId = newId;
|
|
203
|
+
clientId = idToHold;
|
|
204
|
+
clientSecret = secretToHold;
|
|
205
|
+
}
|
|
206
|
+
// Set on the Worker from the held values, every time this step runs. The
|
|
207
|
+
// held secrets' version label is the pinned digest, so a new release sets
|
|
208
|
+
// them again. The held-key reader exposes no per-value id or update time,
|
|
209
|
+
// so a value rotated at the same pin is not seen; the minted ones carry
|
|
210
|
+
// their id.
|
|
211
|
+
const version = `pin-${uptime.sha256.slice(0, 12)}`;
|
|
212
|
+
const secrets = [
|
|
213
|
+
{ name: 'UPTIME_TOKEN', value: token, version: tokenId },
|
|
214
|
+
{ name: 'CF_ACCESS_CLIENT_ID', value: clientId, version: accessId },
|
|
215
|
+
{ name: 'CF_ACCESS_CLIENT_SECRET', value: clientSecret, version: accessId },
|
|
216
|
+
// A ping URL works as a capability: a secret, not a plain text binding.
|
|
217
|
+
...Object.entries(held).map(([name, value]) => ({ name, value, version })),
|
|
218
|
+
];
|
|
219
|
+
await attempt(`setting the Worker's secrets (${secrets.map((s) => s.name).join(', ')})`, () => workers.setSecrets(PLATFORM_UPTIME_SCRIPT_NAME, secrets));
|
|
220
|
+
// After the secrets: the Worker never runs a round without its token.
|
|
221
|
+
await workers.setSchedules(PLATFORM_UPTIME_SCRIPT_NAME, UPTIME_CRONS);
|
|
222
|
+
// Last, after every write the applied values depend on.
|
|
223
|
+
context.recordScopeId(PLATFORM_UPTIME_APPLIED_TAG_SCOPE_KEY, uptime.tag);
|
|
224
|
+
context.recordScopeId(PLATFORM_UPTIME_APPLIED_SHA256_SCOPE_KEY, uptime.sha256);
|
|
225
|
+
context.recordScopeId(PLATFORM_UPTIME_APPLIED_HOSTNAME_SCOPE_KEY, hostname);
|
|
226
|
+
context.recordScopeId(PLATFORM_UPTIME_APPLIED_VARS_SCOPE_KEY, uptimeVarsDigest(deps.vars));
|
|
227
|
+
context.recordScopeId(PLATFORM_UPTIME_APPLIED_SECRETS_SCOPE_KEY, uptimeSecretNames(Object.keys(held)));
|
|
228
|
+
return { resourceId: PLATFORM_UPTIME_SCRIPT_NAME };
|
|
229
|
+
}
|
package/dist/platform.d.ts
CHANGED
|
@@ -24,6 +24,22 @@ export interface PlatformService {
|
|
|
24
24
|
export interface PlatformMailService extends PlatformService {
|
|
25
25
|
lettermintProjectId?: string | null;
|
|
26
26
|
}
|
|
27
|
+
/**
|
|
28
|
+
* The uptime Worker (foundry#542): the outside prober Archon publishes as a
|
|
29
|
+
* built script with each `v*` release. Foundry deploys the pinned release and
|
|
30
|
+
* never builds it. `tag` and `sha256` are the pin; both empty means nothing is
|
|
31
|
+
* pinned yet, and the planner reports that instead of planning the step.
|
|
32
|
+
* It has no hostname: it answers no request, only probes on a cron trigger.
|
|
33
|
+
*/
|
|
34
|
+
export interface PlatformUptimeWorker {
|
|
35
|
+
enabled: boolean;
|
|
36
|
+
/** An Archon release tag, `v1.2.3`. */
|
|
37
|
+
tag: string;
|
|
38
|
+
/** Lower-case hex SHA-256 of `archon-uptime-worker.js` at that tag. */
|
|
39
|
+
sha256: string;
|
|
40
|
+
}
|
|
41
|
+
/** Where Archon publishes the Worker script for `tag` (a private repo; the host's `fetchArtifact` fetches it). */
|
|
42
|
+
export declare function uptimeArtifactUrl(tag: string): string;
|
|
27
43
|
/**
|
|
28
44
|
* The shared services a declared platform names, decided in #165, plus
|
|
29
45
|
* `edge` (#317, F7): the service that mints an entry's own edge key and
|
|
@@ -87,6 +103,8 @@ export interface DeclaredPlatform {
|
|
|
87
103
|
* `gitRef` empty.
|
|
88
104
|
*/
|
|
89
105
|
coolify: PlatformService;
|
|
106
|
+
/** foundry#542: the outside prober Worker; see {@link PlatformUptimeWorker}. */
|
|
107
|
+
uptime: PlatformUptimeWorker;
|
|
90
108
|
}
|
|
91
109
|
/** `<service>.<apex>` -- the default `hostname` for a platform service, before an operator overrides it. */
|
|
92
110
|
export declare function defaultPlatformHostname(service: PlatformServiceKey, apex: string): string;
|
package/dist/platform.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { isIPv4 } from 'node:net';
|
|
2
2
|
import { validateDomain } from './org.js';
|
|
3
|
+
/** Where Archon publishes the Worker script for `tag` (a private repo; the host's `fetchArtifact` fetches it). */
|
|
4
|
+
export function uptimeArtifactUrl(tag) {
|
|
5
|
+
return `https://github.com/wtfalch/archon/releases/download/${tag}/archon-uptime-worker.js`;
|
|
6
|
+
}
|
|
3
7
|
export const PLATFORM_SERVICE_KEYS = [
|
|
4
8
|
'auth',
|
|
5
9
|
'mail',
|
|
@@ -50,6 +54,16 @@ export function validatePlatform(platform) {
|
|
|
50
54
|
return `${key}.gitRef must not be empty once ${key}.enabled is true`;
|
|
51
55
|
}
|
|
52
56
|
}
|
|
57
|
+
const { uptime } = platform;
|
|
58
|
+
if (Boolean(uptime.tag.trim()) !== Boolean(uptime.sha256.trim())) {
|
|
59
|
+
return 'uptime.tag and uptime.sha256 must be set together: a pin is both';
|
|
60
|
+
}
|
|
61
|
+
if (uptime.tag && !/^v[0-9A-Za-z][0-9A-Za-z.+_-]*$/.test(uptime.tag)) {
|
|
62
|
+
return `uptime.tag '${uptime.tag}' must be a release tag such as v1.2.3`;
|
|
63
|
+
}
|
|
64
|
+
if (uptime.sha256 && !/^[0-9a-f]{64}$/.test(uptime.sha256)) {
|
|
65
|
+
return 'uptime.sha256 must be 64 lower-case hex characters';
|
|
66
|
+
}
|
|
53
67
|
// #491: declared, never discovered -- a present-but-blank id is refused the
|
|
54
68
|
// same way `serverIpv4`'s blank string is, rather than being read as unset.
|
|
55
69
|
if (platform.mail.lettermintProjectId != null && !platform.mail.lettermintProjectId.trim()) {
|
package/dist/store/postgres.js
CHANGED
|
@@ -79,6 +79,30 @@ export class DeclaredOrgError extends Error {
|
|
|
79
79
|
this.name = 'DeclaredOrgError';
|
|
80
80
|
}
|
|
81
81
|
}
|
|
82
|
+
/**
|
|
83
|
+
* Reads one stored entry. `hostnames` defaults as migration 0030 set it, and
|
|
84
|
+
* `gates` to none. An entry stored before gates existed with a non-empty
|
|
85
|
+
* `accessEmails` was gated: read it as one gate on its first non-'*' hostname
|
|
86
|
+
* (as stored), or on '*' when that is its only hostname, so the read fails
|
|
87
|
+
* closed and never drops a declared gate. With no hostname it is not served
|
|
88
|
+
* and reads no gates. The emails are dropped (people are chosen
|
|
89
|
+
* elsewhere now), and the next write stores the new shape without them. An
|
|
90
|
+
* entry that already has `gates` keeps them. A converted company over
|
|
91
|
+
* `gateLimit` is not an error here; validation reports it on the next validate.
|
|
92
|
+
*/
|
|
93
|
+
function storedEntryToEntry(stored, usesDomain) {
|
|
94
|
+
const { accessEmails, ...rest } = stored;
|
|
95
|
+
const hostnames = stored.hostnames ?? (stored.key === APP_ENTRY_KEY && usesDomain ? ['@'] : []);
|
|
96
|
+
let gates = stored.gates;
|
|
97
|
+
if (gates === undefined) {
|
|
98
|
+
const first = hostnames.find((h) => h !== '*') ?? hostnames[0];
|
|
99
|
+
gates =
|
|
100
|
+
accessEmails !== undefined && accessEmails.length > 0 && first !== undefined
|
|
101
|
+
? [{ key: stored.key, hostnames: [first] }]
|
|
102
|
+
: [];
|
|
103
|
+
}
|
|
104
|
+
return { ...rest, hostnames, gates };
|
|
105
|
+
}
|
|
82
106
|
function rowToOrg(row) {
|
|
83
107
|
return {
|
|
84
108
|
id: row.id,
|
|
@@ -117,11 +141,8 @@ function rowToOrg(row) {
|
|
|
117
141
|
usesDomain: row.hostingUsesDomain,
|
|
118
142
|
tunnel: row.hostingTunnel,
|
|
119
143
|
issuerApps: row.hostingIssuerApps,
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
...entry,
|
|
123
|
-
hostnames: entry.hostnames ?? (entry.key === APP_ENTRY_KEY && row.hostingUsesDomain ? ['@'] : []),
|
|
124
|
-
})),
|
|
144
|
+
entries: row.hostingEntries.map((entry) => storedEntryToEntry(entry, row.hostingUsesDomain)),
|
|
145
|
+
gateLimit: row.hostingGateLimit,
|
|
125
146
|
},
|
|
126
147
|
sso: { enabled: row.ssoEnabled },
|
|
127
148
|
guests: { enabled: row.guestsEnabled },
|
|
@@ -163,6 +184,7 @@ function orgToRow(org) {
|
|
|
163
184
|
hostingTunnel: org.hosting.tunnel,
|
|
164
185
|
hostingIssuerApps: org.hosting.issuerApps,
|
|
165
186
|
hostingEntries: org.hosting.entries,
|
|
187
|
+
hostingGateLimit: org.hosting.gateLimit,
|
|
166
188
|
ssoEnabled: org.sso.enabled,
|
|
167
189
|
guestsEnabled: org.guests.enabled,
|
|
168
190
|
soloEnabled: org.solo.enabled,
|
|
@@ -613,6 +635,7 @@ function rowToPlatform(row) {
|
|
|
613
635
|
hostname: row.coolifyHostname,
|
|
614
636
|
gitRef: row.coolifyGitRef,
|
|
615
637
|
},
|
|
638
|
+
uptime: { enabled: row.uptimeEnabled, tag: row.uptimeTag, sha256: row.uptimeSha256 },
|
|
616
639
|
};
|
|
617
640
|
}
|
|
618
641
|
function platformToRow(platform) {
|
|
@@ -655,6 +678,9 @@ function platformToRow(platform) {
|
|
|
655
678
|
coolifyEnabled: platform.coolify.enabled,
|
|
656
679
|
coolifyHostname: platform.coolify.hostname,
|
|
657
680
|
coolifyGitRef: platform.coolify.gitRef,
|
|
681
|
+
uptimeEnabled: platform.uptime.enabled,
|
|
682
|
+
uptimeTag: platform.uptime.tag,
|
|
683
|
+
uptimeSha256: platform.uptime.sha256,
|
|
658
684
|
};
|
|
659
685
|
}
|
|
660
686
|
/**
|
package/dist/store/schema.d.ts
CHANGED
|
@@ -533,6 +533,23 @@ export declare const declaredOrgs: import("drizzle-orm/pg-core").PgTableWithColu
|
|
|
533
533
|
}, {}, {
|
|
534
534
|
$type: HostingEntry[];
|
|
535
535
|
}>;
|
|
536
|
+
hostingGateLimit: import("drizzle-orm/pg-core").PgColumn<{
|
|
537
|
+
name: "hosting_gate_limit";
|
|
538
|
+
tableName: "foundry_declared_orgs";
|
|
539
|
+
dataType: "number";
|
|
540
|
+
columnType: "PgInteger";
|
|
541
|
+
data: number;
|
|
542
|
+
driverParam: string | number;
|
|
543
|
+
notNull: true;
|
|
544
|
+
hasDefault: true;
|
|
545
|
+
isPrimaryKey: false;
|
|
546
|
+
isAutoincrement: false;
|
|
547
|
+
hasRuntimeDefault: false;
|
|
548
|
+
enumValues: undefined;
|
|
549
|
+
baseColumn: never;
|
|
550
|
+
identity: undefined;
|
|
551
|
+
generated: undefined;
|
|
552
|
+
}, {}, {}>;
|
|
536
553
|
ssoEnabled: import("drizzle-orm/pg-core").PgColumn<{
|
|
537
554
|
name: "sso_enabled";
|
|
538
555
|
tableName: "foundry_declared_orgs";
|
|
@@ -2034,6 +2051,57 @@ export declare const foundryDeclaredPlatform: import("drizzle-orm/pg-core").PgTa
|
|
|
2034
2051
|
identity: undefined;
|
|
2035
2052
|
generated: undefined;
|
|
2036
2053
|
}, {}, {}>;
|
|
2054
|
+
uptimeEnabled: import("drizzle-orm/pg-core").PgColumn<{
|
|
2055
|
+
name: "uptime_enabled";
|
|
2056
|
+
tableName: "foundry_declared_platform";
|
|
2057
|
+
dataType: "boolean";
|
|
2058
|
+
columnType: "PgBoolean";
|
|
2059
|
+
data: boolean;
|
|
2060
|
+
driverParam: boolean;
|
|
2061
|
+
notNull: true;
|
|
2062
|
+
hasDefault: true;
|
|
2063
|
+
isPrimaryKey: false;
|
|
2064
|
+
isAutoincrement: false;
|
|
2065
|
+
hasRuntimeDefault: false;
|
|
2066
|
+
enumValues: undefined;
|
|
2067
|
+
baseColumn: never;
|
|
2068
|
+
identity: undefined;
|
|
2069
|
+
generated: undefined;
|
|
2070
|
+
}, {}, {}>;
|
|
2071
|
+
uptimeTag: import("drizzle-orm/pg-core").PgColumn<{
|
|
2072
|
+
name: "uptime_tag";
|
|
2073
|
+
tableName: "foundry_declared_platform";
|
|
2074
|
+
dataType: "string";
|
|
2075
|
+
columnType: "PgText";
|
|
2076
|
+
data: string;
|
|
2077
|
+
driverParam: string;
|
|
2078
|
+
notNull: true;
|
|
2079
|
+
hasDefault: true;
|
|
2080
|
+
isPrimaryKey: false;
|
|
2081
|
+
isAutoincrement: false;
|
|
2082
|
+
hasRuntimeDefault: false;
|
|
2083
|
+
enumValues: [string, ...string[]];
|
|
2084
|
+
baseColumn: never;
|
|
2085
|
+
identity: undefined;
|
|
2086
|
+
generated: undefined;
|
|
2087
|
+
}, {}, {}>;
|
|
2088
|
+
uptimeSha256: import("drizzle-orm/pg-core").PgColumn<{
|
|
2089
|
+
name: "uptime_sha256";
|
|
2090
|
+
tableName: "foundry_declared_platform";
|
|
2091
|
+
dataType: "string";
|
|
2092
|
+
columnType: "PgText";
|
|
2093
|
+
data: string;
|
|
2094
|
+
driverParam: string;
|
|
2095
|
+
notNull: true;
|
|
2096
|
+
hasDefault: true;
|
|
2097
|
+
isPrimaryKey: false;
|
|
2098
|
+
isAutoincrement: false;
|
|
2099
|
+
hasRuntimeDefault: false;
|
|
2100
|
+
enumValues: [string, ...string[]];
|
|
2101
|
+
baseColumn: never;
|
|
2102
|
+
identity: undefined;
|
|
2103
|
+
generated: undefined;
|
|
2104
|
+
}, {}, {}>;
|
|
2037
2105
|
serviceIds: import("drizzle-orm/pg-core").PgColumn<{
|
|
2038
2106
|
name: "service_ids";
|
|
2039
2107
|
tableName: "foundry_declared_platform";
|
package/dist/store/schema.js
CHANGED
|
@@ -59,6 +59,8 @@ export const declaredOrgs = pgTable('foundry_declared_orgs', {
|
|
|
59
59
|
// hosting_access_emails and hosting_app_env columns, which stay for an older
|
|
60
60
|
// reader and are no longer written.
|
|
61
61
|
hostingEntries: jsonb('hosting_entries').notNull().default([]).$type(),
|
|
62
|
+
// HostingConfig.gateLimit (org.ts, #603); migrations/0048. Default 3 is DEFAULT_GATE_LIMIT.
|
|
63
|
+
hostingGateLimit: integer('hosting_gate_limit').notNull().default(3),
|
|
62
64
|
ssoEnabled: boolean('sso_enabled').notNull().default(false),
|
|
63
65
|
guestsEnabled: boolean('guests_enabled').notNull().default(false),
|
|
64
66
|
soloEnabled: boolean('solo_enabled').notNull().default(false),
|
|
@@ -190,6 +192,10 @@ export const foundryDeclaredPlatform = pgTable('foundry_declared_platform', {
|
|
|
190
192
|
coolifyEnabled: boolean('coolify_enabled').notNull().default(false),
|
|
191
193
|
coolifyHostname: text('coolify_hostname').notNull(),
|
|
192
194
|
coolifyGitRef: text('coolify_git_ref').notNull().default(''),
|
|
195
|
+
/** `migrations/0049_platform_uptime.sql`: the uptime Worker and its artifact pin. */
|
|
196
|
+
uptimeEnabled: boolean('uptime_enabled').notNull().default(true),
|
|
197
|
+
uptimeTag: text('uptime_tag').notNull().default(''),
|
|
198
|
+
uptimeSha256: text('uptime_sha256').notNull().default(''),
|
|
193
199
|
/** `migrations/0018_platform_apply.sql`: the platform's applied state, `system:kind:key` to provider id. */
|
|
194
200
|
serviceIds: jsonb('service_ids').$type().notNull().default({}),
|
|
195
201
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wtfalch/foundry",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.0",
|
|
4
4
|
"description": "Foundry's SDK: the declared org's types, the plan and apply contracts, and the state store's interface.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
"dependencies": {
|
|
44
44
|
"@wtfalch/audit": "0.6.0",
|
|
45
45
|
"@wtfalch/auth": "0.16.0",
|
|
46
|
-
"@wtfalch/edge": "0.
|
|
46
|
+
"@wtfalch/edge": "0.12.0",
|
|
47
47
|
"@wtfalch/email": "^0.17.0",
|
|
48
48
|
"@wtfalch/providers": "0.7.5",
|
|
49
49
|
"ssh2": "1.17.0",
|