@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
@@ -1,144 +0,0 @@
1
- import { describe, it, expect, beforeEach } from 'vitest';
2
- import { recordAudit, getAuditHistory } from '../record.js';
3
-
4
- function createMockDb() {
5
- const rows: any[] = [];
6
- let idCounter = 1;
7
-
8
- const db: any = {
9
- insertInto: (_table: string) => ({
10
- values: (data: any) => ({
11
- returningAll: () => ({
12
- execute: async () => {
13
- const row = {
14
- id: `audit-${idCounter++}`,
15
- ...data,
16
- changes: typeof data.changes === 'string' ? JSON.parse(data.changes) : data.changes,
17
- timestamp: new Date(),
18
- };
19
- rows.push(row);
20
- return [row];
21
- },
22
- }),
23
- }),
24
- }),
25
- selectFrom: (_table: string) => {
26
- const filters: Array<{ col: string; val: any }> = [];
27
- const builder: any = {
28
- selectAll: () => builder,
29
- where: (col: string, op: string, val: any) => {
30
- filters.push({ col, val });
31
- return builder;
32
- },
33
- orderBy: () => builder,
34
- execute: async () => {
35
- return rows.filter((r) => filters.every((f) => r[f.col] === f.val));
36
- },
37
- };
38
- return builder;
39
- },
40
- };
41
-
42
- return { db, rows };
43
- }
44
-
45
- describe('Audit Log', () => {
46
- let db: any;
47
-
48
- beforeEach(() => {
49
- const mock = createMockDb();
50
- db = mock.db;
51
- });
52
-
53
- it('records audit on create with all fields as changes', async () => {
54
- const result = await recordAudit(db, {
55
- model: 'sales.invoice',
56
- documentId: 'inv-1',
57
- action: 'create',
58
- userId: 'user-1',
59
- before: null,
60
- after: { name: 'INV-001', amount: 1000 },
61
- });
62
-
63
- expect(result).not.toBeNull();
64
- expect(result!.action).toBe('create');
65
- expect(result!.changes.name).toEqual({ from: null, to: 'INV-001' });
66
- expect(result!.changes.amount).toEqual({ from: null, to: 1000 });
67
- });
68
-
69
- it('records audit on update with only changed fields', async () => {
70
- const result = await recordAudit(db, {
71
- model: 'sales.invoice',
72
- documentId: 'inv-1',
73
- action: 'update',
74
- userId: 'user-1',
75
- before: { name: 'INV-001', amount: 1000, status: 'draft' },
76
- after: { name: 'INV-001', amount: 2000, status: 'draft' },
77
- });
78
-
79
- expect(result).not.toBeNull();
80
- expect(result!.changes.amount).toEqual({ from: 1000, to: 2000 });
81
- expect(result!.changes.name).toBeUndefined();
82
- });
83
-
84
- it('returns null for update with no actual changes', async () => {
85
- const result = await recordAudit(db, {
86
- model: 'sales.invoice',
87
- documentId: 'inv-1',
88
- action: 'update',
89
- userId: 'user-1',
90
- before: { name: 'INV-001', amount: 1000 },
91
- after: { name: 'INV-001', amount: 1000 },
92
- });
93
-
94
- expect(result).toBeNull();
95
- });
96
-
97
- it('records audit on delete with all fields removed', async () => {
98
- const result = await recordAudit(db, {
99
- model: 'sales.invoice',
100
- documentId: 'inv-1',
101
- action: 'delete',
102
- userId: 'user-1',
103
- before: { name: 'INV-001', amount: 1000 },
104
- after: null,
105
- });
106
-
107
- expect(result).not.toBeNull();
108
- expect(result!.changes.name).toEqual({ from: 'INV-001', to: null });
109
- });
110
-
111
- it('ignores internal timestamp fields in diff', async () => {
112
- const result = await recordAudit(db, {
113
- model: 'sales.invoice',
114
- documentId: 'inv-1',
115
- action: 'update',
116
- userId: 'user-1',
117
- before: { name: 'INV-001', updated_at: '2024-01-01' },
118
- after: { name: 'INV-001', updated_at: '2024-01-02' },
119
- });
120
-
121
- expect(result).toBeNull();
122
- });
123
-
124
- it('retrieves audit history for a document', async () => {
125
- await recordAudit(db, {
126
- model: 'sales.invoice',
127
- documentId: 'inv-1',
128
- action: 'create',
129
- before: null,
130
- after: { name: 'INV-001' },
131
- });
132
-
133
- await recordAudit(db, {
134
- model: 'sales.invoice',
135
- documentId: 'inv-1',
136
- action: 'submit',
137
- before: { status: 'draft' },
138
- after: { status: 'submitted' },
139
- });
140
-
141
- const history = await getAuditHistory(db, 'sales.invoice', 'inv-1');
142
- expect(history).toHaveLength(2);
143
- });
144
- });
@@ -1,3 +0,0 @@
1
- export { getAuditTables } from './tables.js';
2
- export { recordAudit, getAuditHistory } from './record.js';
3
- export type { AuditAction, AuditChange, AuditLogRecord, AuditOptions } from './types.js';
@@ -1,69 +0,0 @@
1
- import type { AuditLogRecord, AuditOptions } from './types.js';
2
-
3
- const IGNORED_FIELDS = new Set(['updated_at', 'created_at', '_fixture_source', '_fixture_hash']);
4
-
5
- // Compares two snapshots of a document and returns which fields changed.
6
- // Null/undefined snapshots are treated as empty (all fields added or removed).
7
- function computeFieldChanges(
8
- previousState: Record<string, unknown> | null | undefined,
9
- currentState: Record<string, unknown> | null | undefined,
10
- ): Record<string, { from: unknown; to: unknown }> {
11
- const previous = previousState ?? {};
12
- const current = currentState ?? {};
13
- const allFields = new Set([...Object.keys(previous), ...Object.keys(current)]);
14
- const changes: Record<string, { from: unknown; to: unknown }> = {};
15
-
16
- for (const field of allFields) {
17
- if (IGNORED_FIELDS.has(field)) continue;
18
-
19
- const previousValue = previous[field] ?? null;
20
- const currentValue = current[field] ?? null;
21
- const hasChanged = JSON.stringify(previousValue) !== JSON.stringify(currentValue);
22
-
23
- if (hasChanged) {
24
- changes[field] = { from: previousValue, to: currentValue };
25
- }
26
- }
27
-
28
- return changes;
29
- }
30
-
31
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
32
- export async function recordAudit(db: any, options: AuditOptions): Promise<AuditLogRecord | null> {
33
- const changes = computeFieldChanges(options.before, options.after);
34
-
35
- if (options.action === 'update' && Object.keys(changes).length === 0) {
36
- return null;
37
- }
38
-
39
- const [row] = await db
40
- .insertInto('rangka_audit_log')
41
- .values({
42
- model: options.model,
43
- document_id: options.documentId,
44
- action: options.action,
45
- changes: JSON.stringify(changes),
46
- user_id: options.userId ?? null,
47
- })
48
- .returningAll()
49
- .execute();
50
-
51
- return row as AuditLogRecord;
52
- }
53
-
54
- export async function getAuditHistory(
55
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
56
- db: any,
57
- model: string,
58
- documentId: string,
59
- ): Promise<AuditLogRecord[]> {
60
- const rows = await db
61
- .selectFrom('rangka_audit_log')
62
- .selectAll()
63
- .where('model', '=', model)
64
- .where('document_id', '=', documentId)
65
- .orderBy('timestamp', 'desc')
66
- .execute();
67
-
68
- return rows as AuditLogRecord[];
69
- }
@@ -1,48 +0,0 @@
1
- import type { TableDefinition } from '../db/types.js';
2
-
3
- export function getAuditTables(): TableDefinition[] {
4
- return [buildAuditLogTable()];
5
- }
6
-
7
- function buildAuditLogTable(): TableDefinition {
8
- return {
9
- name: 'rangka_audit_log',
10
- columns: [
11
- {
12
- name: 'id',
13
- type: 'UUID',
14
- nullable: false,
15
- primaryKey: true,
16
- defaultValue: 'gen_random_uuid()',
17
- },
18
- { name: 'model', type: 'TEXT', nullable: false },
19
- { name: 'document_id', type: 'UUID', nullable: false },
20
- { name: 'action', type: 'TEXT', nullable: false },
21
- { name: 'changes', type: 'JSONB', nullable: false, defaultValue: "'{}'" },
22
- { name: 'user_id', type: 'UUID', nullable: true },
23
- { name: 'timestamp', type: 'TIMESTAMPTZ', nullable: false, defaultValue: 'NOW()' },
24
- ],
25
- foreignKeys: [],
26
- checkConstraints: [
27
- {
28
- name: 'chk_rangka_audit_log_action',
29
- column: 'action',
30
- expression: "action IN ('create', 'update', 'delete', 'submit', 'cancel')",
31
- },
32
- ],
33
- indexes: [
34
- {
35
- name: 'idx_rangka_audit_log_model_doc',
36
- table: 'rangka_audit_log',
37
- columns: ['model', 'document_id'],
38
- unique: false,
39
- },
40
- {
41
- name: 'idx_rangka_audit_log_timestamp',
42
- table: 'rangka_audit_log',
43
- columns: ['timestamp'],
44
- unique: false,
45
- },
46
- ],
47
- };
48
- }
@@ -1,26 +0,0 @@
1
- export type AuditAction = 'create' | 'update' | 'delete' | 'submit' | 'cancel';
2
-
3
- export interface AuditChange {
4
- field: string;
5
- from: unknown;
6
- to: unknown;
7
- }
8
-
9
- export interface AuditLogRecord {
10
- id: string;
11
- model: string;
12
- document_id: string;
13
- action: AuditAction;
14
- changes: Record<string, { from: unknown; to: unknown }>;
15
- user_id: string | null;
16
- timestamp: Date;
17
- }
18
-
19
- export interface AuditOptions {
20
- model: string;
21
- documentId: string;
22
- action: AuditAction;
23
- userId?: string | null;
24
- before?: Record<string, unknown> | null;
25
- after?: Record<string, unknown> | null;
26
- }
@@ -1,54 +0,0 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { getCoreModels, getCoreApp } from '../core-module.js';
3
- import { coreSchemas } from '../core-models.js';
4
-
5
- describe('core module', () => {
6
- describe('coreSchemas', () => {
7
- it('defines 4 schemas: user, role, user_role, session', () => {
8
- expect(coreSchemas).toHaveLength(4);
9
- const names = coreSchemas.map((s) => s.name);
10
- expect(names).toEqual(['user', 'role', 'user_role', 'session']);
11
- });
12
-
13
- it('user schema has expected fields', () => {
14
- const user = coreSchemas.find((s) => s.name === 'user')!;
15
- expect(Object.keys(user.fields)).toEqual(['email', 'password_hash', 'full_name', 'enabled']);
16
- });
17
-
18
- it('session schema has token and expiry fields', () => {
19
- const session = coreSchemas.find((s) => s.name === 'session')!;
20
- expect(Object.keys(session.fields)).toContain('token');
21
- expect(Object.keys(session.fields)).toContain('expires_at');
22
- expect(Object.keys(session.fields)).toContain('user_id');
23
- });
24
- });
25
-
26
- describe('getCoreModels', () => {
27
- it('returns resolved models with core qualifiedName prefix', () => {
28
- const models = getCoreModels();
29
- expect(models).toHaveLength(4);
30
- for (const m of models) {
31
- expect(m.qualifiedName).toMatch(/^core\./);
32
- expect(m.app).toBe('core');
33
- expect(m.module).toBe('core');
34
- }
35
- });
36
-
37
- it('each model has an id field', () => {
38
- const models = getCoreModels();
39
- for (const m of models) {
40
- const idField = m.fields.find((f) => f.name === 'id');
41
- expect(idField).toBeDefined();
42
- }
43
- });
44
- });
45
-
46
- describe('getCoreApp', () => {
47
- it('returns a valid DiscoveredApp structure', () => {
48
- const app = getCoreApp();
49
- expect(app.config.name).toBe('core');
50
- expect(app.schemas).toHaveLength(4);
51
- expect(app.extensions).toEqual([]);
52
- });
53
- });
54
- });
@@ -1,47 +0,0 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { formatDebugResult } from '../debug.js';
3
- import type { DebugResult } from '../debug.js';
4
-
5
- describe('debug permissions', () => {
6
- describe('formatDebugResult', () => {
7
- it('formats a complete debug result', () => {
8
- const result: DebugResult = {
9
- user: { email: 'john@test.com', id: 'u1', enabled: true },
10
- roles: ['Sales User', 'Sales Manager'],
11
- inheritanceChains: {
12
- 'Sales User': [],
13
- 'Sales Manager': ['Sales User'],
14
- },
15
- effectivePermissions: {
16
- 'sales.customer': { read: true, write: true, create: true, delete: true },
17
- 'sales.invoice': { read: true, write: true },
18
- },
19
- fieldRestrictions: {
20
- 'sales.invoice': { hidden: ['cost_price'], readOnly: ['discount_limit'] },
21
- },
22
- };
23
-
24
- const output = formatDebugResult(result);
25
-
26
- expect(output).toContain('john@test.com');
27
- expect(output).toContain('Sales User, Sales Manager');
28
- expect(output).toContain('Sales Manager ← Sales User');
29
- expect(output).toContain('sales.customer: read, write, create, delete');
30
- expect(output).toContain('cost_price');
31
- expect(output).toContain('discount_limit');
32
- });
33
-
34
- it('handles user with no roles', () => {
35
- const result: DebugResult = {
36
- user: { email: 'nobody@test.com', id: 'u3', enabled: true },
37
- roles: [],
38
- inheritanceChains: {},
39
- effectivePermissions: {},
40
- fieldRestrictions: {},
41
- };
42
-
43
- const output = formatDebugResult(result);
44
- expect(output).toContain('Roles: (none)');
45
- });
46
- });
47
- });
@@ -1,245 +0,0 @@
1
- import { describe, it, expect } from 'vitest';
2
- import {
3
- createFieldWriteGuard,
4
- createFieldStripHook,
5
- resolveFieldPermissions,
6
- } from '../field-permissions.js';
7
- import { createTestServer } from '../../__tests__/helpers.js';
8
- import type { ResolvedModel } from '../../schema/types.js';
9
-
10
- const mockModel: ResolvedModel = {
11
- qualifiedName: 'sales.invoice',
12
- app: 'sales',
13
- module: 'sales',
14
- name: 'invoice',
15
- auditLog: false,
16
- traits: [],
17
- fields: [
18
- { name: 'id', config: { type: 'string' } as any, provenance: { source: 'base' } },
19
- { name: 'total', config: { type: 'decimal' } as any, provenance: { source: 'base' } },
20
- { name: 'cost_price', config: { type: 'decimal' } as any, provenance: { source: 'base' } },
21
- { name: 'discount_limit', config: { type: 'decimal' } as any, provenance: { source: 'base' } },
22
- ],
23
- indexes: [],
24
- };
25
-
26
- describe('field permissions', () => {
27
- describe('resolveFieldPermissions', () => {
28
- it('identifies hidden fields (read: false)', () => {
29
- const result = resolveFieldPermissions(mockModel, {
30
- 'sales.invoice': {
31
- read: true,
32
- fieldPermissions: {
33
- cost_price: { read: false },
34
- },
35
- },
36
- });
37
- expect(result.hidden.has('cost_price')).toBe(true);
38
- expect(result.readOnly.has('cost_price')).toBe(false);
39
- });
40
-
41
- it('identifies read-only fields (write: false, read: true)', () => {
42
- const result = resolveFieldPermissions(mockModel, {
43
- 'sales.invoice': {
44
- read: true,
45
- fieldPermissions: {
46
- discount_limit: { read: true, write: false },
47
- },
48
- },
49
- });
50
- expect(result.readOnly.has('discount_limit')).toBe(true);
51
- expect(result.hidden.has('discount_limit')).toBe(false);
52
- });
53
-
54
- it('returns empty sets when no field permissions defined', () => {
55
- const result = resolveFieldPermissions(mockModel, {
56
- 'sales.invoice': { read: true },
57
- });
58
- expect(result.hidden.size).toBe(0);
59
- expect(result.readOnly.size).toBe(0);
60
- });
61
- });
62
-
63
- describe('field write hook', () => {
64
- it('rejects writes to read-only fields with 403', async () => {
65
- const server = createTestServer();
66
-
67
- server.addHook('onRequest', async (request) => {
68
- (request as any).authContext = {
69
- permissions: {
70
- models: {
71
- 'sales.invoice': {
72
- read: true,
73
- write: true,
74
- fieldPermissions: { discount_limit: { read: true, write: false } },
75
- },
76
- },
77
- scopes: [],
78
- version: 1,
79
- },
80
- };
81
- });
82
-
83
- server.put('/test', {
84
- preHandler: createFieldWriteGuard(mockModel),
85
- handler: async () => ({ ok: true }),
86
- });
87
-
88
- const res = await server.inject({
89
- method: 'PUT',
90
- url: '/test',
91
- payload: { discount_limit: 50, total: 100 },
92
- });
93
- expect(res.statusCode).toBe(403);
94
- expect(res.json().error.details.fields).toContain('discount_limit');
95
- });
96
-
97
- it('allows writes when field is not in body', async () => {
98
- const server = createTestServer();
99
-
100
- server.addHook('onRequest', async (request) => {
101
- (request as any).authContext = {
102
- permissions: {
103
- models: {
104
- 'sales.invoice': {
105
- read: true,
106
- write: true,
107
- fieldPermissions: { discount_limit: { read: true, write: false } },
108
- },
109
- },
110
- scopes: [],
111
- version: 1,
112
- },
113
- };
114
- });
115
-
116
- server.put('/test', {
117
- preHandler: createFieldWriteGuard(mockModel),
118
- handler: async () => ({ ok: true }),
119
- });
120
-
121
- const res = await server.inject({
122
- method: 'PUT',
123
- url: '/test',
124
- payload: { total: 100 },
125
- });
126
- expect(res.statusCode).toBe(200);
127
- });
128
-
129
- it('ignores GET requests', async () => {
130
- const server = createTestServer();
131
-
132
- server.addHook('onRequest', async (request) => {
133
- (request as any).authContext = {
134
- permissions: {
135
- models: {
136
- 'sales.invoice': {
137
- read: true,
138
- fieldPermissions: { cost_price: { read: false } },
139
- },
140
- },
141
- scopes: [],
142
- version: 1,
143
- },
144
- };
145
- });
146
-
147
- server.get('/test', {
148
- preHandler: createFieldWriteGuard(mockModel),
149
- handler: async () => ({ ok: true }),
150
- });
151
-
152
- const res = await server.inject({ method: 'GET', url: '/test' });
153
- expect(res.statusCode).toBe(200);
154
- });
155
- });
156
-
157
- describe('field strip hook', () => {
158
- it('strips hidden fields from response', async () => {
159
- const server = createTestServer();
160
-
161
- server.addHook('onRequest', async (request) => {
162
- (request as any).authContext = {
163
- permissions: {
164
- models: {
165
- 'sales.invoice': {
166
- read: true,
167
- fieldPermissions: { cost_price: { read: false } },
168
- },
169
- },
170
- scopes: [],
171
- version: 1,
172
- },
173
- };
174
- });
175
-
176
- server.get('/test', {
177
- handler: async () => ({ data: { id: '1', total: 100, cost_price: 50 } }),
178
- onSend: createFieldStripHook(mockModel) as any,
179
- });
180
-
181
- const res = await server.inject({ method: 'GET', url: '/test' });
182
- const body = res.json();
183
- expect(body.data.cost_price).toBeUndefined();
184
- expect(body.data.total).toBe(100);
185
- });
186
-
187
- it('strips hidden fields from list responses', async () => {
188
- const server = createTestServer();
189
-
190
- server.addHook('onRequest', async (request) => {
191
- (request as any).authContext = {
192
- permissions: {
193
- models: {
194
- 'sales.invoice': {
195
- read: true,
196
- fieldPermissions: { cost_price: { read: false } },
197
- },
198
- },
199
- scopes: [],
200
- version: 1,
201
- },
202
- };
203
- });
204
-
205
- server.get('/test', {
206
- handler: async () => ({
207
- data: [
208
- { id: '1', total: 100, cost_price: 50 },
209
- { id: '2', total: 200, cost_price: 75 },
210
- ],
211
- }),
212
- onSend: createFieldStripHook(mockModel) as any,
213
- });
214
-
215
- const res = await server.inject({ method: 'GET', url: '/test' });
216
- const body = res.json();
217
- expect(body.data[0].cost_price).toBeUndefined();
218
- expect(body.data[1].cost_price).toBeUndefined();
219
- expect(body.data[0].total).toBe(100);
220
- });
221
-
222
- it('passes through when no field restrictions', async () => {
223
- const server = createTestServer();
224
-
225
- server.addHook('onRequest', async (request) => {
226
- (request as any).authContext = {
227
- permissions: {
228
- models: { 'sales.invoice': { read: true } },
229
- scopes: [],
230
- version: 1,
231
- },
232
- };
233
- });
234
-
235
- server.get('/test', {
236
- handler: async () => ({ data: { id: '1', total: 100, cost_price: 50 } }),
237
- onSend: createFieldStripHook(mockModel) as any,
238
- });
239
-
240
- const res = await server.inject({ method: 'GET', url: '/test' });
241
- const body = res.json();
242
- expect(body.data.cost_price).toBe(50);
243
- });
244
- });
245
- });