@uptimizr/collector-server 2.1.0 → 2.2.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 (135) hide show
  1. package/AGENTS.md +179 -6
  2. package/README.md +69 -0
  3. package/dist/__tests__/support/registryRequests.d.ts +2 -1
  4. package/dist/__tests__/support/registryRequests.d.ts.map +1 -1
  5. package/dist/__tests__/support/registryRequests.js +17 -1
  6. package/dist/__tests__/support/registryRequests.js.map +1 -1
  7. package/dist/__tests__/support/subscriptionFixtures.d.ts +63 -0
  8. package/dist/__tests__/support/subscriptionFixtures.d.ts.map +1 -0
  9. package/dist/__tests__/support/subscriptionFixtures.js +17 -0
  10. package/dist/__tests__/support/subscriptionFixtures.js.map +1 -0
  11. package/dist/agentReport.d.ts +256 -0
  12. package/dist/agentReport.d.ts.map +1 -0
  13. package/dist/agentReport.js +861 -0
  14. package/dist/agentReport.js.map +1 -0
  15. package/dist/app.d.ts +13 -0
  16. package/dist/app.d.ts.map +1 -1
  17. package/dist/app.js +112 -5
  18. package/dist/app.js.map +1 -1
  19. package/dist/audit.d.ts +5 -1
  20. package/dist/audit.d.ts.map +1 -1
  21. package/dist/audit.js +24 -4
  22. package/dist/audit.js.map +1 -1
  23. package/dist/auth.d.ts +41 -1
  24. package/dist/auth.d.ts.map +1 -1
  25. package/dist/auth.js +47 -0
  26. package/dist/auth.js.map +1 -1
  27. package/dist/cli.js +99 -2
  28. package/dist/cli.js.map +1 -1
  29. package/dist/clickhouseStore.d.ts.map +1 -1
  30. package/dist/clickhouseStore.js +44 -2
  31. package/dist/clickhouseStore.js.map +1 -1
  32. package/dist/config.d.ts +49 -0
  33. package/dist/config.d.ts.map +1 -1
  34. package/dist/config.js +21 -0
  35. package/dist/config.js.map +1 -1
  36. package/dist/connectionLimiter.d.ts +23 -0
  37. package/dist/connectionLimiter.d.ts.map +1 -0
  38. package/dist/connectionLimiter.js +24 -0
  39. package/dist/connectionLimiter.js.map +1 -0
  40. package/dist/duckdbStore.d.ts.map +1 -1
  41. package/dist/duckdbStore.js +44 -1
  42. package/dist/duckdbStore.js.map +1 -1
  43. package/dist/internalDispatch.d.ts +36 -0
  44. package/dist/internalDispatch.d.ts.map +1 -0
  45. package/dist/internalDispatch.js +50 -0
  46. package/dist/internalDispatch.js.map +1 -0
  47. package/dist/memoryStore.d.ts.map +1 -1
  48. package/dist/memoryStore.js +314 -1
  49. package/dist/memoryStore.js.map +1 -1
  50. package/dist/mssqlStore.d.ts.map +1 -1
  51. package/dist/mssqlStore.js +44 -2
  52. package/dist/mssqlStore.js.map +1 -1
  53. package/dist/postgresStore.d.ts.map +1 -1
  54. package/dist/postgresStore.js +44 -2
  55. package/dist/postgresStore.js.map +1 -1
  56. package/dist/projectMetadata.d.ts +82 -0
  57. package/dist/projectMetadata.d.ts.map +1 -0
  58. package/dist/projectMetadata.js +50 -0
  59. package/dist/projectMetadata.js.map +1 -0
  60. package/dist/routes/context.d.ts +135 -0
  61. package/dist/routes/context.d.ts.map +1 -0
  62. package/dist/routes/context.js +306 -0
  63. package/dist/routes/context.js.map +1 -0
  64. package/dist/routes/insights.d.ts +36 -0
  65. package/dist/routes/insights.d.ts.map +1 -0
  66. package/dist/routes/insights.js +810 -0
  67. package/dist/routes/insights.js.map +1 -0
  68. package/dist/routes/live.d.ts +8 -0
  69. package/dist/routes/live.d.ts.map +1 -1
  70. package/dist/routes/live.js +8 -7
  71. package/dist/routes/live.js.map +1 -1
  72. package/dist/routes/mcp.d.ts +36 -0
  73. package/dist/routes/mcp.d.ts.map +1 -0
  74. package/dist/routes/mcp.js +262 -0
  75. package/dist/routes/mcp.js.map +1 -0
  76. package/dist/routes/meta.d.ts +16 -2
  77. package/dist/routes/meta.d.ts.map +1 -1
  78. package/dist/routes/meta.js +699 -10
  79. package/dist/routes/meta.js.map +1 -1
  80. package/dist/routes/metadata.d.ts +31 -0
  81. package/dist/routes/metadata.d.ts.map +1 -0
  82. package/dist/routes/metadata.js +163 -0
  83. package/dist/routes/metadata.js.map +1 -0
  84. package/dist/routes/narrative.d.ts +36 -0
  85. package/dist/routes/narrative.d.ts.map +1 -0
  86. package/dist/routes/narrative.js +95 -0
  87. package/dist/routes/narrative.js.map +1 -0
  88. package/dist/routes/panels.d.ts +44 -0
  89. package/dist/routes/panels.d.ts.map +1 -0
  90. package/dist/routes/panels.js +108 -0
  91. package/dist/routes/panels.js.map +1 -0
  92. package/dist/routes/query-dsl.d.ts +50 -0
  93. package/dist/routes/query-dsl.d.ts.map +1 -0
  94. package/dist/routes/query-dsl.js +359 -0
  95. package/dist/routes/query-dsl.js.map +1 -0
  96. package/dist/routes/query.d.ts +36 -0
  97. package/dist/routes/query.d.ts.map +1 -1
  98. package/dist/routes/query.js +101 -13
  99. package/dist/routes/query.js.map +1 -1
  100. package/dist/routes/subscriptions.d.ts +52 -0
  101. package/dist/routes/subscriptions.d.ts.map +1 -0
  102. package/dist/routes/subscriptions.js +290 -0
  103. package/dist/routes/subscriptions.js.map +1 -0
  104. package/dist/store.d.ts +113 -2
  105. package/dist/store.d.ts.map +1 -1
  106. package/dist/subscriptions/cli.d.ts +36 -0
  107. package/dist/subscriptions/cli.d.ts.map +1 -0
  108. package/dist/subscriptions/cli.js +143 -0
  109. package/dist/subscriptions/cli.js.map +1 -0
  110. package/dist/subscriptions/delivery.d.ts +101 -0
  111. package/dist/subscriptions/delivery.d.ts.map +1 -0
  112. package/dist/subscriptions/delivery.js +165 -0
  113. package/dist/subscriptions/delivery.js.map +1 -0
  114. package/dist/subscriptions/evaluate.d.ts +119 -0
  115. package/dist/subscriptions/evaluate.d.ts.map +1 -0
  116. package/dist/subscriptions/evaluate.js +326 -0
  117. package/dist/subscriptions/evaluate.js.map +1 -0
  118. package/dist/subscriptions/scheduler.d.ts +97 -0
  119. package/dist/subscriptions/scheduler.d.ts.map +1 -0
  120. package/dist/subscriptions/scheduler.js +332 -0
  121. package/dist/subscriptions/scheduler.js.map +1 -0
  122. package/dist/subscriptions/stream.d.ts +40 -0
  123. package/dist/subscriptions/stream.d.ts.map +1 -0
  124. package/dist/subscriptions/stream.js +38 -0
  125. package/dist/subscriptions/stream.js.map +1 -0
  126. package/dist/version.d.ts +3 -0
  127. package/dist/version.d.ts.map +1 -0
  128. package/dist/version.js +25 -0
  129. package/dist/version.js.map +1 -0
  130. package/dist/webhookSignature.d.ts +64 -0
  131. package/dist/webhookSignature.d.ts.map +1 -0
  132. package/dist/webhookSignature.js +91 -0
  133. package/dist/webhookSignature.js.map +1 -0
  134. package/llms.txt +38 -1
  135. package/package.json +17 -14
