@ossy/resources 3.4.0 → 3.5.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.
Files changed (37) hide show
  1. package/package.json +4 -4
  2. package/src/PlatformFileField.jsx +120 -85
  3. package/src/PlatformReferenceField.jsx +216 -0
  4. package/src/ResourceContentPage.jsx +2 -2
  5. package/src/ResourceGrid.jsx +44 -4
  6. package/src/ResourceList.jsx +38 -4
  7. package/src/ResourcePanel.jsx +19 -2
  8. package/src/ResourceSelectionCheckbox.jsx +68 -0
  9. package/src/SchemaDetailView.jsx +18 -18
  10. package/src/SchemaPresenter.jsx +6 -4
  11. package/src/Upload.jsx +28 -13
  12. package/src/create.task.js +4 -0
  13. package/src/en.translations.json +4 -1
  14. package/src/get-resource.api.js +15 -0
  15. package/src/index.js +2 -0
  16. package/src/markdown.component.jsx +20 -0
  17. package/src/markdown.schema.js +13 -0
  18. package/src/markdown.spec.js +43 -0
  19. package/src/platform-reference-field.component.jsx +5 -0
  20. package/src/reference-field.helpers.js +57 -0
  21. package/src/reference-field.helpers.spec.js +100 -0
  22. package/src/resource-download.js +194 -0
  23. package/src/resource-download.spec.js +93 -0
  24. package/src/resource-read.helpers.js +5 -1
  25. package/src/resource-selection.js +91 -0
  26. package/src/resource-selection.spec.js +77 -0
  27. package/src/resource-upload-access.js +63 -0
  28. package/src/resource-upload-access.spec.js +100 -0
  29. package/src/resource.helpers.js +30 -5
  30. package/src/resources.attach-reference-links.js +100 -0
  31. package/src/resources.attach-reference-links.spec.js +118 -0
  32. package/src/resources.validate-references.js +104 -0
  33. package/src/resources.validate-references.spec.js +182 -0
  34. package/src/schema-field-display.js +12 -0
  35. package/src/server.js +13 -0
  36. package/src/sv.translations.json +4 -1
  37. package/src/update-content.task.js +4 -0
