mirror of
https://github.com/hanzoai/base.git
synced 2026-10-21 03:41:50 +00:00
Document how to run, extend and upgrade Base
The README quickstart, docs/versions.md, hooks.md, rules.md, config.md and listeners.md, and examples/hooks-hybrid-search, each command run against the binary built from examples/base. LLM.md is cut to a current map with test commands and known problems; threat-model, NETWORK, SECURITY and CONTRIBUTING are corrected to the code. Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
+35
-68
@@ -1,82 +1,49 @@
|
||||
# Contributing to Base
|
||||
# Contributing
|
||||
|
||||
Thanks for taking the time to improve Base!
|
||||
Base is one Go module. `examples/base` is the binary, and everything else is
|
||||
packages it imports.
|
||||
|
||||
This document describes how to prepare a PR for a change in the main repository.
|
||||
## Go
|
||||
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Making changes in the Go code](#making-changes-in-the-go-code)
|
||||
- [Making changes in the Admin UI](#making-changes-in-the-admin-ui)
|
||||
You need the Go version in `go.mod` (1.26.8) or newer.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Go 1.25+ (for making changes in the Go code)
|
||||
- Node 18+ (for making changes in the Admin UI)
|
||||
|
||||
If you haven't already, you can fork the main repository and clone your fork so that you can work locally:
|
||||
|
||||
```
|
||||
git clone https://github.com/your_username/base.git
|
||||
```sh
|
||||
cd examples/base
|
||||
go run . serve --http 127.0.0.1:8090
|
||||
```
|
||||
|
||||
> [!IMPORTANT]
|
||||
> It is recommended to create a new branch from master for each of your bugfixes and features.
|
||||
> This is required if you are planning to submit multiple PRs in order to keep the changes separate for review until they eventually get merged.
|
||||
Check your change the way CI does, with the settings the published binary is
|
||||
built with:
|
||||
|
||||
## Making changes in the Go code
|
||||
```sh
|
||||
GOWORK=off CGO_ENABLED=0 GOEXPERIMENT=jsonv2 go vet ./...
|
||||
GOWORK=off CGO_ENABLED=0 GOEXPERIMENT=jsonv2 go test -count=1 ./...
|
||||
golangci-lint run -c ./golangci.yml ./...
|
||||
```
|
||||
|
||||
Base is distributed as a Go package, which means that in order to run the project you'll have to create a Go `main` program that imports the package.
|
||||
On macOS two tests fail for want of RAM-backed storage: `TestTasksEmbed` in
|
||||
`core` and `TestOrgBaseIsEncryptedAtRest` in `plugins/org`. The encrypted SQLite
|
||||
codec will only decrypt into RAM, which is `/dev/shm` on Linux, or on macOS a
|
||||
`tmpfs` mount named by `HANZO_SQLITE_RAMFS_DIR`.
|
||||
|
||||
The repository already includes such program, located in `examples/base`, that is also used for the prebuilt executables.
|
||||
## Admin UI
|
||||
|
||||
So, let's assume that you already done some changes in the Base Go code and you want now to run them:
|
||||
The admin is a React app in `ui-react/`, built with pnpm. Its build output,
|
||||
`ui-react/dist`, is committed and embedded in the binary, so building Go needs
|
||||
no Node toolchain.
|
||||
|
||||
1. Navigate to `examples/base`
|
||||
2. Run `go run main.go serve`
|
||||
```sh
|
||||
pnpm --dir ui-react install
|
||||
pnpm --dir ui-react dev # http://localhost:3000, sends /v1 to localhost:8090
|
||||
pnpm --dir ui-react build
|
||||
pnpm --dir ui-react smoke # loads the built admin in Playwright and checks what renders
|
||||
```
|
||||
|
||||
This will start a web server on `http://localhost:8090` with the embedded prebuilt Admin UI from `ui/dist`. And that's it!
|
||||
Commit a rebuilt `dist/` with any change under `ui-react/src`, and build it on
|
||||
Linux: a macOS build of the same source emits a different, larger bundle, and
|
||||
the next Linux build undoes it.
|
||||
|
||||
**Before making a PR to the main repository, it is a good idea to:**
|
||||
## Pull requests
|
||||
|
||||
- Add unit/integration tests for your changes (we are using the standard `testing` go package).
|
||||
To run the tests, you could execute (while in the root project directory):
|
||||
|
||||
```sh
|
||||
go test ./...
|
||||
|
||||
# or using the Makefile
|
||||
make test
|
||||
```
|
||||
|
||||
- Run the linter - **golangci** ([see how to install](https://golangci-lint.run/usage/install/#local-installation)):
|
||||
|
||||
```sh
|
||||
golangci-lint run -c ./golangci.yml ./...
|
||||
|
||||
# or using the Makefile
|
||||
make lint
|
||||
```
|
||||
|
||||
## Making changes in the Admin UI
|
||||
|
||||
Base Admin UI is a single-page application (SPA) built with Svelte and Vite.
|
||||
|
||||
To start the Admin UI:
|
||||
|
||||
1. Navigate to the `ui` project directory
|
||||
2. Run `npm install` to install the node dependencies
|
||||
3. Start vite's dev server
|
||||
```sh
|
||||
npm run dev
|
||||
```
|
||||
|
||||
You could open the browser and access the running Admin UI at `http://localhost:3000`.
|
||||
|
||||
Since the Admin UI is just a client-side application, you need to have the Base backend server also running in the background (either manually running the `examples/base/main.go` or download a prebuilt executable).
|
||||
|
||||
> [!NOTE]
|
||||
> By default, the Admin UI is expecting the backend server to be started at `http://localhost:8090`, but you could change that by creating a new `ui/.env.development.local` file with `HZ_BACKEND_URL = YOUR_ADDRESS` variable inside it.
|
||||
|
||||
Every change you make in the Admin UI should be automatically reflected in the browser at `http://localhost:3000` without reloading the page.
|
||||
|
||||
Once you are done with your changes, you have to build the Admin UI with `npm run build`, so that it can be embedded in the go package. And that's it - you can make your PR to the main Base repository.
|
||||
Open an issue before a large change. Keep a pull request to one change, with
|
||||
its tests, and update `docs/` when behaviour a reader relies on changes.
|
||||
|
||||
@@ -2,337 +2,125 @@
|
||||
|
||||
# Base
|
||||
|
||||
The embedded, **SQLite-first** application backend for the Hanzo cloud — per-tenant data, in-process extension runtimes, and Hanzo IAM built in. One Go binary; the storage substrate every multi-tenant Hanzo Go service builds on.
|
||||
Base is a backend in one Go binary: collections of records behind a REST API at
|
||||
`/v1`, with realtime updates, files, per-collection access rules and JavaScript
|
||||
hooks, stored in SQLite. Identity comes from Hanzo IAM, and with IAM on, each
|
||||
org's data lives in a database file of its own.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
## Run it
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
go install github.com/hanzoai/base/examples/base@latest
|
||||
base serve
|
||||
```
|
||||
|
||||
Then open the admin dashboard at `http://127.0.0.1:8090/_/`.
|
||||
|
||||
> [!NOTE]
|
||||
> Base is under active development. Full backward compatibility is not guaranteed before `v1.0.0`.
|
||||
|
||||
## What this is
|
||||
|
||||
[Base](https://hanzo.ai/base) is an open source Go backend that gives every Hanzo Go service the same multi-tenant substrate. It includes:
|
||||
|
||||
- embedded **in-memory, SQL, and vector** data with **realtime subscriptions**
|
||||
- built-in **files and users management**
|
||||
- a convenient **Admin dashboard UI**
|
||||
- **GraphQL** and a REST-style **`/v1` API**
|
||||
- native **analytics and AI observability**
|
||||
- **Hanzo IAM** as the one and only auth path (OIDC / PKCE)
|
||||
- deep integration with the **[Hanzo AI Cloud](https://hanzo.ai)** for scale on day one
|
||||
|
||||
Each org gets its own per-tenant data file with a per-org KMS-derived DEK; `replicate` streams the WAL to age-encrypted object storage. Per-record validators, computed fields, and access rules run inside the same process via in-process extension runtimes (goja, wazero, pyvm, starkvm).
|
||||
|
||||
Use **[Hanzo App](https://hanzo.app)** to rapidly iterate and build new apps.
|
||||
|
||||
## API clients
|
||||
|
||||
The easiest way to talk to the Base web API is one of the official SDK clients:
|
||||
|
||||
- **JavaScript — [@hanzoai/js-sdk](https://github.com/hanzoai/js-sdk)** (_Browser, Node.js, React Native_)
|
||||
- **Dart — [@hanzoai/dart-sdk](https://github.com/hanzoai/dart-sdk)** (_Web, Mobile, Desktop, CLI_)
|
||||
|
||||
See the full guide at [docs.hanzo.ai](https://docs.hanzo.ai).
|
||||
|
||||
## Overview
|
||||
|
||||
### Use as a standalone app
|
||||
|
||||
Download the prebuilt executable for your platform from the [Releases page](https://github.com/hanzoai/base/releases), extract the archive, and run `./base serve`.
|
||||
|
||||
The prebuilt executables are built from [`examples/base/main.go`](https://github.com/hanzoai/base/blob/main/examples/base/main.go) and ship with a JavaScript plugin enabled by default, so you can extend Base with JavaScript (see [Extend with JavaScript](https://docs.hanzo.ai/js-overview/)).
|
||||
|
||||
### Use as a Go framework / toolkit
|
||||
|
||||
Base is distributed as a regular Go library, so you can build your own app-specific business logic and still ship a single portable executable.
|
||||
|
||||
Minimal example:
|
||||
|
||||
0. [Install Go 1.26+](https://go.dev/doc/install) (_if you haven't already_).
|
||||
|
||||
1. Create a new project directory with the following `main.go`:
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"github.com/hanzoai/base"
|
||||
"github.com/hanzoai/base/core"
|
||||
)
|
||||
|
||||
func main() {
|
||||
app := base.New()
|
||||
|
||||
app.OnServe().BindFunc(func(se *core.ServeEvent) error {
|
||||
// registers a new "GET /hello" route
|
||||
se.Router.GET("/hello", func(re *core.RequestEvent) error {
|
||||
return re.String(200, "Hello world!")
|
||||
})
|
||||
|
||||
return se.Next()
|
||||
})
|
||||
|
||||
if err := app.Start(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. Initialize dependencies: `go mod init myapp && go mod tidy`.
|
||||
|
||||
3. Start the app: `go run main.go serve`.
|
||||
|
||||
4. Build a statically linked executable: `CGO_ENABLED=0 go build`, then start it with `./myapp serve`.
|
||||
|
||||
See [Extend with Go](https://docs.hanzo.ai/go-overview/) for details.
|
||||
|
||||
### Building the repo example
|
||||
|
||||
To build the minimal standalone executable (like the prebuilt releases), run `go build` inside `examples/base`:
|
||||
|
||||
0. [Install Go 1.26+](https://go.dev/doc/install) (_if you haven't already_).
|
||||
1. Clone the repo.
|
||||
2. Navigate to `examples/base`.
|
||||
3. Run `GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build` (see the [Go environment reference](https://go.dev/doc/install/source#environment)).
|
||||
4. Start the executable: `./base serve`.
|
||||
|
||||
The pure-Go SQLite driver currently supports these build targets:
|
||||
|
||||
```
|
||||
darwin amd64 linux arm64 linux s390x
|
||||
darwin arm64 linux loong64 windows 386
|
||||
freebsd amd64 linux ppc64le windows amd64
|
||||
freebsd arm64 linux riscv64 windows arm64
|
||||
linux 386 linux arm
|
||||
linux amd64
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
Base comes with a mixed bag of unit and integration tests. Run them with the standard `go test` command:
|
||||
You need Go 1.26.8 or newer.
|
||||
|
||||
```sh
|
||||
go test ./...
|
||||
git clone --depth 1 https://github.com/hanzoai/base
|
||||
cd base/examples/base
|
||||
CGO_ENABLED=0 go build
|
||||
```
|
||||
|
||||
See the [Testing guide](https://docs.hanzo.ai/testing) to learn how to write your own application tests.
|
||||
Describe a collection in a migration. Base applies migrations when it starts.
|
||||
|
||||
## SQLite replication
|
||||
|
||||
Base opens SQLite through the encrypted `hanzoai/sqlite` driver (per-principal CEK). The `hanzoai/replicate` sidecar streams WAL changes to object storage, encrypted end-to-end with `luxfi/age`.
|
||||
|
||||
K8s sidecar pattern:
|
||||
|
||||
- **Init container** — restores the latest snapshot from object storage on startup.
|
||||
- **Sidecar** — continuously replicates the WAL while the service runs.
|
||||
|
||||
Configure `age-identities` / `age-recipients` for end-to-end encryption of replicated data.
|
||||
|
||||
## Specs
|
||||
|
||||
Base implements:
|
||||
|
||||
- **HIP-0105** — In-Process Extension Runtime Standard
|
||||
- **HIP-0107** — Streaming Replication over VFS
|
||||
- **HIP-0302** — Encrypted SQLite + ZapDB Durability
|
||||
- **HIP-0111** — Hanzo IAM (the one auth path)
|
||||
|
||||
It is the storage backend for every multi-tenant subsystem in **HIP-0106**.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
http request -> zip.App -> base.App
|
||||
|
|
||||
per-tenant data/{orgSlug}.db (SQLite or ZapDB)
|
||||
|
|
||||
KMS-derived DEK replicate -> S3 (age-encrypted)
|
||||
|
|
||||
in-process extension runtime (HIP-0105):
|
||||
goja (JS) | wazero (WASM) | pyvm (Python) | starkvm (Starlark)
|
||||
```sh
|
||||
mkdir -p base_/migrations
|
||||
cat > base_/migrations/1_notes.js <<'EOF'
|
||||
migrate((app) => {
|
||||
app.save(new Collection({
|
||||
type: "base",
|
||||
name: "notes",
|
||||
listRule: "",
|
||||
viewRule: "",
|
||||
createRule: "",
|
||||
fields: [{ name: "text", type: "text", required: true }],
|
||||
}))
|
||||
})
|
||||
EOF
|
||||
./base serve --http 127.0.0.1:8090
|
||||
```
|
||||
|
||||
## CLI
|
||||
|
||||
The `base cli` subcommand is a complete HTTP client for operating any running Base-backed daemon from the command line. It works against `base` or any binary that embeds Base.
|
||||
|
||||
### Targeting a server
|
||||
|
||||
```bash
|
||||
# Defaults to http://127.0.0.1:8090 if nothing is set
|
||||
base cli --url http://localhost:8090 collection list
|
||||
|
||||
# Or use environment variables
|
||||
export BASE_URL=http://localhost:8090
|
||||
export BASE_TOKEN=eyJhbGciOi...
|
||||
base cli collection list
|
||||
```
|
||||
Server started at http://127.0.0.1:8090
|
||||
├─ REST API: http://127.0.0.1:8090/v1/
|
||||
└─ Dashboard: http://127.0.0.1:8090/
|
||||
```
|
||||
|
||||
Global flags (apply to all subcommands):
|
||||
From a second terminal, create a record and list the collection:
|
||||
|
||||
| Flag | Env var | Default | Description |
|
||||
|------|---------|---------|-------------|
|
||||
| `--url` | `BASE_URL` | `http://127.0.0.1:8090` | Server URL |
|
||||
| `--token` | `BASE_TOKEN` | `~/.config/base/token` | Auth token |
|
||||
| `--tenant` | — | — | Sets `X-Org-Id` header |
|
||||
| `--format` | — | `table` on TTY, `json` otherwise | `table`, `json`, or `yaml` |
|
||||
```sh
|
||||
curl -s -X POST http://127.0.0.1:8090/v1/collections/notes/records \
|
||||
-H 'Content-Type: application/json' -d '{"text":"hello"}'
|
||||
{"collectionId":"hbc_3395098727","collectionName":"notes","id":"kbx358lmic2a4bw","text":"hello"}
|
||||
|
||||
### Authentication
|
||||
|
||||
```bash
|
||||
# Login as a regular user
|
||||
base cli login --email [email protected] --password secret123
|
||||
|
||||
# Login as superuser
|
||||
base cli login --email [email protected] --password secret123 --superuser
|
||||
|
||||
# Check who you are
|
||||
base cli whoami
|
||||
curl -s http://127.0.0.1:8090/v1/collections/notes/records
|
||||
{"items":[{"collectionId":"hbc_3395098727","collectionName":"notes","id":"kbx358lmic2a4bw","text":"hello"}],"page":1,"perPage":30,"totalItems":1,"totalPages":1}
|
||||
```
|
||||
|
||||
The token is stored at `~/.config/base/token` (or `$XDG_CONFIG_HOME/base/token`) with mode `0600`.
|
||||
The empty rules let anyone read and write `notes`; [docs/rules.md](docs/rules.md)
|
||||
shows how to close them. The data is in `base_/data`.
|
||||
|
||||
### Collections
|
||||
The startup output has two stale lines. The Dashboard address answers only when
|
||||
`BASE_ENABLE_ADMIN_UI=1` is set, and the hint to run `superuser upsert` names a
|
||||
command that was removed. Superusers come from IAM.
|
||||
|
||||
```bash
|
||||
# List all collections
|
||||
base cli collection list
|
||||
## Use it as a Go library
|
||||
|
||||
# Show a specific collection's schema
|
||||
base cli collection get users
|
||||
|
||||
# Export schema as JSON (always JSON, ignores --format)
|
||||
base cli collection schema users > schema.json
|
||||
```
|
||||
|
||||
### Records
|
||||
|
||||
```bash
|
||||
# List records with filtering and sorting
|
||||
base cli record list posts --filter "title~'hello'" --limit 10 --sort "-created"
|
||||
|
||||
# Get a single record
|
||||
base cli record get posts abc123
|
||||
|
||||
# Create a record
|
||||
base cli record create posts '{"title":"Hello","body":"World"}'
|
||||
|
||||
# Update a record
|
||||
base cli record update posts abc123 '{"title":"Updated"}'
|
||||
|
||||
# Delete a record
|
||||
base cli record delete posts abc123
|
||||
```
|
||||
|
||||
### Crons
|
||||
|
||||
```bash
|
||||
# List registered cron schedules
|
||||
base cli crons list
|
||||
```
|
||||
|
||||
### Using from a downstream daemon
|
||||
|
||||
Any Base-backed daemon can expose these subcommands without duplicating code. For example, in a downstream `main.go`:
|
||||
In an empty directory, save this as `main.go`:
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"log"
|
||||
|
||||
"github.com/hanzoai/base"
|
||||
"github.com/hanzoai/base/cmd"
|
||||
"github.com/hanzoai/base"
|
||||
"github.com/hanzoai/base/core"
|
||||
)
|
||||
|
||||
func main() {
|
||||
app := base.New()
|
||||
app := base.New()
|
||||
|
||||
// ... register domain-specific hooks and routes ...
|
||||
app.OnServe().BindFunc(func(se *core.ServeEvent) error {
|
||||
se.Router.GET("/hello", func(re *core.RequestEvent) error {
|
||||
return re.String(200, "Hello")
|
||||
})
|
||||
return se.Next()
|
||||
})
|
||||
|
||||
// Register system commands for a flattened CLI:
|
||||
// `myapp collection list` instead of `myapp cli collection list`
|
||||
app.RootCmd.AddCommand(cmd.NewSuperuserCommand(app))
|
||||
app.RootCmd.AddCommand(cmd.NewServeCommand(app, true))
|
||||
cmd.AddCLISubcommands(app.RootCmd)
|
||||
|
||||
if err := app.Execute(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if err := app.Start(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Then operate the running daemon:
|
||||
|
||||
```bash
|
||||
myapp collection list
|
||||
myapp record list posts --filter "title~'hello'" --limit 20
|
||||
myapp crons list
|
||||
myapp daemon status
|
||||
```sh
|
||||
go mod init example.com/hello
|
||||
go mod tidy
|
||||
CGO_ENABLED=0 go build
|
||||
./hello serve
|
||||
```
|
||||
|
||||
### Registration patterns
|
||||
`GET http://127.0.0.1:8090/hello` then answers `Hello`, next to the whole `/v1`
|
||||
API.
|
||||
|
||||
**Flattened** (recommended for domain daemons) — commands at the root level:
|
||||
## Read next
|
||||
|
||||
```go
|
||||
app.RootCmd.AddCommand(cmd.NewSuperuserCommand(app))
|
||||
app.RootCmd.AddCommand(cmd.NewServeCommand(app, true))
|
||||
cmd.AddCLISubcommands(app.RootCmd) // collection, record, login, whoami, crons, daemon
|
||||
app.Execute()
|
||||
```
|
||||
| Page | For |
|
||||
|---|---|
|
||||
| [docs/versions.md](docs/versions.md) | which tags serve what, and moving from `/api` and passwords to `/v1` and IAM |
|
||||
| [docs/rules.md](docs/rules.md) | access rules, identity from IAM, orgs |
|
||||
| [docs/hooks.md](docs/hooks.md) | JavaScript hooks and migrations |
|
||||
| [docs/config.md](docs/config.md) | every command, flag and environment variable |
|
||||
| [docs/listeners.md](docs/listeners.md) | every socket Base can open |
|
||||
| [docs/threat-model.md](docs/threat-model.md) | what Base defends, and what you configure |
|
||||
| [examples/hooks-hybrid-search](examples/hooks-hybrid-search) | full-text and vector search written as hooks |
|
||||
|
||||
**Nested** (default via `app.Start()`) — commands under a `cli` parent:
|
||||
## Clients
|
||||
|
||||
```go
|
||||
app.Start() // registers serve, superuser, cli (with all subcommands)
|
||||
// Access via: myapp cli collection list
|
||||
```
|
||||
- JavaScript and TypeScript: [`@hanzo/base`](https://github.com/hanzo-js/base).
|
||||
Give it the origin, as in `new BaseClient('http://127.0.0.1:8090')`; it adds
|
||||
`/v1` itself.
|
||||
- Dart: [hanzo-dart/base](https://github.com/hanzo-dart/base).
|
||||
|
||||
### Daemon lifecycle
|
||||
## Contributing and security
|
||||
|
||||
The `daemon` subcommand manages the process lifecycle:
|
||||
|
||||
```bash
|
||||
myapp daemon start # local: nohup spawn
|
||||
myapp daemon stop # local: kill
|
||||
myapp daemon status # local: pgrep
|
||||
myapp daemon logs --follow # local: tail -f
|
||||
myapp daemon restart # local: stop + start
|
||||
|
||||
myapp daemon status --env dev # K8s: kubectl get pods
|
||||
myapp daemon restart --env test --yes # K8s: rollout restart
|
||||
```
|
||||
|
||||
K8s actions require `--env` (`dev`, `test`, `main`) and are dry-run by default. Pass `--yes` to execute.
|
||||
|
||||
## Security
|
||||
|
||||
Report security vulnerabilities to **[email protected]**. All reports are addressed promptly, and you'll be credited in the fix release notes.
|
||||
|
||||
## Contributing
|
||||
|
||||
Base is free and open source under the [MIT License](LICENSE.md).
|
||||
|
||||
- [Open an issue](https://github.com/hanzoai/base/issues) for bugs and feature requests.
|
||||
- Read [CONTRIBUTING.md](CONTRIBUTING.md) before sending a PR.
|
||||
- For new features, open an issue to discuss the design first.
|
||||
|
||||
Bug fixes, optimizations, documentation improvements, and new OAuth2 providers are always welcome.
|
||||
|
||||
## Hanzo — the Open AI Cloud
|
||||
|
||||
Open source · every language · on-chain settlement. [hanzo.ai](https://hanzo.ai) · [docs.hanzo.ai](https://docs.hanzo.ai)
|
||||
|
||||
**SDKs in every language** — [Python](https://github.com/hanzoai/python-sdk) (flagship) · [TypeScript](https://github.com/hanzo-js/sdk) · [Go](https://github.com/hanzo-go/sdk) · [Rust](https://github.com/hanzo-rs/sdk) · [C++](https://github.com/hanzo-cpp/sdk) · [Swift](https://github.com/hanzo-swift/sdk) · [Kotlin](https://github.com/hanzo-kt/sdk) · [umbrella](https://github.com/hanzoai/sdk)
|
||||
[CONTRIBUTING.md](CONTRIBUTING.md) covers building and testing. Report
|
||||
vulnerabilities as [SECURITY.md](SECURITY.md) describes. Base is MIT licensed;
|
||||
see [LICENSE.md](LICENSE.md).
|
||||
|
||||
+10
-5
@@ -2,16 +2,21 @@
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
Email security@hanzo.ai with details. Encrypt with our PGP key (fingerprint TBD).
|
||||
Email security@hanzo.ai with details.
|
||||
|
||||
We respond within 48 hours. Critical issues receive same-day acknowledgment.
|
||||
|
||||
## Scope
|
||||
|
||||
This policy covers code in this repository. For the broader Hanzo platform threat model, see [hanzoai/HIPs](https://github.com/hanzoai/HIPs).
|
||||
This policy covers code in this repository. What Base defends on its own, and
|
||||
what it leaves to a deployment, is in [docs/threat-model.md](docs/threat-model.md).
|
||||
|
||||
## Sandbox boundary
|
||||
## Isolation
|
||||
|
||||
`base` enforces tenant isolation at the storage layer: each org gets its own per-tenant data file (`data/{orgSlug}.db`) with a per-org HKDF-derived DEK from KMS, and the WAL is shipped to age-encrypted object storage via `hanzoai/replicate`. User-supplied per-record validators, computed fields, and access rules execute exclusively inside the HIP-0105 in-process runtimes (goja, wazero, pyvm, starkvm) — never in the host Go process directly.
|
||||
With `IAM_ENDPOINT` set, each org's data is its own SQLite file,
|
||||
`<data dir>/orgs/<org>/data.db`, so a query in one org cannot read another's
|
||||
rows. The `base` binary opens those files unencrypted: encryption needs a master
|
||||
key from KMS, which the org plugin reads only when it is given an `IAMOrg`.
|
||||
|
||||
For runtime sandbox guarantees, see HIP-0105 (in-process extension runtimes).
|
||||
JavaScript hooks run inside the Base process with its full authority. They are
|
||||
not sandboxed.
|
||||
|
||||
+26
-29
@@ -1,6 +1,6 @@
|
||||
# Base Network
|
||||
|
||||
Seamless, PQ-native, consensus-replicated HA for every Base app.
|
||||
PQ-native, consensus-replicated HA for every Base app.
|
||||
One mechanism. One env flag. Replica count `N ∈ {1,2,3,…}` works
|
||||
identically on a laptop (`docker compose`) and on k8s (via
|
||||
`~/work/hanzo/operator`). No FUSE, no LiteFS, no Consul.
|
||||
@@ -8,7 +8,7 @@ identically on a laptop (`docker compose`) and on k8s (via
|
||||
Durability and replication come from `luxfi/consensus/protocol/quasar`
|
||||
— the same DAG consensus that runs Lux validators — applied to SQLite
|
||||
WAL frames, one DAG per shard. Archive tier is optional cold storage
|
||||
on `~/work/hanzo/storage` (`hanzoai/s3`) or GCS for PITR.
|
||||
in S3 (`s3://`) for PITR.
|
||||
|
||||
## Problem
|
||||
|
||||
@@ -159,12 +159,8 @@ per-shard WAL segments to object storage. Layout:
|
||||
of PQ-signed, quasar-finalized frames. Replay any prefix ⇒ SQLite at
|
||||
that `txseq`. PITR for free.
|
||||
|
||||
Backend is pluggable via `github.com/hanzoai/s3` client surface:
|
||||
|
||||
- S3 (`s3://…`) — the S3 API, served by `hanzoai/s3`.
|
||||
- GCS (`gs://…`) — native client, used for production in GCP-hosted
|
||||
clusters.
|
||||
- off — archive disabled; durability = quasar DAG only.
|
||||
The backend is S3 (`s3://bucket/prefix`), served by `hanzoai/s3` or AWS.
|
||||
`off` disables the archive, leaving the quasar DAG as the only durability.
|
||||
|
||||
### Env surface (one shape, every Base app)
|
||||
|
||||
@@ -175,11 +171,12 @@ Backend is pluggable via `github.com/hanzoai/s3` client surface:
|
||||
| `BASE_REPLICATION` | `1` \| `2` \| `3` \| … | ≤ `BASE_PEERS` count. |
|
||||
| `BASE_PEERS` | CSV of `host:port` DNS | operator-emitted in k8s; explicit in compose. |
|
||||
| `BASE_NODE_ROLE` | `validator` (default) \| `archive` | |
|
||||
| `BASE_ARCHIVE` | `gs://…` \| `s3://…` \| `off` (default) | |
|
||||
| `BASE_LISTEN_HTTP` | `:8090` default | Base HTTP. |
|
||||
| `BASE_ARCHIVE` | `s3://bucket/prefix` \| `off` (default) | |
|
||||
| `BASE_LISTEN_HTTP` | `:8090` default | Read, but opens nothing; `serve --http` sets the HTTP address. |
|
||||
| `BASE_LISTEN_P2P` | `host:port`, `:9999` default | quasar p2p listener. The host is honoured: `:9999` is every interface, `127.0.0.1:9999` loopback only. |
|
||||
| `BASE_SHARD_BACKLOG_MAX` | bytes (64 MiB default) | R6 per-shard archive backlog cap; drop-oldest beyond. |
|
||||
| `BASE_SHARD_BACKLOG_SEGMENTS` | integer (100 000 default) | R6 segment-count cap; first-to-hit with MAX drops. |
|
||||
|
||||
The R6 backlog caps are constants in `network/archive.go`: 64 MiB and 100,000
|
||||
segments per shard. No variable changes them.
|
||||
|
||||
**The peer transport presents no certificate.**
|
||||
|
||||
@@ -452,23 +449,23 @@ never diverges. Same code path as prod.
|
||||
|
||||
## Acceptance tests
|
||||
|
||||
1. **Single-node**: `N=1`, `archive=off` → behaves exactly like
|
||||
today. No regression in `core/base_test.go`.
|
||||
2. **Docker compose N=3**: write 1000 rows, kill `base-b`, read all
|
||||
1000 from `base-a` and `base-c`. Bring `base-b` back, verify it
|
||||
catches up (txseq matches). No row loss, no duplication.
|
||||
3. **Archive PITR**: write 1000 rows, record txseq, write 100 more.
|
||||
Restore to the first txseq from GCS archive; DB has exactly 1000.
|
||||
4. **k8s operator**: apply a Base-backed CR with
|
||||
`network.replication: 3`. Verify: headless svc, 3 pods with
|
||||
BASE_PEERS env, HPA, PDB minAvailable=2, archive sidecar when
|
||||
`archive != off`.
|
||||
5. **Autoscale**: drive `base_hot_shards` > `hotShardsTarget` via
|
||||
synthetic load; verify KEDA scales Deployment up, new pod joins
|
||||
the network, existing shards rebalance.
|
||||
6. **Gateway**: two users in different shards produce writes on
|
||||
different owner pods; both succeed; read-your-writes respected
|
||||
via `txseq` cookie.
|
||||
- **Single-node**: `N=1`, `archive=off` → behaves exactly like
|
||||
today. No regression in `core/base_test.go`.
|
||||
- **Docker compose N=3**: write 1000 rows, kill `base-b`, read all
|
||||
1000 from `base-a` and `base-c`. Bring `base-b` back, verify it
|
||||
catches up (txseq matches). No row loss, no duplication.
|
||||
- **Archive PITR**: write 1000 rows, record txseq, write 100 more.
|
||||
Restore to the first txseq from the S3 archive; DB has exactly 1000.
|
||||
- **k8s operator**: apply a Base-backed CR with
|
||||
`network.replication: 3`. Verify: headless svc, 3 pods with
|
||||
BASE_PEERS env, HPA, PDB minAvailable=2, archive sidecar when
|
||||
`archive != off`.
|
||||
- **Autoscale**: drive `base_hot_shards` > `hotShardsTarget` via
|
||||
synthetic load; verify KEDA scales Deployment up, new pod joins
|
||||
the network, existing shards rebalance.
|
||||
- **Gateway**: two users in different shards produce writes on
|
||||
different owner pods; both succeed; read-your-writes respected
|
||||
via `txseq` cookie.
|
||||
|
||||
## Non-goals
|
||||
|
||||
|
||||
+195
@@ -0,0 +1,195 @@
|
||||
# Configuration
|
||||
|
||||
Every command, flag and environment variable the `base` binary reads, taken
|
||||
from the code. A Go program that embeds Base gets the root flags from
|
||||
`base.New`, the `serve` flags, the core variables, and the variables of the
|
||||
plugins it registers.
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | What it does |
|
||||
|---|---|
|
||||
| `base serve [domain...]` | Runs the server. Naming domains turns on HTTPS with certificates from Let's Encrypt. |
|
||||
| `base migrate up\|down [n]\|create <name>\|collections\|history-sync` | Applies, reverts or writes migrations. See [hooks](hooks.md#migrations). |
|
||||
| `base update` | Replaces the binary with the latest GitHub release. No current release carries binaries, so it stops with `missing asset`. |
|
||||
| `base cli <command>` | An HTTP client for a running Base: `collection`, `record`, `whoami`, `login`, `crons`, `daemon`, `status`, `rpc`, `config`, `self`, `cluster`, `operator`. |
|
||||
|
||||
## Root flags
|
||||
|
||||
These apply to every command.
|
||||
|
||||
| Flag | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `--dir` | `$DATA_DIR`, else `base_/data` beside the path the binary was started by | Data directory. `./base` uses `./base_/data`; a `base` found on `PATH` uses `base_/data` in the working directory. |
|
||||
| `--dev` | `false`, or `true` under `go run` | Log SQL statements and debug output. |
|
||||
| `--encryptionEnv` | none | Name of an environment variable whose 32-character value encrypts stored settings. |
|
||||
| `--queryTimeout` | `30` | Seconds before a `SELECT` is cancelled. |
|
||||
| `--zap` | `$ZAP_ADDR`, else the HTTP host on `$ZAP_PORT` | ZAP listen address. See [listeners](listeners.md#zap). |
|
||||
| `--mdns` | `$ZAP_MDNS`, else off | Advertise ZAP over mDNS and connect to the peers found. Never while ZAP listens on loopback. |
|
||||
| `--no-mdns` | off | Keep mDNS off even when `--mdns` or `ZAP_MDNS` asks for it. |
|
||||
| `--hooksDir` | `hooks` beside the data directory | JavaScript hooks. |
|
||||
| `--hooksWatch` | `true` | Restart when a file under the hooks directory changes. Not on Windows. |
|
||||
| `--hooksPool` | `15` | Interpreters kept for running hook handlers. |
|
||||
| `--migrationsDir` | `migrations` beside the data directory | JavaScript migrations. |
|
||||
| `--automigrate` | `true` | Write a migration when a superuser changes a collection over `/v1`. |
|
||||
| `--publicDir` | `public` beside the binary | Parsed, but serves nothing: Base registers its own `GET /{path...}` route (the admin UI, or a `404` when the admin is off) before the binary checks for one. |
|
||||
| `--indexFallback` | `true` | Parsed, but has no effect, for the same reason. |
|
||||
| `-v`, `--version` | | Print the version. |
|
||||
|
||||
## serve flags
|
||||
|
||||
| Flag | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `--http` | `127.0.0.1:8090`, or `0.0.0.0:80` when domains are named | HTTP address. While HTTPS is on, it only redirects and answers ACME challenges. |
|
||||
| `--https` | none, or `0.0.0.0:443` when domains are named | HTTPS address. |
|
||||
| `--origins` | `*` | Allowed CORS origins. |
|
||||
|
||||
## update flags
|
||||
|
||||
| Flag | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `--backup` | `true` | Back up the data directory after replacing the binary. |
|
||||
|
||||
## cli flags
|
||||
|
||||
| Flag | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `--url` | `$BASE_URL`, else `http://127.0.0.1:8090` | Base to call. |
|
||||
| `--token` | `$BASE_TOKEN`, else the file `~/.config/base/token` (`$XDG_CONFIG_HOME/base/token` when set) | Token to send. Get it from IAM. |
|
||||
| `--base` | none | Org to act in, sent as `X-Org-Id`. |
|
||||
| `--format` | `table` on a terminal, else `json` | `table`, `json` or `yaml`. |
|
||||
| `--mainnet` (`-m`), `--testnet` (`-t`), `--devnet` (`-d`), `--dev` | `$APP_ENV`, then `$BASE_ENV`, else local | Environment for `cluster`, `operator` and `status`. |
|
||||
|
||||
## Environment
|
||||
|
||||
A boolean accepts what Go's `strconv.ParseBool` accepts: `1`, `t`, `true`,
|
||||
`TRUE` and their false counterparts. The exceptions are `S3_ENABLED` (`true` in
|
||||
any case), `CLOUD_SQL_ENABLED` (exactly `true`) and `WAITLIST_OPEN` (`1`, `true`
|
||||
or `yes` in any case).
|
||||
|
||||
### Core
|
||||
|
||||
| Variable | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `DATA_DIR` | | Data directory when `--dir` is not given. |
|
||||
| `BASE_API_PREFIX` | `/v1` | Where the API is mounted. `/v1/iam` and `/healthz` stay where they are. |
|
||||
| `BASE_ENABLE_ADMIN_UI` | off | Serve the admin UI at `/`. It signs in through IAM. |
|
||||
| `BASE_FRAME_ANCESTORS` | `'self'` | CSP `frame-ancestors` for the admin UI. |
|
||||
| `IAM_DISPLAY_NAME` | empty | Display name of the `iam` provider in `auth-methods`. |
|
||||
| `NATS_URL` | none | Publish record create, update and delete events to NATS. |
|
||||
| `S3_ENABLED` | off | Store files in S3 rather than the data directory. |
|
||||
| `S3_ENDPOINT`, `S3_BUCKET_NAME` (or `S3_BUCKET`), `S3_REGION`, `S3_ACCESS_KEY`, `S3_SECRET_KEY`, `S3_FORCE_PATH_STYLE` | | The S3 connection, read when `S3_ENABLED` is set. |
|
||||
| `THUMBS_MAX_WORKERS` | CPUs + 2 | Thumbnails generated at once. |
|
||||
| `THUMBS_MAX_WAIT` | `60` | Seconds a thumbnail request waits for a worker. |
|
||||
| `FILES_DELETE_MAX_WORKERS` | `2000` | File deletions run at once. |
|
||||
| `BASE_PRIVATE_MAX_TAG_BYTES` | `1048576` | Largest value in `/v1/private`, the per-user encrypted blob store. |
|
||||
| `BASE_PRIVATE_MAX_USER_BYTES` | `104857600` | Total `/v1/private` bytes per user. |
|
||||
| `BASE_GOJAVM_POOL_SIZE` | `8` | Interpreters for `/v1/functions`, which is how many functions run at once. |
|
||||
| `TASKS_EMBED` | off | Run the Tasks engine inside Base. See [listeners](listeners.md#embedded-tasks). |
|
||||
| `TASKS_EMBED_ADDRESS` | `<data dir>/tasks.sock` | Where the embedded engine listens. |
|
||||
| `TASKS_NAMESPACE` | `default` | Namespace of the embedded engine. |
|
||||
| `TASKS_ZAP` | none | A Tasks server to use over ZAP. |
|
||||
| `TASKS_URL` | none | A Tasks server to use over HTTP. With no Tasks server, scheduled jobs run on timers inside the process. |
|
||||
|
||||
### ZAP
|
||||
|
||||
| Variable | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `ZAP_ADDR` | none | ZAP listen address. `--zap` wins over it. |
|
||||
| `ZAP_PORT` | `9999` | Port used on the HTTP host when no address is given. |
|
||||
| `ZAP_MDNS` | off | The same as `--mdns`. |
|
||||
| `ZAP_DISABLED` | off | Do not start ZAP. |
|
||||
|
||||
### IAM, orgs and KMS
|
||||
|
||||
The binary registers `plugins/org` when `IAM_ENDPOINT` or `IAM_URL` is set.
|
||||
|
||||
| Variable | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `IAM_ENDPOINT` (or `IAM_URL`) | none | IAM origin. Turns on IAM sign-in and a Base per org. |
|
||||
| `IAM_ADDRESS` | none | Where Base reaches the IAM service to check tokens and keys, when that is not `IAM_ENDPOINT`. A bare `host:port` is ZAP. |
|
||||
| `IAM_CLIENT_ID`, `IAM_CLIENT_SECRET` | none | The IAM application Base presents when it calls IAM itself, and to KMS over HTTPS. |
|
||||
| `KMS_ENDPOINT` (or `KMS_URL`) | none | Secrets store: `host:port`, `zap://host:port`, `https://...`, or `zap+mdns://_kms._tcp` to discover it. Contacted on first use. |
|
||||
| `LUX_MNEMONIC` (or `MNEMONIC`) | none | Seed of the identity Base signs KMS requests with. Required for KMS over ZAP. |
|
||||
| `KMS_ENV` | `prod` | Environment of the secrets Base reads. |
|
||||
| `KMS_SERVICE_PATH` | `hanzo/base` | Derivation path of that identity. |
|
||||
| `KMS_NODE_ID` | `hanzo-base` | Node name the KMS client presents. |
|
||||
| `IDV_ENDPOINT` | none | Upstream for `/v1/idv/*`. Unset, `/v1/idv/status` answers `{"enabled":false}` and the rest `503`. |
|
||||
|
||||
### Replication
|
||||
|
||||
| Variable | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `BASE_NETWORK` | `standalone` | `quasar` replicates writes to peers. |
|
||||
| `BASE_PEERS` | none | Peers as comma-separated `host:port`. Replication listens only when this names one. |
|
||||
| `BASE_SHARD_KEY` | none | Required with `quasar`: a field of the verified identity such as `user_id` or `org_id`, or `header:<Name>`. |
|
||||
| `BASE_REPLICATION` | `1` | Members that hold each shard. |
|
||||
| `BASE_NODE_ID` | `$HOSTNAME` | This member's id. Required with `quasar`. |
|
||||
| `BASE_NODE_ROLE` | `validator` | `validator` or `archive`. |
|
||||
| `BASE_ARCHIVE` | `off` | `s3://bucket/prefix` to archive frames. |
|
||||
| `BASE_LISTEN_P2P` | `:9999` | Peer listener, bound exactly as written. The port must be a number. |
|
||||
| `BASE_LISTEN_HTTP` | `:8090` | Read, but opens nothing. |
|
||||
| `BASE_PEER_HTTP_ENDPOINTS` | none | Comma-separated `peer=url` pairs: where to send a write that a peer owns. |
|
||||
| `BASE_PEER_HTTP_PORT` | `8090` | HTTP port assumed for a peer missing from that list. |
|
||||
| `AWS_REGION`, `AWS_ENDPOINT_URL`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN` | | Credentials for an `s3://` archive. |
|
||||
| `BASE_ENCRYPT`, `BASE_TLS_CA`, `BASE_TLS_SERVER_CERT`, `BASE_TLS_SERVER_KEY`, `BASE_TLS_ALLOWED_SANS` | | Refused. Setting any of them stops startup with an explanation. |
|
||||
|
||||
### Plugins in the binary
|
||||
|
||||
The waitlist plugin is always registered and serves `/v1/waitlist/*`.
|
||||
|
||||
| Variable | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `TURNSTILE_SECRET_KEY` | none | Cloudflare Turnstile secret for checking joins. |
|
||||
| `WAITLIST_ADMIN_SECRET` | none | Secret for `boost` and `export`. |
|
||||
| `WAITLIST_AWARD_SECRET` | none | Secret for `award`. |
|
||||
| `WAITLIST_DEFAULT_SLUGS` | none | Comma-separated waitlists to create at startup. |
|
||||
| `WAITLIST_ACCESS_CAPACITY` | `0` | Grant access to the top N entries by rank. |
|
||||
| `WAITLIST_OPEN` | off | Give everyone access. |
|
||||
| `POINTS_REFERRAL`, `POINTS_SHARE`, `POINTS_INVITE_SENT`, `POINTS_INVITE_CONVERTED`, `POINTS_SOCIAL`, `POINTS_HANZOD`, `POINTS_SIGNUP` | `10`, `2`, `1`, `5`, `15`, `25`, `0` | Points per event. |
|
||||
|
||||
The other three are off unless enabled.
|
||||
|
||||
| Variable | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `BOOTNODE_ENABLED` | off | Serve the bootnode developer platform. |
|
||||
| `BOOTNODE_API_KEY_SALT` | none | Salt for issued API keys. Required when `IAM_URL` is not a local host. |
|
||||
| `BOOTNODE_ALLOWED_ORGS` | `hanzo`, `zoo`, `lux`, `pars` | IAM orgs allowed to sign in. |
|
||||
| `BOOTNODE_K8S_NAMESPACE` | `bootnode` | Namespace for the resources it applies. |
|
||||
| `IAM_URL`, `IAM_CLIENT_ID`, `IAM_CLIENT_SECRET` | | The IAM application for its OAuth2 code exchange. |
|
||||
| `FRONTEND_URL` | `http://localhost:3001` | Redirect base when a request names none. |
|
||||
| `COMMERCE_URL`, `COMMERCE_API_KEY` | none | Billing client. An empty key turns billing off. |
|
||||
| `KUBE_APISERVER`, `KUBE_TOKEN`, `KUBE_INSECURE_SKIP_TLS_VERIFY` | | Kubernetes API, when not running in a cluster. |
|
||||
| `KUBERNETES_SERVICE_HOST`, `KUBERNETES_SERVICE_PORT` | | Kubernetes API from inside a cluster. |
|
||||
| `CALENDAR_ENABLED` | off | Serve the booking API at `/v1/calendar`. |
|
||||
| `CALENDAR_SEED_HANDLE` | none | Create a bookable host with this handle at startup. |
|
||||
| `CALENDAR_SEED_OWNER`, `CALENDAR_SEED_TZ`, `CALENDAR_SEED_TITLE`, `CALENDAR_SEED_LOCATION` | the handle, `America/Los_Angeles`, `Intro`, `https://meet.hanzo.ai/<handle>` | That host's details. |
|
||||
| `CLOUD_SQL_ENABLED` | off | Serve `/v1/cloud-sql` and `/v1/meta`. |
|
||||
| `CLOUD_SQL_META_URL`, `CLOUD_SQL_COMPUTE_HOST`, `CLOUD_SQL_PG_USER`, `CLOUD_SQL_PG_PASS` | none | The managed PostgreSQL service it drives. |
|
||||
|
||||
### Plugins you link yourself
|
||||
|
||||
The binary does not include these. A program that registers one reads its
|
||||
variables.
|
||||
|
||||
| Plugin | Variables |
|
||||
|---|---|
|
||||
| `plugins/ha` | `BASE_LOCAL_TARGET`, `BASE_PEERS`, `BASE_STATIC_WRITER`, `BASE_NODE_ID` |
|
||||
| `plugins/replicate` | `REPLICATE_S3_ENDPOINT`, `REPLICATE_S3_PATH` |
|
||||
| `plugins/tasks` | `TASKS_ENABLED` (or `HANZO_TASKS_ENABLED`), `TASKS_ADDRESS` (or `HANZO_TASKS_ADDRESS`), `TASKS_NAMESPACE` (or `HANZO_TASKS_NAMESPACE`), `TASKS_QUEUE`, `TASKS_WORKER` |
|
||||
| `plugins/pyvm`, `plugins/v8vm`, `plugins/wasmvm`, `plugins/starkvm` | `BASE_PYVM_POOL_SIZE`, `BASE_V8VM_POOL_SIZE`, `BASE_WASMVM_POOL_SIZE`, `BASE_STARKVM_POOL_SIZE` |
|
||||
|
||||
### Read by a dependency
|
||||
|
||||
| Variable | Meaning |
|
||||
|---|---|
|
||||
| `HANZO_SQLITE_RAMFS_DIR` | Directory the pure-Go encrypted SQLite codec decrypts into. It must be RAM-backed: `/dev/shm` is used when this is unset, and on macOS only a `tmpfs` mount qualifies. With neither, an encrypted database does not open. |
|
||||
| `HOSTNAME` | Default replication node id. |
|
||||
|
||||
### The cli command
|
||||
|
||||
| Variable | Meaning |
|
||||
|---|---|
|
||||
| `BASE_URL`, `BASE_TOKEN` | Defaults for `--url` and `--token`. |
|
||||
| `XDG_CONFIG_HOME` | Where the token file and `base/config.json` live. |
|
||||
| `APP_ENV`, `BASE_ENV` | Default environment when no environment flag is given. |
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
# Hooks
|
||||
|
||||
A hook is JavaScript that runs inside the Base process: code that runs when a
|
||||
record is written, a route of your own, or a job on a schedule. The `base`
|
||||
binary built from `examples/base` loads hooks. A Go program that embeds Base
|
||||
loads them only if it registers `plugins/jsvm`.
|
||||
|
||||
## Where hooks live
|
||||
|
||||
Base loads every file directly inside the hooks directory whose name ends in
|
||||
`.base.js` or `.base.ts`.
|
||||
|
||||
- The directory is `--hooksDir`. Without it, Base uses `hooks` beside the data
|
||||
directory, so the default data directory `base_/data` gives `base_/hooks`.
|
||||
- Subdirectories are not loaded. Keep shared code there and `require` it.
|
||||
- A `.base.ts` file runs as plain JavaScript. A type annotation is a syntax
|
||||
error.
|
||||
- At startup Base writes type declarations to `types.d.ts` in the data
|
||||
directory. An empty hook file gets a `/// <reference>` line pointing at it, so
|
||||
an editor can complete `$app`, `routerAdd` and the `on*` hooks.
|
||||
|
||||
## An example
|
||||
|
||||
A migration creates the collection, a record hook trims text before it is
|
||||
saved, and a route counts the records.
|
||||
|
||||
`base_/migrations/1_notes.js`:
|
||||
|
||||
```js
|
||||
migrate((app) => {
|
||||
app.save(new Collection({
|
||||
type: "base",
|
||||
name: "notes",
|
||||
listRule: "",
|
||||
viewRule: "",
|
||||
createRule: "",
|
||||
fields: [{ name: "text", type: "text", required: true }],
|
||||
}))
|
||||
})
|
||||
```
|
||||
|
||||
`base_/hooks/notes.base.js`:
|
||||
|
||||
```js
|
||||
onRecordCreateRequest((e) => {
|
||||
e.record.set("text", e.record.getString("text").trim())
|
||||
e.next()
|
||||
}, "notes")
|
||||
|
||||
routerAdd("GET", "/v1/notes/count", (e) => {
|
||||
const { label } = require(`${__hooks}/lib/label.js`)
|
||||
return e.json(200, { [label()]: $app.countRecords("notes") })
|
||||
})
|
||||
```
|
||||
|
||||
`base_/hooks/lib/label.js`:
|
||||
|
||||
```js
|
||||
module.exports = { label: () => "notes" }
|
||||
```
|
||||
|
||||
Start Base next to those files and call it:
|
||||
|
||||
```sh
|
||||
./base serve --http 127.0.0.1:8090
|
||||
```
|
||||
|
||||
```sh
|
||||
curl -s -X POST http://127.0.0.1:8090/v1/collections/notes/records \
|
||||
-H 'Content-Type: application/json' -d '{"text":" buy milk "}'
|
||||
{"collectionId":"hbc_3395098727","collectionName":"notes","id":"ot6ovxamu9s9ovi","text":"buy milk"}
|
||||
|
||||
curl -s http://127.0.0.1:8090/v1/notes/count
|
||||
{"notes":1}
|
||||
```
|
||||
|
||||
## How a hook file runs
|
||||
|
||||
Each file runs once, at startup, and registers handlers: `routerAdd`,
|
||||
`routerUse`, `cronAdd`, and the `on*` functions such as `onRecordCreateRequest`
|
||||
and `onBootstrap`. There is no `onServe`; add routes with `routerAdd`. The
|
||||
handlers run later, on a pool of interpreters whose size is `--hooksPool`
|
||||
(default 15).
|
||||
|
||||
**A handler cannot see the rest of its file.** Base compiles each handler from
|
||||
its own source text, so a `const` at the top of the file does not exist inside
|
||||
the handler. This route fails:
|
||||
|
||||
```js
|
||||
const greeting = "hello"
|
||||
|
||||
routerAdd("GET", "/v1/gotcha", (e) => {
|
||||
return e.json(200, { greeting: greeting })
|
||||
})
|
||||
```
|
||||
|
||||
```sh
|
||||
curl -s http://127.0.0.1:8090/v1/gotcha
|
||||
{"data":{},"message":"Something went wrong while processing your request.","status":400}
|
||||
```
|
||||
|
||||
Put shared code in a module and `require` it inside the handler, as the example
|
||||
does. Modules are CommonJS: `module.exports` out, `require(path)` in. A relative
|
||||
path resolves from the process's working directory, not from the hook file, so
|
||||
build the path from `__hooks`, which holds the absolute path of the hooks
|
||||
directory.
|
||||
|
||||
Record hooks take collection names after the handler, as
|
||||
`onRecordCreateRequest(handler, "notes")` does. With no names, the handler runs
|
||||
for every collection. A handler calls `e.next()` to let the rest of the chain
|
||||
run, including Base's own work; a handler that returns without calling it stops
|
||||
the chain there.
|
||||
|
||||
Inside a handler, `$app` is the Base the event belongs to. With IAM on, that is
|
||||
the org's own Base, so a hook reads and writes that org's data. Record and model
|
||||
hooks run on every org's Base. `routerAdd` and `cronAdd` register once, for the
|
||||
whole process.
|
||||
|
||||
The interpreter is goja, a JavaScript engine written in Go. Beyond the language
|
||||
it has `require`, `console`, `process` and `Buffer`, and no other Node.js
|
||||
modules. Make HTTP requests with `$http.send`. Everything else Base offers is
|
||||
declared in `types.d.ts`.
|
||||
|
||||
## Changing hooks while Base runs
|
||||
|
||||
With `--hooksWatch`, which is on by default, saving any file under the hooks
|
||||
directory restarts Base in place: the same process, running the new code.
|
||||
Hidden directories and `node_modules` are not watched. When the hooks directory
|
||||
is a symlink, Base watches the directory it points to, but it does not follow
|
||||
symlinks inside it. Windows cannot restart this way; restart by hand there.
|
||||
|
||||
A file that fails to load is logged and skipped while watching is on. With
|
||||
`--hooksWatch=false`, the same failure stops the process.
|
||||
|
||||
## Migrations
|
||||
|
||||
Put schema in migrations. They are JavaScript files in `--migrationsDir`
|
||||
(default `migrations` beside the data directory, so `base_/migrations`), and
|
||||
`serve` applies the pending ones in file name order when it starts. Each file
|
||||
calls `migrate(up, down)`; `down` is optional.
|
||||
|
||||
| Command | What it does |
|
||||
|---|---|
|
||||
| `base migrate up` | Applies pending migrations. |
|
||||
| `base migrate down [n]` | Reverts the last `n`. |
|
||||
| `base migrate create <name>` | Writes an empty migration file. |
|
||||
| `base migrate collections` | Writes a migration holding every current collection. |
|
||||
| `base migrate history-sync` | Forgets applied migrations whose files are gone. |
|
||||
|
||||
With `--automigrate`, on by default, a collection change a superuser makes over
|
||||
`/v1` is also written out as a migration file.
|
||||
|
||||
## Before you ship hooks
|
||||
|
||||
A hook runs with the full authority of the process. `$os` can run commands and
|
||||
read and write files, and `routerAdd` can claim any path. Load only hooks you
|
||||
wrote or read. [threat-model.md](threat-model.md) covers what that means for a
|
||||
deployment.
|
||||
|
||||
A route added with `routerAdd` applies no collection rules. Check them yourself
|
||||
with `$app.canAccessRecord(record, e.requestInfo(), collection.listRule)`. The
|
||||
[hybrid search example](../examples/hooks-hybrid-search) does this for every
|
||||
result it returns.
|
||||
@@ -0,0 +1,123 @@
|
||||
# Network listeners
|
||||
|
||||
Every socket a Base process can listen on: what opens it, where it binds, and
|
||||
how to change that. A `serve` with no flags and no environment opens two, both
|
||||
on loopback:
|
||||
|
||||
```sh
|
||||
./base serve
|
||||
lsof -nP -a -p <pid> -iTCP -sTCP:LISTEN
|
||||
```
|
||||
|
||||
```
|
||||
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
|
||||
base 62716 z 18u IPv4 0x570514825a9f5e0d 0t0 TCP 127.0.0.1:8090 (LISTEN)
|
||||
base 62716 z 19u IPv4 0x26859a4d8573f408 0t0 TCP 127.0.0.1:9999 (LISTEN)
|
||||
```
|
||||
|
||||
| Listener | Opens when | Binds by default | Change with |
|
||||
|---|---|---|---|
|
||||
| HTTP | `serve` | `127.0.0.1:8090`; `0.0.0.0:80` when domains are named | `--http` |
|
||||
| HTTPS | `--https`, or domains named | `0.0.0.0:443` when domains are named | `--https` |
|
||||
| ZAP | `serve`, unless `ZAP_DISABLED=true` | the HTTP host, port `9999` | `--zap`, `ZAP_ADDR`, `ZAP_PORT` |
|
||||
| mDNS | `--mdns` or `ZAP_MDNS=true`, and ZAP is not on loopback | UDP `5353` on every multicast interface | `--no-mdns` keeps it off |
|
||||
| Replication | `BASE_NETWORK=quasar` and at least one entry in `BASE_PEERS` | `:9999`, every interface | `BASE_LISTEN_P2P` |
|
||||
| Embedded Tasks | `TASKS_EMBED=true` | unix socket `<data dir>/tasks.sock` | `TASKS_EMBED_ADDRESS` |
|
||||
| KMS discovery | `KMS_ENDPOINT=zap+mdns://...`, at the first secret read | a port the OS picks, on every interface, plus mDNS | a `host:port` endpoint, which dials instead |
|
||||
|
||||
## HTTP and HTTPS
|
||||
|
||||
`serve` listens on `--http`, which is `127.0.0.1:8090` unless you say otherwise.
|
||||
Name domains (`base serve example.com`) and the defaults become `0.0.0.0:80`
|
||||
and `0.0.0.0:443`, with certificates from Let's Encrypt cached in
|
||||
`<data dir>/.autocert_cache`. `--https <address>` turns HTTPS on without naming
|
||||
domains; the certificate is then for the host in that address. While HTTPS is
|
||||
on, the HTTP address only redirects to HTTPS and answers ACME challenges.
|
||||
|
||||
The API is under `/v1` (`BASE_API_PREFIX` moves it) and `GET /healthz` answers
|
||||
at the root. With replication on, `GET /-/base/members` also answers, with no
|
||||
credential required. The admin UI is served at `/` only when
|
||||
`BASE_ENABLE_ADMIN_UI=1`.
|
||||
|
||||
## ZAP
|
||||
|
||||
ZAP is a binary protocol. The ZAP listener serves the same handler as HTTP: the
|
||||
same routes, middleware, IAM checks and rules. It adds a port, not a way around
|
||||
any of them.
|
||||
|
||||
- The address is `--zap`, else `ZAP_ADDR`, else the HTTP listener's host on
|
||||
`ZAP_PORT` (default `9999`). `--http 127.0.0.1:8090` gives `127.0.0.1:9999`;
|
||||
`--http :8090` gives `:9999`, which is every interface. With HTTPS on, the
|
||||
host comes from the HTTPS address.
|
||||
- An address that starts with `/`, `./`, `../` or `@` is a unix socket.
|
||||
- If the address cannot be read or cannot be bound, Base logs an error and
|
||||
keeps serving HTTP.
|
||||
- The container image runs `serve --http=0.0.0.0:8090`, so inside a container
|
||||
ZAP listens on `0.0.0.0:9999`. Publish only the ports you mean to.
|
||||
- `ZAP_DISABLED=true` turns it off.
|
||||
|
||||
## mDNS
|
||||
|
||||
mDNS is off unless `--mdns` or `ZAP_MDNS=true` asks for it, and it stays off
|
||||
when ZAP listens on loopback or a unix socket. `--no-mdns` keeps it off
|
||||
whatever else is set. When it runs, Base joins the multicast groups
|
||||
`224.0.0.251` and `ff02::fb` on UDP port 5353 on every multicast-capable
|
||||
interface, advertises `_hanzo-base._tcp` under the host name, and connects to
|
||||
the peers it finds.
|
||||
|
||||
## Replication
|
||||
|
||||
`BASE_NETWORK=quasar` replicates SQLite writes to the peers in `BASE_PEERS`.
|
||||
With no peers it opens nothing.
|
||||
|
||||
- The peer listener binds `BASE_LISTEN_P2P` exactly as written. The default,
|
||||
`:9999`, is every interface; `127.0.0.1:9999` is loopback. The port must be a
|
||||
number: `BASE_LISTEN_P2P=127.0.0.1` stops startup with
|
||||
`missing port in address`.
|
||||
- Startup also stops without `BASE_SHARD_KEY`, or without `BASE_NODE_ID` when
|
||||
`$HOSTNAME` is unset.
|
||||
- Peers present no certificate. Whatever can reach this port can submit frames
|
||||
for the shards this node owns, so `BASE_PEERS` and your network policy are the
|
||||
whole trust boundary. [NETWORK.md](NETWORK.md) has the design.
|
||||
- ZAP defaults to port 9999 as well. Replication starts first, so when both land
|
||||
on one address, ZAP is the one that logs `address already in use` and stays
|
||||
off. Give one of them another port.
|
||||
- `BASE_LISTEN_HTTP` is read but opens nothing.
|
||||
|
||||
## Embedded Tasks
|
||||
|
||||
`TASKS_EMBED=true` runs the Tasks engine inside Base and listens for it at
|
||||
`TASKS_EMBED_ADDRESS`, by default `<data dir>/tasks.sock`. A path is a unix
|
||||
socket; anything else is `host:port`. When the engine fails to start, Base logs
|
||||
nothing and runs scheduled jobs on timers inside the process. Rule out these
|
||||
causes:
|
||||
|
||||
- A relative data directory, such as the default `base_/data`. The socket path
|
||||
then starts with neither `/` nor `./`, is read as a TCP address, and cannot
|
||||
bind. Give an absolute `--dir` or `TASKS_EMBED_ADDRESS`.
|
||||
- No RAM-backed directory for the engine's encrypted store: `/dev/shm` on
|
||||
Linux, or on macOS a `tmpfs` mount named by `HANZO_SQLITE_RAMFS_DIR`.
|
||||
|
||||
## KMS
|
||||
|
||||
The KMS client does nothing until Base first reads a secret. A `host:port` or
|
||||
`zap://host:port` endpoint then dials that address and listens on nothing, and
|
||||
an `https://` endpoint uses HTTPS. `zap+mdns://_kms._tcp` is the exception: to
|
||||
find KMS, it starts a ZAP node that listens on a port the OS picks, on every
|
||||
interface, and runs mDNS.
|
||||
|
||||
## Connections Base makes but never accepts
|
||||
|
||||
- IAM: HTTPS to `IAM_ENDPOINT` for signing keys and for `/v1/iam/*`, and
|
||||
`IAM_ADDRESS` when set.
|
||||
- Tasks: `TASKS_ZAP` over ZAP or `TASKS_URL` over HTTP. The client dials and
|
||||
does not listen.
|
||||
- NATS: `NATS_URL`, to publish record events.
|
||||
- S3 for files (`S3_*`) and for the replication archive (`BASE_ARCHIVE`), SMTP
|
||||
when mail is configured, and whatever a hook calls with `$http.send`.
|
||||
- Replication: the peers in `BASE_PEERS`, and the HTTP address of the peer that
|
||||
owns a write.
|
||||
|
||||
## Checking a running Base
|
||||
|
||||
`lsof -nP -a -p <pid> -i` lists every socket a process holds, listening or not.
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
# Access rules and identity
|
||||
|
||||
A collection has five rules: `listRule`, `viewRule`, `createRule`, `updateRule`
|
||||
and `deleteRule`. They are all of Base's authorization. Base checks them in Go
|
||||
on every `/v1` request. A list rule becomes part of the query, so a row the
|
||||
caller may not see is neither returned nor counted.
|
||||
|
||||
## Rule values
|
||||
|
||||
| Value | Who passes | A caller who fails gets |
|
||||
|---|---|---|
|
||||
| `null`, the default | superusers | `403 Only superusers can perform this action.` |
|
||||
| `""` | everyone, signed in or not | |
|
||||
| an expression | callers for whom it is true | list: a page with no items; view, update, delete: `404`; create: `400 Failed to create record.` |
|
||||
|
||||
An expression is a filter over the record and the request:
|
||||
|
||||
```
|
||||
@request.auth.id != ""
|
||||
owner = @request.auth.id
|
||||
status = "published" || owner = @request.auth.id
|
||||
@request.body.role:isset = false
|
||||
```
|
||||
|
||||
| Part | Forms |
|
||||
|---|---|
|
||||
| Compare | `=` `!=` `>` `>=` `<` `<=` `~` (contains) `!~` |
|
||||
| Compare any of several values | `?=` `?!=` `?>` `?>=` `?<` `?<=` `?~` `?!~` |
|
||||
| Combine | `&&` `\|\|` and parentheses |
|
||||
| The request | `@request.auth.*` `@request.body.*` `@request.query.*` `@request.headers.*` `@request.method` `@request.context` |
|
||||
| Another collection | `@collection.<name>.<field>` |
|
||||
| Modifiers | `:isset` `:changed` `:length` `:each` `:lower` |
|
||||
|
||||
`?filter=` on list requests uses the same syntax.
|
||||
|
||||
Rules also decide what `?expand=` returns and what the `/v1/rest/{table}` reads
|
||||
return. A route you add in a hook applies no rule on its own. Call
|
||||
`$app.canAccessRecord(record, e.requestInfo(), collection.listRule)` for each
|
||||
record, as the [hybrid search example](../examples/hooks-hybrid-search/hooks/search.js)
|
||||
does.
|
||||
|
||||
## Where identity comes from
|
||||
|
||||
Base signs nobody in. It has no passwords, no sign-up and no sessions of its
|
||||
own. A caller is whoever a Hanzo IAM access token says, sent as
|
||||
`Authorization: Bearer <token>`.
|
||||
|
||||
Point Base at IAM to turn this on:
|
||||
|
||||
```sh
|
||||
IAM_ENDPOINT=https://hanzo.id ./base serve --http 127.0.0.1:8090
|
||||
```
|
||||
|
||||
With `IAM_ENDPOINT` set, Base:
|
||||
|
||||
- verifies every token against IAM's keys at
|
||||
`$IAM_ENDPOINT/v1/iam/.well-known/jwks`, and treats a token that fails as no
|
||||
token;
|
||||
- ignores tokens signed any other way, including those an older Base issued;
|
||||
- passes `/v1/iam/*` through to IAM;
|
||||
- lists IAM as the one sign-in provider at
|
||||
`GET /v1/collections/users/auth-methods`, with IAM's authorize URL and a PKCE
|
||||
challenge;
|
||||
- deletes `X-Org-Id`, `X-User-Id` and `X-User-Email` from every request as it
|
||||
arrives. `X-Org-Id` is read first, as the org the caller asks for.
|
||||
|
||||
For the length of one request, the token becomes an auth record that is never
|
||||
saved:
|
||||
|
||||
| Field | From the token |
|
||||
|---|---|
|
||||
| `id` | `sub` (else `preferred_username`): kept when it is 15 characters of `a-z`, `0-9` and `_`, padded with `_` when shorter, otherwise the first 24 hex characters of its SHA-256 |
|
||||
| `email` | `email` |
|
||||
| `name` | `name`, else `displayName` |
|
||||
| `org_id` | the org the request acts in, when the auth collection has an `org_id` field |
|
||||
|
||||
Rules read these as `@request.auth.id`, `@request.auth.email` and so on.
|
||||
|
||||
**Superusers are the members of IAM's `admin` org**, and nobody else. Their
|
||||
record is mirrored into `_superusers`; everyone else's into `users`. A superuser
|
||||
passes every rule, manages collections and settings, and can act in any org. An
|
||||
`admin` role inside an ordinary org is a different thing and grants none of
|
||||
this.
|
||||
|
||||
**Each org has its own database.** Base takes the org from the token's
|
||||
memberships and opens `<data dir>/orgs/<org>/data.db` for the request. Two orgs
|
||||
never share a file, so a query in one cannot return another's rows. A token
|
||||
that belongs to several orgs picks one with `X-Org-Id: <org>`. Naming an org
|
||||
the token does not carry gets `403`, and so does a token with no org.
|
||||
|
||||
**Keys** stand in for tokens when no person is signing in. IAM issues them.
|
||||
`sk-` keys are for servers. `pk-` keys are for web pages and reach only routes
|
||||
marked public, such as record lists and views.
|
||||
|
||||
## Without IAM
|
||||
|
||||
Leave `IAM_ENDPOINT` unset and the `base` binary serves one Base with no orgs.
|
||||
There is no sign-in route, so a caller without a token cannot get one: every
|
||||
such caller is anonymous and none is a superuser. That is enough to build
|
||||
against collections whose rules admit anonymous callers. Define the collections
|
||||
in [migrations](hooks.md#migrations), because `/v1/collections` needs a
|
||||
superuser.
|
||||
|
||||
Tokens Base signed itself still count, though, and `auth-refresh` renews them.
|
||||
A superuser token left from an older version is one; [versions.md](versions.md)
|
||||
shows how to revoke it.
|
||||
|
||||
Such a Base prints this when it starts:
|
||||
|
||||
```
|
||||
(!) No superuser exists yet. Create the first one with:
|
||||
./base superuser upsert EMAIL PASS
|
||||
```
|
||||
|
||||
That command was removed. Superusers come from IAM.
|
||||
|
||||
## Close the open users collection
|
||||
|
||||
A new Base has a `users` collection whose `createRule` is `""`, so anyone can
|
||||
add rows to it. Nobody can sign in as those rows, but it is still a public
|
||||
write. Close it with a migration:
|
||||
|
||||
```js
|
||||
migrate((app) => {
|
||||
const users = app.findCollectionByNameOrId("users")
|
||||
users.createRule = null
|
||||
app.save(users)
|
||||
})
|
||||
```
|
||||
|
||||
After it runs, an anonymous `POST /v1/collections/users/records` gets `403`.
|
||||
|
||||
Rate limits and CORS have defaults to change too. See
|
||||
[threat-model.md](threat-model.md).
|
||||
+12
-9
@@ -9,10 +9,12 @@ with the thing you do about it.
|
||||
|
||||
## The boundary
|
||||
|
||||
**Identity comes from Hanzo IAM and from nowhere else.** Base hosts no login,
|
||||
stores no password and issues no token. A request is whoever IAM's signed token
|
||||
says it is, verified against IAM's JWKS. `IAM_ENDPOINT` is required; a Base with
|
||||
no IAM refuses to boot rather than falling back to something weaker.
|
||||
**Identity comes from Hanzo IAM and from nowhere else.** Base hosts no login and
|
||||
stores no password. A request is whoever IAM's signed token says it is, verified
|
||||
against IAM's JWKS. `IAM_ENDPOINT` turns that on. Without it, the `base` binary
|
||||
serves one Base in which every HTTP caller is anonymous, except one holding a
|
||||
token Base signed itself, such as a superuser token left from an older version.
|
||||
**Rotate those after an upgrade**; [versions.md](versions.md) shows how.
|
||||
|
||||
**A tenant is a file.** The org on a verified token selects
|
||||
`{DataDir}/orgs/{org}/data.db`, and every read and write in `/v1` lands there.
|
||||
@@ -34,11 +36,12 @@ a different authority and grants none of it.
|
||||
## At rest
|
||||
|
||||
**The platform database is not encrypted by the driver.** `DefaultDBConnect`
|
||||
opens `data.db` with pragmas only — no key. Per-org shards are different: they
|
||||
open under a per-org DEK derived from the master key, SQLCipher under cgo and a
|
||||
pure-Go codec envelope otherwise. **Put the data directory on an encrypted
|
||||
volume.** Anyone who can read the file can read the platform Base with any
|
||||
SQLite client.
|
||||
opens `data.db` with pragmas only — no key. Per-org shards open under a per-org
|
||||
DEK derived from a master key, SQLCipher under cgo and a pure-Go codec envelope
|
||||
otherwise, but only when the org plugin has read that key from KMS, which it
|
||||
does only when it is given an `IAMOrg`. The `base` binary gives it none, so its
|
||||
org shards open unencrypted as well. **Put the data directory on an encrypted
|
||||
volume.** Anyone who can read a file can read that Base with any SQLite client.
|
||||
|
||||
**Settings are plaintext JSON unless you set the encryption env.** SMTP
|
||||
passwords, S3 secrets and OAuth2 client secrets live in `_params`. With
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
# Versions and upgrading
|
||||
|
||||
## What each tag serves
|
||||
|
||||
Tag numbers do not run in a straight line: two lines of work were tagged in the
|
||||
same range. This is what each range serves, read from its code.
|
||||
|
||||
| Tags | Default API prefix | Sign-in |
|
||||
|---|---|---|
|
||||
| `v0.36.7-hanzo.1` to `v0.39.9` | `/api` | passwords, `base superuser` |
|
||||
| `v0.39.10`, `v0.39.11` | `/v1/base` | passwords, `base superuser` |
|
||||
| `v0.39.12`, `v0.39.13` | `/api` | passwords, `base superuser` |
|
||||
| `v0.39.14` to `v0.39.17` | `/v1` | Hanzo IAM |
|
||||
| `v0.40.0` to `v0.52.3` | `/api` | passwords, `base superuser` |
|
||||
| `v1.0.0` | `/api` | Hanzo IAM |
|
||||
| `v1.1.0` and later | `/v1` | Hanzo IAM |
|
||||
|
||||
From `v0.39.10` on, `BASE_API_PREFIX` overrides the prefix. On a password tag,
|
||||
superusers and users sign in at
|
||||
`<prefix>/collections/<collection>/auth-with-password`. IAM tags have no such
|
||||
route.
|
||||
|
||||
The default data directory's name differs between tags too (`hz_data`, `data`,
|
||||
`base_/data`), and so do the hooks and migrations directories beside it. Current
|
||||
versions use `base_/data`. Pass `--dir`, `--hooksDir` and `--migrationsDir` when
|
||||
you upgrade, and the names stop mattering.
|
||||
|
||||
## Where to get a version
|
||||
|
||||
| Channel | What is there |
|
||||
|---|---|
|
||||
| Go module | Tagged versions of `github.com/hanzoai/base`; `go list -m -versions github.com/hanzoai/base` lists them. |
|
||||
| Source | `git clone --depth 1 --branch <tag> https://github.com/hanzoai/base`, then `CGO_ENABLED=0 go build` in `examples/base`. |
|
||||
| Container images | [`ghcr.io/hanzoai/base`](https://github.com/hanzoai/base/pkgs/container/base), tagged with the version, with or without the `v`. Not every git tag has an image, so check before you pin. `latest`, `main` and `dev` are old builds. |
|
||||
| GitHub releases | Binaries only for `v0.36.7-hanzo.1`, which serves `/api`. Later releases have none, so `base update` stops with `missing asset`. |
|
||||
|
||||
`go install github.com/hanzoai/base/examples/base@<version>` does not work: the
|
||||
module's `go.mod` has a `replace` directive, and `go install` refuses those.
|
||||
|
||||
## Moving from /api and passwords to /v1 and IAM
|
||||
|
||||
A current Base opens an older data directory in place and migrates it when it
|
||||
starts. Data written by `v0.36.7-hanzo.1` comes through with its collections,
|
||||
rules and records.
|
||||
|
||||
**Paths.** Routes move from `/api/...` to `/v1/...`, and the old paths answer
|
||||
`404`. The JavaScript client adds the prefix itself, so give it the origin:
|
||||
|
||||
```ts
|
||||
import { BaseClient } from '@hanzo/base'
|
||||
|
||||
const base = new BaseClient('http://127.0.0.1:8090')
|
||||
const page = await base.collection('notes').getList(1, 20)
|
||||
```
|
||||
|
||||
Give it `http://127.0.0.1:8090/v1` instead and every call goes to
|
||||
`/v1/v1/...` and fails with `404`. If Base is mounted under a longer prefix, set
|
||||
the same value in both places: `BASE_API_PREFIX` on the server and `prefix` on
|
||||
the client.
|
||||
|
||||
**Sign-in.** Password, OTP and OAuth2 sign-in are gone, and so is
|
||||
`base superuser`. On first start the migrations drop the `password` column of
|
||||
`_superusers` and the `_mfas`, `_otps`, `_externalAuths` and `_authOrigins`
|
||||
tables. People sign in at Hanzo IAM and send its access token, and superusers
|
||||
are the members of IAM's `admin` org. Set `IAM_ENDPOINT`; [rules.md](rules.md)
|
||||
covers the rest.
|
||||
|
||||
**Tokens from before the upgrade.** Until `IAM_ENDPOINT` is set, Base still
|
||||
accepts tokens it signed itself. A superuser token from the old version keeps
|
||||
superuser access, and `POST /v1/collections/_superusers/auth-refresh` trades it
|
||||
for a fresh one, so it never has to expire. Setting `IAM_ENDPOINT` ends that:
|
||||
the same token then gets `401`. Without IAM, rotate the signing secrets, using
|
||||
that token one last time:
|
||||
|
||||
```sh
|
||||
curl -X POST http://127.0.0.1:8090/v1/collections/_superusers/rotate \
|
||||
-H "Authorization: $OLD_TOKEN"
|
||||
```
|
||||
|
||||
Every `_superusers` token signed before the rotation, `$OLD_TOKEN` included,
|
||||
then gets `401`. Rotate `users` the same way if it held accounts.
|
||||
|
||||
**Ids in rules.** Rules carry over as written, but `@request.auth.id` now comes
|
||||
from the IAM token (see [rules.md](rules.md#where-identity-comes-from)). A rule
|
||||
such as `owner = @request.auth.id` matches only records whose `owner` holds the
|
||||
id derived from that token. Old `users` rows stay, and nobody signs in as them.
|
||||
|
||||
**Hooks and migrations.** They load the same way. A hook file that calls a
|
||||
removed sign-in hook, such as `onRecordAuthWithPasswordRequest`, fails to load,
|
||||
and the log says so on the first start.
|
||||
|
||||
## Upgrading safely
|
||||
|
||||
Copy the data directory, run the new binary against the copy with `--dir`, and
|
||||
check your collections, rules and hooks before you switch. `base update` cannot
|
||||
do this for you while releases carry no binaries.
|
||||
@@ -0,0 +1,65 @@
|
||||
# Hybrid search with hooks
|
||||
|
||||
Search a collection by words and by meaning in one request. SQLite FTS5 ranks
|
||||
the words with BM25, cosine similarity ranks stored embeddings against a query
|
||||
vector, and reciprocal rank fusion merges the two rankings. Every result then
|
||||
passes the collection's list rule.
|
||||
|
||||
| File | What it does |
|
||||
|---|---|
|
||||
| `migrations/1_docs.js` | creates the `docs` collection, an FTS5 table over its title and body, and the triggers that keep that table current |
|
||||
| `migrations/2_sample_docs.js` | adds four documents with three-number embeddings |
|
||||
| `hooks/search.base.js` | registers `POST /v1/docs/search` |
|
||||
| `hooks/search.js` | ranks, fuses, and checks each result against the list rule |
|
||||
|
||||
## Run it
|
||||
|
||||
Build the binary as the [README](../../README.md#run-it) shows. Then, from
|
||||
`examples/base`:
|
||||
|
||||
```sh
|
||||
./base serve --http 127.0.0.1:8090 --dir "$(mktemp -d)" \
|
||||
--hooksDir ../hooks-hybrid-search/hooks \
|
||||
--migrationsDir ../hooks-hybrid-search/migrations
|
||||
```
|
||||
|
||||
In another terminal:
|
||||
|
||||
```sh
|
||||
curl -s -X POST http://127.0.0.1:8090/v1/docs/search \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"q": "rank", "vector": [0.1, 0.9, 0]}'
|
||||
```
|
||||
|
||||
```json
|
||||
{"items":[{"id":"5iy49tdy89q0knu","score":0.03252247488101534,"title":"Reciprocal rank fusion"},{"id":"ahov6ixwj26jsd8","score":0.01639344262295082,"title":"Vector similarity"},{"id":"mehis4xhns53hh6","score":0.015873015873015872,"title":"SQLite full-text search"},{"id":"zl12ppvcbbtofhv","score":0.015625,"title":"Access rules"}]}
|
||||
```
|
||||
|
||||
"Reciprocal rank fusion" comes first because it is in both rankings: it is the
|
||||
only document containing the word `rank`, and its embedding is the second
|
||||
closest to the vector. Send only `q` to search by words, only `vector` to search
|
||||
by meaning, and `limit` to cap the results (default 10, at most 50).
|
||||
|
||||
## How it works
|
||||
|
||||
- The triggers write to `docs_fts` in the same transaction as the write to
|
||||
`docs`, whether the write came through the API, a hook, a migration or plain
|
||||
SQL.
|
||||
- The query is split into words and each word is quoted, so FTS5 reads
|
||||
punctuation as text rather than as query syntax. Any word may match.
|
||||
- Each ranking gives its top 100 documents `1 / (60 + rank)`, and a document's
|
||||
scores add up.
|
||||
- A route added in a hook applies no rules, so `search.js` calls
|
||||
`canAccessRecord` with the collection's `listRule` for each result. Set that
|
||||
rule to `@request.auth.id != ""` and an anonymous search returns no items.
|
||||
|
||||
## Limits
|
||||
|
||||
- Cosine similarity reads every stored embedding on every query. That suits
|
||||
thousands of documents, not millions.
|
||||
- `docs_fts` is matched to `docs` by `id`, so updating or deleting a document
|
||||
scans `docs_fts`. The same scale applies.
|
||||
- This example computes no embeddings. Store each document's vector from your
|
||||
model in `embedding`, and send the query's vector, of the same length, as
|
||||
`vector`.
|
||||
- If you change the fields of `docs`, change the triggers in a new migration.
|
||||
@@ -0,0 +1,8 @@
|
||||
// POST /v1/docs/search {"q": "words", "vector": [0.1, 0.9, 0], "limit": 10}
|
||||
//
|
||||
// The handler is compiled on its own, so nothing declared at the top of this
|
||||
// file is in scope inside it. The code lives in search.js and is required
|
||||
// where it runs.
|
||||
routerAdd("POST", "/v1/docs/search", (e) => {
|
||||
return require(`${__hooks}/search.js`).search(e)
|
||||
})
|
||||
@@ -0,0 +1,109 @@
|
||||
// Hybrid search over the docs collection.
|
||||
//
|
||||
// Two ranked lists: FTS5 matches ordered by BM25, and stored embeddings ordered
|
||||
// by cosine similarity to the query vector. Reciprocal rank fusion merges them:
|
||||
// a document scores the sum of 1 / (K + rank) over the lists it appears in.
|
||||
// Every result then passes the collection's list rule, so the route returns
|
||||
// what the caller could list from /v1/collections/docs/records.
|
||||
|
||||
const K = 60 // the RRF constant from Cormack, Clarke and Buettcher (2009)
|
||||
const DEPTH = 100 // candidates taken from each list
|
||||
|
||||
// match quotes every word, so punctuation in the query is text to find rather
|
||||
// than FTS5 syntax, and joins the words with OR, so any one can match.
|
||||
function match(text) {
|
||||
return String(text || "")
|
||||
.split(/\s+/)
|
||||
.filter((word) => word)
|
||||
.map((word) => '"' + word.replace(/"/g, '""') + '"')
|
||||
.join(" OR ")
|
||||
}
|
||||
|
||||
function lexical(app, text) {
|
||||
const query = match(text)
|
||||
if (!query) {
|
||||
return []
|
||||
}
|
||||
const rows = arrayOf(new DynamicModel({ id: "" }))
|
||||
app.db()
|
||||
.newQuery("SELECT id FROM docs_fts WHERE docs_fts MATCH {:query} ORDER BY bm25(docs_fts) LIMIT {:depth}")
|
||||
.bind({ query: query, depth: DEPTH })
|
||||
.all(rows)
|
||||
return rows.map((row) => row.id)
|
||||
}
|
||||
|
||||
function cosine(a, b) {
|
||||
let dot = 0, aa = 0, bb = 0
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
dot += a[i] * b[i]
|
||||
aa += a[i] * a[i]
|
||||
bb += b[i] * b[i]
|
||||
}
|
||||
return aa && bb ? dot / Math.sqrt(aa * bb) : 0
|
||||
}
|
||||
|
||||
// semantic scores every stored embedding against the query vector. That is a
|
||||
// full scan, which is fine for thousands of documents and not for millions.
|
||||
function semantic(app, vector) {
|
||||
if (vector.length === 0) {
|
||||
return []
|
||||
}
|
||||
const rows = arrayOf(new DynamicModel({ id: "", embedding: "" }))
|
||||
app.db().newQuery("SELECT id, embedding FROM docs").all(rows)
|
||||
const scored = []
|
||||
for (const row of rows) {
|
||||
let embedding
|
||||
try {
|
||||
embedding = JSON.parse(row.embedding)
|
||||
} catch (_) {
|
||||
continue
|
||||
}
|
||||
if (Array.isArray(embedding) && embedding.length === vector.length) {
|
||||
scored.push({ id: row.id, score: cosine(vector, embedding) })
|
||||
}
|
||||
}
|
||||
return scored
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, DEPTH)
|
||||
.map((s) => s.id)
|
||||
}
|
||||
|
||||
function search(e) {
|
||||
const info = e.requestInfo()
|
||||
const body = info.body
|
||||
const docs = e.app.findCollectionByNameOrId("docs")
|
||||
|
||||
const vector = []
|
||||
for (let i = 0; body.vector && i < body.vector.length; i++) {
|
||||
vector.push(Number(body.vector[i]))
|
||||
}
|
||||
|
||||
const scores = {}
|
||||
for (const list of [lexical(e.app, body.q), semantic(e.app, vector)]) {
|
||||
list.forEach((id, rank) => {
|
||||
scores[id] = (scores[id] || 0) + 1 / (K + rank + 1)
|
||||
})
|
||||
}
|
||||
|
||||
const limit = Math.min(Math.max(parseInt(body.limit, 10) || 10, 1), 50)
|
||||
const items = []
|
||||
for (const id of Object.keys(scores).sort((a, b) => scores[b] - scores[a])) {
|
||||
let record
|
||||
try {
|
||||
record = e.app.findRecordById(docs, id)
|
||||
} catch (_) {
|
||||
continue // deleted since it was ranked
|
||||
}
|
||||
if (!e.app.canAccessRecord(record, info, docs.listRule)) {
|
||||
continue
|
||||
}
|
||||
items.push({ id: id, title: record.getString("title"), score: scores[id] })
|
||||
if (items.length === limit) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return e.json(200, { items: items })
|
||||
}
|
||||
|
||||
module.exports = { search }
|
||||
@@ -0,0 +1,31 @@
|
||||
// The docs collection, and an FTS5 index over its title and body.
|
||||
//
|
||||
// Three triggers keep the index in step with docs. They run inside the write's
|
||||
// own transaction, whichever way the write arrived: the API, a hook, a
|
||||
// migration or plain SQL.
|
||||
migrate((app) => {
|
||||
app.save(new Collection({
|
||||
type: "base",
|
||||
name: "docs",
|
||||
listRule: "",
|
||||
viewRule: "",
|
||||
fields: [
|
||||
{ name: "title", type: "text", required: true },
|
||||
{ name: "body", type: "text" },
|
||||
{ name: "embedding", type: "json" },
|
||||
],
|
||||
}))
|
||||
|
||||
const statements = [
|
||||
"CREATE VIRTUAL TABLE docs_fts USING fts5(id UNINDEXED, title, body)",
|
||||
"CREATE TRIGGER docs_fts_insert AFTER INSERT ON docs BEGIN INSERT INTO docs_fts (id, title, body) VALUES (new.id, new.title, new.body); END",
|
||||
"CREATE TRIGGER docs_fts_update AFTER UPDATE ON docs BEGIN UPDATE docs_fts SET title = new.title, body = new.body WHERE id = old.id; END",
|
||||
"CREATE TRIGGER docs_fts_delete AFTER DELETE ON docs BEGIN DELETE FROM docs_fts WHERE id = old.id; END",
|
||||
]
|
||||
for (const sql of statements) {
|
||||
app.db().newQuery(sql).execute()
|
||||
}
|
||||
}, (app) => {
|
||||
app.db().newQuery("DROP TABLE IF EXISTS docs_fts").execute()
|
||||
app.delete(app.findCollectionByNameOrId("docs"))
|
||||
})
|
||||
@@ -0,0 +1,15 @@
|
||||
// Four sample documents with three-number embeddings, so the example runs
|
||||
// without an embedding model. Real embeddings come from your model and are
|
||||
// written to the embedding field like any other value.
|
||||
migrate((app) => {
|
||||
const docs = app.findCollectionByNameOrId("docs")
|
||||
const samples = [
|
||||
["SQLite full-text search", "FTS5 ranks matching rows with BM25.", [0.9, 0.1, 0.0]],
|
||||
["Vector similarity", "Cosine similarity compares embeddings by angle.", [0.1, 0.9, 0.0]],
|
||||
["Reciprocal rank fusion", "RRF merges ranked lists without tuning weights.", [0.5, 0.5, 0.1]],
|
||||
["Access rules", "A list rule decides which records a caller may see.", [0.0, 0.2, 0.9]],
|
||||
]
|
||||
for (const [title, body, embedding] of samples) {
|
||||
app.save(new Record(docs, { title, body, embedding }))
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user