Skip to content

Commit 4a560e5

Browse files
MajorTalclaude
andcommitted
feat(run402): adopt @run402/functions v3 auth.* namespace in functions/
v3.0.0 ships throwing-sentinel getUser/getUserId/getRole — every existing caller would crash with R402_AUTH_UNKNOWN_EXPORT at runtime. Replace each `await getUser(req)` with `await auth.user()` across the 7 functions that consult the actor; drop the defensive try/catch (auth.user() returns Actor | null and never throws on anon). Test mocks of @run402/functions get a parallel `auth.user` entry so the new code path is intercepted. Scope intentionally limited to the v3 break: client-side localStorage → HttpOnly cookie work (src/lib/auth.ts, src/lib/api.ts, six wl_session readers in components) and SSR auth guards on admin pages remain deferred until @run402/astro v2.0 ships and we get explicit go-ahead to drop the Bearer-token flow. Annotated the 4 adminDb() .eq('user_id', user.id) lookups with `run402-allow-user-filter: …` so future doctor source-scans don't flag them — they bypass RLS by design (actor bootstrap, role lookup pre-auth). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a21b839 commit 4a560e5

16 files changed

Lines changed: 43 additions & 37 deletions

functions/export-csv.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
// schedule: none (triggered manually from admin UI)
2-
import { adminDb, getUser } from '@run402/functions';
2+
import { adminDb, auth } from '@run402/functions';
33

44
export default async (req) => {
5-
const user = await getUser(req);
5+
const user = await auth.user();
66
if (!user) {
77
return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401 });
88
}

functions/kychon-api.js

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
// schedule: none (canonical Kychon Capability API gateway at POST /functions/v1/kychon-api)
2-
import { adminDb, getUser } from '@run402/functions';
2+
import { adminDb, auth } from '@run402/functions';
33

44
const API_VERSION = '2026-05-08';
55
const SUPPORTED_API_VERSIONS = [API_VERSION];
@@ -2271,13 +2271,11 @@ function objectRefJson(ref) {
22712271
};
22722272
}
22732273

