Skip to content

Commit 12d96a9

Browse files
committed
feat(site): add Personal Agent Protocol (Poppy 0.1) discovery manifest and RFC 8414 issuer binding
1 parent cdbbb0f commit 12d96a9

5 files changed

Lines changed: 182 additions & 3 deletions

File tree

‎apps/site/src/lib/auth.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ export const MCP_DEFAULT_SCOPES = [
1616
"search",
1717
"mcp:tools",
1818
"agent",
19+
"poppy:read",
20+
"poppy:write",
1921
] as const;
2022

2123
export const DEFAULT_MCP_USER = {

‎apps/site/src/middleware.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ export const onRequest = defineMiddleware(async (context, next) => {
107107
// Always emit Link headers (RFC 8288) for AI agent discoverability
108108
response.headers.set(
109109
"Link",
110-
`<${origin}/.well-known/api-catalog>; rel="api-catalog", <${origin}/.well-known/mcp/server-card.json>; rel="mcp-server-card", <${origin}/.well-known/agent-skills/index.json>; rel="agent-skills", <${origin}/.well-known/ai-catalog.json>; rel="ai-catalog", <${origin}/.well-known/oauth-protected-resource>; rel="oauth-protected-resource", <${origin}/.well-known/oauth-authorization-server>; rel="oauth-authorization-server", <${origin}/llms.txt>; rel="llms-txt", <${origin}/auth.md>; rel="auth-md", <${origin}/AGENTS.md>; rel="agent-guide", <${origin}/pricing.md>; rel="pricing"`,
110+
`<${origin}/.well-known/api-catalog>; rel="api-catalog", <${origin}/.well-known/mcp/server-card.json>; rel="mcp-server-card", <${origin}/.well-known/agent-skills/index.json>; rel="agent-skills", <${origin}/.well-known/ai-catalog.json>; rel="ai-catalog", <${origin}/.well-known/poppy.json>; rel="poppy", <${origin}/.well-known/oauth-protected-resource>; rel="oauth-protected-resource", <${origin}/.well-known/oauth-authorization-server>; rel="oauth-authorization-server", <${origin}/llms.txt>; rel="llms-txt", <${origin}/auth.md>; rel="auth-md", <${origin}/AGENTS.md>; rel="agent-guide", <${origin}/pricing.md>; rel="pricing"`,
111111
);
112112
response.headers.append("Vary", "Accept");
113113

‎apps/site/src/pages/.well-known/oauth-authorization-server.ts‎

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,29 @@ export const prerender = false;
55

66
/**
77
* OAuth 2.0 Authorization Server Metadata (RFC 8414)
8-
* powered by Better Auth with Auth.md agent_auth extension for autonomous AI agent discovery
8+
* powered by Better Auth with Auth.md agent_auth extension and Personal Agent Protocol (Poppy) issuer binding
99
*/
1010
export const GET: APIRoute = async ({ request, locals }) => {
11-
const origin = locals.siteUrl.origin;
11+
const siteUrl = locals?.siteUrl ?? new URL("https://codingcat.dev");
12+
let reqUrl = siteUrl;
13+
if (request?.url) {
14+
try {
15+
reqUrl = new URL(request.url);
16+
} catch {
17+
reqUrl = siteUrl;
18+
}
19+
}
20+
21+
const rawHostname = reqUrl.hostname || siteUrl.hostname || "codingcat.dev";
22+
const domain = rawHostname.replace(/^www\./i, "");
23+
const protocol =
24+
reqUrl.protocol === "https:" || siteUrl.protocol === "https:"
25+
? "https:"
26+
: reqUrl.protocol || "https:";
27+
const origin =
28+
rawHostname === "localhost" || rawHostname === "127.0.0.1"
29+
? siteUrl.origin
30+
: `${protocol}//${domain}`;
1231
const auth = getAuth(origin);
1332

1433
let baseMeta: Record<string, any> = {};
@@ -25,13 +44,20 @@ export const GET: APIRoute = async ({ request, locals }) => {
2544
// Fallback if request is invoked offline/in tests
2645
}
2746

47+
const poppyDomains = Array.from(
48+
new Set([domain, "codingcat.dev", "dev.codingcat.dev"]),
49+
);
50+
2851
const metadata = {
2952
...baseMeta,
3053
issuer: origin,
3154
authorization_endpoint: `${origin}/oauth2/authorize`,
3255
token_endpoint: `${origin}/oauth2/token`,
56+
revocation_endpoint: `${origin}/oauth2/revoke`,
57+
introspection_endpoint: `${origin}/oauth2/introspect`,
3358
registration_endpoint: `${origin}/oauth2/register`,
3459
jwks_uri: `${origin}/jwks`,
60+
poppy_domains: poppyDomains,
3561
scopes_supported: [
3662
"openid",
3763
"profile",
@@ -41,6 +67,8 @@ export const GET: APIRoute = async ({ request, locals }) => {
4167
"search",
4268
"mcp:tools",
4369
"agent",
70+
"poppy:read",
71+
"poppy:write",
4472
],
4573
agent_auth: {
4674
skill: `${origin}/auth.md`,
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import type { APIRoute } from "astro";
2+
3+
export const prerender = false;
4+
5+
/**
6+
* Personal Agent Protocol (Poppy) 0.1 public discovery manifest
7+
* Spec: https://personalagentprotocol.org/docs/spec#3-discovery
8+
*/
9+
export const GET: APIRoute = async ({ request, locals }) => {
10+
const siteUrl = locals?.siteUrl ?? new URL("https://codingcat.dev");
11+
let reqUrl = siteUrl;
12+
if (request?.url) {
13+
try {
14+
reqUrl = new URL(request.url);
15+
} catch {
16+
reqUrl = siteUrl;
17+
}
18+
}
19+
20+
const rawHostname = reqUrl.hostname || siteUrl.hostname || "codingcat.dev";
21+
const domain = rawHostname.replace(/^www\./i, "");
22+
const protocol =
23+
reqUrl.protocol === "https:" || siteUrl.protocol === "https:"
24+
? "https:"
25+
: reqUrl.protocol || "https:";
26+
const origin =
27+
rawHostname === "localhost" || rawHostname === "127.0.0.1"
28+
? siteUrl.origin
29+
: `${protocol}//${domain}`;
30+
31+
const manifest = {
32+
protocol_version: "0.1",
33+
organization: {
34+
name: "CodingCat.dev",
35+
domain,
36+
},
37+
auth: {
38+
issuer: origin,
39+
direct: {
40+
scopes: [
41+
"poppy:read",
42+
"poppy:write",
43+
"read:content",
44+
"search",
45+
"mcp:tools",
46+
],
47+
},
48+
custom_scopes: {
49+
"read:content":
50+
"Read CodingCat.dev tutorials, courses, podcast episodes, and developer guides",
51+
search:
52+
"Search across CodingCat.dev blog posts, courses, and podcast episodes",
53+
"mcp:tools": "Execute CodingCat.dev Model Context Protocol (MCP) tools",
54+
},
55+
},
56+
apis: [
57+
{
58+
type: "openapi",
59+
url: `${origin}/.well-known/openapi.json`,
60+
description:
61+
"Search tutorials, courses, podcasts, and submit sponsorship inquiries",
62+
},
63+
{
64+
type: "mcp",
65+
url: `${origin}/mcp`,
66+
description:
67+
"CodingCat.dev Model Context Protocol (MCP) server for content search, design tokens, and editorial rules",
68+
},
69+
],
70+
web: {},
71+
};
72+
73+
return new Response(JSON.stringify(manifest, null, 2), {
74+
headers: {
75+
"content-type": "application/json; charset=utf-8",
76+
"access-control-allow-origin": "*",
77+
"cache-control": "public, max-age=3600, s-maxage=86400",
78+
},
79+
});
80+
};

‎apps/site/tests/tier1-features.test.mjs‎

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2131,4 +2131,73 @@ describe("Tier 1 - Feature Coverage", () => {
21312131
);
21322132
});
21332133
});
2134+
2135+
// ── 15. Personal Agent Protocol (Poppy) Discovery & Issuer Binding ────
2136+
describe("Personal Agent Protocol (Poppy) Discovery & OAuth Issuer Binding", () => {
2137+
it("15.1 /.well-known/poppy.json returns protocol_version 0.1, matching organization.domain, and RFC 8414 issuer binding", async () => {
2138+
const poppyModule = loadModule("src/pages/.well-known/poppy.json.ts");
2139+
const oauthServerModule = loadModule(
2140+
"src/pages/.well-known/oauth-authorization-server.ts",
2141+
);
2142+
const locals = {
2143+
siteUrl: new URL("https://codingcat.dev"),
2144+
};
2145+
2146+
const poppyRes = await poppyModule.GET({
2147+
request: new Request(
2148+
"https://www.codingcat.dev/.well-known/poppy.json",
2149+
),
2150+
locals,
2151+
});
2152+
assert.equal(poppyRes.status, 200);
2153+
assert.ok(
2154+
poppyRes.headers.get("content-type")?.includes("application/json"),
2155+
);
2156+
const poppy = await poppyRes.json();
2157+
2158+
assert.equal(poppy.protocol_version, "0.1");
2159+
assert.equal(poppy.organization?.name, "CodingCat.dev");
2160+
assert.equal(
2161+
poppy.organization?.domain,
2162+
"codingcat.dev",
2163+
"organization.domain must match requested hostname ignoring leading www.",
2164+
);
2165+
assert.equal(poppy.auth?.issuer, "https://codingcat.dev");
2166+
assert.ok(Array.isArray(poppy.auth?.direct?.scopes));
2167+
assert.ok(Array.isArray(poppy.apis) && poppy.apis.length >= 2);
2168+
assert.equal(typeof poppy.web, "object");
2169+
2170+
const oauthRes = await oauthServerModule.GET({
2171+
request: new Request(
2172+
"https://codingcat.dev/.well-known/oauth-authorization-server",
2173+
),
2174+
locals,
2175+
});
2176+
assert.equal(oauthRes.status, 200);
2177+
const oauthMeta = await oauthRes.json();
2178+
2179+
assert.equal(
2180+
oauthMeta.issuer,
2181+
poppy.auth.issuer,
2182+
"RFC 8414 issuer must exactly match poppy.json auth.issuer",
2183+
);
2184+
assert.ok(
2185+
Array.isArray(oauthMeta.poppy_domains) &&
2186+
oauthMeta.poppy_domains.includes(poppy.organization.domain),
2187+
"RFC 8414 poppy_domains must include organization.domain",
2188+
);
2189+
assert.equal(
2190+
oauthMeta.token_endpoint,
2191+
"https://codingcat.dev/oauth2/token",
2192+
);
2193+
assert.equal(
2194+
oauthMeta.revocation_endpoint,
2195+
"https://codingcat.dev/oauth2/revoke",
2196+
);
2197+
assert.equal(
2198+
oauthMeta.authorization_endpoint,
2199+
"https://codingcat.dev/oauth2/authorize",
2200+
);
2201+
});
2202+
});
21342203
});

0 commit comments

Comments
 (0)