Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions docs/contributing/adding-capabilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,17 +139,17 @@ limited to user/role account metadata and sanitized audit metadata. Never return
or join against user content tables such as packages, secrets, values, memories,
jobs, email, chat threads, storage buckets, OAuth grants, or remote connectors.

Current admin capabilities are read-only:
Current admin capabilities:

- `admin_user_list`
- `admin_user_get`
- `admin_user_create`
- `admin_audit_log_query`

When the invite-signup branch lands, expose its invite and admin
create-user-by-email service functions by adding new `admin/*` capability files
that call those service-layer functions directly, set `requiredRole: 'admin'`,
and audit-log the invocation. Do not duplicate the invite/user-creation SQL in
capability handlers.
When adding more admin actions, expose service-layer functions by adding new
`admin/*` capability files that call those service functions directly, set
`requiredRole: 'admin'`, and audit-log the invocation. Do not duplicate admin
workflow SQL in capability handlers.

Use raw JSON Schema only when you need an escape hatch that Zod does not model
cleanly. The registry and Code Mode layer consume normalized JSON Schema after
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { expect, test } from 'vitest'
import { createMcpCallerContext } from '#mcp/context.ts'
import { adminAuditLogQueryCapability } from './admin-audit-log-query.ts'
import { adminUserCreateCapability } from './admin-user-create.ts'
import { adminUserGetCapability } from './admin-user-get.ts'
import { adminUserListCapability } from './admin-user-list.ts'

Expand All @@ -10,6 +11,8 @@ type UserRow = {
email: string
created_at: string
updated_at: string
password_hash?: string
email_verified_at?: string | null
}

