All docs

Docs · Reference

Contacts

CardDAV as the UI

The contacts personality serves a durable contacts store to any CardDAV client — Apple Contacts, iOS, Thunderbird, DAVx⁵. Stacks put cards into it with an op; the client reads them. The head is a generic address book server: it knows no product and never runs a stack to answer a read.

Nothing lands in an address book on its own. A rule that wants a person to show up in a contacts app calls txco://contacts/put with the card it chose to publish — a structured object the chassis renders to vCard 3.0, or the vCard text itself — and the head serves it from the store.

The other direction is a stack hearing what the client did: every committed mutation (a card added, edited, deleted) can reach the tenant’s _contacts stack after the fact, and an address book can be set to ask the stack first — and to accept the stack’s rewrite of the client’s card. That is how “add a card and the pony lets that person write to it” is written in txcl, not in Go.

The calendar personality is this one’s sibling: same account model, same policy vocabulary, same lanes, same pack kind. What differs is the object — and one consequence of it: a contacts app expects to read back exactly the bytes it wrote (photos, X- properties, groups), so the head stores a client’s card as written and serves it back verbatim, rather than re-encoding it.

Turn it on

txco serve --personalities cron,web,admin,contacts

There is no listener of its own: the head mounts on the web head under a reserved path prefix on every hostname it serves — --contacts-path-prefix (default /carddav) plus /.well-known/carddav, which redirects into it. It must differ from --calendar-path-prefix (the chassis refuses to start otherwise). TLS is the web head’s. The index lives in its own SQLite file (--contacts-db-path, default ./chassis/data/contacts.db); cards live in the index, never the blob CAS.

On a fleet, --contacts-store selects a backend an overlay registers (the hosted build ships postgres, reading TXCO_DB_AUTH_DSN). A shared backend is opened on every node, head or not, so txco://contacts/* on any node project into the one index the head serves.

For local development:

txco dev --contacts       # http://<dev host>:<web port>/carddav/, Basic auth over plaintext

Add the account in Contacts (macOS) with Add Account → Other Contacts Account → CardDAV → Advanced: server = the bound host, port = the web port, SSL off, path /carddav/; in Thunderbird, Address Book → New CardDAV Address Book with the same server and username. Every Basic-auth attempt logs one contacts login line with its outcome.

Accounts: txco://contacts/account

EXEC "txco://contacts/account"
  WITH username = "paris@pony.example.com",   # <local>@<domain the tenant owns>
       password = ._imapacct.password,        # the same password the IMAP account got
       into = "_conacct"

The WITH clause is txco://calendar/account’s: username (req; the domain must be a verified hostname or delegated zone of the tenant), password (omitted: unchanged on update / generated on create; "": generated; else stored, ≥ 8 chars), rotate, password_style / password_words, status, policy (the account-default mutation policy). Result at into (default _contacts): {username, created, principal, password?, rotated?}password appears only when generated, once. Pass the password another head minted and one credential opens Mail, Calendar and Contacts.

Address books: txco://contacts/addressbook

EXEC "txco://contacts/addressbook"
  WITH username     = "paris@pony.example.com",
       name         = "senders",
       display_name = "Paris senders",
       description  = "Who may write to this pony.",
       policy       = &object("put", "stack", "delete", "stack"),
       into         = "_book"
WITHMeaning
username, name (req)The account and the book’s path segment ([A-Za-z0-9._~-], up to 128 chars). Creates when absent, updates otherwise; empty fields are left alone.
display_name, description, sort_orderWhat the client shows.
policyPer-book mutation policy (below).
removetrue: soft-delete the book and tombstone its objects.

Result: {id, name, path, display_name, description, sort_order, policy, sync_token, created} or {name, removed}.

Cards: txco://contacts/put

EXEC "txco://contacts/put"
  WITH username    = "paris@pony.example.com",
       addressbook = "senders",
       name        = "bob-example.com.vcf",     # the resource name on create
       card        = &object(
         "fn",     "Bob Example",
         "emails", &array(&object("value", "bob@example.com"))),
       into        = "_put"
WITHMeaning
username, addressbookThe account and the book (its name).
card{} or vcardThe structured card (the chassis renders vCard 3.0) or vCard text (kept as written; the UID is the bytes’ own). Not both.
uidThe object’s identity. Optional with card{}: derived from name as <name>.<local>@<domain>, stable across re-materializations.
nameResource name on create; on update the object keeps the name it has, whatever the client chose.

