feat(base,app,learn,todo,train,meditate): agent accounts with tokens

ADR 0044's step 6a (D42, D43, D45): accounts for programs, such as AI
assistants, that an identified person makes from the Profile.

halkyon_base keeps them on the instance's register, which its members
ask: an agent is an account (agent_account: its owner, its name, its
public keys, set by itself once) with one token (agent_token: 32
random bytes, kept as their SHA-256 alone, shown once). agents.create,
mine, newToken, revoke, delete and setKeys serve its owner;
agentSignIn.signIn exchanges the token for an ordinary server-side
session of a day, with the scopes identified and agent, at most 60
sign-ins an hour from a network. Revoking a token or making a new one
ends the agent's sessions; a token unused for 90 days lapses in every
service's pruning. Up to 5 agents a person, where the operator didn't
switch them off (AGENTS=off; said on the privacy page and in
instance.basics). An agent makes no agents, creates or owns no shared
space, is invited and raised as a Viewer alone, is never handed a
space on, is named no operator and granted no assistants; each refusal
has its test. Deleting a person deletes their agents first; an agent's
public profile names whose agent it is.

In the apps, every Profile has an Agents card (halkyon_app's
AgentsSection): made with a name and a handle, its token shown once to
copy, a new token, the token taken back, deleted. Learn's Share sheet
marks an agent among the members and offers it no role above Viewer.

The migrations, the module's and each host's (Learn, Meditate, Todo,
Train), are additive alone: CREATE TABLE "agent_account" with a unique
index on "agentId" (one row an agent account) and one on "ownerId" (a
person's agents, and deleting them with the person), and CREATE TABLE
"agent_token" with unique indexes on "agentId" (one token an agent)
and "tokenHash" (the sign-in finds the token by its hash). No existing
table is touched, so the server before the deploy keeps working on the
migrated database.

Whats-New: Make agents in your Profile: accounts for programs such as AI assistants, which sign in with a token and see only what you share with them.
Whats-New-De: Leg in deinem Profil Agenten an: Konten für Programme wie KI-Assistenten, die sich mit einem Token anmelden und nur sehen, was du mit ihnen teilst.

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 19:55:27 +01:00
commit 7aee78bd87
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
163 changed files with 113257 additions and 50 deletions

View file

@ -0,0 +1,195 @@
{
"__className__": "serverpod.DatabaseMigration",
"actions": [
{
"__className__": "serverpod.DatabaseMigrationAction",
"type": "createTable",
"createTable": {
"__className__": "serverpod.TableDefinition",
"name": "agent_account",
"dartName": "AgentAccount",
"module": "halkyon_base",
"schema": "public",
"columns": [
{
"__className__": "serverpod.ColumnDefinition",
"name": "id",
"fieldName": "id",
"columnType": 6,
"isNullable": false,
"columnDefault": "serial",
"dartType": "int?"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "agentId",
"fieldName": "agentId",
"columnType": 7,
"isNullable": false,
"dartType": "UuidValue"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "ownerId",
"fieldName": "ownerId",
"columnType": 7,
"isNullable": false,
"dartType": "UuidValue"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "name",
"fieldName": "name",
"columnType": 0,
"isNullable": false,
"dartType": "String"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "createdAt",
"fieldName": "createdAt",
"columnType": 4,
"isNullable": false,
"dartType": "DateTime"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "encryptionPublicKey",
"fieldName": "encryptionPublicKey",
"columnType": 5,
"isNullable": true,
"dartType": "dart:typed_data:ByteData?"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "signingPublicKey",
"fieldName": "signingPublicKey",
"columnType": 5,
"isNullable": true,
"dartType": "dart:typed_data:ByteData?"
}
],
"foreignKeys": [],
"indexes": [
{
"__className__": "serverpod.IndexDefinition",
"indexName": "agent_account_agent_idx",
"elements": [
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "agentId"
}
],
"type": "btree",
"isUnique": true,
"isPrimary": false
},
{
"__className__": "serverpod.IndexDefinition",
"indexName": "agent_account_owner_idx",
"elements": [
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "ownerId"
}
],
"type": "btree",
"isUnique": false,
"isPrimary": false
}
],
"managed": true
}
},
{
"__className__": "serverpod.DatabaseMigrationAction",
"type": "createTable",
"createTable": {
"__className__": "serverpod.TableDefinition",
"name": "agent_token",
"dartName": "AgentToken",
"module": "halkyon_base",
"schema": "public",
"columns": [
{
"__className__": "serverpod.ColumnDefinition",
"name": "id",
"fieldName": "id",
"columnType": 6,
"isNullable": false,
"columnDefault": "serial",
"dartType": "int?"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "agentId",
"fieldName": "agentId",
"columnType": 7,
"isNullable": false,
"dartType": "UuidValue"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "tokenHash",
"fieldName": "tokenHash",
"columnType": 0,
"isNullable": false,
"dartType": "String"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "createdAt",
"fieldName": "createdAt",
"columnType": 4,
"isNullable": false,
"dartType": "DateTime"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "lastUsedAt",
"fieldName": "lastUsedAt",
"columnType": 4,
"isNullable": true,
"dartType": "DateTime?"
}
],
"foreignKeys": [],
"indexes": [
{
"__className__": "serverpod.IndexDefinition",
"indexName": "agent_token_agent_idx",
"elements": [
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "agentId"
}
],
"type": "btree",
"isUnique": true,
"isPrimary": false
},
{
"__className__": "serverpod.IndexDefinition",
"indexName": "agent_token_hash_idx",
"elements": [
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "tokenHash"
}
],
"type": "btree",
"isUnique": true,
"isPrimary": false
}
],
"managed": true
}
}
],
"warnings": [],
"migrationApiVersion": 1
}

