@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.
Files changed (39) hide show
  1. package/dist/admin-shell/chrome/breadcrumbs/breadcrumbs-provider.js +13 -2
  2. package/dist/admin-shell/chrome/breadcrumbs/breadcrumbs.js +2 -6
  3. package/dist/admin-shell/chrome/breadcrumbs/breadcrumbs.test.d.ts +8 -0
  4. package/dist/admin-shell/collections/api.js +3 -2
  5. package/dist/admin-shell/collections/edit.js +3 -2
  6. package/dist/admin-shell/collections/restore-version-modal.js +19 -0
  7. package/dist/admin-shell/describe-mutation-failure.d.ts +18 -0
  8. package/dist/admin-shell/describe-mutation-failure.js +9 -0
  9. package/dist/admin-shell/singletons/view.js +2 -1
  10. package/dist/integrations/byline-field-services.d.ts +19 -0
  11. package/dist/integrations/byline-field-services.js +18 -2
  12. package/dist/routes/create-admin-layout-route.js +6 -3
  13. package/dist/server-fns/collections/create.js +4 -2
  14. package/dist/server-fns/collections/restore-version.d.ts +1 -0
  15. package/dist/server-fns/collections/restore-version.js +4 -1
  16. package/dist/server-fns/collections/update.js +3 -2
  17. package/dist/server-fns/document-mutation-errors.js +7 -1
  18. package/dist/server-fns/document-write-input.d.ts +9 -0
  19. package/dist/server-fns/document-write-input.js +44 -0
  20. package/dist/server-fns/singletons/restore-version.d.ts +1 -1
  21. package/package.json +16 -16
  22. package/src/admin-shell/chrome/breadcrumbs/breadcrumbs-provider.tsx +34 -4
  23. package/src/admin-shell/chrome/breadcrumbs/breadcrumbs.test.tsx +274 -0
  24. package/src/admin-shell/chrome/breadcrumbs/breadcrumbs.tsx +7 -7
  25. package/src/admin-shell/collections/api.tsx +17 -2
  26. package/src/admin-shell/collections/edit.tsx +3 -2
  27. package/src/admin-shell/collections/restore-version-modal.tsx +26 -0
  28. package/src/admin-shell/describe-mutation-failure.ts +30 -0
  29. package/src/admin-shell/singletons/view.tsx +2 -1
  30. package/src/integrations/byline-field-services.test.node.ts +129 -0
  31. package/src/integrations/byline-field-services.ts +37 -0
  32. package/src/routes/create-admin-layout-route.tsx +17 -6
  33. package/src/server-fns/collections/create.ts +5 -2
  34. package/src/server-fns/collections/restore-version.ts +4 -0
  35. package/src/server-fns/collections/update.ts +3 -2
  36. package/src/server-fns/document-mutation-errors.ts +8 -0
  37. package/src/server-fns/document-mutation-transport.test.node.ts +41 -0
  38. package/src/server-fns/document-write-input.test.node.ts +48 -0
  39. 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 { bylineFieldServices } from '../integrations/byline-field-services.js'
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
- // Cookie + DB write happen in setAdminLocaleFn; full reload
103
- // re-runs beforeLoad so the provider re-renders with the new
104
- // bundle/locale (no in-place bundle swap needed for PR 1's scope).
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={bylineFieldServices}>
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: documentData.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
+ }