@ultimabase/roles 0.0.0-stage → 0.1.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/CHANGELOG.md ADDED
@@ -0,0 +1,19 @@
1
+ # @ultimabase/roles
2
+
3
+ ## 0.1.1
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies
8
+ - ultimabase@0.1.1
9
+
10
+ ## 0.1.0
11
+
12
+ ### Minor Changes
13
+
14
+ - 0e885c4: The first release: the kernel, its adapters, transports and plugins, the testing helpers and the starter.
15
+
16
+ ### Patch Changes
17
+
18
+ - Updated dependencies [0e885c4]
19
+ - ultimabase@0.1.0
package/README.md CHANGED
@@ -1,3 +1,5 @@
1
- # Temporary Holding Version
1
+ # @ultimabase/roles
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Roles for UltimaBase: users get roles, such as `writer` or `team`, roles hold other roles, and the access rule `hasRole` asks whether a caller has one.
4
+
5
+ Documentation: [Roles](https://ultimabase.dev/docs/roles).
@@ -0,0 +1,42 @@
1
+ import type { Field, FieldShapes, IndexShape, Many, ModelFields, ModelIndexes, ModelOf, ModelUse, PluginTypes } from 'ultimabase';
2
+ import type { Roles } from './plugin.ts';
3
+ type Uses = readonly ModelUse<string, PluginTypes>[];
4
+ /** The role model's `use`: roles first, then the developer's plugins. */
5
+ type WithRoles<U extends Uses> = readonly [ModelUse<'roles', Roles>, ...U];
6
+ /** The fields `defineRole` adds; `R` is the role model's own thunk. */
7
+ export type RoleFields<M, R> = {
8
+ readonly name: Field<string, {
9
+ readonly unique: true;
10
+ }>;
11
+ readonly members: Many<M, {
12
+ readonly inverse?: string;
13
+ }>;
14
+ readonly inherits: Many<R, {
15
+ readonly inverse: 'inheritedBy';
16
+ }>;
17
+ readonly inheritedBy: Many<R, {
18
+ readonly inverse: 'inherits';
19
+ }>;
20
+ };
21
+ /** The role model, typed through an interface: it refers to itself. */
22
+ export interface RoleThunk<N extends string, F, M, U extends Uses, X extends readonly IndexShape[]> {
23
+ (): ModelOf<N, F & RoleFields<M, RoleThunk<N, F, M, U, X>>, WithRoles<U>, X>;
24
+ }
25
+ /** What every role model declares; `F` types `indexes` only. */
26
+ type RoleDefinition<N, M, F, U extends Uses, X> = {
27
+ readonly name: N;
28
+ /** The stored table's or collection's name (D-63). */
29
+ readonly dbName?: string;
30
+ /** The user model, `() => User`. */
31
+ readonly members: M;
32
+ readonly use?: U;
33
+ readonly indexes?: ModelIndexes<X, F & RoleFields<M, () => object>, WithRoles<U>>;
34
+ };
35
+ /** The application's one role model, with its own fields (D-123). */
36
+ export declare function defineRole<const N extends string, const M extends () => object, const F extends FieldShapes, const U extends Uses = [], const X extends readonly IndexShape[] = []>(definition: RoleDefinition<N, M, F, U, X> & {
37
+ readonly fields: ModelFields<F, WithRoles<U>>;
38
+ }): ReturnType<RoleThunk<N, F, M, U, X>>;
39
+ /** The application's one role model, without fields of its own (D-123). */
40
+ export declare function defineRole<const N extends string, const M extends () => object, const U extends Uses = [], const X extends readonly IndexShape[] = []>(definition: RoleDefinition<N, M, {}, U, X>): ReturnType<RoleThunk<N, {}, M, U, X>>;
41
+ export {};
42
+ //# sourceMappingURL=define-role.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"define-role.d.ts","sourceRoot":"","sources":["../src/define-role.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,36 @@
1
+ import { defineModel, Refused } from 'ultimabase';
2
+ import { roleModels, rolesUse } from './plugin.js';
3
+ const reserved = ['name', 'members', 'inherits', 'inheritedBy'];
4
+ export function defineRole(definition) {
5
+ const { name, members, fields } = definition;
6
+ if (typeof members !== 'function') {
7
+ throw new Refused(`defineRole(${name}): members is () => the user model.`);
8
+ }
9
+ const self = () => model;
10
+ const model = defineModel({
11
+ name,
12
+ ...(definition.dbName === undefined ? {} : { dbName: definition.dbName }),
13
+ use: [rolesUse(), ...(definition.use ?? [])],
14
+ fields: (f) => {
15
+ // The developer's fields, typed by its own `use`.
16
+ const own = fields === undefined ? {} : Reflect.apply(fields, undefined, [f]);
17
+ if (typeof own !== 'object' || own === null) {
18
+ throw new Refused(`defineRole(${name}): fields returns an object.`);
19
+ }
20
+ const taken = reserved.find((key) => Object.hasOwn(own, key));
21
+ if (taken !== undefined) {
22
+ throw new Refused(`defineRole(${name}): ${taken} is a field defineRole adds.`);
23
+ }
24
+ return {
25
+ ...own,
26
+ name: f.string({ unique: true }),
27
+ members: f.many(members),
28
+ inherits: f.many(self, { inverse: 'inheritedBy' }),
29
+ inheritedBy: f.many(self, { inverse: 'inherits' }),
30
+ };
31
+ },
32
+ indexes: definition.indexes ?? [],
33
+ });
34
+ roleModels.add(model);
35
+ return model;
36
+ }
@@ -0,0 +1,5 @@
1
+ export type { RoleFields, RoleThunk } from './define-role.ts';
2
+ export { defineRole } from './define-role.ts';
3
+ export type { Roles } from './plugin.ts';
4
+ export { hasRole, rolesOf, rolesPlugin } from './plugin.ts';
5
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,YAAY,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAA;AAC7D,OAAO,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAA;AAC7C,YAAY,EAAE,KAAK,EAAE,MAAM,aAAa,CAAA;AACxC,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,WAAW,EAAE,MAAM,aAAa,CAAA"}
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { defineRole } from './define-role.js';
2
+ export { hasRole, rolesOf, rolesPlugin } from './plugin.js';
@@ -0,0 +1,27 @@
1
+ import type { Caller, CallerRule, PluginOf, PluginTypes, UseOf } from 'ultimabase';
2
+ /** The roles plugin's types (D-57). */
3
+ export interface Roles extends PluginTypes {
4
+ readonly name: 'roles';
5
+ }
6
+ /** The models `defineRole` returned. */
7
+ export declare const roleModels: WeakSet<object>;
8
+ /** Each user caller's roles, read once per caller (D-123). */
9
+ export declare const rolesPlugin: PluginOf<Roles>;
10
+ /** The roles plugin's entry in the role model's `use`, which `defineRole` adds. */
11
+ export declare const rolesUse: UseOf<Roles>;
12
+ /**
13
+ * The caller's roles, each `{ id, name }`, inherited ones included; `[]` for
14
+ * a caller the roles plugin did not complete (D-126).
15
+ */
16
+ export declare function rolesOf(caller: Caller): readonly {
17
+ readonly id: string;
18
+ readonly name: string;
19
+ }[];
20
+ /**
21
+ * Whether the caller has one of the roles, inherited ones included; false
22
+ * for an anonymous or Master caller (D-126). Without the caller, the rule
23
+ * asking it (D-10).
24
+ */
25
+ export declare function hasRole(...names: readonly [string, ...string[]]): CallerRule;
26
+ export declare function hasRole(caller: Caller, ...names: readonly [string, ...string[]]): boolean;
27
+ //# sourceMappingURL=plugin.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EACX,MAAM,EACN,UAAU,EAGV,QAAQ,EACR,WAAW,EACX,KAAK,EACL,MAAM,YAAY,CAAA;AAInB,uCAAuC;AACvC,MAAM,WAAW,KAAM,SAAQ,WAAW;IACzC,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAA;CACtB;AAID,wCAAwC;AACxC,eAAO,MAAM,UAAU,EAAE,OAAO,CAAC,MAAM,CAAiB,CAAA;AAwGxD,8DAA8D;AAC9D,eAAO,MAAM,WAAW,EAAE,QAAQ,CAAC,KAAK,CAAgB,CAAA;AACxD,mFAAmF;AACnF,eAAO,MAAM,QAAQ,EAAE,KAAK,CAAC,KAAK,CAAa,CAAA;AAE/C;;;GAGG;AACH,wBAAgB,OAAO,CACtB,MAAM,EAAE,MAAM,GACZ,SAAS;IAAE,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;CAAE,EAAE,CAG3D;AAaD;;;;GAIG;AACH,wBAAgB,OAAO,CAAC,GAAG,KAAK,EAAE,SAAS,CAAC,MAAM,EAAE,GAAG,MAAM,EAAE,CAAC,GAAG,UAAU,CAAA;AAC7E,wBAAgB,OAAO,CACtB,MAAM,EAAE,MAAM,EACd,GAAG,KAAK,EAAE,SAAS,CAAC,MAAM,EAAE,GAAG,MAAM,EAAE,CAAC,GACtC,OAAO,CAAA"}
package/dist/plugin.js ADDED
@@ -0,0 +1,115 @@
1
+ import { definePlugin, master, Refused } from 'ultimabase';
2
+ import manifest from '../package.json' with { type: 'json' };
3
+ /** The models `defineRole` returned. */
4
+ export const roleModels = new WeakSet();
5
+ /** The caller's key holding its roles, which `@ultimabase/roles` keeps (D-126). */
6
+ const rolesKey = Symbol('roles');
7
+ /** The lists of roles this plugin gave a caller. */
8
+ const given = new WeakSet();
9
+ const M = { as: master };
10
+ /** The one role model among the application's models (D-123). */
11
+ function rolesModelOf(models) {
12
+ const [first, second] = models.filter(({ queries }) => roleModels.has(queries));
13
+ if (first === undefined) {
14
+ throw new Refused('rolesPlugin() needs a role model: declare it with defineRole.');
15
+ }
16
+ if (second !== undefined) {
17
+ throw new Refused('models lists two models defineRole returned: an application has one role model.');
18
+ }
19
+ return first.queries;
20
+ }
21
+ /** The levels of inheritance one read follows (D-123). */
22
+ const depth = 5;
23
+ /** A role's id and name, and its inherited roles `left` levels deep. */
24
+ const level = (left) => ({
25
+ id: true,
26
+ name: true,
27
+ inherits: { select: left === 0 ? { id: true } : level(left - 1) },
28
+ });
29
+ /**
30
+ * Adds a read role and the roles it inherits to `held`; a role read past
31
+ * the last level, its id alone, goes to `missing`. A role already held was
32
+ * followed, which ends a cycle.
33
+ */
34
+ function follow(row, held, missing) {
35
+ const id = Reflect.get(Object(row), 'id');
36
+ const name = Reflect.get(Object(row), 'name');
37
+ if (typeof id !== 'string' || held.has(id))
38
+ return;
39
+ if (typeof name !== 'string') {
40
+ missing.add(id);
41
+ return;
42
+ }
43
+ held.set(id, Object.freeze({ id, name }));
44
+ const parents = Reflect.get(Object(row), 'inherits');
45
+ for (const parent of Array.isArray(parents) ? parents : []) {
46
+ follow(parent, held, missing);
47
+ }
48
+ }
49
+ /**
50
+ * Every role of a user: the roles it is a member of and those they inherit,
51
+ * five levels per query (D-123).
52
+ */
53
+ async function rolesOfUser(id, queries) {
54
+ const held = new Map();
55
+ const select = level(depth);
56
+ let where = { members: { some: { id } } };
57
+ for (;;) {
58
+ const missing = new Set();
59
+ for (const row of await queries.findMany({ where, select }, M)) {
60
+ follow(row, held, missing);
61
+ }
62
+ const next = [...missing].filter((role) => !held.has(role));
63
+ if (next.length === 0)
64
+ break;
65
+ where = { id: { in: next } };
66
+ }
67
+ const roles = Object.freeze([...held.values()]);
68
+ given.add(roles);
69
+ return roles;
70
+ }
71
+ const isHeld = (value) => typeof value === 'object' && value !== null && given.has(value);
72
+ const roles = definePlugin({
73
+ name: 'roles',
74
+ manifest,
75
+ start: ({ models }) => {
76
+ rolesModelOf(models);
77
+ },
78
+ caller: async ({ user }, { models }) => {
79
+ // Built apart: a key no interface declares is not an excess one.
80
+ const keys = {
81
+ [rolesKey]: await rolesOfUser(user.id, rolesModelOf(models)),
82
+ };
83
+ return keys;
84
+ },
85
+ });
86
+ /** Each user caller's roles, read once per caller (D-123). */
87
+ export const rolesPlugin = roles.plugin;
88
+ /** The roles plugin's entry in the role model's `use`, which `defineRole` adds. */
89
+ export const rolesUse = roles.use;
90
+ /**
91
+ * The caller's roles, each `{ id, name }`, inherited ones included; `[]` for
92
+ * a caller the roles plugin did not complete (D-126).
93
+ */
94
+ export function rolesOf(caller) {
95
+ const held = Reflect.get(caller, rolesKey);
96
+ return isHeld(held) ? held : [];
97
+ }
98
+ function holds(caller, names) {
99
+ if (caller.kind !== 'user')
100
+ return false;
101
+ const held = Reflect.get(caller, rolesKey);
102
+ if (!isHeld(held)) {
103
+ throw new Error('hasRole reads the roles rolesPlugin() gives a caller: add rolesPlugin() to plugins.');
104
+ }
105
+ return held.some(({ name }) => names.includes(name));
106
+ }
107
+ export function hasRole(first, ...names) {
108
+ if (typeof first !== 'string') {
109
+ if (names.length === 0)
110
+ throw new TypeError('hasRole takes a role name.');
111
+ return holds(first, names);
112
+ }
113
+ const wanted = [first, ...names];
114
+ return ({ caller }) => holds(caller, wanted);
115
+ }
package/package.json CHANGED
@@ -1,6 +1,45 @@
1
1
  {
2
2
  "name": "@ultimabase/roles",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.1",
4
+ "description": "Roles for UltimaBase: a role model, roles inside roles, and the rule asking a caller's role.",
5
+ "type": "module",
6
+ "license": "Apache-2.0",
7
+ "homepage": "https://ultimabase.dev/docs/roles",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/MrEkinox/ultimabase.git",
11
+ "directory": "packages/roles"
12
+ },
13
+ "engines": {
14
+ "node": ">=24.7.0"
15
+ },
16
+ "exports": {
17
+ ".": "./dist/index.js"
18
+ },
19
+ "files": [
20
+ "dist",
21
+ "src",
22
+ "!src/**/*.test.ts",
23
+ "!src/test"
24
+ ],
25
+ "scripts": {
26
+ "prepack": "rm -rf dist && yarn run -T tsc --project tsconfig.build.json"
27
+ },
28
+ "publishConfig": {
29
+ "exports": {
30
+ ".": "./dist/index.js"
31
+ }
32
+ },
33
+ "peerDependencies": {
34
+ "ultimabase": "^0.1.1"
35
+ },
36
+ "devDependencies": {
37
+ "@types/pg": "8.23.1",
38
+ "@ultimabase/postgres": "0.1.1",
39
+ "@ultimabase/testing": "0.1.1",
40
+ "pg": "8.23.1",
41
+ "ultimabase": "0.1.1",
42
+ "vite": "8.3.4",
43
+ "vitest": "5.0.5"
44
+ }
6
45
  }
