Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Kiosk-v2/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Required for deterministic card digests and migration identifiers.
# Set this as a hosted secret; never commit a real value.
CARD_UID_HMAC_SECRET=
42 changes: 42 additions & 0 deletions Kiosk-v2/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.

# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions

# testing
/coverage

# next.js
/.next/
/.vinext/
/out/

# misc
.DS_Store
*.pem

# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*

# env files (can opt-in for committing if needed)
.env*
!.env.example
*.tsbuildinfo

# vercel
.vercel

/dist/
/.wrangler/
/outputs/
/work/
5 changes: 5 additions & 0 deletions Kiosk-v2/.openai/hosting.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"project_id": "appgprj_6a7a93698b7481918f4309125f627899",
"d1": "DB",
"r2": null
}
95 changes: 95 additions & 0 deletions Kiosk-v2/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# Scripps Sandbox check-in

Interactive kiosk prototype and production technical spike for the Scripps Sandbox Makerspace.

- `app/` contains the 1920×1080 kiosk interface and interactive failure/exception flows.
- `bridge/` contains the Raspberry Pi serial-to-browser reader bridge.
- `docs/production-spec.md` captures the agreed workflows, permissions, data model, failure behavior, migration, and milestones.

## Prerequisites

- Node.js `>=22.13.0`

## Kiosk development

```bash
npm install
npm run dev
npm run build
```

This starter does not use `wrangler.jsonc`.

When served from `localhost`, the interface automatically looks for the local reader bridge at `ws://127.0.0.1:8765/ws`. The hosted design prototype stays in demo mode. See `bridge/README.md` for Pi setup and hardware-free simulation.

## Workspace Auth Headers

Signed-in visitors receive both `oai-authenticated-user-id` and `oai-authenticated-user-email`. Private Sites require every visitor to sign in; public Sites may also have anonymous visitors, for whom neither header is present.

The user ID is stable for the same user on the same Site and different across Sites. Email and name are intended for display or contact purposes.

SIWC-authenticated workspace sites may also receive
`oai-authenticated-user-full-name` when the user's SIWC profile has a non-empty
`name` claim. The full-name value is percent-encoded UTF-8 and is accompanied by
`oai-authenticated-user-full-name-encoding: percent-encoded-utf-8`.

Treat the full name as optional and fall back to email when it is absent:

```tsx
import { headers } from "next/headers";

export default async function Home() {
const requestHeaders = await headers();
const userId = requestHeaders.get("oai-authenticated-user-id");
const email = requestHeaders.get("oai-authenticated-user-email");
const encodedFullName = requestHeaders.get("oai-authenticated-user-full-name");
const fullName =
encodedFullName &&
requestHeaders.get("oai-authenticated-user-full-name-encoding") ===
"percent-encoded-utf-8"
? decodeURIComponent(encodedFullName)
: null;

const displayName = fullName ?? email;
// ...
}
```

## Optional Dispatch-Owned ChatGPT Sign-In

Import the ready-to-use helpers from `app/chatgpt-auth.ts` when the site needs
optional or required ChatGPT sign-in:

- Use `getChatGPTUser()` for optional signed-in UI.
- Use `requireChatGPTUser(returnTo)` for server-rendered pages that should send
anonymous visitors through Sign in with ChatGPT.
- Use `chatGPTSignInPath(returnTo)` and `chatGPTSignOutPath(returnTo)` for
browser links or actions.
- Pass a same-origin relative `returnTo` path for the destination after sign-in
or sign-out. The helper validates and safely encodes it.
- Mark protected pages with `export const dynamic = "force-dynamic"` because
they depend on per-request identity headers.

Dispatch owns `/signin-with-chatgpt`, `/signout-with-chatgpt`, `/callback`, the
OAuth cookies, and identity header injection. Do not implement app routes for
those reserved paths. Routes that do not import and call the helper remain
anonymous-compatible.

SIWC establishes identity only; it does not prove workspace membership. Use the
Sites hosting platform's access policy controls for workspace-wide restrictions,
or enforce explicit server-side membership or allowlist checks.

Use SIWC for account pages, user-specific dashboards, saved records, and write
actions tied to the current ChatGPT user. Leave public content anonymous.

