@rdlabo/workers-hono-kit 0.10.1 → 0.10.3
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/README.md +29 -5
- package/dist/cache/kv-cache.d.ts +20 -4
- package/dist/cache/kv-cache.js +42 -12
- package/dist/firebase/jose-firebase-verifier.d.ts +12 -0
- package/dist/firebase/jose-firebase-verifier.js +20 -4
- package/dist/http/defer.d.ts +2 -2
- package/dist/http/defer.js +7 -4
- package/dist/http/http-error.d.ts +1 -1
- package/dist/http/http-error.js +4 -2
- package/dist/http/query-failed-error.js +3 -2
- package/dist/idempotency/idempotency.d.ts +43 -0
- package/dist/idempotency/idempotency.js +28 -0
- package/dist/index.d.ts +6 -6
- package/dist/index.js +3 -3
- package/dist/middleware/auth.d.ts +37 -3
- package/dist/middleware/auth.js +36 -7
- package/dist/middleware/validation.d.ts +2 -2
- package/dist/middleware/validation.js +3 -2
- package/dist/offline/index.d.ts +2 -0
- package/dist/offline/index.js +1 -0
- package/dist/offline/journal-retention.d.ts +49 -0
- package/dist/offline/journal-retention.js +68 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -84,14 +84,14 @@ npm install ai ai-gateway-provider # createAiGatewayProvider
|
|
|
84
84
|
| `createMaintenanceMiddleware(options)` / `createMaintenanceWaitHandler(options)` / `isMaintenanceEnabled(env)` / `MAINTENANCE_CODE` / `MAINTENANCE_WAIT_PATH` | Fleet maintenance short-circuit: when enabled (`MAINTENANCE=1`), every non-allowlisted request returns `503` + `{ statusCode, message, code: 'MAINTENANCE' }` **before** container/DB. Pair with `GET /public/maintenance/wait` SSE (`event: ping` / `event: ended`) so clients can auto-dismiss a lock UI. Mount after `cors`, before `containerMiddleware`. |
|
|
85
85
|
| `ErrorReporter` / `ErrorReportContext` | Types for a `reportError`-style unhandled-error reporter (e.g. wired to Sentry), paired with `createHttpErrorHandler`'s `onUnhandledError`. |
|
|
86
86
|
| `createSentryErrorReporter(sentry)` / `SentryExceptionReporterLike` | Build an `ErrorReporter` that forwards to Sentry with an optional `request_id` tag (no hard `@sentry/cloudflare` dependency). |
|
|
87
|
-
| `DeferExecutor` / `defaultDefer` / `createWaitUntilDefer(ctx)` | Fire-and-forget executor for Workers:
|
|
87
|
+
| `DeferExecutor` / `defaultDefer` / `createWaitUntilDefer(ctx)` | Fire-and-forget executor for Workers: both variants log background rejections without propagating them; `createWaitUntilDefer` also registers work via `ctx.waitUntil`. |
|
|
88
88
|
| `configureHibernationAutoResponse` / `upgradeHibernationWebSocket` / `broadcastHibernationWebSockets` | Hibernation WebSocket room primitives: runtime ping/pong without waking JavaScript, attachment-before-accept upgrade, and broadcast through sockets restored by `getWebSockets()`. |
|
|
89
89
|
| `acknowledgeHibernationWebSocketClose` / `closeHibernationWebSocket` | Safe close helpers, including normalization of reserved received-only close codes. |
|
|
90
90
|
| `retryDurableObjectOperation(operation, options?)` / `isRetryableDurableObjectError(error)` | Retry idempotent DO work only for `retryable && !overloaded`, with jittered exponential backoff. `operation` runs per attempt so callers create a fresh stub after an exception. |
|
|
91
91
|
| `createIdempotencyInput(...)` / `runIdempotentMutation(...)` | Canonical payload hashing and a transaction-bound mutation state machine. Missing keys preserve legacy behavior; replay/conflict/in-flight semantics are shared while each app owns its schema and ORM adapter. |
|
|
92
92
|
| `withIdempotencyHttpErrors(run)` | Maps only standard idempotency failures to 400/409/503 and rethrows unrelated failures. |
|
|
93
93
|
| `createAiGatewayProvider(config)` / `AiGatewayConfig` / `AiGatewayProvider` | Route `@ai-sdk` models through the Cloudflare AI Gateway, via either a Workers `AI` binding or REST credentials (`accountId` / `gateway` / `token`). |
|
|
94
|
-
| `KVCache` / `KVNamespace` / `KVCacheOptions` | Workers-KV cache-aside helper (key `appName+version+table_type_column`, sha256 for string ids, TTL clamped ≥60s). Set `appName` / `version` per application. |
|
|
94
|
+
| `KVCache` / `KVNamespace` / `KVCacheOptions` / `KVCacheErrorContext` / `KVCacheOperation` | Workers-KV cache-aside helper (key `appName+version+table_type_column`, sha256 for string ids, TTL clamped ≥60s). Set `appName` / `version` per application; optional `onError(error, context)` observes fail-soft read/parse/serialize/write/delete failures. Context contains only the operation and logical table, not cache types, keys, ids, or values. |
|
|
95
95
|
| `createStripeClient(secret, opts?)` / `verifyStripeWebhook(...)` / `CreateStripeClientOptions` | Workers-native Stripe client (fetch transport) + async webhook verification (SubtleCrypto). `apiVersion` optional (pin to a fixed Stripe API version). |
|
|
96
96
|
| `extractStripeFailureReason(source)` / `StripeFailureReason` | Duck-type a Stripe `PaymentIntent` / `Invoice` / `{ paymentIntent?, invoice? }` / thrown error into a normalized `{ code, declineCode, message, paymentIntentId, invoiceId, subscriptionId }` (SDK-free), or `null`. |
|
|
97
97
|
| `stripeFailureMessageJa(reason)` | Render a `StripeFailureReason` (or `null`) as a single user-facing Japanese sentence (`decline_code` > `code`; fraud codes masked; unknown → generic). |
|
|
@@ -481,8 +481,10 @@ Repos with a custom DB error classifier (e.g. odss-mobile) pass `classify` to
|
|
|
481
481
|
### Auth middleware
|
|
482
482
|
|
|
483
483
|
Encodes the shared skeleton (read token header → verify → `getAppInfo` → resolve user id →
|
|
484
|
-
set context, with
|
|
485
|
-
|
|
484
|
+
set context, with configurable reporting and response hooks). Inject your own verify/resolver,
|
|
485
|
+
context-variable names, and failure mode. By default a missing header remains backward compatible
|
|
486
|
+
and calls `verify('')`; set `rejectMissingToken: true` to reject missing/blank input first with
|
|
487
|
+
`AuthTokenMissingError`.
|
|
486
488
|
|
|
487
489
|
`createAuthMiddleware<Env, Verified, Id>` is generic over your Hono `Env`, so `c.set(...)` in
|
|
488
490
|
`setContext` is type-checked against your `Variables`.
|
|
@@ -492,6 +494,7 @@ import { createAuthMiddleware, createIdentityAuthFailureBody } from '@rdlabo/wor
|
|
|
492
494
|
|
|
493
495
|
// AuthGuard: verify + resolve (and provision) the DB user id.
|
|
494
496
|
const userAuth = createAuthMiddleware<AppEnv, UserRecord, number>({
|
|
497
|
+
rejectMissingToken: true,
|
|
495
498
|
verify: (token) => container.firebase.verifyIdToken(token),
|
|
496
499
|
resolveUserId: (record, _c, appInfo) =>
|
|
497
500
|
container.auth.getUserIdFromFirebase(record, appInfo).catch(() => container.auth.createUser(record)),
|
|
@@ -500,18 +503,28 @@ const userAuth = createAuthMiddleware<AppEnv, UserRecord, number>({
|
|
|
500
503
|
c.set('userId', userId);
|
|
501
504
|
c.set('appInfo', appInfo);
|
|
502
505
|
},
|
|
503
|
-
|
|
506
|
+
reportFailure: (error, context, { stage, tokenPresent }) => {
|
|
507
|
+
// Suppress expected credential rejection; report dependency/internal failures without tokens.
|
|
508
|
+
},
|
|
509
|
+
onFailure: (_error, context, { stage }) =>
|
|
504
510
|
context.json(createIdentityAuthFailureBody(), 401),
|
|
505
511
|
});
|
|
506
512
|
|
|
507
513
|
// TokenGuard (login): verify only — omit resolveUserId. Override the failure if needed.
|
|
508
514
|
const tokenAuth = createAuthMiddleware<AppEnv, UserRecord>({
|
|
515
|
+
rejectMissingToken: true,
|
|
509
516
|
verify: (token) => container.firebase.verifyIdToken(token),
|
|
510
517
|
setContext: (c, { verified }) => c.set('userRecord', verified),
|
|
511
518
|
onFailure: (_e, c) => c.json(createIdentityAuthFailureBody(), 401),
|
|
512
519
|
});
|
|
513
520
|
```
|
|
514
521
|
|
|
522
|
+
`reportFailure(error, context, details)` receives only the stage (`token`, `verify`, `appInfo`,
|
|
523
|
+
`resolveUserId`, or `setContext`) and a `tokenPresent` boolean; raw token data is never included in
|
|
524
|
+
`details`. If the hook is omitted, the historical `console.error(error)` behavior remains. A reporting
|
|
525
|
+
hook failure is logged but cannot change the authentication response. `onFailure` receives the same
|
|
526
|
+
details as its third argument and may be asynchronous.
|
|
527
|
+
|
|
515
528
|
Authentication failures use three explicit scopes. Only `identity` permits a client to purge its
|
|
516
529
|
global authenticated session, offline replica boundary, and outbox. `reauthentication` means the
|
|
517
530
|
identity remains valid but a recent sign-in is required; `credential` belongs to a domain feature
|
|
@@ -613,6 +626,17 @@ await cache.set('users', 'byId', userId, user, 600);
|
|
|
613
626
|
const hit = await cache.get<User>('users', 'byId', userId);
|
|
614
627
|
```
|
|
615
628
|
|
|
629
|
+
Cache failures remain fail-soft. To report them without changing caller behavior, configure the
|
|
630
|
+
optional observer (for example, to forward the raw error to Sentry). Kit-generated context is
|
|
631
|
+
limited to `operation` and `table`; it never adds the cache type, generated key, id, or value.
|
|
632
|
+
|
|
633
|
+
```ts
|
|
634
|
+
const cache = new KVCache(env.CACHE, {
|
|
635
|
+
appName: 'myapp',
|
|
636
|
+
onError: (error, context) => reportError(error, context),
|
|
637
|
+
});
|
|
638
|
+
```
|
|
639
|
+
|
|
616
640
|
### Stripe (Workers-native)
|
|
617
641
|
|
|
618
642
|
```ts
|
package/dist/cache/kv-cache.d.ts
CHANGED
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
* Provides a thin, JSON-serializing wrapper around a {@link KVNamespace} for the common
|
|
5
5
|
* "look in cache, fall back to the source of truth" pattern. Reads and writes are best-effort:
|
|
6
6
|
* any KV error, serialization failure, or oversized key is swallowed so callers transparently
|
|
7
|
-
* fall through to their backing store instead of throwing.
|
|
7
|
+
* fall through to their backing store instead of throwing. An optional error reporter makes
|
|
8
|
+
* operational failures observable; kit-generated context omits cache types, keys, identifiers,
|
|
9
|
+
* and values.
|
|
8
10
|
*
|
|
9
11
|
* Cache keys are namespaced as `<appName><version><table>_<type>_<id>`, where a string `id` is
|
|
10
12
|
* hashed with SHA-256 (hex) and a numeric `id` is used verbatim.
|
|
@@ -57,6 +59,15 @@ export interface KVNamespace {
|
|
|
57
59
|
*/
|
|
58
60
|
delete(key: string): Promise<void>;
|
|
59
61
|
}
|
|
62
|
+
/** The cache stage that failed while performing a fail-soft operation. */
|
|
63
|
+
export type KVCacheOperation = 'read' | 'parse' | 'serialize' | 'write' | 'delete';
|
|
64
|
+
/** Non-sensitive context supplied to {@link KVCacheOptions.onError}. */
|
|
65
|
+
export interface KVCacheErrorContext {
|
|
66
|
+
/** The cache stage that failed. */
|
|
67
|
+
operation: KVCacheOperation;
|
|
68
|
+
/** Logical table name; cache types, raw keys, identifiers, and values are intentionally omitted. */
|
|
69
|
+
table: string;
|
|
70
|
+
}
|
|
60
71
|
/**
|
|
61
72
|
* Configuration for a {@link KVCache} instance.
|
|
62
73
|
*/
|
|
@@ -81,6 +92,11 @@ export interface KVCacheOptions {
|
|
|
81
92
|
* Defaults to `600`.
|
|
82
93
|
*/
|
|
83
94
|
defaultLifetime?: number;
|
|
95
|
+
/**
|
|
96
|
+
* Optional synchronous observer for failures that the cache intentionally handles as misses.
|
|
97
|
+
* Reporter failures are isolated from cache callers and logged separately.
|
|
98
|
+
*/
|
|
99
|
+
onError?: (error: unknown, context: KVCacheErrorContext) => void;
|
|
84
100
|
}
|
|
85
101
|
/**
|
|
86
102
|
* A single entry to store via {@link KVCache.setMany}.
|
|
@@ -145,8 +161,8 @@ export declare class KVCache {
|
|
|
145
161
|
* JSON-serialize and store a value.
|
|
146
162
|
*
|
|
147
163
|
* Falsy `data` is ignored. The effective TTL is `max(minTtlSeconds, lifetime ?? defaultLifetime)`,
|
|
148
|
-
* honoring the KV 60-second floor. Oversized keys
|
|
149
|
-
* skipped.
|
|
164
|
+
* honoring the KV 60-second floor. Oversized keys are skipped; serialization/write failures are
|
|
165
|
+
* skipped and reported when an observer is configured.
|
|
150
166
|
*
|
|
151
167
|
* @param table - Logical table or entity name.
|
|
152
168
|
* @param type - Sub-key discriminator.
|
|
@@ -198,7 +214,7 @@ export declare class KVCache {
|
|
|
198
214
|
/**
|
|
199
215
|
* Remove a cached entry.
|
|
200
216
|
*
|
|
201
|
-
* Oversized keys
|
|
217
|
+
* Oversized keys are ignored. Delete failures are reported when an observer is configured.
|
|
202
218
|
*
|
|
203
219
|
* @param table - Logical table or entity name.
|
|
204
220
|
* @param type - Sub-key discriminator.
|
package/dist/cache/kv-cache.js
CHANGED
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
* Provides a thin, JSON-serializing wrapper around a {@link KVNamespace} for the common
|
|
5
5
|
* "look in cache, fall back to the source of truth" pattern. Reads and writes are best-effort:
|
|
6
6
|
* any KV error, serialization failure, or oversized key is swallowed so callers transparently
|
|
7
|
-
* fall through to their backing store instead of throwing.
|
|
7
|
+
* fall through to their backing store instead of throwing. An optional error reporter makes
|
|
8
|
+
* operational failures observable; kit-generated context omits cache types, keys, identifiers,
|
|
9
|
+
* and values.
|
|
8
10
|
*
|
|
9
11
|
* Cache keys are namespaced as `<appName><version><table>_<type>_<id>`, where a string `id` is
|
|
10
12
|
* hashed with SHA-256 (hex) and a numeric `id` is used verbatim.
|
|
@@ -56,6 +58,7 @@ export class KVCache {
|
|
|
56
58
|
#version;
|
|
57
59
|
#minTtl;
|
|
58
60
|
#defaultLifetime;
|
|
61
|
+
#onError;
|
|
59
62
|
/**
|
|
60
63
|
* Create a cache bound to a specific KV namespace.
|
|
61
64
|
*
|
|
@@ -68,6 +71,16 @@ export class KVCache {
|
|
|
68
71
|
this.#version = options.version ?? 'v8_';
|
|
69
72
|
this.#minTtl = options.minTtlSeconds ?? 60;
|
|
70
73
|
this.#defaultLifetime = options.defaultLifetime ?? 600;
|
|
74
|
+
this.#onError = options.onError;
|
|
75
|
+
}
|
|
76
|
+
/** Report an operational failure without letting observer code break the fail-soft contract. */
|
|
77
|
+
#reportError(error, context) {
|
|
78
|
+
try {
|
|
79
|
+
this.#onError?.(error, context);
|
|
80
|
+
}
|
|
81
|
+
catch (reporterError) {
|
|
82
|
+
console.error('[KVCache] error reporter failed', reporterError);
|
|
83
|
+
}
|
|
71
84
|
}
|
|
72
85
|
/**
|
|
73
86
|
* Build the fully namespaced KV key for the given coordinates.
|
|
@@ -108,14 +121,22 @@ export class KVCache {
|
|
|
108
121
|
if (!key) {
|
|
109
122
|
return undefined;
|
|
110
123
|
}
|
|
124
|
+
let data;
|
|
125
|
+
try {
|
|
126
|
+
data = await this.#kv.get(key);
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
this.#reportError(error, { operation: 'read', table });
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
132
|
+
if (!data) {
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
111
135
|
try {
|
|
112
|
-
const data = await this.#kv.get(key);
|
|
113
|
-
if (!data) {
|
|
114
|
-
return undefined;
|
|
115
|
-
}
|
|
116
136
|
return JSON.parse(data);
|
|
117
137
|
}
|
|
118
|
-
catch {
|
|
138
|
+
catch (error) {
|
|
139
|
+
this.#reportError(error, { operation: 'parse', table });
|
|
119
140
|
return undefined;
|
|
120
141
|
}
|
|
121
142
|
}
|
|
@@ -123,8 +144,8 @@ export class KVCache {
|
|
|
123
144
|
* JSON-serialize and store a value.
|
|
124
145
|
*
|
|
125
146
|
* Falsy `data` is ignored. The effective TTL is `max(minTtlSeconds, lifetime ?? defaultLifetime)`,
|
|
126
|
-
* honoring the KV 60-second floor. Oversized keys
|
|
127
|
-
* skipped.
|
|
147
|
+
* honoring the KV 60-second floor. Oversized keys are skipped; serialization/write failures are
|
|
148
|
+
* skipped and reported when an observer is configured.
|
|
128
149
|
*
|
|
129
150
|
* @param table - Logical table or entity name.
|
|
130
151
|
* @param type - Sub-key discriminator.
|
|
@@ -149,11 +170,18 @@ export class KVCache {
|
|
|
149
170
|
try {
|
|
150
171
|
payload = JSON.stringify(data);
|
|
151
172
|
}
|
|
152
|
-
catch {
|
|
173
|
+
catch (error) {
|
|
174
|
+
this.#reportError(error, { operation: 'serialize', table });
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
if (payload === undefined) {
|
|
178
|
+
this.#reportError(new TypeError('Cache value is not JSON-serializable'), { operation: 'serialize', table });
|
|
153
179
|
return;
|
|
154
180
|
}
|
|
155
181
|
const ttl = Math.max(this.#minTtl, lifetime ?? this.#defaultLifetime);
|
|
156
|
-
await this.#kv.put(key, payload, { expirationTtl: ttl }).catch(() =>
|
|
182
|
+
await this.#kv.put(key, payload, { expirationTtl: ttl }).catch((error) => {
|
|
183
|
+
this.#reportError(error, { operation: 'write', table });
|
|
184
|
+
});
|
|
157
185
|
}
|
|
158
186
|
/**
|
|
159
187
|
* Store many values concurrently.
|
|
@@ -199,7 +227,7 @@ export class KVCache {
|
|
|
199
227
|
/**
|
|
200
228
|
* Remove a cached entry.
|
|
201
229
|
*
|
|
202
|
-
* Oversized keys
|
|
230
|
+
* Oversized keys are ignored. Delete failures are reported when an observer is configured.
|
|
203
231
|
*
|
|
204
232
|
* @param table - Logical table or entity name.
|
|
205
233
|
* @param type - Sub-key discriminator.
|
|
@@ -215,6 +243,8 @@ export class KVCache {
|
|
|
215
243
|
if (!key) {
|
|
216
244
|
return;
|
|
217
245
|
}
|
|
218
|
-
await this.#kv.delete(key).catch(() =>
|
|
246
|
+
await this.#kv.delete(key).catch((error) => {
|
|
247
|
+
this.#reportError(error, { operation: 'delete', table });
|
|
248
|
+
});
|
|
219
249
|
}
|
|
220
250
|
}
|
|
@@ -22,6 +22,18 @@ type KeyInput = CryptoKey | KeyObject | JWK | Uint8Array | JWTVerifyGetKey;
|
|
|
22
22
|
* be verified against Google's rotating public keys.
|
|
23
23
|
*/
|
|
24
24
|
export declare const SECURETOKEN_JWK_URL = "https://www.googleapis.com/service_accounts/v1/jwk/securetoken@system.gserviceaccount.com";
|
|
25
|
+
/** Expected rejection for Firebase-specific claims that passed JOSE signature/claim verification. */
|
|
26
|
+
export declare class FirebaseIdTokenValidationError extends Error {
|
|
27
|
+
readonly claim: 'subject' | 'exp' | 'iat' | 'auth_time';
|
|
28
|
+
/** Stable machine-readable code for authentication failure classifiers. */
|
|
29
|
+
readonly code = "ERR_FIREBASE_ID_TOKEN_INVALID";
|
|
30
|
+
/**
|
|
31
|
+
* Create a Firebase ID-token validation rejection.
|
|
32
|
+
*
|
|
33
|
+
* @param claim - Firebase-specific claim which failed validation.
|
|
34
|
+
*/
|
|
35
|
+
constructor(claim: 'subject' | 'exp' | 'iat' | 'auth_time');
|
|
36
|
+
}
|
|
25
37
|
/**
|
|
26
38
|
* Verifies Firebase ID tokens with `jose` RS256 against Google's securetoken JWKS, and
|
|
27
39
|
* optionally looks up or deletes users via the Google Identity Toolkit REST API.
|
|
@@ -8,6 +8,22 @@ import { jwtVerify } from 'jose';
|
|
|
8
8
|
* be verified against Google's rotating public keys.
|
|
9
9
|
*/
|
|
10
10
|
export const SECURETOKEN_JWK_URL = 'https://www.googleapis.com/service_accounts/v1/jwk/securetoken@system.gserviceaccount.com';
|
|
11
|
+
/** Expected rejection for Firebase-specific claims that passed JOSE signature/claim verification. */
|
|
12
|
+
export class FirebaseIdTokenValidationError extends Error {
|
|
13
|
+
claim;
|
|
14
|
+
/** Stable machine-readable code for authentication failure classifiers. */
|
|
15
|
+
code = 'ERR_FIREBASE_ID_TOKEN_INVALID';
|
|
16
|
+
/**
|
|
17
|
+
* Create a Firebase ID-token validation rejection.
|
|
18
|
+
*
|
|
19
|
+
* @param claim - Firebase-specific claim which failed validation.
|
|
20
|
+
*/
|
|
21
|
+
constructor(claim) {
|
|
22
|
+
super(`Firebase ID token has an invalid ${claim}`);
|
|
23
|
+
this.claim = claim;
|
|
24
|
+
this.name = 'FirebaseIdTokenValidationError';
|
|
25
|
+
}
|
|
26
|
+
}
|
|
11
27
|
/**
|
|
12
28
|
* Verifies Firebase ID tokens with `jose` RS256 against Google's securetoken JWKS, and
|
|
13
29
|
* optionally looks up or deletes users via the Google Identity Toolkit REST API.
|
|
@@ -74,18 +90,18 @@ export class JoseFirebaseVerifier {
|
|
|
74
90
|
const { payload } = typeof key === 'function' ? await jwtVerify(idToken, key, options) : await jwtVerify(idToken, key, options);
|
|
75
91
|
// Apply Firebase's documented checks beyond signature/iss/aud/exp.
|
|
76
92
|
if (!payload.sub || typeof payload.sub !== 'string' || payload.sub.length > 128) {
|
|
77
|
-
throw new
|
|
93
|
+
throw new FirebaseIdTokenValidationError('subject');
|
|
78
94
|
}
|
|
79
95
|
if (!Number.isFinite(payload.exp)) {
|
|
80
|
-
throw new
|
|
96
|
+
throw new FirebaseIdTokenValidationError('exp');
|
|
81
97
|
}
|
|
82
98
|
const issuedAt = payload.iat;
|
|
83
99
|
if (typeof issuedAt !== 'number' || !Number.isFinite(issuedAt) || issuedAt > now) {
|
|
84
|
-
throw new
|
|
100
|
+
throw new FirebaseIdTokenValidationError('iat');
|
|
85
101
|
}
|
|
86
102
|
const authTime = payload.auth_time;
|
|
87
103
|
if (typeof authTime !== 'number' || !Number.isFinite(authTime) || authTime > now) {
|
|
88
|
-
throw new
|
|
104
|
+
throw new FirebaseIdTokenValidationError('auth_time');
|
|
89
105
|
}
|
|
90
106
|
return { ...payload, uid: payload.sub, email: payload.email };
|
|
91
107
|
}
|
package/dist/http/defer.d.ts
CHANGED
|
@@ -9,8 +9,8 @@ import type { ExecutionContextLike } from './execution-context.js';
|
|
|
9
9
|
*/
|
|
10
10
|
export type DeferExecutor = (promise: Promise<unknown>) => void;
|
|
11
11
|
/**
|
|
12
|
-
* Default defer implementation (NestJS `void promise` equivalent).
|
|
13
|
-
* Used when no `ExecutionContext` is available (tests, partial scheduled paths).
|
|
12
|
+
* Default defer implementation (NestJS `void promise` equivalent). Reports rejections without
|
|
13
|
+
* propagating them. Used when no `ExecutionContext` is available (tests, partial scheduled paths).
|
|
14
14
|
*/
|
|
15
15
|
export declare const defaultDefer: DeferExecutor;
|
|
16
16
|
/**
|
package/dist/http/defer.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
|
+
const reportDeferFailure = (error) => {
|
|
2
|
+
console.error('[defer] background task failed', error);
|
|
3
|
+
};
|
|
1
4
|
/**
|
|
2
|
-
* Default defer implementation (NestJS `void promise` equivalent).
|
|
3
|
-
* Used when no `ExecutionContext` is available (tests, partial scheduled paths).
|
|
5
|
+
* Default defer implementation (NestJS `void promise` equivalent). Reports rejections without
|
|
6
|
+
* propagating them. Used when no `ExecutionContext` is available (tests, partial scheduled paths).
|
|
4
7
|
*/
|
|
5
8
|
export const defaultDefer = (promise) => {
|
|
6
|
-
void promise.catch(
|
|
9
|
+
void promise.catch(reportDeferFailure);
|
|
7
10
|
};
|
|
8
11
|
/**
|
|
9
12
|
* Build a {@link DeferExecutor} that keeps the worker alive until `promise` settles.
|
|
@@ -12,6 +15,6 @@ export const defaultDefer = (promise) => {
|
|
|
12
15
|
*/
|
|
13
16
|
export function createWaitUntilDefer(ctx) {
|
|
14
17
|
return (promise) => {
|
|
15
|
-
ctx.waitUntil(promise.catch(
|
|
18
|
+
ctx.waitUntil(promise.catch(reportDeferFailure));
|
|
16
19
|
};
|
|
17
20
|
}
|
|
@@ -71,7 +71,7 @@ export interface HttpErrorHandlerOptions<E extends Env = Env> {
|
|
|
71
71
|
isHttpError?: (err: unknown) => err is HttpErrorLike;
|
|
72
72
|
/**
|
|
73
73
|
* Hook invoked before an unexpected (non-HTTP) error is returned as a 500, typically used to report the
|
|
74
|
-
* error (e.g. to Sentry). Any exception thrown by this hook is
|
|
74
|
+
* error (e.g. to Sentry). Any exception thrown by this hook is logged and kept from altering the
|
|
75
75
|
* error response.
|
|
76
76
|
*/
|
|
77
77
|
onUnhandledError?: (err: unknown, c: Context<E>) => void;
|
package/dist/http/http-error.js
CHANGED
|
@@ -78,8 +78,10 @@ export function createHttpErrorHandler(options = {}) {
|
|
|
78
78
|
try {
|
|
79
79
|
onUnhandledError?.(err, c);
|
|
80
80
|
}
|
|
81
|
-
catch {
|
|
82
|
-
// Reporting must never change the behavior of the error response
|
|
81
|
+
catch (reportingError) {
|
|
82
|
+
// Reporting must never change the behavior of the error response, but its own failure must
|
|
83
|
+
// stay visible so a broken observability integration does not go unnoticed.
|
|
84
|
+
console.error('[httpError] onUnhandledError failed', reportingError);
|
|
83
85
|
}
|
|
84
86
|
if (findMysqlDriverError(err)) {
|
|
85
87
|
logMysqlDriverError(err, 500);
|
|
@@ -40,8 +40,9 @@ export function createQueryFailedErrorHandler(options) {
|
|
|
40
40
|
try {
|
|
41
41
|
onUnhandledError?.(err, c);
|
|
42
42
|
}
|
|
43
|
-
catch {
|
|
44
|
-
// Reporting must never change the error response.
|
|
43
|
+
catch (reportingError) {
|
|
44
|
+
// Reporting must never change the error response, but a broken reporter must remain visible.
|
|
45
|
+
console.error('[queryFailedError] onUnhandledError failed', reportingError);
|
|
45
46
|
}
|
|
46
47
|
}
|
|
47
48
|
return c.json({ statusCode: classified.statusCode, message: classified.message }, classified.statusCode);
|
|
@@ -45,6 +45,49 @@ export type IdempotencyReservation<TResponse> = {
|
|
|
45
45
|
kind: 'replay';
|
|
46
46
|
response: TResponse;
|
|
47
47
|
};
|
|
48
|
+
/** Locked persistence state for one leased idempotency key. */
|
|
49
|
+
export type LeasedIdempotencyRecord<TResponse> = {
|
|
50
|
+
/** The operation is currently owned by one processing token. */
|
|
51
|
+
state: 'processing';
|
|
52
|
+
/** Canonical payload hash stored with the key. */
|
|
53
|
+
payloadHash: string;
|
|
54
|
+
/** Token of the current processing owner. */
|
|
55
|
+
processingToken: string;
|
|
56
|
+
/** Whether the current owner's lease may be reclaimed. */
|
|
57
|
+
leaseExpired: boolean;
|
|
58
|
+
} | {
|
|
59
|
+
/** The operation completed and its response is replayable. */
|
|
60
|
+
state: 'completed';
|
|
61
|
+
/** Canonical payload hash stored with the key. */
|
|
62
|
+
payloadHash: string;
|
|
63
|
+
/** Persisted response returned to retries. */
|
|
64
|
+
response: TResponse;
|
|
65
|
+
};
|
|
66
|
+
/** Persistence primitives used by the shared leased-idempotency state machine. */
|
|
67
|
+
export interface LeasedIdempotencyStore<TScope extends IdempotencyScope, TResponse> {
|
|
68
|
+
/** Insert a processing row if the scoped key does not already exist. */
|
|
69
|
+
insertProcessing(input: IdempotencyInput<TScope>, processingToken: string): Promise<void>;
|
|
70
|
+
/** Lock and return the scoped key after the insert attempt. */
|
|
71
|
+
lock(input: IdempotencyInput<TScope>): Promise<LeasedIdempotencyRecord<TResponse>>;
|
|
72
|
+
/** Atomically replace an expired processing token and restart its lease. */
|
|
73
|
+
reclaim(input: IdempotencyInput<TScope>, previousToken: string, processingToken: string): Promise<boolean>;
|
|
74
|
+
/** Persist the response only while `processingToken` still owns the key. */
|
|
75
|
+
complete(input: IdempotencyInput<TScope>, processingToken: string, response: TResponse): Promise<boolean>;
|
|
76
|
+
/** Remove an uncommitted reservation only while `processingToken` still owns it. */
|
|
77
|
+
release(input: IdempotencyInput<TScope>, processingToken: string): Promise<void>;
|
|
78
|
+
}
|
|
79
|
+
/** Result of reserving a leased idempotency key. */
|
|
80
|
+
export type LeasedIdempotencyReservation<TResponse> = {
|
|
81
|
+
kind: 'acquired';
|
|
82
|
+
processingToken: string;
|
|
83
|
+
} | {
|
|
84
|
+
kind: 'replay';
|
|
85
|
+
response: TResponse;
|
|
86
|
+
};
|
|
87
|
+
/** Reserve, replay, or safely reclaim one leased idempotency key. */
|
|
88
|
+
export declare function reserveLeasedIdempotency<TScope extends IdempotencyScope, TResponse>(store: LeasedIdempotencyStore<TScope, TResponse>, input: IdempotencyInput<TScope>): Promise<LeasedIdempotencyReservation<TResponse>>;
|
|
89
|
+
/** Complete a leased idempotent operation without accepting a stale owner. */
|
|
90
|
+
export declare function completeLeasedIdempotency<TScope extends IdempotencyScope, TResponse>(store: LeasedIdempotencyStore<TScope, TResponse>, input: IdempotencyInput<TScope>, processingToken: string, response: TResponse): Promise<void>;
|
|
48
91
|
/** Transaction-bound persistence operations required by {@link runIdempotentMutation}. */
|
|
49
92
|
export interface IdempotentMutationStore<TScope extends IdempotencyScope, TResponse> {
|
|
50
93
|
/** Atomically reserve a key or return its previously completed response. */
|
|
@@ -27,6 +27,34 @@ export class IdempotencyInFlightError extends Error {
|
|
|
27
27
|
this.name = 'IdempotencyInFlightError';
|
|
28
28
|
}
|
|
29
29
|
}
|
|
30
|
+
/** Reserve, replay, or safely reclaim one leased idempotency key. */
|
|
31
|
+
export async function reserveLeasedIdempotency(store, input) {
|
|
32
|
+
const processingToken = crypto.randomUUID();
|
|
33
|
+
await store.insertProcessing(input, processingToken);
|
|
34
|
+
const record = await store.lock(input);
|
|
35
|
+
if (record.payloadHash !== input.payloadHash) {
|
|
36
|
+
throw new IdempotencyConflictError();
|
|
37
|
+
}
|
|
38
|
+
if (record.state === 'completed') {
|
|
39
|
+
return { kind: 'replay', response: record.response };
|
|
40
|
+
}
|
|
41
|
+
if (record.processingToken === processingToken) {
|
|
42
|
+
return { kind: 'acquired', processingToken };
|
|
43
|
+
}
|
|
44
|
+
if (!record.leaseExpired) {
|
|
45
|
+
throw new IdempotencyInFlightError();
|
|
46
|
+
}
|
|
47
|
+
if (!(await store.reclaim(input, record.processingToken, processingToken))) {
|
|
48
|
+
throw new IdempotencyInFlightError('Idempotent request ownership changed during lease reclaim');
|
|
49
|
+
}
|
|
50
|
+
return { kind: 'acquired', processingToken };
|
|
51
|
+
}
|
|
52
|
+
/** Complete a leased idempotent operation without accepting a stale owner. */
|
|
53
|
+
export async function completeLeasedIdempotency(store, input, processingToken, response) {
|
|
54
|
+
if (!(await store.complete(input, processingToken, response))) {
|
|
55
|
+
throw new IdempotencyInFlightError('Idempotent request ownership was lost before completion');
|
|
56
|
+
}
|
|
57
|
+
}
|
|
30
58
|
/** Deterministically serialize JSON data with locale-independent, UTF-16 code-unit key ordering. */
|
|
31
59
|
export function canonicalJson(value) {
|
|
32
60
|
try {
|
package/dist/index.d.ts
CHANGED
|
@@ -14,8 +14,8 @@ export { validate, createValidate } from './middleware/validation.js';
|
|
|
14
14
|
export { createSentryValidate } from './middleware/validation.js';
|
|
15
15
|
export type { ValidateOptions, ValidationTarget, ZodErrorLike, SentryLike, SentryScopeLike, } from './middleware/validation.js';
|
|
16
16
|
export { zNum, zNumNullable, zNumOptional, zNumWithDefault } from './middleware/zod-coerce.js';
|
|
17
|
-
export { createAuthMiddleware } from './middleware/auth.js';
|
|
18
|
-
export type { AuthMiddlewareOptions } from './middleware/auth.js';
|
|
17
|
+
export { AuthTokenMissingError, createAuthMiddleware } from './middleware/auth.js';
|
|
18
|
+
export type { AuthMiddlewareFailureDetails, AuthMiddlewareFailureStage, AuthMiddlewareOptions, } from './middleware/auth.js';
|
|
19
19
|
export { perfLog } from './middleware/perf-log.js';
|
|
20
20
|
export type { PerfLogOptions, AnalyticsEngineDatasetLike } from './middleware/perf-log.js';
|
|
21
21
|
export { createMaintenanceMiddleware, createMaintenanceWaitHandler, isMaintenanceEnabled, MAINTENANCE_BODY, MAINTENANCE_CODE, MAINTENANCE_WAIT_PATH, } from './middleware/maintenance.js';
|
|
@@ -49,8 +49,8 @@ export { createSentryErrorReporter } from './http/http-error.js';
|
|
|
49
49
|
export type { SentryExceptionReporterLike } from './http/http-error.js';
|
|
50
50
|
export { AUTH_FAILURE_SCOPES, AUTH_IDENTITY_INVALID_CODE, createAuthFailureBody, createIdentityAuthFailureBody, createLegacyIdentityAuthFailureBody, } from './http/auth-failure.js';
|
|
51
51
|
export type { AuthFailureBody, AuthFailureScope } from './http/auth-failure.js';
|
|
52
|
-
export { canonicalJson, createIdempotencyInput, IdempotencyConflictError, IdempotencyInFlightError, IdempotencyKeyValidationError, IdempotencyPayloadValidationError, runIdempotentMutation, sha256CanonicalJson, withIdempotencyHttpErrors, } from './idempotency/idempotency.js';
|
|
53
|
-
export type { CreateIdempotencyInputOptions, IdempotencyInput, IdempotencyReservation, IdempotencyScope, IdempotencyScopeValue, IdempotentMutationStore, } from './idempotency/idempotency.js';
|
|
52
|
+
export { canonicalJson, completeLeasedIdempotency, createIdempotencyInput, IdempotencyConflictError, IdempotencyInFlightError, IdempotencyKeyValidationError, IdempotencyPayloadValidationError, runIdempotentMutation, reserveLeasedIdempotency, sha256CanonicalJson, withIdempotencyHttpErrors, } from './idempotency/idempotency.js';
|
|
53
|
+
export type { CreateIdempotencyInputOptions, IdempotencyInput, LeasedIdempotencyRecord, LeasedIdempotencyReservation, LeasedIdempotencyStore, IdempotencyReservation, IdempotencyScope, IdempotencyScopeValue, IdempotentMutationStore, } from './idempotency/idempotency.js';
|
|
54
54
|
export { acknowledgeHibernationWebSocketClose, broadcastHibernationWebSockets, closeHibernationWebSocket, configureHibernationAutoResponse, upgradeHibernationWebSocket, } from './realtime/hibernation.js';
|
|
55
55
|
export type { HibernationAutoResponseOptions, HibernationUpgradeOptions, HibernationWebSocketLike, HibernationWebSocketStateLike, WebSocketAutoResponsePairFactory, WebSocketPairFactory, } from './realtime/hibernation.js';
|
|
56
56
|
export { isRetryableDurableObjectError, retryDurableObjectOperation } from './realtime/retry.js';
|
|
@@ -60,7 +60,7 @@ export type { DurableObjectFetchRequest, DurableObjectFetchStubLike, InvokeDurab
|
|
|
60
60
|
export { parseRealtimeWebSocketProtocolOffer, parseWebSocketProtocols } from './realtime/protocol.js';
|
|
61
61
|
export type { ParseRealtimeWebSocketProtocolOptions, RealtimeWebSocketProtocolOffer } from './realtime/protocol.js';
|
|
62
62
|
export { KVCache } from './cache/kv-cache.js';
|
|
63
|
-
export type { KVNamespace, KVCacheOptions } from './cache/kv-cache.js';
|
|
63
|
+
export type { KVNamespace, KVCacheErrorContext, KVCacheOperation, KVCacheOptions } from './cache/kv-cache.js';
|
|
64
64
|
export { createStripeClient, verifyStripeWebhook } from './stripe/client.js';
|
|
65
65
|
export type { CreateStripeClientOptions } from './stripe/client.js';
|
|
66
66
|
export { extractStripeFailureReason, stripeFailureMessageJa, serializePaymentFailure, serializeIapFailureReason, parsePaymentFailure, PaymentDeclinedError, toPaymentDeclinedError, } from './stripe/failure.js';
|
|
@@ -90,7 +90,7 @@ export { getCloudFrontSignedUrl } from './aws/cloudfront.js';
|
|
|
90
90
|
export { getTemporaryCredentials } from './aws/sts.js';
|
|
91
91
|
export type { GetTemporaryCredentialsOptions, StsCredentials } from './aws/sts.js';
|
|
92
92
|
export type { DecodedIdToken, FirebaseVerifier } from './firebase/firebase-verifier.js';
|
|
93
|
-
export { JoseFirebaseVerifier, SECURETOKEN_JWK_URL } from './firebase/jose-firebase-verifier.js';
|
|
93
|
+
export { FirebaseIdTokenValidationError, JoseFirebaseVerifier, SECURETOKEN_JWK_URL, } from './firebase/jose-firebase-verifier.js';
|
|
94
94
|
export { IdentityToolkit } from './firebase/identity-toolkit.js';
|
|
95
95
|
export type { ServiceAccount } from './firebase/identity-toolkit.js';
|
|
96
96
|
export { createRemoteFirebaseVerifier, createServiceAccountVerifier } from './firebase/remote-verifier.js';
|
package/dist/index.js
CHANGED
|
@@ -16,7 +16,7 @@ export { validate, createValidate } from './middleware/validation.js';
|
|
|
16
16
|
// eslint-disable-next-line @typescript-eslint/no-deprecated -- intentional public re-export
|
|
17
17
|
export { createSentryValidate } from './middleware/validation.js';
|
|
18
18
|
export { zNum, zNumNullable, zNumOptional, zNumWithDefault } from './middleware/zod-coerce.js';
|
|
19
|
-
export { createAuthMiddleware } from './middleware/auth.js';
|
|
19
|
+
export { AuthTokenMissingError, createAuthMiddleware } from './middleware/auth.js';
|
|
20
20
|
export { perfLog } from './middleware/perf-log.js';
|
|
21
21
|
export { createMaintenanceMiddleware, createMaintenanceWaitHandler, isMaintenanceEnabled, MAINTENANCE_BODY, MAINTENANCE_CODE, MAINTENANCE_WAIT_PATH, } from './middleware/maintenance.js';
|
|
22
22
|
export { createIsolateMemo } from './container/isolate-memo.js';
|
|
@@ -37,7 +37,7 @@ export { defaultDefer, createWaitUntilDefer } from './http/defer.js';
|
|
|
37
37
|
export { createSentryErrorReporter } from './http/http-error.js';
|
|
38
38
|
export { AUTH_FAILURE_SCOPES, AUTH_IDENTITY_INVALID_CODE, createAuthFailureBody, createIdentityAuthFailureBody, createLegacyIdentityAuthFailureBody, } from './http/auth-failure.js';
|
|
39
39
|
// idempotency
|
|
40
|
-
export { canonicalJson, createIdempotencyInput, IdempotencyConflictError, IdempotencyInFlightError, IdempotencyKeyValidationError, IdempotencyPayloadValidationError, runIdempotentMutation, sha256CanonicalJson, withIdempotencyHttpErrors, } from './idempotency/idempotency.js';
|
|
40
|
+
export { canonicalJson, completeLeasedIdempotency, createIdempotencyInput, IdempotencyConflictError, IdempotencyInFlightError, IdempotencyKeyValidationError, IdempotencyPayloadValidationError, runIdempotentMutation, reserveLeasedIdempotency, sha256CanonicalJson, withIdempotencyHttpErrors, } from './idempotency/idempotency.js';
|
|
41
41
|
// realtime
|
|
42
42
|
export { acknowledgeHibernationWebSocketClose, broadcastHibernationWebSockets, closeHibernationWebSocket, configureHibernationAutoResponse, upgradeHibernationWebSocket, } from './realtime/hibernation.js';
|
|
43
43
|
export { isRetryableDurableObjectError, retryDurableObjectOperation } from './realtime/retry.js';
|
|
@@ -67,6 +67,6 @@ export { createAiGatewayProvider } from './ai/gateway.js';
|
|
|
67
67
|
export { getAuthenticationSecret } from './aws/secrets-manager.js';
|
|
68
68
|
export { getCloudFrontSignedUrl } from './aws/cloudfront.js';
|
|
69
69
|
export { getTemporaryCredentials } from './aws/sts.js';
|
|
70
|
-
export { JoseFirebaseVerifier, SECURETOKEN_JWK_URL } from './firebase/jose-firebase-verifier.js';
|
|
70
|
+
export { FirebaseIdTokenValidationError, JoseFirebaseVerifier, SECURETOKEN_JWK_URL, } from './firebase/jose-firebase-verifier.js';
|
|
71
71
|
export { IdentityToolkit } from './firebase/identity-toolkit.js';
|
|
72
72
|
export { createRemoteFirebaseVerifier, createServiceAccountVerifier } from './firebase/remote-verifier.js';
|
|
@@ -1,6 +1,21 @@
|
|
|
1
1
|
import type { Context, Env, MiddlewareHandler } from 'hono';
|
|
2
2
|
import type { ContentfulStatusCode } from 'hono/utils/http-status';
|
|
3
3
|
import type { AppInfo } from '../http/app-info.js';
|
|
4
|
+
/** Processing stage at which authentication middleware failed. */
|
|
5
|
+
export type AuthMiddlewareFailureStage = 'token' | 'verify' | 'appInfo' | 'resolveUserId' | 'setContext';
|
|
6
|
+
/** Safe metadata describing an authentication middleware failure without including the token. */
|
|
7
|
+
export interface AuthMiddlewareFailureDetails {
|
|
8
|
+
/** Stage which rejected or failed. */
|
|
9
|
+
stage: AuthMiddlewareFailureStage;
|
|
10
|
+
/** Whether the configured token header contained a non-blank value. */
|
|
11
|
+
tokenPresent: boolean;
|
|
12
|
+
}
|
|
13
|
+
/** Expected rejection raised when the configured authentication header is absent or blank. */
|
|
14
|
+
export declare class AuthTokenMissingError extends Error {
|
|
15
|
+
/** Stable machine-readable code for application classifiers. */
|
|
16
|
+
readonly code = "AUTH_TOKEN_MISSING";
|
|
17
|
+
constructor();
|
|
18
|
+
}
|
|
4
19
|
/**
|
|
5
20
|
* Configuration for {@link createAuthMiddleware}.
|
|
6
21
|
*
|
|
@@ -11,10 +26,19 @@ import type { AppInfo } from '../http/app-info.js';
|
|
|
11
26
|
export interface AuthMiddlewareOptions<E extends Env, Verified, Id = unknown> {
|
|
12
27
|
/** Header carrying the ID token. Defaults to `'x-amz-security-token'`. */
|
|
13
28
|
tokenHeader?: string;
|
|
29
|
+
/**
|
|
30
|
+
* Reject an absent or blank token before calling {@link AuthMiddlewareOptions.verify}.
|
|
31
|
+
*
|
|
32
|
+
* @remarks
|
|
33
|
+
* Defaults to `false` for backward compatibility: existing consumers historically receive an
|
|
34
|
+
* empty string in `verify` when the header is absent. Enable this when the application wants a
|
|
35
|
+
* typed {@link AuthTokenMissingError} and does not use an empty token as custom input.
|
|
36
|
+
*/
|
|
37
|
+
rejectMissingToken?: boolean;
|
|
14
38
|
/**
|
|
15
39
|
* Verify the raw token and return the decoded value or user record.
|
|
16
40
|
*
|
|
17
|
-
* @param token - The raw token
|
|
41
|
+
* @param token - The raw token, or an empty string when absent unless `rejectMissingToken` is enabled.
|
|
18
42
|
* @param c - The current Hono context.
|
|
19
43
|
* @returns The verified value passed to {@link AuthMiddlewareOptions.resolveUserId}/{@link AuthMiddlewareOptions.setContext}.
|
|
20
44
|
* @throws If the token is invalid; rejecting/throwing triggers the failure path.
|
|
@@ -59,7 +83,17 @@ export interface AuthMiddlewareOptions<E extends Env, Verified, Id = unknown> {
|
|
|
59
83
|
* @param c - The current Hono context.
|
|
60
84
|
* @returns The failure response to send.
|
|
61
85
|
*/
|
|
62
|
-
onFailure?: (err: unknown, c: Context<E
|
|
86
|
+
onFailure?: (err: unknown, c: Context<E>, details: AuthMiddlewareFailureDetails) => Response | Promise<Response>;
|
|
87
|
+
/**
|
|
88
|
+
* Report a failed authentication attempt.
|
|
89
|
+
*
|
|
90
|
+
* @remarks
|
|
91
|
+
* When omitted, the historical behavior (`console.error(err)`) is preserved. Applications should
|
|
92
|
+
* provide this hook to suppress expected credential rejections while reporting dependency and
|
|
93
|
+
* internal failures through their normal observability path. The hook must never include raw
|
|
94
|
+
* authentication tokens in logs or telemetry.
|
|
95
|
+
*/
|
|
96
|
+
reportFailure?: (err: unknown, c: Context<E>, details: AuthMiddlewareFailureDetails) => void | Promise<void>;
|
|
63
97
|
/** Status used by the default `onFailure`. Defaults to `403`. */
|
|
64
98
|
failureStatus?: ContentfulStatusCode;
|
|
65
99
|
/** Message used by the default `onFailure`. Defaults to `'Forbidden resource'`. */
|
|
@@ -69,7 +103,7 @@ export interface AuthMiddlewareOptions<E extends Env, Verified, Id = unknown> {
|
|
|
69
103
|
* Create an authentication middleware equivalent to a NestJS `AuthGuard` / `TokenGuard`.
|
|
70
104
|
*
|
|
71
105
|
* The middleware runs a fixed skeleton — read the token header, `verify`, `getAppInfo`,
|
|
72
|
-
* `resolveUserId`, `setContext`, and on error `
|
|
106
|
+
* `resolveUserId`, `setContext`, and on error `reportFailure` then `onFailure` — while the
|
|
73
107
|
* application injects the variable parts (token verification, user-id resolution, context variable
|
|
74
108
|
* names, and the failure response). Omitting {@link AuthMiddlewareOptions.resolveUserId} yields a
|
|
75
109
|
* token-only middleware.
|
package/dist/middleware/auth.js
CHANGED
|
@@ -1,10 +1,19 @@
|
|
|
1
1
|
import { HTTPException } from 'hono/http-exception';
|
|
2
2
|
import { getAppInfo } from '../http/app-info.js';
|
|
3
|
+
/** Expected rejection raised when the configured authentication header is absent or blank. */
|
|
4
|
+
export class AuthTokenMissingError extends Error {
|
|
5
|
+
/** Stable machine-readable code for application classifiers. */
|
|
6
|
+
code = 'AUTH_TOKEN_MISSING';
|
|
7
|
+
constructor() {
|
|
8
|
+
super('Authentication token is missing');
|
|
9
|
+
this.name = 'AuthTokenMissingError';
|
|
10
|
+
}
|
|
11
|
+
}
|
|
3
12
|
/**
|
|
4
13
|
* Create an authentication middleware equivalent to a NestJS `AuthGuard` / `TokenGuard`.
|
|
5
14
|
*
|
|
6
15
|
* The middleware runs a fixed skeleton — read the token header, `verify`, `getAppInfo`,
|
|
7
|
-
* `resolveUserId`, `setContext`, and on error `
|
|
16
|
+
* `resolveUserId`, `setContext`, and on error `reportFailure` then `onFailure` — while the
|
|
8
17
|
* application injects the variable parts (token verification, user-id resolution, context variable
|
|
9
18
|
* names, and the failure response). Omitting {@link AuthMiddlewareOptions.resolveUserId} yields a
|
|
10
19
|
* token-only middleware.
|
|
@@ -30,22 +39,42 @@ import { getAppInfo } from '../http/app-info.js';
|
|
|
30
39
|
* ```
|
|
31
40
|
*/
|
|
32
41
|
export function createAuthMiddleware(options) {
|
|
33
|
-
const { tokenHeader = 'x-amz-security-token', verify, resolveUserId, setContext, onFailure, failureStatus = 403, failureMessage = 'Forbidden resource', } = options;
|
|
42
|
+
const { tokenHeader = 'x-amz-security-token', rejectMissingToken = false, verify, resolveUserId, setContext, onFailure, reportFailure, failureStatus = 403, failureMessage = 'Forbidden resource', } = options;
|
|
34
43
|
return async (c, next) => {
|
|
44
|
+
let stage = 'token';
|
|
45
|
+
let tokenPresent = false;
|
|
35
46
|
try {
|
|
36
47
|
const token = c.req.header(tokenHeader) ?? '';
|
|
48
|
+
tokenPresent = token.trim().length > 0;
|
|
49
|
+
if (!tokenPresent && rejectMissingToken) {
|
|
50
|
+
throw new AuthTokenMissingError();
|
|
51
|
+
}
|
|
52
|
+
stage = 'verify';
|
|
37
53
|
const verified = await verify(token, c);
|
|
54
|
+
stage = 'appInfo';
|
|
38
55
|
const appInfo = getAppInfo(c);
|
|
56
|
+
stage = 'resolveUserId';
|
|
39
57
|
const userId = resolveUserId ? await resolveUserId(verified, c, appInfo) : undefined;
|
|
58
|
+
stage = 'setContext';
|
|
40
59
|
setContext(c, { verified, appInfo, userId });
|
|
41
60
|
}
|
|
42
61
|
catch (e) {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
62
|
+
const details = { stage, tokenPresent };
|
|
63
|
+
if (reportFailure) {
|
|
64
|
+
try {
|
|
65
|
+
await reportFailure(e, c, details);
|
|
66
|
+
}
|
|
67
|
+
catch (reportingError) {
|
|
68
|
+
// Observability must never alter the authentication response.
|
|
69
|
+
console.error(reportingError);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
// Preserve the historical default for consumers that have not adopted classified reporting.
|
|
74
|
+
console.error(e);
|
|
75
|
+
}
|
|
47
76
|
if (onFailure) {
|
|
48
|
-
return onFailure(e, c);
|
|
77
|
+
return onFailure(e, c, details);
|
|
49
78
|
}
|
|
50
79
|
throw new HTTPException(failureStatus, { message: failureMessage });
|
|
51
80
|
}
|
|
@@ -23,8 +23,8 @@ export interface ValidateOptions {
|
|
|
23
23
|
*
|
|
24
24
|
* @remarks
|
|
25
25
|
* This never changes validation behavior — the response is always a NestJS `ValidationPipe`-shaped
|
|
26
|
-
* 400. Exceptions thrown by the hook are
|
|
27
|
-
* reported); pass a hook to forward failures to an error tracker.
|
|
26
|
+
* 400. Exceptions thrown by the hook are logged without changing the response. The default is a
|
|
27
|
+
* no-op (4xx errors are not reported); pass a hook to forward failures to an error tracker.
|
|
28
28
|
*
|
|
29
29
|
* @param error - The zod error describing the failed validation.
|
|
30
30
|
* @param c - The Hono context for the failing request.
|
|
@@ -55,8 +55,9 @@ export function validate(target, schema, options) {
|
|
|
55
55
|
try {
|
|
56
56
|
options?.onValidationError?.(result.error, c);
|
|
57
57
|
}
|
|
58
|
-
catch {
|
|
59
|
-
// Reporting must never change validation
|
|
58
|
+
catch (reportingError) {
|
|
59
|
+
// Reporting must never change validation behavior, but a broken reporter must remain visible.
|
|
60
|
+
console.error('[validation] onValidationError failed', reportingError);
|
|
60
61
|
}
|
|
61
62
|
return c.json({ statusCode: 400, message: messages, error: 'Bad Request' }, 400);
|
|
62
63
|
}
|
package/dist/offline/index.d.ts
CHANGED
|
@@ -12,3 +12,5 @@ export { defineRestDbMethodConverter } from './rest-db-method-converter.js';
|
|
|
12
12
|
export type { CompleteRestDbTableScheme, RestDbMethodConverter } from './rest-db-method-converter.js';
|
|
13
13
|
export { decodeOfflineSnapshotCursor, encodeOfflineSnapshotCursor } from './snapshot-cursor.js';
|
|
14
14
|
export type { OfflineSnapshotCursor } from './snapshot-cursor.js';
|
|
15
|
+
export { assertOfflineJournalCursorRetained, compactOfflineJournal, OfflineJournalRebaselineRequiredError, } from './journal-retention.js';
|
|
16
|
+
export type { CompactOfflineJournalOptions, OfflineJournalRetentionCandidate, OfflineJournalRetentionStore, OfflineJournalRetentionTransaction, } from './journal-retention.js';
|
package/dist/offline/index.js
CHANGED
|
@@ -10,3 +10,4 @@ export { fromTinyIntFlag, replicaTimestampMs, toReplicaDateOnly, toReplicaIsoDat
|
|
|
10
10
|
export { replicaNowIso } from './clock.js';
|
|
11
11
|
export { defineRestDbMethodConverter } from './rest-db-method-converter.js';
|
|
12
12
|
export { decodeOfflineSnapshotCursor, encodeOfflineSnapshotCursor } from './snapshot-cursor.js';
|
|
13
|
+
export { assertOfflineJournalCursorRetained, compactOfflineJournal, OfflineJournalRebaselineRequiredError, } from './journal-retention.js';
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/** One retained product journal row considered for bounded cleanup. */
|
|
2
|
+
export interface OfflineJournalRetentionCandidate<TScope> {
|
|
3
|
+
/** Monotonic journal position that becomes a client delta cursor. */
|
|
4
|
+
readonly cursor: number;
|
|
5
|
+
/** Product-defined authorization and retention partition. */
|
|
6
|
+
readonly scope: TScope;
|
|
7
|
+
}
|
|
8
|
+
/** Storage operations that must share one database transaction. */
|
|
9
|
+
export interface OfflineJournalRetentionTransaction<TScope> {
|
|
10
|
+
/** Selects the oldest bounded cleanup candidates. */
|
|
11
|
+
listCandidates(cutoff: Date, limit: number): Promise<readonly OfflineJournalRetentionCandidate<TScope>[]>;
|
|
12
|
+
/** Locks existing scopes against concurrent pull floor validation. */
|
|
13
|
+
lockScopes(scopes: readonly TScope[]): Promise<readonly TScope[]>;
|
|
14
|
+
/** Monotonically advances each scope's retained-history floor. */
|
|
15
|
+
advanceFloors(floors: readonly {
|
|
16
|
+
scope: TScope;
|
|
17
|
+
cursor: number;
|
|
18
|
+
}[]): Promise<void>;
|
|
19
|
+
/** Deletes the complete candidate set, including rows for scopes that no longer exist. */
|
|
20
|
+
deleteCandidates(cursors: readonly number[]): Promise<void>;
|
|
21
|
+
}
|
|
22
|
+
/** Product adapter that owns schema-specific persistence and transaction creation. */
|
|
23
|
+
export interface OfflineJournalRetentionStore<TScope> {
|
|
24
|
+
/** Runs the complete retention state machine in one database transaction. */
|
|
25
|
+
transaction<T>(operation: (tx: OfflineJournalRetentionTransaction<TScope>) => Promise<T>): Promise<T>;
|
|
26
|
+
}
|
|
27
|
+
/** Inputs for one bounded offline journal compaction pass. */
|
|
28
|
+
export interface CompactOfflineJournalOptions<TScope> {
|
|
29
|
+
/** Product adapter that owns persistence and transaction creation. */
|
|
30
|
+
readonly store: OfflineJournalRetentionStore<TScope>;
|
|
31
|
+
/** Exclusive upper time boundary of journal rows eligible for cleanup. */
|
|
32
|
+
readonly cutoff: Date;
|
|
33
|
+
/** Maximum number of journal rows compacted in one pass. */
|
|
34
|
+
readonly limit: number;
|
|
35
|
+
/** Canonical identity used to deduplicate and compare product scopes. */
|
|
36
|
+
readonly scopeKey: (scope: TScope) => string;
|
|
37
|
+
}
|
|
38
|
+
/** Raised when a delta cursor predates the retained journal and must restart from a snapshot. */
|
|
39
|
+
export declare class OfflineJournalRebaselineRequiredError extends Error {
|
|
40
|
+
}
|
|
41
|
+
/** Fail closed before reading deltas whose tombstones may already have been compacted. */
|
|
42
|
+
export declare function assertOfflineJournalCursorRetained(cursor: number, floor: number): void;
|
|
43
|
+
/**
|
|
44
|
+
* Advances retention floors and deletes one bounded journal batch atomically.
|
|
45
|
+
*
|
|
46
|
+
* Product adapters provide schema-specific queries. This state machine owns the
|
|
47
|
+
* safety order: candidates -> ordered opposing locks -> floors -> deletion.
|
|
48
|
+
*/
|
|
49
|
+
export declare function compactOfflineJournal<TScope>(options: CompactOfflineJournalOptions<TScope>): Promise<number>;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/** Raised when a delta cursor predates the retained journal and must restart from a snapshot. */
|
|
2
|
+
export class OfflineJournalRebaselineRequiredError extends Error {
|
|
3
|
+
}
|
|
4
|
+
/** Fail closed before reading deltas whose tombstones may already have been compacted. */
|
|
5
|
+
export function assertOfflineJournalCursorRetained(cursor, floor) {
|
|
6
|
+
if (!Number.isSafeInteger(cursor) || cursor < 0 || !Number.isSafeInteger(floor) || floor < 0) {
|
|
7
|
+
throw new RangeError('Offline journal cursor and retention floor must be non-negative safe integers.');
|
|
8
|
+
}
|
|
9
|
+
if (cursor < floor) {
|
|
10
|
+
throw new OfflineJournalRebaselineRequiredError('Offline journal cursor predates retained history.');
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Advances retention floors and deletes one bounded journal batch atomically.
|
|
15
|
+
*
|
|
16
|
+
* Product adapters provide schema-specific queries. This state machine owns the
|
|
17
|
+
* safety order: candidates -> ordered opposing locks -> floors -> deletion.
|
|
18
|
+
*/
|
|
19
|
+
export function compactOfflineJournal(options) {
|
|
20
|
+
if (!Number.isSafeInteger(options.limit) || options.limit <= 0) {
|
|
21
|
+
throw new RangeError('Offline journal retention limit must be a positive safe integer.');
|
|
22
|
+
}
|
|
23
|
+
if (Number.isNaN(options.cutoff.getTime())) {
|
|
24
|
+
throw new RangeError('Offline journal retention cutoff must be a valid date.');
|
|
25
|
+
}
|
|
26
|
+
return options.store.transaction(async (tx) => {
|
|
27
|
+
const candidates = await tx.listCandidates(options.cutoff, options.limit);
|
|
28
|
+
if (candidates.length === 0) {
|
|
29
|
+
return 0;
|
|
30
|
+
}
|
|
31
|
+
assertCandidates(candidates);
|
|
32
|
+
const scopeByKey = new Map(candidates.map((candidate) => [options.scopeKey(candidate.scope), candidate.scope]));
|
|
33
|
+
const scopes = [...scopeByKey.entries()]
|
|
34
|
+
.sort(([left], [right]) => compareCanonicalKeys(left, right))
|
|
35
|
+
.map(([, scope]) => scope);
|
|
36
|
+
const lockedKeys = new Set((await tx.lockScopes(scopes)).map(options.scopeKey));
|
|
37
|
+
const floorByKey = new Map();
|
|
38
|
+
for (const candidate of candidates) {
|
|
39
|
+
const key = options.scopeKey(candidate.scope);
|
|
40
|
+
if (!lockedKeys.has(key)) {
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
const current = floorByKey.get(key);
|
|
44
|
+
if (!current || candidate.cursor > current.cursor) {
|
|
45
|
+
floorByKey.set(key, { scope: candidate.scope, cursor: candidate.cursor });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (floorByKey.size > 0) {
|
|
49
|
+
await tx.advanceFloors([...floorByKey.entries()]
|
|
50
|
+
.sort(([left], [right]) => compareCanonicalKeys(left, right))
|
|
51
|
+
.map(([, floor]) => floor));
|
|
52
|
+
}
|
|
53
|
+
await tx.deleteCandidates(candidates.map((candidate) => candidate.cursor));
|
|
54
|
+
return candidates.length;
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
function compareCanonicalKeys(left, right) {
|
|
58
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
59
|
+
}
|
|
60
|
+
function assertCandidates(candidates) {
|
|
61
|
+
const cursors = new Set();
|
|
62
|
+
for (const candidate of candidates) {
|
|
63
|
+
if (!Number.isSafeInteger(candidate.cursor) || candidate.cursor <= 0 || cursors.has(candidate.cursor)) {
|
|
64
|
+
throw new Error('Offline journal retention candidates must have unique positive safe-integer cursors.');
|
|
65
|
+
}
|
|
66
|
+
cursors.add(candidate.cursor);
|
|
67
|
+
}
|
|
68
|
+
}
|