@@ -0,0 +1,128 @@
1
+ // `defineRole` (D-13, D-123): the application's one role model, an ordinary
2
+ // model with a unique name, its members, and the roles it inherits.
3
+ import type {
4
+ Field,
5
+ FieldShapes,
6
+ IndexShape,
7
+ Many,
8
+ ModelFields,
9
+ ModelIndexes,
10
+ ModelOf,
11
+ ModelUse,
12
+ PluginTypes,
13
+ } from 'ultimabase'
14
+ import { defineModel, Refused } from 'ultimabase'
15
+ import type { Roles } from './plugin.ts'
16
+ import { roleModels, rolesUse } from './plugin.ts'
17
+
18
+ type Uses = readonly ModelUse<string, PluginTypes>[]
19
+ /** The role model's `use`: roles first, then the developer's plugins. */
20
+ type WithRoles<U extends Uses> = readonly [ModelUse<'roles', Roles>, ...U]
21
+
22
+ /** The fields `defineRole` adds; `R` is the role model's own thunk. */
23
+ export type RoleFields<M, R> = {
24
+ readonly name: Field<string, { readonly unique: true }>
25
+ readonly members: Many<M, { readonly inverse?: string }>
26
+ readonly inherits: Many<R, { readonly inverse: 'inheritedBy' }>
27
+ readonly inheritedBy: Many<R, { readonly inverse: 'inherits' }>
28
+ }
29
+
30
+ /** The role model, typed through an interface: it refers to itself. */
31
+ export interface RoleThunk<
32
+ N extends string,
33
+ F,
34
+ M,
35
+ U extends Uses,
36
+ X extends readonly IndexShape[],
37
+ > {
38
+ (): ModelOf<N, F & RoleFields<M, RoleThunk<N, F, M, U, X>>, WithRoles<U>, X>
39
+ }
40
+
41
+ const reserved = ['name', 'members', 'inherits', 'inheritedBy']
42
+
43
+ /** What every role model declares; `F` types `indexes` only. */
44
+ type RoleDefinition<N, M, F, U extends Uses, X> = {
45
+ readonly name: N
46
+ /** The stored table's or collection's name (D-63). */
47
+ readonly dbName?: string
48
+ /** The user model, `() => User`. */
49
+ readonly members: M
50
+ readonly use?: U
51
+ // Checked on the scalar fields only: the role model's own type would
52
+ // infer `X` from itself.
53
+ readonly indexes?: ModelIndexes<
54
+ X,
55
+ F & RoleFields<M, () => object>,
56
+ WithRoles<U>
57
+ >
58
+ }
59
+
60
+ /** The application's one role model, with its own fields (D-123). */
61
+ export function defineRole<
62
+ const N extends string,
63
+ const M extends () => object,
64
+ const F extends FieldShapes,
65
+ const U extends Uses = [],
66
+ const X extends readonly IndexShape[] = [],
67
+ >(
68
+ definition: RoleDefinition<N, M, F, U, X> & {
69
+ readonly fields: ModelFields<F, WithRoles<U>>
70
+ },
71
+ ): ReturnType<RoleThunk<N, F, M, U, X>>
72
+ /** The application's one role model, without fields of its own (D-123). */
73
+ export function defineRole<
74
+ const N extends string,
75
+ const M extends () => object,
76
+ const U extends Uses = [],
77
+ const X extends readonly IndexShape[] = [],
78
+ >(
79
+ definition: RoleDefinition<N, M, {}, U, X>,
80
+ ): ReturnType<RoleThunk<N, {}, M, U, X>>
81
+ export function defineRole(definition: {
82
+ readonly name: string
83
+ readonly dbName?: string
84
+ readonly members: unknown
85
+ readonly use?: Uses
86
+ readonly fields?: Function
87
+ readonly indexes?: readonly IndexShape[]
88
+ }): object {
89
+ const { name, members, fields } = definition
90
+ if (typeof members !== 'function') {
91
+ throw new Refused(`defineRole(${name}): members is () => the user model.`)
92
+ }
93
+ const self = (): object => model
94
+ const model: object = defineModel<
95
+ string,
96
+ FieldShapes,
97
+ Uses,
98
+ readonly IndexShape[]
99
+ >({
100
+ name,
101
+ ...(definition.dbName === undefined ? {} : { dbName: definition.dbName }),
102
+ use: [rolesUse(), ...(definition.use ?? [])],
103
+ fields: (f) => {
104
+ // The developer's fields, typed by its own `use`.
105
+ const own: unknown =
106
+ fields === undefined ? {} : Reflect.apply(fields, undefined, [f])
107
+ if (typeof own !== 'object' || own === null) {
108
+ throw new Refused(`defineRole(${name}): fields returns an object.`)
109
+ }
110
+ const taken = reserved.find((key) => Object.hasOwn(own, key))
111
+ if (taken !== undefined) {
112
+ throw new Refused(
113
+ `defineRole(${name}): ${taken} is a field defineRole adds.`,
114
+ )
115
+ }
116
+ return {
117
+ ...own,
118
+ name: f.string({ unique: true }),
119
+ members: f.many(members),
120
+ inherits: f.many(self, { inverse: 'inheritedBy' }),
121
+ inheritedBy: f.many(self, { inverse: 'inherits' }),
122
+ }
123
+ },
124
+ indexes: definition.indexes ?? [],
125
+ })
126
+ roleModels.add(model)
127
+ return model
128
+ }
package/src/index.ts ADDED
@@ -0,0 +1,6 @@
1
+ // `@ultimabase/roles` (D-123, D-126): the role model, roles inside roles, and
2
+ // the question whether a caller has a role.
3
+ export type { RoleFields, RoleThunk } from './define-role.ts'
4
+ export { defineRole } from './define-role.ts'
5
+ export type { Roles } from './plugin.ts'
6
+ export { hasRole, rolesOf, rolesPlugin } from './plugin.ts'
package/src/plugin.ts ADDED
@@ -0,0 +1,175 @@
1
+ // `rolesPlugin()` (D-123, D-126): each user caller completed with every role
2
+ // the user has, inherited ones included, under a key no other code names;
3
+ // `hasRole` asks it, `rolesOf` lists it.
4
+ import type {
5
+ Caller,
6
+ CallerRule,
7
+ ExposedModel,
8
+ ModelQueries,
9
+ PluginOf,
10
+ PluginTypes,
11
+ UseOf,
12
+ } from 'ultimabase'
13
+ import { definePlugin, master, Refused } from 'ultimabase'
14
+ import manifest from '../package.json' with { type: 'json' }
15
+
16
+ /** The roles plugin's types (D-57). */
17
+ export interface Roles extends PluginTypes {
18
+ readonly name: 'roles'
19
+ }
20
+
21
+ type Held = { readonly id: string; readonly name: string }
22
+
23
+ /** The models `defineRole` returned. */
24
+ export const roleModels: WeakSet<object> = new WeakSet()
25
+
26
+ /** The caller's key holding its roles, which `@ultimabase/roles` keeps (D-126). */
27
+ const rolesKey = Symbol('roles')
28
+ /** The lists of roles this plugin gave a caller. */
29
+ const given = new WeakSet<object>()
30
+
31
+ const M = { as: master }
32
+
33
+ /** The one role model among the application's models (D-123). */
34
+ function rolesModelOf(models: readonly ExposedModel[]): ModelQueries {
35
+ const [first, second] = models.filter(({ queries }) =>
36
+ roleModels.has(queries),
37
+ )
38
+ if (first === undefined) {
39
+ throw new Refused(
40
+ 'rolesPlugin() needs a role model: declare it with defineRole.',
41
+ )
42
+ }
43
+ if (second !== undefined) {
44
+ throw new Refused(
45
+ 'models lists two models defineRole returned: an application has one role model.',
46
+ )
47
+ }
48
+ return first.queries
49
+ }
50
+
51
+ /** The levels of inheritance one read follows (D-123). */
52
+ const depth = 5
53
+
54
+ /** A role's id and name, and its inherited roles `left` levels deep. */
55
+ const level = (left: number): object => ({
56
+ id: true,
57
+ name: true,
58
+ inherits: { select: left === 0 ? { id: true } : level(left - 1) },
59
+ })
60
+
61
+ /**
62
+ * Adds a read role and the roles it inherits to `held`; a role read past
63
+ * the last level, its id alone, goes to `missing`. A role already held was
64
+ * followed, which ends a cycle.
65
+ */
66
+ function follow(
67
+ row: unknown,
68
+ held: Map<string, Held>,
69
+ missing: Set<string>,
70
+ ): void {
71
+ const id: unknown = Reflect.get(Object(row), 'id')
72
+ const name: unknown = Reflect.get(Object(row), 'name')
73
+ if (typeof id !== 'string' || held.has(id)) return
74
+ if (typeof name !== 'string') {
75
+ missing.add(id)
76
+ return
77
+ }
78
+ held.set(id, Object.freeze({ id, name }))
79
+ const parents: unknown = Reflect.get(Object(row), 'inherits')
80
+ for (const parent of Array.isArray(parents) ? parents : []) {
81
+ follow(parent, held, missing)
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Every role of a user: the roles it is a member of and those they inherit,
87
+ * five levels per query (D-123).
88
+ */
89
+ async function rolesOfUser(
90
+ id: string,
91
+ queries: ModelQueries,
92
+ ): Promise<readonly Held[]> {
93
+ const held = new Map<string, Held>()
94
+ const select = level(depth)
95
+ let where: object = { members: { some: { id } } }
96
+ for (;;) {
97
+ const missing = new Set<string>()
98
+ for (const row of await queries.findMany({ where, select }, M)) {
99
+ follow(row, held, missing)
100
+ }
101
+ const next = [...missing].filter((role) => !held.has(role))
102
+ if (next.length === 0) break
103
+ where = { id: { in: next } }
104
+ }
105
+ const roles = Object.freeze([...held.values()])
106
+ given.add(roles)
107
+ return roles
108
+ }
109
+
110
+ const isHeld = (value: unknown): value is readonly Held[] =>
111
+ typeof value === 'object' && value !== null && given.has(value)
112
+
113
+ const roles = definePlugin<Roles>({
114
+ name: 'roles',
115
+ manifest,
116
+ start: ({ models }) => {
117
+ rolesModelOf(models)
118
+ },
119
+ caller: async ({ user }, { models }) => {
120
+ // Built apart: a key no interface declares is not an excess one.
121
+ const keys = {
122
+ [rolesKey]: await rolesOfUser(user.id, rolesModelOf(models)),
123
+ }
124
+ return keys
125
+ },
126
+ })
127
+
128
+ /** Each user caller's roles, read once per caller (D-123). */
129
+ export const rolesPlugin: PluginOf<Roles> = roles.plugin
130
+ /** The roles plugin's entry in the role model's `use`, which `defineRole` adds. */
131
+ export const rolesUse: UseOf<Roles> = roles.use
132
+
133
+ /**
134
+ * The caller's roles, each `{ id, name }`, inherited ones included; `[]` for
135
+ * a caller the roles plugin did not complete (D-126).
136
+ */
137
+ export function rolesOf(
138
+ caller: Caller,
139
+ ): readonly { readonly id: string; readonly name: string }[] {
140
+ const held: unknown = Reflect.get(caller, rolesKey)
141
+ return isHeld(held) ? held : []
142
+ }
143
+
144
+ function holds(caller: Caller, names: readonly string[]): boolean {
145
+ if (caller.kind !== 'user') return false
146
+ const held: unknown = Reflect.get(caller, rolesKey)
147
+ if (!isHeld(held)) {
148
+ throw new Error(
149
+ 'hasRole reads the roles rolesPlugin() gives a caller: add rolesPlugin() to plugins.',
150
+ )
151
+ }
152
+ return held.some(({ name }) => names.includes(name))
153
+ }
154
+
155
+ /**
156
+ * Whether the caller has one of the roles, inherited ones included; false
157
+ * for an anonymous or Master caller (D-126). Without the caller, the rule
158
+ * asking it (D-10).
159
+ */
160
+ export function hasRole(...names: readonly [string, ...string[]]): CallerRule
161
+ export function hasRole(
162
+ caller: Caller,
163
+ ...names: readonly [string, ...string[]]
164
+ ): boolean
165
+ export function hasRole(
166
+ first: Caller | string,
167
+ ...names: readonly string[]
168
+ ): CallerRule | boolean {
169
+ if (typeof first !== 'string') {
170
+ if (names.length === 0) throw new TypeError('hasRole takes a role name.')
171
+ return holds(first, names)
172
+ }
173
+ const wanted = [first, ...names]
174
+ return ({ caller }) => holds(caller, wanted)
175
+ }