open-action-consent 0.2.1 → 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.
@@ -1,5 +1,4 @@
1
- // Conformance runner: `node src/conformance.ts <vectors dir>`. Runs every case in vectors/*.json and exits non-zero
2
- // if any result differs from the expected one. Same files as the Python runner.
1
+ // Run the shared vectors: node src/conformance.ts vectors
3
2
  import * as fs from "node:fs";
4
3
  import * as path from "node:path";
5
4
  import { OACError } from "./errors.js";
@@ -15,6 +14,7 @@ import { checkUndo } from "./undo.js";
15
14
  import { vectorKey } from "./testing.js";
16
15
  import { bit, decode } from "./status.js";
17
16
  import { keysetFrom, metadataUrl } from "./discovery.js";
17
+ import { claim, MemoryOccurrences } from "./occurrence.js";
18
18
  const OK = { ok: true };
19
19
  const OPS = {
20
20
  jcs: (i) => ({ canonical: canonical(i.value) }),
@@ -27,6 +27,7 @@ const OPS = {
27
27
  verify_consent: (i) => (verifyConsent(i.token, {
28
28
  keys: new KeySet(i.keys), executor: i.executor, sub: i.sub, previews: i.previews, actions: i.actions, profiles: i.profiles,
29
29
  now: i.now, nonces: new MemoryNonces(i.seen_nonces ?? []), policyFloor: i.policy_floor, tainted: i.tainted ?? false,
30
+ device: i.device,
30
31
  }), OK),
31
32
  check_mandate: (i) => (checkMandate(i.token, {
32
33
  keys: new KeySet(i.keys), action: i.action, profile: i.profile, holder: i.holder, sub: i.sub, now: i.now,
@@ -39,6 +40,11 @@ const OPS = {
39
40
  keys: new KeySet(i.keys), executor: i.executor, receiptToken: i.receipt_token, receiptKeys: new KeySet(i.receipt_keys),
40
41
  now: i.now, nonces: new MemoryNonces(i.seen_nonces ?? []), undone: i.undone ?? [], compensation: i.compensation,
41
42
  }), OK),
43
+ claim_occurrence: (i) => {
44
+ const key = ["person", "tool", "occurrence"];
45
+ claim(new MemoryOccurrences(i.state ? [[key, i.state]] : []), key);
46
+ return OK;
47
+ },
42
48
  status_bit: (i) => ({ revoked: bit(decode(i.encoded_list), i.index) }),
43
49
  metadata_url: (i) => ({ url: metadataUrl(i.iss) }),
44
50
  keyset_from: (i) => ({ issuer: keysetFrom(i.docs.map((d) => [d[0], d[1]])).issuer(i.kid) }),
package/dist/consent.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { KeySet, type SigningKey } from "./jws.ts";
2
+ import { type DeviceCheck } from "./webauthn.ts";
2
3
  export declare const TYP = "oac-consent+jws";
3
4
  export declare const MAX_S = 900;
4
5
  export declare const SKEW_S = 60;
@@ -36,4 +37,5 @@ export declare function verifyConsent(token: string, o: {
36
37
  nonces: NonceStore;
37
38
  policyFloor?: string;
38
39
  tainted?: boolean;
40
+ device?: DeviceCheck;
39
41
  }): Record<string, any>;
package/dist/consent.js CHANGED
@@ -1,11 +1,11 @@
1
- // Consent (spec section 6): the person's signed yes to one or more Previews. Checks run in the spec's order; the
2
- // first failure is the one reported. Mirrors consent.py.
1
+ // Consents (spec 6): the person's signed yes to one or more Previews.
3
2
  import * as crypto from "node:crypto";
4
3
  import * as shapes from "./shapes.js";
5
4
  import { OACError } from "./errors.js";
6
5
  import { digest, KeySet, sign, verify } from "./jws.js";
7
6
  import { actionDigest, checkBinding } from "./preview.js";
8
7
  import { tierFor } from "./tiers.js";
8
+ import { verifyDeviceProof } from "./webauthn.js";
9
9
  export const TYP = "oac-consent+jws";
10
10
  export const MAX_S = 900;
11
11
  export const SKEW_S = 60;
@@ -89,6 +89,8 @@ export function verifyConsent(token, o) {
89
89
  });
90
90
  if (pays === 0 && Object.hasOwn(p, "payment"))
91
91
  throw new OACError("payment_mismatch", "payment given but no item pays");
92
+ if (o.device)
93
+ verifyDeviceProof(token, o.device);
92
94
  if (!o.nonces.useOnce(p.jti, p.exp))
93
95
  throw new OACError("replay");
94
96
  return p;
package/dist/discovery.js CHANGED
@@ -1,12 +1,10 @@
1
- // Issuer metadata (spec section 3.5): for issuer https://host/path the document is at
2
- // https://host/.well-known/oac-issuer/path (the RFC 8414 pattern) and its `issuer` must equal the issuer exactly.
3
- // `keysetFrom` turns documents you already have into a KeySet; `fetchMetadata` is a small, strict HTTPS fetcher.
1
+ // Issuer metadata (spec 3.5): where an issuer publishes its public keys.
4
2
  import * as shapes from "./shapes.js";
5
3
  import { OACError } from "./errors.js";
6
4
  import { parse } from "./jcs.js";
7
5
  import { KeySet } from "./jws.js";
8
6
  export const MAX_DOC = 64 * 1024;
9
- // Matched as text, same pattern as discovery.py, so every language agrees (no URL parser normalizing ports or IDNs).
7
+ // matched as text, not with a URL parser, so every language agrees
10
8
  const LABEL = "[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?";
11
9
  const ISSUER = new RegExp(`^https://(${LABEL}(\\.${LABEL})*)(:[0-9]{1,5})?((/(?!\\.\\.?(/|$))[A-Za-z0-9._~-]+)*)/?$`);
12
10
  export function metadataUrl(iss) {
package/dist/errors.js CHANGED
@@ -1,5 +1,4 @@
1
- // Error codes shared by every OAC implementation (spec section 9). A verifier reports exactly one code: the first
2
- // check that fails, in the order the spec lists them.
1
+ // Error codes shared by every OAC implementation.
3
2
  export const CODES = new Set([
4
3
  "malformed", "not_canonical", "bad_type", "bad_alg", "unknown_key", "bad_signature",
5
4
  "wrong_audience", "not_yet_valid", "expired", "lifetime_too_long", "replay",
@@ -9,10 +8,15 @@ export const CODES = new Set([
9
8
  "payee_not_allowed", "site_not_allowed", "currency_mismatch", "over_amount", "over_daily_amount", "over_calls",
10
9
  "chain_broken", "chain_seq", "chain_issuer", "receipt_invalid", "receipt_mismatch", "not_reversible",
11
10
  "undo_mode_mismatch", "undo_window_closed",
12
- // added after the 2026-11-04 review
11
+ // identity, arguments, revocation, undo
13
12
  "wrong_issuer", "undeclared_argument", "revoked", "already_undone", "compensation_mismatch",
14
- // 0.1 gap closers: status lists and seals
13
+ // status lists and seals
15
14
  "status_unknown", "seal_mismatch",
15
+ // device proofs (WebAuthn)
16
+ "device_proof_missing", "device_proof_mismatch", "unknown_credential", "challenge_mismatch", "wrong_origin",
17
+ "rp_mismatch", "user_not_present", "user_not_verified",
18
+ // business occurrences
19
+ "occurrence_unresolved", "duplicate_occurrence",
16
20
  ]);
17
21
  /** A refusal. `code` is one of CODES; `detail` is for logs, never for deciding anything. */
18
22
  export class OACError extends Error {
package/dist/gate.d.ts CHANGED
@@ -1,9 +1,13 @@
1
1
  import { KeySet, type SigningKey } from "./jws.ts";
2
2
  import { type NonceStore } from "./consent.ts";
3
+ import { type OccurrenceStore } from "./occurrence.ts";
3
4
  export declare class NeedsConsent extends Error {
4
5
  readonly preview: Record<string, any>;
5
6
  constructor(preview: Record<string, any>);
6
7
  }
8
+ /** Throw from a tool when the action surely did not happen. Other errors count as unknown. */
9
+ export declare class ActionFailed extends Error {
10
+ }
7
11
  export type ProfileSpec = {
8
12
  effect: string;
9
13
  undo?: string;
@@ -11,17 +15,20 @@ export type ProfileSpec = {
11
15
  tierFloor?: string;
12
16
  binds?: Record<string, string>;
13
17
  otherArgs?: string[];
18
+ occurrence?: string;
14
19
  };
15
20
  export declare class Gate {
16
21
  readonly executor: string;
17
22
  readonly key: SigningKey;
18
23
  readonly signers: KeySet;
19
24
  readonly nonces: NonceStore;
25
+ readonly occurrences: OccurrenceStore;
20
26
  readonly policyFloor: string;
21
27
  readonly profiles: Record<string, Record<string, any>>;
22
28
  readonly pending: Map<string, Record<string, any>>;
23
29
  readonly consents: Map<string, string>;
24
30
  readonly receipts: Map<string, string[]>;
31
+ readonly tokens: Map<string, string>;
25
32
  private readonly clock;
26
33
  private readonly summarize;
27
34
  constructor(o: {
@@ -29,6 +36,7 @@ export declare class Gate {
29
36
  key: SigningKey;
30
37
  signers: KeySet;
31
38
  nonces?: NonceStore;
39
+ occurrences?: OccurrenceStore;
32
40
  policyFloor?: string;
33
41
  summarize?: (a: Record<string, any>) => string;
34
42
  clock?: () => number;
@@ -40,9 +48,15 @@ export declare class Gate {
40
48
  previewById(id: string): Record<string, any> | undefined;
41
49
  acceptConsent(token: string): boolean;
42
50
  private authorize;
51
+ private outcome;
43
52
  run<T>(tool: string, args: Record<string, unknown>, fn: (args: Record<string, unknown>) => T | Promise<T>, o: {
44
53
  person: string;
45
54
  tainted?: boolean;
46
55
  }): Promise<[T, string]>;
56
+ /** Record what the provider says happened to an unknown attempt: ok or failed. */
57
+ resolve(tool: string, occurrence: string, o: {
58
+ person: string;
59
+ outcome: "ok" | "failed";
60
+ }): void;
47
61
  private receipt;
48
62
  }
package/dist/gate.js CHANGED
@@ -1,11 +1,10 @@
1
- // A framework-neutral executor gate (twin of gate.py): tier, Preview, Consent hand-over, verification right before
2
- // the action, run, signed Receipt. A pending Preview is reused for the same person and action until it expires, so
3
- // a framework that re-runs a step after an interruption gets the Preview the person approved.
1
+ // Executor gate shared by the framework adapters. Same behaviour as gate.py.
4
2
  import * as crypto from "node:crypto";
5
3
  import * as shapes from "./shapes.js";
6
4
  import { OACError } from "./errors.js";
7
5
  import { digest, KeySet, tokenDigest, verify } from "./jws.js";
8
6
  import { MemoryNonces, verifyConsent } from "./consent.js";
7
+ import { checkOpen, claim, MemoryOccurrences, occurrenceKey, resolve } from "./occurrence.js";
9
8
  import { actionDigest, buildPreview } from "./preview.js";
10
9
  import { makeReceipt } from "./receipts.js";
11
10
  import { tierFor } from "./tiers.js";
@@ -13,16 +12,21 @@ export class NeedsConsent extends Error {
13
12
  preview;
14
13
  constructor(preview) { super("the person must approve this preview"); this.preview = preview; }
15
14
  }
15
+ /** Throw from a tool when the action surely did not happen. Other errors count as unknown. */
16
+ export class ActionFailed extends Error {
17
+ }
16
18
  export class Gate {
17
19
  executor;
18
20
  key;
19
21
  signers;
20
22
  nonces;
23
+ occurrences;
21
24
  policyFloor;
22
25
  profiles = {};
23
26
  pending = new Map(); // person \0 action digest -> preview
24
27
  consents = new Map();
25
28
  receipts = new Map();
29
+ tokens = new Map(); // authority digest in a receipt -> the exact token
26
30
  clock;
27
31
  summarize;
28
32
  constructor(o) {
@@ -30,6 +34,7 @@ export class Gate {
30
34
  this.key = o.key;
31
35
  this.signers = o.signers;
32
36
  this.nonces = o.nonces ?? new MemoryNonces();
37
+ this.occurrences = o.occurrences ?? new MemoryOccurrences();
33
38
  this.policyFloor = o.policyFloor ?? "A";
34
39
  this.clock = o.clock ?? (() => Math.floor(Date.now() / 1000));
35
40
  this.summarize = o.summarize ?? ((a) => `${a.tool}: ` + Object.keys(a.args).sort().map((k) => `${k}=${JSON.stringify(a.args[k])}`).join(", ").slice(0, 480));
@@ -39,6 +44,8 @@ export class Gate {
39
44
  tier_floor: p.tierFloor ?? "A", binds: { ...(p.binds ?? {}) } };
40
45
  if (p.otherArgs?.length)
41
46
  doc.other_args = [...p.otherArgs];
47
+ if (p.occurrence)
48
+ doc.occurrence = p.occurrence;
42
49
  this.profiles[tool] = shapes.profile(doc);
43
50
  return doc;
44
51
  }
@@ -89,38 +96,61 @@ export class Gate {
89
96
  const tier = this.tier(tool, args, tainted);
90
97
  if (tier === "D")
91
98
  throw new OACError("tier_d", "this action never runs");
99
+ const occurrence = occurrenceKey(person, this.profiles[tool], args);
92
100
  if (tier === "A" || tier === "B")
93
- return [tier, { kind: "tier" }, null];
101
+ return [tier, { kind: "tier" }, null, claim(this.occurrences, occurrence)];
94
102
  const action = { tool, args: { ...args } };
95
103
  const preview = this.preview(tool, args, person);
96
104
  const pd = digest(preview);
97
105
  const token = this.consents.get(pd);
98
- if (!token)
106
+ if (!token) {
107
+ checkOpen(this.occurrences, occurrence);
99
108
  throw new NeedsConsent(preview);
100
- const c = verifyConsent(token, { keys: this.signers, executor: this.executor, sub: person, previews: [preview], actions: [action],
101
- profiles: this.profiles, now: this.clock(), nonces: this.nonces, policyFloor: this.policyFloor, tainted });
109
+ }
110
+ const claimed = claim(this.occurrences, occurrence);
111
+ let c;
112
+ try {
113
+ c = verifyConsent(token, { keys: this.signers, executor: this.executor, sub: person, previews: [preview], actions: [action],
114
+ profiles: this.profiles, now: this.clock(), nonces: this.nonces, policyFloor: this.policyFloor, tainted });
115
+ }
116
+ catch (e) {
117
+ if (claimed)
118
+ this.occurrences.release(claimed);
119
+ throw e;
120
+ }
102
121
  this.pending.delete(person + "\0" + actionDigest(action));
103
- return ["C", { kind: "consent", ref: c.jti }, pd];
122
+ this.tokens.set(tokenDigest(token), token);
123
+ return ["C", { kind: "consent", ref: c.jti, digest: tokenDigest(token) }, pd, claimed];
124
+ }
125
+ outcome(claimed, error) {
126
+ const outcome = error === undefined ? "ok" : claimed === null || error instanceof ActionFailed ? "failed" : "unknown";
127
+ if (claimed)
128
+ this.occurrences.finish(claimed, outcome);
129
+ return outcome;
104
130
  }
105
131
  async run(tool, args, fn, o) {
106
- const [tier, authority, pd] = this.authorize(tool, args, o.person, o.tainted ?? true);
132
+ const [tier, authority, pd, claimed] = this.authorize(tool, args, o.person, o.tainted ?? true);
107
133
  let result;
108
134
  try {
109
135
  result = await fn(args);
110
136
  }
111
137
  catch (e) {
112
- this.receipt(o.person, tool, args, tier, authority, pd, null, true);
138
+ this.receipt(o.person, tool, args, tier, authority, pd, null, this.outcome(claimed, e ?? new Error("thrown")));
113
139
  throw e;
114
140
  }
115
- return [result, this.receipt(o.person, tool, args, tier, authority, pd, result, false)];
141
+ return [result, this.receipt(o.person, tool, args, tier, authority, pd, result, this.outcome(claimed, undefined))];
142
+ }
143
+ /** Record what the provider says happened to an unknown attempt: ok or failed. */
144
+ resolve(tool, occurrence, o) {
145
+ resolve(this.occurrences, [o.person, tool, occurrence], o.outcome);
116
146
  }
117
- receipt(person, tool, args, tier, authority, pd, result, failed) {
147
+ receipt(person, tool, args, tier, authority, pd, result, outcome) {
118
148
  const profile = this.profiles[tool];
119
149
  const chain = this.receipts.get(person) ?? [];
120
150
  this.receipts.set(person, chain);
121
151
  const now = this.clock();
122
152
  const undo = { kind: profile.undo.kind };
123
- if (undo.kind !== "none" && !failed) {
153
+ if (undo.kind !== "none" && outcome === "ok") {
124
154
  undo.until = now + profile.undo.window_s;
125
155
  if (undo.kind === "native")
126
156
  undo.token = "u_" + crypto.randomBytes(12).toString("base64url");
@@ -133,9 +163,11 @@ export class Gate {
133
163
  rd = digest(String(result));
134
164
  }
135
165
  const payload = { iss: this.executor, sub: person, seq: chain.length + 1, prev: chain.length ? tokenDigest(chain[chain.length - 1]) : null,
136
- iat: now, tool, tier, authority, action: actionDigest({ tool, args: { ...args } }), outcome: failed ? "failed" : "ok", result: rd, undo };
166
+ iat: now, tool, tier, authority, action: actionDigest({ tool, args: { ...args } }), outcome, result: rd, undo };
137
167
  if (pd)
138
168
  payload.preview = pd;
169
+ if (Object.hasOwn(profile, "occurrence"))
170
+ payload.occurrence = args[profile.occurrence];
139
171
  const r = makeReceipt(this.key, payload);
140
172
  chain.push(r);
141
173
  return r;
package/dist/index.d.ts CHANGED
@@ -8,5 +8,7 @@ export { makeConsent, MemoryNonces, verifyConsent, type NonceStore } from "./con
8
8
  export { checkMandate, loosens, makeMandate } from "./mandate.ts";
9
9
  export { makeReceipt, verifyChain } from "./receipts.ts";
10
10
  export { checkUndo, makeUndo } from "./undo.ts";
11
- export { Gate, NeedsConsent, type ProfileSpec } from "./gate.ts";
12
- export declare const VERSION = "0.2.1";
11
+ export { ActionFailed, Gate, NeedsConsent, type ProfileSpec } from "./gate.ts";
12
+ export { MemoryOccurrences, type OccurrenceStore } from "./occurrence.ts";
13
+ export { verifyDeviceProof, type DeviceCheck } from "./webauthn.ts";
14
+ export declare const VERSION = "0.3.0";
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- // OAC (Open Action Consent) v0.1 reference SDK for TypeScript. Spec: spec/oac-v0.1.md.
1
+ // Open Action Consent for TypeScript. Spec: spec/oac-v0.1.md
2
2
  export { CODES, OACError } from "./errors.js";
3
3
  export { canonical, parse } from "./jcs.js";
4
4
  export { digest, generate, KeySet, sign, tokenDigest, verify } from "./jws.js";
@@ -9,5 +9,7 @@ export { makeConsent, MemoryNonces, verifyConsent } from "./consent.js";
9
9
  export { checkMandate, loosens, makeMandate } from "./mandate.js";
10
10
  export { makeReceipt, verifyChain } from "./receipts.js";
11
11
  export { checkUndo, makeUndo } from "./undo.js";
12
- export { Gate, NeedsConsent } from "./gate.js";
13
- export const VERSION = "0.2.1";
12
+ export { ActionFailed, Gate, NeedsConsent } from "./gate.js";
13
+ export { MemoryOccurrences } from "./occurrence.js";
14
+ export { verifyDeviceProof } from "./webauthn.js";
15
+ export const VERSION = "0.3.0";
package/dist/jcs.js CHANGED
@@ -1,6 +1,4 @@
1
- // RFC 8785 (JCS) canonical JSON for the OAC subset, plus a strict parser. OAC carries integers only (money in minor
2
- // units, time in unix seconds), so JCS reduces to: keys sorted by UTF-16 code units (JavaScript's default sort),
3
- // no whitespace, strings as JSON.stringify writes them, integers in plain decimal within +-(2^53 - 1).
1
+ // RFC 8785 canonical JSON for OAC's integer-only subset, and a strict parser.
4
2
  import { OACError } from "./errors.js";
5
3
  export const MAX_DEPTH = 64; // arrays and objects nested deeper than this are refused
6
4
  function str(s) {
package/dist/jws.d.ts CHANGED
@@ -23,7 +23,7 @@ export declare function thumbprint(jwk: Jwk): string;
23
23
  export declare function ed25519FromSeed(seed: Uint8Array): SigningKey;
24
24
  export declare function p256FromSeed(seed: Uint8Array): SigningKey;
25
25
  export declare function generate(alg?: string): SigningKey;
26
- /** Public keys a verifier trusts, grouped by issuer: {iss: [jwk, ...]}. A kid maps to one key and one issuer. */
26
+ /** Trusted public keys, grouped by issuer: {iss: [jwk, ...]}. Each key has one issuer. */
27
27
  export declare class KeySet {
28
28
  private keys;
29
29
  constructor(byIssuer: Record<string, Jwk[]>);
package/dist/jws.js CHANGED
@@ -1,7 +1,4 @@
1
- // Keys, digests and the JWS envelope (RFC 7515 compact). Header = exactly {alg, kid, typ}, JCS-canonical; payload =
2
- // JCS-canonical JSON; a token whose bytes are not the canonical form of what they parse to is refused.
3
- // Algorithms: EdDSA (Ed25519, RFC 8037) and ES256 (P-256, raw r||s, low-S only). kid = the RFC 7638 JWK thumbprint.
4
- // Every trusted key belongs to exactly one issuer, and a token's `iss` must be its key's issuer.
1
+ // Keys, digests and the compact JWS envelope used by every signed OAC object.
5
2
  import * as crypto from "node:crypto";
6
3
  import { OACError } from "./errors.js";
7
4
  import { canonical, canonicalBytes, parse } from "./jcs.js";
@@ -66,7 +63,7 @@ export function generate(alg = "EdDSA") {
66
63
  }
67
64
  throw new OACError("bad_alg", alg);
68
65
  }
69
- /** Public keys a verifier trusts, grouped by issuer: {iss: [jwk, ...]}. A kid maps to one key and one issuer. */
66
+ /** Trusted public keys, grouped by issuer: {iss: [jwk, ...]}. Each key has one issuer. */
70
67
  export class KeySet {
71
68
  keys = new Map();
72
69
  constructor(byIssuer) {
package/dist/mandate.d.ts CHANGED
@@ -20,5 +20,5 @@ export declare function checkMandate(token: string, o: {
20
20
  statusLists?: Record<string, string>;
21
21
  policyFloor?: string;
22
22
  }): Record<string, any>;
23
- /** What a replacement mandate allows that the old one did not; non-empty = the signer needs fresh proof (spec 7.4). */
23
+ /** What the new mandate allows that the old one did not (spec 7.4). */
24
24
  export declare function loosens(old: Record<string, any>, nw: Record<string, any>): string[];
package/dist/mandate.js CHANGED
@@ -1,5 +1,4 @@
1
- // Mandates (spec section 7): standing, bounded, expiring permission. Covers tier C only; never tier D; never waives
2
- // the taint floor; a pay tool needs caps and a payee list. Mirrors mandate.py, check for check.
1
+ // Mandates (spec 7): standing, bounded permission that can replace a Consent on tier C.
3
2
  import * as shapes from "./shapes.js";
4
3
  import { OACError } from "./errors.js";
5
4
  import { canonical } from "./jcs.js";
@@ -77,7 +76,7 @@ export function checkMandate(token, o) {
77
76
  throw new OACError("over_calls");
78
77
  return p;
79
78
  }
80
- /** What a replacement mandate allows that the old one did not; non-empty = the signer needs fresh proof (spec 7.4). */
79
+ /** What the new mandate allows that the old one did not (spec 7.4). */
81
80
  export function loosens(old, nw) {
82
81
  shapes.mandate(old);
83
82
  shapes.mandate(nw);
@@ -117,8 +116,7 @@ export function loosens(old, nw) {
117
116
  }
118
117
  }
119
118
  }
120
- // recipients: no list means nobody, so any list where there was none, or a new entry, loosens.
121
- // payees and sites: no list means no limit, so dropping the list, or a new entry, loosens.
119
+ // no recipient list means nobody; no payee or site list means anyone
122
120
  if (nb.recipients !== undefined && (ob.recipients === undefined || newOnes(nb.recipients, ob.recipients)))
123
121
  out.push("recipients");
124
122
  for (const key of ["payees", "sites"]) {
@@ -1,7 +1,9 @@
1
1
  import { type McpServer } from "@modelcontextprotocol/server";
2
2
  import { KeySet, MemoryNonces, type SigningKey } from "../index.ts";
3
+ import { type OccurrenceStore } from "../occurrence.ts";
3
4
  export declare const ID = "com.atlasiw/oac";
4
5
  export declare const PROFILE_KEY = "com.atlasiw/oac-profile", PREVIEW_KEY = "com.atlasiw/oac-preview", RECEIPT_KEY = "com.atlasiw/oac-receipt", CONSENT_KEY = "com.atlasiw/oac-consent";
6
+ export declare const OUTCOME_KEY = "com.atlasiw/oac-outcome";
5
7
  type Ctx = any;
6
8
  type Result = {
7
9
  content: {
@@ -17,11 +19,12 @@ export interface OACOptions {
17
19
  signers: KeySet;
18
20
  consentUrl: (preview: Record<string, any>) => string;
19
21
  person: (ctx: Ctx) => string;
20
- /** From the host runtime, never the model; default: tainted (spec section 4). */
22
+ /** From the host, never the model. Defaults to tainted. */
21
23
  tainted?: (ctx: Ctx) => boolean;
22
24
  summarize?: (action: Record<string, any>) => string;
23
25
  policyFloor?: string;
24
26
  clock?: () => number;
27
+ occurrences?: OccurrenceStore;
25
28
  }
26
29
  export declare class OAC {
27
30
  readonly profiles: Record<string, Record<string, any>>;
@@ -29,6 +32,8 @@ export declare class OAC {
29
32
  readonly consents: Map<string, string>;
30
33
  readonly nonces: MemoryNonces;
31
34
  readonly receipts: Map<string, string[]>;
35
+ readonly tokens: Map<string, string>;
36
+ readonly occurrences: OccurrenceStore;
32
37
  private readonly stateKey;
33
38
  readonly o: OACOptions;
34
39
  constructor(o: OACOptions);
@@ -42,15 +47,23 @@ export declare class OAC {
42
47
  tierFloor?: string;
43
48
  binds?: Record<string, string>;
44
49
  otherArgs?: string[];
50
+ occurrence?: string;
45
51
  }): Record<string, unknown>;
46
52
  /** Register a tool on an McpServer with its OAC profile; every call goes through `guard`. */
47
53
  registerTool(server: McpServer, name: string, config: Record<string, any>, profile: Parameters<OAC["profile"]>[1], handler: (args: any, ctx: Ctx) => Promise<Result>): void;
48
54
  preview(id: string): Record<string, any> | undefined;
49
- /** The signer page hands over a Consent: kept only if valid for this executor over a pending preview, never replaced. */
55
+ /** Keep a valid Consent for a pending preview. Never replaces one already kept. */
50
56
  acceptConsent(token: string): boolean;
51
57
  private seal;
52
58
  private unseal;
53
59
  guard(name: string, args: Record<string, unknown>, ctx: Ctx, handler: (args: any, ctx: Ctx) => Promise<Result>, clientCaps?: Record<string, any> | undefined): Promise<any>;
60
+ private run;
61
+ private finish;
62
+ /** Record what the provider says happened to an unknown attempt: ok or failed. */
63
+ resolve(tool: string, occurrence: string, o: {
64
+ person: string;
65
+ outcome: "ok" | "failed";
66
+ }): void;
54
67
  private ask;
55
68
  private withReceipt;
56
69
  }
package/dist/mcp/index.js CHANGED
@@ -1,13 +1,11 @@
1
- // OAC for the official MCP TypeScript SDK v2 (@modelcontextprotocol/server 2.4+): import from "open-action-consent/mcp". The SDK has no tool-call
2
- // interceptor, so OAC wraps each tool: `oac.registerTool(server, name, config, profile, handler)`.
3
- // Tier A/B run (B with a receipt and undo token); tier C answers input_required with a URL-mode elicitation and
4
- // runs only with a valid signed Consent; tier D, unprofiled tools and undeclared arguments never run. Unlike the
5
- // Python SDK, v2 does not integrity-protect requestState, so OAC signs its own (HMAC-SHA256, short expiry).
1
+ // OAC for the MCP TypeScript SDK v2. The SDK has no interceptor, so this wraps each tool. See guides/mcp-typescript.md.
6
2
  import * as crypto from "node:crypto";
7
3
  import { inputRequired, inputResponse } from "@modelcontextprotocol/server";
8
4
  import { actionDigest, buildPreview, digest, KeySet, makeReceipt, MemoryNonces, OACError, shapes, tierFor, tokenDigest, verify, verifyConsent } from "../index.js";
5
+ import { checkOpen, claim, MemoryOccurrences, occurrenceKey, resolve } from "../occurrence.js";
9
6
  export const ID = "com.atlasiw/oac";
10
7
  export const PROFILE_KEY = `${ID}-profile`, PREVIEW_KEY = `${ID}-preview`, RECEIPT_KEY = `${ID}-receipt`, CONSENT_KEY = `${ID}-consent`;
8
+ export const OUTCOME_KEY = `${ID}-outcome`;
11
9
  const ASK = "oac_consent";
12
10
  const STATE_TTL_S = 900;
13
11
  const refused = (text) => ({ content: [{ type: "text", text }], isError: true });
@@ -17,9 +15,11 @@ export class OAC {
17
15
  consents = new Map();
18
16
  nonces = new MemoryNonces();
19
17
  receipts = new Map();
18
+ tokens = new Map(); // authority digest in a receipt -> the exact token
19
+ occurrences;
20
20
  stateKey = crypto.randomBytes(32);
21
21
  o;
22
- constructor(o) { this.o = o; }
22
+ constructor(o) { this.o = o; this.occurrences = o.occurrences ?? new MemoryOccurrences(); }
23
23
  now() { return (this.o.clock ?? (() => Math.floor(Date.now() / 1000)))(); }
24
24
  /** Capability settings to put under `capabilities.extensions[ID]`. */
25
25
  settings() { return { version: "0.1", executor: this.o.executor }; }
@@ -28,6 +28,8 @@ export class OAC {
28
28
  tier_floor: p.tierFloor ?? "A", binds: { ...(p.binds ?? {}) } };
29
29
  if (p.otherArgs?.length)
30
30
  doc.other_args = [...p.otherArgs];
31
+ if (p.occurrence)
32
+ doc.occurrence = p.occurrence;
31
33
  this.profiles[tool] = shapes.profile(doc);
32
34
  return { [PROFILE_KEY]: doc };
33
35
  }
@@ -38,7 +40,7 @@ export class OAC {
38
40
  server.registerTool(name, { ...config, _meta: { ...(config._meta ?? {}), ...meta } }, (args, ctx) => this.guard(name, args ?? {}, ctx, handler, caps(ctx)));
39
41
  }
40
42
  preview(id) { return [...this.previews.values()].find((p) => p.id === id); }
41
- /** The signer page hands over a Consent: kept only if valid for this executor over a pending preview, never replaced. */
43
+ /** Keep a valid Consent for a pending preview. Never replaces one already kept. */
42
44
  acceptConsent(token) {
43
45
  let p;
44
46
  try {
@@ -89,16 +91,23 @@ export class OAC {
89
91
  }
90
92
  if (tier === "D")
91
93
  return refused("Refused: this action is never allowed (tier D).");
92
- if (tier === "A" || tier === "B")
93
- return this.withReceipt(await handler(args, ctx), person, profile, action, tier, { kind: "tier" }, null);
94
+ if (tier === "A" || tier === "B") {
95
+ let claimed;
96
+ try {
97
+ claimed = claim(this.occurrences, occurrenceKey(person, profile, args));
98
+ }
99
+ catch (e) {
100
+ return refused(`Refused (${e instanceof OACError ? e.code : "error"}). Nothing ran.`);
101
+ }
102
+ return this.run(handler, args, ctx, person, profile, action, tier, { kind: "tier" }, null, claimed);
103
+ }
94
104
  const missing = {};
95
105
  if (!clientCaps?.extensions?.[ID])
96
106
  missing.extensions = { [ID]: {} };
97
107
  if (!clientCaps?.elicitation?.url)
98
108
  missing.elicitation = { url: {} };
99
109
  if (Object.keys(missing).length) {
100
- // spec mcp-extension 6: never run without a consent page. The v2 SDK turns errors thrown in a tool into tool
101
- // results, so the -32021 data travels in the refusal instead of as a protocol error.
110
+ // the v2 SDK turns thrown errors into tool results, so -32021 travels in the refusal
102
111
  return { ...refused("Refused (-32021): this client cannot show an approval page (needs the OAC extension and URL elicitation). Nothing ran."),
103
112
  _meta: { [`${ID}-missing`]: missing } };
104
113
  }
@@ -115,16 +124,52 @@ export class OAC {
115
124
  if (!preview || !token)
116
125
  return this.ask(action, profile, person);
117
126
  let consent;
127
+ let claimed = null;
118
128
  try {
129
+ claimed = claim(this.occurrences, occurrenceKey(person, profile, args));
119
130
  consent = verifyConsent(token, { keys: this.o.signers, executor: this.o.executor, sub: person, previews: [preview], actions: [action],
120
131
  profiles: this.profiles, now: this.now(), nonces: this.nonces, policyFloor: this.o.policyFloor, tainted });
121
132
  }
122
133
  catch (e) {
134
+ if (claimed)
135
+ this.occurrences.release(claimed);
123
136
  return refused(`Refused (${e instanceof OACError ? e.code : "error"}). Nothing ran.`);
124
137
  }
125
- return this.withReceipt(await handler(args, ctx), person, profile, action, "C", { kind: "consent", ref: consent.jti }, digest(preview));
138
+ this.tokens.set(tokenDigest(token), token);
139
+ const authority = { kind: "consent", ref: consent.jti, digest: tokenDigest(token) };
140
+ return this.run(handler, args, ctx, person, profile, action, "C", authority, digest(preview), claimed);
141
+ }
142
+ // a timed-out payment may still have gone through, so an error is "unknown" unless the tool says "failed"
143
+ async run(handler, args, ctx, person, profile, action, tier, authority, previewDigest, claimed) {
144
+ let result;
145
+ try {
146
+ result = await handler(args, ctx);
147
+ }
148
+ catch (e) {
149
+ this.withReceipt(null, person, profile, action, tier, authority, previewDigest, this.finish(claimed, claimed ? "unknown" : "failed"));
150
+ throw e;
151
+ }
152
+ let outcome = "ok";
153
+ if (result?.isError === true)
154
+ outcome = claimed && result._meta?.[OUTCOME_KEY] !== "failed" ? "unknown" : "failed";
155
+ return this.withReceipt(result, person, profile, action, tier, authority, previewDigest, this.finish(claimed, outcome));
156
+ }
157
+ finish(claimed, outcome) {
158
+ if (claimed)
159
+ this.occurrences.finish(claimed, outcome);
160
+ return outcome;
161
+ }
162
+ /** Record what the provider says happened to an unknown attempt: ok or failed. */
163
+ resolve(tool, occurrence, o) {
164
+ resolve(this.occurrences, [o.person, tool, occurrence], o.outcome);
126
165
  }
127
166
  ask(action, profile, person) {
167
+ try {
168
+ checkOpen(this.occurrences, occurrenceKey(person, profile, action.args));
169
+ }
170
+ catch (e) {
171
+ return refused(`Refused (${e instanceof OACError ? e.code : "error"}). Nothing ran.`);
172
+ }
128
173
  const summarize = this.o.summarize ?? ((a) => `${a.tool} with ${JSON.stringify(a.args)}`.slice(0, 500));
129
174
  const p = buildPreview({ profile, action, executor: this.o.executor, sub: person, summary: summarize(action), now: this.now() });
130
175
  const d = digest(p);
@@ -135,24 +180,27 @@ export class OAC {
135
180
  });
136
181
  return { ...result, _meta: { ...(result._meta ?? {}), [PREVIEW_KEY]: p } };
137
182
  }
138
- withReceipt(result, person, profile, action, tier, authority, previewDigest) {
139
- const failed = result?.isError === true;
183
+ withReceipt(result, person, profile, action, tier, authority, previewDigest, outcome) {
140
184
  const chain = this.receipts.get(person) ?? [];
141
185
  this.receipts.set(person, chain);
142
186
  const now = this.now();
143
187
  const undo = { kind: profile.undo.kind };
144
- if (undo.kind !== "none" && !failed) {
188
+ if (undo.kind !== "none" && outcome === "ok") {
145
189
  undo.until = now + profile.undo.window_s;
146
190
  if (undo.kind === "native")
147
191
  undo.token = "u_" + crypto.randomBytes(12).toString("base64url");
148
192
  }
149
193
  const payload = { iss: this.o.executor, sub: person, seq: chain.length + 1, prev: chain.length ? tokenDigest(chain[chain.length - 1]) : null,
150
- iat: now, tool: action.tool, tier, authority, action: actionDigest(action), outcome: failed ? "failed" : "ok", undo,
151
- result: digest(JSON.parse(JSON.stringify(result.content ?? []))) };
194
+ iat: now, tool: action.tool, tier, authority, action: actionDigest(action), outcome, undo,
195
+ result: digest(JSON.parse(JSON.stringify(result?.content ?? []))) };
152
196
  if (previewDigest)
153
197
  payload.preview = previewDigest;
198
+ if (Object.hasOwn(profile, "occurrence"))
199
+ payload.occurrence = action.args[profile.occurrence];
154
200
  const receipt = makeReceipt(this.o.key, payload);
155
201
  chain.push(receipt);
202
+ if (result === null)
203
+ return null;
156
204
  return { ...result, _meta: { ...(result._meta ?? {}), [RECEIPT_KEY]: receipt } };
157
205
  }
158
206
  }
@@ -0,0 +1,24 @@
1
+ export type OccurrenceKey = [person: string, tool: string, occurrence: string];
2
+ export interface OccurrenceStore {
3
+ /** Mark the key running and return null, or return its blocking state. Must be atomic. */
4
+ claim(key: OccurrenceKey): string | null;
5
+ finish(key: OccurrenceKey, outcome: string): void;
6
+ /** Drop a claim whose action never started. */
7
+ release(key: OccurrenceKey): void;
8
+ state(key: OccurrenceKey): string | null;
9
+ }
10
+ /** For tests and examples. A real store keeps these states in a table with a unique key. */
11
+ export declare class MemoryOccurrences implements OccurrenceStore {
12
+ private s;
13
+ constructor(states?: Iterable<[OccurrenceKey, string]>);
14
+ claim(key: OccurrenceKey): string | null;
15
+ finish(key: OccurrenceKey, outcome: string): void;
16
+ release(key: OccurrenceKey): void;
17
+ state(key: OccurrenceKey): string | null;
18
+ }
19
+ export declare function occurrenceKey(person: string, profile: Record<string, any>, args: Record<string, unknown>): OccurrenceKey | null;
20
+ /** Refuse early, before the person is asked, when the occurrence cannot run now. */
21
+ export declare function checkOpen(store: OccurrenceStore, key: OccurrenceKey | null): void;
22
+ export declare function claim(store: OccurrenceStore, key: OccurrenceKey | null): OccurrenceKey | null;
23
+ /** Record what the provider says happened. A "running" record left by a crash counts as unknown. */
24
+ export declare function resolve(store: OccurrenceStore, key: OccurrenceKey, outcome: string): void;
@@ -0,0 +1,46 @@
1
+ // Business occurrences (spec 5.1): one order or payment runs at most once.
2
+ import { OACError } from "./errors.js";
3
+ /** For tests and examples. A real store keeps these states in a table with a unique key. */
4
+ export class MemoryOccurrences {
5
+ s = new Map();
6
+ constructor(states = []) { for (const [k, v] of states)
7
+ this.s.set(JSON.stringify(k), v); }
8
+ claim(key) {
9
+ const now = this.s.get(JSON.stringify(key));
10
+ if (now === "running" || now === "unknown" || now === "ok")
11
+ return now;
12
+ this.s.set(JSON.stringify(key), "running");
13
+ return null;
14
+ }
15
+ finish(key, outcome) { this.s.set(JSON.stringify(key), outcome); }
16
+ release(key) { this.s.delete(JSON.stringify(key)); }
17
+ state(key) { return this.s.get(JSON.stringify(key)) ?? null; }
18
+ }
19
+ export function occurrenceKey(person, profile, args) {
20
+ return Object.hasOwn(profile, "occurrence") ? [person, profile.tool, args[profile.occurrence]] : null;
21
+ }
22
+ function refuse(state) {
23
+ if (state === "ok")
24
+ throw new OACError("duplicate_occurrence", "this occurrence already ran");
25
+ if (state === "running" || state === "unknown")
26
+ throw new OACError("occurrence_unresolved", "an earlier attempt has no known outcome yet");
27
+ }
28
+ /** Refuse early, before the person is asked, when the occurrence cannot run now. */
29
+ export function checkOpen(store, key) {
30
+ if (key !== null)
31
+ refuse(store.state(key));
32
+ }
33
+ export function claim(store, key) {
34
+ if (key === null)
35
+ return null;
36
+ refuse(store.claim(key));
37
+ return key;
38
+ }
39
+ /** Record what the provider says happened. A "running" record left by a crash counts as unknown. */
40
+ export function resolve(store, key, outcome) {
41
+ const state = store.state(key);
42
+ if ((outcome !== "ok" && outcome !== "failed") || (state !== "running" && state !== "unknown")) {
43
+ throw new Error("only an attempt with no known outcome can be resolved, to ok or failed");
44
+ }
45
+ store.finish(key, outcome);
46
+ }
package/dist/preview.js CHANGED
@@ -1,5 +1,4 @@
1
- // Actions and Previews (spec sections 3 and 5). Every structured Preview field is bound to one argument by the tool's
2
- // Action Profile, so what the person reads is provably what will run.
1
+ // Actions and Previews (spec 5 and 6.1).
3
2
  import * as crypto from "node:crypto";
4
3
  import * as shapes from "./shapes.js";
5
4
  import { OACError } from "./errors.js";
package/dist/receipts.js CHANGED
@@ -1,5 +1,4 @@
1
- // Receipts (spec section 8): the executor's signed record of each action, chained per person. prev = digest of the
2
- // previous receipt's whole token, so a dropped, reordered or edited receipt breaks the chain. Mirrors receipts.py.
1
+ // Receipts (spec 8): the executor's signed record of each action, chained per person.
3
2
  import * as shapes from "./shapes.js";
4
3
  import { OACError } from "./errors.js";
5
4
  import { KeySet, sign, tokenDigest, verify } from "./jws.js";
@@ -19,8 +18,11 @@ export function checkSemantics(r) {
19
18
  const tier = r.tier, kind = r.authority.kind, undo = r.undo;
20
19
  if (tier === "D")
21
20
  throw new OACError("receipt_invalid", "a tier D action never runs");
22
- if ((kind !== "tier") !== Object.hasOwn(r.authority, "ref"))
23
- throw new OACError("receipt_invalid", "consent, mandate and undo authority carry a ref; tier carries none");
21
+ for (const name of ["ref", "digest"]) {
22
+ if ((kind !== "tier") !== Object.hasOwn(r.authority, name)) {
23
+ throw new OACError("receipt_invalid", `consent, mandate and undo authority carry a ${name}; tier carries none`);
24
+ }
25
+ }
24
26
  if (kind === "tier" && tier !== "A" && tier !== "B")
25
27
  throw new OACError("receipt_invalid", "tier C needs a consent, mandate or undo authority");
26
28
  if (undo.kind === "none" && ("until" in undo || "token" in undo))
@@ -35,6 +37,7 @@ export function checkSemantics(r) {
35
37
  }
36
38
  export function verifyChain(tokens, o) {
37
39
  const out = [];
40
+ const done = new Set();
38
41
  tokens.forEach((token, i) => {
39
42
  const r = verify(token, o.keys, TYP);
40
43
  shapes.receipt(r);
@@ -53,6 +56,12 @@ export function verifyChain(tokens, o) {
53
56
  if (r.prev !== tokenDigest(tokens[i - 1]))
54
57
  throw new OACError("chain_broken");
55
58
  }
59
+ if (Object.hasOwn(r, "occurrence") && r.outcome === "ok" && r.authority.kind !== "undo") {
60
+ const key = JSON.stringify([r.tool, r.occurrence]);
61
+ if (done.has(key))
62
+ throw new OACError("duplicate_occurrence", "the same occurrence ran twice");
63
+ done.add(key);
64
+ }
56
65
  out.push(r);
57
66
  });
58
67
  if (o.seals && o.seals.length) {
package/dist/shapes.d.ts CHANGED
@@ -8,7 +8,7 @@ export declare function check(v: unknown, fields: Fields, what: string): void;
8
8
  export declare const PREVIEW_FIELDS: Record<string, Check>;
9
9
  export declare const REQUIRED_BINDS: Record<string, string[]>;
10
10
  export declare function profile(v: any): Record<string, any>;
11
- /** Every argument is declared (bound or other_args); bound values are valid fields; pay/send carry their fields. */
11
+ /** Every argument is declared and every bound value is a valid field. */
12
12
  export declare function checkAction(actionDoc: Record<string, any>, prof: Record<string, any>): void;
13
13
  export declare function action(v: any): Record<string, any>;
14
14
  export declare function preview(v: any): Record<string, any>;
@@ -21,4 +21,5 @@ export declare function receipt(v: any): Record<string, any>;
21
21
  /** What a mandate already did, from the executor's own counters (spec 7.3). */
22
22
  export declare function usage(v: any): Record<string, number>;
23
23
  export declare function undoRequest(v: any): Record<string, any>;
24
+ export declare function deviceProof(v: any): Record<string, any>;
24
25
  export {};
package/dist/shapes.js CHANGED
@@ -1,5 +1,4 @@
1
- // Field-level checks for every OAC object: the same rules as schemas/*.schema.json plus the cross-field rules a JSON
2
- // Schema cannot say (marked "rule"). Every failure is `malformed`. Unknown fields are refused. Mirrors shapes.py.
1
+ // Field checks for every OAC object. Same rules as schemas/, plus the cross-field ones.
3
2
  import { OACError } from "./errors.js";
4
3
  import { canonical } from "./jcs.js";
5
4
  export const VERSION = "0.1";
@@ -10,6 +9,7 @@ const RAILS = ["stripe_link", "ap2", "vi", "other"];
10
9
  const HOLDERS = ["agent", "client", "run"];
11
10
  const SIGNERS = ["custodial", "device"];
12
11
  const AUTHORITIES = ["consent", "mandate", "tier", "undo"];
12
+ const OUTCOMES = ["ok", "failed", "unknown"];
13
13
  export const PREVIEW_MAX_S = 900;
14
14
  const YEAR_S = 366 * 86400;
15
15
  const isObj = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
@@ -46,15 +46,16 @@ const DIGEST = re(/^sha-256:[A-Za-z0-9_-]{43}$/);
46
46
  const NONCE = re(/^[A-Za-z0-9_-]{16,128}$/);
47
47
  const CURRENCY = re(/^[A-Z]{3}$/);
48
48
  const HOST = re(/^[a-z0-9]([a-z0-9.-]{0,251}[a-z0-9])?$/);
49
- // one bare ASCII address; a recipient rule is an address or "@domain" (same classes as shapes.py)
49
+ // one bare ASCII address, no display name or list; a recipient rule may also be "@domain"
50
50
  const LOCAL = "[A-Za-z0-9!#$%&'*+/=?^_`{|}~.-]{1,64}";
51
51
  const DOMAIN = "[A-Za-z0-9]([A-Za-z0-9.-]{0,188}[A-Za-z0-9])?";
52
52
  const EMAIL = re(new RegExp(`^${LOCAL}@${DOMAIN}$`));
53
53
  const ADDR = re(new RegExp(`^(${LOCAL})?@${DOMAIN}$`));
54
- // characters that let displayed text lie (controls, bidi overrides, zero-width): refused in what a person reads
54
+ // controls, bidi overrides and zero-width characters can make shown text lie
55
55
  const UNSAFE = /[\x00-\x1f\x7f-\x9f\u200b-\u200f\u2028-\u202e\u2060-\u2064\u2066-\u2069\ufeff]/;
56
56
  const text = (lo, hi) => (v) => strCp(lo, hi)(v) && !UNSAFE.test(v);
57
57
  const LAST4 = re(/^[0-9]{4}$/);
58
+ const OCCURRENCE = re(/^[A-Za-z0-9_.:-]{1,128}$/);
58
59
  const TIME = int(0);
59
60
  const UNDO = { kind: [true, oneOf(...UNDO_KINDS)], window_s: [true, int(0, YEAR_S)], note: [false, strCp(1, 200)] };
60
61
  export const PREVIEW_FIELDS = {
@@ -67,7 +68,6 @@ export const PREVIEW_FIELDS = {
67
68
  site: HOST,
68
69
  count: int(0),
69
70
  };
70
- // rule: a pay tool must show amount, currency and payee; a send tool must show who receives it
71
71
  export const REQUIRED_BINDS = { pay: ["amount_minor", "currency", "payee"], send: ["to"] };
72
72
  const has = (o, k) => Object.hasOwn(o, k);
73
73
  function undoRule(u, what) {
@@ -81,7 +81,7 @@ export function profile(v) {
81
81
  check(v, {
82
82
  oac: [true, constant(VERSION)], type: [true, constant("action-profile")], tool: [true, TOOL],
83
83
  effect: [true, oneOf(...EFFECTS)], undo: [true, obj(UNDO, "undo")], tier_floor: [true, oneOf(...TIERS)],
84
- binds: [false, binds], other_args: [false, others],
84
+ binds: [false, binds], other_args: [false, others], occurrence: [false, ARG],
85
85
  }, "action profile");
86
86
  undoRule(v.undo, "action profile");
87
87
  if (v.effect === "read" && v.undo.kind !== "none")
@@ -94,9 +94,14 @@ export function profile(v) {
94
94
  const missing = (REQUIRED_BINDS[v.effect] ?? []).filter((f) => !has(v.binds ?? {}, f));
95
95
  if (missing.length)
96
96
  throw new OACError("malformed", `action profile: a ${v.effect} tool must bind ${missing[0]}`);
97
+ if (v.effect === "pay" && !has(v, "occurrence"))
98
+ throw new OACError("malformed", "action profile: a pay tool names its occurrence argument");
99
+ if (has(v, "occurrence") && !bound.includes(v.occurrence) && !other.includes(v.occurrence)) {
100
+ throw new OACError("malformed", "action profile: the occurrence argument is not declared");
101
+ }
97
102
  return v;
98
103
  }
99
- /** Every argument is declared (bound or other_args); bound values are valid fields; pay/send carry their fields. */
104
+ /** Every argument is declared and every bound value is a valid field. */
100
105
  export function checkAction(actionDoc, prof) {
101
106
  const binds = prof.binds ?? {}, args = actionDoc.args;
102
107
  const declared = new Set([...Object.values(binds), ...(prof.other_args ?? [])]);
@@ -111,6 +116,9 @@ export function checkAction(actionDoc, prof) {
111
116
  if (!has(args, binds[field]))
112
117
  throw new OACError("malformed", `a ${prof.effect} action needs ${binds[field]}`);
113
118
  }
119
+ if (has(prof, "occurrence") && !OCCURRENCE(args[prof.occurrence])) {
120
+ throw new OACError("malformed", `argument ${prof.occurrence} must name the occurrence`);
121
+ }
114
122
  }
115
123
  export function action(v) {
116
124
  check(v, { tool: [true, TOOL], args: [true, isObj] }, "action");
@@ -189,15 +197,15 @@ export function seal(v) {
189
197
  return v;
190
198
  }
191
199
  export function receipt(v) {
192
- const authority = { kind: [true, oneOf(...AUTHORITIES)], ref: [false, strCp(1, 256)] };
200
+ const authority = { kind: [true, oneOf(...AUTHORITIES)], ref: [false, strCp(1, 256)], digest: [false, DIGEST] };
193
201
  const undo = { kind: [true, oneOf(...UNDO_KINDS)], until: [false, TIME], token: [false, strCp(1, 512)] };
194
202
  const payment = { rail: [true, oneOf(...RAILS)], ref: [true, strCp(1, 256)], last4: [false, LAST4], order: [false, strCp(1, 256)] };
195
203
  check(v, {
196
204
  oac: [true, constant(VERSION)], type: [true, constant("receipt")], iss: [true, ID], sub: [true, ID], seq: [true, int(1)],
197
205
  prev: [true, (p) => p === null || DIGEST(p)], iat: [true, TIME], tool: [true, TOOL], tier: [true, oneOf(...TIERS)],
198
206
  authority: [true, obj(authority, "authority")], action: [true, DIGEST], preview: [false, DIGEST],
199
- outcome: [true, oneOf("ok", "failed")], result: [true, DIGEST], undo: [true, obj(undo, "undo")],
200
- undoes: [false, DIGEST], payment: [false, obj(payment, "payment")],
207
+ outcome: [true, oneOf(...OUTCOMES)], result: [true, DIGEST], undo: [true, obj(undo, "undo")],
208
+ undoes: [false, DIGEST], payment: [false, obj(payment, "payment")], occurrence: [false, OCCURRENCE],
201
209
  }, "receipt");
202
210
  return v;
203
211
  }
@@ -216,3 +224,12 @@ export function undoRequest(v) {
216
224
  }, "undo request");
217
225
  return v;
218
226
  }
227
+ export function deviceProof(v) {
228
+ const b64 = re(/^[A-Za-z0-9_-]{1,8192}$/);
229
+ check(v, {
230
+ oac: [true, constant(VERSION)], type: [true, constant("device-proof")], consent: [true, DIGEST],
231
+ credential: [true, re(/^[A-Za-z0-9_-]{1,1366}$/)], authenticator_data: [true, b64], client_data_json: [true, b64],
232
+ signature: [true, b64],
233
+ }, "device proof");
234
+ return v;
235
+ }
package/dist/status.js CHANGED
@@ -1,6 +1,4 @@
1
- // Status lists (spec section 7.5): which Mandates a signer revoked. W3C Bitstring Status List encoding (GZIP, then
2
- // multibase base64url "u"; index 0 = left-most bit; at least 16 KB), in a signed OAC object (typ oac-status+jws).
3
- // Exactly one gzip member and nothing after it: read by hand, because Node's gunzip joins members and skips zeros.
1
+ // Status lists (spec 7.5): which Mandates a signer revoked, as a W3C bitstring.
4
2
  import * as zlib from "node:zlib";
5
3
  import * as shapes from "./shapes.js";
6
4
  import { OACError } from "./errors.js";
package/dist/testing.js CHANGED
@@ -1,5 +1,4 @@
1
- // Deterministic keys for test vectors and examples ONLY: each seed is sha-256 of a public label. Never use them for
2
- // anything real.
1
+ // Fixed keys for tests and vectors. The seeds are public: never use them for anything real.
3
2
  import * as crypto from "node:crypto";
4
3
  import { ed25519FromSeed, p256FromSeed } from "./jws.js";
5
4
  export function vectorKey(label, alg = "EdDSA") {
package/dist/tiers.js CHANGED
@@ -1,5 +1,4 @@
1
- // Approval tiers (spec section 4): A runs, B runs with a receipt and an Undo, C needs a Consent or a Mandate, D never
2
- // runs. tier = max(implied by the effect, profile floor, executor policy, taint floor, any hint); a hint can only raise.
1
+ // Approval tiers (spec 4). A hint can raise the tier, never lower it.
3
2
  import * as shapes from "./shapes.js";
4
3
  import { OACError } from "./errors.js";
5
4
  const ORDER = "ABCD";
package/dist/undo.js CHANGED
@@ -1,5 +1,4 @@
1
- // Undo requests (spec section 8.3): native restores prior state inside the window; compensate runs a new action that
2
- // needs its own Consent, named here; none cannot be undone. Mirrors undo.py.
1
+ // Undo requests (spec 8.3).
3
2
  import * as shapes from "./shapes.js";
4
3
  import { OACError } from "./errors.js";
5
4
  import { KeySet, sign, tokenDigest, verify } from "./jws.js";
@@ -1,12 +1,8 @@
1
- // OAC for the Vercel AI SDK (ai 7): import from "open-action-consent/vercel-ai". The SDK can pause a tool call for approval (`toolApproval` -> a
2
- // `tool-approval-request`) and resume when a `tool-approval-response` with `approved: true` is sent back. That
3
- // approval is a boolean (optionally HMAC-signed by the server). `withOAC` adds OAC: tier C (computed, not the
4
- // model's say) asks for approval with the Preview's summary as the reason, and the tool's execute runs only if the
5
- // person's signed Consent verifies for exactly this call; an approval without it runs nothing.
1
+ // OAC for the Vercel AI SDK: an approval runs nothing without a signed Consent. See guides/vercel-ai.md.
6
2
  import { Gate, NeedsConsent, OACError } from "../index.js";
7
3
  export const NOT_APPROVED = "Not approved: the person has not signed a consent for exactly this action. Nothing ran.";
8
4
  export function withOAC(gate, tools, profiles, o) {
9
- const tainted = o.tainted ?? (() => true); // spec section 4: unknown = tainted
5
+ const tainted = o.tainted ?? (() => true); // unknown means tainted
10
6
  const wrapped = {};
11
7
  for (const [name, t] of Object.entries(tools)) {
12
8
  gate.profile(name, profiles[name]);
@@ -0,0 +1,12 @@
1
+ import { type Jwk } from "./jws.ts";
2
+ export declare const USER_PRESENT = 1, USER_VERIFIED = 4;
3
+ /** What an executor that requires a passkey knows about the person and itself. */
4
+ export type DeviceCheck = {
5
+ proof: Record<string, any> | null;
6
+ credentials: Record<string, Jwk>;
7
+ rp_id: string;
8
+ origins: string[];
9
+ };
10
+ /** The WebAuthn challenge for a Consent: SHA-256 of the token's ASCII bytes, base64url. */
11
+ export declare const challenge: (consentToken: string) => string;
12
+ export declare function verifyDeviceProof(consentToken: string, check: DeviceCheck): void;
@@ -0,0 +1,55 @@
1
+ // Device proofs (spec 3.6): a WebAuthn assertion from the person's passkey over one exact Consent token.
2
+ import * as crypto from "node:crypto";
3
+ import * as shapes from "./shapes.js";
4
+ import { OACError } from "./errors.js";
5
+ import { parse } from "./jcs.js";
6
+ import { b64url, b64urlDecode, tokenDigest } from "./jws.js";
7
+ export const USER_PRESENT = 0x01, USER_VERIFIED = 0x04;
8
+ /** The WebAuthn challenge for a Consent: SHA-256 of the token's ASCII bytes, base64url. */
9
+ export const challenge = (consentToken) => b64url(crypto.createHash("sha256").update(consentToken, "ascii").digest());
10
+ function verifyEs256Der(jwk, data, sig) {
11
+ if (jwk.kty !== "EC" || jwk.crv !== "P-256")
12
+ throw new OACError("bad_alg", "a credential key is P-256");
13
+ let ok = false;
14
+ try {
15
+ const pub = crypto.createPublicKey({ key: { kty: "EC", crv: "P-256", x: jwk.x, y: jwk.y }, format: "jwk" });
16
+ ok = crypto.verify("sha256", data, { key: pub, dsaEncoding: "der" }, sig);
17
+ }
18
+ catch {
19
+ ok = false;
20
+ }
21
+ if (!ok)
22
+ throw new OACError("bad_signature");
23
+ }
24
+ export function verifyDeviceProof(consentToken, check) {
25
+ const proof = check.proof;
26
+ if (proof === null || proof === undefined)
27
+ throw new OACError("device_proof_missing");
28
+ shapes.deviceProof(proof);
29
+ if (proof.consent !== tokenDigest(consentToken))
30
+ throw new OACError("device_proof_mismatch", "the proof is for another consent");
31
+ const jwk = Object.hasOwn(check.credentials, proof.credential) ? check.credentials[proof.credential] : undefined;
32
+ if (jwk === undefined)
33
+ throw new OACError("unknown_credential");
34
+ const auth = b64urlDecode(proof.authenticator_data), client = b64urlDecode(proof.client_data_json);
35
+ if (auth.length < 37)
36
+ throw new OACError("malformed", "authenticator data is too short");
37
+ const clientHash = crypto.createHash("sha256").update(client).digest();
38
+ verifyEs256Der(jwk, Buffer.concat([auth, clientHash]), b64urlDecode(proof.signature));
39
+ const data = parse(client);
40
+ if (data === null || typeof data !== "object" || Array.isArray(data) || data.type !== "webauthn.get") {
41
+ throw new OACError("malformed", "client data is not an assertion");
42
+ }
43
+ if (data.challenge !== challenge(consentToken))
44
+ throw new OACError("challenge_mismatch");
45
+ const origins = typeof check.origins === "string" ? [check.origins] : [...check.origins];
46
+ if (!origins.includes(data.origin) || data.crossOrigin === true)
47
+ throw new OACError("wrong_origin");
48
+ const rpHash = crypto.createHash("sha256").update(check.rp_id).digest();
49
+ if (!Buffer.from(auth.subarray(0, 32)).equals(rpHash))
50
+ throw new OACError("rp_mismatch");
51
+ if (!(auth[32] & USER_PRESENT))
52
+ throw new OACError("user_not_present");
53
+ if (!(auth[32] & USER_VERIFIED))
54
+ throw new OACError("user_not_verified");
55
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "open-action-consent",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Open Action Consent: a verifiable yes for actions AI agents take for people. Core SDK plus MCP (SDK v2) and Vercel AI SDK integrations.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",