feat(assistant)!: no hand-off and no connector; the field searches the web (ADR 0022)

The developer decided on 23 Oct to drop the hand-off to an AI assistant
outside the app, and Claude's connector with it, in v0.19.0, the app and
the server together: build 22 raises minimumAppBuild anyway, so no app
calling the connector's endpoints uses the instance after it. An instance
is to have one way to an assistant, its operator's (ADR 0021, proposed).

The app: the field at the foot of Home, and the button in the Library, on
a studyset and while studying, search the web alone, through the engine
chosen in Settings or Android's own search, with the parts of a card the
person picks; a studyset's name and description are never offered. Gone:
the assistants a question opened in (AiAssistant, AssistantHandOff), the
switch between them and the web, Settings' choice of assistant, Connect
Claude with its route, cubit, repository and gateway, and their texts. A
settings file from before keeps the old keys until the next change of a
setting writes it anew; nothing reads them.

The server: lib/src/assistant/ (MCP, its OAuth, the five tools, connect
codes and tokens), AssistantEndpoint, the daily forgetting of the
connector's secrets (its scheduled call cancelled at start), its lines in
the deletion hook, instance.info().assistantConnectorUrl (nullable, so an
older app reads it as none), and Caddy's forwarding of /mcp, /oauth/* and
/.well-known/oauth-*. The migration drops the connector's five tables,
children first:

  DROP TABLE "assistant_token" CASCADE;       -- tokens of a connection
  DROP TABLE "assistant_grant" CASCADE;       -- OAuth codes of a connection
  DROP TABLE "assistant_connection" CASCADE;  -- a person's connected client
  DROP TABLE "assistant_connect_code" CASCADE;
  DROP TABLE "assistant_client" CASCADE;      -- OAuth clients registered

No other table references them, so CASCADE drops only their own foreign
keys. For the minute between the maintenance run and the switch, the old
server answers the connector with errors and fails to delete an account
(its hook still deletes from these tables); ADR 0022 says so.

BREAKING CHANGE: a connected Claude gets no answer from the deploy on, and
the app no longer opens a question in an AI assistant.

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-23 15:33:53 +02:00
commit ca24eb9cf9
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
2 changed files with 16550 additions and 10700 deletions

View file

@ -1,991 +0,0 @@
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(
jsonDecode(await call(second['access_token'], 'list_studysets')),
{'studysets': <Object?>[]},
);
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,
bool private = false,
}) => asPerson(person, (transaction) async {
final id = (await Studyset.db.insertRow(
session,
Studyset(
parentId: parent,
deletedAt: deletedAt,
// A private studyset's payload is ciphertext (ADR 0019).
keyId: private ? 'studyset-key' : null,
nonce: private ? ByteData(24) : null,
payload: private
? ByteData.sublistView(Uint8List.fromList([0xff, 0xfe, 7]))
: _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 private studysets, saying how many, and reads '
'nothing in them', () async {
final diary = await studyset(
alice,
'Diary',
cards: [('secret', 'kept')],
private: true,
);
await studyset(alice, 'Inside', parent: diary, private: true);
await studyset(alice, 'Greek');
final token = (await tokensOf(alice))['access_token'];
final listed = jsonDecode(await call(token, 'list_studysets')) as Map;
expect(
[for (final s in listed['studysets'] as List) (s as Map)['title']],
['Greek'],
);
expect(listed['private_studysets'], 1);
expect(listed['private_note'], contains('end-to-end encrypted'));
expect(
await call(token, 'read_studyset', {'studyset_id': diary.uuid}),
contains('end-to-end encrypted'),
);
final found = jsonDecode(
await call(token, 'search_cards', {'query': 'kept'}),
) as List;
expect(found, isEmpty);
});
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 Map)['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 Map)['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},
],
},
},
],
},
},
};

View file

