diff --git a/src/routes/source.js b/src/routes/source.js index 63d6cdd3..a1d508f1 100644 --- a/src/routes/source.js +++ b/src/routes/source.js @@ -26,11 +26,18 @@ export async function deleteSource({ req, env, daCtx }) { export async function postSource({ req, env, daCtx }) { if (!hasPermission(daCtx, daCtx.key, 'write')) return { status: 403 }; + + // Flag MCP-initiated writes so the version author is tagged as an agent. + // Done after the permission check, which also reads daCtx.users[].email. + const initiator = req.headers.get('x-da-initiator'); + if (initiator === 'mcp' && Array.isArray(daCtx.users)) { + daCtx.users = daCtx.users.map((user) => ({ ...user, isAgentic: true })); + } + const obj = await putHelper(req, env, daCtx); const resp = await putObject(env, daCtx, obj); if (resp.status === 201 || resp.status === 200) { - const initiator = req.headers.get('x-da-initiator'); if (initiator !== 'collab') { await notifyCollab('syncadmin', req.url, env); } diff --git a/src/storage/utils/version.js b/src/storage/utils/version.js index 88159966..0a4258d9 100644 --- a/src/storage/utils/version.js +++ b/src/storage/utils/version.js @@ -52,5 +52,8 @@ export function getUsersForMetadata(users) { return undefined; } - return users.map((user) => ({ email: user.email })); + return users.map((user) => ({ + email: user.email, + ...(user.isAgentic && { isAgentic: true }), + })); } diff --git a/test/routes/source.test.js b/test/routes/source.test.js index 27837947..89aef176 100644 --- a/test/routes/source.test.js +++ b/test/routes/source.test.js @@ -55,6 +55,53 @@ describe('Source Route', () => { assert.deepStrictEqual(['https://localhost/api/v1/syncadmin?doc=http://localhost:9876/source/somedoc.html'], sb_callbacks); }); + it('Test postSource from mcp flags users as agent and still notifies collab', async () => { + const putCalled = []; + const putCall = (e, c, o) => { + putCalled.push({ e, c, o }); + return { status: 200 }; + }; + + const { postSource } = await esmock('../../src/routes/source.js', { + '../../src/storage/object/put.js': { + default: putCall, + }, + '../../src/utils/auth.js': { + hasPermission: () => true, + }, + }); + + const callbacks = []; + const env = { + dacollab: { + fetch: async (url) => { + callbacks.push(url); + return { body: { cancel: () => {} } }; + }, + }, + DA_COLLAB: 'http://localhost:1234', + }; + + const headers = new Map(); + headers.set('x-da-initiator', 'mcp'); + + const req = { headers, url: 'http://localhost:8787/source/a/b/mydoc.html' }; + const daCtx = { + key: '/a/b/mydoc.html', + aclCtx: { pathLookup: new Map() }, + users: [{ email: 'jane@example.com', ident: '123' }], + }; + + const resp = await postSource({ req, env, daCtx }); + assert.equal(200, resp.status); + // users passed to putObject are flagged so the version author is an agent + assert.deepStrictEqual( + putCalled[0].c.users, + [{ email: 'jane@example.com', ident: '123', isAgentic: true }], + ); + assert.equal(1, callbacks.length); + }); + it('Test postSource from collab does not trigger invalidate callback', async () => { const { postSource } = await esmock('../../src/routes/source.js', { '../../src/storage/object/put.js': { diff --git a/test/storage/utils/version.test.js b/test/storage/utils/version.test.js new file mode 100644 index 00000000..cd3a6921 --- /dev/null +++ b/test/storage/utils/version.test.js @@ -0,0 +1,44 @@ +/* + * Copyright 2025 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +import assert from 'node:assert'; + +import { getUsersForMetadata } from '../../../src/storage/utils/version.js'; + +describe('getUsersForMetadata', () => { + it('returns undefined when users is missing', () => { + assert.equal(getUsersForMetadata(undefined), undefined); + }); + + it('projects to email only, dropping ident/orgs', () => { + const users = [{ email: 'jane@example.com', ident: '123', orgs: [{}] }]; + assert.deepStrictEqual(getUsersForMetadata(users), [{ email: 'jane@example.com' }]); + }); + + it('keeps the email clean and adds isAgentic when the user is an agent', () => { + const users = [{ email: 'jane@example.com', ident: '123', isAgentic: true }]; + assert.deepStrictEqual( + getUsersForMetadata(users), + [{ email: 'jane@example.com', isAgentic: true }], + ); + }); + + it('flags only the agent users in a mixed list', () => { + const users = [ + { email: 'jane@example.com', isAgentic: true }, + { email: 'bob@example.com' }, + ]; + assert.deepStrictEqual(getUsersForMetadata(users), [ + { email: 'jane@example.com', isAgentic: true }, + { email: 'bob@example.com' }, + ]); + }); +});