fullstack app library β auth, sessions, accounts, DB, SSE, route specs, CLI infrastructure
NOTE: AI-generated
For coding conventions, see Skill(fuz-stack). Commit policy: see workspace
CLAUDE.md (this repo is in git_commit_deny).
When two designs are on the table β one narrow and one with cleaner layering
β choose the cleaner one even when it costs churn or breakage. Layered shapes
(e.g. domain code that returns {status, body} and lets each transport bind,
vs. domain code emitting transport-shaped responses in-line) compound across
consumers and time; "narrow diff" reasoning ships drift to every dispatcher
and test that extends the surface later. Pay the churn once at the source.
Sample applications: the dispatcher authorization phase fold (auth-domain
{status, body} β transport-bound responses) and most other refactors that
touch a shared boundary.
Defense-in-depth checks that exist to compensate for divergent write
semantics between test helpers and production are smells. When you find
one, ask whether the test helper should mirror prod state instead β most
of the time the redundant check is doing real work for the wrong reason.
Better: align write semantics so production code can trust a single
signal. If a test path genuinely must diverge from prod (cost, scope),
document the divergence at the symbol level (_unscoped, _direct,
TEST_CONTEXT_PRESET_KEY) so the redundancy is explicit, not
load-bearing. The bootstrap case in auth/bootstrap_account.ts is the
canonical example β a query_account_has_any check inside the
bootstrap transaction existed to defend against the test helper
leaving bootstrap_lock unflipped while still inserting an account;
teaching the test helper (bootstrap_test_keeper) to flip the lock
the same way production does made the redundant check droppable and
let production code trust the lock as the single signal.
- ./docs/identity.md β Auth design rationale
- ./docs/security.md β Security properties and deployment
- ./docs/architecture.md β DB, session, error schema, subsystem details
- ./docs/usage.md β Code examples (routes, server, SSE, action specs)
- ./docs/testing.md β Consumer test suite wiring guide
- ./docs/local-daemon.md β PGlite local daemon pattern
Standard gro commands apply (see Skill(fuz-stack)). Never run gro dev β
the user manages the dev server.
Consumer projects import from dist/ via .js specifiers. After modifying
fuz_app source, run gro build before consumers can see the changes:
cd ~/dev/fuz_app && gro build # rebuild dist/ with updated types
cd ~/dev/{consumer} && gro check --build --no-lint --no-gen # check consumerConsumers use --no-lint --no-gen because lint and gen are fuz_app-local concerns.
When iterating across fuz_app + a consumer locally, symlink the fuz_app
package root into the consumer's node_modules β not fuz_app's
source directory or dist/:
# Correct
ln -s ~/dev/fuz_app ~/dev/{consumer}/node_modules/@fuzdev/fuz_appThe exports map in fuz_app's package.json references ./dist/* from
the package root β symlinking the root makes consumer imports resolve to
the freshly-built dist/ after each gro build. Symlinking source or
dist/ directly breaks resolution and surfaces as missing-export errors.
Dual-resolution noise. Even with the correct symlink, TypeScript's
strict structural identity surfaces "Two different types with this name
exist" errors when fuz_app's nested node_modules/@fuzdev/fuz_util (or
hono) gets reached separately from the consumer's top-level copy.
The errors look like Type 'Logger' is not assignable to type 'Logger'
with paths differing only in nesting depth. Resolution: dedupe by
symlinking fuz_util into fuz_app's node_modules too β
cd ~/dev/fuz_app && npm link @fuzdev/fuz_utilβ so consumer imports and fuz_app imports resolve to the same on-disk copy. See visionesdelcaribe.org's CLAUDE.md Β§"Cross-repo work via local npm link" for the full triple-link pattern across fuz_app + fuz_util + consumer. Tests are the load-bearing signal when the typecheck step shows this noise; once the symlink layout converges, both typecheck and test pass.
Deno + nodeModulesDir: "manual" rejects version-skewed symlinks.
Deno's manual mode reconciles node_modules against the consumer's
declared range. If the local fuz_app's version doesn't satisfy the
consumer's ^x.y.z constraint, Deno errors with "Could not find
@fuzdev/fuz_app in a node_modules folder" β not because the symlink
itself is rejected, but because the version doesn't match. The fix is
to bump the consumer's package.json + deno.json constraints to
admit the locally-published version, then re-run npm install. The
symlink itself works fine once versions align; the gro transitive
oxc-parser also forces --allow-ffi on the Deno binary, and
test-binary scenarios that spawn shell commands need --allow-run
(verified in zzz's cross-backend test config).
fuz_app uses deep path imports β no barrel/index exports. The wildcard
exports in package.json ("./*.js") makes every module in dist/ importable:
import { create_app_server } from '@fuzdev/fuz_app/server/app_server.ts';
import { create_session_config } from '@fuzdev/fuz_app/auth/session_cookie.ts';
import type { RouteSpec } from '@fuzdev/fuz_app/http/route_spec.ts';Dense subsystems have nested CLAUDE.md β consult those when working in
that subtree.
- auth/ β crypto (keyring, session, password, api/daemon tokens, bootstrap), schemas + DDL,
query_*overQueryDeps, middleware, routes, RPC action registries (admin, role-grant-offer, account, self-service-role, actor-lookup, actor-search) +standard_rpc_actionsbundle, cleanup. βsrc/lib/auth/CLAUDE.md - http/ β generic framework:
RouteSpec+ declarative transactions, three-layer error schema merge, JSON-RPC 2.0 envelopes + errors, origin/proxy middleware,AppSurfacegeneration, post-commitemit_after_commit. βsrc/lib/http/CLAUDE.md - actions/ β SAES (Symmetric Action Event System):
ActionSpectypes, registry-compile invariants, sharedperform_actioncore, RPC dispatcher, REST/WS bridges, transports (HTTP, WS frontend + backend, auth guard),ActionDispatcher+ serverβclient peer requests (peer/ping), reactiveFrontendWebsocketClient, typed RPC client. βsrc/lib/actions/CLAUDE.md - ui/ β Svelte 5 components, runes-based
*_state.svelte.tsmodules,*_rpc_contextDI pattern, auth/admin/role-grant-offer forms, datatable, popovers, layout shell. βsrc/lib/ui/CLAUDE.md - testing/ β test utilities exported to consumers; every module starts with
import './assert_dev_env.js'. βsrc/lib/testing/CLAUDE.md
- db/ β
Dbabstraction over pg + PGlite, URL-based driver auto-detection (create_db), advisory-lock migrations (run_migrations,Migration),QueryDepspattern,assert_rowfor INSERT RETURNING,is_pg_unique_violation,assert_valid_sql_identifier, the*_COLUMNSprojection derivation helpers (columns_sql/qualify_columns/omit_columnsinsql_columns.ts, each taking an optional per-columnColumnExprβiso8601_timestamp_columnis the one in use, built per table byiso8601_timestamp_expr), CLI DB status utility. Also the cell content-primitive schema + queries (cell+cell_grant+cell_field+cell_item, namespacefuz_cell; dormantcell_history); its wire/RPC/authz layer lives in auth/. Plus the optional fact content-addressed byte store (fact+fact_ref+memo, namespacefuz_facts;PgFactStoreover the@fuzdev/fuz_util/fact_store.tsinterface) β size-routed embedded/disk-CAS/put_refwrites plus the bounded-memory streamingput_stream; the<shard>/<rest>disk CAS (fact_disk_storage.tsoverruntime/*Deps), its URL/layout shape (file_fact_url.ts), and theput_streamerror types (fact_store_errors.ts) live in db/; the read-side fetcher + write/serve plumbing live in server/. βsrc/lib/db/CLAUDE.md - server/ β two-step assembly:
create_app_backend(deps + DB +close; accepts optionalmigration_namespacesto splice consumer migrations after the builtin auth namespace, rejecting the reserved'fuz_auth'name) thencreate_app_server(requires initialized backend).AppServerContextcarriesaudit_sse.rpc_endpoints(HTTP RPC auto-mount) andws_endpoints(WebSocket auto-mount β paired with top-levelupgradeWebSocket) are the single source of truth for surface generation and live dispatch β each entry is auto-mounted bycreate_app_server, so consumers no longer callcreate_rpc_endpoint/register_ws_endpointthemselves.AppServer.ws_endpointsreturns the path-keyed transport map for broadcast. Also env validation (validate_server_env), multi-phase SvelteKit static fallback,log_startup_summary,validate_nginx_config. Optional fact-serving plumbing (the URL/layout shapefile_fact_url.ts+ the streaming disk CAS now live in db/):file_fact_fetcher.ts(create_file_fact_fetcherβ filesystemFactExternalFetcher),fact_write.ts(write_factβ buffering embedded-vs-disk size routing),serve_fact_route.ts(cell-scoped fact serving:create_serve_cell_fact_route_specβGET /api/cells/:cell_id/facts/:hash, the per-reference read βcan_view_cell(caller, cell) AND cell.refs includes hash, never unioned across referrers; plus admin-onlycreate_serve_fact_route_specβGET /api/facts/:hash. 404-masked, embedded stream orX-Accel-Redirect). TheX-Accel-Redirectprefix is a make-impossible-states handle:x_accel.ts'sXAccelConfig/create_x_accel_configgate it behind a fail-loudvalidate_facts_internal_locationboot check, so X-Accel can't be enabled against a non-internal;facts location (a public one would bypass every cell-visibility check). The consumer constructsPgFactStore(db/fact_store.ts) + a fetcher and assignsdeps.fact_storeat its own backend assembly;create_app_backendstays facts-agnostic.BaseServerEnvcarriesFUZ_FACTS_DIR+FUZ_FACTS_X_ACCEL_REDIRECT_PREFIX. - realtime/ β
create_sse_response,EventSpec,create_validated_broadcaster.SubscriberRegistry<T>has scope (capped bymax_per_scope) + groups (uncapped) identity split;close_by_identitymatches either.create_sse_auth_guardcloses streams onrole_grant_revoke/session_revoke/session_revoke_all/password_change(ignoresoutcome=failure;session_revokescoped by session hash).AUDIT_LOG_SSE_MAX_PER_SCOPE = 10. - runtime/ β composable
*Depsinterfaces (EnvDeps,FsReadDeps,FsSecureReadDeps(read_secure_fileβ hardened secret read: symlink/permissive-mode/size refusal, twin of Rustfuz_sys::secure_file; Node impl insecure_file.ts),FsWriteDeps(mkdir/write_text_file/write_file/rename+fsyncβ the durability seam the fact disk CAS calls on a temp before its publishing rename; mock no-ops it;write_text_file+mkdirtake{mode, exclusive}creation options),FsRemoveDeps(remove),FsStreamDeps(read_file_stream/write_file_streamβ bounded-memory streaming for GB-scale transfer),FetchDeps,CommandDeps; bundledRuntimeDeps;StatResultcarriessize(byte length) +mtime_ms(epoch ms, populated by node/deno, omitted by the mock β read by the fact-store orphan-temp sweep)). Implementations:create_node_runtime,create_deno_runtime,create_mock_runtime(+MockExitError).write_file_atomicinfs.tsβ unique.{name}.tmp.{pid}.{counter}temp + exclusive create + optionalmode, so a crash-leftover temp is never republished with a stale permissive mode. - cli/ β
parse_command_args,create_extract_global_flags,ParseResult<T>, NO_COLOR-awarecolors,run_local,confirmprompt,CliLogger, generic config loader (get_app_dir,load_config,save_config), daemon info (read_daemon_info,is_daemon_running,check_daemon_health,stop_daemon), schema-driven help (create_help,CommandMeta<T>). - env/ β
load_env()Zod-schema loader +EnvValidationError, masking (format_env_display_value,MASKED_VALUE),$$VAR$$resolution (resolve_env_vars,has_env_vars,scan_env_vars,validate_env_vars,format_missing_env_vars), dotenv parsing (parse_dotenv,load_env_file). - dev/ β consumer setup/reset helpers:
setup_env_file,setup_bootstrap_token,reset_bootstrap_token,create_database,reset_database,read_env_var,generate_random_key,parse_db_name. All accept small*Depsfromruntime/deps.ts.
crypto.tsβgenerate_random_base64url(byte_length?)β shared randomness sourcesensitivity.tsβSensitivity = 'secret'schema_meta.tsβSchemaFieldMetafor Zod.meta()(description + sensitivity)hono_context.tsβ HonoContextVariableMapaugmentation (includesdb: Dbfor declarative transactions)rate_limiter.tsβ sliding-windowRateLimiter,rate_limit_exceeded_response(c, retry_after)429 helperprimitive_schemas.tsβ cross-domain validators:Username,UsernameProvided,Email(split out fromauth/account_schema.tsso non-auth surfaces can reach for them)timestamp.tsβ the canonical wire timestamp shape:to_iso8601_seconds(date)+ theISO8601_SECONDSregex (second-precision UTC, 20 characters). The SQL twin for row-read timestamps isiso8601_timestamp_columnindb/sql_columns.ts
Shared helpers accept small *Deps from runtime/deps.ts (not Pick<GodType, ...>).
hono(>=4),zod(^4),svelte(^5),@sveltejs/kit(^2)@fuzdev/fuz_util(>=0.65.2)@node-rs/argon2(>=2) β forauth/password_argon2@fuzdev/blake3_wasm(>=0.1.0) β forauth/session_queries,auth/bearer_authpg(>=8) or@electric-sql/pglite(>=0.4) β optional, fordb/create_db@hono/node-server(>=1),@hono/node-ws(>=1),ws(>=8) β optional, for the Node server adapter + WebSocket transportesm-env(^1) β optional, for the DEV-only output-validation gate
Three categories β keep them separate:
- Capabilities (
AppDeps) β Stateless, injectable, swappable per env:read_secure_file,delete_file,keyring,password,db,log,audit(the boundAuditEmitterβ built by the consumer'saudit_factorycallback overcreate_audit_emitter, closes over its registered listeners +AuditLogConfig) - Route caps (
RouteFactoryDeps) βOmit<AppDeps, 'db'>β for route factories (handlers getdbviaRouteContext) - Parameters (
*Options) β Static startup values, per-factory:session_options,login_ip_rate_limiter,login_account_rate_limiter,token_path - Runtime state (inline ref) β Mutable values:
bootstrap_statusβ NOT in deps or options
Server assembly is two explicit steps: create_app_backend (deps bundle + DB
metadata + close callback) then create_app_server (requires pre-initialized
AppBackend). When audit_log_sse is set, create_app_server registers
audit_sse.on_audit_event via backend.deps.audit.add_listener so SSE
fan-out runs alongside the consumer's callback (no shallow copy of
AppDeps). Pass argon2_password_deps for production; inject stubs in
tests.
The top-level create_route_specs callback receives (ctx: AppServerContext).
Individual factories take narrower deps: create_account_route_specs(deps: RouteFactoryDeps, options),
create_audit_log_route_specs(options?), create_db_route_specs(deps: DbRouteDeps, options)
(a structural audit slice β ctx.deps satisfies it). Consumers destructure
ctx.deps when calling them.
create_app_server assembles middleware in order:
- Hono context augmentation β side-effect import of
hono_context.ts - Pending effects (
*) β per-request array; flushed viatry/finally+Promise.allSettled - Logging β controlled by
deps.loglevel - Body size limit β default 1 MiB (
DEFAULT_MAX_BODY_SIZE);max_body_sizeto override,nullto disable - Trusted proxy (
*) β resolves client IP from XFF; must run before auth/rate-limiting - Origin verification (
/api/*) - Session parsing (
/api/*) β parses cookie, sets identity on context - Request context (
/api/*) β validates the session and setsc.var.account_id+CREDENTIAL_TYPE_KEY. Account-only β does not load actor or role_grants. - Bearer auth (
/api/*) β CLI clients; same account-only shape. Rejected whenOriginorRefereris present. - Routes β
apply_route_specswithfuz_auth_guard_resolver(params β query β pre-authorization auth (401 + credential type + token scope) β authorization phase β post-authorization auth (403 role) β input validation (400) β handler). Order is 401 β authz β 403 β 400 β handler:require_auth,require_credential_types(types), andrequire_token_scope(capability)all read what the auth middleware set, so they fire before body parsing (no route-shape information to a caller they refuse) and before actor resolution (a wrong channel costs no DB work and learns no account state); the authorization phase then resolves the acting actor whenauth.actor !== 'none'(per registry-time invariant 2, biconditionally implies the input declaredacting?: ActingActor) from theactingselector βc.var.validated_query.actingon GETs, read off the raw body on mutations;require_role(roles)follows as the one gate needing the populatedRequestContext; body validation runs last, so a 400 never describes the route to a caller the authority gates refused. Account-grain routes (auth.actor === 'none') run withRequestContext.actor: null. Same coarse-to-fine priority as the RPC dispatcher (actions/action_rpc.ts). - Static serving (optional) β SvelteKit static fallback
Session parsing is separate from auth enforcement β login and bootstrap routes participate in cookie refresh without being blocked. Acting-actor resolution is separate from authentication β multi-actor accounts can hit account-grain routes (logout, password_change, account_verify) without picking a persona.
Routes are data (RouteSpec[]). apply_route_specs registers them with
auto-validation (params β query β pre-authorization auth β authorization
phase β post-authorization auth β input validation β handler β DEV-only
output + error validation). Duplicate method+path throws at registration. Declarative transactions: transaction?: boolean defaults
to false for GET, true for mutations. Handlers receive (c, route)
where route satisfies QueryDeps; for fire-and-forget effects that must
outlive the transaction (audit writes), call deps.audit.emit(route, input)
β the bound emitter closes over the pool so the row lands even when the
handler's transaction rolls back. generate_app_surface() produces a
JSON-serializable attack surface. Error schemas use three-layer merge
(derived + middleware + explicit β see ./docs/architecture.md).
Input validation runs in both DEV and production (always-on contract for
callers). Output + error-schema validation runs DEV-only via esm-env β
logs an error on mismatch, returns the response unchanged. The asymmetry is
deliberate: caller-facing inputs must be validated; server-authored outputs
are trusted at runtime and checked during development. See
./docs/architecture.md Β§DEV-only Output Validation.
Schema helpers live in http/schema_helpers.ts β import from there, not surface.ts.
One declarative ActionSpec binds to three transport surfaces (REST,
JSON-RPC over HTTP, WebSocket) with uniform DEV-only output validation.
Two bindings live in actions/:
action_rpc.tsβcreate_rpc_endpoint({path, actions, log})produces a single JSON-RPC 2.0 endpoint (GET + POST on same path). Bind specs to handlers withrpc_action<TSpec>(spec, handler)for auto-narrowedctx.auth.action_bridge.tsβcreate_action_route_specderives RESTRouteSpec(escape hatch for SSE, files, custom paths);create_action_event_specderivesEventSpec.
ActionContext is the single handler-context shape across HTTP RPC, WS,
and the REST bridge. Phase order is 401 β authz β 403 β 429 β 400 β
handler on every transport β the REST pipeline without the 429, which is
the dispatcher-only per-action rate limit (ActionSpec.rate_limit). It sits
ahead of validation so malformed params charge the budget too. WS authorizes
per-message, so role_grant changes during a connection lifetime are picked up
on the next message.
For the binding matrix, registry-time invariants, perform_action shared
core, transports, codegen helpers, and reactive frontend client see
src/lib/actions/CLAUDE.md. For DEV-only output validation rationale see
./docs/architecture.md Β§DEV-only Output Validation.
Admin + self-service surfaces are RPC-first. Each registry splits across a
*_action_specs.ts (schemas + specs + registry β importable by typed-client
codegen) and a *_actions.ts (create_*_actions(deps, options) factory
with handlers).
Six factories live in auth/, surfaced via the create_standard_rpc_actions
bundle (admin + role_grant_offer + account) plus three opt-in
extras (self_service_role, actor_lookup, actor_search). For the full
registry table, per-method specs, option routing, error reasons, audit
events, and WS notification fan-out see src/lib/auth/CLAUDE.md Β§RPC
action surfaces.
CreateAppServerOptions.rpc_endpoints is the single source of truth for
RPC mounting β accepts an array or a factory (ctx: AppServerContext) => Array<RpcEndpointSpec>.
create_app_server auto-mounts each via create_rpc_endpoint, so
consumers no longer invoke create_rpc_endpoint themselves.
admin_rpc_adapters.ts (in ui/) exposes create_admin_rpc_adapters(api) +
provide_admin_rpc_contexts(adapters) for single-call wiring of the four
admin RPC contexts (admin_accounts, admin_invites, audit_log,
app_settings).
Only POST /login, POST /logout, POST /password, POST /signup,
POST /bootstrap, GET /verify (empty-body nginx auth_request shim β
the typed payload lives on the account_verify RPC action), and optional
GET /audit/stream (SSE) remain on REST post-migration. Consumer test
suites must pass rpc_endpoints to describe_standard_integration_tests /
describe_standard_admin_integration_tests /
describe_audit_completeness_tests β they hard-fail without it.
See ./docs/testing.md for the consumer wiring guide. Skill(fuz-stack)
covers shared conventions (src/test/ layout, .db.test.ts, assert
from vitest, *Deps over god-type mocks). Backend tests use $lib/
imports.
Cross-process self-tests β fuz_app runs the standard suites against its
own spine over real HTTP (not just in-process) via spawnable TS spine
binaries built on the testing/cross_backend/testing_server_core.ts +
Node/Deno/Bun adapters, plus the Rust testing_spine_stub. These live in the
opt-in cross_backend_* vitest projects (gated behind
FUZ_TEST_CROSS_BACKEND=1, excluded from a bare gro test) and the
npm run benchmark:cross-impl run. See ./src/test/CLAUDE.md Β§Cross-backend
self-tests and src/lib/testing/CLAUDE.md Β§"Building a TS test-server binary".
Cross-impl schema parity β consumers running two backend impls
against a shared schema (e.g., zzz's --backend=both) use
query_schema_snapshot (testing/schema_introspect.ts) +
assert_schema_snapshots_equal / diff_schema_snapshots /
format_schema_diffs (testing/schema_parity.ts) to gate structural
drift between bootstrapped DBs. Captures tables / columns (with
udt_name for int4 vs int8) / indexes / constraints / sequences / enum
types (pg_enum labels in declared order); the
schema_version migration tracker is always excluded (framework
bookkeeping, not domain schema). That exclusion is the gate's scope, not the
parity contract: the TS and Rust spines are permanent twins that must stay
migration-identity-aligned β identical namespaces, migration names, and
partitioning, so the tracker rows are byte-identical and any consumer can swap
TSβRust over one DB without re-bootstrapping. Because the snapshot gate is
provenance-agnostic (it can't see tracker drift β that gap let the cell/fact
migration-name divergence reach the visiones cutover undetected), a separate
_testing_migration_tracker gate dumps the schema_version rows and asserts
the two bootstrapped spines record identical (namespace, name, sequence).
Migration names are descriptive on both spines (full_auth_schema,
full_cell_schema, full_cell_history_schema, full_fact_schema + named
appends like role_grant_offer_and_scoped_role_grants) β identity, not an
ordinal; the schema_version.sequence column carries order, so _vN in a name
would only duplicate it. cell_history is isolated in its own
fuz_cell_history namespace (spliced after fuz_cell) on both spines. Diffs
are tagged-union by kind so failure messages name the specific divergence.
fuz_app gates its own TS spine β testing_spine_stub schema (auth + cell +
cell_history + fact + the cell_visibility enum) and tracker identity via
the cross_backend_parity project (npm run test:cross:parity), which also
runs the action-manifest parity gate (the RPC method-set + per-method
auth-shape twin of schema parity).
When working on tests, touch both directories together:
- ./src/test/ β fuz_app's own suite. See ./src/test/CLAUDE.md.
src/lib/testing/β composable helpers exported to consumers. New shared helpers belong here (every file starts withimport './assert_dev_env.js'). Seesrc/lib/testing/CLAUDE.md.
When middleware or public API gains a new context variable, header, or field,
update both the shared echo/mocks in src/lib/testing/middleware.ts and the
assertions in src/test/auth/*.test.ts.
- Full-stack web app β Auth, admin routes, route specs, SSE, db routes, CLI, env, static, create_db, UI components
- Local daemon (PGlite) β Full auth stack + admin routes, bootstrap with
on_bootstrap, CLI. See ./docs/local-daemon.md - Action-oriented app β Action specs, CLI (runtime, util, config, daemon, help)
git add and git commit are denied by .claude/settings.local.json in
this repo β make the edits and stop, the user commits.