@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/scopes.ts
DELETED
|
@@ -1,146 +0,0 @@
|
|
|
1
|
-
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
-
import type { FastifyRequest, FastifyReply } from 'fastify';
|
|
3
|
-
import type { ResolvedModel } from '../schema/types.js';
|
|
4
|
-
import type { DatabaseClient } from '../db/client.js';
|
|
5
|
-
import type { ScopeFilter, RequestContext } from './types.js';
|
|
6
|
-
import type { ScopeRegistry } from './scope-registry.js';
|
|
7
|
-
import { getAuthContext } from './session.js';
|
|
8
|
-
import { BadRequestError, ForbiddenError } from '../errors.js';
|
|
9
|
-
import { isNil } from '../helpers/coerce.js';
|
|
10
|
-
|
|
11
|
-
export { applyScopeFiltersToQuery } from './scope-filters.js';
|
|
12
|
-
|
|
13
|
-
export interface ScopeHookContext {
|
|
14
|
-
model: ResolvedModel;
|
|
15
|
-
scopeRegistry: ScopeRegistry;
|
|
16
|
-
db: DatabaseClient;
|
|
17
|
-
filterProviders?: FilterProvider[];
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export type FilterProvider = (
|
|
21
|
-
model: ResolvedModel,
|
|
22
|
-
authCtx: RequestContext,
|
|
23
|
-
request: FastifyRequest,
|
|
24
|
-
) => ScopeFilter[] | Promise<ScopeFilter[]>;
|
|
25
|
-
|
|
26
|
-
export function createScopeHook(ctx: ScopeHookContext) {
|
|
27
|
-
const { model, scopeRegistry, db, filterProviders } = ctx;
|
|
28
|
-
const binding = scopeRegistry.getModelBinding(model.qualifiedName);
|
|
29
|
-
|
|
30
|
-
return async function scopeHook(request: FastifyRequest, _reply: FastifyReply): Promise<void> {
|
|
31
|
-
const authCtx = getAuthContext(request);
|
|
32
|
-
if (!authCtx.permissions || !authCtx.user) return;
|
|
33
|
-
|
|
34
|
-
const filters: ScopeFilter[] = [];
|
|
35
|
-
|
|
36
|
-
if (binding) {
|
|
37
|
-
const activeValue = resolveActiveScopeValue(request, binding.scopeName, authCtx.user);
|
|
38
|
-
if (!activeValue) {
|
|
39
|
-
throw new BadRequestError(
|
|
40
|
-
'MISSING_SCOPE',
|
|
41
|
-
`Active scope value for "${binding.scopeName}" is required. Set it via X-Active-Scope header or user default.`,
|
|
42
|
-
);
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
const exists = await validateScopeValueExists(db, binding.scopeModel, activeValue);
|
|
46
|
-
if (!exists) {
|
|
47
|
-
throw new BadRequestError(
|
|
48
|
-
'INVALID_SCOPE',
|
|
49
|
-
`Scope value "${activeValue}" does not exist in "${binding.scopeModel}".`,
|
|
50
|
-
);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
filters.push({ field: binding.column, operator: 'eq', value: activeValue });
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
if (filterProviders) {
|
|
57
|
-
for (const provider of filterProviders) {
|
|
58
|
-
const extra = await provider(model, authCtx, request);
|
|
59
|
-
filters.push(...extra);
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
authCtx.scopeFilters = filters;
|
|
64
|
-
(request as any).authContext = authCtx;
|
|
65
|
-
};
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
export function createScopeWriteGuard(ctx: ScopeHookContext) {
|
|
69
|
-
const { model, scopeRegistry } = ctx;
|
|
70
|
-
const binding = scopeRegistry.getModelBinding(model.qualifiedName);
|
|
71
|
-
|
|
72
|
-
return async function scopeWriteGuard(
|
|
73
|
-
request: FastifyRequest,
|
|
74
|
-
_reply: FastifyReply,
|
|
75
|
-
): Promise<void> {
|
|
76
|
-
if (request.method === 'GET') return;
|
|
77
|
-
if (!binding) return;
|
|
78
|
-
|
|
79
|
-
const authCtx = getAuthContext(request);
|
|
80
|
-
if (!authCtx.scopeFilters?.length) return;
|
|
81
|
-
|
|
82
|
-
const body = request.body as Record<string, unknown> | undefined;
|
|
83
|
-
if (!body) return;
|
|
84
|
-
|
|
85
|
-
const scopeFilter = authCtx.scopeFilters.find((f) => f.field === binding.column);
|
|
86
|
-
if (!scopeFilter) return;
|
|
87
|
-
|
|
88
|
-
const fieldValue = body[binding.column];
|
|
89
|
-
|
|
90
|
-
if (request.method === 'POST') {
|
|
91
|
-
if (isNil(fieldValue)) {
|
|
92
|
-
body[binding.column] = scopeFilter.value;
|
|
93
|
-
return;
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
if (fieldValue !== undefined && fieldValue !== scopeFilter.value) {
|
|
98
|
-
throw new ForbiddenError(
|
|
99
|
-
'SCOPE_VIOLATION',
|
|
100
|
-
`Cannot write to scope "${binding.scopeName}": ${binding.column} must be "${scopeFilter.value}".`,
|
|
101
|
-
);
|
|
102
|
-
}
|
|
103
|
-
};
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
function resolveActiveScopeValue(
|
|
107
|
-
request: FastifyRequest,
|
|
108
|
-
scopeName: string,
|
|
109
|
-
user: Record<string, unknown>,
|
|
110
|
-
): string | undefined {
|
|
111
|
-
const headerValue = parseScopeHeader(request);
|
|
112
|
-
if (headerValue?.[scopeName]) {
|
|
113
|
-
return String(headerValue[scopeName]);
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
const defaultField = `default_${scopeName}`;
|
|
117
|
-
if (user[defaultField]) {
|
|
118
|
-
return String(user[defaultField]);
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
return undefined;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
function parseScopeHeader(request: FastifyRequest): Record<string, string> | undefined {
|
|
125
|
-
const header = request.headers['x-active-scope'];
|
|
126
|
-
if (!header || typeof header !== 'string') return undefined;
|
|
127
|
-
|
|
128
|
-
try {
|
|
129
|
-
return JSON.parse(header);
|
|
130
|
-
} catch {
|
|
131
|
-
return undefined;
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
async function validateScopeValueExists(
|
|
136
|
-
db: DatabaseClient,
|
|
137
|
-
scopeModel: string,
|
|
138
|
-
value: string,
|
|
139
|
-
): Promise<boolean> {
|
|
140
|
-
const result = await db
|
|
141
|
-
.selectFrom(scopeModel)
|
|
142
|
-
.where('id', '=', value)
|
|
143
|
-
.selectAll()
|
|
144
|
-
.executeTakeFirst();
|
|
145
|
-
return result !== undefined;
|
|
146
|
-
}
|
package/src/auth/seed.ts
DELETED
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
import type { DatabaseClient } from '../db/client.js';
|
|
2
|
-
import { hashPassword } from './password.js';
|
|
3
|
-
|
|
4
|
-
export async function seedCoreData(db: DatabaseClient): Promise<void> {
|
|
5
|
-
const existing = await db
|
|
6
|
-
.selectFrom('core.user')
|
|
7
|
-
.select('id')
|
|
8
|
-
.where('email', '=', 'system@rangka.local')
|
|
9
|
-
.executeTakeFirst();
|
|
10
|
-
|
|
11
|
-
if (existing) return;
|
|
12
|
-
|
|
13
|
-
const adminRole = await db
|
|
14
|
-
.insertInto('core.role')
|
|
15
|
-
.values({
|
|
16
|
-
id: crypto.randomUUID(),
|
|
17
|
-
name: 'Administrator',
|
|
18
|
-
inherits: JSON.stringify([]),
|
|
19
|
-
permissions: JSON.stringify({}),
|
|
20
|
-
})
|
|
21
|
-
.returningAll()
|
|
22
|
-
.executeTakeFirstOrThrow();
|
|
23
|
-
|
|
24
|
-
const systemUser = await db
|
|
25
|
-
.insertInto('core.user')
|
|
26
|
-
.values({
|
|
27
|
-
id: crypto.randomUUID(),
|
|
28
|
-
email: 'system@rangka.local',
|
|
29
|
-
password_hash: hashPassword('admin'),
|
|
30
|
-
full_name: 'System Administrator',
|
|
31
|
-
enabled: true,
|
|
32
|
-
})
|
|
33
|
-
.returningAll()
|
|
34
|
-
.executeTakeFirstOrThrow();
|
|
35
|
-
|
|
36
|
-
await db
|
|
37
|
-
.insertInto('core.user_role')
|
|
38
|
-
.values({
|
|
39
|
-
id: crypto.randomUUID(),
|
|
40
|
-
user_id: systemUser.id,
|
|
41
|
-
role_id: adminRole.id,
|
|
42
|
-
})
|
|
43
|
-
.execute();
|
|
44
|
-
}
|
package/src/auth/session.ts
DELETED
|
@@ -1,178 +0,0 @@
|
|
|
1
|
-
import { randomBytes } from 'node:crypto';
|
|
2
|
-
import type { FastifyRequest, FastifyReply } from 'fastify';
|
|
3
|
-
import type { DatabaseClient } from '../db/client.js';
|
|
4
|
-
import { BadRequestError, UnauthorizedError } from '../errors.js';
|
|
5
|
-
import type { PermissionRegistry } from './permission-registry.js';
|
|
6
|
-
import type { AuthUser, AuthSession, RequestContext } from './types.js';
|
|
7
|
-
|
|
8
|
-
const TOKEN_BYTES = 32;
|
|
9
|
-
const SESSION_DURATION_MS = 24 * 60 * 60 * 1000;
|
|
10
|
-
|
|
11
|
-
export function generateToken(): string {
|
|
12
|
-
return randomBytes(TOKEN_BYTES).toString('hex');
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
// Extracts Bearer token, validates the session, loads the user and their permissions,
|
|
16
|
-
// then attaches everything to request.authContext for downstream guards.
|
|
17
|
-
export function createAuthHook(db: DatabaseClient, permissionRegistry: PermissionRegistry) {
|
|
18
|
-
return async function authHook(request: FastifyRequest, _reply: FastifyReply): Promise<void> {
|
|
19
|
-
const ctx: RequestContext = {};
|
|
20
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
21
|
-
(request as any).authContext = ctx;
|
|
22
|
-
|
|
23
|
-
const token = extractBearerToken(request);
|
|
24
|
-
if (!token) {
|
|
25
|
-
throw new UnauthorizedError('Missing or invalid Authorization header');
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
const session = await findActiveSession(db, token);
|
|
29
|
-
if (!session) {
|
|
30
|
-
throw new UnauthorizedError('Invalid or expired session token');
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
const user = await findEnabledUser(db, session.user_id);
|
|
34
|
-
if (!user) {
|
|
35
|
-
throw new UnauthorizedError('User not found or disabled');
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
const roleNames = await getUserRoleNames(db, user.id);
|
|
39
|
-
const permissions = permissionRegistry.resolvePermissionsForRoles(roleNames);
|
|
40
|
-
|
|
41
|
-
ctx.user = user;
|
|
42
|
-
ctx.session = session;
|
|
43
|
-
ctx.permissions = permissions;
|
|
44
|
-
ctx.roles = roleNames;
|
|
45
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
46
|
-
(request as any).authContext = ctx;
|
|
47
|
-
};
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
export function getAuthContext(request: FastifyRequest): RequestContext {
|
|
51
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
52
|
-
return (request as any).authContext ?? {};
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
// --- Session CRUD handlers ---
|
|
56
|
-
|
|
57
|
-
export interface SessionCreateBody {
|
|
58
|
-
email: string;
|
|
59
|
-
password: string;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
// POST /session — authenticates with email/password, returns a session token.
|
|
63
|
-
export function createSessionHandler(db: DatabaseClient) {
|
|
64
|
-
return async (request: FastifyRequest, reply: FastifyReply) => {
|
|
65
|
-
const { verifyPassword } = await import('./password.js');
|
|
66
|
-
const body = request.body as SessionCreateBody | undefined;
|
|
67
|
-
|
|
68
|
-
if (!body?.email || !body?.password) {
|
|
69
|
-
throw new BadRequestError('VALIDATION_ERROR', 'Email and password are required');
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
const user = await findEnabledUser(db, body.email, 'email');
|
|
73
|
-
if (!user) {
|
|
74
|
-
throw new UnauthorizedError('Invalid credentials');
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
if (!verifyPassword(body.password, user.password_hash)) {
|
|
78
|
-
throw new UnauthorizedError('Invalid credentials');
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
const session = await insertSession(db, user.id);
|
|
82
|
-
|
|
83
|
-
return reply.status(201).send({
|
|
84
|
-
data: { token: session.token, expires_at: session.expires_at },
|
|
85
|
-
});
|
|
86
|
-
};
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
// DELETE /session — destroys the current session.
|
|
90
|
-
export function deleteSessionHandler(db: DatabaseClient) {
|
|
91
|
-
return async (request: FastifyRequest, reply: FastifyReply) => {
|
|
92
|
-
const ctx = getAuthContext(request);
|
|
93
|
-
if (!ctx.session) {
|
|
94
|
-
throw new UnauthorizedError('Not authenticated');
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
await db.deleteFrom('core.session').where('id', '=', ctx.session.id).execute();
|
|
98
|
-
return reply.status(204).send();
|
|
99
|
-
};
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
// Invalidates all existing sessions for a user and creates a fresh one.
|
|
103
|
-
export async function regenerateSessionToken(db: DatabaseClient, userId: string): Promise<string> {
|
|
104
|
-
await db.deleteFrom('core.session').where('user_id', '=', userId).execute();
|
|
105
|
-
const session = await insertSession(db, userId);
|
|
106
|
-
return session.token;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
// --- Helpers ---
|
|
110
|
-
|
|
111
|
-
function extractBearerToken(request: FastifyRequest): string | null {
|
|
112
|
-
const header = request.headers.authorization;
|
|
113
|
-
if (!header?.startsWith('Bearer ')) return null;
|
|
114
|
-
const token = header.slice(7);
|
|
115
|
-
return token || null;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
async function findActiveSession(db: DatabaseClient, token: string): Promise<AuthSession | null> {
|
|
119
|
-
const session = (await db
|
|
120
|
-
.selectFrom('core.session')
|
|
121
|
-
.selectAll()
|
|
122
|
-
.where('token', '=', token)
|
|
123
|
-
.executeTakeFirst()) as AuthSession | undefined;
|
|
124
|
-
|
|
125
|
-
if (!session) return null;
|
|
126
|
-
|
|
127
|
-
const isExpired = new Date(session.expires_at).getTime() < Date.now();
|
|
128
|
-
return isExpired ? null : session;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
async function findEnabledUser(
|
|
132
|
-
db: DatabaseClient,
|
|
133
|
-
value: string,
|
|
134
|
-
by: 'id' | 'email' = 'id',
|
|
135
|
-
): Promise<AuthUser | null> {
|
|
136
|
-
const user = (await db
|
|
137
|
-
.selectFrom('core.user')
|
|
138
|
-
.selectAll()
|
|
139
|
-
.where(by, '=', value)
|
|
140
|
-
.executeTakeFirst()) as AuthUser | undefined;
|
|
141
|
-
|
|
142
|
-
if (!user || !user.enabled) return null;
|
|
143
|
-
return user;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
async function getUserRoleNames(db: DatabaseClient, userId: string): Promise<string[]> {
|
|
147
|
-
const userRoles = await db
|
|
148
|
-
.selectFrom('core.user_role')
|
|
149
|
-
.selectAll()
|
|
150
|
-
.where('user_id', '=', userId)
|
|
151
|
-
.execute();
|
|
152
|
-
|
|
153
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
154
|
-
const roleIds = userRoles.map((ur: any) => ur.role_id);
|
|
155
|
-
if (roleIds.length === 0) return [];
|
|
156
|
-
|
|
157
|
-
const roles = await db.selectFrom('core.role').selectAll().where('id', 'in', roleIds).execute();
|
|
158
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
159
|
-
return roles.map((r: any) => r.name);
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
async function insertSession(db: DatabaseClient, userId: string) {
|
|
163
|
-
const token = generateToken();
|
|
164
|
-
const now = new Date();
|
|
165
|
-
const expiresAt = new Date(now.getTime() + SESSION_DURATION_MS);
|
|
166
|
-
|
|
167
|
-
return await db
|
|
168
|
-
.insertInto('core.session')
|
|
169
|
-
.values({
|
|
170
|
-
id: crypto.randomUUID(),
|
|
171
|
-
token,
|
|
172
|
-
user_id: userId,
|
|
173
|
-
expires_at: expiresAt.toISOString(),
|
|
174
|
-
created_at: now.toISOString(),
|
|
175
|
-
})
|
|
176
|
-
.returningAll()
|
|
177
|
-
.executeTakeFirstOrThrow();
|
|
178
|
-
}
|
package/src/auth/types.ts
DELETED
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
import type { RolesConfig, ModelPermissions } from '@rangka/shared';
|
|
2
|
-
|
|
3
|
-
export interface AuthUser {
|
|
4
|
-
id: string;
|
|
5
|
-
email: string;
|
|
6
|
-
full_name: string;
|
|
7
|
-
enabled: boolean;
|
|
8
|
-
password_hash: string;
|
|
9
|
-
[key: string]: unknown;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export interface AuthSession {
|
|
13
|
-
id: string;
|
|
14
|
-
token: string;
|
|
15
|
-
user_id: string;
|
|
16
|
-
expires_at: Date;
|
|
17
|
-
created_at: Date;
|
|
18
|
-
permission_version?: number;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export interface ResolvedPermissions {
|
|
22
|
-
models: Record<string, ModelPermissions>;
|
|
23
|
-
pages: string[];
|
|
24
|
-
version: number;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export interface RequestContext {
|
|
28
|
-
user?: AuthUser;
|
|
29
|
-
session?: AuthSession;
|
|
30
|
-
permissions?: ResolvedPermissions;
|
|
31
|
-
roles?: string[];
|
|
32
|
-
scopeFilters?: ScopeFilter[];
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export interface ScopeFilter {
|
|
36
|
-
field: string;
|
|
37
|
-
operator: string;
|
|
38
|
-
value: unknown;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export interface RegisteredRole {
|
|
42
|
-
name: string;
|
|
43
|
-
config: RolesConfig[string];
|
|
44
|
-
app: string;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
export interface PermissionCacheEntry {
|
|
48
|
-
permissions: ResolvedPermissions;
|
|
49
|
-
version: number;
|
|
50
|
-
}
|
|
@@ -1,170 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
-
import * as path from 'node:path';
|
|
3
|
-
import { ProjectScanner } from '../project-scanner.js';
|
|
4
|
-
import { validatePageSources, detectDuplicatePageKeys } from '../page-utils.js';
|
|
5
|
-
import type { PageDefinition } from '@rangka/shared';
|
|
6
|
-
|
|
7
|
-
const FIXTURE_ROOT = path.resolve(__dirname, '../../../../../tests/fixtures/basic-app');
|
|
8
|
-
|
|
9
|
-
describe('ProjectScanner - page scanning', () => {
|
|
10
|
-
it('discovers pages from modules with pages directory', async () => {
|
|
11
|
-
const scanner = new ProjectScanner(FIXTURE_ROOT);
|
|
12
|
-
const result = await scanner.scan();
|
|
13
|
-
|
|
14
|
-
expect(result.app.pages).toBeDefined();
|
|
15
|
-
expect(result.app.pages!.length).toBe(2);
|
|
16
|
-
|
|
17
|
-
const pageKeys = result.app.pages!.map((p) => p.page.key);
|
|
18
|
-
expect(pageKeys).toContain('sales.customers');
|
|
19
|
-
expect(pageKeys).toContain('sales.orders');
|
|
20
|
-
|
|
21
|
-
for (const entry of result.app.pages!) {
|
|
22
|
-
expect(entry.module).toBe('sales');
|
|
23
|
-
}
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
it('returns no pages field when no pages directory exists', async () => {
|
|
27
|
-
const scanner = new ProjectScanner(
|
|
28
|
-
path.resolve(__dirname, '../../../../../tests/fixtures/basic-app'),
|
|
29
|
-
);
|
|
30
|
-
const result = await scanner.scan();
|
|
31
|
-
|
|
32
|
-
if (result.app.pages) {
|
|
33
|
-
expect(result.app.pages.length).toBeGreaterThan(0);
|
|
34
|
-
}
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
it('logs warning and continues when a page file has import error', async () => {
|
|
38
|
-
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
39
|
-
|
|
40
|
-
const scanner = new ProjectScanner(FIXTURE_ROOT);
|
|
41
|
-
const result = await scanner.scan();
|
|
42
|
-
|
|
43
|
-
expect(result.app.pages).toBeDefined();
|
|
44
|
-
expect(result.app.pages!.length).toBeGreaterThan(0);
|
|
45
|
-
|
|
46
|
-
warnSpy.mockRestore();
|
|
47
|
-
});
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
describe('detectDuplicatePageKeys', () => {
|
|
51
|
-
it('emits warning for duplicate page keys', () => {
|
|
52
|
-
const pages: Array<{ module: string; page: PageDefinition }> = [
|
|
53
|
-
{
|
|
54
|
-
module: 'sales',
|
|
55
|
-
page: { key: 'sales.orders', label: 'Orders', type: 'collection', body: [] },
|
|
56
|
-
},
|
|
57
|
-
{
|
|
58
|
-
module: 'crm',
|
|
59
|
-
page: { key: 'sales.orders', label: 'Orders CRM', type: 'collection', body: [] },
|
|
60
|
-
},
|
|
61
|
-
];
|
|
62
|
-
|
|
63
|
-
const warnings = detectDuplicatePageKeys(pages);
|
|
64
|
-
expect(warnings).toHaveLength(1);
|
|
65
|
-
expect(warnings[0].pageKey).toBe('sales.orders');
|
|
66
|
-
expect(warnings[0].message).toContain('Duplicate page key');
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
it('no warnings when all keys are unique', () => {
|
|
70
|
-
const pages: Array<{ module: string; page: PageDefinition }> = [
|
|
71
|
-
{
|
|
72
|
-
module: 'sales',
|
|
73
|
-
page: { key: 'sales.orders', label: 'Orders', type: 'collection', body: [] },
|
|
74
|
-
},
|
|
75
|
-
{
|
|
76
|
-
module: 'sales',
|
|
77
|
-
page: { key: 'sales.customers', label: 'Customers', type: 'collection', body: [] },
|
|
78
|
-
},
|
|
79
|
-
];
|
|
80
|
-
|
|
81
|
-
const warnings = detectDuplicatePageKeys(pages);
|
|
82
|
-
expect(warnings).toHaveLength(0);
|
|
83
|
-
});
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
describe('validatePageSources', () => {
|
|
87
|
-
const knownModels = new Set(['sales.order', 'sales.customer', 'contacts.contact']);
|
|
88
|
-
|
|
89
|
-
it('passes when all source models exist', () => {
|
|
90
|
-
const pages: Array<{ module: string; page: PageDefinition }> = [
|
|
91
|
-
{
|
|
92
|
-
module: 'sales',
|
|
93
|
-
page: {
|
|
94
|
-
key: 'sales.orders',
|
|
95
|
-
label: 'Orders',
|
|
96
|
-
type: 'collection',
|
|
97
|
-
body: [{ type: 'data', source: { model: 'sales.order' }, children: [] }],
|
|
98
|
-
},
|
|
99
|
-
},
|
|
100
|
-
];
|
|
101
|
-
|
|
102
|
-
const warnings = validatePageSources(pages, knownModels);
|
|
103
|
-
expect(warnings).toHaveLength(0);
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
it('warns when body widget references non-existent model', () => {
|
|
107
|
-
const pages: Array<{ module: string; page: PageDefinition }> = [
|
|
108
|
-
{
|
|
109
|
-
module: 'sales',
|
|
110
|
-
page: {
|
|
111
|
-
key: 'sales.broken',
|
|
112
|
-
label: 'Broken',
|
|
113
|
-
type: 'collection',
|
|
114
|
-
body: [{ type: 'data', source: { model: 'sales.nonexistent' }, children: [] }],
|
|
115
|
-
},
|
|
116
|
-
},
|
|
117
|
-
];
|
|
118
|
-
|
|
119
|
-
const warnings = validatePageSources(pages, knownModels);
|
|
120
|
-
expect(warnings).toHaveLength(1);
|
|
121
|
-
expect(warnings[0].pageKey).toBe('sales.broken');
|
|
122
|
-
expect(warnings[0].location).toBe('body[0]');
|
|
123
|
-
expect(warnings[0].message).toContain('sales.nonexistent');
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
it('warns when nested widget references non-existent model', () => {
|
|
127
|
-
const pages: Array<{ module: string; page: PageDefinition }> = [
|
|
128
|
-
{
|
|
129
|
-
module: 'sales',
|
|
130
|
-
page: {
|
|
131
|
-
key: 'sales.detail',
|
|
132
|
-
label: 'Detail',
|
|
133
|
-
type: 'collection',
|
|
134
|
-
body: [
|
|
135
|
-
{
|
|
136
|
-
type: 'data',
|
|
137
|
-
source: { model: 'sales.order' },
|
|
138
|
-
children: [{ type: 'data', source: { model: 'contacts.missing' }, children: [] }],
|
|
139
|
-
},
|
|
140
|
-
],
|
|
141
|
-
},
|
|
142
|
-
},
|
|
143
|
-
];
|
|
144
|
-
|
|
145
|
-
const warnings = validatePageSources(pages, knownModels);
|
|
146
|
-
expect(warnings).toHaveLength(1);
|
|
147
|
-
expect(warnings[0].location).toBe('body[0].children[0]');
|
|
148
|
-
expect(warnings[0].message).toContain('contacts.missing');
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
it('no warnings for widgets without model sources', () => {
|
|
152
|
-
const pages: Array<{ module: string; page: PageDefinition }> = [
|
|
153
|
-
{
|
|
154
|
-
module: 'reports',
|
|
155
|
-
page: {
|
|
156
|
-
key: 'reports.revenue',
|
|
157
|
-
label: 'Revenue',
|
|
158
|
-
type: 'dashboard',
|
|
159
|
-
body: [
|
|
160
|
-
{ type: 'text', props: { content: 'Revenue Report' } },
|
|
161
|
-
{ type: 'button', props: { label: 'Refresh' } },
|
|
162
|
-
],
|
|
163
|
-
},
|
|
164
|
-
},
|
|
165
|
-
];
|
|
166
|
-
|
|
167
|
-
const warnings = validatePageSources(pages, knownModels);
|
|
168
|
-
expect(warnings).toHaveLength(0);
|
|
169
|
-
});
|
|
170
|
-
});
|