@ossy/platform 3.11.0 → 3.12.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/Dockerfile CHANGED
@@ -1,16 +1,15 @@
1
1
  # Build context: ossy/packages/platform directory.
2
- # From repo root: docker build -f ossy/packages/platform/Dockerfile ossy/packages/platform
3
- # In CI, this image is built and pushed to ghcr.io/ossy-se/runtime:latest by the Publish workflow.
2
+ # From repo root: docker build -f packages/platform/Dockerfile packages/platform
3
+ # Pushed by the manual "Deploy platform runtime" workflow (not npm Publish).
4
4
  FROM node:24-bookworm-slim
5
5
 
6
6
  WORKDIR /app
7
7
 
8
8
  COPY package.json ./
9
9
 
10
- # Install production deps only.
11
- # @ossy/router and @ossy/sdk must be resolvable in production these are
12
- # published npm packages; in monorepo CI use npm pack + install.
13
- RUN npm install --omit=dev --no-audit --no-fund --loglevel=error
10
+ # Production deps from npm. --legacy-peer-deps: transitive peers on an older
11
+ # lockstep patch must not fail the image when ranges are otherwise valid.
12
+ RUN npm install --omit=dev --no-audit --no-fund --loglevel=error --legacy-peer-deps
14
13
 
15
14
  COPY src ./src
16
15
  COPY docker-healthcheck.js ./docker-healthcheck.js
@@ -29,7 +28,7 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
29
28
 
30
29
  # Required env vars at runtime:
31
30
  # OSSY_API_KEY — Ossy API JWT for CMS reads
32
- # OSSY_API_URL — (optional) override API base, default https://api.ossy.se/api/v0
31
+ # OSSY_API_URL — (optional) override API base, default https://ossy.se/api/v0
33
32
  # PORT — (optional) override listen port, default 3000
34
33
  # OSSY_SERVICE_NAME — (optional) `/health` service label, default runtime
35
34
  CMD ["node", "src/runtime.js"]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ossy/platform",
3
- "version": "3.11.0",
3
+ "version": "3.12.0",
4
4
  "description": "Ossy application server runtime",
5
5
  "repository": {
6
6
  "type": "git",
@@ -27,6 +27,7 @@
27
27
  "./test/flow-runner.js": "./src/test/flow-runner.js",
28
28
  "./test/playwright.config.js": "./src/test/playwright.config.js",
29
29
  "./locale": "./src/locale.js",
30
+ "./verify-sign-in-session": "./src/verify-sign-in-session.js",
30
31
  "./storage-keys": "./src/storage/storage-keys.js",
31
32
  "./mcp": "./src/mcp/mount-platform-mcp.js"
32
33
  },
@@ -45,16 +46,16 @@
45
46
  "@aws-sdk/util-format-url": "^3.972.17",
46
47
  "@modelcontextprotocol/sdk": "^1.12.1",
47
48
  "@ossy/config": "^3.0.9",
48
- "@ossy/event-store": "^3.11.0",
49
+ "@ossy/event-store": "^3.12.0",
49
50
  "@ossy/locale": "^3.4.0",
50
51
  "@ossy/manifest": "^3.9.0",
51
52
  "@ossy/observability": "^3.0.9",
52
53
  "@ossy/policies": "^3.0.9",
53
54
  "@ossy/schema": "^3.8.0",
54
- "@ossy/sdk": "^3.5.0",
55
- "@ossy/tokens": "^3.5.0",
56
- "@ossy/users": "^3.11.0",
57
- "@ossy/workspaces": "^3.11.0",
55
+ "@ossy/sdk": "^3.12.0",
56
+ "@ossy/tokens": "^3.11.1",
57
+ "@ossy/users": "^3.12.0",
58
+ "@ossy/workspaces": "^3.12.0",
58
59
  "cookie-parser": "^1.4.7",
59
60
  "dotenv": ">=16.0.0 <18.0.0",
60
61
  "express": ">=5.0.0 <6.0.0",
@@ -75,5 +76,5 @@
75
76
  "Dockerfile",
76
77
  "docker-healthcheck.js"
77
78
  ],
78
- "gitHead": "53ff8456920e09151c9d88df4a6c3ee958d811eb"
79
+ "gitHead": "c2650803219ad1831559b512848358d9550ffad2"
79
80
  }
