@dudousxd/nestjs-catalog 0.2.1 → 0.3.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/README.md +40 -0
- package/dist/access.controller.d.ts +10 -0
- package/dist/access.controller.js +150 -0
- package/dist/catalog.access.d.ts +172 -0
- package/dist/catalog.access.js +37 -0
- package/dist/catalog.module.js +23 -1
- package/dist/catalog.options.d.ts +29 -0
- package/dist/catalog.pipeline.js +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +5 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -84,6 +84,46 @@ overlay store (a JSON file by default; bring your own via `overlayStore`).
|
|
|
84
84
|
searched columns against the catalog before anything reaches SQL, selects only
|
|
85
85
|
the columns the catalog says are visible, and caps the page size.
|
|
86
86
|
|
|
87
|
+
## Who may reach it
|
|
88
|
+
|
|
89
|
+
The console's Access screen asks two questions with different owners, and the
|
|
90
|
+
library treats them differently on purpose.
|
|
91
|
+
|
|
92
|
+
**Applications** are the catalog's own. `catalog_principal` is a table this
|
|
93
|
+
library defines and the grants on it are catalog grants, so
|
|
94
|
+
`CatalogMikroOrmStoreModule` ships the implementation and you get
|
|
95
|
+
`GET /access/principals` by mounting it.
|
|
96
|
+
|
|
97
|
+
**People are almost certainly not the catalog's.** A catalog embedded in an
|
|
98
|
+
application is embedded in one that already knows who its users are, and a
|
|
99
|
+
second user store beside it is how you get two lists of employees that disagree
|
|
100
|
+
about who was offboarded. So implement `listPeople` over what you already have:
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
class MyDirectory extends MikroOrmCatalogDirectory { // applications, inherited
|
|
104
|
+
async listPeople() { return this.users.findAll().map(toPersonSummary); }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
CatalogModule.forRoot({
|
|
108
|
+
directory: { provide: CATALOG_DIRECTORY, useClass: MyDirectory },
|
|
109
|
+
});
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Bind it through `directory` rather than only exporting it from an imported
|
|
113
|
+
module: a provider declared inside `CatalogModule` **shadows** the same token
|
|
114
|
+
exported by one of its imports, so a host that does both gets the shipped
|
|
115
|
+
applications-only one and no error.
|
|
116
|
+
|
|
117
|
+
Leave `listPeople` out and `GET /access/people` answers **501** naming the seam,
|
|
118
|
+
rather than an empty list. That distinction is load-bearing — "nobody can sign in
|
|
119
|
+
yet" and "we did not ask" send an operator to different places, and the first
|
|
120
|
+
invites them to create an account that already exists. Same for `upsertPerson`,
|
|
121
|
+
which most hosts should *not* implement: creating a user from a catalog console
|
|
122
|
+
is a way to create one your IdP has never heard of.
|
|
123
|
+
|
|
124
|
+
The routes mount at `accessPath`, a sibling of `path` by default — `api/catalog`
|
|
125
|
+
gives `api/access`, which is the shape the React screens build.
|
|
126
|
+
|
|
87
127
|
## Build your own endpoints, or use ours
|
|
88
128
|
|
|
89
129
|
The built-in controller is a convenience, not the interface. Pass
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type Type } from '@nestjs/common';
|
|
2
|
+
/**
|
|
3
|
+
* The Access screen's endpoints.
|
|
4
|
+
*
|
|
5
|
+
* A factory for the same reason as `createCatalogController`: the prefix and
|
|
6
|
+
* the guards come from `forRoot`, and this surface enumerates every application
|
|
7
|
+
* that can write to the catalog, which is not something to hand a library's
|
|
8
|
+
* default opinion about auth.
|
|
9
|
+
*/
|
|
10
|
+
export declare function createAccessController(path: string, guards: Type<unknown>[], decorators?: ClassDecorator[]): Type<unknown>;
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
3
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
4
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
5
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
6
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
7
|
+
};
|
|
8
|
+
var __metadata = (this && this.__metadata) || function (k, v) {
|
|
9
|
+
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
10
|
+
};
|
|
11
|
+
var __param = (this && this.__param) || function (paramIndex, decorator) {
|
|
12
|
+
return function (target, key) { decorator(target, key, paramIndex); }
|
|
13
|
+
};
|
|
14
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
+
exports.createAccessController = createAccessController;
|
|
16
|
+
const common_1 = require("@nestjs/common");
|
|
17
|
+
const catalog_access_1 = require("./catalog.access");
|
|
18
|
+
const ROLES = ['viewer', 'curator', 'administrator'];
|
|
19
|
+
function isRole(value) {
|
|
20
|
+
return typeof value === 'string' && ROLES.some((role) => role === value);
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* The Access screen's endpoints.
|
|
24
|
+
*
|
|
25
|
+
* A factory for the same reason as `createCatalogController`: the prefix and
|
|
26
|
+
* the guards come from `forRoot`, and this surface enumerates every application
|
|
27
|
+
* that can write to the catalog, which is not something to hand a library's
|
|
28
|
+
* default opinion about auth.
|
|
29
|
+
*/
|
|
30
|
+
function createAccessController(path, guards, decorators = []) {
|
|
31
|
+
let AccessController = class AccessController {
|
|
32
|
+
directory;
|
|
33
|
+
constructor(directory) {
|
|
34
|
+
this.directory = directory;
|
|
35
|
+
}
|
|
36
|
+
require() {
|
|
37
|
+
if (!this.directory) {
|
|
38
|
+
throw new common_1.NotImplementedException('This catalog has no directory, so it cannot say who may reach it. ' +
|
|
39
|
+
'Provide CATALOG_DIRECTORY — `CatalogMikroOrmStoreModule` ships one that reads `catalog_principal`.');
|
|
40
|
+
}
|
|
41
|
+
return this.directory;
|
|
42
|
+
}
|
|
43
|
+
principals() {
|
|
44
|
+
return this.require().listApplications();
|
|
45
|
+
}
|
|
46
|
+
people(search, limit, offset) {
|
|
47
|
+
const directory = this.require();
|
|
48
|
+
if (!directory.listPeople) {
|
|
49
|
+
// 501, not 200-with-nothing. The screen renders a failed read and an
|
|
50
|
+
// empty list very differently on purpose, and this is genuinely the
|
|
51
|
+
// first: the people exist, they are just not ours to enumerate.
|
|
52
|
+
throw new common_1.NotImplementedException("People come from this application's own identity system, not from the catalog. " +
|
|
53
|
+
'Implement `listPeople` on CATALOG_DIRECTORY to list them here.');
|
|
54
|
+
}
|
|
55
|
+
// Bounded HERE, not left to each implementation. A host's user table is
|
|
56
|
+
// its whole directory, and the seam would otherwise invite exactly the
|
|
57
|
+
// unbounded read this endpoint would then serve to a browser.
|
|
58
|
+
return directory.listPeople({
|
|
59
|
+
...(search ? { search } : {}),
|
|
60
|
+
limit: boundedPage(limit),
|
|
61
|
+
offset: nonNegative(offset),
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
upsertPerson(body) {
|
|
65
|
+
const directory = this.require();
|
|
66
|
+
if (!directory.upsertPerson) {
|
|
67
|
+
throw new common_1.NotImplementedException("This catalog's directory is read-only. Add or change people where they actually live; " +
|
|
68
|
+
'implement `upsertPerson` on CATALOG_DIRECTORY only if that is here.');
|
|
69
|
+
}
|
|
70
|
+
return directory.upsertPerson(parsePersonInput(body));
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
__decorate([
|
|
74
|
+
(0, common_1.Get)('principals'),
|
|
75
|
+
__metadata("design:type", Function),
|
|
76
|
+
__metadata("design:paramtypes", []),
|
|
77
|
+
__metadata("design:returntype", void 0)
|
|
78
|
+
], AccessController.prototype, "principals", null);
|
|
79
|
+
__decorate([
|
|
80
|
+
(0, common_1.Get)('people'),
|
|
81
|
+
__param(0, (0, common_1.Query)('search')),
|
|
82
|
+
__param(1, (0, common_1.Query)('limit')),
|
|
83
|
+
__param(2, (0, common_1.Query)('offset')),
|
|
84
|
+
__metadata("design:type", Function),
|
|
85
|
+
__metadata("design:paramtypes", [String, String, String]),
|
|
86
|
+
__metadata("design:returntype", void 0)
|
|
87
|
+
], AccessController.prototype, "people", null);
|
|
88
|
+
__decorate([
|
|
89
|
+
(0, common_1.Post)('people'),
|
|
90
|
+
__param(0, (0, common_1.Body)()),
|
|
91
|
+
__metadata("design:type", Function),
|
|
92
|
+
__metadata("design:paramtypes", [Object]),
|
|
93
|
+
__metadata("design:returntype", void 0)
|
|
94
|
+
], AccessController.prototype, "upsertPerson", null);
|
|
95
|
+
AccessController = __decorate([
|
|
96
|
+
(0, common_1.Controller)(path),
|
|
97
|
+
__param(0, (0, common_1.Optional)()),
|
|
98
|
+
__param(0, (0, common_1.Inject)(catalog_access_1.CATALOG_DIRECTORY)),
|
|
99
|
+
__metadata("design:paramtypes", [Object])
|
|
100
|
+
], AccessController);
|
|
101
|
+
if (guards.length > 0)
|
|
102
|
+
(0, common_1.UseGuards)(...guards)(AccessController);
|
|
103
|
+
for (const decorate of decorators)
|
|
104
|
+
decorate(AccessController);
|
|
105
|
+
return AccessController;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* A page size that cannot be talked out of its ceiling.
|
|
109
|
+
*
|
|
110
|
+
* Anything unparseable falls back to the default rather than to "no limit" —
|
|
111
|
+
* `?limit=all` must not be the one string that turns the bound off.
|
|
112
|
+
*/
|
|
113
|
+
function boundedPage(raw) {
|
|
114
|
+
const asked = Number.parseInt(raw ?? '', 10);
|
|
115
|
+
if (!Number.isFinite(asked) || asked <= 0)
|
|
116
|
+
return catalog_access_1.CATALOG_DIRECTORY_PAGE;
|
|
117
|
+
return Math.min(asked, catalog_access_1.CATALOG_DIRECTORY_MAX_PAGE);
|
|
118
|
+
}
|
|
119
|
+
function nonNegative(raw) {
|
|
120
|
+
const asked = Number.parseInt(raw ?? '', 10);
|
|
121
|
+
return Number.isFinite(asked) && asked > 0 ? asked : 0;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Validated here rather than with a DTO class, because this package ships no
|
|
125
|
+
* `class-validator` dependency and a host's global `ValidationPipe` is not
|
|
126
|
+
* something a library can assume is mounted.
|
|
127
|
+
*/
|
|
128
|
+
function parsePersonInput(body) {
|
|
129
|
+
if (!body || typeof body !== 'object') {
|
|
130
|
+
throw new common_1.BadRequestException('Expected an object describing the person.');
|
|
131
|
+
}
|
|
132
|
+
const email = Reflect.get(body, 'email');
|
|
133
|
+
if (typeof email !== 'string' || !email.includes('@')) {
|
|
134
|
+
throw new common_1.BadRequestException('`email` is required and must be an email address.');
|
|
135
|
+
}
|
|
136
|
+
const role = Reflect.get(body, 'role');
|
|
137
|
+
if (!isRole(role)) {
|
|
138
|
+
throw new common_1.BadRequestException(`\`role\` must be one of: ${ROLES.join(', ')}.`);
|
|
139
|
+
}
|
|
140
|
+
const displayName = Reflect.get(body, 'displayName');
|
|
141
|
+
const active = Reflect.get(body, 'active');
|
|
142
|
+
const password = Reflect.get(body, 'password');
|
|
143
|
+
return {
|
|
144
|
+
email,
|
|
145
|
+
role,
|
|
146
|
+
...(typeof displayName === 'string' ? { displayName } : {}),
|
|
147
|
+
...(typeof active === 'boolean' ? { active } : {}),
|
|
148
|
+
...(typeof password === 'string' ? { password } : {}),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the Access screen's two lists come from.
|
|
3
|
+
*
|
|
4
|
+
* The screen answers one question — "which application is allowed to touch
|
|
5
|
+
* what, and which person is behind it" — but the two halves of that answer have
|
|
6
|
+
* very different owners.
|
|
7
|
+
*
|
|
8
|
+
* *Applications* are the catalog's own. `catalog_principal` is a table this
|
|
9
|
+
* library defines, the grants on it are catalog grants, and no host has a
|
|
10
|
+
* pre-existing notion of "may load the `Vehicle` type". The library can and does
|
|
11
|
+
* serve this half itself.
|
|
12
|
+
*
|
|
13
|
+
* *People* are almost never the catalog's. A catalog embedded in an application
|
|
14
|
+
* is embedded in an application that already knows who its users are — an IdP, a
|
|
15
|
+
* session table, an OIDC provider — and standing up a second user store beside
|
|
16
|
+
* it is how you get two lists of employees that disagree about who was
|
|
17
|
+
* offboarded. So this half is a seam: the host implements it over whatever it
|
|
18
|
+
* already has, or it does not implement it and the screen says so.
|
|
19
|
+
*
|
|
20
|
+
* That asymmetry is why `listPeople` and `upsertPerson` are OPTIONAL while
|
|
21
|
+
* `listApplications` is not. A directory that only knows about applications is a
|
|
22
|
+
* complete, expected implementation, not a half-finished one.
|
|
23
|
+
*/
|
|
24
|
+
/** A calling application, as the Access screen needs it. */
|
|
25
|
+
export interface CatalogApplicationSummary {
|
|
26
|
+
id: string;
|
|
27
|
+
displayName: string;
|
|
28
|
+
scopes: string[];
|
|
29
|
+
writeTypes: string[];
|
|
30
|
+
/** Null means every type — a safe default for read, unlike write. */
|
|
31
|
+
readTypes: string[] | null;
|
|
32
|
+
classifications: string[];
|
|
33
|
+
active: boolean;
|
|
34
|
+
/**
|
|
35
|
+
* How it authenticates. Never the credential itself — a console that can
|
|
36
|
+
* display one is a console that leaks it over somebody's shoulder.
|
|
37
|
+
*/
|
|
38
|
+
authMethod: 'key' | 'token' | 'session';
|
|
39
|
+
lastSeenAt: string | null;
|
|
40
|
+
/**
|
|
41
|
+
* Types whose publishing this application owns.
|
|
42
|
+
*
|
|
43
|
+
* Distinct from `writeTypes`, which is the grant. Ownership is what actually
|
|
44
|
+
* decides a re-publish, so a screen showing only the grant can show an
|
|
45
|
+
* application that appears able to load a type it will be refused on.
|
|
46
|
+
*/
|
|
47
|
+
ownedTypes: string[];
|
|
48
|
+
}
|
|
49
|
+
export type CatalogPersonRole = 'viewer' | 'curator' | 'administrator';
|
|
50
|
+
/** A person: signs in, and acts through an application that caps them. */
|
|
51
|
+
export interface CatalogPersonSummary {
|
|
52
|
+
email: string;
|
|
53
|
+
displayName: string;
|
|
54
|
+
role: CatalogPersonRole;
|
|
55
|
+
active: boolean;
|
|
56
|
+
/** Exactly the string their actions land in the audit trail as. */
|
|
57
|
+
principalId: string;
|
|
58
|
+
/**
|
|
59
|
+
* Whether they can sign in with a password *here*.
|
|
60
|
+
*
|
|
61
|
+
* False is the normal answer for a host-backed directory, where signing in
|
|
62
|
+
* happens somewhere this library never sees. It is not "no credential".
|
|
63
|
+
*/
|
|
64
|
+
hasPassword: boolean;
|
|
65
|
+
createdAt: string;
|
|
66
|
+
lastLoginAt: string | null;
|
|
67
|
+
/** Sessions the host can see. `0` from a directory that does not track them. */
|
|
68
|
+
liveSessions: number;
|
|
69
|
+
/**
|
|
70
|
+
* The intersection with the application they sign in through, not the role in
|
|
71
|
+
* the abstract. An administrator whose console has had `catalog:admin`
|
|
72
|
+
* removed is not an administrator, and showing the role alone would say
|
|
73
|
+
* otherwise.
|
|
74
|
+
*/
|
|
75
|
+
effective: {
|
|
76
|
+
scopes: string[];
|
|
77
|
+
writeTypes: string[];
|
|
78
|
+
readTypes: string[] | null;
|
|
79
|
+
classifications: string[];
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
/** The bound the endpoint applies before the directory is asked. */
|
|
83
|
+
export interface CatalogDirectoryQuery {
|
|
84
|
+
/**
|
|
85
|
+
* Free text over whatever identifies a person to the host — email and display
|
|
86
|
+
* name, typically. Absent means no filter, NOT "match nothing".
|
|
87
|
+
*/
|
|
88
|
+
search?: string;
|
|
89
|
+
/** Always present, always capped. See {@link CATALOG_DIRECTORY_MAX_PAGE}. */
|
|
90
|
+
limit: number;
|
|
91
|
+
offset: number;
|
|
92
|
+
}
|
|
93
|
+
/** The default page, when the caller asks for no particular one. */
|
|
94
|
+
export declare const CATALOG_DIRECTORY_PAGE = 50;
|
|
95
|
+
/**
|
|
96
|
+
* The ceiling, whatever a caller asks for.
|
|
97
|
+
*
|
|
98
|
+
* A cap rather than a default that can be overridden without limit, because the
|
|
99
|
+
* caller here is a browser and `?limit=100000` would be one URL away from the
|
|
100
|
+
* unbounded read this exists to prevent.
|
|
101
|
+
*/
|
|
102
|
+
export declare const CATALOG_DIRECTORY_MAX_PAGE = 500;
|
|
103
|
+
export interface CatalogPeoplePage {
|
|
104
|
+
people: CatalogPersonSummary[];
|
|
105
|
+
/**
|
|
106
|
+
* How many match, ignoring the page. The console needs it to say "50 of 1,340"
|
|
107
|
+
* — a bounded list that cannot report what it is bounding is indistinguishable
|
|
108
|
+
* from a complete one, and an operator reading it as complete will conclude
|
|
109
|
+
* somebody has no access when they simply were not on the page.
|
|
110
|
+
*/
|
|
111
|
+
total: number;
|
|
112
|
+
limit: number;
|
|
113
|
+
offset: number;
|
|
114
|
+
}
|
|
115
|
+
export interface CatalogPersonInput {
|
|
116
|
+
email: string;
|
|
117
|
+
displayName?: string;
|
|
118
|
+
role: CatalogPersonRole;
|
|
119
|
+
active?: boolean;
|
|
120
|
+
password?: string;
|
|
121
|
+
}
|
|
122
|
+
export interface CatalogPersonUpsertResult {
|
|
123
|
+
email: string;
|
|
124
|
+
created: boolean;
|
|
125
|
+
/**
|
|
126
|
+
* Whether open sessions were invalidated. Surfaced because demoting somebody
|
|
127
|
+
* whose tabs keep working is the failure that makes people distrust the whole
|
|
128
|
+
* session system.
|
|
129
|
+
*/
|
|
130
|
+
sessionsRevoked: boolean;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* The host's answer to "who may reach this catalog".
|
|
134
|
+
*
|
|
135
|
+
* Implement `listPeople` over the user store you already have. The three
|
|
136
|
+
* fields worth care are `principalId` — it must be the exact string that
|
|
137
|
+
* person's actions are recorded under, or the Activity screen attributes their
|
|
138
|
+
* work to nobody — `role`, and `effective`, which is the role capped by the
|
|
139
|
+
* application they sign in through rather than the role in the abstract.
|
|
140
|
+
*/
|
|
141
|
+
export interface CatalogDirectory {
|
|
142
|
+
/**
|
|
143
|
+
* Unpaged, and that is a claim about the data rather than an oversight:
|
|
144
|
+
* applications are rows an operator creates by hand, one per publisher, and a
|
|
145
|
+
* catalog with a thousand of them has a different problem than a missing
|
|
146
|
+
* `LIMIT`. People are the opposite — see {@link listPeople}.
|
|
147
|
+
*/
|
|
148
|
+
listApplications(): Promise<CatalogApplicationSummary[]>;
|
|
149
|
+
/**
|
|
150
|
+
* Omit when people come from somewhere this catalog cannot see. The endpoint
|
|
151
|
+
* then refuses with a message naming this seam, which is a better answer than
|
|
152
|
+
* an empty list: "nobody can sign in yet" and "we did not ask" send an
|
|
153
|
+
* operator to completely different places, and the first invites them to
|
|
154
|
+
* create an account that already exists.
|
|
155
|
+
*
|
|
156
|
+
* **The query is not advisory.** It is passed so the bound reaches the
|
|
157
|
+
* database, not so the implementation can slice an array it already
|
|
158
|
+
* materialised — an embedded catalog's user table is the host's whole
|
|
159
|
+
* directory, and `SELECT *` over it is a screen that gets slower every time
|
|
160
|
+
* somebody is hired. `total` is what lets the console say how much it is not
|
|
161
|
+
* showing, which is the difference between a bounded list and a silently
|
|
162
|
+
* truncated one.
|
|
163
|
+
*/
|
|
164
|
+
listPeople?(query: CatalogDirectoryQuery): Promise<CatalogPeoplePage>;
|
|
165
|
+
/**
|
|
166
|
+
* Omit when the host's directory is not writable from here — which is the
|
|
167
|
+
* common case, and the right one. Creating a user in the IdP from a catalog
|
|
168
|
+
* console is a way to create a user the IdP does not know about.
|
|
169
|
+
*/
|
|
170
|
+
upsertPerson?(input: CatalogPersonInput): Promise<CatalogPersonUpsertResult>;
|
|
171
|
+
}
|
|
172
|
+
export declare const CATALOG_DIRECTORY: unique symbol;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Where the Access screen's two lists come from.
|
|
4
|
+
*
|
|
5
|
+
* The screen answers one question — "which application is allowed to touch
|
|
6
|
+
* what, and which person is behind it" — but the two halves of that answer have
|
|
7
|
+
* very different owners.
|
|
8
|
+
*
|
|
9
|
+
* *Applications* are the catalog's own. `catalog_principal` is a table this
|
|
10
|
+
* library defines, the grants on it are catalog grants, and no host has a
|
|
11
|
+
* pre-existing notion of "may load the `Vehicle` type". The library can and does
|
|
12
|
+
* serve this half itself.
|
|
13
|
+
*
|
|
14
|
+
* *People* are almost never the catalog's. A catalog embedded in an application
|
|
15
|
+
* is embedded in an application that already knows who its users are — an IdP, a
|
|
16
|
+
* session table, an OIDC provider — and standing up a second user store beside
|
|
17
|
+
* it is how you get two lists of employees that disagree about who was
|
|
18
|
+
* offboarded. So this half is a seam: the host implements it over whatever it
|
|
19
|
+
* already has, or it does not implement it and the screen says so.
|
|
20
|
+
*
|
|
21
|
+
* That asymmetry is why `listPeople` and `upsertPerson` are OPTIONAL while
|
|
22
|
+
* `listApplications` is not. A directory that only knows about applications is a
|
|
23
|
+
* complete, expected implementation, not a half-finished one.
|
|
24
|
+
*/
|
|
25
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
26
|
+
exports.CATALOG_DIRECTORY = exports.CATALOG_DIRECTORY_MAX_PAGE = exports.CATALOG_DIRECTORY_PAGE = void 0;
|
|
27
|
+
/** The default page, when the caller asks for no particular one. */
|
|
28
|
+
exports.CATALOG_DIRECTORY_PAGE = 50;
|
|
29
|
+
/**
|
|
30
|
+
* The ceiling, whatever a caller asks for.
|
|
31
|
+
*
|
|
32
|
+
* A cap rather than a default that can be overridden without limit, because the
|
|
33
|
+
* caller here is a browser and `?limit=100000` would be one URL away from the
|
|
34
|
+
* unbounded read this exists to prevent.
|
|
35
|
+
*/
|
|
36
|
+
exports.CATALOG_DIRECTORY_MAX_PAGE = 500;
|
|
37
|
+
exports.CATALOG_DIRECTORY = Symbol('CATALOG_DIRECTORY');
|
package/dist/catalog.module.js
CHANGED
|
@@ -10,6 +10,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
10
10
|
exports.CatalogModule = void 0;
|
|
11
11
|
const node_path_1 = require("node:path");
|
|
12
12
|
const common_1 = require("@nestjs/common");
|
|
13
|
+
const access_controller_1 = require("./access.controller");
|
|
13
14
|
const catalog_controller_1 = require("./catalog.controller");
|
|
14
15
|
const catalog_options_1 = require("./catalog.options");
|
|
15
16
|
const catalog_overlay_store_1 = require("./catalog.overlay-store");
|
|
@@ -24,7 +25,10 @@ let CatalogModule = CatalogModule_1 = class CatalogModule {
|
|
|
24
25
|
const path = options.path ?? 'api/catalog';
|
|
25
26
|
const mountController = options.controller !== false;
|
|
26
27
|
const controllers = mountController
|
|
27
|
-
? [
|
|
28
|
+
? [
|
|
29
|
+
(0, catalog_controller_1.createCatalogController)(path, options.guards ?? [], options.decorators ?? []),
|
|
30
|
+
(0, access_controller_1.createAccessController)(options.accessPath ?? siblingPath(path, 'access'), options.guards ?? [], options.decorators ?? []),
|
|
31
|
+
]
|
|
28
32
|
: [];
|
|
29
33
|
const overlayStore = options.overlayStore ??
|
|
30
34
|
new catalog_overlay_store_1.FileCatalogOverlayStore(options.overlayPath ?? (0, node_path_1.join)(process.cwd(), '.catalog', 'overlay.json'));
|
|
@@ -59,6 +63,11 @@ let CatalogModule = CatalogModule_1 = class CatalogModule {
|
|
|
59
63
|
...(options.store
|
|
60
64
|
? [options.store]
|
|
61
65
|
: [mikro_orm_read_store_1.MikroOrmReadStore, { provide: catalog_store_1.CATALOG_STORE, useExisting: mikro_orm_read_store_1.MikroOrmReadStore }]),
|
|
66
|
+
// No default. Unlike the store and the registry there is nothing
|
|
67
|
+
// sensible to derive: who may reach a catalog is not a fact about its
|
|
68
|
+
// model. Absent, the Access routes answer 501 naming the token, which
|
|
69
|
+
// is the honest state for a host that has not decided yet.
|
|
70
|
+
...(options.directory ? [options.directory] : []),
|
|
62
71
|
catalog_service_1.CatalogService,
|
|
63
72
|
],
|
|
64
73
|
exports: [catalog_registry_base_1.CatalogRegistry, catalog_service_1.CatalogService, catalog_store_1.CATALOG_STORE],
|
|
@@ -69,3 +78,16 @@ exports.CatalogModule = CatalogModule;
|
|
|
69
78
|
exports.CatalogModule = CatalogModule = CatalogModule_1 = __decorate([
|
|
70
79
|
(0, common_1.Module)({})
|
|
71
80
|
], CatalogModule);
|
|
81
|
+
/**
|
|
82
|
+
* `api/catalog` + `access` -> `api/access`.
|
|
83
|
+
*
|
|
84
|
+
* Replacing the last segment rather than appending, because the console builds
|
|
85
|
+
* the two paths as siblings under one API root. Appending would give
|
|
86
|
+
* `api/catalog/access`, which the console never asks for — and the symptom is a
|
|
87
|
+
* screen that 404s while the module reports itself mounted.
|
|
88
|
+
*/
|
|
89
|
+
function siblingPath(path, segment) {
|
|
90
|
+
const trimmed = path.replace(/\/+$/, '');
|
|
91
|
+
const parent = trimmed.slice(0, trimmed.lastIndexOf('/') + 1);
|
|
92
|
+
return `${parent}${segment}`;
|
|
93
|
+
}
|
|
@@ -16,6 +16,21 @@ export interface CatalogModuleOptions {
|
|
|
16
16
|
* Defaults to `api/catalog`.
|
|
17
17
|
*/
|
|
18
18
|
path?: string;
|
|
19
|
+
/**
|
|
20
|
+
* Route prefix for the Access endpoints, which the console's Access screen
|
|
21
|
+
* reads.
|
|
22
|
+
*
|
|
23
|
+
* A sibling of {@link path} by default — `api/catalog` gives `api/access` —
|
|
24
|
+
* because that is the shape `@dudousxd/nestjs-catalog-react` builds by
|
|
25
|
+
* default: it prepends the console's `apiPath` to a base of `/access`, so
|
|
26
|
+
* the two land side by side under the same API root.
|
|
27
|
+
*
|
|
28
|
+
* The endpoints are served only when something provides `CATALOG_DIRECTORY`;
|
|
29
|
+
* see {@link CatalogDirectory}. Mounted regardless, so the console gets a
|
|
30
|
+
* 501 explaining what is unwired rather than a 404 that reads as a broken
|
|
31
|
+
* build.
|
|
32
|
+
*/
|
|
33
|
+
accessPath?: string;
|
|
19
34
|
/**
|
|
20
35
|
* Guards applied to every catalog route. The library ships none: an
|
|
21
36
|
* introspection endpoint that lists every table in the system is exactly the
|
|
@@ -55,6 +70,20 @@ export interface CatalogModuleOptions {
|
|
|
55
70
|
* implementation here instead.
|
|
56
71
|
*/
|
|
57
72
|
registry?: Provider;
|
|
73
|
+
/**
|
|
74
|
+
* Who may reach this catalog. A provider for `CATALOG_DIRECTORY`.
|
|
75
|
+
*
|
|
76
|
+
* Bound here rather than only exported from an imported module for the same
|
|
77
|
+
* reason `store` and `registry` are: a provider declared inside this module
|
|
78
|
+
* SHADOWS the same token exported by one of its imports, so a host that
|
|
79
|
+
* merely imports `CatalogMikroOrmStoreModule` alongside its own
|
|
80
|
+
* implementation gets the shipped applications-only one and no error.
|
|
81
|
+
*
|
|
82
|
+
* The common shape is to extend the shipped `MikroOrmCatalogDirectory` —
|
|
83
|
+
* which reads `catalog_principal` — and add `listPeople` over the user store
|
|
84
|
+
* the host already has. See {@link CatalogDirectory}.
|
|
85
|
+
*/
|
|
86
|
+
directory?: Provider;
|
|
58
87
|
/**
|
|
59
88
|
* Mount the built-in controller. Default true.
|
|
60
89
|
*
|
package/dist/catalog.pipeline.js
CHANGED
package/dist/index.d.ts
CHANGED
|
@@ -14,6 +14,7 @@ export { SubprocessTransformRunner, type TransformRunnerOptions, } from './trans
|
|
|
14
14
|
export { CatalogService } from './catalog.service';
|
|
15
15
|
export { type AuditQuery, CATALOG_TRACE_OUTCOMES, CATALOG_TRACE_STORE, CATALOG_WORKSPACE_STORE, type CatalogAuditEvent, type CatalogTrace, type CatalogTraceList, type CatalogTraceOutcome, type CatalogTraceSpan, type CatalogTraceStore, type CatalogTraceTotals, type CatalogUnlinkedList, type CatalogWorkspaceStore, type Dashboard, type DashboardCard, isCatalogTraceOutcome, isTraceStore, isWorkspaceStore, type QueryVisualization, type SaveQueryInput, type SavedQuery, type TraceQuery, traceOutcomeFilter, } from './catalog.workspace';
|
|
16
16
|
export { CATALOG_PRINCIPAL_RESOLVER, type CatalogActor, type CatalogGrants, type CatalogPrincipal, type CatalogPrincipalResolver, type CatalogScope, composePrincipalId, delegatePrincipal, expandScopes, hasScope, parsePrincipalId, PRINCIPAL_ACTOR_SEPARATOR, maySeeClassification, mayRead, mayWrite, StaticKeyPrincipalResolver, } from './catalog.principal';
|
|
17
|
+
export * from './catalog.access';
|
|
17
18
|
export { assertNoColumnCollisions, CATALOG_RESERVED_COLUMNS, CATALOG_SNAPSHOT_MODES, CATALOG_STORE, type CarryForwardResult, type CatalogColumnCollision, CatalogColumnCollisionError, type CatalogMergeStore, type CatalogReadQuery, type CatalogReadResult, type CatalogReadStore, type CatalogReservedColumn, type CatalogSnapshotMode, type CatalogStoreCapabilities, type CatalogWriteStore, type ColumnCollisionOptions, findColumnCollisions, isCatalogStoreCapabilities, isReservedColumn, isWriteStore, type SnapshotRef, supportsCarryForward, } from './catalog.store';
|
|
18
19
|
export { MikroOrmReadStore } from './stores/mikro-orm-read.store';
|
|
19
20
|
export type { CatalogGraph, CatalogObjectPage, CatalogObjectQuery, CatalogObjectTypeDef, CatalogOverlay, CatalogPropertyDef, CatalogRelationDef, CatalogSnapshot, RelationKind, ScalarType, } from './catalog.types';
|
package/dist/index.js
CHANGED
|
@@ -94,6 +94,11 @@ Object.defineProperty(exports, "maySeeClassification", { enumerable: true, get:
|
|
|
94
94
|
Object.defineProperty(exports, "mayRead", { enumerable: true, get: function () { return catalog_principal_1.mayRead; } });
|
|
95
95
|
Object.defineProperty(exports, "mayWrite", { enumerable: true, get: function () { return catalog_principal_1.mayWrite; } });
|
|
96
96
|
Object.defineProperty(exports, "StaticKeyPrincipalResolver", { enumerable: true, get: function () { return catalog_principal_1.StaticKeyPrincipalResolver; } });
|
|
97
|
+
// Everything, deliberately. The last release exported the directory interface
|
|
98
|
+
// but not the two types its one method takes and returns, so the seam could be
|
|
99
|
+
// implemented only by a host willing to restate them — which nothing in this
|
|
100
|
+
// repo would have caught: no consumer compiles against the built barrel here.
|
|
101
|
+
__exportStar(require("./catalog.access"), exports);
|
|
97
102
|
var catalog_store_1 = require("./catalog.store");
|
|
98
103
|
Object.defineProperty(exports, "assertNoColumnCollisions", { enumerable: true, get: function () { return catalog_store_1.assertNoColumnCollisions; } });
|
|
99
104
|
Object.defineProperty(exports, "CATALOG_RESERVED_COLUMNS", { enumerable: true, get: function () { return catalog_store_1.CATALOG_RESERVED_COLUMNS; } });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dudousxd/nestjs-catalog",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "A metadata registry for NestJS: object types, properties and relations, derived from your ORM and enriched with decorators.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Davide Carvalho",
|