@pidgeon-ai/cli 1.37.0 → 1.39.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 CHANGED
@@ -40,6 +40,21 @@ one request open until mail arrives, and prints it with `extracted.codes` and
40
40
  describes one command, with its endpoint and its flags. Documentation:
41
41
  [pidgeon.ai/docs/cli](https://pidgeon.ai/docs/cli).
42
42
 
43
+ ## Check who sent something
44
+
45
+ `pidgeon id` needs no key. It checks a message, or signs a request as one of
46
+ your agents:
47
+
48
+ ```sh
49
+ pidgeon id verify message.eml
50
+ pidgeon id curl -I https://shop.example --as sunny
51
+ ```
52
+
53
+ `verify` asks the public verifier and says whether the signature holds, which
54
+ agent sent it, who it acts for and whether the body changed. `curl` signs the
55
+ request the way Web Bot Auth asks and prints what it sent. `pidgeon id --help`
56
+ lists the rest, including `init` for your own domain.
57
+
43
58
  ## Flags
44
59
 
45
60
  - Repeat a list flag: `--to one@example.com --to two@example.com`.
@@ -2027,7 +2027,7 @@ function sleep(ms, signal) {
2027
2027
  });
2028
2028
  }
2029
2029
 
2030
- // ../id/dist/chunk-EAXZTLYY.js
2030
+ // ../id/dist/chunk-2NWPDKWJ.js
2031
2031
  import { createHash } from "crypto";
2032
2032
  import { chmod, mkdir, readFile, readdir, writeFile } from "fs/promises";
2033
2033
  import { homedir } from "os";
@@ -2496,6 +2496,34 @@ async function signRequest(identity, input) {
2496
2496
  signature: `${label}=:${Buffer.from(signature).toString("base64")}:`
2497
2497
  };
2498
2498
  }
2499
+ function signedFetch(identity, options) {
2500
+ const base = options.fetch ?? globalThis.fetch;
2501
+ return async (resource, init) => {
2502
+ const request = new Request(resource, init);
2503
+ const body = request.body ? new Uint8Array(await request.arrayBuffer()) : null;
2504
+ const headers = {};
2505
+ request.headers.forEach((value, name) => {
2506
+ headers[name] = value;
2507
+ });
2508
+ const signature = await signRequest(identity, {
2509
+ method: request.method,
2510
+ url: request.url,
2511
+ headers,
2512
+ body,
2513
+ agent: options.agent,
2514
+ grants: options.grants
2515
+ });
2516
+ const signedHeaders = new Headers(request.headers);
2517
+ for (const [name, value] of Object.entries(signature)) signedHeaders.set(name, value);
2518
+ return base(request.url, {
2519
+ method: request.method,
2520
+ headers: signedHeaders,
2521
+ body: body && body.length > 0 ? body : void 0,
2522
+ redirect: request.redirect,
2523
+ signal: request.signal
2524
+ });
2525
+ };
2526
+ }
2499
2527
  var ISSUANCE_TAG = "pidgeon-id";
2500
2528
  var DEFAULT_ID_URL = "https://pidgeon.ai/api/id";
