@appxdigital/appx-core 0.1.121-beta.8 → 0.1.121

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.
@@ -17,6 +17,7 @@ exports.PrismaService = exports.CorePrismaContext = void 0;
17
17
  const common_1 = require("@nestjs/common");
18
18
  const client_1 = require("@prisma/client");
19
19
  const permissionsConfigTypes_1 = require("../common/config/permissionsConfigTypes");
20
+ const permissions_validator_1 = require("./permissions-validator");
20
21
  const nestjs_request_context_1 = require("nestjs-request-context");
21
22
  const node_async_hooks_1 = require("node:async_hooks");
22
23
  exports.CorePrismaContext = new node_async_hooks_1.AsyncLocalStorage();
@@ -26,8 +27,40 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
26
27
  this.fieldConfigs = {};
27
28
  this.prismaClient = prismaClient;
28
29
  this.parseSchema();
30
+ this.validatePermissions();
29
31
  this.proxyModels();
30
32
  }
33
+ /**
34
+ * Validates the permissions config against the schema at boot. Warnings are
35
+ * logged; errors (a create condition that crosses a relation, or a required
36
+ * FK whose target has no `connect` rule) reject boot — the misconfiguration
37
+ * would otherwise surface as a runtime 403.
38
+ */
39
+ validatePermissions() {
40
+ const relations = {};
41
+ for (const model of Object.keys(this.fieldConfigs)) {
42
+ const relationFields = this.fieldConfigs[model].relationFields || {};
43
+ const noWrite = this.fieldConfigs[model].noWriteFields || [];
44
+ relations[model] = Object.keys(relationFields).map((field) => ({
45
+ field,
46
+ model: relationFields[field].model,
47
+ referencingColumn: relationFields[field].referencingColumn,
48
+ isRequired: relationFields[field].isRequired,
49
+ kind: relationFields[field].relation,
50
+ // The scalar FK is client-writable unless the column is @NoWrite / @Role(none).
51
+ fkWritable: !noWrite.includes(relationFields[field].referencingColumn),
52
+ }));
53
+ }
54
+ const issues = (0, permissions_validator_1.validatePermissionsConfig)(this.permissionsConfig, relations);
55
+ for (const issue of issues.filter((i) => i.level === 'warning')) {
56
+ console.warn('\x1b[33m', `[APPX-CORE PERMISSIONS] ${issue.message}`, '\x1b[0m');
57
+ }
58
+ const errors = issues.filter((i) => i.level === 'error');
59
+ if (errors.length) {
60
+ throw new Error(`[APPX-CORE PERMISSIONS] Invalid permissions configuration — ${errors.length} error(s):\n` +
61
+ errors.map((e) => ` • ${e.message}`).join('\n'));
62
+ }
63
+ }
31
64
  debugQueries(enable) {
32
65
  nestjs_request_context_1.RequestContext.currentContext.req.corePrismaDebug = enable;
33
66
  }
@@ -98,9 +131,17 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
98
131
  // Conditions can't be pushed into a WHERE on insert, so
99
132
  // validate the incoming data satisfies them before insert.
100
133
  await this.enforceCreateConditions(String(propKey), userRole, params, user, methodKey.toString());
134
+ // Authorize any relation the write establishes (nested
135
+ // create/connect, or a raw foreign key).
136
+ await this.enforceNestedWrites(String(propKey), params.data, user, userRole);
101
137
  }
102
138
  else {
103
139
  params = this.applyWhereConditions(String(propKey), userRole, params, user, methodKey);
140
+ // An update can re-point a foreign key (scalar column);
141
+ // authorize those changes with the same connect rules as create.
142
+ if (['update', 'updateMany'].includes(methodKey.toString())) {
143
+ await this.enforceNestedWrites(String(propKey), params.data, user, userRole);
144
+ }
104
145
  }
105
146
  }
