@invect/rbac 0.0.1

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 (67) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +103 -0
  3. package/dist/backend/index.cjs +1365 -0
  4. package/dist/backend/index.cjs.map +1 -0
  5. package/dist/backend/index.d.ts +3 -0
  6. package/dist/backend/index.d.ts.map +1 -0
  7. package/dist/backend/index.mjs +1363 -0
  8. package/dist/backend/index.mjs.map +1 -0
  9. package/dist/backend/plugin.d.ts +60 -0
  10. package/dist/backend/plugin.d.ts.map +1 -0
  11. package/dist/frontend/components/AccessControlPage.d.ts +2 -0
  12. package/dist/frontend/components/AccessControlPage.d.ts.map +1 -0
  13. package/dist/frontend/components/FlowAccessPanel.d.ts +10 -0
  14. package/dist/frontend/components/FlowAccessPanel.d.ts.map +1 -0
  15. package/dist/frontend/components/ShareButton.d.ts +9 -0
  16. package/dist/frontend/components/ShareButton.d.ts.map +1 -0
  17. package/dist/frontend/components/ShareFlowModal.d.ts +12 -0
  18. package/dist/frontend/components/ShareFlowModal.d.ts.map +1 -0
  19. package/dist/frontend/components/TeamsPage.d.ts +5 -0
  20. package/dist/frontend/components/TeamsPage.d.ts.map +1 -0
  21. package/dist/frontend/components/UserMenuSection.d.ts +14 -0
  22. package/dist/frontend/components/UserMenuSection.d.ts.map +1 -0
  23. package/dist/frontend/components/access-control/AccessControlPage.d.ts +2 -0
  24. package/dist/frontend/components/access-control/AccessControlPage.d.ts.map +1 -0
  25. package/dist/frontend/components/access-control/AccessTable.d.ts +17 -0
  26. package/dist/frontend/components/access-control/AccessTable.d.ts.map +1 -0
  27. package/dist/frontend/components/access-control/FlowDetailPanel.d.ts +11 -0
  28. package/dist/frontend/components/access-control/FlowDetailPanel.d.ts.map +1 -0
  29. package/dist/frontend/components/access-control/FormDialog.d.ts +7 -0
  30. package/dist/frontend/components/access-control/FormDialog.d.ts.map +1 -0
  31. package/dist/frontend/components/access-control/MemberCombobox.d.ts +8 -0
  32. package/dist/frontend/components/access-control/MemberCombobox.d.ts.map +1 -0
  33. package/dist/frontend/components/access-control/MoveConfirmationDialog.d.ts +9 -0
  34. package/dist/frontend/components/access-control/MoveConfirmationDialog.d.ts.map +1 -0
  35. package/dist/frontend/components/access-control/PrincipalCombobox.d.ts +11 -0
  36. package/dist/frontend/components/access-control/PrincipalCombobox.d.ts.map +1 -0
  37. package/dist/frontend/components/access-control/ScopeDetailPanel.d.ts +11 -0
  38. package/dist/frontend/components/access-control/ScopeDetailPanel.d.ts.map +1 -0
  39. package/dist/frontend/components/access-control/index.d.ts +4 -0
  40. package/dist/frontend/components/access-control/index.d.ts.map +1 -0
  41. package/dist/frontend/components/access-control/types.d.ts +36 -0
  42. package/dist/frontend/components/access-control/types.d.ts.map +1 -0
  43. package/dist/frontend/components/access-control/useUsers.d.ts +3 -0
  44. package/dist/frontend/components/access-control/useUsers.d.ts.map +1 -0
  45. package/dist/frontend/hooks/useFlowAccess.d.ts +15 -0
  46. package/dist/frontend/hooks/useFlowAccess.d.ts.map +1 -0
  47. package/dist/frontend/hooks/useScopes.d.ts +15 -0
  48. package/dist/frontend/hooks/useScopes.d.ts.map +1 -0
  49. package/dist/frontend/hooks/useTeams.d.ts +25 -0
  50. package/dist/frontend/hooks/useTeams.d.ts.map +1 -0
  51. package/dist/frontend/index.cjs +2928 -0
  52. package/dist/frontend/index.cjs.map +1 -0
  53. package/dist/frontend/index.d.ts +23 -0
  54. package/dist/frontend/index.d.ts.map +1 -0
  55. package/dist/frontend/index.mjs +2899 -0
  56. package/dist/frontend/index.mjs.map +1 -0
  57. package/dist/frontend/providers/RbacProvider.d.ts +33 -0
  58. package/dist/frontend/providers/RbacProvider.d.ts.map +1 -0
  59. package/dist/frontend/stores/accessControlStore.d.ts +49 -0
  60. package/dist/frontend/stores/accessControlStore.d.ts.map +1 -0
  61. package/dist/frontend/types.d.ts +95 -0
  62. package/dist/frontend/types.d.ts.map +1 -0
  63. package/dist/shared/types.cjs +0 -0
  64. package/dist/shared/types.d.ts +172 -0
  65. package/dist/shared/types.d.ts.map +1 -0
  66. package/dist/shared/types.mjs +1 -0
  67. package/package.json +107 -0
