@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.
- package/README.md +21 -17
- package/package.json +20 -11
- package/src/Definition.js +2 -1
- package/src/PlatformShell.jsx +10 -10
- package/src/actions/action.service.js +85 -7
- package/src/audit/action-invocation-service.js +143 -0
- package/src/audit/action-invocation.aggregate.js +57 -0
- package/src/audit/audit-helpers.js +61 -0
- package/src/audit/detect-channel.js +19 -0
- package/src/audit/list-task-runs.action.js +5 -0
- package/src/audit/list-task-runs.task.js +17 -0
- package/src/audit/task-run-list.aggregate.js +76 -0
- package/src/audit/task-run-service.js +208 -0
- package/src/audit/task-run.aggregate.js +58 -0
- package/src/auth/action-scopes.js +3 -0
- package/src/capability-schemas/action-capability.schema.js +1 -0
- package/src/capability-schemas/action-meta.schema.js +1 -0
- package/src/capability-schemas/component-capability.schema.js +1 -0
- package/src/capability-schemas/page-capability.schema.js +1 -0
- package/src/capability-schemas/page-meta.schema.js +1 -0
- package/src/capability-schemas/task-capability.schema.js +1 -0
- package/src/capability-schemas/task-meta.schema.js +1 -0
- package/src/capability-schemas/task-output.schema.js +1 -0
- package/src/capability-schemas/task-trigger-authoring.schema.js +1 -0
- package/src/capability-schemas/task-trigger.schema.js +1 -0
- package/src/directory.schema.js +7 -0
- package/src/entitlements/action-entitlement.js +69 -0
- package/src/file.schema.js +11 -0
- package/src/index.js +12 -3
- package/src/mcp/create-ossy-mcp-server.js +97 -0
- package/src/mcp/json-schema-to-zod.js +64 -0
- package/src/mcp/mount-ossy-mcp.js +72 -0
- package/src/mcp/mount-platform-mcp.js +101 -0
- package/src/mcp/upload-file-tool.js +62 -0
- package/src/metering/metering-service.js +91 -0
- package/src/{platform-config.resource.js → platform-config.schema.js} +1 -1
- package/src/proxy-internal.js +13 -16
- package/src/push/mount-push-sse.js +91 -0
- package/src/request-diagnostics.js +144 -0
- package/src/request-diagnostics.spec.js +40 -0
- package/src/resources/index.js +3 -2
- package/src/resources/schema.registry.js +26 -0
- package/src/resources/schema.service.js +54 -0
- package/src/resources/schema.validation.js +90 -0
- package/src/runtime.js +20 -6
- package/src/server.js +281 -62
- package/src/storage/filesystem-storage.client.js +109 -0
- package/src/storage/local-storage.client.js +2 -65
- package/src/storage/resource-read-url.js +36 -0
- package/src/storage/resource-read-url.spec.js +22 -0
- package/src/storage/s3-storage.client.js +102 -0
- package/src/storage/s3.client.js +27 -23
- package/src/storage/storage-keys.js +37 -0
- package/src/storage/storage-keys.spec.js +16 -0
- package/src/storage/storage.client.js +52 -8
- package/src/storage/storage.integration.js +40 -0
- package/src/tasks/change-stream.js +78 -11
- package/src/tasks/task-service.js +211 -34
- package/src/tasks/task-service.spec.js +187 -0
- package/src/test/e2e.util.js +18 -39
- package/src/test/flow-runner.js +476 -0
- package/src/test/test.util.js +30 -29
- package/src/user-app-settings.js +44 -0
- package/src/users.middleware.js +61 -17
- package/src/resources/resource-template.registry.js +0 -29
- 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 {
|
|
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
|
|
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) {
|
|
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
|
-
|
|
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.
|
|
132
|
-
|
|
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
|
|
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
|
-
|
|
162
|
-
|
|
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 —
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
|
330
|
-
|
|
465
|
+
const {
|
|
466
|
+
Layout,
|
|
467
|
+
layoutEntry,
|
|
468
|
+
layoutSlots,
|
|
469
|
+
} = resolvePageLayoutRender(layoutsById, pageEntry)
|
|
331
470
|
const { language, messages, fallbackMessages } = resolveRequestLocale(pageRouter, requestUrl, buildDir, config)
|
|
332
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 {
|
|
409
|
-
export {
|
|
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
|
+
}
|