10 KiB
Space management API
Status: implemented. Builds on the shared-space
membership model in shared-spaces.md; this document specifies the developer-
facing API for managing membership while leaving invitations, acceptance, and
authorization policy in app domain.
Summary
The shared-space machinery is complete for resolving and enforcing
membership (OfflineSyncSpaceMembership.memberSpaces / memberGrants / roleOf /
isMember, the sync-loop enforcement, membership-wide reads), but those helpers
are package internals. The developer-facing API for managing membership is
the server-side session.offlineSync.spaces service: creating a shared space, adding a
member with a role, changing a role, revoking, and listing explicit members.
shared-spaces.md records the same boundary: rows are managed through
session.offlineSync.spaces, while invitations, acceptance, and authorization policy
remain app domain.
The service avoids making developers replicate what space_membership_test.dart
used to do — two non-atomic raw insertRow calls against the server
package's generated OfflineSyncSpace / OfflineSyncSpaceMember, leaking every internal
invariant (spaceId vs uuidSpaceId, the non-null role contract, the unique
index, personal-space-UUID == user-UUID):
final spaceId = await session.offlineSync.spaces.createFor(creatorUuid);
await session.offlineSync.spaces.grant(space: spaceId, user: bob, role: OfflineSyncSpaceRole.readWrite);
await session.offlineSync.spaces.revoke(space: spaceId, user: bob);
Design decisions
These were settled in discussion; the reasoning is recorded so the implementation issue does not relitigate them.
A service, not an endpoint (the AuthUsers model)
The load-bearing constraint is shared-spaces.md #4: membership cannot be
forged; the server never widens access from client-reported state. If the
package auto-generated a management endpoint, any logged-in client could
self-grant into any space and break that.
So the package ships a server-side business service the developer calls from
their own endpoint, after applying their own authorization policy. This
is exactly Serverpod's AuthUsers: AuthUsers.create(session, ...) is a
capability, not a policy — it does not check "may the caller create a user".
The sync path enforces roles internally only because it is directly reachable
by untrusted clients; the management service is server-only (no endpoint), so
its only caller is the developer's own server code.
The package owns the data invariants; the app owns the authorization policy. That is the same division that makes invitations/acceptance app domain.
No authorization check inside the service
An earlier sketch gated writes with a by: (acting-user) parameter checked
against an admin role. This was dropped:
byis forgeable, so it is not enforcement. It is aUuidValuethe caller supplies; the service cannot tell a real identity from a typed-in admin UUID. It prevents a bug, not an attacker — and only if someone upstream binds it to the true identity, which is the endpoint's job.- The unforgeable identity is
session.authenticated— the same source the sync endpoint already trusts (offline_sync_endpoint.dart). It lives at the endpoint, which is where the app's policy lives anyway. - The internal check is what forced a system/backend bypass; removing the check dissolves the bypass.
The service therefore performs no internal authorization. Authorization is 100% the endpoint's job, where the authenticated caller is unforgeable:
class TeamEndpoint extends Endpoint {
Future<void> invite(Session s, UuidValue space, UuidValue user, OfflineSyncSpaceRole role) async {
final caller = UuidValue.withValidation(s.authenticated!.userIdentifier); // unforgeable
if (!myApp.mayManage(caller, space)) throw ...; // app policy
await s.offlineSync.spaces.grant(space: space, user: user, role: role);
}
}
No admin role in the package
The discussion considered an admin tier in OfflineSyncSpaceRole so "any admin can
grant". It was rejected on the enforce-vs-define line: the package should
own only what it enforces.
readOnly/readWriteare enforced by the sync engine against untrusted clients (the server rejects an unauthorized stream write and recordsunauthorizedWrite). This check cannot live in the app; it must be in the package.adminis never consulted by the engine — nothing in the sync path, merge, or read filter asks "is this user an admin". It would be a permission the package defines but never acts on, and it overlaps with Serverpod's ownSpacemechanism (a second permission system the app must reconcile).
Everything uneasy traced back to admin: the forgeable by, the bypass, and a
"last-admin" orphan guard all evaporate once it leaves. OfflineSyncSpaceRole stays
{ readOnly, readWrite } — exactly what is already implemented — so this
implementation adds no enum churn.
Apps model "admin" with what they already have: a Serverpod Space on the auth
user for global staff, or their own domain data for per-space owners. If the
app's TeamMember { teamId, userId, isAdmin } is itself a synced CRDT table, an
"am I an admin" UI works offline for free. The two membership layers stay
separate: access-membership (offline_sync_space_members, server-authoritative,
package-enforced: who may sync/read/write) vs domain-membership (app data:
who is an admin, display name, joined-at). The app keeps them in step — adding a
domain member also calls spaces.grant — the same split Serverpod has between
AuthUser and an app's user profile.
API
Server-side, hung off the session.offlineSync accessor (the session-bound
OfflineSyncSession facade) as session.offlineSync.spaces. A pure data layer: atomic,
transaction-threaded, no internal auth.
/// Creates a shared space and returns its UUID.
///
/// [grants], if given, adds each user as a member with its role, applied in
/// one transaction with the space insert. With no grants the space is created
/// dormant — it exists but no one can sync it until membership is granted.
Future<UuidValue> create({
Map<UuidValue, OfflineSyncSpaceRole>? grants,
Transaction? transaction,
});
/// Creates a shared space granting [user] the given [role], in one
/// transaction.
///
/// Pure sugar for [create] with a single-entry grants map — the member has no
/// special bond to the space and can be re-granted or revoked like any other.
Future<UuidValue> createFor(
UuidValue user, {
OfflineSyncSpaceRole role = OfflineSyncSpaceRole.readWrite,
Transaction? transaction,
});
/// Grants [user] the given [role] in [space], upserting an existing membership.
Future<void> grant({
required UuidValue space,
required UuidValue user,
required OfflineSyncSpaceRole role,
Transaction? transaction,
});
/// Grants several members in one transaction; the bulk twin of [grant].
Future<void> grantAll(
UuidValue space,
Map<UuidValue, OfflineSyncSpaceRole> grants, {
Transaction? transaction,
});
/// Removes [user]'s membership in [space]. A no-op if not a member.
Future<void> revoke({
required UuidValue space,
required UuidValue user,
Transaction? transaction,
});
/// The members of [space] and their roles.
Future<Map<UuidValue, OfflineSyncSpaceRole>> members(
UuidValue space, {
Transaction? transaction,
});
Notes:
- There is no "owner". An earlier sketch had an
ownerparameter oncreate, but the name implied a durable bond the data model does not have — the space row stores no owner, and the creator ends up an ordinaryreadWritemember. It was replaced bycreateFor, which is honest sugar: create a space and grant one member, atomically, with no special standing. Removingowneralso dissolved the owner-appears-in-grantsArgumentErrorspecial case. A durable creator/owner concept is app domain, likeadmin. grantsmay be empty or absent — a space with no members is valid but dormant.create()skips the membership insert entirely.createandgrantAllshare one internal helper so the create path and the later bulk-grant path have a single implementation.- Unknown-space grants throw
OfflineSyncSpaceNotFoundExceptionrather than a generic argument error, so callers can distinguish a stale or invalid space UUID from other failures. createreturns a server-generated UUID — the model already defaultsuuidSpaceIdtorandom_v7. An optional explicituuidSpaceIdparam (app-chosen or idempotent create) is a compatible later addition; left out until there is a real need.- Membership writes are plain
offline_sync_space_members/offline_sync_spacesrows — not CRDT-tracked, not part of the sync loop. Changes propagate to clients on the next sync cycle through the existingSpaceSetre-announcement path.
Client side
Unchanged. Management is server-only; the client never writes membership. A
client learns of new spaces and role changes purely through the sync SpaceSet
announcement and the follower projection already implemented in
shared-spaces.md. There is no client create/grant/revoke.
Test plan
- Create.
createFor(u)yields a space withuatreadWriteand a member-resolvable space;createFor(u, role: ...)stores the explicit role;create()(no grants) yields a dormant space no user can sync;create(grants: {...})stores every entry with its role. - Grant/revoke.
grantinserts and upserts (role change);revokeremoves and is a no-op for a non-member;grantAllapplies a map atomically. - Reads.
membersreturns the explicit member-role map. Internal role resolution still covers stored, missing, and implicit personal membership. - Propagation. After a server-side
grant, a continuous follower session adopts the new space on the next cycle (covered by the existing access-change suite); afterrevoke, it stops cycling it. - Transaction threading. A
createfolded into an app domain transaction rolls back atomically with the app write on failure.