@paydlux/agent 0.1.9 → 0.1.11

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/desk.js CHANGED
@@ -10,7 +10,7 @@
10
10
  // - a stolen phone cannot approve alone
11
11
  // - the approval is a real ledger signature, not a database flag
12
12
  import { createServer } from "node:http";
13
- import { readFileSync } from "node:fs";
13
+ import { readFileSync, statSync } from "node:fs";
14
14
  import { dirname, join } from "node:path";
15
15
  import { fileURLToPath } from "node:url";
16
16
  import { randomBytes, timingSafeEqual } from "node:crypto";
@@ -27,29 +27,58 @@ const HERE = dirname(fileURLToPath(import.meta.url));
27
27
  const APP_DIR = join(HERE, "..", "phone-app");
28
28
  const PORTAL_DIR = join(HERE, "..", "portal-app");
29
29
  const staticCache = new Map();
30
+ // mtime of the bytes held in staticCache, so an edited file is picked up on the
31
+ // next request instead of after a restart. See readStatic.
32
+ const staticStamp = new Map();
30
33
  /**
31
- * Static files are read on each process start rather than bundled, so the desk
32
- * and its front ends can never drift apart. The name is stripped to [a-z.]
33
- * before it touches a path, so a request can't walk out of the directory.
34
+ * Static files are read from disk, not bundled, so the desk and its front ends
35
+ * can never drift apart. The name is stripped to [a-z._-] before it touches a
36
+ * path, so a request can't walk out of the directory.
37
+ *
38
+ * PNGs are returned as bytes: reading an image as utf8 text replaces every
39
+ * invalid sequence with U+FFFD and ships a corrupt icon to the phone.
40
+ *
41
+ * The cache is keyed on the file's mtime, not just its name. Caching once per
42
+ * process looked fine and was not: the approver's service worker is deliberately
43
+ * network-first so that a security fix reaches an installed phone on the next
44
+ * open, and a desk that holds the old bytes in memory until it restarts breaks
45
+ * exactly that promise. A patched app.js on disk would have sat invisible for
46
+ * days behind a running desk. One stat() per request is cheap; a stale approver
47
+ * page that keeps signing old-style transactions is not.
34
48
  */
35
49
  function readStatic(dir, name, label) {
36
- const safe = name.replace(/[^a-z.]/g, "");
37
- const hit = staticCache.get(dir + "/" + safe);
38
- if (hit)
50
+ const safe = name.replace(/[^a-z0-9._-]/g, "");
51
+ const file = join(dir, safe);
52
+ let stamp;
53
+ try {
54
+ stamp = statSync(file).mtimeMs;
55
+ }
56
+ catch {
57
+ return null; // missing file: a 404, not a crash
58
+ }
59
+ const key = dir + "/" + safe;
60
+ const hit = staticCache.get(key);
61
+ if (hit && staticStamp.get(key) === stamp)
39
62
  return hit;
40
63
  let text;
41
64
  try {
42
- text = readFileSync(join(dir, safe), "utf8");
65
+ text = safe.endsWith(".png") ? readFileSync(file) : readFileSync(file, "utf8");
43
66
  }
44
67
  catch {
45
- // A package install that shipped without the folder should say so instead of
46
- // throwing out of the request handler.
47
- throw new Error(label + " is missing from this install (expected " + join(dir, safe) + ")");
68
+ // Unreadable between the stat and the read, or not a file at all. The
69
+ // request handler is not a place to raise: one bad URL must not take the
70
+ // whole desk down.
71
+ return null;
48
72
  }
49
- staticCache.set(dir + "/" + safe, text);
73
+ staticCache.set(key, text);
74
+ staticStamp.set(key, stamp);
50
75
  return text;
51
76
  }
52
77
  const readAppFile = (name) => readStatic(APP_DIR, name, "approver app");
78
+ const notFound = (res) => {
79
+ res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
80
+ res.end("404");
81
+ };
53
82
  const readPortalFile = (name) => readStatic(PORTAL_DIR, name, "portal app");
54
83
  /** A portal link is a capability: whoever holds it can approve. So it is long,
55
84
  * random, and compared without leaking timing. */
