@akanjs/cli 3.0.0-alpha.101 → 3.0.0-alpha.103

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/.build-stamp CHANGED
@@ -1 +1 @@
1
- 5ffeff9d981fb5e862b8dfe8607caec6785dc791950ba23d8338166ad32d1f62
1
+ 708d95c2692df8b7a0bdb905802d48d38352e0305215030af6c3d6aa468a9460
@@ -131,6 +131,11 @@ apps and libs never import it directly (`no-import-external-library`) — everyt
131
131
  `data-agent-zone` container; guides follow the layout cascade (ancestors and own, never a sibling's). Zone
132
132
  membership is positional — there is no per-declaration zone key, so a lib component joins whatever zone the app
133
133
  mounts it in.
134
+ - **Two agents' turns run in parallel; their tool calls take turns.** Tool execution is serialized across the
135
+ whole page, because `AgentAbort` and `AgentProgress` reach the running call through a module slot rather than a
136
+ parameter — two calls in flight restore each other's slot and the second one's progress goes nowhere. Only the
137
+ execution queues: a zone parked on an approval card holds nothing up, since waiting for the user sits outside
138
+ the queue, and a call that is aborted releases it.
134
139
  - **Everything a zone publishes is named `<id>.<name>`,** and that name is what instructions must use. A bare
135
140
  `createVideoProject` inside a zone naming its tool `videoProjectDraft.createVideoProject` is a tool that does
136
141
  not exist: the model calls it, the surface answers `Unknown tool`, and a turn is gone. Build the name from the
@@ -438,16 +443,17 @@ apps and libs never import it directly (`no-import-external-library`) — everyt
438
443
  row, Tab completing its name, and Escape closing the menu and then, pressed again, the panel.
439
444
 
440
445
  ## Built-In Tools
441
- - The framework publishes five built-ins on every store surface: `navigate` (internal paths only, the same
442
- router `Link` rides), `goBack` (this session's history — global, because history is not a control a page owns and
443
- a page that draws no back link is not one you may not leave — a screen that must not be left withholds them
444
- with `builtins`, not with prose), `readScreen` (the rendered DOM as compact text —
445
- headings, links, control values, and `(disabled)` on a control or button that has it; the chat's own UI is
446
- skipped via `data-agent-ui`, and a password value is never read), `readState(key)` (one masked store key), and
447
- `highlight(target)`. Declaring a hook tool under one of those names shadows the built-in, so reuse them only to
448
- mean that. **There is no general-purpose wait**: a built-in one was reachable on every screen and a model spent
449
- it on whatever key it liked, parking turns nobody asked to park. Waiting belongs to the screen that knows what
450
- is worth waiting for — publish an `st.tool` beside the control that starts the work, and let it await the work.
446
+ - The framework publishes five built-ins on every store surface: `navigate` (internal paths only, the same router
447
+ `Link` rides), `goBack` (this session's history — global, because history is not a control a page owns and a page
448
+ that draws no back link is not one you may not leave — a screen that must not be left withholds them with `builtins`,
449
+ not with prose), `readScreen` (the rendered DOM as compact text — headings, links, control values, and `(disabled)`
450
+ on a control or button that has it; the chat's own UI is skipped via `data-agent-ui`, and a password value is never
451
+ read), `readState(key)` (one masked store key — a key that is unknown or that this screen does not read is refused
452
+ *with the keys it can* read, the way `readScreen` names its sections), and `highlight(target)`. Declaring a hook tool
453
+ under one of those names shadows the built-in, so reuse them only to mean that. **There is no general-purpose wait**:
454
+ a built-in one was reachable on every screen and a model spent it on whatever key it liked, parking turns nobody
455
+ asked to park. Waiting belongs to the screen that knows what is worth waiting for — publish an `st.tool` beside the
456
+ control that starts the work, and let it await the work.
451
457
  - **A tool that changes the screen waits for the screen before it answers.** `router.push` returns while the RSC
452
458
  payload is still in flight and a store action that fires `void fetch.*` commits a tick later, so `navigate`
453
459
  awaits `ScreenSettle.wait()` — DOM quiescence, bounded, because the client router hands its promise to nobody —
@@ -470,7 +470,7 @@ Full contract — credential handshake, room revalidation, socket cleanup scopin
470
470
  - **The guards are also the MCP exposure decision** — see MCP Exposure. An endpoint that names none is not published to agents at all, and a mutation whose only guard is `Public` is refused, so a missing `guards` array now costs visibility as well as authorization.
471
471
  - Resource guards are `Can<Verb><Model>` classes in `srvkit/guards.ts` that `implements Guard` with an `async canPass(context)`. They **fail closed**: no resource named ⇒ `false`; a load that throws ⇒ `logger.warn` then `false`. Admin bypass goes first.
472
472
  - Keep `static name = "User";` on guard classes. `fetch` serializes guard names and the API explorer filters on them; it looks like dead code, and deleting it breaks the UI. Comment it so the next reader knows.
473
- - **Every guard class also declares `static scope: GuardScope`, and it is required with no default.** `"account"` means the verdict reads the caller and nothing about the call, so it can be evaluated with no arguments — which is what lets an MCP listing hide what this caller certainly cannot use. `"resource"` means it needs the call's arguments (`context.getArg()`) and fails closed without them, so it is never evaluated for a listing: the entry stays visible and is stopped at call time. Getting it wrong is not a type error, so the marker is mandatory rather than defaulted — `SignedIn` / `Admin` / role checks are `"account"`, and every `Can<Verb><Model>` is `"resource"`.
473
+ - **Every guard class also declares `static scope: GuardScope`, and it is required with no default.** `"account"` means the verdict reads the caller and nothing about the call, so it can be evaluated with no arguments — which is what lets an MCP listing hide what this caller certainly cannot use. `"resource"` means it needs the call's arguments (`context.getArg()`) and fails closed without them, so it is never evaluated for a listing: the entry stays visible and is stopped at call time. Getting it wrong is not a type error, so the marker is mandatory rather than defaulted — `SignedIn` / `Admin` / role checks are `"account"`, and every `Can<Verb><Model>` is `"resource"`. An `"account"` guard that throws at listing time reached for arguments it does not have: the entry is hidden from every caller and the guard is named once in the log as mismarked. A guard that admits no model at all — `Person` — adds `static agents = false`; the MCP catalogue then refuses every endpoint it guards outright, so the person-only act is absent from the document rather than hidden per caller.
474
474
  - The acting user arrives via `.with(Self)` / `.with(CurrentUserId)` / `.with(Me)`. Never trust a client-supplied id.
475
475
  - Guards ship with the library that owns the model and are imported by its own signals through the package path, so a mounting app inherits authorization and cannot forget it.
476
476
  - Services re-check ownership even when a guard already gated the call — two independent gates.
@@ -617,22 +617,38 @@ it.
617
617
  slice and generated CRUD only, never a named slice or a custom endpoint. It is an opt-**out**, and it is
618
618
  curation, not authorization: HTTP serves the endpoint exactly as before.
619
619
  - **Narrow by cost, and read the boot log first.** MCP forbids a `$ref` across entries, so every entry inlines the
620
- full schema of every model it mentions and the listing is re-sent whole to every agent that connects. A plain
621
- 21-field model costs 12.6KB over eight entries; `mcp: { cru: false }` takes it to 4.7KB. `MCP catalogue: … ·
622
- listing 214KB` plus a per-signal `MCP catalogue cost:` line says where it went.
620
+ schema of every model it mentions and the listing is re-sent whole to every agent that connects. By default a
621
+ response schema names a nested model instead of inlining it (`outputSchema: "shallow"`; `"full"` restores the
622
+ closure, `"none"` drops the schema and keeps the text block) and a request schema asks for a relation's id, which
623
+ is what the wire carries. `MCP catalogue: … · listing 214KB` plus a per-signal `MCP catalogue cost:` line says
624
+ where the rest went; `mcp: { cru: false }` on a model is the next lever. `outputSchema` moves wire bytes and client
625
+ memory only — the model receives names, descriptions and input schemas (measured: 219 tools ≈ 41k tokens either
626
+ way) — so tool count and the input model behind `create*` / `update*` are what cost context. In one line:
627
+ `Person` for an act reserved for a person, `mcp: { cru: false }` for the tools whose input models cost the model
628
+ tokens, `outputSchema` for nothing that reaches the model.
623
629
  - Settings live in the app's `lib/option.ts` — `option.setMcp({ enabled, readOnly, path, version, instructions,
624
- allowedOrigins, pageSize, language, auth })`, or `setMcp((env) => …)` for one derived from the server env, **not
630
+ allowedOrigins, pageSize, language, outputSchema, rateLimit, auth })`, or `setMcp((env) => …)` for one derived from the server env, **not
625
631
  `main.ts`**. Each field has an `AKAN_MCP_*` env spelling. `AKAN_MCP_READONLY=true` is the read-only-deployment
