@mailwoman/api 9.0.0 → 9.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.
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@mailwoman/api",
3
- "version": "9.0.0",
3
+ "version": "9.2.0",
4
4
  "description": "The native Mailwoman HTTP API — engine-agnostic /v1 surface (parse, geocode, batch, resolve, format) with health, metrics, and an emitted OpenAPI document.",
5
5
  "license": "AGPL-3.0-only OR LicenseRef-Commercial",
6
6
  "repository": {
7
7
  "type": "git",
8
8
  "url": "https://github.com/sister-software/mailwoman.git",
9
- "directory": "api"
9
+ "directory": "packages/api"
10
10
  },
11
11
  "files": [
12
12
  "out/**/*.js",
@@ -21,14 +21,22 @@
21
21
  "!*.test.ts",
22
22
  "!*.test.tsx",
23
23
  "!**/*.test.ts",
24
- "!**/*.test.tsx"
24
+ "!**/*.test.tsx",
25
+ "!test/**"
25
26
  ],
26
27
  "type": "module",
28
+ "imports": {
29
+ "#package.json": "./package.json"
30
+ },
27
31
  "exports": {
28
32
  "./package.json": "./package.json",
29
33
  ".": {
30
34
  "types": "./out/index.d.ts",
31
35
  "default": "./out/index.js"
36
+ },
37
+ "./schema": {
38
+ "types": "./out/schema.d.ts",
39
+ "default": "./out/schema.js"
32
40
  }
33
41
  },
34
42
  "publishConfig": {
@@ -38,15 +46,22 @@
38
46
  ".": {
39
47
  "types": "./out/index.d.ts",
40
48
  "default": "./out/index.js"
49
+ },
50
+ "./schema": {
51
+ "types": "./out/schema.d.ts",
52
+ "default": "./out/schema.js"
41
53
  }
54
+ },
55
+ "imports": {
56
+ "#package.json": "./package.json"
42
57
  }
43
58
  },
44
59
  "dependencies": {
45
60
  "@hono/zod-openapi": "^1.5.1",
46
- "@mailwoman/api-kit": "9.0.0",
47
- "@mailwoman/core": "9.0.0",
48
- "@mailwoman/formatter": "9.0.0",
49
- "hono": "^4.12.32",
61
+ "@mailwoman/api-kit": "9.2.0",
62
+ "@mailwoman/core": "9.2.0",
63
+ "@mailwoman/formatter": "9.2.0",
64
+ "hono": "^4.13.0",
50
65
  "zod": "^4.4.3"
51
66
  }
52
67
  }
package/routes.ts CHANGED
@@ -20,7 +20,7 @@
20
20
  */
21
21
 
22
22
  import { createRoute, type OpenAPIHono, z } from "@hono/zod-openapi"
23
- import { apiError, metricsSnapshot, recordTimed } from "@mailwoman/api-kit"
23
+ import { geocoderUnavailableError, metricsSnapshot, recordTimed } from "@mailwoman/api-kit"
24
24
  import type { AddressTree } from "@mailwoman/core/decoder"
25
25
  import type { ComponentTag } from "@mailwoman/core/types"
26
26
  import { canonicalKey, type ComponentDict, formatAddress, type FormatAddressOptions } from "@mailwoman/formatter"
@@ -39,6 +39,7 @@ import {
39
39
  ParseRequestSchema,
40
40
  ResolveRequestSchema,
41
41
  ResolveResponseSchema,
42
+ type GeocodeOutcome,
42
43
  } from "./schema.ts"
43
44
 
