bazis 0.138.0 → 0.140.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.
Files changed (44) hide show
  1. package/README.md +13 -5
  2. package/core/access/Access.module.ts +6 -1
  3. package/core/access/catalog/AccessCatalog.ts +56 -7
  4. package/core/access/catalog/IAccessCatalog.service.ts +28 -2
  5. package/core/access/catalog/PermissionSourceLoader.ts +43 -0
  6. package/core/access/catalog/PermissionSources.ts +222 -0
  7. package/core/access/catalog/moduleFields.ts +60 -6
  8. package/core/access/catalog/permissions.ts +46 -1
  9. package/core/access/check/Access.ts +55 -28
  10. package/core/access/check/accessConfig.ts +9 -1
  11. package/core/access/check/can.ts +2 -2
  12. package/core/access/check/conditions.ts +69 -11
  13. package/core/access/check/decimal.ts +61 -0
  14. package/core/access/check/filter.ts +39 -8
  15. package/core/access/contracts/PermissionSource.ts +32 -0
  16. package/core/access/contracts/types.ts +12 -2
  17. package/core/access/generated/deps.ts +5 -1
  18. package/core/access/http/AccessCatalog.controller.ts +9 -2
  19. package/core/access/index.ts +12 -1
  20. package/core/agent/AgentContextBuilder.ts +119 -16
  21. package/core/agent/AgentRuntime.ts +8 -0
  22. package/core/agent/AgentToolExecutor.ts +7 -0
  23. package/core/agent/ModelProvider.ts +6 -0
  24. package/core/agent/README.md +35 -3
  25. package/core/agent/errors.ts +32 -0
  26. package/core/agent/index.ts +7 -0
  27. package/core/agent/internal/AgentExecutionDriver.ts +598 -24
  28. package/core/agent/internal/AgentExecutionState.ts +26 -3
  29. package/core/agent/semantic.ts +60 -1
  30. package/core/cache/SPEC.md +3 -3
  31. package/core/di/README.md +10 -3
  32. package/core/infra/anthropic.ts +11 -0
  33. package/core/infra/connectors/anthropic.ts +440 -0
  34. package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
  35. package/core/infra/connectors/llm.ts +12 -0
  36. package/core/infra/connectors/llmRouter.ts +12 -0
  37. package/core/infra/connectors/openaiCompatible.ts +128 -63
  38. package/core/infra/connectors/openaiTextStream.ts +5 -2
  39. package/core/infra/connectors/toolNames.ts +49 -0
  40. package/core/scripts/di-generate.ts +34 -4
  41. package/core/websocket/DESIGN.md +2 -2
  42. package/index.ts +5 -0
  43. package/library/orm/Repository/README.md +14 -11
  44. package/package.json +7 -2
@@ -1,7 +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
+ import type { BazisPermissions, BazisPermissionSources } from "../index";
5
5
 
6
6
  /** An attribute of the object of a permission: what a condition may test (ACCESS-001 §3). */
