@lovrozagar/honey 0.1.0 → 0.2.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 (263) hide show
  1. package/README.md +1596 -6
  2. package/dist/accepts.d.ts +6 -0
  3. package/dist/accepts.d.ts.map +1 -0
  4. package/dist/body-limit.d.ts +22 -0
  5. package/dist/body-limit.d.ts.map +1 -0
  6. package/dist/build/index.d.ts +42 -0
  7. package/dist/build/index.d.ts.map +1 -0
  8. package/dist/cli.d.ts +3 -0
  9. package/dist/cli.d.ts.map +1 -0
  10. package/dist/client/error.d.ts +14 -0
  11. package/dist/client/error.d.ts.map +1 -0
  12. package/dist/client/http.d.ts +88 -0
  13. package/dist/client/http.d.ts.map +1 -0
  14. package/dist/client/index.d.ts +22 -0
  15. package/dist/client/index.d.ts.map +1 -0
  16. package/dist/client/sdk.d.ts +34 -0
  17. package/dist/client/sdk.d.ts.map +1 -0
  18. package/dist/client/sse.d.ts +10 -0
  19. package/dist/client/sse.d.ts.map +1 -0
  20. package/dist/client/types.d.ts +124 -0
  21. package/dist/client/types.d.ts.map +1 -0
  22. package/dist/client/ws.d.ts +15 -0
  23. package/dist/client/ws.d.ts.map +1 -0
  24. package/dist/client-mcp/server.d.ts +10 -0
  25. package/dist/client-mcp/server.d.ts.map +1 -0
  26. package/dist/client-mcp/types.d.ts +16 -0
  27. package/dist/client-mcp/types.d.ts.map +1 -0
  28. package/dist/cloudflare/realtime.d.ts +30 -0
  29. package/dist/cloudflare/realtime.d.ts.map +1 -0
  30. package/dist/codegen-go-cli.d.ts +22 -0
  31. package/dist/codegen-go-cli.d.ts.map +1 -0
  32. package/dist/codegen-go.d.ts +29 -0
  33. package/dist/codegen-go.d.ts.map +1 -0
  34. package/dist/codegen-hash.d.ts +3 -0
  35. package/dist/codegen-hash.d.ts.map +1 -0
  36. package/dist/codegen-ir.d.ts +123 -0
  37. package/dist/codegen-ir.d.ts.map +1 -0
  38. package/dist/codegen-loaders.d.ts +12 -0
  39. package/dist/codegen-loaders.d.ts.map +1 -0
  40. package/dist/codegen-mcp.d.ts +13 -0
  41. package/dist/codegen-mcp.d.ts.map +1 -0
  42. package/dist/codegen-python.d.ts +21 -0
  43. package/dist/codegen-python.d.ts.map +1 -0
  44. package/dist/codegen-rust.d.ts +18 -0
  45. package/dist/codegen-rust.d.ts.map +1 -0
  46. package/dist/codegen-sanitize.d.ts +3 -0
  47. package/dist/codegen-sanitize.d.ts.map +1 -0
  48. package/dist/codegen-schema-naming.d.ts +22 -0
  49. package/dist/codegen-schema-naming.d.ts.map +1 -0
  50. package/dist/codegen.d.ts +178 -0
  51. package/dist/codegen.d.ts.map +1 -0
  52. package/dist/context.d.ts +56 -0
  53. package/dist/context.d.ts.map +1 -0
  54. package/dist/cookie-sign.d.ts +3 -0
  55. package/dist/cookie-sign.d.ts.map +1 -0
  56. package/dist/cookie.d.ts +12 -0
  57. package/dist/cookie.d.ts.map +1 -0
  58. package/dist/cors.d.ts +13 -0
  59. package/dist/cors.d.ts.map +1 -0
  60. package/dist/crypto.d.ts +2 -0
  61. package/dist/crypto.d.ts.map +1 -0
  62. package/dist/csrf.d.ts +9 -0
  63. package/dist/csrf.d.ts.map +1 -0
  64. package/dist/curl-logger.d.ts +33 -0
  65. package/dist/curl-logger.d.ts.map +1 -0
  66. package/dist/detect-runtime.d.ts +16 -0
  67. package/dist/detect-runtime.d.ts.map +1 -0
  68. package/dist/error.d.ts +21 -0
  69. package/dist/error.d.ts.map +1 -0
  70. package/dist/errors.d.ts +40 -0
  71. package/dist/errors.d.ts.map +1 -0
  72. package/dist/etag.d.ts +9 -0
  73. package/dist/etag.d.ts.map +1 -0
  74. package/dist/feature-load.d.ts +3 -0
  75. package/dist/feature-load.d.ts.map +1 -0
  76. package/dist/go-type-emitter.d.ts +33 -0
  77. package/dist/go-type-emitter.d.ts.map +1 -0
  78. package/dist/honey-response.d.ts +82 -0
  79. package/dist/honey-response.d.ts.map +1 -0
  80. package/dist/i18n-slot.d.ts +7 -0
  81. package/dist/i18n-slot.d.ts.map +1 -0
  82. package/dist/i18n.d.ts +26 -0
  83. package/dist/i18n.d.ts.map +1 -0
  84. package/dist/index.d.ts +470 -0
  85. package/dist/index.d.ts.map +1 -0
  86. package/dist/init.d.ts +7 -0
  87. package/dist/init.d.ts.map +1 -0
  88. package/dist/input.d.ts +6 -0
  89. package/dist/input.d.ts.map +1 -0
  90. package/dist/ip-restrict.d.ts +12 -0
  91. package/dist/ip-restrict.d.ts.map +1 -0
  92. package/dist/logger.d.ts +45 -0
  93. package/dist/logger.d.ts.map +1 -0
  94. package/dist/meta-spec.d.ts +81 -0
  95. package/dist/meta-spec.d.ts.map +1 -0
  96. package/dist/middleware.d.ts +60 -0
  97. package/dist/middleware.d.ts.map +1 -0
  98. package/dist/node-request.d.ts +59 -0
  99. package/dist/node-request.d.ts.map +1 -0
  100. package/dist/node.d.ts +13 -0
  101. package/dist/node.d.ts.map +1 -0
  102. package/dist/openapi/register.d.ts +3 -0
  103. package/dist/openapi/register.d.ts.map +1 -0
  104. package/dist/openapi/scalar.d.ts +12 -0
  105. package/dist/openapi/scalar.d.ts.map +1 -0
  106. package/dist/openapi/spec-factory.d.ts +31 -0
  107. package/dist/openapi/spec-factory.d.ts.map +1 -0
  108. package/dist/openapi/spec.d.ts +19 -0
  109. package/dist/openapi/spec.d.ts.map +1 -0
  110. package/dist/openapi/swagger.d.ts +12 -0
  111. package/dist/openapi/swagger.d.ts.map +1 -0
  112. package/dist/plugin.d.ts +167 -0
  113. package/dist/plugin.d.ts.map +1 -0
  114. package/dist/powered-by.d.ts +7 -0
  115. package/dist/powered-by.d.ts.map +1 -0
  116. package/dist/pretty-json.d.ts +10 -0
  117. package/dist/pretty-json.d.ts.map +1 -0
  118. package/dist/proxy.d.ts +51 -0
  119. package/dist/proxy.d.ts.map +1 -0
  120. package/dist/python-type-emitter.d.ts +11 -0
  121. package/dist/python-type-emitter.d.ts.map +1 -0
  122. package/dist/realtime/buffer.d.ts +18 -0
  123. package/dist/realtime/buffer.d.ts.map +1 -0
  124. package/dist/realtime/bus.d.ts +11 -0
  125. package/dist/realtime/bus.d.ts.map +1 -0
  126. package/dist/realtime/longpoll.d.ts +23 -0
  127. package/dist/realtime/longpoll.d.ts.map +1 -0
  128. package/dist/realtime/protocol.d.ts +50 -0
  129. package/dist/realtime/protocol.d.ts.map +1 -0
  130. package/dist/realtime/route.d.ts +42 -0
  131. package/dist/realtime/route.d.ts.map +1 -0
  132. package/dist/realtime/runtime.d.ts +74 -0
  133. package/dist/realtime/runtime.d.ts.map +1 -0
  134. package/dist/request-id.d.ts +12 -0
  135. package/dist/request-id.d.ts.map +1 -0
  136. package/dist/request-to-curl.d.ts +9 -0
  137. package/dist/request-to-curl.d.ts.map +1 -0
  138. package/dist/response.d.ts +57 -0
  139. package/dist/response.d.ts.map +1 -0
  140. package/dist/rust-type-emitter.d.ts +32 -0
  141. package/dist/rust-type-emitter.d.ts.map +1 -0
  142. package/dist/secure-headers.d.ts +16 -0
  143. package/dist/secure-headers.d.ts.map +1 -0
  144. package/dist/serve-register.d.ts +2 -0
  145. package/dist/serve-register.d.ts.map +1 -0
  146. package/dist/serve-slot.d.ts +6 -0
  147. package/dist/serve-slot.d.ts.map +1 -0
  148. package/dist/serve.d.ts +21 -0
  149. package/dist/serve.d.ts.map +1 -0
  150. package/dist/server-timing.d.ts +9 -0
  151. package/dist/server-timing.d.ts.map +1 -0
  152. package/dist/static.d.ts +12 -0
  153. package/dist/static.d.ts.map +1 -0
  154. package/dist/telemetry/otel.d.ts +59 -0
  155. package/dist/telemetry/otel.d.ts.map +1 -0
  156. package/dist/testing.d.ts +24 -0
  157. package/dist/testing.d.ts.map +1 -0
  158. package/dist/timeout.d.ts +7 -0
  159. package/dist/timeout.d.ts.map +1 -0
  160. package/dist/tree.d.ts +90 -0
  161. package/dist/tree.d.ts.map +1 -0
  162. package/dist/ts-type-emitter.d.ts +4 -0
  163. package/dist/ts-type-emitter.d.ts.map +1 -0
  164. package/dist/type-emitter.d.ts +8 -0
  165. package/dist/type-emitter.d.ts.map +1 -0
  166. package/dist/type-extractor.d.ts +39 -0
  167. package/dist/type-extractor.d.ts.map +1 -0
  168. package/dist/types.d.ts +451 -0
  169. package/dist/types.d.ts.map +1 -0
  170. package/dist/validation.d.ts +18 -0
  171. package/dist/validation.d.ts.map +1 -0
  172. package/dist/ws/bun.d.ts +28 -0
  173. package/dist/ws/bun.d.ts.map +1 -0
  174. package/dist/ws/cloudflare.d.ts +55 -0
  175. package/dist/ws/cloudflare.d.ts.map +1 -0
  176. package/dist/ws/deno.d.ts +13 -0
  177. package/dist/ws/deno.d.ts.map +1 -0
  178. package/dist/ws/node.d.ts +10 -0
  179. package/dist/ws/node.d.ts.map +1 -0
  180. package/dist/yaml.d.ts +4 -0
  181. package/dist/yaml.d.ts.map +1 -0
  182. package/docs/api-ref/go.md +82 -82
  183. package/docs/api-ref/python.md +82 -82
  184. package/docs/api-ref/rust.md +82 -82
  185. package/docs/api-ref/typescript.md +82 -82
  186. package/docs/invalidate-spec.md +13 -13
  187. package/docs/meta-spec.md +513 -0
  188. package/docs/packaging.md +85 -0
  189. package/docs/sdk.md +87 -73
  190. package/package.json +195 -51
  191. package/src/body-limit.ts +2 -1
  192. package/src/build/index.ts +2 -9
  193. package/src/cli.ts +2 -3
  194. package/src/client/http.ts +204 -223
  195. package/src/client/index.ts +6 -14
  196. package/src/client/sdk.ts +24 -17
  197. package/src/client/types.ts +4 -9
  198. package/src/client/ws.ts +3 -5
  199. package/src/client-mcp/server.ts +1 -4
  200. package/src/cloudflare/realtime.ts +8 -10
  201. package/src/codegen-go-cli.ts +74 -67
  202. package/src/codegen-go.ts +71 -51
  203. package/src/codegen-ir.ts +16 -27
  204. package/src/codegen-loaders.ts +5 -8
  205. package/src/codegen-mcp.ts +9 -27
  206. package/src/codegen-python.ts +47 -27
  207. package/src/codegen-rust.ts +110 -66
  208. package/src/codegen-sanitize.ts +1 -7
  209. package/src/codegen-schema-naming.ts +8 -2
  210. package/src/codegen.ts +299 -196
  211. package/src/context.ts +91 -95
  212. package/src/cookie-sign.ts +5 -13
  213. package/src/cookie.ts +1 -2
  214. package/src/cors.ts +4 -6
  215. package/src/crypto.ts +1 -4
  216. package/src/csrf.ts +3 -3
  217. package/src/curl-logger.ts +14 -32
  218. package/src/detect-runtime.ts +1 -3
  219. package/src/errors.ts +1 -3
  220. package/src/etag.ts +3 -4
  221. package/src/go-type-emitter.ts +45 -40
  222. package/src/honey-response.ts +1 -3
  223. package/src/i18n.ts +3 -12
  224. package/src/index.ts +1168 -1412
  225. package/src/ip-restrict.ts +2 -1
  226. package/src/logger.ts +13 -13
  227. package/src/meta-spec.ts +0 -0
  228. package/src/middleware.ts +111 -79
  229. package/src/node-request.ts +2 -1
  230. package/src/node.ts +9 -10
  231. package/src/openapi/register.ts +1 -2
  232. package/src/openapi/scalar.ts +9 -9
  233. package/src/openapi/spec-factory.ts +2 -2
  234. package/src/openapi/spec.ts +26 -19
  235. package/src/openapi/swagger.ts +9 -9
  236. package/src/plugin.ts +226 -256
  237. package/src/powered-by.ts +2 -1
  238. package/src/pretty-json.ts +2 -1
  239. package/src/proxy.ts +1 -3
  240. package/src/python-type-emitter.ts +37 -12
  241. package/src/realtime/route.ts +1 -1
  242. package/src/realtime/runtime.ts +9 -12
  243. package/src/request-id.ts +3 -4
  244. package/src/request-to-curl.ts +1 -4
  245. package/src/response.ts +150 -199
  246. package/src/rust-type-emitter.ts +66 -25
  247. package/src/secure-headers.ts +2 -1
  248. package/src/serve.ts +3 -12
  249. package/src/server-timing.ts +2 -1
  250. package/src/static.ts +3 -4
  251. package/src/telemetry/otel.ts +1 -6
  252. package/src/testing.ts +2 -11
  253. package/src/timeout.ts +2 -1
  254. package/src/tree.ts +12 -27
  255. package/src/ts-type-emitter.ts +71 -15
  256. package/src/type-emitter.ts +226 -237
  257. package/src/type-extractor.ts +260 -334
  258. package/src/types.ts +168 -68
  259. package/src/validation.ts +7 -30
  260. package/src/ws/bun.ts +2 -9
  261. package/src/ws/cloudflare.ts +13 -16
  262. package/src/ws/deno.ts +31 -40
  263. package/src/ws/node.ts +4 -19
