@openlup/platform 0.29.0 → 0.30.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 (121) hide show
  1. package/CHANGELOG.md +150 -138
  2. package/README.md +80 -3
  3. package/dist/composition/nodeHost.d.ts +1 -1
  4. package/dist/composition/nodeHost.js +3 -2
  5. package/dist/lifecycle/appUpgrade.d.ts +52 -0
  6. package/dist/lifecycle/appUpgrade.js +180 -0
  7. package/dist/lifecycle/index.d.ts +2 -0
  8. package/dist/lifecycle/index.js +2 -0
  9. package/dist/lifecycle/postgresInstallQualification.d.ts +51 -0
  10. package/dist/lifecycle/postgresInstallQualification.js +74 -0
  11. package/dist/migrations/postgresRunner.d.ts +9 -0
  12. package/dist/migrations/postgresRunner.js +17 -10
  13. package/dist/operations/adminAlertsSchemas.d.ts +153 -0
  14. package/dist/operations/adminAlertsSchemas.js +69 -0
  15. package/dist/operations/adminAlertsView.d.ts +73 -0
  16. package/dist/operations/adminAlertsView.js +184 -0
  17. package/dist/operations/alertPagingPolicy.d.ts +15 -0
  18. package/dist/operations/alertPagingPolicy.js +43 -0
  19. package/dist/operations/alertThrottlePolicy.d.ts +3 -0
  20. package/dist/operations/alertThrottlePolicy.js +18 -0
  21. package/dist/operations/clients.d.ts +17 -0
  22. package/dist/operations/clients.js +32 -0
  23. package/dist/operations/contracts/admin.d.ts +93 -0
  24. package/dist/operations/contracts/admin.js +1 -0
  25. package/dist/operations/contracts/adminAlertsContracts.d.ts +40 -0
  26. package/dist/operations/contracts/adminAlertsContracts.js +1 -0
  27. package/dist/operations/contracts/alertHumanContext.d.ts +7 -0
  28. package/dist/operations/contracts/alertHumanContext.js +1 -0
  29. package/dist/operations/contracts/index.d.ts +6 -0
  30. package/dist/operations/contracts/index.js +6 -0
  31. package/dist/operations/contracts/observabilityContracts.d.ts +41 -0
  32. package/dist/operations/contracts/observabilityContracts.js +1 -0
  33. package/dist/operations/contracts/observabilityPorts.d.ts +18 -0
  34. package/dist/operations/contracts/observabilityPorts.js +1 -0
  35. package/dist/operations/contracts/ports.d.ts +19 -0
  36. package/dist/operations/contracts/ports.js +1 -0
  37. package/dist/operations/errors.d.ts +6 -0
  38. package/dist/operations/errors.js +6 -0
  39. package/dist/operations/index.d.ts +9 -0
  40. package/dist/operations/index.js +9 -0
  41. package/dist/operations/schemas.d.ts +138 -0
  42. package/dist/operations/schemas.js +104 -0
  43. package/dist/operations/server/adminAlertsHandlers.d.ts +16 -0
  44. package/dist/operations/server/adminAlertsHandlers.js +35 -0
  45. package/dist/operations/server/adminPipelineHandlers.d.ts +12 -0
  46. package/dist/operations/server/adminPipelineHandlers.js +48 -0
  47. package/dist/operations/server/adminSettingsHandlers.d.ts +14 -0
  48. package/dist/operations/server/adminSettingsHandlers.js +82 -0
  49. package/dist/operations/server/controlPlaneHandler.d.ts +7 -0
  50. package/dist/operations/server/controlPlaneHandler.js +54 -0
  51. package/dist/operations/server/controlPlaneStore.d.ts +12 -0
  52. package/dist/operations/server/controlPlaneStore.js +138 -0
  53. package/dist/operations/server/controlPlaneWatchdog.d.ts +7 -0
  54. package/dist/operations/server/controlPlaneWatchdog.js +20 -0
  55. package/dist/operations/server/index.d.ts +12 -0
  56. package/dist/operations/server/index.js +12 -0
  57. package/dist/operations/server/migrations.d.ts +14 -0
  58. package/dist/operations/server/migrations.js +7 -0
  59. package/dist/operations/server/platformWatchdogDecision.d.ts +34 -0
  60. package/dist/operations/server/platformWatchdogDecision.js +71 -0
  61. package/dist/operations/server/platformWatchdogDeliveryPolicy.d.ts +10 -0
  62. package/dist/operations/server/platformWatchdogDeliveryPolicy.js +53 -0
  63. package/dist/operations/server/platformWatchdogQueue.d.ts +11 -0
  64. package/dist/operations/server/platformWatchdogQueue.js +94 -0
  65. package/dist/operations/server/platformWatchdogService.d.ts +47 -0
  66. package/dist/operations/server/platformWatchdogService.js +105 -0
  67. package/dist/operations/validation.d.ts +2 -0
  68. package/dist/operations/validation.js +3 -0
  69. package/dist/portability/filesystemObjects.d.ts +12 -0
  70. package/dist/portability/filesystemObjects.js +191 -0
  71. package/dist/portability/index.d.ts +5 -0
  72. package/dist/portability/index.js +4 -0
  73. package/dist/portability/objects.d.ts +63 -0
  74. package/dist/portability/objects.js +138 -0
  75. package/dist/portability/postgresRows.d.ts +64 -0
  76. package/dist/portability/postgresRows.js +182 -0
  77. package/dist/scheduler/advisoryLock.js +20 -2
  78. package/dist/scheduler/nodeScheduler.d.ts +2 -2
  79. package/dist/scheduler/nodeScheduler.js +35 -6
  80. package/package.json +27 -2
  81. package/release-gates.json +73 -7
  82. package/sql/20260814010000_platform_control_plane.sql +286 -0
  83. package/src/composition/nodeHost.ts +4 -3
  84. package/src/lifecycle/appUpgrade.ts +186 -0
  85. package/src/lifecycle/index.ts +19 -0
  86. package/src/lifecycle/postgresInstallQualification.ts +125 -0
  87. package/src/migrations/postgresRunner.ts +24 -9
  88. package/src/operations/adminAlertsSchemas.ts +78 -0
  89. package/src/operations/adminAlertsView.ts +238 -0
  90. package/src/operations/alertPagingPolicy.ts +50 -0
  91. package/src/operations/alertThrottlePolicy.ts +22 -0
  92. package/src/operations/clients.ts +44 -0
  93. package/src/operations/contracts/admin.ts +24 -0
  94. package/src/operations/contracts/adminAlertsContracts.ts +13 -0
  95. package/src/operations/contracts/alertHumanContext.ts +17 -0
  96. package/src/operations/contracts/index.ts +6 -0
  97. package/src/operations/contracts/observabilityContracts.ts +40 -0
  98. package/src/operations/contracts/observabilityPorts.ts +30 -0
  99. package/src/operations/contracts/ports.ts +22 -0
  100. package/src/operations/errors.ts +6 -0
  101. package/src/operations/index.ts +9 -0
  102. package/src/operations/schemas.ts +122 -0
  103. package/src/operations/server/adminAlertsHandlers.ts +61 -0
  104. package/src/operations/server/adminPipelineHandlers.ts +73 -0
  105. package/src/operations/server/adminSettingsHandlers.ts +114 -0
  106. package/src/operations/server/controlPlaneHandler.ts +56 -0
  107. package/src/operations/server/controlPlaneStore.ts +150 -0
  108. package/src/operations/server/controlPlaneWatchdog.ts +28 -0
  109. package/src/operations/server/index.ts +12 -0
  110. package/src/operations/server/migrations.ts +7 -0
  111. package/src/operations/server/platformWatchdogDecision.ts +93 -0
  112. package/src/operations/server/platformWatchdogDeliveryPolicy.ts +92 -0
  113. package/src/operations/server/platformWatchdogQueue.ts +96 -0
  114. package/src/operations/server/platformWatchdogService.ts +209 -0
  115. package/src/operations/validation.ts +3 -0
  116. package/src/portability/filesystemObjects.ts +138 -0
  117. package/src/portability/index.ts +12 -0
  118. package/src/portability/objects.ts +160 -0
  119. package/src/portability/postgresRows.ts +169 -0
  120. package/src/scheduler/advisoryLock.ts +20 -5
  121. package/src/scheduler/nodeScheduler.ts +28 -8
