@wtfalch/foundry 0.59.0 → 0.61.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/apps/cloudflare.d.ts +173 -0
- package/dist/apps/cloudflare.js +17 -0
- package/dist/apps/deploy.d.ts +116 -0
- package/dist/apps/deploy.js +791 -0
- package/dist/apps/github.d.ts +47 -0
- package/dist/apps/github.js +201 -0
- package/dist/apps/index.d.ts +9 -0
- package/dist/apps/index.js +9 -0
- package/dist/apps/migrations.d.ts +110 -0
- package/dist/apps/migrations.js +458 -0
- package/dist/audit/connector.d.ts +43 -0
- package/dist/audit/connector.js +22 -0
- package/dist/audit/evidence.d.ts +49 -0
- package/dist/audit/evidence.js +56 -0
- package/dist/audit/index.d.ts +9 -0
- package/dist/audit/index.js +9 -0
- package/dist/audit/migrations.d.ts +5 -0
- package/dist/audit/migrations.js +5 -0
- package/dist/audit/registry.d.ts +49 -0
- package/dist/audit/registry.js +44 -0
- package/dist/audit/seal.d.ts +49 -0
- package/dist/audit/seal.js +131 -0
- package/dist/audit/sealer-role.d.ts +33 -0
- package/dist/audit/sealer-role.js +56 -0
- package/dist/audit/signer.d.ts +40 -0
- package/dist/audit/signer.js +78 -0
- package/dist/audit/tick.d.ts +36 -0
- package/dist/audit/tick.js +87 -0
- package/dist/catalogue/copy-en.d.ts +1 -0
- package/dist/catalogue/copy-en.js +129 -0
- package/dist/catalogue/index.d.ts +9 -0
- package/dist/catalogue/index.js +21 -0
- package/dist/catalogue/integrations.d.ts +4 -0
- package/dist/catalogue/integrations.js +104 -0
- package/dist/catalogue/systems.d.ts +7 -0
- package/dist/catalogue/systems.js +31 -0
- package/dist/catalogue/types.d.ts +110 -0
- package/dist/catalogue/types.js +1 -0
- package/dist/entry-rules.d.ts +53 -0
- package/dist/entry-rules.js +71 -0
- package/dist/hosted-label.d.ts +13 -0
- package/dist/hosted-label.js +81 -0
- package/dist/index.d.ts +10 -5
- package/dist/index.js +5 -3
- package/dist/migrations/0062_hosting_github.sql +6 -0
- package/dist/migrations/0063_boule_apps.sql +7 -0
- package/dist/migrations/0064_requested_changes_boule_apps.sql +6 -0
- package/dist/modules/ai.d.ts +3 -0
- package/dist/modules/ai.js +64 -0
- package/dist/modules/assistants.d.ts +3 -0
- package/dist/modules/assistants.js +72 -0
- package/dist/modules/audit-ledger.d.ts +29 -0
- package/dist/modules/audit-ledger.js +49 -0
- package/dist/modules/audit.d.ts +7 -0
- package/dist/modules/audit.js +44 -0
- package/dist/modules/database.d.ts +23 -0
- package/dist/modules/database.js +189 -0
- package/dist/modules/email.d.ts +3 -0
- package/dist/modules/email.js +18 -0
- package/dist/modules/entry-parts.d.ts +23 -0
- package/dist/modules/entry-parts.js +63 -0
- package/dist/modules/files.d.ts +3 -0
- package/dist/modules/files.js +64 -0
- package/dist/modules/index.d.ts +74 -0
- package/dist/modules/index.js +774 -0
- package/dist/modules/jobs.d.ts +3 -0
- package/dist/modules/jobs.js +23 -0
- package/dist/modules/organisations.d.ts +3 -0
- package/dist/modules/organisations.js +20 -0
- package/dist/modules/outputs.d.ts +9 -0
- package/dist/modules/outputs.js +38 -0
- package/dist/modules/sign-in.d.ts +3 -0
- package/dist/modules/sign-in.js +197 -0
- package/dist/modules/signed-records.d.ts +3 -0
- package/dist/modules/signed-records.js +32 -0
- package/dist/modules/types.d.ts +194 -0
- package/dist/modules/types.js +1 -0
- package/dist/modules/workspaces.d.ts +3 -0
- package/dist/modules/workspaces.js +75 -0
- package/dist/org.d.ts +163 -22
- package/dist/org.js +419 -47
- package/dist/plan/apps-db-route.d.ts +90 -0
- package/dist/plan/apps-db-route.js +168 -0
- package/dist/plan/apps-zones.d.ts +56 -0
- package/dist/plan/apps-zones.js +118 -0
- package/dist/plan/archon-blue-green.js +11 -1
- package/dist/plan/blue-green-apply.d.ts +37 -1
- package/dist/plan/blue-green-apply.js +55 -15
- package/dist/plan/blue-green.d.ts +41 -1
- package/dist/plan/blue-green.js +80 -2
- package/dist/plan/boule.d.ts +23 -1
- package/dist/plan/boule.js +35 -2
- package/dist/plan/broker-entries.d.ts +28 -3
- package/dist/plan/broker-entries.js +74 -3
- package/dist/plan/broker-registrar.d.ts +168 -0
- package/dist/plan/broker-registrar.js +440 -0
- package/dist/plan/broker-workspaces-database.d.ts +48 -0
- package/dist/plan/broker-workspaces-database.js +87 -0
- package/dist/plan/desktop-callback.js +12 -7
- package/dist/plan/diff.d.ts +8 -2
- package/dist/plan/diff.js +373 -42
- package/dist/plan/edge-access.d.ts +6 -0
- package/dist/plan/edge-dns.d.ts +6 -0
- package/dist/plan/edge-dns.js +9 -7
- package/dist/plan/entry-ai-key.d.ts +1 -1
- package/dist/plan/entry-ai-key.js +2 -2
- package/dist/plan/entry-audit.d.ts +58 -0
- package/dist/plan/entry-audit.js +122 -0
- package/dist/plan/entry-customer-grants.d.ts +75 -0
- package/dist/plan/entry-customer-grants.js +154 -0
- package/dist/plan/entry-edge-key.d.ts +26 -1
- package/dist/plan/entry-edge-key.js +42 -3
- package/dist/plan/entry-jobs.d.ts +64 -0
- package/dist/plan/entry-jobs.js +92 -0
- package/dist/plan/entry-repo-key-apply.d.ts +55 -0
- package/dist/plan/entry-repo-key-apply.js +185 -0
- package/dist/plan/entry-repo-key.d.ts +92 -0
- package/dist/plan/entry-repo-key.js +235 -0
- package/dist/plan/entry-workspaces.d.ts +55 -0
- package/dist/plan/entry-workspaces.js +90 -0
- package/dist/plan/executor.d.ts +191 -1
- package/dist/plan/executor.js +1505 -90
- package/dist/plan/gates.d.ts +1 -1
- package/dist/plan/gates.js +62 -9
- package/dist/plan/github-deploy-keys.d.ts +78 -0
- package/dist/plan/github-deploy-keys.js +305 -0
- package/dist/plan/github-source.d.ts +53 -0
- package/dist/plan/github-source.js +140 -0
- package/dist/plan/held-key-refs.d.ts +12 -0
- package/dist/plan/held-key-refs.js +12 -0
- package/dist/plan/hostnames.d.ts +9 -0
- package/dist/plan/hostnames.js +28 -1
- package/dist/plan/index.d.ts +9 -0
- package/dist/plan/index.js +6 -0
- package/dist/plan/jobs-scheduler-http.js +3 -0
- package/dist/plan/keys-registrar-http.d.ts +32 -0
- package/dist/plan/keys-registrar-http.js +105 -0
- package/dist/plan/keys-registrar.d.ts +67 -0
- package/dist/plan/keys-registrar.js +103 -0
- package/dist/plan/live.d.ts +1 -1
- package/dist/plan/locked-hostnames.d.ts +2 -2
- package/dist/plan/locked-hostnames.js +2 -2
- package/dist/plan/login-host.js +11 -5
- package/dist/plan/machine-paths.d.ts +36 -0
- package/dist/plan/machine-paths.js +137 -0
- package/dist/plan/mcp.d.ts +1 -1
- package/dist/plan/mcp.js +49 -17
- package/dist/plan/org-apps-admin.d.ts +108 -0
- package/dist/plan/org-apps-admin.js +434 -0
- package/dist/plan/org-names.js +12 -1
- package/dist/plan/own-diff.d.ts +23 -0
- package/dist/plan/own-diff.js +53 -0
- package/dist/plan/own-hostnames.d.ts +2 -0
- package/dist/plan/own-hostnames.js +1 -0
- package/dist/plan/own-jobs-route.d.ts +47 -0
- package/dist/plan/own-jobs-route.js +91 -0
- package/dist/plan/own-services.d.ts +4 -0
- package/dist/plan/own-services.js +4 -0
- package/dist/plan/plan-apply.d.ts +39 -0
- package/dist/plan/plan-apply.js +163 -9
- package/dist/plan/platform-diff.d.ts +2 -2
- package/dist/plan/platform-diff.js +7 -2
- package/dist/plan/platform-org.d.ts +30 -0
- package/dist/plan/platform-org.js +12 -1
- package/dist/plan/platform-providers.d.ts +2 -0
- package/dist/plan/platform-providers.js +7 -2
- package/dist/plan/post-logout-uris.js +7 -1
- package/dist/plan/private-pages.d.ts +26 -0
- package/dist/plan/private-pages.js +39 -0
- package/dist/plan/proxy-labels.d.ts +12 -1
- package/dist/plan/proxy-labels.js +32 -2
- package/dist/plan/release-host.d.ts +6 -2
- package/dist/plan/release-host.js +36 -8
- package/dist/plan/stage-env.d.ts +19 -1
- package/dist/plan/stage-env.js +25 -1
- package/dist/plan/stage-guards.d.ts +20 -0
- package/dist/plan/stage-guards.js +24 -0
- package/dist/plan/stage-isolation.d.ts +24 -0
- package/dist/plan/stage-isolation.js +29 -1
- package/dist/plan/worker-app-database.d.ts +47 -40
- package/dist/plan/worker-app-database.js +150 -152
- package/dist/plan/worker-app.d.ts +6 -65
- package/dist/plan/worker-app.js +29 -105
- package/dist/plan/worker.d.ts +2 -2
- package/dist/plan/worker.js +2 -1
- package/dist/plan/workspace-client.d.ts +25 -0
- package/dist/plan/workspace-client.js +48 -0
- package/dist/plan/workspace-sign-in.d.ts +55 -0
- package/dist/plan/workspace-sign-in.js +273 -0
- package/dist/plan/workspace-worker.d.ts +4 -0
- package/dist/plan/workspace-worker.js +13 -0
- package/dist/plan/workspace.d.ts +101 -0
- package/dist/plan/workspace.js +305 -0
- package/dist/release/decide.d.ts +30 -1
- package/dist/release/decide.js +73 -11
- package/dist/release/health.d.ts +8 -3
- package/dist/release/health.js +58 -5
- package/dist/release/run.d.ts +2 -5
- package/dist/release/run.js +4 -0
- package/dist/release/state.d.ts +3 -1
- package/dist/release/state.js +6 -0
- package/dist/release/types.d.ts +20 -0
- package/dist/requested-changes.d.ts +11 -2
- package/dist/requested-changes.js +11 -0
- package/dist/service-labels.d.ts +4 -0
- package/dist/service-labels.js +11 -0
- package/dist/slug.d.ts +2 -0
- package/dist/slug.js +10 -6
- package/dist/store/postgres.js +12 -2
- package/dist/store/schema.d.ts +37 -1
- package/dist/store/schema.js +4 -0
- package/dist/worker-app-config.d.ts +6 -10
- package/dist/worker-app-config.js +22 -17
- package/dist/worker-app-migrations.d.ts +105 -0
- package/dist/worker-app-migrations.js +166 -0
- package/dist/worker-app-upload.d.ts +103 -0
- package/dist/worker-app-upload.js +128 -0
- package/package.json +18 -1
|
@@ -36,10 +36,31 @@ export declare function isAllowed(kind: StageKind, name: string): boolean;
|
|
|
36
36
|
export declare const SHARED_WITH_PROD: Readonly<Record<string, string>>;
|
|
37
37
|
/** Whether `name` may equal production's value. */
|
|
38
38
|
export declare const isSharedWithProd: (name: string) => boolean;
|
|
39
|
+
/** The live app's own sign-in values. Stage must hold none of them. */
|
|
40
|
+
export interface LiveSignIn {
|
|
41
|
+
/** The live customers organisation's id. */
|
|
42
|
+
customersOrgId?: string;
|
|
43
|
+
/** The live app's client ids. */
|
|
44
|
+
clientIds: readonly string[];
|
|
45
|
+
/** The live workspaces keys. */
|
|
46
|
+
workspacesKeys: readonly string[];
|
|
47
|
+
/** The live apps' MCP audiences (the ZITADEL project ids). */
|
|
48
|
+
mcpAudiences?: readonly string[];
|
|
49
|
+
/** The live apps' MCP resources (their public MCP addresses). */
|
|
50
|
+
mcpResources?: readonly string[];
|
|
51
|
+
}
|
|
52
|
+
/** What a stage with its own sign-in says about it, for the isolation check. */
|
|
53
|
+
export interface StageSignInGuard {
|
|
54
|
+
/** Stage signs in its own test customers organisation, so its organisation id may differ from production's. */
|
|
55
|
+
customers: boolean;
|
|
56
|
+
live: LiveSignIn;
|
|
57
|
+
}
|
|
39
58
|
export interface StageIsolationInput {
|
|
40
59
|
kind: StageKind;
|
|
41
60
|
prod: Surface;
|
|
42
61
|
stage: Surface;
|
|
62
|
+
/** Present for a stage with its own sign-in (a workspace). */
|
|
63
|
+
signIn?: StageSignInGuard;
|
|
43
64
|
/** The mail env stage is allowed to hold: the sink's wiring. Empty: stage holds no mail env. */
|
|
44
65
|
sinkMail?: Readonly<Record<string, string>>;
|
|
45
66
|
}
|
|
@@ -51,6 +72,9 @@ export interface StageIsolationInput {
|
|
|
51
72
|
* {@link SHARED_WITH_PROD} differs from production's), `live_mode_key`.
|
|
52
73
|
* - A4 names: `resource_shared`, `host_is_prod_internal`.
|
|
53
74
|
* - A5 mail: `mail_not_sink`, `mail_token`.
|
|
75
|
+
* - A6 sign-in (with `signIn`): `live_customers_org`, `live_client_id`, `live_workspaces_key`,
|
|
76
|
+
* `live_mcp_audience`, `live_mcp_resource` when stage holds the live customers organisation id, a live client
|
|
77
|
+
* id, the live workspaces key, or a live MCP audience or resource.
|
|
54
78
|
*/
|
|
55
79
|
export declare function assertStageIsolated(input: StageIsolationInput): string[];
|
|
56
80
|
/** The sha256 of the stage env name set plus the resource set: what `bg.<id>.stageisolation` records. */
|
|
@@ -87,6 +87,11 @@ export const STAGE_ALLOWLIST = {
|
|
|
87
87
|
'JOBS_SYNC_KEY',
|
|
88
88
|
'ANALYTICS_URL',
|
|
89
89
|
'ANALYTICS_SITE_KEY',
|
|
90
|
+
'WORKSPACES_URL',
|
|
91
|
+
'WORKSPACES_API_KEY',
|
|
92
|
+
'MCP_AUDIENCE',
|
|
93
|
+
'MCP_RESOURCE',
|
|
94
|
+
'MCP_ISSUER',
|
|
90
95
|
],
|
|
91
96
|
};
|
|
92
97
|
/** Names no stage holds, whatever the app. */
|
|
@@ -159,6 +164,8 @@ export const SHARED_WITH_PROD = {
|
|
|
159
164
|
AI_URL: 'the ai service is shared; stage reaches it only with its own organisation and key',
|
|
160
165
|
JOBS_SCHEDULER_URL: 'the jobs service is shared; stage reaches it only with its own project and signing secret',
|
|
161
166
|
ANALYTICS_URL: 'the analytics collector is shared; stage reports under its own site key',
|
|
167
|
+
MCP_ISSUER: 'the sign-in service is shared by decision; the issuer is a public address, not a credential',
|
|
168
|
+
WORKSPACES_URL: "the broker is shared; stage's own workspaces key opens its own product tenant, which holds no live customers' workspaces",
|
|
162
169
|
};
|
|
163
170
|
/** Whether `name` may equal production's value. */
|
|
164
171
|
export const isSharedWithProd = (name) => Object.keys(SHARED_WITH_PROD).some((p) => globMatches(p, name));
|
|
@@ -174,6 +181,9 @@ const hostOf = (value) => URL_HOST.exec(value)?.[1]?.toLowerCase() ?? null;
|
|
|
174
181
|
* {@link SHARED_WITH_PROD} differs from production's), `live_mode_key`.
|
|
175
182
|
* - A4 names: `resource_shared`, `host_is_prod_internal`.
|
|
176
183
|
* - A5 mail: `mail_not_sink`, `mail_token`.
|
|
184
|
+
* - A6 sign-in (with `signIn`): `live_customers_org`, `live_client_id`, `live_workspaces_key`,
|
|
185
|
+
* `live_mcp_audience`, `live_mcp_resource` when stage holds the live customers organisation id, a live client
|
|
186
|
+
* id, the live workspaces key, or a live MCP audience or resource.
|
|
177
187
|
*/
|
|
178
188
|
export function assertStageIsolated(input) {
|
|
179
189
|
const { kind, prod, stage } = input;
|
|
@@ -211,9 +221,27 @@ export function assertStageIsolated(input) {
|
|
|
211
221
|
out.add(`env_not_allowed:${name}`);
|
|
212
222
|
if (item.value !== undefined && LIVE_MODE_KEY.test(item.value))
|
|
213
223
|
out.add(`live_mode_key:${name}`);
|
|
224
|
+
if (input.signIn && item.value !== undefined) {
|
|
225
|
+
const { live } = input.signIn;
|
|
226
|
+
if (name === 'AUTH_ORGANIZATION_ID' && item.value === live.customersOrgId) {
|
|
227
|
+
out.add(`live_customers_org:${name}`);
|
|
228
|
+
}
|
|
229
|
+
if (name === 'AUTH_CLIENT_ID' && live.clientIds.includes(item.value)) {
|
|
230
|
+
out.add(`live_client_id:${name}`);
|
|
231
|
+
}
|
|
232
|
+
if (name === 'WORKSPACES_API_KEY' && live.workspacesKeys.includes(item.value)) {
|
|
233
|
+
out.add(`live_workspaces_key:${name}`);
|
|
234
|
+
}
|
|
235
|
+
if (name === 'MCP_AUDIENCE' && live.mcpAudiences?.includes(item.value)) {
|
|
236
|
+
out.add(`live_mcp_audience:${name}`);
|
|
237
|
+
}
|
|
238
|
+
if (name === 'MCP_RESOURCE' && live.mcpResources?.includes(item.value)) {
|
|
239
|
+
out.add(`live_mcp_resource:${name}`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
214
242
|
if (item.class === 'literal')
|
|
215
243
|
continue;
|
|
216
|
-
const shared = isSharedWithProd(name);
|
|
244
|
+
const shared = isSharedWithProd(name) && !(input.signIn?.customers && name === 'AUTH_ORGANIZATION_ID');
|
|
217
245
|
const counterpart = prodByName.get(name);
|
|
218
246
|
if (shared) {
|
|
219
247
|
const same = item.value !== undefined && counterpart?.value !== undefined
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import type { DeclaredOrg, HostingEntry } from '../org.js';
|
|
2
|
+
import { type DatabaseMigration, MIGRATIONS_ASSET_NAME, WORKER_APP_MIGRATION_TIMEOUT_MS, type WorkerAppMigrationRunner, migrationsUrl, parseMigrations, runWorkerAppMigrations, unappliedDatabaseMigrations, verifyMigrationsDigest } from '../worker-app-migrations.js';
|
|
2
3
|
import type { AccessPort } from './edge-access.js';
|
|
3
4
|
import type { DnsPort } from './edge-dns.js';
|
|
4
5
|
import type { TunnelPort } from './edge-tunnels.js';
|
|
5
6
|
import type { ZonePort } from './edge-zones.js';
|
|
6
7
|
import type { StepExecutionContext } from './plan-apply.js';
|
|
8
|
+
export { type DatabaseMigration, MIGRATIONS_ASSET_NAME, WORKER_APP_MIGRATION_TIMEOUT_MS, type WorkerAppMigrationRunner, migrationsUrl, parseMigrations, runWorkerAppMigrations, unappliedDatabaseMigrations, verifyMigrationsDigest, };
|
|
7
9
|
/**
|
|
8
10
|
* C2 (campaign 2026-10-15): a Worker app with `workerApp.database` reaches the org's Coolify
|
|
9
11
|
* Postgres through a Hyperdrive config. The database host is Docker-internal, so Hyperdrive
|
|
@@ -35,18 +37,13 @@ export interface HyperdriveOrigin {
|
|
|
35
37
|
caPem: string;
|
|
36
38
|
};
|
|
37
39
|
}
|
|
38
|
-
/** One migration of a release's `migrations.json`. */
|
|
39
|
-
export interface DatabaseMigration {
|
|
40
|
-
id: string;
|
|
41
|
-
sql: string;
|
|
42
|
-
}
|
|
43
40
|
/**
|
|
44
41
|
* The calls a Worker app database needs beyond Cloudflare's Access, tunnel and DNS. Edge's
|
|
45
42
|
* Hyperdrive SDK (`client.platform.hyperdrive`) and the SQL runner behind the tunnel
|
|
46
43
|
* (`@wtfalch/db`'s migrate and `ensureRuntimeRole`) are the host's to implement; foundry only
|
|
47
44
|
* calls this port. Every method is idempotent.
|
|
48
45
|
*/
|
|
49
|
-
export interface WorkerAppDatabasePort {
|
|
46
|
+
export interface WorkerAppDatabasePort extends WorkerAppMigrationRunner<HyperdriveOrigin> {
|
|
50
47
|
/** Creates or updates the Hyperdrive config `name`; returns its id. */
|
|
51
48
|
upsertHyperdrive(input: {
|
|
52
49
|
name: string;
|
|
@@ -63,27 +60,22 @@ export interface WorkerAppDatabasePort {
|
|
|
63
60
|
}): Promise<{
|
|
64
61
|
id: string;
|
|
65
62
|
}>;
|
|
66
|
-
/** The text of a release asset of a wtfalch repo, or `null` when the release has no such asset. */
|
|
67
|
-
fetchReleaseAsset(url: string): Promise<string | null>;
|
|
68
|
-
/** The migration ids the database's ledger holds, in the order applied. */
|
|
69
|
-
appliedMigrationIds(owner: HyperdriveOrigin): Promise<string[]>;
|
|
70
|
-
/** Runs `migration.sql` and records `migration.id` in the ledger in one transaction. */
|
|
71
|
-
applyMigration(owner: HyperdriveOrigin, migration: DatabaseMigration): Promise<void>;
|
|
72
63
|
/**
|
|
73
|
-
*
|
|
74
|
-
*
|
|
64
|
+
* `uses.audit`: runs the sealing role's SQL (`audit/sealer-role.ts`) as the database owner. Idempotent: it makes
|
|
65
|
+
* the role or sets its password again, and grants. Optional: a host without it gets no sealing role, the entry's
|
|
66
|
+
* `audit:ledger` step stays blocked and says so.
|
|
75
67
|
*/
|
|
76
|
-
|
|
68
|
+
ensureSealerRole?(input: {
|
|
77
69
|
owner: HyperdriveOrigin;
|
|
78
|
-
|
|
70
|
+
sealer: {
|
|
79
71
|
user: string;
|
|
80
72
|
password: string;
|
|
81
73
|
};
|
|
82
|
-
|
|
74
|
+
statements: string[];
|
|
83
75
|
}): Promise<void>;
|
|
76
|
+
/** The text of a release asset of a wtfalch repo, or `null` when the release has no such asset. */
|
|
77
|
+
fetchReleaseAsset(url: string): Promise<string | null>;
|
|
84
78
|
}
|
|
85
|
-
export declare const WORKER_APP_MIGRATION_TIMEOUT_MS = 300000;
|
|
86
|
-
export declare const MIGRATIONS_ASSET_NAME = "migrations.json";
|
|
87
79
|
/** The Hyperdrive config, `{name, id}` as JSON. No secret. */
|
|
88
80
|
export declare const workerAppHyperdriveScopeKey: (entryKey: string) => string;
|
|
89
81
|
/** The migration ids applied, a JSON list, for the plan and the operator. The database's ledger is the fact. */
|
|
@@ -94,24 +86,6 @@ export declare const workerAppDbRouteMarkerScopeKey: (entryKey: string) => strin
|
|
|
94
86
|
export declare const hyperdriveName: (scriptName: string) => string;
|
|
95
87
|
/** The tunnel hostname of an entry's database route: `<8 hex of org id>-<key>-hd.<zone>`. */
|
|
96
88
|
export declare const hyperdriveHostname: (org: DeclaredOrg, entryKey: string, zone: string) => string;
|
|
97
|
-
/**
|
|
98
|
-
* Reads a release's `migrations.json`. `null` text is an absent file: no migrations. A file that is
|
|
99
|
-
* not a list of `{id, sql}` with unique ids is refused, naming no SQL.
|
|
100
|
-
*/
|
|
101
|
-
export declare function parseMigrations(text: string | null): DatabaseMigration[];
|
|
102
|
-
/**
|
|
103
|
-
* A present `migrations.json` runs as the database owner, so its bytes must be the ones the
|
|
104
|
-
* declaration pinned. No file needs no digest; a file with no declared digest, or a different
|
|
105
|
-
* one, is refused before it is parsed. The message names neither the SQL nor the wanted digest.
|
|
106
|
-
*/
|
|
107
|
-
export declare function verifyMigrationsDigest(text: string | null, declared: string | undefined): void;
|
|
108
|
-
/** The URL of `migrations.json` beside the module: the same release, another file. */
|
|
109
|
-
export declare const migrationsUrl: (artifactUrl: string) => string;
|
|
110
|
-
/**
|
|
111
|
-
* The migrations the database does not hold, in order. The applied ledger must be the first
|
|
112
|
-
* migrations of the file: a missing or reordered one is refused (migrations are append-only).
|
|
113
|
-
*/
|
|
114
|
-
export declare function unappliedDatabaseMigrations(all: readonly DatabaseMigration[], applied: readonly string[]): DatabaseMigration[];
|
|
115
89
|
export interface WorkerAppDatabaseDeps {
|
|
116
90
|
port: WorkerAppDatabasePort;
|
|
117
91
|
tunnels: Pick<TunnelPort, 'putTunnelConfig'>;
|
|
@@ -133,12 +107,19 @@ export interface WorkerAppDatabaseDeps {
|
|
|
133
107
|
resolve(ref: string): Promise<string | null | undefined>;
|
|
134
108
|
put?(ref: string, value: string): Promise<void>;
|
|
135
109
|
};
|
|
110
|
+
/** One plain line for the operator's log (never a secret). */
|
|
111
|
+
log?: (line: string) => void;
|
|
136
112
|
/** Test hook: the timeout of a call, in ms. */
|
|
137
113
|
timeoutMs?: number;
|
|
138
114
|
/** Test hook: the timeout of one migration, in ms. */
|
|
139
115
|
migrationTimeoutMs?: number;
|
|
140
116
|
}
|
|
141
|
-
type Call = <T>(label: string, task: () => Promise<T>, ms?: number) => Promise<T>;
|
|
117
|
+
export type Call = <T>(label: string, task: () => Promise<T>, ms?: number) => Promise<T>;
|
|
118
|
+
export interface HeldAccess {
|
|
119
|
+
id: string;
|
|
120
|
+
clientId: string;
|
|
121
|
+
clientSecret: string;
|
|
122
|
+
}
|
|
142
123
|
/**
|
|
143
124
|
* Makes the database true and returns the Hyperdrive config id for the `HYPERDRIVE` binding.
|
|
144
125
|
* Secrets (the owner and runtime passwords, the Access service token) go only to the port and the
|
|
@@ -149,11 +130,38 @@ export declare function applyWorkerAppDatabase(deps: WorkerAppDatabaseDeps, org:
|
|
|
149
130
|
artifactUrl: string;
|
|
150
131
|
database: {
|
|
151
132
|
appendOnly: string[];
|
|
133
|
+
grants?: string[];
|
|
152
134
|
migrationsSha256?: string;
|
|
153
135
|
};
|
|
154
136
|
}, context: StepExecutionContext, zoneApex: string): Promise<{
|
|
155
137
|
hyperdriveId: string;
|
|
156
138
|
}>;
|
|
139
|
+
export interface DatabaseRouteInput {
|
|
140
|
+
/** The scope-id family (`bgdb.<entryKey>.*`) the route is recorded under. */
|
|
141
|
+
entryKey: string;
|
|
142
|
+
accessName: string;
|
|
143
|
+
hostname: string;
|
|
144
|
+
/** The database host the tunnel reaches, inside the Docker network. */
|
|
145
|
+
upstreamHost: string;
|
|
146
|
+
/** Part of the applied marker: a change of database rewrites the route. */
|
|
147
|
+
markerDatabase: string;
|
|
148
|
+
tunnelId: string;
|
|
149
|
+
zoneApex: string;
|
|
150
|
+
tokenId: string;
|
|
151
|
+
at: string;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* The Access app (service-token policy), the tunnel TCP rule and the proxied CNAME that put a
|
|
155
|
+
* database behind `hostname`. Idempotent; the ids are recorded and persisted as they appear.
|
|
156
|
+
* Shared by a Worker app's database (above) and the company's apps database (F4, #925).
|
|
157
|
+
*/
|
|
158
|
+
export declare function ensureDatabaseRoute(deps: Pick<WorkerAppDatabaseDeps, 'tunnels' | 'access' | 'dns' | 'zones'>, call: Call, context: StepExecutionContext, input: DatabaseRouteInput): Promise<void>;
|
|
159
|
+
/**
|
|
160
|
+
* The Access service token Hyperdrive and the migration runner present. Minted once: its id is
|
|
161
|
+
* recorded under an undelivered key and persisted before the secret is held, the delivered id
|
|
162
|
+
* replaces it after the hold. A token minted and never held is never minted again.
|
|
163
|
+
*/
|
|
164
|
+
export declare function ensureServiceToken(deps: Pick<WorkerAppDatabaseDeps, 'access' | 'heldKeys' | 'secret'>, org: DeclaredOrg, entry: Pick<HostingEntry, 'key'>, scriptName: string, at: string, call: Call, context: StepExecutionContext, put: (ref: string, value: string) => Promise<void>): Promise<HeldAccess>;
|
|
157
165
|
/** Deletes the entry's Hyperdrive config, found by the recorded name, and clears the record. */
|
|
158
166
|
export declare function deleteWorkerAppHyperdrive(port: Pick<WorkerAppDatabasePort, 'deleteHyperdrive'> | undefined, entryKey: string, recorded: Readonly<Record<string, string>>, context: Pick<StepExecutionContext, 'recordScopeId' | 'persistScopeIds'> | null, call: Call): Promise<void>;
|
|
159
167
|
/**
|
|
@@ -164,5 +172,4 @@ export declare function deleteWorkerAppHyperdrive(port: Pick<WorkerAppDatabasePo
|
|
|
164
172
|
* ids are cleared last, so a failed run is repeated by the next one. With `context` null (org
|
|
165
173
|
* destroy) the ids are not cleared: destroy drops them with the org.
|
|
166
174
|
*/
|
|
167
|
-
export declare function deleteWorkerAppDatabaseRoute(database: WorkerAppDatabaseDeps | undefined, heldKeys: Pick<NonNullable<WorkerAppDatabaseDeps['heldKeys']>, 'put'> | undefined, orgId: string, entryKey: string, recorded: Readonly<Record<string, string>>, context: Pick<StepExecutionContext, 'recordScopeId' | 'persistScopeIds'> | null, call: Call): Promise<void>;
|
|
168
|
-
export {};
|
|
175
|
+
export declare function deleteWorkerAppDatabaseRoute(database: Pick<WorkerAppDatabaseDeps, 'tunnels' | 'access' | 'dns' | 'zones'> | undefined, heldKeys: Pick<NonNullable<WorkerAppDatabaseDeps['heldKeys']>, 'put'> | undefined, orgId: string, entryKey: string, recorded: Readonly<Record<string, string>>, context: Pick<StepExecutionContext, 'recordScopeId' | 'persistScopeIds'> | null, call: Call): Promise<void>;
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
+
import { auditMigrations } from '../audit/migrations.js';
|
|
3
|
+
import { auditSealerRef, auditSealerScopeKey, auditSealerUndeliveredScopeKey, sealerRoleName, sealerRoleSql, } from '../audit/sealer-role.js';
|
|
4
|
+
import { MIGRATIONS_ASSET_NAME, WORKER_APP_MIGRATION_TIMEOUT_MS, migrationsUrl, parseMigrations, runWorkerAppMigrations, unappliedDatabaseMigrations, verifyMigrationsDigest, } from '../worker-app-migrations.js';
|
|
2
5
|
import { REMOTE_DATABASE_PORT, mergeTcpIngress, recordedHttpIngress, recordedTcpRoutes, remoteDatabaseKeys, } from './remote-database.js';
|
|
3
6
|
import { WORKER_APP_DB_SCOPE_KEYS } from './worker-app-db.js';
|
|
4
7
|
import { WORKER_APP_CALL_TIMEOUT_MS, withCallTimeout, workerAppSecretRef } from './worker-app.js';
|
|
5
|
-
|
|
6
|
-
export
|
|
7
|
-
const MIGRATIONS_MAX_CHARS = 4_000_000;
|
|
8
|
-
const MIGRATIONS_MAX_COUNT = 1000;
|
|
9
|
-
const MIGRATION_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
8
|
+
// The pure parts moved to `src/worker-app-migrations.ts` (FM4); they stay exported from here.
|
|
9
|
+
export { MIGRATIONS_ASSET_NAME, WORKER_APP_MIGRATION_TIMEOUT_MS, migrationsUrl, parseMigrations, runWorkerAppMigrations, unappliedDatabaseMigrations, verifyMigrationsDigest, };
|
|
10
10
|
const HOLD_DELIVERY_ATTEMPTS = 3;
|
|
11
11
|
// ---- scope keys ------------------------------------------------------------
|
|
12
12
|
/** The Hyperdrive config, `{name, id}` as JSON. No secret. */
|
|
@@ -19,78 +19,6 @@ export const workerAppDbRouteMarkerScopeKey = (entryKey) => `workerapp_dbroute_$
|
|
|
19
19
|
export const hyperdriveName = (scriptName) => `${scriptName}-db`;
|
|
20
20
|
/** The tunnel hostname of an entry's database route: `<8 hex of org id>-<key>-hd.<zone>`. */
|
|
21
21
|
export const hyperdriveHostname = (org, entryKey, zone) => `${createHash('sha256').update(org.id).digest('hex').slice(0, 8)}-${entryKey}-hd.${zone}`;
|
|
22
|
-
// ---- parsing ---------------------------------------------------------------
|
|
23
|
-
/**
|
|
24
|
-
* Reads a release's `migrations.json`. `null` text is an absent file: no migrations. A file that is
|
|
25
|
-
* not a list of `{id, sql}` with unique ids is refused, naming no SQL.
|
|
26
|
-
*/
|
|
27
|
-
export function parseMigrations(text) {
|
|
28
|
-
if (text === null)
|
|
29
|
-
return [];
|
|
30
|
-
if (text.length > MIGRATIONS_MAX_CHARS) {
|
|
31
|
-
throw new Error(`${MIGRATIONS_ASSET_NAME} is larger than ${MIGRATIONS_MAX_CHARS} characters`);
|
|
32
|
-
}
|
|
33
|
-
let parsed;
|
|
34
|
-
try {
|
|
35
|
-
parsed = JSON.parse(text);
|
|
36
|
-
}
|
|
37
|
-
catch {
|
|
38
|
-
throw new Error(`${MIGRATIONS_ASSET_NAME} is not JSON`);
|
|
39
|
-
}
|
|
40
|
-
if (!Array.isArray(parsed) || parsed.length > MIGRATIONS_MAX_COUNT) {
|
|
41
|
-
throw new Error(`${MIGRATIONS_ASSET_NAME} must be a list of at most ${MIGRATIONS_MAX_COUNT} migrations`);
|
|
42
|
-
}
|
|
43
|
-
const seen = new Set();
|
|
44
|
-
return parsed.map((item, index) => {
|
|
45
|
-
const record = item;
|
|
46
|
-
if (typeof item !== 'object' ||
|
|
47
|
-
item === null ||
|
|
48
|
-
Array.isArray(item) ||
|
|
49
|
-
Object.keys(item).some((key) => key !== 'id' && key !== 'sql') ||
|
|
50
|
-
typeof record?.id !== 'string' ||
|
|
51
|
-
!MIGRATION_ID.test(record.id) ||
|
|
52
|
-
typeof record.sql !== 'string' ||
|
|
53
|
-
record.sql.trim() === '') {
|
|
54
|
-
throw new Error(`${MIGRATIONS_ASSET_NAME} entry ${index} must be { id, sql } with an id of letters, digits, '.', '_' or '-' and a non-empty sql`);
|
|
55
|
-
}
|
|
56
|
-
if (seen.has(record.id)) {
|
|
57
|
-
throw new Error(`${MIGRATIONS_ASSET_NAME} names migration '${record.id}' twice`);
|
|
58
|
-
}
|
|
59
|
-
seen.add(record.id);
|
|
60
|
-
return { id: record.id, sql: record.sql };
|
|
61
|
-
});
|
|
62
|
-
}
|
|
63
|
-
/**
|
|
64
|
-
* A present `migrations.json` runs as the database owner, so its bytes must be the ones the
|
|
65
|
-
* declaration pinned. No file needs no digest; a file with no declared digest, or a different
|
|
66
|
-
* one, is refused before it is parsed. The message names neither the SQL nor the wanted digest.
|
|
67
|
-
*/
|
|
68
|
-
export function verifyMigrationsDigest(text, declared) {
|
|
69
|
-
if (text === null)
|
|
70
|
-
return;
|
|
71
|
-
if (!declared) {
|
|
72
|
-
throw new Error(`${MIGRATIONS_ASSET_NAME} is present and workerApp.database.migrationsSha256 is not declared`);
|
|
73
|
-
}
|
|
74
|
-
if (createHash('sha256').update(text, 'utf8').digest('hex') !== declared) {
|
|
75
|
-
throw new Error(`${MIGRATIONS_ASSET_NAME} does not match workerApp.database.migrationsSha256`);
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
/** The URL of `migrations.json` beside the module: the same release, another file. */
|
|
79
|
-
export const migrationsUrl = (artifactUrl) => artifactUrl.slice(0, artifactUrl.lastIndexOf('/') + 1) + MIGRATIONS_ASSET_NAME;
|
|
80
|
-
/**
|
|
81
|
-
* The migrations the database does not hold, in order. The applied ledger must be the first
|
|
82
|
-
* migrations of the file: a missing or reordered one is refused (migrations are append-only).
|
|
83
|
-
*/
|
|
84
|
-
export function unappliedDatabaseMigrations(all, applied) {
|
|
85
|
-
const appliedSet = new Set(applied);
|
|
86
|
-
const head = new Set(all.slice(0, applied.length).map((m) => m.id));
|
|
87
|
-
for (const id of appliedSet) {
|
|
88
|
-
if (!head.has(id)) {
|
|
89
|
-
throw new Error(`migration '${id}' was applied and is no longer in the same place in ${MIGRATIONS_ASSET_NAME}; migrations are append-only`);
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
return all.filter((m) => !appliedSet.has(m.id));
|
|
93
|
-
}
|
|
94
22
|
const parseHeldAccess = (raw) => {
|
|
95
23
|
if (!raw)
|
|
96
24
|
return null;
|
|
@@ -141,7 +69,6 @@ export async function applyWorkerAppDatabase(deps, org, entry, scriptName, want,
|
|
|
141
69
|
if (!heldKeys?.put)
|
|
142
70
|
throw new Error(`${at}: needs a held-key store that can put`);
|
|
143
71
|
const put = heldKeys.put;
|
|
144
|
-
const keys = remoteDatabaseKeys(entry.key);
|
|
145
72
|
// The foundry-made TLS database (worker-app-db) when recorded, else the legacy Coolify database.
|
|
146
73
|
const databaseId = context.recordedIds()[WORKER_APP_DB_SCOPE_KEYS.app] ||
|
|
147
74
|
context.recordedIds()[`coolify:database:${org.id}`];
|
|
@@ -158,49 +85,17 @@ export async function applyWorkerAppDatabase(deps, org, entry, scriptName, want,
|
|
|
158
85
|
// 1. The service token: minted once (rule 1), its secret held before anything else uses it.
|
|
159
86
|
const access = await ensureServiceToken(deps, org, entry, scriptName, at, call, context, put);
|
|
160
87
|
// 2. The Access gate and the route behind it.
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
})))).id;
|
|
173
|
-
context.recordScopeId(keys.access, accessAppId);
|
|
174
|
-
await context.persistScopeIds();
|
|
175
|
-
}
|
|
176
|
-
const appId = accessAppId;
|
|
177
|
-
const policies = await call('list Access policies', () => deps.access.listAccessPolicies(appId));
|
|
178
|
-
const hasPolicy = policies.some((p) => p.decision === 'non_identity' &&
|
|
179
|
-
(p.include ?? []).some((r) => r.service_token?.token_id === access.id));
|
|
180
|
-
if (!hasPolicy) {
|
|
181
|
-
await call('create the Access policy', () => deps.access.createAccessPolicy(appId, {
|
|
182
|
-
name: 'hyperdrive',
|
|
183
|
-
decision: 'non_identity',
|
|
184
|
-
include: [{ service_token: { token_id: access.id } }],
|
|
185
|
-
}));
|
|
186
|
-
}
|
|
187
|
-
const marker = JSON.stringify({ hostname, database: databaseId });
|
|
188
|
-
if (context.recordedIds()[workerAppDbRouteMarkerScopeKey(entry.key)] !== marker) {
|
|
189
|
-
context.recordScopeId(keys.route, JSON.stringify({ hostname, service: `tcp://${owner.host}:${REMOTE_DATABASE_PORT}` }));
|
|
190
|
-
await call('write the tunnel route', () => deps.tunnels.putTunnelConfig(tunnelId, {
|
|
191
|
-
ingress: mergeTcpIngress(recordedHttpIngress(context.recordedIds()), recordedTcpRoutes(context.recordedIds())),
|
|
192
|
-
}));
|
|
193
|
-
if ((await call('look for the DNS record', () => deps.dns.findDnsRecord(zone.name, hostname, 'CNAME'))).length === 0) {
|
|
194
|
-
await call('create the DNS record', () => deps.dns.createDnsRecord(zone.name, {
|
|
195
|
-
type: 'CNAME',
|
|
196
|
-
name: hostname,
|
|
197
|
-
content: `${tunnelId}.cfargotunnel.com`,
|
|
198
|
-
proxied: true,
|
|
199
|
-
}));
|
|
200
|
-
}
|
|
201
|
-
context.recordScopeId(workerAppDbRouteMarkerScopeKey(entry.key), marker);
|
|
202
|
-
await context.persistScopeIds();
|
|
203
|
-
}
|
|
88
|
+
await ensureDatabaseRoute(deps, call, context, {
|
|
89
|
+
entryKey: entry.key,
|
|
90
|
+
accessName: `${scriptName}-db-access`,
|
|
91
|
+
hostname,
|
|
92
|
+
upstreamHost: owner.host,
|
|
93
|
+
markerDatabase: databaseId,
|
|
94
|
+
tunnelId,
|
|
95
|
+
zoneApex,
|
|
96
|
+
tokenId: access.id,
|
|
97
|
+
at,
|
|
98
|
+
});
|
|
204
99
|
// 3. The runtime password: minted once and held, so a rerun hands out the same one.
|
|
205
100
|
const runtimeUser = `${owner.database}_rt`;
|
|
206
101
|
const rtRef = workerAppSecretRef(org.id, entry.key, 'DATABASE_RT_PASSWORD');
|
|
@@ -233,38 +128,30 @@ export async function applyWorkerAppDatabase(deps, org, entry, scriptName, want,
|
|
|
233
128
|
accessClientSecret: access.clientSecret,
|
|
234
129
|
...(tls ? { tls } : {}),
|
|
235
130
|
};
|
|
236
|
-
// 4. Migrations with the owner credentials. A failure stops the
|
|
131
|
+
// 4. Migrations with the owner credentials, then 5. the runtime role. A failure stops the
|
|
132
|
+
// deploy; ids already applied stay.
|
|
237
133
|
const text = await call('fetch the release migrations', () => port.fetchReleaseAsset(migrationsUrl(want.artifactUrl)));
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
//
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
|
|
134
|
+
await runWorkerAppMigrations(port, ownerOrigin, text, want.database, { user: runtimeUser, password: runtimePassword }, {
|
|
135
|
+
at,
|
|
136
|
+
call,
|
|
137
|
+
...(deps.migrationTimeoutMs !== undefined
|
|
138
|
+
? { migrationTimeoutMs: deps.migrationTimeoutMs }
|
|
139
|
+
: {}),
|
|
140
|
+
// `uses.audit`: the pinned audit migrations, so an app need not ship them. One it ships itself is skipped.
|
|
141
|
+
...(entry.uses?.audit === true ? { platform: auditMigrations() } : {}),
|
|
142
|
+
...(deps.log ? { log: deps.log } : {}),
|
|
143
|
+
onApplied: async (done) => {
|
|
144
|
+
context.recordScopeId(workerAppMigrationsScopeKey(entry.key), JSON.stringify(done));
|
|
145
|
+
await context.persistScopeIds();
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
// 5b. `uses.audit`: the sealing role, apart from the runtime role, after the audit tables exist.
|
|
149
|
+
if (entry.uses?.audit === true && port.ensureSealerRole) {
|
|
150
|
+
await ensureAuditSealer(deps, org, entry, owner.database, ownerOrigin, at, call, context, put);
|
|
254
151
|
}
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
await call(`apply migration '${migration.id}'`, () => port.applyMigration(ownerOrigin, migration), deps.migrationTimeoutMs ?? WORKER_APP_MIGRATION_TIMEOUT_MS);
|
|
258
|
-
done.push(migration.id);
|
|
259
|
-
context.recordScopeId(workerAppMigrationsScopeKey(entry.key), JSON.stringify(done));
|
|
260
|
-
await context.persistScopeIds();
|
|
152
|
+
else if (entry.uses?.audit === true) {
|
|
153
|
+
deps.log?.(`${at}: the sealing role was not made, the host's database port cannot make one (no ensureSealerRole); the audit log stays unregistered`);
|
|
261
154
|
}
|
|
262
|
-
// 5. The runtime role, after the migrations so the tables exist for the revokes.
|
|
263
|
-
await call('ensure the runtime role', () => port.ensureRuntimeRole({
|
|
264
|
-
owner: ownerOrigin,
|
|
265
|
-
runtime: { user: runtimeUser, password: runtimePassword },
|
|
266
|
-
appendOnly: [...want.database.appendOnly],
|
|
267
|
-
}));
|
|
268
155
|
// 6. Hyperdrive, with the runtime credentials only.
|
|
269
156
|
const name = hyperdriveName(scriptName);
|
|
270
157
|
const { id } = await call('upsert the Hyperdrive config', () => port.upsertHyperdrive({
|
|
@@ -283,12 +170,121 @@ export async function applyWorkerAppDatabase(deps, org, entry, scriptName, want,
|
|
|
283
170
|
await context.persistScopeIds();
|
|
284
171
|
return { hyperdriveId: id };
|
|
285
172
|
}
|
|
173
|
+
/**
|
|
174
|
+
* The Access app (service-token policy), the tunnel TCP rule and the proxied CNAME that put a
|
|
175
|
+
* database behind `hostname`. Idempotent; the ids are recorded and persisted as they appear.
|
|
176
|
+
* Shared by a Worker app's database (above) and the company's apps database (F4, #925).
|
|
177
|
+
*/
|
|
178
|
+
export async function ensureDatabaseRoute(deps, call, context, input) {
|
|
179
|
+
const { hostname, tunnelId, zoneApex, entryKey } = input;
|
|
180
|
+
const keys = remoteDatabaseKeys(entryKey);
|
|
181
|
+
const zone = await call('find the zone', () => deps.zones.findZoneByApex(zoneApex));
|
|
182
|
+
if (!zone)
|
|
183
|
+
throw new Error(`${input.at}: zone ${zoneApex} is not live`);
|
|
184
|
+
let accessAppId = context.recordedIds()[keys.access];
|
|
185
|
+
if (!accessAppId) {
|
|
186
|
+
const live = (await call('list Access apps', () => deps.access.listAccessApps())).find((a) => a.domain === hostname);
|
|
187
|
+
accessAppId = (live ??
|
|
188
|
+
(await call('create the Access app', () => deps.access.createAccessApp({
|
|
189
|
+
name: input.accessName,
|
|
190
|
+
domain: hostname,
|
|
191
|
+
type: 'self_hosted',
|
|
192
|
+
})))).id;
|
|
193
|
+
context.recordScopeId(keys.access, accessAppId);
|
|
194
|
+
await context.persistScopeIds();
|
|
195
|
+
}
|
|
196
|
+
const appId = accessAppId;
|
|
197
|
+
const policies = await call('list Access policies', () => deps.access.listAccessPolicies(appId));
|
|
198
|
+
const hasPolicy = policies.some((p) => p.decision === 'non_identity' &&
|
|
199
|
+
(p.include ?? []).some((r) => r.service_token?.token_id ===
|
|
200
|
+
input.tokenId));
|
|
201
|
+
if (!hasPolicy) {
|
|
202
|
+
await call('create the Access policy', () => deps.access.createAccessPolicy(appId, {
|
|
203
|
+
name: 'hyperdrive',
|
|
204
|
+
decision: 'non_identity',
|
|
205
|
+
include: [{ service_token: { token_id: input.tokenId } }],
|
|
206
|
+
}));
|
|
207
|
+
}
|
|
208
|
+
const marker = JSON.stringify({ hostname, database: input.markerDatabase });
|
|
209
|
+
if (context.recordedIds()[workerAppDbRouteMarkerScopeKey(entryKey)] !== marker) {
|
|
210
|
+
context.recordScopeId(keys.route, JSON.stringify({ hostname, service: `tcp://${input.upstreamHost}:${REMOTE_DATABASE_PORT}` }));
|
|
211
|
+
await call('write the tunnel route', () => deps.tunnels.putTunnelConfig(tunnelId, {
|
|
212
|
+
ingress: mergeTcpIngress(recordedHttpIngress(context.recordedIds()), recordedTcpRoutes(context.recordedIds())),
|
|
213
|
+
}));
|
|
214
|
+
const existingRecords = await call('look for the DNS record', () => deps.dns.findDnsRecord(zone.name, hostname, 'CNAME'));
|
|
215
|
+
const mine = `${tunnelId}.cfargotunnel.com`;
|
|
216
|
+
if (existingRecords.some((r) => r.content !== mine)) {
|
|
217
|
+
throw new Error(`${input.at}: a DNS record named ${hostname} already exists and does not point at this company's tunnel; foundry will not reuse or change it. Delete that record in Cloudflare if it is not needed, then apply again`);
|
|
218
|
+
}
|
|
219
|
+
if (existingRecords.length === 0) {
|
|
220
|
+
await call('create the DNS record', () => deps.dns.createDnsRecord(zone.name, {
|
|
221
|
+
type: 'CNAME',
|
|
222
|
+
name: hostname,
|
|
223
|
+
content: `${tunnelId}.cfargotunnel.com`,
|
|
224
|
+
proxied: true,
|
|
225
|
+
}));
|
|
226
|
+
}
|
|
227
|
+
context.recordScopeId(workerAppDbRouteMarkerScopeKey(entryKey), marker);
|
|
228
|
+
await context.persistScopeIds();
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* `uses.audit` (foundry#929): the role the platform seals this entry's ledger as. Its password is minted once
|
|
233
|
+
* (rule 1): the role name is recorded as undelivered and persisted before the password is held, and a password
|
|
234
|
+
* minted and never held is never minted again. The password reaches only the held keys and the port; the
|
|
235
|
+
* recorded delivered key holds the role name alone.
|
|
236
|
+
*/
|
|
237
|
+
async function ensureAuditSealer(deps, org, entry, database, ownerOrigin, at, call, context, put) {
|
|
238
|
+
const ensure = deps.port.ensureSealerRole;
|
|
239
|
+
const heldKeys = deps.heldKeys;
|
|
240
|
+
if (!ensure)
|
|
241
|
+
return;
|
|
242
|
+
const user = sealerRoleName(database);
|
|
243
|
+
const ref = auditSealerRef(org.id, entry.key);
|
|
244
|
+
const undeliveredKey = auditSealerUndeliveredScopeKey(entry.key);
|
|
245
|
+
let password = (await call('read the held sealing password', () => heldKeys.resolve(ref))) ?? '';
|
|
246
|
+
if (!password) {
|
|
247
|
+
const lost = context.recordedIds()[undeliveredKey];
|
|
248
|
+
if (lost) {
|
|
249
|
+
throw new Error(`${at}: the sealing password for role ${lost} was minted and never reached the held-key store; clear the recorded ${undeliveredKey} to mint it again`);
|
|
250
|
+
}
|
|
251
|
+
password = randomBytes(24).toString('base64url');
|
|
252
|
+
deps.secret(password);
|
|
253
|
+
context.recordScopeId(undeliveredKey, user);
|
|
254
|
+
await context.persistScopeIds();
|
|
255
|
+
const minted = password;
|
|
256
|
+
let stored = false;
|
|
257
|
+
for (let attempt = 1; attempt <= HOLD_DELIVERY_ATTEMPTS && !stored; attempt++) {
|
|
258
|
+
try {
|
|
259
|
+
await call('hold the sealing password', () => put(ref, minted));
|
|
260
|
+
stored = true;
|
|
261
|
+
}
|
|
262
|
+
catch {
|
|
263
|
+
// Retried; the cause may echo the secret and is dropped.
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
if (!stored) {
|
|
267
|
+
throw new Error(`${at}: the sealing password for role ${user} could not be held after ${HOLD_DELIVERY_ATTEMPTS} attempts; clear the recorded ${undeliveredKey} to mint it again`);
|
|
268
|
+
}
|
|
269
|
+
context.recordScopeId(undeliveredKey, '');
|
|
270
|
+
await context.persistScopeIds();
|
|
271
|
+
}
|
|
272
|
+
deps.secret(password);
|
|
273
|
+
const sealer = { user, password };
|
|
274
|
+
await call('make the sealing role', () => ensure.call(deps.port, {
|
|
275
|
+
owner: ownerOrigin,
|
|
276
|
+
sealer,
|
|
277
|
+
statements: sealerRoleSql(user, password).statements,
|
|
278
|
+
}));
|
|
279
|
+
context.recordScopeId(auditSealerScopeKey(entry.key), user);
|
|
280
|
+
await context.persistScopeIds();
|
|
281
|
+
}
|
|
286
282
|
/**
|
|
287
283
|
* The Access service token Hyperdrive and the migration runner present. Minted once: its id is
|
|
288
284
|
* recorded under an undelivered key and persisted before the secret is held, the delivered id
|
|
289
285
|
* replaces it after the hold. A token minted and never held is never minted again.
|
|
290
286
|
*/
|
|
291
|
-
async function ensureServiceToken(deps, org, entry, scriptName, at, call, context, put) {
|
|
287
|
+
export async function ensureServiceToken(deps, org, entry, scriptName, at, call, context, put) {
|
|
292
288
|
const keys = remoteDatabaseKeys(entry.key);
|
|
293
289
|
const ref = workerAppSecretRef(org.id, entry.key, 'HYPERDRIVE_ACCESS');
|
|
294
290
|
const heldKeys = deps.heldKeys;
|
|
@@ -404,8 +400,10 @@ export async function deleteWorkerAppDatabaseRoute(database, heldKeys, orgId, en
|
|
|
404
400
|
const zone = await call('find the zone', () => database.zones.findZoneByApex(hostname.slice(hostname.indexOf('.') + 1)));
|
|
405
401
|
if (zone) {
|
|
406
402
|
const records = await call('look for the DNS record', () => database.dns.findDnsRecord(zone.name, hostname, 'CNAME'));
|
|
403
|
+
const ownTunnel = recorded[`cloudflare:tunnel:${orgId}`];
|
|
407
404
|
for (const record of records) {
|
|
408
|
-
|
|
405
|
+
// Only this company's own tunnel's record; anything else at that name is not ours to delete.
|
|
406
|
+
if (!ownTunnel || record.content !== `${ownTunnel}.cfargotunnel.com`)
|
|
409
407
|
continue;
|
|
410
408
|
await call('delete the DNS record', () => tolerateGone(() => database.dns.deleteDnsRecord(zone.name, record.id)));
|
|
411
409
|
}
|