44
45
  /**
@@ -50,15 +51,6 @@ export const DEFAULT_BATCH_MAX = 500
50
51
 
51
52
  const startedAt = Date.now()
52
53
 
53
- /**
54
- * `detail` text for every 503 "engine method absent" response (`/v1/geocode`, `/v1/batch`, `/v1/resolve`, `/v1/reload`)
55
- * — the express-era remediation carried forward: a stranger hitting a 503 must see the exact fix, not just "not
56
- * available". Matches `mailwoman/api-engine.ts`'s `buildPreflightMessage()` boot-time banner in spirit (same two
57
- * missing pieces — the packages, and the gazetteer data), condensed to one line for a JSON error body.
58
- */
59
- const GEOCODER_UNAVAILABLE_DETAIL =
60
- "install @mailwoman/neural + @mailwoman/resolver-wof-sqlite and provide gazetteer data (MAILWOMAN_WOF_DB / MAILWOMAN_CANDIDATE_DB)"
61
-
62
54
  /**
63
55
  * Options for {@link registerMailwomanAPIRoutes}.
64
56
  */
@@ -261,15 +253,16 @@ function toComponentDict(components: Record<string, string | string[]>): Compone
261
253
  /**
262
254
  * Register the native `/v1` routes + `/health` + `/metrics` against an injected engine.
263
255
  */
