@ossy/platform 1.39.2 → 3.0.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.
Files changed (66) hide show
  1. package/README.md +21 -17
  2. package/package.json +20 -11
  3. package/src/Definition.js +2 -1
  4. package/src/PlatformShell.jsx +10 -10
  5. package/src/actions/action.service.js +85 -7
  6. package/src/audit/action-invocation-service.js +143 -0
  7. package/src/audit/action-invocation.aggregate.js +57 -0
  8. package/src/audit/audit-helpers.js +61 -0
  9. package/src/audit/detect-channel.js +19 -0
  10. package/src/audit/list-task-runs.action.js +5 -0
  11. package/src/audit/list-task-runs.task.js +17 -0
  12. package/src/audit/task-run-list.aggregate.js +76 -0
  13. package/src/audit/task-run-service.js +208 -0
  14. package/src/audit/task-run.aggregate.js +58 -0
  15. package/src/auth/action-scopes.js +3 -0
  16. package/src/capability-schemas/action-capability.schema.js +1 -0
  17. package/src/capability-schemas/action-meta.schema.js +1 -0
  18. package/src/capability-schemas/component-capability.schema.js +1 -0
  19. package/src/capability-schemas/page-capability.schema.js +1 -0
  20. package/src/capability-schemas/page-meta.schema.js +1 -0
  21. package/src/capability-schemas/task-capability.schema.js +1 -0
  22. package/src/capability-schemas/task-meta.schema.js +1 -0
  23. package/src/capability-schemas/task-output.schema.js +1 -0
  24. package/src/capability-schemas/task-trigger-authoring.schema.js +1 -0
  25. package/src/capability-schemas/task-trigger.schema.js +1 -0
  26. package/src/directory.schema.js +7 -0
  27. package/src/entitlements/action-entitlement.js +69 -0
  28. package/src/file.schema.js +11 -0
  29. package/src/index.js +12 -3
  30. package/src/mcp/create-ossy-mcp-server.js +97 -0
  31. package/src/mcp/json-schema-to-zod.js +64 -0
  32. package/src/mcp/mount-ossy-mcp.js +72 -0
  33. package/src/mcp/mount-platform-mcp.js +101 -0
  34. package/src/mcp/upload-file-tool.js +62 -0
  35. package/src/metering/metering-service.js +91 -0
  36. package/src/{platform-config.resource.js → platform-config.schema.js} +1 -1
  37. package/src/proxy-internal.js +13 -16
  38. package/src/push/mount-push-sse.js +91 -0
  39. package/src/request-diagnostics.js +144 -0
  40. package/src/request-diagnostics.spec.js +40 -0
  41. package/src/resources/index.js +3 -2
  42. package/src/resources/schema.registry.js +26 -0
  43. package/src/resources/schema.service.js +54 -0
  44. package/src/resources/schema.validation.js +90 -0
  45. package/src/runtime.js +20 -6
  46. package/src/server.js +281 -62
  47. package/src/storage/filesystem-storage.client.js +109 -0
  48. package/src/storage/local-storage.client.js +2 -65
  49. package/src/storage/resource-read-url.js +36 -0
  50. package/src/storage/resource-read-url.spec.js +22 -0
  51. package/src/storage/s3-storage.client.js +102 -0
  52. package/src/storage/s3.client.js +27 -23
  53. package/src/storage/storage-keys.js +37 -0
  54. package/src/storage/storage-keys.spec.js +16 -0
  55. package/src/storage/storage.client.js +52 -8
  56. package/src/storage/storage.integration.js +40 -0
  57. package/src/tasks/change-stream.js +78 -11
  58. package/src/tasks/task-service.js +211 -34
  59. package/src/tasks/task-service.spec.js +187 -0
  60. package/src/test/e2e.util.js +18 -39
  61. package/src/test/flow-runner.js +476 -0
  62. package/src/test/test.util.js +30 -29
  63. package/src/user-app-settings.js +44 -0
  64. package/src/users.middleware.js +61 -17
  65. package/src/resources/resource-template.registry.js +0 -29
  66. package/src/resources/resource-template.validation.js +0 -232
