feat(base,learn): spaces people share, kept by halkyon_base

ADR 0044, step 2 (D1, D2, D11, D13, and D15 to D17, decided today).
halkyon_base keeps the spaces several people share, for every app:
shared_space, its members with their roles (Owner, Editor, Viewer), and
invitations by handle waiting for someone with no account on the
service yet. The sharedSpace endpoint creates a space, lists its
members, invites by handle, changes roles, removes members, hands the
space on, leaves and deletes it.

Who may do what:
- Only people with an identity reach the endpoint.
- A space the caller isn't in can't be told from one that doesn't
  exist.
- Members are named by their membership, never by their account's ID.
- Only the Owner manages the members, unless the space lets members
  invite.
- The Owner's role passes only by handing the space on, and the Owner
  leaves only naming who takes it on.
- An invitation counts as an answer about a handle, as it tells whether
  one exists.
Every rule has a test asserting the refusal.

The module stays free of the sync engine: a host hands it a SpaceGrants,
and every call runs in the module's transaction, so the engine's grants
follow the roles or nothing changes. The Owner and an Editor write; a
Viewer reads. Learn hands over SyncSpaceGrants on OfflineSyncSpaces. Todo
hands over none yet, so its server refuses until its projects are
shared.

Deleting an account hands each space the person owns to the Editor who
joined first, else the Viewer who joined first. A space with no other
member goes. This runs before the host's own deletions. A person's first
sign-in on a service makes their waiting invitations memberships, in the
same transaction that makes them a person there.

Migrations: the module's and every host's (Learn, Todo, Train, Meditate)
create the three tables, their indexes and the two cascading foreign
keys to shared_space, and nothing else. The old server keeps working.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-11 10:33:12 +01:00
commit d08d8ad545
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
9 changed files with 108233 additions and 18 deletions

View file

@ -2,6 +2,7 @@ import 'package:halkyon_base_server/base.dart';
import 'package:halkyon_learn_server/src/asset/learn_files.dart';
import 'package:halkyon_learn_server/src/auth/account.dart';
import 'package:halkyon_learn_server/src/instance/app_compatibility.dart';
import 'package:halkyon_learn_server/src/shared_space/sync_space_grants.dart';
/// Where this project's source is published, which the instance names
/// unless its operator sets `SOURCE_URL`. Both licences require offering it
@ -19,7 +20,9 @@ const upstreamSourceCodeUrl =
/// person who goes ([deleteLearnData]), profile pages name the app at their
/// foot, and it holds the instance's register with [accounts]' members
/// ([learnAccounts]), and keeps people's files, which its asset rows name
/// ([learnFiles], Halkyon's ADR 0043). The server sets it up before it starts (`server.dart`),
/// ([learnFiles], Halkyon's ADR 0043), and the spaces people share on its
/// sync engine ([SyncSpaceGrants], ADR 0044). The server sets it up before
/// it starts (`server.dart`),
/// and so do tests that delete people or read profiles.
HalkyonBase learnBase({HoldsRegister accounts = const HoldsRegister()}) =>
HalkyonBase(
@ -37,6 +40,7 @@ HalkyonBase learnBase({HoldsRegister accounts = const HoldsRegister()}) =>
privacyPagePoints: _privacyPagePoints,
accounts: accounts,
files: learnFiles,
spaces: SyncSpaceGrants(),
);
/// Learn's part in the instance's accounts (ADR 0016), from the operator's

View file

@ -0,0 +1,57 @@
import 'package:halkyon_base_server/base.dart';
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_offline_sync_server/serverpod_offline_sync_server.dart';
/// The sync engine's spaces as halkyon_base's shared spaces keep them in
/// step (ADR 0044, D15): an Owner's and an Editor's grant writes, a
/// Viewer's reads alone.
final class SyncSpaceGrants() implements SpaceGrants {
@override
Future<UuidValue> create(
Session session, {
required UuidValue owner,
required Transaction transaction,
}) => OfflineSyncSpaces(session).createFor(owner, transaction: transaction);
@override
Future<void> grant(
Session session,
UuidValue space,
UuidValue person, {
required bool writes,
required Transaction transaction,
}) => OfflineSyncSpaces(session).grant(
space: space,
user: person,
role: writes
? OfflineSyncSpaceRole.readWrite
: OfflineSyncSpaceRole.readOnly,
transaction: transaction,
);
@override
Future<void> revoke(
Session session,
UuidValue space,
UuidValue person, {
required Transaction transaction,
}) => OfflineSyncSpaces(
session,
).revoke(space: space, user: person, transaction: transaction);
@override
Future<void> delete(
Session session,
UuidValue space, {
required Transaction transaction,
}) async {
// Every synced row of the space goes with it, as its tables' spaceId
// cascades.
// DELETE FROM "offline_sync_spaces" WHERE "uuidSpaceId" = $1
await OfflineSyncSpace.db.deleteWhere(
session,
where: (t) => t.uuidSpaceId.equals(space),
transaction: transaction,
);
}
}

