ncrypted 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -11,10 +11,16 @@ Usage:
11
11
  ncrypted read <link> [--passphrase X]
12
12
  ncrypted status <id> --receipt <token>
13
13
  ncrypted burn <id> --receipt <token>
14
+ ncrypted vercel-env <link> --key NAME [--project P] [--team T] [--target production --target preview]
15
+ write a secret straight into a Vercel env var (never printed)
16
+ ncrypted dotenv <link> --key NAME [--path .env]
17
+ ncrypted request "<what you need>" [--ttl 60m] ask a human for a secret; prints a link for them
18
+ ncrypted receive <id> (--vercel-key NAME [--project P] | --dotenv-key NAME [--path .env] | --print) [--wait 120]
14
19
  ncrypted wallet new create a payer wallet at ~/.config/ncrypted/wallet.key
15
20
  ncrypted wallet show the payer address and its USDC balance on Base
16
21
 
17
- Creating a secret needs a Base wallet holding a little USDC (no ETH needed). The key is read from
22
+ The first 3 secrets per day are free (TTL up to 24h, no email). After that, creating a secret needs a Base
23
+ wallet holding a little USDC (no ETH needed). The key is read from
18
24
  NCRYPTED_WALLET_PRIVATE_KEY, else NCRYPTED_WALLET_FILE, else ~/.config/ncrypted/wallet.key.
