feat(privacy): the instance's side of private studysets (ADR 0019)

- `content_key`, a synced table in the person's space, keeps their data keys
  sealed to their account (a private studyset's, their personal one);
  `learning_path_answer` gains `keyId` and `nonce` for answers in private
  studysets. Additive only, on the server and the device.
- Publishing refuses a private studyset, and a tree holding a private row
  (`PublishRefusal.private`); a private studyset elsewhere no longer breaks
  publishing a readable one. Encrypted tags are named as the device names
  them (`tagNames`): a name for a tag that isn't the person's, or an
  encrypted tag of the release without one, refuses it
  (`tagNamesMismatch`); a readable tag keeps its own name.
- The connector leaves private studysets out and says how many there are.
- The learner count reads no payload any more: Count me is kept by the
  instance (`counted_learner`) and the device reports which publications
  its copies come from (`learning_copy`, accepted only while counted, each
  report replacing the last); only the reporter's own studysets count.
  Deleting a person deletes both.
- An upload says whether its file is encrypted: only a readable one over
  20 MB is checked to be a PDF, and an encrypted one may be 4 KiB larger.

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-21 20:52:35 +02:00
commit a1c5ac3f86
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -1,4 +1,3 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:halkyon_base_server/base.dart';
@ -71,50 +70,40 @@ void main() {
await PublishedStudyset.db.deleteRow(session, japanese);
});
ByteData json(Map<String, Object?> json) =>
ByteData.sublistView(utf8.encode(jsonEncode(json)));
TestSessionBuilder signedIn(UuidValue person, {bool anonymous = false}) =>
sessionBuilder.copyWith(
authentication: AuthenticationOverride.authenticationInfo(
person.uuid,
anonymous ? const {} : {identified},
),
);
/// A person (with an identity unless [anonymous]), counted if
/// [counted], who studied a copy of Japanese on [daysAgo] (a review
/// in [mode] each), and since put it in Recently deleted if
/// [deleted].
Future<void> learner({
/// [counted], who studied a private copy of Japanese on [daysAgo] (a
/// review in [mode] each), which their device [reported], and since
/// put it in Recently deleted if [deleted]. Returns the copy.
Future<UuidValue> learner({
bool counted = true,
bool anonymous = false,
List<int> daysAgo = const [1, 2, 3],
String mode = 'review',
bool deleted = false,
bool reported = true,
}) async {
final person = (await AuthServices.instance.authUsers.create(
session,
scopes: anonymous ? const {} : {identified},
)).id;
people.add(person);
await asPerson(session, person, (transaction) async {
await PersonSettings.db.insertRow(
session,
PersonSettings(
payload: json({
'schemaVersion': 8,
'studyTags': <String, Object?>{},
'countLearning': ?(counted ? true : null),
}),
),
transaction: transaction,
);
final studyset = await asPerson(session, person, (transaction) async {
// A private copy: its origin is encrypted (ADR 0019).
final studyset = (await Studyset.db.insertRow(
session,
Studyset(
deletedAt: deleted ? now : null,
payload: json({
'schemaVersion': 5,
'title': 'Japanese',
'published': {
'publication': japanese.publicId.uuid,
'item': 'root',
'version': 1,
},
}),
keyId: 'studyset-key',
nonce: ByteData(24),
payload: ByteData.sublistView(Uint8List.fromList([0xff, 1, 2])),
),
transaction: transaction,
)).id!;
@ -136,7 +125,35 @@ void main() {
transaction: transaction,
);
}
return studyset;
});
if (counted && !anonymous) {
await endpoints.learningCount.setCounted(signedIn(person), true);
} else if (counted) {
// Only the endpoint is refused an anonymous person; their row is
// made as a modified server would, and still never counts.
await CountedLearner.db.insertRow(
session,
CountedLearner(authUserId: person, since: now),
);
await LearningCopy.db.insertRow(
session,
LearningCopy(
authUserId: person,
publicationId: japanese.publicId.uuid,
studysetId: studyset,
),
);
}
if (reported && !anonymous) {
await endpoints.learningCount.reportCopies(signedIn(person), [
CopyReport(
publicationId: japanese.publicId.uuid,
studysetIds: [studyset],
),
]);
}
return studyset;
}
Future<int> counted() async {
@ -148,7 +165,7 @@ void main() {
}
test('counts the people with an identity who chose to be, studying a '
'copy on 3 days of the last 30', () async {
'copy their device reported on 3 days of the last 30', () async {
await learner();
await learner(daysAgo: [1, 1, 1, 2, 29]);
@ -156,24 +173,108 @@ void main() {
});
test('leaves out who chose not to be counted, anonymous people, too '
'little or too long ago, and a copy deleted', () async {
'little or too long ago, a copy deleted, and one never '
'reported', () async {
await learner(counted: false);
await learner(anonymous: true);
await learner(daysAgo: [1, 2]);
await learner(daysAgo: [31, 32, 40]);
await learner(mode: 'reset');
await learner(deleted: true);
await learner(reported: false);
expect(await counted(), 0);
});
test('a count goes down when learning stops', () async {
test("a studyset reported that isn't the reporter's own never "
'counts', () async {
final alices = await learner(reported: false);
await learner(daysAgo: const []);
final mallory = people.last;
await endpoints.learningCount.reportCopies(signedIn(mallory), [
CopyReport(
publicationId: japanese.publicId.uuid,
studysetIds: [alices],
),
]);
expect(await counted(), 0);
});
test('a count goes down when learning stops, or the person no longer '
'wants to be counted', () async {
await learner();
expect(await counted(), 1);
await deletePerson(session, people.removeLast());
await endpoints.learningCount.setCounted(
signedIn(people.last),
false,
);
expect(await counted(), 0);
expect(
await LearningCopy.db.count(
session,
where: (t) => t.authUserId.equals(people.last),
),
0,
);
await learner();
expect(await counted(), 1);
await deletePerson(session, people.removeLast());
expect(await counted(), 0);
});
test('reports are kept only while the person chose to be counted, '
'each replacing the last', () async {
final copy = await learner(counted: false);
final person = people.last;
final report = [
CopyReport(
publicationId: japanese.publicId.uuid,
studysetIds: [copy],
),
];
expect(
await endpoints.learningCount.reportCopies(signedIn(person), report),
isFalse,
);
expect(
await endpoints.learningCount.counted(signedIn(person)),
isFalse,
);
await endpoints.learningCount.setCounted(signedIn(person), true);
expect(await endpoints.learningCount.counted(signedIn(person)), isTrue);
expect(
await endpoints.learningCount.reportCopies(signedIn(person), report),
isTrue,
);
expect(await counted(), 1);
await endpoints.learningCount.reportCopies(signedIn(person), []);
expect(await counted(), 0);
});
test('anonymous people are refused', () async {
final person = const Uuid().v7obj();
await expectLater(
endpoints.learningCount.setCounted(
signedIn(person, anonymous: true),
true,
),
throwsA(isA<ServerpodInsufficientAccessException>()),
);
await expectLater(
endpoints.learningCount.reportCopies(
signedIn(person, anonymous: true),
[],
),
throwsA(isA<ServerpodInsufficientAccessException>()),
);
expect(await CountedLearner.db.count(session), 0);
});
},
databaseInterceptor: syncDatabaseInterceptor,