@ossy/platform 1.39.2 → 3.0.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 (66) hide show
  1. package/README.md +21 -17
  2. package/package.json +20 -11
  3. package/src/Definition.js +2 -1
  4. package/src/PlatformShell.jsx +10 -10
  5. package/src/actions/action.service.js +85 -7
  6. package/src/audit/action-invocation-service.js +143 -0
  7. package/src/audit/action-invocation.aggregate.js +57 -0
  8. package/src/audit/audit-helpers.js +61 -0
  9. package/src/audit/detect-channel.js +19 -0
  10. package/src/audit/list-task-runs.action.js +5 -0
  11. package/src/audit/list-task-runs.task.js +17 -0
  12. package/src/audit/task-run-list.aggregate.js +76 -0
  13. package/src/audit/task-run-service.js +208 -0
  14. package/src/audit/task-run.aggregate.js +58 -0
  15. package/src/auth/action-scopes.js +3 -0
  16. package/src/capability-schemas/action-capability.schema.js +1 -0
  17. package/src/capability-schemas/action-meta.schema.js +1 -0
  18. package/src/capability-schemas/component-capability.schema.js +1 -0
  19. package/src/capability-schemas/page-capability.schema.js +1 -0
  20. package/src/capability-schemas/page-meta.schema.js +1 -0
  21. package/src/capability-schemas/task-capability.schema.js +1 -0
  22. package/src/capability-schemas/task-meta.schema.js +1 -0
  23. package/src/capability-schemas/task-output.schema.js +1 -0
  24. package/src/capability-schemas/task-trigger-authoring.schema.js +1 -0
  25. package/src/capability-schemas/task-trigger.schema.js +1 -0
  26. package/src/directory.schema.js +7 -0
  27. package/src/entitlements/action-entitlement.js +69 -0
  28. package/src/file.schema.js +11 -0
  29. package/src/index.js +12 -3
  30. package/src/mcp/create-ossy-mcp-server.js +97 -0
  31. package/src/mcp/json-schema-to-zod.js +64 -0
  32. package/src/mcp/mount-ossy-mcp.js +72 -0
  33. package/src/mcp/mount-platform-mcp.js +101 -0
  34. package/src/mcp/upload-file-tool.js +62 -0
  35. package/src/metering/metering-service.js +91 -0
  36. package/src/{platform-config.resource.js → platform-config.schema.js} +1 -1
  37. package/src/proxy-internal.js +13 -16
  38. package/src/push/mount-push-sse.js +91 -0
  39. package/src/request-diagnostics.js +144 -0
  40. package/src/request-diagnostics.spec.js +40 -0
  41. package/src/resources/index.js +3 -2
  42. package/src/resources/schema.registry.js +26 -0
  43. package/src/resources/schema.service.js +54 -0
  44. package/src/resources/schema.validation.js +90 -0
  45. package/src/runtime.js +20 -6
  46. package/src/server.js +281 -62
  47. package/src/storage/filesystem-storage.client.js +109 -0
  48. package/src/storage/local-storage.client.js +2 -65
  49. package/src/storage/resource-read-url.js +36 -0
  50. package/src/storage/resource-read-url.spec.js +22 -0
  51. package/src/storage/s3-storage.client.js +102 -0
  52. package/src/storage/s3.client.js +27 -23
  53. package/src/storage/storage-keys.js +37 -0
  54. package/src/storage/storage-keys.spec.js +16 -0
  55. package/src/storage/storage.client.js +52 -8
  56. package/src/storage/storage.integration.js +40 -0
  57. package/src/tasks/change-stream.js +78 -11
  58. package/src/tasks/task-service.js +211 -34
  59. package/src/tasks/task-service.spec.js +187 -0
  60. package/src/test/e2e.util.js +18 -39
  61. package/src/test/flow-runner.js +476 -0
  62. package/src/test/test.util.js +30 -29
  63. package/src/user-app-settings.js +44 -0
  64. package/src/users.middleware.js +61 -17
  65. package/src/resources/resource-template.registry.js +0 -29
  66. package/src/resources/resource-template.validation.js +0 -232
package/src/server.js CHANGED
@@ -7,12 +7,12 @@ import { Router as OssyRouter } from '@ossy/router'
7
7
  import cookieParser from 'cookie-parser'
8
8
  import { ProxyInternal } from './proxy-internal.js'
9
9
  import { SDK, resolveActionId } from '@ossy/sdk'
10
- import { AggregateRebuild, resolveMongoUrl } from '@ossy/event-store'
10
+ import { AggregateRebuild, ensureEventStoreIndexes, Mongo, resolveMongoUrl } from '@ossy/event-store'
11
11
  import { TaskService } from './tasks/task-service.js'
