@ossy/platform 3.25.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ossy/platform",
3
- "version": "3.25.0",
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.25.0",
53
- "@ossy/event-store": "^3.24.1",
53
+ "@ossy/email": "^3.27.0",
54
+ "@ossy/event-store": "^3.26.0",
54
55
  "@ossy/locale": "^3.4.0",
55
- "@ossy/manifest": "^3.24.0",
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.24.0",
60
- "@ossy/sdk": "^3.24.0",
60
+ "@ossy/schema": "^3.27.0",
61
+ "@ossy/sdk": "^3.26.0",
61
62
  "@ossy/tokens": "^3.24.1",
62
- "@ossy/users": "^3.24.1",
63
- "@ossy/workspaces": "^3.25.0",
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": "00dd4b51a94de7e0399c9dfa59380a53e58c8523"
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
- * Integrations are loaded once at server startup. If a required env var is
12
- * missing, or if `connect()` throws, the integration is skipped with a warning
13
- * so the server can still boot without every optional credential being present.
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 all integration modules, storing each resulting client by id.
18
- * Pass the array of already-imported integration modules (each exporting
19
- * `id`, `credentials`, and `connect`).
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: string[], connect: (opts: { env: NodeJS.ProcessEnv }) => Promise<unknown> | unknown }>} modules
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, credentials = [], connect } = mod
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
- * Retrieve a connected client by integration id.
76
- * Returns `null` when the integration was not loaded (missing credentials,
77
- * connect failure, or not declared in the manifest).
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
- * @returns {unknown | null}
124
+ * @param {{ workspaceId?: string }} [options]
125
+ * @returns {unknown | null | Promise<unknown>}
81
126
  */
82
- get (id) {
83
- return _clients[id] ?? null
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,5 @@
1
+ export const metadata = {
2
+ id: '@ossy/platform/actions/list-integrations',
3
+ access: 'workspace',
4
+ audit: false,
5
+ }
@@ -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 — import each bundled integration module, check its
225
- // declared credentials, and call connect({ env }) to obtain a client.
226
- // Missing credentials or a failing connect() are non-fatal: the integration
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')
@@ -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)
@@ -2,21 +2,22 @@
2
2
  * Vector embedding store backed by MongoDB.
3
3
  *
4
4
  * Collection: `resource_embeddings`
5
- * Schema: { resourceId, workspaceId, model, dimensions, vector, textSource, textLength, updatedAt }
5
+ * Schema: { resourceId, workspaceId, kind, model, dimensions, vector, textSource, textLength, textHash, updatedAt }
6
6
  *
7
7
  * Search strategy:
8
8
  * - If Atlas Vector Search index `resource_embedding_vector` is present → $vectorSearch
9
- * - Otherwise → in-memory cosine similarity (works fine for <10k resources per workspace)
9
+ * - Otherwise → cosine similarity over the workspace (and kind, when set)
10
10
  *
11
- * Atlas Vector Search index definition (create via Atlas UI or Admin API):
11
+ * `workspaceId` and `kind` must be filter fields on that index. A filter on a
12
+ * field the index does not declare fails the query, and search falls back to
13
+ * the in-memory scan. Atlas index definition:
12
14
  * ```json
13
15
  * {
14
- * "mappings": {
15
- * "dynamic": false,
16
- * "fields": {
17
- * "vector": { "type": "knnVector", "dimensions": 1536, "similarity": "cosine" }
18
- * }
19
- * }
16
+ * "fields": [
17
+ * { "type": "vector", "path": "vector", "numDimensions": 1536, "similarity": "cosine" },
18
+ * { "type": "filter", "path": "workspaceId" },
19
+ * { "type": "filter", "path": "kind" }
20
+ * ]
20
21
  * }
21
22
  * ```
22
23
  */
@@ -67,6 +68,8 @@ function col () {
67
68
  * vector: number[],
68
69
  * textSource: string,
69
70
  * textLength: number,
71
+ * kind?: string,
72
+ * textHash?: string,
70
73
  * }} doc
71
74
  */
