actas 0.8.0 → 0.9.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/README.md +4 -3
- package/client/accounts-server.mjs +3 -3
- package/client/identity.mjs +5 -1
- package/client/phone.mjs +86 -0
- package/client/public-cli.mjs +3 -1
- package/client/vault.mjs +2 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -11,10 +11,11 @@ the token from your scoped ActAs connection in the Authorization bearer header,
|
|
|
11
11
|
saved through your client's protected credential settings. Do not put the token
|
|
12
12
|
in the endpoint URL, a prompt, or source control. OAuth is not offered.
|
|
13
13
|
|
|
14
|
-
The remote endpoint shares the
|
|
14
|
+
The remote endpoint shares the stdio tool schemas and applies the same API
|
|
15
15
|
permissions and revocation checks on every call. Browser flows still need a
|
|
16
|
-
local browser adapter. Private verification handles and
|
|
17
|
-
consumers
|
|
16
|
+
local browser adapter. Private verification handles (`actas_inbox_wait_for`) and
|
|
17
|
+
registered executable consumers (`actas_account_use`) are not listed remotely;
|
|
18
|
+
use the local client below.
|
|
18
19
|
|
|
19
20
|
Stored passwords and TOTP codes are withheld by default. To return them remotely,
|
|
20
21
|
explicitly configure `ActAs-MCP-Secret-Output: allow` as an additional header.
|
|
@@ -3,7 +3,7 @@ import {z} from 'zod';
|
|
|
3
3
|
|
|
4
4
|
const ref=z.string().regex(/^[A-Za-z0-9_-]{1,80}$/),path=z.string().regex(/^\/(?:[A-Za-z0-9_-]+\/)*[A-Za-z0-9_-]+$/).max(512);
|
|
5
5
|
const select={accountRef:ref};
|
|
6
|
-
export function createAccountsMcp(runtime){
|
|
6
|
+
export function createAccountsMcp(runtime,{omit=[]}={}){
|
|
7
7
|
const server=new McpServer({name:'actas-accounts',version:'0.1.0'},{maxToolInputElements:300});
|
|
8
8
|
const definitions=[
|
|
9
9
|
['actas_identity_create','Create an identity with a permanently reserved chosen email name (management). inbox:true also allocates its inbox.',{name:z.string().min(1).max(80),localPart:z.string().min(3).max(40).optional(),domain:z.string().min(3).max(253).optional(),inbox:z.boolean().optional()}],
|
|
@@ -38,9 +38,9 @@ export function createAccountsMcp(runtime){
|
|
|
38
38
|
['actas_account_revoke','Revoke future ActAs account use and pending handles (owner/management).',select],
|
|
39
39
|
['actas_inbox_wait_for','Wait for a received verification message. Returns a private value handle; never the code/link.',{emailId:z.string().min(1).max(160),sender:z.string().email().max(254),subject:z.string().min(1).max(200),after:z.string().datetime(),linkOrigin:z.string().url().optional(),linkPath:z.string().max(1024).optional(),codePattern:z.string().max(30).optional(),timeoutMs:z.number().int().min(0).max(30000).optional()}]
|
|
40
40
|
];
|
|
41
|
-
for(const [name,description,shape] of definitions)server.registerTool(name,{description,inputSchema:z.object(shape).strict()},async args=>{
|
|
41
|
+
for(const [name,description,shape] of definitions.filter(([name])=>!omit.includes(name)))server.registerTool(name,{description,inputSchema:z.object(shape).strict()},async args=>{
|
|
42
42
|
try{const result=await runtime.invoke(name,args);return {content:[{type:'text',text:JSON.stringify(result)}]};}
|
|
43
|
-
catch{return {isError:true,content:[{type:'text',text:'ActAs account operation failed. Check account status and permissions before retrying; no automatic retry was made.'}]};}
|
|
43
|
+
catch(error){return {isError:true,content:[{type:'text',text:typeof error?.actasRefusal==='string'?error.actasRefusal+' No automatic retry was made.':'ActAs account operation failed. Check account status and permissions before retrying; no automatic retry was made.'}]};}
|
|
44
44
|
});
|
|
45
45
|
return server;
|
|
46
46
|
}
|
package/client/identity.mjs
CHANGED
|
@@ -22,7 +22,11 @@ export async function readIdentityConnection(file=path()){return readConnection(
|
|
|
22
22
|
export async function identityRequest(connection,route,method='GET',body,extraHeaders={}){
|
|
23
23
|
let response;
|
|
24
24
|
try{response=await fetch(connection.apiUrl+'/api/v1'+route,{method,redirect:'error',headers:{Origin:connection.apiUrl,Authorization:'Bearer '+connection.token,'Content-Type':'application/json',...extraHeaders},signal:AbortSignal.timeout(Math.max(15000,Math.min(75000,(body?.timeoutMs??0)+15000))),...(body===undefined?{}:{body:JSON.stringify(body)})});}catch{throw Error('ActAs could not be reached. No automatic retry was made.');}
|
|
25
|
-
if(!response.ok)
|
|
25
|
+
if(!response.ok){
|
|
26
|
+
const error=Error(response.status===401?'Access expired or was revoked. Reconnect through your workspace.':response.status===403?'This connection does not permit that action.':`ActAs request failed (${response.status}).`);
|
|
27
|
+
if(response.status===403){const message=(await response.json().catch(()=>null))?.error?.message;if(typeof message==='string'){error.message=message;error.actasRefusal=message;}}
|
|
28
|
+
throw error;
|
|
29
|
+
}
|
|
26
30
|
try{return (await response.json()).data;}catch{throw Error('Invalid ActAs response.');}
|
|
27
31
|
}
|
|
28
32
|
export async function connectIdentity(file){
|
package/client/phone.mjs
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import {identityRequest,readIdentityConnection} from './identity.mjs';
|
|
2
|
+
|
|
3
|
+
// Phones this connection may use and the texts each has passed on. Shows only what GET /phones and
|
|
4
|
+
// GET /phones/:id/messages already return to the credential. The number is the one on record: a person
|
|
5
|
+
// typed it when the phone was added, and the phone app does not report its own.
|
|
6
|
+
export const phoneHelp=`Phones
|
|
7
|
+
actas phone Phones this connection may use: number on record, connected or not, last heard from
|
|
8
|
+
actas phone texts [<phone-id>] [--since <time>] Latest texts: sender, time sent, time ActAs got it, code
|
|
9
|
+
actas phone check [<phone-id>] < number.txt Does a number match the number on record? Answers match or no match
|
|
10
|
+
The number on record is what a person typed when the phone was added; the phone does
|
|
11
|
+
not report its own number. A text that arrives is the proof that the number is right.
|
|
12
|
+
Connected means the ActAs Phone app is paired with that phone. The phone id may be left
|
|
13
|
+
out when the connection has one phone. Texts are kept 7 days. Listing phones needs the
|
|
14
|
+
"See names" permission. Reading texts needs "Read SMS codes"; without "See names", name
|
|
15
|
+
the phone id. A text's words appear only with "Read forwarded SMS".
|
|
16
|
+
`;
|
|
17
|
+
const DOCS='https://actas.dev/docs#phone-forwarding';
|
|
18
|
+
const PAIR=`A person signed in to the workspace opens Phones, chooses Pair phone and scans the code with the ActAs Phone app on a dedicated Android phone, then turns forwarding on in the app. Guide: ${DOCS}`;
|
|
19
|
+
const NUMBER_NOTE='The number on record is what a person typed when the phone was added; the phone does not report its own number. A text that arrives is the proof that it is right.';
|
|
20
|
+
// Country calling codes are prefix-free: 1 and 7 stand alone, these have two digits, the rest three.
|
|
21
|
+
const TWO=new Set('20 27 30 31 32 33 34 36 39 40 41 43 44 45 46 47 48 49 51 52 53 54 55 56 57 58 60 61 62 63 64 65 66 81 82 84 86 90 91 92 93 94 95 98'.split(' '));
|
|
22
|
+
export const callingCode=number=>/^\+[1-9]\d{6,14}$/.test(number??'')?'+'+(number[1]==='1'||number[1]==='7'?number[1]:TWO.has(number.slice(1,3))?number.slice(1,3):number.slice(1,4)):null;
|
|
23
|
+
const iso=ms=>Number.isFinite(ms)&&ms>0?new Date(ms).toISOString():null;
|
|
24
|
+
const show=phone=>({id:phone.id,label:phone.label,numberOnRecord:phone.number||null,countryCode:callingCode(phone.number),country:phone.country||null,
|
|
25
|
+
connected:Boolean(phone.forwarding),device:phone.forwarding?.deviceName??null,pairedAt:iso(phone.forwarding?.pairedAt),lastHeardAt:iso(phone.forwarding?.lastSeenAt),
|
|
26
|
+
...(phone.forwarding?{}:{note:'No phone app is paired with this phone, so no text can arrive. '+PAIR})});
|
|
27
|
+
// Listing phones needs "See names" (resources:read). A connection with only "Read SMS codes" can still read
|
|
28
|
+
// a phone's texts by its id, which is all the texts route asks for.
|
|
29
|
+
const NO_LIST='This connection may not list phones: it lacks the "See names" permission (resources:read). If it holds "Read SMS codes" for a phone it can still read that phone\'s texts by id: actas phone texts <phone-id>. The workspace owner can find the id on the Phones page, or add "See names" to this connection.';
|
|
30
|
+
async function list(connection){
|
|
31
|
+
let phones;
|
|
32
|
+
try{phones=await identityRequest(connection,'/phones');}
|
|
33
|
+
catch(error){if(/resources:read/.test(error.actasRefusal??''))throw Object.assign(Error(NO_LIST),{noList:true});throw error;}
|
|
34
|
+
if(!Array.isArray(phones))throw Error('Invalid ActAs response.');
|
|
35
|
+
return phones;
|
|
36
|
+
}
|
|
37
|
+
async function pick(connection,id,texts=false){
|
|
38
|
+
let phones;
|
|
39
|
+
try{phones=await list(connection);}
|
|
40
|
+
catch(error){if(texts&&id&&error.noList)return {id,unlisted:true};throw error;}
|
|
41
|
+
if(id)return phones.find(p=>p.id===id)??{id,unlisted:true};
|
|
42
|
+
if(!phones.length)throw Error('This connection can see no phone. If the workspace has one, ask its owner to give this connection access to it. To add one: '+PAIR);
|
|
43
|
+
if(phones.length>1)throw Error('This connection can see '+phones.length+' phones. Name one: run actas phone for their ids.');
|
|
44
|
+
return phones[0];
|
|
45
|
+
}
|
|
46
|
+
async function stdin(){let input='';for await(const chunk of process.stdin){input+=chunk;if(input.length>200)throw Error('Send one phone number on stdin.');}return input;}
|
|
47
|
+
|
|
48
|
+
export async function runPhoneCommand(args){
|
|
49
|
+
const [command,action,...rest]=args;
|
|
50
|
+
if(command!=='phone')return false;
|
|
51
|
+
try{
|
|
52
|
+
const connection=await readIdentityConnection();
|
|
53
|
+
let result;
|
|
54
|
+
if(action===undefined){
|
|
55
|
+
const phones=await list(connection);
|
|
56
|
+
result=phones.length?{phones:phones.map(show),note:NUMBER_NOTE}
|
|
57
|
+
:{phones:[],message:'This connection can see no phone. If the workspace has one, ask its owner to give this connection access to it. To add one: '+PAIR};
|
|
58
|
+
}else if(action==='texts'){
|
|
59
|
+
const id=rest[0]&&!rest[0].startsWith('--')?rest.shift():null;
|
|
60
|
+
if(rest.length&&(rest.length!==2||rest[0]!=='--since'))throw Error('Use actas phone texts [<phone-id>] [--since <time>].');
|
|
61
|
+
const since=rest.length?Date.parse(rest[1]):0;
|
|
62
|
+
if(!Number.isFinite(since)||since<0)throw Error('Give --since a time such as 2026-11-12T05:00:00Z.');
|
|
63
|
+
const phone=await pick(connection,id,true);
|
|
64
|
+
const texts=await identityRequest(connection,'/phones/'+encodeURIComponent(phone.id)+'/messages'+(since?'?since='+since:''));
|
|
65
|
+
if(!Array.isArray(texts))throw Error('Invalid ActAs response.');
|
|
66
|
+
const connected=phone.forwarding===undefined?undefined:Boolean(phone.forwarding);
|
|
67
|
+
result={phone:{id:phone.id,...(phone.label?{label:phone.label}:{}),...(connected===undefined?{}:{connected,lastHeardAt:iso(phone.forwarding?.lastSeenAt)})},
|
|
68
|
+
// sentAt is the text's own time as the phone reports it; reachedActasAt is when ActAs stored it.
|
|
69
|
+
texts:texts.map(t=>({from:t.from,sentAt:iso(t.receivedAt),reachedActasAt:iso(t.storedAt),secondsToReachActas:Number.isFinite(t.storedAt)&&Number.isFinite(t.receivedAt)?Math.round((t.storedAt-t.receivedAt)/100)/10:null,code:t.code??null,...('body' in t?{body:t.body,untrusted:true}:{})})),
|
|
70
|
+
...(texts.length?{}:{message:connected===false?'No phone app is paired with this phone, so no text can arrive. '+PAIR
|
|
71
|
+
:'No text from this phone '+(since?'since '+new Date(since).toISOString():'in the last 7 days')+'. A text that reaches the phone reaches ActAs within seconds. If one was expected, it was not sent to this phone: check the number and country code on the sender\'s form against the number on record (actas phone).'})};
|
|
72
|
+
}else if(action==='check'&&rest.length<=1){
|
|
73
|
+
const phone=await pick(connection,rest[0]);
|
|
74
|
+
if(phone.unlisted)throw Error('This connection cannot see that phone. Run actas phone for the ones it can.');
|
|
75
|
+
if(!phone.number)throw Error('No number is on record for this phone.');
|
|
76
|
+
const raw=(await stdin()).trim().replace(/[\s().-]/g,'');
|
|
77
|
+
if(!/^\+?\d{4,15}$/.test(raw))throw Error('Send one phone number on stdin, digits with an optional leading +.');
|
|
78
|
+
const given=raw.replace('+',''),record=phone.number.replace('+',''),match=given===record;
|
|
79
|
+
// Never echoes either number: the answer is match or no match, plus the record's country code.
|
|
80
|
+
result={phone:{id:phone.id,label:phone.label},match,countryCodeOnRecord:callingCode(phone.number),
|
|
81
|
+
...(!match&&!raw.startsWith('+')&&record.endsWith(given)&&record.length-given.length<=3?{note:'The digits match the number on record without its country code.'}:{})};
|
|
82
|
+
}else throw Error('Use actas phone, actas phone texts [<phone-id>] [--since <time>] or actas phone check [<phone-id>].');
|
|
83
|
+
process.stdout.write(JSON.stringify(result,null,2)+'\n');
|
|
84
|
+
}catch(error){process.stderr.write(JSON.stringify({status:'failed',message:error.message})+'\n');process.exitCode=1;}
|
|
85
|
+
return true;
|
|
86
|
+
}
|
package/client/public-cli.mjs
CHANGED
|
@@ -5,6 +5,8 @@ import {runManagementCommand,managementHelp} from './management.mjs';
|
|
|
5
5
|
if(await runManagementCommand(process.argv.slice(2)))process.exit(process.exitCode||0);
|
|
6
6
|
import {runVaultCommand,vaultHelp} from './vault.mjs';
|
|
7
7
|
if(await runVaultCommand(process.argv.slice(2)))process.exit(process.exitCode||0);
|
|
8
|
+
import {runPhoneCommand,phoneHelp} from './phone.mjs';
|
|
9
|
+
if(await runPhoneCommand(process.argv.slice(2)))process.exit(process.exitCode||0);
|
|
8
10
|
import {runIdentityCommand,identityHelp} from './identity.mjs';
|
|
9
11
|
if(await runIdentityCommand(process.argv.slice(2)))process.exit(process.exitCode||0);
|
|
10
12
|
import {configureAccount} from './configure.mjs';
|
|
@@ -17,7 +19,7 @@ import {externalExecutor} from './external-caller.mjs';
|
|
|
17
19
|
const [action,url,...args]=process.argv.slice(2);
|
|
18
20
|
if(action==='configure'){try{if(!url||args.length)throw new Error('Use actas configure <connection.json>.');console.log(JSON.stringify(await configureAccount(url)));}catch(e){console.error(JSON.stringify({status:'failed',message:e.message}));process.exitCode=1;}process.exit(process.exitCode||0);}
|
|
19
21
|
if(!action||['help','--help','-h'].includes(action)){
|
|
20
|
-
console.log(registryHelp+"\n"+managementHelp+"\n"+identityHelp+"\n"+vaultHelp);
|
|
22
|
+
console.log(registryHelp+"\n"+managementHelp+"\n"+identityHelp+"\n"+phoneHelp+"\n"+vaultHelp);
|
|
21
23
|
}else try{
|
|
22
24
|
if(!['signup','login'].includes(action)||!url||args.length%2)throw new Error('Use actas signup <url> or actas login <url>.');
|
|
23
25
|
const flags={};for(let i=0;i<args.length;i+=2){if(!['--account','--continue'].includes(args[i])||flags[args[i]]||!args[i+1])throw new Error('Unknown or repeated option.');flags[args[i]]=args[i+1];}
|
package/client/vault.mjs
CHANGED
|
@@ -21,7 +21,7 @@ export const vaultHelp=`Workspace vault (exact and folder grants; no values in c
|
|
|
21
21
|
actas script add /path/script
|
|
22
22
|
actas script check /path/script
|
|
23
23
|
actas grant list
|
|
24
|
-
actas grant add <client|identity>:ID /path <read|write|reveal|run|totp:code> [--folder]
|
|
24
|
+
actas grant add <client|identity>:ID /path <read|write|reveal|run|totp:code|session:use> [--folder]
|
|
25
25
|
actas grant revoke ID
|
|
26
26
|
Connected agents and management automation use ACTAS_CONNECTION_FILE. Paths default to ACTAS_VAULT_PATH
|
|
27
27
|
or /shared for run and env sync. For owner grant management use a
|
|
@@ -120,7 +120,7 @@ export async function runVaultCommand(args){
|
|
|
120
120
|
else if(cmd==='card'&&sub==='status'||cmd==='script'&&sub==='check'){if(rest.length!==1)fail();result=await requestVault(ctx,'/read',{path:validPath(rest[0]),action:cmd==='script'?'run':'read'});}
|
|
121
121
|
else if(cmd==='script'&&sub==='add'){if(rest.length!==1)fail();result=await requestVault(ctx,'/set',{path:validPath(rest[0]),kind:'script'});}
|
|
122
122
|
else if(cmd==='grant'&&sub==='list'){if(rest.length)fail();const query=new URLSearchParams({ownerId:ctx.scope.ownerId,...(ctx.scope.productId?{productId:ctx.scope.productId}:{})});result=await requestVault(ctx,'/grants?'+query,undefined,'GET');}
|
|
123
|
-
else if(cmd==='grant'&&sub==='add'){if(rest.length!==3&&!(rest.length===4&&rest[3]==='--folder'))fail();const m=/^(client|identity):(.+)$/.exec(rest[0]);if(!m||!['read','write','reveal','run','totp:code'].includes(rest[2]))fail();result=await requestVault(ctx,'/grants',{principalKind:m[1],principalId:m[2],path:validPath(rest[1]),action:rest[2],folder:rest[3]==='--folder'});}
|
|
123
|
+
else if(cmd==='grant'&&sub==='add'){if(rest.length!==3&&!(rest.length===4&&rest[3]==='--folder'))fail();const m=/^(client|identity):(.+)$/.exec(rest[0]);if(!m||!['read','write','reveal','run','totp:code','session:use'].includes(rest[2]))fail();result=await requestVault(ctx,'/grants',{principalKind:m[1],principalId:m[2],path:validPath(rest[1]),action:rest[2],folder:rest[3]==='--folder'});}
|
|
124
124
|
else if(cmd==='grant'&&sub==='revoke'){if(rest.length!==1)fail();result=await requestVault(ctx,'/grants/'+encodeURIComponent(rest[0]),undefined,'DELETE');}
|
|
125
125
|
else fail();
|
|
126
126
|
}
|