Skip to Content
DocsArchitecture & EngineeringPersistence & the Server

Persistence & the Server Side

How diagrams are persisted (today)

Saved diagrams live in the browser, not on a server. useFlow (src/hooks/useFlow.tsx) imports its save/load helpers from src/lib/localStore.ts, which reads and writes a single localStorage entry under the key strata:graphs:v1 (an id→graph map). The hosted app runs on a read-only serverless filesystem (Vercel), so there is no server-side datastore to write to — each browser keeps its own diagrams, and JSON export/import is how you move one between browsers.

localStore.ts deliberately mirrors the function surface of the old server client (listGraphs / getGraph / createGraph / updateGraph / deleteGraph, all async) so the hook layer only had to swap one import. It assigns crypto.randomUUID() ids, stamps createdAt / updatedAt and SCHEMA_VERSION on write, sorts list() newest-first, tolerates absent/corrupt storage as empty, and surfaces a quota overflow as the friendly error “Browser storage is full — delete a saved diagram or export to JSON.”

Local version snapshots (src/lib/snapshots.ts)

A second localStorage-backed surface keeps version history: snapshots.ts holds a single global ring under strata:snapshots:v1, capped at MAX_SNAPSHOTS (25, newest-first, oldest dropped). Each record is a structuredClone of the full graph plus a label, the diagram name, a createdAt timestamp, and resourceCount (saveSnapshot / listSnapshots / getSnapshot / deleteSnapshot). It powers Data ▾ → “Version history…” — save, restore, and use a snapshot as a drift/cost baseline (compareWithVersion) — surfaces its own quota error (“Browser storage is full — delete some saved versions.”), and is intentionally not cross-device (promotable later to a Repository versions sub-resource — see Roadmap §1).

What’s stored locally — and why it isn’t encrypted

localStorage holds diagrams (strata:graphs:v1), version snapshots (strata:snapshots:v1, above), and saved views (layer/overlay UI state) — and nothing else. In particular it holds no credentials:

  • Discovery credentials never reach the browser store. AWS keys entered in the Connect dialog live only in transient React state, are sent to /api/discover over HTTPS for one scan, and are discarded server-side; GCP/Azure use the server’s ambient credentials and nothing is entered at all. The no-credentials-in-graph invariant (see Live Discovery & MCP) means a saved diagram can’t contain a secret either — config is filtered to registry-known fields.
  • So there’s nothing credential-shaped to encrypt. What’s persisted is the user’s own architecture diagram (resource types, names, config, relationships) — comparable to a local file they created.

Client-side encryption of that diagram data would be security theatre unless tied to a user-supplied passphrase: any key shipped in the app’s own JS is readable by anyone with the bundle, so it protects nothing. A real at-rest guarantee needs a passphrase (with the usual UX cost — lose it, lose the diagrams) or a server-side datastore with proper access control (the retained Repository tier below is where that would live). The current protections are that localStorage is origin-scoped (same-origin only) and that genuinely sensitive material never enters it in the first place.

The server tier below is retained, not wired up. The Repository interface, its file/SQL backends, and the /api/graphs Route Handlers all still exist and still work — they are kept for a future durable, multi-user backend (Postgres/DynamoDB). The live UI simply no longer calls them for graph persistence. The server routes the UI does still use are /api/discover, /api/discover/gcp, and /api/discover/azure (live cloud discovery). The rest of this page documents that retained server tier, which is where a durable backend will plug in.

The graph Route Handlers (retained for a future backend)

The Next.js App Router Route Handlers under src/app/api/graphs/ are a complete, working server tier — currently dormant for graph persistence (the UI saves to localStorage instead), but ready to host a durable backend:

  • src/app/api/graphs/route.ts
    • GET /api/graphsrepo.list(){ graphs: GraphSummary[] }.
    • POST /api/graphs → merges the body over emptyGraph(name), runs validateGraph() (422 on failure), then repo.create()201.
  • src/app/api/graphs/[id]/route.ts
    • GET /api/graphs/:id → full graph or 404.
    • PUT /api/graphs/:id → first a runtime shape check via hasGraphCollections() (src/server/graphSchema.ts), which requires name plus the accounts/resources/relationships arrays and returns 422 if any are missing — this guards a trust boundary so undefined collections can’t be persisted as silent data loss. It then fills optional defaults over emptyGraph(body.name), runs validateGraph() (422 on errors), and repo.update() (404 if absent).
    • DELETE /api/graphs/:idrepo.remove().

Both files set export const dynamic = "force-dynamic" so reads/writes always hit the store. They never talk to a concrete store — only to a Repository.

Auth guard

Every handler in all three API routes (graphs, graphs/[id], and discover) opens with the same two lines:

const denied = requireAuth(req); if (denied) return denied;

requireAuth() (src/server/auth.ts) is an opt-in, shared-secret bearer-token guard:

  • If AWS_FLOW_API_TOKEN is set, every request must send Authorization: Bearer <token> matching it; otherwise the guard returns 401. The scheme is matched case-insensitively (RFC 7235) and the token itself is compared in constant time — both inputs are SHA-256 hashed so timingSafeEqual gets equal-length buffers and the comparison leaks neither the token nor its length via timing.
  • If AWS_FLOW_API_TOKEN is unset or empty (the default, including tests), the guard is a no-op — behaviour is unchanged and access is open.