card{} is generic vCard vocabulary: uid, fn (defaults to the first email), name{family, given, additional, prefix, suffix}, nickname, org, title, note, url, birthday (YYYY-MM-DD), emails[{value, type[], pref}], phones[{value, type[], pref}], kind (individual, group, org, location) and, for a group, members[]. vCard text must be one VCARD, version 3.0 or 4.0, with a UID.

Result: {name, path, uid, etag, created, noop, modseq}. The same content again (REV and PRODID aside) is a noop — a re-materialization never changes an etag. The op charges fuel per MiB like blob/put. Errors land as <into>.error.{code, message} with the run continuing: txco_contacts_disabled, txco_contacts_no_account, txco_contacts_no_addressbook, txco_contacts_no_object, txco_contacts_domain_not_owned, txco_contacts_username_taken, txco_contacts_invalid_arg, txco_contacts_too_large, txco_contacts_conflict (the UID names another resource).

Lists: txco://contacts/sync

A stack has no loops, and an address book often mirrors a list — the people allowed to write, a team, a roster. sync applies a bounded batch in one transaction:

EXEC "txco://contacts/sync"
  WITH username    = "paris@pony.example.com",
       addressbook = "senders",
       put         = ._plan.put,       # [{name?, uid?, card{} | vcard}, …]  ≤ 200
       delete      = ._plan.delete,    # [uid, …]                            ≤ 200
       into        = "_sync"

Every put entry is a txco://contacts/put body, addressed by UID; every delete is a UID (an unknown one counts as missing, not an error). Any invalid entry refuses the whole batch and names it (error.index, error.op); nothing is written. Result: {created, updated, noop, deleted, missing, count, items[]}. The usual shape is list → a compute decides what to add and drop → sync.

The rest of the op family

OpWITHReturns
txco://contacts/getusername, addressbook, uid or name{name, path, uid, etag, size, version, fn, kind, addresses[], modseq, updated_at, vcard, card{}}card is the parse: every input field plus version, rev, addresses[] (the EMAIL values, lowercased, deduped)
txco://contacts/listusername{addressbooks:[{…, objects}], count, home}
txco://contacts/listusername, addressbook, after (modseq cursor), limit (≤ 1000){items:[{name, path, uid, etag, fn, kind, addresses[], modseq, …}], count, next, sync_token} — facts only, never the bytes
txco://contacts/deleteusername, addressbook, uid or name{deleted, name, uid}

What a client gets

Discovery through /.well-known/carddav<prefix>/ (current-user- principal) → <prefix>/<username>/ (addressbook-home-set) → <prefix>/<username>/addressbooks/ (the books) — so a client needs the server, the username and the password. Then PROPFIND, REPORT (addressbook-multiget, addressbook-query), GET, PUT (with If-Match / If-None-Match; 201 on create, 204 on update), DELETE, MKCOL, PROPPATCH (display name, description). Objects keep stable URLs, UIDs and ETags across re-materialization.

A client’s bytes are stored as written (line endings normalized) and come back byte-for-byte on GET and in a multiget — so a photo, an X-ABLabel, an escaped \; in a note survive. Apple’s groups are ordinary cards (X-ADDRESSBOOKSERVER-KIND:group with X-ADDRESSBOOKSERVER-MEMBER lines); the parse reports them as kind: group. Not yet: sync-collection / getctag (clients fall back to an etag diff per refresh, which works); addressbook-query matches the first value of a property only and case-sensitively (the library’s matcher), and its results are re-encoded by the library, which emits a ; inside a text value unescaped.

Basic auth on every request: the head verifies argon2id once and caches the verified triple for five minutes. A request must arrive over TLS — the web head’s own listener, or X-Forwarded-Proto: https from the front proxy — unless --contacts-insecure-auth (dev). Throttles count cache misses only (--contacts-login-rate). A disabled account and a suspended tenant are refused; an account on another tenant’s hostname is refused exactly like a wrong password.

Seeding address books with a stack

A stack may ship whole address books declaratively in the reserved CONTACTS/ tree, a pack kind beside VECTORS/, KV/, BLOBS/ and CALENDARS/ (deployed by txco data apply, mirrored live by txco dev):

