Skip to content

Commit 92a04ef

Browse files
MajorTalclaude
andcommitted
feat(auth-aware-ssr): scaffold AGENTS.md + rendering-mode matrix + MCP/test fixups
run402-public side of openspec auth-aware-ssr tasks 8.8 + 8.10: - cli/lib/init-astro.mjs: scaffold emits AGENTS.md template with the brutally-small auth.* surface + four Never rules + rendering-mode quick map. Save-page example switched from getUser() to auth.requireUser(). Task 8.8. - astro/README.md: rendering-mode pattern matrix table (SSR / prerendered / server-island / client-hydrate) with copy-pasteable examples for each. Task 8.10. - src/index.ts (MCP deploy_function tool description): swap legacy 'getUser' bullet for 'auth' so the doctor source scanner stops R402_AUTH_UNKNOWN_EXPORT-flagging the MCP descriptor at deploy time. Unblocks 5 cli-e2e tests that exercise tier-violation paths. - sync.test.ts: allowlist auth.fetch() scaffold strings in init-astro.mjs and the canonical-fix string in doctor-source-scan.mjs. Also stops treating .test.mjs files as production interface files. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 0b9b02d commit 92a04ef

4 files changed

Lines changed: 160 additions & 5 deletions

File tree

astro/README.md

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,63 @@ Three options if your SSR route needs request-time config:
8181

