@atomicmail/agent-skill 0.1.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/SKILL.md +242 -0
- package/esm/lib/src/consts.d.ts +13 -0
- package/esm/lib/src/consts.d.ts.map +1 -0
- package/esm/lib/src/consts.js +12 -0
- package/esm/package.json +3 -0
- package/esm/skill/scripts/jmap_request.d.ts +3 -0
- package/esm/skill/scripts/jmap_request.d.ts.map +1 -0
- package/esm/skill/scripts/jmap_request.js +265 -0
- package/esm/skill/scripts/lib/auth.d.ts +42 -0
- package/esm/skill/scripts/lib/auth.d.ts.map +1 -0
- package/esm/skill/scripts/lib/auth.js +163 -0
- package/esm/skill/scripts/lib/credentials.d.ts +18 -0
- package/esm/skill/scripts/lib/credentials.d.ts.map +1 -0
- package/esm/skill/scripts/lib/credentials.js +66 -0
- package/esm/skill/scripts/signup.d.ts +3 -0
- package/esm/skill/scripts/signup.d.ts.map +1 -0
- package/esm/skill/scripts/signup.js +170 -0
- package/package.json +40 -0
package/SKILL.md
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: atomic-mail
|
|
3
|
+
description: Read and write email through the Atomic Mail ESP from an AI agent. Handles the proof-of-work authentication and JMAP protocol so the agent only needs to think in JMAP method calls. Use when the user asks to register an Atomic Mail inbox, list mailboxes, fetch emails, send email, or otherwise programmatically interact with their Atomic Mail account.
|
|
4
|
+
license: MIT
|
|
5
|
+
compatibility: Requires Deno 2.0+ to run scripts directly, or Node 20+ / Bun 1.1+ via `npx @atomic-mail/agent-skill` after publishing. Needs network access to the configured Atomic Mail auth-service and api-service.
|
|
6
|
+
metadata:
|
|
7
|
+
author: atomic-mail
|
|
8
|
+
version: "0.1.0"
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# Atomic Mail
|
|
12
|
+
|
|
13
|
+
Atomic Mail is an AI-agent-first email service provider (ESP) that exposes a
|
|
14
|
+
programmable inbox over JMAP. It uses a custom proof-of-work (PoW) signup flow
|
|
15
|
+
plus three short-lived JWTs (challenge -> session -> capability). This skill
|
|
16
|
+
hides all of that behind two CLI scripts so the agent can focus on JMAP.
|
|
17
|
+
|
|
18
|
+
## When to use this skill
|
|
19
|
+
|
|
20
|
+
Use this skill when the user wants to:
|
|
21
|
+
|
|
22
|
+
- Register a new Atomic Mail inbox (signup with a username).
|
|
23
|
+
- Re-authenticate an existing Atomic Mail account using its API key.
|
|
24
|
+
- Read, search, or modify email via JMAP (`Mailbox/get`, `Email/query`,
|
|
25
|
+
`Email/get`, `Email/set`, etc.).
|
|
26
|
+
- Send email via JMAP (`EmailSubmission/set` with the
|
|
27
|
+
`urn:ietf:params:jmap:submission` capability).
|
|
28
|
+
- Discover the JMAP session object (`/.well-known/jmap`) to find the `accountId`
|
|
29
|
+
before issuing other JMAP method calls.
|
|
30
|
+
|
|
31
|
+
## Available scripts
|
|
32
|
+
|
|
33
|
+
- **`scripts/signup.ts`** — One-time setup: performs PoW signup or login and
|
|
34
|
+
writes credentials to disk. Run once per agent session/inbox.
|
|
35
|
+
- **`scripts/jmap_request.ts`** — Sends JMAP requests using the saved
|
|
36
|
+
credentials. Auto-rotates the session and capability JWTs as they expire.
|
|
37
|
+
|
|
38
|
+
Both scripts are invokable three ways. Pick the one that matches the runtime the
|
|
39
|
+
user has installed:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
# Deno (preferred — runs source directly)
|
|
43
|
+
deno run -A scripts/signup.ts ...
|
|
44
|
+
deno run -A scripts/jmap_request.ts ...
|
|
45
|
+
|
|
46
|
+
# Node (after the npm package is installed/published)
|
|
47
|
+
npx -y @atomic-mail/agent-skill atomic-mail-signup ...
|
|
48
|
+
npx -y @atomic-mail/agent-skill atomic-mail-jmap ...
|
|
49
|
+
|
|
50
|
+
# Bun
|
|
51
|
+
bunx -y @atomic-mail/agent-skill atomic-mail-signup ...
|
|
52
|
+
bunx -y @atomic-mail/agent-skill atomic-mail-jmap ...
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
> The agent should always pass `--help` first if it is unsure of the exact flag
|
|
56
|
+
> spelling. Both scripts print full usage to stdout and exit `0`.
|
|
57
|
+
|
|
58
|
+
## Required configuration
|
|
59
|
+
|
|
60
|
+
The auth and API base URLs come from the Atomic Mail deployment. Pass them as
|
|
61
|
+
flags or set them in the environment:
|
|
62
|
+
|
|
63
|
+
| Flag | Env var | Description |
|
|
64
|
+
| --------------- | ------------------------- | ------------------------------------------------------- |
|
|
65
|
+
| `--auth-url` | `ATOMIC_MAIL_AUTH_URL` | Base URL of `auth-service` (PoW + JWT minting). |
|
|
66
|
+
| `--api-url` | `ATOMIC_MAIL_API_URL` | Base URL of `api-service` (JMAP). |
|
|
67
|
+
| `--scrypt-salt` | `ATOMIC_MAIL_SCRYPT_SALT` | Optional PoW salt override (defaults match `auth-service`). |
|
|
68
|
+
|
|
69
|
+
If the user does not know the URLs, ask them — they are deployment-specific.
|
|
70
|
+
|
|
71
|
+
## Workflow
|
|
72
|
+
|
|
73
|
+
### 1. First-time signup (new account)
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
deno run -A scripts/signup.ts \
|
|
77
|
+
--auth-url "$ATOMIC_MAIL_AUTH_URL" \
|
|
78
|
+
--api-url "$ATOMIC_MAIL_API_URL" \
|
|
79
|
+
--username "alice" \
|
|
80
|
+
--out-dir "./.atomic-mail"
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
This writes three files into `--out-dir`:
|
|
84
|
+
|
|
85
|
+
- `credentials.json` — `{ apiKey, inboxId, authUrl, apiUrl, scryptSalt }`. The
|
|
86
|
+
agent should store the `apiKey` securely; it is the long-lived secret.
|
|
87
|
+
- `session.jwt` — 4-hour session token.
|
|
88
|
+
- `capability.jwt` — 2-minute capability token used as the JMAP bearer.
|
|
89
|
+
|
|
90
|
+
The script prints a JSON summary to stdout that includes `inboxId` and `apiKey`.
|
|
91
|
+
Save these in the agent's persistent memory (or echo them back to the user) —
|
|
92
|
+
they are the only durable identifiers.
|
|
93
|
+
|
|
94
|
+
### 2. Re-authenticate (existing API key)
|
|
95
|
+
|
|
96
|
+
If `credentials.json` already exists, this is normally not needed —
|
|
97
|
+
`jmap_request.ts` will auto-renew session/capability tokens via the stored API
|
|
98
|
+
key. Use `signup.ts --api-key` only if the user wants to start a fresh
|
|
99
|
+
credentials directory from a known API key.
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
deno run -A scripts/signup.ts \
|
|
103
|
+
--auth-url "$ATOMIC_MAIL_AUTH_URL" \
|
|
104
|
+
--api-url "$ATOMIC_MAIL_API_URL" \
|
|
105
|
+
--api-key "11111111-2222-3333-4444-555555555555" \
|
|
106
|
+
--out-dir "./.atomic-mail"
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### 3. Discover the JMAP session
|
|
110
|
+
|
|
111
|
+
Run this **once** before any other JMAP call to learn the `accountId` and
|
|
112
|
+
mailbox structure.
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
deno run -A scripts/jmap_request.ts \
|
|
116
|
+
--credentials-dir "./.atomic-mail" \
|
|
117
|
+
--session
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
The response JSON contains `primaryAccounts`, `accounts`, `capabilities`, etc.
|
|
121
|
+
Extract the `accountId` for the user's primary mail account from
|
|
122
|
+
`primaryAccounts["urn:ietf:params:jmap:mail"]`.
|
|
123
|
+
|
|
124
|
+
### 4. Send a JMAP request (inline ops)
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
deno run -A scripts/jmap_request.ts \
|
|
128
|
+
--credentials-dir "./.atomic-mail" \
|
|
129
|
+
--ops '[["Mailbox/get", {"accountId": "ACCOUNT_ID"}, "m0"]]'
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
For multiple method calls or capabilities, pass a full envelope:
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
deno run -A scripts/jmap_request.ts \
|
|
136
|
+
--credentials-dir "./.atomic-mail" \
|
|
137
|
+
--ops '{
|
|
138
|
+
"using": ["urn:ietf:params:jmap:core","urn:ietf:params:jmap:mail"],
|
|
139
|
+
"methodCalls": [
|
|
140
|
+
["Mailbox/get", {"accountId": "ACCOUNT_ID"}, "m0"],
|
|
141
|
+
["Email/query", {"accountId": "ACCOUNT_ID", "limit": 10}, "q0"]
|
|
142
|
+
]
|
|
143
|
+
}'
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
### 5. Send a JMAP request (preset file)
|
|
147
|
+
|
|
148
|
+
For repeated tasks, save the JMAP body to a file and reuse it. The file may
|
|
149
|
+
contain either a `methodCalls` array or a full `{ using, methodCalls }`
|
|
150
|
+
envelope.
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
cat > fetch_last_100.json <<'EOF'
|
|
154
|
+
[
|
|
155
|
+
["Email/query", {
|
|
156
|
+
"accountId": "ACCOUNT_ID",
|
|
157
|
+
"limit": 100,
|
|
158
|
+
"sort": [{ "property": "receivedAt", "isAscending": false }]
|
|
159
|
+
}, "q0"],
|
|
160
|
+
["Email/get", {
|
|
161
|
+
"accountId": "ACCOUNT_ID",
|
|
162
|
+
"#ids": { "resultOf": "q0", "name": "Email/query", "path": "/ids" },
|
|
163
|
+
"properties": ["id","threadId","subject","from","receivedAt","preview"]
|
|
164
|
+
}, "g0"]
|
|
165
|
+
]
|
|
166
|
+
EOF
|
|
167
|
+
|
|
168
|
+
deno run -A scripts/jmap_request.ts \
|
|
169
|
+
--credentials-dir "./.atomic-mail" \
|
|
170
|
+
--ops-file fetch_last_100.json
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
The agent can build a small library of these preset files (`send_email.json`,
|
|
174
|
+
`mark_read.json`, etc.) and reuse them across runs.
|
|
175
|
+
|
|
176
|
+
### 6. Send email
|
|
177
|
+
|
|
178
|
+
Send is a JMAP `EmailSubmission/set` plus an optional `Email/set` to upload the
|
|
179
|
+
draft. Remember to add the submission capability to `using`:
|
|
180
|
+
|
|
181
|
+
```bash
|
|
182
|
+
deno run -A scripts/jmap_request.ts \
|
|
183
|
+
--credentials-dir "./.atomic-mail" \
|
|
184
|
+
--using "urn:ietf:params:jmap:core,urn:ietf:params:jmap:mail,urn:ietf:params:jmap:submission" \
|
|
185
|
+
--ops-file send_email.json
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
## Token rotation (handled automatically)
|
|
189
|
+
|
|
190
|
+
`jmap_request.ts` checks both JWTs before every request:
|
|
191
|
+
|
|
192
|
+
- If `capability.jwt` is within 20 s of expiry, it calls
|
|
193
|
+
`POST /api/v1/capability` with the existing session JWT and rewrites
|
|
194
|
+
`capability.jwt`.
|
|
195
|
+
- If `session.jwt` is within 60 s of expiry (or missing), it re-runs the full
|
|
196
|
+
PoW handshake using the API key from `credentials.json`, then rewrites both
|
|
197
|
+
`session.jwt` and `capability.jwt`.
|
|
198
|
+
|
|
199
|
+
The agent does not need to call `signup.ts` again to refresh tokens — it only
|
|
200
|
+
needs to call `signup.ts` for the very first registration of an account.
|
|
201
|
+
|
|
202
|
+
## Troubleshooting
|
|
203
|
+
|
|
204
|
+
- **`Could not read credentials file ... Did you run signup first?`** — Run
|
|
205
|
+
`signup.ts` once with `--username` (new account) or `--api-key` (existing
|
|
206
|
+
account) to create the file set.
|
|
207
|
+
- **`auth-service /api/v1/session returned 409`** — The challenge was consumed
|
|
208
|
+
(likely a duplicate request or a clock skew). Just rerun `signup.ts` or the
|
|
209
|
+
failing `jmap_request.ts` once; a fresh challenge will be issued.
|
|
210
|
+
- **`auth-service /api/v1/session returned 401`** — The `apiKey` in
|
|
211
|
+
`credentials.json` is unknown or suspended. Re-register with `--username` or
|
|
212
|
+
get a new API key from the operator.
|
|
213
|
+
- **PoW takes a long time on first run** — Difficulty is fixed at 6 leading zero
|
|
214
|
+
bits, which averages ~64 scrypt iterations. Each scrypt is ~16 MB and ~200-500
|
|
215
|
+
ms, so the whole solve typically completes in under 30 seconds on a modern
|
|
216
|
+
laptop.
|
|
217
|
+
- **`Capability JWT did not contain an inboxId claim`** — Almost certainly a
|
|
218
|
+
server/version mismatch. Verify `--auth-url` points to a current
|
|
219
|
+
`auth-service` deployment.
|
|
220
|
+
|
|
221
|
+
## Security notes
|
|
222
|
+
|
|
223
|
+
- `credentials.json` contains the long-lived API key. The script writes it with
|
|
224
|
+
mode `0600`, but the agent must not echo the file's contents into shared logs
|
|
225
|
+
or commit it to source control.
|
|
226
|
+
- Pick a credentials directory that is private to the agent's runtime user (e.g.
|
|
227
|
+
`~/.config/atomic-mail/` or a per-task working dir).
|
|
228
|
+
- `session.jwt` and `capability.jwt` are short-lived but should be treated as
|
|
229
|
+
bearer credentials too — never log them.
|
|
230
|
+
|
|
231
|
+
## Building an npm package
|
|
232
|
+
|
|
233
|
+
Scripts can be published as an npm package so Node and Bun environments can use
|
|
234
|
+
them through `npx` / `bunx`. From the skill directory:
|
|
235
|
+
|
|
236
|
+
```bash
|
|
237
|
+
deno task build:npm # writes ./npm
|
|
238
|
+
cd npm && npm publish --access public
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
After publishing, `npx @atomic-mail/agent-skill atomic-mail-signup` and
|
|
242
|
+
`npx @atomic-mail/agent-skill atomic-mail-jmap` work without Deno installed.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fixed proof-of-work scrypt salt. The auth-service passes this string (UTF-8
|
|
3
|
+
* bytes of the hex text, not decoded binary) to `scrypt` as the `salt`
|
|
4
|
+
* argument; all PoW clients must use the same value.
|
|
5
|
+
*/
|
|
6
|
+
export declare const DEFAULT_POW_SCRYPT_SALT_HEX = "0b980734412c292d6549110276b604ab1dea4883bd460d77d1b984adf8bca083";
|
|
7
|
+
export declare const ONE_SEC_MS = 1000;
|
|
8
|
+
export declare const ONE_MIN_MS: number;
|
|
9
|
+
export declare const ONE_HOUR_MS: number;
|
|
10
|
+
export declare const ONE_DAY_MS: number;
|
|
11
|
+
export declare const ONE_MONTH_MS: number;
|
|
12
|
+
export declare const ONE_YEAR_MS: number;
|
|
13
|
+
//# sourceMappingURL=consts.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"consts.d.ts","sourceRoot":"","sources":["../../../src/lib/src/consts.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,eAAO,MAAM,2BAA2B,qEAC4B,CAAC;AAErE,eAAO,MAAM,UAAU,OAAO,CAAC;AAC/B,eAAO,MAAM,UAAU,QAAkB,CAAC;AAC1C,eAAO,MAAM,WAAW,QAAkB,CAAC;AAC3C,eAAO,MAAM,UAAU,QAAmB,CAAC;AAC3C,eAAO,MAAM,YAAY,QAAkB,CAAC;AAC5C,eAAO,MAAM,WAAW,QAAmB,CAAC"}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fixed proof-of-work scrypt salt. The auth-service passes this string (UTF-8
|
|
3
|
+
* bytes of the hex text, not decoded binary) to `scrypt` as the `salt`
|
|
4
|
+
* argument; all PoW clients must use the same value.
|
|
5
|
+
*/
|
|
6
|
+
export const DEFAULT_POW_SCRYPT_SALT_HEX = "0b980734412c292d6549110276b604ab1dea4883bd460d77d1b984adf8bca083";
|
|
7
|
+
export const ONE_SEC_MS = 1000;
|
|
8
|
+
export const ONE_MIN_MS = ONE_SEC_MS * 60;
|
|
9
|
+
export const ONE_HOUR_MS = ONE_MIN_MS * 60;
|
|
10
|
+
export const ONE_DAY_MS = ONE_HOUR_MS * 24;
|
|
11
|
+
export const ONE_MONTH_MS = ONE_DAY_MS * 30;
|
|
12
|
+
export const ONE_YEAR_MS = ONE_DAY_MS * 365;
|
package/esm/package.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"jmap_request.d.ts","sourceRoot":"","sources":["../../../src/skill/scripts/jmap_request.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,265 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export declare const SESSION_TTL_MS: number;
|
|
2
|
+
export declare const CAPABILITY_TTL_MS: number;
|
|
3
|
+
export declare const CAPABILITY_SAFETY_MARGIN_MS = 20000;
|
|
4
|
+
export declare const SESSION_SAFETY_MARGIN_MS = 60000;
|
|
5
|
+
export interface JwtPayload {
|
|
6
|
+
exp?: number;
|
|
7
|
+
iat?: number;
|
|
8
|
+
jti?: string;
|
|
9
|
+
[key: string]: unknown;
|
|
10
|
+
}
|
|
11
|
+
export declare function decodeJwtPayload<T = JwtPayload>(jwt: string): T;
|
|
12
|
+
export declare function isJwtExpired(jwt: string, marginMs: number): boolean;
|
|
13
|
+
export declare function solvePow(challenge: string, difficulty: number, salt: string, onProgress?: (nonce: bigint) => void): Promise<{
|
|
14
|
+
powHex: string;
|
|
15
|
+
nonce: string;
|
|
16
|
+
}>;
|
|
17
|
+
export declare function fetchChallenge(authUrl: string): Promise<{
|
|
18
|
+
challengeJWT: string;
|
|
19
|
+
challenge: string;
|
|
20
|
+
difficulty: number;
|
|
21
|
+
}>;
|
|
22
|
+
export interface SessionResponse {
|
|
23
|
+
sessionJWT: string;
|
|
24
|
+
apiKey?: string;
|
|
25
|
+
}
|
|
26
|
+
export declare function exchangeSession(authUrl: string, body: {
|
|
27
|
+
challengeJWT: string;
|
|
28
|
+
powHex: string;
|
|
29
|
+
nonce: string;
|
|
30
|
+
apiKey?: string;
|
|
31
|
+
username?: string;
|
|
32
|
+
}): Promise<SessionResponse>;
|
|
33
|
+
export declare function fetchCapability(authUrl: string, sessionJWT: string): Promise<string>;
|
|
34
|
+
export interface PerformPoWInput {
|
|
35
|
+
authUrl: string;
|
|
36
|
+
scryptSalt: string;
|
|
37
|
+
apiKey?: string;
|
|
38
|
+
username?: string;
|
|
39
|
+
onPowProgress?: (nonce: bigint) => void;
|
|
40
|
+
}
|
|
41
|
+
export declare function performPoWAndSession(input: PerformPoWInput): Promise<SessionResponse>;
|
|
42
|
+
//# sourceMappingURL=auth.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
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"}
|
|
@@ -0,0 +1,163 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export interface Credentials {
|
|
2
|
+
apiKey: string;
|
|
3
|
+
inboxId: string;
|
|
4
|
+
authUrl: string;
|
|
5
|
+
apiUrl: string;
|
|
6
|
+
scryptSalt: string;
|
|
7
|
+
}
|
|
8
|
+
export interface SkillFiles {
|
|
9
|
+
credentialsFile: string;
|
|
10
|
+
sessionFile: string;
|
|
11
|
+
capabilityFile: string;
|
|
12
|
+
}
|
|
13
|
+
export declare function defaultFilesFromOutDir(outDir: string): SkillFiles;
|
|
14
|
+
export declare function writeCredentials(path: string, creds: Credentials): Promise<void>;
|
|
15
|
+
export declare function readCredentials(path: string): Promise<Credentials>;
|
|
16
|
+
export declare function writeJwtFile(path: string, jwt: string): Promise<void>;
|
|
17
|
+
export declare function tryReadJwtFile(path: string): Promise<string | undefined>;
|
|
18
|
+
//# sourceMappingURL=credentials.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
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"}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Credential file I/O. Three files form a complete skill credential set:
|
|
2
|
+
// credentials.json — long-lived: apiKey, inboxId, server URLs, PoW salt.
|
|
3
|
+
// session.jwt — short-lived (~4h): rotates via /session + PoW.
|
|
4
|
+
// capability.jwt — short-lived (~2min): rotates via /capability.
|
|
5
|
+
//
|
|
6
|
+
// Files are written with mode 0600 so other local users cannot read them.
|
|
7
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
8
|
+
import { dirname, join, resolve } from "node:path";
|
|
9
|
+
export function defaultFilesFromOutDir(outDir) {
|
|
10
|
+
const base = resolve(outDir);
|
|
11
|
+
return {
|
|
12
|
+
credentialsFile: join(base, "credentials.json"),
|
|
13
|
+
sessionFile: join(base, "session.jwt"),
|
|
14
|
+
capabilityFile: join(base, "capability.jwt"),
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
async function ensureParent(path) {
|
|
18
|
+
await mkdir(dirname(path), { recursive: true });
|
|
19
|
+
}
|
|
20
|
+
export async function writeCredentials(path, creds) {
|
|
21
|
+
await ensureParent(path);
|
|
22
|
+
await writeFile(path, JSON.stringify(creds, null, 2) + "\n", { mode: 0o600 });
|
|
23
|
+
}
|
|
24
|
+
export async function readCredentials(path) {
|
|
25
|
+
let raw;
|
|
26
|
+
try {
|
|
27
|
+
raw = await readFile(path, "utf-8");
|
|
28
|
+
}
|
|
29
|
+
catch (err) {
|
|
30
|
+
throw new Error(`Could not read credentials file '${path}': ${err.message}. ` +
|
|
31
|
+
"Did you run signup first?");
|
|
32
|
+
}
|
|
33
|
+
let obj;
|
|
34
|
+
try {
|
|
35
|
+
obj = JSON.parse(raw);
|
|
36
|
+
}
|
|
37
|
+
catch (err) {
|
|
38
|
+
throw new Error(`Credentials file '${path}' is not valid JSON: ${err.message}`);
|
|
39
|
+
}
|
|
40
|
+
const required = [
|
|
41
|
+
"apiKey",
|
|
42
|
+
"inboxId",
|
|
43
|
+
"authUrl",
|
|
44
|
+
"apiUrl",
|
|
45
|
+
"scryptSalt",
|
|
46
|
+
];
|
|
47
|
+
for (const k of required) {
|
|
48
|
+
if (typeof obj[k] !== "string" || obj[k].length === 0) {
|
|
49
|
+
throw new Error(`Credentials file '${path}' missing required field: ${k}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return obj;
|
|
53
|
+
}
|
|
54
|
+
export async function writeJwtFile(path, jwt) {
|
|
55
|
+
await ensureParent(path);
|
|
56
|
+
await writeFile(path, jwt, { mode: 0o600 });
|
|
57
|
+
}
|
|
58
|
+
export async function tryReadJwtFile(path) {
|
|
59
|
+
try {
|
|
60
|
+
const raw = await readFile(path, "utf-8");
|
|
61
|
+
return raw.trim();
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"signup.d.ts","sourceRoot":"","sources":["../../../src/skill/scripts/signup.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,170 @@
|
|
|
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
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@atomicmail/agent-skill",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Atomic Mail agent skill — PoW signup + JMAP request CLIs for AI agents.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"atomic-mail",
|
|
7
|
+
"atomicmail",
|
|
8
|
+
"agentskills",
|
|
9
|
+
"agent",
|
|
10
|
+
"ai",
|
|
11
|
+
"jmap",
|
|
12
|
+
"esp",
|
|
13
|
+
"email",
|
|
14
|
+
"mcp",
|
|
15
|
+
"proof-of-work"
|
|
16
|
+
],
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/atomic-mail/agentic-mail.git"
|
|
20
|
+
},
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"bugs": {
|
|
23
|
+
"url": "https://github.com/atomic-mail/agentic-mail/issues"
|
|
24
|
+
},
|
|
25
|
+
"scripts": {},
|
|
26
|
+
"bin": {
|
|
27
|
+
"atomic-mail-signup": "./esm/skill/scripts/signup.js",
|
|
28
|
+
"atomic-mail-jmap": "./esm/skill/scripts/jmap_request.js"
|
|
29
|
+
},
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=20"
|
|
32
|
+
},
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@types/node": "^20.12.0"
|
|
38
|
+
},
|
|
39
|
+
"_generatedBy": "dnt@dev"
|
|
40
|
+
}
|