@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 +5 -3
- package/src/email-preview.js +34 -0
- package/src/runtime.js +2 -0
- package/src/server.js +17 -0
- package/src/ses/poll-inbound-mail.js +88 -0
- package/src/ses/poll-inbound-mail.spec.js +37 -0
- package/src/tasks/task-service.js +13 -0
- package/src/tasks/task-service.spec.js +55 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ossy/platform",
|
|
3
|
-
"version": "3.
|
|
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.
|
|
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": "
|
|
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',
|