264
- export function registerMailwomanAPIRoutes(
256
+ export function registerMailwomanAPIRoutes<T extends Partial<GeocodeOutcome> = GeocodeOutcome>(
265
257
  app: OpenAPIHono,
266
- engine: MailwomanAPIEngine,
258
+ engine: MailwomanAPIEngine<T>,
267
259
  options: RegisterMailwomanAPIRoutesOptions = {}
268
260
  ): void {
269
261
  const batchMax = options.batchMax ?? DEFAULT_BATCH_MAX
270
262
 
271
263
  app.openapi(parseGetRoute, async (c) => {
272
264
  if (!engine.parse) return c.json({ error: "parse not implemented" }, 501)
265
+
273
266
  const address = c.req.query("address")?.trim()
274
267
 
275
268
  if (!address) return c.json({ error: "address is required" }, 400)
@@ -284,11 +277,17 @@ export function registerMailwomanAPIRoutes(
284
277
  app.openapi(
285
278
  parsePostRoute,
286
279
  async (c) => {
287
- if (!engine.parse) return c.json({ error: "parse not implemented" }, 501)
280
+ if (!engine.parse) {
281
+ return c.json({ error: "parse not implemented" }, 501)
282
+ }
283
+
288
284
  const { address, debug, input_mode } = c.req.valid("json")
289
285
  const trimmed = address.trim()
290
286
 
291
- if (!trimmed) return c.json({ error: "address is required" }, 400)
287
+ if (!trimmed) {
288
+ return c.json({ error: "address is required" }, 400)
289
+ }
290
+
292
291
  const outcome = await engine.parse(trimmed, { debug: debug ?? false, inputMode: input_mode })
293
292
 
294
293
  return c.json(outcome, 200)
@@ -303,29 +302,33 @@ export function registerMailwomanAPIRoutes(
303
302
  app.openapi(
304
303
  geocodeRoute,
305
304
  async (c) => {
306
- if (!engine.geocode) return apiError(c, 503, "geocoder not available", GEOCODER_UNAVAILABLE_DETAIL)
305
+ if (!engine.geocode) {
306
+ return geocoderUnavailableError(c)
307
+ }
308
+
307
309
  const { address, input_mode } = c.req.valid("json")
308
310
  const trimmed = address.trim()
309
311
 
310
312
  if (!trimmed) return c.json({ error: "address is required" }, 400)
311
313
  const t0 = performance.now()
312
314
 
313
- try {
314
- const outcome = await engine.geocode(trimmed, { inputMode: input_mode })
315
- recordTimed(performance.now() - t0, String(outcome["resolution_tier"] ?? "admin"))
316
-
317
- // `GeocodeOutcome` (the engine contract) is a deliberate `Record<string, unknown>` passthrough —
318
- // `GeocodeOutcomeSchema` is now a REAL typed shape (doc-accuracy only, per its own docstring), so a
319
- // local cast at this wire boundary is needed, matching the established idiom below (`/v1/resolve`'s
320
- // `tree as unknown as AddressTree`) for "documented wire shape looser than the domain type".
321
- return c.json(outcome as unknown as z.infer<typeof GeocodeOutcomeSchema>, 200)
322
- } catch (error) {
323
- recordTimed(performance.now() - t0, "error")
324
- throw error
325
- }
315
+ return engine
316
+ .geocode(trimmed, { inputMode: input_mode })
317
+ .then((outcome) => {
318
+ recordTimed(performance.now() - t0, String(outcome.resolution_tier ?? "admin"))
319
+
320
+ return c.json(outcome as unknown as GeocodeOutcome, 200)
321
+ })
322
+ .catch((error) => {
323
+ recordTimed(performance.now() - t0, "error")
324
+
325
+ throw error
326
+ })
326
327
  },
327
328
  (result, c) => {
328
- if (!result.success) return c.json({ error: "address is required" }, 400)
329
+ if (!result.success) {
330
+ return c.json({ error: "address is required" }, 400)
331
+ }
329
332
 
330
333
  return undefined
331
334
  }
@@ -342,7 +345,9 @@ export function registerMailwomanAPIRoutes(
342
345
  return c.json({ error: `batch too large: ${addresses.length} > ${batchMax}` }, 413)
343
346
  }
344
347
 
345
- if (!engine.batch) return apiError(c, 503, "geocoder not available", GEOCODER_UNAVAILABLE_DETAIL)
348
+ if (!engine.batch) {
349
+ return geocoderUnavailableError(c)
350
+ }
346
351
 
347
352
  // Whole-call latency, recorded under the "batch" tier. Per-row tier metrics are the ENGINE's
348
353
  // responsibility (phase 4b) — this app only times the call as a unit.
@@ -375,11 +380,14 @@ export function registerMailwomanAPIRoutes(
375
380
  // street node's stamped resolution tier per call — the wired engine must carry that over, and
376
381
  // must trim batch rows the same way (the route passes raw input through).
377
382
  async (c) => {
378
- if (!engine.resolveTree) return apiError(c, 503, "resolver not available", GEOCODER_UNAVAILABLE_DETAIL)
383
+ // `resolver`, not `geocoder` — the missing method is `engine.resolveTree`, and the 503's `error`
384
+ // value is what a caller branches on.
385
+ if (!engine.resolveTree) {
386
+ return geocoderUnavailableError(c, "resolver")
387
+ }
388
+
379
389
  const { tree, opts } = c.req.valid("json")
380
- // The wire schema keeps `tree` loose (`{ roots: unknown[] }`, forward-compat) — a local cast at the
381
- // boundary onto the engine's `AddressTree` contract, matching the established idiom (api-kit's
382
- // `openapi.ts`, the drop-ins' response casts) for "documented wire shape looser than the domain type".
390
+
383
391
  const outcome = await engine.resolveTree(tree as unknown as AddressTree, opts ?? {})
384
392
 
385
393
  return c.json(outcome, 200)
@@ -392,7 +400,10 @@ export function registerMailwomanAPIRoutes(
392
400
  )
393
401
 
394
402
  app.openapi(reloadRoute, async (c) => {
395
- if (!engine.reload) return apiError(c, 503, "geocoder not available", GEOCODER_UNAVAILABLE_DETAIL)
403
+ if (!engine.reload) {
404
+ return geocoderUnavailableError(c)
405
+ }
406
+
396
407
  const outcome = await engine.reload()
397
408
 
398
409
  return c.json(outcome, 200)
package/schema.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  * libpostal), nothing here is a vendor contract — this surface is ours to design, so request
8
8
  * bodies are REQUIRED and validator-enforced (no legacy tolerance to preserve). A `defaultHook`
9
9
  * on the app maps validation failures through the shared `APIErrorSchema` envelope
10
- * (`apiError(c, 400, "invalid request body", <zod summary>)`) — the pattern boundary every
10
+ * (`errorResponse(c, 400, "invalid request body", <zod summary>)`) — the pattern boundary every
11
11
  * surface holds to: where no legacy contract exists, the validator MAY speak, but only in
12
12
  * our envelope.
13
13
  *
@@ -98,6 +98,9 @@ const GeocodeHierarchyEntrySchema = z
98
98
  lat: z.number().optional(),
99
99
  lon: z.number().optional(),
100
100
  placeID: z.string().optional(),
101
+ // #1731 tri-state lineage provenance: true = the winner's ancestor chain vouches for this entry, false =
102
+ // resolved independently OUTSIDE the winner's lineage, absent = unverifiable. Absence is not false.
103
+ in_winner_lineage: z.boolean().optional(),
101
104
  })
102
105
  .openapi("GeocodeHierarchyEntry")
103
106
 
@@ -116,6 +119,80 @@ const GeocodeCandidateSchema = z
116
119
  })
117
120
  .openapi("GeocodeCandidate")
118
121
 
122
+ /**
123
+ * The `ComponentTag` union at this engine-agnostic boundary, named once so every schema that speaks about a tag speaks
124
+ * about the SAME list. Two hand-copied enums would agree on the day they were written and diverge on the day a tag is
125
+ * added — the shape of defect `feedback-parity-needs-shared-function-not-shared-constants` describes.
126
+ */
127
+ const ComponentTagSchema = z.enum([
128
+ "country",
129
+ "region",
130
+ "locality",
131
+ "dependent_locality",
132
+ "postcode",
133
+ "subregion",
134
+ "house_number",
135
+ "street",
136
+ "street_prefix",
137
+ "street_prefix_particle",
138
+ "street_suffix",
139
+ "intersection_a",
140
+ "intersection_b",
141
+ "unit",
142
+ "venue",
143
+ "attention",
144
+ "po_box",
145
+ "cedex",
146
+ "prefecture",
147
+ "municipality",
148
+ "district",
149
+ "block",
150
+ "sub_block",
151
+ "building_number",
152
+ "building_name",
153
+ ])
154
+
155
+ /**
156
+ * Canonical parsed-component map carried by `GeocodeResult.components`. Spelled out at this engine-agnostic API
157
+ * boundary for the same reason the result schema is hand-modeled; the compile-time drift pin in
158
+ * `mailwoman/test/api-schema-drift.test.ts` catches any mismatch with the real `ComponentTag`-keyed result type.
159
+ */
160
+ const GeocodeComponentsSchema = z.partialRecord(ComponentTagSchema, z.string())
161
+
162
+ /**
163
+ * One `GeocodeOutcome.intent_markers` entry — an advisory the ROAD_TO_V9 §4 intent vocabulary raised about the QUERY.
164
+ * Mirrors `QueryIntentMarker` (`core/pipeline/types.ts`).
165
+ *
166
+ * `evidence` is deliberately open (`z.record`): each `code` carries its own measurement — a dominance margin, a pair of
167
+ * interpretations, a taxonomy id — and flattening those into one closed shape would either lose the numbers or invent
168
+ * fields that do not apply. `code` is the discriminator a client branches on.
169
+ */
170
+ const QueryIntentMarkerSchema = z
171
+ .object({
172
+ // Spelled out rather than `z.string()` so `mailwoman/test/api-schema-drift.test.ts`'s schema-too-wide direction
173
+ // keeps biting: a new `QueryKind` that never reaches this list is a documented contract that has quietly stopped
174
+ // describing the real one.
175
+ kind: z.enum([
176
+ "postcode_only",
177
+ "locality_only",
178
+ "structured_address",
179
+ "intersection",
180
+ "po_box",
181
+ "landmark",
182
+ "poi_query",
183
+ "vague",
184
+ "bare_toponym",
185
+ "route_pair",
186
+ "near_me",
187
+ "poi_category",
188
+ ]),
189
+ code: z.enum(["declared_ambiguity", "declared_fork", "focus_point_required", "poi_category"]),
190
+ mechanism: z.string(),
191
+ message: z.string(),
192
+ evidence: z.record(z.string(), z.unknown()).optional(),
193
+ })
194
+ .openapi("QueryIntentMarker")
195
+
119
196
  /**
120
197
  * `POST /v1/geocode` response — a hand-modeled mirror of `GeocodeResult`'s wire shape (`mailwoman/geocode-core.ts`),
121
198
  * `.loose()` so a field the engine adds that this schema doesn't yet know about still rides through undocumented rather
@@ -126,34 +203,93 @@ const GeocodeCandidateSchema = z
126
203
  * `@mailwoman/api`, never the reverse). `mailwoman/test/api-schema-drift.test.ts` is the compile-time tripwire that
127
204
  * catches this shape drifting from the real `GeocodeResult` interface.
128
205
  */
129
- export const GeocodeOutcomeSchema = z
130
- .object({
131
- input: z.string(),
132
- lat: z.number().nullable(),
133
- lon: z.number().nullable(),
134
- resolution_tier: z.enum(["address_point", "interpolated", "street", "admin"]),
135
- uncertainty_m: z.number().nullable(),
136
- locality: z.string().nullable(),
137
- region: z.string().nullable(),
138
- postcode: z.string().nullable(),
139
- house_number: z.string().nullable(),
140
- street: z.string().nullable(),
141
- // The parsed venue span (#1041 posture; surfaced 2026-08-01 for the hierarchy-evidence campaign R1).
142
- venue: z.string().nullable(),
143
- // The parsed dependent-locality span (parse view; `hierarchy` is the resolved view).
144
- dependent_locality: z.string().nullable(),
145
- // The parsed unit / sub-venue span (parse view) — "Terminal 5", "Suite 300".
146
- unit: z.string().nullable(),
147
- countryCode: z.string().nullable(),
148
- hierarchy: z.array(GeocodeHierarchyEntrySchema),
149
- candidates: z.array(GeocodeCandidateSchema),
150
- // #42: the country the postcode-country coherence pass scoped the walk to, or null. Non-null ONLY when it
151
- // OVERRODE the request's country prior so a caller who asked for US and got an FR answer can see which
152
- // evidence bought the change instead of reading it as a bug.
153
- postcode_country_scope: z.string().nullable(),
154
- })
155
- .loose()
156
- .openapi("GeocodeOutcome")
206
+ export const GeocodeOutcomeLikeSchema = z.object({
207
+ input: z.string(),
208
+ components: GeocodeComponentsSchema,
209
+ lat: z.number().nullable(),
210
+ lon: z.number().nullable(),
211
+ resolution_tier: z.enum(["address_point", "interpolated", "street", "admin", "venue", "plus_code"]),
212
+ // The fork→entity probe's answer (#1585) — present only on the `venue` tier; see geocode-core's
213
+ // GeocodeResult.entity.
214
+ entity: z
215
+ .object({
216
+ name: z.string(),
217
+ categoryID: z.string().nullable(),
218
+ confidence: z.number(),
219
+ country: z.string(),
220
+ })
221
+ .optional(),
222
+ uncertainty_m: z.number().nullable(),
223
+ locality: z.string().nullable(),
224
+ region: z.string().nullable(),
225
+ postcode: z.string().nullable(),
226
+ house_number: z.string().nullable(),
227
+ street: z.string().nullable(),
228
+ // The parsed venue span (#1041 posture; surfaced 2026-08-01 for the hierarchy-evidence campaign R1).
229
+ venue: z.string().nullable(),
230
+ // The parsed dependent-locality span (parse view; `hierarchy` is the resolved view).
231
+ dependent_locality: z.string().nullable(),
232
+ // The parsed unit / sub-venue span (parse view) — "Terminal 5", "Suite 300".
233
+ unit: z.string().nullable(),
234
+ countryCode: z.string().nullable(),
235
+ hierarchy: z.array(GeocodeHierarchyEntrySchema),
236
+ candidates: z.array(GeocodeCandidateSchema),
237
+ // The register row's OWN scope tags when the address_point tier answered and its shard carries
238
+ // them (normalized locality key + postcode of the ROOFTOP) — see geocode-core's GeocodeResult.rooftop.
239
+ rooftop: z
240
+ .object({
241
+ localityNorm: z.string().optional(),
242
+ postcode: z.string().optional(),
243
+ })
244
+ .optional(),
245
+ // #42: the country the postcode-country coherence pass scoped the walk to, or null. Non-null ONLY when it
246
+ // OVERRODE the request's country prior — so a caller who asked for US and got an FR answer can see which
247
+ // evidence bought the change instead of reading it as a bug.
248
+ postcode_country_scope: z.string().nullable(),
249
+ // ROAD_TO_V9 §4: query-intent advisories. Always present; empty means the vocabulary looked and had nothing to
250
+ // say. Advisory ONLY — no marker changed which answer won, and a client is free to ignore the array entirely.
251
+ intent_markers: z.array(QueryIntentMarkerSchema),
252
+ // #1717 stage 1: flag-only admin-coherence verdicts — did the winning candidate's resolved ancestry confirm,
253
+ // contradict, or fail to speak to the PARSED region/country qualifiers? Nothing ranks or gates on these; present
254
+ // whenever a winner resolved (both members always populated — `unstated` is the explicit no-qualifier claim),
255
+ // absent when nothing resolved to check against. See mailwoman's `admin-coherence.ts` for the verdict contract.
256
+ admin_coherence: z
257
+ .object({
258
+ region: z.enum(["confirmed", "contradicted", "unstated", "unverifiable"]),
259
+ country: z.enum(["confirmed", "contradicted", "unstated", "unverifiable"]),
260
+ })
261
+ .optional(),
262
+ // #1755: spans the flat `components` map could not represent. `components` holds one value per tag, so a second
263
+ // `locality` span ceases to exist there — and without this line `region: null` means both "the input named no
264
+ // region" and "it named one and we deleted it". Absent when nothing was dropped; never an empty array on the wire,
265
+ // because the common case is nothing dropped and a client should not have to read a field to learn that.
266
+ dropped_components: z
267
+ .array(
268
+ z.object({
269
+ tag: ComponentTagSchema,
270
+ value: z.string(),
271
+ // The value that held the slot, so a reader sees which of the two survived without re-deriving it.
272
+ kept: z.string(),
273
+ })
274
+ )
275
+ .optional(),
276
+ })
277
+
278
+ export type GeocodeOutcomeLike = z.infer<typeof GeocodeOutcomeLikeSchema>
279
+
280
+ /**
281
+ * `POST /v1/geocode` response — a hand-modeled mirror of `GeocodeResult`'s wire shape (`mailwoman/geocode-core.ts`),
282
+ * `.loose()` so a field the engine adds that this schema doesn't yet know about still rides through undocumented rather
283
+ * than being stripped or rejected. DOC-ACCURACY ONLY: the route passes `engine.geocode()`'s outcome through verbatim
284
+ * (`GeocodeOutcome = Record<string, unknown>`, `api/engine.ts`) — nothing here validates a real response, so a
285
+ * schema/engine mismatch can never reject or mutate a result at runtime. Deliberately carries NO import from
286
+ * `mailwoman` (the engine-agnosticism boundary — `mailwoman` is the one workspace allowed to depend on
287
+ * `@mailwoman/api`, never the reverse). `mailwoman/test/api-schema-drift.test.ts` is the compile-time tripwire that
288
+ * catches this shape drifting from the real `GeocodeResult` interface.
289
+ */
290
+ export const GeocodeOutcomeSchema = GeocodeOutcomeLikeSchema.loose().openapi("GeocodeOutcome")
291
+
292
+ export type GeocodeOutcome = z.infer<typeof GeocodeOutcomeSchema>
157
293
 
158
294
  /**
159
295
  * `POST /v1/batch` request body.