8282
The Run402 anon key + service key + project ID + JWT secret + API base ARE auto-injected at deploy time (you'll see `RUN402_ANON_KEY`, `RUN402_SERVICE_KEY`, `RUN402_PROJECT_ID`, `RUN402_JWT_SECRET`, `RUN402_API_BASE` in `process.env` from inside the SSR runtime — those are the platform-managed channel).
8383

84+
### Rendering-mode pattern matrix
85+
86+
Astro supports four rendering modes; `auth.*` calls have different semantics in each. Pick the right mode per page and the rest follows.
87+
88+
| Mode | How to opt in | When to use | Auth + cache |
89+
| ---------------- | ---------------------------------------------- | ----------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
90+
| SSR (default) | The default in v1.0; no flag needed. | Personalized pages that read the actor. | `auth.user()` returns the actor; `auth.*` helpers taint the response so cache bypasses on Set-Cookie / auth. |
91+
| Prerendered | `export const prerender = true;` in the page. | Pure marketing / docs pages that never see the actor. | `auth.*` throws `R402_AUTH_PRERENDERED`. The page is built once and served as a static asset. |
92+
| Server island | `<Component server:defer />` inside a page. | Mostly-static page with a personalized slot (e.g. user dropdown). | `auth.*` is available **inside** the island. The shell is still cacheable. |
93+
| Client hydrate | `<SignedIn client:load>…</SignedIn>`. | Cookie-aware visibility without an SSR pass at all. | Component fetches `/auth/v1/session` from the browser. No server `auth.*` call. |
94+
95+
Pattern picker:
96+
97+
```astro
98+
---
99+
// Personalized SSR (default in this scaffold)
100+
import { auth } from "@run402/functions";
101+
const user = await auth.requireUser(); // 303 to /auth/sign-in if anonymous
102+
---
103+
<h1>Hello, {user.email}</h1>
104+
```
105+
106+
```astro
107+
---
108+
// Prerendered marketing page
109+
export const prerender = true;
110+
// Do NOT call auth.user() here — it throws R402_AUTH_PRERENDERED at build time
111+
---
112+
<h1>Welcome to the product</h1>
113+
```
114+
115+
```astro
116+
---
117+
// Server-island mix: shell is cacheable, island streams in
118+
import UserDropdown from "../components/UserDropdown.astro";
119+
---
120+
<header>
121+
<nav>...</nav>
122+
<UserDropdown server:defer>
123+
<span slot="fallback">Loading…</span>
124+
</UserDropdown>
125+
</header>
126+
```
127+
128+
```astro
129+
---
130+
// Client-hydrated visibility-only (no SSR auth read)
131+
import { SignedIn, SignedOut, SignIn, UserButton } from "@run402/astro";
132+
---
133+
<SignedIn client:load>
134+
<UserButton />
135+
</SignedIn>
136+
<SignedOut client:load>
137+
<SignIn returnTo="/" />
138+
</SignedOut>
139+
```
140+
84141
## `<Run402Picture>` — runtime CMS images
85142

86143
For images coming from a DB row at SSR time (the common CMS pattern), use `<Run402Picture asset={page.hero_asset}>`. The `asset` prop is the `AssetRef` JSONB that `r.assets.put()` returned at upload time — store the whole object, not just the URL, then render directly.

cli/lib/init-astro.mjs

Lines changed: 92 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -215,11 +215,10 @@ const { title, ogImage, canonical } = Astro.props;
215215
// 2. DB update.
216216
// 3. cache.invalidate() so the public URL re-renders fresh on next visit.
217217
import type { APIRoute } from "astro";
218-
import { db, getUser, cache } from "@run402/functions";
218+
import { db, auth, cache } from "@run402/functions";
219219
220220
export const POST: APIRoute = async ({ request }) => {
221-
const user = await getUser();
222-
if (!user) return new Response("Unauthorized", { status: 401 });
221+
const user = await auth.requireUser();
223222
224223
const body = (await request.json()) as { slug: string; title: string; html: string };
225224
if (!body?.slug) return new Response("Missing slug", { status: 400 });
@@ -236,6 +235,96 @@ export const POST: APIRoute = async ({ request }) => {
236235
headers: { "content-type": "application/json" },
237236
});
238237
};
238+
`,
239+
},
240+
{
241+
path: "AGENTS.md",
242+
content: `# AGENTS.md
243+
244+
This file documents the brutally-small Run402 surface this Astro project
245+
uses. Coding agents: read this first. The platform is intentionally small —
246+
there are no other auth helpers, no other client surfaces, and no other
247+
hidden APIs.
248+
249+
## The auth surface
250+
251+
\`auth\` is the entire user-auth surface. Import from \`@run402/functions\`:
252+
253+
\`\`\`ts
254+
import { auth } from "@run402/functions";
255+
256+
// In SSR pages and API routes:
257+
const user = await auth.user(); // Actor | null
258+
const user = await auth.requireUser(); // Actor; throws R402_AUTH_REQUIRED
259+
const { user, role } = await auth.requireRole("admin");
260+
const { user, membership } = await auth.requireMembership("member");
261+
await auth.requireFresh({ maxAge: "10m", amr: ["passkey"] });
262+
263+
// CSRF for hosted forms (server-side, in <form> rendering):
264+
const field = auth.csrfField();
265+
// → <input type="hidden" name="_csrf" value="..." />
266+
267+
// Cross-origin-safe fetch (auto-forwards actor context to same-origin):
268+
const res = await auth.fetch("/api/internal"); // relative URLs only
269+
\`\`\`
270+
271+
## The four Never rules
272+
273+
1. **Never \`try\`/\`catch\` auth errors.** Let them bubble. The platform turns
274+
\`R402_AUTH_REQUIRED\` into a 303 to \`/auth/sign-in?return_to=…\` and
275+
\`R402_AUTH_INSUFFICIENT_ROLE\` into 403 with a fix-it response. Catching
276+
them creates silent-null bugs.
277+
278+
2. **Never \`.eq("user_id", user.id)\`.** \`db()\` propagates the actor to
279+
PostgREST so RLS enforces ownership server-side. The redundant filter is
280+
a code smell that \`run402 doctor\` flags as
281+
\`R402_AUTH_REDUNDANT_USER_FILTER\`.
282+
283+
3. **Never set client-supplied actor headers.** \`x-run402-actor-*\`,
284+
\`run402.actor.*\`, \`x-r402-actor-*\` are platform-owned channel headers.
285+
The gateway strips inbound spoofing attempts and emits
286+
\`R402_AUTH_ACTOR_HEADER_SPOOF\` in strict mode.
287+
288+
4. **Never mint a session from a raw \`userId\`.** Use
289+
\`auth.sessions.createResponseFromIdentity({ provider, subject, proof, amr })\`
290+
with a verified identity proof. No \`createSessionForUserId(uuid)\` API exists.
291+
292+
## Hosted UI components
293+
294+
For sign-in, sign-up, and sign-out chrome, use the platform's
295+
\`@run402/astro\` components — they emit forms posting to platform hosted
296+
routes (\`/auth/v1/sign-in\` etc.) with the CSRF token already wired:
297+
298+
\`\`\`astro
299+
---
300+
import { SignIn, SignUp, UserButton, SignedIn, SignedOut } from "@run402/astro";
301+
---
302+
303+
<SignedIn>
304+
<UserButton />
305+
</SignedIn>
306+
<SignedOut>
307+
<SignIn returnTo="/dashboard" />
308+
</SignedOut>
309+
\`\`\`
310+
311+
Do NOT roll your own sign-in form. The hosted routes handle CSRF, returnTo
312+
validation, OAuth provider bridges, and passkey ceremonies.
313+
314+
## Rendering-mode quick map
315+
316+
\`auth.*\` calls run at request time, so the page must be SSR or a
317+
server-island. Calling \`auth.user()\` from a prerendered page throws
318+
\`R402_AUTH_PRERENDERED\`.
319+
320+
| Mode | When | Auth-aware |
321+
| ----------------------------- | ----------------------------------- | ----------------------- |
322+
| SSR (default) | Personalized pages | \`auth.user()\` works |
323+
| Prerendered | Marketing pages, never sees actor | \`auth.*\` throws |
324+
| Server island | Prerendered page + personalized slot| \`auth.*\` in the island|
325+
| Client hydrate | Visibility-only, no SSR pass | Component hits session |
326+
327+
For error-code reference: https://run402.com/errors/#R402_AUTH_REQUIRED
239328
`,
240329
},
241330
];

