@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 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/:id`.
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 command, auto-exposed at `POST /actions/:id` |
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/:id ──► ActionService.invoke() ──► action.run({ payload, sdk, log, integrations, req })
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.38.7",
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.7.7",
42
- "@ossy/observability": "^1.7.7",
43
- "@ossy/policies": "^1.12.7",
44
- "@ossy/router": "^1.39.7",
45
- "@ossy/sdk": "^1.39.7",
46
- "@ossy/tokens": "^1.12.7",
47
- "@ossy/users": "^1.12.7",
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": "f65df0cd6da864c314c4f424112e05e8e2dec0ff"
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
- module: {
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, run: Function }>} */
6
+ /** @type {Map<string, { id: string, access: string }>} */
6
7
  const _actions = new Map()
7
8
 
8
9
  /**
9
- * Registry and invoker for `*.action.js` command handlers.
10
+ * Registry for action intent (`*.action.js` metadata only).
10
11
  *
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 }`
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 an action module (as imported from a bundled entry).
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, run: Function }} mod
19
+ * @param {{ metadata: { id: string, access?: string } }} mod
23
20
  */
24
21
  register (mod) {
25
- const { id, run, access = 'authenticated' } = mod ?? {}
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, run })
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, run: Function } | null}
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
- * Returns all registered actions.
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, forwarding the provided context.
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 action.run(context)
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 — auto-exposes every registered `*.action.js` handler.
262
- // Access rules are enforced here; business logic lives in the action's `run`.
263
- app.post('/actions/:id', async (req, res) => {
264
- const actionId = req.params.id
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: req.body,
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
- res.status(500).json({ error: err && err.message ? err.message : 'Internal error' })
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,
@@ -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.makeRequest(ResourcesList)({
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
- TaskService._tasks.push({ metadata, handler })
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.
@@ -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(`${API_URL}/users/sign-up`, {
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
 
@@ -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
+ })
@@ -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 fetch(
132
- `${baseUrl()}/users/sign-up`,
133
- { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: TestUtil.signUpBody({ email }) }
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.AssertResponse({
146
- endpoint: '/users/sign-up',
147
- method: 'POST',
148
- headers: { 'Content-Type': 'application/json'},
149
- body: TestUtil.signUpBody({ email }),
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({
@@ -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