@ossy/platform 3.11.0 → 3.11.1

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.11.0",
3
+ "version": "3.11.1",
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.11.1",
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.11.1",
56
+ "@ossy/tokens": "^3.11.1",
57
+ "@ossy/users": "^3.11.1",
58
+ "@ossy/workspaces": "^3.11.1",
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": "f279de3535b6bc76631cbc6b943dae8f92a1565f"
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'
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)
@@ -427,6 +430,9 @@ export async function startServer (options = {}) {
427
430
  clearAuthCookie(res)
428
431
  clearWorkspaceFromUserAppSettings(req, res)
429
432
  }
433
+ if (actionId === '@ossy/authentication/actions/verify-sign-in' && result?.token) {
434
+ await applyVerifySignInSessionCookies(req, res, result)
435
+ }
430
436
  res.json(result ?? { ok: true })
431
437
  } catch (err) {
432
438
  // Still clear auth on sign-out failures so a broken session can recover.
@@ -670,6 +676,7 @@ export default startServer
670
676
  export { loadLayoutsById, resolvePageLayoutRender }
671
677
  export { ConfigService } from './config.service.js'
672
678
  export { ActionService } from './actions/action.service.js'
679
+ export { applyVerifySignInSessionCookies } from './verify-sign-in-session.js'
673
680
  export { IntegrationService } from './integration.service.js'
674
681
  export { StorageClient } from './storage/storage.client.js'
675
682
  export { originalObjectKey, derivativeObjectKey, assertStorageKey } from './storage/storage-keys.js'
@@ -405,13 +405,13 @@ export async function runFlow (flow, options = {}) {
405
405
  const selector = actionSelector(actionId, matchers)
406
406
  const actionTimeout = step.timeout ?? 15000
407
407
  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).
408
+ // SSR markup is visible before React attaches onClick. Wait for visibility,
409
+ // then click: form submits use remount-safe evaluate + requestSubmit; other
410
+ // CTAs use Playwright click (programmatic el.click() often skips React onClick).
411
411
  //
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.
412
+ // Re-query the live node after settle: concurrent auth/workspace fetches can
413
+ // remount the tree and detach Playwright locators mid-scroll (and wipe
414
+ // uncontrolled form state). Retry within the action timeout.
415
415
  // Prefer an actionable match when duplicates exist (e.g. hero CTA under a page overlay).
416
416
  let clicked = false
417
417
  let lastError
@@ -432,52 +432,63 @@ export async function runFlow (flow, options = {}) {
432
432
  state: 'visible',
433
433
  timeout: Math.min(5000, Math.max(250, actionDeadline - Date.now())),
434
434
  })
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
435
  const fieldRestore = { ...(context.fields ?? {}) }
439
436
  if (context.email != null && fieldRestore.email == null) {
440
437
  fieldRestore.email = context.email
441
438
  }
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 }))
439
+ // Form submits: restore fills + requestSubmit in one evaluate (remount-safe).
440
+ // Other CTAs (Enable, Publish, OpenExport): Playwright click — programmatic
441
+ // el.click() often never invokes React onClick (no /actions POST; publish stays off).
442
+ const isSubmitButton = await candidate.evaluate((el) => {
443
+ const form = (el instanceof HTMLButtonElement || el instanceof HTMLInputElement)
444
+ ? el.form
445
+ : el.closest?.('form')
446
+ if (!form) return false
447
+ return (el instanceof HTMLButtonElement || el instanceof HTMLInputElement)
448
+ ? el.type === 'submit'
449
+ : el.getAttribute?.('type') === 'submit'
450
+ })
451
+ if (isSubmitButton) {
452
+ await page.evaluate(async ({ selector: sel, fields }) => {
453
+ await new Promise((resolve) => {
454
+ requestAnimationFrame(() => requestAnimationFrame(resolve))
455
+ })
456
+ await new Promise((resolve) => setTimeout(resolve, 500))
457
+ const el = document.querySelector(sel)
458
+ if (!el) throw new Error(`Action control disappeared after settle: ${sel}`)
459
+ if (typeof el.scrollIntoView === 'function') {
460
+ el.scrollIntoView({ block: 'nearest', inline: 'nearest' })
467
461
  }
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 })
462
+ const form = (el instanceof HTMLButtonElement || el instanceof HTMLInputElement)
463
+ ? el.form
464
+ : el.closest?.('form')
465
+ if (form && fields && typeof fields === 'object') {
466
+ for (const [name, value] of Object.entries(fields)) {
467
+ if (value == null || typeof value === 'object') continue
468
+ const input = form.querySelector(`[name="${name}"]`)
469
+ if (!input || input.disabled) continue
470
+ if (input instanceof HTMLInputElement && input.type === 'file') continue
471
+ const asText = String(value)
472
+ if (input.value === asText) continue
473
+ const proto = Object.getPrototypeOf(input)
474
+ const desc = Object.getOwnPropertyDescriptor(proto, 'value')
475
+ desc?.set?.call(input, asText)
476
+ input.dispatchEvent(new Event('input', { bubbles: true }))
477
+ input.dispatchEvent(new Event('change', { bubbles: true }))
478
+ }
479
+ }
480
+ if (form && typeof form.requestSubmit === 'function') {
481
+ form.requestSubmit(el)
482
+ return
483
+ }
484
+ if (typeof el.click === 'function') el.click()
485
+ }, { selector, fields: fieldRestore })
486
+ } else {
487
+ await candidate.scrollIntoViewIfNeeded().catch(() => {})
488
+ await candidate.click({
489
+ timeout: Math.min(5000, Math.max(250, actionDeadline - Date.now())),
490
+ })
491
+ }
481
492
  clicked = true
