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
44 changed files with 16550 additions and 10700 deletions

View file

@ -1,6 +1,6 @@
# Configuration for the production instance learn.halkyon.eu.
# Caddy terminates TLS on 443 and forwards to the API server on 8080, and the
# assistant connector's paths to the web server on 8082; Insights doesn't run.
# web server's paths to it on 8082; Insights doesn't run.
# Configuration for the main API server.
apiServer:
@ -14,9 +14,10 @@ apiServer:
# back (port 8081), set `serviceSecret` in the production passwords, and reach the
# port through an SSH tunnel, never through Caddy.
# The web server serves only the assistant connector (ADR 0006): the MCP endpoint
# and its OAuth pages and metadata. Caddy forwards those paths alone to it
# (deploy/Caddyfile); everything else stays on the API server.
# The web server serves profile pages (ADR 0011), the dictionaries devices
# download (ADR 0012) and the calls between the instance's services (ADR 0016).
# Caddy forwards the paths of the first two alone to it (deploy/Caddyfile),
# never the third; everything else stays on the API server.
webServer:
port: 8082
publicHost: learn.halkyon.eu

View file

@ -2,7 +2,6 @@ import 'dart:async';
import 'dart:io';
import 'package:halkyon_base_server/base.dart';
import 'package:halkyon_learn_server/src/assistant/connector_routes.dart';
import 'package:halkyon_learn_server/src/auth/learn_base.dart';
import 'package:halkyon_learn_server/src/dictionary/device_dictionaries.dart';
import 'package:halkyon_learn_server/src/dictionary/dictionary_loader.dart';
@ -35,8 +34,6 @@ Future<void> run(List<String> args) async {
// The calls of the instance's other services, on the web server
// (ADR 0016); Caddy never forwards them.
addInstanceRoutes(pod);
// The assistant connector, on the web server (ADR 0006).
addConnectorRoutes(pod);
// Profile pages, `/@handle`, which profile links link back to (ADR 0011),
// halkyon_base's since profiles are the instance's register's (ADR 0016).
addProfilePageRoute(pod);
@ -77,15 +74,11 @@ Future<void> run(List<String> args) async {
.pruning
.tellMembersOfDeletions();
// The assistant connector's expired codes and tokens (ADR 0006, rule 5).
// The assistant connector went with its codes and tokens (ADR 0022): what
// an earlier server scheduled to forget them would call nothing.
await pod.futureCalls.cancel(_connectorPruning);
await pod.futureCalls
.callRecurring(identifier: _connectorPruning)
.cron('57 3 * * *')
.pruning
.forgetConnectorSecrets();
// So are the files nobody can reach any more (ADR 0004, rule 5).
// The files nobody can reach any more, daily (ADR 0004, rule 5).
await pod.futureCalls.cancel(_filePruning);
await pod.futureCalls
.callRecurring(identifier: _filePruning)
@ -138,7 +131,8 @@ const _attemptPruning = 'forget-sign-up-attempts';
/// The identifier of the hourly telling of the members of deleted accounts.
const _deletionNotices = 'tell-members-of-deletions';
/// The identifier of the daily forgetting of the connector's expired secrets.
/// The identifier of the daily forgetting of the connector's expired secrets,
/// scheduled until ADR 0022.
const _connectorPruning = 'forget-connector-secrets';
/// The identifier of the daily pruning of files.

View file

@ -1,23 +0,0 @@
### An app that registered to connect people's studysets (ADR 0006): Claude, or
### another MCP client, through OAuth dynamic client registration (RFC 7591).
### Anyone may register one; it can do nothing until a person connects it with
### a code from their app. One nobody connected is pruned after a week.
class: AssistantClient
table: assistant_client
serverOnly: true
fields:
### The client_id given out: random and unguessable, never reused.
clientId: String
### What it calls itself, shown when a person connects it.
name: String
### Where authorization may send the person back to, compared exactly.
redirectUris: List<String>
### SHA-256 of its secret, for a client that authenticates with one at the
### token endpoint; null for a public client (PKCE alone).
secretHash: String?
### When it registered.
createdAt: DateTime
indexes:
assistant_client_id_idx:
fields: clientId
unique: true

View file

@ -1,7 +0,0 @@
### A code for connecting an assistant, as the app shows it (ADR 0006, rule 3).
class: AssistantCode
fields:
### The code, as `K7QX-M2PD`.
code: String
### When it stops working, in UTC.
expiresAt: DateTime

View file

@ -1,19 +0,0 @@
### A code a person's app showed for connecting an assistant (ADR 0006, rule 3):
### eight characters, valid for ten minutes, used once. Kept only as a hash, so
### the table never holds a code that works.
class: AssistantConnectCode
table: assistant_connect_code
serverOnly: true
fields:
### Whose account the code connects.
authUserId: UuidValue
### SHA-256 of the code as typed, normalized (`normalizeConnectCode`).
codeHash: String
### When it stops working, in UTC.
expiresAt: DateTime
indexes:
assistant_connect_code_hash_idx:
fields: codeHash
unique: true
assistant_connect_code_person_idx:
fields: authUserId

View file

@ -1,22 +0,0 @@
### A person's grant to one assistant (ADR 0006, rule 5): made when they enter a
### code on the client's authorization page. The app lists them, and
### disconnecting one deletes it with its codes and tokens.
class: AssistantConnection
table: assistant_connection
serverOnly: true
fields:
### Whose content the assistant reads.
authUserId: UuidValue
### The assistant; the connection goes with it.
client: AssistantClient?, relation(onDelete=Cascade)
### When the person connected it.
createdAt: DateTime
### When the assistant last read something, to the minute; null before.
lastUsedAt: DateTime?
indexes:
assistant_connection_person_idx:
fields: authUserId
# Deleting a client cascades through this index; Postgres doesn't index
# foreign keys by itself.
assistant_connection_client_idx:
fields: clientId

View file

@ -1,13 +0,0 @@
### A connected assistant, as the app lists it.
class: AssistantConnectionInfo
fields:
### The connection's id, to disconnect it.
id: int
### What the assistant calls itself.
name: String
### The host the person was sent back to on connecting, e.g. `claude.ai`.
host: String
### When the person connected it.
connectedAt: DateTime
### When it last read something; null before.
lastUsedAt: DateTime?

View file

@ -1,113 +0,0 @@
import 'package:clock/clock.dart';
import 'package:halkyon_base_server/base.dart';
import 'package:halkyon_learn_server/src/assistant/connector_oauth.dart';
import 'package:halkyon_learn_server/src/assistant/connector_secrets.dart';
import 'package:halkyon_learn_server/src/generated/protocol.dart';
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.dart';
/// Connecting assistants to the signed-in person's account (ADR 0006): a
/// code to type on an assistant's authorization page, and the assistants
/// connected, to disconnect. Anonymous people too: the code is how they
/// connect without signing in anywhere.
class AssistantEndpoint extends Endpoint {
@override
bool get requireLogin => true;
/// A new code for connecting an assistant, valid for ten minutes and
/// once; the person's earlier codes stop working. Only its hash is kept.
Future<AssistantCode> connectCode(Session session) async {
final person = session.authenticated!.authUserId;
final code = newShortCode();
final expiresAt = clock.now().toUtc().add(connectCodeLifetime);
await session.db.transaction((transaction) async {
// DELETE FROM "assistant_connect_code" WHERE "authUserId" = $1
await AssistantConnectCode.db.deleteWhere(
session,
where: (t) => t.authUserId.equals(person),
transaction: transaction,
);
await AssistantConnectCode.db.insertRow(
session,
AssistantConnectCode(
authUserId: person,
codeHash: hashSecret(normalizeShortCode(code)),
expiresAt: expiresAt,
),
transaction: transaction,
);
});
return AssistantCode(code: code, expiresAt: expiresAt);
}
/// The assistants connected to the person's account, the newest first.
Future<List<AssistantConnectionInfo>> connections(Session session) async {
final person = session.authenticated!.authUserId;
final connections = await AssistantConnection.db.find(
session,
where: (t) => t.authUserId.equals(person),
orderBy: (t) => t.createdAt.desc(),
include: AssistantConnection.include(client: AssistantClient.include()),
);
return [
for (final connection in connections)
AssistantConnectionInfo(
id: connection.id!,
name: connection.client?.name ?? '',
host: switch (connection.client?.redirectUris.firstOrNull) {
final uri? => Uri.tryParse(uri)?.host ?? '',
null => '',
},
connectedAt: connection.createdAt,
lastUsedAt: connection.lastUsedAt,
),
];
}
/// Disconnects the person's assistant [id]: its tokens stop working at
/// once. Someone else's connection is one that doesn't exist.
Future<void> disconnect(Session session, int id) async {
final person = session.authenticated!.authUserId;
// DELETE FROM "assistant_connection" WHERE "id" = $1 AND "authUserId" = $2
// (its grants and tokens cascade)
final deleted = await AssistantConnection.db.deleteWhere(
session,
where: (t) => t.id.equals(id) & t.authUserId.equals(person),
);
if (deleted.isEmpty) {
throw NotFoundException(what: 'connection');
}
}
}
/// Deletes what the connector no longer needs (ADR 0006, rule 5), as of
/// [now], and returns how many rows went: codes, authorization codes and
/// tokens past their time, and clients nobody connected within a week.
Future<int> forgetExpiredConnectorSecrets(
Session session, {
required DateTime now,
}) async {
final codes = await AssistantConnectCode.db.deleteWhere(
session,
where: (t) => t.expiresAt < now,
);
final grants = await AssistantGrant.db.deleteWhere(
session,
where: (t) => t.expiresAt < now,
);
final tokens = await AssistantToken.db.deleteWhere(
session,
where: (t) => t.refreshExpiresAt < now,
);
final connected = {
for (final connection in await AssistantConnection.db.find(session))
connection.clientId,
};
final clients = await AssistantClient.db.deleteWhere(
session,
where: (t) =>
(t.createdAt < now.subtract(unconnectedClientLifetime)) &
t.id.notInSet(connected),
);
return codes.length + grants.length + tokens.length + clients.length;
}

View file

@ -1,29 +0,0 @@
### An authorization code on its way to the assistant (OAuth 2.1, section 4.1):
### valid for five minutes and used once, bound to the redirect URI, the PKCE
### challenge and the resource it was asked for. Kept only as a hash.
class: AssistantGrant
table: assistant_grant
serverOnly: true
fields:
### The connection the code was made for; it goes with it.
connection: AssistantConnection?, relation(onDelete=Cascade)
### SHA-256 of the code.
codeHash: String
### The redirect URI the authorization request named; the token request
### has to name it again.
redirectUri: String
### The PKCE challenge (S256) the token request's verifier has to match.
codeChallenge: String
### The MCP server the tokens are for (RFC 8707).
resource: String
### The scopes granted, space-separated.
scope: String
### When the code stops working, in UTC.
expiresAt: DateTime
indexes:
assistant_grant_hash_idx:
fields: codeHash
unique: true
# Disconnecting cascades through this index.
assistant_grant_connection_idx:
fields: connectionId

View file

@ -1,31 +0,0 @@
### An assistant's access and refresh token for one connection (ADR 0006, rule
### 5): the access token works for an hour, the refresh token for 30 days and
### only once, each use giving a new pair. Kept only as hashes.
class: AssistantToken
table: assistant_token
serverOnly: true
fields:
### The connection; the tokens go with it.
connection: AssistantConnection?, relation(onDelete=Cascade)
### SHA-256 of the access token.
accessHash: String
### When the access token stops working, in UTC.
accessExpiresAt: DateTime
### SHA-256 of the refresh token.
refreshHash: String
### When the refresh token stops working, in UTC.
refreshExpiresAt: DateTime
### The MCP server the tokens are for (RFC 8707); the server refuses others.
resource: String
### The scopes granted, space-separated.
scope: String
indexes:
assistant_token_access_idx:
fields: accessHash
unique: true
assistant_token_refresh_idx:
fields: refreshHash
unique: true
# Disconnecting cascades through this index.
assistant_token_connection_idx:
fields: connectionId

View file

@ -1,90 +0,0 @@
import 'dart:convert';
/// An HTTP request to the assistant connector as its handlers read it:
/// plain values, so tests can make one. The web server's routes
/// (`connector_routes.dart`) turn Relic's requests into these.
typedef ConnectorRequest = ({
/// The method, upper case: `GET`, `POST`.
String method,
/// The query's parameters, each its first value.
Map<String, String> query,
/// The headers, by lower-case name, several values joined by commas.
Map<String, String> headers,
/// The body as text; empty without.
String body,
});
/// A request with [method], [query], [headers] and [body], for tests and
/// the routes.
ConnectorRequest connectorRequest(
String method, {
Map<String, String> query = const {},
Map<String, String> headers = const {},
String body = '',
}) => (
method: method,
query: query,
headers: {
for (final MapEntry(:key, :value) in headers.entries)
key.toLowerCase(): value,
},
body: body,
);
/// What a connector handler answers.
class const ConnectorResponse(
/// The HTTP status.
final int status, {
/// The body.
final String body = '',
/// Its media type.
final String contentType = 'application/json',
/// Further headers, by name.
final Map<String, String> headers = const {},
}) {
/// [json] as the body, kept by nobody along the way: OAuth answers carry
/// tokens (RFC 6749, 5.1).
factory json(
int status,
Object? json, {
Map<String, String> headers = const {},
}) => ConnectorResponse(
status,
body: jsonEncode(json),
headers: {'Cache-Control': 'no-store', ...headers},
);
/// An OAuth error (RFC 6749, 5.2): [error] and what went wrong.
factory oauthError(int status, String error, String description) =>
ConnectorResponse.json(status, {
'error': error,
'error_description': description,
});
/// A redirect to [location].
factory redirect(Uri location) => ConnectorResponse(
302,
contentType: 'text/plain',
headers: {'Location': '$location', 'Cache-Control': 'no-store'},
);
}
/// The form fields of [request]'s body where it is
/// `application/x-www-form-urlencoded`; empty for anything else.
Map<String, String> formOf(ConnectorRequest request) {
final type = request.headers['content-type'] ?? '';
if (!type.toLowerCase().startsWith('application/x-www-form-urlencoded')) {
return const {};
}
try {
return Uri.splitQueryString(request.body);
} on FormatException {
return const {};
}
}

View file

@ -1,57 +0,0 @@
import 'package:halkyon_learn_server/src/assistant/connector_http.dart';
import 'package:halkyon_learn_server/src/assistant/connector_oauth.dart';
import 'package:halkyon_learn_server/src/assistant/connector_tools.dart';
import 'package:halkyon_learn_server/src/assistant/mcp_protocol.dart';
import 'package:serverpod/serverpod.dart';
/// The MCP endpoint (ADR 0006, rule 1): Streamable HTTP, each POST one
/// JSON-RPC message, answered with JSON; no sessions and no stream, so GET
/// and DELETE are refused (405), as the transport allows. Every request
/// carries an access token of [connector], and the tools read that
/// connection's person only.
Future<ConnectorResponse> answerMcpRequest(
Session session,
ConnectorRequest request,
Connector connector,
) async {
if (request.method != 'POST') {
return const ConnectorResponse(
405,
contentType: 'text/plain',
headers: {'Allow': 'POST'},
);
}
// A browser's page on a plain-HTTP origin is how DNS rebinding would
// reach a server; clients call from their own servers, or from https.
if (request.headers['origin'] case final origin?
when !origin.startsWith('https://')) {
return ConnectorResponse.json(403, {'error': 'origin not allowed'});
}
if (request.headers['mcp-protocol-version'] case final version?
when !mcpProtocolVersions.contains(version)) {
return ConnectorResponse.json(400, {
'error': 'unsupported protocol version',
'supported': mcpProtocolVersions,
});
}
final connection = await connector.connectionOf(
session,
request.headers['authorization'],
);
if (connection == null) {
return connector.unauthorized();
}
final (:message, :parseError) = decodeMcp(request.body);
if (parseError != null) {
return ConnectorResponse.json(400, parseError);
}
final answer = await answerMcp(
message,
tools: connectorTools(session, connection.authUserId),
server: (name: 'halkyon-learn', title: 'Halkyon Learn', version: '1'),
instructions: connectorInstructions,
);
return answer == null
? const ConnectorResponse(202, contentType: 'text/plain')
: ConnectorResponse.json(200, answer);
}

View file

@ -1,585 +0,0 @@
import 'dart:convert';
import 'package:clock/clock.dart';
import 'package:halkyon_base_server/base.dart';
import 'package:halkyon_learn_server/src/assistant/connector_http.dart';
import 'package:halkyon_learn_server/src/assistant/connector_page.dart';
import 'package:halkyon_learn_server/src/assistant/connector_secrets.dart';
import 'package:halkyon_learn_server/src/generated/protocol.dart';
import 'package:serverpod/serverpod.dart';
/// How long a connect code works (ADR 0006, rule 3).
const connectCodeLifetime = Duration(minutes: 10);
/// How long an authorization code works.
const grantLifetime = Duration(minutes: 5);
/// How long an access token works.
const accessTokenLifetime = Duration(hours: 1);
/// How long a refresh token works, from the last refresh.
const refreshTokenLifetime = Duration(days: 30);
/// How long a client nobody connected stays registered.
const unconnectedClientLifetime = Duration(days: 7);
/// The one scope: reading the person's content.
const readScope = 'studysets:read';
/// How many clients may register in an hour: registration is open to
/// anyone, so this keeps a flood from filling the table before pruning
/// (clients nobody connects go after a week). Claude registers once per
/// person adding the connector.
const maxRegistrationsPerHour = 200;
/// The OAuth side of the assistant connector (ADR 0006): its metadata, client
/// registration, the authorization page, the token endpoint, and checking
/// a bearer token. [base] is where the web server is reached from outside,
/// as `https://learn.halkyon.eu`, without a slash at the end; the MCP
/// endpoint is [resource]. [instance] names the instance on the page.
class Connector({required final String base, required final String instance}) {
/// The MCP endpoint, as the tokens are bound to it (RFC 8707).
String get resource => '$base/mcp';
/// Where the protected resource metadata is (RFC 9728), as 401s name it.
String get resourceMetadataUrl =>
'$base/.well-known/oauth-protected-resource';
/// The protected resource metadata (RFC 9728): the MCP endpoint, and this
/// instance as its authorization server.
ConnectorResponse resourceMetadata() => ConnectorResponse.json(200, {
'resource': resource,
'authorization_servers': [base],
'scopes_supported': [readScope],
'bearer_methods_supported': ['header'],
'resource_name': 'Halkyon Learn',
});
/// The authorization server metadata (RFC 8414).
ConnectorResponse serverMetadata() => ConnectorResponse.json(200, {
'issuer': base,
'authorization_endpoint': '$base/oauth/authorize',
'token_endpoint': '$base/oauth/token',
'registration_endpoint': '$base/oauth/register',
'response_types_supported': ['code'],
'grant_types_supported': ['authorization_code', 'refresh_token'],
'code_challenge_methods_supported': ['S256'],
'token_endpoint_auth_methods_supported': [
'none',
'client_secret_post',
'client_secret_basic',
],
'scopes_supported': [readScope],
'authorization_response_iss_parameter_supported': true,
// Fetching a client's metadata from its URL would make the instance
// request addresses anyone names; clients register instead.
'client_id_metadata_document_supported': false,
});
/// Registers a client (RFC 7591): its name and redirect URIs, and a
/// secret if it asks to authenticate with one.
Future<ConnectorResponse> register(
Session session,
ConnectorRequest request,
) async {
final Map<String, Object?> metadata;
try {
metadata = switch (jsonDecode(request.body)) {
final Map<String, Object?> json => json,
_ => throw const FormatException(),
};
} on FormatException {
return ConnectorResponse.oauthError(
400,
'invalid_client_metadata',
'The body is not a JSON object.',
);
}
final redirects = switch (metadata['redirect_uris']) {
final List<Object?> uris when uris.isNotEmpty && uris.length <= 5 => uris,
_ => null,
};
if (redirects == null || !redirects.every(_acceptableRedirect)) {
return ConnectorResponse.oauthError(
400,
'invalid_redirect_uri',
'Give one to five redirect URIs, each https, or http on localhost.',
);
}
final method = switch (metadata['token_endpoint_auth_method']) {
null => 'none',
final String method when _authMethods.contains(method) => method,
_ => null,
};
if (method == null) {
return ConnectorResponse.oauthError(
400,
'invalid_client_metadata',
'token_endpoint_auth_method must be one of ${_authMethods.join(', ')}.',
);
}
if (metadata['grant_types'] case final List<Object?> types
when !types.contains('authorization_code')) {
return ConnectorResponse.oauthError(
400,
'invalid_client_metadata',
'Only the authorization_code grant is offered.',
);
}
final name = switch (metadata['client_name']) {
final String name when name.trim().isNotEmpty => _plain(name, 100),
_ => 'MCP client',
};
final hourAgo = clock.now().toUtc().subtract(const Duration(hours: 1));
if (await AssistantClient.db.count(
session,
where: (t) => t.createdAt > hourAgo,
) >=
maxRegistrationsPerHour) {
return ConnectorResponse.oauthError(
429,
'temporarily_unavailable',
'Too many registrations in the last hour; try again later.',
);
}
final secret = method == 'none' ? null : randomSecret();
final client = await AssistantClient.db.insertRow(
session,
AssistantClient(
clientId: randomSecret(16),
name: name,
redirectUris: [for (final uri in redirects) '$uri'],
secretHash: secret == null ? null : hashSecret(secret),
createdAt: clock.now().toUtc(),
),
);
return ConnectorResponse.json(201, {
'client_id': client.clientId,
'client_id_issued_at': client.createdAt.millisecondsSinceEpoch ~/ 1000,
'client_secret': ?secret,
if (secret != null) 'client_secret_expires_at': 0,
'client_name': client.name,
'redirect_uris': client.redirectUris,
'grant_types': ['authorization_code', 'refresh_token'],
'response_types': ['code'],
'token_endpoint_auth_method': method,
'scope': readScope,
});
}
/// The authorization page: asks for a connect code (GET), and grants
/// access on a right one (POST), sending the person back to the client.
///
/// A request naming an unknown client or a redirect URI it didn't
/// register gets an error page and never a redirect (RFC 6749, 4.1.2.1);
/// other faults go back to the client as errors.
Future<ConnectorResponse> authorize(
Session session,
ConnectorRequest request,
) async {
final post = request.method == 'POST';
final fields = post ? formOf(request) : request.query;
final language = pageLanguage(request.headers['accept-language']);
final clientId = fields['client_id'];
final client = clientId == null
? null
: await AssistantClient.db.findFirstRow(
session,
where: (t) => t.clientId.equals(clientId),
);
final redirect = fields['redirect_uri'];
if (client == null ||
redirect == null ||
!client.redirectUris.contains(redirect)) {
return ConnectorResponse(
400,
contentType: 'text/html; charset=utf-8',
body: authorizeProblemPage(language),
headers: pageHeaders(null),
);
}
final redirectUri = Uri.parse(redirect);
final state = fields['state'];
ConnectorResponse refuse(String error, String description) =>
ConnectorResponse.redirect(
redirectUri.replace(
queryParameters: {
...redirectUri.queryParameters,
'error': error,
'error_description': description,
'state': ?state,
'iss': base,
},
),
);
if (fields['response_type'] != 'code') {
return refuse('unsupported_response_type', 'Only code is offered.');
}
final challenge = fields['code_challenge'];
if (challenge == null ||
challenge.isEmpty ||
fields['code_challenge_method'] != 'S256') {
return refuse('invalid_request', 'PKCE with S256 is required.');
}
final asked = fields['resource'];
if (asked != null && _withoutSlash(asked) != resource) {
return refuse('invalid_target', 'The resource is $resource.');
}
final scope = fields['scope'];
if (scope != null &&
scope.trim().isNotEmpty &&
!scope.split(' ').every((each) => each.isEmpty || each == readScope)) {
return refuse('invalid_scope', 'The scope offered is $readScope.');
}
final page = (
language: language,
client: client.name,
returnsTo: redirectUri.host.isEmpty ? redirect : redirectUri.host,
instance: instance,
fields: {
for (final name in _carried) name: ?fields[name],
},
);
if (!post) {
return ConnectorResponse(
200,
contentType: 'text/html; charset=utf-8',
body: authorizePage(page),
headers: pageHeaders(redirectUri),
);
}
final now = clock.now().toUtc();
final typed = normalizeShortCode(fields['connect_code'] ?? '');
final code = typed.isEmpty
? null
: await AssistantConnectCode.db.findFirstRow(
session,
where: (t) =>
t.codeHash.equals(hashSecret(typed)) & (t.expiresAt > now),
);
if (code == null) {
return ConnectorResponse(
200,
contentType: 'text/html; charset=utf-8',
body: authorizePage(page, wrongCode: true),
headers: pageHeaders(redirectUri),
);
}
final grant = randomSecret();
final granted = await session.db.transaction((transaction) async {
// Used once: of two requests racing with it, only the one that deletes
// it goes on.
final spent = await AssistantConnectCode.db.deleteWhere(
session,
where: (t) => t.id.equals(code.id),
transaction: transaction,
);
if (spent.isEmpty) {
return false;
}
// One connection per person and client: connecting again renews it.
final connection =
await AssistantConnection.db.findFirstRow(
session,
where: (t) =>
t.authUserId.equals(code.authUserId) &
t.clientId.equals(client.id),
transaction: transaction,
) ??
await AssistantConnection.db.insertRow(
session,
AssistantConnection(
authUserId: code.authUserId,
clientId: client.id!,
createdAt: now,
),
transaction: transaction,
);
await AssistantGrant.db.insertRow(
session,
AssistantGrant(
connectionId: connection.id!,
codeHash: hashSecret(grant),
redirectUri: redirect,
codeChallenge: challenge,
resource: resource,
scope: readScope,
expiresAt: now.add(grantLifetime),
),
transaction: transaction,
);
return true;
});
if (!granted) {
return ConnectorResponse(
200,
contentType: 'text/html; charset=utf-8',
body: authorizePage(page, wrongCode: true),
headers: pageHeaders(redirectUri),
);
}
return ConnectorResponse.redirect(
redirectUri.replace(
queryParameters: {
...redirectUri.queryParameters,
'code': grant,
'state': ?state,
'iss': base,
},
),
);
}
/// The token endpoint (RFC 6749, 3.2): an authorization code for tokens,
/// or a refresh token for new ones, the old one spent.
Future<ConnectorResponse> token(
Session session,
ConnectorRequest request,
) async {
final form = formOf(request);
final now = clock.now().toUtc();
final client = await _authenticate(session, request, form);
if (client == null) {
return ConnectorResponse.oauthError(
401,
'invalid_client',
'The client is unknown, or its secret is wrong.',
);
}
if (form['resource'] case final asked?
when _withoutSlash(asked) != resource) {
return ConnectorResponse.oauthError(
400,
'invalid_target',
'The resource is $resource.',
);
}
switch (form['grant_type']) {
case 'authorization_code':
final code = form['code'];
final grant = code == null
? null
: await AssistantGrant.db.findFirstRow(
session,
where: (t) => t.codeHash.equals(hashSecret(code)),
include: AssistantGrant.include(
connection: AssistantConnection.include(),
),
);
// Used once, whatever comes of it: of two requests racing with it,
// only the one that deletes it goes on.
if (grant == null ||
(await AssistantGrant.db.deleteWhere(
session,
where: (t) => t.id.equals(grant.id),
)).isEmpty) {
return _invalidGrant('The code is unknown or used.');
}
if (!grant.expiresAt.isAfter(now) ||
grant.connection?.clientId != client.id ||
form['redirect_uri'] != grant.redirectUri ||
!pkceMatches(form['code_verifier'] ?? '', grant.codeChallenge)) {
return _invalidGrant(
'The code expired, or the client, the redirect URI or the '
'code verifier differ.',
);
}
return await _issue(session, grant.connectionId, grant.scope, now);
case 'refresh_token':
final refresh = form['refresh_token'];
final tokens = refresh == null
? null
: await AssistantToken.db.findFirstRow(
session,
where: (t) => t.refreshHash.equals(hashSecret(refresh)),
include: AssistantToken.include(
connection: AssistantConnection.include(),
),
);
if (tokens == null ||
!tokens.refreshExpiresAt.isAfter(now) ||
tokens.connection?.clientId != client.id) {
return _invalidGrant('The refresh token is unknown or expired.');
}
// Spent: a new pair replaces it, for the one request that deletes it.
if ((await AssistantToken.db.deleteWhere(
session,
where: (t) => t.id.equals(tokens.id),
)).isEmpty) {
return _invalidGrant('The refresh token is used.');
}
return await _issue(session, tokens.connectionId, tokens.scope, now);
default:
return ConnectorResponse.oauthError(
400,
'unsupported_grant_type',
'The grants offered are authorization_code and refresh_token.',
);
}
}
/// The connection [authorization] (`Bearer …`) is a valid access token of,
/// for this connector; null for none. Notes when it was last used, to the
/// minute.
Future<AssistantConnection?> connectionOf(
Session session,
String? authorization,
) async {
final match = RegExp(
r'^Bearer\s+(\S+)$',
caseSensitive: false,
).firstMatch(authorization?.trim() ?? '');
if (match == null) {
return null;
}
final now = clock.now().toUtc();
final tokens = await AssistantToken.db.findFirstRow(
session,
where: (t) =>
t.accessHash.equals(hashSecret(match.group(1)!)) &
(t.accessExpiresAt > now),
include: AssistantToken.include(
connection: AssistantConnection.include(),
),
);
final connection = tokens?.connection;
if (tokens == null || connection == null || tokens.resource != resource) {
return null;
}
final last = connection.lastUsedAt;
if (last == null || now.difference(last) >= const Duration(minutes: 1)) {
await AssistantConnection.db.updateRow(
session,
connection.copyWith(lastUsedAt: now),
);
}
return connection;
}
/// What a request without a valid token gets: 401, naming where to find
/// out how to get one (RFC 9728, 5.1).
ConnectorResponse unauthorized() => ConnectorResponse.json(
401,
{'error': 'invalid_token'},
headers: {
'WWW-Authenticate':
'Bearer resource_metadata="$resourceMetadataUrl", '
'scope="$readScope"',
},
);
Future<ConnectorResponse> _issue(
Session session,
int connectionId,
String scope,
DateTime now,
) async {
final access = randomSecret();
final refresh = randomSecret();
await AssistantToken.db.insertRow(
session,
AssistantToken(
connectionId: connectionId,
accessHash: hashSecret(access),
accessExpiresAt: now.add(accessTokenLifetime),
refreshHash: hashSecret(refresh),
refreshExpiresAt: now.add(refreshTokenLifetime),
resource: resource,
scope: scope,
),
);
return ConnectorResponse.json(200, {
'access_token': access,
'token_type': 'Bearer',
'expires_in': accessTokenLifetime.inSeconds,
'refresh_token': refresh,
'scope': scope,
});
}
/// The client of a token request, authenticated as it registered: by its
/// id alone, or with its secret in the form or in Basic authorization.
Future<AssistantClient?> _authenticate(
Session session,
ConnectorRequest request,
Map<String, String> form,
) async {
var id = form['client_id'];
var secret = form['client_secret'];
final basic = RegExp(
r'^Basic\s+(\S+)$',
caseSensitive: false,
).firstMatch(request.headers['authorization']?.trim() ?? '');
if (basic != null) {
try {
final pair = utf8.decode(base64.decode(basic.group(1)!));
final colon = pair.indexOf(':');
if (colon > 0) {
id = Uri.decodeQueryComponent(pair.substring(0, colon));
secret = Uri.decodeQueryComponent(pair.substring(colon + 1));
}
} on FormatException {
return null;
}
}
if (id == null) {
return null;
}
final client = await AssistantClient.db.findFirstRow(
session,
where: (t) => t.clientId.equals(id),
);
if (client == null) {
return null;
}
return switch (client.secretHash) {
null => client,
final hash when secret != null && hashSecret(secret) == hash => client,
_ => null,
};
}
}
ConnectorResponse _invalidGrant(String description) =>
ConnectorResponse.oauthError(400, 'invalid_grant', description);
/// How a client may authenticate at the token endpoint.
const _authMethods = ['none', 'client_secret_post', 'client_secret_basic'];
/// The fields the authorization page carries from its GET to its POST.
const _carried = [
'response_type',
'client_id',
'redirect_uri',
'state',
'scope',
'code_challenge',
'code_challenge_method',
'resource',
];
/// Whether [uri] may be a redirect URI: absolute, without a fragment, https,
/// or http on the device itself (a desktop client's loopback).
bool _acceptableRedirect(Object? uri) {
if (uri is! String || uri.length > 512) {
return false;
}
final parsed = Uri.tryParse(uri);
if (parsed == null || !parsed.isAbsolute || parsed.hasFragment) {
return false;
}
return switch (parsed.scheme) {
'https' => parsed.host.isNotEmpty,
'http' => const ['localhost', '127.0.0.1', '::1'].contains(parsed.host),
_ => false,
};
}
/// [text] without control characters, at most [length] long.
String _plain(String text, int length) {
final plain = text.replaceAll(RegExp(r'[\x00-\x1F\x7F]'), ' ').trim();
return plain.length <= length ? plain : plain.substring(0, length);
}
String _withoutSlash(String uri) =>
uri.endsWith('/') ? uri.substring(0, uri.length - 1) : uri;

View file

@ -1,194 +0,0 @@
import 'dart:convert';
/// The authorization page of the assistant connector (ADR 0006, rule 3): a
/// person connects an assistant by typing the code their app shows. In
/// English or German, as the browser asks; no scripts, nothing loaded from
/// elsewhere, and no page may frame it.
/// The languages the page speaks.
enum PageLanguage() {
en,
de,
}
/// The language [acceptLanguage] (the header) prefers among the page's:
/// German where German comes before English, else English.
PageLanguage pageLanguage(String? acceptLanguage) {
for (final part in (acceptLanguage ?? '').split(',')) {
final tag = part.split(';').first.trim().toLowerCase();
if (tag.startsWith('de')) {
return PageLanguage.de;
}
if (tag.startsWith('en')) {
return PageLanguage.en;
}
}
return PageLanguage.en;
}
/// What the page shows: in which `language`, which `client` asks, the host
/// it `returnsTo`, on which `instance`, and the request's `fields` to carry
/// to the form's POST.
typedef AuthorizePage = ({
PageLanguage language,
String client,
String returnsTo,
String instance,
Map<String, String> fields,
});
/// The page's headers: never framed, never kept, no referrer; the form may
/// post here and go on to [redirect]'s origin.
Map<String, String> pageHeaders(Uri? redirect) => {
'Content-Security-Policy':
"default-src 'none'; style-src 'unsafe-inline'; "
"form-action 'self'${redirect == null ? '' : ' ${redirect.origin}'}; "
"frame-ancestors 'none'; base-uri 'none'",
'X-Frame-Options': 'DENY',
'Referrer-Policy': 'no-referrer',
'Cache-Control': 'no-store',
};
/// The page asking for a connect code; with [wrongCode], saying the last
/// one didn't work.
String authorizePage(AuthorizePage page, {bool wrongCode = false}) {
final t = _texts[page.language]!;
final client = _escape(page.client);
final hidden = [
for (final MapEntry(:key, :value) in page.fields.entries)
'<input type="hidden" name="${_escape(key)}" value="${_escape(value)}">',
].join('\n ');
return _document(
page.language,
t.title(client),
'''
<h1>${t.title(client)}</h1>
<p>${t.asks(client, _escape(page.instance))}</p>
<p>${t.returns(_escape(page.returnsTo))}</p>
<form method="post">
$hidden
<label for="code">${t.codeLabel}</label>
<input id="code" name="connect_code" required autofocus
autocomplete="one-time-code" autocapitalize="characters"
spellcheck="false" maxlength="16" placeholder="K7QX-M2PD"
${wrongCode ? 'aria-invalid="true" aria-describedby="wrong"' : ''}>
${wrongCode ? '<p id="wrong" class="wrong">${t.wrongCode}</p>' : ''}
<button type="submit">${t.connect}</button>
</form>
<p class="quiet">${t.notYou}</p>''',
);
}
/// The page for a request that names no registered client or redirect URI:
/// nothing to send back to, so it only says so.
String authorizeProblemPage(PageLanguage language) {
final t = _texts[language]!;
return _document(
language,
t.problemTitle,
'<h1>${t.problemTitle}</h1>\n <p>${t.problem}</p>',
);
}
String _document(PageLanguage language, String title, String body) =>
'''
<!doctype html>
<html lang="${language.name}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="referrer" content="no-referrer">
<title>$title</title>
<style>
:root { color-scheme: light dark; --accent: #273eb2; --bg: #f7f9ff;
--fg: #191b24; --muted: #474b5c; --line: #c4cadb; --wrong: #ba1e55; }
@media (prefers-color-scheme: dark) { :root { --accent: #aeb9ff;
--bg: #131520; --fg: #e5e7f2; --muted: #c0c4d6; --line: #434862;
--wrong: #ff9ebb; } }
body { margin: 0; padding: 2rem 1rem; background: var(--bg);
color: var(--fg); font: 1.0625rem/1.6 system-ui, sans-serif; }
main { max-width: 32rem; margin: 0 auto; }
h1 { font-size: 1.5rem; line-height: 1.25; margin: 0 0 1rem; }
label { display: block; font-weight: 600; margin: 1.5rem 0 .4rem; }
input { box-sizing: border-box; width: 100%; padding: .7rem .9rem;
font: 1.4rem ui-monospace, monospace; letter-spacing: .15em;
text-transform: uppercase; border: 2px solid var(--line);
border-radius: 12px; background: transparent; color: inherit; }
input:focus { outline: 3px solid var(--accent); outline-offset: 1px; }
button { margin-top: 1rem; padding: .7rem 1.6rem; border: 0;
border-radius: 999px; background: var(--accent); color: var(--bg);
font: inherit; font-weight: 700; cursor: pointer; }
.wrong { color: var(--wrong); margin: .5rem 0 0; }
.quiet { color: var(--muted); font-size: .95rem; margin-top: 2rem; }
</style>
</head>
<body>
<main>
$body
</main>
</body>
</html>
''';
/// [text] safe in HTML text and in a double-quoted attribute.
String _escape(String text) => const HtmlEscape().convert(text);
typedef _Texts = ({
String Function(String client) title,
String Function(String client, String instance) asks,
String Function(String host) returns,
String codeLabel,
String connect,
String wrongCode,
String notYou,
String problemTitle,
String problem,
});
final _texts = <PageLanguage, _Texts>{
PageLanguage.en: (
title: (client) => 'Connect $client to Halkyon Learn',
asks: (client, instance) =>
'$client asks to read your studysets, cards, notes and progress '
'on $instance. It can change nothing. You can disconnect it in the '
'app at any time.',
returns: (host) => 'Afterwards you return to <strong>$host</strong>.',
codeLabel:
'The code from Halkyon Learn (Settings › Assistant › Connect '
'Claude)',
connect: 'Connect',
wrongCode:
"That code doesn't work: it may be mistyped, used or older than ten "
'minutes. Get a new one in the app.',
notYou:
"Didn't expect this? Close the page: nothing is connected without a "
'code from your app.',
problemTitle: 'This link is not complete',
problem:
'The app that sent you here named itself or its address wrongly, so '
'nothing can be connected. Try again from where you started.',
),
PageLanguage.de: (
title: (client) => '$client mit Halkyon Learn verbinden',
asks: (client, instance) =>
'$client möchte deine Lernsets, Karten, Notizen und deinen '
'Lernstand auf $instance lesen. Ändern kann es nichts. Du kannst '
'die Verbindung jederzeit in der App trennen.',
returns: (host) => 'Danach kehrst du zu <strong>$host</strong> zurück.',
codeLabel:
'Der Code aus Halkyon Learn (Einstellungen › Assistent › Claude '
'verbinden)',
connect: 'Verbinden',
wrongCode:
'Dieser Code passt nicht: Vielleicht ist er vertippt, schon benutzt '
'oder älter als zehn Minuten. Hol dir in der App einen neuen.',
notYou:
'Nicht erwartet? Schließ die Seite: Ohne einen Code aus deiner App '
'wird nichts verbunden.',
problemTitle: 'Dieser Link ist unvollständig',
problem:
'Die App, die dich hierher geschickt hat, hat sich oder ihre Adresse '
'falsch angegeben, daher lässt sich nichts verbinden. Versuch es '
'noch einmal von dort, wo du angefangen hast.',
),
};

View file

@ -1,122 +0,0 @@
import 'dart:async';
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:serverpod/serverpod.dart';
/// The assistant connector of [pod] (ADR 0006), reached at its web server's
/// public address; none where the configuration has no web server.
Connector? connectorOf(Serverpod pod) {
final web = pod.config.webServer;
if (web == null) {
return null;
}
final usual = web.publicScheme == 'https' ? 443 : 80;
final port = web.publicPort == usual ? '' : ':${web.publicPort}';
return Connector(
base: '${web.publicScheme}://${web.publicHost}$port',
instance: pod.config.apiServer.publicHost,
);
}
/// Serves the connector on [pod]'s web server, where it has one: the OAuth
/// metadata, registration, the authorization page, the token endpoint and
/// the MCP endpoint. Caddy forwards these paths alone to the web server
/// (`deploy/Caddyfile`).
void addConnectorRoutes(Serverpod pod) {
final connector = connectorOf(pod);
if (connector == null) {
return;
}
ConnectorResponse resource(Session _, ConnectorRequest _) =>
connector.resourceMetadata();
ConnectorResponse server(Session _, ConnectorRequest _) =>
connector.serverMetadata();
pod.webServer
..addRoute(
_ConnectorRoute(resource, {Method.get}),
'/.well-known/oauth-protected-resource',
)
// Clients may ask with the resource's path appended (RFC 9728, 3.1).
..addRoute(
_ConnectorRoute(resource, {Method.get}),
'/.well-known/oauth-protected-resource/mcp',
)
..addRoute(
_ConnectorRoute(server, {Method.get}),
'/.well-known/oauth-authorization-server',
)
..addRoute(
_ConnectorRoute(connector.register, {Method.post}),
'/oauth/register',
)
..addRoute(
_ConnectorRoute(connector.authorize, {Method.get, Method.post}),
'/oauth/authorize',
)
..addRoute(
_ConnectorRoute(connector.token, {Method.post}),
'/oauth/token',
)
..addRoute(
_ConnectorRoute(
(session, request) => answerMcpRequest(session, request, connector),
{Method.post, Method.get, Method.delete},
),
'/mcp',
);
}
/// A Relic route around a connector handler: the request read into plain
/// values (a body of at most [_maxBody] bytes), the answer written back.
class _ConnectorRoute(
final FutureOr<ConnectorResponse> Function(
Session session,
ConnectorRequest request,
)
_handle,
Set<Method> methods,
) extends Route {
this : super(methods: methods);
/// The largest body read: a registration or a JSON-RPC message is small.
static const _maxBody = 64 * 1024;
@override
Future<Result> handleCall(Session session, Request request) async {
String body;
try {
body = await request.readAsString(maxLength: _maxBody);
} on Object {
return Response(413);
}
final answer = await _handle(
session,
connectorRequest(
request.method.value,
query: request.url.queryParameters,
headers: {
for (final MapEntry(:key, :value) in request.headers.entries)
key: value.join(', '),
},
body: body,
),
);
final (type, subtype) = switch (answer.contentType
.split(';')
.first
.split('/')) {
[final type, final subtype] => (type.trim(), subtype.trim()),
_ => ('text', 'plain'),
};
return Response(
answer.status,
body: Body.fromString(answer.body, mimeType: MimeType(type, subtype)),
headers: Headers.fromMap({
for (final MapEntry(:key, :value) in answer.headers.entries)
key: [value],
}),
);
}
}

View file

@ -1,51 +0,0 @@
import 'dart:convert';
import 'dart:math';
import 'package:crypto/crypto.dart';
/// The secrets of the assistant connector (ADR 0006): connect codes,
/// authorization codes, tokens, and how each is checked. Every one is kept
/// as [hashSecret] only, so the database never holds one that works.
final _random = Random.secure();
/// A random secret of [bytes] bytes, base64url without padding: 256 bits
/// for codes and tokens.
String randomSecret([int bytes = 32]) => _base64Url([
for (var i = 0; i < bytes; i++) _random.nextInt(256),
]);
/// SHA-256 of [secret], in hex: how a secret is kept and looked up. A
/// secret of 256 random bits needs no salt or slow hash; a connect code
/// is short, but lives ten minutes and is used once.
String hashSecret(String secret) =>
sha256.convert(utf8.encode(secret)).toString();
/// A PKCE code verifier's form (RFC 7636, 4.1): 43 to 128 unreserved
/// characters.
final _verifier = RegExp(r'^[A-Za-z0-9\-._~]{43,128}$');
/// Whether [verifier] answers [challenge] with S256 (RFC 7636, 4.6), the
/// only method the connector accepts (OAuth 2.1 drops `plain`).
bool pkceMatches(String verifier, String challenge) =>
_verifier.hasMatch(verifier) &&
_sameText(
_base64Url(sha256.convert(ascii.encode(verifier)).bytes),
challenge,
);
/// Whether [a] and [b] are equal, in time that doesn't depend on where they
/// differ.
bool _sameText(String a, String b) {
if (a.length != b.length) {
return false;
}
var difference = 0;
for (var i = 0; i < a.length; i++) {
difference |= a.codeUnitAt(i) ^ b.codeUnitAt(i);
}
return difference == 0;
}
String _base64Url(List<int> bytes) =>
base64UrlEncode(bytes).replaceAll('=', '');

View file

@ -1,672 +0,0 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:clock/clock.dart';
import 'package:halkyon_learn_server/src/assistant/mcp_protocol.dart';
import 'package:halkyon_learn_server/src/generated/protocol.dart';
import 'package:halkyon_learn_server/src/studyset/deleted_studysets.dart';
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_offline_sync_server/serverpod_offline_sync_server.dart';
/// The connector's tools (ADR 0006, rule 2): reading [person]'s own
/// studysets, cards, notes and progress, as the sync engine holds them for
/// their devices. Nothing here writes, and nothing reads anyone else's.
List<McpTool> connectorTools(Session session, UuidValue person) {
final content = _Content(session, person);
return [
McpTool(
name: 'list_studysets',
title: 'List studysets',
description:
"Lists the person's studysets in Halkyon Learn, a flashcard and "
'learning app: each with its id, title, where it sits (the '
'studysets above it), whether it is archived, and how many cards '
'and notes it holds; and how many are private, end-to-end '
'encrypted, which nothing here can read.',
inputSchema: const {'type': 'object', 'properties': <String, Object?>{}},
call: (_) async => _json(await content.studysets()),
),
McpTool(
name: 'read_studyset',
title: 'Read a studyset',
description:
'Reads one studyset: its title and description, its cards (front '
'and back as text; for words their reading and meanings) a page at '
'a time, and the titles of its notes.',
inputSchema: const {
'type': 'object',
'properties': {
'studyset_id': {
'type': 'string',
'description': 'The id, from list_studysets.',
},
'offset': {
'type': 'integer',
'minimum': 0,
'description': 'How many cards to skip; 0 by default.',
},
'limit': {
'type': 'integer',
'minimum': 1,
'maximum': _pageMax,
'description': 'How many cards at most; $_pageSize by default.',
},
},
'required': ['studyset_id'],
},
call: (arguments) async => _json(
await content.studyset(
_id(arguments, 'studyset_id'),
offset: _int(arguments, 'offset', 0, 0, 1 << 30),
limit: _int(arguments, 'limit', _pageSize, 1, _pageMax),
),
),
),
McpTool(
name: 'search_cards',
title: 'Search cards',
description:
"Finds the person's cards whose text contains the query (case "
'insensitive), in all studysets or in one: at most 50.',
inputSchema: const {
'type': 'object',
'properties': {
'query': {'type': 'string', 'minLength': 1},
'studyset_id': {
'type': 'string',
'description': 'Only this studyset and those inside it.',
},
},
'required': ['query'],
},
call: (arguments) async => _json(
await content.search(
switch (arguments['query']) {
final String query when query.trim().isNotEmpty => query.trim(),
_ => throw const McpToolError('Give a query.'),
},
within: switch (arguments['studyset_id']) {
null => null,
_ => _id(arguments, 'studyset_id'),
},
),
),
),
McpTool(
name: 'read_note',
title: 'Read a note',
description: 'Reads one note of a studyset in full, as text.',
inputSchema: const {
'type': 'object',
'properties': {
'note_id': {
'type': 'string',
'description': 'The id, from read_studyset.',
},
},
'required': ['note_id'],
},
call: (arguments) async =>
_json(await content.note(_id(arguments, 'note_id'))),
),
McpTool(
name: 'study_progress',
title: 'Study progress',
description:
'How the person has been studying, overall or in one studyset '
'(with those inside it): cards studied and not yet, reviews in '
'the last 7 and 30 days, on how many days, how they rated '
'themselves (again, hard, good, easy: the app shows them as Bad, '
'OK, Good, Perfect), and when they last studied.',
inputSchema: const {
'type': 'object',
'properties': {
'studyset_id': {
'type': 'string',
'description': 'Only this studyset; all without.',
},
},
},
call: (arguments) async => _json(
await content.progress(
within: switch (arguments['studyset_id']) {
null => null,
_ => _id(arguments, 'studyset_id'),
},
),
),
),
];
}
/// What [connectorTools] tell the model about themselves, in `initialize`.
const connectorInstructions =
"These tools read the person's own content in Halkyon Learn, a "
'flashcard and learning app: studysets (which nest), their flashcards '
'and notes, and how the person has been studying. They only read. Start '
'with list_studysets to find a studyset id.';
const _pageSize = 100;
const _pageMax = 200;
/// A person's content as the tools read it.
class _Content(final Session _session, final UuidValue _person) {
int? _space;
List<({Studyset row, Map<String, Object?> payload})>? _studysets;
/// The person's sync space; none before their first sync.
Future<int?> _spaceId() async =>
_space ??= (await OfflineSyncSpace.db.findFirstRow(
_session,
where: (t) => t.uuidSpaceId.equals(_person),
))?.id;
/// Their studysets that aren't deleted (nor inside a deleted one, nor
/// being deleted for good), with their payloads.
Future<List<({Studyset row, Map<String, Object?> payload})>> _all() async {
if (_studysets case final known?) {
return known;
}
final space = await _spaceId();
final rows = space == null
? const <Studyset>[]
: await Studyset.db.find(
_session,
where: (t) => t.spaceId.equals(space),
orderBy: (t) => t.position,
);
final alive = notDeleted(rows);
// Private studysets (ADR 0019) are encrypted on the person's devices:
// nothing here reads them, and only their number is told.
final private = {
for (final row in alive)
if (row.keyId != null) row.id!,
};
_private = {
for (final row in alive)
if (row.keyId != null && !private.contains(row.parentId)) row.id!,
};
_privateTrees = private;
return _studysets = [
for (final row in alive)
if (row.keyId == null)
if (_decoded(row.payload) case final payload?)
(row: row, payload: payload),
];
}
/// The top-level private studysets [_all] left out.
Set<UuidValue> _private = const {};
/// Every studyset of a private tree, the top-level ones and those inside.
Set<UuidValue> _privateTrees = const {};
Future<Map<String, Object?>> studysets() async {
final all = await _all();
final space = await _spaceId();
final cards = await _countBy(
space,
(ids) => Card.db.find(_session, where: (t) => t.studysetId.inSet(ids)),
(card) => card.studysetId,
all,
);
final notes = await _countBy(
space,
(ids) => Note.db.find(_session, where: (t) => t.studysetId.inSet(ids)),
(note) => note.studysetId,
all,
);
return {
'studysets': [
for (final (:row, :payload) in all)
{
'id': row.id!.uuid,
'title': _title(payload),
if (_text(payload['secondaryTitle']) case final subtitle
when subtitle.isNotEmpty)
'subtitle': subtitle,
if (_path(row, all) case final path when path.isNotEmpty)
'inside': path,
if (row.archivedAt != null) 'archived': true,
'cards': cards[row.id] ?? 0,
'notes': notes[row.id] ?? 0,
},
],
if (_private.isNotEmpty) ...{
'private_studysets': _private.length,
'private_note': _privateNote,
},
};
}
Future<Map<String, Object?>> studyset(
UuidValue id, {
required int offset,
required int limit,
}) async {
final all = await _all();
final found = all.where((each) => each.row.id == id).firstOrNull;
if (found == null) {
throw McpToolError(
_privateTrees.contains(id)
? _privateNote
: 'No studyset of the person has this id.',
);
}
final (:row, :payload) = found;
final cards = await _cards({id});
final notes = await _notes({id});
final page = cards.skip(offset).take(limit).toList();
return {
'id': id.uuid,
'title': _title(payload),
if (_text(payload['secondaryTitle']) case final subtitle
when subtitle.isNotEmpty)
'subtitle': subtitle,
if (_text(payload['description']) case final description
when description.isNotEmpty)
'description': description,
if (_path(row, all) case final path when path.isNotEmpty) 'inside': path,
'studysets_inside': [
for (final child in all)
if (child.row.parentId == id)
{'id': child.row.id!.uuid, 'title': _title(child.payload)},
],
'cards_total': cards.length,
'cards': page,
if (offset + page.length < cards.length)
'next_offset': offset + page.length,
'notes': [
for (final note in notes) {'id': note['id'], 'title': note['title']},
],
};
}
Future<List<Map<String, Object?>>> search(
String query, {
UuidValue? within,
}) async {
final all = await _all();
final ids = within == null
? {for (final each in all) each.row.id!}
: _subtree(within, all);
if (within != null && ids.isEmpty) {
throw const McpToolError('No studyset of the person has this id.');
}
final titles = {for (final each in all) each.row.id: _title(each.payload)};
final needle = query.toLowerCase();
final cards = await _cards(ids);
return [
for (final card in cards)
if ('${card['front']}\n${card['back']}'.toLowerCase().contains(needle))
{
...card,
'studyset': titles[UuidValue.fromString('${card['studyset_id']}')],
},
].take(50).toList();
}
Future<Map<String, Object?>> note(UuidValue id) async {
final space = await _spaceId();
final row = space == null
? null
: await Note.db.findFirstRow(
_session,
where: (t) => t.spaceId.equals(space) & t.id.equals(id),
);
final studyset = (await _all())
.where((each) => each.row.id == row?.studysetId)
.firstOrNull;
if (row == null || studyset == null) {
throw const McpToolError('No note of the person has this id.');
}
final payload = await _head(
NoteRevision.db.find(_session, where: (t) => t.noteId.equals(id)),
(revision) => revision.id!,
(revision) => revision.parents,
(revision) => revision.payload,
);
if (payload == null) {
throw const McpToolError('No note of the person has this id.');
}
return {
'id': id.uuid,
'studyset': _title(studyset.payload),
'title': _text(payload['title']),
'text': _text(payload['body']),
};
}
Future<Map<String, Object?>> progress({UuidValue? within}) async {
final all = await _all();
final ids = within == null
? {for (final each in all) each.row.id!}
: _subtree(within, all);
if (within != null && ids.isEmpty) {
throw const McpToolError('No studyset of the person has this id.');
}
final space = await _spaceId();
final cards = space == null || ids.isEmpty
? const <Card>[]
: await Card.db.find(
_session,
where: (t) => t.spaceId.equals(space) & t.studysetId.inSet(ids),
);
final cardIds = {for (final card in cards) card.id!};
final reviews = cardIds.isEmpty
? const <ReviewLog>[]
: await ReviewLog.db.find(
_session,
where: (t) => t.cardId.inSet(cardIds),
orderBy: (t) => t.reviewedAt,
);
final now = clock.now().toUtc();
final week = now.subtract(const Duration(days: 7));
final month = now.subtract(const Duration(days: 30));
String day(DateTime time) =>
time.toUtc().toIso8601String().substring(0, 10);
const names = {1: 'again', 2: 'hard', 3: 'good', 4: 'easy'};
return {
'cards': cards.length,
'cards_studied': {for (final review in reviews) review.cardId}.length,
'reviews_last_7_days': reviews
.where((review) => review.reviewedAt.isAfter(week))
.length,
'reviews_last_30_days': reviews
.where((review) => review.reviewedAt.isAfter(month))
.length,
'days_studied_last_30_days': {
for (final review in reviews)
if (review.reviewedAt.isAfter(month)) day(review.reviewedAt),
}.length,
'ratings_last_30_days': {
for (final MapEntry(:key, :value) in names.entries)
value: reviews
.where(
(review) =>
review.rating == key && review.reviewedAt.isAfter(month),
)
.length,
},
if (reviews.lastOrNull case final last?)
'last_studied': last.reviewedAt.toUtc().toIso8601String(),
};
}
/// The cards of the studysets [ids] as text, in their order.
Future<List<Map<String, Object?>>> _cards(Set<UuidValue> ids) async {
final space = await _spaceId();
if (space == null || ids.isEmpty) {
return const [];
}
final cards = await Card.db.find(
_session,
where: (t) => t.spaceId.equals(space) & t.studysetId.inSet(ids),
orderBy: (t) => t.position,
);
final revisions = await CardRevision.db.find(
_session,
where: (t) => t.cardId.inSet({for (final card in cards) card.id!}),
);
final byCard = <UuidValue, List<CardRevision>>{};
for (final revision in revisions) {
(byCard[revision.cardId] ??= []).add(revision);
}
return [
for (final card in cards)
if (_latest<CardRevision>(
byCard[card.id] ?? const [],
(revision) => revision.id!,
(revision) => revision.parents,
(revision) => revision.payload,
)
case final payload?)
_card(card, payload),
];
}
/// The notes of the studysets [ids], with their text.
Future<List<Map<String, Object?>>> _notes(Set<UuidValue> ids) async {
final space = await _spaceId();
if (space == null || ids.isEmpty) {
return const [];
}
final notes = await Note.db.find(
_session,
where: (t) => t.spaceId.equals(space) & t.studysetId.inSet(ids),
orderBy: (t) => t.position,
);
final revisions = await NoteRevision.db.find(
_session,
where: (t) => t.noteId.inSet({for (final note in notes) note.id!}),
);
final byNote = <UuidValue, List<NoteRevision>>{};
for (final revision in revisions) {
(byNote[revision.noteId] ??= []).add(revision);
}
return [
for (final note in notes)
if (_latest<NoteRevision>(
byNote[note.id] ?? const [],
(revision) => revision.id!,
(revision) => revision.parents,
(revision) => revision.payload,
)
case final payload?)
{'id': note.id!.uuid, 'title': _text(payload['title'])},
];
}
Future<Map<UuidValue, int>> _countBy<T>(
int? space,
Future<List<T>> Function(Set<UuidValue> ids) find,
UuidValue? Function(T row) studyset,
List<({Studyset row, Map<String, Object?> payload})> all,
) async {
if (space == null || all.isEmpty) {
return const {};
}
final counts = <UuidValue, int>{};
for (final row in await find({for (final each in all) each.row.id!})) {
if (studyset(row) case final id?) {
counts[id] = (counts[id] ?? 0) + 1;
}
}
return counts;
}
}
/// A card as the tools show it: its id, type, front and back as text.
Map<String, Object?> _card(Card card, Map<String, Object?> payload) {
final answers = switch (payload['answers']) {
final List<Object?> answers => answers,
_ => const <Object?>[],
};
return {
'id': card.id!.uuid,
'studyset_id': card.studysetId.uuid,
'type': payload['type'] ?? 'frontBack',
'front': _text(payload['front']),
'back': [
_text(payload['back']),
_text(payload['reading']),
_list(payload['on'], '、'),
_list(payload['kun'], '、'),
_list(payload['meanings'], '; '),
for (final answer in answers)
if (answer case {'correct': true, 'content': final content})
_text(content),
_text(payload['explanation']),
].where((part) => part.isNotEmpty).join('\n'),
if (card.buriedAt != null) 'buried': true,
};
}
/// The latest current revision's payload among [revisions]: of those no
/// other replaces, the newest (ids are UUID v7, in time order); null when
/// none, or the newest is blank (the item is being deleted).
Map<String, Object?>? _latest<R>(
List<R> revisions,
UuidValue Function(R revision) id,
List<UuidValue> Function(R revision) parents,
ByteData Function(R revision) payload,
) {
final replaced = {for (final revision in revisions) ...parents(revision)};
final heads = [
for (final revision in revisions)
if (!replaced.contains(id(revision))) revision,
]..sort((a, b) => id(a).uuid.compareTo(id(b).uuid));
return heads.isEmpty ? null : _decoded(payload(heads.last));
}
/// The latest current revision's payload of what [revisions] finds.
Future<Map<String, Object?>?> _head<R>(
Future<List<R>> revisions,
UuidValue Function(R revision) id,
List<UuidValue> Function(R revision) parents,
ByteData Function(R revision) payload,
) async => _latest(await revisions, id, parents, payload);
/// The ids of [root] and every studyset inside it; none when [root] isn't
/// among [all].
Set<UuidValue> _subtree(
UuidValue root,
List<({Studyset row, Map<String, Object?> payload})> all,
) {
if (!all.any((each) => each.row.id == root)) {
return const {};
}
final ids = {root};
var grown = true;
while (grown) {
grown = false;
for (final each in all) {
if (each.row.parentId case final parent?
when ids.contains(parent) && ids.add(each.row.id!)) {
grown = true;
}
}
}
return ids;
}
/// The titles of the studysets above [row], outermost first, joined by ›.
String _path(
Studyset row,
List<({Studyset row, Map<String, Object?> payload})> all,
) {
final byId = {for (final each in all) each.row.id: each};
final titles = <String>[];
final seen = <UuidValue>{};
var parent = row.parentId;
while (parent != null && seen.add(parent)) {
final above = byId[parent];
if (above == null) {
break;
}
titles.insert(0, _title(above.payload));
parent = above.row.parentId;
}
return titles.join(' › ');
}
String _title(Map<String, Object?> payload) =>
switch (_text(payload['title'])) {
'' => '(untitled)',
final title => title,
};
/// The text of a payload's value: a string as it is, rich content's blocks a
/// line each, anything else empty.
String _text(Object? value) => switch (value) {
final String text => text.trim(),
{'editor': final Object? editor} => _richText(editor),
_ => '',
};
String _list(Object? value, String separator) => switch (value) {
final List<Object?> items => items.whereType<String>().join(separator),
_ => '',
};
/// The text of the editor's document [editor], a line per block: what its
/// paragraphs, headings and lists say, and a formula's, a callout's or a
/// caption's text kept beside them.
String _richText(Object? editor) {
final lines = <String>[];
void visit(Object? node) {
if (node case {'data': final Map<String, Object?> data}) {
for (final key in const ['formula', 'title', 'caption', 'label']) {
if (data[key] case final String text when text.trim().isNotEmpty) {
lines.add(text.trim());
}
}
if (data case {'delta': final List<Object?> delta}) {
final text = [
for (final operation in delta)
if (operation case {'insert': final String insert}) insert,
].join();
if (text.trim().isNotEmpty) {
lines.add(text.trim());
}
}
}
if (node case {'children': final List<Object?> children}) {
children.forEach(visit);
}
}
if (editor case {'document': final Object? document}) {
visit(document);
}
return lines.join('\n');
}
/// What the tools say of private studysets (ADR 0019).
const _privateNote =
"Private studysets are end-to-end encrypted on the person's devices: "
"the connector can't read them. The person can make one non-private "
'in the app to let it be read here.';
/// A synced payload's JSON; null for a blank one, of a row being deleted.
Map<String, Object?>? _decoded(ByteData payload) {
if (payload.lengthInBytes == 0) {
return null;
}
try {
return switch (jsonDecode(utf8.decode(Uint8List.sublistView(payload)))) {
final Map<String, Object?> json => json,
_ => null,
};
} on FormatException {
return null;
}
}
/// The argument [name] as an id.
UuidValue _id(Map<String, Object?> arguments, String name) {
if (arguments[name] case final String id) {
try {
return UuidValue.withValidation(id.trim());
} on FormatException {
// Said below.
}
}
throw McpToolError('$name must be an id, as list_studysets gives them.');
}
/// The argument [name] as a whole number from [min] to [max], [fallback]
/// without.
int _int(
Map<String, Object?> arguments,
String name,
int fallback,
int min,
int max,
) => switch (arguments[name]) {
null => fallback,
final num number => number.toInt().clamp(min, max),
_ => throw McpToolError('$name must be a number.'),
};
String _json(Object? value) => const JsonEncoder.withIndent(' ').convert(value);

View file

@ -1,162 +0,0 @@
import 'dart:async';
import 'dart:convert';
/// The Model Context Protocol's messages, as far as a server of read-only
/// tools needs them: JSON-RPC 2.0 over Streamable HTTP, each POST one
/// message, answered with plain JSON and no session (the specification
/// allows both).
///
/// Written here rather than taken from a package: `dart_mcp` (dart-lang/ai)
/// serves a long-lived stream channel, not stateless HTTP requests, and the
/// HTTP packages bring a server of their own beside Serverpod's.
/// The protocol versions this server speaks, newest first. A client asking
/// for another gets the newest, as the lifecycle prescribes.
const mcpProtocolVersions = ['2026-07-28', '2025-11-25', '2025-06-18'];
/// A tool a client may call.
class const McpTool({
/// Its name, as the client calls it.
required final String name,
/// What it is for, read by the model to decide when to use it.
required final String description,
/// Its arguments, as a JSON Schema object.
required final Map<String, Object?> inputSchema,
/// Its result, for the call's arguments; text the model reads.
required final Future<String> Function(Map<String, Object?> arguments) call,
/// A short title for people.
final String? title,
}) {
/// How `tools/list` describes it. Every tool here only reads, so says so.
Map<String, Object?> toJson() => {
'name': name,
'title': ?title,
'description': description,
'inputSchema': inputSchema,
'annotations': {
'title': ?title,
'readOnlyHint': true,
'destructiveHint': false,
'idempotentHint': true,
'openWorldHint': false,
},
};
}
/// A call refused for a reason the client should see as a tool error: an
/// unknown studyset, a missing argument.
class const McpToolError(final String message) implements Exception {
@override
String toString() => message;
}
/// Answers one JSON-RPC message with [tools]; null for a notification,
/// which gets no answer (HTTP 202).
///
/// [server] names this server in the `initialize` answer, [instructions]
/// tells the model what the tools are about.
Future<Map<String, Object?>?> answerMcp(
Object? message, {
required List<McpTool> tools,
required ({String name, String title, String version}) server,
required String instructions,
}) async {
if (message is! Map<String, Object?> || message['jsonrpc'] != '2.0') {
return _error(null, -32600, 'Not a JSON-RPC 2.0 request');
}
final id = message['id'];
final method = message['method'];
final params = switch (message['params']) {
final Map<String, Object?> params => params,
_ => const <String, Object?>{},
};
if (method is! String) {
// A response or something else a client sends this server isn't asked
// for: nothing to answer.
return id == null ? null : _error(id, -32600, 'No method');
}
// Notifications (no id) need no answer: `notifications/initialized`,
// `notifications/cancelled` and whatever else a client tells.
if (!message.containsKey('id')) {
return null;
}
switch (method) {
case 'initialize':
final asked = params['protocolVersion'];
return _result(id, {
'protocolVersion': mcpProtocolVersions.contains(asked)
? asked
: mcpProtocolVersions.first,
'capabilities': {
'tools': {'listChanged': false},
},
'serverInfo': {
'name': server.name,
'title': server.title,
'version': server.version,
},
'instructions': instructions,
});
case 'ping':
return _result(id, {});
case 'tools/list':
return _result(id, {
'tools': [for (final tool in tools) tool.toJson()],
});
case 'tools/call':
final name = params['name'];
final tool = tools.where((each) => each.name == name).firstOrNull;
if (tool == null) {
return _error(id, -32602, 'Unknown tool: $name');
}
final arguments = switch (params['arguments']) {
final Map<String, Object?> arguments => arguments,
_ => const <String, Object?>{},
};
try {
final text = await tool.call(arguments);
return _result(id, {
'content': [
{'type': 'text', 'text': text},
],
'isError': false,
});
} on McpToolError catch (error) {
// A tool's own failure goes back as its result, so the model can
// read it and try otherwise.
return _result(id, {
'content': [
{'type': 'text', 'text': error.message},
],
'isError': true,
});
}
default:
return _error(id, -32601, 'Unknown method: $method');
}
}
Map<String, Object?> _result(Object? id, Map<String, Object?> result) => {
'jsonrpc': '2.0',
'id': id,
'result': result,
};
Map<String, Object?> _error(Object? id, int code, String message) => {
'jsonrpc': '2.0',
'id': id,
'error': {'code': code, 'message': message},
};
/// [body] decoded, or a parse error's answer.
({Object? message, Map<String, Object?>? parseError}) decodeMcp(String body) {
try {
return (message: jsonDecode(body), parseError: null);
} on FormatException {
return (message: null, parseError: _error(null, -32700, 'Parse error'));
}
}

View file

@ -14,7 +14,7 @@ import 'package:serverpod_offline_sync_server/serverpod_offline_sync_server.dart
/// Their personal space goes with all its content (the foreign keys cascade
/// into every synced table and the sync engine's bookkeeping) and its stored
/// files; what they wrote in discussions, their published
/// studysets, blocks, what they read and the assistants they connected; and
/// studysets, blocks and what they read; and
/// once the deletion holds, their releases' repositories: what learners
/// copied stays theirs (ADR 0005, rule 4). Their profile is halkyon_base's,
/// which deletes it after this (ADR 0016).
@ -75,19 +75,6 @@ Future<AfterDeletion?> deleteLearnData(
where: (t) => t.authUserId.equals(authUserId),
transaction: transaction,
);
// The assistants they connected, with their codes and tokens (ADR 0006).
// DELETE FROM "assistant_connection" WHERE "authUserId" = $1
await AssistantConnection.db.deleteWhere(
session,
where: (t) => t.authUserId.equals(authUserId),
transaction: transaction,
);
// DELETE FROM "assistant_connect_code" WHERE "authUserId" = $1
await AssistantConnectCode.db.deleteWhere(
session,
where: (t) => t.authUserId.equals(authUserId),
transaction: transaction,
);
// DELETE FROM "file_allowance" WHERE "authUserId" = $1
await FileAllowance.db.deleteWhere(
session,

View file

@ -1,15 +1,14 @@
import 'package:clock/clock.dart';
import 'package:halkyon_base_server/base.dart';
import 'package:halkyon_learn_server/src/assistant/assistant_endpoint.dart';
import 'package:serverpod/serverpod.dart';
/// Deletes the anonymous people nobody can return to, daily, the IP
/// addresses of sign-up attempts the rate limit is done with and the email
/// sign-in codes past their time, hourly, the deletions the instance's
/// members haven't confirmed, hourly, and the assistant connector's expired
/// secrets, daily; the server schedules them when it starts (`server.dart`).
/// All but the last are halkyon_base's (ADR 0014, ADR 0016), and people go
/// through its `deletePerson`, so Learn's own data of theirs goes too.
/// sign-in codes past their time, hourly, and the deletions the instance's
/// members haven't confirmed, hourly; the server schedules them when it
/// starts (`server.dart`). They are halkyon_base's (ADR 0014, ADR 0016),
/// and people go through its `deletePerson`, so Learn's own data of theirs
/// goes too.
class PruningFutureCall extends FutureCall {
/// Prunes one batch and logs how many people went; 30 days after GitHub
/// sign-in ended (`GITHUB_SIGN_IN_ENDS`), deletes the accounts that had
@ -26,16 +25,6 @@ class PruningFutureCall extends FutureCall {
);
}
/// Forgets the assistant connector's codes and tokens past their time, and
/// clients nobody connected, and logs how many rows went.
Future<void> forgetConnectorSecrets(Session session) async {
final forgotten = await forgetExpiredConnectorSecrets(
session,
now: clock.now(),
);
session.log('Forgot $forgotten expired connector rows.');
}
/// Forgets the sign-up attempts the rate limit is done with, with their IP
/// addresses, the email sign-in codes past their time, and the password
/// sign-ins never finished and authenticators never confirmed (ADR 0017),

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