@ossy/platform 1.39.3 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ossy/platform",
3
- "version": "1.39.3",
3
+ "version": "3.0.1",
4
4
  "description": "Ossy application server runtime",
5
5
  "repository": {
6
6
  "type": "git",
@@ -44,16 +44,16 @@
44
44
  "@aws-sdk/util-create-request": "^3.972.26",
45
45
  "@aws-sdk/util-format-url": "^3.972.17",
46
46
  "@modelcontextprotocol/sdk": "^1.12.1",
47
- "@ossy/app": "^1.40.3",
48
- "@ossy/event-store": "^1.8.3",
49
- "@ossy/locale": "^1.40.3",
50
- "@ossy/observability": "^1.8.3",
51
- "@ossy/policies": "^1.13.3",
52
- "@ossy/schema": "^1.0.1",
53
- "@ossy/sdk": "^1.40.3",
54
- "@ossy/tokens": "^1.13.3",
55
- "@ossy/users": "^1.13.3",
56
- "@ossy/workspaces": "^1.17.3",
47
+ "@ossy/app": "^3.0.1",
48
+ "@ossy/event-store": "^3.0.1",
49
+ "@ossy/locale": "^3.0.1",
50
+ "@ossy/observability": "^3.0.1",
51
+ "@ossy/policies": "^3.0.1",
52
+ "@ossy/schema": "^3.0.1",
53
+ "@ossy/sdk": "^3.0.1",
54
+ "@ossy/tokens": "^3.0.1",
55
+ "@ossy/users": "^3.0.1",
56
+ "@ossy/workspaces": "^3.0.1",
57
57
  "cookie-parser": "^1.4.7",
58
58
  "dotenv": ">=16.0.0 <18.0.0",
59
59
  "express": ">=5.0.0 <6.0.0",
@@ -73,5 +73,5 @@
73
73
  "src",
74
74
  "Dockerfile"
75
75
  ],
76
- "gitHead": "a0d89185a17f8de8ce328c3a648c108ff1d61d8f"
76
+ "gitHead": "4a70ec216448680d2fddc57b2a8a0077489720ae"
77
77
  }
package/src/Definition.js CHANGED
@@ -3,6 +3,6 @@ export const Definition = {
3
3
  title: 'Platform',
4
4
  description: 'Deployment platform configuration aligned with deployment-tools S3 platform-config.json.',
5
5
  icon: 'controller',
6
- statuses: ['beta'],
6
+ status: ['beta'],
7
7
  entitlementRequired: false,
8
8
  }
@@ -25,12 +25,12 @@ import { ComponentSlotsProvider, Slot } from '@ossy/design-system'
25
25
  export function PlatformShell({ slots = {}, children }) {
26
26
  return (
27
27
  <ComponentSlotsProvider slots={slots}>
28
- <Slot name="app:system-messages" />
29
- <Slot name="app:header" />
30
- <Slot name="app:sidebar" />
31
- <Slot name="app:toolbar" />
28
+ <Slot view="app:system-messages" />
29
+ <Slot view="app:header" />
30
+ <Slot view="app:sidebar" />
31
+ <Slot view="app:toolbar" />
32
32
  {children}
33
- <Slot name="app:notifications" />
33
+ <Slot view="app:notifications" />
34
34
  </ComponentSlotsProvider>
35
35
  )
36
36
  }
