@donkeylabs/cli 1.1.15 → 1.1.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@donkeylabs/cli",
3
- "version": "1.1.15",
3
+ "version": "1.1.17",
4
4
  "type": "module",
5
5
  "description": "CLI for @donkeylabs/server - project scaffolding and code generation",
6
6
  "main": "./src/index.ts",
@@ -33,8 +33,10 @@ NODE_ENV=development
33
33
  # EXTERNAL SERVICES (examples)
34
34
  # =============================================================================
35
35
 
36
- # Email service (e.g., Resend, SendGrid)
36
+ # Email service (Resend)
37
37
  # RESEND_API_KEY=re_xxxxxxxxxxxx
38
+ # EMAIL_FROM=noreply@yourdomain.com
39
+ # PUBLIC_BASE_URL=https://yourdomain.com
38
40
 
39
41
  # Payment processing (e.g., Stripe)
40
42
  # STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxx
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Permissions Helper for Svelte UI
3
+ *
4
+ * Provides reactive permission checking for UI locking.
5
+ *
6
+ * Usage in +page.server.ts:
7
+ * ```ts
8
+ * export const load = async ({ locals }) => {
9
+ * const api = createApi({ locals });
10
+ * const permissions = await api.permissions.context({ tenantId });
11
+ * return { permissions };
12
+ * };
13
+ * ```
14
+ *
15
+ * Usage in +page.svelte:
16
+ * ```svelte
17
+ * <script>
18
+ * import { createPermissions } from "$lib/permissions";
19
+ * let { data } = $props();
20
+ * const can = createPermissions(data.permissions);
21
+ * </script>
22
+ *
23
+ * {#if can.has("documents.create")}
24
+ * <Button>Create Document</Button>
25
+ * {/if}
26
+ * ```
27
+ */
28
+
29
+ import { createApi } from "./api";
30
+
31
+ export interface PermissionContext {
32
+ tenantId: string;
33
+ roles: Array<{ id: string; name: string }>;
34
+ permissions: string[];
35
+ }
36
+
37
+ export interface PermissionsHelper {
38
+ /** Check if user has a static permission */
39
+ has: (permission: string) => boolean;
40
+
41
+ /** Check if user has all of the specified permissions */
42
+ hasAll: (...permissions: string[]) => boolean;
43
+
44
+ /** Check if user has any of the specified permissions */
45
+ hasAny: (...permissions: string[]) => boolean;
46
+
47
+ /** Check if user has a specific role */
48
+ hasRole: (roleName: string) => boolean;
49
+
50
+ /** Get all permissions */
51
+ all: () => string[];
52
+
53
+ /** Get all roles */
54
+ roles: () => Array<{ id: string; name: string }>;
55
+
56
+ /** Get tenant ID */
57
+ tenantId: () => string;
58
+
59
+ /** Check resource access (async - makes API call) */
60
+ canAccess: (
61
+ resourceType: string,
62
+ resourceId: string,
63
+ action: "create" | "read" | "write" | "delete" | "admin",
64
+ ownerId?: string
65
+ ) => Promise<boolean>;
66
+
67
+ /** Batch check resource access (async - makes single API call) */
68
+ canAccessMany: (
69
+ checks: Array<{
70
+ resourceType: string;
71
+ resourceId: string;
72
+ action: "create" | "read" | "write" | "delete" | "admin";
73
+ ownerId?: string;
74
+ }>
75
+ ) => Promise<Record<string, boolean>>;
76
+ }
77
+
78
+ /**
79
+ * Create a permissions helper from context
80
+ */
81
+ export function createPermissions(context: PermissionContext): PermissionsHelper {
82
+ const permissionSet = new Set(context.permissions);
83
+ const api = createApi();
84
+
85
+ return {
86
+ has(permission: string): boolean {
87
+ // Exact match
88
+ if (permissionSet.has(permission)) return true;
89
+
90
+ // Wildcard match
91
+ if (permissionSet.has("*")) return true;
92
+
93
+ // Resource wildcard (e.g., "documents.*")
94
+ const [resource] = permission.split(".");
95
+ if (permissionSet.has(`${resource}.*`)) return true;
96
+
97
+ return false;
98
+ },
99
+
100
+ hasAll(...permissions: string[]): boolean {
101
+ return permissions.every((p) => this.has(p));
102
+ },
103
+
104
+ hasAny(...permissions: string[]): boolean {
105
+ return permissions.some((p) => this.has(p));
106
+ },
107
+
108
+ hasRole(roleName: string): boolean {
109
+ return context.roles.some(
110
+ (r) => r.name.toLowerCase() === roleName.toLowerCase()
111
+ );
112
+ },
113
+
114
+ all(): string[] {
115
+ return context.permissions;
116
+ },
117
+
118
+ roles(): Array<{ id: string; name: string }> {
119
+ return context.roles;
120
+ },
121
+
122
+ tenantId(): string {
123
+ return context.tenantId;
124
+ },
125
+
126
+ async canAccess(
127
+ resourceType: string,
128
+ resourceId: string,
129
+ action: "create" | "read" | "write" | "delete" | "admin",
130
+ ownerId?: string
131
+ ): Promise<boolean> {
132
+ const result = await api.permissions.canAccess({
133
+ tenantId: context.tenantId,
134
+ checks: [{ resourceType, resourceId, action, ownerId }],
135
+ });
136
+ return result[`${resourceType}:${resourceId}:${action}`] ?? false;
137
+ },
138
+
139
+ async canAccessMany(
140
+ checks: Array<{
141
+ resourceType: string;
142
+ resourceId: string;
143
+ action: "create" | "read" | "write" | "delete" | "admin";
144
+ ownerId?: string;
145
+ }>
146
+ ): Promise<Record<string, boolean>> {
147
+ return api.permissions.canAccess({
148
+ tenantId: context.tenantId,
149
+ checks,
150
+ });
151
+ },
152
+ };
153
+ }
154
+
155
+ /**
156
+ * Svelte component helper - use in templates
157
+ *
158
+ * Usage:
159
+ * ```svelte
160
+ * <script>
161
+ * import { Can } from "$lib/permissions";
162
+ * let { data } = $props();
163
+ * </script>
164
+ *
165
+ * <Can permission="documents.create" context={data.permissions}>
166
+ * <Button>Create</Button>
167
+ * </Can>
168
+ * ```
169
+ */
170
+ export function canCheck(
171
+ context: PermissionContext,
172
+ permission: string
173
+ ): boolean {
174
+ const helper = createPermissions(context);
175
+ return helper.has(permission);
176
+ }
177
+
178
+ /**
179
+ * Type helper for defining permissions in your app
180
+ *
181
+ * Usage:
182
+ * ```ts
183
+ * const PERMISSIONS = definePermissions({
184
+ * documents: ["create", "read", "write", "delete", "admin"],
185
+ * users: ["invite", "remove", "manage"],
186
+ * billing: ["view", "manage"],
187
+ * } as const);
188
+ *
189
+ * // Type: "documents.create" | "documents.read" | ...
190
+ * type Permission = typeof PERMISSIONS[number];
191
+ * ```
192
+ */
193
+ export function definePermissions<
194
+ T extends Record<string, readonly string[]>
195
+ >(permissions: T): Array<`${Extract<keyof T, string>}.${T[keyof T][number]}`> {
196
+ const result: string[] = [];
197
+ for (const [resource, actions] of Object.entries(permissions)) {
198
+ for (const action of actions) {
199
+ result.push(`${resource}.${action}`);
200
+ }
201
+ }
202
+ return result as any;
203
+ }
@@ -6,9 +6,13 @@ import { Database } from "bun:sqlite";
6
6
  import { demoPlugin } from "./plugins/demo";
