@toa.io/extensions.exposition 1.0.0-alpha.2 → 1.0.0-alpha.3

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 (67) hide show
  1. package/components/identity.federation/manifest.toa.yaml +12 -0
  2. package/components/identity.federation/operations/authenticate.js +4 -4
  3. package/components/identity.federation/operations/authenticate.js.map +1 -1
  4. package/components/identity.federation/operations/create.js +4 -4
  5. package/components/identity.federation/operations/create.js.map +1 -1
  6. package/components/identity.federation/operations/{assertions-as-values.cjs → lib/assertions-as-values.js} +1 -1
  7. package/components/identity.federation/operations/lib/assertions-as-values.js.map +1 -0
  8. package/components/identity.federation/operations/{jwt.d.cts → lib/jwt.d.ts} +5 -4
  9. package/components/identity.federation/operations/{jwt.cjs → lib/jwt.js} +35 -11
  10. package/components/identity.federation/operations/lib/jwt.js.map +1 -0
  11. package/components/identity.federation/operations/schemas.d.ts +16 -0
  12. package/components/identity.federation/operations/tsconfig.tsbuildinfo +1 -1
  13. package/components/identity.federation/operations/types.d.ts +1 -1
  14. package/components/identity.federation/source/authenticate.ts +2 -2
  15. package/components/identity.federation/source/create.ts +2 -2
  16. package/components/identity.federation/source/{assertions-as-values.cts → lib/assertions-as-values.ts} +1 -2
  17. package/components/identity.federation/source/lib/jwt.test.ts +56 -0
  18. package/components/identity.federation/source/{jwt.cts → lib/jwt.ts} +57 -29
  19. package/components/identity.federation/source/schemas.ts +16 -0
  20. package/components/identity.federation/source/types.ts +1 -1
  21. package/documentation/components.md +7 -0
  22. package/documentation/identity.md +7 -0
  23. package/documentation/octets.md +12 -0
  24. package/features/identity.federation.feature +31 -1
  25. package/features/octets.workflows.feature +38 -0
  26. package/features/steps/IdP.ts +29 -0
  27. package/package.json +8 -8
  28. package/schemas/octets/workflow.cos.yaml +12 -0
  29. package/source/directives/octets/Delete.ts +2 -2
  30. package/source/directives/octets/Octets.ts +3 -1
  31. package/source/directives/octets/Store.ts +17 -3
  32. package/source/directives/octets/Workflow.ts +41 -0
  33. package/source/directives/octets/schemas.test.ts +21 -0
  34. package/source/directives/octets/schemas.ts +2 -0
  35. package/source/directives/octets/{workflow → workflows}/Execution.ts +0 -2
  36. package/transpiled/directives/octets/Delete.d.ts +1 -1
  37. package/transpiled/directives/octets/Delete.js +2 -2
  38. package/transpiled/directives/octets/Delete.js.map +1 -1
  39. package/transpiled/directives/octets/Octets.js +3 -1
  40. package/transpiled/directives/octets/Octets.js.map +1 -1
  41. package/transpiled/directives/octets/Store.d.ts +2 -1
  42. package/transpiled/directives/octets/Store.js +11 -3
  43. package/transpiled/directives/octets/Store.js.map +1 -1
  44. package/transpiled/directives/octets/Workflow.d.ts +14 -0
  45. package/transpiled/directives/octets/Workflow.js +52 -0
  46. package/transpiled/directives/octets/Workflow.js.map +1 -0
  47. package/transpiled/directives/octets/schemas.d.ts +2 -0
  48. package/transpiled/directives/octets/schemas.js +2 -1
  49. package/transpiled/directives/octets/schemas.js.map +1 -1
  50. package/transpiled/directives/octets/{workflow → workflows}/Execution.js +0 -1
  51. package/transpiled/directives/octets/workflows/Execution.js.map +1 -0
  52. package/transpiled/directives/octets/workflows/Workflow.js.map +1 -0
  53. package/transpiled/directives/octets/workflows/index.js.map +1 -0
  54. package/transpiled/tsconfig.tsbuildinfo +1 -1
  55. package/components/identity.federation/operations/assertions-as-values.cjs.map +0 -1
  56. package/components/identity.federation/operations/jwt.cjs.map +0 -1
  57. package/transpiled/directives/octets/workflow/Execution.js.map +0 -1
  58. package/transpiled/directives/octets/workflow/Workflow.js.map +0 -1
  59. package/transpiled/directives/octets/workflow/index.js.map +0 -1
  60. /package/components/identity.federation/operations/{assertions-as-values.d.cts → lib/assertions-as-values.d.ts} +0 -0
  61. /package/source/directives/octets/{workflow → workflows}/Workflow.ts +0 -0
  62. /package/source/directives/octets/{workflow → workflows}/index.ts +0 -0
  63. /package/transpiled/directives/octets/{workflow → workflows}/Execution.d.ts +0 -0
  64. /package/transpiled/directives/octets/{workflow → workflows}/Workflow.d.ts +0 -0
  65. /package/transpiled/directives/octets/{workflow → workflows}/Workflow.js +0 -0
  66. /package/transpiled/directives/octets/{workflow → workflows}/index.d.ts +0 -0
  67. /package/transpiled/directives/octets/{workflow → workflows}/index.js +0 -0
