feat: add EncryptID auth + consistent rApp header bar
- Add @encryptid/sdk (vendored) for WebAuthn passkey authentication - Add zustand auth store matching rMaps/rWork pattern - Create AuthButton component (sign in / register / sign out) - Create HeaderBar client component: AppSwitcher + logo + nav + auth + cart - Remove custom SpaceSwitcher (not part of standard rApp header) - Fix hero: "Noticed" and "rMerch" now use same gradient color Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
c31e440edf
commit
25482f9085
|
|
@ -4,6 +4,7 @@ RUN apk add --no-cache libc6-compat
|
|||
WORKDIR /app
|
||||
|
||||
COPY package.json pnpm-lock.yaml* ./
|
||||
COPY vendor/ ./vendor/
|
||||
RUN corepack enable pnpm && pnpm i --frozen-lockfile || npm install
|
||||
|
||||
# Stage 2: Builder
|
||||
|
|
|
|||
|
|
@ -1,12 +1,10 @@
|
|||
import type { Metadata } from "next";
|
||||
import { GeistSans } from "geist/font";
|
||||
import { cookies } from "next/headers";
|
||||
import Link from "next/link";
|
||||
import "./globals.css";
|
||||
import type { SpaceConfig } from "@/lib/spaces";
|
||||
import { themeToCSS } from "@/lib/spaces";
|
||||
import { AppSwitcher } from "@/components/AppSwitcher";
|
||||
import { SpaceSwitcher } from "@/components/SpaceSwitcher";
|
||||
import { HeaderBar } from "@/components/HeaderBar";
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api";
|
||||
|
||||
|
|
@ -80,77 +78,7 @@ export default async function RootLayout({
|
|||
</head>
|
||||
<body className={GeistSans.className}>
|
||||
<div className="min-h-screen flex flex-col">
|
||||
{/* ── Sticky Nav ──────────────────────────────────── */}
|
||||
<header className="border-b sticky top-0 z-50 bg-background/90 backdrop-blur-sm">
|
||||
<div className="max-w-6xl mx-auto px-4 py-2.5 flex items-center justify-between gap-3">
|
||||
{/* Left: App switcher + Spaces + Logo */}
|
||||
<div className="flex items-center gap-3">
|
||||
<AppSwitcher current="swag" />
|
||||
<SpaceSwitcher />
|
||||
<div className="w-px h-5 bg-white/10 hidden sm:block" />
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-2 font-bold text-lg"
|
||||
>
|
||||
{logoUrl ? (
|
||||
<img src={logoUrl} alt="" className="h-7 w-7 rounded" />
|
||||
) : (
|
||||
<div className="h-7 w-7 bg-gradient-to-br from-cyan-300 to-amber-300 rounded-lg flex items-center justify-center text-slate-900 text-[10px] font-black leading-none">
|
||||
rSw
|
||||
</div>
|
||||
)}
|
||||
<span className="hidden sm:inline">
|
||||
<span className="text-primary">r</span>
|
||||
{name === "rSwag" ? "Swag" : name}
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Center: Nav links */}
|
||||
<nav className="flex items-center gap-1 sm:gap-2">
|
||||
<Link
|
||||
href="/design"
|
||||
className="text-sm text-slate-300 hover:text-white transition-colors px-2.5 py-1.5 rounded-md hover:bg-white/[0.06] hidden sm:inline-flex"
|
||||
>
|
||||
Design
|
||||
</Link>
|
||||
<Link
|
||||
href="/upload"
|
||||
className="text-sm text-slate-300 hover:text-white transition-colors px-2.5 py-1.5 rounded-md hover:bg-white/[0.06]"
|
||||
>
|
||||
Upload
|
||||
</Link>
|
||||
<Link
|
||||
href="/products"
|
||||
className="text-sm px-4 py-1.5 bg-primary text-primary-foreground rounded-lg font-medium hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Shop
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
{/* Right: Cart */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href="/cart"
|
||||
className="text-slate-300 hover:text-white transition-colors p-2 rounded-md hover:bg-white/[0.06]"
|
||||
>
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 00-3 3h15.75m-12.75-3h11.218c1.121-2.3 2.1-4.684 2.924-7.138a60.114 60.114 0 00-16.536-1.84M7.5 14.25L5.106 5.272M6 20.25a.75.75 0 11-1.5 0 .75.75 0 011.5 0zm12.75 0a.75.75 0 11-1.5 0 .75.75 0 011.5 0z"
|
||||
/>
|
||||
</svg>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<HeaderBar name={name} logoUrl={logoUrl ?? null} />
|
||||
|
||||
{/* ── Main Content ────────────────────────────────── */}
|
||||
<main className="flex-1">{children}</main>
|
||||
|
|
|
|||
|
|
@ -81,13 +81,13 @@ export default async function HomePage() {
|
|||
) : (
|
||||
<>
|
||||
Get Your Community{" "}
|
||||
<span className="text-transparent bg-clip-text bg-gradient-to-r from-primary to-cyan-300">
|
||||
<span className="text-transparent bg-clip-text bg-gradient-to-r from-primary to-secondary">
|
||||
Noticed
|
||||
</span>
|
||||
<br />
|
||||
with{" "}
|
||||
<span className="text-white">(you)</span>
|
||||
<span className="text-transparent bg-clip-text bg-gradient-to-r from-secondary to-amber-300">
|
||||
<span className="text-transparent bg-clip-text bg-gradient-to-r from-primary to-secondary">
|
||||
rMerch
|
||||
</span>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,103 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
|
||||
export function AuthButton() {
|
||||
const { isAuthenticated, username, did, loading, login, register, logout } = useAuthStore();
|
||||
const [showRegister, setShowRegister] = useState(false);
|
||||
const [regUsername, setRegUsername] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
if (isAuthenticated) {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-sm">
|
||||
<span className="text-white/60">Signed in as </span>
|
||||
<span className="text-primary font-medium">{username || did?.slice(0, 16) + '...'}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="text-xs text-white/40 hover:text-white/60 transition-colors"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (showRegister) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={regUsername}
|
||||
onChange={(e) => setRegUsername(e.target.value)}
|
||||
placeholder="Choose a username"
|
||||
className="text-sm py-1 px-2 w-36 rounded-md bg-white/10 border border-white/10 text-white placeholder:text-white/30 focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
maxLength={20}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && regUsername.trim()) {
|
||||
setError('');
|
||||
register(regUsername.trim()).catch((err: Error) => {
|
||||
setError(err.message || 'Registration failed');
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (!regUsername.trim()) return;
|
||||
setError('');
|
||||
try {
|
||||
await register(regUsername.trim());
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : 'Registration failed');
|
||||
}
|
||||
}}
|
||||
disabled={loading || !regUsername.trim()}
|
||||
className="text-sm py-1 px-3 bg-primary text-primary-foreground rounded-md font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading ? '...' : 'Register'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowRegister(false)}
|
||||
className="text-xs text-white/40 hover:text-white/60"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
{error && <span className="text-xs text-red-400">{error}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={async () => {
|
||||
setError('');
|
||||
try {
|
||||
await login();
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof DOMException && (e.name === 'NotAllowedError' || e.name === 'SecurityError' || e.name === 'AbortError')) {
|
||||
setShowRegister(true);
|
||||
} else {
|
||||
setError(e instanceof Error ? e.message : 'Sign in failed');
|
||||
}
|
||||
}
|
||||
}}
|
||||
disabled={loading}
|
||||
className="text-sm text-white/60 hover:text-primary transition-colors flex items-center gap-1.5"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} className="w-4 h-4">
|
||||
<circle cx={12} cy={10} r={3} />
|
||||
<path d="M12 13v8" />
|
||||
<path d="M9 18h6" />
|
||||
<circle cx={12} cy={10} r={7} />
|
||||
</svg>
|
||||
{loading ? 'Signing in...' : 'Sign in with Passkey'}
|
||||
</button>
|
||||
{error && <span className="text-xs text-red-400">{error}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { AppSwitcher } from '@/components/AppSwitcher';
|
||||
import { AuthButton } from '@/components/AuthButton';
|
||||
|
||||
interface HeaderBarProps {
|
||||
name: string;
|
||||
logoUrl: string | null;
|
||||
}
|
||||
|
||||
export function HeaderBar({ name, logoUrl }: HeaderBarProps) {
|
||||
return (
|
||||
<header className="border-b sticky top-0 z-50 bg-background/90 backdrop-blur-sm">
|
||||
<div className="max-w-6xl mx-auto px-4 py-2.5 flex items-center justify-between gap-3">
|
||||
{/* Left: App switcher + Logo */}
|
||||
<div className="flex items-center gap-3">
|
||||
<AppSwitcher current="swag" />
|
||||
<div className="w-px h-5 bg-white/10 hidden sm:block" />
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-2 font-bold text-lg"
|
||||
>
|
||||
{logoUrl ? (
|
||||
<img src={logoUrl} alt="" className="h-7 w-7 rounded" />
|
||||
) : (
|
||||
<div className="h-7 w-7 bg-gradient-to-br from-cyan-300 to-amber-300 rounded-lg flex items-center justify-center text-slate-900 text-[10px] font-black leading-none">
|
||||
rSw
|
||||
</div>
|
||||
)}
|
||||
<span className="hidden sm:inline">
|
||||
<span className="text-primary">r</span>
|
||||
{name === 'rSwag' ? 'Swag' : name}
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Center: Nav links */}
|
||||
<nav className="flex items-center gap-1 sm:gap-2">
|
||||
<Link
|
||||
href="/design"
|
||||
className="text-sm text-slate-300 hover:text-white transition-colors px-2.5 py-1.5 rounded-md hover:bg-white/[0.06] hidden sm:inline-flex"
|
||||
>
|
||||
Design
|
||||
</Link>
|
||||
<Link
|
||||
href="/upload"
|
||||
className="text-sm text-slate-300 hover:text-white transition-colors px-2.5 py-1.5 rounded-md hover:bg-white/[0.06]"
|
||||
>
|
||||
Upload
|
||||
</Link>
|
||||
<Link
|
||||
href="/products"
|
||||
className="text-sm px-4 py-1.5 bg-primary text-primary-foreground rounded-lg font-medium hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Shop
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
{/* Right: Auth + Cart */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="hidden sm:block">
|
||||
<AuthButton />
|
||||
</div>
|
||||
<Link
|
||||
href="/cart"
|
||||
className="text-slate-300 hover:text-white transition-colors p-2 rounded-md hover:bg-white/[0.06]"
|
||||
>
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 00-3 3h15.75m-12.75-3h11.218c1.121-2.3 2.1-4.684 2.924-7.138a60.114 60.114 0 00-16.536-1.84M7.5 14.25L5.106 5.272M6 20.25a.75.75 0 11-1.5 0 .75.75 0 011.5 0zm12.75 0a.75.75 0 11-1.5 0 .75.75 0 011.5 0z"
|
||||
/>
|
||||
</svg>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -9,10 +9,7 @@
|
|||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"geist": "^1.3.0",
|
||||
"next": "^15.1.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"@encryptid/sdk": "file:./vendor/@encryptid/sdk",
|
||||
"@radix-ui/react-dialog": "^1.1.4",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.4",
|
||||
"@radix-ui/react-label": "^2.1.1",
|
||||
|
|
@ -24,9 +21,14 @@
|
|||
"@radix-ui/react-toast": "^1.2.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"geist": "^1.3.0",
|
||||
"lucide-react": "^0.469.0",
|
||||
"next": "^15.1.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwindcss-animate": "^1.0.7"
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.0",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,93 @@
|
|||
/**
|
||||
* EncryptID Auth Store for rSwag
|
||||
*
|
||||
* Optional authentication via WebAuthn passkeys.
|
||||
* Zustand with localStorage persistence, delegates WebAuthn ceremony to @encryptid/sdk.
|
||||
*/
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { EncryptIDClient } from '@encryptid/sdk/client';
|
||||
|
||||
const ENCRYPTID_SERVER = process.env.NEXT_PUBLIC_ENCRYPTID_SERVER_URL || 'https://auth.ridentity.online';
|
||||
const client = new EncryptIDClient(ENCRYPTID_SERVER);
|
||||
|
||||
interface AuthState {
|
||||
isAuthenticated: boolean;
|
||||
token: string | null;
|
||||
did: string | null;
|
||||
username: string | null;
|
||||
loading: boolean;
|
||||
|
||||
login: () => Promise<void>;
|
||||
register: (username: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
isAuthenticated: false,
|
||||
token: null,
|
||||
did: null,
|
||||
username: null,
|
||||
loading: false,
|
||||
|
||||
login: async () => {
|
||||
set({ loading: true });
|
||||
try {
|
||||
const result = await client.authenticate();
|
||||
document.cookie = `encryptid_token=${result.token};path=/;max-age=900;SameSite=Lax`;
|
||||
set({
|
||||
isAuthenticated: true,
|
||||
token: result.token,
|
||||
did: result.did,
|
||||
username: result.username,
|
||||
loading: false,
|
||||
});
|
||||
} catch (error) {
|
||||
set({ loading: false });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
register: async (username: string) => {
|
||||
set({ loading: true });
|
||||
try {
|
||||
const result = await client.register(username);
|
||||
document.cookie = `encryptid_token=${result.token};path=/;max-age=900;SameSite=Lax`;
|
||||
set({
|
||||
isAuthenticated: true,
|
||||
token: result.token,
|
||||
did: result.did,
|
||||
username,
|
||||
loading: false,
|
||||
});
|
||||
} catch (error) {
|
||||
set({ loading: false });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
logout: () => {
|
||||
document.cookie = 'encryptid_token=;path=/;max-age=0;SameSite=Lax';
|
||||
set({
|
||||
isAuthenticated: false,
|
||||
token: null,
|
||||
did: null,
|
||||
username: null,
|
||||
loading: false,
|
||||
});
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'rswag-auth',
|
||||
partialize: (state) => ({
|
||||
isAuthenticated: state.isAuthenticated,
|
||||
token: state.token,
|
||||
did: state.did,
|
||||
username: state.username,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
/**
|
||||
* EncryptID Browser Bundle
|
||||
*
|
||||
* IIFE entry point that provides window.EncryptID for vanilla JS apps.
|
||||
* Build: bun build ./src/browser.ts --outfile dist/encryptid.browser.js --target browser
|
||||
*
|
||||
* Usage:
|
||||
* <script src="encryptid.browser.js"></script>
|
||||
* <script>
|
||||
* EncryptID.renderAuthButton('auth-container');
|
||||
* // or
|
||||
* const result = await EncryptID.authenticate();
|
||||
* </script>
|
||||
*/
|
||||
import { EncryptIDClient } from './client/api-client.js';
|
||||
import { detectCapabilities } from './client/webauthn.js';
|
||||
import { AuthLevel } from './client/session.js';
|
||||
import './ui/login-button.js';
|
||||
import './ui/guardian-setup.js';
|
||||
interface StoredUser {
|
||||
did: string;
|
||||
username: string;
|
||||
token: string;
|
||||
}
|
||||
/**
|
||||
* Authenticate with an existing passkey
|
||||
*/
|
||||
declare function authenticate(): Promise<StoredUser>;
|
||||
/**
|
||||
* Register a new passkey
|
||||
*/
|
||||
declare function register(username: string, displayName?: string): Promise<StoredUser>;
|
||||
/**
|
||||
* Log out — clear stored auth state
|
||||
*/
|
||||
declare function logout(): void;
|
||||
/**
|
||||
* Check if user is currently authenticated
|
||||
*/
|
||||
declare function isAuthenticated(): boolean;
|
||||
/**
|
||||
* Get stored user info
|
||||
*/
|
||||
declare function getUser(): StoredUser | null;
|
||||
/**
|
||||
* Get the stored token
|
||||
*/
|
||||
declare function getToken(): string | null;
|
||||
/**
|
||||
* Require authentication — redirects to home with login hint if not authenticated
|
||||
*/
|
||||
declare function requireAuth(redirectUrl?: string): boolean;
|
||||
/**
|
||||
* Set a recovery email for the authenticated user
|
||||
*/
|
||||
declare function setRecoveryEmail(email: string): Promise<void>;
|
||||
/**
|
||||
* Request account recovery via email
|
||||
*/
|
||||
declare function requestRecovery(email: string): Promise<void>;
|
||||
/**
|
||||
* Render an auth button into a container element
|
||||
*/
|
||||
declare function renderAuthButton(containerId: string): void;
|
||||
/**
|
||||
* Verify the stored token is still valid, refresh if needed
|
||||
*/
|
||||
declare function verifySession(): Promise<boolean>;
|
||||
declare const EncryptID: {
|
||||
client: EncryptIDClient;
|
||||
authenticate: typeof authenticate;
|
||||
register: typeof register;
|
||||
logout: typeof logout;
|
||||
isAuthenticated: typeof isAuthenticated;
|
||||
getUser: typeof getUser;
|
||||
getToken: typeof getToken;
|
||||
requireAuth: typeof requireAuth;
|
||||
setRecoveryEmail: typeof setRecoveryEmail;
|
||||
requestRecovery: typeof requestRecovery;
|
||||
renderAuthButton: typeof renderAuthButton;
|
||||
verifySession: typeof verifySession;
|
||||
detectCapabilities: typeof detectCapabilities;
|
||||
AuthLevel: typeof AuthLevel;
|
||||
VERSION: string;
|
||||
};
|
||||
export default EncryptID;
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
/**
|
||||
* EncryptID API Client
|
||||
*
|
||||
* HTTP client for communicating with the EncryptID server.
|
||||
* Handles registration, authentication, session management.
|
||||
*/
|
||||
import type { RegistrationStartResponse, RegistrationCompleteResponse, AuthStartResponse, AuthCompleteResponse, SessionVerifyResponse, EmailRecoverySetResponse, EmailRecoveryRequestResponse, EmailRecoveryVerifyResponse } from '../types/index.js';
|
||||
export declare class EncryptIDClient {
|
||||
private serverUrl;
|
||||
constructor(serverUrl?: string);
|
||||
/**
|
||||
* Start registration — get challenge and options from server
|
||||
*/
|
||||
registerStart(username: string, displayName?: string): Promise<RegistrationStartResponse>;
|
||||
/**
|
||||
* Complete registration — send credential to server
|
||||
*/
|
||||
registerComplete(challenge: string, credential: PublicKeyCredential, userId: string, username: string): Promise<RegistrationCompleteResponse>;
|
||||
/**
|
||||
* Start authentication — get challenge from server
|
||||
*/
|
||||
authStart(credentialId?: string): Promise<AuthStartResponse>;
|
||||
/**
|
||||
* Complete authentication — send assertion to server
|
||||
*/
|
||||
authComplete(challenge: string, credential: PublicKeyCredential): Promise<AuthCompleteResponse>;
|
||||
/**
|
||||
* Verify a session token
|
||||
*/
|
||||
verifySession(token: string): Promise<SessionVerifyResponse>;
|
||||
/**
|
||||
* Refresh a session token
|
||||
*/
|
||||
refreshToken(token: string): Promise<{
|
||||
token: string;
|
||||
}>;
|
||||
/**
|
||||
* List user's credentials
|
||||
*/
|
||||
listCredentials(token: string): Promise<{
|
||||
credentials: any[];
|
||||
}>;
|
||||
/**
|
||||
* Set recovery email for the authenticated user
|
||||
*/
|
||||
setRecoveryEmail(token: string, email: string): Promise<EmailRecoverySetResponse>;
|
||||
/**
|
||||
* Request account recovery via email
|
||||
*/
|
||||
requestEmailRecovery(email: string): Promise<EmailRecoveryRequestResponse>;
|
||||
/**
|
||||
* Verify a recovery token and get a temporary session
|
||||
*/
|
||||
verifyRecoveryToken(recoveryToken: string): Promise<EmailRecoveryVerifyResponse>;
|
||||
/**
|
||||
* Full registration flow: server challenge → WebAuthn create → server verify
|
||||
*/
|
||||
register(username: string, displayName?: string, config?: {
|
||||
rpId?: string;
|
||||
}): Promise<RegistrationCompleteResponse>;
|
||||
/**
|
||||
* Full authentication flow: server challenge → WebAuthn get → server verify
|
||||
*/
|
||||
authenticate(credentialId?: string, config?: {
|
||||
rpId?: string;
|
||||
}): Promise<AuthCompleteResponse & {
|
||||
prfOutput?: ArrayBuffer;
|
||||
}>;
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
/**
|
||||
* @encryptid/sdk/client — Browser-safe client module
|
||||
*/
|
||||
export { registerPasskey, authenticatePasskey, startConditionalUI, abortConditionalUI, isConditionalMediationAvailable, detectCapabilities, bufferToBase64url, base64urlToBuffer, generateChallenge, } from './webauthn.js';
|
||||
export type { EncryptIDCredential, AuthenticationResult, EncryptIDConfig, WebAuthnCapabilities } from './webauthn.js';
|
||||
export { EncryptIDKeyManager, getKeyManager, resetKeyManager, encryptData, decryptData, decryptDataAsString, signData, verifySignature, wrapKeyForRecipient, unwrapSharedKey, } from './key-derivation.js';
|
||||
export type { DerivedKeys, EncryptedData, SignedData } from './key-derivation.js';
|
||||
export { SessionManager, getSessionManager, AuthLevel, OPERATION_PERMISSIONS, } from './session.js';
|
||||
export type { EncryptIDClaims, SessionState, OperationPermission } from './session.js';
|
||||
export { RecoveryManager, getRecoveryManager, GuardianType, getGuardianTypeInfo, } from './recovery.js';
|
||||
export type { Guardian, RecoveryConfig, RecoveryRequest } from './recovery.js';
|
||||
export { EncryptIDClient } from './api-client.js';
|
||||
export { shareTokenAcrossModules, clearTokenAcrossModules, initTokenRelayListener, getStoredToken, requestTokenFromDomain, MODULE_DOMAINS, } from './token-relay.js';
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
import {
|
||||
EncryptIDKeyManager,
|
||||
OPERATION_PERMISSIONS,
|
||||
RecoveryManager,
|
||||
SessionManager,
|
||||
decryptData,
|
||||
decryptDataAsString,
|
||||
encryptData,
|
||||
getGuardianTypeInfo,
|
||||
getKeyManager,
|
||||
getRecoveryManager,
|
||||
getSessionManager,
|
||||
resetKeyManager,
|
||||
signData,
|
||||
unwrapSharedKey,
|
||||
verifySignature,
|
||||
wrapKeyForRecipient
|
||||
} from "../index-24r9wkfe.js";
|
||||
import {
|
||||
EncryptIDClient
|
||||
} from "../index-7egxprg9.js";
|
||||
import {
|
||||
abortConditionalUI,
|
||||
authenticatePasskey,
|
||||
base64urlToBuffer,
|
||||
bufferToBase64url,
|
||||
detectCapabilities,
|
||||
generateChallenge,
|
||||
isConditionalMediationAvailable,
|
||||
registerPasskey,
|
||||
startConditionalUI
|
||||
} from "../index-2cp5044h.js";
|
||||
import {
|
||||
AuthLevel,
|
||||
GuardianType
|
||||
} from "../index-5c1t4ftn.js";
|
||||
export {
|
||||
wrapKeyForRecipient,
|
||||
verifySignature,
|
||||
unwrapSharedKey,
|
||||
startConditionalUI,
|
||||
signData,
|
||||
resetKeyManager,
|
||||
registerPasskey,
|
||||
isConditionalMediationAvailable,
|
||||
getSessionManager,
|
||||
getRecoveryManager,
|
||||
getKeyManager,
|
||||
getGuardianTypeInfo,
|
||||
generateChallenge,
|
||||
encryptData,
|
||||
detectCapabilities,
|
||||
decryptDataAsString,
|
||||
decryptData,
|
||||
bufferToBase64url,
|
||||
base64urlToBuffer,
|
||||
authenticatePasskey,
|
||||
abortConditionalUI,
|
||||
SessionManager,
|
||||
RecoveryManager,
|
||||
OPERATION_PERMISSIONS,
|
||||
GuardianType,
|
||||
EncryptIDKeyManager,
|
||||
EncryptIDClient,
|
||||
AuthLevel
|
||||
};
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
/**
|
||||
* EncryptID Key Derivation Module
|
||||
*
|
||||
* Derives application-specific cryptographic keys from WebAuthn PRF output
|
||||
* or passphrase fallback. Layer 2 of the EncryptID architecture.
|
||||
*/
|
||||
import type { DerivedKeys, EncryptedData, SignedData } from '../types/index.js';
|
||||
export type { DerivedKeys, EncryptedData, SignedData };
|
||||
export declare class EncryptIDKeyManager {
|
||||
private masterKey;
|
||||
private derivedKeys;
|
||||
private fromPRF;
|
||||
initFromPRF(prfOutput: ArrayBuffer): Promise<void>;
|
||||
initFromPassphrase(passphrase: string, salt: Uint8Array): Promise<void>;
|
||||
static generateSalt(): Uint8Array;
|
||||
isInitialized(): boolean;
|
||||
getKeys(): Promise<DerivedKeys>;
|
||||
private deriveEncryptionKey;
|
||||
private deriveSigningKeyPair;
|
||||
/**
|
||||
* Derive deterministic secp256k1 keys from the master key via HKDF.
|
||||
* This gives every EncryptID identity an Ethereum-compatible wallet address,
|
||||
* enabling them to act as Gnosis Safe owners for multi-sig approvals.
|
||||
*/
|
||||
private deriveEthereumKeys;
|
||||
private deriveDIDSeed;
|
||||
private generateDID;
|
||||
clear(): void;
|
||||
}
|
||||
export declare function encryptData(key: CryptoKey, data: ArrayBuffer | Uint8Array | string): Promise<EncryptedData>;
|
||||
export declare function decryptData(key: CryptoKey, encrypted: EncryptedData): Promise<ArrayBuffer>;
|
||||
export declare function decryptDataAsString(key: CryptoKey, encrypted: EncryptedData): Promise<string>;
|
||||
export declare function signData(keyPair: CryptoKeyPair, data: ArrayBuffer | Uint8Array | string): Promise<SignedData>;
|
||||
export declare function verifySignature(signed: SignedData): Promise<boolean>;
|
||||
export declare function wrapKeyForRecipient(keyToWrap: CryptoKey, recipientPublicKey: CryptoKey): Promise<ArrayBuffer>;
|
||||
export declare function unwrapSharedKey(wrappedKey: ArrayBuffer, privateKey: CryptoKey): Promise<CryptoKey>;
|
||||
/**
|
||||
* Sign an Ethereum-compatible message hash with a secp256k1 private key.
|
||||
* Returns { r, s, v } components for Safe transaction signing.
|
||||
*
|
||||
* @param hash - 32-byte message hash (e.g. keccak256 of the message)
|
||||
* @param privateKey - 32-byte secp256k1 private key
|
||||
*/
|
||||
export declare function signEthHash(hash: Uint8Array, privateKey: Uint8Array): {
|
||||
r: string;
|
||||
s: string;
|
||||
v: number;
|
||||
signature: Uint8Array;
|
||||
};
|
||||
export declare function getKeyManager(): EncryptIDKeyManager;
|
||||
export declare function resetKeyManager(): void;
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
/**
|
||||
* EncryptID Social Recovery Module
|
||||
*
|
||||
* Guardian-based account recovery with NO SEED PHRASES.
|
||||
*/
|
||||
import type { Guardian, RecoveryConfig, RecoveryRequest } from '../types/index.js';
|
||||
import { GuardianType } from '../types/index.js';
|
||||
export { GuardianType };
|
||||
export type { Guardian, RecoveryConfig, RecoveryRequest };
|
||||
export declare class RecoveryManager {
|
||||
private config;
|
||||
private activeRequest;
|
||||
constructor();
|
||||
initializeRecovery(threshold?: number): Promise<RecoveryConfig>;
|
||||
addGuardian(guardian: Omit<Guardian, 'id' | 'addedAt'>): Promise<Guardian>;
|
||||
removeGuardian(guardianId: string): Promise<void>;
|
||||
setThreshold(threshold: number): Promise<void>;
|
||||
setDelay(delaySeconds: number): Promise<void>;
|
||||
getConfig(): RecoveryConfig | null;
|
||||
isConfigured(): boolean;
|
||||
verifyGuardian(guardianId: string): Promise<boolean>;
|
||||
initiateRecovery(newCredentialId: string): Promise<RecoveryRequest>;
|
||||
approveRecovery(guardianId: string, signature: string): Promise<RecoveryRequest>;
|
||||
cancelRecovery(): Promise<void>;
|
||||
completeRecovery(): Promise<void>;
|
||||
getActiveRequest(): RecoveryRequest | null;
|
||||
private hashGuardianList;
|
||||
private saveConfig;
|
||||
private loadConfig;
|
||||
}
|
||||
export declare function getRecoveryManager(): RecoveryManager;
|
||||
export declare function getGuardianTypeInfo(type: GuardianType): {
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
setupInstructions: string;
|
||||
};
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
/**
|
||||
* EncryptID Session Management
|
||||
*
|
||||
* Handles session tokens, cross-app SSO, and authentication levels.
|
||||
*/
|
||||
import type { AuthenticationResult, EncryptIDClaims, SessionState, OperationPermission } from '../types/index.js';
|
||||
import { AuthLevel } from '../types/index.js';
|
||||
export { AuthLevel };
|
||||
export type { EncryptIDClaims, SessionState, OperationPermission };
|
||||
export declare const OPERATION_PERMISSIONS: Record<string, OperationPermission>;
|
||||
export declare class SessionManager {
|
||||
private session;
|
||||
private refreshTimer;
|
||||
constructor();
|
||||
createSession(authResult: AuthenticationResult, did: string, capabilities: EncryptIDClaims['eid']['capabilities'], walletAddress?: string, username?: string): Promise<SessionState>;
|
||||
getSession(): SessionState | null;
|
||||
getDID(): string | null;
|
||||
getAccessToken(): string | null;
|
||||
getAuthLevel(): AuthLevel;
|
||||
canPerform(operation: string): {
|
||||
allowed: boolean;
|
||||
reason?: string;
|
||||
};
|
||||
requiresFreshAuth(operation: string): boolean;
|
||||
upgradeAuthLevel(level?: AuthLevel): void;
|
||||
clearSession(): void;
|
||||
isValid(): boolean;
|
||||
private createUnsignedToken;
|
||||
private createRefreshToken;
|
||||
private persistSession;
|
||||
private restoreSession;
|
||||
private scheduleRefresh;
|
||||
private refreshTokens;
|
||||
}
|
||||
export declare function getSessionManager(): SessionManager;
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
/**
|
||||
* EncryptID Token Relay — Cross-Domain Authentication
|
||||
*
|
||||
* Since .online is a public suffix, cookies can't be shared across
|
||||
* r*.online domains. This module uses postMessage via hidden iframes
|
||||
* to relay the JWT token to sibling modules after authentication.
|
||||
*
|
||||
* Usage (on the authenticating domain, e.g., rspace.online):
|
||||
*
|
||||
* import { shareTokenAcrossModules, MODULE_DOMAINS } from '@encryptid/sdk/client/token-relay';
|
||||
* await shareTokenAcrossModules(jwt, MODULE_DOMAINS);
|
||||
*
|
||||
* Usage (on each module, add a relay page at /auth/relay):
|
||||
*
|
||||
* import { initTokenRelayListener } from '@encryptid/sdk/client/token-relay';
|
||||
* initTokenRelayListener(); // Listens for postMessage, stores token
|
||||
*/
|
||||
/** All r*.online module domains for token relay */
|
||||
export declare const MODULE_DOMAINS: readonly ["rvote.online", "rnotes.online", "rmaps.online", "rcal.online", "rfunds.online", "rtube.online", "rfiles.online", "rmail.online", "rtrips.online", "rnetwork.online", "rwallet.online", "rstack.online", "rspace.online"];
|
||||
interface RelayResult {
|
||||
domain: string;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
/**
|
||||
* Share an EncryptID JWT token across all r*.online module domains.
|
||||
* Creates hidden iframes pointing to each module's /auth/relay page,
|
||||
* then sends the token via postMessage.
|
||||
*
|
||||
* @param token - The JWT token to share
|
||||
* @param domains - Array of domains to relay to (defaults to MODULE_DOMAINS)
|
||||
* @param timeout - Timeout per domain in ms (default 5000)
|
||||
* @returns Results for each domain
|
||||
*/
|
||||
export declare function shareTokenAcrossModules(token: string, domains?: readonly string[], timeout?: number): Promise<RelayResult[]>;
|
||||
/**
|
||||
* Clear the token from all module domains.
|
||||
* Call this on sign-out.
|
||||
*/
|
||||
export declare function clearTokenAcrossModules(domains?: readonly string[], timeout?: number): Promise<void>;
|
||||
/**
|
||||
* Initialize the token relay listener on a module's /auth/relay page.
|
||||
* Listens for postMessage from sibling r*.online domains and stores
|
||||
* the token in localStorage.
|
||||
*
|
||||
* This should be called on a minimal page served at /auth/relay on each module.
|
||||
*/
|
||||
export declare function initTokenRelayListener(): void;
|
||||
/**
|
||||
* Get the locally stored EncryptID token, if any.
|
||||
*/
|
||||
export declare function getStoredToken(): string | null;
|
||||
/**
|
||||
* Request the token from a sibling domain if we don't have it locally.
|
||||
* Creates a hidden iframe to the specified domain and asks for the token.
|
||||
*/
|
||||
export declare function requestTokenFromDomain(domain: string, timeout?: number): Promise<string | null>;
|
||||
export {};
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
/**
|
||||
* EncryptID WebAuthn Module
|
||||
*
|
||||
* Handles passkey registration, authentication, and PRF extension
|
||||
* for key derivation. This is the foundation layer of EncryptID.
|
||||
*/
|
||||
import type { EncryptIDCredential, AuthenticationResult, EncryptIDConfig, WebAuthnCapabilities } from '../types/index.js';
|
||||
export type { EncryptIDCredential, AuthenticationResult, EncryptIDConfig, WebAuthnCapabilities };
|
||||
/**
|
||||
* Abort any pending conditional UI request
|
||||
*/
|
||||
export declare function abortConditionalUI(): void;
|
||||
export declare function bufferToBase64url(buffer: ArrayBuffer): string;
|
||||
export declare function base64urlToBuffer(base64url: string): ArrayBuffer;
|
||||
export declare function generateChallenge(): ArrayBuffer;
|
||||
export declare function generatePRFSalt(purpose: string): Promise<ArrayBuffer>;
|
||||
export declare function registerPasskey(username: string, displayName: string, config?: Partial<EncryptIDConfig>): Promise<EncryptIDCredential>;
|
||||
export declare function authenticatePasskey(credentialId?: string, config?: Partial<EncryptIDConfig>): Promise<AuthenticationResult>;
|
||||
export declare function isConditionalMediationAvailable(): Promise<boolean>;
|
||||
export declare function startConditionalUI(config?: Partial<EncryptIDConfig>): Promise<AuthenticationResult | null>;
|
||||
export declare function detectCapabilities(): Promise<WebAuthnCapabilities>;
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,529 @@
|
|||
import {
|
||||
bufferToBase64url
|
||||
} from "./index-2cp5044h.js";
|
||||
import {
|
||||
AuthLevel
|
||||
} from "./index-5c1t4ftn.js";
|
||||
|
||||
// src/client/key-derivation.ts
|
||||
class EncryptIDKeyManager {
|
||||
masterKey = null;
|
||||
derivedKeys = null;
|
||||
fromPRF = false;
|
||||
async initFromPRF(prfOutput) {
|
||||
this.masterKey = await crypto.subtle.importKey("raw", prfOutput, { name: "HKDF" }, false, ["deriveKey", "deriveBits"]);
|
||||
this.fromPRF = true;
|
||||
this.derivedKeys = null;
|
||||
}
|
||||
async initFromPassphrase(passphrase, salt) {
|
||||
const encoder = new TextEncoder;
|
||||
const passphraseKey = await crypto.subtle.importKey("raw", encoder.encode(passphrase), { name: "PBKDF2" }, false, ["deriveBits"]);
|
||||
const masterKeyMaterial = await crypto.subtle.deriveBits({ name: "PBKDF2", salt, iterations: 600000, hash: "SHA-256" }, passphraseKey, 256);
|
||||
this.masterKey = await crypto.subtle.importKey("raw", masterKeyMaterial, { name: "HKDF" }, false, ["deriveKey", "deriveBits"]);
|
||||
this.fromPRF = false;
|
||||
this.derivedKeys = null;
|
||||
}
|
||||
static generateSalt() {
|
||||
return crypto.getRandomValues(new Uint8Array(32));
|
||||
}
|
||||
isInitialized() {
|
||||
return this.masterKey !== null;
|
||||
}
|
||||
async getKeys() {
|
||||
if (!this.masterKey)
|
||||
throw new Error("Key manager not initialized");
|
||||
if (this.derivedKeys)
|
||||
return this.derivedKeys;
|
||||
const [encryptionKey, signingKeyPair, didSeed] = await Promise.all([
|
||||
this.deriveEncryptionKey(),
|
||||
this.deriveSigningKeyPair(),
|
||||
this.deriveDIDSeed()
|
||||
]);
|
||||
const did = await this.generateDID(didSeed);
|
||||
this.derivedKeys = { encryptionKey, signingKeyPair, didSeed, did, fromPRF: this.fromPRF };
|
||||
return this.derivedKeys;
|
||||
}
|
||||
async deriveEncryptionKey() {
|
||||
const encoder = new TextEncoder;
|
||||
return crypto.subtle.deriveKey({ name: "HKDF", hash: "SHA-256", salt: encoder.encode("encryptid-encryption-key-v1"), info: encoder.encode("AES-256-GCM") }, this.masterKey, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt", "wrapKey", "unwrapKey"]);
|
||||
}
|
||||
async deriveSigningKeyPair() {
|
||||
return crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, false, ["sign", "verify"]);
|
||||
}
|
||||
async deriveDIDSeed() {
|
||||
const encoder = new TextEncoder;
|
||||
const seed = await crypto.subtle.deriveBits({ name: "HKDF", hash: "SHA-256", salt: encoder.encode("encryptid-did-key-v1"), info: encoder.encode("Ed25519-seed") }, this.masterKey, 256);
|
||||
return new Uint8Array(seed);
|
||||
}
|
||||
async generateDID(seed) {
|
||||
const publicKeyHash = await crypto.subtle.digest("SHA-256", seed);
|
||||
const publicKeyBytes = new Uint8Array(publicKeyHash).slice(0, 32);
|
||||
const multicodecPrefix = new Uint8Array([237, 1]);
|
||||
const multicodecKey = new Uint8Array(34);
|
||||
multicodecKey.set(multicodecPrefix);
|
||||
multicodecKey.set(publicKeyBytes, 2);
|
||||
const base58Encoded = bufferToBase64url(multicodecKey.buffer).replace(/-/g, "").replace(/_/g, "");
|
||||
return `did:key:z${base58Encoded}`;
|
||||
}
|
||||
clear() {
|
||||
this.masterKey = null;
|
||||
this.derivedKeys = null;
|
||||
this.fromPRF = false;
|
||||
}
|
||||
}
|
||||
async function encryptData(key, data) {
|
||||
let plaintext;
|
||||
if (typeof data === "string")
|
||||
plaintext = new TextEncoder().encode(data).buffer;
|
||||
else if (data instanceof Uint8Array)
|
||||
plaintext = data.buffer;
|
||||
else
|
||||
plaintext = data;
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const ciphertext = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, plaintext);
|
||||
return { ciphertext, iv };
|
||||
}
|
||||
async function decryptData(key, encrypted) {
|
||||
return crypto.subtle.decrypt({ name: "AES-GCM", iv: encrypted.iv }, key, encrypted.ciphertext);
|
||||
}
|
||||
async function decryptDataAsString(key, encrypted) {
|
||||
return new TextDecoder().decode(await decryptData(key, encrypted));
|
||||
}
|
||||
async function signData(keyPair, data) {
|
||||
let dataBuffer;
|
||||
if (typeof data === "string")
|
||||
dataBuffer = new TextEncoder().encode(data).buffer;
|
||||
else if (data instanceof Uint8Array)
|
||||
dataBuffer = data.buffer;
|
||||
else
|
||||
dataBuffer = data;
|
||||
const signature = await crypto.subtle.sign({ name: "ECDSA", hash: "SHA-256" }, keyPair.privateKey, dataBuffer);
|
||||
const publicKey = await crypto.subtle.exportKey("raw", keyPair.publicKey);
|
||||
return { data: dataBuffer, signature, publicKey };
|
||||
}
|
||||
async function verifySignature(signed) {
|
||||
const publicKey = await crypto.subtle.importKey("raw", signed.publicKey, { name: "ECDSA", namedCurve: "P-256" }, false, ["verify"]);
|
||||
return crypto.subtle.verify({ name: "ECDSA", hash: "SHA-256" }, publicKey, signed.signature, signed.data);
|
||||
}
|
||||
async function wrapKeyForRecipient(keyToWrap, recipientPublicKey) {
|
||||
return crypto.subtle.wrapKey("raw", keyToWrap, recipientPublicKey, { name: "RSA-OAEP" });
|
||||
}
|
||||
async function unwrapSharedKey(wrappedKey, privateKey) {
|
||||
return crypto.subtle.unwrapKey("raw", wrappedKey, privateKey, { name: "RSA-OAEP" }, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]);
|
||||
}
|
||||
var keyManagerInstance = null;
|
||||
function getKeyManager() {
|
||||
if (!keyManagerInstance)
|
||||
keyManagerInstance = new EncryptIDKeyManager;
|
||||
return keyManagerInstance;
|
||||
}
|
||||
function resetKeyManager() {
|
||||
if (keyManagerInstance) {
|
||||
keyManagerInstance.clear();
|
||||
keyManagerInstance = null;
|
||||
}
|
||||
}
|
||||
|
||||
// src/client/session.ts
|
||||
var OPERATION_PERMISSIONS = {
|
||||
"rspace:view-public": { minAuthLevel: 1 /* BASIC */ },
|
||||
"rspace:view-private": { minAuthLevel: 2 /* STANDARD */ },
|
||||
"rspace:edit-board": { minAuthLevel: 2 /* STANDARD */ },
|
||||
"rspace:create-board": { minAuthLevel: 2 /* STANDARD */ },
|
||||
"rspace:delete-board": { minAuthLevel: 3 /* ELEVATED */, maxAgeSeconds: 300 },
|
||||
"rspace:encrypt-board": { minAuthLevel: 3 /* ELEVATED */, requiresCapability: "encrypt" },
|
||||
"rwallet:view-balance": { minAuthLevel: 1 /* BASIC */ },
|
||||
"rwallet:view-history": { minAuthLevel: 2 /* STANDARD */ },
|
||||
"rwallet:send-small": { minAuthLevel: 2 /* STANDARD */, requiresCapability: "wallet" },
|
||||
"rwallet:send-large": { minAuthLevel: 3 /* ELEVATED */, requiresCapability: "wallet", maxAgeSeconds: 60 },
|
||||
"rwallet:add-guardian": { minAuthLevel: 4 /* CRITICAL */, maxAgeSeconds: 60 },
|
||||
"rwallet:remove-guardian": { minAuthLevel: 4 /* CRITICAL */, maxAgeSeconds: 60 },
|
||||
"rvote:view-proposals": { minAuthLevel: 1 /* BASIC */ },
|
||||
"rvote:cast-vote": { minAuthLevel: 3 /* ELEVATED */, requiresCapability: "sign", maxAgeSeconds: 300 },
|
||||
"rvote:delegate": { minAuthLevel: 3 /* ELEVATED */, requiresCapability: "wallet" },
|
||||
"rfiles:list-files": { minAuthLevel: 2 /* STANDARD */ },
|
||||
"rfiles:download-own": { minAuthLevel: 2 /* STANDARD */, requiresCapability: "encrypt" },
|
||||
"rfiles:upload": { minAuthLevel: 2 /* STANDARD */, requiresCapability: "encrypt" },
|
||||
"rfiles:share": { minAuthLevel: 3 /* ELEVATED */, requiresCapability: "encrypt" },
|
||||
"rfiles:delete": { minAuthLevel: 3 /* ELEVATED */, maxAgeSeconds: 300 },
|
||||
"rfiles:export-keys": { minAuthLevel: 4 /* CRITICAL */, maxAgeSeconds: 60 },
|
||||
"rmaps:view-public": { minAuthLevel: 1 /* BASIC */ },
|
||||
"rmaps:add-location": { minAuthLevel: 2 /* STANDARD */ },
|
||||
"rmaps:edit-location": { minAuthLevel: 2 /* STANDARD */, requiresCapability: "sign" },
|
||||
"account:view-profile": { minAuthLevel: 2 /* STANDARD */ },
|
||||
"account:edit-profile": { minAuthLevel: 3 /* ELEVATED */ },
|
||||
"account:export-data": { minAuthLevel: 4 /* CRITICAL */, maxAgeSeconds: 60 },
|
||||
"account:delete": { minAuthLevel: 4 /* CRITICAL */, maxAgeSeconds: 60 },
|
||||
"rspace:create-space": { minAuthLevel: 2 /* STANDARD */ },
|
||||
"rspace:configure-space": { minAuthLevel: 3 /* ELEVATED */, maxAgeSeconds: 300 },
|
||||
"rspace:delete-space": { minAuthLevel: 4 /* CRITICAL */, maxAgeSeconds: 60 },
|
||||
"rspace:invite-member": { minAuthLevel: 2 /* STANDARD */ },
|
||||
"rspace:remove-member": { minAuthLevel: 3 /* ELEVATED */, maxAgeSeconds: 300 },
|
||||
"rspace:change-visibility": { minAuthLevel: 3 /* ELEVATED */, maxAgeSeconds: 300 },
|
||||
"rfunds:create-space": { minAuthLevel: 2 /* STANDARD */ },
|
||||
"rfunds:edit-flows": { minAuthLevel: 2 /* STANDARD */ },
|
||||
"rfunds:share-space": { minAuthLevel: 2 /* STANDARD */ }
|
||||
};
|
||||
var SESSION_STORAGE_KEY = "encryptid_session";
|
||||
var TOKEN_REFRESH_THRESHOLD = 5 * 60 * 1000;
|
||||
|
||||
class SessionManager {
|
||||
session = null;
|
||||
refreshTimer = null;
|
||||
constructor() {
|
||||
this.restoreSession();
|
||||
}
|
||||
async createSession(authResult, did, capabilities) {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const claims = {
|
||||
iss: "https://encryptid.jeffemmett.com",
|
||||
sub: did,
|
||||
aud: ["rspace.online", "rwallet.online", "rvote.online", "rfiles.online", "rmaps.online"],
|
||||
iat: now,
|
||||
exp: now + 15 * 60,
|
||||
jti: bufferToBase64url(crypto.getRandomValues(new Uint8Array(16)).buffer),
|
||||
eid: {
|
||||
credentialId: authResult.credentialId,
|
||||
authLevel: 3 /* ELEVATED */,
|
||||
authTime: now,
|
||||
capabilities,
|
||||
recoveryConfigured: false
|
||||
}
|
||||
};
|
||||
const accessToken = this.createUnsignedToken(claims);
|
||||
const refreshToken = this.createRefreshToken(did);
|
||||
this.session = { accessToken, refreshToken, claims, lastAuthTime: Date.now() };
|
||||
this.persistSession();
|
||||
this.scheduleRefresh();
|
||||
return this.session;
|
||||
}
|
||||
getSession() {
|
||||
return this.session;
|
||||
}
|
||||
getDID() {
|
||||
return this.session?.claims.sub ?? null;
|
||||
}
|
||||
getAccessToken() {
|
||||
return this.session?.accessToken ?? null;
|
||||
}
|
||||
getAuthLevel() {
|
||||
if (!this.session)
|
||||
return 1 /* BASIC */;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (now >= this.session.claims.exp)
|
||||
return 1 /* BASIC */;
|
||||
const authAge = now - this.session.claims.eid.authTime;
|
||||
if (authAge < 60)
|
||||
return 3 /* ELEVATED */;
|
||||
if (authAge < 15 * 60)
|
||||
return 2 /* STANDARD */;
|
||||
return 1 /* BASIC */;
|
||||
}
|
||||
canPerform(operation) {
|
||||
const permission = OPERATION_PERMISSIONS[operation];
|
||||
if (!permission)
|
||||
return { allowed: false, reason: "Unknown operation" };
|
||||
if (!this.session)
|
||||
return { allowed: false, reason: "Not authenticated" };
|
||||
const currentLevel = this.getAuthLevel();
|
||||
if (currentLevel < permission.minAuthLevel) {
|
||||
return { allowed: false, reason: `Requires ${AuthLevel[permission.minAuthLevel]} auth level (current: ${AuthLevel[currentLevel]})` };
|
||||
}
|
||||
if (permission.requiresCapability) {
|
||||
if (!this.session.claims.eid.capabilities[permission.requiresCapability]) {
|
||||
return { allowed: false, reason: `Requires ${permission.requiresCapability} capability` };
|
||||
}
|
||||
}
|
||||
if (permission.maxAgeSeconds) {
|
||||
const authAge = Math.floor(Date.now() / 1000) - this.session.claims.eid.authTime;
|
||||
if (authAge > permission.maxAgeSeconds) {
|
||||
return { allowed: false, reason: `Authentication too old (${authAge}s > ${permission.maxAgeSeconds}s)` };
|
||||
}
|
||||
}
|
||||
return { allowed: true };
|
||||
}
|
||||
requiresFreshAuth(operation) {
|
||||
const permission = OPERATION_PERMISSIONS[operation];
|
||||
if (!permission)
|
||||
return true;
|
||||
if (permission.minAuthLevel >= 4 /* CRITICAL */)
|
||||
return true;
|
||||
if (permission.maxAgeSeconds && permission.maxAgeSeconds <= 60)
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
upgradeAuthLevel(level = 3 /* ELEVATED */) {
|
||||
if (!this.session)
|
||||
return;
|
||||
this.session.claims.eid.authLevel = level;
|
||||
this.session.claims.eid.authTime = Math.floor(Date.now() / 1000);
|
||||
this.session.lastAuthTime = Date.now();
|
||||
this.persistSession();
|
||||
}
|
||||
clearSession() {
|
||||
this.session = null;
|
||||
if (this.refreshTimer) {
|
||||
clearTimeout(this.refreshTimer);
|
||||
this.refreshTimer = null;
|
||||
}
|
||||
try {
|
||||
localStorage.removeItem(SESSION_STORAGE_KEY);
|
||||
} catch {}
|
||||
}
|
||||
isValid() {
|
||||
if (!this.session)
|
||||
return false;
|
||||
return Math.floor(Date.now() / 1000) < this.session.claims.exp;
|
||||
}
|
||||
createUnsignedToken(claims) {
|
||||
const header = { alg: "none", typ: "JWT" };
|
||||
return `${btoa(JSON.stringify(header))}.${btoa(JSON.stringify(claims))}.`;
|
||||
}
|
||||
createRefreshToken(did) {
|
||||
return btoa(JSON.stringify({
|
||||
sub: did,
|
||||
iat: Math.floor(Date.now() / 1000),
|
||||
exp: Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60,
|
||||
jti: bufferToBase64url(crypto.getRandomValues(new Uint8Array(16)).buffer)
|
||||
}));
|
||||
}
|
||||
persistSession() {
|
||||
if (!this.session)
|
||||
return;
|
||||
try {
|
||||
localStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(this.session));
|
||||
} catch {}
|
||||
}
|
||||
restoreSession() {
|
||||
try {
|
||||
const stored = localStorage.getItem(SESSION_STORAGE_KEY);
|
||||
if (stored) {
|
||||
const session = JSON.parse(stored);
|
||||
if (Math.floor(Date.now() / 1000) < session.claims.exp) {
|
||||
this.session = session;
|
||||
this.scheduleRefresh();
|
||||
} else {
|
||||
localStorage.removeItem(SESSION_STORAGE_KEY);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
scheduleRefresh() {
|
||||
if (!this.session)
|
||||
return;
|
||||
if (this.refreshTimer)
|
||||
clearTimeout(this.refreshTimer);
|
||||
const expiresAt = this.session.claims.exp * 1000;
|
||||
const refreshAt = expiresAt - TOKEN_REFRESH_THRESHOLD;
|
||||
const delay = Math.max(refreshAt - Date.now(), 0);
|
||||
this.refreshTimer = setTimeout(() => this.refreshTokens(), delay);
|
||||
}
|
||||
async refreshTokens() {
|
||||
if (!this.session)
|
||||
return;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
this.session.claims.eid.authLevel = Math.min(this.session.claims.eid.authLevel, 2 /* STANDARD */);
|
||||
this.session.claims.iat = now;
|
||||
this.session.claims.exp = now + 15 * 60;
|
||||
this.session.claims.jti = bufferToBase64url(crypto.getRandomValues(new Uint8Array(16)).buffer);
|
||||
this.session.accessToken = this.createUnsignedToken(this.session.claims);
|
||||
this.persistSession();
|
||||
this.scheduleRefresh();
|
||||
}
|
||||
}
|
||||
var sessionManagerInstance = null;
|
||||
function getSessionManager() {
|
||||
if (!sessionManagerInstance)
|
||||
sessionManagerInstance = new SessionManager;
|
||||
return sessionManagerInstance;
|
||||
}
|
||||
|
||||
// src/client/recovery.ts
|
||||
class RecoveryManager {
|
||||
config = null;
|
||||
activeRequest = null;
|
||||
constructor() {
|
||||
this.loadConfig();
|
||||
}
|
||||
async initializeRecovery(threshold = 3) {
|
||||
this.config = {
|
||||
threshold,
|
||||
delaySeconds: 48 * 60 * 60,
|
||||
guardians: [],
|
||||
guardianListHash: "",
|
||||
updatedAt: Date.now()
|
||||
};
|
||||
await this.saveConfig();
|
||||
return this.config;
|
||||
}
|
||||
async addGuardian(guardian) {
|
||||
if (!this.config)
|
||||
throw new Error("Recovery not initialized");
|
||||
if (this.config.guardians.length >= 7)
|
||||
throw new Error("Maximum of 7 guardians allowed");
|
||||
const newGuardian = {
|
||||
...guardian,
|
||||
id: bufferToBase64url(crypto.getRandomValues(new Uint8Array(16)).buffer),
|
||||
addedAt: Date.now()
|
||||
};
|
||||
this.config.guardians.push(newGuardian);
|
||||
this.config.guardianListHash = await this.hashGuardianList();
|
||||
this.config.updatedAt = Date.now();
|
||||
await this.saveConfig();
|
||||
return newGuardian;
|
||||
}
|
||||
async removeGuardian(guardianId) {
|
||||
if (!this.config)
|
||||
throw new Error("Recovery not initialized");
|
||||
const index = this.config.guardians.findIndex((g) => g.id === guardianId);
|
||||
if (index === -1)
|
||||
throw new Error("Guardian not found");
|
||||
const remainingWeight = this.config.guardians.filter((g) => g.id !== guardianId).reduce((sum, g) => sum + g.weight, 0);
|
||||
if (remainingWeight < this.config.threshold)
|
||||
throw new Error("Cannot remove guardian: would make recovery impossible");
|
||||
this.config.guardians.splice(index, 1);
|
||||
this.config.guardianListHash = await this.hashGuardianList();
|
||||
this.config.updatedAt = Date.now();
|
||||
await this.saveConfig();
|
||||
}
|
||||
async setThreshold(threshold) {
|
||||
if (!this.config)
|
||||
throw new Error("Recovery not initialized");
|
||||
const totalWeight = this.config.guardians.reduce((sum, g) => sum + g.weight, 0);
|
||||
if (threshold > totalWeight)
|
||||
throw new Error("Threshold cannot exceed total guardian weight");
|
||||
if (threshold < 1)
|
||||
throw new Error("Threshold must be at least 1");
|
||||
this.config.threshold = threshold;
|
||||
this.config.updatedAt = Date.now();
|
||||
await this.saveConfig();
|
||||
}
|
||||
async setDelay(delaySeconds) {
|
||||
if (!this.config)
|
||||
throw new Error("Recovery not initialized");
|
||||
if (delaySeconds < 3600 || delaySeconds > 7 * 24 * 3600)
|
||||
throw new Error("Delay must be between 1 hour and 7 days");
|
||||
this.config.delaySeconds = delaySeconds;
|
||||
this.config.updatedAt = Date.now();
|
||||
await this.saveConfig();
|
||||
}
|
||||
getConfig() {
|
||||
return this.config;
|
||||
}
|
||||
isConfigured() {
|
||||
if (!this.config)
|
||||
return false;
|
||||
return this.config.guardians.reduce((sum, g) => sum + g.weight, 0) >= this.config.threshold;
|
||||
}
|
||||
async verifyGuardian(guardianId) {
|
||||
if (!this.config)
|
||||
throw new Error("Recovery not initialized");
|
||||
const guardian = this.config.guardians.find((g) => g.id === guardianId);
|
||||
if (!guardian)
|
||||
throw new Error("Guardian not found");
|
||||
guardian.lastVerified = Date.now();
|
||||
await this.saveConfig();
|
||||
return true;
|
||||
}
|
||||
async initiateRecovery(newCredentialId) {
|
||||
if (!this.config)
|
||||
throw new Error("Recovery not configured");
|
||||
if (this.activeRequest?.status === "pending")
|
||||
throw new Error("Recovery already in progress");
|
||||
const now = Date.now();
|
||||
this.activeRequest = {
|
||||
id: bufferToBase64url(crypto.getRandomValues(new Uint8Array(16)).buffer),
|
||||
accountDID: "",
|
||||
newCredentialId,
|
||||
initiatedAt: now,
|
||||
completesAt: now + this.config.delaySeconds * 1000,
|
||||
status: "pending",
|
||||
approvals: [],
|
||||
approvalWeight: 0
|
||||
};
|
||||
return this.activeRequest;
|
||||
}
|
||||
async approveRecovery(guardianId, signature) {
|
||||
if (!this.activeRequest || this.activeRequest.status !== "pending")
|
||||
throw new Error("No pending recovery request");
|
||||
if (!this.config)
|
||||
throw new Error("Recovery not configured");
|
||||
const guardian = this.config.guardians.find((g) => g.id === guardianId);
|
||||
if (!guardian)
|
||||
throw new Error("Guardian not found");
|
||||
if (this.activeRequest.approvals.some((a) => a.guardianId === guardianId))
|
||||
throw new Error("Guardian already approved");
|
||||
this.activeRequest.approvals.push({ guardianId, approvedAt: Date.now(), signature });
|
||||
this.activeRequest.approvalWeight += guardian.weight;
|
||||
if (this.activeRequest.approvalWeight >= this.config.threshold) {
|
||||
this.activeRequest.status = "approved";
|
||||
}
|
||||
return this.activeRequest;
|
||||
}
|
||||
async cancelRecovery() {
|
||||
if (!this.activeRequest || this.activeRequest.status !== "pending")
|
||||
throw new Error("No pending recovery request to cancel");
|
||||
this.activeRequest.status = "cancelled";
|
||||
this.activeRequest = null;
|
||||
}
|
||||
async completeRecovery() {
|
||||
if (!this.activeRequest)
|
||||
throw new Error("No recovery request");
|
||||
if (this.activeRequest.status !== "approved")
|
||||
throw new Error("Recovery not approved");
|
||||
if (Date.now() < this.activeRequest.completesAt) {
|
||||
const remaining = this.activeRequest.completesAt - Date.now();
|
||||
throw new Error(`Time-lock not expired. ${Math.ceil(remaining / 1000 / 60)} minutes remaining.`);
|
||||
}
|
||||
this.activeRequest.status = "completed";
|
||||
this.activeRequest = null;
|
||||
}
|
||||
getActiveRequest() {
|
||||
return this.activeRequest;
|
||||
}
|
||||
async hashGuardianList() {
|
||||
if (!this.config)
|
||||
return "";
|
||||
const sortedIds = this.config.guardians.map((g) => g.id).sort().join(",");
|
||||
const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(sortedIds));
|
||||
return bufferToBase64url(hash);
|
||||
}
|
||||
async saveConfig() {
|
||||
if (!this.config)
|
||||
return;
|
||||
try {
|
||||
localStorage.setItem("encryptid_recovery", JSON.stringify(this.config));
|
||||
} catch {}
|
||||
}
|
||||
loadConfig() {
|
||||
try {
|
||||
const stored = localStorage.getItem("encryptid_recovery");
|
||||
if (stored)
|
||||
this.config = JSON.parse(stored);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
var recoveryManagerInstance = null;
|
||||
function getRecoveryManager() {
|
||||
if (!recoveryManagerInstance)
|
||||
recoveryManagerInstance = new RecoveryManager;
|
||||
return recoveryManagerInstance;
|
||||
}
|
||||
function getGuardianTypeInfo(type) {
|
||||
switch (type) {
|
||||
case "secondary_passkey" /* SECONDARY_PASSKEY */:
|
||||
return { name: "Backup Passkey", description: "Another device you own (phone, YubiKey, etc.)", icon: "key", setupInstructions: "Register a passkey on a second device you control." };
|
||||
case "trusted_contact" /* TRUSTED_CONTACT */:
|
||||
return { name: "Trusted Contact", description: "A friend or family member with their own EncryptID", icon: "user", setupInstructions: "Ask a trusted person to create an EncryptID account." };
|
||||
case "hardware_key" /* HARDWARE_KEY */:
|
||||
return { name: "Hardware Security Key", description: "A YubiKey or similar device stored offline", icon: "shield", setupInstructions: "Register a hardware security key and store it safely." };
|
||||
case "institutional" /* INSTITUTIONAL */:
|
||||
return { name: "Recovery Service", description: "A professional recovery service provider", icon: "building", setupInstructions: "Connect with a trusted recovery service." };
|
||||
case "time_delayed_self" /* TIME_DELAYED_SELF */:
|
||||
return { name: "Time-Delayed Self", description: "Recover yourself after a waiting period", icon: "clock", setupInstructions: "Set up a recovery option that requires waiting before completing." };
|
||||
default:
|
||||
return { name: "Unknown", description: "Unknown guardian type", icon: "question", setupInstructions: "" };
|
||||
}
|
||||
}
|
||||
|
||||
export { EncryptIDKeyManager, encryptData, decryptData, decryptDataAsString, signData, verifySignature, wrapKeyForRecipient, unwrapSharedKey, getKeyManager, resetKeyManager, OPERATION_PERMISSIONS, SessionManager, getSessionManager, RecoveryManager, getRecoveryManager, getGuardianTypeInfo };
|
||||
|
|
@ -0,0 +1,194 @@
|
|||
// src/client/webauthn.ts
|
||||
var DEFAULT_CONFIG = {
|
||||
rpId: "jeffemmett.com",
|
||||
rpName: "EncryptID",
|
||||
origin: typeof window !== "undefined" ? window.location.origin : "",
|
||||
userVerification: "required",
|
||||
timeout: 60000
|
||||
};
|
||||
var conditionalUIAbortController = null;
|
||||
function abortConditionalUI() {
|
||||
if (conditionalUIAbortController) {
|
||||
conditionalUIAbortController.abort();
|
||||
conditionalUIAbortController = null;
|
||||
}
|
||||
}
|
||||
function bufferToBase64url(buffer) {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = "";
|
||||
for (let i = 0;i < bytes.byteLength; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
||||
}
|
||||
function base64urlToBuffer(base64url) {
|
||||
const base64 = base64url.replace(/-/g, "+").replace(/_/g, "/");
|
||||
const padding = "=".repeat((4 - base64.length % 4) % 4);
|
||||
const binary = atob(base64 + padding);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0;i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return bytes.buffer;
|
||||
}
|
||||
function generateChallenge() {
|
||||
return crypto.getRandomValues(new Uint8Array(32)).buffer;
|
||||
}
|
||||
async function generatePRFSalt(purpose) {
|
||||
const encoder = new TextEncoder;
|
||||
const data = encoder.encode(`encryptid-prf-salt-${purpose}-v1`);
|
||||
return crypto.subtle.digest("SHA-256", data);
|
||||
}
|
||||
async function registerPasskey(username, displayName, config = {}) {
|
||||
abortConditionalUI();
|
||||
const cfg = { ...DEFAULT_CONFIG, ...config };
|
||||
if (!window.PublicKeyCredential) {
|
||||
throw new Error("WebAuthn is not supported in this browser");
|
||||
}
|
||||
const platformAvailable = await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
|
||||
const userId = crypto.getRandomValues(new Uint8Array(32));
|
||||
const challenge = generateChallenge();
|
||||
const prfSalt = await generatePRFSalt("master-key");
|
||||
const createOptions = {
|
||||
publicKey: {
|
||||
challenge: new Uint8Array(challenge),
|
||||
rp: { id: cfg.rpId, name: cfg.rpName },
|
||||
user: { id: userId, name: username, displayName },
|
||||
pubKeyCredParams: [
|
||||
{ alg: -7, type: "public-key" },
|
||||
{ alg: -257, type: "public-key" }
|
||||
],
|
||||
authenticatorSelection: {
|
||||
residentKey: "required",
|
||||
requireResidentKey: true,
|
||||
userVerification: cfg.userVerification,
|
||||
authenticatorAttachment: platformAvailable ? "platform" : undefined
|
||||
},
|
||||
attestation: "none",
|
||||
timeout: cfg.timeout,
|
||||
extensions: {
|
||||
prf: { eval: { first: new Uint8Array(prfSalt) } },
|
||||
credProps: true
|
||||
}
|
||||
}
|
||||
};
|
||||
const credential = await navigator.credentials.create(createOptions);
|
||||
if (!credential)
|
||||
throw new Error("Failed to create credential");
|
||||
const response = credential.response;
|
||||
const prfSupported = credential.getClientExtensionResults()?.prf?.enabled === true;
|
||||
const publicKey = response.getPublicKey();
|
||||
if (!publicKey)
|
||||
throw new Error("Failed to get public key from credential");
|
||||
return {
|
||||
credentialId: bufferToBase64url(credential.rawId),
|
||||
publicKey,
|
||||
userId: bufferToBase64url(userId.buffer),
|
||||
username,
|
||||
createdAt: Date.now(),
|
||||
prfSupported,
|
||||
transports: response.getTransports?.()
|
||||
};
|
||||
}
|
||||
async function authenticatePasskey(credentialId, config = {}) {
|
||||
abortConditionalUI();
|
||||
const cfg = { ...DEFAULT_CONFIG, ...config };
|
||||
if (!window.PublicKeyCredential) {
|
||||
throw new Error("WebAuthn is not supported in this browser");
|
||||
}
|
||||
const challenge = generateChallenge();
|
||||
const prfSalt = await generatePRFSalt("master-key");
|
||||
const allowCredentials = credentialId ? [{ type: "public-key", id: new Uint8Array(base64urlToBuffer(credentialId)) }] : undefined;
|
||||
const getOptions = {
|
||||
publicKey: {
|
||||
challenge: new Uint8Array(challenge),
|
||||
rpId: cfg.rpId,
|
||||
allowCredentials,
|
||||
userVerification: cfg.userVerification,
|
||||
timeout: cfg.timeout,
|
||||
extensions: {
|
||||
prf: { eval: { first: new Uint8Array(prfSalt) } }
|
||||
}
|
||||
}
|
||||
};
|
||||
const credential = await navigator.credentials.get(getOptions);
|
||||
if (!credential)
|
||||
throw new Error("Authentication failed");
|
||||
const response = credential.response;
|
||||
const prfResults = credential.getClientExtensionResults()?.prf?.results;
|
||||
return {
|
||||
credentialId: bufferToBase64url(credential.rawId),
|
||||
userId: response.userHandle ? bufferToBase64url(response.userHandle) : "",
|
||||
prfOutput: prfResults?.first,
|
||||
signature: response.signature,
|
||||
authenticatorData: response.authenticatorData
|
||||
};
|
||||
}
|
||||
async function isConditionalMediationAvailable() {
|
||||
if (!window.PublicKeyCredential)
|
||||
return false;
|
||||
if (typeof PublicKeyCredential.isConditionalMediationAvailable === "function") {
|
||||
return PublicKeyCredential.isConditionalMediationAvailable();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
async function startConditionalUI(config = {}) {
|
||||
const available = await isConditionalMediationAvailable();
|
||||
if (!available)
|
||||
return null;
|
||||
abortConditionalUI();
|
||||
conditionalUIAbortController = new AbortController;
|
||||
const cfg = { ...DEFAULT_CONFIG, ...config };
|
||||
const challenge = generateChallenge();
|
||||
const prfSalt = await generatePRFSalt("master-key");
|
||||
try {
|
||||
const credential = await navigator.credentials.get({
|
||||
publicKey: {
|
||||
challenge: new Uint8Array(challenge),
|
||||
rpId: cfg.rpId,
|
||||
userVerification: cfg.userVerification,
|
||||
timeout: cfg.timeout,
|
||||
extensions: {
|
||||
prf: { eval: { first: new Uint8Array(prfSalt) } }
|
||||
}
|
||||
},
|
||||
mediation: "conditional",
|
||||
signal: conditionalUIAbortController.signal
|
||||
});
|
||||
conditionalUIAbortController = null;
|
||||
if (!credential)
|
||||
return null;
|
||||
const response = credential.response;
|
||||
const prfResults = credential.getClientExtensionResults()?.prf?.results;
|
||||
return {
|
||||
credentialId: bufferToBase64url(credential.rawId),
|
||||
userId: response.userHandle ? bufferToBase64url(response.userHandle) : "",
|
||||
prfOutput: prfResults?.first,
|
||||
signature: response.signature,
|
||||
authenticatorData: response.authenticatorData
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
async function detectCapabilities() {
|
||||
const capabilities = {
|
||||
webauthn: false,
|
||||
platformAuthenticator: false,
|
||||
conditionalUI: false,
|
||||
prfExtension: false
|
||||
};
|
||||
if (!window.PublicKeyCredential)
|
||||
return capabilities;
|
||||
capabilities.webauthn = true;
|
||||
try {
|
||||
capabilities.platformAuthenticator = await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
|
||||
} catch {
|
||||
capabilities.platformAuthenticator = false;
|
||||
}
|
||||
capabilities.conditionalUI = await isConditionalMediationAvailable();
|
||||
capabilities.prfExtension = true;
|
||||
return capabilities;
|
||||
}
|
||||
|
||||
export { abortConditionalUI, bufferToBase64url, base64urlToBuffer, generateChallenge, registerPasskey, authenticatePasskey, isConditionalMediationAvailable, startConditionalUI, detectCapabilities };
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
import {
|
||||
verifyEncryptIDToken
|
||||
} from "./index-stg63j73.js";
|
||||
|
||||
// src/server/ws-auth.ts
|
||||
async function authenticateWSUpgrade(request, options = {}) {
|
||||
const url = new URL(request.url);
|
||||
const queryToken = url.searchParams.get("token");
|
||||
if (queryToken) {
|
||||
try {
|
||||
return await verifyEncryptIDToken(queryToken, options);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const protocols = request.headers.get("Sec-WebSocket-Protocol") || "";
|
||||
const tokenProtocol = protocols.split(",").map((p) => p.trim()).find((p) => p.startsWith("encryptid."));
|
||||
if (tokenProtocol) {
|
||||
const token = tokenProtocol.slice("encryptid.".length);
|
||||
try {
|
||||
return await verifyEncryptIDToken(token, options);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const cookie = request.headers.get("Cookie") || "";
|
||||
const match = cookie.match(/encryptid_token=([^;]+)/);
|
||||
if (match) {
|
||||
try {
|
||||
return await verifyEncryptIDToken(match[1], options);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export { authenticateWSUpgrade };
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
// src/types/index.ts
|
||||
var AuthLevel;
|
||||
((AuthLevel2) => {
|
||||
AuthLevel2[AuthLevel2["BASIC"] = 1] = "BASIC";
|
||||
AuthLevel2[AuthLevel2["STANDARD"] = 2] = "STANDARD";
|
||||
AuthLevel2[AuthLevel2["ELEVATED"] = 3] = "ELEVATED";
|
||||
AuthLevel2[AuthLevel2["CRITICAL"] = 4] = "CRITICAL";
|
||||
})(AuthLevel ||= {});
|
||||
var GuardianType;
|
||||
((GuardianType2) => {
|
||||
GuardianType2["SECONDARY_PASSKEY"] = "secondary_passkey";
|
||||
GuardianType2["TRUSTED_CONTACT"] = "trusted_contact";
|
||||
GuardianType2["HARDWARE_KEY"] = "hardware_key";
|
||||
GuardianType2["INSTITUTIONAL"] = "institutional";
|
||||
GuardianType2["TIME_DELAYED_SELF"] = "time_delayed_self";
|
||||
})(GuardianType ||= {});
|
||||
var SpaceVisibility;
|
||||
((SpaceVisibility2) => {
|
||||
SpaceVisibility2["PUBLIC"] = "public";
|
||||
SpaceVisibility2["PUBLIC_READ"] = "public_read";
|
||||
SpaceVisibility2["AUTHENTICATED"] = "authenticated";
|
||||
SpaceVisibility2["MEMBERS_ONLY"] = "members_only";
|
||||
})(SpaceVisibility ||= {});
|
||||
|
||||
export { AuthLevel, GuardianType, SpaceVisibility };
|
||||
|
|
@ -0,0 +1,170 @@
|
|||
import {
|
||||
bufferToBase64url
|
||||
} from "./index-2cp5044h.js";
|
||||
|
||||
// src/client/api-client.ts
|
||||
var DEFAULT_SERVER_URL = "https://encryptid.jeffemmett.com";
|
||||
|
||||
class EncryptIDClient {
|
||||
serverUrl;
|
||||
constructor(serverUrl = DEFAULT_SERVER_URL) {
|
||||
this.serverUrl = serverUrl.replace(/\/$/, "");
|
||||
}
|
||||
async registerStart(username, displayName) {
|
||||
const res = await fetch(`${this.serverUrl}/api/register/start`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, displayName: displayName || username })
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: "Registration start failed" }));
|
||||
throw new Error(err.error || `HTTP ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
async registerComplete(challenge, credential, userId, username) {
|
||||
const response = credential.response;
|
||||
const publicKey = response.getPublicKey();
|
||||
const res = await fetch(`${this.serverUrl}/api/register/complete`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
challenge,
|
||||
userId,
|
||||
username,
|
||||
credential: {
|
||||
credentialId: bufferToBase64url(credential.rawId),
|
||||
publicKey: publicKey ? bufferToBase64url(publicKey) : "",
|
||||
transports: response.getTransports?.() || []
|
||||
}
|
||||
})
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: "Registration complete failed" }));
|
||||
throw new Error(err.error || `HTTP ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
async authStart(credentialId) {
|
||||
const res = await fetch(`${this.serverUrl}/api/auth/start`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(credentialId ? { credentialId } : {})
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: "Auth start failed" }));
|
||||
throw new Error(err.error || `HTTP ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
async authComplete(challenge, credential) {
|
||||
const response = credential.response;
|
||||
const prfResults = credential.getClientExtensionResults()?.prf?.results;
|
||||
const res = await fetch(`${this.serverUrl}/api/auth/complete`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
challenge,
|
||||
credential: {
|
||||
credentialId: bufferToBase64url(credential.rawId),
|
||||
signature: bufferToBase64url(response.signature),
|
||||
authenticatorData: bufferToBase64url(response.authenticatorData),
|
||||
prfOutput: prfResults?.first ? bufferToBase64url(prfResults.first) : null
|
||||
}
|
||||
})
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: "Auth complete failed" }));
|
||||
throw new Error(err.error || `HTTP ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
async verifySession(token) {
|
||||
const res = await fetch(`${this.serverUrl}/api/session/verify`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
async refreshToken(token) {
|
||||
const res = await fetch(`${this.serverUrl}/api/session/refresh`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: "Token refresh failed" }));
|
||||
throw new Error(err.error || `HTTP ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
async listCredentials(token) {
|
||||
const res = await fetch(`${this.serverUrl}/api/user/credentials`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error("Failed to list credentials");
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
async register(username, displayName, config) {
|
||||
const { options, userId } = await this.registerStart(username, displayName);
|
||||
const createOptions = {
|
||||
publicKey: {
|
||||
...options,
|
||||
challenge: base64urlToUint8Array(options.challenge),
|
||||
user: {
|
||||
...options.user,
|
||||
id: base64urlToUint8Array(options.user.id)
|
||||
},
|
||||
pubKeyCredParams: options.pubKeyCredParams,
|
||||
extensions: {
|
||||
credProps: true,
|
||||
prf: { eval: { first: new Uint8Array(32) } }
|
||||
}
|
||||
}
|
||||
};
|
||||
const credential = await navigator.credentials.create(createOptions);
|
||||
if (!credential)
|
||||
throw new Error("Failed to create credential");
|
||||
return this.registerComplete(options.challenge, credential, userId, username);
|
||||
}
|
||||
async authenticate(credentialId, config) {
|
||||
const { options } = await this.authStart(credentialId);
|
||||
const getOptions = {
|
||||
publicKey: {
|
||||
challenge: base64urlToUint8Array(options.challenge),
|
||||
rpId: options.rpId,
|
||||
userVerification: options.userVerification,
|
||||
timeout: options.timeout,
|
||||
allowCredentials: options.allowCredentials?.map((c) => ({
|
||||
type: c.type,
|
||||
id: base64urlToUint8Array(c.id),
|
||||
transports: c.transports
|
||||
})),
|
||||
extensions: {
|
||||
prf: { eval: { first: new Uint8Array(32) } }
|
||||
}
|
||||
}
|
||||
};
|
||||
const credential = await navigator.credentials.get(getOptions);
|
||||
if (!credential)
|
||||
throw new Error("Authentication failed");
|
||||
const result = await this.authComplete(options.challenge, credential);
|
||||
const prfResults = credential.getClientExtensionResults()?.prf?.results;
|
||||
return {
|
||||
...result,
|
||||
prfOutput: prfResults?.first
|
||||
};
|
||||
}
|
||||
}
|
||||
function base64urlToUint8Array(base64url) {
|
||||
const base64 = base64url.replace(/-/g, "+").replace(/_/g, "/");
|
||||
const padding = "=".repeat((4 - base64.length % 4) % 4);
|
||||
const binary = atob(base64 + padding);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0;i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export { EncryptIDClient };
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
import {
|
||||
verifyEncryptIDToken
|
||||
} from "./index-stg63j73.js";
|
||||
|
||||
// src/server/space-auth.ts
|
||||
async function evaluateSpaceAccess(spaceSlug, token, method, options) {
|
||||
const config = await options.getSpaceConfig(spaceSlug);
|
||||
if (!config) {
|
||||
return { allowed: false, claims: null, reason: "Space not found", isOwner: false, readOnly: false };
|
||||
}
|
||||
let claims = null;
|
||||
if (token) {
|
||||
try {
|
||||
claims = await verifyEncryptIDToken(token, options);
|
||||
} catch {}
|
||||
}
|
||||
const isRead = method === "GET" || method === "HEAD" || method === "OPTIONS";
|
||||
const isOwner = !!(claims && config.ownerDID && claims.sub === config.ownerDID);
|
||||
switch (config.visibility) {
|
||||
case "public" /* PUBLIC */:
|
||||
return { allowed: true, claims, isOwner, readOnly: false };
|
||||
case "public_read" /* PUBLIC_READ */:
|
||||
if (isRead) {
|
||||
return { allowed: true, claims, isOwner, readOnly: !claims };
|
||||
}
|
||||
if (!claims) {
|
||||
return {
|
||||
allowed: false,
|
||||
claims: null,
|
||||
reason: "Authentication required to modify this space",
|
||||
isOwner: false,
|
||||
readOnly: true
|
||||
};
|
||||
}
|
||||
return { allowed: true, claims, isOwner, readOnly: false };
|
||||
case "authenticated" /* AUTHENTICATED */:
|
||||
if (!claims) {
|
||||
return { allowed: false, claims: null, reason: "Authentication required", isOwner: false, readOnly: false };
|
||||
}
|
||||
return { allowed: true, claims, isOwner, readOnly: false };
|
||||
case "members_only" /* MEMBERS_ONLY */:
|
||||
if (!claims) {
|
||||
return { allowed: false, claims: null, reason: "Authentication required", isOwner: false, readOnly: false };
|
||||
}
|
||||
return { allowed: true, claims, isOwner, readOnly: false };
|
||||
default:
|
||||
return { allowed: false, claims: null, reason: "Unknown visibility setting", isOwner: false, readOnly: false };
|
||||
}
|
||||
}
|
||||
function extractToken(headers) {
|
||||
if (typeof headers.get === "function") {
|
||||
const auth = headers.get("Authorization") || headers.get("authorization");
|
||||
if (auth?.startsWith("Bearer "))
|
||||
return auth.slice(7);
|
||||
const cookie = headers.get("Cookie") || headers.get("cookie") || "";
|
||||
const match = cookie.match(/encryptid_token=([^;]+)/);
|
||||
if (match)
|
||||
return match[1];
|
||||
}
|
||||
if (typeof headers.authorization === "string") {
|
||||
if (headers.authorization.startsWith("Bearer "))
|
||||
return headers.authorization.slice(7);
|
||||
}
|
||||
if (typeof headers.cookie === "string") {
|
||||
const match = headers.cookie.match(/encryptid_token=([^;]+)/);
|
||||
if (match)
|
||||
return match[1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export { evaluateSpaceAccess, extractToken };
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
// src/server/jwt-verify.ts
|
||||
var ENCRYPTID_SERVER = "https://encryptid.jeffemmett.com";
|
||||
async function verifyEncryptIDToken(token, options = {}) {
|
||||
const { secret, serverUrl = ENCRYPTID_SERVER, audience, clockTolerance = 30 } = options;
|
||||
if (secret) {
|
||||
return verifyLocally(token, secret, audience, clockTolerance);
|
||||
}
|
||||
return verifyRemotely(token, serverUrl);
|
||||
}
|
||||
async function verifyLocally(token, secret, audience, clockTolerance = 30) {
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 3) {
|
||||
throw new Error("Invalid JWT format");
|
||||
}
|
||||
const [headerB64, payloadB64, signatureB64] = parts;
|
||||
const encoder = new TextEncoder;
|
||||
const key = await crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["verify"]);
|
||||
const data = encoder.encode(`${headerB64}.${payloadB64}`);
|
||||
const signature = base64urlDecode(signatureB64);
|
||||
const valid = await crypto.subtle.verify("HMAC", key, signature, data);
|
||||
if (!valid) {
|
||||
throw new Error("Invalid JWT signature");
|
||||
}
|
||||
const payload = JSON.parse(new TextDecoder().decode(base64urlDecode(payloadB64)));
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (payload.exp && now > payload.exp + clockTolerance) {
|
||||
throw new Error("Token expired");
|
||||
}
|
||||
if (audience && payload.aud) {
|
||||
const auds = Array.isArray(payload.aud) ? payload.aud : [payload.aud];
|
||||
if (!auds.some((a) => a.includes(audience))) {
|
||||
throw new Error(`Token audience mismatch: expected ${audience}`);
|
||||
}
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
async function verifyRemotely(token, serverUrl) {
|
||||
const res = await fetch(`${serverUrl}/api/session/verify`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.valid) {
|
||||
throw new Error(data.error || "Invalid token");
|
||||
}
|
||||
const parts = token.split(".");
|
||||
if (parts.length >= 2) {
|
||||
try {
|
||||
const payload = JSON.parse(new TextDecoder().decode(base64urlDecode(parts[1])));
|
||||
return payload;
|
||||
} catch {}
|
||||
}
|
||||
return {
|
||||
iss: serverUrl,
|
||||
sub: data.userId,
|
||||
aud: [],
|
||||
iat: 0,
|
||||
exp: data.exp || 0,
|
||||
jti: "",
|
||||
username: data.username,
|
||||
did: data.did,
|
||||
eid: {
|
||||
authLevel: 2,
|
||||
authTime: 0,
|
||||
capabilities: { encrypt: true, sign: true, wallet: false },
|
||||
recoveryConfigured: false
|
||||
}
|
||||
};
|
||||
}
|
||||
function getAuthLevel(claims) {
|
||||
if (!claims.eid)
|
||||
return 1;
|
||||
const authAge = Math.floor(Date.now() / 1000) - claims.eid.authTime;
|
||||
if (authAge < 60)
|
||||
return 3;
|
||||
if (authAge < 15 * 60)
|
||||
return 2;
|
||||
return 1;
|
||||
}
|
||||
function checkPermission(claims, permission) {
|
||||
const currentLevel = getAuthLevel(claims);
|
||||
if (currentLevel < permission.minAuthLevel) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `Requires auth level ${permission.minAuthLevel} (current: ${currentLevel})`
|
||||
};
|
||||
}
|
||||
if (permission.requiresCapability) {
|
||||
const has = claims.eid?.capabilities?.[permission.requiresCapability];
|
||||
if (!has) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `Requires ${permission.requiresCapability} capability`
|
||||
};
|
||||
}
|
||||
}
|
||||
if (permission.maxAgeSeconds) {
|
||||
const authAge = Math.floor(Date.now() / 1000) - (claims.eid?.authTime || 0);
|
||||
if (authAge > permission.maxAgeSeconds) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `Authentication too old (${authAge}s > ${permission.maxAgeSeconds}s)`
|
||||
};
|
||||
}
|
||||
}
|
||||
return { allowed: true };
|
||||
}
|
||||
function base64urlDecode(str) {
|
||||
const base64 = str.replace(/-/g, "+").replace(/_/g, "/");
|
||||
const padding = "=".repeat((4 - base64.length % 4) % 4);
|
||||
const binary = atob(base64 + padding);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0;i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return bytes.buffer;
|
||||
}
|
||||
|
||||
export { verifyEncryptIDToken, getAuthLevel, checkPermission };
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
/**
|
||||
* @encryptid/sdk — Self-Sovereign Identity SDK
|
||||
*
|
||||
* WebAuthn passkey authentication with derived keys, social recovery,
|
||||
* and cross-app SSO for the r-ecosystem.
|
||||
*/
|
||||
export type { EncryptIDCredential, AuthenticationResult, EncryptIDConfig, WebAuthnCapabilities, DerivedKeys, EncryptedData, SignedData, EncryptIDClaims, SessionState, OperationPermission, Guardian, RecoveryConfig, RecoveryRequest, RegistrationStartResponse, RegistrationCompleteResponse, AuthStartResponse, AuthCompleteResponse, SessionVerifyResponse, EmailRecoverySetResponse, EmailRecoveryRequestResponse, EmailRecoveryVerifyResponse, } from './types/index.js';
|
||||
export { AuthLevel, GuardianType } from './types/index.js';
|
||||
export { EncryptIDClient } from './client/api-client.js';
|
||||
export { registerPasskey, authenticatePasskey, startConditionalUI, detectCapabilities, bufferToBase64url, base64urlToBuffer, } from './client/webauthn.js';
|
||||
export { EncryptIDKeyManager, getKeyManager, signEthHash } from './client/key-derivation.js';
|
||||
export { SessionManager, getSessionManager, OPERATION_PERMISSIONS } from './client/session.js';
|
||||
export { RecoveryManager, getRecoveryManager, getGuardianTypeInfo } from './client/recovery.js';
|
||||
export declare const VERSION = "0.1.0";
|
||||
export declare const SPEC_VERSION = "2026-02";
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,129 @@
|
|||
{
|
||||
"name": "@encryptid/sdk",
|
||||
"version": "0.1.0",
|
||||
"description": "Unified identity SDK for the r-ecosystem — WebAuthn passkeys, key derivation, session management, and social recovery",
|
||||
"type": "module",
|
||||
"main": "./index.js",
|
||||
"types": "./index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./index.js",
|
||||
"types": "./index.d.ts"
|
||||
},
|
||||
"./client": {
|
||||
"import": "./client/index.js",
|
||||
"types": "./client/index.d.ts"
|
||||
},
|
||||
"./server": {
|
||||
"import": "./server/index.js",
|
||||
"types": "./server/index.d.ts"
|
||||
},
|
||||
"./server/nextjs": {
|
||||
"import": "./server/middleware/nextjs.js",
|
||||
"types": "./server/middleware/nextjs.d.ts"
|
||||
},
|
||||
"./server/hono": {
|
||||
"import": "./server/middleware/hono.js",
|
||||
"types": "./server/middleware/hono.d.ts"
|
||||
},
|
||||
"./server/express": {
|
||||
"import": "./server/middleware/express.js",
|
||||
"types": "./server/middleware/express.d.ts"
|
||||
},
|
||||
"./server/space-auth": {
|
||||
"import": "./server/space-auth.js",
|
||||
"types": "./server/space-auth.d.ts"
|
||||
},
|
||||
"./server/ws-auth": {
|
||||
"import": "./server/ws-auth.js",
|
||||
"types": "./server/ws-auth.d.ts"
|
||||
},
|
||||
"./ui": {
|
||||
"import": "./ui/index.js",
|
||||
"types": "./ui/index.d.ts"
|
||||
},
|
||||
"./ui/react": {
|
||||
"import": "./ui/react/index.js",
|
||||
"types": "./ui/react/index.d.ts"
|
||||
},
|
||||
"./types": {
|
||||
"import": "./types/index.js",
|
||||
"types": "./types/index.d.ts"
|
||||
},
|
||||
"./types/roles": {
|
||||
"import": "./types/roles.js",
|
||||
"types": "./types/roles.d.ts"
|
||||
},
|
||||
"./types/module-permissions": {
|
||||
"import": "./types/module-permissions.js",
|
||||
"types": "./types/module-permissions.d.ts"
|
||||
},
|
||||
"./types/modules": {
|
||||
"import": "./types/modules/index.js",
|
||||
"types": "./types/modules/index.d.ts"
|
||||
},
|
||||
"./server/role-resolver": {
|
||||
"import": "./server/role-resolver.js",
|
||||
"types": "./server/role-resolver.d.ts"
|
||||
},
|
||||
"./types/membership-events": {
|
||||
"import": "./types/membership-events.js",
|
||||
"types": "./types/membership-events.d.ts"
|
||||
},
|
||||
"./client/token-relay": {
|
||||
"import": "./client/token-relay.js",
|
||||
"types": "./client/token-relay.d.ts"
|
||||
},
|
||||
"./browser": {
|
||||
"import": "./encryptid.browser.js",
|
||||
"default": "./encryptid.browser.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src/python",
|
||||
"src/browser.ts"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "bun build ./src/index.ts --outdir ./dist --target browser && tsc --emitDeclarationOnly",
|
||||
"build:browser": "bun build ./src/browser.ts --outfile ./encryptid.browser.js --target browser --minify",
|
||||
"build:node": "bun build ./src/server/index.ts --outdir ./server --target node",
|
||||
"build:all": "bun run build && bun run build:browser",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@noble/curves": "^2.0.1",
|
||||
"@noble/hashes": "^2.0.1",
|
||||
"hono": "^4.11.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"next": ">=14.0.0",
|
||||
"react": ">=18.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"next": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.0",
|
||||
"typescript": "^5.7.0"
|
||||
},
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://gitea.jeffemmett.com/jeffemmett/encryptid-sdk"
|
||||
},
|
||||
"keywords": [
|
||||
"webauthn",
|
||||
"passkey",
|
||||
"identity",
|
||||
"encryption",
|
||||
"self-sovereign",
|
||||
"social-recovery"
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
/**
|
||||
* @encryptid/sdk/server — Server-side module
|
||||
*/
|
||||
export { verifyEncryptIDToken, getAuthLevel, checkPermission, } from './jwt-verify.js';
|
||||
export type { VerifyOptions } from './jwt-verify.js';
|
||||
export { evaluateSpaceAccess, extractToken, SpaceVisibility, } from './space-auth.js';
|
||||
export type { SpaceAuthConfig, SpaceAuthResult, SpaceAuthOptions } from './space-auth.js';
|
||||
export { authenticateWSUpgrade } from './ws-auth.js';
|
||||
export { resolveSpaceRole, resolveSpaceRoleRemote, createRemoteMembershipLookup, invalidateRoleCache } from './role-resolver.js';
|
||||
export type { RoleResolverOptions, RemoteResolverOptions } from './role-resolver.js';
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
import {
|
||||
evaluateSpaceAccess,
|
||||
extractToken
|
||||
} from "../index-j6kh1974.js";
|
||||
import {
|
||||
SpaceVisibility
|
||||
} from "../index-5c1t4ftn.js";
|
||||
import {
|
||||
authenticateWSUpgrade
|
||||
} from "../index-2yszamrn.js";
|
||||
import {
|
||||
checkPermission,
|
||||
getAuthLevel,
|
||||
verifyEncryptIDToken
|
||||
} from "../index-stg63j73.js";
|
||||
export {
|
||||
verifyEncryptIDToken,
|
||||
getAuthLevel,
|
||||
extractToken,
|
||||
evaluateSpaceAccess,
|
||||
checkPermission,
|
||||
authenticateWSUpgrade,
|
||||
SpaceVisibility
|
||||
};
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
/**
|
||||
* EncryptID JWT Verification
|
||||
*
|
||||
* Server-side utilities for verifying EncryptID JWT tokens.
|
||||
* Can verify locally with shared secret or by calling the EncryptID server.
|
||||
*/
|
||||
import type { EncryptIDClaims, OperationPermission } from '../types/index.js';
|
||||
export interface VerifyOptions {
|
||||
/** JWT secret for local verification (HS256) */
|
||||
secret?: string;
|
||||
/** EncryptID server URL for remote verification */
|
||||
serverUrl?: string;
|
||||
/** Expected audience (your app's origin) */
|
||||
audience?: string;
|
||||
/** Clock tolerance in seconds for expiration check */
|
||||
clockTolerance?: number;
|
||||
}
|
||||
/**
|
||||
* Verify an EncryptID JWT token
|
||||
*
|
||||
* If `secret` is provided, verifies locally using HMAC-SHA256.
|
||||
* Otherwise, calls the EncryptID server's /api/session/verify endpoint.
|
||||
*/
|
||||
export declare function verifyEncryptIDToken(token: string, options?: VerifyOptions): Promise<EncryptIDClaims>;
|
||||
/**
|
||||
* Extract the auth level from claims
|
||||
*/
|
||||
export declare function getAuthLevel(claims: EncryptIDClaims): number;
|
||||
/**
|
||||
* Check if claims satisfy an operation permission
|
||||
*/
|
||||
export declare function checkPermission(claims: EncryptIDClaims, permission: OperationPermission): {
|
||||
allowed: boolean;
|
||||
reason?: string;
|
||||
};
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
/**
|
||||
* EncryptID Express Middleware
|
||||
*
|
||||
* Authentication middleware for Express and compatible frameworks.
|
||||
*/
|
||||
import { type VerifyOptions } from '../jwt-verify.js';
|
||||
import { type SpaceAuthOptions } from '../space-auth.js';
|
||||
import type { EncryptIDClaims, SpaceAuthResult } from '../../types/index.js';
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
encryptid?: EncryptIDClaims;
|
||||
spaceAuth?: SpaceAuthResult;
|
||||
}
|
||||
}
|
||||
}
|
||||
interface ExpressRequest {
|
||||
headers: Record<string, string | string[] | undefined>;
|
||||
method: string;
|
||||
encryptid?: EncryptIDClaims;
|
||||
spaceAuth?: SpaceAuthResult;
|
||||
}
|
||||
interface ExpressResponse {
|
||||
status(code: number): ExpressResponse;
|
||||
json(body: unknown): void;
|
||||
}
|
||||
type NextFunction = () => void;
|
||||
/**
|
||||
* Express middleware that verifies EncryptID JWT tokens
|
||||
*
|
||||
* Usage:
|
||||
* ```ts
|
||||
* import express from 'express';
|
||||
* import { encryptIDAuth } from '@encryptid/sdk/server/express';
|
||||
*
|
||||
* const app = express();
|
||||
*
|
||||
* app.use('/api', encryptIDAuth());
|
||||
*
|
||||
* app.get('/api/profile', (req, res) => {
|
||||
* res.json({ did: req.encryptid.did });
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export declare function encryptIDAuth(options?: VerifyOptions): (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => Promise<void>;
|
||||
/**
|
||||
* Optional auth — sets session if token present, continues either way
|
||||
*/
|
||||
export declare function encryptIDOptional(options?: VerifyOptions): (req: ExpressRequest, _res: ExpressResponse, next: NextFunction) => Promise<void>;
|
||||
export interface EncryptIDSpaceAuthConfig extends SpaceAuthOptions {
|
||||
/** Function to extract space slug from request (default: req.params.slug) */
|
||||
getSlug?: (req: ExpressRequest & {
|
||||
params?: Record<string, string>;
|
||||
}) => string;
|
||||
}
|
||||
/**
|
||||
* Express middleware for space-aware auth.
|
||||
*
|
||||
* Usage:
|
||||
* ```ts
|
||||
* app.use('/api/spaces/:slug', encryptIDSpaceAuth({
|
||||
* getSpaceConfig: async (slug) => db.getSpace(slug),
|
||||
* }));
|
||||
*
|
||||
* app.get('/api/spaces/:slug', (req, res) => {
|
||||
* const { readOnly, isOwner } = req.spaceAuth;
|
||||
* res.json({ canEdit: !readOnly, isOwner });
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export declare function encryptIDSpaceAuth(config: EncryptIDSpaceAuthConfig): (req: ExpressRequest & {
|
||||
params?: Record<string, string>;
|
||||
}, res: ExpressResponse, next: NextFunction) => Promise<void>;
|
||||
export {};
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
import {
|
||||
evaluateSpaceAccess,
|
||||
extractToken
|
||||
} from "../../index-j6kh1974.js";
|
||||
import"../../index-5c1t4ftn.js";
|
||||
import {
|
||||
verifyEncryptIDToken
|
||||
} from "../../index-stg63j73.js";
|
||||
|
||||
// src/server/middleware/express.ts
|
||||
function encryptIDAuth(options = {}) {
|
||||
return async (req, res, next) => {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader || typeof authHeader !== "string" || !authHeader.startsWith("Bearer ")) {
|
||||
res.status(401).json({ error: "Missing EncryptID token" });
|
||||
return;
|
||||
}
|
||||
const token = authHeader.slice(7);
|
||||
try {
|
||||
req.encryptid = await verifyEncryptIDToken(token, options);
|
||||
next();
|
||||
} catch (err) {
|
||||
res.status(401).json({ error: err.message || "Invalid token" });
|
||||
}
|
||||
};
|
||||
}
|
||||
function encryptIDOptional(options = {}) {
|
||||
return async (req, _res, next) => {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (authHeader && typeof authHeader === "string" && authHeader.startsWith("Bearer ")) {
|
||||
try {
|
||||
req.encryptid = await verifyEncryptIDToken(authHeader.slice(7), options);
|
||||
} catch {}
|
||||
}
|
||||
next();
|
||||
};
|
||||
}
|
||||
function encryptIDSpaceAuth(config) {
|
||||
const { getSlug, ...options } = config;
|
||||
return async (req, res, next) => {
|
||||
const slug = getSlug ? getSlug(req) : req.params?.slug || "";
|
||||
const token = extractToken(req.headers);
|
||||
const result = await evaluateSpaceAccess(slug, token, req.method, options);
|
||||
if (!result.allowed) {
|
||||
res.status(result.claims ? 403 : 401).json({ error: result.reason });
|
||||
return;
|
||||
}
|
||||
if (result.claims) {
|
||||
req.encryptid = result.claims;
|
||||
}
|
||||
req.spaceAuth = result;
|
||||
next();
|
||||
};
|
||||
}
|
||||
export {
|
||||
encryptIDSpaceAuth,
|
||||
encryptIDOptional,
|
||||
encryptIDAuth
|
||||
};
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
/**
|
||||
* EncryptID Hono Middleware
|
||||
*
|
||||
* Authentication middleware for Hono web framework.
|
||||
*/
|
||||
import type { MiddlewareHandler } from 'hono';
|
||||
import { type VerifyOptions } from '../jwt-verify.js';
|
||||
import { type SpaceAuthOptions } from '../space-auth.js';
|
||||
import type { EncryptIDClaims, SpaceAuthResult } from '../../types/index.js';
|
||||
import type { ResolvedRole } from '../../types/roles.js';
|
||||
declare module 'hono' {
|
||||
interface ContextVariableMap {
|
||||
encryptid: EncryptIDClaims;
|
||||
spaceAuth: SpaceAuthResult;
|
||||
spaceRole: ResolvedRole;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Hono middleware that verifies EncryptID JWT tokens
|
||||
*
|
||||
* Usage:
|
||||
* ```ts
|
||||
* import { Hono } from 'hono';
|
||||
* import { encryptIDAuth } from '@encryptid/sdk/server/hono';
|
||||
*
|
||||
* const app = new Hono();
|
||||
*
|
||||
* // Protect all /api routes
|
||||
* app.use('/api/*', encryptIDAuth());
|
||||
*
|
||||
* app.get('/api/profile', (c) => {
|
||||
* const session = c.get('encryptid');
|
||||
* return c.json({ did: session.did, sub: session.sub });
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export declare function encryptIDAuth(options?: VerifyOptions): MiddlewareHandler;
|
||||
/**
|
||||
* Optional auth — sets session if token present, continues either way
|
||||
*/
|
||||
export declare function encryptIDOptional(options?: VerifyOptions): MiddlewareHandler;
|
||||
export interface EncryptIDSpaceAuthConfig extends SpaceAuthOptions {
|
||||
/** Route param name for the space slug (default: 'slug') */
|
||||
slugParam?: string;
|
||||
/** Query param fallback for the space slug (default: 'space') */
|
||||
slugQuery?: string;
|
||||
}
|
||||
/**
|
||||
* Hono middleware for space-aware auth.
|
||||
*
|
||||
* Reads the space slug from route params or query, evaluates access
|
||||
* based on visibility, and sets `c.var.spaceAuth` with the result.
|
||||
*
|
||||
* Usage:
|
||||
* ```ts
|
||||
* app.use('/api/communities/:slug/*', encryptIDSpaceAuth({
|
||||
* getSpaceConfig: async (slug) => db.getCommunity(slug),
|
||||
* }));
|
||||
*
|
||||
* app.get('/api/communities/:slug', (c) => {
|
||||
* const auth = c.get('spaceAuth');
|
||||
* if (auth.readOnly) { // public_read, unauthenticated
|
||||
* return c.json({ ...community, canEdit: false });
|
||||
* }
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export declare function encryptIDSpaceAuth(config: EncryptIDSpaceAuthConfig): MiddlewareHandler;
|
||||
export interface EncryptIDSpaceRoleConfig extends EncryptIDSpaceAuthConfig {
|
||||
/** Look up membership for a DID in a space. You provide the DB query. */
|
||||
getMembership: (userDID: string, spaceSlug: string) => Promise<import('../../types/roles.js').SpaceMembership | null>;
|
||||
/** Resolve visibility for a space slug (if not in SpaceAuthConfig). Defaults to using getSpaceConfig. */
|
||||
getVisibility?: (spaceSlug: string) => Promise<import('../../types/index.js').SpaceVisibility>;
|
||||
}
|
||||
/**
|
||||
* Combined space auth + role resolution middleware for Hono.
|
||||
*
|
||||
* Sets `c.var.spaceAuth`, `c.var.spaceRole`, and optionally `c.var.encryptid`.
|
||||
*
|
||||
* Usage:
|
||||
* ```ts
|
||||
* import { encryptIDSpaceRoleAuth } from '@encryptid/sdk/server/hono';
|
||||
* import { hasCapability } from '@encryptid/sdk';
|
||||
* import { RVOTE_PERMISSIONS } from '@encryptid/sdk/types/modules';
|
||||
*
|
||||
* app.use('/api/spaces/:slug/*', encryptIDSpaceRoleAuth({
|
||||
* getSpaceConfig: async (slug) => db.getSpace(slug),
|
||||
* getMembership: async (did, slug) => db.getMembership(did, slug),
|
||||
* }));
|
||||
*
|
||||
* app.post('/api/spaces/:slug/proposals', (c) => {
|
||||
* const { role } = c.get('spaceRole');
|
||||
* if (!hasCapability(role, 'create_proposal', RVOTE_PERMISSIONS)) {
|
||||
* return c.json({ error: 'Insufficient permissions' }, 403);
|
||||
* }
|
||||
* // ...
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export declare function encryptIDSpaceRoleAuth(config: EncryptIDSpaceRoleConfig): MiddlewareHandler;
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
import {
|
||||
evaluateSpaceAccess,
|
||||
extractToken
|
||||
} from "../../index-j6kh1974.js";
|
||||
import"../../index-5c1t4ftn.js";
|
||||
import {
|
||||
verifyEncryptIDToken
|
||||
} from "../../index-stg63j73.js";
|
||||
|
||||
// src/server/middleware/hono.ts
|
||||
function encryptIDAuth(options = {}) {
|
||||
return async (c, next) => {
|
||||
const authHeader = c.req.header("Authorization");
|
||||
if (!authHeader?.startsWith("Bearer ")) {
|
||||
return c.json({ error: "Missing EncryptID token" }, 401);
|
||||
}
|
||||
const token = authHeader.slice(7);
|
||||
try {
|
||||
const claims = await verifyEncryptIDToken(token, options);
|
||||
c.set("encryptid", claims);
|
||||
await next();
|
||||
} catch (err) {
|
||||
return c.json({ error: err.message || "Invalid token" }, 401);
|
||||
}
|
||||
};
|
||||
}
|
||||
function encryptIDOptional(options = {}) {
|
||||
return async (c, next) => {
|
||||
const authHeader = c.req.header("Authorization");
|
||||
if (authHeader?.startsWith("Bearer ")) {
|
||||
try {
|
||||
const claims = await verifyEncryptIDToken(authHeader.slice(7), options);
|
||||
c.set("encryptid", claims);
|
||||
} catch {}
|
||||
}
|
||||
await next();
|
||||
};
|
||||
}
|
||||
function encryptIDSpaceAuth(config) {
|
||||
const { slugParam = "slug", slugQuery = "space", ...options } = config;
|
||||
return async (c, next) => {
|
||||
const spaceSlug = c.req.param(slugParam) || c.req.query(slugQuery) || "";
|
||||
const token = extractToken(c.req.raw.headers);
|
||||
const result = await evaluateSpaceAccess(spaceSlug, token, c.req.method, options);
|
||||
if (!result.allowed) {
|
||||
return c.json({ error: result.reason }, result.claims ? 403 : 401);
|
||||
}
|
||||
if (result.claims) {
|
||||
c.set("encryptid", result.claims);
|
||||
}
|
||||
c.set("spaceAuth", result);
|
||||
await next();
|
||||
};
|
||||
}
|
||||
export {
|
||||
encryptIDSpaceAuth,
|
||||
encryptIDOptional,
|
||||
encryptIDAuth
|
||||
};
|
||||
|
|
@ -0,0 +1,136 @@
|
|||
/**
|
||||
* EncryptID Next.js Middleware
|
||||
*
|
||||
* Helpers for protecting Next.js App Router routes and API endpoints.
|
||||
*/
|
||||
import { type VerifyOptions } from '../jwt-verify.js';
|
||||
import { type SpaceAuthOptions } from '../space-auth.js';
|
||||
import type { EncryptIDClaims, SpaceAuthResult, SpaceVisibility } from '../../types/index.js';
|
||||
import type { ResolvedRole, SpaceMembership } from '../../types/roles.js';
|
||||
export interface EncryptIDNextConfig extends VerifyOptions {
|
||||
/** Paths that don't require authentication */
|
||||
publicPaths?: string[];
|
||||
/** Redirect URL for unauthenticated requests (null = return 401) */
|
||||
loginUrl?: string | null;
|
||||
}
|
||||
/**
|
||||
* Get EncryptID session from a Next.js request
|
||||
*
|
||||
* Usage in API routes:
|
||||
* ```ts
|
||||
* import { getEncryptIDSession } from '@encryptid/sdk/server/nextjs';
|
||||
*
|
||||
* export async function GET(req: Request) {
|
||||
* const session = await getEncryptIDSession(req);
|
||||
* if (!session) return new Response('Unauthorized', { status: 401 });
|
||||
* // session.sub, session.did, session.eid.authLevel, etc.
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export declare function getEncryptIDSession(request: Request, options?: VerifyOptions): Promise<EncryptIDClaims | null>;
|
||||
/**
|
||||
* Protect a Next.js API route handler
|
||||
*
|
||||
* Usage:
|
||||
* ```ts
|
||||
* import { withEncryptID } from '@encryptid/sdk/server/nextjs';
|
||||
*
|
||||
* export const GET = withEncryptID(async (req, session) => {
|
||||
* return Response.json({ user: session.sub, did: session.did });
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export declare function withEncryptID(handler: (request: Request, session: EncryptIDClaims) => Promise<Response>, options?: VerifyOptions): (request: Request) => Promise<Response>;
|
||||
/**
|
||||
* Create Next.js middleware for EncryptID
|
||||
*
|
||||
* Usage in middleware.ts:
|
||||
* ```ts
|
||||
* import { createEncryptIDMiddleware } from '@encryptid/sdk/server/nextjs';
|
||||
*
|
||||
* const encryptIDMiddleware = createEncryptIDMiddleware({
|
||||
* publicPaths: ['/auth/signin', '/api/auth'],
|
||||
* loginUrl: '/auth/signin',
|
||||
* });
|
||||
*
|
||||
* export function middleware(request: NextRequest) {
|
||||
* return encryptIDMiddleware(request);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export declare function createEncryptIDMiddleware(config?: EncryptIDNextConfig): (request: Request) => Promise<Response | null>;
|
||||
/**
|
||||
* Check space access in a Next.js API route or server component.
|
||||
*
|
||||
* Usage:
|
||||
* ```ts
|
||||
* const result = await checkSpaceAccess(request, spaceSlug, {
|
||||
* getSpaceConfig: async (slug) => {
|
||||
* const space = await prisma.space.findUnique({ where: { slug } });
|
||||
* if (!space) return null;
|
||||
* return { spaceSlug: slug, visibility: space.visibility, app: 'rvote' };
|
||||
* },
|
||||
* });
|
||||
* if (!result.allowed) return new Response(result.reason, { status: 401 });
|
||||
* ```
|
||||
*/
|
||||
export declare function checkSpaceAccess(request: Request, spaceSlug: string, options: SpaceAuthOptions): Promise<SpaceAuthResult>;
|
||||
/**
|
||||
* HOC that wraps a Next.js API route handler with space auth.
|
||||
*
|
||||
* Usage:
|
||||
* ```ts
|
||||
* export const POST = withSpaceAuth(
|
||||
* async (req, spaceAuth, slug) => {
|
||||
* return Response.json({ owner: spaceAuth.isOwner });
|
||||
* },
|
||||
* (req) => new URL(req.url).pathname.split('/')[2],
|
||||
* { getSpaceConfig: async (slug) => { ... } },
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
export declare function withSpaceAuth(handler: (request: Request, spaceAuth: SpaceAuthResult, spaceSlug: string) => Promise<Response>, getSlug: (request: Request) => string, options: SpaceAuthOptions): (request: Request) => Promise<Response>;
|
||||
export interface SpaceRoleOptions extends SpaceAuthOptions {
|
||||
/** Look up membership for a DID in a space. You provide the DB query. */
|
||||
getMembership: (userDID: string, spaceSlug: string) => Promise<SpaceMembership | null>;
|
||||
/** Resolve visibility for a space slug. If not provided, uses getSpaceConfig. */
|
||||
getVisibility?: (spaceSlug: string) => Promise<SpaceVisibility>;
|
||||
}
|
||||
export interface SpaceRoleResult {
|
||||
spaceAuth: SpaceAuthResult;
|
||||
resolvedRole: ResolvedRole;
|
||||
}
|
||||
/**
|
||||
* Check space access AND resolve role in one call.
|
||||
*
|
||||
* Usage:
|
||||
* ```ts
|
||||
* const result = await checkSpaceRole(request, slug, {
|
||||
* getSpaceConfig: async (slug) => prisma.space.findUnique({ where: { slug } }),
|
||||
* getMembership: async (did, slug) => prisma.spaceMember.findUnique({
|
||||
* where: { userDID_spaceSlug: { userDID: did, spaceSlug: slug } }
|
||||
* }),
|
||||
* });
|
||||
* if (!result.spaceAuth.allowed) return deny();
|
||||
* if (hasCapability(result.resolvedRole.role, 'create_proposal', RVOTE_PERMISSIONS)) { ... }
|
||||
* ```
|
||||
*/
|
||||
export declare function checkSpaceRole(request: Request, spaceSlug: string, options: SpaceRoleOptions): Promise<SpaceRoleResult>;
|
||||
/**
|
||||
* HOC that wraps a Next.js API route handler with space auth + role resolution.
|
||||
*
|
||||
* Usage:
|
||||
* ```ts
|
||||
* export const POST = withSpaceRole(
|
||||
* async (req, spaceAuth, role, slug) => {
|
||||
* if (!hasCapability(role.role, 'create_proposal', RVOTE_PERMISSIONS)) {
|
||||
* return Response.json({ error: 'Forbidden' }, { status: 403 });
|
||||
* }
|
||||
* return Response.json({ created: true });
|
||||
* },
|
||||
* (req) => new URL(req.url).pathname.split('/')[3], // extract slug
|
||||
* { getSpaceConfig: ..., getMembership: ... },
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
export declare function withSpaceRole(handler: (request: Request, spaceAuth: SpaceAuthResult, resolvedRole: ResolvedRole, spaceSlug: string) => Promise<Response>, getSlug: (request: Request) => string, options: SpaceRoleOptions): (request: Request) => Promise<Response>;
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
import {
|
||||
evaluateSpaceAccess,
|
||||
extractToken
|
||||
} from "../../index-j6kh1974.js";
|
||||
import"../../index-5c1t4ftn.js";
|
||||
import {
|
||||
verifyEncryptIDToken
|
||||
} from "../../index-stg63j73.js";
|
||||
|
||||
// src/server/middleware/nextjs.ts
|
||||
async function getEncryptIDSession(request, options = {}) {
|
||||
const authHeader = request.headers.get("Authorization");
|
||||
if (authHeader?.startsWith("Bearer ")) {
|
||||
try {
|
||||
return await verifyEncryptIDToken(authHeader.slice(7), options);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const cookieHeader = request.headers.get("Cookie") || "";
|
||||
const tokenMatch = cookieHeader.match(/encryptid_token=([^;]+)/);
|
||||
if (tokenMatch) {
|
||||
try {
|
||||
return await verifyEncryptIDToken(tokenMatch[1], options);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function withEncryptID(handler, options = {}) {
|
||||
return async (request) => {
|
||||
const session = await getEncryptIDSession(request, options);
|
||||
if (!session) {
|
||||
return new Response(JSON.stringify({ error: "Unauthorized" }), {
|
||||
status: 401,
|
||||
headers: { "Content-Type": "application/json" }
|
||||
});
|
||||
}
|
||||
return handler(request, session);
|
||||
};
|
||||
}
|
||||
function createEncryptIDMiddleware(config = {}) {
|
||||
const { publicPaths = [], loginUrl = null, ...verifyOptions } = config;
|
||||
return async (request) => {
|
||||
const url = new URL(request.url);
|
||||
if (publicPaths.some((p) => url.pathname.startsWith(p))) {
|
||||
return null;
|
||||
}
|
||||
const session = await getEncryptIDSession(request, verifyOptions);
|
||||
if (!session) {
|
||||
if (loginUrl) {
|
||||
return Response.redirect(new URL(loginUrl, request.url));
|
||||
}
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
return null;
|
||||
};
|
||||
}
|
||||
async function checkSpaceAccess(request, spaceSlug, options) {
|
||||
const token = extractToken(request.headers);
|
||||
return evaluateSpaceAccess(spaceSlug, token, request.method, options);
|
||||
}
|
||||
function withSpaceAuth(handler, getSlug, options) {
|
||||
return async (request) => {
|
||||
const slug = getSlug(request);
|
||||
const result = await checkSpaceAccess(request, slug, options);
|
||||
if (!result.allowed) {
|
||||
return new Response(JSON.stringify({ error: result.reason }), {
|
||||
status: result.claims ? 403 : 401,
|
||||
headers: { "Content-Type": "application/json" }
|
||||
});
|
||||
}
|
||||
return handler(request, result, slug);
|
||||
};
|
||||
}
|
||||
export {
|
||||
withSpaceAuth,
|
||||
withEncryptID,
|
||||
getEncryptIDSession,
|
||||
createEncryptIDMiddleware,
|
||||
checkSpaceAccess
|
||||
};
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
/**
|
||||
* EncryptID Space Role Resolver
|
||||
*
|
||||
* Resolves a user's effective SpaceRole given their space access result
|
||||
* and a membership lookup function. This is the bridge between
|
||||
* evaluateSpaceAccess() (layer 1: "can they enter?") and
|
||||
* hasCapability() (layer 3: "what can they do?").
|
||||
*/
|
||||
import type { ResolvedRole, SpaceMembership } from '../types/roles.js';
|
||||
import type { SpaceAuthResult } from '../types/index.js';
|
||||
import { SpaceVisibility } from '../types/index.js';
|
||||
export interface RoleResolverOptions {
|
||||
/**
|
||||
* Look up membership for a DID in a space.
|
||||
* You provide the DB query — works with Prisma, Automerge, raw SQL, etc.
|
||||
* Return null if no membership found.
|
||||
*/
|
||||
getMembership: (userDID: string, spaceSlug: string) => Promise<SpaceMembership | null>;
|
||||
/** The space's visibility setting */
|
||||
visibility: SpaceVisibility;
|
||||
}
|
||||
export interface RemoteResolverOptions {
|
||||
/** EncryptID server URL (e.g., https://encryptid.jeffemmett.com) */
|
||||
serverUrl: string;
|
||||
/** The space's visibility setting */
|
||||
visibility: SpaceVisibility;
|
||||
/** Cache TTL in milliseconds (default: 5 minutes) */
|
||||
cacheTtlMs?: number;
|
||||
}
|
||||
/**
|
||||
* Invalidate the role cache for a specific user/space or the entire cache.
|
||||
*/
|
||||
export declare function invalidateRoleCache(userDID?: string, spaceSlug?: string): void;
|
||||
/**
|
||||
* Create a getMembership function that calls the EncryptID server.
|
||||
* This is a convenience wrapper for modules that don't have local membership data.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const role = await resolveSpaceRole(spaceAuth, slug, {
|
||||
* visibility: 'public',
|
||||
* getMembership: createRemoteMembershipLookup('https://encryptid.jeffemmett.com'),
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export declare function createRemoteMembershipLookup(serverUrl: string): (userDID: string, spaceSlug: string) => Promise<SpaceMembership | null>;
|
||||
/**
|
||||
* Resolve a user's effective SpaceRole in a space.
|
||||
*
|
||||
* Decision flow:
|
||||
* 1. Owner → ADMIN (always)
|
||||
* 2. Has explicit membership → membership.role
|
||||
* 3. No membership, apply defaults based on visibility:
|
||||
* - PUBLIC: anonymous & authenticated → PARTICIPANT
|
||||
* - PUBLIC_READ: anonymous → VIEWER, authenticated → PARTICIPANT
|
||||
* - AUTHENTICATED: → VIEWER (must have membership for more)
|
||||
* - MEMBERS_ONLY: should not reach here (denied at space access layer)
|
||||
*
|
||||
* @param spaceAuth - Result from evaluateSpaceAccess()
|
||||
* @param spaceSlug - The space identifier
|
||||
* @param options - Membership lookup and visibility config
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const spaceAuth = await evaluateSpaceAccess(slug, token, method, opts);
|
||||
* if (!spaceAuth.allowed) return deny();
|
||||
*
|
||||
* const { role, source } = await resolveSpaceRole(spaceAuth, slug, {
|
||||
* visibility: space.visibility,
|
||||
* getMembership: (did, slug) => db.membership.findUnique({ where: { did_slug: { did, slug } } }),
|
||||
* });
|
||||
*
|
||||
* if (hasCapability(role, 'create_proposal', RVOTE_PERMISSIONS)) { ... }
|
||||
* ```
|
||||
*/
|
||||
export declare function resolveSpaceRole(spaceAuth: SpaceAuthResult, spaceSlug: string, options: RoleResolverOptions): Promise<ResolvedRole>;
|
||||
/**
|
||||
* Resolve a user's SpaceRole by querying the EncryptID server.
|
||||
*
|
||||
* This is the recommended function for modules that don't maintain
|
||||
* their own membership table. It:
|
||||
* 1. Checks the in-memory cache (5-min TTL)
|
||||
* 2. If miss, queries EncryptID server for membership
|
||||
* 3. Falls back to visibility-based defaults on network error
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const { role, source } = await resolveSpaceRoleRemote(spaceAuth, slug, {
|
||||
* serverUrl: 'https://encryptid.jeffemmett.com',
|
||||
* visibility: space.visibility,
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export declare function resolveSpaceRoleRemote(spaceAuth: SpaceAuthResult, spaceSlug: string, options: RemoteResolverOptions): Promise<ResolvedRole>;
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
/**
|
||||
* EncryptID Space Auth Guard
|
||||
*
|
||||
* Framework-agnostic space-aware authentication.
|
||||
* Evaluates whether a request should be allowed based on:
|
||||
* 1. Space visibility configuration
|
||||
* 2. Request method (GET/HEAD/OPTIONS = read, others = write)
|
||||
* 3. EncryptID session (if present)
|
||||
*/
|
||||
import { type VerifyOptions } from './jwt-verify.js';
|
||||
import { SpaceVisibility } from '../types/index.js';
|
||||
import type { SpaceAuthConfig, SpaceAuthResult } from '../types/index.js';
|
||||
export { SpaceVisibility };
|
||||
export type { SpaceAuthConfig, SpaceAuthResult };
|
||||
export interface SpaceAuthOptions extends VerifyOptions {
|
||||
/** Resolve a space slug to its auth config. You provide the DB/store query. */
|
||||
getSpaceConfig: (spaceSlug: string) => Promise<SpaceAuthConfig | null>;
|
||||
}
|
||||
/**
|
||||
* Core space auth evaluation — framework-agnostic.
|
||||
*
|
||||
* Apps call this with the space slug, the extracted token (or null),
|
||||
* the HTTP method, and a callback to look up the space's config.
|
||||
*/
|
||||
export declare function evaluateSpaceAccess(spaceSlug: string, token: string | null, method: string, options: SpaceAuthOptions): Promise<SpaceAuthResult>;
|
||||
/**
|
||||
* Extract EncryptID token from request headers or cookies.
|
||||
* Works with both the standard Headers API (fetch/Hono/Next.js) and
|
||||
* Express-style header objects.
|
||||
*/
|
||||
export declare function extractToken(headers: {
|
||||
get?: (name: string) => string | null | undefined;
|
||||
authorization?: string;
|
||||
cookie?: string;
|
||||
}): string | null;
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
import {
|
||||
evaluateSpaceAccess,
|
||||
extractToken
|
||||
} from "../index-j6kh1974.js";
|
||||
import {
|
||||
SpaceVisibility
|
||||
} from "../index-5c1t4ftn.js";
|
||||
import"../index-stg63j73.js";
|
||||
export {
|
||||
extractToken,
|
||||
evaluateSpaceAccess,
|
||||
SpaceVisibility
|
||||
};
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
/**
|
||||
* EncryptID WebSocket Authentication
|
||||
*
|
||||
* Since WebSocket upgrade requests carry the initial HTTP headers,
|
||||
* we verify the token during the upgrade handshake.
|
||||
*
|
||||
* Supported token locations (checked in order):
|
||||
* 1. `token` query parameter: ws://host/ws?token=xxx
|
||||
* 2. Sec-WebSocket-Protocol subprotocol: "encryptid.TOKEN_HERE"
|
||||
* 3. Cookie: encryptid_token=xxx
|
||||
*/
|
||||
import { type VerifyOptions } from './jwt-verify.js';
|
||||
import type { EncryptIDClaims } from '../types/index.js';
|
||||
/**
|
||||
* Authenticate a WebSocket upgrade request.
|
||||
* Returns claims if a valid token is found, null otherwise.
|
||||
*/
|
||||
export declare function authenticateWSUpgrade(request: Request, options?: VerifyOptions): Promise<EncryptIDClaims | null>;
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import {
|
||||
authenticateWSUpgrade
|
||||
} from "../index-2yszamrn.js";
|
||||
import"../index-stg63j73.js";
|
||||
export {
|
||||
authenticateWSUpgrade
|
||||
};
|
||||
|
|
@ -0,0 +1,246 @@
|
|||
/**
|
||||
* EncryptID SDK — Shared Types
|
||||
*/
|
||||
export interface EncryptIDCredential {
|
||||
credentialId: string;
|
||||
publicKey: ArrayBuffer;
|
||||
userId: string;
|
||||
username: string;
|
||||
createdAt: number;
|
||||
prfSupported: boolean;
|
||||
transports?: AuthenticatorTransport[];
|
||||
}
|
||||
export interface AuthenticationResult {
|
||||
credentialId: string;
|
||||
userId: string;
|
||||
prfOutput?: ArrayBuffer;
|
||||
signature: ArrayBuffer;
|
||||
authenticatorData: ArrayBuffer;
|
||||
}
|
||||
export interface EncryptIDConfig {
|
||||
rpId: string;
|
||||
rpName: string;
|
||||
origin: string;
|
||||
userVerification: UserVerificationRequirement;
|
||||
timeout: number;
|
||||
}
|
||||
export interface WebAuthnCapabilities {
|
||||
webauthn: boolean;
|
||||
platformAuthenticator: boolean;
|
||||
conditionalUI: boolean;
|
||||
prfExtension: boolean;
|
||||
}
|
||||
export interface DerivedKeys {
|
||||
encryptionKey: CryptoKey;
|
||||
signingKeyPair: CryptoKeyPair;
|
||||
didSeed: Uint8Array;
|
||||
did: string;
|
||||
fromPRF: boolean;
|
||||
/** Ethereum-compatible secp256k1 wallet derived from the same master key */
|
||||
ethereum?: {
|
||||
address: string;
|
||||
publicKey: Uint8Array;
|
||||
privateKey: Uint8Array;
|
||||
};
|
||||
}
|
||||
export interface EncryptedData {
|
||||
ciphertext: ArrayBuffer;
|
||||
iv: Uint8Array;
|
||||
tag?: ArrayBuffer;
|
||||
}
|
||||
export interface SignedData {
|
||||
data: ArrayBuffer;
|
||||
signature: ArrayBuffer;
|
||||
publicKey: ArrayBuffer;
|
||||
}
|
||||
export declare enum AuthLevel {
|
||||
BASIC = 1,
|
||||
STANDARD = 2,
|
||||
ELEVATED = 3,
|
||||
CRITICAL = 4
|
||||
}
|
||||
export interface EncryptIDClaims {
|
||||
iss: string;
|
||||
sub: string;
|
||||
aud: string[];
|
||||
iat: number;
|
||||
exp: number;
|
||||
jti: string;
|
||||
username: string;
|
||||
did?: string;
|
||||
eid: {
|
||||
walletAddress?: string;
|
||||
credentialId?: string;
|
||||
authLevel: AuthLevel;
|
||||
authTime: number;
|
||||
capabilities: {
|
||||
encrypt: boolean;
|
||||
sign: boolean;
|
||||
wallet: boolean;
|
||||
};
|
||||
recoveryConfigured: boolean;
|
||||
};
|
||||
}
|
||||
export interface SessionState {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
claims: EncryptIDClaims;
|
||||
lastAuthTime: number;
|
||||
}
|
||||
export interface OperationPermission {
|
||||
minAuthLevel: AuthLevel;
|
||||
requiresCapability?: 'encrypt' | 'sign' | 'wallet';
|
||||
maxAgeSeconds?: number;
|
||||
}
|
||||
export declare enum GuardianType {
|
||||
SECONDARY_PASSKEY = "secondary_passkey",
|
||||
TRUSTED_CONTACT = "trusted_contact",
|
||||
HARDWARE_KEY = "hardware_key",
|
||||
INSTITUTIONAL = "institutional",
|
||||
TIME_DELAYED_SELF = "time_delayed_self"
|
||||
}
|
||||
export interface Guardian {
|
||||
id: string;
|
||||
type: GuardianType;
|
||||
name: string;
|
||||
weight: number;
|
||||
credentialId?: string;
|
||||
contactDID?: string;
|
||||
contactEmail?: string;
|
||||
serviceUrl?: string;
|
||||
delaySeconds?: number;
|
||||
addedAt: number;
|
||||
lastVerified?: number;
|
||||
}
|
||||
export interface RecoveryConfig {
|
||||
threshold: number;
|
||||
delaySeconds: number;
|
||||
guardians: Guardian[];
|
||||
guardianListHash: string;
|
||||
updatedAt: number;
|
||||
}
|
||||
export interface RecoveryRequest {
|
||||
id: string;
|
||||
accountDID: string;
|
||||
newCredentialId: string;
|
||||
initiatedAt: number;
|
||||
completesAt: number;
|
||||
status: 'pending' | 'approved' | 'cancelled' | 'completed';
|
||||
approvals: {
|
||||
guardianId: string;
|
||||
approvedAt: number;
|
||||
signature: string;
|
||||
}[];
|
||||
approvalWeight: number;
|
||||
}
|
||||
export interface RegistrationStartResponse {
|
||||
options: {
|
||||
challenge: string;
|
||||
rp: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
user: {
|
||||
id: string;
|
||||
name: string;
|
||||
displayName: string;
|
||||
};
|
||||
pubKeyCredParams: {
|
||||
alg: number;
|
||||
type: string;
|
||||
}[];
|
||||
authenticatorSelection: Record<string, unknown>;
|
||||
timeout: number;
|
||||
attestation: string;
|
||||
extensions?: Record<string, unknown>;
|
||||
};
|
||||
userId: string;
|
||||
}
|
||||
export interface RegistrationCompleteResponse {
|
||||
success: boolean;
|
||||
userId: string;
|
||||
token: string;
|
||||
did: string;
|
||||
}
|
||||
export interface AuthStartResponse {
|
||||
options: {
|
||||
challenge: string;
|
||||
rpId: string;
|
||||
userVerification: string;
|
||||
timeout: number;
|
||||
allowCredentials?: {
|
||||
type: string;
|
||||
id: string;
|
||||
transports?: string[];
|
||||
}[];
|
||||
};
|
||||
}
|
||||
export interface AuthCompleteResponse {
|
||||
success: boolean;
|
||||
userId: string;
|
||||
username: string;
|
||||
token: string;
|
||||
did: string;
|
||||
}
|
||||
export interface SessionVerifyResponse {
|
||||
valid: boolean;
|
||||
userId?: string;
|
||||
username?: string;
|
||||
did?: string;
|
||||
exp?: number;
|
||||
error?: string;
|
||||
}
|
||||
export interface EmailRecoverySetResponse {
|
||||
success: boolean;
|
||||
email: string;
|
||||
}
|
||||
export interface EmailRecoveryRequestResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
}
|
||||
export interface EmailRecoveryVerifyResponse {
|
||||
success: boolean;
|
||||
token: string;
|
||||
userId: string;
|
||||
username: string;
|
||||
did: string;
|
||||
message: string;
|
||||
}
|
||||
export declare enum SpaceVisibility {
|
||||
/** Anyone can view and interact, no auth required */
|
||||
PUBLIC = "public",
|
||||
/** Anyone can view, auth required for write/interact */
|
||||
PUBLIC_READ = "public_read",
|
||||
/** Auth required for any access */
|
||||
AUTHENTICATED = "authenticated",
|
||||
/** Only space members can access (app must check membership separately) */
|
||||
MEMBERS_ONLY = "members_only"
|
||||
}
|
||||
export type AppName = 'rspace' | 'rvote' | 'rfiles' | 'rmaps' | 'rwallet' | 'rfunds' | 'rnotes' | 'rtrips' | 'rnetwork' | 'rcart' | 'rmail' | 'rcal' | 'rtube' | 'rstack' | 'canvas';
|
||||
export { SpaceRole, SPACE_ROLE_LEVEL, roleAtLeast } from './roles.js';
|
||||
export type { SpaceMembership, ResolvedRole } from './roles.js';
|
||||
export { hasCapability, getCapabilities } from './module-permissions.js';
|
||||
export type { ModulePermissionMap } from './module-permissions.js';
|
||||
export type { SpaceMembershipEvent, SpaceMembershipEventType, AddMemberRequest, UpdateMemberRequest, MemberResponse, MemberListResponse, } from './membership-events.js';
|
||||
export interface SpaceAuthConfig {
|
||||
/** Space identifier (slug) */
|
||||
spaceSlug: string;
|
||||
/** Who can see/interact with this space */
|
||||
visibility: SpaceVisibility;
|
||||
/** DID of the space creator/owner */
|
||||
ownerDID?: string;
|
||||
/** App this space belongs to */
|
||||
app: AppName;
|
||||
}
|
||||
export interface SpaceAuthResult {
|
||||
/** Whether access is allowed */
|
||||
allowed: boolean;
|
||||
/** The authenticated user's claims (null if unauthenticated public access) */
|
||||
claims: EncryptIDClaims | null;
|
||||
/** Why access was denied */
|
||||
reason?: string;
|
||||
/** Whether the user is the space owner */
|
||||
isOwner: boolean;
|
||||
/** Whether this is read-only access (public_read with no auth) */
|
||||
readOnly: boolean;
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import {
|
||||
AuthLevel,
|
||||
GuardianType,
|
||||
SpaceVisibility
|
||||
} from "../index-5c1t4ftn.js";
|
||||
export {
|
||||
SpaceVisibility,
|
||||
GuardianType,
|
||||
AuthLevel
|
||||
};
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
/**
|
||||
* EncryptID SDK — Space Membership Event Types
|
||||
*
|
||||
* Events emitted when membership changes in a space.
|
||||
* Used by the membership sync system to keep all r*.online
|
||||
* modules consistent when a user's role changes.
|
||||
*/
|
||||
import type { SpaceRole } from './roles.js';
|
||||
export type SpaceMembershipEventType = 'member.joined' | 'member.left' | 'member.role_changed';
|
||||
/**
|
||||
* Emitted by EncryptID server when space membership changes.
|
||||
* Modules can subscribe to these events to invalidate role caches.
|
||||
*/
|
||||
export interface SpaceMembershipEvent {
|
||||
type: SpaceMembershipEventType;
|
||||
/** Space identifier */
|
||||
spaceSlug: string;
|
||||
/** DID of the user whose membership changed */
|
||||
userDID: string;
|
||||
/** New role (undefined for member.left) */
|
||||
role?: SpaceRole;
|
||||
/** Previous role (undefined for member.joined) */
|
||||
previousRole?: SpaceRole;
|
||||
/** DID of user who initiated the change */
|
||||
changedBy?: string;
|
||||
/** Unix timestamp (ms) */
|
||||
timestamp: number;
|
||||
}
|
||||
export interface AddMemberRequest {
|
||||
/** DID of user to add */
|
||||
userDID: string;
|
||||
/** Role to grant */
|
||||
role: SpaceRole;
|
||||
}
|
||||
export interface UpdateMemberRequest {
|
||||
/** New role */
|
||||
role: SpaceRole;
|
||||
}
|
||||
export interface MemberResponse {
|
||||
userDID: string;
|
||||
spaceSlug: string;
|
||||
role: SpaceRole;
|
||||
joinedAt: number;
|
||||
grantedBy?: string;
|
||||
}
|
||||
export interface MemberListResponse {
|
||||
members: MemberResponse[];
|
||||
total: number;
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
/**
|
||||
* EncryptID SDK — Module Permission Maps
|
||||
*
|
||||
* Each r*.online module declares a static mapping from capabilities
|
||||
* to the minimum SpaceRole required. This is the central abstraction
|
||||
* for permission inheritance across the ecosystem.
|
||||
*/
|
||||
import { SpaceRole } from './roles.js';
|
||||
import type { AppName } from './index.js';
|
||||
/**
|
||||
* A module's permission declaration.
|
||||
* Maps each capability string to the minimum SpaceRole required.
|
||||
*
|
||||
* @template TCapability - Union of capability string literals for this module
|
||||
*/
|
||||
export interface ModulePermissionMap<TCapability extends string = string> {
|
||||
/** Module identifier (matches AppName) */
|
||||
module: AppName;
|
||||
/** Human-readable module name */
|
||||
displayName: string;
|
||||
/**
|
||||
* For each capability, the minimum SpaceRole required.
|
||||
* If a capability is not listed, it requires ADMIN by default.
|
||||
*/
|
||||
capabilities: Record<TCapability, SpaceRole>;
|
||||
}
|
||||
/**
|
||||
* Check if a user's SpaceRole satisfies a module capability requirement.
|
||||
*
|
||||
* @param userRole - The user's resolved SpaceRole in the space
|
||||
* @param capability - The capability to check
|
||||
* @param permMap - The module's permission map
|
||||
* @returns true if the user's role meets or exceeds the minimum for this capability
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { hasCapability } from '@encryptid/sdk';
|
||||
* import { RVOTE_PERMISSIONS } from '@encryptid/sdk/types/modules';
|
||||
*
|
||||
* if (hasCapability(userRole, 'create_proposal', RVOTE_PERMISSIONS)) {
|
||||
* // user can create proposals
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export declare function hasCapability<T extends string>(userRole: SpaceRole, capability: T, permMap: ModulePermissionMap<T>): boolean;
|
||||
/**
|
||||
* Get all capabilities a role has access to in a module.
|
||||
*
|
||||
* @param userRole - The user's resolved SpaceRole
|
||||
* @param permMap - The module's permission map
|
||||
* @returns Array of capability strings the user has access to
|
||||
*/
|
||||
export declare function getCapabilities<T extends string>(userRole: SpaceRole, permMap: ModulePermissionMap<T>): T[];
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
/**
|
||||
* Module Permission Maps — Barrel Export
|
||||
*
|
||||
* Import specific module permissions:
|
||||
* import { RVOTE_PERMISSIONS } from '@encryptid/sdk/types/modules';
|
||||
*
|
||||
* Or import all:
|
||||
* import * as modules from '@encryptid/sdk/types/modules';
|
||||
*/
|
||||
export { RSPACE_PERMISSIONS, type RSpaceCapability } from './rspace.js';
|
||||
export { RVOTE_PERMISSIONS, type RVoteCapability } from './rvote.js';
|
||||
export { RNOTES_PERMISSIONS, type RNotesCapability } from './rnotes.js';
|
||||
export { RFUNDS_PERMISSIONS, type RFundsCapability } from './rfunds.js';
|
||||
export { RMAPS_PERMISSIONS, type RMapsCapability } from './rmaps.js';
|
||||
export { RTUBE_PERMISSIONS, type RTubeCapability } from './rtube.js';
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
/**
|
||||
* rFunds — Funding Flows & Treasury
|
||||
*
|
||||
* Permission capabilities for the rFunds funding/treasury module.
|
||||
*/
|
||||
import type { ModulePermissionMap } from '../module-permissions.js';
|
||||
export type RFundsCapability = 'view_flows' | 'create_flow' | 'contribute_funds' | 'moderate_flows' | 'configure_treasury';
|
||||
export declare const RFUNDS_PERMISSIONS: ModulePermissionMap<RFundsCapability>;
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
/**
|
||||
* rMaps — Spatial Intelligence
|
||||
*
|
||||
* Permission capabilities for the rMaps collaborative mapping module.
|
||||
*/
|
||||
import type { ModulePermissionMap } from '../module-permissions.js';
|
||||
export type RMapsCapability = 'view_map' | 'add_markers' | 'share_location' | 'moderate_markers' | 'configure_map';
|
||||
export declare const RMAPS_PERMISSIONS: ModulePermissionMap<RMapsCapability>;
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
/**
|
||||
* rNotes — Collaborative Notebooks
|
||||
*
|
||||
* Permission capabilities for the rNotes note-taking module.
|
||||
* Note: rNotes also has per-notebook CollaboratorRole overrides.
|
||||
* Space-level role sets the default; notebook-level can narrow or widen.
|
||||
*/
|
||||
import type { ModulePermissionMap } from '../module-permissions.js';
|
||||
export type RNotesCapability = 'view_notebooks' | 'create_notebook' | 'edit_own_notes' | 'edit_any_notes' | 'manage_notebooks';
|
||||
export declare const RNOTES_PERMISSIONS: ModulePermissionMap<RNotesCapability>;
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
/**
|
||||
* rSpace — Canvas/Collaboration Platform
|
||||
*
|
||||
* Permission capabilities for the rSpace collaborative canvas.
|
||||
*/
|
||||
import type { ModulePermissionMap } from '../module-permissions.js';
|
||||
export type RSpaceCapability = 'view_canvas' | 'add_shapes' | 'edit_own_shapes' | 'edit_any_shape' | 'delete_any_shape' | 'configure_space';
|
||||
export declare const RSPACE_PERMISSIONS: ModulePermissionMap<RSpaceCapability>;
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
/**
|
||||
* rTube — Video Hosting & Streaming
|
||||
*
|
||||
* Permission capabilities for the rTube video module.
|
||||
*/
|
||||
import type { ModulePermissionMap } from '../module-permissions.js';
|
||||
export type RTubeCapability = 'view_videos' | 'upload_video' | 'start_stream' | 'moderate_videos' | 'configure_channel';
|
||||
export declare const RTUBE_PERMISSIONS: ModulePermissionMap<RTubeCapability>;
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
/**
|
||||
* rVote — Decision Engine
|
||||
*
|
||||
* Permission capabilities for the rVote voting/governance module.
|
||||
*/
|
||||
import type { ModulePermissionMap } from '../module-permissions.js';
|
||||
export type RVoteCapability = 'view_proposals' | 'create_proposal' | 'cast_vote' | 'moderate_proposals' | 'configure_voting';
|
||||
export declare const RVOTE_PERMISSIONS: ModulePermissionMap<RVoteCapability>;
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
/**
|
||||
* EncryptID SDK — Space Role System
|
||||
*
|
||||
* Defines the unified role hierarchy for the r*.online ecosystem.
|
||||
* Spaces have visibility (who can enter) and roles (what you can do).
|
||||
*/
|
||||
/**
|
||||
* Ecosystem-wide role within a space.
|
||||
* These are the ONLY roles stored at the space level.
|
||||
* Modules interpret these into module-specific capabilities.
|
||||
*/
|
||||
export declare enum SpaceRole {
|
||||
/** Can view public content in the space */
|
||||
VIEWER = "viewer",
|
||||
/** Can participate: create, edit own content */
|
||||
PARTICIPANT = "participant",
|
||||
/** Can moderate: edit/delete others' content, manage participants */
|
||||
MODERATOR = "moderator",
|
||||
/** Full control: configure space, manage roles, delete space */
|
||||
ADMIN = "admin"
|
||||
}
|
||||
/**
|
||||
* Ordered precedence (higher number = more powerful).
|
||||
* Used by hasCapability() and role comparison helpers.
|
||||
*/
|
||||
export declare const SPACE_ROLE_LEVEL: Record<SpaceRole, number>;
|
||||
/**
|
||||
* Check if a role meets or exceeds a required minimum role.
|
||||
*/
|
||||
export declare function roleAtLeast(userRole: SpaceRole, requiredRole: SpaceRole): boolean;
|
||||
/**
|
||||
* Space membership record.
|
||||
* Each app stores memberships in its own DB (Prisma, Automerge, etc.)
|
||||
* but the shape is consistent across the ecosystem.
|
||||
*/
|
||||
export interface SpaceMembership {
|
||||
/** User's DID (from EncryptID claims.sub) */
|
||||
userDID: string;
|
||||
/** Space identifier (slug) */
|
||||
spaceSlug: string;
|
||||
/** Role in this space */
|
||||
role: SpaceRole;
|
||||
/** When the membership was granted (epoch ms) */
|
||||
joinedAt: number;
|
||||
/** DID of user who granted this membership (null = self-join or owner) */
|
||||
grantedBy?: string;
|
||||
}
|
||||
/**
|
||||
* Result of resolving a user's effective role in a space.
|
||||
* Includes the source for debugging and audit.
|
||||
*/
|
||||
export interface ResolvedRole {
|
||||
/** The effective role */
|
||||
role: SpaceRole;
|
||||
/** How the role was determined */
|
||||
source: 'membership' | 'owner' | 'default' | 'anonymous';
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
/**
|
||||
* EncryptID Guardian Setup Web Component
|
||||
*
|
||||
* Custom element: <encryptid-guardian-setup>
|
||||
* Events: guardian-added, guardian-removed, guardian-verified
|
||||
*/
|
||||
export declare class GuardianSetupElement extends HTMLElement {
|
||||
private shadow;
|
||||
constructor();
|
||||
connectedCallback(): void;
|
||||
private render;
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
/**
|
||||
* @encryptid/sdk/ui — Web Components
|
||||
*/
|
||||
export { EncryptIDLoginButton } from './login-button.js';
|
||||
export { GuardianSetupElement } from './guardian-setup.js';
|
||||
|
|
@ -0,0 +1,263 @@
|
|||
import {
|
||||
getGuardianTypeInfo,
|
||||
getKeyManager,
|
||||
getRecoveryManager,
|
||||
getSessionManager
|
||||
} from "../index-24r9wkfe.js";
|
||||
import {
|
||||
authenticatePasskey,
|
||||
detectCapabilities,
|
||||
registerPasskey,
|
||||
startConditionalUI
|
||||
} from "../index-2cp5044h.js";
|
||||
import {
|
||||
AuthLevel
|
||||
} from "../index-5c1t4ftn.js";
|
||||
|
||||
// src/ui/login-button.ts
|
||||
var PASSKEY_ICON = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="passkey-icon"><circle cx="12" cy="10" r="3"/><path d="M12 13v8"/><path d="M9 18h6"/><circle cx="12" cy="10" r="7"/></svg>`;
|
||||
var styles = `
|
||||
:host { --eid-primary: #06b6d4; --eid-primary-hover: #0891b2; --eid-bg: #0f172a; --eid-bg-hover: #1e293b; --eid-text: #f1f5f9; --eid-text-secondary: #94a3b8; --eid-radius: 8px; display: inline-block; font-family: system-ui, -apple-system, sans-serif; }
|
||||
.login-btn { display: flex; align-items: center; gap: 12px; padding: 12px 24px; background: var(--eid-primary); color: white; border: none; border-radius: var(--eid-radius); font-size: 1rem; font-weight: 500; cursor: pointer; transition: all 0.2s; box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.3); }
|
||||
.login-btn:hover { background: var(--eid-primary-hover); transform: translateY(-1px); }
|
||||
.login-btn:disabled { opacity: 0.6; cursor: not-allowed; transform: none; }
|
||||
.login-btn.outline { background: transparent; border: 2px solid var(--eid-primary); color: var(--eid-primary); }
|
||||
.login-btn.outline:hover { background: var(--eid-primary); color: white; }
|
||||
.login-btn.small { padding: 8px 16px; font-size: 0.875rem; }
|
||||
.login-btn.large { padding: 16px 32px; font-size: 1.125rem; }
|
||||
.passkey-icon { width: 24px; height: 24px; }
|
||||
.user-info { display: flex; align-items: center; gap: 12px; padding: 8px 16px; background: var(--eid-bg); border-radius: var(--eid-radius); color: var(--eid-text); cursor: pointer; }
|
||||
.user-avatar { width: 36px; height: 36px; border-radius: 50%; background: var(--eid-primary); display: flex; align-items: center; justify-content: center; font-weight: 600; font-size: 0.875rem; }
|
||||
.user-did { font-size: 0.75rem; color: var(--eid-text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 150px; }
|
||||
.auth-level { font-size: 0.625rem; padding: 2px 6px; border-radius: 4px; }
|
||||
.auth-level.elevated { background: rgba(34, 197, 94, 0.2); color: #22c55e; }
|
||||
.auth-level.standard { background: rgba(234, 179, 8, 0.2); color: #eab308; }
|
||||
.dropdown { position: absolute; top: 100%; right: 0; margin-top: 8px; background: var(--eid-bg); border-radius: var(--eid-radius); box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.3); min-width: 200px; z-index: 100; overflow: hidden; }
|
||||
.dropdown-item { display: flex; align-items: center; gap: 12px; padding: 12px 16px; color: var(--eid-text); cursor: pointer; transition: background 0.2s; }
|
||||
.dropdown-item:hover { background: var(--eid-bg-hover); }
|
||||
.dropdown-item.danger { color: #ef4444; }
|
||||
.dropdown-divider { height: 1px; background: #334155; margin: 4px 0; }
|
||||
.loading-spinner { width: 20px; height: 20px; border: 2px solid transparent; border-top-color: currentColor; border-radius: 50%; animation: spin 0.8s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
`;
|
||||
|
||||
class EncryptIDLoginButton extends HTMLElement {
|
||||
shadow;
|
||||
loading = false;
|
||||
showDropdown = false;
|
||||
capabilities = null;
|
||||
static get observedAttributes() {
|
||||
return ["size", "variant", "label", "show-user"];
|
||||
}
|
||||
constructor() {
|
||||
super();
|
||||
this.shadow = this.attachShadow({ mode: "open" });
|
||||
}
|
||||
async connectedCallback() {
|
||||
this.capabilities = await detectCapabilities();
|
||||
if (this.capabilities.conditionalUI)
|
||||
this.startConditionalAuth();
|
||||
this.render();
|
||||
document.addEventListener("click", (e) => {
|
||||
if (!this.contains(e.target)) {
|
||||
this.showDropdown = false;
|
||||
this.render();
|
||||
}
|
||||
});
|
||||
}
|
||||
attributeChangedCallback() {
|
||||
this.render();
|
||||
}
|
||||
get size() {
|
||||
return this.getAttribute("size") || "medium";
|
||||
}
|
||||
get variant() {
|
||||
return this.getAttribute("variant") || "primary";
|
||||
}
|
||||
get label() {
|
||||
return this.getAttribute("label") || "Sign in with Passkey";
|
||||
}
|
||||
get showUser() {
|
||||
return this.hasAttribute("show-user");
|
||||
}
|
||||
render() {
|
||||
const session = getSessionManager();
|
||||
const isLoggedIn = session.isValid();
|
||||
const did = session.getDID();
|
||||
const authLevel = session.getAuthLevel();
|
||||
this.shadow.innerHTML = `<style>${styles}</style><div class="login-container" style="position:relative">
|
||||
${isLoggedIn && this.showUser ? this.renderUserInfo(did, authLevel) : this.renderLoginButton()}
|
||||
${this.showDropdown ? this.renderDropdown() : ""}
|
||||
</div>`;
|
||||
this.attachEventListeners();
|
||||
}
|
||||
renderLoginButton() {
|
||||
const sizeClass = this.size === "medium" ? "" : this.size;
|
||||
const variantClass = this.variant === "primary" ? "" : this.variant;
|
||||
return `<button class="login-btn ${sizeClass} ${variantClass}" ${this.loading ? "disabled" : ""}>
|
||||
${this.loading ? '<div class="loading-spinner"></div>' : PASSKEY_ICON}
|
||||
<span>${this.loading ? "Authenticating..." : this.label}</span></button>`;
|
||||
}
|
||||
renderUserInfo(did, authLevel) {
|
||||
const shortDID = did.slice(0, 20) + "..." + did.slice(-8);
|
||||
const initial = did.slice(8, 10).toUpperCase();
|
||||
const levelName = AuthLevel[authLevel].toLowerCase();
|
||||
return `<div class="user-info"><div class="user-avatar">${initial}</div>
|
||||
<div><div class="user-did">${shortDID}</div><span class="auth-level ${levelName}">${levelName}</span></div></div>`;
|
||||
}
|
||||
renderDropdown() {
|
||||
return `<div class="dropdown">
|
||||
<div class="dropdown-item" data-action="profile">Profile</div>
|
||||
<div class="dropdown-item" data-action="recovery">Recovery Settings</div>
|
||||
<div class="dropdown-item" data-action="upgrade">Upgrade Auth Level</div>
|
||||
<div class="dropdown-divider"></div>
|
||||
<div class="dropdown-item danger" data-action="logout">Sign Out</div></div>`;
|
||||
}
|
||||
attachEventListeners() {
|
||||
const session = getSessionManager();
|
||||
if (session.isValid() && this.showUser) {
|
||||
this.shadow.querySelector(".user-info")?.addEventListener("click", () => {
|
||||
this.showDropdown = !this.showDropdown;
|
||||
this.render();
|
||||
});
|
||||
this.shadow.querySelectorAll(".dropdown-item").forEach((item) => {
|
||||
item.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
this.handleDropdownAction(item.dataset.action);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
this.shadow.querySelector(".login-btn")?.addEventListener("click", () => this.handleLogin());
|
||||
}
|
||||
}
|
||||
async handleLogin() {
|
||||
if (this.loading)
|
||||
return;
|
||||
this.loading = true;
|
||||
this.render();
|
||||
try {
|
||||
const result = await authenticatePasskey();
|
||||
const keyManager = getKeyManager();
|
||||
if (result.prfOutput)
|
||||
await keyManager.initFromPRF(result.prfOutput);
|
||||
const keys = await keyManager.getKeys();
|
||||
await getSessionManager().createSession(result, keys.did, { encrypt: true, sign: true, wallet: false });
|
||||
this.dispatchEvent(new CustomEvent("login-success", { detail: { did: keys.did, credentialId: result.credentialId, prfAvailable: !!result.prfOutput }, bubbles: true }));
|
||||
} catch (error) {
|
||||
if (error.name === "NotAllowedError" || error.message?.includes("No credential")) {
|
||||
this.dispatchEvent(new CustomEvent("login-register-needed", { bubbles: true }));
|
||||
} else {
|
||||
this.dispatchEvent(new CustomEvent("login-error", { detail: { error: error.message }, bubbles: true }));
|
||||
}
|
||||
} finally {
|
||||
this.loading = false;
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
async handleDropdownAction(action) {
|
||||
this.showDropdown = false;
|
||||
if (action === "logout") {
|
||||
getSessionManager().clearSession();
|
||||
getKeyManager().clear();
|
||||
this.dispatchEvent(new CustomEvent("logout", { bubbles: true }));
|
||||
} else if (action === "upgrade") {
|
||||
try {
|
||||
await authenticatePasskey();
|
||||
getSessionManager().upgradeAuthLevel(3 /* ELEVATED */);
|
||||
this.dispatchEvent(new CustomEvent("auth-upgraded", { detail: { level: 3 /* ELEVATED */ }, bubbles: true }));
|
||||
} catch {}
|
||||
} else {
|
||||
this.dispatchEvent(new CustomEvent("navigate", { detail: { path: `/${action}` }, bubbles: true }));
|
||||
}
|
||||
this.render();
|
||||
}
|
||||
async startConditionalAuth() {
|
||||
try {
|
||||
const result = await startConditionalUI();
|
||||
if (result) {
|
||||
const keyManager = getKeyManager();
|
||||
if (result.prfOutput)
|
||||
await keyManager.initFromPRF(result.prfOutput);
|
||||
const keys = await keyManager.getKeys();
|
||||
await getSessionManager().createSession(result, keys.did, { encrypt: true, sign: true, wallet: false });
|
||||
this.dispatchEvent(new CustomEvent("login-success", { detail: { did: keys.did, credentialId: result.credentialId, viaConditionalUI: true }, bubbles: true }));
|
||||
this.render();
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
async register(username, displayName) {
|
||||
this.loading = true;
|
||||
this.render();
|
||||
try {
|
||||
const credential = await registerPasskey(username, displayName);
|
||||
this.dispatchEvent(new CustomEvent("register-success", { detail: { credentialId: credential.credentialId, prfSupported: credential.prfSupported }, bubbles: true }));
|
||||
await this.handleLogin();
|
||||
} catch (error) {
|
||||
this.dispatchEvent(new CustomEvent("register-error", { detail: { error: error.message }, bubbles: true }));
|
||||
} finally {
|
||||
this.loading = false;
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
}
|
||||
customElements.define("encryptid-login", EncryptIDLoginButton);
|
||||
// src/ui/guardian-setup.ts
|
||||
class GuardianSetupElement extends HTMLElement {
|
||||
shadow;
|
||||
constructor() {
|
||||
super();
|
||||
this.shadow = this.attachShadow({ mode: "open" });
|
||||
}
|
||||
connectedCallback() {
|
||||
const manager = getRecoveryManager();
|
||||
const config = manager.getConfig();
|
||||
if (!config) {
|
||||
manager.initializeRecovery(3).then(() => this.render());
|
||||
} else {
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
render() {
|
||||
const manager = getRecoveryManager();
|
||||
const config = manager.getConfig();
|
||||
const guardians = config?.guardians ?? [];
|
||||
const threshold = config?.threshold ?? 3;
|
||||
const totalWeight = guardians.reduce((sum, g) => sum + g.weight, 0);
|
||||
const isConfigured = totalWeight >= threshold;
|
||||
this.shadow.innerHTML = `
|
||||
<style>
|
||||
:host { display: block; font-family: system-ui, sans-serif; color: #f1f5f9; }
|
||||
.setup { background: #0f172a; border-radius: 8px; padding: 24px; max-width: 600px; }
|
||||
h2 { margin: 0 0 8px; font-size: 1.5rem; }
|
||||
.subtitle { color: #94a3b8; font-size: 0.875rem; margin: 0 0 24px; }
|
||||
.status { display: flex; align-items: center; gap: 16px; padding: 16px; background: #1e293b; border-radius: 8px; margin-bottom: 24px; }
|
||||
.dot { width: 12px; height: 12px; border-radius: 50%; background: ${isConfigured ? "#22c55e" : "#eab308"}; }
|
||||
.guardian { display: flex; align-items: center; gap: 16px; padding: 16px; background: #1e293b; border-radius: 8px; margin-bottom: 12px; border: 1px solid #475569; }
|
||||
.icon { width: 48px; height: 48px; border-radius: 50%; background: #334155; display: flex; align-items: center; justify-content: center; font-size: 1.25rem; }
|
||||
.info { flex: 1; }
|
||||
.name { font-weight: 500; }
|
||||
.type { font-size: 0.75rem; color: #94a3b8; }
|
||||
</style>
|
||||
<div class="setup">
|
||||
<h2>Social Recovery</h2>
|
||||
<p class="subtitle">Set up guardians to recover your account without seed phrases</p>
|
||||
<div class="status">
|
||||
<div class="dot"></div>
|
||||
<div>
|
||||
<div>${isConfigured ? "Recovery Configured" : "Setup Incomplete"}</div>
|
||||
<div style="font-size:0.75rem;color:#94a3b8">${totalWeight}/${threshold} guardians</div>
|
||||
</div>
|
||||
</div>
|
||||
${guardians.map((g) => {
|
||||
const info = getGuardianTypeInfo(g.type);
|
||||
return `<div class="guardian"><div class="icon">${info.icon === "key" ? "\uD83D\uDD11" : info.icon === "user" ? "\uD83D\uDC64" : info.icon === "shield" ? "\uD83D\uDEE1️" : info.icon === "building" ? "\uD83C\uDFE2" : "⏰"}</div><div class="info"><div class="name">${g.name}</div><div class="type">${info.name}</div></div></div>`;
|
||||
}).join("")}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
customElements.define("encryptid-guardian-setup", GuardianSetupElement);
|
||||
export {
|
||||
GuardianSetupElement,
|
||||
EncryptIDLoginButton
|
||||
};
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
/**
|
||||
* EncryptID Login Button Web Component
|
||||
*
|
||||
* Custom element: <encryptid-login>
|
||||
* Attributes: size (small|medium|large), variant (primary|outline), label, show-user
|
||||
* Events: login-success, login-error, login-register-needed, logout, auth-upgraded
|
||||
*/
|
||||
export declare class EncryptIDLoginButton extends HTMLElement {
|
||||
private shadow;
|
||||
private loading;
|
||||
private showDropdown;
|
||||
private capabilities;
|
||||
static get observedAttributes(): string[];
|
||||
constructor();
|
||||
connectedCallback(): Promise<void>;
|
||||
attributeChangedCallback(): void;
|
||||
private get size();
|
||||
private get variant();
|
||||
private get label();
|
||||
private get showUser();
|
||||
private render;
|
||||
private renderLoginButton;
|
||||
private renderUserInfo;
|
||||
private renderDropdown;
|
||||
private attachEventListeners;
|
||||
private handleLogin;
|
||||
private handleDropdownAction;
|
||||
private startConditionalAuth;
|
||||
register(username: string, displayName: string): Promise<void>;
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
/**
|
||||
* EncryptID React Context Provider
|
||||
*
|
||||
* Wraps your app to provide EncryptID auth state to all components.
|
||||
* Features: localStorage + cookie persistence, auto-refresh, session verification on mount.
|
||||
*/
|
||||
import React, { type ReactNode } from 'react';
|
||||
import { EncryptIDClient } from '../../client/api-client.js';
|
||||
import type { EncryptIDClaims } from '../../types/index.js';
|
||||
interface EncryptIDContextValue {
|
||||
/** Whether the user is authenticated */
|
||||
isAuthenticated: boolean;
|
||||
/** JWT token (null if not authenticated) */
|
||||
token: string | null;
|
||||
/** Decoded claims (null if not authenticated) */
|
||||
claims: EncryptIDClaims | null;
|
||||
/** User's DID (null if not authenticated) */
|
||||
did: string | null;
|
||||
/** Username from EncryptID */
|
||||
username: string | null;
|
||||
/** Whether auth state is being loaded */
|
||||
loading: boolean;
|
||||
/** Full registration + authentication flow */
|
||||
register: (username: string, displayName?: string) => Promise<void>;
|
||||
/** Full authentication flow */
|
||||
login: (credentialId?: string) => Promise<void>;
|
||||
/** Clear session */
|
||||
logout: () => void;
|
||||
/** The EncryptID API client */
|
||||
client: EncryptIDClient;
|
||||
}
|
||||
interface EncryptIDProviderProps {
|
||||
children: ReactNode;
|
||||
/** EncryptID server URL (default: https://encryptid.jeffemmett.com) */
|
||||
serverUrl?: string;
|
||||
}
|
||||
export declare function EncryptIDProvider({ children, serverUrl }: EncryptIDProviderProps): React.FunctionComponentElement<React.ProviderProps<EncryptIDContextValue | null>>;
|
||||
export declare function useEncryptID(): EncryptIDContextValue;
|
||||
export {};
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
/**
|
||||
* EncryptID Login Button — React Component
|
||||
*
|
||||
* Wraps the EncryptID client for easy React integration.
|
||||
*/
|
||||
import React from 'react';
|
||||
interface LoginButtonProps {
|
||||
/** Button label (default: "Sign in with Passkey") */
|
||||
label?: string;
|
||||
/** Button size */
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
/** Visual variant */
|
||||
variant?: 'primary' | 'outline';
|
||||
/** Callback after successful login */
|
||||
onSuccess?: (result: {
|
||||
token: string;
|
||||
did: string;
|
||||
}) => void;
|
||||
/** Callback on error */
|
||||
onError?: (error: Error) => void;
|
||||
/** Callback when registration is needed */
|
||||
onRegisterNeeded?: () => void;
|
||||
/** Additional CSS class */
|
||||
className?: string;
|
||||
}
|
||||
export declare function LoginButton({ label, size, variant, onSuccess, onError, onRegisterNeeded, className, }: LoginButtonProps): React.DetailedReactHTMLElement<{
|
||||
onClick: () => Promise<void>;
|
||||
disabled: boolean;
|
||||
style: React.CSSProperties;
|
||||
className: string;
|
||||
}, HTMLElement>;
|
||||
export {};
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
/**
|
||||
* @encryptid/sdk/ui/react — React Components
|
||||
*/
|
||||
export { EncryptIDProvider, useEncryptID } from './EncryptIDProvider.js';
|
||||
export { LoginButton } from './LoginButton.js';
|
||||
|
|
@ -0,0 +1,160 @@
|
|||
import {
|
||||
EncryptIDClient
|
||||
} from "../../index-7egxprg9.js";
|
||||
import"../../index-2cp5044h.js";
|
||||
|
||||
// src/ui/react/EncryptIDProvider.tsx
|
||||
import React, { createContext, useContext, useEffect, useState, useCallback } from "react";
|
||||
var EncryptIDContext = createContext(null);
|
||||
var TOKEN_KEY = "encryptid_token";
|
||||
function EncryptIDProvider({ children, serverUrl }) {
|
||||
const [client] = useState(() => new EncryptIDClient(serverUrl));
|
||||
const [token, setToken] = useState(null);
|
||||
const [claims, setClaims] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem(TOKEN_KEY);
|
||||
if (stored) {
|
||||
client.verifySession(stored).then((res) => {
|
||||
if (res.valid) {
|
||||
setToken(stored);
|
||||
try {
|
||||
const payload = JSON.parse(atob(stored.split(".")[1]));
|
||||
setClaims(payload);
|
||||
} catch {}
|
||||
} else {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
}
|
||||
}).catch(() => {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
}).finally(() => setLoading(false));
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [client]);
|
||||
const register = useCallback(async (username, displayName) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await client.register(username, displayName);
|
||||
setToken(result.token);
|
||||
localStorage.setItem(TOKEN_KEY, result.token);
|
||||
try {
|
||||
setClaims(JSON.parse(atob(result.token.split(".")[1])));
|
||||
} catch {}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [client]);
|
||||
const login = useCallback(async (credentialId) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await client.authenticate(credentialId);
|
||||
setToken(result.token);
|
||||
localStorage.setItem(TOKEN_KEY, result.token);
|
||||
try {
|
||||
setClaims(JSON.parse(atob(result.token.split(".")[1])));
|
||||
} catch {}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [client]);
|
||||
const logout = useCallback(() => {
|
||||
setToken(null);
|
||||
setClaims(null);
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem("encryptid_session");
|
||||
}, []);
|
||||
const value = {
|
||||
isAuthenticated: !!token,
|
||||
token,
|
||||
claims,
|
||||
did: claims?.did || claims?.sub || null,
|
||||
username: claims?.username || null,
|
||||
loading,
|
||||
register,
|
||||
login,
|
||||
logout,
|
||||
client
|
||||
};
|
||||
return React.createElement(EncryptIDContext.Provider, { value }, children);
|
||||
}
|
||||
function useEncryptID() {
|
||||
const ctx = useContext(EncryptIDContext);
|
||||
if (!ctx)
|
||||
throw new Error("useEncryptID must be used within <EncryptIDProvider>");
|
||||
return ctx;
|
||||
}
|
||||
// src/ui/react/LoginButton.tsx
|
||||
import React2, { useState as useState2, useCallback as useCallback2 } from "react";
|
||||
function LoginButton({
|
||||
label = "Sign in with Passkey",
|
||||
size = "medium",
|
||||
variant = "primary",
|
||||
onSuccess,
|
||||
onError,
|
||||
onRegisterNeeded,
|
||||
className = ""
|
||||
}) {
|
||||
const { login, isAuthenticated, did, logout, loading: contextLoading } = useEncryptID();
|
||||
const [loading, setLoading] = useState2(false);
|
||||
const handleClick = useCallback2(async () => {
|
||||
if (isAuthenticated) {
|
||||
logout();
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
await login();
|
||||
const token = localStorage.getItem("encryptid_token") || "";
|
||||
const currentDid = did || "";
|
||||
onSuccess?.({ token, did: currentDid });
|
||||
} catch (error) {
|
||||
if (error.name === "NotAllowedError") {
|
||||
onRegisterNeeded?.();
|
||||
} else {
|
||||
onError?.(error);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [login, logout, isAuthenticated, did, onSuccess, onError, onRegisterNeeded]);
|
||||
const isLoading = loading || contextLoading;
|
||||
const sizeStyles = {
|
||||
small: { padding: "8px 16px", fontSize: "0.875rem" },
|
||||
medium: { padding: "12px 24px", fontSize: "1rem" },
|
||||
large: { padding: "16px 32px", fontSize: "1.125rem" }
|
||||
};
|
||||
const baseStyle = {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "12px",
|
||||
border: "none",
|
||||
borderRadius: "8px",
|
||||
fontWeight: 500,
|
||||
cursor: isLoading ? "not-allowed" : "pointer",
|
||||
opacity: isLoading ? 0.6 : 1,
|
||||
transition: "all 0.2s",
|
||||
fontFamily: "system-ui, -apple-system, sans-serif",
|
||||
...sizeStyles[size],
|
||||
...variant === "primary" ? { background: "#06b6d4", color: "white" } : { background: "transparent", border: "2px solid #06b6d4", color: "#06b6d4" }
|
||||
};
|
||||
return React2.createElement("button", {
|
||||
onClick: handleClick,
|
||||
disabled: isLoading,
|
||||
style: baseStyle,
|
||||
className
|
||||
}, isLoading ? React2.createElement("span", null, "Authenticating...") : React2.createElement(React2.Fragment, null, React2.createElement("svg", {
|
||||
viewBox: "0 0 24 24",
|
||||
fill: "none",
|
||||
stroke: "currentColor",
|
||||
strokeWidth: 2,
|
||||
strokeLinecap: "round",
|
||||
strokeLinejoin: "round",
|
||||
style: { width: "24px", height: "24px" }
|
||||
}, React2.createElement("circle", { cx: 12, cy: 10, r: 3 }), React2.createElement("path", { d: "M12 13v8" }), React2.createElement("path", { d: "M9 18h6" }), React2.createElement("circle", { cx: 12, cy: 10, r: 7 })), React2.createElement("span", null, isAuthenticated ? "Sign Out" : label)));
|
||||
}
|
||||
export {
|
||||
useEncryptID,
|
||||
LoginButton,
|
||||
EncryptIDProvider
|
||||
};
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
/**
|
||||
* Re-export useEncryptID hook from Provider module
|
||||
*/
|
||||
export { useEncryptID } from './EncryptIDProvider.js';
|
||||
Loading…
Reference in New Issue