docs(1c-alpha): correct TS type definitions to match actual serde shapes

Verified against the Plan 1A Rust sources:
- ItemType / ItemCore use snake_case with tag="type" internal tagging
  (not the external tagging I initially wrote)
- TotpKind is default-externally-tagged (no tag attr), so it serializes
  as bare "totp"/"steam" for unit variants and { hotp: { counter } }
- GeneratorRequest uses tag="kind" internal tagging
- FieldValue / TrashRetention / HistoryRetention / SymbolCharset use
  adjacent tagging { tag: "kind", content: "value" }
- Fix Login form TOTP parse example and "gen" button payload

No scope change — this is a bookkeeping correction so the plan
author references the correct wire shapes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken
2026-04-20 19:19:44 -04:00
parent a1d733ddeb
commit ad6d8af2f6

View File

@@ -104,14 +104,15 @@ extension/wasm/idfoto_wasm* # stale pre-rename artif
## Shared Types (`shared/types.ts`) ## Shared Types (`shared/types.ts`)
Mirror the Rust core verbatim through serde serialization. The Rust-side `ItemCore` enum is externally-tagged by default, producing `{ "Login": {...} }` style JSON: Mirror the Rust core verbatim through serde serialization. The Rust-side shapes use a mix of `snake_case`, internal tagging (`#[serde(tag = "type")]` for `ItemCore`, `tag = "kind"` for `GeneratorRequest`), adjacent tagging (`tag = "kind", content = "value"` for `FieldValue`, `TrashRetention`, `HistoryRetention`, `SymbolCharset`), and default external tagging (`TotpKind`). The TS types must match exactly:
```ts ```ts
export type ItemId = string; // 16-char hex export type ItemId = string; // 16-char hex
export type FieldId = string; export type FieldId = string;
export type AttachmentId = string; export type AttachmentId = string;
export type ItemType = 'Login' | 'SecureNote' | 'Identity' | 'Card' | 'Key' | 'Document' | 'Totp'; // snake_case strings, matches serde rename_all = "snake_case"
export type ItemType = 'login' | 'secure_note' | 'identity' | 'card' | 'key' | 'document' | 'totp';
export interface Item { export interface Item {
id: ItemId; id: ItemId;
@@ -119,39 +120,47 @@ export interface Item {
type: ItemType; // Rust's `r#type` serializes as `type` type: ItemType; // Rust's `r#type` serializes as `type`
tags: string[]; tags: string[];
favorite: boolean; favorite: boolean;
group: string | null; group?: string; // omitted when None (#[serde(skip_serializing_if)])
notes: string | null; notes?: string;
created: number; created: number;
modified: number; modified: number;
trashed_at: number | null; trashed_at?: number;
core: ItemCore; core: ItemCore; // internally-tagged on `"type"` — see below
sections: Section[]; sections: Section[];
attachments: AttachmentRef[]; attachments: AttachmentRef[];
field_history: Record<FieldId, FieldHistoryEntry[]>; field_history: Record<FieldId, FieldHistoryEntry[]>;
} }
// Internally-tagged: ItemCore variant's fields get merged with `"type"` discriminant.
// Example wire format for Login: { "type": "login", "username": "...", ... }
export type ItemCore = export type ItemCore =
| { Login: LoginCore } | ({ type: 'login' } & LoginCore)
| { SecureNote: SecureNoteCore } | ({ type: 'secure_note' } & SecureNoteCore)
| { Identity: IdentityCore } | ({ type: 'identity' } & IdentityCore)
| { Card: CardCore } | ({ type: 'card' } & CardCore)
| { Key: KeyCore } | ({ type: 'key' } & KeyCore)
| { Document: DocumentCore } | ({ type: 'document' } & DocumentCore)
| { Totp: TotpCore }; | ({ type: 'totp' } & TotpCore);
export interface LoginCore { export interface LoginCore {
username: string | null; username?: string;
password: string | null; password?: string;
url: string | null; url?: string; // Rust serializes `Url` as its string form
totp: TotpConfig | null; totp?: TotpConfig;
} }
// TotpKind is externally-tagged (default for enums without #[serde(tag)]):
// Totp → "totp" (unit variant serializes as bare string)
// Hotp{counter}→ { "hotp": { "counter": 42 } }
// Steam → "steam"
export type TotpKind = 'totp' | 'steam' | { hotp: { counter: number } };
export interface TotpConfig { export interface TotpConfig {
secret: number[]; // raw bytes, serde → JSON array secret: number[]; // Vec<u8> → JSON number array
algorithm: 'Sha1' | 'Sha256' | 'Sha512'; algorithm: 'sha1' | 'sha256' | 'sha512';
digits: number; digits: number;
period_seconds: number; period_seconds: number;
kind: { Totp: null } | { Hotp: number } | { Steam: null }; kind: TotpKind;
} }
// Populated minimally for α (structural shape only, no UI); β fills in: // Populated minimally for α (structural shape only, no UI); β fills in:
@@ -193,18 +202,52 @@ export interface DeviceSettings { // chrome.storage.local shape (was Relic
captureStyle: 'bar' | 'toast'; captureStyle: 'bar' | 'toast';
} }
// GeneratorRequest is internally-tagged on "kind", struct variants:
export type GeneratorRequest = export type GeneratorRequest =
| { Bip39: { word_count: number; separator: string; capitalization: Capitalization } } | { kind: 'bip39'; word_count: number; separator: string; capitalization: Capitalization }
| { Random: { length: number; classes: CharClasses; symbol_charset: SymbolCharset } }; | { kind: 'random'; length: number; classes: CharClasses; symbol_charset: SymbolCharset };
export type Capitalization = 'Lower' | 'Upper' | 'FirstOfEach' | 'Title' | 'Mixed'; export type Capitalization = 'lower' | 'upper' | 'first_of_each' | 'title' | 'mixed';
export interface CharClasses { lower: boolean; upper: boolean; digits: boolean; symbols: boolean; } export interface CharClasses { lower: boolean; upper: boolean; digits: boolean; symbols: boolean; }
export type SymbolCharset = 'SafeOnly' | 'Extended' | { Custom: string };
// SymbolCharset is adjacently-tagged { tag: "kind", content: "value" }:
export type SymbolCharset =
| { kind: 'safe_only' }
| { kind: 'extended' }
| { kind: 'custom'; value: string };
// TrashRetention / HistoryRetention use the same adjacent tagging:
export type TrashRetention =
| { kind: 'forever' }
| { kind: 'days'; value: number };
export type HistoryRetention =
| { kind: 'forever' }
| { kind: 'last_n'; value: number }
| { kind: 'days'; value: number };
// FieldValue adjacently-tagged { tag: "kind", content: "value" }, snake_case:
export type FieldValue =
| { kind: 'text'; value: string }
| { kind: 'multiline'; value: string }
| { kind: 'password'; value: string }
| { kind: 'concealed'; value: string }
| { kind: 'url'; value: string } // Url → string
| { kind: 'email'; value: string }
| { kind: 'phone'; value: string }
| { kind: 'date'; value: string } // chrono NaiveDate → "YYYY-MM-DD"
| { kind: 'month_year'; value: { month: number; year: number } }
| { kind: 'totp'; value: TotpConfig }
| { kind: 'reference'; value: AttachmentId };
export type FieldKind =
| 'text' | 'multiline' | 'password' | 'concealed' | 'url' | 'email'
| 'phone' | 'date' | 'month_year' | 'totp' | 'reference';
``` ```
Plus `Section`, `Field`, `FieldKind`, `FieldValue`, `AttachmentRef`, `AttachmentSummary`, `FieldHistoryEntry` as declared. Most are unused by α's UI but present so the type-check catches drift with the Rust side. Plus `Section`, `Field`, `AttachmentRef`, `AttachmentSummary`, `FieldHistoryEntry` as declared. Most are unused by α's UI but present so the type-check catches drift with the Rust side.
The ItemCore external-tagging assumption gets verified in the first SW slice's smoke test: `item_encrypt(handle, JSON.stringify(loginItem))` round-trips through `item_decrypt` with structural equality. The serialization shapes above are verified in slice 3's smoke test: `item_encrypt(handle, JSON.stringify(loginItem))` round-trips through `item_decrypt` with structural equality against a Rust-side item written by the CLI.
## Messages (`shared/messages.ts`) ## Messages (`shared/messages.ts`)
@@ -410,27 +453,28 @@ Rename `entry-*.ts` → `item-*.ts`. List view renders from `ManifestEntry`, sho
```ts ```ts
switch (item.type) { switch (item.type) {
case 'Login': return renderLoginDetail(app, item); case 'login': return renderLoginDetail(app, item);
case 'SecureNote': case 'secure_note':
case 'Identity': case 'identity':
case 'Card': case 'card':
case 'Key': case 'key':
case 'Document': case 'document':
case 'Totp': return renderComingSoonPlaceholder(app, item.type); case 'totp': return renderComingSoonPlaceholder(app, item.type);
} }
``` ```
Add flow: "New…" menu lists all seven types; picking Login opens the form, picking any other type shows "Coming in 1C-β". Add flow: "New…" menu lists all seven types; picking Login opens the form, picking any other type shows "Coming in 1C-β".
Existing Login form (username/url/password/totp/group/notes) maps 1:1 to `LoginCore` + `Item` envelope. TOTP field takes a base32 string; `shared/base32.ts` parses it to `Uint8Array` → bytes in `TotpConfig { secret, algorithm: Sha1, digits: 6, period_seconds: 30, kind: { Totp: null } }`. Display is base32 re-encoded with a reveal toggle. Existing Login form (username/url/password/totp/group/notes) maps 1:1 to `LoginCore` + `Item` envelope. TOTP field takes a base32 string; `shared/base32.ts` parses it to a `number[]` (the `secret: Vec<u8>` on the Rust side) and emits `TotpConfig { secret, algorithm: 'sha1', digits: 6, period_seconds: 30, kind: 'totp' }`. Display is base32 re-encoded with a reveal toggle.
"gen" button sends: "gen" button sends:
```ts ```ts
{ type: 'generate_password', { type: 'generate_password',
request: { Random: { length: 20, request: { kind: 'random',
length: 20,
classes: { lower: true, upper: true, digits: true, symbols: true }, classes: { lower: true, upper: true, digits: true, symbols: true },
symbol_charset: 'SafeOnly' } } } symbol_charset: { kind: 'safe_only' } } }
``` ```
### Setup wizard + zxcvbn ### Setup wizard + zxcvbn