@m5kdev/backend 0.1.0

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 (113) hide show
  1. package/LICENSE +621 -0
  2. package/README.md +22 -0
  3. package/package.json +205 -0
  4. package/src/lib/posthog.ts +5 -0
  5. package/src/lib/sentry.ts +8 -0
  6. package/src/modules/access/access.repository.ts +36 -0
  7. package/src/modules/access/access.service.ts +81 -0
  8. package/src/modules/access/access.test.ts +216 -0
  9. package/src/modules/access/access.utils.ts +46 -0
  10. package/src/modules/ai/ai.db.ts +38 -0
  11. package/src/modules/ai/ai.prompt.ts +47 -0
  12. package/src/modules/ai/ai.repository.ts +53 -0
  13. package/src/modules/ai/ai.router.ts +148 -0
  14. package/src/modules/ai/ai.service.ts +310 -0
  15. package/src/modules/ai/ai.trpc.ts +22 -0
  16. package/src/modules/ai/ideogram/ideogram.constants.ts +170 -0
  17. package/src/modules/ai/ideogram/ideogram.dto.ts +64 -0
  18. package/src/modules/ai/ideogram/ideogram.prompt.ts +858 -0
  19. package/src/modules/ai/ideogram/ideogram.repository.ts +39 -0
  20. package/src/modules/ai/ideogram/ideogram.service.ts +14 -0
  21. package/src/modules/auth/auth.db.ts +224 -0
  22. package/src/modules/auth/auth.dto.ts +47 -0
  23. package/src/modules/auth/auth.lib.ts +349 -0
  24. package/src/modules/auth/auth.middleware.ts +62 -0
  25. package/src/modules/auth/auth.repository.ts +672 -0
  26. package/src/modules/auth/auth.service.ts +261 -0
  27. package/src/modules/auth/auth.trpc.ts +208 -0
  28. package/src/modules/auth/auth.utils.ts +117 -0
  29. package/src/modules/base/base.abstract.ts +62 -0
  30. package/src/modules/base/base.dto.ts +206 -0
  31. package/src/modules/base/base.grants.test.ts +861 -0
  32. package/src/modules/base/base.grants.ts +199 -0
  33. package/src/modules/base/base.repository.ts +433 -0
  34. package/src/modules/base/base.service.ts +154 -0
  35. package/src/modules/base/base.types.ts +7 -0
  36. package/src/modules/billing/billing.db.ts +27 -0
  37. package/src/modules/billing/billing.repository.ts +328 -0
  38. package/src/modules/billing/billing.router.ts +77 -0
  39. package/src/modules/billing/billing.service.ts +177 -0
  40. package/src/modules/billing/billing.trpc.ts +17 -0
  41. package/src/modules/clay/clay.repository.ts +29 -0
  42. package/src/modules/clay/clay.service.ts +61 -0
  43. package/src/modules/connect/connect.db.ts +32 -0
  44. package/src/modules/connect/connect.dto.ts +44 -0
  45. package/src/modules/connect/connect.linkedin.ts +70 -0
  46. package/src/modules/connect/connect.oauth.ts +288 -0
  47. package/src/modules/connect/connect.repository.ts +65 -0
  48. package/src/modules/connect/connect.router.ts +76 -0
  49. package/src/modules/connect/connect.service.ts +171 -0
  50. package/src/modules/connect/connect.trpc.ts +26 -0
  51. package/src/modules/connect/connect.types.ts +27 -0
  52. package/src/modules/crypto/crypto.db.ts +15 -0
  53. package/src/modules/crypto/crypto.repository.ts +13 -0
  54. package/src/modules/crypto/crypto.service.ts +57 -0
  55. package/src/modules/email/email.service.ts +222 -0
  56. package/src/modules/file/file.repository.ts +95 -0
  57. package/src/modules/file/file.router.ts +108 -0
  58. package/src/modules/file/file.service.ts +186 -0
  59. package/src/modules/recurrence/recurrence.db.ts +79 -0
  60. package/src/modules/recurrence/recurrence.repository.ts +70 -0
  61. package/src/modules/recurrence/recurrence.service.ts +105 -0
  62. package/src/modules/recurrence/recurrence.trpc.ts +82 -0
  63. package/src/modules/social/social.dto.ts +22 -0
  64. package/src/modules/social/social.linkedin.test.ts +277 -0
  65. package/src/modules/social/social.linkedin.ts +593 -0
  66. package/src/modules/social/social.service.ts +112 -0
  67. package/src/modules/social/social.types.ts +43 -0
  68. package/src/modules/tag/tag.db.ts +41 -0
  69. package/src/modules/tag/tag.dto.ts +18 -0
  70. package/src/modules/tag/tag.repository.ts +222 -0
  71. package/src/modules/tag/tag.service.ts +48 -0
  72. package/src/modules/tag/tag.trpc.ts +62 -0
  73. package/src/modules/uploads/0581796b-8845-420d-bd95-cd7de79f6d37.webm +0 -0
  74. package/src/modules/uploads/33b1e649-6727-4bd0-94d0-a0b363646865.webm +0 -0
  75. package/src/modules/uploads/49a8c4c0-54d7-4c94-bef4-c93c029f9ed0.webm +0 -0
  76. package/src/modules/uploads/50e31e38-a2f0-47ca-8b7d-2d7fcad9267d.webm +0 -0
  77. package/src/modules/uploads/72ac8cf9-c3a7-4cd8-8a78-6d8e137a4c7e.webm +0 -0
  78. package/src/modules/uploads/75293649-d966-46cd-a675-67518958ae9c.png +0 -0
  79. package/src/modules/uploads/88b7b867-ce15-4891-bf73-81305a7de1f7.wav +0 -0
  80. package/src/modules/uploads/a5d6fee8-6a59-42c6-9d4a-ac8a3c5e7245.webm +0 -0
  81. package/src/modules/uploads/c13a9785-ca5a-4983-af30-b338ed76d370.webm +0 -0
  82. package/src/modules/uploads/caa1a5a7-71ba-4381-902d-7e2cafdf6dcb.webm +0 -0
  83. package/src/modules/uploads/cbeb0b81-374d-445b-914b-40ace7c8e031.webm +0 -0
  84. package/src/modules/uploads/d626aa82-b10f-493f-aee7-87bfb3361dfc.webm +0 -0
  85. package/src/modules/uploads/d7de4c16-de0c-495d-9612-e72260a6ecca.png +0 -0
  86. package/src/modules/uploads/e532e38a-6421-400e-8a5f-8e7bc8ce411b.wav +0 -0
  87. package/src/modules/uploads/e86ec867-6adf-4c51-84e0-00b0836625e8.webm +0 -0
  88. package/src/modules/utils/applyPagination.ts +13 -0
  89. package/src/modules/utils/applySorting.ts +21 -0
  90. package/src/modules/utils/getConditionsFromFilters.ts +216 -0
  91. package/src/modules/video/video.service.ts +89 -0
  92. package/src/modules/webhook/webhook.constants.ts +9 -0
  93. package/src/modules/webhook/webhook.db.ts +15 -0
  94. package/src/modules/webhook/webhook.dto.ts +9 -0
  95. package/src/modules/webhook/webhook.repository.ts +68 -0
  96. package/src/modules/webhook/webhook.router.ts +29 -0
  97. package/src/modules/webhook/webhook.service.ts +78 -0
  98. package/src/modules/workflow/workflow.db.ts +29 -0
  99. package/src/modules/workflow/workflow.repository.ts +171 -0
  100. package/src/modules/workflow/workflow.service.ts +56 -0
  101. package/src/modules/workflow/workflow.trpc.ts +26 -0
  102. package/src/modules/workflow/workflow.types.ts +30 -0
  103. package/src/modules/workflow/workflow.utils.ts +259 -0
  104. package/src/test/stubs/utils.ts +2 -0
  105. package/src/trpc/context.ts +21 -0
  106. package/src/trpc/index.ts +3 -0
  107. package/src/trpc/procedures.ts +43 -0
  108. package/src/trpc/utils.ts +20 -0
  109. package/src/types.ts +22 -0
  110. package/src/utils/errors.ts +148 -0
  111. package/src/utils/logger.ts +8 -0
  112. package/src/utils/posthog.ts +43 -0
  113. package/src/utils/types.ts +5 -0
