@gjermundgaraba/clankerauth-sdk 0.14.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -56,6 +56,8 @@ Register **one** resource in the clankerauth dashboard, identified by the applic
56
56
 
57
57
  A declared resource's `layer` takes the public URL and derives that identifier itself, so the application never writes it twice and never writes a form an MCP client would rewrite. Any path on the URL is discarded. The official client's own `checkResourceAllowed` accepts an origin-root resource for an endpoint beneath it.
58
58
 
59
+ The public URL is where the listener that publishes discovery is reached, because an OAuth client refuses metadata naming another resource than the one it reached (RFC 9728). API keys never read discovery, so they keep working whatever the URL says; only OAuth logins fail. The resource therefore logs one warning the first time a request reaches its publishing listener at another host. A proxy that rewrites the host makes that warning harmless; anything else means the URL is wrong.
60
+
59
61
  Contracts state who may call them, `caller: CurrentPrincipal`, and a binding names how a caller proves it: an effect-actions authentication descriptor, declared once beside the contracts, where a browser client imports it too. The descriptor also declares what its verifier fails with besides a refusal: `ProviderUnavailable`, the issuer being unreachable.
60
62
 
61
63
  ```ts
@@ -111,9 +113,9 @@ const routes = Layer.mergeAll(
111
113
 
112
114
  Serve this layer with Effect's `HttpRouter` and your Node server layer. If using `@effect/platform-node`, install the release that matches your Effect. The scopes are the same in every deployment, so they are declared with the resource; `NotesResource.layer` takes what varies by deployment and builds the resource once: its verifier and refusals. `NotesResource.provider` is the descriptor's provider, effect-actions' `Authentication.layer`: it verifies every protected route and tool call before decoding, and publishes discovery itself, public and outside authentication; there is no discovery route to merge. Discovery cache policy belongs to the host. Every response to a request it authenticates carries `Cache-Control: no-store`. Middleware of your own runs as effect-actions runs it: router middleware provided after the provider, or an HTTP layer's `middleware`.
113
115
 
114
- A surface whose callers hold API keys and never run an OAuth flow, such as a second listener beside the one that publishes the resource, sets `discovery: false`: it authenticates the same way, publishes nothing, and its challenge is a plain `Bearer`.
116
+ A surface whose callers hold API keys and never run an OAuth flow, such as a second listener beside the one that publishes the resource, sets `discovery: false`: it authenticates the same way, publishes nothing, and its challenge is a plain `Bearer`. It is the same registered resource, so it takes the publishing listener's public URL, not its own address: a public machine listener beside a private admin one still passes the admin URL.
115
117
 
116
- A process serving two resources, such as two listeners with different scopes, declares two descriptors and two resources: `Resource.make(Authentication.make("notes.admin", CurrentPrincipal, { error: ProviderUnavailable }), { scopes: ["notes:read", "notes:write"], required: "notes:read" })` and one for `"notes.machine"`, each named by its own binding. Each is a service of its own, so each surface reads its own resource, in one layer graph or several. The descriptor's name is the identity: declaring one name twice declares the same resource twice.
118
+ Two listeners with different scopes, such as that admin and machine pair, are still one resource in the dashboard, with every scope of both. The process declares two descriptors and two resources over it, one per listener, each requiring its own scope and given the same `layer` options but `discovery`: `Resource.make(Authentication.make("notes.admin", CurrentPrincipal, { error: ProviderUnavailable }), { scopes: ["notes:read", "notes:write"], required: "notes:read" })` and one for `"notes.machine"`, each named by its own binding. Each is a service of its own, so each surface reads its own resource, in one layer graph or several. The descriptor's name is the identity: declaring one name twice declares the same resource twice.
117
119
 
118
120
  A declared resource is a `Resource.Declared<"notes.Login">`, and what its `layer` builds a `Resource.Built`; its descriptor is a `Resource.Login<"notes.Login">`. `Declared` and `Login` take what the descriptor declares as an optional second parameter, `ProviderUnavailable` alone when left out. `Declared` and `Built` take the resource's scopes as an optional parameter, last, which `make` infers from `scopes`: an annotation that leaves it out accepts any scope string. The entry point also exports each type a resource's declarations mention under a flat name (`DeclaredResource`, `BuiltResource`, `ResourceService`, `ResourceDeclaration`, `ResourceOptions`, `Login`, `Admission`, `Refusal`, `Principal`, `Verifier`); `CurrentPrincipal` and its `Caller` are `/session`'s, where declarations name them. So a package that exports its descriptor, its resource or its implementations emits declarations without an annotation.
119
121
 
@@ -20,8 +20,10 @@ export declare const authenticate: (verifier: Verifier.Verifier, token: Redacted
20
20
  export interface Options {
21
21
  readonly issuer: string;
22
22
  /**
23
- * The origin browsers and agents reach this application at. The resource is its origin
24
- * root, so any path here is discarded: one identifier covers `/api`, `/mcp` and sockets.
23
+ * The origin browsers and agents reach this application at: the listener that publishes
24
+ * discovery. The resource is its origin root, so any path here is discarded: one identifier
25
+ * covers `/api`, `/mcp` and sockets. A request reaching that listener at another host is
26
+ * logged once as a warning, since an OAuth client discovering the resource there refuses it.
25
27
  */
26
28
  readonly publicUrl: URL;
27
29
  /** `false` refuses API keys without reading the issuer's key list; only OAuth access tokens are accepted. */
@@ -29,7 +31,8 @@ export interface Options {
29
31
  /**
30
32
  * `false` publishes no RFC 9728 discovery and names none in a challenge: for a surface
31
33
  * whose callers hold API keys and never run an OAuth flow, such as a second listener
32
- * beside the one that publishes the resource.
34
+ * beside the one that publishes the resource. That listener still names the same resource,
35
+ * so it takes the publishing listener's `publicUrl`, not its own.
33
36
  */
34
37
  readonly discovery?: boolean;
35
38
  }
package/dist/resource.js CHANGED
@@ -1,7 +1,7 @@
1
- import { Clock, Context, Duration, Effect, Layer, Option, Redacted, Result } from "effect";
1
+ import { Clock, Context, Duration, Effect, Layer, Option, Redacted, Ref, Result } from "effect";
2
2
  import * as Action from "@gjermundgaraba/effect-actions/Action";
3
3
  import * as Authentication from "@gjermundgaraba/effect-actions/Authentication";
4
- import { HttpRouter, HttpServerResponse } from "effect/http";
4
+ import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/http";
5
5
  import { InsufficientScope, ProviderUnavailable, Unauthorized } from "./errors.js";
6
6
  import { keyListRefresh } from "./refresh.js";
7
7
  import { CurrentPrincipal, Whoami } from "./session.js";
@@ -160,7 +160,24 @@ export const make = (authentication, declaration) => {
160
160
  // `E` declares `ProviderUnavailable`, which a generic `E` cannot show the types by itself.
161
161
  const declaring = authentication;
162
162
  // The descriptor's verifier: a token to its principal, a refusal, or `ProviderUnavailable`.
163
- const provider = Authentication.layer(declaring, Effect.map(service, ({ verifier }) => (token) => authenticate(verifier, token)), { protectedResource: Effect.map(service, ({ protectedResource }) => protectedResource) });
163
+ const provider = Authentication.layer(declaring, Effect.gen(function* () {
164
+ const { verifier, resource, protectedResource } = yield* service;
165
+ const host = new URL(resource).host;
166
+ const warned = yield* Ref.make(protectedResource === undefined);
167
+ // RFC 9728 has a client refuse metadata naming another resource than the one it
168
+ // reached, so a listener publishing discovery at another host fails every OAuth login
169
+ // while keys keep working. A proxy may rewrite the host, so this warns and refuses nothing.
170
+ const noteHost = Effect.gen(function* () {
171
+ if (yield* Ref.get(warned))
172
+ return;
173
+ const reached = (yield* HttpServerRequest.HttpServerRequest).headers.host;
174
+ if (reached === undefined || reached === host)
175
+ return;
176
+ yield* Ref.set(warned, true);
177
+ yield* Effect.logWarning(`The resource ${resource} publishes discovery, but a request reached it at ${reached}: an OAuth client discovering it there refuses the mismatch. Set publicUrl to the origin this listener is reached at.`);
178
+ });
179
+ return (token) => Effect.andThen(noteHost, authenticate(verifier, token));
180
+ }), { protectedResource: Effect.map(service, ({ protectedResource }) => protectedResource) });
164
181
  /**
165
182
  * Refuse a caller that lacks `scope`, as the application's authorizer decides:
166
183
  * `Action.implement(actions, handlers, { authorize: (action) => action.readOnly ? Effect.void : Notes.requires("notes:write") })`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjermundgaraba/clankerauth-sdk",
3
- "version": "0.14.0",
3
+ "version": "0.16.0",
4
4
  "description": "Effect-native clankerauth access-token and offline API-key verification, with optional effect-actions integration",
5
5
  "license": "MIT",
6
6
  "repository": {