ncrypted 0.1.1 → 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
@@ -1,6 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import { parseArgs } from "node:util";
3
+ import { privateKeyToAccount } from "viem/accounts";
3
4
  import { Ncrypted } from "./client.js";
5
+ import { createWallet, normalizeKey, readWalletKey, usdcBalance, walletPath } from "./wallet.js";
4
6
  const HELP = `ncrypted - one-time, end-to-end encrypted secrets for agents (0.005 USDC per secret, reads free)
5
7
 
6
8
  Usage:
@@ -9,8 +11,18 @@ Usage:
9
11
  ncrypted read <link> [--passphrase X]
10
12
  ncrypted status <id> --receipt <token>
11
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]
19
+ ncrypted wallet new create a payer wallet at ~/.config/ncrypted/wallet.key
20
+ ncrypted wallet show the payer address and its USDC balance on Base
12
21
 
13
- Env: NCRYPTED_WALLET_PRIVATE_KEY (Base wallet with USDC, needed for create), NCRYPTED_URL (optional)`;
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
24
+ NCRYPTED_WALLET_PRIVATE_KEY, else NCRYPTED_WALLET_FILE, else ~/.config/ncrypted/wallet.key.
25
+ NCRYPTED_URL overrides the API origin.`;
14
26
  function parseTtl(v) {
15
27
  if (!v)
16
28
  return undefined;
@@ -37,12 +49,21 @@ async function main() {
37
49
  note: { type: "string" },
38
50
  text: { type: "string" },
39
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" },
40
61
  json: { type: "boolean" },
41
62
  help: { type: "boolean", short: "h" },
42
63
  },
43
64
  });
44
65
  const [cmd, arg] = positionals;
45
- const client = new Ncrypted();
66
+ const client = new Ncrypted({ client: "cli" });
46
67
  const out = (v) => console.log(values.json ? JSON.stringify(v) : typeof v === "string" ? v : JSON.stringify(v, null, 2));
47
68
  switch (values.help ? "help" : cmd) {
48
69
  case "create": {
@@ -68,6 +89,56 @@ async function main() {
68
89
  await client.burn(arg, values.receipt);
69
90
  out({ burned: true });
70
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
+ }
121
+ case "wallet": {
122
+ if (arg === "new") {
123
+ const { address, path } = await createWallet().catch((e) => {
124
+ if (e.code === "EEXIST")
125
+ throw new Error(`A wallet already exists at ${walletPath()}`);
126
+ throw e;
127
+ });
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." });
129
+ break;
130
+ }
131
+ const key = normalizeKey(process.env.NCRYPTED_WALLET_PRIVATE_KEY) ?? (await readWalletKey());
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
+ }
137
+ const address = privateKeyToAccount(key).address;
138
+ const usdc = await usdcBalance(address);
139
+ out({ address, usdc, paidSecretsAvailable: Math.floor(usdc / 0.005), freeRemainingToday: freeRemaining });
140
+ break;
141
+ }
71
142
  default:
72
143
  console.log(HELP);
73
144
  }
package/dist/client.d.ts CHANGED
@@ -1,13 +1,20 @@
1
- export declare const DEFAULT_BASE_URL = "https://ncrypted-ai.vercel.app";
1
+ import { type Destination, type DeliveryResult } from "./destinations.js";
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;
7
- /** 0x-prefixed EVM private key of a wallet holding USDC on Base. Defaults to NCRYPTED_WALLET_PRIVATE_KEY. */
9
+ /**
10
+ * 0x-prefixed EVM private key of a wallet holding USDC on Base. Defaults to NCRYPTED_WALLET_PRIVATE_KEY,
11
+ * then the key file at NCRYPTED_WALLET_FILE or ~/.config/ncrypted/wallet.key (create one with `npx ncrypted wallet new`).
12
+ */
8
13
  privateKey?: string;
9
14
  /** Bring your own x402-paying fetch instead of a private key. */
10
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;
11
18
  }
12
19
  export interface CreateOptions {
13
20
  /** Lifetime in seconds, 300 to 604800 (7 days). Default 86400. */
@@ -18,6 +25,8 @@ export interface CreateOptions {
18
25
  emailTo?: string;
19
26
  /** Optional short note shown in the email, e.g. "DB password for staging". Never put the secret here. */
20
27
  emailNote?: string;
28
+ /** Skip the free tier and always pay (implied by emailTo). */
29
+ requirePayment?: boolean;
21
30
  }
22
31
  export interface CreatedSecret {
23
32
  id: string;
@@ -29,6 +38,19 @@ export interface CreatedSecret {
29
38
  emailed: boolean;
30
39
  /** On-chain settlement transaction hash, if reported. */
31
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;
32
54
  }
33
55
  export type SecretStatus = "pending" | "read" | "burned" | "expired";
34
56
  export declare class NcryptedError extends Error {
@@ -36,11 +58,76 @@ export declare class NcryptedError extends Error {
36
58
  constructor(message: string, status: number);
37
59
  }
38
60
  export declare class Ncrypted {
61
+ private readonly opts;
39
62
  readonly baseUrl: string;
40
63
  private payFetch?;
41
64
  constructor(opts?: NcryptedOptions);
42
- /** Encrypts locally, pays 0.005 USDC via x402, stores the ciphertext and returns a one-time link. */
65
+ /** Resolves the paying fetch lazily, so a missing or bad wallet only affects create(), never reads. */
66
+ private payer;
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
+ */
43
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>;
44
131
  /** Sends the link by email once. Allowed only for the creator and only once per secret. */
45
132
  email(id: string, receiptToken: string, link: string, to: string, note?: string): Promise<void>;
46
133
  /** Reads and destroys a secret. Free. Works with links from any ncrypted host. */
package/dist/client.js CHANGED
@@ -1,10 +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
- export const DEFAULT_BASE_URL = "https://ncrypted-ai.vercel.app";
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";
9
+ export const DEFAULT_BASE_URL = "https://ncrypted.ai";
6
10
  /** Hard cap on what the SDK will ever pay for one secret, whatever the server asks. */
7
11
  export const MAX_PRICE = "$0.01";
12
+ export const SDK_VERSION = "0.2.0";
8
13
  export class NcryptedError extends Error {
9
14
  status;
10
15
  constructor(message, status) {
@@ -39,45 +44,69 @@ async function errorFrom(res) {
39
44
  }
40
45
  return new NcryptedError(msg, res.status);
41
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.";
42
49
  /** Settlement attempts per create. A failed settlement never moves funds, so retrying is safe. */
43
50
  const SETTLE_ATTEMPTS = 3;
44
51
  export class Ncrypted {
52
+ opts;
45
53
  baseUrl;
46
54
  payFetch;
47
55
  constructor(opts = {}) {
56
+ this.opts = opts;
48
57
  const env = typeof process !== "undefined" ? process.env : {};
49
58
  this.baseUrl = (opts.baseUrl ?? env.NCRYPTED_URL ?? DEFAULT_BASE_URL).replace(/\/$/, "");
50
- const pk = opts.privateKey ?? env.NCRYPTED_WALLET_PRIVATE_KEY;
51
- if (opts.paymentFetch)
52
- this.payFetch = opts.paymentFetch;
53
- else if (pk) {
54
- const account = privateKeyToAccount((pk.startsWith("0x") ? pk : `0x${pk}`));
55
- this.payFetch = wrapFetchWithPaymentFromConfig(fetch, {
56
- schemes: [{ network: "eip155:*", client: new ExactEvmScheme(account) }],
59
+ }
60
+ /** Resolves the paying fetch lazily, so a missing or bad wallet only affects create(), never reads. */
61
+ payer() {
62
+ return (this.payFetch ??= (async () => {
63
+ if (this.opts.paymentFetch)
64
+ return this.opts.paymentFetch;
65
+ const env = typeof process !== "undefined" ? process.env : {};
66
+ const pk = normalizeKey(this.opts.privateKey ?? env.NCRYPTED_WALLET_PRIVATE_KEY) ?? (await readWalletKey());
67
+ if (!pk)
68
+ return undefined;
69
+ return wrapFetchWithPaymentFromConfig(fetch, {
70
+ schemes: [{ network: "eip155:*", client: new ExactEvmScheme(privateKeyToAccount(pk)) }],
57
71
  spendControls: { maxAmountPerPayment: MAX_PRICE },
58
72
  });
59
- }
73
+ })().catch((e) => {
74
+ this.payFetch = undefined;
75
+ throw e;
76
+ }));
60
77
  }
61
- /** 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
+ */
62
85
  async create(secret, opts = {}) {
63
- if (!this.payFetch) {
64
- throw new Error("No wallet configured. Set NCRYPTED_WALLET_PRIVATE_KEY (a Base wallet with USDC) or pass privateKey.");
86
+ const payFetch = await this.payer();
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}`);
65
90
  }
