@lastshotlabs/bunshot 0.0.21 → 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 +247 -46
  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
@@ -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, {}, "/">;
@@ -1,8 +1,22 @@
1
1
  import { OpenAPIHono } from "@hono/zod-openapi";
2
- const defaultHook = (result, c) => {
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 message = result.error.issues.map((i) => i.message).join(", ");
5
- return c.json({ error: message }, 400);
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}Body — request body
57
- * {Method}{PathSegments}{StatusCode} — response body
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;
@@ -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}Body — request body
132
- * {Method}{PathSegments}{StatusCode} — response body
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 {};