fix(feedback): a suggestion takes along only its own tree's sealed files (ADR 0019)

The card's, note's or step's own id goes with a suggestion, and a sealed
file goes along readable only where it is under the key of that item's
tree: a file of another private tree, named by a row the instance
planted, is left out and never uploaded readable (the short reading of
22 Oct, finding 12).

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-22 10:29:45 +02:00
commit 5a61f2ea07
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
7 changed files with 148 additions and 38 deletions

View file

@ -229,6 +229,11 @@ class AssetRepository(
@override @override
Future<bool> exists(String id) async => await _rows.byId(id) != null; Future<bool> exists(String id) async => await _rows.byId(id) != null;
/// The key of the tree the item [id] of [kind] is in; null while
/// readable.
Future<String?> keyOfItem(ItemKind kind, String id) =>
_sealing.keyOfItem(kind, id);
@override @override
Future<({Uint8List bytes, String fileName, String mimeType})?> plainOf( Future<({Uint8List bytes, String fileName, String mimeType})?> plainOf(
String id, String id,

View file

@ -284,6 +284,7 @@ class _CardComposerScreenState() extends State<CardComposerScreen> {
feedback.suggestCard( feedback.suggestCard(
selected.content.normalized, selected.content.normalized,
body: body, body: body,
itemId: selected.cardId,
), ),
) )
: null, : null,

View file

@ -68,48 +68,72 @@ class FeedbackRepository(
/// Suggests a copy's card, as the person has it ([content]), to the /// Suggests a copy's card, as the person has it ([content]), to the
/// author of the published studyset it came from, with [body] saying /// author of the published studyset it came from, with [body] saying
/// why. Throws an [ArgumentError] for a card of the person's own. /// why. Throws an [ArgumentError] for a card of the person's own.
Future<void> suggestCard(CardContent content, {required String body}) => ///
_suggest( /// [itemId], the card's own id here, lets its files go along, those of
content.published, /// its own tree alone (the short reading of 22 Oct).
FeedbackTargetKind.card, Future<void> suggestCard(
_json( CardContent content, {
encodeCardPayload( required String body,
content String? itemId,
.withField(CardField.published, null) }) => _suggest(
.withField(CardField.report, null), content.published,
), FeedbackTargetKind.card,
), _json(
body, encodeCardPayload(
); content
.withField(CardField.published, null)
.withField(CardField.report, null),
),
),
body,
itemId: itemId,
);
/// Suggests a copy's note, as with [suggestCard]. /// Suggests a copy's note, as with [suggestCard].
Future<void> suggestNote(NoteContent content, {required String body}) => Future<void> suggestNote(
_suggest( NoteContent content, {
content.published, required String body,
FeedbackTargetKind.note, String? itemId,
_json( }) => _suggest(
encodeNotePayload(content.withField(NoteField.published, null)), content.published,
), FeedbackTargetKind.note,
body, _json(
); encodeNotePayload(content.withField(NoteField.published, null)),
),
body,
itemId: itemId,
);
/// Suggests a copy's step, as with [suggestCard]. /// Suggests a copy's step, as with [suggestCard].
Future<void> suggestStep(StepContent content, {required String body}) => Future<void> suggestStep(
_suggest( StepContent content, {
content.published, required String body,
FeedbackTargetKind.step, String? itemId,
_json( }) => _suggest(
encodeStepPayload(content.withField(StepField.published, null)), content.published,
), FeedbackTargetKind.step,
body, _json(
); encodeStepPayload(content.withField(StepField.published, null)),
),
body,
itemId: itemId,
);
Future<void> _suggest( Future<void> _suggest(
PublishedRef? origin, PublishedRef? origin,
FeedbackTargetKind kind, FeedbackTargetKind kind,
Map<String, Object?> proposed, Map<String, Object?> proposed,
String body, String body, {
) async { String? itemId,
}) async {
// The key of the item's own tree: only files under it go along readable.
final itemKey = itemId == null
? null
: await _assets.keyOfItem(switch (kind) {
FeedbackTargetKind.card => ItemKind.card,
FeedbackTargetKind.note => ItemKind.note,
_ => ItemKind.step,
}, itemId);
if (origin == null) { if (origin == null) {
throw ArgumentError.value(origin, 'origin', 'is not a copy'); throw ArgumentError.value(origin, 'origin', 'is not a copy');
} }
@ -117,11 +141,20 @@ class FeedbackRepository(
final files = <FeedbackFile>[]; final files = <FeedbackFile>[];
for (final assetId in assetIdsIn(proposed)) { for (final assetId in assetIdsIn(proposed)) {
if (await _assets.fileOf(assetId) case final file?) { if (await _assets.fileOf(assetId) case final file?) {
// A sealed file is left out: the instance could have named another // A sealed file goes along readable only where it is of the item's
// private tree's file through a row it planted, and the suggestion // own tree: the instance could have named another private tree's
// would upload it readable (the short reading of 22 Oct). // file through a row it planted (the short reading of 22 Oct).
if (await _assets.keyIdOf(assetId) != null) { final fileKey = await _assets.keyIdOf(assetId);
continue; if (fileKey != null) {
if (fileKey != itemKey) {
continue;
}
await _assets.add(
file.bytes,
fileName: file.fileName,
mimeType: file.mimeType,
studysetId: null,
);
} }
files.add( files.add(
FeedbackFile( FeedbackFile(

View file

@ -80,6 +80,9 @@ class _StepEditorScreenState() extends State<StepEditorScreen> {
send: (feedback, body) => feedback.suggestStep( send: (feedback, body) => feedback.suggestStep(
state.content.normalized, state.content.normalized,
body: body, body: body,
itemId: GoRouterState.of(
context,
).pathParameters['stepId'],
), ),
), ),
), ),

View file

@ -84,6 +84,7 @@ class _NoteEditorScreenState() extends State<NoteEditorScreen> {
send: (feedback, body) => feedback.suggestNote( send: (feedback, body) => feedback.suggestNote(
state.content.normalized, state.content.normalized,
body: body, body: body,
itemId: state.noteId,
), ),
), ),
), ),

View file

@ -496,6 +496,7 @@ class _CardsPanelState()
send: (feedback, body) => feedback.suggestCard( send: (feedback, body) => feedback.suggestCard(
draft.content.normalized, draft.content.normalized,
body: body, body: body,
itemId: draft.cardId,
), ),
) )
: null, : null,

View file

@ -129,6 +129,72 @@ void main() {
]); ]);
}); });
test("a suggestion takes along readable only files of the item's own "
'tree: one of another private tree, named by a row the instance '
'planted, is never uploaded', () async {
final rows = FakeAssetGateway();
final cache = MemoryAssetFileCache();
final sealedAssets = AssetRepository(
rows,
FakeAssetServerGateway(),
cache,
FakeAssetSealing(
keys: {'own-tree': 'k-own', 'other-tree': 'k-other'},
itemKeys: {'card-1': 'k-own'},
),
);
final sealedFeedback = FeedbackRepository(
written,
server,
cards,
notes,
LearningPathRepository(FakeLearningPathGateway()),
sealedAssets,
);
final mine = await sealedAssets.add(
image,
fileName: 'cat.png',
mimeType: 'image/png',
studysetId: 'own-tree',
);
final secret = utf8.encode('a secret picture');
final theirs = await sealedAssets.add(
secret,
fileName: 'secret.png',
mimeType: 'image/png',
studysetId: 'other-tree',
);
Map<String, Object?> imageBlock(String id) => {
'type': 'image',
'data': {'url': 'asset:$id'},
};
await sealedFeedback.suggestCard(
CardContent(
front: RichContent({
'document': {
'type': 'page',
'children': [imageBlock(mine), imageBlock(theirs)],
},
}),
back: RichContent.plain('a cat'),
published: origin,
),
body: 'Pictures help.',
itemId: 'card-1',
);
final files = written.threads.single.json['files']! as List<Object?>;
expect([for (final file in files) (file! as Map)['assetId']], [mine]);
// A readable copy of the own file waits to upload; the other's never.
final readable = {
for (final row in rows.rows)
if (row.keyId == null) row.sha256,
};
expect(readable, {imageHash});
expect(readable, isNot(contains(sha256Of(secret))));
});
test("an accepted suggestion becomes the author's own card, its files " test("an accepted suggestion becomes the author's own card, its files "
'theirs, and is recorded so', () async { 'theirs, and is recorded so', () async {
final id = await cards.create( final id = await cards.create(