@ossy/platform 3.11.1 → 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.1",
3
+ "version": "3.12.0",
4
4
  "description": "Ossy application server runtime",
5
5
  "repository": {
6
6
  "type": "git",
@@ -46,16 +46,16 @@
46
46
  "@aws-sdk/util-format-url": "^3.972.17",
47
47
  "@modelcontextprotocol/sdk": "^1.12.1",
48
48
  "@ossy/config": "^3.0.9",
49
- "@ossy/event-store": "^3.11.1",
49
+ "@ossy/event-store": "^3.12.0",
50
50
  "@ossy/locale": "^3.4.0",
51
51
  "@ossy/manifest": "^3.9.0",
52
52
  "@ossy/observability": "^3.0.9",
53
53
  "@ossy/policies": "^3.0.9",
54
54
  "@ossy/schema": "^3.8.0",
55
- "@ossy/sdk": "^3.11.1",
55
+ "@ossy/sdk": "^3.12.0",
56
56
  "@ossy/tokens": "^3.11.1",
57
- "@ossy/users": "^3.11.1",
58
- "@ossy/workspaces": "^3.11.1",
57
+ "@ossy/users": "^3.12.0",
58
+ "@ossy/workspaces": "^3.12.0",
59
59
  "cookie-parser": "^1.4.7",
60
60
  "dotenv": ">=16.0.0 <18.0.0",
61
61
  "express": ">=5.0.0 <6.0.0",
@@ -76,5 +76,5 @@
76
76
  "Dockerfile",
77
77
  "docker-healthcheck.js"
78
78
  ],
79
- "gitHead": "f279de3535b6bc76631cbc6b943dae8f92a1565f"
79
+ "gitHead": "c2650803219ad1831559b512848358d9550ffad2"
80
80
  }
@@ -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
@@ -326,6 +326,17 @@ export async function startServer (options = {}) {
326
326
  }
327
327
 
328
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
+ }
329
340
  // Liveness for ALB/ECS — before auth so probes never depend on cookies/Mongo.
330
341
  // Prefer OSSY_SERVICE_NAME (ECS task env) when set.
331
342
  mountHealthEndpoint(app)
@@ -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,
@@ -840,17 +847,32 @@ export function registerFlow (mod, options = {}) {
840
847
  /**
841
848
  * Register all flows listed in `manifest.flows[]`.
842
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`
843
851
  */
844
- export async function registerFlowsFromManifest (manifestPath) {
852
+ export async function registerFlowsFromManifest (manifestPath, options = {}) {
845
853
  const manifest = loadManifest(manifestPath)
846
854
  const buildDir = path.dirname(manifestPath)
847
855
  const staticDir = path.join(buildDir, 'public', 'static')
848
-
849
- 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]
850
865
  const chunkName = entry.entry.replace(/^\/static\//, '')
851
866
  const chunkPath = path.join(staticDir, chunkName)
852
867
  const mod = await import(pathToFileURL(chunkPath).href + `?ts=${Date.now()}`)
853
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
+ )
854
876
  }
855
877
  }
856
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
+ })