packages/feed/docs/phase2-steward-migration.md
Status: Planning complete (LARP-assessed). Ready for implementation. Repos involved: Feed · Steward · ElizaCloud Related PR: #1483 (Phase 1 — crypto removal, prerequisite) Assessed: April 2026. 10 bugs found and corrected below.
Feed currently uses Privy for:
useLoginToMiniApp)useLinkAccount)Why replace it:
Phase 1 prerequisite (already merged via PR #1483): All EVM/Solana wallet auth, Agent0, Solana registry, SIWE, and embedded wallets removed. privyId is the only remaining Privy dependency on the backend. Frontend still calls usePrivy() in ~15 files.
Steward is a sibling directory (../steward, separate git repo). The @stwd/sdk package (v0.5.0, verified on npm) is installed via npm/bun. Steward itself is run as a Docker service in local dev (build from sibling source) and hosted on ElizaCloud in production.
Steward (steward.fi) is an agent wallet infrastructure system — encrypted wallets, policy enforcement, API proxy, spend tracking. It also ships a complete auth module (packages/auth) that can serve as a user identity provider:
POST /auth/email/send + /auth/email/verifyPOST /auth/passkey/register/* + /auth/passkey/login/*GET /auth/oauth/google/authorize + callback (VERIFIED: redirects to redirect_uri?token=<jwt>&refreshToken=<rt>)STEWARD_JWT_SECRET env var), 15-minute access tokens + 30-day refresh tokensPOST /auth/refresh (rotation, one-time use)X-Steward-Tenant headerWhat Steward does NOT have (requires custom work on top):
@farcaster/auth-client directly (already installed in Feed)@farcaster/miniapp-sdk's quickAuth.getToken() (already installed, verified working)SDK: @stwd/[email protected] is published on npm. Exports StewardAuth class for frontend use. Verified: exports match Steward source at v0.5.0.
| Login Method | Privy | Steward | Gap / Solution |
|---|---|---|---|
| Email magic link | ✅ | ✅ | None |
| Passkeys (WebAuthn) | ✅ | ✅ | None |
| Google OAuth | ✅ | ✅ | None — redirect flow verified |
| Discord OAuth | ✅ | ✅ | None |
| Twitter/X | ✅ | ❌ | Add to Steward with no-email fix (Steward PR §5a) |
| Farcaster login | ✅ | ❌ | @farcaster/auth-client already installed; createAppClient().verifySignInMessage() |
| Farcaster mini-app | ✅ useLoginToMiniApp | ❌ | sdk.quickAuth.getToken() verified in installed @farcaster/[email protected] |
| Telegram mini-app | ✅ | ❌ | Telegram WebApp SDK HMAC verify |
| JWT issuance | ✅ | ✅ | None |
| Token refresh | ✅ | ✅ | POST /auth/refresh (rotation) |
getAccessToken() | ✅ Privy | ✅ | stewardAuth.getToken() |
useLinkAccount | ✅ | ❌ | Per-provider OAuth redirects (same as login) |
| HTTP-only cookies | ✅ auto | ❌ localStorage | Cookie bridge: POST /api/auth/session sets steward-token httpOnly cookie |
┌──────────────────────────────────────────────────────────────────┐
│ Feed (Next.js) │
│ │
│ StewardAuthProvider (StewardAuth SDK v0.5.0 from npm) │
│ ↓ getToken() ↓ signInWithEmail() ↓ signInWithPasskey() │
│ useAuth hook → LoginModal → auth callbacks │
│ │
│ POST /api/auth/session → sets steward-token httpOnly cookie │
│ (token received via POST body, NOT URL param — see §6 step 10) │
│ │
│ POST /api/auth/farcaster → SIWF verify → provision user │
│ POST /api/auth/farcaster-miniapp → quickAuth verify → same │
│ POST /api/auth/telegram-miniapp → HMAC verify → same │
│ │
│ auth-middleware → jwtVerify(STEWARD_JWT_SECRET, issuer:'steward')│
│ WHERE stewardId = payload.userId (Steward UUID) │
│ OR email bridge → set stewardId │
│ OR social bridge (fid/telegramId) → set stewardId │
└────────────────────────────┬─────────────────────────────────────┘
│ HTTP to port 3200
▼
┌──────────────────────────────────────────────────────────────────┐
│ Steward API (:3200) │
│ │
│ POST /auth/email/send POST /auth/email/verify │
│ POST /auth/passkey/login/* POST /auth/passkey/register/* │
│ GET /auth/oauth/google/* GET /auth/oauth/discord/* │
│ GET /auth/oauth/twitter/* (after Steward PR, with no-email fix) │
│ POST /auth/refresh POST /auth/revoke │
│ GET /auth/session │
│ POST /platform/tenants (platform admin — tenant provisioning) │
│ │
│ JWT env var: STEWARD_JWT_SECRET (auth.ts reads this) │
│ (separate from STEWARD_SESSION_SECRET used by user.ts routes) │
│ │
│ HS256 JWT: { userId (UUID), tenantId:"feed", email?, exp } │
└────────────────────────────┬─────────────────────────────────────┘
│ Postgres (steward DB in Feed's PG)
▼
┌──────────────────────────────────────────────────────────────────┐
│ Steward DB (separate 'steward' database in Feed's Postgres) │
│ users · authenticators · sessions · accounts · refresh_tokens │
│ tenants · user_tenants │
└──────────────────────────────────────────────────────────────────┘
Local dev: Docker Compose, build from ../steward sibling directory
Production: ElizaCloud-hosted Steward instance
User: POST /auth/email/send { email } → Steward sends magic link
User: clicks link → /auth/callback/email?token=...&email=...
Browser: POST /auth/email/verify { token, email, tenantId:"feed" }
Steward: creates/finds user → mints JWT:
{ userId: "uuid", tenantId: "feed", email: "...", iss: "steward", exp: now+900 }
Browser: POST /api/auth/session { token, refreshToken } ← body, NOT URL param
Feed API route: jwtVerify(token, STEWARD_JWT_SECRET) → sets steward-token httpOnly cookie
All subsequent requests: cookie → auth-middleware → WHERE stewardId = payload.userId
Verified: packages/auth/src/oauth.ts exists and supports exactly the extension pattern needed. BUILT_IN_PROVIDERS tuple, getProviderConfig() switch, getEnabledProviders() list.
CRITICAL BUG to fix first: Twitter's v2 API (/2/users/me) does NOT return email. Even requesting the email scope requires special app-level approval from Twitter and Twitter's API returns it in a non-standard field. Steward's provisionOAuthUser() calls findOrCreateUser(email) which will receive "" and fail or create a user with blank email. This makes the plan's Twitter support non-functional without this fix.
Fix: Steward's provisionOAuthUser() and OAuthClient.getUserInfo() must handle the no-email case. Two parts:
Part 1 — packages/auth/src/oauth.ts: Add Twitter config:
case "twitter": {
const clientId = process.env.TWITTER_CLIENT_ID;
const clientSecret = process.env.TWITTER_CLIENT_SECRET;
if (!clientId || !clientSecret) {
throw new Error("Twitter OAuth not configured: TWITTER_CLIENT_ID and TWITTER_CLIENT_SECRET are required");
}
return {
clientId, clientSecret,
authorizationUrl: "https://twitter.com/i/oauth2/authorize",
tokenUrl: "https://api.twitter.com/2/oauth2/token",
userInfoUrl: "https://api.twitter.com/2/users/me?user.fields=id,name,username,profile_image_url",
scopes: ["tweet.read", "users.read", "offline.access"],
};
}
Twitter also requires PKCE (code_challenge_method=S256). Add PKCE support to OAuthClient.generateAuthUrl():
code_verifier (random 43-128 char string)code_challenge = base64url(sha256(code_verifier))code_challenge_method=S256&code_challenge=<challenge> in the auth URLcode_verifier in the challenge store alongside the CSRF statecode_verifier in exchangeCode() body for TwitterPart 2 — packages/api/src/routes/auth.ts provisionOAuthUser(): Change the email requirement from hard-fail to a fallback using Twitter's account ID as a synthetic email:
// Twitter (and potentially other providers) may not return an email.
// Fall back to a deterministic synthetic email using provider + account ID.
// This is never displayed or sent — it's an internal identity key.
const email = providerUser.email
? providerUser.email.toLowerCase().trim()
: `${providerName}.${providerUser.id}@id.steward.internal`;
This allows findOrCreateUser(email) to work. On the Feed side, when we see @id.steward.internal in the email claim, we know it's a Twitter account without a real email and match by accounts.provider + accounts.providerAccountId instead.
Part 3 — getUserInfo() normalization for Twitter:
Twitter's /2/users/me returns { data: { id, name, username, profile_image_url } } not a flat object. The getUserInfo() method in OAuthClient needs a provider-specific data normalization:
// After: const data = await res.json()
// Twitter returns { data: { id, name, username } } not flat
const flat = (data as Record<string, unknown>).data
? (data as { data: Record<string, unknown> }).data
: data as Record<string, unknown>;
return {
id: String(flat["id"] ?? ""),
email: String(flat["email"] ?? ""),
name: flat["name"] != null ? String(flat["name"]) : flat["username"] != null ? String(flat["username"]) : undefined,
// ...
};
Env vars added to Steward .env.example:
TWITTER_CLIENT_ID=
TWITTER_CLIENT_SECRET=
PR target: Steward-Fi/steward → develop branch.
ElizaCloud already has packages/lib/services/steward-client.ts. This PR adds:
docker-compose.yml: Add steward service (build from pinned release or local source)app/api/v1/steward/tenants/route.ts — POST creates a Steward tenant for a customer organization via X-Steward-Platform-KeystewardTenantId + stewardTenantApiKey columns to organizations table.env.example: Add STEWARD_API_URL, STEWARD_PLATFORM_KEYS, STEWARD_MASTER_PASSWORD, STEWARD_JWT_SECRET, STEWARD_SESSION_SECRETdocs/steward-integration.mdPR target: elizaOS/cloud → dev branch.
See §6 for step-by-step.
Branch: feat/steward-auth-phase2 → staging.
Steward runs as a sibling directory (../steward). Docker allows relative build contexts outside the project directory as long as the docker-compose.yml is explicit:
steward:
build:
context: ../steward # sibling directory — verified Docker supports this
dockerfile: Dockerfile
container_name: feed-steward
restart: unless-stopped
ports:
- "3200:3200"
environment:
PORT: 3200
NODE_ENV: development
STEWARD_MASTER_PASSWORD: ${STEWARD_MASTER_PASSWORD}
STEWARD_JWT_SECRET: ${STEWARD_JWT_SECRET}
STEWARD_SESSION_SECRET: ${STEWARD_JWT_SECRET} # same value; Steward uses both var names
DATABASE_URL: "postgresql://feed:feed_dev_password@postgres:5432/steward"
STEWARD_PLATFORM_KEYS: ${STEWARD_PLATFORM_KEYS}
RESEND_API_KEY: ${RESEND_API_KEY:-}
EMAIL_FROM: ${EMAIL_FROM:[email protected]}
APP_URL: ${NEXT_PUBLIC_APP_URL:-http://localhost:3000}
PASSKEY_RP_NAME: Feed
PASSKEY_RP_ID: localhost
PASSKEY_ORIGIN: ${NEXT_PUBLIC_APP_URL:-http://localhost:3000}
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
DISCORD_CLIENT_ID: ${DISCORD_CLIENT_ID:-}
DISCORD_CLIENT_SECRET: ${DISCORD_CLIENT_SECRET:-}
TWITTER_CLIENT_ID: ${TWITTER_CLIENT_ID:-}
TWITTER_CLIENT_SECRET: ${TWITTER_CLIENT_SECRET:-}
depends_on:
postgres:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "curl -sf http://localhost:3200/health || exit 1"]
interval: 15s
timeout: 5s
retries: 8
start_period: 45s
Note on env var naming: Steward's auth.ts reads STEWARD_JWT_SECRET; user.ts reads STEWARD_SESSION_SECRET. Both must be set. In docker-compose, set both to the same value.
Create the steward Postgres database on first boot via an init script at scripts/docker/init-steward-db.sh:
#!/bin/bash
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" <<-EOSQL
SELECT 'CREATE DATABASE steward'
WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'steward')\gexec
GRANT ALL PRIVILEGES ON DATABASE steward TO $POSTGRES_USER;
EOSQL
Mount it in the postgres service:
volumes:
- postgres_data:/var/lib/postgresql/data
- ./scripts/docker/init-steward-db.sh:/docker-entrypoint-initdb.d/20-steward.sh
Steward runs runMigrations() automatically on startup. No manual step.
scripts/pre-dev/pre-dev-local.tsAdd Steward health check after existing services:
const STEWARD_CONTAINER = 'feed-steward';
const stewardRunning = await $`docker ps --filter name=${STEWARD_CONTAINER} --format "{{.Names}}"`.quiet().text();
if (stewardRunning.trim() !== STEWARD_CONTAINER) {
console.info('[Script] Starting Steward auth service...');
await dockerComposeUp('steward' as DockerService).catch(() => {
console.warn('[Script] ⚠️ Steward start failed — auth will not work');
});
}
let stewardReady = false;
for (let i = 0; i < 30; i++) {
const ok = await fetch('http://localhost:3200/health').then(r => r.ok).catch(() => false);
if (ok) { stewardReady = true; break; }
await new Promise(r => setTimeout(r, 2000));
}
console.info(stewardReady
? '[Script] ✅ Steward is ready at http://localhost:3200'
: '[Script] ⚠️ Steward did not become healthy within 60s');
Add to status printout: Steward: http://localhost:3200
scripts/steward-init.ts)VERIFIED: POST /platform/tenants exists in Steward and requires X-Steward-Platform-Key header. Returns { ok: true, apiKey: "stw_...", tenant: { id } } on creation, 409 on conflict.
#!/usr/bin/env bun
/**
* One-time idempotent script to provision the "feed" tenant in Steward.
* Run: bun run scripts/steward-init.ts
* Copy the output STEWARD_TENANT_ID and STEWARD_TENANT_API_KEY into .env
*/
const STEWARD_API_URL = process.env.STEWARD_API_URL ?? 'http://localhost:3200';
const PLATFORM_KEY = (process.env.STEWARD_PLATFORM_KEYS ?? '').split(',')[0].trim();
if (!PLATFORM_KEY) { console.error('❌ STEWARD_PLATFORM_KEYS is required'); process.exit(1); }
const res = await fetch(`${STEWARD_API_URL}/platform/tenants`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Steward-Platform-Key': PLATFORM_KEY,
},
body: JSON.stringify({ id: 'feed', name: 'Feed Social' }),
});
const data = await res.json() as { ok: boolean; apiKey?: string; error?: string };
if (res.status === 409) {
console.info('ℹ️ Tenant "feed" already exists. API key is not re-returned. Check existing .env.');
} else if (!res.ok) {
console.error('❌ Failed:', data.error); process.exit(1);
} else {
console.info('✅ Feed tenant created. Add to .env:\n');
console.info(`STEWARD_TENANT_ID=feed`);
console.info(`STEWARD_TENANT_API_KEY=${data.apiKey}`);
}
Add to root package.json: "steward:init": "bun run scripts/steward-init.ts"
VERIFIED: Steward has NO admin endpoint to create users without sending emails. This blocks the migration strategy. We must add one.
Steward PR addition — packages/api/src/routes/platform.ts, new route:
/**
* POST /platform/users
* Provision a user record in Steward without sending email.
* Intended for migration use: pre-seeding users from another auth provider.
*
* Body: { email: string; emailVerified?: boolean; name?: string }
* Returns: { ok: true; userId: string; isNew: boolean }
*/
platform.post("/users", async (c) => {
const body = await safeJsonParse<{ email: string; emailVerified?: boolean; name?: string }>(c);
if (!body?.email) {
return c.json<ApiResponse>({ ok: false, error: "email is required" }, 400);
}
const db = getDb();
const email = body.email.toLowerCase().trim();
const [existing] = await db.select({ id: users.id }).from(users).where(eq(users.email, email));
if (existing) {
return c.json({ ok: true, userId: existing.id, isNew: false });
}
const [newUser] = await db
.insert(users)
.values({ email, emailVerified: body.emailVerified ?? false, name: body.name ?? null })
.returning({ id: users.id });
return c.json({ ok: true, userId: newUser.id, isNew: true });
});
This is part of the Steward PR. Without it, the migration script can't pre-seed users safely in production.
stewardId to Feed usersIn packages/db/src/schema/users.ts:
stewardId: text('stewardId').unique(),
Generate and apply:
bun run db:generate
bun run db:migrate
privyId stays nullable — coexists with stewardId until Phase 3 drops it.
scripts/migrate-privy-to-steward.ts)CORRECTED: The original plan said "direct Postgres INSERT into Steward's users table". This is impossible in production (ElizaCloud-hosted Steward). The script now calls Steward's new /platform/users admin endpoint instead.
#!/usr/bin/env bun
/**
* Migrate all Privy users to Steward.
*
* Phase A: Export from Privy Admin API
* Phase B: Pre-seed each user in Steward via POST /platform/users
* Phase C: Build manifest of email-less users (social-only accounts)
* Phase D: Report (no DB writes to Feed — those happen at runtime via bridge)
*
* Usage:
* bun run scripts/migrate-privy-to-steward.ts --dry-run # report only
* bun run scripts/migrate-privy-to-steward.ts # actually migrate
*/
import { writeFileSync } from 'fs';
const PRIVY_APP_ID = process.env.NEXT_PUBLIC_PRIVY_APP_ID ?? '';
const PRIVY_APP_SECRET = process.env.PRIVY_APP_SECRET ?? '';
const STEWARD_API_URL = process.env.STEWARD_API_URL ?? 'http://localhost:3200';
const PLATFORM_KEY = (process.env.STEWARD_PLATFORM_KEYS ?? '').split(',')[0].trim();
const DRY_RUN = process.argv.includes('--dry-run');
if (!PRIVY_APP_ID || !PRIVY_APP_SECRET) {
console.error('❌ NEXT_PUBLIC_PRIVY_APP_ID and PRIVY_APP_SECRET required (read Privy dashboard)');
process.exit(1);
}
if (!PLATFORM_KEY) {
console.error('❌ STEWARD_PLATFORM_KEYS required');
process.exit(1);
}
const basicAuth = Buffer.from(`${PRIVY_APP_ID}:${PRIVY_APP_SECRET}`).toString('base64');
// Phase A: paginate all Privy users
// Privy Admin API: GET https://auth.privy.io/api/v1/users
// Auth: Basic base64(appId:appSecret) + privy-app-id header
// Returns: { data: User[], next_cursor?: string }
const allUsers: Array<{
id: string; // did:privy:xxx
email?: { address: string };
farcaster?: { fid: number; username: string };
twitter?: { username: string };
telegram?: { telegram_user_id: string; username: string };
}> = [];
let cursor: string | undefined;
do {
const url = new URL('https://auth.privy.io/api/v1/users');
url.searchParams.set('limit', '500');
if (cursor) url.searchParams.set('cursor', cursor);
const res = await fetch(url.toString(), {
headers: {
'Authorization': `Basic ${basicAuth}`,
'privy-app-id': PRIVY_APP_ID,
},
});
if (!res.ok) { console.error(`Privy API error: ${res.status} ${await res.text()}`); process.exit(1); }
const body = await res.json() as { data: typeof allUsers; next_cursor?: string };
allUsers.push(...body.data);
cursor = body.next_cursor;
console.info(`Fetched ${allUsers.length} users so far...`);
} while (cursor);
console.info(`✅ Exported ${allUsers.length} Privy users`);
const withEmail = allUsers.filter(u => u.email?.address);
const withoutEmail = allUsers.filter(u => !u.email?.address);
console.info(` With email: ${withEmail.length}`);
console.info(` Without email (social-only): ${withoutEmail.length}`);
// Phase B: pre-seed users in Steward
let seeded = 0, existed = 0, failed = 0;
for (const user of withEmail) {
const email = user.email!.address;
if (DRY_RUN) { seeded++; continue; }
const res = await fetch(`${STEWARD_API_URL}/platform/users`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Steward-Platform-Key': PLATFORM_KEY,
},
body: JSON.stringify({ email, emailVerified: true }),
});
const data = await res.json() as { ok: boolean; isNew?: boolean };
if (!data.ok) { failed++; console.warn(`Failed: ${email}`); }
else if (data.isNew) seeded++;
else existed++;
}
console.info(`\n✅ Steward seeding: ${seeded} created, ${existed} already existed, ${failed} failed`);
// Phase C: write manifest of email-less users
const emaillessManifest = withoutEmail.map(u => ({
privyId: u.id,
farcasterFid: u.farcaster?.fid ?? null,
farcasterUsername: u.farcaster?.username ?? null,
twitterUsername: u.twitter?.username ?? null,
telegramId: u.telegram?.telegram_user_id ?? null,
telegramUsername: u.telegram?.username ?? null,
}));
writeFileSync('migrations/privy-emailless-users.json', JSON.stringify(emaillessManifest, null, 2));
console.info(`📄 Wrote ${emaillessManifest.length} email-less users to migrations/privy-emailless-users.json`);
console.info(' These users will be auto-linked at login time via social profile matching.');
packages/api/src/auth-middleware.tsReplace PrivyClient.verifyAuthToken() with jwtVerify from jose.
Verified: Steward's auth.ts uses STEWARD_JWT_SECRET env var and issuer: "steward" with HS256. Our middleware verification must match exactly.
import { jwtVerify } from 'jose';
const STEWARD_JWT_SECRET = new TextEncoder().encode(
process.env.STEWARD_JWT_SECRET ?? ''
);
// In authenticate():
// Cookie takes priority over Authorization header (same pattern as Privy cookie)
const cookieToken = request.cookies.get('steward-token')?.value;
const authHeaderToken = request.headers.get('authorization')?.startsWith('Bearer ')
? request.headers.get('authorization')!.slice(7)
: undefined;
const token = cookieToken ?? authHeaderToken;
if (!token) throw new AuthenticationError('Missing authentication');
const { payload } = await jwtVerify(token, STEWARD_JWT_SECRET, {
issuer: 'steward',
algorithms: ['HS256'],
});
// payload: { userId: string, tenantId: string, email?: string, address?: string }
User lookup chain (replaces WHERE privyId = claims.userId):
// 1. Fast path: already linked
let dbUser = await db.select().from(users)
.where(eq(users.stewardId, payload.userId as string))
.limit(1).then(r => r[0]);
// 2. Email bridge: existing Privy user logs in via Steward for first time
if (!dbUser && payload.email && !String(payload.email).includes('@id.steward.internal')) {
const emailUser = await db.select().from(users)
.where(and(eq(users.email, payload.email as string), isNull(users.stewardId)))
.limit(1).then(r => r[0]);
if (emailUser) {
await db.update(users).set({ stewardId: payload.userId as string })
.where(eq(users.id, emailUser.id));
dbUser = { ...emailUser, stewardId: payload.userId as string };
}
}
// 3. New user: first time we've seen this Steward userId
if (!dbUser) {
dbUser = await ensureUserFromSteward(
payload.userId as string,
payload.email && !String(payload.email).includes('@id.steward.internal')
? payload.email as string : undefined,
);
}
Dev bypass paths: Update test DID format:
did:privy:test-${userId} (format from Phase 1)steward:test:${userId}Update all integration test fixtures. The extractDevUserIdFromBearerToken function in dev-credentials.ts needs updating to match the new prefix.
CORRECTED from plan: Remove PRIVY_AUTH_FALLBACK flag. It was a placeholder with no implementation. Instead, during cutover, the auth-middleware simply tries Steward JWT first. If it fails (wrong issuer or wrong secret), it falls through to an error. Users with old Privy sessions will see a login prompt, which is expected behavior. No parallel dual-auth system is needed — the cookie name changed (privy-token → steward-token), so old Privy sessions naturally expire within their TTL (typically 24 hours for Privy JWTs) and users re-authenticate.
packages/api/src/users/ensure-user.tsAdd ensureUserFromSteward():
export async function ensureUserFromSteward(
stewardUserId: string,
email?: string,
): Promise<CanonicalUser> {
const [user] = await db.insert(users)
.values({
stewardId: stewardUserId,
email: email ?? null,
})
.onConflictDoUpdate({
target: users.stewardId,
set: { email: email ?? sql`excluded.email` },
})
.returning(canonicalUserSelect);
return user;
}
Update findUserByIdentifier() in user-lookup.ts: add 'stewardId' alongside existing 'id', 'privyId', 'username' kinds. The resolveUserIdentifierKind() function in packages/shared needs updating to detect Steward UUIDs (standard UUID v4 format) as the 'stewardId' kind. Add a separate UUID regex path: if identifier matches /^[0-9a-f-]{36}$/ and is not a known Feed ID, try stewardId lookup.
apps/web/src/app/api/auth/session/route.ts (new):
// POST: validates Steward JWT, sets httpOnly steward-token cookie
// DELETE: clears the cookie
export async function POST(req: NextRequest) {
const { token, refreshToken } = await req.json() as { token: string; refreshToken?: string };
// Verify locally before storing — reject tampered tokens immediately
const STEWARD_JWT_SECRET = new TextEncoder().encode(process.env.STEWARD_JWT_SECRET ?? '');
const { payload } = await jwtVerify(token, STEWARD_JWT_SECRET, {
issuer: 'steward',
algorithms: ['HS256'],
}).catch(() => { throw new Error('Invalid token'); });
const response = NextResponse.json({ ok: true, userId: payload.userId });
response.cookies.set('steward-token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 30, // 30d (refresh token lifetime)
path: '/',
});
// Refresh token stored in a separate httpOnly cookie
if (refreshToken) {
response.cookies.set('steward-refresh', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 30,
path: '/',
});
}
return response;
}
export async function DELETE() {
const response = NextResponse.json({ ok: true });
response.cookies.delete('steward-token');
response.cookies.delete('steward-refresh');
return response;
}
apps/web/src/app/auth/callback/email/page.tsx (new):
'use client';
import { useEffect } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { StewardAuth } from '@stwd/sdk';
export default function EmailCallbackPage() {
const router = useRouter();
const params = useSearchParams();
useEffect(() => {
const token = params.get('token');
const email = params.get('email');
const returnTo = params.get('returnTo') ?? '/feed';
if (!token || !email) { router.replace('/'); return; }
const auth = new StewardAuth({ baseUrl: process.env.NEXT_PUBLIC_STEWARD_API_URL! });
auth.verifyEmailCallback(token, email).then(async (result) => {
// Send token via POST body — NOT URL params — to avoid browser history / server log exposure
await fetch('/api/auth/session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: result.token, refreshToken: result.refreshToken }),
});
router.replace(returnTo);
}).catch(() => router.replace('/?error=auth_failed'));
}, []);
return <div>Completing sign in…</div>;
}
apps/web/src/app/auth/callback/[provider]/page.tsx (new, for Google/Discord/Twitter):
CORRECTED security issue from plan: The Steward OAuth callback delivers the JWT as a URL query param (?token=<jwt>). This exposes the token in browser history, server access logs, and Referer headers. Fix: immediately read and then replace the URL:
'use client';
import { useEffect } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
export default function OAuthCallbackPage() {
const router = useRouter();
const params = useSearchParams();
useEffect(() => {
const token = params.get('token');
const refreshToken = params.get('refreshToken');
const returnTo = params.get('returnTo') ?? '/feed';
// SECURITY: Replace URL immediately to remove token from browser history
// before any async work that could be interrupted
window.history.replaceState(null, '', window.location.pathname);
if (!token) { router.replace('/'); return; }
fetch('/api/auth/session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token, refreshToken }),
}).then(() => router.replace(returnTo))
.catch(() => router.replace('/?error=auth_failed'));
}, []);
return <div>Completing sign in…</div>;
}
OAuth App setup: The OAuth providers (Google, Discord, Twitter) must whitelist the Steward API's callback URL, NOT Feed's:
http://localhost:3200/auth/oauth/google/callback (dev), https://<steward-prod-url>/auth/oauth/google/callback (prod)Feed's /auth/callback/[provider] page receives the already-processed JWT from Steward, not the OAuth code. This is a two-hop redirect: Provider → Steward callback → Feed callback.
StewardAuthProvider.tsxNew file: apps/web/src/components/providers/StewardAuthProvider.tsx
'use client';
import { StewardAuth, type StewardSession } from '@stwd/sdk';
import { createContext, useContext, useEffect, useState } from 'react';
const STEWARD_API_URL = process.env.NEXT_PUBLIC_STEWARD_API_URL!;
// Module-level singleton — one StewardAuth instance for the lifetime of the browser tab
// This is safe because StewardAuth stores state in localStorage and notifies via callbacks
const _stewardAuth = new StewardAuth({ baseUrl: STEWARD_API_URL });
export interface StewardAuthContextValue {
auth: StewardAuth;
session: StewardSession | null;
isLoading: boolean;
}
const StewardAuthContext = createContext<StewardAuthContextValue | null>(null);
export function StewardAuthProvider({ children }: { children: React.ReactNode }) {
const [session, setSession] = useState<StewardSession | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
setSession(_stewardAuth.getSession());
setIsLoading(false);
return _stewardAuth.onSessionChange(setSession);
}, []);
return (
<StewardAuthContext.Provider value={{ auth: _stewardAuth, session, isLoading }}>
{children}
</StewardAuthContext.Provider>
);
}
export function useStewardAuth(): StewardAuthContextValue {
const ctx = useContext(StewardAuthContext);
if (!ctx) throw new Error('useStewardAuth must be used within StewardAuthProvider');
return ctx;
}
apps/web/src/components/providers/Providers.tsxRemove PrivyProvider, ThemedPrivyProvider, PrivyProviderWrapper and all Privy config imports. Add StewardAuthProvider in their place.
apps/web/src/hooks/useAuth.tsPublic API surface stays identical. Internal implementation changes:
| Old (Privy) | New (Steward) |
|---|---|
const { authenticated } = usePrivy() | !!session from useStewardAuth() |
getAccessToken() | reads steward-token httpOnly cookie via GET /api/auth/token, or stewardAuth.getToken() in client context |
login() | sets isLoginModalOpen = true (new modal state) |
logout() | stewardAuth.revokeSession() + DELETE /api/auth/session |
user.id (DID did:privy:xxx) | session.userId (Steward UUID) |
user.email | session.email |
getAccessToken() implementation detail: When called server-side (SSR), it reads the steward-token cookie from the request. When called client-side, it returns stewardAuth.getToken(). The SDK auto-refreshes when near expiry (120-second threshold built in).
apps/web/src/components/auth/LoginModal.tsx)New component. Opens when useAuth().login() is called.
Verified packages: @farcaster/[email protected] is already in apps/web/package.json (v0.8.1 specified). Peer deps are only react >= 17 — no wagmi/viem conflict. @farcaster/[email protected] is also already installed.
Button implementations:
stewardAuth.signInWithPasskey(email) → on success: POST /api/auth/session { token, refreshToken } → close modalstewardAuth.signInWithEmail(email) → shows "Check inbox" state. Callback page handles the rest.${STEWARD_API_URL}/auth/oauth/google/authorize?redirect_uri=${APP_URL}/auth/callback/google&tenant_id=feed<SignInButton> from @farcaster/auth-kit; on success, data goes to POST /api/auth/farcasterapps/web/src/app/api/auth/farcaster/route.ts (new):
import { createAppClient, viemConnector } from '@farcaster/auth-client';
export async function POST(req: NextRequest) {
const { message, signature, nonce } = await req.json();
// Server-side verification using @farcaster/auth-client (already installed)
const appClient = createAppClient({
relay: 'https://relay.farcaster.xyz',
ethereum: viemConnector(),
});
const { data, success, fid } = await appClient.verifySignInMessage({
message,
signature,
nonce,
domain: new URL(process.env.NEXT_PUBLIC_APP_URL!).hostname,
});
if (!success) return NextResponse.json({ ok: false, error: 'Invalid Farcaster signature' }, { status: 401 });
// Look up existing Feed user by FID, then stewardId, then create
// Use ensureUserFromFarcaster() — create if needed, mint JWT
const { token, refreshToken } = await mintSessionForFarcasterUser(fid, data);
return NextResponse.json({ ok: true, token, refreshToken });
}
mintSessionForFarcasterUser(): finds or creates Feed user by FID (social profile table), then creates a Steward user via POST /platform/users if one doesn't exist, gets back the stewardId, mints a JWT using jose SignJWT with STEWARD_JWT_SECRET in the same format Steward uses:
const token = await new SignJWT({ userId: stewardUserId, tenantId: 'feed', fid })
.setProtectedHeader({ alg: 'HS256' })
.setIssuer('steward')
.setIssuedAt()
.setExpirationTime('15m')
.sign(new TextEncoder().encode(process.env.STEWARD_JWT_SECRET!));
CORRECTED from original plan: The original said "Feed mints a JWT" without ensuring the userId exists in Steward's users table. Fixed: we call POST /platform/users first to ensure the Steward user record exists, THEN use the returned userId as the JWT's userId claim. This ensures WHERE stewardId = payload.userId succeeds in auth-middleware.
VERIFIED: quickAuth.getToken() exists in @farcaster/[email protected] (installed). It internally calls miniAppHost.signIn() which is the same as the current flow that calls sdk.actions.signIn(). The quickAuth.getToken() is the correct abstraction.
VERIFIED: quickAuth.getToken() returns { token: string } where the JWT payload has { sub: fid (number), address, iss, aud, exp, iat }.
apps/web/src/components/providers/FarcasterMiniAppProvider.tsx:
Replace:
import { usePrivy } from '@privy-io/react-auth';
import { useLoginToMiniApp } from '@privy-io/react-auth/farcaster';
// ...
const { initLoginToMiniApp, loginToMiniApp } = useLoginToMiniApp();
const { nonce } = await initLoginToMiniApp();
const result = await sdk.actions.signIn({ nonce });
await loginToMiniApp({ message, signature });
With:
import { sdk } from '@farcaster/miniapp-sdk';
// ...
const { token } = await sdk.quickAuth.getToken();
const res = await fetch('/api/auth/farcaster-miniapp', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
});
const { sessionToken, refreshToken } = await res.json();
await fetch('/api/auth/session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: sessionToken, refreshToken }),
});
Also remove the createWallet useEffect entirely (embedded wallets removed in Phase 1).
apps/web/src/app/api/auth/farcaster-miniapp/route.ts (new):
import { createClient } from '@farcaster/quick-auth';
export async function POST(req: NextRequest) {
const { token } = await req.json();
// VERIFIED: createClient() has verifyJwt() method that fetches JWKS from
// https://auth.farcaster.xyz — requires network access (not a local verify).
// This is acceptable for a server-side API route.
const quickAuthClient = createClient(); // from @farcaster/quick-auth (already installed)
const payload = await quickAuthClient.verifyJwt({
token,
domain: new URL(process.env.NEXT_PUBLIC_APP_URL!).hostname,
});
// payload.sub = FID (number), payload.address = custody address
const fid = payload.sub; // number
const { sessionToken, refreshToken } = await mintSessionForFarcasterUser(fid, {});
return NextResponse.json({ ok: true, sessionToken, refreshToken });
}
CLARIFIED: verifyJwt makes a network call to https://auth.farcaster.xyz to fetch the JWKS public key. This is a ~50-100ms network request on the server side, acceptable. The original plan didn't mention this — it's now documented.
CLARIFIED: The domain parameter in verifyJwt must match the Farcaster app's registered domain. In dev: localhost. In prod: feed.social (or whatever). Use new URL(process.env.NEXT_PUBLIC_APP_URL!).hostname to derive it.
apps/web/src/components/providers/TelegramMiniAppProvider.tsx:
Replace Privy Telegram auth with:
const initData = window.Telegram?.WebApp?.initData;
if (initData) {
const res = await fetch('/api/auth/telegram-miniapp', {
method: 'POST',
body: JSON.stringify({ initData }),
});
const { token, refreshToken } = await res.json();
await fetch('/api/auth/session', {
method: 'POST',
body: JSON.stringify({ token, refreshToken }),
});
}
apps/web/src/app/api/auth/telegram-miniapp/route.ts (new):
import crypto from 'node:crypto';
export async function POST(req: NextRequest) {
const { initData } = await req.json();
// Standard Telegram WebApp initData HMAC-SHA256 verification
// https://core.telegram.org/bots/webapps#validating-data-received-via-the-mini-app
const BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN;
if (!BOT_TOKEN) return NextResponse.json({ ok: false, error: 'Telegram not configured' }, { status: 503 });
const params = new URLSearchParams(initData);
const hash = params.get('hash');
params.delete('hash');
const dataCheckString = [...params.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([k, v]) => `${k}=${v}`)
.join('\n');
const secretKey = crypto.createHmac('sha256', 'WebAppData').update(BOT_TOKEN).digest();
const computedHash = crypto.createHmac('sha256', secretKey).update(dataCheckString).digest('hex');
if (computedHash !== hash) {
return NextResponse.json({ ok: false, error: 'Invalid Telegram initData' }, { status: 401 });
}
const user = JSON.parse(params.get('user') ?? '{}') as { id: number; username?: string };
const { token, refreshToken } = await mintSessionForTelegramUser(user.id, user.username);
return NextResponse.json({ ok: true, token, refreshToken });
}
mintSessionForTelegramUser(): same pattern as Farcaster — find/create Feed user by Telegram ID, ensure Steward user exists via POST /platform/users, mint JWT.
apps/web/src/components/profile/LinkSocialAccountsModal.tsx:
Remove useLinkAccount from @privy-io/react-auth. Replace per platform:
mode=link&feedUserId=${userId} query params (Steward stores these in state; after OAuth completes, Feed callback calls POST /api/users/[userId]/link-social)<SignInButton> from @farcaster/auth-kit in link mode; on success POST to /api/auth/farcaster → /api/users/[userId]/link-social/api/auth/telegram-miniapp → /api/users/[userId]/link-socialThe existing POST /api/users/[userId]/link-social route already supports non-wallet platforms. No changes needed to the route itself.
getAccessToken callers (9 files)All these files do const { getAccessToken } = usePrivy(). Change to useAuth():
apps/web/src/hooks/useSSE.tsapps/web/src/hooks/useChatMessages.tsapps/web/src/hooks/useTeamChat.tsapps/web/src/hooks/useToggleReaction.tsapps/web/src/hooks/useQueuedOutcomes.tsapps/web/src/components/points/BuyPointsModal.tsxapps/web/src/components/chats/NftVerificationBanner.tsxapps/web/src/components/settings/SecurityTab.tsxapps/web/src/components/providers/OnboardingProvider.tsx (also uses const { user: privyUser } = usePrivy() — replace with Feed user from useAuth())CORRECTED: @farcaster/auth-kit and @farcaster/auth-client are already installed in apps/web/package.json. Do NOT re-add them. @stwd/sdk is new.
# Remove
bun remove @privy-io/react-auth @privy-io/server-auth @privy-io/node
# Add (only what's actually missing)
bun add @stwd/sdk @simplewebauthn/browser
# Note: @simplewebauthn/browser is a peer dep of StewardAuth passkey flow — not auto-installed
Delete:
packages/shared/src/auth/privy-config.tspackages/api/src/services/privy/privy-node.tspackages/api/src/services/privy/authed-user.ts (if remaining)What does NOT work:
What DOES work (the corrected strategy):
Layer 1 — Pre-seeding via Steward admin API (requires new Steward PR endpoint POST /platform/users)
Run scripts/migrate-privy-to-steward.ts against production AFTER the Steward PR is deployed:
POST /platform/users { email, emailVerified: true } — creates Steward user record, returns UUIDmigrations/privy-emailless-users.jsonLayer 2 — Runtime email bridge (in auth-middleware.ts)
When Steward JWT arrives and stewardId not found in Feed:
payload.email exists (real email, not @id.steward.internal): look up Feed user by email, set stewardId on matchLayer 3 — Runtime social bridge (in custom auth API routes)
When Farcaster/Twitter/Telegram user logs in through their respective custom API route:
socialProfiles tablestewardId on matchLayer 4 — "Claim account" prompt (for edge cases)
If user cannot be matched automatically:
| Category | % (estimated) | Strategy |
|---|---|---|
| Has email in Privy | ~70% | Pre-seed + email bridge at login |
| Farcaster-only | ~20% | Farcaster API route links by FID |
| Twitter-only | ~7% | Twitter API route links by username |
| Telegram-only | ~2% | Telegram API route links by Telegram ID |
| True orphans (no linked data) | <1% | Claim account prompt |
bun run scripts/migrate-privy-to-steward.ts --dry-run → review reportNEXT_PUBLIC_PRIVY_APP_ID in env (login modal still shows as fallback during transition — actually the old modal is completely replaced, so there is no fallback. Users who have existing privy-token cookies will get a login prompt because the cookie name changed. This is expected. They re-authenticate once.)bun run scripts/migrate-privy-to-steward.ts → pre-seed Steward usersPRIVY_APP_SECRET from all environments, delete Privy app from Privy dashboardThe original plan described PRIVY_AUTH_FALLBACK=true as a grace period flag. This does not need to exist. Privy's JWTs use privy-token cookies. Steward uses steward-token cookies. These are different cookie names. When we deploy:
privy-token cookies are ignored by the new middleware (it only reads steward-token)steward-token set, are linked via email/social bridgeThere is no dual-auth complexity needed. The old cookies simply don't work and users re-authenticate once. This is acceptable for a breaking auth migration.
NEXT_PUBLIC_PRIVY_APP_ID
PRIVY_APP_SECRET
PRIVY_APP_ID
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID
# ── Steward Auth Service ────────────────────────────────────────────────────
STEWARD_API_URL=http://localhost:3200
NEXT_PUBLIC_STEWARD_API_URL=http://localhost:3200
# [REQUIRED] Vault master password. Rotating requires re-encrypting all vault entries.
# Generate: openssl rand -hex 32
STEWARD_MASTER_PASSWORD=
# [REQUIRED] JWT signing secret used by Steward's auth.ts module.
# Feed's auth-middleware verifies JWTs using this secret.
# Generate: openssl rand -hex 32
STEWARD_JWT_SECRET=
# [REQUIRED] Same value as STEWARD_JWT_SECRET — Steward's user.ts reads this var name.
# Set both to the same value to avoid confusion.
STEWARD_SESSION_SECRET= # <- set equal to STEWARD_JWT_SECRET
# [REQUIRED] Platform operator key(s). Comma-separated. Used for tenant admin.
# Generate: openssl rand -hex 32
STEWARD_PLATFORM_KEYS=
# [REQUIRED] Output of bun run steward:init
STEWARD_TENANT_ID=feed
STEWARD_TENANT_API_KEY=
# ── OAuth providers (all routed through Steward) ────────────────────────────
# Google: console.cloud.google.com
# Redirect URI in Google: http://localhost:3200/auth/oauth/google/callback
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# Discord: discord.com/developers/applications
# Redirect URI in Discord: http://localhost:3200/auth/oauth/discord/callback
DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET=
# Twitter/X: developer.twitter.com
# App type: Confidential client, OAuth2 with PKCE
# Scopes: tweet.read users.read offline.access
# Redirect URI in Twitter: http://localhost:3200/auth/oauth/twitter/callback
# NOTE: Twitter does NOT return email via API.
# Steward uses a synthetic internal email ([email protected]) for Twitter users.
TWITTER_CLIENT_ID=
TWITTER_CLIENT_SECRET=
# ── Email (Steward sends magic links via Resend) ────────────────────────────
# If RESEND_API_KEY is blank, tokens are printed to console (safe for dev)
RESEND_API_KEY=
[email protected]
# ── Telegram (mini-app HMAC verification) ──────────────────────────────────
TELEGRAM_BOT_TOKEN=
Google Cloud Console:
http://localhost:3200/auth/oauth/google/callbackhttps://<steward-api-prod-url>/auth/oauth/google/callbackDiscord Developer Portal:
http://localhost:3200/auth/oauth/discord/callbackTwitter Developer Portal:
http://localhost:3200/auth/oauth/twitter/callbacktweet.read users.read offline.accessSteward-Fi/steward)| File | Change |
|---|---|
packages/auth/src/oauth.ts | Add Twitter/X with PKCE + no-email fix |
packages/api/src/routes/auth.ts | provisionOAuthUser(): synthetic email for no-email providers |
packages/api/src/routes/platform.ts | NEW route: POST /platform/users (migration support) |
packages/auth/src/__tests__/oauth.test.ts | Add Twitter test cases |
.env.example | Add TWITTER_CLIENT_ID, TWITTER_CLIENT_SECRET |
elizaOS/cloud)| File | Change |
|---|---|
docker-compose.yml | Add steward service |
app/api/v1/steward/tenants/route.ts | NEW — tenant provisioning API |
packages/db/schemas/organizations.ts | Add stewardTenantId, stewardTenantApiKey |
packages/db/migrations/<N>_steward_tenant.sql | NEW |
.env.example | Add Steward env vars |
docs/steward-integration.md | NEW |
FeedSocial/feed)Infrastructure:
| File | Change |
|---|---|
docker-compose.yml | Add steward service (build from ../steward) |
scripts/docker/init-steward-db.sh | NEW |
scripts/pre-dev/pre-dev-local.ts | Add Steward startup + health check |
scripts/steward-init.ts | NEW |
scripts/migrate-privy-to-steward.ts | NEW |
migrations/privy-emailless-users.json | GENERATED (gitignored) |
Database:
| File | Change |
|---|---|
packages/db/src/schema/users.ts | Add stewardId: text().unique() |
packages/db/drizzle/migrations/<N>.sql | NEW |
Backend:
| File | Change |
|---|---|
packages/api/src/auth-middleware.ts | Replace Privy → jose jwtVerify, steward-token cookie, user lookup chain |
packages/api/src/users/ensure-user.ts | Add ensureUserFromSteward(), mintSessionForFarcasterUser(), mintSessionForTelegramUser() |
packages/api/src/users/user-lookup.ts | Add 'stewardId' kind |
packages/api/src/dev-credentials.ts | Update test DID prefix did:privy:test- → steward:test: |
packages/shared/src/auth/privy-config.ts | DELETE |
packages/api/src/services/privy/privy-node.ts | DELETE |
packages/api/src/services/privy/authed-user.ts | DELETE (if remaining) |
Frontend — providers:
| File | Change |
|---|---|
apps/web/src/components/providers/StewardAuthProvider.tsx | NEW |
apps/web/src/components/providers/Providers.tsx | Remove Privy, add Steward |
apps/web/src/components/providers/FarcasterMiniAppProvider.tsx | Remove Privy; use quickAuth.getToken() |
apps/web/src/components/providers/OnboardingProvider.tsx | Remove usePrivy() |
Frontend — hooks:
| File | Change |
|---|---|
apps/web/src/hooks/useAuth.ts | Full rewrite |
apps/web/src/hooks/useSSE.ts | getAccessToken from useAuth |
apps/web/src/hooks/useChatMessages.ts | getAccessToken from useAuth |
apps/web/src/hooks/useTeamChat.ts | getAccessToken from useAuth |
apps/web/src/hooks/useToggleReaction.ts | getAccessToken from useAuth |
apps/web/src/hooks/useQueuedOutcomes.ts | getAccessToken from useAuth |
Frontend — components:
| File | Change |
|---|---|
apps/web/src/components/auth/LoginModal.tsx | NEW |
apps/web/src/components/profile/LinkSocialAccountsModal.tsx | Remove useLinkAccount |
apps/web/src/components/points/BuyPointsModal.tsx | getAccessToken from useAuth |
apps/web/src/components/chats/NftVerificationBanner.tsx | getAccessToken from useAuth |
apps/web/src/components/settings/SecurityTab.tsx | Remove usePrivy/useWallets |
Frontend — pages & API routes:
| File | Change |
|---|---|
apps/web/src/app/api/auth/session/route.ts | NEW — cookie bridge (POST/DELETE) |
apps/web/src/app/api/auth/farcaster/route.ts | NEW — SIWF exchange |
apps/web/src/app/api/auth/farcaster-miniapp/route.ts | NEW — quickAuth exchange |
apps/web/src/app/api/auth/telegram-miniapp/route.ts | NEW — HMAC verify |
apps/web/src/app/auth/callback/email/page.tsx | NEW — magic link callback |
apps/web/src/app/auth/callback/[provider]/page.tsx | NEW — OAuth callback (with URL sanitization) |
Config:
| File | Change |
|---|---|
apps/web/package.json | Remove @privy-io/*, add @stwd/sdk @simplewebauthn/browser |
apps/web/.env.example | Swap vars |
apps/web/src/middleware.ts | Update privy-token cookie ref → steward-token |
Phase A — Steward PR (prerequisite, unblocks Twitter + migration)
├── Twitter/X OAuth to packages/auth/src/oauth.ts (with PKCE + no-email fix)
├── provisionOAuthUser() synthetic email patch
├── POST /platform/users admin endpoint
└── Merge to Steward develop
Phase B — ElizaCloud PR (infrastructure)
├── Steward Docker service in docker-compose.yml
├── Tenant provisioning API
└── Merge to ElizaCloud dev
Phase C — Feed: Infrastructure
├── 1. docker-compose.yml: steward service (../steward build context)
├── 2. scripts/docker/init-steward-db.sh
├── 3. scripts/pre-dev/pre-dev-local.ts: Steward health check
├── 4. scripts/steward-init.ts
└── QG: bun run check + typecheck ✅
Phase D — Feed: Database
├── 5. DB migration: stewardId column
└── QG: bun run db:migrate ✅
Phase E — Feed: Backend auth swap
├── 6. auth-middleware.ts: Steward JWT verification
├── 7. ensure-user.ts + user-lookup.ts
├── 8. /api/auth/session cookie bridge
├── 9. /api/auth/farcaster route
├── 10. /api/auth/farcaster-miniapp route
├── 11. /api/auth/telegram-miniapp route
└── QG: integration tests pass ✅
Phase F — Feed: User migration
├── 12. scripts/migrate-privy-to-steward.ts --dry-run (review output)
└── 13. Run migration against production Steward
Phase G — Feed: Frontend swap
├── 14. StewardAuthProvider.tsx
├── 15. Providers.tsx (remove PrivyProvider)
├── 16. useAuth.ts rewrite
├── 17. Mechanical sweep: 9 getAccessToken callers
└── QG: bun run typecheck ✅
Phase H — Feed: Login + callbacks
├── 18. LoginModal.tsx
├── 19. /auth/callback/email/page.tsx
├── 20. /auth/callback/[provider]/page.tsx (URL sanitization included)
└── QG: manually test each login method ✅
Phase I — Feed: Mini-apps + linking
├── 21. FarcasterMiniAppProvider.tsx (quickAuth.getToken())
├── 22. LinkSocialAccountsModal.tsx
└── QG: test Farcaster + Telegram mini-app flows ✅
Phase J — Feed: Cleanup
├── 23. bun remove @privy-io/react-auth @privy-io/server-auth @privy-io/node
├── 24. bun add @stwd/sdk @simplewebauthn/browser
├── 25. Delete dead files (privy-config.ts, privy-node.ts, etc.)
├── 26. Update .env.example
└── QG: full quality gate ✅
Phase K — Cutover
├── 27. Deploy (users with old privy-token cookies get login prompt — expected)
├── 28. Monitor 2 weeks
└── 29. Remove PRIVY_APP_SECRET from environments, delete Privy app
# Format + lint
bun run check
# TypeScript (zero errors)
bun run typecheck
# Lint (zero warnings)
bun run lint
# Unit tests
bun run test:unit
# Integration tests (after backend changes)
bun run test:integration
# Build
bun run build
Migration-specific checks:
# No @privy-io imports remain
rg "@privy-io" apps/web/src packages --type ts
# No usePrivy() calls remain
rg "usePrivy\(\)" apps/web/src
# steward-token cookie set after login
# Browser DevTools → Application → Cookies → steward-token → HttpOnly: ✓
# Token NOT present in URL after OAuth callback
# Browser → check URL bar after login — should be clean (token removed by replaceState)
# JWT verification (curl test)
curl -H "Cookie: steward-token=$(cat /tmp/test-token)" \
http://localhost:3000/api/users/me | jq .
# Migration report
bun run scripts/migrate-privy-to-steward.ts --dry-run | tail -20
| # | Question | Status |
|---|---|---|
| 1 | Steward sibling vs subtree | Decided: sibling directory (../steward) |
| 2 | Production Steward URL | Decided: ElizaCloud-hosted |
| 3 | Twitter/X required | Decided: Yes (Steward PR covers this) |
| 4 | Email provider | Decided: Resend |
| 5 | STEWARD_MASTER_PASSWORD rotation strategy | Open: document in runbook; rotation requires re-encrypting Steward vault |
| 6 | Farcaster relay | Decided: default relay.farcaster.xyz |
| 7 | ElizaCloud Steward prod URL | Open: needed before prod deploy |
| 8 | Email-less user outreach | Decided: in-app claim-account prompt (Layer 4 bridge) |
The following issues were identified in the original plan and corrected above:
| # | Original Issue | Fix Applied |
|---|---|---|
| 1 | Twitter OAuth returns no email — provisionOAuthUser() would crash | Steward PR must fix with synthetic @id.steward.internal email |
| 2 | Custom Farcaster/Telegram JWTs referenced non-existent Steward userId | Routes now call POST /platform/users first, use returned userId |
| 3 | User migration via direct Postgres INSERT (impossible on prod) | Migration uses new POST /platform/users API endpoint instead |
| 4 | PRIVY_AUTH_FALLBACK=true described as existing flag | Removed. Cookie name change (privy-token → steward-token) is the natural cutover. |
| 5 | @farcaster/auth-kit listed as new dependency | Already installed. Only @stwd/sdk @simplewebauthn/browser are new. |
| 6 | quickAuth.verifyJwt described as simple local verify | Documented as network call to Farcaster auth server (JWKS fetch) — acceptable |
| 7 | Docker build context for sibling directory not addressed | Confirmed Docker supports ../steward relative context; documented |
| 8 | OAuth JWT token delivered and stored via URL query param | window.history.replaceState() called immediately in callback page |
| 9 | STEWARD_JWT_SECRET vs STEWARD_SESSION_SECRET mismatch | Docker-compose sets both to same value; documented the difference |
| 10 | verifyJwt domain parameter origin not specified | new URL(process.env.NEXT_PUBLIC_APP_URL!).hostname — documented |
| Method | Path | Used for | Notes |
|---|---|---|---|
POST | /auth/email/send | Send magic link | |
POST | /auth/email/verify | Verify token | Returns JWT + refresh token |
POST | /auth/passkey/register/options | Start passkey registration | |
POST | /auth/passkey/register/verify | Complete registration | |
POST | /auth/passkey/login/options | Start passkey login | |
POST | /auth/passkey/login/verify | Complete login | |
GET | /auth/oauth/:provider/authorize | Redirect to OAuth provider | Sets state in challenge store |
GET | /auth/oauth/:provider/callback | OAuth callback (Steward-internal) | Redirects to Feed with ?token=<jwt> |
GET | /auth/session | Validate existing JWT | Used on StewardAuthProvider mount |
POST | /auth/refresh | Rotate tokens | One-time use refresh token |
POST | /auth/revoke | Revoke refresh token | Logout |
GET | /health | Health check | Used in pre-dev-local.ts |
POST | /platform/tenants | Create Feed tenant | Run once via steward-init.ts |
POST | /platform/users | Pre-seed users (migration) | New endpoint — requires Steward PR |
Feed's auth-middleware verifies JWTs locally using jose jwtVerify + STEWARD_JWT_SECRET. No network call to Steward per request.
Remove (3 packages):
@privy-io/react-auth
@privy-io/server-auth
@privy-io/node
Add (2 packages — only what's not already present):
@stwd/[email protected] # StewardAuth client (verified on npm)
@simplewebauthn/browser # peer dep for passkey WebAuthn (not auto-installed by @stwd/sdk)
Already installed — do NOT add again:
@farcaster/[email protected] # already in apps/web/package.json
@farcaster/[email protected] # already in apps/web/package.json
@farcaster/[email protected] # already in apps/web/package.json (includes quickAuth)
@farcaster/quick-auth # already pulled in as transitive dep
jose # already used in Feed backend
Last updated: April 2026. LARP-assessed by AI agent. Transcript: Phase 2 Steward Planning.