2501
2529
  var IdApiError = class extends Error {
@@ -2562,6 +2590,10 @@ var IdClient = class {
2562
2590
  grant(input) {
2563
2591
  return this.call("POST", "/grants", input);
2564
2592
  }
2593
+ /** Every token Sign in with Pidgeon issued `name`, taken back; its card and grants stand. */
2594
+ revokeSignIns(name) {
2595
+ return this.call("DELETE", `/agents/${encodeURIComponent(name)}/sign-ins`);
2596
+ }
2565
2597
  revokeGrant(id) {
2566
2598
  return this.call("DELETE", `/grants/${encodeURIComponent(id)}`);
2567
2599
  }
@@ -2633,7 +2665,21 @@ async function fetchCard(agent, fetcher = fetch, postHost = (domain) => `https:/
2633
2665
  }
2634
2666
  return body.jws;
2635
2667
  }
2636
- async function signInWithPidgeon(identity, input) {
2668
+ function signInWithPidgeon(identity, input) {
2669
+ return signIn(
2670
+ identity,
2671
+ input,
2672
+ async ({ tokenEndpoint, exchange, headers, agent }) => signRequest(identity, {
2673
+ method: "POST",
2674
+ url: tokenEndpoint,
2675
+ headers,
2676
+ body: exchange,
2677
+ agent,
2678
+ grants: input.grant ? [input.grant] : void 0
2679
+ })
2680
+ );
2681
+ }
2682
+ async function signIn(identity, input, authenticate) {
2637
2683
  const fetcher = input.fetch ?? fetch;
2638
2684
  const agent = input.agent.trim().toLowerCase();
2639
2685
  const site = await siteMetadata(input.site, fetcher);
@@ -2649,21 +2695,15 @@ async function signInWithPidgeon(identity, input) {
2649
2695
  grant_type: TOKEN_EXCHANGE,
2650
2696
  subject_token: card,
2651
2697
  subject_token_type: TOKEN_TYPE_JWT,
2652
- audience: site.client_id
2698
+ audience: site.client_id,
2699
+ ...input.delegation ? { requested_token_type: TOKEN_TYPE_JWT } : {}
2653
2700
  });
2654
2701
  const headers = { "content-type": "application/x-www-form-urlencoded" };
2655
2702
  const issued = await fetcher(tokenEndpoint, {
2656
2703
  method: "POST",
2657
2704
  headers: {
2658
2705
  ...headers,
2659
- ...await signRequest(identity, {
2660
- method: "POST",
2661
- url: tokenEndpoint,
2662
- headers,
2663
- body: exchange,
2664
- agent,
2665
- grants: input.grant ? [input.grant] : void 0
2666
- })
2706
+ ...await authenticate({ tokenEndpoint, exchange, headers, agent, site })
2667
2707
  },
2668
2708
  body: exchange
2669
2709
  });
@@ -2719,8 +2759,12 @@ var HELP = `pidgeon id \u2014 a signed identity for your agents, on your own dom
2719
2759
  grant <name> Grant <name> what it may do, as the domain
2720
2760
  --scope bookings.change --label "manage bookings"
2721
2761
  --subject customer:42 --audience delta.com --days 30
2762
+ --grantor maria@example.com ask a person instead
2722
2763
  revoke-grant <id> Take a grant back now
2764
+ revoke-sign-ins <name> End every token <name> signed in with; its card and grants stand
2723
2765
  sign-in <site> Sign in to a site as an agent --as <name> --grant <jws>
2766
+ verify [<file>] Check a message's signatures (a file, or standard input). No key
2767
+ curl [-I] <url> Send a request signed as an agent --as <name>
2724
2768
 
2725
2769
  --domain <domain> Which domain, when this machine holds more than one
2726
2770
  --json Print the API's answer as JSON
@@ -2856,6 +2900,69 @@ Or add them by hand:` : "Add these records at your DNS provider:",
2856
2900
  'It renews itself. Sign with it: import { loadIdentity, signEmail, signedFetch } from "@pidgeon-ai/id".'
2857
2901
  ].join("\n");
2858
2902
  }
2903
+ case "verify": {
2904
+ const file = rest[0];
2905
+ const message = file ? await (await import("fs/promises")).readFile(file, "utf8") : await (options.stdin ?? readStdin)();
2906
+ if (!message.trim()) {
2907
+ throw new UsageError("Give it a message: `pidgeon id verify < message.eml`.");
2908
+ }
2909
+ const response = await (options.fetch ?? fetch)(
2910
+ `${new URL(baseUrl(env)).origin}/api/v1/verify`,
2911
+ {
2912
+ method: "POST",
2913
+ headers: { "content-type": "application/json" },
2914
+ body: JSON.stringify({ message })
2915
+ }
2916
+ );
2917
+ const answer = await response.json();
2918
+ if (!response.ok || !answer.data) {
2919
+ throw new UsageError(
2920
+ answer.error?.message ?? `The verifier answered ${response.status}.`
2921
+ );
2922
+ }
2923
+ if (json2) return JSON.stringify(answer.data, null, 2);
2924
+ return describeVerification(answer.data);
2925
+ }
2926
+ case "curl": {
2927
+ const head = rest[0] === "-I";
2928
+ const target = (head ? rest[1] : rest[0])?.trim();
2929
+ if (!target) throw new UsageError("Name a URL: `pidgeon id curl -I shop.example`.");
2930
+ const url = /^https?:\/\//.test(target) ? target : `https://${target}`;
2931
+ const identity = await which();
2932
+ const named = flags.get("as");
2933
+ let agent;
2934
+ if (typeof named === "string") {
2935
+ agent = named.includes("@") ? named : `${named}@${identity.domain}`;
2936
+ } else {
2937
+ const agents = await client2(identity).list();
2938
+ if (agents.length !== 1) {
2939
+ throw new UsageError("Say which agent signs, with --as <name>.");
2940
+ }
2941
+ agent = agents[0].address;
2942
+ }
2943
+ const sent = {};
2944
+ const signed = signedFetch(identity, {
2945
+ agent,
2946
+ fetch: async (resource, init) => {
2947
+ new Headers(init?.headers).forEach((value, name) => {
2948
+ if (/^(signature|signature-input|signature-agent|from|content-digest)$/.test(name)) {
2949
+ sent[name] = value;
2950
+ }
2951
+ });
2952
+ return (options.fetch ?? fetch)(resource, init);
2953
+ }
2954
+ });
2955
+ const response = await signed(url, { method: head ? "HEAD" : "GET" });
2956
+ const lines = Object.entries(sent).map(
2957
+ ([name, value]) => `> ${capitalise(name)}: ${value}`
2958
+ );
2959
+ lines.push(`< ${response.status} ${response.statusText}`.trimEnd());
2960
+ if (head) {
2961
+ response.headers.forEach((value, name) => lines.push(`< ${capitalise(name)}: ${value}`));
2962
+ return lines.join("\n");
2963
+ }
2964
+ return [...lines, "", await response.text()].join("\n");
2965
+ }
2859
2966
  case "list": {
2860
2967
  const agents = await client2(await which()).list();
2861
2968
  if (json2) return JSON.stringify(agents, null, 2);
@@ -2895,14 +3002,23 @@ Or add them by hand:` : "Add these records at your DNS provider:",
2895
3002
  }
2896
3003
  const days = flags.get("days");
2897
3004
  const audience = flags.get("audience");
3005
+ const grantor = flags.get("grantor");
2898
3006
  const grant = await client2(await which()).grant({
2899
3007
  agent: name,
2900
3008
  scope: { name: scope, label },
2901
3009
  subject: { type, id: idParts.join(":") },
2902
3010
  ...typeof audience === "string" ? { audience } : {},
3011
+ ...typeof grantor === "string" ? { grantor: { email: grantor } } : {},
2903
3012
  ...typeof days === "string" ? { expires_at: new Date(Date.now() + Number(days) * 864e5).toISOString() } : {}
2904
3013
  });
2905
3014
  if (json2) return JSON.stringify(grant, null, 2);
3015
+ if (grant.consent_url) {
3016
+ return [
3017
+ `Asked ${typeof grantor === "string" ? grantor : "them"} to let ${grant.agent} ${grant.scope.label} for ${grant.subject.type} ${grant.subject.id}.`,
3018
+ `Send them this link; nothing is allowed until they answer it: ${grant.consent_url}`,
3019
+ `Grant ${grant.id}. \`pidgeon id revoke-grant ${grant.id}\` withdraws the request.`
3020
+ ].join("\n");
3021
+ }
2906
3022
  return [
2907
3023
  `${grant.agent} may ${grant.scope.label} for ${grant.subject.type} ${grant.subject.id}${grant.audience ? ` at ${grant.audience}` : ""}, until ${grant.expires_at}.`,
2908
3024
  `Grant ${grant.id}. The agent carries it as Post-Grant; --json prints the JWS.`
@@ -2915,6 +3031,14 @@ Or add them by hand:` : "Add these records at your DNS provider:",
2915
3031
  if (json2) return JSON.stringify(revoked, null, 2);
2916
3032
  return `Grant ${revoked.id} is revoked. The next check of it fails.`;
2917
3033
  }
3034
+ case "revoke-sign-ins": {
3035
+ const name = rest[0]?.trim().toLowerCase();
3036
+ if (!name)
3037
+ throw new UsageError("Name the agent: `pidgeon id revoke-sign-ins support-agent`.");
3038
+ const done = await client2(await which()).revokeSignIns(name);
3039
+ if (json2) return JSON.stringify(done, null, 2);
3040
+ return `${done.address}: ${done.revoked} token${done.revoked === 1 ? "" : "s"} taken back. Its card and grants stand.`;
3041
+ }
2918
3042
  case "sign-in": {
2919
3043
  const site = rest[0];
2920
3044
  const name = flags.get("as");
@@ -2954,6 +3078,39 @@ ${HELP}`);
2954
3078
  throw error;
2955
3079
  }