66
91
  const { payload, key } = await encrypt(secret, opts.passphrase);
67
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" } : {}) });
68
95
  // The link is only knowable after the server assigns an id, so email goes in a second, free call.
69
96
  let res;
70
97
  for (let attempt = 1;; attempt++) {
71
- res = await this.payFetch(`${this.baseUrl}/api/v1/secrets`, {
72
- method: "POST",
73
- headers: { "content-type": "application/json" },
74
- body: JSON.stringify(body),
75
- });
98
+ res = await (payFetch ?? fetch)(`${this.baseUrl}/api/v1/secrets`, { method: "POST", headers, body: JSON.stringify(body) });
76
99
  // Retry only when the payment was signed but settlement failed on-chain (e.g. a facilitator RPC hiccup).
77
100
  if (res.status === 402 && settlementOf(res)?.success === false && attempt < SETTLE_ATTEMPTS)
78
101
  continue;
79
102
  break;
80
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
+ }
81
110
  if (!res.ok)
82
111
  throw await errorFrom(res);
83
112
  const data = (await res.json());
@@ -88,13 +117,154 @@ export class Ncrypted {
88
117
  await this.email(data.id, data.receiptToken, link, opts.emailTo, opts.emailNote);
89
118
  emailed = true;
90
119
  }
91
- 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());
92
262
  }
