@ossy/platform 3.1.0 → 3.3.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ossy/platform",
3
- "version": "3.1.0",
3
+ "version": "3.3.0",
4
4
  "description": "Ossy application server runtime",
5
5
  "repository": {
6
6
  "type": "git",
@@ -45,16 +45,16 @@
45
45
  "@aws-sdk/util-format-url": "^3.972.17",
46
46
  "@modelcontextprotocol/sdk": "^1.12.1",
47
47
  "@ossy/config": "^3.0.9",
48
- "@ossy/event-store": "^3.0.9",
48
+ "@ossy/event-store": "^3.2.0",
49
49
  "@ossy/locale": "^3.0.9",
50
50
  "@ossy/manifest": "^3.0.9",
51
51
  "@ossy/observability": "^3.0.9",
52
52
  "@ossy/policies": "^3.0.9",
53
53
  "@ossy/schema": "^3.0.9",
54
- "@ossy/sdk": "^3.0.9",
54
+ "@ossy/sdk": "^3.3.0",
55
55
  "@ossy/tokens": "^3.0.9",
56
- "@ossy/users": "^3.0.9",
57
- "@ossy/workspaces": "^3.1.0",
56
+ "@ossy/users": "^3.2.0",
57
+ "@ossy/workspaces": "^3.2.0",
58
58
  "cookie-parser": "^1.4.7",
59
59
  "dotenv": ">=16.0.0 <18.0.0",
60
60
  "express": ">=5.0.0 <6.0.0",
@@ -74,5 +74,5 @@
74
74
  "src",
75
75
  "Dockerfile"
76
76
  ],
77
- "gitHead": "9b146fb6fd9769f800863806c5bd16016508b218"
77
+ "gitHead": "19e8bae4c1e9df79270218f28c0c9391f4f9871c"
78
78
  }
