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