@dudousxd/nestjs-catalog 0.5.0 → 0.6.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.
@@ -14,15 +14,16 @@
14
14
  */
15
15
  export declare const CATALOG_LIB = "catalog";
16
16
  /** Every event name this package emits. Exported so a watcher can claim them. */
17
- export declare const CATALOG_EVENTS: readonly ["schema.changed", "snapshot.written", "snapshot.committed", "snapshot.dropped", "type.curated", "connector.run.started", "connector.run.finished", "transform.changed", "workflow.changed"];
17
+ export declare const CATALOG_EVENTS: readonly ["schema.changed", "snapshot.written", "snapshot.committed", "snapshot.dropped", "type.curated", "connector.run.started", "connector.run.finished", "transform.changed", "workflow.changed", "query.shared", "dashboard.shared"];
18
18
  export type CatalogEvent = (typeof CATALOG_EVENTS)[number];
19
19
  /**
20
20
  * Where each event sits in the life of one load.
21
21
  *
22
22
  * This exists because a timestamp is not enough to order a trace. A fast load
23
23
  * emits its whole story inside a single tick of whatever clock the recorder
24
- * writes — the bundled MySQL store keeps whole seconds so sorting by time
25
- * alone leaves the events in insertion order, and insertion order is arbitrary.
24
+ * writes — the bundled MySQL store keeps milliseconds, and a commit of a few
25
+ * hundred rows lands well inside one so sorting by time alone leaves the
26
+ * events in insertion order, and insertion order is arbitrary.
26
27
  * The observed result is a trace that reads `finished → written → committed →
27
28
  * started`: causally impossible, rendered with total confidence. Sorting by
28
29
  * time *and then* by this rank puts a same-tick story back in the only order it
@@ -142,6 +143,69 @@ export interface CatalogEventPayloads {
142
143
  nodeCount: number;
143
144
  changedBy: string;
144
145
  };
146
+ /**
147
+ * A saved query started or stopped being fetchable through the embed API.
148
+ *
149
+ * The single act that hands an outside application data from this catalog, and
150
+ * for a long time the only one that left no trace: `SavedQuery.shared` is the
151
+ * whole embed boundary, it is set by a person, and nothing recorded that they
152
+ * had set it.
153
+ *
154
+ * One event for both directions rather than a `query.unshared` beside it,
155
+ * following `connector.run.finished`: anybody watching for shares has to watch
156
+ * for revocations too — a trail that records only the grants cannot answer
157
+ * "was this still shared last Tuesday", which is the question an incident
158
+ * actually asks. The direction is in {@link shared}.
159
+ *
160
+ * Emitted on the transition only. A save that leaves the flag where it was is
161
+ * not a sharing decision, and a trail that logged every keystroke on a query
162
+ * would be a trail nobody reads.
163
+ *
164
+ * **Deleting a shared query is one of those transitions**, and it emits here
165
+ * rather than under a name of its own. The question this event answers is
166
+ * "when did this stop being reachable from outside", and anybody asking it
167
+ * filters on `query.shared` and reads the last entry. A separate
168
+ * `query.deleted` would leave that filter reporting `shared: true` forever
169
+ * for something nobody can fetch — the revocation would be in a channel the
170
+ * asker did not subscribe to, which is the same absence this event exists to
171
+ * remove. {@link deleted} says which kind of ending it was.
172
+ */
173
+ 'query.shared': {
174
+ savedQueryId: string;
175
+ /** As it read at the moment of the change, so the trail is legible alone. */
176
+ name: string;
177
+ /** True when access was granted, false when it was taken back. */
178
+ shared: boolean;
179
+ /**
180
+ * Who decided. The host-resolved principal where there is one, and named
181
+ * `principalId` because that is the field a recorder lifts into the audit
182
+ * table's indexed column — spell it anything else and the entry lands
183
+ * attributed to nobody.
184
+ */
185
+ principalId: string;
186
+ /**
187
+ * Set only when the revocation was a deletion. Absent means the thing still
188
+ * exists and was merely un-shared.
189
+ *
190
+ * Typed as `true` rather than `boolean` so the flag cannot be written the
191
+ * other way round: "not a deletion" is the absence of this key, matching
192
+ * how the rest of these payloads treat a fact that did not happen.
193
+ *
194
+ * Worth carrying rather than leaving to be looked up, because after a
195
+ * deletion there is nothing left to look up. An entry that said only
196
+ * `shared: false` would send its reader to a row that is gone, and the
197
+ * empty result reads as a broken trail rather than as the answer.
198
+ */
199
+ deleted?: true;
200
+ };
201
+ /** The same decision, about a whole board. See {@link CatalogEventPayloads['query.shared']}. */
202
+ 'dashboard.shared': {
203
+ dashboardId: string;
204
+ name: string;
205
+ shared: boolean;
206
+ principalId: string;
207
+ deleted?: true;
208
+ };
145
209
  }