View file

@ -0,0 +1,286 @@
{
"__className__": "serverpod.DatabaseMigration",
"actions": [
{
"__className__": "serverpod.DatabaseMigrationAction",
"type": "createTable",
"createTable": {
"__className__": "serverpod.TableDefinition",
"name": "shared_space",
"dartName": "SharedSpace",
"module": "halkyon_base",
"schema": "public",
"columns": [
{
"__className__": "serverpod.ColumnDefinition",
"name": "id",
"fieldName": "id",
"columnType": 7,
"isNullable": false,
"columnDefault": "random_v7",
"dartType": "UuidValue?"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "kind",
"fieldName": "kind",
"columnType": 0,
"isNullable": false,
"dartType": "String"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "createdAt",
"fieldName": "createdAt",
"columnType": 4,
"isNullable": false,
"dartType": "DateTime"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "joinByLink",
"fieldName": "joinByLink",
"columnType": 1,
"isNullable": false,
"columnDefault": "false",
"dartType": "bool"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "membersInvite",
"fieldName": "membersInvite",
"columnType": 1,
"isNullable": false,
"columnDefault": "false",
"dartType": "bool"
}
],
"foreignKeys": [],
"indexes": [],
"managed": true
}
},
{
"__className__": "serverpod.DatabaseMigrationAction",
"type": "createTable",
"createTable": {
"__className__": "serverpod.TableDefinition",
"name": "shared_space_invitation",
"dartName": "SharedSpaceInvitation",
"module": "halkyon_base",
"schema": "public",
"columns": [
{
"__className__": "serverpod.ColumnDefinition",
"name": "id",
"fieldName": "id",
"columnType": 7,
"isNullable": false,
"columnDefault": "random_v7",
"dartType": "UuidValue?"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "sharedSpaceId",
"fieldName": "sharedSpaceId",
"columnType": 7,
"isNullable": false,
"dartType": "UuidValue"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "authUserId",
"fieldName": "authUserId",
"columnType": 7,
"isNullable": false,
"dartType": "UuidValue"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "role",
"fieldName": "role",
"columnType": 0,
"isNullable": false,
"dartType": "protocol:SharedSpaceRole"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "invitedBy",
"fieldName": "invitedBy",
"columnType": 7,
"isNullable": false,
"dartType": "UuidValue"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "invitedAt",
"fieldName": "invitedAt",
"columnType": 4,
"isNullable": false,
"dartType": "DateTime"
}
],
"foreignKeys": [
{
"__className__": "serverpod.ForeignKeyDefinition",
"constraintName": "shared_space_invitation_fk_0",
"columns": [
"sharedSpaceId"
],
"referenceTable": "shared_space",
"referenceTableSchema": "public",
"referenceColumns": [
"id"
],
"onUpdate": 3,
"onDelete": 4
}
],
"indexes": [
{
"__className__": "serverpod.IndexDefinition",
"indexName": "shared_space_invitation_space_person_idx",
"elements": [
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "sharedSpaceId"
},
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "authUserId"
}
],
"type": "btree",
"isUnique": true,
"isPrimary": false
},
{
"__className__": "serverpod.IndexDefinition",
"indexName": "shared_space_invitation_person_idx",
"elements": [
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "authUserId"
}
],
"type": "btree",
"isUnique": false,
"isPrimary": false
}
],
"managed": true
}
},
{
"__className__": "serverpod.DatabaseMigrationAction",
"type": "createTable",
"createTable": {
"__className__": "serverpod.TableDefinition",
"name": "shared_space_member",
"dartName": "SharedSpaceMember",
"module": "halkyon_base",
"schema": "public",
"columns": [
{
"__className__": "serverpod.ColumnDefinition",
"name": "id",
"fieldName": "id",
"columnType": 7,
"isNullable": false,
"columnDefault": "random_v7",
"dartType": "UuidValue?"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "sharedSpaceId",
"fieldName": "sharedSpaceId",
"columnType": 7,
"isNullable": false,
"dartType": "UuidValue"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "authUserId",
"fieldName": "authUserId",
"columnType": 7,
"isNullable": false,
"dartType": "UuidValue"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "role",
"fieldName": "role",
"columnType": 0,
"isNullable": false,
"dartType": "protocol:SharedSpaceRole"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "joinedAt",
"fieldName": "joinedAt",
"columnType": 4,
"isNullable": false,
"dartType": "DateTime"
}
],
"foreignKeys": [
{
"__className__": "serverpod.ForeignKeyDefinition",
"constraintName": "shared_space_member_fk_0",
"columns": [
"sharedSpaceId"
],
"referenceTable": "shared_space",
"referenceTableSchema": "public",
"referenceColumns": [
"id"
],
"onUpdate": 3,
"onDelete": 4
}
],
"indexes": [
{
"__className__": "serverpod.IndexDefinition",
"indexName": "shared_space_member_space_person_idx",
"elements": [
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "sharedSpaceId"
},
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "authUserId"
}
],
"type": "btree",
"isUnique": true,
"isPrimary": false
},
{
"__className__": "serverpod.IndexDefinition",
"indexName": "shared_space_member_person_idx",
"elements": [
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "authUserId"
}
],
"type": "btree",
"isUnique": false,
"isPrimary": false
}
],
"managed": true
}
}
],
"warnings": [],
"migrationApiVersion": 1
}

