@rangka/core 0.1.0 → 0.1.2

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 (197) hide show
  1. package/package.json +6 -2
  2. package/.claude/skills/extend-core/SKILL.md +0 -133
  3. package/.turbo/turbo-build.log +0 -4
  4. package/CHANGELOG.md +0 -18
  5. package/CLAUDE.md +0 -180
  6. package/src/__tests__/coerce.test.ts +0 -154
  7. package/src/__tests__/context.test.ts +0 -111
  8. package/src/__tests__/helpers.ts +0 -21
  9. package/src/__tests__/index.test.ts +0 -7
  10. package/src/__tests__/widgets.test.ts +0 -197
  11. package/src/api/__tests__/handlers.test.ts +0 -389
  12. package/src/api/__tests__/include-resolver.test.ts +0 -393
  13. package/src/api/__tests__/middleware.test.ts +0 -100
  14. package/src/api/__tests__/openapi-schema.test.ts +0 -210
  15. package/src/api/__tests__/query-parser.test.ts +0 -291
  16. package/src/api/__tests__/route-generator.test.ts +0 -137
  17. package/src/api/__tests__/server.test.ts +0 -73
  18. package/src/api/__tests__/swagger.test.ts +0 -166
  19. package/src/api/handlers.ts +0 -274
  20. package/src/api/include-resolver.ts +0 -27
  21. package/src/api/index.ts +0 -4
  22. package/src/api/meta-handler.ts +0 -254
  23. package/src/api/openapi-schema.ts +0 -99
  24. package/src/api/query-parser.ts +0 -315
  25. package/src/api/route-generator.ts +0 -448
  26. package/src/api/server.ts +0 -147
  27. package/src/api/types.ts +0 -16
  28. package/src/audit/__tests__/audit.test.ts +0 -144
  29. package/src/audit/index.ts +0 -3
  30. package/src/audit/record.ts +0 -69
  31. package/src/audit/tables.ts +0 -48
  32. package/src/audit/types.ts +0 -26
  33. package/src/auth/__tests__/core-module.test.ts +0 -54
  34. package/src/auth/__tests__/debug.test.ts +0 -47
  35. package/src/auth/__tests__/field-permissions.test.ts +0 -245
  36. package/src/auth/__tests__/integration.test.ts +0 -208
  37. package/src/auth/__tests__/meta-boot.test.ts +0 -538
  38. package/src/auth/__tests__/model-permissions.test.ts +0 -205
  39. package/src/auth/__tests__/password.test.ts +0 -29
  40. package/src/auth/__tests__/permission-registry.test.ts +0 -313
  41. package/src/auth/__tests__/scope-hook.test.ts +0 -509
  42. package/src/auth/__tests__/scope-registry.test.ts +0 -297
  43. package/src/auth/__tests__/scopes.test.ts +0 -66
  44. package/src/auth/__tests__/session.test.ts +0 -214
  45. package/src/auth/core-models.ts +0 -52
  46. package/src/auth/core-module.ts +0 -59
  47. package/src/auth/debug.ts +0 -157
  48. package/src/auth/field-permissions.ts +0 -116
  49. package/src/auth/index.ts +0 -37
  50. package/src/auth/model-permissions.ts +0 -59
  51. package/src/auth/password.ts +0 -22
  52. package/src/auth/permission-registry.ts +0 -171
  53. package/src/auth/scope-filters.ts +0 -11
  54. package/src/auth/scope-registry.ts +0 -121
  55. package/src/auth/scopes.ts +0 -146
  56. package/src/auth/seed.ts +0 -44
  57. package/src/auth/session.ts +0 -178
  58. package/src/auth/types.ts +0 -50
  59. package/src/boot/__tests__/page-scanning.test.ts +0 -170
  60. package/src/boot/__tests__/page-utils.test.ts +0 -225
  61. package/src/boot/__tests__/project-scanner.test.ts +0 -88
  62. package/src/boot/dependency-sort.ts +0 -82
  63. package/src/boot/discovery.ts +0 -85
  64. package/src/boot/index.ts +0 -457
  65. package/src/boot/page-utils.ts +0 -110
  66. package/src/boot/project-scanner.ts +0 -397
  67. package/src/boot/schema-loader.ts +0 -26
  68. package/src/boot/schema-merger.ts +0 -125
  69. package/src/boot/traits.ts +0 -25
  70. package/src/boot/types.ts +0 -73
  71. package/src/context.ts +0 -105
  72. package/src/db/__tests__/cascade-delete.test.ts +0 -182
  73. package/src/db/__tests__/desired-state.test.ts +0 -136
  74. package/src/db/__tests__/diff-engine.test.ts +0 -635
  75. package/src/db/__tests__/field-mapper.test.ts +0 -355
  76. package/src/db/__tests__/introspect.test.ts +0 -70
  77. package/src/db/__tests__/search-filter.test.ts +0 -45
  78. package/src/db/__tests__/sequence.test.ts +0 -221
  79. package/src/db/auto-sync.ts +0 -133
  80. package/src/db/client.ts +0 -147
  81. package/src/db/desired-state.ts +0 -98
  82. package/src/db/diff-engine.ts +0 -305
  83. package/src/db/field-mapper.ts +0 -504
  84. package/src/db/filter-applier.ts +0 -89
  85. package/src/db/include-resolver.ts +0 -40
  86. package/src/db/index.ts +0 -23
  87. package/src/db/introspect.ts +0 -265
  88. package/src/db/model-include-resolver.ts +0 -327
  89. package/src/db/model-ops.ts +0 -281
  90. package/src/db/scope-enforcer.ts +0 -37
  91. package/src/db/types.ts +0 -98
  92. package/src/errors.ts +0 -41
  93. package/src/events/__tests__/bus.test.ts +0 -105
  94. package/src/events/bus.ts +0 -89
  95. package/src/events/index.ts +0 -2
  96. package/src/events/types.ts +0 -9
  97. package/src/external-model/__tests__/computed-fields.test.ts +0 -106
  98. package/src/external-model/__tests__/field-mapper.test.ts +0 -160
  99. package/src/external-model/__tests__/in-memory-ops.test.ts +0 -247
  100. package/src/external-model/__tests__/mutation-executor.test.ts +0 -160
  101. package/src/external-model/__tests__/query-executor.test.ts +0 -284
  102. package/src/external-model/__tests__/schema-converter.test.ts +0 -174
  103. package/src/external-model/computed-fields.ts +0 -15
  104. package/src/external-model/define.ts +0 -5
  105. package/src/external-model/external-model-ops.ts +0 -108
  106. package/src/external-model/field-mapper.ts +0 -66
  107. package/src/external-model/in-memory-ops.ts +0 -107
  108. package/src/external-model/index.ts +0 -7
  109. package/src/external-model/mutation-executor.ts +0 -71
  110. package/src/external-model/query-executor.ts +0 -100
  111. package/src/external-model/schema-converter.ts +0 -53
  112. package/src/external-model/types.ts +0 -32
  113. package/src/fixtures/__tests__/fixtures.test.ts +0 -203
  114. package/src/fixtures/index.ts +0 -10
  115. package/src/fixtures/loader.ts +0 -196
  116. package/src/fixtures/registry.ts +0 -125
  117. package/src/fixtures/types.ts +0 -33
  118. package/src/helpers/assert-ownership.ts +0 -19
  119. package/src/helpers/coerce.ts +0 -28
  120. package/src/helpers/stamping.ts +0 -28
  121. package/src/helpers/validation.ts +0 -14
  122. package/src/hooks/__tests__/context.test.ts +0 -73
  123. package/src/hooks/__tests__/executor.test.ts +0 -433
  124. package/src/hooks/__tests__/middleware.test.ts +0 -224
  125. package/src/hooks/__tests__/registry.test.ts +0 -50
  126. package/src/hooks/context.ts +0 -89
  127. package/src/hooks/errors.ts +0 -11
  128. package/src/hooks/executor.ts +0 -115
  129. package/src/hooks/index.ts +0 -10
  130. package/src/hooks/middleware.ts +0 -220
  131. package/src/hooks/registry.ts +0 -20
  132. package/src/hooks/types.ts +0 -32
  133. package/src/index.ts +0 -172
  134. package/src/jobs/__tests__/enqueue.test.ts +0 -77
  135. package/src/jobs/__tests__/integration.test.ts +0 -71
  136. package/src/jobs/__tests__/registry.test.ts +0 -103
  137. package/src/jobs/__tests__/scheduler.test.ts +0 -92
  138. package/src/jobs/__tests__/worker-execution.test.ts +0 -202
  139. package/src/jobs/__tests__/worker.test.ts +0 -119
  140. package/src/jobs/enqueue.ts +0 -93
  141. package/src/jobs/index.ts +0 -14
  142. package/src/jobs/registry.ts +0 -92
  143. package/src/jobs/scheduler.ts +0 -205
  144. package/src/jobs/tables.ts +0 -132
  145. package/src/jobs/types.ts +0 -62
  146. package/src/jobs/worker.ts +0 -272
  147. package/src/model-api/__tests__/cross-boundary-includes.test.ts +0 -366
  148. package/src/model-api/__tests__/extended-api.test.ts +0 -244
  149. package/src/model-api/__tests__/filter-applier.test.ts +0 -177
  150. package/src/model-api/__tests__/filter-translator.test.ts +0 -186
  151. package/src/model-api/__tests__/include-resolver.test.ts +0 -226
  152. package/src/model-api/__tests__/model-access.test.ts +0 -284
  153. package/src/model-api/__tests__/query-builder.test.ts +0 -224
  154. package/src/model-api/__tests__/scope-enforcer.test.ts +0 -268
  155. package/src/model-api/field-access.ts +0 -28
  156. package/src/model-api/filter-applier.ts +0 -1
  157. package/src/model-api/filter-translator.ts +0 -67
  158. package/src/model-api/include-resolver.ts +0 -2
  159. package/src/model-api/index.ts +0 -86
  160. package/src/model-api/query-builder.ts +0 -155
  161. package/src/model-api/scope-enforcer.ts +0 -3
  162. package/src/model-api/types.ts +0 -139
  163. package/src/plugins/__tests__/adapter-registry.test.ts +0 -92
  164. package/src/plugins/__tests__/lifecycle.test.ts +0 -96
  165. package/src/plugins/__tests__/loader.test.ts +0 -273
  166. package/src/plugins/__tests__/validator.test.ts +0 -275
  167. package/src/plugins/adapter-registry.ts +0 -42
  168. package/src/plugins/define.ts +0 -5
  169. package/src/plugins/index.ts +0 -28
  170. package/src/plugins/lifecycle.ts +0 -27
  171. package/src/plugins/loader.ts +0 -126
  172. package/src/plugins/types.ts +0 -76
  173. package/src/plugins/validator.ts +0 -141
  174. package/src/schema/__tests__/registry-models-by-module.test.ts +0 -58
  175. package/src/schema/registry.ts +0 -93
  176. package/src/schema/relationships.ts +0 -93
  177. package/src/schema/types.ts +0 -43
  178. package/src/services/__tests__/integration.test.ts +0 -63
  179. package/src/services/__tests__/registry.test.ts +0 -175
  180. package/src/services/index.ts +0 -13
  181. package/src/services/registry.ts +0 -156
  182. package/src/services/types.ts +0 -27
  183. package/src/validation/__tests__/field-validator.test.ts +0 -195
  184. package/src/validation/field-validator.ts +0 -113
  185. package/src/validation/index.ts +0 -1
  186. package/src/widgets/index.ts +0 -3
  187. package/src/widgets/slot-validator.ts +0 -87
  188. package/src/widgets/widget-registry.ts +0 -32
  189. package/tests/boot.test.ts +0 -323
  190. package/tests/dependency-sort.test.ts +0 -99
  191. package/tests/discovery.test.ts +0 -126
  192. package/tests/registry.test.ts +0 -216
  193. package/tests/schema-loader.test.ts +0 -52
  194. package/tests/schema-merger.test.ts +0 -180
  195. package/tsconfig.json +0 -9
  196. package/tsconfig.tsbuildinfo +0 -1
  197. package/vitest.config.ts +0 -14
