@rdlabo/workers-hono-kit 0.10.0 → 0.10.2

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 CHANGED
@@ -106,6 +106,7 @@ npm install ai ai-gateway-provider # createAiGatewayProvider
106
106
  | `sendInChunks(queue, messages, options?)` / `QueueLike` / `QueueSendMessage` | Send queue messages in bounded chunks to stay under the Workers subrequest cap per invocation. `options.chunkSize` sets the per-batch size (defaults to and is capped at 100). |
107
107
  | `processBatch(batch, handler, options?)` / `isNonRetryableQueueError(error)` / `NonRetryableQueueErrorLike` / `MessageBatchLike` / `QueueMessageLike` / `ProcessBatchOptions` / `ProcessBatchResult` | Process a queue batch with bounded concurrency. Errors explicitly tagged with `queueDisposition: 'discard'` are reported and acked as permanent failures; all other errors are retried. |
108
108
  | `createQueueErrorHandler(options)` / `CreateQueueErrorHandlerOptions` | Factory for `processBatch`'s `onError`: logs every failure; optional Sentry capture with queue/message context; optional `maxRetries` gate (report only on final attempt, except permanent failures which are reported immediately). |
109
+ | `assertStripeCustomerUpdated(options)` | Preserve the shared Stripe UPDATE→existence-check algorithm. `createNotFoundError(customerId)` can supply a domain-specific error without forking the algorithm. |
109
110
  | `ExecutionContextLike` | Minimal `waitUntil`-only Workers execution context shape used by lifecycle-compatible APIs and deferred work helpers. |
110
111
 
111
112
  Permanent Queue failures must opt in with the Queue-specific marker; unrelated `retryable` fields are ignored:
@@ -480,8 +481,10 @@ Repos with a custom DB error classifier (e.g. odss-mobile) pass `classify` to
480
481
  ### Auth middleware
481
482
 
482
483
  Encodes the shared skeleton (read token header → verify → `getAppInfo` → resolve user id →
483
- set context, with `console.error` + a configurable failure on error). Inject your own
484
- verify/resolver, context-variable names, and failure mode.
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`.
485
488
 
486
489
  `createAuthMiddleware<Env, Verified, Id>` is generic over your Hono `Env`, so `c.set(...)` in
487
490
  `setContext` is type-checked against your `Variables`.
@@ -491,6 +494,7 @@ import { createAuthMiddleware, createIdentityAuthFailureBody } from '@rdlabo/wor
491
494
 
492
495
  // AuthGuard: verify + resolve (and provision) the DB user id.
493
496
  const userAuth = createAuthMiddleware<AppEnv, UserRecord, number>({
497
+ rejectMissingToken: true,
494
498
  verify: (token) => container.firebase.verifyIdToken(token),
495
499
  resolveUserId: (record, _c, appInfo) =>
496
500
  container.auth.getUserIdFromFirebase(record, appInfo).catch(() => container.auth.createUser(record)),
@@ -499,18 +503,28 @@ const userAuth = createAuthMiddleware<AppEnv, UserRecord, number>({
499
503
  c.set('userId', userId);
500
504
  c.set('appInfo', appInfo);
501
505
  },
502
- onFailure: (_error, context) =>
506
+ reportFailure: (error, context, { stage, tokenPresent }) => {
507
+ // Suppress expected credential rejection; report dependency/internal failures without tokens.
508
+ },
509
+ onFailure: (_error, context, { stage }) =>
503
510
  context.json(createIdentityAuthFailureBody(), 401),
504
511
  });
505
512
 
506
513
  // TokenGuard (login): verify only — omit resolveUserId. Override the failure if needed.
507
514
  const tokenAuth = createAuthMiddleware<AppEnv, UserRecord>({
515
+ rejectMissingToken: true,
508
516
  verify: (token) => container.firebase.verifyIdToken(token),
509
517
  setContext: (c, { verified }) => c.set('userRecord', verified),
510
518
  onFailure: (_e, c) => c.json(createIdentityAuthFailureBody(), 401),
511
519
  });
512
520
  ```
513
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
+
514
528
  Authentication failures use three explicit scopes. Only `identity` permits a client to purge its
515
529
  global authenticated session, offline replica boundary, and outbox. `reauthentication` means the
516
530
  identity remains valid but a recent sign-in is required; `credential` belongs to a domain feature
@@ -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 Error('Firebase ID token has an invalid subject');
93
+ throw new FirebaseIdTokenValidationError('subject');
78
94
  }
79
95
  if (!Number.isFinite(payload.exp)) {
80
- throw new Error('Firebase ID token has an invalid exp');
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 Error('Firebase ID token has an invalid iat');
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 Error('Firebase ID token has an invalid auth_time');
104
+ throw new FirebaseIdTokenValidationError('auth_time');
89
105
  }
90
106
  return { ...payload, uid: payload.sub, email: payload.email };
91
107
  }
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';
@@ -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';
@@ -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 read from {@link AuthMiddlewareOptions.tokenHeader} (empty string if absent).
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>) => Response;
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 `console.error` then `onFailure` — while the
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.
@@ -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 `console.error` then `onFailure` — while the
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
- // Equivalent to a guard returning false → ForbiddenException('Forbidden resource'). Log the
44
- // cause and, by default, throw so the app's onError renders the error body (callers can
45
- // override with onFailure to return a custom response instead).
46
- console.error(e);
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
  }
@@ -29,4 +29,5 @@ export declare function assertStripeCustomerUpdated(options: {
29
29
  affected: number;
30
30
  }>;
31
31
  countCustomer: (productId: string, customerId: string) => Promise<number>;
32
+ createNotFoundError?: (customerId: string) => Error;
32
33
  }): Promise<void>;
@@ -49,6 +49,6 @@ export async function assertStripeCustomerUpdated(options) {
49
49
  return;
50
50
  }
51
51
  if ((await options.countCustomer(options.productId, options.customerId)) === 0) {
52
- throw new Error(`Customer not found: ${options.customerId}`);
52
+ throw options.createNotFoundError?.(options.customerId) ?? new Error(`Customer not found: ${options.customerId}`);
53
53
  }
54
54
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rdlabo/workers-hono-kit",
3
- "version": "0.10.0",
3
+ "version": "0.10.2",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"