@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.
@@ -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
+ }
@@ -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()) {
@@ -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
- // Migration 0030's default for an entry stored before `hostnames`.
121
- entries: row.hostingEntries.map((entry) => ({
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
  /**
@@ -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";
@@ -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.23.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.9.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",