@voyantjs/hono 0.9.0 → 0.10.0
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/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/middleware/auth.d.ts.map +1 -1
- package/dist/middleware/auth.js +4 -0
- package/dist/middleware/idempotency-key.d.ts +71 -0
- package/dist/middleware/idempotency-key.d.ts.map +1 -0
- package/dist/middleware/idempotency-key.js +179 -0
- package/dist/middleware/index.d.ts +1 -0
- package/dist/middleware/index.d.ts.map +1 -1
- package/dist/middleware/index.js +1 -0
- package/dist/middleware/require-actor.d.ts +5 -3
- package/dist/middleware/require-actor.d.ts.map +1 -1
- package/dist/middleware/require-actor.js +9 -4
- package/package.json +5 -5
package/dist/index.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ export type { VoyantPermission } from "@voyantjs/core";
|
|
|
2
2
|
export { createApp } from "./app.js";
|
|
3
3
|
export type { SessionAuthContext } from "./auth/index.js";
|
|
4
4
|
export { extractBearerToken, generateNumericCode, randomBytesHex, requireUserId, sha256Base64Url, sha256Hex, unsignCookie, verifySession, } from "./auth/index.js";
|
|
5
|
-
export { consoleLoggerProvider, cors, db, errorBoundary, handleApiError, LIVE_LIMITS, logger, rateLimit, requestId, requireActor, requireAuth, requirePermission, } from "./middleware/index.js";
|
|
5
|
+
export { consoleLoggerProvider, cors, DEFAULT_IDEMPOTENCY_TTL_MS, db, errorBoundary, handleApiError, type IdempotencyKeyOptions, idempotencyKey, LIVE_LIMITS, logger, purgeExpiredIdempotencyKeys, rateLimit, requestId, requireActor, requireAuth, requirePermission, } from "./middleware/index.js";
|
|
6
6
|
export type { HonoExtension, HonoModule } from "./module.js";
|
|
7
7
|
export type { ExpandedHonoBundles, ExpandedHonoPlugins, HonoBundle, HonoPlugin, } from "./plugin.js";
|
|
8
8
|
export { defineHonoBundle, defineHonoPlugin, expandHonoBundles, expandHonoPlugins, } from "./plugin.js";
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,YAAY,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAA;AACtD,OAAO,EAAE,SAAS,EAAE,MAAM,UAAU,CAAA;AACpC,YAAY,EAAE,kBAAkB,EAAE,MAAM,iBAAiB,CAAA;AACzD,OAAO,EACL,kBAAkB,EAClB,mBAAmB,EACnB,cAAc,EACd,aAAa,EACb,eAAe,EACf,SAAS,EACT,YAAY,EACZ,aAAa,GACd,MAAM,iBAAiB,CAAA;AACxB,OAAO,EACL,qBAAqB,EACrB,IAAI,EACJ,EAAE,EACF,aAAa,EACb,cAAc,EACd,WAAW,EACX,MAAM,EACN,SAAS,EACT,SAAS,EACT,YAAY,EACZ,WAAW,EACX,iBAAiB,GAClB,MAAM,uBAAuB,CAAA;AAC9B,YAAY,EAAE,aAAa,EAAE,UAAU,EAAE,MAAM,aAAa,CAAA;AAC5D,YAAY,EACV,mBAAmB,EACnB,mBAAmB,EACnB,UAAU,EACV,UAAU,GACX,MAAM,aAAa,CAAA;AACpB,OAAO,EACL,gBAAgB,EAChB,gBAAgB,EAChB,iBAAiB,EACjB,iBAAiB,GAClB,MAAM,aAAa,CAAA;AACpB,YAAY,EACV,SAAS,EACT,QAAQ,EACR,cAAc,EACd,eAAe,EACf,qBAAqB,EACrB,wBAAwB,EACxB,qBAAqB,EACrB,cAAc,EACd,QAAQ,EACR,sBAAsB,EACtB,kBAAkB,EAClB,wBAAwB,EACxB,eAAe,GAChB,MAAM,YAAY,CAAA;AACnB,OAAO,EACL,YAAY,EACZ,iBAAiB,EACjB,wBAAwB,EACxB,aAAa,EACb,qBAAqB,EACrB,UAAU,EACV,sBAAsB,EACtB,oBAAoB,GACrB,MAAM,iBAAiB,CAAA"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,YAAY,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAA;AACtD,OAAO,EAAE,SAAS,EAAE,MAAM,UAAU,CAAA;AACpC,YAAY,EAAE,kBAAkB,EAAE,MAAM,iBAAiB,CAAA;AACzD,OAAO,EACL,kBAAkB,EAClB,mBAAmB,EACnB,cAAc,EACd,aAAa,EACb,eAAe,EACf,SAAS,EACT,YAAY,EACZ,aAAa,GACd,MAAM,iBAAiB,CAAA;AACxB,OAAO,EACL,qBAAqB,EACrB,IAAI,EACJ,0BAA0B,EAC1B,EAAE,EACF,aAAa,EACb,cAAc,EACd,KAAK,qBAAqB,EAC1B,cAAc,EACd,WAAW,EACX,MAAM,EACN,2BAA2B,EAC3B,SAAS,EACT,SAAS,EACT,YAAY,EACZ,WAAW,EACX,iBAAiB,GAClB,MAAM,uBAAuB,CAAA;AAC9B,YAAY,EAAE,aAAa,EAAE,UAAU,EAAE,MAAM,aAAa,CAAA;AAC5D,YAAY,EACV,mBAAmB,EACnB,mBAAmB,EACnB,UAAU,EACV,UAAU,GACX,MAAM,aAAa,CAAA;AACpB,OAAO,EACL,gBAAgB,EAChB,gBAAgB,EAChB,iBAAiB,EACjB,iBAAiB,GAClB,MAAM,aAAa,CAAA;AACpB,YAAY,EACV,SAAS,EACT,QAAQ,EACR,cAAc,EACd,eAAe,EACf,qBAAqB,EACrB,wBAAwB,EACxB,qBAAqB,EACrB,cAAc,EACd,QAAQ,EACR,sBAAsB,EACtB,kBAAkB,EAClB,wBAAwB,EACxB,eAAe,GAChB,MAAM,YAAY,CAAA;AACnB,OAAO,EACL,YAAY,EACZ,iBAAiB,EACjB,wBAAwB,EACxB,aAAa,EACb,qBAAqB,EACrB,UAAU,EACV,sBAAsB,EACtB,oBAAoB,GACrB,MAAM,iBAAiB,CAAA"}
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { createApp } from "./app.js";
|
|
2
2
|
export { extractBearerToken, generateNumericCode, randomBytesHex, requireUserId, sha256Base64Url, sha256Hex, unsignCookie, verifySession, } from "./auth/index.js";
|
|
3
|
-
export { consoleLoggerProvider, cors, db, errorBoundary, handleApiError, LIVE_LIMITS, logger, rateLimit, requestId, requireActor, requireAuth, requirePermission, } from "./middleware/index.js";
|
|
3
|
+
export { consoleLoggerProvider, cors, DEFAULT_IDEMPOTENCY_TTL_MS, db, errorBoundary, handleApiError, idempotencyKey, LIVE_LIMITS, logger, purgeExpiredIdempotencyKeys, rateLimit, requestId, requireActor, requireAuth, requirePermission, } from "./middleware/index.js";
|
|
4
4
|
export { defineHonoBundle, defineHonoPlugin, expandHonoBundles, expandHonoPlugins, } from "./plugin.js";
|
|
5
5
|
export { ApiHttpError, ForbiddenApiError, normalizeValidationError, parseJsonBody, parseOptionalJsonBody, parseQuery, RequestValidationError, UnauthorizedApiError, } from "./validation.js";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../../src/middleware/auth.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,MAAM,CAAA;AAI7C,OAAO,KAAK,EAAE,SAAS,EAAE,qBAAqB,EAAE,cAAc,EAAE,eAAe,EAAE,MAAM,aAAa,CAAA;AAsCpG,wBAAgB,WAAW,CAAC,SAAS,SAAS,cAAc,EAC1D,SAAS,EAAE,SAAS,CAAC,SAAS,CAAC,EAC/B,IAAI,CAAC,EAAE;IACL,WAAW,CAAC,EAAE,MAAM,EAAE,CAAA;IACtB,IAAI,CAAC,EAAE,qBAAqB,CAAC,SAAS,CAAC,CAAA;CACxC,GACA,iBAAiB,CAAC;IACnB,QAAQ,EAAE,SAAS,CAAA;IACnB,SAAS,EAAE,eAAe,CAAA;CAC3B,CAAC,
|
|
1
|
+
{"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../../src/middleware/auth.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,MAAM,CAAA;AAI7C,OAAO,KAAK,EAAE,SAAS,EAAE,qBAAqB,EAAE,cAAc,EAAE,eAAe,EAAE,MAAM,aAAa,CAAA;AAsCpG,wBAAgB,WAAW,CAAC,SAAS,SAAS,cAAc,EAC1D,SAAS,EAAE,SAAS,CAAC,SAAS,CAAC,EAC/B,IAAI,CAAC,EAAE;IACL,WAAW,CAAC,EAAE,MAAM,EAAE,CAAA;IACtB,IAAI,CAAC,EAAE,qBAAqB,CAAC,SAAS,CAAC,CAAA;CACxC,GACA,iBAAiB,CAAC;IACnB,QAAQ,EAAE,SAAS,CAAA;IACnB,SAAS,EAAE,eAAe,CAAA;CAC3B,CAAC,CA4ID"}
|
package/dist/middleware/auth.js
CHANGED
|
@@ -118,6 +118,10 @@ export function requireAuth(dbFactory, opts) {
|
|
|
118
118
|
scopes,
|
|
119
119
|
callerType: "api_key",
|
|
120
120
|
apiKeyId: row.id,
|
|
121
|
+
// Core-owned API keys (`voy_` prefix) are server-to-server credentials
|
|
122
|
+
// issued to operator staff. The actor stays explicit here so that
|
|
123
|
+
// `requireActor` doesn't have to default unset callers to "staff".
|
|
124
|
+
actor: "staff",
|
|
121
125
|
});
|
|
122
126
|
return next();
|
|
123
127
|
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { MiddlewareHandler } from "hono";
|
|
2
|
+
import type { DbFactory, VoyantBindings, VoyantVariables } from "../types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Twenty-four hours, in milliseconds. Default TTL for stored idempotency
|
|
5
|
+
* keys. Tunable per middleware instance.
|
|
6
|
+
*/
|
|
7
|
+
export declare const DEFAULT_IDEMPOTENCY_TTL_MS: number;
|
|
8
|
+
export interface IdempotencyKeyOptions {
|
|
9
|
+
/**
|
|
10
|
+
* Namespacing label so unrelated endpoints can safely accept overlapping
|
|
11
|
+
* client keys. Defaults to the request method + pathname when omitted.
|
|
12
|
+
*/
|
|
13
|
+
scope?: string;
|
|
14
|
+
/**
|
|
15
|
+
* How long stored responses are replayable. Defaults to
|
|
16
|
+
* {@link DEFAULT_IDEMPOTENCY_TTL_MS}.
|
|
17
|
+
*/
|
|
18
|
+
ttlMs?: number;
|
|
19
|
+
/**
|
|
20
|
+
* Whether the header is required. When `true`, requests without the header
|
|
21
|
+
* are rejected with 400. Defaults to `false` so existing clients keep
|
|
22
|
+
* working through a deprecation window.
|
|
23
|
+
*/
|
|
24
|
+
required?: boolean;
|
|
25
|
+
/**
|
|
26
|
+
* Optional callback that, given the response body that's about to be
|
|
27
|
+
* stored, returns a `referenceId` (typically the booking id). Used for
|
|
28
|
+
* operational queries to correlate replays with the underlying entity.
|
|
29
|
+
*/
|
|
30
|
+
extractReferenceId?: (body: unknown) => string | null | undefined;
|
|
31
|
+
}
|
|
32
|
+
interface ContextWithIdempotency {
|
|
33
|
+
idempotencyKey?: string;
|
|
34
|
+
idempotencyReplayed?: boolean;
|
|
35
|
+
}
|
|
36
|
+
declare module "hono" {
|
|
37
|
+
interface ContextVariableMap extends ContextWithIdempotency {
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Idempotency-Key middleware.
|
|
42
|
+
*
|
|
43
|
+
* On request:
|
|
44
|
+
* - reads the `Idempotency-Key` header
|
|
45
|
+
* - if absent and `required` is false, passes through
|
|
46
|
+
* - if absent and `required` is true, returns 400
|
|
47
|
+
* - looks up `(scope, key)` in `idempotency_keys`:
|
|
48
|
+
* - hit + same body hash → returns the stored response (replay)
|
|
49
|
+
* - hit + different body hash → returns 409 (conflict)
|
|
50
|
+
* - miss → runs the handler, then stores the response on the way out
|
|
51
|
+
*
|
|
52
|
+
* The handler's response body is captured by cloning the response. The
|
|
53
|
+
* `Idempotency-Key` and a `Idempotency-Replayed: true` header are echoed
|
|
54
|
+
* on replay so callers can detect replays in client-side logging.
|
|
55
|
+
*
|
|
56
|
+
* The middleware reads the `db` instance off the request context (set by
|
|
57
|
+
* the `db` middleware that ships with `createApp`) — caller is responsible
|
|
58
|
+
* for ordering this middleware after `db`.
|
|
59
|
+
*/
|
|
60
|
+
export declare function idempotencyKey<TBindings extends VoyantBindings = VoyantBindings>(options?: IdempotencyKeyOptions): MiddlewareHandler<{
|
|
61
|
+
Bindings: TBindings;
|
|
62
|
+
Variables: VoyantVariables;
|
|
63
|
+
}>;
|
|
64
|
+
/**
|
|
65
|
+
* Sweep expired idempotency rows. Call from a daily cron.
|
|
66
|
+
*/
|
|
67
|
+
export declare function purgeExpiredIdempotencyKeys<TBindings extends VoyantBindings>(dbFactory: DbFactory<TBindings>, env: TBindings): Promise<{
|
|
68
|
+
removed: number;
|
|
69
|
+
}>;
|
|
70
|
+
export {};
|
|
71
|
+
//# sourceMappingURL=idempotency-key.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"idempotency-key.d.ts","sourceRoot":"","sources":["../../src/middleware/idempotency-key.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,MAAM,CAAA;AAE7C,OAAO,KAAK,EAAE,SAAS,EAAE,cAAc,EAAE,eAAe,EAAE,MAAM,aAAa,CAAA;AAE7E;;;GAGG;AACH,eAAO,MAAM,0BAA0B,QAAsB,CAAA;AAmB7D,MAAM,WAAW,qBAAqB;IACpC;;;OAGG;IACH,KAAK,CAAC,EAAE,MAAM,CAAA;IACd;;;OAGG;IACH,KAAK,CAAC,EAAE,MAAM,CAAA;IACd;;;;OAIG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB;;;;OAIG;IACH,kBAAkB,CAAC,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,MAAM,GAAG,IAAI,GAAG,SAAS,CAAA;CAClE;AAED,UAAU,sBAAsB;IAC9B,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,mBAAmB,CAAC,EAAE,OAAO,CAAA;CAC9B;AAED,OAAO,QAAQ,MAAM,CAAC;IACpB,UAAU,kBAAmB,SAAQ,sBAAsB;KAAG;CAC/D;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,cAAc,CAAC,SAAS,SAAS,cAAc,GAAG,cAAc,EAC9E,OAAO,GAAE,qBAA0B,GAClC,iBAAiB,CAAC;IACnB,QAAQ,EAAE,SAAS,CAAA;IACnB,SAAS,EAAE,eAAe,CAAA;CAC3B,CAAC,CAkID;AAiBD;;GAEG;AACH,wBAAsB,2BAA2B,CAAC,SAAS,SAAS,cAAc,EAChF,SAAS,EAAE,SAAS,CAAC,SAAS,CAAC,EAC/B,GAAG,EAAE,SAAS,GACb,OAAO,CAAC;IAAE,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC,CAO9B"}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { infraIdempotencyKeysTable } from "@voyantjs/db/schema/infra";
|
|
2
|
+
import { and, eq, lt } from "drizzle-orm";
|
|
3
|
+
/**
|
|
4
|
+
* Twenty-four hours, in milliseconds. Default TTL for stored idempotency
|
|
5
|
+
* keys. Tunable per middleware instance.
|
|
6
|
+
*/
|
|
7
|
+
export const DEFAULT_IDEMPOTENCY_TTL_MS = 24 * 60 * 60 * 1000;
|
|
8
|
+
const HEADER_NAME = "Idempotency-Key";
|
|
9
|
+
/**
|
|
10
|
+
* Hashes a UTF-8 string with SHA-256 and returns the hex digest. Uses Web
|
|
11
|
+
* Crypto so the middleware works in Cloudflare Workers and Node.
|
|
12
|
+
*/
|
|
13
|
+
async function sha256Hex(value) {
|
|
14
|
+
const data = new TextEncoder().encode(value);
|
|
15
|
+
const digest = await crypto.subtle.digest("SHA-256", data);
|
|
16
|
+
const bytes = new Uint8Array(digest);
|
|
17
|
+
let out = "";
|
|
18
|
+
for (const byte of bytes) {
|
|
19
|
+
out += byte.toString(16).padStart(2, "0");
|
|
20
|
+
}
|
|
21
|
+
return out;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Idempotency-Key middleware.
|
|
25
|
+
*
|
|
26
|
+
* On request:
|
|
27
|
+
* - reads the `Idempotency-Key` header
|
|
28
|
+
* - if absent and `required` is false, passes through
|
|
29
|
+
* - if absent and `required` is true, returns 400
|
|
30
|
+
* - looks up `(scope, key)` in `idempotency_keys`:
|
|
31
|
+
* - hit + same body hash → returns the stored response (replay)
|
|
32
|
+
* - hit + different body hash → returns 409 (conflict)
|
|
33
|
+
* - miss → runs the handler, then stores the response on the way out
|
|
34
|
+
*
|
|
35
|
+
* The handler's response body is captured by cloning the response. The
|
|
36
|
+
* `Idempotency-Key` and a `Idempotency-Replayed: true` header are echoed
|
|
37
|
+
* on replay so callers can detect replays in client-side logging.
|
|
38
|
+
*
|
|
39
|
+
* The middleware reads the `db` instance off the request context (set by
|
|
40
|
+
* the `db` middleware that ships with `createApp`) — caller is responsible
|
|
41
|
+
* for ordering this middleware after `db`.
|
|
42
|
+
*/
|
|
43
|
+
export function idempotencyKey(options = {}) {
|
|
44
|
+
const ttlMs = options.ttlMs ?? DEFAULT_IDEMPOTENCY_TTL_MS;
|
|
45
|
+
return async (c, next) => {
|
|
46
|
+
if (c.req.method === "OPTIONS")
|
|
47
|
+
return next();
|
|
48
|
+
const headerKey = c.req.header(HEADER_NAME) ?? c.req.header(HEADER_NAME.toLowerCase());
|
|
49
|
+
if (!headerKey) {
|
|
50
|
+
if (options.required) {
|
|
51
|
+
return c.json({ error: `${HEADER_NAME} header is required for this endpoint` }, 400);
|
|
52
|
+
}
|
|
53
|
+
return next();
|
|
54
|
+
}
|
|
55
|
+
if (headerKey.length > 255) {
|
|
56
|
+
return c.json({ error: `${HEADER_NAME} must be 255 characters or fewer` }, 400);
|
|
57
|
+
}
|
|
58
|
+
const scope = options.scope ?? `${c.req.method} ${new URL(c.req.url).pathname}`;
|
|
59
|
+
const rawBody = await c.req.text();
|
|
60
|
+
const bodyHash = await sha256Hex(rawBody);
|
|
61
|
+
const db = c.get("db");
|
|
62
|
+
if (!db) {
|
|
63
|
+
throw new Error("idempotencyKey middleware requires `db` on the request context. Mount `db()` (or `createApp`) before this middleware.");
|
|
64
|
+
}
|
|
65
|
+
// Look up an existing record. If the (scope, key) was used recently we
|
|
66
|
+
// either replay or 409.
|
|
67
|
+
const [existing] = await db
|
|
68
|
+
.select()
|
|
69
|
+
.from(infraIdempotencyKeysTable)
|
|
70
|
+
.where(and(eq(infraIdempotencyKeysTable.scope, scope), eq(infraIdempotencyKeysTable.key, headerKey)))
|
|
71
|
+
.limit(1);
|
|
72
|
+
if (existing) {
|
|
73
|
+
if (existing.expiresAt < new Date()) {
|
|
74
|
+
// The row is past its TTL — clean it up and proceed as if missed.
|
|
75
|
+
await db
|
|
76
|
+
.delete(infraIdempotencyKeysTable)
|
|
77
|
+
.where(eq(infraIdempotencyKeysTable.id, existing.id));
|
|
78
|
+
}
|
|
79
|
+
else if (existing.bodyHash !== bodyHash) {
|
|
80
|
+
return c.json({
|
|
81
|
+
error: `${HEADER_NAME} ${headerKey} was previously used with a different request body`,
|
|
82
|
+
}, 409);
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
c.set("idempotencyKey", headerKey);
|
|
86
|
+
c.set("idempotencyReplayed", true);
|
|
87
|
+
const replayed = c.json(existing.responseBody, existing.responseStatus);
|
|
88
|
+
replayed.headers.set("Idempotency-Replayed", "true");
|
|
89
|
+
replayed.headers.set("Idempotency-Key", headerKey);
|
|
90
|
+
return replayed;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
// Re-attach the body so downstream handlers can re-parse it. Hono's
|
|
94
|
+
// `c.req.text()` consumed the original; rebuild a Request whose body
|
|
95
|
+
// matches what we hashed.
|
|
96
|
+
c.req.raw = new Request(c.req.raw, { body: rawBody });
|
|
97
|
+
c.set("idempotencyKey", headerKey);
|
|
98
|
+
c.set("idempotencyReplayed", false);
|
|
99
|
+
await next();
|
|
100
|
+
// Capture the response without consuming the original.
|
|
101
|
+
const response = c.res;
|
|
102
|
+
if (!response)
|
|
103
|
+
return;
|
|
104
|
+
const cloned = response.clone();
|
|
105
|
+
let parsedBody = null;
|
|
106
|
+
try {
|
|
107
|
+
const text = await cloned.text();
|
|
108
|
+
parsedBody = text.length > 0 ? JSON.parse(text) : null;
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
// Non-JSON responses are not stored — idempotency replay only works
|
|
112
|
+
// for JSON endpoints. Pass through silently.
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
// Only persist successful, replayable responses (2xx). 4xx/5xx leave
|
|
116
|
+
// the slot open so the client can retry with a corrected body.
|
|
117
|
+
if (response.status < 200 || response.status >= 300)
|
|
118
|
+
return;
|
|
119
|
+
const referenceId = options.extractReferenceId?.(parsedBody) ??
|
|
120
|
+
pickStringField(parsedBody, ["bookingId", "id"]) ??
|
|
121
|
+
null;
|
|
122
|
+
try {
|
|
123
|
+
await db
|
|
124
|
+
.insert(infraIdempotencyKeysTable)
|
|
125
|
+
.values({
|
|
126
|
+
scope,
|
|
127
|
+
key: headerKey,
|
|
128
|
+
bodyHash,
|
|
129
|
+
responseStatus: response.status,
|
|
130
|
+
responseBody: parsedBody,
|
|
131
|
+
referenceId,
|
|
132
|
+
expiresAt: new Date(Date.now() + ttlMs),
|
|
133
|
+
})
|
|
134
|
+
.onConflictDoNothing({
|
|
135
|
+
target: [infraIdempotencyKeysTable.scope, infraIdempotencyKeysTable.key],
|
|
136
|
+
});
|
|
137
|
+
// Echo the header so callers can confirm idempotent storage.
|
|
138
|
+
c.res = new Response(c.res.body, {
|
|
139
|
+
status: c.res.status,
|
|
140
|
+
statusText: c.res.statusText,
|
|
141
|
+
headers: new Headers(c.res.headers),
|
|
142
|
+
});
|
|
143
|
+
c.res.headers.set("Idempotency-Key", headerKey);
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
// Best-effort storage. If the write fails we still return the
|
|
147
|
+
// response — duplicate-detection just degrades to "no protection
|
|
148
|
+
// for this request." Logging is the deployment's responsibility.
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
function pickStringField(body, keys) {
|
|
153
|
+
if (!body || typeof body !== "object")
|
|
154
|
+
return null;
|
|
155
|
+
const obj = body;
|
|
156
|
+
for (const key of keys) {
|
|
157
|
+
const value = obj[key];
|
|
158
|
+
if (typeof value === "string" && value.length > 0)
|
|
159
|
+
return value;
|
|
160
|
+
// Common shape: `{ data: { id: ... } }`
|
|
161
|
+
if (key === "id" && obj.data && typeof obj.data === "object") {
|
|
162
|
+
const nested = obj.data.id;
|
|
163
|
+
if (typeof nested === "string" && nested.length > 0)
|
|
164
|
+
return nested;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Sweep expired idempotency rows. Call from a daily cron.
|
|
171
|
+
*/
|
|
172
|
+
export async function purgeExpiredIdempotencyKeys(dbFactory, env) {
|
|
173
|
+
const db = dbFactory(env);
|
|
174
|
+
const result = await db
|
|
175
|
+
.delete(infraIdempotencyKeysTable)
|
|
176
|
+
.where(lt(infraIdempotencyKeysTable.expiresAt, new Date()))
|
|
177
|
+
.returning();
|
|
178
|
+
return { removed: result.length };
|
|
179
|
+
}
|
|
@@ -2,6 +2,7 @@ export { requireAuth } from "./auth.js";
|
|
|
2
2
|
export { cors } from "./cors.js";
|
|
3
3
|
export { db } from "./db.js";
|
|
4
4
|
export { errorBoundary, handleApiError, requestId } from "./error-boundary.js";
|
|
5
|
+
export { DEFAULT_IDEMPOTENCY_TTL_MS, type IdempotencyKeyOptions, idempotencyKey, purgeExpiredIdempotencyKeys, } from "./idempotency-key.js";
|
|
5
6
|
export { consoleLoggerProvider, logger } from "./logger.js";
|
|
6
7
|
export { LIVE_LIMITS, rateLimit } from "./rate-limit.js";
|
|
7
8
|
export { requireActor } from "./require-actor.js";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/middleware/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,WAAW,CAAA;AACvC,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAA;AAChC,OAAO,EAAE,EAAE,EAAE,MAAM,SAAS,CAAA;AAC5B,OAAO,EAAE,aAAa,EAAE,cAAc,EAAE,SAAS,EAAE,MAAM,qBAAqB,CAAA;AAC9E,OAAO,EAAE,qBAAqB,EAAE,MAAM,EAAE,MAAM,aAAa,CAAA;AAC3D,OAAO,EAAE,WAAW,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAA;AACxD,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAA;AACjD,OAAO,EAAE,iBAAiB,EAAE,MAAM,yBAAyB,CAAA"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/middleware/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,WAAW,CAAA;AACvC,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAA;AAChC,OAAO,EAAE,EAAE,EAAE,MAAM,SAAS,CAAA;AAC5B,OAAO,EAAE,aAAa,EAAE,cAAc,EAAE,SAAS,EAAE,MAAM,qBAAqB,CAAA;AAC9E,OAAO,EACL,0BAA0B,EAC1B,KAAK,qBAAqB,EAC1B,cAAc,EACd,2BAA2B,GAC5B,MAAM,sBAAsB,CAAA;AAC7B,OAAO,EAAE,qBAAqB,EAAE,MAAM,EAAE,MAAM,aAAa,CAAA;AAC3D,OAAO,EAAE,WAAW,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAA;AACxD,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAA;AACjD,OAAO,EAAE,iBAAiB,EAAE,MAAM,yBAAyB,CAAA"}
|
package/dist/middleware/index.js
CHANGED
|
@@ -2,6 +2,7 @@ export { requireAuth } from "./auth.js";
|
|
|
2
2
|
export { cors } from "./cors.js";
|
|
3
3
|
export { db } from "./db.js";
|
|
4
4
|
export { errorBoundary, handleApiError, requestId } from "./error-boundary.js";
|
|
5
|
+
export { DEFAULT_IDEMPOTENCY_TTL_MS, idempotencyKey, purgeExpiredIdempotencyKeys, } from "./idempotency-key.js";
|
|
5
6
|
export { consoleLoggerProvider, logger } from "./logger.js";
|
|
6
7
|
export { LIVE_LIMITS, rateLimit } from "./rate-limit.js";
|
|
7
8
|
export { requireActor } from "./require-actor.js";
|
|
@@ -12,9 +12,11 @@ import type { VoyantBindings, VoyantVariables } from "../types.js";
|
|
|
12
12
|
* custom `auth.resolve` integration. Internal requests
|
|
13
13
|
* (`isInternalRequest === true`) bypass the check.
|
|
14
14
|
*
|
|
15
|
-
* When the caller has no
|
|
16
|
-
*
|
|
17
|
-
* that
|
|
15
|
+
* When the caller has no resolved actor, this middleware returns `401
|
|
16
|
+
* Unauthorized`. Earlier versions defaulted unset callers to `"staff"` for
|
|
17
|
+
* backwards compatibility, but that meant a misordered or missing auth
|
|
18
|
+
* middleware silently granted operator privileges to anonymous traffic.
|
|
19
|
+
* The default is now fail-closed.
|
|
18
20
|
*
|
|
19
21
|
* @example
|
|
20
22
|
* app.use("/v1/admin/*", requireActor("staff"))
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"require-actor.d.ts","sourceRoot":"","sources":["../../src/middleware/require-actor.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,gBAAgB,CAAA;AAC3C,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,MAAM,CAAA;AAE7C,OAAO,KAAK,EAAE,cAAc,EAAE,eAAe,EAAE,MAAM,aAAa,CAAA;AAElE
|
|
1
|
+
{"version":3,"file":"require-actor.d.ts","sourceRoot":"","sources":["../../src/middleware/require-actor.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,gBAAgB,CAAA;AAC3C,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,MAAM,CAAA;AAE7C,OAAO,KAAK,EAAE,cAAc,EAAE,eAAe,EAAE,MAAM,aAAa,CAAA;AAElE;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,YAAY,CAAC,SAAS,SAAS,cAAc,GAAG,cAAc,EAC5E,GAAG,OAAO,EAAE,KAAK,EAAE,GAClB,iBAAiB,CAAC;IACnB,QAAQ,EAAE,SAAS,CAAA;IACnB,SAAS,EAAE,eAAe,CAAA;CAC3B,CAAC,CAuBD"}
|
|
@@ -9,9 +9,11 @@
|
|
|
9
9
|
* custom `auth.resolve` integration. Internal requests
|
|
10
10
|
* (`isInternalRequest === true`) bypass the check.
|
|
11
11
|
*
|
|
12
|
-
* When the caller has no
|
|
13
|
-
*
|
|
14
|
-
* that
|
|
12
|
+
* When the caller has no resolved actor, this middleware returns `401
|
|
13
|
+
* Unauthorized`. Earlier versions defaulted unset callers to `"staff"` for
|
|
14
|
+
* backwards compatibility, but that meant a misordered or missing auth
|
|
15
|
+
* middleware silently granted operator privileges to anonymous traffic.
|
|
16
|
+
* The default is now fail-closed.
|
|
15
17
|
*
|
|
16
18
|
* @example
|
|
17
19
|
* app.use("/v1/admin/*", requireActor("staff"))
|
|
@@ -28,7 +30,10 @@ export function requireActor(...allowed) {
|
|
|
28
30
|
if (c.get("isInternalRequest")) {
|
|
29
31
|
return next();
|
|
30
32
|
}
|
|
31
|
-
const actor = c.get("actor")
|
|
33
|
+
const actor = c.get("actor");
|
|
34
|
+
if (!actor) {
|
|
35
|
+
return c.json({ error: "Unauthorized: actor not resolved" }, 401);
|
|
36
|
+
}
|
|
32
37
|
if (!allowSet.has(actor)) {
|
|
33
38
|
return c.json({ error: "Forbidden: actor not permitted on this surface" }, 403);
|
|
34
39
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@voyantjs/hono",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"license": "FSL-1.1-Apache-2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -77,10 +77,10 @@
|
|
|
77
77
|
"drizzle-orm": "^0.45.2",
|
|
78
78
|
"hono": "^4.12.10",
|
|
79
79
|
"zod": "^4.3.6",
|
|
80
|
-
"@voyantjs/core": "0.
|
|
81
|
-
"@voyantjs/db": "0.
|
|
82
|
-
"@voyantjs/types": "0.
|
|
83
|
-
"@voyantjs/utils": "0.
|
|
80
|
+
"@voyantjs/core": "0.10.0",
|
|
81
|
+
"@voyantjs/db": "0.10.0",
|
|
82
|
+
"@voyantjs/types": "0.10.0",
|
|
83
|
+
"@voyantjs/utils": "0.10.0"
|
|
84
84
|
},
|
|
85
85
|
"devDependencies": {
|
|
86
86
|
"@cloudflare/workers-types": "^4.20260403.1",
|