@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.
- package/LICENSE +21 -0
- package/package.json +47 -0
- package/pithy.manifest.json +74 -0
- package/src/address/address.ts +83 -0
- package/src/audit/auditEvent.ts +130 -0
- package/src/audit/recorder.ts +22 -0
- package/src/capability/bindings.ts +196 -0
- package/src/capability/capability.ts +555 -0
- package/src/capability/client.ts +136 -0
- package/src/capability/compose.ts +76 -0
- package/src/capability/composition.ts +98 -0
- package/src/capability/config.ts +19 -0
- package/src/capability/devSecret.ts +42 -0
- package/src/capability/manifest.ts +580 -0
- package/src/capability/secretOrigin.ts +253 -0
- package/src/capability/settings.ts +155 -0
- package/src/capability/validateBindings.ts +43 -0
- package/src/capability/vanishingKey.ts +92 -0
- package/src/cloudflare-test.d.ts +20 -0
- package/src/controlPlane/audit/actions.ts +81 -0
- package/src/controlPlane/capability.ts +228 -0
- package/src/controlPlane/config/config.ts +195 -0
- package/src/controlPlane/context.ts +63 -0
- package/src/controlPlane/data/connection.ts +123 -0
- package/src/controlPlane/data/keyLifecycle.ts +159 -0
- package/src/controlPlane/data/replay.ts +39 -0
- package/src/controlPlane/data/tables.ts +51 -0
- package/src/controlPlane/discovery/adminRoute.ts +250 -0
- package/src/controlPlane/discovery/configuration.ts +280 -0
- package/src/controlPlane/discovery/drift.ts +100 -0
- package/src/controlPlane/discovery/health.ts +213 -0
- package/src/controlPlane/discovery/healthSummary.ts +486 -0
- package/src/controlPlane/error/errors.ts +125 -0
- package/src/controlPlane/http/cors.ts +244 -0
- package/src/controlPlane/http/guard.ts +223 -0
- package/src/controlPlane/http/handlers.ts +346 -0
- package/src/controlPlane/http/responses.ts +92 -0
- package/src/controlPlane/http/routes.ts +115 -0
- package/src/controlPlane/http/schemas.ts +70 -0
- package/src/controlPlane/http/verify.ts +198 -0
- package/src/controlPlane/migrations/0001_init.ts +105 -0
- package/src/controlPlane/replay/d1Guard.ts +87 -0
- package/src/controlPlane/replay/guard.ts +55 -0
- package/src/controlPlane/replay/kvGuard.ts +143 -0
- package/src/controlPlane/scope/scope.ts +102 -0
- package/src/controlPlane/token/base64url.ts +65 -0
- package/src/controlPlane/token/claims.ts +151 -0
- package/src/controlPlane/token/digest.ts +63 -0
- package/src/controlPlane/token/jws.ts +112 -0
- package/src/controlPlane/token/mint.ts +93 -0
- package/src/controlPlane/wire.ts +138 -0
- package/src/createBackend.ts +292 -0
- package/src/createEntrypoint.ts +125 -0
- package/src/data/boundParameters.ts +197 -0
- package/src/data/codecs.ts +160 -0
- package/src/data/cursor.ts +127 -0
- package/src/data/databases.ts +84 -0
- package/src/data/db.ts +53 -0
- package/src/data/withD1Retry.ts +176 -0
- package/src/entitlement/entitlement.ts +191 -0
- package/src/entitlement/gateScan.ts +107 -0
- package/src/entitlement/require.ts +199 -0
- package/src/env/ambient.ts +67 -0
- package/src/env/ci.ts +43 -0
- package/src/env/stem.ts +34 -0
- package/src/error/cause.ts +208 -0
- package/src/error/client.ts +43 -0
- package/src/error/extend.ts +135 -0
- package/src/error/http.ts +92 -0
- package/src/error/payload.ts +2195 -0
- package/src/error/pithyError.ts +281 -0
- package/src/error/terminal.ts +36 -0
- package/src/http/authContext.ts +29 -0
- package/src/http/routeContract.ts +115 -0
- package/src/http/sameOrigin.ts +67 -0
- package/src/http/signedWebhook.ts +415 -0
- package/src/http/validation.ts +41 -0
- package/src/http/verification.ts +25 -0
- package/src/i18n/acceptLanguage.ts +70 -0
- package/src/i18n/catalog.ts +113 -0
- package/src/i18n/locale.ts +153 -0
- package/src/i18n/localeMarker.ts +116 -0
- package/src/i18n/match.ts +111 -0
- package/src/i18n/registry.ts +78 -0
- package/src/i18n/translator.ts +168 -0
- package/src/index.ts +116 -0
- package/src/kv/kv.ts +437 -0
- package/src/kv/namespaces.ts +102 -0
- package/src/logger/local.ts +91 -0
- package/src/logger/logger.ts +145 -0
- package/src/logger/record.ts +83 -0
- package/src/logger/worker.ts +117 -0
- package/src/migrations/batch.ts +226 -0
- package/src/migrations/bookkeeping.ts +85 -0
- package/src/migrations/owner.ts +166 -0
- package/src/migrations/registry.ts +121 -0
- package/src/migrations/runner.ts +295 -0
- package/src/naming/domains.ts +194 -0
- package/src/naming/environment.ts +224 -0
- package/src/naming/feature.ts +162 -0
- package/src/naming/limits.ts +223 -0
- package/src/naming/provisionScope.ts +143 -0
- package/src/naming/resource.ts +266 -0
- package/src/naming/resourceNames.ts +174 -0
- package/src/naming/segment.ts +32 -0
- package/src/projection/asRead.ts +211 -0
- package/src/projection/published.ts +210 -0
- package/src/schema/describedness.ts +250 -0
- package/src/seed/compose.ts +94 -0
- package/src/seed/devLogin.ts +67 -0
- package/src/seed/exampleIdentities.ts +43 -0
- package/src/seed/metadata.ts +27 -0
- package/src/seed/seed.ts +306 -0
- package/src/seed/seededRows.ts +41 -0
- package/src/seed/writeD1.ts +103 -0
- package/src/seed/writeKv.ts +99 -0
- package/src/semver/semver.ts +156 -0
- package/src/text/comments.ts +165 -0
- package/src/version.generated.ts +16 -0
- package/src/worker/health.ts +42 -0
- package/src/worker/identity.ts +243 -0
- package/src/workflow/bindings.ts +58 -0
- package/src/workflow/dispatch.ts +240 -0
- package/src/workflow/dispatchRoute.ts +184 -0
- package/src/workflow/faults.ts +219 -0
- package/src/workflow/host.ts +307 -0
- package/src/workflow/hostEntry.ts +71 -0
- package/src/workflow/hostEnv.ts +258 -0
- package/src/workflow/loopback.ts +149 -0
- package/src/workflow/naming.ts +170 -0
- package/src/workflow/register.ts +44 -0
- package/src/workflow/schemas.ts +84 -0
- package/src/workflow/spec.ts +86 -0
- package/src/workflow/stepMessage.ts +160 -0
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Retry a D1 write through transient faults. Ported from the CMS `withD1Retry`, adapted to
|
|
6
|
+
* Pithy: no injected logger (core has none), an optional `onRetry` notifier instead, and a
|
|
7
|
+
* documented idempotency guard. The shared helper every capability's D1 writes can wrap; the
|
|
8
|
+
* audit recorder is its first consumer.
|
|
9
|
+
*
|
|
10
|
+
* D1 surfaces a whole family of transient failures by message — a statement that exceeded its
|
|
11
|
+
* timeout, a database briefly locked/busy under contention, a dropped network connection to the
|
|
12
|
+
* backing store, a storage object reset mid-flight, or an opaque internal error. All of these are
|
|
13
|
+
* worth retrying with exponential backoff; a deterministic failure (a constraint violation, a SQL
|
|
14
|
+
* error) is not. The matchers key off the error message because that is all D1 exposes; a
|
|
15
|
+
* non-matching error is re-thrown untouched (this is a passthrough wrapper, never an originator — it
|
|
16
|
+
* does not wrap foreign throws in a `PithyError`; the caller's own layer owns that). By default
|
|
17
|
+
* **every** transient class is retried, so a caller gets robust coverage without enumerating D1's
|
|
18
|
+
* error vocabulary.
|
|
19
|
+
*
|
|
20
|
+
* The idempotency guard is always active and independent of `retryOn`: a `unique-constraint`
|
|
21
|
+
* failure **on a retry** (attempt > 0) means the prior attempt already committed the row before its
|
|
22
|
+
* transport hiccup — the retry is the duplicate, not a real conflict — so the wrapper returns rather
|
|
23
|
+
* than throwing. A unique-constraint never *initiates* a retry (it is not a transient fault), so on
|
|
24
|
+
* the **first** attempt it is a genuine conflict and propagates. This is what makes a broad retry
|
|
25
|
+
* policy safe for a write: pair it with a unique idempotency key (the audit recorder's `eventId`) and
|
|
26
|
+
* a post-commit retry can never double-write.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/** A retryable D1 fault class, matched against the error message. */
|
|
30
|
+
export const D1RetryableError = {
|
|
31
|
+
/** A statement exceeded its execution timeout (`exceeded timeout`, `storage timeout`, `Timed out`). */
|
|
32
|
+
TIMEOUT: "timeout",
|
|
33
|
+
/** The database was locked or busy under contention (`database is locked/busy`, `SQLITE_BUSY`). */
|
|
34
|
+
DATABASE_BUSY: "database-busy",
|
|
35
|
+
/** The connection to the backing store dropped mid-request (`Network connection lost`, `connection … reset`). */
|
|
36
|
+
NETWORK: "network",
|
|
37
|
+
/** The storage object was reset mid-flight (`reset because its code was updated`, `caused object to be reset`). */
|
|
38
|
+
STORAGE_RESET: "storage-reset",
|
|
39
|
+
/** An opaque D1/storage internal error (`internal error`, `Cannot resolve D1`). */
|
|
40
|
+
INTERNAL: "internal",
|
|
41
|
+
/** A UNIQUE constraint failed — never a retry initiator; drives only the idempotency guard (see module doc). */
|
|
42
|
+
UNIQUE_CONSTRAINT: "unique-constraint",
|
|
43
|
+
} as const;
|
|
44
|
+
export type D1RetryableError = (typeof D1RetryableError)[keyof typeof D1RetryableError];
|
|
45
|
+
|
|
46
|
+
/** Every transient (retry-initiating) fault class — the default `retryOn`. Excludes the guard-only `unique-constraint`. */
|
|
47
|
+
export const D1_TRANSIENT_ERRORS: readonly D1RetryableError[] = [
|
|
48
|
+
D1RetryableError.TIMEOUT,
|
|
49
|
+
D1RetryableError.DATABASE_BUSY,
|
|
50
|
+
D1RetryableError.NETWORK,
|
|
51
|
+
D1RetryableError.STORAGE_RESET,
|
|
52
|
+
D1RetryableError.INTERNAL,
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
/** One D1 retry attempt's context, handed to {@link D1RetryOptions.onRetry}. */
|
|
56
|
+
export interface D1RetryInfo {
|
|
57
|
+
/** The fault class that triggered the retry. */
|
|
58
|
+
error: D1RetryableError;
|
|
59
|
+
/** The attempt that just failed (0-based). */
|
|
60
|
+
attempt: number;
|
|
61
|
+
/** The configured maximum number of retries. */
|
|
62
|
+
maxRetries: number;
|
|
63
|
+
/** How long the wrapper will wait before the next attempt, in ms. */
|
|
64
|
+
delayMs: number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Options for {@link withD1Retry}. All optional; the defaults retry timeouts three times. */
|
|
68
|
+
export interface D1RetryOptions {
|
|
69
|
+
/**
|
|
70
|
+
* Which transient fault classes initiate a retry. Defaults to {@link D1_TRANSIENT_ERRORS} — every
|
|
71
|
+
* transient class. Narrow it only when a caller deliberately wants to ignore some. Listing
|
|
72
|
+
* `unique-constraint` here has no effect — it is never a retry initiator; it only ever drives the
|
|
73
|
+
* always-on idempotency guard (see module doc).
|
|
74
|
+
*/
|
|
75
|
+
retryOn?: readonly D1RetryableError[];
|
|
76
|
+
/** Maximum retries after the initial attempt. Defaults to `3`. */
|
|
77
|
+
maxRetries?: number;
|
|
78
|
+
/** Backoff base delay, in ms. Defaults to `200`. */
|
|
79
|
+
initialDelayMs?: number;
|
|
80
|
+
/** Backoff growth factor. Defaults to `2` (200ms, 400ms, 800ms…). */
|
|
81
|
+
backoffMultiplier?: number;
|
|
82
|
+
/** Notified before each backoff wait — for an optional log line. Never throws into the retry. */
|
|
83
|
+
onRetry?: (info: D1RetryInfo) => void;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Lowercased message substrings that identify each fault class. Matching is case-insensitive (the
|
|
88
|
+
* message is lowered first) and substring-based, because D1 wraps the same underlying fault in
|
|
89
|
+
* varying envelopes (`D1_ERROR: …`, a bare SQLite message, a Workers runtime message). Each list is
|
|
90
|
+
* the set of signatures Cloudflare is known to surface for that class.
|
|
91
|
+
*/
|
|
92
|
+
const errorMatchers: Record<D1RetryableError, readonly string[]> = {
|
|
93
|
+
timeout: ["exceeded timeout", "storage timeout", "timed out"],
|
|
94
|
+
"database-busy": ["database is locked", "database is busy", "sqlite_busy"],
|
|
95
|
+
network: ["network connection lost", "connection was reset", "connection reset", "connection lost"],
|
|
96
|
+
"storage-reset": ["reset because its code was updated", "caused object to be reset", "durable object reset"],
|
|
97
|
+
internal: ["internal error", "cannot resolve d1", "an unknown error occurred"],
|
|
98
|
+
"unique-constraint": ["unique constraint failed"],
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/** Whether a lowercased message matches any signature of `fault`. */
|
|
102
|
+
function matchesFault(message: string, fault: D1RetryableError): boolean {
|
|
103
|
+
return errorMatchers[fault].some((signature) => message.includes(signature));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Whether an error is a UNIQUE-constraint failure — the idempotency-guard signal. */
|
|
107
|
+
function isUniqueConstraint(error: unknown): boolean {
|
|
108
|
+
return error instanceof Error && matchesFault(error.message.toLowerCase(), D1RetryableError.UNIQUE_CONSTRAINT);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The transient fault class an error matches within `retryOn`, or `null` if none. `unique-constraint`
|
|
113
|
+
* is excluded from initiation — it is handled only by the idempotency guard — so listing it in
|
|
114
|
+
* `retryOn` is a no-op.
|
|
115
|
+
*/
|
|
116
|
+
function matchTransient(error: unknown, retryOn: readonly D1RetryableError[]): D1RetryableError | null {
|
|
117
|
+
if (!(error instanceof Error)) return null;
|
|
118
|
+
const message = error.message.toLowerCase();
|
|
119
|
+
for (const fault of retryOn) {
|
|
120
|
+
if (fault === D1RetryableError.UNIQUE_CONSTRAINT) continue;
|
|
121
|
+
if (matchesFault(message, fault)) return fault;
|
|
122
|
+
}
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The transient D1 fault class an error is, or `null` when D1 did not report one of them.
|
|
128
|
+
*
|
|
129
|
+
* The same matchers this wrapper retries on, exported because a second layer has to ask the same
|
|
130
|
+
* question: a Workflow step decides whether to re-drive a body that raised, and "is this D1 fault
|
|
131
|
+
* transient" must have exactly one answer in this kit (pithy-sh/pithy#338). A step that classified D1
|
|
132
|
+
* for itself would be a second vocabulary — and the one that drifted would be the one nobody read.
|
|
133
|
+
*
|
|
134
|
+
* `unique-constraint` is deliberately not reported: it is the idempotency guard's signal, never a
|
|
135
|
+
* transient fault, so a caller asking "should this be retried" is told no.
|
|
136
|
+
*/
|
|
137
|
+
export function d1TransientFault(error: unknown): D1RetryableError | null {
|
|
138
|
+
return matchTransient(error, D1_TRANSIENT_ERRORS);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Run `fn`, retrying it through the configured transient D1 faults with exponential backoff.
|
|
143
|
+
* Returns `fn`'s result; on the idempotency-guard path (a unique-constraint on a retry) returns
|
|
144
|
+
* `undefined` cast to `T`, since the prior attempt already produced the effect. A non-retryable
|
|
145
|
+
* error, or exhausted retries, throws the last error untouched.
|
|
146
|
+
*/
|
|
147
|
+
export async function withD1Retry<T>(fn: () => Promise<T>, options?: D1RetryOptions): Promise<T> {
|
|
148
|
+
const retryOn = options?.retryOn ?? D1_TRANSIENT_ERRORS;
|
|
149
|
+
const maxRetries = options?.maxRetries ?? 3;
|
|
150
|
+
const initialDelayMs = options?.initialDelayMs ?? 200;
|
|
151
|
+
const backoffMultiplier = options?.backoffMultiplier ?? 2;
|
|
152
|
+
|
|
153
|
+
let lastError: unknown;
|
|
154
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
155
|
+
try {
|
|
156
|
+
return await fn();
|
|
157
|
+
} catch (error) {
|
|
158
|
+
lastError = error;
|
|
159
|
+
|
|
160
|
+
// Idempotency guard: a unique-constraint hit on a retry means a prior attempt's write already
|
|
161
|
+
// landed — the conflict is with our own earlier success, so treat it as done. On the first
|
|
162
|
+
// attempt it is a genuine conflict and falls through to propagate.
|
|
163
|
+
if (attempt > 0 && isUniqueConstraint(error)) return undefined as T;
|
|
164
|
+
|
|
165
|
+
const fault = matchTransient(error, retryOn);
|
|
166
|
+
if (!fault) throw error;
|
|
167
|
+
|
|
168
|
+
if (attempt >= maxRetries) break;
|
|
169
|
+
|
|
170
|
+
const delayMs = initialDelayMs * backoffMultiplier ** attempt;
|
|
171
|
+
options?.onRetry?.({ error: fault, attempt, maxRetries, delayMs });
|
|
172
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
throw lastError;
|
|
176
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The entitlement seam. `@pithy-sh/core` owns the contract — what an entitlement *is*, how a request
|
|
8
|
+
* resolves the caller's, and the read-time rule that decides whether one grants access. It owns no
|
|
9
|
+
* payments logic: `@pithy-sh/payments` is the provider that fills the seam, and any capability that
|
|
10
|
+
* gates on a paid feature depends on this file rather than on that package (principle 4).
|
|
11
|
+
*
|
|
12
|
+
* **The uncomposed default denies.** This is the one deliberate difference from the audit seam next
|
|
13
|
+
* door. Audit's `emit()` no-ops when no audit capability is composed, and that is safe — a missing
|
|
14
|
+
* audit write cannot grant anyone access. An entitlement check is a gate, so a missing provider must
|
|
15
|
+
* fail closed: {@link noEntitlementProvider} resolves to nothing, and every `requireEntitlement()`
|
|
16
|
+
* denies. Runtime denial is the backstop rather than the primary defense — a Worker whose routes gate
|
|
17
|
+
* on entitlements while composing no provider is a composition error `pithy doctor` reports, not
|
|
18
|
+
* something an adopter is meant to discover as production 403s.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A logical entitlement key — `pro`, `ads_removed`. Lowercase, digits, and underscores, because the
|
|
23
|
+
* key is a stable identifier gating code names, not display copy.
|
|
24
|
+
*
|
|
25
|
+
* The pattern is the load-bearing distinction of the whole capability: **a product is not an
|
|
26
|
+
* entitlement.** `pro_monthly` and `pro_annual` are two products, in three rails' catalogs, granting
|
|
27
|
+
* one entitlement — `pro`. Gating code never names a SKU.
|
|
28
|
+
*/
|
|
29
|
+
const ENTITLEMENT_KEY_PATTERN = /^[a-z][a-z0-9_]*$/;
|
|
30
|
+
|
|
31
|
+
export const EntitlementKey = z
|
|
32
|
+
.string()
|
|
33
|
+
.min(1)
|
|
34
|
+
.max(64)
|
|
35
|
+
.regex(
|
|
36
|
+
ENTITLEMENT_KEY_PATTERN,
|
|
37
|
+
"An entitlement key is lowercase letters, digits, and underscores, starting with a letter.",
|
|
38
|
+
)
|
|
39
|
+
.describe(
|
|
40
|
+
"A logical entitlement key (`pro`, `ads_removed`) — what gating code names. Never a store SKU: many products across many rails grant one key.",
|
|
41
|
+
);
|
|
42
|
+
export type EntitlementKey = z.output<typeof EntitlementKey>;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* One entitlement the caller holds, as the seam exposes it. This is the resolved read model, not a table:
|
|
46
|
+
* `@pithy-sh/payments` materializes a row per `(holder, entitlement)` and decodes it into this shape.
|
|
47
|
+
*
|
|
48
|
+
* **Who the holder is stays out of this shape, deliberately.** A payments project bills either a person or
|
|
49
|
+
* an organization, and its rows are keyed on that pair — but the seam resolves for *one* caller acting for
|
|
50
|
+
* *one* holder, so the pair is constant across a single `list()` and every element of the result would
|
|
51
|
+
* repeat it. Carrying it here would put a comparison within reach of {@link requireEntitlement} that the
|
|
52
|
+
* gate has no correct way to make (see {@link EntitlementResolver}), and would hand every non-payments
|
|
53
|
+
* provider two fields it cannot fill.
|
|
54
|
+
*
|
|
55
|
+
* `active` and `expiresAt` are both present, and both matter. The flag is an optimization written by
|
|
56
|
+
* the projection; the timestamp is the truth. {@link entitlementGrantsAccess} applies both, so a
|
|
57
|
+
* subscription that lapsed with no notification arriving stops granting access on the read path
|
|
58
|
+
* without waiting for a write.
|
|
59
|
+
*/
|
|
60
|
+
export const Entitlement = z
|
|
61
|
+
.object({
|
|
62
|
+
key: EntitlementKey.describe("The entitlement key this grant is for."),
|
|
63
|
+
active: z
|
|
64
|
+
.boolean()
|
|
65
|
+
.describe(
|
|
66
|
+
"Whether the granting purchase was in an access-granting state when the projection last wrote this row. An optimization, not the truth — `expiresAt` is rechecked on every read.",
|
|
67
|
+
),
|
|
68
|
+
expiresAt: z
|
|
69
|
+
.date()
|
|
70
|
+
.nullable()
|
|
71
|
+
.describe(
|
|
72
|
+
"When the grant lapses, or null for a grant that never does (a non-consumable purchase). Evaluated at read time, so a silent lapse revokes access without a write.",
|
|
73
|
+
),
|
|
74
|
+
source: z
|
|
75
|
+
.string()
|
|
76
|
+
.nullable()
|
|
77
|
+
.describe(
|
|
78
|
+
"Provenance — an opaque reference to whatever currently grants this entitlement (a purchase id, or a support grant). Opaque to core; the provider decides what it means.",
|
|
79
|
+
),
|
|
80
|
+
})
|
|
81
|
+
.describe("One entitlement the current caller holds, as the core seam exposes it — the resolved read model.");
|
|
82
|
+
export type Entitlement = z.infer<typeof Entitlement>;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The read-time rule, in one place: an entitlement grants access when the projection marked it active
|
|
86
|
+
* **and** it has not expired. Applied by {@link requireEntitlement} rather than left to each provider,
|
|
87
|
+
* so the recheck is a property of the seam instead of something a provider must remember.
|
|
88
|
+
*
|
|
89
|
+
* A read never writes. Repairing a stale row is the reconciliation Workflow's job, which keeps the hot
|
|
90
|
+
* path a pure read.
|
|
91
|
+
*/
|
|
92
|
+
export function entitlementGrantsAccess(entitlement: Entitlement, now: Date): boolean {
|
|
93
|
+
if (!entitlement.active) return false;
|
|
94
|
+
return entitlement.expiresAt === null || now.getTime() < entitlement.expiresAt.getTime();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Every key in `entitlements` that grants access at `now`, deduped. The set a gate is checked against. */
|
|
98
|
+
export function grantedEntitlementKeys(entitlements: readonly Entitlement[], now: Date): Set<string> {
|
|
99
|
+
const granted = new Set<string>();
|
|
100
|
+
for (const entitlement of entitlements) {
|
|
101
|
+
if (entitlementGrantsAccess(entitlement, now)) granted.add(entitlement.key);
|
|
102
|
+
}
|
|
103
|
+
return granted;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The resolver seam on the request context (`c.var.entitlements`). One method: every entitlement the
|
|
108
|
+
* **current caller** holds. The resolver is built per request by the provider's middleware, so it
|
|
109
|
+
* already knows who is asking — a gate never passes a holder, and so can never gate on the wrong one.
|
|
110
|
+
*
|
|
111
|
+
* **That property is why the seam still takes no argument, now that a provider's holder can be an
|
|
112
|
+
* organization rather than a person.** `@pithy-sh/payments` decides which subject the caller is acting
|
|
113
|
+
* for *before* it constructs the resolver: the resolution happens once, in its middleware, against the
|
|
114
|
+
* adopter's own membership model, and the resolver closes over the answer. So a gate cannot pass the
|
|
115
|
+
* wrong holder because it passes none, and the alternative — a `list(holder)` — would put that choice at
|
|
116
|
+
* every call site, where a route reaching for `c.var.auth.userId` under organization billing looks
|
|
117
|
+
* exactly like correct code and quietly checks a company's plan against one employee.
|
|
118
|
+
*
|
|
119
|
+
* `provider` names the capability answering, or is `null` when none is composed. It exists so a denial
|
|
120
|
+
* can say *which* of the two reasons it was — genuinely unentitled, or nothing wired — in `detail`,
|
|
121
|
+
* where an operator sees it and a client never does.
|
|
122
|
+
*/
|
|
123
|
+
export interface EntitlementResolver {
|
|
124
|
+
/** The capability resolving entitlements (`payments`), or null when none is composed. */
|
|
125
|
+
readonly provider: string | null;
|
|
126
|
+
/** Every entitlement the current caller holds, granting or not. The gate applies the read-time rule. */
|
|
127
|
+
list(): Promise<readonly Entitlement[]>;
|
|
128
|
+
/**
|
|
129
|
+
* Who {@link list} answered for, **for the log and the audit trail only**. Optional: a resolver that
|
|
130
|
+
* cannot say omits it, and every gate behaves exactly as it did before this existed.
|
|
131
|
+
*
|
|
132
|
+
* See {@link EntitlementHolder} for why this is a label and a tenant rather than the holder itself.
|
|
133
|
+
*/
|
|
134
|
+
holder?(): Promise<EntitlementHolder | undefined>;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* How a resolver's holder appears in a denial's `detail` and on the denial's audit row — and **nothing a
|
|
139
|
+
* gate may compare**.
|
|
140
|
+
*
|
|
141
|
+
* A provider's holder stopped being a person when `@pithy-sh/payments` learned to bill organizations, and
|
|
142
|
+
* that left a denial unable to say which of two things had happened: a company that has bought nothing,
|
|
143
|
+
* and a caller acting for **no** company at all — the ordinary state of somebody signed in with no
|
|
144
|
+
* organization selected. Both produced `payments resolved []`. They are different problems: one is a sale,
|
|
145
|
+
* the other is a subject resolver returning nothing. The same gap left the denial's audit row with no
|
|
146
|
+
* `tenant`, so a trail could not answer "which of our customers is hitting the paywall" — `actorId` cannot,
|
|
147
|
+
* because one person acts in two tenants.
|
|
148
|
+
*
|
|
149
|
+
* **So why not put the holder on the seam?** Because {@link EntitlementResolver} deliberately takes no
|
|
150
|
+
* holder and returns none: the provider decides who the caller acts for *once*, in its own middleware,
|
|
151
|
+
* before it builds the resolver, and a gate that never receives a holder can never check the wrong one.
|
|
152
|
+
* A field carrying the holder itself would put that choice back within reach — a route comparing a resolved
|
|
153
|
+
* subject against `c.var.auth.userId` looks like careful code and quietly checks a company's plan against
|
|
154
|
+
* one employee.
|
|
155
|
+
*
|
|
156
|
+
* The shape is what keeps that from happening. Neither field is the holder:
|
|
157
|
+
*
|
|
158
|
+
* - **`label`** is display text for one log line. It is not parsed, not matched, and has no format this
|
|
159
|
+
* package defines — a provider renders it however reads best (`@pithy-sh/payments` uses `user:ada` and
|
|
160
|
+
* `organization:acme`, its own encoding).
|
|
161
|
+
* - **`tenant`** is the audit dimension and nothing else, opaque exactly as `AuditEvent.tenant` is. `null`
|
|
162
|
+
* means *the holder is not a tenant* — which is the honest answer under per-person billing, where the
|
|
163
|
+
* holder is the actor and a tenant echoing `actorId` would be a dimension the app does not have.
|
|
164
|
+
*
|
|
165
|
+
* There is nothing here to compare a caller against, because there is no caller-shaped value: `label` is
|
|
166
|
+
* prose and `tenant` is a dimension. `require.test.ts` pins the property directly — the gate's decision is
|
|
167
|
+
* identical for every holder, including none.
|
|
168
|
+
*/
|
|
169
|
+
export interface EntitlementHolder {
|
|
170
|
+
/** Display text naming the holder, for a denial's `detail`. Never parsed, never matched. */
|
|
171
|
+
readonly label: string;
|
|
172
|
+
/** The tenant this was resolved for, or null when the holder is not a tenant. `AuditEvent.tenant`. */
|
|
173
|
+
readonly tenant: string | null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* The default resolver when no provider is composed: hold nothing. Every gate denies, which is the
|
|
178
|
+
* fail-closed default the seam exists to guarantee. `createBackend` seeds it on every request, and a
|
|
179
|
+
* provider's middleware replaces it.
|
|
180
|
+
*/
|
|
181
|
+
export const noEntitlementProvider: EntitlementResolver = {
|
|
182
|
+
provider: null,
|
|
183
|
+
list: async () => [],
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* The audit action a denied entitlement gate records, through the core `emit()` seam. Part of the
|
|
188
|
+
* federated `domain/reason` taxonomy — core owns this one because core owns the gate. A blocked
|
|
189
|
+
* attempt is a first-class audit record (`outcome: "denied"`), not only a 403.
|
|
190
|
+
*/
|
|
191
|
+
export const ENTITLEMENT_DENIED_ACTION = "entitlement/denied";
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Finding an entitlement gate in source text — the pure half of the composition check.
|
|
6
|
+
*
|
|
7
|
+
* `pithy doctor` scans a Worker's own source for calls to the gates in `./require`, because the runtime
|
|
8
|
+
* cannot tell the two failing cases apart: a legitimately unentitled user and a Worker that forgot to
|
|
9
|
+
* compose a provider both produce a 403. Walking a directory needs the filesystem. Deciding whether one
|
|
10
|
+
* string of source gates does not, and the halves are split here so the second can be used on its own.
|
|
11
|
+
*
|
|
12
|
+
* On its own, because an adopter has the same question about their own source with the answer inverted:
|
|
13
|
+
* not *where a gate must exist* but *where one may not*. No control-plane route in the Pithy dashboard may
|
|
14
|
+
* gate on a subscription — a customer's data must not become unreachable over a billing problem of ours —
|
|
15
|
+
* and a gate that is never reached at runtime is precisely the one a runtime test misses. That assertion
|
|
16
|
+
* is a few lines over `import.meta.glob(…, "?raw")`, provided it can import these rules rather than restate
|
|
17
|
+
* them. A restated copy drifts, and drifts quietly: both copies keep passing while they disagree.
|
|
18
|
+
*
|
|
19
|
+
* It lives beside the gates it names rather than in the CLI. A rename in `./require` is then one file's
|
|
20
|
+
* work, core is the package every adopter already depends on, and core's no-`node:` rule is asserted over
|
|
21
|
+
* every module here (`../worker-safety.test.ts`) — which is what makes this importable from a program
|
|
22
|
+
* typed for the Workers runtime at all. The CLI composes it; see `entitlementGap.ts` there.
|
|
23
|
+
*
|
|
24
|
+
* The scan is textual, deliberately. Loading a Worker's routes to inspect them would execute the code
|
|
25
|
+
* under audit, and the question is not subtle enough to need a parser: a call to one of two named helpers.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A call to either gate. `\b` before the name keeps `myRequireEntitlement(` out, and requiring the open
|
|
30
|
+
* paren keeps a bare import or a re-export out — importing a gate is not applying one, and reporting an
|
|
31
|
+
* unused import would make the check noise rather than signal.
|
|
32
|
+
*/
|
|
33
|
+
const GATE_CALL = /\brequire(?:Any)?Entitlement\s*\(/;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The source with its comments blanked, so a `// TODO: requireEntitlement("pro")` is not read as a gate.
|
|
37
|
+
*
|
|
38
|
+
* **String-aware, and it has to be.** Doing this with two regexes over raw source looks equivalent and is
|
|
39
|
+
* not: a `/*` inside a string literal opens a comment the scanner never sees closed, and everything up to
|
|
40
|
+
* the next `*/` in the file vanishes. A Worker with `app.get("/assets/*", …)` followed by any doc comment
|
|
41
|
+
* loses whatever sat between them — so a real gate goes unseen and the check reports clean. The same trap
|
|
42
|
+
* in reverse is `https://`, whose `//` is not a comment.
|
|
43
|
+
*
|
|
44
|
+
* So this walks the source once, tracking which of code / string / template / line comment / block comment
|
|
45
|
+
* it is in, and replaces comment characters with spaces rather than removing them — offsets stay put, which
|
|
46
|
+
* keeps anything reported against this text meaningful. A real parser would be the third option and is far
|
|
47
|
+
* more than a two-identifier search needs.
|
|
48
|
+
*/
|
|
49
|
+
export function withoutComments(source: string): string {
|
|
50
|
+
const out: string[] = [];
|
|
51
|
+
let index = 0;
|
|
52
|
+
while (index < source.length) {
|
|
53
|
+
const char = source[index] as string;
|
|
54
|
+
const next = source[index + 1];
|
|
55
|
+
|
|
56
|
+
if (char === "/" && next === "*") {
|
|
57
|
+
const close = source.indexOf("*/", index + 2);
|
|
58
|
+
const end = close === -1 ? source.length : close + 2;
|
|
59
|
+
// Newlines are kept so line structure survives; everything else becomes a space.
|
|
60
|
+
out.push(source.slice(index, end).replace(/[^\n]/g, " "));
|
|
61
|
+
index = end;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (char === "/" && next === "/") {
|
|
65
|
+
const newline = source.indexOf("\n", index);
|
|
66
|
+
const end = newline === -1 ? source.length : newline;
|
|
67
|
+
out.push(" ".repeat(end - index));
|
|
68
|
+
index = end;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (char === '"' || char === "'" || char === "`") {
|
|
72
|
+
// Consume the whole literal, escapes included, so nothing inside it is read as code or as a comment.
|
|
73
|
+
let scan = index + 1;
|
|
74
|
+
while (scan < source.length && source[scan] !== char) {
|
|
75
|
+
// A newline ends an unterminated quoted string; a template literal may legally span lines.
|
|
76
|
+
if (source[scan] === "\n" && char !== "`") break;
|
|
77
|
+
scan += source[scan] === "\\" ? 2 : 1;
|
|
78
|
+
}
|
|
79
|
+
const end = Math.min(scan + 1, source.length);
|
|
80
|
+
out.push(source.slice(index, end));
|
|
81
|
+
index = end;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
out.push(char);
|
|
85
|
+
index += 1;
|
|
86
|
+
}
|
|
87
|
+
return out.join("");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Whether this source applies an entitlement gate, comments and string literals discounted. */
|
|
91
|
+
export function callsGate(source: string): boolean {
|
|
92
|
+
return GATE_CALL.test(withoutComments(source));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The files, of those given, that apply an entitlement gate — sorted, so a finding reads as a set.
|
|
97
|
+
*
|
|
98
|
+
* Takes the sources rather than a directory, which is what keeps this half free of `node:fs`. The CLI
|
|
99
|
+
* hands it a walked `src/`; a test in a Workers-typed program hands it `import.meta.glob(…, "?raw")`,
|
|
100
|
+
* which is the only way that program can read files at all.
|
|
101
|
+
*/
|
|
102
|
+
export function gateCallSites(sources: Readonly<Record<string, string>>): string[] {
|
|
103
|
+
return Object.entries(sources)
|
|
104
|
+
.filter(([, source]) => callsGate(source))
|
|
105
|
+
.map(([file]) => file)
|
|
106
|
+
.sort((a, b) => a.localeCompare(b));
|
|
107
|
+
}
|