@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.
- package/README.md +3035 -1249
- package/dist/adapters/localStorage.d.ts +6 -0
- package/dist/adapters/localStorage.js +44 -0
- package/dist/adapters/memoryAuth.d.ts +7 -0
- package/dist/adapters/memoryAuth.js +144 -0
- package/dist/adapters/memoryStorage.d.ts +3 -0
- package/dist/adapters/memoryStorage.js +44 -0
- package/dist/adapters/mongoAuth.js +120 -0
- package/dist/adapters/s3Storage.d.ts +14 -0
- package/dist/adapters/s3Storage.js +126 -0
- package/dist/adapters/sqliteAuth.d.ts +7 -0
- package/dist/adapters/sqliteAuth.js +199 -0
- package/dist/app.d.ts +100 -3
- package/dist/app.js +248 -47
- package/dist/cli.js +118 -38
- package/dist/index.d.ts +49 -7
- package/dist/index.js +35 -5
- package/dist/lib/HttpError.d.ts +5 -0
- package/dist/lib/HttpError.js +7 -0
- package/dist/lib/appConfig.d.ts +44 -0
- package/dist/lib/appConfig.js +16 -0
- package/dist/lib/auditLog.d.ts +52 -0
- package/dist/lib/auditLog.js +201 -0
- package/dist/lib/authAdapter.d.ts +69 -0
- package/dist/lib/constants.d.ts +4 -0
- package/dist/lib/constants.js +4 -0
- package/dist/lib/context.d.ts +19 -1
- package/dist/lib/context.js +17 -3
- package/dist/lib/createRoute.d.ts +28 -2
- package/dist/lib/createRoute.js +54 -3
- package/dist/lib/deletionCancelToken.d.ts +12 -0
- package/dist/lib/deletionCancelToken.js +88 -0
- package/dist/lib/groups.d.ts +113 -0
- package/dist/lib/groups.js +133 -0
- package/dist/lib/idempotency.d.ts +22 -0
- package/dist/lib/idempotency.js +182 -0
- package/dist/lib/metrics.d.ts +14 -0
- package/dist/lib/metrics.js +158 -0
- package/dist/lib/pagination.d.ts +119 -0
- package/dist/lib/pagination.js +166 -0
- package/dist/lib/session.d.ts +4 -0
- package/dist/lib/session.js +56 -2
- package/dist/lib/signing.d.ts +52 -0
- package/dist/lib/signing.js +180 -0
- package/dist/lib/storageAdapter.d.ts +30 -0
- package/dist/lib/storageAdapter.js +1 -0
- package/dist/lib/stripUnreferencedSchemas.d.ts +11 -0
- package/dist/lib/stripUnreferencedSchemas.js +79 -0
- package/dist/lib/tenant.js +2 -2
- package/dist/lib/upload.d.ts +35 -0
- package/dist/lib/upload.js +87 -0
- package/dist/lib/validate.js +2 -2
- package/dist/lib/ws.d.ts +1 -0
- package/dist/lib/ws.js +21 -0
- package/dist/lib/wsHeartbeat.d.ts +12 -0
- package/dist/lib/wsHeartbeat.js +57 -0
- package/dist/lib/wsMessages.d.ts +40 -0
- package/dist/lib/wsMessages.js +330 -0
- package/dist/lib/wsPresence.d.ts +25 -0
- package/dist/lib/wsPresence.js +99 -0
- package/dist/middleware/auditLog.d.ts +22 -0
- package/dist/middleware/auditLog.js +39 -0
- package/dist/middleware/cacheResponse.js +5 -1
- package/dist/middleware/csrf.js +10 -0
- package/dist/middleware/identify.js +57 -9
- package/dist/middleware/metrics.d.ts +9 -0
- package/dist/middleware/metrics.js +26 -0
- package/dist/middleware/requestId.d.ts +3 -0
- package/dist/middleware/requestId.js +7 -0
- package/dist/middleware/requestLogger.d.ts +38 -0
- package/dist/middleware/requestLogger.js +68 -0
- package/dist/middleware/requestSigning.d.ts +20 -0
- package/dist/middleware/requestSigning.js +99 -0
- package/dist/middleware/requireMfaSetup.d.ts +16 -0
- package/dist/middleware/requireMfaSetup.js +36 -0
- package/dist/middleware/requireRole.d.ts +9 -3
- package/dist/middleware/requireRole.js +23 -36
- package/dist/middleware/upload.d.ts +5 -0
- package/dist/middleware/upload.js +27 -0
- package/dist/middleware/webhookAuth.d.ts +30 -0
- package/dist/middleware/webhookAuth.js +57 -0
- package/dist/models/AuditLog.d.ts +30 -0
- package/dist/models/AuditLog.js +39 -0
- package/dist/models/Group.d.ts +21 -0
- package/dist/models/Group.js +28 -0
- package/dist/models/GroupMembership.d.ts +21 -0
- package/dist/models/GroupMembership.js +25 -0
- package/dist/routes/auth.js +84 -6
- package/dist/routes/groups.d.ts +21 -0
- package/dist/routes/groups.js +346 -0
- package/dist/routes/jobs.js +47 -45
- package/dist/routes/metrics.d.ts +7 -0
- package/dist/routes/metrics.js +52 -0
- package/dist/routes/mfa.js +4 -0
- package/dist/routes/uploads.d.ts +2 -0
- package/dist/routes/uploads.js +135 -0
- package/dist/server.d.ts +26 -0
- package/dist/server.js +46 -3
- package/dist/ws/index.js +3 -0
- package/docs/sections/auth-flow/full.md +779 -634
- package/docs/sections/auth-flow/overview.md +2 -2
- package/docs/sections/auth-security-examples/full.md +365 -0
- package/docs/sections/authentication/full.md +130 -0
- package/docs/sections/authentication/overview.md +5 -0
- package/docs/sections/cli/full.md +13 -1
- package/docs/sections/configuration/full.md +17 -0
- package/docs/sections/configuration/overview.md +1 -0
- package/docs/sections/exports/full.md +34 -3
- package/docs/sections/logging/full.md +83 -0
- package/docs/sections/metrics/full.md +127 -0
- package/docs/sections/oauth/full.md +189 -189
- package/docs/sections/oauth/overview.md +1 -1
- package/docs/sections/pagination/full.md +93 -0
- package/docs/sections/roles/full.md +224 -135
- package/docs/sections/roles/overview.md +3 -1
- package/docs/sections/signing/full.md +203 -0
- package/docs/sections/uploads/full.md +199 -0
- package/docs/sections/versioning/full.md +85 -0
- package/docs/sections/webhook-auth/full.md +100 -0
- package/docs/sections/websocket/full.md +83 -0
- package/docs/sections/websocket-rooms/full.md +6 -1
- 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
|
+
}
|
package/dist/lib/session.d.ts
CHANGED
|
@@ -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 {};
|