@@ -0,0 +1,100 @@
1
+ import { beforeEach, describe, expect, it, jest } from '@jest/globals'
2
+
3
+ const viewResource = jest.fn()
4
+ const workspaceView = jest.fn()
5
+
6
+ jest.unstable_mockModule('./resource-stream.helpers.js', () => ({
7
+ viewResource,
8
+ }))
9
+
10
+ jest.unstable_mockModule('@ossy/event-store', () => ({
11
+ Aggregate: {
12
+ Of: jest.fn(async () => ({})),
13
+ View: () => workspaceView,
14
+ },
15
+ }))
16
+
17
+ jest.unstable_mockModule('@ossy/workspaces/server', () => ({
18
+ Workspace: {},
19
+ }))
20
+
21
+ const {
22
+ resourceIdFromStorageKey,
23
+ assertResourceUploadAccess,
24
+ assertUploadAccessForStorageKey,
25
+ } = await import('./resource-upload-access.js')
26
+
27
+ describe('resourceIdFromStorageKey', () => {
28
+ it('returns the resource id for original keys', () => {
29
+ expect(resourceIdFromStorageKey('abc123')).toBe('abc123')
30
+ })
31
+
32
+ it('strips derivative variants', () => {
33
+ expect(resourceIdFromStorageKey('abc123:thumb')).toBe('abc123')
34
+ })
35
+
36
+ it('rejects empty keys', () => {
37
+ expect(() => resourceIdFromStorageKey('')).toThrow(/Invalid storage key/)
38
+ })
39
+ })
40
+
41
+ describe('assertResourceUploadAccess', () => {
42
+ beforeEach(() => {
43
+ workspaceView.mockReset()
44
+ })
45
+
46
+ it('rejects anonymous callers', async () => {
47
+ await expect(
48
+ assertResourceUploadAccess({ id: 'r1', belongsTo: 'ws1' }, { userId: 'anonymous', workspaceId: 'ws1' }),
49
+ ).rejects.toMatchObject({ status: 401 })
50
+ })
51
+
52
+ it('rejects missing workspace context', async () => {
53
+ await expect(
54
+ assertResourceUploadAccess({ id: 'r1', belongsTo: 'ws1' }, { userId: 'u1' }),
55
+ ).rejects.toMatchObject({ status: 403 })
56
+ })
57
+
58
+ it('rejects cross-workspace resources', async () => {
59
+ await expect(
60
+ assertResourceUploadAccess({ id: 'r1', belongsTo: 'ws-other' }, { userId: 'u1', workspaceId: 'ws1' }),
61
+ ).rejects.toMatchObject({ status: 403 })
62
+ })
63
+
64
+ it('rejects non-members', async () => {
65
+ workspaceView.mockReturnValue({ users: ['someone-else'] })
66
+ await expect(
67
+ assertResourceUploadAccess({ id: 'r1', belongsTo: 'ws1' }, { userId: 'u1', workspaceId: 'ws1' }),
68
+ ).rejects.toMatchObject({ status: 403 })
69
+ })
70
+
71
+ it('allows workspace members', async () => {
72
+ workspaceView.mockReturnValue({ users: ['u1', 'u2'] })
73
+ await expect(
74
+ assertResourceUploadAccess({ id: 'r1', belongsTo: 'ws1' }, { userId: 'u1', workspaceId: 'ws1' }),
75
+ ).resolves.toBeUndefined()
76
+ })
77
+ })
78
+
79
+ describe('assertUploadAccessForStorageKey', () => {
80
+ beforeEach(() => {
81
+ viewResource.mockReset()
82
+ workspaceView.mockReset()
83
+ })
84
+
85
+ it('returns 404 when the resource is missing', async () => {
86
+ viewResource.mockResolvedValue(null)
87
+ await expect(
88
+ assertUploadAccessForStorageKey('missing', { userId: 'u1', workspaceId: 'ws1' }),
89
+ ).rejects.toMatchObject({ status: 404 })
90
+ })
91
+
92
+ it('loads the resource and asserts membership', async () => {
93
+ viewResource.mockResolvedValue({ id: 'abc', belongsTo: 'ws1' })
94
+ workspaceView.mockReturnValue({ users: ['u1'] })
95
+ await expect(
96
+ assertUploadAccessForStorageKey('abc:thumb', { userId: 'u1', workspaceId: 'ws1' }),
97
+ ).resolves.toEqual({ id: 'abc', belongsTo: 'ws1' })
98
+ expect(viewResource).toHaveBeenCalledWith('abc')
99
+ })
100
+ })
@@ -1,3 +1,4 @@
1
+ import { isLocalStorageUploadUrl } from '@ossy/sdk'
1
2
  import { cacheKey, normalizeLocation } from '@ossy/sdk-react'
2
3
  import { metadata as CreateResource } from './create.action.js'
3
4
  import { metadata as DeleteResource } from './delete.action.js'
@@ -7,6 +8,25 @@ import { metadata as UpdateResourceContent } from './update-content.action.js'
7
8
  import { metadata as UpdateResourceLocation } from './update-location.action.js'
8
9
  import { metadata as UpdateResourceName } from './update-name.action.js'
9
10
 
11
+ export {
12
+ downloadFilenameForResource,
13
+ filterDownloadableResources,
14
+ isDirectoryResource,
15
+ isDownloadableResource,
16
+ resourceDownloadHref,
17
+ triggerResourceDownload,
18
+ triggerResourceBlobDownload,
19
+ triggerResourceDownloads,
20
+ } from './resource-download.js'
21
+
22
+ export {
23
+ applyResourceSelectionGesture,
24
+ idsInRange,
25
+ isMultiSelectGesture,
26
+ isRangeSelectModifier,
27
+ isToggleSelectModifier,
28
+ } from './resource-selection.js'
29
+
10
30
  function invalidateLocation(sdk, location) {
11
31
  sdk.invalidate(cacheKey(ListResources, { location: normalizeLocation(location) }))
12
32
  }
@@ -41,11 +61,16 @@ export async function uploadFile(sdk, uploadLocation, file) {
41
61
  size: file.size,
42
62
  })
