@dudousxd/nestjs-catalog 0.5.0 → 0.7.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.
@@ -94,13 +94,24 @@ export interface CatalogEnvironment {
94
94
  */
95
95
  rank: number;
96
96
  /**
97
- * Whether this environment refuses changes that did not arrive as a reviewed
98
- * promotion.
97
+ * A declaration that this environment is one where changes are supposed to
98
+ * arrive as a reviewed promotion. True for production.
99
99
  *
100
- * True for production. It does not lock the environment an operator can
101
- * still fix something by hand it means the API demands the explicit
102
- * confirmation described on {@link CatalogPromotionApproval} rather than
103
- * accepting a plan somebody generated in another tab ten minutes ago.
100
+ * **Advisory. Nothing in this library refuses anything because of it.** No
101
+ * guard, no store and no plan reads this field; the only code that does is the
102
+ * console, which paints the environment switcher amber so that "am I about to
103
+ * do this to production" is answerable at a glance. That is worth having and
104
+ * it is worth not overstating — a flag documented as an enforcement point,
105
+ * enforced nowhere, is worse than no flag, because it invites a host to
106
+ * believe the refusal already exists.
107
+ *
108
+ * Kept rather than removed because the enforcement it describes cannot live
109
+ * here. The library has no apply endpoint and no opinion about who may approve
110
+ * what — see {@link CatalogPromotionPlan.fingerprint} for the same division —
111
+ * so the refusal belongs to whatever host exposes promotion over HTTP. This
112
+ * field is how that host learns which environments to demand
113
+ * {@link CatalogPromotionApproval} for, without every deployment restating its
114
+ * own list of environment names in a guard.
104
115
  */
105
116
  protected: boolean;
106
117
  }
@@ -173,17 +184,40 @@ export declare function catalogDatabaseNameFor(base: string, environmentId: Cata
173
184
  * It stops being enough the moment anyone asks a governance question across
174
185
  * environments — "everything this person did this week" has to be answerable
175
186
  * without the reader having to remember which of three lists they are looking
176
- * at — so the environment is stamped onto every event as it leaves its store.
187
+ * at.
177
188
  *
178
189
  * Stamped on read rather than stored in a column on purpose: a stored column
179
190
  * can be wrong, because nothing in the database stops a row in the production
180
191
  * table saying `environment: "dev"`. A value derived from which connection the
181
192
  * row was read through cannot be.
193
+ *
194
+ * Which is also why the stamping does not happen here, and cannot. This package
195
+ * knows what an environment *is*; it has no connection and no scope, so it can
196
+ * only supply the shape and the function. The stamp is applied by the one reader
197
+ * that resolved an environment in order to do the read at all —
198
+ * `RoutingWorkspaceStore.listEvents` in the MikroORM store package, which widens
199
+ * its return type to this intersection. A read that went straight to a store,
200
+ * with no environment resolved anywhere above it, is *not* stamped, and that is
201
+ * the honest answer rather than a gap: nothing in that call knew which world it
202
+ * was reading, so anything written into the field would be a guess.
182
203
  */
183
204
  export interface EnvironmentStampedAuditEvent {
184
205
  environment: CatalogEnvironmentId;
185
206
  }
186
- /** Stamps a batch of events with the environment they were read from. */
207
+ /**
208
+ * Stamps a batch of events with the environment they were read from.
209
+ *
210
+ * A separate function taking the id as an argument rather than a method
211
+ * somewhere, so the caller has to have the environment in hand to call it. There
212
+ * is no default and nothing to look up — the id comes from the same resolution
213
+ * that chose the connection, which is what makes the stamp a fact about the read
214
+ * rather than a label somebody attached afterwards.
215
+ *
216
+ * Generic over the event rather than typed to `CatalogAuditEvent`, which lives
217
+ * in `catalog.workspace.ts` and would make this file depend on the workspace
218
+ * vocabulary to say something true of any row. Nothing here needs to know more
219
+ * about an event than that it is an object.
220
+ */
187
221
  export declare function stampEnvironment<T>(environment: CatalogEnvironmentId, events: readonly T[]): Array<T & EnvironmentStampedAuditEvent>;
188
222
  /** What a promotion is allowed to carry. */
189
223
  export declare const PROMOTABLE_KINDS: readonly ["objectType", "transform", "workflow", "connector"];
@@ -257,6 +291,45 @@ export interface PromotableObjectType {
257
291
  unit?: string;
258
292
  classification?: string;
259
293
  }>;
294
+ /**
295
+ * The links the type declares, in the shape they are stored in.
296
+ *
297
+ * Absent from this interface until it was noticed that a promoted type arrived
298
+ * complete in every visible way — right properties, right table, right owner —
299
+ * and sat in the target's graph as an island. Nothing errored, nothing was
300
+ * reported, and the plan the operator approved said "nothing to promote" for a
301
+ * release whose only content was a link.
302
+ *
303
+ * Spelled out here rather than imported from the store package, which is the
304
+ * same reason `properties` is: the dependency runs the other way, and this
305
+ * side is the one both a MikroORM environment and anything else that ever
306
+ * holds a catalog have to satisfy. `kind` is a bare `string` for the reason the
307
+ * stored row gives — what comes back out of a JSON column is whatever some
308
+ * earlier version of some publisher put in, and it is narrowed where it is
309
+ * read, not asserted here.
310
+ *
311
+ * Optional, and read as `[]` wherever it is used. An environment whose rows
312
+ * predate the relations column holds `NULL` there, and requiring the field
313
+ * would force every hand-built promotable set to name something it has nothing
314
+ * to say about. "Absent" and "empty" are deliberately the same statement here,
315
+ * unlike on the publish wire: this shape is built by `readPromotable` in this
316
+ * process rather than arriving from a client of unknown vintage, and the plan
317
+ * an operator approves has to describe exactly what the apply will do. See
318
+ * `promoteType`.
319
+ */
320
+ relations?: Array<{
321
+ name: string;
322
+ displayName: string;
323
+ description?: string;
324
+ kind: string;
325
+ targetType: string;
326
+ localKey?: string;
327
+ nullable: boolean;
328
+ hidden: boolean;
329
+ position: number;
330
+ owner: boolean;
331
+ inverseName?: string;
332
+ }>;
260
333
  }
