@hadoku/worker-utils 0.0.0-stage → 1.6.20

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,239 @@
1
+ // identity.ts
2
+ function isIdentityError(result) {
3
+ return "error" in result;
4
+ }
5
+ var NO_USER_ID = {
6
+ error: "That key has never signed in, so it has no id yet.",
7
+ status: 409,
8
+ code: "NO_USER_ID"
9
+ };
10
+ var NO_REGISTRY = {
11
+ error: "Identity resolution is unavailable: this worker has no key registry.",
12
+ status: 503,
13
+ code: "NO_REGISTRY"
14
+ };
15
+ var KEY_PREFIX = "key:";
16
+ function parseRegistryRow(raw) {
17
+ if (!raw) return null;
18
+ try {
19
+ const parsed = JSON.parse(raw);
20
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
21
+ return parsed;
22
+ } catch {
23
+ return null;
24
+ }
25
+ }
26
+ var REGISTRY_METADATA_MAX_BYTES = 1024;
27
+ function registryRowMetadata(record) {
28
+ const meta = {
29
+ v: 1,
30
+ name: typeof record.name === "string" ? record.name : null
31
+ };
32
+ if (typeof record.tier === "string") meta.tier = record.tier;
33
+ if (typeof record.userId === "string") meta.userId = record.userId;
34
+ if (typeof record.createdAt === "number") meta.createdAt = record.createdAt;
35
+ if (typeof record.lastSeenAt === "number") meta.lastSeenAt = record.lastSeenAt;
36
+ if (typeof record.retiredAt === "number") meta.retiredAt = record.retiredAt;
37
+ if (typeof record.repo === "string" || record.repo === null) meta.repo = record.repo;
38
+ if (record.internal === true) meta.internal = true;
39
+ const bytes = new TextEncoder().encode(JSON.stringify(meta)).length;
40
+ return bytes <= REGISTRY_METADATA_MAX_BYTES ? meta : void 0;
41
+ }
42
+ function recordFromRegistryMetadata(raw) {
43
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
44
+ const m = raw;
45
+ if (m.v !== 1) return null;
46
+ if (m.name !== null && typeof m.name !== "string") return null;
47
+ const out = { name: m.name };
48
+ const str = (k) => m[k] === void 0 || typeof m[k] === "string";
49
+ const num = (k) => m[k] === void 0 || typeof m[k] === "number";
50
+ if (!str("tier") || !str("userId")) return null;
51
+ if (!num("createdAt") || !num("lastSeenAt") || !num("retiredAt")) return null;
52
+ if (m.repo !== void 0 && m.repo !== null && typeof m.repo !== "string") return null;
53
+ if (m.internal !== void 0 && m.internal !== true) return null;
54
+ if (m.tier !== void 0) out.tier = m.tier;
55
+ if (m.userId !== void 0) out.userId = m.userId;
56
+ if (m.createdAt !== void 0) out.createdAt = m.createdAt;
57
+ if (m.lastSeenAt !== void 0) out.lastSeenAt = m.lastSeenAt;
58
+ if (m.retiredAt !== void 0) out.retiredAt = m.retiredAt;
59
+ if (m.repo !== void 0) out.repo = m.repo;
60
+ if (m.internal === true) out.internal = true;
61
+ return out;
62
+ }
63
+ function registryKv(env) {
64
+ const kv = env.SESSIONS_KV;
65
+ if (!kv || typeof kv.list !== "function" || typeof kv.get !== "function") return null;
66
+ return kv;
67
+ }
68
+ function foldName(name) {
69
+ return name.trim().toLowerCase();
70
+ }
71
+ async function scanRegistry(env) {
72
+ const kv = registryKv(env);
73
+ if (!kv) return [];
74
+ const entries = [];
75
+ let cursor;
76
+ for (; ; ) {
77
+ const page = await kv.list({ prefix: KEY_PREFIX, ...cursor ? { cursor } : {} });
78
+ for (const e of page.keys) entries.push(e);
79
+ if (page.list_complete) break;
80
+ cursor = page.cursor;
81
+ if (!cursor) break;
82
+ }
83
+ const rows = await Promise.all(
84
+ entries.map(async (e) => ({
85
+ entry: e.name,
86
+ record: recordFromRegistryMetadata(e.metadata) ?? parseRegistryRow(await kv.get(e.name))
87
+ }))
88
+ );
89
+ return rows.filter(
90
+ (r) => r.record !== null
91
+ );
92
+ }
93
+ async function readLiveRows(env) {
94
+ const rows = [];
95
+ for (const { record } of await scanRegistry(env)) {
96
+ if (record.retiredAt || !record.userId) continue;
97
+ rows.push({ userId: record.userId, name: record.name ?? null, tier: record.tier });
98
+ }
99
+ return rows;
100
+ }
101
+ async function isRegistryNameTaken(env, name, exclude) {
102
+ const target = foldName(name);
103
+ if (!target) return false;
104
+ const excludeEntry = `${KEY_PREFIX}${exclude.rawKey}`;
105
+ for (const { entry, record } of await scanRegistry(env)) {
106
+ if (entry === excludeEntry) continue;
107
+ if (record.retiredAt) continue;
108
+ if (exclude.userId && record.userId === exclude.userId) continue;
109
+ if (record.name && foldName(record.name) === target) return true;
110
+ }
111
+ return false;
112
+ }
113
+ function answersToName(tier) {
114
+ return tier !== MEMBER_TIER;
115
+ }
116
+ var MEMBER_TIER = "member";
117
+ async function resolveRegistryName(env, name) {
118
+ if (!registryKv(env)) return null;
119
+ const target = foldName(name);
120
+ if (!target) return null;
121
+ for (const { record } of await scanRegistry(env)) {
122
+ if (record.retiredAt || !answersToName(record.tier)) continue;
123
+ if (record.name && foldName(record.name) === target) {
124
+ return { name: record.name, userId: record.userId ?? null, tier: record.tier };
125
+ }
126
+ }
127
+ return null;
128
+ }
129
+ async function liveRowsByName(env) {
130
+ const map = /* @__PURE__ */ new Map();
131
+ for (const row of await readLiveRows(env)) {
132
+ if (row.name && answersToName(row.tier)) map.set(foldName(row.name), row);
133
+ }
134
+ return map;
135
+ }
136
+ async function registryNameMap(env) {
137
+ const map = /* @__PURE__ */ new Map();
138
+ for (const row of await readLiveRows(env)) {
139
+ map.set(row.userId, { name: row.name, tier: row.tier });
140
+ }
141
+ return map;
142
+ }
143
+ var MIN_SEARCH_QUERY_LENGTH = 2;
144
+ async function searchRegistryNames(env, query, limit) {
145
+ const q = foldName(query);
146
+ if (q.length < MIN_SEARCH_QUERY_LENGTH) return [];
147
+ const seen = /* @__PURE__ */ new Set();
148
+ const out = [];
149
+ for (const row of await readLiveRows(env)) {
150
+ if (!row.name || !answersToName(row.tier)) continue;
151
+ const lower = foldName(row.name);
152
+ if (!lower.includes(q) || seen.has(lower)) continue;
153
+ seen.add(lower);
154
+ out.push({ name: row.name, tier: row.tier });
155
+ }
156
+ out.sort((a, b) => {
157
+ const ap = foldName(a.name).startsWith(q) ? 0 : 1;
158
+ const bp = foldName(b.name).startsWith(q) ? 0 : 1;
159
+ return ap - bp || a.name.localeCompare(b.name);
160
+ });
161
+ return out.slice(0, Math.max(0, limit));
162
+ }
163
+ async function resolveGranteeVia(edge, input) {
164
+ if (!edge || typeof edge.fetch !== "function" || !input.serviceKey) return NO_REGISTRY;
165
+ if (!input.name.trim()) return { error: "`name` is empty.", status: 400 };
166
+ const url = `https://hadoku.me/session/identity/resolve?name=${encodeURIComponent(input.name)}`;
167
+ let res;
168
+ try {
169
+ res = await edge.fetch(new Request(url, { headers: { "X-User-Key": input.serviceKey } }));
170
+ } catch {
171
+ return NO_REGISTRY;
172
+ }
173
+ if (res.status === 401 || res.status === 403) {
174
+ return NO_REGISTRY;
175
+ }
176
+ let body;
177
+ try {
178
+ body = await res.json();
179
+ } catch {
180
+ return NO_REGISTRY;
181
+ }
182
+ if (!body || typeof body !== "object") return NO_REGISTRY;
183
+ if (res.ok) {
184
+ const row = body;
185
+ if (typeof row.userId !== "string" || !row.userId) return NO_REGISTRY;
186
+ return { userId: row.userId, name: row.name ?? null, tier: row.tier };
187
+ }
188
+ const err = body;
189
+ const status = res.status === 404 ? 404 : res.status === 409 ? 409 : res.status === 503 ? 503 : 400;
190
+ return {
191
+ error: typeof err.error === "string" ? err.error : "Identity resolution failed.",
192
+ status,
193
+ ...typeof err.code === "string" ? { code: err.code } : {}
194
+ };
195
+ }
196
+ async function resolveGrantee(env, input) {
197
+ if (input.name) {
198
+ if (!registryKv(env)) {
199
+ return NO_REGISTRY;
200
+ }
201
+ if (!input.name.trim()) return { error: "`name` is empty.", status: 400 };
202
+ const row = await resolveRegistryName(env, input.name);
203
+ if (!row) {
204
+ return {
205
+ error: `No registered key named "${input.name}".`,
206
+ status: 404,
207
+ code: "NAME_NOT_FOUND"
208
+ };
209
+ }
210
+ if (!row.userId) return NO_USER_ID;
211
+ return { userId: row.userId, name: row.name, tier: row.tier };
212
+ }
213
+ const key = input.key;
214
+ if (!key) return { error: "Provide `name` or `key`.", status: 400 };
215
+ const kv = registryKv(env);
216
+ if (!kv) return NO_REGISTRY;
217
+ const rec = parseRegistryRow(await kv.get(`${KEY_PREFIX}${key}`));
218
+ if (!rec) return { error: "That key is not registered.", status: 400 };
219
+ if (!rec.userId) return NO_USER_ID;
220
+ return { userId: rec.userId, name: rec.name ?? null, tier: rec.tier };
221
+ }
222
+ export {
223
+ MIN_SEARCH_QUERY_LENGTH,
224
+ NO_REGISTRY,
225
+ NO_USER_ID,
226
+ REGISTRY_METADATA_MAX_BYTES,
227
+ answersToName,
228
+ isIdentityError,
229
+ isRegistryNameTaken,
230
+ liveRowsByName,
231
+ readLiveRows,
232
+ recordFromRegistryMetadata,
233
+ registryNameMap,
234
+ registryRowMetadata,
235
+ resolveGrantee,
236
+ resolveGranteeVia,
237
+ resolveRegistryName,
238
+ searchRegistryNames
239
+ };
@@ -0,0 +1,108 @@
1
+ import { HadokuAuthContext, HadokuAuthOptions } from './auth.js';
2
+ export { TIER_RANK, Tier, getAuthContext, requireMinTier, tierAtLeast } from './auth.js';
3
+ export { DEFAULT_CONTENT_LEVEL, MAX_CONTENT_LEVEL_BY_TIER, maxContentLevelForTier } from './content-level.js';
4
+ import { MiddlewareHandler, Context } from 'hono';
5
+ export { extractContext, extractField, getRequestMetadata, parseBody } from './context.js';
6
+ export { CommonRules, isNonEmptyString, requireFields, validateFields } from './validation.js';
7
+ export { CORSPresets, DEFAULT_HADOKU_ORIGINS, createCorsMiddleware, createHadokuCors, getCorsHeaders, isOriginAllowed, matchOrigin } from './cors.js';
8
+ export { SENSITIVE_FIELDS, createLogger, createRequestLogger, formatError, logError, logRequest, loggerMiddleware, redactFields, startTimer } from './cloudflareWorkerLogger.js';
9
+ export { RateLimitConfig, RateLimitResult, checkRateLimit, recordRequest, resetRateLimit } from './rate-limit.js';
10
+ export { MASKING, maskEmail, maskIpAddress, maskKey, maskSessionId } from './masking.js';
11
+ export { badRequest, created, healthCheck, notFound, ok, serverError, validationError } from './responses.js';
12
+ export { AuthConfig, AuthContext, CORSConfig, ContextExtractionConfig, ErrorResponse, HealthCheckResponse, LogEntry, LogLevel, LoggerConfig, SuccessResponse, UserType, ValidationResult, ValidationRule } from './types.js';
13
+ export { DetailedErrorResponseSchema, ErrorResponseSchema, PromptApiHealthResponseSchema, SimpleErrorResponseSchema, TaskApiHealthResponseSchema, ThrottleErrorResponseSchema, ValidationErrorResponseSchema, createDeleteResponseSchema, createHealthResponseSchema, createSuccessResponseSchema, createWrappedHealthResponseSchema } from './openapi-schemas.js';
14
+ export { CommonTags, ErrorResponseFormat, badRequestWrapped, createErrorHandlers, createOpenAPIDocConfig, createValidationHook, createdWrapped, notFoundWrapped, okWrapped, serverErrorWrapped, simpleValidationHook, wrappedValidationHook } from './openapi-config.js';
15
+ import '@wolffm/logger/worker';
16
+ import '@hono/zod-openapi';
17
+ import 'hono/utils/types';
18
+
19
+ /**
20
+ * Edge-auth verification — the backend half of the centralized auth channel.
21
+ *
22
+ * edge-router (hadoku.me) resolves the caller's tier once, then stamps every
23
+ * proxied request with:
24
+ * - `X-Edge-Auth: <EDGE_AUTH_SECRET>` — provenance (proves it came via edge)
25
+ * - `X-Hadoku-Tier: public|friend|service|wife|admin` — edge-resolved tier
26
+ * - `X-User-Key: <key>` — the resolved key (for audit trails)
27
+ *
28
+ * A backend worker calls `verifyEdgeAuth` / `createEdgeAuth` instead of
29
+ * resolving the key itself — only edge-router touches the key registry.
30
+ *
31
+ * DEGRADE-TO-PUBLIC, not refuse. Workers are reachable directly at
32
+ * `*.workers.dev` bypassing edge — and some of those direct hits are
33
+ * legitimate and PUBLIC (the monitoring probe hits `/…/health` directly with
34
+ * no auth header). So a request without valid `X-Edge-Auth` is treated as
35
+ * `public`, NOT refused: the edge-stamped `X-Hadoku-Tier` is only trusted when
36
+ * provenance checks out. This blocks tier-forgery (you can't send
37
+ * `X-Hadoku-Tier: admin` directly and be believed) while keeping public routes
38
+ * reachable. Protected routes still gate via `requireMinTier`, which 403s a
39
+ * public caller — so the provenance check effectively gates tier ELEVATION,
40
+ * not request admission.
41
+ *
42
+ * IDENTITY IS THE KEY'S, AND ONLY THE EDGE SAYS WHOSE KEY IT IS. edge-router
43
+ * resolves the caller's key against the registry and stamps `X-User-Id` /
44
+ * `X-User-Name` from that record (stripping any client copy first). On a direct
45
+ * hit nothing did that, so those headers are whatever the caller typed — and
46
+ * until 2026-10-09 they reached the app anyway: this middleware downgraded the
47
+ * TIER but left the identity headers on the request, and task and study read
48
+ * `X-User-Id` straight off it. Anyone could name any userId and act as that
49
+ * person. Now an unverified request has every edge-stamped identity header
50
+ * REMOVED before any handler runs (`c.req.raw` is replaced with a scrubbed
51
+ * copy), so code that reads the header directly — the common shape — sees no
52
+ * identity at all rather than a forged one. Verified requests are untouched,
53
+ * and their identity is also on `authContext.userId` / `.userName`, which is
54
+ * where new code should read it.
55
+ *
56
+ * `createEdgeAuth` is a drop-in for `createHadokuAuth`: it sets the same
57
+ * `authContext` ({ userType, credential }) and supports the same `extend`
58
+ * option, so a worker migrates by swapping the factory name. Keep the route's
59
+ * `requireMinTier(...)` gate — it's the backstop that turns "public direct
60
+ * hit" into a 403 on protected routes.
61
+ */
62
+
63
+ /** Environment shape required by edge-auth — just the shared secret. */
64
+ interface EdgeAuthEnv {
65
+ EDGE_AUTH_SECRET?: string;
66
+ }
67
+ type EdgeAuthResult = {
68
+ ok: true;
69
+ userType: HadokuAuthContext['userType'];
70
+ credential: string | null;
71
+ userId: string | null;
72
+ userName: string | null;
73
+ } | {
74
+ ok: false;
75
+ reason: 'misconfigured' | 'bad-provenance';
76
+ };
77
+ /**
78
+ * Verify a request arrived via edge-router and extract the edge-resolved tier.
79
+ *
80
+ * - `misconfigured`: the worker has no EDGE_AUTH_SECRET bound — fail CLOSED.
81
+ * (Every worker got the secret in Step 1; a missing one is a deploy fault,
82
+ * and degrading to public would be a security hole.)
83
+ * - `bad-provenance`: X-Edge-Auth absent or mismatched — a direct
84
+ * *.workers.dev hit, not edge traffic.
85
+ *
86
+ * On success, `userType` comes from X-Hadoku-Tier (defaults to 'public' if the
87
+ * header is absent/garbage but provenance is valid — e.g. an edge route that
88
+ * stamps auth but resolved an anonymous caller).
89
+ */
90
+ declare function verifyEdgeAuth(c: Context, env: EdgeAuthEnv): EdgeAuthResult;
91
+ /**
92
+ * Drop-in replacement for `createHadokuAuth`. Sets `authContext` to
93
+ * `{ userType, credential }` (+ any `extend` fields). When provenance is
94
+ * valid, the tier comes from the edge-stamped header; otherwise it degrades to
95
+ * `public` (see the module header — never refuses, never trusts an unverified
96
+ * tier). Never short-circuits: routes gate via `requireMinTier`.
97
+ *
98
+ * @example
99
+ * ```typescript
100
+ * // Before: app.use('*', createHadokuAuth<Env, Extra>({ extend }));
101
+ * // After: app.use('*', createEdgeAuth<Env, Extra>({ extend }));
102
+ * ```
103
+ */
104
+ declare function createEdgeAuth<TExtra extends Record<string, unknown> = Record<string, never>>(options?: HadokuAuthOptions<TExtra> & {
105
+ contextKey?: string;
106
+ }): MiddlewareHandler;
107
+
108
+ export { type EdgeAuthEnv, type EdgeAuthResult, HadokuAuthContext, HadokuAuthOptions, createEdgeAuth, verifyEdgeAuth };