@@ -62,7 +62,7 @@ export const ActionService = {
62
62
  * Invoke an action by id — records audit, runs the derived primary task, dispatches on_action follow-ups.
63
63
  *
64
64
  * @param {string} id
65
- * @param {{ payload?: unknown, sdk?: unknown, log?: unknown, integrations?: unknown, req?: unknown }} context
65
+ * @param {{ payload?: unknown, sdk?: unknown, log?: unknown, integrations?: unknown, req?: unknown, signal?: AbortSignal }} context
66
66
  * @returns {Promise<unknown>}
67
67
  */
68
68
  async invoke (id, context = {}) {
@@ -92,6 +92,11 @@ export const ActionService = {
92
92
 
93
93
  try {
94
94
  const result = await TaskService.invoke(taskId, invokeContext)
95
+ if (context.signal?.aborted) {
96
+ throw context.signal.reason instanceof Error
97
+ ? context.signal.reason
98
+ : new Error(`[ActionService] Action aborted: "${id}"`)
99
+ }
95
100
  const taskRunId = invokeContext.audit?.taskRunId ?? null
96
101
 
97
102
  if (!skipAudit) {
@@ -3,7 +3,6 @@ import { Aggregate } from '@ossy/event-store'
3
3
  import { createLogger } from '@ossy/observability'
4
4
  import { taskIdFromActionId } from '@ossy/schema'
5
5
  import { ActionInvocation } from './action-invocation.aggregate.js'
6
- import { PlatformSchema } from '../schema-ids.js'
7
6
  import { MeteringService } from '../metering/metering-service.js'
8
7
  import { detectChannel } from './detect-channel.js'
9
8
  import {
@@ -42,7 +41,7 @@ export const ActionInvocationService = {
42
41
  try {
43
42
  await Aggregate.Of(ActionInvocation, {
44
43
  resourceId: invocationId,
45
- type: PlatformSchema.actionInvocation,
44
+ type: '@ossy/platform/schema/action-invocation',
46
45
  event: 'Invoked',
47
46
  createdBy: actorId ?? undefined,
48
47
  payload: {
@@ -1,12 +1,10 @@
1
- import { PlatformSchema } from '../schema-ids.js'
2
-
3
1
  /**
4
2
  * ADR 0008 entity — audit record for each action invocation (POST /actions, MCP, SDK).
5
3
  */
6
4
  export class ActionInvocation {
7
5
 
8
6
  static kind = 'entity'
9
- static SchemaId = PlatformSchema.actionInvocation
7
+ static SchemaId = '@ossy/platform/schema/action-invocation'
10
8
  static AggregateType = 'ActionInvocation'
11
9
 
12
10
  static View (events, state = {}) {
@@ -1,5 +1,3 @@
1
- import { PlatformSchema } from '../schema-ids.js'
2
-
3
1
  /**
4
2
  * ADR 0008 projection — workspace-scoped task run list for UI and billing queries.
5
3
  */
@@ -9,9 +7,9 @@ export class TaskRunListProjection {
9
7
  static ProjectionId = '@ossy/platform/data/task-run-list'
10
8
 
11
9
  static sources = [
12
- { type: PlatformSchema.taskRun, event: 'Started' },
13
- { type: PlatformSchema.taskRun, event: 'Completed' },
14
- { type: PlatformSchema.taskRun, event: 'Failed' },
10
+ { type: '@ossy/platform/schema/task-run', event: 'Started' },
11
+ { type: '@ossy/platform/schema/task-run', event: 'Completed' },
12
+ { type: '@ossy/platform/schema/task-run', event: 'Failed' },
15
13
  ]
16
14
 
17
15
  static scopeFromEvent (event) {
@@ -2,7 +2,6 @@ import { nanoid } from 'nanoid'
2
2
  import { Aggregate } from '@ossy/event-store'
3
3
  import { createLogger, metrics } from '@ossy/observability'
4
4
  import { TaskRun } from './task-run.aggregate.js'
5
- import { PlatformSchema } from '../schema-ids.js'
6
5
  import { MeteringService } from '../metering/metering-service.js'
7
6
  import { detectChannel } from './detect-channel.js'
8
7
  import {
@@ -12,9 +11,18 @@ import {
12
11
  summarizeError,
13
12
  summarizeResult,
14
13
  } from './audit-helpers.js'
14
+ import { rejectOnAbort } from '../request-diagnostics.js'
15
15
 
16
16
  const log = createLogger('platform/task-run')
17
17
 
18
+ function assertNotAborted (context) {
19
+ if (context.signal?.aborted) {
20
+ throw context.signal.reason instanceof Error
21
+ ? context.signal.reason
22
+ : new Error('Operation aborted')
23
+ }
24
+ }
25
+
18
26
  function recordTaskMetrics (taskId, durationMs) {
19
27
  try { metrics.timing('task.duration', durationMs, { task: taskId }) } catch {}
20
28
  try { metrics.increment('task.run', { task: taskId }) } catch {}
@@ -49,6 +57,10 @@ export const TaskRunService = {
49
57
  } = opts
50
58
 
51
59
  if (audit === false) {
60
+ assertNotAborted(context)
61
+ if (context.signal) {
62
+ return Promise.race([handler(context), rejectOnAbort(context.signal)])
63
+ }
52
64
  return handler(context)
53
65
  }
54
66
 
@@ -72,7 +84,10 @@ export const TaskRunService = {
72
84
  })
73
85
 
74
86
  try {
75
- const result = await handler(context)
87
+ assertNotAborted(context)
88
+ const result = context.signal
89
+ ? await Promise.race([handler(context), rejectOnAbort(context.signal)])
90
+ : await handler(context)
76
91
  const durationMs = Date.now() - startMs
77
92
  await TaskRunService._appendCompleted({
78
93
  runId,
@@ -145,7 +160,7 @@ export const TaskRunService = {
145
160
  try {
146
161
  await Aggregate.Of(TaskRun, {
147
162
  resourceId: runId,
148
- type: PlatformSchema.taskRun,
163
+ type: '@ossy/platform/schema/task-run',
149
164
  event: 'Started',
150
165
  createdBy: actorId ?? undefined,
151
166
  payload: {
@@ -1,12 +1,10 @@
1
- import { PlatformSchema } from '../schema-ids.js'
2
-
3
1
  /**
4
2
  * ADR 0008 entity — one stream per task execution (sync invoke, changestream, cron, on_action).
5
3
  */
6
4
  export class TaskRun {
7
5
 
8
6
  static kind = 'entity'
9
- static SchemaId = PlatformSchema.taskRun
7
+ static SchemaId = '@ossy/platform/schema/task-run'
10
8
  static AggregateType = 'TaskRun'
11
9
 
12
10
  static View (events, state = {}) {
@@ -1,7 +1,5 @@
1
- import { PlatformSchema } from './schema-ids.js'
2
-
3
1
  export default {
4
- id: PlatformSchema.directory,
2
+ id: '@ossy/platform/schema/directory',
5
3
  name: 'Directory',
6
4
  categoryName: 'Platform',
7
5
  icon: 'folder',
@@ -1,7 +1,5 @@
1
- import { PlatformSchema } from './schema-ids.js'
2
-
3
1
  export default {
4
- id: PlatformSchema.file,
2
+ id: '@ossy/platform/schema/file',
5
3
  name: 'File',
6
4
  categoryName: 'Platform',
7
5
  icon: 'file',
package/src/index.js CHANGED
@@ -4,7 +4,6 @@ export { ChangeStream } from './tasks/change-stream.js'
4
4
  export { registerSchema, getSystemSchemas } from './resources/schema.registry.js'
5
5
  export { getPlatformSchema, initPlatformSchema, createSchemaEngine, schemaForWorkspace } from './resources/schema.service.js'
6
6
  export { validateSchemasForImport, ALLOWED_FIELD_TYPES, normalizeFieldType, resolveFieldDef } from './resources/schema.validation.js'
7
- export { PlatformSchema } from './schema-ids.js'
8
7
  export { IntegrationService } from './integration.service.js'
9
8
  export { ConfigService } from './config.service.js'
10
9
  export { ActionService } from './actions/action.service.js'
@@ -1,7 +1,6 @@
1
1
  import { nanoid } from 'nanoid'
2
2
  import { EventStore } from '@ossy/event-store'
3
3
  import { createLogger, metrics } from '@ossy/observability'
4
- import { PlatformSchema } from '../schema-ids.js'
5
4
  import { moduleIdFromTaskId } from '../audit/audit-helpers.js'
6
5
 
7
6
  const log = createLogger('platform/metering')
@@ -73,7 +72,7 @@ export const MeteringService = {
73
72
  try {
74
73
  await EventStore.AppendResourceEvent({
75
74
  id: nanoid(),
76
- type: PlatformSchema.meterEvent,
75
+ type: '@ossy/platform/schema/meter-event',
77
76
  resourceId: nanoid(),
78
77
  event: 'Recorded',
79
78
  version: 1,
@@ -5,6 +5,46 @@ const log = createLogger('@ossy/platform')
5
5
 
6
6
  const HEARTBEAT_MS = 25_000
7
7
 
8
+ /** @type {Map<string, number>} */
9
+ const activeConnectionsByWorkspace = new Map()
10
+
11
+ /** @type {Set<{ res: import('express').Response, heartbeat: ReturnType<typeof setInterval>, unsubscribe: () => void, workspaceId: string }>} */
12
+ const activeConnections = new Set()
13
+
14
+ function trackConnection (workspaceId, delta) {
15
+ const next = Math.max(0, (activeConnectionsByWorkspace.get(workspaceId) ?? 0) + delta)
16
+ if (next === 0) {
17
+ activeConnectionsByWorkspace.delete(workspaceId)
18
+ } else {
19
+ activeConnectionsByWorkspace.set(workspaceId, next)
20
+ }
21
+ return next
22
+ }
23
+
24
+ function releaseConnection (connection) {
25
+ if (!activeConnections.delete(connection)) return
26
+
27
+ clearInterval(connection.heartbeat)
28
+ connection.unsubscribe()
29
+ const remaining = trackConnection(connection.workspaceId, -1)
30
+ log.debug('[push] SSE closed', {
31
+ workspaceId: connection.workspaceId,
32
+ activeForWorkspace: remaining,
33
+ })
34
+ }
35
+
36
+ /**
37
+ * Tear down all open SSE connections so HTTP server shutdown is not blocked.
38
+ */
39
+ export function closePushSseConnections () {
40
+ for (const connection of [...activeConnections]) {
41
+ releaseConnection(connection)
42
+ if (!connection.res.writableEnded) {
43
+ connection.res.end()
44
+ }
45
+ }
46
+ }
47
+
8
48
  /**
9
49
  * ADR 0008 §9 — workspace-scoped SSE invalidation bus.
10
50
  *
@@ -30,14 +70,22 @@ export function mountPushSse (app) {
30
70
  send({ kind: 'connected', scope: { workspaceId } })
31
71
 
32
72
  const unsubscribe = PushInvalidation.subscribe(workspaceId, send)
73
+ const activeForWorkspace = trackConnection(workspaceId, 1)
74
+ log.debug('[push] SSE opened', {
75
+ workspaceId,
76
+ activeForWorkspace,
77
+ userId: req.userId,
78
+ })
79
+
33
80
  const heartbeat = setInterval(() => {
34
81
  res.write(': heartbeat\n\n')
35
82
  }, HEARTBEAT_MS)
36
83
 
84
+ const connection = { res, heartbeat, unsubscribe, workspaceId }
85
+ activeConnections.add(connection)
86
+
37
87
  req.on('close', () => {
38
- clearInterval(heartbeat)
39
- unsubscribe()
40
- log.debug(`[push] SSE closed for workspace ${workspaceId}`)
88
+ releaseConnection(connection)
41
89
  })
42
90
  })
43
91
  }
@@ -0,0 +1,144 @@
1
+ import { createLogger } from '@ossy/observability'
2
+
3
+ const log = createLogger('@ossy/platform/request-diagnostics')
4
+
5
+ export class OperationTimeoutError extends Error {
6
+ constructor (label, timeoutMs) {
7
+ super(`Operation timed out after ${timeoutMs}ms: ${label}`)
8
+ this.name = 'OperationTimeoutError'
9
+ this.label = label
10
+ this.timeoutMs = timeoutMs
11
+ }
12
+ }
13
+
14
+ export function resolveTimeoutMs (envKey, fallback) {
15
+ const raw = process.env[envKey]
16
+ const parsed = Number.parseInt(String(raw ?? ''), 10)
17
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback
18
+ }
19
+
20
+ /**
21
+ * Reject when `promise` does not settle within `timeoutMs`.
22
+ * Pass `timeoutMs <= 0` to disable (returns `promise` unchanged).
23
+ *
24
+ * When `abortController` is supplied, aborts its signal on timeout so callers
25
+ * can propagate cancellation into long-running work.
26
+ *
27
+ * @template T
28
+ * @param {Promise<T>} promise
29
+ * @param {number} timeoutMs
30
+ * @param {string} label
31
+ * @param {{ abortController?: AbortController }} [options]
32
+ * @returns {Promise<T>}
33
+ */
34
+ export function withTimeout (promise, timeoutMs, label, options = {}) {
35
+ if (!timeoutMs || timeoutMs <= 0) return promise
36
+
37
+ const { abortController } = options
38
+ let timer
39
+
40
+ const timeoutPromise = new Promise((_, reject) => {
41
+ timer = setTimeout(() => {
42
+ const err = new OperationTimeoutError(label, timeoutMs)
43
+ abortController?.abort(err)
44
+ reject(err)
45
+ }, timeoutMs)
46
+ })
47
+
48
+ const abortPromise = abortController
49
+ ? new Promise((_, reject) => {
50
+ const { signal } = abortController
51
+ if (signal.aborted) {
52
+ reject(signal.reason ?? new OperationTimeoutError(label, timeoutMs))
53
+ return
54
+ }
55
+ signal.addEventListener('abort', () => {
56
+ reject(signal.reason ?? new OperationTimeoutError(label, timeoutMs))
57
+ }, { once: true })
58
+ })
59
+ : null
60
+
61
+ const races = abortPromise ? [promise, timeoutPromise, abortPromise] : [promise, timeoutPromise]
62
+
63
+ return Promise.race(races).finally(() => {
64
+ clearTimeout(timer)
65
+ })
66
+ }
67
+
68
+ /**
69
+ * Reject when a signal aborts (used to race cooperative task handlers).
70
+ *
71
+ * @param {AbortSignal} signal
72
+ * @returns {Promise<never>}
73
+ */
74
+ export function rejectOnAbort (signal) {
75
+ return new Promise((_, reject) => {
76
+ if (signal.aborted) {
77
+ reject(signal.reason ?? new Error('Operation aborted'))
78
+ return
79
+ }
80
+ signal.addEventListener('abort', () => {
81
+ reject(signal.reason ?? new Error('Operation aborted'))
82
+ }, { once: true })
83
+ })
84
+ }
85
+
86
+ /**
87
+ * Log when an operation exceeds the slow threshold.
88
+ *
89
+ * @param {string} label
90
+ * @param {{ log?: import('@ossy/observability').Logger, warnThresholdMs?: number }} [options]
91
+ */
92
+ export function createTimedOperation (label, options = {}) {
93
+ const logger = options.log ?? log
94
+ const warnThresholdMs = options.warnThresholdMs
95
+ ?? resolveTimeoutMs('OSSY_REQUEST_SLOW_MS', 5000)
96
+ const start = performance.now()
97
+
98
+ return {
99
+ finish (detail) {
100
+ const elapsedMs = Math.round(performance.now() - start)
101
+ const payload = { label, elapsedMs, ...(detail ?? {}) }
102
+ if (elapsedMs >= warnThresholdMs) {
103
+ logger.warn('[slow-operation]', payload)
104
+ } else {
105
+ logger.debug('[timing]', payload)
106
+ }
107
+ return elapsedMs
108
+ },
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Express middleware — logs requests that exceed OSSY_REQUEST_SLOW_MS (default 5s).
114
+ *
115
+ * @param {import('@ossy/observability').Logger} [logger]
116
+ */
117
+ export function createSlowRequestLogger (logger = log) {
118
+ const slowThresholdMs = resolveTimeoutMs('OSSY_REQUEST_SLOW_MS', 5000)
119
+
120
+ return (req, res, next) => {
121
+ const start = performance.now()
122
+ const requestUrl = req.originalUrl || req.url || '/'
123
+
124
+ const onFinished = () => {
125
+ res.removeListener('finish', onFinished)
126
+ res.removeListener('close', onFinished)
127
+ const elapsedMs = Math.round(performance.now() - start)
128
+ if (elapsedMs < slowThresholdMs) return
129
+
130
+ logger.warn('[slow-request]', {
131
+ method: req.method,
132
+ url: requestUrl,
133
+ elapsedMs,
134
+ statusCode: res.statusCode,
135
+ userId: req.userId,
136
+ workspaceId: req.workspaceId,
137
+ })
138
+ }
139
+
140
+ res.on('finish', onFinished)
141
+ res.on('close', onFinished)
142
+ next()
143
+ }
144
+ }
@@ -0,0 +1,40 @@
1
+ import { describe, expect, it } from '@jest/globals'
2
+ import { OperationTimeoutError, rejectOnAbort, withTimeout } from './request-diagnostics.js'
3
+
4
+ describe('request-diagnostics', () => {
5
+ it('withTimeout resolves when promise settles in time', async () => {
6
+ const result = await withTimeout(Promise.resolve('ok'), 100, 'fast')
7
+ expect(result).toBe('ok')
8
+ })
9
+
10
+ it('withTimeout rejects with OperationTimeoutError when promise is slow', async () => {
11
+ await expect(withTimeout(new Promise(() => {}), 20, 'slow-op')).rejects.toMatchObject({
12
+ label: 'slow-op',
13
+ timeoutMs: 20,
14
+ })
15
+ await expect(withTimeout(new Promise(() => {}), 20, 'slow-op')).rejects.toBeInstanceOf(OperationTimeoutError)
16
+ })
17
+
18
+ it('withTimeout passes through when timeout is disabled', async () => {
19
+ const result = await withTimeout(Promise.resolve(42), 0, 'disabled')
20
+ expect(result).toBe(42)
21
+ })
22
+
23
+ it('withTimeout aborts the supplied controller on timeout', async () => {
24
+ const abortController = new AbortController()
25
+ let aborted = false
26
+ abortController.signal.addEventListener('abort', () => { aborted = true })
27
+
28
+ await expect(
29
+ withTimeout(new Promise(() => {}), 20, 'abortable', { abortController }),
30
+ ).rejects.toBeInstanceOf(OperationTimeoutError)
31
+ expect(aborted).toBe(true)
32
+ expect(abortController.signal.aborted).toBe(true)
33
+ })
34
+
35
+ it('rejectOnAbort rejects when signal is already aborted', async () => {
36
+ const abortController = new AbortController()
37
+ abortController.abort(new Error('already gone'))
38
+ await expect(rejectOnAbort(abortController.signal)).rejects.toThrow(/already gone/)
39
+ })
40
+ })
package/src/runtime.js CHANGED
@@ -4,7 +4,7 @@ import express from 'express'
4
4
  import cookieParser from 'cookie-parser'
5
5
  import morgan from 'morgan'
6
6
  import { Router as OssyRouter } from '@ossy/router'
7
- import { loadManifest, resolveEntryUrl } from './server.js'
7
+ import { loadManifest, resolveEntryUrl, loadLayoutsById, resolvePageLayoutRender } from './server.js'
8
8
  import { resolveRequestLocale } from './locale.js'
9
9
  import { buildManifestSummary } from '@ossy/app/manifest/build-manifest-summary'
10
10
  import { ProxyInternal } from './proxy-internal.js'
@@ -31,7 +31,7 @@ function cloneSerializable (value) {
31
31
  */
32
32
  const siteContextCache = new Map()
33
33
 
34
- function buildSiteContext (manifest, buildDir) {
34
+ function buildSiteContext (manifest, buildDir, layoutsById) {
35
35
  const config = manifest.config
36
36
  const supportedLanguages = Array.isArray(config.supportedLanguages) ? config.supportedLanguages : []
37
37
 
@@ -50,14 +50,15 @@ function buildSiteContext (manifest, buildDir) {
50
50
  return promise
51
51
  }
52
52
 
53
- return { pageRouter, apiRouter, manifest, manifestSummary: buildManifestSummary(manifest), loadEntry, buildDir }
53
+ return { pageRouter, apiRouter, manifest, manifestSummary: buildManifestSummary(manifest), loadEntry, buildDir, layoutsById }
54
54
  }
55
55
 
56
56
  async function getSiteContext (domain) {
57
57
  if (siteContextCache.has(domain)) return siteContextCache.get(domain)
58
58
  const buildDir = await loadSite(domain)
59
59
  const manifest = loadManifest(buildDir)
60
- const context = buildSiteContext(manifest, buildDir)
60
+ const layoutsById = await loadLayoutsById(manifest.layouts, buildDir, log)
61
+ const context = buildSiteContext(manifest, buildDir, layoutsById)
61
62
  siteContextCache.set(domain, context)
62
63
  return context
63
64
  }
@@ -129,7 +130,7 @@ export async function startRuntime ({ port } = {}) {
129
130
  return
130
131
  }
131
132
 
132
- const { pageRouter, apiRouter, manifest, manifestSummary, loadEntry, buildDir } = context
133
+ const { pageRouter, apiRouter, manifest, manifestSummary, loadEntry, buildDir, layoutsById } = context
133
134
 
134
135
  try {
135
136
  const apiRoute = apiRouter.getPageByUrl(requestUrl)
@@ -169,6 +170,11 @@ export async function startRuntime ({ port } = {}) {
169
170
 
170
171
  const config = manifest.config
171
172
  const { language, messages, fallbackMessages } = resolveRequestLocale(pageRouter, requestUrl, buildDir, config)
173
+ const {
174
+ Layout,
175
+ layoutEntry,
176
+ layoutSlots,
177
+ } = resolvePageLayoutRender(layoutsById, pageEntry)
172
178
  const props = {
173
179
  ...config,
174
180
  ...(req.userAppSettings || {}),
@@ -190,6 +196,11 @@ export async function startRuntime ({ port } = {}) {
190
196
  ...(page.package ? { package: page.package } : {}),
191
197
  })),
192
198
  pageId: pageRoute.id,
199
+ componentEntries: manifest.components,
200
+ resolveComponentEntry: (entry) => resolveEntryUrl(entry, buildDir),
201
+ layoutSlots,
202
+ Layout,
203
+ layoutEntry,
193
204
  }
194
205
  const html = await mod.render(props)
195
206
  res.status(200).type('html').send(html)
package/src/server.js CHANGED
@@ -7,7 +7,7 @@ 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
13
  import { buildManifestSummary } from '@ossy/app/manifest/build-manifest-summary'
@@ -26,13 +26,23 @@ import {
26
26
  buildActionEntitlementIndex,
27
27
  createWorkspaceLoader,
28
28
  } from './entitlements/action-entitlement.js'
29
- import { mountPushSse } from './push/mount-push-sse.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'
30
37
  import { mergeWorkspaceSchemas } from '@ossy/workspaces/merge-workspace-schemas'
31
38
  import {
32
39
  buildEnableablePackageSet,
33
40
  setEnableablePackages,
34
41
  } from '@ossy/workspaces/entitlements'
35
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)
36
46
 
37
47
  const DEFAULT_PORT = 3000
38
48
  const MANIFEST_FILE = 'manifest.json'
@@ -77,7 +87,8 @@ export function loadManifest (buildDir) {
77
87
  const actions = Array.isArray(manifest.actions) ? manifest.actions : []
78
88
  const emails = Array.isArray(manifest.emails) ? manifest.emails : []
79
89
  const layouts = Array.isArray(manifest.layouts) ? manifest.layouts : []
80
- 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)) {
81
92
  log.warn(`Skipping ${e.type} "${e.id}" — manifest entry has no path.`)
82
93
  }
83
94
  }
@@ -117,6 +128,49 @@ function cloneSerializable (value) {
117
128
  return value == null ? value : JSON.parse(JSON.stringify(value))
118
129
  }
119
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
+
120
174
  export async function startServer (options = {}) {
121
175
  const cwd = options.cwd ? path.resolve(options.cwd) : process.cwd()
122
176
  const buildDir = path.resolve(cwd, options.buildDir || 'build')
@@ -169,6 +223,8 @@ export async function startServer (options = {}) {
169
223
 
170
224
  // Aggregate registration — mirrors task and schema registration above.
171
225
  // AggregateRebuild is provided by @ossy/event-store.
226
+ /** @type {(() => void) | null} */
227
+ let runStartupRebuild = null
172
228
  if (AggregateRebuild) {
173
229
  for (const agg of manifest.aggregates ?? []) {
174
230
  try {
@@ -179,9 +235,13 @@ export async function startServer (options = {}) {
179
235
  }
180
236
  }
181
237
 
182
- AggregateRebuild.BuildAndSaveAll().catch((error) => {
183
- log.error('BuildAndSaveAll failed is MongoDB reachable?', undefined, error)
184
- })
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
+ }
185
245
  }
186
246
 
187
247
  for (const startup of manifest.startups ?? []) {
@@ -206,34 +266,9 @@ export async function startServer (options = {}) {
206
266
  }
207
267
  }
208
268
 
209
- // Layout loading — one `*.layout.jsx` per app wraps every page render. The
210
- // layout component decides route-specific chrome (auth, public flows, etc.).
211
- /** @type {{ component: import('react').ComponentType, entry: string } | null} */
212
- let appLayout = null
269
+ // Layout loading — registered layouts keyed by id; each page picks layout + merged slots at build time.
213
270
  const layoutManifest = manifest.layouts ?? []
214
- if (layoutManifest.length > 1) {
215
- log.warn(
216
- `Multiple layouts in manifest (${layoutManifest.length}); only the first is used. Use one *.layout.jsx per app.`,
217
- )
218
- }
219
- const layoutEntry = layoutManifest[0]
220
- if (layoutEntry) {
221
- try {
222
- const mod = await import(/* @vite-ignore */ resolveEntryUrl(layoutEntry.entry, buildDir))
223
- if (typeof mod.default === 'function') {
224
- appLayout = { component: mod.default, entry: layoutEntry.entry }
225
- }
226
- } catch (err) {
227
- log.warn(`Failed to load layout "${layoutEntry.id}"`, undefined, err)
228
- }
229
- }
230
-
231
- // App-controlled shell slots: `export const slots` in `*.layout.jsx` is recorded
232
- // on `manifest.layouts[0].slots` at build time and resolved at render in page-runtime.
233
- const layoutSlots =
234
- layoutEntry && typeof layoutEntry.slots === 'object' && !Array.isArray(layoutEntry.slots)
235
- ? layoutEntry.slots
236
- : {}
271
+ const layoutsById = await loadLayoutsById(layoutManifest, buildDir, log)
237
272
 
238
273
  // Register the SDK so all tasks receive it as `sdk`.
239
274
  // Priority: explicit options.sdk → SDK.of() from env vars → null (direct-DB fallback in tasks).
@@ -245,6 +280,9 @@ export async function startServer (options = {}) {
245
280
  TaskService.startScheduler()
246
281
 
247
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
+ })
248
286
  ChangeStream.start(resolveMongoUrl(process.env.DB_URL))
249
287
  }
250
288
 
@@ -267,7 +305,12 @@ export async function startServer (options = {}) {
267
305
  let loadWorkspaceForEntitlements = null
268
306
  try {
269
307
  const { Workspace } = await import('@ossy/workspaces/server')
270
- loadWorkspaceForEntitlements = createWorkspaceLoader(Workspace)
308
+ const loadWorkspace = createWorkspaceLoader(Workspace)
309
+ loadWorkspaceForEntitlements = (workspaceId) => withTimeout(
310
+ loadWorkspace(workspaceId),
311
+ MONGO_TIMEOUT_MS,
312
+ `loadWorkspace(${workspaceId})`,
313
+ )
271
314
  } catch (err) {
272
315
  log.warn('[@ossy/platform] Workspace aggregate unavailable — action entitlements disabled', err)
273
316
  }
@@ -288,6 +331,7 @@ export async function startServer (options = {}) {
288
331
  })
289
332
  app.use(UsersMiddleware.AuthenticateUser)
290
333
  app.use(WorkspacesMiddleware.ExtractWorkspaceId())
334
+ app.use(createSlowRequestLogger(log))
291
335
  if (fs.existsSync(publicDir)) app.use(express.static(publicDir))
292
336
  app.use(ProxyInternal())
293
337
 
@@ -343,16 +387,33 @@ export async function startServer (options = {}) {
343
387
  }
344
388
 
345
389
  const actionLog = createLogger(actionId)
390
+ const actionTimer = createTimedOperation(`POST /actions ${actionId}`, { log: actionLog })
391
+ const actionAbort = new AbortController()
346
392
  try {
347
- const result = await ActionService.invoke(actionId, {
348
- payload,
349
- sdk: req.sdk ?? null,
350
- log: actionLog,
351
- integrations: IntegrationService,
352
- req,
353
- })
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 })
354
407
  res.json(result ?? { ok: true })
355
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
+ }
356
417
  actionLog.error('Action failed', { id: actionId }, err)
357
418
  const status = err?.status ?? 500
358
419
  res.status(status).json({ error: err?.message ?? 'Internal error' })
@@ -368,6 +429,7 @@ export async function startServer (options = {}) {
368
429
 
369
430
  app.all('*all', async (req, res) => {
370
431
  const requestUrl = req.originalUrl || '/'
432
+ const pageTimer = createTimedOperation(`page ${req.method} ${requestUrl}`, { log })
371
433
  try {
372
434
  const apiRoute = apiRouter.getPageByUrl(requestUrl)
373
435
  if (apiRoute) {
@@ -393,18 +455,25 @@ export async function startServer (options = {}) {
393
455
  res.status(404).send('Not found')
394
456
  return
395
457
  }
458
+ const loadEntryTimer = createTimedOperation(`SSR loadEntry ${pageRoute.id}`, { log })
396
459
  const mod = await loadEntry(pageEntry.entry)
460
+ loadEntryTimer.finish()
397
461
  if (typeof mod.render !== 'function') {
398
462
  res.status(503).type('text').send('SSR runtime unavailable')
399
463
  return
400
464
  }
401
- const Layout = appLayout?.component ?? null
402
- const layoutEntry = appLayout?.entry ?? null
465
+ const {
466
+ Layout,
467
+ layoutEntry,
468
+ layoutSlots,
469
+ } = resolvePageLayoutRender(layoutsById, pageEntry)
403
470
  const { language, messages, fallbackMessages } = resolveRequestLocale(pageRouter, requestUrl, buildDir, config)
404
471
  let schemasForPage = cloneSerializable(manifest.schemas || [])
405
472
  if (req.workspaceId && loadWorkspaceForEntitlements) {
473
+ const workspaceTimer = createTimedOperation(`SSR loadWorkspace ${req.workspaceId}`, { log })
406
474
  try {
407
475
  const workspace = await loadWorkspaceForEntitlements(req.workspaceId)
476
+ workspaceTimer.finish({ pageId: pageRoute.id })
408
477
  if (workspace?.schemas?.length) {
409
478
  schemasForPage = mergeWorkspaceSchemas(
410
479
  schemasForPage,
@@ -412,7 +481,20 @@ export async function startServer (options = {}) {
412
481
  )
413
482
  }
414
483
  } catch (err) {
415
- log.warn('[@ossy/platform] Failed to merge workspace schemas for SSR', 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
+ }
416
498
  }
417
499
  }
418
500
  // Page component → `app:content` is resolved in page-runtime (SSR + hydrate).
@@ -439,17 +521,44 @@ export async function startServer (options = {}) {
439
521
  })),
440
522
  pageId: pageRoute.id,
441
523
  componentEntries: manifest.components,
524
+ resolveComponentEntry: (entry) => resolveEntryUrl(entry, buildDir),
442
525
  layoutSlots,
443
526
  Layout,
444
527
  layoutEntry,
445
528
  }
446
- 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 })
447
554
  res.status(200).type('html').send(html)
448
555
  return
449
556
  }
450
557
 
558
+ pageTimer.finish({ statusCode: 404 })
451
559
  res.status(404).send('Not found')
452
560
  } catch (err) {
561
+ pageTimer.finish({ error: err?.message ?? String(err) })
453
562
  log.error('Request handling failed', undefined, err)
454
563
  if (!res.headersSent) {
455
564
  res.status(500).type('text').send('Internal Server Error')
@@ -466,7 +575,12 @@ export async function startServer (options = {}) {
466
575
  log.info(`Running on http://localhost:${port}`)
467
576
  log.info('Press Ctrl+C to stop.')
468
577
 
469
- 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
+ })
470
584
 
471
585
  let shuttingDown = false
472
586
  const handleShutdown = async (signal) => {
@@ -474,6 +588,10 @@ export async function startServer (options = {}) {
474
588
  shuttingDown = true
475
589
  log.info(`Received ${signal}, shutting down...`)
476
590
  try {
591
+ TaskService.stop()
592
+ closePushSseConnections()
593
+ await ChangeStream.stop()
594
+ await Mongo.closeConnection()
477
595
  await closeServer()
478
596
  } finally {
479
597
  process.exit(0)
@@ -488,6 +606,7 @@ export async function startServer (options = {}) {
488
606
  }
489
607
 
490
608
  export default startServer
609
+ export { loadLayoutsById, resolvePageLayoutRender }
491
610
  export { ConfigService } from './config.service.js'
492
611
  export { ActionService } from './actions/action.service.js'
493
612
  export { IntegrationService } from './integration.service.js'
@@ -496,7 +615,6 @@ export { originalObjectKey, derivativeObjectKey, assertStorageKey } from './stor
496
615
  export { S3Client } from './storage/s3.client.js'
497
616
  export { LocalStorageClient } from './storage/local-storage.client.js'
498
617
  export { getSystemSchemas } from './resources/schema.registry.js'
499
- export { PlatformSchema } from './schema-ids.js'
500
618
  export { getPlatformSchema, initPlatformSchema, createSchemaEngine, schemaForWorkspace } from './resources/schema.service.js'
501
619
  export { validateSchemasForImport } from './resources/schema.validation.js'
502
620
  export {
@@ -17,6 +17,10 @@ export class ChangeStream {
17
17
  static _reconnectAttempts = 0
18
18
  static _dbUrl = null
19
19
  static _client = null
20
+ /** @type {import('mongodb').ChangeStream | null} */
21
+ static _changeStream = null
22
+ /** @type {ReturnType<typeof setTimeout> | null} */
23
+ static _reconnectTimer = null
20
24
 
21
25
  /**
22
26
  * Opens the MongoDB changestream and wires up reconnect logic.
@@ -33,12 +37,35 @@ export class ChangeStream {
33
37
  })
34
38
  }
35
39
 
36
- static stop() {
40
+ static async stop () {
37
41
  ChangeStream._stopped = true
38
- if (ChangeStream._client) {
39
- ChangeStream._client.close().catch(() => {})
40
- ChangeStream._client = null
42
+
43
+ if (ChangeStream._reconnectTimer) {
44
+ clearTimeout(ChangeStream._reconnectTimer)
45
+ ChangeStream._reconnectTimer = null
46
+ }
47
+
48
+ const stream = ChangeStream._changeStream
49
+ ChangeStream._changeStream = null
50
+ if (stream) {
51
+ try {
52
+ await stream.close()
53
+ } catch {
54
+ // ignore — stream may already be closed
55
+ }
56
+ }
57
+
58
+ const client = ChangeStream._client
59
+ ChangeStream._client = null
60
+ if (client) {
61
+ try {
62
+ await client.close()
63
+ } catch {
64
+ // ignore
65
+ }
41
66
  }
67
+
68
+ log.info('[ChangeStream] Stopped')
42
69
  }
43
70
 
44
71
  static async _getMongoClient() {
@@ -46,7 +73,10 @@ export class ChangeStream {
46
73
  return MongoClient
47
74
  }
48
75
 
49
- static async _getClient() {
76
+ static async _getClient () {
77
+ if (ChangeStream._stopped) {
78
+ throw new Error('ChangeStream stopped')
79
+ }
50
80
  if (!ChangeStream._client) {
51
81
  const MongoClient = await ChangeStream._getMongoClient()
52
82
  ChangeStream._client = new MongoClient(ChangeStream._dbUrl, {
@@ -56,7 +86,8 @@ export class ChangeStream {
56
86
  return ChangeStream._client
57
87
  }
58
88
 
59
- static async _resetClient() {
89
+ static async _resetClient () {
90
+ if (ChangeStream._stopped) return
60
91
  const MongoClient = await ChangeStream._getMongoClient()
61
92
  const prev = ChangeStream._client
62
93
  ChangeStream._client = new MongoClient(ChangeStream._dbUrl, {
@@ -68,7 +99,7 @@ export class ChangeStream {
68
99
  log.info('[ChangeStream] New MongoClient instance created')
69
100
  }
70
101
 
71
- static _scheduleReconnect() {
102
+ static _scheduleReconnect () {
72
103
  if (ChangeStream._stopped) return
73
104
 
74
105
  const delaySecs = Math.min(Math.pow(2, ChangeStream._reconnectAttempts), 30)
@@ -76,7 +107,12 @@ export class ChangeStream {
76
107
 
77
108
  log.info(`[ChangeStream] Reconnecting in ${delaySecs}s...`)
78
109
 
79
- setTimeout(() => {
110
+ if (ChangeStream._reconnectTimer) {
111
+ clearTimeout(ChangeStream._reconnectTimer)
112
+ }
113
+
114
+ ChangeStream._reconnectTimer = setTimeout(() => {
115
+ ChangeStream._reconnectTimer = null
80
116
  if (ChangeStream._stopped) return
81
117
  ChangeStream._open().catch((error) => {
82
118
  log.error('[ChangeStream] Change stream could not be opened', undefined, error)
@@ -85,16 +121,30 @@ export class ChangeStream {
85
121
  }, delaySecs * 1000)
86
122
  }
87
123
 
88
- static async _open() {
124
+ static async _open () {
125
+ if (ChangeStream._stopped) return
126
+
89
127
  log.info('[ChangeStream] Watching for changes')
90
128
 
91
129
  const dbName = process.env.DB_NAME || 'test'
92
130
  const client = await ChangeStream._getClient()
131
+ if (ChangeStream._stopped) return
132
+
93
133
  const collection = client.db(dbName).collection('eventstore')
94
134
 
135
+ if (ChangeStream._changeStream) {
136
+ try {
137
+ await ChangeStream._changeStream.close()
138
+ } catch {
139
+ // ignore
140
+ }
141
+ ChangeStream._changeStream = null
142
+ }
143
+
95
144
  const changeStream = collection.watch([
96
145
  { $match: { operationType: 'insert' } },
97
146
  ])
147
+ ChangeStream._changeStream = changeStream
98
148
 
99
149
  const wasReconnecting = ChangeStream._reconnectAttempts > 0
100
150
  ChangeStream._reconnectAttempts = 0
@@ -105,6 +155,7 @@ export class ChangeStream {
105
155
  // Ensures only one reconnect is scheduled if both 'error' and 'close' fire for the same failure.
106
156
  let reconnectScheduled = false
107
157
  const scheduleOnce = () => {
158
+ if (ChangeStream._stopped) return
108
159
  if (!reconnectScheduled) {
109
160
  reconnectScheduled = true
110
161
  ChangeStream._scheduleReconnect()
@@ -112,6 +163,7 @@ export class ChangeStream {
112
163
  }
113
164
 
114
165
  changeStream.on('error', (error) => {
166
+ if (ChangeStream._stopped) return
115
167
  log.error('[ChangeStream] Change stream error (server keeps running)', undefined, error)
116
168
  if (isMongoTopologyClosedError(error)) {
117
169
  ChangeStream._resetClient().catch(() => {})
@@ -132,11 +184,19 @@ export class ChangeStream {
132
184
  })
133
185
 
134
186
  changeStream.on('close', () => {
187
+ if (ChangeStream._changeStream === changeStream) {
188
+ ChangeStream._changeStream = null
189
+ }
190
+ if (ChangeStream._stopped) return
135
191
  log.info('[ChangeStream] close detected')
136
192
  scheduleOnce()
137
193
  })
138
194
 
139
195
  changeStream.on('end', () => {
196
+ if (ChangeStream._changeStream === changeStream) {
197
+ ChangeStream._changeStream = null
198
+ }
199
+ if (ChangeStream._stopped) return
140
200
  log.info('[ChangeStream] end detected')
141
201
  scheduleOnce()
142
202
  })
@@ -5,8 +5,14 @@ import { User } from '@ossy/users/server'
5
5
  import { Token } from '@ossy/tokens/server'
6
6
  import { ConfigService } from './config.service.js'
7
7
  import { PoliciesQueries } from '@ossy/policies/server'
8
+ import {
9
+ OperationTimeoutError,
10
+ resolveTimeoutMs,
11
+ withTimeout,
12
+ } from './request-diagnostics.js'
8
13
 
9
14
  const log = createLogger('users')
15
+ const AUTH_TIMEOUT_MS = resolveTimeoutMs('OSSY_AUTH_TIMEOUT_MS', 10_000)
10
16
 
11
17
  function normalizeAuthToken (token) {
12
18
  const trimmed = String(token ?? '').trim()
@@ -58,29 +64,55 @@ export class UsersMiddleware {
58
64
  log.info('[UsersMiddleware] Authenticating')
59
65
  log.debug('[UsersMiddleware] authToken', { authToken })
60
66
 
61
- TokenService.verify(authToken)
62
- .then(UsersMiddleware.assertApiTokenActive)
63
- .then(payload => {
64
- req.authPayload = payload
65
- req.tokenScopes = payload?.type === 'Api' ? (payload.scopes ?? ['*']) : null
66
- return payload
67
- })
68
- .then(({ sub }) => Aggregate.Of(User, sub))
69
- .then(Aggregate.View())
70
- .then(user => {
71
- const workspaces = user.workspaces ?? []
67
+ const authStarted = performance.now()
68
+ let authFinished = false
69
+ const finishAuth = (apply) => {
70
+ if (authFinished) return
71
+ authFinished = true
72
+ apply()
73
+ }
72
74
 
73
- return PoliciesQueries.GetPoliciesForUser(user.id)
74
- .then(policies => {
75
- log.info('[UsersMiddleware] Resolved user')
76
- req.userId = user.id
77
- req.user = { ...user, workspaces, policies }
78
- next()
79
- })
80
- })
75
+ withTimeout(
76
+ TokenService.verify(authToken)
77
+ .then(UsersMiddleware.assertApiTokenActive)
78
+ .then(payload => {
79
+ req.authPayload = payload
80
+ req.tokenScopes = payload?.type === 'Api' ? (payload.scopes ?? ['*']) : null
81
+ return payload
82
+ })
83
+ .then(({ sub }) => Aggregate.Of(User, sub))
84
+ .then(Aggregate.View())
85
+ .then(user => {
86
+ const workspaces = user.workspaces ?? []
87
+
88
+ return PoliciesQueries.GetPoliciesForUser(user.id)
89
+ .then(policies => {
90
+ finishAuth(() => {
91
+ log.info('[UsersMiddleware] Resolved user', {
92
+ elapsedMs: Math.round(performance.now() - authStarted),
93
+ })
94
+ req.userId = user.id
95
+ req.user = { ...user, workspaces, policies }
96
+ next()
97
+ })
98
+ })
99
+ }),
100
+ AUTH_TIMEOUT_MS,
101
+ 'UsersMiddleware.AuthenticateUser',
102
+ )
81
103
  .catch(error => {
104
+ if (error instanceof OperationTimeoutError) {
105
+ log.warn('[UsersMiddleware] Auth timed out', {
106
+ timeoutMs: error.timeoutMs,
107
+ elapsedMs: Math.round(performance.now() - authStarted),
108
+ })
109
+ finishAuth(() => {
110
+ res.status(504).json({ error: 'Authentication timed out', code: 'AUTH_TIMEOUT' })
111
+ })
112
+ return
113
+ }
82
114
  log.debug('[UsersMiddleware] Auth failed; anonymous', { error })
83
- asAnonymous()
115
+ finishAuth(asAnonymous)
84
116
  })
85
117
  }
86
118
 
package/src/schema-ids.js DELETED
@@ -1,9 +0,0 @@
1
- /** Platform envelope schemas (ADR 0008). */
2
- export const PlatformSchema = Object.freeze({
3
- config: '@ossy/platform/schema/config',
4
- directory: '@ossy/platform/schema/directory',
5
- file: '@ossy/platform/schema/file',
6
- taskRun: '@ossy/platform/schema/task-run',
7
- actionInvocation: '@ossy/platform/schema/action-invocation',
8
- meterEvent: '@ossy/platform/schema/meter-event',
9
- })