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

@ -549,7 +549,10 @@ void main() {
final second = jsonDecode(renewed.body) as Map;
expect(second['access_token'], isNot(first['access_token']));
expect(reused.status, 400);
expect(await call(second['access_token'], 'list_studysets'), '[]');
expect(
jsonDecode(await call(second['access_token'], 'list_studysets')),
{'studysets': <Object?>[]},
);
expect(
(await mcp(first['access_token'], {
'jsonrpc': '2.0',
@ -609,17 +612,23 @@ void main() {
List<(String, String)> cards = const [],
UuidValue? parent,
DateTime? deletedAt,
bool private = false,
}) => asPerson(person, (transaction) async {
final id = (await Studyset.db.insertRow(
session,
Studyset(
parentId: parent,
deletedAt: deletedAt,
payload: _json({
'schemaVersion': 4,
'title': title,
'description': _rich('About $title'),
}),
// A private studyset's payload is ciphertext (ADR 0019).
keyId: private ? 'studyset-key' : null,
nonce: private ? ByteData(24) : null,
payload: private
? ByteData.sublistView(Uint8List.fromList([0xff, 0xfe, 7]))
: _json({
'schemaVersion': 4,
'title': title,
'description': _rich('About $title'),
}),
),
transaction: transaction,
)).id!;
@ -740,6 +749,35 @@ void main() {
}
});
test('leaves out private studysets, saying how many, and reads '
'nothing in them', () async {
final diary = await studyset(
alice,
'Diary',
cards: [('secret', 'kept')],
private: true,
);
await studyset(alice, 'Inside', parent: diary, private: true);
await studyset(alice, 'Greek');
final token = (await tokensOf(alice))['access_token'];
final listed = jsonDecode(await call(token, 'list_studysets')) as Map;
expect(
[for (final s in listed['studysets'] as List) (s as Map)['title']],
['Greek'],
);
expect(listed['private_studysets'], 1);
expect(listed['private_note'], contains('end-to-end encrypted'));
expect(
await call(token, 'read_studyset', {'studyset_id': diary.uuid}),
contains('end-to-end encrypted'),
);
final found = jsonDecode(
await call(token, 'search_cards', {'query': 'kept'}),
) as List;
expect(found, isEmpty);
});
test('leaves out a studyset waiting in Recently deleted, and what '
'is inside it', () async {
final japanese = await studyset(
@ -753,7 +791,9 @@ void main() {
final token = (await tokensOf(alice))['access_token'];
final listed =
jsonDecode(await call(token, 'list_studysets')) as List;
(jsonDecode(await call(token, 'list_studysets'))
as Map)['studysets']
as List;
expect([for (final s in listed) (s as Map)['title']], ['Greek']);
final found = jsonDecode(
await call(token, 'search_cards', {'query': 'fire'}),
@ -773,7 +813,9 @@ void main() {
final token = (await tokensOf(alice))['access_token'];
final listed =
jsonDecode(await call(token, 'list_studysets')) as List;
(jsonDecode(await call(token, 'list_studysets'))
as Map)['studysets']
as List;
expect(
[for (final s in listed) (s as Map)['title']],
[