From ca946044ef111e0c4da511f2c11a6f4b2f4c2225 Mon Sep 17 00:00:00 2001 From: Mark Mennell Date: Fri, 9 Oct 2026 17:04:43 +0800 Subject: [PATCH] Resolve short names on the caller's domain with FMSG_DEFAULT_DOMAIN=caller A server serving callers on several domains cannot use one fixed default domain. The reserved value `caller` (case-insensitive) resolves short names on the domain of the address the server acts as for each request: the per-request caller over HTTP (API key or OAuth) and the configured key's address over stdio. whoami's default_domain and the server instructions show that domain when known. Directory entries still take precedence; a fixed or unset default domain behaves as before. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 ++ README.md | 2 +- docs/http-deployment.md | 5 ++ src/address.ts | 25 ++++++- src/config.ts | 6 +- src/index.ts | 3 +- src/instructions.ts | 12 ++- src/tools/common.ts | 6 ++ src/tools/identity.ts | 28 ++++--- src/tools/send.ts | 8 +- src/tools/wait.ts | 4 +- test/default-domain.test.ts | 142 ++++++++++++++++++++++++++++++++++++ 12 files changed, 220 insertions(+), 27 deletions(-) create mode 100644 test/default-domain.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index accf438..10c06f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## Unreleased +- Add `FMSG_DEFAULT_DOMAIN=caller` (case-insensitive): short names resolve on the domain of the + address the server acts as for each request, so a deployment serving callers on several domains + resolves `bob` to `@bob@example.org` for `@mark@example.org` and `@bob@example.net` for a caller + on `example.net`. `whoami`'s `default_domain` and the server instructions show the caller's + domain. Over stdio, `resolve_address` first looks up the configured key's address. Directory + entries still take precedence; a fixed or unset default domain is unchanged. - Add HTTP `FMSG_MCP_AUTH_MODE=oauth+api-key`, serving OAuth and API-key callers on one endpoint. `Bearer fmsgk_...` requests use API-key authentication; all others, including unauthenticated requests, use OAuth with its discovery challenge and scope checks. Providers and caches stay diff --git a/README.md b/README.md index 428b108..c5a3bbd 100644 --- a/README.md +++ b/README.md @@ -159,7 +159,7 @@ attach resources; prompts `chat` and `reply` script the wait → reply loop and | `FMSG_MCP_AUTH_MODE` | `api-key` | HTTP authentication: `api-key`, `oauth`, or `oauth+api-key` to accept both on one endpoint; see [OAuth settings](docs/oauth.md#operator-configuration) and [combined mode](docs/oauth.md#combined-oauth-and-api-key-mode) | | `FMSG_MCP_PUBLIC_URL` | OAuth resource URL, otherwise unset | Public MCP endpoint, including `/mcp`; enables the HTTP download-link tool. HTTPS required except loopback; when OAuth is enabled must equal `FMSG_MCP_OAUTH_RESOURCE_URL` | | `FMSG_ALLOW_INSECURE_HTTP` | disabled | Set to `1` only to permit cleartext API access on a trusted development/private network; loopback HTTP is allowed by default | -| `FMSG_DEFAULT_DOMAIN` | — | Lets short names resolve: `bob` → `@bob@` | +| `FMSG_DEFAULT_DOMAIN` | — | Lets short names resolve: `bob` → `@bob@`. `caller` resolves them on the domain of the address the server acts as for each request, so `@mark@example.org` gets `@bob@example.org`; directory entries still take precedence | | `FMSG_DIRECTORY` | — | JSON file mapping short names to full addresses | | `FMSG_MCP_DOWNLOAD_DIR` | — | Enable `save_attachment` in stdio; folder for new files named from message ID and filename | | `FMSG_MCP_WAIT_MAX_SECONDS` | `230` | Cap on one `wait_for_message` call | diff --git a/docs/http-deployment.md b/docs/http-deployment.md index d98962c..55f985a 100644 --- a/docs/http-deployment.md +++ b/docs/http-deployment.md @@ -15,6 +15,11 @@ npx -y @markmnl/fmsg-mcp --http 127.0.0.1:8765 `FMSG_API_KEY` must be unset in HTTP mode. Include only browser origins you use. +When callers have addresses on several domains, set `FMSG_DEFAULT_DOMAIN=caller` rather than one +fixed domain: short names then resolve on each caller's own domain, so `bob` is `@bob@example.org` +for `@mark@example.org` and `@bob@example.net` for a caller on `example.net`. `whoami` and the +server instructions show that caller's domain. `FMSG_DIRECTORY` entries still take precedence. + If the server reaches the Web API at an internal address instead, for example `FMSG_API_URL=http://10.0.0.5:8000` with `FMSG_ALLOW_INSECURE_HTTP=1` on a private network, also set `FMSG_API_PUBLIC_URL=https://api.example.com` so `whoami` reports the URL users know. Without it, diff --git a/src/address.ts b/src/address.ts index 8b2aab8..412fc0d 100644 --- a/src/address.ts +++ b/src/address.ts @@ -22,18 +22,36 @@ export function isFmsgAddress(value: string): boolean { return normalizeFmsgAddress(value) !== undefined; } +/** Reserved FMSG_DEFAULT_DOMAIN value: short names resolve on the caller's own domain. */ +export const CALLER_DOMAIN = "caller"; + +/** + * The domain short names resolve to: the configured default domain, or with + * `caller` the domain of the address the server acts as. Undefined when neither + * is known (no default domain, or `caller` before the caller address is known). + */ +export function effectiveDefaultDomain(defaultDomain: string | undefined, callerAddress?: string): string | undefined { + if (defaultDomain !== CALLER_DOMAIN) return defaultDomain; + const match = callerAddress ? ADDRESS.exec(callerAddress.trim()) : null; + return match ? match[2]!.toLowerCase() : undefined; +} + export type Resolution = "literal" | "directory" | "default_domain"; export type AddressResolver = { + /** A domain, or CALLER_DOMAIN to use the domain of `callerAddress`. */ defaultDomain?: string; directory?: Record; + /** The address the server acts as, when known. */ + callerAddress?: string; }; export type ResolvedAddress = { address: string; resolution: Resolution }; /** * Resolve a full address or short name: literal `@user@domain` first, then a - * configured directory entry, then `@name@`. + * configured directory entry, then `@name@` (the caller's own + * domain when the default domain is `caller`). */ export function resolveAddress(name: string, resolver: AddressResolver = {}): ResolvedAddress { const trimmed = name.trim(); @@ -51,8 +69,9 @@ export function resolveAddress(name: string, resolver: AddressResolver = {}): Re return { address, resolution: "directory" }; } } - if (resolver.defaultDomain) { - const address = normalizeFmsgAddress(`@${trimmed}@${resolver.defaultDomain}`); + const domain = effectiveDefaultDomain(resolver.defaultDomain, resolver.callerAddress); + if (domain) { + const address = normalizeFmsgAddress(`@${trimmed}@${domain}`); if (address) return { address, resolution: "default_domain" }; } throw new Error( diff --git a/src/config.ts b/src/config.ts index b4b5ff0..672cd0d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,5 +1,5 @@ import { readFileSync } from "node:fs"; -import { normalizeFmsgAddress } from "./address.js"; +import { CALLER_DOMAIN, normalizeFmsgAddress } from "./address.js"; import { normalizeApiUrl, normalizeOrigin } from "./client/url.js"; import { loadOAuthConfig, oauthUrl, type OAuthConfig } from "./oauth/config.js"; @@ -31,6 +31,7 @@ export type Config = { allowInsecureHttp?: boolean; /** Only set in stdio mode. */ apiKey?: string; + /** Domain for short names, or CALLER_DOMAIN for each caller's own domain. */ defaultDomain?: string; directory?: Record; /** Trusted local destination; enables save_attachment over stdio only. */ @@ -136,7 +137,8 @@ export function loadConfig( ); } - const defaultDomain = env.FMSG_DEFAULT_DOMAIN?.trim().replace(/^@/u, "") || undefined; + const rawDefaultDomain = env.FMSG_DEFAULT_DOMAIN?.trim().replace(/^@/u, "") || undefined; + const defaultDomain = rawDefaultDomain?.toLowerCase() === CALLER_DOMAIN ? CALLER_DOMAIN : rawDefaultDomain; const directoryPath = env.FMSG_DIRECTORY?.trim(); const port = overrides.port ?? intEnv(env, "FMSG_MCP_PORT", DEFAULT_HTTP_PORT, 0); diff --git a/src/index.ts b/src/index.ts index 23fc950..79de82b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -30,7 +30,8 @@ Environment: FMSG_MCP_OAUTH_* OAuth resource/issuer URLs, client ID/secret and exchange audience; see docs/oauth.md for required settings FMSG_ALLOW_INSECURE_HTTP 1 to allow a trusted private HTTP API outside loopback - FMSG_DEFAULT_DOMAIN lets short names resolve: bob -> @bob@ + FMSG_DEFAULT_DOMAIN lets short names resolve: bob -> @bob@; caller + uses the domain of each caller's own address FMSG_DIRECTORY JSON file mapping short names to @user@domain FMSG_MCP_DOWNLOAD_DIR enables save_attachment to this local folder (stdio) FMSG_MCP_WAIT_MAX_SECONDS cap on one wait_for_message call (default 230) diff --git a/src/instructions.ts b/src/instructions.ts index 12aa3f8..40c7a5c 100644 --- a/src/instructions.ts +++ b/src/instructions.ts @@ -4,9 +4,12 @@ * fmsg access paths, the irreversible-send rule, and the usage facts a model * otherwise gets wrong. Per-tool detail lives in the tool descriptions. */ +import { CALLER_DOMAIN, effectiveDefaultDomain } from "./address.js"; + export type InstructionsContext = { /** The address this server acts as, when already known (HTTP callers; stdio after a token exchange). */ address?: string; + /** FMSG_DEFAULT_DOMAIN: a domain, or CALLER_DOMAIN for the caller's own domain. */ defaultDomain?: string; }; @@ -14,9 +17,12 @@ export function buildInstructions(ctx: InstructionsContext = {}): string { const identity = ctx.address ? `you are acting as ${ctx.address}` : "call whoami to see which"; - const shortNames = ctx.defaultDomain - ? `; short names resolve to @name@${ctx.defaultDomain}` - : ""; + const domain = effectiveDefaultDomain(ctx.defaultDomain, ctx.address); + const shortNames = domain + ? `; short names resolve to @name@${domain}` + : ctx.defaultDomain === CALLER_DOMAIN + ? "; short names resolve to @name@, the domain of the address you act as" + : ""; return [ `This server sends and receives fmsg messages as one fmsg address: ${identity}. ` + "Use its tools for everything fmsg: inbox, threads, attachments, sending, replying, reactions, " + diff --git a/src/tools/common.ts b/src/tools/common.ts index 192bf7f..0104b26 100644 --- a/src/tools/common.ts +++ b/src/tools/common.ts @@ -1,5 +1,6 @@ import type { CallToolResult, McpServer, ServerContext, ToolAnnotations } from "@modelcontextprotocol/server"; import * as z from "zod/v4"; +import type { AddressResolver } from "../address.js"; import type { FmsgMessage, RecipientDelivery } from "../client/types.js"; import type { Config } from "../config.js"; import { type Caller, type CallerProvider, callerFor } from "../context.js"; @@ -85,6 +86,11 @@ export function ok(text: string, structured: Record): CallToolR return { content: [{ type: "text", text }], structuredContent: structured }; } +/** Short-name resolution for this caller: the configured directory and default domain. */ +export function resolverFor(deps: ToolDeps, caller: Caller): AddressResolver { + return { ...deps.config, callerAddress: caller.address }; +} + /** Resolve the caller and run a tool body, turning any failure into an `isError` result. */ export async function withCaller( deps: ToolDeps, diff --git a/src/tools/identity.ts b/src/tools/identity.ts index d2a37b2..0f9c0de 100644 --- a/src/tools/identity.ts +++ b/src/tools/identity.ts @@ -1,7 +1,7 @@ import * as z from "zod/v4"; -import { resolveAddress } from "../address.js"; +import { type AddressResolver, CALLER_DOMAIN, effectiveDefaultDomain, resolveAddress } from "../address.js"; import { isoTime } from "../render.js"; -import { READ_ONLY, type Register, ok, withCaller } from "./common.js"; +import { READ_ONLY, type Register, ok, resolverFor, withCaller } from "./common.js"; import { toolError } from "../errors.js"; export const registerIdentityTools: Register = (server, deps) => { @@ -27,12 +27,13 @@ export const registerIdentityTools: Register = (server, deps) => { async (ctx) => withCaller(deps, ctx, async (caller) => { const expires = isoTime((await caller.tokenExpiresAt()) / 1000); + const defaultDomain = effectiveDefaultDomain(deps.config.defaultDomain, caller.address); const structured = { address: caller.address, api_url: deps.config.apiPublicUrl ?? null, token_expires_at: expires, transport: deps.config.transport, - default_domain: deps.config.defaultDomain ?? null, + default_domain: defaultDomain ?? null, directory_names: Object.keys(deps.config.directory ?? {}), }; const lines = [ @@ -41,7 +42,7 @@ export const registerIdentityTools: Register = (server, deps) => { : `You are **${caller.address}** (${deps.config.transport}).`, `Access token expires ${expires ?? "unknown"} and is renewed automatically.`, ]; - if (deps.config.defaultDomain) lines.push(`Short names resolve to @name@${deps.config.defaultDomain}.`); + if (defaultDomain) lines.push(`Short names resolve to @name@${defaultDomain}.`); if (structured.directory_names.length) lines.push(`Directory names: ${structured.directory_names.join(", ")}.`); return ok(lines.join("\n"), structured); }), @@ -59,13 +60,18 @@ export const registerIdentityTools: Register = (server, deps) => { outputSchema: z.object({ address: z.string(), resolution: z.enum(["literal", "directory", "default_domain"]) }), annotations: { ...READ_ONLY, openWorldHint: false }, }, - async ({ name }) => { - try { - const resolved = resolveAddress(name, deps.config); - return ok(`${name} → ${resolved.address} (${resolved.resolution})`, resolved); - } catch (error) { - return toolError(error instanceof Error ? error.message : String(error)); - } + async ({ name }, ctx) => { + const resolve = (resolver: AddressResolver) => { + try { + const resolved = resolveAddress(name, resolver); + return ok(`${name} → ${resolved.address} (${resolved.resolution})`, resolved); + } catch (error) { + return toolError(error instanceof Error ? error.message : String(error)); + } + }; + // Only the caller's own domain needs the caller; otherwise resolution is local. + if (deps.config.defaultDomain !== CALLER_DOMAIN) return resolve(deps.config); + return withCaller(deps, ctx, async (caller) => resolve(resolverFor(deps, caller))); }, ); }; diff --git a/src/tools/send.ts b/src/tools/send.ts index 5224755..182d4e5 100644 --- a/src/tools/send.ts +++ b/src/tools/send.ts @@ -3,7 +3,7 @@ import { resolveAddresses, sameAddress } from "../address.js"; import type { OutboundAttachment } from "../client/types.js"; import { toolError } from "../errors.js"; import { isoTime, participantsOf } from "../render.js"; -import { READ_ONLY, SENDS, type Register, idSchema, ok, withCaller } from "./common.js"; +import { READ_ONLY, SENDS, type Register, idSchema, ok, resolverFor, withCaller } from "./common.js"; const IMMUTABLE = "fmsg messages are immutable: once sent they cannot be edited or recalled. Send within the user's requested task or authorized automation."; @@ -57,7 +57,7 @@ export const registerSendTools: Register = (server, deps) => { }, async ({ to, topic, body, type, important, no_reply, attachments }, ctx) => withCaller(deps, ctx, async (caller, signal) => { - const recipients = resolveAddresses(to, deps.config); + const recipients = resolveAddresses(to, resolverFor(deps, caller)); const sent = await caller.client.send({ to: recipients, topic, @@ -117,7 +117,7 @@ export const registerSendTools: Register = (server, deps) => { } const warnings: string[] = []; const to = recipients?.length - ? resolveAddresses(recipients, deps.config) + ? resolveAddresses(recipients, resolverFor(deps, caller)) : participantsOf(parent).filter((a) => !sameAddress(a, caller.address)); if (to.length === 0) return toolError(`message ${id} has no other participants to reply to; pass recipients`); const sent = await caller.client.send({ @@ -163,7 +163,7 @@ export const registerSendTools: Register = (server, deps) => { }, async ({ id, add_to }, ctx) => withCaller(deps, ctx, async (caller, signal) => { - const addresses = resolveAddresses(add_to, deps.config); + const addresses = resolveAddresses(add_to, resolverFor(deps, caller)); const result = await caller.client.addRecipients(id, addresses, signal); return ok(`Added ${result.added} recipient(s) to message ${id}: ${addresses.join(", ")}`, { ...result, add_to: addresses }); }), diff --git a/src/tools/wait.ts b/src/tools/wait.ts index 2b8b794..9e041dc 100644 --- a/src/tools/wait.ts +++ b/src/tools/wait.ts @@ -3,7 +3,7 @@ import { resolveAddress } from "../address.js"; import { assembleThread, renderThread } from "../thread.js"; import { DATA_NOT_INSTRUCTIONS, fence, headerValue, messageData, messageLine } from "../render.js"; import { waitForMessage } from "../wait.js"; -import { READ_ONLY, type Register, idSchema, messageItem, ok, toItem, withCaller } from "./common.js"; +import { READ_ONLY, type Register, idSchema, messageItem, ok, resolverFor, toItem, withCaller } from "./common.js"; export const registerWaitTools: Register = (server, deps) => { const maxWait = deps.config.waitMaxSeconds; @@ -56,7 +56,7 @@ export const registerWaitTools: Register = (server, deps) => { { ...(after_id !== undefined ? { afterId: after_id } : {}), ...(thread_of !== undefined ? { threadOf: thread_of } : {}), - ...(from !== undefined ? { from: resolveAddress(from, deps.config).address } : {}), + ...(from !== undefined ? { from: resolveAddress(from, resolverFor(deps, caller)).address } : {}), timeoutMs: timeout_seconds * 1000, settleMs: settle_seconds * 1000, onTick: (elapsed) => { diff --git a/test/default-domain.test.ts b/test/default-domain.test.ts new file mode 100644 index 0000000..5c46f47 --- /dev/null +++ b/test/default-domain.test.ts @@ -0,0 +1,142 @@ +import type { AddressInfo } from "node:net"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client"; +import { CALLER_DOMAIN, resolveAddress } from "../src/address.js"; +import { loadConfig } from "../src/config.js"; +import { createHttpServer, type HttpServerHandle } from "../src/http.js"; +import { buildInstructions } from "../src/instructions.js"; +import { FakeFmsgServer } from "./fake-fmsg-server.js"; +import { FakeOAuthServer } from "./fake-oauth-server.js"; +import { ALICE, CAROL, call, configFor, connectInMemory, structured, text } from "./helpers.js"; + +type Who = { address: string; default_domain: string | null }; + +describe("FMSG_DEFAULT_DOMAIN=caller", () => { + it("parses the reserved value case-insensitively and leaves real domains unchanged", () => { + const parse = (value?: string) => + loadConfig({ FMSG_API_URL: "https://api.example.com", ...(value === undefined ? {} : { FMSG_DEFAULT_DOMAIN: value }) }, "http").defaultDomain; + expect(parse("caller")).toBe(CALLER_DOMAIN); + expect(parse(" CALLER ")).toBe(CALLER_DOMAIN); + expect(parse("@Caller")).toBe(CALLER_DOMAIN); + expect(parse("example.org")).toBe("example.org"); + expect(parse("@example.org")).toBe("example.org"); + expect(parse("")).toBeUndefined(); + expect(parse()).toBeUndefined(); + }); + + it("resolves short names on the caller's domain, after literal addresses and directory entries", () => { + const directory = { bob: "@bob@example.com" }; + expect(resolveAddress("dave", { defaultDomain: CALLER_DOMAIN, callerAddress: "@mark@Example.ORG" })) + .toEqual({ address: "@dave@example.org", resolution: "default_domain" }); + expect(resolveAddress("Bob", { defaultDomain: CALLER_DOMAIN, callerAddress: "@mark@example.org", directory })) + .toEqual({ address: "@bob@example.com", resolution: "directory" }); + expect(resolveAddress("@x@example.net", { defaultDomain: CALLER_DOMAIN, callerAddress: "@mark@example.org" }).address).toBe("@x@example.net"); + expect(() => resolveAddress("dave", { defaultDomain: CALLER_DOMAIN })).toThrow(/ask for the full @user@domain address/u); + expect(resolveAddress("dave", { defaultDomain: "example.net", callerAddress: "@mark@example.org" }).address).toBe("@dave@example.net"); + expect(() => resolveAddress("dave", { callerAddress: "@mark@example.org" })).toThrow(/ask for the full/u); + }); + + it("names the caller's domain in the instructions when known", () => { + expect(buildInstructions({ address: CAROL, defaultDomain: CALLER_DOMAIN })).toContain("short names resolve to @name@example.org."); + expect(buildInstructions({ defaultDomain: CALLER_DOMAIN })).toContain("short names resolve to @name@"); + expect(buildInstructions({ address: CAROL, defaultDomain: "example.net" })).toContain("short names resolve to @name@example.net."); + expect(buildInstructions({ address: CAROL })).not.toContain("short names"); + }); + + it("resolves on the stdio caller's domain once the address is known", async () => { + const fake = new FakeFmsgServer(); + await fake.start(); + const h = await connectInMemory(fake, "fmsgk_alice_secret", { FMSG_DEFAULT_DOMAIN: "caller" }); + try { + expect(h.client.getInstructions()).toContain("short names resolve to @name@"); + expect(structured(await call(h.client, "resolve_address", { name: "dave" }))).toEqual({ address: "@dave@example.com", resolution: "default_domain" }); + expect(structured(await call(h.client, "whoami")).default_domain).toBe("example.com"); + } finally { + await h.close(); + await fake.stop(); + } + }); +}); + +describe("FMSG_DEFAULT_DOMAIN over HTTP", () => { + let fake: FakeFmsgServer; + let idp: FakeOAuthServer | undefined; + let http: HttpServerHandle | undefined; + const clients: Client[] = []; + beforeEach(async () => { + fake = new FakeFmsgServer(); + await fake.start(); + }); + afterEach(async () => { + await Promise.all(clients.splice(0).map((client) => client.close())); + await http?.close(); + await idp?.stop(); + http = idp = undefined; + await fake.stop(); + }); + + async function serve(extra: Record, oauth = false): Promise { + let config = configFor(fake, "http", extra); + if (oauth) { + idp = new FakeOAuthServer(fake); + await idp.start(); + config = { ...config, oauth: idp.config }; + } + http = createHttpServer(config, () => undefined); + await new Promise((resolve) => http!.server.listen(0, "127.0.0.1", resolve)); + return `http://127.0.0.1:${(http.server.address() as AddressInfo).port}/mcp`; + } + + async function connect(url: string, token: string): Promise { + const client = new Client({ name: "domain-test", version: "0.0.0" }, { versionNegotiation: { mode: "auto" } }); + clients.push(client); + await client.connect(new StreamableHTTPClientTransport(new URL(url), { requestInit: { headers: { authorization: `Bearer ${token}` } } })); + return client; + } + + async function expectDomain(client: Client, address: string, domain: string | null): Promise { + const who = await call(client, "whoami"); + expect(structured(who)).toMatchObject({ address, default_domain: domain }); + const resolved = await call(client, "resolve_address", { name: "dave" }); + const sent = await call(client, "send_message", { to: ["dave"], topic: "hi", body: "hello" }); + if (domain === null) { + expect(text(who)).not.toContain("Short names resolve"); + expect(client.getInstructions()).not.toContain("short names"); + expect(resolved.isError).toBe(true); + expect(text(resolved)).toContain("ask for the full @user@domain address"); + expect(sent.isError).toBe(true); + return; + } + expect(text(who)).toContain(`Short names resolve to @name@${domain}.`); + expect(client.getInstructions()).toContain(`short names resolve to @name@${domain}.`); + expect(structured(resolved)).toEqual({ address: `@dave@${domain}`, resolution: "default_domain" }); + expect(structured<{ from: string; to: string[] }>(sent)).toMatchObject({ from: address, to: [`@dave@${domain}`] }); + } + + it("gives each API-key caller their own domain", async () => { + const url = await serve({ FMSG_DEFAULT_DOMAIN: "caller" }); + const alice = await connect(url, "fmsgk_alice_secret"); + const carol = await connect(url, "fmsgk_carol_secret"); + await expectDomain(alice, ALICE, "example.com"); + await expectDomain(carol, CAROL, "example.org"); + }); + + it("gives each OAuth caller their own domain", async () => { + const url = await serve({ FMSG_DEFAULT_DOMAIN: "caller" }, true); + const alice = await connect(url, await idp!.token()); + const carol = await connect(url, await idp!.token({ sub: CAROL })); + await expectDomain(alice, ALICE, "example.com"); + await expectDomain(carol, CAROL, "example.org"); + }); + + it("keeps a fixed default domain for every caller", async () => { + const url = await serve({ FMSG_DEFAULT_DOMAIN: "example.net" }); + await expectDomain(await connect(url, "fmsgk_alice_secret"), ALICE, "example.net"); + await expectDomain(await connect(url, "fmsgk_carol_secret"), CAROL, "example.net"); + }); + + it("does not resolve short names without a default domain", async () => { + const url = await serve({}); + await expectDomain(await connect(url, "fmsgk_alice_secret"), ALICE, null); + }); +});