@tineira/apc-protocol 0.8.0 → 0.9.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/CHANGELOG.md CHANGED
@@ -4,6 +4,26 @@ This file records the contract that an app and an agent share in Agent Pointed C
4
4
 
5
5
  The standard has its own semver, `APC_STANDARD_VERSION` in `@tineira/apc-protocol`, separate from the software release. It stays 0.x while the standard is a draft and becomes 1.0.0 when it is published. The version of the paper (V1, the apc.tineira.com demo) is not a 1.x of this standard. Every change to the contract adds an entry here and bumps the constant: a new minor (or, from 1.0, a major) version. A patch (0.7.0 to 0.7.1) never changes the contract; it fixes the packages or the wording, so an app that implements 0.7.0 implements every 0.7.x.
6
6
 
7
+ ## 0.9.0 (draft, 2026-11-12)
8
+
9
+ The pairing code: on the Dock profile the app's consent shows a code, and the dock's Allow requires it (TIN-859). A drag can be put on any page, and anyone who runs a dock can mint tickets for their own agent; before, they could lure an app's owner into a drop and an Allow and get their own agent paired with the owner's data. In `packages/protocol/src/core.ts`, `packages/server/src/consent.ts`, `packages/server/src/hub-client.ts`, `packages/conformance` and DESIGN.md section 12.
10
+
11
+ ### Pairing
12
+
13
+ - **The app shows a pairing code with its consent, and sends it to the hub.** Six characters of Crockford base32 (`PAIRING_CODE_LENGTH`, `PAIRING_CODE_ALPHABET`, 30 bits), new for every consent, shown as `K7M-4P2` (`formatPairingCode`) with the warning to type it only in the person's own dock. After the Allow the app sends it raw in `pairing_code` of `POST {hub}/apc/pair` (`DockPairRequest`). An app that implements 0.9 **must** send it. The code never goes over MCP, in a URL, to `/apc/confirm` or anywhere but the app owner's page and that one request.
14
+ - **The hub keeps it and answers `pairing_code: "required"`** (`PAIRING_CODE_ACK`, `DockPairAnswer`), never shows it (not in the dock's state, a broadcast or any answer), and checks the body before it spends the ticket: a `pairing_code` that is present but not a code (`null`, `""`, a number) is `invalid_pairing_code`. Only an absent key is a pairing without a code; a hub may refuse those (`pairing_code_required`).
15
+ - **The dock owner's Allow needs the code.** The hub compares it in constant time (`pairingCodesMatch`) with what the app sent; a missing or malformed code is refused without counting; a wrong one counts, `wrong_pairing_code` with `attempts_left`, and the fifth (`PAIRING_CODE_MAX_ATTEMPTS`) removes the request and gives the grant back (`pairing_code_refused`). A request with a code that nobody approves goes after `PAIRING_CODE_TTL_MS` (120 s), its grant given back; one without keeps the old ten minutes. Deny and unpair need no code. Typing is forgiving: case, spaces and dashes are ignored, O reads as 0 and I or L as 1 (`normalizePairingCode`).
16
+ - **The app keeps a grant only on the acknowledgement**: a non-redirected HTTP 200 with `ok: true`, `pairing_code: "required"` and a string `pairing_id`. A hub's own refusal is HTTP 400 with `ok: false` and its error. Anything else (another status, a redirect, a missing acknowledgement, no answer) and the app deletes the grant before it answers its page (`hub_refused`, `hub_unreachable`, `hub_ignores_pairing_code`, `HubCallError`). `/apc/confirm` is believed the same way: 200, `ok: true`, `status` `"pending"` or `"paired"`.
17
+ - **A drag's ticket lives 240 s** (`TICKET_TTL_MS`; it was 60 s), longer than the app's two-minute consent, so the person has the time to read the card.
18
+ - **What the app shows after the Allow** (reference behaviour, not a MUST): the code again, the time left, and Cancel, until the hub connects (its first `initialize` on the grant) or the grant goes; if the person's own dock never asks for that code, the agent was someone else's.
19
+
20
+ ### Packages
21
+
22
+ - `@tineira/apc-server`: `DockConsent.ask` returns `{ nonce, pairing_code }`, and `take` is replaced by `takeConsent`, which returns `{ ok: true, pairing_code }` or `{ ok: false }` (no boolean to test by mistake): every caller has to show the code and send it. New: `newPairingCode`, and `pairWithHub` / `confirmWithHub` (`redirect: "error"`, a 10 s limit, the rules above).
23
+ - `@tineira/apc-conformance`: `standInHub`, a hub to pair with in process, faithful on tickets, the code, wrong codes, expiry, the give-back and the acknowledgement, with broken modes (`no_ack`, `redirect`, `error_500`). An adapter on the Dock profile gives `dock: { hub, drop }`, required of an app that claims 0.9.0 or later (as `page.mobile` since 0.6). New checks: the consent shows a well-formed code and the app sends that code; every consent shows a new code; a hub that does not acknowledge, redirects or says yes with a 500 holds no grant; five wrong codes or the code's expiry give the grant back; the right code pairs.
24
+
25
+ An app that implements 0.8 keeps pairing with a hub in its default mode (`when_sent`), without a code, until the operator requires one; to claim 0.9.0 it shows and sends the code and fails closed on the hub's answer.
26
+
7
27
  ## 0.8.0 (draft, 2026-11-12)
8
28
 
9
29
  The core tools made robust, from a second implementation in use, and the Direct profile freed from how the agent authenticates (TIN-839, TIN-667). In `packages/protocol/src/core.ts`, `manifest.ts`, `packages/server/src/tools.ts`, `waits.ts`, `packages/conformance` and DESIGN.md section 21.
package/README.md CHANGED
@@ -8,7 +8,7 @@ The contract an app and an agent share in Agent Pointed Context: wire types, the
8
8
  npm i @tineira/apc-protocol@next
9
9
  ```
10
10
 
11
- Pre-releases go out on the `next` tag while the standard is 0.x; the package's version follows the standard's (0.8.x ships APC 0.8).
11
+ Pre-releases go out on the `next` tag while the standard is 0.x; the package's version follows the standard's (0.9.x ships APC 0.9).
12
12
 
13
13
  ```ts
14
14
  import { APC_STANDARD_VERSION, focusAttrs, parseApcManifest } from "@tineira/apc-protocol";
@@ -41,7 +41,7 @@ What every APC app implements, whichever way agents reach it ([`src/core.ts`](sr
41
41
  What belongs to a profile, not to the core:
42
42
 
43
43
  - **Direct** (the agent connects to the app's own MCP URL): a grant per agent, its secret URL, the list where the person makes and names agents.
44
- - **Dock** (the agent connects once, to a hub): the drop (`application/x-apc-agent`, the handle and the ticket), consent in the app (checked by the app's server, never its page alone: 0.7, `DockConsent` in `@tineira/apc-server`), `POST {hub}/apc/pair` (the hub reads the app's `apc.json` first), `POST {hub}/apc/confirm` on every drop, and `POST {app}/apc/revoke`, the hub giving a grant back.
44
+ - **Dock** (the agent connects once, to a hub): the drop (`application/x-apc-agent`, the handle and the ticket), consent in the app (checked by the app's server, never its page alone: 0.7, `DockConsent` in `@tineira/apc-server`), with a pairing code the dock's Allow requires (0.9, `PAIRING_CODE_LENGTH`), `POST {hub}/apc/pair` (the hub reads the app's `apc.json` first; the app keeps its grant only on the hub's acknowledgement of the code, `pairWithHub`), `POST {hub}/apc/confirm` on every drop, and `POST {app}/apc/revoke`, the hub giving a grant back.
45
45
 
46
46
  Whatever it offers, an app publishes `apc.json` (below).
47
47
 
package/dist/core.d.ts CHANGED
@@ -17,7 +17,8 @@
17
17
  * a grant for a dock agent it holds none for only on a request that proves the person allowed it on the
18
18
  * app's page just now (the reference apps: a single-use nonce the page shows its consent with,
19
19
  * `DockConsent` in `@tineira/apc-server`), whatever the page last loaded. A page that missed a revoke must not
20
- * pair without asking.
20
+ * pair without asking. Since APC 0.9 the consent shows a pairing code, and the dock's Allow requires it
21
+ * (`PAIRING_CODE_LENGTH`, below).
21
22
  *
22
23
  * The core is the same on both, and per grant: the hub is one more holder of a grant.
23
24
  *
@@ -218,6 +219,85 @@ export declare const APC_MOBILE_VIEWPORT: {
218
219
  readonly width: 375;
219
220
  readonly height: 812;
220
221
  };
222
+ /**
223
+ * The pairing code (Dock profile, APC 0.9). A drag can be put on a page by anyone: whoever runs a dock can
224
+ * mint tickets for their own agent and lure the app's owner into a drop and an Allow. So a pairing is
225
+ * approved in the dock only with a code that only the app's owner sees.
226
+ *
227
+ * - **The app makes it** with its consent nonce: `PAIRING_CODE_LENGTH` characters of `PAIRING_CODE_ALPHABET`
228
+ * (Crockford base32, 30 bits), new for every consent (`newPairingCode` and `DockConsent` in
229
+ * `@tineira/apc-server`), and shows it on its consent card, formatted `K7M-4P2` (`formatPairingCode`). After
230
+ * the Allow, the app sends it raw (`K7M4P2`) in `pairing_code` of `POST {hub}/apc/pair` (`DockPairRequest`).
231
+ * An app that implements 0.9 **must** send it. It never goes anywhere else: not over MCP, not in a URL,
232
+ * not to `/apc/confirm`.
233
+ * - **The hub keeps it** with the pending pairing, never shows it (not in the dock's state, not in a
234
+ * broadcast, not in any answer) and answers a pairing that carries one with `pairing_code: "required"`
235
+ * (`PAIRING_CODE_ACK`, `DockPairAnswer`). It checks the body before it spends the ticket: a `pairing_code`
236
+ * that is present but does not normalize (`null`, `""`, a number) is `invalid_pairing_code`. Only an absent
237
+ * key is a pairing without a code (an app from before 0.9); a hub may refuse those too
238
+ * (`pairing_code_required`).
239
+ * - **The dock owner types it** to approve: the hub compares it, in constant time, with the one the app sent.
240
+ * A wrong one counts; after `PAIRING_CODE_MAX_ATTEMPTS` the request goes and the grant is given back
241
+ * (`pairing_code_refused`). A request with a code that nobody approves goes after `PAIRING_CODE_TTL_MS`, its
242
+ * grant given back. A match clears the code. Deny and unpair need no code.
243
+ * - **The app keeps a grant only on the acknowledgement**: a non-redirected HTTP 200 with `ok: true`,
244
+ * `pairing_code: "required"` and a string `pairing_id` (`pairWithHub` in `@tineira/apc-server`). Anything
245
+ * else (another status, a redirect, a missing acknowledgement, no answer) and the app deletes the grant
246
+ * before it answers its page: a hub that does not check codes never holds a grant (`hub_ignores_pairing_code`).
247
+ *
248
+ * The drag's ticket lives `TICKET_TTL_MS` (240 s since 0.9; 60 s before), longer than the app's consent, so
249
+ * the person has the time to read the card.
250
+ */
251
+ export declare const PAIRING_CODE_LENGTH = 6;
252
+ /** Crockford base32: no I, L, O or U, so a code reads out loud without doubt. */
253
+ export declare const PAIRING_CODE_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
254
+ /** How long a pending pairing with a code waits for the dock owner, from `/apc/pair`. */
255
+ export declare const PAIRING_CODE_TTL_MS = 120000;
256
+ /** Wrong codes a hub takes for one pairing; the last one removes it. */
257
+ export declare const PAIRING_CODE_MAX_ATTEMPTS = 5;
258
+ /** What a hub that keeps the code answers in `pairing_code`. */
259
+ export declare const PAIRING_CODE_ACK = "required";
260
+ /** How long a drag's ticket works (APC 0.9: 240 s). */
261
+ export declare const TICKET_TTL_MS = 240000;
262
+ /** Why a hub refuses a pairing or an approval over its code. */
263
+ export type PairingCodeError = "invalid_pairing_code" | "pairing_code_required" | "wrong_pairing_code" | "pairing_code_refused";
264
+ /** Why an app did not keep a grant after asking a hub to pair (`pairWithHub`), when the hub gave no reason of its own. */
265
+ export type HubCallError = "hub_unreachable" | "hub_refused" | "hub_ignores_pairing_code";
266
+ /**
267
+ * A code as typed or sent, made canonical: case, spaces and dashes are ignored, O reads as 0 and I or L as 1.
268
+ * Null unless the result is `PAIRING_CODE_LENGTH` characters of `PAIRING_CODE_ALPHABET`.
269
+ */
270
+ export declare function normalizePairingCode(input: unknown): string | null;
271
+ /** How a code is shown to a person: `K7M4P2` as `K7M-4P2`. For display only; the wire and storage keep it raw. Anything that is not a code is returned untouched, never shaped like one. */
272
+ export declare function formatPairingCode(code: string): string;
273
+ /** Whether two normalized codes are the same, in time that does not depend on where they differ. */
274
+ export declare function pairingCodesMatch(expected: string, given: string): boolean;
275
+ /** `POST {hub}/apc/pair`: what an app sends after the person's Allow (Dock profile). `pairing_code` is raw, 6 characters. */
276
+ export interface DockPairRequest {
277
+ ticket: string;
278
+ agent: string;
279
+ app: {
280
+ name: string;
281
+ origin: string;
282
+ };
283
+ mcp_url: string;
284
+ /** Required of an app that implements 0.9; absent only from an app before it. */
285
+ pairing_code?: string;
286
+ }
287
+ /** The hub's answer to `/apc/pair`: HTTP 200 with the acknowledgement, or HTTP 400 with the reason. */
288
+ export type DockPairAnswer = {
289
+ ok: true;
290
+ pairing_id: string;
291
+ status: "pending";
292
+ agent: {
293
+ name: string;
294
+ icon: string | null;
295
+ };
296
+ pairing_code: typeof PAIRING_CODE_ACK;
297
+ } | {
298
+ ok: false;
299
+ error: string;
300
+ };
221
301
  /** The other path: Direct for Dock and Dock for Direct. */
222
302
  export declare function oppositePath(path: ApcProfile): ApcProfile;
223
303
  /**
package/dist/core.js CHANGED
@@ -76,6 +76,75 @@ export function isRevokedAnswer(body) {
76
76
  */
77
77
  export const APC_MOBILE_MEDIA = "(pointer: coarse), (max-width: 640px)";
78
78
  export const APC_MOBILE_VIEWPORT = { width: 375, height: 812 };
79
+ /**
80
+ * The pairing code (Dock profile, APC 0.9). A drag can be put on a page by anyone: whoever runs a dock can
81
+ * mint tickets for their own agent and lure the app's owner into a drop and an Allow. So a pairing is
82
+ * approved in the dock only with a code that only the app's owner sees.
83
+ *
84
+ * - **The app makes it** with its consent nonce: `PAIRING_CODE_LENGTH` characters of `PAIRING_CODE_ALPHABET`
85
+ * (Crockford base32, 30 bits), new for every consent (`newPairingCode` and `DockConsent` in
86
+ * `@tineira/apc-server`), and shows it on its consent card, formatted `K7M-4P2` (`formatPairingCode`). After
87
+ * the Allow, the app sends it raw (`K7M4P2`) in `pairing_code` of `POST {hub}/apc/pair` (`DockPairRequest`).
88
+ * An app that implements 0.9 **must** send it. It never goes anywhere else: not over MCP, not in a URL,
89
+ * not to `/apc/confirm`.
90
+ * - **The hub keeps it** with the pending pairing, never shows it (not in the dock's state, not in a
91
+ * broadcast, not in any answer) and answers a pairing that carries one with `pairing_code: "required"`
92
+ * (`PAIRING_CODE_ACK`, `DockPairAnswer`). It checks the body before it spends the ticket: a `pairing_code`
93
+ * that is present but does not normalize (`null`, `""`, a number) is `invalid_pairing_code`. Only an absent
94
+ * key is a pairing without a code (an app from before 0.9); a hub may refuse those too
95
+ * (`pairing_code_required`).
96
+ * - **The dock owner types it** to approve: the hub compares it, in constant time, with the one the app sent.
97
+ * A wrong one counts; after `PAIRING_CODE_MAX_ATTEMPTS` the request goes and the grant is given back
98
+ * (`pairing_code_refused`). A request with a code that nobody approves goes after `PAIRING_CODE_TTL_MS`, its
99
+ * grant given back. A match clears the code. Deny and unpair need no code.
100
+ * - **The app keeps a grant only on the acknowledgement**: a non-redirected HTTP 200 with `ok: true`,
101
+ * `pairing_code: "required"` and a string `pairing_id` (`pairWithHub` in `@tineira/apc-server`). Anything
102
+ * else (another status, a redirect, a missing acknowledgement, no answer) and the app deletes the grant
103
+ * before it answers its page: a hub that does not check codes never holds a grant (`hub_ignores_pairing_code`).
104
+ *
105
+ * The drag's ticket lives `TICKET_TTL_MS` (240 s since 0.9; 60 s before), longer than the app's consent, so
106
+ * the person has the time to read the card.
107
+ */
108
+ export const PAIRING_CODE_LENGTH = 6;
109
+ /** Crockford base32: no I, L, O or U, so a code reads out loud without doubt. */
110
+ export const PAIRING_CODE_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
111
+ /** How long a pending pairing with a code waits for the dock owner, from `/apc/pair`. */
112
+ export const PAIRING_CODE_TTL_MS = 120_000;
113
+ /** Wrong codes a hub takes for one pairing; the last one removes it. */
114
+ export const PAIRING_CODE_MAX_ATTEMPTS = 5;
115
+ /** What a hub that keeps the code answers in `pairing_code`. */
116
+ export const PAIRING_CODE_ACK = "required";
117
+ /** How long a drag's ticket works (APC 0.9: 240 s). */
118
+ export const TICKET_TTL_MS = 240_000;
119
+ /**
120
+ * A code as typed or sent, made canonical: case, spaces and dashes are ignored, O reads as 0 and I or L as 1.
121
+ * Null unless the result is `PAIRING_CODE_LENGTH` characters of `PAIRING_CODE_ALPHABET`.
122
+ */
123
+ export function normalizePairingCode(input) {
124
+ if (typeof input !== "string" || input.length > 32)
125
+ return null;
126
+ const code = input.toUpperCase().replace(/[\s-]/g, "").replace(/O/g, "0").replace(/[IL]/g, "1");
127
+ if (code.length !== PAIRING_CODE_LENGTH)
128
+ return null;
129
+ for (const c of code)
130
+ if (!PAIRING_CODE_ALPHABET.includes(c))
131
+ return null;
132
+ return code;
133
+ }
134
+ /** How a code is shown to a person: `K7M4P2` as `K7M-4P2`. For display only; the wire and storage keep it raw. Anything that is not a code is returned untouched, never shaped like one. */
135
+ export function formatPairingCode(code) {
136
+ const raw = normalizePairingCode(code);
137
+ return raw ? `${raw.slice(0, 3)}-${raw.slice(3)}` : code;
138
+ }
139
+ /** Whether two normalized codes are the same, in time that does not depend on where they differ. */
140
+ export function pairingCodesMatch(expected, given) {
141
+ if (expected.length !== PAIRING_CODE_LENGTH || given.length !== PAIRING_CODE_LENGTH)
142
+ return false;
143
+ let diff = 0;
144
+ for (let i = 0; i < PAIRING_CODE_LENGTH; i++)
145
+ diff |= expected.charCodeAt(i) ^ given.charCodeAt(i);
146
+ return diff === 0;
147
+ }
79
148
  /** The other path: Direct for Dock and Dock for Direct. */
80
149
  export function oppositePath(path) {
81
150
  return path === "direct" ? "dock" : "direct";
package/dist/version.d.ts CHANGED
@@ -7,4 +7,4 @@
7
7
  * and becomes 1.0.0 when the standard is published. Every change to the
8
8
  * contract adds an entry to `packages/protocol/CHANGELOG.md` and bumps this.
9
9
  */
10
- export declare const APC_STANDARD_VERSION = "0.8.0";
10
+ export declare const APC_STANDARD_VERSION = "0.9.0";
package/dist/version.js CHANGED
@@ -7,4 +7,4 @@
7
7
  * and becomes 1.0.0 when the standard is published. Every change to the
8
8
  * contract adds an entry to `packages/protocol/CHANGELOG.md` and bumps this.
9
9
  */
10
- export const APC_STANDARD_VERSION = "0.8.0";
10
+ export const APC_STANDARD_VERSION = "0.9.0";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tineira/apc-protocol",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Agent Pointed Context (APC): the contract an app and an AI agent share. Wire types, the target grammar, the core, apc.json and the version of the standard.",
5
5
  "keywords": [
6
6
  "apc",