43
63
  if (resource.content?.uploadUrl) {
44
- const response = await fetch(resource.content.uploadUrl, {
45
- method: 'PUT',
46
- headers: file.type ? { 'Content-Type': file.type } : undefined,
47
- body: file,
48
- })
64
+ const uploadUrl = resource.content.uploadUrl
65
+ const headers = {}
66
+ if (file.type) headers['Content-Type'] = file.type
67
+ const init = { method: 'PUT', headers, body: file }
68
+ if (isLocalStorageUploadUrl(uploadUrl)) {
69
+ init.credentials = 'include'
70
+ if (sdk.workspaceId) headers.workspaceId = sdk.workspaceId
71
+ if (sdk.authorization) headers.Authorization = sdk.authorization
72
+ }
73
+ const response = await fetch(uploadUrl, init)
49
74
  if (!response.ok) {
50
75
  throw Object.assign(
51
76
  new Error(`Upload failed: HTTP ${response.status}`),
@@ -0,0 +1,100 @@
1
+ import { Aggregate } from '@ossy/event-store'
2
+ import { getPlatformSchema, schemaForWorkspace } from '@ossy/platform'
3
+ import { Workspace } from '@ossy/workspaces/server'
4
+
5
+ /**
6
+ * Public read path for a linked document resource.
7
+ *
8
+ * @param {string} resourceId
9
+ * @returns {string}
10
+ */
11
+ export function documentReferenceHref(resourceId) {
12
+ return `/r/${resourceId}`
13
+ }
14
+
15
+ function isResourceRef(value) {
16
+ return (
17
+ value !== null &&
18
+ typeof value === 'object' &&
19
+ !Array.isArray(value) &&
20
+ typeof value.resourceId === 'string' &&
21
+ value.resourceId.trim().length > 0
22
+ )
23
+ }
24
+
25
+ function rewriteReferenceValue(value, max = 1) {
26
+ if (max === 1) {
27
+ if (!isResourceRef(value)) return value
28
+ return documentReferenceHref(value.resourceId.trim())
29
+ }
30
+ if (!Array.isArray(value)) return value
31
+ return value.map(item =>
32
+ isResourceRef(item) ? documentReferenceHref(item.resourceId.trim()) : item,
33
+ )
34
+ }
35
+
36
+ /**
37
+ * Resolve a schema engine for rewriting reference fields.
38
+ * Falls back to platform schemas when workspace is unavailable.
39
+ *
40
+ * @param {{ workspaceId?: string }} context
41
+ */
42
+ async function resolveSchemaEngine({ workspaceId } = {}) {
43
+ try {
44
+ if (workspaceId) {
45
+ const workspace = await Aggregate.Of(Workspace, workspaceId).then(Aggregate.View())
46
+ return schemaForWorkspace(workspace?.schemas ?? [])
47
+ }
48
+ return getPlatformSchema()
49
+ } catch {
50
+ try {
51
+ return getPlatformSchema()
52
+ } catch {
53
+ return null
54
+ }
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Replace stored `{ resourceId }` reference field values with public document
60
+ * links (`/r/{id}`) for HTTP read responses. Action `get` keeps the stored shape
61
+ * for editing.
62
+ *
63
+ * @param {object} resource
64
+ * @param {{ workspaceId?: string }} [context]
65
+ * @returns {Promise<object>}
66
+ */
67
+ export async function attachResourceReferenceLinks(resource, { workspaceId } = {}) {
68
+ if (!resource?.content || typeof resource.content !== 'object') return resource
69
+ if (!resource.type || typeof resource.type !== 'string') return resource
70
+
71
+ const engine = await resolveSchemaEngine({ workspaceId })
72
+ if (!engine?.has?.(resource.type)) return resource
73
+
74
+ let definition
75
+ try {
76
+ definition = engine.resolve(resource.type)
77
+ } catch {
78
+ return resource
79
+ }
80
+
81
+ const fields = definition?.fields
82
+ if (!Array.isArray(fields) || !fields.length) return resource
83
+
84
+ let changed = false
85
+ const nextContent = { ...resource.content }
86
+
87
+ for (const field of fields) {
88
+ if (field?.type?.trim?.() !== 'reference') continue
89
+ const name = field.name
90
+ if (!name || !Object.prototype.hasOwnProperty.call(nextContent, name)) continue
91
+
92
+ const rewritten = rewriteReferenceValue(nextContent[name], field.max ?? 1)
93
+ if (rewritten !== nextContent[name]) {
94
+ nextContent[name] = rewritten
95
+ changed = true
96
+ }
97
+ }
98
+
99
+ return changed ? { ...resource, content: nextContent } : resource
100
+ }
@@ -0,0 +1,118 @@
1
+ import { describe, expect, it, jest, beforeEach, afterEach } from '@jest/globals'
2
+ import { Schema } from '@ossy/schema'
3
+
4
+ const authorSchema = {
5
+ id: '@ossy/blog/schema/author',
6
+ name: 'Author',
7
+ fields: [{ name: 'name', type: 'text', required: true }],
8
+ }
9
+
10
+ const postSchema = {
11
+ id: '@ossy/blog/schema/post',
12
+ name: 'Post',
13
+ fields: [
14
+ { name: 'title', type: 'text', required: true },
15
+ { name: 'author', type: 'reference', of: '@ossy/blog/schema/author', required: true },
16
+ { name: 'cover', type: 'file' },
17
+ ],
18
+ }
19
+
20
+ describe('attachResourceReferenceLinks', () => {
21
+ let attachResourceReferenceLinks
22
+ let documentReferenceHref
23
+ let getPlatformSchema
24
+
25
+ beforeEach(async () => {
26
+ jest.resetModules()
27
+ getPlatformSchema = jest.fn(() => Schema.of({ schemas: [authorSchema, postSchema] }))
28
+ jest.unstable_mockModule('@ossy/platform', () => ({
29
+ getPlatformSchema,
30
+ schemaForWorkspace: schemas => Schema.of({ schemas: [...schemas, authorSchema, postSchema] }),
31
+ }))
32
+ jest.unstable_mockModule('@ossy/event-store', () => ({
33
+ Aggregate: {
34
+ Of: jest.fn(),
35
+ View: jest.fn(),
36
+ },
37
+ }))
38
+ jest.unstable_mockModule('@ossy/workspaces/server', () => ({
39
+ Workspace: {},
40
+ }))
41
+
42
+ ;({
43
+ attachResourceReferenceLinks,
44
+ documentReferenceHref,
45
+ } = await import('./resources.attach-reference-links.js'))
46
+ })
47
+
48
+ afterEach(() => {
49
+ jest.restoreAllMocks()
50
+ })
51
+
52
+ it('builds document hrefs', () => {
53
+ expect(documentReferenceHref('abc')).toBe('/r/abc')
54
+ })
55
+
56
+ it('rewrites reference fields to /r/{id} links and leaves file fields alone', async () => {
57
+ const resource = {
58
+ id: 'post-1',
59
+ type: '@ossy/blog/schema/post',
60
+ content: {
61
+ title: 'Hello',
62
+ author: { resourceId: 'author-9' },
63
+ cover: { resourceId: 'file-3' },
64
+ },
65
+ }
66
+
67
+ const next = await attachResourceReferenceLinks(resource, {})
68
+ expect(next.content.author).toBe('/r/author-9')
69
+ expect(next.content.cover).toEqual({ resourceId: 'file-3' })
70
+ expect(next.content.title).toBe('Hello')
71
+ })
72
+
73
+ it('rewrites multi-reference arrays to /r/{id} links', async () => {
74
+ getPlatformSchema.mockReturnValue(
75
+ Schema.of({
76
+ schemas: [
77
+ authorSchema,
78
+ {
79
+ id: '@ossy/blog/schema/post',
80
+ name: 'Post',
81
+ fields: [
82
+ { name: 'title', type: 'text', required: true },
83
+ {
84
+ name: 'authors',
85
+ type: 'reference',
86
+ of: '@ossy/blog/schema/author',
87
+ max: 3,
88
+ },
89
+ ],
90
+ },
91
+ ],
92
+ }),
93
+ )
94
+
95
+ const resource = {
96
+ id: 'post-2',
97
+ type: '@ossy/blog/schema/post',
98
+ content: {
99
+ title: 'Team',
100
+ authors: [{ resourceId: 'a1' }, { resourceId: 'a2' }],
101
+ },
102
+ }
103
+
104
+ const next = await attachResourceReferenceLinks(resource, {})
105
+ expect(next.content.authors).toEqual(['/r/a1', '/r/a2'])
106
+ })
107
+
108
+ it('is a no-op when schema is unknown', async () => {
109
+ getPlatformSchema.mockReturnValue(Schema.of({ schemas: [] }))
110
+ const resource = {
111
+ id: 'post-1',
112
+ type: '@ossy/blog/schema/post',
113
+ content: { author: { resourceId: 'author-9' } },
114
+ }
115
+ const next = await attachResourceReferenceLinks(resource, {})
116
+ expect(next).toBe(resource)
117
+ })
118
+ })
@@ -0,0 +1,104 @@
1
+ import {
2
+ resourceIdFromValue,
3
+ resourceIdsFromValue,
4
+ } from './reference-field.helpers.js'
5
+ import { viewResource as defaultViewResource } from './resource-stream.helpers.js'
6
+
7
+ /**
8
+ * Collect reference field lookups from validated document content.
9
+ *
10
+ * @param {object} schema
11
+ * @param {object} content
12
+ * @returns {Array<{ fieldName: string, of: string, resourceId: string, path: string }>}
13
+ */
14
+ export function collectReferenceChecks(schema, content) {
15
+ const checks = []
16
+ const fields = schema?.fields
17
+ if (!Array.isArray(fields) || !content || typeof content !== 'object') return checks
18
+
19
+ const schemaLabel = typeof schema.id === 'string' && schema.id ? schema.id : 'schema'
20
+
21
+ for (const field of fields) {
22
+ if (field?.type?.trim?.() !== 'reference') continue
23
+ const name = field.name
24
+ const of = typeof field.of === 'string' ? field.of.trim() : ''
25
+ if (!name || !of) continue
26
+ if (!Object.prototype.hasOwnProperty.call(content, name)) continue
27
+
28
+ const value = content[name]
29
+ if (value === undefined || value === null) continue
30
+
31
+ const max = field.max ?? 1
32
+ const ids =
33
+ max === 1
34
+ ? (() => {
35
+ const id = resourceIdFromValue(value)
36
+ return id ? [id] : []
37
+ })()
38
+ : resourceIdsFromValue(value)
39
+
40
+ for (const resourceId of ids) {
41
+ checks.push({
42
+ fieldName: name,
43
+ of,
44
+ resourceId,
45
+ path: `${schemaLabel}.${name}`,
46
+ })
47
+ }
48
+ }
49
+
50
+ return checks
51
+ }
52
+
53
+ function isRemoved(resource) {
54
+ return Array.isArray(resource?.status) && resource.status.includes('removed')
55
+ }
56
+
57
+ /**
58
+ * Ensure every `reference` field points at an existing workspace document of
59
+ * the declared `of` schema id. Shape checks stay in `@ossy/schema`; this runs
60
+ * on the write path after sync validation.
61
+ *
62
+ * @param {object} schema
63
+ * @param {object} content
64
+ * @param {{ workspaceId?: string, viewResource?: (id: string) => Promise<object|null> }} [options]
65
+ */
66
+ export async function assertResourceReferencesExist(schema, content, options = {}) {
67
+ const viewResource = options.viewResource ?? defaultViewResource
68
+ const workspaceId = options.workspaceId
69
+ const checks = collectReferenceChecks(schema, content)
70
+ if (!checks.length) return
71
+
72
+ const results = await Promise.all(
73
+ checks.map(async check => {
74
+ let resource = null
75
+ try {
76
+ resource = await viewResource(check.resourceId)
77
+ } catch {
78
+ resource = null
79
+ }
80
+ return { check, resource }
81
+ }),
82
+ )
83
+
84
+ for (const { check, resource } of results) {
85
+ const missing =
86
+ !resource?.id ||
87
+ isRemoved(resource) ||
88
+ (workspaceId && resource.belongsTo && resource.belongsTo !== workspaceId)
89
+
90
+ if (missing) {
91
+ throw Object.assign(
92
+ new Error(`${check.path} references missing resource "${check.resourceId}"`),
93
+ { status: 400, type: 'REFERENCE_NOT_FOUND' },
94
+ )
95
+ }
96
+
97
+ if (resource.type !== check.of) {
98
+ throw Object.assign(
99
+ new Error(`${check.path} must reference a document of type "${check.of}"`),
100
+ { status: 400, type: 'REFERENCE_TYPE_MISMATCH' },
101
+ )
102
+ }
103
+ }
104
+ }
@@ -0,0 +1,182 @@
1
+ import { describe, expect, it, jest } from '@jest/globals'
2
+ import {
3
+ assertResourceReferencesExist,
4
+ collectReferenceChecks,
5
+ } from './resources.validate-references.js'
6
+
7
+ const authorSchema = {
8
+ id: '@ossy/blog/schema/author',
9
+ name: 'Author',
10
+ fields: [{ name: 'name', type: 'text', required: true }],
11
+ }
12
+
13
+ const postSchema = {
14
+ id: '@ossy/blog/schema/post',
15
+ name: 'Post',
16
+ fields: [
17
+ { name: 'title', type: 'text', required: true },
18
+ { name: 'author', type: 'reference', of: '@ossy/blog/schema/author', required: true },
19
+ { name: 'related', type: 'reference', of: '@ossy/blog/schema/post', max: 3 },
20
+ { name: 'cover', type: 'file' },
21
+ ],
22
+ }
23
+
24
+ describe('collectReferenceChecks', () => {
25
+ it('collects single and multi reference ids and skips file fields', () => {
26
+ expect(
27
+ collectReferenceChecks(postSchema, {
28
+ title: 'Hello',
29
+ author: { resourceId: 'author-1' },
30
+ related: [{ resourceId: 'post-2' }, { resourceId: 'post-3' }],
31
+ cover: { resourceId: 'file-1' },
32
+ }),
33
+ ).toEqual([
34
+ {
35
+ fieldName: 'author',
36
+ of: '@ossy/blog/schema/author',
37
+ resourceId: 'author-1',
38
+ path: '@ossy/blog/schema/post.author',
39
+ },
40
+ {
41
+ fieldName: 'related',
42
+ of: '@ossy/blog/schema/post',
43
+ resourceId: 'post-2',
44
+ path: '@ossy/blog/schema/post.related',
45
+ },
46
+ {
47
+ fieldName: 'related',
48
+ of: '@ossy/blog/schema/post',
49
+ resourceId: 'post-3',
50
+ path: '@ossy/blog/schema/post.related',
51
+ },
52
+ ])
53
+ })
54
+
55
+ it('returns no checks when content has no reference values', () => {
56
+ expect(collectReferenceChecks(postSchema, { title: 'Hello' })).toEqual([])
57
+ expect(collectReferenceChecks(authorSchema, { name: 'Ada' })).toEqual([])
58
+ })
59
+ })
60
+
61
+ describe('assertResourceReferencesExist', () => {
62
+ it('resolves when referenced documents exist with matching type and workspace', async () => {
63
+ const viewResource = jest.fn(async id => {
64
+ if (id === 'author-1') {
65
+ return {
66
+ id: 'author-1',
67
+ type: '@ossy/blog/schema/author',
68
+ belongsTo: 'ws-1',
69
+ }
70
+ }
71
+ return null
72
+ })
73
+
74
+ await expect(
75
+ assertResourceReferencesExist(
76
+ postSchema,
77
+ { title: 'Hello', author: { resourceId: 'author-1' } },
78
+ { workspaceId: 'ws-1', viewResource },
79
+ ),
80
+ ).resolves.toBeUndefined()
81
+
82
+ expect(viewResource).toHaveBeenCalledWith('author-1')
83
+ })
84
+
85
+ it('rejects missing referenced resources', async () => {
86
+ const viewResource = jest.fn(async () => null)
87
+
88
+ await expect(
89
+ assertResourceReferencesExist(
90
+ postSchema,
91
+ { title: 'Hello', author: { resourceId: 'missing' } },
92
+ { workspaceId: 'ws-1', viewResource },
93
+ ),
94
+ ).rejects.toMatchObject({
95
+ status: 400,
96
+ type: 'REFERENCE_NOT_FOUND',
97
+ message: '@ossy/blog/schema/post.author references missing resource "missing"',
98
+ })
99
+ })
100
+
101
+ it('rejects deleted referenced resources', async () => {
102
+ const viewResource = jest.fn(async () => ({
103
+ status: ['removed'],
104
+ }))
105
+
106
+ await expect(
107
+ assertResourceReferencesExist(
108
+ postSchema,
109
+ { title: 'Hello', author: { resourceId: 'gone' } },
110
+ { viewResource },
111
+ ),
112
+ ).rejects.toMatchObject({ type: 'REFERENCE_NOT_FOUND' })
113
+ })
114
+
115
+ it('rejects references outside the workspace without leaking existence', async () => {
116
+ const viewResource = jest.fn(async () => ({
117
+ id: 'author-1',
118
+ type: '@ossy/blog/schema/author',
119
+ belongsTo: 'other-ws',
120
+ }))
121
+
122
+ await expect(
123
+ assertResourceReferencesExist(
124
+ postSchema,
125
+ { title: 'Hello', author: { resourceId: 'author-1' } },
126
+ { workspaceId: 'ws-1', viewResource },
127
+ ),
128
+ ).rejects.toMatchObject({ type: 'REFERENCE_NOT_FOUND' })
129
+ })
130
+
131
+ it('rejects references whose document type does not match of', async () => {
132
+ const viewResource = jest.fn(async () => ({
133
+ id: 'author-1',
134
+ type: '@ossy/blog/schema/post',
135
+ belongsTo: 'ws-1',
136
+ }))
137
+
138
+ await expect(
139
+ assertResourceReferencesExist(
140
+ postSchema,
141
+ { title: 'Hello', author: { resourceId: 'author-1' } },
142
+ { workspaceId: 'ws-1', viewResource },
143
+ ),
144
+ ).rejects.toMatchObject({
145
+ status: 400,
146
+ type: 'REFERENCE_TYPE_MISMATCH',
147
+ message: '@ossy/blog/schema/post.author must reference a document of type "@ossy/blog/schema/author"',
148
+ })
149
+ })
150
+
151
+ it('validates each id in a multi-reference field', async () => {
152
+ const viewResource = jest.fn(async id => {
153
+ if (id === 'post-2') {
154
+ return { id: 'post-2', type: '@ossy/blog/schema/post', belongsTo: 'ws-1' }
155
+ }
156
+ return null
157
+ })
158
+
159
+ await expect(
160
+ assertResourceReferencesExist(
161
+ postSchema,
162
+ {
163
+ title: 'Hello',
164
+ author: { resourceId: 'author-1' },
165
+ related: [{ resourceId: 'post-2' }, { resourceId: 'missing-post' }],
166
+ },
167
+ {
168
+ workspaceId: 'ws-1',
169
+ viewResource: async id => {
170
+ if (id === 'author-1') {
171
+ return { id: 'author-1', type: '@ossy/blog/schema/author', belongsTo: 'ws-1' }
172
+ }
173
+ return viewResource(id)
174
+ },
175
+ },
176
+ ),
177
+ ).rejects.toMatchObject({
178
+ type: 'REFERENCE_NOT_FOUND',
179
+ message: '@ossy/blog/schema/post.related references missing resource "missing-post"',
180
+ })
181
+ })
182
+ })
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Whether a schema field value should render as Markdown in detail views.
3
+ *
4
+ * @param {{ type?: string } | null | undefined} field
5
+ * @param {unknown} value
6
+ * @returns {boolean}
7
+ */
8
+ export function shouldRenderFieldAsMarkdown (field, value) {
9
+ return field?.type === 'richtext'
10
+ && typeof value === 'string'
11
+ && value.trim().length > 0
12
+ }
package/src/server.js CHANGED
@@ -3,3 +3,16 @@ export * from './resource-stream.helpers.js'
3
3
  export * from './resources.events.js'
4
4
  export * from './resources.queries.js'
5
5
  export { attachResourceMediaUrls } from './resources.attach-media-urls.js'
6
+ export {
7
+ attachResourceReferenceLinks,
8
+ documentReferenceHref,
9
+ } from './resources.attach-reference-links.js'
10
+ export {
11
+ assertResourceUploadAccess,
12
+ assertUploadAccessForStorageKey,
13
+ resourceIdFromStorageKey,
14
+ } from './resource-upload-access.js'
15
+ export {
16
+ assertResourceReferencesExist,
17
+ collectReferenceChecks,
18
+ } from './resources.validate-references.js'
@@ -45,5 +45,8 @@
45
45
  "resources.createDirectory.namePlaceholder": "Mappnamn",
46
46
  "resources.createDirectory.submit": "Skapa mapp",
47
47
  "resources.createDirectory.errorEmpty": "Mappnamn får inte vara tomt",
48
- "resources.createDirectory.errorOssyPrefix": "Mappnamn får inte börja med @ossy"
48
+ "resources.createDirectory.errorOssyPrefix": "Mappnamn får inte börja med @ossy",
49
+ "resources.panel.download": "Ladda ner",
50
+ "@ossy/resources/schema/markdown.body.label": "Innehåll",
51
+ "@ossy/resources/schema/markdown.body.description": "Markdown-innehåll (rubriker, listor, länkar, kod)."
49
52
  }