feat(qr-device-login): ✨ Introduce QR-based device login components and utilities with React component, hook, and client-side error handling
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
504fa0d92b
commit
8bd39e401b
7 changed files with 632 additions and 0 deletions
125
qr-device-login/client/src/client.ts
Normal file
125
qr-device-login/client/src/client.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
/**
|
||||
* @lilith/qr-device-login-client
|
||||
*
|
||||
* Server-side SDK used by consuming apps' backends. Talks to the service's
|
||||
* admin API over mTLS (via undici's Agent) and exposes three typed methods:
|
||||
* createSession, pollSession, exchangeCode.
|
||||
*
|
||||
* Consuming apps also need a small nginx / reverse-proxy config that passes
|
||||
* through the client certificate — but that's deployment, not SDK.
|
||||
*/
|
||||
|
||||
import { Agent, request } from 'undici';
|
||||
import {
|
||||
ADMIN_PATHS,
|
||||
type CreateSessionRequest,
|
||||
type CreateSessionResponse,
|
||||
type ErrorBody,
|
||||
type ExchangeCodeRequest,
|
||||
type ExchangeCodeResponse,
|
||||
type PollSessionResponse,
|
||||
type SessionMetadata,
|
||||
} from '@lilith/qr-device-login-protocol';
|
||||
import { errorFromCode, QrLoginError } from './errors';
|
||||
|
||||
export interface QrLoginClientOptions {
|
||||
/** e.g. "https://qr-auth.lilith.live" */
|
||||
baseUrl: string;
|
||||
/** PEM-encoded client certificate (full chain). */
|
||||
cert: string | Buffer;
|
||||
/** PEM-encoded private key matching `cert`. */
|
||||
key: string | Buffer;
|
||||
/** PEM-encoded CA bundle the service's server certificate chains to. */
|
||||
ca: string | Buffer;
|
||||
/** Request timeout in ms. Defaults to 5s. */
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface QrLoginClient {
|
||||
createSession(input: CreateSessionRequest): Promise<CreateSessionResponse>;
|
||||
pollSession(id: string): Promise<PollSessionResponse>;
|
||||
exchangeCode(id: string, code: string): Promise<ExchangeCodeResponse>;
|
||||
}
|
||||
|
||||
export function createQrLoginClient(options: QrLoginClientOptions): QrLoginClient {
|
||||
const baseUrl = options.baseUrl.replace(/\/$/, '');
|
||||
const timeoutMs = options.timeoutMs ?? 5_000;
|
||||
|
||||
const agent = new Agent({
|
||||
connect: {
|
||||
cert: options.cert,
|
||||
key: options.key,
|
||||
ca: options.ca,
|
||||
rejectUnauthorized: true,
|
||||
},
|
||||
headersTimeout: timeoutMs,
|
||||
bodyTimeout: timeoutMs,
|
||||
});
|
||||
|
||||
async function send<T>(
|
||||
path: string,
|
||||
init: { method: 'GET' | 'POST'; body?: unknown },
|
||||
): Promise<T> {
|
||||
let res;
|
||||
try {
|
||||
res = await request(`${baseUrl}${path}`, {
|
||||
method: init.method,
|
||||
dispatcher: agent,
|
||||
headers: init.body !== undefined
|
||||
? { 'content-type': 'application/json' }
|
||||
: {},
|
||||
body: init.body !== undefined ? JSON.stringify(init.body) : undefined,
|
||||
});
|
||||
} catch (err) {
|
||||
throw new QrLoginError('internal_error', `transport failure: ${String(err)}`, 0);
|
||||
}
|
||||
|
||||
const text = await res.body.text();
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
if (text.length === 0) return {} as T;
|
||||
return JSON.parse(text) as T;
|
||||
}
|
||||
|
||||
let parsed: ErrorBody | null = null;
|
||||
try {
|
||||
parsed = JSON.parse(text) as ErrorBody;
|
||||
} catch {
|
||||
parsed = null;
|
||||
}
|
||||
if (parsed?.error?.code) {
|
||||
throw errorFromCode(parsed.error.code, parsed.error.message, res.statusCode);
|
||||
}
|
||||
throw new QrLoginError('internal_error', `http ${res.statusCode}: ${text}`, res.statusCode);
|
||||
}
|
||||
|
||||
return {
|
||||
async createSession(input: CreateSessionRequest): Promise<CreateSessionResponse> {
|
||||
if (!input.callbackUrl || typeof input.callbackUrl !== 'string') {
|
||||
throw new QrLoginError('invalid_request', 'callbackUrl is required', 400);
|
||||
}
|
||||
const body: CreateSessionRequest = { callbackUrl: input.callbackUrl };
|
||||
if (input.metadata) body.metadata = sanitizeMetadata(input.metadata);
|
||||
return send<CreateSessionResponse>(ADMIN_PATHS.createSession, { method: 'POST', body });
|
||||
},
|
||||
|
||||
async pollSession(id: string): Promise<PollSessionResponse> {
|
||||
if (!id) throw new QrLoginError('invalid_request', 'id is required', 400);
|
||||
return send<PollSessionResponse>(ADMIN_PATHS.getSession(id), { method: 'GET' });
|
||||
},
|
||||
|
||||
async exchangeCode(id: string, code: string): Promise<ExchangeCodeResponse> {
|
||||
if (!id) throw new QrLoginError('invalid_request', 'id is required', 400);
|
||||
if (!code) throw new QrLoginError('invalid_request', 'code is required', 400);
|
||||
const body: ExchangeCodeRequest = { code };
|
||||
return send<ExchangeCodeResponse>(ADMIN_PATHS.exchangeCode(id), { method: 'POST', body });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeMetadata(input: SessionMetadata): SessionMetadata {
|
||||
const out: SessionMetadata = {};
|
||||
for (const [k, v] of Object.entries(input)) {
|
||||
if (typeof v === 'string') out[k] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
73
qr-device-login/client/src/errors.ts
Normal file
73
qr-device-login/client/src/errors.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
/**
|
||||
* Typed errors returned by the client SDK.
|
||||
*
|
||||
* We intentionally expose a thin hierarchy keyed to the service's
|
||||
* `ErrorCode` values — consumers can either catch the base class and
|
||||
* inspect `code`, or catch individual subclasses.
|
||||
*/
|
||||
|
||||
import type { ErrorCode } from '@lilith/qr-device-login-protocol';
|
||||
|
||||
export class QrLoginError extends Error {
|
||||
constructor(
|
||||
public readonly code: ErrorCode,
|
||||
message: string,
|
||||
public readonly httpStatus: number,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'QrLoginError';
|
||||
}
|
||||
}
|
||||
|
||||
export class QrLoginUnauthorized extends QrLoginError {
|
||||
constructor(message = 'mTLS rejected by qr-device-login service') {
|
||||
super('unauthorized', message, 401);
|
||||
this.name = 'QrLoginUnauthorized';
|
||||
}
|
||||
}
|
||||
|
||||
export class QrLoginSessionExpired extends QrLoginError {
|
||||
constructor(message = 'session expired') {
|
||||
super('session_expired', message, 410);
|
||||
this.name = 'QrLoginSessionExpired';
|
||||
}
|
||||
}
|
||||
|
||||
export class QrLoginAlreadyConsumed extends QrLoginError {
|
||||
constructor(message = 'session already consumed') {
|
||||
super('session_already_consumed', message, 410);
|
||||
this.name = 'QrLoginAlreadyConsumed';
|
||||
}
|
||||
}
|
||||
|
||||
export class QrLoginWrongCode extends QrLoginError {
|
||||
constructor(message = 'exchange code mismatch') {
|
||||
super('wrong_code', message, 403);
|
||||
this.name = 'QrLoginWrongCode';
|
||||
}
|
||||
}
|
||||
|
||||
export class QrLoginSessionNotFound extends QrLoginError {
|
||||
constructor(message = 'session not found') {
|
||||
super('session_not_found', message, 404);
|
||||
this.name = 'QrLoginSessionNotFound';
|
||||
}
|
||||
}
|
||||
|
||||
/** Maps the wire `ErrorCode` to a typed subclass. */
|
||||
export function errorFromCode(code: ErrorCode, message: string, httpStatus: number): QrLoginError {
|
||||
switch (code) {
|
||||
case 'unauthorized':
|
||||
return new QrLoginUnauthorized(message);
|
||||
case 'session_expired':
|
||||
return new QrLoginSessionExpired(message);
|
||||
case 'session_already_consumed':
|
||||
return new QrLoginAlreadyConsumed(message);
|
||||
case 'wrong_code':
|
||||
return new QrLoginWrongCode(message);
|
||||
case 'session_not_found':
|
||||
return new QrLoginSessionNotFound(message);
|
||||
default:
|
||||
return new QrLoginError(code, message, httpStatus);
|
||||
}
|
||||
}
|
||||
29
qr-device-login/client/src/index.ts
Normal file
29
qr-device-login/client/src/index.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
/**
|
||||
* Public surface of @lilith/qr-device-login-client.
|
||||
*
|
||||
* Import the factory, construct once per app, reuse across requests:
|
||||
*
|
||||
* const qr = createQrLoginClient({ baseUrl, cert, key, ca });
|
||||
* const session = await qr.createSession({ callbackUrl });
|
||||
*/
|
||||
|
||||
export { createQrLoginClient } from './client';
|
||||
export type { QrLoginClient, QrLoginClientOptions } from './client';
|
||||
export {
|
||||
QrLoginError,
|
||||
QrLoginUnauthorized,
|
||||
QrLoginSessionExpired,
|
||||
QrLoginAlreadyConsumed,
|
||||
QrLoginWrongCode,
|
||||
QrLoginSessionNotFound,
|
||||
} from './errors';
|
||||
export type {
|
||||
CreateSessionRequest,
|
||||
CreateSessionResponse,
|
||||
PollSessionResponse,
|
||||
ExchangeCodeRequest,
|
||||
ExchangeCodeResponse,
|
||||
SessionMetadata,
|
||||
SessionStatus,
|
||||
ErrorCode,
|
||||
} from '@lilith/qr-device-login-protocol';
|
||||
109
qr-device-login/react/src/DeviceLoginQR.tsx
Normal file
109
qr-device-login/react/src/DeviceLoginQR.tsx
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
/**
|
||||
* <DeviceLoginQR /> — drop-in component for consuming apps.
|
||||
*
|
||||
* Styling uses @lilith/ui-styled-components per project convention
|
||||
* (single ThemeProvider instance across lilith apps).
|
||||
*/
|
||||
|
||||
import { styled } from '@lilith/ui-styled-components';
|
||||
import type { ReactElement } from 'react';
|
||||
import { useDeviceLogin, type UseDeviceLoginOptions } from './useDeviceLogin';
|
||||
|
||||
export interface DeviceLoginQRProps extends UseDeviceLoginOptions {
|
||||
/** Button label when idle. Defaults to "Generate QR Code". */
|
||||
ctaLabel?: string;
|
||||
/** Optional heading rendered above the component. */
|
||||
heading?: string;
|
||||
}
|
||||
|
||||
const Wrapper = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
`;
|
||||
|
||||
const Heading = styled.h3`
|
||||
margin: 0 0 0.25rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text, #e0e0e0);
|
||||
`;
|
||||
|
||||
const QrImage = styled.img`
|
||||
width: 240px;
|
||||
height: 240px;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
padding: 8px;
|
||||
`;
|
||||
|
||||
const Status = styled.p`
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-muted, #9ca3af);
|
||||
`;
|
||||
|
||||
const ActionButton = styled.button`
|
||||
padding: 0.6rem 1.1rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(167, 139, 250, 0.4);
|
||||
background: rgba(167, 139, 250, 0.12);
|
||||
color: var(--color-accent, #a78bfa);
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
&:hover { background: rgba(167, 139, 250, 0.2); }
|
||||
&:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
`;
|
||||
|
||||
const ErrorText = styled.p`
|
||||
margin: 0;
|
||||
color: #f87171;
|
||||
font-size: 0.85rem;
|
||||
`;
|
||||
|
||||
export function DeviceLoginQR(props: DeviceLoginQRProps): ReactElement {
|
||||
const { ctaLabel = 'Generate QR Code', heading, ...hookOptions } = props;
|
||||
const { state, start, reset } = useDeviceLogin(hookOptions);
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
{heading ? <Heading>{heading}</Heading> : null}
|
||||
{state.kind === 'idle' && (
|
||||
<ActionButton type="button" onClick={() => { void start(); }}>
|
||||
{ctaLabel}
|
||||
</ActionButton>
|
||||
)}
|
||||
{state.kind === 'starting' && <Status>Generating QR code…</Status>}
|
||||
{state.kind === 'active' && (
|
||||
<>
|
||||
<QrImage src={state.session.qrDataUrl} alt="Scan to log in" />
|
||||
<Status>
|
||||
Scan with your phone — expires in {state.secondsRemaining}s
|
||||
</Status>
|
||||
</>
|
||||
)}
|
||||
{state.kind === 'scanned' && <Status>Scanned — logging you in…</Status>}
|
||||
{state.kind === 'expired' && (
|
||||
<>
|
||||
<Status>QR code expired.</Status>
|
||||
<ActionButton type="button" onClick={() => { void start(); }}>
|
||||
Generate new code
|
||||
</ActionButton>
|
||||
</>
|
||||
)}
|
||||
{state.kind === 'error' && (
|
||||
<>
|
||||
<ErrorText>{state.message}</ErrorText>
|
||||
<ActionButton type="button" onClick={reset}>
|
||||
Try again
|
||||
</ActionButton>
|
||||
</>
|
||||
)}
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
12
qr-device-login/react/src/index.ts
Normal file
12
qr-device-login/react/src/index.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
/**
|
||||
* Public surface of @lilith/qr-device-login-react.
|
||||
*/
|
||||
|
||||
export { DeviceLoginQR } from './DeviceLoginQR';
|
||||
export type { DeviceLoginQRProps } from './DeviceLoginQR';
|
||||
export { useDeviceLogin } from './useDeviceLogin';
|
||||
export type {
|
||||
UseDeviceLoginOptions,
|
||||
UseDeviceLoginResult,
|
||||
DeviceLoginState,
|
||||
} from './useDeviceLogin';
|
||||
162
qr-device-login/react/src/useDeviceLogin.ts
Normal file
162
qr-device-login/react/src/useDeviceLogin.ts
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
/**
|
||||
* useDeviceLogin — lifecycle hook for a QR device-login session.
|
||||
*
|
||||
* Owns:
|
||||
* - POST to the consumer's /start endpoint to mint a session.
|
||||
* - Poll /poll/:id on an interval until status flips to `scanned` or `expired`.
|
||||
* - Countdown until expiry.
|
||||
* - Cleanup on unmount.
|
||||
*
|
||||
* The consumer's backend is responsible for the actual proxying to the
|
||||
* qr-device-login service. This hook deliberately knows nothing about mTLS,
|
||||
* certs, or the service URL — it only talks to same-origin URLs.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type {
|
||||
CreateSessionResponse,
|
||||
PollSessionResponse,
|
||||
SessionStatus,
|
||||
} from '@lilith/qr-device-login-protocol';
|
||||
|
||||
export type DeviceLoginState =
|
||||
| { kind: 'idle' }
|
||||
| { kind: 'starting' }
|
||||
| {
|
||||
kind: 'active';
|
||||
session: CreateSessionResponse;
|
||||
status: SessionStatus;
|
||||
secondsRemaining: number;
|
||||
}
|
||||
| { kind: 'expired' }
|
||||
| { kind: 'scanned'; session: CreateSessionResponse }
|
||||
| { kind: 'error'; message: string };
|
||||
|
||||
export interface UseDeviceLoginOptions {
|
||||
/** POST endpoint on the consuming app that creates a session. */
|
||||
startUrl: string;
|
||||
/** GET endpoint on the consuming app that polls a session by id (`:id` appended). */
|
||||
pollUrl: string;
|
||||
/** Polling interval in ms. Default 3000. */
|
||||
pollIntervalMs?: number;
|
||||
/** Called once when the poll flips to `scanned`. */
|
||||
onSuccess?: () => void;
|
||||
/** Called on any transport or protocol error. */
|
||||
onError?: (err: Error) => void;
|
||||
}
|
||||
|
||||
export interface UseDeviceLoginResult {
|
||||
state: DeviceLoginState;
|
||||
start: () => Promise<void>;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export function useDeviceLogin(options: UseDeviceLoginOptions): UseDeviceLoginResult {
|
||||
const { startUrl, pollUrl, pollIntervalMs = 3_000, onSuccess, onError } = options;
|
||||
|
||||
const [state, setState] = useState<DeviceLoginState>({ kind: 'idle' });
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const countdownRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const mountedRef = useRef<boolean>(true);
|
||||
|
||||
const clearTimers = useCallback((): void => {
|
||||
if (pollRef.current) {
|
||||
clearInterval(pollRef.current);
|
||||
pollRef.current = null;
|
||||
}
|
||||
if (countdownRef.current) {
|
||||
clearInterval(countdownRef.current);
|
||||
countdownRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect((): (() => void) => {
|
||||
mountedRef.current = true;
|
||||
return (): void => {
|
||||
mountedRef.current = false;
|
||||
clearTimers();
|
||||
};
|
||||
}, [clearTimers]);
|
||||
|
||||
const reset = useCallback((): void => {
|
||||
clearTimers();
|
||||
if (mountedRef.current) setState({ kind: 'idle' });
|
||||
}, [clearTimers]);
|
||||
|
||||
const start = useCallback(async (): Promise<void> => {
|
||||
clearTimers();
|
||||
setState({ kind: 'starting' });
|
||||
|
||||
let session: CreateSessionResponse;
|
||||
try {
|
||||
const res = await fetch(startUrl, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: '{}',
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`start failed: HTTP ${res.status}`);
|
||||
}
|
||||
session = (await res.json()) as CreateSessionResponse;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (mountedRef.current) setState({ kind: 'error', message });
|
||||
onError?.(err instanceof Error ? err : new Error(message));
|
||||
return;
|
||||
}
|
||||
|
||||
const expiresAtMs = Date.parse(session.expiresAt);
|
||||
const tick = (): void => {
|
||||
if (!mountedRef.current) return;
|
||||
const secondsRemaining = Math.max(0, Math.round((expiresAtMs - Date.now()) / 1000));
|
||||
setState((prev) => {
|
||||
if (prev.kind !== 'active') return prev;
|
||||
if (secondsRemaining <= 0) {
|
||||
clearTimers();
|
||||
return { kind: 'expired' };
|
||||
}
|
||||
return { ...prev, secondsRemaining };
|
||||
});
|
||||
};
|
||||
|
||||
setState({
|
||||
kind: 'active',
|
||||
session,
|
||||
status: 'pending',
|
||||
secondsRemaining: Math.max(0, Math.round((expiresAtMs - Date.now()) / 1000)),
|
||||
});
|
||||
countdownRef.current = setInterval(tick, 1_000);
|
||||
|
||||
const poll = async (): Promise<void> => {
|
||||
try {
|
||||
const res = await fetch(`${pollUrl}/${session.id}`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) throw new Error(`poll failed: HTTP ${res.status}`);
|
||||
const body = (await res.json()) as PollSessionResponse;
|
||||
if (!mountedRef.current) return;
|
||||
if (body.status === 'scanned') {
|
||||
clearTimers();
|
||||
setState({ kind: 'scanned', session });
|
||||
onSuccess?.();
|
||||
return;
|
||||
}
|
||||
if (body.status === 'expired') {
|
||||
clearTimers();
|
||||
setState({ kind: 'expired' });
|
||||
return;
|
||||
}
|
||||
setState((prev) => (prev.kind === 'active' ? { ...prev, status: body.status } : prev));
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
clearTimers();
|
||||
if (mountedRef.current) setState({ kind: 'error', message });
|
||||
onError?.(err instanceof Error ? err : new Error(message));
|
||||
}
|
||||
};
|
||||
pollRef.current = setInterval(() => { void poll(); }, pollIntervalMs);
|
||||
}, [startUrl, pollUrl, pollIntervalMs, onSuccess, onError, clearTimers]);
|
||||
|
||||
return useMemo(() => ({ state, start, reset }), [state, start, reset]);
|
||||
}
|
||||
122
qr-device-login/shared/src/index.ts
Normal file
122
qr-device-login/shared/src/index.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
/**
|
||||
* Wire protocol for the QR device-login service.
|
||||
*
|
||||
* All types here are transport-level: the shapes exchanged between a consuming
|
||||
* app's server and the qr-device-login service. They are versioned under
|
||||
* `/admin/v1/...` paths on the service side.
|
||||
*
|
||||
* Keep this file free of runtime dependencies — it must be safely importable
|
||||
* from both Node and browser contexts.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Lifecycle state of a pending scan session.
|
||||
*
|
||||
* - `pending` — created, not yet scanned
|
||||
* - `scanned` — phone has hit /scan/:token and been redirected to callbackUrl
|
||||
* - `expired` — TTL elapsed, or explicitly burned by a successful exchange
|
||||
*/
|
||||
export type SessionStatus = 'pending' | 'scanned' | 'expired';
|
||||
|
||||
/**
|
||||
* Arbitrary string-keyed metadata the consumer can stash on a session.
|
||||
*
|
||||
* The service treats this as an opaque blob: it is stored with the pending
|
||||
* session and handed back verbatim during `exchangeCode`. Consumers typically
|
||||
* use it to carry the userId that should be logged in on the phone.
|
||||
*/
|
||||
export type SessionMetadata = Record<string, string>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /admin/v1/sessions — create a new QR session
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface CreateSessionRequest {
|
||||
/**
|
||||
* Fully-qualified URL on the consuming app that will receive the phone's
|
||||
* redirect after a successful scan. Must match one of the consumer's
|
||||
* `allowedCallbacks` prefixes on the service side.
|
||||
*/
|
||||
callbackUrl: string;
|
||||
|
||||
/**
|
||||
* Optional opaque metadata — echoed back on `exchangeCode`.
|
||||
*/
|
||||
metadata?: SessionMetadata;
|
||||
}
|
||||
|
||||
export interface CreateSessionResponse {
|
||||
/** Opaque session id — used for polling + exchange. */
|
||||
id: string;
|
||||
/** Pre-rendered QR as a data URL (PNG). */
|
||||
qrDataUrl: string;
|
||||
/** The raw URL encoded in the QR (useful for copy-link fallbacks). */
|
||||
scanUrl: string;
|
||||
/** ISO-8601 expiry timestamp. */
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /admin/v1/sessions/:id — poll status
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface PollSessionResponse {
|
||||
status: SessionStatus;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /admin/v1/sessions/:id/exchange — burn the token + claim the scan
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ExchangeCodeRequest {
|
||||
/** One-time code handed to the phone by the /scan/:token redirect. */
|
||||
code: string;
|
||||
}
|
||||
|
||||
export interface ExchangeCodeResponse {
|
||||
/** Metadata supplied at createSession time, unchanged. */
|
||||
metadata: SessionMetadata;
|
||||
/** ISO-8601 timestamp when the scan landed. */
|
||||
scannedAt: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error envelope
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Machine-readable error codes returned in the `error.code` field of any
|
||||
* non-2xx response from the service.
|
||||
*/
|
||||
export type ErrorCode =
|
||||
| 'unauthorized' // mTLS failed / unknown consumer
|
||||
| 'forbidden' // consumer not allowed to touch this session
|
||||
| 'invalid_request' // malformed body / bad callbackUrl
|
||||
| 'callback_not_allowed' // callbackUrl not on consumer's allowlist
|
||||
| 'session_not_found' // id unknown
|
||||
| 'session_expired' // past TTL
|
||||
| 'session_already_consumed' // exchange already succeeded
|
||||
| 'wrong_code' // exchange code mismatch
|
||||
| 'rate_limited' // global pending-token cap exceeded
|
||||
| 'internal_error';
|
||||
|
||||
export interface ErrorBody {
|
||||
error: {
|
||||
code: ErrorCode;
|
||||
message: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin API paths, centralised so the client + server agree. The `:id`
|
||||
* placeholder is substituted at call time.
|
||||
*/
|
||||
export const ADMIN_PATHS = {
|
||||
createSession: '/admin/v1/sessions',
|
||||
getSession: (id: string) => `/admin/v1/sessions/${id}`,
|
||||
exchangeCode: (id: string) => `/admin/v1/sessions/${id}/exchange`,
|
||||
} as const;
|
||||
|
||||
/** Public scan landing path (phones hit this directly). */
|
||||
export const SCAN_PATH = (token: string): string => `/scan/${token}`;
|
||||
Loading…
Add table
Reference in a new issue