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/README.md +13 -0
- package/dist/ai-sdk.d.ts +1635 -0
- package/dist/ai-sdk.js +112 -0
- package/dist/cli.js +53 -6
- package/dist/client.d.ts +82 -1
- package/dist/client.js +173 -16
- package/dist/crypto.d.ts +21 -0
- package/dist/crypto.js +61 -0
- package/dist/destinations.d.ts +35 -0
- package/dist/destinations.js +146 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -1
- package/dist/localRequests.d.ts +13 -0
- package/dist/localRequests.js +25 -0
- package/package.json +27 -7
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
|
-
|
|
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
|
-
|
|
89
|
-
|
|
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,
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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 {
|
|
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
|
+
};
|