@lastshotlabs/bunshot 0.0.20 → 0.0.25

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.
Files changed (122) hide show
  1. package/README.md +3035 -1249
  2. package/dist/adapters/localStorage.d.ts +6 -0
  3. package/dist/adapters/localStorage.js +44 -0
  4. package/dist/adapters/memoryAuth.d.ts +7 -0
  5. package/dist/adapters/memoryAuth.js +144 -0
  6. package/dist/adapters/memoryStorage.d.ts +3 -0
  7. package/dist/adapters/memoryStorage.js +44 -0
  8. package/dist/adapters/mongoAuth.js +120 -0
  9. package/dist/adapters/s3Storage.d.ts +14 -0
  10. package/dist/adapters/s3Storage.js +126 -0
  11. package/dist/adapters/sqliteAuth.d.ts +7 -0
  12. package/dist/adapters/sqliteAuth.js +199 -0
  13. package/dist/app.d.ts +100 -3
  14. package/dist/app.js +248 -47
  15. package/dist/cli.js +118 -38
  16. package/dist/index.d.ts +49 -7
  17. package/dist/index.js +35 -5
  18. package/dist/lib/HttpError.d.ts +5 -0
  19. package/dist/lib/HttpError.js +7 -0
  20. package/dist/lib/appConfig.d.ts +44 -0
  21. package/dist/lib/appConfig.js +16 -0
  22. package/dist/lib/auditLog.d.ts +52 -0
  23. package/dist/lib/auditLog.js +201 -0
  24. package/dist/lib/authAdapter.d.ts +69 -0
  25. package/dist/lib/constants.d.ts +4 -0
  26. package/dist/lib/constants.js +4 -0
  27. package/dist/lib/context.d.ts +19 -1
  28. package/dist/lib/context.js +17 -3
  29. package/dist/lib/createRoute.d.ts +28 -2
  30. package/dist/lib/createRoute.js +54 -3
  31. package/dist/lib/deletionCancelToken.d.ts +12 -0
  32. package/dist/lib/deletionCancelToken.js +88 -0
  33. package/dist/lib/groups.d.ts +113 -0
  34. package/dist/lib/groups.js +133 -0
  35. package/dist/lib/idempotency.d.ts +22 -0
  36. package/dist/lib/idempotency.js +182 -0
  37. package/dist/lib/metrics.d.ts +14 -0
  38. package/dist/lib/metrics.js +158 -0
  39. package/dist/lib/pagination.d.ts +119 -0
  40. package/dist/lib/pagination.js +166 -0
  41. package/dist/lib/session.d.ts +4 -0
  42. package/dist/lib/session.js +56 -2
  43. package/dist/lib/signing.d.ts +52 -0
  44. package/dist/lib/signing.js +180 -0
  45. package/dist/lib/storageAdapter.d.ts +30 -0
  46. package/dist/lib/storageAdapter.js +1 -0
  47. package/dist/lib/stripUnreferencedSchemas.d.ts +11 -0
  48. package/dist/lib/stripUnreferencedSchemas.js +79 -0
  49. package/dist/lib/tenant.js +2 -2
  50. package/dist/lib/upload.d.ts +35 -0
  51. package/dist/lib/upload.js +87 -0
  52. package/dist/lib/validate.js +2 -2
  53. package/dist/lib/ws.d.ts +1 -0
  54. package/dist/lib/ws.js +21 -0
  55. package/dist/lib/wsHeartbeat.d.ts +12 -0
  56. package/dist/lib/wsHeartbeat.js +57 -0
  57. package/dist/lib/wsMessages.d.ts +40 -0
  58. package/dist/lib/wsMessages.js +330 -0
  59. package/dist/lib/wsPresence.d.ts +25 -0
  60. package/dist/lib/wsPresence.js +99 -0
  61. package/dist/middleware/auditLog.d.ts +22 -0
  62. package/dist/middleware/auditLog.js +39 -0
  63. package/dist/middleware/cacheResponse.js +5 -1
  64. package/dist/middleware/csrf.js +10 -0
  65. package/dist/middleware/identify.js +57 -9
  66. package/dist/middleware/metrics.d.ts +9 -0
  67. package/dist/middleware/metrics.js +26 -0
  68. package/dist/middleware/requestId.d.ts +3 -0
  69. package/dist/middleware/requestId.js +7 -0
  70. package/dist/middleware/requestLogger.d.ts +38 -0
  71. package/dist/middleware/requestLogger.js +68 -0
  72. package/dist/middleware/requestSigning.d.ts +20 -0
  73. package/dist/middleware/requestSigning.js +99 -0
  74. package/dist/middleware/requireMfaSetup.d.ts +16 -0
  75. package/dist/middleware/requireMfaSetup.js +36 -0
  76. package/dist/middleware/requireRole.d.ts +9 -3
  77. package/dist/middleware/requireRole.js +23 -36
  78. package/dist/middleware/upload.d.ts +5 -0
  79. package/dist/middleware/upload.js +27 -0
  80. package/dist/middleware/webhookAuth.d.ts +30 -0
  81. package/dist/middleware/webhookAuth.js +57 -0
  82. package/dist/models/AuditLog.d.ts +30 -0
  83. package/dist/models/AuditLog.js +39 -0
  84. package/dist/models/Group.d.ts +21 -0
  85. package/dist/models/Group.js +28 -0
  86. package/dist/models/GroupMembership.d.ts +21 -0
  87. package/dist/models/GroupMembership.js +25 -0
  88. package/dist/routes/auth.js +84 -6
  89. package/dist/routes/groups.d.ts +21 -0
  90. package/dist/routes/groups.js +346 -0
  91. package/dist/routes/jobs.js +47 -45
  92. package/dist/routes/metrics.d.ts +7 -0
  93. package/dist/routes/metrics.js +52 -0
  94. package/dist/routes/mfa.js +4 -0
  95. package/dist/routes/uploads.d.ts +2 -0
  96. package/dist/routes/uploads.js +135 -0
  97. package/dist/server.d.ts +26 -0
  98. package/dist/server.js +46 -3
  99. package/dist/ws/index.js +3 -0
  100. package/docs/sections/auth-flow/full.md +779 -634
  101. package/docs/sections/auth-flow/overview.md +2 -2
  102. package/docs/sections/auth-security-examples/full.md +365 -0
  103. package/docs/sections/authentication/full.md +130 -0
  104. package/docs/sections/authentication/overview.md +5 -0
  105. package/docs/sections/cli/full.md +13 -1
  106. package/docs/sections/configuration/full.md +17 -0
  107. package/docs/sections/configuration/overview.md +1 -0
  108. package/docs/sections/exports/full.md +34 -3
  109. package/docs/sections/logging/full.md +83 -0
  110. package/docs/sections/metrics/full.md +127 -0
  111. package/docs/sections/oauth/full.md +189 -189
  112. package/docs/sections/oauth/overview.md +1 -1
  113. package/docs/sections/pagination/full.md +93 -0
  114. package/docs/sections/roles/full.md +224 -135
  115. package/docs/sections/roles/overview.md +3 -1
  116. package/docs/sections/signing/full.md +203 -0
  117. package/docs/sections/uploads/full.md +199 -0
  118. package/docs/sections/versioning/full.md +85 -0
  119. package/docs/sections/webhook-auth/full.md +100 -0
  120. package/docs/sections/websocket/full.md +83 -0
  121. package/docs/sections/websocket-rooms/full.md +6 -1
  122. package/package.json +16 -4