482
493
  break
483
494
  } catch (err) {
@@ -538,6 +549,42 @@ export async function runFlow (flow, options = {}) {
538
549
  continue
539
550
  }
540
551
 
552
+ if (step.inputs != null) {
553
+ if (!page) throw new Error('inputs step requires a Playwright page')
554
+ const inputsTimeout = step.timeout ?? 15000
555
+ for (const [selector, valueSpec] of Object.entries(step.inputs)) {
556
+ const value = resolveContextValue(valueSpec, context)
557
+ if (value == null) {
558
+ throw new Error(`inputs step: no value resolved for selector "${selector}"`)
559
+ }
560
+ const resolvedSelector = interpolateContextString(selector, context, {
561
+ escape: escapeCssAttrValue,
562
+ })
563
+ const locator = page.locator(resolvedSelector).first()
564
+ await locator.waitFor({ state: 'visible', timeout: inputsTimeout })
565
+ const asText = String(value)
566
+ for (let attempt = 0; attempt < 3; attempt++) {
567
+ await locator.fill(asText)
568
+ await locator.blur().catch(() => {})
569
+ try {
570
+ await expectFn(locator).toHaveValue(asText, { timeout: 2000 })
571
+ break
572
+ } catch (err) {
573
+ if (attempt === 2) throw err
574
+ await page.waitForTimeout(150)
575
+ }
576
+ }
577
+ // Remount-safe action submits restore from context.fields by input name.
578
+ // Keep ad-hoc fills (e.g. sign-in code) in that map so settle remounts
579
+ // do not wipe values before requestSubmit.
580
+ const name = await locator.getAttribute('name')
581
+ if (name) {
582
+ context.fields = { ...(context.fields ?? {}), [name]: asText }
583
+ }
584
+ }
585
+ continue
586
+ }
587
+
541
588
  if (step.capture != null) {
542
589
  if (!page) throw new Error('capture step requires a Playwright page')
543
590
  for (const [key, spec] of Object.entries(step.capture)) {
@@ -567,37 +614,109 @@ export async function runFlow (flow, options = {}) {
567
614
  const to = resolveContextValue(emailStep.to ?? '$email', context)
568
615
  const templateId = emailStep.id ?? emailStep.templateId
569
616
  const clickLabel = emailStep.click ?? emailStep.link
617
+ const extract = emailStep.extract
570
618
  if (!to) throw new Error('email step requires "to" (recipient email)')
571
619
  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)')
620
+ if (!clickLabel && !extract) {
621
+ throw new Error('email step requires "click" or "extract"')
622
+ }
573
623
 
574
624
  const inboxPath = router.getPathname({ id: 'dev-inbox', language: locale }) ?? '/dev/inbox'
575
625
  const params = new URLSearchParams({ to: String(to), template: String(templateId) })
576
626
  const inboxUrl = `${baseURL}${inboxPath}?${params}`
577
- const pattern = linkNamePattern(clickLabel)
578
627
  const deadline = Date.now() + (emailStep.timeout ?? 20000)
579
628
 
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()
629
+ const openInboxBody = async (inboxPage) => {
630
+ await inboxPage.goto(inboxUrl)
631
+ const body = inboxPage.locator('[data-email-body]').first()
632
+ await body.waitFor({ state: 'visible', timeout: 2000 })
633
+ return body
634
+ }
635
+
636
+ // Extract-only must not navigate the flow page — SPA React state (e.g. SignIn
637
+ // success + code form for PWA) is lost on goto/goBack remount.
638
+ if (extract && !clickLabel) {
639
+ const inboxPage = await page.context().newPage()
640
+ let extracted = false
587
641
  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)
642
+ while (Date.now() < deadline && !extracted) {
643
+ try {
644
+ const body = await openInboxBody(inboxPage)
645
+ for (const [key, spec] of Object.entries(extract)) {
646
+ const selector = typeof spec === 'string' ? spec : spec.selector
647
+ const attr = typeof spec === 'object' ? spec.attr : undefined
648
+ const resolvedSelector = interpolateContextString(selector, context, {
649
+ escape: escapeCssAttrValue,
650
+ })
651
+ const locator = body.locator(resolvedSelector).first()
652
+ await locator.waitFor({ state: 'attached', timeout: 2000 })
653
+ const value = attr
654
+ ? await locator.getAttribute(attr)
655
+ : await locator.textContent()
656
+ context[key] = typeof value === 'string' ? value.trim() : value
657
+ }
658
+ extracted = true
659
+ } catch {
660
+ await page.waitForTimeout(500)
661
+ }
662
+ }
663
+ } finally {
664
+ await inboxPage.close().catch(() => {})
597
665
  }
666
+ if (!extracted) {
667
+ throw new Error(`Email extract failed for to=${to}, template=${templateId}`)
668
+ }
669
+ continue
598
670
  }
599
- if (!clicked) {
600
- throw new Error(`Email link "${clickLabel}" not found for to=${to}, template=${templateId}`)
671
+
672
+ if (extract && clickLabel) {
673
+ let extracted = false
674
+ while (Date.now() < deadline && !extracted) {
675
+ try {
676
+ const body = await openInboxBody(page)
677
+ for (const [key, spec] of Object.entries(extract)) {
678
+ const selector = typeof spec === 'string' ? spec : spec.selector
679
+ const attr = typeof spec === 'object' ? spec.attr : undefined
680
+ const resolvedSelector = interpolateContextString(selector, context, {
681
+ escape: escapeCssAttrValue,
682
+ })
683
+ const locator = body.locator(resolvedSelector).first()
684
+ await locator.waitFor({ state: 'attached', timeout: 2000 })
685
+ const value = attr
686
+ ? await locator.getAttribute(attr)
687
+ : await locator.textContent()
688
+ context[key] = typeof value === 'string' ? value.trim() : value
689
+ }
690
+ extracted = true
691
+ } catch {
692
+ await page.waitForTimeout(500)
693
+ }
694
+ }
695
+ if (!extracted) {
696
+ throw new Error(`Email extract failed for to=${to}, template=${templateId}`)
697
+ }
698
+ }
699
+
700
+ if (clickLabel) {
701
+ const pattern = linkNamePattern(clickLabel)
702
+ let clicked = false
703
+ while (Date.now() < deadline && !clicked) {
704
+ const body = await openInboxBody(page)
705
+ const link = body.getByRole('link', { name: pattern }).first()
706
+ try {
707
+ await link.waitFor({ state: 'visible', timeout: 2000 })
708
+ await Promise.all([
709
+ page.waitForLoadState('domcontentloaded'),
710
+ link.click(),
711
+ ])
712
+ clicked = true
713
+ } catch {
714
+ await page.waitForTimeout(500)
715
+ }
716
+ }
717
+ if (!clicked) {
718
+ throw new Error(`Email link "${clickLabel}" not found for to=${to}, template=${templateId}`)
719
+ }
601
720
  }
602
721
  continue
603
722
  }
@@ -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
+ }