Skip to content

Universal Export System ​

LenserFight lets you export any first-class entity — battles, workflows, lenses, agents — into a portable file that you can share, audit, replay, or commit to git. This page explains the architecture, the security guarantees, and how to plug a new entity or format into the pipeline.

Status. Phase EX-1 is shipped: single-file exports for battle and lens in markdown and json. Bundles, YAML, workflow/agent serializers, and the localhost-desktop transport land in EX-2 through EX-5.

TL;DR ​

You want…FormatDestinationTooling that consumes it
Share a battle on GitHubmarkdownCloud / DeviceGitHub auto-renders frontmatter
Feed an agent into a scriptjsonCloud / DeviceLenserFight SDK + Claude API
Commit a workflow to a repo (EX-2)yamlWorkspacelf run -f workflow.yaml
Snapshot everything for backup (EX-2)bundleCloud (.zip)lf import bundle.zip

Pick Markdown for humans, JSON for code, YAML for GitOps.

Layers (Nx boundaries) ​

LayerPackageResponsibility
libs/domain/exports@lenserfight/domain/exportsEnvelope, manifest, canonical-JSON, redaction policy, invariants
libs/api/exports@lenserfight/api/exportsDTOs, error codes, type guards (shared with edge fn and SDK)
libs/data/exports@lenserfight/data/exportsExportsRepositoryPort + Supabase implementation
libs/shared/serializers@lenserfight/shared/serializersSerializer registry, JSON + Markdown adapters. Isomorphic.
libs/features/exports@lenserfight/features/exportsExportOrchestrator, transports, ExportButton, ExportModal
supabase/functions/exports-*(EX-2)exports-build / exports-status / exports-revoke edge functions

The serializer registry is isomorphic — no DOM, no Node-only APIs — so the same code runs in the browser, in apps/cli, and inside Deno edge functions.

Domain model ​

Every export ships inside an ExportEnvelope<T>:

ts
interface ExportEnvelope<T> {
  schema: `lenserfight.export.v${number}`  // pinned major version
  schemaVersion: string                    // semver, currently "1.0.0"
  kind: ExportKind                         // battle | workflow | lens | agent | bundle
  generatedAt: string                      // ISO-8601 UTC, NOT in the checksum
  generatedBy: { userId: string | null; via: 'web' | 'cli' | 'api' }
  source:      { host: string; tenantId: string | null; commit?: string }
  visibility:  'public' | 'authenticated' | 'owner'
  redactions:  string[]                    // every JSON path stripped by policy
  data:        T                           // the typed payload
  checksum:    string                      // sha256 of canonical(data)
}

Three invariants are enforced inside the domain layer:

  1. The checksum is computed over the canonical JSON of data only, never including generatedAt — so identical entities always produce identical hashes (deduplication-friendly across machines).
  2. redactions must be non-empty whenever the caller does not have owner scope. An anonymous export of a battle with apiKey and judge_prompt lists both paths under redactions.
  3. schemaVersion is bumped on any removal or rename, never on additive changes (Protected Variations).

Validation runs after every envelope is minted — ExportEnvelopeFactory.build() throws ExportValidationError if any invariant is broken.

Format guarantees ​

FormatDeterminismHostile-content hygiene
markdownYAML frontmatter + GFM body + checksum footer<script> / <img onerror> stripped, control chars stripped, frontmatter strings always double-quoted with backslash escaping
jsonCanonical JSON (RFC 8785 subset)No NaN/Infinity, no bigint, UTF-8 NFC strings
yamlBlock style only, no anchors/aliases (EX-2)Same control-char policy as markdown frontmatter

The JSON serializer round-trips itself: serialize → parse → canonicalize must produce the original bytes. The validate() step in the registry catches any drift before the user gets a download.

Security model ​

Redaction policy ​

RedactionPolicy is the single authority for what gets stripped. The same code runs server-side (edge fn) and client-side (localhost export of user-owned data), so the two paths can never diverge.

TierAlways strippedOwner seesNon-owner sees
apiKey, secret, token, password, byok, authorization, signing_secret, bearer, credentialsyes❌❌
email, billing, stripe, ip_address, internal_notes, voter_id–✅❌
judge_prompt, evaluation_rationale, admin_note–✅only if authenticated

Tests in libs/domain/exports/src/lib/redaction.spec.ts cover all three tiers, nested objects, and arrays.

Path traversal ​

The safeJoinWithinRoot helper (Pure Fabrication in the domain layer) normalizes every relative path before any filesystem adapter sees it. It refuses:

  • .. segments that escape the workspace root (after collapsing redundant . and double separators)
  • paths containing \0 (NUL bytes)
  • empty results
  • Windows-reserved names (CON, PRN, AUX, NUL, COM1-9, LPT1-9)

The cloud transport never touches a filesystem; the local-workspace transport (EX-4) routes every write through this helper.

