chore: Halkyon Learn moves into apps/learn

Its server, client, app and curator, and the tools that write its
assets (maps, Anki fixtures, icons), as they are; paths are fixed in
the commits that follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-19 20:08:06 +02:00
commit 649499be23
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -0,0 +1,949 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:clock/clock.dart';
import 'package:crypto/crypto.dart';
import 'package:halkyon_base_server/base.dart';
import 'package:halkyon_learn_server/src/assistant/assistant_endpoint.dart';
import 'package:halkyon_learn_server/src/assistant/connector_http.dart';
import 'package:halkyon_learn_server/src/assistant/connector_mcp.dart';
import 'package:halkyon_learn_server/src/assistant/connector_oauth.dart';
import 'package:halkyon_learn_server/src/generated/protocol.dart';
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.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';
/// Where Claude's custom connectors send people back to.
const _callback = 'https://claude.ai/api/mcp/auth_callback';
/// A PKCE verifier, and its S256 challenge.
const _verifier = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk';
final _challenge = base64UrlEncode(
sha256.convert(ascii.encode(_verifier)).bytes,
).replaceAll('=', '');
void main() {
withServerpod(
'The assistant connector',
(sessionBuilder, endpoints) {
late Session session;
late UuidValue alice;
late UuidValue bob;
final connector = Connector(
base: 'https://learn.example.org',
instance: 'learn.example.org',
);
/// The people a test made, whose spaces it deletes again.
final people = <UuidValue>{};
setUp(() {
// The test pod is built without the generated Serverpod class, which
// sets up sync for the running server.
Serverpod.instance.initializeOfflineSync(syncTables: syncTables);
useLearnBase(sessionBuilder);
session = sessionBuilder.build();
alice = const Uuid().v7obj();
bob = const Uuid().v7obj();
people.addAll([alice, bob]);
});
tearDown(() async {
// The clients cascade into connections, grants and tokens.
await AssistantClient.db.deleteWhere(
session,
where: (t) => Constant.bool(true),
);
await AssistantConnectCode.db.deleteWhere(
session,
where: (t) => t.authUserId.inSet(people),
);
for (final person in people) {
await session.db.unsafeExecute(
'DELETE FROM "offline_sync_spaces" WHERE "uuidSpaceId" = @space',
parameters: QueryParameters.named({'space': person.uuid}),
);
}
people.clear();
});
TestSessionBuilder signedIn(UuidValue person) => sessionBuilder.copyWith(
authentication: AuthenticationOverride.authenticationInfo(
person.uuid,
{},
),
);
/// Registers a client as Claude does; its client_id.
Future<String> register({String method = 'none'}) async {
final response = await connector.register(
session,
connectorRequest(
'POST',
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'client_name': 'Claude',
'redirect_uris': [_callback],
'grant_types': ['authorization_code', 'refresh_token'],
'response_types': ['code'],
'token_endpoint_auth_method': method,
}),
),
);
expect(response.status, 201, reason: response.body);
return (jsonDecode(response.body) as Map)['client_id'] as String;
}
Map<String, String> request(String clientId) => {
'response_type': 'code',
'client_id': clientId,
'redirect_uri': _callback,
'state': 'xyz',
'code_challenge': _challenge,
'code_challenge_method': 'S256',
'scope': readScope,
'resource': connector.resource,
};
/// Types [code] on the authorization page of [clientId].
Future<ConnectorResponse> typeCode(
String clientId,
String code, {
Map<String, String>? fields,
}) => connector.authorize(
session,
connectorRequest(
'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: Uri(
queryParameters: {
...fields ?? request(clientId),
'connect_code': code,
},
).query,
),
);
/// Connects [person]'s account to [clientId] with a code from their
/// app; the authorization code the client gets back.
Future<String> connect(UuidValue person, String clientId) async {
final code = await endpoints.assistant.connectCode(signedIn(person));
final response = await typeCode(clientId, code.code);
expect(response.status, 302, reason: response.body);
return Uri.parse(
response.headers['Location']!,
).queryParameters['code']!;
}
Future<ConnectorResponse> exchange(
Map<String, String> form, {
Map<String, String> headers = const {},
}) => connector.token(
session,
connectorRequest(
'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
...headers,
},
body: Uri(queryParameters: form).query,
),
);
Map<String, String> codeForm(String clientId, String code) => {
'grant_type': 'authorization_code',
'code': code,
'redirect_uri': _callback,
'client_id': clientId,
'code_verifier': _verifier,
'resource': connector.resource,
};
/// The tokens [person] gives a newly registered client.
Future<Map<String, Object?>> tokensOf(
UuidValue person, {
String? clientId,
}) async {
final client = clientId ?? await register();
final response = await exchange(
codeForm(client, await connect(person, client)),
);
expect(response.status, 200, reason: response.body);
return jsonDecode(response.body) as Map<String, Object?>;
}
Future<ConnectorResponse> mcp(
Object? token,
Map<String, Object?> message, {
String method = 'POST',
Map<String, String> headers = const {},
}) => answerMcpRequest(
session,
connectorRequest(
method,
headers: {
if (token != null) 'Authorization': 'Bearer $token',
'Content-Type': 'application/json',
...headers,
},
body: jsonEncode(message),
),
connector,
);
/// The text tool [name] answers with [arguments], for [token].
Future<String> call(
Object? token,
String name, [
Map<String, Object?> arguments = const {},
]) async {
final response = await mcp(token, {
'jsonrpc': '2.0',
'id': 1,
'method': 'tools/call',
'params': {'name': name, 'arguments': arguments},
});
expect(response.status, 200, reason: response.body);
final result = (jsonDecode(response.body) as Map)['result'] as Map;
final text = ((result['content'] as List).single as Map)['text'];
return result['isError'] == true ? 'ERROR: $text' : text as String;
}
group('Metadata', () {
test('names the MCP endpoint and the instance as its authorization '
'server', () {
final resource = jsonDecode(connector.resourceMetadata().body) as Map;
final server = jsonDecode(connector.serverMetadata().body) as Map;
expect(resource['resource'], 'https://learn.example.org/mcp');
expect(resource['authorization_servers'], [
'https://learn.example.org',
]);
expect(server['issuer'], 'https://learn.example.org');
expect(server['code_challenge_methods_supported'], ['S256']);
expect(
server['authorization_response_iss_parameter_supported'],
true,
);
});
test('the instance says where its connector is', () async {
final info = await endpoints.instance.info(sessionBuilder);
expect(info.assistantConnectorUrl, endsWith('/mcp'));
});
});
group('Registering', () {
test('keeps the name and the redirect URIs, and gives a public '
'client no secret', () async {
final id = await register();
final client = await AssistantClient.db.findFirstRow(
session,
where: (t) => t.clientId.equals(id),
);
expect(client!.name, 'Claude');
expect(client.redirectUris, [_callback]);
expect(client.secretHash, isNull);
});
test(
'refuses plain http but on localhost, and no redirect URIs',
() async {
Future<int> status(Object? redirects) async =>
(await connector.register(
session,
connectorRequest(
'POST',
body: jsonEncode({'redirect_uris': redirects}),
),
)).status;
expect(await status(['http://claude.ai/callback']), 400);
expect(await status(['https://claude.ai/a#b']), 400);
expect(await status(<String>[]), 400);
expect(await status(null), 400);
expect(await status(['http://localhost:4567/callback']), 201);
},
);
});
test('at most 200 clients register in an hour', () async {
final now = DateTime.now().toUtc();
await AssistantClient.db.insert(session, [
for (var i = 0; i < maxRegistrationsPerHour; i++)
AssistantClient(
clientId: 'flood-$i',
name: 'Flood',
redirectUris: [_callback],
createdAt: now,
),
]);
final response = await connector.register(
session,
connectorRequest(
'POST',
body: jsonEncode({
'redirect_uris': [_callback],
}),
),
);
expect(response.status, 429);
});
group('Single use, also when requests race', () {
test('a connect code', () async {
final client = await register();
final code = await endpoints.assistant.connectCode(signedIn(alice));
final answers = await Future.wait([
typeCode(client, code.code),
typeCode(client, code.code),
]);
expect([for (final answer in answers) answer.status]..sort(), [
200,
302,
]);
expect(await AssistantGrant.db.count(session), 1);
});
test('an authorization code', () async {
final client = await register();
final code = await connect(alice, client);
final answers = await Future.wait([
exchange(codeForm(client, code)),
exchange(codeForm(client, code)),
]);
expect([for (final answer in answers) answer.status]..sort(), [
200,
400,
]);
expect(await AssistantToken.db.count(session), 1);
});
test('a refresh token', () async {
final client = await register();
final tokens = await tokensOf(alice, clientId: client);
final refresh = {
'grant_type': 'refresh_token',
'refresh_token': '${tokens['refresh_token']}',
'client_id': client,
};
final answers = await Future.wait([
exchange(refresh),
exchange(refresh),
]);
expect([for (final answer in answers) answer.status]..sort(), [
200,
400,
]);
expect(await AssistantToken.db.count(session), 1);
});
});
group('Connecting by code', () {
test('a code needs a signed-in person', () async {
await expectLater(
endpoints.assistant.connectCode(sessionBuilder),
throwsA(isA<ServerpodUnauthenticatedException>()),
);
});
test('a code is eight characters, kept only as a hash, and a new one '
'replaces the last', () async {
final first = await endpoints.assistant.connectCode(signedIn(alice));
final second = await endpoints.assistant.connectCode(signedIn(alice));
expect(second.code, matches(RegExp(r'^[0-9A-Z]{4}-[0-9A-Z]{4}$')));
final kept = await AssistantConnectCode.db.find(
session,
where: (t) => t.authUserId.equals(alice),
);
expect(kept, hasLength(1));
expect(kept.single.codeHash, isNot(contains(second.code)));
final client = await register();
expect((await typeCode(client, first.code)).status, 200);
expect((await typeCode(client, second.code)).status, 302);
});
test('the page names the client and where it returns to, and may not '
'be framed', () async {
final client = await register();
final page = await connector.authorize(
session,
connectorRequest('GET', query: request(client)),
);
expect(page.status, 200);
expect(page.body, contains('Connect Claude to Halkyon Learn'));
expect(page.body, contains('<strong>claude.ai</strong>'));
expect(
page.headers['Content-Security-Policy'],
contains("frame-ancestors 'none'"),
);
});
test("an unknown client or a redirect URI it didn't register gets a "
'page, never a redirect', () async {
final client = await register();
final unknown = await connector.authorize(
session,
connectorRequest('GET', query: request('nobody')),
);
final elsewhere = await connector.authorize(
session,
connectorRequest(
'GET',
query: {
...request(client),
'redirect_uri': 'https://evil.example/',
},
),
);
for (final response in [unknown, elsewhere]) {
expect(response.status, 400);
expect(response.headers, isNot(contains('Location')));
}
});
test(
'a wrong code connects nothing; the right one, typed loosely, '
'connects and sends the person back with a code and the issuer',
() async {
final client = await register();
final code = await endpoints.assistant.connectCode(signedIn(alice));
final wrong = await typeCode(client, 'AAAA-AAAA');
expect(wrong.status, 200);
expect(wrong.body, contains("That code doesn't work"));
expect(await AssistantConnection.db.count(session), 0);
final right = await typeCode(
client,
code.code.toLowerCase().replaceAll('-', ' '),
);
expect(right.status, 302);
final back = Uri.parse(right.headers['Location']!);
expect('${back.replace(query: '')}', '$_callback?');
expect(back.queryParameters['code'], isNotEmpty);
expect(back.queryParameters['state'], 'xyz');
expect(back.queryParameters['iss'], 'https://learn.example.org');
final connections = await endpoints.assistant.connections(
signedIn(alice),
);
expect(connections.single.name, 'Claude');
expect(connections.single.host, 'claude.ai');
},
);
test('a code works once, and for ten minutes', () async {
final client = await register();
final used = await endpoints.assistant.connectCode(signedIn(alice));
expect((await typeCode(client, used.code)).status, 302);
expect((await typeCode(client, used.code)).status, 200);
final old = await endpoints.assistant.connectCode(signedIn(bob));
final later = await withClock(
Clock.fixed(DateTime.now().add(const Duration(minutes: 11))),
() => typeCode(client, old.code),
);
expect(later.status, 200);
});
test('without PKCE, or for another resource, the client gets an '
'error back', () async {
final client = await register();
final code = await endpoints.assistant.connectCode(signedIn(alice));
final plain = await typeCode(
client,
code.code,
fields: {...request(client), 'code_challenge_method': 'plain'},
);
final other = await typeCode(
client,
code.code,
fields: {...request(client), 'resource': 'https://other.example/'},
);
expect(
Uri.parse(plain.headers['Location']!).queryParameters['error'],
'invalid_request',
);
expect(
Uri.parse(other.headers['Location']!).queryParameters['error'],
'invalid_target',
);
expect(await AssistantConnection.db.count(session), 0);
});
});
group('Tokens', () {
test('a code and its verifier give bearer tokens, kept only as '
'hashes', () async {
final tokens = await tokensOf(alice);
expect(tokens['token_type'], 'Bearer');
expect(tokens['expires_in'], 3600);
expect(tokens['scope'], readScope);
final kept = await AssistantToken.db.find(session);
expect(kept, hasLength(1));
expect(kept.single.accessHash, isNot(tokens['access_token']));
expect(kept.single.resource, connector.resource);
});
test('a wrong verifier spends the code', () async {
final client = await register();
final code = await connect(alice, client);
final wrong = await exchange({
...codeForm(client, code),
'code_verifier': 'x' * 43,
});
final again = await exchange(codeForm(client, code));
expect(wrong.status, 400);
expect((jsonDecode(wrong.body) as Map)['error'], 'invalid_grant');
expect(again.status, 400);
});
test("another client can't use the code", () async {
final client = await register();
final other = await register();
final code = await connect(alice, client);
final response = await exchange(codeForm(other, code));
expect(response.status, 400);
});
test('a refresh gives a new pair and spends the old refresh '
'token', () async {
final client = await register();
final first = await tokensOf(alice, clientId: client);
final refresh = {
'grant_type': 'refresh_token',
'refresh_token': '${first['refresh_token']}',
'client_id': client,
};
final renewed = await exchange(refresh);
final reused = await exchange(refresh);
expect(renewed.status, 200);
final second = jsonDecode(renewed.body) as Map;
expect(second['access_token'], isNot(first['access_token']));
expect(reused.status, 400);
expect(await call(second['access_token'], 'list_studysets'), '[]');
expect(
(await mcp(first['access_token'], {
'jsonrpc': '2.0',
'id': 1,
'method': 'ping',
})).status,
401,
);
});
test('a client registered with a secret needs it, in the form or '
'as Basic authorization', () async {
final response = await connector.register(
session,
connectorRequest(
'POST',
body: jsonEncode({
'client_name': 'Claude',
'redirect_uris': [_callback],
'token_endpoint_auth_method': 'client_secret_basic',
}),
),
);
final registered = jsonDecode(response.body) as Map;
final id = registered['client_id'] as String;
final secret = registered['client_secret'] as String;
final without = await exchange(
codeForm(id, await connect(alice, id)),
);
final basic = await exchange(
codeForm(id, await connect(alice, id)),
headers: {
'Authorization':
'Basic ${base64.encode(utf8.encode('$id:$secret'))}',
},
);
expect(without.status, 401);
expect(basic.status, 200);
});
});
group('The MCP endpoint', () {
/// Writes as [person] through the sync engine, as their device would.
Future<T> asPerson<T>(
UuidValue person,
Future<T> Function(Transaction transaction) write,
) => _syncDatabases[session]!.transactionForUser(person, write);
/// Gives [person] a studyset [title] holding cards with [fronts],
/// each card's back the front's English, a note, and a review of
/// the first card, deleted at [deletedAt] if given; its id.
Future<UuidValue> studyset(
UuidValue person,
String title, {
List<(String, String)> cards = const [],
UuidValue? parent,
DateTime? deletedAt,
}) => asPerson(person, (transaction) async {
final id = (await Studyset.db.insertRow(
session,
Studyset(
parentId: parent,
deletedAt: deletedAt,
payload: _json({
'schemaVersion': 4,
'title': title,
'description': _rich('About $title'),
}),
),
transaction: transaction,
)).id!;
var position = 0;
for (final (front, back) in cards) {
final card = (await Card.db.insertRow(
session,
Card(studysetId: id, position: 'a${position++}'),
transaction: transaction,
)).id!;
await CardRevision.db.insertRow(
session,
CardRevision(
cardId: card,
parents: [],
authorId: person,
payload: _json({
'schemaVersion': 9,
'type': 'frontBack',
'front': _rich(front),
'back': _rich(back),
}),
),
transaction: transaction,
);
if (position == 1) {
await ReviewLog.db.insertRow(
session,
ReviewLog(
cardId: card,
rating: 3,
reviewedAt: DateTime.now().toUtc(),
durationMs: 2000,
),
transaction: transaction,
);
}
}
final note = (await Note.db.insertRow(
session,
Note(studysetId: id, position: 'a0'),
transaction: transaction,
)).id!;
await NoteRevision.db.insertRow(
session,
NoteRevision(
noteId: note,
parents: [],
authorId: person,
payload: _json({
'schemaVersion': 1,
'title': 'Particles',
'body': _rich('は marks the topic.'),
}),
),
transaction: transaction,
);
return id;
});
test('without a valid token, says where to get one', () async {
final response = await mcp('nonsense', {
'jsonrpc': '2.0',
'id': 1,
'method': 'ping',
});
expect(response.status, 401);
expect(
response.headers['WWW-Authenticate'],
contains(
'resource_metadata="https://learn.example.org/.well-known/'
'oauth-protected-resource"',
),
);
});
test('initializes, takes notifications, and lists five tools, all '
'only reading', () async {
final token = (await tokensOf(alice))['access_token'];
final initialized = await mcp(token, {
'jsonrpc': '2.0',
'id': 1,
'method': 'initialize',
'params': {'protocolVersion': '2025-06-18'},
});
final notified = await mcp(token, {
'jsonrpc': '2.0',
'method': 'notifications/initialized',
});
final listed = await mcp(token, {
'jsonrpc': '2.0',
'id': 2,
'method': 'tools/list',
});
expect(initialized.status, 200);
expect(notified.status, 202);
final tools =
((jsonDecode(listed.body) as Map)['result'] as Map)['tools']
as List;
expect(
[for (final tool in tools) (tool as Map)['name']],
[
'list_studysets',
'read_studyset',
'search_cards',
'read_note',
'study_progress',
],
);
for (final tool in tools) {
expect(
(tool as Map)['annotations'],
containsPair('readOnlyHint', true),
);
}
});
test('leaves out a studyset waiting in Recently deleted, and what '
'is inside it', () async {
final japanese = await studyset(
alice,
'Japanese',
cards: [('火', 'fire')],
deletedAt: DateTime.utc(2026, 10, 12),
);
await studyset(alice, 'Kanji', parent: japanese);
await studyset(alice, 'Greek');
final token = (await tokensOf(alice))['access_token'];
final listed =
jsonDecode(await call(token, 'list_studysets')) as List;
expect([for (final s in listed) (s as Map)['title']], ['Greek']);
final found = jsonDecode(
await call(token, 'search_cards', {'query': 'fire'}),
) as List;
expect(found, isEmpty);
});
test("reads the person's studysets, cards, notes and progress, and "
"none of anyone else's", () async {
final japanese = await studyset(
alice,
'Japanese',
cards: [('火', 'fire'), ('水', 'water')],
);
await studyset(alice, 'Kanji', parent: japanese);
final secret = await studyset(bob, "Bob's diary");
final token = (await tokensOf(alice))['access_token'];
final listed =
jsonDecode(await call(token, 'list_studysets')) as List;
expect(
[for (final s in listed) (s as Map)['title']],
[
'Japanese',
'Kanji',
],
);
expect((listed.first as Map)['cards'], 2);
expect((listed.last as Map)['inside'], 'Japanese');
final read = jsonDecode(
await call(token, 'read_studyset', {
'studyset_id': japanese.uuid,
}),
) as Map;
expect(read['description'], 'About Japanese');
expect(
[for (final c in read['cards'] as List) (c as Map)['back']],
[
'fire',
'water',
],
);
final note = (read['notes'] as List).single as Map;
expect(note['title'], 'Particles');
final found = jsonDecode(
await call(token, 'search_cards', {'query': 'WAT'}),
) as List;
expect((found.single as Map)['front'], '水');
final text = jsonDecode(
await call(token, 'read_note', {'note_id': note['id']}),
) as Map;
expect(text['text'], 'は marks the topic.');
final progress = jsonDecode(
await call(token, 'study_progress', {
'studyset_id': japanese.uuid,
}),
) as Map;
expect(progress['cards'], 2);
expect(progress['cards_studied'], 1);
expect(progress['reviews_last_7_days'], 1);
expect(
await call(token, 'read_studyset', {'studyset_id': secret.uuid}),
startsWith('ERROR: No studyset'),
);
});
test('disconnecting ends the tokens at once', () async {
final token = (await tokensOf(alice))['access_token'];
final connection = (await endpoints.assistant.connections(
signedIn(alice),
)).single;
await expectLater(
endpoints.assistant.disconnect(signedIn(bob), connection.id),
throwsA(isA<NotFoundException>()),
);
await endpoints.assistant.disconnect(signedIn(alice), connection.id);
expect(
(await mcp(token, {
'jsonrpc': '2.0',
'id': 1,
'method': 'ping',
})).status,
401,
);
expect(await AssistantToken.db.count(session), 0);
});
test('an access token works for an hour', () async {
final token = (await tokensOf(alice))['access_token'];
final later = await withClock(
Clock.fixed(DateTime.now().add(const Duration(minutes: 61))),
() => mcp(token, {'jsonrpc': '2.0', 'id': 1, 'method': 'ping'}),
);
expect(later.status, 401);
});
test('takes only POST, and no page from a plain-http origin', () async {
final token = (await tokensOf(alice))['access_token'];
final get = await mcp(token, {}, method: 'GET');
final origin = await mcp(
token,
{'jsonrpc': '2.0', 'id': 1, 'method': 'ping'},
headers: {'Origin': 'http://evil.example'},
);
expect(get.status, 405);
expect(get.headers['Allow'], 'POST');
expect(origin.status, 403);
});
});
test('pruning forgets expired codes and tokens, and clients nobody '
'connected within a week', () async {
final connected = await register();
await tokensOf(alice, clientId: connected);
await register();
await endpoints.assistant.connectCode(signedIn(bob));
final forgotten = await forgetExpiredConnectorSecrets(
session,
now: DateTime.now().toUtc().add(const Duration(days: 31)),
);
expect(forgotten, 3);
expect(await AssistantClient.db.count(session), 1);
expect(await AssistantConnection.db.count(session), 1);
expect(await AssistantConnectCode.db.count(session), 0);
});
test('deleting the account deletes its connections and codes', () async {
useLearnBase(sessionBuilder);
final carol = (await AuthServices.instance.authUsers.create(session))
.id;
people.add(carol);
await tokensOf(carol);
await endpoints.assistant.connectCode(signedIn(carol));
await deletePerson(session, carol);
expect(await AssistantConnection.db.count(session), 0);
expect(await AssistantToken.db.count(session), 0);
expect(await AssistantConnectCode.db.count(session), 0);
});
},
// The sync engine's database, as the running server has it (see
// publish_test.dart): the tests delete what they made instead.
databaseInterceptor: (session, inner) {
final database = offlineSyncDatabaseInterceptor(session, inner);
if (database is OfflineSyncDatabase) {
_syncDatabases[session] = database;
}
return database;
},
rollbackDatabase: RollbackDatabase.disabled,
);
}
/// The sync engine's database of each session.
final _syncDatabases = Expando<OfflineSyncDatabase>();
/// [json] as a synced payload.
ByteData _json(Map<String, Object?> json) =>
ByteData.sublistView(utf8.encode(jsonEncode(json)));
/// Rich content holding one paragraph of [text], as the app stores it.
Map<String, Object?> _rich(String text) => {
'formatVersion': 1,
'editor': {
'document': {
'type': 'page',
'children': [
{
'type': 'paragraph',
'data': {
'delta': [
{'insert': text},
],
},
},
],
},
},
};