Petal is now an OIDC client in its own right rather than trusting a header from the proxy. The Phase-0 Resolver seam was the only integration point: main.go picks the session store when Authentik is configured and the static local user otherwise, and no handler or query moved for either. internal/auth gains three pieces. session.go issues an opaque cookie token and stores only its SHA-256, so a database copy yields nothing usable; the 30-day expiry slides on every request, throttled to one write an hour, and logout deletes the row rather than just the cookie. oidc.go runs the authorization-code flow with state, nonce and PKCE, and discovers the provider lazily and on retry — an Authentik outage should block new logins without stopping Petal booting or invalidating live sessions. users.go provisions accounts from the token's claims and gates them on an allowlist that matches emails as well as subject ids, since a subject is an opaque uuid that doesn't exist until someone has already logged in once. Migration 0010 lands sessions, images and users.pair_lang together. The images table closes the capability-URL hole the Phase-0 audit flagged: a hash was previously enough to fetch anyone's picture. Rows are keyed (name, user_id) so one file can have several owners and deduplication survives; a stranger gets 404 rather than 403, the cache header drops to private, and files already on disk are claimed at startup or every image already pasted into a document would 404. On the frontend a single 401 interceptor feeds a warm bilingual sign-in overlay, drawn over a still-visible editor because nothing has been taken away. Behind it is the part that matters: a save that comes back 401 stashes its body to localStorage before anything else and stops the auto-save loop, and reopening that document after signing in merges the draft back and saves it. An expired session must not cost writing. Writing the round-trip test against a stub identity provider turned up a real bug: the one-shot state/nonce/PKCE cookies were cleared in a defer, which runs after the redirect has written the response header, so the clearing Set-Cookie was silently dropped and they lingered for their full ten minutes. Also swaps the emoji favicon for a drawn sakura, which renders as Petal's own rose palette everywhere instead of whatever each platform's font decides, and doubles as the app tile in Authentik. Migration 0010 verified against a VACUUM INTO copy of the live millenia database: counts intact, FTS still matching, the one existing image claimed. Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
126 lines
5.3 KiB
Go
126 lines
5.3 KiB
Go
package db
|
|
|
|
import "time"
|
|
|
|
// User is an account. Its ID is the OIDC subject for anyone who signed in, or
|
|
// LocalUserID for the pre-auth single user (and for local development, where
|
|
// StaticResolver still hands out that id).
|
|
type User struct {
|
|
ID string `json:"id"`
|
|
Email string `json:"email"`
|
|
DisplayName string `json:"display_name"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
|
|
// PairLang is the X in this writer's (English + X) language pair — "zh"
|
|
// today, "pt-PT"/"fr"/"es" once the langpacks land. It selects the UI copy
|
|
// and dictionary set, not the language they may type in.
|
|
PairLang string `json:"pair_lang"`
|
|
}
|
|
|
|
// Document is a single piece of writing. `Content` is the Tiptap JSON document
|
|
// (source of truth for the editor); `ContentText` is the flattened plain text
|
|
// kept in sync on every save and fed to the LLM.
|
|
type Document struct {
|
|
ID string `json:"id"`
|
|
UserID string `json:"user_id"`
|
|
Title string `json:"title"`
|
|
Content string `json:"content"` // Tiptap JSON
|
|
ContentText string `json:"content_text"` // plain text for the LLM
|
|
Tone string `json:"tone"` // target writing tone; steers LLM advice
|
|
WordCount int `json:"word_count"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
|
|
// PreserveHistory opts this document out of auto-snapshot pruning so its
|
|
// full writing trail survives as authorship evidence (see the passport).
|
|
PreserveHistory bool `json:"preserve_history"`
|
|
}
|
|
|
|
// DocumentVersion is a point-in-time snapshot of a document's body, captured so
|
|
// a writer can recover from a bad edit or an unwanted change. `Content` mirrors
|
|
// the document's Tiptap JSON at snapshot time; `Kind` records why it was taken
|
|
// (see the kind constants). List responses omit the heavy Content/ContentText
|
|
// fields (the `omitempty`-friendly zero strings) and load them only on preview
|
|
// or restore.
|
|
type DocumentVersion struct {
|
|
ID string `json:"id"`
|
|
DocID string `json:"doc_id"`
|
|
Title string `json:"title"`
|
|
Content string `json:"content,omitempty"` // Tiptap JSON; omitted in list view
|
|
ContentText string `json:"content_text,omitempty"` // plain text; omitted in list view
|
|
WordCount int `json:"word_count"`
|
|
Kind string `json:"kind"` // auto | manual | pre_restore
|
|
CreatedAt time.Time `json:"created_at"`
|
|
|
|
// ContentHash chains this snapshot to the previous one (PrevHash), so a
|
|
// history that was edited or thinned after the fact fails verification.
|
|
// Both are empty for snapshots taken before the chain existed. Omitted from
|
|
// list responses; the passport loads them explicitly.
|
|
ContentHash string `json:"content_hash,omitempty"`
|
|
PrevHash string `json:"prev_hash,omitempty"`
|
|
}
|
|
|
|
// Document version kinds, mirrored from the schema CHECK constraint.
|
|
const (
|
|
VersionKindAuto = "auto" // throttled background snapshot on save
|
|
VersionKindManual = "manual" // explicit "save a restore point"
|
|
VersionKindPreRestore = "pre_restore" // safety copy taken just before a restore
|
|
)
|
|
|
|
// Tag is a user-scoped label for organizing documents. `Color` is a palette key
|
|
// (rose, mint, peach, lavender, sky, honey) the frontend maps to a CSS color;
|
|
// storing the key (not a hex value) keeps tags in step with the design tokens.
|
|
// `DocCount` is populated only by the tag-list endpoint (how many documents wear
|
|
// the tag); it's omitted from per-document tag lists.
|
|
type Tag struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Color string `json:"color"`
|
|
DocCount int `json:"doc_count,omitempty"`
|
|
}
|
|
|
|
// Tag color palette keys, mirrored on the frontend. Kept small and aligned with
|
|
// the existing design tokens; unknown values fall back to rose client-side.
|
|
const (
|
|
TagColorRose = "rose"
|
|
TagColorMint = "mint"
|
|
TagColorPeach = "peach"
|
|
TagColorLavender = "lavender"
|
|
TagColorSky = "sky"
|
|
TagColorHoney = "honey"
|
|
)
|
|
|
|
// Suggestion is a single LLM-proposed edit anchored to a span of the document.
|
|
//
|
|
// FromPos/ToPos are plaintext offsets into ContentText for server-side use only;
|
|
// the frontend re-anchors by matching the `Original` string in ProseMirror
|
|
// coordinates at render time (spec Note #6). `Replacement` is empty for `voice`
|
|
// flags — those are awareness-only, with no correction to apply.
|
|
type Suggestion struct {
|
|
ID string `json:"id"`
|
|
DocID string `json:"doc_id"`
|
|
FromPos int `json:"from_pos"`
|
|
ToPos int `json:"to_pos"`
|
|
Original string `json:"original"`
|
|
Replacement string `json:"replacement"`
|
|
Explanation string `json:"explanation"`
|
|
Type string `json:"type"` // grammar | phrasing | idiom | clarity | voice | collocation
|
|
Status string `json:"status"` // pending | accepted | rejected
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
// Suggestion type and status values, mirrored from the schema CHECK constraints.
|
|
const (
|
|
SuggestionTypeGrammar = "grammar"
|
|
SuggestionTypePhrasing = "phrasing"
|
|
SuggestionTypeIdiom = "idiom"
|
|
SuggestionTypeClarity = "clarity"
|
|
SuggestionTypeVoice = "voice"
|
|
SuggestionTypeCollocation = "collocation"
|
|
SuggestionTypeMechanics = "mechanics" // deterministic rule-based pass (no LLM)
|
|
|
|
SuggestionStatusPending = "pending"
|
|
SuggestionStatusAccepted = "accepted"
|
|
SuggestionStatusRejected = "rejected"
|
|
)
|