@bunbraco/core 0.0.0-stage → 0.3.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/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
+ bunbraco
2
+ Copyright (c) 2026 Kineco
3
+
4
+ This product includes third-party software. bunbraco 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
+ @bunbraco/backoffice-dist the built Umbraco backoffice and the browser
12
+ packages bundled into it, produced by
13
+ `bun run vendor:backoffice`
14
+ @bunbraco/backoffice-dist three CSS files copied unmodified from the Umbraco
15
+ client source tree
16
+ @bunbraco/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
+ bunbraco's own marks are served at those paths instead. See `BRANDED_ASSETS` in
23
+ `@bunbraco/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 @bunbraco/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 @bunbraco/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,30 @@
1
1
  {
2
2
  "name": "@bunbraco/core",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.3.0",
4
+ "description": "Domain model, entity keys and shared contracts for bunbraco. No I/O.",
5
+ "license": "MIT",
6
+ "author": "Kineco",
7
+ "homepage": "https://github.com/kineco-au/bunbraco-cms#readme",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/kineco-au/bunbraco-cms.git",
11
+ "directory": "packages/core"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/kineco-au/bunbraco-cms/issues"
15
+ },
16
+ "engines": {
17
+ "bun": ">=1.3.0"
18
+ },
19
+ "type": "module",
20
+ "exports": {
21
+ ".": "./src/index.ts"
22
+ },
23
+ "files": [
24
+ "src",
25
+ "NOTICE"
26
+ ],
27
+ "publishConfig": {
28
+ "access": "public"
29
+ }
30
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * The paths the vendored client hard-codes.
3
+ *
4
+ * Umbraco's `UmbracoPath` moves the editor, not its API: the Management API is
5
+ * declared by the contract and the client's generated SDK calls it at
6
+ * `/umbraco/management/api/v1/...` however the backoffice is mounted. The two
7
+ * SignalR hubs are built the same way in the client's own source. So these stay
8
+ * put while `backOfficePath` moves the shell, the login page, the static assets
9
+ * and the SPA's own routes.
10
+ */
11
+ export const MANAGEMENT_API_PREFIX = '/umbraco/management/api/'
12
+
13
+ export const MANAGEMENT_API_PATH = `${MANAGEMENT_API_PREFIX}v1`
14
+
15
+ /** `server-event.context.js`: `${serverURL}/umbraco/serverEventHub`. */
16
+ export const SERVER_EVENT_HUB_PATH = '/umbraco/serverEventHub'
17
+
18
+ /** `preview.context.js`: `${serverUrl}/umbraco/PreviewHub`. */
19
+ export const PREVIEW_HUB_PATH = '/umbraco/PreviewHub'
20
+
21
+ /** Where the editor is mounted unless a site overrides `backOfficePath`. */
22
+ export const DEFAULT_BACKOFFICE_PATH = '/bunbraco'
@@ -0,0 +1,157 @@
1
+ /**
2
+ * The content-type aggregate: a content type plus its property groups, property
3
+ * types, compositions, allowed children and templates.
4
+ *
5
+ * Kept as one aggregate because the editor saves it as one document, and because
6
+ * the property set is only meaningful together with the compositions it inherits.
7
+ */
8
+ export interface PropertyTypeModel {
9
+ key: string
10
+ alias: string
11
+ name: string
12
+ description: string | null
13
+ dataTypeKey: string
14
+ /** The group this property renders in, or null for the generic tab. */
15
+ containerKey: string | null
16
+ sortOrder: number
17
+ variesByCulture: boolean
18
+ variesBySegment: boolean
19
+ mandatory: boolean
20
+ mandatoryMessage: string | null
21
+ regEx: string | null
22
+ regExMessage: string | null
23
+ labelOnTop: boolean
24
+ /** The file's `since`, when written. */
25
+ sinceVersion?: string | null
26
+ /** Set when the element is not yet live on the reading node: the state it goes live in. */
27
+ pending?: { version: string; revision: string } | null
28
+ /** Member types only: shown to the member on their profile. */
29
+ memberCanView?: boolean
30
+ /** Member types only: editable by the member on their profile. */
31
+ memberCanEdit?: boolean
32
+ /** Member types only: hidden from users without sensitive-data access. */
33
+ isSensitive?: boolean
34
+ }
35
+
36
+ export interface PropertyGroupModel {
37
+ key: string
38
+ name: string | null
39
+ alias: string | null
40
+ /** 'Tab' or 'Group'; the wire format is a string. */
41
+ type: string
42
+ sortOrder: number
43
+ parentKey: string | null
44
+ }
45
+
46
+ export type CompositionType = 'Composition' | 'Inheritance'
47
+
48
+ export interface CompositionModel {
49
+ contentTypeKey: string
50
+ compositionType: CompositionType
51
+ }
52
+
53
+ export interface AllowedChildModel {
54
+ contentTypeKey: string
55
+ sortOrder: number
56
+ }
57
+
58
+ export interface CleanupModel {
59
+ preventCleanup: boolean
60
+ keepAllVersionsNewerThanDays: number | null
61
+ keepLatestVersionPerDayForDays: number | null
62
+ }
63
+
64
+ export interface ContentTypeAggregate {
65
+ key: string
66
+ alias: string
67
+ name: string
68
+ description: string | null
69
+ icon: string
70
+ allowedAsRoot: boolean
71
+ variesByCulture: boolean
72
+ variesBySegment: boolean
73
+ isElement: boolean
74
+ allowedInLibrary: boolean
75
+ collectionKey: string | null
76
+ cleanup: CleanupModel
77
+ properties: PropertyTypeModel[]
78
+ containers: PropertyGroupModel[]
79
+ compositions: CompositionModel[]
80
+ allowedContentTypes: AllowedChildModel[]
81
+ allowedTemplateKeys: string[]
82
+ defaultTemplateKey: string | null
83
+ /** Folder placement in the tree, which is not the composition graph. */
84
+ parentKey: string | null
85
+ sinceVersion?: string | null
86
+ pending?: { version: string; revision: string } | null
87
+ }
88
+
89
+ /**
90
+ * Umbraco's system media types (`IsSystemMediaType`): always present, not
91
+ * deletable, alias fixed. Keys from its installer.
92
+ */
93
+ export const SYSTEM_MEDIA_TYPE_KEYS: Readonly<Record<'Folder' | 'Image' | 'File', string>> = {
94
+ Folder: 'f38bd2d7-65d0-48e6-95dc-87ce06ec2d3d',
95
+ Image: 'cc07b313-0843-4aa8-bbda-871c8da728c8',
96
+ File: '4c52d8ab-54e6-40cd-999c-7a5f24903e4d',
97
+ }
98
+
99
+ export function isSystemMediaType(key: string): boolean {
100
+ return Object.values(SYSTEM_MEDIA_TYPE_KEYS).includes(key.toLowerCase())
101
+ }
102
+
103
+ /**
104
+ * Umbraco's one built-in member type, "Member", by the key its installer uses:
105
+ * always present, so a fresh install can create a member without a schema file.
106
+ */
107
+ export const SYSTEM_MEMBER_TYPE_KEY = 'd59be02f-1df9-4228-aa1e-01917d806cda'
108
+
109
+ export function isSystemMemberType(key: string): boolean {
110
+ return key.toLowerCase() === SYSTEM_MEMBER_TYPE_KEY
111
+ }
112
+
113
+ /** How the editor labels a pending element. */
114
+ export function pendingLabel(until: { version: string }): string {
115
+ return `goes live in ${until.version}`
116
+ }
117
+
118
+ export interface DataTypeModel {
119
+ key: string
120
+ /** Schema files reference a data type by this; Umbraco itself has only key and name. */
121
+ alias: string | null
122
+ name: string
123
+ editorAlias: string
124
+ editorUiAlias: string | null
125
+ /** One of ValueStorageType. */
126
+ dbType: string
127
+ values: Array<{ alias: string; value: unknown }>
128
+ parentKey: string | null
129
+ }
130
+
131
+ export interface TemplateModel {
132
+ key: string
133
+ name: string
134
+ alias: string
135
+ content: string | null
136
+ /** Derived from `export const layout = '…'` in the view; the tree hangs children under it. */
137
+ masterKey?: string | null
138
+ }
139
+
140
+ /** One entry in a backoffice tree. */
141
+ export interface TreeItem {
142
+ key: string
143
+ name: string
144
+ hasChildren: boolean
145
+ parentKey: string | null
146
+ icon: string | null
147
+ isFolder: boolean
148
+ isElement?: boolean
149
+ editorUiAlias?: string | null
150
+ /** The document type of a blueprint in the blueprint tree. */
151
+ contentTypeKey?: string | null
152
+ }
153
+
154
+ export interface Page<T> {
155
+ total: number
156
+ items: T[]
157
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * The document aggregate.
3
+ *
4
+ * Values are a flat list keyed by (alias, culture, segment), matching the wire
5
+ * format, because that is also how they are stored: one `property_data` row per
6
+ * combination.
7
+ */
8
+ export interface DocumentValue {
9
+ alias: string
10
+ culture: string | null
11
+ segment: string | null
12
+ value: unknown
13
+ /** Present on responses so the editor knows which editor rendered the value. */
14
+ editorAlias?: string
15
+ /** The data type's configuration, on reads; converters that depend on it use it. */
16
+ config?: Record<string, unknown>
17
+ }
18
+
19
+ /**
20
+ * Per-culture state. `NotCreated` means the culture has no name yet;
21
+ * `PublishedPendingChanges` means the draft differs from what is published.
22
+ */
23
+ export type VariantState =
24
+ | 'NotCreated'
25
+ | 'Draft'
26
+ | 'Published'
27
+ | 'PublishedPendingChanges'
28
+ | 'Trashed'
29
+
30
+ export interface DocumentVariant {
31
+ culture: string | null
32
+ segment: string | null
33
+ name: string
34
+ state: VariantState
35
+ createDate: Date
36
+ updateDate: Date
37
+ publishDate: Date | null
38
+ /** When a scheduled publish of this variant will run. */
39
+ scheduledPublishDate?: Date | null
40
+ scheduledUnpublishDate?: Date | null
41
+ }
42
+
43
+ export interface DocumentAggregate {
44
+ key: string
45
+ contentTypeKey: string
46
+ contentTypeAlias: string
47
+ contentTypeIcon: string
48
+ /** The list view the type shows its children in, if any. */
49
+ contentTypeCollectionKey?: string | null
50
+ templateKey: string | null
51
+ parentKey: string | null
52
+ sortOrder: number
53
+ isTrashed: boolean
54
+ /** True when any culture is published. */
55
+ published: boolean
56
+ /** True when the draft differs from the published version. */
57
+ edited: boolean
58
+ values: DocumentValue[]
59
+ variants: DocumentVariant[]
60
+ }
61
+
62
+ /** One property that failed validation, in the terms the editor highlights. */
63
+ export interface DocumentValidationError {
64
+ alias: string
65
+ culture: string | null
66
+ segment: string | null
67
+ messages: string[]
68
+ }
69
+
70
+ /** What the content tree shows for a document, beyond the generic tree item. */
71
+ export interface DocumentTreeItem {
72
+ key: string
73
+ name: string
74
+ hasChildren: boolean
75
+ parentKey: string | null
76
+ contentTypeKey: string
77
+ contentTypeCollectionKey?: string | null
78
+ icon: string
79
+ isTrashed: boolean
80
+ createDate: Date
81
+ ancestorKeys: string[]
82
+ variants: Array<{ culture: string | null; name: string; state: VariantState }>
83
+ }
84
+
85
+ /**
86
+ * A row in the Library tree, which mixes folders and elements: a folder carries
87
+ * the name and nothing else, so `documentType` is reported as null and its single
88
+ * variant as `NotCreated` — the contract's own way of saying there is no content
89
+ * here.
90
+ */
91
+ export interface ElementTreeItem extends DocumentTreeItem {
92
+ isFolder: boolean
93
+ }
94
+
95
+ export interface DocumentVersionSummary {
96
+ id: string
97
+ documentKey: string
98
+ versionDate: Date
99
+ /** The draft that is currently being edited. */
100
+ isCurrentDraft: boolean
101
+ isCurrentPublished: boolean
102
+ preventCleanup: boolean
103
+ userKey: string | null
104
+ culture: string | null
105
+ /** What made this version: a save, publish, rollback or schema migration. */
106
+ kind?: 'save' | 'publish' | 'rollback' | 'migrate'
107
+ }
108
+
109
+ /** A value keyed for lookup, so variance handling stays explicit. */
110
+ export function valueKey(alias: string, culture: string | null, segment: string | null): string {
111
+ return `${alias}|${culture ?? ''}|${segment ?? ''}`
112
+ }
113
+
114
+ /**
115
+ * The culture a value belongs to, honouring the effective variance: a value sent
116
+ * with a culture for an invariant property is stored invariant, which is what
117
+ * keeps a type change from orphaning data.
118
+ */
119
+ export function normaliseValueCulture(
120
+ culture: string | null,
121
+ propertyVariesByCulture: boolean,
122
+ ): string | null {
123
+ return propertyVariesByCulture ? culture : null
124
+ }
package/src/index.ts ADDED
@@ -0,0 +1,13 @@
1
+ export * from './api-paths.ts'
2
+ export * from './content-types.ts'
3
+ export * from './documents.ts'
4
+ export * from './notifications.ts'
5
+ export * from './object-types.ts'
6
+ export * from './paging.ts'
7
+ export * from './permissions.ts'
8
+ export * from './problem-details.ts'
9
+ export * from './property-values.ts'
10
+ export * from './return-urls.ts'
11
+ export * from './sections.ts'
12
+ export * from './uploads.ts'
13
+ export * from './users.ts'
@@ -0,0 +1,28 @@
1
+ /**
2
+ * The `Umb-Notifications` response header. Note the name: it is NOT
3
+ * `Umbraco-Notifications`. The backoffice reads it, renders toasts, and strips
4
+ * it. Umbraco never sets it on GET.
5
+ */
6
+ export const NOTIFICATIONS_HEADER = 'Umb-Notifications'
7
+
8
+ export type EventMessageType = 'Default' | 'Info' | 'Error' | 'Success' | 'Warning'
9
+
10
+ export interface EventMessage {
11
+ message: string
12
+ category: string
13
+ type: EventMessageType
14
+ }
15
+
16
+ export function notificationsHeaderValue(messages: readonly EventMessage[]): string {
17
+ return JSON.stringify(messages)
18
+ }
19
+
20
+ export function applyNotifications(
21
+ headers: Headers,
22
+ method: string,
23
+ messages: readonly EventMessage[],
24
+ ): void {
25
+ if (method.toUpperCase() === 'GET') return
26
+ if (messages.length === 0) return
27
+ headers.set(NOTIFICATIONS_HEADER, notificationsHeaderValue(messages))
28
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Node object types.
3
+ *
4
+ * `node` is a universal polymorphic tree — documents, media, members, all content
5
+ * types, data types, templates and folders are rows in it — and this GUID is the
6
+ * discriminator. The values are Umbraco's, so SQL and documentation transfer
7
+ * even though the table names do not.
8
+ *
9
+ * Written lowercase deliberately: Postgres' `uuid` type normalises to lowercase
10
+ * on read while SQLite stores text verbatim, so everything that compares a uuid
11
+ * must agree on one case. `normaliseUuid` enforces it on the way in.
12
+ */
13
+ export const ObjectTypes = {
14
+ SystemRoot: 'ea7d8624-4cfe-4578-a871-24aa946bf34d',
15
+ Document: 'c66ba18e-eaf3-4cff-8a22-41b16d66a972',
16
+ DocumentType: 'a2cb7800-f571-4787-9638-bc48539a0efb',
17
+ DocumentTypeContainer: '2f7a2769-6b0b-4468-90dd-af42d64f7f16',
18
+ DocumentBlueprint: '6ebef410-03aa-48cf-a792-e1c1cb087aca',
19
+ DocumentBlueprintContainer: 'a7eff71b-fa69-4552-93fc-038f7deee453',
20
+ ContentRecycleBin: '01bb7ff2-24dc-4c0c-95a2-c24ef72bbac8',
21
+ DataType: '30a2a501-1978-4ddb-a57b-f7efed43ba3c',
22
+ DataTypeContainer: '521231e3-8b37-469c-9f9d-51afc91feb7b',
23
+ Template: '6fbde604-4178-42ce-a10b-8a2600a2f07d',
24
+ Media: 'b796f64c-1f99-4ffb-b886-4bf4bc011a9c',
25
+ MediaType: '4ea4382b-2f5a-4c2b-9587-ae9b3cf3602e',
26
+ MediaTypeContainer: '42aef799-b288-4744-9b10-be144b73cdc4',
27
+ MediaRecycleBin: 'cf3d8e34-1c1c-41e9-ae56-878b57b32113',
28
+ ElementContainer: '2815b0cf-9706-499f-aa2a-8a4c7aef005d',
29
+ ElementRecycleBin: 'a1ee71eb-659c-4eee-bc97-6243e721cc0d',
30
+ Member: '39eb0f98-b348-42a1-8662-e7eb18487560',
31
+ MemberType: '9b5416fb-e72f-45a9-a07b-5a9a2709ce43',
32
+ MemberTypeContainer: '59ef5767-7223-4abc-b229-72821dc711b9',
33
+ MemberGroup: '366e63b9-880f-4e13-a61c-98069b029728',
34
+ Element: '3d7b623c-94b1-487d-8554-a46ec37568be',
35
+ Language: '6b05d05b-ec78-49be-a4e4-79e274f07a77',
36
+ RelationType: 'b1988fad-8675-4f47-915a-b3a602bc5d8d',
37
+ LockObject: '87a9f1ff-b1e4-4a25-babb-465a4a47ec41',
38
+ } as const
39
+
40
+ export type ObjectType = (typeof ObjectTypes)[keyof typeof ObjectTypes]
41
+
42
+ /** The canonical form for a uuid anywhere in the system. */
43
+ export function normaliseUuid(value: string): string {
44
+ return value.toLowerCase()
45
+ }
46
+
47
+ export function uuidEquals(a: string | null | undefined, b: string | null | undefined): boolean {
48
+ if (!a || !b) return false
49
+ return a.toLowerCase() === b.toLowerCase()
50
+ }
51
+
52
+ /**
53
+ * `umb://document/2ef0…` or a bare uuid, as a dashed lowercase key.
54
+ *
55
+ * The two spellings a stored value uses for a reference. It lives here rather
56
+ * than beside the value converters that first needed it because exporting a
57
+ * bundle reads the same references out of the same values, and `@bunbraco/transfer`
58
+ * must not depend on the renderer to do it.
59
+ */
60
+ export function keyFromReference(reference: unknown): string | undefined {
61
+ if (typeof reference !== 'string') return undefined
62
+ const hex = reference
63
+ .replace(/^umb:\/\/[a-z-]+\//i, '')
64
+ .replaceAll('-', '')
65
+ .toLowerCase()
66
+ if (!/^[0-9a-f]{32}$/.test(hex)) return undefined
67
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
68
+ }
69
+
70
+ /** Fixed node ids, as Umbraco seeds them. */
71
+ export const SystemNodes = {
72
+ Root: -1,
73
+ ContentRecycleBin: -20,
74
+ MediaRecycleBin: -21,
75
+ ElementRecycleBin: -22,
76
+ } as const
77
+
78
+ /** `ContentVariation` is a flags byte on both content types and property types. */
79
+ export const ContentVariation = {
80
+ Nothing: 0,
81
+ Culture: 1,
82
+ Segment: 2,
83
+ CultureAndSegment: 3,
84
+ } as const
85
+
86
+ export function variationFlags(variesByCulture: boolean, variesBySegment: boolean): number {
87
+ return (variesByCulture ? 1 : 0) | (variesBySegment ? 2 : 0)
88
+ }
89
+
90
+ export const variesByCulture = (flags: number): boolean => (flags & 1) !== 0
91
+ export const variesBySegment = (flags: number): boolean => (flags & 2) !== 0
92
+
93
+ /**
94
+ * Effective variance is the intersection of the content type's and the property
95
+ * type's: a property varies by culture only if its type does too.
96
+ */
97
+ export function effectiveVariation(contentTypeFlags: number, propertyTypeFlags: number): number {
98
+ return contentTypeFlags & propertyTypeFlags
99
+ }
100
+
101
+ /** `PropertyGroupType`: a tab is rendered as a top-level tab, a group as a fieldset. */
102
+ export const PropertyGroupType = { Group: 0, Tab: 1 } as const
103
+
104
+ /** How values are stored in `property_data`, chosen by the data type. */
105
+ export const ValueStorageType = {
106
+ Ntext: 'Ntext',
107
+ Nvarchar: 'Nvarchar',
108
+ Integer: 'Integer',
109
+ Date: 'Date',
110
+ Decimal: 'Decimal',
111
+ } as const
112
+
113
+ export type ValueStorageTypeName = (typeof ValueStorageType)[keyof typeof ValueStorageType]
114
+
115
+ /** The `property_data` column a storage type writes to. */
116
+ export const STORAGE_COLUMN: Record<ValueStorageTypeName, string> = {
117
+ Ntext: 'text_value',
118
+ Nvarchar: 'varchar_value',
119
+ Integer: 'int_value',
120
+ Date: 'date_value',
121
+ Decimal: 'decimal_value',
122
+ }
package/src/paging.ts ADDED
@@ -0,0 +1,27 @@
1
+ /** Umbraco's universal paging envelope. `total` is int64 in the contract. */
2
+ export interface PagedViewModel<T> {
3
+ total: number
4
+ items: T[]
5
+ }
6
+
7
+ export const DEFAULT_TAKE = 100
8
+
9
+ export interface SkipTake {
10
+ skip: number
11
+ take: number
12
+ }
13
+
14
+ export type SkipTakeResult = { ok: true; value: SkipTake } | { ok: false }
15
+
16
+ export function parseSkipTake(params: URLSearchParams, defaultTake = DEFAULT_TAKE): SkipTakeResult {
17
+ const skip = Number(params.get('skip') ?? 0)
18
+ const take = Number(params.get('take') ?? defaultTake)
19
+ if (!Number.isInteger(skip) || !Number.isInteger(take) || skip < 0 || take < 0)
20
+ return { ok: false }
21
+ if (take > 0 && skip % take !== 0) return { ok: false }
22
+ return { ok: true, value: { skip, take } }
23
+ }
24
+
25
+ export function paged<T>(items: T[], total: number): PagedViewModel<T> {
26
+ return { total, items }
27
+ }
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Permission verbs.
3
+ *
4
+ * Umbraco 18 replaced the old single-letter action codes with verbs of the form
5
+ * `Umb.<Entity>.<Action>`, stored one per row. Granular rows additionally carry a
6
+ * node key and a context discriminator, so one table serves both per-document
7
+ * ACLs and per-property field-level security.
8
+ */
9
+ export const DocumentPermissions = {
10
+ Read: 'Umb.Document.Read',
11
+ Create: 'Umb.Document.Create',
12
+ Update: 'Umb.Document.Update',
13
+ Delete: 'Umb.Document.Delete',
14
+ Publish: 'Umb.Document.Publish',
15
+ Unpublish: 'Umb.Document.Unpublish',
16
+ Move: 'Umb.Document.Move',
17
+ Duplicate: 'Umb.Document.Duplicate',
18
+ Sort: 'Umb.Document.Sort',
19
+ Rollback: 'Umb.Document.Rollback',
20
+ Notifications: 'Umb.Document.Notifications',
21
+ Permissions: 'Umb.Document.Permissions',
22
+ CultureAndHostnames: 'Umb.Document.CultureAndHostnames',
23
+ PublicAccess: 'Umb.Document.PublicAccess',
24
+ CreateBlueprint: 'Umb.Document.CreateBlueprint',
25
+ PropertyValueRead: 'Umb.Document.PropertyValue.Read',
26
+ PropertyValueWrite: 'Umb.Document.PropertyValue.Write',
27
+ RecycleBinRestore: 'Umb.DocumentRecycleBin.Restore',
28
+ } as const
29
+
30
+ export type DocumentVerb = (typeof DocumentPermissions)[keyof typeof DocumentPermissions]
31
+
32
+ /** The discriminator on a granular permission row. */
33
+ export type PermissionContext = 'Document' | 'Element' | 'DocumentTypeProperty'
34
+
35
+ export const ADMIN_GROUP_ALIAS = 'admin'
36
+
37
+ /** The resolved permission set for a user, assembled from all their groups. */
38
+ export interface UserPermissions {
39
+ isAdmin: boolean
40
+ /** Verbs granted regardless of node. */
41
+ global: ReadonlySet<string>
42
+ /** Verbs granted only on specific nodes, keyed by node key. */
43
+ granular: ReadonlyMap<string, ReadonlySet<string>>
44
+ }
45
+
46
+ /**
47
+ * Where a user may work in one tree: everywhere, or at and below the given
48
+ * nodes. Neither means no access at all, not even to the root.
49
+ */
50
+ export interface StartNodes {
51
+ root: boolean
52
+ keys: readonly string[]
53
+ }
54
+
55
+ export const ROOT_ACCESS: StartNodes = { root: true, keys: [] }
56
+
57
+ /**
58
+ * Umbraco's path access: with root access everything, the recycle bin
59
+ * included; otherwise a node at or below a start node, and never in the bin.
60
+ * `chain` is the node's ancestor keys then its own, root first.
61
+ */
62
+ export function hasPathAccess(
63
+ start: StartNodes,
64
+ chain: readonly string[],
65
+ trashed: boolean,
66
+ ): boolean {
67
+ if (start.root) return true
68
+ if (trashed) return false
69
+ return start.keys.some((key) => chain.includes(key))
70
+ }
71
+
72
+ /** A start node's position: its ancestor keys then its own, root first; empty for the root. */
73
+ export type StartNodePath = readonly string[]
74
+
75
+ const within = (path: StartNodePath, ancestor: StartNodePath) =>
76
+ ancestor.length <= path.length && ancestor.every((key, i) => path[i] === key)
77
+
78
+ /**
79
+ * Umbraco's `CombineStartNodes`: the groups' start nodes keep the topmost, the
80
+ * user's own keep the deepest, and each of the user's replaces any group start
81
+ * node above or below it. Callers leave out nodes that no longer exist or are
82
+ * in the recycle bin.
83
+ */
84
+ export function combineStartNodes(
85
+ group: readonly StartNodePath[],
86
+ user: readonly StartNodePath[],
87
+ ): StartNodes {
88
+ let kept: StartNodePath[] = []
89
+ for (const path of group) {
90
+ if (kept.some((k) => within(path, k))) continue
91
+ kept = kept.filter((k) => !within(k, path))
92
+ kept.push(path)
93
+ }
94
+ let own: StartNodePath[] = []
95
+ for (const path of user) {
96
+ if (own.some((k) => within(k, path))) continue
97
+ own = own.filter((k) => !within(path, k))
98
+ own.push(path)
99
+ }
100
+ for (const path of own) {
101
+ kept = kept.filter((k) => !within(path, k) && !within(k, path))
102
+ kept.push(path)
103
+ }
104
+ return {
105
+ root: kept.some((path) => path.length === 0),
106
+ keys: kept.filter((path) => path.length > 0).map((path) => path[path.length - 1] as string),
107
+ }
108
+ }
109
+
110
+ /** One group's verbs: its defaults, and the nodes it sets explicitly (an empty list denies all). */
111
+ export interface GroupGrant {
112
+ key: string
113
+ alias: string
114
+ defaults: readonly string[]
115
+ granular: ReadonlyMap<string, readonly string[]>
116
+ }
117
+
118
+ /**
119
+ * Umbraco's `HasAccessToSensitiveData`: membership of the Sensitive data group.
120
+ * Without it, values of member properties marked sensitive are withheld.
121
+ */
122
+ export function hasAccessToSensitiveData(groups: ReadonlyArray<{ alias: string }>): boolean {
123
+ return groups.some((group) => group.alias === 'sensitiveData')
124
+ }
125
+
126
+ /**
127
+ * The verbs a user holds on a node, as Umbraco calculates them: for each group,
128
+ * the nearest node on the path (the node itself first) that the group sets
129
+ * explicitly replaces its defaults; the result is the union over groups.
130
+ * `chain` is the node's ancestor keys then its own, root first; empty for the
131
+ * root itself.
132
+ */
133
+ export function permissionsForPath(
134
+ groups: readonly GroupGrant[],
135
+ chain: readonly string[],
136
+ ): Set<string> {
137
+ const verbs = new Set<string>()
138
+ for (const group of groups) {
139
+ let granted = group.defaults
140
+ for (let i = chain.length - 1; i >= 0; i--) {
141
+ const explicit = group.granular.get(chain[i] as string)
142
+ if (explicit) {
143
+ granted = explicit
144
+ break
145
+ }
146
+ }
147
+ for (const verb of granted) verbs.add(verb)
148
+ }
149
+ return verbs
150
+ }
151
+
152
+ /**
153
+ * An administrator bypasses the permission tables entirely, matching Umbraco:
154
+ * membership of the admin group is the check, not an exhaustive grant list.
155
+ */
156
+ export function hasPermission(
157
+ permissions: UserPermissions,
158
+ verb: string,
159
+ nodeKey?: string,
160
+ ): boolean {
161
+ if (permissions.isAdmin) return true
162
+ if (permissions.global.has(verb)) return true
163
+ if (!nodeKey) return false
164
+ return permissions.granular.get(nodeKey)?.has(verb) === true
165
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * RFC 7807 Problem Details as Umbraco emits them: `type` is the literal string
3
+ * "Error", never a URI, plus Umbraco's own extension members.
4
+ */
5
+ export interface ProblemDetails {
6
+ type: string
7
+ title: string
8
+ status: number
9
+ detail?: string
10
+ instance?: string
11
+ /** The domain operation status enum name, e.g. "NotFound". */
12
+ operationStatus?: string
13
+ /** Validation failures keyed by JSON path into the request model. */
14
+ errors?: Record<string, string[]>
15
+ invalidProperties?: string[]
16
+ failedBranchItems?: Array<{ id: string; operationStatus: string }>
17
+ }
18
+
19
+ export const PROBLEM_TYPE = 'Error'
20
+
21
+ export function problemDetails(
22
+ init: Omit<ProblemDetails, 'type'> & { type?: string },
23
+ ): ProblemDetails {
24
+ return { type: PROBLEM_TYPE, ...init }
25
+ }
26
+
27
+ export function problemResponse(problem: ProblemDetails, headers?: HeadersInit): Response {
28
+ return new Response(JSON.stringify(problem), {
29
+ status: problem.status,
30
+ headers: { ...headers, 'content-type': 'application/problem+json; charset=utf-8' },
31
+ })
32
+ }
33
+
34
+ export const notFound = (title = 'Not Found', detail?: string) =>
35
+ problemDetails({ title, status: 404, detail, operationStatus: 'NotFound' })
36
+
37
+ export const unauthorized = (detail = 'Authentication is required.') =>
38
+ problemDetails({ title: 'Unauthorized', status: 401, detail, operationStatus: 'Unauthorized' })
39
+
40
+ export const notImplemented = (operationId: string) =>
41
+ problemDetails({
42
+ title: 'Not implemented',
43
+ status: 501,
44
+ detail: `Operation '${operationId}' is declared in the contract but not implemented yet.`,
45
+ operationStatus: 'NotImplemented',
46
+ })
47
+
48
+ /**
49
+ * Umbraco rejects a skip that is not a multiple of take, and the backoffice
50
+ * relies on that contract when it pages.
51
+ */
52
+ export const invalidSkipTake = () =>
53
+ problemDetails({
54
+ title: 'Invalid skip/take',
55
+ detail: 'Skip must be a multiple of take - i.e. skip = 10, take = 5',
56
+ status: 400,
57
+ })
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Editor-specific value shapes. Structured editors store JSON text and hand the
3
+ * editor the parsed value, as Umbraco's value editors do; everything else is
4
+ * stored and returned as is.
5
+ */
6
+
7
+ export const RICH_TEXT_EDITOR_ALIAS = 'Umbraco.RichText'
8
+ export const UPLOAD_FIELD_EDITOR_ALIAS = 'Umbraco.UploadField'
9
+ export const IMAGE_CROPPER_EDITOR_ALIAS = 'Umbraco.ImageCropper'
10
+
11
+ /** Editors whose value names an uploaded file, and so may arrive with a temporary file. */
12
+ export const FILE_VALUE_EDITORS: ReadonlySet<string> = new Set([
13
+ UPLOAD_FIELD_EDITOR_ALIAS,
14
+ IMAGE_CROPPER_EDITOR_ALIAS,
15
+ ])
16
+
17
+ /** Editors whose values are objects or arrays, stored as JSON. */
18
+ export const JSON_VALUE_EDITORS: ReadonlySet<string> = new Set([
19
+ RICH_TEXT_EDITOR_ALIAS,
20
+ 'Umbraco.BlockList',
21
+ 'Umbraco.BlockGrid',
22
+ 'Umbraco.CheckBoxList',
23
+ 'Umbraco.ColorPicker',
24
+ 'Umbraco.DateTimeWithTimeZone',
25
+ 'Umbraco.DropDown.Flexible',
26
+ // A `Guid[]` of the elements picked, not the `umb://…` references a content
27
+ // picker stores.
28
+ 'Umbraco.ElementPicker',
29
+ 'Umbraco.ImageCropper',
30
+ 'Umbraco.MediaPicker3',
31
+ 'Umbraco.MultipleTextstring',
32
+ 'Umbraco.MultiUrlPicker',
33
+ 'Umbraco.Slider',
34
+ 'Umbraco.Tags',
35
+ ])
36
+
37
+ export interface RichTextValue {
38
+ markup: string
39
+ blocks: unknown
40
+ }
41
+
42
+ function tryParse(text: string): unknown {
43
+ try {
44
+ return JSON.parse(text)
45
+ } catch {
46
+ return undefined
47
+ }
48
+ }
49
+
50
+ /**
51
+ * A string spread into an object (`{...'abc'}` is `{0:'a',1:'b',2:'c'}`), which
52
+ * earlier builds stored when they handed the editor JSON text instead of JSON.
53
+ */
54
+ function unspreadString(value: unknown): string | undefined {
55
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined
56
+ const entries = Object.entries(value as Record<string, unknown>)
57
+ const indexed = entries.filter(([key]) => /^\d+$/.test(key))
58
+ if (indexed.length === 0) return undefined
59
+ const chars: string[] = []
60
+ for (const [key, char] of indexed) {
61
+ if (typeof char !== 'string' || char.length !== 1) return undefined
62
+ chars[Number(key)] = char
63
+ }
64
+ if (chars.length !== indexed.length) return undefined
65
+ return chars.join('')
66
+ }
67
+
68
+ function toRichText(value: unknown): RichTextValue {
69
+ if (typeof value === 'string') {
70
+ const parsed = tryParse(value)
71
+ if (parsed && typeof parsed === 'object') return toRichText(parsed)
72
+ return { markup: value, blocks: null }
73
+ }
74
+ const spread = unspreadString(value)
75
+ if (spread !== undefined) return toRichText(spread)
76
+ const object = (value ?? {}) as Partial<RichTextValue>
77
+ return {
78
+ markup: typeof object.markup === 'string' ? object.markup : '',
79
+ blocks: object.blocks ?? null,
80
+ }
81
+ }
82
+
83
+ /**
84
+ * An upload is stored as its path (`/media/ab12cd34/file.pdf`), as Umbraco
85
+ * stores it, and edited as `{ src }`.
86
+ */
87
+ function toUpload(stored: unknown): { src: string } | null {
88
+ if (typeof stored === 'string') {
89
+ const parsed = tryParse(stored)
90
+ if (parsed && typeof parsed === 'object') return toUpload(parsed)
91
+ return stored ? { src: stored } : null
92
+ }
93
+ const src = (stored as { src?: unknown } | null)?.src
94
+ return typeof src === 'string' && src ? { src } : null
95
+ }
96
+
97
+ /** A stored instant as the date pickers show it: `2026-01-02 03:04:05`, in the zone it was entered in. */
98
+ function toWallClock(stored: unknown): unknown {
99
+ const date = new Date(String(stored))
100
+ if (Number.isNaN(date.getTime())) return stored
101
+ return date.toISOString().slice(0, 19).replace('T', ' ')
102
+ }
103
+
104
+ /** The value an editor receives for what is stored. */
105
+ export function toEditorValue(editorAlias: string | undefined, stored: unknown): unknown {
106
+ if (stored === null || stored === undefined || !editorAlias) return stored
107
+ if (editorAlias === RICH_TEXT_EDITOR_ALIAS) return toRichText(stored)
108
+ if (editorAlias === UPLOAD_FIELD_EDITOR_ALIAS) return toUpload(stored)
109
+ if (editorAlias === 'Umbraco.TrueFalse')
110
+ return stored === true || stored === 1 || stored === '1' || stored === 'true'
111
+ if (editorAlias === 'Umbraco.DateTime') return toWallClock(stored)
112
+ if (!JSON_VALUE_EDITORS.has(editorAlias) || typeof stored !== 'string') return stored
113
+ const parsed = tryParse(stored)
114
+ return parsed !== null && typeof parsed === 'object' ? parsed : stored
115
+ }
116
+
117
+ /** The value a template receives: rich text is its markup, an upload its URL. */
118
+ export function toPublishedValue(editorAlias: string | undefined, stored: unknown): unknown {
119
+ const value = toEditorValue(editorAlias, stored)
120
+ if (editorAlias === RICH_TEXT_EDITOR_ALIAS && value) return (value as RichTextValue).markup
121
+ if (editorAlias === UPLOAD_FIELD_EDITOR_ALIAS)
122
+ return (value as { src: string } | null)?.src ?? null
123
+ return value
124
+ }
125
+
126
+ /** What is stored for an upload the editor sent: its path, once any temporary file is placed. */
127
+ export function toStoredUpload(value: unknown): string | null {
128
+ return toUpload(value)?.src ?? null
129
+ }
@@ -0,0 +1,42 @@
1
+ /** Validation for `returnUrl` values, which are attacker-supplied by nature. */
2
+
3
+ /**
4
+ * The origin a candidate is resolved against. Any host but this one means the
5
+ * value escaped the site, whatever it looked like as text.
6
+ */
7
+ const PROBE = 'https://return-url.invalid'
8
+
9
+ /**
10
+ * A `returnUrl` reduced to a path on this site, or nothing.
11
+ *
12
+ * Both callers take the value from a query string and hand it to a browser to
13
+ * navigate to, so this is the difference between resuming a sign-in and bouncing
14
+ * the person somewhere else with a session in hand. Text comparisons are not
15
+ * enough: browsers read `\` as `/` and strip tabs and newlines before resolving,
16
+ * so `/\evil.com` and `/<tab>/evil.com` both leave the site while starting with a
17
+ * single slash. Resolving against a probe origin and insisting the result stayed
18
+ * there is the check that holds, and it rejects `javascript:` on the way through
19
+ * because that parses to no origin at all.
20
+ *
21
+ * The value returned is the re-serialised path, so what the caller stores cannot
22
+ * be read differently by whatever parses it next.
23
+ */
24
+ export function localReturnUrl(value: string | null | undefined): string | undefined {
25
+ if (!value) return undefined
26
+ // By code point rather than a regex: a control character written into one is
27
+ // exactly what a linter is right to stop, and this says the same thing plainly.
28
+ const cleaned = [...value]
29
+ .filter((character) => {
30
+ const code = character.codePointAt(0) ?? 0
31
+ return code > 0x1f && code !== 0x7f
32
+ })
33
+ .join('')
34
+ if (!cleaned.startsWith('/')) return undefined
35
+ try {
36
+ const resolved = new URL(cleaned, PROBE)
37
+ if (resolved.origin !== PROBE) return undefined
38
+ return `${resolved.pathname}${resolved.search}${resolved.hash}`
39
+ } catch {
40
+ return undefined
41
+ }
42
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Sections, and the two different alias vocabularies for them.
3
+ *
4
+ * The database stores Umbraco's application aliases (`content`, `users`), while
5
+ * the backoffice matches section *manifest* aliases (`Umb.Section.Content`)
6
+ * against `allowedSections`. The Management API translates between them, so a
7
+ * value from one vocabulary must never leak into the other.
8
+ */
9
+ export const SECTION_ALIASES = {
10
+ content: 'Umb.Section.Content',
11
+ media: 'Umb.Section.Media',
12
+ settings: 'Umb.Section.Settings',
13
+ users: 'Umb.Section.Users',
14
+ members: 'Umb.Section.Members',
15
+ translation: 'Umb.Section.Translation',
16
+ library: 'Umb.Section.Library',
17
+ packages: 'Umb.Section.Packages',
18
+ } as const
19
+
20
+ export type AppAlias = keyof typeof SECTION_ALIASES
21
+ export type SectionAlias = (typeof SECTION_ALIASES)[AppAlias]
22
+
23
+ /** Every section the shipped backoffice knows how to render. */
24
+ export const CORE_SECTION_ALIASES: readonly SectionAlias[] = Object.values(SECTION_ALIASES)
25
+
26
+ /**
27
+ * Translates stored application aliases into section manifest aliases, dropping
28
+ * any with no core section — `forms` is seeded by Umbraco but is a commercial
29
+ * add-on, so it has no section in the open-source backoffice.
30
+ */
31
+ export function toSectionAliases(appAliases: readonly string[]): SectionAlias[] {
32
+ const seen = new Set<SectionAlias>()
33
+ for (const appAlias of appAliases) {
34
+ const sectionAlias = SECTION_ALIASES[appAlias as AppAlias]
35
+ if (sectionAlias) seen.add(sectionAlias)
36
+ }
37
+ return [...seen]
38
+ }
39
+
40
+ const STORED_ALIASES: Record<string, string> = { ...SECTION_ALIASES, forms: 'Umb.Section.Forms' }
41
+
42
+ /** Umbraco's `SectionMapper.GetName`: a stored alias as its section alias, or unchanged when unknown. */
43
+ export function sectionName(appAlias: string): string {
44
+ return STORED_ALIASES[appAlias] ?? appAlias
45
+ }
46
+
47
+ /** Umbraco's `SectionMapper.GetAlias`: a section alias as the alias stored for it, or unchanged. */
48
+ export function sectionAppAlias(name: string): string {
49
+ return Object.entries(STORED_ALIASES).find(([, value]) => value === name)?.[0] ?? name
50
+ }
package/src/uploads.ts ADDED
@@ -0,0 +1,41 @@
1
+ /**
2
+ * What may be uploaded, as Umbraco's defaults have it (`ContentSettings`,
3
+ * `ContentImagingSettings`). The temporary-file configuration endpoint reports
4
+ * these, and the upload endpoints enforce the same lists.
5
+ */
6
+ export interface UploadSettings {
7
+ /** Extensions treated as images: resizable, croppable, shown as thumbnails. */
8
+ imageFileTypes: string[]
9
+ /** Never accepted, whatever else is configured: files a server could execute. */
10
+ disallowedExtensions: string[]
11
+ /** When non-empty, only these are accepted. Empty means anything not disallowed. */
12
+ allowedExtensions: string[]
13
+ /** In bytes; null means no limit is configured. */
14
+ maxFileSize: number | null
15
+ }
16
+
17
+ export const DEFAULT_UPLOAD_SETTINGS: Readonly<UploadSettings> = {
18
+ imageFileTypes: ['jpeg', 'jpg', 'gif', 'bmp', 'png', 'tiff', 'tif', 'webp'],
19
+ disallowedExtensions: [
20
+ 'ashx',
21
+ 'aspx',
22
+ 'ascx',
23
+ 'config',
24
+ 'cshtml',
25
+ 'vbhtml',
26
+ 'asmx',
27
+ 'air',
28
+ 'axd',
29
+ 'xamlx',
30
+ ],
31
+ allowedExtensions: [],
32
+ maxFileSize: null,
33
+ }
34
+
35
+ /** Whether a file name's extension may be uploaded under these settings. */
36
+ export function isUploadAllowed(fileName: string, settings: UploadSettings): boolean {
37
+ const dot = fileName.lastIndexOf('.')
38
+ const extension = dot >= 0 ? fileName.slice(dot + 1).toLowerCase() : ''
39
+ if (settings.disallowedExtensions.includes(extension)) return false
40
+ return settings.allowedExtensions.length === 0 || settings.allowedExtensions.includes(extension)
41
+ }
package/src/users.ts ADDED
@@ -0,0 +1,51 @@
1
+ /** Rules for backoffice users' credentials, as Umbraco's `UserPasswordConfigurationSettings` defaults them. */
2
+
3
+ export interface PasswordConfiguration {
4
+ minimumPasswordLength: number
5
+ requireNonLetterOrDigit: boolean
6
+ requireDigit: boolean
7
+ requireLowercase: boolean
8
+ requireUppercase: boolean
9
+ }
10
+
11
+ export const DEFAULT_PASSWORD_CONFIGURATION: PasswordConfiguration = {
12
+ minimumPasswordLength: 10,
13
+ requireNonLetterOrDigit: false,
14
+ requireDigit: false,
15
+ requireLowercase: false,
16
+ requireUppercase: false,
17
+ }
18
+
19
+ export function isValidPassword(
20
+ password: string,
21
+ config: PasswordConfiguration = DEFAULT_PASSWORD_CONFIGURATION,
22
+ ): boolean {
23
+ if (password.length < config.minimumPasswordLength) return false
24
+ if (config.requireDigit && !/\d/.test(password)) return false
25
+ if (config.requireLowercase && !/[a-z]/.test(password)) return false
26
+ if (config.requireUppercase && !/[A-Z]/.test(password)) return false
27
+ if (config.requireNonLetterOrDigit && !/[^A-Za-z0-9]/.test(password)) return false
28
+ return true
29
+ }
30
+
31
+ /** A password that satisfies `config`, for a reset the backoffice shows once. */
32
+ export function generateUserPassword(
33
+ config: PasswordConfiguration = DEFAULT_PASSWORD_CONFIGURATION,
34
+ ): string {
35
+ const letters = 'abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ'
36
+ const all = `${letters}23456789`
37
+ const length = Math.max(config.minimumPasswordLength, 12)
38
+ const random = crypto.getRandomValues(new Uint32Array(length))
39
+ const chars = Array.from(random, (n) => all[n % all.length] as string)
40
+ // One of each class keeps any combination of requirements satisfied
41
+ chars[0] = 'abcdefghijkmnopqrstuvwxyz'[(random[0] ?? 0) % 25] as string
42
+ chars[1] = 'ABCDEFGHJKLMNPQRSTUVWXYZ'[(random[1] ?? 0) % 24] as string
43
+ chars[2] = '23456789'[(random[2] ?? 0) % 8] as string
44
+ if (config.requireNonLetterOrDigit) chars[3] = '!@#$%*-_+='[(random[3] ?? 0) % 10] as string
45
+ return chars.join('')
46
+ }
47
+
48
+ /** A plausible e-mail address; Umbraco's check is no stricter. */
49
+ export function isValidEmail(email: string): boolean {
50
+ return /^[^\s@]+@[^\s@]+$/.test(email)
51
+ }
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.