@caelo-cms/shared 0.10.47 → 0.10.49

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 (54) hide show
  1. package/dist/ai-tools.d.ts +4 -3
  2. package/dist/ai-tools.d.ts.map +1 -1
  3. package/dist/ai-tools.js +5 -4
  4. package/dist/ai-tools.js.map +1 -1
  5. package/dist/auto-update.d.ts +124 -0
  6. package/dist/auto-update.d.ts.map +1 -0
  7. package/dist/auto-update.js +228 -0
  8. package/dist/auto-update.js.map +1 -0
  9. package/dist/cloud-shell-install.d.ts +27 -0
  10. package/dist/cloud-shell-install.d.ts.map +1 -0
  11. package/dist/cloud-shell-install.js +38 -0
  12. package/dist/cloud-shell-install.js.map +1 -0
  13. package/dist/database-url.d.ts +15 -0
  14. package/dist/database-url.d.ts.map +1 -1
  15. package/dist/database-url.js +15 -0
  16. package/dist/database-url.js.map +1 -1
  17. package/dist/index.d.ts +4 -0
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +4 -0
  20. package/dist/index.js.map +1 -1
  21. package/dist/platform-updates.d.ts +159 -0
  22. package/dist/platform-updates.d.ts.map +1 -0
  23. package/dist/platform-updates.js +253 -0
  24. package/dist/platform-updates.js.map +1 -0
  25. package/dist/release-manifest.d.ts +99 -0
  26. package/dist/release-manifest.d.ts.map +1 -0
  27. package/dist/release-manifest.js +161 -0
  28. package/dist/release-manifest.js.map +1 -0
  29. package/dist/seo.d.ts.map +1 -1
  30. package/dist/seo.js +4 -1
  31. package/dist/seo.js.map +1 -1
  32. package/dist/static-cache-policy.d.ts +7 -4
  33. package/dist/static-cache-policy.d.ts.map +1 -1
  34. package/dist/static-cache-policy.js +7 -4
  35. package/dist/static-cache-policy.js.map +1 -1
  36. package/dist/subagents.d.ts +1 -1
  37. package/dist/version.d.ts +2 -2
  38. package/dist/version.js +1 -1
  39. package/package.json +1 -1
  40. package/src/ai-tools.ts +5 -4
  41. package/src/auto-update.test.ts +321 -0
  42. package/src/auto-update.ts +315 -0
  43. package/src/cloud-shell-install.test.ts +20 -0
  44. package/src/cloud-shell-install.ts +47 -0
  45. package/src/database-url.test.ts +16 -1
  46. package/src/database-url.ts +21 -0
  47. package/src/index.ts +4 -0
  48. package/src/platform-updates.test.ts +160 -0
  49. package/src/platform-updates.ts +336 -0
  50. package/src/release-manifest.ts +212 -0
  51. package/src/seo.ts +4 -1
  52. package/src/static-cache-policy.test.ts +6 -2
  53. package/src/static-cache-policy.ts +7 -4
  54. package/src/version.ts +1 -1
