@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.
Files changed (65) hide show
  1. package/README.md +21 -17
  2. package/package.json +20 -11
  3. package/src/Definition.js +1 -0
  4. package/src/PlatformShell.jsx +10 -10
  5. package/src/actions/action.service.js +79 -6
  6. package/src/audit/action-invocation-service.js +144 -0
  7. package/src/audit/action-invocation.aggregate.js +59 -0
  8. package/src/audit/audit-helpers.js +61 -0
  9. package/src/audit/detect-channel.js +19 -0
  10. package/src/audit/list-task-runs.action.js +5 -0
  11. package/src/audit/list-task-runs.task.js +17 -0
  12. package/src/audit/task-run-list.aggregate.js +78 -0
  13. package/src/audit/task-run-service.js +193 -0
  14. package/src/audit/task-run.aggregate.js +60 -0
  15. package/src/auth/action-scopes.js +3 -0
  16. package/src/capability-schemas/action-capability.schema.js +1 -0
  17. package/src/capability-schemas/action-meta.schema.js +1 -0
  18. package/src/capability-schemas/component-capability.schema.js +1 -0
  19. package/src/capability-schemas/page-capability.schema.js +1 -0
  20. package/src/capability-schemas/page-meta.schema.js +1 -0
  21. package/src/capability-schemas/task-capability.schema.js +1 -0
  22. package/src/capability-schemas/task-meta.schema.js +1 -0
  23. package/src/capability-schemas/task-output.schema.js +1 -0
  24. package/src/capability-schemas/task-trigger-authoring.schema.js +1 -0
  25. package/src/capability-schemas/task-trigger.schema.js +1 -0
  26. package/src/directory.schema.js +9 -0
  27. package/src/entitlements/action-entitlement.js +69 -0
  28. package/src/file.schema.js +13 -0
  29. package/src/index.js +13 -3
  30. package/src/mcp/create-ossy-mcp-server.js +97 -0
  31. package/src/mcp/json-schema-to-zod.js +64 -0
  32. package/src/mcp/mount-ossy-mcp.js +72 -0
  33. package/src/mcp/mount-platform-mcp.js +101 -0
  34. package/src/mcp/upload-file-tool.js +62 -0
  35. package/src/metering/metering-service.js +92 -0
  36. package/src/{platform-config.resource.js → platform-config.schema.js} +1 -1
  37. package/src/proxy-internal.js +13 -16
  38. package/src/push/mount-push-sse.js +43 -0
  39. package/src/resources/index.js +3 -2
  40. package/src/resources/schema.registry.js +26 -0
  41. package/src/resources/schema.service.js +54 -0
  42. package/src/resources/schema.validation.js +90 -0
  43. package/src/runtime.js +4 -1
  44. package/src/schema-ids.js +9 -0
  45. package/src/server.js +121 -20
  46. package/src/storage/filesystem-storage.client.js +109 -0
  47. package/src/storage/local-storage.client.js +2 -65
  48. package/src/storage/resource-read-url.js +36 -0
  49. package/src/storage/resource-read-url.spec.js +22 -0
  50. package/src/storage/s3-storage.client.js +102 -0
  51. package/src/storage/s3.client.js +27 -23
  52. package/src/storage/storage-keys.js +37 -0
  53. package/src/storage/storage-keys.spec.js +16 -0
  54. package/src/storage/storage.client.js +52 -8
  55. package/src/storage/storage.integration.js +40 -0
  56. package/src/tasks/change-stream.js +9 -2
  57. package/src/tasks/task-service.js +211 -34
  58. package/src/tasks/task-service.spec.js +187 -0
  59. package/src/test/e2e.util.js +18 -39
  60. package/src/test/flow-runner.js +476 -0
  61. package/src/test/test.util.js +30 -29
  62. package/src/user-app-settings.js +44 -0
  63. package/src/users.middleware.js +14 -2
  64. package/src/resources/resource-template.registry.js +0 -29
  65. 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 { registerResourceTemplate } from './resources/resource-template.registry.js'
14
+ import { registerSchema } from './resources/schema.registry.js'
15
+ import { initPlatformSchema } from './resources/schema.service.js'
16
16
  import { IntegrationService } from './integration.service.js'
17
17
  import { ActionService } from './actions/action.service.js'
18
18
  import { createLogger } from '@ossy/observability'
@@ -20,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 resourceTemplates = Array.isArray(manifest.resourceTemplates) ? manifest.resourceTemplates : []
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
- resourceTemplates,
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.resourceTemplates ?? []) {
132
- registerResourceTemplate(template)
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 resource-template registration above.
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
- const payload = req.body?.payload ?? {}
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
- // Page component → `shell:content` is resolved in page-runtime (SSR + hydrate).
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
- resourceTemplates: cloneSerializable(manifest.resourceTemplates),
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 { getSystemResourceTemplates } from './resources/resource-template.registry.js'
409
- export { normalizeAndValidateDocumentContent, validateResourceTemplatesForImport } from './resources/resource-template.validation.js'
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
- import path from 'path'
2
- import os from 'os'
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
+ }