package/src/auth/debug.ts DELETED
@@ -1,157 +0,0 @@
1
- import type { DatabaseClient } from '../db/client.js';
2
- import type { PermissionRegistry } from './permission-registry.js';
3
- import type { SchemaRegistry } from '../schema/registry.js';
4
-
5
- export interface DebugResult {
6
- user: { email: string; id: string; enabled: boolean };
7
- roles: string[];
8
- inheritanceChains: Record<string, string[]>;
9
- effectivePermissions: Record<string, Record<string, boolean>>;
10
- fieldRestrictions: Record<string, { hidden: string[]; readOnly: string[] }>;
11
- }
12
-
13
- const TRACKED_ACTIONS = ['read', 'write', 'create', 'delete'] as const;
14
-
15
- // Looks up a user by email and resolves their full permission picture:
16
- // roles, inheritance chains, effective model permissions, and field restrictions.
17
- export async function debugPermissions(
18
- email: string,
19
- db: DatabaseClient,
20
- permissionRegistry: PermissionRegistry,
21
- _schemaRegistry: SchemaRegistry,
22
- ): Promise<DebugResult> {
23
- const user = await findUserByEmail(db, email);
24
- const roleNames = await getUserRoleNames(db, user.id);
25
- const resolved = permissionRegistry.resolvePermissionsForRoles(roleNames);
26
-
27
- return {
28
- user: { email: user.email, id: user.id, enabled: user.enabled },
29
- roles: roleNames,
30
- inheritanceChains: buildInheritanceChains(roleNames, permissionRegistry),
31
- effectivePermissions: buildEffectivePermissions(resolved),
32
- fieldRestrictions: buildFieldRestrictions(resolved),
33
- };
34
- }
35
-
36
- async function findUserByEmail(db: DatabaseClient, email: string) {
37
- const user = (await db
38
- .selectFrom('core.user')
39
- .selectAll()
40
- .where('email', '=', email)
41
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
42
- .executeTakeFirst()) as any;
43
-
44
- if (!user) throw new Error(`User not found: ${email}`);
45
- return user;
46
- }
47
-
48
- async function getUserRoleNames(db: DatabaseClient, userId: string): Promise<string[]> {
49
- const userRoles = await db
50
- .selectFrom('core.user_role')
51
- .selectAll()
52
- .where('user_id', '=', userId)
53
- .execute();
54
-
55
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
56
- const roleIds = userRoles.map((ur: any) => ur.role_id);
57
- if (roleIds.length === 0) return [];
58
-
59
- const roles = await db.selectFrom('core.role').selectAll().where('id', 'in', roleIds).execute();
60
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
61
- return roles.map((r: any) => r.name);
62
- }
63
-
64
- function buildInheritanceChains(
65
- roleNames: string[],
66
- permissionRegistry: PermissionRegistry,
67
- ): Record<string, string[]> {
68
- const chains: Record<string, string[]> = {};
69
- for (const name of roleNames) {
70
- chains[name] = permissionRegistry.getInheritanceChain(name);
71
- }
72
- return chains;
73
- }
74
-
75
- function buildEffectivePermissions(
76
- resolved: ReturnType<PermissionRegistry['resolvePermissionsForRoles']>,
77
- ): Record<string, Record<string, boolean>> {
78
- const permissions: Record<string, Record<string, boolean>> = {};
79
-
80
- for (const [model, modelPerms] of Object.entries(resolved.models)) {
81
- const granted: Record<string, boolean> = {};
82
- for (const action of TRACKED_ACTIONS) {
83
- if (modelPerms[action]) granted[action] = true;
84
- }
85
- permissions[model] = granted;
86
- }
87
-
88
- return permissions;
89
- }
90
-
91
- function buildFieldRestrictions(
92
- resolved: ReturnType<PermissionRegistry['resolvePermissionsForRoles']>,
93
- ): Record<string, { hidden: string[]; readOnly: string[] }> {
94
- const restrictions: Record<string, { hidden: string[]; readOnly: string[] }> = {};
95
-
96
- for (const [model, modelPerms] of Object.entries(resolved.models)) {
97
- if (!modelPerms.fieldPermissions) continue;
98
-
99
- const hidden: string[] = [];
100
- const readOnly: string[] = [];
101
-
102
- for (const [field, fieldPerm] of Object.entries(modelPerms.fieldPermissions)) {
103
- if (fieldPerm.read === false) hidden.push(field);
104
- else if (fieldPerm.write === false) readOnly.push(field);
105
- }
106
-
107
- if (hidden.length > 0 || readOnly.length > 0) {
108
- restrictions[model] = { hidden, readOnly };
109
- }
110
- }
111
-
112
- return restrictions;
113
- }
114
-
115
- export function formatDebugResult(result: DebugResult): string {
116
- const lines: string[] = [];
117
-
118
- lines.push(`User: ${result.user.email} (${result.user.id})`);
119
- lines.push(`Enabled: ${result.user.enabled}`);
120
- lines.push(`Roles: ${result.roles.join(', ') || '(none)'}`);
121
- lines.push('');
122
-
123
- if (Object.keys(result.inheritanceChains).length > 0) {
124
- lines.push('Role Inheritance:');
125
- for (const [role, chain] of Object.entries(result.inheritanceChains)) {
126
- if (chain.length > 0) {
127
- lines.push(` ${role} ← ${chain.join(' ← ')}`);
128
- } else {
129
- lines.push(` ${role} (no inheritance)`);
130
- }
131
- }
132
- lines.push('');
133
- }
134
-
135
- lines.push('Effective Permissions:');
136
- for (const [model, perms] of Object.entries(result.effectivePermissions)) {
137
- const actions = Object.entries(perms)
138
- .filter(([, v]) => v)
139
- .map(([k]) => k);
140
- lines.push(` ${model}: ${actions.join(', ') || '(none)'}`);
141
- }
142
- lines.push('');
143
-
144
- if (Object.keys(result.fieldRestrictions).length > 0) {
145
- lines.push('Field Restrictions:');
146
- for (const [model, restrictions] of Object.entries(result.fieldRestrictions)) {
147
- if (restrictions.hidden.length > 0) {
148
- lines.push(` ${model} [hidden]: ${restrictions.hidden.join(', ')}`);
149
- }
150
- if (restrictions.readOnly.length > 0) {
151
- lines.push(` ${model} [read-only]: ${restrictions.readOnly.join(', ')}`);
152
- }
153
- }
154
- }
155
-
156
- return lines.join('\n');
157
- }
@@ -1,116 +0,0 @@
1
- import type { FastifyRequest, FastifyReply } from 'fastify';
2
- import type { ResolvedModel } from '../schema/types.js';
3
- import type { ModelPermissions } from '@rangka/shared';
4
- import { getAuthContext } from './session.js';
5
- import { ForbiddenError } from '../errors.js';
6
-
7
- export interface ResolvedFieldPermissions {
8
- hidden: Set<string>;
9
- readOnly: Set<string>;
10
- }
11
-
12
- // Determines which fields should be hidden or read-only for a given model
13
- // based on the user's resolved permissions.
14
- export function resolveFieldPermissions(
15
- model: ResolvedModel,
16
- modelPermissionsMap: Record<string, ModelPermissions>,
17
- ): ResolvedFieldPermissions {
18
- const hidden = new Set<string>();
19
- const readOnly = new Set<string>();
20
-
21
- const permissions = modelPermissionsMap[model.qualifiedName];
22
- if (!permissions?.fieldPermissions) {
23
- return { hidden, readOnly };
24
- }
25
-
26
- for (const [field, fieldPerm] of Object.entries(permissions.fieldPermissions)) {
27
- if (fieldPerm.read === false) {
28
- hidden.add(field);
29
- } else if (fieldPerm.write === false) {
30
- readOnly.add(field);
31
- }
32
- }
33
-
34
- return { hidden, readOnly };
35
- }
36
-
37
- // Hook that rejects writes to read-only fields with a 403 response.
38
- export function createFieldWriteGuard(model: ResolvedModel) {
39
- return async function fieldWriteGuard(
40
- request: FastifyRequest,
41
- _reply: FastifyReply,
42
- ): Promise<void> {
43
- if (!isWriteMethod(request.method)) return;
44
-
45
- const ctx = getAuthContext(request);
46
- if (!ctx.permissions) return;
47
-
48
- const { readOnly } = resolveFieldPermissions(model, ctx.permissions.models);
49
- if (readOnly.size === 0) return;
50
-
51
- const body = request.body as Record<string, unknown> | undefined;
52
- if (!body) return;
53
-
54
- const violatedFields = Object.keys(body).filter((field) => readOnly.has(field));
55
-
56
- if (violatedFields.length > 0) {
57
- throw new ForbiddenError(
58
- 'FORBIDDEN',
59
- `Cannot write to read-only fields: ${violatedFields.join(', ')}`,
60
- { fields: violatedFields },
61
- );
62
- }
63
- };
64
- }
65
-
66
- // Hook that strips hidden fields from response payloads before sending.
67
- export function createFieldStripHook(model: ResolvedModel) {
68
- return async function fieldStripHook(
69
- request: FastifyRequest,
70
- _reply: FastifyReply,
71
- payload: unknown,
72
- ): Promise<unknown> {
73
- if (typeof payload !== 'string') return payload;
74
-
75
- const ctx = getAuthContext(request);
76
- if (!ctx.permissions) return payload;
77
-
78
- const { hidden } = resolveFieldPermissions(model, ctx.permissions.models);
79
- if (hidden.size === 0) return payload;
80
-
81
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
82
- let parsed: any;
83
- try {
84
- parsed = JSON.parse(payload);
85
- } catch {
86
- return payload;
87
- }
88
-
89
- if (Array.isArray(parsed?.data)) {
90
- parsed.data = parsed.data.map((record: Record<string, unknown>) =>
91
- stripFields(record, hidden),
92
- );
93
- } else if (typeof parsed?.data === 'object' && parsed.data !== null) {
94
- parsed.data = stripFields(parsed.data, hidden);
95
- }
96
-
97
- return JSON.stringify(parsed);
98
- };
99
- }
100
-
101
- function isWriteMethod(method: string): boolean {
102
- return method === 'POST' || method === 'PUT' || method === 'PATCH';
103
- }
104
-
105
- function stripFields(
106
- record: Record<string, unknown>,
107
- hidden: Set<string>,
108
- ): Record<string, unknown> {
109
- const result: Record<string, unknown> = {};
110
- for (const [key, value] of Object.entries(record)) {
111
- if (!hidden.has(key)) {
112
- result[key] = value;
113
- }
114
- }
115
- return result;
116
- }
package/src/auth/index.ts DELETED
@@ -1,37 +0,0 @@
1
- export { hashPassword, verifyPassword } from './password.js';
2
- export {
3
- PermissionRegistry,
4
- DuplicateRoleError,
5
- RoleInheritanceCycleError,
6
- } from './permission-registry.js';
7
- export {
8
- createAuthHook,
9
- getAuthContext,
10
- createSessionHandler,
11
- deleteSessionHandler,
12
- regenerateSessionToken,
13
- generateToken,
14
- } from './session.js';
15
- export { createModelPermissionGuard, isOwnerOnly, modelHasCreatedBy } from './model-permissions.js';
16
- export { createScopeHook, createScopeWriteGuard, applyScopeFiltersToQuery } from './scopes.js';
17
- export type { ScopeHookContext, FilterProvider } from './scopes.js';
18
- export { ScopeRegistry, ScopeResolutionError } from './scope-registry.js';
19
- export type { ResolvedScope, ModelScopeBinding } from './scope-registry.js';
20
- export {
21
- createFieldWriteGuard,
22
- createFieldStripHook,
23
- resolveFieldPermissions,
24
- } from './field-permissions.js';
25
- export { debugPermissions, formatDebugResult } from './debug.js';
26
- export { getCoreModels, getCoreApp } from './core-module.js';
27
- export { coreSchemas } from './core-models.js';
28
- export { seedCoreData } from './seed.js';
29
- export type {
30
- AuthUser,
31
- AuthSession,
32
- RequestContext,
33
- ScopeFilter,
34
- ResolvedPermissions,
35
- RegisteredRole,
36
- PermissionCacheEntry,
37
- } from './types.js';
@@ -1,59 +0,0 @@
1
- import type { FastifyRequest, FastifyReply } from 'fastify';
2
- import type { ResolvedModel } from '../schema/types.js';
3
- import type { PermissionRegistry } from './permission-registry.js';
4
- import type { ResolvedPermissions } from './types.js';
5
- import { getAuthContext } from './session.js';
6
- import { ForbiddenError } from '../errors.js';
7
-
8
- type PermAction = 'read' | 'write' | 'create' | 'delete';
9
-
10
- export function isOwnerOnly(
11
- permissions: ResolvedPermissions | undefined,
12
- model: string,
13
- action: 'read' | 'write' | 'delete',
14
- ): boolean {
15
- if (!permissions) return false;
16
- const modelPerms = permissions.models[model];
17
- return modelPerms?.[action] === 'own';
18
- }
19
-
20
- export function modelHasCreatedBy(model: ResolvedModel): boolean {
21
- return model.fields.some((f) => f.name === 'created_by');
22
- }
23
-
24
- const METHOD_TO_ACTION: Record<string, PermAction> = {
25
- GET: 'read',
26
- POST: 'create',
27
- PUT: 'write',
28
- PATCH: 'write',
29
- DELETE: 'delete',
30
- };
31
-
32
- function resolveAction(method: string): PermAction {
33
- return METHOD_TO_ACTION[method] ?? 'read';
34
- }
35
-
36
- export function createModelPermissionGuard(
37
- model: ResolvedModel,
38
- _permissionRegistry: PermissionRegistry,
39
- ) {
40
- return async function modelPermissionGuard(
41
- request: FastifyRequest,
42
- _reply: FastifyReply,
43
- ): Promise<void> {
44
- const ctx = getAuthContext(request);
45
- if (!ctx.permissions) {
46
- throw new ForbiddenError('FORBIDDEN', 'No permissions resolved');
47
- }
48
-
49
- const action = resolveAction(request.method);
50
- const modelPerms = ctx.permissions.models[model.qualifiedName];
51
-
52
- if (!modelPerms || !modelPerms[action]) {
53
- throw new ForbiddenError(
54
- 'FORBIDDEN',
55
- `Insufficient permission: ${action} on ${model.qualifiedName}`,
56
- );
57
- }
58
- };
59
- }
@@ -1,22 +0,0 @@
1
- import { randomBytes, scryptSync, timingSafeEqual } from 'node:crypto';
2
-
3
- const SALT_LENGTH = 32;
4
- const KEY_LENGTH = 64;
5
- const SCRYPT_PARAMS = { N: 16384, r: 8, p: 1 };
6
-
7
- export function hashPassword(password: string): string {
8
- const salt = randomBytes(SALT_LENGTH);
9
- const hash = scryptSync(password, salt, KEY_LENGTH, SCRYPT_PARAMS);
10
- return `${salt.toString('hex')}:${hash.toString('hex')}`;
11
- }
12
-
13
- export function verifyPassword(password: string, stored: string): boolean {
14
- const [saltHex, hashHex] = stored.split(':');
15
- if (!saltHex || !hashHex) return false;
16
-
17
- const salt = Buffer.from(saltHex, 'hex');
18
- const storedHash = Buffer.from(hashHex, 'hex');
19
- const computedHash = scryptSync(password, salt, KEY_LENGTH, SCRYPT_PARAMS);
20
-
21
- return timingSafeEqual(storedHash, computedHash);
22
- }
@@ -1,171 +0,0 @@
1
- import type { RolesConfig, RoleConfig, ModelPermissions } from '@rangka/shared';
2
- import type { RegisteredRole, ResolvedPermissions } from './types.js';
3
-
4
- const MODEL_ACTIONS = ['read', 'write', 'create', 'delete'] as const;
5
-
6
- export class PermissionRegistry {
7
- private readonly roles: Map<string, RegisteredRole> = new Map();
8
- private resolvedInheritance: Map<string, string[]> = new Map();
9
- private version: number = 0;
10
-
11
- registerRoles(config: RolesConfig, app: string): void {
12
- for (const [name, roleConfig] of Object.entries(config)) {
13
- if (this.roles.has(name)) {
14
- const existing = this.roles.get(name)!;
15
- throw new DuplicateRoleError(name, existing.app, app);
16
- }
17
- this.roles.set(name, { name, config: roleConfig, app });
18
- }
19
- this.rebuildInheritanceChains();
20
- this.version++;
21
- }
22
-
23
- getRole(name: string): RegisteredRole | undefined {
24
- return this.roles.get(name);
25
- }
26
-
27
- getAllRoles(): RegisteredRole[] {
28
- return Array.from(this.roles.values());
29
- }
30
-
31
- getVersion(): number {
32
- return this.version;
33
- }
34
-
35
- getInheritanceChain(roleName: string): string[] {
36
- return this.resolvedInheritance.get(roleName) ?? [];
37
- }
38
-
39
- // Collects permissions from the given roles (and their ancestors) into a single resolved set.
40
- // Later roles in the array can only grant — they never revoke what an earlier role granted.
41
- resolvePermissionsForRoles(roleNames: string[]): ResolvedPermissions {
42
- const models: Record<string, ModelPermissions> = {};
43
- const pages = new Set<string>();
44
-
45
- for (const roleName of roleNames) {
46
- const rolesToApply = [...this.getInheritanceChain(roleName), roleName];
47
-
48
- for (const name of rolesToApply) {
49
- const role = this.roles.get(name);
50
- if (!role) continue;
51
-
52
- this.applyModelPermissions(role.config, models);
53
- this.collectPages(role.config, pages);
54
- }
55
- }
56
-
57
- return { models, pages: Array.from(pages), version: this.version };
58
- }
59
-
60
- private applyModelPermissions(
61
- roleConfig: RoleConfig,
62
- target: Record<string, ModelPermissions>,
63
- ): void {
64
- if (!roleConfig.models) return;
65
-
66
- for (const [model, sourcePerms] of Object.entries(roleConfig.models)) {
67
- if (!target[model]) target[model] = {};
68
- mergeModelPermissions(target[model], sourcePerms);
69
- }
70
- }
71
-
72
- private collectPages(roleConfig: RoleConfig, pages: Set<string>): void {
73
- if (!roleConfig.pages) return;
74
- for (const page of roleConfig.pages) {
75
- pages.add(page);
76
- }
77
- }
78
-
79
- // Walks each role's `extends` chain and caches the full ancestor list.
80
- // Detects cycles and missing parents.
81
- private rebuildInheritanceChains(): void {
82
- const resolved = new Map<string, string[]>();
83
- const inProgress = new Set<string>();
84
-
85
- const resolve = (name: string): string[] => {
86
- if (resolved.has(name)) return resolved.get(name)!;
87
-
88
- if (inProgress.has(name)) {
89
- throw new RoleInheritanceCycleError([...inProgress, name]);
90
- }
91
-
92
- inProgress.add(name);
93
-
94
- const role = this.roles.get(name);
95
- if (!role?.config.extends) {
96
- inProgress.delete(name);
97
- resolved.set(name, []);
98
- return [];
99
- }
100
-
101
- const parentName = role.config.extends;
102
- if (!this.roles.has(parentName)) {
103
- throw new Error(`Role "${name}" extends unknown role "${parentName}"`);
104
- }
105
-
106
- const ancestors = [...resolve(parentName), parentName];
107
- inProgress.delete(name);
108
- resolved.set(name, ancestors);
109
- return ancestors;
110
- };
111
-
112
- for (const name of this.roles.keys()) {
113
- resolve(name);
114
- }
115
-
116
- this.resolvedInheritance = resolved;
117
- }
118
- }
119
-
120
- // Merges source permissions into target. true > 'own' > unset/false.
121
- function mergeModelPermissions(target: ModelPermissions, source: ModelPermissions): void {
122
- for (const action of MODEL_ACTIONS) {
123
- const src = source[action];
124
- if (!src) continue;
125
- // true is the most permissive — never downgrade from true to 'own'
126
- if (target[action] === true) continue;
127
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
128
- target[action] = src as any;
129
- }
130
-
131
- if (!source.fieldPermissions) return;
132
-
133
- if (!target.fieldPermissions) target.fieldPermissions = {};
134
-
135
- for (const [field, sourceField] of Object.entries(source.fieldPermissions)) {
136
- const targetField = target.fieldPermissions[field];
137
-
138
- if (!targetField) {
139
- target.fieldPermissions[field] = { ...sourceField };
140
- continue;
141
- }
142
-
143
- // Grants always win; denials only apply if not already granted
144
- if (sourceField.read === true) targetField.read = true;
145
- else if (sourceField.read === false && targetField.read === undefined) targetField.read = false;
146
-
147
- if (sourceField.write === true) targetField.write = true;
148
- else if (sourceField.write === false && targetField.write === undefined)
149
- targetField.write = false;
150
- }
151
- }
152
-
153
- export class DuplicateRoleError extends Error {
154
- constructor(
155
- public readonly role: string,
156
- public readonly existingApp: string,
157
- public readonly newApp: string,
158
- ) {
159
- super(
160
- `Duplicate role "${role}": already registered by "${existingApp}", cannot register from "${newApp}"`,
161
- );
162
- this.name = 'DuplicateRoleError';
163
- }
164
- }
165
-
166
- export class RoleInheritanceCycleError extends Error {
167
- constructor(public readonly cycle: string[]) {
168
- super(`Role inheritance cycle detected: ${cycle.join(' → ')}`);
169
- this.name = 'RoleInheritanceCycleError';
170
- }
171
- }
@@ -1,11 +0,0 @@
1
- /* eslint-disable @typescript-eslint/no-explicit-any */
2
- import type { ScopeFilter } from './types.js';
3
-
4
- export function applyScopeFiltersToQuery(query: any, scopeFilters: ScopeFilter[]): any {
5
- let result = query;
6
- for (const filter of scopeFilters) {
7
- const operator = filter.operator === 'in' ? 'in' : '=';
8
- result = result.where(filter.field, operator, filter.value);
9
- }
10
- return result;
11
- }
@@ -1,121 +0,0 @@
1
- import type { ModuleConfig, ScopeDefinition, ScopeConfig } from '@rangka/shared';
2
- import type { SchemaRegistry } from '../schema/registry.js';
3
- import type { ResolvedModel } from '../schema/types.js';
4
-
5
- export interface ResolvedScope {
6
- name: string;
7
- definition: ScopeDefinition;
8
- module: string;
9
- }
10
-
11
- export interface ModelScopeBinding {
12
- scopeName: string;
13
- column: string;
14
- scopeModel: string;
15
- }
16
-
17
- export class ScopeResolutionError extends Error {
18
- constructor(message: string) {
19
- super(message);
20
- this.name = 'ScopeResolutionError';
21
- }
22
- }
23
-
24
- export class ScopeRegistry {
25
- private readonly scopes: Map<string, ResolvedScope> = new Map();
26
- private readonly modelBindings: Map<string, ModelScopeBinding> = new Map();
27
-
28
- constructor(modules: ModuleConfig[], schemaRegistry: SchemaRegistry) {
29
- this.registerScopes(modules);
30
- this.resolveModelBindings(schemaRegistry);
31
- }
32
-
33
- getScope(name: string): ResolvedScope | undefined {
34
- return this.scopes.get(name);
35
- }
36
-
37
- getAllScopes(): ResolvedScope[] {
38
- return Array.from(this.scopes.values());
39
- }
40
-
41
- getModelBinding(qualifiedName: string): ModelScopeBinding | undefined {
42
- return this.modelBindings.get(qualifiedName);
43
- }
44
-
45
- isModelScoped(qualifiedName: string): boolean {
46
- return this.modelBindings.has(qualifiedName);
47
- }
48
-
49
- private registerScopes(modules: ModuleConfig[]): void {
50
- for (const mod of modules) {
51
- if (!mod.scopes) continue;
52
- for (const [name, definition] of Object.entries(mod.scopes)) {
53
- if (this.scopes.has(name)) {
54
- throw new ScopeResolutionError(
55
- `Scope "${name}" declared by module "${mod.name}" conflicts with existing scope from module "${this.scopes.get(name)!.module}"`,
56
- );
57
- }
58
- this.scopes.set(name, { name, definition, module: mod.name });
59
- }
60
- }
61
- }
62
-
63
- private resolveModelBindings(schemaRegistry: SchemaRegistry): void {
64
- for (const model of schemaRegistry.getAllModels()) {
65
- if (!model.scope) continue;
66
-
67
- const binding = this.resolveBinding(model, schemaRegistry);
68
- this.modelBindings.set(model.qualifiedName, binding);
69
- }
70
- }
71
-
72
- private resolveBinding(model: ResolvedModel, schemaRegistry: SchemaRegistry): ModelScopeBinding {
73
- const scopeName = this.parseScopeName(model.scope!);
74
- const explicitField = this.parseExplicitField(model.scope!);
75
-
76
- const scope = this.scopes.get(scopeName);
77
- if (!scope) {
78
- throw new ScopeResolutionError(
79
- `Model "${model.qualifiedName}" references scope "${scopeName}" which is not defined by any module`,
80
- );
81
- }
82
-
83
- const column = explicitField ?? this.findLinkColumn(model, scope, schemaRegistry);
84
- return { scopeName, column, scopeModel: scope.definition.model };
85
- }
86
-
87
- private parseScopeName(scopeConfig: ScopeConfig): string {
88
- if (typeof scopeConfig === 'string') return scopeConfig;
89
- return scopeConfig.name;
90
- }
91
-
92
- private parseExplicitField(scopeConfig: ScopeConfig): string | undefined {
93
- if (typeof scopeConfig === 'string') return undefined;
94
- return scopeConfig.field;
95
- }
96
-
97
- private findLinkColumn(
98
- model: ResolvedModel,
99
- scope: ResolvedScope,
100
- schemaRegistry: SchemaRegistry,
101
- ): string {
102
- const relationships = schemaRegistry.getRelationshipsForModel(model.qualifiedName);
103
- const matchingLinks = relationships.filter(
104
- (r) => r.type === 'link' && r.to === scope.definition.model,
105
- );
106
-
107
- if (matchingLinks.length === 0) {
108
- throw new ScopeResolutionError(
109
- `Model "${model.qualifiedName}" is scoped by "${scope.name}" but has no link field to "${scope.definition.model}"`,
110
- );
111
- }
112
-
113
- if (matchingLinks.length > 1) {
114
- throw new ScopeResolutionError(
115
- `Model "${model.qualifiedName}" has multiple link fields to "${scope.definition.model}" (${matchingLinks.map((l) => l.field).join(', ')}). Specify which field to use: scope: { name: '${scope.name}', field: '<field_name>' }`,
116
- );
117
- }
118
-
119
- return matchingLinks[0].field;
120
- }
121
- }