@byline/host-tanstack-start 6.2.1 → 6.4.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/dist/admin-shell/chrome/breadcrumbs/breadcrumbs-provider.js +13 -2
- package/dist/admin-shell/chrome/breadcrumbs/breadcrumbs.js +2 -6
- package/dist/admin-shell/chrome/breadcrumbs/breadcrumbs.test.d.ts +8 -0
- package/dist/admin-shell/collections/api.js +3 -2
- package/dist/admin-shell/collections/edit.js +3 -2
- package/dist/admin-shell/collections/restore-version-modal.js +19 -0
- package/dist/admin-shell/describe-mutation-failure.d.ts +18 -0
- package/dist/admin-shell/describe-mutation-failure.js +9 -0
- package/dist/admin-shell/singletons/view.js +2 -1
- package/dist/integrations/byline-field-services.d.ts +19 -0
- package/dist/integrations/byline-field-services.js +18 -2
- package/dist/routes/create-admin-layout-route.js +6 -3
- package/dist/server-fns/collections/create.js +4 -2
- package/dist/server-fns/collections/restore-version.d.ts +1 -0
- package/dist/server-fns/collections/restore-version.js +4 -1
- package/dist/server-fns/collections/update.js +3 -2
- package/dist/server-fns/document-mutation-errors.js +7 -1
- package/dist/server-fns/document-write-input.d.ts +9 -0
- package/dist/server-fns/document-write-input.js +44 -0
- package/dist/server-fns/singletons/restore-version.d.ts +1 -1
- package/package.json +16 -16
- package/src/admin-shell/chrome/breadcrumbs/breadcrumbs-provider.tsx +34 -4
- package/src/admin-shell/chrome/breadcrumbs/breadcrumbs.test.tsx +274 -0
- package/src/admin-shell/chrome/breadcrumbs/breadcrumbs.tsx +7 -7
- package/src/admin-shell/collections/api.tsx +17 -2
- package/src/admin-shell/collections/edit.tsx +3 -2
- package/src/admin-shell/collections/restore-version-modal.tsx +26 -0
- package/src/admin-shell/describe-mutation-failure.ts +30 -0
- package/src/admin-shell/singletons/view.tsx +2 -1
- package/src/integrations/byline-field-services.test.node.ts +129 -0
- package/src/integrations/byline-field-services.ts +37 -0
- package/src/routes/create-admin-layout-route.tsx +17 -6
- package/src/server-fns/collections/create.ts +5 -2
- package/src/server-fns/collections/restore-version.ts +4 -0
- package/src/server-fns/collections/update.ts +3 -2
- package/src/server-fns/document-mutation-errors.ts +8 -0
- package/src/server-fns/document-mutation-transport.test.node.ts +41 -0
- package/src/server-fns/document-write-input.test.node.ts +48 -0
- package/src/server-fns/document-write-input.ts +67 -0
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
* route (and the `AdminAppBar`) can read it without an extra fetch.
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
-
import { useEffect } from 'react'
|
|
24
|
+
import { useEffect, useMemo } from 'react'
|
|
25
25
|
import { createFileRoute, Outlet } from '@tanstack/react-router'
|
|
26
26
|
|
|
27
27
|
import { applyStoredTheme } from '@byline/admin/admin-account/components/theme'
|
|
@@ -43,7 +43,10 @@ import { SessionChangeBoundary } from '../admin-shell/chrome/session-change-boun
|
|
|
43
43
|
import { buildLocaleDefinitions } from '../i18n/locale-definitions.js'
|
|
44
44
|
import { bylineAdminServices } from '../integrations/byline-admin-services.js'
|
|
45
45
|
import { BylineAiAdminProvider } from '../integrations/byline-ai.js'
|
|
46
|
-
import {
|
|
46
|
+
import {
|
|
47
|
+
abilityFingerprint,
|
|
48
|
+
buildBylineFieldServices,
|
|
49
|
+
} from '../integrations/byline-field-services.js'
|
|
47
50
|
import { observeSession, sessionIsChanged } from '../integrations/session-coordination.js'
|
|
48
51
|
import { renewExpectedSession } from '../integrations/session-renewal.js'
|
|
49
52
|
import { getAnalyticsRuntime } from '../server-fns/analytics/index.js'
|
|
@@ -99,9 +102,17 @@ export function createAdminLayoutRoute(path: string) {
|
|
|
99
102
|
useEffect(() => {
|
|
100
103
|
applyStoredTheme()
|
|
101
104
|
}, [])
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
+
// Composed here rather than imported as a constant because the create
|
|
106
|
+
// affordance depends on the viewer's abilities, which only exist in route
|
|
107
|
+
// context. Keying on ability content avoids notifying context consumers
|
|
108
|
+
// when route context supplies an equivalent user object or abilities array.
|
|
109
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: see above
|
|
110
|
+
const fieldServices = useMemo(
|
|
111
|
+
() => buildBylineFieldServices(user),
|
|
112
|
+
[abilityFingerprint(user)]
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
// The full reload re-runs beforeLoad with the saved locale and its bundle.
|
|
105
116
|
const handleSetLocale = async (next: LocaleCode) => {
|
|
106
117
|
await setAdminLocaleFn({ data: { locale: next } })
|
|
107
118
|
window.location.reload()
|
|
@@ -116,7 +127,7 @@ export function createAdminLayoutRoute(path: string) {
|
|
|
116
127
|
setLocale={handleSetLocale}
|
|
117
128
|
>
|
|
118
129
|
<BylineAdminServicesProvider services={bylineAdminServices}>
|
|
119
|
-
<BylineFieldServicesProvider services={
|
|
130
|
+
<BylineFieldServicesProvider services={fieldServices}>
|
|
120
131
|
<BylineAiAdminProvider>
|
|
121
132
|
<AdminMenuProvider>
|
|
122
133
|
<RouteProgressBar />
|
|
@@ -12,10 +12,12 @@ import { getAdminRequestContext } from '@byline/client/server'
|
|
|
12
12
|
import { ERR_NOT_FOUND, getLogger, getServerConfig } from '@byline/core'
|
|
13
13
|
import type { CreateDocumentResult, DocumentLifecycleContext } from '@byline/core/services'
|
|
14
14
|
import { createDocument } from '@byline/core/services'
|
|
15
|
+
import { getDefaultStatus } from '@byline/core/workflow'
|
|
15
16
|
|
|
16
17
|
import { ensureCollection } from '../../integrations/api-utils.js'
|
|
17
18
|
import { adminSessionMiddleware } from '../../integrations/session-middleware.js'
|
|
18
19
|
import { withDocumentMutationErrors } from '../document-mutation-errors.js'
|
|
20
|
+
import { validateDocumentWriteInput } from '../document-write-input.js'
|
|
19
21
|
import { toCommittedDocumentHookFailureResponse } from './save-outcome.js'
|
|
20
22
|
|
|
21
23
|
// ---------------------------------------------------------------------------
|
|
@@ -31,7 +33,7 @@ export const createCollectionDocument = createServerFn({ method: 'POST' })
|
|
|
31
33
|
locale?: string
|
|
32
34
|
path?: string
|
|
33
35
|
availableLocales?: string[]
|
|
34
|
-
}) => input
|
|
36
|
+
}) => validateDocumentWriteInput(input, 'create')
|
|
35
37
|
)
|
|
36
38
|
.handler(
|
|
37
39
|
withDocumentMutationErrors(async ({ data: input }) => {
|
|
@@ -68,7 +70,8 @@ export const createCollectionDocument = createServerFn({ method: 'POST' })
|
|
|
68
70
|
try {
|
|
69
71
|
result = await createDocument(ctx, {
|
|
70
72
|
data: structuredClone(documentData),
|
|
71
|
-
status
|
|
73
|
+
// The admin create flow always uses the configured initial status.
|
|
74
|
+
status: getDefaultStatus(config.definition),
|
|
72
75
|
locale: locale ?? serverConfig.i18n.content.defaultLocale,
|
|
73
76
|
path: explicitPath,
|
|
74
77
|
availableLocales,
|
|
@@ -66,6 +66,10 @@ export const restoreDocumentVersion = createServerFn({ method: 'POST' })
|
|
|
66
66
|
revision: result.revision,
|
|
67
67
|
documentVersionId: result.documentVersionId,
|
|
68
68
|
sourceVersionId: result.sourceVersionId,
|
|
69
|
+
// Restore is exempt from field validation; these describe what
|
|
70
|
+
// today's rules object to, so the editor can say what must be fixed
|
|
71
|
+
// before the next ordinary save.
|
|
72
|
+
...(result.validationIssues ? { validationIssues: result.validationIssues } : {}),
|
|
69
73
|
}
|
|
70
74
|
} catch (error) {
|
|
71
75
|
const committedFailure = toCommittedDocumentHookFailureResponse(error)
|
|
@@ -17,6 +17,7 @@ import { saveDocument, updateDocumentSystemFields } from '@byline/core/services'
|
|
|
17
17
|
import { ensureCollection } from '../../integrations/api-utils.js'
|
|
18
18
|
import { adminSessionMiddleware } from '../../integrations/session-middleware.js'
|
|
19
19
|
import { withDocumentMutationErrors } from '../document-mutation-errors.js'
|
|
20
|
+
import { validateDocumentWriteInput } from '../document-write-input.js'
|
|
20
21
|
import { toCommittedDocumentHookFailureResponse } from './save-outcome.js'
|
|
21
22
|
|
|
22
23
|
// ---------------------------------------------------------------------------
|
|
@@ -35,7 +36,7 @@ export const updateCollectionDocumentWithPatches = createServerFn({ method: 'POS
|
|
|
35
36
|
path?: string | null
|
|
36
37
|
availableLocales?: string[]
|
|
37
38
|
locale?: string
|
|
38
|
-
}) => input
|
|
39
|
+
}) => validateDocumentWriteInput(input, 'patch')
|
|
39
40
|
)
|
|
40
41
|
.handler(
|
|
41
42
|
withDocumentMutationErrors(async ({ data: input }) => {
|
|
@@ -115,7 +116,7 @@ export const updateCollectionDocumentSystemFields = createServerFn({ method: 'PO
|
|
|
115
116
|
path?: string | null
|
|
116
117
|
/** Editorial advertised-locale set; omitted means no advertised-locale write. */
|
|
117
118
|
availableLocales?: string[]
|
|
118
|
-
}) => input
|
|
119
|
+
}) => validateDocumentWriteInput(input, 'metadata')
|
|
119
120
|
)
|
|
120
121
|
.handler(
|
|
121
122
|
withDocumentMutationErrors(async ({ data: input }) => {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
BylineError,
|
|
3
3
|
ErrorCodes,
|
|
4
|
+
getDocumentFieldValidationDetails,
|
|
4
5
|
getDocumentRevisionValidationDetails,
|
|
5
6
|
getDocumentStaleDetails,
|
|
6
7
|
getLockConflictDetails,
|
|
@@ -27,6 +28,13 @@ export function documentMutationError(error: unknown): unknown {
|
|
|
27
28
|
message: 'Reload this document before making changes.',
|
|
28
29
|
details: validation,
|
|
29
30
|
}
|
|
31
|
+
const fields = getDocumentFieldValidationDetails(error)
|
|
32
|
+
if (fields)
|
|
33
|
+
return {
|
|
34
|
+
code: ErrorCodes.VALIDATION,
|
|
35
|
+
message: 'Some document fields are invalid.',
|
|
36
|
+
details: fields,
|
|
37
|
+
}
|
|
30
38
|
const lock = getLockConflictDetails(error)
|
|
31
39
|
if (lock)
|
|
32
40
|
return {
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
ERR_NOT_FOUND,
|
|
10
10
|
ERR_TREE_HOOK_COMMITTED,
|
|
11
11
|
ERR_VALIDATION,
|
|
12
|
+
getDocumentFieldValidationDetails,
|
|
12
13
|
getDocumentRevisionValidationDetails,
|
|
13
14
|
getDocumentStaleDetails,
|
|
14
15
|
getLockConflictDetails,
|
|
@@ -209,3 +210,43 @@ it('transports unavailable-document errors without provider details', async () =
|
|
|
209
210
|
})
|
|
210
211
|
expect(wire).not.toMatch(/private|internal/)
|
|
211
212
|
})
|
|
213
|
+
|
|
214
|
+
describe('field validation over the installed transport', () => {
|
|
215
|
+
it('retains stable field paths and messages while dropping diagnostics', async () => {
|
|
216
|
+
const issues = [
|
|
217
|
+
{ field: 'items[id=item-1].title', message: 'Title is required', kind: 'required' as const },
|
|
218
|
+
]
|
|
219
|
+
const { value, wire } = await roundTrip({}, async () => {
|
|
220
|
+
throw ERR_VALIDATION({
|
|
221
|
+
message: 'private detail',
|
|
222
|
+
details: { reason: 'invalid_document_fields', issues, sql: 'SELECT secret' },
|
|
223
|
+
cause: new Error('private cause'),
|
|
224
|
+
})
|
|
225
|
+
})
|
|
226
|
+
expect(getDocumentFieldValidationDetails(value.error)).toEqual({
|
|
227
|
+
reason: 'invalid_document_fields',
|
|
228
|
+
issues,
|
|
229
|
+
})
|
|
230
|
+
expect(wire).not.toMatch(/private detail|SELECT secret|private cause/)
|
|
231
|
+
expect(getDocumentRevisionValidationDetails(value.error)).toBeNull()
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
it('never lets a malformed payload smuggle a waivable kind across the wire', async () => {
|
|
235
|
+
const { value } = await roundTrip({}, async () => {
|
|
236
|
+
throw ERR_VALIDATION({
|
|
237
|
+
message: 'private detail',
|
|
238
|
+
details: {
|
|
239
|
+
reason: 'invalid_document_fields',
|
|
240
|
+
issues: [
|
|
241
|
+
{ field: 'title', message: 'Title is required' },
|
|
242
|
+
{ field: 'summary', message: 'Summary is wrong', kind: 'bogus' },
|
|
243
|
+
],
|
|
244
|
+
},
|
|
245
|
+
})
|
|
246
|
+
})
|
|
247
|
+
expect(getDocumentFieldValidationDetails(value.error)?.issues.map((i) => i.kind)).toEqual([
|
|
248
|
+
'invalid',
|
|
249
|
+
'invalid',
|
|
250
|
+
])
|
|
251
|
+
})
|
|
252
|
+
})
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, it } from 'vitest'
|
|
10
|
+
|
|
11
|
+
import { validateDocumentWriteInput } from './document-write-input.js'
|
|
12
|
+
|
|
13
|
+
describe('document write request shapes', () => {
|
|
14
|
+
it.each([null, {}, { collection: 'pages', data: [] }, { collection: 42, data: {} }])(
|
|
15
|
+
'rejects malformed create requests: %j',
|
|
16
|
+
(input) => {
|
|
17
|
+
expect(() => validateDocumentWriteInput(input, 'create')).toThrow(
|
|
18
|
+
'Invalid document write request'
|
|
19
|
+
)
|
|
20
|
+
}
|
|
21
|
+
)
|
|
22
|
+
it.each([
|
|
23
|
+
null,
|
|
24
|
+
[null],
|
|
25
|
+
[{ kind: 'field.set', path: 'title[' }],
|
|
26
|
+
[{ kind: 'array.move', path: 'items', itemId: 'a', toIndex: -1 }],
|
|
27
|
+
[{ kind: 'unknown', path: 'title' }],
|
|
28
|
+
])('rejects malformed patches: %j', (patches) => {
|
|
29
|
+
expect(() =>
|
|
30
|
+
validateDocumentWriteInput({ collection: 'pages', id: 'doc', patches }, 'patch')
|
|
31
|
+
).toThrow()
|
|
32
|
+
})
|
|
33
|
+
it('accepts nested patches and leaves revision checks to their dedicated contract', () => {
|
|
34
|
+
const input = {
|
|
35
|
+
collection: 'pages',
|
|
36
|
+
id: 'doc',
|
|
37
|
+
patches: [
|
|
38
|
+
{
|
|
39
|
+
kind: 'array.updateItem',
|
|
40
|
+
path: 'items',
|
|
41
|
+
itemId: 'a',
|
|
42
|
+
patches: [{ kind: 'field.set', path: 'title', value: 'Hello' }],
|
|
43
|
+
},
|
|
44
|
+
],
|
|
45
|
+
}
|
|
46
|
+
expect(validateDocumentWriteInput(input, 'patch')).toBe(input)
|
|
47
|
+
})
|
|
48
|
+
})
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { ERR_VALIDATION, parseInstancePath } from '@byline/core'
|
|
10
|
+
|
|
11
|
+
const object = (value: unknown): value is Record<string, unknown> =>
|
|
12
|
+
value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
13
|
+
const text = (value: unknown): value is string =>
|
|
14
|
+
typeof value === 'string' && value.trim().length > 0
|
|
15
|
+
const index = (value: unknown) => Number.isSafeInteger(value) && (value as number) >= 0
|
|
16
|
+
const path = (value: unknown) => text(value) && parseInstancePath(value).ok
|
|
17
|
+
|
|
18
|
+
function patches(value: unknown): boolean {
|
|
19
|
+
return (
|
|
20
|
+
Array.isArray(value) &&
|
|
21
|
+
value.every((patch) => {
|
|
22
|
+
if (!object(patch) || !path(patch.path)) return false
|
|
23
|
+
if (patch.opId !== undefined && !text(patch.opId)) return false
|
|
24
|
+
if (patch.timestamp !== undefined && !text(patch.timestamp)) return false
|
|
25
|
+
switch (patch.kind) {
|
|
26
|
+
case 'field.set':
|
|
27
|
+
return 'value' in patch
|
|
28
|
+
case 'field.clear':
|
|
29
|
+
return true
|
|
30
|
+
case 'array.insert':
|
|
31
|
+
return 'item' in patch && (patch.index === undefined || index(patch.index))
|
|
32
|
+
case 'array.move':
|
|
33
|
+
return text(patch.itemId) && index(patch.toIndex)
|
|
34
|
+
case 'array.remove':
|
|
35
|
+
return text(patch.itemId)
|
|
36
|
+
case 'array.updateItem':
|
|
37
|
+
return text(patch.itemId) && patches(patch.patches)
|
|
38
|
+
case 'block.add':
|
|
39
|
+
return text(patch.blockType) && (patch.index === undefined || index(patch.index))
|
|
40
|
+
case 'block.move':
|
|
41
|
+
return text(patch.blockId) && index(patch.toIndex)
|
|
42
|
+
case 'block.remove':
|
|
43
|
+
return text(patch.blockId)
|
|
44
|
+
case 'block.updateField':
|
|
45
|
+
return text(patch.blockId) && path(patch.fieldPath) && 'value' in patch
|
|
46
|
+
default:
|
|
47
|
+
return false
|
|
48
|
+
}
|
|
49
|
+
})
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Shape checks complement lifecycle authorization, revision and field validation. */
|
|
54
|
+
export function validateDocumentWriteInput<T>(input: T, mode: 'create' | 'patch' | 'metadata'): T {
|
|
55
|
+
const valid =
|
|
56
|
+
object(input) &&
|
|
57
|
+
text(input.collection) &&
|
|
58
|
+
(mode === 'create' ? object(input.data) : text(input.id)) &&
|
|
59
|
+
(input.locale === undefined || text(input.locale)) &&
|
|
60
|
+
(input.path == null || typeof input.path === 'string') &&
|
|
61
|
+
(input.availableLocales === undefined ||
|
|
62
|
+
(Array.isArray(input.availableLocales) && input.availableLocales.every(text))) &&
|
|
63
|
+
(mode !== 'patch' || patches(input.patches))
|
|
64
|
+
// Revision errors keep their existing, specialized lifecycle decoder.
|
|
65
|
+
if (!valid) throw ERR_VALIDATION({ message: 'Invalid document write request.' })
|
|
66
|
+
return input
|
|
67
|
+
}
|