@donkeylabs/cli 2.0.15 → 2.0.16

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 (84) hide show
  1. package/package.json +1 -1
  2. package/src/commands/config.ts +610 -0
  3. package/src/commands/deploy-enhanced.ts +354 -0
  4. package/src/commands/deploy.ts +204 -0
  5. package/src/commands/init-enhanced.ts +1994 -0
  6. package/src/deployment/manager.ts +356 -0
  7. package/src/index.ts +47 -19
  8. package/templates/starter/.env.example +0 -44
  9. package/templates/starter/.gitignore.template +0 -4
  10. package/templates/starter/donkeylabs.config.ts +0 -6
  11. package/templates/starter/package.json +0 -21
  12. package/templates/starter/src/index.ts +0 -54
  13. package/templates/starter/src/plugins/stats/index.ts +0 -105
  14. package/templates/starter/src/routes/health/handlers/ping.ts +0 -22
  15. package/templates/starter/src/routes/health/index.ts +0 -19
  16. package/templates/starter/tsconfig.json +0 -27
  17. package/templates/sveltekit-app/.env.example +0 -59
  18. package/templates/sveltekit-app/README.md +0 -103
  19. package/templates/sveltekit-app/bun.lock +0 -683
  20. package/templates/sveltekit-app/donkeylabs.config.ts +0 -12
  21. package/templates/sveltekit-app/package.json +0 -38
  22. package/templates/sveltekit-app/src/app.css +0 -40
  23. package/templates/sveltekit-app/src/app.html +0 -12
  24. package/templates/sveltekit-app/src/hooks.server.ts +0 -4
  25. package/templates/sveltekit-app/src/lib/components/ui/badge/badge.svelte +0 -30
  26. package/templates/sveltekit-app/src/lib/components/ui/badge/index.ts +0 -3
  27. package/templates/sveltekit-app/src/lib/components/ui/button/button.svelte +0 -48
  28. package/templates/sveltekit-app/src/lib/components/ui/button/index.ts +0 -9
  29. package/templates/sveltekit-app/src/lib/components/ui/card/card-content.svelte +0 -18
  30. package/templates/sveltekit-app/src/lib/components/ui/card/card-description.svelte +0 -18
  31. package/templates/sveltekit-app/src/lib/components/ui/card/card-footer.svelte +0 -18
  32. package/templates/sveltekit-app/src/lib/components/ui/card/card-header.svelte +0 -18
  33. package/templates/sveltekit-app/src/lib/components/ui/card/card-title.svelte +0 -18
  34. package/templates/sveltekit-app/src/lib/components/ui/card/card.svelte +0 -21
  35. package/templates/sveltekit-app/src/lib/components/ui/card/index.ts +0 -21
  36. package/templates/sveltekit-app/src/lib/components/ui/index.ts +0 -4
  37. package/templates/sveltekit-app/src/lib/components/ui/input/index.ts +0 -2
  38. package/templates/sveltekit-app/src/lib/components/ui/input/input.svelte +0 -20
  39. package/templates/sveltekit-app/src/lib/permissions.ts +0 -213
  40. package/templates/sveltekit-app/src/lib/utils/index.ts +0 -6
  41. package/templates/sveltekit-app/src/routes/+layout.svelte +0 -8
  42. package/templates/sveltekit-app/src/routes/+page.server.ts +0 -25
  43. package/templates/sveltekit-app/src/routes/+page.svelte +0 -680
  44. package/templates/sveltekit-app/src/routes/workflows/+page.server.ts +0 -23
  45. package/templates/sveltekit-app/src/routes/workflows/+page.svelte +0 -522
  46. package/templates/sveltekit-app/src/server/events.ts +0 -28
  47. package/templates/sveltekit-app/src/server/index.ts +0 -124
  48. package/templates/sveltekit-app/src/server/plugins/auth/auth.test.ts +0 -377
  49. package/templates/sveltekit-app/src/server/plugins/auth/index.ts +0 -815
  50. package/templates/sveltekit-app/src/server/plugins/auth/migrations/001_create_users.ts +0 -25
  51. package/templates/sveltekit-app/src/server/plugins/auth/migrations/002_create_sessions.ts +0 -32
  52. package/templates/sveltekit-app/src/server/plugins/auth/migrations/003_create_refresh_tokens.ts +0 -33
  53. package/templates/sveltekit-app/src/server/plugins/auth/migrations/004_create_passkeys.ts +0 -60
  54. package/templates/sveltekit-app/src/server/plugins/auth/schema.ts +0 -65
  55. package/templates/sveltekit-app/src/server/plugins/demo/index.ts +0 -262
  56. package/templates/sveltekit-app/src/server/plugins/email/email.test.ts +0 -369
  57. package/templates/sveltekit-app/src/server/plugins/email/index.ts +0 -411
  58. package/templates/sveltekit-app/src/server/plugins/email/migrations/001_create_email_tokens.ts +0 -33
  59. package/templates/sveltekit-app/src/server/plugins/email/schema.ts +0 -24
  60. package/templates/sveltekit-app/src/server/plugins/permissions/index.ts +0 -1048
  61. package/templates/sveltekit-app/src/server/plugins/permissions/migrations/001_create_tenants.ts +0 -63
  62. package/templates/sveltekit-app/src/server/plugins/permissions/migrations/002_create_roles.ts +0 -90
  63. package/templates/sveltekit-app/src/server/plugins/permissions/migrations/003_create_resource_grants.ts +0 -50
  64. package/templates/sveltekit-app/src/server/plugins/permissions/permissions.test.ts +0 -566
  65. package/templates/sveltekit-app/src/server/plugins/permissions/schema.ts +0 -67
  66. package/templates/sveltekit-app/src/server/plugins/workflow-demo/index.ts +0 -198
  67. package/templates/sveltekit-app/src/server/routes/auth/auth.schemas.ts +0 -66
  68. package/templates/sveltekit-app/src/server/routes/auth/handlers/login.handler.ts +0 -18
  69. package/templates/sveltekit-app/src/server/routes/auth/handlers/logout.handler.ts +0 -16
  70. package/templates/sveltekit-app/src/server/routes/auth/handlers/me.handler.ts +0 -20
  71. package/templates/sveltekit-app/src/server/routes/auth/handlers/refresh.handler.ts +0 -17
  72. package/templates/sveltekit-app/src/server/routes/auth/handlers/register.handler.ts +0 -19
  73. package/templates/sveltekit-app/src/server/routes/auth/handlers/update-profile.handler.ts +0 -21
  74. package/templates/sveltekit-app/src/server/routes/auth/index.ts +0 -73
  75. package/templates/sveltekit-app/src/server/routes/demo.ts +0 -464
  76. package/templates/sveltekit-app/src/server/routes/example/example.schemas.ts +0 -22
  77. package/templates/sveltekit-app/src/server/routes/example/handlers/greet.handler.ts +0 -21
  78. package/templates/sveltekit-app/src/server/routes/example/index.ts +0 -28
  79. package/templates/sveltekit-app/src/server/routes/permissions/index.ts +0 -248
  80. package/templates/sveltekit-app/src/server/routes/tenants/index.ts +0 -339
  81. package/templates/sveltekit-app/static/robots.txt +0 -3
  82. package/templates/sveltekit-app/svelte.config.ts +0 -17
  83. package/templates/sveltekit-app/tsconfig.json +0 -20
  84. package/templates/sveltekit-app/vite.config.ts +0 -12
