Writing an adapter
An adapter translates one provider SDK onto Anchor's session contract and nothing more. It owns no policy: revisions, commits, session keys, token sharing, timeouts and errors all live in the core, and the core never branches on an adapter's name. Before writing one, check whether the provider speaks OpenID Connect, which the OpenID Connect adapter serves, or whether the HTTP adapter over your own client is enough.
The contract
An adapter is a plain { name, native, tokens, open }. open(observer) installs the provider's listeners synchronously and returns the session: read, and where the provider supports them, getToken, end and dispose.
import { AnchorError } from "@priemskiyyy/anchor";
import type {
SessionAdapter,
SessionAnswer,
SessionIdentity,
} from "@priemskiyyy/anchor";
export type AcmeUser = { id: string; name: string };
export type AcmeAuthLike = {
currentUser: () => Promise<AcmeUser | null>;
onChange: (listener: () => void) => () => void;
getAccessToken: (options: {
userId: string;
forceRefresh: boolean;
}) => Promise<string>;
signOut: () => Promise<void>;
};
const ACME_AUTHORITY = "https://id.acme.example";
const getSessionAnswer = (user: AcmeUser | null): SessionAnswer<AcmeUser> => {
if (user === null) {
return { status: "signed-out" };
}
return {
status: "signed-in",
principal: { authority: ACME_AUTHORITY, subject: user.id, tenant: null },
user: { id: user.id, name: user.name },
context: null,
guest: false,
impersonated: false,
};
};
const isSessionUser = (user: AcmeUser, { principal }: SessionIdentity) =>
user.id === principal.subject;
export const acme = ({
sdk,
}: {
sdk: AcmeAuthLike;
}): SessionAdapter<AcmeUser, AcmeAuthLike, "api"> => ({
name: "acme",
native: sdk,
tokens: { api: {} },
open: (observer) => {
const unsubscribe = sdk.onChange(() => observer.invalidate());
return {
read: async () => getSessionAnswer(await sdk.currentUser()),
getToken: async ({ session, forceRefresh }) => {
const user = await sdk.currentUser();
if (user === null || !isSessionUser(user, session)) {
throw new AnchorError({
code: "SESSION_CHANGED",
message: "Acme holds another user now.",
});
}
const value = await sdk.getAccessToken({
userId: user.id,
forceRefresh,
});
return { value, kind: "access-token", scheme: "Bearer" };
},
end: async ({ session }) => {
const user = await sdk.currentUser();
if (user === null || !isSessionUser(user, session)) {
return {
local: "not-applicable",
remote: "not-applicable",
navigation: null,
};
}
await sdk.signOut();
return { local: "completed", remote: "unknown", navigation: null };
},
dispose: unsubscribe,
};
},
});tokens declares the profiles by name; the core reads only the names, and the adapter keeps whatever it needs per profile as the value. An adapter whose SDK describes the change it reports hands the core a complete answer with observer.session(answer); one that can only notice a change, as here, calls observer.invalidate() and the core reads.
The rules
- The factory is cold. Creating the adapter reads nothing, subscribes to nothing and calls nothing. It may run at module scope and on a server.
- Take the SDK the application set up. The option is
sdk, typed structurally as<Name>Like, naming only what the adapter calls. Never import the provider at runtime, and never initialize, load or close it. A typecheck-only<name>.contracts.tsassigns the real SDK to the structural type, provesnativekeeps the real type, and refuses a wrong object with@ts-expect-error. openis synchronous. It installs the provider's listeners and returns the session. It waits for nothing: readiness, such asauthStateReady()or Clerk'sloaded, is awaited insideread. It may push from insideopen.- Report what the provider proves.
signed-outonly when the provider positively says there is no session; a provider that cannot tell throws fromread. A pending or blocked session isaction-requiredwith the provider's step asaction.kind. - Map users to plain data. The default user is a plain snapshot of the provider's user fields under the provider's own names; an optional
usermapper typesTUser. Never hand the core an SDK object. - Identity is complete and deterministic.
principal.authoritynames the issuer,subjectthe provider's stable user id,tenantthe provider's tenant partition ornull.contextfingerprints everything else that changes permissions: organization, provider session, impersonator. The same provider state always produces the same answer. - Push when the provider says something changed. A complete answer goes through
observer.session; a change the adapter can only notice goes throughobserver.invalidate(). An adapter whose provider emits nothing observes nothing, and its documentation says to callanchor.sync()after native calls. - Bind every operation to the session it names.
getTokenmints only for the request's session and throwsAnchorErrorSESSION_CHANGEDwhen the SDK now holds another identity.endends only the request's session and answersnot-applicablewhen the SDK no longer holds it. Where the SDK can target a session, such as Clerk'ssignOut({ sessionId })or MSAL'saccount, it does. - End only the current session. Choose the SDK's narrowest sign-out explicitly, such as Supabase's
scope: "local", never its broader default. Report what was proven:local,remote, and anavigationURL when the provider needs a page visit to finish. - Answer, do not guess. A
forceRefreshthe SDK cannot honor throwsUNSUPPORTED. A provider "login required" throwsINTERACTION_REQUIRED. Any other failure throws what the SDK threw; the core makes itTOKEN_FAILEDorEND_FAILEDwith it as thecause. - Silence after dispose.
disposeremoves every listener the adapter installed. The core ignores late callbacks anyway, and records them ascallback ignored. - Types before runtime checks. What the types enforce is not checked again at runtime. The adapter does only the work its provider needs.
Every call an adapter receives carries a signal that aborts when Anchor stops waiting for the answer: at the timeout, when the session is replaced, and on disposal. Pass it on to the SDK where the SDK takes one. The core checks every answer against the contract and refuses one outside it with INVALID_ANSWER, keeping a frozen copy of the known fields only.
Test it
Keep a fake of the SDK beside the adapter, modelled on a named SDK version, and register the conformance suite:
import { testSessionAdapter } from "@priemskiyyy/anchor/testing";
import { acme } from "./acme";
import type { AcmeUser } from "./acme";
const createFakeAcme = () => {
const listeners = new Set<() => void>();
const held: { user: AcmeUser | null } = { user: null };
const notify = () => {
for (const listener of listeners) {
listener();
}
};
return {
currentUser: async () => held.user,
onChange: (listener: () => void) => {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
getAccessToken: async ({ userId }: { userId: string }) => `acme:${userId}`,
signOut: async () => {
held.user = null;
notify();
},
signIn: (subject: string) => {
held.user = { id: subject, name: subject };
notify();
},
switchSilently: (subject: string) => {
held.user = { id: subject, name: subject };
},
listenerCount: () => listeners.size,
};
};
testSessionAdapter({
name: "acme",
createFixture: () => {
const sdk = createFakeAcme();
return {
adapter: acme({ sdk }),
native: sdk,
profiles: ["api"],
signIn: (subject) => sdk.signIn(subject),
signOut: () => sdk.signOut(),
listeners: () => sdk.listenerCount(),
observes: true,
switchSilently: (subject) => sdk.switchSilently(subject),
};
},
});The suite registers one describe block and checks, through a real Anchor, that the factory is cold and the adapter names itself, that native is the SDK the fixture passed, that an initial signed-out and signed-in state resolve, that a sign-in and a sign-out after the start are observed, by push when observes is true and by sync() always, that every answer is within the contract, that each declared profile mints a token for the current session, that the adapter refuses to mint for a session its SDK no longer holds, that ending completes locally or says it cannot tell, and that dispose() removes every listener the adapter installed.
It cannot prove refresh, persistence, remote revocation or event ordering against a provider, nor how you map each field. Test those against the fake, and against the real SDK in Node or jsdom where it runs there, as every adapter in this repository does. See testing and the verification matrix.
In this repository
An adapter package here keeps the layout of CONTRIBUTING.md: the factory alone in src/<name>.ts, one helper per file in src/utils/, the structural type in src/types/<Name>Like.ts, the fake in src/fake<Sdk>.fixture.ts, the contracts file, the conformance test and a real-SDK test where the SDK runs in Node or jsdom. Its package.json has no dependencies: the core is a peer, and the provider SDK an optional one. Its README has, in order, one paragraph, Installation, Create an Anchor, Options, Capabilities, How a session is mapped, Tokens, Ending the session, What Anchor does not normalize, Behavior, Tests and License.
A server adapter for @priemskiyyy/anchor-server is a smaller contract, { name, native, read }; see server and SSR.