@@ -0,0 +1,513 @@
1
+ # `.metaSpec()` — declarative control over what reaches the OpenAPI document
2
+
3
+ Status: implemented in `@lovrozagar/honey` (tree of record: `packages/core`).
4
+
5
+ ## 1. The problem
6
+
7
+ Before this feature, `generateOpenApi` mapped route meta onto the operation object with a
8
+ hardcoded allowlist of eight fields:
9
+
10
+ ```ts
11
+ summary, description, tags, deprecated, operationId, security, invalidate → x-invalidate, mcp → x-mcp
12
+ ```
13
+
14
+ Everything else on `.meta()` was **silently discarded**. That has three consequences:
15
+
16
+ 1. **Facts the framework already knows never leave the process.** An app that classifies every
17
+ route by rate-limit category, permission set, or tenancy carries those facts on route meta and
18
+ the document never sees them. Downstream consumers (SDK codegen, gateways, contract testers such
19
+ as [`oat`](https://github.com/lovrozagar/oat)) then re-derive them by heuristic, or don't run the
20
+ check at all.
21
+ 2. **Hiding is accidental, not deliberate.** Fields that _must not_ be published — a captcha
22
+ provider, a test-bypass surface, internal worker routing — were protected only because the
23
+ allowlist happened not to know about them. Nothing recorded the intent.
24
+ 3. **Rot is silent.** Adding a field to the app's meta type produced no signal anywhere. The
25
+ default outcome of growth was "dropped".
26
+
27
+ `.metaSpec()` replaces the allowlist with a **declarative, total, per-document policy**.
28
+
29
+ ## 2. Public API
30
+
31
+ ```ts
32
+ honey<Env>()
33
+ .meta<HoneyMeta<AppRouteMeta>>()
34
+ .metaSpec({
35
+ strict: "error",
36
+
37
+ meta: {
38
+ permissions: "x-permissions",
39
+ rateLimit: { key: "x-rate-limit", map: (v) => ({ category: v, rps: RPS[v] }) },
40
+ summary: { key: "summary" },
41
+ worker: false,
42
+ captcha: false,
43
+ },
44
+
45
+ schema: {
46
+ entity: {
47
+ from: ["output"],
48
+ search: "deep",
49
+ expand: (e) => ({
50
+ "x-entity": e.table,
51
+ "x-generated": e.generated,
52
+ "x-immutable": e.immutable,
53
+ "x-soft-delete": e.softDelete ? { field: e.softDelete } : undefined,
54
+ }),
55
+ },
56
+ },
57
+
58
+ profiles: {
59
+ // default-deny: a tag added tomorrow is absent here until someone opts it in
60
+ public: { include: ["x-entity", "x-query"] },
61
+ },
62
+ })
63
+ ```
64
+
65
+ honey does not own the shape of `e` above. The descriptor is a contract between whatever stamps the
66
+ schema (an ORM, a DTO layer) and the app's policy block; honey only carries it. Agree those field
67
+ names once, on the publisher's side, before either end writes code against them.
68
+
69
+ ### 2.1 Entry forms
70
+
71
+ A policy entry describes one **source key** (a meta key, or a key read off a schema) and what it
72
+ contributes to the operation object.
73
+
74
+ | Form | Meaning |
75
+ | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
76
+ | `false` | **Hidden.** Never emitted, in any profile. Explicit and greppable. |
77
+ | `"x-foo"` / `"summary"` | **Verbatim.** The source value is written to that operation key unchanged. |
78
+ | `{ key, map?, schema?, on?, profiles? }` | **Single target.** `map` transforms the value; returning `undefined` omits it for that route. |
79
+ | `{ expand, schema?, on?, profiles? }` | **Fan-out.** Returns a record of operation keys → values; `undefined` values and an `undefined` return omit. |
80
+
81
+ Common options:
82
+
83
+ - `on?: "http" | "ws" | "both"` — which operation kinds the entry applies to. Default `"both"`.
84
+ - `profiles?: readonly string[]` — restrict the entry to named documents. Default: every profile.
85
+ - `schema?: StandardSchemaLike` — validates the produced value (see §6).
86
+
87
+ `map` / `expand` receive `(value, ctx)` where `ctx` is `{ method, path, meta, profile, source }`.
88
+
89
+ ### 2.2 Target keys
90
+
91
+ An entry may target:
92
+
93
+ - any **extension** key matching `x-*`, or
94
+ - one of the **standard operation fields** honey allows a policy to set:
95
+ `summary`, `description`, `tags`, `deprecated`, `operationId`, `security`, `externalDocs`,
96
+ `servers`.
97
+
98
+ `responses`, `parameters`, `requestBody` and `callbacks` are **reserved** — they are derived from
99
+ schemas and error keys, and a policy that wrote them would produce a document that contradicts the
100
+ route. Targeting them is a build error. Any other non-`x-` key is a build error too (it would be
101
+ dropped by spec-conformant readers, which is the failure mode this feature exists to remove).
102
+
103
+ ### 2.3 Schema-derived facts
104
+
105
+ The `schema` section is keyed by **the entry's own name**. `read` says which schema-metadata key it
106
+ reads, defaulting to that name. Sources:
107
+
108
+ ```
109
+ "output" | "input.json" | "input.search" | "input.form" | "input.headers" | "input.cookies"
110
+ ```
111
+
112
+ `from` defaults to all of them, in that order; the first source that carries the key wins.
113
+ `"output"` reads the JSON output schema of the lowest declared 2xx status.
114
+
115
+ `search` decides how deeply to look:
116
+
117
+ | `search` | Behavior |
118
+ | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
119
+ | `"root"` (default) | the schema root, seeing through one level of `items` so a bare `array<Entity>` list output still finds the descriptor stamped on the item |
120
+ | `"deep"` | walks `properties`, `items` and composition members breadth-first (max depth 6) and takes the **shallowest** match — for envelope shapes like `{ articles: [Article], count, hasMore, nextCursor }` |
121
+
122
+ A deep search that finds two _different_ values at the same depth is a build error
123
+ (`AMBIGUOUS_SCHEMA_KEY`), not a guess. The same descriptor reached by several paths is fine.
124
+
125
+ Reading is vendor-neutral: honey converts the schema to JSON Schema (it already does, for the
126
+ request/response bodies) and reads app keys off the **root** of that JSON Schema. For Zod that is
127
+ exactly what `.meta({ … })` produces, so an ORM that stamps a descriptor onto the schema it already
128
+ uses to parse the request gets the extension for free — and the tag cannot drift from runtime
129
+ behavior, because both read the same object.
130
+
131
+ One schema key routinely fans out to several extensions, which is why `expand` exists.
132
+
133
+ #### One reserved key carrying a union
134
+
135
+ A publisher with more than one kind of fact does not want more than one collision surface, so the
136
+ mature shape is a single reserved key holding a discriminated union with a version field —
137
+ [comb](https://github.com/lovrozagar/comb) stamps `x-comb` with `{ v, kind: "entity" | "query", … }`.
138
+ The two members arrive on _different schemas of the same operation_: the entity descriptor on the
139
+ response, the query descriptor on the search input.
140
+
141
+ `read` plus `match` expresses that. Several entries read one key and are told apart by the union's
142
+ discriminant:
143
+
144
+ ```ts
145
+ schema: {
146
+ entityFacts: {
147
+ read: "x-comb",
148
+ match: { kind: "entity" },
149
+ version: { max: 1 },
150
+ from: ["output"],
151
+ search: "deep",
152
+ expand: (e) => ({
153
+ "x-entity": e.name,
154
+ "x-identity": e.identity,
155
+ "x-soft-delete": e.softDelete ?? undefined,
156
+ "x-tenant-column": e.tenantColumn ?? undefined,
157
+ }),
158
+ },
159
+ queryFacts: {
160
+ read: "x-comb",
161
+ match: { kind: "query" },
162
+ version: { max: 1 },
163
+ from: ["input.search"],
164
+ expand: (q) => ({
165
+ "x-query": { filter: q.filterable, sort: q.sortable, maxLimit: q.maxLimit },
166
+ "x-searchable": q.searchable ?? undefined,
167
+ }),
168
+ },
169
+ }
170
+ ```
171
+
172
+ Resolution rules:
173
+
174
+ - Sources are walked in `from` order. A descriptor that fails `match` **does not end the walk** —
175
+ an entity entry must be free to step past the query descriptor when both entries search every
176
+ source.
177
+ - If no source yields a match but a descriptor _was_ found under `read`, that is a build error
178
+ (`DESCRIPTOR_MISMATCH`). A stamped fact nobody claimed is a misconfigured policy, not an absent
179
+ fact, and silently emitting nothing is how the gaps this feature exists to close came about.
180
+ - `version` is checked **before** `match`, because a newer contract may have redefined the
181
+ discriminant itself. A descriptor newer than `max`, or carrying no integer version when a guard
182
+ is declared, fails the build (`UNSUPPORTED_VERSION`) rather than emitting a partly-understood tag.
183
+
184
+ honey does not own the version number — it is the publisher's contract, and `max` is the app's
185
+ statement of which contract its `expand` was written against. Bumping the publisher then becomes a
186
+ build failure with a name, instead of tags that quietly change shape.
187
+
188
+ The alternatives were an array of entries under one key, and per-source matches that each run
189
+ `expand`. The array makes the policy value irregular (`entry | entry[]`) and leaves the two
190
+ contributions unnamed, which costs diagnostics — `schema."x-comb"[0]` reads worse than
191
+ `schema."entityFacts"`. Per-source matching would force one `expand` to handle every member of the
192
+ union and to de-duplicate its own target keys, moving work into app code that the policy layer
193
+ already does. Naming entries keeps one entry to one contribution, which is what the rest of the
194
+ design assumes.
195
+
196
+ > **Deliberate limit.** Deriving operation metadata from _schemas and table definitions_ is safe:
197
+ > they are a separate, hand-reviewed layer, and a handler can still fail to honor them — which is
198
+ > precisely the finding a contract tester should report. Deriving it from _observed handler
199
+ > behavior_ is not safe: it makes the document a mirror of the implementation and the tester
200
+ > self-confirming. honey has no record-from-runtime mode, and if one is ever added its output must
201
+ > land in a committed, reviewable artifact rather than being regenerated at test time.
202
+
203
+ ### 2.4 Profiles (multi-document)
204
+
205
+ ```ts
206
+ profiles: {
207
+ public: { include: ["x-entity", "x-query"] }, // default-deny — preferred
208
+ partner: { include: ["x-entity", "x-query", "x-cost"] },
209
+ staging: { exclude: ["x-rate-limit"] }, // denylist — internal documents only
210
+ }
211
+ ```
212
+
213
+ - Profile filters operate on **emitted keys**, not source keys.
214
+ - `include` is an allowlist of `x-*` keys (standard fields are always kept unless excluded);
215
+ `exclude` is a denylist; with both, `include` applies first. Both also filter `meta.extensions`,
216
+ so the escape hatch cannot route around a profile.
217
+ - The implicit profile `"default"` filters nothing.
218
+
219
+ **Use `include` for a public document.** `exclude` is a denylist: a tag introduced next month is
220
+ published unless someone remembers to add it, and the failure mode is a leak. `include` is
221
+ default-deny, which is the posture a security boundary wants — the same posture `false` already
222
+ takes on individual keys. Reach for `exclude` only when the document is internal and the list of
223
+ things to withhold is genuinely shorter than the list to publish.
224
+
225
+ - A document selects a profile:
226
+
227
+ ```ts
228
+ codegen: {
229
+ openApi: [
230
+ { path: "src/_gen/openapi.internal.json", title: "…", version: "1.0.0" },
231
+ { path: "src/_gen/openapi.public.json", title: "…", version: "1.0.0", profile: "public" },
232
+ ],
233
+ }
234
+ ```
235
+
236
+ `generateOpenApi(app, { …, profile: "public" })` is the programmatic form.
237
+
238
+ **Why document-level filtering rather than per-entry visibility as the primary model:** the
239
+ question a reviewer asks is "what is in the public document?" — that must be answerable by reading
240
+ one block, not by grepping forty entries for a visibility flag. Per-entry `profiles` remains
241
+ available for the inverse case ("this entry exists only for the internal document"), and `false`
242
+ covers "never, anywhere".
243
+
244
+ ### 2.5 Escape hatch
245
+
246
+ `OpenApiMeta` now carries:
247
+
248
+ ```ts
249
+ extensions?: Record<`x-${string}`, unknown>
250
+ ```
251
+
252
+ emitted verbatim, at the highest precedence, and still subject to profile filtering. Runtime
253
+ enforces the same contract the type states: only `x-*` keys are accepted, and anything else is an
254
+ `UNKNOWN_FIELD` build error. Because passthrough outranks every policy entry and is untyped inside
255
+ a `.meta()` call, letting it reach a standard field would let a stray key silently replace a
256
+ route's own `summary`. It exists so a
257
+ consumer that gains a tag before honey's policy is updated is not blocked. It is deliberately
258
+ secondary: raw passthrough spreads stringly-typed keys across call sites, has no public/private
259
+ separation, and fails on nothing.
260
+
261
+ ## 2.6 Middleware-contributed meta
262
+
263
+ A fact enforced by middleware should not be retyped on every route it protects. Middleware can
264
+ contribute meta to every route that mounts it:
265
+
266
+ ```ts
267
+ export const shard = createMiddleware(fn, { meta: { tenant: "project_id" } })
268
+ // or: defineMiddleware({ fn, errors: […], meta: { tenant: "project_id" } })
269
+
270
+ app.use(shard) // every route on the chain
271
+ app.use("/orgs", shard) // every route under a prefix
272
+ app.get("/x").use(shard).handler(…) // one route
273
+ ```
274
+
275
+ One line, every route the middleware covers, and the tag cannot disagree with the code that
276
+ enforces it — a hand-written `tenant` on 104 routes can, and a wrong one is worse than a missing
277
+ one, because it makes a consumer confident about the wrong parameter.
278
+
279
+ Rules:
280
+
281
+ - **Explicit always wins.** Chain `.meta()` and route `.meta()` both outrank a contributed value.
282
+ Among middleware, later mount order wins.
283
+ - **Contributed meta is real meta.** It lands in the route's `mt`, so it appears on `ctx.meta`, in
284
+ the manifest, and — critically — it is subject to the policy. A middleware cannot smuggle an
285
+ untagged fact into the document: with `strict: "error"`, contributing `sla` with no policy entry
286
+ fails the build exactly as writing it on a route would.
287
+ - **A scoped middleware registered after its routes still reaches them.** `.use("/prefix", mw)`
288
+ back-fills already-registered handlers, mirroring what honey already does for scoped error keys.
289
+ A tag missing where enforcement happens is the failure direction that matters.
290
+ - **`internal` may not be contributed.** It controls whether a route appears in generated artifacts
291
+ at all; a middleware that removed 104 routes from the document would be invisible in both the
292
+ route and the middleware. `createMiddleware` throws.
293
+
294
+ ### Why it is resolved at registration, not per request
295
+
296
+ The precompiled route tree bakes `mt` as a JSON literal (`mw` is never serialized), and
297
+ `.routeTree()` patch mode deliberately does not overwrite `mt` — the baked value is what production
298
+ serves. Contributed meta is therefore collected once, when the route registers, so the JIT and
299
+ precompiled paths cannot disagree. A lazily-derived value would produce one answer in development
300
+ and a different, stale one in production — the exact class of divergence this feature exists to
301
+ remove. The usual contract applies: change what a middleware contributes, regenerate.
302
+
303
+ ### Two things to put in a release note
304
+
305
+ - **Adding a middleware can break a build.** Contributed meta is subject to totality, so mounting a
306
+ middleware that contributes `tenant` fails the build of any app whose policy has no `tenant`
307
+ entry — an app that never touched its own meta type. That is the correct trade (explicit beats
308
+ silent) but it is a surprising blast radius for what looks like a local change.
309
+ - The tap interaction below.
310
+
311
+ ### One runtime interaction to know about
312
+
313
+ Meta-driven taps fire for every registered tap key found on a route's meta. A middleware
314
+ contributing `tenant` will therefore start firing an `app.tap("tenant", …)` handler on every route
315
+ it covers. That is coherent — "run this for every tenant-scoped route" is what meta-driven taps are
316
+ for — but it is a behavior change triggered from a distance, so it is worth grepping for tap keys
317
+ that collide with meta keys a middleware contributes.
318
+
319
+ ## 2.7 `undefined` omits, `null` is emitted
320
+
321
+ `map` returning `undefined`, an `undefined` value inside an `expand` record, and a source key absent
322
+ from meta all mean **omit**. `null` is a value and is **emitted verbatim**.
323
+
324
+ The difference is load-bearing whenever a publisher stamps facts it cannot always know. A schema
325
+ stamped with `searchable: null` or `tenantColumn: null` means _"I don't know"_, but passing that
326
+ straight through publishes `"x-searchable": null`, which a consumer reads as _"nothing is
327
+ searchable"_ — a confident wrong answer, worse than silence. Normalize in the policy:
328
+
329
+ ```ts
330
+ expand: (e) => ({
331
+ "x-entity": e.table,
332
+ "x-searchable": e.searchable ?? undefined, // unknown → key absent
333
+ "x-tenant": e.tenantColumn ? { column: e.tenantColumn } : undefined,
334
+ })
335
+ ```
336
+
337
+ honey does not collapse `null` to omitted for you, because "explicitly nothing" is a legitimate
338
+ statement that some consumers want (`"x-soft-delete": null` = _this entity definitively has no soft
339
+ delete_, as distinct from _unknown_). The framework carries what the policy produces; deciding which
340
+ of the two you mean is the policy's job.
341
+
342
+ This composes with precedence rather than fighting it. A publisher that stamps nothing for a fact
343
+ only the routing layer knows leaves the key absent at rank 1; a route-meta or middleware-contributed
344
+ value at rank 2 fills it wherever it exists, and where it does not, the key is simply missing —
345
+ which is the honest answer.
346
+
347
+ ## 3. Precedence and merge rules
348
+
349
+ Contributions are collected per operation and resolved by **rank**, highest wins:
350
+
351
+ | Rank | Source |
352
+ | ---- | ------------------------------------------------------------------------------ |
353
+ | 3 | `meta.extensions` passthrough |
354
+ | 2 | route meta entries — `spec.meta` **and** the built-in default policy, one tier |
355
+ | 1 | schema-derived entries (`spec.schema`) |
356
+
357
+ Rules, stated once:
358
+
359
+ 1. **Route meta beats schema-derived.** The built-in mappings are route meta too, so a route's
360
+ `summary` is never overwritten by a schema-derived entry. An app entry for a built-in key
361
+ replaces that built-in outright (same source key, so no clash).
362
+ 2. **Chain meta vs route meta is unchanged**: `.meta()` on a chain is merged into each route's meta
363
+ at registration; route meta wins. The policy layer sees the already-merged object.
364
+ 3. **Two entries of the same rank writing the same operation key is a build error**
365
+ (`DUPLICATE_TARGET`). Within-rank shadowing is always a mistake, never a feature.
366
+ 4. **Key order in the emitted document is the order of first contribution**: built-ins, then app
367
+ meta, then schema-derived, then `extensions`. Precedence is resolved independently of write
368
+ order, so a higher-ranked contribution overwrites a value in place and never reorders the JSON.
369
+ 5. **Aliasing a standard field from a second meta key requires hiding the built-in.**
370
+ `blurb: { key: "summary" }` alongside the built-in `summary` entry is a `DUPLICATE_TARGET` error;
371
+ write `summary: false` to say which one owns the field.
372
+ 6. `undefined` from `map`, an `undefined` value inside an `expand` record, and a source key absent
373
+ from meta all mean _omit_, silently. This is the sanctioned way to write a conditional entry.
374
+
375
+ ## 4. Totality
376
+
377
+ **A key of the app's meta type with no policy entry is an error, not a silent drop.** This single
378
+ rule is what stops the rot: adding a field to the meta type forces an emit-or-hide decision.
379
+
380
+ Enforced at two levels:
381
+
382
+ - **Type level.** The `meta` section is a mapped type over the app's meta type with `-?`, minus the
383
+ built-in `DefaultMeta` keys (which ship with default entries and may be _optionally_ overridden).
384
+ Omitting an app key is a TypeScript error at the `.metaSpec()` call site.
385
+ - **Codegen level.** Any key observed on a route's meta with no entry is reported per `strict`.
386
+ This catches JavaScript consumers, dynamically-assigned meta, and meta from mounted sub-apps whose
387
+ types were erased.
388
+
389
+ `strict` values: `"error"` (default when a `metaSpec` is declared), `"warn"`, `"off"`.
390
+
391
+ **Known limit:** a _misspelled_ policy key is not rejected at the call site. `.metaSpec()` takes a
392
+ generic `TSpec extends HoneyMetaSpec<TMeta>` — required to keep `Honey` from becoming invariant in
393
+ `TMeta`, which would break assignability across the whole builder — and TypeScript does not
394
+ excess-property-check a literal inferred as a generic. The typo is still caught, from the other
395
+ side: the real key it was meant to name now has no entry, so `MISSING_ENTRY` fires for it. The
396
+ misspelled entry itself is inert.
397
+
398
+ Apps that never call `.metaSpec()` run with `strict: "off"` and the built-in policy only — see §7.
399
+
400
+ ## 5. Error taxonomy
401
+
402
+ All diagnostics are collected across the whole document and thrown as one aggregated
403
+ `Error` (first 20 listed, with a count), because failing on the first of 250 operations makes
404
+ migration miserable.
405
+
406
+ | Code | Level | When | Why this level |
407
+ | ---------------------- | ---------------------------------------- | ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
408
+ | `MISSING_ENTRY` | error (`strict:"error"`) / warn / silent | a meta key on some route has no policy entry | the rot-stopper; downgradable during migration |
409
+ | `RESERVED_FIELD` | error | entry targets `responses` / `parameters` / `requestBody` / `callbacks` | would contradict schema-derived content |
410
+ | `UNKNOWN_FIELD` | error | entry targets a non-`x-`, non-allowlisted field | silently ignored by readers |
411
+ | `DUPLICATE_TARGET` | error | two same-rank entries write one key | ambiguous, order-dependent output |
412
+ | `INVALID_VALUE` | error | entry `schema` rejects the produced value | a malformed tag is indistinguishable from a missing one downstream |
413
+ | `ASYNC_VALIDATION` | error | entry `schema` validates asynchronously | codegen value resolution is synchronous |
414
+ | `MAP_THREW` | error | `map` / `expand` threw | policy bugs must not produce a half-populated document |
415
+ | `UNKNOWN_PROFILE` | error | a document requests a profile that is not declared | almost always a typo that would silently emit everything |
416
+ | `AMBIGUOUS_SCHEMA_KEY` | error | a `search: "deep"` entry matched two different values at one depth | guessing which entity a route serves is how a tester gets told the wrong thing |
417
+ | `DESCRIPTOR_MISMATCH` | error | a descriptor was found under `read` but satisfied no entry's `match` | a stamped fact nobody claimed is a misconfigured policy, not an absent fact |
418
+ | `UNSUPPORTED_VERSION` | error | a descriptor is newer than the entry's `version.max`, or carries no integer version | a partly-understood tag is worse than none — the consumer cannot tell it is partial |
419
+ | omitted value | silent | `map` returned `undefined`, or the meta key is absent | the sanctioned conditional |
420
+
421
+ Every message carries `method`, `path`, source key and target key.
422
+
423
+ ## 6. Validating what is emitted
424
+
425
+ ```ts
426
+ rateLimit: {
427
+ key: "x-rate-limit",
428
+ map: (v) => ({ category: v, rps: RPS[v] }),
429
+ schema: z.object({ category: z.string(), rps: z.number().int().positive() }),
430
+ }
431
+ ```
432
+
433
+ The schema validates the **produced** value (for `expand`, the produced record). It runs at codegen
434
+ time only, synchronously, through Standard Schema — so any of honey's supported validators works.
435
+ A failure is a build error, not a warning: shipping a tag that a consumer silently ignores is
436
+ worse than not shipping it, because it looks like coverage.
437
+
438
+ ## 7. Default policy and the compatibility argument
439
+
440
+ When an app declares no `metaSpec`, codegen runs the **built-in policy**, which is the previous
441
+ eight mappings, unchanged, with the same truthiness gates and the same emit order:
442
+
443
+ | Meta key | Target | Gate | HTTP | WS |
444
+ | ------------- | ---------------------------- | ---------------- | ---- | --- |
445
+ | `summary` | `summary` | truthy | ✓ | ✓ |
446
+ | `description` | `description` | truthy | ✓ | ✓ |
447
+ | `tags` | `tags` (string → `[string]`) | truthy | ✓ | ✓ |
448
+ | `deprecated` | `deprecated` | truthy | ✓ | — |
449
+ | `operationId` | `operationId` | truthy | ✓ | ✓ |
450
+ | `security` | `security` (normalized) | truthy | ✓ | — |
451
+ | `invalidate` | `x-invalidate` | array, non-empty | ✓ | — |
452
+ | `mcp` | `x-mcp` | `=== true` | ✓ | — |
453
+ | `internal` | _hidden_ | — | — | — |
454
+ | `extensions` | verbatim passthrough | — | ✓ | ✓ |
455
+
456
+ The WS column reproduces the previous WS mapping exactly (it was a strict subset). App entries
457
+ default to `on: "both"`; the asymmetry is preserved only for the built-ins.
458
+
459
+ Because the built-ins are compiled first and written first, and because precedence is resolved
460
+ independently of write order, **a no-policy app produces a byte-identical document**. There is a
461
+ test that asserts exactly this against the pre-change generator output.
462
+
463
+ ## 8. Migration path (≈250 operations, 9-field meta type)
464
+
465
+ Mechanical, and reversible at every step.
466
+
467
+ 1. **Add `.metaSpec({ strict: "warn", meta: {} })`.** Generate. Every dropped key is now listed,
468
+ once, with the routes that carry it. Nothing about the document changed.
469
+ 2. **Triage the list into three buckets**: emit verbatim, emit transformed, hide. Write the entries.
470
+ `false` for anything internal — `worker`, `captcha`, `proxyTimeout` — so the intent is recorded
471
+ rather than implied.
472
+ 3. **Add the public profile** with `exclude` for the tags that must not be published, and add the
473
+ second `codegen.openApi` entry. Diff the two documents; the diff _is_ the review.
474
+ 4. **Flip `strict` to `"error"`.** The mapped type now also fails the typecheck if a tenth meta
475
+ field appears without a decision.
476
+ 5. **Move ORM-derived facts to the `schema` section** last, once the meta-driven tags are stable.
477
+ This is the step that removes per-route annotation: the descriptor the ORM already stamps on the
478
+ schema fans out to `x-entity` / `x-generated` / `x-immutable` / `x-soft-delete` with no route
479
+ edits at all.
480
+
481
+ Steps 1–2 are per-meta-key (nine edits). Step 5 is per-entity, not per-route.
482
+
483
+ ## 9. Trade-offs rejected
484
+
485
+ - **Flat spec object (`.metaSpec({ permissions: "x-permissions" })`) as the only form.** Reads well
486
+ until profiles, schema sources and `strict` need a home, and then every one of them collides with
487
+ a possible meta key name. Sections cost one nesting level and buy an unambiguous namespace.
488
+ - **Per-entry visibility (`visible: ["internal"]`) as the primary profile model.** Rejected as the
489
+ primary: it distributes the answer to "what does the public document contain" across the whole
490
+ policy. Kept as a secondary `profiles` option on entries.
491
+ - **Post-hoc stripping only** (the existing `sanitize.stripXExtensions`). It works, but it is a
492
+ denylist applied after the fact: a tag added later is published by default, and the failure mode
493
+ is a leak. Kept for spec-level surgery; not the mechanism for public/private separation.
494
+ - **A honey-side vocabulary of blessed tags** (`x-entity`, `x-tenant`, …). Rejected outright:
495
+ binding a framework to one consumer's tag set is how vendor lock-in starts. honey controls
496
+ _emission_; the vocabulary is the app's.
497
+ - **Deriving policy from a config file rather than the app builder.** The policy must be typed
498
+ against the app's meta type, which only exists in the app's own module graph.
499
+ - **Runtime enforcement.** Everything here is codegen-time. `.metaSpec()` stores one object on the
500
+ route graph and is never consulted on a request.
501
+
502
+ ## 10. Open questions
503
+
504
+ - **Chain-scoped policies.** `.metaSpec()` is app-global (stored on the shared route graph, merged
505
+ when sub-apps are mounted, parent wins). Whether a mounted sub-app should be able to _override_ a
506
+ parent entry rather than only fill gaps is unresolved; today it fills gaps.
507
+ - **Profile inheritance.** Profiles are flat. If real apps grow four or five documents, `extends`
508
+ will be wanted.
509
+ - **Fan-out from a schema key into `parameters`.** `x-query` describes sortable/filterable columns
510
+ and duplicates information that could, in principle, be emitted as real query parameters. Both are
511
+ useful to different consumers; honey emits the extension and leaves parameters to the schema.
512
+ - **Component-level policy.** Nothing yet maps schema meta onto `components.schemas` entries, only
513
+ onto operations.
@@ -0,0 +1,85 @@
1
+ # Packaging — why honey ships source _and_ declarations
2
+
3
+ ## The problem
4
+
5
+ honey shipped raw TypeScript: `"files": ["docs", "src"]`, and every `exports` subpath pointed at
6
+ `./src/*.ts`. That is pleasant for us and hostile to a consumer, because **a package that ships
7
+ `.ts` inherits the consumer's compiler flags**. There is no per-directory suppression for `.ts`
8
+ under `node_modules`, and `skipLibCheck` does not help — it covers `.d.ts` only.
9
+
10
+ Measured against the real published tarball, from a fixture with every plausible strictness flag on:
11
+
12
+ ```
13
+ 522 diagnostics originate inside @lovrozagar/honey
14
+
15
+ 358 TS4111 Property 'x' comes from an index signature, must be accessed with ['x']
16
+ 81 TS5097 An import path can only end with '.ts' when allowImportingTsExtensions is enabled
17
+ 27 TS18048 'x' is possibly 'undefined'
18
+ 22 TS2379 argument not assignable under exactOptionalPropertyTypes
19
+ …
20
+ ```
21
+
22
+ A downstream app reported 482 under its own flag set. Same wall, different height.
23
+
24
+ ## Why "just fix the source" was not enough
25
+
26
+ The obvious answer is to make our source satisfy those flags — `@lovrozagar/comb` ships raw `src`
27
+ and produces zero errors in the same app, so it is demonstrably possible. Two things ruled it out:
28
+
29
+ 1. **TS5097 is not a style problem.** 81 of the diagnostics say the consumer must enable
30
+ `allowImportingTsExtensions`, which in turn requires `noEmit` or `emitDeclarationOnly` — we would
31
+ be dictating a consumer's build configuration. No amount of tidying our own source removes that;
32
+ it is inherent to importing `.ts` paths with extensions.
33
+ 2. **The flag set is unbounded.** Fixing 522 errors buys immunity to _today's_ flags. Tomorrow a
34
+ consumer enables something that does not exist yet, and we are back here. `skipLibCheck` over
35
+ `.d.ts` is a real, permanent boundary; matching an open set of lint flags is a treadmill.
36
+
37
+ ## What we do instead
38
+
39
+ Ship both, and split the conditions:
40
+
41
+ ```json
42
+ "exports": {
43
+ ".": { "types": "./dist/index.d.ts", "default": "./src/index.ts" }
44
+ }
45
+ ```
46
+
47
+ - **TypeScript** resolves `types` → generated `.d.ts`, which `skipLibCheck` covers. The consumer
48
+ never type-checks our source, so our lint posture is ours alone.
49
+ - **Bundlers and runtimes** resolve `default` → `./src/*.ts`, unchanged. Workers bundling, Vite,
50
+ and the precompiled-tree path all behave exactly as before.
51
+ - **`declarationMap` is emitted and `src` still ships**, so go-to-definition lands in real source
52
+ rather than a generated stub. The debugging story that made source-shipping attractive survives.
53
+
54
+ `prepack` builds `dist` (`tsc -p tsconfig.dts.json`). npm runs `prepack` for both `npm pack` and
55
+ `npm publish`, and the release workflow publishes from `packages/core`, so a tarball physically
56
+ cannot ship with a stale or missing `dist`.
57
+
58
+ ## The trade-off, stated
59
+
60
+ We pay: a build step, ~870KB of declarations in the tarball, and one more artifact that must stay in
61
+ sync (it is generated, so it does — but it is a step that can be forgotten, which is why it hangs off
62
+ `prepack` rather than a human).
63
+
64
+ We keep: source shipping, and with it Workers bundling with no `dist/*.js` indirection, TS-path
65
+ debugging, and the ability to read the real implementation from `node_modules`.
66
+
67
+ We buy: consumers are permanently decoupled from our compiler flags, and from any flag TypeScript
68
+ adds later.
69
+
70
+ **This generalizes.** Any package of ours that ships source has the same exposure. The rule: ship
71
+ source for the runtime condition, ship declarations for the `types` condition, and gate it with a
72
+ packaging test. `comb` passes today only because its source happens to satisfy two particular flags
73
+ — that is luck, not a boundary, and it does not cover TS5097 for a consumer who cannot enable
74
+ `allowImportingTsExtensions`.
75
+
76
+ ## The regression test
77
+
78
+ `bun run test:packaging` (`e2e/run-strict-consumer.ts`) packs the tarball exactly as npm would,
79
+ installs it into a throwaway consumer, type-checks against `e2e/strict-consumer/` — a fixture with
80
+ `strict`, `exactOptionalPropertyTypes`, `noPropertyAccessFromIndexSignature`,
81
+ `noUncheckedIndexedAccess`, `verbatimModuleSyntax` and friends — and fails on any diagnostic whose
82
+ path is inside the package.
83
+
84
+ It runs as its own CI job. Every other tier compiles honey with honey's own tsconfig, so this is the
85
+ only place the consumer's view is visible at all.