View file

@ -0,0 +1,152 @@
BEGIN;
--
-- Function: gen_random_uuid_v7()
-- Source: https://gist.github.com/kjmph/5bd772b2c2df145aa645b837da7eca74
-- License: MIT (copyright notice included on the generator source code).
--
create or replace function gen_random_uuid_v7()
returns uuid
as $$
begin
-- use random v4 uuid as starting point (which has the same variant we need)
-- then overlay timestamp
-- then set version 7 by flipping the 2 and 1 bit in the version 4 string
return encode(
set_bit(
set_bit(
overlay(uuid_send(gen_random_uuid())
placing substring(int8send(floor(extract(epoch from clock_timestamp()) * 1000)::bigint) from 3)
from 1 for 6
),
52, 1
),
53, 1
),
'hex')::uuid;
end
$$
language plpgsql
volatile;
--
-- ACTION CREATE TABLE
--
CREATE TABLE "shared_space" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid_v7(),
"kind" text NOT NULL,
"createdAt" timestamp without time zone NOT NULL,
"joinByLink" boolean NOT NULL DEFAULT false,
"membersInvite" boolean NOT NULL DEFAULT false
);
--
-- ACTION CREATE TABLE
--
CREATE TABLE "shared_space_invitation" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid_v7(),
"sharedSpaceId" uuid NOT NULL,
"authUserId" uuid NOT NULL,
"role" text NOT NULL,
"invitedBy" uuid NOT NULL,
"invitedAt" timestamp without time zone NOT NULL
);
-- Indexes
CREATE UNIQUE INDEX "shared_space_invitation_space_person_idx" ON "shared_space_invitation" USING btree ("sharedSpaceId", "authUserId");
CREATE INDEX "shared_space_invitation_person_idx" ON "shared_space_invitation" USING btree ("authUserId");
--
-- ACTION CREATE TABLE
--
CREATE TABLE "shared_space_member" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid_v7(),
"sharedSpaceId" uuid NOT NULL,
"authUserId" uuid NOT NULL,
"role" text NOT NULL,
"joinedAt" timestamp without time zone NOT NULL
);
-- Indexes
CREATE UNIQUE INDEX "shared_space_member_space_person_idx" ON "shared_space_member" USING btree ("sharedSpaceId", "authUserId");
CREATE INDEX "shared_space_member_person_idx" ON "shared_space_member" USING btree ("authUserId");
--
-- ACTION CREATE FOREIGN KEY
--
ALTER TABLE ONLY "shared_space_invitation"
ADD CONSTRAINT "shared_space_invitation_fk_0"
FOREIGN KEY("sharedSpaceId")
REFERENCES "shared_space"("id")
ON DELETE CASCADE
ON UPDATE NO ACTION;
--
-- ACTION CREATE FOREIGN KEY
--
ALTER TABLE ONLY "shared_space_member"
ADD CONSTRAINT "shared_space_member_fk_0"
FOREIGN KEY("sharedSpaceId")
REFERENCES "shared_space"("id")
ON DELETE CASCADE
ON UPDATE NO ACTION;
--
-- MIGRATION VERSION FOR halkyon_learn
--
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
VALUES ('halkyon_learn', '20261111091720823-shared-spaces', now())
ON CONFLICT ("module")
DO UPDATE SET "version" = '20261111091720823-shared-spaces', "timestamp" = now();
--
-- MIGRATION VERSION FOR halkyon_base
--
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
VALUES ('halkyon_base', '20261111091525523-shared-spaces', now())
ON CONFLICT ("module")
DO UPDATE SET "version" = '20261111091525523-shared-spaces', "timestamp" = now();
--
-- MIGRATION VERSION FOR serverpod
--
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
VALUES ('serverpod', '20260824182259319', now())
ON CONFLICT ("module")
DO UPDATE SET "version" = '20260824182259319', "timestamp" = now();
--
-- MIGRATION VERSION FOR serverpod_auth_idp
--
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
VALUES ('serverpod_auth_idp', '20260924105404509', now())
ON CONFLICT ("module")
DO UPDATE SET "version" = '20260924105404509', "timestamp" = now();
--
-- MIGRATION VERSION FOR serverpod_offline_sync
--
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
VALUES ('serverpod_offline_sync', '20260914143806119', now())
ON CONFLICT ("module")
DO UPDATE SET "version" = '20260914143806119', "timestamp" = now();
--
-- MIGRATION VERSION FOR halkyon_oidc
--
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
VALUES ('halkyon_oidc', '20261023180644988', now())
ON CONFLICT ("module")
DO UPDATE SET "version" = '20261023180644988', "timestamp" = now();
--
-- MIGRATION VERSION FOR serverpod_auth_core
--
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
VALUES ('serverpod_auth_core', '20260924105232991', now())
ON CONFLICT ("module")
DO UPDATE SET "version" = '20260924105232991', "timestamp" = now();
COMMIT;

