refactor!: Rename sync APIs and ownership spaces (#128)

This commit is contained in:
Marcelo Mendonça Soares 2026-09-14 13:39:07 -03:00 • committed by GitHub
commit b89725f6ea
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -1,215 +0,0 @@
# Scope management API
Status: implemented. Builds on the shared-scope
membership model in `shared-scopes.md`; this document specifies the developer-
facing API for *managing* membership while leaving invitations, acceptance, and
authorization policy in app domain.
## Summary
The shared-scope machinery is complete for **resolving and enforcing**
membership (`CrdtScopeMembership.memberScopes` / `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.crdt.scopes` service: creating a shared scope, adding a
member with a role, changing a role, revoking, and listing explicit members.
`shared-scopes.md` records the same boundary: rows are managed through
`session.crdt.scopes`, while invitations, acceptance, and authorization policy
remain app domain.
The service avoids making developers replicate what `scope_membership_test.dart`
used to do — two non-atomic raw `insertRow` calls against the **server**
package's generated `CrdtScope` / `CrdtScopeMember`, leaking every internal
invariant (`scopeId` vs `uuidScopeId`, the non-null `role` contract, the unique
index, personal-scope-UUID == user-UUID):
```dart
final scopeId = await session.crdt.scopes.createFor(creatorUuid);
await session.crdt.scopes.grant(scope: scopeId, user: bob, role: CrdtScopeRole.readWrite);
await session.crdt.scopes.revoke(scope: scopeId, 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-scopes.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 scope 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:
- **`by` is forgeable, so it is not enforcement.** It is a `UuidValue` the
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 (`crdt_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:
```dart
class TeamEndpoint extends Endpoint {
Future<void> invite(Session s, UuidValue scope, UuidValue user, CrdtScopeRole role) async {
final caller = UuidValue.withValidation(s.authenticated!.userIdentifier); // unforgeable
if (!myApp.mayManage(caller, scope)) throw ...; // app policy
await s.crdt.scopes.grant(scope: scope, user: user, role: role);
}
}
```
### No `admin` role in the package
The discussion considered an `admin` tier in `CrdtScopeRole` so "any admin can
grant". It was rejected on the **enforce-vs-define** line: *the package should
own only what it enforces.*
- `readOnly` / `readWrite` are **enforced by the sync engine against untrusted
clients** (the server rejects an unauthorized stream write and records
`unauthorizedWrite`). This check cannot live in the app; it must be in the
package.
- `admin` is **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 own
`Scope` mechanism (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. `CrdtScopeRole` 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 `Scope` on the auth
user for global staff, or their own domain data for per-scope 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** (`crdt_scope_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 `scopes.grant` — the same split Serverpod has between
`AuthUser` and an app's user profile.
## API
Server-side, hung off the `session.crdt` accessor (the session-bound
`CrdtSession` facade) as `session.crdt.scopes`. A pure data layer: atomic,
transaction-threaded, no internal auth.
```dart
/// Creates a shared scope and returns its UUID.
///
/// [grants], if given, adds each user as a member with its role, applied in
/// one transaction with the scope insert. With no grants the scope is created
/// dormant — it exists but no one can sync it until membership is granted.
Future<UuidValue> create({
Map<UuidValue, CrdtScopeRole>? grants,
Transaction? transaction,
});
/// Creates a shared scope 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 scope and can be re-granted or revoked like any other.
Future<UuidValue> createFor(
UuidValue user, {
CrdtScopeRole role = CrdtScopeRole.readWrite,
Transaction? transaction,
});
/// Grants [user] the given [role] in [scope], upserting an existing membership.
Future<void> grant({
required UuidValue scope,
required UuidValue user,
required CrdtScopeRole role,
Transaction? transaction,
});
/// Grants several members in one transaction; the bulk twin of [grant].
Future<void> grantAll(
UuidValue scope,
Map<UuidValue, CrdtScopeRole> grants, {
Transaction? transaction,
});
/// Removes [user]'s membership in [scope]. A no-op if not a member.
Future<void> revoke({
required UuidValue scope,
required UuidValue user,
Transaction? transaction,
});
/// The members of [scope] and their roles.
Future<Map<UuidValue, CrdtScopeRole>> members(
UuidValue scope, {
Transaction? transaction,
});
```
Notes:
- **There is no "owner".** An earlier sketch had an `owner` parameter on
`create`, but the name implied a durable bond the data model does not have —
the scope row stores no owner, and the creator ends up an ordinary
`readWrite` member. It was replaced by `createFor`, which is honest sugar:
create a scope and grant one member, atomically, with no special standing.
Removing `owner` also dissolved the owner-appears-in-`grants` `ArgumentError`
special case. A durable creator/owner concept is app domain, like `admin`.
- **`grants` may be empty or absent** — a scope with no members is valid but
dormant. `create()` skips the membership insert entirely.
- **`create` and `grantAll` share one internal helper** so the create path and
the later bulk-grant path have a single implementation.
- **Unknown-scope grants throw `CrdtScopeNotFoundException`** rather than a
generic argument error, so callers can distinguish a stale or invalid scope
UUID from other failures.
- **`create` returns a server-generated UUID** — the model already defaults
`uuidScopeId` to `random_v7`. An optional explicit `uuidScopeId` param
(app-chosen or idempotent create) is a compatible later addition; left out
until there is a real need.
- Membership writes are plain `crdt_scope_members` / `crdt_scopes` rows — **not**
CRDT-tracked, not part of the sync loop. Changes propagate to clients on the
next sync cycle through the existing `ScopeSet` re-announcement path.
## Client side
Unchanged. Management is server-only; the client never writes membership. A
client learns of new scopes and role changes purely through the sync `ScopeSet`
announcement and the follower projection already implemented in
`shared-scopes.md`. There is no client `create`/`grant`/`revoke`.
## Test plan
- **Create.** `createFor(u)` yields a scope with `u` at `readWrite` and a
member-resolvable scope; `createFor(u, role: ...)` stores the explicit role;
`create()` (no grants) yields a dormant scope no user can sync;
`create(grants: {...})` stores every entry with its role.
- **Grant/revoke.** `grant` inserts and upserts (role change); `revoke` removes
and is a no-op for a non-member; `grantAll` applies a map atomically.
- **Reads.** `members` returns 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 scope on the next cycle (covered by the existing access-change
suite); after `revoke`, it stops cycling it.
- **Transaction threading.** A `create` folded into an app domain transaction
rolls back atomically with the app write on failure.