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/discoverover 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
Repositoryinterface, its file/SQL backends, and the/api/graphsRoute 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.tsGET /api/graphs→repo.list()→{ graphs: GraphSummary[] }.POST /api/graphs→ merges the body overemptyGraph(name), runsvalidateGraph()(422 on failure), thenrepo.create()→201.
src/app/api/graphs/[id]/route.tsGET /api/graphs/:id→ full graph or404.PUT /api/graphs/:id→ first a runtime shape check viahasGraphCollections()(src/server/graphSchema.ts), which requiresnameplus theaccounts/resources/relationshipsarrays and returns422if any are missing — this guards a trust boundary so undefined collections can’t be persisted as silent data loss. It then fills optional defaults overemptyGraph(body.name), runsvalidateGraph()(422on errors), andrepo.update()(404if absent).DELETE /api/graphs/:id→repo.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_TOKENis set, every request must sendAuthorization: Bearer <token>matching it; otherwise the guard returns401. The scheme is matched case-insensitively (RFC 7235) and the token itself is compared in constant time — both inputs are SHA-256 hashed sotimingSafeEqualgets equal-length buffers and the comparison leaks neither the token nor its length via timing. - If
AWS_FLOW_API_TOKENis 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 var | Default | Purpose |
|---|---|---|
AWS_FLOW_REPOSITORY | file | Backend for the retained server tier (file; postgres/dynamodb are designed-for). Not consulted by the current browser-local UI. |
AWS_FLOW_DATA_DIR | .data/graphs | Directory 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 helper | Route | Used by the live UI? |
|---|---|---|
listGraphs() | GET /api/graphs | No — superseded by localStore.ts |
getGraph(id) | GET /api/graphs/:id | No — superseded by localStore.ts |
createGraph(g) | POST /api/graphs | No — superseded by localStore.ts |
updateGraph(id, g) | PUT /api/graphs/:id | No — superseded by localStore.ts |
deleteGraph(id) | DELETE /api/graphs/:id | No — superseded by localStore.ts |
runDiscovery({ region, types, accountId?, creds? }) | POST /api/discover | Yes — 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/updateGraphpassisInfrastructureGraph(a shallow check forid/name/schemaVersion+ the three array collections) andlistGraphs/runDiscoverycheck 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. runDiscoverycarries optionalcreds. TheScanCreds(accessKeyId/secretAccessKey/sessionToken?) are sent over HTTPS for a single request and used only in-memory server-side — never stored (see the credential boundary).