@crombolo/content 0.0.0-stage → 0.24.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kineco
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/NOTICE ADDED
@@ -0,0 +1,69 @@
1
+ crombolo
2
+ Copyright (c) 2026 Kineco
3
+
4
+ This product includes third-party software. crombolo is an independent project.
5
+ It is not affiliated with, endorsed by, or sponsored by Umbraco A/S, which owns
6
+ the Umbraco trademark.
7
+
8
+ There are three places third-party material is redistributed, rather than merely
9
+ depended on:
10
+
11
+ @crombolo/backoffice-dist the built Umbraco backoffice and the browser
12
+ packages bundled into it, produced by
13
+ `bun run vendor:backoffice`
14
+ @crombolo/backoffice-dist three CSS files copied unmodified from the Umbraco
15
+ client source tree
16
+ @crombolo/contracts the Umbraco Management API OpenAPI document,
17
+ copied unmodified
18
+
19
+ No Umbraco trademark is redistributed. The Umbraco wordmark, the "U" roundel
20
+ favicon and the installer illustration are deliberately not carried in this
21
+ repository: MIT grants copyright permission and says nothing about trademarks, so
22
+ crombolo's own marks are served at those paths instead. See `BRANDED_ASSETS` in
23
+ `@crombolo/backoffice-host`.
24
+
25
+ The Umbraco backoffice
26
+ ----------------------
27
+ @umbraco-cms/backoffice 18.2.0 — MIT — https://github.com/umbraco/Umbraco-CMS
28
+ @umbraco-ui/uui 2.0.2 — MIT — https://github.com/umbraco/Umbraco.UI
29
+
30
+ Static files from the Umbraco client source tree
31
+ ------------------------------------------------
32
+ css/umb-css.css, css/rte-content.css, css/umbraco-blockgridlayout.css
33
+ — copied unmodified from Umbraco-CMS release-18.2.0 — MIT
34
+ — https://github.com/umbraco/Umbraco-CMS
35
+ Shipped in @crombolo/backoffice-dist under `upstream-static/`.
36
+
37
+ `rte-content.css` itself carries Tiptap's default editor styles, copied from
38
+ tiptap v2.11.7 (MIT); the file records this in its own header.
39
+
40
+ The Umbraco Management API contract
41
+ -----------------------------------
42
+ OpenApi.json — copied unmodified from Umbraco-CMS release-18.2.0
43
+ (src/Umbraco.Cms.Api.Management/OpenApi.json) — MIT
44
+ — https://github.com/umbraco/Umbraco-CMS
45
+ Shipped in @crombolo/contracts, which also ships the TypeScript types generated
46
+ from it.
47
+
48
+ Browser runtime dependencies bundled into the backoffice build
49
+ --------------------------------------------------------------
50
+ @heximal/expressions 0.1.5 — BSD-3-Clause
51
+ @microsoft/signalr 10.0.11 — MIT
52
+ @tiptap/* 3.31.3 — MIT
53
+ diff 9.0.0 — BSD-3-Clause
54
+ dompurify 3.4.16 — Apache-2.0, elected from "MPL-2.0 OR Apache-2.0"
55
+ element-internals-polyfill 3.0.2 — MIT
56
+ lit 3.3.3 — BSD-3-Clause
57
+ luxon 3.7.2 — MIT
58
+ marked 18.0.14 — MIT
59
+ monaco-editor 0.57.0 — MIT
60
+ rxjs 7.8.2 — Apache-2.0
61
+ uuid 14.0.2 — MIT
62
+
63
+ dompurify is offered under either licence; Apache-2.0 is elected here, so no
64
+ MPL-2.0 obligation attaches to the copy bundled into the backoffice build. Of the
65
+ Apache-2.0 packages above, none ships a NOTICE file of its own, so Apache-2.0
66
+ section 4(d) adds nothing to this file.
67
+
68
+ Each package's own licence text ships inside its npm distribution and applies
69
+ unchanged to the copies bundled here.
package/package.json CHANGED
@@ -1,6 +1,35 @@
1
1
  {
2
2
  "name": "@crombolo/content",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.24.1",
4
+ "description": "The rules every document write follows, whoever starts it: who may, approvals, and what it announces. No transport.",
5
+ "license": "MIT",
6
+ "author": "Crombolo",
7
+ "homepage": "https://github.com/kineco-au/crombolo-cms#readme",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/kineco-au/crombolo-cms.git",
11
+ "directory": "packages/content"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/kineco-au/crombolo-cms/issues"
15
+ },
16
+ "engines": {
17
+ "bun": ">=1.3.0"
18
+ },
19
+ "type": "module",
20
+ "exports": {
21
+ ".": "./src/index.ts",
22
+ "./rules": "./src/rules.ts"
23
+ },
24
+ "files": [
25
+ "src",
26
+ "NOTICE"
27
+ ],
28
+ "publishConfig": {
29
+ "access": "public"
30
+ },
31
+ "dependencies": {
32
+ "@crombolo/core": "0.24.1",
33
+ "@crombolo/data": "0.24.1"
34
+ }
35
+ }
package/src/actor.ts ADDED
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Who a document write is for (story 170). Every operation is handed one, so what
3
+ * it may do, whether approvals apply and whom the audit names never depend on how
4
+ * the write was started. Authentication stays with the transport that made it.
5
+ */
6
+ import type { GroupGrant, StartNodes } from '@crombolo/core'
7
+
8
+ /** A backoffice user, as far as a document write needs to know one. */
9
+ export interface ContentPrincipal {
10
+ /** The user's key. */
11
+ id: string
12
+ name: string
13
+ isAdmin: boolean
14
+ /** Section manifest aliases, such as `Umb.Section.Content`. */
15
+ allowedSections: readonly string[]
16
+ hasAccessToAllLanguages: boolean
17
+ languages: readonly string[]
18
+ startNodes: { document: StartNodes }
19
+ groups: readonly GroupGrant[]
20
+ }
21
+
22
+ /** Where a write came from, for the audit log. */
23
+ export interface ActorOrigin {
24
+ source: 'request' | 'cli' | 'job'
25
+ ipAddress?: string | null
26
+ forwardedFor?: string | null
27
+ }
28
+
29
+ export type Actor =
30
+ /** A signed-in user: their permissions, start nodes, languages and approvals. */
31
+ | { kind: 'user'; principal: ContentPrincipal; origin: ActorOrigin }
32
+ /**
33
+ * Someone at the command line on the box: no permission checks, since they hold
34
+ * the database, but approvals apply unless they say `skipApproval`, which the
35
+ * audit records.
36
+ */
37
+ | { kind: 'operator'; name: string; skipApproval?: boolean; origin: ActorOrigin }
38
+ /**
39
+ * The site acting on what was already decided — a schedule, an approved
40
+ * request, a form's workflow: no permission checks and no approval gate.
41
+ */
42
+ | {
43
+ kind: 'system'
44
+ name: string
45
+ origin: ActorOrigin
46
+ /** The person it acts for, when there is one: the approver of a request. */
47
+ user?: { id: string; name: string }
48
+ /** Said with what it did, in notifications and the audit: `approved through "Legal"`. */
49
+ note?: string
50
+ }
51
+
52
+ export const userActor = (
53
+ principal: ContentPrincipal,
54
+ origin: Omit<ActorOrigin, 'source'> = {},
55
+ ): Actor => ({ kind: 'user', principal, origin: { source: 'request', ...origin } })
56
+
57
+ export const operatorActor = (name: string, options: { skipApproval?: boolean } = {}): Actor => ({
58
+ kind: 'operator',
59
+ name,
60
+ skipApproval: options.skipApproval,
61
+ origin: { source: 'cli' },
62
+ })
63
+
64
+ export const systemActor = (
65
+ name: string,
66
+ options: {
67
+ source?: ActorOrigin['source']
68
+ user?: { id: string; name: string }
69
+ note?: string
70
+ } = {},
71
+ ): Actor => ({
72
+ kind: 'system',
73
+ name,
74
+ origin: { source: options.source ?? 'job' },
75
+ user: options.user,
76
+ note: options.note,
77
+ })
78
+
79
+ /** The name an audit entry or a notification gives the actor. */
80
+ export const actorName = (actor: Actor): string =>
81
+ actor.kind === 'user'
82
+ ? actor.principal.name
83
+ : actor.kind === 'system'
84
+ ? (actor.user?.name ?? actor.name)
85
+ : actor.name
86
+
87
+ /** The user's key, when the actor is one; versions record it as their author. */
88
+ export const actorUserKey = (actor: Actor): string | null =>
89
+ actor.kind === 'user'
90
+ ? actor.principal.id
91
+ : actor.kind === 'system'
92
+ ? (actor.user?.id ?? null)
93
+ : null
94
+
95
+ /** What the actor says alongside the change, if anything. */
96
+ export const actorNote = (actor: Actor): string | undefined =>
97
+ actor.kind === 'system' ? actor.note : undefined
package/src/events.ts ADDED
@@ -0,0 +1,267 @@
1
+ /**
2
+ * What a document write did, as the operation reports it (story 170). An
3
+ * operation reports to an `EventSink`; the server and the command line each wire
4
+ * one to the same database-backed steps — the audit log, webhooks, change
5
+ * notifications — and the server adds live backoffice refresh. The mappings here
6
+ * are pure, so every transport turns one event into the same entries.
7
+ */
8
+ import type { Db } from '@crombolo/data'
9
+ import { type Actor, actorName, actorNote, actorUserKey } from './actor.ts'
10
+
11
+ export type DocumentEvent =
12
+ | { type: 'created'; key: string }
13
+ | { type: 'saved'; key: string }
14
+ | { type: 'createdAndPublished'; key: string; cultures: readonly string[] | null }
15
+ | { type: 'savedAndPublished'; key: string; cultures: readonly string[] | null }
16
+ | { type: 'published'; key: string; cultures: readonly string[] | null }
17
+ /** Only scheduled: nothing is published until the schedule fires. */
18
+ | { type: 'scheduled'; key: string }
19
+ /** A page and the pages below it: `published` names each one that went live. */
20
+ | { type: 'branchPublished'; key: string; published: readonly string[] }
21
+ | { type: 'unpublished'; key: string; cultures: readonly string[] | null }
22
+ | { type: 'trashed'; key: string }
23
+ /** `name` is read before it went, since it cannot be read after. */
24
+ | { type: 'deleted'; key: string; name: string | null }
25
+ | { type: 'restored'; key: string }
26
+ | { type: 'binEmptied' }
27
+ | { type: 'moved'; key: string }
28
+ | { type: 'copied'; key: string; copyKey: string }
29
+ /** `parentKey` null: the top level. */
30
+ | { type: 'sorted'; parentKey: string | null }
31
+ | { type: 'rolledBack'; key: string; versionId: string }
32
+ | { type: 'domainsAssigned'; key: string }
33
+ | { type: 'publicAccessChanged'; key: string; change: 'added' | 'set' | 'removed' }
34
+
35
+ /**
36
+ * Where an operation reports what it did (story 171). `record` runs inside the
37
+ * change's own transaction, so what must not be lost — the audit entry, the
38
+ * announcement waiting to go out — commits with the change or not at all.
39
+ * `afterCommit` is for what only a running server does once it has.
40
+ */
41
+ export interface EventSink {
42
+ record(tx: Db, event: DocumentEvent, actor: Actor): Promise<void>
43
+ afterCommit?(event: DocumentEvent, actor: Actor): void
44
+ }
45
+
46
+ /** A sink that hears nothing: for tests and tools that announce nothing. */
47
+ export const SILENT: EventSink = { record: async () => {} }
48
+
49
+ /** Several sinks heard in turn. */
50
+ export const both = (...sinks: EventSink[]): EventSink => ({
51
+ async record(tx, event, actor) {
52
+ for (const sink of sinks) await sink.record(tx, event, actor)
53
+ },
54
+ afterCommit(event, actor) {
55
+ for (const sink of sinks) sink.afterCommit?.(event, actor)
56
+ },
57
+ })
58
+
59
+ /** The audit entry an event writes: the same whichever transport started it. Pure. */
60
+ export function auditFor(event: DocumentEvent): {
61
+ category: 'content' | 'permission'
62
+ action: string
63
+ entityType: string
64
+ entityKey: string | null
65
+ detail?: string
66
+ } {
67
+ const doc = (action: string, key: string | null) => ({
68
+ category: 'content' as const,
69
+ action,
70
+ entityType: 'Document',
71
+ entityKey: key,
72
+ })
73
+ switch (event.type) {
74
+ case 'created':
75
+ return doc('Create', event.key)
76
+ case 'saved':
77
+ return doc('Save', event.key)
78
+ case 'createdAndPublished':
79
+ return doc('CreateAndPublish', event.key)
80
+ case 'savedAndPublished':
81
+ return doc('SaveAndPublish', event.key)
82
+ case 'published':
83
+ case 'scheduled':
84
+ return doc('Publish', event.key)
85
+ case 'branchPublished':
86
+ return doc('PublishWithDescendants', event.key)
87
+ case 'unpublished':
88
+ return doc('Unpublish', event.key)
89
+ case 'trashed':
90
+ return doc('Trash', event.key)
91
+ case 'deleted':
92
+ return doc('Delete', event.key)
93
+ case 'restored':
94
+ return doc('Restore', event.key)
95
+ case 'binEmptied':
96
+ return doc('EmptyRecycleBin', null)
97
+ case 'moved':
98
+ return doc('Move', event.key)
99
+ case 'copied':
100
+ return doc('Copy', event.key)
101
+ case 'sorted':
102
+ return doc('Sort', event.parentKey)
103
+ case 'rolledBack':
104
+ return { ...doc('Rollback', event.versionId), entityType: 'DocumentVersion' }
105
+ case 'domainsAssigned':
106
+ return doc('AssignDomains', event.key)
107
+ case 'publicAccessChanged':
108
+ return {
109
+ ...doc(
110
+ event.change === 'added'
111
+ ? 'AddPublicAccess'
112
+ : event.change === 'set'
113
+ ? 'SetPublicAccess'
114
+ : 'RemovePublicAccess',
115
+ event.key,
116
+ ),
117
+ category: 'permission',
118
+ }
119
+ }
120
+ }
121
+
122
+ /** The webhook an event raises, by Umbraco's alias, and the page it names. Pure. */
123
+ export function webhookFor(event: DocumentEvent): { alias: string; key: string } | undefined {
124
+ switch (event.type) {
125
+ case 'created':
126
+ case 'saved':
127
+ return { alias: 'Umbraco.ContentSaved', key: event.key }
128
+ case 'createdAndPublished':
129
+ case 'savedAndPublished':
130
+ case 'published':
131
+ case 'branchPublished':
132
+ return { alias: 'Umbraco.ContentPublish', key: event.key }
133
+ case 'unpublished':
134
+ return { alias: 'Umbraco.ContentUnpublish', key: event.key }
135
+ case 'deleted':
136
+ return { alias: 'Umbraco.ContentDelete', key: event.key }
137
+ case 'trashed':
138
+ return { alias: 'Umbraco.ContentMovedToRecycleBin', key: event.key }
139
+ // Out of the bin is a move, as Umbraco raises it.
140
+ case 'moved':
141
+ case 'restored':
142
+ return { alias: 'Umbraco.ContentMoved', key: event.key }
143
+ case 'copied':
144
+ return { alias: 'Umbraco.ContentCopied', key: event.copyKey }
145
+ case 'sorted':
146
+ return event.parentKey ? { alias: 'Umbraco.ContentSorted', key: event.parentKey } : undefined
147
+ case 'rolledBack':
148
+ return { alias: 'Umbraco.ContentRolledBack', key: event.key }
149
+ // A schedule publishes nothing yet: the job raises it when it fires.
150
+ case 'scheduled':
151
+ case 'binEmptied':
152
+ case 'domainsAssigned':
153
+ case 'publicAccessChanged':
154
+ return undefined
155
+ }
156
+ }
157
+
158
+ /** The change people subscribed to a page hear of (story 045), if any. Pure. */
159
+ export function notificationFor(event: DocumentEvent):
160
+ | {
161
+ actions: string[]
162
+ key: string
163
+ keyKind: 'document' | 'version'
164
+ withDescendants?: boolean
165
+ }
166
+ | undefined {
167
+ const of = (actions: string[], key: string) => ({ actions, key, keyKind: 'document' as const })
168
+ switch (event.type) {
169
+ case 'created':
170
+ return of(['Umb.Document.Create'], event.key)
171
+ case 'saved':
172
+ return of(['Umb.Document.Update'], event.key)
173
+ case 'createdAndPublished':
174
+ return of(['Umb.Document.Create', 'Umb.Document.Publish'], event.key)
175
+ case 'savedAndPublished':
176
+ return of(['Umb.Document.Update', 'Umb.Document.Publish'], event.key)
177
+ case 'published':
178
+ return of(['Umb.Document.Publish'], event.key)
179
+ case 'branchPublished':
180
+ return { ...of(['Umb.Document.Publish'], event.key), withDescendants: true }
181
+ case 'trashed':
182
+ return of(['Umb.Document.Delete'], event.key)
183
+ case 'moved':
184
+ return of(['Umb.Document.Move'], event.key)
185
+ // Notified on the original, as Umbraco does.
186
+ case 'copied':
187
+ return of(['Umb.Document.Duplicate'], event.key)
188
+ // On the parent whose children were sorted; sorting the top level notifies nobody.
189
+ case 'sorted':
190
+ return event.parentKey ? of(['Umb.Document.Sort'], event.parentKey) : undefined
191
+ case 'rolledBack':
192
+ return { actions: ['Umb.Document.Rollback'], key: event.versionId, keyKind: 'version' }
193
+ case 'restored':
194
+ return of(['Umb.DocumentRecycleBin.Restore'], event.key)
195
+ case 'publicAccessChanged':
196
+ return of(['Umb.Document.PublicAccess'], event.key)
197
+ case 'scheduled':
198
+ case 'unpublished':
199
+ case 'deleted':
200
+ case 'binEmptied':
201
+ case 'domainsAssigned':
202
+ return undefined
203
+ }
204
+ }
205
+
206
+ /**
207
+ * The live events an open backoffice drops its cached copy on, by Umbraco's
208
+ * names: one per page the change touched. Pure.
209
+ */
210
+ export function liveEventsFor(event: DocumentEvent): Array<{
211
+ eventSource: string
212
+ eventType: 'Created' | 'Updated' | 'Deleted' | 'Trashed'
213
+ key: string
214
+ }> {
215
+ const source = 'Umbraco:CMS:Document'
216
+ const one = (eventType: 'Created' | 'Updated' | 'Deleted' | 'Trashed', key: string) => [
217
+ { eventSource: source, eventType, key },
218
+ ]
219
+ switch (event.type) {
220
+ case 'created':
221
+ case 'createdAndPublished':
222
+ return one('Created', event.key)
223
+ case 'copied':
224
+ return one('Created', event.copyKey)
225
+ case 'trashed':
226
+ return one('Trashed', event.key)
227
+ case 'deleted':
228
+ return one('Deleted', event.key)
229
+ case 'branchPublished':
230
+ return [...new Set([event.key, ...event.published])].map((key) => ({
231
+ eventSource: source,
232
+ eventType: 'Updated' as const,
233
+ key,
234
+ }))
235
+ case 'sorted':
236
+ return event.parentKey ? one('Updated', event.parentKey) : []
237
+ case 'binEmptied':
238
+ return []
239
+ default:
240
+ return one('Updated', event.key)
241
+ }
242
+ }
243
+
244
+ /**
245
+ * The whole audit entry an event writes, as every transport writes it: the
246
+ * entry, who acted and from where. An operator who skipped approval is said to
247
+ * have. The item's name is the transport's to look up. Pure.
248
+ */
249
+ export function auditEntryFor(event: DocumentEvent, actor: Actor) {
250
+ const entry = auditFor(event)
251
+ const skipped = actor.kind === 'operator' && actor.skipApproval === true
252
+ return {
253
+ ...entry,
254
+ source: actor.origin.source,
255
+ userKey: actorUserKey(actor),
256
+ userName: actorName(actor),
257
+ ipAddress: actor.origin.ipAddress ?? null,
258
+ forwardedFor: actor.origin.forwardedFor ?? null,
259
+ detail: skipped
260
+ ? 'Approval skipped from the command line'
261
+ : (sentence(actorNote(actor)) ?? entry.detail ?? null),
262
+ }
263
+ }
264
+
265
+ /** A note as the audit log writes it: a capital first, as its entries read. */
266
+ const sentence = (note: string | undefined) =>
267
+ note ? note.charAt(0).toUpperCase() + note.slice(1) : undefined
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export * from './operations.ts'
2
+ export * from './rules.ts'