@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
@@ -0,0 +1,135 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { MessageParams } from "../i18n/catalog";
5
+ import type { ErrorPayload, KitErrorDomain } from "./payload";
6
+ import { ExtendedErrorPayload } from "./payload";
7
+ import { InternalError } from "./pithyError";
8
+
9
+ /** How much of a rejected code the public message will quote. Enough to debug, not enough to abuse. */
10
+ const QUOTED_CODE_LIMIT = 72;
11
+
12
+ /**
13
+ * A kit domain used in an adopter's code. The false branch is `unknown`, which intersects away; the
14
+ * true branch is a string the caller's own `code` cannot possibly be, so the mistake surfaces at the
15
+ * declaration with its reason spelled out in the type error rather than as `never`.
16
+ */
17
+ type ReservedDomain<Code extends string> = Code extends `${KitErrorDomain}/${string}`
18
+ ? { code: "That domain is the kit's. Namespace your code under a domain of your own." }
19
+ : unknown;
20
+
21
+ /**
22
+ * The payload behind one code, kit or adopter. `Extract` finds a kit member by its literal `code`;
23
+ * an adopter's code is branded and so extracts to nothing, and the open member stands in with the
24
+ * literal intersected back on. Type a vehicle class with it exactly as the kit's own subclasses are:
25
+ * `declare readonly payload: ErrorPayloadOf<"connect/device_code_expired">`.
26
+ */
27
+ export type ErrorPayloadOf<Code extends string> = [Extract<ErrorPayload, { code: Code }>] extends [never]
28
+ ? ExtendedErrorPayload & { code: Code }
29
+ : Extract<ErrorPayload, { code: Code }>;
30
+
31
+ /**
32
+ * Narrow a caught payload to one code. `payload.code === "core/not_found"` narrows a kit member on
33
+ * its own, but an adopter's code is branded — the brand is what keeps a kit narrow honest — so the
34
+ * same comparison against a bare literal is a type error, and `Extract` on it is silently `never`.
35
+ * This is the way in for both: one guard, kit codes and adopter codes alike.
36
+ *
37
+ * ```ts
38
+ * catch (error) {
39
+ * if (error instanceof PithyError && isErrorCode(error.payload, "connect/device_code_expired")) {
40
+ * return restartDeviceFlow();
41
+ * }
42
+ * }
43
+ * ```
44
+ */
45
+ export function isErrorCode<const Code extends `${string}/${string}`>(
46
+ payload: ErrorPayload,
47
+ code: Code,
48
+ ): payload is ErrorPayloadOf<Code> {
49
+ return payload.code === code;
50
+ }
51
+
52
+ /**
53
+ * The one door into `ErrorPayload` for a code the kit does not define. An adopter writing a control
54
+ * plane, a device-code flow, or a key-rotation lock has failures the kit has no word for; without
55
+ * this they reuse a kit code that means something else, or stop throwing `PithyError` at all.
56
+ *
57
+ * It is a function rather than a plain object literal for the same reason `defineSecretRegistry` is:
58
+ * the declaration is where the mistake is, so the declaration is where it should fail. A code under
59
+ * one of the kit's domains does not compile — the kit's domains are reserved the way `pithy_` table
60
+ * names are, so nobody redefines `auth/forbidden` with a status of their choosing, and a
61
+ * capability's own typo stays a hard failure instead of becoming somebody's custom error. The parse
62
+ * below is the second gate, for the grammar, the 4xx/5xx bound, and any caller who cast their way
63
+ * past the first.
64
+ *
65
+ * What it does **not** do is pin a status to a code. A kit member fixes one status per code, and
66
+ * that cannot survive a set the kit does not hold: this seam bounds the range and nothing more, so
67
+ * throwing `connect/device_code_expired` as a 410 from one site and a 500 from another passes every
68
+ * layer. Declare each of your codes in one vehicle class, once, and that stays true by construction.
69
+ *
70
+ * It does not move the security boundary either. The payload it returns carries `action` and
71
+ * `detail` exactly like a kit member, and the HTTP codec strips both from every member alike. An
72
+ * adopter cannot opt into leaking, because there is nothing to opt into — the wire shape has no such
73
+ * key to fill, whoever defined the code.
74
+ *
75
+ * ```ts
76
+ * export class DeviceCodeExpiredError extends PithyError {
77
+ * declare readonly payload: ErrorPayloadOf<"connect/device_code_expired">;
78
+ *
79
+ * constructor(detail?: string) {
80
+ * super(defineErrorPayload({
81
+ * code: "connect/device_code_expired",
82
+ * status: 410,
83
+ * message: "That device code has expired.",
84
+ * action: "Run pithy dashboard connect again.",
85
+ * detail,
86
+ * }));
87
+ * }
88
+ * }
89
+ * ```
90
+ */
91
+ export function defineErrorPayload<const Code extends `${string}/${string}`>(
92
+ payload: {
93
+ /** `domain/reason`, lower snake_case, under a domain of the adopter's own — never one the kit uses. */
94
+ code: Code;
95
+ /** The HTTP status this code maps to. A 4xx or a 5xx — an error is not a success. */
96
+ status: number;
97
+ /** Public, safe-to-expose summary. Sent to clients and shown in the terminal. */
98
+ message: string;
99
+ /** Operator remediation hint. Becomes the CLI action line. Never sent to a client. */
100
+ action?: string;
101
+ /** Internal context for logs + audit. NEVER serialized to clients — the HTTP codec strips it. */
102
+ detail?: string;
103
+ /**
104
+ * Values a translating client interpolates into its own wording for this code. Client-facing:
105
+ * these cross the boundary with `message`, unlike `action` and `detail`.
106
+ *
107
+ * Stated here because this parameter type is **hand-written**, not derived from the schema, and
108
+ * the `as ErrorPayloadOf<Code>` below hides the gap from the compiler. A field left off this
109
+ * literal is not a type error anywhere — it is simply a field an adopter cannot pass.
110
+ */
111
+ params?: MessageParams;
112
+ } & ReservedDomain<Code>,
113
+ ): ErrorPayloadOf<Code> {
114
+ const parsed = ExtendedErrorPayload.safeParse(payload);
115
+ if (!parsed.success) {
116
+ // An author error, like a malformed secret registry — so it reads as one. The offending code and
117
+ // status are the adopter's own source, safe to name; the caller's `detail` is not echoed back.
118
+ // The code is quoted short because `message` is the public half, and a caller who cast their way
119
+ // to a dynamic code must not get to choose how many of its bytes we reflect into a 500 body.
120
+ throw new InternalError({
121
+ message: `error payload: "${quoteCode(payload.code)}" (${payload.status}) is not a usable adopter error code.`,
122
+ action:
123
+ "Use `<your_domain>/<reason>` in lower snake_case, a 4xx or 5xx status, and a domain the kit does not own.",
124
+ detail: parsed.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; "),
125
+ });
126
+ }
127
+ // The parse proved the shape; it cannot prove to TypeScript that `code` survived as the literal
128
+ // the caller passed, because the branded template literal it validates against is wider than that.
129
+ return parsed.data as ErrorPayloadOf<Code>;
130
+ }
131
+
132
+ /** The offending code, bounded, for an author-error message that reaches a client. */
133
+ function quoteCode(code: string): string {
134
+ return code.length <= QUOTED_CODE_LIMIT ? code : `${code.slice(0, QUOTED_CODE_LIMIT)}…`;
135
+ }
@@ -0,0 +1,92 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { Context } from "hono";
5
+ import { HTTPException } from "hono/http-exception";
6
+ import type { ContentfulStatusCode } from "hono/utils/http-status";
7
+ import { z } from "zod";
8
+ import { clientError } from "./client";
9
+ import { ErrorPayload, PublicErrorPayload } from "./payload";
10
+ import { InternalError, messageOf, PithyError, ValidationError } from "./pithyError";
11
+
12
+ /**
13
+ * The HTTP surface of an error, as a real codec. `encode` (server emits) maps an in-memory
14
+ * `ErrorPayload` down to the public wire shape by calling {@link clientError} — **the security
15
+ * boundary, which drops `action` and `detail`, so neither an operator's remedy nor internal context
16
+ * can land in an HTTP body.** `decode` (client SDK parses) maps a wire body back to a payload. The one
17
+ * schema validates both directions, so the error a server sends and the error an app receives are the
18
+ * same contract.
19
+ *
20
+ * **`action` is stripped because it is written for an operator, not for a caller.** It names `pithy`
21
+ * commands, files in the adopter's repository, wrangler bindings and provider consoles — a
22
+ * description of the deployment, handed out at whatever status the error carries. The operator reads
23
+ * it on the surfaces built for them: the terminal, the CLI's `--json` line, a log, an audit row. What
24
+ * the caller gets is `message`, which is the field that has always meant "safe to expose".
25
+ *
26
+ * **The rule is not kept here.** HTTP is one transport of several — a WebSocket frame is a client
27
+ * surface that never touches this codec — so the projection lives in ./client and this encoder is one
28
+ * of its callers. Doing it the other way round is what let an `action` reach a browser over a socket.
29
+ */
30
+ export const HttpError = z.codec(PublicErrorPayload, ErrorPayload, {
31
+ decode: (wire): ErrorPayload => wire,
32
+ // `clientError` ends in a `PublicErrorPayload.parse`, and a `parse` throws. A throw from inside a
33
+ // transform walks straight past `safeParse`/`safeEncode`, which is the whole of #358 — so it is
34
+ // reported here rather than raised, and the rule itself stays in ./client where every transport
35
+ // reaches it.
36
+ //
37
+ // **Defense in depth, not a live bug.** Reaching that parse means a payload that satisfies
38
+ // `ErrorPayload` and, stripped of `action` and `detail`, no longer satisfies `PublicErrorPayload` —
39
+ // which today cannot happen, because the two are built from code sets that match member for member.
40
+ // That they match is a property of a list somebody maintains by hand, and "cannot happen" is exactly
41
+ // what was said of a date column holding text.
42
+ //
43
+ // Everything is caught, not only a `ZodError`. "`safeParse` cannot throw" admits no adjective: a
44
+ // condition this does not anticipate is still a condition a boundary reader must survive, and it is
45
+ // reported rather than swallowed — the message rides out on the issue.
46
+ encode: (payload, result): PublicErrorPayload => {
47
+ try {
48
+ return clientError(payload);
49
+ } catch (error) {
50
+ if (error instanceof z.core.$ZodError) {
51
+ // `input` is dropped rather than forwarded: it is the error payload itself, and an issue that
52
+ // carries one is a second copy of the thing that was too internal to send.
53
+ for (const issue of error.issues) result.issues.push({ ...issue, input: undefined });
54
+ } else {
55
+ result.issues.push({ code: "custom", input: undefined, message: messageOf(error) });
56
+ }
57
+ return z.NEVER;
58
+ }
59
+ },
60
+ });
61
+
62
+ /**
63
+ * Hono's own 400s are the caller's fault, not ours. `hono/validator` throws `HTTPException(400)`
64
+ * for a body it cannot even parse — a malformed JSON document, a malformed multipart form — before
65
+ * any schema runs, so no validator hook ever sees it. Left alone it would fall through to the
66
+ * generic wrap below and answer a bad request with a 500. Only 400 is translated: every other
67
+ * `HTTPException` is a framework condition we have no public wording for, and guessing one would
68
+ * put Hono's internal text on the wire.
69
+ */
70
+ function fromHttpException(error: HTTPException): PithyError | null {
71
+ if (error.status !== 400) return null;
72
+ return new ValidationError({ message: "The request body could not be parsed.", detail: error.message });
73
+ }
74
+
75
+ /**
76
+ * Hono `onError` handler. Register once on the root app (`app.onError(pithyErrorHandler)`): any
77
+ * `PithyError` becomes `{ error: <public payload> }` at its declared status; a Hono `HTTPException`
78
+ * 400 becomes `validation/invalid_input`; any other throw is wrapped as a `core/internal` 500
79
+ * carrying the original as `cause` (kept internal) and a generic public message — mirroring the
80
+ * CLI's "unexpected crash" path.
81
+ */
82
+ export function pithyErrorHandler(err: Error, c: Context): Response {
83
+ const translated = err instanceof HTTPException ? fromHttpException(err) : null;
84
+ const pithy =
85
+ err instanceof PithyError
86
+ ? err
87
+ : (translated ?? new InternalError({ detail: err instanceof Error ? err.message : String(err) }, { cause: err }));
88
+ // A kit member pins a literal status; an adopter's carries a `number` the schema has already
89
+ // bounded to 400–599. Hono types the argument as its own literal union, and that is the one gap
90
+ // between the two — the value is validated, so the assertion narrows rather than trusts.
91
+ return c.json({ error: HttpError.encode(pithy.payload) }, pithy.payload.status as ContentfulStatusCode);
92
+ }