@@ -99,10 +99,19 @@ export class TaskService {
99
99
 
100
100
  const audit = context.audit !== false && task.metadata.audit !== false
101
101
 
102
+ // GET/API handlers often omit integrations; default like dispatch/scheduler so
103
+ // tasks can always use integrations.get('email') (including local delivery).
104
+ const invokeContext = {
105
+ ...context,
106
+ sdk: context.sdk ?? TaskService._sdk,
107
+ integrations: context.integrations ?? IntegrationService,
108
+ log: context.log ?? createLogger(id),
109
+ }
110
+
102
111
  return TaskRunService.execute({
103
112
  taskId: id,
104
113
  handler: task.handler,
105
- context,
114
+ context: invokeContext,
106
115
  trigger: 'invoke',
107
116
  triggeredBy: {
108
117
  channel: context.req ? undefined : 'api',
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Normalize a declarative flow `click` step into a Playwright locator click.
3
+ * Prefer `{ action }` for `data-action` controls; use `click` for structural hooks
4
+ * (e.g. overlay backdrop) that are not action POJOs.
5
+ *
6
+ * @param {unknown} click
7
+ * @returns {{ selector: string, position?: { x: number, y: number } }}
8
+ */
9
+ export function resolveClickTarget (click) {
10
+ if (typeof click === 'string') {
11
+ const selector = click.trim()
12
+ if (!selector) {
13
+ throw new Error('click step requires a non-empty CSS selector')
14
+ }
15
+ return { selector }
16
+ }
17
+
18
+ if (click != null && typeof click === 'object' && !Array.isArray(click)) {
19
+ const selector = typeof click.selector === 'string' ? click.selector.trim() : ''
20
+ if (!selector) {
21
+ throw new Error('click step requires a non-empty CSS selector')
22
+ }
23
+
24
+ const out = { selector }
25
+ if (click.position != null) {
26
+ if (
27
+ typeof click.position !== 'object'
28
+ || Array.isArray(click.position)
29
+ || !Number.isFinite(Number(click.position.x))
30
+ || !Number.isFinite(Number(click.position.y))
31
+ ) {
32
+ throw new Error('click.position requires { x, y } with finite numbers')
33
+ }
34
+ out.position = {
35
+ x: Number(click.position.x),
36
+ y: Number(click.position.y),
37
+ }
38
+ }
39
+ return out
40
+ }
41
+
42
+ throw new Error('click step requires a non-empty CSS selector')
43
+ }
@@ -0,0 +1,42 @@
1
+ import { describe, expect, it } from '@jest/globals'
2
+ import { resolveClickTarget } from './flow-click.js'
3
+
4
+ describe('resolveClickTarget', () => {
5
+ it('accepts a bare selector string', () => {
6
+ expect(resolveClickTarget('[data-overlay]')).toEqual({
7
+ selector: '[data-overlay]',
8
+ })
9
+ })
10
+
11
+ it('accepts { selector } and trims whitespace', () => {
12
+ expect(resolveClickTarget({ selector: ' [data-ossy-mobile-shell-nav-overlay] ' })).toEqual({
13
+ selector: '[data-ossy-mobile-shell-nav-overlay]',
14
+ })
15
+ })
16
+
17
+ it('accepts optional { position: { x, y } }', () => {
18
+ expect(resolveClickTarget({
19
+ selector: '[data-ossy-mobile-shell-nav-overlay]',
20
+ position: { x: 360, y: 200 },
21
+ })).toEqual({
22
+ selector: '[data-ossy-mobile-shell-nav-overlay]',
23
+ position: { x: 360, y: 200 },
24
+ })
25
+ })
26
+
27
+ it('rejects empty or invalid values', () => {
28
+ expect(() => resolveClickTarget('')).toThrow(/non-empty CSS selector/)
29
+ expect(() => resolveClickTarget(' ')).toThrow(/non-empty CSS selector/)
30
+ expect(() => resolveClickTarget(null)).toThrow(/non-empty CSS selector/)
31
+ expect(() => resolveClickTarget({ selector: '' })).toThrow(/non-empty CSS selector/)
32
+ expect(() => resolveClickTarget([])).toThrow(/non-empty CSS selector/)
33
+ expect(() => resolveClickTarget({
34
+ selector: '[data-overlay]',
35
+ position: { x: 'left' },
36
+ })).toThrow(/position requires/)
37
+ expect(() => resolveClickTarget({
38
+ selector: '[data-overlay]',
39
+ position: 10,
40
+ })).toThrow(/position requires/)
41
+ })
42
+ })
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Flow-run context helpers — `$email`-style placeholders for declarative steps.
3
+ */
4
+
5
+ /**
6
+ * Escape a value for use inside a double-quoted CSS attribute selector.
7
+ * @param {unknown} value
8
+ * @returns {string}
9
+ */
10
+ export function escapeCssAttrValue (value) {
11
+ return String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"')
12
+ }
13
+
14
+ /**
15
+ * Resolve a whole-string `$token` placeholder from the run context.
16
+ * Non-strings and strings that do not start with `$` are returned as-is.
17
+ *
18
+ * @param {unknown} value
19
+ * @param {{ fields?: Record<string, unknown>, [key: string]: unknown }} context
20
+ */
21
+ export function resolveContextValue (value, context) {
22
+ if (typeof value !== 'string' || !value.startsWith('$')) return value
23
+ const key = value.slice(1)
24
+ return context[key] ?? context.fields?.[key]
25
+ }
26
+
27
+ /**
28
+ * Replace `$token` placeholders embedded in a string (e.g. selectors).
29
+ * Unknown tokens are left unchanged.
30
+ *
31
+ * @param {unknown} template
32
+ * @param {{ fields?: Record<string, unknown>, [key: string]: unknown }} context
33
+ * @param {{ escape?: (value: unknown) => string }} [options]
34
+ * @returns {unknown}
35
+ */
36
+ export function interpolateContextString (template, context, options = {}) {
37
+ if (typeof template !== 'string' || !template.includes('$')) return template
38
+ const escape = typeof options.escape === 'function' ? options.escape : (value) => String(value)
39
+ return template.replace(/\$([A-Za-z_][A-Za-z0-9_]*)/g, (match, key) => {
40
+ const value = context[key] ?? context.fields?.[key]
41
+ if (value == null) return match
42
+ return escape(value)
43
+ })
44
+ }
@@ -0,0 +1,44 @@
1
+ import { describe, expect, it } from '@jest/globals'
2
+ import {
3
+ escapeCssAttrValue,
4
+ interpolateContextString,
5
+ resolveContextValue,
6
+ } from './flow-context.js'
7
+
8
+ describe('flow-context', () => {
9
+ const context = {
10
+ email: 'new@example.com',
11
+ fields: { firstName: 'Ada', email: 'field@example.com' },
12
+ }
13
+
14
+ it('resolves whole-string $placeholders from context then fields', () => {
15
+ expect(resolveContextValue('$email', context)).toBe('new@example.com')
16
+ expect(resolveContextValue('$firstName', context)).toBe('Ada')
17
+ expect(resolveContextValue('literal', context)).toBe('literal')
18
+ expect(resolveContextValue('$missing', context)).toBeUndefined()
19
+ })
20
+
21
+ it('interpolates embedded $placeholders in selectors', () => {
22
+ expect(
23
+ interpolateContextString('[data-user-email="$email"]', context),
24
+ ).toBe('[data-user-email="new@example.com"]')
25
+ })
26
+
27
+ it('escapes quotes and backslashes when an escape fn is provided', () => {
28
+ const tricky = {
29
+ email: 'a"b\\c@example.com',
30
+ fields: {},
31
+ }
32
+ expect(
33
+ interpolateContextString('[data-user-email="$email"]', tricky, {
34
+ escape: escapeCssAttrValue,
35
+ }),
36
+ ).toBe('[data-user-email="a\\"b\\\\c@example.com"]')
37
+ })
38
+
39
+ it('leaves unknown embedded tokens unchanged', () => {
40
+ expect(
41
+ interpolateContextString('[data-x="$missing"]', context),
42
+ ).toBe('[data-x="$missing"]')
43
+ })
44
+ })
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Normalize a declarative flow `press` step into a Playwright keyboard key.
3
+ *
4
+ * @param {unknown} press
5
+ * @returns {string}
6
+ */
7
+ export function resolvePressKey (press) {
8
+ if (typeof press === 'string') {
9
+ const key = press.trim()
10
+ if (!key) {
11
+ throw new Error('press step requires a non-empty key string (e.g. "Escape")')
12
+ }
13
+ return key
14
+ }
15
+
16
+ if (press != null && typeof press === 'object' && !Array.isArray(press)) {
17
+ const key = typeof press.key === 'string' ? press.key.trim() : ''
18
+ if (!key) {
19
+ throw new Error('press step requires a non-empty key string (e.g. "Escape")')
20
+ }
21
+ return key
22
+ }
23
+
24
+ throw new Error('press step requires a non-empty key string (e.g. "Escape")')
25
+ }
@@ -0,0 +1,25 @@
1
+ import { describe, expect, it } from '@jest/globals'
2
+ import { resolvePressKey } from './flow-press.js'
3
+
4
+ describe('resolvePressKey', () => {
5
+ it('accepts a bare key string', () => {
6
+ expect(resolvePressKey('Escape')).toBe('Escape')
7
+ expect(resolvePressKey('Enter')).toBe('Enter')
8
+ expect(resolvePressKey('Tab')).toBe('Tab')
9
+ expect(resolvePressKey('Shift+Tab')).toBe('Shift+Tab')
10
+ })
11
+
12
+ it('accepts { key } and trims whitespace', () => {
13
+ expect(resolvePressKey({ key: ' Escape ' })).toBe('Escape')
14
+ expect(resolvePressKey({ key: ' Shift+Tab ' })).toBe('Shift+Tab')
15
+ })
16
+
17
+ it('rejects empty or invalid values', () => {
18
+ expect(() => resolvePressKey('')).toThrow(/non-empty key/)
19
+ expect(() => resolvePressKey(' ')).toThrow(/non-empty key/)
20
+ expect(() => resolvePressKey(null)).toThrow(/non-empty key/)
21
+ expect(() => resolvePressKey({ key: '' })).toThrow(/non-empty key/)
22
+ expect(() => resolvePressKey({ key: 27 })).toThrow(/non-empty key/)
23
+ expect(() => resolvePressKey([])).toThrow(/non-empty key/)
24
+ })
25
+ })
@@ -14,6 +14,23 @@ import { faker } from '@faker-js/faker'
14
14
  import { Router } from '@ossy/router'
15
15
  import { Schema } from '@ossy/schema'
16
16
  import { test, expect } from '@playwright/test'
17
+ import {
18
+ escapeCssAttrValue,
19
+ interpolateContextString,
20
+ resolveContextValue,
21
+ } from './flow-context.js'
22
+ import { resolveClickTarget } from './flow-click.js'
23
+ import { resolvePressKey } from './flow-press.js'
24
+ import { resolveViewportSize } from './flow-viewport.js'
25
+
26
+ export {
27
+ escapeCssAttrValue,
28
+ interpolateContextString,
29
+ resolveContextValue,
30
+ } from './flow-context.js'
31
+ export { resolveClickTarget } from './flow-click.js'
32
+ export { resolvePressKey } from './flow-press.js'
33
+ export { resolveViewportSize } from './flow-viewport.js'
17
34
 
18
35
  const FLOW_PATTERN = /\.flow\.(mjs|cjs|js)$/
19
36
 
@@ -110,13 +127,6 @@ function resolveActionService (action, fallback) {
110
127
  return fallback
111
128
  }
112
129
 
113
- /** Resolve `$token`-style placeholders from the run context. */
114
- function resolveContextValue (value, context) {
115
- if (typeof value !== 'string' || !value.startsWith('$')) return value
116
- const key = value.slice(1)
117
- return context[key] ?? context.fields?.[key]
118
- }
119
-
120
130
  function resolveContextObject (obj, context) {
121
131
  if (!obj || typeof obj !== 'object') return obj
122
132
  const out = {}
@@ -197,6 +207,31 @@ export async function runFlow (flow, options = {}) {
197
207
  continue
198
208
  }
199
209
 
210
+ if (step.viewport != null) {
211
+ if (!page) throw new Error('viewport step requires a Playwright page')
212
+ await page.setViewportSize(resolveViewportSize(step.viewport))
213
+ continue
214
+ }
215
+
216
+ if (step.press != null) {
217
+ if (!page) throw new Error('press step requires a Playwright page')
218
+ await page.keyboard.press(resolvePressKey(step.press))
219
+ continue
220
+ }
221
+
222
+ if (step.click != null) {
223
+ if (!page) throw new Error('click step requires a Playwright page')
224
+ const { selector, position } = resolveClickTarget(step.click)
225
+ const resolvedSelector = interpolateContextString(selector, context, {
226
+ escape: escapeCssAttrValue,
227
+ })
228
+ const locator = page.locator(resolvedSelector).first()
229
+ const clickTimeout = step.timeout ?? 15000
230
+ await locator.waitFor({ state: 'visible', timeout: clickTimeout })
231
+ await locator.click(position ? { position } : undefined)
232
+ continue
233
+ }
234
+
200
235
  if (step.form) {
201
236
  if (!page) throw new Error('form step requires a Playwright page')
202
237
  const formMeta = step.form
@@ -305,16 +340,37 @@ export async function runFlow (flow, options = {}) {
305
340
 
306
341
  if (step.result) {
307
342
  if (!page) throw new Error('result step requires a Playwright page')
308
- const { text, url, page: pageRef, action, selector, timeout = 8000 } = step.result
343
+ const {
344
+ text,
345
+ url,
346
+ page: pageRef,
347
+ action,
348
+ selector,
349
+ hidden = false,
350
+ timeout = 8000,
351
+ } = step.result
352
+ const assertLocator = async (locator) => {
353
+ if (hidden) {
354
+ await expectFn(locator).toBeHidden({ timeout })
355
+ return
356
+ }
357
+ await expectFn(locator).toBeVisible({ timeout })
358
+ }
309
359
  if (action != null) {
310
360
  const actionId = resolveActionId(action)
311
361
  const service = resolveActionService(action, step.result.service)
312
- await expectFn(page.locator(actionSelector(actionId, service)).first()).toBeVisible({ timeout })
362
+ await assertLocator(page.locator(actionSelector(actionId, service)).first())
313
363
  }
314
364
  if (selector != null) {
315
- await expectFn(page.locator(selector).first()).toBeVisible({ timeout })
365
+ const resolvedSelector = interpolateContextString(selector, context, {
366
+ escape: escapeCssAttrValue,
367
+ })
368
+ await assertLocator(page.locator(resolvedSelector).first())
316
369
  }
317
370
  if (text != null) {
371
+ if (hidden) {
372
+ throw new Error('result.text cannot use hidden: true — assert via action or selector')
373
+ }
318
374
  const pattern = text instanceof RegExp ? text : new RegExp(text, 'i')
319
375
  await expectFn(page.getByText(pattern).first()).toBeVisible({ timeout })
320
376
  }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Normalize a declarative flow `viewport` step into Playwright setViewportSize input.
3
+ *
4
+ * @param {unknown} viewport
5
+ * @returns {{ width: number, height: number }}
6
+ */
7
+ export function resolveViewportSize (viewport) {
8
+ if (viewport == null || typeof viewport !== 'object' || Array.isArray(viewport)) {
9
+ throw new Error('viewport step requires { width, height } with positive numbers')
10
+ }
11
+
12
+ const width = Number(viewport.width)
13
+ const height = Number(viewport.height)
14
+
15
+ if (!Number.isFinite(width) || width <= 0 || !Number.isFinite(height) || height <= 0) {
16
+ throw new Error('viewport step requires { width, height } with positive numbers')
17
+ }
18
+
19
+ return {
20
+ width: Math.round(width),
21
+ height: Math.round(height),
22
+ }
23
+ }
@@ -0,0 +1,26 @@
1
+ import { describe, expect, it } from '@jest/globals'
2
+ import { resolveViewportSize } from './flow-viewport.js'
3
+
4
+ describe('resolveViewportSize', () => {
5
+ it('accepts positive width/height', () => {
6
+ expect(resolveViewportSize({ width: 390, height: 844 })).toEqual({
7
+ width: 390,
8
+ height: 844,
9
+ })
10
+ })
11
+
12
+ it('rounds fractional dimensions', () => {
13
+ expect(resolveViewportSize({ width: 389.6, height: 843.2 })).toEqual({
14
+ width: 390,
15
+ height: 843,
16
+ })
17
+ })
18
+
19
+ it('rejects missing or non-positive sizes', () => {
20
+ expect(() => resolveViewportSize(null)).toThrow(/width, height/)
21
+ expect(() => resolveViewportSize({ width: 390 })).toThrow(/width, height/)
22
+ expect(() => resolveViewportSize({ width: 0, height: 844 })).toThrow(/width, height/)
23
+ expect(() => resolveViewportSize({ width: -1, height: 844 })).toThrow(/width, height/)
24
+ expect(() => resolveViewportSize('mobile')).toThrow(/width, height/)
25
+ })
26
+ })
@@ -173,6 +173,30 @@ export class TestUtil {
173
173
  return ev.payload.token
174
174
  }
175
175
 
176
+ static async getLatestEmailChangeJwtForSubject(subjectId) {
177
+ const ev = await EventStore.Collection.findOne(
178
+ {
179
+ type: TOKEN_SCHEMA,
180
+ event: 'Created',
181
+ 'payload.type': 'EmailChange',
182
+ 'payload.subject': subjectId,
183
+ },
184
+ { sort: { created: -1 } },
185
+ )
186
+ if (!ev?.payload?.token) {
187
+ return Promise.reject(new Error('No EmailChange token found for subject'))
188
+ }
189
+ return ev.payload.token
190
+ }
191
+
192
+ static countEmailChangeTokenEvents() {
193
+ return EventStore.Collection.countDocuments({
194
+ type: TOKEN_SCHEMA,
195
+ event: 'Created',
196
+ 'payload.type': 'EmailChange',
197
+ })
198
+ }
199
+
176
200
  static GetVerificationToken() {
177
201
  const email = `${casual.email}`
178
202
 
@@ -192,7 +216,7 @@ export class TestUtil {
192
216
 
193
217
  await TestUtil.AssertActionResponse({
194
218
  actionId: '@ossy/authentication/actions/sign-up',
195
- body: TestUtil.signUpBody({ email }),
219
+ payload: { email, firstName: 'Test', lastName: 'User' },
196
220
  expectedResponseStatus: 200,
197
221
  expectedResponseBody: { ok: true },
198
222
  })