@wtfalch/foundry 0.6.0 → 0.7.1
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.d.ts +3 -1
- package/dist/bootstrap/declare.js +6 -2
- package/dist/bootstrap/versions.d.ts +17 -0
- package/dist/bootstrap/versions.js +12 -0
- package/dist/calls-sfu.d.ts +5 -4
- package/dist/calls-sfu.js +6 -4
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/migrations/0022_chat_enabled.sql +6 -0
- package/dist/migrations/0023_hosting_app_env.sql +8 -0
- package/dist/migrations/0024_audit_events.sql +211 -0
- package/dist/migrations/0025_audit_display.sql +147 -0
- package/dist/migrations/0026_audit_erase_email_guard.sql +78 -0
- package/dist/migrations/0027_audit_chain.sql +170 -0
- package/dist/migrations/0028_audit_rls.sql +111 -0
- package/dist/org.d.ts +24 -0
- package/dist/org.js +13 -0
- package/dist/plan/agora.d.ts +21 -0
- package/dist/plan/agora.js +23 -0
- package/dist/plan/calls-matrix.d.ts +2 -2
- package/dist/plan/calls-matrix.js +3 -2
- package/dist/plan/cloudflared-connector.d.ts +3 -2
- package/dist/plan/cloudflared-connector.js +5 -3
- package/dist/plan/diff.d.ts +15 -2
- package/dist/plan/diff.js +306 -41
- package/dist/plan/executor.d.ts +73 -28
- package/dist/plan/executor.js +217 -55
- package/dist/plan/index.d.ts +2 -0
- package/dist/plan/index.js +1 -0
- package/dist/plan/login-host.d.ts +64 -0
- package/dist/plan/login-host.js +52 -0
- package/dist/plan/plan-apply.d.ts +21 -8
- package/dist/plan/plan-apply.js +10 -9
- package/dist/plan/platform-diff.d.ts +23 -1
- package/dist/plan/platform-diff.js +35 -0
- package/dist/plan/platform-providers.d.ts +1 -1
- package/dist/plan/platform-providers.js +71 -16
- package/dist/plan/providers-readers.js +44 -1
- package/dist/store/audit.d.ts +40 -4
- package/dist/store/audit.js +114 -29
- package/dist/store/postgres.js +4 -1
- package/dist/store/schema.d.ts +37 -1
- package/dist/store/schema.js +7 -0
- package/package.json +5 -3
- package/versions.json +8 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { OrgId } from '../org.js';
|
|
1
|
+
import type { DeclaredOrg, OrgId } from '../org.js';
|
|
2
2
|
import type { Plan, UnmanagedItem } from '../plan.js';
|
|
3
3
|
import type { DeclaredPlatform } from '../platform.js';
|
|
4
4
|
import type { PlannedStep } from './diff.js';
|
|
@@ -268,6 +268,28 @@ export declare function platformMailMxHost(apex: string): string;
|
|
|
268
268
|
*/
|
|
269
269
|
export declare const ORG_MAIL_APPLIED_APEX_SCOPE_KEY = "mail_applied_apex";
|
|
270
270
|
export declare const ORG_MAIL_APPLIED_HOST_SCOPE_KEY = "mail_applied_host";
|
|
271
|
+
/**
|
|
272
|
+
* #243: the org's `hosting.appEnv` values the last `coolify:app` apply pushed,
|
|
273
|
+
* recorded as one JSON object (env name to value; ids and a domain, never a
|
|
274
|
+
* secret). `planApp` plans the step again when {@link resolveAppEnv} differs
|
|
275
|
+
* from it, so a changed map reaches an app that is already live.
|
|
276
|
+
*/
|
|
277
|
+
export declare const APP_ENV_APPLIED_SCOPE_KEY = "app_env_applied";
|
|
278
|
+
/**
|
|
279
|
+
* #239: set to '1' once an app has been restarted after its database URLs
|
|
280
|
+
* were pushed. Not a URL. Without it a crash between the push and the restart
|
|
281
|
+
* would leave a stale container that no plan notices.
|
|
282
|
+
*/
|
|
283
|
+
export declare const DB_ENV_APPLIED_SCOPE_KEY = "db_env_applied";
|
|
284
|
+
/**
|
|
285
|
+
* The `hosting.appEnv` entries whose source has a recorded value, in env-name
|
|
286
|
+
* order, and the env names whose source has none yet. Shared by `planApp` and
|
|
287
|
+
* the executor's `pushAppEnv` so both read the same recorded ids.
|
|
288
|
+
*/
|
|
289
|
+
export declare function resolveAppEnv(org: DeclaredOrg, recorded: Readonly<Record<string, string>>): {
|
|
290
|
+
values: Record<string, string>;
|
|
291
|
+
unresolved: Array<[string, string]>;
|
|
292
|
+
};
|
|
271
293
|
/**
|
|
272
294
|
* #172: the resource key `planAi` plans under and `FoundryPlatformStepExecutor`
|
|
273
295
|
* dispatches on, the same `platform:<service>` shape as
|
|
@@ -273,6 +273,41 @@ export function platformMailMxHost(apex) {
|
|
|
273
273
|
*/
|
|
274
274
|
export const ORG_MAIL_APPLIED_APEX_SCOPE_KEY = 'mail_applied_apex';
|
|
275
275
|
export const ORG_MAIL_APPLIED_HOST_SCOPE_KEY = 'mail_applied_host';
|
|
276
|
+
/**
|
|
277
|
+
* #243: the org's `hosting.appEnv` values the last `coolify:app` apply pushed,
|
|
278
|
+
* recorded as one JSON object (env name to value; ids and a domain, never a
|
|
279
|
+
* secret). `planApp` plans the step again when {@link resolveAppEnv} differs
|
|
280
|
+
* from it, so a changed map reaches an app that is already live.
|
|
281
|
+
*/
|
|
282
|
+
export const APP_ENV_APPLIED_SCOPE_KEY = 'app_env_applied';
|
|
283
|
+
/**
|
|
284
|
+
* #239: set to '1' once an app has been restarted after its database URLs
|
|
285
|
+
* were pushed. Not a URL. Without it a crash between the push and the restart
|
|
286
|
+
* would leave a stale container that no plan notices.
|
|
287
|
+
*/
|
|
288
|
+
export const DB_ENV_APPLIED_SCOPE_KEY = 'db_env_applied';
|
|
289
|
+
/**
|
|
290
|
+
* The `hosting.appEnv` entries whose source has a recorded value, in env-name
|
|
291
|
+
* order, and the env names whose source has none yet. Shared by `planApp` and
|
|
292
|
+
* the executor's `pushAppEnv` so both read the same recorded ids.
|
|
293
|
+
*/
|
|
294
|
+
export function resolveAppEnv(org, recorded) {
|
|
295
|
+
const sources = {
|
|
296
|
+
filesOrganisationId: recorded[`files:organisation:${org.id}`],
|
|
297
|
+
aiOrganisationId: recorded[`ai:organisation:${org.id}`],
|
|
298
|
+
mailDomain: recorded.mail_domain,
|
|
299
|
+
};
|
|
300
|
+
const values = {};
|
|
301
|
+
const unresolved = [];
|
|
302
|
+
for (const [name, source] of Object.entries(org.hosting.appEnv).sort(([a], [b]) => a < b ? -1 : 1)) {
|
|
303
|
+
const value = sources[source];
|
|
304
|
+
if (value)
|
|
305
|
+
values[name] = value;
|
|
306
|
+
else
|
|
307
|
+
unresolved.push([name, source]);
|
|
308
|
+
}
|
|
309
|
+
return { values, unresolved };
|
|
310
|
+
}
|
|
276
311
|
/**
|
|
277
312
|
* #172: the resource key `planAi` plans under and `FoundryPlatformStepExecutor`
|
|
278
313
|
* dispatches on, the same `platform:<service>` shape as
|
|
@@ -291,7 +291,7 @@ export interface PlatformMailRuntime {
|
|
|
291
291
|
* {@link PlatformStepExecutionContext}, bundled like {@link PlatformAuthDeps}.
|
|
292
292
|
*/
|
|
293
293
|
export interface PlatformArchonDeps {
|
|
294
|
-
coolify: Pick<CoolifyPort, 'findProjectByName' | 'createProject' | 'findOrCreateEnvironment' | 'listServers' | 'findApplicationByName' | 'getApplication' | 'requireGithubApp' | 'createGithubAppApplication' | 'createDockerfileApplication' | 'createPostgresDatabase' | 'getDatabase' | 'bulkUpsertEnvs' | 'listEnvs' | 'updateApplication' | 'deploy'>;
|
|
294
|
+
coolify: Pick<CoolifyPort, 'findProjectByName' | 'createProject' | 'findOrCreateEnvironment' | 'listServers' | 'findApplicationByName' | 'getApplication' | 'requireGithubApp' | 'createGithubAppApplication' | 'createDockerfileApplication' | 'listDatabases' | 'createPostgresDatabase' | 'getDatabase' | 'bulkUpsertEnvs' | 'listEnvs' | 'updateApplication' | 'deploy'>;
|
|
295
295
|
cloudflare: Pick<CloudflarePort, 'findZoneByApex' | 'findTunnelByName' | 'createTunnel' | 'putTunnelConfig' | 'getTunnelToken' | 'findDnsRecord' | 'createDnsRecord' | 'updateDnsRecord' | 'listAccessApps' | 'createAccessApp' | 'listAccessPolicies' | 'createAccessPolicy' | 'deleteAccessApp'>;
|
|
296
296
|
/** Must target the platform's own ZITADEL instance, or `zitadelForInstance` must be set. */
|
|
297
297
|
zitadel: Pick<ZitadelPort, 'findOrCreateOrg' | 'findOrCreateProject' | 'createOidcApp' | 'listApps' | 'deleteApp'>;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createHash, createPrivateKey, generateKeyPairSync, randomBytes, randomUUID, sign, } from 'node:crypto';
|
|
2
2
|
import { isIPv4 } from 'node:net';
|
|
3
|
+
import { VERSIONS } from '../bootstrap/versions.js';
|
|
3
4
|
import { redactSecrets } from '../calls-sfu-deploy-support.js';
|
|
4
5
|
import { ensureCloudflaredConnector } from './cloudflared-connector.js';
|
|
5
6
|
import { ACCESS_POLICY_NAME, isNotFoundError, isRunningOrStarting } from './executor.js';
|
|
@@ -439,9 +440,9 @@ const PLATFORM_AUTH_BROKER_INTERNAL_NAME = 'platform-auth-broker';
|
|
|
439
440
|
const PLATFORM_AUTH_TUNNEL_NAME = 'platform-auth-tunnel';
|
|
440
441
|
/** #197: the one Coolify app that runs `cloudflared` for {@link PLATFORM_AUTH_TUNNEL_NAME}. */
|
|
441
442
|
const PLATFORM_AUTH_CLOUDFLARED_APP_NAME = 'platform-auth-cloudflared';
|
|
442
|
-
/** `wtfalch/auth`'s `docker-compose.yml`: `image: ghcr.io/zitadel/zitadel:${ZITADEL_VERSION:-v4.17.3}`. */
|
|
443
|
+
/** `wtfalch/auth`'s `docker-compose.yml`: `image: ghcr.io/zitadel/zitadel:${ZITADEL_VERSION:-v4.17.3}`. Pinned in `versions.json` (#247). */
|
|
443
444
|
const AUTH_ZITADEL_IMAGE = 'ghcr.io/zitadel/zitadel';
|
|
444
|
-
const AUTH_ZITADEL_IMAGE_TAG =
|
|
445
|
+
const AUTH_ZITADEL_IMAGE_TAG = VERSIONS.zitadel;
|
|
445
446
|
/** `wtfalch/auth`'s own `login/Dockerfile` -- `docs/deploy.md`'s "One compose service, four containers... Two images are built from this repository: the proxy... and the login". */
|
|
446
447
|
const AUTH_REPO = 'wtfalch/auth';
|
|
447
448
|
const AUTH_LOGIN_DOCKERFILE = 'login/Dockerfile';
|
|
@@ -1848,19 +1849,32 @@ async function provisionPlatformArchon(context, deps) {
|
|
|
1848
1849
|
await record(PLATFORM_ARCHON_PROJECT_SCOPE_KEY, projectUuid);
|
|
1849
1850
|
}
|
|
1850
1851
|
const { environment } = await deps.coolify.findOrCreateEnvironment(projectUuid, 'production');
|
|
1852
|
+
// #265: a database already named `platform-archon-db` (the hand-deployed
|
|
1853
|
+
// one) is adopted, never built a second time. One of that name in another
|
|
1854
|
+
// environment is refused: adopting or duplicating it would split the data.
|
|
1851
1855
|
let dbId = recorded[PLATFORM_ARCHON_DB_SCOPE_KEY];
|
|
1852
1856
|
if (!dbId) {
|
|
1853
|
-
const
|
|
1854
|
-
const
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1857
|
+
const named = (await deps.coolify.listDatabases()).filter((d) => d.name === PLATFORM_ARCHON_DB_NAME);
|
|
1858
|
+
const found = named.find((d) => d.environment_id === environment.id);
|
|
1859
|
+
if (!found && named.length > 0) {
|
|
1860
|
+
throw new Error(`platform:archon: a database named ${PLATFORM_ARCHON_DB_NAME} exists outside the ${PLATFORM_ARCHON_PROJECT} project's production environment; refusing to adopt or duplicate it. Move or rename it in Coolify and apply again`);
|
|
1861
|
+
}
|
|
1862
|
+
if (found) {
|
|
1863
|
+
dbId = found.uuid;
|
|
1864
|
+
}
|
|
1865
|
+
else {
|
|
1866
|
+
const server = await resolvePlatformServer(deps.coolify);
|
|
1867
|
+
const created = await deps.coolify.createPostgresDatabase({
|
|
1868
|
+
project_uuid: projectUuid,
|
|
1869
|
+
server_uuid: server.uuid,
|
|
1870
|
+
environment_name: environment.name,
|
|
1871
|
+
environment_uuid: environment.uuid,
|
|
1872
|
+
name: PLATFORM_ARCHON_DB_NAME,
|
|
1873
|
+
postgres_db: ARCHON_POSTGRES_DB,
|
|
1874
|
+
postgres_user: ARCHON_POSTGRES_DB,
|
|
1875
|
+
});
|
|
1876
|
+
dbId = created.uuid;
|
|
1877
|
+
}
|
|
1864
1878
|
await record(PLATFORM_ARCHON_DB_SCOPE_KEY, dbId);
|
|
1865
1879
|
}
|
|
1866
1880
|
const db = await deps.coolify.getDatabase(dbId);
|
|
@@ -1869,6 +1883,12 @@ async function provisionPlatformArchon(context, deps) {
|
|
|
1869
1883
|
}
|
|
1870
1884
|
// ---- the app ----------------------------------------------------------------
|
|
1871
1885
|
const app = await findOrCreatePlatformApp(deps.coolify, PLATFORM_ARCHON_APP_NAME, environment, async () => {
|
|
1886
|
+
// #265: the lookup above is scoped to this environment, so a same-named
|
|
1887
|
+
// app elsewhere is invisible to it. Making another one would give two
|
|
1888
|
+
// `platform-archon` apps; refuse instead.
|
|
1889
|
+
if (await deps.coolify.findApplicationByName(PLATFORM_ARCHON_APP_NAME)) {
|
|
1890
|
+
throw new Error(`platform:archon: an application named ${PLATFORM_ARCHON_APP_NAME} exists outside the ${PLATFORM_ARCHON_PROJECT} project's production environment; refusing to create a second one. Move or rename it in Coolify and apply again`);
|
|
1891
|
+
}
|
|
1872
1892
|
const server = await resolvePlatformServer(deps.coolify);
|
|
1873
1893
|
const githubApp = await deps.coolify.requireGithubApp();
|
|
1874
1894
|
return deps.coolify.createGithubAppApplication({
|
|
@@ -1944,9 +1964,20 @@ async function provisionPlatformArchon(context, deps) {
|
|
|
1944
1964
|
{ key: 'AUTH_ISSUER', value: issuer },
|
|
1945
1965
|
{ key: 'AUTH_ORGANIZATION_ID', value: org.id },
|
|
1946
1966
|
{ key: 'AUTH_CLIENT_ID', value: clientId },
|
|
1947
|
-
{ key: 'DATABASE_URL_OWNER', value: db.internal_db_url },
|
|
1948
|
-
{ key: 'REPORTING_SITE', value: 'archon' },
|
|
1949
1967
|
];
|
|
1968
|
+
// #265: an owner URL or reporting site already on the app is kept as it is.
|
|
1969
|
+
// The owner URL must name the adopted database's host, or the app's two
|
|
1970
|
+
// database URLs would point at different databases.
|
|
1971
|
+
const liveOwnerUrl = await currentEnvValue(deps.coolify, appId, 'DATABASE_URL_OWNER');
|
|
1972
|
+
if (liveOwnerUrl) {
|
|
1973
|
+
assertSameDatabaseHost('DATABASE_URL_OWNER', liveOwnerUrl, db.internal_db_url);
|
|
1974
|
+
}
|
|
1975
|
+
else {
|
|
1976
|
+
envs.push({ key: 'DATABASE_URL_OWNER', value: db.internal_db_url });
|
|
1977
|
+
}
|
|
1978
|
+
if (!(await currentEnvValue(deps.coolify, appId, 'REPORTING_SITE'))) {
|
|
1979
|
+
envs.push({ key: 'REPORTING_SITE', value: 'archon' });
|
|
1980
|
+
}
|
|
1950
1981
|
const cookieSecret = await ensureGeneratedEnv(deps.coolify, appId, 'AUTH_COOKIE_SECRET', () => randomBytes(32).toString('base64'));
|
|
1951
1982
|
if (cookieSecret)
|
|
1952
1983
|
envs.push({ key: 'AUTH_COOKIE_SECRET', value: cookieSecret });
|
|
@@ -2591,10 +2622,34 @@ async function collectArchonServiceInputs(platform, deps, recorded) {
|
|
|
2591
2622
|
* `base64url` fits `db-runtime-role.sh`'s password alphabet
|
|
2592
2623
|
* (`A-Za-z0-9_.~-`).
|
|
2593
2624
|
*/
|
|
2625
|
+
/**
|
|
2626
|
+
* #265: throws when `liveUrl` points at another host than the adopted
|
|
2627
|
+
* database. Names both hosts and nothing else: a URL carries a credential, and
|
|
2628
|
+
* Node's parse error echoes its whole input.
|
|
2629
|
+
*/
|
|
2630
|
+
function assertSameDatabaseHost(key, liveUrl, dbInternalUrl) {
|
|
2631
|
+
const hostOf = (url) => {
|
|
2632
|
+
try {
|
|
2633
|
+
return new URL(url).host;
|
|
2634
|
+
}
|
|
2635
|
+
catch {
|
|
2636
|
+
return null;
|
|
2637
|
+
}
|
|
2638
|
+
};
|
|
2639
|
+
const liveHost = hostOf(liveUrl);
|
|
2640
|
+
const dbHost = hostOf(dbInternalUrl);
|
|
2641
|
+
if (liveHost !== dbHost) {
|
|
2642
|
+
throw new Error(`platform:archon: ${key} on the app points at host ${liveHost ?? '(unparseable)'}, but the adopted database ${PLATFORM_ARCHON_DB_NAME} is at host ${dbHost ?? '(unparseable)'}; refusing to leave the app's database URLs on different databases`);
|
|
2643
|
+
}
|
|
2644
|
+
}
|
|
2594
2645
|
async function ensureArchonRuntimeUrl(coolify, appId, dbInternalUrl) {
|
|
2595
2646
|
const envs = await coolify.listEnvs(appId);
|
|
2596
|
-
|
|
2647
|
+
const live = envs.find((e) => e.key === 'DATABASE_URL' && !e.is_preview);
|
|
2648
|
+
if (live) {
|
|
2649
|
+
// #265: kept as it is, but it must name the adopted database's host.
|
|
2650
|
+
assertSameDatabaseHost('DATABASE_URL', live.value, dbInternalUrl);
|
|
2597
2651
|
return null;
|
|
2652
|
+
}
|
|
2598
2653
|
let host;
|
|
2599
2654
|
let pathname;
|
|
2600
2655
|
let search;
|
|
@@ -130,6 +130,47 @@ async function withLogVolumeConfig(client, resource, recorded) {
|
|
|
130
130
|
return resource;
|
|
131
131
|
}
|
|
132
132
|
}
|
|
133
|
+
/**
|
|
134
|
+
* #239: whether a recorded database has an internal connection URL, as
|
|
135
|
+
* `config.hasInternalUrl` (a boolean, never the URL), so `planApp` can report
|
|
136
|
+
* a database it cannot push from as blocked. A failed read leaves it absent.
|
|
137
|
+
*/
|
|
138
|
+
async function withDatabaseUrlConfig(client, resource, recorded) {
|
|
139
|
+
if (resource.kind !== 'database' || !recorded[resource.key]?.startsWith('database:')) {
|
|
140
|
+
return resource;
|
|
141
|
+
}
|
|
142
|
+
try {
|
|
143
|
+
const db = await client.getDatabase(resource.key);
|
|
144
|
+
return {
|
|
145
|
+
...resource,
|
|
146
|
+
config: { ...resource.config, hasInternalUrl: Boolean(db.internal_db_url) },
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
return resource;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* #239: the live env var NAMES (never values) of the org's own recorded app,
|
|
155
|
+
* as `config.envNames`, so `diff.ts`'s `planApp` can notice an app that
|
|
156
|
+
* lacks `DATABASE_URL_OWNER`. Failure is swallowed like
|
|
157
|
+
* {@link withLogVolumeConfig}: `envNames` stays absent and the planner reads
|
|
158
|
+
* that as nothing checked.
|
|
159
|
+
*/
|
|
160
|
+
async function withEnvNamesConfig(client, resource, recorded) {
|
|
161
|
+
if (resource.kind !== 'application' || !recorded[resource.key]?.startsWith('app:')) {
|
|
162
|
+
return resource;
|
|
163
|
+
}
|
|
164
|
+
try {
|
|
165
|
+
const envNames = (await client.listEnvs(resource.key))
|
|
166
|
+
.filter((e) => !e.is_preview)
|
|
167
|
+
.map((e) => e.key);
|
|
168
|
+
return { ...resource, config: { ...resource.config, envNames } };
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
return resource;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
133
174
|
/**
|
|
134
175
|
* #164: the org's own recorded Agora app's live `AGORA_*` env vars, as
|
|
135
176
|
* `config.env`, so `diff.ts`'s `planAgora` can compare them with what
|
|
@@ -177,7 +218,9 @@ export class ProvidersCoolifyReader {
|
|
|
177
218
|
this.resolveRecordedIds(orgId),
|
|
178
219
|
]);
|
|
179
220
|
const withLogConfig = await Promise.all(toFoundryResources('coolify', resources).map((r) => withLogVolumeConfig(this.client, r, recorded)));
|
|
180
|
-
const
|
|
221
|
+
const withEnvNames = await Promise.all(withLogConfig.map((r) => withEnvNamesConfig(this.client, r, recorded)));
|
|
222
|
+
const withDbUrl = await Promise.all(withEnvNames.map((r) => withDatabaseUrlConfig(this.client, r, recorded)));
|
|
223
|
+
const withAgoraConfig = await Promise.all(withDbUrl.map((r) => withAgoraEnvConfig(this.client, r, recorded)));
|
|
181
224
|
return applyRecordedIds(withAgoraConfig, recorded);
|
|
182
225
|
}
|
|
183
226
|
}
|
package/dist/store/audit.d.ts
CHANGED
|
@@ -1,8 +1,44 @@
|
|
|
1
1
|
import type { AuditLog } from '../plan/plan-apply.js';
|
|
2
2
|
import type { FoundryStoreDb } from './postgres.js';
|
|
3
3
|
/**
|
|
4
|
-
* foundry's own {@link AuditLog},
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* foundry's own {@link AuditLog}, backed by `@wtfalch/audit`'s ledger
|
|
5
|
+
* (#232) instead of the hand-rolled `foundry_audit_events` table
|
|
6
|
+
* (`migrations/0012_audit.sql`) this replaces. Every row: `tenantId: null`
|
|
7
|
+
* (foundry has no tenant of its own -- the org a plan is *for* is the
|
|
8
|
+
* `target`, not a tenant), `hashChain` on so a changed or deleted row is
|
|
9
|
+
* detectable (`verifyChain`), through migrations 0024-0028
|
|
10
|
+
* (`src/migrations/`, copied from `@wtfalch/audit` 0.6.0's own
|
|
11
|
+
* `migrations/0001-0005`, the same files its own `audit-migrations` CLI
|
|
12
|
+
* would copy into a host that has a `drizzle/` journal -- foundry does not,
|
|
13
|
+
* so they are hand-placed at the next free numbers instead).
|
|
14
|
+
*
|
|
15
|
+
* The `AuditLog` interface this satisfies is unchanged (`plan-apply.ts`),
|
|
16
|
+
* so `bootstrap.ts` and `redactingAudit` need no change either.
|
|
17
|
+
*
|
|
18
|
+
* Decide (#232, open): whether `foundry_audit_events`' existing rows are
|
|
19
|
+
* copied into the ledger or left as a closed, separate trail. Left as-is
|
|
20
|
+
* here -- the simpler default, and the one that forecloses nothing: a
|
|
21
|
+
* backfill can still be written later, where discarding the old rows
|
|
22
|
+
* outright could not be undone.
|
|
23
|
+
*
|
|
24
|
+
* `checkRuntimeRole` defaults to `false` here, the opposite of
|
|
25
|
+
* `createLedger`'s own default (#232, needs a human call -- flagged
|
|
26
|
+
* `ai:needs-human`, not decided here): that check requires the connection
|
|
27
|
+
* to be a real, restricted `<database>_rt` role, the one 0001's migration
|
|
28
|
+
* revokes UPDATE/DELETE/TRUNCATE from by name
|
|
29
|
+
* (`current_database() || '_rt'`). foundry has no such role for its own
|
|
30
|
+
* database -- `FOUNDRY_DATABASE_URL` is one connection, no owner/runtime
|
|
31
|
+
* split the way archon's, manage's or lokessmie's own `DATABASE_URL_OWNER`/
|
|
32
|
+
* `DATABASE_URL` pair is (see #239, the same split for a *hosted* app's
|
|
33
|
+
* database -- this is foundry's *own*). Leaving the default on would make
|
|
34
|
+
* every real call throw `UnsafeRuntimeRoleError`, since the revoke in
|
|
35
|
+
* 0001_audit.sql never ran against whatever role `FOUNDRY_DATABASE_URL`
|
|
36
|
+
* actually is; the append-only guard and the chain still hold at the
|
|
37
|
+
* database level regardless (0001's triggers, 0004's hashing), only the
|
|
38
|
+
* extra fail-closed check on the *connection* is off. Options, not a second
|
|
39
|
+
* positional argument (`CLAUDE.md` rule 7): a caller -- today only a test
|
|
40
|
+
* fixture -- can pass `true` once foundry's own database gets that split.
|
|
7
41
|
*/
|
|
8
|
-
export declare function createPostgresAuditLog(db: FoundryStoreDb
|
|
42
|
+
export declare function createPostgresAuditLog(db: FoundryStoreDb, options?: {
|
|
43
|
+
checkRuntimeRole?: boolean;
|
|
44
|
+
}): AuditLog;
|
package/dist/store/audit.js
CHANGED
|
@@ -1,44 +1,129 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createLedger, ledgerVocabulary } from '@wtfalch/audit';
|
|
2
2
|
/**
|
|
3
|
-
* foundry's own
|
|
4
|
-
* `
|
|
5
|
-
*
|
|
3
|
+
* foundry's own closed vocabulary (#232): one event per `AuditLog` method,
|
|
4
|
+
* `outcome` carrying how it went rather than the action name repeating it
|
|
5
|
+
* (the same split `@wtfalch/audit`'s own "service with no tenant database"
|
|
6
|
+
* example uses: `action: 'plan.applied'`, `outcome: 'refused'`). `actor`
|
|
7
|
+
* has one class, `'operator'`, because `AuditActor` (`plan-apply.ts`) does
|
|
8
|
+
* not distinguish a human from a service caller -- nothing here guesses
|
|
9
|
+
* one. `'recorded'` sorts first so it is the default outcome
|
|
10
|
+
* `plan.computed` rows get (`recordPlan` has no outcome of its own to pass).
|
|
6
11
|
*/
|
|
7
|
-
|
|
12
|
+
const FOUNDRY_LEDGER_VOCABULARY = ledgerVocabulary({
|
|
13
|
+
events: {
|
|
14
|
+
'plan.computed': { tenantVisible: false },
|
|
15
|
+
'apply.recorded': { tenantVisible: false },
|
|
16
|
+
'destroy.recorded': { tenantVisible: false },
|
|
17
|
+
},
|
|
18
|
+
actorClasses: ['operator'],
|
|
19
|
+
contexts: ['operator'],
|
|
20
|
+
outcomes: ['recorded', 'applied', 'destroyed', 'refused', 'failed'],
|
|
21
|
+
});
|
|
22
|
+
/**
|
|
23
|
+
* The ledger table's CHECKs (migration 0024): reason <= 512 chars, `after`
|
|
24
|
+
* <= 65536 bytes as jsonb text, actor_display 1-256 chars. A write that
|
|
25
|
+
* breaks one throws and would hide the apply error being recorded, so each
|
|
26
|
+
* value is bounded here. Postgres' jsonb text adds a space after every `:`
|
|
27
|
+
* and `,`, so the plan is measured against half the limit.
|
|
28
|
+
*/
|
|
29
|
+
const MAX_REASON = 512;
|
|
30
|
+
const MAX_AFTER_BYTES = 32768;
|
|
31
|
+
const MAX_DISPLAY = 256;
|
|
32
|
+
function boundReason(reason) {
|
|
33
|
+
return reason === undefined ? null : reason.slice(0, MAX_REASON);
|
|
34
|
+
}
|
|
35
|
+
function boundDisplay(actor) {
|
|
36
|
+
return (actor.display || actor.id).slice(0, MAX_DISPLAY);
|
|
37
|
+
}
|
|
38
|
+
function boundPlan(plan) {
|
|
39
|
+
return Buffer.byteLength(JSON.stringify(plan)) <= MAX_AFTER_BYTES
|
|
40
|
+
? plan
|
|
41
|
+
: { truncated: true, steps: plan.steps.length, unmanaged: plan.unmanaged.length };
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* foundry's own {@link AuditLog}, backed by `@wtfalch/audit`'s ledger
|
|
45
|
+
* (#232) instead of the hand-rolled `foundry_audit_events` table
|
|
46
|
+
* (`migrations/0012_audit.sql`) this replaces. Every row: `tenantId: null`
|
|
47
|
+
* (foundry has no tenant of its own -- the org a plan is *for* is the
|
|
48
|
+
* `target`, not a tenant), `hashChain` on so a changed or deleted row is
|
|
49
|
+
* detectable (`verifyChain`), through migrations 0024-0028
|
|
50
|
+
* (`src/migrations/`, copied from `@wtfalch/audit` 0.6.0's own
|
|
51
|
+
* `migrations/0001-0005`, the same files its own `audit-migrations` CLI
|
|
52
|
+
* would copy into a host that has a `drizzle/` journal -- foundry does not,
|
|
53
|
+
* so they are hand-placed at the next free numbers instead).
|
|
54
|
+
*
|
|
55
|
+
* The `AuditLog` interface this satisfies is unchanged (`plan-apply.ts`),
|
|
56
|
+
* so `bootstrap.ts` and `redactingAudit` need no change either.
|
|
57
|
+
*
|
|
58
|
+
* Decide (#232, open): whether `foundry_audit_events`' existing rows are
|
|
59
|
+
* copied into the ledger or left as a closed, separate trail. Left as-is
|
|
60
|
+
* here -- the simpler default, and the one that forecloses nothing: a
|
|
61
|
+
* backfill can still be written later, where discarding the old rows
|
|
62
|
+
* outright could not be undone.
|
|
63
|
+
*
|
|
64
|
+
* `checkRuntimeRole` defaults to `false` here, the opposite of
|
|
65
|
+
* `createLedger`'s own default (#232, needs a human call -- flagged
|
|
66
|
+
* `ai:needs-human`, not decided here): that check requires the connection
|
|
67
|
+
* to be a real, restricted `<database>_rt` role, the one 0001's migration
|
|
68
|
+
* revokes UPDATE/DELETE/TRUNCATE from by name
|
|
69
|
+
* (`current_database() || '_rt'`). foundry has no such role for its own
|
|
70
|
+
* database -- `FOUNDRY_DATABASE_URL` is one connection, no owner/runtime
|
|
71
|
+
* split the way archon's, manage's or lokessmie's own `DATABASE_URL_OWNER`/
|
|
72
|
+
* `DATABASE_URL` pair is (see #239, the same split for a *hosted* app's
|
|
73
|
+
* database -- this is foundry's *own*). Leaving the default on would make
|
|
74
|
+
* every real call throw `UnsafeRuntimeRoleError`, since the revoke in
|
|
75
|
+
* 0001_audit.sql never ran against whatever role `FOUNDRY_DATABASE_URL`
|
|
76
|
+
* actually is; the append-only guard and the chain still hold at the
|
|
77
|
+
* database level regardless (0001's triggers, 0004's hashing), only the
|
|
78
|
+
* extra fail-closed check on the *connection* is off. Options, not a second
|
|
79
|
+
* positional argument (`CLAUDE.md` rule 7): a caller -- today only a test
|
|
80
|
+
* fixture -- can pass `true` once foundry's own database gets that split.
|
|
81
|
+
*/
|
|
82
|
+
export function createPostgresAuditLog(db, options = {}) {
|
|
83
|
+
const ledger = createLedger({
|
|
84
|
+
vocabulary: FOUNDRY_LEDGER_VOCABULARY,
|
|
85
|
+
hashChain: true,
|
|
86
|
+
checkRuntimeRole: options.checkRuntimeRole ?? false,
|
|
87
|
+
});
|
|
88
|
+
const targetFor = (orgId, target) => ({
|
|
89
|
+
type: target === 'platform' ? 'platform' : 'org',
|
|
90
|
+
id: orgId,
|
|
91
|
+
});
|
|
8
92
|
return {
|
|
9
93
|
async recordPlan({ actor, orgId, plan, target, at }) {
|
|
10
|
-
await
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
orgId,
|
|
16
|
-
plan,
|
|
17
|
-
|
|
94
|
+
await ledger.sign(db, {
|
|
95
|
+
action: 'plan.computed',
|
|
96
|
+
tenantId: null,
|
|
97
|
+
actor: { class: 'operator', id: actor.id, display: boundDisplay(actor) },
|
|
98
|
+
context: 'operator',
|
|
99
|
+
target: targetFor(orgId, target),
|
|
100
|
+
after: boundPlan(plan),
|
|
101
|
+
occurredAt: at,
|
|
18
102
|
});
|
|
19
103
|
},
|
|
20
104
|
async recordApply({ actor, orgId, plan, outcome, reason, target, at }) {
|
|
21
|
-
await
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
orgId,
|
|
105
|
+
await ledger.sign(db, {
|
|
106
|
+
action: 'apply.recorded',
|
|
107
|
+
tenantId: null,
|
|
108
|
+
actor: { class: 'operator', id: actor.id, display: boundDisplay(actor) },
|
|
109
|
+
context: 'operator',
|
|
110
|
+
target: targetFor(orgId, target),
|
|
27
111
|
outcome,
|
|
28
|
-
reason: reason
|
|
29
|
-
plan,
|
|
30
|
-
|
|
112
|
+
reason: boundReason(reason),
|
|
113
|
+
after: boundPlan(plan),
|
|
114
|
+
occurredAt: at,
|
|
31
115
|
});
|
|
32
116
|
},
|
|
33
117
|
async recordDestroy({ actor, orgId, outcome, reason, at }) {
|
|
34
|
-
await
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
orgId,
|
|
118
|
+
await ledger.sign(db, {
|
|
119
|
+
action: 'destroy.recorded',
|
|
120
|
+
tenantId: null,
|
|
121
|
+
actor: { class: 'operator', id: actor.id, display: boundDisplay(actor) },
|
|
122
|
+
context: 'operator',
|
|
123
|
+
target: targetFor(orgId),
|
|
40
124
|
outcome,
|
|
41
|
-
reason: reason
|
|
125
|
+
reason: boundReason(reason),
|
|
126
|
+
occurredAt: at,
|
|
42
127
|
});
|
|
43
128
|
},
|
|
44
129
|
};
|
package/dist/store/postgres.js
CHANGED
|
@@ -67,7 +67,7 @@ function rowToOrg(row) {
|
|
|
67
67
|
mailApps: row.mailApps,
|
|
68
68
|
roleMailboxes: row.roleMailboxes,
|
|
69
69
|
},
|
|
70
|
-
chat: { usesDomain: row.chatUsesDomain },
|
|
70
|
+
chat: { enabled: row.chatEnabled, usesDomain: row.chatUsesDomain },
|
|
71
71
|
calls: { enabled: row.callsEnabled, usesDomain: row.callsUsesDomain },
|
|
72
72
|
issuer: { usesDomain: row.issuerUsesDomain },
|
|
73
73
|
site: { enabled: row.siteEnabled, usesDomain: row.siteUsesDomain },
|
|
@@ -80,6 +80,7 @@ function rowToOrg(row) {
|
|
|
80
80
|
buildPack: row.hostingBuildPack,
|
|
81
81
|
issuerApps: row.hostingIssuerApps,
|
|
82
82
|
accessEmails: row.hostingAccessEmails,
|
|
83
|
+
appEnv: row.hostingAppEnv,
|
|
83
84
|
},
|
|
84
85
|
sso: { enabled: row.ssoEnabled },
|
|
85
86
|
guests: { enabled: row.guestsEnabled },
|
|
@@ -107,6 +108,7 @@ function orgToRow(org) {
|
|
|
107
108
|
noReplyLocalPart: org.mail.noReplyLocalPart ?? DEFAULT_NO_REPLY_LOCAL_PART,
|
|
108
109
|
mailApps: org.mail.mailApps ?? [...DEFAULT_MAIL_APPS],
|
|
109
110
|
roleMailboxes: org.mail.roleMailboxes ?? [],
|
|
111
|
+
chatEnabled: org.chat.enabled,
|
|
110
112
|
chatUsesDomain: org.chat.usesDomain,
|
|
111
113
|
callsEnabled: org.calls.enabled,
|
|
112
114
|
callsUsesDomain: org.calls.usesDomain,
|
|
@@ -121,6 +123,7 @@ function orgToRow(org) {
|
|
|
121
123
|
hostingBuildPack: org.hosting.buildPack,
|
|
122
124
|
hostingIssuerApps: org.hosting.issuerApps,
|
|
123
125
|
hostingAccessEmails: org.hosting.accessEmails,
|
|
126
|
+
hostingAppEnv: org.hosting.appEnv ?? {},
|
|
124
127
|
ssoEnabled: org.sso.enabled,
|
|
125
128
|
guestsEnabled: org.guests.enabled,
|
|
126
129
|
soloEnabled: org.solo.enabled,
|
package/dist/store/schema.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { IssuerApp } from '../org.js';
|
|
1
|
+
import type { AppEnvSource, IssuerApp } from '../org.js';
|
|
2
2
|
import type { Plan } from '../plan.js';
|
|
3
3
|
import type { Checkpoint } from '../state.js';
|
|
4
4
|
/**
|
|
@@ -185,6 +185,23 @@ export declare const declaredOrgs: import("drizzle-orm/pg-core").PgTableWithColu
|
|
|
185
185
|
}, {}, {
|
|
186
186
|
$type: string[];
|
|
187
187
|
}>;
|
|
188
|
+
chatEnabled: import("drizzle-orm/pg-core").PgColumn<{
|
|
189
|
+
name: "chat_enabled";
|
|
190
|
+
tableName: "foundry_declared_orgs";
|
|
191
|
+
dataType: "boolean";
|
|
192
|
+
columnType: "PgBoolean";
|
|
193
|
+
data: boolean;
|
|
194
|
+
driverParam: boolean;
|
|
195
|
+
notNull: true;
|
|
196
|
+
hasDefault: true;
|
|
197
|
+
isPrimaryKey: false;
|
|
198
|
+
isAutoincrement: false;
|
|
199
|
+
hasRuntimeDefault: false;
|
|
200
|
+
enumValues: undefined;
|
|
201
|
+
baseColumn: never;
|
|
202
|
+
identity: undefined;
|
|
203
|
+
generated: undefined;
|
|
204
|
+
}, {}, {}>;
|
|
188
205
|
chatUsesDomain: import("drizzle-orm/pg-core").PgColumn<{
|
|
189
206
|
name: "chat_uses_domain";
|
|
190
207
|
tableName: "foundry_declared_orgs";
|
|
@@ -427,6 +444,25 @@ export declare const declaredOrgs: import("drizzle-orm/pg-core").PgTableWithColu
|
|
|
427
444
|
}, {}, {
|
|
428
445
|
$type: string[];
|
|
429
446
|
}>;
|
|
447
|
+
hostingAppEnv: import("drizzle-orm/pg-core").PgColumn<{
|
|
448
|
+
name: "hosting_app_env";
|
|
449
|
+
tableName: "foundry_declared_orgs";
|
|
450
|
+
dataType: "json";
|
|
451
|
+
columnType: "PgJsonb";
|
|
452
|
+
data: Record<string, AppEnvSource>;
|
|
453
|
+
driverParam: unknown;
|
|
454
|
+
notNull: true;
|
|
455
|
+
hasDefault: true;
|
|
456
|
+
isPrimaryKey: false;
|
|
457
|
+
isAutoincrement: false;
|
|
458
|
+
hasRuntimeDefault: false;
|
|
459
|
+
enumValues: undefined;
|
|
460
|
+
baseColumn: never;
|
|
461
|
+
identity: undefined;
|
|
462
|
+
generated: undefined;
|
|
463
|
+
}, {}, {
|
|
464
|
+
$type: Record<string, AppEnvSource>;
|
|
465
|
+
}>;
|
|
430
466
|
ssoEnabled: import("drizzle-orm/pg-core").PgColumn<{
|
|
431
467
|
name: "sso_enabled";
|
|
432
468
|
tableName: "foundry_declared_orgs";
|
package/dist/store/schema.js
CHANGED
|
@@ -25,6 +25,7 @@ export const declaredOrgs = pgTable('foundry_declared_orgs', {
|
|
|
25
25
|
// MailConfig.roleMailboxes (org.ts, #230). Local parts of the role mailboxes
|
|
26
26
|
// pre-created on the org's mail domain; empty by default.
|
|
27
27
|
roleMailboxes: jsonb('role_mailboxes').notNull().default([]).$type(),
|
|
28
|
+
chatEnabled: boolean('chat_enabled').notNull().default(false),
|
|
28
29
|
chatUsesDomain: boolean('chat_uses_domain').notNull().default(false),
|
|
29
30
|
callsEnabled: boolean('calls_enabled').notNull().default(false),
|
|
30
31
|
callsUsesDomain: boolean('calls_uses_domain').notNull().default(false),
|
|
@@ -41,6 +42,12 @@ export const declaredOrgs = pgTable('foundry_declared_orgs', {
|
|
|
41
42
|
hostingBuildPack: text('hosting_build_pack').notNull().default('nixpacks'),
|
|
42
43
|
hostingIssuerApps: jsonb('hosting_issuer_apps').notNull().default([]).$type(),
|
|
43
44
|
hostingAccessEmails: jsonb('hosting_access_emails').notNull().default([]).$type(),
|
|
45
|
+
// HostingConfig.appEnv (org.ts, #243). Maps the app's own env var names to
|
|
46
|
+
// values the plan already mints; empty by default.
|
|
47
|
+
hostingAppEnv: jsonb('hosting_app_env')
|
|
48
|
+
.notNull()
|
|
49
|
+
.default({})
|
|
50
|
+
.$type(),
|
|
44
51
|
ssoEnabled: boolean('sso_enabled').notNull().default(false),
|
|
45
52
|
guestsEnabled: boolean('guests_enabled').notNull().default(false),
|
|
46
53
|
soloEnabled: boolean('solo_enabled').notNull().default(false),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wtfalch/foundry",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.1",
|
|
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",
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
},
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"type": "module",
|
|
12
|
-
"files": ["dist"],
|
|
12
|
+
"files": ["dist", "versions.json"],
|
|
13
13
|
"exports": {
|
|
14
14
|
".": {
|
|
15
15
|
"types": "./dist/index.d.ts",
|
|
@@ -37,10 +37,12 @@
|
|
|
37
37
|
"drizzle-orm": ">=0.45.2"
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
+
"@wtfalch/audit": "0.6.0",
|
|
40
41
|
"@wtfalch/auth": "0.16.0",
|
|
41
42
|
"@wtfalch/email": "0.15.0",
|
|
42
43
|
"@wtfalch/providers": "0.6.0",
|
|
43
|
-
"ssh2": "1.17.0"
|
|
44
|
+
"ssh2": "1.17.0",
|
|
45
|
+
"zod": "4.6.5"
|
|
44
46
|
},
|
|
45
47
|
"devDependencies": {
|
|
46
48
|
"@electric-sql/pglite": "^0.5.8",
|