XSS / frontmatter injection ​

The markdown serializer treats every string as untrusted. Control characters are stripped, HTML tags and comments are stripped, and frontmatter strings are always double-quoted with backslash escaping. A title like <script>alert(1)</script>OK lands in the output as just OK. Tests in libs/shared/serializers/src/lib/serializers.spec.ts lock this in.

Signed downloads (EX-2) ​

PropertyValue
TTL10 min single / 30 min bundle
Replay defencenonce stored in export_jobs.request->>'nonce'; reuse → 410 Gone
Tenant isolationbucket key prefixed with <tenantId>/; cross-tenant bundles → 409 Conflict
Bundle size cap100 MB / request
Rate limit30 single/min, 5 bundle/hr (token bucket)

GRASP / OOAD walk-through ​

The system uses each GRASP principle once, with a concrete callsite — no abstract theory:

PrincipleWhereWhat it buys
Information Expertper-entity serializers (next to their domain)Battle owns its export shape; workflow doesn't reach into battle internals.
CreatorExportEnvelopeFactoryThe only place envelopes are minted — invariants can't be bypassed.
ControllerExportOrchestratorUI never coordinates fetch+serialize+deliver; one entry point per use case.
Low CouplingExportTransport interfaceCloud ↔ local swap doesn't touch serializers.
High Cohesionlibs/features/exportsUI, hooks, runtime detection, orchestrator wiring co-located.
PolymorphismSerializer + ExportTransportNew formats / destinations plug in via registration, no switch ladder.
Pure FabricationRedactionPolicy, SerializerRegistry, safeJoinWithinRootNo natural domain home, isolated and easy to mock.
IndirectionExportsRepositoryPortEdge URL / signed-URL provider / bucket can change in one place.
Protected VariationsschemaVersion + manifest versionAdditive fields are safe; removals require a major bump.

Reusing existing UI ​

ExportModal is composed entirely from libs/ui/* primitives — never raw HTML:

  • Dialog + ModalFooter (overlays) for the shell and sticky footer
  • SegmentedControl (components) for both the format and destination pickers, via FormatSelector / DestinationSelector
  • InlineNotice (feedback) for the privacy explainer and error surface
  • Button (components) wrapped by ModalFooter
  • HelpButton (components) deep-linked to this very page

The HelpButton resolves the user's stored language via useDocsLocale() so a Turkish user lands on the Turkish translation of this article, an English user lands here.

Destinations ​

TransportRuntimesWhat it does
cloud-downloadcloud / localhost-browser / localhost-desktopEnqueues an exports-build job, returns a 10-min signed URL.
local-downloadlocalhost-browser / localhost-desktopRenders the file client-side and triggers <a download>. No upload.
local-workspace (EX-4)localhost-desktop onlyWrites atomically to .lenserfight/exports/<kind>/<slug>/…

useRuntimeMode() (backed by useSyncExternalStore) feeds the destination selector. SSR snapshot is always 'cloud' so the first render is deterministic; the client may resolve to localhost-browser or localhost-desktop without a hydration mismatch.

How to add a new format ​

  1. Implement the Serializer<T> interface for the (kind, format) pair.
  2. Register it in libs/shared/serializers/src/lib/bootstrap.ts.
  3. Add the format to EXPORT_FORMATS and formatExtension() in libs/domain/exports.
  4. The FormatSelector picks up the new option automatically; no orchestrator change.

If your serializer introduces a new field on ExportEnvelope, bump EXPORT_SCHEMA_VERSION only when the change is removal or rename. Additive fields stay on 1.x.y.

File layout (EX-4+) ​

.lenserfight/
├── exports/
│   ├── battles/<slug>/<YYYY-MM-DDTHHMMSSZ>--<shortid>.{md,json,yaml}
│   ├── workflows/<slug>/…
│   ├── lenses/<slug>/…
│   └── agents/<slug>/…
├── manifests/<exportId>.manifest.json
├── snapshots/<YYYY-MM-DD>/<exportId>.zip
└── cache/exports/<sha[:2]>/<sha>          # content-addressed dedup

buildExportFilename({ slug, format }) produces the filename: kebab-case slug + ISO-8601 basic UTC + 6-char base32 short id. Collision-safe across machines and clocks.

Phasing ​

PhaseScope
EX-1Domain types, registry, JSON + Markdown for battle and lens, cloud transport.
EX-2YAML, bundle ZIP, bulk toolbar, edge fn + queue, signed URLs, audit log.
EX-3Workflow + Agent serializers, preview modal, validation lifecycle UI, history.
EX-4Localhost-desktop transport (Tauri bridge), .lenserfight/exports/ writes.
EX-5Snapshots, replay/import compatibility tests, schema v1→v2 migration framework.

Each phase is independently shippable behind the reliability gate.