@apso/cli 0.46.0 → 0.46.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.
@@ -193,7 +193,7 @@ class Generate extends base_command_1.default {
193
193
  console.log(`[apso] Finished building entity '${entity.name}' in ${entityBuildTime.toFixed(2)} ms`);
194
194
  }
195
195
  // Generate guards
196
- const guardFiles = await generator.generateGuards(entities, auth);
196
+ const guardFiles = await generator.generateGuards(entities, auth, relationshipMap);
197
197
  for (const file of guardFiles) {
198
198
  // eslint-disable-next-line no-await-in-loop
199
199
  await writeGenerated(file.path, file.content);
@@ -402,7 +402,7 @@ class McpServe extends base_command_1.default {
402
402
  }
403
403
  }
404
404
  // Generate guards
405
- const guardFiles = await generator.generateGuards(entities, auth);
405
+ const guardFiles = await generator.generateGuards(entities, auth, relationshipMap);
406
406
  for (const file of guardFiles) {
407
407
  const fullPath = path.join(autogenPath, file.path);
408
408
  // eslint-disable-next-line no-await-in-loop
@@ -1,5 +1,5 @@
1
1
  import * as Eta from "eta";
2
- import { TargetLanguage, GeneratedFile, ValidationResult, GeneratorConfig, EntityGenerationOptions, ControllerGenerationOptions, ServiceGenerationOptions, DtoGenerationOptions, MigrationGenerationOptions, LanguageGenerator, Entity, AuthConfig } from "../types";
2
+ import { TargetLanguage, GeneratedFile, ValidationResult, GeneratorConfig, EntityGenerationOptions, ControllerGenerationOptions, ServiceGenerationOptions, DtoGenerationOptions, MigrationGenerationOptions, LanguageGenerator, Entity, AuthConfig, RelationshipMap } from "../types";
3
3
  /**
4
4
  * Abstract base class for language-specific generators.
5
5
  * Provides common functionality and enforces the LanguageGenerator interface.
@@ -60,7 +60,7 @@ export declare abstract class BaseGenerator implements LanguageGenerator {
60
60
  /**
61
61
  * Generate guard files (auth, scope, etc.)
62
62
  */
63
- abstract generateGuards(entities: Entity[], auth?: AuthConfig): Promise<GeneratedFile[]>;
63
+ abstract generateGuards(entities: Entity[], auth?: AuthConfig, relationshipMap?: RelationshipMap): Promise<GeneratedFile[]>;
64
64
  /**
65
65
  * Generate shared query utility files (filter, sort, pagination).
66
66
  * Default no-op. Override in language generators that need query utils.
@@ -1,5 +1,5 @@
1
1
  import { BaseGenerator } from "./base";
2
- import { TargetLanguage, GeneratedFile, ValidationResult, GeneratorConfig, EntityGenerationOptions, ControllerGenerationOptions, ServiceGenerationOptions, DtoGenerationOptions, MigrationGenerationOptions, Entity, AuthConfig } from "../types";
2
+ import { TargetLanguage, GeneratedFile, ValidationResult, GeneratorConfig, EntityGenerationOptions, ControllerGenerationOptions, ServiceGenerationOptions, DtoGenerationOptions, MigrationGenerationOptions, Entity, AuthConfig, RelationshipMap } from "../types";
3
3
  /**
4
4
  * TypeScript/NestJS generator that wraps the existing generation functions.
5
5
  * This generator produces TypeORM entities, NestJS controllers, services, and DTOs.
@@ -20,7 +20,7 @@ export declare class TypeScriptGenerator extends BaseGenerator {
20
20
  emitEvents?: boolean;
21
21
  auth?: AuthConfig;
22
22
  }): Promise<GeneratedFile[]>;
23
- generateGuards(entities: Entity[], auth?: AuthConfig): Promise<GeneratedFile[]>;
23
+ generateGuards(entities: Entity[], auth?: AuthConfig, relationshipMap?: RelationshipMap): Promise<GeneratedFile[]>;
24
24
  /**
25
25
  * Emits a schema-derived manifest of the entities that have opted in to
26
26
  * domain-event emission (`.apsorc` `emitEvents`).
@@ -128,6 +128,9 @@ class TypeScriptGenerator extends base_1.BaseGenerator {
128
128
  const ctrlName = `${entityName}Controller`;
129
129
  const pluralEntityName = (0, pluralize_1.default)(entityName);
130
130
  const primaryKeyType = entity.primaryKeyType || "serial";
131
+ const scopes = auth
132
+ ? (_b = (_a = (0, guards_1.getScopedEntities)([entity], relationshipMap)[0]) === null || _a === void 0 ? void 0 : _a.scopes) !== null && _b !== void 0 ? _b : []
133
+ : [];
131
134
  const data = {
132
135
  svcName,
133
136
  ctrlName,
@@ -136,12 +139,12 @@ class TypeScriptGenerator extends base_1.BaseGenerator {
136
139
  primaryKeyType,
137
140
  associations: [...new Set(relationshipsForTemplate)],
138
141
  nestedJoinsFile,
139
- // Direct scopeBy fields @CrudAuth adds to every query once a caller is
140
- // authenticated. Nested scopes (task.workspaceId) are checked per row by
141
- // ScopeGuard only.
142
- crudAuth: auth
143
- ? ((_b = (_a = (0, guards_1.getScopedEntities)([entity])[0]) === null || _a === void 0 ? void 0 : _a.scopes) !== null && _b !== void 0 ? _b : []).filter((s) => s.direct)
144
- : [],
142
+ // scopeBy fields @CrudAuth adds to every query once a caller is
143
+ // authenticated. Direct fields match the caller's tenant; nested paths
144
+ // (task.workspaceId) match the foreign key (taskId) against the parent
145
+ // ids ScopeGuard loaded for the caller into req.apsoScope.
146
+ crudAuth: scopes.filter((s) => s.direct),
147
+ crudAuthNested: scopes.filter((s) => s.fkField),
145
148
  };
146
149
  const controllerContent = await this.renderTemplate("./rest/controller-rest", data);
147
150
  const specContent = await this.renderTemplate("./rest/controller-rest-spec", data);
@@ -328,7 +331,7 @@ class TypeScriptGenerator extends base_1.BaseGenerator {
328
331
  },
329
332
  ];
330
333
  }
331
- async generateGuards(entities, auth) {
334
+ async generateGuards(entities, auth, relationshipMap) {
332
335
  const hasScopes = (0, guards_1.hasScopedEntities)(entities);
333
336
  const normalizedAuth = this.normalizeAuthConfig(auth);
334
337
  const jwt = auth && (0, auth_1.isJwtAuth)(auth) ? (0, auth_1.resolveJwtAuth)(auth) : undefined;
@@ -339,7 +342,7 @@ class TypeScriptGenerator extends base_1.BaseGenerator {
339
342
  if (!hasScopes && !hasAuth) {
340
343
  return [];
341
344
  }
342
- const scopedEntities = (0, guards_1.getScopedEntities)(entities);
345
+ const scopedEntities = (0, guards_1.getScopedEntities)(entities, relationshipMap);
343
346
  const files = [];
344
347
  const authEntities = normalizedAuth
345
348
  ? [
@@ -354,6 +357,8 @@ class TypeScriptGenerator extends base_1.BaseGenerator {
354
357
  : [];
355
358
  const templateData = {
356
359
  scopedEntities,
360
+ // Nested paths are only enforced on list and create with a caller's tenant.
361
+ hasNestedFk: hasAuth && scopedEntities.some((e) => e.scopes.some((s) => s.fkField)),
357
362
  authConfig: normalizedAuth,
358
363
  hasAuth,
359
364
  authProvider: auth === null || auth === void 0 ? void 0 : auth.provider,
@@ -1,4 +1,4 @@
1
- import { Entity, ResolvedApiKeyAuth } from "./types";
1
+ import { Entity, RelationshipMap, ResolvedApiKeyAuth } from "./types";
2
2
  /**
3
3
  * Represents a scope field configuration for template rendering
4
4
  */
@@ -7,6 +7,14 @@ export interface ScopeFieldConfig {
7
7
  contextKey: string;
8
8
  direct: boolean;
9
9
  path?: string;
10
+ /**
11
+ * Nested paths only, set when the relationship map is given: the entity's
12
+ * foreign key to the first hop (taskId), the entity it points to (Task),
13
+ * and the rest of the path on that entity (workspaceId).
14
+ */
15
+ fkField?: string;
16
+ parentEntity?: string;
17
+ parentPath?: string;
10
18
  }
11
19
  /**
12
20
  * Represents a scoped entity configuration for template rendering
@@ -21,9 +29,10 @@ export interface ScopedEntityConfig {
21
29
  bypassRoles: string[];
22
30
  }
23
31
  /**
24
- * Extracts scoped entity configurations from all entities.
32
+ * Extracts scoped entity configurations from all entities. Pass the
33
+ * relationship map to resolve nested paths to their foreign keys.
25
34
  */
26
- export declare function getScopedEntities(entities: Entity[]): ScopedEntityConfig[];
35
+ export declare function getScopedEntities(entities: Entity[], relationshipMap?: RelationshipMap): ScopedEntityConfig[];
27
36
  /**
28
37
  * Checks if any entities have scopeBy configured.
29
38
  */
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.nestedScopeEntities = exports.entityScope = exports.apiKeyTemplateData = exports.hasScopedEntities = exports.getScopedEntities = void 0;
4
4
  const tslib_1 = require("tslib");
5
5
  const pluralize_1 = tslib_1.__importDefault(require("pluralize"));
6
+ const parse_1 = require("./utils/relationships/parse");
6
7
  /**
7
8
  * Default scope options when not specified
8
9
  */
@@ -36,18 +37,40 @@ function toRepoName(entityName) {
36
37
  return `${camelCase}Repository`;
37
38
  }
38
39
  /**
39
- * Extracts scoped entity configurations from all entities.
40
+ * A nested path's first segment must be a ManyToOne relationship of the
41
+ * entity: its foreign key is what list queries filter on.
40
42
  */
41
- function getScopedEntities(entities) {
43
+ function resolveNestedScope(entityName, scope, relationshipMap) {
44
+ var _a;
45
+ if (scope.direct || !scope.path)
46
+ return scope;
47
+ const relation = ((_a = relationshipMap[entityName]) !== null && _a !== void 0 ? _a : []).find((r) => r.type === "ManyToOne" && (0, parse_1.getRelationshipName)(r) === scope.field);
48
+ if (!relation)
49
+ throw new Error(`${entityName}.scopeBy "${scope.path}": "${scope.field}" must be a many-to-one relationship of ${entityName}`);
50
+ return {
51
+ ...scope,
52
+ fkField: (0, parse_1.getRelationshipIdField)(relation),
53
+ parentEntity: relation.name,
54
+ parentPath: scope.path.split(".").slice(1).join("."),
55
+ };
56
+ }
57
+ /**
58
+ * Extracts scoped entity configurations from all entities. Pass the
59
+ * relationship map to resolve nested paths to their foreign keys.
60
+ */
61
+ function getScopedEntities(entities, relationshipMap) {
42
62
  return entities
43
63
  .filter((entity) => entity.scopeBy)
44
64
  .map((entity) => {
45
65
  const options = { ...DEFAULT_SCOPE_OPTIONS, ...entity.scopeOptions };
66
+ const scopes = parseScopeBy(entity.scopeBy);
46
67
  return {
47
68
  name: entity.name,
48
69
  routeName: toRouteName(entity.name),
49
70
  repoName: toRepoName(entity.name),
50
- scopes: parseScopeBy(entity.scopeBy),
71
+ scopes: relationshipMap
72
+ ? scopes.map((s) => resolveNestedScope(entity.name, s, relationshipMap))
73
+ : scopes,
51
74
  injectOnCreate: options.injectOnCreate,
52
75
  enforceOn: options.enforceOn,
53
76
  bypassRoles: options.bypassRoles,
@@ -8,7 +8,7 @@ import {
8
8
  } from '@nestjs/common';
9
9
  import { Reflector } from '@nestjs/core';
10
10
  import { InjectRepository } from '@nestjs/typeorm';
11
- import { Repository } from 'typeorm';
11
+ import { <% if (it.hasNestedFk) { %>DataSource, <% } %>Repository } from 'typeorm';
12
12
 
13
13
  <% /* Import all entities that have scoped relationships (from sibling autogen folders) */ %>
14
14
  <% it.scopedEntities.forEach((entity) => { %>
@@ -73,6 +73,12 @@ export interface ScopeField {
73
73
  direct: boolean;
74
74
  /** For nested paths, the full path (e.g., 'task.workspaceId') */
75
75
  path?: string;
76
+ /** Nested paths: the foreign key to the first hop (e.g., 'taskId') */
77
+ fkField?: string;
78
+ /** Nested paths: the entity the foreign key points to (e.g., 'Task') */
79
+ parentEntity?: string;
80
+ /** Nested paths: the rest of the path on that entity (e.g., 'workspaceId') */
81
+ parentPath?: string;
76
82
  }
77
83
 
78
84
  /**
@@ -90,6 +96,11 @@ export const ENTITY_SCOPES: Record<string, ScopeConfig> = {
90
96
  direct: <%= scope.direct %>,
91
97
  <% if (scope.path) { %>
92
98
  path: '<%= scope.path %>',
99
+ <% } %>
100
+ <% if (scope.fkField) { %>
101
+ fkField: '<%= scope.fkField %>',
102
+ parentEntity: '<%= scope.parentEntity %>',
103
+ parentPath: '<%= scope.parentPath %>',
93
104
  <% } %>
94
105
  }<%= scopeIndex < entity.scopes.length - 1 ? ',' : '' %>
95
106
  <% }) %>
@@ -109,6 +120,9 @@ export class ScopeGuard implements CanActivate {
109
120
  @InjectRepository(<%= entity.name %>)
110
121
  private <%= entity.repoName %>: Repository<<%= entity.name %>>,
111
122
  <% }) %>
123
+ <% if (it.hasNestedFk) { %>
124
+ private dataSource: DataSource,
125
+ <% } %>
112
126
  ) {}
113
127
 
114
128
  async canActivate(context: ExecutionContext): Promise<boolean> {
@@ -166,6 +180,12 @@ export class ScopeGuard implements CanActivate {
166
180
  if (!scopeConfig.enforceOn.includes(operation)) {
167
181
  return true;
168
182
  }
183
+ <% if (it.hasNestedFk) { %>
184
+
185
+ // Nested paths (task.workspaceId): load the caller's parent ids for the
186
+ // controller's list filter, and refuse a parent from another tenant.
187
+ await this.loadNestedScopes(request, scopeConfig, operation);
188
+ <% } %>
169
189
 
170
190
  // For GET requests with an ID, verify the resource belongs to this scope
171
191
  if ((operation === 'get' || operation === 'update' || operation === 'delete') && request.params?.id) {
@@ -338,6 +358,50 @@ export class ScopeGuard implements CanActivate {
338
358
  );
339
359
  }
340
360
 
361
+ <% if (it.hasNestedFk) { %>
362
+ /**
363
+ * For each nested scope, find the ids of the parent rows in the caller's
364
+ * tenant and put them on request.apsoScope, where the controller's
365
+ * @CrudAuth filter reads them. A create or update that names a parent
366
+ * outside that set is refused.
367
+ */
368
+ private async loadNestedScopes(
369
+ request: any,
370
+ scopeConfig: ScopeConfig,
371
+ operation: string,
372
+ ): Promise<void> {
373
+ for (const scope of scopeConfig.scopes) {
374
+ if (!scope.fkField || !scope.parentEntity || !scope.parentPath) continue;
375
+ const scopeValue = this.getScopeValue(request, scope.contextKey);
376
+ if (!scopeValue) {
377
+ throw new ForbiddenException('No tenant in the auth context');
378
+ }
379
+ // ponytail: loads every parent id in the tenant per request; switch to a
380
+ // subquery filter if a tenant grows past tens of thousands of parents.
381
+ const repo = this.dataSource.getRepository(scope.parentEntity);
382
+ const pk = repo.metadata.primaryColumns[0].propertyName;
383
+ const where = scope.parentPath
384
+ .split('.')
385
+ .reverse()
386
+ .reduce<any>((inner, key) => ({ [key]: inner }), scopeValue);
387
+ const parents = await repo.find({ select: { [pk]: true } as any, where });
388
+ const ids = parents.map((p: any) => p[pk]);
389
+ request.apsoScope = { ...request.apsoScope, [scope.fkField]: ids };
390
+
391
+ if (operation === 'create' || operation === 'update') {
392
+ const body = request.body && typeof request.body === 'object' ? request.body : {};
393
+ const items = Array.isArray(body.bulk) ? body.bulk : [body];
394
+ for (const item of items) {
395
+ const fk = item?.[scope.fkField] ?? item?.[scope.field]?.[pk];
396
+ if (fk != null && !ids.some((id: any) => String(id) === String(fk))) {
397
+ throw new ForbiddenException('You do not have access to this resource');
398
+ }
399
+ }
400
+ }
401
+ }
402
+ }
403
+
404
+ <% } %>
341
405
  private getNestedValue(obj: any, path: string): any {
342
406
  return path.split('.').reduce((current, key) => current?.[key], obj);
343
407
  }
@@ -1,11 +1,20 @@
1
1
  <%~ includeFile('./header.eta') %>
2
2
 
3
3
  import { Controller, Body } from "@nestjs/common";
4
- import { Crud, <% if (it.crudAuth.length) { %>CrudAuth, <% } %>CrudController, Override, ParsedRequest, CrudRequest, ParsedBody, CreateManyDto } from '@apso/crud';
4
+ import { Crud, <% if (it.crudAuth.length || it.crudAuthNested.length) { %>CrudAuth, <% } %>CrudController, Override, ParsedRequest, CrudRequest, ParsedBody, CreateManyDto } from '@apso/crud';
5
5
  import { ApiTags, ApiResponse, ApiOperation, ApiBody } from '@nestjs/swagger';
6
6
  import {<%= it.entityName %>, <%= it.entityName %>Create, <%= it.entityName %>Update} from './dtos/<%= it.entityName%>.dto'
7
7
  import {<%= it.svcName %>} from './<%= it.entityName %>.service';
8
8
 
9
+ <% if (it.crudAuthNested.length) { %>
10
+ // A nested scopeBy path (task.workspaceId) limits rows to the parents ScopeGuard
11
+ // found in the caller's tenant (req.apsoScope). An empty list matches no rows.
12
+ const nestedScope = (req: any, fk: string) => {
13
+ const ids = req.apsoScope?.[fk];
14
+ if (!ids) return {};
15
+ return { [fk]: ids.length ? { $in: ids } : { $isnull: true, $notnull: true } };
16
+ };
17
+ <% } %>
9
18
  @Crud({
10
19
  model: {
11
20
  type: <%= it.entityName %>
@@ -51,12 +60,15 @@ import {<%= it.svcName %>} from './<%= it.entityName %>.service';
51
60
  <% } %>
52
61
  }
53
62
  })
54
- <% if (it.crudAuth.length) { %>
63
+ <% if (it.crudAuth.length || it.crudAuthNested.length) { %>
55
64
  // Every query and write is limited to the caller's tenant (.apsorc scopeBy).
56
65
  @CrudAuth({
57
66
  filter: (req: any) => ({
58
67
  <% it.crudAuth.forEach((s) => { %>
59
68
  <%= s.field %>: req.<%= s.contextKey %> ?? req.user?.<%= s.contextKey %> ?? req.auth?.<%= s.contextKey %> ?? req.auth?.workspaceId,
69
+ <% }) %>
70
+ <% it.crudAuthNested.forEach((s) => { %>
71
+ ...nestedScope(req, '<%= s.fkField %>'),
60
72
  <% }) %>
61
73
  }),
62
74
  persist: (req: any) => ({
@@ -262,7 +262,7 @@ export interface LanguageGenerator {
262
262
  /**
263
263
  * Generate guard files (auth, scope, etc.)
264
264
  */
265
- generateGuards(entities: Entity[], auth?: AuthConfig): Promise<GeneratedFile[]>;
265
+ generateGuards(entities: Entity[], auth?: AuthConfig, relationshipMap?: RelationshipMap): Promise<GeneratedFile[]>;
266
266
  /**
267
267
  * Generate shared query utility files (filter, sort, pagination)
268
268
  */
@@ -48,6 +48,7 @@ export declare const parseRelationship: (relationship: ApsorcRelationship, allRe
48
48
  * @returns A comprehensive RelationshipMap mapping entity names to their Relationship objects.
49
49
  */
50
50
  export declare const parseRelationships: (relationships: ApsorcRelationship[]) => RelationshipMap;
51
+ export declare const getRelationshipName: (relationship: Relationship) => string;
51
52
  export declare const getRelationshipIdField: (relationship: Relationship) => string;
52
53
  /**
53
54
  * Transforms the parsed Relationship data into the final format needed by the entity/controller templates.
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.getRelationshipsForImport = exports.streamNestedRelationships = exports.getNestedRelationships = exports.generateNestedRelationships = exports.getRelationshipForTemplate = exports.getRelationshipIdField = exports.parseRelationships = exports.parseRelationship = exports.parseManyToMany = exports.parseManytoOne = exports.parseOneToMany = void 0;
3
+ exports.getRelationshipsForImport = exports.streamNestedRelationships = exports.getNestedRelationships = exports.generateNestedRelationships = exports.getRelationshipForTemplate = exports.getRelationshipIdField = exports.getRelationshipName = exports.parseRelationships = exports.parseRelationship = exports.parseManyToMany = exports.parseManytoOne = exports.parseOneToMany = void 0;
4
4
  const tslib_1 = require("tslib");
5
5
  const pluralize_1 = tslib_1.__importDefault(require("pluralize"));
6
6
  const casing_1 = require("../casing");
@@ -413,8 +413,9 @@ const getRelationshipName = (relationship) => {
413
413
  }
414
414
  return (0, casing_1.camelCase)(relationship.name);
415
415
  };
416
+ exports.getRelationshipName = getRelationshipName;
416
417
  const getRelationshipIdField = (relationship) => {
417
- return `${getRelationshipName(relationship)}Id`;
418
+ return `${(0, exports.getRelationshipName)(relationship)}Id`;
418
419
  };
419
420
  exports.getRelationshipIdField = getRelationshipIdField;
420
421
  /**
@@ -572,7 +573,7 @@ function* generateNestedRelationships(entityName, relationshipMap, maxDepth = 4,
572
573
  if (!relationships)
573
574
  return;
574
575
  for (const relationship of relationships) {
575
- const referenceName = getRelationshipName(relationship);
576
+ const referenceName = (0, exports.getRelationshipName)(relationship);
576
577
  const type = relationship.type;
577
578
  // Format the property name based on relationship type
578
579
  // OneToMany and ManyToMany relationships use plural forms
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@apso/cli",
3
- "version": "0.46.0",
3
+ "version": "0.46.1",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@apso/cli",
9
- "version": "0.46.0",
9
+ "version": "0.46.1",
10
10
  "license": "Apache-2.0",
11
11
  "dependencies": {
12
12
  "@apso/schema-tools": "^0.2.0",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.46.0",
2
+ "version": "0.46.1",
3
3
  "commands": {
4
4
  "config": {
5
5
  "id": "config",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apso/cli",
3
- "version": "0.46.0",
3
+ "version": "0.46.1",
4
4
  "mcpName": "io.github.apsoai/apso",
5
5
  "description": "Apso CLI",
6
6
  "author": "Apso by Mavric - @mavric",