@openlup/platform 0.28.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 (241) hide show
  1. package/CHANGELOG.md +369 -156
  2. package/README.md +220 -4
  3. package/dist/composition/index.d.ts +1 -0
  4. package/dist/composition/index.js +1 -0
  5. package/dist/composition/marketingContribution.d.ts +21 -0
  6. package/dist/composition/marketingContribution.js +29 -0
  7. package/dist/composition/nodeHost.d.ts +1 -1
  8. package/dist/composition/nodeHost.js +3 -2
  9. package/dist/lifecycle/appUpgrade.d.ts +52 -0
  10. package/dist/lifecycle/appUpgrade.js +180 -0
  11. package/dist/lifecycle/index.d.ts +2 -0
  12. package/dist/lifecycle/index.js +2 -0
  13. package/dist/lifecycle/postgresInstallQualification.d.ts +51 -0
  14. package/dist/lifecycle/postgresInstallQualification.js +74 -0
  15. package/dist/marketing/acquisitionCaseSchemas.d.ts +103 -0
  16. package/dist/marketing/acquisitionCaseSchemas.js +53 -0
  17. package/dist/marketing/acquisitionEvidenceSchemas.d.ts +105 -0
  18. package/dist/marketing/acquisitionEvidenceSchemas.js +55 -0
  19. package/dist/marketing/analytics/dataLayer.d.ts +18 -0
  20. package/dist/marketing/analytics/dataLayer.js +111 -0
  21. package/dist/marketing/analytics/noop.d.ts +9 -0
  22. package/dist/marketing/analytics/noop.js +8 -0
  23. package/dist/marketing/analytics/purchaseFacts.d.ts +4 -0
  24. package/dist/marketing/analytics/purchaseFacts.js +69 -0
  25. package/dist/marketing/analytics/routePolicy.d.ts +10 -0
  26. package/dist/marketing/analytics/routePolicy.js +59 -0
  27. package/dist/marketing/consentEvidence.d.ts +8 -0
  28. package/dist/marketing/consentEvidence.js +32 -0
  29. package/dist/marketing/contactLegal.d.ts +14 -0
  30. package/dist/marketing/contactLegal.js +19 -0
  31. package/dist/marketing/contracts/acquisition.d.ts +88 -0
  32. package/dist/marketing/contracts/acquisition.js +1 -0
  33. package/dist/marketing/contracts/acquisitionPorts.d.ts +49 -0
  34. package/dist/marketing/contracts/acquisitionPorts.js +1 -0
  35. package/dist/marketing/contracts/analytics.d.ts +144 -0
  36. package/dist/marketing/contracts/analytics.js +1 -0
  37. package/dist/marketing/contracts/contactLegal.d.ts +53 -0
  38. package/dist/marketing/contracts/contactLegal.js +1 -0
  39. package/dist/marketing/contracts/index.d.ts +6 -0
  40. package/dist/marketing/contracts/index.js +1 -0
  41. package/dist/marketing/contracts/prelaunch.d.ts +39 -0
  42. package/dist/marketing/contracts/prelaunch.js +1 -0
  43. package/dist/marketing/index.d.ts +16 -0
  44. package/dist/marketing/index.js +14 -0
  45. package/dist/marketing/prelaunchAcquisitionSchemas.d.ts +116 -0
  46. package/dist/marketing/prelaunchAcquisitionSchemas.js +51 -0
  47. package/dist/marketing/seo/jsonLd.d.ts +157 -0
  48. package/dist/marketing/seo/jsonLd.js +127 -0
  49. package/dist/marketing/seo/metadata.d.ts +41 -0
  50. package/dist/marketing/seo/metadata.js +36 -0
  51. package/dist/marketing/seo/serializeJsonLd.d.ts +1 -0
  52. package/dist/marketing/seo/serializeJsonLd.js +6 -0
  53. package/dist/marketing/seo/urls.d.ts +14 -0
  54. package/dist/marketing/seo/urls.js +23 -0
  55. package/dist/marketing/server/acquisitionCaseDirect.d.ts +4 -0
  56. package/dist/marketing/server/acquisitionCaseDirect.js +88 -0
  57. package/dist/marketing/server/acquisitionCaseHandlers.d.ts +28 -0
  58. package/dist/marketing/server/acquisitionCaseHandlers.js +40 -0
  59. package/dist/marketing/server/acquisitionCasePorts.d.ts +42 -0
  60. package/dist/marketing/server/acquisitionCasePorts.js +1 -0
  61. package/dist/marketing/server/acquisitionEvidenceDirect.d.ts +5 -0
  62. package/dist/marketing/server/acquisitionEvidenceDirect.js +100 -0
  63. package/dist/marketing/server/analyticsContext.d.ts +3 -0
  64. package/dist/marketing/server/analyticsContext.js +65 -0
  65. package/dist/marketing/server/analyticsContextPorts.d.ts +31 -0
  66. package/dist/marketing/server/analyticsContextPorts.js +1 -0
  67. package/dist/marketing/server/bindings.d.ts +14 -0
  68. package/dist/marketing/server/bindings.js +18 -0
  69. package/dist/marketing/server/directPorts.d.ts +26 -0
  70. package/dist/marketing/server/directPorts.js +1 -0
  71. package/dist/marketing/server/index.d.ts +14 -0
  72. package/dist/marketing/server/index.js +11 -0
  73. package/dist/marketing/server/postgres/acquisitionCaseRoutines.d.ts +10 -0
  74. package/dist/marketing/server/postgres/acquisitionCaseRoutines.js +204 -0
  75. package/dist/marketing/server/postgres/acquisitionEvidenceRoutines.d.ts +10 -0
  76. package/dist/marketing/server/postgres/acquisitionEvidenceRoutines.js +107 -0
  77. package/dist/marketing/server/postgres/index.d.ts +3 -0
  78. package/dist/marketing/server/postgres/index.js +3 -0
  79. package/dist/marketing/server/prelaunchAcquisitionDirect.d.ts +4 -0
  80. package/dist/marketing/server/prelaunchAcquisitionDirect.js +122 -0
  81. package/dist/marketing/server/privacyContributor.d.ts +66 -0
  82. package/dist/marketing/server/privacyContributor.js +87 -0
  83. package/dist/marketing/server/research/adminSurveyResponsesHandler.d.ts +9 -0
  84. package/dist/marketing/server/research/adminSurveyResponsesHandler.js +53 -0
  85. package/dist/marketing/server/research/managedSurveyResponseSubmit.d.ts +39 -0
  86. package/dist/marketing/server/research/managedSurveyResponseSubmit.js +102 -0
  87. package/dist/marketing/server/research/surveyResponseSubmitHandler.d.ts +8 -0
  88. package/dist/marketing/server/research/surveyResponseSubmitHandler.js +41 -0
  89. package/dist/marketing/validation.d.ts +2 -0
  90. package/dist/marketing/validation.js +3 -0
  91. package/dist/migrations/postgresRunner.d.ts +9 -0
  92. package/dist/migrations/postgresRunner.js +17 -10
  93. package/dist/operations/adminAlertsSchemas.d.ts +153 -0
  94. package/dist/operations/adminAlertsSchemas.js +69 -0
  95. package/dist/operations/adminAlertsView.d.ts +73 -0
  96. package/dist/operations/adminAlertsView.js +184 -0
  97. package/dist/operations/alertPagingPolicy.d.ts +15 -0
  98. package/dist/operations/alertPagingPolicy.js +43 -0
  99. package/dist/operations/alertThrottlePolicy.d.ts +3 -0
  100. package/dist/operations/alertThrottlePolicy.js +18 -0
  101. package/dist/operations/clients.d.ts +17 -0
  102. package/dist/operations/clients.js +32 -0
  103. package/dist/operations/contracts/admin.d.ts +93 -0
  104. package/dist/operations/contracts/admin.js +1 -0
  105. package/dist/operations/contracts/adminAlertsContracts.d.ts +40 -0
  106. package/dist/operations/contracts/adminAlertsContracts.js +1 -0
  107. package/dist/operations/contracts/alertHumanContext.d.ts +7 -0
  108. package/dist/operations/contracts/alertHumanContext.js +1 -0
  109. package/dist/operations/contracts/index.d.ts +6 -0
  110. package/dist/operations/contracts/index.js +6 -0
  111. package/dist/operations/contracts/observabilityContracts.d.ts +41 -0
  112. package/dist/operations/contracts/observabilityContracts.js +1 -0
  113. package/dist/operations/contracts/observabilityPorts.d.ts +18 -0
  114. package/dist/operations/contracts/observabilityPorts.js +1 -0
  115. package/dist/operations/contracts/ports.d.ts +19 -0
  116. package/dist/operations/contracts/ports.js +1 -0
  117. package/dist/operations/errors.d.ts +6 -0
  118. package/dist/operations/errors.js +6 -0
  119. package/dist/operations/index.d.ts +9 -0
  120. package/dist/operations/index.js +9 -0
  121. package/dist/operations/schemas.d.ts +138 -0
  122. package/dist/operations/schemas.js +104 -0
  123. package/dist/operations/server/adminAlertsHandlers.d.ts +16 -0
  124. package/dist/operations/server/adminAlertsHandlers.js +35 -0
  125. package/dist/operations/server/adminPipelineHandlers.d.ts +12 -0
  126. package/dist/operations/server/adminPipelineHandlers.js +48 -0
  127. package/dist/operations/server/adminSettingsHandlers.d.ts +14 -0
  128. package/dist/operations/server/adminSettingsHandlers.js +82 -0
  129. package/dist/operations/server/controlPlaneHandler.d.ts +7 -0
  130. package/dist/operations/server/controlPlaneHandler.js +54 -0
  131. package/dist/operations/server/controlPlaneStore.d.ts +12 -0
  132. package/dist/operations/server/controlPlaneStore.js +138 -0
  133. package/dist/operations/server/controlPlaneWatchdog.d.ts +7 -0
  134. package/dist/operations/server/controlPlaneWatchdog.js +20 -0
  135. package/dist/operations/server/index.d.ts +12 -0
  136. package/dist/operations/server/index.js +12 -0
  137. package/dist/operations/server/migrations.d.ts +14 -0
  138. package/dist/operations/server/migrations.js +7 -0
  139. package/dist/operations/server/platformWatchdogDecision.d.ts +34 -0
  140. package/dist/operations/server/platformWatchdogDecision.js +71 -0
  141. package/dist/operations/server/platformWatchdogDeliveryPolicy.d.ts +10 -0
  142. package/dist/operations/server/platformWatchdogDeliveryPolicy.js +53 -0
  143. package/dist/operations/server/platformWatchdogQueue.d.ts +11 -0
  144. package/dist/operations/server/platformWatchdogQueue.js +94 -0
  145. package/dist/operations/server/platformWatchdogService.d.ts +47 -0
  146. package/dist/operations/server/platformWatchdogService.js +105 -0
  147. package/dist/operations/validation.d.ts +2 -0
  148. package/dist/operations/validation.js +3 -0
  149. package/dist/portability/filesystemObjects.d.ts +12 -0
  150. package/dist/portability/filesystemObjects.js +191 -0
  151. package/dist/portability/index.d.ts +5 -0
  152. package/dist/portability/index.js +4 -0
  153. package/dist/portability/objects.d.ts +63 -0
  154. package/dist/portability/objects.js +138 -0
  155. package/dist/portability/postgresRows.d.ts +64 -0
  156. package/dist/portability/postgresRows.js +182 -0
  157. package/dist/scheduler/advisoryLock.js +20 -2
  158. package/dist/scheduler/nodeScheduler.d.ts +2 -2
  159. package/dist/scheduler/nodeScheduler.js +35 -6
  160. package/package.json +47 -2
  161. package/release-gates.json +143 -9
  162. package/sql/20260814010000_platform_control_plane.sql +286 -0
  163. package/src/composition/index.ts +1 -0
  164. package/src/composition/marketingContribution.ts +37 -0
  165. package/src/composition/nodeHost.ts +4 -3
  166. package/src/lifecycle/appUpgrade.ts +186 -0
  167. package/src/lifecycle/index.ts +19 -0
  168. package/src/lifecycle/postgresInstallQualification.ts +125 -0
  169. package/src/marketing/acquisitionCaseSchemas.ts +75 -0
  170. package/src/marketing/acquisitionEvidenceSchemas.ts +72 -0
  171. package/src/marketing/analytics/dataLayer.ts +100 -0
  172. package/src/marketing/analytics/noop.ts +14 -0
  173. package/src/marketing/analytics/purchaseFacts.ts +63 -0
  174. package/src/marketing/analytics/routePolicy.ts +55 -0
  175. package/src/marketing/consentEvidence.ts +36 -0
  176. package/src/marketing/contactLegal.ts +22 -0
  177. package/src/marketing/contracts/acquisition.ts +50 -0
  178. package/src/marketing/contracts/acquisitionPorts.ts +48 -0
  179. package/src/marketing/contracts/analytics.ts +88 -0
  180. package/src/marketing/contracts/contactLegal.ts +41 -0
  181. package/src/marketing/contracts/index.ts +17 -0
  182. package/src/marketing/contracts/prelaunch.ts +15 -0
  183. package/src/marketing/index.ts +16 -0
  184. package/src/marketing/prelaunchAcquisitionSchemas.ts +64 -0
  185. package/src/marketing/seo/jsonLd.ts +153 -0
  186. package/src/marketing/seo/metadata.ts +62 -0
  187. package/src/marketing/seo/serializeJsonLd.ts +6 -0
  188. package/src/marketing/seo/urls.ts +34 -0
  189. package/src/marketing/server/acquisitionCaseDirect.ts +67 -0
  190. package/src/marketing/server/acquisitionCaseHandlers.ts +57 -0
  191. package/src/marketing/server/acquisitionCasePorts.ts +42 -0
  192. package/src/marketing/server/acquisitionEvidenceDirect.ts +76 -0
  193. package/src/marketing/server/analyticsContext.ts +55 -0
  194. package/src/marketing/server/analyticsContextPorts.ts +28 -0
  195. package/src/marketing/server/bindings.ts +27 -0
  196. package/src/marketing/server/directPorts.ts +27 -0
  197. package/src/marketing/server/index.ts +14 -0
  198. package/src/marketing/server/postgres/acquisitionCaseRoutines.ts +218 -0
  199. package/src/marketing/server/postgres/acquisitionEvidenceRoutines.ts +103 -0
  200. package/src/marketing/server/postgres/index.ts +3 -0
  201. package/src/marketing/server/prelaunchAcquisitionDirect.ts +121 -0
  202. package/src/marketing/server/privacyContributor.ts +123 -0
  203. package/src/marketing/server/research/adminSurveyResponsesHandler.ts +78 -0
  204. package/src/marketing/server/research/managedSurveyResponseSubmit.ts +158 -0
  205. package/src/marketing/server/research/surveyResponseSubmitHandler.ts +57 -0
  206. package/src/marketing/validation.ts +3 -0
  207. package/src/migrations/postgresRunner.ts +24 -9
  208. package/src/operations/adminAlertsSchemas.ts +78 -0
  209. package/src/operations/adminAlertsView.ts +238 -0
  210. package/src/operations/alertPagingPolicy.ts +50 -0
  211. package/src/operations/alertThrottlePolicy.ts +22 -0
  212. package/src/operations/clients.ts +44 -0
  213. package/src/operations/contracts/admin.ts +24 -0
  214. package/src/operations/contracts/adminAlertsContracts.ts +13 -0
  215. package/src/operations/contracts/alertHumanContext.ts +17 -0
  216. package/src/operations/contracts/index.ts +6 -0
  217. package/src/operations/contracts/observabilityContracts.ts +40 -0
  218. package/src/operations/contracts/observabilityPorts.ts +30 -0
  219. package/src/operations/contracts/ports.ts +22 -0
  220. package/src/operations/errors.ts +6 -0
  221. package/src/operations/index.ts +9 -0
  222. package/src/operations/schemas.ts +122 -0
  223. package/src/operations/server/adminAlertsHandlers.ts +61 -0
  224. package/src/operations/server/adminPipelineHandlers.ts +73 -0
  225. package/src/operations/server/adminSettingsHandlers.ts +114 -0
  226. package/src/operations/server/controlPlaneHandler.ts +56 -0
  227. package/src/operations/server/controlPlaneStore.ts +150 -0
  228. package/src/operations/server/controlPlaneWatchdog.ts +28 -0
  229. package/src/operations/server/index.ts +12 -0
  230. package/src/operations/server/migrations.ts +7 -0
  231. package/src/operations/server/platformWatchdogDecision.ts +93 -0
  232. package/src/operations/server/platformWatchdogDeliveryPolicy.ts +92 -0
  233. package/src/operations/server/platformWatchdogQueue.ts +96 -0
  234. package/src/operations/server/platformWatchdogService.ts +209 -0
  235. package/src/operations/validation.ts +3 -0
  236. package/src/portability/filesystemObjects.ts +138 -0
  237. package/src/portability/index.ts +12 -0
  238. package/src/portability/objects.ts +160 -0
  239. package/src/portability/postgresRows.ts +169 -0
  240. package/src/scheduler/advisoryLock.ts +20 -5
  241. package/src/scheduler/nodeScheduler.ts +28 -8
