From 7ee31e54aaa14fcda7638f0808be97a902510f83 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Thu, 5 Nov 2026 22:05:44 -0600 Subject: [PATCH 1/3] feat(dart-client): export the SSE parser --- dart-packages/forge_client/lib/forge_client.dart | 1 + dart-packages/forge_client/test/sse_parser_test.dart | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/dart-packages/forge_client/lib/forge_client.dart b/dart-packages/forge_client/lib/forge_client.dart index b299cba99..d6b5b1626 100644 --- a/dart-packages/forge_client/lib/forge_client.dart +++ b/dart-packages/forge_client/lib/forge_client.dart @@ -17,6 +17,7 @@ export 'src/ref.dart'; export 'src/registry.dart'; export 'src/security.dart'; export 'src/snapshot.dart'; +export 'src/sse.dart'; export 'src/state.dart'; export 'src/storage.dart'; export 'src/store.dart'; diff --git a/dart-packages/forge_client/test/sse_parser_test.dart b/dart-packages/forge_client/test/sse_parser_test.dart index 11c1a4273..c2c020bb9 100644 --- a/dart-packages/forge_client/test/sse_parser_test.dart +++ b/dart-packages/forge_client/test/sse_parser_test.dart @@ -1,4 +1,4 @@ -import 'package:forge_client/src/sse.dart'; +import 'package:forge_client/forge_client.dart'; import 'package:test/test.dart'; Future> _parse(List chunks) => From 1f49f6a60ccc2041eab7dfdde18a87b28a5ebab2 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Thu, 5 Nov 2026 22:05:48 -0600 Subject: [PATCH 2/3] fix(dart-client): keep mutation bodies out of devtools Devtools recorded argsKey(args), which included the request body, so a mutation carrying an API key sat in the in-memory ring in debug and profile builds. tagContextJson now keeps path and query only, the same rule requests.dart already follows. That covers the mutation log, explain output and the inspector. --- dart-packages/forge_client/CHANGELOG.md | 2 + .../lib/src/devtools/explain.dart | 5 +- .../test/devtools/explain_test.dart | 55 +++++++++++++++++++ .../forge_client/test/devtools/log_test.dart | 11 +++- 4 files changed, 67 insertions(+), 6 deletions(-) diff --git a/dart-packages/forge_client/CHANGELOG.md b/dart-packages/forge_client/CHANGELOG.md index 4e56d1d9e..8d9909a23 100644 --- a/dart-packages/forge_client/CHANGELOG.md +++ b/dart-packages/forge_client/CHANGELOG.md @@ -1,3 +1,5 @@ ## 1.0.0-dev - Initial release. +- Export `SseParser` and `SseEvent` from `package:forge_client/forge_client.dart`. +- Devtools no longer record a mutation's request body, in the log or in explain output. The operation, method, path and query still show. diff --git a/dart-packages/forge_client/lib/src/devtools/explain.dart b/dart-packages/forge_client/lib/src/devtools/explain.dart index 6a3f99372..d7afd4136 100644 --- a/dart-packages/forge_client/lib/src/devtools/explain.dart +++ b/dart-packages/forge_client/lib/src/devtools/explain.dart @@ -13,12 +13,11 @@ import 'seams.dart'; import 'tag.dart'; import 'types.dart'; -/// The JSON form of [args] the devtools keep: path, query and body. Headers -/// are always left out. +/// The JSON form of [args] the devtools keep: path and query. Never headers, +/// never the body: a mutation body can hold an API key or a secret value. Json tagContextJson(TagContext args) => { if (args.path.isNotEmpty) 'path': args.path, if (args.query.isNotEmpty) 'query': args.query, - if (args.body != null) 'body': args.body, }; /// A truncated argument key, so one enormous body cannot fill the ring. diff --git a/dart-packages/forge_client/test/devtools/explain_test.dart b/dart-packages/forge_client/test/devtools/explain_test.dart index fbaea8fe5..ab43448c9 100644 --- a/dart-packages/forge_client/test/devtools/explain_test.dart +++ b/dart-packages/forge_client/test/devtools/explain_test.dart @@ -1,5 +1,9 @@ +import 'dart:convert'; + import 'package:forge_client/forge_client.dart'; import 'package:forge_client/src/devtools/devtools.dart'; +import 'package:forge_client/src/devtools/explain.dart' + show argsKey, tagContextJson; import 'package:forge_client/src/devtools/tag.dart'; import 'package:forge_client/src/devtools/types.dart'; import 'package:test/test.dart'; @@ -9,6 +13,8 @@ import 'harness.dart'; const _one = TagContext(path: {'id': 1}); void main() { + _secretBodyTests(); + group('why did this query refetch', () { test('names the tag, the operation and the query it hit', () async { final h = Harness(); @@ -313,3 +319,52 @@ void main() { ); }); } + +void _secretBodyTests() { + group('a mutation body never reaches the devtools', () { + const marker = 'sk-secret-marker'; + + test( + 'the ring, its export and explain keep the call but not the body', + () async { + final h = Harness(); + final devtools = attach(h.cache, clock: CounterClock()); + final sub = h.mount(Ops.orderList); + await h.settle(); + + await h.cache.mutate( + Ops.orderUpdate, + const TagContext(path: {'id': 1}, body: {'apiKey': marker}), + ); + h.flush(); + await h.settle(); + + final mutation = devtools.log().whereType().first; + + // The operation and its path params still show. + expect(mutation.operation, 'PATCH /orders/{id}'); + expect(mutation.args, contains('"id":1')); + expect(mutation.args, isNot(contains('body'))); + + final everything = [ + mutation.args, + jsonEncode( + tagContextJson( + const TagContext(path: {'id': 1}, body: {'apiKey': marker}), + ), + ), + argsKey(const TagContext(body: {'apiKey': marker})), + devtools.whyRefetched(h.key(Ops.orderList))?.summary ?? '', + devtools.whyNotRefetched(h.key(Ops.orderList)).toString(), + for (final entry in devtools.log()) entry.toString(), + jsonEncode(devtools.snapshot()), + ].join('\n'); + + expect(everything, isNot(contains(marker))); + + await sub.cancel(); + devtools.dispose(); + }, + ); + }); +} diff --git a/dart-packages/forge_client/test/devtools/log_test.dart b/dart-packages/forge_client/test/devtools/log_test.dart index 99446461d..11dc3db22 100644 --- a/dart-packages/forge_client/test/devtools/log_test.dart +++ b/dart-packages/forge_client/test/devtools/log_test.dart @@ -89,7 +89,7 @@ void main() { await h.cache.mutate( Ops.orderCreate, - TagContext(body: {'note': 'y' * 5000}), + TagContext(path: {'note': 'y' * 5000}, body: {'note': 'z' * 5000}), ); h.flush(); await h.settle(); @@ -107,6 +107,7 @@ void main() { expect(mutation.args, endsWith('...')); expect(mutation.args.length, lessThan(220)); + expect(mutation.args, isNot(contains('z' * 5))); await sub.cancel(); devtools.dispose(); @@ -341,10 +342,14 @@ void main() { ]); // The premise: the planted string is in what the recorder holds, in the - // log (request body and query key) and in the frame ring. + // log (query key) and in the frame ring. The request body is not: a + // mutation body never reaches the devtools. final seen = devtools.log(); - expect(seen.whereType().single.args, contains(secret)); + expect( + seen.whereType().single.args, + isNot(contains(secret)), + ); expect( seen.whereType().any((entry) => entry.query.contains(secret)), isTrue, From 1d5fea13cda051564dead7c54724412a485a620a Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Thu, 5 Nov 2026 22:19:39 -0600 Subject: [PATCH 3/3] fix(dart-client): reserve the SSE parser's names in generated clients forge_client now exports SseEvent and SseParser, so a schema with either name would be ambiguous next to it. The generator renames such a schema like any other forge_client export. --- internal/client/generators/dart/errors.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/client/generators/dart/errors.go b/internal/client/generators/dart/errors.go index e6b171a9d..5d5b4a279 100644 --- a/internal/client/generators/dart/errors.go +++ b/internal/client/generators/dart/errors.go @@ -69,6 +69,7 @@ var forgeClientTypeNames = []string{ "OverlayEntry", "OverlayHost", "OverlayLayer", "QueryEntry", "QueryInvalidated", "QueryPlaced", "QuerySpec", "QueryTransition", "ReceiveOnlyConnection", "ResolvedPatches", "SettleResult", "SocketSnapshot", "StagedWrite", "SubscribeOptions", "SyncStatusChanged", "Unmount", + "SseEvent", "SseParser", } // dartCoreTypeNames are dart:core and dart:async types the generated code