@@ -0,0 +1,186 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { promises as fs } from "node:fs";
3
+ import { dirname, join, resolve } from "node:path";
4
+
5
+ /** Exact UTF-8 scaffold files; every unlisted path remains application-owned. */
6
+ export interface AppVersionSnapshot {
7
+ version: string;
8
+ schema: { min: number; max: number };
9
+ files: Readonly<Record<string, string>>;
10
+ }
11
+
12
+ export interface AppUpgradeInput {
13
+ /** Absolute canonical path (as returned by realpath) of an existing app directory. */
14
+ root: string;
15
+ from: AppVersionSnapshot;
16
+ to: AppVersionSnapshot;
17
+ /** Observed by the caller; planning and applying execute no database work. */
18
+ schemaVersion: number;
19
+ }
20
+
21
+ export type AppUpgradeAction = "create" | "replace" | "remove" | "unchanged" | "preserved" | "already-applied";
22
+ export interface AppUpgradeConflict { path: string; reason: string }
23
+ export interface AppUpgradePlan {
24
+ fromVersion: string;
25
+ toVersion: string;
26
+ schemaVersion: number;
27
+ files: { path: string; action: AppUpgradeAction }[];
28
+ conflicts: AppUpgradeConflict[];
29
+ }
30
+
31
+ export class AppUpgradeConflictError extends Error {
32
+ constructor(public readonly conflicts: readonly AppUpgradeConflict[]) {
33
+ super(`Application upgrade refused: ${conflicts.map(({ path, reason }) => `${path || "schema"}: ${reason}`).join("; ")}`);
34
+ this.name = "AppUpgradeConflictError";
35
+ }
36
+ }
37
+
38
+ /** Read-only comparison of explicit predecessor and candidate snapshots. */
39
+ export async function planAppUpgrade(input: AppUpgradeInput): Promise<AppUpgradePlan> {
40
+ return (await inspectUpgrade(input)).plan;
41
+ }
42
+
43
+ /**
44
+ * The caller must hold an exclusive maintenance window over the application tree.
45
+ * This is not a lock against concurrent editors or hostile filesystem mutation.
46
+ * All conflicts are checked before any writes. A later I/O failure can leave a
47
+ * partial upgrade: rerun the same snapshots to recognize exact completed bytes.
48
+ * Each replacement is atomic on a local filesystem; the complete upgrade is not.
49
+ * An abrupt process termination can leave a uniquely named temporary file; it is
50
+ * never treated as a managed app file or resumed operation.
51
+ * No SQL, dependency install, version marker, deployment or user hook is executed.
52
+ */
53
+ export async function applyAppUpgrade(input: AppUpgradeInput): Promise<{ plan: AppUpgradePlan; applied: string[] }> {
54
+ const { root, plan, target } = await inspectUpgrade(input);
55
+ if (plan.conflicts.length) throw new AppUpgradeConflictError(plan.conflicts);
56
+ const applied: string[] = [];
57
+ for (const file of plan.files) {
58
+ const destination = join(root, file.path);
59
+ if (file.action === "remove") {
60
+ await fs.unlink(destination);
61
+ } else if (file.action === "create" || file.action === "replace") {
62
+ await fs.mkdir(dirname(destination), { recursive: true });
63
+ const temporary = join(dirname(destination), `.openlup-${randomUUID()}.tmp`);
64
+ // Rename replaces this directory entry rather than modifying a possible hardlink.
65
+ const mode = file.action === "replace" ? (await fs.stat(destination)).mode & 0o777 : 0o600;
66
+ let handle;
67
+ try {
68
+ handle = await fs.open(temporary, "wx", mode);
69
+ if (file.action === "replace") await handle.chmod(mode);
70
+ await handle.writeFile(target.get(file.path)!, "utf8");
71
+ await handle.close();
72
+ await fs.rename(temporary, destination);
73
+ } finally {
74
+ if (handle) {
75
+ await handle.close();
76
+ await fs.rm(temporary, { force: true });
77
+ }
78
+ }
79
+ } else continue;
80
+ applied.push(file.path);
81
+ }
82
+ return { plan, applied };
83
+ }
84
+
85
+ async function inspectUpgrade(input: AppUpgradeInput): Promise<{
86
+ root: string; plan: AppUpgradePlan; target: Map<string, string>;
87
+ }> {
88
+ const before = snapshotFiles(input.from), target = snapshotFiles(input.to);
89
+ const paths = [...new Set([...before.keys(), ...target.keys()])].sort();
90
+ const aliases = new Set<string>();
91
+ for (const path of paths) {
92
+ const alias = path.normalize("NFC").toLowerCase().toUpperCase();
93
+ if (aliases.has(alias)) throw new Error(`Application upgrade path alias: ${path}`);
94
+ aliases.add(alias);
95
+ }
96
+ const directorySpellings = new Map<string, string>();
97
+ for (const path of paths) {
98
+ const components = path.split("/");
99
+ while (components.length > 1) {
100
+ components.pop();
101
+ const directory = components.join("/"), alias = directory.normalize("NFC").toLowerCase().toUpperCase();
102
+ if (aliases.has(alias)) throw new Error(`Application upgrade file-parent collision: ${path}`);
103
+ // Parents may not exist yet; both snapshots must still agree on their spelling.
104
+ const spelling = directorySpellings.get(alias);
105
+ if (spelling !== undefined && spelling !== directory) throw new Error(`Application upgrade path alias: ${path}`);
106
+ directorySpellings.set(alias, directory);
107
+ }
108
+ }
109
+ if (!Number.isSafeInteger(input.schemaVersion) || input.schemaVersion < 0) throw new Error("Application schema version must be a nonnegative safe integer");
110
+ const plan: AppUpgradePlan = {
111
+ fromVersion: input.from.version, toVersion: input.to.version,
112
+ schemaVersion: input.schemaVersion, files: [], conflicts: [],
113
+ };
114
+ for (const snapshot of [input.from, input.to]) {
115
+ if (input.schemaVersion < snapshot.schema.min || input.schemaVersion > snapshot.schema.max) {
116
+ plan.conflicts.push({ path: "", reason: `schema ${input.schemaVersion} is outside ${snapshot.version} range ${snapshot.schema.min}..${snapshot.schema.max}` });
117
+ }
118
+ }
119
+ const requestedRoot = resolve(input.root), rootStat = await fs.lstat(requestedRoot);
120
+ if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) throw new Error("Application upgrade root must be an ordinary directory");
121
+ const root = await fs.realpath(requestedRoot);
122
+ if (input.root !== root) throw new Error("Application upgrade root must be an absolute canonical path");
123
+ for (const path of paths) {
124
+ const observed = await readFile(root, path);
125
+ if (typeof observed === "string") {
126
+ plan.conflicts.push({ path, reason: observed });
127
+ continue;
128
+ }
129
+ const oldBytes = before.has(path) ? Buffer.from(before.get(path)!, "utf8") : null;
130
+ const newBytes = target.has(path) ? Buffer.from(target.get(path)!, "utf8") : null;
131
+ const equal = (bytes: Buffer | null) => observed === null ? bytes === null : bytes !== null && observed.equals(bytes);
132
+ let action: AppUpgradeAction;
133
+ if (oldBytes !== null && newBytes !== null && oldBytes.equals(newBytes)) {
134
+ // An upstream-unchanged file carries no write authority over local customization.
135
+ action = equal(oldBytes) ? "unchanged" : "preserved";
136
+ } else if (equal(newBytes)) action = "already-applied";
137
+ else if (equal(oldBytes)) action = newBytes === null ? "remove" : oldBytes === null ? "create" : "replace";
138
+ else {
139
+ plan.conflicts.push({ path, reason: "current bytes match neither predecessor nor candidate" });
140
+ continue;
141
+ }
142
+ plan.files.push({ path, action });
143
+ }
144
+ return { root, plan, target };
145
+ }
146
+
147
+ function snapshotFiles(snapshot: AppVersionSnapshot): Map<string, string> {
148
+ if (!snapshot || typeof snapshot.version !== "string" || !snapshot.version.trim()) throw new Error("Application snapshot needs an explicit version");
149
+ const range = snapshot.schema;
150
+ if (!range || !Number.isSafeInteger(range.min) || !Number.isSafeInteger(range.max) || range.min < 0 || range.max < range.min) throw new Error("Application snapshot needs an ordered nonnegative schema range");
151
+ if (!snapshot.files || typeof snapshot.files !== "object" || Array.isArray(snapshot.files)) throw new Error("Application snapshot files must be a record");
152
+ const result = new Map<string, string>();
153
+ for (const [path, content] of Object.entries(snapshot.files)) {
154
+ const segments = path.split("/");
155
+ if (!path || Buffer.from(path, "utf8").toString("utf8") !== path || /[\\:]/u.test(path) || [...path].some(character => character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127) || segments.some(segment => !segment || segment === "." || segment === ".." || /[. ]$/u.test(segment)) || segments.some(segment => [".git", "node_modules"].includes(segment.toLowerCase()))) {
156
+ throw new Error(`Unsafe application upgrade path: ${path}`);
157
+ }
158
+ if (typeof content !== "string" || Buffer.from(content, "utf8").toString("utf8") !== content) throw new Error(`Application upgrade content must be valid UTF-8: ${path}`);
159
+ result.set(path, content);
160
+ }
161
+ return result;
162
+ }
163
+
164
+ /** A string is a structural refusal; null means an absent path. Never follow links. */
165
+ async function readFile(root: string, path: string): Promise<Buffer | null | string> {
166
+ const segments = path.split("/");
167
+ let current = root;
168
+ for (const [index, segment] of segments.entries()) {
169
+ const parent = current;
170
+ current = join(current, segment);
171
+ let stat;
172
+ try { stat = await fs.lstat(current); }
173
+ catch (error) {
174
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
175
+ throw error;
176
+ }
177
+ // A filesystem alias is not ownership of the differently spelled entry.
178
+ // Check only after lstat: ENOENT on a case-sensitive store stays absence.
179
+ if (!(await fs.readdir(parent)).includes(segment)) return "managed path spelling differs from directory entry";
180
+ if (stat.isSymbolicLink()) return "symlink in managed path";
181
+ const last = index === segments.length - 1;
182
+ if (last) return stat.isFile() ? fs.readFile(current) : "managed path is not a regular file";
183
+ if (!stat.isDirectory()) return "managed parent is not a directory";
184
+ }
185
+ return null;
186
+ }
@@ -0,0 +1,19 @@
1
+ export {
2
+ planAppUpgrade,
3
+ applyAppUpgrade,
4
+ AppUpgradeConflictError,
5
+ type AppVersionSnapshot,
6
+ type AppUpgradeInput,
7
+ type AppUpgradeAction,
8
+ type AppUpgradeConflict,
9
+ type AppUpgradePlan,
10
+ } from "./appUpgrade.js";
11
+ export {
12
+ inspectLegacyPostgresInstallRoles,
13
+ type PostgresInstallQuery,
14
+ type LegacyPostgresInstallRequest,
15
+ type PostgresInstallRoleObservation,
16
+ type PostgresInstallMembershipObservation,
17
+ type LegacyPostgresInstallBlocker,
18
+ type LegacyPostgresInstallRoleReport,
19
+ } from "./postgresInstallQualification.js";
@@ -0,0 +1,125 @@
1
+ /** Read-only prerequisites of the immutable legacy fresh-install bootstrap, not install readiness. */
2
+ export interface PostgresInstallQuery {
3
+ query(sql: string, values?: unknown[]): Promise<{ rows: Record<string, unknown>[] }>;
4
+ }
5
+
6
+ export interface LegacyPostgresInstallRequest {
7
+ /** Expected actual login: both current_user and session_user must match. */
8
+ migrationLogin: string;
9
+ /** Legacy bootstrap requires this to be the same login as the migrator. */
10
+ runtimeLogin: string;
11
+ roleMode: "installer-managed" | "operator-provisioned";
12
+ }
13
+
14
+ export interface PostgresInstallRoleObservation {
15
+ name: string;
16
+ login: boolean;
17
+ superuser: boolean;
18
+ inherit: boolean;
19
+ createRole: boolean;
20
+ createDatabase: boolean;
21
+ replication: boolean;
22
+ bypassRls: boolean;
23
+ }
24
+
25
+ export interface PostgresInstallMembershipObservation {
26
+ role: string;
27
+ member: string;
28
+ grantor: string;
29
+ admin: boolean;
30
+ inherit: boolean;
31
+ set: boolean;
32
+ }
33
+
34
+ export type LegacyPostgresInstallBlocker =
35
+ | "migration_login_mismatch"
36
+ | "legacy_bootstrap_requires_superuser"
37
+ | "legacy_bootstrap_requires_shared_runtime_login"
38
+ | "runtime_login_missing_or_not_login"
39
+ | "legacy_bootstrap_requires_installer_managed_roles"
40
+ | "legacy_bootstrap_role_already_exists";
41
+
42
+ export interface LegacyPostgresInstallRoleReport {
43
+ scope: "legacy-fresh-bootstrap-role-prerequisites";
44
+ database: string;
45
+ currentUser: string;
46
+ sessionUser: string;
47
+ roles: PostgresInstallRoleObservation[];
48
+ memberships: PostgresInstallMembershipObservation[];
49
+ blockers: { code: LegacyPostgresInstallBlocker; role?: string }[];
50
+ rolePrerequisitesMet: boolean;
51
+ }
52
+
53
+ const BOOTSTRAP_ROLES = ["anon", "authenticated", "platform_acquisition_owner", "platform_acquisition_runtime"];
54
+ // One statement observes identities and the relevant role graph in the same statement snapshot.
55
+ // No SQL, identifiers, credentials or catalog expectations come from the caller.
56
+ // Bind functions, cast types and operators independently of the caller's search_path.
57
+ const OBSERVE = `SELECT pg_catalog.current_database() AS database, current_user::pg_catalog.text AS "currentUser",
58
+ session_user::pg_catalog.text AS "sessionUser",
59
+ (SELECT COALESCE(pg_catalog.jsonb_agg(pg_catalog.jsonb_build_object('name', rolname, 'login', rolcanlogin,
60
+ 'superuser', rolsuper, 'inherit', rolinherit, 'createRole', rolcreaterole,
61
+ 'createDatabase', rolcreatedb, 'replication', rolreplication, 'bypassRls', rolbypassrls)
62
+ ORDER BY rolname), '[]'::pg_catalog.jsonb) FROM pg_catalog.pg_roles
63
+ WHERE rolname OPERATOR(pg_catalog.=) ANY($1::pg_catalog.text[]) OR rolname OPERATOR(pg_catalog.=) current_user) AS roles,
64
+ (SELECT COALESCE(pg_catalog.jsonb_agg(pg_catalog.jsonb_build_object('role', granted.rolname, 'member', member.rolname,
65
+ 'grantor', grantor.rolname, 'admin', m.admin_option, 'inherit', m.inherit_option, 'set', m.set_option)
66
+ ORDER BY granted.rolname, member.rolname, grantor.rolname), '[]'::pg_catalog.jsonb)
67
+ FROM pg_catalog.pg_auth_members m
68
+ JOIN pg_catalog.pg_roles granted ON granted.oid OPERATOR(pg_catalog.=) m.roleid
69
+ JOIN pg_catalog.pg_roles member ON member.oid OPERATOR(pg_catalog.=) m.member
70
+ JOIN pg_catalog.pg_roles grantor ON grantor.oid OPERATOR(pg_catalog.=) m.grantor
71
+ WHERE granted.rolname OPERATOR(pg_catalog.=) ANY($1::pg_catalog.text[])
72
+ OR member.rolname OPERATOR(pg_catalog.=) ANY($1::pg_catalog.text[])) AS memberships`;
73
+
74
+ function isRecord(value: unknown): value is Record<string, unknown> {
75
+ return value !== null && typeof value === "object" && !Array.isArray(value);
76
+ }
77
+
78
+ function validRows<T>(value: unknown, texts: string[], flags: string[]): value is T[] {
79
+ return Array.isArray(value) && value.every(row => isRecord(row)
80
+ && texts.every(key => typeof row[key] === "string" && row[key].length > 0)
81
+ && flags.every(key => typeof row[key] === "boolean"));
82
+ }
83
+
84
+ /**
85
+ * Observes PostgreSQL 16+ legacy role prerequisites without changing the database.
86
+ * A true result says nothing about schema inventory, object ACLs, pooling, import safety or hosting.
87
+ * Query/driver errors are redacted because they can contain connection credentials.
88
+ */
89
+ export async function inspectLegacyPostgresInstallRoles(
90
+ db: PostgresInstallQuery,
91
+ request: LegacyPostgresInstallRequest,
92
+ ): Promise<LegacyPostgresInstallRoleReport> {
93
+ if (![request.migrationLogin, request.runtimeLogin].every(name => typeof name === "string" && name.trim().length > 0)
94
+ || !["installer-managed", "operator-provisioned"].includes(request.roleMode)) {
95
+ throw new Error("postgres_install_request_invalid");
96
+ }
97
+ let rows: Record<string, unknown>[];
98
+ try {
99
+ ({ rows } = await db.query(OBSERVE, [[...new Set([...BOOTSTRAP_ROLES, request.migrationLogin, request.runtimeLogin])]]));
100
+ } catch {
101
+ throw new Error("postgres_install_observation_failed");
102
+ }
103
+ const row = rows[0];
104
+ if (rows.length !== 1 || !row
105
+ || ![row.database, row.currentUser, row.sessionUser].every(value => typeof value === "string" && value.length > 0)
106
+ || !validRows<PostgresInstallRoleObservation>(row.roles, ["name"], ["login", "superuser", "inherit", "createRole", "createDatabase", "replication", "bypassRls"])
107
+ || !validRows<PostgresInstallMembershipObservation>(row.memberships, ["role", "member", "grantor"], ["admin", "inherit", "set"])) {
108
+ throw new Error("postgres_install_observation_invalid");
109
+ }
110
+ const roles = row.roles;
111
+ const blockers: LegacyPostgresInstallRoleReport["blockers"] = [];
112
+ if (row.currentUser !== request.migrationLogin || row.sessionUser !== request.migrationLogin) blockers.push({ code: "migration_login_mismatch" });
113
+ if (!roles.some(role => role.name === row.currentUser && role.superuser)) blockers.push({ code: "legacy_bootstrap_requires_superuser" });
114
+ if (row.currentUser !== request.runtimeLogin) blockers.push({ code: "legacy_bootstrap_requires_shared_runtime_login" });
115
+ if (!roles.some(role => role.name === request.runtimeLogin && role.login)) blockers.push({ code: "runtime_login_missing_or_not_login" });
116
+ if (request.roleMode !== "installer-managed") blockers.push({ code: "legacy_bootstrap_requires_installer_managed_roles" });
117
+ for (const name of BOOTSTRAP_ROLES) {
118
+ if (roles.some(role => role.name === name)) blockers.push({ code: "legacy_bootstrap_role_already_exists", role: name });
119
+ }
120
+ return {
121
+ scope: "legacy-fresh-bootstrap-role-prerequisites",
122
+ database: row.database as string, currentUser: row.currentUser as string, sessionUser: row.sessionUser as string,
123
+ roles, memberships: row.memberships, blockers, rolePrerequisitesMet: blockers.length === 0,
124
+ };
125
+ }
@@ -34,6 +34,13 @@ export interface PlatformMigrationSource {
34
34
  migrations: readonly PlatformMigrationFile[];
35
35
  objectInventoryPayload?: string;
36
36
  }): string[];
