Entity Overview

accounts 1 ──── n identities        (login credentials)
accounts 1 ──── n catalogs          (via catalogs.owner)
catalogs 1 ──── n books             (cascade delete)
revoked_tokens                      (logout blocklist, standalone)
profiles n ──── 1 accounts          (display data)

Wire Schemas (shared)

Everything that crosses the wire derives from two zod schemas in shared/src/domain.ts — the types are inferred, so client and server can never disagree:

export const zMessage = z.object({
  type: zMessageType,   // "command" | "query" | "reply" | "event"
  from: zMessageFrom,   // x:… — 28-byte network id + token | "anonymous"
  to: String64,         // operation name, e.g. "books/list"
  data: AnyRecord,
  meta: z.optional(AnyRecord),
})

export const zIdentity = z.object({
  accountId: String64,
  identifier: String64,
  role: z.enum(["BUYER", "SELLER", "ADMIN"]).default("BUYER"),
})

TokenIdentity is what lives inside the encrypted token — it never exists client-side in readable form.

accounts

// server/src/services/accounts/schema.ts (excerpt)
export const accounts = table("accounts", {
  type: text("type", { enum: ["SYSTEM", "MANAGER", "OPERATOR", "MEMBER"] }).notNull(),
  status: text("status", { enum: ["INACTIVE", "ACTIVE", "SUSPENDED"] }).notNull(),
  role: text("role", { enum: ["BUYER", "SELLER", "ADMIN"] }).notNull().default("BUYER"),

  balance: real("balance").notNull().default(0.0),

  privateKey: text("private_key").notNull(),
  publicKey: text("public_key").notNull(),

  createdAt,
  updatedAt,
  id,
})

Every account carries an ECDSA P-384 keypair from creation — groundwork for signed messages. status gates login (accounts/connect rejects anything not ACTIVE).

identities

Credentials are separated from accounts, so one account can hold multiple ways to authenticate:

export const identities = table("identities", {
  type: text("type", { enum: ["SECRET", "TEMPORARY"] }).notNull(),
  identifier: text("identifier").notNull(),
  token: text("secret").unique().notNull(),   // PBKDF2-SHA384 hash
  settings: blob("settings", { mode: "json" }).default("{}"),

  accountId: text("account_id")
    .notNull()
    .references(() => accounts.id, { onDelete: "cascade" }),
  /* … timestamps, id … */
})

token stores the hash of the secret, never the secret — and the TEMPORARY type reserves space for one-time tokens (invites, resets).

catalogs & books

export const catalogs = table("catalogs", {
  id: text("id").unique().primaryKey().default(uniqId),
  name: text("name").notNull(),
  owner: text("owner").notNull(),           // accountId — checked in operations
  fee: real("fee").default(0),
  rules: text("rules"),                     // e.g. "fiction" → rules/fiction.yaml
  createdAt,
  updatedAt,
})

export const books = table("books", {
  price: real("price").notNull(),
  catalogId: text("catalog_id")
    .notNull()
    .references(() => catalogs.id, { onDelete: "cascade" }),
  meta: text("meta").notNull().default("{}"), // JSON: { title, author, isbn, genre }
  id,
  createdAt,
  updatedAt,
})

The meta JSON column is the schema-flexibility play: one table serves every category, with shape enforced per-catalog by the YAML rules engine at the operation boundary rather than by the database (see Data Access Layer).

revoked_tokens

The logout blocklist — the piece that keeps stateless sessions honest:

export const revokedTokens = table("revoked_tokens", {
  tokenHash: text("token_hash").primaryKey(),  // deterministic SHA-384
  revokedAt: createdAt,
})

The hash is deterministic on purpose: a lookup key must be reproducible. In the cloud adaptation this table becomes a Redis set with a TTL (see Cloud Architecture).

Client-Side State (for completeness)

The browser persists exactly one IndexedDB record — a key/value cache holding the agent id and the auth session (auth/token, auth/username, auth/role). Everything else is either in-memory (Pinia) or cached and disposable (TanStack Query).