@@ -0,0 +1,476 @@
1
+ /**
2
+ * Declarative flow runner — single entry for test, guide, seed, and stress modes.
3
+ *
4
+ * User flows are UI-only. The only non-UI read path is the `email` step (dev inbox).
5
+ *
6
+ * import { runFlow, registerInstalledFlows } from '@ossy/platform/test/flow-runner.js'
7
+ * await registerInstalledFlows(import.meta.url)
8
+ */
9
+
10
+ import fs from 'node:fs'
11
+ import path from 'node:path'
12
+ import { fileURLToPath, pathToFileURL } from 'node:url'
13
+ import { faker } from '@faker-js/faker'
14
+ import { Router } from '@ossy/router'
15
+ import { Schema } from '@ossy/schema'
16
+ import { test, expect } from '@playwright/test'
17
+
18
+ const FLOW_PATTERN = /\.flow\.(mjs|cjs|js)$/
19
+
20
+ /**
21
+ * @typedef {'test' | 'guide' | 'seed' | 'stress'} FlowMode
22
+ *
23
+ * @typedef {object} FlowRunContext
24
+ * @property {Record<string, unknown>} fields Filled form values keyed by field name.
25
+ * @property {string} [email]
26
+ * @property {string} [userId]
27
+ * @property {string} [token]
28
+ * @property {string} [providerSlug]
29
+ * @property {string} [workspaceId]
30
+ */
31
+
32
+ /**
33
+ * Load `build/manifest.json` from disk.
34
+ * @param {string} manifestPath
35
+ */
36
+ export function loadManifest (manifestPath) {
37
+ if (!fs.existsSync(manifestPath)) {
38
+ const appRoot = path.dirname(path.dirname(manifestPath))
39
+ throw new Error(
40
+ `Missing ${manifestPath}. Flow tests need a built app manifest — ` +
41
+ `run \`npm run build\` in ${appRoot} first (Playwright does not build for you).`,
42
+ )
43
+ }
44
+ return JSON.parse(fs.readFileSync(manifestPath, 'utf8'))
45
+ }
46
+
47
+ function normalizePageId (pageRef) {
48
+ if (typeof pageRef !== 'string') return ''
49
+ return pageRef.startsWith('@') ? pageRef.slice(1) : pageRef
50
+ }
51
+
52
+ function manifestPages (manifest) {
53
+ return (manifest.entries ?? []).filter(e => e.type === 'page')
54
+ }
55
+
56
+ function buildRouter (manifest, locale) {
57
+ const pages = manifestPages(manifest).map(p => ({ id: p.id, path: p.path }))
58
+ const config = manifest.config ?? {}
59
+ return Router.of({
60
+ pages,
61
+ defaultLanguage: locale ?? config.defaultLanguage ?? 'en',
62
+ supportedLanguages: config.supportedLanguages ?? [locale ?? 'en'],
63
+ })
64
+ }
65
+
66
+ function findSchema (manifest, schemaId) {
67
+ return (manifest.schemas ?? []).find(s => s.id === schemaId)
68
+ }
69
+
70
+ function schemaEngineFromManifest (manifest) {
71
+ return Schema.of(manifest, {
72
+ actionIds: (manifest.actions ?? []).map(a => a.id),
73
+ taskIds: (manifest.tasks ?? []).map(t => t.id),
74
+ })
75
+ }
76
+
77
+ /**
78
+ * Mock form field values via Schema.mock and update the run context.
79
+ *
80
+ * @param {import('@ossy/schema').Schema} engine
81
+ * @param {{ id: string, fields?: { name: string, type?: string }[] }} template
82
+ * @param {FlowRunContext} context
83
+ */
84
+ function mockFormContent (engine, template, context) {
85
+ const content = engine.mock(template, { faker })
86
+ for (const field of template.fields ?? []) {
87
+ const value = content[field.name]
88
+ if (value !== undefined) context.fields[field.name] = value
89
+ if (field.type === 'email' || field.name?.toLowerCase?.().includes('email')) {
90
+ context.email = String(value)
91
+ }
92
+ }
93
+ return content
94
+ }
95
+
96
+ function resolveActionId (action) {
97
+ if (typeof action === 'string') return action
98
+ if (action && typeof action.id === 'string') return action.id
99
+ throw new Error('Flow action step requires an action POJO or id string')
100
+ }
101
+
102
+ function actionSelector (actionId, service) {
103
+ return service
104
+ ? `[data-action="${actionId}"][data-service="${service}"]`
105
+ : `[data-action="${actionId}"]`
106
+ }
107
+
108
+ function resolveActionService (action, fallback) {
109
+ if (typeof action === 'object' && action.service) return action.service
110
+ return fallback
111
+ }
112
+
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
+ function resolveContextObject (obj, context) {
121
+ if (!obj || typeof obj !== 'object') return obj
122
+ const out = {}
123
+ for (const [key, raw] of Object.entries(obj)) {
124
+ out[key] = resolveContextValue(raw, context)
125
+ }
126
+ return out
127
+ }
128
+
129
+ function appendSearch (pathname, search, context) {
130
+ if (!search || typeof search !== 'object') return pathname
131
+ const params = new URLSearchParams()
132
+ for (const [key, raw] of Object.entries(search)) {
133
+ const value = resolveContextValue(raw, context)
134
+ if (value != null && value !== '') params.set(key, String(value))
135
+ }
136
+ const qs = params.toString()
137
+ return qs ? `${pathname}?${qs}` : pathname
138
+ }
139
+
140
+ function linkNamePattern (click) {
141
+ if (click instanceof RegExp) return click
142
+ return new RegExp(click, 'i')
143
+ }
144
+
145
+ /**
146
+ * Execute a declarative flow against a Playwright page.
147
+ *
148
+ * @param {{ title?: string, description?: string, steps?: unknown[], metadata?: object }} flow
149
+ * @param {{
150
+ * mode?: FlowMode,
151
+ * page?: import('@playwright/test').Page,
152
+ * baseURL?: string,
153
+ * manifest?: object,
154
+ * manifestPath?: string,
155
+ * locale?: string,
156
+ * context?: FlowRunContext,
157
+ * expect?: import('@playwright/test').Expect,
158
+ * }} options
159
+ */
160
+ export async function runFlow (flow, options = {}) {
161
+ const {
162
+ mode = 'test',
163
+ page,
164
+ baseURL = process.env.OSSY_APP_URL ?? process.env.BASE_URL ?? 'http://localhost:3002',
165
+ manifest: manifestIn,
166
+ manifestPath,
167
+ locale: localeIn,
168
+ context: contextIn,
169
+ expect: expectFn = expect,
170
+ } = options
171
+
172
+ if (mode !== 'test' && mode !== 'guide' && mode !== 'seed' && mode !== 'stress') {
173
+ throw new Error(`Unknown flow mode "${mode}"`)
174
+ }
175
+
176
+ if (mode !== 'test') {
177
+ console.info(`[flow-runner] mode "${mode}" not fully implemented — running test steps`)
178
+ }
179
+
180
+ const manifest = manifestIn ?? loadManifest(manifestPath)
181
+ const locale = localeIn ?? manifest.config?.defaultLanguage ?? 'en'
182
+ const router = buildRouter(manifest, locale)
183
+ const context = contextIn ?? { fields: {} }
184
+ const steps = flow.steps ?? []
185
+
186
+ for (const step of steps) {
187
+ if (!step || typeof step !== 'object') continue
188
+
189
+ if (step.page != null) {
190
+ if (!page) throw new Error('page step requires a Playwright page')
191
+ const pageId = normalizePageId(step.page)
192
+ const params = resolveContextObject(step.params, context)
193
+ const pathname = router.getPathname({ id: pageId, language: locale, params })
194
+ if (!pathname) throw new Error(`Page "${pageId}" not found in manifest`)
195
+ const target = appendSearch(pathname, step.search ?? step.query, context)
196
+ await page.goto(`${baseURL}${target}`)
197
+ continue
198
+ }
199
+
200
+ if (step.form) {
201
+ if (!page) throw new Error('form step requires a Playwright page')
202
+ const formMeta = step.form
203
+ const schemaId = formMeta?.schemaId
204
+ if (!formMeta || typeof formMeta !== 'object' || typeof formMeta.id !== 'string' || typeof schemaId !== 'string') {
205
+ throw new Error('form step requires a FormMetadata POJO with id and schemaId — import metadata from *.form.js')
206
+ }
207
+ const { id: formId } = formMeta
208
+ const template = findSchema(manifest, schemaId)
209
+ if (!template?.fields?.length) {
210
+ throw new Error(`Resource schema "${schemaId}" not found or has no fields`)
211
+ }
212
+ const engine = schemaEngineFromManifest(manifest)
213
+ const content = mockFormContent(engine, template, context)
214
+ const formRoot = formId ? page.locator(`form[id="${formId}"]`) : page
215
+ for (const field of template.fields) {
216
+ const value = content[field.name]
217
+ const locator = formRoot.locator(`[name="${field.name}"]`)
218
+ const tag = await locator.evaluate(el => el.tagName.toLowerCase()).catch(() => 'input')
219
+ if (tag === 'select') {
220
+ await locator.selectOption(String(value))
221
+ } else if (tag === 'input') {
222
+ const inputType = await locator.getAttribute('type')
223
+ if (inputType === 'checkbox') {
224
+ if (value) await locator.check()
225
+ } else {
226
+ await locator.fill(String(value))
227
+ }
228
+ } else {
229
+ await locator.fill(String(value))
230
+ }
231
+ }
232
+ continue
233
+ }
234
+
235
+ if (step.action != null) {
236
+ if (!page) throw new Error('action step requires a Playwright page')
237
+ const actionId = resolveActionId(step.action)
238
+ const service = resolveActionService(step.action, step.service)
239
+ const locator = page.locator(actionSelector(actionId, service)).first()
240
+ const actionTimeout = step.timeout ?? 15000
241
+ await locator.waitFor({ state: 'visible', timeout: actionTimeout })
242
+ await locator.click()
243
+ continue
244
+ }
245
+
246
+ if (step.capture != null) {
247
+ if (!page) throw new Error('capture step requires a Playwright page')
248
+ for (const [key, spec] of Object.entries(step.capture)) {
249
+ if (typeof spec === 'string' && spec.startsWith('$')) {
250
+ context[key] = resolveContextValue(spec, context)
251
+ continue
252
+ }
253
+ if (typeof spec === 'object' && spec.from === 'urlParam') {
254
+ const url = new URL(page.url())
255
+ context[key] = url.searchParams.get(spec.key ?? key)
256
+ continue
257
+ }
258
+ const selector = typeof spec === 'string' ? spec : spec.selector
259
+ const attr = typeof spec === 'object' ? spec.attr : undefined
260
+ const locator = page.locator(selector).first()
261
+ await locator.waitFor({ state: 'attached', timeout: step.timeout ?? 15000 })
262
+ context[key] = attr
263
+ ? await locator.getAttribute(attr)
264
+ : await locator.textContent()
265
+ }
266
+ continue
267
+ }
268
+
269
+ if (step.email != null) {
270
+ if (!page) throw new Error('email step requires a Playwright page')
271
+ const emailStep = typeof step.email === 'object' ? step.email : { id: step.email }
272
+ const to = resolveContextValue(emailStep.to ?? '$email', context)
273
+ const templateId = emailStep.id ?? emailStep.templateId
274
+ const clickLabel = emailStep.click ?? emailStep.link
275
+ if (!to) throw new Error('email step requires "to" (recipient email)')
276
+ if (!templateId) throw new Error('email step requires "id" (email template id)')
277
+ if (!clickLabel) throw new Error('email step requires "click" (link text in the email)')
278
+
279
+ const inboxPath = router.getPathname({ id: 'dev-inbox', language: locale }) ?? '/dev/inbox'
280
+ const params = new URLSearchParams({ to: String(to), template: String(templateId) })
281
+ const inboxUrl = `${baseURL}${inboxPath}?${params}`
282
+ const pattern = linkNamePattern(clickLabel)
283
+ const deadline = Date.now() + (emailStep.timeout ?? 20000)
284
+
285
+ let clicked = false
286
+ while (Date.now() < deadline && !clicked) {
287
+ await page.goto(inboxUrl)
288
+ const link = page.getByRole('link', { name: pattern }).first()
289
+ try {
290
+ await link.waitFor({ state: 'visible', timeout: 2000 })
291
+ await Promise.all([
292
+ page.waitForLoadState('domcontentloaded'),
293
+ link.click(),
294
+ ])
295
+ clicked = true
296
+ } catch {
297
+ await page.waitForTimeout(500)
298
+ }
299
+ }
300
+ if (!clicked) {
301
+ throw new Error(`Email link "${clickLabel}" not found for to=${to}, template=${templateId}`)
302
+ }
303
+ continue
304
+ }
305
+
306
+ if (step.result) {
307
+ if (!page) throw new Error('result step requires a Playwright page')
308
+ const { text, url, page: pageRef, action, selector, timeout = 8000 } = step.result
309
+ if (action != null) {
310
+ const actionId = resolveActionId(action)
311
+ const service = resolveActionService(action, step.result.service)
312
+ await expectFn(page.locator(actionSelector(actionId, service)).first()).toBeVisible({ timeout })
313
+ }
314
+ if (selector != null) {
315
+ await expectFn(page.locator(selector).first()).toBeVisible({ timeout })
316
+ }
317
+ if (text != null) {
318
+ const pattern = text instanceof RegExp ? text : new RegExp(text, 'i')
319
+ await expectFn(page.getByText(pattern).first()).toBeVisible({ timeout })
320
+ }
321
+ if (pageRef != null) {
322
+ const pageId = normalizePageId(pageRef)
323
+ const pathname = router.getPathname({ id: pageId, language: locale })
324
+ if (!pathname) throw new Error(`Result page "${pageId}" not found in manifest`)
325
+ const pathnameBase = pathname.replace(/\/$/, '') || pathname
326
+ const escaped = pathnameBase.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
327
+ await expectFn(page).toHaveURL(new RegExp(`${escaped}\\/?(\\?|#|$)`), { timeout })
328
+ }
329
+ if (url != null) {
330
+ const pattern = url instanceof RegExp ? url : new RegExp(url, 'i')
331
+ await expectFn(page).toHaveURL(pattern, { timeout })
332
+ }
333
+ continue
334
+ }
335
+
336
+ if (step.pickFirst != null) {
337
+ if (!page) throw new Error('pickFirst step requires a Playwright page')
338
+ const kind = typeof step.pickFirst === 'string' ? step.pickFirst : step.pickFirst.kind
339
+ const actionId = {
340
+ service: 'public-booking/pick-service',
341
+ date: 'public-booking/pick-date',
342
+ slot: 'public-booking/pick-slot',
343
+ }[kind]
344
+ if (!actionId) throw new Error(`Unknown pickFirst kind "${kind}"`)
345
+ const locator = page.locator(`${actionSelector(actionId)}:not([disabled])`).first()
346
+ await locator.waitFor({ state: 'visible', timeout: step.timeout ?? 15000 })
347
+ await locator.click()
348
+ continue
349
+ }
350
+
351
+ throw new Error(`Unknown flow step: ${JSON.stringify(step)}`)
352
+ }
353
+
354
+ return context
355
+ }
356
+
357
+ /**
358
+ * Register a single `*.flow.js` module as a Playwright test.
359
+ *
360
+ * Flows must be self-contained UI journeys — see docs/concepts/TESTING-SPEC.md.
361
+ *
362
+ * @param {{ metadata?: object, default?: object }} mod
363
+ * @param {{ manifestPath?: string, locale?: string }} options
364
+ */
365
+ export function registerFlow (mod, options = {}) {
366
+ const meta = mod.metadata ?? mod.default?.metadata ?? {}
367
+ const flowBody = mod.default ?? mod
368
+ const id = meta.id ?? flowBody.id
369
+ if (typeof id !== 'string' || !id) throw new Error('flow module is missing metadata.id')
370
+
371
+ const feature = meta.feature ?? id
372
+ const steps = flowBody.steps ?? []
373
+ const title = flowBody.title ?? id
374
+
375
+ test.describe(feature, () => {
376
+ test(title, async ({ page, baseURL }) => {
377
+ const manifestPath = options.manifestPath
378
+ const manifest = manifestPath ? loadManifest(manifestPath) : undefined
379
+ await runFlow({ ...flowBody, steps, metadata: meta }, {
380
+ mode: 'test',
381
+ page,
382
+ baseURL,
383
+ manifest,
384
+ manifestPath,
385
+ locale: options.locale,
386
+ })
387
+ })
388
+ })
389
+ }
390
+
391
+ /**
392
+ * Register all flows listed in `manifest.flows[]`.
393
+ * @param {string} manifestPath Absolute path to build/manifest.json
394
+ */
395
+ export async function registerFlowsFromManifest (manifestPath) {
396
+ const manifest = loadManifest(manifestPath)
397
+ const buildDir = path.dirname(manifestPath)
398
+ const staticDir = path.join(buildDir, 'public', 'static')
399
+
400
+ for (const entry of manifest.flows ?? []) {
401
+ const chunkName = entry.entry.replace(/^\/static\//, '')
402
+ const chunkPath = path.join(staticDir, chunkName)
403
+ const mod = await import(pathToFileURL(chunkPath).href + `?ts=${Date.now()}`)
404
+ registerFlow(mod, { manifestPath, locale: manifest.config?.defaultLanguage })
405
+ }
406
+ }
407
+
408
+ function findNodeModules (startDir) {
409
+ let dir = startDir
410
+ for (let i = 0; i < 10; i++) {
411
+ const candidate = path.join(dir, 'node_modules')
412
+ if (fs.existsSync(candidate)) return candidate
413
+ const parent = path.dirname(dir)
414
+ if (parent === dir) break
415
+ dir = parent
416
+ }
417
+ return null
418
+ }
419
+
420
+ function discoverFlowFiles (nmDir) {
421
+ const results = []
422
+ const walk = (dir, cb) => {
423
+ if (!fs.existsSync(dir)) return
424
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
425
+ const full = path.join(dir, entry.name)
426
+ if (entry.isDirectory()) walk(full, cb)
427
+ else if (entry.isFile()) cb(full)
428
+ }
429
+ }
430
+
431
+ const tryPackageDir = (pkgDir) => {
432
+ const pkgJsonPath = path.join(pkgDir, 'package.json')
433
+ if (!fs.existsSync(pkgJsonPath)) return
434
+ let pkg
435
+ try { pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8')) } catch { return }
436
+ if (!pkg.ossy?.src) return
437
+ const srcDir = path.resolve(pkgDir, pkg.ossy.src)
438
+ walk(srcDir, filePath => {
439
+ if (FLOW_PATTERN.test(path.basename(filePath))) results.push(filePath)
440
+ })
441
+ }
442
+
443
+ for (const entry of fs.readdirSync(nmDir, { withFileTypes: true })) {
444
+ if (!entry.isDirectory() && !entry.isSymbolicLink()) continue
445
+ if (entry.name.startsWith('@')) {
446
+ const scopeDir = path.join(nmDir, entry.name)
447
+ if (!fs.existsSync(scopeDir)) continue
448
+ for (const scoped of fs.readdirSync(scopeDir, { withFileTypes: true })) {
449
+ if (scoped.isDirectory() || scoped.isSymbolicLink()) {
450
+ tryPackageDir(path.join(scopeDir, scoped.name))
451
+ }
452
+ }
453
+ } else {
454
+ tryPackageDir(path.join(nmDir, entry.name))
455
+ }
456
+ }
457
+
458
+ return results
459
+ }
460
+
461
+ /**
462
+ * Walk installed `@ossy/*` packages and register all `*.flow.js` files.
463
+ * @param {string} fromUrl `import.meta.url` of the calling spec file.
464
+ * @param {{ manifestPath?: string }} options
465
+ */
466
+ export async function registerInstalledFlows (fromUrl, options = {}) {
467
+ const callerDir = path.dirname(fileURLToPath(fromUrl))
468
+ const nmDir = findNodeModules(callerDir)
469
+ if (!nmDir) return
470
+
471
+ const flowPaths = discoverFlowFiles(nmDir)
472
+ for (const filePath of flowPaths) {
473
+ const mod = await import(filePath)
474
+ registerFlow(mod, options)
475
+ }
476
+ }
@@ -1,6 +1,9 @@
1
1
  import casual from 'casual'
2
2
  import { EventStore } from '@ossy/event-store'
3
3
 
4
+ const USER_SCHEMA = '@ossy/users/schema/user'
5
+ const TOKEN_SCHEMA = '@ossy/tokens/schema/token'
6
+
4
7
  /** Requires Node 18+ (global `fetch`). */
5
8
 
6
9
  /**
@@ -130,11 +133,10 @@ export class TestUtil {
130
133
  return EventStore.FindEvents(query)
131
134
  }
132
135
 
133
- /** JWT from the latest Verification token aggregate for this user (sign-up or sign-in request). */
134
136
  static countVerificationTokenEvents() {
135
137
  return EventStore.Collection.countDocuments({
136
- aggregateType: 'Token',
137
- type: 'Created',
138
+ type: TOKEN_SCHEMA,
139
+ event: 'Created',
138
140
  'payload.type': 'Verification',
139
141
  })
140
142
  }
@@ -142,12 +144,12 @@ export class TestUtil {
142
144
  static async getLatestApiTokenCreatedEventForSubject(subjectId) {
143
145
  const ev = await EventStore.Collection.findOne(
144
146
  {
145
- aggregateType: 'Token',
146
- type: 'Created',
147
+ type: TOKEN_SCHEMA,
148
+ event: 'Created',
147
149
  'payload.type': 'Api',
148
150
  'payload.subject': subjectId,
149
151
  },
150
- { sort: { created: -1 } }
152
+ { sort: { created: -1 } },
151
153
  )
152
154
  if (!ev) {
153
155
  throw new Error('No Api token Created event for subject')
@@ -158,12 +160,12 @@ export class TestUtil {
158
160
  static async getLatestVerificationJwtForSubject(subjectId) {
159
161
  const ev = await EventStore.Collection.findOne(
160
162
  {
161
- aggregateType: 'Token',
162
- type: 'Created',
163
+ type: TOKEN_SCHEMA,
164
+ event: 'Created',
163
165
  'payload.type': 'Verification',
164
166
  'payload.subject': subjectId,
165
167
  },
166
- { sort: { created: -1 } }
168
+ { sort: { created: -1 } },
167
169
  )
168
170
  if (!ev?.payload?.token) {
169
171
  return Promise.reject(new Error('No verification token found for subject'))
@@ -175,51 +177,50 @@ export class TestUtil {
175
177
  const email = `${casual.email}`
176
178
 
177
179
  return TestUtil.InvokeAction({
178
- actionId: 'authentication/sign-up',
180
+ actionId: '@ossy/authentication/actions/sign-up',
179
181
  body: TestUtil.signUpBody({ email }),
180
182
  })
181
- .then(() => EventStore.FindEvent({
182
- aggregateType: 'User',
183
- type: { $in: [ 'SignedUp' ] },
184
- 'payload.email': email
185
- }))
186
- .then(event => event.payload.verificationToken)
183
+ .then(() => EventStore.FindEvent({
184
+ type: USER_SCHEMA,
185
+ event: 'Created',
186
+ 'payload.email': email,
187
+ }))
188
+ .then(event => event.payload.verificationToken)
187
189
  }
188
190
 
189
191
  static async GetAuthenticatedTestUser(email = casual.email) {
190
192
 
191
193
  await TestUtil.AssertActionResponse({
192
- actionId: 'authentication/sign-up',
194
+ actionId: '@ossy/authentication/actions/sign-up',
193
195
  body: TestUtil.signUpBody({ email }),
194
196
  expectedResponseStatus: 200,
195
- expectedResponseBody: { ok: true }
197
+ expectedResponseBody: { ok: true },
196
198
  })
197
199
 
198
200
  const signedUpEvent = await TestUtil.GetEvent({
199
- aggregateType: 'User',
200
- type: { $in: [ 'SignedUp' ] },
201
- 'payload.email': email
201
+ type: USER_SCHEMA,
202
+ event: 'Created',
203
+ 'payload.email': email,
202
204
  })
203
205
 
204
- const verificationJwt = await TestUtil.getLatestVerificationJwtForSubject(signedUpEvent.aggregateId)
206
+ const verificationJwt = await TestUtil.getLatestVerificationJwtForSubject(signedUpEvent.resourceId)
205
207
 
206
208
  await fetch(
207
209
  `${baseUrl()}/users/verify-sign-in?token=${verificationJwt}`,
208
- { method: 'GET' }
210
+ { method: 'GET' },
209
211
  )
210
212
 
211
213
  const signInVerifiedEvent = await TestUtil.GetEvent({
212
- aggregateType: 'User',
213
- aggregateId: signedUpEvent.aggregateId,
214
- type: { $in: [ 'SignInVerified' ] },
214
+ type: USER_SCHEMA,
215
+ resourceId: signedUpEvent.resourceId,
216
+ event: 'SignInVerified',
215
217
  })
216
218
 
217
219
  return {
218
- id: signedUpEvent.aggregateId,
220
+ id: signedUpEvent.resourceId,
219
221
  token: signInVerifiedEvent.payload.token,
220
- email: email,
222
+ email,
221
223
  }
222
-
223
224
  }
224
225
 
225
226
  static CloseDbConnection() {
@@ -0,0 +1,44 @@
1
+ export const USER_SETTINGS_COOKIE = 'x-ossy-user-settings'
2
+ export const AUTH_COOKIE = 'auth'
3
+
4
+ const USER_SETTINGS_MAX_AGE_MS = 2147483647
5
+ const AUTH_MAX_AGE_MS = 14 * 24 * 60 * 60 * 1000
6
+
7
+ export function readUserAppSettings (req) {
8
+ return JSON.parse(req.signedCookies?.[USER_SETTINGS_COOKIE] || '{}')
9
+ }
10
+
11
+ export function mergeUserAppSettingsCookie (req, res, partial) {
12
+ const updated = { ...readUserAppSettings(req), ...partial }
13
+ res.cookie(USER_SETTINGS_COOKIE, JSON.stringify(updated), {
14
+ httpOnly: true,
15
+ signed: true,
16
+ expires: new Date(Date.now() + USER_SETTINGS_MAX_AGE_MS),
17
+ })
18
+ }
19
+
20
+ export function clearWorkspaceFromUserAppSettings (req, res) {
21
+ const settings = readUserAppSettings(req)
22
+ delete settings.workspaceId
23
+ if (Object.keys(settings).length === 0) {
24
+ res.clearCookie(USER_SETTINGS_COOKIE)
25
+ return
26
+ }
27
+ res.cookie(USER_SETTINGS_COOKIE, JSON.stringify(settings), {
28
+ httpOnly: true,
29
+ signed: true,
30
+ expires: new Date(Date.now() + USER_SETTINGS_MAX_AGE_MS),
31
+ })
32
+ }
33
+
34
+ export function setAuthCookie (res, token) {
35
+ res.cookie(AUTH_COOKIE, token, {
36
+ httpOnly: true,
37
+ signed: true,
38
+ expires: new Date(Date.now() + AUTH_MAX_AGE_MS),
39
+ })
40
+ }
41
+
42
+ export function clearAuthCookie (res) {
43
+ res.clearCookie(AUTH_COOKIE)
44
+ }