@@ -0,0 +1,1363 @@
1
+ //#region src/backend/plugin.ts
2
+ /**
3
+ * Resolve team IDs for a user from the rbac_team_members table.
4
+ * Use this in a custom `mapUser` for the auth plugin to populate
5
+ * `identity.teamIds` automatically.
6
+ *
7
+ * @example
8
+ * ```ts
9
+ * import { resolveTeamIds } from '@invect/rbac/backend';
10
+ *
11
+ * betterAuthPlugin({
12
+ * auth,
13
+ * mapUser: async (user, session) => ({
14
+ * id: user.id,
15
+ * name: user.name ?? undefined,
16
+ * role: user.role === 'admin' ? 'admin' : 'user',
17
+ * teamIds: await resolveTeamIds(db, user.id),
18
+ * }),
19
+ * });
20
+ * ```
21
+ */
22
+ async function resolveTeamIds(db, userId) {
23
+ return (await db.query("SELECT team_id FROM rbac_team_members WHERE user_id = ?", [userId])).map((r) => r.team_id);
24
+ }
25
+ const FLOW_RESOURCE_TYPES = new Set([
26
+ "flow",
27
+ "flow-version",
28
+ "flow-run",
29
+ "node-execution"
30
+ ]);
31
+ const FLOW_PERMISSION_LEVELS = {
32
+ viewer: 1,
33
+ operator: 2,
34
+ editor: 3,
35
+ owner: 4
36
+ };
37
+ function isFlowAccessPermission(value) {
38
+ return value === "viewer" || value === "operator" || value === "editor" || value === "owner";
39
+ }
40
+ function toFlowAccessPermission(value) {
41
+ return isFlowAccessPermission(value) ? value : null;
42
+ }
43
+ function getHigherPermission(left, right) {
44
+ if (!left) return right;
45
+ if (!right) return left;
46
+ return FLOW_PERMISSION_LEVELS[right] > FLOW_PERMISSION_LEVELS[left] ? right : left;
47
+ }
48
+ function createInClause(count) {
49
+ return Array.from({ length: count }, () => "?").join(", ");
50
+ }
51
+ function mapActionToRequiredPermission(action) {
52
+ if (action.includes("delete") || action.includes("share") || action.includes("admin")) return "owner";
53
+ if (action.includes("update") || action.includes("write") || action.includes("create")) return "editor";
54
+ if (action.includes("run") || action.includes("execute")) return "operator";
55
+ return "viewer";
56
+ }
57
+ function normalizeFlowAccessRecord(row) {
58
+ return {
59
+ id: String(row.id),
60
+ flowId: String(row.flowId ?? row.flow_id),
61
+ userId: row.userId ?? row.user_id ? String(row.userId ?? row.user_id) : null,
62
+ teamId: row.teamId ?? row.team_id ? String(row.teamId ?? row.team_id) : null,
63
+ permission: String(row.permission),
64
+ grantedBy: row.grantedBy ?? row.granted_by ? String(row.grantedBy ?? row.granted_by) : null,
65
+ grantedAt: String(row.grantedAt ?? row.granted_at),
66
+ expiresAt: row.expiresAt ?? row.expires_at ? String(row.expiresAt ?? row.expires_at) : null
67
+ };
68
+ }
69
+ function normalizeScopeAccessRecord(row) {
70
+ return {
71
+ id: String(row.id),
72
+ scopeId: String(row.scopeId ?? row.scope_id),
73
+ userId: row.userId ?? row.user_id ? String(row.userId ?? row.user_id) : null,
74
+ teamId: row.teamId ?? row.team_id ? String(row.teamId ?? row.team_id) : null,
75
+ permission: String(row.permission),
76
+ grantedBy: row.grantedBy ?? row.granted_by ? String(row.grantedBy ?? row.granted_by) : null,
77
+ grantedAt: String(row.grantedAt ?? row.granted_at)
78
+ };
79
+ }
80
+ function normalizeTeamRow(row) {
81
+ return {
82
+ id: row.id,
83
+ name: row.name,
84
+ description: row.description,
85
+ parentId: row.parent_id ?? null,
86
+ createdBy: row.created_by,
87
+ createdAt: row.created_at,
88
+ updatedAt: row.updated_at
89
+ };
90
+ }
91
+ async function getFlowScopeId(db, flowId) {
92
+ return (await db.query("SELECT scope_id FROM flows WHERE id = ?", [flowId]))[0]?.scope_id ?? null;
93
+ }
94
+ async function getAncestorScopeIds(db, scopeId) {
95
+ return (await db.query(`WITH RECURSIVE ancestors AS (
96
+ SELECT id, parent_id FROM rbac_teams WHERE id = ?
97
+ UNION ALL
98
+ SELECT parent.id, parent.parent_id
99
+ FROM rbac_teams parent
100
+ INNER JOIN ancestors current ON parent.id = current.parent_id
101
+ )
102
+ SELECT id FROM ancestors`, [scopeId])).map((row) => row.id);
103
+ }
104
+ async function getDescendantScopeIds(db, scopeId) {
105
+ return (await db.query(`WITH RECURSIVE descendants AS (
106
+ SELECT id FROM rbac_teams WHERE id = ?
107
+ UNION ALL
108
+ SELECT child.id
109
+ FROM rbac_teams child
110
+ INNER JOIN descendants current ON child.parent_id = current.id
111
+ )
112
+ SELECT id FROM descendants`, [scopeId])).map((row) => row.id);
113
+ }
114
+ async function listScopeAccessForScopeIds(db, scopeIds, userId, teamIds) {
115
+ if (scopeIds.length === 0) return [];
116
+ const params = [...scopeIds, userId];
117
+ let sql = `SELECT id, scope_id, user_id, team_id, permission, granted_by, granted_at FROM rbac_scope_access WHERE (scope_id IN (${createInClause(scopeIds.length)}) AND user_id = ?)`;
118
+ if (teamIds.length > 0) {
119
+ sql += ` OR (scope_id IN (${createInClause(scopeIds.length)}) AND team_id IN (${createInClause(teamIds.length)}))`;
120
+ params.push(...scopeIds, ...teamIds);
121
+ }
122
+ return (await db.query(sql, params)).map(normalizeScopeAccessRecord);
123
+ }
124
+ async function listDirectFlowAccessForIdentity(db, flowId, userId, teamIds) {
125
+ const params = [flowId, userId];
126
+ let sql = "SELECT id, flow_id, user_id, team_id, permission, granted_by, granted_at, expires_at FROM flow_access WHERE (flow_id = ? AND user_id = ?)";
127
+ if (teamIds.length > 0) {
128
+ sql += ` OR (flow_id = ? AND team_id IN (${createInClause(teamIds.length)}))`;
129
+ params.push(flowId, ...teamIds);
130
+ }
131
+ const rows = await db.query(sql, params);
132
+ const now = Date.now();
133
+ return rows.map(normalizeFlowAccessRecord).filter((record) => !record.expiresAt || new Date(record.expiresAt).getTime() > now);
134
+ }
135
+ async function getEffectiveFlowAccessRecords(db, flowId, userId, teamIds) {
136
+ const directRecords = (await listDirectFlowAccessForIdentity(db, flowId, userId, teamIds)).map((record) => ({
137
+ ...record,
138
+ source: "direct"
139
+ }));
140
+ const scopeId = await getFlowScopeId(db, flowId);
141
+ if (!scopeId) return directRecords;
142
+ const ancestorIds = await getAncestorScopeIds(db, scopeId);
143
+ const inheritedRows = await listScopeAccessForScopeIds(db, ancestorIds, userId, teamIds);
144
+ if (inheritedRows.length === 0) return directRecords;
145
+ const scopeRows = await db.query(`SELECT id, name FROM rbac_teams WHERE id IN (${createInClause(ancestorIds.length)})`, ancestorIds);
146
+ const scopeNames = new Map(scopeRows.map((row) => [row.id, row.name]));
147
+ return [...directRecords, ...inheritedRows.map((record) => ({
148
+ id: record.id,
149
+ flowId,
150
+ userId: record.userId,
151
+ teamId: record.teamId,
152
+ permission: record.permission,
153
+ grantedBy: record.grantedBy,
154
+ grantedAt: record.grantedAt,
155
+ expiresAt: null,
156
+ source: "inherited",
157
+ scopeId: record.scopeId,
158
+ scopeName: scopeNames.get(record.scopeId) ?? null
159
+ }))];
160
+ }
161
+ async function getEffectiveFlowPermission(db, flowId, identity) {
162
+ const records = await getEffectiveFlowAccessRecords(db, flowId, identity.id, identity.teamIds ?? []);
163
+ let highest = null;
164
+ for (const record of records) highest = getHigherPermission(highest, record.permission);
165
+ return highest;
166
+ }
167
+ async function getCurrentUserAccessibleFlows(db, identity) {
168
+ const rows = await db.query("SELECT id FROM flows");
169
+ const permissions = {};
170
+ await Promise.all(rows.map(async (row) => {
171
+ permissions[row.id] = await getEffectiveFlowPermission(db, row.id, identity);
172
+ }));
173
+ return {
174
+ flowIds: rows.map((row) => row.id).filter((flowId) => permissions[flowId]),
175
+ permissions
176
+ };
177
+ }
178
+ async function getScopePath(db, scopeId) {
179
+ if (!scopeId) return [];
180
+ return (await db.query(`WITH RECURSIVE ancestors AS (
181
+ SELECT id, name, parent_id, 0 AS depth FROM rbac_teams WHERE id = ?
182
+ UNION ALL
183
+ SELECT parent.id, parent.name, parent.parent_id, current.depth + 1
184
+ FROM rbac_teams parent
185
+ INNER JOIN ancestors current ON parent.id = current.parent_id
186
+ )
187
+ SELECT id, name FROM ancestors ORDER BY depth DESC`, [scopeId])).map((row) => row.name);
188
+ }
189
+ async function listAllScopeAccessForScopeIds(db, scopeIds) {
190
+ if (scopeIds.length === 0) return [];
191
+ return (await db.query(`SELECT id, scope_id, user_id, team_id, permission, granted_by, granted_at
192
+ FROM rbac_scope_access
193
+ WHERE scope_id IN (${createInClause(scopeIds.length)})`, scopeIds)).map(normalizeScopeAccessRecord);
194
+ }
195
+ async function listAllDirectFlowAccess(db, flowId) {
196
+ const rows = await db.query("SELECT id, flow_id, user_id, team_id, permission, granted_by, granted_at, expires_at FROM flow_access WHERE flow_id = ?", [flowId]);
197
+ const now = Date.now();
198
+ return rows.map(normalizeFlowAccessRecord).filter((record) => !record.expiresAt || new Date(record.expiresAt).getTime() > now);
199
+ }
200
+ async function listAllEffectiveFlowAccessForPreview(db, flowId, overrideScopeId) {
201
+ const direct = await listAllDirectFlowAccess(db, flowId);
202
+ const scopeId = overrideScopeId === void 0 ? await getFlowScopeId(db, flowId) : overrideScopeId;
203
+ if (!scopeId) return direct;
204
+ const inherited = await listAllScopeAccessForScopeIds(db, await getAncestorScopeIds(db, scopeId));
205
+ return [...direct, ...inherited];
206
+ }
207
+ async function listAllEffectiveFlowAccessRecords(db, flowId) {
208
+ const directRecords = (await listAllDirectFlowAccess(db, flowId)).map((record) => ({
209
+ ...record,
210
+ source: "direct"
211
+ }));
212
+ const scopeId = await getFlowScopeId(db, flowId);
213
+ if (!scopeId) return directRecords;
214
+ const ancestorIds = await getAncestorScopeIds(db, scopeId);
215
+ const inheritedRows = await listAllScopeAccessForScopeIds(db, ancestorIds);
216
+ if (inheritedRows.length === 0) return directRecords;
217
+ const scopeRows = await db.query(`SELECT id, name FROM rbac_teams WHERE id IN (${createInClause(ancestorIds.length)})`, ancestorIds);
218
+ const scopeNames = new Map(scopeRows.map((row) => [row.id, row.name]));
219
+ const teamIds = [...new Set(inheritedRows.map((r) => r.teamId).filter((id) => !!id))];
220
+ const teamMembersByTeamId = /* @__PURE__ */ new Map();
221
+ if (teamIds.length > 0) {
222
+ const memberRows = await db.query(`SELECT team_id, user_id FROM rbac_team_members WHERE team_id IN (${createInClause(teamIds.length)})`, teamIds);
223
+ for (const row of memberRows) {
224
+ const members = teamMembersByTeamId.get(row.team_id) ?? [];
225
+ members.push(row.user_id);
226
+ teamMembersByTeamId.set(row.team_id, members);
227
+ }
228
+ }
229
+ const expandedInherited = [];
230
+ for (const record of inheritedRows) {
231
+ const scopeName = scopeNames.get(record.scopeId) ?? null;
232
+ if (record.teamId) {
233
+ const members = teamMembersByTeamId.get(record.teamId) ?? [];
234
+ for (const userId of members) expandedInherited.push({
235
+ id: `${record.id}:${userId}`,
236
+ flowId,
237
+ userId,
238
+ teamId: null,
239
+ permission: record.permission,
240
+ grantedBy: record.grantedBy,
241
+ grantedAt: record.grantedAt,
242
+ expiresAt: null,
243
+ source: "inherited",
244
+ scopeId: record.scopeId,
245
+ scopeName
246
+ });
247
+ } else expandedInherited.push({
248
+ id: record.id,
249
+ flowId,
250
+ userId: record.userId,
251
+ teamId: null,
252
+ permission: record.permission,
253
+ grantedBy: record.grantedBy,
254
+ grantedAt: record.grantedAt,
255
+ expiresAt: null,
256
+ source: "inherited",
257
+ scopeId: record.scopeId,
258
+ scopeName
259
+ });
260
+ }
261
+ return [...directRecords, ...expandedInherited];
262
+ }
263
+ function buildAccessKey(record) {
264
+ if (record.userId) return `user:${record.userId}`;
265
+ if (record.teamId) return `team:${record.teamId}`;
266
+ return "unknown";
267
+ }
268
+ async function resolveAccessChangeNames(db, entries) {
269
+ const userIds = Array.from(new Set(entries.map((entry) => entry.userId).filter(Boolean)));
270
+ const teamIds = Array.from(new Set(entries.map((entry) => entry.teamId).filter(Boolean)));
271
+ const [userRows, teamRows] = await Promise.all([userIds.length > 0 ? db.query(`SELECT id, name, email FROM user WHERE id IN (${createInClause(userIds.length)})`, userIds) : Promise.resolve([]), teamIds.length > 0 ? db.query(`SELECT id, name FROM rbac_teams WHERE id IN (${createInClause(teamIds.length)})`, teamIds) : Promise.resolve([])]);
272
+ const userMap = new Map(userRows.map((row) => [row.id, row.name || row.email || row.id]));
273
+ const teamMap = new Map(teamRows.map((row) => [row.id, row.name]));
274
+ return entries.map((entry) => ({
275
+ userId: entry.userId ?? void 0,
276
+ teamId: entry.teamId ?? void 0,
277
+ name: entry.userId ? userMap.get(entry.userId) ?? entry.userId : entry.teamId ? teamMap.get(entry.teamId) ?? entry.teamId : "Unknown",
278
+ permission: entry.permission,
279
+ source: entry.source
280
+ }));
281
+ }
282
+ function rbacPlugin(options = {}) {
283
+ const { useFlowAccessTable = true, adminPermission = "flow:read", enableTeams = true } = options;
284
+ const teamsSchema = enableTeams ? {
285
+ flows: { fields: { scope_id: {
286
+ type: "string",
287
+ required: false,
288
+ references: {
289
+ table: "rbac_teams",
290
+ field: "id",
291
+ onDelete: "set null"
292
+ },
293
+ index: true
294
+ } } },
295
+ rbac_teams: { fields: {
296
+ id: {
297
+ type: "string",
298
+ primaryKey: true
299
+ },
300
+ name: {
301
+ type: "string",
302
+ required: true
303
+ },
304
+ description: {
305
+ type: "text",
306
+ required: false
307
+ },
308
+ parent_id: {
309
+ type: "string",
310
+ required: false,
311
+ references: {
312
+ table: "rbac_teams",
313
+ field: "id",
314
+ onDelete: "set null"
315
+ },
316
+ index: true
317
+ },
318
+ created_by: {
319
+ type: "string",
320
+ required: false,
321
+ references: {
322
+ table: "user",
323
+ field: "id"
324
+ }
325
+ },
326
+ created_at: {
327
+ type: "date",
328
+ required: true,
329
+ defaultValue: "now()"
330
+ },
331
+ updated_at: {
332
+ type: "date",
333
+ required: false
334
+ }
335
+ } },
336
+ rbac_team_members: { fields: {
337
+ id: {
338
+ type: "string",
339
+ primaryKey: true
340
+ },
341
+ team_id: {
342
+ type: "string",
343
+ required: true,
344
+ references: {
345
+ table: "rbac_teams",
346
+ field: "id",
347
+ onDelete: "cascade"
348
+ },
349
+ index: true
350
+ },
351
+ user_id: {
352
+ type: "string",
353
+ required: true,
354
+ references: {
355
+ table: "user",
356
+ field: "id",
357
+ onDelete: "cascade"
358
+ },
359
+ index: true
360
+ },
361
+ created_at: {
362
+ type: "date",
363
+ required: true,
364
+ defaultValue: "now()"
365
+ }
366
+ } },
367
+ rbac_scope_access: { fields: {
368
+ id: {
369
+ type: "string",
370
+ primaryKey: true
371
+ },
372
+ scope_id: {
373
+ type: "string",
374
+ required: true,
375
+ references: {
376
+ table: "rbac_teams",
377
+ field: "id",
378
+ onDelete: "cascade"
379
+ },
380
+ index: true
381
+ },
382
+ user_id: {
383
+ type: "string",
384
+ required: false,
385
+ index: true
386
+ },
387
+ team_id: {
388
+ type: "string",
389
+ required: false,
390
+ index: true
391
+ },
392
+ permission: {
393
+ type: "string",
394
+ required: true,
395
+ defaultValue: "viewer",
396
+ typeAnnotation: "FlowAccessPermission"
397
+ },
398
+ granted_by: {
399
+ type: "string",
400
+ required: false
401
+ },
402
+ granted_at: {
403
+ type: "date",
404
+ required: true,
405
+ defaultValue: "now()"
406
+ }
407
+ } }
408
+ } : {};
409
+ let capturedDbApi = null;
410
+ const ui = {
411
+ sidebar: [{
412
+ label: "Access Control",
413
+ icon: "Shield",
414
+ path: "/access",
415
+ permission: adminPermission
416
+ }, ...enableTeams ? [] : []],
417
+ pages: [{
418
+ path: "/access",
419
+ componentId: "rbac.AccessControlPage",
420
+ title: "Access Control"
421
+ }, ...enableTeams ? [] : []],
422
+ panelTabs: [{
423
+ context: "flowEditor",
424
+ label: "Access",
425
+ componentId: "rbac.FlowAccessPanel",
426
+ permission: "flow:read"
427
+ }],
428
+ headerActions: [{
429
+ context: "flowHeader",
430
+ componentId: "rbac.ShareButton",
431
+ permission: "flow:update"
432
+ }]
433
+ };
434
+ return {
435
+ id: "rbac",
436
+ name: "Role-Based Access Control",
437
+ schema: teamsSchema,
438
+ requiredTables: [
439
+ "user",
440
+ "session",
441
+ ...enableTeams ? [
442
+ "rbac_teams",
443
+ "rbac_team_members",
444
+ "rbac_scope_access"
445
+ ] : []
446
+ ],
447
+ setupInstructions: "The RBAC plugin requires better-auth tables (user, session). Make sure @invect/user-auth is configured, then run `npx invect generate` followed by `npx drizzle-kit push`.",
448
+ init: async (ctx) => {
449
+ if (!ctx.hasPlugin("better-auth")) ctx.logger.warn("RBAC plugin requires the @invect/user-auth plugin. RBAC will work with reduced functionality (no session resolution). Make sure betterAuthPlugin() is registered before rbacPlugin().");
450
+ ctx.logger.info("RBAC plugin initialized", { useFlowAccessTable });
451
+ },
452
+ endpoints: [
453
+ {
454
+ method: "GET",
455
+ path: "/rbac/me",
456
+ isPublic: false,
457
+ handler: async (ctx) => {
458
+ if (!capturedDbApi && enableTeams) capturedDbApi = ctx.database;
459
+ const identity = ctx.identity;
460
+ const permissions = ctx.core.getPermissions(identity);
461
+ const resolvedRole = identity ? ctx.core.getResolvedRole(identity) : null;
462
+ return {
463
+ status: 200,
464
+ body: {
465
+ identity: identity ? {
466
+ id: identity.id,
467
+ name: identity.name,
468
+ role: identity.role,
469
+ resolvedRole
470
+ } : null,
471
+ permissions,
472
+ isAuthenticated: !!identity
473
+ }
474
+ };
475
+ }
476
+ },
477
+ {
478
+ method: "GET",
479
+ path: "/rbac/roles",
480
+ isPublic: false,
481
+ permission: "flow:read",
482
+ handler: async (ctx) => {
483
+ return {
484
+ status: 200,
485
+ body: { roles: ctx.core.getAvailableRoles() }
486
+ };
487
+ }
488
+ },
489
+ {
490
+ method: "GET",
491
+ path: "/rbac/ui-manifest",
492
+ isPublic: true,
493
+ handler: async (_ctx) => {
494
+ return {
495
+ status: 200,
496
+ body: {
497
+ id: "rbac",
498
+ ...ui
499
+ }
500
+ };
501
+ }
502
+ },
503
+ {
504
+ method: "GET",
505
+ path: "/rbac/flows/:flowId/access",
506
+ permission: "flow:read",
507
+ handler: async (ctx) => {
508
+ const flowId = ctx.params.flowId;
509
+ if (!flowId) return {
510
+ status: 400,
511
+ body: { error: "Missing flowId parameter" }
512
+ };
513
+ if (!ctx.core.isFlowAccessTableEnabled()) return {
514
+ status: 501,
515
+ body: {
516
+ error: "Not Implemented",
517
+ message: "Flow access table not enabled. Set auth.useFlowAccessTable: true in config."
518
+ }
519
+ };
520
+ if (!ctx.identity) return {
521
+ status: 401,
522
+ body: {
523
+ error: "Unauthorized",
524
+ message: "Authentication required"
525
+ }
526
+ };
527
+ if (!(ctx.core.getPermissions(ctx.identity).includes("admin:*") ? "owner" : await getEffectiveFlowPermission(ctx.database, flowId, ctx.identity))) return {
528
+ status: 403,
529
+ body: {
530
+ error: "Forbidden",
531
+ message: "No access to this flow"
532
+ }
533
+ };
534
+ return {
535
+ status: 200,
536
+ body: { access: await ctx.core.listFlowAccess(flowId) }
537
+ };
538
+ }
539
+ },
540
+ {
541
+ method: "POST",
542
+ path: "/rbac/flows/:flowId/access",
543
+ permission: "flow:read",
544
+ handler: async (ctx) => {
545
+ const flowId = ctx.params.flowId;
546
+ if (!flowId) return {
547
+ status: 400,
548
+ body: { error: "Missing flowId parameter" }
549
+ };
550
+ if (!ctx.core.isFlowAccessTableEnabled()) return {
551
+ status: 501,
552
+ body: {
553
+ error: "Not Implemented",
554
+ message: "Flow access table not enabled. Set auth.useFlowAccessTable: true in config."
555
+ }
556
+ };
557
+ const { userId, teamId, permission, expiresAt } = ctx.body;
558
+ if (!userId && !teamId) return {
559
+ status: 400,
560
+ body: { error: "Either userId or teamId must be provided" }
561
+ };
562
+ if (!ctx.identity) return {
563
+ status: 401,
564
+ body: {
565
+ error: "Unauthorized",
566
+ message: "Authentication required"
567
+ }
568
+ };
569
+ if ((ctx.core.getPermissions(ctx.identity).includes("admin:*") ? "owner" : await getEffectiveFlowPermission(ctx.database, flowId, ctx.identity)) !== "owner") return {
570
+ status: 403,
571
+ body: {
572
+ error: "Forbidden",
573
+ message: "Owner access is required to manage sharing"
574
+ }
575
+ };
576
+ if (!permission || ![
577
+ "owner",
578
+ "editor",
579
+ "operator",
580
+ "viewer"
581
+ ].includes(permission)) return {
582
+ status: 400,
583
+ body: { error: "permission must be one of: owner, editor, operator, viewer" }
584
+ };
585
+ return {
586
+ status: 201,
587
+ body: await ctx.core.grantFlowAccess({
588
+ flowId,
589
+ userId,
590
+ teamId,
591
+ permission,
592
+ grantedBy: ctx.identity?.id,
593
+ expiresAt
594
+ })
595
+ };
596
+ }
597
+ },
598
+ {
599
+ method: "DELETE",
600
+ path: "/rbac/flows/:flowId/access/:accessId",
601
+ permission: "flow:read",
602
+ handler: async (ctx) => {
603
+ const { flowId, accessId } = ctx.params;
604
+ if (!flowId || !accessId) return {
605
+ status: 400,
606
+ body: { error: "Missing flowId or accessId parameter" }
607
+ };
608
+ if (!ctx.core.isFlowAccessTableEnabled()) return {
609
+ status: 501,
610
+ body: {
611
+ error: "Not Implemented",
612
+ message: "Flow access table not enabled. Set auth.useFlowAccessTable: true in config."
613
+ }
614
+ };
615
+ if (!ctx.identity) return {
616
+ status: 401,
617
+ body: {
618
+ error: "Unauthorized",
619
+ message: "Authentication required"
620
+ }
621
+ };
622
+ if ((ctx.core.getPermissions(ctx.identity).includes("admin:*") ? "owner" : await getEffectiveFlowPermission(ctx.database, flowId, ctx.identity)) !== "owner") return {
623
+ status: 403,
624
+ body: {
625
+ error: "Forbidden",
626
+ message: "Owner access is required to manage sharing"
627
+ }
628
+ };
629
+ await ctx.core.revokeFlowAccess(accessId);
630
+ return {
631
+ status: 204,
632
+ body: null
633
+ };
634
+ }
635
+ },
636
+ {
637
+ method: "GET",
638
+ path: "/rbac/flows/accessible",
639
+ isPublic: false,
640
+ handler: async (ctx) => {
641
+ if (!ctx.core.isFlowAccessTableEnabled()) return {
642
+ status: 501,
643
+ body: {
644
+ error: "Not Implemented",
645
+ message: "Flow access table not enabled. Set auth.useFlowAccessTable: true in config."
646
+ }
647
+ };
648
+ const identity = ctx.identity;
649
+ if (!identity) return {
650
+ status: 401,
651
+ body: {
652
+ error: "Unauthorized",
653
+ message: "Authentication required"
654
+ }
655
+ };
656
+ if (ctx.core.getPermissions(identity).includes("admin:*")) return {
657
+ status: 200,
658
+ body: {
659
+ flowIds: [],
660
+ permissions: {},
661
+ isAdmin: true
662
+ }
663
+ };
664
+ const { flowIds, permissions } = await getCurrentUserAccessibleFlows(ctx.database, identity);
665
+ return {
666
+ status: 200,
667
+ body: {
668
+ flowIds,
669
+ permissions,
670
+ isAdmin: false
671
+ }
672
+ };
673
+ }
674
+ },
675
+ {
676
+ method: "GET",
677
+ path: "/rbac/flows/:flowId/effective-access",
678
+ permission: "flow:read",
679
+ handler: async (ctx) => {
680
+ const flowId = ctx.params.flowId;
681
+ if (!flowId) return {
682
+ status: 400,
683
+ body: { error: "Missing flowId parameter" }
684
+ };
685
+ if (!ctx.identity) return {
686
+ status: 401,
687
+ body: {
688
+ error: "Unauthorized",
689
+ message: "Authentication required"
690
+ }
691
+ };
692
+ if (!(ctx.core.getPermissions(ctx.identity).includes("admin:*") ? "owner" : await getEffectiveFlowPermission(ctx.database, flowId, ctx.identity))) return {
693
+ status: 403,
694
+ body: {
695
+ error: "Forbidden",
696
+ message: "No access to this flow"
697
+ }
698
+ };
699
+ const records = await listAllEffectiveFlowAccessRecords(ctx.database, flowId);
700
+ return {
701
+ status: 200,
702
+ body: {
703
+ flowId,
704
+ scopeId: await getFlowScopeId(ctx.database, flowId),
705
+ records
706
+ }
707
+ };
708
+ }
709
+ },
710
+ {
711
+ method: "PUT",
712
+ path: "/rbac/flows/:flowId/scope",
713
+ permission: "flow:update",
714
+ handler: async (ctx) => {
715
+ const flowId = ctx.params.flowId;
716
+ const { scopeId } = ctx.body;
717
+ if (!flowId) return {
718
+ status: 400,
719
+ body: { error: "Missing flowId parameter" }
720
+ };
721
+ if (!ctx.identity) return {
722
+ status: 401,
723
+ body: {
724
+ error: "Unauthorized",
725
+ message: "Authentication required"
726
+ }
727
+ };
728
+ if ((ctx.core.getPermissions(ctx.identity).includes("admin:*") ? "owner" : await getEffectiveFlowPermission(ctx.database, flowId, ctx.identity)) !== "owner") return {
729
+ status: 403,
730
+ body: {
731
+ error: "Forbidden",
732
+ message: "Owner access is required to move flows"
733
+ }
734
+ };
735
+ if (scopeId) {
736
+ if ((await ctx.database.query("SELECT id FROM rbac_teams WHERE id = ?", [scopeId])).length === 0) return {
737
+ status: 404,
738
+ body: { error: "Scope not found" }
739
+ };
740
+ }
741
+ await ctx.database.execute("UPDATE flows SET scope_id = ? WHERE id = ?", [scopeId ?? null, flowId]);
742
+ return {
743
+ status: 200,
744
+ body: {
745
+ success: true,
746
+ flowId,
747
+ scopeId: scopeId ?? null
748
+ }
749
+ };
750
+ }
751
+ },
752
+ ...enableTeams ? [
753
+ {
754
+ method: "GET",
755
+ path: "/rbac/scopes/tree",
756
+ isPublic: false,
757
+ handler: async (ctx) => {
758
+ if (!ctx.identity) return {
759
+ status: 401,
760
+ body: { error: "Unauthorized" }
761
+ };
762
+ const [teamRows, flowRows, memberRows, accessRows, teamRoleRows] = await Promise.all([
763
+ ctx.database.query("SELECT id, name, description, parent_id, created_by, created_at, updated_at FROM rbac_teams ORDER BY name"),
764
+ ctx.database.query("SELECT id, name, scope_id FROM flows ORDER BY name"),
765
+ ctx.database.query("SELECT team_id, COUNT(*) AS member_count FROM rbac_team_members GROUP BY team_id"),
766
+ ctx.database.query("SELECT scope_id, COUNT(*) AS access_count FROM rbac_scope_access GROUP BY scope_id"),
767
+ ctx.database.query("SELECT scope_id, permission FROM rbac_scope_access WHERE team_id = scope_id AND team_id IS NOT NULL")
768
+ ]);
769
+ const memberCounts = new Map(memberRows.map((row) => [row.team_id, Number(row.member_count)]));
770
+ const accessCounts = new Map(accessRows.map((row) => [row.scope_id, Number(row.access_count)]));
771
+ const teamPermissions = new Map(teamRoleRows.map((row) => [row.scope_id, row.permission]));
772
+ const nodeMap = /* @__PURE__ */ new Map();
773
+ for (const row of teamRows) nodeMap.set(row.id, {
774
+ ...normalizeTeamRow(row),
775
+ children: [],
776
+ flows: [],
777
+ directAccessCount: accessCounts.get(row.id) ?? 0,
778
+ memberCount: memberCounts.get(row.id) ?? 0,
779
+ teamPermission: teamPermissions.get(row.id) ?? null
780
+ });
781
+ const roots = [];
782
+ for (const node of nodeMap.values()) if (node.parentId && nodeMap.has(node.parentId)) nodeMap.get(node.parentId)?.children.push(node);
783
+ else roots.push(node);
784
+ const unscopedFlows = [];
785
+ for (const flow of flowRows) {
786
+ const mapped = {
787
+ id: flow.id,
788
+ name: flow.name,
789
+ scopeId: flow.scope_id
790
+ };
791
+ if (flow.scope_id && nodeMap.has(flow.scope_id)) nodeMap.get(flow.scope_id)?.flows.push(mapped);
792
+ else unscopedFlows.push(mapped);
793
+ }
794
+ return {
795
+ status: 200,
796
+ body: {
797
+ scopes: roots,
798
+ unscopedFlows
799
+ }
800
+ };
801
+ }
802
+ },
803
+ {
804
+ method: "GET",
805
+ path: "/rbac/scopes/:scopeId/access",
806
+ isPublic: false,
807
+ handler: async (ctx) => {
808
+ if (!ctx.identity) return {
809
+ status: 401,
810
+ body: { error: "Unauthorized" }
811
+ };
812
+ return {
813
+ status: 200,
814
+ body: { access: (await ctx.database.query("SELECT id, scope_id, user_id, team_id, permission, granted_by, granted_at FROM rbac_scope_access WHERE scope_id = ?", [ctx.params.scopeId])).map(normalizeScopeAccessRecord) }
815
+ };
816
+ }
817
+ },
818
+ {
819
+ method: "POST",
820
+ path: "/rbac/scopes/:scopeId/access",
821
+ isPublic: false,
822
+ handler: async (ctx) => {
823
+ if (!ctx.identity) return {
824
+ status: 401,
825
+ body: { error: "Unauthorized" }
826
+ };
827
+ if (!ctx.core.getPermissions(ctx.identity).includes("admin:*")) return {
828
+ status: 403,
829
+ body: {
830
+ error: "Forbidden",
831
+ message: "Admin access required"
832
+ }
833
+ };
834
+ const { scopeId } = ctx.params;
835
+ const { userId, teamId, permission } = ctx.body;
836
+ if (!scopeId) return {
837
+ status: 400,
838
+ body: { error: "Missing scopeId parameter" }
839
+ };
840
+ if (!userId && !teamId) return {
841
+ status: 400,
842
+ body: { error: "Either userId or teamId must be provided" }
843
+ };
844
+ if (userId && teamId) return {
845
+ status: 400,
846
+ body: { error: "Provide either userId or teamId, not both" }
847
+ };
848
+ if (teamId && teamId !== scopeId) return {
849
+ status: 400,
850
+ body: { error: "Teams can only hold a role on their own scope" }
851
+ };
852
+ if (!isFlowAccessPermission(permission)) return {
853
+ status: 400,
854
+ body: { error: "permission must be one of: owner, editor, operator, viewer" }
855
+ };
856
+ const existing = await ctx.database.query("SELECT id FROM rbac_scope_access WHERE scope_id = ? AND user_id IS ? AND team_id IS ?", [
857
+ scopeId,
858
+ userId ?? null,
859
+ teamId ?? null
860
+ ]);
861
+ const now = (/* @__PURE__ */ new Date()).toISOString();
862
+ if (existing[0]) {
863
+ await ctx.database.execute("UPDATE rbac_scope_access SET permission = ?, granted_by = ?, granted_at = ? WHERE id = ?", [
864
+ permission,
865
+ ctx.identity.id,
866
+ now,
867
+ existing[0].id
868
+ ]);
869
+ return {
870
+ status: 200,
871
+ body: {
872
+ id: existing[0].id,
873
+ scopeId,
874
+ userId: userId ?? null,
875
+ teamId: teamId ?? null,
876
+ permission,
877
+ grantedBy: ctx.identity.id,
878
+ grantedAt: now
879
+ }
880
+ };
881
+ }
882
+ const id = crypto.randomUUID();
883
+ await ctx.database.execute("INSERT INTO rbac_scope_access (id, scope_id, user_id, team_id, permission, granted_by, granted_at) VALUES (?, ?, ?, ?, ?, ?, ?)", [
884
+ id,
885
+ scopeId,
886
+ userId ?? null,
887
+ teamId ?? null,
888
+ permission,
889
+ ctx.identity.id,
890
+ now
891
+ ]);
892
+ return {
893
+ status: 201,
894
+ body: {
895
+ id,
896
+ scopeId,
897
+ userId: userId ?? null,
898
+ teamId: teamId ?? null,
899
+ permission,
900
+ grantedBy: ctx.identity.id,
901
+ grantedAt: now
902
+ }
903
+ };
904
+ }
905
+ },
906
+ {
907
+ method: "DELETE",
908
+ path: "/rbac/scopes/:scopeId/access/:accessId",
909
+ isPublic: false,
910
+ handler: async (ctx) => {
911
+ if (!ctx.identity) return {
912
+ status: 401,
913
+ body: { error: "Unauthorized" }
914
+ };
915
+ if (!ctx.core.getPermissions(ctx.identity).includes("admin:*")) return {
916
+ status: 403,
917
+ body: {
918
+ error: "Forbidden",
919
+ message: "Admin access required"
920
+ }
921
+ };
922
+ await ctx.database.execute("DELETE FROM rbac_scope_access WHERE id = ?", [ctx.params.accessId]);
923
+ return {
924
+ status: 204,
925
+ body: null
926
+ };
927
+ }
928
+ },
929
+ {
930
+ method: "POST",
931
+ path: "/rbac/preview-move",
932
+ isPublic: false,
933
+ handler: async (ctx) => {
934
+ if (!ctx.identity) return {
935
+ status: 401,
936
+ body: { error: "Unauthorized" }
937
+ };
938
+ if (!ctx.core.getPermissions(ctx.identity).includes("admin:*")) return {
939
+ status: 403,
940
+ body: {
941
+ error: "Forbidden",
942
+ message: "Admin access required"
943
+ }
944
+ };
945
+ const { type, id, targetScopeId } = ctx.body;
946
+ if (!type || !id) return {
947
+ status: 400,
948
+ body: { error: "type and id are required" }
949
+ };
950
+ let affectedFlowIds = [];
951
+ let itemName = id;
952
+ if (type === "flow") {
953
+ const rows = await ctx.database.query("SELECT id, name FROM flows WHERE id = ?", [id]);
954
+ if (!rows[0]) return {
955
+ status: 404,
956
+ body: { error: "Flow not found" }
957
+ };
958
+ itemName = rows[0].name;
959
+ affectedFlowIds = [id];
960
+ } else {
961
+ const scopeRows = await ctx.database.query("SELECT id, name FROM rbac_teams WHERE id = ?", [id]);
962
+ if (!scopeRows[0]) return {
963
+ status: 404,
964
+ body: { error: "Scope not found" }
965
+ };
966
+ itemName = scopeRows[0].name;
967
+ const descendantScopeIds = await getDescendantScopeIds(ctx.database, id);
968
+ if (targetScopeId && descendantScopeIds.includes(targetScopeId)) return {
969
+ status: 400,
970
+ body: { error: "Cannot move a scope into itself or its descendant" }
971
+ };
972
+ affectedFlowIds = (await ctx.database.query(`SELECT id FROM flows WHERE scope_id IN (${createInClause(descendantScopeIds.length)})`, descendantScopeIds)).map((row) => row.id);
973
+ }
974
+ const targetPath = await getScopePath(ctx.database, targetScopeId ?? null);
975
+ const targetAncestorIds = targetScopeId ? await getAncestorScopeIds(ctx.database, targetScopeId) : [];
976
+ const targetScopeAccess = await listAllScopeAccessForScopeIds(ctx.database, targetAncestorIds);
977
+ const gainedEntries = /* @__PURE__ */ new Map();
978
+ let unchanged = 0;
979
+ for (const flowId of affectedFlowIds) {
980
+ const currentRecords = await listAllEffectiveFlowAccessForPreview(ctx.database, flowId);
981
+ const currentPermissions = /* @__PURE__ */ new Map();
982
+ for (const record of currentRecords) {
983
+ const permission = toFlowAccessPermission(record.permission);
984
+ if (!permission) continue;
985
+ const key = buildAccessKey(record);
986
+ currentPermissions.set(key, getHigherPermission(currentPermissions.get(key) ?? null, permission) ?? permission);
987
+ }
988
+ for (const record of targetScopeAccess) {
989
+ const key = buildAccessKey(record);
990
+ const existingPermission = currentPermissions.get(key) ?? null;
991
+ if (existingPermission && FLOW_PERMISSION_LEVELS[existingPermission] >= FLOW_PERMISSION_LEVELS[record.permission]) {
992
+ unchanged += 1;
993
+ continue;
994
+ }
995
+ gainedEntries.set(key, {
996
+ userId: record.userId,
997
+ teamId: record.teamId,
998
+ permission: record.permission,
999
+ source: `from ${targetPath.at(-1) ?? "root"}`
1000
+ });
1001
+ }
1002
+ }
1003
+ const gained = await resolveAccessChangeNames(ctx.database, Array.from(gainedEntries.values()));
1004
+ return {
1005
+ status: 200,
1006
+ body: {
1007
+ item: {
1008
+ id,
1009
+ name: itemName,
1010
+ type
1011
+ },
1012
+ target: {
1013
+ id: targetScopeId ?? null,
1014
+ name: targetPath.at(-1) ?? "Unscoped",
1015
+ path: targetPath
1016
+ },
1017
+ affectedFlows: affectedFlowIds.length,
1018
+ accessChanges: {
1019
+ gained,
1020
+ unchanged
1021
+ }
1022
+ }
1023
+ };
1024
+ }
1025
+ },
1026
+ {
1027
+ method: "GET",
1028
+ path: "/rbac/teams",
1029
+ isPublic: false,
1030
+ handler: async (ctx) => {
1031
+ if (!ctx.identity) return {
1032
+ status: 401,
1033
+ body: { error: "Unauthorized" }
1034
+ };
1035
+ return {
1036
+ status: 200,
1037
+ body: { teams: (await ctx.database.query("SELECT id, name, description, parent_id, created_by, created_at, updated_at FROM rbac_teams ORDER BY name")).map((r) => normalizeTeamRow(r)) }
1038
+ };
1039
+ }
1040
+ },
1041
+ {
1042
+ method: "POST",
1043
+ path: "/rbac/teams",
1044
+ isPublic: false,
1045
+ handler: async (ctx) => {
1046
+ if (!ctx.identity) return {
1047
+ status: 401,
1048
+ body: { error: "Unauthorized" }
1049
+ };
1050
+ if (!ctx.core.getPermissions(ctx.identity).includes("admin:*")) return {
1051
+ status: 403,
1052
+ body: {
1053
+ error: "Forbidden",
1054
+ message: "Admin access required"
1055
+ }
1056
+ };
1057
+ const { name, description, parentId } = ctx.body;
1058
+ if (!name?.trim()) return {
1059
+ status: 400,
1060
+ body: { error: "Team name is required" }
1061
+ };
1062
+ if (parentId) {
1063
+ if ((await ctx.database.query("SELECT id FROM rbac_teams WHERE id = ?", [parentId])).length === 0) return {
1064
+ status: 404,
1065
+ body: { error: "Parent scope not found" }
1066
+ };
1067
+ }
1068
+ const id = crypto.randomUUID();
1069
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1070
+ await ctx.database.execute("INSERT INTO rbac_teams (id, name, description, parent_id, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)", [
1071
+ id,
1072
+ name.trim(),
1073
+ description?.trim() || null,
1074
+ parentId ?? null,
1075
+ ctx.identity.id,
1076
+ now,
1077
+ now
1078
+ ]);
1079
+ return {
1080
+ status: 201,
1081
+ body: {
1082
+ id,
1083
+ name: name.trim(),
1084
+ description: description?.trim() || null,
1085
+ parentId: parentId ?? null,
1086
+ createdBy: ctx.identity.id,
1087
+ createdAt: now,
1088
+ updatedAt: now
1089
+ }
1090
+ };
1091
+ }
1092
+ },
1093
+ {
1094
+ method: "PUT",
1095
+ path: "/rbac/teams/:teamId",
1096
+ isPublic: false,
1097
+ handler: async (ctx) => {
1098
+ if (!ctx.identity) return {
1099
+ status: 401,
1100
+ body: { error: "Unauthorized" }
1101
+ };
1102
+ if (!ctx.core.getPermissions(ctx.identity).includes("admin:*")) return {
1103
+ status: 403,
1104
+ body: {
1105
+ error: "Forbidden",
1106
+ message: "Admin access required"
1107
+ }
1108
+ };
1109
+ const { teamId } = ctx.params;
1110
+ const { name, description, parentId } = ctx.body;
1111
+ if ((await ctx.database.query("SELECT id FROM rbac_teams WHERE id = ?", [teamId])).length === 0) return {
1112
+ status: 404,
1113
+ body: { error: "Team not found" }
1114
+ };
1115
+ if (parentId === teamId) return {
1116
+ status: 400,
1117
+ body: { error: "A scope cannot be its own parent" }
1118
+ };
1119
+ if (parentId) {
1120
+ if ((await ctx.database.query("SELECT id FROM rbac_teams WHERE id = ?", [parentId])).length === 0) return {
1121
+ status: 404,
1122
+ body: { error: "Parent scope not found" }
1123
+ };
1124
+ if ((await getDescendantScopeIds(ctx.database, teamId)).includes(parentId)) return {
1125
+ status: 400,
1126
+ body: { error: "Cannot move a scope into itself or its descendant" }
1127
+ };
1128
+ }
1129
+ const updates = [];
1130
+ const values = [];
1131
+ if (name !== void 0) {
1132
+ if (!name.trim()) return {
1133
+ status: 400,
1134
+ body: { error: "Team name cannot be empty" }
1135
+ };
1136
+ updates.push("name = ?");
1137
+ values.push(name.trim());
1138
+ }
1139
+ if (description !== void 0) {
1140
+ updates.push("description = ?");
1141
+ values.push(description?.trim() || null);
1142
+ }
1143
+ if (parentId !== void 0) {
1144
+ updates.push("parent_id = ?");
1145
+ values.push(parentId ?? null);
1146
+ }
1147
+ if (updates.length === 0) return {
1148
+ status: 400,
1149
+ body: { error: "No fields to update" }
1150
+ };
1151
+ updates.push("updated_at = ?");
1152
+ values.push((/* @__PURE__ */ new Date()).toISOString());
1153
+ values.push(teamId);
1154
+ await ctx.database.execute(`UPDATE rbac_teams SET ${updates.join(", ")} WHERE id = ?`, values);
1155
+ return {
1156
+ status: 200,
1157
+ body: { success: true }
1158
+ };
1159
+ }
1160
+ },
1161
+ {
1162
+ method: "DELETE",
1163
+ path: "/rbac/teams/:teamId",
1164
+ isPublic: false,
1165
+ handler: async (ctx) => {
1166
+ if (!ctx.identity) return {
1167
+ status: 401,
1168
+ body: { error: "Unauthorized" }
1169
+ };
1170
+ if (!ctx.core.getPermissions(ctx.identity).includes("admin:*")) return {
1171
+ status: 403,
1172
+ body: {
1173
+ error: "Forbidden",
1174
+ message: "Admin access required"
1175
+ }
1176
+ };
1177
+ const { teamId } = ctx.params;
1178
+ const teams = await ctx.database.query("SELECT id, parent_id FROM rbac_teams WHERE id = ?", [teamId]);
1179
+ if (teams.length === 0) return {
1180
+ status: 404,
1181
+ body: { error: "Team not found" }
1182
+ };
1183
+ const parentId = teams[0].parent_id ?? null;
1184
+ await ctx.database.execute("UPDATE flows SET scope_id = ? WHERE scope_id = ?", [parentId, teamId]);
1185
+ await ctx.database.execute("DELETE FROM rbac_teams WHERE id = ?", [teamId]);
1186
+ return {
1187
+ status: 204,
1188
+ body: null
1189
+ };
1190
+ }
1191
+ },
1192
+ {
1193
+ method: "GET",
1194
+ path: "/rbac/teams/:teamId",
1195
+ isPublic: false,
1196
+ handler: async (ctx) => {
1197
+ if (!ctx.identity) return {
1198
+ status: 401,
1199
+ body: { error: "Unauthorized" }
1200
+ };
1201
+ const { teamId } = ctx.params;
1202
+ const teams = await ctx.database.query("SELECT id, name, description, parent_id, created_by, created_at, updated_at FROM rbac_teams WHERE id = ?", [teamId]);
1203
+ if (teams.length === 0) return {
1204
+ status: 404,
1205
+ body: { error: "Team not found" }
1206
+ };
1207
+ const members = await ctx.database.query("SELECT id, team_id, user_id, created_at FROM rbac_team_members WHERE team_id = ?", [teamId]);
1208
+ const team = teams[0];
1209
+ return {
1210
+ status: 200,
1211
+ body: {
1212
+ ...normalizeTeamRow(team),
1213
+ members: members.map((m) => ({
1214
+ id: m.id,
1215
+ teamId: m.team_id,
1216
+ userId: m.user_id,
1217
+ createdAt: m.created_at
1218
+ }))
1219
+ }
1220
+ };
1221
+ }
1222
+ },
1223
+ {
1224
+ method: "POST",
1225
+ path: "/rbac/teams/:teamId/members",
1226
+ isPublic: false,
1227
+ handler: async (ctx) => {
1228
+ if (!ctx.identity) return {
1229
+ status: 401,
1230
+ body: { error: "Unauthorized" }
1231
+ };
1232
+ if (!ctx.core.getPermissions(ctx.identity).includes("admin:*")) return {
1233
+ status: 403,
1234
+ body: {
1235
+ error: "Forbidden",
1236
+ message: "Admin access required"
1237
+ }
1238
+ };
1239
+ const { teamId } = ctx.params;
1240
+ const { userId } = ctx.body;
1241
+ if (!userId?.trim()) return {
1242
+ status: 400,
1243
+ body: { error: "userId is required" }
1244
+ };
1245
+ if ((await ctx.database.query("SELECT id FROM rbac_teams WHERE id = ?", [teamId])).length === 0) return {
1246
+ status: 404,
1247
+ body: { error: "Team not found" }
1248
+ };
1249
+ if ((await ctx.database.query("SELECT id FROM rbac_team_members WHERE team_id = ? AND user_id = ?", [teamId, userId.trim()])).length > 0) return {
1250
+ status: 409,
1251
+ body: { error: "User is already a member of this team" }
1252
+ };
1253
+ const id = crypto.randomUUID();
1254
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1255
+ await ctx.database.execute("INSERT INTO rbac_team_members (id, team_id, user_id, created_at) VALUES (?, ?, ?, ?)", [
1256
+ id,
1257
+ teamId,
1258
+ userId.trim(),
1259
+ now
1260
+ ]);
1261
+ return {
1262
+ status: 201,
1263
+ body: {
1264
+ id,
1265
+ teamId,
1266
+ userId: userId.trim(),
1267
+ createdAt: now
1268
+ }
1269
+ };
1270
+ }
1271
+ },
1272
+ {
1273
+ method: "DELETE",
1274
+ path: "/rbac/teams/:teamId/members/:userId",
1275
+ isPublic: false,
1276
+ handler: async (ctx) => {
1277
+ if (!ctx.identity) return {
1278
+ status: 401,
1279
+ body: { error: "Unauthorized" }
1280
+ };
1281
+ if (!ctx.core.getPermissions(ctx.identity).includes("admin:*")) return {
1282
+ status: 403,
1283
+ body: {
1284
+ error: "Forbidden",
1285
+ message: "Admin access required"
1286
+ }
1287
+ };
1288
+ const { teamId, userId } = ctx.params;
1289
+ await ctx.database.execute("DELETE FROM rbac_team_members WHERE team_id = ? AND user_id = ?", [teamId, userId]);
1290
+ return {
1291
+ status: 204,
1292
+ body: null
1293
+ };
1294
+ }
1295
+ },
1296
+ {
1297
+ method: "GET",
1298
+ path: "/rbac/my-teams",
1299
+ isPublic: false,
1300
+ handler: async (ctx) => {
1301
+ if (!ctx.identity) return {
1302
+ status: 401,
1303
+ body: { error: "Unauthorized" }
1304
+ };
1305
+ return {
1306
+ status: 200,
1307
+ body: { teams: (await ctx.database.query("SELECT t.id, t.name, t.description, t.parent_id, t.created_by, t.created_at, t.updated_at FROM rbac_teams t INNER JOIN rbac_team_members tm ON t.id = tm.team_id WHERE tm.user_id = ? ORDER BY t.name", [ctx.identity.id])).map((r) => normalizeTeamRow(r)) }
1308
+ };
1309
+ }
1310
+ }
1311
+ ] : []
1312
+ ],
1313
+ hooks: {
1314
+ onRequest: enableTeams ? async (_request, context) => {
1315
+ if (!context.identity || !capturedDbApi) return;
1316
+ if (context.identity.teamIds && context.identity.teamIds.length > 0) return;
1317
+ try {
1318
+ const rows = await capturedDbApi.query("SELECT team_id FROM rbac_team_members WHERE user_id = ?", [context.identity.id]);
1319
+ if (rows.length > 0) context.identity = {
1320
+ ...context.identity,
1321
+ teamIds: rows.map((r) => r.team_id)
1322
+ };
1323
+ } catch {}
1324
+ } : void 0,
1325
+ onAuthorize: async (context) => {
1326
+ if (!useFlowAccessTable) return;
1327
+ const { identity, resource, action } = context;
1328
+ if (!identity || !resource?.id) return;
1329
+ if (!FLOW_RESOURCE_TYPES.has(resource.type)) return;
1330
+ if (identity.permissions?.includes("admin:*") || identity.role === "admin") return { allowed: true };
1331
+ const database = context.database ?? capturedDbApi;
1332
+ if (!database) return;
1333
+ const effectivePermission = await getEffectiveFlowPermission(database, resource.id, identity);
1334
+ if (!effectivePermission) return { allowed: false };
1335
+ const requiredPermission = mapActionToRequiredPermission(action);
1336
+ return { allowed: FLOW_PERMISSION_LEVELS[effectivePermission] >= FLOW_PERMISSION_LEVELS[requiredPermission] };
1337
+ },
1338
+ afterFlowRun: async (_context) => {}
1339
+ },
1340
+ $ERROR_CODES: {
1341
+ "rbac:no_access": {
1342
+ message: "You do not have access to this flow.",
1343
+ status: 403
1344
+ },
1345
+ "rbac:insufficient_permission": {
1346
+ message: "Your access level is insufficient for this operation.",
1347
+ status: 403
1348
+ },
1349
+ "rbac:auth_required": {
1350
+ message: "Authentication is required. Please sign in.",
1351
+ status: 401
1352
+ },
1353
+ "rbac:plugin_missing": {
1354
+ message: "The RBAC plugin requires the @invect/user-auth plugin.",
1355
+ status: 500
1356
+ }
1357
+ }
1358
+ };
1359
+ }
1360
+ //#endregion
1361
+ export { rbacPlugin, resolveTeamIds };
1362
+
1363
+ //# sourceMappingURL=index.mjs.map