106
147
  else {
@@ -175,6 +216,7 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
175
216
  const fieldTypes = {};
176
217
  const scalarFields = [];
177
218
  const relationFields = {};
219
+ const noWriteFields = [];
178
220
  for (const field of fields) {
179
221
  let field_name = field.name;
180
222
  const fieldType = field.type;
@@ -184,6 +226,10 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
184
226
  if (roleMatch) {
185
227
  fieldConfig[field_name] = roleMatch[1].split(',').map((role) => role.trim());
186
228
  }
229
+ // Fields excluded from write DTOs — never client-writable.
230
+ if (/@NoWrite\b/.test(commentPart) || /@Role\(\s*none\s*\)/i.test(commentPart)) {
231
+ noWriteFields.push(field_name);
232
+ }
187
233
  }
188
234
  allFields.push(field_name);
189
235
  fieldTypes[field_name] = fieldType;
@@ -195,6 +241,10 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
195
241
  model: field.type,
196
242
  relation: field.isList ? 'hasMany' : 'belongsTo',
197
243
  isRequired: field.isRequired,
244
+ // Disambiguates multiple relations between the same two models
245
+ // (e.g. Task.assignee / Task.reviewer, self-relations) so the
246
+ // inverse (back-reference) side can be paired reliably.
247
+ relationName: field.relationName,
198
248
  };
199
249
  if (!field.isList && field.relationToFields && field.relationFromFields) {
200
250
  relationFields[field_name].foreignKey = field.relationToFields[0];
@@ -208,6 +258,7 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
208
258
  fieldTypes,
209
259
  scalarFields,
210
260
  relationFields,
261
+ noWriteFields,
211
262
  };
212
263
  }
213
264
  }
@@ -450,7 +501,7 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
450
501
  * Fails **closed**: throws on a violation and on any condition shape it
451
502
  * cannot evaluate, rather than silently allowing the insert.
452
503
  */