@@ -0,0 +1,47 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * The "Open in Google Cloud Shell" install link (epic #696, Phase 3).
5
+ *
6
+ * The link opens github.com/caelo-cms/install on the `release-v<version>`
7
+ * branch that repository's track-release workflow creates for every stable
8
+ * release (and never moves), with the provider's walkthrough tutorial. Its
9
+ * `run.sh` pins and verifies exactly that release of the provisioner. One
10
+ * builder for every place that shows the link — README, docs, and the
11
+ * admin's "run this release in Cloud Shell" hint — so they can't drift.
12
+ */
13
+
14
+ /** The repository the Cloud Shell button clones. */
15
+ export const CLOUD_SHELL_INSTALL_REPO = "https://github.com/caelo-cms/install";
16
+
17
+ /** The button image Google publishes for "Open in Cloud Shell" links. */
18
+ export const CLOUD_SHELL_BUTTON_IMAGE = "https://gstatic.com/cloudssh/images/open-btn.svg";
19
+
20
+ /** Providers the Cloud Shell install covers (one tutorial each). */
21
+ export type CloudShellProvider = "gcp" | "gcp-firebase";
22
+
23
+ const STABLE = /^v?(\d+\.\d+\.\d+)$/;
24
+
25
+ /**
26
+ * The Cloud Shell install link for a stable release.
27
+ *
28
+ * @throws for a pre-release or non-SemVer version — only stable releases
29
+ * get a `release-v<version>` branch in the install repository
30
+ *
31
+ * @example cloudShellInstallUrl("0.10.47", "gcp")
32
+ * // → "https://shell.cloud.google.com/cloudshell/editor?cloudshell_git_repo=https://github.com/caelo-cms/install&cloudshell_git_branch=release-v0.10.47&cloudshell_tutorial=gcp.md"
33
+ */
34
+ export function cloudShellInstallUrl(version: string, provider: CloudShellProvider): string {
35
+ const m = STABLE.exec(version);
36
+ if (!m) {
37
+ throw new Error(
38
+ `no Cloud Shell install link for "${version}": only stable releases are pinned in ${CLOUD_SHELL_INSTALL_REPO}`,
39
+ );
40
+ }
41
+ return [
42
+ "https://shell.cloud.google.com/cloudshell/editor",
43
+ `?cloudshell_git_repo=${CLOUD_SHELL_INSTALL_REPO}`,
44
+ `&cloudshell_git_branch=release-v${m[1]}`,
45
+ `&cloudshell_tutorial=${provider}.md`,
46
+ ].join("");
47
+ }
@@ -1,7 +1,12 @@
1
1
  // SPDX-License-Identifier: MPL-2.0
2
2
 
3
3
  import { describe, expect, it } from "bun:test";
4
- import { databasePasswordVar, databaseUrlFromEnv, withDatabasePassword } from "./database-url.js";
4
+ import {
5
+ cloudSqlIamDatabaseUrls,
6
+ databasePasswordVar,
7
+ databaseUrlFromEnv,
8
+ withDatabasePassword,
9
+ } from "./database-url.js";
5
10
 
6
11
  describe("databasePasswordVar", () => {
7
12
  it("pairs each URL var with its _PASSWORD var", () => {
@@ -114,3 +119,13 @@ describe("databaseUrlFromEnv", () => {
114
119
  );
115
120
  });
116
121
  });
122
+
123
+ describe("cloudSqlIamDatabaseUrls", () => {
124
+ it("logs in as the service account's IAM database user with the token as password", () => {
125
+ expect(cloudSqlIamDatabaseUrls("10.0.0.3", "job@p.iam.gserviceaccount.com", "ya29/x")).toEqual({
126
+ user: "job@p.iam",
127
+ admin: "postgresql://job%40p.iam:ya29%2Fx@10.0.0.3:5432/cms_admin?sslmode=require",
128
+ public: "postgresql://job%40p.iam:ya29%2Fx@10.0.0.3:5432/cms_public?sslmode=require",
129
+ });
130
+ });
131
+ });
@@ -81,3 +81,24 @@ export function databaseUrlFromEnv(
81
81
  }
82
82
  return undefined;
83
83
  }
84
+
85
+ /**
86
+ * Password-less database URLs for Cloud SQL IAM database authentication —
87
+ * how the in-cloud jobs (the operator-access sync job, the upgrader job)
88
+ * connect: the user is the service account email without
89
+ * `.gserviceaccount.com`, the password a short-lived OAuth access token.
90
+ *
91
+ * @example
92
+ * cloudSqlIamDatabaseUrls("10.0.0.3", "job@p.iam.gserviceaccount.com", "ya29.x").user
93
+ * // → "job@p.iam"
94
+ */
95
+ export function cloudSqlIamDatabaseUrls(
96
+ host: string,
97
+ serviceAccountEmail: string,
98
+ accessToken: string,
99
+ ): { admin: string; public: string; user: string } {
100
+ const user = serviceAccountEmail.replace(/\.gserviceaccount\.com$/, "");
101
+ const url = (db: string) =>
102
+ `postgresql://${encodeURIComponent(user)}:${encodeURIComponent(accessToken)}@${host}:5432/${db}?sslmode=require`;
103
+ return { admin: url("cms_admin"), public: url("cms_public"), user };
104
+ }
package/src/index.ts CHANGED
@@ -4,9 +4,11 @@ export const PROJECT_NAME = "caelo-cms";
4
4
 
5
5
  export * from "./ai-tools.js";
6
6
  export * from "./auth-forms.js";
7
+ export * from "./auto-update.js";
7
8
  export * from "./base-css.js";
8
9
  export * from "./build-page.js";
9
10
  export * from "./cap-failures.js";
11
+ export * from "./cloud-shell-install.js";
10
12
  export * from "./content.js";
11
13
  export * from "./context.js";
12
14
  export * from "./css-gradient-scan.js";
@@ -35,12 +37,14 @@ export {
35
37
  export * from "./media.js";
36
38
  export * from "./nested-module-render.js";
37
39
  export * from "./page-log.js";
40
+ export * from "./platform-updates.js";
38
41
  export * from "./plugin-preview.js";
39
42
  export * from "./preview-compose.js";
40
43
  export * from "./preview-scanner.js";
41
44
  export * from "./proposal-status.js";
42
45
  export * from "./provisioning-command.js";
43
46
  export * from "./redirect-target.js";
47
+ export * from "./release-manifest.js";
44
48
  export * from "./responsive-images.js";
45
49
  export * from "./result.js";
46
50
  export * from "./safe-keys.js";
@@ -0,0 +1,160 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ import { describe, expect, it } from "bun:test";
4
+ import {
5
+ autoInstallRefusal,
6
+ inMaintenanceWindow,
7
+ isFinishedUpdateStatus,
8
+ PLATFORM_UPDATE_STALE_REASON,
9
+ type StoredUpdatePolicy,
10
+ updateKind,
11
+ updatePolicySchema,
12
+ } from "./platform-updates.js";
13
+
14
+ describe("updateKind", () => {
15
+ it.each([
16
+ ["0.10.45", "0.10.46", "patch"],
17
+ ["0.10.45", "0.11.0", "minor"],
18
+ ["0.10.45", "1.0.0", "major"],
19
+ ["0.11.0-rc.1", "0.11.0", "patch"],
20
+ ["0.11.0-rc.1", "0.11.0-rc.2", "patch"],
21
+ ["0.11.0-rc.9", "0.11.0-rc.10", "patch"],
22
+ ["0.11.0-rc.2", "0.11.0-rc.1", null],
23
+ ["0.11.0", "0.11.0-rc.2", null],
24
+ ["0.10.45", "0.10.45", null],
25
+ ["0.10.45", "0.10.44", null],
26
+ ["0.11.0", "0.10.99", null],
27
+ ["nope", "0.10.46", null],
28
+ ] as const)("%s → %s is %s", (from, to, kind) => {
29
+ expect(updateKind(from, to)).toBe(kind);
30
+ });
31
+ });
32
+
33
+ describe("inMaintenanceWindow", () => {
34
+ const at = (iso: string) => new Date(iso);
35
+ it("no window = any time", () => {
36
+ expect(
37
+ inMaintenanceWindow(
38
+ { windowStart: null, windowEnd: null, windowTimezone: "UTC" },
39
+ at("2026-10-13T12:00:00Z"),
40
+ ),
41
+ ).toBe(true);
42
+ });
43
+ it("respects the time zone", () => {
44
+ const w = { windowStart: "02:00", windowEnd: "05:00", windowTimezone: "Europe/Berlin" };
45
+ // 01:30 UTC = 03:30 Berlin (CEST).
46
+ expect(inMaintenanceWindow(w, at("2026-10-13T01:30:00Z"))).toBe(true);
47
+ expect(inMaintenanceWindow(w, at("2026-10-13T03:30:00Z"))).toBe(false);
48
+ });
49
+ it("a window across midnight", () => {
50
+ const w = { windowStart: "22:00", windowEnd: "04:00", windowTimezone: "UTC" };
51
+ expect(inMaintenanceWindow(w, at("2026-10-13T23:00:00Z"))).toBe(true);
52
+ expect(inMaintenanceWindow(w, at("2026-10-13T03:59:00Z"))).toBe(true);
53
+ expect(inMaintenanceWindow(w, at("2026-10-13T04:00:00Z"))).toBe(false);
54
+ expect(inMaintenanceWindow(w, at("2026-10-13T12:00:00Z"))).toBe(false);
55
+ });
56
+ });
57
+
58
+ describe("autoInstallRefusal", () => {
59
+ const policy: StoredUpdatePolicy = {
60
+ mode: "auto",
61
+ autoSecurity: true,
62
+ autoPatch: false,
63
+ autoMinor: false,
64
+ channel: "stable",
65
+ windowStart: null,
66
+ windowEnd: null,
67
+ windowTimezone: "UTC",
68
+ soakHours: 24,
69
+ securitySoakHours: 2,
70
+ paused: false,
71
+ pausedReason: null,
72
+ pausedAt: null,
73
+ pausedUntil: null,
74
+ pausedByOwner: false,
75
+ updatedAt: "2026-10-13T00:00:00Z",
76
+ };
77
+ const now = new Date("2026-10-13T12:00:00Z");
78
+ const patch = {
79
+ kind: "patch",
80
+ security: false,
81
+ contractMigrations: false,
82
+ channel: "stable",
83
+ } as const;
84
+
85
+ it.each([
86
+ ["mode off", { ...policy, mode: "notify" as const }, patch, "switched off"],
87
+ ["paused", { ...policy, paused: true }, { ...patch, security: true }, "paused"],
88
+ ["rc channel", policy, { ...patch, security: true, channel: "rc" }, "only stable"],
89
+ [
90
+ "contract migrations",
91
+ policy,
92
+ { ...patch, security: true, contractMigrations: true },
93
+ "destructive",
94
+ ],
95
+ [
96
+ "major",
97
+ { ...policy, autoMinor: true, autoPatch: true },
98
+ { ...patch, kind: "major" as const },
99
+ "major",
100
+ ],
101
+ ["patch not enabled", policy, patch, "does not install"],
102
+ [
103
+ "outside window",
104
+ { ...policy, autoPatch: true, windowStart: "02:00", windowEnd: "03:00" },
105
+ patch,
106
+ "outside the maintenance window",
107
+ ],
108
+ ])("%s → refused", (_name, p, c, why) => {
109
+ expect(autoInstallRefusal(p, c, now)).toContain(why);
110
+ });
111
+
112
+ it("allows an enabled kind inside the window", () => {
113
+ expect(autoInstallRefusal(policy, { ...patch, security: true }, now)).toBeNull();
114
+ expect(
115
+ autoInstallRefusal({ ...policy, autoMinor: true }, { ...patch, kind: "minor" }, now),
116
+ ).toBeNull();
117
+ });
118
+ });
119
+
120
+ describe("updatePolicySchema", () => {
121
+ const base = {
122
+ mode: "auto",
123
+ autoSecurity: true,
124
+ autoPatch: false,
125
+ autoMinor: false,
126
+ channel: "stable",
127
+ windowStart: "02:00",
128
+ windowEnd: "04:00",
129
+ windowTimezone: "Europe/Berlin",
130
+ soakHours: 24,
131
+ securitySoakHours: 2,
132
+ };
133
+ it("accepts a sane policy and rejects broken ones", () => {
134
+ expect(updatePolicySchema.safeParse(base).success).toBe(true);
135
+ for (const bad of [
136
+ { ...base, channel: "rc" },
137
+ { ...base, windowEnd: null },
138
+ { ...base, windowStart: "25:00" },
139
+ { ...base, windowTimezone: "Nowhere/Land" },
140
+ { ...base, soakHours: 1000 },
141
+ { ...base, securitySoakHours: 48 },
142
+ { ...base, extra: true },
143
+ ]) {
144
+ expect(updatePolicySchema.safeParse(bad).success).toBe(false);
145
+ }
146
+ });
147
+ });
148
+
149
+ it("isFinishedUpdateStatus", () => {
150
+ expect(isFinishedUpdateStatus("pending")).toBe(false);
151
+ expect(isFinishedUpdateStatus("running")).toBe(false);
152
+ expect(isFinishedUpdateStatus("needs_cloud_shell")).toBe(true);
153
+ });
154
+
155
+ describe("PLATFORM_UPDATE_STALE_REASON (#721)", () => {
156
+ it("names no provider's tool: it ends dead Google Cloud and self-hosted runs alike", () => {
157
+ expect(PLATFORM_UPDATE_STALE_REASON).not.toContain("Cloud Shell");
158
+ expect(PLATFORM_UPDATE_STALE_REASON).toContain("Settings → Updates");
159
+ });
160
+ });
@@ -0,0 +1,336 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * In-product Caelo updates (epic #696): the vocabulary the admin (Settings →
5
+ * Updates, the `platform_updates.*` ops), the upgrader job and the Phase 5
6
+ * scheduler share. One module so the timeline the page renders, the states
7
+ * the job writes and the database CHECK constraints (migration 0267) agree.
8
+ */
9
+
10
+ import { z } from "zod";
11
+
12
+ /** Every state of an update request (`platform_update_requests.status`). */
13
+ export const PLATFORM_UPDATE_STATUSES = [
14
+ "pending",
15
+ "running",
16
+ "succeeded",
17
+ "rolled_back",
18
+ "failed",
19
+ "refused",
20
+ "needs_cloud_shell",
21
+ "cancelled",
22
+ ] as const;
23
+ export type PlatformUpdateStatusValue = (typeof PLATFORM_UPDATE_STATUSES)[number];
24
+
25
+ /** States in which a request is still in flight (at most one at a time). */
26
+ export const ACTIVE_UPDATE_STATUSES: readonly PlatformUpdateStatusValue[] = ["pending", "running"];
27
+
28
+ /** Whether `status` is an end state. */
29
+ export function isFinishedUpdateStatus(status: PlatformUpdateStatusValue): boolean {
30
+ return !ACTIVE_UPDATE_STATUSES.includes(status);
31
+ }
32
+
33
+ /**
34
+ * The phases of an upgrader run, in order (`platform_update_events.phase`).
35
+ * `rollback` only appears when a roll had to be undone.
36
+ */
37
+ export const PLATFORM_UPDATE_PHASES = [
38
+ "queued",
39
+ "start",
40
+ "verify",
41
+ "assess",
42
+ "backup",
43
+ "health-baseline",
44
+ "migrations",
45
+ "roll",
46
+ "health",
47
+ "rollback",
48
+ "record",
49
+ "self-update",
50
+ "finish",
51
+ ] as const;
52
+ export type PlatformUpdatePhase = (typeof PLATFORM_UPDATE_PHASES)[number];
53
+
54
+ /** Severity of one timeline line. */
55
+ export const PLATFORM_UPDATE_EVENT_LEVELS = ["info", "success", "warn", "error"] as const;
56
+ export type PlatformUpdateEventLevel = (typeof PLATFORM_UPDATE_EVENT_LEVELS)[number];
57
+
58
+ /** Who started a request: the Owner's click, or the Phase 5 scheduler. */
59
+ export const PLATFORM_UPDATE_TRIGGERS = ["manual", "auto"] as const;
60
+ export type PlatformUpdateTrigger = (typeof PLATFORM_UPDATE_TRIGGERS)[number];
61
+
62
+ /**
63
+ * A run still `running` this long after it started died (the upgrader job's
64
+ * task timeout is 60 minutes). The next job run, and the next install
65
+ * request, end it as failed — so a crashed run never blocks updates.
66
+ */
67
+ export const PLATFORM_UPDATE_STALE_MINUTES = 90;
68
+
69
+ /**
70
+ * Why a stale run was ended (shown on its timeline). Provider-neutral: the
71
+ * same text ends a dead Google Cloud job run and a dead self-hosted agent
72
+ * run, and Settings → Updates shows each install its own way to upgrade by
73
+ * hand (Cloud Shell there, the upgrade command on the server here).
74
+ */
75
+ export const PLATFORM_UPDATE_STALE_REASON =
76
+ "The upgrader stopped before it finished (its run timed out or crashed). The previous release may still serve, or the new one may be partly rolled out: open the site, and if it misbehaves run the upgrade by hand once — Settings → Updates shows how for this install.";
77
+
78
+ /** Longest an event message may be (DB CHECK). */
79
+ export const PLATFORM_UPDATE_EVENT_MAX = 4000;
80
+
81
+ const SEMVER = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/;
82
+
83
+ /** A SemVer release version without a leading `v` (`0.11.0`, `0.11.0-rc.1`). */
84
+ export const releaseVersionSchema = z
85
+ .string()
86
+ .regex(SEMVER, "expected a release version like 0.11.0 (no leading v)");
87
+
88
+ /** How big a step from `from` to `to` is, by SemVer position. */
89
+ export type UpdateKind = "major" | "minor" | "patch";
90
+
91
+ /**
92
+ * Classify the step from the installed release to a newer one.
93
+ *
94
+ * @returns null when `to` is not newer than `from` or either is not SemVer.
95
+ * @example updateKind("0.10.45", "0.10.46") // → "patch"
96
+ */
97
+ export function updateKind(from: string, to: string): UpdateKind | null {
98
+ const a = SEMVER.exec(from);
99
+ const b = SEMVER.exec(to);
100
+ if (!a || !b) return null;
101
+ const [fa, fb, fc] = [Number(a[1]), Number(a[2]), Number(a[3])];
102
+ const [ta, tb, tc] = [Number(b[1]), Number(b[2]), Number(b[3])];
103
+ if (ta !== fa) return ta > fa ? "major" : null;
104
+ if (tb !== fb) return tb > fb ? "minor" : null;
105
+ if (tc !== fc) return tc > fc ? "patch" : null;
106
+ // Same X.Y.Z: a stable release over its own pre-release, or a later
107
+ // pre-release (rc.1 → rc.2), is a step.
108
+ if (a[4] === undefined) return null;
109
+ if (b[4] === undefined) return "patch";
110
+ return comparePrerelease(b[4], a[4]) > 0 ? "patch" : null;
111
+ }
112
+
113
+ /** SemVer §11 precedence of two pre-release identifier lists. */
114
+ function comparePrerelease(x: string, y: string): number {
115
+ const xs = x.split(".");
116
+ const ys = y.split(".");
117
+ for (let i = 0; i < Math.max(xs.length, ys.length); i++) {
118
+ const p = xs[i];
119
+ const q = ys[i];
120
+ if (p === undefined) return -1;
121
+ if (q === undefined) return 1;
122
+ const pn = /^\d+$/.test(p);
123
+ const qn = /^\d+$/.test(q);
124
+ if (pn && qn && Number(p) !== Number(q)) return Number(p) - Number(q);
125
+ if (pn !== qn) return pn ? -1 : 1;
126
+ if (p !== q) return p < q ? -1 : 1;
127
+ }
128
+ return 0;
129
+ }
130
+
131
+ /** The automatic-update modes. */
132
+ export const UPDATE_POLICY_MODES = ["off", "notify", "auto"] as const;
133
+ export type UpdatePolicyMode = (typeof UPDATE_POLICY_MODES)[number];
134
+
135
+ /** Release channels a policy may follow (only `stable` installs automatically). */
136
+ export const UPDATE_POLICY_CHANNELS = ["stable", "rc", "beta", "alpha"] as const;
137
+
138
+ const WALL_CLOCK = /^([01]\d|2[0-3]):[0-5]\d$/;
139
+
140
+ /** Whether `tz` is an IANA time zone this runtime knows. */
141
+ export function isKnownTimeZone(tz: string): boolean {
142
+ try {
143
+ new Intl.DateTimeFormat("en-US", { timeZone: tz });
144
+ return true;
145
+ } catch {
146
+ return false;
147
+ }
148
+ }
149
+
150
+ /**
151
+ * The Owner's automatic-update policy (`platform_update_policy`), as the
152
+ * ops read and write it. Major releases have no toggle: they are never
153
+ * installed automatically before 1.0.
154
+ */
155
+ export const updatePolicySchema = z
156
+ .object({
157
+ mode: z.enum(UPDATE_POLICY_MODES),
158
+ autoSecurity: z.boolean(),
159
+ autoPatch: z.boolean(),
160
+ autoMinor: z.boolean(),
161
+ channel: z.enum(UPDATE_POLICY_CHANNELS),
162
+ /** `HH:MM` local time in `windowTimezone`, or null with windowEnd null = any time. */
163
+ windowStart: z.string().regex(WALL_CLOCK, "expected HH:MM").nullable(),
164
+ windowEnd: z.string().regex(WALL_CLOCK, "expected HH:MM").nullable(),
165
+ windowTimezone: z.string().min(1).max(64).refine(isKnownTimeZone, "unknown time zone"),
166
+ /** Hours after publication before a non-security release installs automatically. */
167
+ soakHours: z.number().int().min(0).max(720),
168
+ /**
169
+ * The same for a release whose signed manifest names a published GitHub
170
+ * Security Advisory (auto-update.ts `isSecurityRelease`). Shorter by
171
+ * default (2 h vs 24 h): the fix is public, so every hour unpatched is
172
+ * exposure, while two hours still let a broken release be pulled first.
173
+ */
174
+ securitySoakHours: z.number().int().min(0).max(720),
175
+ })
176
+ .strict()
177
+ .refine((p) => (p.windowStart === null) === (p.windowEnd === null), {
178
+ message: "set both ends of the maintenance window, or neither",
179
+ })
180
+ .refine((p) => p.mode !== "auto" || p.channel === "stable", {
181
+ message: "automatic installs follow the stable channel only",
182
+ })
183
+ .refine((p) => p.securitySoakHours <= p.soakHours, {
184
+ message: "security fixes cannot wait longer than other releases",
185
+ });
186
+ export type UpdatePolicy = z.infer<typeof updatePolicySchema>;
187
+
188
+ /** The policy plus its pause state, as stored. */
189
+ export interface StoredUpdatePolicy extends UpdatePolicy {
190
+ readonly paused: boolean;
191
+ readonly pausedReason: string | null;
192
+ readonly pausedAt: string | null;
193
+ /** End of the Owner's pause; null = until resumed (or not paused). */
194
+ readonly pausedUntil: string | null;
195
+ /** True when the Owner paused; false when the upgrader paused after a failure. */
196
+ readonly pausedByOwner: boolean;
197
+ readonly updatedAt: string;
198
+ }
199
+
200
+ /** Minutes since local midnight of `at` in `timeZone`. */
201
+ function localMinutes(at: Date, timeZone: string): number {
202
+ const parts = new Intl.DateTimeFormat("en-GB", {
203
+ timeZone,
204
+ hour: "2-digit",
205
+ minute: "2-digit",
206
+ hourCycle: "h23",
207
+ }).formatToParts(at);
208
+ const n = (t: string) => Number(parts.find((p) => p.type === t)?.value ?? "0");
209
+ return n("hour") * 60 + n("minute");
210
+ }
211
+
212
+ const toMinutes = (hhmm: string) => Number(hhmm.slice(0, 2)) * 60 + Number(hhmm.slice(3, 5));
213
+
214
+ /**
215
+ * Whether `at` falls inside the policy's maintenance window. A window whose
216
+ * end is before its start spans midnight (`22:00`–`04:00`).
217
+ */
218
+ export function inMaintenanceWindow(
219
+ policy: Pick<UpdatePolicy, "windowStart" | "windowEnd" | "windowTimezone">,
220
+ at: Date,
221
+ ): boolean {
222
+ if (policy.windowStart === null || policy.windowEnd === null) return true;
223
+ const now = localMinutes(at, policy.windowTimezone);
224
+ const start = toMinutes(policy.windowStart);
225
+ const end = toMinutes(policy.windowEnd);
226
+ if (start === end) return true;
227
+ return start < end ? now >= start && now < end : now >= start || now < end;
228
+ }
229
+
230
+ /** What the policy needs to know about a release to decide on an automatic install. */
231
+ export interface AutoInstallCandidate {
232
+ readonly kind: UpdateKind;
233
+ /** From the signed manifest AND auto-installable (assessAutoInstall's `kind`). */
234
+ readonly security: boolean;
235
+ /** Manifest `migrations.contract` (or a skipped contract release). */
236
+ readonly contractMigrations: boolean;
237
+ /** The release's channel. */
238
+ readonly channel: string;
239
+ }
240
+
241
+ /**
242
+ * Whether the policy lets the upgrader install `candidate` unattended at
243
+ * `at` — the job's check for `trigger = 'auto'` requests (the scheduler
244
+ * applies the soak period before it ever queues one).
245
+ *
246
+ * @returns null when allowed, else the reason in plain words
247
+ */
248
+ export function autoInstallRefusal(
249
+ policy: Pick<
250
+ StoredUpdatePolicy,
251
+ | "mode"
252
+ | "autoSecurity"
253
+ | "autoPatch"
254
+ | "autoMinor"
255
+ | "paused"
256
+ | "pausedUntil"
257
+ | "windowStart"
258
+ | "windowEnd"
259
+ | "windowTimezone"
260
+ >,
261
+ candidate: AutoInstallCandidate,
262
+ at: Date,
263
+ ): string | null {
264
+ if (policy.mode !== "auto") return "automatic installs are switched off in the update policy";
265
+ // An Owner pause with an end that has passed no longer holds (the
266
+ // scheduler lifts it on its next tick; this keeps the job consistent).
267
+ if (
268
+ policy.paused &&
269
+ (policy.pausedUntil === null || Date.parse(policy.pausedUntil) > at.getTime())
270
+ ) {
271
+ return "automatic installs are paused";
272
+ }
273
+ if (candidate.channel !== "stable") return "only stable releases install automatically";
274
+ if (candidate.contractMigrations) {
275
+ return "the release carries destructive database changes; the Owner installs it by hand";
276
+ }
277
+ if (candidate.kind === "major") return "major releases never install automatically";
278
+ const allowed =
279
+ (candidate.security && policy.autoSecurity) ||
280
+ (candidate.kind === "patch" && policy.autoPatch) ||
281
+ (candidate.kind === "minor" && policy.autoMinor);
282
+ if (!allowed) {
283
+ return `the policy does not install ${candidate.security ? "security fixes or " : ""}${candidate.kind} releases automatically`;
284
+ }
285
+ if (!inMaintenanceWindow(policy, at)) return "outside the maintenance window";
286
+ return null;
287
+ }
288
+
289
+ /**
290
+ * How long a verified Google sign-in authorizes one guarded action. Long
291
+ * enough to read the confirmation dialog, short enough that an unattended
292
+ * browser is useless soon after.
293
+ */
294
+ export const STEP_UP_MAX_AGE_SECONDS = 600;
295
+
296
+ /** What a step-up is for (`step_up_challenges.purpose`). */
297
+ export const STEP_UP_PURPOSES = ["platform_update", "update_policy", "step_up_client"] as const;
298
+ export type StepUpPurpose = (typeof STEP_UP_PURPOSES)[number];
299
+
300
+ /**
301
+ * Self-hosted installs (#696 Phase 4c): the host agent (`caelo-agent`, a
302
+ * systemd timer on the install's host) polls for update requests this
303
+ * often and writes a heartbeat each time it looks.
304
+ */
305
+ export const PLATFORM_UPDATE_AGENT_POLL_SECONDS = 60;
306
+
307
+ /**
308
+ * The admin offers "Install update" on a self-hosted install only while the
309
+ * agent's last heartbeat is younger than this (five missed polls): a request
310
+ * nobody picks up would just sit there.
311
+ */
312
+ export const PLATFORM_UPDATE_AGENT_FRESH_SECONDS = 300;
313
+
314
+ /** The host agent's last check-in (`platform_update_agent`), as the admin reads it. */
315
+ export interface PlatformUpdateAgentStatus {
316
+ /** The Caelo release of the agent's own code. */
317
+ readonly agentVersion: string;
318
+ /** The host it runs on (its hostname). */
319
+ readonly host: string;
320
+ readonly lastSeenAt: string;
321
+ /**
322
+ * What would make an install fail on that host (cosign missing, Docker
323
+ * not reachable), in plain words; null when its preflight passed.
324
+ */
325
+ readonly problem: string | null;
326
+ /** Whether {@link lastSeenAt} is within {@link PLATFORM_UPDATE_AGENT_FRESH_SECONDS}. */
327
+ readonly checkedInRecently: boolean;
328
+ }
329
+
330
+ /** Whether an agent last seen at `lastSeenAt` counts as running at `now`. */
331
+ export function agentCheckedInRecently(lastSeenAt: Date | string, now: Date): boolean {
332
+ const seen = new Date(lastSeenAt).getTime();
333
+ return (
334
+ Number.isFinite(seen) && now.getTime() - seen <= PLATFORM_UPDATE_AGENT_FRESH_SECONDS * 1000
335
+ );
336
+ }