Files
zeekay d9f7c4da8e the storage tier can address the backend it selects
BASE_DB_TIER=sql opened PostgreSQL and then spoke to it in SQLite: strftime,
sqlite_master, json_extract, json_each, an INTEGER column holding an int64, an
outer join with no ON, backticked identifiers, and a rowid that only one engine
has. The tier picked a backend the queries could not reach.

Every one of those now comes from hanzoai/orm/dialect, resolved once from the
driver the data DB was opened with and reachable as app.Dialect(). Base holds no
switch on the engine — it asks for the JSON accessors, the schema catalog, the
generated instant and identifier, and the column types, and it asks whether a
feature exists at all where one engine has nothing to offer: an insertion-order
column, a write-ahead log to fold, a function that renders an instant. Empty
means the caller falls back or refuses rather than emitting something the engine
will reject.

Some differences turned out not to need a dialect at all. An instant is stored
as text in a fixed layout, so bucketing the log rollup to the hour is a
substring rather than a calendar function, which every engine spells the same
way and an index can be built on. Null-safe equality is IS NOT DISTINCT FROM.
An int64 column is BIGINT. A join with nothing of its own to match still states
a condition. Case-insensitive comparison stays COLLATE NOCASE, because the
schema now asks the engine for the collation that name refers to.

The proof is a Postgres 18 running the data plane: migrations apply, a
collection created over /v1 lands as a table with text, numeric and jsonb
columns and its declared index, and records insert, page, sort, filter across
scalars, multi-value elements and JSON paths, update and delete. A unique index
declared COLLATE NOCASE rejects the second spelling of a handle.

Comparing a JSON sub-path to a number is refused there: the extraction yields
text, the literal binds as a number, and the filter language does not say which
the comparison meant. Compared as text it works.
2026-08-11 10:39:19 -07:00

245 lines
7.4 KiB
Go

