@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.
package/LICENSE ADDED
@@ -0,0 +1,15 @@
1
+ ISC License
2
+
3
+ Copyright (c) 2026 WolffM
4
+
5
+ Permission to use, copy, modify, and/or distribute this software for any
6
+ purpose with or without fee is hereby granted, provided that the above
7
+ copyright notice and this permission notice appear in all copies.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
10
+ WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
11
+ MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
12
+ ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
13
+ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
14
+ ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
15
+ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,335 @@
1
- # Temporary Holding Version
1
+ # Worker Utilities - Quick Reference
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Shared utilities for Cloudflare Workers. Generic, reusable patterns extracted from production workers.
4
+
5
+ **Import:** `import { ... } from '@wolffm/worker-utils'`
6
+
7
+ ---
8
+
9
+ ## Files
10
+
11
+ ```text
12
+ workers/shared/
13
+ ├── types.ts # TypeScript type definitions
14
+ ├── auth.ts # Authentication middleware
15
+ ├── context.ts # Request context extraction
16
+ ├── validation.ts # Input validation
17
+ ├── cors.ts # CORS configuration
18
+ ├── logging.ts # Structured logging
19
+ ├── masking.ts # Data masking for safe logging
20
+ ├── responses.ts # HTTP response helpers
21
+ └── index.ts # Barrel export
22
+ ```
23
+
24
+ ---
25
+
26
+ ## auth.ts
27
+
28
+ **Edge-verified authentication:**
29
+
30
+ A worker does NOT resolve keys. edge-router resolves the caller against its KV
31
+ key registry, then stamps `X-Hadoku-Tier` alongside an `X-Edge-Auth` provenance
32
+ header; `createEdgeAuth` verifies the provenance against `EDGE_AUTH_SECRET`
33
+ before trusting the tier, and degrades to `public` when it doesn't verify.
34
+ Tier priority: `admin > service > friend > public`. `service` is for
35
+ worker-to-worker / sibling-app traffic.
36
+
37
+ > **Removed in 1.3.0:** `createHadokuAuth`, `HadokuAuthEnv`, `parseKeysFromEnv`,
38
+ > `parseKeysWithNames`, `validateKeyAndGetType`. They read the `ADMIN_KEYS` /
39
+ > `SERVICE_KEYS` / `FRIEND_KEYS` env arrays, which were retired 2026-07-26 —
40
+ > so they had already stopped authenticating anyone. `createEdgeAuth` is a
41
+ > drop-in (same context shape, same `extend` option).
42
+
43
+ ```typescript
44
+ // Standard hadoku ecosystem auth:
45
+ import { createEdgeAuth } from '@wolffm/worker-utils';
46
+
47
+ app.use('*', createEdgeAuth());
48
+ // ↑ verifies X-Edge-Auth, then trusts the edge-stamped X-Hadoku-Tier
49
+
50
+ // Generic helper (single-key-per-tier case only):
51
+ app.use(
52
+ '*',
53
+ createKeyAuth((env) => ({
54
+ [env.SOME_SINGLE_KEY]: 'admin',
55
+ }))
56
+ );
57
+ ```
58
+
59
+ **Functions:**
60
+
61
+ - `createKeyAuth(keyMap, options?)` - Simple key → userType mapping
62
+ - `createAuthMiddleware(config)` - Custom auth logic
63
+ - `validateKeyAndGetType(key, adminKeys, friendKeys, serviceKeys?)` - Validate key & return userType (4-arg form opts into the `'service'` tier)
64
+ - `parseKeysFromEnv(jsonString)` - Parse key config from env vars
65
+ - `getAuthContext(c)` - Get auth from context
66
+ - `requireMinTier('friend')` - Gate a route at a MINIMUM tier (`public < friend < service < wife < admin`); every higher tier is admitted by rank
67
+ - `tierAtLeast(auth, 'admin')` - Same rank check for an already-resolved auth context, for in-handler branching. Never compare `auth.userType` to a string
68
+ - `TIER_RANK` / `Tier` - The rank table and tier union, shared by the edge gate and the in-worker gate
69
+ - ~~`requireUserType([...])`~~ - **Removed** 2026-07-25. Translate to `requireMinTier(<lowest tier in the list>)`
70
+
71
+ **Adds to context:** `c.get('authContext')` → `{ userType, isAdmin, isFriend, isPublic }`
72
+
73
+ ---
74
+
75
+ ## masking.ts
76
+
77
+ **Mask sensitive data for logging:**
78
+
79
+ ```typescript
80
+ logRequest('POST', '/session/create', {
81
+ sessionId: maskSessionId(sessionId), // "1a2b3c4d5e6f7g8h"
82
+ key: maskKey(authKey), // "admin-secre" (50%)
83
+ });
84
+ ```
85
+
86
+ **Functions:**
87
+
88
+ - `maskKey(key, percentage?)` - Mask key (show first 50% by default, no suffix)
89
+ - `maskSessionId(id)` - Mask session ID (show first 16 chars, no suffix)
90
+ - `maskEmail(email)` - Mask email (show first char + domain)
91
+ - `redactFields(obj, fields)` - Redact sensitive fields from objects
92
+
93
+ **Constants:**
94
+
95
+ - `MASKING.KEY_PERCENTAGE` - Percentage of key to show (0.5 = 50%)
96
+ - `MASKING.KEY_MIN_LENGTH` - Minimum characters to show (4)
97
+ - `MASKING.SESSION_ID_PREFIX_LENGTH` - Default session ID prefix (16)
98
+ - `SENSITIVE_FIELDS` - Common sensitive field names
99
+
100
+ **Examples:**
101
+
102
+ ```typescript
103
+ maskKey('admin-secret-key-123'); // "admin-secre"
104
+ maskSessionId('1a2b3c4d5e6f7g8h9i0j'); // "1a2b3c4d5e6f7g8h"
105
+ maskEmail('john@example.com'); // "j***@example.com"
106
+
107
+ redactFields({ username: 'john', password: 'secret' }, ['password']);
108
+ // { username: 'john', password: '[REDACTED]' }
109
+ ```
110
+
111
+ ---
112
+
113
+ **Extract common fields:**
114
+
115
+ ```typescript
116
+ const { userId, sessionId } = extractUserContext(c);
117
+ const boardId = extractField(c, ['query:boardId', 'body:boardId'], 'main');
118
+ ```
119
+
120
+ **Functions:**
121
+
122
+ - `extractUserContext(c)` - Get userId, sessionId
123
+ - `extractField(c, sources, default?, transform?)` - Single field from multiple sources
124
+ - `extractContext(c, config)` - Multiple fields at once
125
+ - `extractPagination(c)` - Get page, limit, offset
126
+ - `extractSorting(c)` - Get sortBy, sortOrder
127
+ - `getRequestMetadata(c)` - Get IP, userAgent, country, etc.
128
+
129
+ **Source format:** `'header:X-User-Id'`, `'query:boardId'`, `'body:title'`
130
+
131
+ ---
132
+
133
+ ## validation.ts
134
+
135
+ **Validate request data:**
136
+
137
+ ```typescript
138
+ const validation = validateFields(body, [
139
+ { field: 'id', required: true, type: 'string' },
140
+ { field: 'title', required: true, minLength: 1, maxLength: 200 },
141
+ ]);
142
+ if (!validation.valid) return validationError(c, validation.errors);
143
+ ```
144
+
145
+ **Functions:**
146
+
147
+ - `validateFields(data, rules)` - Full validation with rules
148
+ - `requireFields(data, ['id', 'title'])` - Quick required check
149
+ - `validateBody(rules)` - Middleware for auto-validation
150
+ - `createValidator(rules)` - Reusable validator function
151
+
152
+ **CommonRules:**
153
+
154
+ - `CommonRules.id()` - UUID validation
155
+ - `CommonRules.title({ maxLength })` - String 1-200 chars
156
+ - `CommonRules.email(required)` - Email pattern
157
+ - `CommonRules.url(required)` - URL pattern
158
+ - `CommonRules.enum('status', ['active', 'inactive'])` - Enum validation
159
+ - `CommonRules.array('tags', options)` - Array validation
160
+ - `CommonRules.number('age', { min, max, integer })` - Number constraints
161
+
162
+ ---
163
+
164
+ ## cors.ts
165
+
166
+ **Standard Hadoku CORS:**
167
+
168
+ ```typescript
169
+ app.use('*', createHadokuCors());
170
+ ```
171
+
172
+ **Functions:**
173
+
174
+ - `createHadokuCors(additionalOrigins?)` - Hadoku defaults + custom
175
+ - `createCorsMiddleware(config)` - Full custom config
176
+ - `matchOrigin(origin, pattern)` - Wildcard matching
177
+
178
+ **Presets:**
179
+
180
+ - `CORSPresets.hadoku` - Production Hadoku origins
181
+ - `CORSPresets.development` - Includes localhost
182
+ - `CORSPresets.production(origins)` - Custom production
183
+ - `CORSPresets.publicApi` - Public API (all origins)
184
+
185
+ ---
186
+
187
+ ## logging.ts
188
+
189
+ **Log requests and errors:**
190
+
191
+ ```typescript
192
+ logRequest('GET', '/api/items', { userId, boardId });
193
+ logError('POST', '/api/items', 'Validation failed', { errors });
194
+ ```
195
+
196
+ **Functions:**
197
+
198
+ - `logRequest(method, path, context?, level?)` - Log HTTP requests
199
+ - `logError(method, path, error, context?)` - Log errors
200
+ - `createLogger(config)` - Custom logger instance
201
+ - `createRequestLogger(method, path, baseContext)` - Request-scoped logger
202
+ - `startTimer()` - Performance timing (`timer.end(message, context)`)
203
+ - `redactFields(obj, fields)` - Remove sensitive data
204
+
205
+ **Constants:**
206
+
207
+ - `SENSITIVE_FIELDS` - Common sensitive field names (password, apiKey, token, etc.)
208
+
209
+ ---
210
+
211
+ ## responses.ts
212
+
213
+ **Standard HTTP responses:**
214
+
215
+ ```typescript
216
+ return ok(c, { items });
217
+ return created(c, { item });
218
+ return badRequest(c, 'Missing field: id');
219
+ return notFound(c, 'Task');
220
+ ```
221
+
222
+ **Success:**
223
+
224
+ - `ok(c, data, message?)` - 200
225
+ - `created(c, data, message?)` - 201
226
+ - `noContent(c)` - 204
227
+
228
+ **Errors:**
229
+
230
+ - `badRequest(c, message, details?)` - 400
231
+ - `unauthorized(c, message?)` - 401
232
+ - `forbidden(c, message?)` - 403
233
+ - `notFound(c, resource?)` - 404
234
+ - `conflict(c, message, details?)` - 409
235
+ - `serverError(c, message, details?)` - 500
236
+ - `validationError(c, errors)` - 400 with validation details
237
+
238
+ **Health & Error Handling:**
239
+
240
+ - `healthCheck(c, service, checks?)` - Health check response
241
+ - `createHealthCheckHandler(service, config)` - Health check handler factory
242
+ - `handleError(c, error, message)` - Generic error handler
243
+ - `withErrorHandling(handler)` - Async error wrapper
244
+
245
+ ---
246
+
247
+ ## types.ts
248
+
249
+ **Import types:**
250
+
251
+ ```typescript
252
+ import type {
253
+ UserType, // 'admin' | 'service' | 'friend' | 'public'
254
+ AuthContext, // { userType, userId?, isAdmin, isFriend, isPublic }
255
+ ValidationRule, // { field, required?, type?, minLength?, ... }
256
+ ValidationResult, // { valid, errors }
257
+ LoggerConfig, // { prefix?, minLevel?, includeTimestamp? }
258
+ CORSConfig, // { origins, methods, allowedHeaders, ... }
259
+ } from '@wolffm/worker-utils';
260
+ ```
261
+
262
+ ---
263
+
264
+ ## Common Patterns
265
+
266
+ ### Minimal Worker
267
+
268
+ ```typescript
269
+ import { Hono } from 'hono';
270
+ import {
271
+ createKeyAuth,
272
+ createHadokuCors,
273
+ extractUserContext,
274
+ ok,
275
+ } from '@wolffm/worker-utils';
276
+
277
+ const app = new Hono();
278
+
279
+ app.use('*', createHadokuCors());
280
+ app.use('*', createEdgeAuth());
281
+ // ↑ verifies X-Edge-Auth, trusts the edge-stamped tier
282
+
283
+ app.get('/api/items', async (c) => {
284
+ const { userId } = extractUserContext(c);
285
+ const items = await getItems(userId);
286
+ return ok(c, { items });
287
+ });
288
+
289
+ export default app;
290
+ ```
291
+
292
+ ### With Validation
293
+
294
+ ```typescript
295
+ app.post('/api/items', async (c) => {
296
+ const body = await c.req.json();
297
+
298
+ const validation = validateFields(body, [
299
+ CommonRules.id(),
300
+ CommonRules.title({ maxLength: 200 }),
301
+ ]);
302
+
303
+ if (!validation.valid) {
304
+ return validationError(c, validation.errors);
305
+ }
306
+
307
+ const item = await createItem(body);
308
+ return created(c, { item });
309
+ });
310
+ ```
311
+
312
+ ### With Logging
313
+
314
+ ```typescript
315
+ app.post('/api/items', async (c) => {
316
+ const { userId } = extractUserContext(c);
317
+ logRequest('POST', '/api/items', { userId });
318
+
319
+ try {
320
+ const item = await createItem(body);
321
+ return created(c, { item });
322
+ } catch (error) {
323
+ logError('POST', '/api/items', error.message, { userId });
324
+ return serverError(c, 'Failed to create item');
325
+ }
326
+ });
327
+ ```
328
+
329
+ ---
330
+
331
+ ## See Also
332
+
333
+ - **[WORKERS.md](../WORKERS.md)** - Worker index and setup guide
334
+ - **[docs/child-apps/NEW_CHILD_APP_CHECKLIST.md](../../docs/child-apps/NEW_CHILD_APP_CHECKLIST.md)** - Backend worker setup (see "Optional: Backend API" section)
335
+ - **[AGENTS.md](../../AGENTS.md)** - Worker debugging and common patterns
package/dist/auth.d.ts ADDED
@@ -0,0 +1,153 @@
1
+ import { Context, MiddlewareHandler } from 'hono';
2
+ import { AuthContext } from './types.js';
3
+
4
+ /**
5
+ * The Hadoku tier model, and the helpers that read it off a Hono context.
6
+ *
7
+ * Workers do NOT authenticate anyone. edge-router resolves the caller against
8
+ * its KV key registry, stamps `X-Hadoku-Tier` alongside an `X-Edge-Auth`
9
+ * provenance header, and `createEdgeAuth` (edgeAuth.ts) verifies the
10
+ * provenance before trusting the tier — degrading to `public` when it doesn't.
11
+ * That is the only auth middleware in this package.
12
+ *
13
+ * What used to live here — `createAuthMiddleware`, `createHadokuAuth`,
14
+ * `parseKeysFromEnv`, `validateKeyAndGetType` — resolved a caller by parsing
15
+ * the ADMIN_KEYS / FRIEND_KEYS / SERVICE_KEYS env bindings. Those bindings
16
+ * were retired on 2026-07-26, so the code had already stopped authenticating
17
+ * anyone; it was removed in 1.3.0 rather than left exported looking like
18
+ * working auth. See workers/shared/AGENTS.md.
19
+ *
20
+ * What's left is the part every worker still needs: `TIER_RANK` (the single
21
+ * source of the `public < member < friend < service < wife < admin` ordering, shared with
22
+ * edge-router's route gate), `tierAtLeast`, `requireMinTier`, and
23
+ * `getAuthContext`.
24
+ */
25
+
26
+ /**
27
+ * Get auth context from Hono context
28
+ *
29
+ * @param c - Hono context
30
+ * @param contextKey - Key where auth context is stored
31
+ * @returns Auth context
32
+ */
33
+ declare function getAuthContext(c: Context, contextKey?: string): AuthContext;
34
+ /**
35
+ * The Hadoku tier hierarchy. Each tier supersedes every tier below it:
36
+ * `public < member < friend < service < wife < admin`. A higher tier is admitted
37
+ * anywhere a lower tier is — service and admin can reach a friend-gated route,
38
+ * admin can reach a service-gated route, etc. Access is by rank, never an
39
+ * ad-hoc list.
40
+ *
41
+ * The NUMBERS are meaningless on their own; only their ORDER is load-bearing.
42
+ * Nothing persists a rank — the registry stores the tier NAME and every
43
+ * comparison re-derives the rank from this table — so inserting a tier in the
44
+ * middle (as `wife` was on 2026-08-04, between service and admin, and `member`
45
+ * on 2026-10-29, between public and friend) is a renumbering, not a migration.
46
+ *
47
+ * `member` is a person who signed themselves up (a passkey, no handed-out key;
48
+ * docs/planning/member-tier-2026-10-28.md). Only cardtable admits them; every
49
+ * other private app stays at `friend`, which they do not reach.
50
+ *
51
+ * A CALLER tier not in this table ranks as `public` — see {@link tierAtLeast}.
52
+ */
53
+ declare const TIER_RANK: Record<string, number>;
54
+ type Tier = 'public' | 'member' | 'friend' | 'service' | 'wife' | 'admin';
55
+ /**
56
+ * Rank comparison for an already-resolved auth context — the in-handler
57
+ * counterpart to {@link requireMinTier}.
58
+ *
59
+ * Use this instead of comparing `auth.userType` to a string. A direct
60
+ * `auth.userType === 'admin'` (or `!== 'friend'`) breaks the hierarchy: it
61
+ * locks a higher tier out of a lower tier's branch, which is exactly the bug
62
+ * the tier model exists to prevent. `tierAtLeast(auth, 'friend')` admits
63
+ * friend, service, wife, and admin.
64
+ *
65
+ * An unknown or absent caller tier ranks as `public`: admitted where anyone is,
66
+ * nowhere else. That is still fail-closed for every gated route, and it means
67
+ * a tier this bundle has never heard of is a guest rather than a refusal — so a
68
+ * stale consumer serves a new tier's public pages instead of 403ing them, and
69
+ * the next tier added does not need every app upgraded in lockstep. Until
70
+ * 1.6.0 an unknown tier ranked -1, below public.
71
+ *
72
+ * An unknown REQUIRED tier throws: that is a typo in a gate, and refusing
73
+ * everyone or admitting everyone would both hide it.
74
+ *
75
+ * @example
76
+ * ```typescript
77
+ * const auth = getAuthContext(c);
78
+ * if (!tierAtLeast(auth, 'admin')) return c.json({ error: 'Forbidden' }, 403);
79
+ *
80
+ * // Branch on tier inside a handler that serves several tiers:
81
+ * const rows = tierAtLeast(auth, 'admin') ? await allRows() : await ownRows(auth);
82
+ * ```
83
+ */
84
+ declare function tierAtLeast(auth: {
85
+ userType?: string;
86
+ } | null | undefined, minTier: Tier): boolean;
87
+ /**
88
+ * Create middleware that requires a MINIMUM tier — the preferred, hierarchy-
89
+ * aware way to gate a route. Any caller at `minTier` or above is admitted
90
+ * (`public < member < friend < service < wife < admin`), so `requireMinTier('friend')`
91
+ * lets friend, service, wife, and admin through. Use this instead of hardcoding
92
+ * an allowlist; it expresses intent ("friend and up") and can never accidentally
93
+ * lock out a higher tier.
94
+ *
95
+ * @example
96
+ * ```typescript
97
+ * app.use('/profiles/*', requireMinTier('friend')); // friend, service, wife, admin
98
+ * app.use('/ingest', requireMinTier('service')); // service, wife, admin
99
+ * app.use('/wife/*', requireMinTier('wife')); // wife, admin
100
+ * app.use('/admin/*', requireMinTier('admin')); // admin only
101
+ * ```
102
+ */
103
+ declare function requireMinTier(minTier: Tier, options?: {
104
+ contextKey?: string;
105
+ errorMessage?: string;
106
+ errorStatus?: number;
107
+ }): MiddlewareHandler;
108
+ /**
109
+ * Standard auth context for Hadoku APIs
110
+ *
111
+ * Used by prompt-api, contact-api, and task-api for consistent auth handling.
112
+ */
113
+ interface HadokuAuthContext {
114
+ /**
115
+ * User permission level. Tier priority is
116
+ * `admin > wife > service > friend > member > public`.
117
+ * `service` is for worker-to-worker / sibling-app traffic.
118
+ */
119
+ userType: 'admin' | 'wife' | 'service' | 'friend' | 'member' | 'public';
120
+ /** The credential used for authentication (for audit trails) */
121
+ credential: string | null;
122
+ /**
123
+ * The caller's registry userId, as edge-router resolved it from their key —
124
+ * set only when edge provenance verified, null otherwise. Prefer this to
125
+ * reading `X-User-Id` yourself (createEdgeAuth strips an unverified one, but
126
+ * this field cannot be forged by construction).
127
+ */
128
+ userId?: string | null;
129
+ /** The caller's registry display name, same provenance rule as `userId`. */
130
+ userName?: string | null;
131
+ }
132
+ /**
133
+ * Options for the tier-resolving auth middlewares (today: createEdgeAuth).
134
+ */
135
+ interface HadokuAuthOptions<TExtra extends Record<string, unknown> = Record<string, never>> {
136
+ /**
137
+ * Extend the auth context with additional fields.
138
+ * Receives the base context and returns extra fields to merge.
139
+ *
140
+ * @example
141
+ * ```typescript
142
+ * createEdgeAuth({
143
+ * extend: (base) => ({
144
+ * sessionId: base.credential || 'public',
145
+ * key: base.credential,
146
+ * })
147
+ * })
148
+ * ```
149
+ */
150
+ extend?: (base: HadokuAuthContext) => TExtra;
151
+ }
152
+
153
+ export { type HadokuAuthContext, type HadokuAuthOptions, TIER_RANK, type Tier, getAuthContext, requireMinTier, tierAtLeast };
package/dist/auth.js ADDED
@@ -0,0 +1,39 @@
1
+ // auth.ts
2
+ function getAuthContext(c, contextKey = "authContext") {
3
+ return c.get(contextKey);
4
+ }
5
+ var TIER_RANK = {
6
+ public: 0,
7
+ member: 1,
8
+ friend: 2,
9
+ service: 3,
10
+ wife: 4,
11
+ admin: 5
12
+ };
13
+ function tierAtLeast(auth, minTier) {
14
+ if (!Object.hasOwn(TIER_RANK, minTier)) {
15
+ throw new Error(`tierAtLeast: unknown required tier '${minTier}'`);
16
+ }
17
+ const caller = auth?.userType;
18
+ const callerRank = caller !== void 0 && Object.hasOwn(TIER_RANK, caller) ? TIER_RANK[caller] : 0;
19
+ return callerRank >= TIER_RANK[minTier];
20
+ }
21
+ function requireMinTier(minTier, options = {}) {
22
+ const {
23
+ contextKey = "authContext",
24
+ errorMessage = "Insufficient permissions",
25
+ errorStatus = 403
26
+ } = options;
27
+ return async (c, next) => {
28
+ if (!tierAtLeast(getAuthContext(c, contextKey), minTier)) {
29
+ return c.json({ error: errorMessage }, errorStatus);
30
+ }
31
+ await next();
32
+ };
33
+ }
34
+ export {
35
+ TIER_RANK,
36
+ getAuthContext,
37
+ requireMinTier,
38
+ tierAtLeast
39
+ };
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Generic Auth Page
3
+ *
4
+ * Renders a simple authentication page for protected routes.
5
+ * After successful auth, creates a session via /session/create and redirects.
6
+ * The session cookie is set by the server and used for subsequent requests.
7
+ *
8
+ * Progressive enhancement: the form has `action="/session/create"`
9
+ * `method="POST"` and submits as `application/x-www-form-urlencoded` if the
10
+ * JS handler doesn't run (content blocker, JS error, transient extension
11
+ * issue). The /session/create handler accepts both shapes — JSON+header for
12
+ * the JS-enhanced path, form body for the native-form fallback. Without this
13
+ * fallback, ANY fetch failure (e.g. iOS content blocker dropping POSTs)
14
+ * leaves the user with "Load failed" and no recourse.
15
+ *
16
+ * Themes: the visitor's theme, as everywhere else on hadoku.me. The page
17
+ * links /theme-bootstrap.js (spec/theme-bootstrap.js, which sets data-theme
18
+ * from the `hadoku-theme` storage the apps write) and /themes.css
19
+ * (@wolffm/themes' own stylesheet); the Astro build emits both
20
+ * (src/pages/themes.css.ts, src/pages/theme-bootstrap.js.ts). Every colour
21
+ * below is a theme token with the old fixed palette as its fallback, so the
22
+ * page still renders where those files are not served.
23
+ *
24
+ * Password managers: the key is a password and the form says so in the
25
+ * vocabulary every manager reads — `autocomplete="current-password"`, a
26
+ * label, and a hidden `username` field, because managers file a credential
27
+ * under a username and a key has none (Chrome's and Bitwarden's documented
28
+ * shape for a password-only form). The WebView in the Android app hands the
29
+ * same hints to whichever autofill service is installed. The server reads
30
+ * only `key`, `return`, `fp` and `ft`.
31
+ *
32
+ * Device fingerprint (`fp`): a DETECTION signal, never an auth factor. The
33
+ * page hashes a handful of device traits (screen, timezone, languages,
34
+ * hardware, canvas and WebGL renders) in the browser and sends only the
35
+ * digests — the raw values never leave it. edge-router compares them against
36
+ * the devices that key has signed in from before and flags a stranger (see
37
+ * workers/edge-router/src/device-ledger.ts). Collection is time-boxed and
38
+ * every failure degrades to "no fingerprint", so it can never cost a login.
39
+ */
40
+ interface AuthPageOptions {
41
+ /** Title shown on the auth page */
42
+ title?: string;
43
+ /** Description shown above the form */
44
+ description?: string;
45
+ /** The target path to redirect to after auth (defaults to current path) */
46
+ targetPath?: string;
47
+ /** Error message shown above the form (from ?error= on the form-fallback path) */
48
+ errorMessage?: string;
49
+ /**
50
+ * Open Graph title/description for link-preview crawlers (Discord, Slack,
51
+ * iMessage…). Crawlers hitting a gated path receive this sign-in page, so
52
+ * without these the embed reads "Sign in to Hadoku / Enter your access
53
+ * key." — pass the APP's identity here so the preview describes the
54
+ * destination while humans still see the sign-in form. Falls back to
55
+ * title/description when not given.
56
+ */
57
+ ogTitle?: string;
58
+ ogDescription?: string;
59
+ /**
60
+ * Absolute og:image URL for the preview card. Crawlers fetch this with NO
61
+ * cookies, from their own IPs — so it must resolve for an unauthenticated
62
+ * caller or the card renders imageless. Supplying it also upgrades the
63
+ * Twitter card from `summary` to `summary_large_image`.
64
+ */
65
+ ogImage?: string;
66
+ /** Pixel dimensions of ogImage. Let crawlers reserve layout before the fetch. */
67
+ ogImageWidth?: number;
68
+ ogImageHeight?: number;
69
+ /** Canonical og:url for the resource, when it differs from the gated path. */
70
+ ogUrl?: string;
71
+ /**
72
+ * The edge's signed proof that a sign-in came through THIS page (edge-router
73
+ * auth-funnel.ts). Posted back as `ft` on both the fetch and the native-form
74
+ * path; `/session/create` refuses a sign-in without one.
75
+ */
76
+ funnelToken?: string;
77
+ /**
78
+ * Offer sign-up beside sign-in: a name and a new passkey make a member
79
+ * (edge-router accounts.ts). Only where the destination admits members,
80
+ * so a stranger is never invited to make an account that gets them nowhere.
81
+ */
82
+ signup?: boolean;
83
+ }
84
+ /**
85
+ * `/auth/sign-in.js`: the sign-in client as an ES module, for an app that
86
+ * takes a key in its own UI.
87
+ *
88
+ * const { signIn } = await import('/auth/sign-in.js');
89
+ * await signIn(key); // throws Error(message) on a refused key
90
+ *
91
+ * if (passkeysSupported()) await signInWithPasskey(); // same, no key
92
+ * await addPasskey(); // signed in: save one on this device
93
+ * await signUp(name); // a new member account, signed in
94
+ *
95
+ * Both passkey calls take `{ signal }` to cancel an open prompt, and give up
96
+ * on their own after 90s, so a prompt that never answers cannot hang the UI.
97
+ *
98
+ * Each throws an Error whose message is fit to show. The funnel token is
99
+ * fetched per call from `/auth/funnel-token` rather than baked into the module, so a tab left open past the token's life still signs
100
+ * in through the funnel. Signing in changes who the page is acting for, so the
101
+ * caller reloads afterwards.
102
+ */
103
+ declare function renderSignInModule(): string;
104
+ /**
105
+ * Render a standalone auth page HTML
106
+ */
107
+ declare function renderAuthPage(options?: AuthPageOptions): string;
108
+ /**
109
+ * Create an auth page Response
110
+ */
111
+ declare function authPageResponse(options?: AuthPageOptions): Response;
112
+
113
+ export { type AuthPageOptions, authPageResponse, renderAuthPage, renderSignInModule };