@wtfalch/foundry 0.65.0 → 0.67.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.
Files changed (88) hide show
  1. package/dist/apps/cloudflare.d.ts +5 -0
  2. package/dist/apps/deploy.js +33 -1
  3. package/dist/apps/migrations.d.ts +3 -0
  4. package/dist/apps/migrations.js +32 -7
  5. package/dist/catalogue/copy-en.js +5 -0
  6. package/dist/catalogue/integrations.js +5 -0
  7. package/dist/entry-rules.d.ts +11 -0
  8. package/dist/entry-rules.js +70 -0
  9. package/dist/index.d.ts +9 -4
  10. package/dist/index.js +5 -2
  11. package/dist/migrations/0069_release_events.sql +16 -0
  12. package/dist/modules/analytics.d.ts +8 -0
  13. package/dist/modules/analytics.js +28 -0
  14. package/dist/modules/database.js +22 -3
  15. package/dist/modules/index.js +2 -0
  16. package/dist/native-build.d.ts +16 -12
  17. package/dist/native-build.js +16 -12
  18. package/dist/org.d.ts +9 -1
  19. package/dist/org.js +4 -1
  20. package/dist/plan/analytics-boule.d.ts +17 -0
  21. package/dist/plan/analytics-boule.js +43 -0
  22. package/dist/plan/analytics.d.ts +58 -0
  23. package/dist/plan/analytics.js +89 -0
  24. package/dist/plan/boule.js +2 -0
  25. package/dist/plan/company-backups.js +2 -0
  26. package/dist/plan/diff.d.ts +4 -0
  27. package/dist/plan/diff.js +103 -3
  28. package/dist/plan/entry-analytics-key.d.ts +35 -0
  29. package/dist/plan/entry-analytics-key.js +134 -0
  30. package/dist/plan/executor.d.ts +79 -6
  31. package/dist/plan/executor.js +379 -19
  32. package/dist/plan/hostnames.js +4 -0
  33. package/dist/plan/index.d.ts +2 -0
  34. package/dist/plan/index.js +1 -0
  35. package/dist/plan/live.d.ts +1 -1
  36. package/dist/plan/locked-hostnames.js +3 -0
  37. package/dist/plan/login-host.d.ts +8 -0
  38. package/dist/plan/login-host.js +8 -0
  39. package/dist/plan/org-names.d.ts +3 -3
  40. package/dist/plan/org-names.js +3 -0
  41. package/dist/plan/own-limits.d.ts +6 -0
  42. package/dist/plan/own-limits.js +1 -0
  43. package/dist/plan/plan-apply.d.ts +12 -0
  44. package/dist/plan/plan-apply.js +40 -1
  45. package/dist/plan/platform-diff.d.ts +17 -1
  46. package/dist/plan/platform-diff.js +40 -2
  47. package/dist/plan/platform-org.d.ts +6 -0
  48. package/dist/plan/platform-providers.d.ts +8 -6
  49. package/dist/plan/platform-providers.js +56 -10
  50. package/dist/plan/providers-readers.js +16 -1
  51. package/dist/plan/release-host.d.ts +30 -1
  52. package/dist/plan/release-host.js +84 -41
  53. package/dist/plan/release-series-reader.d.ts +26 -0
  54. package/dist/plan/release-series-reader.js +40 -0
  55. package/dist/plan/sign-in-archon.d.ts +1 -1
  56. package/dist/plan/sign-in-archon.js +2 -2
  57. package/dist/plan/stage-services.d.ts +3 -1
  58. package/dist/plan/stage-services.js +2 -1
  59. package/dist/plan/uptime-worker.js +53 -3
  60. package/dist/plan/worker-app-database.d.ts +12 -1
  61. package/dist/plan/worker-app-database.js +13 -0
  62. package/dist/plan/worker-app.d.ts +27 -2
  63. package/dist/plan/worker-app.js +52 -7
  64. package/dist/plan/workspace.d.ts +2 -2
  65. package/dist/plan/workspace.js +3 -3
  66. package/dist/release/decide.d.ts +2 -0
  67. package/dist/release/decide.js +67 -10
  68. package/dist/release/events.d.ts +13 -0
  69. package/dist/release/events.js +67 -0
  70. package/dist/release/health.d.ts +5 -0
  71. package/dist/release/health.js +3 -3
  72. package/dist/release/run.d.ts +7 -2
  73. package/dist/release/run.js +8 -0
  74. package/dist/release/series.d.ts +58 -0
  75. package/dist/release/series.js +65 -0
  76. package/dist/release/state.d.ts +58 -1
  77. package/dist/release/state.js +45 -0
  78. package/dist/release/types.d.ts +44 -0
  79. package/dist/slug.d.ts +1 -1
  80. package/dist/slug.js +5 -2
  81. package/dist/store/postgres.js +29 -3
  82. package/dist/store/schema.d.ts +112 -0
  83. package/dist/store/schema.js +9 -0
  84. package/dist/worker-app-config.d.ts +22 -1
  85. package/dist/worker-app-config.js +73 -15
  86. package/dist/worker-app-migrations.d.ts +6 -2
  87. package/dist/worker-app-migrations.js +1 -0
  88. package/package.json +1 -1