453
- async enforceCreateConditions(modelName, userRole, params, user, action) {
504
+ async enforceCreateConditions(modelName, userRole, params, user, action, trustedFk) {
454
505
  const normalizedName = modelName.toLowerCase().trim();
455
506
  const exposedModels = (exports.CorePrismaContext.getStore()?.exposedModels || []).map((m) => m.toLowerCase());
456
507
  if (exposedModels.includes(normalizedName))
@@ -473,12 +524,159 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
473
524
  const data = params?.data;
474
525
  const rows = Array.isArray(data) ? data : [data ?? {}];
475
526
  for (const row of rows) {
476
- const ok = await this.matchesCreateConditions(resolved, row || {}, modelName);
527
+ const effectiveRow = this.resolveConnectForeignKeys(modelName, row || {});
528
+ const ok = await this.matchesCreateConditions(resolved, effectiveRow, modelName, trustedFk);
477
529
  if (!ok) {
478
530
  throw new common_1.ForbiddenException(`Not allowed to create ${modelName}: the record does not satisfy the '${action}' permission conditions for role ${userRole}.`);
479
531
  }
480
532
  }
481
533
  }
534
+ /**
535
+ * Authorizes every relation a write establishes. Relationship authorization
536
+ * lives entirely in the `connect` action — create conditions judge a model's
537
+ * own scalar fields only.
538
+ *
539
+ * Runs on create AND update: a `create` can nest relation writes or set raw
540
+ * FKs; an `updateMany` (scalar-only) can re-point a raw FK. Both go through
541
+ * the same `connect` authorization.
542
+ *
543
+ * - A raw scalar foreign key (`courseId: 7`) → the target's `connect` rule.
544
+ * - `relation: { connect }` → the target's `connect` rule. `connect` is a
545
+ * DEDICATED action: being allowed to READ a record does not authorize
546
+ * associating it.
547
+ * - `relation: { create }` → the target's `create` rule (recurses, so deeper
548
+ * nesting is authorized too). Every other nested operator is rejected.
549
+ *
550
+ * The one exception is `trustedFk`: the scalar FK that points back to the row
551
+ * we're nested under. Prisma fills it at write time and the parent already
552
+ * authorized itself, so it is not re-checked.
553
+ */
554
+ async enforceNestedWrites(modelName, data, user, userRole, trustedFk) {
555
+ if (!data || typeof data !== 'object')
556
+ return;
557
+ const rows = Array.isArray(data) ? data : [data];
558
+ for (const row of rows) {
559
+ if (!row || typeof row !== 'object')
560
+ continue;
561
+ for (const key of Object.keys(row)) {
562
+ const relation = this.getRelation(modelName, key);
563
+ if (relation) {
564
+ const ops = row[key];
565
+ if (!ops || typeof ops !== 'object')
566
+ continue; // e.g. an explicit `null` disconnect-by-omission
567
+ await this.enforceNestedRelationOps(relation.model, key, ops, user, userRole, modelName, relation.relationName);
568
+ continue;
569
+ }
570
+ // A plain scalar that happens to be a belongsTo foreign key still
571
+ // establishes an association — authorize it against the target's
572
+ // `connect` rule, exactly as the `connect` form would be.
573
+ const fk = this.foreignKeyColumn(modelName, key);
574
+ if (!fk)
575
+ continue; // ordinary scalar
576
+ if (key === trustedFk)
577
+ continue; // back-reference to the nesting parent — trusted
578
+ let value = row[key];
579
+ if (value && typeof value === 'object' && 'set' in value)
580
+ value = value.set; // update form: { set: x }
581
+ if (value === undefined || value === null)
582
+ continue;
583
+ await this.assertConnectAllowed(modelName, fk.field, fk.model, { [fk.foreignKey]: value }, user, userRole);
584
+ }
585
+ }
586
+ }
587
+ /** Authorizes each operator inside one relation's nested-write object. */
588
+ async enforceNestedRelationOps(relatedModel, field, ops, user, userRole, parentModel, parentRelationName) {
589
+ const ALLOWED = ['create', 'connect'];
590
+ // The child's FK back to the parent it's nested under is filled by Prisma
591
+ // and trusted; skip it when authorizing the child's own relations.
592
+ const backFk = this.backReferenceFk(relatedModel, parentModel, parentRelationName);
593
+ for (const op of Object.keys(ops)) {
594
+ if (!ALLOWED.includes(op)) {
595
+ throw new common_1.ForbiddenException(`Nested '${op}' on relation '${field}' of ${parentModel} is not supported through the data-access proxy. ` +
596
+ `Only ${ALLOWED.join(' / ')} are permitted; perform other nested writes in an explicit endpoint with its own authorization.`);
597
+ }
598
+ const value = ops[op];
599
+ const items = Array.isArray(value) ? value : [value];
600
+ if (op === 'create') {
601
+ for (const child of items) {
602
+ await this.enforceCreateConditions(relatedModel, userRole, { data: child }, user, 'create', backFk);
603
+ await this.enforceNestedWrites(relatedModel, child, user, userRole, backFk);
604
+ }
605
+ }
606
+ else {
607
+ for (const target of items) {
608
+ await this.assertConnectAllowed(parentModel, field, relatedModel, target, user, userRole);
609
+ }
610
+ }
611
+ }
612
+ }
613
+ /** If `column` is the scalar FK backing a belongsTo relation, returns that relation's field + target. */
614
+ foreignKeyColumn(modelName, column) {
615
+ const rels = this.fieldConfigs[modelName.toLowerCase()]?.relationFields || {};
616
+ for (const key of Object.keys(rels)) {
617
+ const r = rels[key];
618
+ if (r.relation === 'belongsTo' && r.referencingColumn === column && r.foreignKey) {
619
+ return { field: key, model: r.model, foreignKey: r.foreignKey };
620
+ }
621
+ }
622
+ return null;
623
+ }
624
+ /** The child's scalar FK column pointing back to the parent it's nested under (trusted). */
625
+ backReferenceFk(childModel, parentModel, parentRelationName) {
626
+ const rels = this.fieldConfigs[childModel.toLowerCase()]?.relationFields || {};
627
+ const candidates = Object.keys(rels)
628
+ .map(key => rels[key])
629
+ .filter((r) => r.relation === 'belongsTo' && r.referencingColumn && r.model.toLowerCase() === parentModel.toLowerCase());
630
+ // Prisma pairs the two sides of a relation by `relationName`; use it to
631
+ // disambiguate when the child has several relations to the same parent.
632
+ if (parentRelationName) {
633
+ const exact = candidates.find((r) => r.relationName === parentRelationName);
634
+ if (exact)
635
+ return exact.referencingColumn;
636
+ }
637
+ return candidates.length === 1 ? candidates[0].referencingColumn : undefined;
638
+ }
639
+ /**
640
+ * Authorizes attaching `relatedModel` (identified by `where`) through the
641
+ * `field` relation on `sourceModel`. Two rules can apply, evaluated against
642
+ * the candidate target row:
643
+ * - a relation-scoped rule on the source (`sourceModel[role].relations[field].connect`)
644
+ * - the target model's own rule (`relatedModel[role].connect`)
645
+ * When both exist they are ANDed (the relation rule can only strengthen).
646
+ * When only one exists it stands alone; when neither exists, default-deny.
647
+ */
648
+ async assertConnectAllowed(sourceModel, field, relatedModel, where, user, userRole) {
649
+ const normalizedName = relatedModel.toLowerCase().trim();
650
+ const exposedModels = (exports.CorePrismaContext.getStore()?.exposedModels || []).map((m) => m.toLowerCase());
651
+ if (exposedModels.includes(normalizedName))
652
+ return;
653
+ const relationRule = this.normalizedPermissionsConfig[sourceModel.toLowerCase().trim()]?.[userRole]?.['relations']?.[field]?.connect;
654
+ const targetRule = this.normalizedPermissionsConfig[normalizedName]?.[userRole]?.['connect'];
655
+ if (relationRule === undefined && targetRule === undefined) {
656
+ throw new common_1.ForbiddenException(`Not allowed to connect ${relatedModel} (via '${field}'): role ${userRole} has no 'connect' permission. ` +
657
+ `Declare it on ${relatedModel}.${userRole}.connect, or scope it to this relation with ${sourceModel}.${userRole}.relations.${field}.connect.`);
658
+ }
659
+ // Collect the conditions each applicable rule imposes; an 'ALL' or
660
+ // condition-less rule constrains nothing. The target must satisfy every
661
+ // collected condition (AND — the relation rule strengthens the target's).
662
+ const conditionSets = [];
663
+ for (const rule of [relationRule, targetRule]) {
664
+ if (rule === undefined || rule === 'ALL')
665
+ continue;
666
+ const conditions = rule.conditions;
667
+ if (conditions) {
668
+ conditionSets.push(PrismaService_1._buildConditions(Array.isArray(conditions) ? { AND: conditions } : conditions, user));
669
+ }
670
+ }
671
+ if (conditionSets.length === 0)
672
+ return; // every applicable rule is unconditional
673
+ const delegate = relatedModel.charAt(0).toLowerCase() + relatedModel.slice(1);
674
+ const client = nestjs_request_context_1.RequestContext.currentContext?.req.prisma || this.rawClient;
675
+ const found = await client[delegate].findFirst({ where: { AND: [where || {}, ...conditionSets] } });
676
+ if (!found) {
677
+ throw new common_1.ForbiddenException(`Not allowed to connect ${relatedModel} (via '${field}'): the target does not satisfy the 'connect' permission conditions for role ${userRole}.`);
678
+ }
679
+ }
482
680
  /**
483
681
  * True only in an explicit local-development environment. Deliberately NOT
484
682
  * `!== 'production'` — staging and other envs should behave like production
@@ -512,14 +710,50 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
512
710
  // Dev diagnostic only — never let it affect the create.
513
711
  }
514
712
  }
515
- /** Recursively evaluates a (placeholder-resolved) condition object against a to-be-created row. */
516
- async matchesCreateConditions(cond, row, modelName) {
713
+ /**
714
+ * A view of a create `row` where a belongsTo relation given via `connect`
715
+ * (`owner: { connect: { id } }`) is also surfaced as its scalar FK (`ownerId`),
716
+ * so scalar-FK create conditions match either payload form. Metadata-driven
717
+ * (`referencingColumn`/`foreignKey`); resolvable only when the connect targets
718
+ * the referenced column. Explicit scalar wins; row is not mutated.
719
+ */
720
+ resolveConnectForeignKeys(modelName, row) {
721
+ if (!row || typeof row !== 'object')
722
+ return row;
723
+ let effective = row;
724
+ for (const key of Object.keys(row)) {
725
+ const relation = this.getRelation(modelName, key);
726
+ if (!relation || relation.relation !== 'belongsTo')
727
+ continue;
728
+ if (!relation.referencingColumn || !relation.foreignKey)
729
+ continue;
730
+ if (row[relation.referencingColumn] !== undefined)
731
+ continue; // explicit scalar wins
732
+ const connectWhere = row[key]?.connect;
733
+ const fk = connectWhere?.[relation.foreignKey];
734
+ if (fk === undefined)
735
+ continue;
736
+ if (effective === row)
737
+ effective = { ...row };
738
+ effective[relation.referencingColumn] = fk;
739
+ }
740
+ return effective;
741
+ }
742
+ /**
743
+ * Evaluates a (placeholder-resolved) create condition against a to-be-created
744
+ * row. Create conditions judge the model's OWN scalar fields only — a clause
745
+ * that reaches into a relation is not a create concern (relationships are
746
+ * authorized by the target's `connect` rule) and is skipped here; the config
747
+ * validator rejects such clauses at boot. `trustedFk` (the back-reference to a
748
+ * same-request nesting parent) is likewise skipped — its value doesn't exist yet.
749
+ */
750
+ async matchesCreateConditions(cond, row, modelName, trustedFk) {
517
751
  for (const key of Object.keys(cond)) {
518
752
  const val = cond[key];
519
753
  if (key === 'AND') {
520
754
  const arr = Array.isArray(val) ? val : [val];
521
755
  for (const c of arr)
522
- if (!(await this.matchesCreateConditions(c, row, modelName)))
756
+ if (!(await this.matchesCreateConditions(c, row, modelName, trustedFk)))
523
757
  return false;
524
758
  continue;
525
759
  }
@@ -527,7 +761,7 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
527
761
  const arr = Array.isArray(val) ? val : [val];
528
762
  let any = false;
529
763
  for (const c of arr)
530
- if (await this.matchesCreateConditions(c, row, modelName)) {
764
+ if (await this.matchesCreateConditions(c, row, modelName, trustedFk)) {
531
765
  any = true;
532
766
  break;
533
767
  }
@@ -538,16 +772,14 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
538
772
  if (key === 'NOT') {
539
773
  const arr = Array.isArray(val) ? val : [val];
540
774
  for (const c of arr)
541
- if (await this.matchesCreateConditions(c, row, modelName))
775
+ if (await this.matchesCreateConditions(c, row, modelName, trustedFk))
542
776
  return false;
543
777
  continue;
544
778
  }
545
- const relation = this.getRelation(modelName, key);
546
- if (relation) {
547
- if (!(await this.matchesCreateRelation(relation, key, val, row, modelName)))
548
- return false;
549
- continue;
550
- }
779
+ if (key === trustedFk)
780
+ continue; // parent link, filled by Prisma — trusted
781
+ if (this.getRelation(modelName, key))
782
+ continue; // relation clause — authorized via `connect`, not here
551
783
  if (!this.matchesCreateScalar(row[key], val, key, modelName))
552
784
  return false;
553
785
  }
@@ -600,26 +832,6 @@ let PrismaService = exports.PrismaService = PrismaService_1 = class PrismaServic
600
832
  }
601
833
  return true;
602
834
  }
603
- /** Verifies the parent referenced by a belongsTo condition exists and satisfies the nested condition. */
604
- async matchesCreateRelation(relation, field, nestedCond, row, modelName) {
605
- if (relation.relation !== 'belongsTo' || !relation.referencingColumn) {
606
- throw new common_1.ForbiddenException(`create on ${modelName}: unsupported relation condition on '${field}' — a new row has no related '${field}' yet to match. Constrain the scalar foreign key instead.`);
607
- }
608
- const fkValue = row[relation.referencingColumn];
609
- if (fkValue === undefined || fkValue === null)
610
- return false;
611
- const delegate = relation.model.charAt(0).toLowerCase() + relation.model.slice(1);
612
- // Use the request's transaction client if present (to see rows created
613
- // earlier in the same request), else the raw client. Either way, access
614
- // filtering on the parent is intentionally NOT re-applied — we check
615
- // only the condition.
616
- const client = nestjs_request_context_1.RequestContext.currentContext?.req.prisma || this.rawClient;
617
- const found = await client[delegate].findFirst({
618
- where: { AND: [{ [relation.foreignKey]: fkValue }, nestedCond] },
619
- select: { [relation.foreignKey]: true },
620
- });
621
- return !!found;
622
- }
623
835
  /**
624
836
  * Builds dynamic conditions based on the type of clause (OR, AND, or field conditions).
625
837
  * Each condition is processed, and placeholders (like `$USER_ID`) are replaced with actual values.
package/docs/README.md ADDED
@@ -0,0 +1,22 @@
1
+ # AppX Core — Documentation
2
+
3
+ Authoritative usage documentation for `@appxdigital/appx-core`. **This folder is the source of truth** for how the framework behaves — humans and AI agents should read it here rather than inferring patterns from source code. If code and these docs disagree, that is a bug in one of them; fix it, don't guess.
4
+
5
+ ## Contents
6
+
7
+ - **[permissions.md](./permissions.md)** — the authorization model: the `@Permission` and `@ExposeModels` decorators, the `permissions.config.ts` shape, roles/actions, row-level `conditions`, field-level control, action fallbacks, and the `GUEST` (unauthenticated) flow.
8
+ - **[dtos.md](./dtos.md)** — the generated CRUD validation DTOs: their two-file structure, **how to remove a field** (`OmitType` / `@NoWrite`), how to add validation, and why nested relation writes don't belong on generic CRUD.
9
+ - **[data-access.md](./data-access.md)** — how you talk to the database: the Prisma proxy (`prisma.model.*`), how ABAC filtering and field omission are enforced on every call, the redirected single-record methods, relation behaviour, bypass hatches, and transactions.
10
+ - **[authentication.md](./authentication.md)** — the built-in `/auth` surface, and **how to override framework internals** (extend the exported `AuthController`/`AuthService`/`RegisterDto`/Passport strategies and register your own at the same prefix via `AuthModule.forRoot({ controller: false })`) — e.g. adding `name` to registration.
11
+ - **[graphql.md](./graphql.md)** — the **read-only** GraphQL API generated from your schema: queries, nested selections, and how ABAC filters them.
12
+ - **[limitations.md](./limitations.md)** — **known limitations and features that are NOT ready for use** (e.g. `aggregate`, GraphQL writes). Read this before relying on any method not covered in the guides above.
13
+
14
+ ## The one-paragraph mental model
15
+
16
+ AppX Core wraps the Prisma client in a proxy that enforces **attribute-based access control (ABAC)** on every database call. You declare row-level and field-level rules once in `permissions.config.ts`; the proxy injects them into each query automatically based on the current request's user role. Controllers/resolvers are generated from your Prisma schema. The rules of the road: **go through the proxy** (`prismaService.model.*`) so ABAC applies, **declare a permission for every model/role/action** you want to allow (everything else is default-denied), and **check [limitations.md](./limitations.md)** before using a method.
17
+
18
+ ## Conventions used in these docs
19
+
20
+ - `$USER_ID` refers to `PermissionPlaceholder.USER_ID` — a placeholder replaced with the current user's id at query time.
21
+ - "the proxy" means the `PrismaService` client returned by `prismaService.model` (and the `prisma.user` / `prisma.session` / `prisma.userRefreshToken` accessors).
22
+ - "default-deny" means: if no rule matches, the request is refused (a `403` at the guard, or a thrown `HttpException` at the proxy) — never silently allowed.
@@ -0,0 +1,220 @@
1
+ # Authentication & overriding framework internals
2
+
3
+ The framework ships a working auth surface. When you need to change part of it —
4
+ add a field to registration, add endpoints, swap a Passport strategy — you don't
5
+ fork it: you **extend the exported classes and register your own versions**. This
6
+ page shows the pattern (auth is the main example, but it applies to any exported
7
+ framework class).
8
+
9
+ ---
10
+
11
+ ## What `AuthModule` provides
12
+
13
+ **Always wire it as `AuthModule.forRoot()`** — importing the bare `AuthModule`
14
+ registers nothing (all wiring lives in `forRoot()` so the controller can be
15
+ conditionally omitted; see the override section). `AuthModule.forRoot()` registers,
16
+ under the `/auth` prefix:
17
+
18
+ | Route | Guard | Body |
19
+ |---|---|---|
20
+ | `POST /auth/register` | — | `RegisterDto` (`email`, `password`) |
21
+ | `POST /auth/login` | `LocalAuthGuard` | credentials |
22
+ | `POST /auth/login/jwt` | `LocalAuthGuard` | credentials → tokens |
23
+ | `POST /auth/refresh` | `RefreshTokenGuard` | refresh token |
24
+ | `POST /auth/logout` · `logout/jwt` | `AuthenticatedGuard` | — |
25
+ | `GET /auth/me`, `GET/POST /auth/sessions…` | `AuthenticatedGuard` | — |
26
+
27
+ plus the providers (all **global**): `AuthService`, the Passport strategies
28
+ (`LocalStrategy`, `JwtStrategy`, `RefreshTokenStrategy`), the guards
29
+ (`LocalAuthGuard`, `JwtAuthGuard`, `AuthenticatedGuard`, `RefreshTokenGuard`,
30
+ `SessionAuthGuard`), and `SessionSerializer`.
31
+
32
+ **Registration is intentionally narrow.** `RegisterDto` accepts only `email` +
33
+ `password`, and `AuthService.register` creates the user with `BYPASS_FILTERING`
34
+ (it's an unauthenticated flow, so ABAC create-enforcement can't gate it). The
35
+ `RegisterDto` whitelist is therefore the *only* thing controlling which fields a
36
+ sign-up may set — which is exactly why widening it is a deliberate override, not
37
+ a config toggle.
38
+
39
+ All of these are exported from the package root — **import from
40
+ `@appxdigital/appx-core`, never from `@appxdigital/appx-core/dist/...`** (deep
41
+ paths are internal and can move between versions):
42
+
43
+ ```ts
44
+ import {
45
+ AuthModule, AuthController, AuthService,
46
+ RegisterDto, UserDto, AuthField,
47
+ LocalStrategy, JwtStrategy, RefreshTokenStrategy,
48
+ } from '@appxdigital/appx-core';
49
+ ```
50
+
51
+ ---
52
+
53
+ ## The override pattern
54
+
55
+ To replace or extend an auth endpoint **at the same `/auth` prefix**:
56
+
57
+ 1. **Extend** the framework class (controller / service / strategy).
58
+ 2. Put your version in **your own module**.
59
+ 3. Import **`AuthModule.forRoot({ controller: false })`** — this keeps every auth
60
+ provider global but drops the built-in `AuthController`, so *your* controller
61
+ owns `/auth` with no duplicate route.
62
+ 4. Register your module in `AppModule` (in place of a bare `AuthModule`).
63
+
64
+ ```ts
65
+ // app.module.ts
66
+ @Module({
67
+ imports: [
68
+ AppxCoreModule.forRoot(PermissionsConfig),
69
+ AuthModule.forRoot({ controller: false }), // providers only, no built-in controller
70
+ MyAuthModule, // your controller/service live here
71
+ // …
72
+ ],
73
+ })
74
+ export class AppModule {}
75
+ ```
76
+
77
+ ```ts
78
+ // my-auth.module.ts
79
+ @Module({
80
+ controllers: [MyAuthController],
81
+ providers: [/* MyAuthService, MyLocalStrategy, … as needed */],
82
+ })
83
+ export class MyAuthModule {}
84
+ ```
85
+
86
+ > **Why `forRoot({ controller: false })`?** If the built-in controller *and* your
87
+ > own `/auth` controller are both registered, you get two handlers for the same
88
+ > routes (Express runs the first-registered, so the base `RegisterDto` would win
89
+ > and reject your extra fields). `forRoot({ controller: false })` drops the built-in
90
+ > controller while keeping every provider global, so your controller takes over
91
+ > cleanly. Use plain `AuthModule.forRoot()` (no options) when you're *not*
92
+ > overriding the controller.
93
+
94
+ ---
95
+
96
+ ## Recipe 1 — add a field to registration (e.g. `name`)
97
+
98
+ Extend `RegisterDto`, then override the inherited `register` handler:
99
+
100
+ ```ts
101
+ // register-local.dto.ts
102
+ import { RegisterDto } from '@appxdigital/appx-core';
103
+ import { IsOptional, IsString } from 'class-validator';
104
+
105
+ export class RegisterLocalDto extends RegisterDto {
106
+ @IsOptional()
107
+ @IsString()
108
+ name?: string;
109
+ }
110
+ ```
111
+
112
+ ```ts
113
+ // my-auth.controller.ts
114
+ import { Body, Controller, Post } from '@nestjs/common';
115
+ import { AuthController } from '@appxdigital/appx-core';
116
+ import { RegisterLocalDto } from './register-local.dto';
117
+
118
+ @Controller('auth')
119
+ export class MyAuthController extends AuthController {
120
+ @Post('register')
121
+ async register(@Body() dto: RegisterLocalDto) {
122
+ return this.authService.register(dto); // authService is `protected` on AuthController
123
+ }
124
+ }
125
+ ```
126
+
127
+ `AuthService.register` spreads the DTO straight into the user create, so `name`
128
+ persists (your `User` model must have the column). Everything else on
129
+ `AuthController` — login, refresh, sessions — is inherited unchanged. The global
130
+ validation pipe now validates the body against `RegisterLocalDto`, so `name` is
131
+ accepted and any *other* unknown field still `400`s.
132
+
133
+ > Keep registration a **narrow allowlist** — only add fields a self-service
134
+ > sign-up should legitimately set. Never widen it to privileged columns (`role`,
135
+ > tenant, ownership ids); mark those `/// @NoWrite` and set them server-side.
136
+
137
+ ---
138
+
139
+ ## Recipe 2 — extend the service (add methods / change behaviour)
140
+
141
+ Extend `AuthService` and forward its constructor. Its signature is
142
+ `(userService, prisma, jwtService, configService)`:
143
+
144
+ ```ts
145
+ import { Injectable } from '@nestjs/common';
146
+ import { AuthService, PrismaService } from '@appxdigital/appx-core';
147
+ import { UserService } from '../user/user.service';
148
+ import { ConfigService } from '@nestjs/config';
149
+ import { JwtService } from '@nestjs/jwt';
150
+
151
+ @Injectable()
152
+ export class MyAuthService extends AuthService {
153
+ constructor(
154
+ userService: UserService,
155
+ prisma: PrismaService,
156
+ jwtService: JwtService,
157
+ configService: ConfigService,
158
+ ) {
159
+ super(userService, prisma, jwtService, configService);
160
+ }
161
+
162
+ async handleForgotPassword(email: string) { /* … */ }
163
+ }
164
+ ```
165
+
166
+ Provide `MyAuthService` in your module and inject it in your controller
167
+ (`super(myAuthService)` if the controller extends `AuthController`). Because you
168
+ imported `AuthModule.forRoot({ controller: false })`, `UserService`, `JwtService`,
169
+ `PrismaService`, and `ConfigService` are all already available to inject.
170
+
171
+ ---
172
+
173
+ ## Recipe 3 — override a Passport strategy
174
+
175
+ The built-in `LocalStrategy` reads the username/password field names from
176
+ `@AuthField(...)` metadata on `UserDto` and delegates to
177
+ `AuthService.validateUser`. To change credential validation, extend it (or
178
+ `JwtStrategy` / `RefreshTokenStrategy`) and provide your version:
179
+
180
+ ```ts
181
+ import { Injectable } from '@nestjs/common';
182
+ import { LocalStrategy } from '@appxdigital/appx-core';
183
+
184
+ @Injectable()
185
+ export class MyLocalStrategy extends LocalStrategy {
186
+ async validate(username: string, password: string) {
187
+ // custom checks, then fall back to the framework behaviour:
188
+ return super.validate(username, password);
189
+ }
190
+ }
191
+ ```
192
+
193
+ ```ts
194
+ @Module({
195
+ controllers: [MyAuthController],
196
+ providers: [MyLocalStrategy], // Passport picks up the last-registered strategy of this name
197
+ })
198
+ export class MyAuthModule {}
199
+ ```
200
+
201
+ > Passport registers strategies by name. Providing your own `LocalStrategy`
202
+ > subclass in your module (with `AuthModule.forRoot({ controller: false })` for
203
+ > the rest) makes yours the active `local` strategy.
204
+
205
+ ---
206
+
207
+ ## The general principle
208
+
209
+ This isn't auth-specific. Every framework class you might need to specialise is
210
+ exported from `@appxdigital/appx-core`. To override inner functionality:
211
+
212
+ 1. **Extend** the exported class.
213
+ 2. **Provide** your subclass in your own module (a subclass provider shadows the
214
+ base where you inject it).
215
+ 3. When the framework module would *also* register the thing you're replacing
216
+ (a controller, a route), import it in a mode that omits that piece — e.g.
217
+ `AuthModule.forRoot({ controller: false })` — so there's no duplicate.
218
+
219
+ If a class you need to extend isn't exported, that's a framework gap — open it
220
+ rather than deep-importing from `dist/`.