@krizaka/audit 0.1.5 → 0.1.7

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/index.js CHANGED
@@ -45,7 +45,8 @@ var PAGE = {
45
45
  var ACTION = /^[a-z][a-z0-9_]*(\.[a-z0-9_]+)*$/;
46
46
  function toInsert(input, at, allowedActions) {
47
47
  if (!ACTION.test(input.action) || input.action.length > 80) throw new AuditError(AUDIT_CODE.badAction, input.action);
48
- if (allowedActions && !allowedActions.includes(input.action)) throw new AuditError(AUDIT_CODE.unknownAction, input.action);
48
+ if (allowedActions && !allowedActions.includes(input.action))
49
+ throw new AuditError(AUDIT_CODE.unknownAction, input.action);
49
50
  if (input.ip != null && isIP(input.ip) === 0) throw new AuditError(AUDIT_CODE.badIp, input.ip);
50
51
  const label = input.actorLabel.trim();
51
52
  if (label.length < 1 || label.length > 120) throw new AuditError(AUDIT_CODE.badLabel, input.actorLabel);
@@ -64,8 +65,10 @@ var pageSize = (limit) => Math.min(limit ?? PAGE.default, PAGE.max);
64
65
  var CURSOR = /^[1-9][0-9]{0,18}$/;
65
66
  var BIGINT_MAX = 9223372036854775807n;
66
67
  function checkFilters(filters) {
67
- if (filters.limit !== void 0 && !(Number.isInteger(filters.limit) && filters.limit > 0)) throw new AuditError(AUDIT_CODE.badFilter, `limit ${String(filters.limit)}`);
68
- if (filters.before !== void 0 && !(CURSOR.test(filters.before) && BigInt(filters.before) <= BIGINT_MAX)) throw new AuditError(AUDIT_CODE.badFilter, `before ${filters.before}`);
68
+ if (filters.limit !== void 0 && !(Number.isInteger(filters.limit) && filters.limit > 0))
69
+ throw new AuditError(AUDIT_CODE.badFilter, `limit ${String(filters.limit)}`);
70
+ if (filters.before !== void 0 && !(CURSOR.test(filters.before) && BigInt(filters.before) <= BIGINT_MAX))
71
+ throw new AuditError(AUDIT_CODE.badFilter, `before ${filters.before}`);
69
72
  }
70
73
  function checkRetention(days) {
71
74
  const { min = 1, max = Number.MAX_SAFE_INTEGER } = auditSettings["audit.retentionDays"];
@@ -74,7 +77,17 @@ function checkRetention(days) {
74
77
 
75
78
  // src/persistence.ts
76
79
  var COLUMNS = "id::text AS id, at, actor_id, actor_label, action, target_type, target_id, detail, host(ip) AS ip";
77
- var toEntry = (r) => ({ id: r.id, at: r.at, actorId: r.actor_id, actorLabel: r.actor_label, action: r.action, targetType: r.target_type, targetId: r.target_id, detail: r.detail, ip: r.ip });
80
+ var toEntry = (r) => ({
81
+ id: r.id,
82
+ at: r.at,
83
+ actorId: r.actor_id,
84
+ actorLabel: r.actor_label,
85
+ action: r.action,
86
+ targetType: r.target_type,
87
+ targetId: r.target_id,
88
+ detail: r.detail,
89
+ ip: r.ip
90
+ });
78
91
  function where(filters) {
79
92
  const parts = [];
80
93
  const params = [];
@@ -98,15 +111,24 @@ function dbAuditStore(query, schema = "audit") {
98
111
  const rows = async (sql, params) => (await query(sql, params)).map(toEntry);
99
112
  return {
100
113
  async insert(e) {
101
- await query(`INSERT INTO ${table} (at, actor_id, actor_label, action, target_type, target_id, detail, ip) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, [e.at, e.actorId, e.actorLabel, e.action, e.targetType, e.targetId, JSON.stringify(e.detail), e.ip]);
114
+ await query(
115
+ `INSERT INTO ${table} (at, actor_id, actor_label, action, target_type, target_id, detail, ip) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
116
+ [e.at, e.actorId, e.actorLabel, e.action, e.targetType, e.targetId, JSON.stringify(e.detail), e.ip]
117
+ );
102
118
  },
103
119
  async list(filters) {
104
120
  const { sql, params } = where(filters);
105
- return rows(`SELECT ${COLUMNS} FROM ${table} ${sql} ORDER BY id DESC LIMIT $${params.length + 1}`, [...params, pageSize(filters.limit)]);
121
+ return rows(`SELECT ${COLUMNS} FROM ${table} ${sql} ORDER BY id DESC LIMIT $${params.length + 1}`, [
122
+ ...params,
123
+ pageSize(filters.limit)
124
+ ]);
106
125
  },
107
126
  forActor: (actorId) => rows(`SELECT ${COLUMNS} FROM ${table} WHERE actor_id = $1 ORDER BY id DESC`, [actorId]),
108
127
  async redactActor(actorId) {
109
- return (await query(`UPDATE ${table} SET actor_id = NULL, actor_label = 'erased', ip = NULL WHERE actor_id = $1 RETURNING id`, [actorId])).length;
128
+ return (await query(
129
+ `UPDATE ${table} SET actor_id = NULL, actor_label = 'erased', ip = NULL WHERE actor_id = $1 RETURNING id`,
130
+ [actorId]
131
+ )).length;
110
132
  },
111
133
  async purgeBefore(before) {
112
134
  return (await query(`DELETE FROM ${table} WHERE at < $1 RETURNING id`, [before])).length;
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/audit.ts","../src/persistence.ts","../src/rules.ts","../src/port.ts","../src/settings.ts","../src/memory.ts","../src/schema.ts"],"sourcesContent":["import { ident } from \"@krizaka/db\";\nimport { subDays } from \"date-fns\";\n\nimport { dbAuditStore } from \"./persistence\";\nimport type { AuditEntry, AuditEntryInput, AuditFilters, AuditStore, Clock, Query } from \"./port\";\nimport { checkFilters, checkRetention, toInsert } from \"./rules\";\n\nexport interface AuditDeps {\n clock: Clock;\n /** The store over a query (a pool or a transaction). Default: the PostgreSQL store on schema `audit`. */\n storeFor?: (query: Query) => AuditStore;\n /** The Postgres schema, when the product renamed the module's (used by the default store). */\n schema?: string;\n /** The product's fixed list of actions, when it keeps one (refused otherwise). */\n allowedActions?: readonly string[];\n /** Where a quiet write's failure goes (the product's logger). */\n onError?: (error: unknown, entry: AuditEntryInput) => void | Promise<void>;\n}\n\n/** The trail as a product uses it. Every function takes the `Query` it writes through: pass `tx` to join a unit of work. */\nexport interface Audit {\n /** Records an entry; throws on failure, so it can join the caller's transaction (it commits or rolls back with it). */\n record(query: Query, entry: AuditEntryInput): Promise<void>;\n /** Records an entry beside the work: a failure goes to `onError`, never to the caller. */\n recordQuietly(query: Query, entry: AuditEntryInput): Promise<void>;\n list(query: Query, filters: AuditFilters): Promise<AuditEntry[]>;\n /** One actor's entries, for the Loi 25 export. */\n exportFor(query: Query, actorId: string): Promise<AuditEntry[]>;\n /** Detaches one actor from the trail (Loi 25 erasure): the facts stay, the person does not. */\n redactActor(query: Query, actorId: string): Promise<number>;\n /** Removes what is older than `retentionDays` (the `audit.retentionDays` setting). */\n purge(query: Query, options: { retentionDays: number }): Promise<number>;\n}\n\n/** Hands a quiet write's failure to the sink; a sink that throws or rejects is swallowed too: `recordQuietly` never throws. */\nasync function report(onError: AuditDeps[\"onError\"], error: unknown, entry: AuditEntryInput): Promise<void> {\n try {\n await onError?.(error, entry);\n } catch {\n // The sink is the product's logger; a failing logger must not fail the request it was only meant to observe.\n }\n}\n\n/**\n * The audit trail of a product.\n * @param deps the clock, the store, the product's action list and error sink\n * @returns the trail's functions\n */\nexport function createAudit(deps: AuditDeps): Audit {\n const schema = ident(deps.schema ?? \"audit\");\n const storeFor = deps.storeFor ?? ((query: Query) => dbAuditStore(query, schema));\n const record = async (query: Query, entry: AuditEntryInput) => storeFor(query).insert(toInsert(entry, deps.clock.now(), deps.allowedActions));\n return {\n record,\n async recordQuietly(query, entry) {\n try {\n await record(query, entry);\n } catch (error) {\n await report(deps.onError, error, entry);\n }\n },\n async list(query, filters) {\n checkFilters(filters);\n return storeFor(query).list(filters);\n },\n exportFor: (query, actorId) => storeFor(query).forActor(actorId),\n redactActor: (query, actorId) => storeFor(query).redactActor(actorId),\n async purge(query, { retentionDays }) {\n checkRetention(retentionDays);\n return storeFor(query).purgeBefore(subDays(deps.clock.now(), retentionDays));\n },\n };\n}\n","import { ident, type Query } from \"@krizaka/db\";\n\nimport type { AuditEntry, AuditFilters, AuditStore } from \"./port\";\nimport { pageSize } from \"./rules\";\n\ninterface Row {\n id: string;\n at: Date;\n actor_id: string | null;\n actor_label: string;\n action: string;\n target_type: string | null;\n target_id: string | null;\n detail: Record<string, unknown>;\n ip: string | null;\n}\n\nconst COLUMNS = \"id::text AS id, at, actor_id, actor_label, action, target_type, target_id, detail, host(ip) AS ip\";\nconst toEntry = (r: Row): AuditEntry => ({ id: r.id, at: r.at, actorId: r.actor_id, actorLabel: r.actor_label, action: r.action, targetType: r.target_type, targetId: r.target_id, detail: r.detail, ip: r.ip });\n\n/** The WHERE clause of the admin's filters, with its parameters ($1, $2… in order). */\nfunction where(filters: AuditFilters): { sql: string; params: unknown[] } {\n const parts: string[] = [];\n const params: unknown[] = [];\n const add = (clause: (n: number) => string, value: unknown) => { params.push(value); parts.push(clause(params.length)); };\n if (filters.actorId !== undefined) add((n) => `actor_id = $${n}`, filters.actorId);\n if (filters.actionPrefix !== undefined) add((n) => `starts_with(action, $${n})`, filters.actionPrefix);\n if (filters.target !== undefined) { add((n) => `target_type = $${n}`, filters.target.type); add((n) => `target_id = $${n}`, filters.target.id); }\n if (filters.from !== undefined) add((n) => `at >= $${n}`, filters.from);\n if (filters.to !== undefined) add((n) => `at < $${n}`, filters.to);\n if (filters.before !== undefined) add((n) => `id < $${n}::bigint`, filters.before);\n return { sql: parts.length > 0 ? `WHERE ${parts.join(\" AND \")}` : \"\", params };\n}\n\n/**\n * The PostgreSQL store, on `<schema>.entry` (`auditSchema`), over the product's query: a pool, or an open transaction.\n * @param query the product's SQL runner\n * @param schema the Postgres schema, when the product renamed it (default `audit`)\n * @returns a store that passes `auditStoreContract`\n * @throws DbError `DB_UNSAFE_IDENTIFIER` for a schema that is not an identifier\n */\nexport function dbAuditStore(query: Query, schema = \"audit\"): AuditStore {\n const table = `${ident(schema)}.entry`;\n const rows = async (sql: string, params?: unknown[]) => (await query<Row>(sql, params)).map(toEntry);\n return {\n async insert(e) {\n await query(`INSERT INTO ${table} (at, actor_id, actor_label, action, target_type, target_id, detail, ip) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, [e.at, e.actorId, e.actorLabel, e.action, e.targetType, e.targetId, JSON.stringify(e.detail), e.ip]);\n },\n async list(filters) {\n const { sql, params } = where(filters);\n return rows(`SELECT ${COLUMNS} FROM ${table} ${sql} ORDER BY id DESC LIMIT $${params.length + 1}`, [...params, pageSize(filters.limit)]);\n },\n forActor: (actorId) => rows(`SELECT ${COLUMNS} FROM ${table} WHERE actor_id = $1 ORDER BY id DESC`, [actorId]),\n async redactActor(actorId) {\n return (await query(`UPDATE ${table} SET actor_id = NULL, actor_label = 'erased', ip = NULL WHERE actor_id = $1 RETURNING id`, [actorId])).length;\n },\n async purgeBefore(before) {\n return (await query(`DELETE FROM ${table} WHERE at < $1 RETURNING id`, [before])).length;\n },\n };\n}\n","import { isIP } from \"node:net\";\n\nimport { AUDIT_CODE, type AuditEntryInput, AuditError, type AuditFilters, type AuditInsert } from \"./port\";\nimport { auditSettings } from \"./settings\";\n\n/** The size of a page of the trail, for the admin. */\nexport const PAGE = {\n // @fixed: a page the admin reads at once; beyond it, the cursor.\n default: 50,\n // @fixed: the most one request may return, whatever the caller asks.\n max: 200,\n} as const;\n\nconst ACTION = /^[a-z][a-z0-9_]*(\\.[a-z0-9_]+)*$/;\n\n/**\n * Checks an entry against the trail's rules and the product's list of actions, and flattens it for a store.\n * @param input what the product records\n * @param at when (the injected clock)\n * @param allowedActions the product's actions, when it keeps a fixed list\n * @returns the row to write\n * @throws AuditError `AUDIT_BAD_ACTION`, `AUDIT_UNKNOWN_ACTION`, `AUDIT_BAD_LABEL`, `AUDIT_BAD_IP`\n */\nexport function toInsert(input: AuditEntryInput, at: Date, allowedActions?: readonly string[]): AuditInsert {\n if (!ACTION.test(input.action) || input.action.length > 80) throw new AuditError(AUDIT_CODE.badAction, input.action);\n if (allowedActions && !allowedActions.includes(input.action)) throw new AuditError(AUDIT_CODE.unknownAction, input.action);\n if (input.ip != null && isIP(input.ip) === 0) throw new AuditError(AUDIT_CODE.badIp, input.ip);\n const label = input.actorLabel.trim();\n if (label.length < 1 || label.length > 120) throw new AuditError(AUDIT_CODE.badLabel, input.actorLabel);\n return {\n at,\n actorId: input.actorId,\n actorLabel: label,\n action: input.action,\n targetType: input.target?.type ?? null,\n targetId: input.target?.id ?? null,\n detail: input.detail ?? {},\n ip: input.ip ?? null,\n };\n}\n\n/**\n * The page size a store uses: the caller's, capped at `PAGE.max`, or `PAGE.default`.\n * @param limit the requested size (already checked by `checkFilters`)\n */\nexport const pageSize = (limit: number | undefined): number => Math.min(limit ?? PAGE.default, PAGE.max);\n\nconst CURSOR = /^[1-9][0-9]{0,18}$/;\n/** PostgreSQL's largest `bigint`: an entry id, and so a cursor, is never above it. */\nconst BIGINT_MAX = 9_223_372_036_854_775_807n;\n\n/**\n * Checks filters that may come from a request: a page size that is a positive integer, a cursor that is an entry id.\n * @throws AuditError `AUDIT_BAD_FILTER`\n */\nexport function checkFilters(filters: AuditFilters): void {\n if (filters.limit !== undefined && !(Number.isInteger(filters.limit) && filters.limit > 0)) throw new AuditError(AUDIT_CODE.badFilter, `limit ${String(filters.limit)}`);\n if (filters.before !== undefined && !(CURSOR.test(filters.before) && BigInt(filters.before) <= BIGINT_MAX)) throw new AuditError(AUDIT_CODE.badFilter, `before ${filters.before}`);\n}\n\n/**\n * Checks a retention against the setting's bounds, so a wrong value can never purge the whole trail.\n * @throws AuditError `AUDIT_BAD_RETENTION`\n */\nexport function checkRetention(days: number): void {\n const { min = 1, max = Number.MAX_SAFE_INTEGER } = auditSettings[\"audit.retentionDays\"];\n if (!Number.isInteger(days) || days < min || days > max) throw new AuditError(AUDIT_CODE.badRetention, String(days));\n}\n","/**\n * The ports of « audit »: who did what, when, to what. Generic: no product import, no database client (a `Query` comes in),\n * no wall clock (a `Clock` comes in). What an action means is the product's; the package keeps the trail.\n */\nexport type { Clock, Query } from \"@krizaka/db\";\n\n/** What a product records. `actorId` is null for the system or a deleted account; the label stays readable. */\nexport interface AuditEntryInput {\n actorId: string | null;\n /** A readable name of who acted (« Eric », « system », « stripe webhook »). */\n actorLabel: string;\n /** The product's action, dotted (`billing.checkout_started`, `admin.user_suspended`). */\n action: string;\n target?: { type: string; id: string } | null;\n /** Facts about the action. Never a secret, and as little personal data as the action needs. */\n detail?: Record<string, unknown>;\n ip?: string | null;\n}\n\n/** What a store writes: the input, flattened, with its time. */\nexport interface AuditInsert {\n at: Date;\n actorId: string | null;\n actorLabel: string;\n action: string;\n targetType: string | null;\n targetId: string | null;\n detail: Record<string, unknown>;\n ip: string | null;\n}\n\n/** An entry as read back: its id (a cursor too) and everything written. */\nexport interface AuditEntry extends AuditInsert {\n id: string;\n}\n\n/** How the admin filters the trail. Newest first; `before` is the last id of the previous page. */\nexport interface AuditFilters {\n actorId?: string;\n actionPrefix?: string;\n target?: { type: string; id: string };\n from?: Date;\n to?: Date;\n limit?: number;\n before?: string;\n}\n\n/** Where the trail lives. Every adapter runs `auditStoreContract` (`@krizaka/audit/testkit`). */\nexport interface AuditStore {\n insert(entry: AuditInsert): Promise<void>;\n list(filters: AuditFilters): Promise<AuditEntry[]>;\n /** Every entry of one actor (Loi 25 export). */\n forActor(actorId: string): Promise<AuditEntry[]>;\n /** Detaches one actor from the trail: id and IP removed, label « erased »; the facts stay. Returns how many entries changed. */\n redactActor(actorId: string): Promise<number>;\n /** Removes the entries older than `before`. Returns how many. */\n purgeBefore(before: Date): Promise<number>;\n}\n\nexport const AUDIT_CODE = {\n badAction: \"AUDIT_BAD_ACTION\",\n unknownAction: \"AUDIT_UNKNOWN_ACTION\",\n badLabel: \"AUDIT_BAD_LABEL\",\n badIp: \"AUDIT_BAD_IP\",\n badFilter: \"AUDIT_BAD_FILTER\",\n badRetention: \"AUDIT_BAD_RETENTION\",\n} as const;\nexport type AuditCode = (typeof AUDIT_CODE)[keyof typeof AUDIT_CODE];\n\nexport class AuditError extends Error {\n constructor(\n readonly code: AuditCode,\n readonly value: string,\n ) {\n super(`${code}: ${value}`);\n this.name = \"AuditError\";\n }\n}\n","import { integer, type Registry } from \"@krizaka/settings\";\n\n/**\n * The settings of « audit », a fragment the product adds to its registry: the admin sets how long the trail is kept, and\n * the product's job calls `purge` with it. Global scope only.\n */\nexport const auditSettings = {\n // @fixed: three years by default; the admin chooses between one month and ten years.\n \"audit.retentionDays\": integer(\"audit\", 1095, { min: 30, max: 3650, unit: \"days\" }),\n} as const satisfies Registry;\n","import type { AuditEntry, AuditFilters, AuditStore } from \"./port\";\nimport { pageSize } from \"./rules\";\n\n/** Whether an entry passes the admin's filters (the same semantics as the database store). */\nexport function matches(entry: AuditEntry, filters: AuditFilters): boolean {\n return (\n (filters.actorId === undefined || entry.actorId === filters.actorId) &&\n (filters.actionPrefix === undefined || entry.action.startsWith(filters.actionPrefix)) &&\n (filters.target === undefined || (entry.targetType === filters.target.type && entry.targetId === filters.target.id)) &&\n (filters.from === undefined || entry.at >= filters.from) &&\n (filters.to === undefined || entry.at < filters.to) &&\n (filters.before === undefined || BigInt(entry.id) < BigInt(filters.before))\n );\n}\n\n/**\n * An in-memory trail, for unit tests and demos. Ids grow like the table's `bigserial`.\n * @returns a store that passes `auditStoreContract`\n */\nexport function memoryAuditStore(): AuditStore {\n const rows: AuditEntry[] = [];\n let lastId = 0n; // like the table's sequence: never reused, even after a purge\n /** An independent copy: nothing given or read can change the stored trail. */\n const copy = (e: AuditEntry): AuditEntry => structuredClone(e);\n const newestFirst = () => [...rows].sort((a, b) => Number(BigInt(b.id) - BigInt(a.id)));\n return {\n async insert(entry) {\n lastId += 1n;\n rows.push(copy({ ...entry, id: String(lastId) }));\n },\n async list(filters) { return newestFirst().filter((e) => matches(e, filters)).slice(0, pageSize(filters.limit)).map(copy); },\n async forActor(actorId) { return newestFirst().filter((e) => e.actorId === actorId).map(copy); },\n async redactActor(actorId) {\n const mine = rows.filter((e) => e.actorId === actorId);\n for (const e of mine) Object.assign(e, { actorId: null, actorLabel: \"erased\", ip: null });\n return mine.length;\n },\n async purgeBefore(before) {\n const old = rows.filter((e) => e.at < before);\n for (const e of old) rows.splice(rows.indexOf(e), 1);\n return old.length;\n },\n };\n}\n","import { defineModule } from \"@krizaka/db\";\n\n/**\n * The tables of « audit », in their own Postgres schema (ADR-001 §3). The one product table referenced is the owner (the\n * account table), declared by the product; deleting an account keeps its entries with the actor detached.\n * Version 1 is frozen once released: a change is version 2.\n */\nexport const auditSchema = defineModule({\n module: \"audit\",\n tables: [\"owner\"],\n migrations: [\n {\n version: 1,\n name: \"entries\",\n sql: (c) => `\nCREATE TABLE ${c.schema}.entry (\n id bigserial PRIMARY KEY,\n at timestamptz NOT NULL,\n actor_id uuid REFERENCES ${c.tables.owner}(id) ON DELETE SET NULL,\n actor_label text NOT NULL CHECK (char_length(actor_label) BETWEEN 1 AND 120),\n action text NOT NULL CHECK (action ~ '^[a-z][a-z0-9_]*(\\\\.[a-z0-9_]+)*$' AND char_length(action) <= 80),\n target_type text CHECK (target_type IS NULL OR char_length(target_type) <= 40),\n target_id text CHECK (target_id IS NULL OR char_length(target_id) <= 200),\n detail jsonb NOT NULL DEFAULT '{}'::jsonb,\n ip inet\n);\nCREATE INDEX entry_at ON ${c.schema}.entry (at DESC);\nCREATE INDEX entry_action ON ${c.schema}.entry (action, at DESC);\nCREATE INDEX entry_target ON ${c.schema}.entry (target_type, target_id, at DESC);\nCREATE INDEX entry_actor ON ${c.schema}.entry (actor_id) WHERE actor_id IS NOT NULL;`,\n },\n ],\n});\n"],"mappings":"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","names":["ident","ident"]}
1
+ {"version":3,"sources":["../src/audit.ts","../src/persistence.ts","../src/rules.ts","../src/port.ts","../src/settings.ts","../src/memory.ts","../src/schema.ts"],"sourcesContent":["import { ident } from \"@krizaka/db\";\nimport { subDays } from \"date-fns\";\n\nimport { dbAuditStore } from \"./persistence\";\nimport type { AuditEntry, AuditEntryInput, AuditFilters, AuditStore, Clock, Query } from \"./port\";\nimport { checkFilters, checkRetention, toInsert } from \"./rules\";\n\nexport interface AuditDeps {\n clock: Clock;\n /** The store over a query (a pool or a transaction). Default: the PostgreSQL store on schema `audit`. */\n storeFor?: (query: Query) => AuditStore;\n /** The Postgres schema, when the product renamed the module's (used by the default store). */\n schema?: string;\n /** The product's fixed list of actions, when it keeps one (refused otherwise). */\n allowedActions?: readonly string[];\n /** Where a quiet write's failure goes (the product's logger). */\n onError?: (error: unknown, entry: AuditEntryInput) => void | Promise<void>;\n}\n\n/** The trail as a product uses it. Every function takes the `Query` it writes through: pass `tx` to join a unit of work. */\nexport interface Audit {\n /** Records an entry; throws on failure, so it can join the caller's transaction (it commits or rolls back with it). */\n record(query: Query, entry: AuditEntryInput): Promise<void>;\n /** Records an entry beside the work: a failure goes to `onError`, never to the caller. */\n recordQuietly(query: Query, entry: AuditEntryInput): Promise<void>;\n list(query: Query, filters: AuditFilters): Promise<AuditEntry[]>;\n /** One actor's entries, for the Loi 25 export. */\n exportFor(query: Query, actorId: string): Promise<AuditEntry[]>;\n /** Detaches one actor from the trail (Loi 25 erasure): the facts stay, the person does not. */\n redactActor(query: Query, actorId: string): Promise<number>;\n /** Removes what is older than `retentionDays` (the `audit.retentionDays` setting). */\n purge(query: Query, options: { retentionDays: number }): Promise<number>;\n}\n\n/** Hands a quiet write's failure to the sink; a sink that throws or rejects is swallowed too: `recordQuietly` never throws. */\nasync function report(onError: AuditDeps[\"onError\"], error: unknown, entry: AuditEntryInput): Promise<void> {\n try {\n await onError?.(error, entry);\n } catch {\n // The sink is the product's logger; a failing logger must not fail the request it was only meant to observe.\n }\n}\n\n/**\n * The audit trail of a product.\n * @param deps the clock, the store, the product's action list and error sink\n * @returns the trail's functions\n */\nexport function createAudit(deps: AuditDeps): Audit {\n const schema = ident(deps.schema ?? \"audit\");\n const storeFor = deps.storeFor ?? ((query: Query) => dbAuditStore(query, schema));\n const record = async (query: Query, entry: AuditEntryInput) =>\n storeFor(query).insert(toInsert(entry, deps.clock.now(), deps.allowedActions));\n return {\n record,\n async recordQuietly(query, entry) {\n try {\n await record(query, entry);\n } catch (error) {\n await report(deps.onError, error, entry);\n }\n },\n async list(query, filters) {\n checkFilters(filters);\n return storeFor(query).list(filters);\n },\n exportFor: (query, actorId) => storeFor(query).forActor(actorId),\n redactActor: (query, actorId) => storeFor(query).redactActor(actorId),\n async purge(query, { retentionDays }) {\n checkRetention(retentionDays);\n return storeFor(query).purgeBefore(subDays(deps.clock.now(), retentionDays));\n },\n };\n}\n","import { ident, type Query } from \"@krizaka/db\";\n\nimport type { AuditEntry, AuditFilters, AuditStore } from \"./port\";\nimport { pageSize } from \"./rules\";\n\ninterface Row {\n id: string;\n at: Date;\n actor_id: string | null;\n actor_label: string;\n action: string;\n target_type: string | null;\n target_id: string | null;\n detail: Record<string, unknown>;\n ip: string | null;\n}\n\nconst COLUMNS = \"id::text AS id, at, actor_id, actor_label, action, target_type, target_id, detail, host(ip) AS ip\";\nconst toEntry = (r: Row): AuditEntry => ({\n id: r.id,\n at: r.at,\n actorId: r.actor_id,\n actorLabel: r.actor_label,\n action: r.action,\n targetType: r.target_type,\n targetId: r.target_id,\n detail: r.detail,\n ip: r.ip,\n});\n\n/** The WHERE clause of the admin's filters, with its parameters ($1, $2… in order). */\nfunction where(filters: AuditFilters): { sql: string; params: unknown[] } {\n const parts: string[] = [];\n const params: unknown[] = [];\n const add = (clause: (n: number) => string, value: unknown) => {\n params.push(value);\n parts.push(clause(params.length));\n };\n if (filters.actorId !== undefined) add((n) => `actor_id = $${n}`, filters.actorId);\n if (filters.actionPrefix !== undefined) add((n) => `starts_with(action, $${n})`, filters.actionPrefix);\n if (filters.target !== undefined) {\n add((n) => `target_type = $${n}`, filters.target.type);\n add((n) => `target_id = $${n}`, filters.target.id);\n }\n if (filters.from !== undefined) add((n) => `at >= $${n}`, filters.from);\n if (filters.to !== undefined) add((n) => `at < $${n}`, filters.to);\n if (filters.before !== undefined) add((n) => `id < $${n}::bigint`, filters.before);\n return { sql: parts.length > 0 ? `WHERE ${parts.join(\" AND \")}` : \"\", params };\n}\n\n/**\n * The PostgreSQL store, on `<schema>.entry` (`auditSchema`), over the product's query: a pool, or an open transaction.\n * @param query the product's SQL runner\n * @param schema the Postgres schema, when the product renamed it (default `audit`)\n * @returns a store that passes `auditStoreContract`\n * @throws DbError `DB_UNSAFE_IDENTIFIER` for a schema that is not an identifier\n */\nexport function dbAuditStore(query: Query, schema = \"audit\"): AuditStore {\n const table = `${ident(schema)}.entry`;\n const rows = async (sql: string, params?: unknown[]) => (await query<Row>(sql, params)).map(toEntry);\n return {\n async insert(e) {\n await query(\n `INSERT INTO ${table} (at, actor_id, actor_label, action, target_type, target_id, detail, ip) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,\n [e.at, e.actorId, e.actorLabel, e.action, e.targetType, e.targetId, JSON.stringify(e.detail), e.ip],\n );\n },\n async list(filters) {\n const { sql, params } = where(filters);\n return rows(`SELECT ${COLUMNS} FROM ${table} ${sql} ORDER BY id DESC LIMIT $${params.length + 1}`, [\n ...params,\n pageSize(filters.limit),\n ]);\n },\n forActor: (actorId) => rows(`SELECT ${COLUMNS} FROM ${table} WHERE actor_id = $1 ORDER BY id DESC`, [actorId]),\n async redactActor(actorId) {\n return (\n await query(\n `UPDATE ${table} SET actor_id = NULL, actor_label = 'erased', ip = NULL WHERE actor_id = $1 RETURNING id`,\n [actorId],\n )\n ).length;\n },\n async purgeBefore(before) {\n return (await query(`DELETE FROM ${table} WHERE at < $1 RETURNING id`, [before])).length;\n },\n };\n}\n","import { isIP } from \"node:net\";\n\nimport { AUDIT_CODE, type AuditEntryInput, AuditError, type AuditFilters, type AuditInsert } from \"./port\";\nimport { auditSettings } from \"./settings\";\n\n/** The size of a page of the trail, for the admin. */\nexport const PAGE = {\n // @fixed: a page the admin reads at once; beyond it, the cursor.\n default: 50,\n // @fixed: the most one request may return, whatever the caller asks.\n max: 200,\n} as const;\n\nconst ACTION = /^[a-z][a-z0-9_]*(\\.[a-z0-9_]+)*$/;\n\n/**\n * Checks an entry against the trail's rules and the product's list of actions, and flattens it for a store.\n * @param input what the product records\n * @param at when (the injected clock)\n * @param allowedActions the product's actions, when it keeps a fixed list\n * @returns the row to write\n * @throws AuditError `AUDIT_BAD_ACTION`, `AUDIT_UNKNOWN_ACTION`, `AUDIT_BAD_LABEL`, `AUDIT_BAD_IP`\n */\nexport function toInsert(input: AuditEntryInput, at: Date, allowedActions?: readonly string[]): AuditInsert {\n if (!ACTION.test(input.action) || input.action.length > 80) throw new AuditError(AUDIT_CODE.badAction, input.action);\n if (allowedActions && !allowedActions.includes(input.action))\n throw new AuditError(AUDIT_CODE.unknownAction, input.action);\n if (input.ip != null && isIP(input.ip) === 0) throw new AuditError(AUDIT_CODE.badIp, input.ip);\n const label = input.actorLabel.trim();\n if (label.length < 1 || label.length > 120) throw new AuditError(AUDIT_CODE.badLabel, input.actorLabel);\n return {\n at,\n actorId: input.actorId,\n actorLabel: label,\n action: input.action,\n targetType: input.target?.type ?? null,\n targetId: input.target?.id ?? null,\n detail: input.detail ?? {},\n ip: input.ip ?? null,\n };\n}\n\n/**\n * The page size a store uses: the caller's, capped at `PAGE.max`, or `PAGE.default`.\n * @param limit the requested size (already checked by `checkFilters`)\n */\nexport const pageSize = (limit: number | undefined): number => Math.min(limit ?? PAGE.default, PAGE.max);\n\nconst CURSOR = /^[1-9][0-9]{0,18}$/;\n/** PostgreSQL's largest `bigint`: an entry id, and so a cursor, is never above it. */\nconst BIGINT_MAX = 9_223_372_036_854_775_807n;\n\n/**\n * Checks filters that may come from a request: a page size that is a positive integer, a cursor that is an entry id.\n * @throws AuditError `AUDIT_BAD_FILTER`\n */\nexport function checkFilters(filters: AuditFilters): void {\n if (filters.limit !== undefined && !(Number.isInteger(filters.limit) && filters.limit > 0))\n throw new AuditError(AUDIT_CODE.badFilter, `limit ${String(filters.limit)}`);\n if (filters.before !== undefined && !(CURSOR.test(filters.before) && BigInt(filters.before) <= BIGINT_MAX))\n throw new AuditError(AUDIT_CODE.badFilter, `before ${filters.before}`);\n}\n\n/**\n * Checks a retention against the setting's bounds, so a wrong value can never purge the whole trail.\n * @throws AuditError `AUDIT_BAD_RETENTION`\n */\nexport function checkRetention(days: number): void {\n const { min = 1, max = Number.MAX_SAFE_INTEGER } = auditSettings[\"audit.retentionDays\"];\n if (!Number.isInteger(days) || days < min || days > max) throw new AuditError(AUDIT_CODE.badRetention, String(days));\n}\n","/**\n * The ports of « audit »: who did what, when, to what. Generic: no product import, no database client (a `Query` comes in),\n * no wall clock (a `Clock` comes in). What an action means is the product's; the package keeps the trail.\n */\nexport type { Clock, Query } from \"@krizaka/db\";\n\n/** What a product records. `actorId` is null for the system or a deleted account; the label stays readable. */\nexport interface AuditEntryInput {\n actorId: string | null;\n /** A readable name of who acted (« Eric », « system », « stripe webhook »). */\n actorLabel: string;\n /** The product's action, dotted (`billing.checkout_started`, `admin.user_suspended`). */\n action: string;\n target?: { type: string; id: string } | null;\n /** Facts about the action. Never a secret, and as little personal data as the action needs. */\n detail?: Record<string, unknown>;\n ip?: string | null;\n}\n\n/** What a store writes: the input, flattened, with its time. */\nexport interface AuditInsert {\n at: Date;\n actorId: string | null;\n actorLabel: string;\n action: string;\n targetType: string | null;\n targetId: string | null;\n detail: Record<string, unknown>;\n ip: string | null;\n}\n\n/** An entry as read back: its id (a cursor too) and everything written. */\nexport interface AuditEntry extends AuditInsert {\n id: string;\n}\n\n/** How the admin filters the trail. Newest first; `before` is the last id of the previous page. */\nexport interface AuditFilters {\n actorId?: string;\n actionPrefix?: string;\n target?: { type: string; id: string };\n from?: Date;\n to?: Date;\n limit?: number;\n before?: string;\n}\n\n/** Where the trail lives. Every adapter runs `auditStoreContract` (`@krizaka/audit/testkit`). */\nexport interface AuditStore {\n insert(entry: AuditInsert): Promise<void>;\n list(filters: AuditFilters): Promise<AuditEntry[]>;\n /** Every entry of one actor (Loi 25 export). */\n forActor(actorId: string): Promise<AuditEntry[]>;\n /** Detaches one actor from the trail: id and IP removed, label « erased »; the facts stay. Returns how many entries changed. */\n redactActor(actorId: string): Promise<number>;\n /** Removes the entries older than `before`. Returns how many. */\n purgeBefore(before: Date): Promise<number>;\n}\n\nexport const AUDIT_CODE = {\n badAction: \"AUDIT_BAD_ACTION\",\n unknownAction: \"AUDIT_UNKNOWN_ACTION\",\n badLabel: \"AUDIT_BAD_LABEL\",\n badIp: \"AUDIT_BAD_IP\",\n badFilter: \"AUDIT_BAD_FILTER\",\n badRetention: \"AUDIT_BAD_RETENTION\",\n} as const;\nexport type AuditCode = (typeof AUDIT_CODE)[keyof typeof AUDIT_CODE];\n\nexport class AuditError extends Error {\n constructor(\n readonly code: AuditCode,\n readonly value: string,\n ) {\n super(`${code}: ${value}`);\n this.name = \"AuditError\";\n }\n}\n","import { integer, type Registry } from \"@krizaka/settings\";\n\n/**\n * The settings of « audit », a fragment the product adds to its registry: the admin sets how long the trail is kept, and\n * the product's job calls `purge` with it. Global scope only.\n */\nexport const auditSettings = {\n // @fixed: three years by default; the admin chooses between one month and ten years.\n \"audit.retentionDays\": integer(\"audit\", 1095, { min: 30, max: 3650, unit: \"days\" }),\n} as const satisfies Registry;\n","import type { AuditEntry, AuditFilters, AuditStore } from \"./port\";\nimport { pageSize } from \"./rules\";\n\n/** Whether an entry passes the admin's filters (the same semantics as the database store). */\nexport function matches(entry: AuditEntry, filters: AuditFilters): boolean {\n return (\n (filters.actorId === undefined || entry.actorId === filters.actorId) &&\n (filters.actionPrefix === undefined || entry.action.startsWith(filters.actionPrefix)) &&\n (filters.target === undefined ||\n (entry.targetType === filters.target.type && entry.targetId === filters.target.id)) &&\n (filters.from === undefined || entry.at >= filters.from) &&\n (filters.to === undefined || entry.at < filters.to) &&\n (filters.before === undefined || BigInt(entry.id) < BigInt(filters.before))\n );\n}\n\n/**\n * An in-memory trail, for unit tests and demos. Ids grow like the table's `bigserial`.\n * @returns a store that passes `auditStoreContract`\n */\nexport function memoryAuditStore(): AuditStore {\n const rows: AuditEntry[] = [];\n let lastId = 0n; // like the table's sequence: never reused, even after a purge\n /** An independent copy: nothing given or read can change the stored trail. */\n const copy = (e: AuditEntry): AuditEntry => structuredClone(e);\n const newestFirst = () => [...rows].sort((a, b) => Number(BigInt(b.id) - BigInt(a.id)));\n return {\n async insert(entry) {\n lastId += 1n;\n rows.push(copy({ ...entry, id: String(lastId) }));\n },\n async list(filters) {\n return newestFirst()\n .filter((e) => matches(e, filters))\n .slice(0, pageSize(filters.limit))\n .map(copy);\n },\n async forActor(actorId) {\n return newestFirst()\n .filter((e) => e.actorId === actorId)\n .map(copy);\n },\n async redactActor(actorId) {\n const mine = rows.filter((e) => e.actorId === actorId);\n for (const e of mine) Object.assign(e, { actorId: null, actorLabel: \"erased\", ip: null });\n return mine.length;\n },\n async purgeBefore(before) {\n const old = rows.filter((e) => e.at < before);\n for (const e of old) rows.splice(rows.indexOf(e), 1);\n return old.length;\n },\n };\n}\n","import { defineModule } from \"@krizaka/db\";\n\n/**\n * The tables of « audit », in their own Postgres schema (ADR-001 §3). The one product table referenced is the owner (the\n * account table), declared by the product; deleting an account keeps its entries with the actor detached.\n * Version 1 is frozen once released: a change is version 2.\n */\nexport const auditSchema = defineModule({\n module: \"audit\",\n tables: [\"owner\"],\n migrations: [\n {\n version: 1,\n name: \"entries\",\n sql: (c) => `\nCREATE TABLE ${c.schema}.entry (\n id bigserial PRIMARY KEY,\n at timestamptz NOT NULL,\n actor_id uuid REFERENCES ${c.tables.owner}(id) ON DELETE SET NULL,\n actor_label text NOT NULL CHECK (char_length(actor_label) BETWEEN 1 AND 120),\n action text NOT NULL CHECK (action ~ '^[a-z][a-z0-9_]*(\\\\.[a-z0-9_]+)*$' AND char_length(action) <= 80),\n target_type text CHECK (target_type IS NULL OR char_length(target_type) <= 40),\n target_id text CHECK (target_id IS NULL OR char_length(target_id) <= 200),\n detail jsonb NOT NULL DEFAULT '{}'::jsonb,\n ip inet\n);\nCREATE INDEX entry_at ON ${c.schema}.entry (at DESC);\nCREATE INDEX entry_action ON ${c.schema}.entry (action, at DESC);\nCREATE INDEX entry_target ON ${c.schema}.entry (target_type, target_id, at DESC);\nCREATE INDEX entry_actor ON ${c.schema}.entry (actor_id) WHERE actor_id IS NOT NULL;`,\n },\n ],\n});\n"],"mappings":"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","names":["ident","ident"]}
package/dist/testkit.js CHANGED
@@ -18,10 +18,27 @@ function auditStoreContract(name, make, owner) {
18
18
  const p = tag();
19
19
  const actor = await owner(1);
20
20
  await store.insert(entry(p, { at: /* @__PURE__ */ new Date("2026-10-23T09:00:00Z"), action: `${p}.first` }));
21
- await store.insert(entry(p, { actorId: actor, actorLabel: "Eric", action: `${p}.second`, targetType: "shop", targetId: "s1", detail: { n: 2, tags: ["a"] }, ip: "203.0.113.9" }));
21
+ await store.insert(
22
+ entry(p, {
23
+ actorId: actor,
24
+ actorLabel: "Eric",
25
+ action: `${p}.second`,
26
+ targetType: "shop",
27
+ targetId: "s1",
28
+ detail: { n: 2, tags: ["a"] },
29
+ ip: "203.0.113.9"
30
+ })
31
+ );
22
32
  const listed = await store.list({ actionPrefix: `${p}.` });
23
33
  expect(listed.map((e) => e.action)).toEqual([`${p}.second`, `${p}.first`]);
24
- expect(listed[0]).toMatchObject({ actorId: actor, actorLabel: "Eric", targetType: "shop", targetId: "s1", detail: { n: 2, tags: ["a"] }, ip: "203.0.113.9" });
34
+ expect(listed[0]).toMatchObject({
35
+ actorId: actor,
36
+ actorLabel: "Eric",
37
+ targetType: "shop",
38
+ targetId: "s1",
39
+ detail: { n: 2, tags: ["a"] },
40
+ ip: "203.0.113.9"
41
+ });
25
42
  expect(listed[0].at.toISOString()).toBe("2026-10-23T10:00:00.000Z");
26
43
  expect(typeof listed[0].id).toBe("string");
27
44
  });
@@ -29,8 +46,12 @@ function auditStoreContract(name, make, owner) {
29
46
  const store = await make();
30
47
  const p = tag();
31
48
  const [a, b] = [await owner(2), await owner(3)];
32
- await store.insert(entry(p, { actorId: a, targetType: "shop", targetId: "x", at: /* @__PURE__ */ new Date("2026-10-01T00:00:00Z") }));
33
- await store.insert(entry(p, { actorId: b, targetType: "shop", targetId: "y", at: /* @__PURE__ */ new Date("2026-10-10T00:00:00Z") }));
49
+ await store.insert(
50
+ entry(p, { actorId: a, targetType: "shop", targetId: "x", at: /* @__PURE__ */ new Date("2026-10-01T00:00:00Z") })
51
+ );
52
+ await store.insert(
53
+ entry(p, { actorId: b, targetType: "shop", targetId: "y", at: /* @__PURE__ */ new Date("2026-10-10T00:00:00Z") })
54
+ );
34
55
  expect(await store.list({ actionPrefix: p, actorId: a })).toHaveLength(1);
35
56
  expect((await store.list({ actionPrefix: p, target: { type: "shop", id: "y" } }))[0].actorId).toBe(b);
36
57
  expect(await store.list({ actionPrefix: p, from: /* @__PURE__ */ new Date("2026-10-05T00:00:00Z") })).toHaveLength(1);
@@ -55,7 +76,11 @@ function auditStoreContract(name, make, owner) {
55
76
  expect(await store.redactActor(gone)).toBeGreaterThanOrEqual(1);
56
77
  const after = await store.list({ actionPrefix: p });
57
78
  expect(after.find((e) => e.actorLabel === "Stays").actorId).toBe(stays);
58
- expect(after.find((e) => e.actorLabel !== "Stays")).toMatchObject({ actorId: null, actorLabel: "erased", ip: null });
79
+ expect(after.find((e) => e.actorLabel !== "Stays")).toMatchObject({
80
+ actorId: null,
81
+ actorLabel: "erased",
82
+ ip: null
83
+ });
59
84
  expect((await store.forActor(gone)).filter((e) => e.action.startsWith(p))).toEqual([]);
60
85
  });
61
86
  it(`${name}: ids never repeat after a purge, so a cursor never skips an entry`, async () => {
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/testkit.ts"],"sourcesContent":["import { expect, it } from \"vitest\";\n\nimport type { AuditInsert, AuditStore } from \"./port\";\n\n/**\n * What every AuditStore adapter must do: the same behaviour behind the same port. `owner(n)` gives an actor id the adapter\n * accepts (an account of the product). Each case writes under its own action prefix, so the cases share one database.\n * @param name the adapter's name, shown in the test titles\n * @param make builds the store\n * @param owner the n-th actor id\n */\nexport function auditStoreContract(name: string, make: () => AuditStore | Promise<AuditStore>, owner: (n: number) => string | Promise<string>) {\n const tag = () => `t${crypto.randomUUID().replaceAll(\"-\", \"\").slice(0, 8)}`;\n const entry = (prefix: string, over: Partial<AuditInsert> = {}): AuditInsert => ({\n at: new Date(\"2026-10-23T10:00:00Z\"),\n actorId: null,\n actorLabel: \"system\",\n action: `${prefix}.done`,\n targetType: null,\n targetId: null,\n detail: {},\n ip: null,\n ...over,\n });\n\n it(`${name}: keeps every field and lists the newest first`, async () => {\n const store = await make();\n const p = tag();\n const actor = await owner(1);\n await store.insert(entry(p, { at: new Date(\"2026-10-23T09:00:00Z\"), action: `${p}.first` }));\n await store.insert(entry(p, { actorId: actor, actorLabel: \"Eric\", action: `${p}.second`, targetType: \"shop\", targetId: \"s1\", detail: { n: 2, tags: [\"a\"] }, ip: \"203.0.113.9\" }));\n const listed = await store.list({ actionPrefix: `${p}.` });\n expect(listed.map((e) => e.action)).toEqual([`${p}.second`, `${p}.first`]);\n expect(listed[0]).toMatchObject({ actorId: actor, actorLabel: \"Eric\", targetType: \"shop\", targetId: \"s1\", detail: { n: 2, tags: [\"a\"] }, ip: \"203.0.113.9\" });\n expect(listed[0]!.at.toISOString()).toBe(\"2026-10-23T10:00:00.000Z\");\n expect(typeof listed[0]!.id).toBe(\"string\");\n });\n\n it(`${name}: filters by actor, target and time window`, async () => {\n const store = await make();\n const p = tag();\n const [a, b] = [await owner(2), await owner(3)];\n await store.insert(entry(p, { actorId: a, targetType: \"shop\", targetId: \"x\", at: new Date(\"2026-10-01T00:00:00Z\") }));\n await store.insert(entry(p, { actorId: b, targetType: \"shop\", targetId: \"y\", at: new Date(\"2026-10-10T00:00:00Z\") }));\n expect(await store.list({ actionPrefix: p, actorId: a })).toHaveLength(1);\n expect((await store.list({ actionPrefix: p, target: { type: \"shop\", id: \"y\" } }))[0]!.actorId).toBe(b);\n expect(await store.list({ actionPrefix: p, from: new Date(\"2026-10-05T00:00:00Z\") })).toHaveLength(1);\n expect(await store.list({ actionPrefix: p, to: new Date(\"2026-10-05T00:00:00Z\") })).toHaveLength(1);\n });\n\n it(`${name}: pages with a limit and a cursor (the last id seen)`, async () => {\n const store = await make();\n const p = tag();\n for (let i = 0; i < 5; i++) await store.insert(entry(p, { action: `${p}.n${i}` }));\n const page1 = await store.list({ actionPrefix: p, limit: 2 });\n const page2 = await store.list({ actionPrefix: p, limit: 2, before: page1.at(-1)!.id });\n expect(page1.map((e) => e.action)).toEqual([`${p}.n4`, `${p}.n3`]);\n expect(page2.map((e) => e.action)).toEqual([`${p}.n2`, `${p}.n1`]);\n });\n\n it(`${name}: exports one actor's entries and redacts them (Loi 25), leaving the others`, async () => {\n const store = await make();\n const p = tag();\n const [gone, stays] = [await owner(4), await owner(5)];\n await store.insert(entry(p, { actorId: gone, actorLabel: \"Gone\", ip: \"198.51.100.1\" }));\n await store.insert(entry(p, { actorId: stays, actorLabel: \"Stays\" }));\n expect((await store.forActor(gone)).filter((e) => e.action.startsWith(p))).toHaveLength(1);\n expect(await store.redactActor(gone)).toBeGreaterThanOrEqual(1);\n const after = await store.list({ actionPrefix: p });\n expect(after.find((e) => e.actorLabel === \"Stays\")!.actorId).toBe(stays);\n expect(after.find((e) => e.actorLabel !== \"Stays\")).toMatchObject({ actorId: null, actorLabel: \"erased\", ip: null });\n expect((await store.forActor(gone)).filter((e) => e.action.startsWith(p))).toEqual([]);\n });\n\n it(`${name}: ids never repeat after a purge, so a cursor never skips an entry`, async () => {\n const store = await make();\n const p = tag();\n await store.insert(entry(p, { action: `${p}.old`, at: new Date(\"2020-01-01T00:00:00Z\") }));\n await store.insert(entry(p, { action: `${p}.kept`, at: new Date(\"2026-10-01T00:00:00Z\") }));\n await store.purgeBefore(new Date(\"2021-01-01T00:00:00Z\"));\n await store.insert(entry(p, { action: `${p}.after`, at: new Date(\"2026-10-02T00:00:00Z\") }));\n const all = await store.list({ actionPrefix: p });\n expect(new Set(all.map((e) => e.id)).size).toBe(all.length);\n const first = await store.list({ actionPrefix: p, limit: 1 });\n const rest = await store.list({ actionPrefix: p, before: first[0]!.id });\n expect([...first, ...rest].map((e) => e.action)).toEqual([`${p}.after`, `${p}.kept`]);\n });\n\n it(`${name}: the trail cannot be changed by mutating what was given or what was read`, async () => {\n const store = await make();\n const p = tag();\n const input = entry(p, { detail: { nested: { n: 1 } } });\n await store.insert(input);\n input.at.setUTCFullYear(1999);\n (input.detail.nested as { n: number }).n = 99;\n const [read] = await store.list({ actionPrefix: p });\n read!.at.setUTCFullYear(1999);\n (read!.detail.nested as { n: number }).n = 42;\n read!.actorLabel = \"forged\";\n const [again] = await store.list({ actionPrefix: p });\n expect(again).toMatchObject({ actorLabel: \"system\", detail: { nested: { n: 1 } } });\n expect(again!.at.toISOString()).toBe(\"2026-10-23T10:00:00.000Z\");\n const [exported] = await store.forActor(\"00000000-0000-4000-8000-000000000000\");\n expect(exported).toBeUndefined();\n });\n\n it(`${name}: purges only what is older than the given time, and counts it`, async () => {\n const store = await make();\n const p = tag();\n await store.insert(entry(p, { action: `${p}.old`, at: new Date(\"2020-01-01T00:00:00Z\") }));\n await store.insert(entry(p, { action: `${p}.new`, at: new Date(\"2026-10-01T00:00:00Z\") }));\n expect(await store.purgeBefore(new Date(\"2021-01-01T00:00:00Z\"))).toBeGreaterThanOrEqual(1);\n expect((await store.list({ actionPrefix: p })).map((e) => e.action)).toEqual([`${p}.new`]);\n });\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/testkit.ts"],"sourcesContent":["import { expect, it } from \"vitest\";\n\nimport type { AuditInsert, AuditStore } from \"./port\";\n\n/**\n * What every AuditStore adapter must do: the same behaviour behind the same port. `owner(n)` gives an actor id the adapter\n * accepts (an account of the product). Each case writes under its own action prefix, so the cases share one database.\n * @param name the adapter's name, shown in the test titles\n * @param make builds the store\n * @param owner the n-th actor id\n */\nexport function auditStoreContract(\n name: string,\n make: () => AuditStore | Promise<AuditStore>,\n owner: (n: number) => string | Promise<string>,\n) {\n const tag = () => `t${crypto.randomUUID().replaceAll(\"-\", \"\").slice(0, 8)}`;\n const entry = (prefix: string, over: Partial<AuditInsert> = {}): AuditInsert => ({\n at: new Date(\"2026-10-23T10:00:00Z\"),\n actorId: null,\n actorLabel: \"system\",\n action: `${prefix}.done`,\n targetType: null,\n targetId: null,\n detail: {},\n ip: null,\n ...over,\n });\n\n it(`${name}: keeps every field and lists the newest first`, async () => {\n const store = await make();\n const p = tag();\n const actor = await owner(1);\n await store.insert(entry(p, { at: new Date(\"2026-10-23T09:00:00Z\"), action: `${p}.first` }));\n await store.insert(\n entry(p, {\n actorId: actor,\n actorLabel: \"Eric\",\n action: `${p}.second`,\n targetType: \"shop\",\n targetId: \"s1\",\n detail: { n: 2, tags: [\"a\"] },\n ip: \"203.0.113.9\",\n }),\n );\n const listed = await store.list({ actionPrefix: `${p}.` });\n expect(listed.map((e) => e.action)).toEqual([`${p}.second`, `${p}.first`]);\n expect(listed[0]).toMatchObject({\n actorId: actor,\n actorLabel: \"Eric\",\n targetType: \"shop\",\n targetId: \"s1\",\n detail: { n: 2, tags: [\"a\"] },\n ip: \"203.0.113.9\",\n });\n expect(listed[0]!.at.toISOString()).toBe(\"2026-10-23T10:00:00.000Z\");\n expect(typeof listed[0]!.id).toBe(\"string\");\n });\n\n it(`${name}: filters by actor, target and time window`, async () => {\n const store = await make();\n const p = tag();\n const [a, b] = [await owner(2), await owner(3)];\n await store.insert(\n entry(p, { actorId: a, targetType: \"shop\", targetId: \"x\", at: new Date(\"2026-10-01T00:00:00Z\") }),\n );\n await store.insert(\n entry(p, { actorId: b, targetType: \"shop\", targetId: \"y\", at: new Date(\"2026-10-10T00:00:00Z\") }),\n );\n expect(await store.list({ actionPrefix: p, actorId: a })).toHaveLength(1);\n expect((await store.list({ actionPrefix: p, target: { type: \"shop\", id: \"y\" } }))[0]!.actorId).toBe(b);\n expect(await store.list({ actionPrefix: p, from: new Date(\"2026-10-05T00:00:00Z\") })).toHaveLength(1);\n expect(await store.list({ actionPrefix: p, to: new Date(\"2026-10-05T00:00:00Z\") })).toHaveLength(1);\n });\n\n it(`${name}: pages with a limit and a cursor (the last id seen)`, async () => {\n const store = await make();\n const p = tag();\n for (let i = 0; i < 5; i++) await store.insert(entry(p, { action: `${p}.n${i}` }));\n const page1 = await store.list({ actionPrefix: p, limit: 2 });\n const page2 = await store.list({ actionPrefix: p, limit: 2, before: page1.at(-1)!.id });\n expect(page1.map((e) => e.action)).toEqual([`${p}.n4`, `${p}.n3`]);\n expect(page2.map((e) => e.action)).toEqual([`${p}.n2`, `${p}.n1`]);\n });\n\n it(`${name}: exports one actor's entries and redacts them (Loi 25), leaving the others`, async () => {\n const store = await make();\n const p = tag();\n const [gone, stays] = [await owner(4), await owner(5)];\n await store.insert(entry(p, { actorId: gone, actorLabel: \"Gone\", ip: \"198.51.100.1\" }));\n await store.insert(entry(p, { actorId: stays, actorLabel: \"Stays\" }));\n expect((await store.forActor(gone)).filter((e) => e.action.startsWith(p))).toHaveLength(1);\n expect(await store.redactActor(gone)).toBeGreaterThanOrEqual(1);\n const after = await store.list({ actionPrefix: p });\n expect(after.find((e) => e.actorLabel === \"Stays\")!.actorId).toBe(stays);\n expect(after.find((e) => e.actorLabel !== \"Stays\")).toMatchObject({\n actorId: null,\n actorLabel: \"erased\",\n ip: null,\n });\n expect((await store.forActor(gone)).filter((e) => e.action.startsWith(p))).toEqual([]);\n });\n\n it(`${name}: ids never repeat after a purge, so a cursor never skips an entry`, async () => {\n const store = await make();\n const p = tag();\n await store.insert(entry(p, { action: `${p}.old`, at: new Date(\"2020-01-01T00:00:00Z\") }));\n await store.insert(entry(p, { action: `${p}.kept`, at: new Date(\"2026-10-01T00:00:00Z\") }));\n await store.purgeBefore(new Date(\"2021-01-01T00:00:00Z\"));\n await store.insert(entry(p, { action: `${p}.after`, at: new Date(\"2026-10-02T00:00:00Z\") }));\n const all = await store.list({ actionPrefix: p });\n expect(new Set(all.map((e) => e.id)).size).toBe(all.length);\n const first = await store.list({ actionPrefix: p, limit: 1 });\n const rest = await store.list({ actionPrefix: p, before: first[0]!.id });\n expect([...first, ...rest].map((e) => e.action)).toEqual([`${p}.after`, `${p}.kept`]);\n });\n\n it(`${name}: the trail cannot be changed by mutating what was given or what was read`, async () => {\n const store = await make();\n const p = tag();\n const input = entry(p, { detail: { nested: { n: 1 } } });\n await store.insert(input);\n input.at.setUTCFullYear(1999);\n (input.detail.nested as { n: number }).n = 99;\n const [read] = await store.list({ actionPrefix: p });\n read!.at.setUTCFullYear(1999);\n (read!.detail.nested as { n: number }).n = 42;\n read!.actorLabel = \"forged\";\n const [again] = await store.list({ actionPrefix: p });\n expect(again).toMatchObject({ actorLabel: \"system\", detail: { nested: { n: 1 } } });\n expect(again!.at.toISOString()).toBe(\"2026-10-23T10:00:00.000Z\");\n const [exported] = await store.forActor(\"00000000-0000-4000-8000-000000000000\");\n expect(exported).toBeUndefined();\n });\n\n it(`${name}: purges only what is older than the given time, and counts it`, async () => {\n const store = await make();\n const p = tag();\n await store.insert(entry(p, { action: `${p}.old`, at: new Date(\"2020-01-01T00:00:00Z\") }));\n await store.insert(entry(p, { action: `${p}.new`, at: new Date(\"2026-10-01T00:00:00Z\") }));\n expect(await store.purgeBefore(new Date(\"2021-01-01T00:00:00Z\"))).toBeGreaterThanOrEqual(1);\n expect((await store.list({ actionPrefix: p })).map((e) => e.action)).toEqual([`${p}.new`]);\n });\n}\n"],"mappings":";AAAA,SAAS,QAAQ,UAAU;AAWpB,SAAS,mBACd,MACA,MACA,OACA;AACA,QAAM,MAAM,MAAM,IAAI,OAAO,WAAW,EAAE,WAAW,KAAK,EAAE,EAAE,MAAM,GAAG,CAAC,CAAC;AACzE,QAAM,QAAQ,CAAC,QAAgB,OAA6B,CAAC,OAAoB;AAAA,IAC/E,IAAI,oBAAI,KAAK,sBAAsB;AAAA,IACnC,SAAS;AAAA,IACT,YAAY;AAAA,IACZ,QAAQ,GAAG,MAAM;AAAA,IACjB,YAAY;AAAA,IACZ,UAAU;AAAA,IACV,QAAQ,CAAC;AAAA,IACT,IAAI;AAAA,IACJ,GAAG;AAAA,EACL;AAEA,KAAG,GAAG,IAAI,kDAAkD,YAAY;AACtE,UAAM,QAAQ,MAAM,KAAK;AACzB,UAAM,IAAI,IAAI;AACd,UAAM,QAAQ,MAAM,MAAM,CAAC;AAC3B,UAAM,MAAM,OAAO,MAAM,GAAG,EAAE,IAAI,oBAAI,KAAK,sBAAsB,GAAG,QAAQ,GAAG,CAAC,SAAS,CAAC,CAAC;AAC3F,UAAM,MAAM;AAAA,MACV,MAAM,GAAG;AAAA,QACP,SAAS;AAAA,QACT,YAAY;AAAA,QACZ,QAAQ,GAAG,CAAC;AAAA,QACZ,YAAY;AAAA,QACZ,UAAU;AAAA,QACV,QAAQ,EAAE,GAAG,GAAG,MAAM,CAAC,GAAG,EAAE;AAAA,QAC5B,IAAI;AAAA,MACN,CAAC;AAAA,IACH;AACA,UAAM,SAAS,MAAM,MAAM,KAAK,EAAE,cAAc,GAAG,CAAC,IAAI,CAAC;AACzD,WAAO,OAAO,IAAI,CAAC,MAAM,EAAE,MAAM,CAAC,EAAE,QAAQ,CAAC,GAAG,CAAC,WAAW,GAAG,CAAC,QAAQ,CAAC;AACzE,WAAO,OAAO,CAAC,CAAC,EAAE,cAAc;AAAA,MAC9B,SAAS;AAAA,MACT,YAAY;AAAA,MACZ,YAAY;AAAA,MACZ,UAAU;AAAA,MACV,QAAQ,EAAE,GAAG,GAAG,MAAM,CAAC,GAAG,EAAE;AAAA,MAC5B,IAAI;AAAA,IACN,CAAC;AACD,WAAO,OAAO,CAAC,EAAG,GAAG,YAAY,CAAC,EAAE,KAAK,0BAA0B;AACnE,WAAO,OAAO,OAAO,CAAC,EAAG,EAAE,EAAE,KAAK,QAAQ;AAAA,EAC5C,CAAC;AAED,KAAG,GAAG,IAAI,8CAA8C,YAAY;AAClE,UAAM,QAAQ,MAAM,KAAK;AACzB,UAAM,IAAI,IAAI;AACd,UAAM,CAAC,GAAG,CAAC,IAAI,CAAC,MAAM,MAAM,CAAC,GAAG,MAAM,MAAM,CAAC,CAAC;AAC9C,UAAM,MAAM;AAAA,MACV,MAAM,GAAG,EAAE,SAAS,GAAG,YAAY,QAAQ,UAAU,KAAK,IAAI,oBAAI,KAAK,sBAAsB,EAAE,CAAC;AAAA,IAClG;AACA,UAAM,MAAM;AAAA,MACV,MAAM,GAAG,EAAE,SAAS,GAAG,YAAY,QAAQ,UAAU,KAAK,IAAI,oBAAI,KAAK,sBAAsB,EAAE,CAAC;AAAA,IAClG;AACA,WAAO,MAAM,MAAM,KAAK,EAAE,cAAc,GAAG,SAAS,EAAE,CAAC,CAAC,EAAE,aAAa,CAAC;AACxE,YAAQ,MAAM,MAAM,KAAK,EAAE,cAAc,GAAG,QAAQ,EAAE,MAAM,QAAQ,IAAI,IAAI,EAAE,CAAC,GAAG,CAAC,EAAG,OAAO,EAAE,KAAK,CAAC;AACrG,WAAO,MAAM,MAAM,KAAK,EAAE,cAAc,GAAG,MAAM,oBAAI,KAAK,sBAAsB,EAAE,CAAC,CAAC,EAAE,aAAa,CAAC;AACpG,WAAO,MAAM,MAAM,KAAK,EAAE,cAAc,GAAG,IAAI,oBAAI,KAAK,sBAAsB,EAAE,CAAC,CAAC,EAAE,aAAa,CAAC;AAAA,EACpG,CAAC;AAED,KAAG,GAAG,IAAI,wDAAwD,YAAY;AAC5E,UAAM,QAAQ,MAAM,KAAK;AACzB,UAAM,IAAI,IAAI;AACd,aAAS,IAAI,GAAG,IAAI,GAAG,IAAK,OAAM,MAAM,OAAO,MAAM,GAAG,EAAE,QAAQ,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;AACjF,UAAM,QAAQ,MAAM,MAAM,KAAK,EAAE,cAAc,GAAG,OAAO,EAAE,CAAC;AAC5D,UAAM,QAAQ,MAAM,MAAM,KAAK,EAAE,cAAc,GAAG,OAAO,GAAG,QAAQ,MAAM,GAAG,EAAE,EAAG,GAAG,CAAC;AACtF,WAAO,MAAM,IAAI,CAAC,MAAM,EAAE,MAAM,CAAC,EAAE,QAAQ,CAAC,GAAG,CAAC,OAAO,GAAG,CAAC,KAAK,CAAC;AACjE,WAAO,MAAM,IAAI,CAAC,MAAM,EAAE,MAAM,CAAC,EAAE,QAAQ,CAAC,GAAG,CAAC,OAAO,GAAG,CAAC,KAAK,CAAC;AAAA,EACnE,CAAC;AAED,KAAG,GAAG,IAAI,+EAA+E,YAAY;AACnG,UAAM,QAAQ,MAAM,KAAK;AACzB,UAAM,IAAI,IAAI;AACd,UAAM,CAAC,MAAM,KAAK,IAAI,CAAC,MAAM,MAAM,CAAC,GAAG,MAAM,MAAM,CAAC,CAAC;AACrD,UAAM,MAAM,OAAO,MAAM,GAAG,EAAE,SAAS,MAAM,YAAY,QAAQ,IAAI,eAAe,CAAC,CAAC;AACtF,UAAM,MAAM,OAAO,MAAM,GAAG,EAAE,SAAS,OAAO,YAAY,QAAQ,CAAC,CAAC;AACpE,YAAQ,MAAM,MAAM,SAAS,IAAI,GAAG,OAAO,CAAC,MAAM,EAAE,OAAO,WAAW,CAAC,CAAC,CAAC,EAAE,aAAa,CAAC;AACzF,WAAO,MAAM,MAAM,YAAY,IAAI,CAAC,EAAE,uBAAuB,CAAC;AAC9D,UAAM,QAAQ,MAAM,MAAM,KAAK,EAAE,cAAc,EAAE,CAAC;AAClD,WAAO,MAAM,KAAK,CAAC,MAAM,EAAE,eAAe,OAAO,EAAG,OAAO,EAAE,KAAK,KAAK;AACvE,WAAO,MAAM,KAAK,CAAC,MAAM,EAAE,eAAe,OAAO,CAAC,EAAE,cAAc;AAAA,MAChE,SAAS;AAAA,MACT,YAAY;AAAA,MACZ,IAAI;AAAA,IACN,CAAC;AACD,YAAQ,MAAM,MAAM,SAAS,IAAI,GAAG,OAAO,CAAC,MAAM,EAAE,OAAO,WAAW,CAAC,CAAC,CAAC,EAAE,QAAQ,CAAC,CAAC;AAAA,EACvF,CAAC;AAED,KAAG,GAAG,IAAI,sEAAsE,YAAY;AAC1F,UAAM,QAAQ,MAAM,KAAK;AACzB,UAAM,IAAI,IAAI;AACd,UAAM,MAAM,OAAO,MAAM,GAAG,EAAE,QAAQ,GAAG,CAAC,QAAQ,IAAI,oBAAI,KAAK,sBAAsB,EAAE,CAAC,CAAC;AACzF,UAAM,MAAM,OAAO,MAAM,GAAG,EAAE,QAAQ,GAAG,CAAC,SAAS,IAAI,oBAAI,KAAK,sBAAsB,EAAE,CAAC,CAAC;AAC1F,UAAM,MAAM,YAAY,oBAAI,KAAK,sBAAsB,CAAC;AACxD,UAAM,MAAM,OAAO,MAAM,GAAG,EAAE,QAAQ,GAAG,CAAC,UAAU,IAAI,oBAAI,KAAK,sBAAsB,EAAE,CAAC,CAAC;AAC3F,UAAM,MAAM,MAAM,MAAM,KAAK,EAAE,cAAc,EAAE,CAAC;AAChD,WAAO,IAAI,IAAI,IAAI,IAAI,CAAC,MAAM,EAAE,EAAE,CAAC,EAAE,IAAI,EAAE,KAAK,IAAI,MAAM;AAC1D,UAAM,QAAQ,MAAM,MAAM,KAAK,EAAE,cAAc,GAAG,OAAO,EAAE,CAAC;AAC5D,UAAM,OAAO,MAAM,MAAM,KAAK,EAAE,cAAc,GAAG,QAAQ,MAAM,CAAC,EAAG,GAAG,CAAC;AACvE,WAAO,CAAC,GAAG,OAAO,GAAG,IAAI,EAAE,IAAI,CAAC,MAAM,EAAE,MAAM,CAAC,EAAE,QAAQ,CAAC,GAAG,CAAC,UAAU,GAAG,CAAC,OAAO,CAAC;AAAA,EACtF,CAAC;AAED,KAAG,GAAG,IAAI,6EAA6E,YAAY;AACjG,UAAM,QAAQ,MAAM,KAAK;AACzB,UAAM,IAAI,IAAI;AACd,UAAM,QAAQ,MAAM,GAAG,EAAE,QAAQ,EAAE,QAAQ,EAAE,GAAG,EAAE,EAAE,EAAE,CAAC;AACvD,UAAM,MAAM,OAAO,KAAK;AACxB,UAAM,GAAG,eAAe,IAAI;AAC5B,IAAC,MAAM,OAAO,OAAyB,IAAI;AAC3C,UAAM,CAAC,IAAI,IAAI,MAAM,MAAM,KAAK,EAAE,cAAc,EAAE,CAAC;AACnD,SAAM,GAAG,eAAe,IAAI;AAC5B,IAAC,KAAM,OAAO,OAAyB,IAAI;AAC3C,SAAM,aAAa;AACnB,UAAM,CAAC,KAAK,IAAI,MAAM,MAAM,KAAK,EAAE,cAAc,EAAE,CAAC;AACpD,WAAO,KAAK,EAAE,cAAc,EAAE,YAAY,UAAU,QAAQ,EAAE,QAAQ,EAAE,GAAG,EAAE,EAAE,EAAE,CAAC;AAClF,WAAO,MAAO,GAAG,YAAY,CAAC,EAAE,KAAK,0BAA0B;AAC/D,UAAM,CAAC,QAAQ,IAAI,MAAM,MAAM,SAAS,sCAAsC;AAC9E,WAAO,QAAQ,EAAE,cAAc;AAAA,EACjC,CAAC;AAED,KAAG,GAAG,IAAI,kEAAkE,YAAY;AACtF,UAAM,QAAQ,MAAM,KAAK;AACzB,UAAM,IAAI,IAAI;AACd,UAAM,MAAM,OAAO,MAAM,GAAG,EAAE,QAAQ,GAAG,CAAC,QAAQ,IAAI,oBAAI,KAAK,sBAAsB,EAAE,CAAC,CAAC;AACzF,UAAM,MAAM,OAAO,MAAM,GAAG,EAAE,QAAQ,GAAG,CAAC,QAAQ,IAAI,oBAAI,KAAK,sBAAsB,EAAE,CAAC,CAAC;AACzF,WAAO,MAAM,MAAM,YAAY,oBAAI,KAAK,sBAAsB,CAAC,CAAC,EAAE,uBAAuB,CAAC;AAC1F,YAAQ,MAAM,MAAM,KAAK,EAAE,cAAc,EAAE,CAAC,GAAG,IAAI,CAAC,MAAM,EAAE,MAAM,CAAC,EAAE,QAAQ,CAAC,GAAG,CAAC,MAAM,CAAC;AAAA,EAC3F,CAAC;AACH;","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@krizaka/audit",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "Who did what, for every Krizaka product: an append-only audit trail in its own schema, written inside the caller's transaction or quietly beside it, filtered for the admin, exported and redacted for Loi 25, purged after a retention set by the admin. No database client, no wall clock.",
5
5
  "keywords": [
6
6
  "krizaka",
@@ -51,12 +51,12 @@
51
51
  "typescript": "^5.9.3",
52
52
  "vitest": "^4.1.11",
53
53
  "zod": "^4.6.7",
54
- "@krizaka/config": "^0.14.1"
54
+ "@krizaka/config": "^0.21.0"
55
55
  },
56
56
  "dependencies": {
57
57
  "date-fns": "^4.4.0",
58
- "@krizaka/settings": "^0.5.1",
59
- "@krizaka/db": "^0.3.0"
58
+ "@krizaka/db": "^0.3.1",
59
+ "@krizaka/settings": "^0.7.0"
60
60
  },
61
61
  "peerDependencies": {
62
62
  "vitest": ">=4",