626
- valve, not the exposure switch; `AKAN_MCP=false` takes the whole surface off.
632
+ valve, not the exposure switch; `AKAN_MCP=false` takes the whole surface off. Every call that executes an
633
+ endpoint is rate-limited per caller (120 a minute, 8 in flight, per process; 429 + `Retry-After`) — `rateLimit`
634
+ / `AKAN_MCP_RATE_LIMIT` tunes or disables it.
627
635
  - **Authentication is the same server.** An app mounting `libs/shared` serves the OAuth 2.1 authorization server
628
636
  beside `/mcp` — `/.well-known/oauth-authorization-server`, `/oauth/authorize`, `/oauth/token`, `/oauth/register`
629
637
  (RFC 7591; Cursor's `cursor://` redirect admitted) plus the consent page `libs/shared/page/oauth/consent` — and
630
638
  names itself as the issuer, so `/mcp` demands a bearer token, verifies its signature, and refuses one carrying no
631
639
  `aud`. A token is the app's own access JWT plus `aud`/`iss`/`client_id`, so `AccountMiddleware` and the guards
632
640
  judge it unchanged; there is no scope. Per-app knobs live in `env.server.*` under `oauth` (`consentPath` and
633
- `signinPath` with the basePath, `clients`, `dynamicRegistration`, `allowedRedirectSchemes`, `enabled`). `/mcp`
634
- reads the `Authorization` header only — a cookie is dropped at the door — and `JWT_SECRET` is mandatory outside
635
- `local`, because the derived fallback is forgeable from three public names.
641
+ `signinPath` with the basePath, `clients`, `dynamicRegistration`, `allowedRedirectSchemes`, `clientIdMetadata`,
642
+ `enabled`). `/mcp` reads the `Authorization` header only — a cookie is dropped at the door — and `JWT_SECRET` is
643
+ mandatory outside `local`, because the derived fallback is forgeable from three public names. A grant is revoked
644
+ whole — `POST /oauth/revoke` (RFC 7009) by the client, `fetch.revokeOAuthConnection(sessionId)` by the account's
645
+ owner, `fetch.listOAuthConnections()` to see them — and a revoked access token is refused at its next call.
646
+ - **A person may, a model may not.** An agent's token names its `client_id` and `aud`; a browser session has
647
+ neither, and `context.origin` is `"mcp"` for a call through the MCP endpoint. `guards: [Every, Person]` refuses a
648
+ model and, because `Person` declares `static agents = false`, takes the entry out of the MCP catalogue document
649
+ itself (named in the boot log like `mcp: false`); `.with(AgentCall)` hands an endpoint the same verdict as a
650
+ boolean to narrow what the call sets in motion; `isAgentCall(context)` is the function behind both
651
+ (`@libs/shared/srvkit`). Never sniff `aud` through a cast.
636
652
  - **The refusals are fail-closed**: a declared `mcp: false`, an endpoint with no `guards`, a mutation with no real
