@tineira/apc-protocol 0.7.1 → 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,52 @@ 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
+
27
+ ## 0.8.0 (draft, 2026-11-12)
28
+
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.
30
+
31
+ ### The loop
32
+
33
+ - **`then` only after a recent pin.** A result carries `then` only while a page of the person's is open (and shared) **and** the grant was pinned within the app's window, read or unread (`THEN_PIN_WINDOW_MIN`, 30 minutes unless the app sets its own; `thenApplies` in `@tineira/apc-server`). An open page with no recent pin gives no `then`, so an agent no longer waits after every answer. Before, an open page was enough.
34
+ - **Wait in the background.** The instructions (`APC_INSTRUCTIONS`), the `guide` prompt, the `then` note and `wait_for_focus`'s description tell the agent to run the wait in the background if its client can (a background subagent, say), and otherwise to keep it short and never chain waits (`WAIT_IN_BACKGROUND`). The default timeout stays the app's: 25 to 45 s suits a conversation.
35
+ - **One wait per grant.** At most one wait of a grant delivers. A new wait takes over: the one it replaces ends at its next check with `focus: null, timed_out: false` and a note, delivers nothing and marks nothing seen. A new wait within `WAIT_CLAIM_GAP_S` (2 s) of one still running is refused instead, with `focus: null, timed_out: false, retry_after_s` (new in `WaitNoFocus`) and a note, so two waits fired together do not take over from each other in a loop. A wait whose request the client cancels stops and frees its place at once: MCP's `notifications/cancelled`, which reaches a stateless server in a POST of its own (`InflightCalls` keeps the calls in flight per grant to find the one it names), and an HTTP abort where the host reports one.
36
+ - **Seen once delivered.** Checking for a pin, or polling for one, never marks it seen: only returning it does. An app marks a pin seen only once the result that carries it is final (in a wait, after it gave its place up and checked once more that its request was not cancelled), and marks that exact pin by its `pinned_at`, never a newer one dropped meanwhile. The result goes out whether or not the mark succeeds. So a pin may be delivered twice, and is never lost to a wait that was taken over or cancelled, or to a newer pin. MCP has no acknowledgement: a result lost after the app sent it, with no abort the app could see, cannot be told apart, and that pin is marked seen. Before, the reference marked the pin seen inside the tool, before the result left, so a wait the client had given up on, or a newer pin dropped between the read and the mark, could be lost.
37
+ - **Unavailable has its own shape, and trim never fails the read.** A core tool the app cannot answer just now still answers in its own shape with a `note` (new, optional, in `CurrentViewResult`): `{ view: null, focus: null, note }`, or `{ focus: null, timed_out: false, page_open: false, note }`; never a tool error or an `{ ok, reason }` of the app's own. A check on one item of the answer that cannot run hides that item and the rest answers; it never fails the read open, and a defect in one check never fails it whole (guidance, not wire format).
38
+
39
+ ### The Direct profile, whatever the auth
40
+
41
+ - **What a grant must be** (`GrantRequirement`, `GRANT_REQUIREMENTS`): a stable id per agent connection (pins, views, presence and `apc_link` are keyed by it); revocable from the app alone, checked on every request and before and after each tool, and revoking ends what could bring it back without the person (with OAuth, its refresh tokens and authorization); revoked told apart from expired or unknown; trimmed per grant, so pointing grants no access. Any binding that meets them will do. The secret URL (`newDirectGrant`, `directMcpUrl`) stays the reference apps' binding; OAuth 2.1 (one MCP URL shared by every grant, the bearer token naming the grant) is another.
42
+ - **The revoked answer's status is the binding's.** Every binding answers a revoked grant with the `RevokedAnswer` body. The status is 403 with a secret URL (`REVOKED_HTTP_STATUS`, unchanged) and 401 with `WWW-Authenticate: Bearer error="invalid_token"` with a bearer token (`REVOKED_HTTP_STATUS_BEARER`). An unknown or expired grant answers without a `RevokedAnswer`.
43
+ - **`apc.json`.** A Direct app whose grants share one URL may serve it at `mcp.path` itself or at `mcp.path` without its last `/`; one origin may serve several such endpoints under the path, one per role (`declaresMcpUrl(manifest, url, { shared: true })`; a hub pairing a Dock app still requires one URL per grant). `direct.auth` (a short word such as `"url"` or `"oauth"`) and `direct.resource_metadata` (with OAuth, the protected-resource metadata URL on the origin) may say how agents authenticate, as information only: a reader never requires them.
44
+ - **Hosts with no long-lived process.** An app on stateless functions bounds `wait_for_focus` by its function's time limit (its own ceiling for `timeout_s`), tells a closed page from a heartbeat, and checks the grant before and after each tool call.
45
+
46
+ ### Conformance
47
+
48
+ - A connection may carry headers: `AgentConnection.headers` (`{ authorization: "Bearer …" }`), and the CLI takes `--bearer-env <VAR>` and `--revoked-bearer-env <VAR>`, the name of an environment variable that holds the token (never the token itself on the command line; it is never printed). With a bearer token the revoked checks expect 401, the grant's URL may be `mcp.path` itself, and the unknown-grant 404 check is skipped.
49
+ - New checks of an app that claims 0.8.0 or later: an open page with no pin gives no `then`; a newer wait takes over and the older one delivers nothing; a second wait right after the first is refused with `retry_after_s` and the first still delivers; a wait cancelled by `notifications/cancelled`, and one cancelled by an HTTP abort, marks nothing seen and frees its place. Of an app that claims less, those checks pass with a note that they did not apply. The `then` check pins the agent first.
50
+
51
+ An app that implements 0.7 keeps working with agents and docks unchanged; to claim 0.8.0 it adds the `then` window, one wait per grant, and seen once delivered. `@tineira/apc-server`'s `ApcToolContext` now requires `waitSlot` (`MemoryWaitSlots.for(grantId)` for an app whose waits run in one process), takes `signal` (the request's, with `InflightCalls`' for MCP's cancel), and calls `markSeen(pinnedAt)` with the pin it delivered.
52
+
7
53
  ## 0.7.1 (draft, 2026-11-04)
