Authorization Center
Self-service accounts and SMTP
Pass --auth-config /etc/memories-center/auth.toml (or set
MEMORIES_CENTER_AUTH_CONFIG) in hosted mode. See
deploy/center/auth.example.toml.
Without an auth config, Center retains its token-only behavior. With a config,
account login is enabled; registration defaults to disabled and email mode.
registration_enabled = true
registration_mode = "email" # or "username"
max_accounts = 10000
[smtp]
host = "smtp.example.com"
port = 587
tls = "starttls" # required STARTTLS; "tls" for implicit TLS, commonly port 465
from = "Memories <noreply@example.com>"
username = "smtp-user"
password_env = "MEMORIES_CENTER_SMTP_PASSWORD"
SMTP passwords are read from the named environment variable. Keep that variable
in the protected service environment, never in arguments, URLs, source control,
or browser configuration. A relay without authentication may omit username;
certificate-verified TLS remains mandatory. Invalid configuration prevents
startup. Missing SMTP disables email registration; transport failure returns 503
and creates no account. The public config response never includes SMTP settings.
Set registration_enabled = false to close new registrations without disabling
existing accounts. Set registration_mode = "username" for username/password
signup without email; omit [smtp] if unused. Restart after changing settings.
Existing accounts of either kind continue to log in after a mode change.
Public endpoints (JSON, maximum request body 4 KiB):
| Endpoint | Body / behavior |
|---|---|
GET /v1/auth/config |
Login availability, registration availability/mode, password minimum, session duration |
POST /v1/auth/register |
Email mode: {identity,locale?} sends a code. Username mode: {identity,password} creates account |
POST /v1/auth/verify |
{identity,code,password} proves mailbox ownership and creates account |
POST /v1/auth/login |
{identity,password} returns a one-hour scoped bearer grant |
POST /v1/auth/logout |
Bearer authorization; revokes that expiring grant and descendants |
Email addresses and usernames are trimmed and ASCII case-folded. Email signup accepts ASCII mailbox addresses only. Usernames are 3-64 ASCII letters, digits, hyphens or underscores. Passwords are 12-256 UTF-8 bytes and stored as salted Argon2id PHC records (64 MiB, 3 passes, parallelism 1). Hash work runs outside the vault lock with at most two concurrent workers. Users cannot select a vault, scope, or role at registration: each receives a random independent hosted vault.
Verification codes contain 256 random bits, expire after 15 minutes and are
single-use. Only hashes are held in server memory; a restart invalidates pending
codes. Duplicate sends within that window return the same generic acceptance
without another email; retry after expiry. Passwords are chosen at verification,
not when requesting the email. Pending registrations are capped at 1024.
The optional locale selects the email subject and body: en, zh-CN, zh-TW,
ja, fr, de, or es. Regional variants use their base language; zh-Hant,
zh-HK and zh-MO select Traditional Chinese, while explicit zh-Hans selects
Simplified Chinese. Missing or unsupported locales fall back to English for
existing API clients. Locale is per request and is not stored in the account.
Changing language during an outstanding challenge does not send another email
or bypass its rate limit. It does not change identity normalization or verification.
Auth attempts have a global 60/minute ceiling and per-identity limits of 3
registration requests or 10 login/verification attempts per 15 minutes. These
process-local limits reset on restart and can cause denial of service under
attack; use an upstream abuse-control service for higher-volume deployments.
They do not trust client-supplied IP headers.
Existing durable member tokens remain supported. The members.json format now
has optional account credentials: protect its backups as password databases.
Old Center builds cannot read account records; rollback after registration
requires a compatible binary or a matching pre-upgrade data backup (which would
discard later data). Password reset, mailbox changes and email recovery are not
implemented. Admin member deletion disables login and invalidates sessions.
A Center is an external authorization control plane that distributes enrollment and capability tickets to Memories endpoints (CLI, desktop daemon, mobile, and other clients that share configuration).
Hosted Centers also support Memories Web, a browser client for
memories, cards and files. New builds serve it at /app/. To connect from the
public website, explicitly allow --web-origin https://memories.ivjn.us (or
MEMORIES_CENTER_WEB_ORIGINS). Same-origin use requires no CORS setting; existing
member/grant authorization still applies. See the guide for setup and limits.
Rules
- Endpoints may configure one or more Centers in
config.toml([[centers]]). - If any Center is configured, the endpoint is Center-managed:
- Local self-bootstrap is forbidden (
memories init/restore). - Device creation uses a Center-signed bootstrap ticket (memories center enroll). - Local MCP grant minting is forbidden; import Center-signed grant tickets instead. - Device vault crypto unlock remains local (device password). A ticket-only Center never receives device vault keys or plaintext secrets. Optional hosted mode instead owns a separate, operator-keyed server vault (see below). An optional signed unlock ticket authorizes a local password unlock; it carries neither the password nor device keys.
- Multiple Centers may be listed; a ticket must verify against the Center
idandpublic_keythat issued it. - Listen tokens, Center signing secrets, and admin tokens are never stored in TOML.
Grant scopes
MCP grant tickets carry an explicit scope (permission level). Wire / config identifiers are snake_case; CLI and HTTP also accept human-friendly aliases.
| Scope | Aliases (examples) | Allows |
|---|---|---|
allow_all |
allow all, all, full |
Full set of ops this grant surface can authorize (today: read + create/update + delete of ordinary assets; reserved for future elevated Center-gated capabilities on the same surface) |
read |
r |
List / get / search / retrieve / RAG read / ordinary graph — no mutations |
write |
w |
Create/update ordinary memory and cards only (not read, not delete) |
read_write |
read+write, rw |
Read + create/update |
read_write_delete |
read+write+delete, rwd |
Read + create/update + delete ordinary memory/cards |
Notes:
writeis intentionally write-only so Centers can mint narrowly scoped agents. Preferread_writewhen the agent also needs to search or read.- Delete covers ordinary (non-private, memory/card) deletes enforced for MCP callers; private/credential deletes still require local elevation and remain outside MCP grants.
- Legacy tickets that only have the old
write: boolfield (noscope):write: false→read,write: true→read_write. - New tickets without an explicit scope default to
readat issuance (CLI /memories-centerHTTP). - These ordinary-asset/elevation restrictions describe daemon MCP, not hosted
vault RPC. Hosted grants cover private cards, attachments and protected profiles
too; their exact authority is described below. Daemon MCP is not elevated by this
feature, even when its scope is
allow_all.
Local ticket import now checks Admin/Owner membership and the role/scope ceiling, not just write access, and does not require terminal elevation. See vault authorization for local delegation, request, access, and audit behavior and the staged Center/CLI/MCP rollout.
Threat model fit
| Concern | Behavior |
|---|---|
| Device enrollment | Center decides which devices may initialize a store |
| MCP / agent grants | Center issues time-bounded grant tickets with scopes; endpoints import them |
| Device vault confidentiality | Still Argon2 + local store encryption; ticket-only Center is policy, not key escrow |
| Hosted vault confidentiality | Server-side encrypted Store; host operators hold the password and can read all hosted plaintext; not end-to-end encryption |
| Compromised Center signing key | Attacker can enroll devices / issue grants for that Center id — rotate key and remove stale public_key from endpoints |
| Compromised Center admin | Can issue time-bounded device unlock tickets, lock the hosted Store and mint/update/remove hosted members; cannot decrypt a ticket-only device Store without its local password |
| Compromised endpoint | Holds vault material after unlock; Center cannot revoke ciphertext already on disk (same as today) |
Endpoint configuration
[[centers]]
id = "corp"
url = "https://center.example.com"
public_key = "…" # from `memories center public-key` or memories-center /v1/public-key
mode = "ticket" # default: enrollment/grants/unlock tickets only
[[centers]]
id = "team"
url = "http://127.0.0.1:9191" # loopback http allowed for local Center
public_key = "…"
Non-loopback Center URLs must be https://.
SaaS attachment/direct CLI URLs must be origins only, without userinfo, paths,
queries or fragments; ticket-mode configuration retains existing URL compatibility.
SaaS client attachment
Set exactly one configured Center to mode = "saas" to use its hosted content
backend instead of a parallel local vault:
[[centers]]
id = "corp"
url = "https://center.example.com"
public_key = "…"
mode = "saas"
Inject MEMORIES_CENTER_TOKEN="…" into the daemon environment: this is a
hosted member, static grant or time-bounded access bearer, never the admin token.
Start memories daemon using that config. Daemon content requests are sent to
that Center's /v1/rpc using the core TLS/HTTP client, outside the local writer
lock. Unsupported hosted operations fail closed; they do not fall back to local
storage. Use the bound vault name (for example default) and canonical asset IDs
returned by hosted RPC. Local status/Center listing remains local. Init, restore,
local MCP minting and local enrollment remain forbidden; an old local database is
not the content backend for this attachment. Content and permissions belong to
the hosted Center's grants/members. Other ticket-only Centers may coexist.
The configured daemon URL is authoritative (no environment URL override). The daemon bearer is shared by authenticated daemon clients; protect IPC access and any daemon listen/relay token as delegation of that member's authority. Local MCP capabilities are not forwarded with the daemon bearer; use a separately scoped hosted member with a direct HTTP/MCP adapter instead. No Center admin MCP tools are added.
For direct CLI HTTP calls, URL selection is --center-url, then
MEMORIES_CENTER_URL, then the configured SaaS Center. Admin calls require
MEMORIES_CENTER_ADMIN_TOKEN; content RPC requires MEMORIES_CENTER_TOKEN.
None of these bearer tokens belongs in TOML. TLS settings reuse --ca-file /
--insecure; do not disable TLS verification in production. Redirects are not
followed. The direct helper does not require a running daemon:
export MEMORIES_CENTER_URL="https://center.example.com"
export MEMORIES_CENTER_TOKEN="…"
# request.json contains an ordinary hosted Request, e.g. {"op":"list","vault":"default"}
memories center rpc --request request.json
CLI
# Admin: derive public key for config.toml
export MEMORIES_CENTER_SECRET="…" # 32-byte URL-safe base64 or 64 hex
memories center public-key
# Admin: issue tickets (or use memories-center HTTP service)
memories center issue-bootstrap --center corp --device-name laptop
memories center issue-grant --center corp --vault VAULT_ID --minutes 15
memories center issue-grant --center corp --vault VAULT_ID --scope read --minutes 15
memories center issue-grant --center corp --vault VAULT_ID --scope write --minutes 15
memories center issue-grant --center corp --vault VAULT_ID --scope read_write --minutes 15
memories center issue-grant --center corp --vault VAULT_ID --scope 'read+write+delete' --minutes 15
memories center issue-grant --center corp --vault VAULT_ID --scope allow_all --minutes 15
# Legacy: --write alone ⇒ read_write (when --scope omitted)
# Endpoint (daemon already running with centers in config)
memories center list
memories center enroll --ticket "$BOOTSTRAP_TICKET"
memories sudo
memories center import-grant --ticket "$GRANT_TICKET"
Local (non-Center) MCP grants accept the same scopes:
memories mcp grant VAULT_ID ./grant.token --scope read --minutes 15
memories mcp grant VAULT_ID ./grant.token --scope read_write_delete --minutes 15
# Legacy: --write ⇒ read_write
Center HTTP service (memories-center)
export MEMORIES_CENTER_SECRET="…"
export MEMORIES_CENTER_ADMIN_TOKEN="…"
memories-center --listen http://127.0.0.1:9191 --center-id corp
The Center serves plain HTTP over TCP. --listen https://HOST:PORT only selects
the bind address; it does not enable native TLS. For public exposure, bind the
Center to loopback or a protected internal address behind a TLS reverse proxy,
and configure clients with the proxy's public https:// URL. Protect the admin
token and signing secret; do not expose the plain HTTP listener directly.
GET /healthGET /v1/public-keyPOST /v1/tickets/bootstrap(Bearer admin token)POST /v1/tickets/mcp-grant(Bearer admin token)POST /v1/tickets/unlock(Bearer admin token;{"ttl":300}, 1–3600 seconds)
Grant body (JSON):
{
"vault": "…",
"scope": "read_write",
"minutes": 30,
"ttl": 3600
}
scope accepts the same wire names and aliases as the CLI. When scope is
omitted, write: true maps to read_write, otherwise the default is
read. Static hosted grants may omit scope (TOKEN or TOKEN::VAULT), and
member creation defaults to read when scope is omitted. Request allow_all
explicitly rather than relying on the former permissive default.
Full hosted vault (SaaS)
Enable the hosted vault with --data DIR (or MEMORIES_CENTER_DATA). Supply a
nonempty MEMORIES_CENTER_VAULT_PASSWORD through your service manager's protected
environment/secret injection. There is deliberately no password command-line flag.
The password is required only with --data; losing it loses access to the Store.
Use a strong, independently generated password, not a bearer token or signing key.
# Inject MEMORIES_CENTER_SECRET, MEMORIES_CENTER_ADMIN_TOKEN,
# MEMORIES_CENTER_VAULT_PASSWORD and MEMORIES_CENTER_GRANTS securely first.
memories-center --listen http://127.0.0.1:9191 --center-id corp --data ./center-data
From a checkout, prepend cargo run -p memories-center -- to the options.
Without --data, the process remains a backward-compatible ticket-only Center.
Tickets and enrollment continue to work in either mode.
Hosted mode reuses memories_core::service::Service, Request, Content, and the
daemon's encrypted SQLite Store. It can replace Center + daemon for hosted
vault content, including:
- Full memory/card fields: bodies, tags, sources, aliases, notes, categories, private flag, links, allowed hosts and secret references.
- Brain, persona, workflow and workflow-template cards; protected account, env,
token, login, TOTP/HOTP and
otpauthprofiles. - Signed revision DAGs, history, diffs, historical reads and reapplication; same-vault card links and graphs.
- Persona world snapshots, rollover, archive cards, things/attributes, chat conversations/read cursors, automation hooks, allowlists and triggers.
- Encrypted file and credential attachments, protected-secret replacement, explicit credential delivery and OTP generation.
Storage and migration
DIR/
store/
memories.db # Same encrypted Store format as the daemon
memories.lock # Exclusive process lock
memories.json.bak # Only if imported from the old notes library
SQLite may also create its normal transactional journal files. Store saves are
transactional; vault identity, signed revisions and audit state persist together.
One Center process owns the Store at a time. The default vault and named vaults
used by configured or issued grants are created as needed. Grant bindings use
canonical vault IDs internally and cannot enumerate/access other vaults.
Unix directories are owner-only (0700); Store files and the legacy backup are
owner-only (0600). Protect the directory and service account on every platform.
If DIR/memories.json exists, startup imports its ordinary notes, preserving IDs
and vault names, in one atomic content transaction, then renames the input to
memories.json.bak. Invalid records fail startup without discarding the source
or publishing a partial note batch. A retry after an interrupted rename skips
identical imported records; conflicting records or an already-existing backup
fail closed for operator review instead of overwriting data. The legacy backup
is still plaintext: securely remove or independently encrypt it after verifying
the import and arranging a backup. Newly written content is never saved as JSON.
Encryption and threat model
The Store is unlocked at process start with the operator-held password (Argon2 password wrapping and the same authenticated encryption as local Store). On each authorized content operation, hosted dispatch refreshes the server session before idle-expiry checks; it does not idle-lock after fifteen quiet minutes. Shutdown and admin lock release in-memory Store keys and cached content. Admin lock/unlock lives on separate admin HTTP routes, never the grant content RPC. There are no hosted sudo or password-change RPCs.
This is server-side encryption, not end-to-end client encryption. Operators
controlling the host/process/password can read every hosted vault, including
private content and credentials. Anyone holding both the password and a disk
copy can decrypt the Store. Host memory, crash dumps, environment inspection,
malicious authorized clients and compromised TLS termination are outside this
confidentiality boundary. private still controls safe summaries/retrieval, but
is not a barrier against a hosted grant authorized to read that vault.
The daemon instead uses a device password and may use local OS-sealed unattended keys. Hosted mode has no sealed device keys or per-device password UX. Device vault keys are not uploaded or shared with this hosted Store automatically. Use the TLS reverse proxy described above; HTTP requests and authorized secret delivery are plaintext to the Center and TLS terminator. Do not log bearer tokens, RPC bodies, credential responses or passwords.
Remote lock/unlock
Hosted routes require the admin bearer and exist only with --data:
| Route | Behavior |
|---|---|
GET /v1/admin/status |
Returns unlocked, hosted mode, member count and restart_required, without secrets |
POST /v1/admin/lock |
Drops Store keys/state, clears caches and suspends grants until unlock |
POST /v1/admin/unlock |
Accepts {"password":"…"} and reopens the same existing Store with the operator password |
While locked, content routes and hosted access-token minting return 503;
no grant can serve plaintext. Admin status, member listing/removal and device
ticket issuance remain available. Member creation/scope/vault update requires an
unlocked Store. Wrong hosted passwords return 403; failed authentication uses
core exponential backoff (subsequent attempts during backoff return 429). A
successful unlock restores static/member access, not revoked minted tokens.
Requests already completed before lock cannot have their returned plaintext
retracted. Startup still requires MEMORIES_CENTER_VAULT_PASSWORD; remote unlock
does not persist the supplied password or change startup configuration.
export MEMORIES_CENTER_ADMIN_TOKEN="…"
memories center status --center-url https://center.example.com
memories center lock --center-url https://center.example.com
memories center unlock --center-url https://center.example.com # password prompt
Device unlock is different: POST /v1/tickets/unlock is available on both
ticket-only and hosted Centers. The admin signs a short-lived unlock ticket;
the local daemon verifies its kind, signature, configured Center and expiry,
then unlocks with the operator's locally prompted device password. It grants
no sudo/MCP elevation. memories center unlock --ticket always uses local,
OS-authenticated IPC, not the selected remote daemon or the Center HTTP API.
There is no automatic device-password delivery, escrow or push channel.
# Admin: save the returned ticket securely; do not put it in logs.
memories center issue-unlock --center-url https://center.example.com --ttl 300
# On the device (daemon running with the same --home/config):
memories center unlock --ticket "…" # local device password prompt
Unlock tickets are reusable until expiry and authorize unlock on endpoints trusting that Center, subject to each endpoint's password; they are not single-use or device-bound. Existing local password unlock remains available on ticket-only managed devices. SaaS attachments cannot use a ticket to enroll or unlock a parallel local content Store.
Password custody: the hosted operator holds the hosted Store password and sends it to the trusted Center/TLS terminator only for hosted unlock. Device passwords and vault keys stay on the device/local daemon in ticket mode. A compromised Center can issue unlock tickets but still needs a device password to unlock its ciphertext. A compromised hosted admin can lock the Store and mint members with plaintext authority whenever it is unlocked. Hosted operators already control the process and can read all unlocked plaintext; admin/member separation is not encryption against the operator.
Durable members
Hosted members are named, durable bearer principals with the same five
GrantScope tiers, not identity-provider accounts. An optional role is
reader, editor, admin, or owner; omitted roles retain the legacy mapping
from read, write/read_write, read_write_delete, and allow_all, respectively.
An explicit role caps the member scope (Reader: read, Editor: read_write,
Admin: read_write_delete, Owner: allow_all). Member CRUD routes still require
the operator admin bearer, not a member's Admin/Owner role:
| Route | Body / response |
|---|---|
GET /v1/admin/members |
members array of id, vault, scope, role, created and updated; no bearer tokens |
POST /v1/admin/members |
{"id":"agent","vault":"default","scope":"read"}; metadata plus generated token, shown only in this response |
PUT /v1/admin/members/{id} |
Optional scope, vault, and/or role; same bearer, metadata-only response |
DELETE /v1/admin/members/{id} |
Removes member and immediately invalidates bearer |
IDs are stable 1–64 character names (ASCII letters/digits, -, _); center-admin
is reserved for the operator Owner. Duplicate
IDs return 409. Tokens are random, unique and distinct from the admin/static
tokens. Use a protected terminal/output sink for member creation; the CLI prints
the returned bearer once by design. Never capture creation responses in logs.
Members authenticate through the same grant table on /v1/memories and /v1/rpc;
scope/vault changes apply to subsequent requests without restarting. Requests
authenticated before an intervening membership mutation are rejected before
content dispatch. Removing/recreating a member is the token rotation procedure.
DIR/members.json is an atomically replaced, owner-only (0600 on Unix) durable
file under the owner-only hosted directory. It contains the bearer tokens in
plaintext, but no passwords, keys or vault content; protect it like credentials,
include it in protected backups, and do not commit it. Startup reloads/validates
members and fails closed on corrupt or colliding bearers. A failed durable member
save locks content and sets restart_required: remote unlock returns 503 until
the operator repairs the filesystem and restarts to reload durable authorization.
This prevents uncertain persistence results from resurrecting revoked bearers.
memories center member list --center-url https://center.example.com
memories center member add --center-url https://center.example.com --id agent --vault default --scope read
memories center member set --center-url https://center.example.com --id agent --scope read_write
memories center member remove --center-url https://center.example.com --id agent
This is still a single operator-keyed Store with logical per-vault grant binding, not hard multi-tenant isolation, OIDC/SSO, user passwords, refresh tokens, quotas or self-service recovery.
Grants and API
Static grants use repeatable --grant TOKEN[:SCOPE[:VAULT]] and/or comma-separated
MEMORIES_CENTER_GRANTS; an omitted scope means read, and an omitted vault
means default (TOKEN::VAULT chooses a vault with the default scope). Their lifetime is the
process lifetime. Admin-authenticated POST /v1/tickets/mcp-grant returns the
unchanged signed ticket plus a hosted access_token bound to its vault/scope.
That access token expires after minutes (1–60) and survives hosted restart.
The ticket's ttl separately governs ticket import validity. Hosted minted grants
are persisted atomically in owner-only (0600) DIR/grants.json: only SHA-256
bearer hashes and authorization metadata, never plaintext minted tokens. Bearer
hashes are compared in constant time. Revocations persist, and member removal
still cascades through restored grant ancestry after restart. Expired grants
and their descendants are purged on load, every minute and during
authentication/issuance. Treat this owner-controlled authorization file and its
backups as security-sensitive; rolling back it or the Store can restore old state.
Persistence failure locks the service and requires repair/restart.
Locking the hosted Store drops its keys and makes grants unusable, without
revoking them; unexpired minted grants, static grants and durable members resume
after unlock. Each durable member has a persisted generation counter. Issuer and
recipient generations are frozen at issuance and never refreshed by
authentication; the public generation field is the recipient's issuance
generation. A scope/role/vault update or removal bumps only that member's generation;
removed-member counters are retained so recreating an ID does not reset it.
Removal/update explicitly revokes grants issued by or to that member and all
descendants transitively; unrelated grants remain usable, including after
restart. Static/durable credentials are re-established at the explicit policy
transition, not at authentication. Static principal and durable-member parent
IDs are stable across restarts so restored delegation chains remain connected.
The admin bearer alone receives 401 on /v1/memories and /v1/rpc, even for
reads. It cannot directly mutate vault content. Administrators can still mint
separate grants through the ticket or authorization API; that administrative capability is not
a confidentiality barrier against the administrator.
Authorization HTTP API
These routes exist only in hosted mode. They authenticate either the operator admin bearer (an Owner across all vaults) or a hosted principal's bearer. The operator controls server lock/unlock/member CRUD; a hosted Admin/Owner may manage authorization only within the bearer vault and scope. No terminal elevation is needed. Readers and Editors cannot issue or delegate, even when granted broader content access. Role/scope/vault/expiry violations return 403 with a clear authority error; handler validation failures return 400, missing objects 404, repeat decisions 409, and expired/revoked/stale-generation bearers 401. Malformed or mistyped JSON can be rejected by Axum's JSON extractor with 400 or 422.
| Route | Fields and response |
|---|---|
GET /v1/access/{vault} |
Admin/Owner; vault, principals grouped under owner, admin, editor, reader, active grants including expiry, plus pending_requests and approved_unclaimed_requests (id, requester, scope, approvers list or "broadcast", Unix-seconds created and expires) |
POST /v1/grants |
Admin/Owner; vault, optional scope (default read), duration_minutes (default 30, alias minutes), grantee; grant metadata plus one-time token |
GET /v1/grants |
Optional vault; Admin/Owner see all issued grants in the vault, other current principals see only their own issued grants; grants array, excluding static/durable credentials and expired/stale grants |
DELETE /v1/grants/{id} |
Issuer or Admin/Owner within the vault; revokes minted grant and descendants, returns revoked and count |
POST /v1/requests |
Any member; vault, optional scope (default read), reason, duration_minutes (default 30, alias duration), approvers; pending request metadata |
GET /v1/requests |
Optional vault; pending requests belonging to requester or eligible approver, plus requester's approved-unclaimed requests; no tokens |
POST /v1/requests/{id}/approve |
Eligible Admin/Owner; no body; approved-unclaimed request metadata and validated bounds only, no grant or token |
POST /v1/requests/{id}/claim |
Requester's durable member bearer only; no body; request plus newly minted grant with one-time token |
POST /v1/requests/{id}/reject |
Eligible Admin/Owner; optional JSON reason; request with rejected status |
POST /v1/requests/{id}/cancel |
Requester only; no body; request with cancelled status |
GET /v1/audit |
Admin/Owner; optional vault, format=json|jsonl (default json); verified entries/verified, or newline-delimited export |
All issuance/request durations are 1–60 minutes. At most 10,000 grants may be
active at once (memories_core::service::MAX_ACTIVE_GRANTS); the local daemon
uses the same cap. Issuers cannot exceed their role
ceiling or bearer scope, leave their vault, or extend their parent expiry.
Recipients default to the member issuer; explicit grantee names must be existing
principals of the selected vault. The reserved center-admin grantee is always
rejected with HTTP 403; previously persisted grants with that subject are also
unusable. Operator issuance without a grantee (including hosted
ticket issuance) creates an anonymous principal whose subject is its grant ID,
not center-admin. No recipient may have a higher role than the issuer's
effective role, even for a read-only grant.
Both Center and daemon define pub const MAX_DELEGATION_DEPTH: u32 = 2.
Direct member/operator issuance is depth 1; using a grant issues at
parent.depth + 1, and depth 3 returns HTTP 403. Durable member credentials are
direct member authority (internal depth 0); grants they mint are depth 1 with
no grant parent. Persisted legacy grants without depth are reconstructed from
their full parent chain, including configured static and durable credentials.
Missing/cyclic or over-limit ancestry is unusable and purged on startup.
Grant authority with subject != issued_by is a designated grant caller.
Such callers cannot approve, reject, cancel, or claim any permission request,
even their own (HTTP 403); they are not eligible approvers. They may create
requests, but must use their real member credential for permission workflows.
Delegated issuance preserves the real issuer so a designated bearer cannot
launder itself into a self-issued grant. Self-issued grants keep their existing
request rules, and claims still require the requester's durable member bearer.
Deferred legacy approvals made using a designated-grantee grant cannot authorize
a claim; obtain a new approval from eligible member authority.
Breaking changes: higher-role and center-admin recipients, designated-grantee
permission decisions/cancellation/claims, and delegation deeper than two grants
are rejected. Operator-issued subjects are now anonymous rather than
center-admin; member-issued roots no longer list the durable member credential
as a delegation parent. Reissue an appropriate root or use a member credential
instead of relying on these former behaviors.
Grants record id (an independent opaque ID),
subject, scope, vault, Unix-seconds expiry, parent, issued_by,
generation, depth, and role. Static principal IDs are static:OPAQUE_ID.
GET /v1/grants returns only issued time-bounded grants with id, parent,
issued_by, grantee, scope, vault, Unix-seconds expiry, generation,
depth, and Unix-seconds created when available. It never returns a token or
bearer hash.
grantee is the recipient (subject in issuance/access responses).
Generation remains the generation at issuance. This issued-grant view differs
from the unchanged GET /v1/access/{vault} principal/access view: static and
durable credentials appear only in the latter and are removed via
configuration/member CRUD, not DELETE /v1/grants/{id}. Issuers may list their
own grants; receiving a grant alone does not make it visible to a non-admin
recipient. Hosted bearers are restricted to their vault; an omitted vault
selects that vault, while the operator can list across vaults.
memories center grant list [VAULT] returns this same issued-grant response.
Requests with empty/omitted approvers are broadcast via eligible pending lists;
nonempty lists contain named Admin/Owner approver IDs (or center-admin). Both
approval and rejection are bounded by the approver's scope, vault, role, and
remaining grant lifetime. The requester cannot approve or reject their own
request, even as an Admin/Owner or a named approver; they may still cancel it.
Decisions are serialized under the same authorization lock and may occur only
once. Request states are pending, approved, rejected, or cancelled.
Approval records approved grant parameters and the approver's scope/vault/role/
expiry bounds, but mints nothing and returns no token. The requesting member
uses their durable member credential to call POST /v1/requests/{id}/claim, or
memories center request claim ID. Approvers, other members, and minted grants
for the same requester subject are rejected. Claims are serialized: only one
claim succeeds, and subsequent attempts return 409, including after restart.
The grant's generation and creation/expiry clock start at claim; its expiry is
capped by the recorded approving authority's expiry. Expired/revoked parents and
changed issuer/requester generations prevent claims. Only the token hash is
persisted with grant metadata. request.claim audits the requester actor and
grant ID, never the token. If the one-time response is lost, request a new approval.
An approved content grant does not change its requester's member role or
delegation authority.
Breaking change: approval responses no longer contain grant or its token;
clients must explicitly claim with the requester member bearer. Request lists
show approved-unclaimed metadata, not tokens. Legacy approved requests lacking
deferred approval bounds cannot be claimed; create a new request instead.
Center audit and request files
DIR/requests.json is atomically replaced with mode 0600 and stores request
metadata and completed decisions across restarts. DIR/audit.jsonl is opened in
append mode with mode 0600, fsynced after each entry, and never rewritten by
Center. Both live under the mode-0700 hosted directory. Reasons are audit
metadata: do not put passwords or other secrets in them. Audit records do not
include bearer tokens, passwords, keys, or vault content.
Audit entries expose sequence, timestamp, prev_hash, hash, action,
and optional issued_by / via_grant for grant-authorized actions. The former
is the real issuer, the latter the caller's grant ID; actor remains the grant
subject except that grant.issue records the real issuer as actor and the
grantee as subject. The fields default to absent and are omitted when absent,
so old audit logs retain their existing hashes. The shared Store's signed hosted
content audits also record these fields. The existing fields include
actor, subject, scope, vault, expiry, parent, reason, request_id, and
object. The first prev_hash is null; later values reference the preceding
hash. SHA-256 covers the JSON tuple (sequence, timestamp, prev_hash, event_fields).
verify_audit_chain(path) in crates/center/src/saas/audit.rs verifies continuity,
hashes, and complete JSONL lines. Startup verifies the file and reconciles request
decisions and claims against it, so a crash after auditing a decision or claim
but before replacing request metadata cannot enable a repeat. Appends and reads
verify the chain against the current in-process head; audit reads verify the entire chain
before applying a vault filter.
Actions are device.authorize (bootstrap ticket issuance), grant.issue,
grant.revoke, member.add, member.set, member.remove, session.lock,
session.unlock, request.create, request.approve, request.claim,
request.reject, and request.cancel. Lock and member-change revocations are
audited too. JSONL
exports use Content-Type: application/x-ndjson; filtered exports retain original
hashes/sequences and are not independent complete chains. Vault-bound Admin/Owner
callers only see their vault's entries; the operator also sees global events and
may export the complete log. Any audit append/request persistence failure locks
the service, sets restart_required, and disables remote unlock until repair and
restart. Authorization mutations and permission decisions return 503 while
restart_required is set; read-only operator inspection remains available.
Keep these files in protected backups, not source control.
This is an unkeyed chain, not a device signature or external anchor: a compromised operator can rewrite/recompute it, omit events, or roll back all files together. Complete tail truncation before startup cannot be detected without an external known head. Do not treat chain verification as an independent security audit.
Hosted content scope and API
Hosted content scopes apply to the operation, not merely the HTTP method.
Authorization-management routes additionally check the principal's role as
described above; content scope alone never grants host administration:
| Scope | Hosted authority in the bound vault |
|---|---|
read |
Safe list/search/graph; full authorized reads, file reads, history/diff/audit and explicit credential_copy |
write |
Content creation/updates, protected/OTP enrollment, files/credentials, world/chat/automation writes; no prior-content read or delete |
read_write |
Both; also otp_code (requires read + write because HOTP advances state) |
read_write_delete |
Both plus asset, thing, attribute and automation-hook deletion |
allow_all |
All of the above within the content-only hosted surface, not server/device administration |
Unlike daemon MCP, hosted dispatch supplies operation-local elevation only after
the closed operation allowlist, scope and vault binding pass. Daemon check_mcp
and its local elevation restrictions are unchanged. Hosted agents/MCP adapters
can use their scoped bearer on the full RPC content surface; no local terminal
elevation or daemon connection is required. Cross-vault brain/archive references
cannot resolve another vault's content through a grant.
GET /v1/session→ current vault, role, scope, expiry and read/write/delete capabilities, without bearer material. Write-only tokens can inspect their session; operator admin tokens cannot. See mobile hosted mode.GET /v1/memories→{"memories":[...]}. Ordinary non-private note records retainid,vault,title,body; cards/private/file/credential records use safe summaries, never credential bodies or resolved secrets.GET /v1/memories/{id}→ full safe client content +id,vault. Credentials require explicitcredential_copyrather than generic reads.POST /v1/memoriesandPUT /v1/memories/{id}accepttitle/bodyplus fullContentmetadata. PUT preserves omitted fields. Adding card metadata at creation inferskind: "card"if no kind is supplied. Structured/protected profile creation uses its dedicated RPC, not generic PUT. A write-only PUT returns only submitted fields and record identity, not existing private fields.- Optional JSON
vault/?vault=…must match the grant name or canonical ID; omission selects the grant's vault.DELETE /v1/memories/{id}requires delete. POST /v1/rpcaccepts the daemonRequestJSON (opin snake_case), returns its direct JSON result, and checks each operation independently. Use canonical asset/revision IDs from responses; vault accepts the grant's name or canonical ID.listwithoutvaultselects only the grant's vault. Errors use HTTP 401 (unknown/admin/expired token), 403 (scope/surface/vault), 404 (missing), 409 (conflict), 400 (invalid input), or 422 (malformed Request JSON/schema).
Examples (send with the grant bearer, not the admin bearer):
{"op":"brain_card","vault":"default","title":"Research","abstract":"Summary","links":[]}
{"op":"history","vault":"default","asset":"CANONICAL_ASSET_ID"}
{"op":"persona_world_get","vault":"default","asset":"PERSONA_ASSET_ID"}
{"op":"put","vault":"default","content":{"title":"attachment.bin","kind":"file","private":true,"body":"BASE64URL_BYTES"}}
Files/credentials use the existing URL-safe base64 body convention, not a second
blob store. Core's 16 MiB content-body limit still applies; the HTTP JSON body
limit is 24 MiB. protect_secret maintains credential references atomically.
Generic reads/lists/diffs never resolve stored credential bytes; authorized
credential_copy is the intentional explicit plaintext delivery endpoint.
Hosted archive_history/archive_read address signed revisions of the bound
live vault (including historical/tombstoned revisions), not daemon recovery
archives. archive_audit addresses that vault's audit. Hosted reapply appends a
signed historical or quarantined revision as a new head, including structured/
protected metadata; reapplying a tombstone also requires delete. Unlike inventing
protected references through generic PUT, this restores references already
authenticated in the vault's signed history. It does not resurrect a deleted
linked credential automatically; reapply that credential revision separately if
needed.
Intentional differences from the daemon
POST /v1/bootstrapand/v1/restorealways return 403: this remains a center-managed library, not device self-service enrollment/restoration.- No sudo, device enrollment inside hosted content, vault policy membership
administration, grant mint/revoke content RPC, full-store backup/restore RPC, remote
sessions/proxies, cloud/relay sync, unattended custody, execution or crypto-job
execution. Such requests fail closed even with
allow_all. - Hosted lock/unlock/status and durable member administration are separate
admin-authorized HTTP routes, not capabilities conferred by
allow_all. - Recovery archives and cross-vault content resolution are not exposed. Historical reads/reapplication of the hosted vault's own revisions are supported.
- Semantic/RAG model configuration/generation and model installation are not
hosted endpoints. Lexical
searchand the synchronousretrievepath reuse the core retrieval policy (no private content, file bodies or credential bodies). - Automation runs on explicit RPC and existing content-trigger events; there is no background scheduler. Outbound webhooks use the core's exact URL allowlist; treat a hosted writer who can configure webhooks as authorized outbound network access and enforce operator egress restrictions for untrusted tenants.
- SaaS-configured daemons and the thin CLI helper use hosted
/v1/rpc; native mobile uses its separatehosted_*FFI path with a smaller content allowlist. Ticket-only local IPC remains intact. There is no JSON-RPC MCP admin tool server.
Store persistence still uses whole-state transactional saves and serialized service operations; this is not independent tenant-key encryption or a claim of production multi-tenant isolation/security certification.
Mobile / FFI
The shipped Flutter app chooses Standalone, Center or SaaS in its own interface.
It does not load the daemon's config.toml or configure a ticket-managed local
service. Its Center choice connects to a self-hosted server with hosted
storage, while SaaS connects to https://memories.ivjn.us. Both use the
hosted FFI operations below.
The shared service enforces ticket-managed Init/Restore and grant restrictions when a caller configures Centers on that service. This is separate from the phone's mode selector. Mobile FFI continues to deny Run/CryptoJob/local MCP grant management/remote/proxy and local grant/permission-request RPCs; selecting a hosted mode does not widen that local interface.
Residual work
Remote instruction approvals and other elevated policy surfaces may later accept
Center-signed approval tickets. Until then, Center-managed endpoints still use
local sudo for vault-elevated operations other than MCP grant minting and
bootstrap. allow_all is reserved to grow with those surfaces without renaming
scopes.
Permission request limits, expiry and retention
Applies to both the daemon (requests in the encrypted Store) and Center (requests.json):
MAX_PENDING_PER_REQUESTER_PER_VAULT = 20andMAX_PENDING_PER_VAULT = 200count pending requests only. Decided, cancelled, expired, and approved-but-unclaimed requests don't count toward these limits. (This replaces the old global cap of 1000 requests, which counted every request ever made.) If you go over a limit, the daemon returns a limit error and Center returns HTTP 429.PENDING_REQUEST_TTL = 7 days: a pending request older than this moves to statusexpired. It's audited asrequest.expirewith actorsystem, and it can no longer be approved. An approved scope request that hasn't been claimed within 7 days of approval also expires, the same way, and can't be claimed after that.REQUEST_RETENTION = 30 days: requests that are rejected, cancelled, expired, or claimed are deleted this long after their last transition (thedecidedtimestamp, orcreatedfor older records that have none).- Expiry and pruning happen lazily: the daemon runs them at the start of any authorization request, and Center runs them on request create, list, decide, and claim. Nothing runs them at startup.
- Center startup reconciliation ignores audit entries for pruned requests and accepts
request.expireentries. Anexpiredrequest must have an audited expiry. Request IDs are random 128-bit values that are never reused, so a pruned request (including a claimed one) doesn't exist anymore and can't be claimed. Because of that, Center doesn't keep a separate tombstone set.
Mobile hosted mode (phone client)
The mobile app's Center and SaaS modes connect directly to a Center running with
--data DIR; the phone does not need a daemon mode = "saas" entry. See
mobile usage for first connection and switching,
and configuration for profile migration.
The FFI memories_call API accepts these hosted_* operations:
{"op":"hosted_connect","center_url":"https://…","bearer":"…","ca_file":null,"insecure":false}→{"session":N,"url":…,"server_readable":true}{"op":"hosted_rpc","session":N,"request":{…}}uses the content allowlist below{"op":"hosted_status","session":N}authenticates viaGET /v1/session{"op":"hosted_requests","session":N},hosted_approve/hosted_reject(id),hosted_access(vault){"op":"hosted_disconnect","session":N};memories_lock_allalso drops every hosted session
HostedClient::rpc in crates/core/src/center.rs permits list, read, search, delete,
type_list, type_show, type_create, type_update, type_delete,
card_create, card_fields, card_field, credential_set, credential_show,
and put when content.kind is memory, card or credential. The server still applies the
member's vault, role and scope. This FFI allowlist is not a promise that every
operation has a Flutter screen: hosted knowledge supports list, server search,
detail, ordinary memory/card editing, creation and confirmed deletion. Structured
profiles, protected cards, type templates and sealed cards require specialized
editors. Authorization requests and paired-device controls remain available.
GET /v1/session returns vault, vault_id, scope, role, expires_at
(Unix seconds or null), can_read, can_write, can_delete and
server_readable. It accepts any active scoped content token, including
write-only tokens, but rejects operator admin tokens. It returns no bearer or
secret material. Each content request still rechecks authorization; this response
is a UI snapshot, not a grant. Deploy this endpoint before upgrading the app:
older servers without it cannot validate a new app connection.
Search uses the shared retrieval service and excludes private content, credentials and files. The unfiltered knowledge list can include private cards for an authorized reader. Failed saves retain the draft; deletion requires confirmation and removes the item only after server success. Updates use the existing revision service without an atomic expected-revision precondition; simultaneous edits can replace each other's latest fields.
Hosted mode is server-readable: the Store is encrypted at rest, but the
operator holds the password and can read hosted content. The app marks it
“server can read”. Rust keeps each live client's bearer in memory, redacted from
Debug and errors; Flutter persists each mode's URL/token pair in platform
secure storage. Switching ends the active session while retaining the profile.
Disconnect removes that profile's saved bearer; it does not revoke the server
token. Backgrounding drops hosted sessions and visible content; reconnection on
return is explicit.
Device-to-device E2E secrets (hosted Center)
Hosted vaults are server-readable. For secrets the Center must never see, pair
devices and deliver sealed envelopes (crypto::Envelope: X25519 + signed
by the sender's Ed25519 key). The Center stores only public keys and ciphertext.
Pairing flow
- CLI:
memories device hosted init --backend <keychain|secret-service|dpapi|owner-file>creates the device identity (32-byte seed in OS custody; X25519 + Ed25519 derived). - CLI (member token in
MEMORIES_CENTER_TOKEN):memories device hosted pair --vault V→POST /v1/devices/pairingswith both public keys and a self-signature. The Center creates a pending PermissionRequest carrying the device fingerprint and returns{code, request_id, qr: {pairing_id, fingerprint, center}}(centeris the Center's--center-id). Pairings expire after 5 minutes. The CLI prints a terminal QR (stderr) of{"type":"memories-pair","v":1,"pairing_id","fingerprint","center","code"}— the one-time code is included so scanning is enough; stdout JSON is unchanged. Re-pairing a key that is already active returns 409; after revocation the same key can pair again. - Phone (admin of the vault) scans the QR (or types the code) →
GET /v1/devices/pairings/{code}. The phone refuses a QR that is not a Memories pairing QR, whosecenterdiffers from the connected Center, or whose fingerprint differs from the fetched device. Unknown, expired, or used codes all return 404. — single-use, expiring, 10 failed lookups/minute → 429. Shows the device fingerprint. - Phone approves with
POST /v1/requests/{id}/approveand body{"attestation": DeviceAttestation}signed by the phone's Ed25519 key over device fingerprint, both device keys, pairing id, approver, scope and expiry. - CLI:
memories device hosted claim --request-id ID(once). The device-scoped grant token is stored in OS custody; the Center keeps only its hash. - Compare the SAS: the phone shows it (grouped
123 456) before approving; after approvalmemories device hosted sas --pairing-id Pprints the same digits on the CLI. Different digits: revoke the device and pair again.
Mobile permissions: Android CAMERA (camera feature optional), iOS
NSCameraUsageDescription. If camera access is denied the phone explains how
to enable it in system Settings and offers manual code entry. The CLI and phone
translate Center statuses into actionable messages (404 expired/used/other
Center, 409 already done or already paired, 403 not permitted or revoked
device, 422 e2e content refused, 429 rate limited).
SAS = first 6 decimal digits of
u64_be(SHA-256("memories/device-sas/v1\0" ‖ phone_ed25519_pub ‖ "\0" ‖ cli_ed25519_pub ‖ "\0" ‖ pairing_id)[0..8]) mod 10^6,
zero padded. The phone has the CLI key from the fetch; the CLI learns the phone key
from the attestation. A mismatch means a substituted key: revoke immediately.
Rules
- Device grants are bound to the device (unrevoked, attested) and the member's generation; delegation depth ≤ 2. Devices cannot issue grants or decide requests.
- Bumping or removing the member invalidates the device and deletes its envelopes.
PUT|GET|DELETE /v1/sealed/{vault}/{card}/{device},GET /v1/sealed?device=: envelopes only for attested devices, addressed to the device's X25519 key, from a known signer; versions must increase. Audit records fingerprints only.- Cards with
e2e: truerefuse plaintext secret content on the hosted Center. /v1/access/{vault}lists devices. MCP:device_list,sealed_reveal(requiresintent: "reveal_plaintext"and a reason).
Threat model
The Center operator cannot read sealed secrets, but can withhold or replay old envelopes (versions are monotonic per device), and sees metadata (who, which card, when). Pairing trusts the SAS comparison and the approver's phone key. Revocation cannot recall plaintext already delivered to a device: after revoking, rotate the secret upstream.
E2E hardening (P2)
- Sticky
e2eflag. Once a card ise2e: true, writes that drop the flag are refused (422). Only an Owner grant (not a device grant) may clear it, and only with a write that carries no secret material; later writes then follow normal rules. - OTP parameters. A sealed OTP secret may be a bare Base32 seed (SHA1, 6 digits, 30 s) or a full
otpauth://totp/...URI;memories device hosted sealed-get --otphonorsalgorithm(SHA1/SHA256/SHA512),digits(6/8) andperiod(30/60) from the URI.device hosted pairalso prints the pairing payload as a terminal QR code (stderr). - Re-seal after revocation. Revoking (or invalidating) a device deletes its envelopes and marks each affected card
needs_rotation(shown inGET /v1/sealed?device=,GET /v1/sealed/rotation?vault=, the access list, and the phone's re-seal page). Revocation cannot recall plaintext already delivered: rotate the secret upstream, then use the phone's Re-seal for all devices (hosted_reseal_all) which seals to every currently approved device with a new version and clears the flag. Revoked devices are never included. - Phone recovery. The Center never holds the phone identity. When created, the phone shows a one-time recovery code (Base32 of the 32-byte seed + 2-byte checksum, groups of four) to write down; Restore from recovery code (
hosted_phone_restore) rebuilds the same identity. - Identity rotation. Move to a new phone key (
hosted_identity_rotate) creates a new identity and postsPOST /v1/devices/identity-rotationswith a record signed by both the old key and the new key; only an admin member (not a device) may post it, and the old key must already be trusted for some device. CLIs pin the first approving phone key (hosted-device-trust.json, trust on first use) and accept envelope signers reachable from that root through verified rotations only. - Hardware-backed storage. The phone seed is stored with
flutter_secure_storageusing the strongest built-in options: iOS/macOS Keychainpasscodeaccessibility (this device only, not synced, requires a device passcode); Android Keystore-wrapped storage with biometric or device-credential gating andresetOnError: false. Limits: the seed is still exported into app memory to sign/seal (Secure Enclave / StrongBox cannot do X25519/Ed25519 natively), so this protects at rest, not against a compromised running app. - Transparency log. The Center keeps an append-only, hash-chained device log (
device.pair,device.approve,identity.rotate,device.revoke) atGET /v1/devices/log. Each entry embeds the CLI pairing self-signature, the phone attestation, or the rotation record.memories device hosted log --verifychecks chain linkage, every signature, and that the log extends the head it verified last time (hosted-device-log.json); a rewritten history is reported as an error.