OPS/<stack>/
  CONTACTS/
    paris@pony.example.com/team.jsonl     → the address book "team" of that account
{"addressbook":{"display_name":"Paris team","description":"seeded"}}
{"name":"bob.vcf","card":{"fn":"Bob Example","emails":[{"value":"bob@example.com"}]}}
{"name":"raw.vcf","vcard":"BEGIN:VCARD\r\nVERSION:3.0\r\n…"}

The optional addressbook line sets the display fields (and policy); every other line is one card — card{} as txco://contacts/put takes it or vcard text. On activation the book is ensured, every card put, and every live card the pack no longer lists is deleted — the pack is the book’s desired state. The account must already exist; a pack for an unknown account is an error for that pack, logged, activation unaffected. A book the pack creates denies client put/delete unless its header says otherwise: keep runtime-written books out of packs.

Policy: what a stack hears, and when

Per address book, five verbs — put, delete, mkaddressbook (a client creates a book), remove (a client deletes one), proppatch — each one of deny (403, the default for mkaddressbook and remove), local (the default for proppatch), observe (commit, then tell the _contacts stack; the default for put and delete) or stack (ask the _contacts stack first; 403 unless it answers @contacts.res.ok = true). Resolution: the book’s policy, then the account’s, then the chassis default. The _contacts stack is the subscription — without it observe is silent and stack answers 503.

The envelope

@src                  "contacts"                          @client.ip
@contacts.tenant      (slug)         @contacts.account    (username)
@contacts.phase       observe | answer                    @contacts.op   put | delete | mkaddressbook | remove | proppatch
@contacts.addressbook {id, name, display_name}
@contacts.object      {name, uid, etag, prior_etag, size, exists}     (put / delete)
@contacts.vcard       the client's card, as written                   (put)
@contacts.card        {…the parse of it: uid, fn, emails[], addresses[], kind, …}
@contacts.prior       {card: {…the stored card's parse…}}             (put on an existing object, delete)
@contacts.props       {displayname, description}                      (mkaddressbook / proppatch)

A rule may EMIT @delete = &array("@contacts.vcard", "@contacts.card", "@contacts.prior") once it has consumed them.

Answering (@contacts.phase == "answer")

WHEN @contacts.phase == "answer" && @contacts.op == "put" && @contacts.card.addresses.0 !~ /./
  EMIT @contacts.res.ok = false, @contacts.res.code = "cannot",
       @contacts.res.msg = "add an email address to this card"

WHEN @contacts.phase == "answer"
  EMIT @contacts.res.ok = true

ok absent or false is a 403; code is cannot (403), limit (507) or unavailable (503); msg is shown to the client. On ok, an optional @contacts.res.card (or .vcard) is the card the head commits instead of the client’s bytes — the client’s UID is kept. Most stacks accept a card as written: the client’s own rich card is what its owner wants to keep. The head waits --contacts-resp-timeout (30 s).

Client settings

The head never routes by name: it serves every hostname the web head does, so the server a contacts app should use is the domain of the addressparis@<stack>.stacks.example connects to https://<stack>.stacks.example/carddav/, and /.well-known/carddav on that host does the rest. A user name typed without its domain (paris) completes to the server’s host, so an account dialog that shows only the local part still logs in. For clients that discover a server from an address by DNS (RFC 6764), the dns personality can publish _carddavs._tcp SRV + TXT records (--dns-carddavs-port, see dns).

Flags

FlagDefaultMeaning
--contacts-path-prefix/carddavThe reserved prefix on every hostname (plus /.well-known/carddav); must differ from the calendar’s
--contacts-store / --contacts-db-pathsqlite / ./chassis/data/contacts.dbThe index; a non-sqlite backend is shared and opened on every node
--contacts-insecure-authfalseAccept Basic auth without TLS (txco dev --contacts sets it)
--contacts-login-rate30Verifications per minute, per IP and per username, on cache misses only
--contacts-object-max-bytes1 MiBSize cap for a card (ops and client PUT; advertised as max-resource-size)
--contacts-resp-timeout30sAnswer-lane deadline
--contacts-observe-sample / --contacts-observe-max-inflight1 / 8Observe-lane sampling and concurrency

Env: TXCO_CONTACTS_*. Example: examples/contacts-hello.

Edit this page · View as markdown