package apis
import (
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
"github.com/hanzoai/base/core"
"github.com/hanzoai/base/tools/router"
"github.com/hanzoai/dbx"
)
// bindPrivateApi exposes the /private endpoint — a blind, per-user encrypted
// blob store. The server never inspects the ciphertext and never derives keys.
//
// PUT /private/{tag} upsert ciphertext (body = raw bytes)
// GET /private/{tag} fetch ciphertext
// GET /private list tags (user_id, tag, size, updated_at)
// DELETE /private/{tag} hard delete
//
// The client is responsible for encryption (recommended: luxfi/age, X25519 or
// X-Wing PQ-hybrid). The server has no key material and cannot decrypt under
// any circumstances — that is the point.
//
// Per-tag cap (default 1 MiB) and per-user cap (default 100 MiB) are
// configurable via BASE_PRIVATE_MAX_TAG_BYTES and BASE_PRIVATE_MAX_USER_BYTES.
func bindPrivateApi(app core.App, rg *router.RouterGroup[*core.RequestEvent]) {
sub := rg.Group("/private").Bind(RequireAuth())
sub.GET("", privateList)
sub.GET("/{tag}", privateGet)
sub.PUT("/{tag}", privatePut).Unbind(DefaultBodyLimitMiddlewareId)
sub.DELETE("/{tag}", privateDelete)
}
const (
maxTagBytesDefault int64 = 1 << 20 // 1 MiB
maxUserBytesDefault int64 = 100 << 20 // 100 MiB
)
// privateTable is a plain table rather than a Base collection, so its rows stay
// invisible to the admin UI and the CRUD endpoints. Named once because six
// queries reference it.
const privateTable = "_private"
func ensurePrivateTable(app core.App) error {
// Idempotent. Stored in a plain table (not a Base collection) so the rows
// stay invisible to the admin UI and the CRUD endpoints.
_, err := app.DB().NewQuery(fmt.Sprintf(`
CREATE TABLE IF NOT EXISTS _private (
user_id TEXT NOT NULL,
tag TEXT NOT NULL,
ct %s NOT NULL,
updated_at BIGINT NOT NULL,
PRIMARY KEY (user_id, tag)
)
`, app.Dialect().Bytes())).Execute()
return err
}
func validPrivateTag(tag string) bool {
if tag == "" || len(tag) > 128 || strings.Contains(tag, "..") {
return false
}
// Tags are client-chosen labels. Restrict to a safe charset so we never
// have to worry about path-encoding bugs in logs or exports.
for _, r := range tag {
switch {
case r >= 'a' && r <= 'z',
r >= 'A' && r <= 'Z',
r >= '0' && r <= '9',
r == '-', r == '_', r == '.', r == ':':
default:
return false
}
}
return true
}
func privateEnvInt64(key string, fallback int64) int64 {
if v := os.Getenv(key); v != "" {
if n, err := strconv.ParseInt(v, 10, 64); err == nil && n > 0 {
return n
}
}
return fallback
}
func privateUserBytes(db dbx.Builder, userID string) (int64, error) {
var total int64
err := db.Select("COALESCE(SUM(LENGTH(ct)), 0)").
From(privateTable).
Where(dbx.HashExp{"user_id": userID}).
Row(&total)
return total, err
}
func privatePut(e *core.RequestEvent) error {
if e.Auth == nil {
return e.UnauthorizedError("Authentication required.", nil)
}
tag := e.Request.PathValue("tag")
if !validPrivateTag(tag) {
return e.BadRequestError("Invalid tag.", nil)
}
if err := ensurePrivateTable(e.App); err != nil {
return e.InternalServerError("Private store unavailable.", err)
}
maxTag := privateEnvInt64("BASE_PRIVATE_MAX_TAG_BYTES", maxTagBytesDefault)
maxUser := privateEnvInt64("BASE_PRIVATE_MAX_USER_BYTES", maxUserBytesDefault)
// Read body with a hard cap. Anything beyond maxTag is 413 — we never
// buffer past the limit so a hostile client can't exhaust memory.
body, err := io.ReadAll(io.LimitReader(e.Request.Body, maxTag+1))
if err != nil {
return e.BadRequestError("Failed to read body.", err)
}
if int64(len(body)) > maxTag {
return e.Error(http.StatusRequestEntityTooLarge, "Ciphertext exceeds per-tag limit.", nil)
}
if len(body) == 0 {
return e.BadRequestError("Empty ciphertext not allowed — use DELETE to remove a tag.", nil)
}
// Quota check — subtract the old row size so overwrites don't double count.
userID := e.Auth.Id
var oldSize int64
_ = e.App.DB().Select("LENGTH(ct)").
From(privateTable).
Where(dbx.HashExp{"user_id": userID, "tag": tag}).
Row(&oldSize)
total, err := privateUserBytes(e.App.DB(), userID)
if err != nil {
return e.InternalServerError("Quota check failed.", err)
}
if total-oldSize+int64(len(body)) > maxUser {
return e.Error(http.StatusInsufficientStorage, "Per-user quota exceeded.", nil)
}
now := time.Now().UnixMilli()
// Upsert, keyed on the (user_id, tag) primary key. This was hand-written
// ON CONFLICT SQL because SqliteBuilder.Upsert used to answer "not
// supported" — it was simply an unimplemented override, and dbx v1.17.2
// implements it. The builder emits the same statement.
_, err = e.App.DB().Upsert(privateTable, dbx.Params{
"user_id": userID,
"tag": tag,
"ct": body,
"updated_at": now,
}, "user_id", "tag").Execute()
if err != nil {
return e.InternalServerError("Write failed.", err)
}
return e.JSON(http.StatusOK, map[string]any{
"tag": tag,
"size": len(body),
"updated_at": now,
})
}
func privateGet(e *core.RequestEvent) error {
if e.Auth == nil {
return e.UnauthorizedError("Authentication required.", nil)
}
tag := e.Request.PathValue("tag")
if !validPrivateTag(tag) {
return e.BadRequestError("Invalid tag.", nil)
}
if err := ensurePrivateTable(e.App); err != nil {
return e.InternalServerError("Private store unavailable.", err)
}
var row struct {
CT []byte `db:"ct"`
UpdatedAt int64 `db:"updated_at"`
}
err := e.App.DB().Select("ct", "updated_at").
From(privateTable).
Where(dbx.HashExp{"user_id": e.Auth.Id, "tag": tag}).
One(&row)
if err != nil {
// Indistinguishable from not-authorized — don't leak existence.
return e.NotFoundError("", nil)
}
// Raw ciphertext. Client decrypts. Server can't.
e.Response.Header().Set("Content-Type", "application/octet-stream")
e.Response.Header().Set("Cache-Control", "no-store")
e.Response.Header().Set("X-Content-Type-Options", "nosniff")
e.Response.Header().Set("X-Private-Updated-At", strconv.FormatInt(row.UpdatedAt, 10))
_, err = e.Response.Write(row.CT)
return err
}
// privateListItem is the per-tag metadata row returned by GET /private.
type privateListItem struct {
Tag string `db:"tag" json:"tag"`
Size int64 `db:"size" json:"size"`
UpdatedAt int64 `db:"updated_at" json:"updated_at"`
}
func privateList(e *core.RequestEvent) error {
if e.Auth == nil {
return e.UnauthorizedError("Authentication required.", nil)
}
if err := ensurePrivateTable(e.App); err != nil {
return e.InternalServerError("Private store unavailable.", err)
}
rows := []privateListItem{}
err := e.App.DB().Select("tag", "LENGTH(ct) AS size", "updated_at").
From(privateTable).
Where(dbx.HashExp{"user_id": e.Auth.Id}).
OrderBy("tag").
All(&rows)
if err != nil {
return e.InternalServerError("List failed.", err)
}
return e.JSON(http.StatusOK, map[string]any{"items": rows})
}
func privateDelete(e *core.RequestEvent) error {
if e.Auth == nil {
return e.UnauthorizedError("Authentication required.", nil)
}
tag := e.Request.PathValue("tag")
if !validPrivateTag(tag) {
return e.BadRequestError("Invalid tag.", nil)
}
_, err := e.App.DB().Delete(privateTable,
dbx.HashExp{"user_id": e.Auth.Id, "tag": tag},
).Execute()
if err != nil {
return e.InternalServerError("Delete failed.", err)
}
return e.NoContent(http.StatusNoContent)
}