Vatxzz commited on
Commit
3cae21f
·
1 Parent(s): 251e1bb

feat: bearer-token transport with single 401 refresh-retry

Browse files

Every verb funnels through _send (auth header + one forced-refresh retry on 401); SSE stream carries the bearer header; x-owner-id removed.

app/lib/data/services/api_client.dart CHANGED
@@ -88,11 +88,19 @@ class RabbitHoleStep {
88
  }
89
 
90
  class ApiClient {
91
- ApiClient({String? baseUrl, http.Client? client, LocalStore? store})
92
- : baseUrl = (baseUrl ?? _defaultBaseUrl).replaceAll(RegExp(r'/+$'), ''),
 
 
 
 
93
  _client = client ?? http.Client(),
94
  _store = store;
95
 
 
 
 
 
96
  /// Default backend for end users: the hosted Hugging Face Space, so a plain
97
  /// `flutter build apk` connects with zero config. Override at build time with
98
  /// `--dart-define=CACHY_API_BASE=https://host` (empty = same-origin, for the
@@ -131,10 +139,22 @@ class ApiClient {
131
  return discovered;
132
  }
133
 
134
- Map<String, String> get _ownerHeader {
135
- final name = _store?.userName;
136
- if (name == null || name.isEmpty) return const {};
137
- return {'x-owner-id': name};
 
 
 
 
 
 
 
 
 
 
 
 
138
  }
139
 
140
  Uri _uri(String path, [Map<String, dynamic>? query]) => Uri.parse('$baseUrl$path')
@@ -154,10 +174,9 @@ class ApiClient {
154
  // ------------------------------------------------------------------------- //
155
 
156
  Future<CreateCardResult> createCard(String url) async {
157
- final resp = await _client.post(
158
- _uri('/cards'),
159
- headers: {'content-type': 'application/json', ..._ownerHeader},
160
- body: jsonEncode({'url': url}),
161
  );
162
  final json = _decodeMap(resp);
163
  return CreateCardResult(
@@ -171,8 +190,7 @@ class ApiClient {
171
  /// Stored extraction bundle of a quota-degraded card, or null when the
172
  /// server has none (not degraded / already upgraded / not the owner).
173
  Future<Map<String, String>?> getBundle(String cardId) async {
174
- final resp =
175
- await _client.get(_uri('/cards/$cardId/bundle'), headers: _ownerHeader);
176
  if (resp.statusCode == 404) return null;
177
  final json = _decodeMap(resp);
178
  return {
@@ -185,16 +203,15 @@ class ApiClient {
185
  /// Upload a device-generated structured card. The server re-validates and
186
  /// throws [ApiException] (422) when the payload doesn't survive validation.
187
  Future<Card> uploadStructure(String cardId, Map<String, dynamic> payload) async {
188
- final resp = await _client.post(
189
- _uri('/cards/$cardId/structure'),
190
- headers: {'content-type': 'application/json', ..._ownerHeader},
191
- body: jsonEncode(payload),
192
  );
193
  return Card.fromJson(_decodeMap(resp));
194
  }
195
 
196
  Future<Card> getCard(String cardId) async {
197
- final resp = await _client.get(_uri('/cards/$cardId'), headers: _ownerHeader);
198
  return Card.fromJson(_decodeMap(resp));
199
  }
200
 
@@ -205,16 +222,16 @@ class ApiClient {
205
  int limit = 50,
206
  int offset = 0,
207
  }) async {
208
- final resp = await _client.get(
209
- _uri('/cards', {
210
- 'state': ?state?.wire,
211
- 'content_type': ?contentType,
212
- 'collection_id': ?collectionId,
213
- 'limit': limit,
214
- 'offset': offset,
215
- }),
216
- headers: _ownerHeader,
217
- );
218
  return _decodeList(resp).map(Card.fromJson).toList();
219
  }
220
 
@@ -223,10 +240,9 @@ class ApiClient {
223
  String cardId,
224
  List<Map<String, dynamic>> blocks,
225
  ) async {
226
- final resp = await _client.patch(
227
- _uri('/cards/$cardId'),
228
- headers: {'content-type': 'application/json', ..._ownerHeader},
229
- body: jsonEncode({'blocks': blocks}),
230
  );
231
  return Card.fromJson(_decodeMap(resp));
232
  }
@@ -236,16 +252,16 @@ class ApiClient {
236
  String cardId,
237
  Map<String, dynamic> actionItems,
238
  ) async {
239
- final resp = await _client.patch(
240
- _uri('/cards/$cardId'),
241
- headers: {'content-type': 'application/json', ..._ownerHeader},
242
- body: jsonEncode({'action_items': actionItems}),
243
  );
244
  return Card.fromJson(_decodeMap(resp));
245
  }
246
 
247
  Future<void> deleteCard(String cardId) async {
248
- final resp = await _client.delete(_uri('/cards/$cardId'), headers: _ownerHeader);
249
  if (resp.statusCode >= 400) {
250
  throw ApiException(resp.statusCode, resp.body);
251
  }
@@ -253,19 +269,16 @@ class ApiClient {
253
 
254
  Future<void> importCards(List<Map<String, dynamic>> cards) async {
255
  if (cards.isEmpty) return;
256
- final resp = await _client.post(
257
- _uri('/cards/import'),
258
- headers: {'content-type': 'application/json', ..._ownerHeader},
259
- body: jsonEncode({'cards': cards}),
260
  );
261
  if (resp.statusCode >= 400) throw ApiException(resp.statusCode, resp.body);
262
  }
263
 
264
  Future<List<Card>> search(String query, {int limit = 30}) async {
265
- final resp = await _client.get(
266
- _uri('/search', {'q': query, 'limit': limit}),
267
- headers: _ownerHeader,
268
- );
269
  return _decodeList(resp).map(Card.fromJson).toList();
270
  }
271
 
@@ -273,10 +286,10 @@ class ApiClient {
273
  /// [{'role','content'}]; returns the assistant's reply text. The conversation
274
  /// is persisted server-side per owner (docs/14).
275
  Future<String> chat(String cardId, List<Map<String, String>> messages) async {
276
- final resp = await _client.post(
277
- _uri('/cards/$cardId/chat'),
278
- headers: {'content-type': 'application/json', ..._ownerHeader},
279
- body: jsonEncode({'messages': messages}),
280
  );
281
  return (_decodeMap(resp)['reply'] as String?) ?? '';
282
  }
@@ -284,7 +297,7 @@ class ApiClient {
284
  /// Restore this owner's saved chat for a card (docs/14) as
285
  /// [{'role','content'}] maps, oldest → newest. Empty when none saved.
286
  Future<List<Map<String, String>>> chatHistory(String cardId) async {
287
- final resp = await _client.get(_uri('/cards/$cardId/chat'), headers: _ownerHeader);
288
  return _decodeMessages(_decodeMap(resp)['messages']);
289
  }
290
 
@@ -299,10 +312,10 @@ class ApiClient {
299
  List<String> trail,
300
  String root,
301
  ) async {
302
- final resp = await _client.post(
303
- _uri('/cards/$cardId/rabbithole'),
304
- headers: {'content-type': 'application/json', ..._ownerHeader},
305
- body: jsonEncode({'topic': topic, 'trail': trail, 'root': root}),
306
  );
307
  final json = _decodeMap(resp);
308
  return RabbitHoleStep(
@@ -317,10 +330,8 @@ class ApiClient {
317
  /// Restore this owner's saved rabbit-hole trail for a card + [root] topic
318
  /// (docs/14), oldest → deepest. Empty when none saved.
319
  Future<List<RabbitHoleStep>> rabbitHoleHistory(String cardId, String root) async {
320
- final resp = await _client.get(
321
- _uri('/cards/$cardId/rabbithole', {'root': root}),
322
- headers: _ownerHeader,
323
- );
324
  return ((_decodeMap(resp)['steps'] as List?) ?? const [])
325
  .whereType<Map<String, dynamic>>()
326
  .map((s) => RabbitHoleStep(
@@ -338,33 +349,31 @@ class ApiClient {
338
  // ------------------------------------------------------------------------- //
339
 
340
  Future<List<CollectionEntry>> listCollections() async {
341
- final resp = await _client.get(_uri('/collections'), headers: _ownerHeader);
342
  return _decodeList(resp).map(CollectionEntry.fromJson).toList();
343
  }
344
 
345
  Future<CollectionEntry> renameCollection(String id, String name) async {
346
- final resp = await _client.patch(
347
- _uri('/collections/$id'),
348
- headers: {'content-type': 'application/json', ..._ownerHeader},
349
- body: jsonEncode({'name': name}),
350
  );
351
  return CollectionEntry.fromJson(_decodeMap(resp));
352
  }
353
 
354
  Future<CollectionEntry> createCollection(String name) async {
355
- final resp = await _client.post(
356
- _uri('/collections'),
357
- headers: {'content-type': 'application/json', ..._ownerHeader},
358
- body: jsonEncode({'name': name}),
359
  );
360
  return CollectionEntry.fromJson(_decodeMap(resp));
361
  }
362
 
363
  Future<void> moveCardToCollection(String cardId, String? collectionId) async {
364
- final resp = await _client.post(
365
- _uri('/collections/cards/$cardId/move'),
366
- headers: {'content-type': 'application/json', ..._ownerHeader},
367
- body: jsonEncode({'collection_id': collectionId}),
368
  );
369
  if (resp.statusCode >= 400) throw ApiException(resp.statusCode, resp.body);
370
  }
@@ -378,16 +387,19 @@ class ApiClient {
378
  int limit = 200,
379
  int offset = 0,
380
  }) async {
381
- final resp = await _client.get(_uri('/catalog', {
382
- 'type': ?type?.wire,
383
- 'limit': limit,
384
- 'offset': offset,
385
- }), headers: _ownerHeader);
 
 
 
386
  return _decodeList(resp).map(CatalogEntry.fromJson).toList();
387
  }
388
 
389
  Future<void> deleteCatalogEntry(String artifactId) async {
390
- final resp = await _client.delete(_uri('/catalog/$artifactId'), headers: _ownerHeader);
391
  if (resp.statusCode >= 400) {
392
  throw ApiException(resp.statusCode, resp.body);
393
  }
@@ -395,22 +407,21 @@ class ApiClient {
395
 
396
  /// Save a referenced artifact into the catalog tab (long-press to save).
397
  Future<CatalogEntry> saveCatalogEntry(String artifactId) async {
398
- final resp = await _client.post(_uri('/catalog/$artifactId/save'), headers: _ownerHeader);
399
  return CatalogEntry.fromJson(_decodeMap(resp));
400
  }
401
 
402
  /// Generate + persist the on-demand LLM detail for an artifact (Fetch info).
403
  Future<CatalogEntry> fetchCatalogInfo(String artifactId) async {
404
- final resp = await _client.post(_uri('/catalog/$artifactId/fetch-info'), headers: _ownerHeader);
 
405
  return CatalogEntry.fromJson(_decodeMap(resp));
406
  }
407
 
408
  /// Artifacts a single card references (docs/12) — the reader "References" strip.
409
  Future<List<CatalogEntry>> cardArtifacts(String cardId, {int limit = 50}) async {
410
- final resp = await _client.get(
411
- _uri('/catalog', {'card_id': cardId, 'limit': limit}),
412
- headers: _ownerHeader,
413
- );
414
  return _decodeList(resp).map(CatalogEntry.fromJson).toList();
415
  }
416
 
@@ -419,25 +430,28 @@ class ApiClient {
419
  // ------------------------------------------------------------------------- //
420
 
421
  Future<List<ConceptEntry>> listConcepts({String? cardId, int limit = 200}) async {
422
- final resp = await _client.get(_uri('/concepts', {
423
- 'card_id': ?cardId,
424
- 'limit': limit,
425
- }), headers: _ownerHeader);
 
 
 
426
  return _decodeList(resp).map(ConceptEntry.fromJson).toList();
427
  }
428
 
429
  Future<ConceptDetail> getConcept(String conceptId) async {
430
- final resp = await _client.get(_uri('/concepts/$conceptId'), headers: _ownerHeader);
431
  return ConceptDetail.fromJson(_decodeMap(resp));
432
  }
433
 
434
  Future<ConceptEntry> defineConcept(String conceptId) async {
435
- final resp = await _client.post(_uri('/concepts/$conceptId/define'), headers: _ownerHeader);
436
  return ConceptEntry.fromJson(_decodeMap(resp));
437
  }
438
 
439
  Future<void> deleteConcept(String conceptId) async {
440
- final resp = await _client.delete(_uri('/concepts/$conceptId'), headers: _ownerHeader);
441
  if (resp.statusCode >= 400) throw ApiException(resp.statusCode, resp.body);
442
  }
443
 
@@ -446,10 +460,8 @@ class ApiClient {
446
  // ------------------------------------------------------------------------- //
447
 
448
  Future<GraphData> graph({double threshold = 0.55, int topK = 4}) async {
449
- final resp = await _client.get(
450
- _uri('/graph', {'threshold': threshold, 'top_k': topK}),
451
- headers: _ownerHeader,
452
- );
453
  return GraphData.fromJson(_decodeMap(resp));
454
  }
455
 
@@ -461,10 +473,10 @@ class ApiClient {
461
  /// Returns the reply plus the cards it was grounded on.
462
  Future<LibraryChatResult> libraryChat(
463
  List<Map<String, String>> messages) async {
464
- final resp = await _client.post(
465
- _uri('/library/chat'),
466
- headers: {'content-type': 'application/json', ..._ownerHeader},
467
- body: jsonEncode({'messages': messages}),
468
  );
469
  final json = _decodeMap(resp);
470
  final sources = ((json['sources'] as List?) ?? const [])
@@ -483,7 +495,7 @@ class ApiClient {
483
  /// Restore this owner's saved library chat (docs/14) as [{'role','content'}]
484
  /// maps, oldest → newest. Empty when none saved.
485
  Future<List<Map<String, String>>> libraryChatHistory() async {
486
- final resp = await _client.get(_uri('/library/chat'), headers: _ownerHeader);
487
  return _decodeMessages(_decodeMap(resp)['messages']);
488
  }
489
 
@@ -494,10 +506,8 @@ class ApiClient {
494
  /// The reel-style knowledge feed: a shuffled stream of moments built from the
495
  /// owner's cards. Owner-scoped.
496
  Future<List<FeedItem>> feed({int limit = 40}) async {
497
- final resp = await _client.get(
498
- _uri('/feed', {'limit': limit}),
499
- headers: _ownerHeader,
500
- );
501
  return ((_decodeMap(resp)['items'] as List?) ?? const [])
502
  .whereType<Map<String, dynamic>>()
503
  .map(FeedItem.fromJson)
@@ -507,10 +517,10 @@ class ApiClient {
507
  /// Surprising links between the owner's cards. [refresh] spends a little more
508
  /// LLM budget to surface fresh connections.
509
  Future<List<Connection>> connections({int limit = 12, bool refresh = false}) async {
510
- final resp = await _client.get(
511
- _uri('/connections', {'limit': limit, if (refresh) 'refresh': true}),
512
- headers: _ownerHeader,
513
- );
514
  return ((_decodeMap(resp)['connections'] as List?) ?? const [])
515
  .whereType<Map<String, dynamic>>()
516
  .map(Connection.fromJson)
@@ -527,7 +537,8 @@ class ApiClient {
527
  /// subscription to disconnect early.
528
  Stream<PipelineEvent> streamCard(String cardId) async* {
529
  final request = http.Request('GET', _uri('/cards/$cardId/stream'))
530
- ..headers['accept'] = 'text/event-stream';
 
531
  final response = await _client.send(request);
532
  if (response.statusCode >= 400) {
533
  throw ApiException(response.statusCode, 'stream failed');
 
88
  }
89
 
90
  class ApiClient {
91
+ ApiClient({
92
+ String? baseUrl,
93
+ http.Client? client,
94
+ LocalStore? store,
95
+ this.tokenProvider,
96
+ }) : baseUrl = (baseUrl ?? _defaultBaseUrl).replaceAll(RegExp(r'/+$'), ''),
97
  _client = client ?? http.Client(),
98
  _store = store;
99
 
100
+ /// Supplies the Firebase ID token (uid = backend owner_id). `forceRefresh`
101
+ /// mints a fresh token after a 401. Null when signed out.
102
+ final Future<String?> Function({bool forceRefresh})? tokenProvider;
103
+
104
  /// Default backend for end users: the hosted Hugging Face Space, so a plain
105
  /// `flutter build apk` connects with zero config. Override at build time with
106
  /// `--dart-define=CACHY_API_BASE=https://host` (empty = same-origin, for the
 
139
  return discovered;
140
  }
141
 
142
+ Future<Map<String, String>> _authHeader({bool forceRefresh = false}) async {
143
+ final token = await tokenProvider?.call(forceRefresh: forceRefresh);
144
+ if (token == null || token.isEmpty) return const {};
145
+ return {'authorization': 'Bearer $token'};
146
+ }
147
+
148
+ /// All verbs funnel through here: auth header + one refresh-retry on 401.
149
+ Future<http.Response> _send(
150
+ Future<http.Response> Function(Map<String, String> headers) go, {
151
+ Map<String, String> extra = const {},
152
+ }) async {
153
+ var resp = await go({...extra, ...await _authHeader()});
154
+ if (resp.statusCode == 401 && tokenProvider != null) {
155
+ resp = await go({...extra, ...await _authHeader(forceRefresh: true)});
156
+ }
157
+ return resp;
158
  }
159
 
160
  Uri _uri(String path, [Map<String, dynamic>? query]) => Uri.parse('$baseUrl$path')
 
174
  // ------------------------------------------------------------------------- //
175
 
176
  Future<CreateCardResult> createCard(String url) async {
177
+ final resp = await _send(
178
+ (h) => _client.post(_uri('/cards'), headers: h, body: jsonEncode({'url': url})),
179
+ extra: const {'content-type': 'application/json'},
 
180
  );
181
  final json = _decodeMap(resp);
182
  return CreateCardResult(
 
190
  /// Stored extraction bundle of a quota-degraded card, or null when the
191
  /// server has none (not degraded / already upgraded / not the owner).
192
  Future<Map<String, String>?> getBundle(String cardId) async {
193
+ final resp = await _send((h) => _client.get(_uri('/cards/$cardId/bundle'), headers: h));
 
194
  if (resp.statusCode == 404) return null;
195
  final json = _decodeMap(resp);
196
  return {
 
203
  /// Upload a device-generated structured card. The server re-validates and
204
  /// throws [ApiException] (422) when the payload doesn't survive validation.
205
  Future<Card> uploadStructure(String cardId, Map<String, dynamic> payload) async {
206
+ final resp = await _send(
207
+ (h) => _client.post(_uri('/cards/$cardId/structure'), headers: h, body: jsonEncode(payload)),
208
+ extra: const {'content-type': 'application/json'},
 
209
  );
210
  return Card.fromJson(_decodeMap(resp));
211
  }
212
 
213
  Future<Card> getCard(String cardId) async {
214
+ final resp = await _send((h) => _client.get(_uri('/cards/$cardId'), headers: h));
215
  return Card.fromJson(_decodeMap(resp));
216
  }
217
 
 
222
  int limit = 50,
223
  int offset = 0,
224
  }) async {
225
+ final resp = await _send((h) => _client.get(
226
+ _uri('/cards', {
227
+ 'state': ?state?.wire,
228
+ 'content_type': ?contentType,
229
+ 'collection_id': ?collectionId,
230
+ 'limit': limit,
231
+ 'offset': offset,
232
+ }),
233
+ headers: h,
234
+ ));
235
  return _decodeList(resp).map(Card.fromJson).toList();
236
  }
237
 
 
240
  String cardId,
241
  List<Map<String, dynamic>> blocks,
242
  ) async {
243
+ final resp = await _send(
244
+ (h) => _client.patch(_uri('/cards/$cardId'), headers: h, body: jsonEncode({'blocks': blocks})),
245
+ extra: const {'content-type': 'application/json'},
 
246
  );
247
  return Card.fromJson(_decodeMap(resp));
248
  }
 
252
  String cardId,
253
  Map<String, dynamic> actionItems,
254
  ) async {
255
+ final resp = await _send(
256
+ (h) => _client.patch(_uri('/cards/$cardId'),
257
+ headers: h, body: jsonEncode({'action_items': actionItems})),
258
+ extra: const {'content-type': 'application/json'},
259
  );
260
  return Card.fromJson(_decodeMap(resp));
261
  }
262
 
263
  Future<void> deleteCard(String cardId) async {
264
+ final resp = await _send((h) => _client.delete(_uri('/cards/$cardId'), headers: h));
265
  if (resp.statusCode >= 400) {
266
  throw ApiException(resp.statusCode, resp.body);
267
  }
 
269
 
270
  Future<void> importCards(List<Map<String, dynamic>> cards) async {
271
  if (cards.isEmpty) return;
272
+ final resp = await _send(
273
+ (h) => _client.post(_uri('/cards/import'), headers: h, body: jsonEncode({'cards': cards})),
274
+ extra: const {'content-type': 'application/json'},
 
275
  );
276
  if (resp.statusCode >= 400) throw ApiException(resp.statusCode, resp.body);
277
  }
278
 
279
  Future<List<Card>> search(String query, {int limit = 30}) async {
280
+ final resp = await _send(
281
+ (h) => _client.get(_uri('/search', {'q': query, 'limit': limit}), headers: h));
 
 
282
  return _decodeList(resp).map(Card.fromJson).toList();
283
  }
284
 
 
286
  /// [{'role','content'}]; returns the assistant's reply text. The conversation
287
  /// is persisted server-side per owner (docs/14).
288
  Future<String> chat(String cardId, List<Map<String, String>> messages) async {
289
+ final resp = await _send(
290
+ (h) => _client.post(_uri('/cards/$cardId/chat'),
291
+ headers: h, body: jsonEncode({'messages': messages})),
292
+ extra: const {'content-type': 'application/json'},
293
  );
294
  return (_decodeMap(resp)['reply'] as String?) ?? '';
295
  }
 
297
  /// Restore this owner's saved chat for a card (docs/14) as
298
  /// [{'role','content'}] maps, oldest → newest. Empty when none saved.
299
  Future<List<Map<String, String>>> chatHistory(String cardId) async {
300
+ final resp = await _send((h) => _client.get(_uri('/cards/$cardId/chat'), headers: h));
301
  return _decodeMessages(_decodeMap(resp)['messages']);
302
  }
303
 
 
312
  List<String> trail,
313
  String root,
314
  ) async {
315
+ final resp = await _send(
316
+ (h) => _client.post(_uri('/cards/$cardId/rabbithole'),
317
+ headers: h, body: jsonEncode({'topic': topic, 'trail': trail, 'root': root})),
318
+ extra: const {'content-type': 'application/json'},
319
  );
320
  final json = _decodeMap(resp);
321
  return RabbitHoleStep(
 
330
  /// Restore this owner's saved rabbit-hole trail for a card + [root] topic
331
  /// (docs/14), oldest → deepest. Empty when none saved.
332
  Future<List<RabbitHoleStep>> rabbitHoleHistory(String cardId, String root) async {
333
+ final resp = await _send((h) =>
334
+ _client.get(_uri('/cards/$cardId/rabbithole', {'root': root}), headers: h));
 
 
335
  return ((_decodeMap(resp)['steps'] as List?) ?? const [])
336
  .whereType<Map<String, dynamic>>()
337
  .map((s) => RabbitHoleStep(
 
349
  // ------------------------------------------------------------------------- //
350
 
351
  Future<List<CollectionEntry>> listCollections() async {
352
+ final resp = await _send((h) => _client.get(_uri('/collections'), headers: h));
353
  return _decodeList(resp).map(CollectionEntry.fromJson).toList();
354
  }
355
 
356
  Future<CollectionEntry> renameCollection(String id, String name) async {
357
+ final resp = await _send(
358
+ (h) => _client.patch(_uri('/collections/$id'), headers: h, body: jsonEncode({'name': name})),
359
+ extra: const {'content-type': 'application/json'},
 
360
  );
361
  return CollectionEntry.fromJson(_decodeMap(resp));
362
  }
363
 
364
  Future<CollectionEntry> createCollection(String name) async {
365
+ final resp = await _send(
366
+ (h) => _client.post(_uri('/collections'), headers: h, body: jsonEncode({'name': name})),
367
+ extra: const {'content-type': 'application/json'},
 
368
  );
369
  return CollectionEntry.fromJson(_decodeMap(resp));
370
  }
371
 
372
  Future<void> moveCardToCollection(String cardId, String? collectionId) async {
373
+ final resp = await _send(
374
+ (h) => _client.post(_uri('/collections/cards/$cardId/move'),
375
+ headers: h, body: jsonEncode({'collection_id': collectionId})),
376
+ extra: const {'content-type': 'application/json'},
377
  );
378
  if (resp.statusCode >= 400) throw ApiException(resp.statusCode, resp.body);
379
  }
 
387
  int limit = 200,
388
  int offset = 0,
389
  }) async {
390
+ final resp = await _send((h) => _client.get(
391
+ _uri('/catalog', {
392
+ 'type': ?type?.wire,
393
+ 'limit': limit,
394
+ 'offset': offset,
395
+ }),
396
+ headers: h,
397
+ ));
398
  return _decodeList(resp).map(CatalogEntry.fromJson).toList();
399
  }
400
 
401
  Future<void> deleteCatalogEntry(String artifactId) async {
402
+ final resp = await _send((h) => _client.delete(_uri('/catalog/$artifactId'), headers: h));
403
  if (resp.statusCode >= 400) {
404
  throw ApiException(resp.statusCode, resp.body);
405
  }
 
407
 
408
  /// Save a referenced artifact into the catalog tab (long-press to save).
409
  Future<CatalogEntry> saveCatalogEntry(String artifactId) async {
410
+ final resp = await _send((h) => _client.post(_uri('/catalog/$artifactId/save'), headers: h));
411
  return CatalogEntry.fromJson(_decodeMap(resp));
412
  }
413
 
414
  /// Generate + persist the on-demand LLM detail for an artifact (Fetch info).
415
  Future<CatalogEntry> fetchCatalogInfo(String artifactId) async {
416
+ final resp =
417
+ await _send((h) => _client.post(_uri('/catalog/$artifactId/fetch-info'), headers: h));
418
  return CatalogEntry.fromJson(_decodeMap(resp));
419
  }
420
 
421
  /// Artifacts a single card references (docs/12) — the reader "References" strip.
422
  Future<List<CatalogEntry>> cardArtifacts(String cardId, {int limit = 50}) async {
423
+ final resp = await _send((h) =>
424
+ _client.get(_uri('/catalog', {'card_id': cardId, 'limit': limit}), headers: h));
 
 
425
  return _decodeList(resp).map(CatalogEntry.fromJson).toList();
426
  }
427
 
 
430
  // ------------------------------------------------------------------------- //
431
 
432
  Future<List<ConceptEntry>> listConcepts({String? cardId, int limit = 200}) async {
433
+ final resp = await _send((h) => _client.get(
434
+ _uri('/concepts', {
435
+ 'card_id': ?cardId,
436
+ 'limit': limit,
437
+ }),
438
+ headers: h,
439
+ ));
440
  return _decodeList(resp).map(ConceptEntry.fromJson).toList();
441
  }
442
 
443
  Future<ConceptDetail> getConcept(String conceptId) async {
444
+ final resp = await _send((h) => _client.get(_uri('/concepts/$conceptId'), headers: h));
445
  return ConceptDetail.fromJson(_decodeMap(resp));
446
  }
447
 
448
  Future<ConceptEntry> defineConcept(String conceptId) async {
449
+ final resp = await _send((h) => _client.post(_uri('/concepts/$conceptId/define'), headers: h));
450
  return ConceptEntry.fromJson(_decodeMap(resp));
451
  }
452
 
453
  Future<void> deleteConcept(String conceptId) async {
454
+ final resp = await _send((h) => _client.delete(_uri('/concepts/$conceptId'), headers: h));
455
  if (resp.statusCode >= 400) throw ApiException(resp.statusCode, resp.body);
456
  }
457
 
 
460
  // ------------------------------------------------------------------------- //
461
 
462
  Future<GraphData> graph({double threshold = 0.55, int topK = 4}) async {
463
+ final resp = await _send((h) =>
464
+ _client.get(_uri('/graph', {'threshold': threshold, 'top_k': topK}), headers: h));
 
 
465
  return GraphData.fromJson(_decodeMap(resp));
466
  }
467
 
 
473
  /// Returns the reply plus the cards it was grounded on.
474
  Future<LibraryChatResult> libraryChat(
475
  List<Map<String, String>> messages) async {
476
+ final resp = await _send(
477
+ (h) => _client.post(_uri('/library/chat'),
478
+ headers: h, body: jsonEncode({'messages': messages})),
479
+ extra: const {'content-type': 'application/json'},
480
  );
481
  final json = _decodeMap(resp);
482
  final sources = ((json['sources'] as List?) ?? const [])
 
495
  /// Restore this owner's saved library chat (docs/14) as [{'role','content'}]
496
  /// maps, oldest → newest. Empty when none saved.
497
  Future<List<Map<String, String>>> libraryChatHistory() async {
498
+ final resp = await _send((h) => _client.get(_uri('/library/chat'), headers: h));
499
  return _decodeMessages(_decodeMap(resp)['messages']);
500
  }
501
 
 
506
  /// The reel-style knowledge feed: a shuffled stream of moments built from the
507
  /// owner's cards. Owner-scoped.
508
  Future<List<FeedItem>> feed({int limit = 40}) async {
509
+ final resp =
510
+ await _send((h) => _client.get(_uri('/feed', {'limit': limit}), headers: h));
 
 
511
  return ((_decodeMap(resp)['items'] as List?) ?? const [])
512
  .whereType<Map<String, dynamic>>()
513
  .map(FeedItem.fromJson)
 
517
  /// Surprising links between the owner's cards. [refresh] spends a little more
518
  /// LLM budget to surface fresh connections.
519
  Future<List<Connection>> connections({int limit = 12, bool refresh = false}) async {
520
+ final resp = await _send((h) => _client.get(
521
+ _uri('/connections', {'limit': limit, if (refresh) 'refresh': true}),
522
+ headers: h,
523
+ ));
524
  return ((_decodeMap(resp)['connections'] as List?) ?? const [])
525
  .whereType<Map<String, dynamic>>()
526
  .map(Connection.fromJson)
 
537
  /// subscription to disconnect early.
538
  Stream<PipelineEvent> streamCard(String cardId) async* {
539
  final request = http.Request('GET', _uri('/cards/$cardId/stream'))
540
+ ..headers['accept'] = 'text/event-stream'
541
+ ..headers.addAll(await _authHeader());
542
  final response = await _client.send(request);
543
  if (response.statusCode >= 400) {
544
  throw ApiException(response.statusCode, 'stream failed');
app/test/api_client_auth_test.dart ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import 'dart:convert';
2
+
3
+ import 'package:flutter_test/flutter_test.dart';
4
+ import 'package:http/http.dart' as http;
5
+ import 'package:http/testing.dart';
6
+ import 'package:cachy/data/services/api_client.dart';
7
+
8
+ void main() {
9
+ test('attaches bearer token to requests', () async {
10
+ String? seenAuth;
11
+ final mock = MockClient((req) async {
12
+ seenAuth = req.headers['authorization'];
13
+ return http.Response(jsonEncode([]), 200);
14
+ });
15
+ final api = ApiClient(
16
+ baseUrl: 'http://x',
17
+ client: mock,
18
+ tokenProvider: ({bool forceRefresh = false}) async => 'tok-1',
19
+ );
20
+ await api.listCards();
21
+ expect(seenAuth, 'Bearer tok-1');
22
+ });
23
+
24
+ test('one forced-refresh retry on 401', () async {
25
+ var calls = 0;
26
+ final mock = MockClient((req) async {
27
+ calls++;
28
+ if (req.headers['authorization'] == 'Bearer stale') {
29
+ return http.Response('unauthorized', 401);
30
+ }
31
+ return http.Response(jsonEncode([]), 200);
32
+ });
33
+ var fresh = false;
34
+ final api = ApiClient(
35
+ baseUrl: 'http://x',
36
+ client: mock,
37
+ tokenProvider: ({bool forceRefresh = false}) async {
38
+ if (forceRefresh) fresh = true;
39
+ return fresh ? 'fresh' : 'stale';
40
+ },
41
+ );
42
+ final cards = await api.listCards();
43
+ expect(cards, isEmpty);
44
+ expect(calls, 2); // 401 then success — exactly one retry
45
+ });
46
+ }