146
210
  /**
147
211
  * Teach the diagnostics channel what this library puts on it.
@@ -31,14 +31,17 @@ exports.CATALOG_EVENTS = [
31
31
  'connector.run.finished',
32
32
  'transform.changed',
33
33
  'workflow.changed',
34
+ 'query.shared',
35
+ 'dashboard.shared',
34
36
  ];
35
37
  /**
36
38
  * Where each event sits in the life of one load.
37
39
  *
38
40
  * This exists because a timestamp is not enough to order a trace. A fast load
39
41
  * emits its whole story inside a single tick of whatever clock the recorder
40
- * writes — the bundled MySQL store keeps whole seconds so sorting by time
41
- * alone leaves the events in insertion order, and insertion order is arbitrary.
42
+ * writes — the bundled MySQL store keeps milliseconds, and a commit of a few
43
+ * hundred rows lands well inside one so sorting by time alone leaves the
44
+ * events in insertion order, and insertion order is arbitrary.
42
45
  * The observed result is a trace that reads `finished → written → committed →
43
46
  * started`: causally impossible, rendered with total confidence. Sorting by
44
47
  * time *and then* by this rank puts a same-tick story back in the only order it
@@ -64,6 +67,16 @@ exports.CATALOG_EVENT_PHASE = {
64
67
  // be complete is exactly the mechanism that will fail the build the day a new
65
68
  // event is added and nobody thinks about where it belongs.
66
69
  'type.curated': 2,
70
+ // Sharing carries no snapshot id either, for the same reason curation does
71
+ // not: it is a standalone act on a saved query or a board, not a step of any
72
+ // load. So these ranks are never consulted, and they are written out for the
73
+ // reason the note above gives — the `Record` has to be complete, and that is
74
+ // what makes the build fail the day an event is added and nobody thinks about
75
+ // where it belongs. Ranked beside curation because they are the same kind of
76
+ // thing: a person deciding something about the catalog rather than data moving
77
+ // through it.
78
+ 'query.shared': 2,
79
+ 'dashboard.shared': 2,
67
80
  'schema.changed': 3,
68
81
  'snapshot.written': 4,
69
82
  'snapshot.committed': 5,
@@ -16,6 +16,7 @@
16
16
  * to survive into the audit trail, because "the console renamed this column" is
17
17
  * not an answer anybody accepts.
18
18
  */
19
+ import type { CatalogObjectPage } from './catalog.types';
19
20
  export type CatalogScope =
20
21
  /** Read object metadata and rows. */
21
22
  'catalog:read'
