@ossy/platform 1.25.1 → 1.26.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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ossy/platform",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.26.0",
|
|
4
4
|
"description": "Ossy application server runtime",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
"./proxy-internal": "./src/proxy-internal.js",
|
|
15
15
|
"./runtime": "./src/runtime.js",
|
|
16
16
|
"./site-loader": "./src/site-loader.js",
|
|
17
|
-
"./tasks": "./src/index.js"
|
|
17
|
+
"./tasks": "./src/index.js",
|
|
18
|
+
"./resources": "./src/resources/index.js"
|
|
18
19
|
},
|
|
19
20
|
"scripts": {
|
|
20
21
|
"start": "PORT=3003 node -e \"import('./src/server.js').then(m => m.startServer())\""
|
|
@@ -23,8 +24,8 @@
|
|
|
23
24
|
"author": "Ossy <yourfriends@ossy.se> (https://ossy.se)",
|
|
24
25
|
"license": "MIT",
|
|
25
26
|
"dependencies": {
|
|
26
|
-
"@ossy/router": "^1.
|
|
27
|
-
"@ossy/sdk": "^1.
|
|
27
|
+
"@ossy/router": "^1.27.0",
|
|
28
|
+
"@ossy/sdk": "^1.27.0",
|
|
28
29
|
"cookie-parser": "^1.4.7",
|
|
29
30
|
"dotenv": ">=16.0.0 <18.0.0",
|
|
30
31
|
"express": ">=5.0.0 <6.0.0",
|
|
@@ -35,5 +36,5 @@
|
|
|
35
36
|
"src",
|
|
36
37
|
"Dockerfile"
|
|
37
38
|
],
|
|
38
|
-
"gitHead": "
|
|
39
|
+
"gitHead": "ed72d972761405ecc4e12804aefa77f763aa4646"
|
|
39
40
|
}
|
package/src/index.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
1
|
export { TaskService } from './tasks/task-service.js'
|
|
2
2
|
export { loadAndRegisterTasks } from './tasks/task-registry.js'
|
|
3
3
|
export { ChangeStream } from './tasks/change-stream.js'
|
|
4
|
+
export { registerResourceTemplate, getSystemResourceTemplates } from './resources/resource-template.registry.js'
|
|
5
|
+
export { normalizeAndValidateDocumentContent, validateResourceTemplatesForImport, ALLOWED_FIELD_TYPES } from './resources/resource-template.validation.js'
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** @type {object[]} */
|
|
2
|
+
const systemTemplates = []
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Register a single resource template POJO (as inlined in `build/manifest.json`).
|
|
6
|
+
* Silently skips duplicates (same `id`) and invalid entries.
|
|
7
|
+
*
|
|
8
|
+
* @param {object} template
|
|
9
|
+
*/
|
|
10
|
+
export function registerResourceTemplate(template) {
|
|
11
|
+
if (!template || typeof template !== 'object' || Array.isArray(template)) return
|
|
12
|
+
if (typeof template.id !== 'string' || template.id.trim() === '') return
|
|
13
|
+
if (systemTemplates.find(t => t.id === template.id)) return
|
|
14
|
+
systemTemplates.push(template)
|
|
15
|
+
console.log(`[INFO][ResourceTemplateRegistry] Registered system template: ${template.id}`)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Returns all registered system resource templates.
|
|
20
|
+
*
|
|
21
|
+
* @returns {object[]}
|
|
22
|
+
*/
|
|
23
|
+
export function getSystemResourceTemplates() {
|
|
24
|
+
return systemTemplates
|
|
25
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/** Field input types accepted in resource template definitions (UI + API contract). */
|
|
2
|
+
export const ALLOWED_FIELD_TYPES = new Set([
|
|
3
|
+
'text',
|
|
4
|
+
'textarea',
|
|
5
|
+
'richtext',
|
|
6
|
+
'number',
|
|
7
|
+
'select',
|
|
8
|
+
'multiselect',
|
|
9
|
+
'image',
|
|
10
|
+
'boolean',
|
|
11
|
+
'date',
|
|
12
|
+
])
|
|
13
|
+
|
|
14
|
+
function isNonEmptyString(value) {
|
|
15
|
+
return typeof value === 'string' && value.trim().length > 0
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function isEmptyValue(value) {
|
|
19
|
+
if (value === undefined || value === null) return true
|
|
20
|
+
if (typeof value === 'string' && value.trim() === '') return true
|
|
21
|
+
return false
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {unknown} template
|
|
26
|
+
* @param {{ reservedIds: Set<string> }} ctx
|
|
27
|
+
* @returns {{ ok: true } | { ok: false, code: string, message: string }}
|
|
28
|
+
*/
|
|
29
|
+
function validateOneTemplateShape(template, ctx) {
|
|
30
|
+
if (template === null || typeof template !== 'object' || Array.isArray(template)) {
|
|
31
|
+
return { ok: false, code: 'INVALID_TEMPLATE', message: 'Each template must be a non-array object' }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (!isNonEmptyString(template.id)) {
|
|
35
|
+
return { ok: false, code: 'INVALID_TEMPLATE', message: 'Each template must have a non-empty string id' }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (!isNonEmptyString(template.name)) {
|
|
39
|
+
return { ok: false, code: 'INVALID_TEMPLATE', message: `Template "${template.id}" must have a non-empty string name` }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (ctx.reservedIds.has(template.id)) {
|
|
43
|
+
return {
|
|
44
|
+
ok: false,
|
|
45
|
+
code: 'RESERVED_TEMPLATE_ID',
|
|
46
|
+
message: `Template id "${template.id}" is reserved by a system template`,
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (!Array.isArray(template.fields)) {
|
|
51
|
+
return {
|
|
52
|
+
ok: false,
|
|
53
|
+
code: 'INVALID_TEMPLATE',
|
|
54
|
+
message: `Template "${template.id}" must have a fields array`,
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const fieldNames = new Set()
|
|
59
|
+
for (let i = 0; i < template.fields.length; i++) {
|
|
60
|
+
const field = template.fields[i]
|
|
61
|
+
if (field === null || typeof field !== 'object' || Array.isArray(field)) {
|
|
62
|
+
return {
|
|
63
|
+
ok: false,
|
|
64
|
+
code: 'INVALID_FIELD',
|
|
65
|
+
message: `Template "${template.id}" fields[${i}] must be an object`,
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (!isNonEmptyString(field.name)) {
|
|
69
|
+
return {
|
|
70
|
+
ok: false,
|
|
71
|
+
code: 'INVALID_FIELD',
|
|
72
|
+
message: `Template "${template.id}" fields[${i}] must have a non-empty string name`,
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (fieldNames.has(field.name)) {
|
|
76
|
+
return {
|
|
77
|
+
ok: false,
|
|
78
|
+
code: 'DUPLICATE_FIELD_NAME',
|
|
79
|
+
message: `Template "${template.id}" has duplicate field name "${field.name}"`,
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
fieldNames.add(field.name)
|
|
83
|
+
|
|
84
|
+
if (typeof field.type !== 'string' || !isNonEmptyString(field.type)) {
|
|
85
|
+
return {
|
|
86
|
+
ok: false,
|
|
87
|
+
code: 'INVALID_FIELD_TYPE',
|
|
88
|
+
message: `Template "${template.id}" field "${field.name}" must have a non-empty string type`,
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const fieldType = field.type.trim()
|
|
93
|
+
if (!ALLOWED_FIELD_TYPES.has(fieldType)) {
|
|
94
|
+
return {
|
|
95
|
+
ok: false,
|
|
96
|
+
code: 'INVALID_FIELD_TYPE',
|
|
97
|
+
message: `Template "${template.id}" field "${field.name}" has unsupported type "${fieldType}"`,
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (fieldType === 'select' || fieldType === 'multiselect') {
|
|
102
|
+
if (!Array.isArray(field.options) || field.options.length === 0) {
|
|
103
|
+
return {
|
|
104
|
+
ok: false,
|
|
105
|
+
code: 'INVALID_FIELD_OPTIONS',
|
|
106
|
+
message: `Template "${template.id}" field "${field.name}" of type ${fieldType} requires a non-empty options array`,
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const badOption = field.options.find(o => typeof o !== 'string' || o.trim() === '')
|
|
110
|
+
if (badOption !== undefined) {
|
|
111
|
+
return {
|
|
112
|
+
ok: false,
|
|
113
|
+
code: 'INVALID_FIELD_OPTIONS',
|
|
114
|
+
message: `Template "${template.id}" field "${field.name}" options must be non-empty strings`,
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return { ok: true }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Validates a batch import of workspace resource templates.
|
|
125
|
+
*
|
|
126
|
+
* @param {unknown} templates
|
|
127
|
+
* @param {Set<string>} reservedIds - System template ids that must not be redefined
|
|
128
|
+
* @returns {{ ok: true } | { ok: false, code: string, message: string }}
|
|
129
|
+
*/
|
|
130
|
+
export function validateResourceTemplatesForImport(templates, reservedIds) {
|
|
131
|
+
if (!Array.isArray(templates)) {
|
|
132
|
+
return { ok: false, code: 'INVALID_PAYLOAD', message: 'Body must be a JSON array of resource templates' }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const seenIds = new Set()
|
|
136
|
+
for (let i = 0; i < templates.length; i++) {
|
|
137
|
+
const template = templates[i]
|
|
138
|
+
const shape = validateOneTemplateShape(template, { reservedIds })
|
|
139
|
+
if (!shape.ok) return shape
|
|
140
|
+
|
|
141
|
+
if (seenIds.has(template.id)) {
|
|
142
|
+
return {
|
|
143
|
+
ok: false,
|
|
144
|
+
code: 'DUPLICATE_TEMPLATE_ID',
|
|
145
|
+
message: `Duplicate template id "${template.id}" in import payload`,
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
seenIds.add(template.id)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return { ok: true }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Strips content to template field names and validates required fields and basic value shapes.
|
|
156
|
+
*
|
|
157
|
+
* @param {unknown} rawContent
|
|
158
|
+
* @param {{ fields?: { name: string, type?: string, required?: boolean, options?: string[] }[] }} template
|
|
159
|
+
* @returns {{ ok: true, content: Record<string, unknown> } | { ok: false, code: string, message: string }}
|
|
160
|
+
*/
|
|
161
|
+
export function normalizeAndValidateDocumentContent(rawContent, template) {
|
|
162
|
+
const fields = template?.fields || []
|
|
163
|
+
|
|
164
|
+
let base
|
|
165
|
+
if (rawContent === undefined || rawContent === null) {
|
|
166
|
+
base = {}
|
|
167
|
+
} else if (typeof rawContent !== 'object' || Array.isArray(rawContent)) {
|
|
168
|
+
return { ok: false, code: 'INVALID_CONTENT', message: 'Content must be a plain object' }
|
|
169
|
+
} else {
|
|
170
|
+
base = rawContent
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const allowedNames = new Set(fields.map(f => f.name))
|
|
174
|
+
const content = {}
|
|
175
|
+
for (const name of allowedNames) {
|
|
176
|
+
if (Object.prototype.hasOwnProperty.call(base, name)) {
|
|
177
|
+
content[name] = base[name]
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
for (const field of fields) {
|
|
182
|
+
const v = content[field.name]
|
|
183
|
+
const t = typeof field.type === 'string' ? field.type.trim() : field.type
|
|
184
|
+
|
|
185
|
+
if (field.required && isEmptyValue(v)) {
|
|
186
|
+
return {
|
|
187
|
+
ok: false,
|
|
188
|
+
code: 'REQUIRED_FIELD_MISSING',
|
|
189
|
+
message: `Field "${field.name}" is required`,
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (v === undefined) continue
|
|
194
|
+
|
|
195
|
+
if (t === 'number' && v !== null && (typeof v !== 'number' || Number.isNaN(v))) {
|
|
196
|
+
return {
|
|
197
|
+
ok: false,
|
|
198
|
+
code: 'INVALID_FIELD_VALUE',
|
|
199
|
+
message: `Field "${field.name}" must be a number`,
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (t === 'select' && v !== null && v !== undefined && !field.options?.includes(v)) {
|
|
204
|
+
return {
|
|
205
|
+
ok: false,
|
|
206
|
+
code: 'INVALID_FIELD_VALUE',
|
|
207
|
+
message: `Field "${field.name}" must be one of the defined options`,
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (t === 'multiselect' && v !== null && v !== undefined) {
|
|
212
|
+
if (!Array.isArray(v)) {
|
|
213
|
+
return {
|
|
214
|
+
ok: false,
|
|
215
|
+
code: 'INVALID_FIELD_VALUE',
|
|
216
|
+
message: `Field "${field.name}" must be an array of option values`,
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
const opts = field.options || []
|
|
220
|
+
const invalid = v.some(item => typeof item !== 'string' || !opts.includes(item))
|
|
221
|
+
if (invalid) {
|
|
222
|
+
return {
|
|
223
|
+
ok: false,
|
|
224
|
+
code: 'INVALID_FIELD_VALUE',
|
|
225
|
+
message: `Field "${field.name}" must only contain values from options`,
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return { ok: true, content }
|
|
232
|
+
}
|