@zeyolabs/kit 0.0.0-stage → 2026.11.6

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 (95) hide show
  1. package/README.md +102 -2
  2. package/auth/accounts/accounts.remote.ts +116 -0
  3. package/auth/accounts/accounts.ts +332 -0
  4. package/auth/accounts/handle.ts +102 -0
  5. package/auth/accounts/invites.ts +149 -0
  6. package/auth/accounts/permissions.ts +90 -0
  7. package/auth/accounts/schemas.ts +22 -0
  8. package/auth/config.ts +77 -0
  9. package/auth/emails.ts +57 -0
  10. package/auth/schema.ts +152 -0
  11. package/auth/sessions/handle.ts +124 -0
  12. package/auth/sessions/magic-link/magic-link.ts +71 -0
  13. package/auth/sessions/passkeys/client.ts +115 -0
  14. package/auth/sessions/passkeys/passkeys.ts +295 -0
  15. package/auth/sessions/sessions.ts +101 -0
  16. package/auth/sveltekit/loads.ts +59 -0
  17. package/auth/sveltekit/login.ts +28 -0
  18. package/auth/sveltekit/routes.ts +264 -0
  19. package/auth/users.ts +49 -0
  20. package/cloudflare/access.ts +134 -0
  21. package/cloudflare/amqp/client.ts +276 -0
  22. package/cloudflare/amqp/testing.ts +9 -0
  23. package/cloudflare/context.ts +66 -0
  24. package/cloudflare/dispatch/client.ts +30 -0
  25. package/cloudflare/email/base.ts +59 -0
  26. package/cloudflare/email/client.ts +51 -0
  27. package/cloudflare/email/dev.ts +14 -0
  28. package/cloudflare/email/smtp.ts +65 -0
  29. package/cloudflare/email/testing.ts +18 -0
  30. package/cloudflare/r2/client.ts +154 -0
  31. package/cloudflare/r2/testing.ts +43 -0
  32. package/cloudflare/rate-limiter.ts +65 -0
  33. package/cloudflare/wait-until.ts +12 -0
  34. package/cloudflare/workers/index.ts +311 -0
  35. package/cloudflare/workers/testing.ts +94 -0
  36. package/cloudflare/workers/ticking-do.ts +104 -0
  37. package/cloudflare/workers-ai/client.ts +30 -0
  38. package/cloudflare/workers-ai/testing.ts +17 -0
  39. package/cloudflare/workers-env.ts +12 -0
  40. package/eslint-config.js +115 -0
  41. package/monitoring/alerts/client.ts +48 -0
  42. package/monitoring/alerts/event-config.ts +33 -0
  43. package/monitoring/alerts/policy.ts +39 -0
  44. package/monitoring/alerts/registry.ts +44 -0
  45. package/monitoring/gates/client.ts +90 -0
  46. package/monitoring/gates/dev.ts +29 -0
  47. package/monitoring/gates/testing.ts +30 -0
  48. package/monitoring/logger/client.ts +66 -0
  49. package/monitoring/prometheus/client.ts +147 -0
  50. package/monitoring/prometheus/dev.ts +23 -0
  51. package/monitoring/prometheus/gen/remote_write_pb.ts +105 -0
  52. package/monitoring/prometheus/metrics.ts +180 -0
  53. package/monitoring/prometheus/testing.ts +19 -0
  54. package/monitoring/slack/api.ts +68 -0
  55. package/monitoring/slack/client.ts +33 -0
  56. package/monitoring/slack/events.ts +15 -0
  57. package/package.json +156 -5
  58. package/server/logger.ts +63 -0
  59. package/server/rate-limit-client.ts +88 -0
  60. package/server/rate-limit.ts +24 -0
  61. package/server/remote-functions.ts +139 -0
  62. package/server/result.ts +18 -0
  63. package/server/turnstile.ts +19 -0
  64. package/storage/clickhouse/client.ts +441 -0
  65. package/storage/clickhouse/compiler.ts +218 -0
  66. package/storage/clickhouse/config.ts +13 -0
  67. package/storage/clickhouse/dev.ts +18 -0
  68. package/storage/clickhouse/error.ts +61 -0
  69. package/storage/clickhouse/index.ts +4 -0
  70. package/storage/clickhouse/native-decoder.ts +192 -0
  71. package/storage/clickhouse/readers.ts +167 -0
  72. package/storage/clickhouse/retry.ts +30 -0
  73. package/storage/clickhouse/schema.ts +163 -0
  74. package/storage/clickhouse/testing.ts +81 -0
  75. package/storage/manticore/client.ts +165 -0
  76. package/storage/manticore/schema.ts +23 -0
  77. package/storage/postgres/client.ts +44 -0
  78. package/storage/postgres/testing.ts +275 -0
  79. package/storage/redis/client.ts +219 -0
  80. package/storage/redis/protocol.ts +119 -0
  81. package/storage/redis/schema.ts +17 -0
  82. package/storage/redis/testing.ts +122 -0
  83. package/utils/crypto.ts +36 -0
  84. package/utils/datetime.ts +338 -0
  85. package/utils/email.ts +173 -0
  86. package/utils/env.ts +171 -0
  87. package/utils/errors.ts +67 -0
  88. package/utils/form.ts +135 -0
  89. package/utils/http-status.ts +42 -0
  90. package/utils/money.ts +10 -0
  91. package/utils/public-host.ts +66 -0
  92. package/utils/schema.ts +176 -0
  93. package/utils/strings/format.ts +51 -0
  94. package/utils/strings/normalize.ts +129 -0
  95. package/utils/transport.ts +25 -0