View file

@ -41,6 +41,7 @@ part '20261111100124819-shared-space-keys/migration.dart';
part '20261111110923694-own-rows-by-id/migration.dart'; part '20261111110923694-own-rows-by-id/migration.dart';
part '20261111145608988-shared-space-key-roll/migration.dart'; part '20261111145608988-shared-space-key-roll/migration.dart';
part '20261111170119332-shared-space-invitations/migration.dart'; part '20261111170119332-shared-space-invitations/migration.dart';
part '20261111182838916-agents/migration.dart';
/// Migration registry for the client-side database. /// Migration registry for the client-side database.
class MigrationRegistry { class MigrationRegistry {
@ -84,6 +85,7 @@ class MigrationRegistry {
_Migration20261111110923694_own_rows_by_id(), _Migration20261111110923694_own_rows_by_id(),
_Migration20261111145608988_shared_space_key_roll(), _Migration20261111145608988_shared_space_key_roll(),
_Migration20261111170119332_shared_space_invitations(), _Migration20261111170119332_shared_space_invitations(),
_Migration20261111182838916_agents(),
]; ];
/// List of all client-side database migration versions. /// List of all client-side database migration versions.

View file

@ -2308,5 +2308,7 @@
"inboxSharedNoHandle": "{title} wurde mit dir geteilt", "inboxSharedNoHandle": "{title} wurde mit dir geteilt",
"inboxSharedArriving": "Ein Lernset, das noch unterwegs ist", "inboxSharedArriving": "Ein Lernset, das noch unterwegs ist",
"inboxSharedOpen": "Öffnen", "inboxSharedOpen": "Öffnen",
"studysetNew": "Neu" "studysetNew": "Neu",
"shareRefusedAgentViewer": "Ein Agent kann nur Betrachter:in sein.",
"shareAgent": "Agent"
} }

View file

@ -2946,5 +2946,9 @@
"inboxSharedOpen": "Open", "inboxSharedOpen": "Open",
"@inboxSharedOpen": {"description": "Button opening a studyset shared with the person"}, "@inboxSharedOpen": {"description": "Button opening a studyset shared with the person"},
"studysetNew": "New", "studysetNew": "New",
"@studysetNew": {"description": "Mark on a library card of a studyset someone shared with the person, until opened"} "@studysetNew": {"description": "Mark on a library card of a studyset someone shared with the person, until opened"},
"shareRefusedAgentViewer": "An agent can only be a Viewer.",
"@shareRefusedAgentViewer": {"description": "An agent invited or set to another role than Viewer, refused"},
"shareAgent": "Agent",
"@shareAgent": {"description": "Marks a member that is an agent, a program acting for its owner"}
} }

