ncrypted 0.1.2 → 0.3.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/ai-sdk.js ADDED
@@ -0,0 +1,112 @@
1
+ // Vercel AI SDK tools for ncrypted. Usage:
2
+ // import { ncryptedTools } from "ncrypted/ai-sdk";
3
+ // await generateText({ model, tools: ncryptedTools(), prompt });
4
+ import { tool } from "ai";
5
+ import { z } from "zod";
6
+ import { Ncrypted } from "./client.js";
7
+ const errorText = (e) => `Error: ${e.message}`;
8
+ /**
9
+ * Returns AI SDK tools for sharing secrets as one-time, end-to-end encrypted links (and writing them into Vercel env
10
+ * vars without the model seeing them). Encryption happens in your server process. The first 3 secrets per day are
11
+ * free; after that each costs 0.005 USDC on Base, paid via x402 from the configured wallet
12
+ * (NCRYPTED_WALLET_PRIVATE_KEY or ~/.config/ncrypted/wallet.key). Reads and deliveries are free.
13
+ */
14
+ export function ncryptedTools(options = {}) {
15
+ const { include, ...clientOptions } = options;
16
+ const nc = new Ncrypted({ client: "ai-sdk", ...clientOptions });
17
+ const all = {
18
+ ncrypted_create: tool({
19
+ description: "Encrypt a password, API key, token or private note and return a one-time link that self-destructs after a single read. " +
20
+ "Use it whenever a credential must be shared with a person or another agent, instead of writing the value in a message. " +
21
+ "Never repeat the secret in your reply; share only the link. The first 3 per day are free (TTL up to 24h); then 0.005 USDC each.",
22
+ inputSchema: z.object({
23
+ secret: z.string().min(1).max(65536).describe("The sensitive value. Max 64 KB."),
24
+ ttlSeconds: z.number().int().min(300).max(604800).optional().describe("Expiry if never opened. Default 86400 (24h)."),
25
+ passphrase: z.string().optional().describe("Optional second factor; deliver it through a different channel than the link."),
26
+ emailTo: z.string().email().optional().describe("Email the link to this address (requires a paid secret, i.e. a funded wallet)."),
27
+ }),
28
+ execute: async ({ secret, ttlSeconds, passphrase, emailTo }) => {
29
+ try {
30
+ const r = await nc.create(secret, { ttl: ttlSeconds, passphrase, emailTo });
31
+ return {
32
+ link: r.link,
33
+ id: r.id,
34
+ expiresAt: r.expiresAt,
35
+ receiptToken: r.receiptToken,
36
+ emailed: r.emailed,
37
+ free: r.free,
38
+ note: "The link opens exactly once. Share only the link.",
39
+ };
40
+ }
41
+ catch (e) {
42
+ return errorText(e);
43
+ }
44
+ },
45
+ }),
46
+ ncrypted_read: tool({
47
+ description: "Open an ncrypted link (https://ncrypted.ai/s/{id}#{key}) and return the secret. This destroys it permanently. Free. " +
48
+ "Use the value only for the task at hand and do not repeat it.",
49
+ inputSchema: z.object({
50
+ link: z.string().url().describe("The full link including the #key fragment."),
51
+ passphrase: z.string().optional().describe("Required only if the sender set one."),
52
+ }),
53
+ execute: async ({ link, passphrase }) => {
54
+ try {
55
+ return await nc.read(link, passphrase);
56
+ }
57
+ catch (e) {
58
+ return errorText(e);
59
+ }
60
+ },
61
+ }),
62
+ ncrypted_status: tool({
63
+ description: "Check whether a secret you created has been opened (pending, read, burned or expired). Free.",
64
+ inputSchema: z.object({ id: z.string(), receiptToken: z.string() }),
65
+ execute: async ({ id, receiptToken }) => {
66
+ try {
67
+ return await nc.status(id, receiptToken);
68
+ }
69
+ catch (e) {
70
+ return errorText(e);
71
+ }
72
+ },
73
+ }),
74
+ ncrypted_burn: tool({
75
+ description: "Destroy a secret you created before anyone opens it, e.g. if the link went to the wrong place. Free.",
76
+ inputSchema: z.object({ id: z.string(), receiptToken: z.string() }),
77
+ execute: async ({ id, receiptToken }) => {
78
+ try {
79
+ await nc.burn(id, receiptToken);
80
+ return { burned: true };
81
+ }
82
+ catch (e) {
83
+ return errorText(e);
84
+ }
85
+ },
86
+ }),
87
+ ncrypted_to_vercel_env: tool({
88
+ description: "Open an ncrypted link and write its value straight into a Vercel project environment variable (type sensitive), so the value " +
89
+ "is never shown to you. Needs VERCEL_TOKEN. Vercel access is checked before the link is consumed.",
90
+ inputSchema: z.object({
91
+ link: z.string().url(),
92
+ passphrase: z.string().optional(),
93
+ key: z.string().describe("Env var name, e.g. STRIPE_SECRET_KEY."),
94
+ project: z.string().describe("Vercel project name or id."),
95
+ team: z.string().optional().describe("Vercel team id (team_...) or slug."),
96
+ targets: z.array(z.enum(["production", "preview", "development"])).optional().describe("Default: production and preview."),
97
+ }),
98
+ execute: async ({ link, passphrase, key, project, team, targets }) => {
99
+ try {
100
+ const r = await nc.deliver(link, { type: "vercel_env", key, project, team, targets }, { passphrase });
101
+ return { delivered: true, ...r, note: "The value was written directly and was not shown to you." };
102
+ }
103
+ catch (e) {
104
+ return errorText(e);
105
+ }
106
+ },
107
+ }),
108
+ };
109
+ if (!include)
110
+ return all;
111
+ return Object.fromEntries(include.map((name) => [name, all[name]]));
112
+ }
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.3.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.3.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
+ };