@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.
- package/LICENSE +15 -0
- package/README.md +334 -2
- package/dist/auth.d.ts +153 -0
- package/dist/auth.js +39 -0
- package/dist/authPage.d.ts +113 -0
- package/dist/authPage.js +833 -0
- package/dist/cloudflareWorkerLogger.d.ts +71 -0
- package/dist/cloudflareWorkerLogger.js +94 -0
- package/dist/content-level.d.ts +52 -0
- package/dist/content-level.js +28 -0
- package/dist/context.d.ts +110 -0
- package/dist/context.js +75 -0
- package/dist/cors.d.ts +123 -0
- package/dist/cors.js +113 -0
- package/dist/edge-routes.d.ts +47 -0
- package/dist/edge-routes.js +34 -0
- package/dist/identity.d.ts +477 -0
- package/dist/identity.js +239 -0
- package/dist/index.d.ts +108 -0
- package/dist/index.js +976 -0
- package/dist/masking.d.ts +108 -0
- package/dist/masking.js +90 -0
- package/dist/openapi-config.d.ts +252 -0
- package/dist/openapi-config.js +131 -0
- package/dist/openapi-schemas.d.ts +157 -0
- package/dist/openapi-schemas.js +79 -0
- package/dist/rate-limit.d.ts +39 -0
- package/dist/rate-limit.js +59 -0
- package/dist/responses.d.ts +159 -0
- package/dist/responses.js +80 -0
- package/dist/types.d.ts +166 -0
- package/dist/types.js +0 -0
- package/dist/validation.d.ts +88 -0
- package/dist/validation.js +172 -0
- package/package.json +34 -4
|
@@ -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 };
|