burtenshaw HF Staff commited on
Commit
654b283
·
verified ·
1 Parent(s): 51e3cc4

Deploy Next.js signup Space

Browse files
.dockerignore ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ .git
2
+ .gitignore
3
+ .next
4
+ .env
5
+ .env.*
6
+ node_modules
7
+ npm-debug.log*
8
+ .DS_Store
.gitignore CHANGED
@@ -3,21 +3,39 @@
3
  # dependencies
4
  /node_modules
5
  /.pnp
6
- .pnp.js
 
 
 
 
 
7
 
8
  # testing
9
  /coverage
10
 
 
 
 
 
11
  # production
12
  /build
13
 
14
  # misc
15
  .DS_Store
16
- .env.local
17
- .env.development.local
18
- .env.test.local
19
- .env.production.local
20
 
 
21
  npm-debug.log*
22
  yarn-debug.log*
23
  yarn-error.log*
 
 
 
 
 
 
 
 
 
 
 
 
3
  # dependencies
4
  /node_modules
5
  /.pnp
6
+ .pnp.*
7
+ .yarn/*
8
+ !.yarn/patches
9
+ !.yarn/plugins
10
+ !.yarn/releases
11
+ !.yarn/versions
12
 
13
  # testing
14
  /coverage
15
 
16
+ # next.js
17
+ /.next/
18
+ /out/
19
+
20
  # production
21
  /build
22
 
23
  # misc
24
  .DS_Store
25
+ *.pem
 
 
 
26
 
27
+ # debug
28
  npm-debug.log*
29
  yarn-debug.log*
30
  yarn-error.log*
31
+ .pnpm-debug.log*
32
+
33
+ # env files (can opt-in for committing if needed)
34
+ .env*
35
+
36
+ # vercel
37
+ .vercel
38
+
39
+ # typescript
40
+ *.tsbuildinfo
41
+ next-env.d.ts
AGENTS.md ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ <!-- BEGIN:nextjs-agent-rules -->
2
+ # This is NOT the Next.js you know
3
+
4
+ This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
5
+ <!-- END:nextjs-agent-rules -->
CLAUDE.md ADDED
@@ -0,0 +1 @@
 
 
1
+ @AGENTS.md
Dockerfile ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM node:20-bookworm-slim AS deps
2
+
3
+ WORKDIR /app
4
+
5
+ COPY package.json package-lock.json ./
6
+ RUN npm ci
7
+
8
+ FROM node:20-bookworm-slim AS builder
9
+
10
+ WORKDIR /app
11
+
12
+ ENV NEXT_TELEMETRY_DISABLED=1
13
+
14
+ COPY --from=deps /app/node_modules ./node_modules
15
+ COPY . .
16
+
17
+ RUN npm run build
18
+
19
+ FROM node:20-bookworm-slim AS runner
20
+
21
+ WORKDIR /app
22
+
23
+ ENV NODE_ENV=production
24
+ ENV NEXT_TELEMETRY_DISABLED=1
25
+ ENV PORT=7860
26
+ ENV HOSTNAME=0.0.0.0
27
+
28
+ COPY --from=builder /app/.next/standalone ./
29
+ COPY --from=builder /app/.next/static ./.next/static
30
+
31
+ EXPOSE 7860
32
+
33
+ CMD ["node", "server.js"]
README.md CHANGED
@@ -1,83 +1,45 @@
1
  ---
2
- title: Signup
3
- emoji: 🐠
4
- colorFrom: indigo
5
- colorTo: red
6
- sdk: static
7
- pinned: false
8
- app_build_command: npm run build
9
- app_file: build/index.html
10
- license: apache-2.0
11
- short_description: Sign up to Humanity's Last Hackathon
12
  ---
13
 
14
- # Getting Started with Create React App
15
 
16
- This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
17
 
18
- ## Available Scripts
19
 
20
- In the project directory, you can run:
 
 
21
 
22
- ### `npm start`
23
 
24
- Runs the app in the development mode.\
25
- Open [http://localhost:3000](http://localhost:3000) to view it in your browser.
 
 
26
 
27
- The page will reload when you make changes.\
28
- You may also see any lint errors in the console.
29
 
30
- ### `npm test`
 
 
 
31
 
32
- Launches the test runner in the interactive watch mode.\
33
- See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
34
 
35
- ### `npm run build`
36
 
37
- Builds the app for production to the `build` folder.\
38
- It correctly bundles React in production mode and optimizes the build for the best performance.
 
 
39
 
40
- The build is minified and the filenames include the hashes.\
41
- Your app is ready to be deployed!
42
 
43
- See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
44
-
45
- ### `npm run eject`
46
-
47
- **Note: this is a one-way operation. Once you `eject`, you can't go back!**
48
-
49
- If you aren't satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
50
-
51
- Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you're on your own.
52
-
53
- You don't have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn't feel obligated to use this feature. However we understand that this tool wouldn't be useful if you couldn't customize it when you are ready for it.
54
-
55
- ## Learn More
56
-
57
- You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
58
-
59
- To learn React, check out the [React documentation](https://reactjs.org/).
60
-
61
- ### Code Splitting
62
-
63
- This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting)
64
-
65
- ### Analyzing the Bundle Size
66
-
67
- This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size)
68
-
69
- ### Making a Progressive Web App
70
-
71
- This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app)
72
-
73
- ### Advanced Configuration
74
-
75
- This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration)
76
-
77
- ### Deployment
78
-
79
- This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment)
80
-
81
- ### `npm run build` fails to minify
82
-
83
- This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify)
 
1
  ---
2
+ title: Humanity's Last Hackathon Signup
3
+ emoji: "🖥️"
4
+ colorFrom: gray
5
+ colorTo: gray
6
+ sdk: docker
7
+ app_port: 7860
 
 
 
 
8
  ---
9
 
10
+ # Humanity's Last Hackathon Signup
11
 
12
+ Black-and-white Next.js landing page for **Humanity's Last Hackathon**.
13
 
14
+ The current production flow is intentionally minimal:
15
 
16
+ 1. Open the signup page.
17
+ 2. Click **Sign up with Hugging Face**.
18
+ 3. Continue on the official Hugging Face registration link.
19
 
20
+ ## Stack
21
 
22
+ - Next.js 16 App Router
23
+ - TypeScript
24
+ - CSS modules
25
+ - Docker-based Hugging Face Space deployment
26
 
27
+ ## Local Development
 
28
 
29
+ ```bash
30
+ npm install
31
+ npm run dev
32
+ ```
33
 
34
+ Open `http://localhost:3000`.
 
35
 
36
+ ## Verification
37
 
38
+ ```bash
39
+ npm run lint
40
+ npm run build
41
+ ```
42
 
43
+ ## Deployment
 
44
 
45
+ This repository is configured as a **Docker Space** and serves the Next.js app on port `7860`.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
eslint.config.mjs ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineConfig, globalIgnores } from "eslint/config";
2
+ import nextVitals from "eslint-config-next/core-web-vitals";
3
+ import nextTs from "eslint-config-next/typescript";
4
+
5
+ const eslintConfig = defineConfig([
6
+ ...nextVitals,
7
+ ...nextTs,
8
+ // Override default ignores of eslint-config-next.
9
+ globalIgnores([
10
+ // Default ignores of eslint-config-next:
11
+ ".next/**",
12
+ "out/**",
13
+ "build/**",
14
+ "next-env.d.ts",
15
+ ]),
16
+ ]);
17
+
18
+ export default eslintConfig;
next.config.ts ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { NextConfig } from "next";
2
+
3
+ const nextConfig: NextConfig = {
4
+ output: "standalone",
5
+ images: {
6
+ remotePatterns: [
7
+ {
8
+ protocol: "https",
9
+ hostname: "cdn-avatars.huggingface.co",
10
+ },
11
+ {
12
+ protocol: "https",
13
+ hostname: "www.gravatar.com",
14
+ },
15
+ {
16
+ protocol: "https",
17
+ hostname: "avatars.githubusercontent.com",
18
+ },
19
+ ],
20
+ },
21
+ };
22
+
23
+ export default nextConfig;
package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
package.json CHANGED
@@ -1,39 +1,26 @@
1
  {
2
- "name": "react-template",
3
  "version": "0.1.0",
4
  "private": true,
5
- "dependencies": {
6
- "@testing-library/dom": "^10.4.0",
7
- "@testing-library/jest-dom": "^6.6.3",
8
- "@testing-library/react": "^16.3.0",
9
- "@testing-library/user-event": "^13.5.0",
10
- "react": "^19.1.0",
11
- "react-dom": "^19.1.0",
12
- "react-scripts": "5.0.1",
13
- "web-vitals": "^2.1.4"
14
- },
15
  "scripts": {
16
- "start": "react-scripts start",
17
- "build": "react-scripts build",
18
- "test": "react-scripts test",
19
- "eject": "react-scripts eject"
20
  },
21
- "eslintConfig": {
22
- "extends": [
23
- "react-app",
24
- "react-app/jest"
25
- ]
 
26
  },
27
- "browserslist": {
28
- "production": [
29
- ">0.2%",
30
- "not dead",
31
- "not op_mini all"
32
- ],
33
- "development": [
34
- "last 1 chrome version",
35
- "last 1 firefox version",
36
- "last 1 safari version"
37
- ]
38
  }
39
  }
 
1
  {
2
+ "name": "hlh-signup-app",
3
  "version": "0.1.0",
4
  "private": true,
 
 
 
 
 
 
 
 
 
 
5
  "scripts": {
6
+ "dev": "next dev",
7
+ "build": "next build",
8
+ "start": "next start",
9
+ "lint": "eslint"
10
  },
11
+ "dependencies": {
12
+ "@huggingface/hub": "^2.11.0",
13
+ "next": "16.2.4",
14
+ "react": "19.2.4",
15
+ "react-dom": "19.2.4",
16
+ "zod": "^4.3.6"
17
  },
18
+ "devDependencies": {
19
+ "@types/node": "^20",
20
+ "@types/react": "^19",
21
+ "@types/react-dom": "^19",
22
+ "eslint": "^9",
23
+ "eslint-config-next": "16.2.4",
24
+ "typescript": "^5"
 
 
 
 
25
  }
26
  }
public/file.svg ADDED
public/globe.svg ADDED
public/next.svg ADDED
public/vercel.svg ADDED
public/window.svg ADDED
src/app/api/auth/huggingface/callback/route.ts ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from "next/server";
2
+
3
+ import {
4
+ createSignupRecord,
5
+ exchangeCodeForToken,
6
+ fetchUserInfo,
7
+ fetchViewer,
8
+ getAppUrl,
9
+ getOAuthRedirectUri,
10
+ storeSignupRecord,
11
+ } from "@/lib/huggingface";
12
+ import {
13
+ clearOAuthCookies,
14
+ clearSessionCookie,
15
+ readOAuthCookies,
16
+ setSessionCookie,
17
+ } from "@/lib/session";
18
+
19
+ export const runtime = "nodejs";
20
+
21
+ function redirectWithAuthError(request: NextRequest, code: string) {
22
+ const response = NextResponse.redirect(new URL(`/?error=${code}`, getAppUrl(request)));
23
+ clearOAuthCookies(response);
24
+ clearSessionCookie(response);
25
+ return response;
26
+ }
27
+
28
+ export async function GET(request: NextRequest) {
29
+ const url = new URL(request.url);
30
+ const providerError = url.searchParams.get("error");
31
+
32
+ if (providerError) {
33
+ return redirectWithAuthError(
34
+ request,
35
+ providerError === "access_denied" ? "oauth_denied" : "oauth_failed",
36
+ );
37
+ }
38
+
39
+ const code = url.searchParams.get("code");
40
+ const returnedState = url.searchParams.get("state");
41
+ const { state, verifier } = readOAuthCookies(request);
42
+
43
+ if (!code || !returnedState || !state || returnedState !== state || !verifier) {
44
+ return redirectWithAuthError(request, "oauth_state");
45
+ }
46
+
47
+ try {
48
+ const token = await exchangeCodeForToken({
49
+ code,
50
+ codeVerifier: verifier,
51
+ redirectUri: getOAuthRedirectUri(request),
52
+ });
53
+
54
+ const [userInfo, viewer] = await Promise.all([
55
+ fetchUserInfo(token.access_token),
56
+ fetchViewer(token.access_token),
57
+ ]);
58
+
59
+ const session = {
60
+ id: userInfo.sub,
61
+ username: userInfo.preferred_username || viewer.name,
62
+ name: userInfo.name.trim() || viewer.fullname || viewer.name,
63
+ email: userInfo.email ?? viewer.email,
64
+ emailVerified: userInfo.email_verified ?? viewer.emailVerified,
65
+ avatarUrl: userInfo.picture || viewer.avatarUrl,
66
+ profileUrl: userInfo.profile || `${process.env.HF_HUB_URL ?? "https://huggingface.co"}/${viewer.name}`,
67
+ website: userInfo.website ?? null,
68
+ isPro: userInfo.isPro ?? viewer.isPro,
69
+ registrationComplete: false,
70
+ };
71
+
72
+ try {
73
+ await storeSignupRecord(createSignupRecord({ session }));
74
+
75
+ const response = NextResponse.redirect(new URL("/?signed_up=1", getAppUrl(request)));
76
+ clearOAuthCookies(response);
77
+ setSessionCookie(response, {
78
+ ...session,
79
+ registrationComplete: true,
80
+ });
81
+
82
+ return response;
83
+ } catch (error) {
84
+ console.error(error);
85
+
86
+ const response = NextResponse.redirect(new URL("/?error=signup_store", getAppUrl(request)));
87
+ clearOAuthCookies(response);
88
+ setSessionCookie(response, session);
89
+
90
+ return response;
91
+ }
92
+ } catch (error) {
93
+ console.error(error);
94
+ return redirectWithAuthError(request, "oauth_exchange");
95
+ }
96
+ }
src/app/api/auth/huggingface/start/route.ts ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from "next/server";
2
+
3
+ import { createOAuthAuthorizationUrl } from "@/lib/huggingface";
4
+ import { createOpaqueToken, createPkcePair, setOAuthCookies } from "@/lib/session";
5
+
6
+ export const runtime = "nodejs";
7
+
8
+ export async function GET(request: NextRequest) {
9
+ const state = createOpaqueToken(24);
10
+ const { verifier, challenge } = createPkcePair();
11
+
12
+ const response = NextResponse.redirect(
13
+ createOAuthAuthorizationUrl({
14
+ state,
15
+ codeChallenge: challenge,
16
+ request,
17
+ }),
18
+ );
19
+
20
+ setOAuthCookies(response, state, verifier);
21
+
22
+ return response;
23
+ }
src/app/api/auth/logout/route.ts ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from "next/server";
2
+
3
+ import { getAppUrl } from "@/lib/huggingface";
4
+ import { clearOAuthCookies, clearSessionCookie } from "@/lib/session";
5
+
6
+ export const runtime = "nodejs";
7
+
8
+ export async function GET(request: NextRequest) {
9
+ const response = NextResponse.redirect(new URL("/", getAppUrl(request)));
10
+ clearOAuthCookies(response);
11
+ clearSessionCookie(response);
12
+ return response;
13
+ }
src/app/api/signup/route.ts ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from "next/server";
2
+
3
+ import { createSignupRecord, storeSignupRecord } from "@/lib/huggingface";
4
+ import { parseSessionCookie, SESSION_COOKIE_NAME, setSessionCookie } from "@/lib/session";
5
+
6
+ export const runtime = "nodejs";
7
+
8
+ export async function POST(request: NextRequest) {
9
+ const session = parseSessionCookie(request.cookies.get(SESSION_COOKIE_NAME)?.value);
10
+
11
+ if (!session) {
12
+ return NextResponse.json(
13
+ {
14
+ error: "Authenticate with Hugging Face before completing signup.",
15
+ },
16
+ { status: 401 },
17
+ );
18
+ }
19
+
20
+ try {
21
+ const record = createSignupRecord({ session });
22
+
23
+ const saved = await storeSignupRecord(record);
24
+
25
+ const response = NextResponse.json({
26
+ ok: true,
27
+ repo: saved.repoName,
28
+ path: saved.path,
29
+ });
30
+
31
+ setSessionCookie(response, {
32
+ ...session,
33
+ registrationComplete: true,
34
+ });
35
+
36
+ return response;
37
+ } catch (error) {
38
+ console.error(error);
39
+ return NextResponse.json(
40
+ {
41
+ error: "Failed to write the signup record to Hugging Face.",
42
+ },
43
+ { status: 500 },
44
+ );
45
+ }
46
+ }
src/app/favicon.ico ADDED
src/app/globals.css ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ --background: #020202;
3
+ --foreground: #f4f4f4;
4
+ --muted: #9b9b9b;
5
+ --border: rgba(255, 255, 255, 0.18);
6
+ --panel: rgba(7, 7, 7, 0.92);
7
+ --panel-strong: rgba(12, 12, 12, 0.98);
8
+ --shadow: rgba(0, 0, 0, 0.48);
9
+ }
10
+
11
+ html {
12
+ height: 100%;
13
+ color-scheme: dark;
14
+ }
15
+
16
+ html,
17
+ body {
18
+ max-width: 100vw;
19
+ overflow-x: clip;
20
+ }
21
+
22
+ body {
23
+ min-height: 100vh;
24
+ color: var(--foreground);
25
+ background: var(--background);
26
+ font-family: var(--font-ibm-plex-mono), monospace;
27
+ background-image:
28
+ radial-gradient(circle at top left, rgba(255, 255, 255, 0.06), transparent 28%),
29
+ radial-gradient(circle at top right, rgba(255, 255, 255, 0.04), transparent 24%),
30
+ linear-gradient(rgba(255, 255, 255, 0.03) 1px, transparent 1px),
31
+ linear-gradient(90deg, rgba(255, 255, 255, 0.03) 1px, transparent 1px);
32
+ background-size: auto, auto, 38px 38px, 38px 38px;
33
+ -webkit-font-smoothing: antialiased;
34
+ -moz-osx-font-smoothing: grayscale;
35
+ }
36
+
37
+ * {
38
+ box-sizing: border-box;
39
+ padding: 0;
40
+ margin: 0;
41
+ }
42
+
43
+ a {
44
+ color: inherit;
45
+ text-decoration: none;
46
+ }
47
+
48
+ button,
49
+ input,
50
+ textarea {
51
+ font: inherit;
52
+ }
53
+
54
+ ::selection {
55
+ background: #ffffff;
56
+ color: #000000;
57
+ }
src/app/layout.tsx ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Metadata } from "next";
2
+ import { IBM_Plex_Mono } from "next/font/google";
3
+ import "./globals.css";
4
+
5
+ const ibmPlexMono = IBM_Plex_Mono({
6
+ display: "swap",
7
+ subsets: ["latin"],
8
+ variable: "--font-ibm-plex-mono",
9
+ weight: ["400", "500", "600", "700"],
10
+ });
11
+
12
+ export const metadata: Metadata = {
13
+ title: "Humanity's Last Hackathon | Signup",
14
+ description:
15
+ "Simple Hugging Face OAuth signup app for Humanity's Last Hackathon, with private dataset registration storage.",
16
+ };
17
+
18
+ export default function RootLayout({
19
+ children,
20
+ }: Readonly<{
21
+ children: React.ReactNode;
22
+ }>) {
23
+ return (
24
+ <html lang="en" className={ibmPlexMono.variable}>
25
+ <body>{children}</body>
26
+ </html>
27
+ );
28
+ }
src/app/page.module.css ADDED
@@ -0,0 +1,308 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .page {
2
+ min-height: 100vh;
3
+ padding: 40px 20px 72px;
4
+ }
5
+
6
+ .shell {
7
+ width: min(1120px, 100%);
8
+ margin: 0 auto;
9
+ display: grid;
10
+ gap: 28px;
11
+ }
12
+
13
+ .hero {
14
+ display: grid;
15
+ gap: 14px;
16
+ max-width: 860px;
17
+ }
18
+
19
+ .eyebrow {
20
+ font-size: 0.78rem;
21
+ letter-spacing: 0.28em;
22
+ text-transform: uppercase;
23
+ color: var(--muted);
24
+ }
25
+
26
+ .title {
27
+ font-size: clamp(2.3rem, 7vw, 4.6rem);
28
+ line-height: 0.95;
29
+ letter-spacing: -0.05em;
30
+ text-transform: uppercase;
31
+ }
32
+
33
+ .subtitle {
34
+ max-width: 760px;
35
+ font-size: 1rem;
36
+ line-height: 1.7;
37
+ color: #c6c6c6;
38
+ }
39
+
40
+ .heroMeta {
41
+ display: flex;
42
+ flex-wrap: wrap;
43
+ gap: 10px;
44
+ }
45
+
46
+ .heroMeta span {
47
+ border: 1px solid var(--border);
48
+ background: rgba(255, 255, 255, 0.03);
49
+ padding: 10px 12px;
50
+ font-size: 0.76rem;
51
+ letter-spacing: 0.08em;
52
+ text-transform: uppercase;
53
+ }
54
+
55
+ .grid {
56
+ display: grid;
57
+ grid-template-columns: minmax(280px, 0.95fr) minmax(320px, 1.05fr);
58
+ gap: 20px;
59
+ }
60
+
61
+ .panel {
62
+ border: 1px solid var(--border);
63
+ background: linear-gradient(180deg, rgba(20, 20, 20, 0.96), rgba(8, 8, 8, 0.96));
64
+ box-shadow: 0 28px 70px var(--shadow);
65
+ padding: 24px;
66
+ display: grid;
67
+ align-content: start;
68
+ gap: 18px;
69
+ }
70
+
71
+ .panelLabel {
72
+ font-size: 0.74rem;
73
+ letter-spacing: 0.18em;
74
+ text-transform: uppercase;
75
+ color: var(--muted);
76
+ }
77
+
78
+ .panelTitle {
79
+ font-size: clamp(1.35rem, 3vw, 2rem);
80
+ line-height: 1.05;
81
+ }
82
+
83
+ .panelCopy {
84
+ color: #d0d0d0;
85
+ line-height: 1.7;
86
+ }
87
+
88
+ .list {
89
+ display: grid;
90
+ gap: 12px;
91
+ padding-left: 18px;
92
+ color: #d0d0d0;
93
+ line-height: 1.7;
94
+ }
95
+
96
+ .noteBlock {
97
+ border: 1px solid var(--border);
98
+ background: rgba(255, 255, 255, 0.03);
99
+ padding: 16px;
100
+ display: grid;
101
+ gap: 8px;
102
+ }
103
+
104
+ .noteTitle {
105
+ font-size: 0.76rem;
106
+ letter-spacing: 0.14em;
107
+ text-transform: uppercase;
108
+ color: var(--muted);
109
+ }
110
+
111
+ .noteBody {
112
+ font-size: 1rem;
113
+ }
114
+
115
+ .noteFoot {
116
+ color: var(--muted);
117
+ font-size: 0.86rem;
118
+ line-height: 1.6;
119
+ }
120
+
121
+ .profileCard {
122
+ display: flex;
123
+ gap: 16px;
124
+ align-items: center;
125
+ border: 1px solid var(--border);
126
+ background: rgba(255, 255, 255, 0.03);
127
+ padding: 18px;
128
+ }
129
+
130
+ .avatar {
131
+ width: 72px;
132
+ height: 72px;
133
+ object-fit: cover;
134
+ border: 1px solid var(--border);
135
+ background: #111111;
136
+ }
137
+
138
+ .profileCopy {
139
+ display: grid;
140
+ gap: 6px;
141
+ }
142
+
143
+ .profileName {
144
+ font-size: 1.1rem;
145
+ }
146
+
147
+ .profileMeta {
148
+ color: var(--muted);
149
+ font-size: 0.9rem;
150
+ }
151
+
152
+ .inlineLink {
153
+ width: fit-content;
154
+ color: #ffffff;
155
+ text-decoration: underline;
156
+ text-underline-offset: 3px;
157
+ font-size: 0.86rem;
158
+ }
159
+
160
+ .detailGrid {
161
+ display: grid;
162
+ gap: 12px;
163
+ }
164
+
165
+ .detailRow {
166
+ display: grid;
167
+ gap: 6px;
168
+ border: 1px solid var(--border);
169
+ background: rgba(255, 255, 255, 0.02);
170
+ padding: 14px 16px;
171
+ }
172
+
173
+ .detailLabel {
174
+ font-size: 0.74rem;
175
+ letter-spacing: 0.14em;
176
+ text-transform: uppercase;
177
+ color: var(--muted);
178
+ }
179
+
180
+ .detailValue {
181
+ line-height: 1.55;
182
+ word-break: break-word;
183
+ }
184
+
185
+ .actions {
186
+ display: flex;
187
+ flex-wrap: wrap;
188
+ gap: 12px;
189
+ align-items: center;
190
+ }
191
+
192
+ .primaryButton,
193
+ .secondaryButton {
194
+ display: inline-flex;
195
+ align-items: center;
196
+ justify-content: center;
197
+ min-height: 50px;
198
+ padding: 0 18px;
199
+ border: 1px solid var(--border);
200
+ text-transform: uppercase;
201
+ letter-spacing: 0.12em;
202
+ font-size: 0.76rem;
203
+ transition:
204
+ background 0.18s ease,
205
+ color 0.18s ease,
206
+ border-color 0.18s ease,
207
+ transform 0.18s ease;
208
+ }
209
+
210
+ .primaryButton {
211
+ background: #ffffff;
212
+ color: #000000;
213
+ }
214
+
215
+ .primaryButton:disabled {
216
+ opacity: 0.6;
217
+ cursor: wait;
218
+ }
219
+
220
+ .secondaryButton {
221
+ background: transparent;
222
+ color: #ffffff;
223
+ }
224
+
225
+ .successBadge {
226
+ display: inline-flex;
227
+ align-items: center;
228
+ min-height: 44px;
229
+ padding: 0 14px;
230
+ border: 1px solid rgba(255, 255, 255, 0.35);
231
+ background: rgba(255, 255, 255, 0.08);
232
+ text-transform: uppercase;
233
+ letter-spacing: 0.12em;
234
+ font-size: 0.72rem;
235
+ }
236
+
237
+ .status {
238
+ border: 1px solid var(--border);
239
+ background: rgba(255, 255, 255, 0.03);
240
+ padding: 14px 16px;
241
+ line-height: 1.6;
242
+ color: #d8d8d8;
243
+ }
244
+
245
+ .statusInfo {
246
+ border-color: rgba(255, 255, 255, 0.22);
247
+ }
248
+
249
+ .statusSuccess {
250
+ border-color: rgba(255, 255, 255, 0.42);
251
+ color: #ffffff;
252
+ }
253
+
254
+ .statusError {
255
+ border-color: rgba(255, 255, 255, 0.42);
256
+ background: rgba(255, 255, 255, 0.08);
257
+ }
258
+
259
+ @media (hover: hover) and (pointer: fine) {
260
+ .primaryButton:hover,
261
+ .secondaryButton:hover {
262
+ transform: translateY(-1px);
263
+ }
264
+
265
+ .primaryButton:hover {
266
+ background: transparent;
267
+ color: #ffffff;
268
+ }
269
+
270
+ .secondaryButton:hover {
271
+ background: rgba(255, 255, 255, 0.08);
272
+ }
273
+ }
274
+
275
+ @media (max-width: 900px) {
276
+ .grid {
277
+ grid-template-columns: 1fr;
278
+ }
279
+ }
280
+
281
+ @media (max-width: 720px) {
282
+ .page {
283
+ padding: 24px 14px 40px;
284
+ }
285
+
286
+ .heroMeta span {
287
+ width: 100%;
288
+ }
289
+
290
+ .panel {
291
+ padding: 18px;
292
+ }
293
+
294
+ .profileCard {
295
+ align-items: flex-start;
296
+ }
297
+
298
+ .actions {
299
+ flex-direction: column;
300
+ align-items: stretch;
301
+ }
302
+
303
+ .primaryButton,
304
+ .secondaryButton,
305
+ .successBadge {
306
+ width: 100%;
307
+ }
308
+ }
src/app/page.tsx ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ import { getSessionFromCookies } from "@/lib/session";
2
+
3
+ import SignupPanel from "./signup-panel";
4
+
5
+ export default async function Home() {
6
+ const session = await getSessionFromCookies();
7
+
8
+ return <SignupPanel session={session} />;
9
+ }
src/app/signup-panel.tsx ADDED
@@ -0,0 +1,244 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import Image from "next/image";
4
+ import { useState } from "react";
5
+ import { useSearchParams } from "next/navigation";
6
+
7
+ import type { SignupSession } from "@/lib/session";
8
+
9
+ import styles from "./page.module.css";
10
+
11
+ interface SignupPanelProps {
12
+ session: SignupSession | null;
13
+ }
14
+
15
+ interface StatusState {
16
+ kind: "idle" | "info" | "success" | "error";
17
+ message: string;
18
+ }
19
+
20
+ const HACKATHON_SIGNUP_URL =
21
+ "https://huggingface.co/organizations/humanitys-last-hackathon/share/ItCCmUCGldPpHioCeqQcymUPTDXuwEepwV";
22
+
23
+ function getQueryStatus(params: URLSearchParams, session: SignupSession | null): StatusState | null {
24
+ const error = params.get("error");
25
+ const signedUp = params.get("signed_up") === "1";
26
+
27
+ if (error === "oauth_denied") {
28
+ return {
29
+ kind: "error",
30
+ message: "Hugging Face authorization was denied before signup completed.",
31
+ };
32
+ }
33
+
34
+ if (error === "oauth_state") {
35
+ return {
36
+ kind: "error",
37
+ message: "The Hugging Face login session expired. Start the signup flow again.",
38
+ };
39
+ }
40
+
41
+ if (error === "oauth_exchange") {
42
+ return {
43
+ kind: "error",
44
+ message: "The server could not complete the Hugging Face OAuth handshake.",
45
+ };
46
+ }
47
+
48
+ if (error === "signup_store") {
49
+ return {
50
+ kind: "error",
51
+ message: "Your Hugging Face account was read successfully, but the signup record could not be written.",
52
+ };
53
+ }
54
+
55
+ if (signedUp || session?.registrationComplete) {
56
+ return {
57
+ kind: "success",
58
+ message: "You are signed up. Your Hugging Face profile details were added to the private roster.",
59
+ };
60
+ }
61
+
62
+ if (session) {
63
+ return {
64
+ kind: "info",
65
+ message: "Your Hugging Face account is connected. Finish signup to write your profile details to the roster.",
66
+ };
67
+ }
68
+
69
+ return null;
70
+ }
71
+
72
+ export default function SignupPanel({ session }: SignupPanelProps) {
73
+ const searchParams = useSearchParams();
74
+ const [submissionStatus, setSubmissionStatus] = useState<StatusState>({
75
+ kind: "idle",
76
+ message: "",
77
+ });
78
+ const [isSubmitting, setIsSubmitting] = useState(false);
79
+
80
+ const status = submissionStatus.kind !== "idle" ? submissionStatus : getQueryStatus(searchParams, session);
81
+ const isRegistered =
82
+ submissionStatus.kind === "success" ||
83
+ searchParams.get("signed_up") === "1" ||
84
+ session?.registrationComplete === true;
85
+
86
+ async function handleSignup() {
87
+ setIsSubmitting(true);
88
+ setSubmissionStatus({
89
+ kind: "info",
90
+ message: "Writing your Hugging Face profile details into the private signup dataset...",
91
+ });
92
+
93
+ try {
94
+ const response = await fetch("/api/signup", {
95
+ method: "POST",
96
+ });
97
+
98
+ const payload = (await response.json().catch(() => null)) as { error?: string } | null;
99
+
100
+ if (!response.ok) {
101
+ setSubmissionStatus({
102
+ kind: "error",
103
+ message: payload?.error ?? "Signup failed before the roster write completed.",
104
+ });
105
+ return;
106
+ }
107
+
108
+ setSubmissionStatus({
109
+ kind: "success",
110
+ message: "You are signed up. Your Hugging Face profile details were added to the private roster.",
111
+ });
112
+ } catch {
113
+ setSubmissionStatus({
114
+ kind: "error",
115
+ message: "Network failure while trying to write the signup record.",
116
+ });
117
+ } finally {
118
+ setIsSubmitting(false);
119
+ }
120
+ }
121
+
122
+ return (
123
+ <div className={styles.page}>
124
+ <main className={styles.shell}>
125
+ <section className={styles.hero}>
126
+ <p className={styles.eyebrow}>OpenAi + Hugging Face + GPU Mode</p>
127
+ <h1 className={styles.title}>Humanity&apos;s Last Hackathon</h1>
128
+ <p className={styles.subtitle}>
129
+ Sign up to the hardest most AGI pilled hackathon of 2026. Your task is to liberate AI models from the cloud, by optimizing kernels. <b>Your tools are Codex by OpenAI, Hugging Face, and pure mac metal silicon.</b>
130
+ </p>
131
+ <div className={styles.heroMeta}>
132
+ <span>Launch: May 4th, 2026</span>
133
+ <span>Format: Qualify online for the final battle</span>
134
+ </div>
135
+ </section>
136
+
137
+ <section className={styles.grid}>
138
+ <article className={styles.panel}>
139
+ <p className={styles.panelLabel}>What You&apos;re Signing Up For</p>
140
+ <h2 className={styles.panelTitle}>Three weeks of ML systems work aimed at the hardest kernel problems.</h2>
141
+ <ul className={styles.list}>
142
+ <li>Week 1: fundamental inference and onboarding through a fun optimization task.</li>
143
+ <li>Week 2: scaling production inference on the latest hardware.</li>
144
+ <li>Week 3: a frontier systems engineering challenge around state-of-the-art kernels.</li>
145
+ </ul>
146
+
147
+ {/* <div className={styles.noteBlock}>
148
+ <p className={styles.noteTitle}>OAuth scope</p>
149
+ <p className={styles.noteBody}>openid profile email</p>
150
+ <p className={styles.noteFoot}>
151
+ GitHub and X are not requested. The signup uses only the details Hugging Face exposes in the basic
152
+ profile flow.
153
+ </p>
154
+ </div> */}
155
+ </article>
156
+
157
+ <section className={styles.panel}>
158
+ <p className={styles.panelLabel}>Signup</p>
159
+ <h2 className={styles.panelTitle}>Register with your Hugging Face account.</h2>
160
+
161
+ {!session ? (
162
+ <>
163
+ <p className={styles.panelCopy}>
164
+ Continue on Hugging Face to open the official Humanity&apos;s Last Hackathon signup page.
165
+ </p>
166
+ <div className={styles.actions}>
167
+ <a className={styles.primaryButton} href={HACKATHON_SIGNUP_URL}>
168
+ Sign up with Hugging Face
169
+ </a>
170
+ </div>
171
+ </>
172
+ ) : (
173
+ <>
174
+ <div className={styles.profileCard}>
175
+ <Image
176
+ alt={`${session.username} avatar`}
177
+ className={styles.avatar}
178
+ height={72}
179
+ src={session.avatarUrl}
180
+ width={72}
181
+ />
182
+ <div className={styles.profileCopy}>
183
+ <p className={styles.profileName}>{session.name}</p>
184
+ <p className={styles.profileMeta}>@{session.username}</p>
185
+ <a className={styles.inlineLink} href={session.profileUrl} rel="noreferrer" target="_blank">
186
+ View Hugging Face profile
187
+ </a>
188
+ </div>
189
+ </div>
190
+
191
+ <div className={styles.detailGrid}>
192
+ <div className={styles.detailRow}>
193
+ <span className={styles.detailLabel}>Email</span>
194
+ <span className={styles.detailValue}>{session.email}</span>
195
+ </div>
196
+ <div className={styles.detailRow}>
197
+ <span className={styles.detailLabel}>Email verified</span>
198
+ <span className={styles.detailValue}>{session.emailVerified ? "Yes" : "No"}</span>
199
+ </div>
200
+ <div className={styles.detailRow}>
201
+ <span className={styles.detailLabel}>Website</span>
202
+ <span className={styles.detailValue}>{session.website ?? "Not set on profile"}</span>
203
+ </div>
204
+ <div className={styles.detailRow}>
205
+ <span className={styles.detailLabel}>Plan</span>
206
+ <span className={styles.detailValue}>{session.isPro ? "Pro" : "Free"}</span>
207
+ </div>
208
+ </div>
209
+
210
+ <div className={styles.actions}>
211
+ {!isRegistered ? (
212
+ <button className={styles.primaryButton} disabled={isSubmitting} onClick={handleSignup} type="button">
213
+ {isSubmitting ? "Signing up..." : "Complete signup"}
214
+ </button>
215
+ ) : (
216
+ <span className={styles.successBadge}>Signed up</span>
217
+ )}
218
+ <a className={styles.secondaryButton} href="/api/auth/logout">
219
+ {isRegistered ? "Use another account" : "Disconnect"}
220
+ </a>
221
+ </div>
222
+ </>
223
+ )}
224
+
225
+ {status ? (
226
+ <div
227
+ className={[
228
+ styles.status,
229
+ status.kind === "error" ? styles.statusError : "",
230
+ status.kind === "success" ? styles.statusSuccess : "",
231
+ status.kind === "info" ? styles.statusInfo : "",
232
+ ]
233
+ .filter(Boolean)
234
+ .join(" ")}
235
+ >
236
+ {status.message}
237
+ </div>
238
+ ) : null}
239
+ </section>
240
+ </section>
241
+ </main>
242
+ </div>
243
+ );
244
+ }
src/lib/huggingface.ts ADDED
@@ -0,0 +1,255 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createRepo, repoExists, uploadFile, whoAmI } from "@huggingface/hub";
2
+ import type { NextRequest } from "next/server";
3
+ import { z } from "zod";
4
+
5
+ import type { SignupSession } from "./session";
6
+
7
+ const DEFAULT_HUB_URL = "https://huggingface.co";
8
+ const OAUTH_SCOPE = "openid profile email";
9
+ const EVENT_NAME = "Humanity's Last Hackathon";
10
+ const EVENT_OBJECTIVE = "Write Mac Metal kernels with Codex.";
11
+
12
+ const tokenResponseSchema = z.object({
13
+ access_token: z.string().min(1),
14
+ expires_in: z.number().int().positive(),
15
+ scope: z.string().min(1),
16
+ token_type: z.string().min(1),
17
+ });
18
+
19
+ const userInfoSchema = z.object({
20
+ sub: z.string().min(1),
21
+ name: z.string(),
22
+ preferred_username: z.string().min(1),
23
+ email: z.string().email().optional(),
24
+ email_verified: z.boolean().optional(),
25
+ picture: z.string().url(),
26
+ profile: z.string().url(),
27
+ website: z.string().url().optional(),
28
+ isPro: z.boolean(),
29
+ });
30
+
31
+ export interface SignupRecord {
32
+ schemaVersion: 2;
33
+ event: {
34
+ name: string;
35
+ objective: string;
36
+ };
37
+ signedUpAt: string;
38
+ source: {
39
+ type: "huggingface-oauth";
40
+ scope: string;
41
+ };
42
+ participant: {
43
+ hf: {
44
+ id: string;
45
+ username: string;
46
+ fullName: string;
47
+ email: string;
48
+ emailVerified: boolean;
49
+ avatarUrl: string;
50
+ profileUrl: string;
51
+ website: string | null;
52
+ isPro: boolean;
53
+ };
54
+ };
55
+ }
56
+
57
+ function getHubUrl(): string {
58
+ return (process.env.HF_HUB_URL?.trim() || DEFAULT_HUB_URL).replace(/\/+$/, "");
59
+ }
60
+
61
+ function requireEnv(name: string): string {
62
+ const value = process.env[name]?.trim();
63
+
64
+ if (!value) {
65
+ throw new Error(`Missing ${name}.`);
66
+ }
67
+
68
+ return value;
69
+ }
70
+
71
+ export function getAppUrl(request?: NextRequest): string {
72
+ const configured = process.env.APP_URL?.trim();
73
+
74
+ if (configured) {
75
+ return configured.replace(/\/+$/, "");
76
+ }
77
+
78
+ if (!request) {
79
+ return "http://localhost:3000";
80
+ }
81
+
82
+ const forwardedProto = request.headers.get("x-forwarded-proto");
83
+ const forwardedHost = request.headers.get("x-forwarded-host") ?? request.headers.get("host");
84
+
85
+ if (forwardedProto && forwardedHost) {
86
+ return `${forwardedProto}://${forwardedHost}`;
87
+ }
88
+
89
+ return new URL(request.url).origin;
90
+ }
91
+
92
+ export function getOAuthRedirectUri(request?: NextRequest): string {
93
+ return `${getAppUrl(request)}/api/auth/huggingface/callback`;
94
+ }
95
+
96
+ export function createOAuthAuthorizationUrl(params: {
97
+ state: string;
98
+ codeChallenge: string;
99
+ request?: NextRequest;
100
+ }): string {
101
+ const search = new URLSearchParams({
102
+ client_id: requireEnv("HF_OAUTH_CLIENT_ID"),
103
+ redirect_uri: getOAuthRedirectUri(params.request),
104
+ response_type: "code",
105
+ scope: OAUTH_SCOPE,
106
+ state: params.state,
107
+ code_challenge: params.codeChallenge,
108
+ code_challenge_method: "S256",
109
+ });
110
+
111
+ return `${getHubUrl()}/oauth/authorize?${search.toString()}`;
112
+ }
113
+
114
+ export async function exchangeCodeForToken(params: {
115
+ code: string;
116
+ codeVerifier: string;
117
+ redirectUri: string;
118
+ }) {
119
+ const response = await fetch(`${getHubUrl()}/oauth/token`, {
120
+ method: "POST",
121
+ headers: {
122
+ "Content-Type": "application/x-www-form-urlencoded",
123
+ },
124
+ body: new URLSearchParams({
125
+ grant_type: "authorization_code",
126
+ code: params.code,
127
+ redirect_uri: params.redirectUri,
128
+ client_id: requireEnv("HF_OAUTH_CLIENT_ID"),
129
+ client_secret: requireEnv("HF_OAUTH_CLIENT_SECRET"),
130
+ code_verifier: params.codeVerifier,
131
+ }),
132
+ });
133
+
134
+ if (!response.ok) {
135
+ throw new Error(`Hugging Face token exchange failed with status ${response.status}.`);
136
+ }
137
+
138
+ return tokenResponseSchema.parse(await response.json());
139
+ }
140
+
141
+ export async function fetchUserInfo(accessToken: string) {
142
+ const response = await fetch(`${getHubUrl()}/oauth/userinfo`, {
143
+ headers: {
144
+ Authorization: `Bearer ${accessToken}`,
145
+ },
146
+ });
147
+
148
+ if (!response.ok) {
149
+ throw new Error(`Hugging Face userinfo failed with status ${response.status}.`);
150
+ }
151
+
152
+ return userInfoSchema.parse(await response.json());
153
+ }
154
+
155
+ export async function fetchViewer(accessToken: string) {
156
+ const viewer = await whoAmI({ accessToken });
157
+
158
+ if (viewer.type !== "user") {
159
+ throw new Error("Expected a user account from Hugging Face OAuth.");
160
+ }
161
+
162
+ return viewer;
163
+ }
164
+
165
+ export function createSignupRecord(params: { session: SignupSession }): SignupRecord {
166
+ const signedUpAt = new Date().toISOString();
167
+
168
+ return {
169
+ schemaVersion: 2,
170
+ event: {
171
+ name: EVENT_NAME,
172
+ objective: EVENT_OBJECTIVE,
173
+ },
174
+ signedUpAt,
175
+ source: {
176
+ type: "huggingface-oauth",
177
+ scope: OAUTH_SCOPE,
178
+ },
179
+ participant: {
180
+ hf: {
181
+ id: params.session.id,
182
+ username: params.session.username,
183
+ fullName: params.session.name,
184
+ email: params.session.email,
185
+ emailVerified: params.session.emailVerified,
186
+ avatarUrl: params.session.avatarUrl,
187
+ profileUrl: params.session.profileUrl,
188
+ website: params.session.website,
189
+ isPro: params.session.isPro,
190
+ },
191
+ },
192
+ };
193
+ }
194
+
195
+ function createDatasetReadme(): string {
196
+ return `---
197
+ tags:
198
+ - private
199
+ - registrations
200
+ - hackathon
201
+ ---
202
+
203
+ # Humanity's Last Hackathon Signups
204
+
205
+ Private registration dataset for the Humanity's Last Hackathon landing page.
206
+
207
+ - Event: ${EVENT_NAME}
208
+ - Objective: ${EVENT_OBJECTIVE}
209
+ - Source: simple Hugging Face OAuth signup app
210
+
211
+ Each participant record is stored at \`signups/<hf-user-id>.json\`.
212
+ `;
213
+ }
214
+
215
+ export async function storeSignupRecord(record: SignupRecord) {
216
+ const repo = {
217
+ type: "dataset" as const,
218
+ name: requireEnv("HF_DATASET_REPO"),
219
+ };
220
+
221
+ const accessToken = requireEnv("HF_DATASET_WRITE_TOKEN");
222
+ const filePath = `signups/${record.participant.hf.id}.json`;
223
+
224
+ if (!(await repoExists({ repo, accessToken }))) {
225
+ await createRepo({
226
+ repo,
227
+ accessToken,
228
+ private: true,
229
+ files: [
230
+ {
231
+ path: "README.md",
232
+ content: new Blob([createDatasetReadme()], { type: "text/markdown" }),
233
+ },
234
+ ],
235
+ });
236
+ }
237
+
238
+ await uploadFile({
239
+ repo,
240
+ accessToken,
241
+ file: {
242
+ path: filePath,
243
+ content: new Blob([`${JSON.stringify(record, null, 2)}\n`], {
244
+ type: "application/json",
245
+ }),
246
+ },
247
+ commitTitle: `Register ${record.participant.hf.username} for Humanity's Last Hackathon`,
248
+ commitDescription: "Write or update a signup record from the Hugging Face OAuth signup app.",
249
+ });
250
+
251
+ return {
252
+ repoName: repo.name,
253
+ path: filePath,
254
+ };
255
+ }
src/lib/session.ts ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
2
+
3
+ import { cookies } from "next/headers";
4
+ import type { NextRequest, NextResponse } from "next/server";
5
+ import { z } from "zod";
6
+
7
+ export const SESSION_COOKIE_NAME = "hlh_signup_session";
8
+ export const OAUTH_STATE_COOKIE_NAME = "hlh_signup_oauth_state";
9
+ export const OAUTH_VERIFIER_COOKIE_NAME = "hlh_signup_oauth_verifier";
10
+
11
+ const OAUTH_COOKIE_MAX_AGE_SECONDS = 60 * 10;
12
+ const SESSION_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 30;
13
+
14
+ const signupSessionSchema = z.object({
15
+ id: z.string().min(1),
16
+ username: z.string().min(1),
17
+ name: z.string().min(1),
18
+ email: z.string().email(),
19
+ emailVerified: z.boolean(),
20
+ avatarUrl: z.string().url(),
21
+ profileUrl: z.string().url(),
22
+ website: z.string().url().nullable(),
23
+ isPro: z.boolean(),
24
+ registrationComplete: z.boolean().default(false),
25
+ });
26
+
27
+ export type SignupSession = z.infer<typeof signupSessionSchema>;
28
+
29
+ function getSessionSecret(): string {
30
+ const secret = process.env.SESSION_SECRET?.trim();
31
+
32
+ if (!secret) {
33
+ throw new Error("Missing SESSION_SECRET.");
34
+ }
35
+
36
+ return secret;
37
+ }
38
+
39
+ function getCookieOptions(maxAge?: number) {
40
+ return {
41
+ httpOnly: true,
42
+ sameSite: "lax" as const,
43
+ secure: process.env.NODE_ENV === "production",
44
+ path: "/",
45
+ ...(typeof maxAge === "number" ? { maxAge } : {}),
46
+ };
47
+ }
48
+
49
+ function encodeJsonPayload(value: unknown): string {
50
+ return Buffer.from(JSON.stringify(value), "utf8").toString("base64url");
51
+ }
52
+
53
+ function decodeJsonPayload(value: string): unknown {
54
+ return JSON.parse(Buffer.from(value, "base64url").toString("utf8"));
55
+ }
56
+
57
+ function signPayload(payload: string): string {
58
+ return createHmac("sha256", getSessionSecret()).update(payload).digest("base64url");
59
+ }
60
+
61
+ function verifyPayload(payload: string, signature: string): boolean {
62
+ const expected = Buffer.from(signPayload(payload), "utf8");
63
+ const actual = Buffer.from(signature, "utf8");
64
+
65
+ if (expected.length !== actual.length) {
66
+ return false;
67
+ }
68
+
69
+ return timingSafeEqual(expected, actual);
70
+ }
71
+
72
+ export function createOpaqueToken(bytes = 32): string {
73
+ return randomBytes(bytes).toString("base64url");
74
+ }
75
+
76
+ export function createPkcePair(): { verifier: string; challenge: string } {
77
+ const verifier = createOpaqueToken(48);
78
+ const challenge = createHash("sha256").update(verifier).digest("base64url");
79
+
80
+ return { verifier, challenge };
81
+ }
82
+
83
+ export function parseSessionCookie(value?: string | null): SignupSession | null {
84
+ if (!value) {
85
+ return null;
86
+ }
87
+
88
+ const [payload, signature] = value.split(".");
89
+
90
+ if (!payload || !signature || !verifyPayload(payload, signature)) {
91
+ return null;
92
+ }
93
+
94
+ try {
95
+ return signupSessionSchema.parse(decodeJsonPayload(payload));
96
+ } catch {
97
+ return null;
98
+ }
99
+ }
100
+
101
+ export async function getSessionFromCookies(): Promise<SignupSession | null> {
102
+ const cookieStore = await cookies();
103
+ return parseSessionCookie(cookieStore.get(SESSION_COOKIE_NAME)?.value);
104
+ }
105
+
106
+ export function setSessionCookie(response: NextResponse, session: SignupSession) {
107
+ const payload = encodeJsonPayload(session);
108
+ const signedValue = `${payload}.${signPayload(payload)}`;
109
+
110
+ response.cookies.set(SESSION_COOKIE_NAME, signedValue, getCookieOptions(SESSION_COOKIE_MAX_AGE_SECONDS));
111
+ }
112
+
113
+ export function clearSessionCookie(response: NextResponse) {
114
+ response.cookies.set(SESSION_COOKIE_NAME, "", getCookieOptions(0));
115
+ }
116
+
117
+ export function setOAuthCookies(response: NextResponse, state: string, verifier: string) {
118
+ response.cookies.set(OAUTH_STATE_COOKIE_NAME, state, getCookieOptions(OAUTH_COOKIE_MAX_AGE_SECONDS));
119
+ response.cookies.set(OAUTH_VERIFIER_COOKIE_NAME, verifier, getCookieOptions(OAUTH_COOKIE_MAX_AGE_SECONDS));
120
+ }
121
+
122
+ export function readOAuthCookies(request: NextRequest): {
123
+ state: string | null;
124
+ verifier: string | null;
125
+ } {
126
+ return {
127
+ state: request.cookies.get(OAUTH_STATE_COOKIE_NAME)?.value ?? null,
128
+ verifier: request.cookies.get(OAUTH_VERIFIER_COOKIE_NAME)?.value ?? null,
129
+ };
130
+ }
131
+
132
+ export function clearOAuthCookies(response: NextResponse) {
133
+ response.cookies.set(OAUTH_STATE_COOKIE_NAME, "", getCookieOptions(0));
134
+ response.cookies.set(OAUTH_VERIFIER_COOKIE_NAME, "", getCookieOptions(0));
135
+ }
tsconfig.json ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2017",
4
+ "lib": ["dom", "dom.iterable", "esnext"],
5
+ "allowJs": true,
6
+ "skipLibCheck": true,
7
+ "strict": true,
8
+ "noEmit": true,
9
+ "esModuleInterop": true,
10
+ "module": "esnext",
11
+ "moduleResolution": "bundler",
12
+ "resolveJsonModule": true,
13
+ "isolatedModules": true,
14
+ "jsx": "react-jsx",
15
+ "incremental": true,
16
+ "plugins": [
17
+ {
18
+ "name": "next"
19
+ }
20
+ ],
21
+ "paths": {
22
+ "@/*": ["./src/*"]
23
+ }
24
+ },
25
+ "include": [
26
+ "next-env.d.ts",
27
+ "**/*.ts",
28
+ "**/*.tsx",
29
+ ".next/types/**/*.ts",
30
+ ".next/dev/types/**/*.ts",
31
+ "**/*.mts"
32
+ ],
33
+ "exclude": ["node_modules"]
34
+ }