261
334
  export interface PromotableTransform {
262
335
  id: string;
@@ -375,9 +448,22 @@ export interface CatalogPromotionPlan {
375
448
  * The point of a preview is that somebody read it. Without a fingerprint the
376
449
  * apply call is a fresh promotion that happens to have been preceded by a
377
450
  * preview, and anything that changed in between — a colleague editing the
378
- * transform, a connector deleted — goes in unreviewed. The apply endpoint
379
- * demands this value back, recomputes the plan, and refuses if the two
380
- * differ. That is what turns "we show a diff" into "you approved this diff".
451
+ * transform, a connector deleted — goes in unreviewed.
452
+ *
453
+ * **The check is the host's, and this library does not perform it.** There is
454
+ * no apply endpoint here: `applyPromotion` is exported for a host to call, and
455
+ * it does not recompute the plan or compare anything to this value. The
456
+ * expectation, stated the same way where the apply lives, is that the caller
457
+ * re-runs {@link planPromotion} immediately before applying and confirms the
458
+ * fingerprint still matches what the reviewer approved. It lives with the
459
+ * caller because it is a policy decision about who may approve what, and
460
+ * `applyPromotion` is the mechanism rather than the policy.
461
+ *
462
+ * So this field is what makes that check *possible* — a stable name for one
463
+ * reviewed set of changes, computed from the effect and not the clock — and
464
+ * not, on its own, the check. A host that carries the value from preview to
465
+ * apply and never compares it has a promotion nobody approved, and nothing in
466
+ * this package will say so.
381
467
  */
382
468
  fingerprint: string;
383
469
  }
@@ -394,7 +480,13 @@ export interface CatalogPromotionPlan {
394
480
  * which environment the row belongs to.
395
481
  */
396
482
  export declare const PROMOTION_AUDIT_EVENT = "promotion.applied";
397
- /** What an apply call must present to prove which plan was approved. */
483
+ /**
484
+ * What a host's apply call asks for, to prove which plan was approved.
485
+ *
486
+ * A shape rather than a check — see {@link CatalogPromotionPlan.fingerprint} for
487
+ * why the comparison lives with the caller. Written down here so that every host
488
+ * demands the same two things and an audit row means the same thing in each.
489
+ */
398
490
  export interface CatalogPromotionApproval {
399
491
  fingerprint: string;
400
492
  /** Free text the operator typed. Recorded in the audit trail, never parsed. */
@@ -216,7 +216,20 @@ function durableKeyspaceFor(environmentId, prefix = 'catalog') {
216
216
  function catalogDatabaseNameFor(base, environmentId) {
217
217
  return `${base}_${assertEnvironmentId(environmentId)}`;
218
218
  }
219
- /** Stamps a batch of events with the environment they were read from. */
219
+ /**
220
+ * Stamps a batch of events with the environment they were read from.
221
+ *
222
+ * A separate function taking the id as an argument rather than a method
223
+ * somewhere, so the caller has to have the environment in hand to call it. There
224
+ * is no default and nothing to look up — the id comes from the same resolution
225
+ * that chose the connection, which is what makes the stamp a fact about the read
226
+ * rather than a label somebody attached afterwards.
227
+ *
228
+ * Generic over the event rather than typed to `CatalogAuditEvent`, which lives
229
+ * in `catalog.workspace.ts` and would make this file depend on the workspace
230
+ * vocabulary to say something true of any row. Nothing here needs to know more
231
+ * about an event than that it is an object.
232
+ */
220
233
  function stampEnvironment(environment, events) {
221
234
  return events.map((event) => ({ ...event, environment }));
222
235
  }
@@ -719,5 +732,55 @@ function diffObjectType(existing, incoming) {
719
732
  to: gone,
720
733
  });
721
734
  }
735
+ diffs.push(...diffRelations(existing, incoming));
736
+ return diffs;
737
+ }
738
+ /**
739
+ * The links, compared the same way the properties above are — and named
740
+ * differently on purpose.
741
+ *
742
+ * **Why this exists at all.** Without it a promotion whose only difference is a
743
+ * link reports `unchanged`, so the plan says there is nothing to promote and the
744
+ * apply, driven by that plan, does nothing. The change is real, the operator is
745
+ * shown an empty diff, and the link stays behind in dev. A plan is what somebody
746
+ * approves; a change invisible in the plan is a change nobody approved.
747
+ *
748
+ * **`relations.removed`, not `relations.absentFromSource`.** The properties
749
+ * above borrow the softer word because nothing acts on them: `ensureType` is
750
+ * additive, so a column that vanished from the source keeps its data in the
751
+ * target. A link that vanished really is deleted there — `promoteType` assigns
752
+ * the source's links rather than merging them, because a link holds no data and
753
+ * keeping one the source deliberately dropped means the target asserts a join
754
+ * the schema no longer has. The field name has to say which of those two a
755
+ * reviewer is looking at.
756
+ *
757
+ * **`to` repeats the removed names rather than being empty.** The fingerprint an
758
+ * approval is compared against hashes each field's `to` value; an empty one
759
+ * would make "drops the link to Base" and "drops the link to Depot" hash
760
+ * identically, so a plan approved for one would be applicable as the other.
761
+ */
762
+ function diffRelations(existing, incoming) {
763
+ const diffs = [];
764
+ // `?? []` on both sides, so an environment predating the relations column
765
+ // compares as holding none rather than as differing from everything.
766
+ const before = new Map((existing?.relations ?? []).map((relation) => [relation.name, relation]));
767
+ const after = new Map((incoming.relations ?? []).map((relation) => [relation.name, relation]));
768
+ const added = [...after.keys()].filter((name) => !before.has(name));
769
+ const changed = [...after.entries()]
770
+ .filter(([name, relation]) => {
771
+ const previous = before.get(name);
772
+ return previous !== undefined && stable(previous) !== stable(relation);
773
+ })
774
+ .map(([name]) => name);
775
+ const removed = [...before.keys()].filter((name) => !after.has(name));
776
+ if (added.length) {
777
+ diffs.push({ field: 'relations.added', from: [], to: added });
778
+ }
779
+ if (changed.length) {
780
+ diffs.push({ field: 'relations.changed', from: changed, to: changed });
781
+ }
782
+ if (removed.length) {
783
+ diffs.push({ field: 'relations.removed', from: removed, to: removed });
784
+ }
722
785
  return diffs;
723
786
  }
@@ -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,
@@ -84,6 +84,25 @@ export interface CatalogConnector {
84
84
  * view atomically. `incremental` reads only what changed since the last run
85
85
  * and carries the rest forward, which is cheaper but needs the source to
86
86
  * offer a watermark and the type to have a primary key to merge on.
87
+ *
88
+ * **`incremental` is blind to deletes, and that is structural rather than a
89
+ * gap in any particular fetcher.** A run asks its source for what changed
90
+ * since a watermark; a row physically removed from the source never changes
91
+ * again, so it is never returned again, so the carry-forward copies it into
92
+ * every subsequent snapshot indefinitely. Nothing goes wrong at any single
93
+ * step — the catalog simply never finds out, and every count and dashboard
94
+ * built on the type is quietly wrong from then on.
95
+ *
96
+ * Because that failure is silent, the pipeline **refuses an incremental load
97
+ * of a type for which no reconciliation strategy has been declared**: a full
98
+ * read on an interval, a source that soft-deletes where the watermark can see
99
+ * it, or an explicit "stale rows are acceptable here, because …". The
100
+ * declaration is per object type and lives in the host's
101
+ * `CATALOG_LOAD_EXPECTATIONS` (see `load-expectations.ts` in
102
+ * `@dudousxd/nestjs-catalog-pipeline`), because it is a statement about the
103
+ * data rather than about the connector reading it — the same type loaded by a
104
+ * workflow sink or by an application POSTing to the publish API has exactly
105
+ * the same problem.
87
106
  */
88
107
  mode?: 'full' | 'incremental';
89
108
  /**
@@ -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
+ }