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.
- 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" } → 200 resource view. The caller
becomes the owner (active). Errors: 403 reserved type, 404 unknown type.
GET /v1/resources?type=room — my resources#
Resources the caller is an active member of (optionally filtered by type).
→ { "resources": [ ResourceView, … ] }
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).
DELETE /v1/resources/:id — delete (owner only)#
Cascades: 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. → { "ok": true }
Resource view
{
"id": "…",
"type": "room",
"name": "General",
"visibility": "public",
"discoverable": true,
"memberCount": 4
}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, or a self-serve join on a public resource.
GET /v1/resources/:id/members#
Active-and-pending roster, visible to members. → { "members": [ { userId, role, state }, … ] }
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" }
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), or an admin accepts a pending
request by userId. → { "ok": true, "state": "active" }
Body { "userId"?: "…" }
POST /v1/resources/:id/reject#
Decline your own invite (empty body), 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 a member's role; can't touch the owner or a peer/superior, and can't
assign above the grant ceiling. Body { "userId": "…", "role": "admin" } →
{ "ok": true, "role": "admin" }
POST /v1/resources/:id/remove#
Admin+ removes a member (not the owner or a peer/superior). Applies each shared table's
onMemberRemove policy. Body { "userId": "…" } → { "ok": true }
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 — a
public read rule needs no membership to begin with.
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 lifecycle policy for when the user's account itself is deleted (as opposed to just leaving one resource) — same three shapes (remove/tombstone/retain), buttombstoneis unique to this case: content stays in place, but authorship resolves to a reserved, platform-defined deleted-user profile ({ id: null, displayName: "Deleted user" }, the[deleted]pattern) instead of vanishing. Designed but not yet implemented. See not-yet-implemented.md.
SDK#
const room = await osgarden.resource.create("room", { name: "General" });
await osgarden.resource.list({ type: "room" });
await osgarden.resource.browse({ type: "room" }); // { rows, nextCursor }
await osgarden.resource.static("feed", "global");
const r = osgarden.resource(room.id);
await r.members.invite("user_123", { role: "writer" });
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();
await r.delete();See sdk.md.