@ossy/platform 3.20.1 → 3.22.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": "3.20.1",
3
+ "version": "3.22.0",
4
4
  "description": "Ossy application server runtime",
5
5
  "repository": {
6
6
  "type": "git",
@@ -43,11 +43,13 @@
43
43
  "dependencies": {
44
44
  "@aws-sdk/client-s3": "^3.1057.0",
45
45
  "@aws-sdk/client-ses": "^3.0.0",
46
+ "@aws-sdk/client-sqs": "^3.1103.0",
46
47
  "@aws-sdk/s3-request-presigner": "^3.1057.0",
47
48
  "@aws-sdk/util-create-request": "^3.972.26",
48
49
  "@aws-sdk/util-format-url": "^3.972.17",
49
50
  "@modelcontextprotocol/sdk": "^1.12.1",
50
51
  "@ossy/config": "^3.0.9",
52
+ "@ossy/email": "^3.22.0",
51
53
  "@ossy/event-store": "^3.20.1",
52
54
  "@ossy/locale": "^3.4.0",
53
55
  "@ossy/manifest": "^3.20.1",
@@ -58,7 +60,7 @@
58
60
  "@ossy/sdk": "^3.20.1",
59
61
  "@ossy/tokens": "^3.11.1",
60
62
  "@ossy/users": "^3.20.1",
61
- "@ossy/workspaces": "^3.20.1",
63
+ "@ossy/workspaces": "^3.22.0",
62
64
  "cookie-parser": "^1.4.7",
63
65
  "dotenv": ">=16.0.0 <18.0.0",
64
66
  "express": ">=5.0.0 <6.0.0",
@@ -79,5 +81,5 @@
79
81
  "Dockerfile",
80
82
  "docker-healthcheck.js"
81
83
  ],
82
- "gitHead": "3e8bb118b3ef163792a004663ccb803ab51a8601"
84
+ "gitHead": "29de300fd5e2c36f9d6d00f09eb9f1508997a904"
83
85
  }
@@ -0,0 +1,34 @@
1
+ import { createTranslatorForBuild } from './locale.js'
2
+
3
+ /**
4
+ * Give API handlers a way to load a discovered `*.email.jsx` and translate it.
5
+ * Email bundles are server entries, so the browser cannot render them itself.
6
+ *
7
+ * @param {import('express').Request} req
8
+ * @param {{
9
+ * manifest: { emails?: Array<{ id?: string, entry?: string }>, config?: { defaultLanguage?: string, supportedLanguages?: string[] } }
10
+ * loadEntry: (entryUrl: string) => Promise<any>
11
+ * buildDir: string
12
+ * }} context
13
+ */
14
+ export function attachEmailPreview (req, { manifest, loadEntry, buildDir }) {
15
+ const config = manifest?.config || {}
16
+ const supported = new Set(
17
+ Array.isArray(config.supportedLanguages) && config.supportedLanguages.length
18
+ ? config.supportedLanguages
19
+ : ['en', 'sv'],
20
+ )
21
+ const defaultLanguage = config.defaultLanguage || 'en'
22
+
23
+ req.loadEmailTemplate = async (id) => {
24
+ const email = (manifest?.emails || []).find((entry) => entry?.id === id)
25
+ if (!email?.entry) return null
26
+ return loadEntry(email.entry)
27
+ }
28
+
29
+ req.emailTranslator = (language) => {
30
+ const requested = typeof language === 'string' ? language : ''
31
+ const active = supported.has(requested) ? requested : defaultLanguage
32
+ return createTranslatorForBuild(buildDir, active, { ...config, warnOnMissingKey: false })
33
+ }
34
+ }
package/src/runtime.js CHANGED
@@ -6,6 +6,7 @@ import morgan from 'morgan'
6
6
  import { Router as OssyRouter } from '@ossy/router'
7
7
  import { loadManifest, resolveEntryUrl, loadLayoutsById, resolvePageLayoutRender } from './server.js'
8
8
  import { resolveRequestLocale } from './locale.js'
