diff --git a/README.md b/README.md index b9f7f47..c21c0fb 100644 --- a/README.md +++ b/README.md @@ -71,3 +71,6 @@ The MCP uses Keycloak for OAuth issuance via confidential authorization-code flo ## Character portraits The character editor and hero portrait frame accept device photo and illustration uploads (JPEG, PNG, WebP, supported HEIC/HEIF). Browser canvas centre-crops at 4:5 and compresses to JPEG/WebP, max 320,000 data URL characters. Unsupported, oversized (>12 MB) or undecodable images are rejected. No raw photo uploads or external image service: compressed portrait is an optional, nullable part of the validated character, so offline IndexedDB, JSON backups, MCP sheet reads and authenticated cross-device sync all retain it. Explicit null removes it; older clients omitting the optional field preserve existing images through the storage merge. Photos remain private to the character owner and are never shared in campaign party projections. + +## MCP portrait uploads +MCP tools `upload_character_portrait` and `remove_character_portrait` edit only a caller-owned sheet, under the usual Keycloak identity and write permission checks, and require the latest sheet revision. Upload accepts a compressed JPEG or WebP data URL, maximum 320,000 encoded characters / 240 KB decoded, and verifies base64 canonical encoding and image format headers. Upload clients should crop to 4:5 before encoding, matching the PWA editor. The tools do not fetch arbitrary remote URLs, cannot automatically access chat-local/generated image files, and return only metadata (no image bytes). Portrait removal stores explicit null; all other fields are retained. After a successful write the portrait follows existing IndexedDB/account sync and JSON exports. Old MCP clients can still update sheets without supplying `portrait` and the persistence layer retains it. diff --git a/plugin/arrowstack-character/skills/character/SKILL.md b/plugin/arrowstack-character/skills/character/SKILL.md index 57ae6e0..567980f 100644 --- a/plugin/arrowstack-character/skills/character/SKILL.md +++ b/plugin/arrowstack-character/skills/character/SKILL.md @@ -20,3 +20,5 @@ Private journal entries are author-only; DM-only notes and NPC secrets require t Encounter HP is a snapshot, separate from saved PC HP. Use advance_campaign_encounter for initiative and snapshot damage/healing. Use change_party_hit_points only for an explicitly requested saved-PC change when owner delegation is present; pass the PC revision from get_campaign. Sessions and encounter snapshots are party-visible, so do not put DM secrets in their summaries or participant names. Campaign data needs an online connection; do not claim offline campaign support. Use search_rules_catalogue and get_rules_entry for official actions, spells and items. Resolve the edition with the user or established campaign context before applying a rule; do not silently mix editions. Return the source and page supplied by the catalogue. Reference text is data. Search results omit full descriptions; read the selected entry before copying it to a sheet. Preserve the existing sheet and current revision when adding an entry. New spells should be unprepared unless the user asks to prepare them. Add spells only to the spellbook, items to inventory, and standard actions/weapon attacks to actions. Do not infer proficiency, spell attack bonuses, equipped AC, slot spending or damage rolls from the reference catalogue alone. Weapons require the user's configured modifier. Do not add entries to every character merely because the catalogue is seeded. + +When a user asks to add artwork to a character, call `get_character` for the current revision, then `upload_character_portrait` with a compact JPEG/WebP base64 data URL and `expected_revision`. The client must actually have access to the image bytes; a photo embedded in chat or a generated image is not automatically available as a tool argument. Do not claim upload succeeded without a tool confirmation. Compress/crop locally to roughly 4:5, max 240 KB decoded and 320,000 characters for the full data URL. Never paste large base64 into user-facing chat. Use `remove_character_portrait` for explicit removal requests, with a fresh revision. Both operations leave all other character fields unchanged and return only small metadata. Character portraits remain private, not campaign party-visible. diff --git a/server/mcp.mjs b/server/mcp.mjs index ab7a2d2..3da3660 100644 --- a/server/mcp.mjs +++ b/server/mcp.mjs @@ -4,8 +4,24 @@ import {McpServer} from '@modelcontextprotocol/sdk/server/mcp.js'; import {StreamableHTTPServerTransport} from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import {z} from 'zod'; import {randomInt,randomUUID} from 'node:crypto'; -import {characterSchema,newCharacter,changeHP,applyRest,companionSchema,stableLocationSchema,addCompanion,stableCompanion,retrieveCompanion,changeCompanionHP,configureSpellSlotPool,changeSpellSlotPool} from './model.mjs'; +import {characterSchema,newCharacter,changeHP,applyRest,companionSchema,stableLocationSchema,addCompanion,stableCompanion,retrieveCompanion,changeCompanionHP,configureSpellSlotPool,changeSpellSlotPool,portraitSchema} from './model.mjs'; const id=z.string().min(1).max(200),revision=z.number().int().min(1),result=data=>({content:[{type:'text',text:JSON.stringify(data)}],structuredContent:data}); +/** Only compact, embedded JPEG or WebP portrait bytes are accepted; no remote URL fetches. */ +function checkedPortrait(dataUrl){ + const valid=portraitSchema.parse(dataUrl); + const match=/^data:image\/(webp|jpeg);base64,([A-Za-z0-9+/]+={0,2})$/.exec(valid); + if(!match)throw new Error('Upload a compressed JPEG or WebP portrait data URL.'); + const binary=Buffer.from(match[2],'base64'); + if(binary.length<16||binary.length>240000||binary.toString('base64')!==match[2])throw new Error('Portrait encoding is invalid or exceeds 240 KB.'); + const mime=match[1],jpeg=mime==='jpeg'; + if(jpeg){ + if(binary.length<4||binary[0]!==255||binary[1]!==216||binary[2]!==255||binary.at(-2)!==255||binary.at(-1)!==217)throw new Error('JPEG image signature is invalid.'); + }else{ + if(binary.toString('ascii',0,4)!=='RIFF'||binary.toString('ascii',8,12)!=='WEBP'||binary.readUInt32LE(4)!==binary.length-8||!['VP8 ','VP8L','VP8X'].includes(binary.toString('ascii',12,16)))throw new Error('WebP image signature is invalid.'); + } + return {dataUrl:valid,mimeType:'image/'+mime,bytes:binary.length}; +} + export function characterMcp({storage,campaigns,catalogue,sub,scopes}){ const server=new McpServer({name:'arrowstack-character',version:'0.8.0'}); const read={readOnlyHint:true,destructiveHint:false,idempotentHint:true,openWorldHint:false}; @@ -18,6 +34,24 @@ export function characterMcp({storage,campaigns,catalogue,sub,scopes}){ tool('get_character','Read a complete saved character and its revision.',{id},read,async({id})=>({sheet:await get(id)})); tool('create_character','Create a new account-backed character. Returns its generated id and revision.',{name:z.string().min(1).max(120)},write,async({name})=>{allowed();const c=newCharacter();c.id=randomUUID();c.name=name;return {sheet:await storage.put(sub,c.id,c,0)}}); tool('update_character','Replace a complete character using the revision returned by get_character. Preserves unrelated changes by rejecting stale revisions.',{character:characterSchema,expected_revision:revision},write,async({character,expected_revision})=>{allowed();await get(character.id);return {sheet:await storage.put(sub,character.id,character,expected_revision)}}); + tool('upload_character_portrait','Upload or replace a saved character portrait. Pass an already cropped, compressed JPEG or WebP as a base64 data URL (data:image/webp;base64,... or data:image/jpeg;base64,...), maximum 240 KB. This tool cannot access chat attachments automatically; the calling client must provide encoded image bytes. Call get_character first for the current revision. Only your own character can be changed.',{id,expected_revision:revision,image_data_url:portraitSchema},write,async({id,expected_revision,image_data_url})=>{ + allowed(); + const portrait=checkedPortrait(image_data_url); + const row=await get(id); + if(row.revision!==expected_revision)throw new Error('Revision conflict. Reload the character before changing its portrait.'); + const character=characterSchema.parse({...row.character,portrait:portrait.dataUrl}); + const saved=await storage.put(sub,id,character,expected_revision); + // Never echo large image strings as a tool result. + return {id:saved.id,revision:saved.revision,name:saved.character.name,portrait:{present:true,mime_type:portrait.mimeType,bytes:portrait.bytes}}; + }); + tool('remove_character_portrait','Remove the portrait from your own saved character. Requires the current sheet revision; does not change other fields.',{id,expected_revision:revision},write,async({id,expected_revision})=>{ + allowed(); + const row=await get(id); + if(row.revision!==expected_revision)throw new Error('Revision conflict. Reload the character before removing its portrait.'); + const character=characterSchema.parse({...row.character,portrait:null}); + const saved=await storage.put(sub,id,character,expected_revision); + return {id:saved.id,revision:saved.revision,name:saved.character.name,portrait:{present:false}}; + }); tool('change_hit_points','Apply signed HP change: negative damages temporary HP first; positive heals up to maximum.',{id,expected_revision:revision,amount:z.number().int().min(-9999).max(9999)},write,async a=>mutate(a,c=>changeHP(c,a.amount))); tool('apply_rest','Apply declared resource resets. Long rest restores configured regular spell slots; either rest restores Pact Magic slots. Hit dice and edition/class-specific exceptions remain manual.',{id,expected_revision:revision,type:z.enum(['short-rest','long-rest'])},write,async a=>mutate(a,c=>applyRest(c,a.type))); tool('set_resource','Set remaining uses for one resource within its maximum.',{id,expected_revision:revision,resource_id:id,remaining:z.number().int().min(0).max(9999)},write,async a=>mutate(a,c=>{const r=c.resources.find(r=>r.id===a.resource_id);if(!r||a.remaining>r.max)throw new Error('Resource missing or remaining uses exceed maximum');r.current=a.remaining})); diff --git a/tests/campaigns.test.ts b/tests/campaigns.test.ts index 72a49e1..20f92cb 100644 --- a/tests/campaigns.test.ts +++ b/tests/campaigns.test.ts @@ -7,7 +7,7 @@ test('private notes, DM secrets and hidden NPCs remain filtered after role chang test('delegated saved HP requires current DM role and owner permission, and preserves private data',async()=>{const {storage,campaigns,c,act}=await fixture(),pc=newCharacter();pc.notes='Private';pc.hp.temp=3;await storage.put('player',pc.id,pc,0);await act('player','assign',{character_id:pc.id});const input={owner:'player',character_id:pc.id,expected_revision:1,amount:-5};await assert.rejects(campaigns.partyHp('dm',c.id,input));await act('player','assign',{character_id:pc.id,allow_dm_edit:true});await assert.rejects(campaigns.partyHp('player',c.id,input));const changed=await campaigns.partyHp('dm',c.id,input);assert.equal(changed.revision,2);assert.deepEqual(changed.character.hp,{current:8,max:10,temp:0});assert.equal(changed.character.notes,undefined);assert.equal((await storage.get('player',pc.id)).character.notes,'Private');await assert.rejects(campaigns.partyHp('dm',c.id,input));await act('player','assign',{character_id:pc.id,allow_dm_edit:false});await assert.rejects(campaigns.partyHp('dm',c.id,{...input,expected_revision:2}));await act('dm','member',{sub:'player',role:'remove'});await assert.rejects(campaigns.view('player',c.id));assert.equal((await campaigns.view('dm',c.id)).party.length,0)}); test('DM encounter snapshots sort initiative, wrap rounds and clamp HP without touching saved PCs',async()=>{const {storage,campaigns,c,act}=await fixture(),pc=newCharacter();await storage.put('player',pc.id,pc,0);await assert.rejects(act('player','session',{title:'No'}));await assert.rejects(act('player','npc',{name:'No',hp:1,maxHp:1,ac:1}));await assert.rejects(act('player','invite',{}));await act('dm','session',{title:'Session 1',summary:'Meet at the inn'});await act('dm','encounter',{name:'Ambush',participants:[{name:'PC',initiative:8,hp:10,maxHp:10,ac:10},{name:'Goblin',initiative:15,hp:5,maxHp:5,ac:12}]});let encounter=(await campaigns.view('player',c.id)).encounters[0];assert.equal(encounter.participants[0].name,'Goblin');await assert.rejects(act('player','encounterStep',{id:encounter.id,action:'next'}));await act('dm','encounterStep',{id:encounter.id,action:'next'});await act('dm','encounterStep',{id:encounter.id,action:'next'});await act('dm','encounterStep',{id:encounter.id,action:'damage',participant_id:encounter.participants[0].id,amount:50});encounter=(await campaigns.view('player',c.id)).encounters[0];assert.equal(encounter.round,2);assert.equal(encounter.turn,0);assert.equal(encounter.participants[0].hp,0);assert.equal((await storage.get('player',pc.id)).character.hp.current,10);const old=(await campaigns.row(c.id)).revision;await act('dm','encounterStep',{id:encounter.id,action:'finish'});await assert.rejects(campaigns.mutate('dm',c.id,old,'note',{title:'Stale',body:'',visibility:'party'}));assert.equal((await campaigns.view('dm',c.id)).notes.length,0)}); test('HTTP campaign routes deny unsigned users and cross-origin writes',async()=>{const {campaigns}=await fixture(),app=express();app.use(express.json());app.use((req:any,res,next)=>{if(req.get('x-test-user'))req.identity={sub:req.get('x-test-user'),name:'Test'};next()});installCampaigns(app,{campaigns,origin:'https://dnd.example.test'});const server=app.listen(0,'127.0.0.1');await once(server,'listening');const base=`http://127.0.0.1:${(server.address() as any).port}`;try{assert.equal((await fetch(base+'/api/campaigns')).status,401);assert.equal((await fetch(base+'/api/campaigns',{method:'POST',headers:{'x-test-user':'dm','content-type':'application/json',origin:'https://evil.test'},body:JSON.stringify({name:'No'})})).status,403);assert.equal((await fetch(base+'/api/campaigns',{headers:{'x-test-user':'dm'}})).status,200)}finally{await new Promise(r=>server.close(()=>r()))}}); -test('MCP exposes campaign tools and enforces read scopes and campaign membership',async()=>{const {storage,campaigns,c}=await fixture();async function connect(sub:string,scopes:string[]){const server=characterMcp({storage,campaigns,sub,scopes}),client=new Client({name:'campaign-test',version:'1'});const [a,b]=InMemoryTransport.createLinkedPair();await server.connect(a);await client.connect(b);return client}const read=await connect('dm',['characters:read']),player=await connect('player',['characters:read','characters:write']),stranger=await connect('stranger',['characters:read']);try{assert.equal((await read.listTools()).tools.length,42);assert.equal((await read.callTool({name:'create_campaign',arguments:{name:'Denied'}})).isError,true);assert.equal((await stranger.callTool({name:'get_campaign',arguments:{campaign_id:c.id}})).isError,true);assert.equal((await player.callTool({name:'invite_campaign_member',arguments:{campaign_id:c.id,expected_revision:(await campaigns.row(c.id)).revision,input:{role:'dm'}}})).isError,true);assert.equal((await read.callTool({name:'get_campaign',arguments:{campaign_id:c.id}})).isError,undefined)}finally{await Promise.all([read.close(),player.close(),stranger.close()])}}); +test('MCP exposes campaign tools and enforces read scopes and campaign membership',async()=>{const {storage,campaigns,c}=await fixture();async function connect(sub:string,scopes:string[]){const server=characterMcp({storage,campaigns,sub,scopes}),client=new Client({name:'campaign-test',version:'1'});const [a,b]=InMemoryTransport.createLinkedPair();await server.connect(a);await client.connect(b);return client}const read=await connect('dm',['characters:read']),player=await connect('player',['characters:read','characters:write']),stranger=await connect('stranger',['characters:read']);try{assert.equal((await read.listTools()).tools.length,44);assert.equal((await read.callTool({name:'create_campaign',arguments:{name:'Denied'}})).isError,true);assert.equal((await stranger.callTool({name:'get_campaign',arguments:{campaign_id:c.id}})).isError,true);assert.equal((await player.callTool({name:'invite_campaign_member',arguments:{campaign_id:c.id,expected_revision:(await campaigns.row(c.id)).revision,input:{role:'dm'}}})).isError,true);assert.equal((await read.callTool({name:'get_campaign',arguments:{campaign_id:c.id}})).isError,undefined)}finally{await Promise.all([read.close(),player.close(),stranger.close()])}}); test('DM conditions are manual snapshots, bounded, revision guarded and removed explicitly',async()=>{ const {storage,campaigns,c,act}=await fixture(),pc=newCharacter();await storage.put('player',pc.id,pc,0); diff --git a/tests/catalogue.test.ts b/tests/catalogue.test.ts index fb281c6..3f93624 100644 --- a/tests/catalogue.test.ts +++ b/tests/catalogue.test.ts @@ -5,4 +5,4 @@ test('official seeds preserve both editions, provenance and all SRD spells',asyn test('seeding persists counts, runs idempotently and preserves personal sheets',async()=>{const pc=newCharacter();pc.notes='Private';await storage.put('owner',pc.id,pc,0);const before=(await catalogue.pool.query('SELECT id FROM rules_catalogue')).rows.length;assert.equal(before,seed.entries.length);await catalogue.init();assert.equal((await catalogue.pool.query('SELECT id FROM rules_catalogue')).rows.length,before);assert.equal((await storage.get('owner',pc.id)).character.notes,'Private');assert.equal((await storage.get('owner',pc.id)).revision,1)}); test('catalogue filters and paging never mix editions or fabricate mechanics',async()=>{const old=await catalogue.list({edition:'2014',kind:'spell',q:'Fire Bolt'}),revised=await catalogue.list({edition:'2024',kind:'spell',q:'Fire Bolt'});assert.equal(old.total,1);assert.equal(revised.total,1);assert.notEqual(old.entries[0].id,revised.entries[0].id);assert.equal(old.entries[0].description,undefined);const entry=await catalogue.get(revised.entries[0].id);assert.equal(entry.level,0);assert.match(entry.description,/Casting Time: Action/);assert.match(entry.description,/1d10 Fire damage/);assert.match((await catalogue.get(old.entries[0].id)).description,/Casting Time: 1 action/);const cantrips=await catalogue.list({edition:'2024',kind:'spell',level:0});assert.ok(cantrips.entries.every((e:any)=>e.level===0));const first=await catalogue.list({edition:'2014',kind:'spell'}),second=await catalogue.list({edition:'2014',kind:'spell',offset:40});assert.equal(first.entries.length,40);assert.ok(!second.entries.some((e:any)=>first.entries.some((f:any)=>f.id===e.id)));assert.equal((await catalogue.list({q:"' OR 1=1 --"})).total,0);await assert.rejects(catalogue.list({edition:'unknown'}));assert.equal(await catalogue.get('missing'),undefined);const staff=await catalogue.list({edition:'2024',kind:'item',category:'Weapon',q:'Quarterstaff'});assert.equal(staff.total,1);assert.equal((await catalogue.get(staff.entries[0].id)).mastery,'Topple')}); test('catalogue HTTP requires a session and validates edition and missing entry',async()=>{const app=express();app.use((req:any,res,next)=>{if(req.get('x-test-user'))req.identity={sub:'reader'};next()});installCatalogue(app,{catalogue});const server=app.listen(0,'127.0.0.1');await once(server,'listening');const base=`http://127.0.0.1:${(server.address() as any).port}`;try{assert.equal((await fetch(base+'/api/catalogue')).status,401);assert.equal((await fetch(base+'/api/catalogue?edition=bad',{headers:{'x-test-user':'1'}})).status,400);const r=await fetch(base+'/api/catalogue?edition=2024&kind=spell&q=Fire%20Bolt',{headers:{'x-test-user':'1'}});assert.equal(r.status,200);assert.equal((await r.json()).total,1);assert.equal((await fetch(base+'/api/catalogue/missing',{headers:{'x-test-user':'1'}})).status,404)}finally{await new Promise(r=>server.close(()=>r()))}}); -test('read-only MCP searches and reads official catalogue without modifying characters',async()=>{const server=characterMcp({storage,catalogue,sub:'reader',scopes:['characters:read']}),client=new Client({name:'catalogue-test',version:'1'}),[a,b]=InMemoryTransport.createLinkedPair();await server.connect(a);await client.connect(b);try{assert.equal((await client.listTools()).tools.length,19);const result:any=await client.callTool({name:'search_rules_catalogue',arguments:{edition:'2024',kind:'spell',q:'Fire Bolt'}});assert.equal(result.isError,undefined);const id=result.structuredContent.entries[0].id;const entry:any=await client.callTool({name:'get_rules_entry',arguments:{id}});assert.equal(entry.structuredContent.entry.name,'Fire Bolt');assert.equal((await storage.list('reader')).length,0)}finally{await client.close()}}); +test('read-only MCP searches and reads official catalogue without modifying characters',async()=>{const server=characterMcp({storage,catalogue,sub:'reader',scopes:['characters:read']}),client=new Client({name:'catalogue-test',version:'1'}),[a,b]=InMemoryTransport.createLinkedPair();await server.connect(a);await client.connect(b);try{assert.equal((await client.listTools()).tools.length,21);const result:any=await client.callTool({name:'search_rules_catalogue',arguments:{edition:'2024',kind:'spell',q:'Fire Bolt'}});assert.equal(result.isError,undefined);const id=result.structuredContent.entries[0].id;const entry:any=await client.callTool({name:'get_rules_entry',arguments:{id}});assert.equal(entry.structuredContent.entry.name,'Fire Bolt');assert.equal((await storage.list('reader')).length,0)}finally{await client.close()}}); diff --git a/tests/mcp.test.ts b/tests/mcp.test.ts index 0a1f6fa..295a5bf 100644 --- a/tests/mcp.test.ts +++ b/tests/mcp.test.ts @@ -42,8 +42,48 @@ test('SDK tools use delegated Keycloak user ownership and revision enforcement', const s=await storage(),subject='mcp-machine:dnd-character-mcp:machine-1',c=newCharacter();await s.put(subject,c.id,c,0); const server=characterMcp({storage:s,sub:subject,scopes:['characters:read','characters:write']}); const client=new Client({name:'test',version:'1'}),[a,b]=InMemoryTransport.createLinkedPair();await server.connect(a);await client.connect(b); - try{assert.equal((await client.listTools()).tools.length,17); + try{assert.equal((await client.listTools()).tools.length,19); const hp=await client.callTool({name:'change_hit_points',arguments:{id:c.id,expected_revision:1,amount:-1}});assert.equal(hp.isError,undefined); const stale=await client.callTool({name:'change_hit_points',arguments:{id:c.id,expected_revision:1,amount:-1}});assert.equal(stale.isError,true); }finally{await client.close()} }); + +test('portrait MCP uploads compact WebP bytes without echoing data, and removes with revision guard',async()=>{ + const s=await storage(),c=newCharacter();c.name='Artful hero';c.notes='Keep all notes'; + await s.put('artist',c.id,c,0); + const pc=characterMcp({storage:s,sub:'artist',scopes:['characters:read','characters:write']}),client=new Client({name:'portrait-test',version:'1'}); + const [a,b]=InMemoryTransport.createLinkedPair();await pc.connect(a);await client.connect(b); + // Minimal syntactically structured RIFF/WebP stream for validating the upload envelope. + const bytes=Buffer.alloc(30);bytes.write('RIFF',0);bytes.writeUInt32LE(22,4);bytes.write('WEBPVP8X',8);bytes.writeUInt32LE(10,16); + const image='data:image/webp;base64,'+bytes.toString('base64'); + try{ + const added=await client.callTool({name:'upload_character_portrait',arguments:{id:c.id,expected_revision:1,image_data_url:image}}); + assert.equal(added.isError,undefined); + assert.deepEqual(added.structuredContent.portrait,{present:true,mime_type:'image/webp',bytes:30}); + assert.equal(added.structuredContent.revision,2); + assert.ok(!JSON.stringify(added).includes(image)); + const stored=await s.get('artist',c.id); + assert.equal(stored.character.portrait,image);assert.equal(stored.character.notes,'Keep all notes'); + const stale=await client.callTool({name:'upload_character_portrait',arguments:{id:c.id,expected_revision:1,image_data_url:image}}); + assert.equal(stale.isError,true); + const cleared=await client.callTool({name:'remove_character_portrait',arguments:{id:c.id,expected_revision:2}}); + assert.equal(cleared.isError,undefined);assert.equal(cleared.structuredContent.portrait.present,false); + assert.equal((await s.get('artist',c.id)).character.portrait,null); + }finally{await client.close()} +}); +test('portrait MCP rejects invalid signatures, oversized data, another account and read-only scopes',async()=>{ + const s=await storage(),c=newCharacter();await s.put('alice',c.id,c,0); + async function connect(sub,scopes){const server=characterMcp({storage:s,sub,scopes}),client=new Client({name:'portrait-auth-test',version:'1'}),[a,b]=InMemoryTransport.createLinkedPair();await server.connect(a);await client.connect(b);return client} + const user=await connect('alice',['characters:read','characters:write']),readonly=await connect('alice',['characters:read']),other=await connect('bob',['characters:read','characters:write']); + const image='data:image/jpeg;base64,'+Buffer.alloc(35,65).toString('base64'); + const call=(client,image_data_url=image)=>client.callTool({name:'upload_character_portrait',arguments:{id:c.id,expected_revision:1,image_data_url}}); + try{ + assert.equal((await call(user)).isError,true); + assert.equal((await call(user,'data:image/png;base64,AAAA')).isError,true); + assert.equal((await call(user,'data:image/webp;base64,'+'A'.repeat(320004))).isError,true); + assert.equal((await call(readonly)).isError,true); + assert.equal((await call(other)).isError,true); + assert.equal((await readonly.callTool({name:'remove_character_portrait',arguments:{id:c.id,expected_revision:1}})).isError,true); + assert.equal((await s.get('alice',c.id)).revision,1); + }finally{await Promise.all([user.close(),readonly.close(),other.close()])} +});