@fennecstudio/x-tkn-mcp 0.0.0-stage → 1.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 +153 -2
- package/dist/index.js +1185 -0
- package/package.json +47 -4
package/README.md
CHANGED
|
@@ -1,3 +1,154 @@
|
|
|
1
|
-
#
|
|
1
|
+
# X-TKN MCP server
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Lets an AI agent hand a secret to a person as a one-time link, with no way to read it back.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
npx -y @fennecstudio/x-tkn-mcp
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
- **No read tool.** The server has `create_secret`, `check_status` and
|
|
10
|
+
`revoke_secret`. Once a secret is handed over, the agent can see whether it
|
|
11
|
+
was opened and can cancel it. It cannot see the secret again.
|
|
12
|
+
- **Encrypted on your machine.** `create_secret` encrypts with AES-256-GCM
|
|
13
|
+
before anything is sent. X-TKN stores only ciphertext. The key travels in the
|
|
14
|
+
link's `#fragment`, which browsers never send to a server.
|
|
15
|
+
- **Up to 256 KB per secret**: 64 KB on the free Developer plan, 256 KB on Team
|
|
16
|
+
and Enterprise. Enough for a certificate bundle or a `.env` file, not just a
|
|
17
|
+
password.
|
|
18
|
+
|
|
19
|
+
It runs locally over stdio for a reason. A hosted MCP server would receive the
|
|
20
|
+
plaintext before encrypting it, and the whole point is that nobody but the
|
|
21
|
+
recipient can read it.
|
|
22
|
+
|
|
23
|
+
## Setup
|
|
24
|
+
|
|
25
|
+
You need Node 20 or later. An X-TKN API key is optional.
|
|
26
|
+
|
|
27
|
+
**With no key**, the server starts a sandbox on its first tool call, with no
|
|
28
|
+
signup. Nothing is minted at startup. The key is saved in
|
|
29
|
+
`$XDG_CONFIG_HOME/x-tkn/sandbox.json` (`~/.config/x-tkn/sandbox.json` by
|
|
30
|
+
default), the directory 0700 and the file 0600, and later starts reuse it until
|
|
31
|
+
it expires. The key never appears in a tool result or in the server's log.
|
|
32
|
+
Servers that start together share one sandbox: while one mints, it holds a
|
|
33
|
+
`sandbox.json.lock` beside the file, and the others wait for it and use its key.
|
|
34
|
+
|
|
35
|
+
- A sandbox lasts 24 hours, and so does every secret it creates:
|
|
36
|
+
`create_secret` refuses an `expiresIn` that runs past the sandbox's end.
|
|
37
|
+
Every result on a sandbox key ends with a line giving that end.
|
|
38
|
+
- When it ends, the next call starts a new sandbox and says so. Codes from the
|
|
39
|
+
old one no longer work with `check_status` or `revoke_secret`.
|
|
40
|
+
- A person can keep a sandbox by claiming it at `POST /accounts/claim` with the
|
|
41
|
+
key from that file. A sandbox has to be claimed before it can be upgraded.
|
|
42
|
+
The sandbox key still ends on schedule after a claim, so a claimed account
|
|
43
|
+
goes on with a Developer key created for it and set here.
|
|
44
|
+
- Once a sandbox has spent its operations, the server does not start another,
|
|
45
|
+
because that would lose the old one's codes. Claim it, or set a key.
|
|
46
|
+
|
|
47
|
+
**For a key that stays**, Developer keys are free: sign up at
|
|
48
|
+
[x-tkn.com](https://x-tkn.com) and create one in the account console. A key in
|
|
49
|
+
`X_TKN_API_KEY` always wins: then nothing is minted and nothing is written.
|
|
50
|
+
|
|
51
|
+
**Claude Code**
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
claude mcp add x-tkn -- npx -y @fennecstudio/x-tkn-mcp # a sandbox
|
|
55
|
+
claude mcp add x-tkn -e X_TKN_API_KEY=your-key -- npx -y @fennecstudio/x-tkn-mcp # your key
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
**Claude Desktop, Cursor, Windsurf and other clients** take the same block in
|
|
59
|
+
their MCP config file. Leave `env` out for a sandbox:
|
|
60
|
+
|
|
61
|
+
```json
|
|
62
|
+
{
|
|
63
|
+
"mcpServers": {
|
|
64
|
+
"x-tkn": {
|
|
65
|
+
"command": "npx",
|
|
66
|
+
"args": ["-y", "@fennecstudio/x-tkn-mcp"],
|
|
67
|
+
"env": { "X_TKN_API_KEY": "your-key" }
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Tools
|
|
74
|
+
|
|
75
|
+
### `create_secret`
|
|
76
|
+
|
|
77
|
+
Encrypts a secret and returns a link that opens it once.
|
|
78
|
+
|
|
79
|
+
| Argument | Required | What it does |
|
|
80
|
+
| ---------------- | -------- | -------------------------------------------------------------------------------------------------------------- |
|
|
81
|
+
| `secret` | yes | The text to hand over. |
|
|
82
|
+
| `expiresIn` | no | `{ days, hours, minutes }`. Defaults to the plan's: 7 days on Developer, 30 on Team. On a sandbox key, defaults to the sandbox's end and cannot be later. |
|
|
83
|
+
| `passphrase` | no | The recipient has to type this before the secret opens. 8 to 256 characters. Team and above. |
|
|
84
|
+
| `recipientEmail` | no | The recipient has to enter a 6-digit code sent to this address. Team and above. Not with `passphrase`. |
|
|
85
|
+
| `note` | no | For your own records in the X-TKN console. Stored unencrypted and never shown to the recipient. |
|
|
86
|
+
|
|
87
|
+
Returns the `link`, a `code` for the other two tools, and `expiresAt`.
|
|
88
|
+
|
|
89
|
+
Send a passphrase by a different channel than the link. Anyone holding both can
|
|
90
|
+
open the secret.
|
|
91
|
+
|
|
92
|
+
### `check_status`
|
|
93
|
+
|
|
94
|
+
Takes the `code`. Returns `waiting`, `opened`, `expired` or `revoked`, with
|
|
95
|
+
`openedAt` once it has been opened. It never returns the secret.
|
|
96
|
+
|
|
97
|
+
Each call is one metered operation, and a sandbox has 100 in all. Call it no
|
|
98
|
+
more often than every 30 seconds: at one call every 5 seconds, a link nobody
|
|
99
|
+
opens spends a whole sandbox in under nine minutes.
|
|
100
|
+
|
|
101
|
+
### `revoke_secret`
|
|
102
|
+
|
|
103
|
+
Takes the `code`. Stops the link working, immediately and for good. A secret
|
|
104
|
+
that was already opened stays opened.
|
|
105
|
+
|
|
106
|
+
## What X-TKN sees
|
|
107
|
+
|
|
108
|
+
The ciphertext, the expiry, your `note`, and, if you set them, a hash of the
|
|
109
|
+
passphrase or the recipient's email address. The passphrase gates the link and
|
|
110
|
+
plays no part in the encryption. X-TKN never receives the plaintext or the key.
|
|
111
|
+
|
|
112
|
+
Opening the link uses it up, so give it only to the person it is for. The
|
|
113
|
+
retrieval page takes the key out of the address bar after it decrypts the
|
|
114
|
+
secret. If opening fails, the full link, key included, stays in that browser's
|
|
115
|
+
history.
|
|
116
|
+
|
|
117
|
+
## Publishing
|
|
118
|
+
|
|
119
|
+
For whoever ships a release. Run these in order, from this directory. The
|
|
120
|
+
registry steps read what npm holds, so none of them can come before the first.
|
|
121
|
+
|
|
122
|
+
Keep one version everywhere: `version` in `package.json`, `VERSION` in
|
|
123
|
+
`src/version.ts`, and both `version` fields in `server.json`. Specs fail when
|
|
124
|
+
they disagree. Keep `server.json`'s `description` equal to the first paragraph
|
|
125
|
+
of this README, and no longer than 100 characters, which is the registry's limit.
|
|
126
|
+
|
|
127
|
+
1. **npm.** `npm run deploy -- --otp=<code>`. That is `npm publish`, and
|
|
128
|
+
`prepublishOnly` typechecks, tests and builds first. It goes first because
|
|
129
|
+
the registry reads `mcpName` out of the published package, and npm will not
|
|
130
|
+
take the same version twice.
|
|
131
|
+
2. **Official MCP Registry.** Install the CLI with `brew install mcp-publisher`,
|
|
132
|
+
then:
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
mcp-publisher login github
|
|
136
|
+
mcp-publisher publish
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Log in as an Owner of the `fennecstudio` GitHub organization. The registry
|
|
140
|
+
grants `io.github.fennecstudio/*` to its owners only, and an ordinary member
|
|
141
|
+
is refused. Check it landed with
|
|
142
|
+
`curl "https://registry.modelcontextprotocol.io/v0.1/servers?search=io.github.fennecstudio/x-tkn-mcp"`.
|
|
143
|
+
3. **Smithery.** `smithery mcp publish`. It does not run bare: Smithery takes a
|
|
144
|
+
server URL or a pre-built `.mcpb` bundle, and `-n <org>/<server>`. This
|
|
145
|
+
package serves no URL and builds no bundle, so read `smithery mcp publish --help`
|
|
146
|
+
and Smithery's publishing docs for the current form before running it.
|
|
147
|
+
4. **Glama and PulseMCP.** Both crawl on their own, so there is nothing to
|
|
148
|
+
submit. Once the listing appears, claim it on each site.
|
|
149
|
+
|
|
150
|
+
## Links
|
|
151
|
+
|
|
152
|
+
- [x-tkn.com](https://x-tkn.com)
|
|
153
|
+
- API reference: [x-tkn.com/docs](https://x-tkn.com/docs)
|
|
154
|
+
- JavaScript SDK: [`@fennecstudio/x-tkn-js`](https://www.npmjs.com/package/@fennecstudio/x-tkn-js)
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,1185 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
|
+
|
|
6
|
+
// src/env.ts
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
import { isAbsolute, join } from "node:path";
|
|
9
|
+
|
|
10
|
+
// ../js/src/errors.ts
|
|
11
|
+
var XTknError = class extends Error {
|
|
12
|
+
/** HTTP status. `0` when the request never got a response. */
|
|
13
|
+
status;
|
|
14
|
+
/** Field-level errors, when the API supplied them. */
|
|
15
|
+
details;
|
|
16
|
+
/** Parsed response body, for anything the typed fields do not cover. */
|
|
17
|
+
body;
|
|
18
|
+
constructor(message, status2, details, body) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.name = new.target.name;
|
|
21
|
+
this.status = status2;
|
|
22
|
+
this.details = details;
|
|
23
|
+
this.body = body;
|
|
24
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
var XTknRequestError = class extends XTknError {
|
|
28
|
+
};
|
|
29
|
+
var XTknAuthError = class extends XTknError {
|
|
30
|
+
};
|
|
31
|
+
var XTknPaymentRequiredError = class extends XTknError {
|
|
32
|
+
/**
|
|
33
|
+
* A link a human can act on — Stripe Checkout, or the Billing Portal when the
|
|
34
|
+
* account already has a subscription. See {@link upgradeMethod}.
|
|
35
|
+
*
|
|
36
|
+
* `undefined` when the API could not build one — Stripe unreachable or slow,
|
|
37
|
+
* or no price configured for the plan — and *always* undefined on an
|
|
38
|
+
* unclaimed sandbox account, whose next step is {@link claimEndpoint} rather
|
|
39
|
+
* than a payment. The refusal is still correct; there is just no link to
|
|
40
|
+
* print. Always handle that case: it is the difference between telling
|
|
41
|
+
* someone what to do and printing "undefined".
|
|
42
|
+
*/
|
|
43
|
+
get upgradeUrl() {
|
|
44
|
+
return this.details?.upgradeUrl;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* How this account gets unblocked: `'checkout'` to buy a first subscription,
|
|
48
|
+
* `'portal'` to change an existing one, `'claim'` to turn an unclaimed
|
|
49
|
+
* sandbox into a real account first.
|
|
50
|
+
*
|
|
51
|
+
* A second subscription would bill twice, so an account that already pays us
|
|
52
|
+
* changes plan in the portal instead. Both of those are a link a human opens.
|
|
53
|
+
* `'claim'` is the one that is not: there is no {@link upgradeUrl}, and the
|
|
54
|
+
* next step is a call your own code makes to {@link claimEndpoint}. Branch on
|
|
55
|
+
* this rather than assuming a URL is always there.
|
|
56
|
+
*/
|
|
57
|
+
get upgradeMethod() {
|
|
58
|
+
return this.details?.upgradeMethod;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Where to claim an unclaimed sandbox account, when {@link upgradeMethod} is
|
|
62
|
+
* `'claim'`. Undefined otherwise.
|
|
63
|
+
*
|
|
64
|
+
* A sandbox is anonymous and reaped within the day, so it cannot hold a
|
|
65
|
+
* subscription — claiming it is what makes it an account worth buying a plan
|
|
66
|
+
* for. Claiming grants an ordinary free account, not a paid one, so the plan
|
|
67
|
+
* in {@link requiredPlan} is still needed afterwards.
|
|
68
|
+
*/
|
|
69
|
+
get claimEndpoint() {
|
|
70
|
+
return this.details?.claimEndpoint;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* A sentence explaining the claim step, worth showing a human verbatim.
|
|
74
|
+
* Undefined unless {@link upgradeMethod} is `'claim'`.
|
|
75
|
+
*/
|
|
76
|
+
get claimReason() {
|
|
77
|
+
return this.details?.claimReason;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* @deprecated No longer sent, so always `undefined`. It carried
|
|
81
|
+
* {@link upgradeUrl} wrapped as an X-TKN link, which the retrieval page could
|
|
82
|
+
* not open. Use {@link upgradeUrl}.
|
|
83
|
+
*/
|
|
84
|
+
get upgradeToken() {
|
|
85
|
+
return this.details?.upgradeToken;
|
|
86
|
+
}
|
|
87
|
+
/** The entitlement that was missing, e.g. `recipientAuth`. */
|
|
88
|
+
get capability() {
|
|
89
|
+
return this.details?.capability;
|
|
90
|
+
}
|
|
91
|
+
/** The cheapest plan granting it — `team` or `enterprise`. */
|
|
92
|
+
get requiredPlan() {
|
|
93
|
+
return this.details?.requiredPlan;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* {@link upgradeUrl} works until at least this time. Present only when
|
|
97
|
+
* {@link upgradeMethod} is `'checkout'`.
|
|
98
|
+
*/
|
|
99
|
+
get upgradeExpiresAt() {
|
|
100
|
+
return this.details?.upgradeExpiresAt;
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
var XTknNotFoundError = class extends XTknError {
|
|
104
|
+
};
|
|
105
|
+
var XTknGoneError = class extends XTknError {
|
|
106
|
+
};
|
|
107
|
+
var XTknRateLimitError = class extends XTknError {
|
|
108
|
+
};
|
|
109
|
+
var XTknServerError = class extends XTknError {
|
|
110
|
+
};
|
|
111
|
+
var XTknConnectionError = class extends XTknError {
|
|
112
|
+
constructor(message, cause) {
|
|
113
|
+
super(message, 0);
|
|
114
|
+
this.cause = cause;
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
var XTknConfigError = class extends XTknError {
|
|
118
|
+
constructor(message) {
|
|
119
|
+
super(message, 0);
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
function errorForStatus(status2) {
|
|
123
|
+
if (status2 === 400) return XTknRequestError;
|
|
124
|
+
if (status2 === 401 || status2 === 403) return XTknAuthError;
|
|
125
|
+
if (status2 === 402) return XTknPaymentRequiredError;
|
|
126
|
+
if (status2 === 404) return XTknNotFoundError;
|
|
127
|
+
if (status2 === 410) return XTknGoneError;
|
|
128
|
+
if (status2 === 429) return XTknRateLimitError;
|
|
129
|
+
if (status2 >= 500) return XTknServerError;
|
|
130
|
+
return XTknError;
|
|
131
|
+
}
|
|
132
|
+
function errorFromResponse(status2, body, statusText) {
|
|
133
|
+
const record = body && typeof body === "object" ? body : void 0;
|
|
134
|
+
const message = typeof record?.error === "string" && record.error || typeof record?.message === "string" && record.message || statusText || `Request failed with status ${status2}`;
|
|
135
|
+
const details = record?.details && typeof record.details === "object" ? record.details : void 0;
|
|
136
|
+
const Ctor = errorForStatus(status2);
|
|
137
|
+
return new Ctor(message, status2, details, body);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ../js/src/duration.ts
|
|
141
|
+
var MS = {
|
|
142
|
+
seconds: 1e3,
|
|
143
|
+
minutes: 6e4,
|
|
144
|
+
hours: 36e5,
|
|
145
|
+
days: 864e5
|
|
146
|
+
};
|
|
147
|
+
function durationToMs(duration) {
|
|
148
|
+
let total = 0;
|
|
149
|
+
let seen = false;
|
|
150
|
+
for (const unit of ["days", "hours", "minutes", "seconds"]) {
|
|
151
|
+
const value = duration?.[unit];
|
|
152
|
+
if (value === void 0 || value === null) continue;
|
|
153
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
154
|
+
throw new XTknConfigError(`Duration field "${unit}" must be a finite number`);
|
|
155
|
+
}
|
|
156
|
+
total += value * MS[unit];
|
|
157
|
+
seen = true;
|
|
158
|
+
}
|
|
159
|
+
if (!seen) {
|
|
160
|
+
throw new XTknConfigError("Duration must set at least one of days, hours, minutes or seconds");
|
|
161
|
+
}
|
|
162
|
+
return total;
|
|
163
|
+
}
|
|
164
|
+
function durationToSeconds(duration) {
|
|
165
|
+
return Math.round(durationToMs(duration) / 1e3);
|
|
166
|
+
}
|
|
167
|
+
function expiryFromDuration(duration, from = /* @__PURE__ */ new Date()) {
|
|
168
|
+
return new Date(from.getTime() + durationToMs(duration));
|
|
169
|
+
}
|
|
170
|
+
function resolveExpiry(expiresAt, expiresIn2) {
|
|
171
|
+
if (expiresAt !== void 0 && expiresIn2 !== void 0) {
|
|
172
|
+
throw new XTknConfigError("Pass either expiresAt or expiresIn, not both");
|
|
173
|
+
}
|
|
174
|
+
if (expiresIn2 !== void 0) return expiryFromDuration(expiresIn2).toISOString();
|
|
175
|
+
if (expiresAt === void 0) return void 0;
|
|
176
|
+
const date = expiresAt instanceof Date ? expiresAt : new Date(expiresAt);
|
|
177
|
+
if (Number.isNaN(date.getTime())) {
|
|
178
|
+
throw new XTknConfigError(`expiresAt is not a valid date: ${String(expiresAt)}`);
|
|
179
|
+
}
|
|
180
|
+
return date.toISOString();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ../js/src/client.ts
|
|
184
|
+
var DEFAULT_BASE_URL = "https://api.x-tkn.com";
|
|
185
|
+
var DEFAULT_TIMEOUT_MS = 3e4;
|
|
186
|
+
var API_KEY_ENV_VARS = ["X_TKN_API_KEY", "X_TKN_API_KEY_ID"];
|
|
187
|
+
function envApiKey() {
|
|
188
|
+
const env = typeof process !== "undefined" ? process.env : void 0;
|
|
189
|
+
if (!env) return void 0;
|
|
190
|
+
for (const name of API_KEY_ENV_VARS) {
|
|
191
|
+
const value = env[name];
|
|
192
|
+
if (value) return value;
|
|
193
|
+
}
|
|
194
|
+
return void 0;
|
|
195
|
+
}
|
|
196
|
+
function compact(input) {
|
|
197
|
+
const out = {};
|
|
198
|
+
for (const [key, value] of Object.entries(input)) {
|
|
199
|
+
if (value !== void 0) out[key] = value;
|
|
200
|
+
}
|
|
201
|
+
return out;
|
|
202
|
+
}
|
|
203
|
+
var XTkn = class _XTkn {
|
|
204
|
+
options;
|
|
205
|
+
baseUrl;
|
|
206
|
+
constructor(options = {}) {
|
|
207
|
+
this.options = options;
|
|
208
|
+
this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Mint a sandbox account and return a client already holding its key.
|
|
212
|
+
*
|
|
213
|
+
* No signup, no inbox, no key of your own — which is the point. This is the
|
|
214
|
+
* one call on the API with no credential, so it is the one way a coding
|
|
215
|
+
* agent gets from `npm install` to a working request without a human
|
|
216
|
+
* clicking a link in an email.
|
|
217
|
+
*
|
|
218
|
+
* The account is capped and temporary: 100 operations and 24 hours. It may
|
|
219
|
+
* create `secret` tokens, none of which outlives it. {@link XTkn.claim}
|
|
220
|
+
* turns it into an ordinary Developer account with 10,000 operations a month
|
|
221
|
+
* and no expiry.
|
|
222
|
+
*
|
|
223
|
+
* `account.apiKey` is returned once and cannot be read back. Persist it if
|
|
224
|
+
* the process may restart before the account is claimed. It stops working at
|
|
225
|
+
* `account.expiresAt` whether or not the account was claimed: a claimed
|
|
226
|
+
* account needs a Developer key of its own, created in the console.
|
|
227
|
+
*
|
|
228
|
+
* @example
|
|
229
|
+
* ```ts
|
|
230
|
+
* const { client, account } = await XTkn.createSandbox()
|
|
231
|
+
* console.log(`${account.limits.monthlyOperations} ops until ${account.expiresAt}`)
|
|
232
|
+
*
|
|
233
|
+
* const token = await client.createToken({ maxUses: 1, expiresIn: { minutes: 15 } })
|
|
234
|
+
* ```
|
|
235
|
+
*/
|
|
236
|
+
static async createSandbox(options = {}) {
|
|
237
|
+
const bootstrap = new _XTkn(options);
|
|
238
|
+
const account = await bootstrap.request(
|
|
239
|
+
"POST",
|
|
240
|
+
"/accounts/sandbox",
|
|
241
|
+
void 0,
|
|
242
|
+
{ auth: false }
|
|
243
|
+
);
|
|
244
|
+
return { client: new _XTkn({ ...options, apiKey: account.apiKey }), account };
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Keep this sandbox account permanently.
|
|
248
|
+
*
|
|
249
|
+
* Takes a Firebase ID token for whoever is claiming it. The account this
|
|
250
|
+
* client authenticates as is the one claimed — there is no account id to
|
|
251
|
+
* pass and no way to name somebody else's.
|
|
252
|
+
*
|
|
253
|
+
* Existing tokens and keys survive. Throws {@link XTknRequestError} with a
|
|
254
|
+
* 409 when that email already owns an account; sign in with that one
|
|
255
|
+
* instead, since merging is not supported.
|
|
256
|
+
*
|
|
257
|
+
* @example
|
|
258
|
+
* ```ts
|
|
259
|
+
* const { client } = await XTkn.createSandbox()
|
|
260
|
+
* // ... later, once a human has signed in ...
|
|
261
|
+
* await client.claim(await firebaseUser.getIdToken())
|
|
262
|
+
* ```
|
|
263
|
+
*/
|
|
264
|
+
async claim(firebaseIdToken) {
|
|
265
|
+
if (!firebaseIdToken) {
|
|
266
|
+
throw new XTknConfigError("A Firebase ID token is required to claim an account");
|
|
267
|
+
}
|
|
268
|
+
return this.request("POST", "/accounts/claim", void 0, {
|
|
269
|
+
headers: { "X-Firebase-Token": firebaseIdToken }
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Create a token.
|
|
274
|
+
*
|
|
275
|
+
* The returned {@link Token.code} is the only copy — the server stores a hash
|
|
276
|
+
* of it and cannot return it again.
|
|
277
|
+
*
|
|
278
|
+
* @throws {XTknConfigError} if both `expiresAt` and `expiresIn` are given.
|
|
279
|
+
* @throws {XTknRequestError} if a field fails server validation.
|
|
280
|
+
*
|
|
281
|
+
* @example
|
|
282
|
+
* ```ts
|
|
283
|
+
* const token = await xtkn.createToken({
|
|
284
|
+
* type: 'password-reset',
|
|
285
|
+
* refId: user.id,
|
|
286
|
+
* maxUses: 1,
|
|
287
|
+
* expiresIn: { minutes: 30 },
|
|
288
|
+
* })
|
|
289
|
+
* await sendEmail(user.email, `https://app.example.com/reset#${token.code}`)
|
|
290
|
+
* ```
|
|
291
|
+
*/
|
|
292
|
+
async createToken(input = {}) {
|
|
293
|
+
const { expiresAt, expiresIn: expiresIn2, idleTimeout, ...rest } = input;
|
|
294
|
+
const body = compact({
|
|
295
|
+
...rest,
|
|
296
|
+
expiresAt: resolveExpiry(expiresAt, expiresIn2),
|
|
297
|
+
idleTimeoutSeconds: idleTimeout === void 0 ? void 0 : durationToSeconds(idleTimeout)
|
|
298
|
+
});
|
|
299
|
+
return this.request("POST", "/tokens/create", body);
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Read a token without consuming a use.
|
|
303
|
+
*
|
|
304
|
+
* @throws {XTknNotFoundError} if no token with this code belongs to your account.
|
|
305
|
+
*
|
|
306
|
+
* @example
|
|
307
|
+
* ```ts
|
|
308
|
+
* const token = await xtkn.readToken(code)
|
|
309
|
+
* if (!token.isActive) return renderExpired()
|
|
310
|
+
* ```
|
|
311
|
+
*
|
|
312
|
+
* On a live sliding token — one created with
|
|
313
|
+
* {@link CreateTokenInput.idleTimeout} — a read counts as activity and moves
|
|
314
|
+
* its expiry forward like a redeem, without consuming a use.
|
|
315
|
+
*/
|
|
316
|
+
async readToken(code2) {
|
|
317
|
+
return this.request("GET", `/tokens/${encodeURIComponent(this.requireCode(code2))}/read`);
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Redeem a token, consuming one use.
|
|
321
|
+
*
|
|
322
|
+
* Scope the redeem wherever you can. `refId` confines the lookup to the
|
|
323
|
+
* identity the code was issued for, which is what bounds guessing; `type`
|
|
324
|
+
* stops a redemption consuming a different kind of code held by the same
|
|
325
|
+
* identity.
|
|
326
|
+
*
|
|
327
|
+
* @throws {XTknNotFoundError} if the code is wrong, or does not match the scoping.
|
|
328
|
+
* @throws {XTknGoneError} if the token is revoked, expired, or out of uses.
|
|
329
|
+
*
|
|
330
|
+
* @example
|
|
331
|
+
* ```ts
|
|
332
|
+
* try {
|
|
333
|
+
* const token = await xtkn.redeemToken(code, { refId: user.id, type: 'password-reset' })
|
|
334
|
+
* await resetPassword(user, token.payload)
|
|
335
|
+
* } catch (err) {
|
|
336
|
+
* if (err instanceof XTknGoneError) return renderLinkAlreadyUsed()
|
|
337
|
+
* throw err
|
|
338
|
+
* }
|
|
339
|
+
* ```
|
|
340
|
+
*/
|
|
341
|
+
async redeemToken(code2, options = {}) {
|
|
342
|
+
return this.request(
|
|
343
|
+
"PATCH",
|
|
344
|
+
`/tokens/${encodeURIComponent(this.requireCode(code2))}/redeem`,
|
|
345
|
+
compact({ refId: options.refId, type: options.type })
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* Change a token's mutable fields. Omitted fields are left alone.
|
|
350
|
+
*
|
|
351
|
+
* `description` is not updatable — the server's update handler ignores it.
|
|
352
|
+
*
|
|
353
|
+
* @throws {XTknConfigError} if both `expiresAt` and `expiresIn` are given.
|
|
354
|
+
* @throws {XTknNotFoundError} if no token with this code belongs to your account.
|
|
355
|
+
*/
|
|
356
|
+
async updateToken(code2, updates = {}) {
|
|
357
|
+
const { expiresAt, expiresIn: expiresIn2, ...rest } = updates;
|
|
358
|
+
const body = compact({ ...rest, expiresAt: resolveExpiry(expiresAt, expiresIn2) });
|
|
359
|
+
return this.request(
|
|
360
|
+
"PATCH",
|
|
361
|
+
`/tokens/${encodeURIComponent(this.requireCode(code2))}/update`,
|
|
362
|
+
body
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* Push a token's expiry out to `duration` from now.
|
|
367
|
+
*
|
|
368
|
+
* Measured from the current time, not from the existing expiry, so calling
|
|
369
|
+
* this on an already-expired token revives it rather than extending from a
|
|
370
|
+
* date in the past.
|
|
371
|
+
*
|
|
372
|
+
* On a sliding token this moves the hard expiry,
|
|
373
|
+
* {@link Token.absoluteExpiresAt}, and restarts the idle window from now
|
|
374
|
+
* inside it. A sliding token does not need this to stay alive while in use.
|
|
375
|
+
*
|
|
376
|
+
* @example
|
|
377
|
+
* ```ts
|
|
378
|
+
* await xtkn.extendExpiration(sessionCode, { hours: 2 })
|
|
379
|
+
* ```
|
|
380
|
+
*/
|
|
381
|
+
async extendExpiration(code2, duration) {
|
|
382
|
+
return this.updateToken(code2, { expiresAt: expiryFromDuration(duration) });
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Revoke a token. Permanent, and takes effect immediately.
|
|
386
|
+
*
|
|
387
|
+
* @throws {XTknNotFoundError} if no token with this code belongs to your account.
|
|
388
|
+
*/
|
|
389
|
+
async revokeToken(code2) {
|
|
390
|
+
return this.request(
|
|
391
|
+
"PATCH",
|
|
392
|
+
`/tokens/${encodeURIComponent(this.requireCode(code2))}/revoke`
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* Revoke every token matching a filter.
|
|
397
|
+
*
|
|
398
|
+
* One call revokes a bounded number of tokens. When
|
|
399
|
+
* {@link BulkRevokeResult.hasMore} comes back true there are more matches
|
|
400
|
+
* than were revoked — call again with the same filter until it is false.
|
|
401
|
+
*
|
|
402
|
+
* @throws {XTknConfigError} if neither `type` nor `refId` is set. An empty
|
|
403
|
+
* filter would mean "revoke everything", which is never worth allowing by
|
|
404
|
+
* omission.
|
|
405
|
+
*
|
|
406
|
+
* @example Log a user out everywhere
|
|
407
|
+
* ```ts
|
|
408
|
+
* let result
|
|
409
|
+
* do {
|
|
410
|
+
* result = await xtkn.revokeTokens({ refId: user.id, type: 'session' })
|
|
411
|
+
* } while (result.hasMore)
|
|
412
|
+
* ```
|
|
413
|
+
*/
|
|
414
|
+
async revokeTokens(filter) {
|
|
415
|
+
if (!filter?.type && !filter?.refId) {
|
|
416
|
+
throw new XTknConfigError("revokeTokens requires at least one of type or refId");
|
|
417
|
+
}
|
|
418
|
+
return this.request(
|
|
419
|
+
"PATCH",
|
|
420
|
+
"/tokens/revoke",
|
|
421
|
+
compact({ type: filter.type, refId: filter.refId })
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* Permanently delete a token. Prefer {@link XTkn.revokeToken} unless you need
|
|
426
|
+
* the row gone — a revoked token still reports why a redemption failed,
|
|
427
|
+
* a deleted one is indistinguishable from one that never existed.
|
|
428
|
+
*
|
|
429
|
+
* @throws {XTknNotFoundError} if no token with this code belongs to your account.
|
|
430
|
+
*/
|
|
431
|
+
async deleteToken(code2) {
|
|
432
|
+
await this.request("DELETE", `/tokens/${encodeURIComponent(this.requireCode(code2))}`);
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* List your tokens, newest first.
|
|
436
|
+
*
|
|
437
|
+
* Only `type`, `refId` and `isRevoked` are filterable; the server drops
|
|
438
|
+
* anything else in `where` without complaining, so a typo widens the result
|
|
439
|
+
* rather than erroring. `limit` is capped at 100.
|
|
440
|
+
*
|
|
441
|
+
* Codes are not exposed as a filter. The API does accept one, hashing the
|
|
442
|
+
* value to match `sha256(code)`, but it returns at most the single token you
|
|
443
|
+
* already hold the code for — {@link XTkn.readToken} does that directly.
|
|
444
|
+
*
|
|
445
|
+
* @example
|
|
446
|
+
* ```ts
|
|
447
|
+
* const { tokens, count } = await xtkn.listTokens({
|
|
448
|
+
* where: { refId: user.id, isRevoked: false },
|
|
449
|
+
* limit: 50,
|
|
450
|
+
* })
|
|
451
|
+
* ```
|
|
452
|
+
*/
|
|
453
|
+
async listTokens(options = {}) {
|
|
454
|
+
const body = compact({
|
|
455
|
+
where: options.where,
|
|
456
|
+
sort: options.sort,
|
|
457
|
+
page: options.page,
|
|
458
|
+
limit: options.limit
|
|
459
|
+
});
|
|
460
|
+
return this.request("POST", "/tokens/list", body);
|
|
461
|
+
}
|
|
462
|
+
// ── internals ───────────────────────────────────────────────────────────
|
|
463
|
+
/**
|
|
464
|
+
* Read this key's plan, limits and quota position.
|
|
465
|
+
*
|
|
466
|
+
* Call it to find out what you may do *before* doing it. The alternative is
|
|
467
|
+
* discovering each fence by tripping it, which costs a failed request and —
|
|
468
|
+
* for anything with a side effect — leaves you guessing what did and did not
|
|
469
|
+
* happen.
|
|
470
|
+
*
|
|
471
|
+
* Cheap: it reaches nothing outside our own database.
|
|
472
|
+
*
|
|
473
|
+
* @example
|
|
474
|
+
* ```ts
|
|
475
|
+
* const me = await xtkn.account()
|
|
476
|
+
* if (!me.limits.recipientAuth) {
|
|
477
|
+
* // Do not even try to gate this secret; the create would 402.
|
|
478
|
+
* return sendUngated()
|
|
479
|
+
* }
|
|
480
|
+
* if (me.usage.remaining !== null && me.usage.remaining < batch.length) {
|
|
481
|
+
* return console.warn(`Only ${me.usage.remaining} operations left this month`)
|
|
482
|
+
* }
|
|
483
|
+
* ```
|
|
484
|
+
*/
|
|
485
|
+
async account() {
|
|
486
|
+
return this.request("GET", "/account");
|
|
487
|
+
}
|
|
488
|
+
/**
|
|
489
|
+
* Get a Checkout link a human can pay through.
|
|
490
|
+
*
|
|
491
|
+
* **This does not upgrade anything.** It creates a link. The plan changes
|
|
492
|
+
* only once Stripe reports the completed subscription back to us — poll
|
|
493
|
+
* {@link account} if you need to know when.
|
|
494
|
+
*
|
|
495
|
+
* Paying is deliberately left to a person: recurring-subscription consent is
|
|
496
|
+
* the consent an agent has the weakest claim to give, and a card challenge is
|
|
497
|
+
* browser-shaped.
|
|
498
|
+
*
|
|
499
|
+
* Say what you want one of three ways — name a `tier`, name the `capability`
|
|
500
|
+
* you were refused, or pass nothing and get the next plan up.
|
|
501
|
+
*
|
|
502
|
+
* @example
|
|
503
|
+
* ```ts
|
|
504
|
+
* try {
|
|
505
|
+
* await xtkn.createWebhookEndpoint(url)
|
|
506
|
+
* } catch (err) {
|
|
507
|
+
* if (err instanceof XTknPaymentRequiredError) {
|
|
508
|
+
* const intent = await xtkn.upgradeIntent({ capability: err.capability as never })
|
|
509
|
+
* console.log(`Ask someone to pay here, within 30 minutes: ${intent.url}`)
|
|
510
|
+
* }
|
|
511
|
+
* }
|
|
512
|
+
* ```
|
|
513
|
+
*/
|
|
514
|
+
async upgradeIntent(options = {}) {
|
|
515
|
+
return this.request("POST", "/billing/upgrade-intent", compact({ ...options }));
|
|
516
|
+
}
|
|
517
|
+
requireCode(code2) {
|
|
518
|
+
if (typeof code2 !== "string" || code2.length === 0) {
|
|
519
|
+
throw new XTknConfigError("A token code is required");
|
|
520
|
+
}
|
|
521
|
+
return code2;
|
|
522
|
+
}
|
|
523
|
+
resolveApiKey() {
|
|
524
|
+
const key = this.options.apiKey ?? envApiKey();
|
|
525
|
+
if (!key) {
|
|
526
|
+
throw new XTknConfigError(
|
|
527
|
+
`No API key. Pass { apiKey } to the XTkn constructor, or set ${API_KEY_ENV_VARS[0]}.`
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
return key;
|
|
531
|
+
}
|
|
532
|
+
/**
|
|
533
|
+
* Issue one request and unwrap the response.
|
|
534
|
+
*
|
|
535
|
+
* Two things v1 got wrong happen here. Non-2xx responses throw instead of
|
|
536
|
+
* being handed back typed as data, and the `{ success, data }` envelope is
|
|
537
|
+
* unwrapped so callers get the token rather than the wrapper around it.
|
|
538
|
+
*/
|
|
539
|
+
async request(method, path, body, options = {}) {
|
|
540
|
+
const doFetch = this.options.fetch ?? globalThis.fetch;
|
|
541
|
+
if (typeof doFetch !== "function") {
|
|
542
|
+
throw new XTknConfigError(
|
|
543
|
+
"No fetch implementation. Node 18+ provides one; on older runtimes pass { fetch }."
|
|
544
|
+
);
|
|
545
|
+
}
|
|
546
|
+
const timeoutMs = this.options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
547
|
+
const controller = timeoutMs > 0 ? new AbortController() : void 0;
|
|
548
|
+
const timer = controller ? setTimeout(() => controller.abort(), timeoutMs) : void 0;
|
|
549
|
+
let response;
|
|
550
|
+
let raw;
|
|
551
|
+
try {
|
|
552
|
+
response = await doFetch(`${this.baseUrl}${path}`, {
|
|
553
|
+
method,
|
|
554
|
+
headers: {
|
|
555
|
+
...this.options.headers,
|
|
556
|
+
"Content-Type": "application/json",
|
|
557
|
+
// Resolved only when the endpoint needs it. `POST /accounts/sandbox`
|
|
558
|
+
// is the one route with no credential, and calling `resolveApiKey`
|
|
559
|
+
// there would throw the "no API key" error at the exact caller who
|
|
560
|
+
// is asking for one.
|
|
561
|
+
...options.auth === false ? {} : { Authorization: this.resolveApiKey() },
|
|
562
|
+
...options.headers
|
|
563
|
+
},
|
|
564
|
+
body: body === void 0 ? void 0 : JSON.stringify(body),
|
|
565
|
+
signal: controller?.signal
|
|
566
|
+
});
|
|
567
|
+
raw = response.status === 204 ? "" : await response.text();
|
|
568
|
+
} catch (err) {
|
|
569
|
+
if (err instanceof XTknError) throw err;
|
|
570
|
+
const aborted = err?.name === "AbortError";
|
|
571
|
+
throw new XTknConnectionError(
|
|
572
|
+
aborted ? `Request to ${path} timed out after ${timeoutMs}ms` : `Request to ${path} failed`,
|
|
573
|
+
err
|
|
574
|
+
);
|
|
575
|
+
} finally {
|
|
576
|
+
if (timer) clearTimeout(timer);
|
|
577
|
+
}
|
|
578
|
+
const parsed = raw ? safeJsonParse(raw) : void 0;
|
|
579
|
+
if (!response.ok) throw errorFromResponse(response.status, parsed, response.statusText);
|
|
580
|
+
return unwrap(parsed);
|
|
581
|
+
}
|
|
582
|
+
};
|
|
583
|
+
function safeJsonParse(raw) {
|
|
584
|
+
try {
|
|
585
|
+
return JSON.parse(raw);
|
|
586
|
+
} catch {
|
|
587
|
+
return raw;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
function unwrap(parsed) {
|
|
591
|
+
if (parsed && typeof parsed === "object" && "success" in parsed && "data" in parsed) {
|
|
592
|
+
return parsed.data;
|
|
593
|
+
}
|
|
594
|
+
return parsed;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// src/connection.ts
|
|
598
|
+
function keyConnection(client) {
|
|
599
|
+
return {
|
|
600
|
+
async call(work) {
|
|
601
|
+
try {
|
|
602
|
+
return { ok: true, value: await work(client), notes: [] };
|
|
603
|
+
} catch (error) {
|
|
604
|
+
return { ok: false, error, origin: "key", notes: [] };
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// src/sandbox.ts
|
|
611
|
+
import { randomBytes } from "node:crypto";
|
|
612
|
+
import { chmod, mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
613
|
+
import { dirname } from "node:path";
|
|
614
|
+
|
|
615
|
+
// ../js/src/vendor/token-envelope.ts
|
|
616
|
+
var PREFIX_V1 = "xtkn.v1.";
|
|
617
|
+
var NONCE_BYTES = 12;
|
|
618
|
+
var TAG_BYTES = 16;
|
|
619
|
+
var KEY_BYTES = 32;
|
|
620
|
+
var B64URL = /^[A-Za-z0-9_-]*$/;
|
|
621
|
+
var B64URL_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
|
|
622
|
+
function encodeBase64Url(bytes) {
|
|
623
|
+
let binary = "";
|
|
624
|
+
const CHUNK = 32768;
|
|
625
|
+
for (let i = 0; i < bytes.length; i += CHUNK) {
|
|
626
|
+
binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
|
|
627
|
+
}
|
|
628
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
629
|
+
}
|
|
630
|
+
function decodeBase64Url(value) {
|
|
631
|
+
if (!B64URL.test(value)) return null;
|
|
632
|
+
const tail = value.length % 4;
|
|
633
|
+
if (tail === 1) return null;
|
|
634
|
+
if (tail !== 0) {
|
|
635
|
+
const slack = tail === 2 ? 15 : 3;
|
|
636
|
+
const last = B64URL_ALPHABET.indexOf(value[value.length - 1]);
|
|
637
|
+
if (last < 0 || (last & slack) !== 0) return null;
|
|
638
|
+
}
|
|
639
|
+
const b64 = value.replace(/-/g, "+").replace(/_/g, "/");
|
|
640
|
+
const padded = b64 + "=".repeat((4 - b64.length % 4) % 4);
|
|
641
|
+
try {
|
|
642
|
+
const binary = atob(padded);
|
|
643
|
+
const out = new Uint8Array(binary.length);
|
|
644
|
+
for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
|
|
645
|
+
return out;
|
|
646
|
+
} catch {
|
|
647
|
+
return null;
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
function buildEnvelope(nonce, ciphertext) {
|
|
651
|
+
if (nonce.length !== NONCE_BYTES) {
|
|
652
|
+
throw new Error(`nonce must be ${NONCE_BYTES} bytes, got ${nonce.length}`);
|
|
653
|
+
}
|
|
654
|
+
if (ciphertext.length <= TAG_BYTES) {
|
|
655
|
+
throw new Error(`ciphertext must exceed the ${TAG_BYTES}-byte tag`);
|
|
656
|
+
}
|
|
657
|
+
return `${PREFIX_V1}${encodeBase64Url(nonce)}.${encodeBase64Url(ciphertext)}`;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// ../js/src/vendor/token-crypto.ts
|
|
661
|
+
var ALGORITHM = "AES-GCM";
|
|
662
|
+
var TAG_BITS = TAG_BYTES * 8;
|
|
663
|
+
function subtle() {
|
|
664
|
+
const c = globalThis.crypto;
|
|
665
|
+
if (!c?.subtle) {
|
|
666
|
+
throw new Error("WebCrypto is unavailable \u2014 token encryption needs a secure context");
|
|
667
|
+
}
|
|
668
|
+
return c.subtle;
|
|
669
|
+
}
|
|
670
|
+
function generateKey() {
|
|
671
|
+
return globalThis.crypto.getRandomValues(new Uint8Array(KEY_BYTES));
|
|
672
|
+
}
|
|
673
|
+
function encodeKey(key) {
|
|
674
|
+
if (key.length !== KEY_BYTES) {
|
|
675
|
+
throw new Error(`key must be ${KEY_BYTES} bytes, got ${key.length}`);
|
|
676
|
+
}
|
|
677
|
+
return encodeBase64Url(key);
|
|
678
|
+
}
|
|
679
|
+
function decodeKey(value) {
|
|
680
|
+
const bytes = decodeBase64Url(value);
|
|
681
|
+
if (!bytes || bytes.length !== KEY_BYTES) return null;
|
|
682
|
+
return bytes;
|
|
683
|
+
}
|
|
684
|
+
async function importKey(key, usage) {
|
|
685
|
+
if (key.length !== KEY_BYTES) {
|
|
686
|
+
throw new Error(`key must be ${KEY_BYTES} bytes, got ${key.length}`);
|
|
687
|
+
}
|
|
688
|
+
return subtle().importKey("raw", key, ALGORITHM, false, [usage]);
|
|
689
|
+
}
|
|
690
|
+
async function encryptPayload(plaintext, key) {
|
|
691
|
+
const nonce = globalThis.crypto.getRandomValues(new Uint8Array(NONCE_BYTES));
|
|
692
|
+
const cryptoKey = await importKey(key, "encrypt");
|
|
693
|
+
const ciphertext = await subtle().encrypt(
|
|
694
|
+
{ name: ALGORITHM, iv: nonce, tagLength: TAG_BITS },
|
|
695
|
+
cryptoKey,
|
|
696
|
+
new TextEncoder().encode(plaintext)
|
|
697
|
+
);
|
|
698
|
+
return buildEnvelope(nonce, new Uint8Array(ciphertext));
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// ../js/src/seal.ts
|
|
702
|
+
var DEFAULT_SECRET_LINK_ORIGIN = "https://x-tkn.com";
|
|
703
|
+
async function sealSecret(plaintext) {
|
|
704
|
+
if (typeof plaintext !== "string") {
|
|
705
|
+
throw new XTknConfigError("sealSecret takes a string; JSON.stringify anything else first");
|
|
706
|
+
}
|
|
707
|
+
if (!plaintext) {
|
|
708
|
+
throw new XTknConfigError("sealSecret needs a secret to seal; got an empty string");
|
|
709
|
+
}
|
|
710
|
+
if (!globalThis.crypto?.subtle) {
|
|
711
|
+
throw new XTknConfigError(
|
|
712
|
+
"sealSecret needs a global WebCrypto (globalThis.crypto): Node 20 or later, a browser, Deno, Bun or Workers"
|
|
713
|
+
);
|
|
714
|
+
}
|
|
715
|
+
const key = generateKey();
|
|
716
|
+
return { envelope: await encryptPayload(plaintext, key), key: encodeKey(key) };
|
|
717
|
+
}
|
|
718
|
+
function buildSecretLink(code2, key, options = {}) {
|
|
719
|
+
if (typeof code2 !== "string" || !code2) {
|
|
720
|
+
throw new XTknConfigError("A token code is required");
|
|
721
|
+
}
|
|
722
|
+
if (typeof key !== "string" || !decodeKey(key)) {
|
|
723
|
+
throw new XTknConfigError("key must be the key sealSecret returned: 43 characters of base64url");
|
|
724
|
+
}
|
|
725
|
+
const origin = (options.origin ?? DEFAULT_SECRET_LINK_ORIGIN).replace(/\/+$/, "");
|
|
726
|
+
return `${origin}/s/#${code2}.${key}`;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// src/tools.ts
|
|
730
|
+
var SECRET_TYPE = "secret";
|
|
731
|
+
var SIGNUP_URL = "https://x-tkn.com";
|
|
732
|
+
var SANDBOX_CLAIM_ENDPOINT = "POST /accounts/claim";
|
|
733
|
+
var SANDBOX_EXPIRY_REFUSAL = "Sandbox accounts cannot mint tokens that outlive them.";
|
|
734
|
+
var NO_KEY_MESSAGE = `No X-TKN API key. Set ${API_KEY_ENV_VARS[0]} in this MCP server's environment. To start now with no signup, POST to ${DEFAULT_BASE_URL}/accounts/sandbox for a sandbox key. It lasts 24 hours, and so does every secret it creates. For a key that stays, Developer keys are free: sign up at ${SIGNUP_URL} and create one in the account console.`;
|
|
735
|
+
var SANDBOX_KEY_MESSAGE = `This is a sandbox key. A sandbox account is deleted with its secrets 24 hours after it was created, so no secret it creates can expire later than that. Leave expiresIn out to get the latest expiry it allows. For longer, a person can claim the account at ${SANDBOX_CLAIM_ENDPOINT}, or set ${API_KEY_ENV_VARS[0]} to a free Developer key from ${SIGNUP_URL}.`;
|
|
736
|
+
function sandboxStartupMessage(path) {
|
|
737
|
+
return `No ${API_KEY_ENV_VARS[0]} set, so this server uses a sandbox: the first tool call reuses the one saved in ${path}, or starts a new one that lasts 24 hours. For a key that stays, set ${API_KEY_ENV_VARS[0]} to a free Developer key from ${SIGNUP_URL}.`;
|
|
738
|
+
}
|
|
739
|
+
function sandboxLine(sandbox, path) {
|
|
740
|
+
const ends = `Sandbox key: it and its secrets end at ${sandbox.expiresAt}.`;
|
|
741
|
+
if (sandbox.unsaved) {
|
|
742
|
+
return `${ends} It could not be saved to ${path} (${sandbox.unsaved}), so nobody can claim it, and this server loses it when it stops.`;
|
|
743
|
+
}
|
|
744
|
+
return `${ends} To keep it, a person claims it at ${SANDBOX_CLAIM_ENDPOINT} with the key saved in ${path}. A sandbox has to be claimed before it can be upgraded.`;
|
|
745
|
+
}
|
|
746
|
+
var SANDBOX_RENEWED_MESSAGE = `A new sandbox started: the last sandbox key expired or X-TKN refused it. Codes create_secret returned before this belong to the old sandbox, and check_status and revoke_secret cannot reach them. If that sandbox was claimed, the account lives on: create a Developer key for it in the account console at ${SIGNUP_URL} and set ${API_KEY_ENV_VARS[0]}.`;
|
|
747
|
+
var SANDBOX_MINT_REFUSED_MESSAGE = `X-TKN is not starting another sandbox from this address for now. Set ${API_KEY_ENV_VARS[0]} in this MCP server's environment to a free Developer key from ${SIGNUP_URL}, or try again in a few minutes.`;
|
|
748
|
+
async function createSecret(client, args) {
|
|
749
|
+
if (args.passphrase !== void 0 && args.recipientEmail !== void 0) {
|
|
750
|
+
throw new XTknConfigError("Set passphrase or recipientEmail, not both. A secret takes one recipient check.");
|
|
751
|
+
}
|
|
752
|
+
const { envelope, key } = await sealSecret(args.secret);
|
|
753
|
+
const input = {
|
|
754
|
+
type: SECRET_TYPE,
|
|
755
|
+
payload: envelope,
|
|
756
|
+
maxUses: 1,
|
|
757
|
+
expiresIn: args.expiresIn,
|
|
758
|
+
description: args.note
|
|
759
|
+
};
|
|
760
|
+
if (args.passphrase !== void 0) {
|
|
761
|
+
input.authMethod = "passphrase";
|
|
762
|
+
input.authPassphrase = args.passphrase;
|
|
763
|
+
} else if (args.recipientEmail !== void 0) {
|
|
764
|
+
input.authMethod = "email";
|
|
765
|
+
input.authEmail = args.recipientEmail;
|
|
766
|
+
}
|
|
767
|
+
const token = await client.createToken(input);
|
|
768
|
+
if (!token.code) throw new XTknError("X-TKN created the secret but returned no code for it", 0);
|
|
769
|
+
return {
|
|
770
|
+
link: buildSecretLink(token.code, key),
|
|
771
|
+
code: token.code,
|
|
772
|
+
expiresAt: token.expiresAt ?? null,
|
|
773
|
+
recipientAuth: input.authMethod ?? null
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
async function checkStatus(client, code2) {
|
|
777
|
+
return statusOf(await client.readToken(code2));
|
|
778
|
+
}
|
|
779
|
+
async function revokeSecret(client, code2) {
|
|
780
|
+
return statusOf(await client.revokeToken(code2));
|
|
781
|
+
}
|
|
782
|
+
function statusOf(token) {
|
|
783
|
+
const uses = token.uses ?? 0;
|
|
784
|
+
const opened = token.isUsed ?? (token.maxUses != null && uses >= token.maxUses);
|
|
785
|
+
const status2 = opened ? "opened" : token.isRevoked ? "revoked" : token.isExpired ? "expired" : "waiting";
|
|
786
|
+
return {
|
|
787
|
+
status: status2,
|
|
788
|
+
type: token.type ?? null,
|
|
789
|
+
openedAt: uses > 0 ? token.lastUsedAt ?? null : null,
|
|
790
|
+
expiresAt: token.expiresAt ?? null,
|
|
791
|
+
createdAt: token.createdAt ?? null
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
function describeError(err, origin = "key") {
|
|
795
|
+
if (origin === "mint" && err instanceof XTknRateLimitError) return SANDBOX_MINT_REFUSED_MESSAGE;
|
|
796
|
+
if (origin === "sandbox" && err instanceof XTknRateLimitError) {
|
|
797
|
+
return `X-TKN is refusing more requests on this sandbox key (${err.message}). To go on, a person can claim the sandbox at ${SANDBOX_CLAIM_ENDPOINT}, which lifts its cap, or set ${API_KEY_ENV_VARS[0]} to a free Developer key from ${SIGNUP_URL}.`;
|
|
798
|
+
}
|
|
799
|
+
if (origin === "sandbox" && err instanceof XTknAuthError && err.status === 401) {
|
|
800
|
+
return `X-TKN refused this server's sandbox key (${err.message}). Set ${API_KEY_ENV_VARS[0]} to a free Developer key from ${SIGNUP_URL}.`;
|
|
801
|
+
}
|
|
802
|
+
if (err instanceof XTknPaymentRequiredError) {
|
|
803
|
+
const plan = err.requiredPlan ? `the ${err.requiredPlan} plan` : "a paid plan";
|
|
804
|
+
if (err.upgradeMethod === "claim") {
|
|
805
|
+
const claim = err.claimEndpoint ?? SANDBOX_CLAIM_ENDPOINT;
|
|
806
|
+
return `${err.message} This needs ${plan}, and this is a sandbox key: a person has to claim the account at ${claim} before it can be upgraded.`;
|
|
807
|
+
}
|
|
808
|
+
const where = err.upgradeUrl ? ` A person can upgrade here: ${err.upgradeUrl}` : "";
|
|
809
|
+
return `${err.message} This needs ${plan}.${where}`;
|
|
810
|
+
}
|
|
811
|
+
if (err instanceof XTknRequestError && err.message.startsWith(SANDBOX_EXPIRY_REFUSAL)) return SANDBOX_KEY_MESSAGE;
|
|
812
|
+
if (err instanceof XTknConfigError && err.message.startsWith("No API key")) return NO_KEY_MESSAGE;
|
|
813
|
+
if (err instanceof XTknAuthError && err.status === 401) {
|
|
814
|
+
return `X-TKN refused the API key in ${API_KEY_ENV_VARS[0]} (${err.message}). It may be mistyped or revoked.`;
|
|
815
|
+
}
|
|
816
|
+
if (err instanceof XTknNotFoundError) {
|
|
817
|
+
return "No secret with that code on this account. Use the code create_secret returned.";
|
|
818
|
+
}
|
|
819
|
+
if (err instanceof XTknRateLimitError) return `X-TKN is refusing more requests for now: ${err.message}`;
|
|
820
|
+
if (err instanceof XTknConnectionError) return `Could not reach X-TKN: ${err.message}`;
|
|
821
|
+
if (err instanceof XTknError) return err.status ? `X-TKN answered ${err.status}: ${err.message}` : err.message;
|
|
822
|
+
return "The tool failed unexpectedly.";
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
// src/sandbox.ts
|
|
826
|
+
async function readSandboxCache(path, baseUrl) {
|
|
827
|
+
let parsed;
|
|
828
|
+
try {
|
|
829
|
+
parsed = JSON.parse(await readFile(path, "utf8"));
|
|
830
|
+
} catch {
|
|
831
|
+
return void 0;
|
|
832
|
+
}
|
|
833
|
+
if (!parsed || typeof parsed !== "object") return void 0;
|
|
834
|
+
const { apiKey, expiresAt, baseUrl: cachedBaseUrl } = parsed;
|
|
835
|
+
if (typeof apiKey !== "string" || apiKey === "") return void 0;
|
|
836
|
+
if (typeof expiresAt !== "string" || Number.isNaN(Date.parse(expiresAt))) return void 0;
|
|
837
|
+
if (cachedBaseUrl !== baseUrl) return void 0;
|
|
838
|
+
return { apiKey, expiresAt, baseUrl };
|
|
839
|
+
}
|
|
840
|
+
async function writeSandboxCache(path, sandbox) {
|
|
841
|
+
await prepareDir(dirname(path));
|
|
842
|
+
const temp = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
|
|
843
|
+
try {
|
|
844
|
+
await writeFile(temp, `${JSON.stringify(sandbox, null, 2)}
|
|
845
|
+
`, { mode: 384, flag: "wx" });
|
|
846
|
+
await rename(temp, path);
|
|
847
|
+
} catch (err) {
|
|
848
|
+
await rm(temp, { force: true });
|
|
849
|
+
throw err;
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
async function prepareDir(dir) {
|
|
853
|
+
await mkdir(dir, { recursive: true, mode: 448 });
|
|
854
|
+
await chmod(dir, 448);
|
|
855
|
+
}
|
|
856
|
+
var LOCK_STALE_MS = DEFAULT_TIMEOUT_MS + 1e4;
|
|
857
|
+
var LOCK_POLL_MS = 25;
|
|
858
|
+
async function withCacheLock(path, work) {
|
|
859
|
+
const lock = `${path}.lock`;
|
|
860
|
+
const token = randomBytes(8).toString("hex");
|
|
861
|
+
if (!await acquireLock(path, lock, token)) return work();
|
|
862
|
+
try {
|
|
863
|
+
return await work();
|
|
864
|
+
} finally {
|
|
865
|
+
await releaseLock(lock, token);
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
async function acquireLock(path, lock, token) {
|
|
869
|
+
try {
|
|
870
|
+
await prepareDir(dirname(path));
|
|
871
|
+
for (; ; ) {
|
|
872
|
+
try {
|
|
873
|
+
await writeFile(lock, token, { mode: 384, flag: "wx" });
|
|
874
|
+
return true;
|
|
875
|
+
} catch (err) {
|
|
876
|
+
if (err.code !== "EEXIST") return false;
|
|
877
|
+
}
|
|
878
|
+
let age;
|
|
879
|
+
try {
|
|
880
|
+
age = Date.now() - (await stat(lock)).mtimeMs;
|
|
881
|
+
} catch (err) {
|
|
882
|
+
if (err.code === "ENOENT") continue;
|
|
883
|
+
return false;
|
|
884
|
+
}
|
|
885
|
+
if (age > LOCK_STALE_MS) await rm(lock, { force: true });
|
|
886
|
+
else await new Promise((resolve) => setTimeout(resolve, LOCK_POLL_MS));
|
|
887
|
+
}
|
|
888
|
+
} catch {
|
|
889
|
+
return false;
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
async function releaseLock(lock, token) {
|
|
893
|
+
try {
|
|
894
|
+
if (await readFile(lock, "utf8") === token) await rm(lock, { force: true });
|
|
895
|
+
} catch {
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
var SandboxConnection = class {
|
|
899
|
+
path;
|
|
900
|
+
baseUrl;
|
|
901
|
+
clientOptions;
|
|
902
|
+
now;
|
|
903
|
+
held;
|
|
904
|
+
pending;
|
|
905
|
+
constructor(options) {
|
|
906
|
+
this.path = options.path;
|
|
907
|
+
this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
908
|
+
this.clientOptions = { baseUrl: this.baseUrl, fetch: options.fetch, headers: options.headers };
|
|
909
|
+
this.now = options.now ?? Date.now;
|
|
910
|
+
}
|
|
911
|
+
async call(work) {
|
|
912
|
+
let acquired;
|
|
913
|
+
try {
|
|
914
|
+
acquired = await this.acquire();
|
|
915
|
+
} catch (error) {
|
|
916
|
+
return { ok: false, error, origin: "mint", notes: [] };
|
|
917
|
+
}
|
|
918
|
+
const { sandbox, fresh, renewed } = acquired;
|
|
919
|
+
try {
|
|
920
|
+
return { ok: true, value: await work(this.clientFor(sandbox)), notes: this.notes(sandbox, renewed) };
|
|
921
|
+
} catch (error) {
|
|
922
|
+
if (fresh || !isUnauthorized(error)) {
|
|
923
|
+
return { ok: false, error, origin: "sandbox", notes: this.notes(sandbox, renewed) };
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
let replacement;
|
|
927
|
+
try {
|
|
928
|
+
replacement = await this.replace(sandbox);
|
|
929
|
+
} catch (error) {
|
|
930
|
+
return { ok: false, error, origin: "mint", notes: [] };
|
|
931
|
+
}
|
|
932
|
+
try {
|
|
933
|
+
return { ok: true, value: await work(this.clientFor(replacement)), notes: this.notes(replacement, true) };
|
|
934
|
+
} catch (error) {
|
|
935
|
+
return { ok: false, error, origin: "sandbox", notes: this.notes(replacement, true) };
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
/**
|
|
939
|
+
* The sandbox for this call: the one held while it lasts, then the file's,
|
|
940
|
+
* then a new one.
|
|
941
|
+
*
|
|
942
|
+
* `fresh` is whether it was minted for this call. `renewed` is whether it
|
|
943
|
+
* replaced a sandbox this server or the file had, so the codes from that one
|
|
944
|
+
* are gone.
|
|
945
|
+
*/
|
|
946
|
+
async acquire() {
|
|
947
|
+
const held = this.held;
|
|
948
|
+
if (held && !this.expired(held)) return { sandbox: held, fresh: false, renewed: false };
|
|
949
|
+
const sandbox = await this.single(() => this.load());
|
|
950
|
+
return {
|
|
951
|
+
sandbox,
|
|
952
|
+
fresh: sandbox.minted,
|
|
953
|
+
renewed: held ? sandbox.apiKey !== held.apiKey : sandbox.replaced
|
|
954
|
+
};
|
|
955
|
+
}
|
|
956
|
+
async load() {
|
|
957
|
+
const cached = await readSandboxCache(this.path, this.baseUrl);
|
|
958
|
+
if (cached && !this.expired(cached)) return this.adopt(cached, false);
|
|
959
|
+
return withCacheLock(this.path, async () => {
|
|
960
|
+
const again = await readSandboxCache(this.path, this.baseUrl);
|
|
961
|
+
if (again && !this.expired(again)) return this.adopt(again, cached !== void 0);
|
|
962
|
+
return this.mint(cached !== void 0 || again !== void 0);
|
|
963
|
+
});
|
|
964
|
+
}
|
|
965
|
+
/**
|
|
966
|
+
* A sandbox in place of `refused`, unless a concurrent call, or another
|
|
967
|
+
* server, already made one.
|
|
968
|
+
*/
|
|
969
|
+
replace(refused) {
|
|
970
|
+
return this.single(async () => {
|
|
971
|
+
const held = this.held;
|
|
972
|
+
if (held && held.apiKey !== refused.apiKey && !this.expired(held)) return held;
|
|
973
|
+
return withCacheLock(this.path, async () => {
|
|
974
|
+
const cached = await readSandboxCache(this.path, this.baseUrl);
|
|
975
|
+
if (cached && cached.apiKey !== refused.apiKey && !this.expired(cached)) return this.adopt(cached, true);
|
|
976
|
+
return this.mint(true);
|
|
977
|
+
});
|
|
978
|
+
});
|
|
979
|
+
}
|
|
980
|
+
/** Hold a sandbox another server minted, which this one found in the file. */
|
|
981
|
+
adopt(cached, replaced) {
|
|
982
|
+
this.held = { ...cached, minted: false, replaced };
|
|
983
|
+
return this.held;
|
|
984
|
+
}
|
|
985
|
+
async mint(replaced) {
|
|
986
|
+
const { account } = await XTkn.createSandbox(this.clientOptions);
|
|
987
|
+
if (typeof account?.apiKey !== "string" || !account.apiKey || Number.isNaN(Date.parse(account.expiresAt))) {
|
|
988
|
+
throw new XTknError("X-TKN started a sandbox but returned no key or expiry for it", 0);
|
|
989
|
+
}
|
|
990
|
+
const sandbox = { apiKey: account.apiKey, expiresAt: account.expiresAt, baseUrl: this.baseUrl };
|
|
991
|
+
let unsaved;
|
|
992
|
+
try {
|
|
993
|
+
await writeSandboxCache(this.path, sandbox);
|
|
994
|
+
} catch (err) {
|
|
995
|
+
unsaved = err?.code ?? "write failed";
|
|
996
|
+
}
|
|
997
|
+
this.held = { ...sandbox, minted: true, replaced, unsaved };
|
|
998
|
+
return this.held;
|
|
999
|
+
}
|
|
1000
|
+
/** One mint or file read at a time. Concurrent first calls share the one sandbox. */
|
|
1001
|
+
single(start) {
|
|
1002
|
+
this.pending ??= start().finally(() => {
|
|
1003
|
+
this.pending = void 0;
|
|
1004
|
+
});
|
|
1005
|
+
return this.pending;
|
|
1006
|
+
}
|
|
1007
|
+
expired(sandbox) {
|
|
1008
|
+
return Date.parse(sandbox.expiresAt) <= this.now();
|
|
1009
|
+
}
|
|
1010
|
+
clientFor(sandbox) {
|
|
1011
|
+
return new XTkn({ ...this.clientOptions, apiKey: sandbox.apiKey });
|
|
1012
|
+
}
|
|
1013
|
+
notes(sandbox, renewed) {
|
|
1014
|
+
const line = sandboxLine(sandbox, this.path);
|
|
1015
|
+
return renewed ? [SANDBOX_RENEWED_MESSAGE, line] : [line];
|
|
1016
|
+
}
|
|
1017
|
+
};
|
|
1018
|
+
function isUnauthorized(err) {
|
|
1019
|
+
return err instanceof XTknAuthError && err.status === 401;
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
// src/version.ts
|
|
1023
|
+
var VERSION = "1.2.0";
|
|
1024
|
+
|
|
1025
|
+
// src/env.ts
|
|
1026
|
+
var USER_AGENT = `x-tkn-mcp/${VERSION}`;
|
|
1027
|
+
function apiKeyFrom(env) {
|
|
1028
|
+
for (const name of API_KEY_ENV_VARS) {
|
|
1029
|
+
if (env[name]) return env[name];
|
|
1030
|
+
}
|
|
1031
|
+
return void 0;
|
|
1032
|
+
}
|
|
1033
|
+
function clientFrom(env, options = {}) {
|
|
1034
|
+
return new XTkn({ ...options, apiKey: apiKeyFrom(env), headers: { "User-Agent": USER_AGENT } });
|
|
1035
|
+
}
|
|
1036
|
+
function sandboxCachePath(env, home = homedir()) {
|
|
1037
|
+
const xdg = env.XDG_CONFIG_HOME;
|
|
1038
|
+
const configHome = xdg && isAbsolute(xdg) ? xdg : join(home, ".config");
|
|
1039
|
+
return join(configHome, "x-tkn", "sandbox.json");
|
|
1040
|
+
}
|
|
1041
|
+
function connectionFrom(env, options = {}) {
|
|
1042
|
+
const { home, now, ...client } = options;
|
|
1043
|
+
if (apiKeyFrom(env)) return keyConnection(clientFrom(env, client));
|
|
1044
|
+
return new SandboxConnection({
|
|
1045
|
+
...client,
|
|
1046
|
+
path: sandboxCachePath(env, home),
|
|
1047
|
+
headers: { "User-Agent": USER_AGENT },
|
|
1048
|
+
now
|
|
1049
|
+
});
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
// src/server.ts
|
|
1053
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
1054
|
+
import { z } from "zod";
|
|
1055
|
+
var INSTRUCTIONS = [
|
|
1056
|
+
"X-TKN hands a secret to a person as a one-time link.",
|
|
1057
|
+
"create_secret encrypts the secret on this machine. X-TKN stores only ciphertext, and the key travels in the link after the #.",
|
|
1058
|
+
"There is no tool to read a secret back. After handing one over you can check whether it was opened (check_status) or cancel it (revoke_secret).",
|
|
1059
|
+
"Give the link only to the person it is for: opening it uses it up. If you set a passphrase, send it by a different channel than the link.",
|
|
1060
|
+
// Read at connect time, before any tool call can say it. An agent with no
|
|
1061
|
+
// key should start building, not stop to ask a person to sign up, and since
|
|
1062
|
+
// 1.2.0 it need not even fetch a key: `connectionFrom` mints one.
|
|
1063
|
+
"With no X_TKN_API_KEY set, this server gets a sandbox key itself on the first tool call: no signup, and it creates real secrets for 24 hours, long enough to build and test the integration.",
|
|
1064
|
+
`For a key that stays, a person can claim the sandbox at ${SANDBOX_CLAIM_ENDPOINT}, or sign up at ${SIGNUP_URL} for a free Developer key.`
|
|
1065
|
+
].join(" ");
|
|
1066
|
+
var durationField = z.number().int().min(0);
|
|
1067
|
+
var expiresIn = z.object({
|
|
1068
|
+
days: durationField.optional(),
|
|
1069
|
+
hours: durationField.optional(),
|
|
1070
|
+
minutes: durationField.optional()
|
|
1071
|
+
}).refine((d) => (d.days ?? 0) + (d.hours ?? 0) + (d.minutes ?? 0) > 0, {
|
|
1072
|
+
message: "expiresIn needs at least one field above zero"
|
|
1073
|
+
});
|
|
1074
|
+
var code = z.string().min(1).describe("The code create_secret returned for this secret.");
|
|
1075
|
+
var statusShape = {
|
|
1076
|
+
status: z.enum(["waiting", "opened", "expired", "revoked"]).describe("waiting: not opened yet. opened: the recipient has it, and the link no longer works."),
|
|
1077
|
+
type: z.string().nullable(),
|
|
1078
|
+
openedAt: z.string().nullable(),
|
|
1079
|
+
expiresAt: z.string().nullable(),
|
|
1080
|
+
createdAt: z.string().nullable()
|
|
1081
|
+
};
|
|
1082
|
+
function createXtknMcpServer(options) {
|
|
1083
|
+
const connection = "connection" in options ? options.connection : keyConnection(options.client);
|
|
1084
|
+
const server = new McpServer({ name: "x-tkn", title: "X-TKN", version: VERSION }, { instructions: INSTRUCTIONS });
|
|
1085
|
+
server.registerTool(
|
|
1086
|
+
"create_secret",
|
|
1087
|
+
{
|
|
1088
|
+
title: "Create a one-time secret link",
|
|
1089
|
+
description: "Turn a secret (a password, API key, recovery code or private note) into a link a person opens once. The secret is encrypted on this machine before anything is sent, and X-TKN never holds the key. The link stops working once opened, or at its expiry. You cannot read the secret back afterwards: this server has no read tool. Returns the link and a code for check_status and revoke_secret. Up to 64 KB on the free Developer plan, 256 KB on Team and Enterprise.",
|
|
1090
|
+
inputSchema: {
|
|
1091
|
+
secret: z.string().min(1).describe("The secret to hand over. It is encrypted before it leaves this machine."),
|
|
1092
|
+
expiresIn: expiresIn.optional().describe(
|
|
1093
|
+
"How long the link works if nobody opens it. Defaults to the plan's: 7 days on Developer, 30 on Team. On a sandbox key it cannot run past the sandbox, which ends 24 hours after it was created, and defaults to that end."
|
|
1094
|
+
),
|
|
1095
|
+
passphrase: z.string().min(8).max(256).optional().describe(
|
|
1096
|
+
"Make the recipient type this before the secret opens. Send it by a different channel than the link. X-TKN stores a hash of it, which gates the link and plays no part in the encryption. Team plan and above."
|
|
1097
|
+
),
|
|
1098
|
+
recipientEmail: z.email().optional().describe(
|
|
1099
|
+
"Make the recipient enter a 6-digit code emailed to this address before the secret opens. Team plan and above. Set this or passphrase, not both."
|
|
1100
|
+
),
|
|
1101
|
+
note: z.string().max(4e3).optional().describe(
|
|
1102
|
+
"A note for your own records, shown in the X-TKN console and never to the recipient. Stored unencrypted, so never put the secret in it."
|
|
1103
|
+
)
|
|
1104
|
+
},
|
|
1105
|
+
outputSchema: {
|
|
1106
|
+
link: z.string().describe("The one-time link for the recipient."),
|
|
1107
|
+
code: z.string().describe("Pass this to check_status or revoke_secret."),
|
|
1108
|
+
expiresAt: z.string().nullable(),
|
|
1109
|
+
recipientAuth: z.enum(["passphrase", "email"]).nullable()
|
|
1110
|
+
},
|
|
1111
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }
|
|
1112
|
+
},
|
|
1113
|
+
(args) => run(connection, async (client) => created(await createSecret(client, args)))
|
|
1114
|
+
);
|
|
1115
|
+
server.registerTool(
|
|
1116
|
+
"check_status",
|
|
1117
|
+
{
|
|
1118
|
+
title: "Check a secret link",
|
|
1119
|
+
description: "Whether a secret link is still waiting, has been opened, expired, or was revoked. Never returns the secret. Each call is one metered operation, and a sandbox has 100 in all, so call this no more often than every 30 seconds.",
|
|
1120
|
+
inputSchema: { code },
|
|
1121
|
+
outputSchema: statusShape,
|
|
1122
|
+
annotations: { readOnlyHint: true, openWorldHint: true }
|
|
1123
|
+
},
|
|
1124
|
+
({ code: code2 }) => run(connection, async (client) => status(await checkStatus(client, code2)))
|
|
1125
|
+
);
|
|
1126
|
+
server.registerTool(
|
|
1127
|
+
"revoke_secret",
|
|
1128
|
+
{
|
|
1129
|
+
title: "Revoke a secret link",
|
|
1130
|
+
description: "Cancel a secret link so it can no longer be opened. Permanent, and immediate. A secret that was already opened stays opened: revoking cannot take it back from the recipient.",
|
|
1131
|
+
inputSchema: { code },
|
|
1132
|
+
outputSchema: statusShape,
|
|
1133
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true }
|
|
1134
|
+
},
|
|
1135
|
+
({ code: code2 }) => run(connection, async (client) => status(await revokeSecret(client, code2)))
|
|
1136
|
+
);
|
|
1137
|
+
return server;
|
|
1138
|
+
}
|
|
1139
|
+
async function run(connection, work) {
|
|
1140
|
+
const called = await connection.call(work);
|
|
1141
|
+
const result = called.ok ? called.value : { content: [{ type: "text", text: describeError(called.error, called.origin) }], isError: true };
|
|
1142
|
+
if (called.notes.length === 0) return result;
|
|
1143
|
+
return { ...result, content: [...result.content, { type: "text", text: called.notes.join("\n") }] };
|
|
1144
|
+
}
|
|
1145
|
+
function created(secret) {
|
|
1146
|
+
const lines = [
|
|
1147
|
+
`One-time link: ${secret.link}`,
|
|
1148
|
+
`It opens once, then stops working. Expires: ${secret.expiresAt ?? "on the plan default"}.`
|
|
1149
|
+
];
|
|
1150
|
+
if (secret.recipientAuth === "passphrase") {
|
|
1151
|
+
lines.push("The recipient will be asked for the passphrase. Send it by a different channel than the link.");
|
|
1152
|
+
} else if (secret.recipientAuth === "email") {
|
|
1153
|
+
lines.push("The recipient will be emailed a 6-digit code to enter before it opens.");
|
|
1154
|
+
}
|
|
1155
|
+
lines.push(`Code for check_status and revoke_secret: ${secret.code}`);
|
|
1156
|
+
return { content: [{ type: "text", text: lines.join("\n") }], structuredContent: { ...secret } };
|
|
1157
|
+
}
|
|
1158
|
+
var STATUS_TEXT = {
|
|
1159
|
+
waiting: "Waiting: not opened yet.",
|
|
1160
|
+
opened: "Opened: the recipient has it, and the link no longer works.",
|
|
1161
|
+
expired: "Expired before anyone opened it.",
|
|
1162
|
+
revoked: "Revoked before anyone opened it."
|
|
1163
|
+
};
|
|
1164
|
+
function status(secret) {
|
|
1165
|
+
const lines = [STATUS_TEXT[secret.status]];
|
|
1166
|
+
if (secret.openedAt) lines.push(`Opened at ${secret.openedAt}.`);
|
|
1167
|
+
if (secret.status === "waiting" && secret.expiresAt) lines.push(`Expires at ${secret.expiresAt}.`);
|
|
1168
|
+
return { content: [{ type: "text", text: lines.join(" ") }], structuredContent: { ...secret } };
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
// src/index.ts
|
|
1172
|
+
async function main(argv) {
|
|
1173
|
+
if (argv.includes("--version")) {
|
|
1174
|
+
process.stdout.write(`${VERSION}
|
|
1175
|
+
`);
|
|
1176
|
+
return;
|
|
1177
|
+
}
|
|
1178
|
+
if (!apiKeyFrom(process.env)) console.error(sandboxStartupMessage(sandboxCachePath(process.env)));
|
|
1179
|
+
const server = createXtknMcpServer({ connection: connectionFrom(process.env) });
|
|
1180
|
+
await server.connect(new StdioServerTransport());
|
|
1181
|
+
}
|
|
1182
|
+
main(process.argv.slice(2)).catch((err) => {
|
|
1183
|
+
console.error("x-tkn-mcp failed to start:", err instanceof Error ? err.message : err);
|
|
1184
|
+
process.exit(1);
|
|
1185
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,49 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fennecstudio/x-tkn-mcp",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "1.2.0",
|
|
4
|
+
"mcpName": "io.github.fennecstudio/x-tkn-mcp",
|
|
5
|
+
"description": "MCP server for X-TKN: hand a secret to a person as a one-time link, encrypted on your machine. No read tool.",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"mcp",
|
|
8
|
+
"model-context-protocol",
|
|
9
|
+
"x-tkn",
|
|
10
|
+
"secret",
|
|
11
|
+
"one-time",
|
|
12
|
+
"agent"
|
|
13
|
+
],
|
|
14
|
+
"homepage": "https://x-tkn.com",
|
|
15
|
+
"license": "ISC",
|
|
16
|
+
"author": "Fennec Studio",
|
|
17
|
+
"type": "module",
|
|
18
|
+
"bin": {
|
|
19
|
+
"x-tkn-mcp": "dist/index.js"
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"dist"
|
|
23
|
+
],
|
|
24
|
+
"publishConfig": {
|
|
25
|
+
"access": "public"
|
|
26
|
+
},
|
|
27
|
+
"scripts": {
|
|
28
|
+
"vendor": "node ../js/scripts/vendor-crypto.mjs",
|
|
29
|
+
"build": "npm run vendor && node scripts/build.mjs",
|
|
30
|
+
"typecheck": "npm run vendor && tsc -p tsconfig.json --noEmit",
|
|
31
|
+
"prepublishOnly": "npm run typecheck && npm run test && npm run build",
|
|
32
|
+
"deploy": "npm publish",
|
|
33
|
+
"test": "npm run vendor && vitest run"
|
|
34
|
+
},
|
|
35
|
+
"engines": {
|
|
36
|
+
"node": ">=20"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@modelcontextprotocol/sdk": "1.30.1",
|
|
40
|
+
"zod": "^4.5.4"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@types/node": "^22.5.4",
|
|
44
|
+
"esbuild": "^0.25.12",
|
|
45
|
+
"typescript": "^5.7.3",
|
|
46
|
+
"vitest": "^3.2.4"
|
|
47
|
+
},
|
|
48
|
+
"packageManager": "pnpm@10.34.5"
|
|
49
|
+
}
|