@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 +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
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
|
-
#
|
|
1
|
+
# Worker Utilities - Quick Reference
|
|
2
2
|
|
|
3
|
-
|
|
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 };
|