@oaklandzoo/ostup 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +20 -0
  2. package/bin/cli.mjs +40 -2
  3. package/package.json +1 -1
  4. package/scripts/verify-auth.sh +30 -0
  5. package/src/auth-clerk.mjs +146 -0
  6. package/src/auth-google.mjs +137 -0
  7. package/src/credential-prompts-npm.mjs +154 -0
  8. package/src/credential-prompts.mjs +5 -0
  9. package/src/doctor-privacy.mjs +103 -0
  10. package/src/doctor.mjs +7 -1
  11. package/src/mvp-flow.mjs +27 -0
  12. package/src/private-cmd.mjs +35 -0
  13. package/src/private.mjs +215 -0
  14. package/templates/.claude/commands/add-auth.md +87 -0
  15. package/templates/.claude/commands/add-storage.md +31 -1
  16. package/templates/.claude/commands/publish.md +77 -0
  17. package/templates/auth-clerk/env.example.additions +10 -0
  18. package/templates/auth-clerk/layout.tsx +25 -0
  19. package/templates/auth-clerk/middleware.ts +20 -0
  20. package/templates/auth-clerk/package.json.additions +5 -0
  21. package/templates/auth-clerk/sign-in-page.tsx +9 -0
  22. package/templates/auth-clerk/sign-up-page.tsx +9 -0
  23. package/templates/auth-google/auth.ts +12 -0
  24. package/templates/auth-google/env.example.additions +9 -0
  25. package/templates/auth-google/package.json.additions +5 -0
  26. package/templates/auth-google/route.ts +2 -0
  27. package/templates/auth-google/sign-in-page.tsx +32 -0
  28. package/templates/auth-google/sign-up-page.tsx +32 -0
  29. package/templates/private/CLAUDE_PART_20.md +30 -0
  30. package/templates/private/api-audit-health.ts +9 -0
  31. package/templates/private/api-blob-proxy.ts +40 -0
  32. package/templates/private/lib-audit.ts +18 -0
  33. package/templates/private/lib-rate-limit-kv.ts +21 -0
  34. package/templates/private/lib-rate-limit-memory.ts +25 -0
  35. package/templates/private/middleware.ts +67 -0
  36. package/templates/private/next.config.ts +14 -0
