From 5562f82f34b1481280d795d7e1e7ad192da18a6e Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Sun, 17 May 2026 14:49:37 -0400 Subject: [PATCH] =?UTF-8?q?feat(canary):=20landing=20=E2=80=94=20specimen?= =?UTF-8?q?=20requisition=20page=20+=20Turnstile=20widget?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Landing route (/) is the token-creation page, framed as a "specimen requisition" intake form per the vector-minimalism ethos. Mind: cataloging a thing that resists cataloging — a trap baited for an unseen visitor. Page composition: - Top archive Strip (FIELD STATION / VOL / ISSUE pill) - Hero block: large blocky "CANARIES" headline, Latin subtitle, purpose line - Halftone separator - Five sections (01 species / 02 annotate / 03 route / 04 species config / 05 verify) - Bottom Strip with routing/license metadata Form sub-sections (each a small component to keep complexity under biome's maxAllowedComplexity=25 cap on FormView): - SpeciesSection — 7-card grid via TypePicker (radio fieldset, sr-only inputs) - AnnotateSection — memo textarea + conditional filename (when file-kind) - RouteSection — Telegram | Webhook radio fieldset + conditional credentials - ConfigSection — slowredirect destination_url or envfile include_keys - VerifySection — Turnstile widget if VITE_TURNSTILE_SITE_KEY present - SubmitFooter — release button + privacy note Validation: client uses createTokenRequestSchema.safeParse, surfaces field errors inline (mono caps in alarm color); server-side ApiError.fields are mapped back into the same FieldErrors shape via buildSubmissionState (Phase 14 single-source envelope contract preserved). Result view: SpecimenCard with full token dossier (DataRows), kind-discriminated artifact display (url → CopyField; file/text → download Blob via object-URL; connection_string → CopyField with note), Manage + Trigger CopyFields, link to the dossier route /m/:manage_id (route wired in next chunk). Turnstile: explicit-render widget, lazy script-load, registers token via setTurnstileTokenProvider on success; cleans up on unmount; no key = no widget (matches backend dev-bypass at internal/turnstile/verifier.go). Gates green: typecheck + biome + stylelint + build (406ms, landing chunk 123KB / 38KB gzip). --- .../frontend/src/core/turnstile/index.tsx | 136 ++++ .../frontend/src/pages/landing/artifact.tsx | 163 +++++ .../frontend/src/pages/landing/copy.ts | 61 ++ .../frontend/src/pages/landing/form-state.ts | 136 ++++ .../frontend/src/pages/landing/index.tsx | 605 +++++++++++++++- .../src/pages/landing/landing.module.scss | 669 ++++++++++++++++++ .../frontend/src/pages/landing/result.tsx | 99 +++ .../src/pages/landing/type-picker.tsx | 78 ++ 8 files changed, 1946 insertions(+), 1 deletion(-) create mode 100644 PROJECTS/beginner/canary-token-generator/frontend/src/core/turnstile/index.tsx create mode 100644 PROJECTS/beginner/canary-token-generator/frontend/src/pages/landing/artifact.tsx create mode 100644 PROJECTS/beginner/canary-token-generator/frontend/src/pages/landing/copy.ts create mode 100644 PROJECTS/beginner/canary-token-generator/frontend/src/pages/landing/form-state.ts create mode 100644 PROJECTS/beginner/canary-token-generator/frontend/src/pages/landing/landing.module.scss create mode 100644 PROJECTS/beginner/canary-token-generator/frontend/src/pages/landing/result.tsx create mode 100644 PROJECTS/beginner/canary-token-generator/frontend/src/pages/landing/type-picker.tsx diff --git a/PROJECTS/beginner/canary-token-generator/frontend/src/core/turnstile/index.tsx b/PROJECTS/beginner/canary-token-generator/frontend/src/core/turnstile/index.tsx new file mode 100644 index 00000000..eef6586d --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/frontend/src/core/turnstile/index.tsx @@ -0,0 +1,136 @@ +// =================== +// ©AngelaMos | 2026 +// index.tsx +// =================== + +import { useEffect, useRef, useState } from 'react' +import { setTurnstileTokenProvider } from '@/api' + +declare global { + interface Window { + turnstile?: { + render: ( + container: HTMLElement, + options: { + sitekey: string + callback?: (token: string) => void + 'expired-callback'?: () => void + 'error-callback'?: () => void + theme?: 'light' | 'dark' | 'auto' + appearance?: 'always' | 'execute' | 'interaction-only' + size?: 'normal' | 'flexible' | 'compact' | 'invisible' + } + ) => string + reset: (widgetId?: string) => void + remove: (widgetId?: string) => void + } + onloadTurnstileCallback?: () => void + } +} + +const TURNSTILE_SRC = + 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit&onload=onloadTurnstileCallback' +const TURNSTILE_SCRIPT_ID = 'cf-turnstile-script' + +function loadTurnstileScript(): Promise { + if (typeof window === 'undefined') { + return Promise.resolve() + } + if (window.turnstile) { + return Promise.resolve() + } + return new Promise((resolve, reject) => { + const existing = document.getElementById(TURNSTILE_SCRIPT_ID) + if (existing) { + existing.addEventListener('load', () => resolve(), { once: true }) + existing.addEventListener( + 'error', + () => reject(new Error('turnstile script failed to load')), + { once: true } + ) + return + } + window.onloadTurnstileCallback = () => resolve() + const script = document.createElement('script') + script.id = TURNSTILE_SCRIPT_ID + script.src = TURNSTILE_SRC + script.async = true + script.defer = true + script.addEventListener( + 'error', + () => reject(new Error('turnstile script failed to load')), + { once: true } + ) + document.head.appendChild(script) + }) +} + +type TurnstileProps = { + siteKey: string + appearance?: 'always' | 'execute' | 'interaction-only' +} + +export function Turnstile({ + siteKey, + appearance = 'always', +}: TurnstileProps): React.ReactElement { + const containerRef = useRef(null) + const widgetIdRef = useRef(null) + const tokenRef = useRef(null) + const [status, setStatus] = useState<'idle' | 'ready' | 'error'>('idle') + + useEffect(() => { + let cancelled = false + setTurnstileTokenProvider(() => tokenRef.current) + loadTurnstileScript() + .then(() => { + if (cancelled || !window.turnstile || !containerRef.current) { + return + } + widgetIdRef.current = window.turnstile.render(containerRef.current, { + sitekey: siteKey, + appearance, + callback: (token: string) => { + tokenRef.current = token + setStatus('ready') + }, + 'expired-callback': () => { + tokenRef.current = null + setStatus('idle') + }, + 'error-callback': () => { + tokenRef.current = null + setStatus('error') + }, + }) + }) + .catch(() => { + if (!cancelled) { + setStatus('error') + } + }) + return () => { + cancelled = true + setTurnstileTokenProvider(null) + if (widgetIdRef.current && window.turnstile) { + window.turnstile.remove(widgetIdRef.current) + widgetIdRef.current = null + } + tokenRef.current = null + } + }, [siteKey, appearance]) + + return ( +
+
+
+ ) +} + +export function getTurnstileSiteKey(): string | null { + const key = import.meta.env.VITE_TURNSTILE_SITE_KEY + if (typeof key !== 'string' || key.length === 0) { + return null + } + return key +} diff --git a/PROJECTS/beginner/canary-token-generator/frontend/src/pages/landing/artifact.tsx b/PROJECTS/beginner/canary-token-generator/frontend/src/pages/landing/artifact.tsx new file mode 100644 index 00000000..10aedb6c --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/frontend/src/pages/landing/artifact.tsx @@ -0,0 +1,163 @@ +// =================== +// ©AngelaMos | 2026 +// artifact.tsx +// =================== + +import { useMemo } from 'react' +import { toast } from 'sonner' +import type { Artifact } from '@/api' +import { CopyField, Pill } from '@/components' +import styles from './landing.module.scss' + +type ArtifactDisplayProps = { + artifact: Artifact + filenameFallback: string +} + +export function ArtifactDisplay({ + artifact, + filenameFallback, +}: ArtifactDisplayProps): React.ReactElement { + if (artifact.kind === 'url') { + return + } + if (artifact.kind === 'file') { + return ( + + ) + } + if (artifact.kind === 'text') { + return ( + + ) + } + return +} + +function UrlArtifact({ + artifact, +}: { + artifact: Extract +}): React.ReactElement { + const url = artifact.url ?? '' + return ( +
+ + {artifact.destination_url ? ( + + ) : null} +
+ ) +} + +function FileArtifact({ + artifact, + filenameFallback, +}: { + artifact: Extract + filenameFallback: string +}): React.ReactElement { + const filename = artifact.filename ?? filenameFallback + const contentType = artifact.content_type ?? 'application/octet-stream' + const href = useMemo(() => { + if (!artifact.content_b64) { + return null + } + try { + return base64ToObjectUrl(artifact.content_b64, contentType) + } catch (_err) { + return null + } + }, [artifact.content_b64, contentType]) + + if (!href) { + return artifact unreadable + } + + return ( +
+
+ + FILE + {filename} + {contentType} + + toast.success(`Issuing ${filename}`)} + > + download + +
+
+ ) +} + +function TextArtifact({ + artifact, + filenameFallback, +}: { + artifact: Extract + filenameFallback: string +}): React.ReactElement { + const filename = artifact.filename ?? filenameFallback + const content = artifact.content ?? '' + const contentType = artifact.content_type ?? 'text/plain' + const href = useMemo(() => { + if (content.length === 0) { + return null + } + const blob = new Blob([content], { type: contentType }) + return URL.createObjectURL(blob) + }, [content, contentType]) + + return ( +
+
+ + TEXT + {filename} + {contentType} + + {href ? ( + + download + + ) : null} +
+
{content}
+
+ ) +} + +function ConnectionStringArtifact({ + artifact, +}: { + artifact: Extract +}): React.ReactElement { + const value = artifact.connection_string ?? '' + return ( +
+ +

+ Open in any MySQL client. The handshake will trip the trap. +

+
+ ) +} + +function base64ToObjectUrl(b64: string, contentType: string): string { + const binary = atob(b64) + const len = binary.length + const bytes = new Uint8Array(len) + for (let i = 0; i < len; i += 1) { + bytes[i] = binary.charCodeAt(i) + } + return URL.createObjectURL(new Blob([bytes], { type: contentType })) +} diff --git a/PROJECTS/beginner/canary-token-generator/frontend/src/pages/landing/copy.ts b/PROJECTS/beginner/canary-token-generator/frontend/src/pages/landing/copy.ts new file mode 100644 index 00000000..b5ede77e --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/frontend/src/pages/landing/copy.ts @@ -0,0 +1,61 @@ +// =================== +// ©AngelaMos | 2026 +// copy.ts +// =================== + +import type { TokenType } from '@/api' + +export const PAGE_COPY = { + HEADLINE: 'CANARIES', + HEADLINE_LATIN: 'Serinus Canaria · token-form (n.)', + HEADLINE_PURPOSE: + 'Field-deployable trip-wires for the quiet hours. Each specimen waits in place, reports when touched, and leaves a forensic note behind.', + STRIP_FIELD_STATION: 'FIELD STATION', + STRIP_ISSUE: 'ISSUE', + STRIP_VOLUME: 'VOL.001', + STRIP_FOLIO: 'FOLIO', + SECTION_01_INDEX: '01 / SPECIES', + SECTION_01_TITLE: 'SELECT SPECIES', + SECTION_01_BODY: + 'Each type lives in a different habitat. Pick the one that fits where the trap will be set.', + SECTION_02_INDEX: '02 / FIELD LABEL', + SECTION_02_TITLE: 'ANNOTATE THE SPECIMEN', + SECTION_02_BODY: + 'A short note describing where this specimen will be deployed. You will see it again when the trap reports.', + SECTION_03_INDEX: '03 / ALERT ROUTE', + SECTION_03_TITLE: 'ROUTE THE REPORT', + SECTION_03_BODY: + 'When the trap is touched, where should the message land? Choose one channel.', + SECTION_04_INDEX: '04 / SPECIES CONFIG', + SECTION_04_BODY: 'Configuration specific to the chosen species.', + SECTION_05_INDEX: '05 / VERIFY', + SECTION_05_BODY: + 'A one-touch human check from Cloudflare. Skipped in development.', + SUBMIT_LABEL: 'Release specimen', + ISSUE_ANOTHER_LABEL: 'Release another', + RESULT_HEADLINE: 'SPECIMEN ISSUED', + RESULT_BODY: + 'The trap is live. Keep the manage URL safe — it is the only way back to this dossier.', +} as const + +export const TOKEN_BLURB: Record = { + webbug: + '1×1 transparent pixel. Embed anywhere an works — emails, wiki pages, slide decks.', + slowredirect: + 'A redirect that pauses to fingerprint the visitor before delivering them. Drop-in short link replacement.', + docx: 'A Word document that phones home the moment Word renders its footer.', + pdf: 'A PDF whose embedded URI action fires in Adobe Reader. Quiet against Chromium / PDF.js.', + kubeconfig: + 'A kubeconfig pointing at a fake Kubernetes API. kubectl calls trigger and are recorded with their verb + path.', + envfile: + 'A plausible-looking .env file with bait credentials and one disguised canary URL.', + mysql: + 'A fake MySQL server that answers the handshake, captures the username, then 1045 ACCESS DENIED.', +} + +export const ARTIFACT_LABEL = { + url: 'TRIGGER URL', + file: 'DOWNLOAD', + text: 'CONTENT', + connection_string: 'CONNECTION STRING', +} as const diff --git a/PROJECTS/beginner/canary-token-generator/frontend/src/pages/landing/form-state.ts b/PROJECTS/beginner/canary-token-generator/frontend/src/pages/landing/form-state.ts new file mode 100644 index 00000000..e3fd3e4d --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/frontend/src/pages/landing/form-state.ts @@ -0,0 +1,136 @@ +// =================== +// ©AngelaMos | 2026 +// form-state.ts +// =================== + +import { + type CreateTokenInput, + createTokenRequestSchema, + type EnvfileIncludeKey, + type TokenType, + type TypeDescriptor, +} from '@/api' +import { ApiError } from '@/core/api' + +export type FormState = { + type: TokenType | '' + memo: string + filename: string + alertChannel: 'telegram' | 'webhook' + telegramBot: string + telegramChat: string + webhookUrl: string + slowredirectDestination: string + envfileIncludeKeys: EnvfileIncludeKey[] +} + +export const INITIAL_FORM: FormState = { + type: '', + memo: '', + filename: '', + alertChannel: 'telegram', + telegramBot: '', + telegramChat: '', + webhookUrl: '', + slowredirectDestination: '', + envfileIncludeKeys: ['aws', 'stripe', 'db'], +} + +export const FILE_KIND_TYPES = new Set([ + 'docx', + 'pdf', + 'kubeconfig', + 'envfile', +]) + +export function descriptorFor( + descriptors: TypeDescriptor[], + type: TokenType | '' +): TypeDescriptor | undefined { + if (type === '') { + return undefined + } + return descriptors.find((d) => d.type === type) +} + +export function buildPayload(form: FormState): CreateTokenInput { + if (form.type === '') { + throw new Error('species not selected') + } + const payload: CreateTokenInput = { + type: form.type, + memo: form.memo, + alert_channel: form.alertChannel, + } + if (form.filename.length > 0) { + payload.filename = form.filename + } + if (form.alertChannel === 'telegram') { + payload.telegram_bot = form.telegramBot + payload.telegram_chat = form.telegramChat + } else { + payload.webhook_url = form.webhookUrl + } + if (form.type === 'slowredirect') { + payload.metadata = { destination_url: form.slowredirectDestination } + } else if (form.type === 'envfile') { + payload.metadata = { include_keys: form.envfileIncludeKeys } + } + return payload +} + +export type FieldErrors = Partial> + +const BACKEND_TO_FORM_FIELD: Readonly> = { + type: 'type', + memo: 'memo', + filename: 'filename', + alert_channel: 'alert_channel', + telegram_bot: 'telegram_bot', + telegram_chat: 'telegram_chat', + webhook_url: 'webhook_url', + 'metadata.destination_url': 'metadata.destination_url', + 'metadata.include_keys': 'metadata.include_keys', +} + +export function buildSubmissionState(error: unknown): { + errors: FieldErrors +} { + if (!(error instanceof ApiError) || !error.fields) { + return { errors: { form: 'submission failed — try again' } } + } + const errors: FieldErrors = {} + for (const [key, value] of Object.entries(error.fields)) { + const mapped = BACKEND_TO_FORM_FIELD[key] ?? key + errors[mapped] = value + } + return { errors } +} + +export function validateForm(form: FormState): { + ok: boolean + errors: FieldErrors + payload: CreateTokenInput | null +} { + if (form.type === '') { + return { ok: false, errors: { type: 'select a species' }, payload: null } + } + let payload: CreateTokenInput + try { + payload = buildPayload(form) + } catch (_err) { + return { ok: false, errors: { type: 'select a species' }, payload: null } + } + const parsed = createTokenRequestSchema.safeParse(payload) + if (parsed.success) { + return { ok: true, errors: {}, payload } + } + const errors: FieldErrors = {} + for (const issue of parsed.error.issues) { + const key = issue.path.join('.') || 'form' + if (errors[key] === undefined) { + errors[key] = issue.message + } + } + return { ok: false, errors, payload: null } +} diff --git a/PROJECTS/beginner/canary-token-generator/frontend/src/pages/landing/index.tsx b/PROJECTS/beginner/canary-token-generator/frontend/src/pages/landing/index.tsx index 3c3ecdea..97aeb682 100644 --- a/PROJECTS/beginner/canary-token-generator/frontend/src/pages/landing/index.tsx +++ b/PROJECTS/beginner/canary-token-generator/frontend/src/pages/landing/index.tsx @@ -3,8 +3,611 @@ // index.tsx // =================== +import { useMemo, useState } from 'react' +import { toast } from 'sonner' +import { + type CreateTokenResponse, + type EnvfileIncludeKey, + envfileIncludeKeySchema, + type TokenType, + type TypeDescriptor, + useCreateToken, + useTokenTypes, +} from '@/api' +import { Button, Halftone, Strip, StripItem } from '@/components' +import { getTurnstileSiteKey, Turnstile } from '@/core/turnstile' +import { ArtifactDisplay } from './artifact' +import { PAGE_COPY, TOKEN_BLURB } from './copy' +import { + buildSubmissionState, + FILE_KIND_TYPES, + type FieldErrors, + type FormState, + INITIAL_FORM, + validateForm, +} from './form-state' +import styles from './landing.module.scss' +import { ResultView } from './result' +import { TypePicker } from './type-picker' + +const ENVFILE_KEYS = envfileIncludeKeySchema.options + export function Component(): React.ReactElement { - return
+ const typesQuery = useTokenTypes() + const createMutation = useCreateToken() + const [form, setForm] = useState(INITIAL_FORM) + const [errors, setErrors] = useState({}) + const [created, setCreated] = useState(null) + const turnstileSiteKey = useMemo(() => getTurnstileSiteKey(), []) + + function patchForm(next: Partial): void { + setForm((prev) => ({ ...prev, ...next })) + } + + function handleSubmit(event: React.FormEvent): void { + event.preventDefault() + const result = validateForm(form) + setErrors(result.errors) + if (!result.ok || !result.payload) { + toast.error('Some fields need attention') + return + } + createMutation.mutate(result.payload, { + onSuccess: (data) => { + setCreated(data) + toast.success('Specimen released') + }, + onError: (error) => { + const submission = buildSubmissionState(error) + setErrors(submission.errors) + }, + }) + } + + function handleAnother(): void { + setCreated(null) + setErrors({}) + setForm((prev) => ({ ...prev, memo: '', filename: '' })) + } + + return ( +
+ + canary + 2026·05 + + {created ? 'DOSSIER' : 'INTAKE'} + + + +
+

{PAGE_COPY.HEADLINE}

+

{PAGE_COPY.HEADLINE_LATIN}

+

{PAGE_COPY.HEADLINE_PURPOSE}

+
+ + + +
+ {created ? ( + + ) : ( + + )} +
+ + + cloudflare tunnel + telegram / webhook + ©AngelaMos 2026 + +
+ ) } Component.displayName = 'Landing' + +type FormViewProps = { + form: FormState + errors: FieldErrors + descriptors: TypeDescriptor[] + descriptorsLoading: boolean + descriptorsError: Error | null + turnstileSiteKey: string | null + submitting: boolean + onChange: (next: Partial) => void + onSubmit: (event: React.FormEvent) => void +} + +function FormView({ + form, + errors, + descriptors, + descriptorsLoading, + descriptorsError, + turnstileSiteKey, + submitting, + onChange, + onSubmit, +}: FormViewProps): React.ReactElement { + const showTypeMetadata = form.type === 'slowredirect' || form.type === 'envfile' + return ( +
+ + + + {showTypeMetadata ? ( + + ) : null} + {turnstileSiteKey ? : null} + + + ) +} + +type SectionPropsBase = { + form: FormState + errors: FieldErrors + onChange: (next: Partial) => void +} + +type SpeciesSectionProps = SectionPropsBase & { + descriptors: TypeDescriptor[] + descriptorsLoading: boolean + descriptorsError: Error | null +} + +function SpeciesSection({ + form, + errors, + descriptors, + descriptorsLoading, + descriptorsError, + onChange, +}: SpeciesSectionProps): React.ReactElement { + return ( +
+ {descriptorsError ? ( +

+ Could not load species catalog. Try again in a moment. +

+ ) : descriptorsLoading ? ( +

Loading species catalog…

+ ) : ( + onChange({ type: t })} + invalid={errors.type !== undefined} + /> + )} + {errors.type ?

{errors.type}

: null} +
+ ) +} + +function AnnotateSection({ + form, + errors, + onChange, +}: SectionPropsBase): React.ReactElement { + const showFilename = + form.type !== '' && FILE_KIND_TYPES.has(form.type as TokenType) + return ( +
+
+ MEMO +