637
653
  guard, `pubsub` and `message`, an `Any` or `Upload` return, a file upload, a required `Any` argument, and the
638
654
  generated `light<Model>` read. A `prompt` also refuses a list argument and any `Any` argument. **Every refusal
@@ -43,17 +43,21 @@ export class FileSlice extends slice(srv.file, {
43
43
  the guard rules keep out.
44
44
  - **Reach for it by cost, not by taste.** Every entry inlines the schema of every model it mentions, so a plain
45
45
  21-field model with one named slice costs 12.6KB across its eight entries. `mcp: { cru: false }` on that model
46
- takes it to 4.7KB — the write entries carry both the input *and* the full model, and are 63% of it.
46
+ takes it to 4.7KB — the write entries carry both the input *and* the full model, and are 63% of it. Those figures
47
+ are for `outputSchema: "full"`; the default names nested models instead of inlining them (below). Measured on a
48
+ fully open 347-tool catalogue: 1194KB before, 532KB under the default, 194KB under `"none"`, and 1046KB even under
49
+ `"full"` — the id and nullable savings apply in every mode — before any endpoint was curated.
47
50
 
48
51
  ## Configuration
49
52
  Settings live in the app's `lib/option.ts` — `option.setMcp({ … })`, taking `enabled`, `readOnly`, `path`,
50
- `version`, `instructions`, `allowedOrigins`, `pageSize`, `language`, `legacyTextBlock`, and `auth`. **Not `main.ts`**: the gateway
53
+ `version`, `instructions`, `allowedOrigins`, `pageSize`, `language`, `legacyTextBlock`, `outputSchema`, `rateLimit`, and `auth`. **Not `main.ts`**: the gateway
51
54
  there only spawns children, and `option.ts` is the app-authored file `server.ts` already hands to the process that
52
55
  mounts `/mcp`. Every lib's option is read in mount order with the app's last, so an app tightens what a library
53
56
  declared without restating it. Each field also has an env spelling (`AKAN_MCP`, `AKAN_MCP_READONLY`,
54
57
  `AKAN_MCP_PATH`, `AKAN_MCP_VERSION`, `AKAN_MCP_INSTRUCTIONS`, `AKAN_MCP_ALLOWED_ORIGINS`, `AKAN_MCP_PAGE_SIZE`,
55
- `AKAN_MCP_LANGUAGE`, `AKAN_MCP_LEGACY_TEXT`, `AKAN_MCP_AUTH_SERVERS`, `AKAN_MCP_SCOPES`, `AKAN_MCP_RESOURCE`) for a
56
- deployment that configures what the source does not, which the option overrides.
58
+ `AKAN_MCP_LANGUAGE`, `AKAN_MCP_LEGACY_TEXT`, `AKAN_MCP_OUTPUT_SCHEMA`, `AKAN_MCP_RATE_LIMIT`, `AKAN_MCP_CONCURRENT`,
59
+ `AKAN_MCP_AUTH_SERVERS`, `AKAN_MCP_SCOPES`, `AKAN_MCP_RESOURCE`) for a deployment that configures what the source does
60
+ not, which the option overrides.
57
61
  The two booleans answer to `AKAN_PUBLIC_MCP` / `AKAN_PUBLIC_MCP_READONLY` too, the same pairing `AKAN_OPENAPI`
58
62
  has, and a value written in code wins over the env of the same name — an explicit `undefined` is not a value.
59
63
  `AKAN_MCP_PATH` is normalized to a leading `/`, because the route key and the OAuth metadata path are both built by
@@ -143,6 +147,32 @@ export class TaskEndpoint extends endpoint(srv.task, ({ mutation, prompt }) => (
143
147
  `AKAN_MCP_LEGACY_TEXT=false` — leaves a one-line pointer in the text block instead, for a deployment whose
144
148
  clients read the structured half. A scalar return is unaffected: it has no structured half to point at, so it
145
149
  ships as the value itself either way.
150
+ - **A request schema asks for a relation's id, and a response schema names a nested model.** `serialize` sends a
151
+ relation field as its id and the server never converts an object back, so publishing the related model's shape
152
+ asked an agent for what the document layer cannot take. On the response side every entry inlines the transitive
153
+ closure of every model it mentions (a `$ref` may not cross entries), and on an open catalogue 83% of the bytes
154
+ were the same schemas repeated — so a nested database model is published as `{ type: "object", description:
155
+ "<ModelName>" }` and the returned model keeps every field of its own; an embedded scalar stays inline. Measured,
156
+ that took a 347-tool listing from 1194KB to 532KB, and one user's paged `tools/list` to 320KB.
157
+ `option.setMcp({ outputSchema: "full" })` (`AKAN_MCP_OUTPUT_SCHEMA`) restores the closure; `"none"` publishes no
158
+ `outputSchema` at all — results still ship as `structuredContent`, and the text block stays on whatever
159
+ `legacyTextBlock` said, because a client validates structured content only against a declared schema. Inside a
160
+ `$defs` an id carries no `pattern` and nullability rides in `type: [T, "null"]` (an enum lists `null` too); a
161
+ top-level argument keeps the spellings the OpenAPI document uses. **Know which bytes reach the model.** The
162
+ provider tool definition a client builds from a listing carries the name, the description and the input schema —
163
+ the Anthropic and OpenAI tool formats have no field for an output schema — so `outputSchema` is a wire and
164
+ client-memory cost, and the model's context is spent on names, descriptions, input schemas and the number of
165
+ tools. Curate those (`mcp: false`, `mcp: { cru: false }`, `readOnly`) to cut context; pick `outputSchema` for the
166
+ transfer.
167
+ Measured on a 219-tool user listing: the wire carried 366KB, the client reported ≈41.6k tokens for it, and the
168
+ server's own count of names + descriptions + input schemas was 95KB — the same 41k at 2.4 characters per token.
169
+ So `outputSchema` (`full` / `shallow` / `none`) moves wire bytes and client memory and **not one model token**;
170
+ do not tune it expecting a context win. What does cost context: the tool count, and the input model inlined
171
+ behind every `create*` / `update*` (22 such tools were 8.7k of those 41k; the heaviest single tool was
172
+ `updateUser` at ~900 tokens) — `mcp: { cru: false }` on a model whose writes an agent has no business with is
173
+ the lever that pays. A custom mutation whose arguments are plain ids is cheap (nine contract tools, 1.2k). In
174
+ one line: `Person` for an act reserved for a person, `mcp: { cru: false }` for the tools whose input models cost
175
+ the model tokens, and `outputSchema` for nothing that reaches the model at all.
146
176
  - A refused endpoint answers the *same* "unknown tool" as one that does not exist. Never make that
147
177
  message more helpful — the difference is what enumerates your private surface. Every 401 and 403 is generalized
148
178
  the same way, the framework's `Access denied by guard: Admin` and an app's `No authentication with roles: admin`
@@ -168,9 +198,19 @@ export class TaskEndpoint extends endpoint(srv.task, ({ mutation, prompt }) => (
168
198
  `akan quality scan` rule for this any more: a source scanner found the exposure only as an `mcp:` literal, and
169
199
  with exposure derived from the guards the resolved catalogue is the only place that can answer.
170
200
  - A browser-hosted client needs `allowedOrigins` **and** the CORS answer the server sends back for those origins.
171
- Every other MCP client sends no `Origin` at all, and the one that does is matched against the forwarded host so
172
- a proxy does not turn each call into a 403 — which is only as trustworthy as an edge that *overwrites* that
173
- header. `AKAN_MCP_RESOURCE` pins the resource identifier where you cannot guarantee it.
201
+ Every other MCP client sends no `Origin` at all, and the one that does is matched against the server's public
202
+ origin: the configured `auth.resource`'s when there is one (`libs/shared` derives it from the issuer, so a mounted
203
+ app is always pinned), and only otherwise the forwarded host — which is as trustworthy as an edge that
204
+ *overwrites* that header. The same origin names the `resource_metadata` URL in every 401 challenge.
205
+ `AKAN_MCP_RESOURCE` is the pin for an app that mounts no authorization server.
206
+ - **Every call that executes an endpoint is rate-limited per caller** — `tools/call`, `resources/read` and
207
+ `prompts/get`, never a listing. A caller is the bearer's session (`sid`), so one connector counts as one however
208
+ many connections it opens; a credential-less caller is the address the proxy recorded, and callers with none share
209
+ one bucket. Defaults are 120 calls a minute and 8 in flight, counted per process (N replicas grant N budgets); past
210
+ either the answer is HTTP 429 with JSON-RPC `-32010` and a `Retry-After`, and an unknown tool name is counted like
211
+ any other call. `option.setMcp({ rateLimit: { calls, windowMs, concurrent } })`, `rateLimit: false` to turn it off;
212
+ `AKAN_MCP_RATE_LIMIT=<calls>` or `<calls>/<seconds>` (`off` disables) and `AKAN_MCP_CONCURRENT`. The boot log says
213
+ which is in force, and warns when it is off.
174
214
  - **A `resources/read` uri that does not decode** — a stray `%` — is `Unknown resource`, not a server failure.
175
215
  - **A caller's own mistake is reported as one** and never as a server failure: an argument that is missing,
176
216
  unparseable or **undeclared** comes back as `isError` naming it — `additionalProperties: false` travels in the
@@ -210,9 +250,35 @@ delegates to lives in `akanjs/server` (`OAuthAuthorize`, `OAuthToken`, `OAuthReg
210
250
  `OAuthPkce`, `OAuthRedirect`, `OAuthMetadata`, `OAuthErrors`) and is unit-tested without a container.
211
251
 
212
252
  - **Endpoints, at the origin's root:** `GET /.well-known/oauth-authorization-server`, `GET /oauth/authorize`,
213
- `POST /oauth/token` (form-encoded), `POST /oauth/register`. The consent step is `libs/shared/page/oauth/consent`
253
+ `POST /oauth/token` (form-encoded), `POST /oauth/register`, `POST /oauth/revoke` (RFC 7009, form-encoded, the
254
+ same client authentication as the token endpoint). The consent step is `libs/shared/page/oauth/consent`
214
255
  — a server-rendered page whose two forms post to `/api/approveOAuthConsent/<requestId>` and
215
- `/api/denyOAuthConsent/<requestId>` and follow the 302 back to the client. All of it is `mcp: false`.
256
+ `/api/denyOAuthConsent/<requestId>` and follow the 302 back to the client. The account's own controls are
257
+ `fetch.listOAuthConnections()` (one entry per live grant: `sessionId`, `clientId`, `clientName`, `userAgent`,
258
+ `createdAt`, `expiresAt`, `isCurrent`) and `fetch.revokeOAuthConnection(sessionId)`; the app renders the
259
+ connected-apps page, the lib supplies the two endpoints and the `oauth.connectedApps` / `disconnect` /
260
+ `noConnectedApps` / `currentConnection` translations. All of it is `mcp: false`.
261
+ - **Revocation closes a grant, not a token.** A refresh token or an access token both name one grant (the access
262
+ token carries the lineage as `sid`); revoking either closes the refresh lineage and denylists the lineage id for
263
+ an access token's lifetime, so a stateless token dies early — `/mcp` answers 401 `invalid_token` and
264
+ `AccountMiddleware` treats it as anonymous, at the next call. `/oauth/revoke` answers an empty 200 whether the token
265
+ was live, unknown, or another client's (RFC 7009 §2.2 — it must not double as a token oracle); only a failed client
266
+ authentication is 401. Changing a password still revokes every session, browser ones included; disconnecting an
267
+ app revokes that one.
268
+ - **A person may, a model may not.** The one honest marker of "a model is driving this call" is on the token: an
269
+ access token minted here names its `client_id` and the MCP resource as `aud`, and a browser session carries
270
+ neither; `SerAccount` declares those claims, so nothing casts to read them. Read it three ways, all in
271
+ `@libs/shared/srvkit`: `.with(AgentCall)` hands the endpoint a boolean (drop `sendEmails` on an agent's say-so);
272
+ `guards: [Every, Person]` refuses a model outright and, being `account`-scoped, hides the entry from every MCP
273
+ listing (signing, paying, anything the page treats as a deliberate act); `isAgentCall(context)` inside a guard or
274
+ service. All three read `context.origin === "mcp"` first — a field on `SignalContext`, never the trace, which can
275
+ be absent — and the token second, so an agent's token used over plain HTTP is still an agent. `Person` also
276
+ declares **`static agents = false`**, the guard-level marker the catalogue reads: an endpoint it guards is refused
277
+ from the document itself (named in the boot log like `mcp: false`, with the guards' `static name`s — the explorer's
278
+ filter labels, which need not be the class names), not merely
279
+ hidden per caller at listing time — so the boot count, the size line and every agent's listing agree, and no
280
+ second flag is needed. Any guard that admits no model may declare the marker; a listing still evaluates
281
+ `account`-scoped guards per caller, so the two mechanisms compose.
216
282
  - **The flow:** `POST /mcp` with no token → 401 with `resource_metadata` → the client reads the PRM, then the AS
217
283
  metadata, registers (or uses a pre-registered / URL `client_id`), opens `/oauth/authorize` in a browser → the
218
284
  server parks the request and redirects to the consent page (via `signinPath?redirect=` when anonymous) → the user
@@ -224,13 +290,18 @@ delegates to lives in `akanjs/server` (`OAuthAuthorize`, `OAuthToken`, `OAuthReg
224
290
  and is decided once; a code lives sixty seconds and is consumed on first exchange even if that exchange fails; a
225
291
  redirect URI must be registered and match exactly, loopback ports excepted (RFC 8252 §7.3); a refresh token is
226
292
  bound to the client it was issued to. A `client_id` that is an HTTPS URL with a path is fetched as a metadata
227
- document, never from the server's own network.
293
+ document: the name is judged first (no address literal, no reserved suffix, no port), then resolved, and refused
294
+ when any address is private, loopback, link-local, CGNAT or multicast — the rebinding window between lookup and
295
+ fetch is what the deployment's egress policy still owns.
228
296
  - **Per-app configuration** goes in `env.server.*` under `oauth`: `consentPath` and `signinPath` (with the basePath
229
297
  when the app has one — `/office/oauth/consent`), `clients` (pre-registered, with an optional `clientSecret`),
230
298
  `dynamicRegistration` (on by default: Claude Code and claude.ai register themselves), `allowedRedirectSchemes`
231
- (`["cursor"]` by default, for Cursor's desktop callback), `accessTokenSeconds` (3600), `issuer` and `resource` (only when a tunnel or an edge
232
- makes the derived origin wrong), `enabled: false` to take the server and the `/mcp` credential requirement away
233
- together. The signin page must honour `?redirect=` to return the user to the consent page.
299
+ (`["cursor"]` by default, for Cursor's desktop callback), `accessTokenSeconds` (3600, also how long a revoked
300
+ access token can outlive its grant), `clientIdMetadata: { enabled, refusePrivateAddresses }` (both on; `enabled:
301
+ false` turns Client ID Metadata Documents off and the metadata stops advertising them — Claude Code then registers
302
+ dynamically instead), `issuer` and `resource` (only when a tunnel or an edge makes the derived origin wrong),
303
+ `enabled: false` to take the server and the `/mcp` credential requirement away together. The signin page must
304
+ honour `?redirect=` to return the user to the consent page.
234
305
  - **Clients:** Claude Code — `claude mcp add --transport http <name> <origin>/mcp` and nothing else; it registers,
235
306
  opens the browser to `http://localhost:<port>/callback`, refreshes on 401. claude.ai — a custom connector by URL
236
307
  (needs public HTTPS; DCR or the Advanced-settings client id/secret; callback
@@ -311,6 +382,7 @@ the same guards.** What stays closed is closed because no agent has a reason to
311
382
  | Authentication, SSO, and the OAuth protocol endpoints | Already `mcp: false` where they are declared. |
312
383
  | File upload, an `Any` / `Upload` return, a required `Any` argument, `pubsub` / `message`, `light<Model>` | Refused by the framework; nothing to write. |
313
384
  | Admin-only endpoints | Leave the `Admin` guard; an account-scope guard hides the entry from every caller it refuses. |
385
+ | An act only a person may take (signing, paying, sending to a customer) | `guards: [Every, Person]` refuses every agent and, because `Person` declares `static agents = false`, takes the entry out of the catalogue document — no `mcp: false` needed, the HTTP route untouched. When a person and a model may both call but not to the same effect, keep the guards and take `.with(AgentCall)` to narrow what the call sets in motion. |
314
386
 
315
387
  - **Scope is what makes hiding possible.** `static scope: "account"` (a role check) is evaluated with no arguments
316
388
  and filters the listing per caller; `"resource"` (`Can<Verb><Model>`, ownership) needs `context.getArg()`, stays
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akanjs/cli",
3
- "version": "3.0.0-alpha.101",
3
+ "version": "3.0.0-alpha.103",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -33,7 +33,7 @@
33
33
  "@langchain/openai": "^1.4.6",
34
34
  "@tailwindcss/node": "^4.3.0",
35
35
  "@trapezedev/project": "^7.1.4",
36
- "akanjs": "3.0.0-alpha.101",
36
+ "akanjs": "3.0.0-alpha.103",
37
37
  "chalk": "^5.6.2",
38
38
  "commander": "^14.0.3",
39
39
  "dayjs": "^1.11.20",