@@ -0,0 +1,9 @@
1
+ import { SignIn } from '@clerk/nextjs';
2
+
3
+ export default function SignInPage() {
4
+ return (
5
+ <main className="flex min-h-screen items-center justify-center p-6">
6
+ <SignIn />
7
+ </main>
8
+ );
9
+ }
@@ -0,0 +1,9 @@
1
+ import { SignUp } from '@clerk/nextjs';
2
+
3
+ export default function SignUpPage() {
4
+ return (
5
+ <main className="flex min-h-screen items-center justify-center p-6">
6
+ <SignUp />
7
+ </main>
8
+ );
9
+ }
@@ -0,0 +1,12 @@
1
+ import NextAuth from 'next-auth';
2
+ import Google from 'next-auth/providers/google';
3
+
4
+ export const { handlers, signIn, signOut, auth } = NextAuth({
5
+ providers: [
6
+ Google({
7
+ clientId: process.env.GOOGLE_CLIENT_ID || '',
8
+ clientSecret: process.env.GOOGLE_CLIENT_SECRET || '',
9
+ }),
10
+ ],
11
+ trustHost: true,
12
+ });
@@ -0,0 +1,9 @@
1
+ # --- Google OAuth (NextAuth v5) additions ---
2
+ # Create an OAuth 2.0 Client ID at:
3
+ # https://console.cloud.google.com/apis/credentials/oauthclient
4
+ # Authorized redirect URI: <your-prod-url>/api/auth/callback/google
5
+ GOOGLE_CLIENT_ID=
6
+ GOOGLE_CLIENT_SECRET=
7
+
8
+ # NextAuth secret. Generate one with: openssl rand -base64 32
9
+ AUTH_SECRET=
@@ -0,0 +1,5 @@
1
+ {
2
+ "dependencies": {
3
+ "next-auth": "^5.0.0-beta.20"
4
+ }
5
+ }
@@ -0,0 +1,2 @@
1
+ import { handlers } from '@/auth';
2
+ export const { GET, POST } = handlers;
@@ -0,0 +1,32 @@
1
+ import { signIn } from '@/auth';
2
+
3
+ export default function SignInPage() {
4
+ return (
5
+ <main className="flex min-h-screen items-center justify-center p-6">
6
+ <div className="w-full max-w-sm rounded-lg border border-slate-200 bg-white p-8">
7
+ <h1 className="text-2xl font-semibold tracking-tight">Sign in</h1>
8
+ <p className="mt-2 text-sm text-slate-600">Continue to {`{{DISPLAY_NAME}}`}.</p>
9
+ <form
10
+ action={async () => {
11
+ 'use server';
12
+ await signIn('google', { redirectTo: '/dashboard' });
13
+ }}
14
+ className="mt-8"
15
+ >
16
+ <button
17
+ type="submit"
18
+ className="flex w-full items-center justify-center gap-2 rounded-md border border-slate-300 bg-white px-4 py-2 font-medium text-slate-900 hover:bg-slate-50"
19
+ >
20
+ <svg className="h-5 w-5" viewBox="0 0 24 24" aria-hidden="true">
21
+ <path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09Z" fill="#4285F4"/>
22
+ <path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.99.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84A11 11 0 0 0 12 23Z" fill="#34A853"/>
23
+ <path d="M5.84 14.1A6.6 6.6 0 0 1 5.5 12c0-.73.13-1.43.34-2.1V7.07H2.18A11 11 0 0 0 1 12c0 1.77.42 3.45 1.18 4.93l3.66-2.83Z" fill="#FBBC05"/>
24
+ <path d="M12 5.38c1.62 0 3.06.56 4.21 1.65l3.15-3.15C17.45 2.09 14.97 1 12 1A11 11 0 0 0 2.18 7.07l3.66 2.83C6.71 7.3 9.14 5.38 12 5.38Z" fill="#EA4335"/>
25
+ </svg>
26
+ Sign in with Google
27
+ </button>
28
+ </form>
29
+ </div>
30
+ </main>
31
+ );
32
+ }
@@ -0,0 +1,32 @@
1
+ import { signIn } from '@/auth';
2
+
3
+ export default function SignUpPage() {
4
+ return (
5
+ <main className="flex min-h-screen items-center justify-center p-6">
6
+ <div className="w-full max-w-sm rounded-lg border border-slate-200 bg-white p-8">
7
+ <h1 className="text-2xl font-semibold tracking-tight">Create account</h1>
8
+ <p className="mt-2 text-sm text-slate-600">Sign up with Google to get started.</p>
9
+ <form
10
+ action={async () => {
11
+ 'use server';
12
+ await signIn('google', { redirectTo: '/dashboard' });
13
+ }}
14
+ className="mt-8"
15
+ >
16
+ <button
17
+ type="submit"
18
+ className="flex w-full items-center justify-center gap-2 rounded-md border border-slate-300 bg-white px-4 py-2 font-medium text-slate-900 hover:bg-slate-50"
19
+ >
20
+ <svg className="h-5 w-5" viewBox="0 0 24 24" aria-hidden="true">
21
+ <path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09Z" fill="#4285F4"/>
22
+ <path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.99.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84A11 11 0 0 0 12 23Z" fill="#34A853"/>
23
+ <path d="M5.84 14.1A6.6 6.6 0 0 1 5.5 12c0-.73.13-1.43.34-2.1V7.07H2.18A11 11 0 0 0 1 12c0 1.77.42 3.45 1.18 4.93l3.66-2.83Z" fill="#FBBC05"/>
24
+ <path d="M12 5.38c1.62 0 3.06.56 4.21 1.65l3.15-3.15C17.45 2.09 14.97 1 12 1A11 11 0 0 0 2.18 7.07l3.66 2.83C6.71 7.3 9.14 5.38 12 5.38Z" fill="#EA4335"/>
25
+ </svg>
26
+ Continue with Google
27
+ </button>
28
+ </form>
29
+ </div>
30
+ </main>
31
+ );
32
+ }
@@ -0,0 +1,30 @@
1
+
2
+ ---
3
+
4
+ # PART 20: PRIVACY DEFAULT-DENY (--private mode)
5
+
6
+ This project was scaffolded with `--private` (or `ostup private add` was later run on it). Default-deny is mandatory. Treat the rules below as hard rules per Part 3.
7
+
8
+ ## Hard rules
9
+
10
+ 1. **Do NOT widen the middleware matcher.** The `middleware.ts` matcher at the project root is intentionally strict. Adding paths to its exclusion list ungates them across the entire app. Any change MUST be reviewed for whether it accidentally exposes user content.
11
+
12
+ 2. **All `@vercel/blob` helpers default to `access: 'private'`.** Generated `src/lib/blob.ts` and any blob helper code uses `access: 'private'`. To serve a blob to authenticated users, route it through `app/api/blob/[...path]/route.ts` (the auth-checked proxy). Never set `access: 'public'` unless the file is truly safe to expose to the open internet.
13
+
14
+ 3. **Do NOT change `next.config.ts` -> `images.remotePatterns`.** It is intentionally empty (`[]`). Adding blob hosts would let `/_next/image?url=<blob-url>` proxy private blobs around auth.
15
+
16
+ 4. **`/_next/image` is auth-gated.** The matcher deliberately does NOT exclude `/_next/image`. This blocks public abuse of the image optimizer for any URL the matcher does not already allow.
17
+
18
+ 5. **Rate limiting is wired in middleware.** Default: `/api/*` 60 req/min/IP, everything else 600 req/min/IP. Adjust `src/lib/rate-limit.ts` constants. Removing the rate-limit call from middleware requires explicit operator review.
19
+
20
+ 6. **Audit log every blocked request.** Every 401/429 goes through `src/lib/audit.ts` -> `console.log` -> Vercel Logs. Do not silence the audit logger.
21
+
22
+ 7. **The audit-health endpoint at `/api/audit/health` is the only intentionally public route in privacy mode.** It confirms the audit pipeline is wired. Used by `ostup doctor --privacy` to verify the deploy.
23
+
24
+ ## Verification
25
+
26
+ Run `ostup doctor --privacy <deployed-url>` after every deploy. It probes a list of suspicious paths without cookies and asserts they return 401 or 404. Any 200 is a privacy regression.
27
+
28
+ ## To undo
29
+
30
+ If privacy mode is no longer needed, run `ostup private remove` from the project root. The applier captured original file contents in `.ostup/private.json`; remove will restore them.
@@ -0,0 +1,9 @@
1
+ import { NextResponse } from 'next/server';
2
+
3
+ export async function GET() {
4
+ return NextResponse.json({
5
+ ok: true,
6
+ pipeline: 'wired',
7
+ privacy: '--private',
8
+ });
9
+ }
@@ -0,0 +1,40 @@
1
+ import { NextResponse } from 'next/server';
2
+ import { head } from '@vercel/blob';
3
+ import { audit } from '@/lib/audit';
4
+
5
+ const SESSION_COOKIE_NAMES = ['better-auth.session_token', 'authjs.session-token', 'session_token'];
6
+
7
+ export async function GET(req: Request, { params }: { params: Promise<{ path: string[] }> }) {
8
+ const cookieHeader = req.headers.get('cookie') || '';
9
+ const hasSession = SESSION_COOKIE_NAMES.some((name) => cookieHeader.includes(`${name}=`));
10
+ if (!hasSession) {
11
+ audit({
12
+ ts: new Date().toISOString(),
13
+ path: new URL(req.url).pathname,
14
+ ip: req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || 'unknown',
15
+ method: 'GET',
16
+ ua: req.headers.get('user-agent') || '',
17
+ reason: 'no_session',
18
+ status: 401,
19
+ });
20
+ return new NextResponse('Unauthorized', { status: 401 });
21
+ }
22
+
23
+ const { path } = await params;
24
+ const blobPath = path.join('/');
25
+ try {
26
+ const blob = await head(blobPath);
27
+ if (!blob) return new NextResponse('Not Found', { status: 404 });
28
+ const upstream = await fetch(blob.url);
29
+ if (!upstream.ok || !upstream.body) return new NextResponse('Not Found', { status: 404 });
30
+ return new NextResponse(upstream.body, {
31
+ headers: {
32
+ 'content-type': blob.contentType || 'application/octet-stream',
33
+ 'cache-control': 'private, no-store',
34
+ 'x-robots-tag': 'noindex, nofollow',
35
+ },
36
+ });
37
+ } catch {
38
+ return new NextResponse('Not Found', { status: 404 });
39
+ }
40
+ }
@@ -0,0 +1,18 @@
1
+ export type AuditEntry = {
2
+ ts: string;
3
+ path: string;
4
+ ip: string;
5
+ method: string;
6
+ ua: string;
7
+ reason: 'no_session' | 'rate_limit' | 'invalid_token';
8
+ status: number;
9
+ };
10
+
11
+ const IGNORE_PATHS: string[] = [
12
+ '/api/audit/health',
13
+ ];
14
+
15
+ export function audit(entry: AuditEntry): void {
16
+ if (IGNORE_PATHS.some((p) => entry.path.startsWith(p))) return;
17
+ console.log(`[audit] ${JSON.stringify(entry)}`);
18
+ }
@@ -0,0 +1,21 @@
1
+ import { kv } from '@vercel/kv';
2
+
3
+ const LIMITS: { test: (path: string) => boolean; perMin: number }[] = [
4
+ { test: (p) => p.startsWith('/api/'), perMin: 60 },
5
+ { test: () => true, perMin: 600 },
6
+ ];
7
+
8
+ export async function rateLimit({ ip, path }: { ip: string; path: string }): Promise<{ ok: boolean; retryAfterSeconds: number }> {
9
+ const limit = LIMITS.find((l) => l.test(path))!;
10
+ const minute = Math.floor(Date.now() / 60_000);
11
+ const key = `rl:${ip}:${minute}:${limit.perMin}`;
12
+ const count = await kv.incr(key);
13
+ if (count === 1) {
14
+ await kv.expire(key, 65);
15
+ }
16
+ if (count > limit.perMin) {
17
+ const retryAfterSeconds = 60 - (Math.floor(Date.now() / 1000) % 60);
18
+ return { ok: false, retryAfterSeconds };
19
+ }
20
+ return { ok: true, retryAfterSeconds: 0 };
21
+ }
@@ -0,0 +1,25 @@
1
+ type Bucket = { count: number; resetAt: number };
2
+
3
+ const buckets = new Map<string, Bucket>();
4
+
5
+ const LIMITS: { test: (path: string) => boolean; perMin: number }[] = [
6
+ { test: (p) => p.startsWith('/api/'), perMin: 60 },
7
+ { test: () => true, perMin: 600 },
8
+ ];
9
+
10
+ export async function rateLimit({ ip, path }: { ip: string; path: string }): Promise<{ ok: boolean; retryAfterSeconds: number }> {
11
+ const limit = LIMITS.find((l) => l.test(path))!;
12
+ const key = `${ip}::${limit.perMin}`;
13
+ const now = Date.now();
14
+ const bucket = buckets.get(key);
15
+
16
+ if (!bucket || bucket.resetAt < now) {
17
+ buckets.set(key, { count: 1, resetAt: now + 60_000 });
18
+ return { ok: true, retryAfterSeconds: 0 };
19
+ }
20
+ if (bucket.count >= limit.perMin) {
21
+ return { ok: false, retryAfterSeconds: Math.ceil((bucket.resetAt - now) / 1000) };
22
+ }
23
+ bucket.count += 1;
24
+ return { ok: true, retryAfterSeconds: 0 };
25
+ }
@@ -0,0 +1,67 @@
1
+ import { NextResponse } from 'next/server';
2
+ import type { NextRequest } from 'next/server';
3
+ import { rateLimit } from '@/lib/rate-limit';
4
+ import { audit } from '@/lib/audit';
5
+
6
+ const SESSION_COOKIE_NAMES = [
7
+ '__session', // Clerk
8
+ '__clerk_db_jwt', // Clerk db cookie
9
+ 'authjs.session-token', // NextAuth v5
10
+ '__Secure-authjs.session-token', // NextAuth v5 secure
11
+ 'better-auth.session_token', // Better Auth
12
+ 'session_token', // Generic fallback
13
+ ];
14
+
15
+ function getIp(req: NextRequest): string {
16
+ const xff = req.headers.get('x-forwarded-for');
17
+ if (xff) return xff.split(',')[0].trim();
18
+ return req.headers.get('x-real-ip') || 'unknown';
19
+ }
20
+
21
+ export async function middleware(req: NextRequest) {
22
+ const ip = getIp(req);
23
+ const path = req.nextUrl.pathname;
24
+ const method = req.method;
25
+ const ua = req.headers.get('user-agent') || '';
26
+
27
+ const rl = await rateLimit({ ip, path });
28
+ if (!rl.ok) {
29
+ audit({ ts: new Date().toISOString(), path, ip, method, ua, reason: 'rate_limit', status: 429 });
30
+ return new NextResponse('Too Many Requests', {
31
+ status: 429,
32
+ headers: {
33
+ 'retry-after': String(rl.retryAfterSeconds),
34
+ 'x-robots-tag': 'noindex, nofollow',
35
+ },
36
+ });
37
+ }
38
+
39
+ const hasSession = SESSION_COOKIE_NAMES.some((name) => req.cookies.get(name)?.value);
40
+ if (!hasSession) {
41
+ audit({ ts: new Date().toISOString(), path, ip, method, ua, reason: 'no_session', status: 401 });
42
+ if (method === 'GET' && req.headers.get('accept')?.includes('text/html')) {
43
+ const url = req.nextUrl.clone();
44
+ url.pathname = '/sign-in';
45
+ url.searchParams.set('next', path);
46
+ return NextResponse.redirect(url);
47
+ }
48
+ return new NextResponse('Unauthorized', {
49
+ status: 401,
50
+ headers: { 'x-robots-tag': 'noindex, nofollow' },
51
+ });
52
+ }
53
+
54
+ const res = NextResponse.next();
55
+ res.headers.set('x-robots-tag', 'noindex, nofollow');
56
+ return res;
57
+ }
58
+
59
+ export const config = {
60
+ // Default-deny matcher.
61
+ // Bypassed: /login, /signup, /sign-in, /sign-up, /api/auth/*, /api/audit/health,
62
+ // /_next/static/*, /favicon.ico, /robots.txt.
63
+ // NOTE: /_next/image is deliberately NOT excluded. The image optimizer must be
64
+ // auth-gated too. Otherwise /_next/image?url=<blob-url> would proxy private blobs
65
+ // around auth.
66
+ matcher: ['/((?!login|signup|sign-in|sign-up|api/auth|api/audit/health|_next/static|favicon\\.ico|robots\\.txt).*)'],
67
+ };
@@ -0,0 +1,14 @@
1
+ import type { NextConfig } from 'next';
2
+
3
+ const nextConfig: NextConfig = {
4
+ images: {
5
+ // Intentionally empty. The Next.js image optimizer cannot proxy any external
6
+ // host. This is required for privacy mode: if a private blob URL were added
7
+ // here, /_next/image?url=<blob-url> would serve the blob around auth.
8
+ //
9
+ // To serve user uploads with auth, use the /api/blob/[...path] proxy route.
10
+ remotePatterns: [],
11
+ },
12
+ };
13
+
14
+ export default nextConfig;