@@ -0,0 +1,56 @@
1
+ /* eslint-disable max-len */
2
+ /* eslint-disable @typescript-eslint/consistent-type-assertions */
3
+ import { validateSignature, decodeJwt } from './jwt'
4
+
5
+ describe('jwt', () => {
6
+ test('decode', () => {
7
+ const { header, payload } = decodeJwt('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzb21lIjoicGF5bG9hZCJ9.4twFt5NiznN84AWoo1d7KO1T_yoc0Z6XOpOVswacPZg')
8
+
9
+ expect(header).toMatchObject({ alg: 'HS256' })
10
+ expect(payload).toEqual({ some: 'payload' })
11
+ })
12
+
13
+ test('symmetric pass', async () => {
14
+ // example from
15
+ // https://pyjwt.readthedocs.io/en/latest/usage.html#encoding-decoding-tokens-with-hs256
16
+
17
+ await expect(validateSignature({
18
+ header: { alg: 'HS256', kid: 'k2' },
19
+ payload: { iss: 'test-issuer', aud: 'test', sub: '0', exp: 0, iat: 0 },
20
+ rawHeader: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9',
21
+ rawPayload: 'eyJzb21lIjoicGF5bG9hZCJ9',
22
+ signature: '4twFt5NiznN84AWoo1d7KO1T_yoc0Z6XOpOVswacPZg',
23
+ trusted: [
24
+ {
25
+ issuer: 'test-issuer',
26
+ secrets: {
27
+ HS256: {
28
+ k1: 'old-secret',
29
+ k2: 'secret'
30
+ }
31
+ }
32
+ }
33
+ ]
34
+ } as Parameters<typeof validateSignature>[0])).resolves.toBeUndefined()
35
+ })
36
+
37
+ test('symmetric fail', async () => {
38
+ await expect(validateSignature({
39
+ header: { alg: 'HS256' },
40
+ payload: { iss: 'test-issuer', aud: 'test', sub: '0', exp: 0, iat: 0 },
41
+ rawHeader: 'header',
42
+ rawPayload: 'payload',
43
+ signature: 'signature',
44
+ trusted: [
45
+ {
46
+ issuer: 'test-issuer',
47
+ secrets: {
48
+ HS256: {
49
+ theKey: 'secret'
50
+ }
51
+ }
52
+ }
53
+ ]
54
+ } as Parameters<typeof validateSignature>[0])).rejects.toThrow('Signature does not match')
55
+ })
56
+ })
@@ -1,7 +1,7 @@
1
1
  import crypto from 'node:crypto'
2
2
  import * as assert from 'node:assert'
3
- import type { JwtHeader, IdToken } from './types'
4
- import { type TrustConfiguration } from './schemas'
3
+ import { type JwtHeader, type IdToken } from '../types'
4
+ import { type TrustConfiguration } from '../schemas'
5
5
 