37
+ /**
38
+ * Optional trusted-source observation/refusal after exact-prefix validation, under the
39
+ * migration session lock. Runs once per operation before any pending SQL or dry-run result.
40
+ * The canonical source uses read-only observations; the callback shares the caller's existing
41
+ * database capability and is not a sandbox or a provisioning/per-unit execution hook.
42
+ */
43
+ assertSession?(client: PoolClient, applied: readonly (PlatformMigrationManifestEntry & { position: number })[]): Promise<void>;
37
44
  }
38
45
 
39
46
  export interface PostgresMigrationRunnerOptions {
@@ -60,6 +67,9 @@ type LedgerExistenceRow = { ledger_exists: unknown };
60
67
  type AdvisoryUnlockRow = { unlocked: unknown };
61
68
  const ACQUIRE_MIGRATION_LOCK = "SELECT pg_advisory_lock(hashtextextended($1, 0))";
62
69
  const RELEASE_MIGRATION_LOCK = "SELECT pg_advisory_unlock(hashtextextended($1, 0)) AS unlocked";
70
+ const ACQUIRE_CATALOG_BOUND_MIGRATION_LOCK = "SELECT pg_catalog.pg_advisory_lock(pg_catalog.hashtextextended($1::pg_catalog.text, 0::pg_catalog.int8))";
71
+ const RELEASE_CATALOG_BOUND_MIGRATION_LOCK = "SELECT pg_catalog.pg_advisory_unlock(pg_catalog.hashtextextended($1::pg_catalog.text, 0::pg_catalog.int8)) AS unlocked";
72
+ const CATALOG_BOUND_LEDGER_EXISTENCE = "SELECT pg_catalog.to_regclass($1::pg_catalog.text) IS NOT NULL AS ledger_exists";
63
73
 
64
74
  export function createPostgresMigrationRunner(
65
75
  env: PostgresMigrationRunnerEnv,
@@ -79,8 +89,10 @@ export function createPostgresMigrationRunner(
79
89
  async status(): Promise<{ pending: string[]; applied: string[] }> {
80
90
  assertConnectionString(env);
81
91
  const plan = loadPlan(root, options.source);
82
- return withMigrationSession(await getPool(), options.lockName, async (client) => {
83
- const applied = await readAppliedPrefix(client, plan.units);
92
+ const catalogBoundSession = options.source.assertSession !== undefined;
93
+ return withMigrationSession(await getPool(), options.lockName, catalogBoundSession, async (client) => {
94
+ const applied = await readAppliedPrefix(client, plan.units, catalogBoundSession);
95
+ await options.source.assertSession?.(client, applied.map((unit) => ({ file: unit.catalogPath, sha256: unit.sha256, position: unit.position })));
84
96
  if (applied.length === plan.units.length) await assertObjectInventory(client, plan, options);
85
97
  return {
86
98
  pending: plan.units.slice(applied.length).map((unit) => unit.catalogPath),
@@ -92,8 +104,10 @@ export function createPostgresMigrationRunner(
92
104
  async apply(opts?: { dryRun?: boolean }): Promise<{ applied: string[] }> {
93
105
  assertConnectionString(env);
94
106
  const plan = loadPlan(root, options.source);
95
- return withMigrationSession(await getPool(), options.lockName, async (client) => {
96
- const already = await readAppliedPrefix(client, plan.units);
107
+ const catalogBoundSession = options.source.assertSession !== undefined;
108
+ return withMigrationSession(await getPool(), options.lockName, catalogBoundSession, async (client) => {
109
+ const already = await readAppliedPrefix(client, plan.units, catalogBoundSession);
110
+ await options.source.assertSession?.(client, already.map((unit) => ({ file: unit.catalogPath, sha256: unit.sha256, position: unit.position })));
97
111
  const pending = plan.units.slice(already.length);
98
112
  if (opts?.dryRun) {
99
113
  if (pending.length === 0) await assertObjectInventory(client, plan, options);
@@ -115,7 +129,7 @@ export function createPostgresMigrationRunner(
115
129
  throw new Error(`platform migration ${unit.catalogPath} failed: ${errorMessage(error)}`);
116
130
  }
117
131
  }
118
- const complete = await readAppliedPrefix(client, plan.units);
132
+ const complete = await readAppliedPrefix(client, plan.units, catalogBoundSession);
119
133
  if (complete.length !== plan.units.length) throw new Error("platform migration ledger is incomplete after apply");
120
134
  await assertObjectInventory(client, plan, options);
121
135
  return { applied: appliedNow };
@@ -127,6 +141,7 @@ export function createPostgresMigrationRunner(
127
141
  async function withMigrationSession<T>(
128
142
  pool: Pool,
129
143
  lockName: string,
144
+ catalogBoundSession: boolean,
130
145
  operation: (client: PoolClient) => Promise<T>,
131
146
  ): Promise<T> {
132
147
  const client = await pool.connect();
@@ -135,7 +150,7 @@ async function withMigrationSession<T>(
135
150
  let operationFailure: unknown;
136
151
  let result!: T;
137
152
  try {
138
- await client.query(ACQUIRE_MIGRATION_LOCK, [lockName]);
153
+ await client.query(catalogBoundSession ? ACQUIRE_CATALOG_BOUND_MIGRATION_LOCK : ACQUIRE_MIGRATION_LOCK, [lockName]);
139
154
  locked = true;
140
155
  result = await operation(client);
141
156
  } catch (error) {
@@ -146,7 +161,7 @@ async function withMigrationSession<T>(
146
161
  let unlockFailure: unknown;
147
162
  if (locked) {
148
163
  try {
149
- const unlocked = await client.query<AdvisoryUnlockRow>(RELEASE_MIGRATION_LOCK, [lockName]);
164
+ const unlocked = await client.query<AdvisoryUnlockRow>(catalogBoundSession ? RELEASE_CATALOG_BOUND_MIGRATION_LOCK : RELEASE_MIGRATION_LOCK, [lockName]);
150
165
  if (unlocked.rows[0]?.unlocked !== true) throw new Error("PostgreSQL did not release the advisory lock");
151
166
  } catch (error) {
152
167
  unlockFailure = error;
@@ -194,9 +209,9 @@ function loadPlan(root: string, source: PlatformMigrationSource): LoadedPlan {
194
209
  return Object.freeze({ manifest, migrations: catalog.migrations, units: Object.freeze(units) });
195
210
  }
196
211
 
197
- async function readAppliedPrefix(client: PoolClient, units: readonly PlannedMigration[]): Promise<readonly PlannedMigration[]> {
212
+ async function readAppliedPrefix(client: PoolClient, units: readonly PlannedMigration[], catalogBoundSession: boolean): Promise<readonly PlannedMigration[]> {
198
213
  const exists = await client.query<LedgerExistenceRow>(
199
- "SELECT to_regclass($1) IS NOT NULL AS ledger_exists",
214
+ catalogBoundSession ? CATALOG_BOUND_LEDGER_EXISTENCE : "SELECT to_regclass($1) IS NOT NULL AS ledger_exists",
200
215
  [LEDGER],
201
216
  );
202
217
  if (exists.rows[0]?.ledger_exists !== true) return [];
@@ -0,0 +1,78 @@
1
+ import { z } from "./validation.js";
2
+
3
+ /**
4
+ * Admin-facing projection of the platform alert ledger.
5
+ *
6
+ * Only what an operator needs to read; the ledger itself stays service-role only
7
+ * (platform_alerts REVOKEs ALL from `authenticated`), so this shape is served by
8
+ * /api/bff/admin/platform/alerts rather than by PostgREST.
9
+ */
10
+
11
+ export const adminAlertSeveritySchema = z.enum(["p0", "p1", "p2", "p3"]);
12
+
13
+ /**
14
+ * Audience split, derived from `platform_alerts.owner`:
15
+ * commerce -> a business operator can act on it
16
+ * platform -> engineering owns it; shown for context so the health pill is explainable
17
+ */
18
+ export const adminAlertLaneSchema = z.enum(["commerce", "platform"]);
19
+
20
+ export const adminAlertHealthSchema = z.enum(["ok", "degraded", "down", "unknown"]);
21
+
22
+ const isoDateTimeSchema = z.string().datetime({ offset: true });
23
+
24
+ export const adminAlertSchema = z
25
+ .object({
26
+ id: z.string().min(1).max(128),
27
+ dedupeKey: z.string().min(1).max(256),
28
+ lane: adminAlertLaneSchema,
29
+ // Free text on purpose: platform_alerts.owner has no CHECK constraint, so an
30
+ // enum here would throw the whole admin shell on the first new owner string.
31
+ // The open-endedness is absorbed by `lane`.
32
+ owner: z.string().trim().min(1).max(128),
33
+ severity: adminAlertSeveritySchema,
34
+ status: z.enum(["open", "acknowledged"]),
35
+ // Server-decided so every consumer agrees; the client never re-derives it.
36
+ pageable: z.boolean(),
37
+ title: z.string().trim().min(1).max(512),
38
+ message: z.string().trim().max(4096),
39
+ supportCode: z.string().trim().max(128).nullable(),
40
+ runbookUrl: z.string().trim().max(1024).nullable(),
41
+ firstSeenAt: isoDateTimeSchema,
42
+ lastSeenAt: isoDateTimeSchema,
43
+ snoozedUntil: isoDateTimeSchema.nullable(),
44
+ })
45
+ .strict();
46
+
47
+ export const adminAlertsHeartbeatSchema = z
48
+ .object({
49
+ lastSuccessAt: isoDateTimeSchema.nullable(),
50
+ staleAfterSeconds: z.number().int().positive(),
51
+ stale: z.boolean(),
52
+ })
53
+ .strict();
54
+
55
+ export const adminAlertsSummarySchema = z
56
+ .object({
57
+ /** Drives the bell badge. */
58
+ commercePageableCount: z.number().int().min(0),
59
+ commerceTotalCount: z.number().int().min(0),
60
+ platformPageableCount: z.number().int().min(0),
61
+ /** Visible controlled exceptions; excluded from paging and health degradation. */
62
+ snoozedCount: z.number().int().min(0),
63
+ maxOpenSeverity: adminAlertSeveritySchema.nullable(),
64
+ })
65
+ .strict();
66
+
67
+ export const adminAlertsOverviewResponseSchema = z
68
+ .object({
69
+ health: adminAlertHealthSchema,
70
+ summary: adminAlertsSummarySchema,
71
+ heartbeat: adminAlertsHeartbeatSchema,
72
+ alerts: z.array(adminAlertSchema).max(50),
73
+ // Snoozed rows are returned separately from the active-list cap so a
74
+ // controlled exception never disappears behind routine diagnostics.
75
+ snoozedAlerts: z.array(adminAlertSchema).max(200),
76
+ generatedAt: isoDateTimeSchema,
77
+ })
78
+ .strict();