8
54
 
9
55
  No change to the contract: an app or a hub that implements 0.7.0 implements 0.7.1, and `standard` in `apc.json` may say either. This is the first release of `@tineira/apc-protocol`, `@tineira/apc-server`, `@tineira/apc-web` and `@tineira/apc-conformance` published by `.github/workflows/publish.yml` (npm trusted publishing) rather than from a maintainer's machine.
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.7.x ships APC 0.7).
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";
@@ -32,16 +32,16 @@ What every APC app implements, whichever way agents reach it ([`src/core.ts`](sr
32
32
  | The pin | Keeps one pin per grant, dropped by the person on a target (`FocusTarget`), and gives it as `FocusPayload` with `next` and `next_args`, the call that reads it. While it is unread, every tool result carries `attention`. |
33
33
  | Presence | After each successful tool call, publishes a `PresenceEvent` (ids only) to the person's page; a read of a target names it. |
34
34
  | The focus format | `FocusPayload`, from the grammar in [`src/target.ts`](src/target.ts): kinds, ids, parts and key-only params. |
35
- | `get_current_view`, `wait_for_focus` | The two core tools (`CORE_TOOLS`), with `then` on results while a page of the person's is open, never on a wait that came back empty. A wait returns a pin dropped during it, times out cleanly, and ends as soon as the app knows the page is closed. |
35
+ | `get_current_view`, `wait_for_focus` | The two core tools (`CORE_TOOLS`), with `then` on results while a page of the person's is open and the grant was pinned in the last 30 minutes (0.8), never on a wait that came back empty. A wait returns a pin dropped during it, times out cleanly, and ends as soon as the app knows the page is closed. One wait per grant: a newer one takes over (0.8). A pin is marked seen only once the result that carries it is final, by its `pinned_at` (0.8): delivered twice at worst, never lost. |
36
36
  | `apc.json` | Serves `/.well-known/apc.json` from its own origin: what it is and which profiles it offers (below). |
37
37
  | `apc_link` | Only with both profiles: the same agent on both paths shown as one, with a one-time code; it grants nothing (below). |
38
38
  | Mobile (0.6) | On a phone or any touch viewport (`APC_MOBILE_MEDIA`) shows its agents in its own page, on both profiles (below). |
39
- | Revoke from the app | Revoke on every agent in the agent bar. At once, in the app alone (it calls nobody): the grant's MCP URL answers 403 with `RevokedAnswer` from then on, a call already running ends the same way (told by the grant's secret, never an id a later grant may reuse, 0.7), and the grant never works again. |
39
+ | Revoke from the app | Revoke on every agent in the agent bar. At once, in the app alone (it calls nobody): every request with the grant answers `RevokedAnswer` from then on (403 with a secret URL, 401 with a bearer token, 0.8), a call already running ends the same way (told by the grant itself, never an id a later grant may reuse, 0.7), and the grant never works again. |
40
40
 
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
@@ -3,9 +3,12 @@
3
3
  *
4
4
  * An app offers one profile or both:
5
5
  *
6
- * - **Direct**: the agent connects to the app's own MCP URL, one per agent. The connection (a grant
7
- * per agent, its secret MCP URL, the agent list where the person makes and names them) is the
8
- * profile's, not the core's.
6
+ * - **Direct**: the agent connects to the app's own MCP endpoint. The connection (a grant per agent,
7
+ * how the agent authenticates, the agent list where the person makes and names them) is the
8
+ * profile's, not the core's. Since APC 0.8 the standard does not fix how the agent authenticates:
9
+ * any binding that meets `GrantRequirement` will do. The reference apps' binding is the secret URL
10
+ * (one MCP URL per grant, `newDirectGrant` in `@tineira/apc-server`); OAuth (one MCP URL shared by
11
+ * every grant, the bearer token naming the grant) is another.
9
12
  * - **Dock**: the agent connects once, to a hub (the dock), which reaches the app with a grant the