@@ -0,0 +1,134 @@
1
+ import { analyticsUrlFor } from './analytics.js';
2
+ import { appStepKey } from './platform-diff.js';
3
+ /**
4
+ * `uses.analytics` on a server entry: one site key of its own at the company's analytics backend, minted
5
+ * through `POST /v1/site-keys` with the operator key and written as `ANALYTICS_URL` and `ANALYTICS_SITE_KEY` to
6
+ * the entry's env. The backend shows the secret once, so this follows rule 1 of CLAUDE.md "Platform steps":
7
+ *
8
+ * 1. the key's id is recorded as `analytics_key_undelivered.<entry>` and persisted before the env is written;
9
+ * 2. the env write is tried 3 times with the same secret; the id is the main id `analytics:key:<orgId>:<entry>`
10
+ * and the applied marker only after it worked, and the undelivered marker is cleared then;
11
+ * 3. a marker left behind (every try failed) is reported blocked and never minted again.
12
+ *
13
+ * A Worker app entry is skipped: Boule installs its keys per copy. The entry's stage gets its own key through
14
+ * `plan/stage-services.ts` (service `analytics`, environment `stage`), so the two differ. Removing
15
+ * `uses.analytics` or the entry plans the retire form: revoke the key, take both env names away.
16
+ */
17
+ export const entryAnalyticsUndeliveredScopeKey = (entryKey) => `analytics_key_undelivered.${entryKey}`;
18
+ /** The address the entry's env was last written with; a change rewrites the address only. */
19
+ export const entryAnalyticsAppliedScopeKey = (entryKey) => `analytics_key_applied.${entryKey}`;
20
+ /** The id of a key that was revoked, so a stale main id is not revoked again. */
21
+ export const entryAnalyticsRetiredScopeKey = (entryKey) => `analytics_key_retired.${entryKey}`;
22
+ export const ENTRY_ANALYTICS_KEY_DELIVERY_ATTEMPTS = 3;
23
+ /** The name the backend knows the key by; its site derives from it. */
24
+ export const entryAnalyticsKeyName = (org, entryKey) => `${org.slug}-${entryKey}-analytics`;
25
+ /** The stage copy's key name: the backend reads the `-stage` suffix as the stage environment. */
26
+ export const stageAnalyticsKeyName = (org, entryKey) => `${org.slug}-${entryKey}-stage-analytics`;
27
+ const servesKey = (entry) => entry.uses?.analytics === true && !entry.workerApp && entry.live !== false;
28
+ export function planEntryAnalyticsKeys(org, recordedIds, plannedSteps, analyticsPort) {
29
+ const steps = [];
30
+ const blocked = [];
31
+ if (!org.hosting.enabled)
32
+ return { steps, blocked };
33
+ const planned = (system, kind, key) => plannedSteps.some((p) => p.action.system === system && p.action.kind === kind && p.action.key === key);
34
+ const wanted = new Map(org.hosting.entries.filter(servesKey).map((e) => [e.key, e]));
35
+ const mainPrefix = `analytics:key:${org.id}:`;
36
+ // Keys whose entry stopped using analytics or is gone: a delivered key (main id) or one minted and never
37
+ // delivered (undelivered marker), so the retire clears the marker and the entry can mint again later.
38
+ const undeliveredPrefix = entryAnalyticsUndeliveredScopeKey('');
39
+ const retiring = new Set();
40
+ for (const [name, id] of Object.entries(recordedIds)) {
41
+ if (!id)
42
+ continue;
43
+ const prefix = name.startsWith(mainPrefix)
44
+ ? mainPrefix
45
+ : name.startsWith(undeliveredPrefix)
46
+ ? undeliveredPrefix
47
+ : null;
48
+ if (!prefix)
49
+ continue;
50
+ const entryKey = name.slice(prefix.length);
51
+ if (wanted.has(entryKey) ||
52
+ retiring.has(entryKey) ||
53
+ recordedIds[entryAnalyticsRetiredScopeKey(entryKey)] === id) {
54
+ continue;
55
+ }
56
+ retiring.add(entryKey);
57
+ const key = `${mainPrefix}${entryKey}`;
58
+ if (!analyticsPort) {
59
+ blocked.push({
60
+ description: `${org.slug}'s ${entryKey} analytics key is not revoked, not planned: the host supplies no analytics port`,
61
+ key,
62
+ });
63
+ }
64
+ else if (!recordedIds[`coolify:analytics:${org.id}`]) {
65
+ blocked.push({
66
+ description: `${org.slug}'s ${entryKey} analytics key is not revoked, not planned: the analytics backend is not recorded`,
67
+ key,
68
+ });
69
+ }
70
+ else {
71
+ steps.push({
72
+ action: {
73
+ system: 'analytics',
74
+ kind: 'key',
75
+ key: `${org.id}:${entryKey}`,
76
+ desired: { mode: 'retire' },
77
+ },
78
+ step: {
79
+ description: `revoke ${org.slug}'s ${entryKey} analytics key and take ANALYTICS_* out of its env: the entry no longer uses analytics`,
80
+ destroys: true,
81
+ migrates: false,
82
+ },
83
+ });
84
+ }
85
+ }
86
+ for (const entry of wanted.values()) {
87
+ const stepKey = appStepKey(org, entry.key);
88
+ const key = `analytics:key:${stepKey}`;
89
+ const at = `${org.slug}'s ${entry.key} analytics key`;
90
+ const block = (why) => blocked.push({ description: `${at} is blocked: ${why}`, key });
91
+ const url = analyticsUrlFor(org);
92
+ const undelivered = recordedIds[entryAnalyticsUndeliveredScopeKey(entry.key)];
93
+ if (undelivered) {
94
+ block(`key ${undelivered} was minted but never written to the app, and the backend shows a key once; remove the recorded id ${entryAnalyticsUndeliveredScopeKey(entry.key)} to mint a new key, which replaces that one`);
95
+ continue;
96
+ }
97
+ const live = recordedIds[key] &&
98
+ recordedIds[entryAnalyticsRetiredScopeKey(entry.key)] !== recordedIds[key];
99
+ if (live && recordedIds[entryAnalyticsAppliedScopeKey(entry.key)] === url)
100
+ continue;
101
+ if (!analyticsPort) {
102
+ block('the host supplies no analytics port');
103
+ continue;
104
+ }
105
+ if (!recordedIds[`coolify:analytics:${org.id}`] && !planned('coolify', 'analytics', org.id)) {
106
+ block("the company's analytics backend is not recorded or planned yet");
107
+ continue;
108
+ }
109
+ if (!recordedIds[`coolify:app:${stepKey}`] && !planned('coolify', 'app', stepKey)) {
110
+ block("the entry's app is not recorded yet");
111
+ continue;
112
+ }
113
+ steps.push({
114
+ action: {
115
+ system: 'analytics',
116
+ kind: 'key',
117
+ key: stepKey,
118
+ desired: {
119
+ mode: live ? 'url' : 'mint',
120
+ name: entryAnalyticsKeyName(org, entry.key),
121
+ analyticsUrl: url,
122
+ },
123
+ },
124
+ step: {
125
+ description: live
126
+ ? `write the analytics address to ${org.slug}'s ${entry.key} env`
127
+ : `mint ${at}, write ANALYTICS_URL and ANALYTICS_SITE_KEY to its env, restart the app`,
128
+ destroys: false,
129
+ migrates: false,
130
+ },
131
+ });
132
+ }
133
+ return { steps, blocked };
134
+ }
@@ -2,6 +2,7 @@ import type { OrgMailOptions, OrgMailOrg, OrgMailResult } from '@wtfalch/email/p
2
2
  import { type CallsSfuUrls } from '../calls-sfu.js';
