| new file mode 100644 |
| index 000000000..47d1f23ad |
| --- /dev/null |
| +++ b/internal/ledger/ledger.go |
| @@ -0,0 +1,84 @@ |
| +// Package ledger — the open-ledger stream primitive: append-only, |
| +// hash-chained public JSON entries in the deployment bucket, keyed by |
| +// (account, app) exactly as the fleet page groups routes. The chain |
| +// hashes STORED entry bytes verbatim: the writer marshals once and the |
| +// hash commits to exactly those bytes, so verification needs no |
| +// canonicalization pass in any language. |
| +// Spec: docs/superpowers/specs/2026-08-03-open-ledgers-design.md §2. |
| +package ledger |
| + |
| +import ( |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "encoding/json" |
| + "regexp" |
| + "strings" |
| +) |
| + |
| +const ( |
| + Schema = 1 |
| + MaxPayload = 64 << 10 |
| + MaxBlob = 4 << 20 |
| +) |
| + |
| +type Entry struct { |
| + Schema int `json:"schema"` |
| + Ledger string `json:"ledger"` |
| + Writer string `json:"writer"` |
| + TS string `json:"ts"` |
| + Payload json.RawMessage `json:"payload"` |
| + PrevKey string `json:"prev_key"` |
| + PrevSHA256 string `json:"prev_sha256"` |
| +} |
| + |
| +type Head struct { |
| + Schema int `json:"schema"` |
| + UpdatedAt string `json:"updated_at"` |
| + HeadKey string `json:"head_key"` |
| + HeadSHA256 string `json:"head_sha256"` |
| + Count int `json:"count"` |
| +} |
| + |
| +type Manifest struct { |
| + Schema int `json:"schema"` |
| + Public bool `json:"public"` |
| + Ledgers []string `json:"ledgers"` |
| + Title string `json:"title"` |
| +} |
| + |
| +var nameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,63}$`) |
| + |
| +func ValidName(s string) bool { |
| + return nameRe.MatchString(s) |
| +} |
| + |
| +func Prefix(sqid, app, name string) string { |
| + return "ledgers/" + sqid + "/" + app + "/" + name + "/" |
| +} |
| + |
| +func HeadKey(sqid, app, name string) string { |
| + return Prefix(sqid, app, name) + "HEAD.json" |
| +} |
| + |
| +func ManifestKey(sqid, app string) string { |
| + return "ledgers/" + sqid + "/" + app + "/ledgers.json" |
| +} |
| + |
| +func BlobKey(sqid, app, name, sha string) string { |
| + return Prefix(sqid, app, name) + "blobs/" + sha |
| +} |
| + |
| +// tsCompact strips the separators RFC3339 puts in a UTC timestamp so entry |
| +// keys sort chronologically as plain strings. |
| +func tsCompact(ts string) string { |
| + return strings.NewReplacer("-", "", ":", "").Replace(ts) |
| +} |
| + |
| +func EntryKey(sqid, app, name, ts, sha string) string { |
| + return Prefix(sqid, app, name) + "entries/" + tsCompact(ts) + "-" + sha[:12] + ".json" |
| +} |
| + |
| +func SHA256Hex(b []byte) string { |
| + sum := sha256.Sum256(b) |
| + return hex.EncodeToString(sum[:]) |
| +} |