10
13
  * app gave it. Receiving the drop (`application/x-apc-agent`, the handle and the ticket), consent
11
14
  * in the app, pairing (`POST {hub}/apc/pair`), the check on every drop (`POST {hub}/apc/confirm`)
@@ -14,7 +17,8 @@
14
17
  * a grant for a dock agent it holds none for only on a request that proves the person allowed it on the
15
18
  * app's page just now (the reference apps: a single-use nonce the page shows its consent with,
16
19
  * `DockConsent` in `@tineira/apc-server`), whatever the page last loaded. A page that missed a revoke must not
17
- * 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).
18
22
  *
19
23
  * The core is the same on both, and per grant: the hub is one more holder of a grant.
20
24
  *
@@ -26,7 +30,9 @@
26
30
  * the person's page; a read of a target names it in `targets`.
27
31
  * 4. **The focus format**: `FocusPayload`, from the grammar in `target.ts`.
28
32
  * 5. **`get_current_view` and `wait_for_focus`**, the two core tools (`CORE_TOOLS`), with the loop
29
- * hint `then` while a page of the person's is open.
33
+ * hint `then` while a page of the person's is open and the grant was pinned lately (APC 0.8), at most
34
+ * one delivering wait per grant (APC 0.8), and a pin marked seen only once the result that carries
35
+ * it is final (APC 0.8): an app may deliver a pin twice, never lose one.
30
36
  * 6. **Revoke from the app**: the person can cut any agent from the app itself, at once, without the
31
37
  * hub (`RevokedAnswer`).
32
38
  * 7. **`apc_link`** (an app that offers both profiles): the same agent on both paths shown as one,
@@ -48,21 +54,68 @@ export type CoreTool = (typeof CORE_TOOLS)[number];
48
54
  /** The key an unread pin puts on every tool result (structured and in the text): `{ focus, note }`. */
49
55
  export declare const ATTENTION_KEY = "attention";
50
56
  /**
51
- * The loop hint: present on a result while a page of the person's is open and they share their view,
52
- * except on a `wait_for_focus` that came back with no focus (that one ends the loop).
57
+ * The loop hint. Since APC 0.8 it is present on a result only while both hold: a page of the person's
58
+ * is open (and they share their view), and the grant was pinned within the app's window
59
+ * (`THEN_PIN_WINDOW_MIN`, 30 minutes unless the app sets its own). An open page with no recent pin gives
60
+ * no `then`, so an agent does not wait after every answer. It is never on a `wait_for_focus` that came
61
+ * back with no focus (that one ends the loop).
62
+ *
63
+ * The agent is told (in the instructions, the guide, the note and the tool's description) to run the
64
+ * wait in the background if its client can (a background subagent, say), and otherwise to keep it short
65
+ * and never chain waits.
53
66
  */
54
67
  export declare const THEN_KEY = "then";
