@ossy/platform 1.38.7 → 1.39.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/README.md +3 -3
- package/package.json +12 -10
- package/src/Definition.js +1 -7
- package/src/actions/action.service.js +14 -24
- package/src/locale.js +90 -0
- package/src/runtime.js +7 -2
- package/src/server.js +17 -7
- package/src/site-loader.js +1 -1
- package/src/tasks/task-service.js +32 -1
- package/src/test/e2e.util.js +3 -2
- package/src/test/jest.setup.js +6 -0
- package/src/test/test.util.js +55 -11
- package/src/users.middleware.js +3 -3
package/README.md
CHANGED
|
@@ -14,7 +14,7 @@ At startup `@ossy/platform`:
|
|
|
14
14
|
6. Rebuilds all **aggregates** (`*.aggregate.js`) from the event store.
|
|
15
15
|
7. Registers all **actions** (`*.action.js`) with `ActionService`.
|
|
16
16
|
8. Starts an Express server that routes requests to pages (`*.page.jsx`) and API handlers (`*.api.js`).
|
|
17
|
-
9. Auto-mounts every action at `POST /actions
|
|
17
|
+
9. Auto-mounts every action at `POST /actions` (`{ action, payload }`).
|
|
18
18
|
|
|
19
19
|
## Quick start
|
|
20
20
|
|
|
@@ -117,7 +117,7 @@ The platform is built around file conventions called **primitives**. Each primit
|
|
|
117
117
|
| Page | `*.page.jsx` | Routable UI (SSR + hydration) |
|
|
118
118
|
| API | `*.api.js` | HTTP endpoint (any method) |
|
|
119
119
|
| Task | `*.task.js` | Event-driven or scheduled async work |
|
|
120
|
-
| Action | `*.action.js` | Named
|
|
120
|
+
| Action | `*.action.js` | Named intent, auto-exposed at `POST /actions` |
|
|
121
121
|
| Integration | `*.integration.js` | Third-party client connected at startup |
|
|
122
122
|
| Email | `*.email.jsx` | Transactional React email template |
|
|
123
123
|
| Component | `*.component.jsx` | Injectable UI fragment |
|
|
@@ -130,7 +130,7 @@ The platform is built around file conventions called **primitives**. Each primit
|
|
|
130
130
|
```
|
|
131
131
|
Incoming request
|
|
132
132
|
│
|
|
133
|
-
├─ POST /actions
|
|
133
|
+
├─ POST /actions ──► ActionService.invoke() ──► TaskService.invoke() ──► task.run({ payload, sdk, log, integrations, req })
|
|
134
134
|
│
|
|
135
135
|
├─ Match API route ──► api.handle(req, res)
|
|
136
136
|
│
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ossy/platform",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.39.1",
|
|
4
4
|
"description": "Ossy application server runtime",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -22,8 +22,10 @@
|
|
|
22
22
|
"./definition": "./src/Definition.js",
|
|
23
23
|
"./integrations": "./src/integration.service.js",
|
|
24
24
|
"./test": "./src/test/index.js",
|
|
25
|
+
"./test/jest.setup.js": "./src/test/jest.setup.js",
|
|
25
26
|
"./test/e2e-runner.js": "./src/test/e2e-runner.js",
|
|
26
|
-
"./test/playwright.config.js": "./src/test/playwright.config.js"
|
|
27
|
+
"./test/playwright.config.js": "./src/test/playwright.config.js",
|
|
28
|
+
"./locale": "./src/locale.js"
|
|
27
29
|
},
|
|
28
30
|
"scripts": {
|
|
29
31
|
"start": "PORT=3003 node -e \"import('./src/server.js').then(m => m.startServer())\"",
|
|
@@ -38,13 +40,13 @@
|
|
|
38
40
|
"@aws-sdk/s3-request-presigner": "^3.1057.0",
|
|
39
41
|
"@aws-sdk/util-create-request": "^3.972.26",
|
|
40
42
|
"@aws-sdk/util-format-url": "^3.972.17",
|
|
41
|
-
"@ossy/event-store": "^1.
|
|
42
|
-
"@ossy/
|
|
43
|
-
"@ossy/
|
|
44
|
-
"@ossy/
|
|
45
|
-
"@ossy/sdk": "^1.
|
|
46
|
-
"@ossy/tokens": "^1.
|
|
47
|
-
"@ossy/users": "^1.
|
|
43
|
+
"@ossy/event-store": "^1.8.1",
|
|
44
|
+
"@ossy/locale": "^1.40.1",
|
|
45
|
+
"@ossy/observability": "^1.8.1",
|
|
46
|
+
"@ossy/policies": "^1.13.1",
|
|
47
|
+
"@ossy/sdk": "^1.40.1",
|
|
48
|
+
"@ossy/tokens": "^1.13.1",
|
|
49
|
+
"@ossy/users": "^1.13.1",
|
|
48
50
|
"cookie-parser": "^1.4.7",
|
|
49
51
|
"dotenv": ">=16.0.0 <18.0.0",
|
|
50
52
|
"express": ">=5.0.0 <6.0.0",
|
|
@@ -62,5 +64,5 @@
|
|
|
62
64
|
"src",
|
|
63
65
|
"Dockerfile"
|
|
64
66
|
],
|
|
65
|
-
"gitHead": "
|
|
67
|
+
"gitHead": "c0ba5d90749690634e4dc2705178ff8d89dd3070"
|
|
66
68
|
}
|
package/src/Definition.js
CHANGED
|
@@ -2,12 +2,6 @@ export const Definition = {
|
|
|
2
2
|
id: 'platform',
|
|
3
3
|
title: 'Platform',
|
|
4
4
|
description: 'Deployment platform configuration aligned with deployment-tools S3 platform-config.json.',
|
|
5
|
-
|
|
6
|
-
id: 'platform',
|
|
7
|
-
enabled: true
|
|
8
|
-
},
|
|
5
|
+
icon: 'controller',
|
|
9
6
|
statuses: ['beta'],
|
|
10
|
-
actions: ['resources.create', 'resources.update-content', 'resources.rename', 'resources.remove'],
|
|
11
|
-
views: ['home.page', 'resources.search', 'resources.get'],
|
|
12
|
-
tasks: []
|
|
13
7
|
}
|
|
@@ -1,65 +1,55 @@
|
|
|
1
1
|
import { createLogger } from '@ossy/observability'
|
|
2
|
+
import { TaskService } from '../tasks/task-service.js'
|
|
2
3
|
|
|
3
4
|
const log = createLogger('platform/actions')
|
|
4
5
|
|
|
5
|
-
/** @type {Map<string, { id: string, access: string
|
|
6
|
+
/** @type {Map<string, { id: string, access: string }>} */
|
|
6
7
|
const _actions = new Map()
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
|
-
* Registry
|
|
10
|
+
* Registry for action intent (`*.action.js` metadata only).
|
|
10
11
|
*
|
|
11
|
-
* Actions
|
|
12
|
-
*
|
|
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 }`
|
|
12
|
+
* Actions name what callers want: `{ id, access }`. Implementation lives in
|
|
13
|
+
* a task with the same id — see `TaskService.invoke`.
|
|
16
14
|
*/
|
|
17
15
|
export const ActionService = {
|
|
18
16
|
/**
|
|
19
|
-
* Register
|
|
20
|
-
* Validates that `id` is a non-empty string and `run` is a function.
|
|
17
|
+
* Register action intent from a bundled `*.action.js` module.
|
|
21
18
|
*
|
|
22
|
-
* @param {{ id: string, access?: string
|
|
19
|
+
* @param {{ metadata: { id: string, access?: string } }} mod
|
|
23
20
|
*/
|
|
24
21
|
register (mod) {
|
|
25
|
-
const { id,
|
|
22
|
+
const { id, access = 'authenticated' } = mod?.metadata ?? {}
|
|
26
23
|
|
|
27
24
|
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`)
|
|
25
|
+
throw new Error(`[ActionService] Action module must export a non-empty string "metadata.id" (got ${JSON.stringify(id)})`)
|
|
32
26
|
}
|
|
33
27
|
|
|
34
28
|
if (_actions.has(id)) {
|
|
35
29
|
log.warn(`[ActionService] Action "${id}" already registered — overwriting`)
|
|
36
30
|
}
|
|
37
31
|
|
|
38
|
-
_actions.set(id, { id, access
|
|
32
|
+
_actions.set(id, { id, access })
|
|
39
33
|
log.info(`[ActionService] Registered action "${id}" (access: ${access})`)
|
|
40
34
|
},
|
|
41
35
|
|
|
42
36
|
/**
|
|
43
|
-
* Look up a registered action by id. Returns `null` when not found.
|
|
44
|
-
*
|
|
45
37
|
* @param {string} id
|
|
46
|
-
* @returns {{ id: string, access: string
|
|
38
|
+
* @returns {{ id: string, access: string } | null}
|
|
47
39
|
*/
|
|
48
40
|
get (id) {
|
|
49
41
|
return _actions.get(id) ?? null
|
|
50
42
|
},
|
|
51
43
|
|
|
52
44
|
/**
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
* @returns {{ id: string, access: string, run: Function }[]}
|
|
45
|
+
* @returns {{ id: string, access: string }[]}
|
|
56
46
|
*/
|
|
57
47
|
all () {
|
|
58
48
|
return [..._actions.values()]
|
|
59
49
|
},
|
|
60
50
|
|
|
61
51
|
/**
|
|
62
|
-
* Invoke an action by id,
|
|
52
|
+
* Invoke an action by id — enforces registration, delegates execution to TaskService.
|
|
63
53
|
*
|
|
64
54
|
* @param {string} id
|
|
65
55
|
* @param {{ payload?: unknown, sdk?: unknown, log?: unknown, integrations?: unknown, req?: unknown }} context
|
|
@@ -68,6 +58,6 @@ export const ActionService = {
|
|
|
68
58
|
async invoke (id, context = {}) {
|
|
69
59
|
const action = _actions.get(id)
|
|
70
60
|
if (!action) throw new Error(`[ActionService] Action not found: "${id}"`)
|
|
71
|
-
return
|
|
61
|
+
return TaskService.invoke(id, context)
|
|
72
62
|
},
|
|
73
63
|
}
|
package/src/locale.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { createTranslator } from '@ossy/locale'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @param {string} buildDir
|
|
8
|
+
* @param {string} language
|
|
9
|
+
* @returns {Record<string, string>}
|
|
10
|
+
*/
|
|
11
|
+
export function loadMessagesForLanguage (buildDir, language) {
|
|
12
|
+
const filePath = path.join(buildDir, 'public', `${language}.translations.json`)
|
|
13
|
+
if (!fs.existsSync(filePath)) return {}
|
|
14
|
+
try {
|
|
15
|
+
const raw = fs.readFileSync(filePath, 'utf8')
|
|
16
|
+
const parsed = JSON.parse(raw)
|
|
17
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}
|
|
18
|
+
} catch {
|
|
19
|
+
return {}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {string} buildDir
|
|
25
|
+
* @param {string} language
|
|
26
|
+
* @param {{ defaultLanguage?: string }} [config]
|
|
27
|
+
* @returns {Record<string, string> | undefined}
|
|
28
|
+
*/
|
|
29
|
+
export function loadFallbackMessagesForLanguage (buildDir, language, config = {}) {
|
|
30
|
+
const defaultLanguage = config.defaultLanguage || 'en'
|
|
31
|
+
if (language === defaultLanguage) return undefined
|
|
32
|
+
return loadMessagesForLanguage(buildDir, defaultLanguage)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {{ getParamsFromUrl: (url: string) => Record<string, string> }} pageRouter
|
|
37
|
+
* @param {string} requestUrl
|
|
38
|
+
* @param {{ defaultLanguage?: string }} config
|
|
39
|
+
* @returns {string}
|
|
40
|
+
*/
|
|
41
|
+
export function resolveRequestLanguage (pageRouter, requestUrl, config) {
|
|
42
|
+
const params = pageRouter.getParamsFromUrl(requestUrl)
|
|
43
|
+
return params.language || config.defaultLanguage || 'en'
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @param {{ getParamsFromUrl: (url: string) => Record<string, string> }} pageRouter
|
|
48
|
+
* @param {string} requestUrl
|
|
49
|
+
* @param {string} buildDir
|
|
50
|
+
* @param {{ defaultLanguage?: string }} config
|
|
51
|
+
* @returns {{ language: string, messages: Record<string, string>, fallbackMessages?: Record<string, string> }}
|
|
52
|
+
*/
|
|
53
|
+
export function resolveRequestLocale (pageRouter, requestUrl, buildDir, config) {
|
|
54
|
+
const language = resolveRequestLanguage(pageRouter, requestUrl, config)
|
|
55
|
+
const messages = loadMessagesForLanguage(buildDir, language)
|
|
56
|
+
const fallbackMessages = loadFallbackMessagesForLanguage(buildDir, language, config)
|
|
57
|
+
return {
|
|
58
|
+
language,
|
|
59
|
+
messages,
|
|
60
|
+
...(fallbackMessages ? { fallbackMessages } : {}),
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Server-side `t()` for actions, tasks, and email — reads merged catalogs from build output.
|
|
66
|
+
*
|
|
67
|
+
* @param {string} buildDir Absolute path to app `build/`
|
|
68
|
+
* @param {string} [language] Active locale
|
|
69
|
+
* @param {{ defaultLanguage?: string, warnOnMissingKey?: boolean }} [config]
|
|
70
|
+
* @returns {(key: string, params?: Record<string, string | number>) => string}
|
|
71
|
+
*/
|
|
72
|
+
export function createTranslatorForBuild (buildDir, language, config = {}) {
|
|
73
|
+
const defaultLanguage = config.defaultLanguage || 'en'
|
|
74
|
+
const activeLanguage = language || defaultLanguage
|
|
75
|
+
const messages = loadMessagesForLanguage(buildDir, activeLanguage)
|
|
76
|
+
const fallbackCatalog = activeLanguage !== defaultLanguage
|
|
77
|
+
? loadMessagesForLanguage(buildDir, defaultLanguage)
|
|
78
|
+
: undefined
|
|
79
|
+
|
|
80
|
+
const warnOnMissingKey = config.warnOnMissingKey ?? process.env.NODE_ENV !== 'production'
|
|
81
|
+
const onMissingKey = warnOnMissingKey
|
|
82
|
+
? (key) => {
|
|
83
|
+
if (typeof console !== 'undefined' && typeof console.warn === 'function') {
|
|
84
|
+
console.warn(`[@ossy/locale] Missing translation key: ${key}`)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
: undefined
|
|
88
|
+
|
|
89
|
+
return createTranslator(messages, { fallbackCatalog, onMissingKey })
|
|
90
|
+
}
|
package/src/runtime.js
CHANGED
|
@@ -5,6 +5,7 @@ import cookieParser from 'cookie-parser'
|
|
|
5
5
|
import morgan from 'morgan'
|
|
6
6
|
import { Router as OssyRouter } from '@ossy/router'
|
|
7
7
|
import { loadManifest, resolveEntryUrl } from './server.js'
|
|
8
|
+
import { resolveRequestLocale } from './locale.js'
|
|
8
9
|
import { buildManifestSummary } from '@ossy/app/manifest/build-manifest-summary'
|
|
9
10
|
import { ProxyInternal } from './proxy-internal.js'
|
|
10
11
|
import { loadSite, invalidateSite } from './site-loader.js'
|
|
@@ -49,7 +50,7 @@ function buildSiteContext (manifest, buildDir) {
|
|
|
49
50
|
return promise
|
|
50
51
|
}
|
|
51
52
|
|
|
52
|
-
return { pageRouter, apiRouter, manifest, manifestSummary: buildManifestSummary(manifest), loadEntry }
|
|
53
|
+
return { pageRouter, apiRouter, manifest, manifestSummary: buildManifestSummary(manifest), loadEntry, buildDir }
|
|
53
54
|
}
|
|
54
55
|
|
|
55
56
|
async function getSiteContext (domain) {
|
|
@@ -128,7 +129,7 @@ export async function startRuntime ({ port } = {}) {
|
|
|
128
129
|
return
|
|
129
130
|
}
|
|
130
131
|
|
|
131
|
-
const { pageRouter, apiRouter, manifest, manifestSummary, loadEntry } = context
|
|
132
|
+
const { pageRouter, apiRouter, manifest, manifestSummary, loadEntry, buildDir } = context
|
|
132
133
|
|
|
133
134
|
try {
|
|
134
135
|
const apiRoute = apiRouter.getPageByUrl(requestUrl)
|
|
@@ -167,6 +168,7 @@ export async function startRuntime ({ port } = {}) {
|
|
|
167
168
|
}
|
|
168
169
|
|
|
169
170
|
const config = manifest.config
|
|
171
|
+
const { language, messages, fallbackMessages } = resolveRequestLocale(pageRouter, requestUrl, buildDir, config)
|
|
170
172
|
const props = {
|
|
171
173
|
...config,
|
|
172
174
|
...(req.userAppSettings || {}),
|
|
@@ -176,6 +178,9 @@ export async function startRuntime ({ port } = {}) {
|
|
|
176
178
|
manifestSummary: cloneSerializable(manifestSummary),
|
|
177
179
|
url: requestUrl,
|
|
178
180
|
isAuthenticated: !!req.isAuthenticated,
|
|
181
|
+
language,
|
|
182
|
+
messages: cloneSerializable(messages),
|
|
183
|
+
...(fallbackMessages ? { fallbackMessages: cloneSerializable(fallbackMessages) } : {}),
|
|
179
184
|
pages: manifest.pages.map((page) => ({
|
|
180
185
|
id: page.id,
|
|
181
186
|
path: page.path,
|
package/src/server.js
CHANGED
|
@@ -6,7 +6,7 @@ import morgan from 'morgan'
|
|
|
6
6
|
import { Router as OssyRouter } from '@ossy/router'
|
|
7
7
|
import cookieParser from 'cookie-parser'
|
|
8
8
|
import { ProxyInternal } from './proxy-internal.js'
|
|
9
|
-
import { SDK } from '@ossy/sdk'
|
|
9
|
+
import { SDK, resolveActionId } from '@ossy/sdk'
|
|
10
10
|
import { AggregateRebuild } from '@ossy/event-store'
|
|
11
11
|
import { TaskService } from './tasks/task-service.js'
|
|
12
12
|
import { ChangeStream } from './tasks/change-stream.js'
|
|
@@ -19,6 +19,7 @@ import { createLogger } from '@ossy/observability'
|
|
|
19
19
|
import { ConfigService } from './config.service.js'
|
|
20
20
|
import { UsersMiddleware } from './users.middleware.js'
|
|
21
21
|
import { WorkspacesMiddleware } from './workspaces.middleware.js'
|
|
22
|
+
import { resolveRequestLocale } from './locale.js'
|
|
22
23
|
const log = createLogger('@ossy/platform')
|
|
23
24
|
|
|
24
25
|
const DEFAULT_PORT = 3000
|
|
@@ -82,6 +83,8 @@ export function loadManifest (buildDir) {
|
|
|
82
83
|
emails,
|
|
83
84
|
layouts,
|
|
84
85
|
config: manifest.config || {},
|
|
86
|
+
definitions: manifest.definitions || {},
|
|
87
|
+
translations: manifest.translations || {},
|
|
85
88
|
}
|
|
86
89
|
}
|
|
87
90
|
|
|
@@ -258,10 +261,11 @@ export async function startServer (options = {}) {
|
|
|
258
261
|
if (fs.existsSync(publicDir)) app.use(express.static(publicDir))
|
|
259
262
|
app.use(ProxyInternal())
|
|
260
263
|
|
|
261
|
-
// Actions endpoint —
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
264
|
+
// Actions endpoint — POST /actions with body { action, payload }.
|
|
265
|
+
app.post('/actions', async (req, res) => {
|
|
266
|
+
const actionId = resolveActionId(req.body?.action)
|
|
267
|
+
if (!actionId) return res.status(400).json({ error: 'Missing action' })
|
|
268
|
+
|
|
265
269
|
const action = ActionService.get(actionId)
|
|
266
270
|
if (!action) return res.status(404).json({ error: 'Action not found' })
|
|
267
271
|
|
|
@@ -272,10 +276,11 @@ export async function startServer (options = {}) {
|
|
|
272
276
|
return res.status(403).json({ error: 'Forbidden' })
|
|
273
277
|
}
|
|
274
278
|
|
|
279
|
+
const payload = req.body?.payload ?? {}
|
|
275
280
|
const actionLog = createLogger(actionId)
|
|
276
281
|
try {
|
|
277
282
|
const result = await ActionService.invoke(actionId, {
|
|
278
|
-
payload
|
|
283
|
+
payload,
|
|
279
284
|
sdk: req.sdk ?? null,
|
|
280
285
|
log: actionLog,
|
|
281
286
|
integrations: IntegrationService,
|
|
@@ -284,7 +289,8 @@ export async function startServer (options = {}) {
|
|
|
284
289
|
res.json(result ?? { ok: true })
|
|
285
290
|
} catch (err) {
|
|
286
291
|
actionLog.error('Action failed', { id: actionId }, err)
|
|
287
|
-
|
|
292
|
+
const status = err?.status ?? 500
|
|
293
|
+
res.status(status).json({ error: err?.message ?? 'Internal error' })
|
|
288
294
|
}
|
|
289
295
|
})
|
|
290
296
|
|
|
@@ -322,6 +328,7 @@ export async function startServer (options = {}) {
|
|
|
322
328
|
}
|
|
323
329
|
const Layout = appLayout?.component ?? null
|
|
324
330
|
const layoutEntry = appLayout?.entry ?? null
|
|
331
|
+
const { language, messages, fallbackMessages } = resolveRequestLocale(pageRouter, requestUrl, buildDir, config)
|
|
325
332
|
// Page component → `shell:content` is resolved in page-runtime (SSR + hydrate).
|
|
326
333
|
const props = {
|
|
327
334
|
...config,
|
|
@@ -332,6 +339,9 @@ export async function startServer (options = {}) {
|
|
|
332
339
|
manifestSummary: cloneSerializable(manifestSummary),
|
|
333
340
|
url: requestUrl,
|
|
334
341
|
isAuthenticated: !!req.isAuthenticated,
|
|
342
|
+
language,
|
|
343
|
+
messages: cloneSerializable(messages),
|
|
344
|
+
...(fallbackMessages ? { fallbackMessages: cloneSerializable(fallbackMessages) } : {}),
|
|
335
345
|
pages: manifest.pages.map((page) => ({
|
|
336
346
|
id: page.id,
|
|
337
347
|
path: page.path,
|
package/src/site-loader.js
CHANGED
|
@@ -54,7 +54,7 @@ async function collectFiles (sdk, location) {
|
|
|
54
54
|
|
|
55
55
|
while (queue.length > 0) {
|
|
56
56
|
const { location: loc, prefix } = queue.shift()
|
|
57
|
-
const resources = await sdk.
|
|
57
|
+
const resources = await sdk.invoke(ResourcesList, {
|
|
58
58
|
search: new URLSearchParams({ location: loc }).toString(),
|
|
59
59
|
})
|
|
60
60
|
|
|
@@ -13,6 +13,9 @@ export class TaskService {
|
|
|
13
13
|
/** @type {Array<{ metadata: object, handler: function }>} */
|
|
14
14
|
static _tasks = []
|
|
15
15
|
|
|
16
|
+
/** @type {Map<string, { metadata: object, handler: function }>} */
|
|
17
|
+
static _tasksById = new Map()
|
|
18
|
+
|
|
16
19
|
/** @type {ReturnType<typeof setInterval> | null} */
|
|
17
20
|
static _schedulerInterval = null
|
|
18
21
|
|
|
@@ -51,13 +54,41 @@ export class TaskService {
|
|
|
51
54
|
return
|
|
52
55
|
}
|
|
53
56
|
|
|
54
|
-
|
|
57
|
+
const entry = { metadata, handler }
|
|
58
|
+
TaskService._tasks.push(entry)
|
|
59
|
+
if (TaskService._tasksById.has(metadata.id)) {
|
|
60
|
+
_serviceLog.warn(`Task "${metadata.id}" already registered — overwriting`)
|
|
61
|
+
}
|
|
62
|
+
TaskService._tasksById.set(metadata.id, entry)
|
|
55
63
|
_serviceLog.info(
|
|
56
64
|
`Registered task "${metadata.id}" with ${metadata.triggers?.length ?? 0} trigger(s)` +
|
|
57
65
|
(metadata.schedule ? ` and schedule "${metadata.schedule}"` : ''),
|
|
58
66
|
)
|
|
59
67
|
}
|
|
60
68
|
|
|
69
|
+
/**
|
|
70
|
+
* Look up a registered task by id.
|
|
71
|
+
*
|
|
72
|
+
* @param {string} id
|
|
73
|
+
* @returns {{ metadata: object, handler: function } | null}
|
|
74
|
+
*/
|
|
75
|
+
static get(id) {
|
|
76
|
+
return TaskService._tasksById.get(id) ?? null
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Synchronously invoke a task by id (HTTP / sdk.invoke path).
|
|
81
|
+
*
|
|
82
|
+
* @param {string} id
|
|
83
|
+
* @param {{ payload?: unknown, sdk?: unknown, log?: unknown, integrations?: unknown, req?: unknown }} context
|
|
84
|
+
* @returns {Promise<unknown>}
|
|
85
|
+
*/
|
|
86
|
+
static async invoke(id, context = {}) {
|
|
87
|
+
const task = TaskService._tasksById.get(id)
|
|
88
|
+
if (!task) throw new Error(`[TaskService] Task not found: "${id}"`)
|
|
89
|
+
return task.handler(context)
|
|
90
|
+
}
|
|
91
|
+
|
|
61
92
|
/**
|
|
62
93
|
* Dispatches a single event (fullDocument from the changestream) to all
|
|
63
94
|
* registered tasks whose triggers match.
|
package/src/test/e2e.util.js
CHANGED
|
@@ -3,6 +3,7 @@ import { MongoClient } from 'mongodb'
|
|
|
3
3
|
const DB_URL = process.env.DB_URL ?? 'mongodb://localhost:27017/'
|
|
4
4
|
const DB_NAME = process.env.DB_NAME ?? 'ossy-local'
|
|
5
5
|
export const API_URL = process.env.OSSY_API_URL ?? process.env.API_URL ?? 'http://localhost:3001/api/v0'
|
|
6
|
+
export const ACTIONS_URL = API_URL.replace(/\/api\/v0\/?$/, '')
|
|
6
7
|
export const APP_URL = process.env.OSSY_APP_URL ?? process.env.BASE_URL ?? 'http://localhost:3002'
|
|
7
8
|
|
|
8
9
|
/**
|
|
@@ -80,10 +81,10 @@ export async function signUpAndGetToken(firstName = 'Test', lastName = 'User') {
|
|
|
80
81
|
await client.connect()
|
|
81
82
|
const db = client.db(DB_NAME)
|
|
82
83
|
|
|
83
|
-
const res = await fetch(`${
|
|
84
|
+
const res = await fetch(`${ACTIONS_URL}/actions`, {
|
|
84
85
|
method: 'POST',
|
|
85
86
|
headers: { 'content-type': 'application/json' },
|
|
86
|
-
body: JSON.stringify({ email, firstName, lastName }),
|
|
87
|
+
body: JSON.stringify({ action: 'authentication/sign-up', payload: { email, firstName, lastName } }),
|
|
87
88
|
})
|
|
88
89
|
if (!res.ok) throw new Error(`Sign-up failed with status ${res.status}`)
|
|
89
90
|
|
package/src/test/jest.setup.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { TestUtil } from './test.util.js'
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Integration tests run in Node on the host. Mongo in Docker is often a single-node replica set
|
|
3
5
|
* whose persisted config advertises a hostname that only resolves inside Docker (e.g.
|
|
@@ -22,3 +24,7 @@ function jestMongoUrl() {
|
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
process.env.DB_URL = jestMongoUrl()
|
|
27
|
+
|
|
28
|
+
afterAll(async () => {
|
|
29
|
+
await TestUtil.CloseDbConnection()
|
|
30
|
+
})
|
package/src/test/test.util.js
CHANGED
|
@@ -23,7 +23,13 @@ export function getApiTestBaseUrl() {
|
|
|
23
23
|
return process.env.API_TEST_BASE_URL ?? 'http://localhost:3000/api/v0'
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
/** Server root for `POST /actions` (strips `/api/v0` suffix). */
|
|
27
|
+
export function getActionsTestBaseUrl() {
|
|
28
|
+
return getApiTestBaseUrl().replace(/\/api\/v0\/?$/, '')
|
|
29
|
+
}
|
|
30
|
+
|
|
26
31
|
const baseUrl = /* lazy */ () => getApiTestBaseUrl()
|
|
32
|
+
const actionsBaseUrl = /* lazy */ () => getActionsTestBaseUrl()
|
|
27
33
|
|
|
28
34
|
export class TestUtil {
|
|
29
35
|
|
|
@@ -32,6 +38,44 @@ export class TestUtil {
|
|
|
32
38
|
return JSON.stringify({ email, firstName, lastName })
|
|
33
39
|
}
|
|
34
40
|
|
|
41
|
+
static InvokeAction({ actionId, headers = {}, body, payload }) {
|
|
42
|
+
const requestBody = body ?? JSON.stringify({ action: actionId, payload: payload ?? {} })
|
|
43
|
+
return fetch(`${actionsBaseUrl()}/actions`, {
|
|
44
|
+
method: 'POST',
|
|
45
|
+
headers: { 'Content-Type': 'application/json', ...headers },
|
|
46
|
+
body: requestBody,
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
static AssertActionResponse(test) {
|
|
51
|
+
return TestUtil.InvokeAction(test).then(response => {
|
|
52
|
+
expect(response.status).toBe(test.expectedResponseStatus)
|
|
53
|
+
return response.json()
|
|
54
|
+
.then(data => expect(data).toEqual(test.expectedResponseBody))
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
static AssertActionAuthenticationNeeded({ actionId, headers = {}, body, payload }) {
|
|
59
|
+
describe('given no auth token is provided', () => {
|
|
60
|
+
it('must return 401 Unauthorized', async () => {
|
|
61
|
+
const response = await TestUtil.InvokeAction({ actionId, headers, body, payload })
|
|
62
|
+
expect(response.status).toEqual(401)
|
|
63
|
+
await expect(response.json()).resolves.toEqual({ error: 'Unauthorized' })
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
static AssertWorkspaceActionAuthenticationNeeded({ actionId, headers = {}, body, payload }) {
|
|
69
|
+
describe('given no auth token is provided', () => {
|
|
70
|
+
it('must return 403 Forbidden', async () => {
|
|
71
|
+
const response = await TestUtil.InvokeAction({ actionId, headers, body, payload })
|
|
72
|
+
expect(response.status).toEqual(403)
|
|
73
|
+
await expect(response.json()).resolves.toEqual({ error: 'Forbidden' })
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** @deprecated Prefer `InvokeAction` for platform actions. Kept for Tier-2 REST APIs (verify-sign-in, sign-off). */
|
|
35
79
|
static AssertResponse(test) {
|
|
36
80
|
return fetch(
|
|
37
81
|
`${baseUrl()}${test.endpoint}`,
|
|
@@ -48,6 +92,7 @@ export class TestUtil {
|
|
|
48
92
|
})
|
|
49
93
|
}
|
|
50
94
|
|
|
95
|
+
/** @deprecated Prefer `InvokeAction`. Kept for Tier-2 REST APIs. */
|
|
51
96
|
static AssertAuthenticationNeeded(request) {
|
|
52
97
|
describe('given no auth token is provided', () => {
|
|
53
98
|
it('must return 401 Unauthorized', async () => {
|
|
@@ -58,6 +103,7 @@ export class TestUtil {
|
|
|
58
103
|
})
|
|
59
104
|
}
|
|
60
105
|
|
|
106
|
+
/** @deprecated Prefer `InvokeAction`. Kept for Tier-2 REST APIs. */
|
|
61
107
|
static MakeRequest(request) {
|
|
62
108
|
return fetch(
|
|
63
109
|
`${baseUrl()}${request.endpoint}`,
|
|
@@ -128,10 +174,10 @@ export class TestUtil {
|
|
|
128
174
|
static GetVerificationToken() {
|
|
129
175
|
const email = `${casual.email}`
|
|
130
176
|
|
|
131
|
-
return
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
)
|
|
177
|
+
return TestUtil.InvokeAction({
|
|
178
|
+
actionId: 'authentication/sign-up',
|
|
179
|
+
body: TestUtil.signUpBody({ email }),
|
|
180
|
+
})
|
|
135
181
|
.then(() => EventStore.FindEvent({
|
|
136
182
|
aggregateType: 'User',
|
|
137
183
|
type: { $in: [ 'SignedUp' ] },
|
|
@@ -142,13 +188,11 @@ export class TestUtil {
|
|
|
142
188
|
|
|
143
189
|
static async GetAuthenticatedTestUser(email = casual.email) {
|
|
144
190
|
|
|
145
|
-
await TestUtil.
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
expectedResponseStatus: 200,
|
|
151
|
-
expectedResponseBody: ''
|
|
191
|
+
await TestUtil.AssertActionResponse({
|
|
192
|
+
actionId: 'authentication/sign-up',
|
|
193
|
+
body: TestUtil.signUpBody({ email }),
|
|
194
|
+
expectedResponseStatus: 200,
|
|
195
|
+
expectedResponseBody: { ok: true }
|
|
152
196
|
})
|
|
153
197
|
|
|
154
198
|
const signedUpEvent = await TestUtil.GetEvent({
|
package/src/users.middleware.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { TokenService } from './token.service.js'
|
|
2
2
|
import { createLogger } from '@ossy/observability'
|
|
3
3
|
import { Aggregate } from '@ossy/event-store'
|
|
4
|
-
import { User } from '@ossy/users'
|
|
5
|
-
import { Token } from '@ossy/tokens'
|
|
4
|
+
import { User } from '@ossy/users/server'
|
|
5
|
+
import { Token } from '@ossy/tokens/server'
|
|
6
6
|
import { ConfigService } from './config.service.js'
|
|
7
|
-
import { PoliciesQueries } from '@ossy/policies'
|
|
7
|
+
import { PoliciesQueries } from '@ossy/policies/server'
|
|
8
8
|
|
|
9
9
|
const log = createLogger('users')
|
|
10
10
|
|