9
+ import { attachEmailPreview } from './email-preview.js'
9
10
  import { buildManifestSummary } from '@ossy/manifest/build-manifest-summary'
10
11
  import { ProxyInternal } from './proxy-internal.js'
11
12
  import { loadSite, invalidateSite } from './site-loader.js'
@@ -158,6 +159,7 @@ export async function startRuntime ({ port } = {}) {
158
159
  if (apiEntry) {
159
160
  const mod = await loadEntry(apiEntry.entry)
160
161
  if (typeof mod.handle === 'function') {
162
+ attachEmailPreview(req, { manifest, loadEntry, buildDir })
161
163
  await mod.handle(req, res)
162
164
  return
163
165
  }
package/src/server.js CHANGED
@@ -21,6 +21,7 @@ import { ConfigService } from './config.service.js'
21
21
  import { UsersMiddleware } from './users.middleware.js'
22
22
  import { WorkspacesMiddleware } from './workspaces.middleware.js'
23
23
  import { resolveRequestLocale } from './locale.js'
24
+ import { attachEmailPreview } from './email-preview.js'
24
25
  import { mountPlatformMcp } from './mcp/mount-platform-mcp.js'
25
26
  import {
26
27
  assertActionEntitled,
@@ -507,6 +508,7 @@ export async function startServer (options = {}) {
507
508
  if (apiEntry) {
508
509
  const mod = await loadEntry(apiEntry.entry)
509
510
  if (typeof mod.handle === 'function') {
511
+ attachEmailPreview(req, { manifest, loadEntry, buildDir })
510
512
  await mod.handle(req, res)
511
513
  return
512
514
  }
@@ -667,6 +669,20 @@ export async function startServer (options = {}) {
667
669
  log.info(`Running on http://localhost:${port}`)
668
670
  log.info('Press Ctrl+C to stop.')
669
671
 
672
+ let stopInboundMail = async () => {}
673
+ if (process.env.INBOUND_EMAIL_QUEUE_URL && process.env.INBOUND_EMAIL_BUCKET) {
674
+ try {
675
+ const { startInboundMailPoller } = await import('./ses/poll-inbound-mail.js')
676
+ stopInboundMail = startInboundMailPoller({
677
+ queueUrl: process.env.INBOUND_EMAIL_QUEUE_URL,
678
+ bucket: process.env.INBOUND_EMAIL_BUCKET,
679
+ log,
680
+ })
681
+ } catch (err) {
682
+ log.error('Inbound mail poller not started', undefined, err)
683
+ }
684
+ }
685
+
670
686
  runStartupRebuild?.()
671
687
 
672
688
  const closeServer = () => new Promise((resolve) => {
@@ -682,6 +698,7 @@ export async function startServer (options = {}) {
682
698
  try {
683
699
  TaskService.stop()
684
700
  closePushSseConnections()
701
+ await stopInboundMail()
685
702
  await ChangeStream.stop()
686
703
  await Mongo.closeConnection()
687
704
  await closeServer()
@@ -0,0 +1,88 @@
1
+ import { DeleteMessageCommand, ReceiveMessageCommand, SQSClient } from '@aws-sdk/client-sqs'
2
+ import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3'
3
+ import { fileSesReceipt, sesReceiptFromBody } from '@ossy/email/ses-inbound'
4
+ import { createInProcessSdk } from '../tasks/in-process-sdk.js'
5
+
6
+ const ReceiveInbound = { id: '@ossy/email/actions/receive-inbound' }
7
+
8
+ async function readObject ({ bucket, key }) {
9
+ const client = new S3Client({})
10
+ const response = await client.send(new GetObjectCommand({ Bucket: bucket, Key: key }))
11
+ return Buffer.from(await response.Body.transformToByteArray())
12
+ }
13
+
14
+ /**
15
+ * File each receipt and delete it. A failure leaves the message for SQS to
16
+ * retry, then the dead-letter queue. A skipped receipt is deleted.
17
+ *
18
+ * @param {Array<{ Body?: string, ReceiptHandle?: string }>} messages
19
+ */
20
+ export async function ackInboundMessages (messages, { file, deleteMessage, log } = {}) {
21
+ for (const message of messages ?? []) {
22
+ try {
23
+ const result = await file(message.Body)
24
+ if (result?.skipped) log?.info('[ses/inbound] skipped receipt', result)
25
+ await deleteMessage(message)
26
+ } catch (error) {
27
+ if (error?.name === 'NoSuchKey') {
28
+ log?.info('[ses/inbound] raw message already gone', { key: error.Key })
29
+ await deleteMessage(message)
30
+ continue
31
+ }
32
+ log?.error('[ses/inbound] receipt failed', undefined, error)
33
+ }
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Long-poll the inbound queue. Several tasks may poll; SQS delivers each
39
+ * message to one of them. Returns a stop function for shutdown.
40
+ */
41
+ export function startInboundMailPoller ({ queueUrl, bucket, log } = {}) {
42
+ const sqs = new SQSClient({})
43
+ const abort = new AbortController()
44
+
45
+ const file = (body) => fileSesReceipt(sesReceiptFromBody(body), {
46
+ bucket,
47
+ getObject: readObject,
48
+ invoke: ({ mime, workspaceId }) => createInProcessSdk()
49
+ .forWorkspace(workspaceId)
50
+ .invoke(ReceiveInbound, { mime, workspaceId }),
51
+ })
52
+
53
+ const run = (async () => {
54
+ while (!abort.signal.aborted) {
55
+ try {
56
+ const response = await sqs.send(new ReceiveMessageCommand({
57
+ QueueUrl: queueUrl,
58
+ MaxNumberOfMessages: 5,
59
+ WaitTimeSeconds: 20,
60
+ VisibilityTimeout: 120,
61
+ }), { abortSignal: abort.signal })
62
+ await ackInboundMessages(response.Messages, {
63
+ file,
64
+ log,
65
+ deleteMessage: (message) => sqs.send(new DeleteMessageCommand({
66
+ QueueUrl: queueUrl,
67
+ ReceiptHandle: message.ReceiptHandle,
68
+ })),
69
+ })
70
+ } catch (error) {
71
+ if (abort.signal.aborted) return
72
+ log?.error('[ses/inbound] poll failed', undefined, error)
73
+ await new Promise((resolve) => {
74
+ const timer = setTimeout(resolve, 5000)
75
+ abort.signal.addEventListener('abort', () => {
76
+ clearTimeout(timer)
77
+ resolve()
78
+ }, { once: true })
79
+ })
80
+ }
81
+ }
82
+ })()
83
+
84
+ return async function stopInboundMailPoller () {
85
+ abort.abort()
86
+ await run.catch(() => {})
87
+ }
88
+ }
@@ -0,0 +1,37 @@
1
+ import { describe, expect, it, jest } from '@jest/globals'
2
+ import { ackInboundMessages } from './poll-inbound-mail.js'
3
+
4
+ describe('ackInboundMessages', () => {
5
+ it('deletes a filed receipt and a skipped one', async () => {
6
+ const deleteMessage = jest.fn()
7
+ await ackInboundMessages([
8
+ { Body: 'filed' },
9
+ { Body: 'skip' },
10
+ ], {
11
+ file: async (body) => (body === 'skip' ? { skipped: true, reason: 'no workspace' } : { id: 'res-1' }),
12
+ deleteMessage,
13
+ })
14
+ expect(deleteMessage).toHaveBeenCalledTimes(2)
15
+ })
16
+
17
+ it('leaves a failed receipt on the queue', async () => {
18
+ const deleteMessage = jest.fn()
19
+ await ackInboundMessages([{ Body: 'bad' }], {
20
+ file: async () => { throw new Error('store failed') },
21
+ deleteMessage,
22
+ log: { error () {} },
23
+ })
24
+ expect(deleteMessage).not.toHaveBeenCalled()
25
+ })
26
+
27
+ it('deletes a receipt whose raw object is already gone', async () => {
28
+ const deleteMessage = jest.fn()
29
+ const error = Object.assign(new Error('missing'), { name: 'NoSuchKey' })
30
+ await ackInboundMessages([{ Body: 'gone' }], {
31
+ file: async () => { throw error },
32
+ deleteMessage,
33
+ log: { info () {} },
34
+ })
35
+ expect(deleteMessage).toHaveBeenCalledTimes(1)
36
+ })
37
+ })
@@ -16,6 +16,18 @@ import { resolveClientIp, runWithEventContext } from '@ossy/event-store'
16
16
  const SCHEDULER_INTERVAL_MS = 60_000
17
17
  const _serviceLog = createLogger('TaskService')
18
18
 
19
+ /**
20
+ * Written on the request path. Dispatch must not resolve workspace triggers
21
+ * for these — that lists task instances once per configurable task.
22
+ * Projections still fold them on the change stream (#1026).
23
+ * Same set as aggregate-rebuild `excludeTypes`.
24
+ */
25
+ const AUDIT_DISPATCH_TYPES = new Set([
26
+ '@ossy/platform/schema/task-run',
27
+ '@ossy/platform/schema/action-invocation',
28
+ '@ossy/platform/schema/meter-event',
29
+ ])
30
+
19
31
  function buildTaskContext (baseContext, extras = {}) {
20
32
  return {
21
33
  ...baseContext,
@@ -356,6 +368,7 @@ export class TaskService {
356
368
  */
357
369
  static async dispatch(event, { sdk } = {}) {
358
370
  if (!event) return
371
+ if (AUDIT_DISPATCH_TYPES.has(event.type)) return
359
372
 
360
373
  const baseSdk = sdk ?? await sdkBoundTo()
361
374
  const effectiveSdk = typeof baseSdk?.withEventContext === 'function'
@@ -122,6 +122,61 @@ describe('TaskService triggers', () => {
122
122
  TaskService.setInstanceTriggersResolver(null)
123
123
  })
124
124
 
125
+ it('skips audit events before resolving instance triggers', async () => {
126
+ const calls = []
127
+ const previousTasks = TaskService._tasks
128
+ const previousById = TaskService._tasksById
129
+ TaskService._tasks = []
130
+ TaskService._tasksById = new Map()
131
+ TaskService.setInstanceTriggersResolver(async ({ taskId, workspaceId }) => {
132
+ calls.push({ taskId, workspaceId })
133
+ return []
134
+ })
135
+ TaskService.registerTask({
136
+ metadata: {
137
+ id: '@ossy/media-tasks/tasks/resize-common-web',
138
+ configurable: true,
139
+ triggers: [{ type: '@ossy/platform/schema/file', event: 'Created' }],
140
+ },
141
+ run () {},
142
+ })
143
+
144
+ const auditEvent = (type) => ({
145
+ id: `evt-${type}`,
146
+ type,
147
+ event: 'Recorded',
148
+ resourceId: 'r1',
149
+ payload: { belongsTo: 'ws1' },
150
+ })
151
+
152
+ try {
153
+ for (const type of [
154
+ '@ossy/platform/schema/task-run',
155
+ '@ossy/platform/schema/action-invocation',
156
+ '@ossy/platform/schema/meter-event',
157
+ ]) {
158
+ await TaskService.dispatch(auditEvent(type), { sdk: {} })
159
+ }
160
+ expect(calls).toEqual([])
161
+
162
+ await TaskService.dispatch({
163
+ id: 'evt-file',
164
+ type: '@ossy/platform/schema/file',
165
+ event: 'Created',
166
+ resourceId: 'file1',
167
+ payload: { belongsTo: 'ws1', location: '/incoming/a.jpg' },
168
+ }, { sdk: {} })
169
+ expect(calls).toEqual([{
170
+ taskId: '@ossy/media-tasks/tasks/resize-common-web',
171
+ workspaceId: 'ws1',
172
+ }])
173
+ } finally {
174
+ TaskService._tasks = previousTasks
175
+ TaskService._tasksById = previousById
176
+ TaskService.setInstanceTriggersResolver(null)
177
+ }
178
+ })
179
+
125
180
  it('uses instance triggers for configurable tasks', async () => {
126
181
  const instanceTriggers = [{
127
182
  kind: 'on_event',