@ossy/platform 1.32.0 → 1.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ossy/platform",
3
- "version": "1.32.0",
3
+ "version": "1.34.0",
4
4
  "description": "Ossy application server runtime",
5
5
  "repository": {
6
6
  "type": "git",
@@ -29,11 +29,15 @@
29
29
  "author": "Ossy <yourfriends@ossy.se> (https://ossy.se)",
30
30
  "license": "MIT",
31
31
  "dependencies": {
32
+ "@aws-sdk/client-s3": "^3.1057.0",
32
33
  "@aws-sdk/client-ses": "^3.0.0",
33
- "@ossy/event-store": "^1.1.0",
34
- "@ossy/observability": "^1.1.0",
35
- "@ossy/router": "^1.33.0",
36
- "@ossy/sdk": "^1.33.0",
34
+ "@aws-sdk/s3-request-presigner": "^3.1057.0",
35
+ "@aws-sdk/util-create-request": "^3.972.26",
36
+ "@aws-sdk/util-format-url": "^3.972.17",
37
+ "@ossy/event-store": "^1.3.0",
38
+ "@ossy/observability": "^1.3.0",
39
+ "@ossy/router": "^1.35.0",
40
+ "@ossy/sdk": "^1.35.0",
37
41
  "cookie-parser": "^1.4.7",
38
42
  "dotenv": ">=16.0.0 <18.0.0",
39
43
  "express": ">=5.0.0 <6.0.0",
@@ -44,5 +48,5 @@
44
48
  "src",
45
49
  "Dockerfile"
46
50
  ],
47
- "gitHead": "9a8a1bb0466d35001425d241d77c2c9ab31e11d3"
51
+ "gitHead": "c6697078268867d88553ca0bac08faad5cea1546"
48
52
  }
