@orthacms/content-server 0.4.2 → 0.4.3
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/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +38 -1
- package/dist/lib/content.module.d.ts.map +1 -1
- package/dist/lib/content.module.js +23 -1
- package/dist/lib/entries/http/controllers/create-entry.controller.d.ts.map +1 -1
- package/dist/lib/entries/http/controllers/create-entry.controller.js +1 -1
- package/dist/lib/entries/http/controllers/update-entry.controller.d.ts.map +1 -1
- package/dist/lib/entries/http/controllers/update-entry.controller.js +1 -1
- package/dist/lib/entries/http/dto/extension-bag.validator.d.ts +14 -0
- package/dist/lib/entries/http/dto/extension-bag.validator.d.ts.map +1 -0
- package/dist/lib/entries/http/dto/extension-bag.validator.js +42 -0
- package/dist/lib/entries/http/dto/save-entry.dto.d.ts +16 -0
- package/dist/lib/entries/http/dto/save-entry.dto.d.ts.map +1 -1
- package/dist/lib/entries/http/dto/save-entry.dto.js +30 -0
- package/dist/lib/entries/infrastructure/persistence/entry-writer.service.d.ts +28 -2
- package/dist/lib/entries/infrastructure/persistence/entry-writer.service.d.ts.map +1 -1
- package/dist/lib/entries/infrastructure/persistence/entry-writer.service.js +112 -15
- package/dist/lib/entries/infrastructure/persistence/relation-link.service.d.ts +38 -5
- package/dist/lib/entries/infrastructure/persistence/relation-link.service.d.ts.map +1 -1
- package/dist/lib/entries/infrastructure/persistence/relation-link.service.js +38 -6
- package/dist/lib/entries/infrastructure/queries/entries.service.d.ts +3 -1
- package/dist/lib/entries/infrastructure/queries/entries.service.d.ts.map +1 -1
- package/dist/lib/entries/infrastructure/queries/entries.service.js +17 -5
- package/dist/lib/entries/infrastructure/queries/entry-match.query.d.ts +82 -0
- package/dist/lib/entries/infrastructure/queries/entry-match.query.d.ts.map +1 -0
- package/dist/lib/entries/infrastructure/queries/entry-match.query.js +152 -0
- package/dist/lib/extension/entry-access-source.d.ts +112 -0
- package/dist/lib/extension/entry-access-source.d.ts.map +1 -0
- package/dist/lib/extension/entry-filter-provider.d.ts +79 -0
- package/dist/lib/extension/entry-filter-provider.d.ts.map +1 -0
- package/dist/lib/extension/entry-filter-provider.js +149 -0
- package/dist/lib/extension/entry-write-extension.d.ts +185 -0
- package/dist/lib/extension/entry-write-extension.d.ts.map +1 -0
- package/dist/lib/extension/entry-write-extension.js +182 -0
- package/dist/lib/extension/entry-write-hook.d.ts +98 -0
- package/dist/lib/extension/entry-write-hook.d.ts.map +1 -0
- package/dist/lib/extension/read-scope.d.ts +107 -0
- package/dist/lib/extension/read-scope.d.ts.map +1 -0
- package/dist/lib/extension/read-scope.js +131 -0
- package/dist/lib/public-api/infrastructure/public-entries.query.d.ts +14 -1
- package/dist/lib/public-api/infrastructure/public-entries.query.d.ts.map +1 -1
- package/dist/lib/public-api/infrastructure/public-entries.query.js +20 -3
- package/dist/lib/public-api/infrastructure/public-expansion.query.d.ts +16 -4
- package/dist/lib/public-api/infrastructure/public-expansion.query.d.ts.map +1 -1
- package/dist/lib/public-api/infrastructure/public-expansion.query.js +29 -9
- package/dist/lib/revisions/application/use-cases/restore-revision.use-case.d.ts.map +1 -1
- package/dist/lib/revisions/application/use-cases/restore-revision.use-case.js +12 -1
- package/dist/lib/revisions/infrastructure/persistence/revision-snapshot.d.ts +7 -1
- package/dist/lib/revisions/infrastructure/persistence/revision-snapshot.d.ts.map +1 -1
- package/dist/lib/revisions/infrastructure/persistence/revision-snapshot.js +9 -2
- package/dist/lib/revisions/types/revision-view.d.ts +11 -0
- package/dist/lib/revisions/types/revision-view.d.ts.map +1 -1
- package/dist/lib/utils/content-views-plugin.d.ts +28 -0
- package/dist/lib/utils/content-views-plugin.d.ts.map +1 -0
- package/dist/lib/utils/content-views-plugin.js +29 -0
- package/dist/lib/views/application/queries/saved-views.query.d.ts +16 -0
- package/dist/lib/views/application/queries/saved-views.query.d.ts.map +1 -0
- package/dist/lib/views/application/queries/saved-views.query.js +46 -0
- package/dist/lib/views/application/saved-view-access.service.d.ts +30 -0
- package/dist/lib/views/application/saved-view-access.service.d.ts.map +1 -0
- package/dist/lib/views/application/saved-view-access.service.js +55 -0
- package/dist/lib/views/application/use-cases/create-saved-view.use-case.d.ts +27 -0
- package/dist/lib/views/application/use-cases/create-saved-view.use-case.d.ts.map +1 -0
- package/dist/lib/views/application/use-cases/create-saved-view.use-case.js +57 -0
- package/dist/lib/views/application/use-cases/delete-saved-view.use-case.d.ts +15 -0
- package/dist/lib/views/application/use-cases/delete-saved-view.use-case.d.ts.map +1 -0
- package/dist/lib/views/application/use-cases/delete-saved-view.use-case.js +35 -0
- package/dist/lib/views/application/use-cases/set-default-view.use-case.d.ts +23 -0
- package/dist/lib/views/application/use-cases/set-default-view.use-case.d.ts.map +1 -0
- package/dist/lib/views/application/use-cases/set-default-view.use-case.js +51 -0
- package/dist/lib/views/application/use-cases/update-saved-view.use-case.d.ts +21 -0
- package/dist/lib/views/application/use-cases/update-saved-view.use-case.d.ts.map +1 -0
- package/dist/lib/views/application/use-cases/update-saved-view.use-case.js +61 -0
- package/dist/lib/views/application/view-scope.service.d.ts +25 -0
- package/dist/lib/views/application/view-scope.service.d.ts.map +1 -0
- package/dist/lib/views/application/view-scope.service.js +45 -0
- package/dist/lib/views/content-views.module.d.ts +22 -0
- package/dist/lib/views/content-views.module.d.ts.map +1 -0
- package/dist/lib/views/content-views.module.js +63 -0
- package/dist/lib/views/domain/errors/index.d.ts +6 -0
- package/dist/lib/views/domain/errors/index.d.ts.map +1 -0
- package/dist/lib/views/domain/errors/index.js +12 -0
- package/dist/lib/views/domain/errors/saved-view-forbidden.error.d.ts +10 -0
- package/dist/lib/views/domain/errors/saved-view-forbidden.error.d.ts.map +1 -0
- package/dist/lib/views/domain/errors/saved-view-forbidden.error.js +16 -0
- package/dist/lib/views/domain/errors/saved-view-limit-error.d.ts +9 -0
- package/dist/lib/views/domain/errors/saved-view-limit-error.d.ts.map +1 -0
- package/dist/lib/views/domain/errors/saved-view-limit-error.js +16 -0
- package/dist/lib/views/domain/errors/saved-view-name-taken.error.d.ts +5 -0
- package/dist/lib/views/domain/errors/saved-view-name-taken.error.d.ts.map +1 -0
- package/dist/lib/views/domain/errors/saved-view-name-taken.error.js +11 -0
- package/dist/lib/views/domain/errors/saved-view-not-found.error.d.ts +9 -0
- package/dist/lib/views/domain/errors/saved-view-not-found.error.d.ts.map +1 -0
- package/dist/lib/views/domain/errors/saved-view-not-found.error.js +15 -0
- package/dist/lib/views/domain/saved-view.d.ts +66 -0
- package/dist/lib/views/domain/saved-view.d.ts.map +1 -0
- package/dist/lib/views/domain/saved-view.js +18 -0
- package/dist/lib/views/domain/saved-view.repository.d.ts +77 -0
- package/dist/lib/views/domain/saved-view.repository.d.ts.map +1 -0
- package/dist/lib/views/domain/saved-view.repository.js +5 -0
- package/dist/lib/views/http/controllers/saved-views.controller.d.ts +45 -0
- package/dist/lib/views/http/controllers/saved-views.controller.d.ts.map +1 -0
- package/dist/lib/views/http/controllers/saved-views.controller.js +216 -0
- package/dist/lib/views/http/controllers/to-http-error.d.ts +10 -0
- package/dist/lib/views/http/controllers/to-http-error.d.ts.map +1 -0
- package/dist/lib/views/http/controllers/to-http-error.js +26 -0
- package/dist/lib/views/http/dto/save-view.dto.d.ts +54 -0
- package/dist/lib/views/http/dto/save-view.dto.d.ts.map +1 -0
- package/dist/lib/views/http/dto/save-view.dto.js +230 -0
- package/dist/lib/views/http/dto/view-payload.validator.d.ts +20 -0
- package/dist/lib/views/http/dto/view-payload.validator.d.ts.map +1 -0
- package/dist/lib/views/http/dto/view-payload.validator.js +52 -0
- package/dist/lib/views/infrastructure/persistence/drizzle-saved-view.repository.d.ts +17 -0
- package/dist/lib/views/infrastructure/persistence/drizzle-saved-view.repository.d.ts.map +1 -0
- package/dist/lib/views/infrastructure/persistence/drizzle-saved-view.repository.js +125 -0
- package/dist/lib/views/infrastructure/schema/external-refs.d.ts +68 -0
- package/dist/lib/views/infrastructure/schema/external-refs.d.ts.map +1 -0
- package/dist/lib/views/infrastructure/schema/external-refs.js +29 -0
- package/dist/lib/views/infrastructure/schema/index.d.ts +8 -0
- package/dist/lib/views/infrastructure/schema/index.d.ts.map +1 -0
- package/dist/lib/views/infrastructure/schema/index.js +13 -0
- package/dist/lib/views/infrastructure/schema/saved-views.d.ts +275 -0
- package/dist/lib/views/infrastructure/schema/saved-views.d.ts.map +1 -0
- package/dist/lib/views/infrastructure/schema/saved-views.js +87 -0
- package/dist/lib/views/views.constants.d.ts +26 -0
- package/dist/lib/views/views.constants.d.ts.map +1 -0
- package/dist/lib/views/views.constants.js +28 -0
- package/dist/lib/views/views.tokens.d.ts +17 -0
- package/dist/lib/views/views.tokens.d.ts.map +1 -0
- package/dist/lib/views/views.tokens.js +21 -0
- package/migrations/0000_saved_views.sql +29 -0
- package/migrations/meta/0000_snapshot.json +256 -0
- package/migrations/meta/_journal.json +13 -0
- package/package.json +13 -12
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.EntryMatchQuery = void 0;
|
|
4
|
+
const tslib_1 = require("tslib");
|
|
5
|
+
const common_1 = require("@nestjs/common");
|
|
6
|
+
const drizzle_orm_1 = require("drizzle-orm");
|
|
7
|
+
const database_1 = require("@orthacms/database");
|
|
8
|
+
const utils_server_1 = require("@orthacms/utils-server");
|
|
9
|
+
const entry_extension_1 = require("../../../extension/entry-extension");
|
|
10
|
+
const entry_filter_provider_1 = require("../../../extension/entry-filter-provider");
|
|
11
|
+
const entry_filter_surface_1 = require("./entry-filter-surface");
|
|
12
|
+
/**
|
|
13
|
+
* Answers "which entries of this type match this filter?" against the same
|
|
14
|
+
* surface, parser and translator the records list uses.
|
|
15
|
+
*
|
|
16
|
+
* It exists so a downstream plugin can reuse content's filter semantics
|
|
17
|
+
* without reaching for the database or rebuilding the surface: the filterable
|
|
18
|
+
* paths, the relation-hop budget, the workspace scoping of every relation
|
|
19
|
+
* subquery, and the 400 on a malformed tree are all inherited rather than
|
|
20
|
+
* re-implemented. `@orthacms/alarms-server` evaluates its rules through this,
|
|
21
|
+
* which is what makes "the rule I saved from the list means what the list
|
|
22
|
+
* showed me" true by construction rather than by review.
|
|
23
|
+
*
|
|
24
|
+
* Two deliberate differences from the list query:
|
|
25
|
+
*
|
|
26
|
+
* - **No locale scoping.** The entries extension's `listScope` narrows a list
|
|
27
|
+
* to the active locale; a rule is a statement about the collection, so it
|
|
28
|
+
* must see every translation. The extension's *virtual filter fields* are
|
|
29
|
+
* still wired, so a rule may filter on them explicitly.
|
|
30
|
+
* - **Trashed rows are excluded, always.** There is no trash view here, and a
|
|
31
|
+
* tombstoned row is not something anyone can act on.
|
|
32
|
+
*/
|
|
33
|
+
let EntryMatchQuery = class EntryMatchQuery {
|
|
34
|
+
db;
|
|
35
|
+
filterProviders;
|
|
36
|
+
extension;
|
|
37
|
+
constructor(db,
|
|
38
|
+
// Same pair as `EntriesService`: an alarm rule stores the records
|
|
39
|
+
// list's filter verbatim, so it has to parse against exactly the
|
|
40
|
+
// surface that list offers — including the fields other plugins add.
|
|
41
|
+
filterProviders, extension) {
|
|
42
|
+
this.db = db;
|
|
43
|
+
this.filterProviders = filterProviders;
|
|
44
|
+
this.extension = extension;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Parses `filter` against `type`'s surface without running it, throwing a
|
|
48
|
+
* `FilterException` when it does not hold up. Callers store filters that
|
|
49
|
+
* will be replayed by a background subscriber for months, so the parse
|
|
50
|
+
* belongs at the moment the filter is written — a tree that only fails at
|
|
51
|
+
* evaluation time fails somewhere nobody is looking.
|
|
52
|
+
*/
|
|
53
|
+
assertParses(type, filter, workspaceId) {
|
|
54
|
+
const { schema } = (0, entry_filter_surface_1.buildEntryFilterSurface)(type, {
|
|
55
|
+
workspaceId,
|
|
56
|
+
extensionFields: this.filterProviders.compose(type, this.extension?.filterExtension(type))?.fields
|
|
57
|
+
});
|
|
58
|
+
(0, utils_server_1.parseFilterTree)(filter, schema);
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Ids of `type` in `workspaceId` matching `filter`, oldest-id first so a
|
|
62
|
+
* batched scan is stable across calls.
|
|
63
|
+
*/
|
|
64
|
+
async matchingIds(type, filter, workspaceId, options = {}) {
|
|
65
|
+
// An empty restriction means "none of them", not "all of them" — an
|
|
66
|
+
// `inArray(col, [])` would be a false predicate anyway, but returning
|
|
67
|
+
// early keeps a caller that batched its ids from paying for a query
|
|
68
|
+
// whose answer is known.
|
|
69
|
+
if (options.entryIds && options.entryIds.length === 0)
|
|
70
|
+
return [];
|
|
71
|
+
const where = await this.predicate(type, filter, workspaceId, options);
|
|
72
|
+
const table = type.table;
|
|
73
|
+
const rows = await this.db
|
|
74
|
+
.select({ id: table['id'] })
|
|
75
|
+
.from(type.table)
|
|
76
|
+
.where(where)
|
|
77
|
+
.orderBy(table['id'])
|
|
78
|
+
.limit(options.limit ?? Number.MAX_SAFE_INTEGER)
|
|
79
|
+
.offset(options.offset ?? 0);
|
|
80
|
+
return rows.map((row) => String(row.id));
|
|
81
|
+
}
|
|
82
|
+
/** How many entries of `type` match `filter` right now. */
|
|
83
|
+
async countMatching(type, filter, workspaceId) {
|
|
84
|
+
const where = await this.predicate(type, filter, workspaceId, {});
|
|
85
|
+
const [row] = await this.db
|
|
86
|
+
.select({ total: (0, drizzle_orm_1.count)() })
|
|
87
|
+
.from(type.table)
|
|
88
|
+
.where(where);
|
|
89
|
+
return Number(row?.total ?? 0);
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* How many entries of `type` the workspace holds at all — the denominator
|
|
93
|
+
* the rule editor shows beside a match count ("14 of 312"), which is what
|
|
94
|
+
* turns a number into a judgement about whether the rule is too broad.
|
|
95
|
+
*/
|
|
96
|
+
async countAll(type, workspaceId) {
|
|
97
|
+
const [row] = await this.db
|
|
98
|
+
.select({ total: (0, drizzle_orm_1.count)() })
|
|
99
|
+
.from(type.table)
|
|
100
|
+
.where(this.scope(type, workspaceId));
|
|
101
|
+
return Number(row?.total ?? 0);
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* The workspace an entry belongs to, or `null` when no such row exists.
|
|
105
|
+
*
|
|
106
|
+
* A domain event names an entry and its content type but not its workspace
|
|
107
|
+
* — every content table carries its own `workspace_id`, so there is nothing
|
|
108
|
+
* for the envelope to add that a one-column lookup cannot answer. Exposed
|
|
109
|
+
* here rather than left to each subscriber, so a downstream plugin never
|
|
110
|
+
* has a reason to query a `content_<name>` table directly.
|
|
111
|
+
*/
|
|
112
|
+
async workspaceOf(type, entryId) {
|
|
113
|
+
const table = type.table;
|
|
114
|
+
const [row] = await this.db
|
|
115
|
+
.select({ workspaceId: table['workspaceId'] })
|
|
116
|
+
.from(type.table)
|
|
117
|
+
.where((0, drizzle_orm_1.eq)(table['id'], entryId))
|
|
118
|
+
.limit(1);
|
|
119
|
+
return row?.workspaceId ? String(row.workspaceId) : null;
|
|
120
|
+
}
|
|
121
|
+
/** The workspace + not-trashed guard every scan starts from. */
|
|
122
|
+
scope(type, workspaceId) {
|
|
123
|
+
const table = type.table;
|
|
124
|
+
return (0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(table['workspaceId'], workspaceId), type.paranoid ? (0, drizzle_orm_1.isNull)(table['deletedAt']) : undefined);
|
|
125
|
+
}
|
|
126
|
+
/** Scope + translated filter + optional id restriction. */
|
|
127
|
+
async predicate(type, filter, workspaceId, options) {
|
|
128
|
+
const filterExtension = this.filterProviders.compose(type, this.extension?.filterExtension(type));
|
|
129
|
+
const { schema } = (0, entry_filter_surface_1.buildEntryFilterSurface)(type, {
|
|
130
|
+
workspaceId,
|
|
131
|
+
extensionFields: filterExtension?.fields
|
|
132
|
+
});
|
|
133
|
+
const tree = (0, utils_server_1.parseFilterTree)(filter, schema);
|
|
134
|
+
const filterSql = await (0, utils_server_1.applyFilterTree)(tree, schema, type.table, this.db, filterExtension
|
|
135
|
+
? {
|
|
136
|
+
resolveExtension: (rule) => filterExtension.resolve(rule, { type, workspaceId })
|
|
137
|
+
}
|
|
138
|
+
: {});
|
|
139
|
+
const table = type.table;
|
|
140
|
+
return (0, drizzle_orm_1.and)(this.scope(type, workspaceId), filterSql, options.entryIds
|
|
141
|
+
? (0, drizzle_orm_1.inArray)(table['id'], [...options.entryIds])
|
|
142
|
+
: undefined);
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
exports.EntryMatchQuery = EntryMatchQuery;
|
|
146
|
+
exports.EntryMatchQuery = EntryMatchQuery = tslib_1.__decorate([
|
|
147
|
+
(0, common_1.Injectable)(),
|
|
148
|
+
tslib_1.__param(0, (0, database_1.InjectDatabase)()),
|
|
149
|
+
tslib_1.__param(2, (0, common_1.Optional)()),
|
|
150
|
+
tslib_1.__param(2, (0, common_1.Inject)(entry_extension_1.CONTENT_ENTRY_EXTENSION)),
|
|
151
|
+
tslib_1.__metadata("design:paramtypes", [Object, entry_filter_provider_1.EntryFilterProviderRegistry, Object])
|
|
152
|
+
], EntryMatchQuery);
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The **entry-access description port** — the seam a downstream plugin uses to
|
|
3
|
+
* tell a protocol adapter *that* an entry it just served is restricted, without
|
|
4
|
+
* this package knowing what a restriction is.
|
|
5
|
+
*
|
|
6
|
+
* It is the read scope's opposite number and deliberately not the same thing.
|
|
7
|
+
* `CONTENT_READ_SCOPE` decides which rows a reader gets; this describes the rows
|
|
8
|
+
* they got. A scope can only subtract, so by the time an annotation is asked
|
|
9
|
+
* for, the entry has already been admitted — which is why nothing here can
|
|
10
|
+
* refuse anything, and why an implementation that returned nothing would be a
|
|
11
|
+
* missing label rather than a leak.
|
|
12
|
+
*
|
|
13
|
+
* ## What it is for
|
|
14
|
+
*
|
|
15
|
+
* One thing, and it is worth being blunt about it: **a restricted entry must not
|
|
16
|
+
* be cached as public.** A CDN, a shared HTTP cache or a client store that keeps
|
|
17
|
+
* one reader's copy and hands it to the next turns a working entitlement into a
|
|
18
|
+
* data leak, and nothing else in the response says the entry was reader-scoped —
|
|
19
|
+
* it looks exactly like an open one. Everything else an implementation could
|
|
20
|
+
* report (who exactly may read it, which segments) is a question for the admin
|
|
21
|
+
* API, where the caller is a member of the workspace rather than a consumer of
|
|
22
|
+
* its content.
|
|
23
|
+
*
|
|
24
|
+
* ## Why a registry rather than a DI token
|
|
25
|
+
*
|
|
26
|
+
* The same reason the read scope uses one: Nest has **no multi-provider**, so
|
|
27
|
+
* two dynamic modules binding one token do not merge — the second silently
|
|
28
|
+
* replaces the first. Here that would mean an entry that one plugin knows is
|
|
29
|
+
* restricted reported as open because a second plugin was registered after it,
|
|
30
|
+
* which is precisely the mislabelling this port exists to prevent. Registration
|
|
31
|
+
* is a runtime `register(...)` call, the same shape as
|
|
32
|
+
* {@link contentReadScopeRegistrar}.
|
|
33
|
+
*
|
|
34
|
+
* ## The batching constraint
|
|
35
|
+
*
|
|
36
|
+
* `describe` takes the whole page of entry ids, not one. A protocol adapter
|
|
37
|
+
* resolves a list and then labels it; asking per entry would be a query per row
|
|
38
|
+
* on a path whose cost is otherwise one page read. An implementation that cannot
|
|
39
|
+
* answer for an id simply leaves it out of the map, and the caller reads a
|
|
40
|
+
* missing key as unrestricted — the same default absence carries everywhere
|
|
41
|
+
* else in this feature.
|
|
42
|
+
*/
|
|
43
|
+
import { type Provider, type Type } from '@nestjs/common';
|
|
44
|
+
/** What one entry's restriction looks like from outside the plugin that owns it. */
|
|
45
|
+
export interface EntryAccessDescription {
|
|
46
|
+
/**
|
|
47
|
+
* Whether anything narrowed who may read this entry.
|
|
48
|
+
*
|
|
49
|
+
* `true` means the response is reader-specific and must not be stored in a
|
|
50
|
+
* shared cache without varying on whatever identifies the reader.
|
|
51
|
+
*/
|
|
52
|
+
readonly restricted: boolean;
|
|
53
|
+
/**
|
|
54
|
+
* The axes that took part, as opaque keys (`org`, `plan`). A hint for a
|
|
55
|
+
* client deciding what to vary its own cache on — never the segments
|
|
56
|
+
* themselves, which would tell a consumer who *else* may read the entry.
|
|
57
|
+
*/
|
|
58
|
+
readonly dimensions: readonly string[];
|
|
59
|
+
}
|
|
60
|
+
/** What a source is told about the page it is describing. */
|
|
61
|
+
export interface EntryAccessQuery {
|
|
62
|
+
/** The workspace the entries were read in. */
|
|
63
|
+
readonly workspaceId: string;
|
|
64
|
+
/** The ids to describe. Never empty — the caller skips an empty page. */
|
|
65
|
+
readonly entryIds: readonly string[];
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* One contributed description of what was served.
|
|
69
|
+
*
|
|
70
|
+
* Returning an empty map means "nothing to say about this page", which is what
|
|
71
|
+
* an installed-but-unconfigured plugin returns and what every entry defaults to.
|
|
72
|
+
*/
|
|
73
|
+
export interface EntryAccessSource {
|
|
74
|
+
/** Describes the given entries, keyed by entry id. Ids may be omitted. */
|
|
75
|
+
describe(query: EntryAccessQuery): Promise<ReadonlyMap<string, EntryAccessDescription>>;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Every registered source, merged.
|
|
79
|
+
*
|
|
80
|
+
* The merge is a **union that can only tighten**: an entry is restricted if any
|
|
81
|
+
* source says so, and the dimensions are the union of what each reported. Two
|
|
82
|
+
* plugins narrowing reads for different reasons both need to be heard, and the
|
|
83
|
+
* safe direction for a disagreement is the one that keeps a shared cache from
|
|
84
|
+
* storing the entry.
|
|
85
|
+
*/
|
|
86
|
+
export declare class EntryAccessSourceRegistry {
|
|
87
|
+
private readonly sources;
|
|
88
|
+
/** Adds a source. Registering the same instance twice is a no-op. */
|
|
89
|
+
register(source: EntryAccessSource): void;
|
|
90
|
+
/** Whether anything is registered — the cheap check before building a page. */
|
|
91
|
+
get active(): boolean;
|
|
92
|
+
/**
|
|
93
|
+
* Describes one page of entries.
|
|
94
|
+
*
|
|
95
|
+
* Returns an empty map when nothing is registered or the page is empty, so
|
|
96
|
+
* a caller on an installation with no scoping plugin does no work and every
|
|
97
|
+
* entry reads as unrestricted.
|
|
98
|
+
*/
|
|
99
|
+
describe(query: EntryAccessQuery): Promise<ReadonlyMap<string, EntryAccessDescription>>;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Builds the DI provider that registers `sources` with content's access-source
|
|
103
|
+
* registry at bootstrap.
|
|
104
|
+
*
|
|
105
|
+
* A factory with an explicit `inject` list rather than a class with reflected
|
|
106
|
+
* parameters, for the reason `contentReadScopeRegistrar` documents: an optional
|
|
107
|
+
* dependency typed as `Foo | null` emits `Object` for `design:paramtypes`, Nest
|
|
108
|
+
* injects `undefined` with no error, and the plugin ends up registering
|
|
109
|
+
* nothing.
|
|
110
|
+
*/
|
|
111
|
+
export declare function entryAccessSourceRegistrar(label: string, ...sources: Type<EntryAccessSource>[]): Provider;
|
|
112
|
+
//# sourceMappingURL=entry-access-source.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"entry-access-source.d.ts","sourceRoot":"","sources":["../../../src/lib/extension/entry-access-source.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyCG;AAEH,OAAO,EAIH,KAAK,QAAQ,EACb,KAAK,IAAI,EACZ,MAAM,gBAAgB,CAAC;AAExB,oFAAoF;AACpF,MAAM,WAAW,sBAAsB;IACnC;;;;;OAKG;IACH,QAAQ,CAAC,UAAU,EAAE,OAAO,CAAC;IAC7B;;;;OAIG;IACH,QAAQ,CAAC,UAAU,EAAE,SAAS,MAAM,EAAE,CAAC;CAC1C;AAED,6DAA6D;AAC7D,MAAM,WAAW,gBAAgB;IAC7B,8CAA8C;IAC9C,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,yEAAyE;IACzE,QAAQ,CAAC,QAAQ,EAAE,SAAS,MAAM,EAAE,CAAC;CACxC;AAED;;;;;GAKG;AACH,MAAM,WAAW,iBAAiB;IAC9B,0EAA0E;IAC1E,QAAQ,CACJ,KAAK,EAAE,gBAAgB,GACxB,OAAO,CAAC,WAAW,CAAC,MAAM,EAAE,sBAAsB,CAAC,CAAC,CAAC;CAC3D;AAED;;;;;;;;GAQG;AACH,qBACa,yBAAyB;IAClC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAA2B;IAEnD,qEAAqE;IACrE,QAAQ,CAAC,MAAM,EAAE,iBAAiB,GAAG,IAAI;IAMzC,+EAA+E;IAC/E,IAAI,MAAM,IAAI,OAAO,CAEpB;IAED;;;;;;OAMG;IACG,QAAQ,CACV,KAAK,EAAE,gBAAgB,GACxB,OAAO,CAAC,WAAW,CAAC,MAAM,EAAE,sBAAsB,CAAC,CAAC;CAgC1D;AA4BD;;;;;;;;;GASG;AACH,wBAAgB,0BAA0B,CACtC,KAAK,EAAE,MAAM,EACb,GAAG,OAAO,EAAE,IAAI,CAAC,iBAAiB,CAAC,EAAE,GACtC,QAAQ,CAYV"}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The **virtual filter-field registry** — how more than one plugin contributes
|
|
3
|
+
* fields to a content type's `?filter=` surface.
|
|
4
|
+
*
|
|
5
|
+
* `CONTENT_ENTRY_EXTENSION.filterExtension` already declares virtual fields
|
|
6
|
+
* (i18n's `hasLocale` / `missingLocale` / `localeCount`), but that port is a
|
|
7
|
+
* documented **single binding**: one provider per app, and i18n holds it. Nest
|
|
8
|
+
* has no multi-provider, so a second plugin binding the token would silently
|
|
9
|
+
* replace the first — for a filter surface that means fields quietly vanishing
|
|
10
|
+
* from the picker and saved filters starting to 400.
|
|
11
|
+
*
|
|
12
|
+
* So a plugin registers here instead (`@orthacms/segments-server` contributes
|
|
13
|
+
* "can be seen by" / "cannot be seen by" / "restricted"), the same shape and the
|
|
14
|
+
* same reason as `contentReadScopeRegistrar`. The bound extension keeps its
|
|
15
|
+
* method; {@link EntryFilterProviderRegistry.compose} folds the two together
|
|
16
|
+
* into the one `EntryFilterExtension` the query path already knows how to use,
|
|
17
|
+
* so nothing downstream changed.
|
|
18
|
+
*
|
|
19
|
+
* ## What a contribution owes
|
|
20
|
+
*
|
|
21
|
+
* **Every emitted subquery MUST scope to the workspace it is handed.** A virtual
|
|
22
|
+
* field is a subquery over a table this package knows nothing about; one that
|
|
23
|
+
* forgets the workspace turns a filter into a cross-tenant read.
|
|
24
|
+
*
|
|
25
|
+
* **Only declare fields you can answer, with only the operators you support.**
|
|
26
|
+
* The declared `fields` become the SQL whitelist, so an operator the resolver
|
|
27
|
+
* refuses reaches the user as "couldn't load this collection" over a rule the
|
|
28
|
+
* picker itself proposed. Narrow the operator set in the admin's `FilterField`
|
|
29
|
+
* to match.
|
|
30
|
+
*
|
|
31
|
+
* **A filter narrows what is listed; it is not a visibility rule.** Reachability
|
|
32
|
+
* is `CONTENT_READ_SCOPE`'s job and is applied separately. A field here must not
|
|
33
|
+
* be relied on to hide anything.
|
|
34
|
+
*/
|
|
35
|
+
import { type Provider, type Type } from '@nestjs/common';
|
|
36
|
+
import type { AnyContentType } from '../types/content-type';
|
|
37
|
+
import type { EntryFilterExtension } from './entry-extension';
|
|
38
|
+
/** One plugin's virtual filter fields. */
|
|
39
|
+
export interface EntryFilterProvider {
|
|
40
|
+
/**
|
|
41
|
+
* The fields this plugin adds to `type`'s filter surface, or `undefined`
|
|
42
|
+
* when it adds none — which is what a plugin that is installed but
|
|
43
|
+
* unconfigured must return, so a bare install pays nothing.
|
|
44
|
+
*/
|
|
45
|
+
filterFor(type: AnyContentType): EntryFilterExtension | undefined;
|
|
46
|
+
}
|
|
47
|
+
/** Every registered provider, in registration order. */
|
|
48
|
+
export declare class EntryFilterProviderRegistry {
|
|
49
|
+
private readonly logger;
|
|
50
|
+
private readonly providers;
|
|
51
|
+
/** Adds a provider. Registering the same instance twice is a no-op. */
|
|
52
|
+
register(provider: EntryFilterProvider): void;
|
|
53
|
+
/**
|
|
54
|
+
* Fold the bound extension's contribution and every registered provider's
|
|
55
|
+
* into one {@link EntryFilterExtension}.
|
|
56
|
+
*
|
|
57
|
+
* Returns `undefined` when nobody contributed anything, so a type with no
|
|
58
|
+
* virtual fields builds exactly the surface it did before this registry
|
|
59
|
+
* existed — and `entries.service` keeps its "no extension, no
|
|
60
|
+
* `resolveExtension`" branch.
|
|
61
|
+
*
|
|
62
|
+
* `resolve` routes by the rule's **field name**, to whoever declared it.
|
|
63
|
+
* A name declared twice is kept by its first declarer and the duplicate is
|
|
64
|
+
* dropped with a warning: silently letting the last writer win would make
|
|
65
|
+
* the meaning of a saved filter depend on plugin registration order.
|
|
66
|
+
*/
|
|
67
|
+
compose(type: AnyContentType, bound: EntryFilterExtension | undefined): EntryFilterExtension | undefined;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Builds the DI provider that registers `providers` with content's filter-field
|
|
71
|
+
* registry at bootstrap.
|
|
72
|
+
*
|
|
73
|
+
* A factory with an explicit `inject` list rather than a class with reflected
|
|
74
|
+
* parameters, for the reason `contentReadScopeRegistrar` documents: an optional
|
|
75
|
+
* dependency typed `Foo | null` emits `Object` for `design:paramtypes`, Nest
|
|
76
|
+
* injects `undefined` with no error, and the plugin registers nothing.
|
|
77
|
+
*/
|
|
78
|
+
export declare function entryFilterProviderRegistrar(label: string, ...providers: Type<EntryFilterProvider>[]): Provider;
|
|
79
|
+
//# sourceMappingURL=entry-filter-provider.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"entry-filter-provider.d.ts","sourceRoot":"","sources":["../../../src/lib/extension/entry-filter-provider.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AAEH,OAAO,EAIH,KAAK,QAAQ,EACb,KAAK,IAAI,EACZ,MAAM,gBAAgB,CAAC;AAExB,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AAC5D,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,mBAAmB,CAAC;AAE9D,0CAA0C;AAC1C,MAAM,WAAW,mBAAmB;IAChC;;;;OAIG;IACH,SAAS,CAAC,IAAI,EAAE,cAAc,GAAG,oBAAoB,GAAG,SAAS,CAAC;CACrE;AAED,wDAAwD;AACxD,qBACa,2BAA2B;IACpC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAgD;IACvE,OAAO,CAAC,QAAQ,CAAC,SAAS,CAA6B;IAEvD,uEAAuE;IACvE,QAAQ,CAAC,QAAQ,EAAE,mBAAmB,GAAG,IAAI;IAM7C;;;;;;;;;;;;;OAaG;IACH,OAAO,CACH,IAAI,EAAE,cAAc,EACpB,KAAK,EAAE,oBAAoB,GAAG,SAAS,GACxC,oBAAoB,GAAG,SAAS;CAyCtC;AA0BD;;;;;;;;GAQG;AACH,wBAAgB,4BAA4B,CACxC,KAAK,EAAE,MAAM,EACb,GAAG,SAAS,EAAE,IAAI,CAAC,mBAAmB,CAAC,EAAE,GAC1C,QAAQ,CAYV"}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* The **virtual filter-field registry** — how more than one plugin contributes
|
|
4
|
+
* fields to a content type's `?filter=` surface.
|
|
5
|
+
*
|
|
6
|
+
* `CONTENT_ENTRY_EXTENSION.filterExtension` already declares virtual fields
|
|
7
|
+
* (i18n's `hasLocale` / `missingLocale` / `localeCount`), but that port is a
|
|
8
|
+
* documented **single binding**: one provider per app, and i18n holds it. Nest
|
|
9
|
+
* has no multi-provider, so a second plugin binding the token would silently
|
|
10
|
+
* replace the first — for a filter surface that means fields quietly vanishing
|
|
11
|
+
* from the picker and saved filters starting to 400.
|
|
12
|
+
*
|
|
13
|
+
* So a plugin registers here instead (`@orthacms/segments-server` contributes
|
|
14
|
+
* "can be seen by" / "cannot be seen by" / "restricted"), the same shape and the
|
|
15
|
+
* same reason as `contentReadScopeRegistrar`. The bound extension keeps its
|
|
16
|
+
* method; {@link EntryFilterProviderRegistry.compose} folds the two together
|
|
17
|
+
* into the one `EntryFilterExtension` the query path already knows how to use,
|
|
18
|
+
* so nothing downstream changed.
|
|
19
|
+
*
|
|
20
|
+
* ## What a contribution owes
|
|
21
|
+
*
|
|
22
|
+
* **Every emitted subquery MUST scope to the workspace it is handed.** A virtual
|
|
23
|
+
* field is a subquery over a table this package knows nothing about; one that
|
|
24
|
+
* forgets the workspace turns a filter into a cross-tenant read.
|
|
25
|
+
*
|
|
26
|
+
* **Only declare fields you can answer, with only the operators you support.**
|
|
27
|
+
* The declared `fields` become the SQL whitelist, so an operator the resolver
|
|
28
|
+
* refuses reaches the user as "couldn't load this collection" over a rule the
|
|
29
|
+
* picker itself proposed. Narrow the operator set in the admin's `FilterField`
|
|
30
|
+
* to match.
|
|
31
|
+
*
|
|
32
|
+
* **A filter narrows what is listed; it is not a visibility rule.** Reachability
|
|
33
|
+
* is `CONTENT_READ_SCOPE`'s job and is applied separately. A field here must not
|
|
34
|
+
* be relied on to hide anything.
|
|
35
|
+
*/
|
|
36
|
+
var EntryFilterProviderRegistry_1;
|
|
37
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
38
|
+
exports.EntryFilterProviderRegistry = void 0;
|
|
39
|
+
exports.entryFilterProviderRegistrar = entryFilterProviderRegistrar;
|
|
40
|
+
const tslib_1 = require("tslib");
|
|
41
|
+
const common_1 = require("@nestjs/common");
|
|
42
|
+
/** Every registered provider, in registration order. */
|
|
43
|
+
let EntryFilterProviderRegistry = EntryFilterProviderRegistry_1 = class EntryFilterProviderRegistry {
|
|
44
|
+
logger = new common_1.Logger(EntryFilterProviderRegistry_1.name);
|
|
45
|
+
providers = [];
|
|
46
|
+
/** Adds a provider. Registering the same instance twice is a no-op. */
|
|
47
|
+
register(provider) {
|
|
48
|
+
if (!this.providers.includes(provider)) {
|
|
49
|
+
this.providers.push(provider);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Fold the bound extension's contribution and every registered provider's
|
|
54
|
+
* into one {@link EntryFilterExtension}.
|
|
55
|
+
*
|
|
56
|
+
* Returns `undefined` when nobody contributed anything, so a type with no
|
|
57
|
+
* virtual fields builds exactly the surface it did before this registry
|
|
58
|
+
* existed — and `entries.service` keeps its "no extension, no
|
|
59
|
+
* `resolveExtension`" branch.
|
|
60
|
+
*
|
|
61
|
+
* `resolve` routes by the rule's **field name**, to whoever declared it.
|
|
62
|
+
* A name declared twice is kept by its first declarer and the duplicate is
|
|
63
|
+
* dropped with a warning: silently letting the last writer win would make
|
|
64
|
+
* the meaning of a saved filter depend on plugin registration order.
|
|
65
|
+
*/
|
|
66
|
+
compose(type, bound) {
|
|
67
|
+
const contributions = [
|
|
68
|
+
...(bound ? [bound] : []),
|
|
69
|
+
...this.providers
|
|
70
|
+
.map((provider) => provider.filterFor(type))
|
|
71
|
+
.filter((entry) => !!entry)
|
|
72
|
+
];
|
|
73
|
+
if (!contributions.length)
|
|
74
|
+
return undefined;
|
|
75
|
+
if (contributions.length === 1)
|
|
76
|
+
return contributions[0];
|
|
77
|
+
const fields = {};
|
|
78
|
+
const owners = new Map();
|
|
79
|
+
for (const contribution of contributions) {
|
|
80
|
+
for (const [name, spec] of Object.entries(contribution.fields)) {
|
|
81
|
+
if (owners.has(name)) {
|
|
82
|
+
this.logger.warn(`Two plugins declare the filter field "${name}" on "${type.name}"; keeping the first.`);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
owners.set(name, contribution);
|
|
86
|
+
fields[name] = spec;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
fields,
|
|
91
|
+
resolve: (rule, context) => {
|
|
92
|
+
const owner = owners.get(rule.path[0]);
|
|
93
|
+
if (!owner) {
|
|
94
|
+
// Unreachable through the parser — a rule only gets here
|
|
95
|
+
// after its name was whitelisted from `fields` above — but a
|
|
96
|
+
// thrown error beats a silently-true predicate if it ever is.
|
|
97
|
+
throw new Error(`No provider owns the filter field "${rule.path[0]}".`);
|
|
98
|
+
}
|
|
99
|
+
return owner.resolve(rule, context);
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
exports.EntryFilterProviderRegistry = EntryFilterProviderRegistry;
|
|
105
|
+
exports.EntryFilterProviderRegistry = EntryFilterProviderRegistry = EntryFilterProviderRegistry_1 = tslib_1.__decorate([
|
|
106
|
+
(0, common_1.Injectable)()
|
|
107
|
+
], EntryFilterProviderRegistry);
|
|
108
|
+
/** Registers a plugin's filter providers with content at bootstrap. */
|
|
109
|
+
class EntryFilterBootstrapper {
|
|
110
|
+
label;
|
|
111
|
+
registry;
|
|
112
|
+
providers;
|
|
113
|
+
logger = new common_1.Logger(EntryFilterBootstrapper.name);
|
|
114
|
+
constructor(label, registry, providers) {
|
|
115
|
+
this.label = label;
|
|
116
|
+
this.registry = registry;
|
|
117
|
+
this.providers = providers;
|
|
118
|
+
}
|
|
119
|
+
onApplicationBootstrap() {
|
|
120
|
+
// A deployment without `ContentPlugin` is not a real configuration, but
|
|
121
|
+
// a binding plugin should still boot rather than fail on an injection it
|
|
122
|
+
// cannot influence.
|
|
123
|
+
if (!this.registry)
|
|
124
|
+
return;
|
|
125
|
+
for (const provider of this.providers) {
|
|
126
|
+
this.registry.register(provider);
|
|
127
|
+
}
|
|
128
|
+
this.logger.log(`Registered the ${this.label} filter fields with content.`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Builds the DI provider that registers `providers` with content's filter-field
|
|
133
|
+
* registry at bootstrap.
|
|
134
|
+
*
|
|
135
|
+
* A factory with an explicit `inject` list rather than a class with reflected
|
|
136
|
+
* parameters, for the reason `contentReadScopeRegistrar` documents: an optional
|
|
137
|
+
* dependency typed `Foo | null` emits `Object` for `design:paramtypes`, Nest
|
|
138
|
+
* injects `undefined` with no error, and the plugin registers nothing.
|
|
139
|
+
*/
|
|
140
|
+
function entryFilterProviderRegistrar(label, ...providers) {
|
|
141
|
+
return {
|
|
142
|
+
provide: `ENTRY_FILTER_PROVIDER_REGISTRAR_${label.toUpperCase()}`,
|
|
143
|
+
useFactory: (registry, ...resolved) => new EntryFilterBootstrapper(label, registry, resolved),
|
|
144
|
+
inject: [
|
|
145
|
+
{ token: EntryFilterProviderRegistry, optional: true },
|
|
146
|
+
...providers
|
|
147
|
+
]
|
|
148
|
+
};
|
|
149
|
+
}
|