refactor(content,core,app,editor,learn): Learn's content in pure Dart, for its agent

ADR 0044's D49 (amended in building it): what reads and seals Learn's
content moves out of the Flutter packages, so Learn's agent, which runs
without Flutter, shares it rather than repeating the privacy rules.

packages/halkyon_content (new, pure Dart) takes from halkyon_app the
envelope and its cipher, SealedFile, UnlockedAccountKeys, shared
spaces' gateway with SharedKeys and KeyPins, FileCache and
FileServerGateway, with their fakes and tests; halkyon_app exports it,
so no app changes. Its rich_text.dart takes RichContent from
halkyon_editor, which exports it. newAccountKeys makes an agent's key
pair. halkyon_core takes DelayedDeletions and gains SyncRequests, what a
synced gateway asks of the sync, which SyncService implements.

apps/learn/halkyon_learn_core (new, pure Dart) takes Learn's privacy,
sync, payloads and the models, codecs, gateways and repositories of
studysets, cards, notes, tags and feedback, with what they reach of
files, learning paths, citations and study options; the app imports them
from there. A subject's icon, colour and name stay in the app
(SubjectLook); what only the app shows (progress, exam coverage, the
card browser, deletions' waits) stays too. Nothing changes for people
using the apps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-11 21:24:30 +01:00
commit d09625d43e
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
3 changed files with 3517 additions and 2007 deletions

View file

@ -0,0 +1,32 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:halkyon_content/halkyon_content.dart';
import 'package:test/test.dart';
/// An agent's keys (Halkyon's ADR 0044, D6): made where it runs, each
/// agent's its own, and what a cipher unlocks to seal and open with.
void main() {
test("new keys are an account's own, and seal what they open", () async {
final one = await newAccountKeys();
final other = await newAccountKeys();
expect(one.encryptionSecretKey, hasLength(32));
expect(one.signingSecretKey, hasLength(64));
expect(one.encryptionPublicKey, isNot(other.encryptionPublicKey));
expect(one.signingPublicKey, isNot(other.signingPublicKey));
final cipher = SodiumContentCipher();
addTearDown(cipher.dispose);
await cipher.unlock(one);
cipher.newKey('k-1');
final sealed = cipher.seal(
Uint8List.fromList(utf8.encode('猫')),
keyId: 'k-1',
context: utf8.encode('card|1'),
);
expect(
utf8.decode(cipher.unseal(sealed, context: utf8.encode('card|1'))!),
'猫',
);
});
}

View file

@ -0,0 +1,217 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:halkyon_content/halkyon_content.dart';
import 'package:test/test.dart';
/// A synced row's envelope (ADR 0019): sealed for its table, row, column and
/// the row it belongs to, readable payloads signed, and nothing an instance
/// moves, changes or makes up read as the person's.
void main() {
late UnlockedAccountKeys alice;
late UnlockedAccountKeys bob;
late SodiumContentCipher device;
late PayloadEnvelope envelope;
final plain = ByteData.sublistView(
Uint8List.fromList(utf8.encode('{"schemaVersion":1,"name":"Run"}')),
);
Opened openAt(
Envelope sealed, {
String table = 'workout',
String id = 'w1',
String column = 'payload',
String bound = '',
}) => envelope.open(
sealed,
table: table,
id: id,
column: column,
bound: bound,
);
setUpAll(() async {
alice = await newAccountKeys();
bob = await newAccountKeys();
});
setUp(() async {
device = SodiumContentCipher();
await device.unlock(alice);
device.newKey('person');
envelope = PayloadEnvelope(device, canSign: () => true);
});
tearDown(() => device.dispose());
test('a sealed payload opens where it was sealed for', () {
final sealed = envelope.seal(
plain,
keyId: 'person',
table: 'workout',
id: 'w1',
);
expect(sealed.keyId, 'person');
expect(sealed.nonce, isNotNull);
expect(
Uint8List.sublistView(sealed.payload),
isNot(containsAllInOrder(Uint8List.sublistView(plain))),
);
expect(openAt(sealed), Readable(plain));
});
test('a sealed payload moved to another table, row, column or item '
"doesn't open", () {
final sealed = envelope.seal(
plain,
keyId: 'person',
table: 'workout',
id: 'w1',
bound: 'workout:w0',
);
expect(openAt(sealed, bound: 'workout:w0'), Readable(plain));
expect(
openAt(sealed, table: 'workout_series', bound: 'workout:w0'),
const Locked(),
);
expect(openAt(sealed, id: 'w2', bound: 'workout:w0'), const Locked());
expect(openAt(sealed, column: 'name', bound: 'workout:w0'), const Locked());
expect(openAt(sealed, bound: 'workout:w9'), const Locked());
});
test("a sealed payload changed, or without its nonce, doesn't open", () {
final sealed = envelope.seal(
plain,
keyId: 'person',
table: 'workout',
id: 'w1',
);
final changed = Uint8List.fromList(Uint8List.sublistView(sealed.payload));
changed[changed.length - 1] ^= 1;
expect(
openAt((
keyId: 'person',
nonce: sealed.nonce,
payload: ByteData.sublistView(changed),
)),
const Locked(),
);
expect(
openAt((keyId: 'person', nonce: null, payload: sealed.payload)),
const Locked(),
);
});
test("a payload under a key not open here doesn't open, and none is "
'sealed with it', () async {
final sealed = envelope.seal(
plain,
keyId: 'person',
table: 'workout',
id: 'w1',
);
final other = SodiumContentCipher();
await other.unlock(alice);
addTearDown(other.dispose);
final elsewhere = PayloadEnvelope(other);
expect(
elsewhere.open(sealed, table: 'workout', id: 'w1'),
const Locked(),
);
expect(
() => elsewhere.seal(plain, keyId: 'person', table: 'workout', id: 'w1'),
throwsStateError,
);
});
test(
"another account's device can't open a key sealed to this one",
() async {
final sealedKey = device.newKey('person-2');
final sealed = envelope.seal(
plain,
keyId: 'person-2',
table: 'workout',
id: 'w1',
);
final bobs = SodiumContentCipher();
await bobs.unlock(bob);
addTearDown(bobs.dispose);
expect(bobs.openKey('person-2', sealedKey), isFalse);
expect(
PayloadEnvelope(bobs).open(sealed, table: 'workout', id: 'w1'),
const Locked(),
);
},
);
test('a readable payload is signed where the device can sign, and trusted '
'only where it stays', () {
final readable = envelope.seal(
plain,
keyId: null,
table: 'published_workout',
id: 'p1',
);
expect(readable.nonce, isNotNull);
expect(
openAt(readable, table: 'published_workout', id: 'p1'),
Readable(plain),
);
expect(
openAt(readable, table: 'published_workout', id: 'p2'),
Readable(plain, trusted: false),
);
});
test('a readable payload nobody signed, or signed by another account, '
'is not trusted', () async {
final unsigned = PayloadEnvelope(device).seal(
plain,
keyId: null,
table: 'published_workout',
id: 'p1',
);
expect(unsigned.nonce, isNull);
expect(
openAt(unsigned, table: 'published_workout', id: 'p1'),
Readable(plain, trusted: false),
);
final bobs = SodiumContentCipher();
await bobs.unlock(bob);
addTearDown(bobs.dispose);
final bobsSigned = PayloadEnvelope(bobs, canSign: () => true).seal(
plain,
keyId: null,
table: 'published_workout',
id: 'p1',
);
expect(
openAt(bobsSigned, table: 'published_workout', id: 'p1'),
Readable(plain, trusted: false),
);
});
test('a blank payload, a row being deleted, stays blank and reads as '
'blank', () {
final blank = ByteData(0);
final sealed = envelope.seal(
blank,
keyId: 'person',
table: 'workout',
id: 'w1',
);
expect(sealed.payload.lengthInBytes, 0);
expect(sealed.nonce, isNull);
expect(openAt(sealed), Readable(blank));
});
}

View file

@ -0,0 +1,186 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:halkyon_content/halkyon_content.dart';
import 'package:test/test.dart';
/// Content sealed with a data key (ADR 0019), with libsodium as its build
/// hook compiles it.
void main() {
late UnlockedAccountKeys alice;
late UnlockedAccountKeys bob;
late SodiumContentCipher device;
final context = utf8.encode('card_revision/0192/payload');
final plain = Uint8List.fromList(utf8.encode('{"front":"火"}'));
setUpAll(() async {
alice = await newAccountKeys();
bob = await newAccountKeys();
});
setUp(() async {
device = SodiumContentCipher();
await device.unlock(alice);
});
tearDown(() => device.dispose());
test("a key made on one device opens on another of the account's, and "
'what it seals opens there', () async {
final sealedKey = device.newKey('studyset-1');
final sealed = device.seal(plain, keyId: 'studyset-1', context: context);
final other = SodiumContentCipher();
await other.unlock(alice);
addTearDown(other.dispose);
expect(other.has('studyset-1'), isFalse);
expect(other.openKey('studyset-1', sealedKey), isTrue);
expect(other.has('studyset-1'), isTrue);
expect(other.unseal(sealed, context: context), plain);
expect(sealed.keyId, 'studyset-1');
expect(String.fromCharCodes(sealed.payload), isNot(contains('front')));
});
test("another account's device opens neither the key nor what it "
'sealed', () async {
final sealedKey = device.newKey('studyset-1');
final sealed = device.seal(plain, keyId: 'studyset-1', context: context);
final bobs = SodiumContentCipher();
await bobs.unlock(bob);
addTearDown(bobs.dispose);
expect(bobs.openKey('studyset-1', sealedKey), isFalse);
expect(bobs.unseal(sealed, context: context), isNull);
});
test('a key opens only under the ID it was sealed with, and only once '
'the account is unlocked', () async {
final sealedKey = device.newKey('studyset-1');
final other = SodiumContentCipher();
addTearDown(other.dispose);
expect(() => other.openKey('studyset-1', sealedKey), throwsStateError);
await other.unlock(alice);
expect(other.openKey('studyset-2', sealedKey), isFalse);
expect(other.openKey('studyset-1', sealedKey), isTrue);
});
test('a payload moved to another row, or changed, opens no more', () {
device.newKey('studyset-1');
final sealed = device.seal(plain, keyId: 'studyset-1', context: context);
final changed = Uint8List.fromList(sealed.payload)..last ^= 1;
expect(
device.unseal(sealed, context: utf8.encode('card_revision/0193/payload')),
isNull,
);
expect(
device.unseal(
SealedPayload(
keyId: sealed.keyId,
nonce: sealed.nonce,
payload: changed,
),
context: context,
),
isNull,
);
expect(device.unseal(sealed, context: context), plain);
});
test(
"a row's keyed ID (ADR 0030, D57) is the same on every device of the "
"account, another on another account's, and none before unlocking",
() async {
final other = SodiumContentCipher();
await other.unlock(alice);
addTearDown(other.dispose);
final bobs = SodiumContentCipher();
await bobs.unlock(bob);
addTearDown(bobs.dispose);
final locked = SodiumContentCipher();
final id = device.rowId('train/day/2026-10-30');
expect(other.rowId('train/day/2026-10-30'), id);
expect(bobs.rowId('train/day/2026-10-30'), isNot(id));
expect(device.rowId('train/day/2026-10-31'), isNot(id));
expect(() => locked.rowId('train/day/2026-10-30'), throwsStateError);
},
);
test('without its key nothing opens, nor is sealed', () {
final sealed = SealedPayload(
keyId: 'unknown',
nonce: Uint8List(24),
payload: Uint8List(100),
);
expect(device.unseal(sealed, context: context), isNull);
expect(
() => device.seal(plain, keyId: 'unknown', context: context),
throwsStateError,
);
});
test("a shared key's content opens when a member it trusts signed it, "
"and only for that key (Halkyon's ADR 0044, D20)", () async {
final carol = await newAccountKeys();
PublicKeys keysOf(UnlockedAccountKeys account) => (
encryption: account.encryptionPublicKey,
signing: account.signingPublicKey,
);
Future<SodiumContentCipher> deviceOf(UnlockedAccountKeys account) async {
final cipher = SodiumContentCipher();
await cipher.unlock(account);
addTearDown(cipher.dispose);
return cipher;
}
final bobs = await deviceOf(bob);
final carols = await deviceOf(carol);
for (final keyId in ['shared', 'other']) {
device.newKey(keyId);
for (final (cipher, keys) in [(bobs, bob), (carols, carol)]) {
expect(
cipher.openSharedKey(
keyId,
device.sealKeyTo(keyId, keysOf(keys)),
sealedBy: alice.signingPublicKey,
),
isTrue,
);
}
}
final byBob = bobs.seal(plain, keyId: 'shared', context: context);
final byCarol = carols.seal(plain, keyId: 'shared', context: context);
final byAlice = device.seal(plain, keyId: 'shared', context: context);
// Only the account's own, until told whom to trust.
expect(device.unseal(byBob, context: context), isNull);
expect(device.unseal(byAlice, context: context), plain);
device.trustSigners('shared', [
alice.signingPublicKey,
bob.signingPublicKey,
]);
expect(device.unseal(byBob, context: context), plain);
expect(device.unseal(byAlice, context: context), plain);
expect(device.unseal(byCarol, context: context), isNull);
expect(
device.unseal(
bobs.seal(plain, keyId: 'other', context: context),
context: context,
),
isNull,
);
// What it trusts is replaced, not added to.
device.trustSigners('shared', [carol.signingPublicKey]);
expect(device.unseal(byCarol, context: context), plain);
expect(device.unseal(byBob, context: context), isNull);
expect(device.unseal(byAlice, context: context), isNull);
});
}