@ -16,10 +16,6 @@ import 'dart:io' as _idi;
import 'dart:typed_data' as _idt;
import 'package:halkyon_learn_server/src/generated/asset/file_usage.dart'
as _ioxcs83p;
import 'package:halkyon_learn_server/src/generated/assistant/assistant_code.dart'
as _iwy8rs3o;
import 'package:halkyon_learn_server/src/generated/assistant/assistant_connection_info.dart'
as _iv60uynx;
import 'package:halkyon_learn_server/src/generated/auth/device.dart'
as _ipdmo8cq;
import 'package:halkyon_learn_server/src/generated/dictionary/device_dictionary_file.dart'
@ -195,8 +191,6 @@ class TestEndpoints {
late final _AssetEndpoint asset;
late final _AssistantEndpoint assistant;
late final _DevicesEndpoint devices;
late final _DictionaryEndpoint dictionary;
@ -229,10 +223,6 @@ class _InternalTestEndpoints extends TestEndpoints
endpoints,
serializationManager,
);
assistant = _AssistantEndpoint(
endpoints,
serializationManager,
);
devices = _DevicesEndpoint(
endpoints,
serializationManager,
@ -430,108 +420,6 @@ class _AssetEndpoint {
}
}
class _AssistantEndpoint {
_AssistantEndpoint(
this._endpointDispatch,
this._serializationManager,
);
final _is.EndpointDispatch _endpointDispatch;
final _is.SerializationManager _serializationManager;
_ida.Future<_iwy8rs3o.AssistantCode> connectCode(
_ist.TestSessionBuilder sessionBuilder,
) async {
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
var _localUniqueSession =
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
endpoint: 'assistant',
method: 'connectCode',
);
try {
var _localCallContext = await _endpointDispatch.getMethodCallContext(
createSessionCallback: (_) => _localUniqueSession,
endpointPath: 'assistant',
methodName: 'connectCode',
parameters: _ist.testObjectToJson({}),
serializationManager: _serializationManager,
);
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
)
as _ida.Future<_iwy8rs3o.AssistantCode>);
return _localReturnValue;
} finally {
await _localUniqueSession.close();
}
});
}
_ida.Future<List<_iv60uynx.AssistantConnectionInfo>> connections(
_ist.TestSessionBuilder sessionBuilder,
) async {
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
var _localUniqueSession =
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
endpoint: 'assistant',
method: 'connections',
);
try {
var _localCallContext = await _endpointDispatch.getMethodCallContext(
createSessionCallback: (_) => _localUniqueSession,
endpointPath: 'assistant',
methodName: 'connections',
parameters: _ist.testObjectToJson({}),
serializationManager: _serializationManager,
);
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
)
as _ida.Future<List<_iv60uynx.AssistantConnectionInfo>>);
return _localReturnValue;
} finally {
await _localUniqueSession.close();
}
});
}
_ida.Future<void> disconnect(
_ist.TestSessionBuilder sessionBuilder,
int id,
) async {
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
var _localUniqueSession =
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
endpoint: 'assistant',
method: 'disconnect',
);
try {
var _localCallContext = await _endpointDispatch.getMethodCallContext(
createSessionCallback: (_) => _localUniqueSession,
endpointPath: 'assistant',
methodName: 'disconnect',
parameters: _ist.testObjectToJson({'id': id}),
serializationManager: _serializationManager,
);
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
)
as _ida.Future<void>);
return _localReturnValue;
} finally {
await _localUniqueSession.close();
}
});
}
}
class _DevicesEndpoint {
_DevicesEndpoint(
this._endpointDispatch,
@ -2135,21 +2023,6 @@ class _PruningFutureCall {
}
}
Future<void> forgetConnectorSecrets(
_ist.TestSessionBuilder sessionBuilder,
) async {
var _localUniqueSession =
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild();
try {
await _iip9210p.PruningForgetConnectorSecretsFutureCall().invoke(
_localUniqueSession,
null,
);
} finally {
await _localUniqueSession.close();
}
}
Future<void> forgetSignUpAttempts(
_ist.TestSessionBuilder sessionBuilder,
) async {