@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.
- package/package.json +6 -2
- package/.claude/skills/extend-core/SKILL.md +0 -133
- package/.turbo/turbo-build.log +0 -4
- package/CHANGELOG.md +0 -18
- package/CLAUDE.md +0 -180
- package/src/__tests__/coerce.test.ts +0 -154
- package/src/__tests__/context.test.ts +0 -111
- package/src/__tests__/helpers.ts +0 -21
- package/src/__tests__/index.test.ts +0 -7
- package/src/__tests__/widgets.test.ts +0 -197
- package/src/api/__tests__/handlers.test.ts +0 -389
- package/src/api/__tests__/include-resolver.test.ts +0 -393
- package/src/api/__tests__/middleware.test.ts +0 -100
- package/src/api/__tests__/openapi-schema.test.ts +0 -210
- package/src/api/__tests__/query-parser.test.ts +0 -291
- package/src/api/__tests__/route-generator.test.ts +0 -137
- package/src/api/__tests__/server.test.ts +0 -73
- package/src/api/__tests__/swagger.test.ts +0 -166
- package/src/api/handlers.ts +0 -274
- package/src/api/include-resolver.ts +0 -27
- package/src/api/index.ts +0 -4
- package/src/api/meta-handler.ts +0 -254
- package/src/api/openapi-schema.ts +0 -99
- package/src/api/query-parser.ts +0 -315
- package/src/api/route-generator.ts +0 -448
- package/src/api/server.ts +0 -147
- package/src/api/types.ts +0 -16
- package/src/audit/__tests__/audit.test.ts +0 -144
- package/src/audit/index.ts +0 -3
- package/src/audit/record.ts +0 -69
- package/src/audit/tables.ts +0 -48
- package/src/audit/types.ts +0 -26
- package/src/auth/__tests__/core-module.test.ts +0 -54
- package/src/auth/__tests__/debug.test.ts +0 -47
- package/src/auth/__tests__/field-permissions.test.ts +0 -245
- package/src/auth/__tests__/integration.test.ts +0 -208
- package/src/auth/__tests__/meta-boot.test.ts +0 -538
- package/src/auth/__tests__/model-permissions.test.ts +0 -205
- package/src/auth/__tests__/password.test.ts +0 -29
- package/src/auth/__tests__/permission-registry.test.ts +0 -313
- package/src/auth/__tests__/scope-hook.test.ts +0 -509
- package/src/auth/__tests__/scope-registry.test.ts +0 -297
- package/src/auth/__tests__/scopes.test.ts +0 -66
- package/src/auth/__tests__/session.test.ts +0 -214
- package/src/auth/core-models.ts +0 -52
- package/src/auth/core-module.ts +0 -59
- package/src/auth/debug.ts +0 -157
- package/src/auth/field-permissions.ts +0 -116
- package/src/auth/index.ts +0 -37
- package/src/auth/model-permissions.ts +0 -59
- package/src/auth/password.ts +0 -22
- package/src/auth/permission-registry.ts +0 -171
- package/src/auth/scope-filters.ts +0 -11
- package/src/auth/scope-registry.ts +0 -121
- package/src/auth/scopes.ts +0 -146
- package/src/auth/seed.ts +0 -44
- package/src/auth/session.ts +0 -178
- package/src/auth/types.ts +0 -50
- package/src/boot/__tests__/page-scanning.test.ts +0 -170
- package/src/boot/__tests__/page-utils.test.ts +0 -225
- package/src/boot/__tests__/project-scanner.test.ts +0 -88
- package/src/boot/dependency-sort.ts +0 -82
- package/src/boot/discovery.ts +0 -85
- package/src/boot/index.ts +0 -457
- package/src/boot/page-utils.ts +0 -110
- package/src/boot/project-scanner.ts +0 -397
- package/src/boot/schema-loader.ts +0 -26
- package/src/boot/schema-merger.ts +0 -125
- package/src/boot/traits.ts +0 -25
- package/src/boot/types.ts +0 -73
- package/src/context.ts +0 -105
- package/src/db/__tests__/cascade-delete.test.ts +0 -182
- package/src/db/__tests__/desired-state.test.ts +0 -136
- package/src/db/__tests__/diff-engine.test.ts +0 -635
- package/src/db/__tests__/field-mapper.test.ts +0 -355
- package/src/db/__tests__/introspect.test.ts +0 -70
- package/src/db/__tests__/search-filter.test.ts +0 -45
- package/src/db/__tests__/sequence.test.ts +0 -221
- package/src/db/auto-sync.ts +0 -133
- package/src/db/client.ts +0 -147
- package/src/db/desired-state.ts +0 -98
- package/src/db/diff-engine.ts +0 -305
- package/src/db/field-mapper.ts +0 -504
- package/src/db/filter-applier.ts +0 -89
- package/src/db/include-resolver.ts +0 -40
- package/src/db/index.ts +0 -23
- package/src/db/introspect.ts +0 -265
- package/src/db/model-include-resolver.ts +0 -327
- package/src/db/model-ops.ts +0 -281
- package/src/db/scope-enforcer.ts +0 -37
- package/src/db/types.ts +0 -98
- package/src/errors.ts +0 -41
- package/src/events/__tests__/bus.test.ts +0 -105
- package/src/events/bus.ts +0 -89
- package/src/events/index.ts +0 -2
- package/src/events/types.ts +0 -9
- package/src/external-model/__tests__/computed-fields.test.ts +0 -106
- package/src/external-model/__tests__/field-mapper.test.ts +0 -160
- package/src/external-model/__tests__/in-memory-ops.test.ts +0 -247
- package/src/external-model/__tests__/mutation-executor.test.ts +0 -160
- package/src/external-model/__tests__/query-executor.test.ts +0 -284
- package/src/external-model/__tests__/schema-converter.test.ts +0 -174
- package/src/external-model/computed-fields.ts +0 -15
- package/src/external-model/define.ts +0 -5
- package/src/external-model/external-model-ops.ts +0 -108
- package/src/external-model/field-mapper.ts +0 -66
- package/src/external-model/in-memory-ops.ts +0 -107
- package/src/external-model/index.ts +0 -7
- package/src/external-model/mutation-executor.ts +0 -71
- package/src/external-model/query-executor.ts +0 -100
- package/src/external-model/schema-converter.ts +0 -53
- package/src/external-model/types.ts +0 -32
- package/src/fixtures/__tests__/fixtures.test.ts +0 -203
- package/src/fixtures/index.ts +0 -10
- package/src/fixtures/loader.ts +0 -196
- package/src/fixtures/registry.ts +0 -125
- package/src/fixtures/types.ts +0 -33
- package/src/helpers/assert-ownership.ts +0 -19
- package/src/helpers/coerce.ts +0 -28
- package/src/helpers/stamping.ts +0 -28
- package/src/helpers/validation.ts +0 -14
- package/src/hooks/__tests__/context.test.ts +0 -73
- package/src/hooks/__tests__/executor.test.ts +0 -433
- package/src/hooks/__tests__/middleware.test.ts +0 -224
- package/src/hooks/__tests__/registry.test.ts +0 -50
- package/src/hooks/context.ts +0 -89
- package/src/hooks/errors.ts +0 -11
- package/src/hooks/executor.ts +0 -115
- package/src/hooks/index.ts +0 -10
- package/src/hooks/middleware.ts +0 -220
- package/src/hooks/registry.ts +0 -20
- package/src/hooks/types.ts +0 -32
- package/src/index.ts +0 -172
- package/src/jobs/__tests__/enqueue.test.ts +0 -77
- package/src/jobs/__tests__/integration.test.ts +0 -71
- package/src/jobs/__tests__/registry.test.ts +0 -103
- package/src/jobs/__tests__/scheduler.test.ts +0 -92
- package/src/jobs/__tests__/worker-execution.test.ts +0 -202
- package/src/jobs/__tests__/worker.test.ts +0 -119
- package/src/jobs/enqueue.ts +0 -93
- package/src/jobs/index.ts +0 -14
- package/src/jobs/registry.ts +0 -92
- package/src/jobs/scheduler.ts +0 -205
- package/src/jobs/tables.ts +0 -132
- package/src/jobs/types.ts +0 -62
- package/src/jobs/worker.ts +0 -272
- package/src/model-api/__tests__/cross-boundary-includes.test.ts +0 -366
- package/src/model-api/__tests__/extended-api.test.ts +0 -244
- package/src/model-api/__tests__/filter-applier.test.ts +0 -177
- package/src/model-api/__tests__/filter-translator.test.ts +0 -186
- package/src/model-api/__tests__/include-resolver.test.ts +0 -226
- package/src/model-api/__tests__/model-access.test.ts +0 -284
- package/src/model-api/__tests__/query-builder.test.ts +0 -224
- package/src/model-api/__tests__/scope-enforcer.test.ts +0 -268
- package/src/model-api/field-access.ts +0 -28
- package/src/model-api/filter-applier.ts +0 -1
- package/src/model-api/filter-translator.ts +0 -67
- package/src/model-api/include-resolver.ts +0 -2
- package/src/model-api/index.ts +0 -86
- package/src/model-api/query-builder.ts +0 -155
- package/src/model-api/scope-enforcer.ts +0 -3
- package/src/model-api/types.ts +0 -139
- package/src/plugins/__tests__/adapter-registry.test.ts +0 -92
- package/src/plugins/__tests__/lifecycle.test.ts +0 -96
- package/src/plugins/__tests__/loader.test.ts +0 -273
- package/src/plugins/__tests__/validator.test.ts +0 -275
- package/src/plugins/adapter-registry.ts +0 -42
- package/src/plugins/define.ts +0 -5
- package/src/plugins/index.ts +0 -28
- package/src/plugins/lifecycle.ts +0 -27
- package/src/plugins/loader.ts +0 -126
- package/src/plugins/types.ts +0 -76
- package/src/plugins/validator.ts +0 -141
- package/src/schema/__tests__/registry-models-by-module.test.ts +0 -58
- package/src/schema/registry.ts +0 -93
- package/src/schema/relationships.ts +0 -93
- package/src/schema/types.ts +0 -43
- package/src/services/__tests__/integration.test.ts +0 -63
- package/src/services/__tests__/registry.test.ts +0 -175
- package/src/services/index.ts +0 -13
- package/src/services/registry.ts +0 -156
- package/src/services/types.ts +0 -27
- package/src/validation/__tests__/field-validator.test.ts +0 -195
- package/src/validation/field-validator.ts +0 -113
- package/src/validation/index.ts +0 -1
- package/src/widgets/index.ts +0 -3
- package/src/widgets/slot-validator.ts +0 -87
- package/src/widgets/widget-registry.ts +0 -32
- package/tests/boot.test.ts +0 -323
- package/tests/dependency-sort.test.ts +0 -99
- package/tests/discovery.test.ts +0 -126
- package/tests/registry.test.ts +0 -216
- package/tests/schema-loader.test.ts +0 -52
- package/tests/schema-merger.test.ts +0 -180
- package/tsconfig.json +0 -9
- package/tsconfig.tsbuildinfo +0 -1
- 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
|
-
}
|
package/src/auth/password.ts
DELETED
|
@@ -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
|
-
}
|