@atomicmail/agent-skill 0.1.0 → 0.2.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 +56 -0
- package/SKILL.md +67 -187
- package/esm/{skill/scripts/lib/auth.d.ts → lib/src/agent-auth-http.d.ts} +1 -17
- package/esm/lib/src/agent-auth-http.d.ts.map +1 -0
- package/esm/lib/src/agent-auth-http.js +76 -0
- package/esm/{skill/scripts/lib/credentials.d.ts → lib/src/agent-credentials-store.d.ts} +4 -1
- package/esm/lib/src/agent-credentials-store.d.ts.map +1 -0
- package/esm/{skill/scripts/lib/credentials.js → lib/src/agent-credentials-store.js} +28 -8
- package/esm/lib/src/agent-help-content.d.ts +4 -0
- package/esm/lib/src/agent-help-content.d.ts.map +1 -0
- package/esm/lib/src/agent-help-content.js +236 -0
- package/esm/lib/src/agent-jmap.d.ts +49 -0
- package/esm/lib/src/agent-jmap.d.ts.map +1 -0
- package/esm/lib/src/agent-jmap.js +130 -0
- package/esm/lib/src/agent-jwt.d.ts +14 -0
- package/esm/lib/src/agent-jwt.d.ts.map +1 -0
- package/esm/lib/src/agent-jwt.js +29 -0
- package/esm/lib/src/agent-pow.d.ts +5 -0
- package/esm/lib/src/agent-pow.d.ts.map +1 -0
- package/esm/lib/src/agent-pow.js +49 -0
- package/esm/lib/src/agent-session.d.ts +62 -0
- package/esm/lib/src/agent-session.d.ts.map +1 -0
- package/esm/lib/src/agent-session.js +206 -0
- package/esm/lib/src/agent-vars.d.ts +23 -0
- package/esm/lib/src/agent-vars.d.ts.map +1 -0
- package/esm/lib/src/agent-vars.js +65 -0
- package/esm/skill/scripts/cli.d.ts +3 -0
- package/esm/skill/scripts/cli.d.ts.map +1 -0
- package/esm/skill/scripts/cli.js +309 -0
- package/package.json +3 -4
- package/esm/skill/scripts/jmap_request.d.ts +0 -3
- package/esm/skill/scripts/jmap_request.d.ts.map +0 -1
- package/esm/skill/scripts/jmap_request.js +0 -265
- package/esm/skill/scripts/lib/auth.d.ts.map +0 -1
- package/esm/skill/scripts/lib/auth.js +0 -163
- package/esm/skill/scripts/lib/credentials.d.ts.map +0 -1
- package/esm/skill/scripts/signup.d.ts +0 -3
- package/esm/skill/scripts/signup.d.ts.map +0 -1
- package/esm/skill/scripts/signup.js +0 -170
|
@@ -1,265 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// Atomic Mail skill: JMAP request.
|
|
3
|
-
//
|
|
4
|
-
// Sends a JMAP request to api-service using credentials previously written
|
|
5
|
-
// by signup. Auto-rotates session JWT (re-running PoW) and capability JWT
|
|
6
|
-
// when they are within their safety margin of expiry, and writes the rotated
|
|
7
|
-
// tokens back to disk so subsequent invocations stay fresh.
|
|
8
|
-
//
|
|
9
|
-
// JMAP method calls can be supplied two ways:
|
|
10
|
-
// --ops '<json>' inline JSON body
|
|
11
|
-
// --ops-file <path> read JSON body from a file (e.g. saved
|
|
12
|
-
// "fetch_last_100.json" preset)
|
|
13
|
-
//
|
|
14
|
-
// In both cases, the JSON may be either:
|
|
15
|
-
// • an array of methodCalls, e.g. [["Mailbox/get", {...}, "m0"]]
|
|
16
|
-
// • a full envelope { using: [...], methodCalls: [...] }
|
|
17
|
-
import process from "node:process";
|
|
18
|
-
import { parseArgs } from "node:util";
|
|
19
|
-
import { readFile } from "node:fs/promises";
|
|
20
|
-
import { CAPABILITY_SAFETY_MARGIN_MS, fetchCapability, isJwtExpired, performPoWAndSession, SESSION_SAFETY_MARGIN_MS, } from "./lib/auth.js";
|
|
21
|
-
import { defaultFilesFromOutDir, readCredentials, tryReadJwtFile, writeJwtFile, } from "./lib/credentials.js";
|
|
22
|
-
const DEFAULT_USING = [
|
|
23
|
-
"urn:ietf:params:jmap:core",
|
|
24
|
-
"urn:ietf:params:jmap:mail",
|
|
25
|
-
];
|
|
26
|
-
const HELP = `Usage: atomic-mail-jmap [OPTIONS]
|
|
27
|
-
|
|
28
|
-
Send a JMAP request to your Atomic Mail inbox using credentials previously
|
|
29
|
-
written by atomic-mail-signup. Capability and session JWTs are renewed
|
|
30
|
-
automatically when expired.
|
|
31
|
-
|
|
32
|
-
Token sources (defaults are based on --credentials-dir):
|
|
33
|
-
--credentials-dir DIR Directory containing credentials.json,
|
|
34
|
-
session.jwt, capability.jwt. Default: cwd.
|
|
35
|
-
--credentials-file PATH Override path to credentials.json.
|
|
36
|
-
--session-file PATH Override path to session.jwt.
|
|
37
|
-
--capability-file PATH Override path to capability.jwt.
|
|
38
|
-
|
|
39
|
-
Request body (one of these is required):
|
|
40
|
-
--ops JSON Inline JSON: either an array of methodCalls or
|
|
41
|
-
a full { using, methodCalls } object.
|
|
42
|
-
--ops-file PATH Read --ops content from a file. Useful for
|
|
43
|
-
saving reusable presets like 'fetch_last_100.json'.
|
|
44
|
-
--session Fetch JMAP session metadata via
|
|
45
|
-
GET /.well-known/jmap (no body).
|
|
46
|
-
|
|
47
|
-
Other:
|
|
48
|
-
--using LIST Comma-separated capability URNs. Overrides
|
|
49
|
-
defaults: ${DEFAULT_USING.join(",")}.
|
|
50
|
-
Ignored if the JSON envelope already sets 'using'.
|
|
51
|
-
--dry-run Print the resolved request to stdout without
|
|
52
|
-
sending it. Tokens are NOT rotated.
|
|
53
|
-
--help, -h Show this help.
|
|
54
|
-
|
|
55
|
-
Exit codes:
|
|
56
|
-
0 success — server response printed to stdout
|
|
57
|
-
1 network error or HTTP non-2xx response from api-service / auth-service
|
|
58
|
-
2 invalid CLI usage / malformed credentials or ops JSON
|
|
59
|
-
|
|
60
|
-
Examples:
|
|
61
|
-
# Inline ops
|
|
62
|
-
atomic-mail-jmap --ops '[["Mailbox/get", {"accountId":"abc"}, "m0"]]'
|
|
63
|
-
|
|
64
|
-
# Reusable preset file
|
|
65
|
-
echo '[["Email/query",{"accountId":"abc","limit":100,"sort":[{"property":"receivedAt","isAscending":false}]},"q0"]]' \\
|
|
66
|
-
> fetch_last_100.json
|
|
67
|
-
atomic-mail-jmap --ops-file fetch_last_100.json
|
|
68
|
-
|
|
69
|
-
# Session discovery
|
|
70
|
-
atomic-mail-jmap --session
|
|
71
|
-
`;
|
|
72
|
-
function fail(message, code = 1) {
|
|
73
|
-
process.stderr.write(`Error: ${message}\n`);
|
|
74
|
-
if (code === 2)
|
|
75
|
-
process.stderr.write("\nRun with --help for usage.\n");
|
|
76
|
-
process.exit(code);
|
|
77
|
-
}
|
|
78
|
-
function readArgs() {
|
|
79
|
-
let parsed;
|
|
80
|
-
try {
|
|
81
|
-
parsed = parseArgs({
|
|
82
|
-
args: process.argv.slice(2),
|
|
83
|
-
options: {
|
|
84
|
-
"credentials-dir": { type: "string" },
|
|
85
|
-
"credentials-file": { type: "string" },
|
|
86
|
-
"session-file": { type: "string" },
|
|
87
|
-
"capability-file": { type: "string" },
|
|
88
|
-
ops: { type: "string" },
|
|
89
|
-
"ops-file": { type: "string" },
|
|
90
|
-
using: { type: "string" },
|
|
91
|
-
session: { type: "boolean" },
|
|
92
|
-
"dry-run": { type: "boolean" },
|
|
93
|
-
help: { type: "boolean", short: "h" },
|
|
94
|
-
},
|
|
95
|
-
strict: true,
|
|
96
|
-
allowPositionals: false,
|
|
97
|
-
});
|
|
98
|
-
}
|
|
99
|
-
catch (err) {
|
|
100
|
-
fail(err.message, 2);
|
|
101
|
-
}
|
|
102
|
-
if (parsed.values.help) {
|
|
103
|
-
process.stdout.write(HELP);
|
|
104
|
-
process.exit(0);
|
|
105
|
-
}
|
|
106
|
-
const dir = parsed.values["credentials-dir"] ?? ".";
|
|
107
|
-
const defaults = defaultFilesFromOutDir(dir);
|
|
108
|
-
const credentialsFile = parsed.values["credentials-file"] ??
|
|
109
|
-
defaults.credentialsFile;
|
|
110
|
-
const sessionFile = parsed.values["session-file"] ??
|
|
111
|
-
defaults.sessionFile;
|
|
112
|
-
const capabilityFile = parsed.values["capability-file"] ??
|
|
113
|
-
defaults.capabilityFile;
|
|
114
|
-
const ops = parsed.values.ops;
|
|
115
|
-
const opsFile = parsed.values["ops-file"];
|
|
116
|
-
const sessionMode = parsed.values.session === true;
|
|
117
|
-
if (sessionMode && (ops || opsFile)) {
|
|
118
|
-
fail("--session cannot be combined with --ops or --ops-file.", 2);
|
|
119
|
-
}
|
|
120
|
-
if (!sessionMode && !ops && !opsFile) {
|
|
121
|
-
fail("Provide one of --ops, --ops-file, or --session.", 2);
|
|
122
|
-
}
|
|
123
|
-
if (ops && opsFile) {
|
|
124
|
-
fail("--ops and --ops-file are mutually exclusive.", 2);
|
|
125
|
-
}
|
|
126
|
-
const usingFlag = parsed.values.using;
|
|
127
|
-
const using = usingFlag
|
|
128
|
-
? usingFlag.split(",").map((s) => s.trim()).filter((s) => s.length > 0)
|
|
129
|
-
: undefined;
|
|
130
|
-
return {
|
|
131
|
-
credentialsFile,
|
|
132
|
-
sessionFile,
|
|
133
|
-
capabilityFile,
|
|
134
|
-
ops,
|
|
135
|
-
opsFile,
|
|
136
|
-
using,
|
|
137
|
-
sessionMode,
|
|
138
|
-
dryRun: parsed.values["dry-run"] === true,
|
|
139
|
-
};
|
|
140
|
-
}
|
|
141
|
-
async function loadOps(args) {
|
|
142
|
-
if (args.sessionMode)
|
|
143
|
-
return undefined;
|
|
144
|
-
let raw;
|
|
145
|
-
if (args.ops !== undefined) {
|
|
146
|
-
raw = args.ops;
|
|
147
|
-
}
|
|
148
|
-
else if (args.opsFile) {
|
|
149
|
-
try {
|
|
150
|
-
raw = await readFile(args.opsFile, "utf-8");
|
|
151
|
-
}
|
|
152
|
-
catch (err) {
|
|
153
|
-
fail(`Could not read --ops-file '${args.opsFile}': ${err.message}`, 2);
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
else {
|
|
157
|
-
fail("Internal: ops not provided", 2);
|
|
158
|
-
}
|
|
159
|
-
let value;
|
|
160
|
-
try {
|
|
161
|
-
value = JSON.parse(raw);
|
|
162
|
-
}
|
|
163
|
-
catch (err) {
|
|
164
|
-
fail(`Invalid JSON in JMAP ops: ${err.message}`, 2);
|
|
165
|
-
}
|
|
166
|
-
let using = args.using ?? DEFAULT_USING;
|
|
167
|
-
let methodCalls;
|
|
168
|
-
if (Array.isArray(value)) {
|
|
169
|
-
methodCalls = value;
|
|
170
|
-
}
|
|
171
|
-
else if (value !== null &&
|
|
172
|
-
typeof value === "object" &&
|
|
173
|
-
Array.isArray(value.methodCalls)) {
|
|
174
|
-
const obj = value;
|
|
175
|
-
methodCalls = obj.methodCalls;
|
|
176
|
-
// Inline `using` only takes precedence if user did not pass --using.
|
|
177
|
-
if (Array.isArray(obj.using) && !args.using) {
|
|
178
|
-
using = obj.using.filter((u) => typeof u === "string");
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
else {
|
|
182
|
-
fail('JMAP ops must be a methodCalls array, e.g. [["Mailbox/get",{...},"m0"]], ' +
|
|
183
|
-
"or an object with a 'methodCalls' array.", 2);
|
|
184
|
-
}
|
|
185
|
-
return { using, methodCalls };
|
|
186
|
-
}
|
|
187
|
-
async function ensureFreshTokens(args) {
|
|
188
|
-
const credentials = await readCredentials(args.credentialsFile);
|
|
189
|
-
let sessionJWT = await tryReadJwtFile(args.sessionFile);
|
|
190
|
-
let capabilityJWT = await tryReadJwtFile(args.capabilityFile);
|
|
191
|
-
const sessionExpired = !sessionJWT ||
|
|
192
|
-
isJwtExpired(sessionJWT, SESSION_SAFETY_MARGIN_MS);
|
|
193
|
-
if (sessionExpired) {
|
|
194
|
-
process.stderr.write("Session JWT missing or near expiry — re-running PoW.\n");
|
|
195
|
-
const result = await performPoWAndSession({
|
|
196
|
-
authUrl: credentials.authUrl,
|
|
197
|
-
scryptSalt: credentials.scryptSalt,
|
|
198
|
-
apiKey: credentials.apiKey,
|
|
199
|
-
});
|
|
200
|
-
sessionJWT = result.sessionJWT;
|
|
201
|
-
// After session rotation, the previous capability JWT is also no longer
|
|
202
|
-
// recognized by auth-service (challenge-status cache reset), so force
|
|
203
|
-
// a refresh below.
|
|
204
|
-
capabilityJWT = undefined;
|
|
205
|
-
await writeJwtFile(args.sessionFile, sessionJWT);
|
|
206
|
-
}
|
|
207
|
-
const capabilityExpired = !capabilityJWT ||
|
|
208
|
-
isJwtExpired(capabilityJWT, CAPABILITY_SAFETY_MARGIN_MS);
|
|
209
|
-
if (capabilityExpired) {
|
|
210
|
-
process.stderr.write("Capability JWT missing or near expiry — refreshing.\n");
|
|
211
|
-
capabilityJWT = await fetchCapability(credentials.authUrl, sessionJWT);
|
|
212
|
-
await writeJwtFile(args.capabilityFile, capabilityJWT);
|
|
213
|
-
}
|
|
214
|
-
return {
|
|
215
|
-
capabilityJWT: capabilityJWT,
|
|
216
|
-
sessionJWT: sessionJWT,
|
|
217
|
-
credentials,
|
|
218
|
-
};
|
|
219
|
-
}
|
|
220
|
-
async function main() {
|
|
221
|
-
const args = readArgs();
|
|
222
|
-
const envelope = await loadOps(args);
|
|
223
|
-
if (args.dryRun) {
|
|
224
|
-
const credentials = await readCredentials(args.credentialsFile);
|
|
225
|
-
const url = args.sessionMode
|
|
226
|
-
? `${credentials.apiUrl}/.well-known/jmap`
|
|
227
|
-
: `${credentials.apiUrl}/jmap/`;
|
|
228
|
-
const dryRun = {
|
|
229
|
-
method: args.sessionMode ? "GET" : "POST",
|
|
230
|
-
url,
|
|
231
|
-
headers: { Authorization: "Bearer <capability-jwt>" },
|
|
232
|
-
body: envelope ?? null,
|
|
233
|
-
};
|
|
234
|
-
process.stdout.write(JSON.stringify(dryRun, null, 2) + "\n");
|
|
235
|
-
return;
|
|
236
|
-
}
|
|
237
|
-
const tokens = await ensureFreshTokens(args);
|
|
238
|
-
if (args.sessionMode) {
|
|
239
|
-
const res = await fetch(`${tokens.credentials.apiUrl}/.well-known/jmap`, {
|
|
240
|
-
headers: { Authorization: `Bearer ${tokens.capabilityJWT}` },
|
|
241
|
-
});
|
|
242
|
-
const body = await res.text();
|
|
243
|
-
if (!res.ok) {
|
|
244
|
-
fail(`JMAP session fetch failed (HTTP ${res.status}): ${body}`);
|
|
245
|
-
}
|
|
246
|
-
process.stdout.write(body.endsWith("\n") ? body : body + "\n");
|
|
247
|
-
return;
|
|
248
|
-
}
|
|
249
|
-
const res = await fetch(`${tokens.credentials.apiUrl}/jmap/`, {
|
|
250
|
-
method: "POST",
|
|
251
|
-
headers: {
|
|
252
|
-
"Content-Type": "application/json",
|
|
253
|
-
Authorization: `Bearer ${tokens.capabilityJWT}`,
|
|
254
|
-
},
|
|
255
|
-
body: JSON.stringify(envelope),
|
|
256
|
-
});
|
|
257
|
-
const body = await res.text();
|
|
258
|
-
if (!res.ok) {
|
|
259
|
-
fail(`JMAP request failed (HTTP ${res.status}): ${body}`);
|
|
260
|
-
}
|
|
261
|
-
process.stdout.write(body.endsWith("\n") ? body : body + "\n");
|
|
262
|
-
}
|
|
263
|
-
main().catch((err) => {
|
|
264
|
-
fail(err instanceof Error ? err.message : String(err));
|
|
265
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../../../../src/skill/scripts/lib/auth.ts"],"names":[],"mappings":"AAWA,eAAO,MAAM,cAAc,QAAqB,CAAC;AACjD,eAAO,MAAM,iBAAiB,QAAgB,CAAC;AAI/C,eAAO,MAAM,2BAA2B,QAAS,CAAC;AAClD,eAAO,MAAM,wBAAwB,QAAS,CAAC;AAE/C,MAAM,WAAW,UAAU;IACzB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB;AAED,wBAAgB,gBAAgB,CAAC,CAAC,GAAG,UAAU,EAAE,GAAG,EAAE,MAAM,GAAG,CAAC,CAc/D;AAED,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAQnE;AAsCD,wBAAsB,QAAQ,CAC5B,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,EAClB,IAAI,EAAE,MAAM,EACZ,UAAU,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,GACnC,OAAO,CAAC;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC,CAU5C;AAiCD,wBAAsB,cAAc,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC;IAC7D,YAAY,EAAE,MAAM,CAAC;IACrB,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;CACpB,CAAC,CAqBD;AAED,MAAM,WAAW,eAAe;IAC9B,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,wBAAsB,eAAe,CACnC,OAAO,EAAE,MAAM,EACf,IAAI,EAAE;IACJ,YAAY,EAAE,MAAM,CAAC;IACrB,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB,GACA,OAAO,CAAC,eAAe,CAAC,CAS1B;AAED,wBAAsB,eAAe,CACnC,OAAO,EAAE,MAAM,EACf,UAAU,EAAE,MAAM,GACjB,OAAO,CAAC,MAAM,CAAC,CAUjB;AAED,MAAM,WAAW,eAAe;IAC9B,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,aAAa,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;CACzC;AAKD,wBAAsB,oBAAoB,CACxC,KAAK,EAAE,eAAe,GACrB,OAAO,CAAC,eAAe,CAAC,CAgB1B"}
|
|
@@ -1,163 +0,0 @@
|
|
|
1
|
-
// PoW + auth-service client used by signup.ts and jmap_request.ts.
|
|
2
|
-
//
|
|
3
|
-
// Mirrors services/auth-service/src/crypto.ts and the protocol implemented
|
|
4
|
-
// in services/mcp-server-local/src/auth-session.ts. Uses only `node:` imports
|
|
5
|
-
// so the same source runs on Deno, Node, and Bun unchanged.
|
|
6
|
-
import { scrypt } from "node:crypto";
|
|
7
|
-
const SCRYPT_PARAMS = { N: 16384, r: 8, p: 1 };
|
|
8
|
-
const POW_HASH_BYTES = 64;
|
|
9
|
-
export const SESSION_TTL_MS = 4 * 60 * 60 * 1000;
|
|
10
|
-
export const CAPABILITY_TTL_MS = 2 * 60 * 1000;
|
|
11
|
-
// Refresh window: how close to expiry before we re-issue. The server enforces
|
|
12
|
-
// strict expiry, so the client must rotate before the wire-side TTL elapses.
|
|
13
|
-
export const CAPABILITY_SAFETY_MARGIN_MS = 20_000;
|
|
14
|
-
export const SESSION_SAFETY_MARGIN_MS = 60_000;
|
|
15
|
-
export function decodeJwtPayload(jwt) {
|
|
16
|
-
const parts = jwt.split(".");
|
|
17
|
-
if (parts.length < 2) {
|
|
18
|
-
throw new Error("Malformed JWT: expected at least 2 dot-separated segments.");
|
|
19
|
-
}
|
|
20
|
-
const payloadB64Url = parts[1];
|
|
21
|
-
const padLen = (4 - (payloadB64Url.length % 4)) % 4;
|
|
22
|
-
const base64 = payloadB64Url
|
|
23
|
-
.replace(/-/g, "+")
|
|
24
|
-
.replace(/_/g, "/")
|
|
25
|
-
.padEnd(payloadB64Url.length + padLen, "=");
|
|
26
|
-
return JSON.parse(atob(base64));
|
|
27
|
-
}
|
|
28
|
-
export function isJwtExpired(jwt, marginMs) {
|
|
29
|
-
try {
|
|
30
|
-
const { exp } = decodeJwtPayload(jwt);
|
|
31
|
-
if (typeof exp !== "number")
|
|
32
|
-
return true;
|
|
33
|
-
return Date.now() >= exp * 1000 - marginMs;
|
|
34
|
-
}
|
|
35
|
-
catch {
|
|
36
|
-
return true;
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
function bytesToHex(bytes) {
|
|
40
|
-
let hex = "";
|
|
41
|
-
for (let i = 0; i < bytes.length; i++) {
|
|
42
|
-
hex += bytes[i].toString(16).padStart(2, "0");
|
|
43
|
-
}
|
|
44
|
-
return hex;
|
|
45
|
-
}
|
|
46
|
-
function hasLeadingZeroBits(hash, bits) {
|
|
47
|
-
if (bits > hash.length * 8)
|
|
48
|
-
return false;
|
|
49
|
-
const fullBytes = Math.floor(bits / 8);
|
|
50
|
-
const remainingBits = bits % 8;
|
|
51
|
-
for (let i = 0; i < fullBytes; i++) {
|
|
52
|
-
if (hash[i] !== 0)
|
|
53
|
-
return false;
|
|
54
|
-
}
|
|
55
|
-
if (remainingBits > 0) {
|
|
56
|
-
const mask = (0xff << (8 - remainingBits)) & 0xff;
|
|
57
|
-
if ((hash[fullBytes] & mask) !== 0)
|
|
58
|
-
return false;
|
|
59
|
-
}
|
|
60
|
-
return true;
|
|
61
|
-
}
|
|
62
|
-
// auth-service passes the SCRYPT_SALT_HEX string directly as the `salt`
|
|
63
|
-
// argument to node:crypto's scrypt (i.e. the UTF-8 bytes of the hex string,
|
|
64
|
-
// NOT the decoded hex bytes). We mirror that exactly so client and server
|
|
65
|
-
// derive the same digest.
|
|
66
|
-
function scryptHash(data, salt) {
|
|
67
|
-
const bytes = new TextEncoder().encode(data);
|
|
68
|
-
return new Promise((resolve, reject) => {
|
|
69
|
-
scrypt(bytes, salt, POW_HASH_BYTES, SCRYPT_PARAMS, (err, derived) => {
|
|
70
|
-
if (err)
|
|
71
|
-
return reject(err);
|
|
72
|
-
resolve(new Uint8Array(derived));
|
|
73
|
-
});
|
|
74
|
-
});
|
|
75
|
-
}
|
|
76
|
-
export async function solvePow(challenge, difficulty, salt, onProgress) {
|
|
77
|
-
let nonce = 0n;
|
|
78
|
-
while (true) {
|
|
79
|
-
const digest = await scryptHash(`${challenge}:${nonce}`, salt);
|
|
80
|
-
if (hasLeadingZeroBits(digest, difficulty)) {
|
|
81
|
-
return { powHex: bytesToHex(digest), nonce: nonce.toString() };
|
|
82
|
-
}
|
|
83
|
-
nonce++;
|
|
84
|
-
if (onProgress && nonce % 64n === 0n)
|
|
85
|
-
onProgress(nonce);
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
async function postJson(url, body, headers = {}) {
|
|
89
|
-
const res = await fetch(url, {
|
|
90
|
-
method: "POST",
|
|
91
|
-
headers: {
|
|
92
|
-
...(body ? { "Content-Type": "application/json" } : {}),
|
|
93
|
-
...headers,
|
|
94
|
-
},
|
|
95
|
-
body: body ? JSON.stringify(body) : undefined,
|
|
96
|
-
});
|
|
97
|
-
const text = await res.text();
|
|
98
|
-
const path = (() => {
|
|
99
|
-
try {
|
|
100
|
-
return new URL(url).pathname;
|
|
101
|
-
}
|
|
102
|
-
catch {
|
|
103
|
-
return url;
|
|
104
|
-
}
|
|
105
|
-
})();
|
|
106
|
-
if (!res.ok) {
|
|
107
|
-
throw new Error(`auth-service ${path} returned ${res.status}: ${text}`);
|
|
108
|
-
}
|
|
109
|
-
try {
|
|
110
|
-
return JSON.parse(text);
|
|
111
|
-
}
|
|
112
|
-
catch {
|
|
113
|
-
throw new Error(`auth-service ${path} returned non-JSON body: ${text}`);
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
export async function fetchChallenge(authUrl) {
|
|
117
|
-
const data = await postJson(`${authUrl}/api/v1/challenge`, undefined);
|
|
118
|
-
if (typeof data.challengeJWT !== "string") {
|
|
119
|
-
throw new Error("Challenge response missing challengeJWT.");
|
|
120
|
-
}
|
|
121
|
-
const payload = decodeJwtPayload(data.challengeJWT);
|
|
122
|
-
if (typeof payload.jti !== "string" ||
|
|
123
|
-
typeof payload.difficulty !== "number") {
|
|
124
|
-
throw new Error("Challenge JWT payload malformed (missing jti or difficulty).");
|
|
125
|
-
}
|
|
126
|
-
return {
|
|
127
|
-
challengeJWT: data.challengeJWT,
|
|
128
|
-
challenge: payload.jti,
|
|
129
|
-
difficulty: payload.difficulty,
|
|
130
|
-
};
|
|
131
|
-
}
|
|
132
|
-
export async function exchangeSession(authUrl, body) {
|
|
133
|
-
const data = await postJson(`${authUrl}/api/v1/session`, { ...body });
|
|
134
|
-
if (typeof data.sessionJWT !== "string") {
|
|
135
|
-
throw new Error("Session response missing sessionJWT.");
|
|
136
|
-
}
|
|
137
|
-
return {
|
|
138
|
-
sessionJWT: data.sessionJWT,
|
|
139
|
-
apiKey: typeof data.apiKey === "string" ? data.apiKey : undefined,
|
|
140
|
-
};
|
|
141
|
-
}
|
|
142
|
-
export async function fetchCapability(authUrl, sessionJWT) {
|
|
143
|
-
const data = await postJson(`${authUrl}/api/v1/capability`, undefined, { Authorization: `Bearer ${sessionJWT}` });
|
|
144
|
-
if (typeof data.capabilityJWT !== "string") {
|
|
145
|
-
throw new Error("Capability response missing capabilityJWT.");
|
|
146
|
-
}
|
|
147
|
-
return data.capabilityJWT;
|
|
148
|
-
}
|
|
149
|
-
// One round trip of: /challenge -> solve PoW -> /session.
|
|
150
|
-
// `apiKey` (login) and `username` (signup) are mutually exclusive; the caller
|
|
151
|
-
// must enforce that before calling.
|
|
152
|
-
export async function performPoWAndSession(input) {
|
|
153
|
-
const { authUrl, scryptSalt } = input;
|
|
154
|
-
const { challengeJWT, challenge, difficulty } = await fetchChallenge(authUrl);
|
|
155
|
-
const { powHex, nonce } = await solvePow(challenge, difficulty, scryptSalt, input.onPowProgress);
|
|
156
|
-
return exchangeSession(authUrl, {
|
|
157
|
-
challengeJWT,
|
|
158
|
-
powHex,
|
|
159
|
-
nonce,
|
|
160
|
-
apiKey: input.apiKey,
|
|
161
|
-
username: input.username,
|
|
162
|
-
});
|
|
163
|
-
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"credentials.d.ts","sourceRoot":"","sources":["../../../../src/skill/scripts/lib/credentials.ts"],"names":[],"mappings":"AAUA,MAAM,WAAW,WAAW;IAC1B,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,UAAU;IACzB,eAAe,EAAE,MAAM,CAAC;IACxB,WAAW,EAAE,MAAM,CAAC;IACpB,cAAc,EAAE,MAAM,CAAC;CACxB;AAED,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,MAAM,GAAG,UAAU,CAOjE;AAMD,wBAAsB,gBAAgB,CACpC,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,WAAW,GACjB,OAAO,CAAC,IAAI,CAAC,CAGf;AAED,wBAAsB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,CAAC,CAiCxE;AAED,wBAAsB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAG3E;AAED,wBAAsB,cAAc,CAClC,IAAI,EAAE,MAAM,GACX,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAO7B"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"signup.d.ts","sourceRoot":"","sources":["../../../src/skill/scripts/signup.ts"],"names":[],"mappings":""}
|
|
@@ -1,170 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// Atomic Mail skill: signup / login.
|
|
3
|
-
//
|
|
4
|
-
// Performs the full PoW handshake against auth-service:
|
|
5
|
-
// POST /api/v1/challenge -> { challengeJWT }
|
|
6
|
-
// solve scrypt PoW
|
|
7
|
-
// POST /api/v1/session -> { sessionJWT, apiKey? }
|
|
8
|
-
// POST /api/v1/capability -> { capabilityJWT }
|
|
9
|
-
//
|
|
10
|
-
// Writes three files into --out-dir:
|
|
11
|
-
// credentials.json stable account info + server config
|
|
12
|
-
// session.jwt 4h TTL, rotated by jmap_request when expired
|
|
13
|
-
// capability.jwt 2m TTL, rotated by jmap_request before each request
|
|
14
|
-
import process from "node:process";
|
|
15
|
-
import { parseArgs } from "node:util";
|
|
16
|
-
import { DEFAULT_POW_SCRYPT_SALT_HEX } from "../../lib/src/consts.js";
|
|
17
|
-
import { decodeJwtPayload, fetchCapability, performPoWAndSession, } from "./lib/auth.js";
|
|
18
|
-
import { defaultFilesFromOutDir, writeCredentials, writeJwtFile, } from "./lib/credentials.js";
|
|
19
|
-
const HELP = `Usage: atomic-mail-signup [OPTIONS]
|
|
20
|
-
|
|
21
|
-
Register a new Atomic Mail account, or log in with an existing API key.
|
|
22
|
-
Persists credentials to disk so subsequent atomic-mail-jmap invocations
|
|
23
|
-
work without re-authentication.
|
|
24
|
-
|
|
25
|
-
Options:
|
|
26
|
-
--auth-url URL Auth-service base URL.
|
|
27
|
-
[env: ATOMIC_MAIL_AUTH_URL]
|
|
28
|
-
--api-url URL API-service base URL (stored for jmap_request).
|
|
29
|
-
[env: ATOMIC_MAIL_API_URL]
|
|
30
|
-
--scrypt-salt SALT Override PoW scrypt salt (defaults to the deployment
|
|
31
|
-
constant). [env: ATOMIC_MAIL_SCRYPT_SALT]
|
|
32
|
-
--username NAME Register a NEW account with this username. The server
|
|
33
|
-
returns a freshly minted API key. Mutually exclusive
|
|
34
|
-
with --api-key.
|
|
35
|
-
--api-key KEY Log in with an existing API key. Mutually exclusive
|
|
36
|
-
with --username.
|
|
37
|
-
--out-dir DIR Directory to write credential files into.
|
|
38
|
-
Default: current working directory.
|
|
39
|
-
--quiet Suppress progress messages on stderr.
|
|
40
|
-
--help, -h Show this help.
|
|
41
|
-
|
|
42
|
-
Output files (in --out-dir):
|
|
43
|
-
credentials.json { apiKey, inboxId, authUrl, apiUrl, scryptSalt }
|
|
44
|
-
session.jwt Session JWT (4h TTL).
|
|
45
|
-
capability.jwt Capability JWT (2m TTL).
|
|
46
|
-
|
|
47
|
-
Exit codes:
|
|
48
|
-
0 success
|
|
49
|
-
1 network error, server rejection, or unexpected response shape
|
|
50
|
-
2 invalid CLI usage (missing/conflicting flags)
|
|
51
|
-
|
|
52
|
-
Examples:
|
|
53
|
-
atomic-mail-signup --auth-url https://auth.example.com \\
|
|
54
|
-
--api-url https://api.example.com \\
|
|
55
|
-
--username alice
|
|
56
|
-
atomic-mail-signup --api-key 11111111-2222-3333-4444-555555555555
|
|
57
|
-
`;
|
|
58
|
-
function fail(message, code = 1) {
|
|
59
|
-
process.stderr.write(`Error: ${message}\n`);
|
|
60
|
-
if (code === 2)
|
|
61
|
-
process.stderr.write("\nRun with --help for usage.\n");
|
|
62
|
-
process.exit(code);
|
|
63
|
-
}
|
|
64
|
-
function readArgs() {
|
|
65
|
-
let parsed;
|
|
66
|
-
try {
|
|
67
|
-
parsed = parseArgs({
|
|
68
|
-
args: process.argv.slice(2),
|
|
69
|
-
options: {
|
|
70
|
-
"auth-url": { type: "string" },
|
|
71
|
-
"api-url": { type: "string" },
|
|
72
|
-
"scrypt-salt": { type: "string" },
|
|
73
|
-
username: { type: "string" },
|
|
74
|
-
"api-key": { type: "string" },
|
|
75
|
-
"out-dir": { type: "string" },
|
|
76
|
-
quiet: { type: "boolean" },
|
|
77
|
-
help: { type: "boolean", short: "h" },
|
|
78
|
-
},
|
|
79
|
-
strict: true,
|
|
80
|
-
allowPositionals: false,
|
|
81
|
-
});
|
|
82
|
-
}
|
|
83
|
-
catch (err) {
|
|
84
|
-
fail(err.message, 2);
|
|
85
|
-
}
|
|
86
|
-
if (parsed.values.help) {
|
|
87
|
-
process.stdout.write(HELP);
|
|
88
|
-
process.exit(0);
|
|
89
|
-
}
|
|
90
|
-
const env = process.env;
|
|
91
|
-
const authUrl = parsed.values["auth-url"] ??
|
|
92
|
-
env.ATOMIC_MAIL_AUTH_URL ?? "";
|
|
93
|
-
const apiUrl = parsed.values["api-url"] ??
|
|
94
|
-
env.ATOMIC_MAIL_API_URL ?? "";
|
|
95
|
-
const scryptSalt = parsed.values["scrypt-salt"] ??
|
|
96
|
-
env.ATOMIC_MAIL_SCRYPT_SALT ?? DEFAULT_POW_SCRYPT_SALT_HEX;
|
|
97
|
-
const outDir = parsed.values["out-dir"] ?? ".";
|
|
98
|
-
if (!authUrl) {
|
|
99
|
-
fail("--auth-url is required (or set ATOMIC_MAIL_AUTH_URL).", 2);
|
|
100
|
-
}
|
|
101
|
-
if (!apiUrl) {
|
|
102
|
-
fail("--api-url is required (or set ATOMIC_MAIL_API_URL).", 2);
|
|
103
|
-
}
|
|
104
|
-
const username = parsed.values.username;
|
|
105
|
-
const apiKey = parsed.values["api-key"];
|
|
106
|
-
if (!!username === !!apiKey) {
|
|
107
|
-
fail("Provide exactly one of --username (new account) or --api-key (login).", 2);
|
|
108
|
-
}
|
|
109
|
-
return {
|
|
110
|
-
authUrl: authUrl.replace(/\/+$/, ""),
|
|
111
|
-
apiUrl: apiUrl.replace(/\/+$/, ""),
|
|
112
|
-
scryptSalt,
|
|
113
|
-
username,
|
|
114
|
-
apiKey,
|
|
115
|
-
outDir,
|
|
116
|
-
quiet: parsed.values.quiet === true,
|
|
117
|
-
};
|
|
118
|
-
}
|
|
119
|
-
async function main() {
|
|
120
|
-
const args = readArgs();
|
|
121
|
-
const files = defaultFilesFromOutDir(args.outDir);
|
|
122
|
-
const log = (msg) => {
|
|
123
|
-
if (!args.quiet)
|
|
124
|
-
process.stderr.write(msg + "\n");
|
|
125
|
-
};
|
|
126
|
-
log(args.username
|
|
127
|
-
? `Registering new account "${args.username}"...`
|
|
128
|
-
: `Logging in with existing API key...`);
|
|
129
|
-
log(`Solving PoW + exchanging session against ${args.authUrl}...`);
|
|
130
|
-
const session = await performPoWAndSession({
|
|
131
|
-
authUrl: args.authUrl,
|
|
132
|
-
scryptSalt: args.scryptSalt,
|
|
133
|
-
username: args.username,
|
|
134
|
-
apiKey: args.apiKey,
|
|
135
|
-
});
|
|
136
|
-
const apiKey = session.apiKey ?? args.apiKey;
|
|
137
|
-
if (!apiKey) {
|
|
138
|
-
fail("Session response did not include an apiKey and none was provided. " +
|
|
139
|
-
"This indicates a server bug.");
|
|
140
|
-
}
|
|
141
|
-
log(`Fetching initial capability JWT...`);
|
|
142
|
-
const capabilityJWT = await fetchCapability(args.authUrl, session.sessionJWT);
|
|
143
|
-
const claims = decodeJwtPayload(capabilityJWT);
|
|
144
|
-
const inboxId = claims.inboxId;
|
|
145
|
-
if (typeof inboxId !== "string" || inboxId.length === 0) {
|
|
146
|
-
fail("Capability JWT did not contain an inboxId claim.");
|
|
147
|
-
}
|
|
148
|
-
await writeCredentials(files.credentialsFile, {
|
|
149
|
-
apiKey,
|
|
150
|
-
inboxId,
|
|
151
|
-
authUrl: args.authUrl,
|
|
152
|
-
apiUrl: args.apiUrl,
|
|
153
|
-
scryptSalt: args.scryptSalt,
|
|
154
|
-
});
|
|
155
|
-
await writeJwtFile(files.sessionFile, session.sessionJWT);
|
|
156
|
-
await writeJwtFile(files.capabilityFile, capabilityJWT);
|
|
157
|
-
log(`Wrote ${files.credentialsFile}`);
|
|
158
|
-
log(`Wrote ${files.sessionFile}`);
|
|
159
|
-
log(`Wrote ${files.capabilityFile}`);
|
|
160
|
-
process.stdout.write(JSON.stringify({
|
|
161
|
-
inboxId,
|
|
162
|
-
apiKey,
|
|
163
|
-
credentialsFile: files.credentialsFile,
|
|
164
|
-
sessionFile: files.sessionFile,
|
|
165
|
-
capabilityFile: files.capabilityFile,
|
|
166
|
-
}, null, 2) + "\n");
|
|
167
|
-
}
|
|
168
|
-
main().catch((err) => {
|
|
169
|
-
fail(err instanceof Error ? err.message : String(err));
|
|
170
|
-
});
|