@ossy/platform 3.0.9 → 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/README.md CHANGED
@@ -53,6 +53,8 @@ The server reads configuration from:
53
53
  - `build/manifest.json` — produced by `@ossy/app build`.
54
54
  - `process.env` — used by integrations for their credentials and by startup hooks.
55
55
 
56
+ Platform integrations are **boot-time and process-global** via `IntegrationService`. Per-workspace credentials are a separate design — see [WORKSPACE-INTEGRATION-SECRETS.md](../../docs/concepts/WORKSPACE-INTEGRATION-SECRETS.md) (SPEC only; not implemented).
57
+
56
58
  Optional environment variables used by the platform itself:
57
59
 
58
60
  | Variable | Description |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ossy/platform",
3
- "version": "3.0.9",
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.0.9",
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": "908fae28675f6c3c179d4c2eecb451e8a1af886e"
77
+ "gitHead": "b045f1fd7f2fe00c776b9ea96f76083b93fd8556"
78
78
  }
package/src/Definition.js CHANGED
@@ -1,7 +1,7 @@
1
1
  export const Definition = {
2
2
  id: 'platform',
3
3
  title: 'Platform',
4
- description: 'Deployment platform configuration aligned with deployment-tools S3 platform-config.json.',
4
+ description: 'Deployment platform configuration aligned with deployment-tools platforms.json / PlatformConfig.',
5
5
  icon: 'controller',
6
6
  status: ['beta'],
7
7
  entitlementRequired: false,
@@ -1,12 +1,10 @@
1
1
  /**
2
- * Matches the shape written to S3 as `platform-config.json` by deployment-tools
3
- * (`BucketDeployment` → `Source.jsonData('platform-config.json', { ...config, awsRoleToAssume: undefined })`).
2
+ * Matches the `platforms.json` / PlatformConfig shape used by `@ossy/deployment-tools`.
4
3
  *
5
4
  * @see ossy/packages/deployment-tools/src/config/platform-config.js (PlatformConfig typedef)
6
- * @see ossy/packages/deployment-tools/src/infrastructure/container-deployment-target/container-deployment-target.js
7
5
  *
8
- * Note: `awsRoleToAssume` is intentionally omitted from the uploaded file (secret); do not store it here.
9
- * Optional `sesDomains` / `dnsRecords` are JSON text fields for arrays/objects.
6
+ * Note: `env` secret values live in `platforms.json` and Secrets Manager; do not store them here.
7
+ * Optional `domains` / `services` / `sesDomains` / `dnsRecords` / `githubDeployRepos` are JSON text fields.
10
8
  */
11
9
  export default {
12
10
  name: 'Platform config',
@@ -34,29 +32,29 @@ export default {
34
32
  type: 'text',
35
33
  },
36
34
  {
37
- name: 'awsKeyPairName',
38
- label: 'awsKeyPairName',
39
- type: 'text',
35
+ name: 'domains',
36
+ label: 'domains (JSON array — Route53 → CloudFront + ACM)',
37
+ type: 'textarea',
40
38
  },
41
39
  {
42
- name: 'awsStaticBucketName',
43
- label: 'awsStaticBucketName',
44
- type: 'text',
40
+ name: 'services',
41
+ label: 'services (JSON array — HTTP → ECS/ALB; TCP ignored)',
42
+ type: 'textarea',
45
43
  },
46
44
  {
47
- name: 'awsDeploymentSqsName',
48
- label: 'awsDeploymentSqsName',
49
- type: 'text',
45
+ name: 'githubDeployRepos',
46
+ label: 'githubDeployRepos (JSON array of owner/repo for OIDC)',
47
+ type: 'textarea',
50
48
  },
51
49
  {
52
- name: 'awsDeploymentSqsArn',
53
- label: 'awsDeploymentSqsArn',
54
- type: 'text',
50
+ name: 'sesDomains',
51
+ label: 'sesDomains (JSON array of strings, optional)',
52
+ type: 'textarea',
55
53
  },
56
54
  {
57
- name: 'ciGithubActionsRepo',
58
- label: 'ciGithubActionsRepo (org/repo)',
59
- type: 'text',
55
+ name: 'dnsRecords',
56
+ label: 'dnsRecords (JSON object, optional)',
57
+ type: 'textarea',
60
58
  },
61
59
  {
62
60
  name: 'ciDockerNetworkName',
@@ -64,14 +62,9 @@ export default {
64
62
  type: 'text',
65
63
  },
66
64
  {
67
- name: 'sesDomains',
68
- label: 'sesDomains (JSON array of strings, optional)',
69
- type: 'textarea',
70
- },
71
- {
72
- name: 'dnsRecords',
73
- label: 'dnsRecords (JSON object, optional)',
74
- type: 'textarea',
65
+ name: 'awsKeyPairName',
66
+ label: 'awsKeyPairName (unused after EC2 decommission)',
67
+ type: 'text',
75
68
  },
76
69
  {
77
70
  name: 'Notes',
@@ -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
  })