Resources & Membership API — /v1/resources
A resource is the permission, billing, sharing, and lifecycle boundary for shared data — a room, album, project, team, or app-level public state. Resources are principals like users: they can own data and bear cost. Membership is always consensual — there is no force-add. This section documents managing resources and their members; reading and writing the data inside a resource is the database API.
All endpoints are under /v1/resources and require authentication.
Concepts#
Type — an app-declared resource type (e.g.
room), configured in the schema. Reserved_-prefixed types (like_app) can't be created via the API.Visibility — declared with
r.visibility(...), one of:members— hidden from non-members; entry is by invite/request only.unlisted— anyone who has the id can view and self-join, but it doesn't appear inbrowse.discoverable— appears inbrowsein addition to being self-joinable.
Visibility is stored per instance. By default every instance of a type gets the declared state, but a type declared with the object form —
r.visibility({ default: 'members', allow: ['unlisted', 'discoverable'] })— lets the creator pick any allowed state at create time (so onecommunitytype can hold both public and private communities). Visibility is fixed after creation.Roles —
owner>admin>writer>reader. The creator is theowner.State — a membership is
active,invited, orrequested. Onlyactivecounts as membership; pending rows carry a TTL (default 14 days).Grant ceiling — the max role a role may assign. Default:
owner→admin,admin→writer,writer/reader→nothing. (So ownership can't be handed out by grant.) Overridable per type withgrant.
Resource endpoints#
POST /v1/resources — create#
Body { "type": "room", "name": "General", "visibility"?: "unlisted" } → 200
resource view. The caller becomes the owner (active). visibility is a DSL state
(members | unlisted | discoverable) and is honored only when the type declares it
in its allow list (naming the type's fixed state is always accepted). Errors: 400
unknown visibility value, 403 reserved type or disallowed visibility, 404 unknown
type.
GET /v1/resources?type=room — my resources#
Resources the caller is an active member of (optionally filtered by type).
→ { "resources": [ ResourceView, … ] }
Each row carries currentMember: { role, state } — the caller's own membership
only, never other members' identities. It is resolved from the same membership join
that scopes the list (no extra queries), so role-aware UI can render from the list
response instead of calling GET /:id/members per resource.
GET /v1/resources/browse?type=room&query=&limit=&cursor= — discover#
Discoverable public resources of a type, cursor-paginated (reader-safe metadata only).
query filters by name; limit defaults to 20 (max 100).
→ { "resources": [ { id, type, name, memberCount, createdAt }, … ], "nextCursor": … }
GET /v1/resources/static?type=&slug= — app-owned singleton#
A static resource declared in the schema and reconciled at deploy (e.g. a global feed).
→ resource view, or 404.
GET /v1/resources/:id — fetch one#
Visible to active members, or anyone for a public resource. A members-only resource
returns 404 to non-members. → resource view (includes memberCount, plus
currentMember when the caller is an active member; a non-member viewing a public
resource gets no currentMember).
Deleting a resource#
There is no DELETE here — deliberately. Every other membership verb has an undo
(re-invite restores what remove took away, rejoining restores hidden content);
deleting a resource does not, and it's destructive for every member, not just the
caller. That combination is kept out of the app-facing API the same way wallet
custody and account deletion are: it's a dashboard-only action
(DELETE /v1/dashboard/apps/:appId/resources/:resourceId, owner-only, behind the
signed-in user's own session — not reachable with an app's client credentials at
all). Apps that want in-app "delete this room" UX link out to the dashboard's
resource management page instead of reimplementing the confirmation themselves.
The cascade itself is unchanged: removes members, resource-owned rows, and
shared-table context links; creator-owned rows survive in their creators'
namespaces; the _app resource can't be deleted. Every removed member is notified
over realtime exactly as a remove would notify them — see
realtime.md.
Resource view
{
"id": "…",
"type": "room",
"name": "General",
"visibility": "public",
"discoverable": true,
"memberCount": 4,
"currentMember": { "role": "writer", "state": "active" }
}
currentMember is the caller's own membership: always present on list rows, present
on GET /:id only for active members, and never returned by browse/static.
visibility/discoverable here are the stored pair the schema's r.visibility(...)
enum compiles to (members → {members, false}, unlisted → {public, false},
discoverable → {public, true}) — see
schema-and-deploy.md.
owner_principal and deployment internals are never exposed.
Membership endpoints#
Membership is consensual: the only paths to active are an invite the user accepts, a
request an admin accepts, a self-serve join on a public resource, or an owner adding their
own agent — which is that owner consenting on behalf of a principal that has no will of
its own to consult. There is still no force-add: nobody can put another person, or
another person's agent, into a resource without a human agreeing.
GET /v1/resources/:id/members#
Active-and-pending roster, visible to members.
→ { "members": [ { userId, role, state, kind, ownerId, ownerHandle, ownerDisplayName }, … ] }
kind is "user" or "agent". For an agent the owner* fields name the person who acts
through it (null for everyone else). Render them. An agent's owner sees a full
transcript of everything it does, so a roster that shows a bare id is a room readable by
someone it does not list — disclosing the owner is what keeps presence and readership from
coming apart. The alternative, adding the owner as a member too, would force a person into
a resource they never agreed to join.
GET /v1/resources/invitations?type=room — my pending invites#
Recipient-scoped: the caller's own invited rows across every resource of the deployment
(optionally filtered by type), since a members-visibility resource can't otherwise be
looked up without already knowing its id. Excludes expired invites and invites from a user
now blocked in either direction.
→ { "invitations": [ { resourceId, type, name, role, invitedBy, expiresAt }, … ] }
Deployment-scoped, deliberately. This is the app asking on the user's behalf, so it
sees only invites inside this app. The user's invitations across apps — including into
apps they have never opened — are a platform surface (GET /v1/me/invitations, dashboard
session only, rendered in the hub). No app may enumerate a user's memberships elsewhere,
which is the same boundary the profile already holds.
POST /v1/resources/:id/invite#
Admin+ invites a user; bounded by the grant ceiling and a per-member rate limit (max 50 pending). The invite is pending until the user accepts.
Body { "userId": "…", "role"?: "writer" } → { "ok": true, "state": "invited" }
One exception, and it is why the response tells you the state rather than
assuming it: inviting a non-human principal you own returns
"state": "active". Such a principal has no will to consult, so its owner's
decision is the consent — waiting for an acceptance would mean waiting on
something that cannot meaningfully agree. One owned by someone else still goes
pending, and its owner is who accepts.
POST /v1/resources/:id/invite-link#
Admin+ mints a claim link — an invite for someone invite cannot reach, because
they have no tallpond account yet or because you have no way to name their user id.
Same authority as invite: the role must be within your grant ceiling, and at most
50 live links per person per resource.
Body { "role"?: "writer" } → { "url": "https://<slug>.tallpond.app/_invite/<code>", "role": "writer", "expiresAt": "…" }
The app hands url to a share sheet, a message, or a QR code — whatever it already
does. The app never learns who the invitee is until they claim, which is the same
line function_invocations holds by recording shape and never content.
Single use, expires in 7 days (deliberately shorter than the 14-day pending-invite TTL: a link in a chat log lives longer, and is seen by more people, than a row in a table). The code appears in this response and nowhere else — only its hash is stored, so a leaked database is not a pile of working invitations.
Opening the link is handled by the platform on the app's own origin, not by the app.
A visitor there has no session and no consent to the app, so no app code may run for
them yet, and the membership minted at the end must not be app-forgeable. Signup, app
consent, and membership all happen in that one pass. See
../invites.md.
Every check is re-run at claim time rather than trusted from when the link was written, because authority decays:
- The creator's grant ceiling is measured against the role they hold now. A link written by an admin who has since been demoted cannot still mint the old role.
- Blocks apply in either direction.
- A pending
invitedrow for the same person is absorbed rather than fought with — same person, same destination, two routes in. The link's role wins.
A refusal does not consume the ticket, so restoring a demoted admin's role revives every link they wrote rather than stranding whoever is holding one.
POST /v1/resources/:id/request#
A non-member requests to join (pending until an admin accepts).
Body { "role"?: "writer" } → { "ok": true, "state": "requested" }
POST /v1/resources/:id/accept#
The invited user accepts their own invite (empty body), an agent's owner accepts on its
behalf by passing the agent id, or an admin accepts a pending request by userId.
→ { "ok": true, "state": "active" }
Body { "userId"?: "…" }
An agent may not accept its own invite even with its own token: it has no interest of its own to weigh, so a self-accept is a rubber stamp rather than consent.
POST /v1/resources/:id/reject#
Decline your own invite (empty body), decline on behalf of an agent you own by passing its
id, or (admin) reject a request by userId.
→ { "ok": true }
POST /v1/resources/:id/join#
Self-join a public (unlisted or discoverable) resource at its default role.
Idempotent. → { "ok": true, "state": "active" } · 403 on a members-only resource.
You rarely need to call this explicitly — see Auto-join below.
POST /v1/resources/:id/leave#
Leave a resource. The owner can't leave (it would orphan the resource). Applies each
shared table's onMemberRemove policy to your contributed rows. → { "ok": true }
POST /v1/resources/:id/setRole#
Admin+ changes an active member's or pending invite's role; can't touch the owner or a
peer/superior, and can't assign above the grant ceiling. Changing an invite determines
the role the recipient receives if they accept. Pending access requests are not changed.
Body { "userId": "…", "role": "admin" } → { "ok": true, "role": "admin" }
POST /v1/resources/:id/remove#
Admin+ removes an active member or cancels a pending invite (not the owner or a
peer/superior). Removing an active member applies each shared table's onMemberRemove
policy; canceling an invite simply withdraws it. Pending access requests use reject
instead. Body { "userId": "…" } → { "ok": true }
Rooms#
A room is an assignment scope inside one resource. It owns nothing, pays for nothing, and is not a principal — it is a list of grants that rows point at. Three questions, three mechanisms, meeting at exactly one point (the role name):
| Question | Mechanism |
|---|---|
| Are you in at all? | resource membership — invite, accept, leave |
| What role do you hold here? | room grants — (principal → role) |
| What may that role do? | access rules — t.access({ … }) in schema |
Every resource is its own default room. Your role in it is your resource role, and a row lands there when nobody says otherwise. That is why an app that never creates a second room behaves exactly as it always has: nothing in the schema changes, and no row is ever backfilled.
Your effective role in a room is min(resource role, grant). A grant only ever narrows
— it cannot promote you past what the resource says you are, which is what stops a room
being a back door around membership, and what makes a resource demotion narrow every room
at once with no grant rewriting. No grant means no role: a resource owner is not silently a
reader of every private room, they are the person who can administer it. A schema that
wants admin visibility says so — t.access({ read: ['reader', 'owner'] }).
The word member is reserved for consent and grant for assignment, throughout:
await ws.members.invite(carolId, { role: 'writer' }) // crosses the boundary — consent
await ws.room(specsId).grants.set(carolId, 'writer') // inside it — immediate
Granting to someone who is not an active member of the resource is 409 not_resource_member;
the next move is the ordinary invite flow.
Room endpoints#
| Endpoint | Notes |
|---|---|
POST /v1/resources/:id/rooms | admin+; { name } |
GET /v1/resources/:id/rooms | rooms you hold, each with currentGrant; owners and admins also see rooms they hold nothing in, to administer |
GET /v1/resources/:id/rooms/:roomId | 404 if you hold no grant and are not an admin |
PATCH /v1/resources/:id/rooms/:roomId | admin+; rename |
DELETE /v1/resources/:id/rooms/:roomId | admin+; 409 room_not_empty while rows remain |
GET /v1/resources/:id/rooms/:roomId/grants | admin only — the room's ACL |
PUT /v1/resources/:id/rooms/:roomId/grants/:principal | idempotent upsert of { role } |
DELETE /v1/resources/:id/rooms/:roomId/grants/:principal | |
GET /v1/resources/grants | every room you hold, app-wide — the rooms.live() snapshot |
The grants list is admin-only for a reason that does not apply to a resource roster: in a resource the roster is mutually known, because everyone consented to be there together. In a room the roster is the sensitive fact — an ordinary member learns their own role and nothing more.
The default room cannot be renamed, deleted, or left; leaving it means leaving the
resource. Its roster is the member list, so its grants are read through GET /members.
Reading and writing in rooms#
ws.table('documents').select() // union: every room you can read, one cursor
ws.table('documents').insert({ … }) // the default room
ws.room(id).table('documents').select() // that room alone
ws.table('documents').moveRoom(ids, roomId) // "make this private", and its undo
A resource-scoped read spans every room whose effective role passes the read rule, in
one result set with one cursor — the sidebar query, with no client fan-out. Each row
carries a virtual roomId (null = the default room). A resource-scoped write targets
the default room: reads want the union, a write needs one destination, and the destination
people mean when they do not say is "visible to the resource".
moveRoom takes a list, so emptying a room or moving a document tree is one call and
one charge. It requires admin in the room the rows are in and write access in the
destination, so a move can never place a row somewhere you could not otherwise write. Room
placement is never a column: it cannot be written, filtered, ordered by, or projected.
Federated reads (tallpond.resources({ type })) span each resource's default room only.
Files follow the same rules. A resource bucket is partitioned by rooms exactly as its
tables are — union read, default-room write, moveRoom with the same authority, and a
404 (never a 403) for a file in a room you hold nothing in:
ws.files('photos').list() // union: every room you can read
ws.room(id).files('photos').upload(path, blob) // that room
ws.files('photos').moveRoom([path], roomId)
The exception is a linked file (.link()), which is a publication across resources
rather than a placement inside one. Mounts carry no room and behave as default-room
content. See files.md.
Error codes#
| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_room | Malformed, or belongs to another resource or deployment. |
| 400 | default_room_immutable | Rename, delete or leave attempted on the default room. |
| 403 | room_forbidden | No passing grant. |
| 403 | grant_ceiling | Assigning above your own effective role. |
| 409 | not_resource_member | Grant subject has not consented to the resource. |
| 409 | room_not_empty | Move or delete the rows and files first. |
Auto-join on a public write#
A resource with unlisted/discoverable visibility has no consent step to protect —
any user could already call /join at will. So the platform folds that call into the
first write instead of requiring it up front: the first insert/update/delete/link a
non-member makes against an unlisted or discoverable resource joins them at the
type's defaultRole, atomically with the write. It's exactly the /join path, just
triggered by data access instead of a separate call — idempotent, and a no-op if the
caller already has a pending invite/request (those still resolve through their own
explicit accept, so consent is never silently bypassed). Reads never trigger it, so a
non-member's read is simply denied — the public access value that used to admit
them was removed when rooms shipped.
members-visibility resources are unaffected: there is no implicit path to membership
there, by design.
Member removal & shared data (onMemberRemove)#
When a member leaves or is removed, each shared table applies its declared policy to that member's contributed rows in the resource:
| Policy | Effect |
|---|---|
remove (default) | The context links are dropped; rows no longer appear in the resource. The member's originals are untouched. |
hide | The links are soft-hidden; rows disappear from resource reads and reappear if the member rejoins. |
retain | Rows are copied into resource ownership (the resource bears at-rest cost from then on); the member's originals are untouched. |
hide and retain solve different problems and aren't a spectrum — pick by whether the
content must survive the member's departure permanently. hide never moves ownership,
so a rejoin cleanly restores the exact same rows with no merge question. retain copies
the rows because the resource needs to keep them even if the member never returns
(an audit trail, a collaborative doc) — at that point the member's copy and the
resource's copy are deliberately independent, so there's nothing to "give back" on
rejoin.
Account deletion (
onOwnerDelete) is the equivalent shipped lifecycle policy for when the user's account itself is deleted (as opposed to just leaving one resource) — the same three shapes (remove/tombstone/retain). Withtombstone, content stays in place but authorship resolves to the reserved, platform-defined deleted-user profile ({ id: null, displayName: "Deleted user" }, the[deleted]pattern). See users.md.
SDK#
const room = await tallpond.resource.create("room", { name: "General" });
await tallpond.resource.list({ type: "room" });
await tallpond.resource.browse({ type: "room" }); // { rows, nextCursor }
await tallpond.resource.static("feed", "global");
await tallpond.resource.invitations({ type: "room" }); // my pending invites
const r = tallpond.resource(room.id);
await r.members.invite("user_123", { role: "writer" });
await r.members.createInviteLink({ role: "writer" }); // { url, role, expiresAt }
await r.members.accept(); // accept my own invite
await r.members.join(); // public self-join
await r.members.setRole("user_123", "admin");
await r.members.remove("user_123");
await r.members.list();
// No r.delete() — see "Deleting a resource" above.
Realtime#
Two live feeds, both .on('insert' | 'update', handler) / .on('delete', handler)
same as any other .live() — see realtime.md. Rows are
{ id, resourceId, userId, role, state }; id is a synthetic resourceId:userId key,
not a server column, needed because membership has no single-column primary key.
// This resource's roster, live — visible to active members (same gate as members.list()).
const roster = r.members.live();
roster.on("insert", (m) => console.log("joined", m.userId));
roster.on("update", (m) => console.log("role/state changed", m.userId, m.role));
roster.on("delete", ({ id }) => console.log("left", id));
// Your own membership across every resource in the app — this is the feed that
// answers "was I just invited?" It fires on a new invite, an accepted request, a
// role change, or being removed, from any device.
const mine = tallpond.resources.live();
mine.on("insert", (m) => console.log("new invite or request", m.resourceId, m.state));
Both feeds carry only the four columns above — no resource name, no inviter, no
agent/profile join, because the gateway publishes straight off the write with no
extra query. Call members.list() / resource.invitations() again on any event for
the richer display data; .live() is the signal to do that, not a replacement for it.
See sdk.md.