This is deliberately minimal. There is no per-user or per-tenant model: the token is a single shared secret that closes the door on unauthenticated access (an IDOR mitigation for a deployed single-tenant instance), not an authorization system. Real auth (sessions / JWT / OIDC) would slot in here once the app grows a user model (see Roadmap → item 3).

The Repository interface (src/server/repository.ts)

The application talks to this interface, never to a concrete store:

export interface Repository { list(): Promise<GraphSummary[]>; get(id: string): Promise<InfrastructureGraph | null>; create(graph: InfrastructureGraph): Promise<InfrastructureGraph>; // assigns id + timestamps update(id: string, graph: InfrastructureGraph): Promise<InfrastructureGraph | null>; // null if absent remove(id: string): Promise<boolean>; }

Default: file-backed (src/server/fileRepository.ts)

FileRepository stores each graph as JSON under .data/graphs/<id>.json (override the directory with AWS_FLOW_DATA_DIR). It runs with zero external infrastructure — ideal for local dev and demos. It assigns randomUUID() ids and createdAt/updatedAt timestamps on write, stamps SCHEMA_VERSION, sorts list results by updatedAt, and guards fileFor() against path traversal.

Selecting a backend (src/server/index.ts)

getRepository() lazily constructs and memoizes the active store, chosen by the AWS_FLOW_REPOSITORY env var (default "file"). The switch already has commented slots for "postgres" and "dynamodb":

const kind = process.env.AWS_FLOW_REPOSITORY ?? "file"; switch (kind) { case "file": default: instance = new FileRepository(); break; // case "postgres": instance = new PostgresRepository(); break; // case "dynamodb": instance = new DynamoRepository(); break; }

The instance is memoized as a process-wide singleton, so AWS_FLOW_REPOSITORY must be set before the first request. resetRepository() clears the memo (used in tests that switch backends).

Swapping to Postgres/RDS or DynamoDB

A new backend implements the five-method Repository interface and is registered in the getRepository() switch — no caller changes. Sketch:

// src/server/postgresRepository.ts export class PostgresRepository implements Repository { async list() { /* SELECT id,name,description,jsonb_array_length(resources),updated_at … */ } async get(id) { /* SELECT doc FROM graphs WHERE id = $1 */ } async create(g) { /* INSERT … RETURNING; set id=gen_random_uuid(), created_at/updated_at=now() */ } async update(id, g) { /* UPDATE … WHERE id=$1 RETURNING; null if 0 rows */ } async remove(id) { /* DELETE … WHERE id=$1; return rowCount>0 */ } }

Store the full InfrastructureGraph as a JSONB column (Postgres) or a single item (DynamoDB, partition key = id); list() projects to GraphSummary. Preserve the contract the file store establishes: server-assigned ids, server-stamped timestamps, SCHEMA_VERSION on every write, and null/false on missing records.

Environment variables

Env varDefaultPurpose
AWS_FLOW_REPOSITORYfileBackend for the retained server tier (file; postgres/dynamodb are designed-for). Not consulted by the current browser-local UI.
AWS_FLOW_DATA_DIR.data/graphsDirectory for the file-backed store (retained server tier only).
AWS_FLOW_API_TOKEN(unset)When set, all API routes require Authorization: Bearer <token> (401 otherwise). Unset = open.
NEXT_PUBLIC_STRATA_HOSTED(unset)1/true marks a shared/hosted deploy: disables ambient-credential discovery (see Live Discovery & MCP).

The client data layer (src/lib/api.ts)

src/lib/api.ts is the thin typed fetch client for the server routes. Every helper maps one-to-one to a route, parses the JSON, and on a non-ok response throws an Error carrying the server-provided { error } message (falling back to the HTTP status text):

Client helperRouteUsed by the live UI?
listGraphs()GET /api/graphsNo — superseded by localStore.ts
getGraph(id)GET /api/graphs/:idNo — superseded by localStore.ts
createGraph(g)POST /api/graphsNo — superseded by localStore.ts
updateGraph(id, g)PUT /api/graphs/:idNo — superseded by localStore.ts
deleteGraph(id)DELETE /api/graphs/:idNo — superseded by localStore.ts
runDiscovery({ region, types, accountId?, creds? })POST /api/discoverYes — imported by page.tsx

The graph helpers here share the exact signatures of their localStore.ts counterparts, so re-pointing the hook layer back at the server (once a durable backend exists) is a one-line import swap. Today only runDiscovery is imported from this module; graph save/load goes through localStore.ts.

Two details worth knowing:

  • Defensive shape checks on the way in. parseJson() takes an optional type guard; getGraph/createGraph/updateGraph pass isInfrastructureGraph (a shallow check for id/name/schemaVersion + the three array collections) and listGraphs/runDiscovery check the expected top-level shape, so a malformed response fails fast at the boundary rather than as an undefined-property error deeper in the UI.
  • runDiscovery carries optional creds. The ScanCreds (accessKeyId/secretAccessKey/sessionToken?) are sent over HTTPS for a single request and used only in-memory server-side — never stored (see the credential boundary).
Last updated on