@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,250 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { z } from "zod";
5
+ import { InternalError } from "../error/pithyError";
6
+
7
+ /**
8
+ * **CLAUDE.md §Zod, made executable: the schemas are the documentation.**
9
+ *
10
+ * Every object, enum and union carries a `.describe()`, and so does every field of every object. The
11
+ * rule is stated once in `CLAUDE.md` and enforced nineteen times — once per package that declares a
12
+ * schema — and until #351 each of those nineteen enforcements was a private copy of the walk. They
13
+ * drifted, as copies do: three demanded a description on the field exactly as written, fifteen walked
14
+ * the wrapper chain to find one, one stepped through a `pipe` and eighteen did not, and exactly one
15
+ * (`@pithy-sh/audit`, #326) descended into an `array` — the shape that hides an undescribed object.
16
+ * A package's coverage was decided by which copy it inherited.
17
+ *
18
+ * This is that walk, once, in the package every other one already depends on. It is deliberately not
19
+ * test-only code: an adopter declaring capability config of their own is held to the same rule by the
20
+ * same CLI surfaces that read these descriptions, so the check belongs where they can reach it.
21
+ *
22
+ * ## This folder
23
+ *
24
+ * `src/schema/` reasons *about* Zod schemas. It is not a drawer to declare them in — §"group by
25
+ * concern, not by artifact kind" forbids that, and a schema belongs beside the thing it describes.
26
+ */
27
+
28
+ /** A Zod schema's internal definition, as much of it as this walk reads. */
29
+ interface Def {
30
+ type: string;
31
+ element?: unknown;
32
+ innerType?: unknown;
33
+ options?: unknown[];
34
+ valueType?: unknown;
35
+ keyType?: unknown;
36
+ items?: unknown[];
37
+ rest?: unknown;
38
+ left?: unknown;
39
+ right?: unknown;
40
+ }
41
+
42
+ /** The kind tag Zod puts on a schema's definition. */
43
+ function kindOf(schema: z.ZodType): string {
44
+ return (schema as unknown as { def: Def }).def.type;
45
+ }
46
+
47
+ /**
48
+ * Kinds with no schema inside them. **Listed, so that anything not listed is a hole rather than a
49
+ * leaf** — that is the whole difference between a walk that can report and one that quietly cannot.
50
+ */
51
+ const LEAVES: ReadonlySet<string> = new Set([
52
+ "string",
53
+ "number",
54
+ "int",
55
+ "bigint",
56
+ "boolean",
57
+ "date",
58
+ "symbol",
59
+ "undefined",
60
+ "null",
61
+ "void",
62
+ "any",
63
+ "unknown",
64
+ "never",
65
+ "nan",
66
+ "literal",
67
+ "enum",
68
+ "file",
69
+ "template_literal",
70
+ "custom",
71
+ "transform",
72
+ "function",
73
+ "promise",
74
+ // Cyclic by construction. Following the getter is how a walk over a recursive schema never returns.
75
+ "lazy",
76
+ ]);
77
+
78
+ /**
79
+ * Wrappers that describe **the same documented thing** as what they wrap.
80
+ *
81
+ * `TenantFilter.optional().describe("…")` and `TenantFilter.describe("…").optional()` are one described
82
+ * field written two ways, and Zod's `.description` sits on whichever came last. A walk that demanded a
83
+ * description at both depths would report a field that is documented, which is how a gate gets deleted
84
+ * rather than obeyed — so a description is carried *inward* by {@link collectMissing} and looked for
85
+ * *outward* by {@link describedInChain}, and both directions are asserted.
86
+ *
87
+ * An `array` is deliberately not on this list: describing a list says nothing about the shape of its
88
+ * elements, and an undescribed object inside an array is the gap this walk was deepened to find.
89
+ */
90
+ const TRANSPARENT: ReadonlySet<string> = new Set([
91
+ "optional",
92
+ "nullable",
93
+ "default",
94
+ "prefault",
95
+ "readonly",
96
+ "catch",
97
+ "nonoptional",
98
+ ]);
99
+
100
+ /**
101
+ * The children of one schema, by kind. **Throws for a kind this walk has never been taught** — a
102
+ * walker whose unknown case is silently empty cannot report the thing it exists to report.
103
+ *
104
+ * A `pipe` is stepped through; a **codec** is not. This gate reads the schema tree, so wrapping an
105
+ * object in a guard — `refusesVanishingKey`, and anything else that pipes into a schema — would turn
106
+ * an export it checks into an export it silently skips, and losing coverage as a side effect of adding
107
+ * a guard is the wrong direction. A codec is the §Zod exemption spelled as a type rather than as an
108
+ * accident: `z.codec` builds a `ZodCodec`, so the exemption names codec helpers and nothing else.
109
+ * Measured in #326 — stepping through codecs too reports seven fields whose union sides carry no
110
+ * description, every one of them a `SQLiteDate` or a `sqliteJson`.
111
+ */
112
+ export function childSchemas(schema: z.ZodType, path: string): z.ZodType[] {
113
+ const def = (schema as unknown as { def: Def }).def;
114
+ switch (def.type) {
115
+ case "object":
116
+ return Object.values((schema as unknown as z.ZodObject).shape) as z.ZodType[];
117
+ case "array":
118
+ return [def.element as z.ZodType];
119
+ case "optional":
120
+ case "nullable":
121
+ case "default":
122
+ case "prefault":
123
+ case "readonly":
124
+ case "catch":
125
+ case "nonoptional":
126
+ case "success":
127
+ return [def.innerType as z.ZodType];
128
+ case "union":
129
+ return (def.options ?? []) as z.ZodType[];
130
+ case "intersection":
131
+ return [def.left as z.ZodType, def.right as z.ZodType];
132
+ case "tuple":
133
+ return [...((def.items ?? []) as z.ZodType[]), ...(def.rest ? [def.rest as z.ZodType] : [])];
134
+ case "record":
135
+ case "map":
136
+ return [def.keyType as z.ZodType, def.valueType as z.ZodType].filter(Boolean);
137
+ case "set":
138
+ return [def.valueType as z.ZodType];
139
+ case "pipe":
140
+ if (schema instanceof z.ZodCodec) return [];
141
+ return [(schema as unknown as { in: z.ZodType }).in, (schema as unknown as { out: z.ZodType }).out];
142
+ default:
143
+ if (LEAVES.has(def.type)) return [];
144
+ throw new InternalError({
145
+ message: "A schema kind this walk has never been taught was reached.",
146
+ action: `Add "${def.type}" to the switch in @pithy-sh/core/src/schema/describedness, or to its LEAVES.`,
147
+ detail: `${path}: unknown Zod kind "${def.type}". A kind silently treated as a leaf is a schema nothing is holding to CLAUDE.md §Zod.`,
148
+ });
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Whether a description sits anywhere in a field's transparent-wrapper chain.
154
+ *
155
+ * `.describe("…").optional()` puts it on the inner type and `.optional().describe("…")` puts it on the
156
+ * wrapper. Both are one described field. Only {@link TRANSPARENT} wrappers are followed — an `array`
157
+ * or a `pipe` describes something other than what it holds.
158
+ */
159
+ export function describedInChain(schema: z.ZodType): boolean {
160
+ let current: z.ZodType | undefined = schema;
161
+ const seen = new Set<z.ZodType>();
162
+ while (current && !seen.has(current)) {
163
+ if (current.description) return true;
164
+ if (!TRANSPARENT.has(kindOf(current))) return false;
165
+ seen.add(current);
166
+ current = (current as unknown as { def?: { innerType?: z.ZodType } }).def?.innerType;
167
+ }
168
+ return false;
169
+ }
170
+
171
+ /** What one walk found: the complaints, and how much it actually looked at. */
172
+ export interface SchemaWalk {
173
+ /** One line per object, enum, union or field with no `.describe()` anywhere that documents it. */
174
+ missing: string[];
175
+ /** Every field of every object reached, so an empty walk is visible as an empty walk. */
176
+ fields: number;
177
+ }
178
+
179
+ /**
180
+ * Record any object/enum/union missing a `.describe()` — on the schema itself or on any of its
181
+ * fields — following every container Zod has. Codec-helper primitives are exempt (CLAUDE.md §Zod) and
182
+ * are not objects/enums/unions, so they are skipped.
183
+ *
184
+ * `described` carries a transparent wrapper's own description inward; see {@link TRANSPARENT}.
185
+ */
186
+ export function collectMissing(
187
+ schema: z.ZodType,
188
+ path: string,
189
+ walk: SchemaWalk,
190
+ seen: Set<z.ZodType>,
191
+ described = false,
192
+ ): void {
193
+ if (seen.has(schema)) return;
194
+ seen.add(schema);
195
+ const kind = kindOf(schema);
196
+ const documented = described || describedInChain(schema);
197
+ if (kind === "object") {
198
+ if (!documented) walk.missing.push(`${path} — object has no .describe()`);
199
+ for (const [key, field] of Object.entries((schema as unknown as z.ZodObject).shape)) {
200
+ const fieldSchema = field as z.ZodType;
201
+ walk.fields += 1;
202
+ if (!describedInChain(fieldSchema)) walk.missing.push(`${path}.${key} — field has no .describe()`);
203
+ collectMissing(fieldSchema, `${path}.${key}`, walk, seen);
204
+ }
205
+ return;
206
+ }
207
+ if ((kind === "union" || kind === "enum") && !documented) {
208
+ walk.missing.push(`${path} — enum/union has no .describe()`);
209
+ }
210
+ const inherited = TRANSPARENT.has(kind) && documented;
211
+ for (const [index, child] of childSchemas(schema, path).entries()) {
212
+ collectMissing(child, `${path}[${kind}:${index}]`, walk, seen, inherited);
213
+ }
214
+ }
215
+
216
+ /** One schema, walked. The entry point a unit test reaches for. */
217
+ export function undescribed(schema: z.ZodType, path = "schema"): SchemaWalk {
218
+ const walk: SchemaWalk = { missing: [], fields: 0 };
219
+ collectMissing(schema, path, walk, new Set());
220
+ return walk;
221
+ }
222
+
223
+ /** What a package-wide sweep found, including the size of the population it swept. */
224
+ export interface ExportWalk extends SchemaWalk {
225
+ /** Modules the glob handed over. Zero is a broken pattern, not a clean package. */
226
+ modules: number;
227
+ /** Exports that resolved to a Zod schema. */
228
+ schemas: number;
229
+ }
230
+
231
+ /**
232
+ * Every exported Zod schema in a package, walked.
233
+ *
234
+ * Takes the result of `import.meta.glob([...], { eager: true })`. The counts come back with the
235
+ * findings **because a sweep that found nothing and a sweep that looked at nothing are the same green
236
+ * run otherwise** — the caller pins all three against literals of its own.
237
+ */
238
+ export function undescribedExports(modules: Record<string, Record<string, unknown>>): ExportWalk {
239
+ const walk: SchemaWalk = { missing: [], fields: 0 };
240
+ const seen = new Set<z.ZodType>();
241
+ let schemas = 0;
242
+ for (const [file, mod] of Object.entries(modules)) {
243
+ for (const [name, value] of Object.entries(mod)) {
244
+ if (!(value instanceof z.ZodType)) continue;
245
+ schemas += 1;
246
+ collectMissing(value, `${file}:${name}`, walk, seen);
247
+ }
248
+ }
249
+ return { ...walk, modules: Object.keys(modules).length, schemas };
250
+ }
@@ -0,0 +1,94 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { Capability } from "../capability/capability";
5
+ import { InternalError } from "../error/pithyError";
6
+ import type { SeedSet } from "./seed";
7
+
8
+ /** Width of the zero-padded `order` prefix in a composed key — the stable sort anchor. */
9
+ const ORDER_DIGITS = 4;
10
+
11
+ /** Maximum per-set `order`, derived from the prefix width so the two never drift. */
12
+ export const MAX_SEED_ORDER = 10 ** ORDER_DIGITS - 1;
13
+
14
+ /** Options controlling how `composeSeeds` filters and resolves the registry. */
15
+ export interface ComposeSeedsOptions {
16
+ /** The environment being seeded. A set is included only if its `environments` lists this value. */
17
+ env: string;
18
+ /** Whether `example` sets are composed in (the project's `seed.includeExamples`; default off). */
19
+ includeExamples: boolean;
20
+ }
21
+
22
+ /** One capability's seed set, resolved into a stably-keyed, ready-to-run entry. */
23
+ export interface ResolvedSeedSet {
24
+ /** The namespaced, order-prefixed key: `NNNN_<capability>_<name>` — the stable sort/identity key. */
25
+ key: string;
26
+ /** The capability that contributed this set (the namespace). */
27
+ capability: string;
28
+ /** The original set. */
29
+ set: SeedSet;
30
+ }
31
+
32
+ /** The result of composing every capability's seed sets for one environment. */
33
+ export interface ComposedSeeds {
34
+ /** The runnable sets, ordered library-before-app (`order`, then the namespaced key). */
35
+ sets: ResolvedSeedSet[];
36
+ /**
37
+ * The namespaced keys of sets present in the registry but not allowed in the requested env (their
38
+ * `environments` did not list it). Surfaced so the CLI can refuse a set explicitly targeted for a
39
+ * disallowed env, rather than silently doing nothing.
40
+ */
41
+ skippedByEnv: string[];
42
+ }
43
+
44
+ /** A bad `order` is a capability-author mistake — surfaced at compose (build), so the value rides along. */
45
+ function assertValidOrder(order: number, capability: string, name: string): void {
46
+ if (!Number.isInteger(order) || order < 0 || order > MAX_SEED_ORDER) {
47
+ throw new InternalError({
48
+ message: `seed order ${order} (set "${name}" in capability "${capability}") must be an integer in 0..${MAX_SEED_ORDER}.`,
49
+ });
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Merge every capability's `seeds` into one ordered registry for `env`, mirroring the migration
55
+ * composer. Each set's key is `NNNN_<capability>_<name>` (NNNN = zero-padded `order`), so the sort
56
+ * is dependency-correct (libraries before app) and stable across releases. `example` sets are
57
+ * dropped unless `includeExamples`; sets whose `environments` does not list `env` are dropped from
58
+ * the runnable list but reported in `skippedByEnv` (the env-allowlist safety layer). A set name used
59
+ * twice within one capability throws an `InternalError` attributed to that capability.
60
+ */
61
+ export function composeSeeds(capabilities: readonly Capability[], options: ComposeSeedsOptions): ComposedSeeds {
62
+ const sets: ResolvedSeedSet[] = [];
63
+ const skippedByEnv: string[] = [];
64
+
65
+ for (const cap of capabilities) {
66
+ const seen = new Set<string>();
67
+ for (const set of cap.seeds ?? []) {
68
+ if (seen.has(set.name)) {
69
+ throw new InternalError({
70
+ message: `Duplicate seed set "${set.name}" in capability "${cap.name}".`,
71
+ action: "Give every seed set within a capability a unique name.",
72
+ });
73
+ }
74
+ seen.add(set.name);
75
+ assertValidOrder(set.order, cap.name, set.name);
76
+
77
+ // Example sets are config-gated: absent entirely unless the project opted in. This is not an
78
+ // env skip, so it is never reported — it is simply not part of the registry.
79
+ if (set.example && !options.includeExamples) continue;
80
+
81
+ const prefix = String(set.order).padStart(ORDER_DIGITS, "0");
82
+ const key = `${prefix}_${cap.name}_${set.name}`;
83
+
84
+ if (!set.environments.includes(options.env)) {
85
+ skippedByEnv.push(key);
86
+ continue;
87
+ }
88
+ sets.push({ key, capability: cap.name, set });
89
+ }
90
+ }
91
+
92
+ sets.sort((a, b) => a.set.order - b.set.order || a.key.localeCompare(b.key));
93
+ return { sets, skippedByEnv };
94
+ }
@@ -0,0 +1,67 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { z } from "zod";
5
+ import { JsonDate } from "../data/codecs";
6
+
7
+ /**
8
+ * The dev-login artifact: the one file a seed run writes so `pithy dev` can say how to sign in.
9
+ *
10
+ * It lives in core because it has two ends in two packages that must not depend on each other — the
11
+ * auth capability mints it inside its seed set, and the CLI reads it to print the ready banner. Core is
12
+ * the seam they already share, so the shape is stated once instead of being duplicated (and drifting) at
13
+ * both ends.
14
+ *
15
+ * **The file holds a live credential.** A signed session cookie for a local database is a way in for
16
+ * anyone who can read it, which is why it is written under `logs/` — gitignored by the starter template,
17
+ * so it can never be committed — and why nothing here is ever put in a `PithyError` (the payload reaches
18
+ * logs verbatim).
19
+ */
20
+
21
+ /** The directory seed artifacts are written to, relative to the project root. Gitignored, so transient. */
22
+ export const SEED_ARTIFACT_DIR = "logs";
23
+
24
+ /** The dev-login file's name within {@link SEED_ARTIFACT_DIR}. The `pithy dev` banner reads it by this name. */
25
+ export const DEV_LOGIN_FILE = "dev-login.json";
26
+
27
+ /** The dev-login file's path relative to the project root — what the banner and the docs both name. */
28
+ export const DEV_LOGIN_PATH = `${SEED_ARTIFACT_DIR}/${DEV_LOGIN_FILE}`;
29
+
30
+ /**
31
+ * The dev-login **route**: where a `dev` composition serves the seeded session as a `Set-Cookie` and a
32
+ * redirect, so signing in is a URL rather than a value pasted into a browser console.
33
+ *
34
+ * It is stated here, beside the file, because the two ends are the same two packages the file already
35
+ * had to reconcile — the auth capability registers it, and `pithy dev` opens it — and neither may
36
+ * import the other. A second spelling in one of them is a `l` that opens a 404.
37
+ *
38
+ * **`__pithy/` is the namespace, and the reservation is the point.** An adopter's own routes are theirs;
39
+ * anything the kit serves that is not part of a capability's public surface lives under this prefix, so
40
+ * a route added here can never collide with an application path someone already shipped.
41
+ *
42
+ * Registered **only** in a `dev` composition, and never under CI. It mints an authenticated session with
43
+ * no credential presented, which is the whole risk of the feature and the reason its gates live at
44
+ * registration rather than inside the handler.
45
+ */
46
+ export const DEV_LOGIN_ROUTE = "/__pithy/dev-login";
47
+
48
+ /**
49
+ * A seeded dev login — everything a browser needs to be signed in as a seeded user, and nothing else.
50
+ * `z.input` is the JSON on disk (dates as ISO-8601 strings); `z.output` is the app shape.
51
+ */
52
+ export const DevLogin = z
53
+ .object({
54
+ email: z.string().describe("The seeded user this cookie signs in as. Shown on the `pithy dev` ready banner."),
55
+ userId: z.string().describe("The seeded user's id, so a tool can correlate the cookie with the seeded rows."),
56
+ cookieName: z
57
+ .string()
58
+ .describe("The cookie name the auth capability's session is read from, e.g. `better-auth.session_token`."),
59
+ cookieValue: z
60
+ .string()
61
+ .describe(
62
+ "The signed, URI-encoded cookie value. A live credential for the local database — never logged, never committed, never in an error payload.",
63
+ ),
64
+ expiresAt: JsonDate.describe("When the seeded session expires. ISO-8601 text on disk; a `Date` in app code."),
65
+ })
66
+ .describe("A seeded dev login: the signed session cookie for one seeded user, written to `logs/dev-login.json`.");
67
+ export type DevLogin = z.output<typeof DevLogin>;
@@ -0,0 +1,43 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /**
5
+ * The canonical example identities — a small, fixed cast of demo users that every capability's
6
+ * example seed set references. It is what makes `pithy seed` (with `seed.includeExamples`) fill a
7
+ * fresh backend with *connected* data instead of isolated rows: the same three users own the seeded
8
+ * auth records, leaderboard scores, ledger balances, and multiplayer results, so a new project can
9
+ * see the whole stack working together in `dev` without authoring a fixture first.
10
+ *
11
+ * This is the shared vocabulary that lets each capability seed user-linked rows without depending on
12
+ * `@pithy-sh/auth` — or on each other. Every module reads these ids from `@pithy-sh/core`, the one
13
+ * seam they all share (capabilities depend on core seams, never each other's internals). It is a
14
+ * seeding *convention*, the peer of the standard asset-metadata block: a handful of stable
15
+ * constants, never a data set.
16
+ *
17
+ * The ids are stable and human-legible (`example-<name>`) so seeded rows are obvious in a dev
18
+ * database. Emails use the reserved `example.com` domain (RFC 2606) so a seeded user can never
19
+ * collide with, or be mistaken for, a real address.
20
+ */
21
+
22
+ /** One canonical demo user — the shared identity a capability's example seed hangs its rows off. */
23
+ export interface ExampleIdentity {
24
+ /** The stable demo user id — used as the `userId`/owner across every capability's example seed. */
25
+ id: string;
26
+ /** The user's display name. */
27
+ name: string;
28
+ /** The user's unique email, on the reserved `example.com` domain. */
29
+ email: string;
30
+ }
31
+
32
+ /**
33
+ * The canonical demo cast, in a stable order. Referenced by every capability's example seed set so
34
+ * the seeded data is connected across tables. Keep it tiny — this is a demonstration, not a dataset.
35
+ */
36
+ export const EXAMPLE_IDENTITIES = [
37
+ { id: "example-ada", name: "Ada Lovelace", email: "ada@example.com" },
38
+ { id: "example-grace", name: "Grace Hopper", email: "grace@example.com" },
39
+ { id: "example-alan", name: "Alan Turing", email: "alan@example.com" },
40
+ ] as const satisfies readonly ExampleIdentity[];
41
+
42
+ /** The three canonical identities by name, for readable references in fixtures (`EXAMPLE_ADA.id`). */
43
+ export const [EXAMPLE_ADA, EXAMPLE_GRACE, EXAMPLE_ALAN] = EXAMPLE_IDENTITIES;
@@ -0,0 +1,27 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /**
5
+ * The standard Cloudflare-asset metadata convention.
6
+ *
7
+ * Every write to a shared Images/Stream store (seeded assets are the first consumer, but the
8
+ * convention is account-wide) stamps a small, stable metadata block so an asset can be attributed
9
+ * and scoped without a separate lookup — most importantly to the environment that created it, so a
10
+ * staging teardown never touches a production asset. App-defined fields (e.g. `userId`) merge on
11
+ * top; the standard keys always win, so a caller's `extra` can never overwrite `pithyEnv`.
12
+ */
13
+
14
+ /** The metadata key carrying the environment an asset was created in — the scoping anchor. */
15
+ export const PITHY_ENV_METADATA_KEY = "pithyEnv";
16
+
17
+ /** The metadata keys Pithy sets on every shared-asset write. Kept stable and documented. */
18
+ export const STANDARD_ASSET_METADATA_KEYS: readonly string[] = [PITHY_ENV_METADATA_KEY];
19
+
20
+ /**
21
+ * Build the metadata block for a shared-asset upload: the standard keys (at minimum
22
+ * `{ pithyEnv: env }`) merged over any app-defined `extra`. The standard keys are applied last, so
23
+ * they always win — `extra` can add fields but never override the scoping anchor.
24
+ */
25
+ export function buildAssetMetadata(env: string, extra?: Record<string, string>): Record<string, string> {
26
+ return { ...extra, [PITHY_ENV_METADATA_KEY]: env };
27
+ }