@wtfalch/foundry 0.17.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bootstrap/declare.js +6 -4
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/migrations/0043_platform_names.sql +25 -0
- package/dist/migrations/0044_platform_mail_lettermint_project.sql +11 -0
- package/dist/migrations/0045_names_to_edge.sql +107 -0
- package/dist/org.d.ts +74 -3
- package/dist/org.js +133 -0
- package/dist/plan/chat-matrix.d.ts +0 -1
- package/dist/plan/chat-matrix.js +4 -1
- package/dist/plan/diff.d.ts +18 -0
- package/dist/plan/diff.js +239 -26
- package/dist/plan/entry-edge-key.d.ts +79 -0
- package/dist/plan/entry-edge-key.js +260 -0
- package/dist/plan/executor.d.ts +246 -1
- package/dist/plan/executor.js +684 -32
- package/dist/plan/index.d.ts +2 -2
- package/dist/plan/index.js +1 -1
- package/dist/plan/live.d.ts +4 -2
- package/dist/plan/login-host.d.ts +12 -1
- package/dist/plan/login-host.js +20 -2
- package/dist/plan/plan-apply.d.ts +15 -0
- package/dist/plan/plan-apply.js +102 -9
- package/dist/plan/platform-diff.d.ts +92 -1
- package/dist/plan/platform-diff.js +166 -1
- package/dist/plan/platform-org.d.ts +29 -1
- package/dist/plan/platform-org.js +17 -2
- package/dist/plan/platform-providers.d.ts +45 -1
- package/dist/plan/platform-providers.js +514 -7
- package/dist/plan/step-system.js +1 -0
- package/dist/plan/worker.d.ts +134 -0
- package/dist/plan/worker.js +239 -0
- package/dist/platform.d.ts +22 -3
- package/dist/platform.js +6 -0
- package/dist/store/postgres.js +15 -1
- package/dist/store/schema.d.ts +70 -2
- package/dist/store/schema.js +6 -0
- package/package.json +1 -1
|
@@ -23,10 +23,12 @@ export const DEFAULT_GIT_REF = 'main';
|
|
|
23
23
|
*/
|
|
24
24
|
export const DEFAULT_MAIL_IMAGE_TAG = VERSIONS.stalwart;
|
|
25
25
|
/**
|
|
26
|
-
* Every platform service except `sfu` is enabled, like the other
|
|
27
|
-
* service whose dependencies the host did not supply is reported
|
|
28
|
-
* planned. `sfu` stays off because it needs a held SSH key and
|
|
29
|
-
* fingerprint, a separate human step (issue 170).
|
|
26
|
+
* Every platform service except `sfu` and `edge` is enabled, like the other
|
|
27
|
+
* steps: a service whose dependencies the host did not supply is reported
|
|
28
|
+
* blocked, not planned. `sfu` stays off because it needs a held SSH key and
|
|
29
|
+
* host fingerprint, a separate human step (issue 170). `edge` stays off
|
|
30
|
+
* because the edge service (N1) does not exist yet -- a human deploys it
|
|
31
|
+
* and sets its hostname before enabling it (#317's own "Human steps").
|
|
30
32
|
*/
|
|
31
33
|
const SERVICES_ENABLED_BY_DEFAULT = new Set([
|
|
32
34
|
'auth',
|
package/dist/index.d.ts
CHANGED
|
@@ -20,7 +20,7 @@ export type { FoundryStoreDb } from './store/postgres.js';
|
|
|
20
20
|
export { declaredOrgs, foundryAppliedState, foundryAppliedVersions, foundryAuditEvents, foundryDeclaredPlatform, foundryRequestedChanges, type AuditEventRow, type DeclaredOrgRow, type NewDeclaredOrgRow, type AppliedStateRow, type NewAppliedStateRow, type AppliedVersionRow, type NewAppliedVersionRow, type DeclaredPlatformRow, type NewDeclaredPlatformRow, type RequestedChangeRow, type NewRequestedChangeRow, } from './store/schema.js';
|
|
21
21
|
export type { LiveResource, ZitadelReader, CoolifyReader, CloudflareReader, FilesReader, AiReader, CmsReader, LiveReaders, PlatformCloudflareReader, PlatformCoolifyReader, PlatformLiveReaders, StepAction, PlannedStep, StepSystem, PlanDetail, PlatformPlanDetail, AdoptCandidate, AdoptInput, AdoptPlan, AdoptSnapshot, AuditActor, AuditLog, AuthzGate, StepExecutor, StepDestroyer, DestroyTarget, StepExecutionContext, PlatformStepExecutor, PlatformStepExecutionContext, FoundryPlanApplyOptions, } from './plan/index.js';
|
|
22
22
|
export { AdoptRefusedError } from './plan/index.js';
|
|
23
|
-
export { readLiveResources, readLivePlatformResources, computePlanDetail, toPlan, planPlatform, toPlatformPlan, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, planNeedsConfirmation, } from './plan/index.js';
|
|
23
|
+
export { readLiveResources, readLivePlatformResources, computePlanDetail, toPlan, planPlatform, toPlatformPlan, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, planNeedsConfirmation, } from './plan/index.js';
|
|
24
24
|
export type { HeldKeyResolver, OrgPlatformReadiness } from './plan/index.js';
|
|
25
25
|
export { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesFromPlatform, heldPlatformKeyRefs, orgPlatformReadinessFrom, } from './plan/index.js';
|
|
26
26
|
export type { PlatformOrgClientFactories } from './plan/index.js';
|
|
@@ -35,7 +35,7 @@ export { checkMailIdentityAudience } from './plan/index.js';
|
|
|
35
35
|
export type { MailAllowlistApp } from './plan/index.js';
|
|
36
36
|
export { checkMailAppsAllowlist } from './plan/index.js';
|
|
37
37
|
export { FoundryPlatformStepExecutor, ProvidersPlatformCloudflareReader, ProvidersPlatformCoolifyReader, createZitadelAdminMinter, createZitadelProjectRoles, PLATFORM_AUTH_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION, } from './plan/index.js';
|
|
38
|
-
export type { PlatformAuthDeps, PlatformAuthRuntime, PlatformFilesDeps, PlatformFilesRuntime, } from './plan/index.js';
|
|
38
|
+
export type { PlatformAuthDeps, PlatformAuthRuntime, PlatformFilesDeps, PlatformFilesRuntime, PlatformEdgeDeps, PlatformEdgeRuntime, } from './plan/index.js';
|
|
39
39
|
export type { PlatformMailDeps, PlatformMailAdminPort, PlatformMailRuntime, } from './plan/index.js';
|
|
40
40
|
export { FILES_INTROSPECTION_SECRET_REF, FILES_MANAGEMENT_ISSUER, FILES_MANAGEMENT_KEY_ID, FILES_MANAGEMENT_KEY_REF, PLATFORM_AUTH_INSTANCE_BEARER_REF, } from './plan/index.js';
|
|
41
41
|
export { AI_MANAGEMENT_ISSUER, AI_MANAGEMENT_KEY_ID, AI_SIGNING_KEY_REF, AiManagementKeyExistsError, createAiManagementKeyMinter, PLATFORM_AI_MANAGEMENT_KEY_REF, PLATFORM_AI_MANAGEMENT_PERMISSIONS, } from './plan/index.js';
|
package/dist/index.js
CHANGED
|
@@ -12,7 +12,7 @@ export { createPostgresAppliedVersionsStore, createPostgresDeclaredOrgStore, cre
|
|
|
12
12
|
export { createPostgresAuditLog } from './store/audit.js';
|
|
13
13
|
export { declaredOrgs, foundryAppliedState, foundryAppliedVersions, foundryAuditEvents, foundryDeclaredPlatform, foundryRequestedChanges, } from './store/schema.js';
|
|
14
14
|
export { AdoptRefusedError } from './plan/index.js';
|
|
15
|
-
export { readLiveResources, readLivePlatformResources, computePlanDetail, toPlan, planPlatform, toPlatformPlan, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, planNeedsConfirmation, } from './plan/index.js';
|
|
15
|
+
export { readLiveResources, readLivePlatformResources, computePlanDetail, toPlan, planPlatform, toPlatformPlan, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, planNeedsConfirmation, } from './plan/index.js';
|
|
16
16
|
export { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesFromPlatform, heldPlatformKeyRefs, orgPlatformReadinessFrom, } from './plan/index.js';
|
|
17
17
|
export { PlatformOrgBlockedError, PlatformOrgPorts } from './plan/index.js';
|
|
18
18
|
export { FoundryStepExecutor, NotBuiltError, healthUrls, loginHostnameFor } from './plan/index.js';
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
-- @wtfalch/foundry: #317 (F7) -- the names service, which mints an entry's
|
|
2
|
+
-- own names key and holds the Cloudflare token `<username>.<org domain>`
|
|
3
|
+
-- claims use, added to the declared platform the same way every other
|
|
4
|
+
-- shared service here was (#165). Mirrors `src/store/schema.ts`'s
|
|
5
|
+
-- `foundryDeclaredPlatform` table additions.
|
|
6
|
+
--
|
|
7
|
+
-- names_hostname has no static default the way every other service's
|
|
8
|
+
-- column did at table creation -- #317 is the first service added after
|
|
9
|
+
-- the table already carries its one live row, so a column that must not be
|
|
10
|
+
-- empty (src/platform.ts's `validatePlatform`) cannot default to ''.
|
|
11
|
+
-- Backfilled from this row's own apex before the column is required, so an
|
|
12
|
+
-- existing platform keeps validating unchanged, at the default hostname an
|
|
13
|
+
-- operator can still override afterwards.
|
|
14
|
+
|
|
15
|
+
alter table foundry_declared_platform
|
|
16
|
+
add column if not exists names_enabled boolean not null default false,
|
|
17
|
+
add column if not exists names_hostname text,
|
|
18
|
+
add column if not exists names_git_ref text not null default '';
|
|
19
|
+
|
|
20
|
+
update foundry_declared_platform
|
|
21
|
+
set names_hostname = 'names.' || apex
|
|
22
|
+
where names_hostname is null;
|
|
23
|
+
|
|
24
|
+
alter table foundry_declared_platform
|
|
25
|
+
alter column names_hostname set not null;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
-- @wtfalch/foundry: #491 -- the Lettermint project id of the platform's own
|
|
2
|
+
-- org (an org whose mail domain equals the platform apex, e.g. Company
|
|
3
|
+
-- Included on companyincluded.com), so that org shares the platform's
|
|
4
|
+
-- existing Lettermint project instead of getting a second `<slug>-app` one
|
|
5
|
+
-- of its own. Declared, never discovered: null until an operator sets it
|
|
6
|
+
-- (src/platform.ts's `validatePlatform` refuses a present-but-blank value,
|
|
7
|
+
-- not this column). Mirrors src/store/schema.ts's `foundryDeclaredPlatform`
|
|
8
|
+
-- table addition.
|
|
9
|
+
|
|
10
|
+
alter table foundry_declared_platform
|
|
11
|
+
add column if not exists mail_lettermint_project_id text;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
-- @wtfalch/foundry: the names service is renamed edge, and the hosting entry
|
|
2
|
+
-- field `edge` (a Cloudflare Worker) is renamed `worker`. No behaviour change.
|
|
3
|
+
-- Mirrors `src/store/schema.ts`'s `foundryDeclaredPlatform` columns.
|
|
4
|
+
--
|
|
5
|
+
-- 1. foundry_declared_platform: names_enabled / names_hostname /
|
|
6
|
+
-- names_git_ref become edge_*. Each rename runs only while the old column
|
|
7
|
+
-- exists, so a second run is a no-op.
|
|
8
|
+
-- 2. The hostname moves from `names.<apex>` to `edge.<apex>` only where it
|
|
9
|
+
-- still equals the old default (0043); an operator's own value stays.
|
|
10
|
+
-- 3. Recorded ids are rewritten in place: `names:key:`, `names:reserved-labels:`
|
|
11
|
+
-- and `platform:names` (ids and step ids), and the colon-free scope keys
|
|
12
|
+
-- `names_*` and `*_platform_names*`. The applied-hostname value is left
|
|
13
|
+
-- alone, so the planner sees a changed hostname and applies the step again.
|
|
14
|
+
-- Every WHERE clause matches only text that still holds an old key, so a
|
|
15
|
+
-- second run changes nothing. `foundry_audit_events` is a signed chain and
|
|
16
|
+
-- is not touched.
|
|
17
|
+
-- 4. foundry_declared_orgs.hosting_entries: each entry's `uses.names` becomes
|
|
18
|
+
-- `uses.edge`, and its `edge` becomes `worker`.
|
|
19
|
+
|
|
20
|
+
do $$
|
|
21
|
+
begin
|
|
22
|
+
if exists (select 1 from information_schema.columns
|
|
23
|
+
where table_schema = current_schema()
|
|
24
|
+
and table_name = 'foundry_declared_platform' and column_name = 'names_enabled') then
|
|
25
|
+
alter table foundry_declared_platform rename column names_enabled to edge_enabled;
|
|
26
|
+
end if;
|
|
27
|
+
if exists (select 1 from information_schema.columns
|
|
28
|
+
where table_schema = current_schema()
|
|
29
|
+
and table_name = 'foundry_declared_platform' and column_name = 'names_hostname') then
|
|
30
|
+
alter table foundry_declared_platform rename column names_hostname to edge_hostname;
|
|
31
|
+
end if;
|
|
32
|
+
if exists (select 1 from information_schema.columns
|
|
33
|
+
where table_schema = current_schema()
|
|
34
|
+
and table_name = 'foundry_declared_platform' and column_name = 'names_git_ref') then
|
|
35
|
+
alter table foundry_declared_platform rename column names_git_ref to edge_git_ref;
|
|
36
|
+
end if;
|
|
37
|
+
end
|
|
38
|
+
$$;
|
|
39
|
+
|
|
40
|
+
update foundry_declared_platform
|
|
41
|
+
set edge_hostname = 'edge.' || apex
|
|
42
|
+
where edge_hostname = 'names.' || apex;
|
|
43
|
+
|
|
44
|
+
-- The old-to-new text rewrite for recorded ids, applied to the jsonb as text.
|
|
45
|
+
create or replace function pg_temp.names_to_edge(value jsonb) returns jsonb
|
|
46
|
+
language sql immutable as $fn$
|
|
47
|
+
select replace(
|
|
48
|
+
replace(
|
|
49
|
+
replace(
|
|
50
|
+
replace(
|
|
51
|
+
replace(value::text, '"names:key:', '"edge:key:'),
|
|
52
|
+
'"names:reserved-labels:', '"edge:reserved-labels:'),
|
|
53
|
+
'"names_', '"edge_'),
|
|
54
|
+
'_platform_names', '_platform_edge'),
|
|
55
|
+
'platform:names', 'platform:edge')::jsonb
|
|
56
|
+
$fn$;
|
|
57
|
+
|
|
58
|
+
update foundry_declared_platform
|
|
59
|
+
set service_ids = pg_temp.names_to_edge(service_ids)
|
|
60
|
+
where service_ids::text ~ '"names[:_]|_platform_names|platform:names';
|
|
61
|
+
|
|
62
|
+
update foundry_applied_state
|
|
63
|
+
set service_ids = pg_temp.names_to_edge(service_ids)
|
|
64
|
+
where service_ids::text ~ '"names[:_]|_platform_names|platform:names';
|
|
65
|
+
|
|
66
|
+
update foundry_applied_state
|
|
67
|
+
set checkpoints = pg_temp.names_to_edge(checkpoints)
|
|
68
|
+
where checkpoints::text ~ '"names[:_]|_platform_names|platform:names';
|
|
69
|
+
|
|
70
|
+
update foundry_applied_state
|
|
71
|
+
set destroy = pg_temp.names_to_edge(destroy)
|
|
72
|
+
where destroy is not null
|
|
73
|
+
and destroy::text ~ '"names[:_]|_platform_names|platform:names';
|
|
74
|
+
|
|
75
|
+
update foundry_runs
|
|
76
|
+
set step_ids = pg_temp.names_to_edge(step_ids)
|
|
77
|
+
where step_ids::text ~ '"names[:_]|_platform_names|platform:names';
|
|
78
|
+
|
|
79
|
+
update foundry_runs
|
|
80
|
+
set checkpoints = pg_temp.names_to_edge(checkpoints)
|
|
81
|
+
where checkpoints::text ~ '"names[:_]|_platform_names|platform:names';
|
|
82
|
+
|
|
83
|
+
update foundry_runs
|
|
84
|
+
set failure = pg_temp.names_to_edge(failure)
|
|
85
|
+
where failure is not null
|
|
86
|
+
and failure::text ~ '"names[:_]|_platform_names|platform:names';
|
|
87
|
+
|
|
88
|
+
update foundry_declared_orgs o
|
|
89
|
+
set hosting_entries = (
|
|
90
|
+
select coalesce(jsonb_agg(
|
|
91
|
+
case
|
|
92
|
+
when jsonb_typeof(s -> 'uses') = 'object' and (s -> 'uses') ? 'names'
|
|
93
|
+
then jsonb_set(s, '{uses}',
|
|
94
|
+
((s -> 'uses') - 'names') || jsonb_build_object('edge', s -> 'uses' -> 'names'))
|
|
95
|
+
else s
|
|
96
|
+
end order by t.ord), '[]'::jsonb)
|
|
97
|
+
from jsonb_array_elements(o.hosting_entries) with ordinality as t(e, ord),
|
|
98
|
+
lateral (select case
|
|
99
|
+
when jsonb_typeof(t.e) = 'object' and t.e ? 'edge'
|
|
100
|
+
then (t.e - 'edge') || jsonb_build_object('worker', t.e -> 'edge')
|
|
101
|
+
else t.e
|
|
102
|
+
end as s) as r(s))
|
|
103
|
+
where exists (
|
|
104
|
+
select 1 from jsonb_array_elements(o.hosting_entries) e
|
|
105
|
+
where jsonb_typeof(e) = 'object'
|
|
106
|
+
and (e ? 'edge' or (jsonb_typeof(e -> 'uses') = 'object' and (e -> 'uses') ? 'names'))
|
|
107
|
+
);
|
package/dist/org.d.ts
CHANGED
|
@@ -331,6 +331,62 @@ export interface HostingEntry {
|
|
|
331
331
|
env: Record<string, EnvValue>;
|
|
332
332
|
/** What the entry signs in through. Absent means it uses nothing. */
|
|
333
333
|
uses?: HostingEntryUses;
|
|
334
|
+
/**
|
|
335
|
+
* F8: a Cloudflare Worker foundry deploys from this entry's own repo,
|
|
336
|
+
* beside the app, on the org's own zone. Only our own repos may use this
|
|
337
|
+
* (#318: outside customers need Workers for Platforms, not built yet), so
|
|
338
|
+
* `validateEntryWorker` refuses it unless `repo` is set (every non-null
|
|
339
|
+
* `repo` already matches `wtfalch/...`, {@link REPO}) and the route's zone
|
|
340
|
+
* is the org's own domain.
|
|
341
|
+
*/
|
|
342
|
+
worker?: WorkerConfig;
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* One Cloudflare rate limiting binding on a Worker (#318) -- Cloudflare's own
|
|
346
|
+
* "simple" shape: a fixed number of requests per period, where `period` is
|
|
347
|
+
* the only two values Cloudflare's rate limiting binding accepts.
|
|
348
|
+
*/
|
|
349
|
+
export interface WorkerRateLimitBinding {
|
|
350
|
+
name: string;
|
|
351
|
+
namespaceId: string;
|
|
352
|
+
limit: number;
|
|
353
|
+
period: 10 | 60;
|
|
354
|
+
}
|
|
355
|
+
/** {@link HostingEntry.worker}'s bindings, one list per binding kind -- only rate limits today. */
|
|
356
|
+
export interface WorkerBindings {
|
|
357
|
+
rateLimits: WorkerRateLimitBinding[];
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* A plain Worker deployed beside a hosting entry (#318), named by one route
|
|
361
|
+
* on the org's own zone. `artifactUrl`/`artifactSha256` name a *built*
|
|
362
|
+
* module -- the entry's own repo builds and publishes it (a release asset,
|
|
363
|
+
* typically), foundry only fetches and verifies it against the declared
|
|
364
|
+
* digest before uploading (estate DECISIONS.md, 2026-10-04, "offline.tf
|
|
365
|
+
* Worker setup": "The app's automated build process builds the bundle
|
|
366
|
+
* outside Foundry; Foundry consumes and deploys the artifact through the
|
|
367
|
+
* provider boundary... No manual build step and no source checkout/bundling
|
|
368
|
+
* in Foundry"). `validateEntryWorker` refuses anything that is not an
|
|
369
|
+
* `https://` URL -- a bare path or relative file name is exactly the shape
|
|
370
|
+
* this decision rules out.
|
|
371
|
+
*/
|
|
372
|
+
export interface WorkerConfig {
|
|
373
|
+
/** Where foundry fetches the built module from, behind `WorkersPort` -- never checked out or bundled here. */
|
|
374
|
+
artifactUrl: string;
|
|
375
|
+
/** The built module's sha256, lowercase hex. Verified against the fetched bytes before upload, and the sole signal `planWorkers` diffs on: an org that rebuilds under an unchanged URL redeploys only when this changes. */
|
|
376
|
+
artifactSha256: string;
|
|
377
|
+
/**
|
|
378
|
+
* One route per entry today. A wildcard route (`*.<domain>/*`) matches
|
|
379
|
+
* every subdomain, which also captures every other hostname the org
|
|
380
|
+
* serves under that domain -- its own app included, e.g. `app.<domain>`
|
|
381
|
+
* -- since Cloudflare routes the most specific match but a Worker route
|
|
382
|
+
* has no path-based exclusion of its own; declare it carefully so it
|
|
383
|
+
* does not shadow the app. The apex itself (`<domain>`, no subdomain
|
|
384
|
+
* label) is never matched by a wildcard route -- not even partially --
|
|
385
|
+
* so an org that wants the apex reachable through a Worker declares a
|
|
386
|
+
* separate entry with that exact route; there is no exclusion list.
|
|
387
|
+
*/
|
|
388
|
+
route: string;
|
|
389
|
+
bindings?: WorkerBindings;
|
|
334
390
|
}
|
|
335
391
|
/**
|
|
336
392
|
* Sign-in for one entry: one ZITADEL client and one broker entry, signing in
|
|
@@ -384,7 +440,17 @@ export interface HostingEntryUses {
|
|
|
384
440
|
auth?: EntryAuth;
|
|
385
441
|
ai?: EntryAi;
|
|
386
442
|
files?: EntryFiles;
|
|
443
|
+
/**
|
|
444
|
+
* F7 (#317): the entry gets its own edge key, minted directly below the
|
|
445
|
+
* org with the edge service, and `EDGE_URL`/`EDGE_API_KEY` in its env.
|
|
446
|
+
* Serves only the org's own domain (edge claims `<label>.<org's domain>`),
|
|
447
|
+
* so `validateEntryEdge` refuses it without `hosting.usesDomain` and an
|
|
448
|
+
* org `domain`.
|
|
449
|
+
*/
|
|
450
|
+
edge?: true;
|
|
387
451
|
}
|
|
452
|
+
/** The env names `uses.edge` writes; `env` may not name them (#317). */
|
|
453
|
+
export declare const EDGE_ENTRY_ENV_NAMES: readonly ["EDGE_URL", "EDGE_API_KEY"];
|
|
388
454
|
/**
|
|
389
455
|
* A plain string is pushed as it is, so `validateHosting` refuses one that
|
|
390
456
|
* looks like a secret. `{ from }` names a value the plan already mints.
|
|
@@ -526,9 +592,14 @@ export interface StorageConfig {
|
|
|
526
592
|
* this is the declared value `apply` pushes onto it.
|
|
527
593
|
*
|
|
528
594
|
* A bigint-safe decimal string of a non-negative integer, never converted to
|
|
529
|
-
* a number. `null` means
|
|
530
|
-
*
|
|
531
|
-
*
|
|
595
|
+
* a number. `null` means no limit is declared -- not uncapped: `apply`
|
|
596
|
+
* (`diff.ts`'s `effectiveAiLimitMicros`) substitutes a default spend cap,
|
|
597
|
+
* `DEFAULT_AI_LIMIT_MICROS`, in that case (#484; Archon creates every
|
|
598
|
+
* company this way, and AI's own budget API has no uncapped
|
|
599
|
+
* representation). A declared numeric limit always wins over the default.
|
|
600
|
+
* Spend itself (usage, money spent) is never declared (#27 "Done when"): it
|
|
601
|
+
* is a runtime fact read live from AI's usage ledger, shown beside this
|
|
602
|
+
* limit, never stored here.
|
|
532
603
|
*/
|
|
533
604
|
export interface AiConfig {
|
|
534
605
|
limitMicros: string | null;
|
package/dist/org.js
CHANGED
|
@@ -36,6 +36,8 @@ export const FILES_ENTRY_ENV_NAMES = [
|
|
|
36
36
|
'FILES_API_KEY',
|
|
37
37
|
];
|
|
38
38
|
export const filesCollectionEnvName = (name) => `FILES_COLLECTION_${name}_ID`;
|
|
39
|
+
/** The env names `uses.edge` writes; `env` may not name them (#317). */
|
|
40
|
+
export const EDGE_ENTRY_ENV_NAMES = ['EDGE_URL', 'EDGE_API_KEY'];
|
|
39
41
|
/** The org's `app` entry: today's one app. */
|
|
40
42
|
export function appEntry(hosting) {
|
|
41
43
|
return hosting.entries.find((entry) => entry.key === APP_ENTRY_KEY);
|
|
@@ -130,10 +132,30 @@ export function validateHosting(hosting, names) {
|
|
|
130
132
|
const invalidFiles = validateEntryFiles(entry, hosting, names?.domain);
|
|
131
133
|
if (invalidFiles)
|
|
132
134
|
return invalidFiles;
|
|
135
|
+
const invalidWorker = validateEntryWorker(entry, hosting, names?.domain);
|
|
136
|
+
if (invalidWorker)
|
|
137
|
+
return invalidWorker;
|
|
138
|
+
const invalidEdgeUse = validateEntryEdge(entry, hosting, names?.domain);
|
|
139
|
+
if (invalidEdgeUse)
|
|
140
|
+
return invalidEdgeUse;
|
|
133
141
|
}
|
|
134
142
|
if (hosting.entries.filter((entry) => entry.uses?.ai?.webhookPath !== undefined).length > 1) {
|
|
135
143
|
return 'at most one hosting entry may set uses.ai webhookPath: ai keeps one webhook per organisation';
|
|
136
144
|
}
|
|
145
|
+
// #318 re-review: two entries declaring the same worker route both throw at
|
|
146
|
+
// apply (`cloudflareWorker` refuses a route already bound to a Worker
|
|
147
|
+
// that is not its own), one of which stops Agora and everything planned
|
|
148
|
+
// after it provisioning at all -- refused here instead, before an apply
|
|
149
|
+
// ever reaches it.
|
|
150
|
+
const seenWorkerRoutes = new Set();
|
|
151
|
+
for (const entry of hosting.entries) {
|
|
152
|
+
if (!entry.worker)
|
|
153
|
+
continue;
|
|
154
|
+
if (seenWorkerRoutes.has(entry.worker.route)) {
|
|
155
|
+
return `hosting.entries '${entry.key}' worker route '${entry.worker.route}' is already declared by another entry`;
|
|
156
|
+
}
|
|
157
|
+
seenWorkerRoutes.add(entry.worker.route);
|
|
158
|
+
}
|
|
137
159
|
const invalidNames = validateEntryHostnames(hosting, names);
|
|
138
160
|
if (invalidNames)
|
|
139
161
|
return invalidNames;
|
|
@@ -415,6 +437,117 @@ function validateEntryFiles(entry, hosting, domain) {
|
|
|
415
437
|
return `${at} writes ${named}; remove it from env`;
|
|
416
438
|
return null;
|
|
417
439
|
}
|
|
440
|
+
/** A route pattern's zone apex: the host part before the first `/`, with one leading `*.` label stripped. */
|
|
441
|
+
function workerRouteZone(route) {
|
|
442
|
+
const slash = route.indexOf('/');
|
|
443
|
+
if (slash < 1)
|
|
444
|
+
return null;
|
|
445
|
+
const host = route.slice(0, slash);
|
|
446
|
+
return host.startsWith('*.') ? host.slice(2) : host;
|
|
447
|
+
}
|
|
448
|
+
const WORKER_ROUTE = /^(\*\.)?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+\/.*$/;
|
|
449
|
+
const WORKER_SHA256 = /^[0-9a-f]{64}$/;
|
|
450
|
+
/**
|
|
451
|
+
* Restated, not shared, from `plan/worker.ts`'s `WORKER_ORG_HASH_LENGTH`/
|
|
452
|
+
* `WORKER_NAME_MAX_LENGTH` -- importing them here would import that
|
|
453
|
+
* module's own `DeclaredOrg`/`HostingEntry` imports right back into this
|
|
454
|
+
* one. `workerName`'s shape is `${8-hex-char hash}-${entry.key}-edge`;
|
|
455
|
+
* the hash and the `-edge` suffix are fixed length, so the entry key's own
|
|
456
|
+
* budget against Cloudflare's 63-character Worker name limit is fixed too.
|
|
457
|
+
*/
|
|
458
|
+
const WORKER_NAME_FIXED_LENGTH = 8 + 1 + '-edge'.length;
|
|
459
|
+
const WORKER_NAME_MAX_LENGTH = 63;
|
|
460
|
+
/**
|
|
461
|
+
* `worker`: the entry's own repo (never the shared standard app -- entries
|
|
462
|
+
* with `repo: null` have no Worker source of their own), an `https://`
|
|
463
|
+
* artifact URL with a lowercase-hex sha256 (never a bare path or relative
|
|
464
|
+
* file -- estate DECISIONS.md, 2026-10-04, rules out any checkout or
|
|
465
|
+
* bundling inside foundry), a route whose zone is the org's own domain
|
|
466
|
+
* (#318: our own projects only, never a customer's zone), and a rate limit
|
|
467
|
+
* binding's `period` restricted to the two values Cloudflare's simple rate
|
|
468
|
+
* limiting binding accepts.
|
|
469
|
+
*/
|
|
470
|
+
function validateEntryWorker(entry, hosting, domain) {
|
|
471
|
+
const worker = entry.worker;
|
|
472
|
+
if (!worker)
|
|
473
|
+
return null;
|
|
474
|
+
const at = `hosting.entries '${entry.key}' worker`;
|
|
475
|
+
if (!hosting.enabled)
|
|
476
|
+
return `${at} requires hosting.enabled`;
|
|
477
|
+
if (entry.repo === null) {
|
|
478
|
+
return `${at} needs its own repo (repo: null is the shared standard app, not a Worker source)`;
|
|
479
|
+
}
|
|
480
|
+
if (entry.key.length > WORKER_NAME_MAX_LENGTH - WORKER_NAME_FIXED_LENGTH) {
|
|
481
|
+
return `${at} entry key is too long: the deployed Worker's name (an 8-character org hash, the key, and "-edge") must fit Cloudflare's ${WORKER_NAME_MAX_LENGTH}-character limit`;
|
|
482
|
+
}
|
|
483
|
+
let artifactUrl;
|
|
484
|
+
try {
|
|
485
|
+
artifactUrl = new URL(worker.artifactUrl);
|
|
486
|
+
}
|
|
487
|
+
catch {
|
|
488
|
+
return `${at} artifactUrl must be an absolute URL, not a bare path or relative file`;
|
|
489
|
+
}
|
|
490
|
+
if (artifactUrl.protocol !== 'https:') {
|
|
491
|
+
return `${at} artifactUrl must be an https:// URL`;
|
|
492
|
+
}
|
|
493
|
+
if (artifactUrl.username || artifactUrl.password) {
|
|
494
|
+
return `${at} artifactUrl must not carry userinfo (a signed or credentialed URL ends up stored verbatim in recorded scope ids)`;
|
|
495
|
+
}
|
|
496
|
+
if (artifactUrl.search) {
|
|
497
|
+
return `${at} artifactUrl must not carry a query string (a signed or credentialed URL ends up stored verbatim in recorded scope ids)`;
|
|
498
|
+
}
|
|
499
|
+
// `URL.hash` is '' for a bare trailing `#`, so the raw string is checked.
|
|
500
|
+
if (worker.artifactUrl.includes('#')) {
|
|
501
|
+
return `${at} artifactUrl must not carry a fragment (refused for the same reason as a query string)`;
|
|
502
|
+
}
|
|
503
|
+
if (!WORKER_SHA256.test(worker.artifactSha256)) {
|
|
504
|
+
return `${at} artifactSha256 must be 64 lowercase hex characters`;
|
|
505
|
+
}
|
|
506
|
+
if (!WORKER_ROUTE.test(worker.route)) {
|
|
507
|
+
return `${at} route must be a hostname (an optional leading '*.' label, then at least two more labels) and a path, joined by '/'`;
|
|
508
|
+
}
|
|
509
|
+
const zone = workerRouteZone(worker.route);
|
|
510
|
+
if (!domain || zone !== domain) {
|
|
511
|
+
return `${at} route '${worker.route}' must be on the org's own domain${domain ? ` (${domain})` : ''}: worker is only for our own projects`;
|
|
512
|
+
}
|
|
513
|
+
// A wildcard route (`*.<domain>/*`) is allowed beside hostnames foundry owns:
|
|
514
|
+
// `planWorkers` plans a no-Worker route for each of them, so they stay out of it.
|
|
515
|
+
for (const limit of worker.bindings?.rateLimits ?? []) {
|
|
516
|
+
if (!limit.name.trim())
|
|
517
|
+
return `${at} bindings.rateLimits needs a non-empty name`;
|
|
518
|
+
if (!limit.namespaceId.trim())
|
|
519
|
+
return `${at} bindings.rateLimits needs a non-empty namespaceId`;
|
|
520
|
+
if (!(Number.isInteger(limit.limit) && limit.limit > 0)) {
|
|
521
|
+
return `${at} bindings.rateLimits limit must be a positive whole number`;
|
|
522
|
+
}
|
|
523
|
+
if (limit.period !== 10 && limit.period !== 60) {
|
|
524
|
+
return `${at} bindings.rateLimits period must be 10 or 60 (Cloudflare allows no other value)`;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
return null;
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* `uses.edge`: serves only the org's own domain (edge claims
|
|
531
|
+
* `<label>.<domain>`), so it needs `hosting.usesDomain` and a `domain`, and
|
|
532
|
+
* no `env` entry that names a value `uses.edge` writes.
|
|
533
|
+
*/
|
|
534
|
+
function validateEntryEdge(entry, hosting, domain) {
|
|
535
|
+
const edge = entry.uses?.edge;
|
|
536
|
+
if (edge === undefined)
|
|
537
|
+
return null;
|
|
538
|
+
const at = `hosting.entries '${entry.key}' uses.edge`;
|
|
539
|
+
if (edge !== true)
|
|
540
|
+
return `${at} must be true`;
|
|
541
|
+
if (!hosting.enabled)
|
|
542
|
+
return `${at} requires hosting.enabled`;
|
|
543
|
+
if (!hosting.usesDomain || !domain) {
|
|
544
|
+
return `${at} needs hosting.usesDomain and an org domain: edge serves only the org's own domain`;
|
|
545
|
+
}
|
|
546
|
+
const named = Object.keys(entry.env ?? {}).find((name) => EDGE_ENTRY_ENV_NAMES.includes(name));
|
|
547
|
+
if (named)
|
|
548
|
+
return `${at} writes ${named}; remove it from env`;
|
|
549
|
+
return null;
|
|
550
|
+
}
|
|
418
551
|
function urlHasPassword(value) {
|
|
419
552
|
try {
|
|
420
553
|
return new URL(value).password !== '';
|
|
@@ -90,7 +90,6 @@ export interface ChatServiceEnvInput {
|
|
|
90
90
|
agoraHost: string;
|
|
91
91
|
masClientId: string;
|
|
92
92
|
masClientSecret: string;
|
|
93
|
-
databaseUrl: string;
|
|
94
93
|
}
|
|
95
94
|
/** Every variable `chat/apps/chat-service/src/config.ts` requires, plus the internal Synapse address and port. */
|
|
96
95
|
export declare function chatServiceEnvs(input: ChatServiceEnvInput): Array<{
|
package/dist/plan/chat-matrix.js
CHANGED
|
@@ -195,7 +195,10 @@ export function chatServiceEnvs(input) {
|
|
|
195
195
|
{ key: 'MAS_URL', value: upstreams.mas },
|
|
196
196
|
{ key: 'MAS_CLIENT_ID', value: input.masClientId },
|
|
197
197
|
{ key: 'MAS_CLIENT_SECRET', value: input.masClientSecret },
|
|
198
|
-
|
|
198
|
+
// DATABASE_URL_OWNER/DATABASE_URL are pushed by executor.ts's
|
|
199
|
+
// pushDatabaseUrl instead, not here (the same split coolifyApp and
|
|
200
|
+
// cmsSite follow, #239) -- that call mints a distinct <dbName>_rt
|
|
201
|
+
// runtime role and never overwrites an already-minted DATABASE_URL.
|
|
199
202
|
{ key: 'CHAT_ALLOWED_ORIGINS', value: `https://${input.agoraHost}` },
|
|
200
203
|
{ key: 'PORT', value: CHAT_SERVICE_PORT },
|
|
201
204
|
];
|
package/dist/plan/diff.d.ts
CHANGED
|
@@ -51,6 +51,10 @@ export interface PlanOrgOptions {
|
|
|
51
51
|
now?: () => number;
|
|
52
52
|
/** #374: the Archon that bills this org (`plan-apply.ts`'s `orgArchonUrl`), written into Boule's env as `ARCHON_URL`. `null` with no platform wired. */
|
|
53
53
|
archonUrl?: string | null;
|
|
54
|
+
/** #318: the executor was built with a `WorkersPort`, read by `planWorkers`. */
|
|
55
|
+
edgeWorkers?: boolean;
|
|
56
|
+
/** The platform's own service hostnames: `edgeReservedLabels` reserves them under the org's domain, and a wildcard Worker route (`planWorkers`) keeps them out. */
|
|
57
|
+
platformHostnames?: readonly string[];
|
|
54
58
|
}
|
|
55
59
|
export interface PlanDetail {
|
|
56
60
|
orgId: OrgId;
|
|
@@ -117,3 +121,17 @@ export declare function computePlanDetail(org: DeclaredOrg, live: LiveResource[]
|
|
|
117
121
|
export declare const AI_KEY_UNDELIVERED_SCOPE_KEY = "ai_key_undelivered";
|
|
118
122
|
/** Restated from `executor.ts`, the same rule: the id of a jobs project registered but not delivered. */
|
|
119
123
|
export declare const JOBS_REGISTRATION_UNDELIVERED_SCOPE_KEY = "jobs_registration_undelivered";
|
|
124
|
+
/**
|
|
125
|
+
* #484: the spend cap foundry applies when an org declares no AI limit
|
|
126
|
+
* (`org.ai.limitMicros === null`) -- Archon creates every company that way,
|
|
127
|
+
* and AI's own `updateBudget` has no uncapped representation
|
|
128
|
+
* (`UpdateBudget.limitMicros` is a required string), so a null limit used to
|
|
129
|
+
* throw `ai:budget` out of every apply. William's Decision, 2026-10-05: 100
|
|
130
|
+
* EUR, in `ai`'s own USD-micros unit (`usdToMicros`,
|
|
131
|
+
* `ai/packages/service/src/money.ts`), billing to revisit the number later.
|
|
132
|
+
* 100 EUR at the ECB reference rate for 2 October 2026 (1.1225 USD/EUR, the
|
|
133
|
+
* most recent published rate as of the decision) is 112.25 USD = 112,250,000
|
|
134
|
+
* micros. A declared numeric limit always wins over this default --
|
|
135
|
+
* {@link effectiveAiLimitMicros} is the one place that substitutes it.
|
|
136
|
+
*/
|
|
137
|
+
export declare const DEFAULT_AI_LIMIT_MICROS = "112250000";
|