7
7
  export interface AttributeSpec {
@@ -46,6 +46,16 @@ export type PermissionsOf<T extends PermissionDeclarations> = { readonly [K in k
46
46
  /** A declared permission. */
47
47
  export type PermissionKey = Extract<keyof BazisPermissions, string>;
48
48
 
49
+ /**
50
+ * A permission of a source (ACCESS-001 §3.1): any key under a prefix of
51
+ * `@Module({ permissionSources })`. Whether it exists is known only while the
52
+ * application runs; a key the source does not give is a permission nobody has.
53
+ */
54
+ export type SourcePermissionKey = `${Extract<keyof BazisPermissionSources, string>}.${string}`;
55
+
56
+ /** What `can`, `access.can`, `access.filter` and `access.fields` accept. */
57
+ export type AnyPermissionKey = PermissionKey | SourcePermissionKey;
58
+
49
59
  /** A permission with an `owner`: the only kind `"<key>:own"` makes sense for. */
50
60
  export type OwnPermissionKey = { [K in keyof BazisPermissions]: BazisPermissions[K] extends { readonly owner: string } ? Extract<K, string> : never }[keyof BazisPermissions];
51
61
 
@@ -81,6 +91,8 @@ const RESERVED = new Set(["all", "any", "not", "own", "scope"]);
81
91
  export function permissionProblems(key: string, spec: PermissionSpec): string[] {
82
92
  const problems: string[] = [];
83
93
  const at = `permission "${key}"`;
94
+ if (typeof spec !== "object" || spec === null || Array.isArray(spec)) return [`${at}: must be an object with a title`];
95
+ if (spec.on !== undefined && (typeof spec.on !== "object" || spec.on === null || Array.isArray(spec.on))) return [`${at}: on must map attribute names to their descriptions`];
84
96
  if (!KEY.test(key) || key.length > 128) problems.push(`${at}: the key must look like "<module>.<object>.<action>" in lower case`);
85
97
  if (!isText(spec.title, 200)) problems.push(`${at}: title must be a non-empty text up to 200 characters`);
86
98
  if (spec.description !== undefined && !isText(spec.description, 2000)) problems.push(`${at}: description must be a non-empty text up to 2000 characters`);
@@ -95,6 +107,10 @@ export function permissionProblems(key: string, spec: PermissionSpec): string[]
95
107
  for (const [name, attribute] of Object.entries(spec.on ?? {})) {
96
108
  if (!NAME.test(name)) problems.push(`${at}: attribute "${name}" must be a name of letters, digits and _`);
97
109
  else if (RESERVED.has(name)) problems.push(`${at}: attribute "${name}" is a reserved word of conditions`);
110
+ if (typeof attribute !== "object" || attribute === null) {
111
+ problems.push(`${at}: attribute "${name}" must be an object with ref or type`);
112
+ continue;
113
+ }
98
114
  if ((attribute.ref === undefined) === (attribute.type === undefined)) problems.push(`${at}: attribute "${name}" needs exactly one of ref and type`);
99
115
  if (attribute.ref !== undefined && !KEY.test(attribute.ref)) problems.push(`${at}: attribute "${name}": ref must be a reference kind like "<module>.<kind>"`);
100
116
  if (attribute.type !== undefined && !["string", "number", "boolean"].includes(attribute.type)) problems.push(`${at}: attribute "${name}": type must be string, number or boolean`);
@@ -119,6 +135,34 @@ export function isReferenceKind(value: string): boolean {
119
135
  return KEY.test(value) && value.length <= 128;
120
136
  }
121
137
 
138
+ const PREFIX = /^[a-z][a-z0-9_-]*(\.[a-z][a-z0-9_-]*)*$/;
139
+
140
+ /** A prefix of a permission source: one or several segments of a key (`data`, `dm.records`). */
141
+ export function isSourcePrefix(value: string): boolean {
142
+ return PREFIX.test(value) && value.length <= 64;
143
+ }
144
+
145
+ /** Whether the key lies under the prefix: `data.orders.read` under `data`. */
146
+ export function underPrefix(key: string, prefix: string): boolean {
147
+ return key.startsWith(`${prefix}.`);
148
+ }
149
+
150
+ /** A frozen copy of a specification a source gave: the catalog keeps it, the source may change its own. */
151
+ export function frozenSpec(spec: PermissionSpec): PermissionSpec {
152
+ const scope = typeof spec.scope === "object" ? { ...spec.scope } : spec.scope;
153
+ const on = spec.on === undefined ? undefined : Object.fromEntries(Object.entries(spec.on).map(([name, attribute]) => [name, { ...attribute }]));
154
+ const title = typeof spec.title === "object" ? { ...spec.title } : spec.title;
155
+ const description = typeof spec.description === "object" ? { ...spec.description } : spec.description;
156
+ return deepFreeze({
157
+ title,
158
+ ...(description === undefined ? {} : { description }),
159
+ ...(scope === undefined ? {} : { scope }),
160
+ ...(on === undefined ? {} : { on }),
161
+ ...(spec.owner === undefined ? {} : { owner: spec.owner }),
162
+ ...(spec.fields === undefined ? {} : { fields: spec.fields }),
163
+ } as PermissionSpec);
164
+ }
165
+
122
166
  function isText(value: unknown, max: number): boolean {
123
167
  if (typeof value === "string") return value.trim() !== "" && value.length <= max;
124
168
  if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
@@ -132,6 +176,7 @@ function isName(value: unknown): value is string {
132
176
 
133
177
  function deepFreeze<T>(value: T): T {
134
178
  if (typeof value !== "object" || value === null || Object.isFrozen(value)) return value;
179
+ // A lookup is a class: a function, left as it is.
135
180
  for (const item of Object.values(value)) if (typeof item === "object") deepFreeze(item);
136
181
  return Object.freeze(value);
137
182
  }
@@ -3,7 +3,8 @@ 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 { PermissionKey, PermissionSpec } from "../catalog/permissions";
6
+ import type { OwnedPermission } from "../catalog/AccessCatalog";
7
+ import type { AnyPermissionKey, PermissionSpec } from "../catalog/permissions";
7
8
  import { AccessPolicy, type ScopePrincipals } from "../contracts/AccessPolicy";
8
9
  import type { AttributeLookup } from "../contracts/AttributeLookup";
9
10
  import type { AccessObject, AccessSubject, Condition, Grant, Grants, Scope } from "../contracts/types";
@@ -56,9 +57,19 @@ export class Access extends IAccess {
56
57
  }
57
58
  }
58
59
 
59
- /** What one subject may do; grants are asked once per permission. */
60
+ /** The grants of a permission with the specification they were checked against. */
61
+ interface Given {
62
+ readonly spec: PermissionSpec;
63
+ readonly grants: Grants;
64
+ }
65
+
66
+ /**
67
+ * What one subject may do; grants are asked once per permission. A permission
68
+ * of a source that the source does not give now does not exist: nobody has it,
69
+ * and the rights system is not asked (ACCESS-001 §3.1).
70
+ */
60
71
  export class SubjectAccess {
61
- private readonly grantCache = new Map<string, Promise<Grants>>();
72
+ private readonly grantCache = new Map<string, Promise<Given | undefined>>();
62
73
  private owners: Promise<readonly string[]> | undefined;
63
74
 
64
75
  constructor(
@@ -70,31 +81,28 @@ export class SubjectAccess {
70
81
  ) {}
71
82
 
72
83
  /** The grants of the permission; a grant the permission cannot use is dropped with a warning. */
73
- grants(permission: PermissionKey): Promise<Grants> {
74
- let grants = this.grantCache.get(permission);
75
- if (grants === undefined) {
76
- grants = this.load(permission);
77
- this.grantCache.set(permission, grants);
78
- }
79
- return grants;
84
+ async grants(permission: AnyPermissionKey): Promise<Grants> {
85
+ return (await this.given(permission))?.grants ?? [];
80
86
  }
81
87
 
82
88
  /**
83
89
  * Whether the subject may do `permission` on `object`. Without an object —
84
90
  * whether it has the permission anywhere (a route before the object is known).
85
91
  */
86
- async can(permission: PermissionKey, object?: AccessObject): Promise<boolean> {
87
- const spec = this.catalog.require(permission).spec;
88
- const grants = await this.grants(permission);
92
+ async can(permission: AnyPermissionKey, object?: AccessObject): Promise<boolean> {
93
+ const given = await this.given(permission);
94
+ if (given === undefined) return false;
95
+ const { spec, grants } = given;
89
96
  if (grants === "all") return true;
90
97
  if (object === undefined) return grants.length > 0;
91
98
  return (await this.matching(permission, spec, grants, object)).length > 0;
92
99
  }
93
100
 
94
101
  /** Which rows of a list the subject may see; `scope` — the partition of the request for `scope: "request"`. */
95
- async filter(permission: PermissionKey, options: { readonly scope?: Scope } = {}): Promise<AccessFilter> {
96
- const spec = this.catalog.require(permission).spec;
97
- const grants = await this.grants(permission);
102
+ async filter(permission: AnyPermissionKey, options: { readonly scope?: Scope } = {}): Promise<AccessFilter> {
103
+ const given = await this.given(permission);
104
+ if (given === undefined) return { kind: "none" };
105
+ const { spec, grants } = given;
98
106
  return await buildFilter({
99
107
  permission,
100
108
  spec,
@@ -107,9 +115,10 @@ export class SubjectAccess {
107
115
  }
108
116
 
109
117
  /** The fields of `object` the subject may read and write (ACCESS-001 §4.4). */
110
- async fields(permission: PermissionKey, object: AccessObject): Promise<FieldAccess> {
111
- const spec = this.catalog.require(permission).spec;
112
- const grants = await this.grants(permission);
118
+ async fields(permission: AnyPermissionKey, object: AccessObject): Promise<FieldAccess> {
119
+ const given = await this.given(permission);
120
+ if (given === undefined) return { read: [], write: [] };
121
+ const { spec, grants } = given;
113
122
  if (grants === "all") return { read: "all", write: "all" };
114
123
  const matching = await this.matching(permission, spec, grants, object);
115
124
  return { read: union(matching, "read"), write: union(matching, "write") };
@@ -126,19 +135,37 @@ export class SubjectAccess {
126
135
  return this.owners;
127
136
  }
128
137
 
129
- private async load(permission: string): Promise<Grants> {
130
- const spec = this.catalog.require(permission).spec;
138
+ private given(permission: string): Promise<Given | undefined> {
139
+ let given = this.grantCache.get(permission);
140
+ if (given === undefined) {
141
+ given = this.load(permission);
142
+ this.grantCache.set(permission, given);
143
+ }
144
+ return given;
145
+ }
146
+
147
+ /** `undefined` — a permission of a source that does not exist now. */
148
+ private async load(permission: string): Promise<Given | undefined> {
149
+ let owned: OwnedPermission | undefined = await this.catalog.find(permission);
150
+ if (owned === undefined) return undefined;
131
151
  const grants = await this.policy.grants(this.subject, permission);
132
- if (grants === "all") return "all";
152
+ if (grants === "all") return { spec: owned.spec, grants: "all" };
133
153
  if (!Array.isArray(grants)) {
134
154
  this.warn(permission, "the rights system must answer \"all\" or a list of grants");
135
- return [];
155
+ return { spec: owned.spec, grants: [] };
136
156
  }
137
- return grants.filter((grant) => {
138
- const problem = grantProblem(spec, grant, this.maxInList);
139
- if (problem !== undefined) this.warn(permission, problem);
140
- return problem === undefined;
141
- });
157
+ let problems = grants.map((grant) => grantProblem(owned!.spec, grant, this.maxInList));
158
+ if (owned.source !== undefined && problems.some((problem) => problem !== undefined)) {
159
+ // The rule may name a field another instance has just added: compare the source's version before dropping it.
160
+ const fresh = await this.catalog.find(permission, { verify: true });
161
+ if (fresh === undefined) return undefined;
162
+ if (fresh !== owned) {
163
+ owned = fresh;
164
+ problems = grants.map((grant) => grantProblem(fresh.spec, grant, this.maxInList));
165
+ }
166
+ }
167
+ problems.forEach((problem) => { if (problem !== undefined) this.warn(permission, problem); });
168
+ return { spec: owned.spec, grants: grants.filter((_, index) => problems[index] === undefined) };
142
169
  }
143
170
 
144
171
  private async matching(permission: string, spec: PermissionSpec, grants: readonly Grant[], object: AccessObject): Promise<Grant[]> {
@@ -8,11 +8,19 @@ export interface AccessConfig {
8
8
  roleClaim: string;
9
9
  /** Values of one list condition and of a lookup result. */
10
10
  maxInList: number;
11
+ /**
12
+ * Seconds between two questions to the permission sources about their
13
+ * version (ACCESS-001 §3.1): how long another instance may keep a removed
14
+ * permission. `0` — never ask: one instance, or the application calls
15
+ * `IAccessCatalog.changed` on every instance itself.
16
+ */
17
+ sourcePollSeconds: number;
11
18
  }
12
19
 
13
20
  export const accessConfig = defineConfig<AccessConfig>("access", {
14
- default: { kindClaim: "", roleClaim: "role", maxInList: 1000 },
21
+ default: { kindClaim: "", roleClaim: "role", maxInList: 1000, sourcePollSeconds: 5 },
15
22
  validate: {
16
23
  maxInList: (value) => Number.isSafeInteger(value) && value >= 1 && value <= 100_000 ? undefined : "must be an integer 1–100000",
24
+ sourcePollSeconds: (value) => Number.isFinite(value) && value >= 0 && value <= 3600 ? undefined : "must be a number 0–3600",
17
25
  },
18
26
  });
@@ -1,5 +1,5 @@
1
1
  import type { AuthorizeCheck, AuthorizeContext } from "../../http";
2
- import type { PermissionKey } from "../catalog/permissions";
2
+ import type { AnyPermissionKey } from "../catalog/permissions";
3
3
  import type { AccessObject } from "../contracts/types";
4
4
  import { IAccess } from "./IAccess.service";
5
5
 
@@ -14,7 +14,7 @@ import { IAccess } from "./IAccess.service";
14
14
  * @Post() create(ctx: HttpContext) { … }
15
15
  * ```
16
16
  */
17
- export function can(permission: PermissionKey, objectOf?: (ctx: AuthorizeContext) => AccessObject | Promise<AccessObject>): AuthorizeCheck {
17
+ export function can(permission: AnyPermissionKey, objectOf?: (ctx: AuthorizeContext) => AccessObject | Promise<AccessObject>): AuthorizeCheck {
18
18
  return async (ctx) => {
19
19
  const access = await ctx.services.resolve(IAccess).for(ctx);
20
20
  return await access.can(permission, objectOf === undefined ? undefined : await objectOf(ctx));
@@ -1,5 +1,6 @@
1
- import type { PermissionSpec } from "../catalog/permissions";
2
- import type { AccessValue, Condition, Grant, Scope, ValueTest } from "../contracts/types";
1
+ import type { AttributeSpec, PermissionSpec } from "../catalog/permissions";
2
+ import type { AccessValue, CompareTest, Condition, Grant, Scope, ValueTest } from "../contracts/types";
3
+ import { compareDecimals, decimalOf } from "./decimal";
3
4
 
4
5
  export const MAX_DEPTH = 8;
5
6
  export const MAX_NODES = 200;
@@ -41,30 +42,53 @@ function conditionProblem(spec: PermissionSpec, condition: Condition, depth: num
41
42
  if (value !== true) return "own must be true";
42
43
  return spec.owner === undefined ? "the permission declares no owner, own cannot be used" : undefined;
43
44
  }
44
- if (spec.on?.[key] === undefined) return `unknown attribute "${key}"`;
45
- return testProblem(key, value, maxInList);
45
+ const attribute = spec.on?.[key];
46
+ if (attribute === undefined) return `unknown attribute "${key}"`;
47
+ return testProblem(key, attribute, value, maxInList);
46
48
  }
47
49
 
48
- function testProblem(attribute: string, test: unknown, maxInList: number): string | undefined {
50
+ function testProblem(attribute: string, spec: AttributeSpec, test: unknown, maxInList: number): string | undefined {
49
51
  if (typeof test !== "object" || test === null || Array.isArray(test)) return `the test of "${attribute}" must be an object`;
50
52
  const keys = Object.keys(test);
51
- if (keys.length !== 1) return `the test of "${attribute}" must have exactly one of eq, in, notIn, isNull`;
53
+ if (keys.length !== 1) return `the test of "${attribute}" must have exactly one of eq, in, notIn, isNull, gt, gte, lt, lte`;
52
54
  const [op] = keys;
53
55
  const value = (test as Record<string, unknown>)[op!];
56
+ const numeric = spec.type === "number";
54
57
  switch (op) {
55
58
  case "eq":
59
+ if (numeric) return isNumeric(value) ? undefined : `${attribute}.eq must be a number: the attribute is a number`;
56
60
  return isValue(value) && value !== null ? undefined : `${attribute}.eq must be a string, number or boolean (an empty value is tested with isNull)`;
57
61
  case "in":
58
62
  case "notIn":
59
- if (!Array.isArray(value) || !value.every((item) => isValue(item) && item !== null)) return `${attribute}.${op} must be a list of strings, numbers or booleans (an empty value is tested with isNull)`;
63
+ if (numeric) {
64
+ if (!Array.isArray(value) || !value.every(isNumeric)) return `${attribute}.${op} must be a list of numbers: the attribute is a number`;
65
+ } else if (!Array.isArray(value) || !value.every((item) => isValue(item) && item !== null)) {
66
+ return `${attribute}.${op} must be a list of strings, numbers or booleans (an empty value is tested with isNull)`;
67
+ }
60
68
  return value.length > maxInList ? `${attribute}.${op} has more than ${maxInList} values` : undefined;
61
69
  case "isNull":
62
70
  return typeof value === "boolean" ? undefined : `${attribute}.isNull must be true or false`;
71
+ case "gt":
72
+ case "gte":
73
+ case "lt":
74
+ case "lte":
75
+ if (!comparable(spec)) return `${attribute}.${op}: only an attribute of type "number" with a column can be compared${spec.lookup !== undefined ? " (this one has a lookup)" : ""}`;
76
+ return typeof value === "number" && Number.isFinite(value) ? undefined : `${attribute}.${op} must be a number`;
63
77
  default:
64
78
  return `unknown test "${op}" of "${attribute}"`;
65
79
  }
66
80
  }
67
81
 
82
+ /** Whether `gt`, `gte`, `lt`, `lte` may test the attribute: a number in a column of the listed rows. */
83
+ export function comparable(attribute: AttributeSpec): boolean {
84
+ return attribute.type === "number" && attribute.lookup === undefined;
85
+ }
86
+
87
+ /** A number, or a decimal string for a value a `double` cannot hold. */
88
+ function isNumeric(value: unknown): boolean {
89
+ return (typeof value === "number" || typeof value === "string") && decimalOf(value) !== undefined;
90
+ }
91
+
68
92
  /**
69
93
  * The value of a condition on an object, in SQL's three-valued logic so that a
70
94
  * check and a list filter agree: an attribute the object does not carry, or a
@@ -100,16 +124,50 @@ export function evaluate(condition: Condition, object: Readonly<Record<string, u
100
124
  return ownerIds.includes(String(owner));
101
125
  }
102
126
  const [attribute, test] = Object.entries(condition)[0] as [string, ValueTest];
103
- return testValue(object[attribute], test);
127
+ return testValue(object[attribute], test, spec.on?.[attribute]?.type === "number");
104
128
  }
105
129
 
106
- function testValue(raw: unknown, test: ValueTest): boolean | undefined {
130
+ const COMPARE: Readonly<Record<CompareTest, (order: number) => boolean>> = {
131
+ gt: (order) => order > 0,
132
+ gte: (order) => order >= 0,
133
+ lt: (order) => order < 0,
134
+ lte: (order) => order <= 0,
135
+ };
136
+
137
+ function testValue(raw: unknown, test: ValueTest, numeric: boolean): boolean | undefined {
107
138
  if (raw === undefined) return undefined;
108
139
  if ("isNull" in test) return (raw === null) === test.isNull;
109
140
  if (raw === null) return undefined;
141
+ if (numeric) return testNumber(raw, test);
110
142
  if ("eq" in test) return same(raw, test.eq);
111
- const found = ("in" in test ? test.in : test.notIn).some((value) => same(raw, value));
112
- return "in" in test ? found : !found;
143
+ if ("in" in test || "notIn" in test) {
144
+ const found = ("in" in test ? test.in : test.notIn).some((value) => same(raw, value));
145
+ return "in" in test ? found : !found;
146
+ }
147
+ // A comparison of an attribute that is not a number: such a grant is dropped before it gets here.
148
+ return undefined;
149
+ }
150
+
151
+ /**
152
+ * A number attribute is compared as a number, the way the database does it:
153
+ * `"100000.00"` of a `numeric` column equals `100000`. The comparison is exact
154
+ * and decimal, so a check and a list filter agree. Not a number — unknown.
155
+ */
156
+ function testNumber(raw: unknown, test: Exclude<ValueTest, { readonly isNull: boolean }>): boolean | undefined {
157
+ const left = decimalOf(raw);
158
+ if (left === undefined) return undefined;
159
+ const order = (value: unknown): number | undefined => {
160
+ const right = decimalOf(value);
161
+ return right === undefined ? undefined : compareDecimals(left, right);
162
+ };
163
+ if ("in" in test || "notIn" in test) {
164
+ const found = ("in" in test ? test.in : test.notIn).some((value) => order(value) === 0);
165
+ return "in" in test ? found : !found;
166
+ }
167
+ const [op, value] = Object.entries(test)[0] as ["eq" | CompareTest, unknown];
168
+ const result = order(value);
169
+ if (result === undefined) return undefined;
170
+ return op === "eq" ? result === 0 : COMPARE[op](result);
113
171
  }
114
172
 
115
173
  /** Ids come as numbers from one side and as strings from the other. */
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Exact decimal numbers for the tests of a number attribute (ACCESS-001 §4.2).
3
+ * A `numeric` column comes from the database as a string; turning it into a
4
+ * `double` would make a check disagree with the list filter the database runs.
5
+ */
6
+ export interface Decimal {
7
+ readonly negative: boolean;
8
+ /** Digits before the point, without leading zeros; `""` — zero. */
9
+ readonly integer: string;
10
+ /** Digits after the point, without trailing zeros. */
11
+ readonly fraction: string;
12
+ }
13
+
14
+ const FORM = /^([+-]?)(\d*)(?:\.(\d*))?(?:[eE]([+-]?\d+))?$/;
15
+ /** Enough for a `double` (1e308, 5e-324) and far beyond a column of money. */
16
+ const MAX_EXPONENT = 1000;
17
+ const MAX_DIGITS = 1100;
18
+
19
+ /** The decimal of a number, a bigint or a decimal string; `undefined` — not a number. */
20
+ export function decimalOf(value: unknown): Decimal | undefined {
21
+ if (typeof value === "number") return Number.isFinite(value) ? parse(String(value)) : undefined;
22
+ if (typeof value === "bigint") return parse(String(value));
23
+ if (typeof value === "string") return parse(value.trim());
24
+ return undefined;
25
+ }
26
+
27
+ function parse(text: string): Decimal | undefined {
28
+ if (text.length > MAX_DIGITS) return undefined;
29
+ const match = FORM.exec(text);
30
+ if (match === null) return undefined;
31
+ const [, sign, whole = "", part = "", power] = match;
32
+ if (whole === "" && part === "") return undefined;
33
+ const exponent = power === undefined ? 0 : Number(power);
34
+ if (Math.abs(exponent) > MAX_EXPONENT) return undefined;
35
+ // Move the point by the exponent: digits = whole + part, the point stands after `whole.length + exponent` digits.
36
+ const digits = whole + part;
37
+ const point = whole.length + exponent;
38
+ const integer = (point <= 0 ? "" : point >= digits.length ? digits + "0".repeat(point - digits.length) : digits.slice(0, point)).replace(/^0+/, "");
39
+ const fraction = (point <= 0 ? "0".repeat(-point) + digits : point >= digits.length ? "" : digits.slice(point)).replace(/0+$/, "");
40
+ return { negative: sign === "-" && (integer !== "" || fraction !== ""), integer, fraction };
41
+ }
42
+
43
+ /** Below zero — `left` is less, above — greater, zero — the numbers are equal. */
44
+ export function compareDecimals(left: Decimal, right: Decimal): number {
45
+ if (left.negative !== right.negative) return left.negative ? -1 : 1;
46
+ const order = compareMagnitudes(left, right);
47
+ return order === 0 ? 0 : left.negative ? -order : order;
48
+ }
49
+
50
+ function compareMagnitudes(left: Decimal, right: Decimal): number {
51
+ if (left.integer.length !== right.integer.length) return left.integer.length - right.integer.length;
52
+ if (left.integer !== right.integer) return left.integer < right.integer ? -1 : 1;
53
+ if (left.fraction === right.fraction) return 0;
54
+ const length = Math.max(left.fraction.length, right.fraction.length);
55
+ return left.fraction.padEnd(length, "0") < right.fraction.padEnd(length, "0") ? -1 : 1;
56
+ }
57
+
58
+ /** The decimal in plain notation, without an exponent: `-0.5`, `100000.01`. */
59
+ export function decimalText(value: Decimal): string {
60
+ return `${value.negative ? "-" : ""}${value.integer === "" ? "0" : value.integer}${value.fraction === "" ? "" : `.${value.fraction}`}`;
61
+ }
@@ -3,15 +3,21 @@ import type { Class } from "../../di";
3
3
  import type { PermissionSpec } from "../catalog/permissions";
4
4
  import type { AttributeLookup } from "../contracts/AttributeLookup";
5
5
  import { AccessFilterTooLargeError } from "../contracts/errors";
6
- import type { AccessValue, Condition, Grant, Scope, ValueTest } from "../contracts/types";
6
+ import type { AccessValue, CompareTest, Condition, Grant, Scope, ValueTest } from "../contracts/types";
7
+ import { decimalOf, decimalText } from "./decimal";
7
8
 
8
- /** A condition on the columns of the listed rows. */
9
+ /**
10
+ * A condition on the columns of the listed rows. `op` — a comparison of a
11
+ * number column with a value (ACCESS-001 §4.2). A module that lists rows
12
+ * without the ORM translates this form itself.
13
+ */
9
14
  export type ColumnCondition =
10
15
  | { readonly all: readonly ColumnCondition[] }
11
16
  | { readonly any: readonly ColumnCondition[] }
12
17
  | { readonly not: ColumnCondition }
13
18
  | { readonly column: string; readonly in: readonly AccessValue[] }
14
- | { readonly column: string; readonly isNull: boolean };
19
+ | { readonly column: string; readonly isNull: boolean }
20
+ | { readonly column: string; readonly op: CompareTest; readonly value: number };
15
21
 
16
22
  /** Which rows of a list the subject may see (ACCESS-001 §4.3). */
17
23
  export type AccessFilter =
@@ -67,13 +73,19 @@ async function whereCondition(input: FilterInput, condition: Condition): Promise
67
73
  if ("isNull" in test) return test.isNull ? await fromLookup(input, lookup, [null]) : not(await fromLookup(input, lookup, [null]));
68
74
  if ("eq" in test) return await fromLookup(input, lookup, [test.eq]);
69
75
  if ("in" in test) return await fromLookup(input, lookup, test.in);
70
- return not(await fromLookup(input, lookup, test.notIn));
76
+ if ("notIn" in test) return not(await fromLookup(input, lookup, test.notIn));
77
+ // A lookup turns "one of the values" into a column and cannot turn a range: such a grant is dropped before it gets here.
78
+ throw new TypeError(`The list filter of "${input.permission}": attribute "${name}" has a lookup and cannot be compared.`);
71
79
  }
72
80
  const column = attribute.column ?? name;
73
81
  if ("isNull" in test) return { column, isNull: test.isNull };
74
- if ("eq" in test) return { column, in: [test.eq] };
75
- if ("in" in test) return test.in.length === 0 ? false : { column, in: test.in };
76
- return test.notIn.length === 0 ? { column, isNull: false } : not({ column, in: test.notIn });
82
+ // A number given as a string ("1e3", "012.50") goes to the database in plain notation.
83
+ const values = (list: readonly AccessValue[]): readonly AccessValue[] => (attribute.type === "number" ? list.map(plainNumber) : list);
84
+ if ("eq" in test) return { column, in: values([test.eq]) };
85
+ if ("in" in test) return test.in.length === 0 ? false : { column, in: values(test.in) };
86
+ if ("notIn" in test) return test.notIn.length === 0 ? { column, isNull: false } : not({ column, in: values(test.notIn) });
87
+ const [op, value] = Object.entries(test)[0] as [CompareTest, number];
88
+ return { column, op, value };
77
89
  }
78
90
 
79
91
  async function fromLookup(input: FilterInput, type: Class<AttributeLookup>, values: readonly AccessValue[]): Promise<Built> {
@@ -84,6 +96,12 @@ async function fromLookup(input: FilterInput, type: Class<AttributeLookup>, valu
84
96
  return inColumn(found.column, found.in);
85
97
  }
86
98
 
99
+ function plainNumber(value: AccessValue): AccessValue {
100
+ if (typeof value !== "string") return value;
101
+ const decimal = decimalOf(value);
102
+ return decimal === undefined ? value : decimalText(decimal);
103
+ }
104
+
87
105
  /** `column in values`; `null` among them means an empty column. */
88
106
  function inColumn(column: string, values: readonly AccessValue[]): Built {
89
107
  const known = values.filter((value) => value !== null);
@@ -128,5 +146,18 @@ function toPredicate(condition: ColumnCondition): Predicate {
128
146
  if ("not" in condition) return toPredicate(condition.not).not();
129
147
  const column = new Operand(condition.column);
130
148
  if ("isNull" in condition) return condition.isNull ? column.isNull() : column.isNotNull();
131
- return condition.in.length === 1 ? column.eq(condition.in[0]) : column.in(condition.in);
149
+ if ("op" in condition) return column[condition.op](parameter(condition.value));
150
+ const values = condition.in.map(parameter);
151
+ return values.length === 1 ? column.eq(values[0]) : column.in(values);
152
+ }
153
+
154
+ /**
155
+ * A fraction goes to the database as decimal text, not as a `double`: a
156
+ * `decimal` column of the ORM refuses a float, and the text keeps the number
157
+ * the rule names (`100000.01`) for `integer` and `real` columns too.
158
+ */
159
+ function parameter(value: AccessValue): AccessValue {
160
+ if (typeof value !== "number" || Number.isSafeInteger(value)) return value;
161
+ const decimal = decimalOf(value);
162
+ return decimal === undefined ? value : decimalText(decimal);
132
163
  }
@@ -0,0 +1,32 @@
1
+ import type { PermissionDeclarations } from "../catalog/permissions";
2
+
3
+ /**
4
+ * Permissions that appear while the application runs (ACCESS-001 §3.1): a
5
+ * data manager's tables and their fields. A class of the declaring module,
6
+ * named in `@Module({ permissionSources: { <prefix>: Class } })`; the catalog
7
+ * creates it in a scope of its own for one reading, so it may take a
8
+ * `DbContext`. It owns every key that starts with `<prefix>.`.
9
+ *
10
+ * ```ts
11
+ * export class DataPermissionSource extends PermissionSource {
12
+ * constructor(private readonly db: DataDbContext) { super(); }
13
+ * async version() { return String((await this.db.meta.first()).version); }
14
+ * async load() { return Object.fromEntries((await this.db.tables.toList()).map((table) => [`data.${table.code}.read`, specOf(table)])); }
15
+ * }
16
+ * ```
17
+ */
18
+ export abstract class PermissionSource {
19
+ /**
20
+ * The version of the source's data: a counter or a timestamp that changes in
21
+ * one transaction with the data. Every instance of the application asks it
22
+ * every `access.sourcePollSeconds` and reads the permissions again when it
23
+ * differs.
24
+ */
25
+ abstract version(): string | Promise<string>;
26
+
27
+ /**
28
+ * The permissions of the prefix, in the form of `definePermissions`. With
29
+ * `keys` — only these: a key missing from the answer is a removed permission.
30
+ */
31
+ abstract load(keys?: readonly string[]): PermissionDeclarations | Promise<PermissionDeclarations>;
32
+ }
@@ -7,12 +7,22 @@ export type AccessValue = string | number | boolean | null;
7
7
  /** A partition of the application (a CMS space, a tenant); `null` — no partition. */
8
8
  export type Scope = string | null;
9
9
 
10
- /** A test of one attribute. */
10
+ /**
11
+ * A test of one attribute. The comparisons `gt`, `gte`, `lt`, `lte` are for an
12
+ * attribute of `type: "number"` with a `column` only (ACCESS-001 §4.2).
13
+ */
11
14
  export type ValueTest =
12
15
  | { readonly eq: AccessValue }
13
16
  | { readonly in: readonly AccessValue[] }
14
17
  | { readonly notIn: readonly AccessValue[] }
15
- | { readonly isNull: boolean };
18
+ | { readonly isNull: boolean }
19
+ | { readonly gt: number }
20
+ | { readonly gte: number }
21
+ | { readonly lt: number }
22
+ | { readonly lte: number };
23
+
24
+ /** The comparisons of a number attribute. */
25
+ export type CompareTest = "gt" | "gte" | "lt" | "lte";
16
26
 
17
27
  /**
18
28
  * A condition of a grant (ACCESS-001 §4.2): a tree over the attributes the
@@ -4,11 +4,15 @@ import { registerFrameworkClassDeps } from "../../di/module/autoDeps";
4
4
  import { ServiceProvider } from "../../di/ServiceProvider";
5
5
  import { AccessCatalog } from "../catalog/AccessCatalog";
6
6
  import { IAccessCatalog } from "../catalog/IAccessCatalog.service";
7
+ import { PermissionSourceLoader } from "../catalog/PermissionSourceLoader";
8
+ import { PermissionSources } from "../catalog/PermissionSources";
7
9
  import { Access } from "../check/Access";
8
10
  import { RolesAccessPolicy } from "../roles/RolesAccessPolicy";
9
11
 
10
12
  registerFrameworkClassDeps([
11
13
  [Access, [IAccessCatalog, "Config:access"]],
12
- [AccessCatalog, [ServiceProvider]],
14
+ [AccessCatalog, [ServiceProvider, PermissionSources, "Config:access"]],
15
+ [PermissionSourceLoader, [PermissionSources]],
16
+ [PermissionSources, [ServiceProvider, "Config:access"]],
13
17
  [RolesAccessPolicy, [IAccessCatalog, "Config:access"]],
14
18
  ]);
@@ -2,6 +2,7 @@ import { contextOnlyController } from "../../http/Routing/contextOnlyControllers
2
2
  import { Authorize, BadRequestError, Controller, Get, NotFoundError, type HttpContext } from "../../http";
3
3
  import { IAccessCatalog } from "../catalog/IAccessCatalog.service";
4
4
  import { can } from "../check/can";
5
+ import { comparable } from "../check/conditions";
5
6
  import type { Scope } from "../contracts/types";
6
7
 
7
8
  /**
@@ -15,13 +16,19 @@ export class AccessCatalogController {
15
16
  @Authorize(can("access.catalog.read"))
16
17
  catalogList(ctx: HttpContext) {
17
18
  return {
18
- data: ctx.services.resolve(IAccessCatalog).permissions().map(({ key, spec, module }) => ({
19
+ data: ctx.services.resolve(IAccessCatalog).permissions().map(({ key, spec, module, source }) => ({
19
20
  key,
20
21
  module,
22
+ ...(source === undefined ? {} : { source }),
21
23
  title: spec.title,
22
24
  ...(spec.description === undefined ? {} : { description: spec.description }),
23
25
  scope: typeof spec.scope === "object" ? ("column" in spec.scope ? "column" : "lookup") : spec.scope ?? "none",
24
- attributes: Object.entries(spec.on ?? {}).map(([name, attribute]) => ({ name, ...(attribute.ref === undefined ? { type: attribute.type } : { ref: attribute.ref }) })),
26
+ attributes: Object.entries(spec.on ?? {}).map(([name, attribute]) => ({
27
+ name,
28
+ ...(attribute.ref === undefined ? { type: attribute.type } : { ref: attribute.ref }),
29
+ // The administrator's screen offers >, ≥, <, ≤ only here (ACCESS-001 §4.2).
30
+ ...(comparable(attribute) ? { compare: true } : {}),
31
+ })),
25
32
  own: spec.owner !== undefined,
26
33
  fields: spec.fields === true,
27
34
  })),