Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@

> **Update (2026-11-05)**: Core registers operator-level read tools beside the per-player ones, so an admin's assistant can answer questions about the whole operation: `payments.flow_daily` and `financial.summary` (analytics, `analytics:view`), `withdrawals.queue` (`withdrawal:view`) and `players.deposit_compare` (`transaction:view`). Each bounds its date range or windows, caps every list it returns and says so in its description, and returns none of the free text an admin or a check wrote (a review reason, a note, a decision reason), no address, hash or provider reference, and no name or email. A tool output names a player only by player id, under a key named `playerId` or `playerIds` or ending in `PlayerId` or `PlayerIds`: a consumer pseudonymises players by key name, so a player id under another key, or a user id under any key, would reach the model as it is. `players.deposit_compare` reads its population through `PLAYER_SEGMENT_READER`, refuses a segment above 20 000 players, and resolves each page of player ids to user ids in one query with the optional `IDENTITY_READER.getUserIdsByPlayerIds`. The iam catalog gains `agent-chat:use`, which an agent hub checks before an admin may use its back-office assistant; core enforces nothing on it, grants it where it grants `mcp-access`, and every tool call is still checked against the asking admin's grant for that tool.

> **Update (2026-11-06)**: Compliance adds three read tools under `compliance:view`: `rg.player_state` (one player's responsible-gambling standing as `RG_STANDING_READER` reports it - exclusions in force, open flags, limits with a raise still waiting, and priced money windows - with no reason and no admin), `rg.flags_queue` (the open monitoring flags across all players, counted per type, the oldest by player id, without the flag's detail) and `kyc.queue` (each player's latest verification per tier waiting for a decision or a resubmission, the oldest pending by player id, without a decision reason, document or risk signal). The two queues join the player profile through its read-only schema, so they name a player by player id with no identity lookup per row. `rg.player_state` resolves the standing reader per call, so a kernel built without play eligibility still serves compliance's other tools.

## Context

An agent hub (a premium package outside this repo) runs models that read platform data and
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,18 @@ import { Container, ModuleRegistryImpl, type CoreTokenCatalog } from '@openora/c
import compliancePlugin from '../plugin.js';

describe('compliance agent surface registration', () => {
it('registers kyc.status and request_enhanced_kyc within the kernel contract rules', async () => {
it('registers its read tools and request_enhanced_kyc within the kernel contract rules', async () => {
const registry = new ModuleRegistryImpl<CoreTokenCatalog>(new Container());
registry.setOwner('compliance');

await compliancePlugin.register(registry);

expect(registry.mcp.getTools().map((tool) => tool.contract.id)).toEqual(['kyc.status']);
expect(registry.mcp.getTools().map((tool) => tool.contract.id)).toEqual([
'kyc.status',
'rg.player_state',
'rg.flags_queue',
'kyc.queue',
]);
expect(registry.actions.getAll().map((action) => action.contract.id)).toEqual([
'request_enhanced_kyc',
]);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { describe, it, expect } from 'vitest';
import * as z from 'zod';
import {
KycQueueInputSchema,
RgFlagsQueueInputSchema,
RgPlayerStateInputSchema,
} from '../contract/index.js';

const PLAYER_ID = '11111111-1111-4111-8111-111111111111';

describe('rg.player_state input', () => {
const parse = (input: unknown) => z.safeParse(RgPlayerStateInputSchema, input);

it('defaults to the 7, 30 and 90 day windows', () => {
expect(parse({ playerId: PLAYER_ID })).toEqual({
success: true,
data: { playerId: PLAYER_ID, windowDays: [7, 30, 90] },
});
});

it('takes 1 to 4 windows of 1 to 365 days, and a player id only', () => {
expect(parse({ playerId: PLAYER_ID, windowDays: ['1', 365] }).success).toBe(true);
expect(parse({ playerId: PLAYER_ID, windowDays: [] }).success).toBe(false);
expect(parse({ playerId: PLAYER_ID, windowDays: [1, 2, 3, 4, 5] }).success).toBe(false);
expect(parse({ playerId: PLAYER_ID, windowDays: [366] }).success).toBe(false);
expect(parse({ playerId: 'p_abcdefghijklmnop' }).success).toBe(false);
});
});

describe('rg.flags_queue and kyc.queue input', () => {
it('default to the 10 oldest and take a limit from 1 to 20', () => {
expect(z.safeParse(RgFlagsQueueInputSchema, {})).toEqual({
success: true,
data: { limit: 10 },
});
expect(z.safeParse(KycQueueInputSchema, {})).toEqual({ success: true, data: { limit: 10 } });
expect(z.safeParse(RgFlagsQueueInputSchema, { limit: '20' }).success).toBe(true);
expect(z.safeParse(RgFlagsQueueInputSchema, { limit: 21 }).success).toBe(false);
expect(z.safeParse(KycQueueInputSchema, { limit: 0 }).success).toBe(false);
});

it('narrow only by a flag type or a tier they know', () => {
expect(z.safeParse(RgFlagsQueueInputSchema, { flagType: 'session_time' }).success).toBe(true);
expect(z.safeParse(RgFlagsQueueInputSchema, { flagType: 'chasing_losses' }).success).toBe(
false,
);
expect(z.safeParse(KycQueueInputSchema, { tier: 'advanced' }).success).toBe(true);
expect(z.safeParse(KycQueueInputSchema, { tier: 'premium' }).success).toBe(false);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from 'vitest';
import { randomUUID } from 'node:crypto';
import { sql } from 'drizzle-orm';
import {
Container,
DRIZZLE,
EVENT_BUS,
ModuleRegistryImpl,
findOneOrThrow,
type CoreTokenCatalog,
} from '@openora/core/server';
import {
AUDIT_WRITER,
IDENTITY_READER,
KYC_ADAPTER,
KYC_STATUS_WRITER,
type KycAdapter,
type KycStatus,
type KycStatusWriter,
type KycTier,
type RunContext,
} from '@openora/core/contracts';
import { createMcpKernel, createTestDb, type TestDb } from '@openora/core/testing';
import { player } from '@openora/core/pam/schema/profile';
import { migrate as migrateProfile } from '@openora/core/pam/migrate/profile';
import { makeAuditWriter, makeEventBus, makeIdentityReader, mock } from '../../testing/mock.js';
import { migrate } from '../migrate.js';
import { kycVerification, rgFlag } from '../schema/index.js';
import compliancePlugin from '../plugin.js';

let db: TestDb;

const HOUR_MS = 60 * 60 * 1000;
const hoursAgo = (hours: number) => new Date(Date.now() - hours * HOUR_MS);
const DECISION_REASON = 'name on the ID does not match the account';

function bootKernel() {
const container = new Container<CoreTokenCatalog>();
container.register(DRIZZLE, () => db.drizzle);
container.register(EVENT_BUS, () => makeEventBus());
container.register(AUDIT_WRITER, () => makeAuditWriter());
container.register(IDENTITY_READER, () => makeIdentityReader());
container.register(KYC_ADAPTER, () => mock<KycAdapter>({}));
container.register(KYC_STATUS_WRITER, () =>
mock<KycStatusWriter>({ setStatus: vi.fn(async () => null) }),
);
const registry = new ModuleRegistryImpl<CoreTokenCatalog>(container);
registry.setOwner('compliance');
compliancePlugin.register(registry);
return createMcpKernel({
tools: registry.mcp.getTools(),
actions: registry.actions.getAll(),
container,
authorize: async () => 'allowed',
audit: makeAuditWriter(),
});
}

const runContext = (): RunContext => ({
runId: randomUUID(),
actor: { kind: 'agent', agentId: randomUUID(), agentVersion: 1, onBehalfOf: randomUUID() },
catalogVersion: 'test',
correlationId: 'compliance-queue-tools',
});

async function seedPlayer() {
return findOneOrThrow(
await db.drizzle.db.insert(player).values({ userId: randomUUID() }).returning(),
new Error('seedPlayer: query returned no row'),
);
}

async function seedFlag(values: Partial<typeof rgFlag.$inferInsert> & { userId: string }) {
return findOneOrThrow(
await db.drizzle.db
.insert(rgFlag)
.values({
flagType: 'limit_threshold',
limitType: 'deposit',
detail: { trigger: 'free text that must stay out', kind: 'self_exclusion' },
...values,
})
.returning(),
new Error('seedFlag: query returned no row'),
);
}

async function seedVerification(userId: string, tier: KycTier, status: KycStatus, createdAt: Date) {
return findOneOrThrow(
await db.drizzle.db
.insert(kycVerification)
.values({
userId,
provider: 'vendor',
referenceId: `ref-${randomUUID()}`,
tier,
status,
documentTypes: ['passport'],
decisionReason: DECISION_REASON,
triggeredBy: 'submission',
submittedAt: createdAt,
createdAt,
})
.returning(),
new Error('seedVerification: query returned no row'),
);
}

beforeAll(async () => {
db = await createTestDb([migrate, migrateProfile]);
});

afterAll(async () => {
await db.drop();
});

beforeEach(async () => {
await db.drizzle.db.execute(
sql`TRUNCATE ${rgFlag}, ${kycVerification}, ${player} RESTART IDENTITY CASCADE`,
);
});

describe('rg.flags_queue through the MCP kernel (real PG)', () => {
it('counts the active flags by type and lists the oldest by player id, with no flag detail', async () => {
const kernel = bootKernel();
const [oldest, newer] = [await seedPlayer(), await seedPlayer()];
const first = await seedFlag({ userId: oldest.userId, flaggedAt: hoursAgo(30) });
const second = await seedFlag({
userId: newer.userId,
flagType: 'session_time',
limitType: null,
flaggedAt: hoursAgo(2),
});
await seedFlag({ userId: newer.userId, status: 'cleared', flaggedAt: hoursAgo(400) });

const result = await kernel.invokeTool('rg.flags_queue', { limit: 5 }, runContext());

expect(result).toEqual({
ok: true,
output: {
activeCount: 2,
activeByType: [
{ flagType: 'limit_threshold', count: 1 },
{ flagType: 'session_time', count: 1 },
],
flaggedLast7Days: 2,
oldest: [
{
flagId: first.id,
playerId: oldest.id,
flagType: 'limit_threshold',
limitType: 'deposit',
flaggedAt: first.flaggedAt.toISOString(),
waitHours: expect.closeTo(30, 0),
},
{
flagId: second.id,
playerId: newer.id,
flagType: 'session_time',
limitType: null,
flaggedAt: second.flaggedAt.toISOString(),
waitHours: expect.closeTo(2, 0),
},
],
},
});
const served = JSON.stringify(result);
expect(served).not.toContain('free text');
expect(served).not.toContain(oldest.userId);
});

it('narrows the oldest to one flag type, and refuses a limit out of bounds', async () => {
const kernel = bootKernel();
const row = await seedPlayer();
await seedFlag({ userId: row.userId, flaggedAt: hoursAgo(10) });
const session = await seedFlag({
userId: row.userId,
flagType: 'session_time',
limitType: null,
flaggedAt: hoursAgo(5),
});

const narrowed = await kernel.invokeTool(
'rg.flags_queue',
{ flagType: 'session_time' },
runContext(),
);
expect(narrowed).toMatchObject({ ok: true, output: { activeCount: 2 } });
expect(narrowed).toMatchObject({ ok: true, output: { oldest: [{ flagId: session.id }] } });

expect(await kernel.invokeTool('rg.flags_queue', { limit: 21 }, runContext())).toMatchObject({
ok: false,
error: 'invalid_input',
});
});
});

describe('kyc.queue through the MCP kernel (real PG)', () => {
it("counts each player's latest verification per tier and lists the oldest pending ones", async () => {
const kernel = bootKernel();
const [superseded, waiting, resubmitting] = [
await seedPlayer(),
await seedPlayer(),
await seedPlayer(),
];
// An older pending attempt the player's later verification superseded waits for nothing.
await seedVerification(superseded.userId, 'basic', 'pending', hoursAgo(100));
await seedVerification(superseded.userId, 'basic', 'approved', hoursAgo(50));
const advanced = await seedVerification(waiting.userId, 'advanced', 'pending', hoursAgo(20));
const basic = await seedVerification(waiting.userId, 'basic', 'pending', hoursAgo(10));
await seedVerification(resubmitting.userId, 'basic', 'resubmission_requested', hoursAgo(5));

const result = await kernel.invokeTool('kyc.queue', {}, runContext());

expect(result).toEqual({
ok: true,
output: {
pendingCount: 2,
resubmissionRequestedCount: 1,
byTier: [
{ tier: 'basic', pending: 1, resubmissionRequested: 1 },
{ tier: 'advanced', pending: 1, resubmissionRequested: 0 },
],
avgPendingWaitHours: expect.closeTo(15, 0),
oldest: [
{
verificationId: advanced.id,
playerId: waiting.id,
tier: 'advanced',
status: 'pending',
triggeredBy: 'submission',
documentTypes: ['passport'],
submittedAt: advanced.submittedAt.toISOString(),
waitHours: expect.closeTo(20, 0),
},
expect.objectContaining({ verificationId: basic.id, tier: 'basic' }),
],
},
});
expect(JSON.stringify(result)).not.toContain(DECISION_REASON);
});

it('narrows the oldest pending to one tier', async () => {
const kernel = bootKernel();
const row = await seedPlayer();
await seedVerification(row.userId, 'advanced', 'pending', hoursAgo(20));
const basic = await seedVerification(row.userId, 'basic', 'pending', hoursAgo(10));

const result = await kernel.invokeTool('kyc.queue', { tier: 'basic', limit: 1 }, runContext());

expect(result).toMatchObject({ ok: true, output: { oldest: [{ verificationId: basic.id }] } });
expect(result).toMatchObject({ ok: true, output: { pendingCount: 2 } });
});
});
Loading
Loading