h4ckath0n 0.1.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 (48) hide show
  1. package/bin/cli.js +189 -0
  2. package/lib/scaffold.js +144 -0
  3. package/package.json +38 -0
  4. package/templates/fullstack/README.md +56 -0
  5. package/templates/fullstack/backend/.python-version +1 -0
  6. package/templates/fullstack/backend/app/__init__.py +1 -0
  7. package/templates/fullstack/backend/app/cli.py +98 -0
  8. package/templates/fullstack/backend/app/main.py +7 -0
  9. package/templates/fullstack/backend/app/middleware.py +55 -0
  10. package/templates/fullstack/backend/pyproject.toml +19 -0
  11. package/templates/fullstack/web/eslint.config.js +22 -0
  12. package/templates/fullstack/web/index.html +13 -0
  13. package/templates/fullstack/web/package-lock.json +5133 -0
  14. package/templates/fullstack/web/package.json +42 -0
  15. package/templates/fullstack/web/public/vite.svg +4 -0
  16. package/templates/fullstack/web/src/App.tsx +45 -0
  17. package/templates/fullstack/web/src/auth/AuthContext.tsx +238 -0
  18. package/templates/fullstack/web/src/auth/__tests__/token.test.ts +22 -0
  19. package/templates/fullstack/web/src/auth/__tests__/webauthn.test.ts +44 -0
  20. package/templates/fullstack/web/src/auth/api.ts +63 -0
  21. package/templates/fullstack/web/src/auth/deviceKey.ts +71 -0
  22. package/templates/fullstack/web/src/auth/index.ts +26 -0
  23. package/templates/fullstack/web/src/auth/token.ts +59 -0
  24. package/templates/fullstack/web/src/auth/webauthn.ts +133 -0
  25. package/templates/fullstack/web/src/auth/ws.ts +40 -0
  26. package/templates/fullstack/web/src/components/Alert.tsx +35 -0
  27. package/templates/fullstack/web/src/components/Button.tsx +37 -0
  28. package/templates/fullstack/web/src/components/Card.tsx +22 -0
  29. package/templates/fullstack/web/src/components/Input.tsx +27 -0
  30. package/templates/fullstack/web/src/components/Layout.tsx +88 -0
  31. package/templates/fullstack/web/src/components/ProtectedRoute.tsx +34 -0
  32. package/templates/fullstack/web/src/components/index.ts +6 -0
  33. package/templates/fullstack/web/src/index.css +48 -0
  34. package/templates/fullstack/web/src/main.tsx +28 -0
  35. package/templates/fullstack/web/src/pages/Admin.tsx +43 -0
  36. package/templates/fullstack/web/src/pages/Dashboard.tsx +75 -0
  37. package/templates/fullstack/web/src/pages/Landing.tsx +73 -0
  38. package/templates/fullstack/web/src/pages/Login.tsx +66 -0
  39. package/templates/fullstack/web/src/pages/Register.tsx +80 -0
  40. package/templates/fullstack/web/src/pages/Settings.tsx +172 -0
  41. package/templates/fullstack/web/src/pages/index.ts +6 -0
  42. package/templates/fullstack/web/src/test/setup.ts +1 -0
  43. package/templates/fullstack/web/src/vite-env.d.ts +1 -0
  44. package/templates/fullstack/web/tsconfig.app.json +21 -0
  45. package/templates/fullstack/web/tsconfig.json +7 -0
  46. package/templates/fullstack/web/tsconfig.node.json +18 -0
  47. package/templates/fullstack/web/vite.config.ts +16 -0
  48. package/templates/fullstack/web/vitest.config.ts +9 -0
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "{{PROJECT_NAME}}-web",
3
+ "private": true,
4
+ "version": "0.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "tsc -b && vite build",
9
+ "preview": "vite preview",
10
+ "lint": "eslint .",
11
+ "typecheck": "tsc --noEmit",
12
+ "test": "vitest run",
13
+ "test:watch": "vitest"
14
+ },
15
+ "dependencies": {
16
+ "react": "^19.1.0",
17
+ "react-dom": "^19.1.0",
18
+ "react-router": "^7.12.0",
19
+ "@tanstack/react-query": "^5.80.7",
20
+ "lucide-react": "^0.563.0",
21
+ "jose": "^6.0.11",
22
+ "idb-keyval": "^6.2.1"
23
+ },
24
+ "devDependencies": {
25
+ "@types/react": "^19.1.8",
26
+ "@types/react-dom": "^19.1.6",
27
+ "@vitejs/plugin-react": "^4.5.2",
28
+ "typescript": "~5.9.0",
29
+ "vite": "^6.3.5",
30
+ "@tailwindcss/vite": "^4.1.10",
31
+ "tailwindcss": "^4.1.10",
32
+ "eslint": "^9.28.0",
33
+ "@eslint/js": "^9.28.0",
34
+ "typescript-eslint": "^8.33.1",
35
+ "eslint-plugin-react-hooks": "^5.2.0",
36
+ "globals": "^16.2.0",
37
+ "vitest": "^3.2.4",
38
+ "@testing-library/react": "^16.3.0",
39
+ "@testing-library/jest-dom": "^6.6.3",
40
+ "jsdom": "^26.1.0"
41
+ }
42
+ }
@@ -0,0 +1,4 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" fill="none">
2
+ <rect width="32" height="32" rx="6" fill="#6366f1"/>
3
+ <text x="16" y="22" text-anchor="middle" font-size="18" font-weight="bold" fill="white" font-family="system-ui">h4</text>
4
+ </svg>
@@ -0,0 +1,45 @@
1
+ import { Routes, Route } from "react-router";
2
+ import { Layout } from "./components/Layout";
3
+ import { ProtectedRoute } from "./components/ProtectedRoute";
4
+ import { Landing } from "./pages/Landing";
5
+ import { Register } from "./pages/Register";
6
+ import { Login } from "./pages/Login";
7
+ import { Dashboard } from "./pages/Dashboard";
8
+ import { Settings } from "./pages/Settings";
9
+ import { Admin } from "./pages/Admin";
10
+
11
+ export function App() {
12
+ return (
13
+ <Routes>
14
+ <Route element={<Layout />}>
15
+ <Route path="/" element={<Landing />} />
16
+ <Route path="/register" element={<Register />} />
17
+ <Route path="/login" element={<Login />} />
18
+ <Route
19
+ path="/dashboard"
20
+ element={
21
+ <ProtectedRoute>
22
+ <Dashboard />
23
+ </ProtectedRoute>
24
+ }
25
+ />
26
+ <Route
27
+ path="/settings"
28
+ element={
29
+ <ProtectedRoute>
30
+ <Settings />
31
+ </ProtectedRoute>
32
+ }
33
+ />
34
+ <Route
35
+ path="/admin"
36
+ element={
37
+ <ProtectedRoute requiredRole="admin">
38
+ <Admin />
39
+ </ProtectedRoute>
40
+ }
41
+ />
42
+ </Route>
43
+ </Routes>
44
+ );
45
+ }
@@ -0,0 +1,238 @@
1
+ import {
2
+ createContext,
3
+ useContext,
4
+ useState,
5
+ useEffect,
6
+ useCallback,
7
+ type ReactNode,
8
+ } from "react";
9
+ import {
10
+ ensureDeviceKeyMaterial,
11
+ getDeviceIdentity,
12
+ setDeviceIdentity,
13
+ clearDeviceKeyMaterial,
14
+ } from "./deviceKey";
15
+ import { clearCachedToken } from "./token";
16
+ import { publicFetch } from "./api";
17
+ import {
18
+ toCreateOptions,
19
+ toGetOptions,
20
+ serializeCreateResponse,
21
+ serializeGetResponse,
22
+ } from "./webauthn";
23
+ import { useNavigate } from "react-router";
24
+
25
+ interface User {
26
+ id: string;
27
+ role: string;
28
+ scopes: string[];
29
+ }
30
+
31
+ interface AuthState {
32
+ isAuthenticated: boolean;
33
+ isLoading: boolean;
34
+ userId: string | null;
35
+ deviceId: string | null;
36
+ role: string | null;
37
+ displayName: string | null;
38
+ /** Backward-compatible user object for existing components */
39
+ user: User | null;
40
+ /** Backward-compatible loading alias */
41
+ loading: boolean;
42
+ }
43
+
44
+ interface AuthContextType extends AuthState {
45
+ register: (displayName: string) => Promise<void>;
46
+ login: () => Promise<void>;
47
+ logout: () => Promise<void>;
48
+ }
49
+
50
+ const AuthContext = createContext<AuthContextType | null>(null);
51
+
52
+ export function useAuth(): AuthContextType {
53
+ const ctx = useContext(AuthContext);
54
+ if (!ctx) throw new Error("useAuth must be used within AuthProvider");
55
+ return ctx;
56
+ }
57
+
58
+ interface FinishResponse {
59
+ user_id: string;
60
+ device_id: string;
61
+ role?: string;
62
+ display_name?: string;
63
+ }
64
+
65
+ function buildState(
66
+ partial: Omit<AuthState, "user" | "loading">,
67
+ ): AuthState {
68
+ const user = partial.isAuthenticated && partial.userId
69
+ ? { id: partial.userId, role: partial.role ?? "user", scopes: [] }
70
+ : null;
71
+ return { ...partial, user, loading: partial.isLoading };
72
+ }
73
+
74
+ export function AuthProvider({ children }: { children: ReactNode }) {
75
+ const [state, setState] = useState<AuthState>(
76
+ buildState({
77
+ isAuthenticated: false,
78
+ isLoading: true,
79
+ userId: null,
80
+ deviceId: null,
81
+ role: null,
82
+ displayName: null,
83
+ }),
84
+ );
85
+ const navigate = useNavigate();
86
+
87
+ // Check existing device identity on mount
88
+ useEffect(() => {
89
+ (async () => {
90
+ try {
91
+ const identity = await getDeviceIdentity();
92
+ if (identity) {
93
+ setState(
94
+ buildState({
95
+ isAuthenticated: true,
96
+ isLoading: false,
97
+ userId: identity.userId,
98
+ deviceId: identity.deviceId,
99
+ role: null,
100
+ displayName: null,
101
+ }),
102
+ );
103
+ } else {
104
+ setState((s) => buildState({ ...s, isLoading: false }));
105
+ }
106
+ } catch {
107
+ setState((s) => buildState({ ...s, isLoading: false }));
108
+ }
109
+ })();
110
+ }, []);
111
+
112
+ const register = useCallback(
113
+ async (displayName: string) => {
114
+ const keyMaterial = await ensureDeviceKeyMaterial();
115
+
116
+ const startRes = await publicFetch<{
117
+ options: Record<string, unknown>;
118
+ flow_id: string;
119
+ }>("/auth/passkey/register/start", {
120
+ method: "POST",
121
+ body: JSON.stringify({ display_name: displayName }),
122
+ });
123
+ if (!startRes.ok) throw new Error("Registration start failed");
124
+
125
+ const createOptions = toCreateOptions(
126
+ startRes.data.options as unknown as Parameters<typeof toCreateOptions>[0],
127
+ );
128
+ const credential = (await navigator.credentials.create(
129
+ createOptions,
130
+ )) as PublicKeyCredential | null;
131
+ if (!credential) throw new Error("Credential creation cancelled");
132
+
133
+ const finishRes = await publicFetch<FinishResponse>(
134
+ "/auth/passkey/register/finish",
135
+ {
136
+ method: "POST",
137
+ body: JSON.stringify({
138
+ flow_id: startRes.data.flow_id,
139
+ credential: serializeCreateResponse(credential),
140
+ device_public_key_jwk: keyMaterial.publicJwk,
141
+ device_label: navigator.userAgent.slice(0, 64),
142
+ }),
143
+ },
144
+ );
145
+ if (!finishRes.ok) throw new Error("Registration finish failed");
146
+
147
+ await setDeviceIdentity(
148
+ finishRes.data.device_id,
149
+ finishRes.data.user_id,
150
+ );
151
+ setState(
152
+ buildState({
153
+ isAuthenticated: true,
154
+ isLoading: false,
155
+ userId: finishRes.data.user_id,
156
+ deviceId: finishRes.data.device_id,
157
+ role: finishRes.data.role ?? "user",
158
+ displayName: finishRes.data.display_name ?? displayName,
159
+ }),
160
+ );
161
+ navigate("/dashboard");
162
+ },
163
+ [navigate],
164
+ );
165
+
166
+ const login = useCallback(async () => {
167
+ const keyMaterial = await ensureDeviceKeyMaterial();
168
+
169
+ const startRes = await publicFetch<{
170
+ options: Record<string, unknown>;
171
+ flow_id: string;
172
+ }>("/auth/passkey/login/start", {
173
+ method: "POST",
174
+ body: JSON.stringify({}),
175
+ });
176
+ if (!startRes.ok) throw new Error("Login start failed");
177
+
178
+ const getOptions = toGetOptions(
179
+ startRes.data.options as unknown as Parameters<typeof toGetOptions>[0],
180
+ );
181
+ const credential = (await navigator.credentials.get(
182
+ getOptions,
183
+ )) as PublicKeyCredential | null;
184
+ if (!credential) throw new Error("Login cancelled");
185
+
186
+ const finishRes = await publicFetch<FinishResponse>(
187
+ "/auth/passkey/login/finish",
188
+ {
189
+ method: "POST",
190
+ body: JSON.stringify({
191
+ flow_id: startRes.data.flow_id,
192
+ credential: serializeGetResponse(credential),
193
+ device_public_key_jwk: keyMaterial.publicJwk,
194
+ device_label: navigator.userAgent.slice(0, 64),
195
+ }),
196
+ },
197
+ );
198
+ if (!finishRes.ok) throw new Error("Login finish failed");
199
+
200
+ await setDeviceIdentity(
201
+ finishRes.data.device_id,
202
+ finishRes.data.user_id,
203
+ );
204
+ setState(
205
+ buildState({
206
+ isAuthenticated: true,
207
+ isLoading: false,
208
+ userId: finishRes.data.user_id,
209
+ deviceId: finishRes.data.device_id,
210
+ role: finishRes.data.role ?? "user",
211
+ displayName: finishRes.data.display_name ?? null,
212
+ }),
213
+ );
214
+ navigate("/dashboard");
215
+ }, [navigate]);
216
+
217
+ const logout = useCallback(async () => {
218
+ clearCachedToken();
219
+ await clearDeviceKeyMaterial();
220
+ setState(
221
+ buildState({
222
+ isAuthenticated: false,
223
+ isLoading: false,
224
+ userId: null,
225
+ deviceId: null,
226
+ role: null,
227
+ displayName: null,
228
+ }),
229
+ );
230
+ navigate("/");
231
+ }, [navigate]);
232
+
233
+ return (
234
+ <AuthContext.Provider value={{ ...state, register, login, logout }}>
235
+ {children}
236
+ </AuthContext.Provider>
237
+ );
238
+ }
@@ -0,0 +1,22 @@
1
+ import { describe, it, expect, beforeEach } from "vitest";
2
+ import { isTokenValid, clearCachedToken, shouldRenewToken } from "../token";
3
+
4
+ describe("token management", () => {
5
+ beforeEach(() => {
6
+ clearCachedToken();
7
+ });
8
+
9
+ it("isTokenValid returns false when no token is cached", () => {
10
+ expect(isTokenValid()).toBe(false);
11
+ });
12
+
13
+ it("shouldRenewToken returns true when no token is cached", () => {
14
+ expect(shouldRenewToken()).toBe(true);
15
+ });
16
+
17
+ it("clearCachedToken resets state", () => {
18
+ clearCachedToken();
19
+ expect(isTokenValid()).toBe(false);
20
+ expect(shouldRenewToken()).toBe(true);
21
+ });
22
+ });
@@ -0,0 +1,44 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { base64urlEncode, base64urlDecode } from "../webauthn";
3
+
4
+ describe("base64url helpers", () => {
5
+ it("encodes an ArrayBuffer to base64url string", () => {
6
+ const buffer = new Uint8Array([72, 101, 108, 108, 111]).buffer;
7
+ const encoded = base64urlEncode(buffer);
8
+ // "Hello" in base64 is "SGVsbG8=", base64url removes padding
9
+ expect(encoded).toBe("SGVsbG8");
10
+ });
11
+
12
+ it("decodes a base64url string to ArrayBuffer", () => {
13
+ const decoded = base64urlDecode("SGVsbG8");
14
+ const bytes = new Uint8Array(decoded);
15
+ expect(Array.from(bytes)).toEqual([72, 101, 108, 108, 111]);
16
+ });
17
+
18
+ it("round-trips correctly", () => {
19
+ const original = new Uint8Array([0, 1, 2, 255, 254, 253, 128, 127]);
20
+ const encoded = base64urlEncode(original.buffer);
21
+ const decoded = new Uint8Array(base64urlDecode(encoded));
22
+ expect(Array.from(decoded)).toEqual(Array.from(original));
23
+ });
24
+
25
+ it("handles empty buffer", () => {
26
+ const empty = new Uint8Array(0).buffer;
27
+ const encoded = base64urlEncode(empty);
28
+ expect(encoded).toBe("");
29
+ const decoded = base64urlDecode("");
30
+ expect(new Uint8Array(decoded).length).toBe(0);
31
+ });
32
+
33
+ it("handles URL-unsafe characters correctly", () => {
34
+ // bytes that produce + and / in standard base64
35
+ const buffer = new Uint8Array([251, 255, 254]).buffer;
36
+ const encoded = base64urlEncode(buffer);
37
+ expect(encoded).not.toContain("+");
38
+ expect(encoded).not.toContain("/");
39
+ expect(encoded).not.toContain("=");
40
+ // Round-trip
41
+ const decoded = new Uint8Array(base64urlDecode(encoded));
42
+ expect(Array.from(decoded)).toEqual([251, 255, 254]);
43
+ });
44
+ });
@@ -0,0 +1,63 @@
1
+ import { getOrMintToken, clearCachedToken } from "./token";
2
+ import { getDeviceIdentity } from "./deviceKey";
3
+
4
+ const API_BASE = import.meta.env.VITE_API_BASE_URL || "/api";
5
+
6
+ export class AuthError extends Error {
7
+ constructor(message: string) {
8
+ super(message);
9
+ this.name = "AuthError";
10
+ }
11
+ }
12
+
13
+ export interface ApiResponse<T = unknown> {
14
+ ok: boolean;
15
+ status: number;
16
+ data: T;
17
+ }
18
+
19
+ export async function apiFetch<T = unknown>(
20
+ path: string,
21
+ options: RequestInit = {},
22
+ auth = true,
23
+ ): Promise<ApiResponse<T>> {
24
+ const url = `${API_BASE}${path}`;
25
+ const headers = new Headers(options.headers);
26
+
27
+ if (!headers.has("Content-Type") && options.body) {
28
+ headers.set("Content-Type", "application/json");
29
+ }
30
+
31
+ if (auth) {
32
+ const identity = await getDeviceIdentity();
33
+ if (!identity) {
34
+ throw new AuthError("Not authenticated");
35
+ }
36
+ const token = await getOrMintToken("http");
37
+ headers.set("Authorization", `Bearer ${token}`);
38
+ }
39
+
40
+ const response = await fetch(url, { ...options, headers });
41
+
42
+ if (response.status === 401) {
43
+ clearCachedToken();
44
+ throw new AuthError("Unauthorized");
45
+ }
46
+
47
+ let data: T;
48
+ try {
49
+ data = (await response.json()) as T;
50
+ } catch {
51
+ // Non-JSON responses (e.g. 204 No Content) return null
52
+ data = null as T;
53
+ }
54
+ return { ok: response.ok, status: response.status, data };
55
+ }
56
+
57
+ // Unauthenticated fetch for login/register endpoints
58
+ export async function publicFetch<T = unknown>(
59
+ path: string,
60
+ options: RequestInit = {},
61
+ ): Promise<ApiResponse<T>> {
62
+ return apiFetch<T>(path, options, false);
63
+ }
@@ -0,0 +1,71 @@
1
+ import { get, set, del } from "idb-keyval";
2
+
3
+ const DB_PRIVATE_KEY = "h4ckath0n_device_private_key";
4
+ const DB_PUBLIC_JWK = "h4ckath0n_device_public_jwk";
5
+ const DB_DEVICE_ID = "h4ckath0n_device_id";
6
+ const DB_USER_ID = "h4ckath0n_user_id";
7
+
8
+ export interface DeviceKeyMaterial {
9
+ privateKey: CryptoKey;
10
+ publicJwk: JsonWebKey;
11
+ }
12
+
13
+ export interface DeviceIdentity {
14
+ deviceId: string;
15
+ userId: string;
16
+ }
17
+
18
+ export async function ensureDeviceKeyMaterial(): Promise<DeviceKeyMaterial> {
19
+ const existing = await loadDeviceKeyMaterial();
20
+ if (existing) return existing;
21
+ return generateDeviceKeyMaterial();
22
+ }
23
+
24
+ async function loadDeviceKeyMaterial(): Promise<DeviceKeyMaterial | null> {
25
+ const privateKey = await get<CryptoKey>(DB_PRIVATE_KEY);
26
+ const publicJwk = await get<JsonWebKey>(DB_PUBLIC_JWK);
27
+ if (privateKey && publicJwk) return { privateKey, publicJwk };
28
+ return null;
29
+ }
30
+
31
+ async function generateDeviceKeyMaterial(): Promise<DeviceKeyMaterial> {
32
+ const keyPair = await crypto.subtle.generateKey(
33
+ { name: "ECDSA", namedCurve: "P-256" },
34
+ false, // non-extractable private key
35
+ ["sign", "verify"],
36
+ );
37
+ const publicJwk = await crypto.subtle.exportKey("jwk", keyPair.publicKey);
38
+ await set(DB_PRIVATE_KEY, keyPair.privateKey);
39
+ await set(DB_PUBLIC_JWK, publicJwk);
40
+ return { privateKey: keyPair.privateKey, publicJwk };
41
+ }
42
+
43
+ export async function getPrivateKey(): Promise<CryptoKey | null> {
44
+ return (await get<CryptoKey>(DB_PRIVATE_KEY)) ?? null;
45
+ }
46
+
47
+ export async function getPublicJwk(): Promise<JsonWebKey | null> {
48
+ return (await get<JsonWebKey>(DB_PUBLIC_JWK)) ?? null;
49
+ }
50
+
51
+ export async function getDeviceIdentity(): Promise<DeviceIdentity | null> {
52
+ const deviceId = await get<string>(DB_DEVICE_ID);
53
+ const userId = await get<string>(DB_USER_ID);
54
+ if (deviceId && userId) return { deviceId, userId };
55
+ return null;
56
+ }
57
+
58
+ export async function setDeviceIdentity(
59
+ deviceId: string,
60
+ userId: string,
61
+ ): Promise<void> {
62
+ await set(DB_DEVICE_ID, deviceId);
63
+ await set(DB_USER_ID, userId);
64
+ }
65
+
66
+ export async function clearDeviceKeyMaterial(): Promise<void> {
67
+ await del(DB_PRIVATE_KEY);
68
+ await del(DB_PUBLIC_JWK);
69
+ await del(DB_DEVICE_ID);
70
+ await del(DB_USER_ID);
71
+ }
@@ -0,0 +1,26 @@
1
+ export { useAuth, AuthProvider } from "./AuthContext";
2
+ export {
3
+ ensureDeviceKeyMaterial,
4
+ getDeviceIdentity,
5
+ setDeviceIdentity,
6
+ clearDeviceKeyMaterial,
7
+ getPublicJwk,
8
+ getPrivateKey,
9
+ } from "./deviceKey";
10
+ export {
11
+ getOrMintToken,
12
+ mintToken,
13
+ clearCachedToken,
14
+ isTokenValid,
15
+ shouldRenewToken,
16
+ } from "./token";
17
+ export { apiFetch, publicFetch, AuthError, type ApiResponse } from "./api";
18
+ export {
19
+ base64urlEncode,
20
+ base64urlDecode,
21
+ toCreateOptions,
22
+ toGetOptions,
23
+ serializeCreateResponse,
24
+ serializeGetResponse,
25
+ } from "./webauthn";
26
+ export { createAuthWebSocket, sendReauth } from "./ws";
@@ -0,0 +1,59 @@
1
+ import { SignJWT } from "jose";
2
+ import { getPrivateKey, getDeviceIdentity } from "./deviceKey";
3
+
4
+ let cachedToken: string | null = null;
5
+ let cachedExp: number = 0;
6
+
7
+ const TOKEN_LIFETIME = 900; // 15 minutes in seconds
8
+ const RENEWAL_BUFFER = 60; // renew 60s before expiry
9
+
10
+ export function isTokenValid(): boolean {
11
+ if (!cachedToken) return false;
12
+ const now = Math.floor(Date.now() / 1000);
13
+ return now < cachedExp - RENEWAL_BUFFER;
14
+ }
15
+
16
+ export async function getOrMintToken(aud?: string): Promise<string> {
17
+ if (isTokenValid() && cachedToken) return cachedToken;
18
+ return mintToken(aud);
19
+ }
20
+
21
+ export async function mintToken(aud?: string): Promise<string> {
22
+ const privateKey = await getPrivateKey();
23
+ const identity = await getDeviceIdentity();
24
+ if (!privateKey || !identity) {
25
+ throw new Error("No device key material or identity found");
26
+ }
27
+
28
+ const now = Math.floor(Date.now() / 1000);
29
+ const exp = now + TOKEN_LIFETIME;
30
+
31
+ let builder = new SignJWT({ sub: identity.userId })
32
+ .setProtectedHeader({
33
+ alg: "ES256",
34
+ typ: "JWT",
35
+ kid: identity.deviceId,
36
+ })
37
+ .setIssuedAt(now)
38
+ .setExpirationTime(exp);
39
+
40
+ if (aud) {
41
+ builder = builder.setAudience(aud);
42
+ }
43
+
44
+ const token = await builder.sign(privateKey);
45
+ cachedToken = token;
46
+ cachedExp = exp;
47
+ return token;
48
+ }
49
+
50
+ export function clearCachedToken(): void {
51
+ cachedToken = null;
52
+ cachedExp = 0;
53
+ }
54
+
55
+ export function shouldRenewToken(): boolean {
56
+ if (!cachedToken) return true;
57
+ const now = Math.floor(Date.now() / 1000);
58
+ return now >= cachedExp - RENEWAL_BUFFER;
59
+ }