@@ -118,7 +119,9 @@ export interface CatalogPrincipal {
118
119
  readTypes?: string[];
119
120
  /**
120
121
  * Classifications this principal may see. A column marked with anything
121
- * outside this list is dropped from its reads.
122
+ * outside this list is to be dropped from its reads — *by whoever serves
123
+ * them*, which is not this library. See the note above {@link mayWrite} on
124
+ * who enforces, and {@link readableObjectPage} for the drop itself.
122
125
  *
123
126
  * Undefined means "no classified columns", not "all of them" — the safe
124
127
  * default for a caller nobody has thought about yet.
@@ -198,12 +201,57 @@ export declare function expandScopes(scopes: CatalogScope[]): CatalogScope[];
198
201
  * something that happens because an administrator logged in.
199
202
  */
200
203
  export declare function delegatePrincipal(application: CatalogPrincipal, actor: CatalogActor, grants: CatalogGrants): CatalogPrincipal;
204
+ /** Enforced by the library, because the publish API is handed a principal. */
201
205
  export declare function mayWrite(principal: CatalogPrincipal, typeName: string): boolean;
206
+ /**
207
+ * Whether this principal may read a type at all.
208
+ *
209
+ * A predicate for a host to apply, not something the catalog's own read routes
210
+ * consult — see the note above. `readableObjectPage` is the ready-made
211
+ * application of it.
212
+ */
202
213
  export declare function mayRead(principal: CatalogPrincipal, typeName: string): boolean;
203
214
  /**
204
215
  * Whether a column is visible to this principal.
205
216
  *
206
217
  * Unclassified columns are visible to everyone; a classified one requires the
207
218
  * principal to name that classification. Absence is denial.
219
+ *
220
+ * The same caveat as {@link mayRead}: answering the question is all this does.
221
+ * No read path in this library asks it.
208
222
  */
209
223
  export declare function maySeeClassification(principal: CatalogPrincipal, classification: string | undefined): boolean;
224
+ /**
225
+ * A page of objects as this principal is allowed to see it: the page, minus
226
+ * every column carrying a classification they do not hold — and `null` when
227
+ * they may not read the type at all.
228
+ *
229
+ * This exists so that "who applies `mayRead` and `maySeeClassification`?" has an
230
+ * answer a host can call rather than a rule a host has to re-derive. Wrap the
231
+ * service:
232
+ *
233
+ * ```ts
234
+ * const page = await this.catalog.readObjects(name, query);
235
+ * const visible = readableObjectPage(principal, page);
236
+ * if (!visible) throw new ForbiddenException(`${principal.id} may not read ${name}.`);
237
+ * return visible;
238
+ * ```
239
+ *
240
+ * `null` rather than a thrown exception because the status code is the host's
241
+ * decision: a 403 confirms to the caller that the type exists, and a deployment
242
+ * that considers the type list itself sensitive would rather answer 404. `null`
243
+ * rather than an empty page because a page with no columns is what a type whose
244
+ * every column is hidden legitimately returns, and a denial must not be
245
+ * mistakable for one.
246
+ *
247
+ * Hidden values are **deleted** from each row rather than blanked. A key present
248
+ * with `null` asserts that the column exists and happens to be empty, which for
249
+ * a classified column is itself a disclosure.
250
+ *
251
+ * What it does not do: rows can carry keys no column declares, because
252
+ * `readObjects` always fetches the primary key so a UI has a row identity even
253
+ * when that key is hidden. Those survive. This removes what a classification
254
+ * hides; it is not a whitelist, and a classified column that is also part of a
255
+ * hidden primary key is outside what a page alone can express.
256
+ */
257
+ export declare function readableObjectPage(principal: CatalogPrincipal, page: CatalogObjectPage): CatalogObjectPage | null;
@@ -27,6 +27,7 @@ exports.delegatePrincipal = delegatePrincipal;
27
27
  exports.mayWrite = mayWrite;
28
28
  exports.mayRead = mayRead;
29
29
  exports.maySeeClassification = maySeeClassification;
30
+ exports.readableObjectPage = readableObjectPage;
30
31
  /** Every scope, in one place, so `catalog:admin` can be expanded exactly. */
31
32
  const ALL_SCOPES = [
32
33
  'catalog:read',
@@ -222,9 +223,45 @@ function matches(list, typeName) {
222
223
  return false;
223
224
  return list.includes('*') || list.includes(typeName);
224
225
  }
226
+ // -----------------------------------------------------------------------------
227
+ // The grant predicates, and who is expected to call them.
228
+ //
229
+ // **This library declares and evaluates; the host enforces.** The same split
230
+ // `catalog.route-auth.ts` states for scopes holds for grants: nothing in this
231
+ // package ever turns a request into a principal. `CatalogPrincipalResolver` is
232
+ // an interface with one bundled development implementation, no guard ships here,
233
+ // and the `request.principal` that `publish.controller.ts` reads is put there by
234
+ // a guard the host wrote — the error it throws even names a class this repo does
235
+ // not contain.
236
+ //
237
+ // The asymmetry between write and read is real and is not the bug it looks like.
238
+ // `mayWrite` is called inside the library, in `publish.service.ts`, for exactly
239
+ // one reason: the publish API takes a `CatalogPrincipal` as an argument, so it
240
+ // has one in hand. The read path does not. `CatalogService.readObjects` has no
241
+ // principal parameter, `GET /catalog/objects/:name` has none to give it, and so
242
+ // `mayRead` and `maySeeClassification` are called nowhere in this repository at
243
+ // runtime. That is the design: a library that filtered reads would need a
244
+ // principal on every read, which means shipping a guard, which means having an
245
+ // opinion about the host's identity provider.
246
+ //
247
+ // What was wrong was not the mechanism but the prose around it. A host reading
248
+ // "classifications this principal may see" is entitled to assume something drops
249
+ // the columns, and nothing does. So, stated once, here: **the read path applies
250
+ // no grants and no classification filtering, and a host that wants either calls
251
+ // `readableObjectPage` itself.** Anyone about to "wire this up" should change
252
+ // that sentence rather than quietly making it false.
253
+ // -----------------------------------------------------------------------------
254
+ /** Enforced by the library, because the publish API is handed a principal. */
225
255
  function mayWrite(principal, typeName) {
226
256
  return hasScope(principal, 'catalog:write') && matches(principal.writeTypes, typeName);
227
257
  }
258
+ /**
259
+ * Whether this principal may read a type at all.
260
+ *
261
+ * A predicate for a host to apply, not something the catalog's own read routes
262
+ * consult — see the note above. `readableObjectPage` is the ready-made
263
+ * application of it.
264
+ */
228
265
  function mayRead(principal, typeName) {
229
266
  if (!hasScope(principal, 'catalog:read'))
230
267
  return false;
@@ -237,9 +274,64 @@ function mayRead(principal, typeName) {
237
274
  *
238
275
  * Unclassified columns are visible to everyone; a classified one requires the
239
276
  * principal to name that classification. Absence is denial.
277
+ *
278
+ * The same caveat as {@link mayRead}: answering the question is all this does.
279
+ * No read path in this library asks it.
240
280
  */
241
281
  function maySeeClassification(principal, classification) {
242
282
  if (!classification)
243
283
  return true;
244
284
  return principal.classifications?.includes(classification) ?? false;
245
285
  }
286
+ /**
287
+ * A page of objects as this principal is allowed to see it: the page, minus
288
+ * every column carrying a classification they do not hold — and `null` when
289
+ * they may not read the type at all.
290
+ *
291
+ * This exists so that "who applies `mayRead` and `maySeeClassification`?" has an
292
+ * answer a host can call rather than a rule a host has to re-derive. Wrap the
293
+ * service:
294
+ *
295
+ * ```ts
296
+ * const page = await this.catalog.readObjects(name, query);
297
+ * const visible = readableObjectPage(principal, page);
298
+ * if (!visible) throw new ForbiddenException(`${principal.id} may not read ${name}.`);
299
+ * return visible;
300
+ * ```
301
+ *
302
+ * `null` rather than a thrown exception because the status code is the host's
303
+ * decision: a 403 confirms to the caller that the type exists, and a deployment
304
+ * that considers the type list itself sensitive would rather answer 404. `null`
305
+ * rather than an empty page because a page with no columns is what a type whose
306
+ * every column is hidden legitimately returns, and a denial must not be
307
+ * mistakable for one.
308
+ *
309
+ * Hidden values are **deleted** from each row rather than blanked. A key present
310
+ * with `null` asserts that the column exists and happens to be empty, which for
311
+ * a classified column is itself a disclosure.
312
+ *
313
+ * What it does not do: rows can carry keys no column declares, because
314
+ * `readObjects` always fetches the primary key so a UI has a row identity even
315
+ * when that key is hidden. Those survive. This removes what a classification
316
+ * hides; it is not a whitelist, and a classified column that is also part of a
317
+ * hidden primary key is outside what a page alone can express.
318
+ */
319
+ function readableObjectPage(principal, page) {
320
+ if (!mayRead(principal, page.type))
321
+ return null;
322
+ const hidden = page.columns
323
+ .filter((column) => !maySeeClassification(principal, column.classification))
324
+ .map((column) => column.name);
325
+ if (hidden.length === 0)
326
+ return page;
327
+ return {
328
+ ...page,
329
+ columns: page.columns.filter((column) => !hidden.includes(column.name)),
330
+ rows: page.rows.map((row) => {
331
+ const visible = { ...row };
332
+ for (const name of hidden)
333
+ delete visible[name];
334
+ return visible;
335
+ }),
336
+ };
337
+ }
@@ -3,7 +3,7 @@ import { type CatalogQueryRelation, type CatalogQueryResult } from './catalog.qu
3
3
  import { CatalogRegistry } from './catalog.registry.base';
4
4
  import { type CatalogReadStore, type SnapshotRef } from './catalog.store';
5
5
  import type { CatalogGraph, CatalogObjectPage, CatalogObjectQuery, CatalogObjectTypeDef, CatalogOverlay, CatalogSnapshot } from './catalog.types';
6
- import { type AuditQuery, type CatalogAuditEvent, type CatalogWorkspaceStore, type Dashboard, type DashboardCard, type EmbeddedChart, type EmbeddedDashboard, type SaveQueryInput, type SavedQuery } from './catalog.workspace';
6
+ import { type AuditQuery, type CatalogAuditEvent, type CatalogWorkspaceStore, type Dashboard, type DashboardCard, type EmbeddedChart, type EmbeddedChartPlacement, type EmbeddedDashboard, type SaveQueryInput, type SavedQuery } from './catalog.workspace';
7
7
  /**
8
8
  * Reads objects of any catalogued type through one endpoint.
9
9
  *
@@ -32,6 +32,17 @@ export declare class CatalogService {
32
32
  resetOverlay(): Promise<void>;
33
33
  /** Columns a generic UI may render: visible, and not a blob. */
34
34
  visibleColumns(type: CatalogObjectTypeDef): import("./catalog.types").CatalogPropertyDef[];
35
+ /**
36
+ * Rows of one type, paged.
37
+ *
38
+ * **No principal, and so no access control.** This applies the guardrails that
39
+ * hold for every caller — the type exists, the page is bounded, the sort names
40
+ * a real column — and none that depend on who is asking: a classified column
41
+ * comes back to whoever the host's guard let through the door. That is the
42
+ * library's declare-and-enforce split, written out at length above `mayWrite`
43
+ * in `catalog.principal.ts`. A host that wants per-principal reads passes this
44
+ * page through `readableObjectPage`.
45
+ */
35
46
  readObjects(typeName: string, query: CatalogObjectQuery & {
36
47
  snapshot?: string;
37
48
  }): Promise<CatalogObjectPage>;
@@ -68,9 +79,22 @@ export declare class CatalogService {
68
79
  private requireWorkspace;
69
80
  listSavedQueries(): Promise<SavedQuery[]>;
70
81
  getSavedQuery(id: string): Promise<SavedQuery>;
82
+ /**
83
+ * @param createdBy who saved it — the row's author and the audit entry's
84
+ * actor. The host's resolved principal id where the host resolves one; see
85
+ * the enforcement note in `catalog.principal.ts` for why this library cannot
86
+ * work it out itself.
87
+ */
71
88
  saveQuery(input: SaveQueryInput, createdBy: string): Promise<SavedQuery>;
72
- updateSavedQuery(id: string, input: Partial<SaveQueryInput>): Promise<SavedQuery>;
73
- deleteSavedQuery(id: string): Promise<boolean>;
89
+ /** @param changedBy who made the change, for the audit trail. */
90
+ updateSavedQuery(id: string, input: Partial<SaveQueryInput>, changedBy: string): Promise<SavedQuery>;
91
+ /**
92
+ * @param deletedBy who deleted it, for the audit trail. Required rather than
93
+ * defaulted, matching `saveQuery` and `updateSavedQuery`: a default would
94
+ * quietly attribute revocations to nobody in every caller that was not
95
+ * updated, and the trail's whole value here is that it names somebody.
96
+ */
97
+ deleteSavedQuery(id: string, deletedBy: string): Promise<boolean>;
74
98
  /** Runs a saved query, honouring the TTL it was saved with. */
75
99
  runSavedQuery(id: string, maxRows?: number): Promise<{
76
100
  savedQuery: SavedQuery;
@@ -78,17 +102,31 @@ export declare class CatalogService {
78
102
  }>;
79
103
  listDashboards(): Promise<Dashboard[]>;
80
104
  getDashboard(id: string): Promise<Dashboard>;
105
+ /**
106
+ * `shared` is declared here, and that is not cosmetic.
107
+ *
108
+ * The store has always accepted it, so it worked as long as the body reached
109
+ * the store untouched. A host with a whitelisting `ValidationPipe` — the
110
+ * normal, recommended configuration — strips a property no type declares, and
111
+ * the symptom is a dashboard that cannot be shared with no error anywhere:
112
+ * the toggle saves, the response says `shared: false`, and the embed API
113
+ * keeps answering 403 for a board somebody just shared.
114
+ */
81
115
  saveDashboard(input: {
82
116
  name: string;
83
117
  description?: string;
84
118
  cards?: DashboardCard[];
119
+ shared?: boolean;
85
120
  }, createdBy: string): Promise<Dashboard>;
121
+ /** @param changedBy who made the change, for the audit trail. */
86
122
  updateDashboard(id: string, input: Partial<{
87
123
  name: string;
88
124
  description: string;
89
125
  cards: DashboardCard[];
90
- }>): Promise<Dashboard>;
91
- deleteDashboard(id: string): Promise<boolean>;
126
+ shared: boolean;
127
+ }>, changedBy: string): Promise<Dashboard>;
128
+ /** @param deletedBy who deleted it. See {@link deleteSavedQuery}. */
129
+ deleteDashboard(id: string, deletedBy: string): Promise<boolean>;
92
130
  /** Everything shared, so a consumer can discover what it may render. */
93
131
  listEmbeddable(): Promise<{
94
132
  dashboards: Array<{
@@ -104,11 +142,16 @@ export declare class CatalogService {
104
142
  kind: string;
105
143
  }>;
106
144
  }>;
107
- /** One chart, rendered. */
108
- embedChart(savedQueryId: string, layout?: {
109
- width: number;
110
- position: number;
111
- }): Promise<EmbeddedChart>;
145
+ /**
146
+ * One chart, rendered.
147
+ *
148
+ * `placement` is what the dashboard card said, and it is honoured rather than
149
+ * merely carried: a card's `title` and `library` exist to override the saved
150
+ * query on THIS board, so an embed that ignored them would show a different
151
+ * heading and a different chart from the console for the same dashboard —
152
+ * silently, with nothing thrown and nothing logged.
153
+ */
154
+ embedChart(savedQueryId: string, placement?: EmbeddedChartPlacement): Promise<EmbeddedChart>;
112
155
  /** A whole dashboard, every chart resolved. */
113
156
  embedDashboard(dashboardId: string): Promise<EmbeddedDashboard>;
114
157
  listEvents(query: AuditQuery): Promise<CatalogAuditEvent[]>;