19
25
  NCRYPTED_URL overrides the API origin.`;
20
26
  function parseTtl(v) {
@@ -43,12 +49,21 @@ async function main() {
43
49
  note: { type: "string" },
44
50
  text: { type: "string" },
45
51
  receipt: { type: "string" },
52
+ key: { type: "string" },
53
+ project: { type: "string" },
54
+ team: { type: "string" },
55
+ target: { type: "string", multiple: true },
56
+ path: { type: "string" },
57
+ wait: { type: "string" },
58
+ "vercel-key": { type: "string" },
59
+ "dotenv-key": { type: "string" },
60
+ print: { type: "boolean" },
46
61
  json: { type: "boolean" },
47
62
  help: { type: "boolean", short: "h" },
48
63
  },
49
64
  });
50
65
  const [cmd, arg] = positionals;
51
- const client = new Ncrypted();
66
+ const client = new Ncrypted({ client: "cli" });
52
67
  const out = (v) => console.log(values.json ? JSON.stringify(v) : typeof v === "string" ? v : JSON.stringify(v, null, 2));
53
68
  switch (values.help ? "help" : cmd) {
54
69
  case "create": {
@@ -74,6 +89,35 @@ async function main() {
74
89
  await client.burn(arg, values.receipt);
75
90
  out({ burned: true });
76
91
  break;
92
+ case "vercel-env":
93
+ if (!arg || !values.key)
94
+ throw new Error("Usage: ncrypted vercel-env <link> --key NAME [--project P] [--target production]");
95
+ out(await client.deliver(arg, { type: "vercel_env", key: values.key, project: values.project, team: values.team, targets: values.target }, { passphrase: values.passphrase }));
96
+ break;
97
+ case "dotenv":
98
+ if (!arg || !values.key)
99
+ throw new Error("Usage: ncrypted dotenv <link> --key NAME [--path .env]");
100
+ out(await client.deliver(arg, { type: "dotenv", key: values.key, path: values.path }, { passphrase: values.passphrase }));
101
+ break;
102
+ case "request":
103
+ if (!arg)
104
+ throw new Error('Usage: ncrypted request "<what you need>" [--ttl 60m]');
105
+ out(await client.requestSecret(arg, { ttl: parseTtl(values.ttl) ?? 3600 }));
106
+ break;
107
+ case "receive": {
108
+ if (!arg)
109
+ throw new Error("Usage: ncrypted receive <id> (--vercel-key NAME | --dotenv-key NAME | --print)");
110
+ const destination = values["vercel-key"]
111
+ ? { type: "vercel_env", key: values["vercel-key"], project: values.project, team: values.team, targets: values.target }
112
+ : values["dotenv-key"]
113
+ ? { type: "dotenv", key: values["dotenv-key"], path: values.path }
114
+ : undefined;
115
+ if (!destination && !values.print)
116
+ throw new Error("Choose where the value goes: --vercel-key, --dotenv-key, or --print");
117
+ const res = await client.fulfillRequest(arg, { destination, waitSeconds: Number(parseTtl(values.wait) ?? 120) });
118
+ out(res.status === "received" ? res.value : res);
119
+ break;
120
+ }
77
121
  case "wallet": {
78
122
  if (arg === "new") {
79
123
  const { address, path } = await createWallet().catch((e) => {
@@ -81,15 +125,18 @@ async function main() {
81
125
  throw new Error(`A wallet already exists at ${walletPath()}`);
82
126
  throw e;
83
127
  });
84
- out({ address, path, next: "Send a little USDC on Base to this address. Each secret costs 0.005 USDC; no ETH needed." });
128
+ out({ address, path, fundUrl: client.fundUrl(address), next: "Send a little USDC on Base to this address (or open fundUrl). Each secret costs 0.005 USDC; no ETH needed." });
85
129
  break;
86
130
  }
87
131
  const key = normalizeKey(process.env.NCRYPTED_WALLET_PRIVATE_KEY) ?? (await readWalletKey());
88
- if (!key)
89
- throw new Error("No wallet configured. Run `npx ncrypted wallet new`.");
132
+ const { freeRemaining, freePerDay } = await client.quota();
133
+ if (!key) {
134
+ out({ wallet: null, freeRemainingToday: freeRemaining, freePerDay, next: "Run `npx ncrypted wallet new` to pay for more than the free tier." });
135
+ break;
136
+ }
90
137
  const address = privateKeyToAccount(key).address;
91
138
  const usdc = await usdcBalance(address);
92
- out({ address, usdc, secretsAvailable: Math.floor(usdc / 0.005) });
139
+ out({ address, usdc, paidSecretsAvailable: Math.floor(usdc / 0.005), freeRemainingToday: freeRemaining });
93
140
  break;
94
141
  }
95
142
  default:
package/dist/client.d.ts CHANGED
@@ -1,6 +1,8 @@
1
+ import { type Destination, type DeliveryResult } from "./destinations.js";
1
2
  export declare const DEFAULT_BASE_URL = "https://ncrypted.ai";
2
3
  /** Hard cap on what the SDK will ever pay for one secret, whatever the server asks. */
3
4
  export declare const MAX_PRICE = "$0.01";
5
+ export declare const SDK_VERSION = "0.2.0";
4
6
  export interface NcryptedOptions {
5
7
  /** API origin. Defaults to NCRYPTED_URL or the hosted service. */
6
8
  baseUrl?: string;
@@ -11,6 +13,8 @@ export interface NcryptedOptions {
11
13
  privateKey?: string;
12
14
  /** Bring your own x402-paying fetch instead of a private key. */
13
15
  paymentFetch?: typeof fetch;
16
+ /** Integration label sent as x-ncrypted-client for anonymous usage stats, e.g. "ai-sdk". Default "sdk". */
17
+ client?: string;
14
18
  }
15
19
  export interface CreateOptions {
16
20
  /** Lifetime in seconds, 300 to 604800 (7 days). Default 86400. */
@@ -21,6 +25,8 @@ export interface CreateOptions {
21
25
  emailTo?: string;
22
26
  /** Optional short note shown in the email, e.g. "DB password for staging". Never put the secret here. */
23
27
  emailNote?: string;
28
+ /** Skip the free tier and always pay (implied by emailTo). */
29
+ requirePayment?: boolean;
24
30
  }
25
31
  export interface CreatedSecret {
26
32
  id: string;
@@ -32,6 +38,19 @@ export interface CreatedSecret {
32
38
  emailed: boolean;
33
39
  /** On-chain settlement transaction hash, if reported. */
34
40
  paymentTx?: string;
41
+ /** True when created under the free tier (no payment). */
42
+ free: boolean;
43
+ /** Free secrets left today, when created for free. */
44
+ freeRemaining?: number;
45
+ }
46
+ export interface Quota {
47
+ freePerDay: number;
48
+ freeRemaining: number;
49
+ /** Longest TTL (seconds) allowed for free secrets. */
50
+ freeMaxTtl: number;
51
+ price: string;
52
+ currency: string;
53
+ network: string;
35
54
  }
36
55
  export type SecretStatus = "pending" | "read" | "burned" | "expired";
37
56
  export declare class NcryptedError extends Error {
@@ -45,8 +64,70 @@ export declare class Ncrypted {
45
64
  constructor(opts?: NcryptedOptions);
46
65
  /** Resolves the paying fetch lazily, so a missing or bad wallet only affects create(), never reads. */
47
66
  private payer;
48
- /** Encrypts locally, pays 0.005 USDC via x402, stores the ciphertext and returns a one-time link. */
67
+ private headers;
68
+ /**
69
+ * Encrypts locally and stores the ciphertext, returning a one-time link. The first few secrets per day are free
70
+ * (TTL up to 24h, no email); after that each costs 0.005 USDC via x402 from the configured wallet.
71
+ */
49
72
  create(secret: string, opts?: CreateOptions): Promise<CreatedSecret>;
73
+ /**
74
+ * Reads a one-time link and writes the value straight into a destination (Vercel env var, .env file), so it never
75
+ * passes through the caller (e.g. an LLM). The destination is checked before the secret is consumed. If writing still
76
+ * fails afterwards, the value is re-saved to a new one-time link, which is returned in the error.
77
+ */
78
+ deliver(link: string, destination: Destination, opts?: {
79
+ passphrase?: string;
80
+ }): Promise<DeliveryResult>;
81
+ private deliverOrRescue;
82
+ /**
83
+ * Starts a secret request: returns a link for a human, who pastes the value into the page. It's encrypted in their
84
+ * browser to a key that only exists on this machine. Free. Complete it with fulfillRequest().
85
+ */
86
+ requestSecret(label: string, opts?: {
87
+ ttl?: number;
88
+ }): Promise<{
89
+ id: string;
90
+ url: string;
91
+ expiresAt: string;
92
+ }>;
93
+ /**
94
+ * Waits up to waitSeconds for the human to answer a request. With a destination, the value is written there and
95
+ * never returned; without one, the value is returned. Resolves { status: "pending" } if not answered yet.
96
+ */
97
+ fulfillRequest(id: string, opts?: {
98
+ destination?: Destination;
99
+ waitSeconds?: number;
100
+ }): Promise<{
101
+ status: "pending";
102
+ expiresAt: string;
103
+ } | {
104
+ status: "delivered";
105
+ result: DeliveryResult;
106
+ } | {
107
+ status: "received";
108
+ value: string;
109
+ }>;
110
+ /** Link to a page where the wallet's owner can send it USDC on Base. */
111
+ fundUrl(address: string, amountUsdc?: number): string;
112
+ /**
113
+ * Sets up the local payer wallet if there isn't one. Returns its address and a funding link for the owner; funding
114
+ * needs only the address. With backupToOwner, the private key is also put into a one-time link (24h) for the owner,
115
+ * so they can recover or withdraw the funds. The key itself is never returned.
116
+ */
117
+ setupWallet(opts?: {
118
+ amountUsdc?: number;
119
+ backupToOwner?: boolean;
120
+ }): Promise<{
121
+ created: boolean;
122
+ address: string;
123
+ usdc: number;
124
+ fundUrl: string;
125
+ keyFile: string;
126
+ backupLink?: string;
127
+ backupError?: string;
128
+ }>;
129
+ /** Free-tier allowance left today and the paid price. */
130
+ quota(): Promise<Quota>;
50
131
  /** Sends the link by email once. Allowed only for the creator and only once per secret. */
51
132
  email(id: string, receiptToken: string, link: string, to: string, note?: string): Promise<void>;
52
133
  /** Reads and destroys a secret. Free. Works with links from any ncrypted host. */
package/dist/client.js CHANGED
@@ -1,11 +1,15 @@
1
1
  import { wrapFetchWithPaymentFromConfig, decodePaymentResponseHeader } from "@x402/fetch";
2
2
  import { ExactEvmScheme } from "@x402/evm";
3
3
  import { privateKeyToAccount } from "viem/accounts";
4
- import { encrypt, decrypt, parseLink } from "./crypto.js";
5
- import { normalizeKey, readWalletKey, walletPath } from "./wallet.js";
4
+ import { encrypt, decrypt, parseLink, generateRequestKeypair, openSealed } from "./crypto.js";
5
+ import { prepareDestination } from "./destinations.js";
6
+ import { saveLocalRequest, loadLocalRequest, removeLocalRequest } from "./localRequests.js";
7
+ import { normalizeKey, readWalletKey, createWallet, usdcBalance, walletPath } from "./wallet.js";
8
+ import { privateKeyToAccount as accountOf } from "viem/accounts";
6
9
  export const DEFAULT_BASE_URL = "https://ncrypted.ai";
7
10
  /** Hard cap on what the SDK will ever pay for one secret, whatever the server asks. */
8
11
  export const MAX_PRICE = "$0.01";
12
+ export const SDK_VERSION = "0.2.0";
9
13
  export class NcryptedError extends Error {
10
14
  status;
11
15
  constructor(message, status) {
@@ -40,6 +44,8 @@ async function errorFrom(res) {
40
44
  }
41
45
  return new NcryptedError(msg, res.status);
42
46
  }
47
+ const NO_WALLET = `Run \`npx ncrypted wallet new\` to create a payer wallet at ${"~/.config/ncrypted/wallet.key"}, send it a little USDC on Base ` +
48
+ "(0.005 per secret, no ETH needed), or set NCRYPTED_WALLET_PRIVATE_KEY.";
43
49
  /** Settlement attempts per create. A failed settlement never moves funds, so retrying is safe. */
