@wtfalch/reporting 0.1.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.
package/dist/reader.js ADDED
@@ -0,0 +1,40 @@
1
+ import { and, desc, eq, lt, or } from 'drizzle-orm';
2
+ import { reportingEvents } from './tables.js';
3
+ /**
4
+ * Newest first, keyset-paged on `(occurred_at, id)`, which is the table's
5
+ * first index. Every filter is an equality on an indexed column. No
6
+ * permission is applied here: the host gates, and this returns rows as
7
+ * stored.
8
+ */
9
+ export async function eventsPage(db, opts = {}) {
10
+ const limit = Math.max(1, Math.min(opts.limit ?? 50, 200));
11
+ const conditions = [];
12
+ if (opts.level)
13
+ conditions.push(eq(reportingEvents.level, opts.level));
14
+ if (opts.kindNs)
15
+ conditions.push(eq(reportingEvents.kindNs, opts.kindNs));
16
+ if (opts.tenantId)
17
+ conditions.push(eq(reportingEvents.tenantId, opts.tenantId));
18
+ if (opts.site)
19
+ conditions.push(eq(reportingEvents.site, opts.site));
20
+ if (opts.requestId)
21
+ conditions.push(eq(reportingEvents.requestId, opts.requestId));
22
+ if (opts.after) {
23
+ const { occurredAt, id } = opts.after;
24
+ const keyset = or(lt(reportingEvents.occurredAt, occurredAt), and(eq(reportingEvents.occurredAt, occurredAt), lt(reportingEvents.id, id)));
25
+ if (keyset)
26
+ conditions.push(keyset);
27
+ }
28
+ const rows = await db
29
+ .select()
30
+ .from(reportingEvents)
31
+ .where(conditions.length > 0 ? and(...conditions) : undefined)
32
+ .orderBy(desc(reportingEvents.occurredAt), desc(reportingEvents.id))
33
+ .limit(limit + 1);
34
+ const items = rows.slice(0, limit);
35
+ const last = items.at(-1);
36
+ return {
37
+ items,
38
+ next: rows.length > limit && last ? { occurredAt: last.occurredAt, id: last.id } : null,
39
+ };
40
+ }
@@ -0,0 +1,78 @@
1
+ import { z } from 'zod';
2
+ /**
3
+ * What a row may hold, refused in TypeScript before it is queued and again by
4
+ * the table's CHECKs. The two lists that matter for privacy, `BANNED_KEYS` and
5
+ * the flatness rule, are here and in `migrations/0001_reporting.sql`, and
6
+ * `schema.test.ts` reads the SQL to pin them equal.
7
+ */
8
+ export declare const LEVELS: readonly ["info", "warn", "error", "alert"];
9
+ export type Level = (typeof LEVELS)[number];
10
+ export declare const ACTOR_CLASSES: readonly ["human", "api_key", "agent", "service"];
11
+ export type ActorClass = (typeof ACTOR_CLASSES)[number];
12
+ /** `namespace.name`: one dot, lower case, digits and underscores. */
13
+ export declare const KIND_PATTERN: RegExp;
14
+ export declare const SITE_PATTERN: RegExp;
15
+ /**
16
+ * Keys a `data` object may not carry, because each names a person or a
17
+ * secret. A guardrail against the accidental case, not proof of absence: a
18
+ * string under any other key can still hold an address, and review is the
19
+ * third net.
20
+ */
21
+ export declare const BANNED_KEYS: readonly ["email", "name", "display_name", "displayName", "password", "secret", "token", "authorization"];
22
+ export declare const LIMITS: {
23
+ readonly message: 512;
24
+ readonly actorId: 256;
25
+ readonly requestId: 128;
26
+ readonly targetType: 64;
27
+ readonly targetId: 256;
28
+ readonly dataBytes: 16384;
29
+ readonly site: 64;
30
+ };
31
+ /** Flat, bounded, and free of the banned keys. Numbers must be finite: JSON has no NaN. */
32
+ export declare const flatDataSchema: z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean, z.ZodNull]>>;
33
+ export type FlatData = z.infer<typeof flatDataSchema>;
34
+ export declare const actorSchema: z.ZodObject<{
35
+ class: z.ZodEnum<{
36
+ human: "human";
37
+ api_key: "api_key";
38
+ agent: "agent";
39
+ service: "service";
40
+ }>;
41
+ id: z.ZodString;
42
+ }, z.core.$strict>;
43
+ export type Actor = z.infer<typeof actorSchema>;
44
+ export declare const targetSchema: z.ZodObject<{
45
+ type: z.ZodString;
46
+ id: z.ZodString;
47
+ }, z.core.$strict>;
48
+ /** What a caller hands `event()`. Everything else on the row is the writer's. */
49
+ export declare const eventInputSchema: z.ZodObject<{
50
+ kind: z.ZodString;
51
+ message: z.ZodString;
52
+ level: z.ZodDefault<z.ZodEnum<{
53
+ info: "info";
54
+ warn: "warn";
55
+ error: "error";
56
+ alert: "alert";
57
+ }>>;
58
+ tenantId: z.ZodOptional<z.ZodNullable<z.ZodUUID>>;
59
+ actor: z.ZodOptional<z.ZodNullable<z.ZodObject<{
60
+ class: z.ZodEnum<{
61
+ human: "human";
62
+ api_key: "api_key";
63
+ agent: "agent";
64
+ service: "service";
65
+ }>;
66
+ id: z.ZodString;
67
+ }, z.core.$strict>>>;
68
+ requestId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
69
+ target: z.ZodOptional<z.ZodNullable<z.ZodObject<{
70
+ type: z.ZodString;
71
+ id: z.ZodString;
72
+ }, z.core.$strict>>>;
73
+ data: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean, z.ZodNull]>>>;
74
+ occurredAt: z.ZodOptional<z.ZodDate>;
75
+ }, z.core.$strict>;
76
+ export type EventInput = z.input<typeof eventInputSchema>;
77
+ export type ValidEvent = z.output<typeof eventInputSchema>;
78
+ export declare const siteSchema: z.ZodString;
package/dist/schema.js ADDED
@@ -0,0 +1,80 @@
1
+ import { z } from 'zod';
2
+ /**
3
+ * What a row may hold, refused in TypeScript before it is queued and again by
4
+ * the table's CHECKs. The two lists that matter for privacy, `BANNED_KEYS` and
5
+ * the flatness rule, are here and in `migrations/0001_reporting.sql`, and
6
+ * `schema.test.ts` reads the SQL to pin them equal.
7
+ */
8
+ export const LEVELS = ['info', 'warn', 'error', 'alert'];
9
+ export const ACTOR_CLASSES = ['human', 'api_key', 'agent', 'service'];
10
+ /** `namespace.name`: one dot, lower case, digits and underscores. */
11
+ export const KIND_PATTERN = /^[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*$/;
12
+ export const SITE_PATTERN = /^[a-z][a-z0-9-]{0,63}$/;
13
+ /**
14
+ * Keys a `data` object may not carry, because each names a person or a
15
+ * secret. A guardrail against the accidental case, not proof of absence: a
16
+ * string under any other key can still hold an address, and review is the
17
+ * third net.
18
+ */
19
+ export const BANNED_KEYS = [
20
+ 'email',
21
+ 'name',
22
+ 'display_name',
23
+ 'displayName',
24
+ 'password',
25
+ 'secret',
26
+ 'token',
27
+ 'authorization',
28
+ ];
29
+ export const LIMITS = {
30
+ message: 512,
31
+ actorId: 256,
32
+ requestId: 128,
33
+ targetType: 64,
34
+ targetId: 256,
35
+ dataBytes: 16384,
36
+ site: 64,
37
+ };
38
+ const scalar = z.union([z.string(), z.number(), z.boolean(), z.null()]);
39
+ /** Flat, bounded, and free of the banned keys. Numbers must be finite: JSON has no NaN. */
40
+ export const flatDataSchema = z.record(z.string(), scalar).superRefine((data, ctx) => {
41
+ for (const key of Object.keys(data)) {
42
+ if (BANNED_KEYS.includes(key)) {
43
+ ctx.addIssue({ code: 'custom', path: [key], message: `"${key}" may not be a data key` });
44
+ }
45
+ const value = data[key];
46
+ if (typeof value === 'number' && !Number.isFinite(value)) {
47
+ ctx.addIssue({ code: 'custom', path: [key], message: 'must be a finite number' });
48
+ }
49
+ }
50
+ if (Buffer.byteLength(JSON.stringify(data), 'utf8') > LIMITS.dataBytes) {
51
+ ctx.addIssue({
52
+ code: 'custom',
53
+ message: `must serialise to at most ${LIMITS.dataBytes} bytes`,
54
+ });
55
+ }
56
+ });
57
+ const bounded = (max) => z.string().min(1).max(max);
58
+ export const actorSchema = z.strictObject({
59
+ class: z.enum(ACTOR_CLASSES),
60
+ id: bounded(LIMITS.actorId),
61
+ });
62
+ export const targetSchema = z.strictObject({
63
+ type: bounded(LIMITS.targetType),
64
+ id: bounded(LIMITS.targetId),
65
+ });
66
+ /** What a caller hands `event()`. Everything else on the row is the writer's. */
67
+ export const eventInputSchema = z.strictObject({
68
+ kind: z.string().regex(KIND_PATTERN, 'a kind is namespace.name, lower case'),
69
+ message: bounded(LIMITS.message),
70
+ level: z.enum(LEVELS).default('info'),
71
+ tenantId: z.uuid().nullish(),
72
+ actor: actorSchema.nullish(),
73
+ requestId: bounded(LIMITS.requestId).nullish(),
74
+ target: targetSchema.nullish(),
75
+ data: flatDataSchema.default({}),
76
+ occurredAt: z.date().optional(),
77
+ });
78
+ export const siteSchema = z
79
+ .string()
80
+ .regex(SITE_PATTERN, 'a site id is lower case, digits and dashes');
@@ -0,0 +1,16 @@
1
+ import type { Actor, Db, Settings, SettingsChange } from './types.js';
2
+ /**
3
+ * Operator-edited settings, one row per key, read by housekeeping on every
4
+ * run. Bounds are enforced here and again inside `reporting_prune_events`,
5
+ * whose clamp is what makes a setting safe to expose: the worst an edit can
6
+ * do is shorten history to a week.
7
+ */
8
+ export declare const RETENTION_BOUNDS: {
9
+ readonly min: 7;
10
+ readonly max: 400;
11
+ };
12
+ export declare const SETTING_KEYS: (keyof Settings)[];
13
+ export declare const DEFAULT_SETTINGS: Settings;
14
+ export declare function getSettings(db: Db): Promise<Settings>;
15
+ /** Validates every key, writes the changed ones in one transaction, returns before and after. Throws on an out-of-bounds value: the caller is a form. */
16
+ export declare function setSettings(db: Db, patch: Partial<Settings>, by: Actor): Promise<SettingsChange>;
@@ -0,0 +1,71 @@
1
+ import { sql } from 'drizzle-orm';
2
+ import { z } from 'zod';
3
+ import { reportingSettings } from './tables.js';
4
+ /**
5
+ * Operator-edited settings, one row per key, read by housekeeping on every
6
+ * run. Bounds are enforced here and again inside `reporting_prune_events`,
7
+ * whose clamp is what makes a setting safe to expose: the worst an edit can
8
+ * do is shorten history to a week.
9
+ */
10
+ export const RETENTION_BOUNDS = { min: 7, max: 400 };
11
+ const DEFS = {
12
+ 'events.retention_days': {
13
+ schema: z.number().int().min(RETENTION_BOUNDS.min).max(RETENTION_BOUNDS.max),
14
+ default: 30,
15
+ },
16
+ };
17
+ export const SETTING_KEYS = Object.keys(DEFS);
18
+ export const DEFAULT_SETTINGS = Object.freeze({
19
+ 'events.retention_days': DEFS['events.retention_days'].default,
20
+ });
21
+ function fromRows(rows) {
22
+ const out = { ...DEFAULT_SETTINGS };
23
+ for (const row of rows) {
24
+ const def = DEFS[row.key];
25
+ if (!def)
26
+ continue;
27
+ const parsed = def.schema.safeParse(row.value);
28
+ if (parsed.success)
29
+ out[row.key] = parsed.data;
30
+ }
31
+ return out;
32
+ }
33
+ export async function getSettings(db) {
34
+ const rows = await db
35
+ .select({ key: reportingSettings.key, value: reportingSettings.value })
36
+ .from(reportingSettings);
37
+ return fromRows(rows);
38
+ }
39
+ /** Validates every key, writes the changed ones in one transaction, returns before and after. Throws on an out-of-bounds value: the caller is a form. */
40
+ export async function setSettings(db, patch, by) {
41
+ const entries = Object.entries(patch);
42
+ for (const [key, value] of entries) {
43
+ const def = DEFS[key];
44
+ if (!def)
45
+ throw new Error(`reporting.settings: unknown key "${key}"`);
46
+ const parsed = def.schema.safeParse(value);
47
+ if (!parsed.success) {
48
+ throw new Error(`reporting.settings: ${key} must be an integer between ${RETENTION_BOUNDS.min} and ${RETENTION_BOUNDS.max}`);
49
+ }
50
+ }
51
+ return db.transaction(async (tx) => {
52
+ const before = fromRows(await tx
53
+ .select({ key: reportingSettings.key, value: reportingSettings.value })
54
+ .from(reportingSettings));
55
+ const changed = [];
56
+ for (const [key, value] of entries) {
57
+ if (before[key] === value)
58
+ continue;
59
+ changed.push(key);
60
+ await tx
61
+ .insert(reportingSettings)
62
+ .values({ key, value, updatedBy: `${by.class}:${by.id}` })
63
+ .onConflictDoUpdate({
64
+ target: reportingSettings.key,
65
+ set: { value, updatedAt: sql `now()`, updatedBy: `${by.class}:${by.id}` },
66
+ });
67
+ }
68
+ const after = { ...before, ...Object.fromEntries(entries) };
69
+ return { before, after, by, changed };
70
+ });
71
+ }