@mailwoman/api 9.1.0 → 9.3.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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @mailwoman/api
2
2
 
3
- The **native [Mailwoman](https://mailwoman.sister.software) HTTP API** — an engine-agnostic `/v1` surface
3
+ The **native [Mailwoman](https://mailwoman.ai) HTTP API** — an engine-agnostic `/v1` surface
4
4
  (parse, geocode, batch, resolve, format) plus health, metrics, and an emitted OpenAPI document. Unlike its
5
5
  drop-in siblings ([`@mailwoman/nominatim`](../nominatim), [`@mailwoman/photon`](../photon),
6
6
  [`@mailwoman/libpostal`](../libpostal)), nothing here mimics a third-party API — this is Mailwoman's own
@@ -9,13 +9,24 @@
9
9
  */
10
10
 
11
11
  import { OpenAPIHono } from "@hono/zod-openapi"
12
- import { apiError, attachOpenAPIDocs, type OpenAPIDocInfo } from "@mailwoman/api-kit"
13
- import packageJson from "@mailwoman/api/package.json" with { type: "json" }
12
+ import { attachOpenAPIDocs, engineHeaders, errorResponse, type OpenAPIDocInfo } from "@mailwoman/api-kit"
13
+ import { readLocalJSONFile } from "@mailwoman/core/fs/readers"
14
+ import type { EngineStamp } from "@mailwoman/core/license"
15
+ import { resolvePackagePath } from "@mailwoman/core/module/resolvers"
14
16
  import { bodyLimit } from "hono/body-limit"
15
17
  import { cors } from "hono/cors"
16
18
 
17
- import type { MailwomanAPIEngine } from "./engine.ts"
18
- import { DEFAULT_BATCH_MAX, registerMailwomanAPIRoutes } from "./routes.ts"
19
+ import type { MailwomanAPIEngine } from "#engine"
20
+ import { DEFAULT_BATCH_MAX, registerMailwomanAPIRoutes } from "#routes"
21
+ import type { GeocodeOutcomeLike } from "#schema"
22
+
23
+ /**
24
+ * This package's own manifest, read at load rather than imported as a module: a JSON import makes `tsc` copy the file
25
+ * into `out/`, where it becomes the package scope for the compiled tree and breaks every `#` import in it.
26
+ */
27
+ const packageJson = await readLocalJSONFile<{ name: string; version: string; description: string }>(
28
+ resolvePackagePath("@mailwoman/api", "package.json")
29
+ )
19
30
 
20
31
  /**
21
32
  * 2 MiB — carried from the express server's `express.json({ limit: "2mb" })` (`mailwoman/server/index.ts`).
@@ -43,6 +54,13 @@ export interface MailwomanAPIOptions {
43
54
  * Max `addresses` rows accepted by `POST /v1/batch`. Default 500 (see `routes.ts`'s `DEFAULT_BATCH_MAX`).
44
55
  */
45
56
  batchMax?: number
57
+
58
+ /**
59
+ * The engine stamp to carry on every response: `engine` in each `/v1` body and the `Server` + `Link: rel="license"`
60
+ * headers everywhere. Absent when an embedding application builds the app without the `mailwoman` package; the
61
+ * `mailwoman serve` command always passes one.
62
+ */
63
+ engine?: EngineStamp
46
64
  }
47
65
 
48
66
  /**
@@ -63,7 +81,7 @@ export const MAILWOMAN_API_DOC_INFO: OpenAPIDocInfo = {
63
81
  version: packageJson.version,
64
82
  description: packageJson.description,
65
83
  license: { name: "AGPL-3.0-only OR LicenseRef-Commercial", identifier: "AGPL-3.0-only" },
66
- contact: { name: "Sister Software", url: "https://mailwoman.sister.software" },
84
+ contact: { name: "Sister Software", url: "https://mailwoman.ai" },
67
85
  servers: [
68
86
  {
69
87
  url: "http://{host}:{port}",
@@ -83,7 +101,10 @@ export const MAILWOMAN_API_DOC_INFO: OpenAPIDocInfo = {
83
101
  /**
84
102
  * Build the native Mailwoman app around an injected {@link MailwomanAPIEngine}.
85
103
  */
86
- export function createMailwomanAPI(engine: MailwomanAPIEngine, options: MailwomanAPIOptions = {}): OpenAPIHono {
104
+ export function createMailwomanAPI<T extends Partial<GeocodeOutcomeLike> = GeocodeOutcomeLike>(
105
+ engine: MailwomanAPIEngine<T>,
106
+ options: MailwomanAPIOptions = {}
107
+ ): OpenAPIHono {
87
108
  const app = new OpenAPIHono({
88
109
  // This surface is ours (no vendor contract to preserve): every declared body/query schema is
89
110
  // validator-enforced, and a failure maps through the shared api-kit envelope — never the raw zod
@@ -92,7 +113,7 @@ export function createMailwomanAPI(engine: MailwomanAPIEngine, options: Mailwoma
92
113
  // just `/v1/format`).
93
114
  defaultHook: (result, c) => {
94
115
  if (!result.success) {
95
- return apiError(c, 400, "invalid request body", summarizeValidationError(result.error))
116
+ return errorResponse(c, 400, "invalid request body", summarizeValidationError(result.error))
96
117
  }
97
118
 
98
119
  return undefined
@@ -105,6 +126,10 @@ export function createMailwomanAPI(engine: MailwomanAPIEngine, options: Mailwoma
105
126
  app.use(cors({ origin: "*", allowMethods: ["GET", "POST", "OPTIONS"], allowHeaders: ["*"], maxAge: 86_400 }))
106
127
  }
107
128
 
129
+ if (options.engine) {
130
+ app.use(engineHeaders(options.engine))
131
+ }
132
+
108
133
  // Safety net: an engine fault answers the native envelope, never a crash. `detail` carries the raw message —
109
134
  // this surface is ours to design, so (unlike the vendor-constrained drop-in envelopes) we can be helpful.
110
135
  app.onError((error, c) => {
@@ -112,10 +137,10 @@ export function createMailwomanAPI(engine: MailwomanAPIEngine, options: Mailwoma
112
137
  // validator throws before a route's own hook ever sees the body, so it lands here instead of the
113
138
  // per-route 400s in routes.ts. Answer 400, not the 500 net (which stays reserved for engine faults).
114
139
  if (error instanceof Error && error.message.includes("Malformed JSON")) {
115
- return apiError(c, 400, "invalid request body", "malformed JSON")
140
+ return errorResponse(c, 400, "invalid request body", "malformed JSON")
116
141
  }
117
142
 
118
- return apiError(c, 500, "internal error", error instanceof Error ? error.message : String(error))
143
+ return errorResponse(c, 500, "internal error", error instanceof Error ? error.message : String(error))
119
144
  })
120
145
 
121
146
  // Ahead of the handlers (which buffer the body into memory) so an oversized POST is rejected before that
@@ -124,11 +149,14 @@ export function createMailwomanAPI(engine: MailwomanAPIEngine, options: Mailwoma
124
149
  "/v1/*",
125
150
  bodyLimit({
126
151
  maxSize: options.bodyLimitBytes ?? DEFAULT_BODY_LIMIT_BYTES,
127
- onError: (c) => apiError(c, 413, "request body too large"),
152
+ onError: (c) => errorResponse(c, 413, "request body too large"),
128
153
  })
129
154
  )
130
155
 
131
- registerMailwomanAPIRoutes(app, engine, { batchMax: options.batchMax ?? DEFAULT_BATCH_MAX })
156
+ registerMailwomanAPIRoutes(app, engine, {
157
+ batchMax: options.batchMax ?? DEFAULT_BATCH_MAX,
158
+ engine: options.engine,
159
+ })
132
160
 
133
161
  attachOpenAPIDocs(app, MAILWOMAN_API_DOC_INFO)
134
162
 
@@ -10,6 +10,8 @@
10
10
 
11
11
  import type { AddressTree } from "@mailwoman/core"
12
12
 
13
+ import type { GeocodeOutcomeLike } from "#schema"
14
+
13
15
  /**
14
16
  * One parsed component in reading order (a `ComponentTag` + the covered text).
15
17
  */
@@ -21,22 +23,22 @@ export interface ParseComponent {
21
23
  /**
22
24
  * One parse outcome: ordered components + the full decoded tree (the same language `/v1/resolve` speaks).
23
25
  */
24
- export interface ParseOutcome {
26
+ export interface ParsedAddressResult {
25
27
  input: string
26
28
  components: ParseComponent[]
27
29
  tree: AddressTree
28
30
  debug?: string
29
31
  }
30
32
 
31
- /**
32
- * A geocode outcome — the engine returns the geocode-core `GeocodeResult` shape verbatim (passthrough).
33
- */
34
- export type GeocodeOutcome = Record<string, unknown>
33
+ export interface BatchResultFailure {
34
+ input: string
35
+ error: string
36
+ }
35
37
 
36
38
  /**
37
- * A batch row: a GeocodeOutcome, or an `{ input, error }` slot (per-row isolation).
39
+ * A batch row slot (per-row isolation).
38
40
  */
39
- export type BatchRow = GeocodeOutcome | { input: string; error: string }
41
+ export type BatchResultEntry<T extends Partial<GeocodeOutcomeLike> = GeocodeOutcomeLike> = T | BatchResultFailure
40
42
 
41
43
  export interface ResolveTreeOutcome {
42
44
  tree: AddressTree
@@ -53,11 +55,21 @@ export type HealthData = Record<string, unknown>
53
55
  */
54
56
  export type WireInputMode = "fragmented" | "formatted"
55
57
 
56
- export interface MailwomanAPIEngine {
57
- parse?(address: string, opts: { debug: boolean; inputMode?: WireInputMode }): Promise<ParseOutcome>
58
- geocode?(address: string, opts?: { inputMode?: WireInputMode }): Promise<GeocodeOutcome>
59
- batch?(addresses: string[], opts?: { inputMode?: WireInputMode }): Promise<{ results: BatchRow[] }>
58
+ export interface ParseInit {
59
+ inputMode?: WireInputMode
60
+ debug?: boolean
61
+ }
62
+
63
+ export type GeocodeCallback<T extends Partial<GeocodeOutcomeLike> = GeocodeOutcomeLike> = (
64
+ address: string,
65
+ opts?: ParseInit
66
+ ) => Promise<T>
67
+
68
+ export interface MailwomanAPIEngine<T extends Partial<GeocodeOutcomeLike> = GeocodeOutcomeLike> {
69
+ parse?(address: string, opts: ParseInit): Promise<ParsedAddressResult>
70
+ geocode?: GeocodeCallback<T>
71
+ batch?(addresses: string[], opts?: ParseInit): Promise<{ results: BatchResultEntry<T>[] }>
60
72
  resolveTree?(tree: AddressTree, opts: Record<string, unknown>): Promise<ResolveTreeOutcome>
61
73
  reload?(): Promise<{ reloaded: boolean; versions: unknown }>
62
- health?(): HealthData
74
+ health?(): Promise<HealthData>
63
75
  }
@@ -16,7 +16,7 @@
16
16
  * engine contract in `engine.ts`; the zod wire schemas in `schema.ts`.
17
17
  */
18
18
 
19
- export * from "./app.ts"
20
- export * from "./engine.ts"
21
- export * from "./routes.ts"
22
- export * from "./schema.ts"
19
+ export * from "#app"
20
+ export * from "#engine"
21
+ export * from "#routes"
22
+ export * from "#schema"
@@ -20,12 +20,19 @@
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 {
24
+ geocoderUnavailableError,
25
+ metricsSnapshot,
26
+ recordTimed,
27
+ stampedResponseSchema,
28
+ withEngineStamp,
29
+ } from "@mailwoman/api-kit"
24
30
  import type { AddressTree } from "@mailwoman/core/decoder"
31
+ import type { EngineStamp } from "@mailwoman/core/license"
25
32
  import type { ComponentTag } from "@mailwoman/core/types"
26
33
  import { canonicalKey, type ComponentDict, formatAddress, type FormatAddressOptions } from "@mailwoman/formatter"
27
34
 
28
- import type { MailwomanAPIEngine } from "./engine.ts"
35
+ import type { MailwomanAPIEngine } from "#engine"
29
36
  import {
30
37
  APIErrorSchema,
31
38
  BatchRequestSchema,
@@ -39,26 +46,18 @@ import {
39
46
  ParseRequestSchema,
40
47
  ResolveRequestSchema,
41
48
  ResolveResponseSchema,
42
- } from "./schema.ts"
49
+ type GeocodeOutcome,
50
+ } from "#schema"
43
51
 
44
52
  /**
45
53
  * Default `POST /v1/batch` row cap when {@link RegisterMailwomanAPIRoutesOptions.batchMax} is omitted. This is the
46
54
  * standalone-engine default, not derived from env — `mailwoman serve` always passes the env-derived value explicitly
47
- * (`$public.MAILWOMAN_BATCH_MAX`, default 1000; see `core/env/schema.ts`).
55
+ * (`$public.MAILWOMAN_BATCH_MAX`, default 1000; see `mailwoman/lib/env/schema.ts`).
48
56
  */
49
57
  export const DEFAULT_BATCH_MAX = 500
50
58
 
51
59
  const startedAt = Date.now()
52
60
 
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
61
  /**
63
62
  * Options for {@link registerMailwomanAPIRoutes}.
64
63
  */
@@ -67,6 +66,11 @@ export interface RegisterMailwomanAPIRoutesOptions {
67
66
  * Max `addresses` rows accepted by `POST /v1/batch`. Default {@link DEFAULT_BATCH_MAX}.
68
67
  */
69
68
  batchMax?: number
69
+
70
+ /**
71
+ * The engine stamp attached as `engine` to every `/v1` success body. Absent: no field is added.
72
+ */
73
+ engine?: EngineStamp
70
74
  }
71
75
 
72
76
  const errorContent = (description: string) => ({
@@ -86,7 +90,7 @@ const parseQueryParams = z.object({
86
90
  const parseResponses = {
87
91
  200: {
88
92
  description: "The tokenized input span + ranked solutions.",
89
- content: { "application/json": { schema: ParseOutcomeSchema } },
93
+ content: { "application/json": { schema: stampedResponseSchema(ParseOutcomeSchema) } },
90
94
  },
91
95
  400: errorContent("`address` is required."),
92
96
  501: errorContent("The backing engine method is not wired for this deployment."),
@@ -95,7 +99,7 @@ const parseResponses = {
95
99
  const geocodeResponses = {
96
100
  200: {
97
101
  description: "One geocode result (parse → resolve cascade), passed through from the engine verbatim.",
98
- content: { "application/json": { schema: GeocodeOutcomeSchema } },
102
+ content: { "application/json": { schema: stampedResponseSchema(GeocodeOutcomeSchema) } },
99
103
  },
100
104
  400: errorContent("`address` is required."),
101
105
  503: errorContent("The geocoding engine is not wired for this deployment (dependencies missing)."),
@@ -104,7 +108,7 @@ const geocodeResponses = {
104
108
  const batchResponses = {
105
109
  200: {
106
110
  description: "One result per input address, in input order (per-row error isolation).",
107
- content: { "application/json": { schema: BatchResponseSchema } },
111
+ content: { "application/json": { schema: stampedResponseSchema(BatchResponseSchema) } },
108
112
  },
109
113
  400: errorContent("Body must be `{ addresses: string[] }`."),
110
114
  413: errorContent("`addresses.length` exceeds the configured batch cap."),
@@ -114,7 +118,7 @@ const batchResponses = {
114
118
  const resolveResponses = {
115
119
  200: {
116
120
  description: "The same tree, decorated in place with gazetteer coordinates + attribution.",
117
- content: { "application/json": { schema: ResolveResponseSchema } },
121
+ content: { "application/json": { schema: stampedResponseSchema(ResolveResponseSchema) } },
118
122
  },
119
123
  400: errorContent("Body must be `{ tree: AddressTree, opts? }`."),
120
124
  503: errorContent("The resolver is not wired for this deployment (dependencies missing)."),
@@ -122,7 +126,7 @@ const resolveResponses = {
122
126
 
123
127
  const reloadResponses = {
124
128
  200: {
125
- description: "Versioned data switchover result — the new per-shard version map.",
129
+ description: "Versioned data switchover result — the new per-extract version map.",
126
130
  content: {
127
131
  "application/json": { schema: z.looseObject({ reloaded: z.boolean(), versions: z.unknown() }) },
128
132
  },
@@ -133,7 +137,7 @@ const reloadResponses = {
133
137
  const formatResponses = {
134
138
  200: {
135
139
  description: "The rendered address string + the deterministic canonical match key.",
136
- content: { "application/json": { schema: FormatResponseSchema } },
140
+ content: { "application/json": { schema: stampedResponseSchema(FormatResponseSchema) } },
137
141
  },
138
142
  400: errorContent("Invalid request body."),
139
143
  }
@@ -206,7 +210,7 @@ const reloadRoute = createRoute({
206
210
  method: "post",
207
211
  path: "/v1/reload",
208
212
  operationId: "reload",
209
- summary: "Reload versioned data shards (deploy-only; gate at ingress)",
213
+ summary: "Reload versioned data extracts (deploy-only; check at ingress)",
210
214
  tags: ["meta"],
211
215
  responses: reloadResponses,
212
216
  })
@@ -261,15 +265,17 @@ function toComponentDict(components: Record<string, string | string[]>): Compone
261
265
  /**
262
266
  * Register the native `/v1` routes + `/health` + `/metrics` against an injected engine.
263
267
  */
264
- export function registerMailwomanAPIRoutes(
268
+ export function registerMailwomanAPIRoutes<T extends Partial<GeocodeOutcome> = GeocodeOutcome>(
265
269
  app: OpenAPIHono,
266
- engine: MailwomanAPIEngine,
270
+ engine: MailwomanAPIEngine<T>,
267
271
  options: RegisterMailwomanAPIRoutesOptions = {}
268
272
  ): void {
269
273
  const batchMax = options.batchMax ?? DEFAULT_BATCH_MAX
274
+ const stamp = options.engine
270
275
 
271
276
  app.openapi(parseGetRoute, async (c) => {
272
277
  if (!engine.parse) return c.json({ error: "parse not implemented" }, 501)
278
+
273
279
  const address = c.req.query("address")?.trim()
274
280
 
275
281
  if (!address) return c.json({ error: "address is required" }, 400)
@@ -278,20 +284,26 @@ export function registerMailwomanAPIRoutes(
278
284
  const inputMode = inputModeRaw === "fragmented" || inputModeRaw === "formatted" ? inputModeRaw : undefined
279
285
  const outcome = await engine.parse(address, { debug, inputMode })
280
286
 
281
- return c.json(outcome, 200)
287
+ return c.json(withEngineStamp(outcome, stamp), 200)
282
288
  })
283
289
 
284
290
  app.openapi(
285
291
  parsePostRoute,
286
292
  async (c) => {
287
- if (!engine.parse) return c.json({ error: "parse not implemented" }, 501)
293
+ if (!engine.parse) {
294
+ return c.json({ error: "parse not implemented" }, 501)
295
+ }
296
+
288
297
  const { address, debug, input_mode } = c.req.valid("json")
289
298
  const trimmed = address.trim()
290
299
 
291
- if (!trimmed) return c.json({ error: "address is required" }, 400)
300
+ if (!trimmed) {
301
+ return c.json({ error: "address is required" }, 400)
302
+ }
303
+
292
304
  const outcome = await engine.parse(trimmed, { debug: debug ?? false, inputMode: input_mode })
293
305
 
294
- return c.json(outcome, 200)
306
+ return c.json(withEngineStamp(outcome, stamp), 200)
295
307
  },
296
308
  (result, c) => {
297
309
  if (!result.success) return c.json({ error: "address is required" }, 400)
@@ -303,29 +315,33 @@ export function registerMailwomanAPIRoutes(
303
315
  app.openapi(
304
316
  geocodeRoute,
305
317
  async (c) => {
306
- if (!engine.geocode) return apiError(c, 503, "geocoder not available", GEOCODER_UNAVAILABLE_DETAIL)
318
+ if (!engine.geocode) {
319
+ return geocoderUnavailableError(c)
320
+ }
321
+
307
322
  const { address, input_mode } = c.req.valid("json")
308
323
  const trimmed = address.trim()
309
324
 
310
325
  if (!trimmed) return c.json({ error: "address is required" }, 400)
311
326
  const t0 = performance.now()
312
327
 
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
- }
328
+ return engine
329
+ .geocode(trimmed, { inputMode: input_mode })
330
+ .then((outcome) => {
331
+ recordTimed(performance.now() - t0, String(outcome.resolution_tier ?? "admin"))
332
+
333
+ return c.json(withEngineStamp(outcome as GeocodeOutcome, stamp), 200)
334
+ })
335
+ .catch((error) => {
336
+ recordTimed(performance.now() - t0, "error")
337
+
338
+ throw error
339
+ })
326
340
  },
327
341
  (result, c) => {
328
- if (!result.success) return c.json({ error: "address is required" }, 400)
342
+ if (!result.success) {
343
+ return c.json({ error: "address is required" }, 400)
344
+ }
329
345
 
330
346
  return undefined
331
347
  }
@@ -336,13 +352,15 @@ export function registerMailwomanAPIRoutes(
336
352
  async (c) => {
337
353
  const { addresses, input_mode } = c.req.valid("json")
338
354
 
339
- if (!addresses.length) return c.json({ results: [] }, 200)
355
+ if (!addresses.length) return c.json(withEngineStamp({ results: [] }, stamp), 200)
340
356
 
341
357
  if (addresses.length > batchMax) {
342
358
  return c.json({ error: `batch too large: ${addresses.length} > ${batchMax}` }, 413)
343
359
  }
344
360
 
345
- if (!engine.batch) return apiError(c, 503, "geocoder not available", GEOCODER_UNAVAILABLE_DETAIL)
361
+ if (!engine.batch) {
362
+ return geocoderUnavailableError(c)
363
+ }
346
364
 
347
365
  // Whole-call latency, recorded under the "batch" tier. Per-row tier metrics are the ENGINE's
348
366
  // responsibility (phase 4b) — this app only times the call as a unit.
@@ -356,7 +374,7 @@ export function registerMailwomanAPIRoutes(
356
374
  // Same wire-vs-domain cast as `/v1/geocode` above — `BatchRow`'s `GeocodeOutcome` half is a
357
375
  // `Record<string, unknown>` passthrough; `BatchResponseSchema` now types its `GeocodeOutcome` union
358
376
  // member as the real shape.
359
- return c.json(outcome as unknown as z.infer<typeof BatchResponseSchema>, 200)
377
+ return c.json(withEngineStamp(outcome as z.infer<typeof BatchResponseSchema>, stamp), 200)
360
378
  } catch (error) {
361
379
  recordTimed(performance.now() - t0, "error")
362
380
  throw error
@@ -375,14 +393,17 @@ export function registerMailwomanAPIRoutes(
375
393
  // street node's stamped resolution tier per call — the wired engine must carry that over, and
376
394
  // must trim batch rows the same way (the route passes raw input through).
377
395
  async (c) => {
378
- if (!engine.resolveTree) return apiError(c, 503, "resolver not available", GEOCODER_UNAVAILABLE_DETAIL)
396
+ // `resolver`, not `geocoder` — the missing method is `engine.resolveTree`, and the 503's `error`
397
+ // value is what a caller branches on.
398
+ if (!engine.resolveTree) {
399
+ return geocoderUnavailableError(c, "resolver")
400
+ }
401
+
379
402
  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".
383
- const outcome = await engine.resolveTree(tree as unknown as AddressTree, opts ?? {})
384
403
 
385
- return c.json(outcome, 200)
404
+ const outcome = await engine.resolveTree(tree as AddressTree, opts ?? {})
405
+
406
+ return c.json(withEngineStamp(outcome, stamp), 200)
386
407
  },
387
408
  (result, c) => {
388
409
  if (!result.success) return c.json({ error: "body must be { tree: AddressTree, opts? }" }, 400)
@@ -392,7 +413,10 @@ export function registerMailwomanAPIRoutes(
392
413
  )
393
414
 
394
415
  app.openapi(reloadRoute, async (c) => {
395
- if (!engine.reload) return apiError(c, 503, "geocoder not available", GEOCODER_UNAVAILABLE_DETAIL)
416
+ if (!engine.reload) {
417
+ return geocoderUnavailableError(c)
418
+ }
419
+
396
420
  const outcome = await engine.reload()
397
421
 
398
422
  return c.json(outcome, 200)
@@ -403,13 +427,13 @@ export function registerMailwomanAPIRoutes(
403
427
  const dict = toComponentDict(components)
404
428
  const formatted = formatAddress(dict, country, formatOptions as FormatAddressOptions | undefined)
405
429
 
406
- return c.json({ formatted, canonicalKey: canonicalKey(dict) }, 200)
430
+ return c.json(withEngineStamp({ formatted, canonicalKey: canonicalKey(dict) }, stamp), 200)
407
431
  })
408
432
 
409
- app.openapi(healthRoute, (c) => {
433
+ app.openapi(healthRoute, async (c) => {
410
434
  const uptimeSeconds = Math.round((Date.now() - startedAt) / 1000)
411
435
 
412
- return c.json({ status: "ok", uptime_s: uptimeSeconds, ...engine.health?.() }, 200)
436
+ return c.json({ status: "ok", uptime_s: uptimeSeconds, ...(await engine.health?.()) }, 200)
413
437
  })
414
438
 
415
439
  app.openapi(metricsRoute, (c) => c.json(metricsSnapshot(), 200))