@pithy-sh/audit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,110 @@
1
+ # Functional Source License, Version 1.1, MIT Future License
2
+
3
+ ## Abbreviation
4
+
5
+ FSL-1.1-MIT
6
+
7
+ ## Notice
8
+
9
+ Copyright 2026 Pithy
10
+
11
+ ## Terms and Conditions
12
+
13
+ ### Licensor ("We")
14
+
15
+ The party offering the Software under these Terms and Conditions.
16
+
17
+ ### The Software
18
+
19
+ The "Software" is each version of the software that we make available under
20
+ these Terms and Conditions, as indicated by our inclusion of these Terms and
21
+ Conditions with the Software.
22
+
23
+ ### License Grant
24
+
25
+ Subject to your compliance with this License Grant and the Patents,
26
+ Redistribution and Trademark clauses below, we hereby grant you the right to
27
+ use, copy, modify, create derivative works, publicly perform, publicly display
28
+ and redistribute the Software for any Permitted Purpose identified below.
29
+
30
+ ### Permitted Purpose
31
+
32
+ A Permitted Purpose is any purpose other than a Competing Use. A Competing Use
33
+ means making the Software available to others in a commercial product or
34
+ service that:
35
+
36
+ 1. substitutes for the Software;
37
+
38
+ 2. substitutes for any other product or service we offer using the Software
39
+ that exists as of the date we make the Software available; or
40
+
41
+ 3. offers the same or substantially similar functionality as the Software.
42
+
43
+ Permitted Purposes specifically include using the Software:
44
+
45
+ 1. for your internal use and access;
46
+
47
+ 2. for non-commercial education;
48
+
49
+ 3. for non-commercial research; and
50
+
51
+ 4. in connection with professional services that you provide to a licensee
52
+ using the Software in accordance with these Terms and Conditions.
53
+
54
+ ### Patents
55
+
56
+ To the extent your use for a Permitted Purpose would necessarily infringe our
57
+ patents, the license grant above includes a license under our patents. If you
58
+ make a claim against any party that the Software infringes or contributes to
59
+ the infringement of any patent, then your patent license to the Software ends
60
+ immediately.
61
+
62
+ ### Redistribution
63
+
64
+ The Terms and Conditions apply to all copies, modifications and derivatives of
65
+ the Software.
66
+
67
+ If you redistribute any copies, modifications or derivatives of the Software,
68
+ you must include a copy of or a link to these Terms and Conditions and not
69
+ remove any copyright notices provided in or with the Software.
70
+
71
+ ### Disclaimer
72
+
73
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
74
+ IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES OF MERCHANTABILITY, FITNESS
75
+ FOR A PARTICULAR PURPOSE, TITLE OR NON-INFRINGEMENT.
76
+
77
+ IN NO EVENT WILL WE HAVE ANY LIABILITY TO YOU ARISING OUT OF OR RELATED TO THE
78
+ SOFTWARE, INCLUDING INDIRECT, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES,
79
+ EVEN IF WE HAVE BEEN INFORMED OF THEIR POSSIBILITY IN ADVANCE.
80
+
81
+ ### Trademarks
82
+
83
+ Except for displaying the License Details and identifying us as the origin of
84
+ the Software, you have no right under these Terms and Conditions to use our
85
+ trademarks, trade names, service marks or product names.
86
+
87
+ ## Grant of Future License
88
+
89
+ We hereby irrevocably grant you an additional license to use the Software under
90
+ the MIT License that is effective on the second anniversary of the date we make
91
+ the Software available. On or after that date, you may use the Software under
92
+ the MIT License, in which case the following will apply:
93
+
94
+ Permission is hereby granted, free of charge, to any person obtaining a copy of
95
+ the Software and associated documentation files (the "Software"), to deal in
96
+ the Software without restriction, including without limitation the rights to
97
+ use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
98
+ of the Software, and to permit persons to whom the Software is furnished to do
99
+ so, subject to the following conditions:
100
+
101
+ The above copyright notice and this permission notice shall be included in all
102
+ copies or substantial portions of the Software.
103
+
104
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
105
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
106
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
107
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
108
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
109
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
110
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,17 @@
1
+ # @pithy-sh/audit
2
+
3
+ A queryable audit trail for Pithy. Records security-relevant actions — logins, token refreshes, entitlement grants, admin config changes — as durable rows in your own D1, attributed to the right actor.
4
+
5
+ Better Auth ships no audit plugin, so Pithy owns this.
6
+
7
+ ```sh
8
+ pithy add audit
9
+ ```
10
+
11
+ **Documentation: [pithy.sh/docs/capabilities/audit](https://pithy.sh/docs/capabilities/audit).** Overview, adding it, using it, and the reference: the event model, actors, querying, retention.
12
+
13
+ _Everything else is on the site. `pithy.sh/docs` is canonical — new prose goes there, not here._
14
+
15
+ ## License
16
+
17
+ `FSL-1.1-MIT` (Functional Source License). Use it freely for any purpose except a competing product; it converts to MIT two years after each release. The audit trail feeds the premium dashboard, so it starts more restrictive than the MIT core capabilities (CLAUDE.md §Packaging). See `LICENSE`.
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@pithy-sh/audit",
3
+ "version": "0.1.0",
4
+ "license": "FSL-1.1-MIT",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/pithy-sh/pithy.git",
8
+ "directory": "packages/audit"
9
+ },
10
+ "files": [
11
+ "src",
12
+ "pithy.manifest.json",
13
+ "!src/**/*.test.*"
14
+ ],
15
+ "type": "module",
16
+ "engines": {
17
+ "node": ">=22"
18
+ },
19
+ "exports": {
20
+ "./src/*": "./src/*.ts"
21
+ },
22
+ "scripts": {
23
+ "build": "tsc -p tsconfig.json --noEmit false --outDir dist",
24
+ "typecheck": "tsc -p tsconfig.json",
25
+ "test": "vitest run",
26
+ "test:node": "vitest run --project=node",
27
+ "test:workers": "vitest run --project=workers",
28
+ "clean": "rm -rf dist .turbo",
29
+ "reset": "bun run clean && rm -rf node_modules"
30
+ },
31
+ "dependencies": {
32
+ "@cloudflare/workers-types": "^5.20260729.1",
33
+ "@hono/zod-validator": "^0.9.0",
34
+ "@pithy-sh/cloudflare": "workspace:*",
35
+ "@pithy-sh/core": "workspace:*",
36
+ "hono": "^4.13.2",
37
+ "kysely": "^0.29.0",
38
+ "zod": "^4.0.0"
39
+ },
40
+ "devDependencies": {
41
+ "@cloudflare/vitest-plugin": "^1.0.0",
42
+ "@pithy-sh/tsconfig": "workspace:*",
43
+ "@types/node": "^22.15.0",
44
+ "@vitest/coverage-v8": "^4.1.0",
45
+ "kysely-d1": "^0.4.0",
46
+ "typescript": "^7.0.2",
47
+ "vitest": "^4.1.0",
48
+ "wrangler": "^4.115.0"
49
+ }
50
+ }
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "audit",
3
+ "package": "@pithy-sh/audit",
4
+ "requiredBindings": [{ "type": "d1", "name": "DB" }],
5
+ "migrationNamespace": "audit",
6
+ "whenToEnable": "A queryable, tamper-resistant audit trail of security-relevant actions — logins, token refreshes, entitlement grants, admin config changes. Records who did what, when, and whether it succeeded, attributed to the right actor. Other capabilities emit through the core seam; auth needs it the moment it lands.",
7
+ "configOptions": [
8
+ {
9
+ "key": "database",
10
+ "default": "DB",
11
+ "describe": "The D1 binding the audit table and its migrations target. Defaults to `DB`, the shared app database. KV is not an option — an audit log is a query workload, not a key lookup."
12
+ },
13
+ {
14
+ "key": "basePath",
15
+ "default": "/audit",
16
+ "describe": "Where the control-plane routes that read the trail mount. Reads only — the trail is append-only and this surface never writes."
17
+ }
18
+ ]
19
+ }
package/src/actions.ts ADDED
@@ -0,0 +1,59 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: FSL-1.1-MIT
3
+
4
+ import { AuditAction } from "@pithy-sh/core/src/audit/auditEvent";
5
+ import { AuditInvalidEventError } from "./error/errors";
6
+
7
+ /**
8
+ * Author a capability's action constants. The taxonomy is **federated**: each capability owns and
9
+ * exports its own `domain/reason` action codes (`auth/login`, `entitlement/granted`) and adds them
10
+ * without touching core — the way migrations and table prefixes already federate per-capability.
11
+ *
12
+ * This validates every code against the shared {@link AuditAction} shape at author time, so a typo
13
+ * (a missing slash, an uppercase letter) fails loudly where the constant is declared rather than
14
+ * silently when an event is emitted. Returns the same object, typed, so call sites read
15
+ * `AuthAuditActions.login`:
16
+ *
17
+ * ```ts
18
+ * export const AuthAuditActions = defineAuditActions({
19
+ * login: "auth/login",
20
+ * tokenRefreshed: "auth/token_refreshed",
21
+ * });
22
+ * ```
23
+ */
24
+ export function defineAuditActions<const T extends Record<string, string>>(actions: T): T {
25
+ for (const [name, code] of Object.entries(actions)) {
26
+ const parsed = AuditAction.safeParse(code);
27
+ if (!parsed.success) {
28
+ throw new AuditInvalidEventError({
29
+ message: `Invalid audit action code for "${name}": ${code}`,
30
+ action: "Use a lowercase namespaced `domain/reason` code, e.g. `auth/login`.",
31
+ detail: parsed.error.message,
32
+ });
33
+ }
34
+ }
35
+ return actions;
36
+ }
37
+
38
+ /**
39
+ * This capability's own action codes — the trail recording the reads of itself.
40
+ *
41
+ * **Reading an audit trail is a security-relevant action.** It is the record of who did what across
42
+ * every other capability, so a management client paging through it is doing something that has to be
43
+ * answerable later; a trail that records every write and no read cannot answer "who looked".
44
+ *
45
+ * They are separate codes rather than one, because the two reads disclose different things and a
46
+ * reviewer filtering the trail should not have to inspect metadata to tell them apart:
47
+ * `audit/trail_read` is a filtered page of structural fields, while `audit/event_read` is the full
48
+ * record of one event — the IP, the user-agent, and the capability metadata included.
49
+ *
50
+ * Yes, this appends to the table it just read. That is a loop by design, not by accident: the row it
51
+ * writes is one row, it is the only durable evidence the read happened, and the alternative is a
52
+ * surface that quietly exempts itself from the guarantee it exists to provide.
53
+ */
54
+ export const AuditTrailActions = defineAuditActions({
55
+ /** A filtered page of the trail was read. */
56
+ trailRead: "audit/trail_read",
57
+ /** One event was read in full, network identifiers and metadata included. */
58
+ eventRead: "audit/event_read",
59
+ });
@@ -0,0 +1,135 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: FSL-1.1-MIT
3
+
4
+ import { type Capability, defineCapability } from "@pithy-sh/core/src/capability/capability";
5
+ import { workerIdentity } from "@pithy-sh/core/src/worker/identity";
6
+ import { z } from "zod";
7
+ import type { AuditDatabase } from "./data/tables";
8
+ import { auditTables } from "./data/tables";
9
+ import { auditAdminRoutes } from "./http/guards";
10
+ import { registerAuditRoutes } from "./http/routes";
11
+ import { audit_0001_init } from "./migrations/0001_init";
12
+ import { recordAuditEvent } from "./recorder";
13
+ import { auditExampleSeed } from "./seeds/example";
14
+ import { PACKAGE_VERSION } from "./version.generated";
15
+
16
+ /**
17
+ * Sort order of the audit migration within the audit database, relative to other capabilities
18
+ * (core low, app high). Unique per database; the migration registry composes the key
19
+ * `0250_audit_0001_init`.
20
+ */
21
+ export const AUDIT_MIGRATION_ORDER = 250;
22
+
23
+ /**
24
+ * The registry name the audit table joins. The store of record is D1, shared with the primary app
25
+ * database (`app`, the `DB` binding) by default — so the audit migration tracks alongside the app's
26
+ * in one `pithy_migrations` table rather than fighting it on a second provider over the same binding.
27
+ * The physical binding is config-selectable; this registry key is the coordination point capabilities
28
+ * use to share one database (CLAUDE.md §Data layer; isolating audit into its own D1 is a later issue).
29
+ */
30
+ const AUDIT_DATABASE_NAME = "app" as const;
31
+
32
+ /**
33
+ * Configuration for the audit capability, passed in `pithy.config.ts`. `database` names the D1
34
+ * **binding** the audit table lives in and writes go to, defaulting to `DB` (the shared app
35
+ * database). KV is deliberately not an option: an audit log is a query workload (by actor, action,
36
+ * time range, resource, outcome) and KV is get-by-key only.
37
+ *
38
+ * `basePath` is where the read-only control-plane routes mount. It is config rather than a constant
39
+ * for the reason every capability's is: an adopter may already own `/audit`, and the admin manifest
40
+ * reports whatever they chose, so a management client follows the move without either side
41
+ * coordinating.
42
+ */
43
+ export const AuditConfig = z
44
+ .object({
45
+ database: z
46
+ .string()
47
+ .default("DB")
48
+ .describe("The D1 binding the audit table and its migrations target. Defaults to `DB`, the shared app database."),
49
+ basePath: z
50
+ .string()
51
+ .startsWith("/")
52
+ .default("/audit")
53
+ .describe(
54
+ "Where the control-plane routes that read the trail mount. Reads only — the trail is append-only and this surface never writes.",
55
+ ),
56
+ })
57
+ .describe("Configuration for the audit capability.");
58
+ export type AuditConfig = z.output<typeof AuditConfig>;
59
+ export type AuditConfigInput = z.input<typeof AuditConfig>;
60
+
61
+ /** The audit capability, with its resolved binding attached for discovery. */
62
+ export interface AuditCapability extends Capability {
63
+ /** The D1 binding the audit table lives in (the resolved `database` config). */
64
+ auditDatabase: string;
65
+ }
66
+
67
+ /**
68
+ * The audit capability. It contributes the `pithy_audit_events` table to the configured D1 binding
69
+ * (default `DB`) and installs the request-context `emit()` recorder via middleware — replacing core's
70
+ * no-op with a synchronous, non-fatal D1 writer. Any capability then records a security-relevant
71
+ * action with `c.var.emit(...)` without importing this package (principle 4).
72
+ */
73
+ export function audit(config: AuditConfigInput = {}): AuditCapability {
74
+ const resolved = AuditConfig.parse(config);
75
+ const capability = defineCapability({
76
+ name: "audit",
77
+ // The package version this capability ships at, stamped by `scripts/stampVersions.ts` — a Worker
78
+ // cannot read its own package.json. Reported per capability by the control-plane manifest.
79
+ version: PACKAGE_VERSION,
80
+ config: AuditConfig,
81
+ requiredBindings: [{ type: "d1", name: resolved.database }],
82
+ databases: {
83
+ [AUDIT_DATABASE_NAME]: {
84
+ binding: resolved.database,
85
+ tables: auditTables,
86
+ migrationOrder: AUDIT_MIGRATION_ORDER,
87
+ migrations: { "0001_init": audit_0001_init },
88
+ },
89
+ },
90
+ seeds: [auditExampleSeed],
91
+ // Built from the resolved `basePath`, never the default: an adopter who mounts this at `/trail`
92
+ // gets a manifest naming their paths, where a client assuming the default would 404.
93
+ adminRoutes: auditAdminRoutes(resolved.basePath),
94
+ // The registry key is this package's own detail, so the routes are handed a resolver rather than
95
+ // being made to know it — the same reason the middleware below resolves lazily.
96
+ routes: registerAuditRoutes({
97
+ basePath: resolved.basePath,
98
+ database: (c) => (c.var.db as Record<typeof AUDIT_DATABASE_NAME, AuditDatabase>)[AUDIT_DATABASE_NAME],
99
+ }),
100
+ // Replace the no-op `emit` seam with a recorder over this request's audit database. The database
101
+ // is resolved lazily inside the closure — only when a route actually emits — so a request that
102
+ // never audits never triggers the lazy Kysely build. Runs after createBackend's default-setter
103
+ // (which seeds `db`, `log`, and the no-op `emit`), so `c.var.db` and `c.var.log` are ready by the
104
+ // time emit is called. A non-fatal write failure routes through the logger seam — replacing the
105
+ // former `console.error` stopgap — as a namespaced `audit/*` record on the `audit` sub-logger.
106
+ middleware: [
107
+ (app) => {
108
+ app.use("*", async (c, next) => {
109
+ // Origin is read from `c.env` here and handed to the recorder, so no emitter supplies it and
110
+ // none can override it. Read per request rather than once at capability construction: the
111
+ // capability object is built before any request exists, and `env` is only reachable from a
112
+ // context. `workerIdentity` never throws and returns nulls for whatever is unstamped, so a
113
+ // Worker predating these vars still records its events — just without an origin.
114
+ c.set("emit", (event) =>
115
+ recordAuditEvent(
116
+ (c.var.db as Record<typeof AUDIT_DATABASE_NAME, AuditDatabase>)[AUDIT_DATABASE_NAME],
117
+ event,
118
+ {
119
+ origin: workerIdentity(c.env),
120
+ onError: (error) => c.var.log.child("audit").error("audit event dropped", { error }),
121
+ },
122
+ ),
123
+ );
124
+ await next();
125
+ });
126
+ },
127
+ ],
128
+ });
129
+ return Object.assign(capability, { auditDatabase: resolved.database });
130
+ }
131
+
132
+ /** Whether a capability is the audit capability — carries its resolved binding. */
133
+ export function isAuditCapability(capability: Capability): capability is AuditCapability {
134
+ return capability.name === "audit" && "auditDatabase" in capability;
135
+ }
@@ -0,0 +1,66 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: FSL-1.1-MIT
3
+
4
+ import type { D1Database } from "@cloudflare/workers-types";
5
+ import type { AuditEventInput } from "@pithy-sh/core/src/audit/auditEvent";
6
+ import { auditDatabase } from "../data/tables";
7
+ import { type AuditRecorderOptions, recordAuditEvent } from "../recorder";
8
+ import type { ResolvedActor } from "./resolveActor";
9
+
10
+ /**
11
+ * The event an `emitFromCLI` caller supplies — every audit field except the actor, which comes from
12
+ * {@link ResolvedActor} (resolved from the CF token, not the caller). The CLI describes *what*
13
+ * happened; the resolver decides *who*.
14
+ *
15
+ * Plus `environment`, which the CLI alone may state per event. In a Worker the origin is read from
16
+ * `env` and an emitter can never touch it — there the emitter is a request handler and the whole point
17
+ * is that it cannot lie. A CLI command is not that: it *is* the trusted process, there is no untrusted
18
+ * caller in it, and one invocation legitimately acts on several environments — `pithy storage provision`
19
+ * loops every managed environment in a single run. Nothing else knows which one a given event belongs
20
+ * to, so the event carries it.
21
+ */
22
+ export type CliAuditEvent = Omit<AuditEventInput, "actorType" | "actorId"> & {
23
+ /**
24
+ * The environment this one event acted on. Overrides the emitter's configured origin environment;
25
+ * omitted, the configured one stands.
26
+ */
27
+ environment?: string | null;
28
+ };
29
+
30
+ /**
31
+ * Record an audit event from a Node/Bun context — the second emit path, for CLI-originated actions
32
+ * (`pithy migrate`, `pithy deploy`, `pithy secrets set`). The CLI can't use a D1 binding, so it
33
+ * writes over the REST API: `d1` is a `CloudflareD1Manager` from `@pithy-sh/cloudflare` (which
34
+ * implements `D1Database`), and the same Kysely/codec path the in-Worker recorder uses runs against
35
+ * it. The {@link ResolvedActor} is merged in, with its correlation metadata under the caller's own.
36
+ *
37
+ * Like the in-Worker recorder, this is **non-fatal**: a write failure is logged, never thrown, so an
38
+ * audit write never breaks the command it records.
39
+ */
40
+ export async function emitFromCLI(
41
+ d1: D1Database,
42
+ event: CliAuditEvent,
43
+ actor: ResolvedActor,
44
+ options?: AuditRecorderOptions,
45
+ ): Promise<void> {
46
+ // `environment` is an origin field, not an event field — pulled off here so it reaches the origin
47
+ // rather than `AuditEvent`, which has no such key and would drop it silently.
48
+ const { environment, ...rest } = event;
49
+ const merged: AuditEventInput = {
50
+ ...rest,
51
+ actorType: actor.actorType,
52
+ actorId: actor.actorId,
53
+ // The caller's metadata wins on a key clash; the actor's correlation fields fill in the rest.
54
+ metadata: { ...actor.metadata, ...(event.metadata ?? {}) },
55
+ };
56
+ const origin =
57
+ environment === undefined
58
+ ? options?.origin
59
+ : {
60
+ project: options?.origin?.project ?? null,
61
+ environment,
62
+ worker: options?.origin?.worker ?? null,
63
+ version: options?.origin?.version ?? null,
64
+ };
65
+ await recordAuditEvent(auditDatabase(d1), merged, { ...options, ...(origin ? { origin } : {}) });
66
+ }
@@ -0,0 +1,126 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: FSL-1.1-MIT
3
+
4
+ import type { AuditActorType, AuditMetadata } from "@pithy-sh/core/src/audit/auditEvent";
5
+ import { messageOf } from "@pithy-sh/core/src/error/pithyError";
6
+
7
+ /** The user-scoped slice: who is behind a `cfut_*` token. `clients.user()` satisfies it. */
8
+ export interface CfUserActorSource {
9
+ /** The Cloudflare user behind a user token (`GET /user`). */
10
+ getUser(): Promise<{ id?: string; email?: string }>;
11
+ }
12
+
13
+ /** The account-scoped slice: which credential a `cfat_*` token is. `clients.accountTokens()` satisfies it. */
14
+ export interface CfAccountTokenActorSource {
15
+ /** Verify the calling token against the account, returning its id + status (`GET /accounts/{id}/tokens/verify`). */
16
+ verifyToken(): Promise<{ id: string; status: string }>;
17
+ /** The token's name by id, or `null` when the caller may not read it (`GET /accounts/{id}/tokens/{id}`). */
18
+ getTokenName(tokenId: string): Promise<string | null>;
19
+ }
20
+
21
+ /**
22
+ * The slices of `@pithy-sh/cloudflare` actor resolution needs — declared structurally so the resolver
23
+ * is unit-testable with a fake and the audit package never hard-depends on the concrete managers.
24
+ * `{ user: clients.user(), accountTokens: clients.accountTokens() }` satisfies it.
25
+ *
26
+ * **Two scopes, not one, because a token is valid in exactly one of them.** An account-owned
27
+ * (`cfat_*`) token — the kind CLAUDE.md prefers and `pithy token mint` produces — answers
28
+ * `Invalid API Token` at every `/user/*` endpoint. A single flat source made the wrong scope
29
+ * reachable from the account path, and since resolution failure is never fatal, the whole preferred
30
+ * setup silently attributed to `system`. Split, the account path cannot reach a user endpoint at all.
31
+ */
32
+ export interface CfActorSource {
33
+ /** User-scoped reads, for a `cfut_*` token. */
34
+ user: CfUserActorSource;
35
+ /** Account-scoped reads, for a `cfat_*` token. */
36
+ accountTokens: CfAccountTokenActorSource;
37
+ }
38
+
39
+ /** A resolved CLI actor: who to attribute a control-plane audit event to, plus correlation metadata. */
40
+ export interface ResolvedActor {
41
+ /** The kind of principal. */
42
+ actorType: AuditActorType;
43
+ /** The principal's id (a user email, a token name), or null when unresolved. */
44
+ actorId: string | null;
45
+ /** Correlation detail for the event's `metadata` — never the raw token value. */
46
+ metadata: AuditMetadata;
47
+ }
48
+
49
+ /** The fallback actor when resolution can't attribute the action: `system`, with a note (never fatal). */
50
+ function systemActor(note: string): ResolvedActor {
51
+ return { actorType: "system", actorId: null, metadata: { actorResolutionFailed: true, note } };
52
+ }
53
+
54
+ /**
55
+ * Resolve the actor behind a CF API token from its **prefix** — the only part read, never the value:
56
+ *
57
+ * - `cfut_*` (user token) → a human developer. Resolve the user's email via `GET /user`; the CF user
58
+ * id rides in `metadata` for stable cross-reference. `actorType: "user"`, `actorId: <email>`.
59
+ * - `cfat_*` (account token) → a service account or CI pipeline. Verify the token **against the
60
+ * account**, then read its name; `actorType: "service"`, `actorId: <token-name>` (the token id is
61
+ * the fallback and rides in `metadata`).
62
+ *
63
+ * The name is best-effort by design. Reading a token record needs `API Tokens Read`, which the
64
+ * least-privilege tokens `pithy token mint` produces deliberately do not carry, so the account source
65
+ * answers `null` there and `actorId` falls back to the token id — which verify always yields, for any
66
+ * account token, with no permission at all. Attribution stays on the credential either way — once
67
+ * verify has named it, nothing about a decorative label may take that back — so a name read that
68
+ * *fails* rather than declining takes the same fallback, and records why in `metadata.cfTokenNameError`
69
+ * instead of vanishing. Widening the mint permission set to make the name readable would trade least
70
+ * privilege for a label.
71
+ *
72
+ * Resolution failure — a bad token, a network error, an unrecognized prefix — is **never fatal**: it
73
+ * returns a `system` actor with a `metadata` note, so the event is still written, just unattributed.
74
+ */
75
+ export async function resolveActor(apiToken: string, source: CfActorSource): Promise<ResolvedActor> {
76
+ try {
77
+ if (apiToken.startsWith("cfut_")) {
78
+ const user = await source.user.getUser();
79
+ return {
80
+ actorType: "user",
81
+ actorId: user.email ?? user.id ?? null,
82
+ metadata: { cfTokenType: "user", cfUserId: user.id ?? null },
83
+ };
84
+ }
85
+ if (apiToken.startsWith("cfat_")) {
86
+ const verification = await source.accountTokens.verifyToken();
87
+ // Verify has already named the credential, so the name read cannot cost us the attribution: it
88
+ // falls back to the token id rather than collapsing a known credential to `system`. It does not
89
+ // pass unremarked, though — a trail that degrades quietly is the defect this path had. The two
90
+ // cases are different and the record says which: a **declined** read (the source answers `null`)
91
+ // is the ordinary least-privilege shape and needs no note, while a read that **failed** leaves
92
+ // its reason in `metadata` for whoever asks why the actor is an id and not a name.
93
+ let nameError: string | undefined;
94
+ const name = await source.accountTokens.getTokenName(verification.id).catch((error: unknown) => {
95
+ nameError = messageOf(error);
96
+ return null;
97
+ });
98
+ return {
99
+ actorType: "service",
100
+ actorId: name ?? verification.id,
101
+ metadata: {
102
+ cfTokenType: "account",
103
+ cfTokenId: verification.id,
104
+ cfTokenStatus: verification.status,
105
+ ...(nameError === undefined ? {} : { cfTokenNameError: nameError }),
106
+ },
107
+ };
108
+ }
109
+ return systemActor("Unrecognized Cloudflare API token prefix; expected `cfut_` or `cfat_`.");
110
+ } catch (error) {
111
+ return systemActor(`Cloudflare actor resolution failed: ${messageOf(error)}`);
112
+ }
113
+ }
114
+
115
+ /**
116
+ * A resolver that runs {@link resolveActor} **once** and caches the result for the lifetime of a CLI
117
+ * command — actor identity is resolved at session start, not per event. Memoizes the in-flight
118
+ * promise so concurrent emits share the single resolution.
119
+ */
120
+ export function createCachedActorResolver(apiToken: string, source: CfActorSource): () => Promise<ResolvedActor> {
121
+ let cached: Promise<ResolvedActor> | undefined;
122
+ return () => {
123
+ cached ??= resolveActor(apiToken, source);
124
+ return cached;
125
+ };
126
+ }
@@ -0,0 +1,13 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: FSL-1.1-MIT
3
+
4
+ /// <reference types="@cloudflare/vitest-plugin/types" />
5
+
6
+ // Bindings the Workers-runtime test project provides to `*.workers.test.ts`, matching the Miniflare
7
+ // config in `vitest.workers.config.ts`: the default audit D1 binding `DB`. `cloudflare:test` types
8
+ // its `env` as `Cloudflare.Env`, so the test bindings are declared by augmenting that interface.
9
+ declare namespace Cloudflare {
10
+ interface Env {
11
+ DB: D1Database;
12
+ }
13
+ }