@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
package/dist/lib/context.d.ts
CHANGED
|
@@ -1,12 +1,30 @@
|
|
|
1
|
-
import { OpenAPIHono } from "@hono/zod-openapi";
|
|
1
|
+
import { OpenAPIHono, type Hook } from "@hono/zod-openapi";
|
|
2
|
+
import type { ZodIssue } from "zod";
|
|
3
|
+
import type { UploadResult } from "./storageAdapter";
|
|
4
|
+
export interface ValidationErrorDetail {
|
|
5
|
+
path: string;
|
|
6
|
+
message: string;
|
|
7
|
+
}
|
|
8
|
+
export interface DefaultValidationErrorBody {
|
|
9
|
+
error: string;
|
|
10
|
+
details: ValidationErrorDetail[];
|
|
11
|
+
requestId: string;
|
|
12
|
+
}
|
|
13
|
+
export type ValidationErrorFormatter = (issues: ZodIssue[], requestId: string) => unknown;
|
|
14
|
+
export declare const defaultValidationErrorFormatter: ValidationErrorFormatter;
|
|
2
15
|
export type AppVariables = {
|
|
16
|
+
requestId: string;
|
|
3
17
|
authUserId: string | null;
|
|
4
18
|
roles: string[] | null;
|
|
5
19
|
sessionId: string | null;
|
|
6
20
|
tenantId: string | null;
|
|
7
21
|
tenantConfig: Record<string, unknown> | null;
|
|
22
|
+
validationErrorFormatter: ValidationErrorFormatter;
|
|
23
|
+
uploadResults: UploadResult[] | null;
|
|
24
|
+
uploadBucket: string | undefined;
|
|
8
25
|
};
|
|
9
26
|
export type AppEnv = {
|
|
10
27
|
Variables: AppVariables;
|
|
11
28
|
};
|
|
29
|
+
export declare const defaultHook: Hook<any, AppEnv, any, any>;
|
|
12
30
|
export declare const createRouter: () => OpenAPIHono<AppEnv, {}, "/">;
|
package/dist/lib/context.js
CHANGED
|
@@ -1,8 +1,22 @@
|
|
|
1
1
|
import { OpenAPIHono } from "@hono/zod-openapi";
|
|
2
|
-
const
|
|
2
|
+
export const defaultValidationErrorFormatter = (issues, requestId) => {
|
|
3
|
+
const error = issues.map((i) => i.message).join(", ");
|
|
4
|
+
const details = issues.map((i) => ({
|
|
5
|
+
path: i.path.join("."),
|
|
6
|
+
message: i.message,
|
|
7
|
+
}));
|
|
8
|
+
return { error, details, requestId };
|
|
9
|
+
};
|
|
10
|
+
export const defaultHook = (result, c) => {
|
|
3
11
|
if (!result.success) {
|
|
4
|
-
const
|
|
5
|
-
|
|
12
|
+
const requestId = c.get("requestId") ?? "unknown";
|
|
13
|
+
const formatter = c.get("validationErrorFormatter") ?? defaultValidationErrorFormatter;
|
|
14
|
+
try {
|
|
15
|
+
return c.json(formatter(result.error.issues, requestId), 400);
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return c.json(defaultValidationErrorFormatter(result.error.issues, requestId), 400);
|
|
19
|
+
}
|
|
6
20
|
}
|
|
7
21
|
};
|
|
8
22
|
export const createRouter = () => new OpenAPIHono({ defaultHook });
|
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
import type { RouteConfig } from "@hono/zod-openapi";
|
|
2
2
|
import type { ZodType } from "zod";
|
|
3
|
+
/**
|
|
4
|
+
* Sets the active version prefix for schema name generation and creates a unique
|
|
5
|
+
* Symbol token for interleaving detection. Call before importing a version's route files.
|
|
6
|
+
*/
|
|
7
|
+
export declare function setVersionPrefix(version: string): void;
|
|
8
|
+
/**
|
|
9
|
+
* Clears the active version prefix and token. Call after each version's route files
|
|
10
|
+
* have been fully imported.
|
|
11
|
+
*/
|
|
12
|
+
export declare function clearVersionPrefix(): void;
|
|
13
|
+
/** Returns the current version token for assertion after import. */
|
|
14
|
+
export declare function getVersionToken(): symbol | null;
|
|
15
|
+
/**
|
|
16
|
+
* Drains and returns all tokens captured by createRoute() calls since the last drain.
|
|
17
|
+
* Used by versioned route discovery to detect interleaving.
|
|
18
|
+
*/
|
|
19
|
+
export declare function drainCapturedTokens(): (symbol | null)[];
|
|
20
|
+
/**
|
|
21
|
+
* Asserts that all tokens in the array match the expected token.
|
|
22
|
+
* Throws a clear startup error if any mismatch is detected.
|
|
23
|
+
*/
|
|
24
|
+
export declare function assertCapturedTokens(tokens: (symbol | null)[], expectedToken: symbol | null): void;
|
|
3
25
|
/**
|
|
4
26
|
* Registers a Zod schema as a named entry in `components/schemas`.
|
|
5
27
|
*
|
|
@@ -53,9 +75,13 @@ export declare const withSecurity: <T extends RouteConfig>(route: T, ...schemes:
|
|
|
53
75
|
* OpenAPI components so they appear in `components/schemas` instead of being
|
|
54
76
|
* inlined at every use site. Generated names follow the convention:
|
|
55
77
|
*
|
|
56
|
-
* {Method}{PathSegments}
|
|
57
|
-
* {Method}{PathSegments}{
|
|
78
|
+
* {Version}{Method}{PathSegments}Request — request body (when versioning is active)
|
|
79
|
+
* {Version}{Method}{PathSegments}{Status} — response body (when versioning is active)
|
|
58
80
|
*
|
|
59
81
|
* Schemas already named via `.openapi("Name")` are never overwritten.
|
|
82
|
+
*
|
|
83
|
+
* When `setVersionPrefix` has been called, the version prefix is prepended to all
|
|
84
|
+
* generated schema names and the current version token is captured for interleaving
|
|
85
|
+
* detection via `drainCapturedTokens()` / `assertCapturedTokens()`.
|
|
60
86
|
*/
|
|
61
87
|
export declare const createRoute: <T extends RouteConfig>(config: T) => T;
|
package/dist/lib/createRoute.js
CHANGED
|
@@ -1,5 +1,50 @@
|
|
|
1
1
|
import { createRoute as _createRoute } from "@hono/zod-openapi";
|
|
2
2
|
import { getRefId, zodToOpenAPIRegistry } from "@asteasolutions/zod-to-openapi";
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Version prefix state — set once per version batch before importing route files
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
let _versionPrefix = "";
|
|
7
|
+
let _versionToken = null;
|
|
8
|
+
/** Tokens captured by createRoute() calls since the last drainCapturedTokens() call. */
|
|
9
|
+
const _capturedTokens = [];
|
|
10
|
+
/**
|
|
11
|
+
* Sets the active version prefix for schema name generation and creates a unique
|
|
12
|
+
* Symbol token for interleaving detection. Call before importing a version's route files.
|
|
13
|
+
*/
|
|
14
|
+
export function setVersionPrefix(version) {
|
|
15
|
+
_versionPrefix = version.charAt(0).toUpperCase() + version.slice(1);
|
|
16
|
+
_versionToken = Symbol(version);
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Clears the active version prefix and token. Call after each version's route files
|
|
20
|
+
* have been fully imported.
|
|
21
|
+
*/
|
|
22
|
+
export function clearVersionPrefix() {
|
|
23
|
+
_versionPrefix = "";
|
|
24
|
+
_versionToken = null;
|
|
25
|
+
}
|
|
26
|
+
/** Returns the current version token for assertion after import. */
|
|
27
|
+
export function getVersionToken() {
|
|
28
|
+
return _versionToken;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Drains and returns all tokens captured by createRoute() calls since the last drain.
|
|
32
|
+
* Used by versioned route discovery to detect interleaving.
|
|
33
|
+
*/
|
|
34
|
+
export function drainCapturedTokens() {
|
|
35
|
+
return _capturedTokens.splice(0);
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Asserts that all tokens in the array match the expected token.
|
|
39
|
+
* Throws a clear startup error if any mismatch is detected.
|
|
40
|
+
*/
|
|
41
|
+
export function assertCapturedTokens(tokens, expectedToken) {
|
|
42
|
+
for (const tok of tokens) {
|
|
43
|
+
if (tok !== expectedToken) {
|
|
44
|
+
throw new Error("Route file imported with wrong version prefix — avoid unbounded top-level await in versioned route files");
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
3
48
|
const STATUS_SUFFIX = {
|
|
4
49
|
"200": "Response",
|
|
5
50
|
"201": "Response",
|
|
@@ -128,13 +173,19 @@ export const withSecurity = (route, ...schemes) => Object.assign(route, { securi
|
|
|
128
173
|
* OpenAPI components so they appear in `components/schemas` instead of being
|
|
129
174
|
* inlined at every use site. Generated names follow the convention:
|
|
130
175
|
*
|
|
131
|
-
* {Method}{PathSegments}
|
|
132
|
-
* {Method}{PathSegments}{
|
|
176
|
+
* {Version}{Method}{PathSegments}Request — request body (when versioning is active)
|
|
177
|
+
* {Version}{Method}{PathSegments}{Status} — response body (when versioning is active)
|
|
133
178
|
*
|
|
134
179
|
* Schemas already named via `.openapi("Name")` are never overwritten.
|
|
180
|
+
*
|
|
181
|
+
* When `setVersionPrefix` has been called, the version prefix is prepended to all
|
|
182
|
+
* generated schema names and the current version token is captured for interleaving
|
|
183
|
+
* detection via `drainCapturedTokens()` / `assertCapturedTokens()`.
|
|
135
184
|
*/
|
|
136
185
|
export const createRoute = (config) => {
|
|
137
|
-
const base = toBaseName(config.method, config.path);
|
|
186
|
+
const base = (_versionPrefix ? `${_versionPrefix}` : "") + toBaseName(config.method, config.path);
|
|
187
|
+
// Capture the current version token for interleaving detection
|
|
188
|
+
_capturedTokens.push(_versionToken);
|
|
138
189
|
// Auto-name the JSON request body schema if present and unnamed
|
|
139
190
|
const bodySchema = config.request?.body?.content?.["application/json"]?.schema;
|
|
140
191
|
maybeRegister(bodySchema, `${base}Request`);
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
type CancelStore = "redis" | "mongo" | "sqlite" | "memory";
|
|
2
|
+
export declare const setDeletionCancelTokenStore: (store: CancelStore) => void;
|
|
3
|
+
/** Create a cancel token. Returns the raw token (to embed in the cancel link).
|
|
4
|
+
* Only the SHA-256 hash is persisted. TTL is gracePeriod + a 5-minute buffer. */
|
|
5
|
+
export declare const createDeletionCancelToken: (userId: string, jobId: string, gracePeriodSeconds: number) => Promise<string>;
|
|
6
|
+
/** Atomically consume a cancel token — returns its payload and deletes it.
|
|
7
|
+
* Returns null if the token is invalid, expired, or already used. */
|
|
8
|
+
export declare const consumeDeletionCancelToken: (token: string) => Promise<{
|
|
9
|
+
userId: string;
|
|
10
|
+
jobId: string;
|
|
11
|
+
} | null>;
|
|
12
|
+
export {};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { getRedis } from "./redis";
|
|
2
|
+
import { appConnection, mongoose } from "./mongo";
|
|
3
|
+
import { getAppName } from "./appConfig";
|
|
4
|
+
import { sqliteCreateDeletionCancelToken, sqliteConsumeDeletionCancelToken, } from "../adapters/sqliteAuth";
|
|
5
|
+
import { memoryCreateDeletionCancelToken, memoryConsumeDeletionCancelToken, } from "../adapters/memoryAuth";
|
|
6
|
+
import { sha256 as hashToken } from "./crypto";
|
|
7
|
+
function getCancelModel() {
|
|
8
|
+
if (appConnection.models["DeletionCancelToken"])
|
|
9
|
+
return appConnection.models["DeletionCancelToken"];
|
|
10
|
+
const { Schema } = mongoose;
|
|
11
|
+
const schema = new Schema({
|
|
12
|
+
token: { type: String, required: true, unique: true },
|
|
13
|
+
userId: { type: String, required: true },
|
|
14
|
+
jobId: { type: String, required: true },
|
|
15
|
+
expiresAt: { type: Date, required: true, index: { expireAfterSeconds: 0 } },
|
|
16
|
+
}, { collection: "deletion_cancel_tokens" });
|
|
17
|
+
return appConnection.model("DeletionCancelToken", schema);
|
|
18
|
+
}
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Redis helpers
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
async function redisGetDel(key) {
|
|
23
|
+
const redis = getRedis();
|
|
24
|
+
if (typeof redis.getdel === "function") {
|
|
25
|
+
try {
|
|
26
|
+
return await redis.getdel(key);
|
|
27
|
+
}
|
|
28
|
+
catch (err) {
|
|
29
|
+
const msg = err?.message ?? "";
|
|
30
|
+
if (!/unknown command|ERR unknown command/i.test(msg))
|
|
31
|
+
throw err;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const result = await redis.eval("local v = redis.call('GET', KEYS[1])\nif v then redis.call('DEL', KEYS[1]) end\nreturn v", 1, key);
|
|
35
|
+
return result ?? null;
|
|
36
|
+
}
|
|
37
|
+
let _store = "redis";
|
|
38
|
+
export const setDeletionCancelTokenStore = (store) => { _store = store; };
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// Public API
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
/** Create a cancel token. Returns the raw token (to embed in the cancel link).
|
|
43
|
+
* Only the SHA-256 hash is persisted. TTL is gracePeriod + a 5-minute buffer. */
|
|
44
|
+
export const createDeletionCancelToken = async (userId, jobId, gracePeriodSeconds) => {
|
|
45
|
+
const token = crypto.randomUUID();
|
|
46
|
+
const hash = hashToken(token);
|
|
47
|
+
const ttl = gracePeriodSeconds + 300; // 5-min buffer after grace period expires
|
|
48
|
+
if (_store === "memory") {
|
|
49
|
+
memoryCreateDeletionCancelToken(hash, userId, jobId, ttl);
|
|
50
|
+
return token;
|
|
51
|
+
}
|
|
52
|
+
if (_store === "sqlite") {
|
|
53
|
+
sqliteCreateDeletionCancelToken(hash, userId, jobId, ttl);
|
|
54
|
+
return token;
|
|
55
|
+
}
|
|
56
|
+
if (_store === "mongo") {
|
|
57
|
+
await getCancelModel().create({
|
|
58
|
+
token: hash,
|
|
59
|
+
userId,
|
|
60
|
+
jobId,
|
|
61
|
+
expiresAt: new Date(Date.now() + ttl * 1000),
|
|
62
|
+
});
|
|
63
|
+
return token;
|
|
64
|
+
}
|
|
65
|
+
await getRedis().set(`delcancel:${getAppName()}:${hash}`, JSON.stringify({ userId, jobId }), "EX", ttl);
|
|
66
|
+
return token;
|
|
67
|
+
};
|
|
68
|
+
/** Atomically consume a cancel token — returns its payload and deletes it.
|
|
69
|
+
* Returns null if the token is invalid, expired, or already used. */
|
|
70
|
+
export const consumeDeletionCancelToken = async (token) => {
|
|
71
|
+
const hash = hashToken(token);
|
|
72
|
+
if (_store === "memory")
|
|
73
|
+
return memoryConsumeDeletionCancelToken(hash);
|
|
74
|
+
if (_store === "sqlite")
|
|
75
|
+
return sqliteConsumeDeletionCancelToken(hash);
|
|
76
|
+
if (_store === "mongo") {
|
|
77
|
+
const doc = await getCancelModel()
|
|
78
|
+
.findOneAndDelete({ token: hash, expiresAt: { $gt: new Date() } })
|
|
79
|
+
.lean();
|
|
80
|
+
if (!doc)
|
|
81
|
+
return null;
|
|
82
|
+
return { userId: doc.userId, jobId: doc.jobId };
|
|
83
|
+
}
|
|
84
|
+
const raw = await redisGetDel(`delcancel:${getAppName()}:${hash}`);
|
|
85
|
+
if (!raw)
|
|
86
|
+
return null;
|
|
87
|
+
return JSON.parse(raw);
|
|
88
|
+
};
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
export interface GroupRecord {
|
|
2
|
+
id: string;
|
|
3
|
+
/** Machine-readable slug: /^[a-z0-9_-]+$/, unique within scope (app-wide or per-tenant). */
|
|
4
|
+
name: string;
|
|
5
|
+
displayName?: string;
|
|
6
|
+
description?: string;
|
|
7
|
+
/** Baseline roles granted to every member of this group. */
|
|
8
|
+
roles: string[];
|
|
9
|
+
/** null = app-wide group, string = tenant-scoped group. Immutable after creation. */
|
|
10
|
+
tenantId: string | null;
|
|
11
|
+
createdAt: number;
|
|
12
|
+
updatedAt: number;
|
|
13
|
+
}
|
|
14
|
+
export interface GroupMembershipRecord {
|
|
15
|
+
userId: string;
|
|
16
|
+
groupId: string;
|
|
17
|
+
/** Per-member extra roles on top of the group's baseline roles. */
|
|
18
|
+
roles: string[];
|
|
19
|
+
/**
|
|
20
|
+
* Denormalized from the group at insert time for efficient tenant-scoped queries.
|
|
21
|
+
* Immutable: the group's tenantId cannot change after creation, so this is always consistent.
|
|
22
|
+
*/
|
|
23
|
+
tenantId: string | null;
|
|
24
|
+
createdAt: number;
|
|
25
|
+
}
|
|
26
|
+
export interface PaginationOpts {
|
|
27
|
+
/** Default: 50, max: 200 */
|
|
28
|
+
limit?: number;
|
|
29
|
+
/** Default: 0 */
|
|
30
|
+
offset?: number;
|
|
31
|
+
}
|
|
32
|
+
export interface PaginatedResult<T> {
|
|
33
|
+
items: T[];
|
|
34
|
+
total: number;
|
|
35
|
+
limit: number;
|
|
36
|
+
offset: number;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Create a new group. tenantId null = app-wide, string = tenant-scoped.
|
|
40
|
+
* The group name must be a slug (/^[a-z0-9_-]+$/) and unique within its scope.
|
|
41
|
+
* Returns the new group's id.
|
|
42
|
+
*/
|
|
43
|
+
export declare const createGroup: (group: Omit<GroupRecord, "id" | "createdAt" | "updatedAt">) => Promise<{
|
|
44
|
+
id: string;
|
|
45
|
+
}>;
|
|
46
|
+
/**
|
|
47
|
+
* Delete a group by ID. All memberships are cascade-deleted by the adapter.
|
|
48
|
+
*/
|
|
49
|
+
export declare const deleteGroup: (groupId: string) => Promise<void>;
|
|
50
|
+
/**
|
|
51
|
+
* Get a group by ID. Returns null if not found.
|
|
52
|
+
*/
|
|
53
|
+
export declare const getGroup: (groupId: string) => Promise<GroupRecord | null>;
|
|
54
|
+
/**
|
|
55
|
+
* List groups scoped to a tenant (tenantId string) or app-wide (tenantId null).
|
|
56
|
+
* Results are paginated.
|
|
57
|
+
*/
|
|
58
|
+
export declare const listGroups: (tenantId: string | null, opts?: PaginationOpts) => Promise<PaginatedResult<GroupRecord>>;
|
|
59
|
+
/**
|
|
60
|
+
* Update a group's mutable fields: name, displayName, description, roles.
|
|
61
|
+
* tenantId is NOT in the update type — it is immutable after creation.
|
|
62
|
+
*/
|
|
63
|
+
export declare const updateGroup: (groupId: string, updates: Partial<Pick<GroupRecord, "roles" | "name" | "displayName" | "description">>) => Promise<void>;
|
|
64
|
+
/**
|
|
65
|
+
* Add a user to a group. Optionally supply per-membership roles (extras on top of group.roles).
|
|
66
|
+
*
|
|
67
|
+
* CONTRACT: throws if the user is already a member (unique constraint).
|
|
68
|
+
* Use updateGroupMembership to change roles on an existing membership.
|
|
69
|
+
* All adapters surface this as a thrown error, not a silent no-op.
|
|
70
|
+
*/
|
|
71
|
+
export declare const addGroupMember: (groupId: string, userId: string, roles?: string[]) => Promise<void>;
|
|
72
|
+
/**
|
|
73
|
+
* Update the per-membership roles for an existing member.
|
|
74
|
+
* This replaces the member's roles[] entirely (not an additive operation).
|
|
75
|
+
*/
|
|
76
|
+
export declare const updateGroupMembership: (groupId: string, userId: string, roles: string[]) => Promise<void>;
|
|
77
|
+
/**
|
|
78
|
+
* Remove a user from a group. No-op if the user is not a member.
|
|
79
|
+
*/
|
|
80
|
+
export declare const removeGroupMember: (groupId: string, userId: string) => Promise<void>;
|
|
81
|
+
/**
|
|
82
|
+
* List members of a group, with their per-membership roles. Paginated.
|
|
83
|
+
*/
|
|
84
|
+
export declare const getGroupMembers: (groupId: string, opts?: PaginationOpts) => Promise<PaginatedResult<{
|
|
85
|
+
userId: string;
|
|
86
|
+
roles: string[];
|
|
87
|
+
}>>;
|
|
88
|
+
/**
|
|
89
|
+
* List all groups a user belongs to, with their per-membership roles.
|
|
90
|
+
* Pass tenantId=null for app-wide groups, tenantId=string for tenant-scoped groups.
|
|
91
|
+
*/
|
|
92
|
+
export declare const getUserGroups: (userId: string, tenantId: string | null) => Promise<Array<{
|
|
93
|
+
group: GroupRecord;
|
|
94
|
+
membershipRoles: string[];
|
|
95
|
+
}>>;
|
|
96
|
+
/**
|
|
97
|
+
* Return all roles a user effectively has in the given scope, combining:
|
|
98
|
+
* 1. Direct roles (app-wide or tenant-scoped)
|
|
99
|
+
* 2. Group baseline roles (from all groups the user belongs to in that scope)
|
|
100
|
+
* 3. Per-membership roles (user-specific extras within each group)
|
|
101
|
+
*
|
|
102
|
+
* SCOPE CONTRACT:
|
|
103
|
+
* - tenantId = null → app-wide direct roles + app-wide group roles
|
|
104
|
+
* - tenantId = string → tenant-scoped direct roles + tenant-scoped group roles
|
|
105
|
+
*
|
|
106
|
+
* Tenant-scoped group roles NEVER appear in app-wide effective roles and vice versa.
|
|
107
|
+
* This is intentional: a user who is "admin" only in a tenant-scoped group is NOT
|
|
108
|
+
* a global admin. Assign roles app-wide for global access.
|
|
109
|
+
*
|
|
110
|
+
* Used internally by requireRole and requireRole.global. Also exported for use in
|
|
111
|
+
* custom middleware, route handlers, or GET /auth/me enrichment.
|
|
112
|
+
*/
|
|
113
|
+
export declare const getEffectiveRoles: (userId: string, tenantId: string | null) => Promise<string[]>;
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { getAuthAdapter } from "./authAdapter";
|
|
2
|
+
// ---------------------------------------------------------------------------
|
|
3
|
+
// Group CRUD
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
/**
|
|
6
|
+
* Create a new group. tenantId null = app-wide, string = tenant-scoped.
|
|
7
|
+
* The group name must be a slug (/^[a-z0-9_-]+$/) and unique within its scope.
|
|
8
|
+
* Returns the new group's id.
|
|
9
|
+
*/
|
|
10
|
+
export const createGroup = async (group) => {
|
|
11
|
+
const adapter = getAuthAdapter();
|
|
12
|
+
if (!adapter.createGroup)
|
|
13
|
+
throw new Error("Auth adapter does not implement createGroup");
|
|
14
|
+
return adapter.createGroup(group);
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Delete a group by ID. All memberships are cascade-deleted by the adapter.
|
|
18
|
+
*/
|
|
19
|
+
export const deleteGroup = async (groupId) => {
|
|
20
|
+
const adapter = getAuthAdapter();
|
|
21
|
+
if (!adapter.deleteGroup)
|
|
22
|
+
throw new Error("Auth adapter does not implement deleteGroup");
|
|
23
|
+
return adapter.deleteGroup(groupId);
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* Get a group by ID. Returns null if not found.
|
|
27
|
+
*/
|
|
28
|
+
export const getGroup = async (groupId) => {
|
|
29
|
+
const adapter = getAuthAdapter();
|
|
30
|
+
if (!adapter.getGroup)
|
|
31
|
+
throw new Error("Auth adapter does not implement getGroup");
|
|
32
|
+
return adapter.getGroup(groupId);
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* List groups scoped to a tenant (tenantId string) or app-wide (tenantId null).
|
|
36
|
+
* Results are paginated.
|
|
37
|
+
*/
|
|
38
|
+
export const listGroups = async (tenantId, opts) => {
|
|
39
|
+
const adapter = getAuthAdapter();
|
|
40
|
+
if (!adapter.listGroups)
|
|
41
|
+
throw new Error("Auth adapter does not implement listGroups");
|
|
42
|
+
return adapter.listGroups(tenantId, opts);
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* Update a group's mutable fields: name, displayName, description, roles.
|
|
46
|
+
* tenantId is NOT in the update type — it is immutable after creation.
|
|
47
|
+
*/
|
|
48
|
+
export const updateGroup = async (groupId, updates) => {
|
|
49
|
+
const adapter = getAuthAdapter();
|
|
50
|
+
if (!adapter.updateGroup)
|
|
51
|
+
throw new Error("Auth adapter does not implement updateGroup");
|
|
52
|
+
return adapter.updateGroup(groupId, updates);
|
|
53
|
+
};
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// Membership management
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
/**
|
|
58
|
+
* Add a user to a group. Optionally supply per-membership roles (extras on top of group.roles).
|
|
59
|
+
*
|
|
60
|
+
* CONTRACT: throws if the user is already a member (unique constraint).
|
|
61
|
+
* Use updateGroupMembership to change roles on an existing membership.
|
|
62
|
+
* All adapters surface this as a thrown error, not a silent no-op.
|
|
63
|
+
*/
|
|
64
|
+
export const addGroupMember = async (groupId, userId, roles) => {
|
|
65
|
+
const adapter = getAuthAdapter();
|
|
66
|
+
if (!adapter.addGroupMember)
|
|
67
|
+
throw new Error("Auth adapter does not implement addGroupMember");
|
|
68
|
+
return adapter.addGroupMember(groupId, userId, roles);
|
|
69
|
+
};
|
|
70
|
+
/**
|
|
71
|
+
* Update the per-membership roles for an existing member.
|
|
72
|
+
* This replaces the member's roles[] entirely (not an additive operation).
|
|
73
|
+
*/
|
|
74
|
+
export const updateGroupMembership = async (groupId, userId, roles) => {
|
|
75
|
+
const adapter = getAuthAdapter();
|
|
76
|
+
if (!adapter.updateGroupMembership)
|
|
77
|
+
throw new Error("Auth adapter does not implement updateGroupMembership");
|
|
78
|
+
return adapter.updateGroupMembership(groupId, userId, roles);
|
|
79
|
+
};
|
|
80
|
+
/**
|
|
81
|
+
* Remove a user from a group. No-op if the user is not a member.
|
|
82
|
+
*/
|
|
83
|
+
export const removeGroupMember = async (groupId, userId) => {
|
|
84
|
+
const adapter = getAuthAdapter();
|
|
85
|
+
if (!adapter.removeGroupMember)
|
|
86
|
+
throw new Error("Auth adapter does not implement removeGroupMember");
|
|
87
|
+
return adapter.removeGroupMember(groupId, userId);
|
|
88
|
+
};
|
|
89
|
+
/**
|
|
90
|
+
* List members of a group, with their per-membership roles. Paginated.
|
|
91
|
+
*/
|
|
92
|
+
export const getGroupMembers = async (groupId, opts) => {
|
|
93
|
+
const adapter = getAuthAdapter();
|
|
94
|
+
if (!adapter.getGroupMembers)
|
|
95
|
+
throw new Error("Auth adapter does not implement getGroupMembers");
|
|
96
|
+
return adapter.getGroupMembers(groupId, opts);
|
|
97
|
+
};
|
|
98
|
+
/**
|
|
99
|
+
* List all groups a user belongs to, with their per-membership roles.
|
|
100
|
+
* Pass tenantId=null for app-wide groups, tenantId=string for tenant-scoped groups.
|
|
101
|
+
*/
|
|
102
|
+
export const getUserGroups = async (userId, tenantId) => {
|
|
103
|
+
const adapter = getAuthAdapter();
|
|
104
|
+
if (!adapter.getUserGroups)
|
|
105
|
+
throw new Error("Auth adapter does not implement getUserGroups");
|
|
106
|
+
return adapter.getUserGroups(userId, tenantId);
|
|
107
|
+
};
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
// Effective role resolution
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
/**
|
|
112
|
+
* Return all roles a user effectively has in the given scope, combining:
|
|
113
|
+
* 1. Direct roles (app-wide or tenant-scoped)
|
|
114
|
+
* 2. Group baseline roles (from all groups the user belongs to in that scope)
|
|
115
|
+
* 3. Per-membership roles (user-specific extras within each group)
|
|
116
|
+
*
|
|
117
|
+
* SCOPE CONTRACT:
|
|
118
|
+
* - tenantId = null → app-wide direct roles + app-wide group roles
|
|
119
|
+
* - tenantId = string → tenant-scoped direct roles + tenant-scoped group roles
|
|
120
|
+
*
|
|
121
|
+
* Tenant-scoped group roles NEVER appear in app-wide effective roles and vice versa.
|
|
122
|
+
* This is intentional: a user who is "admin" only in a tenant-scoped group is NOT
|
|
123
|
+
* a global admin. Assign roles app-wide for global access.
|
|
124
|
+
*
|
|
125
|
+
* Used internally by requireRole and requireRole.global. Also exported for use in
|
|
126
|
+
* custom middleware, route handlers, or GET /auth/me enrichment.
|
|
127
|
+
*/
|
|
128
|
+
export const getEffectiveRoles = async (userId, tenantId) => {
|
|
129
|
+
const adapter = getAuthAdapter();
|
|
130
|
+
if (!adapter.getEffectiveRoles)
|
|
131
|
+
throw new Error("Auth adapter does not implement getEffectiveRoles");
|
|
132
|
+
return adapter.getEffectiveRoles(userId, tenantId);
|
|
133
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { MiddlewareHandler } from "hono";
|
|
2
|
+
import type { AppEnv } from "./context";
|
|
3
|
+
export interface IdempotencyOptions {
|
|
4
|
+
/** TTL in seconds for cached responses. Default: 86400 (24 hours). */
|
|
5
|
+
ttl?: number;
|
|
6
|
+
}
|
|
7
|
+
type IdempotencyStore = "redis" | "mongo" | "sqlite" | "memory";
|
|
8
|
+
export declare const setIdempotencyStore: (store: IdempotencyStore) => void;
|
|
9
|
+
export declare const clearIdempotencyMemoryStore: () => void;
|
|
10
|
+
/**
|
|
11
|
+
* Idempotency middleware. Reads the `Idempotency-Key` header and returns a
|
|
12
|
+
* cached response if one exists for this user + key combination. Otherwise
|
|
13
|
+
* calls the next handler, stores the response, and returns it.
|
|
14
|
+
*
|
|
15
|
+
* On write collision (two concurrent identical requests), the second request
|
|
16
|
+
* re-reads and returns the first-stored result.
|
|
17
|
+
*
|
|
18
|
+
* When `signing.idempotencyKeys: true`, keys are HMAC'd before storage to
|
|
19
|
+
* prevent enumeration. When off, raw keys are stored (slight enumeration risk).
|
|
20
|
+
*/
|
|
21
|
+
export declare const idempotent: (opts?: IdempotencyOptions) => MiddlewareHandler<AppEnv>;
|
|
22
|
+
export {};
|