@ossy/platform 3.20.2 → 3.22.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ossy/platform",
3
- "version": "3.20.2",
3
+ "version": "3.22.1",
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.1",
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": "d0e5d69906917105609af4bb8bbdd2340d9fbaf1"
84
+ "gitHead": "b90c38bb41c552f9050067c6518e8a27cabf9827"
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,34 @@
1
+ /**
2
+ * The website task also has static AWS keys in its environment, and the SDK
3
+ * uses those before the task role. Inbound mail is granted to the task role,
4
+ * so the poller has to ask ECS for that role when it is running in a task.
5
+ *
6
+ * @param {NodeJS.ProcessEnv} [env]
7
+ * @param {typeof fetch} [fetchImpl]
8
+ */
9
+ export function inboundAwsClientConfig (env = process.env, fetchImpl = globalThis.fetch) {
10
+ const relative = env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI
11
+ const full = env.AWS_CONTAINER_CREDENTIALS_FULL_URI
12
+ if (!relative && !full) return {}
13
+
14
+ const url = full || `http://169.254.170.2${relative}`
15
+ return {
16
+ credentials: async () => {
17
+ const headers = {}
18
+ if (env.AWS_CONTAINER_AUTHORIZATION_TOKEN) {
19
+ headers.Authorization = env.AWS_CONTAINER_AUTHORIZATION_TOKEN
20
+ }
21
+ const response = await fetchImpl(url, { headers })
22
+ if (!response.ok) {
23
+ throw new Error('Task role credentials could not be read')
24
+ }
25
+ const body = await response.json()
26
+ return {
27
+ accessKeyId: body.AccessKeyId,
28
+ secretAccessKey: body.SecretAccessKey,
29
+ sessionToken: body.Token,
30
+ expiration: body.Expiration ? new Date(body.Expiration) : undefined,
31
+ }
32
+ },
33
+ }
34
+ }
@@ -0,0 +1,36 @@
1
+ import { describe, expect, it } from '@jest/globals'
2
+ import { inboundAwsClientConfig } from './inbound-aws-credentials.js'
3
+
4
+ describe('inboundAwsClientConfig', () => {
5
+ it('uses the default chain when the process is not in a task', () => {
6
+ expect(inboundAwsClientConfig({ AWS_ACCESS_KEY_ID: 'static' })).toEqual({})
7
+ })
8
+
9
+ it('reads the task role and ignores static keys', async () => {
10
+ const fetchImpl = async (url, { headers }) => {
11
+ expect(url).toBe('http://169.254.170.2/v2/credentials/task')
12
+ expect(headers.Authorization).toBe('token')
13
+ return {
14
+ ok: true,
15
+ json: async () => ({
16
+ AccessKeyId: 'task-key',
17
+ SecretAccessKey: 'task-secret',
18
+ Token: 'task-token',
19
+ Expiration: '2026-10-17T20:00:00.000Z',
20
+ }),
21
+ }
22
+ }
23
+ const config = inboundAwsClientConfig({
24
+ AWS_ACCESS_KEY_ID: 'cms-prod',
25
+ AWS_SECRET_ACCESS_KEY: 'static',
26
+ AWS_CONTAINER_CREDENTIALS_RELATIVE_URI: '/v2/credentials/task',
27
+ AWS_CONTAINER_AUTHORIZATION_TOKEN: 'token',
28
+ }, fetchImpl)
29
+ await expect(config.credentials()).resolves.toEqual({
30
+ accessKeyId: 'task-key',
31
+ secretAccessKey: 'task-secret',
32
+ sessionToken: 'task-token',
33
+ expiration: new Date('2026-10-17T20:00:00.000Z'),
34
+ })
35
+ })
36
+ })
@@ -0,0 +1,90 @@
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
+ import { inboundAwsClientConfig } from './inbound-aws-credentials.js'
6
+
7
+ const ReceiveInbound = { id: '@ossy/email/actions/receive-inbound' }
8
+
9
+ async function readObject (client, { bucket, key }) {
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 clientConfig = inboundAwsClientConfig()
43
+ const sqs = new SQSClient(clientConfig)
44
+ const objects = new S3Client(clientConfig)
45
+ const abort = new AbortController()
46
+
47
+ const file = (body) => fileSesReceipt(sesReceiptFromBody(body), {
48
+ bucket,
49
+ getObject: (location) => readObject(objects, location),
50
+ invoke: ({ mime, workspaceId }) => createInProcessSdk()
51
+ .forWorkspace(workspaceId)
52
+ .invoke(ReceiveInbound, { mime, workspaceId }),
53
+ })
54
+
55
+ const run = (async () => {
56
+ while (!abort.signal.aborted) {
57
+ try {
58
+ const response = await sqs.send(new ReceiveMessageCommand({
59
+ QueueUrl: queueUrl,
60
+ MaxNumberOfMessages: 5,
61
+ WaitTimeSeconds: 20,
62
+ VisibilityTimeout: 120,
63
+ }), { abortSignal: abort.signal })
64
+ await ackInboundMessages(response.Messages, {
65
+ file,
66
+ log,
67
+ deleteMessage: (message) => sqs.send(new DeleteMessageCommand({
68
+ QueueUrl: queueUrl,
69
+ ReceiptHandle: message.ReceiptHandle,
70
+ })),
71
+ })
72
+ } catch (error) {
73
+ if (abort.signal.aborted) return
74
+ log?.error('[ses/inbound] poll failed', undefined, error)
75
+ await new Promise((resolve) => {
76
+ const timer = setTimeout(resolve, 5000)
77
+ abort.signal.addEventListener('abort', () => {
78
+ clearTimeout(timer)
79
+ resolve()
80
+ }, { once: true })
81
+ })
82
+ }
83
+ }
84
+ })()
85
+
86
+ return async function stopInboundMailPoller () {
87
+ abort.abort()
88
+ await run.catch(() => {})
89
+ }
90
+ }
@@ -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
+ })