93
263
  /** Sends the link by email once. Allowed only for the creator and only once per secret. */
94
264
  async email(id, receiptToken, link, to, note) {
95
265
  const res = await fetch(`${this.baseUrl}/api/v1/secrets/${id}/email`, {
96
266
  method: "POST",
97
- headers: { "content-type": "application/json", authorization: `Bearer ${receiptToken}` },
267
+ headers: this.headers({ "content-type": "application/json", authorization: `Bearer ${receiptToken}` }),
98
268
  body: JSON.stringify({ to, link, note }),
99
269
  });
100
270
  if (!res.ok)
@@ -103,7 +273,7 @@ export class Ncrypted {
103
273
  /** Reads and destroys a secret. Free. Works with links from any ncrypted host. */
104
274
  async read(link, passphrase) {
105
275
  const { origin, id, key } = parseLink(link);
106
- 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() });
107
277
  if (res.status === 410 || res.status === 404) {
108
278
  throw new NcryptedError("This secret has already been read, burned, or has expired.", 410);
109
279
  }
@@ -115,7 +285,7 @@ export class Ncrypted {
115
285
  /** Whether the secret has been read yet. Needs the creator's receipt token. */
116
286
  async status(id, receiptToken) {
117
287
  const res = await fetch(`${this.baseUrl}/api/v1/secrets/${id}/status`, {
118
- headers: { authorization: `Bearer ${receiptToken}` },
288
+ headers: this.headers({ authorization: `Bearer ${receiptToken}` }),
119
289
  });
120
290
  if (!res.ok)
121
291
  throw await errorFrom(res);
@@ -125,7 +295,7 @@ export class Ncrypted {
125
295
  async burn(id, receiptToken) {
126
296
  const res = await fetch(`${this.baseUrl}/api/v1/secrets/${id}`, {
127
297
  method: "DELETE",
128
- headers: { authorization: `Bearer ${receiptToken}` },
298
+ headers: this.headers({ authorization: `Bearer ${receiptToken}` }),
129
299
  });
130
300
  if (!res.ok)
131
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,2 +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";
4
+ export { walletPath, createWallet, usdcBalance } from "./wallet.js";
package/dist/index.js CHANGED
@@ -1,2 +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
+ 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 });
@@ -0,0 +1,13 @@
1
+ export declare const USDC_BASE = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913";
2
+ /** NCRYPTED_WALLET_FILE, or ~/.config/ncrypted/wallet.key */
3
+ export declare function walletPath(): string;
4
+ /** Normalizes a private key, or returns undefined for empty or unexpanded "${VAR}" placeholders. */
5
+ export declare function normalizeKey(raw: string | undefined): `0x${string}` | undefined;
6
+ export declare function readWalletKey(): Promise<`0x${string}` | undefined>;
7
+ /** Creates a new wallet key file (mode 0600). Refuses to overwrite an existing one. */
8
+ export declare function createWallet(): Promise<{
9
+ address: string;
10
+ path: string;
11
+ }>;
12
+ /** USDC balance on Base mainnet, via a public RPC. */
13
+ export declare function usdcBalance(address: string, rpc?: string): Promise<number>;
package/dist/wallet.js ADDED
@@ -0,0 +1,49 @@
1
+ // Node-only helpers for the local payer wallet used to pay for secrets.
2
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import { generatePrivateKey, privateKeyToAccount } from "viem/accounts";
6
+ export const USDC_BASE = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913";
7
+ /** NCRYPTED_WALLET_FILE, or ~/.config/ncrypted/wallet.key */
8
+ export function walletPath() {
9
+ return process.env.NCRYPTED_WALLET_FILE || join(homedir(), ".config", "ncrypted", "wallet.key");
10
+ }
11
+ /** Normalizes a private key, or returns undefined for empty or unexpanded "${VAR}" placeholders. */
12
+ export function normalizeKey(raw) {
13
+ const v = raw?.trim();
14
+ if (!v || v.startsWith("${") || v.startsWith("$"))
15
+ return undefined;
16
+ const hex = v.startsWith("0x") ? v : `0x${v}`;
17
+ if (!/^0x[0-9a-fA-F]{64}$/.test(hex))
18
+ throw new Error("Wallet private key must be 32 bytes of hex (0x + 64 hex characters)");
19
+ return hex;
20
+ }
21
+ export async function readWalletKey() {
22
+ try {
23
+ return normalizeKey(await readFile(walletPath(), "utf8"));
24
+ }
25
+ catch (e) {
26
+ if (e.code === "ENOENT")
27
+ return undefined;
28
+ throw e;
29
+ }
30
+ }
31
+ /** Creates a new wallet key file (mode 0600). Refuses to overwrite an existing one. */
32
+ export async function createWallet() {
33
+ const path = walletPath();
34
+ const key = generatePrivateKey();
35
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
36
+ await writeFile(path, `${key}\n`, { mode: 0o600, flag: "wx" });
37
+ return { address: privateKeyToAccount(key).address, path };
38
+ }
39
+ /** USDC balance on Base mainnet, via a public RPC. */
40
+ export async function usdcBalance(address, rpc = "https://mainnet.base.org") {
41
+ const data = `0x70a08231${address.slice(2).toLowerCase().padStart(64, "0")}`;
42
+ const res = await fetch(rpc, {
43
+ method: "POST",
44
+ headers: { "content-type": "application/json" },
45
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "eth_call", params: [{ to: USDC_BASE, data }, "latest"] }),
46
+ });
47
+ const { result } = (await res.json());
48
+ return Number(BigInt(result)) / 1e6;
49
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ncrypted",
3
- "version": "0.1.1",
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",
@@ -14,6 +14,10 @@
14
14
  "./crypto": {
15
15
  "types": "./dist/crypto.d.ts",
16
16
  "default": "./dist/crypto.js"
17
+ },
18
+ "./wallet": {
19
+ "types": "./dist/wallet.d.ts",
20
+ "default": "./dist/wallet.js"
17
21
  }
18
22
  },
19
23
  "bin": {