@adula/kit 0.2.0-alpha.2 → 0.2.0-alpha.4

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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @adula/kit
2
2
 
3
- Experimental 0.2.0-alpha.2 resource framework for AdonisJS 7 and PostgreSQL 17. Node 24 or later is required. MIT licensed. The target is the complete approved 1.0 scope; it has not passed acceptance.
3
+ Experimental 0.2.0-alpha.4 resource framework for AdonisJS 7 and PostgreSQL 17. Node 24 or later is required. MIT licensed. The target is the complete approved 1.0 scope; it has not passed acceptance.
4
4
 
5
5
  ## Install into a consumer
6
6
 
@@ -28,4 +28,4 @@ Doctor measures the deployment's `storage/uploads` directory and warns above 5 d
28
28
 
29
29
  ## Current limitations
30
30
 
31
- Attachment ownership, upload/download, storage migration, authentication lifecycle/rate limiting, generic resource UI and core administration have local integration consumers and tests. Real OAuth/SMTP, full Tuyau field contracts, human/performance acceptance, XState workflows, later business features, staging/offsite restoration and a genuine minor-version consumer upgrade remain open. Submission envelopes are durable records in pending_definition state, not running workflows. See [implementation status](https://github.com/adulash/adula-kit/blob/main/docs/implementation-status.md), [gaps](https://github.com/adulash/adula-kit/blob/main/KIT_GAPS.md) and [1.0 acceptance](https://github.com/adulash/adula-kit/blob/main/docs/acceptance-1.0.md). Version 0.2.0-alpha.2 targets alpha; the owner-authorized latest alias remains on 0.2.0-alpha.1.
31
+ Attachment ownership, upload/download, storage migration, authentication lifecycle/rate limiting, generic resource UI and core administration have local integration consumers and tests. Real OAuth/SMTP, full Tuyau field contracts, human/performance acceptance, XState workflows, later business features, staging/offsite restoration and a genuine minor-version consumer upgrade remain open. Submission envelopes are durable records in pending_definition state, not running workflows. See [implementation status](https://github.com/adulash/adula-kit/blob/main/docs/implementation-status.md), [gaps](https://github.com/adulash/adula-kit/blob/main/KIT_GAPS.md) and [1.0 acceptance](https://github.com/adulash/adula-kit/blob/main/docs/acceptance-1.0.md). Version 0.2.0-alpha.4 targets alpha; the owner-authorized latest alias remains on 0.2.0-alpha.1.
@@ -1,13 +1,13 @@
1
1
  # Current capabilities
2
2
 
3
- Version 0.2.0-alpha.2, experimental alpha. Resource definitions and registry; six flat condition operators (typed limits documented in KIT_GAPS.md); CASL evaluation with structural ltree scope; JSON/Inertia resource CRUD; field contracts; keyset pagination; sequences/lookups/settings; soft deletion; optimistic concurrency; atomic inline writes/activity/outbox; deduplicated database listeners; org moves; attachment ownership/storage migration; saved views; core administration services; resource/module commands; copied shadcn UI and installation diagnostics.
3
+ Version 0.2.0-alpha.4, experimental alpha. Resource definitions and registry; six flat condition operators (typed limits documented in KIT_GAPS.md); CASL evaluation with structural ltree scope; JSON/Inertia resource CRUD; field contracts; keyset pagination; sequences/lookups/settings; soft deletion; optimistic concurrency; atomic inline writes/activity/outbox; deduplicated database listeners; org moves; attachment ownership/storage migration; saved views (queries limited to fields the author may query); attachment fields with per-field `accept`/`maxSize` limits and daily pruning of unbound uploads; case-insensitive e-mail identity with ownership proof before OAuth linking; `adula:gaps report`; core administration services; resource/module commands; copied shadcn UI and installation diagnostics.
4
4
 
5
5
  The reference consumer wires authentication/session lifecycle, admin screens, authorized attachments, optional MCP, Redis actor caching, a queue worker, one scheduler and backup/restore drills. Educational modules are isolated test fixtures, not installed application features. The agent ships business frontend-design and idea-review skills, company identity intake and modal defaults. Real SMTP receipt/recovery and isolated offsite local/source-S3 attachment recovery are documented in the source repository's confirmed-achievements ledger. OAuth is optional and externally unverified. Source-S3 recovery is integrated into the operational backup commands and verified directly; local Docker backup/recovery also passed; natural scheduled operation and staging remain pending; each consumer must verify its own configuration.
6
6
 
7
- This is not the 1.0 capability catalog. Read docs/implementation-status.md before using a planned feature.
7
+ This is not the 1.0 capability catalog. Planned features are listed in docs/implementation-status.md of the adula-kit source repository; do not assume them.
8
8
 
9
9
  Core user invitations are available through `UserInvitations` and the `core.users/invite` permission. New standalone projects include an Arabic invitation Dialog, SMTP mail and single-use 24-hour acceptance links; recipients set their own passwords and receive no automatic roles. Configure SMTP and APP_URL, then assign business roles after acceptance. Existing consumers must merge project-owned routes/pages deliberately.
10
10
 
11
11
  Administrative mutations protect the last active administrator and the acting administrator's own access. Repeated installation repairs missing bootstrap grants. The reference settings screen controls Gregorian, Umm al-Qura Hijri or dual-calendar presentation, edit-dialog dismissal confirmation and restrained transitions. Storage and API dates stay canonical. Permission actions require confirmation and report server outcomes.
12
12
 
13
- The separate @adula/create-app package creates a complete application from an empty directory, including local service provisioning, fresh migrations, an administrator and company identity intake. Node/npm and Docker for the default service mode are prerequisites; existing PostgreSQL/Redis can be used instead. The creator and kit remain unpublished; use local archives until an authorized release exists.
13
+ The separate @adula/create-app package creates a complete application from an empty directory, including local service provisioning, fresh migrations, an administrator and company identity intake. Node/npm and Docker for the default service mode are prerequisites; existing PostgreSQL/Redis can be used instead. The packages are published on npm under the experimental `alpha` tag: `npm create @adula/app@alpha my-app`.
@@ -6,6 +6,7 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
6
6
  };
7
7
  import { BaseCommand, args, flags } from '@adonisjs/core/ace';
8
8
  import { readFile } from 'node:fs/promises';
9
+ import { gapReport } from '../src/commands/gap_report.js';
9
10
  /** Collects KIT_GAPS.md for reporting; nothing leaves the machine without explicit confirmation. */
10
11
  export default class Gaps extends BaseCommand {
11
12
  static commandName = 'adula:gaps';
@@ -22,14 +23,10 @@ export default class Gaps extends BaseCommand {
22
23
  catch {
23
24
  throw new Error('KIT_GAPS.md does not exist; adula:install creates it');
24
25
  }
25
- const masked = content
26
- .replace(/[\w.+-]+@[\w-]+(\.[\w-]+)+/g, '<email>')
27
- .replace(/https?:\/\/[^\s)]+/g, '<url>')
28
- .replace(/\b(?:\d{1,3}\.){3}\d{1,3}\b/g, '<ip>');
29
- const gaps = masked.match(/^## GAP-\d+.*$/gm) ?? [];
30
- this.logger.info(`${gaps.length} gap(s) recorded in KIT_GAPS.md`);
31
- for (const title of gaps)
32
- this.logger.log(` ${title.replace(/^## /, '')}`);
26
+ const { masked, titles } = gapReport(content);
27
+ this.logger.info(`${titles.length} gap(s) recorded in KIT_GAPS.md`);
28
+ for (const title of titles)
29
+ this.logger.log(` ${title}`);
33
30
  if (!this.yes) {
34
31
  const confirmed = await this.prompt.confirm('Show the full masked report? Nothing is sent anywhere by this command.');
35
32
  if (!confirmed)
@@ -6,6 +6,7 @@ import ModuleAdd from './module_add.js';
6
6
  import ModuleRemove from './module_remove.js';
7
7
  import Ui from './ui.js';
8
8
  import StorageMigrate from './storage_migrate.js';
9
+ import Gaps from './gaps.js';
9
10
  const commands = [
10
11
  Resource,
11
12
  Doctor,
@@ -15,6 +16,7 @@ const commands = [
15
16
  ModuleRemove,
16
17
  Ui,
17
18
  StorageMigrate,
19
+ Gaps,
18
20
  ];
19
21
  export async function getMetaData() {
20
22
  return commands.map((command) => {
package/build/index.d.ts CHANGED
@@ -15,7 +15,7 @@ export { ResourceService } from './src/admin/resource_service.js';
15
15
  export { serialize, writableInput, selectedFields } from './src/admin/contracts.js';
16
16
  export { KitError } from './src/admin/errors.js';
17
17
  export { createCoreSchema, createResourceTable, createAttachmentsSchema, createSavedViewsSchema, } from './src/database/schema.js';
18
- export { registerUpload, findAttachment, loadAttachments, claimAttachment, releaseAttachment, summarizeAttachment, attachmentUrl, isAttachmentId, isRelativeDiskPath, } from './src/attachments/attachment_service.js';
18
+ export { registerUpload, findAttachment, loadAttachments, claimAttachment, releaseAttachment, summarizeAttachment, attachmentUrl, isAttachmentId, isRelativeDiskPath, attachmentPolicy, pendingUploadCount, staleUploads, forgetUpload, DEFAULT_ATTACHMENT_EXTENSIONS, PENDING_UPLOAD_LIMIT, UNBOUND_UPLOAD_TTL_MS, } from './src/attachments/attachment_service.js';
19
19
  export type { AttachmentRecord, AttachmentSummary, UploadInput, ClaimInput, } from './src/attachments/attachment_service.js';
20
20
  export { migrateStorage } from './src/attachments/storage_migrate.js';
21
21
  export type { StorageDisk, StorageMigrationOptions, StorageMigrationResult, } from './src/attachments/storage_migrate.js';
package/build/index.js CHANGED
@@ -11,7 +11,7 @@ export { ResourceService } from './src/admin/resource_service.js';
11
11
  export { serialize, writableInput, selectedFields } from './src/admin/contracts.js';
12
12
  export { KitError } from './src/admin/errors.js';
13
13
  export { createCoreSchema, createResourceTable, createAttachmentsSchema, createSavedViewsSchema, } from './src/database/schema.js';
14
- export { registerUpload, findAttachment, loadAttachments, claimAttachment, releaseAttachment, summarizeAttachment, attachmentUrl, isAttachmentId, isRelativeDiskPath, } from './src/attachments/attachment_service.js';
14
+ export { registerUpload, findAttachment, loadAttachments, claimAttachment, releaseAttachment, summarizeAttachment, attachmentUrl, isAttachmentId, isRelativeDiskPath, attachmentPolicy, pendingUploadCount, staleUploads, forgetUpload, DEFAULT_ATTACHMENT_EXTENSIONS, PENDING_UPLOAD_LIMIT, UNBOUND_UPLOAD_TTL_MS, } from './src/attachments/attachment_service.js';
15
15
  export { migrateStorage } from './src/attachments/storage_migrate.js';
16
16
  export { diagnoseAttachments } from './src/attachments/doctor.js';
17
17
  export { Settings, sequence, notify } from './src/services/settings.js';
@@ -1,7 +1,7 @@
1
1
  import type { Knex } from 'knex';
2
2
  import type { ResourceRegistry } from '../resource/registry.js';
3
- import type { Action, RecordData, SerializedRecord } from '../resource/types.js';
4
- import { type Actor } from '../auth/ability.js';
3
+ import type { Action, RecordData, Resource, SerializedRecord } from '../resource/types.js';
4
+ import { type Actor, type KitAbility } from '../auth/ability.js';
5
5
  import type { ResourceDescription, ResourceNavigation, ResourceField } from './presentation.js';
6
6
  export type ListOptions = {
7
7
  limit?: number;
@@ -12,6 +12,11 @@ export type ListOptions = {
12
12
  filters?: Record<string, unknown>;
13
13
  estimate?: boolean;
14
14
  };
15
+ /**
16
+ * Whether an actor may sort, filter or search by a field. Querying reveals values
17
+ * indirectly, so it needs unconditional view access at the field's level.
18
+ */
19
+ export declare function canQueryField(resource: Resource, actor: Actor, ability: KitAbility, key: string): boolean;
15
20
  export declare class ResourceService {
16
21
  private db;
17
22
  private registry;
@@ -75,6 +80,11 @@ export declare class ResourceService {
75
80
  action: Action;
76
81
  fields: string[];
77
82
  actorId: number;
83
+ actorName: string | null;
84
+ /**
85
+ * @deprecated Always null. Kept so project-owned copies of resource-show from
86
+ * earlier releases still compile; they fall back to the actor id.
87
+ */
78
88
  actorEmail: string | null;
79
89
  createdAt: string;
80
90
  }[]>;
@@ -92,7 +102,21 @@ export declare class ResourceService {
92
102
  filterable?: boolean;
93
103
  permissionLevel?: number;
94
104
  sequence?: string;
95
- type: "string" | "text" | "integer" | "money" | "boolean" | "date" | "datetime" | "json" | "attachment";
105
+ type: "string" | "text" | "integer" | "money" | "boolean" | "date" | "datetime" | "json";
106
+ key: string;
107
+ } | {
108
+ label: import("../resource/types.js").Label;
109
+ column?: string;
110
+ required?: boolean;
111
+ unique?: boolean;
112
+ sortable?: boolean;
113
+ searchable?: boolean;
114
+ filterable?: boolean;
115
+ permissionLevel?: number;
116
+ sequence?: string;
117
+ type: "attachment";
118
+ accept?: string[] | "any";
119
+ maxSize?: string;
96
120
  key: string;
97
121
  } | {
98
122
  label: import("../resource/types.js").Label;
@@ -8,6 +8,19 @@ import { sequence } from '../services/settings.js';
8
8
  import { recordMutation } from '../events/record_mutation.js';
9
9
  import { fieldValue } from '../resource/values.js';
10
10
  import { claimAttachment, isAttachmentId, loadAttachments, releaseAttachment, } from '../attachments/attachment_service.js';
11
+ /**
12
+ * Whether an actor may sort, filter or search by a field. Querying reveals values
13
+ * indirectly, so it needs unconditional view access at the field's level.
14
+ */
15
+ export function canQueryField(resource, actor, ability, key) {
16
+ const level = Math.max(resource.fields[key].permissionLevel ?? 0, resource.hidden?.includes(key) ? 1 : 0);
17
+ // Conditional field rules cannot safely authorize a global sort/search.
18
+ return (actor.permissionLevel >= level &&
19
+ ability.can('view', resource.name, key) &&
20
+ !ability.rules.some((rule) => rule.conditions &&
21
+ (!rule.fields || rule.fields.includes(key)) &&
22
+ (rule.subject === resource.name || rule.subject === 'all')));
23
+ }
11
24
  export class ResourceService {
12
25
  db;
13
26
  registry;
@@ -178,8 +191,9 @@ export class ResourceService {
178
191
  query.where(`r.${resource.fields[key].column ?? columnName(key)}`, value);
179
192
  }
180
193
  // Estimate the authorized, filtered query, never the deployment-wide table cardinality.
194
+ // Only the first page pays for the extra planner round trip; later pages keep its figure.
181
195
  let estimatedTotal;
182
- if (options.estimate !== false) {
196
+ if (options.estimate !== false && !options.cursor) {
183
197
  const compiled = query.clone().clearSelect().select('r.id').toSQL();
184
198
  const estimate = await this.db.raw(`EXPLAIN (FORMAT JSON) ${compiled.sql}`, [
185
199
  ...compiled.bindings,
@@ -242,13 +256,7 @@ export class ResourceService {
242
256
  };
243
257
  }
244
258
  canQueryField(resource, actor, ability, key) {
245
- const level = Math.max(resource.fields[key].permissionLevel ?? 0, resource.hidden?.includes(key) ? 1 : 0);
246
- // Conditional field rules cannot safely authorize a global sort/search.
247
- return (actor.permissionLevel >= level &&
248
- ability.can('view', resource.name, key) &&
249
- !ability.rules.some((rule) => rule.conditions &&
250
- (!rule.fields || rule.fields.includes(key)) &&
251
- (rule.subject === resource.name || rule.subject === 'all')));
259
+ return canQueryField(resource, actor, ability, key);
252
260
  }
253
261
  async preload(resource, records, actor) {
254
262
  const related = {};
@@ -363,13 +371,19 @@ export class ResourceService {
363
371
  .where({ 'a.resource': name, 'a.record_id': id })
364
372
  .orderBy('a.id', 'desc')
365
373
  .limit(50)
366
- .select('a.id', 'a.action', 'a.changes', 'a.actor_id', 'a.created_at', 'u.email as actor_email');
374
+ .select('a.id', 'a.action', 'a.changes', 'a.actor_id', 'a.created_at', 'u.full_name as actor_name');
367
375
  return rows.map((row) => ({
368
376
  id: Number(row.id),
369
377
  action: String(row.action),
370
378
  fields: Array.isArray(row.changes?.fields) ? row.changes.fields : [],
371
379
  actorId: Number(row.actor_id),
372
- actorEmail: row.actor_email === null ? null : String(row.actor_email),
380
+ // A display name only: record viewers need not be able to read account e-mails.
381
+ actorName: row.actor_name ? String(row.actor_name) : null,
382
+ /**
383
+ * @deprecated Always null. Kept so project-owned copies of resource-show from
384
+ * earlier releases still compile; they fall back to the actor id.
385
+ */
386
+ actorEmail: null,
373
387
  createdAt: new Date(row.created_at).toISOString(),
374
388
  }));
375
389
  }
@@ -540,7 +554,12 @@ export class ResourceService {
540
554
  candidate.orgPath = unit?.path;
541
555
  }
542
556
  this.requireRecord(ability, actor, resource, action, candidate);
543
- for (const key of Object.keys(form)) {
557
+ // Validators may add fields (defaults, transforms); those are written too, so check them.
558
+ const written = new Set([
559
+ ...Object.keys(form),
560
+ ...Object.keys(validated).filter((key) => key in resource.fields),
561
+ ]);
562
+ for (const key of written) {
544
563
  const field = resource.fields[key];
545
564
  if (!ability.can(action, subject(name, candidate), key) ||
546
565
  (id !== undefined && !ability.can(action, subject(name, existing), key)) ||
@@ -756,6 +775,7 @@ export class ResourceService {
756
775
  resource: resource.name,
757
776
  id: record.id,
758
777
  actorId: actor.id,
778
+ impersonatorId: actor.impersonatorId,
759
779
  action,
760
780
  fields,
761
781
  });
@@ -1,5 +1,5 @@
1
1
  import type { Knex } from 'knex';
2
- import type { JsonValue } from '../resource/types.js';
2
+ import type { Field, JsonValue } from '../resource/types.js';
3
3
  export type AttachmentSummary = {
4
4
  id: number;
5
5
  name: string;
@@ -51,6 +51,23 @@ export type ClaimInput = {
51
51
  orgUnitId?: number | null;
52
52
  scoped: boolean;
53
53
  };
54
+ /** Business documents, images and archives; a field widens or narrows it with `accept`. */
55
+ export declare const DEFAULT_ATTACHMENT_EXTENSIONS: readonly string[];
56
+ /** Unbound uploads one user may hold at once; each is removed by pruning after a day. */
57
+ export declare const PENDING_UPLOAD_LIMIT = 50;
58
+ /** Uploads never bound to a record are removed after this age. */
59
+ export declare const UNBOUND_UPLOAD_TTL_MS: number;
60
+ /** Upload constraints of an attachment field: accepted extensions (undefined = any) and size. */
61
+ export declare function attachmentPolicy(field: Field): {
62
+ extnames?: string[];
63
+ size: string;
64
+ };
65
+ /** Unbound uploads of one user; hosts refuse new uploads beyond PENDING_UPLOAD_LIMIT. */
66
+ export declare function pendingUploadCount(db: Knex, userId: number): Promise<number>;
67
+ /** Uploads that were never bound to a record and are older than the cutoff. */
68
+ export declare function staleUploads(db: Knex, olderThan: Date, limit?: number): Promise<AttachmentRecord[]>;
69
+ /** Deletes the row of an upload that is still unbound; the host removes the file first. */
70
+ export declare function forgetUpload(db: Knex, id: number): Promise<boolean>;
54
71
  export declare function attachmentUrl(id: number): string;
55
72
  export declare function isAttachmentId(value: unknown): value is number;
56
73
  /** Disk keys stay relative and slash-separated so files move between disks unchanged. */
@@ -2,6 +2,68 @@ import { KitError } from '../admin/errors.js';
2
2
  import { identifier } from '../resource/define_resource.js';
3
3
  const MAX_ID = 2147483647;
4
4
  const REJECTED = 'المرفق غير موجود أو لا يخصك';
5
+ /** Business documents, images and archives; a field widens or narrows it with `accept`. */
6
+ export const DEFAULT_ATTACHMENT_EXTENSIONS = Object.freeze([
7
+ 'pdf',
8
+ 'png',
9
+ 'jpg',
10
+ 'jpeg',
11
+ 'gif',
12
+ 'webp',
13
+ 'txt',
14
+ 'csv',
15
+ 'doc',
16
+ 'docx',
17
+ 'xls',
18
+ 'xlsx',
19
+ 'ppt',
20
+ 'pptx',
21
+ 'odt',
22
+ 'ods',
23
+ 'odp',
24
+ 'rtf',
25
+ 'zip',
26
+ ]);
27
+ /** Unbound uploads one user may hold at once; each is removed by pruning after a day. */
28
+ export const PENDING_UPLOAD_LIMIT = 50;
29
+ /** Uploads never bound to a record are removed after this age. */
30
+ export const UNBOUND_UPLOAD_TTL_MS = 24 * 60 * 60 * 1000;
31
+ /** Upload constraints of an attachment field: accepted extensions (undefined = any) and size. */
32
+ export function attachmentPolicy(field) {
33
+ if (field.type !== 'attachment')
34
+ throw new KitError(422, 'E_FIELD_INVALID', 'الحقل ليس حقل مرفقات');
35
+ const accept = field.accept ?? DEFAULT_ATTACHMENT_EXTENSIONS;
36
+ return {
37
+ extnames: accept === 'any'
38
+ ? undefined
39
+ : accept.map((extname) => extname.toLowerCase().replace(/^\./, '')),
40
+ size: field.maxSize ?? '20mb',
41
+ };
42
+ }
43
+ /** Unbound uploads of one user; hosts refuse new uploads beyond PENDING_UPLOAD_LIMIT. */
44
+ export async function pendingUploadCount(db, userId) {
45
+ const [{ count }] = await db('attachments')
46
+ .where('uploaded_by', userId)
47
+ .whereNull('record_id')
48
+ .whereNull('deleted_at')
49
+ .count('* as count');
50
+ return Number(count);
51
+ }
52
+ /** Uploads that were never bound to a record and are older than the cutoff. */
53
+ export async function staleUploads(db, olderThan, limit = 500) {
54
+ const rows = await db('attachments')
55
+ .whereNull('record_id')
56
+ .where('created_at', '<', olderThan)
57
+ .orderBy('id')
58
+ .limit(limit);
59
+ return rows.map(fromRow);
60
+ }
61
+ /** Deletes the row of an upload that is still unbound; the host removes the file first. */
62
+ export async function forgetUpload(db, id) {
63
+ if (!isAttachmentId(id))
64
+ return false;
65
+ return (await db('attachments').where('id', id).whereNull('record_id').delete()) > 0;
66
+ }
5
67
  export function attachmentUrl(id) {
6
68
  return `/attachments/${id}`;
7
69
  }
@@ -13,6 +13,8 @@ export type Actor = {
13
13
  orgPaths: string[];
14
14
  permissionLevel: number;
15
15
  rules: Rule[];
16
+ /** Set while an administrator acts as this user; recorded with every mutation. */
17
+ impersonatorId?: number;
16
18
  };
17
19
  export type KitAbility = Ability<[string, any], Conditions>;
18
20
  export declare const abilitySchemas: WeakMap<KitAbility, readonly Pick<Resource, "fields" | "name">[]>;
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Prepares KIT_GAPS.md for sharing with the kit maintainers: e-mails, URLs and
3
+ * IPv4 addresses are masked, and gap titles are listed for a quick review.
4
+ */
5
+ export declare function gapReport(content: string): {
6
+ masked: string;
7
+ titles: string[];
8
+ };
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Prepares KIT_GAPS.md for sharing with the kit maintainers: e-mails, URLs and
3
+ * IPv4 addresses are masked, and gap titles are listed for a quick review.
4
+ */
5
+ export function gapReport(content) {
6
+ const masked = content
7
+ .replace(/[\w.+-]+@[\w-]+(\.[\w-]+)+/g, '<email>')
8
+ .replace(/https?:\/\/[^\s)]+/g, '<url>')
9
+ .replace(/\b(?:\d{1,3}\.){3}\d{1,3}\b/g, '<ip>');
10
+ const titles = (masked.match(/^## GAP-\d+.*$/gm) ?? []).map((title) => title.replace(/^## /, ''));
11
+ return { masked, titles };
12
+ }
@@ -1,7 +1,10 @@
1
1
  import { buildAbility } from '../auth/ability.js';
2
2
  import { KitError } from '../admin/errors.js';
3
+ import { canQueryField } from '../admin/resource_service.js';
3
4
  const NAME_LIMIT = 60;
4
5
  const VIEW_LIMIT = 50;
6
+ /** Shared views are visible to every viewer of a resource; cap them per resource. */
7
+ const SHARED_LIMIT = 100;
5
8
  /**
6
9
  * A saved view is stored user input that later drives a list query, so every key
7
10
  * is validated against the resource definition before it is written and again
@@ -16,12 +19,14 @@ export class SavedViews {
16
19
  this.registry = registry;
17
20
  }
18
21
  async list(name, actor) {
19
- const resource = this.authorize(name, actor);
22
+ const { resource } = this.authorize(name, actor);
23
+ // Own views first, so colleagues' shared views can never crowd them out.
20
24
  const rows = await this.db('saved_views')
21
25
  .where('resource', resource.name)
22
26
  .where((where) => where.where('user_id', actor.id).orWhere('shared', true))
27
+ .orderByRaw('(user_id = ?) DESC', [actor.id])
23
28
  .orderBy('name')
24
- .limit(VIEW_LIMIT * 2);
29
+ .limit(VIEW_LIMIT + SHARED_LIMIT);
25
30
  return rows.flatMap((row) => {
26
31
  const query = this.sanitize(resource, row.query);
27
32
  return query
@@ -38,15 +43,35 @@ export class SavedViews {
38
43
  });
39
44
  }
40
45
  async save(name, actor, input) {
41
- const resource = this.authorize(name, actor);
46
+ const { resource, ability } = this.authorize(name, actor);
42
47
  const label = typeof input.name === 'string' ? input.name.trim() : '';
43
48
  if (!label || label.length > NAME_LIMIT)
44
49
  throw new KitError(422, 'E_VIEW_NAME', 'اسم العرض مطلوب ولا يتجاوز 60 حرفاً');
45
50
  const query = this.sanitize(resource, input.query, true);
51
+ // A view may only query what its author may query; stored filters must not probe hidden fields.
52
+ const queried = [
53
+ ...(query.sort ? [query.sort] : []),
54
+ ...Object.keys(query.filters ?? {}),
55
+ ...(query.search
56
+ ? Object.entries(resource.fields)
57
+ .filter(([, field]) => field.searchable)
58
+ .map(([key]) => key)
59
+ : []),
60
+ ];
61
+ for (const key of queried)
62
+ if (!canQueryField(resource, actor, ability, key))
63
+ throw new KitError(403, 'E_FIELD_FORBIDDEN', `لا يمكنك الاستعلام بهذا الحقل: ${key}`);
46
64
  const shared = input.shared === true;
47
65
  const existing = await this.db('saved_views')
48
66
  .where({ user_id: actor.id, resource: resource.name, name: label })
49
- .first('id');
67
+ .first('id', 'shared');
68
+ if (shared && !existing?.shared) {
69
+ const [{ count }] = await this.db('saved_views')
70
+ .where({ resource: resource.name, shared: true })
71
+ .count('* as count');
72
+ if (Number(count) >= SHARED_LIMIT)
73
+ throw new KitError(422, 'E_VIEW_LIMIT', 'بلغ هذا الكيان الحد الأقصى للعروض المشتركة');
74
+ }
50
75
  if (!existing) {
51
76
  const [{ count }] = await this.db('saved_views')
52
77
  .where({ user_id: actor.id, resource: resource.name })
@@ -68,7 +93,7 @@ export class SavedViews {
68
93
  return { id: Number(row.id), name: label, query, shared, own: true };
69
94
  }
70
95
  async remove(name, actor, id) {
71
- const resource = this.authorize(name, actor);
96
+ const { resource } = this.authorize(name, actor);
72
97
  const removed = await this.db('saved_views')
73
98
  .where({ id, resource: resource.name, user_id: actor.id })
74
99
  .del();
@@ -80,7 +105,7 @@ export class SavedViews {
80
105
  const ability = buildAbility(actor.rules, this.registry.all());
81
106
  if (!resource.actions.includes('view') || !ability.can('view', resource.name))
82
107
  throw new KitError(403, 'E_FORBIDDEN', 'ليس لديك صلاحية لهذا الكيان');
83
- return resource;
108
+ return { resource, ability };
84
109
  }
85
110
  /** Returns null for a stored view the resource no longer supports; throws on user input. */
86
111
  sanitize(resource, value, strict = false) {
@@ -104,8 +104,8 @@ export class UserInvitations {
104
104
  async accept(token, password, hashPassword) {
105
105
  if (!(await this.valid(token)))
106
106
  throw invalid();
107
- if (typeof password !== 'string' || password.length < 8 || password.length > 32)
108
- throw new KitError(422, 'E_PASSWORD', 'كلمة المرور من 8 إلى 32 حرفًا');
107
+ if (typeof password !== 'string' || password.length < 8 || password.length > 64)
108
+ throw new KitError(422, 'E_PASSWORD', 'كلمة المرور من 8 إلى 64 حرفًا');
109
109
  const passwordHash = await hashPassword(password);
110
110
  return this.db.transaction(async (trx) => {
111
111
  const pending = await trx('user_invitations').where('token_hash', digest(token)).first();
@@ -6,6 +6,8 @@ export declare function recordMutation(trx: Knex.Transaction, mutation: {
6
6
  resource: string;
7
7
  id: unknown;
8
8
  actorId: number;
9
+ /** The administrator acting as actorId, when impersonating. */
10
+ impersonatorId?: number;
9
11
  action: Action;
10
12
  fields: string[];
11
13
  }): Promise<void>;
@@ -17,13 +17,17 @@ export async function recordMutation(trx, mutation) {
17
17
  resource: mutation.resource,
18
18
  id: mutation.id,
19
19
  actorId: mutation.actorId,
20
+ ...(mutation.impersonatorId ? { impersonatorId: mutation.impersonatorId } : {}),
20
21
  };
21
22
  await trx('activities').insert({
22
23
  resource: mutation.resource,
23
24
  record_id: mutation.id,
24
25
  actor_id: mutation.actorId,
25
26
  action: mutation.action,
26
- changes: JSON.stringify({ fields: mutation.fields }),
27
+ changes: JSON.stringify({
28
+ fields: mutation.fields,
29
+ ...(mutation.impersonatorId ? { impersonatedBy: mutation.impersonatorId } : {}),
30
+ }),
27
31
  });
28
32
  await trx('outbox').insert({
29
33
  id: eventId,
@@ -22,7 +22,16 @@ export type Field = {
22
22
  permissionLevel?: number;
23
23
  sequence?: string;
24
24
  } & ({
25
- type: 'string' | 'text' | 'integer' | 'money' | 'boolean' | 'date' | 'datetime' | 'json' | 'attachment';
25
+ type: 'string' | 'text' | 'integer' | 'money' | 'boolean' | 'date' | 'datetime' | 'json';
26
+ } | {
27
+ type: 'attachment';
28
+ /**
29
+ * Lower-case file extensions accepted for upload, or 'any'. Defaults to
30
+ * DEFAULT_ATTACHMENT_EXTENSIONS (documents, images and archives).
31
+ */
32
+ accept?: string[] | 'any';
33
+ /** Upload size limit such as '5mb'. Defaults to '20mb'. */
34
+ maxSize?: string;
26
35
  } | {
27
36
  type: 'belongsTo';
28
37
  resource: string;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@adula/kit",
3
3
  "description": "Resource-driven business applications for AdonisJS 7",
4
- "version": "0.2.0-alpha.2",
4
+ "version": "0.2.0-alpha.4",
5
5
  "engines": {
6
6
  "node": ">=24.0.0"
7
7
  },
@@ -45,7 +45,6 @@
45
45
  "@jrmc/adonis-mcp": "2.0.0",
46
46
  "@poppinss/ts-exec": "1.4.4",
47
47
  "@types/node": "26.2.0",
48
- "@ucast/sql": "0.2.0",
49
48
  "eslint": "10.9.0",
50
49
  "pg": "8.16.3",
51
50
  "typescript": "6.0.3"
@@ -72,15 +71,6 @@
72
71
  "access": "public",
73
72
  "provenance": true
74
73
  },
75
- "c8": {
76
- "reporter": [
77
- "text",
78
- "html"
79
- ],
80
- "exclude": [
81
- "tests/**"
82
- ]
83
- },
84
74
  "prettier": "@adonisjs/prettier-config",
85
75
  "dependencies": {
86
76
  "@casl/ability": "7.0.1",