bazis 0.140.0 → 0.140.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.
- package/core/access/Access.module.ts +3 -3
- package/core/access/catalog/AccessCatalog.ts +10 -4
- package/core/access/catalog/IAccessCatalog.service.ts +1 -1
- package/core/access/catalog/{PermissionSourceLoader.ts → PermissionSourceLoader.background.ts} +14 -8
- package/core/access/catalog/{PermissionSources.ts → PermissionSources.service.ts} +92 -22
- package/core/access/catalog/moduleFields.ts +1 -1
- package/core/access/catalog/permissions.ts +5 -3
- package/core/access/check/accessConfig.ts +4 -1
- package/core/access/contracts/PermissionSource.ts +6 -2
- package/core/access/generated/deps.ts +5 -5
- package/core/access/index.ts +2 -2
- package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
- package/package.json +1 -1
|
@@ -2,8 +2,8 @@ import { Module, singleton } from "../di";
|
|
|
2
2
|
import { AccessCatalog } from "./catalog/AccessCatalog";
|
|
3
3
|
import { IAccessCatalog } from "./catalog/IAccessCatalog.service";
|
|
4
4
|
import { definePermissions } from "./catalog/permissions";
|
|
5
|
-
import { PermissionSourceLoader } from "./catalog/PermissionSourceLoader";
|
|
6
|
-
import {
|
|
5
|
+
import { PermissionSourceLoader } from "./catalog/PermissionSourceLoader.background";
|
|
6
|
+
import { PermissionSourcesService } from "./catalog/PermissionSources.service";
|
|
7
7
|
import { Access } from "./check/Access";
|
|
8
8
|
import { accessConfig } from "./check/accessConfig";
|
|
9
9
|
import { IAccess } from "./check/IAccess.service";
|
|
@@ -29,7 +29,7 @@ export const accessPermissions = definePermissions({
|
|
|
29
29
|
config: [accessConfig],
|
|
30
30
|
permissions: accessPermissions,
|
|
31
31
|
providers: [
|
|
32
|
-
singleton(
|
|
32
|
+
singleton(PermissionSourcesService),
|
|
33
33
|
singleton(IAccessCatalog, AccessCatalog),
|
|
34
34
|
singleton(AccessPolicy, RolesAccessPolicy),
|
|
35
35
|
singleton(IAccess, Access),
|
|
@@ -9,7 +9,7 @@ import type { RolePermission } from "../roles/roles";
|
|
|
9
9
|
import { IAccessCatalog } from "./IAccessCatalog.service";
|
|
10
10
|
import { declarationsOf } from "./moduleFields";
|
|
11
11
|
import type { PermissionSpec } from "./permissions";
|
|
12
|
-
import type {
|
|
12
|
+
import type { PermissionSourcesService, PermissionSourceState } from "./PermissionSources.service";
|
|
13
13
|
|
|
14
14
|
/** A permission with the module that declares it: in `@Module({ permissions })` or through a source. */
|
|
15
15
|
export interface OwnedPermission {
|
|
@@ -40,7 +40,10 @@ export class AccessCatalog extends IAccessCatalog {
|
|
|
40
40
|
private readonly byKind: ReadonlyMap<string, OwnedReferenceKind>;
|
|
41
41
|
private readonly byName: ReadonlyMap<string, DeclaredRole>;
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
/** The declared permissions and the ones of the sources as one sorted list, good for one revision of the sources. */
|
|
44
|
+
private merged: { readonly revision: number; readonly list: readonly OwnedPermission[] } | undefined;
|
|
45
|
+
|
|
46
|
+
constructor(services: ServiceProvider, private readonly sourced: PermissionSourcesService, private readonly config: ConfigView<AccessConfig>) {
|
|
44
47
|
super();
|
|
45
48
|
const { permissions, kinds, roles } = declarationsOf(services);
|
|
46
49
|
this.byKey = new Map([...permissions].sort((a, b) => a.key.localeCompare(b.key)).map((item) => [item.key, item]));
|
|
@@ -49,8 +52,11 @@ export class AccessCatalog extends IAccessCatalog {
|
|
|
49
52
|
}
|
|
50
53
|
|
|
51
54
|
permissions(): readonly OwnedPermission[] {
|
|
52
|
-
const
|
|
53
|
-
|
|
55
|
+
const revision = this.sourced.revision;
|
|
56
|
+
if (this.merged?.revision !== revision) {
|
|
57
|
+
this.merged = { revision, list: [...this.byKey.values(), ...this.sourced.permissions()].sort((a, b) => a.key.localeCompare(b.key)) };
|
|
58
|
+
}
|
|
59
|
+
return [...this.merged.list];
|
|
54
60
|
}
|
|
55
61
|
|
|
56
62
|
permission(key: string): OwnedPermission | undefined {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Grant } from "../contracts/types";
|
|
2
2
|
import type { DeclaredRole, OwnedPermission, OwnedReferenceKind } from "./AccessCatalog";
|
|
3
|
-
import type { PermissionSourceState } from "./PermissionSources";
|
|
3
|
+
import type { PermissionSourceState } from "./PermissionSources.service";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* What the application's modules declared about access: permissions, reference
|
package/core/access/catalog/{PermissionSourceLoader.ts → PermissionSourceLoader.background.ts}
RENAMED
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
import type { HostedService, HostedServiceHealth } from "../../di";
|
|
2
|
-
import type {
|
|
2
|
+
import type { PermissionSourcesService } from "./PermissionSources.service";
|
|
3
3
|
|
|
4
4
|
/** After the infrastructure and the schema (phases below), before the HTTP host (phase 10). */
|
|
5
5
|
const PHASE = 5;
|
|
6
|
+
/** How often a failed source is tried again when the versions are not polled. */
|
|
7
|
+
const RETRY_SECONDS = 5;
|
|
8
|
+
/** A failure or two is a database that blinked; `/health` reports a source that keeps failing. */
|
|
9
|
+
const UNHEALTHY_AFTER = 3;
|
|
6
10
|
|
|
7
11
|
/**
|
|
8
12
|
* Reads the permission sources before the application takes requests and
|
|
9
13
|
* then asks their versions every `access.sourcePollSeconds` (ACCESS-001 §3.1).
|
|
10
14
|
* A source that fails does not stop the start: its permissions do not exist
|
|
11
|
-
* until a later reading succeeds, and `/health` says so.
|
|
15
|
+
* until a later reading succeeds, and `/health` says so. Without a poll
|
|
16
|
+
* (`sourcePollSeconds: 0`) only the sources that failed are tried again.
|
|
12
17
|
*/
|
|
13
18
|
export class PermissionSourceLoader implements HostedService {
|
|
14
19
|
readonly phase = PHASE;
|
|
@@ -16,16 +21,16 @@ export class PermissionSourceLoader implements HostedService {
|
|
|
16
21
|
private timer: ReturnType<typeof setInterval> | undefined;
|
|
17
22
|
private polling: Promise<void> | undefined;
|
|
18
23
|
|
|
19
|
-
constructor(private readonly sources:
|
|
24
|
+
constructor(private readonly sources: PermissionSourcesService) {}
|
|
20
25
|
|
|
21
26
|
async start(): Promise<void> {
|
|
27
|
+
if (this.sources.count === 0) return;
|
|
22
28
|
await this.sources.load();
|
|
23
|
-
const
|
|
24
|
-
if (seconds <= 0) return;
|
|
29
|
+
const poll = this.sources.pollSeconds;
|
|
25
30
|
this.timer = setInterval(() => {
|
|
26
31
|
// One question at a time: a slow source is not asked again before it answers.
|
|
27
|
-
this.polling ??= this.sources.poll().finally(() => { this.polling = undefined; });
|
|
28
|
-
},
|
|
32
|
+
this.polling ??= (poll > 0 ? this.sources.poll() : this.sources.retry()).catch(() => undefined).finally(() => { this.polling = undefined; });
|
|
33
|
+
}, (poll > 0 ? poll : RETRY_SECONDS) * 1000);
|
|
29
34
|
this.timer.unref?.();
|
|
30
35
|
}
|
|
31
36
|
|
|
@@ -35,8 +40,9 @@ export class PermissionSourceLoader implements HostedService {
|
|
|
35
40
|
await this.polling;
|
|
36
41
|
}
|
|
37
42
|
|
|
43
|
+
/** Unhealthy: a source without permissions at all, or one whose readings keep failing. */
|
|
38
44
|
checkHealth(): HostedServiceHealth {
|
|
39
|
-
const failed = this.sources.state().filter((source) => !source.loaded || source.
|
|
45
|
+
const failed = this.sources.state().filter((source) => !source.loaded || source.failures >= UNHEALTHY_AFTER);
|
|
40
46
|
if (failed.length === 0) return { healthy: true };
|
|
41
47
|
return { healthy: false, details: failed.map((source) => `permission source "${source.prefix}": ${source.loaded ? "" : "not loaded: "}${source.error ?? "no reading yet"}`).join("; ") };
|
|
42
48
|
}
|
|
@@ -23,6 +23,8 @@ export interface PermissionSourceState {
|
|
|
23
23
|
readonly version: string | undefined;
|
|
24
24
|
/** Why the last reading failed; the permissions read before it stay. */
|
|
25
25
|
readonly error: string | undefined;
|
|
26
|
+
/** Readings and version questions that failed in a row; `0` after a success. */
|
|
27
|
+
readonly failures: number;
|
|
26
28
|
readonly permissions: number;
|
|
27
29
|
}
|
|
28
30
|
|
|
@@ -31,10 +33,13 @@ interface Source extends OwnedPermissionSource {
|
|
|
31
33
|
loaded: boolean;
|
|
32
34
|
version: string | undefined;
|
|
33
35
|
error: string | undefined;
|
|
34
|
-
|
|
36
|
+
failures: number;
|
|
37
|
+
/** When the version was last compared, for the limit of {@link PermissionSourcesService.verify}. */
|
|
35
38
|
verifiedAt: number;
|
|
36
39
|
/** Readings of one source go one after another. */
|
|
37
40
|
queue: Promise<unknown>;
|
|
41
|
+
/** A call that ran out of time and has not answered yet: no new call starts beside it. */
|
|
42
|
+
unanswered: Promise<void> | undefined;
|
|
38
43
|
}
|
|
39
44
|
|
|
40
45
|
/** How often a missing key or attribute may ask a source for its version. */
|
|
@@ -45,18 +50,21 @@ const VERIFY_EVERY_MS = 1000;
|
|
|
45
50
|
* the application takes requests, read again when a source's version changes.
|
|
46
51
|
* A specification passes the checks of a declared one; a bad one is logged and
|
|
47
52
|
* the permission does not exist — closed, never open. A reading that fails
|
|
48
|
-
* leaves the permissions read before it.
|
|
53
|
+
* leaves the permissions read before it. A source that does not answer in
|
|
54
|
+
* `access.sourceTimeoutSeconds` has failed: it holds neither the start nor a
|
|
55
|
+
* request.
|
|
49
56
|
*/
|
|
50
|
-
export class
|
|
57
|
+
export class PermissionSourcesService {
|
|
51
58
|
private readonly sources: readonly Source[];
|
|
52
59
|
private readonly kinds: ReadonlySet<string>;
|
|
60
|
+
private changes = 0;
|
|
53
61
|
|
|
54
62
|
constructor(private readonly services: ServiceProvider, private readonly config: ConfigView<AccessConfig>) {
|
|
55
63
|
const { sources, kinds } = declarationsOf(services);
|
|
56
64
|
this.kinds = new Set(kinds.map((kind) => kind.kind));
|
|
57
65
|
this.sources = [...sources]
|
|
58
66
|
.sort((left, right) => left.prefix.localeCompare(right.prefix))
|
|
59
|
-
.map((source) => ({ ...source, permissions: new Map(), loaded: false, version: undefined, error: undefined, verifiedAt: 0, queue: Promise.resolve() }));
|
|
67
|
+
.map((source) => ({ ...source, permissions: new Map(), loaded: false, version: undefined, error: undefined, failures: 0, verifiedAt: 0, queue: Promise.resolve(), unanswered: undefined }));
|
|
60
68
|
}
|
|
61
69
|
|
|
62
70
|
/** The prefix of the source that owns the key, or `undefined` — the key is not of a source. */
|
|
@@ -74,7 +82,16 @@ export class PermissionSources {
|
|
|
74
82
|
}
|
|
75
83
|
|
|
76
84
|
state(): readonly PermissionSourceState[] {
|
|
77
|
-
return this.sources.map(({ prefix, module, loaded, version, error, permissions }) => ({ prefix, module, loaded, version, error, permissions: permissions.size }));
|
|
85
|
+
return this.sources.map(({ prefix, module, loaded, version, error, failures, permissions }) => ({ prefix, module, loaded, version, error, failures, permissions: permissions.size }));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Grows with every reading that changed the permissions: a list built from them is good until it does. */
|
|
89
|
+
get revision(): number {
|
|
90
|
+
return this.changes;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
get count(): number {
|
|
94
|
+
return this.sources.length;
|
|
78
95
|
}
|
|
79
96
|
|
|
80
97
|
/** The seconds between two questions about the versions; `0` — never ask. */
|
|
@@ -92,6 +109,13 @@ export class PermissionSources {
|
|
|
92
109
|
await Promise.all(this.sources.map((source) => this.enqueue(source, () => this.compare(source))));
|
|
93
110
|
}
|
|
94
111
|
|
|
112
|
+
/** Tries again the sources whose last reading failed: without a poll nothing else would. */
|
|
113
|
+
async retry(): Promise<void> {
|
|
114
|
+
await Promise.all(this.sources
|
|
115
|
+
.filter((source) => !source.loaded || source.error !== undefined)
|
|
116
|
+
.map((source) => this.enqueue(source, () => this.compare(source))));
|
|
117
|
+
}
|
|
118
|
+
|
|
95
119
|
/**
|
|
96
120
|
* The source of the prefix changed. With `keys` only these permissions are
|
|
97
121
|
* read now; the version stays the old one, so the next comparison reads the
|
|
@@ -113,7 +137,9 @@ export class PermissionSources {
|
|
|
113
137
|
const source = this.sources.find((item) => underPrefix(key, item.prefix));
|
|
114
138
|
if (source === undefined) return false;
|
|
115
139
|
return await this.enqueue(source, async () => {
|
|
116
|
-
|
|
140
|
+
// A clock set back gives a negative time: compare then, do not wait for the clock to catch up.
|
|
141
|
+
const since = Date.now() - source.verifiedAt;
|
|
142
|
+
if (!force && since >= 0 && since < VERIFY_EVERY_MS) return false;
|
|
117
143
|
return await this.compare(source);
|
|
118
144
|
});
|
|
119
145
|
}
|
|
@@ -129,9 +155,10 @@ export class PermissionSources {
|
|
|
129
155
|
source.verifiedAt = Date.now();
|
|
130
156
|
if (source.loaded) {
|
|
131
157
|
try {
|
|
132
|
-
const version = await this.use(source, async (instance) =>
|
|
158
|
+
const version = await this.use(source, async (instance) => versionOf(await instance.version()));
|
|
133
159
|
if (version === source.version) {
|
|
134
160
|
source.error = undefined;
|
|
161
|
+
source.failures = 0;
|
|
135
162
|
return false;
|
|
136
163
|
}
|
|
137
164
|
} catch (error) {
|
|
@@ -146,7 +173,7 @@ export class PermissionSources {
|
|
|
146
173
|
source.verifiedAt = Date.now();
|
|
147
174
|
try {
|
|
148
175
|
// The version first: data changed during the reading gives a newer version to the next comparison.
|
|
149
|
-
const { version, declarations } = await this.use(source, async (instance) => ({ version:
|
|
176
|
+
const { version, declarations } = await this.use(source, async (instance) => ({ version: versionOf(await instance.version()), declarations: await instance.load() }));
|
|
150
177
|
if (typeof declarations !== "object" || declarations === null || Array.isArray(declarations)) throw new TypeError("load() must return an object: key → permission");
|
|
151
178
|
const permissions = new Map<string, OwnedPermission>();
|
|
152
179
|
for (const key of Object.keys(declarations).sort()) {
|
|
@@ -157,6 +184,8 @@ export class PermissionSources {
|
|
|
157
184
|
source.version = version;
|
|
158
185
|
source.loaded = true;
|
|
159
186
|
source.error = undefined;
|
|
187
|
+
source.failures = 0;
|
|
188
|
+
this.changes += 1;
|
|
160
189
|
return true;
|
|
161
190
|
} catch (error) {
|
|
162
191
|
this.failed(source, error);
|
|
@@ -176,6 +205,9 @@ export class PermissionSources {
|
|
|
176
205
|
else permissions.set(key, permission);
|
|
177
206
|
}
|
|
178
207
|
source.permissions = new Map([...permissions].sort(([left], [right]) => left.localeCompare(right)));
|
|
208
|
+
source.error = undefined;
|
|
209
|
+
source.failures = 0;
|
|
210
|
+
this.changes += 1;
|
|
179
211
|
return true;
|
|
180
212
|
} catch (error) {
|
|
181
213
|
this.failed(source, error);
|
|
@@ -185,33 +217,61 @@ export class PermissionSources {
|
|
|
185
217
|
|
|
186
218
|
/** The permission, or `undefined` with the problems logged: it does not exist then. */
|
|
187
219
|
private accept(source: Source, key: string, spec: PermissionSpec): OwnedPermission | undefined {
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
220
|
+
let problems: string[];
|
|
221
|
+
let accepted: OwnedPermission | undefined;
|
|
222
|
+
try {
|
|
223
|
+
problems = underPrefix(key, source.prefix) ? permissionProblems(key, spec) : [`permission "${key}": the key must start with "${source.prefix}."`];
|
|
224
|
+
if (problems.length === 0) {
|
|
225
|
+
for (const [name, attribute] of Object.entries(spec.on ?? {})) {
|
|
226
|
+
if (attribute.ref !== undefined && !this.kinds.has(attribute.ref)) problems.push(`permission "${key}": attribute "${name}" refers to kind "${attribute.ref}", which no module declares in references`);
|
|
227
|
+
}
|
|
228
|
+
for (const lookup of lookupsOf(spec)) {
|
|
229
|
+
if (!this.services.has(lookup)) problems.push(`permission "${key}": ${lookup.name} is not registered: add it to providers of ${source.module}`);
|
|
230
|
+
}
|
|
195
231
|
}
|
|
232
|
+
if (problems.length === 0) accepted = { key, spec: frozenSpec(spec), module: source.module, source: source.prefix };
|
|
233
|
+
} catch (error) {
|
|
234
|
+
// Data of any shape may come from a source: what cannot even be checked is one bad permission, not a failed reading.
|
|
235
|
+
problems = [`permission "${key}": cannot be checked: ${error instanceof Error ? error.message : String(error)}`];
|
|
196
236
|
}
|
|
197
|
-
if (
|
|
198
|
-
|
|
199
|
-
return undefined;
|
|
200
|
-
}
|
|
201
|
-
return { key, spec: frozenSpec(spec), module: source.module, source: source.prefix };
|
|
237
|
+
if (accepted === undefined) this.services.tryResolve(LOGGER)?.error("access permission of a source rejected", { source: source.prefix, module: source.module, permission: key, problems });
|
|
238
|
+
return accepted;
|
|
202
239
|
}
|
|
203
240
|
|
|
241
|
+
/**
|
|
242
|
+
* One call of the source in a scope of its own, with a deadline. A call that
|
|
243
|
+
* ran out of time cannot be stopped: its scope is closed when it answers, and
|
|
244
|
+
* until then no new call starts, so a database that hangs is asked once.
|
|
245
|
+
*/
|
|
204
246
|
private async use<T>(source: Source, work: (instance: PermissionSource) => Promise<T>): Promise<T> {
|
|
247
|
+
if (source.unanswered !== undefined) throw new Error("the previous call of the source has not answered yet");
|
|
248
|
+
const seconds = this.config.get("sourceTimeoutSeconds");
|
|
205
249
|
const scope = this.services.createScope();
|
|
250
|
+
const working = (async () => await work(scope.resolve(source.type)))();
|
|
251
|
+
const answered = working.then(() => undefined, () => undefined).then(async () => {
|
|
252
|
+
source.unanswered = undefined;
|
|
253
|
+
await scope.dispose().catch(() => undefined);
|
|
254
|
+
});
|
|
255
|
+
let late = false;
|
|
256
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
257
|
+
const deadline = new Promise<never>((_, reject) => {
|
|
258
|
+
timer = setTimeout(() => {
|
|
259
|
+
late = true;
|
|
260
|
+
source.unanswered = answered;
|
|
261
|
+
reject(new Error(`the source did not answer in ${seconds} s (access.sourceTimeoutSeconds)`));
|
|
262
|
+
}, seconds * 1000);
|
|
263
|
+
});
|
|
206
264
|
try {
|
|
207
|
-
return await
|
|
265
|
+
return await Promise.race([working, deadline]);
|
|
208
266
|
} finally {
|
|
209
|
-
|
|
267
|
+
clearTimeout(timer);
|
|
268
|
+
if (!late) await answered;
|
|
210
269
|
}
|
|
211
270
|
}
|
|
212
271
|
|
|
213
272
|
private failed(source: Source, error: unknown): void {
|
|
214
273
|
source.error = error instanceof Error ? error.message : String(error);
|
|
274
|
+
source.failures += 1;
|
|
215
275
|
this.services.tryResolve(LOGGER)?.error("access permission source failed", {
|
|
216
276
|
source: source.prefix,
|
|
217
277
|
module: source.module,
|
|
@@ -220,3 +280,13 @@ export class PermissionSources {
|
|
|
220
280
|
});
|
|
221
281
|
}
|
|
222
282
|
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* A version is what the catalog compares to learn of a change: `undefined`,
|
|
286
|
+
* `null` or an empty string would look like a version that never changes.
|
|
287
|
+
*/
|
|
288
|
+
function versionOf(value: unknown): string {
|
|
289
|
+
if (typeof value === "string" && value !== "") return value;
|
|
290
|
+
if ((typeof value === "number" && Number.isFinite(value)) || typeof value === "bigint") return String(value);
|
|
291
|
+
throw new TypeError(`version() must return a non-empty string or a number, got ${value === null ? "null" : typeof value === "string" ? "an empty string" : typeof value}`);
|
|
292
|
+
}
|
|
@@ -17,7 +17,7 @@ import { isDeclaredRoles, rolePermission, type RoleDeclarations, type RolePermis
|
|
|
17
17
|
import { IAccess } from "../check/IAccess.service";
|
|
18
18
|
import type { DeclaredRole, OwnedPermission, OwnedReferenceKind } from "./AccessCatalog";
|
|
19
19
|
import { isDeclaredPermissions, isReferenceKind, isSourcePrefix, lookupsOf, permissionProblems, underPrefix, type PermissionDeclarations, type PermissionSpec } from "./permissions";
|
|
20
|
-
import type { OwnedPermissionSource } from "./PermissionSources";
|
|
20
|
+
import type { OwnedPermissionSource } from "./PermissionSources.service";
|
|
21
21
|
|
|
22
22
|
interface PermissionPayload { readonly key: string; readonly spec: PermissionSpec }
|
|
23
23
|
interface ReferencePayload { readonly kind: string; readonly type: Class<References> }
|
|
@@ -97,7 +97,9 @@ export function permissionProblems(key: string, spec: PermissionSpec): string[]
|
|
|
97
97
|
if (!isText(spec.title, 200)) problems.push(`${at}: title must be a non-empty text up to 200 characters`);
|
|
98
98
|
if (spec.description !== undefined && !isText(spec.description, 2000)) problems.push(`${at}: description must be a non-empty text up to 2000 characters`);
|
|
99
99
|
const scope = spec.scope;
|
|
100
|
-
if (
|
|
100
|
+
if (typeof scope !== "object" || scope === null) {
|
|
101
|
+
if (scope !== undefined && scope !== "none" && scope !== "request") problems.push(`${at}: scope must be "none", "request", { column } or { lookup }`);
|
|
102
|
+
} else {
|
|
101
103
|
const column = (scope as { column?: unknown }).column;
|
|
102
104
|
const lookup = (scope as { lookup?: unknown }).lookup;
|
|
103
105
|
if ((column === undefined) === (lookup === undefined)) problems.push(`${at}: scope must be "none", "request", { column } or { lookup }`);
|
|
@@ -126,7 +128,7 @@ export function permissionProblems(key: string, spec: PermissionSpec): string[]
|
|
|
126
128
|
/** The lookup classes a permission needs registered. */
|
|
127
129
|
export function lookupsOf(spec: PermissionSpec): Class<AttributeLookup>[] {
|
|
128
130
|
const lookups: Class<AttributeLookup>[] = [];
|
|
129
|
-
if (typeof spec.scope === "object" && "lookup" in spec.scope) lookups.push(spec.scope.lookup);
|
|
131
|
+
if (typeof spec.scope === "object" && spec.scope !== null && "lookup" in spec.scope) lookups.push(spec.scope.lookup);
|
|
130
132
|
for (const attribute of Object.values(spec.on ?? {})) if (attribute.lookup !== undefined) lookups.push(attribute.lookup);
|
|
131
133
|
return lookups;
|
|
132
134
|
}
|
|
@@ -149,7 +151,7 @@ export function underPrefix(key: string, prefix: string): boolean {
|
|
|
149
151
|
|
|
150
152
|
/** A frozen copy of a specification a source gave: the catalog keeps it, the source may change its own. */
|
|
151
153
|
export function frozenSpec(spec: PermissionSpec): PermissionSpec {
|
|
152
|
-
const scope = typeof spec.scope === "object" ? { ...spec.scope } : spec.scope;
|
|
154
|
+
const scope = typeof spec.scope === "object" && spec.scope !== null ? { ...spec.scope } : spec.scope;
|
|
153
155
|
const on = spec.on === undefined ? undefined : Object.fromEntries(Object.entries(spec.on).map(([name, attribute]) => [name, { ...attribute }]));
|
|
154
156
|
const title = typeof spec.title === "object" ? { ...spec.title } : spec.title;
|
|
155
157
|
const description = typeof spec.description === "object" ? { ...spec.description } : spec.description;
|
|
@@ -15,12 +15,15 @@ export interface AccessConfig {
|
|
|
15
15
|
* `IAccessCatalog.changed` on every instance itself.
|
|
16
16
|
*/
|
|
17
17
|
sourcePollSeconds: number;
|
|
18
|
+
/** Seconds a permission source has for one answer; after them the reading has failed. */
|
|
19
|
+
sourceTimeoutSeconds: number;
|
|
18
20
|
}
|
|
19
21
|
|
|
20
22
|
export const accessConfig = defineConfig<AccessConfig>("access", {
|
|
21
|
-
default: { kindClaim: "", roleClaim: "role", maxInList: 1000, sourcePollSeconds: 5 },
|
|
23
|
+
default: { kindClaim: "", roleClaim: "role", maxInList: 1000, sourcePollSeconds: 5, sourceTimeoutSeconds: 10 },
|
|
22
24
|
validate: {
|
|
23
25
|
maxInList: (value) => Number.isSafeInteger(value) && value >= 1 && value <= 100_000 ? undefined : "must be an integer 1–100000",
|
|
24
26
|
sourcePollSeconds: (value) => Number.isFinite(value) && value >= 0 && value <= 3600 ? undefined : "must be a number 0–3600",
|
|
27
|
+
sourceTimeoutSeconds: (value) => Number.isFinite(value) && value > 0 && value <= 600 ? undefined : "must be a number above 0, up to 600",
|
|
25
28
|
},
|
|
26
29
|
});
|
|
@@ -20,9 +20,10 @@ export abstract class PermissionSource {
|
|
|
20
20
|
* The version of the source's data: a counter or a timestamp that changes in
|
|
21
21
|
* one transaction with the data. Every instance of the application asks it
|
|
22
22
|
* every `access.sourcePollSeconds` and reads the permissions again when it
|
|
23
|
-
* differs.
|
|
23
|
+
* differs. Nothing — `undefined`, `null`, an empty string — is a failed
|
|
24
|
+
* reading: it would look like a version that never changes.
|
|
24
25
|
*/
|
|
25
|
-
abstract version():
|
|
26
|
+
abstract version(): PermissionSourceVersion | Promise<PermissionSourceVersion>;
|
|
26
27
|
|
|
27
28
|
/**
|
|
28
29
|
* The permissions of the prefix, in the form of `definePermissions`. With
|
|
@@ -30,3 +31,6 @@ export abstract class PermissionSource {
|
|
|
30
31
|
*/
|
|
31
32
|
abstract load(keys?: readonly string[]): PermissionDeclarations | Promise<PermissionDeclarations>;
|
|
32
33
|
}
|
|
34
|
+
|
|
35
|
+
/** A counter (a number, a bigint) or any non-empty string: the catalog only tells whether it differs. */
|
|
36
|
+
export type PermissionSourceVersion = string | number | bigint;
|
|
@@ -4,15 +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 {
|
|
7
|
+
import { PermissionSourceLoader } from "../catalog/PermissionSourceLoader.background";
|
|
8
|
+
import { PermissionSourcesService } from "../catalog/PermissionSources.service";
|
|
9
9
|
import { Access } from "../check/Access";
|
|
10
10
|
import { RolesAccessPolicy } from "../roles/RolesAccessPolicy";
|
|
11
11
|
|
|
12
12
|
registerFrameworkClassDeps([
|
|
13
13
|
[Access, [IAccessCatalog, "Config:access"]],
|
|
14
|
-
[AccessCatalog, [ServiceProvider,
|
|
15
|
-
[PermissionSourceLoader, [
|
|
16
|
-
[
|
|
14
|
+
[AccessCatalog, [ServiceProvider, PermissionSourcesService, "Config:access"]],
|
|
15
|
+
[PermissionSourceLoader, [PermissionSourcesService]],
|
|
16
|
+
[PermissionSourcesService, [ServiceProvider, "Config:access"]],
|
|
17
17
|
[RolesAccessPolicy, [IAccessCatalog, "Config:access"]],
|
|
18
18
|
]);
|
package/core/access/index.ts
CHANGED
|
@@ -26,7 +26,7 @@ export interface BazisPermissionSources {}
|
|
|
26
26
|
|
|
27
27
|
export { AccessModule } from "./Access.module";
|
|
28
28
|
export { definePermissions, type AnyPermissionKey, type AttributeSpec, type OwnPermissionKey, type PermissionDeclarations, type PermissionKey, type PermissionsOf, type PermissionSpec, type ScopeSource, type SourcePermissionKey } from "./catalog/permissions";
|
|
29
|
-
export type { PermissionSourceState } from "./catalog/PermissionSources";
|
|
29
|
+
export type { PermissionSourceState } from "./catalog/PermissionSources.service";
|
|
30
30
|
export { IAccessCatalog } from "./catalog/IAccessCatalog.service";
|
|
31
31
|
export type { DeclaredRole, OwnedPermission, OwnedReferenceKind } from "./catalog/AccessCatalog";
|
|
32
32
|
export { IAccess } from "./check/IAccess.service";
|
|
@@ -36,7 +36,7 @@ export { can } from "./check/can";
|
|
|
36
36
|
export { grantsToWhere, type AccessFilter, type ColumnCondition } from "./check/filter";
|
|
37
37
|
export { AccessPolicy, type ScopePrincipals } from "./contracts/AccessPolicy";
|
|
38
38
|
export { AttributeLookup, type LookupColumn } from "./contracts/AttributeLookup";
|
|
39
|
-
export { PermissionSource } from "./contracts/PermissionSource";
|
|
39
|
+
export { PermissionSource, type PermissionSourceVersion } from "./contracts/PermissionSource";
|
|
40
40
|
export { References, type ReferenceContext, type ReferenceItem, type ReferencePage } from "./contracts/References";
|
|
41
41
|
export { AccessFilterTooLargeError, AccessSetupError } from "./contracts/errors";
|
|
42
42
|
export type {
|
|
@@ -232,7 +232,7 @@ export class CodexAppServerClient implements CodexClient {
|
|
|
232
232
|
if (method === "account/updated") this.modelCache = undefined;
|
|
233
233
|
}, () => { this.clearLogin(); this.modelCache = undefined; });
|
|
234
234
|
const initialized = await client.request("initialize", {
|
|
235
|
-
clientInfo: { name: "bazis", title: "Bazis", version: "0.140.
|
|
235
|
+
clientInfo: { name: "bazis", title: "Bazis", version: "0.140.1" }, capabilities: { experimentalApi: true },
|
|
236
236
|
}, 15_000, signal);
|
|
237
237
|
if (resolve(text(initialized.codexHome, 4096)) !== this.paths.home) throw new CodexError("PROTOCOL_ERROR");
|
|
238
238
|
client.notify("initialized");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bazis",
|
|
3
|
-
"version": "0.140.
|
|
3
|
+
"version": "0.140.1",
|
|
4
4
|
"description": "Modular backend framework for Bun in TypeScript: DI wired by codegen, HTTP, a PostgreSQL ORM, JWT, WebSocket, agents and a CLI.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Alexander Alenin",
|