@hadoku/worker-utils 0.0.0-stage → 1.6.19

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.
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Single source of truth for hadoku.me edge prefixes.
3
+ *
4
+ * Direct `'https://hadoku.me/...'` string literals outside this file are
5
+ * a lint error after Tier 4 of the 2026-05-14 quality refactor. Reuse
6
+ * these constants so a hostname migration is one diff, not a grep-replace
7
+ * across 8+ files.
8
+ *
9
+ * Browser fetches must hit `EDGE_BASE/{prefix}/*` per the repo invariant
10
+ * recorded in AGENTS.md: direct `*.hadoku.me` subdomains are reserved for
11
+ * service-to-service traffic only.
12
+ */
13
+ declare const EDGE_BASE = "https://hadoku.me";
14
+ declare const MGMT_BASE = "https://hadoku.me/mgmt";
15
+ declare const MONITORING_BASE = "https://hadoku.me/health";
16
+ declare const SCRAPER_BASE = "https://hadoku.me/scraper";
17
+ declare const CONTACT_BASE = "https://hadoku.me/contact";
18
+ declare const RESUME_BASE = "https://hadoku.me/resume";
19
+ declare const DISPATCH_BASE = "https://hadoku.me/dispatch";
20
+ declare const PREFS_BASE = "https://hadoku.me/prefs";
21
+ declare const JOBPLATFORM_BASE = "https://hadoku.me/jobplatform";
22
+ /**
23
+ * Build a `*.workers.dev` URL for a sibling worker.
24
+ *
25
+ * Used by health checks and monitoring code that needs to bypass
26
+ * edge-router (e.g. checking whether the worker itself is reachable
27
+ * independent of the edge). Browser code MUST NOT use this — go through
28
+ * `EDGE_BASE/{prefix}/*` instead.
29
+ *
30
+ * Callers MUST pass `subdomain` explicitly (typically from
31
+ * `env.CF_WORKERS_SUBDOMAIN`). No implicit env lookup — CF Workers don't
32
+ * have a `process` global and the binding is per-request.
33
+ */
34
+ declare function workersDevUrl(workerName: string, subdomain: string | undefined): string;
35
+ /**
36
+ * The vault broker's machine-to-machine paths: mgmt-api's status probe, the
37
+ * vault status pre-flight, and single-secret reads. Exact paths (plus the one
38
+ * `get/` prefix), so the rest of `/mgmt/api/*` is not caught.
39
+ *
40
+ * edge-router keeps only their FAILURES in request_logs (a success says nothing
41
+ * mgmt-api does not audit itself), so anything that aggregates request_logs
42
+ * must leave them out too: over an errors-only sample, a count, latency or
43
+ * error rate describes the failures and reads as the route's health.
44
+ */
45
+ declare function isBrokerPlumbing(path: string): boolean;
46
+
47
+ export { CONTACT_BASE, DISPATCH_BASE, EDGE_BASE, JOBPLATFORM_BASE, MGMT_BASE, MONITORING_BASE, PREFS_BASE, RESUME_BASE, SCRAPER_BASE, isBrokerPlumbing, workersDevUrl };
@@ -0,0 +1,34 @@
1
+ // edge-routes.ts
2
+ var EDGE_BASE = "https://hadoku.me";
3
+ var MGMT_BASE = `${EDGE_BASE}/mgmt`;
4
+ var MONITORING_BASE = `${EDGE_BASE}/health`;
5
+ var SCRAPER_BASE = `${EDGE_BASE}/scraper`;
6
+ var CONTACT_BASE = `${EDGE_BASE}/contact`;
7
+ var RESUME_BASE = `${EDGE_BASE}/resume`;
8
+ var DISPATCH_BASE = `${EDGE_BASE}/dispatch`;
9
+ var PREFS_BASE = `${EDGE_BASE}/prefs`;
10
+ var JOBPLATFORM_BASE = `${EDGE_BASE}/jobplatform`;
11
+ function workersDevUrl(workerName, subdomain) {
12
+ if (!subdomain) {
13
+ throw new Error(
14
+ `CF_WORKERS_SUBDOMAIN not configured \u2014 cannot build workers.dev URL for ${workerName}.`
15
+ );
16
+ }
17
+ return `https://${workerName}.${subdomain}.workers.dev`;
18
+ }
19
+ function isBrokerPlumbing(path) {
20
+ return path === "/mgmt/api/status" || path === "/mgmt/api/secrets/status" || path.startsWith("/mgmt/api/secrets/get/");
21
+ }
22
+ export {
23
+ CONTACT_BASE,
24
+ DISPATCH_BASE,
25
+ EDGE_BASE,
26
+ JOBPLATFORM_BASE,
27
+ MGMT_BASE,
28
+ MONITORING_BASE,
29
+ PREFS_BASE,
30
+ RESUME_BASE,
31
+ SCRAPER_BASE,
32
+ isBrokerPlumbing,
33
+ workersDevUrl
34
+ };
@@ -0,0 +1,477 @@
1
+ /**
2
+ * The identity module — the single owner of "who does this row belong to".
3
+ *
4
+ * Normative contract: `docs/architecture/IDENTITY_MODEL.md` in hadoku_site.
5
+ * A comment could not hold this model; one already failed and took a working
6
+ * guard with it (the 2026-08-25 study incident, §7 there). So the rules live
7
+ * here, as the SHAPE OF AN API rather than as prose:
8
+ *
9
+ * - R3/R4 — ownership is keyed on `userId`; a `name` is input only. The only
10
+ * way out of this module is a resolved userId, so storing a name is not
11
+ * something a caller can do by accident.
12
+ * - R5 — every user reference arriving in a request body is unresolved input.
13
+ * THIS MODULE EXPORTS NO FUNCTION THAT RETURNS AN UNRESOLVED IDENTIFIER.
14
+ * `resolveGrantee` has no `userId` passthrough: handing it a userId is not
15
+ * a way to skip the registry, because there is no such way. That is the
16
+ * clause both incidents broke.
17
+ * - R6 — this file is a BOUNDARY, so it validates. Code downstream of it has
18
+ * been handed a resolved userId and treats it as opaque: store it, compare
19
+ * it, pass it through, never parse it.
20
+ * - R10 — what comes back is a BARE userId. hopper addresses its rows as
21
+ * `u:{userId}`; a consumer applies its own key derivation, and this module
22
+ * never assumes the column holds the raw value.
23
+ *
24
+ * Everything here READS the registry and never writes it, never stores a key,
25
+ * and never logs one. Minting is edge-router's and mgmt-api's alone (R2). The
26
+ * one thing a writer takes from here is {@link registryRowMetadata}: the shape
27
+ * every `key:` put must stamp so these reads can skip the per-row `get`.
28
+ *
29
+ * TWO WAYS TO READ IT, and the difference is a GRANT, not a preference:
30
+ * - directly, through a `SESSIONS_KV` binding. That namespace is keyed
31
+ * `key:{rawKey}`, so its KEYS ARE CREDENTIALS, and Workers KV has neither
32
+ * prefix scoping nor a read-only binding mode. This file can keep the raw
33
+ * key from leaving the FILE; nothing can keep it from the worker holding
34
+ * the binding. So the grant an app takes on to answer one name lookup is
35
+ * `list()` over every live key in the fleet, plus write.
36
+ * - through `resolveGranteeVia`, over a service binding to edge-router,
37
+ * which answers the one question and discloses nothing else.
38
+ *
39
+ * A worker that already resolves BY RAW KEY (a server-to-server ingest that
40
+ * holds credentials) needs the direct binding. A worker resolving a NAME so a
41
+ * human can hand a row to someone else does not, and should not have it.
42
+ */
43
+ /**
44
+ * The tiers a KEY may hold — the registry's `tier` field.
45
+ *
46
+ * Deliberately NOT the same set as `Tier` in ./auth: `public` is the ABSENCE of
47
+ * a valid key, never a value stored on a row, so a row typed `public` would be
48
+ * a key that authenticates as nobody. Ordering lives in `TIER_RANK`; this type
49
+ * only enumerates what may be WRITTEN. Adding a tier means adding it in both.
50
+ */
51
+ type KeyTier = 'admin' | 'wife' | 'service' | 'friend' | 'member';
52
+ /**
53
+ * A row in the key registry: `key:{rawKey}` → this.
54
+ *
55
+ * THE canonical definition (G1). It used to be hand-copied into edge-router,
56
+ * hadoku-task and two test fixtures, which is how the same field acquired four
57
+ * different doc comments and one of them acquired a false example.
58
+ *
59
+ * Two views of one type, and the asymmetry is deliberate:
60
+ * - a WRITER (edge-router, mgmt-api) produces a whole record, so `name`,
61
+ * `tier`, `createdAt` and `lastSeenAt` are required here;
62
+ * - a READER parses untrusted JSON out of KV, so it goes through
63
+ * `parseRegistryRow` below and gets a `Partial<KeyRegistryRecord>`. A row
64
+ * written before a field existed is a real thing that must not throw.
65
+ */
66
+ interface KeyRegistryRecord {
67
+ /** User-provided display name. null = key issued but not yet claimed. */
68
+ name: string | null;
69
+ /**
70
+ * Tier resolved at the time of last write.
71
+ * - admin/wife/friend: human-user keys, set on /session/create or by the
72
+ * mgmt-api lifecycle broker (mint / rename / set-tier).
73
+ * - service: machine-to-machine keys (PM2 wrappers, dev-vault shim, CLI).
74
+ * Service keys never go through the cookie/session flow — they are
75
+ * header-only (X-User-Key) and are issued exclusively via the broker.
76
+ *
77
+ * `wife` (added 2026-08-04) outranks service and sits below admin: a human
78
+ * tier reaching every service-gated surface without the vault / mgmt-api CLI
79
+ * admin that `KEY_ADMIN_*` confers.
80
+ */
81
+ tier: KeyTier;
82
+ /** ms epoch — first time this key was seen by the broker. Set once. */
83
+ createdAt: number;
84
+ /**
85
+ * ms epoch — last time this key AUTHENTICATED anything, not last login.
86
+ *
87
+ * Written by `upsertKeyRecord` (login / self-rename) and by
88
+ * `touchKeyLastSeen` (authGate, on any request that resolves this key,
89
+ * throttled). Counting only logins made the field a lie: sessions carry a
90
+ * 30-day sliding TTL, so a friend who signs in once and browses daily for
91
+ * months froze at the login date — and the same number is what an operator
92
+ * uses to judge whether a key is dead enough to retire.
93
+ */
94
+ lastSeenAt: number;
95
+ /**
96
+ * Stable per-user identifier, decoupled from the raw key value.
97
+ *
98
+ * A **v4 UUID**, minted lazily on first upsert/admin-set and preserved
99
+ * across key rotation, so per-user data survives a rotation (R2/R3). Five
100
+ * mint sites exist and all of them are `crypto.randomUUID()`; every live
101
+ * row conformed when verified against KV on 2026-08-27.
102
+ *
103
+ * It is NOT the display name. A row named `Hadoku` has a UUID userId like
104
+ * any other; `hadoku` is never one. A comment here once offered `hadoku` as an example userId, hadoku-study cited that when
105
+ * deleting a correct guard, and a display name landed in an ownership
106
+ * column. The two identifiers are on the same record, which is why they get
107
+ * confused, and why this module is the only thing allowed to turn one into
108
+ * the other.
109
+ *
110
+ * Downstream of a resolve, treat it as opaque — store, compare, pass
111
+ * through, never parse (R6). The rule that prevents the incident is not
112
+ * "check the shape": it is NEVER STORE AN IDENTIFIER YOU DID NOT RESOLVE.
113
+ * A registry lookup rejects `hadoku` AND a well-formed UUID belonging to
114
+ * nobody, which a regex waves through.
115
+ *
116
+ * Absent on a legacy row that has never signed in — such a row authenticates
117
+ * but cannot own anything, which is `NO_USER_ID` below.
118
+ */
119
+ userId?: string;
120
+ /**
121
+ * ms epoch — when this key was retired by a rotation swap. Set once, by the
122
+ * rotation path (mgmt-api markKeyRetired), and NEVER cleared. A retired key
123
+ * can no longer authenticate — but its row is deliberately KEPT so old logs
124
+ * (which reference masked key prefixes) still resolve back to a user via
125
+ * `userId`. Absent = still live. Rows with a `retiredAt` are the historical
126
+ * key ledger and must never be purged by inventory cleanup.
127
+ *
128
+ * The two resolution paths treat it differently, on purpose: a retired KEY
129
+ * still names its user (rotation preserves the userId), a retired NAME does
130
+ * not (names are renameable and may since have moved). See `resolveGrantee`.
131
+ */
132
+ retiredAt?: number;
133
+ /** The managed-identity id whose swap retired this key (forensic trail). */
134
+ retiredFrom?: string;
135
+ /**
136
+ * A key nobody holds (added 2026-10-29): minted by edge-router's sign-up
137
+ * route behind a passkey, never by the mgmt-api broker, and never shown to
138
+ * anyone. Set once at sign-up and never changed, so set-tier can promote the
139
+ * account without the key turning into something the vault should hold.
140
+ * Every path that accepts a PRESENTED key refuses it (a leaked copy signs in
141
+ * nobody), and `check-key-sync` does not expect a vault item for it.
142
+ */
143
+ internal?: true;
144
+ /**
145
+ * The GitHub repo this key belongs to, as `owner/name` (added 2026-07-29).
146
+ * null/absent = no repo association, which is the honest answer for a human
147
+ * friend key or a credential that no repo owns.
148
+ *
149
+ * WHY IT LIVES HERE. The mint path has always KNOWN the repo —
150
+ * `key-generate --repo PATH` takes it — and threw it away: the only durable
151
+ * copy was `secret_acl.source_id`, a bare directory BASENAME in a hash-keyed
152
+ * secrets-accounting table, which cannot tell `WolffM/foo` from
153
+ * `SomeoneElse/foo`. So "which identity is this repo's key" was unanswerable,
154
+ * and the naming convention is no substitute: three incompatible shapes are
155
+ * in live use (`agent-<repo>`, `<repo>-devvault`, `<repo>-e2e`) and a string
156
+ * heuristic would silently no-op on most repos. Authorization identity
157
+ * belongs on the identity row, not in vault bookkeeping.
158
+ *
159
+ * Written ONLY by the mgmt-api lifecycle broker (mint / register / set-repo).
160
+ * Read by `listKeysByRepo` and by task-api through its own read-only
161
+ * SESSIONS_KV binding.
162
+ */
163
+ repo?: string | null;
164
+ }
165
+ /** The minimum SESSIONS_KV surface this module uses. Read-only, by construction.
166
+ *
167
+ * `list` declares the PAGINATION fields, not just `keys`. Omitting them was not
168
+ * merely incomplete — it made the truncation bug untypeable: `scanRegistry`
169
+ * could not have followed the cursor without a type error, so the narrow shape
170
+ * quietly asserted that one call returns everything. It does not; Workers KV
171
+ * caps a page at 1000. Keep `list_complete` here so that stays impossible to
172
+ * write. */
173
+ interface RegistryKvNamespace {
174
+ get(key: string): Promise<string | null>;
175
+ list(options?: {
176
+ prefix?: string;
177
+ cursor?: string;
178
+ }): Promise<{
179
+ keys: {
180
+ name: string;
181
+ metadata?: unknown;
182
+ }[];
183
+ list_complete?: boolean;
184
+ cursor?: string;
185
+ }>;
186
+ }
187
+ /** Any env carrying the read-only registry binding. */
188
+ interface RegistryEnv {
189
+ SESSIONS_KV?: RegistryKvNamespace;
190
+ }
191
+ /** A resolved identity. Never carries key material. */
192
+ interface ResolvedIdentity {
193
+ /** The stable per-user id ownership is keyed on (R3). Bare — see R10. */
194
+ userId: string;
195
+ /** Display name, echoed so a caller can confirm the identity (R4). */
196
+ name: string | null;
197
+ /** The row's tier, echoed for the same reason. */
198
+ tier?: string;
199
+ }
200
+ /** Why a resolve did not produce an identity. `status` is the HTTP code to send. */
201
+ interface IdentityError {
202
+ error: string;
203
+ status: 400 | 404 | 409 | 503;
204
+ code?: 'NAME_NOT_FOUND' | 'NO_USER_ID' | 'NO_REGISTRY';
205
+ }
206
+ type IdentityResult = ResolvedIdentity | IdentityError;
207
+ /** Narrow an {@link IdentityResult}. Cheaper to read than `'error' in x` at call sites. */
208
+ declare function isIdentityError(result: IdentityResult): result is IdentityError;
209
+ /**
210
+ * A row matched, but it has never signed in, so no userId has been minted for
211
+ * it. It can authenticate and it cannot own anything — a real state, and not
212
+ * the same as "no such name", which is why it gets its own code and a 409.
213
+ */
214
+ declare const NO_USER_ID: IdentityError;
215
+ /**
216
+ * This worker has no registry binding, so no reference can be resolved AT ALL.
217
+ *
218
+ * Not a 4xx. Every other error here is a verdict ON THE INPUT — that name does
219
+ * not exist, that key never signed in — and reporting an unreachable registry
220
+ * in the same shape tells the caller their input is wrong when it may be
221
+ * perfectly good. It is an OUTAGE: 503, retryable, and true.
222
+ *
223
+ * It gets a code because callers could not tell it apart otherwise, and two of
224
+ * the three live call sites proved it. `watchparty-stats-api` reached around
225
+ * this module into `env.SESSIONS_KV` to make the distinction itself — correct
226
+ * answer, wrong seam, and only available to a caller who already knows the
227
+ * binding's name. `hadoku-study` did not, and mapped it through
228
+ * `status === 409 ? 409 : 404`, so a transfer against a worker whose binding
229
+ * was missing reported "No registered key named X" — a 404 blaming a name that
230
+ * was never looked up.
231
+ *
232
+ * Absence must be typed, never inferred by the caller from the outside (R8).
233
+ */
234
+ declare const NO_REGISTRY: IdentityError;
235
+ /**
236
+ * What every `key:` row carries as KV LIST METADATA — the fields a scan needs,
237
+ * copied out of the row's value at write time (hadoku_site#450).
238
+ *
239
+ * WHY. `kv.list()` returns key names, and before this each one then cost its
240
+ * own `get`: at 872 rows (2026-10-10; 84% of them retired, growing +33/day as
241
+ * `keys:bulk-swap` retires one row per service identity nightly) that put a
242
+ * named sign-in's `isNameTaken` and the share picker's user search at 1.4–4s.
243
+ * Workers KV hands back up to 1,024 bytes of metadata per key inside the list
244
+ * page itself, so with this stamped a scan is one `list` per thousand rows and
245
+ * zero gets.
246
+ *
247
+ * HOW IT STAYS TRUE. Metadata is written in the SAME put as the value, so the
248
+ * two cannot disagree for a writer that derives one from the other — and every
249
+ * writer does, through this function (edge-router) or its mirror in mgmt-api's
250
+ * `registry-metadata.ts`, pinned equal by `registry-metadata-parity.test.ts`. A
251
+ * put WITHOUT metadata clears it, which is the safe direction: such a row is
252
+ * read with a `get`, exactly as every row was before. So a writer that forgets
253
+ * costs speed, never correctness. mgmt-api's nightly `keys:registry-metadata`
254
+ * stamps any such row and `check:key-sync` reports one whose metadata
255
+ * disagrees with its value.
256
+ *
257
+ * WHAT IT IS NOT. Auth. `resolveKeyTier` reads the row's VALUE and always
258
+ * will; metadata answers only the scans (names, search, inventory), so a stale
259
+ * copy can mislabel a picker entry but can never admit a key.
260
+ *
261
+ * `v` versions the shape. A reader accepts only a version it knows and falls
262
+ * back to the value for anything else, so changing the shape is a version bump
263
+ * plus a re-stamp, never a flag day.
264
+ */
265
+ interface RegistryRowMetadata {
266
+ v: 1;
267
+ name: string | null;
268
+ tier?: string;
269
+ userId?: string;
270
+ createdAt?: number;
271
+ lastSeenAt?: number;
272
+ retiredAt?: number;
273
+ repo?: string | null;
274
+ /** Present only as `true`, on a sign-up key (KeyRegistryRecord.internal). */
275
+ internal?: true;
276
+ }
277
+ /** Workers KV's ceiling on serialized list metadata, in bytes. */
278
+ declare const REGISTRY_METADATA_MAX_BYTES = 1024;
279
+ /**
280
+ * The metadata to stamp alongside `record`, or `undefined` when it would not
281
+ * fit — in which case the row is written without, and read by `get`.
282
+ *
283
+ * Only fields present on the record are copied, so metadata never asserts a
284
+ * value the row does not hold.
285
+ */
286
+ declare function registryRowMetadata(record: Partial<KeyRegistryRecord>): RegistryRowMetadata | undefined;
287
+ /**
288
+ * The record a list entry's metadata describes, or null when it carries none
289
+ * this reader understands — the caller then reads the value.
290
+ *
291
+ * Validates every field it copies. A malformed field means the whole entry is
292
+ * distrusted rather than half-used: one `get` is cheap, a half-read row is not.
293
+ */
294
+ declare function recordFromRegistryMetadata(raw: unknown): Partial<KeyRegistryRecord> | null;
295
+ /**
296
+ * Every LIVE registry row — non-retired, and carrying a userId. Built on
297
+ * `scanRegistry`, so a stamped row costs no `get` at all.
298
+ *
299
+ * Rows without a userId are excluded because such a row cannot own anything.
300
+ * Use {@link resolveRegistryName} when you need to tell "no such name" apart
301
+ * from "that name has never signed in".
302
+ */
303
+ declare function readLiveRows(env: RegistryEnv): Promise<ResolvedIdentity[]>;
304
+ /**
305
+ * Is `name` already claimed by someone other than the caller?
306
+ *
307
+ * Lives here rather than in edge-router because it is the same scan as
308
+ * everything else in this file and it answers a question about IDENTITY, not
309
+ * about KV. Two exclusions, and both are load-bearing:
310
+ *
311
+ * - `exclude.rawKey` — the caller's own row. Without it, re-saving your own
312
+ * name reads as a collision with yourself.
313
+ * - `exclude.userId` — the caller's ROTATION PAIR. One identity holds an
314
+ * active key and a standby, both carrying the same name and userId; the
315
+ * standby is a different `rawKey`, so only the userId exclusion catches it.
316
+ *
317
+ * Unlike {@link readLiveRows} this considers rows with NO userId. A key minted
318
+ * with a name but never signed in holds that name — it is "in transit", not
319
+ * free — and treating it as free would let a second user claim a name an
320
+ * operator has already handed out. That distinction is exactly why this is not
321
+ * built on `readLiveRows`, which drops those rows because they cannot OWN
322
+ * anything. Holding a name and owning a row are different questions.
323
+ *
324
+ * A raw key goes IN (the caller already holds it) and never comes out.
325
+ */
326
+ declare function isRegistryNameTaken(env: RegistryEnv, name: string, exclude: {
327
+ rawKey: string;
328
+ userId?: string;
329
+ }): Promise<boolean>;
330
+ /**
331
+ * Resolve a registry DISPLAY NAME to its live row.
332
+ *
333
+ * Case-folded, retired-row-excluding — the same match `isNameTaken` makes in
334
+ * edge-router, so a name is exactly as unambiguous here as it is there.
335
+ *
336
+ * Returns null when no live row carries the name, and `userId: null` when a row
337
+ * matched but has never signed in. Null is AMBIGUOUS here — "no registry" and
338
+ * "no such name" both read as it — which is why this is the low-level primitive
339
+ * and {@link resolveGrantee} is what a request handler should call: that one
340
+ * separates the two as {@link NO_REGISTRY} and returns a caller nothing it has
341
+ * to infer from the outside.
342
+ */
343
+ /**
344
+ * Whether a row answers to its NAME: everyone but a member. A member signed
345
+ * themselves up (edge-router accounts.ts) and is known to cardtable only, so
346
+ * a name a friend types into a picker or a grant never resolves to one. That
347
+ * keeps a stranger's name from shadowing a friend's in anything that hands
348
+ * out access by name.
349
+ */
350
+ declare function answersToName(tier: string | undefined): boolean;
351
+ declare function resolveRegistryName(env: RegistryEnv, name: string): Promise<{
352
+ name: string;
353
+ userId: string | null;
354
+ tier?: string;
355
+ } | null>;
356
+ /**
357
+ * Every live row keyed by LOWERCASED display name, for callers resolving many
358
+ * names at once. `resolveRegistryName` costs a full scan per name; a reconcile
359
+ * over N boards would pay that 2N times. This pays it once.
360
+ *
361
+ * A name on more than one live row is operator error (the registry treats names
362
+ * as unique, `isNameTaken`-style); last row wins, matching how
363
+ * {@link registryNameMap} resolves a duplicate userId.
364
+ */
365
+ declare function liveRowsByName(env: RegistryEnv): Promise<Map<string, ResolvedIdentity>>;
366
+ /**
367
+ * userId → `{ name, tier }`, in ONE scan, for annotating a list of owners.
368
+ *
369
+ * A userId legitimately appears on more than one row mid-rotation (the active
370
+ * key and its standby share an identity). `readLiveRows` has already dropped
371
+ * the retired half, so what lands in the map is the LIVE row — which is the
372
+ * whole point, since the retired one carries the same name anyway and the map
373
+ * must not depend on scan order to say so.
374
+ *
375
+ * Degrades to an empty map when there is no registry rather than throwing: an
376
+ * annotation failure must never cost the caller the list it was annotating.
377
+ */
378
+ declare function registryNameMap(env: RegistryEnv): Promise<Map<string, {
379
+ name: string | null;
380
+ tier?: string;
381
+ }>>;
382
+ /**
383
+ * The shortest query a name search will answer.
384
+ *
385
+ * Two, not one. A single character matches most of a 113-row registry, which
386
+ * turns an autocomplete into a directory dump; two is still a usable prefix for
387
+ * a human who is typing a name they already know.
388
+ */
389
+ declare const MIN_SEARCH_QUERY_LENGTH = 2;
390
+ /**
391
+ * Search live display names by case-insensitive substring, for a person-picker.
392
+ *
393
+ * Display names carry no auth bearing, so returning them is safe — but the SET
394
+ * of them is an enumeration of the registry, so a caller must be tier-gated and
395
+ * `query` must be substantial. Results dedupe by name, prefix matches sort
396
+ * first, and the list is capped.
397
+ *
398
+ * Returns names and tiers ONLY. No userId leaves here: a person-picker has no
399
+ * use for one, and a selector that hands out ids invites the caller to send one
400
+ * back, which is exactly the shape R5 forbids.
401
+ */
402
+ declare function searchRegistryNames(env: RegistryEnv, query: string, limit: number): Promise<{
403
+ name: string;
404
+ tier?: string;
405
+ }[]>;
406
+ /**
407
+ * A service binding to edge-router. Structurally a `Fetcher`, declared here so
408
+ * a consumer needs no `@cloudflare/workers-types` import to type its env.
409
+ */
410
+ interface EdgeIdentityBinding {
411
+ fetch(input: Request): Promise<Response>;
412
+ }
413
+ /**
414
+ * What {@link resolveGranteeVia} needs, passed rather than read off a named env
415
+ * var — so the package does not get a vote on what a consumer calls its
416
+ * secret. jobplatform-api already holds a service-tier key as
417
+ * `HADOKU_SERVICE_KEY`; requiring an `EDGE_IDENTITY_KEY` would have meant
418
+ * provisioning a second vault item holding the same credential, and two names
419
+ * for one secret is how one of them goes stale.
420
+ */
421
+ interface EdgeIdentityCall {
422
+ /** The display name to resolve. Input only — never storage (R4). */
423
+ name: string;
424
+ /** A SERVICE-tier key this worker already holds. Never logged, never stored. */
425
+ serviceKey: string;
426
+ }
427
+ /**
428
+ * {@link resolveGrantee}, over a service binding instead of a registry binding.
429
+ *
430
+ * SAME QUESTION, MUCH SMALLER GRANT — that is the entire reason it exists.
431
+ * `resolveGrantee` reads `SESSIONS_KV` directly, and that namespace is keyed
432
+ * `key:{rawKey}`: its KEYS are the fleet's credentials. Workers KV has no
433
+ * prefix scoping and no read-only binding mode, so binding it into an app to
434
+ * answer one name lookup hands that whole worker `list()` over every live key,
435
+ * and write access besides. This module can keep the raw key inside the FILE
436
+ * (see `scanRegistry`); it cannot keep it inside the worker. Five apps hold
437
+ * that grant today and each new consumer widens it.
438
+ *
439
+ * Over the binding, the caller learns one userId for one name it already knew.
440
+ *
441
+ * WHICH ONE TO USE:
442
+ * - a worker that ALREADY resolves by raw key (a server-to-server ingest
443
+ * holding credentials) keeps `resolveGrantee` and its registry binding —
444
+ * there is no `key` branch here, deliberately: a route that says whether an
445
+ * arbitrary key resolves is a credential oracle, which trades the exposure
446
+ * this function exists to remove for a different one;
447
+ * - anything resolving a NAME so a human can hand a row to someone else —
448
+ * the transfer/assign shape — should use this.
449
+ *
450
+ * Returns the identical {@link IdentityResult}, so the two are interchangeable
451
+ * at a call site. A hop that fails is {@link NO_REGISTRY}: from the caller's
452
+ * side an unreachable resolver and an unbound registry are the same fact —
453
+ * identity cannot be resolved right now, the input is not at fault, retry.
454
+ */
455
+ declare function resolveGranteeVia(edge: EdgeIdentityBinding | undefined, input: EdgeIdentityCall): Promise<IdentityResult>;
456
+ /**
457
+ * Turn an inbound reference into an owner — THE only way to obtain one.
458
+ *
459
+ * Two ways in, and neither of them is "trust what you were sent":
460
+ * - by display NAME — preferred. It is the identifier a human actually has,
461
+ * and no credential changes hands.
462
+ * - by raw KEY — a bearer credential, for a server-to-server ingest that
463
+ * holds one. Never stored, never logged, never echoed.
464
+ *
465
+ * There is deliberately NO `userId` branch (R5). A userId in a request body is
466
+ * a claim: it can be a display name someone read in a doc comment, or a
467
+ * well-formed UUID belonging to nobody, and neither is distinguishable from a
468
+ * real one without asking the registry. Adding a passthrough here would make
469
+ * every consumer's R5 compliance optional again, which is how a display name
470
+ * reached `sets.owner_user_id` on 2026-08-25.
471
+ */
472
+ declare function resolveGrantee(env: RegistryEnv, input: {
473
+ name?: string;
474
+ key?: string;
475
+ }): Promise<IdentityResult>;
476
+
477
+ export { type EdgeIdentityBinding, type EdgeIdentityCall, type IdentityError, type IdentityResult, type KeyRegistryRecord, type KeyTier, MIN_SEARCH_QUERY_LENGTH, NO_REGISTRY, NO_USER_ID, REGISTRY_METADATA_MAX_BYTES, type RegistryEnv, type RegistryKvNamespace, type RegistryRowMetadata, type ResolvedIdentity, answersToName, isIdentityError, isRegistryNameTaken, liveRowsByName, readLiveRows, recordFromRegistryMetadata, registryNameMap, registryRowMetadata, resolveGrantee, resolveGranteeVia, resolveRegistryName, searchRegistryNames };