@lenso/audit 0.0.0-stage → 0.2.1
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/CHECKS.md +84 -0
- package/README.md +240 -2
- package/dist/auth.d.ts +7 -0
- package/dist/auth.js +16 -0
- package/dist/contracts.d.ts +102 -0
- package/dist/diagnostics.d.ts +6 -0
- package/dist/diagnostics.js +22 -0
- package/dist/index-9cajwcjn.js +19 -0
- package/dist/index-dxwdr6tz.js +46 -0
- package/dist/index-ph1dkars.js +169 -0
- package/dist/index-xk4zknet.js +191 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +16 -0
- package/dist/manage.d.ts +119 -0
- package/dist/manage.js +59 -0
- package/dist/plugin.d.ts +20 -0
- package/dist/plugin.js +37 -0
- package/dist/postgres.d.ts +361 -0
- package/dist/postgres.js +80 -0
- package/dist/repository-helpers.d.ts +4 -0
- package/dist/service.d.ts +49 -0
- package/dist/sqlite.d.ts +398 -0
- package/dist/sqlite.js +80 -0
- package/dist/tasks.d.ts +26 -0
- package/dist/tasks.js +40 -0
- package/dist/validation.d.ts +16 -0
- package/migrations/pg/0000_audit.sql +15 -0
- package/migrations/sqlite/0000_audit.sql +15 -0
- package/package.json +104 -4
package/CHECKS.md
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# Audit validation
|
|
2
|
+
|
|
3
|
+
## Passed in this checkout
|
|
4
|
+
|
|
5
|
+
- Bun 1.4.2 with repository-pinned dependency versions.
|
|
6
|
+
- Required framework dependency builds, then `bun run --cwd packages/audit build`.
|
|
7
|
+
- `bun run --cwd packages/audit typecheck`.
|
|
8
|
+
- `bun run --cwd examples/notes typecheck` and `bun run --cwd examples/notes build`.
|
|
9
|
+
- `LENSO_REQUIRE_POSTGRES=1 bun test packages/audit/test`: **15 passed, 0 failed**.
|
|
10
|
+
- Real Bun SQLite: reopen persistence, duplicates/conflicts, exact tenant/scope
|
|
11
|
+
isolation, filters and tied-time keyset pagination.
|
|
12
|
+
- Real disposable PostgreSQL: independent connections, concurrent duplicate
|
|
13
|
+
inserts, JSON roundtrip, scope/filter/page checks; `fsync=on` and
|
|
14
|
+
`synchronous_commit=on`; a strict intent is visible from another connection
|
|
15
|
+
before continuing, and a duplicate intent never returns another receipt.
|
|
16
|
+
- Actual local Miniflare/workerd D1 binding: explicit migration, insert/returning,
|
|
17
|
+
duplicate/conflict lookup, tenant/scope isolation, filters and pagination.
|
|
18
|
+
- Auth: genuine minted actors, copied/foreign-audience/foreign-runtime actors,
|
|
19
|
+
revoked sessions, scope denial and Auth-valid opaque subject/issuer IDs.
|
|
20
|
+
- Whitelisting/length limits, client identity rejection, append-only correction,
|
|
21
|
+
safe failure reporting, strict admission, post-effect unknown, system subjects.
|
|
22
|
+
- Exact plugin dependencies, invalid-config preflight, owned stop/rollback
|
|
23
|
+
cleanup and borrowed database survival.
|
|
24
|
+
- Existing Notes removal through real Manage/Engine/Auth/SQLite, including
|
|
25
|
+
cross-owner denial; Audit query-only companion, scope denial, no counts,
|
|
26
|
+
secret/body omission and 129-character target-ID filtering.
|
|
27
|
+
- Tasks reconciliation registration: locator-only payload, per-attempt trusted
|
|
28
|
+
principal, linked stable outcome and denied unauthorized job attempts.
|
|
29
|
+
- Standalone root bundled without optional framework/provider imports.
|
|
30
|
+
- `bun test examples/notes/test/notes.test.ts examples/notes/test/operations.test.ts examples/notes/test/manage.test.ts`:
|
|
31
|
+
**7 passed, 0 failed**, including real CLI inspect/call subprocesses.
|
|
32
|
+
- `bun packages/cli/src/bin.ts inspect notes-operations remove --root examples/notes --json`:
|
|
33
|
+
existing strict business input and unchanged opt-out assembly.
|
|
34
|
+
- Focused `oxlint --deny-warnings`: **0 warnings, 0 errors**.
|
|
35
|
+
- Focused `oxfmt --check` and `git diff --check`: passed.
|
|
36
|
+
|
|
37
|
+
## Clean-install integration
|
|
38
|
+
|
|
39
|
+
After explicit authorization, the single root `bun.lock` was regenerated with
|
|
40
|
+
`bun install --lockfile-only --ignore-scripts`. Its changes are the Audit
|
|
41
|
+
workspace, Notes' Audit dev dependency, and existing Auth/Manage manifest peer
|
|
42
|
+
ranges that were already `^0.2.0` but stale in the previous lock. No external
|
|
43
|
+
dependency version or public Auth/Tasks/Manage source was changed.
|
|
44
|
+
|
|
45
|
+
A disposable non-Git source tree was copied from the current workspace without
|
|
46
|
+
`node_modules`, `dist` or Turbo cache. It passed:
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
bun install --frozen-lockfile
|
|
50
|
+
bun run build --filter=@lenso/audit... --filter=@lenso/example-notes... --concurrency=1 --cache=local:rw
|
|
51
|
+
bun run --cwd packages/audit typecheck
|
|
52
|
+
bun run --cwd examples/notes typecheck
|
|
53
|
+
LENSO_REQUIRE_POSTGRES=1 bun test packages/audit/test
|
|
54
|
+
bun test examples/notes/test/notes.test.ts examples/notes/test/operations.test.ts examples/notes/test/manage.test.ts
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
All **14 selected package builds** ran successfully on the first build, with
|
|
58
|
+
no cache hits and remote caching disabled. Audit again passed **15 tests**,
|
|
59
|
+
including actual PostgreSQL and local workerd D1; Notes again passed **7 tests**.
|
|
60
|
+
Focused lint and formatting checks also passed there. The frozen installation
|
|
61
|
+
left the copied lock byte-identical to the updated workspace lock.
|
|
62
|
+
|
|
63
|
+
## Not claimed or verified
|
|
64
|
+
|
|
65
|
+
- No cloud D1 deployment, replication/failover, production PostgreSQL/SQLite
|
|
66
|
+
durability, power-loss recovery, or broad platform compatibility.
|
|
67
|
+
- No business/Audit atomic transaction or outbox integration. Notes uses
|
|
68
|
+
best-effort; strict is explicit persisted-intent admission, not rollback or
|
|
69
|
+
exactly-once external execution.
|
|
70
|
+
- No real notifications, payments, production mutations, credential provisioning,
|
|
71
|
+
publishing, pushing or merging.
|
|
72
|
+
- Tasks registration/handler behavior was checked, **not a new durable Tasks
|
|
73
|
+
provider/end-to-end queue run**. Use the existing provider and its tests;
|
|
74
|
+
queue enqueue is an independent write and cannot replace a missing intent.
|
|
75
|
+
- No OTel SDK/exporter end-to-end run; diagnostics reuse the existing API and
|
|
76
|
+
supplied logger, without acquiring or closing an SDK.
|
|
77
|
+
- No full-repository test/release suite, browser tests or compliance claim.
|
|
78
|
+
|
|
79
|
+
## Remaining consumer setup
|
|
80
|
+
|
|
81
|
+
Apply the selected Audit SQL baseline through the consumer's existing migration
|
|
82
|
+
history, supply the actual scope policy and diagnostic sink, and explicitly opt
|
|
83
|
+
in to the Notes Audit dependency or query companion. No default management
|
|
84
|
+
entry, listener, automatic migration or retry was enabled.
|
package/README.md
CHANGED
|
@@ -1,3 +1,241 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Audit
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
`@lenso/audit` records who did what to which resource, in which scope, when, and
|
|
4
|
+
with what outcome. Log/OTel remains the runtime diagnostic channel. Audit is not
|
|
5
|
+
an authorization engine, compliance certification, tamper-proof ledger, or a
|
|
6
|
+
guarantee of lossless delivery.
|
|
7
|
+
|
|
8
|
+
## Entries and ownership
|
|
9
|
+
|
|
10
|
+
| Entry | Purpose | Additional packages |
|
|
11
|
+
| -------------- | -------------------------------------------------------------- | ------------------------- |
|
|
12
|
+
| `@lenso/audit` | Ordinary async service and contracts | None |
|
|
13
|
+
| `/auth` | Revalidate an actual Auth actor and exact scope policy | Auth (type-only) |
|
|
14
|
+
| `/plugin` | Thin exact-instance registration and page-size Config contract | Core, Zod |
|
|
15
|
+
| `/sqlite` | Drizzle Bun SQLite or D1 repository and schema | Drizzle |
|
|
16
|
+
| `/postgres` | Drizzle Bun SQL PostgreSQL repository and schema | Drizzle |
|
|
17
|
+
| `/diagnostics` | Bounded OTel failure counter and supplied logger | OTel API |
|
|
18
|
+
| `/manage` | Explicit query-only companion, no automatic entry | Core, Engine, Manage, Zod |
|
|
19
|
+
| `/tasks` | Existing Tasks reconciliation registration | Tasks, Zod |
|
|
20
|
+
|
|
21
|
+
Root imports do not load optional integrations. Install only the peers for entries
|
|
22
|
+
you use. Repositories borrow databases and never close them. Use the existing DB
|
|
23
|
+
resource plugins to own connections; they register cleanup during setup. Startup
|
|
24
|
+
does not run migrations. Drain application calls before stopping their DB owner.
|
|
25
|
+
|
|
26
|
+
## Ordinary service
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
import { createAuditService } from "@lenso/audit";
|
|
30
|
+
import { createAuthAuditAuthority } from "@lenso/audit/auth";
|
|
31
|
+
import { createSqliteAuditRepository } from "@lenso/audit/sqlite";
|
|
32
|
+
import { createAuditReporter } from "@lenso/audit/diagnostics";
|
|
33
|
+
|
|
34
|
+
// db, access and logger are existing, explicitly supplied application instances.
|
|
35
|
+
const audit = createAuditService({
|
|
36
|
+
repository: createSqliteAuditRepository(db),
|
|
37
|
+
authority: createAuthAuditAuthority(
|
|
38
|
+
access,
|
|
39
|
+
({ principal, scope }) =>
|
|
40
|
+
principal.kind === "user" &&
|
|
41
|
+
scope.tenantId === null &&
|
|
42
|
+
scope.scopeId === `owner:${principal.subjectId}`,
|
|
43
|
+
),
|
|
44
|
+
summaryPolicy: { "notes.remove": { removed: { type: "boolean" } } },
|
|
45
|
+
report: createAuditReporter({ logger }),
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// actor comes from access.required(trustedRequestEvidence), never business JSON.
|
|
49
|
+
const status = await audit.appendBestEffort(
|
|
50
|
+
{
|
|
51
|
+
id: crypto.randomUUID(),
|
|
52
|
+
occurredAt: Date.now(),
|
|
53
|
+
scope: { tenantId: null, scopeId: `owner:${actor.subjectId}` },
|
|
54
|
+
action: "notes.remove",
|
|
55
|
+
target: { type: "note", id: noteId },
|
|
56
|
+
result: "success",
|
|
57
|
+
reasonCode: "removed",
|
|
58
|
+
summary: { removed: true },
|
|
59
|
+
},
|
|
60
|
+
actor,
|
|
61
|
+
);
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
`AuditAuthority.resolve` is the trusted server boundary: it must authenticate,
|
|
65
|
+
revalidate and authorize the requested `append` or `query` scope, returning only
|
|
66
|
+
an identity snapshot. The Auth companion calls the existing `Access.enforce`,
|
|
67
|
+
so copied actors, another audience/runtime and revoked sessions fail. Custom
|
|
68
|
+
authorities can use a server-owned system principal and explicitly return
|
|
69
|
+
`{kind:"system", systemId:"maintenance"}`; missing identity is an error, not a
|
|
70
|
+
fabricated user. Do not implement an authority by trusting a JSON actor or
|
|
71
|
+
unconditionally accepting an arbitrary caller object.
|
|
72
|
+
|
|
73
|
+
Events require a stable lowercase UUID, server-supplied occurrence time
|
|
74
|
+
(integer milliseconds), action, target, result and reason code. Recording time
|
|
75
|
+
comes from the service clock. They optionally carry correlation and a relation.
|
|
76
|
+
Subject, target, scope and correlation IDs must be opaque identifiers, not
|
|
77
|
+
credentials, signed URLs, email addresses or request content. Business identifiers are
|
|
78
|
+
bounded ASCII (`A-Z`, `a-z`, digits, `.`, `_`, `:`, `/`, `-`), with no URL scheme.
|
|
79
|
+
Targets permit 256 characters; reason codes 64. Trusted Auth identity snapshots
|
|
80
|
+
retain Auth's own bounded realm/subject grammar (256/512 characters), including
|
|
81
|
+
opaque subject IDs such as `auth0|alice` and issuer URI realms. Map such subjects
|
|
82
|
+
to an application-owned scope identifier if they are not scope-token compatible;
|
|
83
|
+
do not rewrite or hash credentials to manufacture an audit identity.
|
|
84
|
+
|
|
85
|
+
Summaries default to empty. Each action explicitly whitelists at most 16 fields
|
|
86
|
+
of boolean, bounded integer or fixed enum values (up to 32 literals of 64
|
|
87
|
+
characters). No free-form text, nested input, full body, credential hash/digest,
|
|
88
|
+
or arbitrary error text is accepted. Credential/body/PII-shaped field names are
|
|
89
|
+
rejected even when configured. Applications must still choose safe enum literals
|
|
90
|
+
and identifiers: validation cannot recognize a secret disguised as an opaque ID.
|
|
91
|
+
Unknown fields, missing tenant declaration, malformed actors and oversized data
|
|
92
|
+
fail explicitly rather than silently disappearing.
|
|
93
|
+
|
|
94
|
+
## Queries, corrections and duplicates
|
|
95
|
+
|
|
96
|
+
Every `get({scope,id}, principal)` and `query({scope,...}, principal)` requires
|
|
97
|
+
an explicit exact scope, including for administrators. Public resources use
|
|
98
|
+
`tenantId: null`; tenant-bound resources require their actual tenant. Neither
|
|
99
|
+
missing tenant nor missing scope means all resources. The authority must enforce
|
|
100
|
+
actual membership/permission, not a client-provided tenant assertion.
|
|
101
|
+
|
|
102
|
+
Query filters are action, exact target, result, correlation and inclusive
|
|
103
|
+
recording-time bounds. Pages use descending `(recordedAt,id)` keyset cursors.
|
|
104
|
+
Default limit is 50, configurable maximum defaults to 100 (hard ceiling 500).
|
|
105
|
+
No global count is returned. A cursor is only a position within the requested
|
|
106
|
+
authorized query, not a permission token or snapshot; concurrent appends or clock
|
|
107
|
+
changes can change later pages. Do not use pagination as a complete frozen export.
|
|
108
|
+
|
|
109
|
+
The repository key is `(tenant namespace, scopeId, id)`, including a distinct
|
|
110
|
+
namespace for null tenants. Repeating identical immutable content returns
|
|
111
|
+
`duplicate` and the original recording time; changed content at that key fails
|
|
112
|
+
`duplicate-conflict`. There is no cross-scope ID-existence oracle.
|
|
113
|
+
|
|
114
|
+
The service and repositories have no update/delete API. Append a new event with
|
|
115
|
+
`relation: {kind:"correction", eventId:originalId}` to correct an existing event
|
|
116
|
+
in the same scope. Results use `kind:"outcome"` to link to an intent for the same
|
|
117
|
+
action and target. This does not prevent DB administrators, other SQL writers,
|
|
118
|
+
backups or retention workflows from changing/removing underlying data.
|
|
119
|
+
|
|
120
|
+
## Strict versus best-effort
|
|
121
|
+
|
|
122
|
+
- `append` propagates safe persistence errors; a failed acknowledgement may
|
|
123
|
+
mean the row was committed. Retry only the **same event ID and content**.
|
|
124
|
+
- `appendBestEffort` requires a diagnostic reporter. A storage failure produces
|
|
125
|
+
`status:"unconfirmed"` and a safe failure metric/log; it does not assert the
|
|
126
|
+
row is absent. Authorization, malformed data and duplicate conflicts still
|
|
127
|
+
fail. The supplied reporter must have a working sink; reporter failures are
|
|
128
|
+
not swallowed. `/diagnostics` requires a logger and emits only fixed
|
|
129
|
+
mode/stage/code labels, never actor, tenant, target, summary or driver errors.
|
|
130
|
+
OTel exporters/SDK ownership stay with the existing application.
|
|
131
|
+
- `prepare` is the strict pre-effect gate. It is disabled unless the repository
|
|
132
|
+
owner explicitly attests `durableIntents:true`. A newly acknowledged intent
|
|
133
|
+
returns `ready` with a service-issued receipt. Duplicate intent returns
|
|
134
|
+
`already-recorded`, **never permission to repeat the business effect**.
|
|
135
|
+
|
|
136
|
+
```ts
|
|
137
|
+
const prepared = await audit.prepare(intentEvent, actor); // result:"intent"
|
|
138
|
+
if (prepared.status !== "ready") {
|
|
139
|
+
// Consult the stored outcome/reconcile. Do not rerun the effect.
|
|
140
|
+
return { state: "pending-reconciliation", intentId: prepared.intentId };
|
|
141
|
+
}
|
|
142
|
+
// Business authorization is still required at the real effect boundary.
|
|
143
|
+
const result = await alreadyAuthorizedEffect();
|
|
144
|
+
await audit.complete(prepared.receipt, {
|
|
145
|
+
id: stableOutcomeId,
|
|
146
|
+
occurredAt: Date.now(),
|
|
147
|
+
result: "success",
|
|
148
|
+
reasonCode: "completed",
|
|
149
|
+
});
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
The application owns effect classification. A rejected/timeout external call
|
|
153
|
+
may already have taken effect: append `unknown`, not an invented failure/rollback.
|
|
154
|
+
If `complete` cannot confirm the result, it throws `AuditOutcomeUnknownError`
|
|
155
|
+
with the persisted intent ID. The effect is not rolled back, automatically retried
|
|
156
|
+
or hidden by a successful response. Outcome recording uses the receipt's trusted
|
|
157
|
+
pre-effect identity snapshot, including if a session is revoked after the effect.
|
|
158
|
+
Receipts are instance-local, not serializable authorization grants.
|
|
159
|
+
|
|
160
|
+
`durableIntents` is a deployment assertion, not a capability detected by
|
|
161
|
+
Drizzle. Do not enable it for memory-only SQLite, uncommitted transaction handles,
|
|
162
|
+
asynchronously replicated acknowledgement paths, or unverified durability
|
|
163
|
+
settings. PostgreSQL requires an acknowledged commit with appropriate deployment
|
|
164
|
+
durability; SQLite requires a persistent database and deliberate sync settings;
|
|
165
|
+
D1 requires the actual binding's committed write semantics. A timed-out write
|
|
166
|
+
does not authorize continuing. No adapter combines independent business/Audit
|
|
167
|
+
writes into a transaction. PostgreSQL transactions and D1 batch atomicity apply
|
|
168
|
+
only when the application really submits both operations together through that
|
|
169
|
+
existing mechanism; this package does not provide that integration.
|
|
170
|
+
|
|
171
|
+
For durable supplementary work, `/tasks` defines a reconciliation task carrying
|
|
172
|
+
only `{scope,intentId}`. Its application-owned principal is reauthorized; the
|
|
173
|
+
resolver reads the real effect, then appends a linked outcome with a stable ID
|
|
174
|
+
and occurrence time. It must not repeat the effect. Install/enqueue/drain it
|
|
175
|
+
through existing Tasks. Enqueue after an effect is another independent write:
|
|
176
|
+
if enqueue fails or the process crashes first, scan persisted intents using an
|
|
177
|
+
authorized application workflow. There is no automatic outbox or exactly-once
|
|
178
|
+
claim, and no durable actor/credential in the task payload.
|
|
179
|
+
|
|
180
|
+
## Optional Lenso and Manage wiring
|
|
181
|
+
|
|
182
|
+
`createAuditPlugin({id,repository,authority,diagnostics?,summaryPolicy?,config?})`
|
|
183
|
+
requires the **exact supplied plugin objects**. Config validates only
|
|
184
|
+
`maxPageSize`; trusted policy/providers remain code, not string DI or a global
|
|
185
|
+
Context. It creates no client, listener, SDK or queue worker.
|
|
186
|
+
|
|
187
|
+
`createAuditManage({id,audit})` returns a query sidecar, its one Operation and
|
|
188
|
+
Manage declaration. Creating it opens nothing. Install the sidecar and explicitly
|
|
189
|
+
select its operation for the chosen entry. Supply per-call trusted binding
|
|
190
|
+
`context:{principal:actualActor}` plus current-identity `canList`, then let the
|
|
191
|
+
service check scope. Default CLI/MCP/agent lists contain no Audit operations.
|
|
192
|
+
No append, complete event input, credentials or full request body is handed to
|
|
193
|
+
an agent. Query results still contain identities and resource IDs; select this
|
|
194
|
+
operation only for callers authorized to read that audit data.
|
|
195
|
+
|
|
196
|
+
The existing Notes factory accepts optional `audit` in
|
|
197
|
+
`createNotesOperations({notes,authentication,audit})`. Only its real `remove`
|
|
198
|
+
management operation records a best-effort event; Notes still performs Auth and
|
|
199
|
+
owner checks. Auth denials retain a known actor with a fixed reason code;
|
|
200
|
+
unclassified business-write errors are recorded as `unknown`, never a claimed
|
|
201
|
+
rollback. Assembly opts in to `notes.remove`'s `removed:boolean` summary and
|
|
202
|
+
owner scope as above. Omitting Audit preserves the existing graph. The focused
|
|
203
|
+
consumer test composes the existing Notes/Auth/DB/Manage services, not another
|
|
204
|
+
demo or authorization framework.
|
|
205
|
+
|
|
206
|
+
## Migrations and provider evidence
|
|
207
|
+
|
|
208
|
+
Apply `migrations/pg/0000_audit.sql` or `migrations/sqlite/0000_audit.sql` through
|
|
209
|
+
the application's explicit migration history before starting the consumer.
|
|
210
|
+
Exported `auditPostgresSchema`/`auditSqliteSchema` match those tables and index.
|
|
211
|
+
SQLite SQL is also intended for D1's normal migration runner; do not mix runner
|
|
212
|
+
histories. The repository uses explicit composite-conflict `DO NOTHING`,
|
|
213
|
+
`RETURNING`, and a fresh exact-scope duplicate read. It uses no update,
|
|
214
|
+
interactive D1 transaction, invented conditional-write abstraction or batch
|
|
215
|
+
rollback promise.
|
|
216
|
+
|
|
217
|
+
Official references checked against the pinned Drizzle 0.45.3 APIs:
|
|
218
|
+
[Drizzle insert](https://orm.drizzle.team/docs/insert),
|
|
219
|
+
[Bun SQLite](https://bun.sh/docs/api/sqlite),
|
|
220
|
+
[Bun SQL](https://bun.sh/docs/api/sql),
|
|
221
|
+
[PostgreSQL INSERT](https://www.postgresql.org/docs/current/sql-insert.html),
|
|
222
|
+
[SQLite RETURNING](https://www.sqlite.org/lang_returning.html),
|
|
223
|
+
[D1 database API](https://developers.cloudflare.com/d1/worker-api/d1-database/).
|
|
224
|
+
D1 `batch` is sequential and rollback-on-statement-failure; it does not cover
|
|
225
|
+
external effects. Local workerd tests do not verify cloud replication or
|
|
226
|
+
production crash durability.
|
|
227
|
+
|
|
228
|
+
Focused checks:
|
|
229
|
+
|
|
230
|
+
```sh
|
|
231
|
+
bun run --cwd packages/audit build
|
|
232
|
+
bun run --cwd packages/audit typecheck
|
|
233
|
+
LENSO_REQUIRE_POSTGRES=1 bun test packages/audit/test
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
Build Core/Auth/DB/Engine/Manage/Tasks and other consumer dependencies first,
|
|
237
|
+
because workspace public exports resolve to `dist`. PostgreSQL tests create
|
|
238
|
+
their own disposable loopback cluster, never use an inherited database URL.
|
|
239
|
+
D1 tests use owned local Miniflare/workerd bindings. Fault-injection tests verify
|
|
240
|
+
failure protocol only, not a real provider's durability. See `CHECKS.md` for
|
|
241
|
+
actual results and unverified boundaries.
|
package/dist/auth.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Access, Actor } from "@lenso/auth";
|
|
2
|
+
import type { AuditAuthority, AuditScope } from "./contracts";
|
|
3
|
+
export declare function createAuthAuditAuthority<R extends string, E, S extends string, A extends string>(access: Access<R, E, S, A>, policy: (input: {
|
|
4
|
+
principal: Actor<R, S, A>;
|
|
5
|
+
scope: AuditScope;
|
|
6
|
+
operation: "append" | "query";
|
|
7
|
+
}) => boolean | Promise<boolean>): AuditAuthority<Actor<R, S, A> | null>;
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// src/auth.ts
|
|
2
|
+
function createAuthAuditAuthority(access, policy) {
|
|
3
|
+
return {
|
|
4
|
+
async resolve(actor, scope, operation) {
|
|
5
|
+
const principal = await access.enforce(actor, scope, ({ principal: verified, resource }) => policy({ principal: verified, scope: resource, operation }));
|
|
6
|
+
return {
|
|
7
|
+
kind: principal.kind,
|
|
8
|
+
realmId: principal.realmId,
|
|
9
|
+
subjectId: principal.subjectId
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
export {
|
|
15
|
+
createAuthAuditAuthority
|
|
16
|
+
};
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
export interface AuditScope {
|
|
2
|
+
readonly tenantId: string | null;
|
|
3
|
+
readonly scopeId: string;
|
|
4
|
+
}
|
|
5
|
+
export type AuditActor = {
|
|
6
|
+
readonly kind: "user" | "guest" | "service";
|
|
7
|
+
readonly realmId: string;
|
|
8
|
+
readonly subjectId: string;
|
|
9
|
+
} | {
|
|
10
|
+
readonly kind: "system";
|
|
11
|
+
readonly systemId: string;
|
|
12
|
+
};
|
|
13
|
+
export type AuditResult = "intent" | "success" | "failure" | "denied" | "unknown";
|
|
14
|
+
export type SummaryValue = boolean | number | string;
|
|
15
|
+
export type SummaryRule = {
|
|
16
|
+
readonly type: "boolean";
|
|
17
|
+
} | {
|
|
18
|
+
readonly type: "integer";
|
|
19
|
+
readonly min: number;
|
|
20
|
+
readonly max: number;
|
|
21
|
+
} | {
|
|
22
|
+
readonly type: "enum";
|
|
23
|
+
readonly values: readonly string[];
|
|
24
|
+
};
|
|
25
|
+
export type SummaryPolicy = Readonly<Record<string, Readonly<Record<string, SummaryRule>>>>;
|
|
26
|
+
export interface AuditInput {
|
|
27
|
+
readonly id: string;
|
|
28
|
+
readonly occurredAt: number;
|
|
29
|
+
readonly scope: AuditScope;
|
|
30
|
+
readonly action: string;
|
|
31
|
+
readonly target: {
|
|
32
|
+
readonly type: string;
|
|
33
|
+
readonly id: string;
|
|
34
|
+
};
|
|
35
|
+
readonly result: AuditResult;
|
|
36
|
+
readonly reasonCode: string;
|
|
37
|
+
readonly correlationId?: string;
|
|
38
|
+
readonly summary?: Readonly<Record<string, SummaryValue>>;
|
|
39
|
+
readonly relation?: {
|
|
40
|
+
readonly kind: "correction" | "outcome";
|
|
41
|
+
readonly eventId: string;
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
export interface AuditEvent extends AuditInput {
|
|
45
|
+
readonly recordedAt: number;
|
|
46
|
+
readonly actor: AuditActor;
|
|
47
|
+
readonly summary: Readonly<Record<string, SummaryValue>>;
|
|
48
|
+
}
|
|
49
|
+
export interface AuditCursor {
|
|
50
|
+
readonly recordedAt: number;
|
|
51
|
+
readonly id: string;
|
|
52
|
+
}
|
|
53
|
+
export interface AuditQuery {
|
|
54
|
+
readonly scope: AuditScope;
|
|
55
|
+
readonly limit?: number;
|
|
56
|
+
readonly cursor?: AuditCursor;
|
|
57
|
+
readonly action?: string;
|
|
58
|
+
readonly target?: {
|
|
59
|
+
readonly type: string;
|
|
60
|
+
readonly id: string;
|
|
61
|
+
};
|
|
62
|
+
readonly result?: AuditResult;
|
|
63
|
+
readonly correlationId?: string;
|
|
64
|
+
readonly recordedFrom?: number;
|
|
65
|
+
readonly recordedTo?: number;
|
|
66
|
+
}
|
|
67
|
+
export interface AuditPage {
|
|
68
|
+
readonly events: readonly AuditEvent[];
|
|
69
|
+
readonly nextCursor: AuditCursor | null;
|
|
70
|
+
}
|
|
71
|
+
/** Trusted repository boundary. No mutation or global lookup is exposed. */
|
|
72
|
+
export interface AuditRepository {
|
|
73
|
+
readonly durableIntents: boolean;
|
|
74
|
+
insert(event: AuditEvent): Promise<"inserted" | "duplicate" | "conflict">;
|
|
75
|
+
get(scope: AuditScope, id: string): Promise<AuditEvent | null>;
|
|
76
|
+
list(query: AuditQuery & {
|
|
77
|
+
readonly limit: number;
|
|
78
|
+
}): Promise<readonly AuditEvent[]>;
|
|
79
|
+
}
|
|
80
|
+
export interface AuditAuthority<P> {
|
|
81
|
+
/** Authenticate/revalidate and authorize the exact scope, then return only an identity snapshot. */
|
|
82
|
+
resolve(principal: P, scope: AuditScope, operation: "append" | "query"): Promise<AuditActor>;
|
|
83
|
+
}
|
|
84
|
+
export interface AuditDiagnostic {
|
|
85
|
+
readonly mode: "strict" | "best-effort";
|
|
86
|
+
readonly stage: "intent" | "append" | "outcome";
|
|
87
|
+
readonly code: "storage-failed";
|
|
88
|
+
}
|
|
89
|
+
export type AuditReporter = (diagnostic: AuditDiagnostic) => void | Promise<void>;
|
|
90
|
+
export declare class AuditError extends Error {
|
|
91
|
+
readonly code: "invalid-input" | "unauthorized" | "duplicate-conflict" | "relation-missing" | "storage-failed" | "strict-unavailable" | "invalid-receipt" | "diagnostics-failed" | "diagnostics-required";
|
|
92
|
+
constructor(code: "invalid-input" | "unauthorized" | "duplicate-conflict" | "relation-missing" | "storage-failed" | "strict-unavailable" | "invalid-receipt" | "diagnostics-failed" | "diagnostics-required");
|
|
93
|
+
}
|
|
94
|
+
export declare class AuditOutcomeUnknownError extends Error {
|
|
95
|
+
readonly intentId: string;
|
|
96
|
+
readonly diagnosticsFailed: boolean;
|
|
97
|
+
readonly code = "outcome-unknown";
|
|
98
|
+
constructor(intentId: string, diagnosticsFailed?: boolean);
|
|
99
|
+
}
|
|
100
|
+
export declare function sameScope(a: AuditScope, b: AuditScope): boolean;
|
|
101
|
+
/** Recorded time is storage metadata, not part of an event's idempotency content. */
|
|
102
|
+
export declare function sameEvent(a: AuditEvent, b: AuditEvent): boolean;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// src/diagnostics.ts
|
|
2
|
+
import { metrics } from "@opentelemetry/api";
|
|
3
|
+
function createAuditReporter(options) {
|
|
4
|
+
const failures = metrics.getMeter("@lenso/audit").createCounter("lenso.audit.write_failures", {
|
|
5
|
+
description: "Audit persistence failures; no event or identity labels"
|
|
6
|
+
});
|
|
7
|
+
return (diagnostic) => {
|
|
8
|
+
const fields = {
|
|
9
|
+
mode: diagnostic.mode,
|
|
10
|
+
stage: diagnostic.stage,
|
|
11
|
+
code: diagnostic.code
|
|
12
|
+
};
|
|
13
|
+
try {
|
|
14
|
+
failures.add(1, fields);
|
|
15
|
+
} finally {
|
|
16
|
+
options.logger.warn(fields, "Audit persistence failed");
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
export {
|
|
21
|
+
createAuditReporter
|
|
22
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AuditError2,
|
|
3
|
+
sameEvent2
|
|
4
|
+
} from "./index-dxwdr6tz.js";
|
|
5
|
+
|
|
6
|
+
// src/repository-helpers.ts
|
|
7
|
+
function tenantKey(scope) {
|
|
8
|
+
return JSON.stringify(scope.tenantId);
|
|
9
|
+
}
|
|
10
|
+
function decodeEvent(value) {
|
|
11
|
+
return JSON.parse(value);
|
|
12
|
+
}
|
|
13
|
+
function insertionResult(event, existing) {
|
|
14
|
+
if (!existing)
|
|
15
|
+
throw new AuditError2("storage-failed");
|
|
16
|
+
return sameEvent2(event, existing) ? "duplicate" : "conflict";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export { tenantKey, decodeEvent, insertionResult };
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// src/contracts.ts
|
|
2
|
+
class AuditError2 extends Error {
|
|
3
|
+
code;
|
|
4
|
+
constructor(code) {
|
|
5
|
+
super(`Audit ${code}`);
|
|
6
|
+
this.code = code;
|
|
7
|
+
this.name = "AuditError";
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
class AuditOutcomeUnknownError2 extends Error {
|
|
12
|
+
intentId;
|
|
13
|
+
diagnosticsFailed;
|
|
14
|
+
code = "outcome-unknown";
|
|
15
|
+
constructor(intentId, diagnosticsFailed = false) {
|
|
16
|
+
super("Effect may have occurred; audit outcome is unknown and requires reconciliation");
|
|
17
|
+
this.intentId = intentId;
|
|
18
|
+
this.diagnosticsFailed = diagnosticsFailed;
|
|
19
|
+
this.name = "AuditOutcomeUnknownError";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function sameScope2(a, b) {
|
|
23
|
+
return a.tenantId === b.tenantId && a.scopeId === b.scopeId;
|
|
24
|
+
}
|
|
25
|
+
function sameEvent2(a, b) {
|
|
26
|
+
const content = (event) => ({
|
|
27
|
+
id: event.id,
|
|
28
|
+
occurredAt: event.occurredAt,
|
|
29
|
+
scope: { tenantId: event.scope.tenantId, scopeId: event.scope.scopeId },
|
|
30
|
+
actor: event.actor.kind === "system" ? { kind: event.actor.kind, systemId: event.actor.systemId } : {
|
|
31
|
+
kind: event.actor.kind,
|
|
32
|
+
realmId: event.actor.realmId,
|
|
33
|
+
subjectId: event.actor.subjectId
|
|
34
|
+
},
|
|
35
|
+
action: event.action,
|
|
36
|
+
target: { type: event.target.type, id: event.target.id },
|
|
37
|
+
result: event.result,
|
|
38
|
+
reasonCode: event.reasonCode,
|
|
39
|
+
correlationId: event.correlationId ?? null,
|
|
40
|
+
summary: Object.fromEntries(Object.entries(event.summary).sort(([left], [right]) => left.localeCompare(right))),
|
|
41
|
+
relation: event.relation ? { kind: event.relation.kind, eventId: event.relation.eventId } : null
|
|
42
|
+
});
|
|
43
|
+
return JSON.stringify(content(a)) === JSON.stringify(content(b));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export { AuditError2, AuditOutcomeUnknownError2, sameScope2, sameEvent2 };
|