@adula/kit 0.2.0-alpha.1
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/LICENSE.md +10 -0
- package/README.md +31 -0
- package/build/agent/AGENTS.template.md +28 -0
- package/build/agent/capabilities.md +13 -0
- package/build/agent/skills/adula-frontend-design/SKILL.md +59 -0
- package/build/agent/skills/idea-review/SKILL.md +6 -0
- package/build/commands/capabilities.d.ts +6 -0
- package/build/commands/capabilities.js +9 -0
- package/build/commands/doctor.d.ts +9 -0
- package/build/commands/doctor.js +32 -0
- package/build/commands/gaps.d.ts +12 -0
- package/build/commands/gaps.js +46 -0
- package/build/commands/install.d.ts +9 -0
- package/build/commands/install.js +91 -0
- package/build/commands/main.d.ts +6 -0
- package/build/commands/main.js +27 -0
- package/build/commands/module_add.d.ts +8 -0
- package/build/commands/module_add.js +57 -0
- package/build/commands/module_remove.d.ts +10 -0
- package/build/commands/module_remove.js +94 -0
- package/build/commands/resource.d.ts +8 -0
- package/build/commands/resource.js +29 -0
- package/build/commands/storage_migrate.d.ts +12 -0
- package/build/commands/storage_migrate.js +39 -0
- package/build/commands/ui.d.ts +11 -0
- package/build/commands/ui.js +210 -0
- package/build/configure.d.ts +2 -0
- package/build/configure.js +79 -0
- package/build/database/migrations/1770000000000_kit_core.d.ts +5 -0
- package/build/database/migrations/1770000000000_kit_core.js +10 -0
- package/build/database/migrations/1770000000001_kit_attachments.d.ts +5 -0
- package/build/database/migrations/1770000000001_kit_attachments.js +10 -0
- package/build/database/migrations/1770000000002_kit_saved_views.d.ts +5 -0
- package/build/database/migrations/1770000000002_kit_saved_views.js +10 -0
- package/build/database/migrations/1770000000003_kit_user_invitations.d.ts +5 -0
- package/build/database/migrations/1770000000003_kit_user_invitations.js +18 -0
- package/build/index.d.ts +51 -0
- package/build/index.js +34 -0
- package/build/providers/kit_provider.d.ts +6 -0
- package/build/providers/kit_provider.js +10 -0
- package/build/src/admin/contracts.d.ts +12 -0
- package/build/src/admin/contracts.js +93 -0
- package/build/src/admin/controller.d.ts +30 -0
- package/build/src/admin/controller.js +116 -0
- package/build/src/admin/errors.d.ts +5 -0
- package/build/src/admin/errors.js +9 -0
- package/build/src/admin/presentation.d.ts +22 -0
- package/build/src/admin/presentation.js +1 -0
- package/build/src/admin/resource_service.d.ts +170 -0
- package/build/src/admin/resource_service.js +763 -0
- package/build/src/attachments/attachment_service.d.ts +71 -0
- package/build/src/attachments/attachment_service.js +182 -0
- package/build/src/attachments/doctor.d.ts +4 -0
- package/build/src/attachments/doctor.js +28 -0
- package/build/src/attachments/storage_migrate.d.ts +39 -0
- package/build/src/attachments/storage_migrate.js +64 -0
- package/build/src/auth/ability.d.ts +22 -0
- package/build/src/auth/ability.js +31 -0
- package/build/src/auth/actor_store.d.ts +14 -0
- package/build/src/auth/actor_store.js +65 -0
- package/build/src/auth/conditions.d.ts +11 -0
- package/build/src/auth/conditions.js +81 -0
- package/build/src/auth/packed_rules.d.ts +4 -0
- package/build/src/auth/packed_rules.js +13 -0
- package/build/src/auth/sql.d.ts +11 -0
- package/build/src/auth/sql.js +114 -0
- package/build/src/commands/agent_assets.d.ts +7 -0
- package/build/src/commands/agent_assets.js +54 -0
- package/build/src/commands/doctor.d.ts +11 -0
- package/build/src/commands/doctor.js +261 -0
- package/build/src/commands/generator.d.ts +1 -0
- package/build/src/commands/generator.js +70 -0
- package/build/src/commands/source_markers.d.ts +2 -0
- package/build/src/commands/source_markers.js +9 -0
- package/build/src/core/activity.d.ts +43 -0
- package/build/src/core/activity.js +77 -0
- package/build/src/core/administration_guard.d.ts +3 -0
- package/build/src/core/administration_guard.js +37 -0
- package/build/src/core/health.d.ts +42 -0
- package/build/src/core/health.js +44 -0
- package/build/src/core/mail_test.d.ts +18 -0
- package/build/src/core/mail_test.js +88 -0
- package/build/src/core/notifications.d.ts +25 -0
- package/build/src/core/notifications.js +65 -0
- package/build/src/core/org_units.d.ts +30 -0
- package/build/src/core/org_units.js +123 -0
- package/build/src/core/roles.d.ts +72 -0
- package/build/src/core/roles.js +319 -0
- package/build/src/core/saved_views.d.ts +37 -0
- package/build/src/core/saved_views.js +139 -0
- package/build/src/core/settings.d.ts +29 -0
- package/build/src/core/settings.js +113 -0
- package/build/src/core/setup.d.ts +14 -0
- package/build/src/core/setup.js +89 -0
- package/build/src/core/ui_preferences.d.ts +12 -0
- package/build/src/core/ui_preferences.js +30 -0
- package/build/src/core/user_invitations.d.ts +23 -0
- package/build/src/core/user_invitations.js +144 -0
- package/build/src/core/users.d.ts +44 -0
- package/build/src/core/users.js +182 -0
- package/build/src/database/configure.d.ts +3 -0
- package/build/src/database/configure.js +41 -0
- package/build/src/database/schema.d.ts +7 -0
- package/build/src/database/schema.js +248 -0
- package/build/src/eslint/index.d.ts +3 -0
- package/build/src/eslint/index.js +64 -0
- package/build/src/events/adonis_jobs.d.ts +9 -0
- package/build/src/events/adonis_jobs.js +13 -0
- package/build/src/events/outbox.d.ts +18 -0
- package/build/src/events/outbox.js +33 -0
- package/build/src/events/record_mutation.d.ts +11 -0
- package/build/src/events/record_mutation.js +45 -0
- package/build/src/mcp/tools.d.ts +8 -0
- package/build/src/mcp/tools.js +156 -0
- package/build/src/org/org_service.d.ts +2 -0
- package/build/src/org/org_service.js +16 -0
- package/build/src/resource/define_resource.d.ts +4 -0
- package/build/src/resource/define_resource.js +48 -0
- package/build/src/resource/registry.d.ts +9 -0
- package/build/src/resource/registry.js +59 -0
- package/build/src/resource/types.d.ts +80 -0
- package/build/src/resource/types.js +1 -0
- package/build/src/resource/values.d.ts +5 -0
- package/build/src/resource/values.js +87 -0
- package/build/src/services/backup.d.ts +14 -0
- package/build/src/services/backup.js +59 -0
- package/build/src/services/settings.d.ts +9 -0
- package/build/src/services/settings.js +23 -0
- package/build/src/version.d.ts +1 -0
- package/build/src/version.js +2 -0
- package/build/stubs/README.md +6 -0
- package/build/stubs/controller.stub +8 -0
- package/build/stubs/main.d.ts +5 -0
- package/build/stubs/main.js +5 -0
- package/build/stubs/main.ts +5 -0
- package/build/stubs/modules.stub +6 -0
- package/build/stubs/resource_contract.stub +1 -0
- package/build/stubs/resource_contract.txt +390 -0
- package/build/stubs/routes.stub +16 -0
- package/build/stubs/service.stub +9 -0
- package/package.json +104 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { Knex } from 'knex';
|
|
2
|
+
import type { JsonValue } from '../resource/types.js';
|
|
3
|
+
export type AttachmentSummary = {
|
|
4
|
+
id: number;
|
|
5
|
+
name: string;
|
|
6
|
+
size: number;
|
|
7
|
+
mimeType: string;
|
|
8
|
+
url: string;
|
|
9
|
+
};
|
|
10
|
+
export type AttachmentRecord = {
|
|
11
|
+
id: number;
|
|
12
|
+
disk: string;
|
|
13
|
+
path: string;
|
|
14
|
+
name: string;
|
|
15
|
+
originalName: string;
|
|
16
|
+
size: number;
|
|
17
|
+
mimeType: string;
|
|
18
|
+
extname: string;
|
|
19
|
+
data: Record<string, JsonValue>;
|
|
20
|
+
uploadedBy: number;
|
|
21
|
+
orgUnitId: number | null;
|
|
22
|
+
resource: string | null;
|
|
23
|
+
recordId: number | null;
|
|
24
|
+
field: string | null;
|
|
25
|
+
createdAt: string;
|
|
26
|
+
deletedAt: string | null;
|
|
27
|
+
};
|
|
28
|
+
export type UploadInput = {
|
|
29
|
+
disk: string;
|
|
30
|
+
path: string;
|
|
31
|
+
name: string;
|
|
32
|
+
originalName: string;
|
|
33
|
+
size: number;
|
|
34
|
+
mimeType: string;
|
|
35
|
+
extname: string;
|
|
36
|
+
data: Record<string, unknown>;
|
|
37
|
+
uploadedBy: number;
|
|
38
|
+
orgUnitId?: number | null;
|
|
39
|
+
resource?: string | null;
|
|
40
|
+
field?: string | null;
|
|
41
|
+
};
|
|
42
|
+
export type ClaimInput = {
|
|
43
|
+
attachmentId: number;
|
|
44
|
+
actor: {
|
|
45
|
+
id: number;
|
|
46
|
+
};
|
|
47
|
+
resource: string;
|
|
48
|
+
field: string;
|
|
49
|
+
/** Omitted while a record is being created: the upload is verified but not bound yet. */
|
|
50
|
+
recordId?: number;
|
|
51
|
+
orgUnitId?: number | null;
|
|
52
|
+
scoped: boolean;
|
|
53
|
+
};
|
|
54
|
+
export declare function attachmentUrl(id: number): string;
|
|
55
|
+
export declare function isAttachmentId(value: unknown): value is number;
|
|
56
|
+
/** Disk keys stay relative and slash-separated so files move between disks unchanged. */
|
|
57
|
+
export declare function isRelativeDiskPath(path: unknown): path is string;
|
|
58
|
+
export declare function summarizeAttachment(row: Record<string, unknown>): AttachmentSummary;
|
|
59
|
+
/** Records a stored file. The host performs the Drive write; the kit only keeps verified metadata. */
|
|
60
|
+
export declare function registerUpload(db: Knex, input: UploadInput): Promise<AttachmentRecord>;
|
|
61
|
+
export declare function findAttachment(db: Knex, id: unknown): Promise<AttachmentRecord | undefined>;
|
|
62
|
+
/** One query per resource page: callers collect every attachment id first. */
|
|
63
|
+
export declare function loadAttachments(db: Knex, ids: readonly number[]): Promise<Map<number, AttachmentSummary>>;
|
|
64
|
+
/**
|
|
65
|
+
* Ownership is decided inside the caller's transaction: a rejected claim rolls
|
|
66
|
+
* the whole record write back. Only the uploader may bind an upload, and an
|
|
67
|
+
* upload binds to exactly one record field.
|
|
68
|
+
*/
|
|
69
|
+
export declare function claimAttachment(trx: Knex.Transaction, input: ClaimInput): Promise<AttachmentRecord>;
|
|
70
|
+
/** Unbinding keeps the file and its metadata for restore drills; only the row is soft-deleted. */
|
|
71
|
+
export declare function releaseAttachment(db: Knex, id: number): Promise<boolean>;
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { KitError } from '../admin/errors.js';
|
|
2
|
+
import { identifier } from '../resource/define_resource.js';
|
|
3
|
+
const MAX_ID = 2147483647;
|
|
4
|
+
const REJECTED = 'المرفق غير موجود أو لا يخصك';
|
|
5
|
+
export function attachmentUrl(id) {
|
|
6
|
+
return `/attachments/${id}`;
|
|
7
|
+
}
|
|
8
|
+
export function isAttachmentId(value) {
|
|
9
|
+
return Number.isSafeInteger(value) && Number(value) >= 1 && Number(value) <= MAX_ID;
|
|
10
|
+
}
|
|
11
|
+
/** Disk keys stay relative and slash-separated so files move between disks unchanged. */
|
|
12
|
+
export function isRelativeDiskPath(path) {
|
|
13
|
+
return (typeof path === 'string' &&
|
|
14
|
+
path.length > 0 &&
|
|
15
|
+
path.length <= 1024 &&
|
|
16
|
+
!path.startsWith('/') &&
|
|
17
|
+
!path.includes('\\') &&
|
|
18
|
+
!path.includes('\0') &&
|
|
19
|
+
!/^[a-zA-Z]:/.test(path) &&
|
|
20
|
+
path.split('/').every((segment) => segment !== '' && segment !== '.' && segment !== '..'));
|
|
21
|
+
}
|
|
22
|
+
function text(value, max) {
|
|
23
|
+
return (typeof value === 'string' && value.length > 0 && value.length <= max && !value.includes('\0'));
|
|
24
|
+
}
|
|
25
|
+
export function summarizeAttachment(row) {
|
|
26
|
+
return {
|
|
27
|
+
id: Number(row.id),
|
|
28
|
+
name: String(row.original_name),
|
|
29
|
+
size: Number(row.size),
|
|
30
|
+
mimeType: String(row.mime_type),
|
|
31
|
+
url: attachmentUrl(Number(row.id)),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
function fromRow(row) {
|
|
35
|
+
const stamp = (value) => (value instanceof Date ? value.toISOString() : value);
|
|
36
|
+
return {
|
|
37
|
+
id: Number(row.id),
|
|
38
|
+
disk: String(row.disk),
|
|
39
|
+
path: String(row.path),
|
|
40
|
+
name: String(row.name),
|
|
41
|
+
originalName: String(row.original_name),
|
|
42
|
+
size: Number(row.size),
|
|
43
|
+
mimeType: String(row.mime_type),
|
|
44
|
+
extname: String(row.extname),
|
|
45
|
+
data: (row.data ?? {}),
|
|
46
|
+
uploadedBy: Number(row.uploaded_by),
|
|
47
|
+
orgUnitId: row.org_unit_id === null || row.org_unit_id === undefined ? null : Number(row.org_unit_id),
|
|
48
|
+
resource: row.resource === null || row.resource === undefined ? null : String(row.resource),
|
|
49
|
+
recordId: row.record_id === null || row.record_id === undefined ? null : Number(row.record_id),
|
|
50
|
+
field: row.field === null || row.field === undefined ? null : String(row.field),
|
|
51
|
+
createdAt: String(stamp(row.created_at)),
|
|
52
|
+
deletedAt: row.deleted_at === null || row.deleted_at === undefined
|
|
53
|
+
? null
|
|
54
|
+
: String(stamp(row.deleted_at)),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/** Records a stored file. The host performs the Drive write; the kit only keeps verified metadata. */
|
|
58
|
+
export async function registerUpload(db, input) {
|
|
59
|
+
if (!text(input.disk, 100) || !/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(input.disk))
|
|
60
|
+
throw new KitError(422, 'E_ATTACHMENT_INPUT', 'Invalid disk name');
|
|
61
|
+
if (!isRelativeDiskPath(input.path))
|
|
62
|
+
throw new KitError(422, 'E_ATTACHMENT_INPUT', 'Attachment paths must be relative disk keys');
|
|
63
|
+
if (!text(input.name, 255) ||
|
|
64
|
+
input.name.includes('/') ||
|
|
65
|
+
input.name !== input.path.split('/').at(-1))
|
|
66
|
+
throw new KitError(422, 'E_ATTACHMENT_INPUT', 'Attachment name must be the last path segment');
|
|
67
|
+
if (!text(input.originalName, 255) || /[/\\]/.test(input.originalName))
|
|
68
|
+
throw new KitError(422, 'E_ATTACHMENT_INPUT', 'Invalid original file name');
|
|
69
|
+
if (!Number.isSafeInteger(input.size) || input.size < 0)
|
|
70
|
+
throw new KitError(422, 'E_ATTACHMENT_INPUT', 'Invalid file size');
|
|
71
|
+
if (typeof input.mimeType !== 'string' ||
|
|
72
|
+
input.mimeType.length > 255 ||
|
|
73
|
+
/[\r\n\0]/.test(input.mimeType))
|
|
74
|
+
throw new KitError(422, 'E_ATTACHMENT_INPUT', 'Invalid content type');
|
|
75
|
+
if (typeof input.extname !== 'string' || !/^[a-z0-9]{0,32}$/.test(input.extname))
|
|
76
|
+
throw new KitError(422, 'E_ATTACHMENT_INPUT', 'Invalid file extension');
|
|
77
|
+
if (!input.data || typeof input.data !== 'object' || Array.isArray(input.data))
|
|
78
|
+
throw new KitError(422, 'E_ATTACHMENT_INPUT', 'Attachment data must be an object');
|
|
79
|
+
if (!isAttachmentId(input.uploadedBy))
|
|
80
|
+
throw new KitError(422, 'E_ATTACHMENT_INPUT', 'Invalid uploader');
|
|
81
|
+
if (input.orgUnitId !== undefined && input.orgUnitId !== null && !isAttachmentId(input.orgUnitId))
|
|
82
|
+
throw new KitError(422, 'E_ATTACHMENT_INPUT', 'Invalid organization unit');
|
|
83
|
+
for (const key of ['resource', 'field']) {
|
|
84
|
+
const value = input[key];
|
|
85
|
+
if (value !== undefined &&
|
|
86
|
+
value !== null &&
|
|
87
|
+
(typeof value !== 'string' || !/^[a-zA-Z][a-zA-Z0-9_]*$/.test(value)))
|
|
88
|
+
throw new KitError(422, 'E_ATTACHMENT_INPUT', `Invalid ${key}`);
|
|
89
|
+
}
|
|
90
|
+
if (input.resource)
|
|
91
|
+
identifier(input.resource);
|
|
92
|
+
const [row] = await db('attachments')
|
|
93
|
+
.insert({
|
|
94
|
+
disk: input.disk,
|
|
95
|
+
path: input.path,
|
|
96
|
+
name: input.name,
|
|
97
|
+
original_name: input.originalName,
|
|
98
|
+
size: input.size,
|
|
99
|
+
mime_type: input.mimeType || 'application/octet-stream',
|
|
100
|
+
extname: input.extname,
|
|
101
|
+
data: JSON.stringify(input.data),
|
|
102
|
+
uploaded_by: input.uploadedBy,
|
|
103
|
+
org_unit_id: input.orgUnitId ?? null,
|
|
104
|
+
resource: input.resource ?? null,
|
|
105
|
+
field: input.field ?? null,
|
|
106
|
+
})
|
|
107
|
+
.returning('*');
|
|
108
|
+
return fromRow(row);
|
|
109
|
+
}
|
|
110
|
+
export async function findAttachment(db, id) {
|
|
111
|
+
if (!isAttachmentId(id))
|
|
112
|
+
return undefined;
|
|
113
|
+
const row = await db('attachments').where('id', id).whereNull('deleted_at').first();
|
|
114
|
+
return row ? fromRow(row) : undefined;
|
|
115
|
+
}
|
|
116
|
+
/** One query per resource page: callers collect every attachment id first. */
|
|
117
|
+
export async function loadAttachments(db, ids) {
|
|
118
|
+
const summaries = new Map();
|
|
119
|
+
const valid = [...new Set(ids.filter(isAttachmentId))];
|
|
120
|
+
if (!valid.length)
|
|
121
|
+
return summaries;
|
|
122
|
+
const rows = await db('attachments')
|
|
123
|
+
.whereIn('id', valid)
|
|
124
|
+
.whereNull('deleted_at')
|
|
125
|
+
.select('id', 'original_name', 'size', 'mime_type');
|
|
126
|
+
for (const row of rows)
|
|
127
|
+
summaries.set(Number(row.id), summarizeAttachment(row));
|
|
128
|
+
return summaries;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Ownership is decided inside the caller's transaction: a rejected claim rolls
|
|
132
|
+
* the whole record write back. Only the uploader may bind an upload, and an
|
|
133
|
+
* upload binds to exactly one record field.
|
|
134
|
+
*/
|
|
135
|
+
export async function claimAttachment(trx, input) {
|
|
136
|
+
if (!isAttachmentId(input.attachmentId))
|
|
137
|
+
throw new KitError(422, 'E_ATTACHMENT', REJECTED);
|
|
138
|
+
const row = await trx('attachments').where('id', input.attachmentId).forUpdate().first();
|
|
139
|
+
if (!row || row.deleted_at !== null || Number(row.uploaded_by) !== Number(input.actor.id))
|
|
140
|
+
throw new KitError(422, 'E_ATTACHMENT', REJECTED);
|
|
141
|
+
if ((row.resource !== null && row.resource !== input.resource) ||
|
|
142
|
+
(row.field !== null && row.field !== input.field))
|
|
143
|
+
throw new KitError(422, 'E_ATTACHMENT', 'المرفق مخصص لحقل آخر');
|
|
144
|
+
const bound = row.record_id !== null;
|
|
145
|
+
const sameRecord = bound &&
|
|
146
|
+
input.recordId !== undefined &&
|
|
147
|
+
Number(row.record_id) === Number(input.recordId) &&
|
|
148
|
+
row.resource === input.resource &&
|
|
149
|
+
row.field === input.field;
|
|
150
|
+
if (bound && !sameRecord)
|
|
151
|
+
throw new KitError(422, 'E_ATTACHMENT', 'المرفق مرتبط بسجل آخر');
|
|
152
|
+
const orgUnitId = input.scoped && input.orgUnitId !== undefined && input.orgUnitId !== null
|
|
153
|
+
? Number(input.orgUnitId)
|
|
154
|
+
: null;
|
|
155
|
+
if (input.scoped &&
|
|
156
|
+
!sameRecord &&
|
|
157
|
+
row.org_unit_id !== null &&
|
|
158
|
+
(orgUnitId === null || Number(row.org_unit_id) !== orgUnitId))
|
|
159
|
+
throw new KitError(422, 'E_ATTACHMENT', 'المرفق يتبع وحدة تنظيمية أخرى');
|
|
160
|
+
if (input.recordId === undefined)
|
|
161
|
+
return fromRow(row);
|
|
162
|
+
const [updated] = await trx('attachments')
|
|
163
|
+
.where('id', input.attachmentId)
|
|
164
|
+
.update({
|
|
165
|
+
resource: input.resource,
|
|
166
|
+
field: input.field,
|
|
167
|
+
record_id: input.recordId,
|
|
168
|
+
org_unit_id: input.scoped ? orgUnitId : row.org_unit_id,
|
|
169
|
+
})
|
|
170
|
+
.returning('*');
|
|
171
|
+
return fromRow(updated);
|
|
172
|
+
}
|
|
173
|
+
/** Unbinding keeps the file and its metadata for restore drills; only the row is soft-deleted. */
|
|
174
|
+
export async function releaseAttachment(db, id) {
|
|
175
|
+
if (!isAttachmentId(id))
|
|
176
|
+
return false;
|
|
177
|
+
const changed = await db('attachments')
|
|
178
|
+
.where('id', id)
|
|
179
|
+
.whereNull('deleted_at')
|
|
180
|
+
.update({ deleted_at: db.fn.now() });
|
|
181
|
+
return changed > 0;
|
|
182
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { Knex } from 'knex';
|
|
2
|
+
import type { Finding } from '../commands/doctor.js';
|
|
3
|
+
/** Unbound uploads are files whose form was never saved; they are reported, never deleted here. */
|
|
4
|
+
export declare function diagnoseAttachments(db: Knex, now?: number): Promise<Finding>;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
const STALE_UPLOAD_MS = 24 * 3600000;
|
|
2
|
+
/** Unbound uploads are files whose form was never saved; they are reported, never deleted here. */
|
|
3
|
+
export async function diagnoseAttachments(db, now = Date.now()) {
|
|
4
|
+
try {
|
|
5
|
+
const result = await db.raw(`SELECT
|
|
6
|
+
count(*) FILTER (WHERE record_id IS NOT NULL) AS bound,
|
|
7
|
+
count(*) FILTER (WHERE record_id IS NULL) AS unbound,
|
|
8
|
+
count(*) FILTER (WHERE record_id IS NULL AND created_at < ?) AS stale,
|
|
9
|
+
coalesce(sum(size), 0) AS bytes
|
|
10
|
+
FROM attachments WHERE deleted_at IS NULL`, [new Date(now - STALE_UPLOAD_MS).toISOString()]);
|
|
11
|
+
const row = result.rows[0];
|
|
12
|
+
const live = Number(row.bound) + Number(row.unbound);
|
|
13
|
+
return {
|
|
14
|
+
check: 'attachments',
|
|
15
|
+
status: 'info',
|
|
16
|
+
message: `${row.bound} bound attachments, ${row.unbound} unbound uploads (${row.stale} older than 24 hours), ${row.bytes} bytes in ${live} live files`,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
catch (error) {
|
|
20
|
+
if (error.code === '42P01')
|
|
21
|
+
return {
|
|
22
|
+
check: 'attachments',
|
|
23
|
+
status: 'warn',
|
|
24
|
+
message: 'attachments table is missing; run migration:run',
|
|
25
|
+
};
|
|
26
|
+
throw error;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { Readable } from 'node:stream';
|
|
2
|
+
import type { Knex } from 'knex';
|
|
3
|
+
/** The subset of a Drive disk the migration needs; keeps the kit independent of flydrive. */
|
|
4
|
+
export type StorageDisk = {
|
|
5
|
+
exists(key: string): Promise<boolean>;
|
|
6
|
+
getStream(key: string): Promise<Readable>;
|
|
7
|
+
putStream(key: string, contents: Readable): Promise<void>;
|
|
8
|
+
getMetaData(key: string): Promise<{
|
|
9
|
+
contentLength: number;
|
|
10
|
+
}>;
|
|
11
|
+
};
|
|
12
|
+
export type StorageMigrationOptions = {
|
|
13
|
+
from: string;
|
|
14
|
+
to: string;
|
|
15
|
+
disks: readonly string[];
|
|
16
|
+
use: (name: string) => StorageDisk;
|
|
17
|
+
dryRun?: boolean;
|
|
18
|
+
report?: (line: string) => void;
|
|
19
|
+
};
|
|
20
|
+
export type StorageMigrationResult = {
|
|
21
|
+
from: string;
|
|
22
|
+
to: string;
|
|
23
|
+
dryRun: boolean;
|
|
24
|
+
total: number;
|
|
25
|
+
copied: number;
|
|
26
|
+
skipped: number;
|
|
27
|
+
failed: {
|
|
28
|
+
id: number;
|
|
29
|
+
path: string;
|
|
30
|
+
reason: string;
|
|
31
|
+
}[];
|
|
32
|
+
remaining: number;
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Copies every live attachment file to the target disk, verifies its size and
|
|
36
|
+
* repoints the row in its own statement. Rerunning after an interruption only
|
|
37
|
+
* touches rows still on the source disk; source files are never deleted here.
|
|
38
|
+
*/
|
|
39
|
+
export declare function migrateStorage(db: Knex, options: StorageMigrationOptions): Promise<StorageMigrationResult>;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copies every live attachment file to the target disk, verifies its size and
|
|
3
|
+
* repoints the row in its own statement. Rerunning after an interruption only
|
|
4
|
+
* touches rows still on the source disk; source files are never deleted here.
|
|
5
|
+
*/
|
|
6
|
+
export async function migrateStorage(db, options) {
|
|
7
|
+
for (const name of [options.from, options.to])
|
|
8
|
+
if (!options.disks.includes(name))
|
|
9
|
+
throw new Error(`Unknown disk: ${name}`);
|
|
10
|
+
if (options.from === options.to)
|
|
11
|
+
throw new Error('Source and target disks must differ');
|
|
12
|
+
const rows = await db('attachments')
|
|
13
|
+
.where('disk', options.from)
|
|
14
|
+
.whereNull('deleted_at')
|
|
15
|
+
.orderBy('id')
|
|
16
|
+
.select('id', 'path', 'size');
|
|
17
|
+
const result = {
|
|
18
|
+
from: options.from,
|
|
19
|
+
to: options.to,
|
|
20
|
+
dryRun: Boolean(options.dryRun),
|
|
21
|
+
total: rows.length,
|
|
22
|
+
copied: 0,
|
|
23
|
+
skipped: 0,
|
|
24
|
+
failed: [],
|
|
25
|
+
remaining: rows.length,
|
|
26
|
+
};
|
|
27
|
+
const report = options.report ?? (() => { });
|
|
28
|
+
if (options.dryRun) {
|
|
29
|
+
for (const row of rows)
|
|
30
|
+
report(`would copy #${row.id} ${row.path} (${row.size} bytes)`);
|
|
31
|
+
return result;
|
|
32
|
+
}
|
|
33
|
+
const source = options.use(options.from);
|
|
34
|
+
const target = options.use(options.to);
|
|
35
|
+
for (const row of rows) {
|
|
36
|
+
const size = Number(row.size);
|
|
37
|
+
try {
|
|
38
|
+
let present = false;
|
|
39
|
+
if (await target.exists(row.path)) {
|
|
40
|
+
const existing = await target.getMetaData(row.path);
|
|
41
|
+
present = existing.contentLength === size;
|
|
42
|
+
}
|
|
43
|
+
if (!present) {
|
|
44
|
+
await target.putStream(row.path, await source.getStream(row.path));
|
|
45
|
+
const written = await target.getMetaData(row.path);
|
|
46
|
+
if (written.contentLength !== size)
|
|
47
|
+
throw new Error(`size mismatch after copy: expected ${size}, found ${written.contentLength}`);
|
|
48
|
+
}
|
|
49
|
+
await db.transaction((trx) => trx('attachments').where({ id: row.id, disk: options.from }).update({ disk: options.to }));
|
|
50
|
+
if (present)
|
|
51
|
+
result.skipped++;
|
|
52
|
+
else
|
|
53
|
+
result.copied++;
|
|
54
|
+
result.remaining--;
|
|
55
|
+
report(`${present ? 'verified' : 'copied'} #${row.id} ${row.path}`);
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
59
|
+
result.failed.push({ id: Number(row.id), path: row.path, reason });
|
|
60
|
+
report(`failed #${row.id} ${row.path}: ${reason}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return result;
|
|
64
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { Ability } from '@casl/ability';
|
|
2
|
+
import type { Conditions } from './conditions.js';
|
|
3
|
+
import type { RecordData, Resource } from '../resource/types.js';
|
|
4
|
+
export type Rule = {
|
|
5
|
+
subject: string;
|
|
6
|
+
action: string | string[];
|
|
7
|
+
conditions?: Conditions;
|
|
8
|
+
fields?: string[];
|
|
9
|
+
inverted?: boolean;
|
|
10
|
+
};
|
|
11
|
+
export type Actor = {
|
|
12
|
+
id: number;
|
|
13
|
+
orgPaths: string[];
|
|
14
|
+
permissionLevel: number;
|
|
15
|
+
rules: Rule[];
|
|
16
|
+
};
|
|
17
|
+
export type KitAbility = Ability<[string, any], Conditions>;
|
|
18
|
+
export declare const abilitySchemas: WeakMap<KitAbility, readonly Pick<Resource, "fields" | "name">[]>;
|
|
19
|
+
export declare function buildAbility(rules: readonly Rule[], schemas?: readonly Pick<Resource, 'name' | 'fields'>[]): KitAbility;
|
|
20
|
+
export declare function inOrgScope(path: unknown, paths: readonly string[]): boolean;
|
|
21
|
+
export declare function canRecord(ability: KitAbility, actor: Actor, resource: Resource, action: string, record: RecordData, field?: string): boolean;
|
|
22
|
+
export declare function conditionFields(ability: KitAbility, resource: Resource): string[];
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Ability, subject, fieldPatternMatcher } from '@casl/ability';
|
|
2
|
+
import { conditionsMatcher, predicates } from './conditions.js';
|
|
3
|
+
export const abilitySchemas = new WeakMap();
|
|
4
|
+
export function buildAbility(rules, schemas = []) {
|
|
5
|
+
for (const rule of rules) {
|
|
6
|
+
predicates(rule.conditions);
|
|
7
|
+
if (rule.fields?.length === 0)
|
|
8
|
+
throw new Error('An empty fields rule is invalid');
|
|
9
|
+
}
|
|
10
|
+
const ability = new Ability([...rules.filter((r) => !r.inverted), ...rules.filter((r) => r.inverted)], {
|
|
11
|
+
conditionsMatcher: (conditions) => conditionsMatcher(conditions, schemas),
|
|
12
|
+
fieldMatcher: fieldPatternMatcher,
|
|
13
|
+
});
|
|
14
|
+
abilitySchemas.set(ability, schemas);
|
|
15
|
+
return ability;
|
|
16
|
+
}
|
|
17
|
+
export function inOrgScope(path, paths) {
|
|
18
|
+
return (typeof path === 'string' &&
|
|
19
|
+
paths.some((parent) => path === parent || path.startsWith(`${parent}.`)));
|
|
20
|
+
}
|
|
21
|
+
export function canRecord(ability, actor, resource, action, record, field) {
|
|
22
|
+
return ((!resource.scoped || inOrgScope(record.orgPath, actor.orgPaths)) &&
|
|
23
|
+
ability.can(action, subject(resource.name, record), field));
|
|
24
|
+
}
|
|
25
|
+
export function conditionFields(ability, resource) {
|
|
26
|
+
return [
|
|
27
|
+
...new Set(ability.rules
|
|
28
|
+
.filter((r) => r.subject === resource.name || r.subject === 'all')
|
|
29
|
+
.flatMap((r) => Object.keys(r.conditions ?? {}))),
|
|
30
|
+
];
|
|
31
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Knex } from 'knex';
|
|
2
|
+
import type { Actor } from './ability.js';
|
|
3
|
+
import type { ResourceRegistry } from '../resource/registry.js';
|
|
4
|
+
export interface ActorCache {
|
|
5
|
+
get(key: string): Promise<Actor | undefined>;
|
|
6
|
+
set(key: string, value: Actor): Promise<void>;
|
|
7
|
+
}
|
|
8
|
+
export declare class ActorStore {
|
|
9
|
+
private db;
|
|
10
|
+
private registry;
|
|
11
|
+
private cache?;
|
|
12
|
+
constructor(db: Knex, registry: ResourceRegistry, cache?: ActorCache | undefined);
|
|
13
|
+
load(id: number): Promise<Actor>;
|
|
14
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
export class ActorStore {
|
|
2
|
+
db;
|
|
3
|
+
registry;
|
|
4
|
+
cache;
|
|
5
|
+
constructor(db, registry, cache) {
|
|
6
|
+
this.db = db;
|
|
7
|
+
this.registry = registry;
|
|
8
|
+
this.cache = cache;
|
|
9
|
+
}
|
|
10
|
+
async load(id) {
|
|
11
|
+
// Revision is read on every request; DB triggers invalidate across workers and tree moves.
|
|
12
|
+
const { version } = await this.db('authorization_revision').where({ id: 1 }).first('version');
|
|
13
|
+
const key = `ability:${id}:${version}`;
|
|
14
|
+
const cached = await this.cache?.get(key);
|
|
15
|
+
if (cached)
|
|
16
|
+
return cached;
|
|
17
|
+
const memberships = await this.db('user_org_units as u')
|
|
18
|
+
.join('org_units as o', 'o.id', 'u.org_unit_id')
|
|
19
|
+
.where('u.user_id', id)
|
|
20
|
+
.select('o.path');
|
|
21
|
+
const assignments = await this.db('user_roles as ur')
|
|
22
|
+
.join('roles as r', 'r.id', 'ur.role_id')
|
|
23
|
+
.leftJoin('org_units as o', 'o.id', 'ur.org_unit_id')
|
|
24
|
+
.join('role_rules as rr', 'rr.role_id', 'r.id')
|
|
25
|
+
.where('ur.user_id', id)
|
|
26
|
+
.select('rr.*', 'r.permission_level', 'o.path as role_path');
|
|
27
|
+
const rules = [];
|
|
28
|
+
let permissionLevel = 0;
|
|
29
|
+
for (const row of assignments) {
|
|
30
|
+
// Privileged field access only comes from deployment-wide roles.
|
|
31
|
+
if (!row.role_path)
|
|
32
|
+
permissionLevel = Math.max(permissionLevel, row.permission_level);
|
|
33
|
+
const rule = {
|
|
34
|
+
subject: row.subject,
|
|
35
|
+
action: row.action,
|
|
36
|
+
conditions: row.conditions ?? undefined,
|
|
37
|
+
fields: row.fields ?? undefined,
|
|
38
|
+
inverted: row.inverted,
|
|
39
|
+
};
|
|
40
|
+
if (!row.role_path) {
|
|
41
|
+
rules.push(rule);
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
for (const resource of this.registry
|
|
45
|
+
.all()
|
|
46
|
+
.filter((r) => r.scoped && (rule.subject === 'all' || rule.subject === r.name))) {
|
|
47
|
+
if (rule.conditions?.orgPath)
|
|
48
|
+
throw new Error('orgPath is reserved for role scope');
|
|
49
|
+
rules.push({
|
|
50
|
+
...rule,
|
|
51
|
+
subject: resource.name,
|
|
52
|
+
conditions: { ...rule.conditions, orgPath: row.role_path },
|
|
53
|
+
});
|
|
54
|
+
rules.push({
|
|
55
|
+
...rule,
|
|
56
|
+
subject: resource.name,
|
|
57
|
+
conditions: { ...rule.conditions, orgPath: { $like: `${row.role_path}.%` } },
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const actor = { id, orgPaths: memberships.map((r) => r.path), permissionLevel, rules };
|
|
62
|
+
await this.cache?.set(key, actor);
|
|
63
|
+
return actor;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Scalar, RecordData, Resource } from '../resource/types.js';
|
|
2
|
+
export type Operator = '$eq' | '$ne' | '$in' | '$lt' | '$gt' | '$like';
|
|
3
|
+
export type Conditions = Record<string, Scalar | Partial<Record<Operator, Scalar | Scalar[]>>>;
|
|
4
|
+
export type Predicate = {
|
|
5
|
+
field: string;
|
|
6
|
+
operator: Operator;
|
|
7
|
+
value: Scalar | Scalar[];
|
|
8
|
+
};
|
|
9
|
+
export declare function predicates(conditions?: Conditions, fields?: Set<string>): Predicate[];
|
|
10
|
+
export declare function likePattern(value: string): RegExp;
|
|
11
|
+
export declare function conditionsMatcher(conditions: Conditions, schemas?: readonly Pick<Resource, 'name' | 'fields'>[]): (record: RecordData) => boolean;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { columnName } from '../resource/define_resource.js';
|
|
2
|
+
const operators = new Set(['$eq', '$ne', '$in', '$lt', '$gt', '$like']);
|
|
3
|
+
const scalar = (v) => v === null ||
|
|
4
|
+
['string', 'boolean'].includes(typeof v) ||
|
|
5
|
+
(typeof v === 'number' && Number.isFinite(v));
|
|
6
|
+
export function predicates(conditions = {}, fields) {
|
|
7
|
+
if (!conditions || Array.isArray(conditions) || typeof conditions !== 'object')
|
|
8
|
+
throw new Error('Conditions must be an object');
|
|
9
|
+
return Object.entries(conditions).flatMap(([field, condition]) => {
|
|
10
|
+
columnName(field);
|
|
11
|
+
if (fields && !fields.has(field))
|
|
12
|
+
throw new Error(`Unknown condition field: ${field}`);
|
|
13
|
+
const ops = scalar(condition) ? { $eq: condition } : condition;
|
|
14
|
+
if (!ops || Array.isArray(ops) || typeof ops !== 'object' || !Object.keys(ops).length)
|
|
15
|
+
throw new Error(`Invalid condition: ${field}`);
|
|
16
|
+
return Object.entries(ops).map(([operator, value]) => {
|
|
17
|
+
if (!operators.has(operator))
|
|
18
|
+
throw new Error(`Unsupported condition operator: ${operator}`);
|
|
19
|
+
if (operator === '$in' ? !Array.isArray(value) || !value.every(scalar) : !scalar(value))
|
|
20
|
+
throw new Error(`Invalid operand: ${operator}`);
|
|
21
|
+
if (operator === '$like' && typeof value !== 'string')
|
|
22
|
+
throw new Error('$like requires a string');
|
|
23
|
+
if (['$lt', '$gt'].includes(operator) && !['string', 'number'].includes(typeof value))
|
|
24
|
+
throw new Error(`${operator} requires a number or string`);
|
|
25
|
+
return { field, operator: operator, value: value };
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
// SQL LIKE semantics: % and _ are wildcards, backslash is a literal character.
|
|
30
|
+
export function likePattern(value) {
|
|
31
|
+
return new RegExp(`^${[...value].map((c) => (c === '%' ? '[\\s\\S]*' : c === '_' ? '[\\s\\S]' : c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))).join('')}$`, 'u');
|
|
32
|
+
}
|
|
33
|
+
// This matcher is used exclusively by CASL. SQL compilation shares the validated AST.
|
|
34
|
+
export function conditionsMatcher(conditions, schemas = []) {
|
|
35
|
+
const parts = predicates(conditions);
|
|
36
|
+
return (record) => parts.every(({ field, operator, value }) => {
|
|
37
|
+
const actual = record[field] ?? null;
|
|
38
|
+
const type = schemas.find((schema) => schema.name === record.__caslSubjectType__)?.fields[field]?.type;
|
|
39
|
+
const compare = () => {
|
|
40
|
+
if (type === 'money') {
|
|
41
|
+
if (typeof actual !== 'string' ||
|
|
42
|
+
typeof value !== 'string' ||
|
|
43
|
+
!/^(0|-?[1-9]\d*)$/.test(actual) ||
|
|
44
|
+
!/^(0|-?[1-9]\d*)$/.test(value))
|
|
45
|
+
return undefined;
|
|
46
|
+
const left = BigInt(actual);
|
|
47
|
+
const right = BigInt(value);
|
|
48
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
49
|
+
}
|
|
50
|
+
if (typeof actual !== typeof value || actual === null)
|
|
51
|
+
return undefined;
|
|
52
|
+
if (typeof actual === 'string' && typeof value === 'string') {
|
|
53
|
+
const left = [...actual].map((point) => point.codePointAt(0));
|
|
54
|
+
const right = [...value].map((point) => point.codePointAt(0));
|
|
55
|
+
for (let index = 0; index < Math.min(left.length, right.length); index++)
|
|
56
|
+
if (left[index] !== right[index])
|
|
57
|
+
return left[index] < right[index] ? -1 : 1;
|
|
58
|
+
return Math.sign(left.length - right.length);
|
|
59
|
+
}
|
|
60
|
+
return actual < value
|
|
61
|
+
? -1
|
|
62
|
+
: actual > value
|
|
63
|
+
? 1
|
|
64
|
+
: 0;
|
|
65
|
+
};
|
|
66
|
+
switch (operator) {
|
|
67
|
+
case '$eq':
|
|
68
|
+
return actual === value;
|
|
69
|
+
case '$ne':
|
|
70
|
+
return actual !== value;
|
|
71
|
+
case '$in':
|
|
72
|
+
return value.includes(actual);
|
|
73
|
+
case '$lt':
|
|
74
|
+
return compare() === -1;
|
|
75
|
+
case '$gt':
|
|
76
|
+
return compare() === 1;
|
|
77
|
+
case '$like':
|
|
78
|
+
return typeof actual === 'string' && likePattern(value).test(actual);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { type Actor } from './ability.js';
|
|
2
|
+
import type { Resource } from '../resource/types.js';
|
|
3
|
+
/** UI hints only. Organizational scope and every mutation are enforced on the server. */
|
|
4
|
+
export declare function packedResourceRules(resource: Resource, actor: Actor): import("@casl/ability/extra").PackRule<import("@casl/ability").SubjectRawRule<string, any, import("./conditions.ts").Conditions>>[];
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { packRules } from '@casl/ability/extra';
|
|
2
|
+
import { buildAbility } from './ability.js';
|
|
3
|
+
/** UI hints only. Organizational scope and every mutation are enforced on the server. */
|
|
4
|
+
export function packedResourceRules(resource, actor) {
|
|
5
|
+
const rules = actor.rules
|
|
6
|
+
.filter((rule) => rule.subject === resource.name || rule.subject === 'all')
|
|
7
|
+
.map((rule) => ({ ...rule, subject: resource.name }));
|
|
8
|
+
const forbidden = Object.keys(resource.fields).filter((key) => actor.permissionLevel <
|
|
9
|
+
Math.max(resource.fields[key].permissionLevel ?? 0, resource.hidden?.includes(key) ? 1 : 0));
|
|
10
|
+
if (forbidden.length)
|
|
11
|
+
rules.push({ subject: resource.name, action: 'manage', fields: forbidden, inverted: true });
|
|
12
|
+
return packRules(buildAbility(rules).rules);
|
|
13
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Knex } from 'knex';
|
|
2
|
+
import type { KitAbility, Actor } from './ability.js';
|
|
3
|
+
import { type Conditions } from './conditions.js';
|
|
4
|
+
import type { Resource } from '../resource/types.js';
|
|
5
|
+
export type Sql = {
|
|
6
|
+
text: string;
|
|
7
|
+
bindings: Knex.RawBinding[];
|
|
8
|
+
};
|
|
9
|
+
export declare function conditionSql(conditions: Conditions | undefined, resource: Resource): Sql;
|
|
10
|
+
export declare function authorizationSql(ability: KitAbility, action: string, resource: Resource, field?: string): Sql;
|
|
11
|
+
export declare function accessibleBy(query: Knex.QueryBuilder, ability: KitAbility, actor: Actor, action: string, resource: Resource): Knex.QueryBuilder<any, any>;
|