12
12
  import { ChangeStream } from './tasks/change-stream.js'
13
- import { CONTENT_SLOT_NAME } from '@ossy/app/runtime/resolve-shell-slots'
14
13
  import { buildManifestSummary } from '@ossy/app/manifest/build-manifest-summary'
15
- import { registerResourceTemplate } from './resources/resource-template.registry.js'
14
+ import { registerSchema } from './resources/schema.registry.js'
15
+ import { initPlatformSchema } from './resources/schema.service.js'
16
16
  import { IntegrationService } from './integration.service.js'
17
17
  import { ActionService } from './actions/action.service.js'
18
18
  import { createLogger } from '@ossy/observability'
@@ -20,7 +20,29 @@ import { ConfigService } from './config.service.js'
20
20
  import { UsersMiddleware } from './users.middleware.js'
21
21
  import { WorkspacesMiddleware } from './workspaces.middleware.js'
22
22
  import { resolveRequestLocale } from './locale.js'
23
+ import { mountPlatformMcp } from './mcp/mount-platform-mcp.js'
24
+ import {
25
+ assertActionEntitled,
26
+ buildActionEntitlementIndex,
27
+ createWorkspaceLoader,
28
+ } from './entitlements/action-entitlement.js'
29
+ import { closePushSseConnections, mountPushSse } from './push/mount-push-sse.js'
30
+ import {
31
+ createSlowRequestLogger,
32
+ createTimedOperation,
33
+ OperationTimeoutError,
34
+ resolveTimeoutMs,
35
+ withTimeout,
36
+ } from './request-diagnostics.js'
37
+ import { mergeWorkspaceSchemas } from '@ossy/workspaces/merge-workspace-schemas'
38
+ import {
39
+ buildEnableablePackageSet,
40
+ setEnableablePackages,
41
+ } from '@ossy/workspaces/entitlements'
23
42
  const log = createLogger('@ossy/platform')
43
+ const MONGO_TIMEOUT_MS = resolveTimeoutMs('OSSY_MONGO_TIMEOUT_MS', 10_000)
44
+ const SSR_RENDER_TIMEOUT_MS = resolveTimeoutMs('OSSY_SSR_RENDER_TIMEOUT_MS', 30_000)
45
+ const ACTION_TIMEOUT_MS = resolveTimeoutMs('OSSY_ACTION_TIMEOUT_MS', 60_000)
24
46
 
25
47
  const DEFAULT_PORT = 3000
26
48
  const MANIFEST_FILE = 'manifest.json'
