@ossy/platform 3.1.0 → 3.2.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.2.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.2.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": "b045f1fd7f2fe00c776b9ea96f76083b93fd8556"
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,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
+ })
@@ -14,6 +14,17 @@ 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
+
23
+ export {
24
+ escapeCssAttrValue,
25
+ interpolateContextString,
26
+ resolveContextValue,
27
+ } from './flow-context.js'
17
28
 
18
29
  const FLOW_PATTERN = /\.flow\.(mjs|cjs|js)$/
19
30
 
@@ -110,13 +121,6 @@ function resolveActionService (action, fallback) {
110
121
  return fallback
111
122
  }
112
123
 
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
124
  function resolveContextObject (obj, context) {
121
125
  if (!obj || typeof obj !== 'object') return obj
122
126
  const out = {}
@@ -312,7 +316,10 @@ export async function runFlow (flow, options = {}) {
312
316
  await expectFn(page.locator(actionSelector(actionId, service)).first()).toBeVisible({ timeout })
313
317
  }
314
318
  if (selector != null) {
315
- await expectFn(page.locator(selector).first()).toBeVisible({ timeout })
319
+ const resolvedSelector = interpolateContextString(selector, context, {
320
+ escape: escapeCssAttrValue,
321
+ })
322
+ await expectFn(page.locator(resolvedSelector).first()).toBeVisible({ timeout })
316
323
  }
317
324
  if (text != null) {
318
325
  const pattern = text instanceof RegExp ? text : new RegExp(text, 'i')
@@ -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
  })