## Useful Commands

- `npm run dev`: start local development
- `npm run build`: verify the vinext build output
- `npm test`: build the starter and verify its rendered loading skeleton
- `npm run db:generate`: generate Drizzle migrations after schema changes

## Learn More

- [vinext Documentation](https://github.com/cloudflare/vinext)
- [Drizzle D1 Guide](https://orm.drizzle.team/docs/get-started/d1-new)
158 changes: 158 additions & 0 deletions Kiosk-v2/app/api/registrations/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { getDb } from "@/db";
import {
auditEvents,
cardLinkSessions,
registrationApplications,
userIdentifiers,
users,
waiverStatuses,
} from "@/db/schema";
import {
createClaimCode,
digestClaimCode,
id,
makeDisplayName,
normalizeEmail,
normalizeIdentifier,
REGISTRATION_CONSENT_VERSION,
WAIVER_POWERFORM_URL,
} from "@/lib/registration";

type RegistrationPayload = {
firstName?: string;
lastName?: string;
preferredName?: string;
userType?: string;
affiliation?: string;
email?: string;
secondaryEmail?: string;
identifierType?: string;
identifierValue?: string;
consent?: boolean;
};

const allowedUserTypes = new Set(["student", "staff", "faculty", "postdoc", "visitor", "other"]);
const allowedIdentifierTypes = new Set(["pid", "employee_id", "other"]);

function validEmail(value: string) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
}
function errorMessage(error: unknown) {
const message = error instanceof Error ? error.message : "Unexpected registration error";
if (message.includes("UNIQUE constraint failed")) {
return "An account already exists for that email or UC San Diego ID. Please use the existing-account option at the kiosk or ask staff for help.";
}
if (message.includes("D1 binding") || message.includes("no such table")) {
return "Registration storage is not ready yet. No information was saved.";
}
return "We could not save the registration. Please try again or ask Sandbox staff for help.";
}

export async function POST(request: Request) {
try {
const payload = (await request.json()) as RegistrationPayload;
const firstName = payload.firstName?.trim() ?? "";
const lastName = payload.lastName?.trim() ?? "";
const preferredName = payload.preferredName?.trim() || null;
const userType = payload.userType?.trim().toLowerCase() ?? "";
const affiliation = payload.affiliation?.trim() ?? "";
const primaryEmail = normalizeEmail(payload.email ?? "");
const secondaryEmail = payload.secondaryEmail
? normalizeEmail(payload.secondaryEmail)
: null;
const identifierType = payload.identifierType?.trim().toLowerCase() ?? "";
const identifierValue = payload.identifierValue?.trim() ?? "";
const normalizedIdentifier = normalizeIdentifier(identifierValue);

if (!firstName || !lastName || !affiliation) {
return Response.json({ error: "Name and affiliation are required." }, { status: 400 });
}
if (!allowedUserTypes.has(userType) || !allowedIdentifierTypes.has(identifierType)) {
return Response.json({ error: "Choose a valid role and ID type." }, { status: 400 });
}
if (!validEmail(primaryEmail) || (secondaryEmail && !validEmail(secondaryEmail))) {
return Response.json({ error: "Enter a valid email address." }, { status: 400 });
}
if (normalizedIdentifier.length < 4 || normalizedIdentifier.length > 32) {
return Response.json({ error: "Enter a valid UC San Diego ID number." }, { status: 400 });
}
if (!payload.consent) {
return Response.json({ error: "Consent is required to create an account." }, { status: 400 });
}

const userId = id("usr");
const identifierId = id("uid");
const waiverId = id("wvr");
const applicationId = id("reg");
const linkSessionId = id("lnk");
const auditId = id("aud");
const claimCode = createClaimCode();
const codeDigest = await digestClaimCode(claimCode);
const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString();
const displayName = makeDisplayName(firstName, lastName, preferredName ?? undefined);

const db = getDb();
await db.batch([
db.insert(users).values({
id: userId,
firstName,
lastName,
preferredName,
displayName,
userType,
affiliation,
primaryEmail,
secondaryEmail,
status: "pending_waiver",
}),
db.insert(userIdentifiers).values({
id: identifierId,
userId,
identifierType,
identifierValue,
normalizedValue: normalizedIdentifier,
isPrimary: true,
sourceSystem: "Sandbox website registration",
}),
db.insert(waiverStatuses).values({
id: waiverId,
userId,
status: "pending",
}),
db.insert(registrationApplications).values({
id: applicationId,
userId,
consentVersion: REGISTRATION_CONSENT_VERSION,
}),
db.insert(cardLinkSessions).values({
id: linkSessionId,
userId,
applicationId,
codeDigest,
codeLastTwo: claimCode.slice(-2),
expiresAt,
}),
db.insert(auditEvents).values({
id: auditId,
actorEmail: primaryEmail,
action: "registration.submitted",
entityType: "registration_application",
entityId: applicationId,
detailJson: JSON.stringify({ source: "website", consentVersion: REGISTRATION_CONSENT_VERSION }),
}),
]);

return Response.json(
{
applicationId,
displayName,
claimCode,
claimCodeExpiresAt: expiresAt,
waiverUrl: WAIVER_POWERFORM_URL,
},
{ status: 201 },
);
} catch (error) {
return Response.json({ error: errorMessage(error) }, { status: 500 });
}
}
90 changes: 90 additions & 0 deletions Kiosk-v2/app/chatgpt-auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { headers } from "next/headers";
import { redirect } from "next/navigation";

export type ChatGPTUser = {
userId: string;
displayName: string;
email: string;
fullName: string | null;
};

const USER_ID_HEADER = "oai-authenticated-user-id";
const USER_EMAIL_HEADER = "oai-authenticated-user-email";
const USER_FULL_NAME_HEADER = "oai-authenticated-user-full-name";
const USER_FULL_NAME_ENCODING_HEADER =
"oai-authenticated-user-full-name-encoding";
const PERCENT_ENCODED_UTF8 = "percent-encoded-utf-8";
const SIGN_IN_PATH = "/signin-with-chatgpt";
const SIGN_OUT_PATH = "/signout-with-chatgpt";
const CALLBACK_PATH = "/callback";

export async function getChatGPTUser(): Promise<ChatGPTUser | null> {
const requestHeaders = await headers();
const userId = requestHeaders.get(USER_ID_HEADER);
const email = requestHeaders.get(USER_EMAIL_HEADER);
if (!userId || !email) return null;

const encodedFullName = requestHeaders.get(USER_FULL_NAME_HEADER);
const fullName =
encodedFullName &&
requestHeaders.get(USER_FULL_NAME_ENCODING_HEADER) === PERCENT_ENCODED_UTF8
? safeDecodeURIComponent(encodedFullName)
: null;

return {
userId,
displayName: fullName ?? email,
email,
fullName,
};
}

export async function requireChatGPTUser(
returnTo: string,
): Promise<ChatGPTUser> {
const user = await getChatGPTUser();
if (user) return user;

redirect(chatGPTSignInPath(returnTo));
}

export function chatGPTSignInPath(returnTo: string): string {
const safeReturnTo = safeRelativeReturnPath(returnTo);
return `${SIGN_IN_PATH}?return_to=${encodeURIComponent(safeReturnTo)}`;
}

export function chatGPTSignOutPath(returnTo = "/"): string {
const safeReturnTo = safeRelativeReturnPath(returnTo);
return `${SIGN_OUT_PATH}?return_to=${encodeURIComponent(safeReturnTo)}`;
}

function safeRelativeReturnPath(value: string): string {
if (!value.startsWith("/") || value.startsWith("//")) return "/";

let url: URL;
try {
url = new URL(value, "https://app.local");
} catch {
return "/";
}
if (url.origin !== "https://app.local") return "/";
if (isReservedAuthPath(url.pathname)) return "/";

return `${url.pathname}${url.search}${url.hash}`;
}

function isReservedAuthPath(pathname: string): boolean {
return (
pathname === SIGN_IN_PATH ||
pathname === SIGN_OUT_PATH ||
pathname === CALLBACK_PATH
);
}

function safeDecodeURIComponent(value: string): string | null {
try {
return decodeURIComponent(value);
} catch {
return null;
}
}
Loading