72
75
  export async function upsertEmbedding (doc) {
@@ -97,6 +100,43 @@ export async function deleteEmbedding (resourceId) {
97
100
  await col().deleteOne({ resourceId })
98
101
  }
99
102
 
103
+ /**
104
+ * Filter for `$vectorSearch` and the in-memory fallback.
105
+ * `kind` keeps a request vector from matching other requests.
106
+ *
107
+ * Atlas `$vectorSearch` filters do not support `$exists`, so the Atlas query
108
+ * matches `kind` exactly. A changed document writes `kind` when it is embedded
109
+ * or scored. The in-memory fallback can include legacy rows that predate the field.
110
+ *
111
+ * @param {{ workspaceId?: string, kind?: string, includeMissingKind?: boolean }} [scope]
112
+ */
113
+ export function embeddingSearchFilter ({ workspaceId, kind, includeMissingKind = false } = {}) {
114
+ const filter = {}
115
+ if (workspaceId) filter.workspaceId = workspaceId
116
+ if (!kind) return filter
117
+ if (!includeMissingKind) {
118
+ filter.kind = kind
119
+ return filter
120
+ }
121
+ filter.$or = [
122
+ { kind },
123
+ { kind: { $exists: false } },
124
+ { kind: null },
125
+ ]
126
+ return filter
127
+ }
128
+
129
+ /**
130
+ * Record what a stored vector represents without rewriting the vector.
131
+ *
132
+ * @param {string} resourceId
133
+ * @param {string} kind
134
+ */
135
+ export async function setEmbeddingKind (resourceId, kind) {
136
+ if (!resourceId || !kind) return
137
+ await col().updateOne({ resourceId }, { $set: { kind, updatedAt: new Date() } })
138
+ }
139
+
100
140
  /**
101
141
  * Semantic search within a workspace using cosine similarity.
102
142
  * Tries Atlas $vectorSearch first; falls back to in-memory similarity.
@@ -106,11 +146,16 @@ export async function deleteEmbedding (resourceId) {
106
146
  * queryVector: number[],
107
147
  * limit?: number,
108
148
  * threshold?: number,
149
+ * kind?: string,
109
150
  * }} opts
110
151
  * @returns {Promise<Array<{ resourceId: string, score: number }>>}
111
152
  */
112
- export async function searchEmbeddings ({ workspaceId, queryVector, limit = 20, threshold = 0 }) {
153
+ export async function searchEmbeddings ({ workspaceId, queryVector, limit = 20, threshold = 0, kind } = {}) {
154
+ if (!workspaceId || !Array.isArray(queryVector) || !queryVector.length) return []
155
+ const filter = embeddingSearchFilter({ workspaceId, kind })
156
+ const capped = Math.max(1, limit)
113
157
  // Try Atlas Vector Search (requires index on Atlas M10+).
158
+ // An empty result is the index answer. A missing index throws and falls back.
114
159
  try {
115
160
  const pipeline = [
116
161
  {
@@ -118,9 +163,9 @@ export async function searchEmbeddings ({ workspaceId, queryVector, limit = 20,
118
163
  index: ATLAS_INDEX,
119
164
  path: 'vector',
120
165
  queryVector,
121
- numCandidates: limit * 10,
122
- limit,
123
- filter: { workspaceId },
166
+ numCandidates: capped * 10,
167
+ limit: capped,
168
+ filter,
124
169
  },
125
170
  },
126
171
  {
@@ -132,17 +177,14 @@ export async function searchEmbeddings ({ workspaceId, queryVector, limit = 20,
132
177
  },
133
178
  ]
134
179
  const results = await col().aggregate(pipeline).toArray()
135
- if (results.length > 0) {
136
- return results.filter((r) => r.score >= threshold)
137
- }
180
+ return results.filter((r) => r.score >= threshold)
138
181
  } catch {
139
182
  // Atlas Vector Search not available — fall through to cosine similarity.
140
183
  }
141
184
 
142
- // In-memory cosine similarity fallback.
143
185
  log.debug('[VectorStore] Atlas $vectorSearch unavailable — using in-memory cosine similarity')
144
186
  const docs = await col()
145
- .find({ workspaceId })
187
+ .find(embeddingSearchFilter({ workspaceId, kind, includeMissingKind: true }))
146
188
  .project({ resourceId: 1, vector: 1 })
147
189
  .toArray()
148
190
 
@@ -153,7 +195,7 @@ export async function searchEmbeddings ({ workspaceId, queryVector, limit = 20,
153
195
  }))
154
196
  .filter((r) => r.score >= threshold)
155
197
  .sort((a, b) => b.score - a.score)
156
- .slice(0, limit)
198
+ .slice(0, capped)
157
199
  }
158
200
 
159
201
  /**
@@ -178,5 +220,6 @@ export async function ensureEmbeddingIndexes () {
178
220
  await c.createIndex({ resourceId: 1 }, { name: 'resourceId', unique: true, background: true })
179
221
  await c.createIndex({ workspaceId: 1 }, { name: 'workspaceId', background: true })
180
222
  await c.createIndex({ workspaceId: 1, updatedAt: -1 }, { name: 'workspaceId_updatedAt', background: true })
223
+ await c.createIndex({ workspaceId: 1, kind: 1 }, { name: 'workspaceId_kind', background: true })
181
224
  log.info('[VectorStore] resource_embeddings indexes ensured')
182
225
  }
@@ -0,0 +1,35 @@
1
+ import { describe, expect, it, jest } from '@jest/globals'
2
+
3
+ jest.unstable_mockModule('@ossy/observability', () => ({
4
+ createLogger: () => ({ debug () {}, info () {}, warn () {}, error () {} }),
5
+ }))
6
+
7
+ jest.unstable_mockModule('@ossy/event-store', () => ({
8
+ Mongo: { db: { collection () { return {} } } },
9
+ }))
10
+
11
+ const { embeddingSearchFilter } = await import('./vector-store.js')
12
+
13
+ describe('embeddingSearchFilter', () => {
14
+ it('scopes by workspace and kind for Atlas-compatible filters', () => {
15
+ expect(embeddingSearchFilter({ workspaceId: 'ws-1', kind: 'resume' })).toEqual({
16
+ workspaceId: 'ws-1',
17
+ kind: 'resume',
18
+ })
19
+ })
20
+
21
+ it('includes legacy rows without kind for the in-memory fallback', () => {
22
+ expect(embeddingSearchFilter({
23
+ workspaceId: 'ws-1',
24
+ kind: 'resume',
25
+ includeMissingKind: true,
26
+ })).toEqual({
27
+ workspaceId: 'ws-1',
28
+ $or: [
29
+ { kind: 'resume' },
30
+ { kind: { $exists: false } },
31
+ { kind: null },
32
+ ],
33
+ })
34
+ })
35
+ })
@@ -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
+ })