src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -357,7 +357,7 @@ server.tool(
357357

358358
server.tool(
359359
"deploy_function",
360-
"Deploy a serverless function (Node 22) to a project. Handler signature: export default async (req: Request) => Response. The function can `import { db, adminDb, getUser, email, ai } from '@run402/functions'` — auto-bundled by the platform. Additional npm packages are bundled at deploy time when listed in `deps` (bare names resolve to latest; pinned/range specs are honored verbatim; `@run402/functions` and `run402-functions` rejected; max 30 entries; native binaries rejected). The response includes `runtime_version` (the bundled `@run402/functions` version — surface as 'Functions runtime version', never bare 'runtime'), `deps_resolved` (map of dep name → installed concrete version), and an optional top-level `warnings` array (sibling to the function record).",
360+
"Deploy a serverless function (Node 22) to a project. Handler signature: export default async (req: Request) => Response. The function can `import { db, adminDb, auth, email, ai } from '@run402/functions'` — auto-bundled by the platform. Additional npm packages are bundled at deploy time when listed in `deps` (bare names resolve to latest; pinned/range specs are honored verbatim; `@run402/functions` and `run402-functions` rejected; max 30 entries; native binaries rejected). The response includes `runtime_version` (the bundled `@run402/functions` version — surface as 'Functions runtime version', never bare 'runtime'), `deps_resolved` (map of dep name → installed concrete version), and an optional top-level `warnings` array (sibling to the function record).",
361361
deployFunctionSchema,
362362
async (args) => handleDeployFunction(args),
363363
);

sync.test.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -896,6 +896,15 @@ describe("CLI/MCP SDK-boundary guard", () => {
896896
["cli/lib/init.mjs", [/\bfetch\(TEMPO_RPC\b/]], // Tempo faucet/RPC
897897
["cli/lib/ci.mjs", [/\bfetch\(`https:\/\/api\.github\.com\/repos\//]], // GitHub repository lookup
898898
["src/tools/init.ts", [/\bfetch\(TEMPO_RPC\b/]], // Tempo faucet/RPC
899+
// doctor-source-scan.mjs documents the canonical fix string for
900+
// browser-bearer scans — the string itself contains "auth.fetch()"
901+
// as the recommended replacement, not a real fetch call.
902+
["cli/lib/doctor-source-scan.mjs", [/Use auth\.fetch\(\) for same-origin/]],
903+
// init-astro.mjs emits scaffold *strings* that get written into the
904+
// user's generated project. `auth.fetch("/api/internal")` is the
905+
// recommended SDK pattern shown in the template — it is template
906+
// text, not a CLI-runtime fetch.
907+
["cli/lib/init-astro.mjs", [/auth\.fetch\("\/api\/internal"\)/, /Cross-origin-safe fetch/]],
899908
]);
900909

901910
const violations: string[] = [];
@@ -927,7 +936,7 @@ function productionInterfaceFiles(): string[] {
927936
const srcTools = join(__dirname, "src/tools");
928937
return [
929938
...readdirSync(cliLib)
930-
.filter((name) => name.endsWith(".mjs"))
939+
.filter((name) => name.endsWith(".mjs") && !name.endsWith(".test.mjs"))
931940
.map((name) => join(cliLib, name)),
932941
...readdirSync(srcTools)
933942
.filter((name) => name.endsWith(".ts") && !name.endsWith(".test.ts"))

0 commit comments

Comments
 (0)