@@ -0,0 +1,73 @@
1
+ import { createLogger } from '@ossy/observability'
2
+
3
+ const log = createLogger('platform/actions')
4
+
5
+ /** @type {Map<string, { id: string, access: string, run: Function }>} */
6
+ const _actions = new Map()
7
+
8
+ /**
9
+ * Registry and invoker for `*.action.js` command handlers.
10
+ *
11
+ * Actions are named, discoverable functions exposed at `POST /actions/:id`.
12
+ * Each action exports:
13
+ * - `id` {string} — unique slug, e.g. `'authentication/request-sign-in'`
14
+ * - `access` {string} — `'public'` | `'authenticated'` | `'workspace'` (default `'authenticated'`)
15
+ * - `run` {Function} — async handler receiving `{ payload, sdk, log, integrations, req }`
16
+ */
17
+ export const ActionService = {
18
+ /**
19
+ * Register an action module (as imported from a bundled entry).
20
+ * Validates that `id` is a non-empty string and `run` is a function.
21
+ *
22
+ * @param {{ id: string, access?: string, run: Function }} mod
23
+ */
24
+ register (mod) {
25
+ const { id, run, access = 'authenticated' } = mod ?? {}
26
+
27
+ if (typeof id !== 'string' || id.trim() === '') {
28
+ throw new Error(`[ActionService] Action module must export a non-empty string "id" (got ${JSON.stringify(id)})`)
29
+ }
30
+ if (typeof run !== 'function') {
31
+ throw new Error(`[ActionService] Action "${id}" must export a "run" function`)
32
+ }
33
+
34
+ if (_actions.has(id)) {
35
+ log.warn(`[ActionService] Action "${id}" already registered — overwriting`)
36
+ }
37
+
38
+ _actions.set(id, { id, access, run })
39
+ log.info(`[ActionService] Registered action "${id}" (access: ${access})`)
40
+ },
41
+
42
+ /**
43
+ * Look up a registered action by id. Returns `null` when not found.
44
+ *
45
+ * @param {string} id
46
+ * @returns {{ id: string, access: string, run: Function } | null}
47
+ */
48
+ get (id) {
49
+ return _actions.get(id) ?? null
50
+ },
51
+
52
+ /**
53
+ * Returns all registered actions.
54
+ *
55
+ * @returns {{ id: string, access: string, run: Function }[]}
56
+ */
57
+ all () {
58
+ return [..._actions.values()]
59
+ },
60
+
61
+ /**
62
+ * Invoke an action by id, forwarding the provided context.
63
+ *
64
+ * @param {string} id
65
+ * @param {{ payload?: unknown, sdk?: unknown, log?: unknown, integrations?: unknown, req?: unknown }} context
66
+ * @returns {Promise<unknown>}
67
+ */
68
+ async invoke (id, context = {}) {
69
+ const action = _actions.get(id)
70
+ if (!action) throw new Error(`[ActionService] Action not found: "${id}"`)
71
+ return action.run(context)
72
+ },
73
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * API Configuration
3
+ * @class
4
+ */
5
+ export class ConfigService {
6
+
7
+ static Port = process.env.BE_PORT || 3000
8
+ static Domain = process.env.DOMAIN || `localhost:${ConfigService.Port}`
9
+ static WebClientDomain = process.env.WEB_CLIENT_DOMAIN || `http://localhost:3000`
10
+ static MongoUrl = process.env.DB_URL || 'mongodb://mongodb:27017/'
11
+ static DbName = process.env.DB_NAME || 'test'
12
+ static TokenSecret = process.env.TOKEN_SECRET || 'testsecret2'
13
+ static TokenValidity = process.env.TOKEN_VALIDITY || 60 * 60 * 24 * 14
14
+ static LimitedAccessCode = process.env.LIMITED_ACCESS_CODE || 'test'
15
+ static BuildEnvironment = process.env.BUILD_ENVIRONMENT
16
+ static BotUserEmail = process.env.BOT_USER_EMAIL || 'ossybot@ossy.se'
17
+ static BotUserId = process.env.BOT_USER_ID || 'Mil5qAL7jDFCTyuKD_BKb'
18
+ /** Set on `req.userId` when there is no valid session; role checks can treat this as unauthenticated. */
19
+ static AnonymousUserId = process.env.ANONYMOUS_USER_ID || 'anonymous'
20
+ static MediaRepository = process.env.MEDIA_REPOSITORY
21
+ static MediaCdnDomainName = process.env.MEDIA_CDN_DOMAIN_NAME
22
+ static awsAccessKeyId = process.env.AWS_ACCESS_KEY_ID
23
+ static awsSecretAccessKey = process.env.AWS_SECRET_ACCESS_KEY
24
+ static SesRegion = process.env.SES_REGION || 'eu-north-1'
25
+ static Debug = process.env.DEBUG
26
+
27
+ /**
28
+ * Derives the web client base URL from the incoming request's Origin or Referer
29
+ * header so that email links point back to whichever domain the user came from
30
+ * (e.g. http://ossy.local, http://localhost:3002). Falls back to the
31
+ * WEB_CLIENT_DOMAIN env var / default when no header is present.
32
+ */
33
+ static getWebClientBaseUrl(req) {
34
+ const origin = req?.headers?.origin
35
+ if (origin && origin !== 'null') return origin
36
+
37
+ const referer = req?.headers?.referer
38
+ if (referer) {
39
+ try {
40
+ const { origin: refOrigin } = new URL(referer)
41
+ if (refOrigin && refOrigin !== 'null') return refOrigin
42
+ } catch {}
43
+ }
44
+
45
+ return ConfigService.WebClientDomain
46
+ }
47
+
48
+ }
package/src/index.js CHANGED
@@ -5,3 +5,5 @@ export { registerResourceTemplate, getSystemResourceTemplates } from './resource
5
5
  export { normalizeAndValidateDocumentContent, validateResourceTemplatesForImport, ALLOWED_FIELD_TYPES } from './resources/resource-template.validation.js'
6
6
  export { Definition } from './Definition.js'
7
7
  export { IntegrationService } from './integration.service.js'
8
+ export { ConfigService } from './config.service.js'
9
+ export { ActionService } from './actions/action.service.js'
package/src/server.js CHANGED
@@ -12,6 +12,7 @@ import { TaskService } from './tasks/task-service.js'
12
12
  import { ChangeStream } from './tasks/change-stream.js'
13
13
  import { registerResourceTemplate } from './resources/resource-template.registry.js'
14
14
  import { IntegrationService } from './integration.service.js'
15
+ import { ActionService } from './actions/action.service.js'
15
16
  import { createLogger } from '@ossy/observability'
16
17
 
17
18
  const log = createLogger('@ossy/platform')
@@ -56,6 +57,7 @@ export function loadManifest (buildDir) {
56
57
  const aggregates = Array.isArray(manifest.aggregates) ? manifest.aggregates : []
57
58
  const integrations = Array.isArray(manifest.integrations) ? manifest.integrations : []
58
59
  const startups = Array.isArray(manifest.startups) ? manifest.startups : []
60
+ const actions = Array.isArray(manifest.actions) ? manifest.actions : []
59
61
  for (const e of entries) {
60
62
  if ((e.type === 'page' || e.type === 'api') && !validRoutable(e)) {
61
63
  log.warn(`Skipping ${e.type} "${e.id}" — manifest entry has no path.`)
@@ -71,6 +73,7 @@ export function loadManifest (buildDir) {
71
73
  aggregates,
72
74
  integrations,
73
75
  startups,
76
+ actions,
74
77
  config: manifest.config || {},
75
78
  }
76
79
  }
@@ -159,6 +162,17 @@ export async function startServer (options = {}) {
159
162
  }
160
163
  }
161
164
 
165
+ // Action registration — import each bundled action module and register it
166
+ // with ActionService so it can be looked up and invoked at runtime.
167
+ for (const actionEntry of manifest.actions ?? []) {
168
+ try {
169
+ const mod = await import(resolveEntryUrl(actionEntry.entry, buildDir))
170
+ ActionService.register(mod)
171
+ } catch (err) {
172
+ log.error(`Failed to load action "${actionEntry.id}"`, undefined, err)
173
+ }
174
+ }
175
+
162
176
  // Register the SDK so all tasks receive it as `sdk`.
163
177
  // Priority: explicit options.sdk → SDK.of() from env vars → null (direct-DB fallback in tasks).
164
178
  const botSdk = (process.env.API_URL && process.env.OSSY_API_KEY)
@@ -210,6 +224,36 @@ export async function startServer (options = {}) {
210
224
  if (fs.existsSync(publicDir)) app.use(express.static(publicDir))
211
225
  app.use(ProxyInternal())
212
226
 
227
+ // Actions endpoint — auto-exposes every registered `*.action.js` handler.
228
+ // Access rules are enforced here; business logic lives in the action's `run`.
229
+ app.post('/actions/:id', async (req, res) => {
230
+ const actionId = req.params.id
231
+ const action = ActionService.get(actionId)
232
+ if (!action) return res.status(404).json({ error: 'Action not found' })
233
+
234
+ if (action.access === 'authenticated' && (!req.userId || req.userId === 'anonymous')) {
235
+ return res.status(401).json({ error: 'Unauthorized' })
236
+ }
237
+ if (action.access === 'workspace' && !req.workspaceId) {
238
+ return res.status(403).json({ error: 'Forbidden' })
239
+ }
240
+
241
+ const actionLog = createLogger(actionId)
242
+ try {
243
+ const result = await ActionService.invoke(actionId, {
244
+ payload: req.body,
245
+ sdk: req.sdk ?? null,
246
+ log: actionLog,
247
+ integrations: IntegrationService,
248
+ req,
249
+ })
250
+ res.json(result ?? { ok: true })
251
+ } catch (err) {
252
+ actionLog.error('Action failed', { id: actionId }, err)
253
+ res.status(500).json({ error: err && err.message ? err.message : 'Internal error' })
254
+ }
255
+ })
256
+
213
257
  app.all('*all', async (req, res) => {
214
258
  const requestUrl = req.originalUrl || '/'
215
259
  try {
@@ -314,3 +358,8 @@ async function loadMiddleware (buildDir) {
314
358
  }
315
359
 
316
360
  export default startServer
361
+ export { ConfigService } from './config.service.js'
362
+ export { ActionService } from './actions/action.service.js'
363
+ export { StorageClient } from './storage/storage.client.js'
364
+ export { S3Client } from './storage/s3.client.js'
365
+ export { LocalStorageClient } from './storage/local-storage.client.js'
@@ -0,0 +1,65 @@
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
+ }
@@ -0,0 +1,100 @@
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 { ConfigService } from '../config.service.js'
6
+ import { createLogger } from '@ossy/observability'
7
+
8
+ const log = createLogger('storage')
9
+
10
+ const REGION = 'eu-north-1'
11
+
12
+ function createS3Client() {
13
+ return new S3({
14
+ region: REGION,
15
+ credentials: {
16
+ accessKeyId: ConfigService.awsAccessKeyId,
17
+ secretAccessKey: ConfigService.awsSecretAccessKey
18
+ }
19
+ })
20
+ }
21
+
22
+ function normalizeKey(keyOrShape) {
23
+ const Key = typeof keyOrShape === 'string' ? keyOrShape : keyOrShape?.Key
24
+ if (!Key || typeof Key !== 'string') {
25
+ throw new Error('[S3Client] missing Key')
26
+ }
27
+ return Key
28
+ }
29
+
30
+ export class S3Client {
31
+
32
+ /**
33
+ * Public CDN URL (CloudFront) for objects under `media/` — use only when `access === 'public'`.
34
+ */
35
+ static createDownloadUrl(keyOrShape) {
36
+ const Key = normalizeKey(keyOrShape)
37
+ const pathAfterMedia = Key.includes('media/') ? Key.split('media/')[1] : Key
38
+ const base = ConfigService.MediaCdnDomainName || ''
39
+ return `${base.replace(/\/$/, '')}/${pathAfterMedia}`
40
+ }
41
+
42
+ /**
43
+ * Short-lived presigned GET for private bucket reads (restricted resources, workspace members).
44
+ */
45
+ static createPresignedDownloadUrl(keyOrShape, { expiresIn = 15 * 60 } = {}) {
46
+ const Key = normalizeKey(keyOrShape)
47
+ const Bucket = ConfigService.MediaRepository
48
+ if (!Bucket) {
49
+ return Promise.reject(new Error('[S3Client] MEDIA_REPOSITORY is not configured'))
50
+ }
51
+
52
+ const s3Client = createS3Client()
53
+ const signedRequest = new S3RequestPresigner(s3Client.config)
54
+
55
+ return createRequest(s3Client, new GetObjectCommand({ Bucket, Key }))
56
+ .then(request => signedRequest.presign(request, { expiresIn }))
57
+ .then(formatUrl)
58
+ .catch(error => {
59
+ log.error('S3Client.createPresignedDownloadUrl error', undefined, error)
60
+ return Promise.reject(error)
61
+ })
62
+ }
63
+
64
+ static createUploadUrl({ ContentType, ContentLength, Key, Bucket, expiresIn = 60 * 60 * 24 }) {
65
+ log.info('[S3Client][createUploadUrl()] Creating upload URL')
66
+
67
+ const bucket = Bucket ?? ConfigService.MediaRepository
68
+ if (!bucket) {
69
+ return Promise.reject(new Error('[S3Client] Bucket is not configured'))
70
+ }
71
+
72
+ const clientParams = {
73
+ Bucket: bucket,
74
+ Key,
75
+ ContentType,
76
+ ContentLength
77
+ }
78
+
79
+ const s3Client = createS3Client()
80
+ const signedRequest = new S3RequestPresigner(s3Client.config)
81
+
82
+ return createRequest(s3Client, new PutObjectCommand(clientParams))
83
+ .then(request => signedRequest.presign(request, { expiresIn }))
84
+ .then(formatUrl)
85
+ .catch(error => {
86
+ log.error('S3Client.createUploadUrl error', undefined, error)
87
+ return Promise.reject(error)
88
+ })
89
+ }
90
+
91
+ static headObject({ Key, Bucket }) {
92
+ const bucket = Bucket ?? ConfigService.MediaRepository
93
+ if (!bucket) {
94
+ return Promise.reject(new Error('[S3Client] Bucket is not configured'))
95
+ }
96
+ const s3Client = createS3Client()
97
+ return s3Client.send(new HeadObjectCommand({ Bucket: bucket, Key }))
98
+ }
99
+
100
+ }
@@ -0,0 +1,12 @@
1
+ import { S3Client } from './s3.client.js'
2
+ import { LocalStorageClient } from './local-storage.client.js'
3
+ import { ConfigService } from '../config.service.js'
4
+
5
+ /**
6
+ * Active storage backend.
7
+ *
8
+ * - When `MEDIA_REPOSITORY` is set (production): delegates to S3.
9
+ * - When `MEDIA_REPOSITORY` is unset (local dev): delegates to LocalStorageClient,
10
+ * which stores files in /tmp/ossy-local-media/ and serves them via GET /local-storage.
11
+ */
12
+ export const StorageClient = ConfigService.MediaRepository ? S3Client : LocalStorageClient
@@ -1,26 +0,0 @@
1
- import { SESClient } from '@aws-sdk/client-ses'
2
-
3
- export const id = 'email'
4
-
5
- export const credentials = [
6
- 'SES_REGION',
7
- 'SES_ACCESS_KEY_ID',
8
- 'SES_SECRET_ACCESS_KEY',
9
- ]
10
-
11
- /**
12
- * Returns an AWS SES client configured from env vars.
13
- * Tasks receive this via `integrations.get('email')`.
14
- *
15
- * @param {{ env: NodeJS.ProcessEnv }} opts
16
- * @returns {SESClient}
17
- */
18
- export async function connect ({ env }) {
19
- return new SESClient({
20
- region: env.SES_REGION,
21
- credentials: {
22
- accessKeyId: env.SES_ACCESS_KEY_ID,
23
- secretAccessKey: env.SES_SECRET_ACCESS_KEY,
24
- },
25
- })
26
- }