7
7
  import { workflowDemoPlugin } from "./plugins/workflow-demo";
8
8
  import { authPlugin } from "./plugins/auth";
9
+ import { emailPlugin } from "./plugins/email";
10
+ import { permissionsPlugin } from "./plugins/permissions";
9
11
  import demoRoutes from "./routes/demo";
10
12
  import { exampleRouter } from "./routes/example";
11
13
  import { authRouter } from "./routes/auth";
14
+ import { permissionsRouter } from "./routes/permissions";
15
+ import { tenantsRouter } from "./routes/tenants";
12
16
 
13
17
  // Simple in-memory database
14
18
  const db = new Kysely<{}>({
@@ -56,11 +60,63 @@ export const server = new AppServer({
56
60
 
57
61
  // Using default session strategy for this template
58
62
  server.registerPlugin(authPlugin());
63
+
64
+ // Email plugin - supports Resend or console (for development)
65
+ // Configure with process.env.RESEND_API_KEY for production
66
+ server.registerPlugin(emailPlugin({
67
+ provider: process.env.RESEND_API_KEY ? "resend" : "console",
68
+ resend: process.env.RESEND_API_KEY ? { apiKey: process.env.RESEND_API_KEY } : undefined,
69
+ from: process.env.EMAIL_FROM || "noreply@example.com",
70
+ baseUrl: process.env.PUBLIC_BASE_URL || "http://localhost:5173",
71
+ }));
72
+
73
+ // =============================================================================
74
+ // PERMISSIONS PLUGIN - Multi-tenant RBAC
75
+ // =============================================================================
76
+ // Define your app's permissions here for type-safe checking.
77
+ // Client can use: api.permissions.check({ permissions: ["documents.create"] })
78
+ //
79
+ server.registerPlugin(permissionsPlugin({
80
+ permissions: {
81
+ // Documents
82
+ documents: ["create", "read", "write", "delete", "admin"],
83
+ // Members & Roles
84
+ members: ["invite", "remove", "list"],
85
+ roles: ["create", "assign", "manage"],
86
+ // Billing (example)
87
+ billing: ["view", "manage"],
88
+ } as const,
89
+ defaultRoles: [
90
+ {
91
+ name: "Admin",
92
+ permissions: ["*"], // Full access
93
+ isDefault: false,
94
+ },
95
+ {
96
+ name: "Member",
97
+ permissions: [
98
+ "documents.create",
99
+ "documents.read",
100
+ "documents.write",
101
+ "members.list",
102
+ ],
103
+ isDefault: true, // Auto-assigned to new members
104
+ },
105
+ {
106
+ name: "Viewer",
107
+ permissions: ["documents.read", "members.list"],
108
+ isDefault: false,
109
+ },
110
+ ],
111
+ }));
112
+
59
113
  server.registerPlugin(demoPlugin);
60
114
  server.registerPlugin(workflowDemoPlugin);
61
115
 
62
116
  // Register routes
63
117
  server.use(authRouter);
118
+ server.use(permissionsRouter);
119
+ server.use(tenantsRouter);
64
120
  server.use(demoRoutes);
65
121
  server.use(exampleRouter);
66
122
 
@@ -0,0 +1,60 @@
1
+ import type { Kysely } from "kysely";
2
+
3
+ export async function up(db: Kysely<any>): Promise<void> {
4
+ // Passkey credentials (WebAuthn)
5
+ await db.schema
6
+ .createTable("passkeys")
7
+ .ifNotExists()
8
+ .addColumn("id", "text", (col) => col.primaryKey())
9
+ .addColumn("user_id", "text", (col) =>
10
+ col.notNull().references("users.id").onDelete("cascade")
11
+ )
12
+ .addColumn("credential_id", "text", (col) => col.notNull().unique())
13
+ .addColumn("public_key", "text", (col) => col.notNull()) // Base64 encoded
14
+ .addColumn("counter", "integer", (col) => col.notNull().defaultTo(0))
15
+ .addColumn("device_type", "text") // platform, cross-platform
16
+ .addColumn("backed_up", "integer", (col) => col.notNull().defaultTo(0))
17
+ .addColumn("transports", "text") // JSON array
18
+ .addColumn("name", "text") // User-friendly name
19
+ .addColumn("created_at", "text", (col) => col.notNull().defaultTo("CURRENT_TIMESTAMP"))
20
+ .addColumn("last_used_at", "text")
21
+ .execute();
22
+
23
+ await db.schema
24
+ .createIndex("idx_passkeys_user_id")
25
+ .ifNotExists()
26
+ .on("passkeys")
27
+ .column("user_id")
28
+ .execute();
29
+
30
+ await db.schema
31
+ .createIndex("idx_passkeys_credential_id")
32
+ .ifNotExists()
33
+ .on("passkeys")
34
+ .column("credential_id")
35
+ .execute();
36
+
37
+ // Passkey challenges (temporary storage)
38
+ await db.schema
39
+ .createTable("passkey_challenges")
40
+ .ifNotExists()
41
+ .addColumn("id", "text", (col) => col.primaryKey())
42
+ .addColumn("challenge", "text", (col) => col.notNull())
43
+ .addColumn("user_id", "text") // Null for registration
44
+ .addColumn("type", "text", (col) => col.notNull()) // registration, authentication
45
+ .addColumn("expires_at", "text", (col) => col.notNull())
46
+ .addColumn("created_at", "text", (col) => col.notNull().defaultTo("CURRENT_TIMESTAMP"))
47
+ .execute();
48
+
49
+ await db.schema
50
+ .createIndex("idx_passkey_challenges_expires_at")
51
+ .ifNotExists()
52
+ .on("passkey_challenges")
53
+ .column("expires_at")
54
+ .execute();
55
+ }
56
+
57
+ export async function down(db: Kysely<any>): Promise<void> {
58
+ await db.schema.dropTable("passkey_challenges").ifExists().execute();
59
+ await db.schema.dropTable("passkeys").ifExists().execute();
60
+ }