chore: Halkyon Learn moves into apps/learn

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

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

View file

@ -0,0 +1,222 @@
import 'dart:convert';
import 'package:crypto/crypto.dart';
import 'package:halkyon_base_server/base.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/assistant/mcp_protocol.dart';
import 'package:test/test.dart';
void main() {
group('Connect codes', () {
test('are eight characters of Crockford base 32, in two groups', () {
for (var i = 0; i < 100; i++) {
expect(
newShortCode(),
matches(RegExp(r'^[0-9A-HJKMNP-TV-Z]{4}-[0-9A-HJKMNP-TV-Z]{4}$')),
);
}
});
test('read as typed: any case, spaces and dashes, O for 0, I and L '
'for 1', () {
expect(normalizeShortCode(' k7qx-m2pd '), 'K7QXM2PD');
expect(
normalizeShortCode('O1IL lo'),
'0111'
'10',
);
});
test('are kept as a hash, never as they are', () {
final hash = hashSecret('K7QXM2PD');
expect(hash, isNot(contains('K7QXM2PD')));
expect(hash, hashSecret('K7QXM2PD'));
expect(hash, hasLength(64));
});
});
group('PKCE', () {
const verifier = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk';
final challenge = base64UrlEncode(
sha256.convert(ascii.encode(verifier)).bytes,
).replaceAll('=', '');
test('a verifier matches its S256 challenge', () {
// RFC 7636, appendix B.
expect(challenge, 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM');
expect(pkceMatches(verifier, challenge), isTrue);
});
test('another verifier, a short one or one with other characters '
"doesn't", () {
expect(pkceMatches('${verifier}x', challenge), isFalse);
expect(pkceMatches('short', challenge), isFalse);
expect(pkceMatches('$verifier!', challenge), isFalse);
});
});
group('The MCP protocol', () {
final tools = [
McpTool(
name: 'echo',
description: 'Says what it was given.',
inputSchema: const {'type': 'object'},
call: (arguments) async => switch (arguments['text']) {
final String text => text,
_ => throw const McpToolError('Give a text.'),
},
),
];
Future<Map<String, Object?>?> answer(Object? message) => answerMcp(
message,
tools: tools,
server: (name: 'test', title: 'Test', version: '1'),
instructions: 'Echoes.',
);
test('initializing agrees on a version this server speaks', () async {
final known = await answer({
'jsonrpc': '2.0',
'id': 1,
'method': 'initialize',
'params': {'protocolVersion': '2025-06-18'},
});
final unknown = await answer({
'jsonrpc': '2.0',
'id': 2,
'method': 'initialize',
'params': {'protocolVersion': '2024-01-01'},
});
expect(known!['result'], containsPair('protocolVersion', '2025-06-18'));
expect(
unknown!['result'],
containsPair('protocolVersion', mcpProtocolVersions.first),
);
expect(
(known['result']! as Map)['capabilities'],
containsPair('tools', isA<Map<String, Object?>>()),
);
});
test('a notification gets no answer', () async {
expect(
await answer({
'jsonrpc': '2.0',
'method': 'notifications/initialized',
}),
isNull,
);
});
test('lists its tools, each marked as only reading', () async {
final listed = await answer({
'jsonrpc': '2.0',
'id': 3,
'method': 'tools/list',
});
final tool =
((listed!['result']! as Map)['tools']! as List).single as Map;
expect(tool['name'], 'echo');
expect(tool['annotations'], containsPair('readOnlyHint', true));
expect(tool['annotations'], containsPair('destructiveHint', false));
});
test("calls a tool; a tool's refusal is its result, marked as an "
'error', () async {
final called = await answer({
'jsonrpc': '2.0',
'id': 4,
'method': 'tools/call',
'params': {
'name': 'echo',
'arguments': {'text': 'hi'},
},
});
final refused = await answer({
'jsonrpc': '2.0',
'id': 5,
'method': 'tools/call',
'params': {'name': 'echo', 'arguments': <String, Object?>{}},
});
expect(called!['result'], {
'content': [
{'type': 'text', 'text': 'hi'},
],
'isError': false,
});
expect((refused!['result']! as Map)['isError'], isTrue);
});
test('an unknown method or tool, or something else than JSON-RPC, is '
'an error', () async {
final method = await answer({
'jsonrpc': '2.0',
'id': 6,
'method': 'resources/list',
});
final tool = await answer({
'jsonrpc': '2.0',
'id': 7,
'method': 'tools/call',
'params': {'name': 'nothing'},
});
expect((method!['error']! as Map)['code'], -32601);
expect((tool!['error']! as Map)['code'], -32602);
expect(((await answer([1]))!['error']! as Map)['code'], -32600);
expect(decodeMcp('{').parseError, isNotNull);
});
});
group('The authorization page', () {
AuthorizePage page(String client) => (
language: PageLanguage.en,
client: client,
returnsTo: 'claude.ai',
instance: 'learn.example.org',
fields: {'state': '"><script>'},
);
test('escapes what a client names itself and carries', () {
final html = authorizePage(page('<b>Evil</b>'));
expect(html, contains('&lt;b&gt;Evil&lt;&#47;b&gt;'));
expect(html, isNot(contains('<b>Evil')));
expect(html, isNot(contains('"><script>')));
});
test('names where the person returns to, and how to get a code', () {
final html = authorizePage(page('Claude'));
expect(html, contains('<strong>claude.ai</strong>'));
expect(html, contains('Settings › Assistant'));
expect(html, contains('name="connect_code"'));
});
test('speaks German where the browser prefers it', () {
expect(pageLanguage('de-DE,de;q=0.9,en;q=0.8'), PageLanguage.de);
expect(pageLanguage('en-GB,de;q=0.5'), PageLanguage.en);
expect(pageLanguage(null), PageLanguage.en);
});
test('may not be framed, and may only post here and to the client', () {
final headers = pageHeaders(
Uri.parse('https://claude.ai/api/mcp/auth_callback'),
);
expect(
headers['Content-Security-Policy'],
allOf(
contains("frame-ancestors 'none'"),
contains("form-action 'self' https://claude.ai"),
),
);
expect(headers['X-Frame-Options'], 'DENY');
});
});
}

View file

@ -0,0 +1,9 @@
import 'package:halkyon_learn_server/src/auth/learn_base.dart';
import 'package:test/test.dart';
void main() {
test("Learn's instances name the app `learn`, the only instances Learn's "
'apps accept', () {
expect(learnBase().app, 'learn');
});
}

View file

@ -0,0 +1,190 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:clock/clock.dart';
import 'package:halkyon_learn_server/src/instance/jellyfin_suggestion.dart';
import 'package:halkyon_learn_server/src/instance/public_suffix.dart';
import 'package:test/test.dart';
void main() {
group('The public suffix list', () {
// A few rules as the list writes them, of each kind.
final suffixes = PublicSuffixList.parse('''
// A comment, and an empty line below.
eu
li
uk
co.uk
org
duckdns.org
*.ck
!www.ck
''');
test('tells the domain registered for a host', () {
expect(suffixes.registrableDomain('learn.halkyon.eu'), 'halkyon.eu');
expect(suffixes.registrableDomain('halkyon.li'), 'halkyon.li');
expect(suffixes.registrableDomain('Learn.Halkyon.EU'), 'halkyon.eu');
});
test('knows suffixes of two labels, private ones too: no foreign '
'jellyfin.co.uk for an instance at example.co.uk', () {
expect(suffixes.registrableDomain('example.co.uk'), 'example.co.uk');
expect(
suffixes.registrableDomain('learn.example.co.uk'),
'example.co.uk',
);
expect(
suffixes.registrableDomain('halkyon.duckdns.org'),
'halkyon.duckdns.org',
);
});
test('follows wildcards and their exceptions', () {
expect(suffixes.registrableDomain('a.b.ck'), 'a.b.ck');
expect(suffixes.registrableDomain('www.ck'), 'www.ck');
});
test('has no registered domain for a suffix, a single name or an '
'address', () {
expect(suffixes.registrableDomain('co.uk'), isNull);
expect(suffixes.registrableDomain('localhost'), isNull);
expect(suffixes.registrableDomain('192.168.1.10'), isNull);
expect(suffixes.registrableDomain('::1'), isNull);
expect(suffixes.registrableDomain(''), isNull);
});
test("Mozilla's list as the server holds it reads", () {
final list = PublicSuffixList.read();
expect(list.registrableDomain('learn.halkyon.eu'), 'halkyon.eu');
expect(list.registrableDomain('learn.example.co.uk'), 'example.co.uk');
expect(list.registrableDomain('me.github.io'), 'me.github.io');
});
});
group("The instance's Jellyfin server", () {
final suffixes = PublicSuffixList.parse('eu\nli\n');
final asked = <Uri>[];
setUp(asked.clear);
JellyfinSuggestion suggestion({
String? configured,
String? host = 'learn.halkyon.eu',
bool answers = true,
}) => JellyfinSuggestion(
configured: configured,
host: host,
suffixes: () => suffixes,
answersAsJellyfin: (address) async {
asked.add(address);
return answers;
},
);
test("is the operator's, as set, asking nothing", () async {
final found = suggestion(configured: 'https://media.example.org/');
expect(found.url, Uri.parse('https://media.example.org/'));
await found.look();
expect(asked, isEmpty);
});
test('is none when the operator set it to none', () async {
final found = suggestion(configured: 'none');
await found.look();
expect(found.url, isNull);
expect(asked, isEmpty);
});
test('set empty, as Compose passes an unset one, it is looked for as '
'unset', () async {
final found = suggestion(configured: '');
await found.look();
expect(found.url, Uri.parse('https://jellyfin.halkyon.eu/'));
});
test('else jellyfin. on the domain registered for the instance, once it '
'answers as a Jellyfin server', () async {
final found = suggestion();
expect(found.url, isNull);
await found.look();
expect(asked, [Uri.parse('https://jellyfin.halkyon.eu/')]);
expect(found.url, Uri.parse('https://jellyfin.halkyon.eu/'));
});
test('is none where nothing answers so, and none for an instance on '
'localhost', () async {
final silent = suggestion(answers: false);
await silent.look();
expect(silent.url, isNull);
asked.clear();
final local = suggestion(host: 'localhost');
await local.look();
expect(local.url, isNull);
expect(asked, isEmpty);
});
test('looks again an hour after, as it is asked for', () async {
var now = DateTime.utc(2026, 10, 13, 22);
await withClock(Clock(() => now), () async {
final found = suggestion();
await found.look();
found.url;
expect(asked, hasLength(1));
now = now.add(const Duration(minutes: 59));
found.url;
expect(asked, hasLength(1));
now = now.add(const Duration(minutes: 2));
found.url;
await Future<void>.delayed(Duration.zero);
expect(asked, hasLength(2));
});
});
});
group('Whether an address answers as a Jellyfin server', () {
late HttpServer server;
late Uri address;
var product = 'Jellyfin Server';
setUp(() async {
server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
address = Uri.parse('http://127.0.0.1:${server.port}/');
server.listen((request) {
if (request.uri.path == '/System/Info/Public') {
request.response
..headers.contentType = ContentType.json
..write(jsonEncode({'ProductName': product, 'Version': '12.2.0'}));
} else {
request.response.statusCode = HttpStatus.notFound;
}
unawaited(request.response.close());
});
});
tearDown(() => server.close(force: true));
test('it does when its public information names Jellyfin', () async {
product = 'Jellyfin Server';
expect(await answersAsJellyfin(address), isTrue);
});
test('anything else does not', () async {
product = 'Emby Server';
expect(await answersAsJellyfin(address), isFalse);
expect(
await answersAsJellyfin(address.resolve('elsewhere/')),
isFalse,
);
await server.close(force: true);
expect(await answersAsJellyfin(address), isFalse);
});
});
}

View file

@ -0,0 +1,132 @@
import 'dart:io';
import 'package:halkyon_learn_server/src/profile/link_verification.dart';
import 'package:test/test.dart';
void main() {
group('a public address', () {
test('is one anybody on the internet reaches', () {
for (final address in ['93.184.216.34', '1.1.1.1', '2606:4700::1111']) {
expect(
isPublicAddress(InternetAddress(address)),
isTrue,
reason: address,
);
}
});
test(
'is never the instance itself, its networks, or addresses kept for '
"special uses: the instance mustn't be made to look around itself",
() {
for (final address in [
'127.0.0.1', // loopback
'0.0.0.0', // this network
'10.0.0.1', // private
'172.16.5.4',
'172.30.80.2', // the instance's edge network, Caddy
'192.168.1.1',
'169.254.169.254', // link-local, cloud metadata
'100.64.0.1', // shared address space (carrier-grade NAT)
'192.0.0.8', // IETF protocol assignments
'192.0.2.1', // documentation
'198.51.100.1',
'203.0.113.1',
'198.18.0.1', // benchmarking
'224.0.0.1', // multicast
'240.0.0.1', // reserved
'255.255.255.255', // broadcast
'::1',
'::',
'fe80::1',
'fc00::1', // unique local
'fd12:3456::1',
'ff02::1', // multicast
'2001:db8::1', // documentation
'::ffff:127.0.0.1', // IPv4 inside IPv6
'::ffff:10.0.0.1',
'64:ff9b::7f00:1', // NAT64 of 127.0.0.1
]) {
expect(
isPublicAddress(InternetAddress(address)),
isFalse,
reason: address,
);
}
},
);
});
group('a page links back', () {
final profile = Uri.parse('https://halkyon.eu/@Raisondetredev');
final page = Uri.parse('https://example.org/about');
bool linksBack(String html, {Uri? at}) =>
linksBackTo(html, pageUrl: at ?? page, profileUrl: profile);
test('with an <a> or a <link> marked rel="me" to the profile page, its '
"host's and its handle's case and a trailing slash aside", () {
for (final html in [
'<a rel="me" href="https://halkyon.eu/@Raisondetredev">Halkyon</a>',
'<head><link rel="me" href="https://halkyon.eu/@raisondetredev/"></head>',
'<a href="https://halkyon.eu/@raisondetredev" rel="noopener me">x</a>',
"<A REL='Me' HREF='https://HALKYON.EU/@RAISONDETREDEV'>x</A>",
'<a rel="me" href="https://halkyon.eu/@raisondetredev?from=site#top">',
]) {
expect(linksBack(html), isTrue, reason: html);
}
expect(
linksBack(
'<a rel="me" href="/@raisondetredev">x</a>',
at: Uri.parse('https://halkyon.eu/about'),
),
isTrue,
reason: 'a relative link, from the page it is on',
);
});
test('not with a link without rel="me", to another page, another '
'person, another host or over plain HTTP', () {
for (final html in [
'<a href="https://halkyon.eu/@raisondetredev">x</a>',
'<a rel="nofollow" href="https://halkyon.eu/@raisondetredev">x</a>',
'<a rel="meme" href="https://halkyon.eu/@raisondetredev">x</a>',
'<a rel="me" href="https://halkyon.eu/@raisondetredev2">x</a>',
'<a rel="me" href="https://halkyon.eu/@raisondetredev/studysets">x</a>',
'<a rel="me" href="https://learn.halkyon.eu/@raisondetredev">x</a>',
'<a rel="me" href="http://halkyon.eu/@raisondetredev">x</a>',
'<p>rel="me" href="https://halkyon.eu/@raisondetredev"</p>',
]) {
expect(linksBack(html), isFalse, reason: html);
}
});
});
group('the fetcher refuses, without connecting,', () {
final fetcher = SafeLinkFetcher(userAgent: 'test');
test('what is no HTTPS link on the usual port', () async {
for (final link in [
'http://example.org/',
'ftp://example.org/',
'https://example.org:8443/',
]) {
expect(await fetcher.fetch(Uri.parse(link)), isNull, reason: link);
}
});
test(
'a host that is, or resolves to, an address that is not public',
() async {
for (final link in [
'https://127.0.0.1/',
'https://[::1]/',
'https://localhost/',
'https://169.254.169.254/latest/meta-data/',
]) {
expect(await fetcher.fetch(Uri.parse(link)), isNull, reason: link);
}
},
);
});
}

View file

@ -0,0 +1,62 @@
import 'package:halkyon_learn_server/src/publish/release_license.dart';
import 'package:test/test.dart';
void main() {
test('licences that allow passing content on allow publishing', () {
for (final license in [
'CC0-1.0',
'Public domain',
'CC-BY-4.0',
'CC-BY-SA-3.0-US',
'CC-BY-NC-SA-4.0',
'CC-BY-ND-4.0',
'cc-by-nc-nd-4.0',
'ODbL-1.0',
'GFDL-1.3-no-invariants-or-later',
'GPL-3.0-or-later',
'AGPL-3.0-only',
'MIT',
'Apache-2.0',
]) {
expect(allowsPublishing(license), isTrue, reason: license);
}
});
test('no licence, all rights reserved, or one not known to be open '
"doesn't", () {
for (final license in [
null,
'',
' ',
'All Rights Reserved',
'GPLv3',
'Proprietary',
'CC-BY',
'CC-SA-1.0',
]) {
expect(allowsPublishing(license), isFalse, reason: license);
}
});
test('ShareAlike and copyleft licences keep adaptations under them', () {
for (final license in [
'CC-BY-SA-4.0',
'CC-BY-NC-SA-3.0-US',
'GPL-3.0-or-later',
'GFDL-1.3-only',
'ODbL-1.0',
]) {
expect(keepsAdaptations(license), isTrue, reason: license);
}
for (final license in ['CC-BY-4.0', 'CC0-1.0', 'MIT', 'CC-BY-ND-4.0']) {
expect(keepsAdaptations(license), isFalse, reason: license);
}
});
test('"no derivatives" forbids passing on adaptations', () {
expect(forbidsAdaptations('CC-BY-ND-4.0'), isTrue);
expect(forbidsAdaptations('CC-BY-NC-ND-3.0-DE'), isTrue);
expect(forbidsAdaptations('CC-BY-SA-4.0'), isFalse);
expect(forbidsAdaptations('MIT'), isFalse);
});
}

View file

@ -0,0 +1,46 @@
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:halkyon_learn_server/src/publish/release_repository.dart';
import 'package:test/test.dart';
void main() {
late Directory folder;
setUp(() async {
folder = await Directory.systemTemp.createTemp('release-repository-');
});
tearDown(() => folder.delete(recursive: true));
const someone = (name: 'Someone', email: 'someone@example.org');
test('reads a release of thousands of files back, as large as a '
"language's vocabulary, without waiting on git for ever", () async {
// git answers each object as it reads its name: once its answers fill
// the pipe, it reads no more names until they are read, so they must
// be read while the names are still being written.
final repository = ReleaseRepository(Directory('${folder.path}/r.git'));
Uint8List cardOf(int index) => utf8.encode(
jsonEncode({'id': 'card-$index', 'payload': 'x' * 900}),
);
await repository.release(
files: {
for (var index = 0; index < 3000; index++)
'cards/card-$index.json': () async => cardOf(index),
},
version: 1,
message: 'v1',
author: someone,
committer: someone,
at: DateTime.utc(2026, 10, 16),
);
final read = await repository
.readJson(1)
.timeout(const Duration(seconds: 30));
expect(read, hasLength(3000));
expect(read['cards/card-42.json'], utf8.decode(cardOf(42)));
}, timeout: const Timeout(Duration(minutes: 2)));
}