@crombolo/content 0.0.0-stage → 0.23.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.
package/src/policy.ts ADDED
@@ -0,0 +1,130 @@
1
+ /**
2
+ * What a user may do to a document (story 170): the verbs each write needs, where
3
+ * it needs them, the languages it touches and the properties it changes. One
4
+ * table: the Management API's router builds its own early checks from it, and the
5
+ * operations apply it to every user actor, whatever started the write.
6
+ */
7
+ import {
8
+ DocumentPermissions as D,
9
+ hasPathAccess,
10
+ permissionsForPath,
11
+ propertyValuePermissions,
12
+ sectionKey,
13
+ } from '@crombolo/core'
14
+ import type { ContentPrincipal } from './actor.ts'
15
+
16
+ /** The verbs each document write needs, on the page it names (and, where stated, a target). */
17
+ export const WRITE_VERBS = {
18
+ create: [D.Create],
19
+ save: [D.Update],
20
+ publish: [D.Publish],
21
+ unpublish: [D.Unpublish],
22
+ trash: [D.Delete],
23
+ delete: [D.Delete],
24
+ move: [D.Move],
25
+ /** Where a moved or copied page lands. */
26
+ intoTarget: [D.Create],
27
+ copy: [D.Duplicate],
28
+ sort: [D.Sort],
29
+ rollback: [D.Rollback],
30
+ domains: [D.CultureAndHostnames],
31
+ publicAccess: [D.PublicAccess],
32
+ /** The recycle bin: restoring moves a page out of it. */
33
+ restore: [D.Move],
34
+ emptyBin: [D.Delete],
35
+ } as const satisfies Record<string, readonly string[]>
36
+
37
+ export type WriteKind = keyof typeof WRITE_VERBS
38
+
39
+ /** Whether the user holds a section, named in either vocabulary. Pure. */
40
+ export const holdsSection = (principal: ContentPrincipal, section: string): boolean =>
41
+ principal.allowedSections.some((alias) => sectionKey(alias) === sectionKey(section))
42
+
43
+ /**
44
+ * Whether the user may act on a page at `chain` (its ancestors' keys, then its
45
+ * own, root first), or on the root (`[]`) or the recycle bin. Pure.
46
+ */
47
+ export function mayAt(
48
+ principal: ContentPrincipal,
49
+ verbs: readonly string[],
50
+ place: readonly string[] | 'root' | 'bin',
51
+ trashed = false,
52
+ ): boolean {
53
+ const start = principal.startNodes.document
54
+ if (place === 'root' || place === 'bin') {
55
+ if (!start.root) return false
56
+ const granted = permissionsForPath(principal.groups, [])
57
+ return verbs.every((verb) => granted.has(verb))
58
+ }
59
+ if (!hasPathAccess(start, place, trashed)) return false
60
+ const granted = permissionsForPath(principal.groups, place)
61
+ return verbs.every((verb) => granted.has(verb))
62
+ }
63
+
64
+ /** Whether the user may work in every culture named. Pure. */
65
+ export function mayUseCultures(principal: ContentPrincipal, cultures: readonly string[]): boolean {
66
+ if (principal.hasAccessToAllLanguages) return true
67
+ const allowed = new Set(principal.languages.map((l) => l.toLowerCase()))
68
+ return cultures.every((c) => allowed.has(c.toLowerCase()))
69
+ }
70
+
71
+ /** One property value as a save carries it. */
72
+ export interface PropertyValueRef {
73
+ alias: string
74
+ culture: string | null
75
+ segment: string | null
76
+ value: unknown
77
+ }
78
+
79
+ /** JSON with object keys in order, so equal values compare equal however they were built. */
80
+ const canonical = (value: unknown): string =>
81
+ JSON.stringify(value ?? null, (_key, v) =>
82
+ v && typeof v === 'object' && !Array.isArray(v)
83
+ ? Object.fromEntries(Object.entries(v).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)))
84
+ : v,
85
+ )
86
+
87
+ /** Each property's values by culture and segment, nulls left out: what a save would leave stored. */
88
+ function valuesByAlias(values: readonly PropertyValueRef[]): Map<string, Map<string, string>> {
89
+ const out = new Map<string, Map<string, string>>()
90
+ for (const v of values) {
91
+ if (v.value === null || v.value === undefined) continue
92
+ const alias = v.alias.toLowerCase()
93
+ const variants = out.get(alias) ?? new Map<string, string>()
94
+ const where = `${v.culture?.toLowerCase() ?? ''}|${v.segment ?? ''}`
95
+ if (!variants.has(where)) variants.set(where, canonical(v.value))
96
+ out.set(alias, variants)
97
+ }
98
+ return out
99
+ }
100
+
101
+ /** Whether some group writes every property, so there is nothing to check per property. Pure. */
102
+ export const writesEveryProperty = (principal: ContentPrincipal): boolean =>
103
+ principal.groups.some(
104
+ (group) => !group.properties?.size && group.defaults.includes(D.PropertyValueWrite),
105
+ )
106
+
107
+ /**
108
+ * The first property a save would change that the user's groups may not write
109
+ * (`Umb.Document.PropertyValue.Write`, per property type), or undefined. A value
110
+ * sent back as it was is not a change; one left out that was stored is. Pure.
111
+ */
112
+ export function deniedPropertyWrite<P extends { alias: string; key: string }>(
113
+ principal: Pick<ContentPrincipal, 'groups'>,
114
+ properties: readonly P[],
115
+ before: readonly PropertyValueRef[],
116
+ after: readonly PropertyValueRef[],
117
+ ): P | undefined {
118
+ const old = valuesByAlias(before)
119
+ const next = valuesByAlias(after)
120
+ for (const property of properties) {
121
+ const alias = property.alias.toLowerCase()
122
+ const a = old.get(alias) ?? new Map<string, string>()
123
+ const b = next.get(alias) ?? new Map<string, string>()
124
+ const changed = a.size !== b.size || [...a].some(([where, value]) => b.get(where) !== value)
125
+ if (!changed) continue
126
+ if (!propertyValuePermissions(principal.groups, property.key).has(D.PropertyValueWrite))
127
+ return property
128
+ }
129
+ return undefined
130
+ }
package/src/rules.ts ADDED
@@ -0,0 +1,8 @@
1
+ /**
2
+ * The rules alone — actors, permissions and what an event announces — without the
3
+ * operations, so a transport that is handed its operations (the Management API)
4
+ * imports nothing that reaches the database.
5
+ */
6
+ export * from './actor.ts'
7
+ export * from './events.ts'
8
+ export * from './policy.ts'
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.