3
3
  import type { DeclaredOrg, HostingBuildPack, OrgId } from '../org.js';
4
4
  import type { DeclaredOrgStore } from '../store.js';
5
+ import { type AnalyticsPort } from './analytics.js';
5
6
  import { type BlueGreenServer, type BlueGreenSubject } from './blue-green.js';
6
7
  import { type BouleOwnerSeed } from './boule.js';
7
8
  import { type BrokerPort } from './broker-entries.js';
@@ -844,6 +845,11 @@ export interface ZitadelPort {
844
845
  postLogoutRedirectUris: string[];
845
846
  /** `jwt` for an entry with `uses.auth.mcp` (providers 0.11.0). Omitted: opaque. */
846
847
  accessTokenType?: 'jwt' | 'opaque';
848
+ /**
849
+ * The instance's login host. With `loginV2.required` off an app with no base URI of its own lands on
850
+ * the v1 login, so every app foundry makes carries one from the start; a `required` instance ignores it.
851
+ */
852
+ loginBaseUri?: string;
847
853
  }): Promise<{
848
854
  appId: string;
849
855
  clientId: string;
@@ -1183,12 +1189,24 @@ export interface ZitadelPort {
1183
1189
  updateOidcAppLoginBaseUri(orgId: string, projectId: string, appId: string, loginBaseUri: string): Promise<void>;
1184
1190
  /**
1185
1191
  * Lets each app's own Login V2 base URI count: foundry starts ZITADEL with `LOGINV2_REQUIRED=true`,
1186
- * which makes the instance ignore it. The host sets the instance feature (`PUT /v2/features/instance`
1192
+ * which makes the instance ignore it (`internal/query/oidc_client.go` in ZITADEL v4.17.3, lines 88-92:
1193
+ * with `required` the app's own base URI is overwritten by the instance's). Call it through
1194
+ * {@link allowPerAppLoginBaseUris}, which first gives every app a base URI. The host sets the instance feature (`PUT /v2/features/instance`
1187
1195
  * with `loginV2.required` false and the instance default as `baseUri`). Called before an app is
1188
1196
  * pointed at its own `/sign-in/` on a company that runs its own ZITADEL; the shared instance's
1189
1197
  * bindings port does the same for the platform's. Optional: absent, nothing is called.
1190
1198
  */
1191
1199
  allowPerAppLoginBaseUri?(instanceBaseUri: string): Promise<void>;
1200
+ /** Every organisation of the instance. Optional: {@link allowPerAppLoginBaseUris} refuses without it. */
1201
+ listOrgs?(): Promise<Array<{
1202
+ id: string;
1203
+ state?: string;
1204
+ }>>;
1205
+ /** Every project of an organisation. Optional: {@link allowPerAppLoginBaseUris} refuses without it. */
1206
+ listProjects?(orgId: string): Promise<Array<{
1207
+ id: string;
1208
+ state?: string;
1209
+ }>>;
1192
1210
  /**
1193
1211
  * Occupies `userName` in `orgId` with a machine user that has no role and
1194
1212
  * no token, so nobody can register under that name. Idempotent: a user
@@ -1824,6 +1842,8 @@ export interface FoundryStepExecutorPorts {
1824
1842
  workerAppKeys?: KeysCustodyPort;
1825
1843
  /** F7: the keys service's per-company registrar. Without it the plan reports Boule's registrar key blocked (the host also sets `FoundryPlanApplyOptions.keysRegistrarUrl`). */
1826
1844
  keysRegistrar?: KeysRegistrarPort;
1845
+ /** The company's analytics backend operator API: mints and revokes site keys. Without it the plan reports each `uses.analytics` key blocked (the host also sets `FoundryPlanApplyOptions.analyticsPort`). */
1846
+ analytics?: AnalyticsPort;
1827
1847
  /**
1828
1848
  * C3: mints the credential an org's Boule reaches Archon with (org-scoped; `orgs:request` and
1829
1849
  * `orgs:read` for that org). Archon implements it. Every call rotates the credential (the old secret
@@ -2003,6 +2023,10 @@ export declare const BOULE_PORT = 3000;
2003
2023
  export declare const KOSMOS_REPO = "wtfalch/kosmos";
2004
2024
  export declare const KOSMOS_APP_DOCKERFILE = "/Dockerfile";
2005
2025
  export declare const KOSMOS_PORT = 3000;
2026
+ /** The company's analytics backend (https://github.com/wtfalch/analytics): root Dockerfile, serves on 3000, migrates in its own start script. */
2027
+ export declare const ANALYTICS_REPO = "wtfalch/analytics";
2028
+ export declare const ANALYTICS_APP_DOCKERFILE = "/Dockerfile";
2029
+ export declare const ANALYTICS_PORT = 3000;
2006
2030
  export declare const LOG_DIR = "/app/logs";
2007
2031
  /** The company's Coolify destination (its Docker network `co-<slug>`), recorded when its first resource is created. */
2008
2032
  export declare const COOLIFY_DESTINATION_SCOPE_KEY = "coolify_destination";
@@ -2377,6 +2401,12 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
2377
2401
  * the snapshot taken before the repoint still named it.
2378
2402
  */
2379
2403
  private zitadelLoginHost;
2404
+ /**
2405
+ * `{ loginBaseUri }` for an app made in the org's ZITADEL: the instance's own login host (a company's own
2406
+ * `auth.` host, else the shared one). Nothing when the shared host is not known. See
2407
+ * {@link FoundryStepExecutorPorts}' `createOidcApp` `loginBaseUri`.
2408
+ */
2409
+ private instanceLoginBaseUriFor;
2380
2410
  /**
2381
2411
  * The host table of a company's own in-app login app as a bindings port that collects its edits and
2382
2412
  * writes them once on `flush` (one redeploy, none when nothing changed). Used for a company that runs
@@ -2776,12 +2806,14 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
2776
2806
  */
2777
2807
  private mintIfAbsent;
2778
2808
  /**
2779
- * Every company's Boule, F3/#970: `NATIVE_BUILD_KEY` is what Boule presents to Archon's
2780
- * native-build route (estate#262) to ask it to dispatch `build-app.yml`. Minted once, like
2781
- * {@link ensureAuthAppKey}, and never rotated; a present-but-empty value (the bug
2782
- * `mintNativeBuildKey`'s own read-then-generate had, see #957) is treated as not set.
2809
+ * foundry#987: nothing reads `NATIVE_BUILD_KEY` any more -- Boule calls Archon's org build
2810
+ * route (`/api/orgs/{orgId}/native/builds`) with its own org credential instead
2811
+ * (`BOULE_ARCHON_CREDENTIAL_ENV`, archon#706, boule#432). This takes the env row away from
2812
+ * every Boule that still has one from #970/#971; `NATIVE_BUILD_KEY_ENV` and
2813
+ * `NATIVE_BUILD_KEY_BYTES` are deprecated and go in the next release (the two-release removal
2814
+ * rule), once no live Boule holds the row any more.
2783
2815
  */
2784
- private ensureNativeBuildKey;
2816
+ private dropNativeBuildKeyEnv;
2785
2817
  /**
2786
2818
  * `null` once the app already has one -- `AUTH_COOKIE_SECRET` is generated
2787
2819
  * only the first time (task brief), never regenerated or overwritten, so a
@@ -3400,6 +3432,35 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
3400
3432
  * shape {@link coolifyAgora}'s own doc comment explains.
3401
3433
  */
3402
3434
  private coolifyBoule;
3435
+ /**
3436
+ * `plan/diff.ts`'s `planAnalyticsBackend`: the company's analytics backend as one more Coolify application
3437
+ * from https://github.com/wtfalch/analytics's root Dockerfile ({@link ANALYTICS_REPO}), with a database of its
3438
+ * own, shaped like {@link coolifyKosmos}. Its start script migrates as the owner role and then serves as the
3439
+ * runtime role, so `pushDatabaseUrl` gives it both URLs. It needs no sign-in client: it reads only
3440
+ * `AUTH_ORGANIZATION_ID` (the tenant id derives from it), `APP_URL` and the operator key.
3441
+ *
3442
+ * `ANALYTICS_OPERATOR_KEY` is made once here (`mintIfAbsent`) and written only to this app's env. No outside
3443
+ * service shows it once, so no undelivered marker is needed: a failed write leaves nothing live to mint twice.
3444
+ * Steps that call the backend's operator API read it back from this env.
3445
+ *
3446
+ * A brand new app is deployed once ({@link ANALYTICS_DEPLOYED_SCOPE_KEY}), as for Boule (#504).
3447
+ */
3448
+ private coolifyAnalytics;
3449
+ /** The backend's operator key, read from its own env. Fixed text when the backend or the key is not there. */
3450
+ private analyticsOperatorKey;
3451
+ /**
3452
+ * C2 `coolify:analytics-boule` (`plan/analytics-boule.ts`): the backend's address and operator key into
3453
+ * Boule's env, then Boule restarts. `analytics_boule_applied` is recorded last. Retire takes both names away.
3454
+ */
3455
+ private analyticsBoule;
3456
+ /**
3457
+ * `plan/entry-analytics-key.ts`: one server entry's site key (CLAUDE.md "Platform steps" rule 1). Mint: the
3458
+ * key's id is recorded as undelivered and persisted before anything is written; the env write is tried 3
3459
+ * times with the same secret; the applied address and the cleared marker come last; every try failing leaves
3460
+ * the marker, and the error names the id only. `url` rewrites the address. Retire revokes the key and the
3461
+ * env names. The operator key is read from the backend's env and never recorded.
3462
+ */
3463
+ private analyticsEntryKey;
3403
3464
  /**
3404
3465
  * `plan/diff.ts`'s `planKosmos`: provisions the org's own kosmos
3405
3466
  * (https://github.com/wtfalch/kosmos) as one more Coolify application, from
@@ -4103,4 +4164,16 @@ export declare function isNoChangesError(error: unknown): boolean;
4103
4164
  * {@link ZitadelPort.updateOidcAppLoginBaseUri} that counts an app already holding the wanted base URI as
4104
4165
  * done: ZITADEL answers that write with "No changes" and a repeat apply must not fail on it.
4105
4166
  */
4167
+ /**
4168
+ * Turns the instance feature `loginV2.required` off so each app's own base URI counts, and makes sure no
4169
+ * app can then land on ZITADEL's v1 login: with `required` off an app without a base URI of its own
4170
+ * (ZITADEL's own console app, an app foundry did not make, an app made later) falls back to v1, and
4171
+ * ZITADEL v4.17.3 gives a `required` instance's base URI to every app but ignores an app's own
4172
+ * (`internal/query/oidc_client.go:88-92`), so the only way is a base URI on every app. Every live OIDC
4173
+ * app of every organisation and project gets `instanceBaseUri` when it has none; an app that has one
4174
+ * keeps it. The apps are set first, while `required` still makes the instance ignore them, so no sign-in
4175
+ * passes through a gap. Without a way to list every app, or with a listing that may be cut short, it
4176
+ * throws before the feature is touched.
4177
+ */
4178
+ export declare function allowPerAppLoginBaseUris(zitadel: Pick<ZitadelPort, 'allowPerAppLoginBaseUri' | 'listOrgs' | 'listProjects' | 'listApps' | 'updateOidcAppLoginBaseUri'>, instanceBaseUri: string): Promise<void>;
4106
4179
  export declare function setOidcAppLoginBaseUri(zitadel: Pick<ZitadelPort, 'updateOidcAppLoginBaseUri'>, orgId: string, projectId: string, appId: string, loginBaseUri: string): Promise<void>;