@@ -165,12 +194,61 @@ export class ApprovalDesk {
165
194
  * principal only, and hand it to the phone. The money moves when
166
195
  * /signed comes back with the phone's signature.
167
196
  */
197
+ /**
198
+ * The mandate check, in one place, called before money moves.
199
+ *
200
+ * Returns null when the payment is allowed, or the reason it is not. The
201
+ * record is marked failed so the buyer's poll gets a real answer instead of a
202
+ * timeout, and the reason is surfaced to the human rather than logged only.
203
+ *
204
+ * Why this exists at all: `--max` and `--daily` were printed in the seller's
205
+ * banner and never applied to anything. A ceiling nobody enforces is worse
206
+ * than no ceiling, because the seller believes they have one.
207
+ */
208
+ mandateBlock(rec, when) {
209
+ const policy = this.cfg.policy;
210
+ if (!policy)
211
+ return null;
212
+ // Approvals already made that have not settled yet. Those are promises the
213
+ // seller has already given, so a new one has to fit alongside them.
214
+ // `pending` deliberately does NOT count: that is a request nobody has
215
+ // approved, and counting the whole queue would refuse the first approval
216
+ // just because more requests exist than the budget can cover.
217
+ const committed = this.list().reduce((sum, r) => (r.id !== rec.id && (r.status === "awaiting_phone" || r.status === "approved"))
218
+ ? sum + Number(r.price) : sum, 0);
219
+ const verdict = policy.check({
220
+ amount: Number(rec.price),
221
+ payee: this.cfg.payee,
222
+ ttl: this.cfg.decideWithinSeconds,
223
+ committed,
224
+ });
225
+ if (verdict.ok)
226
+ return null;
227
+ rec.status = "failed";
228
+ rec.reason = `mandate refused (${verdict.rule}): ${verdict.detail}`;
229
+ rec.mandateBlocked = true;
230
+ rec.challenge = undefined;
231
+ rec.signedXdr = undefined;
232
+ console.log(`[desk] ${rec.id} ${when}: ${rec.reason}`);
233
+ return rec.reason;
234
+ }
235
+ /** The mandate has been satisfied: count the money as spent. */
236
+ recordSpend(rec, tx) {
237
+ this.cfg.policy?.record({
238
+ at: Date.now(), amount: Number(rec.price), payee: this.cfg.payee,
239
+ uri: rec.uri, mandateHash: tx,
240
+ });
241
+ }
168
242
  async approve(id) {
169
243
  const rec = this.records.get(id);
170
244
  if (!rec)
171
245
  throw new Error("unknown handle " + id);
172
246
  if (rec.status !== "pending")
173
247
  throw new Error(`already ${rec.status}`);
248
+ // Check before asking the phone. A human who approves a payment the mandate
249
+ // will refuse has spent a Face ID scan and a signature for nothing.
250
+ if (this.mandateBlock(rec, "refused before asking"))
251
+ return rec;
174
252
  if (this.cfg.split)
175
253
  return this.requestPhoneSignature(rec);
176
254
  if (!this.cfg.principal || !this.cfg.approver) {
@@ -182,6 +260,7 @@ export class ApprovalDesk {
182
260
  const res = await this.cfg.vault.release(this.cfg.payee, rec.price, Keypair.fromSecret(this.cfg.principal), Keypair.fromSecret(this.cfg.approver), id);
183
261
  rec.status = "settled";
184
262
  rec.tx = String(res?.hash ?? "");
263
+ this.recordSpend(rec, rec.tx);
185
264
  }
186
265
  catch (e) {
187
266
  rec.status = "failed";
@@ -246,28 +325,73 @@ export class ApprovalDesk {
246
325
  console.log(`[desk] ${id} rejected: ${problem}`);
247
326
  return rec;
248
327
  }
328
+ // Re-check at the last possible moment. The budget was verified when the
329
+ // phone was asked; a day is long and other payments have been approved since.
330
+ // Better to refuse now than to submit a payment the mandate forbids.
331
+ if (this.mandateBlock(rec, "refused at submission"))
332
+ return rec;
249
333
  rec.signedXdr = signedXdr;
250
334
  try {
251
335
  const res = await submitApproved(signedXdr, this.cfg.vault.network, this.cfg.vault.horizon);
252
336
  rec.status = "settled";
253
337
  rec.tx = res.hash;
338
+ this.recordSpend(rec, res.hash);
339
+ // The money moving is the one event a seller should never have to
340
+ // discover by refreshing a page.
341
+ console.log(`[desk] ${rec.id} PAID ${rec.price} ${this.cfg.vault.token.getCode()} — ledger ${rec.tx}`);
254
342
  }
255
343
  catch (e) {
256
344
  rec.status = "failed";
257
345
  rec.reason = e.message;
346
+ console.log(`[desk] ${rec.id} failed on the ledger: ${rec.reason}`);
258
347
  }
259
348
  return rec;
260
349
  }
350
+ /**
351
+ * The human said no.
352
+ *
353
+ * `awaiting_phone` is a valid state to deny from. Opening the phone link
354
+ * builds the challenge on the spot (so a human needs one tap, not two), which
355
+ * means the first thing a person sees on their phone is already
356
+ * `awaiting_phone` — refusing to deny from that state left the Reject button
357
+ * dead, which was verified on live testnet. Denying is always allowed up to
358
+ * the point the transaction is submitted; after that it is too late.
359
+ */
261
360
  deny(id, reason = "denied by the approver") {
262
361
  const rec = this.records.get(id);
263
362
  if (!rec)
264
363
  throw new Error("unknown handle " + id);
265
- if (rec.status !== "pending")
266
- throw new Error(`already ${rec.status}`);
364
+ if (rec.status === "settled")
365
+ throw new Error("already settled — the money has moved, it cannot be denied");
366
+ if (rec.status === "denied")
367
+ return rec; // idempotent: the app may retry
267
368
  rec.status = "denied";
268
369
  rec.reason = reason;
370
+ rec.challenge = undefined; // the phone's answer is now worthless
371
+ rec.signedXdr = undefined;
269
372
  return rec;
270
373
  }
374
+ /**
375
+ * What the phone app is allowed to see. Never the paid `content` — that is
376
+ * what the buyer has not paid for yet — and never the raw challenge XDR,
377
+ * which the app fetches per item so the desk can re-check the binding.
378
+ */
379
+ queue() {
380
+ return this.list().map((r) => ({
381
+ id: r.id,
382
+ at: r.at,
383
+ uri: r.uri,
384
+ price: r.price,
385
+ status: r.status,
386
+ reason: r.reason,
387
+ tx: r.tx,
388
+ /** what the buyer says it wants. Agent-controlled text: render as text. */
389
+ wants: (r.body ?? "").slice(0, 200),
390
+ token: this.cfg.vault.token.getCode(),
391
+ expiresAt: r.challenge?.summary.expiresAt,
392
+ hash: r.challenge?.hash,
393
+ }));
394
+ }
271
395
  /** Serve the pending list as JSON (the portal polls this). */
272
396
  api(handler) { return handler; }
273
397
  listen(port = this.cfg.port) {
@@ -329,6 +453,65 @@ export class ApprovalDesk {
329
453
  res.end("ok");
330
454
  return;
331
455
  }
456
+ // The approver app's static shell is served BEFORE the key gate, on
457
+ // purpose. It is index.html, app.js, xdr.js, the icons and the manifest:
458
+ // client code with no secret and no payment data in it, the same as the
459
+ // portal's own portal.js. Serving it unauthenticated is what lets a phone
460
+ // install it to a home screen from a plain URL, with the seller's key
461
+ // arriving separately in the link. Everything that carries data or moves
462
+ // money — /queue, /challenge, /list, /approve, /deny, /signed — stays
463
+ // behind the gate below.
464
+ //
465
+ // The app uses relative script srcs, so it must be served from a
466
+ // directory URL: /phone without the slash would resolve app.js to /app.js.
467
+ if (url.pathname === "/phone") {
468
+ res.writeHead(302, { location: "/phone/" + url.search });
469
+ res.end();
470
+ return;
471
+ }
472
+ if (url.pathname === "/phone/" || url.pathname === "/phone/index.html") {
473
+ res.writeHead(200, {
474
+ "content-type": "text/html; charset=utf-8",
475
+ // The approver key goes in IndexedDB behind a non-extractable
476
+ // CryptoKey; an injected script is the realistic threat, so lock the
477
+ // origin. No inline script on the page: app.js and xdr.js are
478
+ // separate files, so script-src 'self' actually holds. connect-src
479
+ // 'self' is what stops the app talking anywhere but the desk that
480
+ // served it.
481
+ "content-security-policy": "default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; " +
482
+ "img-src 'self' data:; connect-src 'self'; base-uri 'none'; " +
483
+ "form-action 'none'; frame-ancestors 'none'",
484
+ "referrer-policy": "no-referrer",
485
+ "x-content-type-options": "nosniff",
486
+ "cache-control": "no-store",
487
+ });
488
+ const page = readAppFile("index.html");
489
+ if (page === null)
490
+ return notFound(res);
491
+ res.end(page);
492
+ return;
493
+ }
494
+ if (url.pathname.startsWith("/phone/")) {
495
+ const file = url.pathname.slice("/phone/".length);
496
+ // Icons and the manifest are part of installing the app to a home
497
+ // screen, so they are served alongside the code. Names are still
498
+ // stripped to a safe set before they touch a path.
499
+ if (!/^[a-z0-9_-]+\.(js|png|webmanifest)$/.test(file))
500
+ return notFound(res);
501
+ const type = file.endsWith(".png") ? "image/png"
502
+ : file.endsWith(".webmanifest") ? "application/manifest+json"
503
+ : "text/javascript";
504
+ const body = readAppFile(file);
505
+ if (body === null)
506
+ return notFound(res);
507
+ res.writeHead(200, {
508
+ "content-type": type + (file.endsWith(".png") ? "" : "; charset=utf-8"),
509
+ "cache-control": "no-store",
510
+ "x-content-type-options": "nosniff",
511
+ });
512
+ res.end(body);
513
+ return;
514
+ }
332
515
  // A public demo link must not be an open approval button. If a portalKey
333
516
  // is configured, every route needs it, or the desk answers 403.
334
517
  //
@@ -364,56 +547,22 @@ export class ApprovalDesk {
364
547
  // approval page and an injected string can't become executable script.
365
548
  if (url.pathname === "/portal.js" || url.pathname === "/portal.css") {
366
549
  const file = url.pathname.slice(1);
550
+ const body = readPortalFile(file);
551
+ if (body === null)
552
+ return notFound(res);
367
553
  res.writeHead(200, {
368
554
  "content-type": (file.endsWith(".css") ? "text/css" : "text/javascript") + "; charset=utf-8",
369
555
  "x-content-type-options": "nosniff",
370
556
  });
371
- res.end(readPortalFile(file));
557
+ res.end(body);
372
558
  return;
373
559
  }
374
560
  if (url.pathname === "/list")
375
561
  return send(200, this.list());
376
- // The approver app. Served from the desk so the human opens one link on
377
- // their phone instead of pasting XDR around. The app itself makes no
378
- // requests after loading, and the portalKey gate above already covers
379
- // these paths: without the key, nothing here is reachable.
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
- }
562
+ // The approver app's list view. Key-gated like everything else, and it
563
+ // carries no paid content and no challenge XDR.
564
+ if (url.pathname === "/queue")
565
+ return send(200, this.queue());
417
566
  // The app fetches the pending transaction by handle rather than asking
418
567
  // the human to paste 400 characters of base64 on a phone.
419
568
  const ch = url.pathname.match(/^\/challenge\/([\w-]+)$/);
@@ -431,6 +580,16 @@ export class ApprovalDesk {
431
580
  return send(200, {
432
581
  id: rec.id, network: rec.challenge.network,
433
582
  hash: rec.challenge.hash, xdr: rec.challenge.xdr,
583
+ // Which key this vault will actually accept. A phone holding a key
584
+ // from a previous run signs a valid signature over a valid
585
+ // transaction, and the ledger answers BAD_AUTH with no explanation.
586
+ // Naming the expected key here lets the app say so BEFORE signing.
587
+ approver: this.cfg.vault.signers.approver,
588
+ // what the human is being asked to read. Agent-controlled text: the
589
+ // app renders it as text, never as markup.
590
+ wants: (rec.body ?? "").slice(0, 200),
591
+ memo: rec.challenge.summary.memo ?? "",
592
+ expiresAt: rec.challenge.summary.expiresAt,
434
593
  });
435
594
  }
436
595
  const m = url.pathname.match(/^\/(approve|deny|signed)\/([\w-]+)$/);
package/dist/gateway.d.ts CHANGED
@@ -1,7 +1,8 @@
1
- import { type IncomingMessage, type ServerResponse } from "node:http";
1
+ import { type IncomingMessage, type Server, type ServerResponse } from "node:http";
2
2
  import { Transaction } from "@stellar/stellar-sdk";
3
3
  import { StellarVault, StellarTxError, type Amount, type Mandate, type Network } from "./vault.ts";
4
4
  import { ApprovalDesk, type DeskStore } from "./desk.ts";
5
+ import type { PolicyEngine } from "./policy.ts";
5
6
  export declare const PAY_HEADER = "x-payment";
6
7
  export type Route = {
7
8
  uri: string;
@@ -37,6 +38,8 @@ export type GatewayConfig = {
37
38
  portalKey?: string;
38
39
  /** hostname the desk is publicly served on; pins the Host header. See DeskConfig. */
39
40
  publicHost?: string;
41
+ /** how long a human gets to decide before the parked payment's tx is dead */
42
+ decideWithinSeconds?: number;
40
43
  /**
41
44
  * Agent public keys allowed to park a payment for approval. An intent is only
42
45
  * honoured if it is signed by one of these, so an unlisted key gets a plain
@@ -50,6 +53,11 @@ export type GatewayConfig = {
50
53
  * loopback address, which is only useful on one machine.
51
54
  */
52
55
  deskUrl?: string;
56
+ /**
57
+ * The spending mandate. Passed straight to the desk, which is where it is
58
+ * enforced — see DeskConfig.policy for why the gateway is the wrong place.
59
+ */
60
+ policy?: PolicyEngine;
53
61
  };
54
62
  export type PaymentProof = {
55
63
  /** pre-authorised envelope, base64 XDR, as issued by the vault */
@@ -111,7 +119,7 @@ export declare class Gateway {
111
119
  hash: string;
112
120
  }>;
113
121
  handler(getContent: (uri: string) => string): (req: IncomingMessage, res: ServerResponse) => Promise<void>;
114
- listen(port: number, getContent?: (uri: string) => string): Promise<void>;
122
+ listen(port: number, getContent?: (uri: string) => string): Promise<Server<typeof IncomingMessage, typeof ServerResponse>>;
115
123
  }
116
124
  export { StellarTxError };
117
125
  export type { DeskStore };
package/dist/gateway.js CHANGED
@@ -39,6 +39,8 @@ export class Gateway {
39
39
  portalKey: cfg.portalKey,
40
40
  split: cfg.split,
41
41
  publicHost: cfg.publicHost,
42
+ decideWithinSeconds: cfg.decideWithinSeconds,
43
+ policy: cfg.policy,
42
44
  });
43
45
  }
44
46
  /** Mandates the vault currently holds, so the gateway can hand them out. */
@@ -244,6 +246,20 @@ export class Gateway {
244
246
  return;
245
247
  }
246
248
  }
249
+ // The seller's own spending mandate refused this one. Retrying the
250
+ // identical request can never succeed — the ceiling is not going to
251
+ // move — so re-parking it would loop: park, refuse, park, refuse, with
252
+ // a fresh handle each time and a queue the human cannot clear. Answer
253
+ // 402 and make the buyer change the request (a smaller price, a
254
+ // different route) to ask again.
255
+ if (hit.mandateBlocked) {
256
+ res.writeHead(402, { [PAY_HEADER]: JSON.stringify(this.requirements(uri)) });
257
+ res.end(JSON.stringify({
258
+ error: "mandate_refused", handle: hit.id,
259
+ reason: hit.reason ?? "refused by the seller's spending mandate",
260
+ }));
261
+ return;
262
+ }
247
263
  // A human said no. Do not silently re-open the same request and nag
248
264
  // them with it: the buyer has to change the request to ask again.
249
265
  if (hit.status === "denied") {
@@ -332,7 +348,10 @@ export class Gateway {
332
348
  }
333
349
  listen(port, getContent = (u) => `payload for ${u} at ${new Date().toISOString()}`) {
334
350
  const srv = createServer(this.handler(getContent));
335
- return new Promise((resolve) => srv.listen(port, () => resolve()));
351
+ // Hand the server back. Returning void meant a started gateway could never
352
+ // be stopped, which left tests holding ports and a seller with no clean way
353
+ // to change a route without killing the process.
354
+ return new Promise((resolve) => srv.listen(port, () => resolve(srv)));
336
355
  }
337
356
  }
338
357
  function readBody(req) {
package/dist/policy.d.ts CHANGED
@@ -48,5 +48,14 @@ export declare class PolicyEngine {
48
48
  payee: string;
49
49
  ttl?: number;
50
50
  liveMandates?: number;
51
+ /**
52
+ * Money not spent yet but already promised: payments a human has approved
53
+ * and is waiting on a signature for. Without this the ceiling only counts
54
+ * settled payments, so a seller with a 20/day budget can approve six 4.50
55
+ * requests, spend six Face ID scans, and watch two of them fail at
56
+ * submission. Counting the queue refuses the fifth one at the button
57
+ * instead, which is the point of having a human in the loop.
58
+ */
59
+ committed?: number;
51
60
  }): Verdict;
52
61
  }
package/dist/policy.js CHANGED
@@ -36,9 +36,9 @@ export class PolicyEngine {
36
36
  if (req.amount > p.maxPerPayment) {
37
37
  return no("max_per_payment", `${req.amount} exceeds the per-payment ceiling of ${p.maxPerPayment}`);
38
38
  }
39
- const spent = this.spentToday();
39
+ const spent = this.spentToday() + (req.committed ?? 0);
40
40
  if (spent + req.amount > p.dailyBudget) {
41
- return no("daily_budget", `${req.amount} would take today to ${(spent + req.amount).toFixed(2)}, over the ${p.dailyBudget} budget (spent ${spent.toFixed(2)})`);
41
+ return no("daily_budget", `${req.amount} would take today to ${(spent + req.amount).toFixed(2)}, over the ${p.dailyBudget} budget (${this.spentToday().toFixed(2)} paid${req.committed ? ` + ${req.committed.toFixed(2)} already approved and waiting` : ""})`);
42
42
  }
43
43
  if (p.denylist?.includes(req.payee))
44
44
  return no("denylist", `${short(req.payee)} is blocked`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@paydlux/agent",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
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",
@@ -36,7 +40,11 @@
36
40
  "test": "cd contracts && forge test",
37
41
  "test:stellar": "tsx stellar/demo.ts",
38
42
  "test:portal": "tsx stellar/xss-check.ts",
39
- "test:phone": "tsx stellar/phone-check.ts && tsx stellar/desk-split-check.ts && tsx stellar/e2e-phone.ts && tsx phone-app/xdr-check.ts && tsx phone-app/app-check.ts && tsx phone-app/desk-app-check.ts",
43
+ "test:mandate": "tsx stellar/mandate-check.ts",
44
+ "test:phone": "tsx stellar/phone-check.ts && tsx stellar/desk-split-check.ts && tsx stellar/mandate-check.ts && tsx stellar/e2e-phone.ts && tsx phone-app/xdr-check.ts && tsx phone-app/app-check.ts && tsx phone-app/desk-app-check.ts && tsx ios/bridge-check.ts",
45
+ "test:ios": "tsx ios/bridge-check.ts",
46
+ "test:link": "swiftc ios/PaydluxApprover/LinkResolver.swift ios/Checks/linkcheck/main.swift -o \"$TMPDIR/linkcheck\" && \"$TMPDIR/linkcheck\"",
47
+ "test:seed": "DEVELOPER_DIR=/Library/Developer/CommandLineTools /usr/bin/swiftc -target arm64-apple-macosx14.0 ios/PaydluxApprover/Seed.swift ios/PaydluxApprover/Hex.swift ios/Checks/main.swift -o \"$TMPDIR/seedcheck\" && \"$TMPDIR/seedcheck\"",
40
48
  "typecheck": "tsc --noEmit",
41
49
  "app": "tsx stellar/cli.ts",
42
50
  "app:mainnet": "tsx stellar/cli.ts setup --mainnet"