@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.
- package/README.md +102 -2
- package/auth/accounts/accounts.remote.ts +116 -0
- package/auth/accounts/accounts.ts +332 -0
- package/auth/accounts/handle.ts +102 -0
- package/auth/accounts/invites.ts +149 -0
- package/auth/accounts/permissions.ts +90 -0
- package/auth/accounts/schemas.ts +22 -0
- package/auth/config.ts +77 -0
- package/auth/emails.ts +57 -0
- package/auth/schema.ts +152 -0
- package/auth/sessions/handle.ts +124 -0
- package/auth/sessions/magic-link/magic-link.ts +71 -0
- package/auth/sessions/passkeys/client.ts +115 -0
- package/auth/sessions/passkeys/passkeys.ts +295 -0
- package/auth/sessions/sessions.ts +101 -0
- package/auth/sveltekit/loads.ts +59 -0
- package/auth/sveltekit/login.ts +28 -0
- package/auth/sveltekit/routes.ts +264 -0
- package/auth/users.ts +49 -0
- package/cloudflare/access.ts +134 -0
- package/cloudflare/amqp/client.ts +276 -0
- package/cloudflare/amqp/testing.ts +9 -0
- package/cloudflare/context.ts +66 -0
- package/cloudflare/dispatch/client.ts +30 -0
- package/cloudflare/email/base.ts +59 -0
- package/cloudflare/email/client.ts +51 -0
- package/cloudflare/email/dev.ts +14 -0
- package/cloudflare/email/smtp.ts +65 -0
- package/cloudflare/email/testing.ts +18 -0
- package/cloudflare/r2/client.ts +154 -0
- package/cloudflare/r2/testing.ts +43 -0
- package/cloudflare/rate-limiter.ts +65 -0
- package/cloudflare/wait-until.ts +12 -0
- package/cloudflare/workers/index.ts +311 -0
- package/cloudflare/workers/testing.ts +94 -0
- package/cloudflare/workers/ticking-do.ts +104 -0
- package/cloudflare/workers-ai/client.ts +30 -0
- package/cloudflare/workers-ai/testing.ts +17 -0
- package/cloudflare/workers-env.ts +12 -0
- package/eslint-config.js +115 -0
- package/monitoring/alerts/client.ts +48 -0
- package/monitoring/alerts/event-config.ts +33 -0
- package/monitoring/alerts/policy.ts +39 -0
- package/monitoring/alerts/registry.ts +44 -0
- package/monitoring/gates/client.ts +90 -0
- package/monitoring/gates/dev.ts +29 -0
- package/monitoring/gates/testing.ts +30 -0
- package/monitoring/logger/client.ts +66 -0
- package/monitoring/prometheus/client.ts +147 -0
- package/monitoring/prometheus/dev.ts +23 -0
- package/monitoring/prometheus/gen/remote_write_pb.ts +105 -0
- package/monitoring/prometheus/metrics.ts +180 -0
- package/monitoring/prometheus/testing.ts +19 -0
- package/monitoring/slack/api.ts +68 -0
- package/monitoring/slack/client.ts +33 -0
- package/monitoring/slack/events.ts +15 -0
- package/package.json +156 -5
- package/server/logger.ts +63 -0
- package/server/rate-limit-client.ts +88 -0
- package/server/rate-limit.ts +24 -0
- package/server/remote-functions.ts +139 -0
- package/server/result.ts +18 -0
- package/server/turnstile.ts +19 -0
- package/storage/clickhouse/client.ts +441 -0
- package/storage/clickhouse/compiler.ts +218 -0
- package/storage/clickhouse/config.ts +13 -0
- package/storage/clickhouse/dev.ts +18 -0
- package/storage/clickhouse/error.ts +61 -0
- package/storage/clickhouse/index.ts +4 -0
- package/storage/clickhouse/native-decoder.ts +192 -0
- package/storage/clickhouse/readers.ts +167 -0
- package/storage/clickhouse/retry.ts +30 -0
- package/storage/clickhouse/schema.ts +163 -0
- package/storage/clickhouse/testing.ts +81 -0
- package/storage/manticore/client.ts +165 -0
- package/storage/manticore/schema.ts +23 -0
- package/storage/postgres/client.ts +44 -0
- package/storage/postgres/testing.ts +275 -0
- package/storage/redis/client.ts +219 -0
- package/storage/redis/protocol.ts +119 -0
- package/storage/redis/schema.ts +17 -0
- package/storage/redis/testing.ts +122 -0
- package/utils/crypto.ts +36 -0
- package/utils/datetime.ts +338 -0
- package/utils/email.ts +173 -0
- package/utils/env.ts +171 -0
- package/utils/errors.ts +67 -0
- package/utils/form.ts +135 -0
- package/utils/http-status.ts +42 -0
- package/utils/money.ts +10 -0
- package/utils/public-host.ts +66 -0
- package/utils/schema.ts +176 -0
- package/utils/strings/format.ts +51 -0
- package/utils/strings/normalize.ts +129 -0
- package/utils/transport.ts +25 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { DurableObject } from 'cloudflare:workers';
|
|
2
|
+
|
|
3
|
+
import type { ScheduledHandler } from './index';
|
|
4
|
+
|
|
5
|
+
import { EnvStore } from '../../utils/env';
|
|
6
|
+
|
|
7
|
+
/** Storage key marking a tick currently in flight — see `ensureAlarm()`'s doc comment for why. */
|
|
8
|
+
const TICK_IN_PROGRESS_KEY = 'tickInProgress';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Base class for a Durable Object whose whole job is a self-rescheduling alarm loop. Resolves
|
|
12
|
+
* `EnvStore` and always re-arms even if `tick()` throws, then calls `onTick(outcome)` — no
|
|
13
|
+
* default, implement it directly or wrap with `withTicking(...)`.
|
|
14
|
+
*
|
|
15
|
+
* ```ts
|
|
16
|
+
* export class MyThingDO extends TickingDurableObject<Env> {
|
|
17
|
+
* protected readonly tickIntervalMs = 60_000;
|
|
18
|
+
* protected readonly service = 'my-worker';
|
|
19
|
+
* protected readonly secretNames = SECRET_STORE_BINDING_NAMES; // the app's secret-store binding names
|
|
20
|
+
* protected async tick(): Promise<void> { ... }
|
|
21
|
+
* protected async onTick(outcome: 'error' | 'ok'): Promise<void> {
|
|
22
|
+
* Metrics.TickingDO(this.service, envStore()).onTick(outcome);
|
|
23
|
+
* }
|
|
24
|
+
* }
|
|
25
|
+
* ```
|
|
26
|
+
*
|
|
27
|
+
* Wire a cron backstop with `.cron(expr, ensureTickingDO<Env>(env => env.MY_DO_NAMESPACE))` below.
|
|
28
|
+
*/
|
|
29
|
+
export abstract class TickingDurableObject<Env> extends DurableObject<Env> {
|
|
30
|
+
/** the app's secret-store binding names. */
|
|
31
|
+
protected abstract readonly secretNames: ReadonlySet<string>;
|
|
32
|
+
protected abstract readonly service: string;
|
|
33
|
+
protected abstract readonly tickIntervalMs: number;
|
|
34
|
+
|
|
35
|
+
async alarm(): Promise<void> {
|
|
36
|
+
await EnvStore.resolve(this.env, this.secretNames);
|
|
37
|
+
await this.ctx.storage.put(TICK_IN_PROGRESS_KEY, true);
|
|
38
|
+
try {
|
|
39
|
+
await this.tick();
|
|
40
|
+
await this.onTick('ok');
|
|
41
|
+
} catch (e) {
|
|
42
|
+
await this.onTick('error');
|
|
43
|
+
throw e;
|
|
44
|
+
} finally {
|
|
45
|
+
await this.ctx.storage.delete(TICK_IN_PROGRESS_KEY);
|
|
46
|
+
await this.ctx.storage.setAlarm(Date.now() + this.tickIntervalMs);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** No-op if an alarm is already scheduled or a tick is running — `getAlarm() === null` alone
|
|
51
|
+
* can't tell a healthy in-progress tick from a genuinely broken chain, since the slot is
|
|
52
|
+
* empty for a normal tick's whole duration too. */
|
|
53
|
+
async ensureAlarm(): Promise<void> {
|
|
54
|
+
const [current, tickInProgress] = await Promise.all([
|
|
55
|
+
this.ctx.storage.getAlarm(),
|
|
56
|
+
this.ctx.storage.get<boolean>(TICK_IN_PROGRESS_KEY),
|
|
57
|
+
]);
|
|
58
|
+
if (current === null && !tickInProgress) {
|
|
59
|
+
await this.ctx.storage.setAlarm(Date.now() + this.tickIntervalMs);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
protected abstract onTick(outcome: 'error' | 'ok'): Promise<void>;
|
|
64
|
+
|
|
65
|
+
protected abstract tick(): Promise<void>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Plug `onTick` in at the binding site instead of overriding it in a subclass — same spot
|
|
69
|
+
* `WorkerDOBinding.withSentry(...)` already wraps the class:
|
|
70
|
+
*
|
|
71
|
+
* ```ts
|
|
72
|
+
* export const MyThing = WorkerDOBinding.withSentry(
|
|
73
|
+
* withTicking(MyThingDO, (outcome, instance) => sendToSlack(`${instance.service}: ${outcome}`))
|
|
74
|
+
* );
|
|
75
|
+
* ```
|
|
76
|
+
*/
|
|
77
|
+
export function withTicking<Env, T extends TickingDurableObject<Env>>(
|
|
78
|
+
DOClass: new (state: DurableObjectState, env: Env) => T,
|
|
79
|
+
onTick: (outcome: 'error' | 'ok', instance: T) => Promise<void> | void
|
|
80
|
+
): new (state: DurableObjectState, env: Env) => T {
|
|
81
|
+
// Extending T directly leaves its abstract members "unimplemented" as far as TS is concerned.
|
|
82
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
83
|
+
class Ticking extends (DOClass as unknown as new (state: DurableObjectState, env: Env) => any) {
|
|
84
|
+
protected async onTick(outcome: 'error' | 'ok'): Promise<void> {
|
|
85
|
+
await onTick(outcome, this as unknown as T);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return Ticking as unknown as new (state: DurableObjectState, env: Env) => T;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** For `.cron(expr, ensureTickingDO<Env>(env => env.MY_DO_NAMESPACE))` — no per-worker handler
|
|
92
|
+
* needed when the DO's own `ensureAlarm()` is the whole job. */
|
|
93
|
+
export function ensureTickingDO<Env, Id = unknown>(
|
|
94
|
+
getNamespace: (env: Env) => {
|
|
95
|
+
get(id: Id): { ensureAlarm(): Promise<void> };
|
|
96
|
+
idFromName(name: string): Id;
|
|
97
|
+
},
|
|
98
|
+
name = 'global'
|
|
99
|
+
): ScheduledHandler<Env> {
|
|
100
|
+
return async (_controller, env) => {
|
|
101
|
+
const namespace = getNamespace(env);
|
|
102
|
+
await namespace.get(namespace.idFromName(name)).ensureAlarm();
|
|
103
|
+
};
|
|
104
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Ai } from '@cloudflare/workers-types';
|
|
2
|
+
|
|
3
|
+
import { envStore, type EnvStore } from '../../utils/env';
|
|
4
|
+
|
|
5
|
+
export interface IWorkersAiClient {
|
|
6
|
+
run(...args: Parameters<Ai['run']>): ReturnType<Ai['run']>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Thin wrapper over the Workers AI binding (`env.AI` — Cloudflare's own hosted models). No
|
|
11
|
+
* business logic of its own — a feature builds its own prompt and parses its own output; this
|
|
12
|
+
* just forwards to the binding.
|
|
13
|
+
*/
|
|
14
|
+
export class WorkersAiClient implements IWorkersAiClient {
|
|
15
|
+
private readonly binding: Ai;
|
|
16
|
+
|
|
17
|
+
constructor(env: EnvStore) {
|
|
18
|
+
this.binding = env.binding<Ai>('AI');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
run(...args: Parameters<Ai['run']>): ReturnType<Ai['run']> {
|
|
22
|
+
return this.binding.run(...args);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Built fresh per call, same as `gates()` — the binding itself is cheap to wrap, nothing to
|
|
27
|
+
* isolate-cache. */
|
|
28
|
+
export function workersAi(): WorkersAiClient {
|
|
29
|
+
return new WorkersAiClient(envStore());
|
|
30
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Ai } from '@cloudflare/workers-types';
|
|
2
|
+
|
|
3
|
+
import type { IWorkersAiClient } from './client';
|
|
4
|
+
|
|
5
|
+
export class FakeWorkersAiClient implements IWorkersAiClient {
|
|
6
|
+
readonly calls: Parameters<Ai['run']>[] = [];
|
|
7
|
+
private readonly responses: unknown[] = [];
|
|
8
|
+
|
|
9
|
+
respondWith(response: unknown): void {
|
|
10
|
+
this.responses.push(response);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
run(...args: Parameters<Ai['run']>): ReturnType<Ai['run']> {
|
|
14
|
+
this.calls.push(args);
|
|
15
|
+
return Promise.resolve(this.responses.shift()) as ReturnType<Ai['run']>;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** The Workers bindings (`cloudflare:workers`'s `env`), or `{}` where there are none. The module
|
|
2
|
+
* only resolves inside the Workers runtime — not in the Node process that analyses routes during
|
|
3
|
+
* `vite build`, and the adapter throws on access while prerendering — so it's imported
|
|
4
|
+
* dynamically and any failure means "no bindings". */
|
|
5
|
+
export async function workersEnv(): Promise<Record<string, unknown>> {
|
|
6
|
+
try {
|
|
7
|
+
const { env } = await import('cloudflare:workers');
|
|
8
|
+
return { ...env };
|
|
9
|
+
} catch {
|
|
10
|
+
return {};
|
|
11
|
+
}
|
|
12
|
+
}
|
package/eslint-config.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { includeIgnoreFile } from '@eslint/compat';
|
|
2
|
+
import js from '@eslint/js';
|
|
3
|
+
import prettier from 'eslint-config-prettier';
|
|
4
|
+
import drizzlePlugin from 'eslint-plugin-drizzle';
|
|
5
|
+
import perfectionist from 'eslint-plugin-perfectionist';
|
|
6
|
+
import svelte from 'eslint-plugin-svelte';
|
|
7
|
+
import globals from 'globals';
|
|
8
|
+
import { existsSync } from 'node:fs';
|
|
9
|
+
import { dirname, join } from 'node:path';
|
|
10
|
+
import * as svelteParser from 'svelte-eslint-parser';
|
|
11
|
+
import ts from 'typescript-eslint';
|
|
12
|
+
|
|
13
|
+
// Re-export `config()` so each package's eslint.config.js can import everything from this one
|
|
14
|
+
// package — no direct dependency on typescript-eslint required downstream.
|
|
15
|
+
export { ts };
|
|
16
|
+
export const config = ts.config;
|
|
17
|
+
|
|
18
|
+
// The consumer's .gitignore: the nearest one upward from where ESLint runs, so a monorepo app
|
|
19
|
+
// inherits the repo root's ignores and this package works the same installed from a registry.
|
|
20
|
+
function nearestGitignore(dir = process.cwd()) {
|
|
21
|
+
for (let current = dir; ; current = dirname(current)) {
|
|
22
|
+
const candidate = join(current, '.gitignore');
|
|
23
|
+
if (existsSync(candidate)) return candidate;
|
|
24
|
+
if (dirname(current) === current) return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Shared base: js + ts + prettier + perfectionist, browser+node globals. Syntactic rules only
|
|
30
|
+
* (`ts.configs.recommended`), so no `projectService` is configured — type-aware linting is slow
|
|
31
|
+
* and none of the enabled rules need it.
|
|
32
|
+
*/
|
|
33
|
+
export function base() {
|
|
34
|
+
const gitignore = nearestGitignore();
|
|
35
|
+
return [
|
|
36
|
+
...(gitignore ? [includeIgnoreFile(gitignore)] : []),
|
|
37
|
+
{ ignores: ['**/worker-configuration.d.ts'] },
|
|
38
|
+
js.configs.recommended,
|
|
39
|
+
...ts.configs.recommended,
|
|
40
|
+
prettier,
|
|
41
|
+
perfectionist.configs['recommended-natural'],
|
|
42
|
+
{
|
|
43
|
+
languageOptions: {
|
|
44
|
+
globals: { ...globals.browser, ...globals.node },
|
|
45
|
+
},
|
|
46
|
+
rules: {
|
|
47
|
+
'@typescript-eslint/consistent-type-imports': 'error',
|
|
48
|
+
'@typescript-eslint/no-import-type-side-effects': 'error',
|
|
49
|
+
'@typescript-eslint/no-unused-vars': [
|
|
50
|
+
'error',
|
|
51
|
+
{ argsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_', varsIgnorePattern: '^_' },
|
|
52
|
+
],
|
|
53
|
+
'perfectionist/sort-modules': ['error', { partitionByNewLine: true }],
|
|
54
|
+
// ClickHouse Native decode is positional: a row schema's field order must match its
|
|
55
|
+
// SELECT list, not get alphabetized. Exempt schemas passed straight to rawSelect() and
|
|
56
|
+
// ones assigned to a `*CHRow` const (the established naming convention for these).
|
|
57
|
+
'perfectionist/sort-objects': [
|
|
58
|
+
'error',
|
|
59
|
+
{
|
|
60
|
+
type: 'unsorted',
|
|
61
|
+
useConfigurationIf: {
|
|
62
|
+
callingFunctionNamePattern: { pattern: 'rawSelect', scope: 'deep' },
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
type: 'unsorted',
|
|
67
|
+
useConfigurationIf: {
|
|
68
|
+
declarationMatchesPattern: { pattern: 'CHRow$', scope: 'deep' },
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
{ partitionByNewLine: true },
|
|
72
|
+
],
|
|
73
|
+
'perfectionist/sort-switch-case': 'off',
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Svelte parsing + recommended rules. `svelteConfig` is optional: apps pass their own
|
|
81
|
+
* svelte.config.js; source packages (ui) have none and call with no argument.
|
|
82
|
+
* @param {object} [svelteConfig]
|
|
83
|
+
*/
|
|
84
|
+
export function svelteRules(svelteConfig) {
|
|
85
|
+
return [
|
|
86
|
+
...svelte.configs.recommended,
|
|
87
|
+
...svelte.configs.prettier,
|
|
88
|
+
{
|
|
89
|
+
files: ['**/*.svelte', '**/*.svelte.ts'],
|
|
90
|
+
languageOptions: {
|
|
91
|
+
parser: svelteParser,
|
|
92
|
+
parserOptions: {
|
|
93
|
+
extraFileExtensions: ['.svelte'],
|
|
94
|
+
parser: ts.parser,
|
|
95
|
+
...(svelteConfig ? { svelteConfig } : {}),
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
rules: {
|
|
99
|
+
'svelte/sort-attributes': 'error',
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Drizzle safety rules: guard unfiltered delete/update. Scoped to the real drizzle handles
|
|
106
|
+
// (`db`, `tx`) so Map/Set `.delete()` calls aren't flagged.
|
|
107
|
+
const drizzleObjectName = ['db', 'tx'];
|
|
108
|
+
export const drizzleRules = {
|
|
109
|
+
files: ['**/*.ts'],
|
|
110
|
+
plugins: { drizzle: drizzlePlugin },
|
|
111
|
+
rules: {
|
|
112
|
+
'drizzle/enforce-delete-with-where': ['error', { drizzleObjectName }],
|
|
113
|
+
'drizzle/enforce-update-with-where': ['error', { drizzleObjectName }],
|
|
114
|
+
},
|
|
115
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
import type { LoggerClient } from '../logger/client';
|
|
4
|
+
import type { EventKey, EventRegistry } from './registry';
|
|
5
|
+
|
|
6
|
+
/** Structural shape of the alerts queue binding — kept minimal so this doesn't pull in a
|
|
7
|
+
* Cloudflare Workers type kit doesn't already depend on. */
|
|
8
|
+
export type AlertsQueueBinding = { send(body: unknown): Promise<void> };
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Fires alert events onto a queue binding, generic over the caller's own event registry `R` —
|
|
12
|
+
* kit has no notion of what an alert type is called. `fire` warns and returns instead of throwing
|
|
13
|
+
* when the binding is absent — this is what lets the binding roll out app-by-app without every
|
|
14
|
+
* caller needing a feature-flag check first. Once the binding exists, a failed enqueue IS a real
|
|
15
|
+
* error (the caller believed the alert would fire and it silently wouldn't), so that case logs
|
|
16
|
+
* and rethrows instead of being swallowed.
|
|
17
|
+
*/
|
|
18
|
+
export class Alert<R extends EventRegistry> {
|
|
19
|
+
constructor(
|
|
20
|
+
private readonly queue: AlertsQueueBinding | undefined,
|
|
21
|
+
private readonly logger: LoggerClient,
|
|
22
|
+
private readonly registry: R
|
|
23
|
+
) {}
|
|
24
|
+
|
|
25
|
+
/** `accountId` is the envelope the consumer scopes by, not part of what happened — so it's its
|
|
26
|
+
* own argument rather than something every producer has to remember to put in the payload.
|
|
27
|
+
* The payload is encoded to its wire form here, so an invalid one throws instead of being
|
|
28
|
+
* dropped by the consumer. */
|
|
29
|
+
async fire<K extends EventKey<R>>(
|
|
30
|
+
accountId: number,
|
|
31
|
+
event: K,
|
|
32
|
+
payload: z.output<R[K]['payload']>
|
|
33
|
+
): Promise<void> {
|
|
34
|
+
if (!this.queue) {
|
|
35
|
+
this.logger.warn('alerts queue binding not configured — dropping alert event', { event });
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
const encoded = z.encode(this.registry[event].payload, payload);
|
|
41
|
+
// eslint-disable-next-line perfectionist/sort-objects -- key order is the serialized wire order
|
|
42
|
+
await this.queue.send({ event, payload: encoded, accountId });
|
|
43
|
+
} catch (err) {
|
|
44
|
+
this.logger.error(err, { accountId, event, op: 'alertFire' });
|
|
45
|
+
throw err;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
import type { Event, EventKey, EventRegistry } from './registry';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Optional behavior for one event: a user-configurable filter deciding whether a fired payload
|
|
7
|
+
* actually trips an alert. An event with no entry in the map it belongs to always matches, with
|
|
8
|
+
* no config to collect — this is only for an event that needs a real parameter, like
|
|
9
|
+
* `credits_low`'s threshold.
|
|
10
|
+
*
|
|
11
|
+
* Distributed over `K` so the type is a union with one arm per event key. `matches` gets that
|
|
12
|
+
* event's own payload type rather than a union of all payloads. `config` isn't correlated with
|
|
13
|
+
* `matches`' second argument — a single object literal can't tie a schema to its own inferred
|
|
14
|
+
* type — so `matches` and `summary` receive it as `unknown` and parse.
|
|
15
|
+
*/
|
|
16
|
+
export type EventConfig<
|
|
17
|
+
R extends EventRegistry,
|
|
18
|
+
K extends EventKey<R> = EventKey<R>,
|
|
19
|
+
> = K extends unknown
|
|
20
|
+
? {
|
|
21
|
+
config: z.ZodType;
|
|
22
|
+
defaults: unknown;
|
|
23
|
+
/** The entity this alert is about, for dedupe — absent means the whole payload is the entity. */
|
|
24
|
+
entity?(payload: Extract<Event<R>, { event: K }>['payload']): string;
|
|
25
|
+
matches(payload: Extract<Event<R>, { event: K }>['payload'], config: unknown): boolean;
|
|
26
|
+
/** One-line description of the config, for list views. */
|
|
27
|
+
summary(config: unknown): string;
|
|
28
|
+
}
|
|
29
|
+
: never;
|
|
30
|
+
|
|
31
|
+
export type EventConfigMap<R extends EventRegistry> = Partial<{
|
|
32
|
+
[K in EventKey<R>]: EventConfig<R, K>;
|
|
33
|
+
}>;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
import { Schema } from '../../utils/schema';
|
|
4
|
+
|
|
5
|
+
/** A policy's recipients are unverified addresses the account types in, so the count is bounded. */
|
|
6
|
+
export const MAX_POLICY_RECIPIENTS = 10;
|
|
7
|
+
|
|
8
|
+
/** Each webhook is an outbound request per fired event, so the count is bounded. */
|
|
9
|
+
export const MAX_POLICY_WEBHOOKS = 5;
|
|
10
|
+
|
|
11
|
+
/** Saved webhook destinations per account. */
|
|
12
|
+
export const MAX_ACCOUNT_WEBHOOKS = 20;
|
|
13
|
+
|
|
14
|
+
/** Scalars and string lists, so it fits a `<Form>`'s input type; the service validates it against the config schema of the policy's alert type. */
|
|
15
|
+
export const PolicyConfig = z.record(
|
|
16
|
+
z.string(),
|
|
17
|
+
z.union([z.string(), z.number(), z.boolean(), z.array(z.string())])
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
/** `description: null` clears it; omitted fields are left as they are, and `recipients`/`webhookIds` replace the destinations only when given. */
|
|
21
|
+
export const UpdatePolicySchema = z.object({
|
|
22
|
+
config: PolicyConfig.optional(),
|
|
23
|
+
description: Schema.Text.max(280).nullable().optional(),
|
|
24
|
+
enabled: z.boolean().optional(),
|
|
25
|
+
id: Schema.UUID,
|
|
26
|
+
name: Schema.Text.max(80).optional(),
|
|
27
|
+
recipients: z.array(Schema.Email).max(MAX_POLICY_RECIPIENTS).optional(),
|
|
28
|
+
webhookIds: z.array(z.number().int()).max(MAX_POLICY_WEBHOOKS).optional(),
|
|
29
|
+
});
|
|
30
|
+
export type UpdatePolicySchema = z.infer<typeof UpdatePolicySchema>;
|
|
31
|
+
|
|
32
|
+
export const PolicyIdSchema = z.object({ id: Schema.UUID });
|
|
33
|
+
export type PolicyIdSchema = z.infer<typeof PolicyIdSchema>;
|
|
34
|
+
|
|
35
|
+
export const HistoryPageQuerySchema = z.object({
|
|
36
|
+
limit: z.number().int().min(1).max(100).default(20),
|
|
37
|
+
offset: z.number().int().min(0).default(0),
|
|
38
|
+
});
|
|
39
|
+
export type HistoryPageQuerySchema = z.infer<typeof HistoryPageQuerySchema>;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
/** One event's metadata: what it means, and the zod schema for its payload. */
|
|
4
|
+
export type EventMeta<Payload extends z.ZodType = z.ZodType> = {
|
|
5
|
+
description: string;
|
|
6
|
+
payload: Payload;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A flat registry of every valid event, keyed by event name. Callers declare one with
|
|
11
|
+
* `as const satisfies EventRegistry` and derive everything else from it through the helpers below,
|
|
12
|
+
* so the key union, the keys array and the runtime schema can't drift from the event list.
|
|
13
|
+
*/
|
|
14
|
+
export type EventRegistry = Record<string, EventMeta>;
|
|
15
|
+
|
|
16
|
+
export type EventKey<R extends EventRegistry> = Extract<keyof R, string>;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* One fired event: its key plus the payload that key requires, as a union discriminated on
|
|
20
|
+
* `event`. A union rather than a per-key map because consumers need to accept "any event" as a
|
|
21
|
+
* single type and narrow on `event`; a map pushes that narrowing onto every caller.
|
|
22
|
+
*/
|
|
23
|
+
export type Event<R extends EventRegistry> = {
|
|
24
|
+
[K in EventKey<R>]: { event: K; payload: z.infer<R[K]['payload']> };
|
|
25
|
+
}[EventKey<R>];
|
|
26
|
+
|
|
27
|
+
export const eventKeys = <R extends EventRegistry>(registry: R) =>
|
|
28
|
+
Object.keys(registry) as [EventKey<R>, ...EventKey<R>[]];
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A zod union over every event in `registry`, discriminated on `event`. `extra` adds fields to
|
|
32
|
+
* every arm — the queue wire format needs an envelope field kit has no notion of.
|
|
33
|
+
*
|
|
34
|
+
* `discriminatedUnion` wants a non-empty tuple and `.map()` only ever produces a plain array, so
|
|
35
|
+
* the cast is required rather than a shortcut: zod doesn't care about tuple-vs-array at runtime,
|
|
36
|
+
* and the returned type is pinned by the caller's own annotation.
|
|
37
|
+
*/
|
|
38
|
+
export const eventSchema = <R extends EventRegistry>(registry: R, extra?: z.ZodRawShape) =>
|
|
39
|
+
z.discriminatedUnion(
|
|
40
|
+
'event',
|
|
41
|
+
eventKeys(registry).map((key) =>
|
|
42
|
+
z.object({ ...extra, event: z.literal(key), payload: registry[key].payload })
|
|
43
|
+
) as never
|
|
44
|
+
);
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Fleet-wide boolean flags over a GATES Workers VPC binding (see the separate `gates` repo). No
|
|
2
|
+
// ambient context, no app-specific knowledge — the binding is passed in; the caller decides where
|
|
3
|
+
// it comes from.
|
|
4
|
+
|
|
5
|
+
export type Gate = { key: string; namespace: string; value: boolean };
|
|
6
|
+
|
|
7
|
+
const FRESH_MS = 5_000; // skip a refetch within this long of the last successful read
|
|
8
|
+
const STALE_MAX_MS = 60_000; // how long a failed fetch may still use the last successful read
|
|
9
|
+
|
|
10
|
+
type FlagCacheEntry = { fetchedAt: number; value: boolean };
|
|
11
|
+
const flagCache = new Map<string, FlagCacheEntry>(); // isolate-shared, not per-instance
|
|
12
|
+
|
|
13
|
+
/** Just the shape callers depend on — a test can hand-write a fake object matching this, or use
|
|
14
|
+
* `FakeGatesClient` from `./testing`, instead of constructing `GatesClient` with a fake binding. */
|
|
15
|
+
export interface IGatesClient {
|
|
16
|
+
delete(namespace: string, key: string): Promise<void>;
|
|
17
|
+
get(namespace: string, key: string): Promise<Gate | null>;
|
|
18
|
+
getFlag(identifier: string, fallback: boolean): Promise<boolean>;
|
|
19
|
+
list(namespace: string): Promise<Gate[]>;
|
|
20
|
+
set(namespace: string, key: string, value: boolean): Promise<Gate>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class GatesClient implements IGatesClient {
|
|
24
|
+
constructor(private readonly binding: Fetcher) {}
|
|
25
|
+
|
|
26
|
+
async delete(namespace: string, key: string): Promise<void> {
|
|
27
|
+
const res = await this.binding.fetch(`http://gates${this.path(namespace, key)}`, {
|
|
28
|
+
method: 'DELETE',
|
|
29
|
+
});
|
|
30
|
+
if (!res.ok && res.status !== 404) {
|
|
31
|
+
throw new Error(`GatesClient.delete ${namespace}/${key}: ${res.status}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async get(namespace: string, key: string): Promise<Gate | null> {
|
|
36
|
+
const res = await this.binding.fetch(`http://gates${this.path(namespace, key)}`);
|
|
37
|
+
if (res.status === 404) return null;
|
|
38
|
+
if (!res.ok) throw new Error(`GatesClient.get ${namespace}/${key}: ${res.status}`);
|
|
39
|
+
return res.json();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** `identifier` is `namespace/key`. Cached, never-throws boolean read: fresh cache within
|
|
43
|
+
* `FRESH_MS`, else refetch; on failure, reuse the last good value within `STALE_MAX_MS`, else
|
|
44
|
+
* `fallback`. */
|
|
45
|
+
async getFlag(identifier: string, fallback: boolean): Promise<boolean> {
|
|
46
|
+
const [namespace, key] = identifier.split('/');
|
|
47
|
+
const cached = flagCache.get(identifier);
|
|
48
|
+
const now = Date.now();
|
|
49
|
+
if (cached && now - cached.fetchedAt < FRESH_MS) return cached.value;
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
const gate = await this.get(namespace, key);
|
|
53
|
+
const value = gate?.value ?? fallback;
|
|
54
|
+
flagCache.set(identifier, { fetchedAt: now, value });
|
|
55
|
+
return value;
|
|
56
|
+
} catch (e) {
|
|
57
|
+
const stillFresh = cached && now - cached.fetchedAt < STALE_MAX_MS;
|
|
58
|
+
console.warn(
|
|
59
|
+
`gates: ${identifier} unreachable, ${stillFresh ? 'using last known value' : 'no recent value, using fallback'}`,
|
|
60
|
+
{
|
|
61
|
+
error: e instanceof Error ? e.message : String(e),
|
|
62
|
+
fallback: stillFresh ? cached.value : fallback,
|
|
63
|
+
}
|
|
64
|
+
);
|
|
65
|
+
return stillFresh ? cached.value : fallback;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async list(namespace: string): Promise<Gate[]> {
|
|
70
|
+
const res = await this.binding.fetch(
|
|
71
|
+
`http://gates/v1/namespaces/${encodeURIComponent(namespace)}/gates`
|
|
72
|
+
);
|
|
73
|
+
if (!res.ok) throw new Error(`GatesClient.list ${namespace}: ${res.status}`);
|
|
74
|
+
return res.json();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async set(namespace: string, key: string, value: boolean): Promise<Gate> {
|
|
78
|
+
const res = await this.binding.fetch(`http://gates${this.path(namespace, key)}`, {
|
|
79
|
+
body: JSON.stringify({ value }),
|
|
80
|
+
headers: { 'content-type': 'application/json' },
|
|
81
|
+
method: 'PUT',
|
|
82
|
+
});
|
|
83
|
+
if (!res.ok) throw new Error(`GatesClient.set ${namespace}/${key}: ${res.status}`);
|
|
84
|
+
return res.json();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private path(namespace: string, key: string): string {
|
|
88
|
+
return `/v1/namespaces/${encodeURIComponent(namespace)}/gates/${encodeURIComponent(key)}`;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { Gate, IGatesClient } from './client';
|
|
2
|
+
|
|
3
|
+
export class GatesDevClient implements IGatesClient {
|
|
4
|
+
private readonly store = new Map<string, Gate>();
|
|
5
|
+
|
|
6
|
+
async delete(namespace: string, key: string): Promise<void> {
|
|
7
|
+
this.store.delete(`${namespace}/${key}`);
|
|
8
|
+
console.log(`[gates:dev] delete ${namespace}/${key}`);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async get(namespace: string, key: string): Promise<Gate | null> {
|
|
12
|
+
return this.store.get(`${namespace}/${key}`) ?? null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async getFlag(identifier: string, fallback: boolean): Promise<boolean> {
|
|
16
|
+
return this.store.get(identifier)?.value ?? fallback;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async list(namespace: string): Promise<Gate[]> {
|
|
20
|
+
return [...this.store.values()].filter((g) => g.namespace === namespace);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async set(namespace: string, key: string, value: boolean): Promise<Gate> {
|
|
24
|
+
const gate: Gate = { key, namespace, value };
|
|
25
|
+
this.store.set(`${namespace}/${key}`, gate);
|
|
26
|
+
console.log(`[gates:dev] set ${namespace}/${key} = ${value}`);
|
|
27
|
+
return gate;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Gate, IGatesClient } from './client';
|
|
2
|
+
|
|
3
|
+
/** In-memory `GatesClient` stand-in — real get/set/list/delete/getFlag semantics against a local
|
|
4
|
+
* `Map` instead of a GATES Workers VPC binding, so a test can seed or read flags without a real
|
|
5
|
+
* binding and without touching `client.ts`'s isolate-shared flag cache. */
|
|
6
|
+
export class FakeGatesClient implements IGatesClient {
|
|
7
|
+
private readonly store = new Map<string, Gate>();
|
|
8
|
+
|
|
9
|
+
async delete(namespace: string, key: string): Promise<void> {
|
|
10
|
+
this.store.delete(`${namespace}/${key}`);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async get(namespace: string, key: string): Promise<Gate | null> {
|
|
14
|
+
return this.store.get(`${namespace}/${key}`) ?? null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async getFlag(identifier: string, fallback: boolean): Promise<boolean> {
|
|
18
|
+
return this.store.get(identifier)?.value ?? fallback;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async list(namespace: string): Promise<Gate[]> {
|
|
22
|
+
return [...this.store.values()].filter((g) => g.namespace === namespace);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async set(namespace: string, key: string, value: boolean): Promise<Gate> {
|
|
26
|
+
const gate: Gate = { key, namespace, value };
|
|
27
|
+
this.store.set(`${namespace}/${key}`, gate);
|
|
28
|
+
return gate;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { captureException } from '@sentry/cloudflare';
|
|
2
|
+
|
|
3
|
+
// Drizzle (and other wrappers) bury the real failure in `error.cause` — e.g. a pg error like
|
|
4
|
+
// `[28P01] password authentication failed` or `[42501] permission denied`. Surface the whole
|
|
5
|
+
// chain so logs show the actual cause, not just "Failed query".
|
|
6
|
+
export function causeChain(error: unknown): string {
|
|
7
|
+
const parts: string[] = [];
|
|
8
|
+
let e: unknown = error;
|
|
9
|
+
for (let depth = 0; depth < 5 && e && typeof e === 'object' && 'cause' in e; depth++) {
|
|
10
|
+
e = (e as { cause: unknown }).cause;
|
|
11
|
+
if (!e || typeof e !== 'object') break;
|
|
12
|
+
const x = e as { code?: unknown; detail?: unknown; message?: unknown };
|
|
13
|
+
const code = x.code ? `[${String(x.code)}] ` : '';
|
|
14
|
+
const detail = x.detail ? ` | detail: ${String(x.detail)}` : '';
|
|
15
|
+
parts.push(`${code}${String(x.message ?? e)}${detail}`);
|
|
16
|
+
}
|
|
17
|
+
return parts.join(' <- ');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** `console.error(error)`'s own serialization is lossy in Workers/CI log capture — pull
|
|
21
|
+
* `message`/`name`/`code` (the last from node-postgres and similar driver errors) into the
|
|
22
|
+
* logged context explicitly so they survive. `cause` walks wrapper errors (drizzle, etc) down
|
|
23
|
+
* to the real underlying error, which otherwise never surfaces at all. */
|
|
24
|
+
function errorDetails(error: unknown): Record<string, unknown> {
|
|
25
|
+
if (!(error instanceof Error)) return { message: String(error) };
|
|
26
|
+
const code = 'code' in error ? (error as { code: unknown }).code : undefined;
|
|
27
|
+
const cause = causeChain(error);
|
|
28
|
+
return {
|
|
29
|
+
message: error.message,
|
|
30
|
+
name: error.name,
|
|
31
|
+
...(code !== undefined && { code }),
|
|
32
|
+
...(cause && { cause }),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A `context.tags` key (if present) is Sentry tag data (grouping/filtering) — peeled off here so
|
|
37
|
+
* every caller gets consistent tag/extra separation for free, rather than each host re-deriving
|
|
38
|
+
* this split in its own wiring (see the old per-host `logger.configure` callbacks this replaced). */
|
|
39
|
+
function reportToSentry(error: unknown, context?: Record<string, unknown>): void {
|
|
40
|
+
const { tags, ...extra } = context ?? {};
|
|
41
|
+
captureException(error, { extra, tags: tags as Record<string, string> | undefined });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** `warn`/`error` always fire — both are reserved for genuinely abnormal conditions (not routine
|
|
45
|
+
* per-request narration), so there's nothing to gate: volume stays near-zero in normal operation
|
|
46
|
+
* and only rises during a real incident, which is exactly when more visibility is wanted, not
|
|
47
|
+
* less. `debug` is for active, temporary investigation only — add a call while chasing a
|
|
48
|
+
* specific bug, delete it once resolved; it's not a permanent narration/tracing tool, so it gets
|
|
49
|
+
* no gate either. */
|
|
50
|
+
export class LoggerClient {
|
|
51
|
+
debug(message: string, context?: Record<string, unknown>): void {
|
|
52
|
+
console.debug(`[DEBUG] ${message}`, ...(context ? [context] : []));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
error(error: unknown, context?: Record<string, unknown>): void {
|
|
56
|
+
console.error('[ERROR]', error, { ...errorDetails(error), ...context });
|
|
57
|
+
reportToSentry(error, context);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
warn(message: string, context?: Record<string, unknown>): void {
|
|
61
|
+
console.warn(`[WARN] ${message}`, ...(context ? [context] : []));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Process-wide logger singleton — Sentry reporting is self-contained, nothing to wire per host. */
|
|
66
|
+
export const logger = new LoggerClient();
|