55
- /** `get_current_view`: the screen and, if the person pointed the agent, the focus. Reading the focus marks it seen. */
68
+ /** The default `then` window, in minutes: the grant's pin, read or unread, is at most this old. An app may set its own. */
69
+ export declare const THEN_PIN_WINDOW_MIN = 30;
70
+ /**
71
+ * `get_current_view`: the screen and, if the person pointed the agent, the focus. Returning the focus
72
+ * marks it seen, once the result is final (APC 0.8, `WaitForFocusResult`).
73
+ *
74
+ * **Unavailable (APC 0.8).** When the app cannot answer a core tool just now (its store is down, a check
75
+ * cannot run), the tool still answers in its own shape, with nothing in it and a `note` for the agent:
76
+ * `{ view: null, focus: null, note }` here, `{ focus: null, timed_out: false, page_open: false, note }`
77
+ * from `wait_for_focus`. Never a tool error or an `{ ok, reason }` of the app's own. A revoked grant is not
78
+ * unavailable: it answers `RevokedAnswer`.
79
+ *
80
+ * **Trim, never fail (APC 0.8).** A check on one item of the answer (a filter, the selection, the pin)
81
+ * that cannot run hides that item, and the rest still answers. It never fails the read open (the item
82
+ * shown unchecked), and a defect in one check (a missing column, a value it cannot parse) never fails
83
+ * the read whole. A failure that concerns the grant itself (its auth, an expired session) still fails it
84
+ * closed.
85
+ */
56
86
  export interface CurrentViewResult {
57
87
  view: ViewPayload | null;
58
88
  focus: FocusPayload | null;
89
+ /** Why the result is empty, when the app could not answer it just now (APC 0.8). */
90
+ note?: string;
59
91
  }
60
92
  /**
61
93
  * `wait_for_focus({ timeout_s })`: holds the call until the person pins the agent or `timeout_s`
62
94
  * passes (1 to the app's ceiling). A pin dropped during the wait is returned, marked seen. With no
63
95
  * pin: `timed_out: true` at the timeout, or `timed_out: false, page_open: false` as soon as the app
64
96
  * knows the person's page is closed. `page_open` is left out while the person does not share their
65
- * view (an app without that switch always shares).
97
+ * view (an app without that switch always shares). The default timeout is the app's: 25 to 45 s suits
98
+ * a conversation (`WAIT_DEFAULT_S` in `@tineira/apc-server` is 25), bounded by what its host can hold.
99
+ *
100
+ * **One wait per grant (APC 0.8).** At most one wait of a grant delivers. A new wait takes over: the one
101
+ * it replaces ends at its next check with `focus: null, timed_out: false` and a note, delivers nothing,
102
+ * and never marks a pin seen. A new wait that starts within `WAIT_CLAIM_GAP_S` of one still running is
103
+ * refused instead, with `focus: null, timed_out: false, retry_after_s` and a note: two waits fired
104
+ * together would otherwise take over from each other in a loop. A wait whose request the client cancels
105
+ * stops and frees its place at once: MCP's `notifications/cancelled` (which, to a stateless server, comes
106
+ * in a POST of its own: the app keeps the calls in flight per grant to find the one it names,
107
+ * `InflightCalls` in `@tineira/apc-server`), and an HTTP abort where the host reports one (on Cloudflare
108
+ * Workers, `request.signal` with the `enable_request_signal` flag, or a compatibility date from 2026-11-17).
109
+ *
110
+ * **Seen once delivered (APC 0.8).** Checking for a pin, or polling for one, never marks it seen:
111
+ * only returning it does. An app marks a pin seen only once the result that carries it is final (in a
112
+ * wait, after it gave its place up and checked one last time that its request was not cancelled), and
113
+ * marks that exact pin (by its `pinned_at`), never a newer one dropped meanwhile. It returns the result
114
+ * whether or not the mark succeeds. So a pin may be delivered twice, and is never lost to a wait that was
115
+ * taken over or cancelled, or to a newer pin. MCP has no acknowledgement: a result lost after the app sent
116
+ * it (the connection dropped with no abort the app could see) is not something an app can tell, and that
117
+ * pin is marked seen. A hub that relays a wait holds the pin it was handed: it keeps it unread until its
118
+ * own agent gets it.
66
119
  */
67
120
  export type WaitForFocusResult = WaitGotFocus | WaitNoFocus;