2956
3080
  }
3081
+ function describeVerification(v) {
3082
+ const signer = v.signatures.envelope.domain ?? v.signatures.declaration.domain;
3083
+ const signed = v.signatures.envelope.result === "pass" || v.signatures.declaration.result === "pass";
3084
+ const broken = v.signatures.envelope.result === "fail" || v.signatures.declaration.result === "fail";
3085
+ const row = (ok, label, value) => `${ok === null ? "\xB7" : ok ? "\u2713" : "\u2717"} ${label.padEnd(10)} ${value}`;
3086
+ return [
3087
+ row(
3088
+ signed && !broken,
3089
+ "signature",
3090
+ signed && !broken ? `valid, ${signer}` : broken ? `broken${signer ? `, ${signer}` : ""}` : "none"
3091
+ ),
3092
+ row(v.is_agent, "agent", v.sender?.address ?? "not declared"),
3093
+ row(v.sender?.on_behalf_of ? true : null, "acts for", v.sender?.on_behalf_of ?? "not said"),
3094
+ row(
3095
+ v.body === "not_checked" ? null : v.body === "intact",
3096
+ "body",
3097
+ v.body === "intact" ? "as it was sent" : v.body === "changed" ? "changed since" : "not checked"
3098
+ ),
3099
+ "",
3100
+ v.advice
3101
+ ].join("\n");
3102
+ }
3103
+ function capitalise(name) {
3104
+ return name.replace(
3105
+ /(^|-)([a-z])/g,
3106
+ (_, dash, letter) => dash + letter.toUpperCase()
3107
+ );
3108
+ }
3109
+ async function readStdin() {
3110
+ const chunks = [];
3111
+ for await (const chunk of process.stdin) chunks.push(chunk);
3112
+ return Buffer.concat(chunks).toString("utf8");
3113
+ }
2957
3114
 
2958
3115
  // src/index.ts
2959
3116
  var DEFAULT_BASE_URL = "https://pidgeon.ai/api/v1";
@@ -3147,4 +3304,4 @@ export {
3147
3304
  find,
3148
3305
  run
3149
3306
  };
3150
- //# sourceMappingURL=chunk-RWZQSI67.js.map
3307
+ //# sourceMappingURL=chunk-F6XZFMGH.js.map