@pithy-sh/core 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.
Files changed (134) hide show
  1. package/LICENSE +21 -0
  2. package/package.json +47 -0
  3. package/pithy.manifest.json +74 -0
  4. package/src/address/address.ts +83 -0
  5. package/src/audit/auditEvent.ts +130 -0
  6. package/src/audit/recorder.ts +22 -0
  7. package/src/capability/bindings.ts +196 -0
  8. package/src/capability/capability.ts +555 -0
  9. package/src/capability/client.ts +136 -0
  10. package/src/capability/compose.ts +76 -0
  11. package/src/capability/composition.ts +98 -0
  12. package/src/capability/config.ts +19 -0
  13. package/src/capability/devSecret.ts +42 -0
  14. package/src/capability/manifest.ts +580 -0
  15. package/src/capability/secretOrigin.ts +253 -0
  16. package/src/capability/settings.ts +155 -0
  17. package/src/capability/validateBindings.ts +43 -0
  18. package/src/capability/vanishingKey.ts +92 -0
  19. package/src/cloudflare-test.d.ts +20 -0
  20. package/src/controlPlane/audit/actions.ts +81 -0
  21. package/src/controlPlane/capability.ts +228 -0
  22. package/src/controlPlane/config/config.ts +195 -0
  23. package/src/controlPlane/context.ts +63 -0
  24. package/src/controlPlane/data/connection.ts +123 -0
  25. package/src/controlPlane/data/keyLifecycle.ts +159 -0
  26. package/src/controlPlane/data/replay.ts +39 -0
  27. package/src/controlPlane/data/tables.ts +51 -0
  28. package/src/controlPlane/discovery/adminRoute.ts +250 -0
  29. package/src/controlPlane/discovery/configuration.ts +280 -0
  30. package/src/controlPlane/discovery/drift.ts +100 -0
  31. package/src/controlPlane/discovery/health.ts +213 -0
  32. package/src/controlPlane/discovery/healthSummary.ts +486 -0
  33. package/src/controlPlane/error/errors.ts +125 -0
  34. package/src/controlPlane/http/cors.ts +244 -0
  35. package/src/controlPlane/http/guard.ts +223 -0
  36. package/src/controlPlane/http/handlers.ts +346 -0
  37. package/src/controlPlane/http/responses.ts +92 -0
  38. package/src/controlPlane/http/routes.ts +115 -0
  39. package/src/controlPlane/http/schemas.ts +70 -0
  40. package/src/controlPlane/http/verify.ts +198 -0
  41. package/src/controlPlane/migrations/0001_init.ts +105 -0
  42. package/src/controlPlane/replay/d1Guard.ts +87 -0
  43. package/src/controlPlane/replay/guard.ts +55 -0
  44. package/src/controlPlane/replay/kvGuard.ts +143 -0
  45. package/src/controlPlane/scope/scope.ts +102 -0
  46. package/src/controlPlane/token/base64url.ts +65 -0
  47. package/src/controlPlane/token/claims.ts +151 -0
  48. package/src/controlPlane/token/digest.ts +63 -0
  49. package/src/controlPlane/token/jws.ts +112 -0
  50. package/src/controlPlane/token/mint.ts +93 -0
  51. package/src/controlPlane/wire.ts +138 -0
  52. package/src/createBackend.ts +292 -0
  53. package/src/createEntrypoint.ts +125 -0
  54. package/src/data/boundParameters.ts +197 -0
  55. package/src/data/codecs.ts +160 -0
  56. package/src/data/cursor.ts +127 -0
  57. package/src/data/databases.ts +84 -0
  58. package/src/data/db.ts +53 -0
  59. package/src/data/withD1Retry.ts +176 -0
  60. package/src/entitlement/entitlement.ts +191 -0
  61. package/src/entitlement/gateScan.ts +107 -0
  62. package/src/entitlement/require.ts +199 -0
  63. package/src/env/ambient.ts +67 -0
  64. package/src/env/ci.ts +43 -0
  65. package/src/env/stem.ts +34 -0
  66. package/src/error/cause.ts +208 -0
  67. package/src/error/client.ts +43 -0
  68. package/src/error/extend.ts +135 -0
  69. package/src/error/http.ts +92 -0
  70. package/src/error/payload.ts +2195 -0
  71. package/src/error/pithyError.ts +281 -0
  72. package/src/error/terminal.ts +36 -0
  73. package/src/http/authContext.ts +29 -0
  74. package/src/http/routeContract.ts +115 -0
  75. package/src/http/sameOrigin.ts +67 -0
  76. package/src/http/signedWebhook.ts +415 -0
  77. package/src/http/validation.ts +41 -0
  78. package/src/http/verification.ts +25 -0
  79. package/src/i18n/acceptLanguage.ts +70 -0
  80. package/src/i18n/catalog.ts +113 -0
  81. package/src/i18n/locale.ts +153 -0
  82. package/src/i18n/localeMarker.ts +116 -0
  83. package/src/i18n/match.ts +111 -0
  84. package/src/i18n/registry.ts +78 -0
  85. package/src/i18n/translator.ts +168 -0
  86. package/src/index.ts +116 -0
  87. package/src/kv/kv.ts +437 -0
  88. package/src/kv/namespaces.ts +102 -0
  89. package/src/logger/local.ts +91 -0
  90. package/src/logger/logger.ts +145 -0
  91. package/src/logger/record.ts +83 -0
  92. package/src/logger/worker.ts +117 -0
  93. package/src/migrations/batch.ts +226 -0
  94. package/src/migrations/bookkeeping.ts +85 -0
  95. package/src/migrations/owner.ts +166 -0
  96. package/src/migrations/registry.ts +121 -0
  97. package/src/migrations/runner.ts +295 -0
  98. package/src/naming/domains.ts +194 -0
  99. package/src/naming/environment.ts +224 -0
  100. package/src/naming/feature.ts +162 -0
  101. package/src/naming/limits.ts +223 -0
  102. package/src/naming/provisionScope.ts +143 -0
  103. package/src/naming/resource.ts +266 -0
  104. package/src/naming/resourceNames.ts +174 -0
  105. package/src/naming/segment.ts +32 -0
  106. package/src/projection/asRead.ts +211 -0
  107. package/src/projection/published.ts +210 -0
  108. package/src/schema/describedness.ts +250 -0
  109. package/src/seed/compose.ts +94 -0
  110. package/src/seed/devLogin.ts +67 -0
  111. package/src/seed/exampleIdentities.ts +43 -0
  112. package/src/seed/metadata.ts +27 -0
  113. package/src/seed/seed.ts +306 -0
  114. package/src/seed/seededRows.ts +41 -0
  115. package/src/seed/writeD1.ts +103 -0
  116. package/src/seed/writeKv.ts +99 -0
  117. package/src/semver/semver.ts +156 -0
  118. package/src/text/comments.ts +165 -0
  119. package/src/version.generated.ts +16 -0
  120. package/src/worker/health.ts +42 -0
  121. package/src/worker/identity.ts +243 -0
  122. package/src/workflow/bindings.ts +58 -0
  123. package/src/workflow/dispatch.ts +240 -0
  124. package/src/workflow/dispatchRoute.ts +184 -0
  125. package/src/workflow/faults.ts +219 -0
  126. package/src/workflow/host.ts +307 -0
  127. package/src/workflow/hostEntry.ts +71 -0
  128. package/src/workflow/hostEnv.ts +258 -0
  129. package/src/workflow/loopback.ts +149 -0
  130. package/src/workflow/naming.ts +170 -0
  131. package/src/workflow/register.ts +44 -0
  132. package/src/workflow/schemas.ts +84 -0
  133. package/src/workflow/spec.ts +86 -0
  134. package/src/workflow/stepMessage.ts +160 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Pithy
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@pithy-sh/core",
3
+ "version": "0.1.0",
4
+ "license": "MIT",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/pithy-sh/pithy.git",
8
+ "directory": "packages/core"
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
+ "hono": "^4.13.2",
35
+ "kysely": "^0.29.0",
36
+ "kysely-d1": "^0.4.0",
37
+ "zod": "^4.0.0"
38
+ },
39
+ "devDependencies": {
40
+ "@cloudflare/vitest-plugin": "^1.0.0",
41
+ "@pithy-sh/tsconfig": "workspace:*",
42
+ "@vitest/coverage-v8": "^4.1.0",
43
+ "typescript": "^7.0.2",
44
+ "vitest": "^4.1.0",
45
+ "wrangler": "^4.115.0"
46
+ }
47
+ }
@@ -0,0 +1,74 @@
1
+ {
2
+ "name": "controlplane",
3
+ "package": "@pithy-sh/core",
4
+ "requiredBindings": [
5
+ {
6
+ "type": "d1",
7
+ "name": "DB"
8
+ },
9
+ {
10
+ "type": "kv",
11
+ "name": "CONTROL_PLANE",
12
+ "optional": true
13
+ }
14
+ ],
15
+ "peerCapabilities": [],
16
+ "optionalCapabilities": ["audit"],
17
+ "migrationNamespace": "controlplane",
18
+ "whenToEnable": "Let a management client — the Pithy dashboard, or one you write yourself — reach into this project's own Worker without any data plane in between. It is present-and-denying by default: with no connection registered, every control-plane route answers 403, and there is no backdoor to open. Connect one with `pithy dashboard connect --env production` and the client gets exactly the operations you granted it, per environment, so a staging credential can never touch production. The credential is asymmetric: the client holds a private key, you hold and can revoke the public one, and nothing secret of yours ever leaves your infrastructure. Rotation is append, prove, then expire — never replace — so a rotation that fails leaves the old key working rather than locking anyone out, and revocation is a row you delete, immediate and needing nothing from anybody else. Every call is verified against a signed 60-second single-scope token bound to a digest of its own body, checked for replay, and written to your audit trail with its own actor kind, so 'what did the dashboard do' is answerable separately from 'what did my users do'. Capabilities contribute their own admin routes behind it — @pithy-sh/payments puts manual entitlement grant and revoke here — so adding one adds its management surface with nothing to wire. They describe those routes too, so `GET /control-plane/manifest` tells a client which capabilities this Worker composes and exactly how to call each one, mount paths and required scopes included; a client never has to assume where anything lives. Turning this on grants nobody anything; connecting a client is the deliberate second step.",
19
+ "scaffold": [
20
+ "Add a `controlplane()` block to pithy.config.ts. Every option has a working default; leave `issuer` alone unless you run your own management client.",
21
+ "Bind a D1 database named DB in wrangler.jsonc — the same app database your other capabilities use.",
22
+ "Only if you set `replayBackend: \"kv\"`: bind a KV namespace named CONTROL_PLANE in wrangler.jsonc. The default records spent token ids in D1 instead, where a primary key decides the race, so most projects need no KV at all.",
23
+ "Run `pithy migrate` to create pithy_controlplane_connections and pithy_controlplane_replays.",
24
+ "Run `pithy dashboard connect --env <environment>` to register a management client. Until you do, every control-plane route denies — which is the correct state, not a broken one.",
25
+ "Add `@pithy-sh/audit` if it is not already installed. The seam emits on every allowed and every denied call, and this is the surface where an unaudited admin action is least acceptable."
26
+ ],
27
+ "configOptions": [
28
+ {
29
+ "key": "basePath",
30
+ "default": "/control-plane",
31
+ "describe": "Where the seam's own routes mount — ping, manifest, and the three key-lifecycle routes. Capability admin routes mount under their own capability's path, not here."
32
+ },
33
+ {
34
+ "key": "issuer",
35
+ "default": "https://app.pithy.sh",
36
+ "describe": "The management-client origin a new connection is registered against, and the `iss` every one of its tokens must carry. Trust-critical and effectively permanent: each connection stores the issuer it was created with, and verification checks that stored value, so changing this only affects connections made afterwards. Point it at your own origin if you run your own client. It also seeds the browser origins this Worker answers a CORS preflight for; add any others with `allowedOrigins`."
37
+ },
38
+ {
39
+ "key": "allowedOrigins",
40
+ "default": [],
41
+ "describe": "Browser origins allowed to call this Worker's control-plane surface cross-origin, in addition to `issuer`. Additive: an entry here never removes `issuer`, so adding your own console cannot lock out the dashboard that was already working. Read from config alone and never from a connection row — a preflight carries no credential, so answering one costs no database read and says nothing about which origins are registered. List a management client you host yourself here."
42
+ },
43
+ {
44
+ "key": "corsMaxAgeSeconds",
45
+ "default": 600,
46
+ "describe": "How long a browser may cache a preflight for this Worker's admin surface, in seconds. The allow-list it caches is a compile-time constant, so ten minutes costs nothing and saves a round trip on every call. Set it to 0 while you are working out an allow-list: a browser that cached a refusal keeps refusing for the full window after you have fixed it, which reads like a change that did not take."
47
+ },
48
+ {
49
+ "key": "maxTokenLifetimeSeconds",
50
+ "default": 60,
51
+ "describe": "The longest `exp - iat` a token may claim. A cap rather than a suggestion: it is what keeps the replay memory sound, since a token allowed to live longer than that memory would become replayable the moment its id aged out."
52
+ },
53
+ {
54
+ "key": "clockSkewSeconds",
55
+ "default": 60,
56
+ "describe": "How far a caller's clock may drift from this Worker's before its tokens are rejected. Applied either side of `iat` and `exp`."
57
+ },
58
+ {
59
+ "key": "jtiTtlSeconds",
60
+ "default": 300,
61
+ "describe": "How long a spent token id is remembered, so the same token cannot be presented twice. Must exceed the maximum token lifetime plus TWO clock skews, because a token is accepted a skew before it was issued and a skew after it expires. Config that would let the memory be shorter is rejected outright, since that gap is exactly where replay reopens."
62
+ },
63
+ {
64
+ "key": "keyRetentionDays",
65
+ "default": 30,
66
+ "describe": "How long an expired or revoked key stays on the row before it is pruned. Verification parses this array on every call, so it is bounded on purpose; the audit trail is the history of record, not the row."
67
+ },
68
+ {
69
+ "key": "maxKeys",
70
+ "default": 8,
71
+ "describe": "The hardest cap on retained keys, whatever the retention window says. Two live at once is the normal state during a rotation overlap; eight is room for a long tail of superseded ones without letting the blob grow without limit."
72
+ }
73
+ ]
74
+ }
@@ -0,0 +1,83 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /**
5
+ * Email addresses: the one rule for deciding whether two strings are the same person.
6
+ *
7
+ * Four capabilities compare addresses. `auth` matches a sign-in address, `email` suppresses one,
8
+ * `support` links a sender by `From`, and `testers` invites one. Four rules is four chances to
9
+ * disagree, and a disagreement here does not present as anything about addresses — it presents as "the
10
+ * suppression list did not work", or as one customer with two support threads, or as an invitation
11
+ * nobody can accept because it was minted against `Ada@` and every session carries `ada@`.
12
+ *
13
+ * So the rule is written down once, here, and it is deliberately small.
14
+ *
15
+ * ## What it normalizes
16
+ *
17
+ * - **Surrounding whitespace.** Trimmed. An address pasted out of a spreadsheet carries it.
18
+ * - **Case, in both halves.** RFC 5321 says the local part is case-sensitive; no mail provider in
19
+ * practice treats it that way, and treating `Ada@` and `ada@` as two people splits one customer's
20
+ * history in half. The failure the other way — two genuinely distinct mailboxes differing only in
21
+ * case — does not exist in the wild.
22
+ *
23
+ * ## What it deliberately does not
24
+ *
25
+ * - **Subaddressing (`ada+shop@`) and dots in the local part.** Gmail collapses both. Most providers do
26
+ * not, and folding them would merge two real people on a self-hosted domain. Showing one customer as
27
+ * two is recoverable; showing two customers as one is not.
28
+ * - **Unicode normalization.** No NFC, no NFKC, no case folding beyond `toLowerCase`. NFKC in
29
+ * particular maps distinct codepoints onto ASCII, which is exactly how one address comes to match
30
+ * another that was never issued to the same person — the confusable-domain attack, performed by us,
31
+ * on our own comparison. An address whose bytes differ stays a different address.
32
+ * - **Validation.** {@link normalizeAddress} takes any string and returns its normal form. Whether a
33
+ * string *is* an address is a question for the boundary that accepted it — Zod, or {@link
34
+ * parseAddress} where the input is a mail header. A normalizer that also rejects is a normalizer
35
+ * whose callers stop calling it.
36
+ * - **IDN / punycode.** A unicode domain is not converted to its `xn--` form, or back. Both spellings
37
+ * are stable under this rule; a project that accepts one must accept only one, at its boundary.
38
+ */
39
+
40
+ /** The longest address accepted. RFC 5321 caps a path at 256 octets; anything longer is malformed. */
41
+ export const MAX_ADDRESS_LENGTH = 256;
42
+
43
+ /**
44
+ * One address, in the one form every comparison in the kit is against.
45
+ *
46
+ * Trimmed and lowercased, and nothing else. Total: every string has a normal form, including the ones
47
+ * that are not addresses. Use {@link parseAddress} where the input is attacker-supplied text that may
48
+ * not contain an address at all.
49
+ */
50
+ export function normalizeAddress(value: string): string {
51
+ return value.trim().toLowerCase();
52
+ }
53
+
54
+ /**
55
+ * Read one address out of a header-shaped string, or refuse.
56
+ *
57
+ * Unwraps `Ada Lovelace <ada@example.com>`, bounds the length, refuses anything that is not
58
+ * recognizably a single address, and returns it through {@link normalizeAddress}.
59
+ *
60
+ * Returns `undefined` rather than throwing. Inbound mail is attacker-controlled, so a malformed `From`
61
+ * is an expected input, not an exception — the caller decides whether that means "drop the message" or
62
+ * "store it with no sender", and both are reasonable.
63
+ */
64
+ export function parseAddress(value: string | null | undefined): string | undefined {
65
+ if (typeof value !== "string") return undefined;
66
+ let candidate = value.trim();
67
+ if (candidate.length === 0 || candidate.length > MAX_ADDRESS_LENGTH) return undefined;
68
+
69
+ // `Ada Lovelace <ada@example.com>` — take what the angle brackets delimit, which is the address even
70
+ // when the display name itself contains an `@`.
71
+ const angled = /<([^<>]*)>\s*$/.exec(candidate);
72
+ if (angled?.[1] !== undefined) candidate = angled[1].trim();
73
+
74
+ const at = candidate.lastIndexOf("@");
75
+ if (at <= 0 || at === candidate.length - 1) return undefined;
76
+
77
+ // A domain with no dot is either localhost or a mistake, and a space, comma, semicolon, bracket or
78
+ // quote anywhere means this was never one address. Both are cheap to check and both are worth
79
+ // refusing before anything is stored.
80
+ if (!candidate.slice(at + 1).includes(".") || /[\s,;<>"]/.test(candidate)) return undefined;
81
+
82
+ return normalizeAddress(candidate);
83
+ }
@@ -0,0 +1,130 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { z } from "zod";
5
+
6
+ /**
7
+ * The audit-event seam. `AuditEvent` is the shape any capability hands to `emit()` (the request
8
+ * context's recorder) — the logical event, before storage. `@pithy-sh/audit` owns the D1 table that
9
+ * persists it (`pithy_audit_events`, with codecs); core owns only this contract so a capability
10
+ * emits an event without importing the audit package (principle 4). With no audit capability
11
+ * installed, `emit()` is a no-op and these types are still the contract every emitter writes to.
12
+ *
13
+ * Every field carries a `.describe()` — the schema is the documentation, and it drives the
14
+ * generated audit-event catalog the dashboard reads.
15
+ */
16
+
17
+ /** `domain/reason` — a namespaced lowercase action code (`auth/login`, `admin/config_changed`). */
18
+ const AUDIT_ACTION_PATTERN = /^[a-z][a-z0-9]*\/[a-z][a-z0-9_]*$/;
19
+
20
+ /**
21
+ * The action taxonomy is **federated, not centralized**: `action` is an open, namespace-validated
22
+ * `domain/reason` string, and each capability owns and exports its own action constants (`auth/*`,
23
+ * `entitlement/*`, `admin/*`) — added without touching core, the way migrations and table prefixes
24
+ * already federate. Core never holds a closed union of every event.
25
+ */
26
+ export const AuditAction = z
27
+ .string()
28
+ .regex(
29
+ AUDIT_ACTION_PATTERN,
30
+ "An audit action must be a namespaced `domain/reason` code: lowercase, the domain and reason separated by a single `/` (e.g. `auth/login`).",
31
+ )
32
+ .describe("A namespaced `domain/reason` action code (`auth/login`, `admin/config_changed`); the federated taxonomy.");
33
+ export type AuditAction = z.output<typeof AuditAction>;
34
+
35
+ /** The result of an audited action. A blocked authorization attempt is a first-class record. */
36
+ export const AuditOutcome = z
37
+ .enum(["success", "failure", "denied"])
38
+ .describe(
39
+ "The result of the audited action: `success`, `failure` (it was attempted and errored), or `denied` (an authorization gate blocked it). `denied` is first-class — blocked attempts are recorded, not only successes.",
40
+ );
41
+ export type AuditOutcome = z.output<typeof AuditOutcome>;
42
+
43
+ /** How serious the event is — orthogonal to outcome; drives dashboard filtering and alerting. */
44
+ export const AuditSeverity = z
45
+ .enum(["info", "warning", "critical"])
46
+ .describe(
47
+ "The event's severity, orthogonal to its outcome: `info` (routine), `warning` (notable), `critical` (alert-worthy). Drives dashboard filtering and alerting.",
48
+ );
49
+ export type AuditSeverity = z.output<typeof AuditSeverity>;
50
+
51
+ /**
52
+ * What kind of principal acted. `anonymous` covers an unauthenticated request; `system` an internal job.
53
+ *
54
+ * `control-plane` is its own kind rather than a flavour of `service` because the question an adopter
55
+ * actually asks is "what did the management client do", separately from "what did my users do". A
56
+ * control-plane caller is not a user of their app at all — it holds no session and owns no user row —
57
+ * so folding it into `user` or `service` would make that question unanswerable from the trail.
58
+ */
59
+ export const AuditActorType = z
60
+ .enum(["user", "service", "system", "anonymous", "control-plane"])
61
+ .describe(
62
+ "The kind of principal that acted: `user` (an authenticated person), `service` (a service account or CI token), `system` (an internal job, no external actor), `anonymous` (an unauthenticated request), or `control-plane` (a management client calling in under the `control-plane` strategy — a principal outside the adopter's own user base, whose actions are answerable separately from their users').",
63
+ );
64
+ export type AuditActorType = z.output<typeof AuditActorType>;
65
+
66
+ /** Capability-specific structured detail attached to an event; Zod-validated on write and read. */
67
+ export const AuditMetadata = z
68
+ .record(z.string(), z.unknown())
69
+ .describe(
70
+ "Capability-specific structured detail (a JSON object), validated on write and read. Never put a secret, credential, or sensitive payload here — the trail is queryable and long-lived.",
71
+ );
72
+ export type AuditMetadata = z.output<typeof AuditMetadata>;
73
+
74
+ /**
75
+ * One audit event as an emitter supplies it. `occurredAt` is optional — the recorder stamps it at
76
+ * write time when absent. The nullable correlation fields (`actorId`, `sessionId`, request fields,
77
+ * resource fields) are populated from request context where available; an emitter sets only what it
78
+ * knows. The recorder turns this into the stored `pithy_audit_events` row.
79
+ *
80
+ * `tenant` is the one field here that names a *dimension* of the trail rather than a detail of the
81
+ * action, and it is on this seam rather than on the recorder's origin for the reason the origin fields
82
+ * are not: the recorder cannot know it. `project`, `environment` and `worker` are constant across every
83
+ * row a multi-tenant Worker writes, so without this the trail cannot be read per customer at all — and
84
+ * it must be stamped here, at write time, because the tenant of an action is a fact *at the time of the
85
+ * action* while membership is a fact *now*. Deriving one from the other later moves a year of history
86
+ * between tenants every time somebody joins or leaves.
87
+ */
88
+ export const AuditEvent = z
89
+ .object({
90
+ action: AuditAction.describe("What happened, as a namespaced `domain/reason` action code."),
91
+ outcome: AuditOutcome.describe("Whether the action succeeded, failed, or was denied."),
92
+ severity: AuditSeverity.default("info").describe("How serious the event is; defaults to `info`."),
93
+ actorType: AuditActorType.describe("The kind of principal that acted."),
94
+ actorId: z
95
+ .string()
96
+ .nullish()
97
+ .describe("The acting principal's stable id (user id, service/token name); null for `system`/`anonymous`."),
98
+ sessionId: z
99
+ .string()
100
+ .nullish()
101
+ .describe("The session this action belongs to, tying a chain of actions together; null when none."),
102
+ resourceType: z
103
+ .string()
104
+ .nullish()
105
+ .describe("The type of resource the action targeted (e.g. `user`, `secret`); null when not resource-scoped."),
106
+ resourceId: z
107
+ .string()
108
+ .nullish()
109
+ .describe("The id of the resource the action targeted; null when not resource-scoped."),
110
+ ip: z.string().nullish().describe("The client IP the request came from, for correlation; null when unknown."),
111
+ userAgent: z.string().nullish().describe("The client user-agent string, for correlation; null when unknown."),
112
+ requestId: z
113
+ .string()
114
+ .nullish()
115
+ .describe("The request correlation id, tying this event to a single request/trace; null when unknown."),
116
+ tenant: z
117
+ .string()
118
+ .nullish()
119
+ .describe(
120
+ "Whose action it was — the id of the tenant the action was taken *for*, in the emitter's own tenancy model. Opaque here: compared, never interpreted. **The one dimension an emitter supplies.** The recorder stamps `project`, `environment` and `worker` from the Worker's own vars and no emitter can touch them, but no var can know which customer an action belonged to, so this one comes from the call site and is exactly as trustworthy as it — set it where the tenant is already resolved and authorized, never from an unvalidated request field. Optional and nullable: a single-tenant app has no such dimension and must not invent one, and null means *not tenant-scoped* (a CLI-originated action, a fleet-wide operator action) rather than unknown. It is not `actorId`: one person can act in two tenants, so who acted does not answer for whom.",
121
+ ),
122
+ occurredAt: z
123
+ .date()
124
+ .optional()
125
+ .describe("When the action occurred. Optional on emit — the recorder stamps the current time when absent."),
126
+ metadata: AuditMetadata.nullish().describe("Capability-specific structured detail; null when there is none."),
127
+ })
128
+ .describe("One audit event as an emitter supplies it to `emit()` — the logical event before storage.");
129
+ export type AuditEvent = z.output<typeof AuditEvent>;
130
+ export type AuditEventInput = z.input<typeof AuditEvent>;
@@ -0,0 +1,22 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { AuditEventInput } from "./auditEvent";
5
+
6
+ /**
7
+ * The recorder seam. `emit()` on the request context (`c.var.emit`) takes one {@link AuditEventInput}
8
+ * and durably records it. `@pithy-sh/audit`'s middleware replaces the default with a real D1-backed
9
+ * recorder; with no audit capability installed the default is {@link noopEmit}, so an emitter can
10
+ * always call `emit()` without first checking whether auditing is on.
11
+ *
12
+ * `emit()` is **non-fatal by contract**: it never throws and never rejects, so an audited action is
13
+ * never broken by an audit write. The real recorder swallows and logs its own failures; the no-op
14
+ * simply resolves.
15
+ */
16
+ export type AuditEmit = (event: AuditEventInput) => Promise<void>;
17
+
18
+ /**
19
+ * The default recorder when no audit capability is composed: accept the event and drop it. Resolves
20
+ * without throwing, so calling `emit()` is always safe whether or not auditing is installed.
21
+ */
22
+ export const noopEmit: AuditEmit = async () => {};
@@ -0,0 +1,196 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { z } from "zod";
5
+ import { NAME_SEGMENT } from "../naming/segment";
6
+
7
+ export const BindingType = z
8
+ .union([
9
+ z.literal("d1").describe("D1 SQL database (SQLite at the edge)."),
10
+ z.literal("kv").describe("Workers KV — eventually-consistent key/value store."),
11
+ z.literal("r2").describe("R2 bucket — S3-compatible object storage."),
12
+ z.literal("ai").describe("Workers AI — run models on Cloudflare's GPU network."),
13
+ z.literal("vectorize").describe("Vectorize — vector DB for embeddings and similarity search."),
14
+ z.literal("queue").describe("Cloudflare Queue — async message producer/consumer."),
15
+ z.literal("ratelimit").describe("Workers Rate Limiting — per-key request limiting."),
16
+ z.literal("email").describe("Email Sending binding (Cloudflare Email Service)."),
17
+ z.literal("secret").describe("Secret from the Secrets Store, encrypted at rest."),
18
+ z.literal("workflow").describe("Cloudflare Workflow — durable, multi-step execution."),
19
+ z.literal("service").describe("Service binding — direct RPC to another Worker."),
20
+ z
21
+ .literal("durable_object")
22
+ .describe(
23
+ "Durable Object — a single-threaded, stateful actor with its own storage. Backed by an exported DO class; the CLI wires the namespace binding and the class migration tag.",
24
+ ),
25
+ ])
26
+ .describe("Kind of Cloudflare resource a binding refers to.");
27
+ export type BindingType = z.infer<typeof BindingType>;
28
+
29
+ /**
30
+ * Whether a provision command creates the **resource** behind this kind of binding, rather than the
31
+ * adopter standing it up themselves.
32
+ *
33
+ * The three are the ones whose resource exists only after `pithy <capability> provision`: a `secret` is a
34
+ * Secrets Store entry (a `.dev.vars` string in local dev), a `workflow` runs in a deployed host Worker,
35
+ * and a `vectorize` binding addresses a provisioned index. `pithy add` says so in a note, at the moment
36
+ * the adopter is thinking about the capability.
37
+ *
38
+ * **This is not the same question as "did anything write the stanza".** Collapsing the two is what left
39
+ * `workflow:EMAIL_SENDER` unwritten and every route answering 500 (#258): the entry is derivable offline
40
+ * even though the Workflow it names is not deployed yet, so {@link isWrittenBinding} writes it and this
41
+ * one still reports that provisioning is what makes it work. A binding can be in both sets, and the
42
+ * Workflow is.
43
+ *
44
+ * `service` is not here. Its entry is also written for the adopter — by `pithy feature`, out of the
45
+ * target Worker's env-scoped script name — but it is wiring between an app's own Workers rather than a
46
+ * capability's provisioned resource, and no shipped capability declares one.
47
+ */
48
+ export function isProvisionedBinding(type: BindingType): boolean {
49
+ return type === "secret" || type === "workflow" || type === "vectorize";
50
+ }
51
+
52
+ /**
53
+ * Whether `pithy add` writes this kind's `wrangler.jsonc` entry, offline, at the moment it composes the
54
+ * capability.
55
+ *
56
+ * Together with {@link isProvisionedBinding} this is the whole answer to "where does this binding come
57
+ * from" — the invariant `capabilities/requiredBindings.test.ts` states. A kind in **neither** set is a
58
+ * binding a capability requires and nothing supplies: the composition refuses to assemble, the error
59
+ * names the binding, and no command anywhere fixes it. `ratelimit` was exactly that, which is why a
60
+ * scaffolded project composing `auth` answered 500 on every route including `/health` (#258).
61
+ *
62
+ * Membership turns on one question only: **is every field wrangler requires derivable offline?** A rate
63
+ * limiter's are — the stanza is a policy with no resource behind it. A Workflow's are, because the
64
+ * manifest binding states the job and the exported class, and the rest is the project-scoped naming rule.
65
+ * A Vectorize index's `index_name` is a provisioning output and is not, so an entry carrying only
66
+ * `binding` would fail wrangler's validator and stop `wrangler dev` and `wrangler deploy` both — worse
67
+ * than no entry. A `secret` has no `wrangler.jsonc` array at all.
68
+ *
69
+ * `service` is in neither, deliberately: `pithy feature` writes it out of the target Worker's env-scoped
70
+ * script name, and no shipped capability declares one.
71
+ */
72
+ export function isWrittenBinding(type: BindingType): boolean {
73
+ return (
74
+ type === "d1" ||
75
+ type === "kv" ||
76
+ type === "r2" ||
77
+ type === "ai" ||
78
+ type === "durable_object" ||
79
+ type === "ratelimit" ||
80
+ type === "workflow"
81
+ );
82
+ }
83
+
84
+ /**
85
+ * A JavaScript class name — what `className` may be.
86
+ *
87
+ * Constrained because it is no longer only a JSON value. A Durable Object's `className` is written into
88
+ * the adopter's Worker entry as `export { <className> } from "…";` (#428), which is generated TypeScript
89
+ * built out of third-party data read from `node_modules` — the shape #171, #174 and #183 each met one
90
+ * field too late. An identifier cannot close the statement and open another.
91
+ */
92
+ const CLASS_NAME = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
93
+
94
+ /**
95
+ * A bare module specifier — a package, optionally with a path into it.
96
+ *
97
+ * The other half of the generated export line, and narrowed for the same reason. No quotes, no
98
+ * whitespace, no `..`: a specifier that walked back out of its own package would re-export whatever it
99
+ * landed on, which is `configImports.isInside`'s argument in a regex.
100
+ */
101
+ const CLASS_MODULE = /^(@[a-z0-9~-][a-z0-9._~-]*\/)?[a-z0-9~-][a-z0-9._~-]*(\/[A-Za-z0-9_~-][A-Za-z0-9._~-]*)*$/;
102
+
103
+ /**
104
+ * Declares a Cloudflare binding a capability requires in the Worker env. The authoring
105
+ * shape lets `optional` be omitted (defaults false); `createBackend` normalizes via parse.
106
+ */
107
+ export const BindingSpec = z
108
+ .object({
109
+ type: BindingType.describe("Resource kind this binding provides."),
110
+ name: z.string().min(1).describe('Binding name expected in the Worker env (e.g. "DB", "SESSIONS").'),
111
+ optional: z.boolean().default(false).describe("If true, createBackend won't fail when this binding is absent."),
112
+ className: z
113
+ .string()
114
+ .min(1)
115
+ .regex(CLASS_NAME, "A className is a JavaScript identifier: letters, digits, `_` and `$`, not leading a digit.")
116
+ .optional()
117
+ .describe(
118
+ 'The exported class this binding is backed by. For a `durable_object` binding it is the DO class (e.g. "MultiplayerSession") the CLI writes into `durable_objects.bindings`, the DO class migration tag, and the `export { … }` line in the Worker entry. For a `workflow` binding it is the `WorkflowEntrypoint` subclass (e.g. "EmailSendWorkflow") the CLI writes as `class_name` — the same value the capability\'s own `WorkflowSpec.className` carries. Ignored for every other kind.',
119
+ ),
120
+ classModule: z
121
+ .string()
122
+ .min(1)
123
+ .regex(CLASS_MODULE, "A classModule is a bare module specifier: a package, optionally with a path into it.")
124
+ .optional()
125
+ .describe(
126
+ 'The module the class is exported from (e.g. "@pithy-sh/multiplayer/src/session/durableObject") — what the CLI writes the Worker entry\'s `export { <className> } from "<classModule>";` against. Required for a `durable_object` binding, because wrangler resolves `class_name` against the entry `main` names and refuses the deploy when nothing there exports it. Its own module, never the package entry point, which `pithy.config.ts` loads in Node. Ignored for every other kind.',
127
+ ),
128
+ job: z
129
+ .string()
130
+ .min(1)
131
+ // Constrained here rather than only where the name is composed, because a manifest is
132
+ // third-party data read out of `node_modules` and this string lands verbatim in the adopter's
133
+ // `wrangler.jsonc` as a Cloudflare Workflow name. `NAME_SEGMENT` is the one segment rule every
134
+ // composed name answers to, so a manifest that states something a deploy would refuse is refused
135
+ // at parse instead — attributed to the capability, before anything is written.
136
+ .regex(NAME_SEGMENT, "A job is one name segment: lowercase letters, digits, and single hyphens.")
137
+ .optional()
138
+ .describe(
139
+ 'The job within the capability this Workflow runs (e.g. "send") — the `<job>` segment of the deployed `<project>-<env>-<capability>-<job>` name, and the second half of the `<capability>/<job>` dispatch key. Meaningful only for a `workflow` binding, where it is what lets `pithy add` name the Workflow offline instead of leaving the binding unwritten. Ignored for every other kind.',
140
+ ),
141
+ service: z
142
+ .string()
143
+ .min(1)
144
+ .optional()
145
+ .describe(
146
+ 'The Worker this binding calls, named as it appears in `apps/<name>/` (e.g. "api"). Meaningful only for a `service` binding — the CLI resolves it to that Worker\'s environment-scoped script name when writing `services` into wrangler.jsonc, so worker-to-worker RPC targets the right deployment per environment. Ignored for every other kind.',
147
+ ),
148
+ remote: z
149
+ .boolean()
150
+ .optional()
151
+ .describe(
152
+ "Reach the real Cloudflare resource during local development instead of a local emulation. Set it for a binding that has none — Vectorize and Workers AI both lack local simulation — so `wrangler dev` and any Workflow host, which always runs locally, still work. Left unset rather than defaulting to false, so a spec that does not care emits no flag at all. Ignored in a deployed Worker.",
153
+ ),
154
+ })
155
+ .describe("Declares a Cloudflare binding a capability requires in the Worker env.")
156
+ .check((ctx) => {
157
+ // A durable_object binding is inert without the class that backs it — the CLI would emit a
158
+ // `durable_objects.bindings` entry with no `class_name`, which wrangler rejects. Fail here, at
159
+ // define/manifest-parse time, attributed to the capability, rather than deep in the writer.
160
+ if (ctx.value.type === "durable_object" && ctx.value.className === undefined) {
161
+ ctx.issues.push({
162
+ code: "custom",
163
+ input: ctx.value,
164
+ path: ["className"],
165
+ message: `Durable Object binding "${ctx.value.name}" needs a className — the exported DO class it is backed by.`,
166
+ });
167
+ }
168
+ // And the module that class comes from. A DO binding is two halves — the `durable_objects.bindings`
169
+ // entry and a named export on the module `main` points at — and the CLI can only write the second
170
+ // one if the capability says where the class lives. Without it the wiring is complete in the config
171
+ // and the deploy fails on "not exported in your entrypoint file" (#428). Refused at define time,
172
+ // attributed to the capability, for the same reason `className` is.
173
+ if (ctx.value.type === "durable_object" && ctx.value.classModule === undefined) {
174
+ ctx.issues.push({
175
+ code: "custom",
176
+ input: ctx.value,
177
+ path: ["classModule"],
178
+ message: `Durable Object binding "${ctx.value.name}" needs a classModule — the module its class is exported from.`,
179
+ });
180
+ }
181
+ // A service binding with no target is unresolvable: the CLI cannot know which Worker to point
182
+ // `services[].service` at, and wrangler rejects the entry. Fail at define time, attributed to the
183
+ // capability, rather than emitting a broken binding into an environment's wrangler.jsonc.
184
+ if (ctx.value.type === "service" && ctx.value.service === undefined) {
185
+ ctx.issues.push({
186
+ code: "custom",
187
+ input: ctx.value,
188
+ path: ["service"],
189
+ message: `Service binding "${ctx.value.name}" needs a service — the Worker it calls, as named in apps/.`,
190
+ });
191
+ }
192
+ });
193
+ export type BindingSpec = z.infer<typeof BindingSpec>;
194
+
195
+ /** Authoring shape for a capability's `requiredBindings`: `optional` may be omitted. */
196
+ export type BindingSpecInput = z.input<typeof BindingSpec>;