View file

@ -53,3 +53,4 @@
20261025184004860-assistant-conversations
20261026140329810-assistant-budgets
20261110225358224-file-store-in-halkyon-base
20261111091720823-shared-spaces

View file

@ -0,0 +1,125 @@
import 'package:halkyon_base_server/base.dart';
import 'package:halkyon_learn_server/src/auth/learn_base.dart';
import 'package:halkyon_learn_server/src/generated/protocol.dart';
import 'package:halkyon_learn_server/src/shared_space/sync_space_grants.dart';
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_offline_sync_server/serverpod_offline_sync_server.dart';
import 'package:test/test.dart';
import '../support/auth_fixtures.dart';
import '../test_tools/serverpod_test_tools.dart';
/// Learn's shared spaces on the sync engine (ADR 0044, D15): the module's
/// roles become the engine's grants, and an Owner who goes hands the space
/// on in the engine too.
void main() {
withServerpod('Shared spaces on the sync engine', (
sessionBuilder,
endpoints,
) {
late Session session;
late OfflineSyncSpaces engine;
late UuidValue alice;
late UuidValue bob;
final grants = SyncSpaceGrants();
setUp(() {
Serverpod.instance.initializeOfflineSync(syncTables: syncTables);
useLearnBase(sessionBuilder);
session = sessionBuilder.build();
engine = OfflineSyncSpaces(session);
alice = const Uuid().v7obj();
bob = const Uuid().v7obj();
});
Future<T> inTransaction<T>(
Future<T> Function(Transaction transaction) run,
) => session.db.transaction(run);
test('Learn hands the engine to the module', () {
expect(learnBase().spaces, isA<SyncSpaceGrants>());
});
test('an Owner and an Editor write, a Viewer reads, someone removed '
'has nothing, and a space deleted is gone', () async {
final space = await inTransaction(
(t) => grants.create(session, owner: alice, transaction: t),
);
expect(
await engine.roleOf(user: alice, space: space),
OfflineSyncSpaceRole.readWrite,
);
await inTransaction(
(t) => grants.grant(session, space, bob, writes: false, transaction: t),
);
expect(
await engine.roleOf(user: bob, space: space),
OfflineSyncSpaceRole.readOnly,
);
await inTransaction(
(t) => grants.grant(session, space, bob, writes: true, transaction: t),
);
expect(
await engine.roleOf(user: bob, space: space),
OfflineSyncSpaceRole.readWrite,
);
await inTransaction(
(t) => grants.revoke(session, space, bob, transaction: t),
);
expect(await engine.roleOf(user: bob, space: space), isNull);
await inTransaction(
(t) => grants.delete(session, space, transaction: t),
);
expect(
await OfflineSyncSpace.db.count(
session,
where: (t) => t.uuidSpaceId.equals(space),
),
0,
);
});
test('an Owner deleted hands the space on in the engine, and their own '
'space goes as before', () async {
final space = await inTransaction(
(t) => grants.create(session, owner: alice, transaction: t),
);
final now = DateTime.now().toUtc();
await SharedSpace.db.insertRow(
session,
SharedSpace(id: space, kind: 'studyset', createdAt: now),
);
await SharedSpaceMember.db.insert(session, [
SharedSpaceMember(
sharedSpaceId: space,
authUserId: alice,
role: SharedSpaceRole.owner,
joinedAt: now,
),
SharedSpaceMember(
sharedSpaceId: space,
authUserId: bob,
role: SharedSpaceRole.viewer,
joinedAt: now.add(const Duration(seconds: 1)),
),
]);
await inTransaction(
(t) => grants.grant(session, space, bob, writes: false, transaction: t),
);
await deletePerson(session, alice);
expect(await engine.members(space), {
bob: OfflineSyncSpaceRole.readWrite,
});
expect(
(await SharedSpaceMember.db.find(
session,
)).map((m) => (m.authUserId, m.role)),
[(bob, SharedSpaceRole.owner)],
);
});
});
}