@@ -58,14 +80,15 @@ export function loadManifest (buildDir) {
58
80
  const apis = entries.filter((e) => e.type === 'api' && validRoutable(e))
59
81
  const tasks = entries.filter((e) => e.type === 'task')
60
82
  const components = Array.isArray(manifest.components) ? manifest.components : []
61
- const resourceTemplates = Array.isArray(manifest.resourceTemplates) ? manifest.resourceTemplates : []
83
+ const schemas = Array.isArray(manifest.schemas) ? manifest.schemas : []
62
84
  const aggregates = Array.isArray(manifest.aggregates) ? manifest.aggregates : []
63
85
  const integrations = Array.isArray(manifest.integrations) ? manifest.integrations : []
64
86
  const startups = Array.isArray(manifest.startups) ? manifest.startups : []
65
87
  const actions = Array.isArray(manifest.actions) ? manifest.actions : []
66
88
  const emails = Array.isArray(manifest.emails) ? manifest.emails : []
67
89
  const layouts = Array.isArray(manifest.layouts) ? manifest.layouts : []
68
- for (const e of entries) { if ((e.type === 'page' || e.type === 'api') && !validRoutable(e)) {
90
+ for (const e of entries) {
91
+ if ((e.type === 'page' || e.type === 'api') && !validRoutable(e)) {
69
92
  log.warn(`Skipping ${e.type} "${e.id}" — manifest entry has no path.`)
70
93
  }
71
94
  }
@@ -75,7 +98,7 @@ export function loadManifest (buildDir) {
75
98
  apis,
76
99
  tasks,
77
100
  components,
78
- resourceTemplates,
101
+ schemas,
79
102
  aggregates,
80
103
  integrations,
81
104
  startups,
@@ -85,6 +108,9 @@ export function loadManifest (buildDir) {
85
108
  config: manifest.config || {},
86
109
  definitions: manifest.definitions || {},
87
110
  translations: manifest.translations || {},
111
+ actionRoutes: manifest.actionRoutes || {},
112
+ taskCatalog: Array.isArray(manifest.taskCatalog) ? manifest.taskCatalog : [],
113
+ taskGraphEdges: Array.isArray(manifest.taskGraphEdges) ? manifest.taskGraphEdges : [],
88
114
  }
89
115
  }
90
116
 
@@ -102,6 +128,49 @@ function cloneSerializable (value) {
102
128
  return value == null ? value : JSON.parse(JSON.stringify(value))
103
129
  }
104
130
 
131
+ /**
132
+ * Load all registered layout bundles keyed by canonical layout id.
133
+ *
134
+ * @param {Array<{ id: string, entry: string }>} layoutManifest
135
+ * @param {string} buildDir
136
+ * @param {import('@ossy/observability').Logger} log
137
+ */
138
+ async function loadLayoutsById (layoutManifest, buildDir, log) {
139
+ /** @type {Map<string, { component: import('react').ComponentType, entry: string }>} */
140
+ const layoutsById = new Map()
141
+ for (const layoutEntry of layoutManifest ?? []) {
142
+ if (!layoutEntry?.id || !layoutEntry?.entry) continue
143
+ try {
144
+ const mod = await import(/* @vite-ignore */ resolveEntryUrl(layoutEntry.entry, buildDir))
145
+ if (typeof mod.default === 'function') {
146
+ layoutsById.set(layoutEntry.id, { component: mod.default, entry: layoutEntry.entry })
147
+ }
148
+ } catch (err) {
149
+ log.warn(`Failed to load layout "${layoutEntry.id}"`, undefined, err)
150
+ }
151
+ }
152
+ return layoutsById
153
+ }
154
+
155
+ /**
156
+ * @param {Map<string, { component: import('react').ComponentType, entry: string }>} layoutsById
157
+ * @param {{ layout?: string, slots?: Record<string, string | null> }} pageEntry
158
+ */
159
+ function resolvePageLayoutRender (layoutsById, pageEntry) {
160
+ const layoutId = pageEntry?.layout ?? '@ossy/app/layout/default'
161
+ const layoutRecord = layoutsById.get(layoutId) ?? layoutsById.get('@ossy/app/layout/default') ?? null
162
+ const layoutSlots =
163
+ pageEntry?.slots && typeof pageEntry.slots === 'object' && !Array.isArray(pageEntry.slots)
164
+ ? pageEntry.slots
165
+ : {}
166
+ return {
167
+ Layout: layoutRecord?.component ?? null,
168
+ layoutEntry: layoutRecord?.entry ?? null,
169
+ layoutSlots,
170
+ layoutId,
171
+ }
172
+ }
173
+
105
174
  export async function startServer (options = {}) {
106
175
  const cwd = options.cwd ? path.resolve(options.cwd) : process.cwd()
107
176
  const buildDir = path.resolve(cwd, options.buildDir || 'build')
@@ -114,12 +183,18 @@ export async function startServer (options = {}) {
114
183
 
115
184
  const manifest = loadManifest(buildDir)
116
185
  const manifestSummary = buildManifestSummary(manifest)
186
+ setEnableablePackages(buildEnableablePackageSet(manifest))
187
+ initPlatformSchema(manifest)
188
+ const capabilitiesPath = path.join(buildDir, 'capabilities.json')
189
+ const capabilities = fs.existsSync(capabilitiesPath)
190
+ ? JSON.parse(fs.readFileSync(capabilitiesPath, 'utf8'))
191
+ : null
117
192
  const config = manifest.config
118
193
  const supportedLanguages = Array.isArray(config.supportedLanguages) ? config.supportedLanguages : []
119
194
  const defaultLanguage = config.defaultLanguage
120
195
  for (const task of manifest.tasks ?? []) {
121
196
  try {
122
- const mod = await import(resolveEntryUrl(task.entry, buildDir))
197
+ const mod = await import(/* @vite-ignore */ resolveEntryUrl(task.entry, buildDir))
123
198
  if (mod.metadata) {
124
199
  TaskService.registerTask(mod)
125
200
  }
@@ -128,8 +203,8 @@ export async function startServer (options = {}) {
128
203
  }
129
204
  }
130
205
 
131
- for (const template of manifest.resourceTemplates ?? []) {
132
- registerResourceTemplate(template)
206
+ for (const template of manifest.schemas ?? []) {
207
+ registerSchema(template)
133
208
  }
134
209
 
135
210
  // Integration loading — import each bundled integration module, check its
@@ -139,33 +214,39 @@ export async function startServer (options = {}) {
139
214
  const integrationModules = []
140
215
  for (const intEntry of manifest.integrations ?? []) {
141
216
  try {
142
- integrationModules.push(await import(resolveEntryUrl(intEntry.entry, buildDir)))
217
+ integrationModules.push(await import(/* @vite-ignore */ resolveEntryUrl(intEntry.entry, buildDir)))
143
218
  } catch (err) {
144
219
  log.warn(`Failed to import integration "${intEntry.id}"`, undefined, err)
145
220
  }
146
221
  }
147
222
  await IntegrationService.load(integrationModules, process.env)
148
223
 
149
- // Aggregate registration — mirrors task and resource-template registration above.
224
+ // Aggregate registration — mirrors task and schema registration above.
150
225
  // AggregateRebuild is provided by @ossy/event-store.
226
+ /** @type {(() => void) | null} */
227
+ let runStartupRebuild = null
151
228
  if (AggregateRebuild) {
152
229
  for (const agg of manifest.aggregates ?? []) {
153
230
  try {
154
- const mod = await import(resolveEntryUrl(agg.entry, buildDir))
231
+ const mod = await import(/* @vite-ignore */ resolveEntryUrl(agg.entry, buildDir))
155
232
  AggregateRebuild.registerAggregate(mod)
156
233
  } catch (err) {
157
234
  log.error(`Failed to load aggregate "${agg.id}"`, undefined, err)
158
235
  }
159
236
  }
160
237
 
161
- AggregateRebuild.BuildAndSaveAll().catch((error) => {
162
- log.error('BuildAndSaveAll failed is MongoDB reachable?', undefined, error)
163
- })
238
+ // Defer full startup rebuild until the server is listening so request handling
239
+ // is not starved while snapshots catch up (332k+ streams on large local DBs).
240
+ runStartupRebuild = () => {
241
+ AggregateRebuild.BuildAndSaveAll().catch((error) => {
242
+ log.error('BuildAndSaveAll failed — is MongoDB reachable?', undefined, error)
243
+ })
244
+ }
164
245
  }
165
246
 
166
247
  for (const startup of manifest.startups ?? []) {
167
248
  try {
168
- const mod = await import(resolveEntryUrl(startup.entry, buildDir))
249
+ const mod = await import(/* @vite-ignore */ resolveEntryUrl(startup.entry, buildDir))
169
250
  if (typeof mod.run === 'function') {
170
251
  await mod.run({ env: process.env })
171
252
  }
@@ -178,41 +259,16 @@ export async function startServer (options = {}) {
178
259
  // with ActionService so it can be looked up and invoked at runtime.
179
260
  for (const actionEntry of manifest.actions ?? []) {
180
261
  try {
181
- const mod = await import(resolveEntryUrl(actionEntry.entry, buildDir))
262
+ const mod = await import(/* @vite-ignore */ resolveEntryUrl(actionEntry.entry, buildDir))
182
263
  ActionService.register(mod)
183
264
  } catch (err) {
184
265
  log.error(`Failed to load action "${actionEntry.id}"`, undefined, err)
185
266
  }
186
267
  }
187
268
 
188
- // Layout loading — one `*.layout.jsx` per app wraps every page render. The
189
- // layout component decides route-specific chrome (auth, public flows, etc.).
190
- /** @type {{ component: import('react').ComponentType, entry: string } | null} */
191
- let appLayout = null
269
+ // Layout loading — registered layouts keyed by id; each page picks layout + merged slots at build time.
192
270
  const layoutManifest = manifest.layouts ?? []
193
- if (layoutManifest.length > 1) {
194
- log.warn(
195
- `Multiple layouts in manifest (${layoutManifest.length}); only the first is used. Use one *.layout.jsx per app.`,
196
- )
197
- }
198
- const layoutEntry = layoutManifest[0]
199
- if (layoutEntry) {
200
- try {
201
- const mod = await import(resolveEntryUrl(layoutEntry.entry, buildDir))
202
- if (typeof mod.default === 'function') {
203
- appLayout = { component: mod.default, entry: layoutEntry.entry }
204
- }
205
- } catch (err) {
206
- log.warn(`Failed to load layout "${layoutEntry.id}"`, undefined, err)
207
- }
208
- }
209
-
210
- // App-controlled shell slots: `export const slots` in `*.layout.jsx` is recorded
211
- // on `manifest.layouts[0].slots` at build time and resolved at render in page-runtime.
212
- const layoutSlots =
213
- layoutEntry && typeof layoutEntry.slots === 'object' && !Array.isArray(layoutEntry.slots)
214
- ? layoutEntry.slots
215
- : {}
271
+ const layoutsById = await loadLayoutsById(layoutManifest, buildDir, log)
216
272
 
217
273
  // Register the SDK so all tasks receive it as `sdk`.
218
274
  // Priority: explicit options.sdk → SDK.of() from env vars → null (direct-DB fallback in tasks).
@@ -224,6 +280,9 @@ export async function startServer (options = {}) {
224
280
  TaskService.startScheduler()
225
281
 
226
282
  if (process.env.DB_URL) {
283
+ await ensureEventStoreIndexes().catch((err) => {
284
+ log.error('Failed to ensure MongoDB indexes — aggregate reads may be very slow', undefined, err)
285
+ })
227
286
  ChangeStream.start(resolveMongoUrl(process.env.DB_URL))
228
287
  }
229
288
 
@@ -237,11 +296,25 @@ export async function startServer (options = {}) {
237
296
  const moduleCache = new Map()
238
297
  async function loadEntry (entryUrl) {
239
298
  if (moduleCache.has(entryUrl)) return moduleCache.get(entryUrl)
240
- const promise = import(resolveEntryUrl(entryUrl, buildDir))
299
+ const promise = import(/* @vite-ignore */ resolveEntryUrl(entryUrl, buildDir))
241
300
  moduleCache.set(entryUrl, promise)
242
301
  return promise
243
302
  }
244
303
 
304
+ const actionEntitlementIndex = buildActionEntitlementIndex(manifest)
305
+ let loadWorkspaceForEntitlements = null
306
+ try {
307
+ const { Workspace } = await import('@ossy/workspaces/server')
308
+ const loadWorkspace = createWorkspaceLoader(Workspace)
309
+ loadWorkspaceForEntitlements = (workspaceId) => withTimeout(
310
+ loadWorkspace(workspaceId),
311
+ MONGO_TIMEOUT_MS,
312
+ `loadWorkspace(${workspaceId})`,
313
+ )
314
+ } catch (err) {
315
+ log.warn('[@ossy/platform] Workspace aggregate unavailable — action entitlements disabled', err)
316
+ }
317
+
245
318
  const app = express()
246
319
  app.use(morgan('tiny'))
247
320
  app.use(express.json({ strict: false }))
@@ -258,14 +331,19 @@ export async function startServer (options = {}) {
258
331
  })
259
332
  app.use(UsersMiddleware.AuthenticateUser)
260
333
  app.use(WorkspacesMiddleware.ExtractWorkspaceId())
334
+ app.use(createSlowRequestLogger(log))
261
335
  if (fs.existsSync(publicDir)) app.use(express.static(publicDir))
262
336
  app.use(ProxyInternal())
263
337
 
338
+ mountPushSse(app)
339
+
264
340
  // Actions endpoint — POST /actions with body { action, payload }.
265
341
  app.post('/actions', async (req, res) => {
266
342
  const actionId = resolveActionId(req.body?.action)
267
343
  if (!actionId) return res.status(400).json({ error: 'Missing action' })
268
344
 
345
+ const payload = req.body?.payload ?? {}
346
+
269
347
  const action = ActionService.get(actionId)
270
348
  if (!action) return res.status(404).json({ error: 'Action not found' })
271
349
 
@@ -276,26 +354,82 @@ export async function startServer (options = {}) {
276
354
  return res.status(403).json({ error: 'Forbidden' })
277
355
  }
278
356
 
279
- const payload = req.body?.payload ?? {}
357
+ if (!ActionService.hasHandler(actionId)) {
358
+ return res.status(405).json({ error: 'Action has no server handler' })
359
+ }
360
+
361
+ if (loadWorkspaceForEntitlements) {
362
+ try {
363
+ await assertActionEntitled({
364
+ actionId,
365
+ access: action.access,
366
+ workspaceId: req.workspaceId,
367
+ entitlementIndex: actionEntitlementIndex,
368
+ loadWorkspace: loadWorkspaceForEntitlements,
369
+ })
370
+ } catch (err) {
371
+ if (err?.code === 'SERVICE_NOT_ENTITLED') {
372
+ return res.status(403).json({
373
+ error: err.message,
374
+ code: err.code,
375
+ package: err.package,
376
+ })
377
+ }
378
+ if (err?.code === 'ACTION_NOT_PERMITTED') {
379
+ return res.status(403).json({
380
+ error: err.message,
381
+ code: err.code,
382
+ })
383
+ }
384
+ const status = err?.status ?? 500
385
+ return res.status(status).json({ error: err.message ?? 'Forbidden' })
386
+ }
387
+ }
388
+
280
389
  const actionLog = createLogger(actionId)
390
+ const actionTimer = createTimedOperation(`POST /actions ${actionId}`, { log: actionLog })
391
+ const actionAbort = new AbortController()
281
392
  try {
282
- const result = await ActionService.invoke(actionId, {
283
- payload,
284
- sdk: req.sdk ?? null,
285
- log: actionLog,
286
- integrations: IntegrationService,
287
- req,
288
- })
393
+ const result = await withTimeout(
394
+ ActionService.invoke(actionId, {
395
+ payload,
396
+ sdk: req.sdk ?? null,
397
+ log: actionLog,
398
+ integrations: IntegrationService,
399
+ req,
400
+ signal: actionAbort.signal,
401
+ }),
402
+ ACTION_TIMEOUT_MS,
403
+ `POST /actions ${actionId}`,
404
+ { abortController: actionAbort },
405
+ )
406
+ actionTimer.finish({ statusCode: 200 })
289
407
  res.json(result ?? { ok: true })
290
408
  } catch (err) {
409
+ actionTimer.finish({
410
+ statusCode: err instanceof OperationTimeoutError ? 504 : (err?.status ?? 500),
411
+ timedOut: err instanceof OperationTimeoutError,
412
+ })
413
+ if (err instanceof OperationTimeoutError) {
414
+ actionLog.error('Action timed out', { id: actionId, timeoutMs: err.timeoutMs })
415
+ return res.status(504).json({ error: 'Action timed out', code: 'ACTION_TIMEOUT' })
416
+ }
291
417
  actionLog.error('Action failed', { id: actionId }, err)
292
418
  const status = err?.status ?? 500
293
419
  res.status(status).json({ error: err?.message ?? 'Internal error' })
294
420
  }
295
421
  })
296
422
 
423
+ const resolvedCapabilities = mountPlatformMcp(app, {
424
+ manifest,
425
+ capabilities,
426
+ actionEntitlementIndex,
427
+ loadWorkspaceForEntitlements,
428
+ })
429
+
297
430
  app.all('*all', async (req, res) => {
298
431
  const requestUrl = req.originalUrl || '/'
432
+ const pageTimer = createTimedOperation(`page ${req.method} ${requestUrl}`, { log })
299
433
  try {
300
434
  const apiRoute = apiRouter.getPageByUrl(requestUrl)
301
435
  if (apiRoute) {
@@ -321,22 +455,60 @@ export async function startServer (options = {}) {
321
455
  res.status(404).send('Not found')
322
456
  return
323
457
  }
458
+ const loadEntryTimer = createTimedOperation(`SSR loadEntry ${pageRoute.id}`, { log })
324
459
  const mod = await loadEntry(pageEntry.entry)
460
+ loadEntryTimer.finish()
325
461
  if (typeof mod.render !== 'function') {
326
462
  res.status(503).type('text').send('SSR runtime unavailable')
327
463
  return
328
464
  }
329
- const Layout = appLayout?.component ?? null
330
- const layoutEntry = appLayout?.entry ?? null
465
+ const {
466
+ Layout,
467
+ layoutEntry,
468
+ layoutSlots,
469
+ } = resolvePageLayoutRender(layoutsById, pageEntry)
331
470
  const { language, messages, fallbackMessages } = resolveRequestLocale(pageRouter, requestUrl, buildDir, config)
332
- // Page component → `shell:content` is resolved in page-runtime (SSR + hydrate).
471
+ let schemasForPage = cloneSerializable(manifest.schemas || [])
472
+ if (req.workspaceId && loadWorkspaceForEntitlements) {
473
+ const workspaceTimer = createTimedOperation(`SSR loadWorkspace ${req.workspaceId}`, { log })
474
+ try {
475
+ const workspace = await loadWorkspaceForEntitlements(req.workspaceId)
476
+ workspaceTimer.finish({ pageId: pageRoute.id })
477
+ if (workspace?.schemas?.length) {
478
+ schemasForPage = mergeWorkspaceSchemas(
479
+ schemasForPage,
480
+ cloneSerializable(workspace.schemas),
481
+ )
482
+ }
483
+ } catch (err) {
484
+ workspaceTimer.finish({
485
+ pageId: pageRoute.id,
486
+ error: err?.message ?? String(err),
487
+ timedOut: err instanceof OperationTimeoutError,
488
+ })
489
+ if (err instanceof OperationTimeoutError) {
490
+ log.warn('[@ossy/platform] SSR workspace schema merge timed out — using manifest schemas only', {
491
+ workspaceId: req.workspaceId,
492
+ pageId: pageRoute.id,
493
+ timeoutMs: err.timeoutMs,
494
+ })
495
+ } else {
496
+ log.warn('[@ossy/platform] Failed to merge workspace schemas for SSR', err)
497
+ }
498
+ }
499
+ }
500
+ // Page component → `app:content` is resolved in page-runtime (SSR + hydrate).
333
501
  const props = {
334
502
  ...config,
335
503
  ...(req.userAppSettings || {}),
336
504
  theme: req.userAppSettings?.theme || cloneSerializable(config?.theme),
337
505
  themes: cloneSerializable(config?.themes),
338
- resourceTemplates: cloneSerializable(manifest.resourceTemplates),
506
+ schemas: schemasForPage,
339
507
  manifestSummary: cloneSerializable(manifestSummary),
508
+ capabilities: cloneSerializable(resolvedCapabilities),
509
+ actionRoutes: cloneSerializable(manifest.actionRoutes || {}),
510
+ taskCatalog: cloneSerializable(manifest.taskCatalog || []),
511
+ taskGraphEdges: cloneSerializable(manifest.taskGraphEdges || []),
340
512
  url: requestUrl,
341
513
  isAuthenticated: !!req.isAuthenticated,
342
514
  language,
@@ -349,18 +521,44 @@ export async function startServer (options = {}) {
349
521
  })),
350
522
  pageId: pageRoute.id,
351
523
  componentEntries: manifest.components,
524
+ resolveComponentEntry: (entry) => resolveEntryUrl(entry, buildDir),
352
525
  layoutSlots,
353
- contentSlotName: CONTENT_SLOT_NAME,
354
526
  Layout,
355
527
  layoutEntry,
356
528
  }
357
- const html = await mod.render(props)
529
+ const renderTimer = createTimedOperation(`SSR render ${pageRoute.id}`, { log })
530
+ let html
531
+ try {
532
+ html = await withTimeout(
533
+ mod.render(props),
534
+ SSR_RENDER_TIMEOUT_MS,
535
+ `SSR render(${pageRoute.id})`,
536
+ )
537
+ } catch (err) {
538
+ renderTimer.finish({ timedOut: err instanceof OperationTimeoutError })
539
+ if (err instanceof OperationTimeoutError) {
540
+ log.error('[@ossy/platform] SSR render timed out', {
541
+ pageId: pageRoute.id,
542
+ url: requestUrl,
543
+ timeoutMs: err.timeoutMs,
544
+ })
545
+ if (!res.headersSent) {
546
+ res.status(504).type('text').send('Page render timed out')
547
+ }
548
+ return
549
+ }
550
+ throw err
551
+ }
552
+ renderTimer.finish()
553
+ pageTimer.finish({ pageId: pageRoute.id, statusCode: 200 })
358
554
  res.status(200).type('html').send(html)
359
555
  return
360
556
  }
361
557
 
558
+ pageTimer.finish({ statusCode: 404 })
362
559
  res.status(404).send('Not found')
363
560
  } catch (err) {
561
+ pageTimer.finish({ error: err?.message ?? String(err) })
364
562
  log.error('Request handling failed', undefined, err)
365
563
  if (!res.headersSent) {
366
564
  res.status(500).type('text').send('Internal Server Error')
@@ -377,7 +575,12 @@ export async function startServer (options = {}) {
377
575
  log.info(`Running on http://localhost:${port}`)
378
576
  log.info('Press Ctrl+C to stop.')
379
577
 
380
- const closeServer = () => new Promise((resolve) => server.close(() => resolve()))
578
+ runStartupRebuild?.()
579
+
580
+ const closeServer = () => new Promise((resolve) => {
581
+ server.closeAllConnections?.()
582
+ server.close(() => resolve())
583
+ })
381
584
 
382
585
  let shuttingDown = false
383
586
  const handleShutdown = async (signal) => {
@@ -385,6 +588,10 @@ export async function startServer (options = {}) {
385
588
  shuttingDown = true
386
589
  log.info(`Received ${signal}, shutting down...`)
387
590
  try {
591
+ TaskService.stop()
592
+ closePushSseConnections()
593
+ await ChangeStream.stop()
594
+ await Mongo.closeConnection()
388
595
  await closeServer()
389
596
  } finally {
390
597
  process.exit(0)
@@ -399,11 +606,23 @@ export async function startServer (options = {}) {
399
606
  }
400
607
 
401
608
  export default startServer
609
+ export { loadLayoutsById, resolvePageLayoutRender }
402
610
  export { ConfigService } from './config.service.js'
403
611
  export { ActionService } from './actions/action.service.js'
404
612
  export { IntegrationService } from './integration.service.js'
405
613
  export { StorageClient } from './storage/storage.client.js'
614
+ export { originalObjectKey, derivativeObjectKey, assertStorageKey } from './storage/storage-keys.js'
406
615
  export { S3Client } from './storage/s3.client.js'
407
616
  export { LocalStorageClient } from './storage/local-storage.client.js'
408
- export { getSystemResourceTemplates } from './resources/resource-template.registry.js'
409
- export { normalizeAndValidateDocumentContent, validateResourceTemplatesForImport } from './resources/resource-template.validation.js'
617
+ export { getSystemSchemas } from './resources/schema.registry.js'
618
+ export { getPlatformSchema, initPlatformSchema, createSchemaEngine, schemaForWorkspace } from './resources/schema.service.js'
619
+ export { validateSchemasForImport } from './resources/schema.validation.js'
620
+ export {
621
+ USER_SETTINGS_COOKIE,
622
+ AUTH_COOKIE,
623
+ readUserAppSettings,
624
+ mergeUserAppSettingsCookie,
625
+ clearWorkspaceFromUserAppSettings,
626
+ setAuthCookie,
627
+ clearAuthCookie,
628
+ } from './user-app-settings.js'
@@ -0,0 +1,109 @@
1
+ import path from 'path'
2
+ import os from 'os'
3
+ import { mkdir, writeFile, readFile, stat } from 'fs/promises'
4
+ import { existsSync } from 'fs'
5
+ import { assertStorageKey } from './storage-keys.js'
6
+ import { createResourceReadUrl } from './resource-read-url.js'
7
+
8
+ const DEV_FALLBACK_DIR = path.join(os.tmpdir(), 'ossy-local-media')
9
+
10
+ export function resolveFilesystemStorageRoot (env = process.env) {
11
+ const dataDir = env.OSSY_DATA_DIR
12
+ if (dataDir) {
13
+ return path.join(path.resolve(dataDir), 'blobs')
14
+ }
15
+ return DEV_FALLBACK_DIR
16
+ }
17
+
18
+ function normalizeKey (keyOrShape) {
19
+ const Key = typeof keyOrShape === 'string' ? keyOrShape : keyOrShape?.Key
20
+ assertStorageKey(Key)
21
+ return Key
22
+ }
23
+
24
+ function keyToLocalPath (root, key) {
25
+ const filename = encodeURIComponent(key)
26
+ const resolved = path.resolve(root, filename)
27
+ const rootResolved = path.resolve(root)
28
+ if (!resolved.startsWith(`${rootResolved}${path.sep}`)) {
29
+ throw new Error('[FilesystemStorage] invalid key: path traversal detected')
30
+ }
31
+ return resolved
32
+ }
33
+
34
+ function buildUrl (key, env = process.env) {
35
+ return createResourceReadUrl(key, env)
36
+ }
37
+
38
+ export function createFilesystemStorageClient ({ root, env = process.env } = {}) {
39
+ const storageRoot = root ?? resolveFilesystemStorageRoot(env)
40
+
41
+ return {
42
+ backend: 'filesystem',
43
+ root: storageRoot,
44
+
45
+ createUploadUrl ({ Key }) {
46
+ return Promise.resolve(buildUrl(normalizeKey({ Key }), env))
47
+ },
48
+
49
+ createPresignedDownloadUrl (keyOrShape) {
50
+ const Key = normalizeKey(keyOrShape)
51
+ return Promise.resolve(buildUrl(Key, env))
52
+ },
53
+
54
+ createDownloadUrl (keyOrShape) {
55
+ const Key = normalizeKey(keyOrShape)
56
+ return buildUrl(Key, env)
57
+ },
58
+
59
+ async save (key, body) {
60
+ const Key = normalizeKey(key)
61
+ const localPath = keyToLocalPath(storageRoot, Key)
62
+ await mkdir(storageRoot, { recursive: true })
63
+ await writeFile(localPath, body)
64
+ },
65
+
66
+ async load (keyOrShape) {
67
+ const Key = normalizeKey(keyOrShape)
68
+ const localPath = keyToLocalPath(storageRoot, Key)
69
+ if (!existsSync(localPath)) return { buffer: null, exists: false }
70
+ const buffer = await readFile(localPath)
71
+ return { buffer, exists: true }
72
+ },
73
+
74
+ async headObject ({ Key }) {
75
+ const key = normalizeKey({ Key })
76
+ const localPath = keyToLocalPath(storageRoot, key)
77
+ const s = await stat(localPath).catch(() => null)
78
+ if (!s) throw Object.assign(new Error('Not Found'), { $metadata: { httpStatusCode: 404 } })
79
+ return { ContentLength: s.size }
80
+ },
81
+ }
82
+ }
83
+
84
+ /** @deprecated Use createFilesystemStorageClient — kept for direct imports. */
85
+ export class LocalStorageClient {
86
+ static createUploadUrl (params) {
87
+ return createFilesystemStorageClient().createUploadUrl(params)
88
+ }
89
+
90
+ static createPresignedDownloadUrl (keyOrShape) {
91
+ return createFilesystemStorageClient().createPresignedDownloadUrl(keyOrShape)
92
+ }
93
+
94
+ static createDownloadUrl (keyOrShape) {
95
+ return createFilesystemStorageClient().createDownloadUrl(keyOrShape)
96
+ }
97
+
98
+ static save (key, body) {
99
+ return createFilesystemStorageClient().save(key, body)
100
+ }
101
+
102
+ static load (keyOrShape) {
103
+ return createFilesystemStorageClient().load(keyOrShape)
104
+ }
105
+
106
+ static headObject (params) {
107
+ return createFilesystemStorageClient().headObject(params)
108
+ }
109
+ }