@ossy/platform 3.26.0 → 3.27.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/package.json +8 -7
- package/src/integration.service.js +135 -14
- package/src/integration.service.spec.js +41 -0
- package/src/list-integrations.action.js +5 -0
- package/src/list-integrations.task.js +13 -0
- package/src/server.js +3 -4
- package/src/tasks/task-service.js +48 -0
- package/src/tasks/task-service.spec.js +56 -0
- package/src/test/flow-runner.js +15 -0
- package/src/workspace-secrets.js +336 -0
- package/src/workspace-secrets.spec.js +140 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ossy/platform",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.27.0",
|
|
4
4
|
"description": "Ossy application server runtime",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
"license": "MIT",
|
|
43
43
|
"dependencies": {
|
|
44
44
|
"@aws-sdk/client-s3": "^3.1057.0",
|
|
45
|
+
"@aws-sdk/client-secrets-manager": "^3.1134.0",
|
|
45
46
|
"@aws-sdk/client-ses": "^3.0.0",
|
|
46
47
|
"@aws-sdk/client-sqs": "^3.1103.0",
|
|
47
48
|
"@aws-sdk/s3-request-presigner": "^3.1057.0",
|
|
@@ -49,18 +50,18 @@
|
|
|
49
50
|
"@aws-sdk/util-format-url": "^3.972.17",
|
|
50
51
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
51
52
|
"@ossy/config": "^3.0.9",
|
|
52
|
-
"@ossy/email": "^3.
|
|
53
|
+
"@ossy/email": "^3.27.0",
|
|
53
54
|
"@ossy/event-store": "^3.26.0",
|
|
54
55
|
"@ossy/locale": "^3.4.0",
|
|
55
|
-
"@ossy/manifest": "^3.
|
|
56
|
+
"@ossy/manifest": "^3.27.0",
|
|
56
57
|
"@ossy/observability": "^3.24.1",
|
|
57
58
|
"@ossy/policies": "^3.24.1",
|
|
58
59
|
"@ossy/router": "^3.25.0",
|
|
59
|
-
"@ossy/schema": "^3.
|
|
60
|
+
"@ossy/schema": "^3.27.0",
|
|
60
61
|
"@ossy/sdk": "^3.26.0",
|
|
61
62
|
"@ossy/tokens": "^3.24.1",
|
|
62
|
-
"@ossy/users": "^3.
|
|
63
|
-
"@ossy/workspaces": "^3.
|
|
63
|
+
"@ossy/users": "^3.27.0",
|
|
64
|
+
"@ossy/workspaces": "^3.27.0",
|
|
64
65
|
"cookie-parser": "^1.4.7",
|
|
65
66
|
"dotenv": ">=16.0.0 <18.0.0",
|
|
66
67
|
"express": ">=5.0.0 <6.0.0",
|
|
@@ -81,5 +82,5 @@
|
|
|
81
82
|
"Dockerfile",
|
|
82
83
|
"docker-healthcheck.js"
|
|
83
84
|
],
|
|
84
|
-
"gitHead": "
|
|
85
|
+
"gitHead": "f72042c42723468c010d0d9744ad69257143d0b9"
|
|
85
86
|
}
|
|
@@ -1,24 +1,38 @@
|
|
|
1
1
|
import { createLogger, registerBackend, clearBackends } from '@ossy/observability'
|
|
2
|
+
import { getWorkspaceSecret } from './workspace-secrets.js'
|
|
2
3
|
|
|
3
4
|
const log = createLogger('platform')
|
|
4
5
|
|
|
5
6
|
/** @type {Record<string, unknown>} */
|
|
6
7
|
const _clients = {}
|
|
7
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Workspace integrations declare `secrets` (key names) and `connect(secret)`.
|
|
11
|
+
* The secret payload stays out of platform env and out of this map.
|
|
12
|
+
*
|
|
13
|
+
* @type {Map<string, { secrets: string[], connect: (secret: { type: string, values: Record<string, string> }) => unknown }>}
|
|
14
|
+
*/
|
|
15
|
+
const _workspaceModules = new Map()
|
|
16
|
+
|
|
17
|
+
/** @type {Map<string, { expiresAt: number, client: unknown }>} */
|
|
18
|
+
const _workspaceClientCache = new Map()
|
|
19
|
+
|
|
20
|
+
const WORKSPACE_CLIENT_TTL_MS = 60_000
|
|
21
|
+
|
|
8
22
|
/**
|
|
9
23
|
* Manages third-party integration clients declared via `*.integration.js` files.
|
|
10
24
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
25
|
+
* Platform integrations are loaded once at server startup from env vars.
|
|
26
|
+
* Workspace integrations resolve with `get(id, { workspaceId })` from the
|
|
27
|
+
* workspace secret store. A missing secret does not stop the server.
|
|
14
28
|
*/
|
|
15
29
|
export const IntegrationService = {
|
|
16
30
|
/**
|
|
17
|
-
* Connect
|
|
18
|
-
*
|
|
19
|
-
*
|
|
31
|
+
* Connect platform integration modules and register workspace ones.
|
|
32
|
+
* A module with `secrets` is workspace-scoped and is not connected at boot.
|
|
33
|
+
* A module with `credentials` is connected from `env` at boot.
|
|
20
34
|
*
|
|
21
|
-
* @param {Array<{ id: string, credentials
|
|
35
|
+
* @param {Array<{ id: string, credentials?: string[], secrets?: string[], connect: Function }>} modules
|
|
22
36
|
* @param {NodeJS.ProcessEnv} env Usually `process.env`.
|
|
23
37
|
*/
|
|
24
38
|
async load (modules, env) {
|
|
@@ -26,13 +40,23 @@ export const IntegrationService = {
|
|
|
26
40
|
const loggerBackends = []
|
|
27
41
|
|
|
28
42
|
for (const mod of modules) {
|
|
29
|
-
const { id,
|
|
43
|
+
const { id, connect } = mod
|
|
44
|
+
const secrets = Array.isArray(mod.secrets)
|
|
45
|
+
? mod.secrets.filter((key) => typeof key === 'string' && key.trim())
|
|
46
|
+
: []
|
|
47
|
+
const credentials = Array.isArray(mod.credentials) ? mod.credentials : null
|
|
30
48
|
|
|
31
49
|
if (typeof id !== 'string' || id.trim() === '') {
|
|
32
50
|
log.warn('[IntegrationService] Skipping integration with missing or invalid "id"')
|
|
33
51
|
continue
|
|
34
52
|
}
|
|
35
53
|
|
|
54
|
+
if (secrets.length > 0 && typeof connect === 'function') {
|
|
55
|
+
_workspaceModules.set(id, { secrets, connect })
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (!credentials) continue
|
|
59
|
+
|
|
36
60
|
// An env var set to an empty string is treated as "present" — only
|
|
37
61
|
// vars that are completely absent from the environment are considered missing.
|
|
38
62
|
const missing = credentials.filter((key) => !(key in env))
|
|
@@ -72,14 +96,111 @@ export const IntegrationService = {
|
|
|
72
96
|
},
|
|
73
97
|
|
|
74
98
|
/**
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
99
|
+
* Drop a cached workspace client after the secret is written or removed.
|
|
100
|
+
*
|
|
101
|
+
* @param {{ workspaceId?: string, integrationId?: string }} [filter]
|
|
102
|
+
*/
|
|
103
|
+
invalidateWorkspaceClient (filter = {}) {
|
|
104
|
+
const { workspaceId, integrationId } = filter
|
|
105
|
+
if (!workspaceId && !integrationId) {
|
|
106
|
+
_workspaceClientCache.clear()
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
for (const key of _workspaceClientCache.keys()) {
|
|
110
|
+
const [ws, cachedId] = key.split('::')
|
|
111
|
+
if (workspaceId && ws !== workspaceId) continue
|
|
112
|
+
if (integrationId && cachedId !== integrationId) continue
|
|
113
|
+
_workspaceClientCache.delete(key)
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Retrieve an integration client.
|
|
119
|
+
*
|
|
120
|
+
* - `get(id)` — sync; boot-time platform client, or `null` if not loaded.
|
|
121
|
+
* - `get(id, { workspaceId })` — async; client from that workspace's secret.
|
|
78
122
|
*
|
|
79
123
|
* @param {string} id
|
|
80
|
-
* @
|
|
124
|
+
* @param {{ workspaceId?: string }} [options]
|
|
125
|
+
* @returns {unknown | null | Promise<unknown>}
|
|
81
126
|
*/
|
|
82
|
-
get (id) {
|
|
83
|
-
|
|
127
|
+
get (id, options) {
|
|
128
|
+
if (options === undefined) {
|
|
129
|
+
return _clients[id] ?? null
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (!options || typeof options !== 'object') {
|
|
133
|
+
throw Object.assign(new Error('Integration options must be an object with workspaceId'), {
|
|
134
|
+
status: 400,
|
|
135
|
+
code: 'INVALID_INTEGRATION_OPTIONS',
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const workspaceId = options.workspaceId
|
|
140
|
+
if (typeof workspaceId !== 'string' || !workspaceId.trim()) {
|
|
141
|
+
throw Object.assign(
|
|
142
|
+
new Error('workspaceId is required for workspace integrations.get(id, { workspaceId })'),
|
|
143
|
+
{ status: 400, code: 'WORKSPACE_ID_REQUIRED' },
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return getWorkspaceClient(id, workspaceId.trim())
|
|
84
148
|
},
|
|
85
149
|
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* @param {string} id
|
|
153
|
+
* @param {string} workspaceId
|
|
154
|
+
*/
|
|
155
|
+
async function getWorkspaceClient (id, workspaceId) {
|
|
156
|
+
const mod = _workspaceModules.get(id)
|
|
157
|
+
if (!mod) {
|
|
158
|
+
throw Object.assign(new Error(`Workspace integration not registered: ${id}`), {
|
|
159
|
+
status: 404,
|
|
160
|
+
code: 'INTEGRATION_NOT_FOUND',
|
|
161
|
+
})
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const cacheKey = `${workspaceId}::${id}`
|
|
165
|
+
const cached = _workspaceClientCache.get(cacheKey)
|
|
166
|
+
if (cached && cached.expiresAt > Date.now()) {
|
|
167
|
+
return cached.client
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
let secret
|
|
171
|
+
try {
|
|
172
|
+
secret = await getWorkspaceSecret({ workspaceId, integrationId: id })
|
|
173
|
+
} catch (err) {
|
|
174
|
+
if (err?.code === 'INTEGRATION_SECRET_NOT_FOUND') {
|
|
175
|
+
throw Object.assign(new Error(`Integration "${id}" is not connected for this workspace`), {
|
|
176
|
+
status: 409,
|
|
177
|
+
code: 'INTEGRATION_NOT_CONNECTED',
|
|
178
|
+
})
|
|
179
|
+
}
|
|
180
|
+
throw err
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const missing = mod.secrets.filter((key) => {
|
|
184
|
+
const value = secret?.values?.[key]
|
|
185
|
+
return typeof value !== 'string' || !value.trim()
|
|
186
|
+
})
|
|
187
|
+
if (missing.length > 0) {
|
|
188
|
+
throw Object.assign(
|
|
189
|
+
new Error(`Integration "${id}" is missing secret keys: ${missing.join(', ')}`),
|
|
190
|
+
{ status: 409, code: 'INTEGRATION_NOT_CONNECTED' },
|
|
191
|
+
)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const client = await mod.connect(secret)
|
|
195
|
+
_workspaceClientCache.set(cacheKey, {
|
|
196
|
+
expiresAt: Date.now() + WORKSPACE_CLIENT_TTL_MS,
|
|
197
|
+
client,
|
|
198
|
+
})
|
|
199
|
+
return client
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export {
|
|
203
|
+
deleteWorkspaceSecret,
|
|
204
|
+
listWorkspaceIntegrationIds,
|
|
205
|
+
putWorkspaceSecret,
|
|
206
|
+
} from './workspace-secrets.js'
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it } from '@jest/globals'
|
|
2
|
+
import { IntegrationService } from './integration.service.js'
|
|
3
|
+
import {
|
|
4
|
+
_resetWorkspaceSecretsMemoryForTests,
|
|
5
|
+
putWorkspaceSecret,
|
|
6
|
+
} from './workspace-secrets.js'
|
|
7
|
+
|
|
8
|
+
describe('workspace integrations.get', () => {
|
|
9
|
+
beforeEach(async () => {
|
|
10
|
+
_resetWorkspaceSecretsMemoryForTests()
|
|
11
|
+
process.env.WORKSPACE_SECRETS_BACKEND = 'memory'
|
|
12
|
+
IntegrationService.invalidateWorkspaceClient()
|
|
13
|
+
await IntegrationService.load([{
|
|
14
|
+
id: 'linkedin',
|
|
15
|
+
secrets: ['ACCESS_TOKEN'],
|
|
16
|
+
connect (secret) {
|
|
17
|
+
return { token: secret.values.ACCESS_TOKEN }
|
|
18
|
+
},
|
|
19
|
+
}], process.env)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('does not connect a workspace integration from platform env', () => {
|
|
23
|
+
expect(IntegrationService.get('linkedin')).toBeNull()
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('resolves the client from that workspace secret only', async () => {
|
|
27
|
+
await expect(IntegrationService.get('linkedin', { workspaceId: 'ws-1' }))
|
|
28
|
+
.rejects.toMatchObject({ code: 'INTEGRATION_NOT_CONNECTED' })
|
|
29
|
+
|
|
30
|
+
await putWorkspaceSecret({
|
|
31
|
+
workspaceId: 'ws-1',
|
|
32
|
+
integrationId: 'linkedin',
|
|
33
|
+
payload: { values: { ACCESS_TOKEN: 'tok-1' } },
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
await expect(IntegrationService.get('linkedin', { workspaceId: 'ws-1' }))
|
|
37
|
+
.resolves.toEqual({ token: 'tok-1' })
|
|
38
|
+
await expect(IntegrationService.get('linkedin', { workspaceId: 'ws-2' }))
|
|
39
|
+
.rejects.toMatchObject({ code: 'INTEGRATION_NOT_CONNECTED' })
|
|
40
|
+
})
|
|
41
|
+
})
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { listWorkspaceIntegrationIds } from './workspace-secrets.js'
|
|
2
|
+
|
|
3
|
+
export const metadata = {
|
|
4
|
+
id: '@ossy/platform/tasks/list-integrations',
|
|
5
|
+
audit: false,
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export async function run ({ req, payload }) {
|
|
9
|
+
const workspaceId = payload?.workspaceId || req?.workspaceId
|
|
10
|
+
if (!workspaceId) return { integrations: [] }
|
|
11
|
+
const integrations = await listWorkspaceIntegrationIds(workspaceId)
|
|
12
|
+
return { integrations }
|
|
13
|
+
}
|
package/src/server.js
CHANGED
|
@@ -221,10 +221,9 @@ export async function startServer (options = {}) {
|
|
|
221
221
|
registerSchema(template)
|
|
222
222
|
}
|
|
223
223
|
|
|
224
|
-
// Integration loading —
|
|
225
|
-
//
|
|
226
|
-
//
|
|
227
|
-
// is skipped with a warning so the server still starts.
|
|
224
|
+
// Integration loading — platform modules connect from env at boot.
|
|
225
|
+
// Workspace modules declare `secrets` and connect later via
|
|
226
|
+
// integrations.get(id, { workspaceId }). Missing credentials are non-fatal.
|
|
228
227
|
const integrationModules = []
|
|
229
228
|
for (const intEntry of manifest.integrations ?? []) {
|
|
230
229
|
try {
|
|
@@ -36,6 +36,32 @@ function buildTaskContext (baseContext, extras = {}) {
|
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
const NOT_CONNECTED = new Set([
|
|
40
|
+
'INTEGRATION_NOT_CONNECTED',
|
|
41
|
+
'INTEGRATION_NOT_FOUND',
|
|
42
|
+
'INTEGRATION_SECRET_NOT_FOUND',
|
|
43
|
+
])
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A task with `metadata.integration` runs only after that workspace has stored the secret.
|
|
47
|
+
*
|
|
48
|
+
* @param {object} metadata
|
|
49
|
+
* @param {string | null | undefined} workspaceId
|
|
50
|
+
* @returns {Promise<boolean>}
|
|
51
|
+
*/
|
|
52
|
+
async function workspaceIntegrationReady (metadata, workspaceId) {
|
|
53
|
+
const integrationId = typeof metadata?.integration === 'string' ? metadata.integration.trim() : ''
|
|
54
|
+
if (!integrationId) return true
|
|
55
|
+
if (!workspaceId) return false
|
|
56
|
+
try {
|
|
57
|
+
await IntegrationService.get(integrationId, { workspaceId })
|
|
58
|
+
return true
|
|
59
|
+
} catch (err) {
|
|
60
|
+
if (NOT_CONNECTED.has(err?.code)) return false
|
|
61
|
+
throw err
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
39
65
|
/**
|
|
40
66
|
* In-process SDK bound to this request. Dynamic import avoids a cycle through
|
|
41
67
|
* ActionService. A missing req loads the bot; callers that have a user or a
|
|
@@ -319,6 +345,14 @@ export class TaskService {
|
|
|
319
345
|
// tasks can always use integrations.get('email') (including local delivery).
|
|
320
346
|
// Nested invokes keep this sdk, so the first actor (the caller, or an sdk
|
|
321
347
|
// the entry point already bound) is the actor for the whole chain.
|
|
348
|
+
const workspaceId = context.payload?.workspaceId ?? context.req?.workspaceId ?? null
|
|
349
|
+
if (!(await workspaceIntegrationReady(task.metadata, workspaceId))) {
|
|
350
|
+
throw Object.assign(
|
|
351
|
+
new Error(`Task "${id}" requires integration "${task.metadata.integration}"`),
|
|
352
|
+
{ status: 409, code: 'INTEGRATION_NOT_CONNECTED' },
|
|
353
|
+
)
|
|
354
|
+
}
|
|
355
|
+
|
|
322
356
|
const invokeContext = {
|
|
323
357
|
...context,
|
|
324
358
|
sdk: context.sdk ?? await sdkBoundTo(context.req),
|
|
@@ -396,6 +430,13 @@ export class TaskService {
|
|
|
396
430
|
|
|
397
431
|
// Multiple startServer processes share Mongo changestreams (#982) — claim before run.
|
|
398
432
|
void (async () => {
|
|
433
|
+
if (!(await workspaceIntegrationReady(metadata, workspaceId))) {
|
|
434
|
+
_serviceLog.debug(
|
|
435
|
+
`Skipping task "${_taskId}" — integration "${metadata.integration}" is not connected`,
|
|
436
|
+
)
|
|
437
|
+
return
|
|
438
|
+
}
|
|
439
|
+
|
|
399
440
|
const won = await tryClaimDispatch(claimKey)
|
|
400
441
|
if (!won) {
|
|
401
442
|
_serviceLog.debug(`Skipping task "${_taskId}" — dispatch claim lost`)
|
|
@@ -601,6 +642,13 @@ export class TaskService {
|
|
|
601
642
|
return
|
|
602
643
|
}
|
|
603
644
|
|
|
645
|
+
if (!(await workspaceIntegrationReady(metadata, job.workspaceId))) {
|
|
646
|
+
_serviceLog.debug(
|
|
647
|
+
`Skipping schedule for task "${metadata.id}" — integration "${metadata.integration}" is not connected`,
|
|
648
|
+
)
|
|
649
|
+
return
|
|
650
|
+
}
|
|
651
|
+
|
|
604
652
|
_serviceLog.debug(
|
|
605
653
|
`Schedule fired for task "${metadata.id}"`
|
|
606
654
|
+ (job.workspaceId ? ` (workspace ${job.workspaceId})` : ''),
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it } from '@jest/globals'
|
|
2
2
|
import { jobsForGlobalSchedule, TaskService } from './task-service.js'
|
|
3
|
+
import { IntegrationService, putWorkspaceSecret } from '../integration.service.js'
|
|
4
|
+
import { _resetWorkspaceSecretsMemoryForTests } from '../workspace-secrets.js'
|
|
3
5
|
import { TaskRun } from '../audit/task-run.aggregate.js'
|
|
4
6
|
import { ActionInvocation } from '../audit/action-invocation.aggregate.js'
|
|
5
7
|
import { TaskRunListProjection } from '../audit/task-run-list.aggregate.js'
|
|
@@ -391,6 +393,60 @@ describe('ActionInvocation entity fold', () => {
|
|
|
391
393
|
})
|
|
392
394
|
})
|
|
393
395
|
|
|
396
|
+
describe('workspace integration gate', () => {
|
|
397
|
+
it('does not run a task until that workspace has connected', async () => {
|
|
398
|
+
_resetWorkspaceSecretsMemoryForTests()
|
|
399
|
+
process.env.WORKSPACE_SECRETS_BACKEND = 'memory'
|
|
400
|
+
IntegrationService.invalidateWorkspaceClient()
|
|
401
|
+
await IntegrationService.load([{
|
|
402
|
+
id: 'linkedin',
|
|
403
|
+
secrets: ['ACCESS_TOKEN'],
|
|
404
|
+
connect (secret) {
|
|
405
|
+
return { token: secret.values.ACCESS_TOKEN }
|
|
406
|
+
},
|
|
407
|
+
}], process.env)
|
|
408
|
+
|
|
409
|
+
const previousTasks = TaskService._tasks
|
|
410
|
+
const previousById = TaskService._tasksById
|
|
411
|
+
TaskService._tasks = []
|
|
412
|
+
TaskService._tasksById = new Map()
|
|
413
|
+
const calls = []
|
|
414
|
+
TaskService.registerTask({
|
|
415
|
+
metadata: {
|
|
416
|
+
id: '@ossy/linkedin/tasks/publish',
|
|
417
|
+
integration: 'linkedin',
|
|
418
|
+
audit: false,
|
|
419
|
+
},
|
|
420
|
+
run (context) {
|
|
421
|
+
calls.push(context.payload?.workspaceId)
|
|
422
|
+
return { ok: true }
|
|
423
|
+
},
|
|
424
|
+
})
|
|
425
|
+
|
|
426
|
+
try {
|
|
427
|
+
await expect(TaskService.invoke('@ossy/linkedin/tasks/publish', {
|
|
428
|
+
sdk: {},
|
|
429
|
+
payload: { workspaceId: 'ws-1' },
|
|
430
|
+
})).rejects.toMatchObject({ code: 'INTEGRATION_NOT_CONNECTED' })
|
|
431
|
+
expect(calls).toEqual([])
|
|
432
|
+
|
|
433
|
+
await putWorkspaceSecret({
|
|
434
|
+
workspaceId: 'ws-1',
|
|
435
|
+
integrationId: 'linkedin',
|
|
436
|
+
payload: { values: { ACCESS_TOKEN: 'tok' } },
|
|
437
|
+
})
|
|
438
|
+
await expect(TaskService.invoke('@ossy/linkedin/tasks/publish', {
|
|
439
|
+
sdk: {},
|
|
440
|
+
payload: { workspaceId: 'ws-1' },
|
|
441
|
+
})).resolves.toEqual({ ok: true })
|
|
442
|
+
expect(calls).toEqual(['ws-1'])
|
|
443
|
+
} finally {
|
|
444
|
+
TaskService._tasks = previousTasks
|
|
445
|
+
TaskService._tasksById = previousById
|
|
446
|
+
}
|
|
447
|
+
})
|
|
448
|
+
})
|
|
449
|
+
|
|
394
450
|
describe('audit helpers', () => {
|
|
395
451
|
it('detects channel from request shape', () => {
|
|
396
452
|
expect(detectChannel({ originalUrl: '/mcp', signedCookies: {} })).toBe('mcp')
|
package/src/test/flow-runner.js
CHANGED
|
@@ -204,6 +204,16 @@ export async function runFlow (flow, options = {}) {
|
|
|
204
204
|
const context = contextIn ?? { fields: {} }
|
|
205
205
|
const steps = flow.steps ?? []
|
|
206
206
|
|
|
207
|
+
// SSR paints controls before React attaches listeners. A submit in that
|
|
208
|
+
// window is a native GET, and a button click never opens its dialog.
|
|
209
|
+
const waitForHydration = async (timeout = 20000) => {
|
|
210
|
+
if (!page) return
|
|
211
|
+
await page.locator('html[data-hydrated="true"]').waitFor({
|
|
212
|
+
state: 'attached',
|
|
213
|
+
timeout,
|
|
214
|
+
})
|
|
215
|
+
}
|
|
216
|
+
|
|
207
217
|
for (const step of steps) {
|
|
208
218
|
if (!step || typeof step !== 'object') continue
|
|
209
219
|
|
|
@@ -231,6 +241,7 @@ export async function runFlow (flow, options = {}) {
|
|
|
231
241
|
}
|
|
232
242
|
|
|
233
243
|
if (step.click != null) {
|
|
244
|
+
await waitForHydration(step.timeout ?? 20000)
|
|
234
245
|
if (!page) throw new Error('click step requires a Playwright page')
|
|
235
246
|
const { selector, position } = resolveClickTarget(step.click)
|
|
236
247
|
const resolvedSelector = interpolateContextString(selector, context, {
|
|
@@ -306,6 +317,7 @@ export async function runFlow (flow, options = {}) {
|
|
|
306
317
|
|
|
307
318
|
if (step.form) {
|
|
308
319
|
if (!page) throw new Error('form step requires a Playwright page')
|
|
320
|
+
await waitForHydration(step.timeout ?? 20000)
|
|
309
321
|
const formMeta = step.form
|
|
310
322
|
const schemaId = formMeta?.schemaId
|
|
311
323
|
if (!formMeta || typeof formMeta !== 'object' || typeof formMeta.id !== 'string' || typeof schemaId !== 'string') {
|
|
@@ -427,6 +439,7 @@ export async function runFlow (flow, options = {}) {
|
|
|
427
439
|
|
|
428
440
|
if (step.action != null) {
|
|
429
441
|
if (!page) throw new Error('action step requires a Playwright page')
|
|
442
|
+
await waitForHydration(step.timeout ?? 20000)
|
|
430
443
|
const actionStep = typeof step.action === 'object' && step.action != null
|
|
431
444
|
? resolveContextObject(step.action, context)
|
|
432
445
|
: step.action
|
|
@@ -546,6 +559,7 @@ export async function runFlow (flow, options = {}) {
|
|
|
546
559
|
|
|
547
560
|
if (step.download != null) {
|
|
548
561
|
if (!page) throw new Error('download step requires a Playwright page')
|
|
562
|
+
await waitForHydration(step.timeout ?? 20000)
|
|
549
563
|
const target = resolveDownloadTarget(step.download)
|
|
550
564
|
const timeout = step.timeout ?? target.timeout
|
|
551
565
|
const locator = target.actionId
|
|
@@ -585,6 +599,7 @@ export async function runFlow (flow, options = {}) {
|
|
|
585
599
|
|
|
586
600
|
if (step.inputs != null) {
|
|
587
601
|
if (!page) throw new Error('inputs step requires a Playwright page')
|
|
602
|
+
await waitForHydration(step.timeout ?? 20000)
|
|
588
603
|
const inputsTimeout = step.timeout ?? 15000
|
|
589
604
|
for (const [selector, valueSpec] of Object.entries(step.inputs)) {
|
|
590
605
|
const value = resolveContextValue(valueSpec, context)
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import { createLogger } from '@ossy/observability'
|
|
2
|
+
|
|
3
|
+
const log = createLogger('platform')
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Workspace integration secrets (SPEC #556).
|
|
7
|
+
* Payload: { type, values: Record<string, string> }
|
|
8
|
+
*
|
|
9
|
+
* Backend:
|
|
10
|
+
* - `memory` (default for local/tests) — in-process Map
|
|
11
|
+
* - `aws` — Secrets Manager (`WORKSPACE_SECRETS_BACKEND=aws`)
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** @type {Map<string, string>} */
|
|
15
|
+
const memoryStore = new Map()
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {string} workspaceId
|
|
19
|
+
* @param {string} integrationId
|
|
20
|
+
* @returns {string}
|
|
21
|
+
*/
|
|
22
|
+
export function workspaceSecretName (workspaceId, integrationId) {
|
|
23
|
+
return `ossybot/workspaces/${workspaceId}/integrations/${integrationId}`
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Name portion of a secret id. Accepts a secret name, a `memory:` name,
|
|
28
|
+
* or a Secrets Manager ARN (`…:secret:{name}-{suffix}`).
|
|
29
|
+
*
|
|
30
|
+
* @param {unknown} secretId
|
|
31
|
+
* @returns {string}
|
|
32
|
+
*/
|
|
33
|
+
export function secretNameFromId (secretId) {
|
|
34
|
+
if (typeof secretId !== 'string') return ''
|
|
35
|
+
let id = secretId.trim()
|
|
36
|
+
if (!id) return ''
|
|
37
|
+
if (id.startsWith('memory:')) id = id.slice('memory:'.length)
|
|
38
|
+
const marker = ':secret:'
|
|
39
|
+
const at = id.indexOf(marker)
|
|
40
|
+
if (id.startsWith('arn:') && at !== -1) id = id.slice(at + marker.length)
|
|
41
|
+
return id
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function crossTenantSecretError () {
|
|
45
|
+
return Object.assign(new Error('Secret is outside this workspace'), {
|
|
46
|
+
status: 403,
|
|
47
|
+
code: 'CROSS_TENANT_SECRET',
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Level-1 tenant check (#849). The id must be this workspace's secret path.
|
|
53
|
+
* A caller-supplied ARN is not trusted on its own.
|
|
54
|
+
*
|
|
55
|
+
* @param {unknown} secretId
|
|
56
|
+
* @param {unknown} workspaceId
|
|
57
|
+
*/
|
|
58
|
+
export function assertWorkspaceSecretId (secretId, workspaceId) {
|
|
59
|
+
if (
|
|
60
|
+
typeof workspaceId !== 'string'
|
|
61
|
+
|| !workspaceId
|
|
62
|
+
|| workspaceId.includes('/')
|
|
63
|
+
|| workspaceId.includes('\\')
|
|
64
|
+
|| workspaceId.includes('..')
|
|
65
|
+
) {
|
|
66
|
+
throw crossTenantSecretError()
|
|
67
|
+
}
|
|
68
|
+
const prefix = `ossybot/workspaces/${workspaceId}/`
|
|
69
|
+
const name = secretNameFromId(secretId)
|
|
70
|
+
if (!name.startsWith(prefix)) throw crossTenantSecretError()
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* @returns {'memory' | 'aws'}
|
|
75
|
+
*/
|
|
76
|
+
export function workspaceSecretsBackend () {
|
|
77
|
+
const raw = typeof process.env.WORKSPACE_SECRETS_BACKEND === 'string'
|
|
78
|
+
? process.env.WORKSPACE_SECRETS_BACKEND.trim().toLowerCase()
|
|
79
|
+
: ''
|
|
80
|
+
if (raw === 'aws') return 'aws'
|
|
81
|
+
if (raw === 'memory') return 'memory'
|
|
82
|
+
// Local / test default — avoid requiring AWS for unit tests and app-test.
|
|
83
|
+
if (process.env.BUILD_ENVIRONMENT === 'local' || process.env.NODE_ENV === 'test') {
|
|
84
|
+
return 'memory'
|
|
85
|
+
}
|
|
86
|
+
return process.env.AWS_ACCESS_KEY_ID ? 'aws' : 'memory'
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* @param {unknown} payload
|
|
91
|
+
* @returns {{ type: string, values: Record<string, string> }}
|
|
92
|
+
*/
|
|
93
|
+
export function normalizeSecretPayload (payload) {
|
|
94
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
|
95
|
+
throw Object.assign(new Error('Secret payload must be an object'), {
|
|
96
|
+
status: 400,
|
|
97
|
+
code: 'INVALID_SECRET_PAYLOAD',
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
const type = typeof payload.type === 'string' && payload.type.trim()
|
|
101
|
+
? payload.type.trim()
|
|
102
|
+
: 'oauth2'
|
|
103
|
+
const rawValues = payload.values
|
|
104
|
+
if (!rawValues || typeof rawValues !== 'object' || Array.isArray(rawValues)) {
|
|
105
|
+
throw Object.assign(new Error('Secret payload.values must be an object'), {
|
|
106
|
+
status: 400,
|
|
107
|
+
code: 'INVALID_SECRET_VALUES',
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
/** @type {Record<string, string>} */
|
|
111
|
+
const values = {}
|
|
112
|
+
for (const [key, value] of Object.entries(rawValues)) {
|
|
113
|
+
if (typeof key !== 'string' || !key.trim()) continue
|
|
114
|
+
if (typeof value !== 'string' || !value.trim()) continue
|
|
115
|
+
values[key.trim()] = value
|
|
116
|
+
}
|
|
117
|
+
if (!Object.keys(values).length) {
|
|
118
|
+
throw Object.assign(new Error('Secret payload.values must include at least one string'), {
|
|
119
|
+
status: 400,
|
|
120
|
+
code: 'EMPTY_SECRET_VALUES',
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
return { type, values }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function awsClientConfig () {
|
|
127
|
+
const region = process.env.AWS_REGION
|
|
128
|
+
|| process.env.SES_REGION
|
|
129
|
+
|| 'eu-north-1'
|
|
130
|
+
/** @type {{ region: string, credentials?: { accessKeyId: string, secretAccessKey: string } }} */
|
|
131
|
+
const config = { region }
|
|
132
|
+
if (process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY) {
|
|
133
|
+
config.credentials = {
|
|
134
|
+
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
|
|
135
|
+
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return config
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function getAwsClient () {
|
|
142
|
+
const { SecretsManagerClient } = await import('@aws-sdk/client-secrets-manager')
|
|
143
|
+
return new SecretsManagerClient(awsClientConfig())
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* @param {{ workspaceId: string, integrationId: string, payload: unknown }} args
|
|
148
|
+
* @returns {Promise<{ secretName: string, secretArn: string, credentialKeys: string[] }>}
|
|
149
|
+
*/
|
|
150
|
+
export async function putWorkspaceSecret ({ workspaceId, integrationId, payload }) {
|
|
151
|
+
if (!workspaceId || !integrationId) {
|
|
152
|
+
throw Object.assign(new Error('workspaceId and integrationId are required'), { status: 400 })
|
|
153
|
+
}
|
|
154
|
+
const normalized = normalizeSecretPayload(payload)
|
|
155
|
+
const secretName = workspaceSecretName(workspaceId, integrationId)
|
|
156
|
+
assertWorkspaceSecretId(secretName, workspaceId)
|
|
157
|
+
const secretString = JSON.stringify(normalized)
|
|
158
|
+
const credentialKeys = Object.keys(normalized.values).sort()
|
|
159
|
+
const backend = workspaceSecretsBackend()
|
|
160
|
+
|
|
161
|
+
if (backend === 'memory') {
|
|
162
|
+
memoryStore.set(secretName, secretString)
|
|
163
|
+
return {
|
|
164
|
+
secretName,
|
|
165
|
+
secretArn: `memory:${secretName}`,
|
|
166
|
+
credentialKeys,
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const client = await getAwsClient()
|
|
171
|
+
const {
|
|
172
|
+
CreateSecretCommand,
|
|
173
|
+
PutSecretValueCommand,
|
|
174
|
+
DescribeSecretCommand,
|
|
175
|
+
} = await import('@aws-sdk/client-secrets-manager')
|
|
176
|
+
|
|
177
|
+
try {
|
|
178
|
+
await client.send(new DescribeSecretCommand({ SecretId: secretName }))
|
|
179
|
+
await client.send(new PutSecretValueCommand({
|
|
180
|
+
SecretId: secretName,
|
|
181
|
+
SecretString: secretString,
|
|
182
|
+
}))
|
|
183
|
+
const described = await client.send(new DescribeSecretCommand({ SecretId: secretName }))
|
|
184
|
+
return {
|
|
185
|
+
secretName,
|
|
186
|
+
secretArn: described.ARN || secretName,
|
|
187
|
+
credentialKeys,
|
|
188
|
+
}
|
|
189
|
+
} catch (err) {
|
|
190
|
+
if (err?.name !== 'ResourceNotFoundException') {
|
|
191
|
+
log.warn(`[workspace-secrets] put failed for ${secretName}: ${err?.message || err}`)
|
|
192
|
+
throw err
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const created = await client.send(new CreateSecretCommand({
|
|
197
|
+
Name: secretName,
|
|
198
|
+
SecretString: secretString,
|
|
199
|
+
Tags: [
|
|
200
|
+
{ Key: 'ossy:kind', Value: 'workspace-integration' },
|
|
201
|
+
{ Key: 'ossy:workspaceId', Value: workspaceId },
|
|
202
|
+
{ Key: 'ossy:integrationId', Value: integrationId },
|
|
203
|
+
],
|
|
204
|
+
}))
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
secretName,
|
|
208
|
+
secretArn: created.ARN || secretName,
|
|
209
|
+
credentialKeys,
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* @param {{ workspaceId: string, integrationId: string, secretArn?: string | null }} args
|
|
215
|
+
* @returns {Promise<{ type: string, values: Record<string, string> }>}
|
|
216
|
+
*/
|
|
217
|
+
export async function getWorkspaceSecret ({ workspaceId, integrationId, secretArn = null }) {
|
|
218
|
+
if (!workspaceId || !integrationId) {
|
|
219
|
+
throw Object.assign(new Error('workspaceId and integrationId are required'), {
|
|
220
|
+
status: 400,
|
|
221
|
+
code: 'INTEGRATION_SECRET_NOT_FOUND',
|
|
222
|
+
})
|
|
223
|
+
}
|
|
224
|
+
const secretName = workspaceSecretName(workspaceId, integrationId)
|
|
225
|
+
assertWorkspaceSecretId(secretName, workspaceId)
|
|
226
|
+
if (secretArn) assertWorkspaceSecretId(secretArn, workspaceId)
|
|
227
|
+
const backend = workspaceSecretsBackend()
|
|
228
|
+
|
|
229
|
+
if (backend === 'memory') {
|
|
230
|
+
const raw = memoryStore.get(secretName)
|
|
231
|
+
if (!raw) {
|
|
232
|
+
throw Object.assign(new Error('Integration secret not found'), {
|
|
233
|
+
status: 404,
|
|
234
|
+
code: 'INTEGRATION_SECRET_NOT_FOUND',
|
|
235
|
+
})
|
|
236
|
+
}
|
|
237
|
+
return normalizeSecretPayload(JSON.parse(raw))
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const client = await getAwsClient()
|
|
241
|
+
const { GetSecretValueCommand } = await import('@aws-sdk/client-secrets-manager')
|
|
242
|
+
try {
|
|
243
|
+
const result = await client.send(new GetSecretValueCommand({
|
|
244
|
+
SecretId: secretArn || secretName,
|
|
245
|
+
}))
|
|
246
|
+
if (!result.SecretString) {
|
|
247
|
+
throw Object.assign(new Error('Integration secret is empty'), {
|
|
248
|
+
status: 404,
|
|
249
|
+
code: 'INTEGRATION_SECRET_NOT_FOUND',
|
|
250
|
+
})
|
|
251
|
+
}
|
|
252
|
+
return normalizeSecretPayload(JSON.parse(result.SecretString))
|
|
253
|
+
} catch (err) {
|
|
254
|
+
if (err?.code === 'INTEGRATION_SECRET_NOT_FOUND') throw err
|
|
255
|
+
if (err?.name === 'ResourceNotFoundException') {
|
|
256
|
+
throw Object.assign(new Error('Integration secret not found'), {
|
|
257
|
+
status: 404,
|
|
258
|
+
code: 'INTEGRATION_SECRET_NOT_FOUND',
|
|
259
|
+
})
|
|
260
|
+
}
|
|
261
|
+
log.warn(`[workspace-secrets] get failed for ${secretName}: ${err?.message || err}`)
|
|
262
|
+
throw err
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* @param {{ workspaceId: string, integrationId: string, secretArn?: string | null }} args
|
|
268
|
+
*/
|
|
269
|
+
export async function deleteWorkspaceSecret ({ workspaceId, integrationId, secretArn = null }) {
|
|
270
|
+
if (!workspaceId || !integrationId) return
|
|
271
|
+
const secretName = workspaceSecretName(workspaceId, integrationId)
|
|
272
|
+
assertWorkspaceSecretId(secretName, workspaceId)
|
|
273
|
+
if (secretArn) assertWorkspaceSecretId(secretArn, workspaceId)
|
|
274
|
+
const backend = workspaceSecretsBackend()
|
|
275
|
+
|
|
276
|
+
if (backend === 'memory') {
|
|
277
|
+
memoryStore.delete(secretName)
|
|
278
|
+
return
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const client = await getAwsClient()
|
|
282
|
+
const { DeleteSecretCommand } = await import('@aws-sdk/client-secrets-manager')
|
|
283
|
+
try {
|
|
284
|
+
await client.send(new DeleteSecretCommand({
|
|
285
|
+
SecretId: secretArn || secretName,
|
|
286
|
+
ForceDeleteWithoutRecovery: true,
|
|
287
|
+
}))
|
|
288
|
+
} catch (err) {
|
|
289
|
+
if (err?.name === 'ResourceNotFoundException') return
|
|
290
|
+
log.warn(`[workspace-secrets] delete failed for ${secretName}: ${err?.message || err}`)
|
|
291
|
+
throw err
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Integration ids this workspace has stored. Names only — never the secret values.
|
|
297
|
+
*
|
|
298
|
+
* @param {string} workspaceId
|
|
299
|
+
* @returns {Promise<string[]>}
|
|
300
|
+
*/
|
|
301
|
+
export async function listWorkspaceIntegrationIds (workspaceId) {
|
|
302
|
+
if (!workspaceId) return []
|
|
303
|
+
const prefix = `ossybot/workspaces/${workspaceId}/integrations/`
|
|
304
|
+
assertWorkspaceSecretId(prefix, workspaceId)
|
|
305
|
+
if (workspaceSecretsBackend() === 'memory') {
|
|
306
|
+
return [...memoryStore.keys()]
|
|
307
|
+
.filter((name) => name.startsWith(prefix))
|
|
308
|
+
.map((name) => name.slice(prefix.length))
|
|
309
|
+
.sort()
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const client = await getAwsClient()
|
|
313
|
+
const { ListSecretsCommand } = await import('@aws-sdk/client-secrets-manager')
|
|
314
|
+
/** @type {string[]} */
|
|
315
|
+
const ids = []
|
|
316
|
+
let nextToken
|
|
317
|
+
for (let page = 0; page < 10; page += 1) {
|
|
318
|
+
const result = await client.send(new ListSecretsCommand({
|
|
319
|
+
NextToken: nextToken,
|
|
320
|
+
Filters: [{ Key: 'name', Values: [prefix] }],
|
|
321
|
+
}))
|
|
322
|
+
for (const entry of result.SecretList || []) {
|
|
323
|
+
if (typeof entry.Name === 'string' && entry.Name.startsWith(prefix)) {
|
|
324
|
+
ids.push(entry.Name.slice(prefix.length))
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
nextToken = result.NextToken
|
|
328
|
+
if (!nextToken) break
|
|
329
|
+
}
|
|
330
|
+
return ids.sort()
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Test helper — clear memory backend. */
|
|
334
|
+
export function _resetWorkspaceSecretsMemoryForTests () {
|
|
335
|
+
memoryStore.clear()
|
|
336
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it } from '@jest/globals'
|
|
2
|
+
import {
|
|
3
|
+
putWorkspaceSecret,
|
|
4
|
+
getWorkspaceSecret,
|
|
5
|
+
deleteWorkspaceSecret,
|
|
6
|
+
listWorkspaceIntegrationIds,
|
|
7
|
+
workspaceSecretName,
|
|
8
|
+
_resetWorkspaceSecretsMemoryForTests,
|
|
9
|
+
} from './workspace-secrets.js'
|
|
10
|
+
|
|
11
|
+
describe('workspace-secrets (memory backend)', () => {
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
_resetWorkspaceSecretsMemoryForTests()
|
|
14
|
+
process.env.WORKSPACE_SECRETS_BACKEND = 'memory'
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('puts and gets a normalized payload', async () => {
|
|
18
|
+
const stored = await putWorkspaceSecret({
|
|
19
|
+
workspaceId: 'ws-1',
|
|
20
|
+
integrationId: 'linkedin-primary',
|
|
21
|
+
payload: { type: 'oauth2', values: { ACCESS_TOKEN: 'tok', REFRESH_TOKEN: 'ref' } },
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
expect(stored.secretName).toBe(workspaceSecretName('ws-1', 'linkedin-primary'))
|
|
25
|
+
expect(stored.credentialKeys).toEqual(['ACCESS_TOKEN', 'REFRESH_TOKEN'])
|
|
26
|
+
|
|
27
|
+
const loaded = await getWorkspaceSecret({
|
|
28
|
+
workspaceId: 'ws-1',
|
|
29
|
+
integrationId: 'linkedin-primary',
|
|
30
|
+
})
|
|
31
|
+
expect(loaded).toEqual({
|
|
32
|
+
type: 'oauth2',
|
|
33
|
+
values: { ACCESS_TOKEN: 'tok', REFRESH_TOKEN: 'ref' },
|
|
34
|
+
})
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('lists integration ids for one workspace and not another', async () => {
|
|
38
|
+
await putWorkspaceSecret({
|
|
39
|
+
workspaceId: 'ws-1',
|
|
40
|
+
integrationId: 'linkedin',
|
|
41
|
+
payload: { values: { ACCESS_TOKEN: 'tok' } },
|
|
42
|
+
})
|
|
43
|
+
await putWorkspaceSecret({
|
|
44
|
+
workspaceId: 'ws-2',
|
|
45
|
+
integrationId: 'linkedin',
|
|
46
|
+
payload: { values: { ACCESS_TOKEN: 'other' } },
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
expect(await listWorkspaceIntegrationIds('ws-1')).toEqual(['linkedin'])
|
|
50
|
+
expect(await listWorkspaceIntegrationIds('ws-2')).toEqual(['linkedin'])
|
|
51
|
+
await expect(getWorkspaceSecret({
|
|
52
|
+
workspaceId: 'ws-2',
|
|
53
|
+
integrationId: 'linkedin',
|
|
54
|
+
})).resolves.toEqual({ type: 'oauth2', values: { ACCESS_TOKEN: 'other' } })
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('reads and deletes through this workspace secret id', async () => {
|
|
58
|
+
const stored = await putWorkspaceSecret({
|
|
59
|
+
workspaceId: 'ws-1',
|
|
60
|
+
integrationId: 'linkedin',
|
|
61
|
+
payload: { values: { ACCESS_TOKEN: 'tok' } },
|
|
62
|
+
})
|
|
63
|
+
const arn = 'arn:aws:secretsmanager:eu-north-1:111122223333:secret:ossybot/workspaces/ws-1/integrations/linkedin-a1B2c3'
|
|
64
|
+
|
|
65
|
+
await expect(getWorkspaceSecret({
|
|
66
|
+
workspaceId: 'ws-1',
|
|
67
|
+
integrationId: 'linkedin',
|
|
68
|
+
secretArn: stored.secretArn,
|
|
69
|
+
})).resolves.toEqual({ type: 'oauth2', values: { ACCESS_TOKEN: 'tok' } })
|
|
70
|
+
await expect(getWorkspaceSecret({
|
|
71
|
+
workspaceId: 'ws-1',
|
|
72
|
+
integrationId: 'linkedin',
|
|
73
|
+
secretArn: arn,
|
|
74
|
+
})).resolves.toEqual({ type: 'oauth2', values: { ACCESS_TOKEN: 'tok' } })
|
|
75
|
+
await deleteWorkspaceSecret({
|
|
76
|
+
workspaceId: 'ws-1',
|
|
77
|
+
integrationId: 'linkedin',
|
|
78
|
+
secretArn: arn,
|
|
79
|
+
})
|
|
80
|
+
await expect(getWorkspaceSecret({
|
|
81
|
+
workspaceId: 'ws-1',
|
|
82
|
+
integrationId: 'linkedin',
|
|
83
|
+
})).rejects.toMatchObject({ code: 'INTEGRATION_SECRET_NOT_FOUND' })
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('rejects a secret id from another workspace', async () => {
|
|
87
|
+
await putWorkspaceSecret({
|
|
88
|
+
workspaceId: 'ws-1',
|
|
89
|
+
integrationId: 'linkedin',
|
|
90
|
+
payload: { values: { ACCESS_TOKEN: 'tok' } },
|
|
91
|
+
})
|
|
92
|
+
await putWorkspaceSecret({
|
|
93
|
+
workspaceId: 'ws-2',
|
|
94
|
+
integrationId: 'linkedin',
|
|
95
|
+
payload: { values: { ACCESS_TOKEN: 'other' } },
|
|
96
|
+
})
|
|
97
|
+
const otherArn = 'arn:aws:secretsmanager:eu-north-1:111122223333:secret:ossybot/workspaces/ws-2/integrations/linkedin-a1B2c3'
|
|
98
|
+
|
|
99
|
+
await expect(getWorkspaceSecret({
|
|
100
|
+
workspaceId: 'ws-1',
|
|
101
|
+
integrationId: 'linkedin',
|
|
102
|
+
secretArn: otherArn,
|
|
103
|
+
})).rejects.toMatchObject({ status: 403, code: 'CROSS_TENANT_SECRET' })
|
|
104
|
+
await expect(getWorkspaceSecret({
|
|
105
|
+
workspaceId: 'ws-1',
|
|
106
|
+
integrationId: 'linkedin',
|
|
107
|
+
secretArn: workspaceSecretName('ws-2', 'linkedin'),
|
|
108
|
+
})).rejects.toMatchObject({ code: 'CROSS_TENANT_SECRET' })
|
|
109
|
+
await expect(getWorkspaceSecret({
|
|
110
|
+
workspaceId: 'ws-1',
|
|
111
|
+
integrationId: 'linkedin',
|
|
112
|
+
secretArn: 'ossybot/workspaces/ws-1-evil/integrations/linkedin',
|
|
113
|
+
})).rejects.toMatchObject({ code: 'CROSS_TENANT_SECRET' })
|
|
114
|
+
await expect(deleteWorkspaceSecret({
|
|
115
|
+
workspaceId: 'ws-1',
|
|
116
|
+
integrationId: 'linkedin',
|
|
117
|
+
secretArn: otherArn,
|
|
118
|
+
})).rejects.toMatchObject({ code: 'CROSS_TENANT_SECRET' })
|
|
119
|
+
await expect(getWorkspaceSecret({
|
|
120
|
+
workspaceId: 'ws-2',
|
|
121
|
+
integrationId: 'linkedin',
|
|
122
|
+
})).resolves.toEqual({ type: 'oauth2', values: { ACCESS_TOKEN: 'other' } })
|
|
123
|
+
await expect(listWorkspaceIntegrationIds('ws-1/../ws-2')).rejects.toMatchObject({
|
|
124
|
+
code: 'CROSS_TENANT_SECRET',
|
|
125
|
+
})
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('deletes secrets', async () => {
|
|
129
|
+
await putWorkspaceSecret({
|
|
130
|
+
workspaceId: 'ws-1',
|
|
131
|
+
integrationId: 'x-primary',
|
|
132
|
+
payload: { values: { ACCESS_TOKEN: 'x' } },
|
|
133
|
+
})
|
|
134
|
+
await deleteWorkspaceSecret({ workspaceId: 'ws-1', integrationId: 'x-primary' })
|
|
135
|
+
await expect(getWorkspaceSecret({
|
|
136
|
+
workspaceId: 'ws-1',
|
|
137
|
+
integrationId: 'x-primary',
|
|
138
|
+
})).rejects.toMatchObject({ code: 'INTEGRATION_SECRET_NOT_FOUND' })
|
|
139
|
+
})
|
|
140
|
+
})
|