package/src/index.js CHANGED
@@ -8,6 +8,7 @@ export { validateSchemasForImport, ALLOWED_FIELD_TYPES, normalizeFieldType, reso
8
8
  export { IntegrationService } from './integration.service.js'
9
9
  export { ConfigService } from './config.service.js'
10
10
  export { ActionService } from './actions/action.service.js'
11
+ export { applyVerifySignInSessionCookies } from './verify-sign-in-session.js'
11
12
  export { TokenService } from './token.service.js'
12
13
  export { UsersMiddleware } from './users.middleware.js'
13
14
  export { WorkspacesMiddleware } from './workspaces.middleware.js'
@@ -14,6 +14,25 @@ function normalizeWorkspaceIdHeader (value) {
14
14
  return first || undefined
15
15
  }
16
16
 
17
+ /**
18
+ * Map `/@ossy…` to the same-process path (`/actions` or `/api/v0…`).
19
+ * @param {string} originalUrl
20
+ * @returns {string}
21
+ */
22
+ export function ossyProxyLocalPath (originalUrl) {
23
+ const pathAfterOssy = String(originalUrl || '').replace(/^\/@ossy/, '') || '/'
24
+ const pathOnly = pathAfterOssy.split('?')[0]
25
+ if (pathOnly === '/actions' || pathOnly === '/events') {
26
+ return pathAfterOssy.startsWith('/') ? pathAfterOssy : `/${pathAfterOssy}`
27
+ }
28
+ return `/api/v0${pathAfterOssy.startsWith('/') ? pathAfterOssy : `/${pathAfterOssy}`}`
29
+ }
30
+
31
+ /** Absolute OSSY_API_URL → HTTP hop. Unset / relative → same Express app. */
32
+ export function isRemoteOssyApiUrl (envUrl = process.env.OSSY_API_URL) {
33
+ return /^https?:\/\//i.test(String(envUrl || '').trim())
34
+ }
35
+
17
36
  export function ProxyInternal () {
18
37
  return (req, res, next) => {
19
38
  if (!req.originalUrl.startsWith('/@ossy')) {
@@ -47,15 +66,20 @@ export function ProxyInternal () {
47
66
  return
48
67
  }
49
68
 
69
+ if (!isRemoteOssyApiUrl()) {
70
+ const dest = ossyProxyLocalPath(req.originalUrl)
71
+ log.info(`[@ossy/platform][proxy] ${req.method} ${req.originalUrl} → ${dest} (same origin)`)
72
+ req.url = dest
73
+ req.originalUrl = dest
74
+ return next()
75
+ }
76
+
50
77
  log.info(`[@ossy/platform][proxy] ${req.method} ${req.originalUrl}`)
51
78
 
52
- const domain = (process.env.OSSY_API_URL || 'https://api.ossy.se')
79
+ const domain = String(process.env.OSSY_API_URL).trim()
53
80
  .replace(/\/api\/v0\/?$/, '')
54
81
  .replace(/\/$/, '')
55
- const pathAfterOssy = req.originalUrl.replace(/^\/@ossy/, '') || '/'
56
- // POST /actions lives on the app server root — not under /api/v0.
57
- const upstreamPath = pathAfterOssy === '/actions' ? '/actions' : `/api/v0${pathAfterOssy}`
58
- const url = `${domain}${upstreamPath}`
82
+ const url = `${domain}${ossyProxyLocalPath(req.originalUrl)}`
59
83
  const forwardedHeaders = JSON.parse(JSON.stringify(req.headers))
60
84
  const workspaceId = normalizeWorkspaceIdHeader(req.get('workspaceId'))
61
85
 
@@ -0,0 +1,62 @@
1
+ import { afterEach, describe, expect, it, jest } from '@jest/globals'
2
+ import {
3
+ isRemoteOssyApiUrl,
4
+ ossyProxyLocalPath,
5
+ ProxyInternal,
6
+ } from './proxy-internal.js'
7
+
8
+ describe('ossyProxyLocalPath', () => {
9
+ it('maps REST under /@ossy onto /api/v0', () => {
10
+ expect(ossyProxyLocalPath('/@ossy/users/me')).toBe('/api/v0/users/me')
11
+ expect(ossyProxyLocalPath('/@ossy/apps/ask?domain=ossy.se'))
12
+ .toBe('/api/v0/apps/ask?domain=ossy.se')
13
+ })
14
+
15
+ it('keeps POST /actions and GET /events at the server root', () => {
16
+ expect(ossyProxyLocalPath('/@ossy/actions')).toBe('/actions')
17
+ expect(ossyProxyLocalPath('/@ossy/actions?x=1')).toBe('/actions?x=1')
18
+ expect(ossyProxyLocalPath('/@ossy/events')).toBe('/events')
19
+ })
20
+ })
21
+
22
+ describe('isRemoteOssyApiUrl', () => {
23
+ it('treats unset, empty, and relative values as same-origin', () => {
24
+ expect(isRemoteOssyApiUrl(undefined)).toBe(false)
25
+ expect(isRemoteOssyApiUrl('')).toBe(false)
26
+ expect(isRemoteOssyApiUrl('/api/v0')).toBe(false)
27
+ })
28
+
29
+ it('treats absolute http(s) as a remote hop', () => {
30
+ expect(isRemoteOssyApiUrl('https://ossy.se/api/v0')).toBe(true)
31
+ expect(isRemoteOssyApiUrl('http://localhost:3006')).toBe(true)
32
+ })
33
+ })
34
+
35
+ describe('ProxyInternal same-origin rewrite', () => {
36
+ const original = process.env.OSSY_API_URL
37
+
38
+ afterEach(() => {
39
+ if (original === undefined) delete process.env.OSSY_API_URL
40
+ else process.env.OSSY_API_URL = original
41
+ })
42
+
43
+ it('rewrites /@ossy onto this process when OSSY_API_URL is unset', () => {
44
+ delete process.env.OSSY_API_URL
45
+ const req = { originalUrl: '/@ossy/users/me', url: '/@ossy/users/me', method: 'GET' }
46
+ const next = jest.fn()
47
+ ProxyInternal()(req, {}, next)
48
+ expect(req.url).toBe('/api/v0/users/me')
49
+ expect(req.originalUrl).toBe('/api/v0/users/me')
50
+ expect(next).toHaveBeenCalledTimes(1)
51
+ })
52
+
53
+ it('rewrites /@ossy/actions onto POST /actions', () => {
54
+ delete process.env.OSSY_API_URL
55
+ const req = { originalUrl: '/@ossy/actions', url: '/@ossy/actions', method: 'POST' }
56
+ const next = jest.fn()
57
+ ProxyInternal()(req, {}, next)
58
+ expect(req.url).toBe('/actions')
59
+ expect(req.originalUrl).toBe('/actions')
60
+ expect(next).toHaveBeenCalledTimes(1)
61
+ })
62
+ })
package/src/runtime.js CHANGED
@@ -71,13 +71,24 @@ async function getSiteContext (domain) {
71
71
  *
72
72
  * Environment variables:
73
73
  * OSSY_API_KEY — API JWT for CMS reads (required)
74
- * OSSY_API_URL — Override API base URL (default: https://api.ossy.se/api/v0)
74
+ * OSSY_API_URL — Override API base URL (default: https://ossy.se/api/v0)
75
75
  * PORT — Override listen port (default: 3000)
76
76
  */
77
77
  export async function startRuntime ({ port } = {}) {
78
78
  const resolvedPort = port ?? resolvePort()
79
79
 
80
80
  const app = express()
81
+ // CloudFront → ALB → Node: hop count so `req.ip` is the viewer (#769).
82
+ // Default 2. Avoid `true` (leftmost XFF — spoofable). Override via OSSY_TRUST_PROXY_HOPS.
83
+ {
84
+ const raw = process.env.OSSY_TRUST_PROXY_HOPS
85
+ if (raw != null && String(raw).trim() !== '') {
86
+ const hops = Number(raw)
87
+ app.set('trust proxy', Number.isFinite(hops) && hops >= 0 ? hops : 2)
88
+ } else {
89
+ app.set('trust proxy', 2)
90
+ }
91
+ }
81
92
  // Liveness for ALB/ECS — before site loading so probes never depend on CMS/domain.
82
93
  // Prefer OSSY_SERVICE_NAME (ECS task env) when set.
83
94
  mountHealthEndpoint(app)
@@ -97,7 +108,7 @@ export async function startRuntime ({ port } = {}) {
97
108
  next()
98
109
  })
99
110
 
100
- // Proxy /@ossy/* to the upstream Ossy API
111
+ // Proxy leftover /@ossy/* onto this process (/api/v0, /actions) unless OSSY_API_URL is absolute.
101
112
  app.use(ProxyInternal())
102
113
 
103
114
  // Force a fresh site download without waiting for the TTL to expire
package/src/server.js CHANGED
@@ -43,8 +43,11 @@ import {
43
43
  import {
44
44
  clearAuthCookie,
45
45
  clearWorkspaceFromUserAppSettings,
46
+ mergeUserAppSettingsCookie,
46
47
  readWorkspaceIdFromCookies,
48
+ setAuthCookie,
47
49
  } from './user-app-settings.js'
50
+ import { applyVerifySignInSessionCookies } from './verify-sign-in-session.js'
48
51
  const log = createLogger('@ossy/platform')
49
52
  const MONGO_TIMEOUT_MS = resolveTimeoutMs('OSSY_MONGO_TIMEOUT_MS', 10_000)
50
53
  const SSR_RENDER_TIMEOUT_MS = resolveTimeoutMs('OSSY_SSR_RENDER_TIMEOUT_MS', 30_000)
@@ -323,6 +326,17 @@ export async function startServer (options = {}) {
323
326
  }
324
327
 
325
328
  const app = express()
329
+ // CloudFront → ALB → Node: hop count so `req.ip` is the viewer (#769).
330
+ // Default 2. Avoid `true` (leftmost XFF — spoofable). Override via OSSY_TRUST_PROXY_HOPS.
331
+ {
332
+ const raw = process.env.OSSY_TRUST_PROXY_HOPS
333
+ if (raw != null && String(raw).trim() !== '') {
334
+ const hops = Number(raw)
335
+ app.set('trust proxy', Number.isFinite(hops) && hops >= 0 ? hops : 2)
336
+ } else {
337
+ app.set('trust proxy', 2)
338
+ }
339
+ }
326
340
  // Liveness for ALB/ECS — before auth so probes never depend on cookies/Mongo.
327
341
  // Prefer OSSY_SERVICE_NAME (ECS task env) when set.
328
342
  mountHealthEndpoint(app)
@@ -427,6 +441,9 @@ export async function startServer (options = {}) {
427
441
  clearAuthCookie(res)
428
442
  clearWorkspaceFromUserAppSettings(req, res)
429
443
  }
444
+ if (actionId === '@ossy/authentication/actions/verify-sign-in' && result?.token) {
445
+ await applyVerifySignInSessionCookies(req, res, result)
446
+ }
430
447
  res.json(result ?? { ok: true })
431
448
  } catch (err) {
432
449
  // Still clear auth on sign-out failures so a broken session can recover.
@@ -670,6 +687,7 @@ export default startServer
670
687
  export { loadLayoutsById, resolvePageLayoutRender }
671
688
  export { ConfigService } from './config.service.js'
672
689
  export { ActionService } from './actions/action.service.js'
690
+ export { applyVerifySignInSessionCookies } from './verify-sign-in-session.js'
673
691
  export { IntegrationService } from './integration.service.js'
674
692
  export { StorageClient } from './storage/storage.client.js'
675
693
  export { originalObjectKey, derivativeObjectKey, assertStorageKey } from './storage/storage-keys.js'
@@ -10,7 +10,7 @@ const CACHE_TTL_MS = 5 * 60 * 1000 // 5 minutes
10
10
  const DOWNLOAD_CONCURRENCY = 10
11
11
  const BASE_DIR = path.join(os.tmpdir(), 'ossy-rt')
12
12
 
13
- const API_URL = process.env.OSSY_API_URL || 'https://api.ossy.se/api/v0'
13
+ const API_URL = process.env.OSSY_API_URL || 'https://ossy.se/api/v0'
14
14
  const API_KEY = process.env.OSSY_API_KEY
15
15
 
16
16
  /** @type {Map<string, { buildDir: string, workspaceId: string, loadedAt: number }>} */
@@ -4,6 +4,7 @@ import { IntegrationService } from '../integration.service.js'
4
4
  import { createLogger } from '@ossy/observability'
5
5
  import { isPrimaryTaskForAction, taskIdFromActionId } from '@ossy/schema'
6
6
  import { TaskRunService } from '../audit/task-run-service.js'
7
+ import { resolveClientIp, runWithEventContext } from '@ossy/event-store'
7
8
 
8
9
  const SCHEDULER_INTERVAL_MS = 60_000
9
10
  const _serviceLog = createLogger('TaskService')
@@ -108,18 +109,20 @@ export class TaskService {
108
109
  log: context.log ?? createLogger(id),
109
110
  }
110
111
 
111
- return TaskRunService.execute({
112
- taskId: id,
113
- handler: task.handler,
114
- context: invokeContext,
115
- trigger: 'invoke',
116
- triggeredBy: {
117
- channel: context.req ? undefined : 'api',
118
- actionInvocationId: context.actionInvocationId ?? null,
119
- },
120
- executionEnv: 'ossy_server',
121
- audit,
122
- })
112
+ return runWithEventContext({ ip: resolveClientIp(context.req) }, () =>
113
+ TaskRunService.execute({
114
+ taskId: id,
115
+ handler: task.handler,
116
+ context: invokeContext,
117
+ trigger: 'invoke',
118
+ triggeredBy: {
119
+ channel: context.req ? undefined : 'api',
120
+ actionInvocationId: context.actionInvocationId ?? null,
121
+ },
122
+ executionEnv: 'ossy_server',
123
+ audit,
124
+ }),
125
+ )
123
126
  }
124
127
 
125
128
  /** @param {string} id */
@@ -26,6 +26,7 @@ import { assertDownloadFilenames, resolveDownloadTarget } from './flow-download.
26
26
  import { resolveFilesTarget } from './flow-files.js'
27
27
  import { resolvePressKey } from './flow-press.js'
28
28
  import { resolveViewportSize } from './flow-viewport.js'
29
+ import { flowBelongsToShard, parseFlowShard, sortFlowsForShard } from './flow-shard.js'
29
30
 
30
31
  export {
31
32
  applyFormFieldOverrides,
@@ -39,6 +40,12 @@ export {
39
40
  resolveActionMatchers,
40
41
  resolveActionService,
41
42
  } from './flow-action.js'
43
+ export {
44
+ flowBelongsToShard,
45
+ flowIdsForShard,
46
+ parseFlowShard,
47
+ sortFlowsForShard,
48
+ } from './flow-shard.js'
42
49
  export { resolveClickTarget } from './flow-click.js'
43
50
  export {
44
51
  assertDownloadFilenames,
@@ -405,13 +412,13 @@ export async function runFlow (flow, options = {}) {
405
412
  const selector = actionSelector(actionId, matchers)
406
413
  const actionTimeout = step.timeout ?? 15000
407
414
  const actionDeadline = Date.now() + actionTimeout
408
- // SSR markup is visible before React attaches onClick. Wait a paint+microtask
409
- // so hydration can finish; then use DOM click() (Playwright pointer clicks can
410
- // miss React handlers when overlays intercept hit-testing).
415
+ // SSR markup is visible before React attaches onClick. Wait for visibility,
416
+ // then click: form submits use remount-safe evaluate + requestSubmit; other
417
+ // CTAs use Playwright click (programmatic el.click() often skips React onClick).
411
418
  //
412
- // Re-query the live node after the settle wait: concurrent auth/workspace
413
- // fetches can remount the tree and detach Playwright locators mid-scroll
414
- // (and wipe uncontrolled form state). Retry within the action timeout.
419
+ // Re-query the live node after settle: concurrent auth/workspace fetches can
420
+ // remount the tree and detach Playwright locators mid-scroll (and wipe
421
+ // uncontrolled form state). Retry within the action timeout.
415
422
  // Prefer an actionable match when duplicates exist (e.g. hero CTA under a page overlay).
416
423
  let clicked = false
417
424
  let lastError
@@ -432,52 +439,63 @@ export async function runFlow (flow, options = {}) {
432
439
  state: 'visible',
433
440
  timeout: Math.min(5000, Math.max(250, actionDeadline - Date.now())),
434
441
  })
435
- // Scroll inside evaluate via a fresh querySelector — Playwright's
436
- // scrollIntoViewIfNeeded holds a locator through a stability wait and
437
- // throws "not attached" when GetWorkspace remounts PackageServiceToggle.
438
442
  const fieldRestore = { ...(context.fields ?? {}) }
439
443
  if (context.email != null && fieldRestore.email == null) {
440
444
  fieldRestore.email = context.email
441
445
  }
442
- await page.evaluate(async ({ selector: sel, fields }) => {
443
- await new Promise((resolve) => {
444
- requestAnimationFrame(() => requestAnimationFrame(resolve))
445
- })
446
- await new Promise((resolve) => setTimeout(resolve, 500))
447
- const el = document.querySelector(sel)
448
- if (!el) throw new Error(`Action control disappeared after settle: ${sel}`)
449
- if (typeof el.scrollIntoView === 'function') {
450
- el.scrollIntoView({ block: 'nearest', inline: 'nearest' })
451
- }
452
- const form = el.closest('form')
453
- if (form && fields && typeof fields === 'object') {
454
- for (const [name, value] of Object.entries(fields)) {
455
- if (value == null || typeof value === 'object') continue
456
- const input = form.querySelector(`[name="${name}"]`)
457
- if (!input || input.disabled) continue
458
- // File inputs throw InvalidStateError if value is set programmatically.
459
- if (input instanceof HTMLInputElement && input.type === 'file') continue
460
- const asText = String(value)
461
- if (input.value === asText) continue
462
- const proto = Object.getPrototypeOf(input)
463
- const desc = Object.getOwnPropertyDescriptor(proto, 'value')
464
- desc?.set?.call(input, asText)
465
- input.dispatchEvent(new Event('input', { bubbles: true }))
466
- input.dispatchEvent(new Event('change', { bubbles: true }))
446
+ // Form submits: restore fills + requestSubmit in one evaluate (remount-safe).
447
+ // Other CTAs (Enable, Publish, OpenExport): Playwright click — programmatic
448
+ // el.click() often never invokes React onClick (no /actions POST; publish stays off).
449
+ const isSubmitButton = await candidate.evaluate((el) => {
450
+ const form = (el instanceof HTMLButtonElement || el instanceof HTMLInputElement)
451
+ ? el.form
452
+ : el.closest?.('form')
453
+ if (!form) return false
454
+ return (el instanceof HTMLButtonElement || el instanceof HTMLInputElement)
455
+ ? el.type === 'submit'
456
+ : el.getAttribute?.('type') === 'submit'
457
+ })
458
+ if (isSubmitButton) {
459
+ await page.evaluate(async ({ selector: sel, fields }) => {
460
+ await new Promise((resolve) => {
461
+ requestAnimationFrame(() => requestAnimationFrame(resolve))
462
+ })
463
+ await new Promise((resolve) => setTimeout(resolve, 500))
464
+ const el = document.querySelector(sel)
465
+ if (!el) throw new Error(`Action control disappeared after settle: ${sel}`)
466
+ if (typeof el.scrollIntoView === 'function') {
467
+ el.scrollIntoView({ block: 'nearest', inline: 'nearest' })
467
468
  }
468
- }
469
- const isSubmit = form && (
470
- (el instanceof HTMLButtonElement && el.type === 'submit')
471
- || el.getAttribute('type') === 'submit'
472
- )
473
- if (isSubmit && typeof form.requestSubmit === 'function') {
474
- form.requestSubmit(el)
475
- } else if (typeof el.click === 'function') {
476
- el.click()
477
- } else {
478
- el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window }))
479
- }
480
- }, { selector, fields: fieldRestore })
469
+ const form = (el instanceof HTMLButtonElement || el instanceof HTMLInputElement)
470
+ ? el.form
471
+ : el.closest?.('form')
472
+ if (form && fields && typeof fields === 'object') {
473
+ for (const [name, value] of Object.entries(fields)) {
474
+ if (value == null || typeof value === 'object') continue
475
+ const input = form.querySelector(`[name="${name}"]`)
476
+ if (!input || input.disabled) continue
477
+ if (input instanceof HTMLInputElement && input.type === 'file') continue
478
+ const asText = String(value)
479
+ if (input.value === asText) continue
480
+ const proto = Object.getPrototypeOf(input)
481
+ const desc = Object.getOwnPropertyDescriptor(proto, 'value')
482
+ desc?.set?.call(input, asText)
483
+ input.dispatchEvent(new Event('input', { bubbles: true }))
484
+ input.dispatchEvent(new Event('change', { bubbles: true }))
485
+ }
486
+ }
487
+ if (form && typeof form.requestSubmit === 'function') {
488
+ form.requestSubmit(el)
489
+ return
490
+ }
491
+ if (typeof el.click === 'function') el.click()
492
+ }, { selector, fields: fieldRestore })
493
+ } else {
494
+ await candidate.scrollIntoViewIfNeeded().catch(() => {})
495
+ await candidate.click({
496
+ timeout: Math.min(5000, Math.max(250, actionDeadline - Date.now())),
497
+ })
498
+ }
481
499
  clicked = true
482
500
  break
483
501
  } catch (err) {
@@ -538,6 +556,42 @@ export async function runFlow (flow, options = {}) {
538
556
  continue
539
557
  }
540
558
 
559
+ if (step.inputs != null) {
560
+ if (!page) throw new Error('inputs step requires a Playwright page')
561
+ const inputsTimeout = step.timeout ?? 15000
562
+ for (const [selector, valueSpec] of Object.entries(step.inputs)) {
563
+ const value = resolveContextValue(valueSpec, context)
564
+ if (value == null) {
565
+ throw new Error(`inputs step: no value resolved for selector "${selector}"`)
566
+ }
567
+ const resolvedSelector = interpolateContextString(selector, context, {
568
+ escape: escapeCssAttrValue,
569
+ })
570
+ const locator = page.locator(resolvedSelector).first()
571
+ await locator.waitFor({ state: 'visible', timeout: inputsTimeout })
572
+ const asText = String(value)
573
+ for (let attempt = 0; attempt < 3; attempt++) {
574
+ await locator.fill(asText)
575
+ await locator.blur().catch(() => {})
576
+ try {
577
+ await expectFn(locator).toHaveValue(asText, { timeout: 2000 })
578
+ break
579
+ } catch (err) {
580
+ if (attempt === 2) throw err
581
+ await page.waitForTimeout(150)
582
+ }
583
+ }
584
+ // Remount-safe action submits restore from context.fields by input name.
585
+ // Keep ad-hoc fills (e.g. sign-in code) in that map so settle remounts
586
+ // do not wipe values before requestSubmit.
587
+ const name = await locator.getAttribute('name')
588
+ if (name) {
589
+ context.fields = { ...(context.fields ?? {}), [name]: asText }
590
+ }
591
+ }
592
+ continue
593
+ }
594
+
541
595
  if (step.capture != null) {
542
596
  if (!page) throw new Error('capture step requires a Playwright page')
543
597
  for (const [key, spec] of Object.entries(step.capture)) {
@@ -567,37 +621,109 @@ export async function runFlow (flow, options = {}) {
567
621
  const to = resolveContextValue(emailStep.to ?? '$email', context)
568
622
  const templateId = emailStep.id ?? emailStep.templateId
569
623
  const clickLabel = emailStep.click ?? emailStep.link
624
+ const extract = emailStep.extract
570
625
  if (!to) throw new Error('email step requires "to" (recipient email)')
571
626
  if (!templateId) throw new Error('email step requires "id" (email template id)')
572
- if (!clickLabel) throw new Error('email step requires "click" (link text in the email)')
627
+ if (!clickLabel && !extract) {
628
+ throw new Error('email step requires "click" or "extract"')
629
+ }
573
630
 
574
631
  const inboxPath = router.getPathname({ id: 'dev-inbox', language: locale }) ?? '/dev/inbox'
575
632
  const params = new URLSearchParams({ to: String(to), template: String(templateId) })
576
633
  const inboxUrl = `${baseURL}${inboxPath}?${params}`
577
- const pattern = linkNamePattern(clickLabel)
578
634
  const deadline = Date.now() + (emailStep.timeout ?? 20000)
579
635
 
580
- let clicked = false
581
- while (Date.now() < deadline && !clicked) {
582
- await page.goto(inboxUrl)
583
- // Scope to email HTML body so chrome auth links (e.g. header "Sign in")
584
- // cannot steal the click when the CTA label collides.
585
- const body = page.locator('[data-email-body]').first()
586
- const link = body.getByRole('link', { name: pattern }).first()
636
+ const openInboxBody = async (inboxPage) => {
637
+ await inboxPage.goto(inboxUrl)
638
+ const body = inboxPage.locator('[data-email-body]').first()
639
+ await body.waitFor({ state: 'visible', timeout: 2000 })
640
+ return body
641
+ }
642
+
643
+ // Extract-only must not navigate the flow page — SPA React state (e.g. SignIn
644
+ // success + code form for PWA) is lost on goto/goBack remount.
645
+ if (extract && !clickLabel) {
646
+ const inboxPage = await page.context().newPage()
647
+ let extracted = false
587
648
  try {
588
- await body.waitFor({ state: 'visible', timeout: 2000 })
589
- await link.waitFor({ state: 'visible', timeout: 2000 })
590
- await Promise.all([
591
- page.waitForLoadState('domcontentloaded'),
592
- link.click(),
593
- ])
594
- clicked = true
595
- } catch {
596
- await page.waitForTimeout(500)
649
+ while (Date.now() < deadline && !extracted) {
650
+ try {
651
+ const body = await openInboxBody(inboxPage)
652
+ for (const [key, spec] of Object.entries(extract)) {
653
+ const selector = typeof spec === 'string' ? spec : spec.selector
654
+ const attr = typeof spec === 'object' ? spec.attr : undefined
655
+ const resolvedSelector = interpolateContextString(selector, context, {
656
+ escape: escapeCssAttrValue,
657
+ })
658
+ const locator = body.locator(resolvedSelector).first()
659
+ await locator.waitFor({ state: 'attached', timeout: 2000 })
660
+ const value = attr
661
+ ? await locator.getAttribute(attr)
662
+ : await locator.textContent()
663
+ context[key] = typeof value === 'string' ? value.trim() : value
664
+ }
665
+ extracted = true
666
+ } catch {
667
+ await page.waitForTimeout(500)
668
+ }
669
+ }
670
+ } finally {
671
+ await inboxPage.close().catch(() => {})
672
+ }
673
+ if (!extracted) {
674
+ throw new Error(`Email extract failed for to=${to}, template=${templateId}`)
597
675
  }
676
+ continue
598
677
  }
599
- if (!clicked) {
600
- throw new Error(`Email link "${clickLabel}" not found for to=${to}, template=${templateId}`)
678
+
679
+ if (extract && clickLabel) {
680
+ let extracted = false
681
+ while (Date.now() < deadline && !extracted) {
682
+ try {
683
+ const body = await openInboxBody(page)
684
+ for (const [key, spec] of Object.entries(extract)) {
685
+ const selector = typeof spec === 'string' ? spec : spec.selector
686
+ const attr = typeof spec === 'object' ? spec.attr : undefined
687
+ const resolvedSelector = interpolateContextString(selector, context, {
688
+ escape: escapeCssAttrValue,
689
+ })
690
+ const locator = body.locator(resolvedSelector).first()
691
+ await locator.waitFor({ state: 'attached', timeout: 2000 })
692
+ const value = attr
693
+ ? await locator.getAttribute(attr)
694
+ : await locator.textContent()
695
+ context[key] = typeof value === 'string' ? value.trim() : value
696
+ }
697
+ extracted = true
698
+ } catch {
699
+ await page.waitForTimeout(500)
700
+ }
701
+ }
702
+ if (!extracted) {
703
+ throw new Error(`Email extract failed for to=${to}, template=${templateId}`)
704
+ }
705
+ }
706
+
707
+ if (clickLabel) {
708
+ const pattern = linkNamePattern(clickLabel)
709
+ let clicked = false
710
+ while (Date.now() < deadline && !clicked) {
711
+ const body = await openInboxBody(page)
712
+ const link = body.getByRole('link', { name: pattern }).first()
713
+ try {
714
+ await link.waitFor({ state: 'visible', timeout: 2000 })
715
+ await Promise.all([
716
+ page.waitForLoadState('domcontentloaded'),
717
+ link.click(),
718
+ ])
719
+ clicked = true
720
+ } catch {
721
+ await page.waitForTimeout(500)
722
+ }
723
+ }
724
+ if (!clicked) {
725
+ throw new Error(`Email link "${clickLabel}" not found for to=${to}, template=${templateId}`)
726
+ }
601
727
  }
602
728
  continue
603
729
  }
@@ -721,17 +847,32 @@ export function registerFlow (mod, options = {}) {
721
847
  /**
722
848
  * Register all flows listed in `manifest.flows[]`.
723
849
  * @param {string} manifestPath Absolute path to build/manifest.json
850
+ * @param {{ shard?: string }} [options] Optional shard override (`N/M`); defaults to `E2E_FLOW_SHARD`
724
851
  */
725
- export async function registerFlowsFromManifest (manifestPath) {
852
+ export async function registerFlowsFromManifest (manifestPath, options = {}) {
726
853
  const manifest = loadManifest(manifestPath)
727
854
  const buildDir = path.dirname(manifestPath)
728
855
  const staticDir = path.join(buildDir, 'public', 'static')
729
-
730
- for (const entry of manifest.flows ?? []) {
856
+ const shard = parseFlowShard(options.shard ?? process.env.E2E_FLOW_SHARD)
857
+ // Sort by stable flow id so independently built matrix jobs partition identically
858
+ // even when package discovery/`readdir` order differs (#768 / #770).
859
+ const flows = sortFlowsForShard(manifest.flows ?? [])
860
+ let registered = 0
861
+
862
+ for (let i = 0; i < flows.length; i++) {
863
+ if (!flowBelongsToShard(i, shard)) continue
864
+ const entry = flows[i]
731
865
  const chunkName = entry.entry.replace(/^\/static\//, '')
732
866
  const chunkPath = path.join(staticDir, chunkName)
733
867
  const mod = await import(pathToFileURL(chunkPath).href + `?ts=${Date.now()}`)
734
868
  registerFlow(mod, { manifestPath, locale: manifest.config?.defaultLanguage })
869
+ registered += 1
870
+ }
871
+
872
+ if (shard) {
873
+ console.log(
874
+ `[e2e] flow shard ${shard.index}/${shard.total}: registered ${registered}/${flows.length} flows`,
875
+ )
735
876
  }
736
877
  }
737
878
 
@@ -0,0 +1,66 @@
1
+ /**
2
+ * CI flow partitioning helpers.
3
+ *
4
+ * All product flows register from one Playwright `runner.spec.js`, so Playwright's
5
+ * file-level `--shard` leaves shards 2..N empty. Matrix jobs set `E2E_FLOW_SHARD=N/M`
6
+ * and the flow runner registers only that slice.
7
+ *
8
+ * Membership is by **stable flow id order**, not discovery/`readdir` order — each
9
+ * matrix job builds independently (#768 / #770).
10
+ */
11
+
12
+ /**
13
+ * Parse `E2E_FLOW_SHARD` / explicit shard string (`1/4`) into 1-based index + total.
14
+ *
15
+ * @param {string | undefined | null} value
16
+ * @returns {{ index: number, total: number } | null}
17
+ */
18
+ export function parseFlowShard (value = process.env.E2E_FLOW_SHARD) {
19
+ if (value == null || value === '') return null
20
+ const match = String(value).trim().match(/^(\d+)\s*\/\s*(\d+)$/)
21
+ if (!match) {
22
+ throw new Error(`Invalid E2E_FLOW_SHARD "${value}" (expected N/M, e.g. 1/4)`)
23
+ }
24
+ const index = Number(match[1])
25
+ const total = Number(match[2])
26
+ if (!Number.isInteger(index) || !Number.isInteger(total) || total < 1 || index < 1 || index > total) {
27
+ throw new Error(`Invalid E2E_FLOW_SHARD "${value}" (index must be 1..total)`)
28
+ }
29
+ return { index, total }
30
+ }
31
+
32
+ /**
33
+ * Deterministic order for shard partitioning (localeCompare on flow id).
34
+ *
35
+ * @param {Array<{ id?: string }>} flows
36
+ * @returns {Array<{ id?: string }>}
37
+ */
38
+ export function sortFlowsForShard (flows) {
39
+ return [...(flows ?? [])].sort((a, b) =>
40
+ String(a?.id ?? '').localeCompare(String(b?.id ?? '')),
41
+ )
42
+ }
43
+
44
+ /**
45
+ * Stable round-robin membership for CI flow matrix jobs.
46
+ * @param {number} flowIndex 0-based position in the **id-sorted** flow list
47
+ * @param {{ index: number, total: number } | null} shard
48
+ */
49
+ export function flowBelongsToShard (flowIndex, shard) {
50
+ if (!shard) return true
51
+ return flowIndex % shard.total === shard.index - 1
52
+ }
53
+
54
+ /**
55
+ * Flow ids owned by a shard for a given (unordered) manifest list.
56
+ *
57
+ * @param {Array<{ id?: string }>} flows
58
+ * @param {{ index: number, total: number } | null} shard
59
+ * @returns {string[]}
60
+ */
61
+ export function flowIdsForShard (flows, shard) {
62
+ const ordered = sortFlowsForShard(flows)
63
+ return ordered
64
+ .filter((_, i) => flowBelongsToShard(i, shard))
65
+ .map((flow) => String(flow.id ?? ''))
66
+ }
@@ -0,0 +1,73 @@
1
+ import { describe, expect, it } from '@jest/globals'
2
+ import {
3
+ flowBelongsToShard,
4
+ flowIdsForShard,
5
+ parseFlowShard,
6
+ sortFlowsForShard,
7
+ } from './flow-shard.js'
8
+
9
+ describe('parseFlowShard', () => {
10
+ it('returns null for empty values', () => {
11
+ expect(parseFlowShard(undefined)).toBeNull()
12
+ expect(parseFlowShard(null)).toBeNull()
13
+ expect(parseFlowShard('')).toBeNull()
14
+ })
15
+
16
+ it('parses N/M', () => {
17
+ expect(parseFlowShard('1/4')).toEqual({ index: 1, total: 4 })
18
+ expect(parseFlowShard(' 3 / 4 ')).toEqual({ index: 3, total: 4 })
19
+ })
20
+
21
+ it('rejects invalid shapes', () => {
22
+ expect(() => parseFlowShard('4')).toThrow(/Invalid E2E_FLOW_SHARD/)
23
+ expect(() => parseFlowShard('0/4')).toThrow(/Invalid E2E_FLOW_SHARD/)
24
+ expect(() => parseFlowShard('5/4')).toThrow(/Invalid E2E_FLOW_SHARD/)
25
+ })
26
+ })
27
+
28
+ describe('flowBelongsToShard', () => {
29
+ it('keeps every flow when shard is null', () => {
30
+ expect(flowBelongsToShard(0, null)).toBe(true)
31
+ expect(flowBelongsToShard(7, null)).toBe(true)
32
+ })
33
+
34
+ it('round-robins indices across shards', () => {
35
+ const shard1 = { index: 1, total: 4 }
36
+ const shard2 = { index: 2, total: 4 }
37
+ const owned = [...Array(8).keys()].filter(i => flowBelongsToShard(i, shard1))
38
+ const owned2 = [...Array(8).keys()].filter(i => flowBelongsToShard(i, shard2))
39
+ expect(owned).toEqual([0, 4])
40
+ expect(owned2).toEqual([1, 5])
41
+ })
42
+ })
43
+
44
+ describe('sortFlowsForShard / flowIdsForShard', () => {
45
+ it('sorts by stable id regardless of input order', () => {
46
+ const a = [{ id: 'zeta' }, { id: 'alpha' }, { id: 'mu' }]
47
+ const b = [{ id: 'mu' }, { id: 'zeta' }, { id: 'alpha' }]
48
+ expect(sortFlowsForShard(a).map(f => f.id)).toEqual(['alpha', 'mu', 'zeta'])
49
+ expect(sortFlowsForShard(b).map(f => f.id)).toEqual(['alpha', 'mu', 'zeta'])
50
+ })
51
+
52
+ it('assigns the same ids to shard 1/4 for two different input orders', () => {
53
+ const shard = { index: 1, total: 4 }
54
+ const orderA = [
55
+ { id: '@ossy/z/flows/c' },
56
+ { id: '@ossy/a/flows/x' },
57
+ { id: '@ossy/m/flows/y' },
58
+ { id: '@ossy/b/flows/w' },
59
+ { id: '@ossy/n/flows/v' },
60
+ { id: '@ossy/c/flows/u' },
61
+ { id: '@ossy/d/flows/t' },
62
+ { id: '@ossy/e/flows/s' },
63
+ ]
64
+ const orderB = [...orderA].reverse()
65
+
66
+ expect(flowIdsForShard(orderA, shard)).toEqual(flowIdsForShard(orderB, shard))
67
+ // id-sorted: a/x, b/w, c/u, d/t, e/s, m/y, n/v, z/c → shard 1 owns indices 0,4
68
+ expect(flowIdsForShard(orderA, shard)).toEqual([
69
+ '@ossy/a/flows/x',
70
+ '@ossy/e/flows/s',
71
+ ])
72
+ })
73
+ })
@@ -173,6 +173,43 @@ export class TestUtil {
173
173
  return ev.payload.token
174
174
  }
175
175
 
176
+ /**
177
+ * Resolve a six-digit sign-in code from its stored HMAC digest (integration tests only).
178
+ *
179
+ * @param {string} subjectId
180
+ * @returns {Promise<string>}
181
+ */
182
+ static async getLatestVerificationCodeForSubject(subjectId) {
183
+ const ev = await EventStore.Collection.findOne(
184
+ {
185
+ type: TOKEN_SCHEMA,
186
+ event: 'Created',
187
+ 'payload.type': 'Verification',
188
+ 'payload.subject': subjectId,
189
+ },
190
+ { sort: { created: -1 } },
191
+ )
192
+ const codeHash = ev?.payload?.codeHash
193
+ if (!codeHash) {
194
+ return Promise.reject(new Error('No verification code hash found for subject'))
195
+ }
196
+
197
+ const secret = process.env.TOKEN_SECRET
198
+ if (!secret) {
199
+ return Promise.reject(new Error('TOKEN_SECRET is required to resolve verification codes in tests'))
200
+ }
201
+
202
+ const { createHmac } = await import('node:crypto')
203
+ const hashCode = (code) => createHmac('sha256', secret).update(code).digest('hex')
204
+
205
+ for (let i = 0; i < 1_000_000; i += 1) {
206
+ const code = String(i).padStart(6, '0')
207
+ if (hashCode(code) === codeHash) return code
208
+ }
209
+
210
+ return Promise.reject(new Error('Could not resolve verification code from hash'))
211
+ }
212
+
176
213
  static async getLatestEmailChangeJwtForSubject(subjectId) {
177
214
  const ev = await EventStore.Collection.findOne(
178
215
  {
@@ -0,0 +1,24 @@
1
+ import { ActionService } from './actions/action.service.js'
2
+ import { mergeUserAppSettingsCookie, setAuthCookie } from './user-app-settings.js'
3
+
4
+ /**
5
+ * Set auth (and optional single-workspace default) cookies after verify-sign-in.
6
+ *
7
+ * @param {import('express').Request} req
8
+ * @param {import('express').Response} res
9
+ * @param {{ sub: string, token: string }} session
10
+ */
11
+ export async function applyVerifySignInSessionCookies (req, res, { sub, token }) {
12
+ setAuthCookie(res, token)
13
+ try {
14
+ const workspaces = await ActionService.invoke('@ossy/workspaces/actions/list', {
15
+ payload: { userId: sub },
16
+ req,
17
+ })
18
+ if (workspaces.length === 1) {
19
+ mergeUserAppSettingsCookie(req, res, { workspaceId: workspaces[0].id })
20
+ }
21
+ } catch {
22
+ // Cookie for the session is enough; workspace default is best-effort.
23
+ }
24
+ }