type UserRoleRow = {
Expand All @@ -30,6 +33,12 @@ type AuditEventRow = {
timestamp: string
}

type PasswordResetRow = {
user_id: number
token_hash: string
expires_at: number
}

function normalizeQuery(query: string) {
return query.replace(/\s+/g, ' ').trim().toLowerCase()
}
Expand All @@ -38,9 +47,11 @@ function createAdminCapabilityTestDb(input: {
users: Array<UserRow>
userRoles: Array<UserRoleRow>
}) {
let nextUserId = Math.max(0, ...input.users.map((user) => user.id)) + 1
const users = input.users.map((user) => ({ ...user }))
const userRoles = input.userRoles.map((row) => ({ ...row }))
const auditEvents: Array<AuditEventRow> = []
const passwordResets: Array<PasswordResetRow> = []

function selectAuditEvents(
normalizedQuery: string,
Expand Down Expand Up @@ -109,6 +120,18 @@ function createAdminCapabilityTestDb(input: {
return (users.find((user) => user.email.toLowerCase() === email) ??
null) as T | null
}
if (normalizedQuery.includes('select id from users where email')) {
const email = String(params[0]).toLowerCase()
const user = users.find((row) => row.email.toLowerCase() === email)
return user ? ({ id: user.id } as T) : null
}
if (normalizedQuery.includes('select id from users where username')) {
const username = String(params[0]).toLowerCase()
const user = users.find(
(row) => row.username.toLowerCase() === username,
)
return user ? ({ id: user.id } as T) : null
}
if (
normalizedQuery.includes(
'select count(*) as total from audit_events',
Expand Down Expand Up @@ -157,6 +180,75 @@ function createAdminCapabilityTestDb(input: {
return { results: [] as Array<T> }
},
async run() {
if (normalizedQuery.startsWith('insert into users')) {
const [username, email, passwordHash, emailVerifiedAt] = params
if (
users.some(
(row) =>
row.email.toLowerCase() === String(email ?? '').toLowerCase(),
)
) {
throw new Error('UNIQUE constraint failed: users.email')
}
if (
users.some(
(row) =>
row.username.toLowerCase() ===
String(username ?? '').toLowerCase(),
)
) {
throw new Error('UNIQUE constraint failed: users.username')
}
const now = new Date().toISOString()
const user = {
id: nextUserId,
username: String(username),
email: String(email),
created_at: now,
updated_at: now,
password_hash: String(passwordHash),
email_verified_at: String(emailVerifiedAt),
}
nextUserId += 1
users.push(user)
return { meta: { changes: 1, last_row_id: user.id } }
}
if (normalizedQuery.includes('insert or ignore into user_roles')) {
const userId = Number(params[0])
const roleName = String(params[1])
if (
!userRoles.some(
(row) => row.user_id === userId && row.role_name === roleName,
)
) {
userRoles.push({ user_id: userId, role_name: roleName })
}
return { meta: { changes: 1, last_row_id: 0 } }
}
if (normalizedQuery.startsWith('delete from password_resets')) {
const userId = Number(params[0])
for (let index = passwordResets.length - 1; index >= 0; index--) {
if (passwordResets[index]?.user_id === userId) {
passwordResets.splice(index, 1)
}
}
return { meta: { changes: 1, last_row_id: 0 } }
}
if (normalizedQuery.startsWith('insert into password_resets')) {
const [userId, tokenHash, expiresAt] = params
passwordResets.push({
user_id: Number(userId),
token_hash: String(tokenHash),
expires_at: Number(expiresAt),
})
return { meta: { changes: 1, last_row_id: 1 } }
}
if (normalizedQuery.startsWith('delete from users')) {
const userId = Number(params[0])
const index = users.findIndex((user) => user.id === userId)
if (index >= 0) users.splice(index, 1)
return { meta: { changes: index >= 0 ? 1 : 0, last_row_id: 0 } }
}
if (normalizedQuery.includes('insert into audit_events')) {
auditEvents.push({
id: auditEvents.length + 1,
Expand All @@ -183,7 +275,7 @@ function createAdminCapabilityTestDb(input: {
},
} as unknown as D1Database

return { db, auditEvents }
return { db, auditEvents, passwordResets, userRoles, users }
}

function createAdminCapabilityContext(db: D1Database) {
Expand Down Expand Up @@ -279,6 +371,59 @@ test('admin capabilities list and get account metadata and query sanitized audit
])
})

test('admin_user_create creates an account with a setup link and audit metadata', async () => {
const { db, auditEvents, passwordResets, userRoles, users } =
createAdminCapabilityTestDb({
users: [
{
id: 1,
username: 'admin',
email: 'admin@example.com',
created_at: '2026-01-01 00:00:00',
updated_at: '2026-01-02 00:00:00',
},
],
userRoles: [{ user_id: 1, role_name: 'admin' }],
})
const ctx = createAdminCapabilityContext(db)

const result = await adminUserCreateCapability.handler(
{ email: 'Person+Launch@Example.com' },
ctx,
)

expect(result.createdUser).toMatchObject({
userId: 2,
email: 'person+launch@example.com',
username: 'person-launch',
setupTokenExpiresAt: expect.any(Number),
})
expect(result.createdUser.setupLink).toMatch(
/^https:\/\/example\.com\/reset-password\?token=[0-9a-f]{64}$/,
)
expect(users.find((user) => user.id === 2)).toMatchObject({
email: 'person+launch@example.com',
username: 'person-launch',
password_hash: 'admin_created_no_usable_password',
email_verified_at: expect.any(String),
})
expect(userRoles).toContainEqual({ user_id: 2, role_name: 'user' })
expect(passwordResets).toEqual([
expect.objectContaining({
user_id: 2,
token_hash: expect.stringMatching(/^[0-9a-f]{64}$/),
expires_at: result.createdUser.setupTokenExpiresAt,
}),
])
expect(auditEvents).toEqual([
expect.objectContaining({
action: 'admin_user_create',
result: 'success',
reason: 'target_user_id=2;target_email=***@example.com',
}),
])
})

test('admin capabilities reject non-admin direct handler calls', async () => {
const { db } = createAdminCapabilityTestDb({
users: [],
Expand Down
24 changes: 22 additions & 2 deletions packages/worker/src/mcp/capabilities/admin/admin-shared.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { z } from 'zod'
import { logAuditEvent } from '#app/audit-log.ts'
import { logAuditEvent, redactEmailRecipient } from '#app/audit-log.ts'
import { roleNames } from '#app/permissions.ts'
import { type CapabilityContext } from '#mcp/capabilities/types.ts'

Expand All @@ -10,6 +10,13 @@ export const adminCapabilityAccess = {
destructive: false,
} as const

export const adminMutationCapabilityAccess = {
requiredRole: 'admin',
readOnly: false,
idempotent: false,
destructive: false,
} as const

export const roleNameSchema = z.enum(roleNames)

export const adminUserMetadataSchema = z.object({
Expand All @@ -32,6 +39,9 @@ export async function auditAdminCapabilityInvocation<TResult>(
ctx: CapabilityContext,
capabilityName: string,
run: () => Promise<TResult>,
options: {
successReason?: (result: TResult) => string
} = {},
): Promise<TResult> {
const actorEmail = ctx.callerContext.user?.email
try {
Expand All @@ -43,7 +53,7 @@ export async function auditAdminCapabilityInvocation<TResult>(
result: 'success',
email: actorEmail,
path: '/mcp',
reason: 'mcp_admin_capability',
reason: options.successReason?.(result) ?? 'mcp_admin_capability',
})
return result
} catch (error) {
Expand All @@ -59,3 +69,13 @@ export async function auditAdminCapabilityInvocation<TResult>(
throw error
}
}

export function buildCreatedUserAuditReason(input: {
userId: number
email: string
}) {
return [
`target_user_id=${input.userId}`,
`target_email=${redactEmailRecipient(input.email)}`,
].join(';')
}
73 changes: 73 additions & 0 deletions packages/worker/src/mcp/capabilities/admin/admin-user-create.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { z } from 'zod'
import { adminCreateUserWithPasswordSetup } from '#app/admin-user-creation.ts'
import { defineDomainCapability } from '#mcp/capabilities/define-domain-capability.ts'
import { capabilityDomainNames } from '#mcp/capabilities/domain-metadata.ts'
import {
adminMutationCapabilityAccess,
auditAdminCapabilityInvocation,
buildCreatedUserAuditReason,
} from './admin-shared.ts'

const inputSchema = z.object({
email: z.email().describe('Email address for the account to create.'),
username: z
.string()
.min(1)
.optional()
.describe(
'Optional username. When omitted, Kody generates an available username from the email local part.',
),
})

const outputSchema = z.object({
createdUser: z.object({
userId: z.number().int().positive(),
email: z.string(),
username: z.string(),
setupLink: z.string(),
setupTokenExpiresAt: z.number().int().positive(),
}),
})

export const adminUserCreateCapability = defineDomainCapability(
capabilityDomainNames.admin,
{
...adminMutationCapabilityAccess,
name: 'admin_user_create',
description:
'Create one user account by email, assign the default user role, and return a password setup link. Admin-only; does not expose user content.',
keywords: [
'admin',
'user',
'create',
'account',
'email',
'password setup',
'invite',
],
inputSchema,
outputSchema,
async handler(args, ctx) {
return auditAdminCapabilityInvocation(
ctx,
'admin_user_create',
async () => {
const createdUser = await adminCreateUserWithPasswordSetup({
db: ctx.env.APP_DB,
email: args.email,
username: args.username,
setupLinkOrigin: ctx.callerContext.baseUrl,
})
return { createdUser }
},
{
successReason: ({ createdUser }) =>
buildCreatedUserAuditReason({
userId: createdUser.userId,
email: createdUser.email,
}),
},
)
},
},
)
2 changes: 2 additions & 0 deletions packages/worker/src/mcp/capabilities/admin/domain.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { defineDomain } from '#mcp/capabilities/define-domain.ts'
import { capabilityDomainNames } from '#mcp/capabilities/domain-metadata.ts'
import { adminAuditLogQueryCapability } from './admin-audit-log-query.ts'
import { adminUserCreateCapability } from './admin-user-create.ts'
import { adminUserGetCapability } from './admin-user-get.ts'
import { adminUserListCapability } from './admin-user-list.ts'

Expand All @@ -12,6 +13,7 @@ export const adminDomain = defineDomain({
capabilities: [
adminUserListCapability,
adminUserGetCapability,
adminUserCreateCapability,
adminAuditLogQueryCapability,
],
})
Loading