@ossy/platform 1.25.1 → 1.26.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ossy/platform",
3
- "version": "1.25.1",
3
+ "version": "1.26.1",
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.26.1",
27
- "@ossy/sdk": "^1.26.1",
27
+ "@ossy/router": "^1.27.1",
28
+ "@ossy/sdk": "^1.27.1",
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": "efb5f69afd7a5bbef66887334a2118449ae2c6b4"
39
+ "gitHead": "8769681a3a9ad9e930bd88b29b34a6ec7aaeac7c"
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,2 @@
1
+ export { registerResourceTemplate, getSystemResourceTemplates } from './resource-template.registry.js'
2
+ export { normalizeAndValidateDocumentContent, validateResourceTemplatesForImport, ALLOWED_FIELD_TYPES } from './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
+ }
package/src/server.js CHANGED
@@ -9,6 +9,7 @@ import { ProxyInternal } from './proxy-internal.js'
9
9
  import { SDK } from '@ossy/sdk'
10
10
  import { TaskService } from './tasks/task-service.js'
11
11
  import { ChangeStream } from './tasks/change-stream.js'
12
+ import { registerResourceTemplate } from './resources/resource-template.registry.js'
12
13
 
13
14
  const DEFAULT_PORT = 3000
14
15
  const MANIFEST_FILE = 'manifest.json'
@@ -101,6 +102,10 @@ export async function startServer (options = {}) {
101
102
  }
102
103
  }
103
104
 
105
+ for (const template of manifest.resourceTemplates ?? []) {
106
+ registerResourceTemplate(template)
107
+ }
108
+
104
109
  // Register the SDK so all tasks receive it as `sdk`.
105
110
  // Priority: explicit options.sdk → SDK.of() from env vars → null (direct-DB fallback in tasks).
106
111
  const botSdk = (process.env.API_URL && process.env.OSSY_API_KEY)