@@ -1,1048 +0,0 @@
1
- /**
2
- * Permissions Plugin - Multi-tenant RBAC with resource-level grants
3
- *
4
- * Features:
5
- * - Multi-tenant isolation
6
- * - Role-based access control (RBAC) with inheritance
7
- * - Resource-level grants (user or role can access specific resources)
8
- * - Owner-based access (resource creators have full access)
9
- * - Middleware for route protection
10
- * - Client-side permission checking for UI
11
- */
12
-
13
- import { createPlugin, createMiddleware } from "@donkeylabs/server";
14
- import type { ColumnType } from "kysely";
15
-
16
- // =============================================================================
17
- // CONFIGURATION TYPES
18
- // =============================================================================
19
-
20
- export type ResourceAction = "create" | "read" | "write" | "delete" | "admin";
21
-
22
- export interface PermissionsConfig<
23
- TPermissions extends Record<string, readonly string[]> = Record<string, readonly string[]>
24
- > {
25
- /**
26
- * Define available permissions for autocomplete
27
- * @example { documents: ["create", "read", "write"], users: ["invite", "manage"] }
28
- */
29
- permissions: TPermissions;
30
-
31
- /**
32
- * Default roles created for new tenants
33
- */
34
- defaultRoles?: Array<{
35
- name: string;
36
- permissions: string[];
37
- isDefault?: boolean; // Auto-assign to new members
38
- }>;
39
-
40
- /**
41
- * How to resolve tenant context
42
- * @default "header"
43
- */
44
- tenantResolver?: "header" | "subdomain" | "path";
45
-
46
- /**
47
- * Header name for tenant ID
48
- * @default "x-tenant-id"
49
- */
50
- tenantHeader?: string;
51
- }
52
-
53
- // =============================================================================
54
- // DATABASE SCHEMA
55
- // =============================================================================
56
-
57
- interface TenantsTable {
58
- id: string;
59
- name: string;
60
- slug: string;
61
- settings: string | null; // JSON
62
- created_at: ColumnType<string, string | undefined, never>;
63
- updated_at: string;
64
- }
65
-
66
- interface TenantMembersTable {
67
- id: string;
68
- tenant_id: string;
69
- user_id: string;
70
- created_at: ColumnType<string, string | undefined, never>;
71
- }
72
-
73
- interface RolesTable {
74
- id: string;
75
- tenant_id: string | null; // null = global role
76
- name: string;
77
- description: string | null;
78
- permissions: string; // JSON array
79
- inherits_from: string | null; // role_id
80
- is_default: number; // 0 or 1 - auto-assign to new members
81
- created_at: ColumnType<string, string | undefined, never>;
82
- updated_at: string;
83
- }
84
-
85
- interface UserRolesTable {
86
- id: string;
87
- user_id: string;
88
- role_id: string;
89
- tenant_id: string;
90
- assigned_by: string | null;
91
- created_at: ColumnType<string, string | undefined, never>;
92
- }
93
-
94
- interface ResourceGrantsTable {
95
- id: string;
96
- tenant_id: string;
97
- resource_type: string;
98
- resource_id: string;
99
- grantee_type: "user" | "role";
100
- grantee_id: string;
101
- permissions: string; // JSON array of actions
102
- granted_by: string | null;
103
- created_at: ColumnType<string, string | undefined, never>;
104
- }
105
-
106
- interface PermissionsSchema {
107
- tenants: TenantsTable;
108
- tenant_members: TenantMembersTable;
109
- roles: RolesTable;
110
- user_roles: UserRolesTable;
111
- resource_grants: ResourceGrantsTable;
112
- }
113
-
114
- // =============================================================================
115
- // TYPES
116
- // =============================================================================
117
-
118
- export interface Tenant {
119
- id: string;
120
- name: string;
121
- slug: string;
122
- settings: Record<string, unknown> | null;
123
- }
124
-
125
- export interface Role {
126
- id: string;
127
- tenantId: string | null;
128
- name: string;
129
- description: string | null;
130
- permissions: string[];
131
- inheritsFrom: string | null;
132
- isDefault: boolean;
133
- }
134
-
135
- export interface ResourceGrant {
136
- resourceType: string;
137
- resourceId: string;
138
- granteeType: "user" | "role";
139
- granteeId: string;
140
- permissions: ResourceAction[];
141
- }
142
-
143
- export interface PermissionContext {
144
- userId: string;
145
- tenantId: string;
146
- roles: Role[];
147
- permissions: Set<string>;
148
- }
149
-
150
- // =============================================================================
151
- // PLUGIN DEFINITION
152
- // =============================================================================
153
-
154
- export const permissionsPlugin = <
155
- TPermissions extends Record<string, readonly string[]>
156
- >(config: PermissionsConfig<TPermissions>) => {
157
- // Generate permission type union for validation
158
- type PermissionKey = {
159
- [K in keyof TPermissions]: `${K & string}.${TPermissions[K][number]}`;
160
- }[keyof TPermissions];
161
-
162
- const factory = createPlugin
163
- .withSchema<PermissionsSchema>()
164
- .withConfig<PermissionsConfig<TPermissions>>()
165
- .define({
166
- name: "permissions",
167
-
168
- customErrors: {
169
- TenantNotFound: {
170
- status: 404,
171
- code: "TENANT_NOT_FOUND",
172
- message: "Tenant not found",
173
- },
174
- TenantRequired: {
175
- status: 400,
176
- code: "TENANT_REQUIRED",
177
- message: "Tenant context is required",
178
- },
179
- NotTenantMember: {
180
- status: 403,
181
- code: "NOT_TENANT_MEMBER",
182
- message: "User is not a member of this tenant",
183
- },
184
- PermissionDenied: {
185
- status: 403,
186
- code: "PERMISSION_DENIED",
187
- message: "Permission denied",
188
- },
189
- RoleNotFound: {
190
- status: 404,
191
- code: "ROLE_NOT_FOUND",
192
- message: "Role not found",
193
- },
194
- ResourceAccessDenied: {
195
- status: 403,
196
- code: "RESOURCE_ACCESS_DENIED",
197
- message: "Access to this resource is denied",
198
- },
199
- },
200
-
201
- service: async (ctx) => {
202
- const tenantHeader = config.tenantHeader || "x-tenant-id";
203
-
204
- // =====================================================================
205
- // TENANT METHODS
206
- // =====================================================================
207
-
208
- async function createTenant(data: {
209
- name: string;
210
- slug: string;
211
- ownerId: string;
212
- settings?: Record<string, unknown>;
213
- }): Promise<Tenant> {
214
- const id = crypto.randomUUID();
215
- const now = new Date().toISOString();
216
-
217
- await ctx.db
218
- .insertInto("tenants")
219
- .values({
220
- id,
221
- name: data.name,
222
- slug: data.slug.toLowerCase(),
223
- settings: data.settings ? JSON.stringify(data.settings) : null,
224
- created_at: now,
225
- updated_at: now,
226
- })
227
- .execute();
228
-
229
- // Add owner as member
230
- await ctx.db
231
- .insertInto("tenant_members")
232
- .values({
233
- id: crypto.randomUUID(),
234
- tenant_id: id,
235
- user_id: data.ownerId,
236
- created_at: now,
237
- })
238
- .execute();
239
-
240
- // Create default roles if configured
241
- if (config.defaultRoles) {
242
- for (const roleDef of config.defaultRoles) {
243
- const roleId = crypto.randomUUID();
244
- await ctx.db
245
- .insertInto("roles")
246
- .values({
247
- id: roleId,
248
- tenant_id: id,
249
- name: roleDef.name,
250
- description: null,
251
- permissions: JSON.stringify(roleDef.permissions),
252
- inherits_from: null,
253
- is_default: roleDef.isDefault ? 1 : 0,
254
- created_at: now,
255
- updated_at: now,
256
- })
257
- .execute();
258
-
259
- // Assign default role to owner
260
- if (roleDef.isDefault || roleDef.name.toLowerCase() === "admin") {
261
- await ctx.db
262
- .insertInto("user_roles")
263
- .values({
264
- id: crypto.randomUUID(),
265
- user_id: data.ownerId,
266
- role_id: roleId,
267
- tenant_id: id,
268
- assigned_by: data.ownerId,
269
- created_at: now,
270
- })
271
- .execute();
272
- }
273
- }
274
- }
275
-
276
- return {
277
- id,
278
- name: data.name,
279
- slug: data.slug,
280
- settings: data.settings || null,
281
- };
282
- }
283
-
284
- async function getTenant(tenantId: string): Promise<Tenant | null> {
285
- const row = await ctx.db
286
- .selectFrom("tenants")
287
- .where("id", "=", tenantId)
288
- .selectAll()
289
- .executeTakeFirst();
290
-
291
- if (!row) return null;
292
-
293
- return {
294
- id: row.id,
295
- name: row.name,
296
- slug: row.slug,
297
- settings: row.settings ? JSON.parse(row.settings) : null,
298
- };
299
- }
300
-
301
- async function getTenantBySlug(slug: string): Promise<Tenant | null> {
302
- const row = await ctx.db
303
- .selectFrom("tenants")
304
- .where("slug", "=", slug.toLowerCase())
305
- .selectAll()
306
- .executeTakeFirst();
307
-
308
- if (!row) return null;
309
-
310
- return {
311
- id: row.id,
312
- name: row.name,
313
- slug: row.slug,
314
- settings: row.settings ? JSON.parse(row.settings) : null,
315
- };
316
- }
317
-
318
- async function isTenantMember(userId: string, tenantId: string): Promise<boolean> {
319
- const member = await ctx.db
320
- .selectFrom("tenant_members")
321
- .where("user_id", "=", userId)
322
- .where("tenant_id", "=", tenantId)
323
- .selectAll()
324
- .executeTakeFirst();
325
-
326
- return !!member;
327
- }
328
-
329
- async function addTenantMember(
330
- tenantId: string,
331
- userId: string,
332
- addedBy?: string
333
- ): Promise<void> {
334
- const now = new Date().toISOString();
335
-
336
- await ctx.db
337
- .insertInto("tenant_members")
338
- .values({
339
- id: crypto.randomUUID(),
340
- tenant_id: tenantId,
341
- user_id: userId,
342
- created_at: now,
343
- })
344
- .execute();
345
-
346
- // Assign default roles
347
- const defaultRoles = await ctx.db
348
- .selectFrom("roles")
349
- .where("tenant_id", "=", tenantId)
350
- .where("is_default", "=", 1)
351
- .selectAll()
352
- .execute();
353
-
354
- for (const role of defaultRoles) {
355
- await ctx.db
356
- .insertInto("user_roles")
357
- .values({
358
- id: crypto.randomUUID(),
359
- user_id: userId,
360
- role_id: role.id,
361
- tenant_id: tenantId,
362
- assigned_by: addedBy || null,
363
- created_at: now,
364
- })
365
- .execute();
366
- }
367
- }
368
-
369
- async function removeTenantMember(tenantId: string, userId: string): Promise<void> {
370
- await ctx.db
371
- .deleteFrom("tenant_members")
372
- .where("tenant_id", "=", tenantId)
373
- .where("user_id", "=", userId)
374
- .execute();
375
-
376
- await ctx.db
377
- .deleteFrom("user_roles")
378
- .where("tenant_id", "=", tenantId)
379
- .where("user_id", "=", userId)
380
- .execute();
381
-
382
- await ctx.db
383
- .deleteFrom("resource_grants")
384
- .where("tenant_id", "=", tenantId)
385
- .where("grantee_type", "=", "user")
386
- .where("grantee_id", "=", userId)
387
- .execute();
388
- }
389
-
390
- async function getUserTenants(userId: string): Promise<Tenant[]> {
391
- const rows = await ctx.db
392
- .selectFrom("tenant_members")
393
- .innerJoin("tenants", "tenants.id", "tenant_members.tenant_id")
394
- .where("tenant_members.user_id", "=", userId)
395
- .select([
396
- "tenants.id",
397
- "tenants.name",
398
- "tenants.slug",
399
- "tenants.settings",
400
- ])
401
- .execute();
402
-
403
- return rows.map((row) => ({
404
- id: row.id,
405
- name: row.name,
406
- slug: row.slug,
407
- settings: row.settings ? JSON.parse(row.settings) : null,
408
- }));
409
- }
410
-
411
- // =====================================================================
412
- // ROLE METHODS
413
- // =====================================================================
414
-
415
- async function createRole(data: {
416
- tenantId: string;
417
- name: string;
418
- description?: string;
419
- permissions: string[];
420
- inheritsFrom?: string;
421
- isDefault?: boolean;
422
- }): Promise<Role> {
423
- const id = crypto.randomUUID();
424
- const now = new Date().toISOString();
425
-
426
- await ctx.db
427
- .insertInto("roles")
428
- .values({
429
- id,
430
- tenant_id: data.tenantId,
431
- name: data.name,
432
- description: data.description || null,
433
- permissions: JSON.stringify(data.permissions),
434
- inherits_from: data.inheritsFrom || null,
435
- is_default: data.isDefault ? 1 : 0,
436
- created_at: now,
437
- updated_at: now,
438
- })
439
- .execute();
440
-
441
- return {
442
- id,
443
- tenantId: data.tenantId,
444
- name: data.name,
445
- description: data.description || null,
446
- permissions: data.permissions,
447
- inheritsFrom: data.inheritsFrom || null,
448
- isDefault: data.isDefault || false,
449
- };
450
- }
451
-
452
- async function getRole(roleId: string): Promise<Role | null> {
453
- const row = await ctx.db
454
- .selectFrom("roles")
455
- .where("id", "=", roleId)
456
- .selectAll()
457
- .executeTakeFirst();
458
-
459
- if (!row) return null;
460
-
461
- return {
462
- id: row.id,
463
- tenantId: row.tenant_id,
464
- name: row.name,
465
- description: row.description,
466
- permissions: JSON.parse(row.permissions),
467
- inheritsFrom: row.inherits_from,
468
- isDefault: row.is_default === 1,
469
- };
470
- }
471
-
472
- async function getTenantRoles(tenantId: string): Promise<Role[]> {
473
- const rows = await ctx.db
474
- .selectFrom("roles")
475
- .where((eb) =>
476
- eb.or([
477
- eb("tenant_id", "=", tenantId),
478
- eb("tenant_id", "is", null), // Global roles
479
- ])
480
- )
481
- .selectAll()
482
- .execute();
483
-
484
- return rows.map((row) => ({
485
- id: row.id,
486
- tenantId: row.tenant_id,
487
- name: row.name,
488
- description: row.description,
489
- permissions: JSON.parse(row.permissions),
490
- inheritsFrom: row.inherits_from,
491
- isDefault: row.is_default === 1,
492
- }));
493
- }
494
-
495
- async function assignRole(
496
- userId: string,
497
- roleId: string,
498
- tenantId: string,
499
- assignedBy?: string
500
- ): Promise<void> {
501
- const existing = await ctx.db
502
- .selectFrom("user_roles")
503
- .where("user_id", "=", userId)
504
- .where("role_id", "=", roleId)
505
- .where("tenant_id", "=", tenantId)
506
- .selectAll()
507
- .executeTakeFirst();
508
-
509
- if (existing) return;
510
-
511
- await ctx.db
512
- .insertInto("user_roles")
513
- .values({
514
- id: crypto.randomUUID(),
515
- user_id: userId,
516
- role_id: roleId,
517
- tenant_id: tenantId,
518
- assigned_by: assignedBy || null,
519
- created_at: new Date().toISOString(),
520
- })
521
- .execute();
522
- }
523
-
524
- async function revokeRole(
525
- userId: string,
526
- roleId: string,
527
- tenantId: string
528
- ): Promise<void> {
529
- await ctx.db
530
- .deleteFrom("user_roles")
531
- .where("user_id", "=", userId)
532
- .where("role_id", "=", roleId)
533
- .where("tenant_id", "=", tenantId)
534
- .execute();
535
- }
536
-
537
- async function getUserRoles(userId: string, tenantId: string): Promise<Role[]> {
538
- const rows = await ctx.db
539
- .selectFrom("user_roles")
540
- .innerJoin("roles", "roles.id", "user_roles.role_id")
541
- .where("user_roles.user_id", "=", userId)
542
- .where("user_roles.tenant_id", "=", tenantId)
543
- .select([
544
- "roles.id",
545
- "roles.tenant_id",
546
- "roles.name",
547
- "roles.description",
548
- "roles.permissions",
549
- "roles.inherits_from",
550
- "roles.is_default",
551
- ])
552
- .execute();
553
-
554
- return rows.map((row) => ({
555
- id: row.id,
556
- tenantId: row.tenant_id,
557
- name: row.name,
558
- description: row.description,
559
- permissions: JSON.parse(row.permissions),
560
- inheritsFrom: row.inherits_from,
561
- isDefault: row.is_default === 1,
562
- }));
563
- }
564
-
565
- // =====================================================================
566
- // PERMISSION RESOLUTION
567
- // =====================================================================
568
-
569
- /**
570
- * Get all permissions for a role, including inherited ones
571
- */
572
- async function resolveRolePermissions(
573
- roleId: string,
574
- visited: Set<string> = new Set()
575
- ): Promise<Set<string>> {
576
- if (visited.has(roleId)) return new Set(); // Prevent cycles
577
- visited.add(roleId);
578
-
579
- const role = await getRole(roleId);
580
- if (!role) return new Set();
581
-
582
- const permissions = new Set(role.permissions);
583
-
584
- // Add inherited permissions
585
- if (role.inheritsFrom) {
586
- const inherited = await resolveRolePermissions(role.inheritsFrom, visited);
587
- for (const perm of inherited) {
588
- permissions.add(perm);
589
- }
590
- }
591
-
592
- return permissions;
593
- }
594
-
595
- /**
596
- * Get all static permissions for a user in a tenant
597
- */
598
- async function getUserPermissions(
599
- userId: string,
600
- tenantId: string
601
- ): Promise<Set<string>> {
602
- const roles = await getUserRoles(userId, tenantId);
603
- const permissions = new Set<string>();
604
-
605
- for (const role of roles) {
606
- const rolePerms = await resolveRolePermissions(role.id);
607
- for (const perm of rolePerms) {
608
- permissions.add(perm);
609
- }
610
- }
611
-
612
- return permissions;
613
- }
614
-
615
- /**
616
- * Check if user has a static permission
617
- */
618
- async function hasPermission(
619
- userId: string,
620
- tenantId: string,
621
- permission: string
622
- ): Promise<boolean> {
623
- const permissions = await getUserPermissions(userId, tenantId);
624
-
625
- // Check exact match
626
- if (permissions.has(permission)) return true;
627
-
628
- // Check wildcard (e.g., "documents.*" or "*")
629
- if (permissions.has("*")) return true;
630
-
631
- const [resource] = permission.split(".");
632
- if (permissions.has(`${resource}.*`)) return true;
633
-
634
- return false;
635
- }
636
-
637
- /**
638
- * Require a static permission, throw if denied
639
- */
640
- async function requirePermission(
641
- userId: string,
642
- tenantId: string,
643
- permission: string
644
- ): Promise<void> {
645
- const has = await hasPermission(userId, tenantId, permission);
646
- if (!has) {
647
- throw ctx.core.errors.Forbidden(`Missing permission: ${permission}`);
648
- }
649
- }
650
-
651
- // =====================================================================
652
- // RESOURCE GRANTS
653
- // =====================================================================
654
-
655
- /**
656
- * Grant access to a resource
657
- */
658
- async function grantAccess(data: {
659
- tenantId: string;
660
- resourceType: string;
661
- resourceId: string;
662
- granteeType: "user" | "role";
663
- granteeId: string;
664
- permissions: ResourceAction[];
665
- grantedBy?: string;
666
- }): Promise<void> {
667
- // Check if grant already exists
668
- const existing = await ctx.db
669
- .selectFrom("resource_grants")
670
- .where("tenant_id", "=", data.tenantId)
671
- .where("resource_type", "=", data.resourceType)
672
- .where("resource_id", "=", data.resourceId)
673
- .where("grantee_type", "=", data.granteeType)
674
- .where("grantee_id", "=", data.granteeId)
675
- .selectAll()
676
- .executeTakeFirst();
677
-
678
- if (existing) {
679
- // Update permissions
680
- const existingPerms: ResourceAction[] = JSON.parse(existing.permissions);
681
- const merged = [...new Set([...existingPerms, ...data.permissions])];
682
-
683
- await ctx.db
684
- .updateTable("resource_grants")
685
- .set({ permissions: JSON.stringify(merged) })
686
- .where("id", "=", existing.id)
687
- .execute();
688
- } else {
689
- await ctx.db
690
- .insertInto("resource_grants")
691
- .values({
692
- id: crypto.randomUUID(),
693
- tenant_id: data.tenantId,
694
- resource_type: data.resourceType,
695
- resource_id: data.resourceId,
696
- grantee_type: data.granteeType,
697
- grantee_id: data.granteeId,
698
- permissions: JSON.stringify(data.permissions),
699
- granted_by: data.grantedBy || null,
700
- created_at: new Date().toISOString(),
701
- })
702
- .execute();
703
- }
704
- }
705
-
706
- /**
707
- * Revoke access to a resource
708
- */
709
- async function revokeAccess(data: {
710
- tenantId: string;
711
- resourceType: string;
712
- resourceId: string;
713
- granteeType: "user" | "role";
714
- granteeId: string;
715
- permissions?: ResourceAction[]; // If not provided, revokes all
716
- }): Promise<void> {
717
- if (!data.permissions) {
718
- await ctx.db
719
- .deleteFrom("resource_grants")
720
- .where("tenant_id", "=", data.tenantId)
721
- .where("resource_type", "=", data.resourceType)
722
- .where("resource_id", "=", data.resourceId)
723
- .where("grantee_type", "=", data.granteeType)
724
- .where("grantee_id", "=", data.granteeId)
725
- .execute();
726
- } else {
727
- const existing = await ctx.db
728
- .selectFrom("resource_grants")
729
- .where("tenant_id", "=", data.tenantId)
730
- .where("resource_type", "=", data.resourceType)
731
- .where("resource_id", "=", data.resourceId)
732
- .where("grantee_type", "=", data.granteeType)
733
- .where("grantee_id", "=", data.granteeId)
734
- .selectAll()
735
- .executeTakeFirst();
736
-
737
- if (existing) {
738
- const existingPerms: ResourceAction[] = JSON.parse(existing.permissions);
739
- const remaining = existingPerms.filter((p) => !data.permissions!.includes(p));
740
-
741
- if (remaining.length === 0) {
742
- await ctx.db
743
- .deleteFrom("resource_grants")
744
- .where("id", "=", existing.id)
745
- .execute();
746
- } else {
747
- await ctx.db
748
- .updateTable("resource_grants")
749
- .set({ permissions: JSON.stringify(remaining) })
750
- .where("id", "=", existing.id)
751
- .execute();
752
- }
753
- }
754
- }
755
- }
756
-
757
- /**
758
- * Check if user can access a resource
759
- */
760
- async function canAccess(
761
- userId: string,
762
- tenantId: string,
763
- resourceType: string,
764
- resourceId: string,
765
- action: ResourceAction,
766
- ownerId?: string // If provided, owner check is performed
767
- ): Promise<boolean> {
768
- // 1. Owner check - owners have full access
769
- if (ownerId && ownerId === userId) {
770
- return true;
771
- }
772
-
773
- // 2. Admin permission check
774
- const hasAdmin = await hasPermission(userId, tenantId, `${resourceType}.admin`);
775
- if (hasAdmin) return true;
776
-
777
- // 3. Direct user grant
778
- const userGrant = await ctx.db
779
- .selectFrom("resource_grants")
780
- .where("tenant_id", "=", tenantId)
781
- .where("resource_type", "=", resourceType)
782
- .where("resource_id", "=", resourceId)
783
- .where("grantee_type", "=", "user")
784
- .where("grantee_id", "=", userId)
785
- .selectAll()
786
- .executeTakeFirst();
787
-
788
- if (userGrant) {
789
- const perms: ResourceAction[] = JSON.parse(userGrant.permissions);
790
- if (perms.includes(action) || perms.includes("admin")) {
791
- return true;
792
- }
793
- }
794
-
795
- // 4. Role grant
796
- const userRoles = await getUserRoles(userId, tenantId);
797
- for (const role of userRoles) {
798
- const roleGrant = await ctx.db
799
- .selectFrom("resource_grants")
800
- .where("tenant_id", "=", tenantId)
801
- .where("resource_type", "=", resourceType)
802
- .where("resource_id", "=", resourceId)
803
- .where("grantee_type", "=", "role")
804
- .where("grantee_id", "=", role.id)
805
- .selectAll()
806
- .executeTakeFirst();
807
-
808
- if (roleGrant) {
809
- const perms: ResourceAction[] = JSON.parse(roleGrant.permissions);
810
- if (perms.includes(action) || perms.includes("admin")) {
811
- return true;
812
- }
813
- }
814
- }
815
-
816
- return false;
817
- }
818
-
819
- /**
820
- * Require access to a resource, throw if denied
821
- */
822
- async function requireAccess(
823
- userId: string,
824
- tenantId: string,
825
- resourceType: string,
826
- resourceId: string,
827
- action: ResourceAction,
828
- ownerId?: string
829
- ): Promise<void> {
830
- const can = await canAccess(userId, tenantId, resourceType, resourceId, action, ownerId);
831
- if (!can) {
832
- throw ctx.core.errors.Forbidden(
833
- `Cannot ${action} ${resourceType}:${resourceId}`
834
- );
835
- }
836
- }
837
-
838
- /**
839
- * Get all grants for a resource
840
- */
841
- async function getResourceGrants(
842
- tenantId: string,
843
- resourceType: string,
844
- resourceId: string
845
- ): Promise<ResourceGrant[]> {
846
- const rows = await ctx.db
847
- .selectFrom("resource_grants")
848
- .where("tenant_id", "=", tenantId)
849
- .where("resource_type", "=", resourceType)
850
- .where("resource_id", "=", resourceId)
851
- .selectAll()
852
- .execute();
853
-
854
- return rows.map((row) => ({
855
- resourceType: row.resource_type,
856
- resourceId: row.resource_id,
857
- granteeType: row.grantee_type as "user" | "role",
858
- granteeId: row.grantee_id,
859
- permissions: JSON.parse(row.permissions),
860
- }));
861
- }
862
-
863
- // =====================================================================
864
- // CONTEXT BUILDER
865
- // =====================================================================
866
-
867
- /**
868
- * Build permission context for a user in a tenant
869
- * Useful for passing to client
870
- */
871
- async function buildContext(
872
- userId: string,
873
- tenantId: string
874
- ): Promise<PermissionContext> {
875
- const roles = await getUserRoles(userId, tenantId);
876
- const permissions = await getUserPermissions(userId, tenantId);
877
-
878
- return {
879
- userId,
880
- tenantId,
881
- roles,
882
- permissions,
883
- };
884
- }
885
-
886
- /**
887
- * Serialize context for client (permissions as array)
888
- */
889
- async function getClientContext(
890
- userId: string,
891
- tenantId: string
892
- ): Promise<{
893
- tenantId: string;
894
- roles: Array<{ id: string; name: string }>;
895
- permissions: string[];
896
- }> {
897
- const context = await buildContext(userId, tenantId);
898
- return {
899
- tenantId: context.tenantId,
900
- roles: context.roles.map((r) => ({ id: r.id, name: r.name })),
901
- permissions: Array.from(context.permissions),
902
- };
903
- }
904
-
905
- // =====================================================================
906
- // RETURN SERVICE
907
- // =====================================================================
908
-
909
- return {
910
- // Tenant methods
911
- createTenant,
912
- getTenant,
913
- getTenantBySlug,
914
- isTenantMember,
915
- addTenantMember,
916
- removeTenantMember,
917
- getUserTenants,
918
-
919
- // Role methods
920
- createRole,
921
- getRole,
922
- getTenantRoles,
923
- assignRole,
924
- revokeRole,
925
- getUserRoles,
926
-
927
- // Permission methods
928
- hasPermission,
929
- requirePermission,
930
- getUserPermissions,
931
-
932
- // Resource grant methods
933
- grantAccess,
934
- revokeAccess,
935
- canAccess,
936
- requireAccess,
937
- getResourceGrants,
938
-
939
- // Context
940
- buildContext,
941
- getClientContext,
942
-
943
- // Config access
944
- getPermissionDefinitions: () => config.permissions,
945
- getTenantHeader: () => tenantHeader,
946
- };
947
- },
948
-
949
- middleware: (ctx, service) => ({
950
- /**
951
- * Tenant middleware - resolves and validates tenant context
952
- */
953
- tenant: createMiddleware<{ required?: boolean }>(
954
- async (req, reqCtx, next, options) => {
955
- const tenantHeader = service.getTenantHeader();
956
- const tenantId = req.headers.get(tenantHeader);
957
-
958
- if (!tenantId) {
959
- if (options?.required !== false) {
960
- return Response.json(
961
- { error: "Tenant context required", code: "TENANT_REQUIRED" },
962
- { status: 400 }
963
- );
964
- }
965
- return next();
966
- }
967
-
968
- const tenant = await service.getTenant(tenantId);
969
- if (!tenant) {
970
- return Response.json(
971
- { error: "Tenant not found", code: "TENANT_NOT_FOUND" },
972
- { status: 404 }
973
- );
974
- }
975
-
976
- // Check membership if user is authenticated
977
- if (reqCtx.user?.id) {
978
- const isMember = await service.isTenantMember(reqCtx.user.id, tenantId);
979
- if (!isMember) {
980
- return Response.json(
981
- { error: "Not a tenant member", code: "NOT_TENANT_MEMBER" },
982
- { status: 403 }
983
- );
984
- }
985
- }
986
-
987
- (reqCtx as any).tenant = tenant;
988
- (reqCtx as any).tenantId = tenant.id;
989
- return next();
990
- }
991
- ),
992
-
993
- /**
994
- * Permission middleware - checks static permissions
995
- */
996
- permissions: createMiddleware<{ require: string | string[] }>(
997
- async (req, reqCtx, next, options) => {
998
- if (!reqCtx.user?.id) {
999
- return Response.json(
1000
- { error: "Authentication required", code: "UNAUTHORIZED" },
1001
- { status: 401 }
1002
- );
1003
- }
1004
-
1005
- if (!(reqCtx as any).tenantId) {
1006
- return Response.json(
1007
- { error: "Tenant context required", code: "TENANT_REQUIRED" },
1008
- { status: 400 }
1009
- );
1010
- }
1011
-
1012
- const required = Array.isArray(options.require)
1013
- ? options.require
1014
- : [options.require];
1015
-
1016
- for (const permission of required) {
1017
- const has = await service.hasPermission(
1018
- reqCtx.user.id,
1019
- (reqCtx as any).tenantId,
1020
- permission
1021
- );
1022
- if (!has) {
1023
- return Response.json(
1024
- {
1025
- error: `Missing permission: ${permission}`,
1026
- code: "PERMISSION_DENIED",
1027
- },
1028
- { status: 403 }
1029
- );
1030
- }
1031
- }
1032
-
1033
- return next();
1034
- }
1035
- ),
1036
- }),
1037
-
1038
- init: async (ctx, service) => {
1039
- ctx.core.logger.info("Permissions plugin initialized", {
1040
- permissions: Object.keys(config.permissions),
1041
- defaultRoles: config.defaultRoles?.map((r) => r.name) || [],
1042
- });
1043
- },
1044
- });
1045
-
1046
- // Call factory with config to get the actual Plugin
1047
- return factory(config);
1048
- };