bazis 0.136.0 → 0.138.0
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 +7 -4
- package/cli/generateProject.ts +11 -18
- package/cli/main.ts +9 -1
- package/cli/parseCli.ts +9 -0
- package/cli/templates/module.ts +16 -19
- package/cli/templates/passport.ts +2 -2
- package/cli/upgrade/markColumns.ts +151 -0
- package/cli/upgrade/runUpgrade.ts +57 -0
- package/core/access/Access.module.ts +1 -1
- package/core/access/catalog/moduleFields.ts +41 -4
- package/core/access/catalog/permissions.ts +10 -0
- package/core/access/check/Access.ts +5 -5
- package/core/access/check/can.ts +2 -1
- package/core/access/index.ts +11 -1
- package/core/access/roles/roles.ts +4 -2
- package/core/access/testing/index.ts +26 -4
- package/core/agent/ToolRunner.ts +7 -1
- package/core/agent/defineTool.ts +109 -0
- package/core/agent/index.ts +8 -3
- package/core/agent/moduleContributions-v1.ts +3 -10
- package/core/agent/session/entities.ts +45 -45
- package/core/app/index.ts +1 -1
- package/core/app/messages.ts +3 -3
- package/core/app/runApp.ts +90 -26
- package/core/cache/ICache.ts +1 -1
- package/core/cache/README.md +4 -2
- package/core/cache/SPEC.md +16 -9
- package/core/cache/http/missingCacheModuleCheck.ts +5 -4
- package/core/cache/internal/buildCacheProviders.ts +4 -5
- package/core/cache/providers/cachedProviders.ts +1 -1
- package/core/di/index.ts +0 -4
- package/core/di/lowLevel.ts +12 -0
- package/core/di/module/index.ts +0 -3
- package/core/di/module/shortcuts.ts +31 -86
- package/core/generatedRuntime.ts +11 -0
- package/core/grpc/Grpc.module.ts +2 -1
- package/core/grpc/grpcClients.ts +2 -1
- package/core/http/Authorization/metadata.ts +1 -1
- package/core/http/Decorators/metadata.ts +3 -0
- package/core/http/Decorators/routes.ts +12 -0
- package/core/http/Errors/HttpError.ts +16 -2
- package/core/http/Http.config.ts +22 -0
- package/core/http/HttpContext/HttpContext.ts +1 -1
- package/core/http/HttpServer.ts +29 -9
- package/core/http/Middleware/errorHandler.ts +16 -1
- package/core/http/OpenApi/openApiDocument.ts +44 -5
- package/core/http/Results/normalize.ts +38 -1
- package/core/http/Results/results.ts +5 -2
- package/core/http/Routing/RouterBuilder.ts +27 -2
- package/core/http/httpModule.ts +10 -8
- package/core/http/index.ts +4 -0
- package/core/http/middlewareTokens.ts +12 -0
- package/core/http/options.ts +5 -1
- package/core/http-client/httpClients.ts +2 -1
- package/core/infra/Infra.ts +3 -3
- package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
- package/core/infra/connectors/codex/contracts.ts +3 -3
- package/core/infra/connectors/llm.ts +2 -1
- package/core/infra/connectors/postgres.ts +4 -2
- package/core/infra/connectors/redis.ts +2 -1
- package/core/infra/index.ts +1 -1
- package/core/kernel/KernelBuilder.ts +2 -1
- package/core/kernel/config/ConfigRegistry.ts +2 -1
- package/core/kernel/config/addConfigOptions.ts +2 -1
- package/core/kernel/config/defineConfig.ts +56 -20
- package/core/kernel/events/EventBus.ts +1 -0
- package/core/kernel/events/eventSubscribers.ts +1 -1
- package/core/kernel/events/onEvent.ts +2 -1
- package/core/kernel/index.ts +3 -0
- package/core/orm/buildOrmProviders.ts +4 -2
- package/core/orm/contextEntities.ts +36 -0
- package/core/orm/databaseConnector.ts +9 -3
- package/core/orm/databaseHttpError.ts +21 -0
- package/core/orm/encryptedWith.ts +1 -1
- package/core/orm/index.ts +12 -1
- package/core/orm/ormModule.ts +19 -8
- package/core/orm/ownedStoreContributions.ts +2 -1
- package/core/orm/registerRepositories.ts +2 -1
- package/core/scripts/di-generate.ts +113 -12
- package/core/testing/index.ts +51 -11
- package/core/tools/StandardTools.module.ts +1 -1
- package/core/tools/contracts/Tools.config.ts +13 -7
- package/core/tools/generated/deps.ts +3 -3
- package/core/tools/http/HttpRequest.tool.ts +1 -2
- package/core/tools/http/publicAddress.ts +2 -2
- package/core/tools/index.ts +1 -1
- package/core/tools/orm/OrmEntity.references.ts +7 -8
- package/core/tools/orm/ormEntityTools.ts +54 -28
- package/core/websocket/DESIGN.md +5 -3
- package/core/websocket/middleware.ts +1 -1
- package/core/websocket/websocketModule.ts +3 -3
- package/core/workflow/contracts/Workflow.config.ts +5 -5
- package/core/workflow/definitions_module/WorkflowDefinitions.module.ts +3 -4
- package/core/workflow/definitions_module/code/defineProcess.ts +213 -11
- package/core/workflow/definitions_module/code/inspectProcesses.ts +12 -2
- package/core/workflow/definitions_module/http/Processes.controller.ts +2 -2
- package/core/workflow/definitions_module/internal/PublishedVersions.ts +39 -0
- package/core/workflow/definitions_module/model/WfProcess.model.ts +24 -11
- package/core/workflow/definitions_module/model/WfVersion.model.ts +7 -1
- package/core/workflow/definitions_module/services/IWorkflowDefinitions.service.ts +4 -1
- package/core/workflow/definitions_module/services/WorkflowDefinitions.service.ts +17 -7
- package/core/workflow/generated/deps.ts +2 -1
- package/core/workflow/index.ts +23 -1
- package/core/workflow/internal/WorkflowLanguages.ts +1 -1
- package/core/workflow/internal/http.ts +2 -2
- package/core/workflow/runtime_module/background/WorkflowWorker.ts +6 -2
- package/core/workflow/runtime_module/http/Instances.controller.ts +1 -1
- package/core/workflow/runtime_module/internal/WorkflowJobs.ts +43 -1
- package/core/workflow/runtime_module/model/WfHistory.model.ts +13 -6
- package/core/workflow/runtime_module/model/WfInstance.model.ts +30 -10
- package/core/workflow/runtime_module/model/WfJob.model.ts +22 -10
- package/core/workflow/runtime_module/model/WfSubscription.model.ts +8 -3
- package/core/workflow/runtime_module/model/WfToken.model.ts +16 -7
- package/core/workflow/runtime_module/services/IWorkflowRuntime.service.ts +10 -6
- package/core/workflow/runtime_module/services/WorkflowRuntime.service.ts +5 -2
- package/core/workflow/tasks_module/model/WfTask.model.ts +25 -9
- package/core/workflow/testing/index.ts +46 -4
- package/index.ts +4 -3
- package/library/openapi/codegen.ts +2 -2
- package/library/orm/DbContext.ts +37 -1
- package/library/orm/Metadata/ModelBuilder.ts +23 -7
- package/library/orm/Metadata/decorators.ts +23 -10
- package/library/orm/Metadata/types.ts +2 -0
- package/library/orm/Transactions/TransactionScopeCoordinator.ts +3 -3
- package/library/orm/errors.ts +39 -1
- package/library/orm/index.ts +1 -0
- package/library/terminal/messages.ts +6 -6
- package/library/validation/README.md +1 -1
- package/library/validation/SPEC.md +1 -1
- package/library/workflow/index.ts +1 -0
- package/library/workflow/nodeConfigs.ts +130 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -142,7 +142,8 @@ import { dbConfig } from "../config/db.config"; // defineConfig("db", { default:
|
|
|
142
142
|
export class AppInfra {}
|
|
143
143
|
|
|
144
144
|
// src/index.ts
|
|
145
|
-
|
|
145
|
+
@App({ modules: [AppModule], infra: AppInfra, http: { health: true } })
|
|
146
|
+
export class Application {}
|
|
146
147
|
```
|
|
147
148
|
|
|
148
149
|
A feature module attaches its own `DbContext` and entities with
|
|
@@ -180,11 +181,13 @@ import { CodexError, RU_CODEX_MESSAGES } from "bazis/core/infra";
|
|
|
180
181
|
import { RU_UI_LABELS } from "bazis/core/app";
|
|
181
182
|
|
|
182
183
|
CodexError.useMessages(RU_CODEX_MESSAGES); // Codex connector errors
|
|
183
|
-
|
|
184
|
+
@App({
|
|
185
|
+
modules: [AppModule],
|
|
184
186
|
language: "ru", // response texts: validation, 404/405, 401/403, binding errors
|
|
185
187
|
messages: { notFound: "Нет такой страницы" }, // optional: own texts for single keys
|
|
186
|
-
ui: { labels: RU_UI_LABELS, surfaces: [/* ... */] }, // generated UI texts
|
|
187
|
-
})
|
|
188
|
+
ui: { labels: RU_UI_LABELS, surfaces: [/* ... */] }, // generated UI texts,
|
|
189
|
+
})
|
|
190
|
+
export class Application {}
|
|
188
191
|
```
|
|
189
192
|
|
|
190
193
|
`language` is per application: it is applied when the response is written, so
|
package/cli/generateProject.ts
CHANGED
|
@@ -169,39 +169,32 @@ function buildProjectFiles(name: string, dependency: string, frameworkMode: Fram
|
|
|
169
169
|
["bazis.config.json", `${JSON.stringify(codegen, null, 2)}\n`],
|
|
170
170
|
// Bun reads .env, .env.local and .env.<NODE_ENV>: none of them belongs in git.
|
|
171
171
|
[".gitignore", "node_modules/\nbin/\nsrc/generated/\n.env\n.env.*\n!.env.example\n"],
|
|
172
|
-
[".env.example", "# Copy to .env (Bun loads it automatically).\nBAZIS_ENV=development\
|
|
172
|
+
[".env.example", "# Copy to .env (Bun loads it automatically).\nBAZIS_ENV=development\nBAZIS_HTTP__HOST=127.0.0.1\nBAZIS_HTTP__PORT=3000\n"],
|
|
173
173
|
["src/app/test/health.test.ts", HEALTH_TEST],
|
|
174
174
|
["AGENTS.md", "# Working on this bazis project\n\nBefore changing the application, read the [module architecture](docs/architecture/MODULE_ARCHITECTURE.md). Create new modules only with `bunx bazis g module` or `bunx bazis g pack`; fill in the generated `MODULE.md` afterwards. Only codegen updates `src/generated/`.\n"],
|
|
175
|
-
["docs/architecture/MODULE_ARCHITECTURE.md", "# Application module architecture\n\n`src/index.ts` calls `runApp`; `src/app/modules/App.module.ts` composes feature modules through `imports`. The application root owns no domain logic.\n\nOne self-contained function is an atomic module. It owns its data, services, HTTP and background handlers. A composite module is only for several independent functions; its root does composition. Layers and file counts alone do not create submodules.\n\nCreate new modules only with `bunx bazis g module <Name> --empty|--minimal|--full` or `bunx bazis g pack <Name> --parts <a,b>`; add a part to an existing composite with `bunx bazis g module <Name> --pack <Pack>`. The generated `MODULE.md` records the creation command. Before implementing, define the responsibility and public entries, then fill in the generated `MODULE.md`: fields, errors, dependencies, exports and checks. Use the public APIs of the `bazis` package and its DI and ORM. Do not edit `src/generated/` by hand; run `bunx bazis codegen`.\n\n`--minimal` creates a sample CRUD with the ORM. To run it the application needs a database provider and a ready schema. For a first function without a database use `--empty`. Check types and the binary build after changes that affect startup.\n"],
|
|
175
|
+
["docs/architecture/MODULE_ARCHITECTURE.md", "# Application module architecture\n\n`src/index.ts` calls `runApp(Application)`; `src/app/App.ts` declares the application with `@App`: its modules, infrastructure and servers; `src/app/modules/App.module.ts` composes feature modules through `imports`. The application root owns no domain logic.\n\nOne self-contained function is an atomic module. It owns its data, services, HTTP and background handlers. A composite module is only for several independent functions; its root does composition. Layers and file counts alone do not create submodules.\n\nCreate new modules only with `bunx bazis g module <Name> --empty|--minimal|--full` or `bunx bazis g pack <Name> --parts <a,b>`; add a part to an existing composite with `bunx bazis g module <Name> --pack <Pack>`. The generated `MODULE.md` records the creation command. Before implementing, define the responsibility and public entries, then fill in the generated `MODULE.md`: fields, errors, dependencies, exports and checks. Use the public APIs of the `bazis` package and its DI and ORM. Do not edit `src/generated/` by hand; run `bunx bazis codegen`.\n\n`--minimal` creates a sample CRUD with the ORM. To run it the application needs a database provider and a ready schema. For a first function without a database use `--empty`. Check types and the binary build after changes that affect startup.\n"],
|
|
176
176
|
["src/app/modules/App.module.ts", 'import { Module } from "bazis/core/di";\n\n@Module({ imports: [], exports: [] })\nexport class AppModule {}\n'],
|
|
177
|
-
["src/
|
|
177
|
+
["src/app/App.ts", 'import { App } from "bazis/core/app";\nimport { AppModule } from "./modules/App.module";\n\n/** The application: its modules and the servers it starts. `src/index.ts` runs it, tests start the same one. */\n@App({\n modules: [AppModule],\n http: { health: true },\n})\nexport class Application {}\n'],
|
|
178
|
+
["src/index.ts", 'import { runApp } from "bazis/core/app";\nimport { Application } from "./app/App";\nimport { registerBazisGeneratedRuntime } from "./generated/bazis/runtime";\n\nawait registerBazisGeneratedRuntime();\nawait runApp(Application);\n'],
|
|
178
179
|
["README.md", `# ${name}\n\nA [bazis](https://www.npmjs.com/package/bazis) application. ${frameworkMode === "registry"
|
|
179
180
|
? "The framework comes from npm as the `bazis` dependency; update it with `bun update bazis`."
|
|
180
181
|
: frameworkMode === "snapshot"
|
|
181
182
|
? "The framework package is copied to `vendor/bazis`; keep it in Git and move it with the project. The framework checkout is no longer needed. Framework updates do not reach this copy automatically."
|
|
182
|
-
: "The `bazis` dependency links an external local checkout (`--link-framework`). Moving the project needs the same package and an updated path in `package.json`."}\nRead the [local architecture](docs/architecture/MODULE_ARCHITECTURE.md) before changing modules.\n\nNeeds Bun ≥ 1.4.0. Settings go to \`.env\` (example: \`.env.example\`).\n\n\`\`\`sh\nbun install\nbunx bazis dev\n# GET http://127.0.0.1:3000/health\n# If the port is busy:
|
|
183
|
+
: "The `bazis` dependency links an external local checkout (`--link-framework`). Moving the project needs the same package and an updated path in `package.json`."}\nRead the [local architecture](docs/architecture/MODULE_ARCHITECTURE.md) before changing modules.\n\nNeeds Bun ≥ 1.4.0. Settings go to \`.env\` (example: \`.env.example\`).\n\n\`\`\`sh\nbun install\nbunx bazis dev\n# GET http://127.0.0.1:3000/health\n# If the port is busy: BAZIS_HTTP__PORT=3100 bunx bazis dev\n\`\`\`\n\nAdd an atomic module from the project root: \`bunx bazis g module Task --empty\`.\nAfter filling in its passport and implementation, run \`bunx bazis codegen\`.\nTests: \`bunx bazis test\`. Typecheck: \`bunx bazis build\`. Binary: \`bunx bazis build --bin\`, run it with \`bun run start\`.\n\n\`--minimal\` creates a sample CRUD with paging (20 records by default, at most 100 over HTTP); it needs a database provider and a schema to run. The service returns \`PageResult\`; the HTTP controller builds JSON:API. \`DbContext.saveChanges()\` saves every change of its context. In ORM predicates combine conditions with \`.and()\` and \`.or()\`; codegen rejects \`&&\` and \`||\` between predicates.\n`],
|
|
183
184
|
];
|
|
184
185
|
}
|
|
185
186
|
|
|
186
187
|
const HEALTH_TEST = `import { expect, test } from "bun:test";
|
|
188
|
+
import { startTestApp } from "bazis/core/testing";
|
|
189
|
+
import { Application } from "../App";
|
|
187
190
|
|
|
188
|
-
// Starts the
|
|
191
|
+
// Starts the application declared in src/app/App.ts on a free port and checks /health.
|
|
189
192
|
test("app starts and answers /health", async () => {
|
|
190
|
-
|
|
191
|
-
const probe = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response() });
|
|
192
|
-
const port = String(probe.port);
|
|
193
|
-
probe.stop(true);
|
|
194
|
-
const app = Bun.spawn([process.execPath, "run", "src/index.ts"], { env: { ...process.env, HOST: "127.0.0.1", PORT: port }, stdout: "ignore", stderr: "inherit" });
|
|
193
|
+
const app = await startTestApp(Application);
|
|
195
194
|
try {
|
|
196
|
-
|
|
197
|
-
for (let attempt = 0; attempt < 100 && status !== 200; attempt++) {
|
|
198
|
-
await Bun.sleep(100);
|
|
199
|
-
status = await fetch(\`http://127.0.0.1:\${port}/health\`).then((response) => response.status, () => 0);
|
|
200
|
-
}
|
|
201
|
-
expect(status).toBe(200);
|
|
195
|
+
expect((await app.fetch("/health")).status).toBe(200);
|
|
202
196
|
} finally {
|
|
203
|
-
app.
|
|
204
|
-
await app.exited;
|
|
197
|
+
await app.stop();
|
|
205
198
|
}
|
|
206
199
|
});
|
|
207
200
|
`;
|
package/cli/main.ts
CHANGED
|
@@ -18,6 +18,8 @@ export const USAGE = `Usage:
|
|
|
18
18
|
for a part of a composite module)
|
|
19
19
|
bazis workflow check [--app-module <path>] Check the processes of the modules against their Tools
|
|
20
20
|
bazis workflow diagram <key> Print a process as a Mermaid flowchart
|
|
21
|
+
bazis upgrade columns [--dry-run] For 0.137.0: mark every column @Required() or @Nullable() by what
|
|
22
|
+
it was, so the schema stays as it is (--path <dir>, default src)
|
|
21
23
|
bazis codegen [--target <name|all>] Generate DI wiring into src/generated/bazis
|
|
22
24
|
bazis dev [--watch] Codegen, then run the app from source with
|
|
23
25
|
BAZIS_ENV=development (unless set);
|
|
@@ -46,7 +48,7 @@ Generation options:
|
|
|
46
48
|
--minimal Compact example CRUD and MODULE.md (default)
|
|
47
49
|
--full Example CRUD/list/cache/auth/background/AI and MODULE.md;
|
|
48
50
|
uses src/app/modules/auth helpers when the project has them.
|
|
49
|
-
To run, the host needs a cache (
|
|
51
|
+
To run, the host needs a cache (@App({ cache: memory() }))
|
|
50
52
|
and a database provider. Legacy alias: --enterprise
|
|
51
53
|
--parts <a,b,...> Independent empty atomic parts; pack only, at least two
|
|
52
54
|
--dry-run Validate and list planned changes without writing
|
|
@@ -143,6 +145,12 @@ export async function runCli(argv: readonly string[], runtime: CliRuntime = defa
|
|
|
143
145
|
if (parsed.kind === "test") return await runTest(cwd, parsed.args, codegen, ui);
|
|
144
146
|
if (parsed.kind === "build") return await runBuild(cwd, { bin: parsed.bin, outfile: parsed.outfile, dotenv: parsed.dotenv, platform: parsed.platform }, codegen, ui);
|
|
145
147
|
if (parsed.kind === "new") return await runNew(parsed, ui);
|
|
148
|
+
if (parsed.kind === "upgrade") {
|
|
149
|
+
// The codemod parses sources with the TypeScript API: load it for this project first.
|
|
150
|
+
useTypeScriptCompilerApi(cwd);
|
|
151
|
+
const { runUpgradeColumns } = await import("./upgrade/runUpgrade");
|
|
152
|
+
return await runUpgradeColumns(cwd, parsed.path, parsed.dryRun, runtime.log);
|
|
153
|
+
}
|
|
146
154
|
if (parsed.kind === "workflow") {
|
|
147
155
|
const { runWorkflowCheck, runWorkflowDiagram } = await import("./workflow");
|
|
148
156
|
return parsed.action === "check" ? await runWorkflowCheck(cwd, parsed.appModule, ui) : await runWorkflowDiagram(cwd, parsed.key, parsed.appModule, runtime.log);
|
package/cli/parseCli.ts
CHANGED
|
@@ -37,6 +37,7 @@ export type ParseCliResult =
|
|
|
37
37
|
| { readonly kind: "workflow"; readonly action: "check"; readonly appModule?: string }
|
|
38
38
|
| { readonly kind: "workflow"; readonly action: "diagram"; readonly key: string; readonly appModule?: string }
|
|
39
39
|
| { readonly kind: "codegen"; readonly target?: string }
|
|
40
|
+
| { readonly kind: "upgrade"; readonly action: "columns"; readonly path?: string; readonly dryRun: boolean }
|
|
40
41
|
| { readonly kind: "dev"; readonly watch: boolean }
|
|
41
42
|
| { readonly kind: "test"; readonly args: readonly string[] }
|
|
42
43
|
| { readonly kind: "build"; readonly bin: boolean; readonly outfile?: string; readonly dotenv: boolean; readonly platform?: string }
|
|
@@ -127,6 +128,14 @@ export function parseCliArgs(argv: readonly string[]): ParseCliResult {
|
|
|
127
128
|
}
|
|
128
129
|
return { kind: "codegen", target };
|
|
129
130
|
}
|
|
131
|
+
if (command === "upgrade") {
|
|
132
|
+
for (const option of options.keys()) {
|
|
133
|
+
if (option !== "--path" && option !== "--dry-run") return error(`Option ${option} is not supported by upgrade.`);
|
|
134
|
+
}
|
|
135
|
+
if (generatorToken !== "columns" || positional.length !== 2) return error("Use: bazis upgrade columns [--path <directory>] [--dry-run]");
|
|
136
|
+
const directory = options.get("--path");
|
|
137
|
+
return { kind: "upgrade", action: "columns", ...(directory === undefined ? {} : { path: directory }), dryRun: options.has("--dry-run") };
|
|
138
|
+
}
|
|
130
139
|
if (command === "workflow") {
|
|
131
140
|
for (const option of options.keys()) {
|
|
132
141
|
if (option !== "--app-module") return error(`Option ${option} is not supported by workflow.`);
|
package/cli/templates/module.ts
CHANGED
|
@@ -68,7 +68,6 @@ function moduleFile(n: ModuleNaming, profile: ModuleTemplateProfile): string {
|
|
|
68
68
|
|
|
69
69
|
return `${moduleImport}
|
|
70
70
|
${aiImports}${backgroundImport}import { ${n.module}Controller } from "./http/${n.module}.controller";
|
|
71
|
-
import { ${n.entity} } from "./model/${n.module}.model";
|
|
72
71
|
import { ${n.module}DbContext } from "./model/${n.module}.dbContext";
|
|
73
72
|
import { I${n.module}Service } from "./services/I${n.module}.service";
|
|
74
73
|
import { ${n.module}Service } from "./services/${n.module}.service";
|
|
@@ -76,7 +75,6 @@ import { ${n.module}Service } from "./services/${n.module}.service";
|
|
|
76
75
|
@Module({
|
|
77
76
|
ormBazis: {
|
|
78
77
|
context: ${n.module}DbContext,
|
|
79
|
-
entities: [${n.entity}],
|
|
80
78
|
},
|
|
81
79
|
controllers: [${n.module}Controller],
|
|
82
80
|
providers: [
|
|
@@ -91,15 +89,15 @@ export class ${n.moduleClass} {}
|
|
|
91
89
|
function controllerFile(n: ModuleNaming, profile: ModuleTemplateProfile, authImportPath: string | null): string {
|
|
92
90
|
const auth = profile === "full" && authImportPath !== null;
|
|
93
91
|
const fullImports = profile !== "full"
|
|
94
|
-
? "import { Controller, Created, Delete, Get, HttpContext, NoContent, NotFound,
|
|
92
|
+
? "import { Controller, Created, Delete, Get, HttpContext, NoContent, NotFound, Post, Put, type FromBody, type FromQuery, type FromRoute } from \"bazis/core/http\";\n"
|
|
95
93
|
: auth
|
|
96
94
|
? `import { OutputCache } from "bazis/core/cache";
|
|
97
|
-
import { Authorize, Controller, Created, Delete, Get, HttpContext, NoContent, NotFound,
|
|
95
|
+
import { Authorize, Controller, Created, Delete, Get, HttpContext, NoContent, NotFound, Post, Put, type FromBody, type FromQuery, type FromRoute } from "bazis/core/http";
|
|
98
96
|
import { TokenKind } from "${authImportPath}/tokenKinds";
|
|
99
97
|
import { requireTokenKind } from "${authImportPath}/jwtAuth";
|
|
100
98
|
`
|
|
101
99
|
: `import { OutputCache } from "bazis/core/cache";
|
|
102
|
-
import { Controller, Created, Delete, Get, HttpContext, NoContent, NotFound,
|
|
100
|
+
import { Controller, Created, Delete, Get, HttpContext, NoContent, NotFound, Post, Put, type FromBody, type FromQuery, type FromRoute } from "bazis/core/http";
|
|
103
101
|
// No host auth helpers (src/app/modules/auth) were found: these routes are public.
|
|
104
102
|
// Add @Authorize(...) from "bazis/core/http" with your policy before exposing them.
|
|
105
103
|
`;
|
|
@@ -125,28 +123,25 @@ ${authList} @Get()
|
|
|
125
123
|
return buildListDocument(items, query, total, { basePath: ctx.path });
|
|
126
124
|
}
|
|
127
125
|
|
|
128
|
-
${authRead} @Get(":id(uuid)")
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
return item ? Ok(item) : NotFound({ error: \`${n.entity.toLowerCase()} \${id} not found\` });
|
|
126
|
+
${authRead} @Get(":id(uuid)", { nullAs: NotFound })
|
|
127
|
+
getById(id: FromRoute<string>) {
|
|
128
|
+
return this.${n.collection}.getById(id);
|
|
132
129
|
}
|
|
133
130
|
|
|
134
131
|
${authWrite} @Post()
|
|
135
|
-
async create(body: FromBody<Create${n.entity}Request
|
|
136
|
-
const
|
|
137
|
-
return Created(
|
|
132
|
+
async create(body: FromBody<Create${n.entity}Request>) {
|
|
133
|
+
const result = await this.${n.collection}.create(body);
|
|
134
|
+
return Created(result);
|
|
138
135
|
}
|
|
139
136
|
|
|
140
|
-
${authWrite} @Put(":id(uuid)")
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
return item ? Ok(item) : NotFound({ error: \`${n.entity.toLowerCase()} \${id} not found\` });
|
|
137
|
+
${authWrite} @Put(":id(uuid)", { nullAs: NotFound })
|
|
138
|
+
update(id: FromRoute<string>, body: FromBody<Update${n.entity}Request>) {
|
|
139
|
+
return this.${n.collection}.update(id, body);
|
|
144
140
|
}
|
|
145
141
|
|
|
146
142
|
${authWrite} @Delete(":id(uuid)")
|
|
147
143
|
async delete(id: FromRoute<string>) {
|
|
148
|
-
|
|
149
|
-
return removed ? NoContent() : NotFound({ error: \`${n.entity.toLowerCase()} \${id} not found\` });
|
|
144
|
+
return await this.${n.collection}.delete(id) ? NoContent() : NotFound();
|
|
150
145
|
}
|
|
151
146
|
}
|
|
152
147
|
`;
|
|
@@ -155,17 +150,19 @@ ${authWrite} @Delete(":id(uuid)")
|
|
|
155
150
|
function ormModelFile(n: ModuleNaming, profile: ModuleTemplateProfile): string {
|
|
156
151
|
const schemaImport = profile === "full" ? ", Schema" : "";
|
|
157
152
|
const schemaDecorator = profile === "full" ? `@Schema("${n.dbSchema}")\n` : "";
|
|
158
|
-
return `import { Column, Entity, Index, UUID${schemaImport} } from "bazis/core/orm";
|
|
153
|
+
return `import { Column, Entity, Index, Required, UUID${schemaImport} } from "bazis/core/orm";
|
|
159
154
|
|
|
160
155
|
${schemaDecorator}@Entity({ table: "${n.route}" })
|
|
161
156
|
export class ${n.entity} {
|
|
162
157
|
@UUID()
|
|
163
158
|
id = "";
|
|
164
159
|
|
|
160
|
+
@Required()
|
|
165
161
|
@Column({ type: "text" })
|
|
166
162
|
name = "";
|
|
167
163
|
|
|
168
164
|
@Index({ unique: true })
|
|
165
|
+
@Required()
|
|
169
166
|
@Column({ type: "text" })
|
|
170
167
|
email = "";
|
|
171
168
|
|
|
@@ -76,14 +76,14 @@ exports: [I${m}Service]. TypeScript contract: services/I${e}.service.ts;
|
|
|
76
76
|
HTTP access is defined by the controller and the host policy, separately from DI exports.
|
|
77
77
|
I${m}Service → ${m}Service, scoped lifetime. The context and DbSet belong to the ORM.
|
|
78
78
|
${profile === "full" ? `${m}SummaryTool is listed in tools: [${m}SummaryTool]; the framework registers it as scoped, the tool is not exported.
|
|
79
|
-
The service is scoped; its keyed @Cacheable works when the host installs the cache (
|
|
79
|
+
The service is scoped; its keyed @Cacheable works when the host installs the cache (@App cache: memory()).
|
|
80
80
|
The background service gets its own scope per tick and disposes it in finally.
|
|
81
81
|
The host also provides auth from src/app/modules/auth (TokenKind.Admin/Client)
|
|
82
82
|
and an LLM connector, which registers AgentRuntime. The CLI does not create them.
|
|
83
83
|
` : ""}Constructor dependencies, including ${m}DbContext, are wired by codegen.
|
|
84
84
|
No manual deps arrays. UI, events and own configuration are not used.
|
|
85
85
|
|
|
86
|
-
ORM: context ${m}DbContext
|
|
86
|
+
ORM: context ${m}DbContext with the set of ${e}, shared host provider.
|
|
87
87
|
Table ${n.route}${profile === "full" ? `, schema ${n.dbSchema}` : ""}.
|
|
88
88
|
Only the module decides how tables are created: \`ensureCreated: true\` or
|
|
89
89
|
\`migrateOnStart: true\` in \`ormBazis\`. The scaffold sets neither, so the host
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
|
|
3
|
+
/** What {@link markColumns} did to one file. */
|
|
4
|
+
export interface MarkColumnsResult {
|
|
5
|
+
readonly text: string;
|
|
6
|
+
/** Columns that got `@Nullable()`. */
|
|
7
|
+
readonly nullable: number;
|
|
8
|
+
/** Columns that got `@Required()`. */
|
|
9
|
+
readonly required: number;
|
|
10
|
+
/** What needs a look: options not written in place, a mark that could not be imported. */
|
|
11
|
+
readonly skipped: readonly string[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Decorators of a field that make it a column. */
|
|
15
|
+
const COLUMN_DECORATORS = new Set(["Column", "Index", "ForeignKey", "HasConversion"]);
|
|
16
|
+
/** Decorators that make a column NOT NULL whatever else is said. */
|
|
17
|
+
const ALWAYS_REQUIRED = new Set(["Key", "UUID", "CreatedAt", "UpdatedAt"]);
|
|
18
|
+
/** A `uuid` column is NOT NULL only as a key; as a plain column it was nullable. */
|
|
19
|
+
const REQUIRED_TYPES = new Set(["createdAt", "updatedAt"]);
|
|
20
|
+
/** Names an ORM import is recognised by. */
|
|
21
|
+
const ORM_NAMES = new Set(["Column", "Entity", "Index", "Key", "UUID", "ForeignKey"]);
|
|
22
|
+
|
|
23
|
+
interface Edit { readonly start: number; readonly end: number; readonly text: string }
|
|
24
|
+
|
|
25
|
+
function decoratorCall(decorator: ts.Decorator): { readonly name: string; readonly call: ts.CallExpression } | undefined {
|
|
26
|
+
const expression = decorator.expression;
|
|
27
|
+
if (!ts.isCallExpression(expression) || !ts.isIdentifier(expression.expression)) return undefined;
|
|
28
|
+
return { name: expression.expression.text, call: expression };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function propertyName(node: ts.ObjectLiteralElementLike): string | undefined {
|
|
32
|
+
const name = node.name;
|
|
33
|
+
return name && (ts.isIdentifier(name) || ts.isStringLiteralLike(name)) ? name.text : undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Brings the entities of a file to 0.137.0, where every column says whether it
|
|
38
|
+
* may be empty: `@Required()` or `@Nullable()`. A column gets the mark of what it
|
|
39
|
+
* was, so the schema of the application stays as it is: `@Nullable()` for one
|
|
40
|
+
* that allowed NULL by silence or by `nullable: true`, `@Required()` for
|
|
41
|
+
* `nullable: false`. The `nullable` option, which no longer exists, is removed.
|
|
42
|
+
* Keys and timestamps are left alone: they were always NOT NULL. Works on the
|
|
43
|
+
* text of one file and changes nothing else in it.
|
|
44
|
+
*/
|
|
45
|
+
export function markColumns(source: string, fileName = "file.ts"): MarkColumnsResult {
|
|
46
|
+
const file = ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
|
|
47
|
+
const edits: Edit[] = [];
|
|
48
|
+
const skipped: string[] = [];
|
|
49
|
+
const marks = { Nullable: 0, Required: 0 };
|
|
50
|
+
|
|
51
|
+
/** `, nullable: true` out of the options; an object left empty goes away whole. */
|
|
52
|
+
const dropOption = (options: ts.ObjectLiteralExpression, index: number): void => {
|
|
53
|
+
const property = options.properties[index]!;
|
|
54
|
+
if (options.properties.length === 1) {
|
|
55
|
+
edits.push({ start: options.getStart(file), end: options.end, text: "" });
|
|
56
|
+
} else if (index > 0) {
|
|
57
|
+
edits.push({ start: options.properties[index - 1]!.end, end: property.end, text: "" });
|
|
58
|
+
} else {
|
|
59
|
+
edits.push({ start: property.getStart(file), end: options.properties[1]!.getStart(file), text: "" });
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/** The mark before a decorator: on its own line when the decorator has one. */
|
|
64
|
+
const addMark = (mark: "Nullable" | "Required", before: ts.Decorator): void => {
|
|
65
|
+
const start = before.getStart(file);
|
|
66
|
+
const lineStart = source.lastIndexOf("\n", start - 1) + 1;
|
|
67
|
+
const indent = source.slice(lineStart, start);
|
|
68
|
+
const ownLine = /^[ \t]*$/.test(indent) && /^[ \t]*\r?\n/.test(source.slice(before.end));
|
|
69
|
+
edits.push({ start, end: start, text: ownLine ? `@${mark}()\n${indent}` : `@${mark}() ` });
|
|
70
|
+
marks[mark] += 1;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const visitClass = (node: ts.ClassLikeDeclaration): void => {
|
|
74
|
+
// `@Key(["shopId", "code"])` on the class: these properties are the key.
|
|
75
|
+
const keyed = new Set<string>();
|
|
76
|
+
for (const decorator of ts.getDecorators(node) ?? []) {
|
|
77
|
+
const found = decoratorCall(decorator);
|
|
78
|
+
const list = found?.name === "Key" ? found.call.arguments[0] : undefined;
|
|
79
|
+
if (list && ts.isArrayLiteralExpression(list)) for (const item of list.elements) if (ts.isStringLiteralLike(item)) keyed.add(item.text);
|
|
80
|
+
}
|
|
81
|
+
for (const member of node.members) {
|
|
82
|
+
if (!ts.isPropertyDeclaration(member)) continue;
|
|
83
|
+
const decorators = (ts.getDecorators(member) ?? []).map((decorator) => ({ decorator, found: decoratorCall(decorator) }));
|
|
84
|
+
const has = (name: string) => decorators.some(({ found }) => found?.name === name);
|
|
85
|
+
const anchor = decorators.find(({ found }) => found?.name === "Column") ?? decorators.find(({ found }) => found !== undefined && COLUMN_DECORATORS.has(found.name));
|
|
86
|
+
if (!anchor?.found) continue;
|
|
87
|
+
const name = member.name.getText(file);
|
|
88
|
+
const where = `${fileName}: ${node.name?.text ?? "class"}.${name}`;
|
|
89
|
+
|
|
90
|
+
const column = decorators.find(({ found }) => found?.name === "Column")?.found?.call;
|
|
91
|
+
const options = column?.arguments[0];
|
|
92
|
+
let declared: boolean | undefined;
|
|
93
|
+
let type: string | undefined;
|
|
94
|
+
if (options !== undefined) {
|
|
95
|
+
if (!ts.isObjectLiteralExpression(options) || options.properties.some(ts.isSpreadAssignment)) {
|
|
96
|
+
skipped.push(`${where}: the options of @Column are not written in place; mark the column @Required() or @Nullable() by hand`);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const typeProperty = options.properties.find((property) => propertyName(property) === "type");
|
|
100
|
+
if (typeProperty && ts.isPropertyAssignment(typeProperty) && ts.isStringLiteralLike(typeProperty.initializer)) type = typeProperty.initializer.text;
|
|
101
|
+
const index = options.properties.findIndex((property) => propertyName(property) === "nullable");
|
|
102
|
+
if (index >= 0) {
|
|
103
|
+
const property = options.properties[index]!;
|
|
104
|
+
const value = ts.isPropertyAssignment(property) ? property.initializer.kind : undefined;
|
|
105
|
+
if (value !== ts.SyntaxKind.TrueKeyword && value !== ts.SyntaxKind.FalseKeyword) {
|
|
106
|
+
skipped.push(`${where}: nullable is not true or false here; mark the column @Required() or @Nullable() by hand`);
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
declared = value === ts.SyntaxKind.TrueKeyword;
|
|
110
|
+
dropOption(options, index);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// A soft-delete column is nullable by its own decorator; keys and timestamps are always NOT NULL.
|
|
115
|
+
if (has("SoftDelete") || keyed.has(name) || decorators.some(({ found }) => found !== undefined && ALWAYS_REQUIRED.has(found.name)) || (type !== undefined && REQUIRED_TYPES.has(type))) continue;
|
|
116
|
+
if (has("Required") || has("Nullable")) continue;
|
|
117
|
+
addMark(declared === false ? "Required" : "Nullable", anchor.decorator);
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const visit = (node: ts.Node): void => {
|
|
122
|
+
if (ts.isClassLike(node)) visitClass(node);
|
|
123
|
+
ts.forEachChild(node, visit);
|
|
124
|
+
};
|
|
125
|
+
visit(file);
|
|
126
|
+
|
|
127
|
+
const wanted = (["Nullable", "Required"] as const).filter((mark) => marks[mark] > 0);
|
|
128
|
+
if (wanted.length > 0) {
|
|
129
|
+
const problem = importEdits(file, wanted, edits);
|
|
130
|
+
if (problem !== undefined) skipped.push(`${fileName}: ${problem}`);
|
|
131
|
+
}
|
|
132
|
+
let text = source;
|
|
133
|
+
for (const edit of edits.sort((left, right) => right.start - left.start || right.end - left.end)) text = text.slice(0, edit.start) + edit.text + text.slice(edit.end);
|
|
134
|
+
return { text, nullable: marks.Nullable, required: marks.Required, skipped };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The marks join the import the other ORM decorators of the file come from. */
|
|
138
|
+
function importEdits(file: ts.SourceFile, marks: readonly string[], edits: Edit[]): string | undefined {
|
|
139
|
+
for (const statement of file.statements) {
|
|
140
|
+
if (!ts.isImportDeclaration(statement) || statement.importClause?.isTypeOnly) continue;
|
|
141
|
+
const bindings = statement.importClause?.namedBindings;
|
|
142
|
+
if (!bindings || !ts.isNamedImports(bindings)) continue;
|
|
143
|
+
const names = new Set(bindings.elements.map((element) => (element.propertyName ?? element.name).text));
|
|
144
|
+
if (![...names].some((name) => ORM_NAMES.has(name))) continue;
|
|
145
|
+
const missing = marks.filter((mark) => !names.has(mark));
|
|
146
|
+
const last = bindings.elements.at(-1);
|
|
147
|
+
if (missing.length > 0 && last) edits.push({ start: last.end, end: last.end, text: `, ${missing.join(", ")}` });
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
150
|
+
return `import ${marks.join(" and ")} from the ORM by hand: the file has no import of its decorators to add them to`;
|
|
151
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { readdir } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { markColumns } from "./markColumns";
|
|
4
|
+
|
|
5
|
+
const SKIPPED_DIRECTORIES = new Set(["node_modules", "generated", "dist", "bin", ".git"]);
|
|
6
|
+
|
|
7
|
+
async function sourceFiles(directory: string): Promise<string[]> {
|
|
8
|
+
const out: string[] = [];
|
|
9
|
+
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
10
|
+
const full = path.join(directory, entry.name);
|
|
11
|
+
if (entry.isDirectory()) {
|
|
12
|
+
if (!SKIPPED_DIRECTORIES.has(entry.name)) out.push(...await sourceFiles(full));
|
|
13
|
+
} else if (/\.tsx?$/.test(entry.name) && !entry.name.endsWith(".d.ts")) {
|
|
14
|
+
out.push(full);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return out.sort();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* `bazis upgrade columns`: from 0.137.0 every column says whether it may be
|
|
22
|
+
* empty, `@Required()` or `@Nullable()`. This gives each column the mark of what
|
|
23
|
+
* it was, so the schema of the application stays as it is, and removes the
|
|
24
|
+
* `nullable` option. With `--dry-run` it only tells what it would change.
|
|
25
|
+
*/
|
|
26
|
+
export async function runUpgradeColumns(cwd: string, directory: string | undefined, dryRun: boolean, log: (text: string) => void): Promise<number> {
|
|
27
|
+
const root = path.resolve(cwd, directory ?? "src");
|
|
28
|
+
let files: string[];
|
|
29
|
+
try {
|
|
30
|
+
files = await sourceFiles(root);
|
|
31
|
+
} catch {
|
|
32
|
+
log(`No directory ${path.relative(cwd, root) || "."}. Pass the sources with --path <directory>.`);
|
|
33
|
+
return 1;
|
|
34
|
+
}
|
|
35
|
+
let nullable = 0;
|
|
36
|
+
let required = 0;
|
|
37
|
+
let changed = 0;
|
|
38
|
+
const skipped: string[] = [];
|
|
39
|
+
for (const file of files) {
|
|
40
|
+
const source = await Bun.file(file).text();
|
|
41
|
+
if (!/@(Column|Index|ForeignKey|HasConversion)\(/.test(source)) continue;
|
|
42
|
+
const name = path.relative(cwd, file);
|
|
43
|
+
const result = markColumns(source, name);
|
|
44
|
+
skipped.push(...result.skipped);
|
|
45
|
+
if (result.text === source) continue;
|
|
46
|
+
changed += 1;
|
|
47
|
+
nullable += result.nullable;
|
|
48
|
+
required += result.required;
|
|
49
|
+
log(`${dryRun ? "would change" : "changed"} ${name}: ${[result.nullable > 0 ? `${result.nullable} @Nullable()` : "", result.required > 0 ? `${result.required} @Required()` : "", result.nullable + result.required === 0 ? "the nullable option removed" : ""].filter(Boolean).join(", ")}`);
|
|
50
|
+
if (!dryRun) await Bun.write(file, result.text);
|
|
51
|
+
}
|
|
52
|
+
for (const line of skipped) log(`look at ${line}`);
|
|
53
|
+
log(changed === 0
|
|
54
|
+
? "Nothing to change: every column already says whether it may be empty."
|
|
55
|
+
: `${dryRun ? "Would change" : "Changed"} ${changed} ${changed === 1 ? "file" : "files"}: ${nullable} columns marked @Nullable(), ${required} marked @Required(). The schema stays as it was. Review the diff: where a column must not be empty, write @Required() instead and migrate the data.`);
|
|
56
|
+
return 0;
|
|
57
|
+
}
|
|
@@ -9,7 +9,7 @@ import { AccessPolicy } from "./contracts/AccessPolicy";
|
|
|
9
9
|
import { AccessCatalogController } from "./http/AccessCatalog.controller";
|
|
10
10
|
import { RolesAccessPolicy } from "./roles/RolesAccessPolicy";
|
|
11
11
|
|
|
12
|
-
const accessPermissions = definePermissions({
|
|
12
|
+
export const accessPermissions = definePermissions({
|
|
13
13
|
"access.catalog.read": {
|
|
14
14
|
title: { en: "See the permissions catalog", ru: "Видеть каталог прав" },
|
|
15
15
|
},
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
createModuleOwnedMetadataChannel,
|
|
3
|
+
createModuleOwnedProviderChannel,
|
|
4
|
+
scoped,
|
|
3
5
|
registerModuleOwnedContributionValidator,
|
|
4
6
|
registerModuleOwnedProviderContributor,
|
|
5
7
|
DiContainer,
|
|
@@ -22,6 +24,10 @@ interface RolePayload { readonly name: string; readonly permissions: "all" | rea
|
|
|
22
24
|
const PERMISSION_CHANNEL = createModuleOwnedMetadataChannel<PermissionPayload>("access.permission/v1");
|
|
23
25
|
const REFERENCE_CHANNEL = createModuleOwnedMetadataChannel<ReferencePayload>("access.reference/v1");
|
|
24
26
|
const ROLE_CHANNEL = createModuleOwnedMetadataChannel<RolePayload>("access.role/v1");
|
|
27
|
+
/** The classes of `references`: the field registers them, like `tools` and `controllers`. */
|
|
28
|
+
const REFERENCE_CLASS_CHANNEL = createModuleOwnedProviderChannel<{ readonly kind: string }, object>("access.reference-class/v1");
|
|
29
|
+
/** The `lookup` classes of `permissions`: the field registers them too. */
|
|
30
|
+
const LOOKUP_CLASS_CHANNEL = createModuleOwnedProviderChannel<{ readonly permission: string }, object>("access.lookup-class/v1");
|
|
25
31
|
|
|
26
32
|
/** The values of `in`/`notIn` a role may list; the run-time limit is `access.maxInList`. */
|
|
27
33
|
const STARTUP_MAX_IN_LIST = 1000;
|
|
@@ -42,7 +48,20 @@ registerModuleOwnedProviderContributor((metadata: BazisModuleMetadata, context)
|
|
|
42
48
|
if (!isDeclaredPermissions(fields.permissions)) throw new AccessSetupError(`${module}: permissions must be a definePermissions(...) result.`);
|
|
43
49
|
const problems = Object.entries(fields.permissions).flatMap(([key, spec]) => permissionProblems(key, spec));
|
|
44
50
|
if (problems.length > 0) throw setupError(module, "permissions", problems);
|
|
45
|
-
|
|
51
|
+
const lookups = new Set<unknown>();
|
|
52
|
+
for (const [key, spec] of Object.entries(fields.permissions)) {
|
|
53
|
+
context.addMetadata(PERMISSION_CHANNEL, { key, spec });
|
|
54
|
+
for (const lookup of lookupsOf(spec)) {
|
|
55
|
+
// A lookup another module owns and exports is taken from it.
|
|
56
|
+
if (lookups.has(lookup) || importsExport(metadata, lookup)) continue;
|
|
57
|
+
lookups.add(lookup);
|
|
58
|
+
try {
|
|
59
|
+
context.registerScoped(LOOKUP_CLASS_CHANNEL, scoped(lookup as Class<object>), { permission: key });
|
|
60
|
+
} catch {
|
|
61
|
+
// The module registered the class its own way: that registration stands.
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
46
65
|
}
|
|
47
66
|
if (fields.references !== undefined) {
|
|
48
67
|
const references = fields.references;
|
|
@@ -53,7 +72,17 @@ registerModuleOwnedProviderContributor((metadata: BazisModuleMetadata, context)
|
|
|
53
72
|
if (typeof type !== "function") problems.push(`reference kind "${kind}" must name a References class`);
|
|
54
73
|
}
|
|
55
74
|
if (problems.length > 0) throw setupError(module, "references", problems);
|
|
56
|
-
|
|
75
|
+
const registered = new Set<unknown>();
|
|
76
|
+
for (const [kind, type] of Object.entries(references)) {
|
|
77
|
+
context.addMetadata(REFERENCE_CHANNEL, { kind, type: type as Class<References> });
|
|
78
|
+
if (registered.has(type)) continue;
|
|
79
|
+
registered.add(type);
|
|
80
|
+
try {
|
|
81
|
+
context.registerScoped(REFERENCE_CLASS_CHANNEL, scoped(type as Class<object>), { kind });
|
|
82
|
+
} catch {
|
|
83
|
+
// The module registered the class its own way (a singleton): that registration stands.
|
|
84
|
+
}
|
|
85
|
+
}
|
|
57
86
|
}
|
|
58
87
|
if (fields.roles !== undefined) {
|
|
59
88
|
if (!isDeclaredRoles(fields.roles)) throw new AccessSetupError(`${module}: roles must be a defineRoles(...) result.`);
|
|
@@ -93,7 +122,7 @@ registerModuleOwnedContributionValidator((snapshot) => {
|
|
|
93
122
|
const other = kinds.get(payload.kind);
|
|
94
123
|
if (other !== undefined) throw new AccessSetupError(`Reference kind "${payload.kind}" is declared twice: in ${other} and in ${ownerName}.`);
|
|
95
124
|
kinds.set(payload.kind, ownerName);
|
|
96
|
-
if (snapshot.countProviders(payload.type) === 0) throw new AccessSetupError(`${ownerName}: ${payload.type.name} of reference kind "${payload.kind}" is not registered
|
|
125
|
+
if (snapshot.countProviders(payload.type) === 0) throw new AccessSetupError(`${ownerName}: ${payload.type.name} of reference kind "${payload.kind}" is not registered.`);
|
|
97
126
|
}
|
|
98
127
|
|
|
99
128
|
for (const { payload, ownerName } of permissions) {
|
|
@@ -103,7 +132,7 @@ registerModuleOwnedContributionValidator((snapshot) => {
|
|
|
103
132
|
}
|
|
104
133
|
}
|
|
105
134
|
for (const lookup of lookupsOf(payload.spec)) {
|
|
106
|
-
if (snapshot.countProviders(lookup) === 0) throw new AccessSetupError(`${ownerName}: ${lookup.name} of permission "${payload.key}" is not registered
|
|
135
|
+
if (snapshot.countProviders(lookup) === 0) throw new AccessSetupError(`${ownerName}: ${lookup.name} of permission "${payload.key}" is not registered.`);
|
|
107
136
|
}
|
|
108
137
|
}
|
|
109
138
|
|
|
@@ -122,6 +151,14 @@ registerModuleOwnedContributionValidator((snapshot) => {
|
|
|
122
151
|
}
|
|
123
152
|
});
|
|
124
153
|
|
|
154
|
+
/** Whether a module the given one imports exports this class. */
|
|
155
|
+
function importsExport(metadata: BazisModuleMetadata, type: unknown): boolean {
|
|
156
|
+
return (metadata.imports ?? []).some((imported) => {
|
|
157
|
+
const exported = (imported as { readonly exports?: unknown }).exports;
|
|
158
|
+
return Array.isArray(exported) && exported.includes(type);
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
125
162
|
/** What the modules of the application's container declared. */
|
|
126
163
|
export function declarationsOf(services: ServiceProvider): { permissions: OwnedPermission[]; kinds: OwnedReferenceKind[]; roles: DeclaredRole[] } {
|
|
127
164
|
if (!(services instanceof DiContainer)) throw new AccessSetupError("Access declarations require the framework DiContainer.");
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Class } from "../../di";
|
|
2
2
|
import type { AttributeLookup } from "../contracts/AttributeLookup";
|
|
3
3
|
import type { LocalizedText } from "../contracts/types";
|
|
4
|
+
import type { BazisPermissions } from "../index";
|
|
4
5
|
|
|
5
6
|
/** An attribute of the object of a permission: what a condition may test (ACCESS-001 §3). */
|
|
6
7
|
export interface AttributeSpec {
|
|
@@ -39,6 +40,15 @@ export interface PermissionSpec {
|
|
|
39
40
|
|
|
40
41
|
export type PermissionDeclarations = Readonly<Record<string, PermissionSpec>>;
|
|
41
42
|
|
|
43
|
+
/** The declarations of `definePermissions(...)` as a part of {@link BazisPermissions}. */
|
|
44
|
+
export type PermissionsOf<T extends PermissionDeclarations> = { readonly [K in keyof T]: T[K] };
|
|
45
|
+
|
|
46
|
+
/** A declared permission. */
|
|
47
|
+
export type PermissionKey = Extract<keyof BazisPermissions, string>;
|
|
48
|
+
|
|
49
|
+
/** A permission with an `owner`: the only kind `"<key>:own"` makes sense for. */
|
|
50
|
+
export type OwnPermissionKey = { [K in keyof BazisPermissions]: BazisPermissions[K] extends { readonly owner: string } ? Extract<K, string> : never }[keyof BazisPermissions];
|
|
51
|
+
|
|
42
52
|
const DECLARED = new WeakSet<object>();
|
|
43
53
|
|
|
44
54
|
/**
|
|
@@ -3,7 +3,7 @@ import { UnauthorizedError, type AuthorizeContext } from "../../http";
|
|
|
3
3
|
import { LOGGER, type ConfigView } from "../../kernel";
|
|
4
4
|
import type { AccessConfig } from "./accessConfig";
|
|
5
5
|
import type { IAccessCatalog } from "../catalog/IAccessCatalog.service";
|
|
6
|
-
import type { PermissionSpec } from "../catalog/permissions";
|
|
6
|
+
import type { PermissionKey, PermissionSpec } from "../catalog/permissions";
|
|
7
7
|
import { AccessPolicy, type ScopePrincipals } from "../contracts/AccessPolicy";
|
|
8
8
|
import type { AttributeLookup } from "../contracts/AttributeLookup";
|
|
9
9
|
import type { AccessObject, AccessSubject, Condition, Grant, Grants, Scope } from "../contracts/types";
|
|
@@ -70,7 +70,7 @@ export class SubjectAccess {
|
|
|
70
70
|
) {}
|
|
71
71
|
|
|
72
72
|
/** The grants of the permission; a grant the permission cannot use is dropped with a warning. */
|
|
73
|
-
grants(permission:
|
|
73
|
+
grants(permission: PermissionKey): Promise<Grants> {
|
|
74
74
|
let grants = this.grantCache.get(permission);
|
|
75
75
|
if (grants === undefined) {
|
|
76
76
|
grants = this.load(permission);
|
|
@@ -83,7 +83,7 @@ export class SubjectAccess {
|
|
|
83
83
|
* Whether the subject may do `permission` on `object`. Without an object —
|
|
84
84
|
* whether it has the permission anywhere (a route before the object is known).
|
|
85
85
|
*/
|
|
86
|
-
async can(permission:
|
|
86
|
+
async can(permission: PermissionKey, object?: AccessObject): Promise<boolean> {
|
|
87
87
|
const spec = this.catalog.require(permission).spec;
|
|
88
88
|
const grants = await this.grants(permission);
|
|
89
89
|
if (grants === "all") return true;
|
|
@@ -92,7 +92,7 @@ export class SubjectAccess {
|
|
|
92
92
|
}
|
|
93
93
|
|
|
94
94
|
/** Which rows of a list the subject may see; `scope` — the partition of the request for `scope: "request"`. */
|
|
95
|
-
async filter(permission:
|
|
95
|
+
async filter(permission: PermissionKey, options: { readonly scope?: Scope } = {}): Promise<AccessFilter> {
|
|
96
96
|
const spec = this.catalog.require(permission).spec;
|
|
97
97
|
const grants = await this.grants(permission);
|
|
98
98
|
return await buildFilter({
|
|
@@ -107,7 +107,7 @@ export class SubjectAccess {
|
|
|
107
107
|
}
|
|
108
108
|
|
|
109
109
|
/** The fields of `object` the subject may read and write (ACCESS-001 §4.4). */
|
|
110
|
-
async fields(permission:
|
|
110
|
+
async fields(permission: PermissionKey, object: AccessObject): Promise<FieldAccess> {
|
|
111
111
|
const spec = this.catalog.require(permission).spec;
|
|
112
112
|
const grants = await this.grants(permission);
|
|
113
113
|
if (grants === "all") return { read: "all", write: "all" };
|