@paydlux/agent 0.1.8 → 0.1.10
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 +8 -2
- package/dist/cli.js +58 -3
- package/dist/desk.d.ts +16 -0
- package/dist/desk.js +123 -49
- package/package.json +5 -1
- package/phone-app/app.js +405 -226
- package/phone-app/icon-192.png +0 -0
- package/phone-app/icon-512.png +0 -0
- package/phone-app/index.html +81 -18
- package/phone-app/manifest.webmanifest +15 -0
- package/phone-app/xdr.js +45 -5
package/README.md
CHANGED
|
@@ -84,11 +84,17 @@ one key. So the approval half is split off:
|
|
|
84
84
|
npx tsx stellar/phone-check.ts # 17 checks: seller alone cannot pay; phone alone cannot pay
|
|
85
85
|
npx tsx stellar/e2e-phone.ts # desk with no approver key still settles
|
|
86
86
|
npx tsx phone-app/xdr-check.ts # 32 checks: the phone's own decoder is byte-exact with the SDK
|
|
87
|
-
npx tsx phone-app/desk-app-check.ts #
|
|
87
|
+
npx tsx phone-app/desk-app-check.ts # 57 checks: the real app over real HTTP, end to end
|
|
88
88
|
```
|
|
89
89
|
|
|
90
90
|
The seller builds the payment and signs it with their own half. The human opens
|
|
91
|
-
one link — `/phone/?
|
|
91
|
+
one link — `/phone/?k=...` — and the approver app in `phone-app/` shows them the
|
|
92
|
+
`https://desk.paydlux.com/phone/` link opens an installable app: it has a manifest
|
|
93
|
+
and icons, so a phone can put it on the home screen and it runs full-screen. The
|
|
94
|
+
shell (html, js, icons) is public because it holds no secret and no payment data
|
|
95
|
+
and must load before the key does; every route that returns data or moves money
|
|
96
|
+
(`/queue`, `/challenge`, `/approve`, `/signed`, `/deny`) still answers 403 without
|
|
97
|
+
the portal key.
|
|
92
98
|
amount, the token, the payee and the deadline, then signs on the device.
|
|
93
99
|
|
|
94
100
|
The app is three static files with no dependencies and no build step. It carries
|
package/dist/cli.js
CHANGED
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
// A seller should type one command and get a price, a link, and a key to paste
|
|
15
15
|
// into their bot.
|
|
16
16
|
import { createInterface } from "node:readline/promises";
|
|
17
|
-
import { existsSync, readFileSync, writeFileSync, chmodSync } from "node:fs";
|
|
17
|
+
import { existsSync, readFileSync, writeFileSync, chmodSync, realpathSync } from "node:fs";
|
|
18
|
+
import { pathToFileURL } from "node:url";
|
|
18
19
|
import { resolve } from "node:path";
|
|
19
20
|
import { Keypair } from "@stellar/stellar-sdk";
|
|
20
21
|
import { StellarVault, USDC_ISSUER } from "./vault.js";
|
|
@@ -406,6 +407,49 @@ removed from ${path}. this machine can no longer approve anything by itself.
|
|
|
406
407
|
sell with: paydlux sell --split --tls
|
|
407
408
|
`);
|
|
408
409
|
}
|
|
410
|
+
// --------------------------------------------------------------------- withdraw
|
|
411
|
+
/**
|
|
412
|
+
* Take your money back out of the vault. This is a normal 2-of-2 payment:
|
|
413
|
+
* principal (this laptop) + approver (your phone, or the laptop until you run
|
|
414
|
+
* `approver --forget`). The agent key cannot do this — it has no path to an
|
|
415
|
+
* arbitrary destination, which is the whole point of the vault.
|
|
416
|
+
*
|
|
417
|
+
* The XLM the vault pays in ledger rent stays locked while the account exists.
|
|
418
|
+
* Closing the vault (not done here) returns that too.
|
|
419
|
+
*/
|
|
420
|
+
async function withdraw() {
|
|
421
|
+
const { data } = loadVault();
|
|
422
|
+
const to = arg("to");
|
|
423
|
+
const amount = arg("amount");
|
|
424
|
+
const all = flag("all");
|
|
425
|
+
if (!to)
|
|
426
|
+
bad(`where should it go?\n\n paydlux withdraw --to G… [--all | --amount 8]\n\n --to can be any Stellar account, including your Binance deposit address.`);
|
|
427
|
+
const vault = await StellarVault.load(data.vault, data.token.code, data.token.issuer, {
|
|
428
|
+
principal: pub(data.keys.principal),
|
|
429
|
+
approver: data.keys.approver ? pub(data.keys.approver) : undefined,
|
|
430
|
+
agent: pub(data.keys.agent),
|
|
431
|
+
}, (data.network ?? "testnet"));
|
|
432
|
+
if (data.token.code === "USDC")
|
|
433
|
+
vault.useUsdc();
|
|
434
|
+
const bal = await vault.balance();
|
|
435
|
+
const want = all ? bal : amount;
|
|
436
|
+
if (!want)
|
|
437
|
+
bad(`say how much: --all, or --amount 8 (vault holds ${bal} ${data.token.code})`);
|
|
438
|
+
if (Number(want) > Number(bal))
|
|
439
|
+
bad(`the vault holds ${bal} ${data.token.code}, not ${want}.`);
|
|
440
|
+
const keys = data.keys ?? {};
|
|
441
|
+
const principal = Keypair.fromSecret(keys.principal);
|
|
442
|
+
const approver = keys.approver ? Keypair.fromSecret(keys.approver) : null;
|
|
443
|
+
if (!approver) {
|
|
444
|
+
console.log(`this laptop holds only one of the two keys, so the withdrawal needs your phone.
|
|
445
|
+
open: ${flag("tls") ? `https://${PUBLIC_HOST}` : "http://127.0.0.1:4022"}/?k=${data.desk?.portalKey ?? "…"}`);
|
|
446
|
+
bad("then run this again — approvals from the phone land automatically.");
|
|
447
|
+
}
|
|
448
|
+
console.log(`sending ${want} ${data.token.code} from the vault to ${to} …`);
|
|
449
|
+
const res = await vault.release(to, String(want), principal, approver, "paydlux withdraw");
|
|
450
|
+
console.log(`done. tx ${res?.hash}`);
|
|
451
|
+
console.log(`vault now holds ${await vault.balance()} ${data.token.code}`);
|
|
452
|
+
}
|
|
409
453
|
// ------------------------------------------------------------------------- main
|
|
410
454
|
const HELP = `paydlux — get paid by AI agents, with a human in the loop.
|
|
411
455
|
|
|
@@ -418,6 +462,9 @@ const HELP = `paydlux — get paid by AI agents, with a human in the loop.
|
|
|
418
462
|
paydlux approver [--forget]
|
|
419
463
|
print the human half of the vault, then optionally delete it from this
|
|
420
464
|
machine so only the phone can approve.
|
|
465
|
+
paydlux withdraw --to G… [--all | --amount N]
|
|
466
|
+
take your money back out of the vault. Needs both human keys; the agent
|
|
467
|
+
key can never do this.
|
|
421
468
|
|
|
422
469
|
one file holds it all: ./paydlux-vault.json (0600, never commit it)
|
|
423
470
|
`;
|
|
@@ -435,9 +482,17 @@ export async function main() {
|
|
|
435
482
|
return status();
|
|
436
483
|
if (cmd === "approver")
|
|
437
484
|
return approver();
|
|
485
|
+
if (cmd === "withdraw")
|
|
486
|
+
return withdraw();
|
|
438
487
|
bad(`unknown command "${cmd}".\n\n${HELP}`);
|
|
439
488
|
}
|
|
440
|
-
//
|
|
441
|
-
|
|
489
|
+
// Run only when this file is the entry point, so importing it in a test is safe.
|
|
490
|
+
// Compare realpaths, not the invocation string: npm's bin launcher runs this as
|
|
491
|
+
// .../node_modules/.bin/paydlux, which does not end in .js, so a name-based test
|
|
492
|
+
// silently turns the published `paydlux` command into a no-op.
|
|
493
|
+
const invoked = process.argv[1]
|
|
494
|
+
? pathToFileURL(existsSync(process.argv[1]) ? realpathSync(process.argv[1]) : process.argv[1]).href
|
|
495
|
+
: undefined;
|
|
496
|
+
if (invoked === import.meta.url) {
|
|
442
497
|
main().catch((e) => { console.error("failed:", e.message); process.exit(1); });
|
|
443
498
|
}
|
package/dist/desk.d.ts
CHANGED
|
@@ -85,7 +85,23 @@ export declare class ApprovalDesk {
|
|
|
85
85
|
* ledger rejects it if the signature is not over this exact transaction.
|
|
86
86
|
*/
|
|
87
87
|
complete(id: string, signedXdr: string): Promise<DeskRecord>;
|
|
88
|
+
/**
|
|
89
|
+
* The human said no.
|
|
90
|
+
*
|
|
91
|
+
* `awaiting_phone` is a valid state to deny from. Opening the phone link
|
|
92
|
+
* builds the challenge on the spot (so a human needs one tap, not two), which
|
|
93
|
+
* means the first thing a person sees on their phone is already
|
|
94
|
+
* `awaiting_phone` — refusing to deny from that state left the Reject button
|
|
95
|
+
* dead, which was verified on live testnet. Denying is always allowed up to
|
|
96
|
+
* the point the transaction is submitted; after that it is too late.
|
|
97
|
+
*/
|
|
88
98
|
deny(id: string, reason?: string): DeskRecord;
|
|
99
|
+
/**
|
|
100
|
+
* What the phone app is allowed to see. Never the paid `content` — that is
|
|
101
|
+
* what the buyer has not paid for yet — and never the raw challenge XDR,
|
|
102
|
+
* which the app fetches per item so the desk can re-check the binding.
|
|
103
|
+
*/
|
|
104
|
+
queue(): Record<string, unknown>[];
|
|
89
105
|
/** Serve the pending list as JSON (the portal polls this). */
|
|
90
106
|
api(handler: (req: IncomingMessage, res: ServerResponse) => void): (req: IncomingMessage, res: ServerResponse) => void;
|
|
91
107
|
listen(port?: number): Promise<void>;
|
package/dist/desk.js
CHANGED
|
@@ -31,25 +31,34 @@ const staticCache = new Map();
|
|
|
31
31
|
* Static files are read on each process start rather than bundled, so the desk
|
|
32
32
|
* and its front ends can never drift apart. The name is stripped to [a-z.]
|
|
33
33
|
* before it touches a path, so a request can't walk out of the directory.
|
|
34
|
+
*
|
|
35
|
+
* PNGs are returned as bytes: reading an image as utf8 text replaces every
|
|
36
|
+
* invalid sequence with U+FFFD and ships a corrupt icon to the phone.
|
|
34
37
|
*/
|
|
35
38
|
function readStatic(dir, name, label) {
|
|
36
|
-
const safe = name.replace(/[^a-
|
|
39
|
+
const safe = name.replace(/[^a-z0-9._-]/g, "");
|
|
37
40
|
const hit = staticCache.get(dir + "/" + safe);
|
|
38
41
|
if (hit)
|
|
39
42
|
return hit;
|
|
40
43
|
let text;
|
|
41
44
|
try {
|
|
42
|
-
text =
|
|
45
|
+
text = safe.endsWith(".png")
|
|
46
|
+
? readFileSync(join(dir, safe))
|
|
47
|
+
: readFileSync(join(dir, safe), "utf8");
|
|
43
48
|
}
|
|
44
49
|
catch {
|
|
45
|
-
// A
|
|
46
|
-
//
|
|
47
|
-
|
|
50
|
+
// A missing file is a 404, not a crash. Throwing here took the whole desk
|
|
51
|
+
// down over one bad URL: the request handler is not a place to raise.
|
|
52
|
+
return null;
|
|
48
53
|
}
|
|
49
54
|
staticCache.set(dir + "/" + safe, text);
|
|
50
55
|
return text;
|
|
51
56
|
}
|
|
52
57
|
const readAppFile = (name) => readStatic(APP_DIR, name, "approver app");
|
|
58
|
+
const notFound = (res) => {
|
|
59
|
+
res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
|
|
60
|
+
res.end("404");
|
|
61
|
+
};
|
|
53
62
|
const readPortalFile = (name) => readStatic(PORTAL_DIR, name, "portal app");
|
|
54
63
|
/** A portal link is a capability: whoever holds it can approve. So it is long,
|
|
55
64
|
* random, and compared without leaking timing. */
|
|
@@ -258,16 +267,51 @@ export class ApprovalDesk {
|
|
|
258
267
|
}
|
|
259
268
|
return rec;
|
|
260
269
|
}
|
|
270
|
+
/**
|
|
271
|
+
* The human said no.
|
|
272
|
+
*
|
|
273
|
+
* `awaiting_phone` is a valid state to deny from. Opening the phone link
|
|
274
|
+
* builds the challenge on the spot (so a human needs one tap, not two), which
|
|
275
|
+
* means the first thing a person sees on their phone is already
|
|
276
|
+
* `awaiting_phone` — refusing to deny from that state left the Reject button
|
|
277
|
+
* dead, which was verified on live testnet. Denying is always allowed up to
|
|
278
|
+
* the point the transaction is submitted; after that it is too late.
|
|
279
|
+
*/
|
|
261
280
|
deny(id, reason = "denied by the approver") {
|
|
262
281
|
const rec = this.records.get(id);
|
|
263
282
|
if (!rec)
|
|
264
283
|
throw new Error("unknown handle " + id);
|
|
265
|
-
if (rec.status
|
|
266
|
-
throw new Error(
|
|
284
|
+
if (rec.status === "settled")
|
|
285
|
+
throw new Error("already settled — the money has moved, it cannot be denied");
|
|
286
|
+
if (rec.status === "denied")
|
|
287
|
+
return rec; // idempotent: the app may retry
|
|
267
288
|
rec.status = "denied";
|
|
268
289
|
rec.reason = reason;
|
|
290
|
+
rec.challenge = undefined; // the phone's answer is now worthless
|
|
291
|
+
rec.signedXdr = undefined;
|
|
269
292
|
return rec;
|
|
270
293
|
}
|
|
294
|
+
/**
|
|
295
|
+
* What the phone app is allowed to see. Never the paid `content` — that is
|
|
296
|
+
* what the buyer has not paid for yet — and never the raw challenge XDR,
|
|
297
|
+
* which the app fetches per item so the desk can re-check the binding.
|
|
298
|
+
*/
|
|
299
|
+
queue() {
|
|
300
|
+
return this.list().map((r) => ({
|
|
301
|
+
id: r.id,
|
|
302
|
+
at: r.at,
|
|
303
|
+
uri: r.uri,
|
|
304
|
+
price: r.price,
|
|
305
|
+
status: r.status,
|
|
306
|
+
reason: r.reason,
|
|
307
|
+
tx: r.tx,
|
|
308
|
+
/** what the buyer says it wants. Agent-controlled text: render as text. */
|
|
309
|
+
wants: (r.body ?? "").slice(0, 200),
|
|
310
|
+
token: this.cfg.vault.token.getCode(),
|
|
311
|
+
expiresAt: r.challenge?.summary.expiresAt,
|
|
312
|
+
hash: r.challenge?.hash,
|
|
313
|
+
}));
|
|
314
|
+
}
|
|
271
315
|
/** Serve the pending list as JSON (the portal polls this). */
|
|
272
316
|
api(handler) { return handler; }
|
|
273
317
|
listen(port = this.cfg.port) {
|
|
@@ -329,6 +373,65 @@ export class ApprovalDesk {
|
|
|
329
373
|
res.end("ok");
|
|
330
374
|
return;
|
|
331
375
|
}
|
|
376
|
+
// The approver app's static shell is served BEFORE the key gate, on
|
|
377
|
+
// purpose. It is index.html, app.js, xdr.js, the icons and the manifest:
|
|
378
|
+
// client code with no secret and no payment data in it, the same as the
|
|
379
|
+
// portal's own portal.js. Serving it unauthenticated is what lets a phone
|
|
380
|
+
// install it to a home screen from a plain URL, with the seller's key
|
|
381
|
+
// arriving separately in the link. Everything that carries data or moves
|
|
382
|
+
// money — /queue, /challenge, /list, /approve, /deny, /signed — stays
|
|
383
|
+
// behind the gate below.
|
|
384
|
+
//
|
|
385
|
+
// The app uses relative script srcs, so it must be served from a
|
|
386
|
+
// directory URL: /phone without the slash would resolve app.js to /app.js.
|
|
387
|
+
if (url.pathname === "/phone") {
|
|
388
|
+
res.writeHead(302, { location: "/phone/" + url.search });
|
|
389
|
+
res.end();
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
if (url.pathname === "/phone/" || url.pathname === "/phone/index.html") {
|
|
393
|
+
res.writeHead(200, {
|
|
394
|
+
"content-type": "text/html; charset=utf-8",
|
|
395
|
+
// The approver key goes in IndexedDB behind a non-extractable
|
|
396
|
+
// CryptoKey; an injected script is the realistic threat, so lock the
|
|
397
|
+
// origin. No inline script on the page: app.js and xdr.js are
|
|
398
|
+
// separate files, so script-src 'self' actually holds. connect-src
|
|
399
|
+
// 'self' is what stops the app talking anywhere but the desk that
|
|
400
|
+
// served it.
|
|
401
|
+
"content-security-policy": "default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; " +
|
|
402
|
+
"img-src 'self' data:; connect-src 'self'; base-uri 'none'; " +
|
|
403
|
+
"form-action 'none'; frame-ancestors 'none'",
|
|
404
|
+
"referrer-policy": "no-referrer",
|
|
405
|
+
"x-content-type-options": "nosniff",
|
|
406
|
+
"cache-control": "no-store",
|
|
407
|
+
});
|
|
408
|
+
const page = readAppFile("index.html");
|
|
409
|
+
if (page === null)
|
|
410
|
+
return notFound(res);
|
|
411
|
+
res.end(page);
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
if (url.pathname.startsWith("/phone/")) {
|
|
415
|
+
const file = url.pathname.slice("/phone/".length);
|
|
416
|
+
// Icons and the manifest are part of installing the app to a home
|
|
417
|
+
// screen, so they are served alongside the code. Names are still
|
|
418
|
+
// stripped to a safe set before they touch a path.
|
|
419
|
+
if (!/^[a-z0-9_-]+\.(js|png|webmanifest)$/.test(file))
|
|
420
|
+
return notFound(res);
|
|
421
|
+
const type = file.endsWith(".png") ? "image/png"
|
|
422
|
+
: file.endsWith(".webmanifest") ? "application/manifest+json"
|
|
423
|
+
: "text/javascript";
|
|
424
|
+
const body = readAppFile(file);
|
|
425
|
+
if (body === null)
|
|
426
|
+
return notFound(res);
|
|
427
|
+
res.writeHead(200, {
|
|
428
|
+
"content-type": type + (file.endsWith(".png") ? "" : "; charset=utf-8"),
|
|
429
|
+
"cache-control": "no-store",
|
|
430
|
+
"x-content-type-options": "nosniff",
|
|
431
|
+
});
|
|
432
|
+
res.end(body);
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
332
435
|
// A public demo link must not be an open approval button. If a portalKey
|
|
333
436
|
// is configured, every route needs it, or the desk answers 403.
|
|
334
437
|
//
|
|
@@ -364,56 +467,22 @@ export class ApprovalDesk {
|
|
|
364
467
|
// approval page and an injected string can't become executable script.
|
|
365
468
|
if (url.pathname === "/portal.js" || url.pathname === "/portal.css") {
|
|
366
469
|
const file = url.pathname.slice(1);
|
|
470
|
+
const body = readPortalFile(file);
|
|
471
|
+
if (body === null)
|
|
472
|
+
return notFound(res);
|
|
367
473
|
res.writeHead(200, {
|
|
368
474
|
"content-type": (file.endsWith(".css") ? "text/css" : "text/javascript") + "; charset=utf-8",
|
|
369
475
|
"x-content-type-options": "nosniff",
|
|
370
476
|
});
|
|
371
|
-
res.end(
|
|
477
|
+
res.end(body);
|
|
372
478
|
return;
|
|
373
479
|
}
|
|
374
480
|
if (url.pathname === "/list")
|
|
375
481
|
return send(200, this.list());
|
|
376
|
-
// The approver app
|
|
377
|
-
//
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
// The app uses relative script srcs, so it must be served from a
|
|
381
|
-
// directory URL. /phone without the slash would resolve app.js to
|
|
382
|
-
// /app.js, so send it to the canonical path with the key intact.
|
|
383
|
-
if (url.pathname === "/phone") {
|
|
384
|
-
res.writeHead(302, { location: "/phone/" + url.search });
|
|
385
|
-
res.end();
|
|
386
|
-
return;
|
|
387
|
-
}
|
|
388
|
-
if (url.pathname === "/phone/" || url.pathname === "/phone/index.html") {
|
|
389
|
-
res.writeHead(200, {
|
|
390
|
-
"content-type": "text/html; charset=utf-8",
|
|
391
|
-
// The key lives in IndexedDB behind a non-extractable CryptoKey;
|
|
392
|
-
// an injected script is the realistic threat, so lock the origin.
|
|
393
|
-
// No inline script on the page: app.js and xdr.js are separate
|
|
394
|
-
// files, so script-src 'self' actually holds. connect-src 'self' is
|
|
395
|
-
// what stops the app talking anywhere but the desk that served it.
|
|
396
|
-
"content-security-policy": "default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; " +
|
|
397
|
-
"img-src 'self' data:; connect-src 'self'; base-uri 'none'; " +
|
|
398
|
-
"form-action 'none'; frame-ancestors 'none'",
|
|
399
|
-
"referrer-policy": "no-referrer",
|
|
400
|
-
"x-content-type-options": "nosniff",
|
|
401
|
-
});
|
|
402
|
-
res.end(readAppFile("index.html"));
|
|
403
|
-
return;
|
|
404
|
-
}
|
|
405
|
-
if (url.pathname.startsWith("/phone/")) {
|
|
406
|
-
const file = url.pathname.slice("/phone/".length);
|
|
407
|
-
if (!/^[a-z]+\.(js|css)$/.test(file))
|
|
408
|
-
return send(404, { error: "not found" });
|
|
409
|
-
res.writeHead(200, {
|
|
410
|
-
"content-type": (file.endsWith(".css") ? "text/css" : "text/javascript") + "; charset=utf-8",
|
|
411
|
-
"cache-control": "no-store",
|
|
412
|
-
"x-content-type-options": "nosniff",
|
|
413
|
-
});
|
|
414
|
-
res.end(readAppFile(file));
|
|
415
|
-
return;
|
|
416
|
-
}
|
|
482
|
+
// The approver app's list view. Key-gated like everything else, and it
|
|
483
|
+
// carries no paid content and no challenge XDR.
|
|
484
|
+
if (url.pathname === "/queue")
|
|
485
|
+
return send(200, this.queue());
|
|
417
486
|
// The app fetches the pending transaction by handle rather than asking
|
|
418
487
|
// the human to paste 400 characters of base64 on a phone.
|
|
419
488
|
const ch = url.pathname.match(/^\/challenge\/([\w-]+)$/);
|
|
@@ -431,6 +500,11 @@ export class ApprovalDesk {
|
|
|
431
500
|
return send(200, {
|
|
432
501
|
id: rec.id, network: rec.challenge.network,
|
|
433
502
|
hash: rec.challenge.hash, xdr: rec.challenge.xdr,
|
|
503
|
+
// what the human is being asked to read. Agent-controlled text: the
|
|
504
|
+
// app renders it as text, never as markup.
|
|
505
|
+
wants: (rec.body ?? "").slice(0, 200),
|
|
506
|
+
memo: rec.challenge.summary.memo ?? "",
|
|
507
|
+
expiresAt: rec.challenge.summary.expiresAt,
|
|
434
508
|
});
|
|
435
509
|
}
|
|
436
510
|
const m = url.pathname.match(/^\/(approve|deny|signed)\/([\w-]+)$/);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@paydlux/agent",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.10",
|
|
4
4
|
"description": "Payments for AI agents that a human approves. Spending mandates enforced on the Stellar ledger: 2-of-2 vault, pre-authorised transaction envelopes, HTTP 402, USDC.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -16,6 +16,10 @@
|
|
|
16
16
|
"phone-app/index.html",
|
|
17
17
|
"phone-app/app.js",
|
|
18
18
|
"phone-app/xdr.js",
|
|
19
|
+
"phone-app/app.css",
|
|
20
|
+
"phone-app/manifest.webmanifest",
|
|
21
|
+
"phone-app/icon-192.png",
|
|
22
|
+
"phone-app/icon-512.png",
|
|
19
23
|
"portal-app/portal.js",
|
|
20
24
|
"portal-app/portal.css",
|
|
21
25
|
"README.md",
|