@ossy/platform 1.39.2 → 1.39.3
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 +1 -0
- package/src/PlatformShell.jsx +10 -10
- package/src/actions/action.service.js +79 -6
- package/src/audit/action-invocation-service.js +144 -0
- package/src/audit/action-invocation.aggregate.js +59 -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 +78 -0
- package/src/audit/task-run-service.js +193 -0
- package/src/audit/task-run.aggregate.js +60 -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 +9 -0
- package/src/entitlements/action-entitlement.js +69 -0
- package/src/file.schema.js +13 -0
- package/src/index.js +13 -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 +92 -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 +43 -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 +4 -1
- package/src/schema-ids.js +9 -0
- package/src/server.js +121 -20
- 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 +9 -2
- 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 +14 -2
- package/src/resources/resource-template.registry.js +0 -29
- package/src/resources/resource-template.validation.js +0 -232
package/src/server.js
CHANGED
|
@@ -10,9 +10,9 @@ import { SDK, resolveActionId } from '@ossy/sdk'
|
|
|
10
10
|
import { AggregateRebuild, 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,6 +20,18 @@ 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 { mountPushSse } from './push/mount-push-sse.js'
|
|
30
|
+
import { mergeWorkspaceSchemas } from '@ossy/workspaces/merge-workspace-schemas'
|
|
31
|
+
import {
|
|
32
|
+
buildEnableablePackageSet,
|
|
33
|
+
setEnableablePackages,
|
|
34
|
+
} from '@ossy/workspaces/entitlements'
|
|
23
35
|
const log = createLogger('@ossy/platform')
|
|
24
36
|
|
|
25
37
|
const DEFAULT_PORT = 3000
|
|
@@ -58,7 +70,7 @@ export function loadManifest (buildDir) {
|
|
|
58
70
|
const apis = entries.filter((e) => e.type === 'api' && validRoutable(e))
|
|
59
71
|
const tasks = entries.filter((e) => e.type === 'task')
|
|
60
72
|
const components = Array.isArray(manifest.components) ? manifest.components : []
|
|
61
|
-
const
|
|
73
|
+
const schemas = Array.isArray(manifest.schemas) ? manifest.schemas : []
|
|
62
74
|
const aggregates = Array.isArray(manifest.aggregates) ? manifest.aggregates : []
|
|
63
75
|
const integrations = Array.isArray(manifest.integrations) ? manifest.integrations : []
|
|
64
76
|
const startups = Array.isArray(manifest.startups) ? manifest.startups : []
|
|
@@ -75,7 +87,7 @@ export function loadManifest (buildDir) {
|
|
|
75
87
|
apis,
|
|
76
88
|
tasks,
|
|
77
89
|
components,
|
|
78
|
-
|
|
90
|
+
schemas,
|
|
79
91
|
aggregates,
|
|
80
92
|
integrations,
|
|
81
93
|
startups,
|
|
@@ -85,6 +97,9 @@ export function loadManifest (buildDir) {
|
|
|
85
97
|
config: manifest.config || {},
|
|
86
98
|
definitions: manifest.definitions || {},
|
|
87
99
|
translations: manifest.translations || {},
|
|
100
|
+
actionRoutes: manifest.actionRoutes || {},
|
|
101
|
+
taskCatalog: Array.isArray(manifest.taskCatalog) ? manifest.taskCatalog : [],
|
|
102
|
+
taskGraphEdges: Array.isArray(manifest.taskGraphEdges) ? manifest.taskGraphEdges : [],
|
|
88
103
|
}
|
|
89
104
|
}
|
|
90
105
|
|
|
@@ -114,12 +129,18 @@ export async function startServer (options = {}) {
|
|
|
114
129
|
|
|
115
130
|
const manifest = loadManifest(buildDir)
|
|
116
131
|
const manifestSummary = buildManifestSummary(manifest)
|
|
132
|
+
setEnableablePackages(buildEnableablePackageSet(manifest))
|
|
133
|
+
initPlatformSchema(manifest)
|
|
134
|
+
const capabilitiesPath = path.join(buildDir, 'capabilities.json')
|
|
135
|
+
const capabilities = fs.existsSync(capabilitiesPath)
|
|
136
|
+
? JSON.parse(fs.readFileSync(capabilitiesPath, 'utf8'))
|
|
137
|
+
: null
|
|
117
138
|
const config = manifest.config
|
|
118
139
|
const supportedLanguages = Array.isArray(config.supportedLanguages) ? config.supportedLanguages : []
|
|
119
140
|
const defaultLanguage = config.defaultLanguage
|
|
120
141
|
for (const task of manifest.tasks ?? []) {
|
|
121
142
|
try {
|
|
122
|
-
const mod = await import(resolveEntryUrl(task.entry, buildDir))
|
|
143
|
+
const mod = await import(/* @vite-ignore */ resolveEntryUrl(task.entry, buildDir))
|
|
123
144
|
if (mod.metadata) {
|
|
124
145
|
TaskService.registerTask(mod)
|
|
125
146
|
}
|
|
@@ -128,8 +149,8 @@ export async function startServer (options = {}) {
|
|
|
128
149
|
}
|
|
129
150
|
}
|
|
130
151
|
|
|
131
|
-
for (const template of manifest.
|
|
132
|
-
|
|
152
|
+
for (const template of manifest.schemas ?? []) {
|
|
153
|
+
registerSchema(template)
|
|
133
154
|
}
|
|
134
155
|
|
|
135
156
|
// Integration loading — import each bundled integration module, check its
|
|
@@ -139,19 +160,19 @@ export async function startServer (options = {}) {
|
|
|
139
160
|
const integrationModules = []
|
|
140
161
|
for (const intEntry of manifest.integrations ?? []) {
|
|
141
162
|
try {
|
|
142
|
-
integrationModules.push(await import(resolveEntryUrl(intEntry.entry, buildDir)))
|
|
163
|
+
integrationModules.push(await import(/* @vite-ignore */ resolveEntryUrl(intEntry.entry, buildDir)))
|
|
143
164
|
} catch (err) {
|
|
144
165
|
log.warn(`Failed to import integration "${intEntry.id}"`, undefined, err)
|
|
145
166
|
}
|
|
146
167
|
}
|
|
147
168
|
await IntegrationService.load(integrationModules, process.env)
|
|
148
169
|
|
|
149
|
-
// Aggregate registration — mirrors task and
|
|
170
|
+
// Aggregate registration — mirrors task and schema registration above.
|
|
150
171
|
// AggregateRebuild is provided by @ossy/event-store.
|
|
151
172
|
if (AggregateRebuild) {
|
|
152
173
|
for (const agg of manifest.aggregates ?? []) {
|
|
153
174
|
try {
|
|
154
|
-
const mod = await import(resolveEntryUrl(agg.entry, buildDir))
|
|
175
|
+
const mod = await import(/* @vite-ignore */ resolveEntryUrl(agg.entry, buildDir))
|
|
155
176
|
AggregateRebuild.registerAggregate(mod)
|
|
156
177
|
} catch (err) {
|
|
157
178
|
log.error(`Failed to load aggregate "${agg.id}"`, undefined, err)
|
|
@@ -165,7 +186,7 @@ export async function startServer (options = {}) {
|
|
|
165
186
|
|
|
166
187
|
for (const startup of manifest.startups ?? []) {
|
|
167
188
|
try {
|
|
168
|
-
const mod = await import(resolveEntryUrl(startup.entry, buildDir))
|
|
189
|
+
const mod = await import(/* @vite-ignore */ resolveEntryUrl(startup.entry, buildDir))
|
|
169
190
|
if (typeof mod.run === 'function') {
|
|
170
191
|
await mod.run({ env: process.env })
|
|
171
192
|
}
|
|
@@ -178,7 +199,7 @@ export async function startServer (options = {}) {
|
|
|
178
199
|
// with ActionService so it can be looked up and invoked at runtime.
|
|
179
200
|
for (const actionEntry of manifest.actions ?? []) {
|
|
180
201
|
try {
|
|
181
|
-
const mod = await import(resolveEntryUrl(actionEntry.entry, buildDir))
|
|
202
|
+
const mod = await import(/* @vite-ignore */ resolveEntryUrl(actionEntry.entry, buildDir))
|
|
182
203
|
ActionService.register(mod)
|
|
183
204
|
} catch (err) {
|
|
184
205
|
log.error(`Failed to load action "${actionEntry.id}"`, undefined, err)
|
|
@@ -198,7 +219,7 @@ export async function startServer (options = {}) {
|
|
|
198
219
|
const layoutEntry = layoutManifest[0]
|
|
199
220
|
if (layoutEntry) {
|
|
200
221
|
try {
|
|
201
|
-
const mod = await import(resolveEntryUrl(layoutEntry.entry, buildDir))
|
|
222
|
+
const mod = await import(/* @vite-ignore */ resolveEntryUrl(layoutEntry.entry, buildDir))
|
|
202
223
|
if (typeof mod.default === 'function') {
|
|
203
224
|
appLayout = { component: mod.default, entry: layoutEntry.entry }
|
|
204
225
|
}
|
|
@@ -237,11 +258,20 @@ export async function startServer (options = {}) {
|
|
|
237
258
|
const moduleCache = new Map()
|
|
238
259
|
async function loadEntry (entryUrl) {
|
|
239
260
|
if (moduleCache.has(entryUrl)) return moduleCache.get(entryUrl)
|
|
240
|
-
const promise = import(resolveEntryUrl(entryUrl, buildDir))
|
|
261
|
+
const promise = import(/* @vite-ignore */ resolveEntryUrl(entryUrl, buildDir))
|
|
241
262
|
moduleCache.set(entryUrl, promise)
|
|
242
263
|
return promise
|
|
243
264
|
}
|
|
244
265
|
|
|
266
|
+
const actionEntitlementIndex = buildActionEntitlementIndex(manifest)
|
|
267
|
+
let loadWorkspaceForEntitlements = null
|
|
268
|
+
try {
|
|
269
|
+
const { Workspace } = await import('@ossy/workspaces/server')
|
|
270
|
+
loadWorkspaceForEntitlements = createWorkspaceLoader(Workspace)
|
|
271
|
+
} catch (err) {
|
|
272
|
+
log.warn('[@ossy/platform] Workspace aggregate unavailable — action entitlements disabled', err)
|
|
273
|
+
}
|
|
274
|
+
|
|
245
275
|
const app = express()
|
|
246
276
|
app.use(morgan('tiny'))
|
|
247
277
|
app.use(express.json({ strict: false }))
|
|
@@ -261,11 +291,15 @@ export async function startServer (options = {}) {
|
|
|
261
291
|
if (fs.existsSync(publicDir)) app.use(express.static(publicDir))
|
|
262
292
|
app.use(ProxyInternal())
|
|
263
293
|
|
|
294
|
+
mountPushSse(app)
|
|
295
|
+
|
|
264
296
|
// Actions endpoint — POST /actions with body { action, payload }.
|
|
265
297
|
app.post('/actions', async (req, res) => {
|
|
266
298
|
const actionId = resolveActionId(req.body?.action)
|
|
267
299
|
if (!actionId) return res.status(400).json({ error: 'Missing action' })
|
|
268
300
|
|
|
301
|
+
const payload = req.body?.payload ?? {}
|
|
302
|
+
|
|
269
303
|
const action = ActionService.get(actionId)
|
|
270
304
|
if (!action) return res.status(404).json({ error: 'Action not found' })
|
|
271
305
|
|
|
@@ -276,7 +310,38 @@ export async function startServer (options = {}) {
|
|
|
276
310
|
return res.status(403).json({ error: 'Forbidden' })
|
|
277
311
|
}
|
|
278
312
|
|
|
279
|
-
|
|
313
|
+
if (!ActionService.hasHandler(actionId)) {
|
|
314
|
+
return res.status(405).json({ error: 'Action has no server handler' })
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (loadWorkspaceForEntitlements) {
|
|
318
|
+
try {
|
|
319
|
+
await assertActionEntitled({
|
|
320
|
+
actionId,
|
|
321
|
+
access: action.access,
|
|
322
|
+
workspaceId: req.workspaceId,
|
|
323
|
+
entitlementIndex: actionEntitlementIndex,
|
|
324
|
+
loadWorkspace: loadWorkspaceForEntitlements,
|
|
325
|
+
})
|
|
326
|
+
} catch (err) {
|
|
327
|
+
if (err?.code === 'SERVICE_NOT_ENTITLED') {
|
|
328
|
+
return res.status(403).json({
|
|
329
|
+
error: err.message,
|
|
330
|
+
code: err.code,
|
|
331
|
+
package: err.package,
|
|
332
|
+
})
|
|
333
|
+
}
|
|
334
|
+
if (err?.code === 'ACTION_NOT_PERMITTED') {
|
|
335
|
+
return res.status(403).json({
|
|
336
|
+
error: err.message,
|
|
337
|
+
code: err.code,
|
|
338
|
+
})
|
|
339
|
+
}
|
|
340
|
+
const status = err?.status ?? 500
|
|
341
|
+
return res.status(status).json({ error: err.message ?? 'Forbidden' })
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
280
345
|
const actionLog = createLogger(actionId)
|
|
281
346
|
try {
|
|
282
347
|
const result = await ActionService.invoke(actionId, {
|
|
@@ -294,6 +359,13 @@ export async function startServer (options = {}) {
|
|
|
294
359
|
}
|
|
295
360
|
})
|
|
296
361
|
|
|
362
|
+
const resolvedCapabilities = mountPlatformMcp(app, {
|
|
363
|
+
manifest,
|
|
364
|
+
capabilities,
|
|
365
|
+
actionEntitlementIndex,
|
|
366
|
+
loadWorkspaceForEntitlements,
|
|
367
|
+
})
|
|
368
|
+
|
|
297
369
|
app.all('*all', async (req, res) => {
|
|
298
370
|
const requestUrl = req.originalUrl || '/'
|
|
299
371
|
try {
|
|
@@ -329,14 +401,32 @@ export async function startServer (options = {}) {
|
|
|
329
401
|
const Layout = appLayout?.component ?? null
|
|
330
402
|
const layoutEntry = appLayout?.entry ?? null
|
|
331
403
|
const { language, messages, fallbackMessages } = resolveRequestLocale(pageRouter, requestUrl, buildDir, config)
|
|
332
|
-
|
|
404
|
+
let schemasForPage = cloneSerializable(manifest.schemas || [])
|
|
405
|
+
if (req.workspaceId && loadWorkspaceForEntitlements) {
|
|
406
|
+
try {
|
|
407
|
+
const workspace = await loadWorkspaceForEntitlements(req.workspaceId)
|
|
408
|
+
if (workspace?.schemas?.length) {
|
|
409
|
+
schemasForPage = mergeWorkspaceSchemas(
|
|
410
|
+
schemasForPage,
|
|
411
|
+
cloneSerializable(workspace.schemas),
|
|
412
|
+
)
|
|
413
|
+
}
|
|
414
|
+
} catch (err) {
|
|
415
|
+
log.warn('[@ossy/platform] Failed to merge workspace schemas for SSR', err)
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
// Page component → `app:content` is resolved in page-runtime (SSR + hydrate).
|
|
333
419
|
const props = {
|
|
334
420
|
...config,
|
|
335
421
|
...(req.userAppSettings || {}),
|
|
336
422
|
theme: req.userAppSettings?.theme || cloneSerializable(config?.theme),
|
|
337
423
|
themes: cloneSerializable(config?.themes),
|
|
338
|
-
|
|
424
|
+
schemas: schemasForPage,
|
|
339
425
|
manifestSummary: cloneSerializable(manifestSummary),
|
|
426
|
+
capabilities: cloneSerializable(resolvedCapabilities),
|
|
427
|
+
actionRoutes: cloneSerializable(manifest.actionRoutes || {}),
|
|
428
|
+
taskCatalog: cloneSerializable(manifest.taskCatalog || []),
|
|
429
|
+
taskGraphEdges: cloneSerializable(manifest.taskGraphEdges || []),
|
|
340
430
|
url: requestUrl,
|
|
341
431
|
isAuthenticated: !!req.isAuthenticated,
|
|
342
432
|
language,
|
|
@@ -350,7 +440,6 @@ export async function startServer (options = {}) {
|
|
|
350
440
|
pageId: pageRoute.id,
|
|
351
441
|
componentEntries: manifest.components,
|
|
352
442
|
layoutSlots,
|
|
353
|
-
contentSlotName: CONTENT_SLOT_NAME,
|
|
354
443
|
Layout,
|
|
355
444
|
layoutEntry,
|
|
356
445
|
}
|
|
@@ -403,7 +492,19 @@ export { ConfigService } from './config.service.js'
|
|
|
403
492
|
export { ActionService } from './actions/action.service.js'
|
|
404
493
|
export { IntegrationService } from './integration.service.js'
|
|
405
494
|
export { StorageClient } from './storage/storage.client.js'
|
|
495
|
+
export { originalObjectKey, derivativeObjectKey, assertStorageKey } from './storage/storage-keys.js'
|
|
406
496
|
export { S3Client } from './storage/s3.client.js'
|
|
407
497
|
export { LocalStorageClient } from './storage/local-storage.client.js'
|
|
408
|
-
export {
|
|
409
|
-
export {
|
|
498
|
+
export { getSystemSchemas } from './resources/schema.registry.js'
|
|
499
|
+
export { PlatformSchema } from './schema-ids.js'
|
|
500
|
+
export { getPlatformSchema, initPlatformSchema, createSchemaEngine, schemaForWorkspace } from './resources/schema.service.js'
|
|
501
|
+
export { validateSchemasForImport } from './resources/schema.validation.js'
|
|
502
|
+
export {
|
|
503
|
+
USER_SETTINGS_COOKIE,
|
|
504
|
+
AUTH_COOKIE,
|
|
505
|
+
readUserAppSettings,
|
|
506
|
+
mergeUserAppSettingsCookie,
|
|
507
|
+
clearWorkspaceFromUserAppSettings,
|
|
508
|
+
setAuthCookie,
|
|
509
|
+
clearAuthCookie,
|
|
510
|
+
} 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
|
+
}
|
|
@@ -1,65 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
import { mkdir, writeFile, readFile, stat } from 'fs/promises'
|
|
4
|
-
import { existsSync } from 'fs'
|
|
5
|
-
|
|
6
|
-
const LOCAL_STORAGE_DIR = path.join(os.tmpdir(), 'ossy-local-media')
|
|
7
|
-
|
|
8
|
-
function normalizeKey(keyOrShape) {
|
|
9
|
-
const Key = typeof keyOrShape === 'string' ? keyOrShape : keyOrShape?.Key
|
|
10
|
-
if (!Key || typeof Key !== 'string') {
|
|
11
|
-
throw new Error('[LocalStorageClient] missing Key')
|
|
12
|
-
}
|
|
13
|
-
return Key
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
function keyToLocalPath(key) {
|
|
17
|
-
const resolved = path.resolve(LOCAL_STORAGE_DIR, key)
|
|
18
|
-
if (!resolved.startsWith(LOCAL_STORAGE_DIR)) {
|
|
19
|
-
throw new Error('[LocalStorageClient] invalid key: path traversal detected')
|
|
20
|
-
}
|
|
21
|
-
return resolved
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
function buildUrl(key) {
|
|
25
|
-
const base = (process.env.LOCAL_API_URL || 'http://localhost:3001').replace(/\/$/, '')
|
|
26
|
-
return `${base}/local-storage?key=${encodeURIComponent(key)}`
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export class LocalStorageClient {
|
|
30
|
-
|
|
31
|
-
static createUploadUrl({ Key }) {
|
|
32
|
-
return Promise.resolve(buildUrl(Key))
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
static createPresignedDownloadUrl(keyOrShape) {
|
|
36
|
-
const Key = normalizeKey(keyOrShape)
|
|
37
|
-
return Promise.resolve(buildUrl(Key))
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
static createDownloadUrl(keyOrShape) {
|
|
41
|
-
const Key = normalizeKey(keyOrShape)
|
|
42
|
-
return buildUrl(Key)
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
static async save(key, body) {
|
|
46
|
-
const localPath = keyToLocalPath(key)
|
|
47
|
-
await mkdir(path.dirname(localPath), { recursive: true })
|
|
48
|
-
await writeFile(localPath, body)
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
static async load(key) {
|
|
52
|
-
const localPath = keyToLocalPath(key)
|
|
53
|
-
if (!existsSync(localPath)) return { buffer: null, exists: false }
|
|
54
|
-
const buffer = await readFile(localPath)
|
|
55
|
-
return { buffer, exists: true }
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
static async headObject({ Key }) {
|
|
59
|
-
const localPath = keyToLocalPath(Key)
|
|
60
|
-
const s = await stat(localPath).catch(() => null)
|
|
61
|
-
if (!s) throw Object.assign(new Error('Not Found'), { $metadata: { httpStatusCode: 404 } })
|
|
62
|
-
return { ContentLength: s.size }
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
}
|
|
1
|
+
/** @deprecated Import from filesystem-storage.client.js */
|
|
2
|
+
export { LocalStorageClient, createFilesystemStorageClient, resolveFilesystemStorageRoot } from './filesystem-storage.client.js'
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { assertStorageKey } from './storage-keys.js'
|
|
2
|
+
|
|
3
|
+
function normalizeLogicalKey (keyOrShape) {
|
|
4
|
+
const Key = typeof keyOrShape === 'string' ? keyOrShape : keyOrShape?.Key
|
|
5
|
+
assertStorageKey(Key)
|
|
6
|
+
return Key
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Predictable app-relative read path for a storage key.
|
|
11
|
+
* Original: `/r/{resourceId}` — derivative: `/r/{resourceId}/{variant}`.
|
|
12
|
+
*
|
|
13
|
+
* @param {string | { Key?: string }} keyOrShape
|
|
14
|
+
* @returns {string}
|
|
15
|
+
*/
|
|
16
|
+
export function storageKeyToReadPath (keyOrShape) {
|
|
17
|
+
const key = normalizeLogicalKey(keyOrShape)
|
|
18
|
+
const colon = key.indexOf(':')
|
|
19
|
+
if (colon === -1) return `/r/${key}`
|
|
20
|
+
const resourceId = key.slice(0, colon)
|
|
21
|
+
const variant = key.slice(colon + 1)
|
|
22
|
+
return `/r/${resourceId}/${variant}`
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Absolute resource read URL (same shape for filesystem and S3 backends).
|
|
27
|
+
*
|
|
28
|
+
* @param {string | { Key?: string }} keyOrShape
|
|
29
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
30
|
+
* @returns {string}
|
|
31
|
+
*/
|
|
32
|
+
export function createResourceReadUrl (keyOrShape, env = process.env) {
|
|
33
|
+
const path = storageKeyToReadPath(keyOrShape)
|
|
34
|
+
const base = (env.WEB_CLIENT_DOMAIN || env.LOCAL_API_URL || '').replace(/\/$/, '')
|
|
35
|
+
return base ? `${base}${path}` : path
|
|
36
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, expect, it } from '@jest/globals'
|
|
2
|
+
import { createResourceReadUrl, storageKeyToReadPath } from './resource-read-url.js'
|
|
3
|
+
|
|
4
|
+
describe('resource-read-url', () => {
|
|
5
|
+
it('maps original key to /r/{resourceId}', () => {
|
|
6
|
+
expect(storageKeyToReadPath('abc123XYZ')).toBe('/r/abc123XYZ')
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
it('maps derivative key to /r/{resourceId}/{variant}', () => {
|
|
10
|
+
expect(storageKeyToReadPath('abc123:galleryLarge')).toBe('/r/abc123/galleryLarge')
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('builds absolute URL from WEB_CLIENT_DOMAIN', () => {
|
|
14
|
+
expect(createResourceReadUrl('abc123', { WEB_CLIENT_DOMAIN: 'https://app.example.com' }))
|
|
15
|
+
.toBe('https://app.example.com/r/abc123')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('falls back to LOCAL_API_URL', () => {
|
|
19
|
+
expect(createResourceReadUrl('abc123', { LOCAL_API_URL: 'http://localhost:3006' }))
|
|
20
|
+
.toBe('http://localhost:3006/r/abc123')
|
|
21
|
+
})
|
|
22
|
+
})
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { S3, PutObjectCommand, GetObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3'
|
|
2
|
+
import { S3RequestPresigner } from '@aws-sdk/s3-request-presigner'
|
|
3
|
+
import { createRequest } from '@aws-sdk/util-create-request'
|
|
4
|
+
import { formatUrl } from '@aws-sdk/util-format-url'
|
|
5
|
+
import { createLogger } from '@ossy/observability'
|
|
6
|
+
import { assertStorageKey } from './storage-keys.js'
|
|
7
|
+
import { createResourceReadUrl } from './resource-read-url.js'
|
|
8
|
+
|
|
9
|
+
const log = createLogger('storage')
|
|
10
|
+
|
|
11
|
+
const REGION = 'eu-north-1'
|
|
12
|
+
|
|
13
|
+
/** S3/CloudFront origin uses `/media` — flat keys become `media/{resourceId}`. */
|
|
14
|
+
const S3_KEY_PREFIX = 'media/'
|
|
15
|
+
|
|
16
|
+
function normalizeLogicalKey (keyOrShape) {
|
|
17
|
+
const Key = typeof keyOrShape === 'string' ? keyOrShape : keyOrShape?.Key
|
|
18
|
+
assertStorageKey(Key)
|
|
19
|
+
return Key
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function toS3Key (logicalKey) {
|
|
23
|
+
return `${S3_KEY_PREFIX}${logicalKey}`
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function createS3StorageClient ({
|
|
27
|
+
bucket,
|
|
28
|
+
cdnDomainName,
|
|
29
|
+
accessKeyId,
|
|
30
|
+
secretAccessKey,
|
|
31
|
+
region = REGION,
|
|
32
|
+
} = {}) {
|
|
33
|
+
if (!bucket) {
|
|
34
|
+
throw new Error('[S3Storage] bucket is required')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function createAwsClient () {
|
|
38
|
+
return new S3({
|
|
39
|
+
region,
|
|
40
|
+
credentials: {
|
|
41
|
+
accessKeyId,
|
|
42
|
+
secretAccessKey,
|
|
43
|
+
},
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
backend: 's3',
|
|
49
|
+
bucket,
|
|
50
|
+
|
|
51
|
+
createDownloadUrl (keyOrShape) {
|
|
52
|
+
return createResourceReadUrl(normalizeLogicalKey(keyOrShape))
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
createPresignedDownloadUrl (keyOrShape) {
|
|
56
|
+
return Promise.resolve(createResourceReadUrl(normalizeLogicalKey(keyOrShape)))
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
createUploadUrl ({ ContentType, ContentLength, Key, expiresIn = 60 * 60 * 24 }) {
|
|
60
|
+
log.info('[S3Storage][createUploadUrl] Creating upload URL')
|
|
61
|
+
|
|
62
|
+
const clientParams = {
|
|
63
|
+
Bucket: bucket,
|
|
64
|
+
Key: toS3Key(normalizeLogicalKey({ Key })),
|
|
65
|
+
ContentType,
|
|
66
|
+
ContentLength,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const s3Client = createAwsClient()
|
|
70
|
+
const signedRequest = new S3RequestPresigner(s3Client.config)
|
|
71
|
+
|
|
72
|
+
return createRequest(s3Client, new PutObjectCommand(clientParams))
|
|
73
|
+
.then(request => signedRequest.presign(request, { expiresIn }))
|
|
74
|
+
.then(formatUrl)
|
|
75
|
+
.catch(error => {
|
|
76
|
+
log.error('S3Storage.createUploadUrl error', undefined, error)
|
|
77
|
+
return Promise.reject(error)
|
|
78
|
+
})
|
|
79
|
+
},
|
|
80
|
+
|
|
81
|
+
headObject ({ Key }) {
|
|
82
|
+
const s3Client = createAwsClient()
|
|
83
|
+
return s3Client.send(new HeadObjectCommand({ Bucket: bucket, Key: toS3Key(normalizeLogicalKey({ Key })) }))
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
async load (keyOrShape) {
|
|
87
|
+
const Key = toS3Key(normalizeLogicalKey(keyOrShape))
|
|
88
|
+
const s3Client = createAwsClient()
|
|
89
|
+
try {
|
|
90
|
+
const response = await s3Client.send(new GetObjectCommand({ Bucket: bucket, Key }))
|
|
91
|
+
const bytes = await response.Body?.transformToByteArray?.()
|
|
92
|
+
if (!bytes) return { buffer: null, exists: false }
|
|
93
|
+
return { buffer: Buffer.from(bytes), exists: true }
|
|
94
|
+
} catch (err) {
|
|
95
|
+
if (err?.$metadata?.httpStatusCode === 404 || err?.name === 'NoSuchKey') {
|
|
96
|
+
return { buffer: null, exists: false }
|
|
97
|
+
}
|
|
98
|
+
throw err
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
}
|
|
102
|
+
}
|