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

@ -38,6 +38,8 @@ import 'package:halkyon_learn_server/src/generated/discussion/discussion_target.
as _ike2gc23;
import 'package:halkyon_learn_server/src/generated/discussion/discussion_view.dart'
as _ikk5e562;
import 'package:halkyon_learn_server/src/generated/explore/copy_report.dart'
as _ipludy4j;
import 'package:halkyon_learn_server/src/generated/explore/explore_entry.dart'
as _iscq690j;
import 'package:halkyon_learn_server/src/generated/explore/explore_query.dart'
@ -205,6 +207,8 @@ class TestEndpoints {
late final _ExploreEndpoint explore;
late final _LearningCountEndpoint learningCount;
late final _FeedbackEndpoint feedback;
late final _InboxEndpoint inbox;
@ -249,6 +253,10 @@ class _InternalTestEndpoints extends TestEndpoints
endpoints,
serializationManager,
);
learningCount = _LearningCountEndpoint(
endpoints,
serializationManager,
);
feedback = _FeedbackEndpoint(
endpoints,
serializationManager,
@ -291,8 +299,9 @@ class _AssetEndpoint {
_ida.Future<String?> startUpload(
_ist.TestSessionBuilder sessionBuilder,
String sha256,
int byteSize,
) async {
int byteSize, {
bool? encrypted,
}) async {
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
var _localUniqueSession =
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
@ -307,6 +316,7 @@ class _AssetEndpoint {
parameters: _ist.testObjectToJson({
'sha256': sha256,
'byteSize': byteSize,
'encrypted': encrypted,
}),
serializationManager: _serializationManager,
);
@ -325,8 +335,9 @@ class _AssetEndpoint {
_ida.Future<bool> finishUpload(
_ist.TestSessionBuilder sessionBuilder,
String sha256,
) async {
String sha256, {
bool? encrypted,
}) async {
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
var _localUniqueSession =
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
@ -338,7 +349,10 @@ class _AssetEndpoint {
createSessionCallback: (_) => _localUniqueSession,
endpointPath: 'asset',
methodName: 'finishUpload',
parameters: _ist.testObjectToJson({'sha256': sha256}),
parameters: _ist.testObjectToJson({
'sha256': sha256,
'encrypted': encrypted,
}),
serializationManager: _serializationManager,
);
var _localReturnValue =
@ -1535,6 +1549,107 @@ class _ExploreEndpoint {
}
}
class _LearningCountEndpoint {
_LearningCountEndpoint(
this._endpointDispatch,
this._serializationManager,
);
final _is.EndpointDispatch _endpointDispatch;
final _is.SerializationManager _serializationManager;
_ida.Future<bool> counted(_ist.TestSessionBuilder sessionBuilder) async {
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
var _localUniqueSession =
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
endpoint: 'learningCount',
method: 'counted',
);
try {
var _localCallContext = await _endpointDispatch.getMethodCallContext(
createSessionCallback: (_) => _localUniqueSession,
endpointPath: 'learningCount',
methodName: 'counted',
parameters: _ist.testObjectToJson({}),
serializationManager: _serializationManager,
);
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
)
as _ida.Future<bool>);
return _localReturnValue;
} finally {
await _localUniqueSession.close();
}
});
}
_ida.Future<void> setCounted(
_ist.TestSessionBuilder sessionBuilder,
bool counted,
) async {
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
var _localUniqueSession =
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
endpoint: 'learningCount',
method: 'setCounted',
);
try {
var _localCallContext = await _endpointDispatch.getMethodCallContext(
createSessionCallback: (_) => _localUniqueSession,
endpointPath: 'learningCount',
methodName: 'setCounted',
parameters: _ist.testObjectToJson({'counted': counted}),
serializationManager: _serializationManager,
);
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
)
as _ida.Future<void>);
return _localReturnValue;
} finally {
await _localUniqueSession.close();
}
});
}
_ida.Future<bool> reportCopies(
_ist.TestSessionBuilder sessionBuilder,
List<_ipludy4j.CopyReport> copies,
) async {
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
var _localUniqueSession =
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
endpoint: 'learningCount',
method: 'reportCopies',
);
try {
var _localCallContext = await _endpointDispatch.getMethodCallContext(
createSessionCallback: (_) => _localUniqueSession,
endpointPath: 'learningCount',
methodName: 'reportCopies',
parameters: _ist.testObjectToJson({'copies': copies}),
serializationManager: _serializationManager,
);
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
)
as _ida.Future<bool>);
return _localReturnValue;
} finally {
await _localUniqueSession.close();
}
});
}
}
class _FeedbackEndpoint {
_FeedbackEndpoint(
this._endpointDispatch,
@ -1922,6 +2037,7 @@ class _PublishEndpoint {
String slug,
String notes, {
String? description,
Map<String, String>? tagNames,
}) async {
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
var _localUniqueSession =
@ -1939,6 +2055,7 @@ class _PublishEndpoint {
'slug': slug,
'notes': notes,
'description': description,
'tagNames': tagNames,
}),
serializationManager: _serializationManager,
);