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#

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

json
{
  "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:

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):

QuestionMechanism
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 rulest.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:

ts
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#

EndpointNotes
POST /v1/resources/:id/roomsadmin+; { name }
GET /v1/resources/:id/roomsrooms you hold, each with currentGrant; owners and admins also see rooms they hold nothing in, to administer
GET /v1/resources/:id/rooms/:roomId404 if you hold no grant and are not an admin
PATCH /v1/resources/:id/rooms/:roomIdadmin+; rename
DELETE /v1/resources/:id/rooms/:roomIdadmin+; 409 room_not_empty while rows remain
GET /v1/resources/:id/rooms/:roomId/grantsadmin only — the room's ACL
PUT /v1/resources/:id/rooms/:roomId/grants/:principalidempotent upsert of { role }
DELETE /v1/resources/:id/rooms/:roomId/grants/:principal
GET /v1/resources/grantsevery 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#

ts
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:

ts
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#

StatusCodeMeaning
400invalid_roomMalformed, or belongs to another resource or deployment.
400default_room_immutableRename, delete or leave attempted on the default room.
403room_forbiddenNo passing grant.
403grant_ceilingAssigning above your own effective role.
409not_resource_memberGrant subject has not consented to the resource.
409room_not_emptyMove 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:

PolicyEffect
remove (default)The context links are dropped; rows no longer appear in the resource. The member's originals are untouched.
hideThe links are soft-hidden; rows disappear from resource reads and reappear if the member rejoins.
retainRows 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). With tombstone, 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#

ts
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.

ts
// 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.