@@ -0,0 +1,56 @@
1
+ import { createBffResponder, type BffResponderOptions, type BffResponder } from "../../bff/index.js";
2
+ import type { HttpRequest, HttpResponse } from "../../http/index.js";
3
+ import { platformControlPlaneMutationResponseSchema, platformControlPlaneMutationSchema, platformControlPlaneReadResponseSchema } from "../index.js";
4
+ import { PlatformControlPlaneConflictError } from "../index.js";
5
+ import type { PlatformControlPlanePort } from "../contracts/index.js";
6
+
7
+ export function createPlatformControlPlaneHandler(deps: {
8
+ port: PlatformControlPlanePort;
9
+ authorizeAdmin: () => Promise<boolean>;
10
+ } & BffResponderOptions) {
11
+ const { sendBffError, sendBffSuccess, sendMethodNotAllowed } = createBffResponder(deps);
12
+ return async (req: HttpRequest, res: HttpResponse): Promise<void> => {
13
+ if (!(await deps.authorizeAdmin())) {
14
+ sendBffError(res, "UNAUTHORIZED", "Admin session required");
15
+ return;
16
+ }
17
+ try {
18
+ if (req.method === "GET") {
19
+ const parsed = platformControlPlaneReadResponseSchema.safeParse(
20
+ await deps.port.readControlPlane({}),
21
+ );
22
+ if (!parsed.success) return invalidResponse(res, sendBffError);
23
+ sendBffSuccess(res, parsed.data);
24
+ return;
25
+ }
26
+ if (req.method === "POST") {
27
+ const parsed = platformControlPlaneMutationSchema.safeParse(body(req.body));
28
+ if (!parsed.success) {
29
+ sendBffError(res, "BAD_REQUEST", "Invalid platform control-plane request");
30
+ return;
31
+ }
32
+ const response = platformControlPlaneMutationResponseSchema.safeParse(
33
+ await deps.port.mutateControlPlane(parsed.data),
34
+ );
35
+ if (!response.success) return invalidResponse(res, sendBffError);
36
+ sendBffSuccess(res, response.data);
37
+ return;
38
+ }
39
+ sendMethodNotAllowed(res, ["GET", "POST"]);
40
+ } catch (error) {
41
+ if (error instanceof PlatformControlPlaneConflictError) {
42
+ sendBffError(res, "CONFLICT", error.message);
43
+ return;
44
+ }
45
+ sendBffError(res, "UPSTREAM_UNAVAILABLE", "Platform control plane unavailable");
46
+ }
47
+ };
48
+ }
49
+
50
+ function body(value: unknown): unknown {
51
+ if (typeof value !== "string") return value;
52
+ try { return JSON.parse(value); } catch { return null; }
53
+ }
54
+ function invalidResponse(res: HttpResponse, sendBffError: BffResponder["sendBffError"]): void {
55
+ sendBffError(res, "INVALID_RESPONSE", "Platform control plane returned invalid response");
56
+ }
@@ -0,0 +1,150 @@
1
+ import { PlatformControlPlaneConflictError, PlatformControlPlaneUnavailableError } from "../index.js";
2
+ import { createHash } from "node:crypto";
3
+
4
+ import type {
5
+ PlatformControlPlaneMutation,
6
+ PlatformControlPlaneMutationResponse,
7
+ PlatformControlPlaneReadResponse,
8
+ } from "../contracts/index.js";
9
+ import {
10
+ type PlatformControlPlanePort,
11
+ } from "../contracts/index.js";
12
+ import type { PgQueryExecutor } from "../../data/index.js";
13
+
14
+ const rpc = (name: string, types: string[]) =>
15
+ `SELECT * FROM public.${name}(${types.map((type, index) => `$${index + 1}::${type}`).join(", ")})`;
16
+
17
+ export const platformControlPlaneSql = {
18
+ active: rpc("platform_control_operator_is_active", ["uuid"]),
19
+ setControl: rpc("platform_control_set_control", ["uuid", "text", "text", "text", "boolean"]),
20
+ heartbeat: rpc("platform_control_record_heartbeat", ["uuid", "text", "text", "text", "integer", "text", "boolean"]),
21
+ controls: rpc("platform_control_list_controls", ["uuid"]),
22
+ jobs: rpc("platform_control_list_jobs", ["uuid"]),
23
+ alerts: rpc("platform_control_list_alerts", ["uuid"]),
24
+ } as const;
25
+
26
+ export function createPlatformControlPlanePort(
27
+ client: PgQueryExecutor,
28
+ operatorId: string,
29
+ ): PlatformControlPlanePort {
30
+ return {
31
+ async readControlPlane() {
32
+ try {
33
+ const [controls, jobs, alerts] = await Promise.all([
34
+ client.query(platformControlPlaneSql.controls, [operatorId]),
35
+ client.query(platformControlPlaneSql.jobs, [operatorId]),
36
+ client.query(platformControlPlaneSql.alerts, [operatorId]),
37
+ ]);
38
+ return {
39
+ controls: controls.rows.map((row) => ({
40
+ controlKey: text(row.control_key), enabled: bool(row.enabled),
41
+ revision: positive(row.revision), updatedAt: timestamp(row.updated_at),
42
+ })),
43
+ jobs: jobs.rows.map((row) => ({
44
+ jobName: text(row.job_name), enabled: bool(row.enabled),
45
+ activeDriver: driver(row.active_driver), lastStatus: jobStatus(row.last_status),
46
+ lastStartedAt: nullableTimestamp(row.last_started_at),
47
+ lastFinishedAt: nullableTimestamp(row.last_finished_at),
48
+ lastSuccessAt: nullableTimestamp(row.last_success_at),
49
+ })),
50
+ alerts: alerts.rows.map((row) => ({
51
+ dedupeKey: text(row.dedupe_key), severity: severity(row.severity),
52
+ status: alertStatus(row.status), owner: text(row.owner), title: text(row.title),
53
+ firstSeenAt: timestamp(row.first_seen_at), lastSeenAt: timestamp(row.last_seen_at),
54
+ resolvedAt: nullableTimestamp(row.resolved_at),
55
+ })),
56
+ } satisfies PlatformControlPlaneReadResponse;
57
+ } catch (error) {
58
+ throw mapError(error);
59
+ }
60
+ },
61
+
62
+ async mutateControlPlane(request) {
63
+ try {
64
+ const fingerprint = digest(JSON.stringify(request));
65
+ const result = request.action === "set-control"
66
+ ? await client.query(platformControlPlaneSql.setControl, [
67
+ operatorId, request.idempotencyKey, fingerprint, request.controlKey, request.enabled,
68
+ ])
69
+ : await client.query(platformControlPlaneSql.heartbeat, [
70
+ operatorId, request.idempotencyKey, fingerprint, request.health,
71
+ request.firingCount, request.maxSeverity, request.promotionReady,
72
+ ]);
73
+ return mutation(first(result.rows), request.action);
74
+ } catch (error) {
75
+ throw mapError(error);
76
+ }
77
+ },
78
+ };
79
+ }
80
+
81
+ export async function isPlatformControlOperatorActive(
82
+ client: PgQueryExecutor,
83
+ principalId: string,
84
+ ): Promise<boolean> {
85
+ try {
86
+ return first((await client.query(platformControlPlaneSql.active, [principalId])).rows)
87
+ ?.platform_control_operator_is_active === true;
88
+ } catch (error) {
89
+ throw mapError(error);
90
+ }
91
+ }
92
+
93
+ function mutation(
94
+ row: Record<string, unknown> | undefined,
95
+ expected: PlatformControlPlaneMutation["action"],
96
+ ): PlatformControlPlaneMutationResponse {
97
+ if (!row || row.action !== expected) throw new PlatformControlPlaneUnavailableError();
98
+ return {
99
+ action: expected,
100
+ replayed: bool(row.replayed),
101
+ revision: row.revision == null ? null : positive(row.revision),
102
+ recordedAt: timestamp(row.recorded_at),
103
+ };
104
+ }
105
+
106
+ function digest(value: string): string {
107
+ return createHash("sha256").update(value).digest("hex");
108
+ }
109
+ function first(rows: Record<string, unknown>[]): Record<string, unknown> | undefined { return rows[0]; }
110
+ function text(value: unknown): string {
111
+ if (typeof value !== "string" || value.length === 0) throw new PlatformControlPlaneUnavailableError();
112
+ return value;
113
+ }
114
+ function bool(value: unknown): boolean {
115
+ if (typeof value !== "boolean") throw new PlatformControlPlaneUnavailableError();
116
+ return value;
117
+ }
118
+ function positive(value: unknown): number {
119
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 1) throw new PlatformControlPlaneUnavailableError();
120
+ return value;
121
+ }
122
+ function timestamp(value: unknown): string {
123
+ const result = text(value);
124
+ if (!Number.isFinite(Date.parse(result))) throw new PlatformControlPlaneUnavailableError();
125
+ return result;
126
+ }
127
+ function nullableTimestamp(value: unknown): string | null { return value == null ? null : timestamp(value); }
128
+ function driver(value: unknown): "worker" | "scheduler" | "operator" {
129
+ if (value === "worker" || value === "scheduler" || value === "operator") return value;
130
+ throw new PlatformControlPlaneUnavailableError();
131
+ }
132
+ function jobStatus(value: unknown): "running" | "success" | "failed" | "skipped" | null {
133
+ if (value == null) return null;
134
+ if (value === "running" || value === "success" || value === "failed" || value === "skipped") return value;
135
+ throw new PlatformControlPlaneUnavailableError();
136
+ }
137
+ function severity(value: unknown): "p0" | "p1" | "p2" | "p3" {
138
+ if (value === "p0" || value === "p1" || value === "p2" || value === "p3") return value;
139
+ throw new PlatformControlPlaneUnavailableError();
140
+ }
141
+ function alertStatus(value: unknown): "open" | "acknowledged" | "resolved" {
142
+ if (value === "open" || value === "acknowledged" || value === "resolved") return value;
143
+ throw new PlatformControlPlaneUnavailableError();
144
+ }
145
+ function mapError(error: unknown): Error {
146
+ if (error instanceof PlatformControlPlaneUnavailableError || error instanceof PlatformControlPlaneConflictError) return error;
147
+ const code = typeof error === "object" && error && "code" in error ? (error as { code?: unknown }).code : undefined;
148
+ if (code === "23505") return new PlatformControlPlaneConflictError("platform_control_idempotency_conflict");
149
+ return new PlatformControlPlaneUnavailableError("platform_control_plane_unavailable");
150
+ }
@@ -0,0 +1,28 @@
1
+ import type { PlatformControlPlanePort } from "../contracts/index.js";
2
+
3
+ export type DirectPlatformWatchdogResult = { status: number; body: Record<string, unknown> };
4
+
5
+ /** Existing public-bundle evaluation of persisted neutral job facts. No provider calls. */
6
+ export async function runControlPlaneWatchdogTick(
7
+ port: PlatformControlPlanePort,
8
+ now: Date,
9
+ checkOnly: boolean,
10
+ ): Promise<DirectPlatformWatchdogResult> {
11
+ const snapshot = await port.readControlPlane({});
12
+ const failedJobs = snapshot.jobs.filter((job) => job.enabled && job.lastStatus === "failed");
13
+ const health = failedJobs.length > 0 ? "degraded" as const : "healthy" as const;
14
+ const recorded = checkOnly ? null : await port.mutateControlPlane({
15
+ action: "record-heartbeat",
16
+ idempotencyKey: `platform-watchdog:${now.toISOString()}`,
17
+ health,
18
+ firingCount: failedJobs.length,
19
+ maxSeverity: failedJobs.length > 0 ? "p2" : null,
20
+ promotionReady: failedJobs.length === 0,
21
+ });
22
+ return { status: 200, body: {
23
+ ok: true, health, firingCount: failedJobs.length,
24
+ failedJobs: failedJobs.map((job) => job.jobName),
25
+ checkedAt: now.toISOString(), heartbeatRecorded: recorded != null,
26
+ replayed: recorded?.replayed ?? false,
27
+ } };
28
+ }
@@ -0,0 +1,12 @@
1
+ /** Server mechanisms; authorization, persistence and provider transports are injected. */
2
+ export * from "./adminSettingsHandlers.js";
3
+ export * from "./adminPipelineHandlers.js";
4
+ export * from "./adminAlertsHandlers.js";
5
+ export * from "./controlPlaneHandler.js";
6
+ export * from "./controlPlaneStore.js";
7
+ export * from "./platformWatchdogService.js";
8
+ export * from "./platformWatchdogDecision.js";
9
+ export * from "./platformWatchdogDeliveryPolicy.js";
10
+ export * from "./platformWatchdogQueue.js";
11
+ export * from "./controlPlaneWatchdog.js";
12
+ export * from "./migrations.js";
@@ -0,0 +1,7 @@
1
+ /** Sparse projection only: install the complete canonical native prefix, never this unit alone. */
2
+ export const OPERATIONS_MIGRATIONS = {
3
+ native: { units: [{ position: 28, catalogPath: "db/platform/migrations/20260814010000_platform_control_plane.sql",
4
+ sha256: "b0c8ef6424ebc6f1a84cb132c37908f4831fa40484a729c3713cfb35936559c9",
5
+ url: new URL("../../../sql/20260814010000_platform_control_plane.sql", import.meta.url) }] },
6
+ managed: { verified: false },
7
+ } as const;
@@ -0,0 +1,93 @@
1
+ import type {
2
+ AlertDecision,
3
+ AlertNotificationOutcome,
4
+ AlertSeverity,
5
+ } from "../contracts/index.js";
6
+ import type { AlertLedgerPort, AlertSinkPort } from "../contracts/index.js";
7
+ import { shouldPage } from "../index.js";
8
+ import { throttleSecondsForSeverity } from "../index.js";
9
+ import {
10
+ isActionabilitySuppressed,
11
+ isDeliveryBackoff,
12
+ isPagingSuppressed,
13
+ notificationSchedule,
14
+ shouldAttemptNotification,
15
+ } from "./platformWatchdogDeliveryPolicy.js";
16
+
17
+ const BELOW_PAGING_SEVERITY_OUTCOME: AlertNotificationOutcome<"webhook"> = {
18
+ channel: "webhook", status: "skipped", provider: "webhook", error: "below_paging_severity",
19
+ };
20
+ const NON_PAGEABLE_DIAGNOSTIC_OUTCOME: AlertNotificationOutcome<"webhook"> = {
21
+ channel: "webhook", status: "skipped", provider: "webhook", error: "non_pageable_diagnostic",
22
+ };
23
+
24
+ export function emptyWatchdogDecisionCounts() {
25
+ return {
26
+ notified: 0,
27
+ notificationFailures: 0,
28
+ deliveryBackoffCount: 0,
29
+ throttledNotifications: 0,
30
+ skippedNotifications: 0,
31
+ belowThreshold: 0,
32
+ suppressed: 0,
33
+ actionableCriticalCount: 0,
34
+ suppressedCriticalCount: 0,
35
+ actionablePageableCount: 0,
36
+ };
37
+ }
38
+
39
+ /** One complete ledger transition; callers must serialize the same dedupe key. */
40
+ export async function processWatchdogDecision<Channel extends string>(
41
+ decision: AlertDecision<Channel>,
42
+ {
43
+ ledgerPort, sinkPort, now, pagingMinSeverity, notificationThrottleSeconds,
44
+ }: {
45
+ ledgerPort: AlertLedgerPort<Channel>;
46
+ sinkPort: AlertSinkPort<Channel>;
47
+ now: Date;
48
+ pagingMinSeverity: AlertSeverity;
49
+ notificationThrottleSeconds?: number;
50
+ },
51
+ ) {
52
+ const counts = emptyWatchdogDecisionCounts();
53
+ const alert = await ledgerPort.upsertOpenAlert(decision, now);
54
+ const critical = isCriticalDecision(decision);
55
+ const actionableSuppressed = isActionabilitySuppressed(alert, now);
56
+ const pagingSuppressed = isPagingSuppressed(alert, now);
57
+ const pageable = decision.paging !== "never" && shouldPage(decision.severity, pagingMinSeverity);
58
+
59
+ // Acknowledgement silences pages, but only a snooze suppresses actionability.
60
+ if (critical) {
61
+ if (actionableSuppressed) counts.suppressedCriticalCount += 1;
62
+ else counts.actionableCriticalCount += 1;
63
+ }
64
+ if (pageable && !pagingSuppressed) counts.actionablePageableCount += 1;
65
+ if (pageable && !pagingSuppressed && isDeliveryBackoff(alert, now)) counts.deliveryBackoffCount += 1;
66
+ if (pagingSuppressed) {
67
+ counts.suppressed += 1;
68
+ return counts;
69
+ }
70
+
71
+ const throttleSeconds = notificationThrottleSeconds ?? throttleSecondsForSeverity(decision.severity);
72
+ if (!shouldAttemptNotification(alert, now, throttleSeconds)) {
73
+ counts.throttledNotifications += 1;
74
+ return counts;
75
+ }
76
+
77
+ // Non-pageable and below-threshold alerts retain their skipped-attempt audit.
78
+ const nonPageable = decision.paging === "never";
79
+ const paging = !nonPageable && shouldPage(decision.severity, pagingMinSeverity);
80
+ const outcome = paging
81
+ ? await sinkPort.send(decision, alert)
82
+ : nonPageable ? NON_PAGEABLE_DIAGNOSTIC_OUTCOME : BELOW_PAGING_SEVERITY_OUTCOME;
83
+ if (outcome.status === "sent") counts.notified += 1;
84
+ else if (outcome.status === "failed") counts.notificationFailures += 1;
85
+ else counts.skippedNotifications += 1;
86
+ if (!paging) counts.belowThreshold += 1;
87
+ await ledgerPort.recordNotification(alert.id, outcome, now, notificationSchedule(alert, outcome, now, throttleSeconds));
88
+ return counts;
89
+ }
90
+
91
+ export function isCriticalDecision(decision: AlertDecision): boolean {
92
+ return decision.paging !== "never" && (decision.severity === "p0" || decision.severity === "p1");
93
+ }
@@ -0,0 +1,92 @@
1
+ import type {
2
+ AlertDecision,
3
+ AlertNotificationOutcome,
4
+ OpenAlert,
5
+ } from "../contracts/index.js";
6
+
7
+ const FAILURE_RETRY_INITIAL_SECONDS = 10 * 60;
8
+ const FAILURE_RETRY_MAX_SECONDS = 60 * 60;
9
+
10
+ export function shouldAttemptNotification(
11
+ alert: Pick<
12
+ OpenAlert,
13
+ "lastNotifiedAt" | "lastNotificationStatus" | "nextNotificationAttemptAt" | "snoozedUntil"
14
+ >,
15
+ now: Date,
16
+ throttleSeconds: number,
17
+ ): boolean {
18
+ const snoozeExpiry = alert.snoozedUntil ? Date.parse(alert.snoozedUntil) : Number.NaN;
19
+ const lastSentAt = alert.lastNotifiedAt ? Date.parse(alert.lastNotifiedAt) : Number.NaN;
20
+ if (
21
+ alert.lastNotificationStatus !== "failed"
22
+ && Number.isFinite(snoozeExpiry)
23
+ && snoozeExpiry <= now.getTime()
24
+ && (!Number.isFinite(lastSentAt) || snoozeExpiry > lastSentAt)
25
+ ) {
26
+ return true;
27
+ }
28
+ const nextAttemptAt = alert.nextNotificationAttemptAt
29
+ ? Date.parse(alert.nextNotificationAttemptAt)
30
+ : Number.NaN;
31
+ if (Number.isFinite(nextAttemptAt) && nextAttemptAt > now.getTime()) return false;
32
+ if (!alert.lastNotifiedAt) return true;
33
+ return now.getTime() - new Date(alert.lastNotifiedAt).getTime() >= throttleSeconds * 1000;
34
+ }
35
+
36
+ export function notificationSchedule(
37
+ alert: Pick<OpenAlert, "notificationFailureCount">,
38
+ outcome: AlertNotificationOutcome,
39
+ now: Date,
40
+ throttleSeconds: number,
41
+ ): { nextAttemptAt: Date; failureCount: number } {
42
+ if (outcome.status === "failed") {
43
+ const failureCount = (alert.notificationFailureCount ?? 0) + 1;
44
+ const exponent = Math.max(0, Math.min(failureCount - 1, 16));
45
+ const retrySeconds = Math.min(
46
+ FAILURE_RETRY_MAX_SECONDS,
47
+ FAILURE_RETRY_INITIAL_SECONDS * 2 ** exponent,
48
+ );
49
+ return {
50
+ nextAttemptAt: new Date(now.getTime() + retrySeconds * 1000),
51
+ failureCount,
52
+ };
53
+ }
54
+
55
+ return {
56
+ // Sent uses the severity cadence. Skipped uses the same due field only to
57
+ // avoid duplicate audit rows; last_notified_at still advances on sent only.
58
+ nextAttemptAt: new Date(now.getTime() + throttleSeconds * 1000),
59
+ failureCount: 0,
60
+ };
61
+ }
62
+
63
+ export function isActionabilitySuppressed(
64
+ alert: Pick<OpenAlert, "snoozedUntil">,
65
+ now: Date,
66
+ ): boolean {
67
+ return alert.snoozedUntil != null && new Date(alert.snoozedUntil).getTime() > now.getTime();
68
+ }
69
+
70
+ export function isPagingSuppressed(
71
+ alert: Pick<OpenAlert, "status" | "snoozedUntil">,
72
+ now: Date,
73
+ ): boolean {
74
+ return alert.status === "acknowledged" || isActionabilitySuppressed(alert, now);
75
+ }
76
+
77
+ export function isDeliveryBackoff(
78
+ alert: Pick<OpenAlert, "lastNotificationStatus" | "nextNotificationAttemptAt">,
79
+ now: Date,
80
+ ): boolean {
81
+ if (alert.lastNotificationStatus !== "failed" || !alert.nextNotificationAttemptAt) return false;
82
+ const nextAttemptAt = Date.parse(alert.nextNotificationAttemptAt);
83
+ return Number.isFinite(nextAttemptAt) && nextAttemptAt > now.getTime();
84
+ }
85
+
86
+ export function isSeverityEscalation(
87
+ decision: Pick<AlertDecision, "severity">,
88
+ alert: Pick<OpenAlert, "severity">,
89
+ ): boolean {
90
+ const rank = { p0: 0, p1: 1, p2: 2, p3: 3 };
91
+ return rank[decision.severity] < rank[alert.severity];
92
+ }
@@ -0,0 +1,96 @@
1
+ import type { AlertDecision } from "../contracts/index.js";
2
+ import type { AlertSinkPort } from "../contracts/index.js";
3
+
4
+ type WatchdogQueueItem = Pick<AlertDecision, "dedupeKey" | "severity" | "paging">;
5
+ const WATCHDOG_CONCURRENCY = 4;
6
+ const SEVERITY_RANK = { p0: 0, p1: 1, p2: 2, p3: 3 };
7
+
8
+ /**
9
+ * Only each key's first unfinished item is eligible. A head heap prioritizes
10
+ * normalized severity without reordering the caller's decisions or duplicates.
11
+ */
12
+ export async function runWatchdogQueue(
13
+ items: readonly WatchdogQueueItem[],
14
+ process: (index: number) => Promise<void>,
15
+ ): Promise<void> {
16
+ const next = new Array<number>(items.length).fill(-1);
17
+ const lastByKey = new Map<string, number>();
18
+ const ready: number[] = [];
19
+ for (let index = 0; index < items.length; index += 1) {
20
+ const previous = lastByKey.get(items[index].dedupeKey);
21
+ if (previous === undefined) push(index);
22
+ else next[previous] = index;
23
+ lastByKey.set(items[index].dedupeKey, index);
24
+ }
25
+
26
+ let failed = false;
27
+ let firstError: unknown;
28
+ async function worker() {
29
+ while (!failed) {
30
+ const index = pop();
31
+ if (index === undefined) return;
32
+ try {
33
+ await process(index);
34
+ } catch (error) {
35
+ if (!failed) firstError = error;
36
+ failed = true;
37
+ return;
38
+ }
39
+ if (!failed && next[index] !== -1) push(next[index]);
40
+ }
41
+ }
42
+
43
+ // Workers catch their own errors so every admitted chain settles before reject.
44
+ await Promise.all(Array.from({ length: WATCHDOG_CONCURRENCY }, () => worker()));
45
+ if (failed) throw firstError;
46
+
47
+ function compare(left: number, right: number): number {
48
+ const a = items[left];
49
+ const b = items[right];
50
+ return SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity]
51
+ || Number(a.paging === "never") - Number(b.paging === "never")
52
+ || a.dedupeKey.localeCompare(b.dedupeKey)
53
+ || left - right;
54
+ }
55
+
56
+ function push(index: number) {
57
+ let position = ready.length;
58
+ ready.push(index);
59
+ while (position > 0) {
60
+ const parent = Math.floor((position - 1) / 2);
61
+ if (compare(ready[parent], index) <= 0) break;
62
+ ready[position] = ready[parent];
63
+ position = parent;
64
+ }
65
+ ready[position] = index;
66
+ }
67
+
68
+ function pop(): number | undefined {
69
+ if (ready.length === 0) return undefined;
70
+ const first = ready[0];
71
+ const last = ready.pop()!;
72
+ if (ready.length === 0) return first;
73
+ let position = 0;
74
+ while (position * 2 + 1 < ready.length) {
75
+ let child = position * 2 + 1;
76
+ if (child + 1 < ready.length && compare(ready[child + 1], ready[child]) < 0) child += 1;
77
+ if (compare(last, ready[child]) <= 0) break;
78
+ ready[position] = ready[child];
79
+ position = child;
80
+ }
81
+ ready[position] = last;
82
+ return first;
83
+ }
84
+ }
85
+
86
+ /** Per-tick transport limit; admitted waiters still run if an earlier send throws. */
87
+ export function createSerialWatchdogSink<Channel extends string>(sinkPort: AlertSinkPort<Channel>): AlertSinkPort<Channel> {
88
+ let tail = Promise.resolve();
89
+ return {
90
+ send(decision, alert) {
91
+ const attempt = tail.then(() => sinkPort.send(decision, alert));
92
+ tail = attempt.then(() => undefined, () => undefined);
93
+ return attempt;
94
+ },
95
+ };
96
+ }