@ossy/platform 1.39.0 → 1.39.2

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.39.0",
3
+ "version": "1.39.2",
4
4
  "description": "Ossy application server runtime",
5
5
  "repository": {
6
6
  "type": "git",
@@ -40,13 +40,13 @@
40
40
  "@aws-sdk/s3-request-presigner": "^3.1057.0",
41
41
  "@aws-sdk/util-create-request": "^3.972.26",
42
42
  "@aws-sdk/util-format-url": "^3.972.17",
43
- "@ossy/event-store": "^1.8.0",
44
- "@ossy/locale": "^1.40.0",
45
- "@ossy/observability": "^1.8.0",
46
- "@ossy/policies": "^1.13.0",
47
- "@ossy/sdk": "^1.40.0",
48
- "@ossy/tokens": "^1.13.0",
49
- "@ossy/users": "^1.13.0",
43
+ "@ossy/event-store": "^1.8.2",
44
+ "@ossy/locale": "^1.40.2",
45
+ "@ossy/observability": "^1.8.2",
46
+ "@ossy/policies": "^1.13.2",
47
+ "@ossy/sdk": "^1.40.2",
48
+ "@ossy/tokens": "^1.13.2",
49
+ "@ossy/users": "^1.13.2",
50
50
  "cookie-parser": "^1.4.7",
51
51
  "dotenv": ">=16.0.0 <18.0.0",
52
52
  "express": ">=5.0.0 <6.0.0",
@@ -64,5 +64,5 @@
64
64
  "src",
65
65
  "Dockerfile"
66
66
  ],
67
- "gitHead": "d9d31182be64a448d575da432af2830d909ad4b9"
67
+ "gitHead": "2b8745d57fee8b6c08787e2755b4df489291a5cd"
68
68
  }
@@ -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/server.js CHANGED
@@ -6,8 +6,8 @@ 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'
10
- import { AggregateRebuild } from '@ossy/event-store'
9
+ import { SDK, resolveActionId } from '@ossy/sdk'
10
+ import { AggregateRebuild, resolveMongoUrl } from '@ossy/event-store'
11
11
  import { TaskService } from './tasks/task-service.js'
12
12
  import { ChangeStream } from './tasks/change-stream.js'
13
13
  import { CONTENT_SLOT_NAME } from '@ossy/app/runtime/resolve-shell-slots'
@@ -224,7 +224,7 @@ export async function startServer (options = {}) {
224
224
  TaskService.startScheduler()
225
225
 
226
226
  if (process.env.DB_URL) {
227
- ChangeStream.start(process.env.DB_URL)
227
+ ChangeStream.start(resolveMongoUrl(process.env.DB_URL))
228
228
  }
229
229
 
230
230
  const pageRouter = OssyRouter.of({
@@ -261,10 +261,11 @@ export async function startServer (options = {}) {
261
261
  if (fs.existsSync(publicDir)) app.use(express.static(publicDir))
262
262
  app.use(ProxyInternal())
263
263
 
264
- // Actions endpoint — auto-exposes every registered `*.action.js` handler.
265
- // Access rules are enforced here; business logic lives in the action's `run`.
266
- app.post('/actions/:id', async (req, res) => {
267
- 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
+
268
269
  const action = ActionService.get(actionId)
269
270
  if (!action) return res.status(404).json({ error: 'Action not found' })
270
271
 
@@ -275,10 +276,11 @@ export async function startServer (options = {}) {
275
276
  return res.status(403).json({ error: 'Forbidden' })
276
277
  }
277
278
 
279
+ const payload = req.body?.payload ?? {}
278
280
  const actionLog = createLogger(actionId)
279
281
  try {
280
282
  const result = await ActionService.invoke(actionId, {
281
- payload: req.body,
283
+ payload,
282
284
  sdk: req.sdk ?? null,
283
285
  log: actionLog,
284
286
  integrations: IntegrationService,
@@ -287,7 +289,8 @@ export async function startServer (options = {}) {
287
289
  res.json(result ?? { ok: true })
288
290
  } catch (err) {
289
291
  actionLog.error('Action failed', { id: actionId }, err)
290
- 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' })
291
294
  }
292
295
  })
293
296
 
@@ -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
 
@@ -1,5 +1,6 @@
1
1
  import { TaskService } from './task-service.js'
2
2
  import { createLogger } from '@ossy/observability'
3
+ import { resolveMongoUrl } from '@ossy/event-store'
3
4
 
4
5
  const log = createLogger('platform')
5
6
 
@@ -25,7 +26,7 @@ export class ChangeStream {
25
26
  static start(dbUrl) {
26
27
  ChangeStream._stopped = false
27
28
  ChangeStream._reconnectAttempts = 0
28
- ChangeStream._dbUrl = dbUrl
29
+ ChangeStream._dbUrl = resolveMongoUrl(dbUrl)
29
30
  ChangeStream._open().catch((error) => {
30
31
  log.error('[ChangeStream] Change stream could not be opened', undefined, error)
31
32
  ChangeStream._scheduleReconnect()
@@ -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
 
@@ -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