6
6
  export function decodeJwt (token: string): {
7
7
  header: unknown
@@ -20,20 +20,19 @@ export function decodeJwt (token: string): {
20
20
 
21
21
  export function validateJwtHeader (header: unknown): asserts header is JwtHeader {
22
22
  assert.ok(header !== null && typeof header === 'object', 'Header is not an object')
23
- assert.ok('typ' in header, 'Header is missing typ')
24
- assert.equal(header.typ, 'JWT')
25
23
  assert.ok('alg' in header, 'Header is missing alg')
26
24
  assert.ok(typeof header.alg === 'string', 'Header alg is not a string')
27
- assert.equal(header.alg, 'RS256', `We only validating RS256 id_tokens, but got ${header.alg}`)
25
+ assert.match(header.alg, /^RS256|HS\d{3}$/, `Unknown algorithm ${header.alg}`)
26
+ assert.ok(!('kid' in header) || typeof header.kid === 'string', 'kid must be a string if present')
28
27
  }
29
28
 
30
- export function validateJwtPayload (
31
- payload: unknown,
32
- trusted: TrustConfiguration[] = []
33
- ): asserts payload is IdToken {
29
+ export function validateJwtPayload (payload: unknown,
30
+ trusted: TrustConfiguration[] = [],
31
+ header: JwtHeader): asserts payload is IdToken {
34
32
  assert.ok(trusted.length > 0, 'No trusted issuers provided')
35
33
 
36
- // full list of validations is at https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation
34
+ // full list of validations is
35
+ // at https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation
37
36
  assert.ok(payload !== null && typeof payload === 'object', 'Payload is not an object')
38
37
 
39
38
  assert.ok('iss' in payload, 'Payload is missing iss')
@@ -41,9 +40,24 @@ export function validateJwtPayload (
41
40
  assert.ok('aud' in payload, 'Payload is missing aud')
42
41
  assert.ok(typeof payload.aud === 'string', 'Payload aud is not a string')
43
42
 
44
- assert.ok(trusted.some(config => config.issuer === payload.iss &&
45
- (config.audience === undefined || config.audience.some(a => a === payload.aud))),
46
- `Unknown issuer / audience: ${payload.iss} / ${payload.aud}`)
43
+ const issuer = trusted.find((config) => config.issuer === payload.iss)
44
+
45
+ assert.ok(issuer !== undefined &&
46
+ (issuer.audience === undefined || issuer.audience.some((a) => a === payload.aud),
47
+ `Unknown issuer / audience: ${payload.iss} / ${payload.aud}`))
48
+
49
+ if (header.alg.startsWith('HS')) {
50
+ const secrets = issuer.secrets
51
+
52
+ assert.ok(secrets, `We don't have known secrets for ${payload.iss}`)
53
+
54
+ const keys = secrets[header.alg]
55
+
56
+ assert.ok(keys, `No known secrets for ${header.alg}`)
57
+
58
+ if (typeof header.kid === 'string')
59
+ assert.ok(header.kid in keys, `No secret ${header.kid} provided for ${header.alg}`)
60
+ }
47
61
 
48
62
  assert.ok('sub' in payload, 'Payload is missing sub')
49
63
  assert.ok(typeof payload.sub === 'string', 'Payload sub is not a string')
@@ -68,20 +82,39 @@ export async function validateSignature ({
68
82
  payload: { iss },
69
83
  rawHeader,
70
84
  rawPayload,
71
- signature
85
+ signature,
86
+ trusted = []
72
87
  }: {
73
88
  readonly header: JwtHeader
74
89
  rawHeader: string
75
90
  readonly payload: IdToken
76
91
  rawPayload: string
77
92
  signature: string
93
+ trusted?: TrustConfiguration[]
78
94
  }): Promise<void> {
95
+ if (alg.startsWith('HS')) {
96
+ // symmetric algorithm, issuer is validated at this point
97
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- `kid` is validated
98
+ const secrets = trusted.find((c) => c.issuer === iss)!.secrets![alg]
99
+ const secret = kid !== undefined ? secrets[kid] : Object.values(secrets)[0]
100
+ const algorithm = alg.replace(/^HS(\d{3})$/, 'sha$1') // HS256 -> sha256
101
+ const hmac = crypto.createHmac(algorithm, secret)
102
+
103
+ hmac.update(rawHeader)
104
+ hmac.update('.')
105
+ hmac.update(rawPayload)
106
+ assert.strictEqual(signature, hmac.digest('base64url'), 'Signature does not match')
107
+
108
+ return
109
+ }
110
+
79
111
  // Getting issuer public keys
80
112
  const oidcRequest = await fetch(`${iss}/.well-known/openid-configuration`, {
81
113
  cache: 'default'
82
114
  })
83
115
 
84
- assert.ok(oidcRequest.ok, `Failed to fetch OpenID configuration: ${oidcRequest.statusText}`)
116
+ assert.ok(oidcRequest.ok,
117
+ `Failed to fetch OpenID configuration: ${oidcRequest.statusText}`)
85
118
 
86
119
  const { jwks_uri: jwksUri } = (await oidcRequest.json()) as { jwks_uri: string }
87
120
 
@@ -98,10 +131,8 @@ export async function validateSignature ({
98
131
 
99
132
  assert.ok(signingKeys.length > 0, 'No acceptable signing keys found')
100
133
 
101
- assert.ok(
102
- kid === undefined || signingKeys.length === 1,
103
- 'Signing key selection is not deterministic'
104
- )
134
+ assert.ok(kid === undefined || signingKeys.length === 1,
135
+ 'Signing key selection is not deterministic')
105
136
 
106
137
  const signingKey = kid === undefined ? signingKeys.find((k) => k.kid === kid) : keys[0]
107
138
 
@@ -114,29 +145,26 @@ export async function validateSignature ({
114
145
  verifyFunction.write(rawPayload)
115
146
  verifyFunction.end()
116
147
 
117
- const signatureValid = verifyFunction.verify(
118
- { format: 'jwk', key: signingKey },
148
+ const signatureValid = verifyFunction.verify({ format: 'jwk', key: signingKey },
119
149
  signature,
120
- 'base64url'
121
- )
150
+ 'base64url')
122
151
 
123
152
  assert.ok(signatureValid, 'Failed to validate signature')
124
153
  }
125
154
 
126
- export async function validateIdToken (
127
- token: string,
128
- trusted?: TrustConfiguration[]
129
- ): Promise<IdToken> {
155
+ export async function validateIdToken (token: string,
156
+ trusted?: TrustConfiguration[]): Promise<IdToken> {
130
157
  const { header, payload, rawHeader, rawPayload, signature } = decodeJwt(token)
131
158
 
132
159
  validateJwtHeader(header)
133
- validateJwtPayload(payload, trusted)
160
+ validateJwtPayload(payload, trusted, header)
134
161
  await validateSignature({
135
162
  header,
136
163
  rawHeader,
137
164
  payload,
138
165
  rawPayload,
139
- signature
166
+ signature,
167
+ trusted
140
168
  })
141
169
 
142
170
  return payload
@@ -42,4 +42,20 @@ export interface TrustConfiguration {
42
42
  * @minItems 1
43
43
  */
44
44
  audience?: [string, ...string[]];
45
+ /**
46
+ * Symmetric encryption secrets
47
+ */
48
+ secrets?: {
49
+ /**
50
+ * This interface was referenced by `undefined`'s JSON-Schema definition
51
+ * via the `patternProperty` "^HS\d{3}$".
52
+ */
53
+ [k: string]: {
54
+ /**
55
+ * This interface was referenced by `undefined`'s JSON-Schema definition
56
+ * via the `patternProperty` "^\w+$".
57
+ */
58
+ [k: string]: string;
59
+ };
60
+ };
45
61
  }
@@ -32,7 +32,7 @@ interface IdentityTokensRevokeInput {
32
32
  }
33
33
 
34
34
  export interface JwtHeader {
35
- typ: string
35
+ typ?: string
36
36
  alg: string
37
37
  kid?: string
38
38
  }
@@ -100,6 +100,8 @@ The configuration schema alongside default values is described in the [component
100
100
 
101
101
  No federated tokens are accepted by default until at least one entry is added to the `trust` configuration.
102
102
 
103
+ Toa supports either asymmetric RS256 or symmetric HS256 / HS384 / HS512 tokens with pre-shared secrets.
104
+
103
105
  ```yaml
104
106
  # context.toa.yaml
105
107
 
@@ -110,6 +112,11 @@ configuration:
110
112
  audience:
111
113
  - https://github.com/tinovyatkin
112
114
  - https://github.com/temich
115
+
116
+ - issuer: some.private.issuer
117
+ secrets:
118
+ HS256:
119
+ k1: <secret-to-be-used-for-hs256>
113
120
  ```
114
121
 
115
122
  ## Stateless tokens
@@ -80,7 +80,14 @@ configuration:
80
80
  - issuer: https://accounts.google.com
81
81
  audience:
82
82
  - <GOOGLE_CLIENT_ID>
83
+
83
84
  - issuer: https://appleid.apple.com
85
+
86
+ - issuer: private.entity
87
+ secrets:
88
+ HS384:
89
+ key0: <THE-SECRET-STRING-FOR-HS384>
90
+ key1: <THE-SECRET-STRING-FOR-HS384> # selected by `kid` in the JWT header
84
91
  ```
85
92
 
86
93
  ## Identity inception
@@ -209,6 +209,18 @@ under the request path.
209
209
 
210
210
  The request body must be a list of entry identifiers.
211
211
 
212
+ ## `octets:workflow`
213
+
214
+ Execute a [workflow](#workflows) on the entry under the request path.
215
+
216
+ ```yaml
217
+ /images:
218
+ /*:
219
+ DELETE:
220
+ octets:workflow:
221
+ archive: images.archive
222
+ ```
223
+
212
224
  ## Workflows
213
225
 
214
226
  A workflow is a list of endpoints to be called.
@@ -54,6 +54,36 @@ Feature: Identity Federation
54
54
  id: ${{ User.id }}
55
55
  """
56
56
 
57
+ Scenario: Getting identity for a user with symmetric tokens
58
+ Given the `identity.federation` configuration:
59
+ """yaml
60
+ explicit_identity_creation: false
61
+ trust:
62
+ - issuer: http://localhost:44444
63
+ secrets:
64
+ HS384:
65
+ k1: the-secret
66
+ """
67
+ And the IDP HS384 token for GoodUser is issued with following secret:
68
+ """
69
+ the-secret
70
+ """
71
+ When the following request is received:
72
+ """
73
+ GET /identity/ HTTP/1.1
74
+ authorization: Bearer ${{ GoodUser.id_token }}
75
+ accept: application/yaml
76
+ content-type: application/yaml
77
+ """
78
+ Then the following reply is sent:
79
+ """
80
+ 200 OK
81
+ authorization: Token ${{ GoodUser.token }}
82
+
83
+ id: ${{ GoodUser.id }}
84
+ scheme: bearer
85
+ """
86
+
57
87
  Scenario: Creating an Identity using inception with existing credentials
58
88
  Given the `identity.federation` configuration:
59
89
  """yaml
@@ -67,7 +97,7 @@ Feature: Identity Federation
67
97
  anonymous: true
68
98
  POST:
69
99
  incept: id
70
- endpoint: transit
100
+ endpoint: create
71
101
  """
72
102
  And the IDP token for Bill is issued
73
103
  When the following request is received:
@@ -246,3 +246,41 @@ Feature: Octets storage workflows
246
246
  concat: hello world
247
247
  --cut--
248
248
  """
249
+
250
+ Scenario: Executing a workflow with `octets:workflow`
251
+ Given the `octets.tester` is running
252
+ And the annotation:
253
+ """yaml
254
+ /:
255
+ auth:anonymous: true
256
+ octets:context: octets
257
+ POST:
258
+ octets:store: ~
259
+ /*:
260
+ DELETE:
261
+ octets:workflow:
262
+ echo: octets.tester.echo
263
+ """
264
+ When the stream of `lenna.ascii` is received with the following headers:
265
+ """
266
+ POST / HTTP/1.1
267
+ content-type: application/octet-stream
268
+ """
269
+ Then the following reply is sent:
270
+ """
271
+ 201 Created
272
+ """
273
+ When the following request is received:
274
+ """
275
+ DELETE /10cf16b458f759e0d617f2f3d83599ff HTTP/1.1
276
+ accept: application/yaml
277
+ """
278
+ Then the following reply is sent:
279
+ """
280
+ 202 Accepted
281
+ content-type: multipart/yaml; boundary=cut
282
+
283
+ --cut
284
+ echo: 10cf16b458f759e0d617f2f3d83599ff
285
+ --cut--
286
+ """
@@ -117,4 +117,33 @@ export class IdP {
117
117
 
118
118
  this.captures.set(`${user}.id_token`, idToken)
119
119
  }
120
+
121
+ @given('the IDP {word} token for {word} is issued with following secret:')
122
+ public async issueSymmetricToken (alg: string, user: string, secret: string): Promise<void> {
123
+ console.log('Sym token for %s with secret "%s"', user, secret)
124
+
125
+ const jwt = [
126
+ {
127
+ typ: 'JWT',
128
+ alg
129
+ },
130
+ {
131
+ iss: IdP.issuer,
132
+ sub: `${user}-mock-id`,
133
+ aud: 'test',
134
+ iat: Math.floor(Date.now() / 1000),
135
+ exp: Math.floor((Date.now() + 1000 * 60 * 5) / 1000)
136
+ }
137
+ ]
138
+ .map((v) => Buffer.from(JSON.stringify(v)).toString('base64url'))
139
+ .join('.')
140
+
141
+ const signature = crypto.createHmac(alg.replace(/^HS(\d{3})$/, 'sha$1'), secret)
142
+ .update(jwt)
143
+ .digest('base64url')
144
+
145
+ const idToken = `${jwt}.${signature}`
146
+
147
+ this.captures.set(`${user}.id_token`, idToken)
148
+ }
120
149
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@toa.io/extensions.exposition",
3
- "version": "1.0.0-alpha.2",
3
+ "version": "1.0.0-alpha.3",
4
4
  "description": "Toa Exposition",
5
5
  "author": "temich <tema.gurtovoy@gmail.com>",
6
6
  "homepage": "https://github.com/toa-io/toa#readme",
@@ -17,10 +17,10 @@
17
17
  "access": "public"
18
18
  },
19
19
  "dependencies": {
20
- "@toa.io/core": "1.0.0-alpha.2",
21
- "@toa.io/generic": "1.0.0-alpha.2",
22
- "@toa.io/schemas": "1.0.0-alpha.2",
23
- "@toa.io/streams": "1.0.0-alpha.2",
20
+ "@toa.io/core": "1.0.0-alpha.3",
21
+ "@toa.io/generic": "1.0.0-alpha.3",
22
+ "@toa.io/schemas": "1.0.0-alpha.3",
23
+ "@toa.io/streams": "1.0.0-alpha.3",
24
24
  "bcryptjs": "2.4.3",
25
25
  "error-value": "0.3.0",
26
26
  "express": "4.18.2",
@@ -45,12 +45,12 @@
45
45
  "features": "cucumber-js"
46
46
  },
47
47
  "devDependencies": {
48
- "@toa.io/agent": "1.0.0-alpha.2",
49
- "@toa.io/extensions.storages": "1.0.0-alpha.2",
48
+ "@toa.io/agent": "1.0.0-alpha.3",
49
+ "@toa.io/extensions.storages": "1.0.0-alpha.3",
50
50
  "@types/bcryptjs": "2.4.3",
51
51
  "@types/cors": "2.8.13",
52
52
  "@types/express": "4.17.17",
53
53
  "@types/negotiator": "0.6.1"
54
54
  },
55
- "gitHead": "7688e6e980a65c82ac2e459be4e355eebf406cd0"
55
+ "gitHead": "e36ac7871fc14d15863aaf8f9bbdeace8bdfa9f0"
56
56
  }
@@ -0,0 +1,12 @@
1
+ definitions:
2
+ unit:
3
+ type: object
4
+ patternProperties:
5
+ ^[a-zA-Z0-9_]+$:
6
+ type: string
7
+
8
+ oneOf:
9
+ - $ref: '#/definitions/unit'
10
+ - type: array
11
+ items:
12
+ $ref: '#/definitions/unit'
@@ -1,9 +1,9 @@
1
1
  import { Readable } from 'stream'
2
2
  import { NotFound } from '../../HTTP'
3
3
  import * as schemas from './schemas'
4
- import { Workflow } from './workflow'
4
+ import { Workflow } from './workflows'
5
5
  import type { Parameter } from '../../RTD'
6
- import type { Unit } from './workflow'
6
+ import type { Unit } from './workflows'
7
7
  import type { Maybe } from '@toa.io/types'
8
8
  import type { Component } from '@toa.io/core'
9
9
  import type { Output } from '../../io'
@@ -5,6 +5,7 @@ import { Fetch } from './Fetch'
5
5
  import { List } from './List'
6
6
  import { Delete } from './Delete'
7
7
  import { Permute } from './Permute'
8
+ import { WorkflowDirective } from './Workflow'
8
9
  import type { Output } from '../../io'
9
10
  import type { Component } from '@toa.io/core'
10
11
  import type { Remotes } from '../../Remotes'
@@ -63,7 +64,8 @@ const DIRECTIVES: Record<string, Constructor> = {
63
64
  fetch: Fetch,
64
65
  list: List,
65
66
  delete: Delete,
66
- permute: Permute
67
+ permute: Permute,
68
+ workflow: WorkflowDirective
67
69
  }
68
70
 
69
71
  type Constructor = new (value: any, discovery: Promise<Component>, remotes: Remotes) => Directive
@@ -1,10 +1,12 @@
1
+ import { PassThrough } from 'node:stream'
1
2
  import { match } from 'matchacho'
2
3
  import { BadRequest, UnsupportedMediaType } from '../../HTTP'
3
4
  import { cors } from '../cors'
4
5
  import * as schemas from './schemas'
5
- import { Workflow } from './workflow'
6
+ import { Workflow } from './workflows'
7
+ import type { Readable } from 'stream'
6
8
  import type { Parameter } from '../../RTD'
7
- import type { Unit } from './workflow'
9
+ import type { Unit } from './workflows'
8
10
  import type { Entry } from '@toa.io/extensions.storages'
9
11
  import type { Remotes } from '../../Remotes'
10
12
  import type { ErrorType } from 'error-value'
@@ -60,11 +62,23 @@ export class Store implements Directive {
60
62
  private reply (request: Input, storage: string, entry: Entry, parameters: Parameter[]): Output {
61
63
  const body = this.workflow === undefined
62
64
  ? entry
63
- : this.workflow.execute(request, storage, entry, parameters)
65
+ : this.execute(request, storage, entry, parameters)
64
66
 
65
67
  return { body }
66
68
  }
67
69
 
70
+ // eslint-disable-next-line max-params
71
+ private execute
72
+ (request: Input, storage: string, entry: Entry, parameters: Parameter[]): Readable {
73
+ const stream = new PassThrough({ objectMode: true })
74
+
75
+ stream.push(entry)
76
+
77
+ this.workflow!.execute(request, storage, entry, parameters).pipe(stream)
78
+
79
+ return stream
80
+ }
81
+
68
82
  private throw (error: ErrorType): never {
69
83
  throw match(error.code,
70
84
  'NOT_ACCEPTABLE', () => new UnsupportedMediaType(),
@@ -0,0 +1,41 @@
1
+ import { NotFound } from '../../HTTP'
2
+ import * as schemas from './schemas'
3
+ import { Workflow } from './workflows'
4
+ import type { Unit } from './workflows'
5
+ import type { Directive, Input } from './types'
6
+ import type { Component } from '@toa.io/core'
7
+ import type { Output } from '../../io'
8
+ import type { Remotes } from '../../Remotes'
9
+ import type { Maybe } from '@toa.io/types'
10
+ import type { Entry } from '@toa.io/extensions.storages'
11
+ import type { Parameter } from '../../RTD'
12
+
13
+ export class WorkflowDirective implements Directive {
14
+ public readonly targeted = true
15
+
16
+ private readonly workflow: Workflow
17
+ private readonly discovery: Promise<Component>
18
+ private storage: Component | null = null
19
+
20
+ public constructor (units: Unit[] | Unit, discovery: Promise<Component>, remotes: Remotes) {
21
+ schemas.workflow.validate(units)
22
+
23
+ this.workflow = new Workflow(units, remotes)
24
+ this.discovery = discovery
25
+ }
26
+
27
+ public async apply (storage: string, request: Input, parameters: Parameter[]): Promise<Output> {
28
+ this.storage ??= await this.discovery
29
+
30
+ const entry = await this.storage.invoke<Maybe<Entry>>('get',
31
+ { input: { storage, path: request.url } })
32
+
33
+ if (entry instanceof Error)
34
+ throw new NotFound()
35
+
36
+ return {
37
+ status: 202,
38
+ body: this.workflow.execute(request, storage, entry, parameters)
39
+ }
40
+ }
41
+ }
@@ -0,0 +1,21 @@
1
+ import * as schemas from './schemas'
2
+
3
+ describe('workflow', () => {
4
+ const ok = [
5
+ { echo: 'hello world' },
6
+ [{ echo: 'hello world' }, { ok: 'ok' }]
7
+ ]
8
+
9
+ const oh = [
10
+ { echo: [] },
11
+ { echo: 'hello world', ok: { not: 'ok' } }
12
+ ]
13
+
14
+ it.each(ok)('should be valid', (workflow) => {
15
+ expect(() => schemas.workflow.validate(workflow)).not.toThrow()
16
+ })
17
+
18
+ it.each(oh)('should not be valid', (workflow) => {
19
+ expect(() => schemas.workflow.validate(workflow)).toThrow()
20
+ })
21
+ })
@@ -5,6 +5,7 @@ import type { Permissions as ListPermissions } from './List'
5
5
  import type { Options as StoreOptions } from './Store'
6
6
  import type { Options as DeleteOptions } from './Delete'
7
7
  import type { Schema } from '@toa.io/schemas'
8
+ import type { Unit } from './workflows'
8
9
 
9
10
  const path = resolve(__dirname, '../../../schemas/octets')
10
11
  const namespace = schemas.namespace(path)
@@ -15,3 +16,4 @@ export const fetch: Schema<FetchPermissions | null> = namespace.schema('fetch')
15
16
  export const remove: Schema<DeleteOptions | null> = namespace.schema('delete')
16
17
  export const list: Schema<ListPermissions | null> = namespace.schema('list')
17
18
  export const permute: Schema<null> = namespace.schema('permute')
19
+ export const workflow: Schema<Unit[] | Unit> = namespace.schema('workflow')
@@ -27,8 +27,6 @@ export class Execution extends Readable {
27
27
  }
28
28
 
29
29
  private async run (): Promise<void> {
30
- this.push(this.context.entry)
31
-
32
30
  for (const unit of this.units) {
33
31
  await this.execute(unit)
34
32
 
@@ -1,5 +1,5 @@
1
1
  import type { Parameter } from '../../RTD';
2
- import type { Unit } from './workflow';
2
+ import type { Unit } from './workflows';
3
3
  import type { Component } from '@toa.io/core';
4
4
  import type { Output } from '../../io';
5
5
  import type { Directive, Input } from './types';
@@ -27,7 +27,7 @@ exports.Delete = void 0;
27
27
  const stream_1 = require("stream");
28
28
  const HTTP_1 = require("../../HTTP");
29
29
  const schemas = __importStar(require("./schemas"));
30
- const workflow_1 = require("./workflow");
30
+ const workflows_1 = require("./workflows");
31
31
  class Delete {
32
32
  targeted = true;
33
33
  workflow;
@@ -36,7 +36,7 @@ class Delete {
36
36
  constructor(options, discovery, remotes) {
37
37
  schemas.remove.validate(options);
38
38
  if (options?.workflow !== undefined)
39
- this.workflow = new workflow_1.Workflow(options.workflow, remotes);
39
+ this.workflow = new workflows_1.Workflow(options.workflow, remotes);
40
40
  this.discovery = discovery;
41
41
  }
42
42
  async apply(storage, request, parameters) {