68
121
  export interface WaitGotFocus {
@@ -73,19 +126,56 @@ export interface WaitNoFocus {
73
126
  focus: null;
74
127
  timed_out: boolean;
75
128
  page_open?: boolean;
129
+ /** On a wait refused because another wait of the grant started just before it (APC 0.8): when to try again. */
130
+ retry_after_s?: number;
76
131
  note: string;
77
132
  }
133
+ /** How soon after one wait of a grant another is refused rather than taking over, in seconds (APC 0.8). */
134
+ export declare const WAIT_CLAIM_GAP_S = 2;
135
+ /**
136
+ * What a Direct grant must be, whatever way the agent authenticates (APC 0.8). The standard does not fix
137
+ * the binding (a secret MCP URL, OAuth, an API key, mTLS); any binding that meets these will do:
138
+ *
139
+ * - `stable_id`: **a stable id per agent connection.** Pins, views, presence and `apc_link` are keyed by it.
140
+ * - `revocable_in_app`: **revocable from the app alone.** The app revokes it in its own store, calling
141
+ * nobody, and checks it on every request, and before and after each tool call. Revoking ends everything
142
+ * that could bring the grant back without the person: with OAuth, the refresh tokens and the
143
+ * authorization behind it too, so a client that answers a 401 by refreshing or re-authorizing silently
144
+ * gets nothing. The person lets the agent back with a new grant.
145
+ * - `revoked_told_apart`: **revoked is told apart from expired or unknown**, with a `RevokedAnswer` body,
146
+ * so the agent tells the person and stops retrying, where an expired credential may just be renewed.
147
+ * - `trimmed_per_grant`: **pointing grants no access.** What a grant may read is trimmed per grant; a pin
148
+ * never widens it.
149
+ *
150
+ * Two bindings are described:
151
+ *
152
+ * - **The secret URL** (the reference apps; `newDirectGrant` and `directMcpUrl` in `@tineira/apc-server`):
153
+ * one MCP URL per grant, under `apc.json`'s `mcp.path`; whoever holds the URL holds the grant. Revoked is
154
+ * HTTP 403 with `RevokedAnswer`; an unknown or expired grant is 404.
155
+ * - **OAuth 2.1**: one MCP URL shared by every grant, the bearer token naming the grant. Revoked is HTTP
156
+ * 401 with `WWW-Authenticate: Bearer error="invalid_token"` and the `RevokedAnswer` body, so the client
157
+ * tells "taken back by the person" from "expired" and does not just authorize again.
158
+ *
159
+ * On a host with no long-lived process (stateless functions and a database), an app still bounds
160
+ * `wait_for_focus` by its function's time limit (its own ceiling for `timeout_s`), tells a closed page from
161
+ * a heartbeat the page sends, and checks the grant before and after each tool call.
162
+ */
163
+ export type GrantRequirement = "stable_id" | "revocable_in_app" | "revoked_told_apart" | "trimmed_per_grant";
164
+ export declare const GRANT_REQUIREMENTS: readonly GrantRequirement[];
78
165
  /**
79
166
  * Revoke from the app. Every agent in the app's agent bar, Direct or Dock, has Revoke, and it holds
80
167
  * in the app alone:
81
168
  *
82
169
  * - It takes effect at once: the grant, its pin and (Dock) the app's link to the hub go. The app
83
170
  * calls nobody, so it works with the hub down.
84
- * - From then on every request to the grant's MCP URL, whatever the method, is answered with HTTP
85
- * `REVOKED_HTTP_STATUS` (403) and a `RevokedAnswer` body. A call already running when the grant is
86
- * revoked ends as a tool error whose `error` is `"revoked"`, with no app data. An unknown or
87
- * expired grant stays 404, so the holder can tell "taken back" from "gone". Whether a grant was
88
- * revoked is told by the grant itself (its secret), never by an id a later grant may reuse (0.7).
171
+ * - From then on every request with the grant, whatever the method, is answered with a `RevokedAnswer`
172
+ * body. The HTTP status is the binding's (APC 0.8, `GrantRequirement`): `REVOKED_HTTP_STATUS` (403)
173
+ * with a secret URL, `REVOKED_HTTP_STATUS_BEARER` (401, with `WWW-Authenticate: Bearer
174
+ * error="invalid_token"`) with OAuth. A call already running when the grant is revoked ends as a tool
175
+ * error whose `error` is `"revoked"`, with no app data. An unknown or expired grant is answered without
176
+ * a `RevokedAnswer` (404 with a secret URL; a plain 401 with OAuth), so the holder can tell "taken back"
177
+ * from "gone". Whether a grant was revoked is told by the grant itself (its secret or token), never by
178
+ * an id a later grant may reuse (0.7).
89
179
  * - A revoked grant never works again. To let the agent back, the person makes a new grant: a new
90
180
  * MCP URL (Direct), or a new drop, with consent in the app and in the dock (Dock).
91
181
  * - A hub that gets a `RevokedAnswer` marks that pairing revoked ("Revoked by <app host>"), stops
@@ -93,7 +183,10 @@ export interface WaitNoFocus {
93
183
  * again by itself.
94
184
  */
95
185
  export declare const REVOKED_ERROR = "revoked";
186
+ /** The status of a revoked grant with the secret-URL binding. */
96
187
  export declare const REVOKED_HTTP_STATUS = 403;
188
+ /** The status of a revoked grant with a bearer token (OAuth), with `WWW-Authenticate: Bearer error="invalid_token"` (APC 0.8). */
189
+ export declare const REVOKED_HTTP_STATUS_BEARER = 401;
97
190
  export interface RevokedAnswer {
98
191
  error: typeof REVOKED_ERROR;
99
192
  /** The host of the app that revoked the grant, e.g. "pulse.example". */
@@ -126,6 +219,85 @@ export declare const APC_MOBILE_VIEWPORT: {
126
219
  readonly width: 375;
127
220
  readonly height: 812;
128
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
+ };
129
301
  /** The other path: Direct for Dock and Dock for Direct. */
130
302
  export declare function oppositePath(path: ApcProfile): ApcProfile;
131
303
  /**
package/dist/core.js CHANGED
@@ -3,21 +3,36 @@ export const CORE_TOOLS = ["get_current_view", "wait_for_focus"];
3
3
  /** The key an unread pin puts on every tool result (structured and in the text): `{ focus, note }`. */
4
4
  export const ATTENTION_KEY = "attention";
5
5
  /**
6
- * The loop hint: present on a result while a page of the person's is open and they share their view,
7
- * except on a `wait_for_focus` that came back with no focus (that one ends the loop).
6
+ * The loop hint. Since APC 0.8 it is present on a result only while both hold: a page of the person's
7
+ * is open (and they share their view), and the grant was pinned within the app's window
8
+ * (`THEN_PIN_WINDOW_MIN`, 30 minutes unless the app sets its own). An open page with no recent pin gives
9
+ * no `then`, so an agent does not wait after every answer. It is never on a `wait_for_focus` that came
10
+ * back with no focus (that one ends the loop).
11
+ *
12
+ * The agent is told (in the instructions, the guide, the note and the tool's description) to run the
13
+ * wait in the background if its client can (a background subagent, say), and otherwise to keep it short
14
+ * and never chain waits.
8
15
  */
9
16
  export const THEN_KEY = "then";
17
+ /** The default `then` window, in minutes: the grant's pin, read or unread, is at most this old. An app may set its own. */
18
+ export const THEN_PIN_WINDOW_MIN = 30;
19
+ /** How soon after one wait of a grant another is refused rather than taking over, in seconds (APC 0.8). */
20
+ export const WAIT_CLAIM_GAP_S = 2;
21
+ export const GRANT_REQUIREMENTS = ["stable_id", "revocable_in_app", "revoked_told_apart", "trimmed_per_grant"];
10
22
  /**
11
23
  * Revoke from the app. Every agent in the app's agent bar, Direct or Dock, has Revoke, and it holds
12
24
  * in the app alone:
13
25
  *
14
26
  * - It takes effect at once: the grant, its pin and (Dock) the app's link to the hub go. The app
15
27
  * calls nobody, so it works with the hub down.
16
- * - From then on every request to the grant's MCP URL, whatever the method, is answered with HTTP
17
- * `REVOKED_HTTP_STATUS` (403) and a `RevokedAnswer` body. A call already running when the grant is
18
- * revoked ends as a tool error whose `error` is `"revoked"`, with no app data. An unknown or
19
- * expired grant stays 404, so the holder can tell "taken back" from "gone". Whether a grant was
20
- * revoked is told by the grant itself (its secret), never by an id a later grant may reuse (0.7).
28
+ * - From then on every request with the grant, whatever the method, is answered with a `RevokedAnswer`
29
+ * body. The HTTP status is the binding's (APC 0.8, `GrantRequirement`): `REVOKED_HTTP_STATUS` (403)
30
+ * with a secret URL, `REVOKED_HTTP_STATUS_BEARER` (401, with `WWW-Authenticate: Bearer
31
+ * error="invalid_token"`) with OAuth. A call already running when the grant is revoked ends as a tool
32
+ * error whose `error` is `"revoked"`, with no app data. An unknown or expired grant is answered without
33
+ * a `RevokedAnswer` (404 with a secret URL; a plain 401 with OAuth), so the holder can tell "taken back"
34
+ * from "gone". Whether a grant was revoked is told by the grant itself (its secret or token), never by
35
+ * an id a later grant may reuse (0.7).
21
36
  * - A revoked grant never works again. To let the agent back, the person makes a new grant: a new
22
37
  * MCP URL (Direct), or a new drop, with consent in the app and in the dock (Dock).
23
38
  * - A hub that gets a `RevokedAnswer` marks that pairing revoked ("Revoked by <app host>"), stops
@@ -25,7 +40,10 @@ export const THEN_KEY = "then";
25
40
  * again by itself.
26
41
  */
27
42
  export const REVOKED_ERROR = "revoked";
43
+ /** The status of a revoked grant with the secret-URL binding. */
28
44
  export const REVOKED_HTTP_STATUS = 403;
45
+ /** The status of a revoked grant with a bearer token (OAuth), with `WWW-Authenticate: Bearer error="invalid_token"` (APC 0.8). */
46
+ export const REVOKED_HTTP_STATUS_BEARER = 401;
29
47
  export function revokedAnswer(host, revokedAt) {
30
48
  return {
31
49
  error: REVOKED_ERROR,
@@ -58,6 +76,75 @@ export function isRevokedAnswer(body) {
58
76
  */
59
77
  export const APC_MOBILE_MEDIA = "(pointer: coarse), (max-width: 640px)";
60
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
+ }
61
148
  /** The other path: Direct for Dock and Dock for Direct. */
62
149
  export function oppositePath(path) {
63
150
  return path === "direct" ? "dock" : "direct";
@@ -15,9 +15,16 @@
15
15
  * `"hub"` alone, so apps and tools can tell a dock from an app. Words a reader does not know are ignored.
16
16
  * - **`mcp.path`**: where its MCP URLs live. Every grant's MCP URL on this origin starts with
17
17
  * `origin + mcp.path`. A hub pairs only an MCP URL under it, so a path the domain did not declare in
18
- * its own `/.well-known/` (another tenant's, on a shared host) is never paired.
18
+ * its own `/.well-known/` (another tenant's, on a shared host) is never paired. Since APC 0.8 a Direct
19
+ * app whose grants share one MCP URL (a bearer token names the grant, as with OAuth) may serve it at
20
+ * `mcp.path` itself, or at `mcp.path` without its last `/` (`/api/mcp` for `"/api/mcp/"`); one origin
21
+ * may serve several such endpoints under the path, one per role (`declaresMcpUrl` with `shared`).
19
22
  * - **`direct.connect`** (with `"direct"`): the page, on the same origin, where the person connects an
20
23
  * agent directly: what a person is shown when they drop a dock agent on a Direct-only app.
24
+ * - **`direct.auth`** (APC 0.8, optional, information only): how a Direct agent authenticates, a short
25
+ * word such as `"url"` (the secret URL) or `"oauth"`; with OAuth, `direct.resource_metadata` may give
26
+ * the URL of its OAuth protected-resource metadata, on its origin. A reader never requires either: the
27
+ * standard does not fix how agents authenticate (`GrantRequirement`).
21
28
  * - **`dock`** (a hub): where it takes pairings, `pair` and `confirm`, as absolute URLs on its origin.
22
29
  *
23
30
  * A hub reads an app's `apc.json` when the app asks to pair (`POST {hub}/apc/pair`) and refuses the
@@ -50,9 +57,15 @@ export interface ApcManifest {
50
57
  mcp: {
51
58
  path: string;
52
59
  };
53
- /** With "direct": where the person connects an agent directly, on the same origin. */
60
+ /**
61
+ * With "direct": where the person connects an agent directly, on the same origin; and, as information
62
+ * only (APC 0.8), how agents authenticate (`auth`, e.g. "url" or "oauth") and, with OAuth, the URL of
63
+ * the protected-resource metadata (`resource_metadata`, on the same origin).
64
+ */
54
65
  direct?: {
55
66
  connect: string;
67
+ auth?: string;
68
+ resource_metadata?: string;
56
69
  };
57
70
  /** A hub's pairing endpoints, on its origin. */
58
71
  dock?: {
@@ -89,8 +102,14 @@ export declare function parseApcManifest(raw: unknown, origin: string): {
89
102
  * ones, which a server may decode afterwards: so the rest of the path after `mcp.path` may hold no
90
103
  * encoded slash, backslash or dot (`%2f`, `%5c`, `%2e`), no encoded percent (`%25`, a second round of
91
104
  * encoding), and no backslash.
105
+ *
106
+ * With `shared` (APC 0.8: a Direct endpoint whose grants share one URL, the credential naming the grant),
107
+ * the URL may also be `mcp.path` itself or `mcp.path` without its last `/`. Without it, as a hub pairing a
108
+ * Dock app checks, the URL must lie strictly under `mcp.path`: one URL per grant.
92
109
  */
93
- export declare function declaresMcpUrl(manifest: Pick<ApcManifest, "origin" | "mcp">, mcpUrl: string): boolean;
110
+ export declare function declaresMcpUrl(manifest: Pick<ApcManifest, "origin" | "mcp">, mcpUrl: string, options?: {
111
+ shared?: boolean;
112
+ }): boolean;
94
113
  /**
95
114
  * What an app tells a person who drops an agent from their dock on it when it connects agents only
96
115
  * directly (its `apc.json` lists no `"dock"`): the drop is answered, never lost. These are the app's
package/dist/manifest.js CHANGED
@@ -10,6 +10,7 @@ export const APC_DEFAULT_MCP_PATH = "/mcp/";
10
10
  export const APC_ROLES = ["direct", "dock", "hub"];
11
11
  const VERSION_RE = /^(0|[1-9]\d{0,3})\.(0|[1-9]\d{0,4})\.(0|[1-9]\d{0,5})$/;
12
12
  const PATH_RE = /^\/([A-Za-z0-9._~!$&'()*+,;=:@-]+\/)*$/;
13
+ const AUTH_RE = /^[a-z0-9][a-z0-9_-]{0,31}$/;
13
14
  const isObject = (v) => !!v && typeof v === "object" && !Array.isArray(v);
14
15
  /**
15
16
  * An absolute http(s) URL on `origin`, with no credentials, as its href; null otherwise. The scheme is
@@ -80,6 +81,13 @@ export function parseApcManifest(raw, origin) {
80
81
  if (!connect)
81
82
  return bad("direct.connect is not a URL on its origin");
82
83
  manifest.direct = { connect };
84
+ // Information only (APC 0.8): kept when well formed, otherwise left out, never a reason to refuse.
85
+ const direct = raw.direct;
86
+ if (typeof direct.auth === "string" && AUTH_RE.test(direct.auth))
87
+ manifest.direct.auth = direct.auth;
88
+ const metadata = onOrigin(direct.resource_metadata, origin);
89
+ if (metadata)
90
+ manifest.direct.resource_metadata = metadata;
83
91
  }
84
92
  if (profiles.includes("hub")) {
85
93
  const dock = isObject(raw.dock) ? raw.dock : {};
@@ -97,8 +105,12 @@ export function parseApcManifest(raw, origin) {
97
105
  * ones, which a server may decode afterwards: so the rest of the path after `mcp.path` may hold no
98
106
  * encoded slash, backslash or dot (`%2f`, `%5c`, `%2e`), no encoded percent (`%25`, a second round of
99
107
  * encoding), and no backslash.
108
+ *
109
+ * With `shared` (APC 0.8: a Direct endpoint whose grants share one URL, the credential naming the grant),
110
+ * the URL may also be `mcp.path` itself or `mcp.path` without its last `/`. Without it, as a hub pairing a
111
+ * Dock app checks, the URL must lie strictly under `mcp.path`: one URL per grant.
100
112
  */
101
- export function declaresMcpUrl(manifest, mcpUrl) {
113
+ export function declaresMcpUrl(manifest, mcpUrl, options = {}) {
102
114
  let url;
103
115
  try {
104
116
  url = new URL(mcpUrl);
@@ -108,9 +120,12 @@ export function declaresMcpUrl(manifest, mcpUrl) {
108
120
  }
109
121
  if (!urlOnOrigin(url.href, manifest.origin))
110
122
  return false;
111
- if (!url.pathname.startsWith(manifest.mcp.path) || url.pathname.length <= manifest.mcp.path.length)
123
+ const path = manifest.mcp.path;
124
+ if (options.shared && path !== "/" && (url.pathname === path || url.pathname === path.slice(0, -1)))
125
+ return !/%(2f|5c|2e|25)/i.test(mcpUrl.split(/[?#]/, 1)[0]);
126
+ if (!url.pathname.startsWith(path) || url.pathname.length <= path.length)
112
127
  return false;
113
- const rest = url.pathname.slice(manifest.mcp.path.length);
128
+ const rest = url.pathname.slice(path.length);
114
129
  // Checked on the URL as sent too: the parser turns some encoded dots into segments it then removes.
115
130
  const sent = mcpUrl.split(/[?#]/, 1)[0];
116
131
  const encoded = /%(2f|5c|2e|25)/i;
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.7.1";
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.7.1";
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.7.1",
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",