44
50
  const SETTLE_ATTEMPTS = 3;
45
51
  export class Ncrypted {
@@ -69,28 +75,38 @@ export class Ncrypted {
69
75
  throw e;
70
76
  }));
71
77
  }
72
- /** Encrypts locally, pays 0.005 USDC via x402, stores the ciphertext and returns a one-time link. */
78
+ headers(extra = {}) {
79
+ return { "x-ncrypted-client": `${this.opts.client ?? "sdk"}/${SDK_VERSION}`, ...extra };
80
+ }
81
+ /**
82
+ * Encrypts locally and stores the ciphertext, returning a one-time link. The first few secrets per day are free
83
+ * (TTL up to 24h, no email); after that each costs 0.005 USDC via x402 from the configured wallet.
84
+ */
73
85
  async create(secret, opts = {}) {
74
86
  const payFetch = await this.payer();
75
- if (!payFetch) {
76
- throw new Error(`No wallet configured. Run \`npx ncrypted wallet new\` to create one at ${walletPath()} and send it a little USDC on Base, ` +
77
- "or set NCRYPTED_WALLET_PRIVATE_KEY.");
87
+ const requirePayment = opts.requirePayment || !!opts.emailTo;
88
+ if (!payFetch && requirePayment) {
89
+ throw new Error(`${opts.emailTo ? "Email delivery needs a paid secret." : "requirePayment is set but no wallet is configured."} ${NO_WALLET}`);
78
90
  }
79
91
  const { payload, key } = await encrypt(secret, opts.passphrase);
80
92
  const body = { ...payload, ttl: opts.ttl ?? 86400 };
93
+ // Email requires a paid secret, so skip the free tier when it's requested.
94
+ const headers = this.headers({ "content-type": "application/json", ...(requirePayment ? { "x-ncrypted-no-free": "1" } : {}) });
81
95
  // The link is only knowable after the server assigns an id, so email goes in a second, free call.
82
96
  let res;
83
97
  for (let attempt = 1;; attempt++) {
84
- res = await payFetch(`${this.baseUrl}/api/v1/secrets`, {
85
- method: "POST",
86
- headers: { "content-type": "application/json" },
87
- body: JSON.stringify(body),
88
- });
98
+ res = await (payFetch ?? fetch)(`${this.baseUrl}/api/v1/secrets`, { method: "POST", headers, body: JSON.stringify(body) });
89
99
  // Retry only when the payment was signed but settlement failed on-chain (e.g. a facilitator RPC hiccup).
90
100
  if (res.status === 402 && settlementOf(res)?.success === false && attempt < SETTLE_ATTEMPTS)
91
101
  continue;
92
102
  break;
93
103
  }
104
+ if (res.status === 402 && !payFetch) {
105
+ const perDay = res.headers.get("x-ncrypted-free-per-day") ?? "3";
106
+ const maxTtl = Number(res.headers.get("x-ncrypted-free-max-ttl") ?? 86400);
107
+ const why = (opts.ttl ?? 86400) > maxTtl ? `Free secrets can last at most ${maxTtl / 3600}h.` : `The ${perDay} free secrets per day are used up.`;
108
+ throw new NcryptedError(`${why} ${NO_WALLET}`, 402);
109
+ }
94
110
  if (!res.ok)
95
111
  throw await errorFrom(res);
96
112
  const data = (await res.json());
@@ -101,13 +117,154 @@ export class Ncrypted {
101
117
  await this.email(data.id, data.receiptToken, link, opts.emailTo, opts.emailNote);
102
118
  emailed = true;
103
119
  }
104
- return { id: data.id, link, expiresAt: data.expiresAt, receiptToken: data.receiptToken, emailed, paymentTx };
120
+ return {
121
+ id: data.id,
122
+ link,
123
+ expiresAt: data.expiresAt,
124
+ receiptToken: data.receiptToken,
125
+ emailed,
126
+ paymentTx,
127
+ free: !!data.free,
128
+ ...(data.free ? { freeRemaining: data.freeRemaining } : {}),
129
+ };
130
+ }
131
+ /**
132
+ * Reads a one-time link and writes the value straight into a destination (Vercel env var, .env file), so it never
133
+ * passes through the caller (e.g. an LLM). The destination is checked before the secret is consumed. If writing still
134
+ * fails afterwards, the value is re-saved to a new one-time link, which is returned in the error.
135
+ */
136
+ async deliver(link, destination, opts = {}) {
137
+ const target = await prepareDestination(destination);
138
+ const value = await this.read(link, opts.passphrase);
139
+ return this.deliverOrRescue(value, target);
140
+ }
141
+ async deliverOrRescue(value, target) {
142
+ try {
143
+ return await target.deliver(value);
144
+ }
145
+ catch (e) {
146
+ let rescue = "";
147
+ try {
148
+ const saved = await this.create(value, { ttl: 3600 });
149
+ rescue = ` The value was re-saved to a new one-time link (1h): ${saved.link}`;
150
+ }
151
+ catch {
152
+ rescue = " Re-saving the value also failed; ask for it again.";
153
+ }
154
+ throw new Error(`Delivery failed: ${e.message}.${rescue}`);
155
+ }
156
+ }
157
+ /**
158
+ * Starts a secret request: returns a link for a human, who pastes the value into the page. It's encrypted in their
159
+ * browser to a key that only exists on this machine. Free. Complete it with fulfillRequest().
160
+ */
161
+ async requestSecret(label, opts = {}) {
162
+ const { publicKey, privateKey } = await generateRequestKeypair();
163
+ const res = await fetch(`${this.baseUrl}/api/v1/requests`, {
164
+ method: "POST",
165
+ headers: this.headers({ "content-type": "application/json" }),
166
+ body: JSON.stringify({ publicKey, label, ttl: opts.ttl ?? 3600 }),
167
+ });
168
+ if (!res.ok)
169
+ throw await errorFrom(res);
170
+ const data = (await res.json());
171
+ await saveLocalRequest({ id: data.id, origin: this.baseUrl, publicKey, privateKey, pollToken: data.pollToken, label, expiresAt: data.expiresAt });
172
+ return { id: data.id, url: `${data.url}#${publicKey}`, expiresAt: data.expiresAt };
173
+ }
174
+ /**
175
+ * Waits up to waitSeconds for the human to answer a request. With a destination, the value is written there and
176
+ * never returned; without one, the value is returned. Resolves { status: "pending" } if not answered yet.
177
+ */
178
+ async fulfillRequest(id, opts = {}) {
179
+ const local = await loadLocalRequest(id);
180
+ const target = opts.destination ? await prepareDestination(opts.destination) : undefined;
181
+ const deadline = Date.now() + Math.min(opts.waitSeconds ?? 0, 300) * 1000;
182
+ for (;;) {
183
+ const res = await fetch(`${local.origin}/api/v1/requests/${id}/collect`, {
184
+ method: "POST",
185
+ headers: this.headers({ authorization: `Bearer ${local.pollToken}` }),
186
+ });
187
+ if (res.status === 410) {
188
+ await removeLocalRequest(id);
189
+ throw new NcryptedError("This request expired or was already collected.", 410);
190
+ }
191
+ if (!res.ok)
192
+ throw await errorFrom(res);
193
+ const body = (await res.json());
194
+ if (body.status === "fulfilled" && body.sealed) {
195
+ const value = await openSealed(body.sealed, local.privateKey, local.publicKey);
196
+ await removeLocalRequest(id);
197
+ if (!target)
198
+ return { status: "received", value };
199
+ return { status: "delivered", result: await this.deliverOrRescue(value, target) };
200
+ }
201
+ if (Date.now() >= deadline)
202
+ return { status: "pending", expiresAt: body.expiresAt ?? local.expiresAt };
203
+ await new Promise((r) => setTimeout(r, 2000));
204
+ }
205
+ }
206
+ /** Link to a page where the wallet's owner can send it USDC on Base. */
207
+ fundUrl(address, amountUsdc = 1) {
208
+ return `${this.baseUrl}/fund?address=${address}&amount=${amountUsdc}`;
209
+ }
210
+ /**
211
+ * Sets up the local payer wallet if there isn't one. Returns its address and a funding link for the owner; funding
212
+ * needs only the address. With backupToOwner, the private key is also put into a one-time link (24h) for the owner,
213
+ * so they can recover or withdraw the funds. The key itself is never returned.
214
+ */
215
+ async setupWallet(opts = {}) {
216
+ const existing = normalizeKey(this.opts.privateKey ?? process.env.NCRYPTED_WALLET_PRIVATE_KEY) ?? (await readWalletKey());
217
+ let address;
218
+ let created = false;
219
+ if (existing)
220
+ address = accountOf(existing).address;
221
+ else {
222
+ try {
223
+ address = (await createWallet()).address;
224
+ created = true;
225
+ }
226
+ catch (e) {
227
+ // Another process created it at the same moment; use that one.
228
+ if (e.code !== "EEXIST")
229
+ throw e;
230
+ address = accountOf((await readWalletKey())).address;
231
+ }
232
+ this.payFetch = undefined; // pick up the new wallet on the next create()
233
+ }
234
+ const result = {
235
+ created,
236
+ address,
237
+ usdc: await usdcBalance(address).catch(() => 0),
238
+ fundUrl: this.fundUrl(address, opts.amountUsdc ?? 1),
239
+ keyFile: existing && !(await readWalletKey()) ? "NCRYPTED_WALLET_PRIVATE_KEY" : walletPath(),
240
+ };
241
+ if (!opts.backupToOwner)
242
+ return result;
243
+ try {
244
+ const key = existing ?? (await readWalletKey());
245
+ const backup = await this.create(`ncrypted payer wallet ${address}\nPrivate key: ${key}\nNetwork: Base`, { ttl: 86_400 });
246
+ return { ...result, backupLink: backup.link };
247
+ }
248
+ catch (e) {
249
+ return {
250
+ ...result,
251
+ backupError: `Couldn't create the backup link yet (${e.message.split(".")[0]}). ` +
252
+ "Once the wallet is funded, run setup again with backup to create it.",
253
+ };
254
+ }
255
+ }
256
+ /** Free-tier allowance left today and the paid price. */
257
+ async quota() {
258
+ const res = await fetch(`${this.baseUrl}/api/v1/quota`, { headers: this.headers() });
259
+ if (!res.ok)
260
+ throw await errorFrom(res);
261
+ return (await res.json());
105
262
  }
106
263
  /** Sends the link by email once. Allowed only for the creator and only once per secret. */
107
264
  async email(id, receiptToken, link, to, note) {
108
265
  const res = await fetch(`${this.baseUrl}/api/v1/secrets/${id}/email`, {
109
266
  method: "POST",
110
- headers: { "content-type": "application/json", authorization: `Bearer ${receiptToken}` },
267
+ headers: this.headers({ "content-type": "application/json", authorization: `Bearer ${receiptToken}` }),
111
268
  body: JSON.stringify({ to, link, note }),
112
269
  });
113
270
  if (!res.ok)
@@ -116,7 +273,7 @@ export class Ncrypted {
116
273
  /** Reads and destroys a secret. Free. Works with links from any ncrypted host. */
117
274
  async read(link, passphrase) {
118
275
  const { origin, id, key } = parseLink(link);
119
- const res = await fetch(`${origin}/api/v1/secrets/${id}/reveal`, { method: "POST" });
276
+ const res = await fetch(`${origin}/api/v1/secrets/${id}/reveal`, { method: "POST", headers: this.headers() });
120
277
  if (res.status === 410 || res.status === 404) {
121
278
  throw new NcryptedError("This secret has already been read, burned, or has expired.", 410);
122
279
  }
@@ -128,7 +285,7 @@ export class Ncrypted {
128
285
  /** Whether the secret has been read yet. Needs the creator's receipt token. */
129
286
  async status(id, receiptToken) {
130
287
  const res = await fetch(`${this.baseUrl}/api/v1/secrets/${id}/status`, {
131
- headers: { authorization: `Bearer ${receiptToken}` },
288
+ headers: this.headers({ authorization: `Bearer ${receiptToken}` }),
132
289
  });
133
290
  if (!res.ok)
134
291
  throw await errorFrom(res);
@@ -138,7 +295,7 @@ export class Ncrypted {
138
295
  async burn(id, receiptToken) {
139
296
  const res = await fetch(`${this.baseUrl}/api/v1/secrets/${id}`, {
140
297
  method: "DELETE",
141
- headers: { authorization: `Bearer ${receiptToken}` },
298
+ headers: this.headers({ authorization: `Bearer ${receiptToken}` }),
142
299
  });
143
300
  if (!res.ok)
144
301
  throw await errorFrom(res);
package/dist/crypto.d.ts CHANGED
@@ -23,3 +23,24 @@ export declare function parseLink(link: string): {
23
23
  id: string;
24
24
  key: string;
25
25
  };
26
+ export interface SealedPayload {
27
+ v: number;
28
+ epk: string;
29
+ iv: string;
30
+ ciphertext: string;
31
+ }
32
+ /** New X25519 keypair for a secret request. publicKey goes in the request link's #fragment; keep privateKey local. */
33
+ export declare function generateRequestKeypair(): Promise<{
34
+ publicKey: string;
35
+ privateKey: JsonWebKey;
36
+ }>;
37
+ /** Seals plaintext so only the holder of the request's private key can open it. Used by the request page. */
38
+ export declare function sealToPublicKey(plaintext: string, publicKey: string): Promise<SealedPayload>;
39
+ /** Opens a sealed payload with the request's private key. Throws if it was tampered with or sealed to another key. */
40
+ export declare function openSealed(sealed: SealedPayload, privateKey: JsonWebKey, publicKey: string): Promise<string>;
41
+ /** Splits a request link: https://host/r/{id}#{publicKey} */
42
+ export declare function parseRequestLink(link: string): {
43
+ origin: string;
44
+ id: string;
45
+ publicKey: string;
46
+ };
package/dist/crypto.js CHANGED
@@ -72,3 +72,64 @@ export function parseLink(link) {
72
72
  throw new Error("Not a valid ncrypted link (expected https://host/s/{id}#{key})");
73
73
  return { origin: url.origin, id: m[1], key };
74
74
  }
75
+ // ---- Secret requests: a human seals a value to the requesting agent's X25519 public key. ----
76
+ const REQUEST_INFO = new TextEncoder().encode("ncrypted request v1");
77
+ /** New X25519 keypair for a secret request. publicKey goes in the request link's #fragment; keep privateKey local. */
78
+ export async function generateRequestKeypair() {
79
+ const kp = (await crypto.subtle.generateKey({ name: "X25519" }, true, ["deriveBits"]));
80
+ const raw = new Uint8Array(await crypto.subtle.exportKey("raw", kp.publicKey));
81
+ return { publicKey: toB64url(raw), privateKey: await crypto.subtle.exportKey("jwk", kp.privateKey) };
82
+ }
83
+ async function requestKey(shared, epk, pk) {
84
+ const salt = new Uint8Array(epk.length + pk.length);
85
+ salt.set(epk);
86
+ salt.set(pk, epk.length);
87
+ const base = await crypto.subtle.importKey("raw", shared, "HKDF", false, ["deriveKey"]);
88
+ return crypto.subtle.deriveKey({ name: "HKDF", hash: "SHA-256", salt, info: REQUEST_INFO }, base, { name: "AES-GCM", length: 256 }, false, [
89
+ "encrypt",
90
+ "decrypt",
91
+ ]);
92
+ }
93
+ /** Seals plaintext so only the holder of the request's private key can open it. Used by the request page. */
94
+ export async function sealToPublicKey(plaintext, publicKey) {
95
+ const data = new TextEncoder().encode(plaintext);
96
+ if (data.length === 0)
97
+ throw new Error("Secret is empty");
98
+ if (data.length > MAX_PLAINTEXT_BYTES)
99
+ throw new Error(`Secret exceeds ${MAX_PLAINTEXT_BYTES} bytes`);
100
+ const pk = fromB64url(publicKey);
101
+ if (pk.length !== 32)
102
+ throw new Error("Invalid request public key");
103
+ const recipient = await crypto.subtle.importKey("raw", pk, { name: "X25519" }, false, []);
104
+ const eph = (await crypto.subtle.generateKey({ name: "X25519" }, true, ["deriveBits"]));
105
+ const epk = new Uint8Array(await crypto.subtle.exportKey("raw", eph.publicKey));
106
+ const shared = await crypto.subtle.deriveBits({ name: "X25519", public: recipient }, eph.privateKey, 256);
107
+ const key = await requestKey(shared, epk, pk);
108
+ const iv = randomBytes(12);
109
+ const ct = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, data));
110
+ return { v: VERSION, epk: toB64url(epk), iv: toB64url(iv), ciphertext: toB64url(ct) };
111
+ }
112
+ /** Opens a sealed payload with the request's private key. Throws if it was tampered with or sealed to another key. */
113
+ export async function openSealed(sealed, privateKey, publicKey) {
114
+ const priv = await crypto.subtle.importKey("jwk", privateKey, { name: "X25519" }, false, ["deriveBits"]);
115
+ const epk = fromB64url(sealed.epk);
116
+ const eph = await crypto.subtle.importKey("raw", epk, { name: "X25519" }, false, []);
117
+ const shared = await crypto.subtle.deriveBits({ name: "X25519", public: eph }, priv, 256);
118
+ const key = await requestKey(shared, epk, fromB64url(publicKey));
119
+ try {
120
+ const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv: fromB64url(sealed.iv) }, key, fromB64url(sealed.ciphertext));
121
+ return new TextDecoder().decode(pt);
122
+ }
123
+ catch {
124
+ throw new Error("Could not open the submitted secret: it was tampered with or sealed to a different key");
125
+ }
126
+ }
127
+ /** Splits a request link: https://host/r/{id}#{publicKey} */
128
+ export function parseRequestLink(link) {
129
+ const url = new URL(link);
130
+ const m = url.pathname.match(/^\/r\/([A-Za-z0-9_-]{16,64})\/?$/);
131
+ const publicKey = url.hash.slice(1);
132
+ if (!m || !publicKey)
133
+ throw new Error("Not a valid ncrypted request link (expected https://host/r/{id}#{publicKey})");
134
+ return { origin: url.origin, id: m[1], publicKey };
135
+ }
@@ -0,0 +1,35 @@
1
+ export type VercelTarget = "production" | "preview" | "development";
2
+ export interface VercelEnvDestination {
3
+ type: "vercel_env";
4
+ /** Env var name, e.g. STRIPE_SECRET_KEY. */
5
+ key: string;
6
+ /** Project name or id. Defaults to the project linked in <cwd>/.vercel/project.json. */
7
+ project?: string;
8
+ /** Team id (team_...) or slug. Defaults to the linked project's org. */
9
+ team?: string;
10
+ /** Default: production and preview. */
11
+ targets?: VercelTarget[];
12
+ /** Directory containing .vercel/project.json. Default: process.cwd(). */
13
+ cwd?: string;
14
+ comment?: string;
15
+ }
16
+ export interface DotenvDestination {
17
+ type: "dotenv";
18
+ key: string;
19
+ /** Path to the .env file. Default: ./.env */
20
+ path?: string;
21
+ }
22
+ export type Destination = VercelEnvDestination | DotenvDestination;
23
+ export interface DeliveryResult {
24
+ destination: string;
25
+ key: string;
26
+ details: Record<string, unknown>;
27
+ }
28
+ export interface PreparedDestination {
29
+ deliver(value: string): Promise<DeliveryResult>;
30
+ }
31
+ /**
32
+ * Validates a destination (auth, project access, names) without touching the secret.
33
+ * Call this before consuming a one-time secret so a bad destination doesn't destroy it.
34
+ */
35
+ export declare function prepareDestination(d: Destination): Promise<PreparedDestination>;
@@ -0,0 +1,146 @@
1
+ // Node-only destinations that receive a secret's value directly, so it never passes through the model.
2
+ import { readFile, writeFile, chmod } from "node:fs/promises";
3
+ import { execFile } from "node:child_process";
4
+ import { homedir, platform } from "node:os";
5
+ import { join, resolve, dirname } from "node:path";
6
+ const KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
7
+ function checkKey(key) {
8
+ if (!KEY_RE.test(key))
9
+ throw new Error(`Invalid variable name "${key}" (letters, digits and _, not starting with a digit)`);
10
+ }
11
+ // ---------------- Vercel ----------------
12
+ function vercelAuthPath() {
13
+ if (process.env.VERCEL_AUTH_FILE)
14
+ return process.env.VERCEL_AUTH_FILE;
15
+ if (platform() === "darwin")
16
+ return join(homedir(), "Library", "Application Support", "com.vercel.cli", "auth.json");
17
+ if (platform() === "win32")
18
+ return join(process.env.APPDATA ?? homedir(), "com.vercel.cli", "auth.json");
19
+ return join(process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share"), "com.vercel.cli", "auth.json");
20
+ }
21
+ /** VERCEL_TOKEN, else the Vercel CLI's login session (`vercel login`). */
22
+ async function vercelToken() {
23
+ if (process.env.VERCEL_TOKEN)
24
+ return process.env.VERCEL_TOKEN;
25
+ let auth;
26
+ try {
27
+ auth = JSON.parse(await readFile(vercelAuthPath(), "utf8"));
28
+ }
29
+ catch {
30
+ throw new Error("Not logged in to Vercel. Run `vercel login` (or set VERCEL_TOKEN) and try again.");
31
+ }
32
+ if (!auth.token)
33
+ throw new Error("Not logged in to Vercel. Run `vercel login` (or set VERCEL_TOKEN) and try again.");
34
+ if (auth.expiresAt && auth.expiresAt * (auth.expiresAt < 1e12 ? 1000 : 1) < Date.now() + 60_000) {
35
+ throw new Error("Your Vercel CLI session has expired. Run `vercel whoami` to refresh it (or set VERCEL_TOKEN) and try again.");
36
+ }
37
+ return auth.token;
38
+ }
39
+ async function linkedProject(cwd) {
40
+ try {
41
+ return JSON.parse(await readFile(join(cwd, ".vercel", "project.json"), "utf8"));
42
+ }
43
+ catch {
44
+ return undefined;
45
+ }
46
+ }
47
+ async function vercelApi(r, method, path, body) {
48
+ const res = await fetch(`https://api.vercel.com${path}${path.includes("?") ? "&" : "?"}${r.query}`, {
49
+ method,
50
+ headers: { authorization: `Bearer ${r.token}`, "content-type": "application/json" },
51
+ body: body === undefined ? undefined : JSON.stringify(body),
52
+ });
53
+ const data = (await res.json().catch(() => ({})));
54
+ if (!res.ok)
55
+ throw new Error(`Vercel API ${res.status}: ${data.error?.message ?? res.statusText}`);
56
+ return data;
57
+ }
58
+ async function resolveVercel(d) {
59
+ checkKey(d.key);
60
+ const token = await vercelToken();
61
+ const linked = await linkedProject(d.cwd ?? process.cwd());
62
+ const project = d.project ?? linked?.projectId;
63
+ if (!project)
64
+ throw new Error("No Vercel project given and no .vercel/project.json here. Pass `project` (name or id) or run `vercel link`.");
65
+ const team = d.team ?? (d.project ? undefined : linked?.orgId);
66
+ const query = team ? (team.startsWith("team_") ? `teamId=${encodeURIComponent(team)}` : `slug=${encodeURIComponent(team)}`) : "";
67
+ const targets = d.targets?.length ? [...new Set(d.targets)] : ["production", "preview"];
68
+ return { token, project, query, targets };
69
+ }
70
+ /** Checks auth and project access before a one-time secret is consumed. */
71
+ async function preflightVercel(d) {
72
+ const r = await resolveVercel(d);
73
+ await vercelApi(r, "GET", `/v9/projects/${encodeURIComponent(r.project)}`);
74
+ return r;
75
+ }
76
+ async function deliverVercel(value, d, r) {
77
+ // Sensitive vars can't target development; use encrypted there.
78
+ const sensitiveTargets = r.targets.filter((t) => t !== "development");
79
+ const entries = [
80
+ ...(sensitiveTargets.length ? [{ key: d.key, value, type: "sensitive", target: sensitiveTargets }] : []),
81
+ ...(r.targets.includes("development") ? [{ key: d.key, value, type: "encrypted", target: ["development"] }] : []),
82
+ ].map((e) => ({ ...e, comment: d.comment ?? "Set via ncrypted (value never shown to the agent)" }));
83
+ await vercelApi(r, "POST", `/v10/projects/${encodeURIComponent(r.project)}/env?upsert=true`, entries.length === 1 ? entries[0] : entries);
84
+ return {
85
+ destination: "vercel_env",
86
+ key: d.key,
87
+ details: {
88
+ project: r.project,
89
+ targets: r.targets,
90
+ type: sensitiveTargets.length ? "sensitive" : "encrypted",
91
+ note: "Redeploy for the new value to take effect.",
92
+ },
93
+ };
94
+ }
95
+ // ---------------- .env file ----------------
96
+ function quoteEnv(value) {
97
+ if (/^[A-Za-z0-9_./:@+-]*$/.test(value))
98
+ return value;
99
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n")}"`;
100
+ }
101
+ function gitIgnored(path) {
102
+ return new Promise((done) => execFile("git", ["check-ignore", "-q", path], { cwd: dirname(path) }, (err) => {
103
+ if (!err)
104
+ return done(true);
105
+ done(err.code === 1 ? false : undefined); // 128: not a git repo
106
+ }));
107
+ }
108
+ async function deliverDotenv(value, d) {
109
+ checkKey(d.key);
110
+ const path = resolve(d.path ?? ".env");
111
+ let text = "";
112
+ try {
113
+ text = await readFile(path, "utf8");
114
+ }
115
+ catch { }
116
+ const line = `${d.key}=${quoteEnv(value)}`;
117
+ const re = new RegExp(`^(export\\s+)?${d.key}=.*$`, "m");
118
+ text = re.test(text) ? text.replace(re, line) : `${text}${text && !text.endsWith("\n") ? "\n" : ""}${line}\n`;
119
+ await writeFile(path, text, { mode: 0o600 });
120
+ await chmod(path, 0o600);
121
+ const ignored = await gitIgnored(path);
122
+ return {
123
+ destination: "dotenv",
124
+ key: d.key,
125
+ details: {
126
+ path,
127
+ gitIgnored: ignored,
128
+ ...(ignored === false ? { warning: `${path} is NOT git-ignored. Add it to .gitignore before committing.` } : {}),
129
+ },
130
+ };
131
+ }
132
+ /**
133
+ * Validates a destination (auth, project access, names) without touching the secret.
134
+ * Call this before consuming a one-time secret so a bad destination doesn't destroy it.
135
+ */
136
+ export async function prepareDestination(d) {
137
+ if (d.type === "vercel_env") {
138
+ const r = await preflightVercel(d);
139
+ return { deliver: (v) => deliverVercel(v, d, r) };
140
+ }
141
+ if (d.type === "dotenv") {
142
+ checkKey(d.key);
143
+ return { deliver: (v) => deliverDotenv(v, d) };
144
+ }
145
+ throw new Error(`Unknown destination type "${d.type}"`);
146
+ }
package/dist/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  export * from "./client.js";
2
- export { encrypt, decrypt, parseLink, MAX_PLAINTEXT_BYTES, type EncryptedPayload } from "./crypto.js";
2
+ export { encrypt, decrypt, parseLink, parseRequestLink, sealToPublicKey, MAX_PLAINTEXT_BYTES, type EncryptedPayload, type SealedPayload } from "./crypto.js";
3
+ export type { Destination, VercelEnvDestination, DotenvDestination, DeliveryResult, VercelTarget } from "./destinations.js";
3
4
  export { walletPath, createWallet, usdcBalance } from "./wallet.js";
package/dist/index.js CHANGED
@@ -1,3 +1,3 @@
1
1
  export * from "./client.js";
2
- export { encrypt, decrypt, parseLink, MAX_PLAINTEXT_BYTES } from "./crypto.js";
2
+ export { encrypt, decrypt, parseLink, parseRequestLink, sealToPublicKey, MAX_PLAINTEXT_BYTES } from "./crypto.js";
3
3
  export { walletPath, createWallet, usdcBalance } from "./wallet.js";
@@ -0,0 +1,13 @@
1
+ export interface LocalRequest {
2
+ id: string;
3
+ origin: string;
4
+ publicKey: string;
5
+ privateKey: JsonWebKey;
6
+ pollToken: string;
7
+ label: string;
8
+ expiresAt: string;
9
+ }
10
+ export declare function requestsDir(): string;
11
+ export declare function saveLocalRequest(r: LocalRequest): Promise<void>;
12
+ export declare function loadLocalRequest(id: string): Promise<LocalRequest>;
13
+ export declare const removeLocalRequest: (id: string) => Promise<void>;
@@ -0,0 +1,25 @@
1
+ // Node-only storage for the private keys of pending secret requests (one 0600 file per request).
2
+ import { readFile, writeFile, mkdir, rm } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ export function requestsDir() {
6
+ return process.env.NCRYPTED_REQUESTS_DIR || join(homedir(), ".config", "ncrypted", "requests");
7
+ }
8
+ const fileFor = (id) => {
9
+ if (!/^[A-Za-z0-9_-]{16,64}$/.test(id))
10
+ throw new Error("Invalid request id");
11
+ return join(requestsDir(), `${id}.json`);
12
+ };
13
+ export async function saveLocalRequest(r) {
14
+ await mkdir(requestsDir(), { recursive: true, mode: 0o700 });
15
+ await writeFile(fileFor(r.id), JSON.stringify(r), { mode: 0o600 });
16
+ }
17
+ export async function loadLocalRequest(id) {
18
+ try {
19
+ return JSON.parse(await readFile(fileFor(id), "utf8"));
20
+ }
21
+ catch {
22
+ throw new Error(`No pending request ${id} on this machine (it was collected already, or created elsewhere).`);
23
+ }
24
+ }
25
+ export const removeLocalRequest = (id) => rm(fileFor(id), { force: true });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ncrypted",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "One-time, end-to-end encrypted secrets for AI agents. Pay 0.005 USDC per secret via x402; the note self-destructs after one read.",
5
5
  "license": "MIT",
6
6
  "type": "module",