@@ -0,0 +1,182 @@
1
+ import { getRedis } from "./redis";
2
+ import { appConnection, mongoose } from "./mongo";
3
+ import { getAppName } from "./appConfig";
4
+ import { getSigningConfig, getSigningSecret } from "./appConfig";
5
+ import { hmacSign } from "./signing";
6
+ import { HEADER_IDEMPOTENCY_KEY } from "./constants";
7
+ let _store = "redis";
8
+ export const setIdempotencyStore = (store) => { _store = store; };
9
+ // ---------------------------------------------------------------------------
10
+ // Memory store (tests only — no TTL eviction)
11
+ // ---------------------------------------------------------------------------
12
+ const _memory = new Map();
13
+ export const clearIdempotencyMemoryStore = () => _memory.clear();
14
+ function getIdempotencyModel() {
15
+ if (appConnection.models["Idempotency"])
16
+ return appConnection.models["Idempotency"];
17
+ const { Schema } = mongoose;
18
+ const schema = new Schema({
19
+ key: { type: String, required: true, unique: true },
20
+ status: { type: Number, required: true },
21
+ body: { type: String, required: true },
22
+ createdAt: { type: Date, required: true },
23
+ expiresAt: { type: Date, required: true, index: { expireAfterSeconds: 0 } },
24
+ }, { collection: "idempotency" });
25
+ return appConnection.model("Idempotency", schema);
26
+ }
27
+ // ---------------------------------------------------------------------------
28
+ // SQLite helpers (lazy — only available when bun:sqlite is in use)
29
+ // ---------------------------------------------------------------------------
30
+ function getSqliteDb() {
31
+ const { getDb } = require("../adapters/sqliteAuth");
32
+ return getDb();
33
+ }
34
+ function sqliteEnsureTable() {
35
+ const db = getSqliteDb();
36
+ db.run(`CREATE TABLE IF NOT EXISTS idempotency (
37
+ key TEXT PRIMARY KEY,
38
+ status INTEGER NOT NULL,
39
+ body TEXT NOT NULL,
40
+ createdAt INTEGER NOT NULL,
41
+ expiresAt INTEGER NOT NULL
42
+ )`);
43
+ }
44
+ // ---------------------------------------------------------------------------
45
+ // Key derivation
46
+ // ---------------------------------------------------------------------------
47
+ function deriveKey(rawKey, userId) {
48
+ const prefix = userId ?? "anon";
49
+ const cfg = getSigningConfig();
50
+ if (cfg?.idempotencyKeys) {
51
+ const secret = getSigningSecret();
52
+ if (secret) {
53
+ return `${prefix}:${hmacSign(rawKey, secret)}`;
54
+ }
55
+ }
56
+ return `${prefix}:${rawKey}`;
57
+ }
58
+ function redisIdempotencyKey(key) {
59
+ return `idempotency:${getAppName()}:${key}`;
60
+ }
61
+ // ---------------------------------------------------------------------------
62
+ // Store operations
63
+ // ---------------------------------------------------------------------------
64
+ async function getRecord(key) {
65
+ if (_store === "memory") {
66
+ return _memory.get(key) ?? null;
67
+ }
68
+ if (_store === "sqlite") {
69
+ sqliteEnsureTable();
70
+ const row = getSqliteDb().query("SELECT status, body, createdAt FROM idempotency WHERE key = ? AND expiresAt > ?").get(key, Date.now());
71
+ return row ? { status: row.status, body: row.body, createdAt: row.createdAt } : null;
72
+ }
73
+ if (_store === "redis") {
74
+ const raw = await getRedis().get(redisIdempotencyKey(key));
75
+ if (!raw)
76
+ return null;
77
+ return JSON.parse(raw);
78
+ }
79
+ // mongo
80
+ const doc = await getIdempotencyModel()
81
+ .findOne({ key, expiresAt: { $gt: new Date() } }, "status body createdAt")
82
+ .lean();
83
+ return doc ? { status: doc.status, body: doc.body, createdAt: doc.createdAt.getTime() } : null;
84
+ }
85
+ /**
86
+ * Attempt to store a record. Returns true if stored, false if a record
87
+ * already exists (write collision — treat as cache hit).
88
+ */
89
+ async function tryStoreRecord(key, record, ttl) {
90
+ if (_store === "memory") {
91
+ if (_memory.has(key))
92
+ return false;
93
+ _memory.set(key, record);
94
+ return true;
95
+ }
96
+ if (_store === "sqlite") {
97
+ sqliteEnsureTable();
98
+ const db = getSqliteDb();
99
+ const expiresAt = record.createdAt + ttl * 1000;
100
+ db.run("INSERT OR IGNORE INTO idempotency (key, status, body, createdAt, expiresAt) VALUES (?, ?, ?, ?, ?)", [key, record.status, record.body, record.createdAt, expiresAt]);
101
+ // SQLite INSERT OR IGNORE doesn't throw on conflict; check changes count
102
+ const changes = db.query("SELECT changes() as changes").get()?.changes ?? 0;
103
+ return changes > 0;
104
+ }
105
+ if (_store === "redis") {
106
+ // SET NX: set-if-not-exists — second concurrent writer gets a no-op
107
+ const value = JSON.stringify(record);
108
+ const result = await getRedis().set(redisIdempotencyKey(key), value, "EX", ttl, "NX");
109
+ return result === "OK";
110
+ }
111
+ // mongo — unique index on key; second writer catches duplicate key error
112
+ try {
113
+ const now = new Date(record.createdAt);
114
+ await getIdempotencyModel().create({
115
+ key,
116
+ status: record.status,
117
+ body: record.body,
118
+ createdAt: now,
119
+ expiresAt: new Date(now.getTime() + ttl * 1000),
120
+ });
121
+ return true;
122
+ }
123
+ catch (err) {
124
+ // Duplicate key — another concurrent request already stored the result
125
+ if (err?.code === 11000 || err?.code === "11000")
126
+ return false;
127
+ throw err;
128
+ }
129
+ }
130
+ // ---------------------------------------------------------------------------
131
+ // Middleware factory
132
+ // ---------------------------------------------------------------------------
133
+ /**
134
+ * Idempotency middleware. Reads the `Idempotency-Key` header and returns a
135
+ * cached response if one exists for this user + key combination. Otherwise
136
+ * calls the next handler, stores the response, and returns it.
137
+ *
138
+ * On write collision (two concurrent identical requests), the second request
139
+ * re-reads and returns the first-stored result.
140
+ *
141
+ * When `signing.idempotencyKeys: true`, keys are HMAC'd before storage to
142
+ * prevent enumeration. When off, raw keys are stored (slight enumeration risk).
143
+ */
144
+ export const idempotent = (opts) => async (c, next) => {
145
+ const rawKey = c.req.header(HEADER_IDEMPOTENCY_KEY);
146
+ if (!rawKey) {
147
+ await next();
148
+ return;
149
+ }
150
+ const userId = c.get("authUserId") ?? null;
151
+ const key = deriveKey(rawKey, userId);
152
+ const ttl = opts?.ttl ?? 86400;
153
+ // Cache hit — return stored response
154
+ const cached = await getRecord(key);
155
+ if (cached) {
156
+ return c.json(JSON.parse(cached.body), cached.status);
157
+ }
158
+ // Cache miss — call handler
159
+ await next();
160
+ // Capture the response body by reading it
161
+ const status = c.res.status;
162
+ let body = "";
163
+ try {
164
+ body = await c.res.clone().text();
165
+ }
166
+ catch {
167
+ // Non-text/non-json response — skip caching
168
+ return;
169
+ }
170
+ const record = { status, body, createdAt: Date.now() };
171
+ const stored = await tryStoreRecord(key, record, ttl);
172
+ if (!stored) {
173
+ // Write collision — return the first-stored result
174
+ const winner = await getRecord(key);
175
+ if (winner) {
176
+ c.res = new Response(winner.body, {
177
+ status: winner.status,
178
+ headers: { "content-type": "application/json" },
179
+ });
180
+ }
181
+ }
182
+ };
@@ -0,0 +1,14 @@
1
+ type Labels = Record<string, string>;
2
+ export declare function defaultNormalizePath(path: string): string;
3
+ export declare function incrementCounter(name: string, labels: Labels, amount?: number): void;
4
+ export declare function observeHistogram(name: string, labels: Labels, value: number, buckets?: number[]): void;
5
+ type GaugeCallback = () => Promise<{
6
+ labels: Labels;
7
+ value: number;
8
+ }[]>;
9
+ export declare function registerGaugeCallback(name: string, cb: GaugeCallback): void;
10
+ export declare function serializeMetrics(): Promise<string>;
11
+ export declare function resetMetrics(): void;
12
+ export declare function setMetricsQueues(map: Map<string, any>): void;
13
+ export declare function closeMetricsQueues(): Promise<void>;
14
+ export {};
@@ -0,0 +1,158 @@
1
+ // In-memory Prometheus-compatible metrics registry.
2
+ // Bun is single-threaded so no atomics are needed.
3
+ // ── Helpers ──────────────────────────────────────────────────────────────────
4
+ function labelKey(labels) {
5
+ return Object.entries(labels)
6
+ .sort(([a], [b]) => a.localeCompare(b))
7
+ .map(([k, v]) => `${k}="${v}"`)
8
+ .join(",");
9
+ }
10
+ function formatLabels(labels) {
11
+ const pairs = Object.entries(labels)
12
+ .sort(([a], [b]) => a.localeCompare(b))
13
+ .map(([k, v]) => `${k}="${v}"`);
14
+ return pairs.length ? `{${pairs.join(",")}}` : "";
15
+ }
16
+ // ── Path normalization ───────────────────────────────────────────────────────
17
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
18
+ const OBJECTID_RE = /^[0-9a-f]{24}$/i;
19
+ const NUMERIC_RE = /^\d+$/;
20
+ export function defaultNormalizePath(path) {
21
+ return path
22
+ .split("/")
23
+ .map((seg) => {
24
+ if (!seg)
25
+ return seg;
26
+ if (UUID_RE.test(seg))
27
+ return ":id";
28
+ if (OBJECTID_RE.test(seg))
29
+ return ":id";
30
+ if (NUMERIC_RE.test(seg))
31
+ return ":id";
32
+ return seg;
33
+ })
34
+ .join("/");
35
+ }
36
+ const counters = new Map();
37
+ export function incrementCounter(name, labels, amount = 1) {
38
+ let metric = counters.get(name);
39
+ if (!metric) {
40
+ metric = new Map();
41
+ counters.set(name, metric);
42
+ }
43
+ const key = labelKey(labels);
44
+ const existing = metric.get(key);
45
+ if (existing) {
46
+ existing.value += amount;
47
+ }
48
+ else {
49
+ metric.set(key, { labels: { ...labels }, value: amount });
50
+ }
51
+ }
52
+ // ── Histogram ────────────────────────────────────────────────────────────────
53
+ const DEFAULT_BUCKETS = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10];
54
+ const histograms = new Map();
55
+ export function observeHistogram(name, labels, value, buckets = DEFAULT_BUCKETS) {
56
+ let metric = histograms.get(name);
57
+ if (!metric) {
58
+ metric = { boundaries: buckets, entries: new Map() };
59
+ histograms.set(name, metric);
60
+ }
61
+ const key = labelKey(labels);
62
+ let entry = metric.entries.get(key);
63
+ if (!entry) {
64
+ entry = { labels: { ...labels }, buckets: new Array(buckets.length).fill(0), sum: 0, count: 0 };
65
+ metric.entries.set(key, entry);
66
+ }
67
+ // Find the first (tightest) bucket the value fits in.
68
+ // Serialization will compute cumulative sums across buckets.
69
+ for (let i = 0; i < metric.boundaries.length; i++) {
70
+ if (value <= metric.boundaries[i]) {
71
+ entry.buckets[i]++;
72
+ break;
73
+ }
74
+ }
75
+ entry.sum += value;
76
+ entry.count++;
77
+ }
78
+ const gaugeCallbacks = new Map();
79
+ export function registerGaugeCallback(name, cb) {
80
+ gaugeCallbacks.set(name, cb);
81
+ }
82
+ // ── Serialize ────────────────────────────────────────────────────────────────
83
+ export async function serializeMetrics() {
84
+ const lines = [];
85
+ // Collect gauge callbacks first so any error-counter increments are
86
+ // included when we serialize counters below.
87
+ const gaugeLines = [];
88
+ for (const [name, cb] of gaugeCallbacks) {
89
+ try {
90
+ const results = await cb();
91
+ gaugeLines.push(`# HELP ${name} ${name.replace(/_/g, " ")}`);
92
+ gaugeLines.push(`# TYPE ${name} gauge`);
93
+ for (const { labels, value } of results) {
94
+ gaugeLines.push(`${name}${formatLabels(labels)} ${value}`);
95
+ }
96
+ gaugeLines.push("");
97
+ }
98
+ catch (err) {
99
+ console.warn(`[metrics] Gauge callback "${name}" failed:`, err);
100
+ incrementCounter("bunshot_gauge_errors_total", { gauge: name });
101
+ }
102
+ }
103
+ // Counters
104
+ for (const [name, entries] of counters) {
105
+ lines.push(`# HELP ${name} Total ${name.replace(/_/g, " ")}`);
106
+ lines.push(`# TYPE ${name} counter`);
107
+ for (const entry of entries.values()) {
108
+ lines.push(`${name}${formatLabels(entry.labels)} ${entry.value}`);
109
+ }
110
+ lines.push("");
111
+ }
112
+ // Histograms
113
+ for (const [name, metric] of histograms) {
114
+ lines.push(`# HELP ${name} ${name.replace(/_/g, " ")}`);
115
+ lines.push(`# TYPE ${name} histogram`);
116
+ for (const entry of metric.entries.values()) {
117
+ const lbls = formatLabels(entry.labels);
118
+ let cumulative = 0;
119
+ for (let i = 0; i < metric.boundaries.length; i++) {
120
+ cumulative += entry.buckets[i];
121
+ const bucketLabels = { ...entry.labels, le: String(metric.boundaries[i]) };
122
+ lines.push(`${name}_bucket${formatLabels(bucketLabels)} ${cumulative}`);
123
+ }
124
+ const infLabels = { ...entry.labels, le: "+Inf" };
125
+ lines.push(`${name}_bucket${formatLabels(infLabels)} ${entry.count}`);
126
+ lines.push(`${name}_sum${lbls} ${entry.sum}`);
127
+ lines.push(`${name}_count${lbls} ${entry.count}`);
128
+ }
129
+ lines.push("");
130
+ }
131
+ // Gauges (already collected above)
132
+ lines.push(...gaugeLines);
133
+ return lines.join("\n");
134
+ }
135
+ // ── Reset (for tests) ───────────────────────────────────────────────────────
136
+ export function resetMetrics() {
137
+ counters.clear();
138
+ histograms.clear();
139
+ gaugeCallbacks.clear();
140
+ }
141
+ // ── Queue cleanup ────────────────────────────────────────────────────────────
142
+ let metricsQueues = null;
143
+ export function setMetricsQueues(map) {
144
+ metricsQueues = map;
145
+ }
146
+ export async function closeMetricsQueues() {
147
+ if (!metricsQueues)
148
+ return;
149
+ for (const q of metricsQueues.values()) {
150
+ try {
151
+ await q.close();
152
+ }
153
+ catch {
154
+ // best-effort cleanup
155
+ }
156
+ }
157
+ metricsQueues.clear();
158
+ }
@@ -0,0 +1,119 @@
1
+ import { z } from "zod";
2
+ import type { ZodType } from "zod";
3
+ export type { PaginationOpts, PaginatedResult } from "./groups";
4
+ export interface OffsetParamDefaults {
5
+ /** Default: 50 */
6
+ limit?: number;
7
+ /** Default: 200 */
8
+ maxLimit?: number;
9
+ /** Default: 0 */
10
+ offset?: number;
11
+ }
12
+ export interface ParsedOffsetParams {
13
+ limit: number;
14
+ offset: number;
15
+ }
16
+ /**
17
+ * Zod schema for offset pagination query params.
18
+ * Fields are `z.string().optional()` — matches the existing query param
19
+ * convention. Parse the values with `parseOffsetParams`.
20
+ *
21
+ * @example
22
+ * createRoute({ ..., request: { query: offsetParams({ limit: 20 }) }, ... })
23
+ */
24
+ export declare function offsetParams(defaults?: OffsetParamDefaults): z.ZodObject<{
25
+ limit: z.ZodOptional<z.ZodString>;
26
+ offset: z.ZodOptional<z.ZodString>;
27
+ }, z.core.$strip>;
28
+ /**
29
+ * Parses raw string query values into clamped integers.
30
+ * - NaN (non-numeric strings) falls back to defaults
31
+ * - limit clamped to [1, maxLimit]
32
+ * - offset clamped to [0, ∞)
33
+ *
34
+ * @example
35
+ * const { limit, offset } = parseOffsetParams(c.req.query(), { maxLimit: 100 });
36
+ */
37
+ export declare function parseOffsetParams(raw: {
38
+ limit?: string;
39
+ offset?: string;
40
+ }, defaults?: OffsetParamDefaults): ParsedOffsetParams;
41
+ /**
42
+ * Zod schema factory for paginated offset responses.
43
+ * Wraps `itemSchema` in `{ items, total, limit, offset }` and registers
44
+ * the result as a named OpenAPI component.
45
+ *
46
+ * Throws if `name` was previously registered to a different schema instance.
47
+ * Calling with the same `name` + `schema` pair is idempotent.
48
+ *
49
+ * @example
50
+ * const PaginatedUsersResponse = paginatedResponse(UserSchema, "PaginatedUsers");
51
+ */
52
+ export declare function paginatedResponse<T extends ZodType>(itemSchema: T, name: string): z.ZodObject<{
53
+ items: z.ZodArray<T>;
54
+ total: z.ZodNumber;
55
+ limit: z.ZodNumber;
56
+ offset: z.ZodNumber;
57
+ }, z.core.$strip>;
58
+ export interface CursorParamDefaults {
59
+ /** Default: 50 */
60
+ limit?: number;
61
+ /** Default: 200 */
62
+ maxLimit?: number;
63
+ }
64
+ export interface ParsedCursorParams {
65
+ limit: number;
66
+ cursor: string | undefined;
67
+ }
68
+ export interface CursorResult<T> {
69
+ items: T[];
70
+ nextCursor: string | null;
71
+ hasMore: boolean;
72
+ }
73
+ /**
74
+ * Zod schema for cursor pagination query params.
75
+ * Fields are `z.string().optional()`. Parse the values with `parseCursorParams`.
76
+ *
77
+ * @example
78
+ * createRoute({ ..., request: { query: cursorParams() }, ... })
79
+ */
80
+ export declare function cursorParams(defaults?: CursorParamDefaults): z.ZodObject<{
81
+ limit: z.ZodOptional<z.ZodString>;
82
+ cursor: z.ZodOptional<z.ZodString>;
83
+ }, z.core.$strip>;
84
+ /**
85
+ * Parses raw string query values into typed cursor params.
86
+ * - limit: NaN falls back to default, clamped to [1, maxLimit]
87
+ * - cursor: empty string normalized to `undefined`; non-empty is pass-through
88
+ * - When `signing.cursors: true`, verifies the cursor HMAC — invalid cursor returns null
89
+ *
90
+ * @example
91
+ * const { limit, cursor } = parseCursorParams(c.req.query());
92
+ */
93
+ export declare function parseCursorParams(raw: {
94
+ limit?: string;
95
+ cursor?: string;
96
+ }, defaults?: CursorParamDefaults): ParsedCursorParams & {
97
+ invalidCursor?: true;
98
+ };
99
+ /**
100
+ * Sign a cursor value if `signing.cursors: true`. Otherwise returns the
101
+ * cursor unchanged (current behavior).
102
+ */
103
+ export declare function maybeSignCursor(cursor: string | null): string | null;
104
+ /**
105
+ * Zod schema factory for cursor-paginated responses.
106
+ * Wraps `itemSchema` in `{ items, nextCursor, hasMore }` and registers
107
+ * the result as a named OpenAPI component.
108
+ *
109
+ * Throws if `name` was previously registered to a different schema instance.
110
+ * Calling with the same `name` + `schema` pair is idempotent.
111
+ *
112
+ * @example
113
+ * const PostsPage = cursorResponse(PostSchema, "PostsPage");
114
+ */
115
+ export declare function cursorResponse<T extends ZodType>(itemSchema: T, name: string): z.ZodObject<{
116
+ items: z.ZodArray<T>;
117
+ nextCursor: z.ZodNullable<z.ZodString>;
118
+ hasMore: z.ZodBoolean;
119
+ }, z.core.$strip>;
@@ -0,0 +1,166 @@
1
+ import { z } from "zod";
2
+ import { registerSchema } from "./createRoute";
3
+ import { getSigningConfig, getSigningSecret } from "./appConfig";
4
+ import { signCursor, verifyCursor } from "./signing";
5
+ const _registered = new Map();
6
+ function guardedRegister(name, itemSchema, buildWrapper) {
7
+ const existing = _registered.get(name);
8
+ if (existing !== undefined) {
9
+ if (existing.itemSchema !== itemSchema) {
10
+ throw new Error(`Pagination schema name "${name}" is already registered to a different schema`);
11
+ }
12
+ // Same item schema → idempotent, return the cached wrapper
13
+ return existing.wrapper;
14
+ }
15
+ const wrapper = buildWrapper();
16
+ _registered.set(name, { itemSchema, wrapper });
17
+ registerSchema(name, wrapper);
18
+ return wrapper;
19
+ }
20
+ /**
21
+ * Zod schema for offset pagination query params.
22
+ * Fields are `z.string().optional()` — matches the existing query param
23
+ * convention. Parse the values with `parseOffsetParams`.
24
+ *
25
+ * @example
26
+ * createRoute({ ..., request: { query: offsetParams({ limit: 20 }) }, ... })
27
+ */
28
+ export function offsetParams(defaults) {
29
+ const defaultLimit = defaults?.limit ?? 50;
30
+ const defaultOffset = defaults?.offset ?? 0;
31
+ const maxLimit = defaults?.maxLimit ?? 200;
32
+ return z.object({
33
+ limit: z
34
+ .string()
35
+ .optional()
36
+ .describe(`Number of items to return (1–${maxLimit}, default ${defaultLimit})`),
37
+ offset: z
38
+ .string()
39
+ .optional()
40
+ .describe(`Number of items to skip (default ${defaultOffset})`),
41
+ });
42
+ }
43
+ /**
44
+ * Parses raw string query values into clamped integers.
45
+ * - NaN (non-numeric strings) falls back to defaults
46
+ * - limit clamped to [1, maxLimit]
47
+ * - offset clamped to [0, ∞)
48
+ *
49
+ * @example
50
+ * const { limit, offset } = parseOffsetParams(c.req.query(), { maxLimit: 100 });
51
+ */
52
+ export function parseOffsetParams(raw, defaults) {
53
+ const defaultLimit = defaults?.limit ?? 50;
54
+ const maxLimit = defaults?.maxLimit ?? 200;
55
+ const defaultOffset = defaults?.offset ?? 0;
56
+ const rawLimit = parseInt(raw.limit ?? "", 10);
57
+ const rawOffset = parseInt(raw.offset ?? "", 10);
58
+ const limit = isNaN(rawLimit)
59
+ ? defaultLimit
60
+ : Math.min(Math.max(rawLimit, 1), maxLimit);
61
+ const offset = isNaN(rawOffset) ? defaultOffset : Math.max(rawOffset, 0);
62
+ return { limit, offset };
63
+ }
64
+ /**
65
+ * Zod schema factory for paginated offset responses.
66
+ * Wraps `itemSchema` in `{ items, total, limit, offset }` and registers
67
+ * the result as a named OpenAPI component.
68
+ *
69
+ * Throws if `name` was previously registered to a different schema instance.
70
+ * Calling with the same `name` + `schema` pair is idempotent.
71
+ *
72
+ * @example
73
+ * const PaginatedUsersResponse = paginatedResponse(UserSchema, "PaginatedUsers");
74
+ */
75
+ export function paginatedResponse(itemSchema, name) {
76
+ return guardedRegister(name, itemSchema, () => z.object({
77
+ items: z.array(itemSchema),
78
+ total: z.number().int().nonnegative(),
79
+ limit: z.number().int().positive(),
80
+ offset: z.number().int().nonnegative(),
81
+ }));
82
+ }
83
+ /**
84
+ * Zod schema for cursor pagination query params.
85
+ * Fields are `z.string().optional()`. Parse the values with `parseCursorParams`.
86
+ *
87
+ * @example
88
+ * createRoute({ ..., request: { query: cursorParams() }, ... })
89
+ */
90
+ export function cursorParams(defaults) {
91
+ const defaultLimit = defaults?.limit ?? 50;
92
+ const maxLimit = defaults?.maxLimit ?? 200;
93
+ return z.object({
94
+ limit: z
95
+ .string()
96
+ .optional()
97
+ .describe(`Number of items to return (1–${maxLimit}, default ${defaultLimit})`),
98
+ cursor: z
99
+ .string()
100
+ .optional()
101
+ .describe("Opaque cursor from a previous response's nextCursor field"),
102
+ });
103
+ }
104
+ /**
105
+ * Parses raw string query values into typed cursor params.
106
+ * - limit: NaN falls back to default, clamped to [1, maxLimit]
107
+ * - cursor: empty string normalized to `undefined`; non-empty is pass-through
108
+ * - When `signing.cursors: true`, verifies the cursor HMAC — invalid cursor returns null
109
+ *
110
+ * @example
111
+ * const { limit, cursor } = parseCursorParams(c.req.query());
112
+ */
113
+ export function parseCursorParams(raw, defaults) {
114
+ const defaultLimit = defaults?.limit ?? 50;
115
+ const maxLimit = defaults?.maxLimit ?? 200;
116
+ const rawLimit = parseInt(raw.limit ?? "", 10);
117
+ const limit = isNaN(rawLimit)
118
+ ? defaultLimit
119
+ : Math.min(Math.max(rawLimit, 1), maxLimit);
120
+ if (!raw.cursor)
121
+ return { limit, cursor: undefined };
122
+ const cfg = getSigningConfig();
123
+ if (cfg?.cursors) {
124
+ const secret = getSigningSecret();
125
+ if (secret) {
126
+ const verified = verifyCursor(raw.cursor, secret);
127
+ if (verified === null)
128
+ return { limit, cursor: undefined, invalidCursor: true };
129
+ return { limit, cursor: verified };
130
+ }
131
+ }
132
+ return { limit, cursor: raw.cursor };
133
+ }
134
+ /**
135
+ * Sign a cursor value if `signing.cursors: true`. Otherwise returns the
136
+ * cursor unchanged (current behavior).
137
+ */
138
+ export function maybeSignCursor(cursor) {
139
+ if (!cursor)
140
+ return cursor;
141
+ const cfg = getSigningConfig();
142
+ if (cfg?.cursors) {
143
+ const secret = getSigningSecret();
144
+ if (secret)
145
+ return signCursor(cursor, secret);
146
+ }
147
+ return cursor;
148
+ }
149
+ /**
150
+ * Zod schema factory for cursor-paginated responses.
151
+ * Wraps `itemSchema` in `{ items, nextCursor, hasMore }` and registers
152
+ * the result as a named OpenAPI component.
153
+ *
154
+ * Throws if `name` was previously registered to a different schema instance.
155
+ * Calling with the same `name` + `schema` pair is idempotent.
156
+ *
157
+ * @example
158
+ * const PostsPage = cursorResponse(PostSchema, "PostsPage");
159
+ */
160
+ export function cursorResponse(itemSchema, name) {
161
+ return guardedRegister(name, itemSchema, () => z.object({
162
+ items: z.array(itemSchema),
163
+ nextCursor: z.string().nullable(),
164
+ hasMore: z.boolean(),
165
+ }));
166
+ }
@@ -32,4 +32,8 @@ export declare const setRefreshToken: (sessionId: string, refreshToken: string)
32
32
  export declare const getSessionByRefreshToken: (refreshToken: string) => Promise<RefreshResult | null>;
33
33
  /** Rotate the refresh token: move current to prev with grace window, set new token + access token. */
34
34
  export declare const rotateRefreshToken: (sessionId: string, newRefreshToken: string, newAccessToken: string) => Promise<void>;
35
+ /** Read the stored fingerprint for a session. Returns null if not yet set. */
36
+ export declare const getSessionFingerprint: (sessionId: string) => Promise<string | null>;
37
+ /** Store a fingerprint on an existing session. No-op if the session does not exist. */
38
+ export declare const setSessionFingerprint: (sessionId: string, fingerprint: string) => Promise<void>;
35
39
  export {};