2274-
async function resolveActor(req) {
2275-
let user = null;
2276-
try {
2277-
user = await getUser(req);
2278-
} catch {
2279-
user = null;
2280-
}
2274+
async function resolveActor(_req) {
2275+
// auth.user() returns Actor | null and never throws on anon — drop the try/catch.
2276+
// The platform-verified actor envelope is the only trusted source; legacy
2277+
// Bearer-header path is forwarded by the gateway into the same ALS context.
2278+
const user = await auth.user();
22812279
if (!user?.id) return { state: 'anonymous', authenticated: false, user: null, member: null, authority: {} };
22822280

22832281
const projectAdmin = isProjectAdmin(user);
@@ -2297,6 +2295,7 @@ async function resolveActor(req) {
22972295

22982296
async function findMember(user) {
22992297
const db = adminDb();
2298+
// run402-allow-user-filter: adminDb() bypasses RLS to bootstrap actor → member mapping
23002299
const byUserId = await db
23012300
.from('members')
23022301
.select('id,user_id,email,display_name,role,status')

functions/on-signup.js

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// Lifecycle hook: called automatically by Run402 after first signup (fire-and-forget).
22
// Also supports direct invocation with auth token for backward compatibility.
3-
import { adminDb, getUser } from '@run402/functions';
3+
import { adminDb, auth } from '@run402/functions';
44

55
export default async (req) => {
66
// Determine user identity from lifecycle hook payload or auth token
@@ -17,8 +17,11 @@ export default async (req) => {
1717
return new Response(JSON.stringify({ error: 'Missing user.id in hook payload' }), { status: 400 });
1818
}
1919
} else {
20-
// Direct invocation: use auth token
21-
const user = await getUser(req);
20+
// Direct invocation: read actor from the platform's verified envelope.
21+
// Returns null for anonymous; we preserve the legacy `{ error: 'Unauthorized' }`
22+
// response shape rather than letting auth.requireUser() throw, because the
23+
// platform's 401 envelope shape differs and BC callers parse this body.
24+
const user = await auth.user();
2225
if (!user) {
2326
return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401 });
2427
}

functions/site-search.js

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
// schedule: none (native site search endpoint)
2-
import { adminDb, getUser } from '@run402/functions';
2+
import { adminDb, auth } from '@run402/functions';
33

44
const SEARCH_TYPES = new Set(['all', 'pages', 'resources', 'events']);
55
const TYPE_TO_SOURCE = { pages: 'page', resources: 'resource', events: 'event' };
@@ -71,14 +71,11 @@ function emptyResponse(query, type, page, pageSize) {
7171
};
7272
}
7373

74-
async function isActiveMember(req) {
75-
let user = null;
76-
try {
77-
user = await getUser(req);
78-
} catch {
79-
user = null;
80-
}
74+
async function isActiveMember(_req) {
75+
// auth.user() returns Actor | null and never throws — no try/catch needed.
76+
const user = await auth.user();
8177
if (!user?.id) return false;
78+
// run402-allow-user-filter: adminDb() bypasses RLS to look up member by raw user.id
8279
const rows = await adminDb().from('members').select('id,status,role').eq('user_id', user.id).limit(1);
8380
const member = rows?.[0];
8481
return member?.status === 'active' && ['member', 'moderator', 'admin'].includes(member.role);

functions/translate-content.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
// schedule: none (triggered by client after content publish)
2-
import { adminDb, ai, getUser } from '@run402/functions';
2+
import { adminDb, ai, auth } from '@run402/functions';
33

44
export default async (req) => {
5-
const user = await getUser(req);
5+
const user = await auth.user();
66
if (!user) {
77
return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401 });
88
}

functions/upload-asset.js

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
// dimensions, blurhash, variants, exif policy) via `internal.blobs.metadata`
55
// + intrinsic image columns — Kychon no longer keeps a shadow `media_assets`
66
// table. See `openspec/changes/admin-content-management/design.md` Decision 3.
7-
import { adminDb, assets, getUser } from '@run402/functions';
7+
import { adminDb, assets, auth } from '@run402/functions';
88

99
// Aspect-ratio heuristic for the brand_icon_url upload target. Per
1010
// brand-identity-fields/design.md §3, when an icon upload's intrinsic width
@@ -39,7 +39,7 @@ const STORAGE_PREFIX = 'assets/';
3939
const MEDIA_LIST_LIMIT = 40;
4040

4141
export default async (req) => {
42-
const user = await getUser(req);
42+
const user = await auth.user();
4343
if (!user) {
4444
return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401 });
4545
}
@@ -48,11 +48,10 @@ export default async (req) => {
4848
// safe today because Run402 issues UUIDs, but a future auth path that
4949
// produces a different `sub` shape could turn this into SQL injection. (#25)
5050
//
51-
// TODO(verify): Run402 v2.9.0 ships declarative function-level role gates
52-
// (requireAuth + requireRole). When the existing per-function role checks
53-
// are swept (see `migrate-to-declarative-role-gates` follow-up change), this
54-
// block is replaced by `ctx.user` + `ctx.role` populated by the gateway.
55-
// Left as-is for this change per the agreed scope.
51+
// TODO(auth-aware-ssr): once @run402/astro v2 ships and we drop bearer-token
52+
// auth, swap to `await auth.requireRole('admin')` and let the platform's
53+
// R402_AUTH_INSUFFICIENT_ROLE bubble up as the 403.
54+
// run402-allow-user-filter: adminDb() raw SQL bypasses RLS; user.id binding required
5655
const memberResult = await adminDb().sql('SELECT role FROM members WHERE user_id = $1 LIMIT 1', [user.id]);
5756
if (!memberResult.rows?.length || memberResult.rows[0].role !== 'admin') {
5857
return new Response(JSON.stringify({ error: 'Admin access required' }), { status: 403 });

functions/upload-resource.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
// schedule: none (triggered by client on resource upload)
2-
import { adminDb, assets, getUser } from '@run402/functions';
2+
import { adminDb, assets, auth } from '@run402/functions';
33

44
// File names that flow into the storage path must be limited to safe ASCII
55
// segments — `..`, `/`, NUL, and other surprises would let a caller place
@@ -12,14 +12,15 @@ function pickAssetUrl(ref, fallbackKey) {
1212
}
1313

1414
export default async (req) => {
15-
const user = await getUser(req);
15+
const user = await auth.user();
1616
if (!user) {
1717
return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401 });
1818
}
1919

2020
// Admin-only — mirror the role check in upload-asset.js. The legacy "any
2121
// authenticated user can upload" surface let arbitrary signed-in Run402
2222
// users write to the project's resources bucket. (#25)
23+
// run402-allow-user-filter: adminDb() raw SQL bypasses RLS; user.id binding required
2324
const memberResult = await adminDb().sql('SELECT role FROM members WHERE user_id = $1 LIMIT 1', [user.id]);
2425
const role = memberResult?.rows?.[0]?.role;
2526
if (role !== 'admin') {

package-lock.json

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@
6464
"@radix-ui/react-slot": "1.2.4",
6565
"@radix-ui/react-tooltip": "1.2.8",
6666
"@run402/astro": "^1.2.2",
67-
"@run402/functions": "^2.7.0",
67+
"@run402/functions": "^3.0.0",
6868
"class-variance-authority": "0.7.1",
6969
"clsx": "2.1.1",
7070
"lucide-react": "1.14.0",

tests/unit/kychon-api-execute.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ vi.mock(
9393
'@run402/functions',
9494
() => ({
9595
getUser: vi.fn(async () => mockState.user),
96+
auth: { user: vi.fn(async () => mockState.user) },
9697
adminDb: () => ({
9798
sql(query: string, params: unknown[] = []) {
9899
return mockSql(query, params);

0 commit comments

Comments
 (0)