@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 +1 -1
- package/templates/sveltekit-app/.env.example +3 -1
- package/templates/sveltekit-app/src/lib/permissions.ts +203 -0
- package/templates/sveltekit-app/src/server/index.ts +56 -0
- package/templates/sveltekit-app/src/server/plugins/auth/migrations/004_create_passkeys.ts +60 -0
- package/templates/sveltekit-app/src/server/plugins/email/index.ts +411 -0
- package/templates/sveltekit-app/src/server/plugins/email/migrations/001_create_email_tokens.ts +33 -0
- package/templates/sveltekit-app/src/server/plugins/permissions/index.ts +1045 -0
- package/templates/sveltekit-app/src/server/plugins/permissions/migrations/001_create_tenants.ts +63 -0
- package/templates/sveltekit-app/src/server/plugins/permissions/migrations/002_create_roles.ts +90 -0
- package/templates/sveltekit-app/src/server/plugins/permissions/migrations/003_create_resource_grants.ts +50 -0
- package/templates/sveltekit-app/src/server/routes/permissions/index.ts +248 -0
- package/templates/sveltekit-app/src/server/routes/tenants/index.ts +339 -0
package/package.json
CHANGED
|
@@ -33,8 +33,10 @@ NODE_ENV=development
|
|
|
33
33
|
# EXTERNAL SERVICES (examples)
|
|
34
34
|
# =============================================================================
|
|
35
35
|
|
|
36
|
-
# Email service (
|
|
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
|
+
}
|