View file

@ -13633,6 +13633,18 @@ abstract class AppLocalizations {
/// In en, this message translates to: /// In en, this message translates to:
/// **'New'** /// **'New'**
String get studysetNew; String get studysetNew;
/// An agent invited or set to another role than Viewer, refused
///
/// In en, this message translates to:
/// **'An agent can only be a Viewer.'**
String get shareRefusedAgentViewer;
/// Marks a member that is an agent, a program acting for its owner
///
/// In en, this message translates to:
/// **'Agent'**
String get shareAgent;
} }
class _AppLocalizationsDelegate class _AppLocalizationsDelegate

View file

@ -8714,4 +8714,11 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get studysetNew => 'Neu'; String get studysetNew => 'Neu';
@override
String get shareRefusedAgentViewer =>
'Ein Agent kann nur Betrachter:in sein.';
@override
String get shareAgent => 'Agent';
} }

View file

@ -8636,6 +8636,12 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get studysetNew => 'New'; String get studysetNew => 'New';
@override
String get shareRefusedAgentViewer => 'An agent can only be a Viewer.';
@override
String get shareAgent => 'Agent';
} }
/// The translations for English, as used in the United Kingdom (`en_GB`). /// The translations for English, as used in the United Kingdom (`en_GB`).

View file

@ -126,6 +126,9 @@ class Dependencies({
/// What the instance's operators do there (ADR 0021, D21 to D24). /// What the instance's operators do there (ADR 0021, D21 to D24).
@override required final InstanceOperatorRepository instanceOperators, @override required final InstanceOperatorRepository instanceOperators,
/// The person's agents (Halkyon's ADR 0044, D45).
@override required final AgentRepository agents,
/// The assistants the operator granted the person, and asking them or /// The assistants the operator granted the person, and asking them or
/// the person's own (ADR 0021, step 2). /// the person's own (ADR 0021, step 2).
@override required final AssistantRepository assistants, @override required final AssistantRepository assistants,
@ -309,6 +312,7 @@ class Dependencies({
passwords: passwords, passwords: passwords,
simpleLogin: simpleLogin, simpleLogin: simpleLogin,
instanceOperators: instanceOperators, instanceOperators: instanceOperators,
agents: AgentRepository(ServerpodAgentGateway(client.modules.base)),
assistants: assistants, assistants: assistants,
assistantOperators: assistantOperators, assistantOperators: assistantOperators,
profiles: PublicProfileRepository( profiles: PublicProfileRepository(

View file

@ -258,6 +258,7 @@ String? _problemText(AppLocalizations l10n, JoinSpaceState state) {
SpaceRefusal.self || SpaceRefusal.self ||
SpaceRefusal.nextOwnerNeeded || SpaceRefusal.nextOwnerNeeded ||
SpaceRefusal.lastMember || SpaceRefusal.lastMember ||
SpaceRefusal.agentViewer ||
SpaceRefusal.handleNeeded => l10n.shareRefusedNotAllowed, SpaceRefusal.handleNeeded => l10n.shareRefusedNotAllowed,
}; };
} }

View file

@ -55,7 +55,7 @@ class const _Ready({
final owner = state.yourRole == SpaceRole.owner; final owner = state.yourRole == SpaceRole.owner;
final others = [ final others = [
for (final member in state.members) for (final member in state.members)
if (!member.you && !member.invited) member, if (!member.you && !member.invited && !member.agent) member,
]; ];
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
@ -221,6 +221,8 @@ class const _MemberRow({
final role = _roleName(l10n, member.role); final role = _roleName(l10n, member.role);
final note = member.you final note = member.you
? l10n.shareYou ? l10n.shareYou
: member.agent
? l10n.shareAgent
: member.invited : member.invited
? l10n.shareInvited ? l10n.shareInvited
: switch (trust) { : switch (trust) {
@ -233,6 +235,8 @@ class const _MemberRow({
leading: CircleAvatar( leading: CircleAvatar(
child: member.byEmail child: member.byEmail
? const Icon(LucideIcons.mail, size: 18) ? const Icon(LucideIcons.mail, size: 18)
: member.agent
? const Icon(LucideIcons.bot, size: 18)
: Text(_nameOf(member).characters.firstOrNull?.toUpperCase() ?? ''), : Text(_nameOf(member).characters.firstOrNull?.toUpperCase() ?? ''),
), ),
title: Text( title: Text(
@ -286,7 +290,7 @@ class const _RoleMenu({required final SpaceMember member})
tooltip: l10n.shareMemberOptions, tooltip: l10n.shareMemberOptions,
icon: const Icon(LucideIcons.ellipsisVertical), icon: const Icon(LucideIcons.ellipsisVertical),
itemBuilder: (context) => [ itemBuilder: (context) => [
if (member.role != SpaceRole.editor) if (member.role != SpaceRole.editor && !member.agent)
PopupMenuItem( PopupMenuItem(
value: _MemberAction.editor, value: _MemberAction.editor,
child: Text(l10n.shareMakeEditor), child: Text(l10n.shareMakeEditor),
@ -296,7 +300,7 @@ class const _RoleMenu({required final SpaceMember member})
value: _MemberAction.viewer, value: _MemberAction.viewer,
child: Text(l10n.shareMakeViewer), child: Text(l10n.shareMakeViewer),
), ),
if (!member.invited) if (!member.invited && !member.agent)
PopupMenuItem( PopupMenuItem(
value: _MemberAction.owner, value: _MemberAction.owner,
child: Text(l10n.shareMakeOwner), child: Text(l10n.shareMakeOwner),
@ -572,6 +576,7 @@ void _report(BuildContext context, SpaceAction outcome) {
SpaceRefusal.tooManyMails => l10n.shareRefusedTooManyMails, SpaceRefusal.tooManyMails => l10n.shareRefusedTooManyMails,
SpaceRefusal.mailFailed => l10n.shareRefusedMailFailed, SpaceRefusal.mailFailed => l10n.shareRefusedMailFailed,
SpaceRefusal.handleNeeded => l10n.shareRefusedHandleNeeded, SpaceRefusal.handleNeeded => l10n.shareRefusedHandleNeeded,
SpaceRefusal.agentViewer => l10n.shareRefusedAgentViewer,
SpaceRefusal.notAllowed || SpaceRefusal.notAllowed ||
SpaceRefusal.ownerRole || SpaceRefusal.ownerRole ||
SpaceRefusal.self || SpaceRefusal.self ||

View file

@ -6,6 +6,7 @@ import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
import 'package:halkyon_ui/halkyon_ui.dart'; import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart'; import 'package:material_ui/material_ui.dart';
import '../../support/fake_studyset_gateway.dart'; import '../../support/fake_studyset_gateway.dart';
@ -121,4 +122,28 @@ void main() {
expect(find.byType(SheetScaffold), findsNothing); expect(find.byType(SheetScaffold), findsNothing);
expect(await spaces.as('person').mine(), isEmpty); expect(await spaces.as('person').mine(), isEmpty);
}); });
testWidgets('an agent among the members is marked so, and its Owner can '
'make it nothing more than a Viewer', (tester) async {
spaces
..addPerson('bot', handle: 'claude_bot')
..agents.add('bot');
final own = await spaces.as('person').create('studyset');
await spaces.as('person').invite(own, 'claude_bot', SpaceRole.viewer);
studysets.shared['japanese'] = (space: own, viewOnly: false);
await showStudyset(tester);
await tester.tap(find.text('Share'));
await tester.pumpAndSettle();
expect(find.text('@claude_bot'), findsOneWidget);
expect(find.text('Viewer · Agent'), findsOneWidget);
expect(find.byIcon(LucideIcons.bot), findsOneWidget);
await tester.tap(find.byTooltip('Member options').last);
await tester.pumpAndSettle();
expect(find.text('Make editor'), findsNothing);
expect(find.text('Make owner'), findsNothing);
expect(find.text('Remove'), findsOneWidget);
});
} }

View file

@ -223,6 +223,7 @@ Dependencies testDependencies({
loopbackPort: 43823, loopbackPort: 43823,
), ),
instanceOperators: InstanceOperatorRepository(operators), instanceOperators: InstanceOperatorRepository(operators),
agents: AgentRepository(FakeAgentGateway()),
assistants: AssistantRepository( assistants: AssistantRepository(
assistantGateway ?? FakeAssistantGateway(), assistantGateway ?? FakeAssistantGateway(),
llm: llm ?? FakeLlmClient(), llm: llm ?? FakeLlmClient(),

View file

@ -32,8 +32,8 @@ class PruningFutureCall extends FutureCall {
/// addresses, the email sign-in codes past their time, the password /// addresses, the email sign-in codes past their time, the password
/// sign-ins never finished and authenticators never confirmed (ADR 0017), /// sign-ins never finished and authenticators never confirmed (ADR 0017),
/// the SimpleLogin sign-ins never finished (ADR 0020) and the invitations /// the SimpleLogin sign-ins never finished (ADR 0020) and the invitations
/// to shared spaces mailed that lapsed (Halkyon's ADR 0044, D37), and /// to shared spaces mailed that lapsed (Halkyon's ADR 0044, D37) with the
/// logs how many. /// agents' tokens unused for 90 days (D12), and logs how many.
Future<void> forgetSignUpAttempts(Session session) async { Future<void> forgetSignUpAttempts(Session session) async {
final now = clock.now(); final now = clock.now();
final forgotten = await forgetOldSignUpAttempts(session, now: now); final forgotten = await forgetOldSignUpAttempts(session, now: now);
@ -44,11 +44,12 @@ class PruningFutureCall extends FutureCall {
); );
final simpleLogins = await forgetUnfinishedSimpleLogins(session, now: now); final simpleLogins = await forgetUnfinishedSimpleLogins(session, now: now);
final invitations = await forgetLapsedSpaceInvitations(session, now: now); final invitations = await forgetLapsedSpaceInvitations(session, now: now);
final agentTokens = await forgetLapsedAgentTokens(session);
session.log( session.log(
'Forgot $forgotten sign-up attempts, $codes email codes, ' 'Forgot $forgotten sign-up attempts, $codes email codes, '
'$unfinished unfinished sign-ins and authenticators, ' '$unfinished unfinished sign-ins and authenticators, '
'$simpleLogins unfinished SimpleLogin sign-ins and $invitations ' '$simpleLogins unfinished SimpleLogin sign-ins, $invitations '
'lapsed invitations by mail.', 'lapsed invitations by mail and $agentTokens lapsed agent tokens.',
); );
} }

View file

@ -302,8 +302,8 @@ class PruningPruneAccountsFutureCall extends _is.FutureCall
/// addresses, the email sign-in codes past their time, the password /// addresses, the email sign-in codes past their time, the password
/// sign-ins never finished and authenticators never confirmed (ADR 0017), /// sign-ins never finished and authenticators never confirmed (ADR 0017),
/// the SimpleLogin sign-ins never finished (ADR 0020) and the invitations /// the SimpleLogin sign-ins never finished (ADR 0020) and the invitations
/// to shared spaces mailed that lapsed (Halkyon's ADR 0044, D37), and /// to shared spaces mailed that lapsed (Halkyon's ADR 0044, D37) with the
/// logs how many. /// agents' tokens unused for 90 days (D12), and logs how many.
class PruningForgetSignUpAttemptsFutureCall extends _is.FutureCall class PruningForgetSignUpAttemptsFutureCall extends _is.FutureCall
implements _is.InvokableFutureCall { implements _is.InvokableFutureCall {
@override @override

Some files were not shown because too many files have changed in this diff Show more