package/package.json ADDED
@@ -0,0 +1,205 @@
1
+ {
2
+ "name": "@m5kdev/backend",
3
+ "version": "0.1.0",
4
+ "description": "Composable Express server stack with Drizzle ORM and tRPC.",
5
+ "license": "GPL-3.0-only",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/michalkow/m5kdev.git"
9
+ },
10
+ "homepage": "https://github.com/michalkow/m5kdev#readme",
11
+ "bugs": "https://github.com/michalkow/m5kdev/issues",
12
+ "files": [
13
+ "src"
14
+ ],
15
+ "dependencies": {
16
+ "@aws-sdk/client-s3": "3.891.0",
17
+ "@aws-sdk/client-sts": "3.891.0",
18
+ "@aws-sdk/s3-request-presigner": "3.891.0",
19
+ "@libsql/client": "0.17.0",
20
+ "@mastra/core": "1.0.4",
21
+ "@mastra/rag": "2.0.0",
22
+ "@openrouter/ai-sdk-provider": "1.5.4",
23
+ "@posthog/ai": "6.2.0",
24
+ "@sentry/node": "10.22.0",
25
+ "@trpc/server": "11.4.3",
26
+ "@types/multer": "1.4.12",
27
+ "ai": "5.0.14",
28
+ "better-auth": "1.4.18",
29
+ "bip32": "4.0.0",
30
+ "bip39": "3.1.0",
31
+ "bitcoinjs-lib": "7.0.0",
32
+ "body-parser": "1.20.3",
33
+ "bullmq": "5.58.0",
34
+ "cors": "2.8.5",
35
+ "dotenv": "16.6.1",
36
+ "drizzle-orm": "0.44.3",
37
+ "drizzle-zod": "0.8.2",
38
+ "express": "4.21.2",
39
+ "ffmpeg-ffprobe-static": "6.1.2-rc.1",
40
+ "fluent-ffmpeg": "2.1.3",
41
+ "ioredis": "5.7.0",
42
+ "luxon": "3.7.1",
43
+ "multer": "1.4.5-lts.1",
44
+ "mustache": "4.2.0",
45
+ "neverthrow": "8.2.0",
46
+ "openid-client": "6.8.1",
47
+ "pino": "9.6.0",
48
+ "pino-pretty": "13.0.0",
49
+ "posthog-node": "4.10.2",
50
+ "radashi": "12.6.0",
51
+ "react": "19.2.1",
52
+ "react-dom": "19.2.1",
53
+ "replicate": "1.0.1",
54
+ "resend": "6.5.2",
55
+ "rrule": "2.8.1",
56
+ "stripe": "20.1.0",
57
+ "tiny-secp256k1": "2.2.3",
58
+ "trpc-to-openapi": "2.3.0",
59
+ "uuid": "11.0.5",
60
+ "zod": "4.2.1",
61
+ "@m5kdev/commons": "0.1.0",
62
+ "@m5kdev/config": "0.0.0"
63
+ },
64
+ "devDependencies": {
65
+ "@jest/globals": "30.2.0",
66
+ "@types/body-parser": "1.19.5",
67
+ "@types/cors": "2.8.17",
68
+ "@types/express": "4.17.21",
69
+ "@types/fluent-ffmpeg": "2.1.27",
70
+ "@types/jest": "30.0.0",
71
+ "@types/luxon": "3.7.1",
72
+ "@types/mustache": "4.2.6",
73
+ "@types/node": "20.19.11",
74
+ "@types/react": "19.2.7",
75
+ "@types/react-dom": "19.2.3",
76
+ "drizzle-kit": "0.31.4",
77
+ "jest": "30.1.3",
78
+ "ts-jest": "29.4.4",
79
+ "tslib": "2.8.1",
80
+ "tsup": "8.4.0",
81
+ "tsx": "4.19.2",
82
+ "typescript": "5.9.2"
83
+ },
84
+ "imports": {
85
+ "#modules/ai/*": "./src/modules/ai/*.ts",
86
+ "#modules/utils/*": "./src/modules/utils/*.ts",
87
+ "#modules/base/*": "./src/modules/base/*.ts",
88
+ "#modules/billing/*": "./src/modules/billing/*.ts",
89
+ "#modules/crypto/*": "./src/modules/crypto/*.ts",
90
+ "#modules/auth/*": "./src/modules/auth/*.ts",
91
+ "#modules/tag/*": "./src/modules/tag/*.ts",
92
+ "#modules/connect/*": "./src/modules/connect/*.ts",
93
+ "#modules/workflow/*": "./src/modules/workflow/*.ts",
94
+ "#modules/file/*": "./src/modules/file/*.ts",
95
+ "#modules/recurrence/*": "./src/modules/recurrence/*.ts",
96
+ "#modules/video/*": "./src/modules/video/*.ts",
97
+ "#modules/posthog/*": "./src/modules/posthog/*.ts",
98
+ "#modules/access/*": "./src/modules/access/*.ts",
99
+ "#modules/social/*": "./src/modules/social/*.ts",
100
+ "#modules/clay/*": "./src/modules/clay/*.ts",
101
+ "#modules/webhook/*": "./src/modules/webhook/*.ts",
102
+ "#modules/email/*": "./src/modules/email/*.ts",
103
+ "#lib/*": "./src/lib/*.ts",
104
+ "#trpc": "./src/trpc/index.ts",
105
+ "#utils/*": "./src/utils/*.ts"
106
+ },
107
+ "exports": {
108
+ "./modules/ai/*": {
109
+ "types": "./dist/src/modules/ai/*.d.ts",
110
+ "default": "./dist/src/modules/ai/*.js"
111
+ },
112
+ "./modules/utils/*": {
113
+ "types": "./dist/src/modules/utils/*.d.ts",
114
+ "default": "./dist/src/modules/utils/*.js"
115
+ },
116
+ "./modules/base/*": {
117
+ "types": "./dist/src/modules/base/*.d.ts",
118
+ "default": "./dist/src/modules/base/*.js"
119
+ },
120
+ "./modules/billing/*": {
121
+ "types": "./dist/src/modules/billing/*.d.ts",
122
+ "default": "./dist/src/modules/billing/*.js"
123
+ },
124
+ "./modules/crypto/*": {
125
+ "types": "./dist/src/modules/crypto/*.d.ts",
126
+ "default": "./dist/src/modules/crypto/*.js"
127
+ },
128
+ "./modules/auth/*": {
129
+ "types": "./dist/src/modules/auth/*.d.ts",
130
+ "default": "./dist/src/modules/auth/*.js"
131
+ },
132
+ "./modules/tag/*": {
133
+ "types": "./dist/src/modules/tag/*.d.ts",
134
+ "default": "./dist/src/modules/tag/*.js"
135
+ },
136
+ "./modules/connect/*": {
137
+ "types": "./dist/src/modules/connect/*.d.ts",
138
+ "default": "./dist/src/modules/connect/*.js"
139
+ },
140
+ "./modules/workflow/*": {
141
+ "types": "./dist/src/modules/workflow/*.d.ts",
142
+ "default": "./dist/src/modules/workflow/*.js"
143
+ },
144
+ "./modules/file/*": {
145
+ "types": "./dist/src/modules/file/*.d.ts",
146
+ "default": "./dist/src/modules/file/*.js"
147
+ },
148
+ "./modules/recurrence/*": {
149
+ "types": "./dist/src/modules/recurrence/*.d.ts",
150
+ "default": "./dist/src/modules/recurrence/*.js"
151
+ },
152
+ "./modules/video/*": {
153
+ "types": "./dist/src/modules/video/*.d.ts",
154
+ "default": "./dist/src/modules/video/*.js"
155
+ },
156
+ "./modules/posthog/*": {
157
+ "types": "./dist/src/modules/posthog/*.d.ts",
158
+ "default": "./dist/src/modules/posthog/*.js"
159
+ },
160
+ "./modules/access/*": {
161
+ "types": "./dist/src/modules/access/*.d.ts",
162
+ "default": "./dist/src/modules/access/*.js"
163
+ },
164
+ "./modules/social/*": {
165
+ "types": "./dist/src/modules/social/*.d.ts",
166
+ "default": "./dist/src/modules/social/*.js"
167
+ },
168
+ "./modules/clay/*": {
169
+ "types": "./dist/src/modules/clay/*.d.ts",
170
+ "default": "./dist/src/modules/clay/*.js"
171
+ },
172
+ "./modules/webhook/*": {
173
+ "types": "./dist/src/modules/webhook/*.d.ts",
174
+ "default": "./dist/src/modules/webhook/*.js"
175
+ },
176
+ "./modules/email/*": {
177
+ "types": "./dist/src/modules/email/*.d.ts",
178
+ "default": "./dist/src/modules/email/*.js"
179
+ },
180
+ "./lib/*": {
181
+ "types": "./dist/src/lib/*.d.ts",
182
+ "default": "./dist/src/lib/*.js"
183
+ },
184
+ "./trpc": {
185
+ "types": "./dist/src/trpc/index.d.ts",
186
+ "default": "./dist/src/trpc/index.js"
187
+ },
188
+ "./utils/*": {
189
+ "types": "./dist/src/utils/*.d.ts",
190
+ "default": "./dist/src/utils/*.js"
191
+ },
192
+ "./types": {
193
+ "types": "./dist/src/types.d.ts",
194
+ "default": "./dist/src/types.js"
195
+ }
196
+ },
197
+ "scripts": {
198
+ "lint": "biome check .",
199
+ "lint:fix": "biome check . --write",
200
+ "build": "tsc --build",
201
+ "check-types": "tsc --noEmit",
202
+ "test": "jest -c jest.config.ts",
203
+ "test:watch": "jest -c jest.config.ts --watch"
204
+ }
205
+ }
@@ -0,0 +1,5 @@
1
+ import { PostHog } from "posthog-node";
2
+
3
+ export const posthogClient = new PostHog(process.env.VITE_PUBLIC_POSTHOG_KEY!, {
4
+ host: process.env.VITE_PUBLIC_POSTHOG_HOST!,
5
+ });
@@ -0,0 +1,8 @@
1
+ import * as Sentry from "@sentry/node";
2
+
3
+ Sentry.init({
4
+ dsn: process.env.SENTRY_SERVER_DNS,
5
+
6
+ // Set sampling rate for profiling - this is evaluated only once per SDK.init
7
+ profileSessionSampleRate: 1.0,
8
+ });
@@ -0,0 +1,36 @@
1
+ import { and, eq } from "drizzle-orm";
2
+ import type { LibSQLDatabase } from "drizzle-orm/libsql";
3
+ import { ok } from "neverthrow";
4
+ import * as auth from "#modules/auth/auth.db";
5
+ import type { ServerResultAsync } from "#modules/base/base.dto";
6
+ import { BaseRepository } from "#modules/base/base.repository";
7
+
8
+ const schema = { ...auth };
9
+ type Schema = typeof schema;
10
+ type Orm = LibSQLDatabase<Schema>;
11
+
12
+ export class AccessRepository extends BaseRepository<Orm, Schema, Record<string, never>> {
13
+ async getOrganizationRole(userId: string, organizationId: string): ServerResultAsync<string> {
14
+ return this.throwableAsync(async () => {
15
+ const [member] = await this.orm
16
+ .select({ role: schema.members.role })
17
+ .from(schema.members)
18
+ .where(
19
+ and(eq(schema.members.organizationId, organizationId), eq(schema.members.userId, userId))
20
+ )
21
+ .limit(1);
22
+ return ok(member?.role ?? "");
23
+ });
24
+ }
25
+
26
+ async getTeamRole(userId: string, teamId: string): ServerResultAsync<string> {
27
+ return this.throwableAsync(async () => {
28
+ const [member] = await this.orm
29
+ .select({ role: schema.teamMembers.role })
30
+ .from(schema.teamMembers)
31
+ .where(and(eq(schema.teamMembers.teamId, teamId), eq(schema.teamMembers.userId, userId)))
32
+ .limit(1);
33
+ return ok(member?.role ?? "");
34
+ });
35
+ }
36
+ }
@@ -0,0 +1,81 @@
1
+ import type { Statements } from "better-auth/plugins/access";
2
+ import { err, ok } from "neverthrow";
3
+ import type { AccessRepository } from "#modules/access/access.repository";
4
+ import type { AccessControlRoles } from "#modules/access/access.utils";
5
+ import type { ServerResultAsync } from "#modules/base/base.dto";
6
+ import { BaseService } from "#modules/base/base.service";
7
+
8
+ type User = {
9
+ id: string;
10
+ role: string;
11
+ };
12
+
13
+ export class AccessService<T extends Statements> extends BaseService<
14
+ { access: AccessRepository },
15
+ never
16
+ > {
17
+ acr: AccessControlRoles<T>;
18
+
19
+ constructor(repositories: { access: AccessRepository }, acr: AccessControlRoles<T>) {
20
+ super(repositories);
21
+ this.acr = acr;
22
+ }
23
+
24
+ authorize(
25
+ level: "user" | "team" | "organization",
26
+ role: string,
27
+ request: any,
28
+ connector: "OR" | "AND" = "AND"
29
+ ) {
30
+ try {
31
+ return !!this.acr[level][role].authorize(request, connector).success;
32
+ } catch (error) {
33
+ console.error(error);
34
+ return false;
35
+ }
36
+ }
37
+
38
+ async checkAccess(
39
+ user: User,
40
+ level: "team" | "organization",
41
+ levelId: string,
42
+ request: any,
43
+ connector: "OR" | "AND" = "AND"
44
+ ): ServerResultAsync<boolean> {
45
+ const role =
46
+ level === "organization"
47
+ ? await this.repository.access.getOrganizationRole(user.id, levelId)
48
+ : await this.repository.access.getTeamRole(user.id, levelId);
49
+ if (role.isErr()) return err(role.error);
50
+ return ok(this.authorize(level, role.value, request, connector));
51
+ }
52
+
53
+ async hasAccess(
54
+ user: User,
55
+ level: "user" | "team" | "organization",
56
+ levelId: string,
57
+ request: any,
58
+ connector: "OR" | "AND" = "AND"
59
+ ): ServerResultAsync<boolean> {
60
+ // FIXME: catch all admin user access for now
61
+ if (user.role === "admin") return ok(true);
62
+ const userAccess = this.authorize("user", user.role, request, connector);
63
+ if (level === "user") return ok(userAccess && user.id === levelId);
64
+ if (userAccess) return ok(true);
65
+
66
+ const organizationAccess = await this.checkAccess(
67
+ user,
68
+ "organization",
69
+ levelId,
70
+ request,
71
+ connector
72
+ );
73
+ if (organizationAccess.isErr()) return err(organizationAccess.error);
74
+ if (level === "organization") return organizationAccess;
75
+ if (organizationAccess.value) return ok(true);
76
+
77
+ const teamAccess = await this.checkAccess(user, "team", levelId, request, connector);
78
+ if (teamAccess.isErr()) return err(teamAccess.error);
79
+ return teamAccess;
80
+ }
81
+ }
@@ -0,0 +1,216 @@
1
+ import { createClient } from "@libsql/client";
2
+ import type { LibSQLDatabase } from "drizzle-orm/libsql";
3
+ import { drizzle } from "drizzle-orm/libsql";
4
+ import fs from "fs";
5
+ import path from "path";
6
+ import { AccessRepository } from "#modules/access/access.repository";
7
+ import { AccessService } from "#modules/access/access.service";
8
+ import { createAccessRoles } from "#modules/access/access.utils";
9
+ import * as authSchema from "#modules/auth/auth.db";
10
+
11
+ describe("AccessService", () => {
12
+ const statements = {
13
+ project: ["create", "share", "update", "delete"],
14
+ } as const;
15
+
16
+ const roleDefinitions = {
17
+ user: {
18
+ admin: { project: ["create", "share", "update"] },
19
+ },
20
+ team: {
21
+ admin: { project: ["create", "share", "update"] },
22
+ },
23
+ organization: {
24
+ admin: { project: ["create", "share", "update"] },
25
+ },
26
+ } as const;
27
+
28
+ const acr = createAccessRoles(statements, roleDefinitions);
29
+ // Minimal repository stub; not used by authorize() unit tests
30
+ const accessService = new AccessService({ access: {} as unknown as AccessRepository }, acr);
31
+
32
+ describe("user level", () => {
33
+ it("allows defined action", () => {
34
+ expect(accessService.authorize("user", "admin", { project: ["create"] })).toBe(true);
35
+ });
36
+
37
+ it("denies undefined action", () => {
38
+ expect(accessService.authorize("user", "admin", { project: ["delete"] })).toBe(false);
39
+ });
40
+
41
+ it("handles AND vs OR connectors for multiple actions", () => {
42
+ // admin has create but not delete
43
+ expect(
44
+ accessService.authorize("user", "admin", { project: ["create", "delete"] }, "AND")
45
+ ).toBe(false);
46
+ expect(
47
+ accessService.authorize(
48
+ "user",
49
+ "admin",
50
+ { project: { actions: ["create", "delete"], connector: "OR" } },
51
+ "OR"
52
+ )
53
+ ).toBe(true);
54
+ });
55
+ });
56
+
57
+ describe("team level", () => {
58
+ it("allows defined action", () => {
59
+ expect(accessService.authorize("team", "admin", { project: ["share"] })).toBe(true);
60
+ });
61
+
62
+ it("denies undefined action", () => {
63
+ expect(accessService.authorize("team", "admin", { project: ["delete"] })).toBe(false);
64
+ });
65
+ });
66
+
67
+ describe("organization level", () => {
68
+ it("allows defined action", () => {
69
+ expect(accessService.authorize("organization", "admin", { project: ["update"] })).toBe(true);
70
+ });
71
+
72
+ it("denies undefined action", () => {
73
+ expect(accessService.authorize("organization", "admin", { project: ["delete"] })).toBe(false);
74
+ });
75
+ });
76
+ });
77
+
78
+ describe("AccessRepository (libsql local)", () => {
79
+ const dbFile = path.join(__dirname, "access.test.sqlite");
80
+ const url = `file:${dbFile}`;
81
+ const client = createClient({ url });
82
+ type Schema = typeof authSchema;
83
+ type Orm = LibSQLDatabase<Schema>;
84
+ const orm = drizzle(client, { schema: authSchema }) as Orm;
85
+ const repo = new AccessRepository({ orm, schema: authSchema as Schema }, {});
86
+
87
+ beforeAll(async () => {
88
+ // Create minimal tables required for tests
89
+ await client.execute(`
90
+ CREATE TABLE IF NOT EXISTS members (
91
+ id TEXT PRIMARY KEY,
92
+ organization_id TEXT NOT NULL,
93
+ user_id TEXT NOT NULL,
94
+ role TEXT NOT NULL
95
+ );
96
+ `);
97
+ await client.execute(`
98
+ CREATE TABLE IF NOT EXISTS teammembers (
99
+ id TEXT PRIMARY KEY,
100
+ team_id TEXT NOT NULL,
101
+ user_id TEXT NOT NULL,
102
+ role TEXT NOT NULL
103
+ );
104
+ `);
105
+ });
106
+
107
+ beforeEach(async () => {
108
+ await client.execute({
109
+ sql: "INSERT INTO members (id, organization_id, user_id, role) VALUES (?, ?, ?, ?)",
110
+ args: ["m1", "org1", "user1", "admin"],
111
+ });
112
+ await client.execute({
113
+ sql: "INSERT INTO teammembers (id, team_id, user_id, role) VALUES (?, ?, ?, ?)",
114
+ args: ["tm1", "team1", "user1", "admin"],
115
+ });
116
+ });
117
+
118
+ afterEach(async () => {
119
+ await client.execute("DELETE FROM members;");
120
+ await client.execute("DELETE FROM teammembers;");
121
+ });
122
+
123
+ afterAll(async () => {
124
+ try {
125
+ await client.close();
126
+ } catch {}
127
+ try {
128
+ if (fs.existsSync(dbFile)) fs.unlinkSync(dbFile);
129
+ } catch {}
130
+ });
131
+
132
+ it("returns organization role for user", async () => {
133
+ const res = await repo.getOrganizationRole("user1", "org1");
134
+ expect(res.isOk()).toBe(true);
135
+ if (res.isOk()) expect(res.value).toBe("admin");
136
+ });
137
+
138
+ it("returns team role for user", async () => {
139
+ const res = await repo.getTeamRole("user1", "team1");
140
+ expect(res.isOk()).toBe(true);
141
+ if (res.isOk()) expect(res.value).toBe("admin");
142
+ });
143
+
144
+ describe("AccessService.hasAccess (with repo)", () => {
145
+ const hasAccessStatements = {
146
+ project: ["create", "share", "update", "delete"],
147
+ } as const;
148
+
149
+ const hasAccessRoles = {
150
+ user: {
151
+ member: { project: ["create"] },
152
+ },
153
+ team: {
154
+ manager: { project: ["share"] },
155
+ },
156
+ organization: {
157
+ manager: { project: ["update"] },
158
+ },
159
+ } as const;
160
+
161
+ const acr2 = createAccessRoles(hasAccessStatements, hasAccessRoles);
162
+ const service = new AccessService({ access: repo }, acr2);
163
+
164
+ it("admin override grants access", async () => {
165
+ const result = await service.hasAccess(
166
+ { id: "any", role: "admin" },
167
+ "organization",
168
+ "whatever",
169
+ { project: ["delete"] }
170
+ );
171
+ expect(result.isOk()).toBe(true);
172
+ if (result.isOk()) expect(result.value).toBe(true);
173
+ });
174
+
175
+ it("user level access only for self and allowed action", async () => {
176
+ const allowSelf = await service.hasAccess({ id: "user2", role: "member" }, "user", "user2", {
177
+ project: ["create"],
178
+ });
179
+ expect(allowSelf.isOk()).toBe(true);
180
+ if (allowSelf.isOk()) expect(allowSelf.value).toBe(true);
181
+
182
+ const denyOther = await service.hasAccess({ id: "user2", role: "member" }, "user", "other", {
183
+ project: ["create"],
184
+ });
185
+ expect(denyOther.isOk()).toBe(true);
186
+ if (denyOther.isOk()) expect(denyOther.value).toBe(false);
187
+ });
188
+
189
+ it("organization membership grants access based on repo role", async () => {
190
+ await client.execute({
191
+ sql: "INSERT INTO members (id, organization_id, user_id, role) VALUES (?, ?, ?, ?)",
192
+ args: ["m2", "org2", "user2", "manager"],
193
+ });
194
+ const result = await service.hasAccess(
195
+ { id: "user2", role: "member" },
196
+ "organization",
197
+ "org2",
198
+ { project: ["update"] }
199
+ );
200
+ expect(result.isOk()).toBe(true);
201
+ if (result.isOk()) expect(result.value).toBe(true);
202
+ });
203
+
204
+ it("team membership grants access based on repo role", async () => {
205
+ await client.execute({
206
+ sql: "INSERT INTO teammembers (id, team_id, user_id, role) VALUES (?, ?, ?, ?)",
207
+ args: ["tm2", "team2", "user2", "manager"],
208
+ });
209
+ const result = await service.hasAccess({ id: "user2", role: "member" }, "team", "team2", {
210
+ project: ["share"],
211
+ });
212
+ expect(result.isOk()).toBe(true);
213
+ if (result.isOk()) expect(result.value).toBe(true);
214
+ });
215
+ });
216
+ });
@@ -0,0 +1,46 @@
1
+ import {
2
+ type AccessControl,
3
+ createAccessControl,
4
+ type Role,
5
+ type Statements,
6
+ type Subset,
7
+ } from "better-auth/plugins/access";
8
+
9
+ export type AccessControlRoles<T extends Statements> = {
10
+ ac: AccessControl<T>;
11
+ user: Record<string, Role<Subset<keyof T, T>>>;
12
+ team: Record<string, Role<Subset<keyof T, T>>>;
13
+ organization: Record<string, Role<Subset<keyof T, T>>>;
14
+ };
15
+
16
+ // Allow defining role statements with any subset of resources from T
17
+ // and only actions permitted by each resource definition in T
18
+ export type RoleDefinition<T extends Statements> = {
19
+ [K in keyof T]?: T[K] extends readonly (infer A)[] ? readonly A[] : never;
20
+ };
21
+
22
+ export type RoleDefinitions<T extends Statements> = {
23
+ user: Record<string, RoleDefinition<T>>;
24
+ team: Record<string, RoleDefinition<T>>;
25
+ organization: Record<string, RoleDefinition<T>>;
26
+ };
27
+
28
+ export function createAccessRoles<T extends Statements>(
29
+ statements: T,
30
+ roleDefinitions: RoleDefinitions<T>
31
+ ): AccessControlRoles<T> {
32
+ const ac = createAccessControl(statements);
33
+ const user: Record<string, Role<Subset<keyof T, T>>> = {};
34
+ const team: Record<string, Role<Subset<keyof T, T>>> = {};
35
+ const organization: Record<string, Role<Subset<keyof T, T>>> = {};
36
+ for (const [roleName, roleStatements] of Object.entries(roleDefinitions.user)) {
37
+ user[roleName] = ac.newRole(roleStatements as unknown as Subset<keyof T, T>);
38
+ }
39
+ for (const [roleName, roleStatements] of Object.entries(roleDefinitions.team)) {
40
+ team[roleName] = ac.newRole(roleStatements as unknown as Subset<keyof T, T>);
41
+ }
42
+ for (const [roleName, roleStatements] of Object.entries(roleDefinitions.organization)) {
43
+ organization[roleName] = ac.newRole(roleStatements as unknown as Subset<keyof T, T>);
44
+ }
45
+ return { ac, user, team, organization };
46
+ }
@@ -0,0 +1,38 @@
1
+ import { integer, real, sqliteTable as table, text } from "drizzle-orm/sqlite-core";
2
+ import { v4 as uuidv4 } from "uuid";
3
+ import { organizations, teams, users } from "#modules/auth/auth.db";
4
+
5
+ export const chats = table("chats", {
6
+ id: text("id").primaryKey().$default(uuidv4),
7
+ userId: text("user_id")
8
+ .notNull()
9
+ .references(() => users.id, { onDelete: "cascade" }),
10
+ title: text("title"),
11
+ type: text("type"),
12
+ conversation: text("conversation", { mode: "json" }),
13
+ createdAt: integer("created_at", { mode: "timestamp" }).$default(() => new Date()),
14
+ updatedAt: integer("updated_at", { mode: "timestamp" })
15
+ .notNull()
16
+ .$default(() => new Date()),
17
+ });
18
+
19
+ export const aiUsage = table("ai_usage", {
20
+ id: text("id").primaryKey().$default(uuidv4),
21
+ userId: text("user_id").references(() => users.id, { onDelete: "cascade" }),
22
+ teamId: text("team_id").references(() => teams.id, { onDelete: "cascade" }),
23
+ organizationId: text("organization_id").references(() => organizations.id, {
24
+ onDelete: "cascade",
25
+ }),
26
+ feature: text("feature").notNull(),
27
+ provider: text("provider").notNull(),
28
+ model: text("model").notNull(),
29
+ inputTokens: integer("input_tokens"),
30
+ outputTokens: integer("output_tokens"),
31
+ totalTokens: integer("total_tokens"),
32
+ cost: real("cost"),
33
+ traceId: text("trace_id"),
34
+ createdAt: integer("created_at", { mode: "timestamp" })
35
+ .notNull()
36
+ .$default(() => new Date()),
37
+ metadata: text("metadata", { mode: "json" }),
38
+ });