@@ -0,0 +1,147 @@
1
+ import { create, toBinary } from '@bufbuild/protobuf';
2
+ import * as snappy from 'snappyjs';
3
+
4
+ // Direct push into Prometheus over a VPC-style binding (Workers VPC, no public host). No ambient
5
+ // context, no app-specific knowledge — the binding + credentials are passed in.
6
+ import { waitUntil } from '../../cloudflare/wait-until';
7
+ import { WriteRequestSchema } from './gen/remote_write_pb';
8
+
9
+ export type PrometheusSample = {
10
+ labels?: Record<string, string>;
11
+ name: string;
12
+ timestampMs?: number;
13
+ value: number;
14
+ };
15
+
16
+ /** Just the shape callers depend on — a test can hand-write a fake object matching this
17
+ * (`{ push: vi.fn(), pushAndForget: vi.fn(), pushWithRetry: vi.fn() }`) instead of constructing
18
+ * `PrometheusClient` with a fake `vpc.fetch`. */
19
+ export interface IPrometheusClient {
20
+ push(...samples: PrometheusSample[]): Promise<void>;
21
+ pushAndForget(...samples: PrometheusSample[]): void;
22
+ pushWithRetry(attempts?: number, ...samples: PrometheusSample[]): Promise<void>;
23
+ }
24
+
25
+ export type PrometheusConfig = {
26
+ password: string;
27
+ user: string;
28
+ /** The resolved Workers VPC Service binding — the caller reads it off its own `EnvStore`
29
+ * (`env.vpc('PROMETHEUS_VPC')`), not a binding name for this client to look up itself. */
30
+ vpc: Fetcher;
31
+ };
32
+
33
+ /** The real implementation — pushes over Prometheus's remote-write protocol. Construct directly
34
+ * via `new PrometheusClient(config)`. */
35
+ export class PrometheusClient implements IPrometheusClient {
36
+ constructor(private readonly config: PrometheusConfig) {}
37
+
38
+ /** Zero samples is a real no-op (nothing to push). */
39
+ async push(...samples: PrometheusSample[]): Promise<void> {
40
+ if (samples.length === 0) return;
41
+ const { password, user, vpc } = this.config;
42
+
43
+ const now = BigInt(Date.now());
44
+ const writeRequest = create(WriteRequestSchema, {
45
+ timeseries: samples.map((s) => ({
46
+ labels: [
47
+ { name: '__name__', value: s.name },
48
+ ...Object.entries(s.labels ?? {}).map(([name, value]) => ({ name, value })),
49
+ ],
50
+ samples: [
51
+ { timestamp: s.timestampMs != null ? BigInt(s.timestampMs) : now, value: s.value },
52
+ ],
53
+ })),
54
+ });
55
+
56
+ const res = await vpc.fetch('http://localhost/api/v1/write', {
57
+ body: snappy.compress(toBinary(WriteRequestSchema, writeRequest)),
58
+ headers: {
59
+ Authorization: `Basic ${btoa(`${user}:${password}`)}`,
60
+ 'Content-Encoding': 'snappy',
61
+ 'Content-Type': 'application/x-protobuf',
62
+ 'X-Prometheus-Remote-Write-Version': '0.1.0',
63
+ },
64
+ method: 'POST',
65
+ });
66
+
67
+ if (!res.ok) {
68
+ throw new Error(
69
+ `PrometheusClient.push: Prometheus returned ${res.status}: ${await res.text()}`
70
+ );
71
+ }
72
+ }
73
+
74
+ /** Swallow-and-log — a Prometheus outage must never block the caller's own critical path.
75
+ * Extends its own lifetime past the caller's response where that's possible (the real
76
+ * Workers runtime), since without it the platform can kill this push mid-flight the moment
77
+ * the caller's own response has been sent. */
78
+ pushAndForget(...samples: PrometheusSample[]): void {
79
+ if (samples.length === 0) return;
80
+ const result = this.push(...samples).catch((error) => {
81
+ console.warn('metrics push failed', {
82
+ error: error instanceof Error ? error.message : String(error),
83
+ });
84
+ });
85
+ void waitUntil(result);
86
+ }
87
+
88
+ /** Retries with exponential backoff + jitter instead of swallowing — for a caller that needs
89
+ * the push to land. Still throws after exhausting `attempts`. */
90
+ async pushWithRetry(attempts = 3, ...samples: PrometheusSample[]): Promise<void> {
91
+ if (samples.length === 0) return;
92
+ for (let i = 0; ; i++) {
93
+ try {
94
+ return await this.push(...samples);
95
+ } catch (error) {
96
+ if (i >= attempts - 1) throw error;
97
+ const backoff = 2 ** i * 100 + Math.random() * 100;
98
+ await new Promise((resolve) => setTimeout(resolve, backoff));
99
+ }
100
+ }
101
+ }
102
+ }
103
+
104
+ // Metric-type-shaped `PrometheusSample` builders — thin naming-convention wrappers, not real
105
+ // client-side accumulation (a Worker invocation is stateless; every call is one point-in-time
106
+ // observation the caller already computed). `bucket`/`sum` apply the `_bucket`/`_sum` suffixes
107
+ // Prometheus's histogram functions (`histogram_quantile`, `rate`) require to find the right
108
+ // series — `le` is the caller's own bucket boundary, folded into labels as a string (`"+Inf"` is
109
+ // a valid value for the top bucket). `count` does NOT auto-suffix `_total` — unlike `_bucket`/
110
+ // `_sum`, that's a naming convention `rate()`/`increase()` don't require, so it stays opt-in.
111
+
112
+ export function bucket(
113
+ name: string,
114
+ le: number | string,
115
+ value: number,
116
+ labels?: Record<string, string>,
117
+ timestampMs?: number
118
+ ): PrometheusSample {
119
+ return { labels: { ...labels, le: String(le) }, name: `${name}_bucket`, timestampMs, value };
120
+ }
121
+
122
+ export function count(
123
+ name: string,
124
+ value: number,
125
+ labels?: Record<string, string>,
126
+ timestampMs?: number
127
+ ): PrometheusSample {
128
+ return { labels, name, timestampMs, value };
129
+ }
130
+
131
+ export function gauge(
132
+ name: string,
133
+ value: number,
134
+ labels?: Record<string, string>,
135
+ timestampMs?: number
136
+ ): PrometheusSample {
137
+ return { labels, name, timestampMs, value };
138
+ }
139
+
140
+ export function sum(
141
+ name: string,
142
+ value: number,
143
+ labels?: Record<string, string>,
144
+ timestampMs?: number
145
+ ): PrometheusSample {
146
+ return { labels, name: `${name}_sum`, timestampMs, value };
147
+ }
@@ -0,0 +1,23 @@
1
+ import type { IPrometheusClient, PrometheusSample } from './client';
2
+
3
+ /** Stands in for `PrometheusClient` where there's no `PROMETHEUS_VPC` binding to push to (local
4
+ * dev) — logs instead of sending, so metrics are still visible without a real Prometheus. */
5
+ export class DevPrometheusClient implements IPrometheusClient {
6
+ async push(...samples: PrometheusSample[]): Promise<void> {
7
+ this.log(samples);
8
+ }
9
+
10
+ pushAndForget(...samples: PrometheusSample[]): void {
11
+ this.log(samples);
12
+ }
13
+
14
+ async pushWithRetry(_attempts?: number, ...samples: PrometheusSample[]): Promise<void> {
15
+ this.log(samples);
16
+ }
17
+
18
+ private log(samples: PrometheusSample[]): void {
19
+ for (const s of samples) {
20
+ console.log(`[prometheus:dev] ${s.name} ${s.value}`, s.labels ?? {});
21
+ }
22
+ }
23
+ }
@@ -0,0 +1,105 @@
1
+ // Minimal Prometheus remote_write v1 wire schema — only the fields the workers actually send
2
+ // (a plain gauge sample per series, no exemplars/histograms/metadata). Field numbers match upstream
3
+ // prometheus/prometheus's prompb/{types,remote}.proto exactly, so the wire bytes are compatible; the
4
+ // gogoproto annotations there are Go-codegen-only and don't affect the wire format, so this file
5
+ // omits that dependency entirely.
6
+
7
+ // @generated by protoc-gen-es v2.16.0 with parameter "target=ts"
8
+ // @generated from file remote_write.proto (package zeyo.prometheus.remote_write.v1, syntax proto3)
9
+ /* eslint-disable */
10
+
11
+ import type { GenFile, GenMessage } from "@bufbuild/protobuf/codegenv2";
12
+ import { fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv2";
13
+ import type { Message } from "@bufbuild/protobuf";
14
+
15
+ /**
16
+ * Describes the file remote_write.proto.
17
+ */
18
+ export const file_remote_write: GenFile = /*@__PURE__*/
19
+ fileDesc("ChJyZW1vdGVfd3JpdGUucHJvdG8SH3pleW8ucHJvbWV0aGV1cy5yZW1vdGVfd3JpdGUudjEiJAoFTGFiZWwSDAoEbmFtZRgBIAEoCRINCgV2YWx1ZRgCIAEoCSIqCgZTYW1wbGUSDQoFdmFsdWUYASABKAESEQoJdGltZXN0YW1wGAIgASgDIn4KClRpbWVTZXJpZXMSNgoGbGFiZWxzGAEgAygLMiYuemV5by5wcm9tZXRoZXVzLnJlbW90ZV93cml0ZS52MS5MYWJlbBI4CgdzYW1wbGVzGAIgAygLMicuemV5by5wcm9tZXRoZXVzLnJlbW90ZV93cml0ZS52MS5TYW1wbGUiTwoMV3JpdGVSZXF1ZXN0Ej8KCnRpbWVzZXJpZXMYASADKAsyKy56ZXlvLnByb21ldGhldXMucmVtb3RlX3dyaXRlLnYxLlRpbWVTZXJpZXNiBnByb3RvMw");
20
+
21
+ /**
22
+ * @generated from message zeyo.prometheus.remote_write.v1.Label
23
+ */
24
+ export type Label = Message<"zeyo.prometheus.remote_write.v1.Label"> & {
25
+ /**
26
+ * @generated from field: string name = 1;
27
+ */
28
+ name: string;
29
+
30
+ /**
31
+ * @generated from field: string value = 2;
32
+ */
33
+ value: string;
34
+ };
35
+
36
+ /**
37
+ * Describes the message zeyo.prometheus.remote_write.v1.Label.
38
+ * Use `create(LabelSchema)` to create a new message.
39
+ */
40
+ export const LabelSchema: GenMessage<Label> = /*@__PURE__*/
41
+ messageDesc(file_remote_write, 0);
42
+
43
+ /**
44
+ * @generated from message zeyo.prometheus.remote_write.v1.Sample
45
+ */
46
+ export type Sample = Message<"zeyo.prometheus.remote_write.v1.Sample"> & {
47
+ /**
48
+ * @generated from field: double value = 1;
49
+ */
50
+ value: number;
51
+
52
+ /**
53
+ * ms since epoch
54
+ *
55
+ * @generated from field: int64 timestamp = 2;
56
+ */
57
+ timestamp: bigint;
58
+ };
59
+
60
+ /**
61
+ * Describes the message zeyo.prometheus.remote_write.v1.Sample.
62
+ * Use `create(SampleSchema)` to create a new message.
63
+ */
64
+ export const SampleSchema: GenMessage<Sample> = /*@__PURE__*/
65
+ messageDesc(file_remote_write, 1);
66
+
67
+ /**
68
+ * @generated from message zeyo.prometheus.remote_write.v1.TimeSeries
69
+ */
70
+ export type TimeSeries = Message<"zeyo.prometheus.remote_write.v1.TimeSeries"> & {
71
+ /**
72
+ * @generated from field: repeated zeyo.prometheus.remote_write.v1.Label labels = 1;
73
+ */
74
+ labels: Label[];
75
+
76
+ /**
77
+ * @generated from field: repeated zeyo.prometheus.remote_write.v1.Sample samples = 2;
78
+ */
79
+ samples: Sample[];
80
+ };
81
+
82
+ /**
83
+ * Describes the message zeyo.prometheus.remote_write.v1.TimeSeries.
84
+ * Use `create(TimeSeriesSchema)` to create a new message.
85
+ */
86
+ export const TimeSeriesSchema: GenMessage<TimeSeries> = /*@__PURE__*/
87
+ messageDesc(file_remote_write, 2);
88
+
89
+ /**
90
+ * @generated from message zeyo.prometheus.remote_write.v1.WriteRequest
91
+ */
92
+ export type WriteRequest = Message<"zeyo.prometheus.remote_write.v1.WriteRequest"> & {
93
+ /**
94
+ * @generated from field: repeated zeyo.prometheus.remote_write.v1.TimeSeries timeseries = 1;
95
+ */
96
+ timeseries: TimeSeries[];
97
+ };
98
+
99
+ /**
100
+ * Describes the message zeyo.prometheus.remote_write.v1.WriteRequest.
101
+ * Use `create(WriteRequestSchema)` to create a new message.
102
+ */
103
+ export const WriteRequestSchema: GenMessage<WriteRequest> = /*@__PURE__*/
104
+ messageDesc(file_remote_write, 3);
105
+
@@ -0,0 +1,180 @@
1
+ import type { Handle } from '@sveltejs/kit/hooks';
2
+
3
+ import type { WorkerMiddleware } from '../../cloudflare/workers';
4
+ import type { EnvStore } from '../../utils/env';
5
+
6
+ import { runWithContext } from '../../cloudflare/context';
7
+ // Workers analog of rust-common's RequestMetrics/track_metrics — pushes rather than scrapes.
8
+ import { waitUntil } from '../../cloudflare/wait-until';
9
+ import { envStore } from '../../utils/env';
10
+ import { count, gauge, type IPrometheusClient, PrometheusClient } from './client';
11
+
12
+ /** Does nothing — the fallback `client()` returns when `PROMETHEUS_*` isn't configured in this
13
+ * env, so a caller expecting "swallow-and-log" fire-and-forget metrics (see `pushAndForget`'s own
14
+ * doc comment) doesn't get an unhandled construction throw instead. */
15
+ const NOOP_CLIENT: IPrometheusClient = {
16
+ push: async () => {},
17
+ pushAndForget: () => {},
18
+ pushWithRetry: async () => {},
19
+ };
20
+
21
+ let instance: IPrometheusClient | null = null;
22
+
23
+ /** Isolate-level singleton, not per-request — the binding is isolate-stable. Private to this
24
+ * file: the generic middleware below has no `env` param to thread through, so it needs its own
25
+ * ambient access; a caller with an `env` in hand should construct `PrometheusClient` directly.
26
+ *
27
+ * Construction itself (reading `PROMETHEUS_*` secrets/bindings) can throw when this env has none
28
+ * configured — local dev, most notably. Every caller here treats metrics as fire-and-forget, so
29
+ * that must degrade to a no-op instead of throwing: an unhandled construction error surfaces as
30
+ * an unhandled promise rejection in `pushRequestCount`'s async body, which crashes the whole
31
+ * process outside the real Workers runtime (Node kills on unhandled rejection by default). */
32
+ function client(): IPrometheusClient {
33
+ if (instance) return instance;
34
+ try {
35
+ const env = envStore();
36
+ return (instance = new PrometheusClient({
37
+ password: env.secret('PROMETHEUS_PASSWORD'),
38
+ user: env.string('PROMETHEUS_USER'),
39
+ vpc: env.vpc('PROMETHEUS_VPC'),
40
+ }));
41
+ } catch (error) {
42
+ console.warn('Prometheus metrics disabled — PROMETHEUS_* not configured in this env', {
43
+ error: error instanceof Error ? error.message : String(error),
44
+ });
45
+ return (instance = NOOP_CLIENT);
46
+ }
47
+ }
48
+
49
+ /** `colo` splits this metric per Cloudflare colo, so concurrent pushes from different colos don't race Prometheus's per-series ordering and get rejected as "out of order". */
50
+ async function pushRequestCount(
51
+ service: string,
52
+ method: string,
53
+ route: string,
54
+ status: number,
55
+ colo?: string
56
+ ): Promise<void> {
57
+ client().pushAndForget(
58
+ count('platform_http_requests_total', 1, {
59
+ method,
60
+ route,
61
+ service,
62
+ status: String(status),
63
+ ...(colo ? { colo } : {}),
64
+ })
65
+ );
66
+ }
67
+
68
+ export const Metrics = {
69
+ /** Add to `sequence(...)` right after `contextHandle`. Falls back to fire-and-forget on apps
70
+ * with no `Platform.ctx` (marketing, ui). */
71
+ trackHTTP(service: string): Handle {
72
+ return async ({ event, resolve }) => {
73
+ const response = await resolve(event);
74
+ const push = pushRequestCount(
75
+ service,
76
+ event.request.method,
77
+ event.route.id ?? 'unmatched',
78
+ response.status,
79
+ (event.request as Request & { cf?: { colo?: string } }).cf?.colo
80
+ );
81
+ void waitUntil(push);
82
+ return response;
83
+ };
84
+ },
85
+
86
+ /** One call wires whichever surfaces a router has: HTTP request counts, `.queue(...)`
87
+ * success/failure, and `.cron(...)` success/failure + last-run — `WorkerRouter.use()`/
88
+ * `WorkerBindingRouter.use()` each apply only the fields that surface actually has.
89
+ */
90
+ WorkerMiddleware<Env>(service: string): WorkerMiddleware<Env> {
91
+ return {
92
+ cron: async (cron, _env, _executionCtx, next) => {
93
+ try {
94
+ await next();
95
+ client().pushAndForget(
96
+ count('platform_cron_runs_total', 1, { cron, outcome: 'ok', service })
97
+ );
98
+ client().pushAndForget(
99
+ gauge('platform_cron_last_run_timestamp_ms', Date.now(), { cron, service })
100
+ );
101
+ } catch (e) {
102
+ client().pushAndForget(
103
+ count('platform_cron_runs_total', 1, { cron, outcome: 'error', service })
104
+ );
105
+ throw e;
106
+ }
107
+ },
108
+ http: async (c, next) => {
109
+ const response = await next();
110
+ const colo = (c.req.cf as undefined | { colo?: string })?.colo;
111
+ c.executionCtx.waitUntil(
112
+ pushRequestCount(service, c.req.method, c.route, response.status, colo)
113
+ );
114
+ return response;
115
+ },
116
+ queue: async (queueName, _env, _executionCtx, next) => {
117
+ try {
118
+ await next();
119
+ client().pushAndForget(
120
+ count('platform_queue_messages_total', 1, { outcome: 'ok', queue: queueName, service })
121
+ );
122
+ } catch (e) {
123
+ client().pushAndForget(
124
+ count('platform_queue_messages_total', 1, {
125
+ outcome: 'error',
126
+ queue: queueName,
127
+ service,
128
+ })
129
+ );
130
+ throw e;
131
+ }
132
+ },
133
+ };
134
+ },
135
+
136
+ /** For a `TickingDurableObject.onTick()` implementation (`.../workers/ticking-do`) that wants
137
+ * to push `platform_do_tick_runs_total`/`platform_do_tick_last_run_timestamp_ms`. */
138
+ TickingDO(service: string, env: EnvStore): { onTick(outcome: 'error' | 'ok'): void } {
139
+ const client = new PrometheusClient({
140
+ password: env.secret('PROMETHEUS_PASSWORD'),
141
+ user: env.string('PROMETHEUS_USER'),
142
+ vpc: env.vpc('PROMETHEUS_VPC'),
143
+ });
144
+ return {
145
+ onTick(outcome) {
146
+ client.pushAndForget(count('platform_do_tick_runs_total', 1, { outcome, service }));
147
+ if (outcome === 'ok') {
148
+ client.pushAndForget(
149
+ gauge('platform_do_tick_last_run_timestamp_ms', Date.now(), { service })
150
+ );
151
+ }
152
+ },
153
+ };
154
+ },
155
+
156
+ /** Plain-Worker `fetch` wrapper. `classifyRoute` collapses the path onto a fixed route set (no
157
+ * router here to supply one, and an unbounded label would blow up cardinality). `secretNames`:
158
+ * the app's secret-store binding names. */
159
+ withHTTP<Env extends object>(
160
+ service: string,
161
+ classifyRoute: (path: string) => string,
162
+ secretNames: ReadonlySet<string>,
163
+ handler: (request: Request, env: Env, ctx: ExecutionContext) => Promise<Response>
164
+ ): (request: Request, env: Env, ctx: ExecutionContext) => Promise<Response> {
165
+ return (request, env, ctx) =>
166
+ runWithContext(env, secretNames, async () => {
167
+ const response = await handler(request, env, ctx);
168
+ ctx.waitUntil(
169
+ pushRequestCount(
170
+ service,
171
+ request.method,
172
+ classifyRoute(new URL(request.url).pathname),
173
+ response.status,
174
+ (request.cf as undefined | { colo?: string })?.colo
175
+ )
176
+ );
177
+ return response;
178
+ });
179
+ },
180
+ };
@@ -0,0 +1,19 @@
1
+ import type { IPrometheusClient, PrometheusSample } from './client';
2
+
3
+ /** Records every sample passed to `push`/`pushAndForget`/`pushWithRetry` instead of sending
4
+ * anything — for asserting what a service pushed, without a mock `vpc.fetch`. */
5
+ export class FakePrometheusClient implements IPrometheusClient {
6
+ readonly pushed: PrometheusSample[] = [];
7
+
8
+ async push(...samples: PrometheusSample[]): Promise<void> {
9
+ this.pushed.push(...samples);
10
+ }
11
+
12
+ pushAndForget(...samples: PrometheusSample[]): void {
13
+ this.pushed.push(...samples);
14
+ }
15
+
16
+ async pushWithRetry(_attempts?: number, ...samples: PrometheusSample[]): Promise<void> {
17
+ this.pushed.push(...samples);
18
+ }
19
+ }
@@ -0,0 +1,68 @@
1
+ // Generic Slack Web API + Events API primitives — no app domain knowledge. `SlackClient`
2
+ // (./client.ts) is the separate Incoming-Webhook poster; this file is the bot-token
3
+ // (`chat.postMessage`) side plus request-signature verification.
4
+
5
+ /** Post a message (or thread reply) to a Slack channel via the bot token. Returns the posted
6
+ * message's `ts`. */
7
+ export async function slackPostMessage(
8
+ token: string,
9
+ params: {
10
+ blocks?: Record<string, unknown>[];
11
+ channel: string;
12
+ icon_url?: string;
13
+ text: string;
14
+ thread_ts?: string;
15
+ username?: string;
16
+ }
17
+ ): Promise<{ channel: string; ts: string }> {
18
+ const res = await fetch('https://slack.com/api/chat.postMessage', {
19
+ body: JSON.stringify(params),
20
+ headers: {
21
+ Authorization: `Bearer ${token}`,
22
+ 'Content-Type': 'application/json',
23
+ },
24
+ method: 'POST',
25
+ });
26
+ const data = (await res.json()) as { channel: string; error?: string; ok: boolean; ts: string };
27
+ if (!data.ok) throw new Error(`Slack postMessage: ${data.error ?? 'unknown error'}`);
28
+ return { channel: data.channel, ts: data.ts };
29
+ }
30
+
31
+ /**
32
+ * Verify a Slack request signature (v0 scheme, HMAC-SHA256). Returns false when the timestamp
33
+ * is stale (> 5 minutes) or the computed signature does not match.
34
+ */
35
+ export async function verifySlackSignature(
36
+ signingSecret: string,
37
+ timestamp: string,
38
+ rawBody: string,
39
+ signature: string
40
+ ): Promise<boolean> {
41
+ // Guard against replay attacks.
42
+ if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
43
+
44
+ const key = await crypto.subtle.importKey(
45
+ 'raw',
46
+ new TextEncoder().encode(signingSecret),
47
+ { hash: 'SHA-256', name: 'HMAC' },
48
+ false,
49
+ ['sign']
50
+ );
51
+ const base = `v0:${timestamp}:${rawBody}`;
52
+ const buf = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(base));
53
+ const hex = Array.from(new Uint8Array(buf))
54
+ .map((b) => b.toString(16).padStart(2, '0'))
55
+ .join('');
56
+ return timingSafeEqual(signature, `v0=${hex}`);
57
+ }
58
+
59
+ // Web Crypto has no `timingSafeEqual` — compare every byte instead of short-circuiting on the
60
+ // first mismatch, so response timing can't leak how many signature bytes an attacker got right.
61
+ function timingSafeEqual(a: string, b: string): boolean {
62
+ if (a.length !== b.length) return false;
63
+ let diff = 0;
64
+ for (let i = 0; i < a.length; i++) {
65
+ diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
66
+ }
67
+ return diff === 0;
68
+ }
@@ -0,0 +1,33 @@
1
+ import type { EnvStore } from '../../utils/env';
2
+
3
+ import { logger } from '../logger/client';
4
+
5
+ /**
6
+ * Posts a message to a Slack channel via an incoming webhook. Never throws on a failed post —
7
+ * losing an alert must not fail the caller's actual work — but a webhook request failure (network,
8
+ * non-2xx) is still logged.
9
+ */
10
+ export class SlackClient {
11
+ constructor(private readonly webhookUrl: string) {}
12
+
13
+ /** `blocks` is optional, for a richer message than plain `text`. */
14
+ async post(payload: { blocks?: Record<string, unknown>[]; text: string }): Promise<boolean> {
15
+ try {
16
+ const res = await fetch(this.webhookUrl, {
17
+ body: JSON.stringify(payload),
18
+ headers: { 'content-type': 'application/json' },
19
+ method: 'POST',
20
+ });
21
+ if (!res.ok) throw new Error(`Slack webhook responded ${res.status}`);
22
+ return true;
23
+ } catch (err) {
24
+ await logger.error(err, { alert: payload.text });
25
+ return false;
26
+ }
27
+ }
28
+ }
29
+
30
+ /** Built from `SLACK_OPS_WEBHOOK_URL` off the ambient env store. */
31
+ export function slackClient(env: EnvStore): SlackClient {
32
+ return new SlackClient(env.string('SLACK_OPS_WEBHOOK_URL'));
33
+ }
@@ -0,0 +1,15 @@
1
+ // Slack Events API envelope shapes — generic wire format, no app domain knowledge.
2
+ export type SlackMessageEvent = {
3
+ bot_id?: string;
4
+ channel: string;
5
+ subtype?: string;
6
+ text: string;
7
+ thread_ts?: string;
8
+ ts: string;
9
+ type: 'message';
10
+ user?: string;
11
+ };
12
+
13
+ export type SlackPayload =
14
+ | { challenge: string; type: 'url_verification' }
15
+ | { event: SlackMessageEvent; event_id: string; type: 'event_callback' };