@@ -0,0 +1,143 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { subscriptionSchema } from "@uptimizr/schema";
4
+ import { getMetric } from "@uptimizr/metrics";
5
+ import { createStore } from "../serve.js";
6
+ import { evaluateSubscription } from "./evaluate.js";
7
+ /**
8
+ * `uptimizr subscriptions …` — the operator's offline half of the subscriptions
9
+ * API (#311, ADR 0051 §6).
10
+ *
11
+ * The offline sibling of `/api/v1/subscriptions`, and an **operator** command in
12
+ * the same sense as `new-key` and `regions set`: it takes no API key and opens
13
+ * the store directly, because store access is already strictly more than any
14
+ * capability grants. It is the path a self-hoster uses to put a subscription
15
+ * under version control — `sub.json` in a repo, applied by a deploy step —
16
+ * rather than clicking one together.
17
+ *
18
+ * `test` deliberately **never delivers**. A dry run from a shell should not be
19
+ * able to page anyone; proving a webhook receiver works is
20
+ * `POST /api/v1/subscriptions/:id/test?deliver=true`, which needs an `annotate`
21
+ * key and is auditable.
22
+ *
23
+ * Human progress goes to stderr, machine-readable JSON to stdout — the same
24
+ * split the rest of `cli.ts` uses, so `uptimizr subscriptions list | jq` works.
25
+ */
26
+ /** Read and validate a declaration file (a bare object or a `{ subscription }` envelope). */
27
+ export function readSubscriptionFile(file) {
28
+ const raw = readFileSync(resolve(file), "utf8");
29
+ let parsed;
30
+ try {
31
+ parsed = JSON.parse(raw);
32
+ }
33
+ catch (err) {
34
+ throw new Error(`${file} is not valid JSON: ${err instanceof Error ? err.message : err}`, {
35
+ cause: err,
36
+ });
37
+ }
38
+ const candidate = parsed != null && typeof parsed === "object" && "subscription" in parsed
39
+ ? parsed.subscription
40
+ : parsed;
41
+ const result = subscriptionSchema.safeParse(candidate);
42
+ if (!result.success) {
43
+ const issues = result.error.issues
44
+ .map((issue) => ` - ${issue.path.join(".") || "(root)"}: ${issue.message}`)
45
+ .join("\n");
46
+ throw new Error(`${file} is not a valid subscription:\n${issues}`);
47
+ }
48
+ return result.data;
49
+ }
50
+ /** One line per subscription, for `list`'s stderr summary. */
51
+ function summarize(sub) {
52
+ const state = sub.enabled ? "enabled" : "disabled";
53
+ const last = sub.lastFiredAt ? sub.lastFiredAt.toISOString() : "never";
54
+ const error = sub.lastError ? ` · last error: ${sub.lastError}` : "";
55
+ return `${sub.id} ${sub.name} [${sub.predicate.kind} on ${sub.metric}] ${state} · last fired ${last}${error}`;
56
+ }
57
+ async function withStore(fn) {
58
+ const store = await createStore();
59
+ try {
60
+ return await fn(store);
61
+ }
62
+ finally {
63
+ await store.close();
64
+ }
65
+ }
66
+ /** `uptimizr subscriptions list` — every subscription of `projectId`. */
67
+ export async function cmdSubscriptionsList(projectId) {
68
+ const rows = await withStore((store) => store.listSubscriptions(projectId));
69
+ for (const sub of rows)
70
+ console.error(summarize(sub));
71
+ if (rows.length === 0)
72
+ console.error(`No subscriptions on project ${projectId}.`);
73
+ process.stdout.write(`${JSON.stringify(rows, null, 2)}\n`);
74
+ }
75
+ /** `uptimizr subscriptions add --file sub.json` — store a new declaration. */
76
+ export async function cmdSubscriptionsAdd(projectId, file) {
77
+ const declaration = readSubscriptionFile(file);
78
+ // The registry check the route does, done here too: an operator must not be
79
+ // able to create offline a subscription the scheduler will only reject later.
80
+ if (getMetric(declaration.metric) == null) {
81
+ throw new Error(`Unknown metric ${JSON.stringify(declaration.metric)}.`);
82
+ }
83
+ const created = await withStore((store) => store.createSubscription(projectId, declaration));
84
+ console.error(`✓ subscription ${created.id} created on project ${projectId}`);
85
+ if (created.delivery.some((d) => d.kind === "webhook")) {
86
+ console.error(" A webhook is configured. The collector only POSTs to hosts named in " +
87
+ "COLLECTOR_WEBHOOK_ALLOWED_HOSTS; until one is set, firings are recorded and " +
88
+ "fanned out over SSE but nothing leaves the process.");
89
+ }
90
+ process.stdout.write(`${JSON.stringify(created, null, 2)}\n`);
91
+ }
92
+ /** `uptimizr subscriptions remove <id>` — delete a subscription and its firings. */
93
+ export async function cmdSubscriptionsRemove(projectId, id) {
94
+ const deleted = await withStore((store) => store.deleteSubscription(projectId, id));
95
+ if (!deleted)
96
+ throw new Error(`No subscription ${JSON.stringify(id)} on project ${projectId}.`);
97
+ console.error(`✓ subscription ${id} removed`);
98
+ }
99
+ /**
100
+ * `uptimizr subscriptions test <id>` — evaluate once, now, delivering nothing.
101
+ *
102
+ * Answers with the evaluation, including why it did not fire, which is what
103
+ * makes a subscription tunable rather than mysterious.
104
+ */
105
+ export async function cmdSubscriptionsTest(projectId, id) {
106
+ const result = await withStore(async (store) => {
107
+ const sub = await store.getSubscription(projectId, id);
108
+ if (sub == null) {
109
+ throw new Error(`No subscription ${JSON.stringify(id)} on project ${projectId}.`);
110
+ }
111
+ const metric = getMetric(sub.metric);
112
+ if (metric == null) {
113
+ throw new Error(`Subscription ${id} names unknown metric ${JSON.stringify(sub.metric)}.`);
114
+ }
115
+ const evaluation = await evaluateSubscription({
116
+ readBuckets: (opts) => store.metricBuckets(projectId, opts),
117
+ // No live bus in a one-shot CLI process, so a `presence` predicate here
118
+ // reads zero live sessions. Said plainly rather than left to be guessed.
119
+ activeSessions: () => 0,
120
+ }, sub, metric);
121
+ return { sub, evaluation };
122
+ });
123
+ console.error(result.evaluation.fired
124
+ ? `✓ would fire: ${result.evaluation.reason}`
125
+ : `· would not fire: ${result.evaluation.reason}`);
126
+ if (result.sub.predicate.kind === "presence") {
127
+ console.error(" Note: `presence` is evaluated from the running collector's live bus; a CLI process " +
128
+ "has none, so this reports zero live sessions.");
129
+ }
130
+ process.stdout.write(`${JSON.stringify({
131
+ subscriptionId: result.sub.id,
132
+ fired: result.evaluation.fired,
133
+ reason: result.evaluation.reason,
134
+ window: result.evaluation.window,
135
+ value: result.evaluation.value,
136
+ expected: result.evaluation.expected,
137
+ sampleSize: result.evaluation.sampleSize,
138
+ dimensionValue: result.evaluation.dimensionValue,
139
+ bucket: result.evaluation.bucket,
140
+ delivered: false,
141
+ }, null, 2)}\n`);
142
+ }
143
+ //# sourceMappingURL=cli.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli.js","sourceRoot":"","sources":["../../src/subscriptions/cli.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,101 @@
1
+ import type { SubscriptionDelivery, SubscriptionFiring } from "@uptimizr/schema";
2
+ import { type SubscriptionRecord } from "@uptimizr/db";
3
+ /**
4
+ * **Delivering a subscription firing** (#311, ADR 0051 §6 / sketch §F.3).
5
+ *
6
+ * Two targets, and they are deliberately asymmetric:
7
+ *
8
+ * - **SSE** is a fan-out to whoever is already connected. It costs nothing, it
9
+ * leaves the process only over a connection the operator's own key opened, and
10
+ * it is the default.
11
+ * - **A webhook is egress.** Nothing here makes an outbound request unless a
12
+ * subscription carries a `webhook` delivery with a URL — ADR 0051 §6, "no
13
+ * outbound egress happens unless a self-hoster configures a webhook URL", is
14
+ * enforced by {@link deliver} having nothing to do in that case.
15
+ *
16
+ * ## The SSRF boundary
17
+ *
18
+ * `parseWebhookUrl` (shared with `uptimizr agent report`) rejects anything that
19
+ * is not `http(s)`, and its own doc is explicit that a surface which accepts a
20
+ * URL *over HTTP* must add an allow-list on top — which is exactly this surface,
21
+ * because a subscription is created through the API by an `annotate` key rather
22
+ * than typed into the operator's shell. {@link checkWebhookUrl} is that
23
+ * allow-list: `COLLECTOR_WEBHOOK_ALLOWED_HOSTS`, empty by default, which means
24
+ * **no webhook delivery leaves the process at all until an operator names the
25
+ * hosts it may reach**. A key holder can therefore not turn the collector into a
26
+ * probe for the network it sits in.
27
+ *
28
+ * ## Retries
29
+ *
30
+ * Three attempts, exponential backoff, only for failures that can plausibly
31
+ * succeed on a retry (a network error, a 408/429, any 5xx). A 4xx other than
32
+ * those is the receiver saying "not like that", and repeating it just triples
33
+ * the noise. Every attempt carries the same {@link WEBHOOK_DELIVERY_HEADER} id,
34
+ * so a receiver can dedupe.
35
+ */
36
+ /** Attempts per firing, including the first. */
37
+ export declare const WEBHOOK_MAX_ATTEMPTS = 3;
38
+ /** Backoff before the 2nd attempt; doubled for each further one. */
39
+ export declare const WEBHOOK_BASE_BACKOFF_MS = 500;
40
+ /** Per-attempt timeout. A webhook receiver that is slow is a webhook that fails. */
41
+ export declare const WEBHOOK_TIMEOUT_MS = 10000;
42
+ /** The body a webhook receives. */
43
+ export interface WebhookPayload {
44
+ /** Schema marker, so a receiver can branch on shape without guessing. */
45
+ type: "subscription.firing";
46
+ firing: SubscriptionFiring;
47
+ /** Bounded `format=summary` of the metric over the window that fired. */
48
+ summary: Record<string, unknown> | null;
49
+ }
50
+ /** Outcome of delivering one firing. */
51
+ export interface DeliveryOutcome {
52
+ /** Whether every configured target accepted it (SSE always does). */
53
+ ok: boolean;
54
+ /** Bounded, redacted failure text when a webhook failed, else `null`. */
55
+ error: string | null;
56
+ /** How many webhook attempts were made (0 when no webhook is configured). */
57
+ attempts: number;
58
+ /** True when a webhook request actually left the process. */
59
+ egress: boolean;
60
+ }
61
+ /** What {@link deliver} needs from its host. */
62
+ export interface DeliveryDeps {
63
+ /** Push the firing to connected SSE listeners. */
64
+ broadcast: (firing: SubscriptionFiring, summary: Record<string, unknown> | null) => void;
65
+ /** The subscription's webhook secret, or `null`. */
66
+ secret: string | null;
67
+ /** Hosts a webhook may target; empty forbids all webhook egress. */
68
+ allowedHosts: readonly string[];
69
+ /** Injected for tests. Defaults to the global `fetch`. */
70
+ fetchImpl?: typeof fetch;
71
+ /** Injected for tests. Defaults to a real timer. */
72
+ sleep?: (ms: number) => Promise<void>;
73
+ }
74
+ /**
75
+ * Validate a webhook URL against the operator's allow-list.
76
+ *
77
+ * Returns the parsed URL, or a refusal string naming why. Matching is on the
78
+ * **hostname** (case-insensitive, port-independent), because a port is not a
79
+ * trust boundary: an operator who allows `hooks.slack.com` means the host, and
80
+ * an operator who allows `localhost` means their own box. `*` allows every host
81
+ * and is an explicit, documented opt-out for a collector on a closed network.
82
+ */
83
+ export declare function checkWebhookUrl(raw: string, allowedHosts: readonly string[]): {
84
+ url: URL;
85
+ } | {
86
+ refused: string;
87
+ };
88
+ /** The one webhook target of a subscription, or `null`. */
89
+ export declare function webhookTargetOf(sub: SubscriptionRecord): Extract<SubscriptionDelivery, {
90
+ kind: "webhook";
91
+ }> | null;
92
+ /** Whether a subscription asks for SSE fan-out. */
93
+ export declare function wantsSse(sub: SubscriptionRecord): boolean;
94
+ /**
95
+ * Deliver one firing to every target the subscription declares.
96
+ *
97
+ * SSE first and unconditionally — it is in-process, so a broken webhook must
98
+ * never cost a connected agent its event.
99
+ */
100
+ export declare function deliver(sub: SubscriptionRecord, firing: SubscriptionFiring, summary: Record<string, unknown> | null, deps: DeliveryDeps): Promise<DeliveryOutcome>;
101
+ //# sourceMappingURL=delivery.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"delivery.d.ts","sourceRoot":"","sources":["../../src/subscriptions/delivery.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,oBAAoB,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACjF,OAAO,EAA0B,KAAK,kBAAkB,EAAE,MAAM,cAAc,CAAC;AAS/E;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AAEH,gDAAgD;AAChD,eAAO,MAAM,oBAAoB,IAAI,CAAC;AACtC,oEAAoE;AACpE,eAAO,MAAM,uBAAuB,MAAM,CAAC;AAC3C,oFAAoF;AACpF,eAAO,MAAM,kBAAkB,QAAS,CAAC;AAEzC,mCAAmC;AACnC,MAAM,WAAW,cAAc;IAC7B,yEAAyE;IACzE,IAAI,EAAE,qBAAqB,CAAC;IAC5B,MAAM,EAAE,kBAAkB,CAAC;IAC3B,yEAAyE;IACzE,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;CACzC;AAED,wCAAwC;AACxC,MAAM,WAAW,eAAe;IAC9B,qEAAqE;IACrE,EAAE,EAAE,OAAO,CAAC;IACZ,yEAAyE;IACzE,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,6EAA6E;IAC7E,QAAQ,EAAE,MAAM,CAAC;IACjB,6DAA6D;IAC7D,MAAM,EAAE,OAAO,CAAC;CACjB;AAED,gDAAgD;AAChD,MAAM,WAAW,YAAY;IAC3B,kDAAkD;IAClD,SAAS,EAAE,CAAC,MAAM,EAAE,kBAAkB,EAAE,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,KAAK,IAAI,CAAC;IACzF,oDAAoD;IACpD,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,oEAAoE;IACpE,YAAY,EAAE,SAAS,MAAM,EAAE,CAAC;IAChC,0DAA0D;IAC1D,SAAS,CAAC,EAAE,OAAO,KAAK,CAAC;IACzB,oDAAoD;IACpD,KAAK,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CACvC;AAQD;;;;;;;;GAQG;AACH,wBAAgB,eAAe,CAC7B,GAAG,EAAE,MAAM,EACX,YAAY,EAAE,SAAS,MAAM,EAAE,GAC9B;IAAE,GAAG,EAAE,GAAG,CAAA;CAAE,GAAG;IAAE,OAAO,EAAE,MAAM,CAAA;CAAE,CAsBpC;AAED,2DAA2D;AAC3D,wBAAgB,eAAe,CAC7B,GAAG,EAAE,kBAAkB,GACtB,OAAO,CAAC,oBAAoB,EAAE;IAAE,IAAI,EAAE,SAAS,CAAA;CAAE,CAAC,GAAG,IAAI,CAG3D;AAED,mDAAmD;AACnD,wBAAgB,QAAQ,CAAC,GAAG,EAAE,kBAAkB,GAAG,OAAO,CAEzD;AA0DD;;;;;GAKG;AACH,wBAAsB,OAAO,CAC3B,GAAG,EAAE,kBAAkB,EACvB,MAAM,EAAE,kBAAkB,EAC1B,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,EACvC,IAAI,EAAE,YAAY,GACjB,OAAO,CAAC,eAAe,CAAC,CAuB1B"}
@@ -0,0 +1,165 @@
1
+ import { clampSubscriptionError } from "@uptimizr/db";
2
+ import { WEBHOOK_DELIVERY_HEADER, WEBHOOK_SIGNATURE_HEADER, newDeliveryId, parseWebhookUrl, signWebhookBody, } from "../webhookSignature.js";
3
+ /**
4
+ * **Delivering a subscription firing** (#311, ADR 0051 §6 / sketch §F.3).
5
+ *
6
+ * Two targets, and they are deliberately asymmetric:
7
+ *
8
+ * - **SSE** is a fan-out to whoever is already connected. It costs nothing, it
9
+ * leaves the process only over a connection the operator's own key opened, and
10
+ * it is the default.
11
+ * - **A webhook is egress.** Nothing here makes an outbound request unless a
12
+ * subscription carries a `webhook` delivery with a URL — ADR 0051 §6, "no
13
+ * outbound egress happens unless a self-hoster configures a webhook URL", is
14
+ * enforced by {@link deliver} having nothing to do in that case.
15
+ *
16
+ * ## The SSRF boundary
17
+ *
18
+ * `parseWebhookUrl` (shared with `uptimizr agent report`) rejects anything that
19
+ * is not `http(s)`, and its own doc is explicit that a surface which accepts a
20
+ * URL *over HTTP* must add an allow-list on top — which is exactly this surface,
21
+ * because a subscription is created through the API by an `annotate` key rather
22
+ * than typed into the operator's shell. {@link checkWebhookUrl} is that
23
+ * allow-list: `COLLECTOR_WEBHOOK_ALLOWED_HOSTS`, empty by default, which means
24
+ * **no webhook delivery leaves the process at all until an operator names the
25
+ * hosts it may reach**. A key holder can therefore not turn the collector into a
26
+ * probe for the network it sits in.
27
+ *
28
+ * ## Retries
29
+ *
30
+ * Three attempts, exponential backoff, only for failures that can plausibly
31
+ * succeed on a retry (a network error, a 408/429, any 5xx). A 4xx other than
32
+ * those is the receiver saying "not like that", and repeating it just triples
33
+ * the noise. Every attempt carries the same {@link WEBHOOK_DELIVERY_HEADER} id,
34
+ * so a receiver can dedupe.
35
+ */
36
+ /** Attempts per firing, including the first. */
37
+ export const WEBHOOK_MAX_ATTEMPTS = 3;
38
+ /** Backoff before the 2nd attempt; doubled for each further one. */
39
+ export const WEBHOOK_BASE_BACKOFF_MS = 500;
40
+ /** Per-attempt timeout. A webhook receiver that is slow is a webhook that fails. */
41
+ export const WEBHOOK_TIMEOUT_MS = 10_000;
42
+ const defaultSleep = (ms) => new Promise((resolve) => {
43
+ const timer = setTimeout(resolve, ms);
44
+ timer.unref?.();
45
+ });
46
+ /**
47
+ * Validate a webhook URL against the operator's allow-list.
48
+ *
49
+ * Returns the parsed URL, or a refusal string naming why. Matching is on the
50
+ * **hostname** (case-insensitive, port-independent), because a port is not a
51
+ * trust boundary: an operator who allows `hooks.slack.com` means the host, and
52
+ * an operator who allows `localhost` means their own box. `*` allows every host
53
+ * and is an explicit, documented opt-out for a collector on a closed network.
54
+ */
55
+ export function checkWebhookUrl(raw, allowedHosts) {
56
+ let url;
57
+ try {
58
+ url = parseWebhookUrl(raw);
59
+ }
60
+ catch (err) {
61
+ return { refused: err instanceof Error ? err.message : "invalid webhook url" };
62
+ }
63
+ if (allowedHosts.length === 0) {
64
+ return {
65
+ refused: "webhook delivery is disabled: set COLLECTOR_WEBHOOK_ALLOWED_HOSTS to the hosts this " +
66
+ "collector may POST to",
67
+ };
68
+ }
69
+ const host = url.hostname.toLowerCase();
70
+ const allowed = allowedHosts.some((entry) => {
71
+ const candidate = entry.trim().toLowerCase();
72
+ return candidate === "*" || candidate === host;
73
+ });
74
+ return allowed
75
+ ? { url }
76
+ : { refused: `webhook host ${JSON.stringify(host)} is not allow-listed` };
77
+ }
78
+ /** The one webhook target of a subscription, or `null`. */
79
+ export function webhookTargetOf(sub) {
80
+ for (const target of sub.delivery)
81
+ if (target.kind === "webhook")
82
+ return target;
83
+ return null;
84
+ }
85
+ /** Whether a subscription asks for SSE fan-out. */
86
+ export function wantsSse(sub) {
87
+ return sub.delivery.some((target) => target.kind === "sse");
88
+ }
89
+ /** Whether a response/error is worth another attempt. */
90
+ function retryable(status) {
91
+ if (status == null)
92
+ return true; // network-level failure
93
+ return status === 408 || status === 429 || status >= 500;
94
+ }
95
+ /**
96
+ * POST one firing to `url`, signed, with bounded retries.
97
+ *
98
+ * The body is serialised **once** and the signature is taken over those exact
99
+ * bytes, because re-serialising between signing and sending is the classic way
100
+ * to ship a signature the receiver cannot reproduce.
101
+ */
102
+ async function postWebhook(url, payload, secret, deps) {
103
+ const doFetch = deps.fetchImpl ?? fetch;
104
+ const sleep = deps.sleep ?? defaultSleep;
105
+ const body = JSON.stringify(payload);
106
+ const deliveryId = newDeliveryId();
107
+ const headers = {
108
+ "content-type": "application/json",
109
+ "user-agent": "uptimizr-collector",
110
+ [WEBHOOK_DELIVERY_HEADER]: deliveryId,
111
+ };
112
+ if (secret != null)
113
+ headers[WEBHOOK_SIGNATURE_HEADER] = signWebhookBody(secret, body);
114
+ let lastError = "webhook delivery failed";
115
+ for (let attempt = 1; attempt <= WEBHOOK_MAX_ATTEMPTS; attempt += 1) {
116
+ let status = null;
117
+ try {
118
+ const res = await doFetch(url, {
119
+ method: "POST",
120
+ headers,
121
+ body,
122
+ signal: AbortSignal.timeout(WEBHOOK_TIMEOUT_MS),
123
+ redirect: "error",
124
+ });
125
+ status = res.status;
126
+ if (res.ok)
127
+ return { ok: true, error: null, attempts: attempt };
128
+ lastError = `webhook responded ${res.status}`;
129
+ }
130
+ catch (err) {
131
+ lastError = err instanceof Error ? err.message : String(err);
132
+ }
133
+ if (!retryable(status) || attempt === WEBHOOK_MAX_ATTEMPTS) {
134
+ return { ok: false, error: clampSubscriptionError(lastError), attempts: attempt };
135
+ }
136
+ await sleep(WEBHOOK_BASE_BACKOFF_MS * 2 ** (attempt - 1));
137
+ }
138
+ /* c8 ignore next */
139
+ return { ok: false, error: clampSubscriptionError(lastError), attempts: WEBHOOK_MAX_ATTEMPTS };
140
+ }
141
+ /**
142
+ * Deliver one firing to every target the subscription declares.
143
+ *
144
+ * SSE first and unconditionally — it is in-process, so a broken webhook must
145
+ * never cost a connected agent its event.
146
+ */
147
+ export async function deliver(sub, firing, summary, deps) {
148
+ if (wantsSse(sub))
149
+ deps.broadcast(firing, summary);
150
+ const target = webhookTargetOf(sub);
151
+ if (target == null)
152
+ return { ok: true, error: null, attempts: 0, egress: false };
153
+ const checked = checkWebhookUrl(target.url, deps.allowedHosts);
154
+ if ("refused" in checked) {
155
+ return {
156
+ ok: false,
157
+ error: clampSubscriptionError(checked.refused),
158
+ attempts: 0,
159
+ egress: false,
160
+ };
161
+ }
162
+ const result = await postWebhook(checked.url, { type: "subscription.firing", firing, summary }, deps.secret, deps);
163
+ return { ...result, egress: true };
164
+ }
165
+ //# sourceMappingURL=delivery.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"delivery.js","sourceRoot":"","sources":["../../src/subscriptions/delivery.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,119 @@
1
+ import { type BucketGrain, type MetricBucketOptions, type MetricBucketRow, type SubscriptionRecord } from "@uptimizr/db";
2
+ import { type ComparisonOp, type SubscriptionFiring } from "@uptimizr/schema";
3
+ import type { MetricDefinition } from "@uptimizr/metrics";
4
+ /**
5
+ * **Subscription predicate evaluation** (#311, ADR 0051 §6 / sketch §F.2).
6
+ *
7
+ * Everything that decides whether a standing question is answered "yes" right
8
+ * now lives here, and it is a pure function of *rows* — the only I/O is the one
9
+ * injected {@link BucketReader}, which is the same
10
+ * `store.metricBuckets(projectId, …)` the insight routes call. That keeps the
11
+ * whole predicate vocabulary unit-testable without a database, a timer or a
12
+ * server, and keeps the four stores interchangeable: the series a predicate sees
13
+ * is the parity-tested portable bucket series, not per-dialect SQL of its own.
14
+ *
15
+ * ## Why the window is not snapped the way the insight routes snap theirs
16
+ *
17
+ * `baseline`, `movers` and `anomalies` floor `until` to the last **complete**
18
+ * bucket, because they compare periods and a partial period reads as a collapse
19
+ * every morning. A subscription is the opposite question — *is something wrong
20
+ * right now* — so its window ends at the instant of evaluation and its final
21
+ * bucket is deliberately partial. `since` is still floored, so the series the
22
+ * store returns is bucket-aligned and every row is attributed exactly once.
23
+ *
24
+ * ## Why `threshold.column` must be the metric's headline column
25
+ *
26
+ * The portable series reproduces exactly one column per metric — the registry's
27
+ * `comparable.primary` (`perf_summary` → `p50_fps`). Comparing any other column
28
+ * would mean running the metric's own endpoint handler, which is per-metric code
29
+ * the scheduler would have to re-enter, and would drag five dialects' quantile
30
+ * semantics into an alert threshold. Asking for another column is a `400` that
31
+ * *names* the right one — see {@link thresholdColumnFor}.
32
+ */
33
+ /** The one store read a predicate may issue, injected so tests need no store. */
34
+ export type BucketReader = (opts: MetricBucketOptions) => Promise<MetricBucketRow[]>;
35
+ /** Live presence, injected the same way (the `presence` predicate's only input). */
36
+ export type PresenceReader = () => number;
37
+ /** What an evaluation needs besides the subscription itself. */
38
+ export interface EvaluateDeps {
39
+ readBuckets: BucketReader;
40
+ /** Concurrent live sessions; only read by the `presence` predicate. */
41
+ activeSessions?: PresenceReader;
42
+ /** Injected clock. Defaults to `Date.now`. */
43
+ now?: () => number;
44
+ }
45
+ /** The half-open window an evaluation measured. */
46
+ export interface EvaluationWindow {
47
+ since: number;
48
+ until: number;
49
+ }
50
+ /**
51
+ * The outcome of one evaluation.
52
+ *
53
+ * A non-firing evaluation still carries its numbers and a `reason`: that is what
54
+ * `POST /api/v1/subscriptions/:id/test` answers with, and "it did not fire
55
+ * because the window held 12 samples and `minSample` is 20" is the difference
56
+ * between a subscription an operator can tune and one they delete.
57
+ */
58
+ export interface EvaluationResult {
59
+ fired: boolean;
60
+ /** Human-readable account of the outcome, firing or not. */
61
+ reason: string;
62
+ window: EvaluationWindow;
63
+ /** The observed value the predicate judged, when there is a single one. */
64
+ value: number | null;
65
+ /** What it was judged against — a level, an expectation, a previous window. */
66
+ expected: number | null;
67
+ /** The window's denominator: events, or distinct sessions. */
68
+ sampleSize: number;
69
+ /** The dimension value behind the outcome (`new_value`, attributed anomaly). */
70
+ dimensionValue: string | null;
71
+ /** The bucket series the evaluation read, for the delivery summary. */
72
+ series: readonly MetricBucketRow[];
73
+ /** Grain the series was read at. */
74
+ bucket: BucketGrain;
75
+ }
76
+ /** Resolve the series grain: the declared one, else hourly for short windows. */
77
+ export declare function bucketFor(sub: SubscriptionRecord): BucketGrain;
78
+ /**
79
+ * The window one evaluation measures: `[floor(now − window), now)`.
80
+ *
81
+ * `since` is floored so the series is bucket-aligned; `until` is not, so the
82
+ * partial bucket containing *this minute* is included. See the module note.
83
+ */
84
+ export declare function resolveWindow(windowMs: number, bucket: BucketGrain, now: number): EvaluationWindow;
85
+ /** Compare two numbers with a subscription's operator. */
86
+ export declare function compare(left: number, op: ComparisonOp, right: number): boolean;
87
+ /**
88
+ * The column a `threshold` predicate on `metric` may name — its registry
89
+ * `comparable.primary`, or `null` when the metric declares none.
90
+ */
91
+ export declare function thresholdColumnFor(metric: MetricDefinition): string | null;
92
+ /** Whether a metric can back a store-evaluated subscription at all. */
93
+ export declare function isSubscribableMetric(metricId: string): boolean;
94
+ /**
95
+ * Evaluate one subscription now.
96
+ *
97
+ * Every predicate kind but `presence` issues **one** bucket read; `movers` and
98
+ * `new_value` cover both their windows with a single spanning scan, the same
99
+ * trick `movers` itself uses, so both windows are guaranteed to have seen one
100
+ * snapshot of the data.
101
+ */
102
+ export declare function evaluateSubscription(deps: EvaluateDeps, sub: SubscriptionRecord, metric: MetricDefinition): Promise<EvaluationResult>;
103
+ /** Shape an evaluation into the firing record stored and delivered. */
104
+ export declare function toFiring(sub: SubscriptionRecord, result: EvaluationResult, at: number): SubscriptionFiring;
105
+ /**
106
+ * The bounded `format=summary` block a webhook carries alongside the firing
107
+ * (sketch §F.3: "so the receiver can act without a second call").
108
+ *
109
+ * It is the registry's own summary of `insight_baseline` over exactly the window
110
+ * that fired — a real registry metric with real declared units and caveats,
111
+ * rather than a hand-rolled bag of numbers. That is deliberate: the receiver
112
+ * reads the same envelope it would get from `GET /api/v1/insights/baseline`, so
113
+ * anything that can already render one can render this.
114
+ *
115
+ * `null` when the predicate read no series (`presence`) or the summariser
116
+ * declines the rows.
117
+ */
118
+ export declare function summaryFor(sub: SubscriptionRecord, result: EvaluationResult): Record<string, unknown> | null;
119
+ //# sourceMappingURL=evaluate.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"evaluate.d.ts","sourceRoot":"","sources":["../../src/subscriptions/evaluate.ts"],"names":[],"mappings":"AAAA,OAAO,EAaL,KAAK,WAAW,EAChB,KAAK,mBAAmB,EACxB,KAAK,eAAe,EACpB,KAAK,kBAAkB,EACxB,MAAM,cAAc,CAAC;AACtB,OAAO,EAAmB,KAAK,YAAY,EAAE,KAAK,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AAC/F,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AAE1D;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAEH,iFAAiF;AACjF,MAAM,MAAM,YAAY,GAAG,CAAC,IAAI,EAAE,mBAAmB,KAAK,OAAO,CAAC,eAAe,EAAE,CAAC,CAAC;AAErF,oFAAoF;AACpF,MAAM,MAAM,cAAc,GAAG,MAAM,MAAM,CAAC;AAE1C,gEAAgE;AAChE,MAAM,WAAW,YAAY;IAC3B,WAAW,EAAE,YAAY,CAAC;IAC1B,uEAAuE;IACvE,cAAc,CAAC,EAAE,cAAc,CAAC;IAChC,8CAA8C;IAC9C,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;CACpB;AAED,mDAAmD;AACnD,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;CACf;AAED;;;;;;;GAOG;AACH,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,OAAO,CAAC;IACf,4DAA4D;IAC5D,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,gBAAgB,CAAC;IACzB,2EAA2E;IAC3E,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,+EAA+E;IAC/E,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,8DAA8D;IAC9D,UAAU,EAAE,MAAM,CAAC;IACnB,gFAAgF;IAChF,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,uEAAuE;IACvE,MAAM,EAAE,SAAS,eAAe,EAAE,CAAC;IACnC,oCAAoC;IACpC,MAAM,EAAE,WAAW,CAAC;CACrB;AAKD,iFAAiF;AACjF,wBAAgB,SAAS,CAAC,GAAG,EAAE,kBAAkB,GAAG,WAAW,CAI9D;AAED;;;;;GAKG;AACH,wBAAgB,aAAa,CAC3B,QAAQ,EAAE,MAAM,EAChB,MAAM,EAAE,WAAW,EACnB,GAAG,EAAE,MAAM,GACV,gBAAgB,CAIlB;AAED,0DAA0D;AAC1D,wBAAgB,OAAO,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,YAAY,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAe9E;AAmBD;;;GAGG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,gBAAgB,GAAG,MAAM,GAAG,IAAI,CAE1E;AAED,uEAAuE;AACvE,wBAAgB,oBAAoB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAE9D;AAgCD;;;;;;;GAOG;AACH,wBAAsB,oBAAoB,CACxC,IAAI,EAAE,YAAY,EAClB,GAAG,EAAE,kBAAkB,EACvB,MAAM,EAAE,gBAAgB,GACvB,OAAO,CAAC,gBAAgB,CAAC,CAkN3B;AAED,uEAAuE;AACvE,wBAAgB,QAAQ,CACtB,GAAG,EAAE,kBAAkB,EACvB,MAAM,EAAE,gBAAgB,EACxB,EAAE,EAAE,MAAM,GACT,kBAAkB,CAepB;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,UAAU,CACxB,GAAG,EAAE,kBAAkB,EACvB,MAAM,EAAE,gBAAgB,GACvB,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAiBhC"}