Vatxzz commited on
Commit
fb4cdd5
·
1 Parent(s): 3026945

increased security

Browse files
app/lib/data/repositories/card_repository.dart CHANGED
@@ -28,6 +28,14 @@ class CardRepository extends ChangeNotifier {
28
 
29
  ApiClient get api => _api;
30
 
 
 
 
 
 
 
 
 
31
  void updateBaseUrl(String url) {
32
  _api.setBaseUrl(url);
33
  notifyListeners();
@@ -41,9 +49,9 @@ class CardRepository extends ChangeNotifier {
41
 
42
  /// Submit a shared URL. On network failure the URL is queued locally and the
43
  /// caller is told it is pending (offline share queue, docs/06).
44
- Future<CreateCardResult> share(String url) async {
45
  try {
46
- final result = await _api.createCard(url);
47
  await flushPendingShares();
48
  notifyListeners();
49
  return result;
 
28
 
29
  ApiClient get api => _api;
30
 
31
+ /// Developer toggle (persisted): route new cards through the on-device model
32
+ /// instead of the server LLM. Callers still gate on the model being ready.
33
+ bool get preferLocalModel => _store.preferLocalModel;
34
+ Future<void> setPreferLocalModel(bool value) async {
35
+ await _store.setPreferLocalModel(value);
36
+ notifyListeners();
37
+ }
38
+
39
  void updateBaseUrl(String url) {
40
  _api.setBaseUrl(url);
41
  notifyListeners();
 
49
 
50
  /// Submit a shared URL. On network failure the URL is queued locally and the
51
  /// caller is told it is pending (offline share queue, docs/06).
52
+ Future<CreateCardResult> share(String url, {bool preferLocal = false}) async {
53
  try {
54
+ final result = await _api.createCard(url, preferLocal: preferLocal);
55
  await flushPendingShares();
56
  notifyListeners();
57
  return result;
app/lib/data/services/api_client.dart CHANGED
@@ -233,13 +233,31 @@ class ApiClient {
233
  return (_decodeMap(resp)['claimed'] as num?)?.toInt() ?? 0;
234
  }
235
 
 
 
 
 
 
 
 
 
 
 
 
 
 
236
  // ------------------------------------------------------------------------- //
237
  // Cards
238
  // ------------------------------------------------------------------------- //
239
 
240
- Future<CreateCardResult> createCard(String url) async {
241
  final resp = await _send(
242
- (h) => _client.post(_uri('/cards'), headers: h, body: jsonEncode({'url': url})),
 
 
 
 
 
243
  extra: const {'content-type': 'application/json'},
244
  );
245
  final json = _decodeMap(resp);
 
233
  return (_decodeMap(resp)['claimed'] as num?)?.toInt() ?? 0;
234
  }
235
 
236
+ /// Fold a guest (anonymous) account's data into the caller's account. The
237
+ /// request is authenticated as the destination (Google) uid; [guestToken] is
238
+ /// the guest's own ID token, captured before switching identities, and proves
239
+ /// ownership of the source. Returns the number of rows moved.
240
+ Future<int> mergeGuestLibrary(String guestToken) async {
241
+ final resp = await _send(
242
+ (h) => _client.post(_uri('/auth/merge'),
243
+ headers: h, body: jsonEncode({'guest_token': guestToken})),
244
+ extra: const {'content-type': 'application/json'},
245
+ );
246
+ return (_decodeMap(resp)['merged'] as num?)?.toInt() ?? 0;
247
+ }
248
+
249
  // ------------------------------------------------------------------------- //
250
  // Cards
251
  // ------------------------------------------------------------------------- //
252
 
253
+ Future<CreateCardResult> createCard(String url, {bool preferLocal = false}) async {
254
  final resp = await _send(
255
+ (h) => _client.post(_uri('/cards'),
256
+ headers: h,
257
+ body: jsonEncode({
258
+ 'url': url,
259
+ if (preferLocal) 'prefer_local': true,
260
+ })),
261
  extra: const {'content-type': 'application/json'},
262
  );
263
  final json = _decodeMap(resp);
app/lib/data/services/auth_service.dart CHANGED
@@ -24,7 +24,16 @@ abstract class AuthService {
24
  Stream<AuthUser?> get userChanges;
25
  AuthUser? get currentUser;
26
  Future<AuthUser> signInAnonymously();
27
- Future<AuthUser> signInWithGoogle();
 
 
 
 
 
 
 
 
 
28
  Future<String?> idToken({bool forceRefresh = false});
29
  Future<void> signOut();
30
  }
@@ -60,7 +69,9 @@ class FirebaseAuthService implements AuthService {
60
  }
61
 
62
  @override
63
- Future<AuthUser> signInWithGoogle() async {
 
 
64
  final account = await _google.signIn();
65
  if (account == null) throw fb.FirebaseAuthException(code: 'canceled');
66
  final gAuth = await account.authentication;
@@ -75,8 +86,17 @@ class FirebaseAuthService implements AuthService {
75
  cred = await current.linkWithCredential(credential); // uid preserved
76
  } on fb.FirebaseAuthException catch (e) {
77
  if (e.code != 'credential-already-in-use') rethrow;
78
- // Google account already has a Cachy identity — switch to it.
 
 
 
79
  cred = await _auth.signInWithCredential(credential);
 
 
 
 
 
 
80
  }
81
  } else {
82
  cred = await _auth.signInWithCredential(credential);
 
24
  Stream<AuthUser?> get userChanges;
25
  AuthUser? get currentUser;
26
  Future<AuthUser> signInAnonymously();
27
+
28
+ /// Sign in with Google. When the current session is an anonymous guest, the
29
+ /// uid is linked (data stays put). If that Google account already has its own
30
+ /// Cachy identity, linking is impossible, so we switch to it and — if
31
+ /// [mergeGuestData] is provided — hand back the guest's ID token so the
32
+ /// caller can fold the guest's server-side data into the account.
33
+ Future<AuthUser> signInWithGoogle({
34
+ Future<void> Function(String guestIdToken)? mergeGuestData,
35
+ });
36
+
37
  Future<String?> idToken({bool forceRefresh = false});
38
  Future<void> signOut();
39
  }
 
69
  }
70
 
71
  @override
72
+ Future<AuthUser> signInWithGoogle({
73
+ Future<void> Function(String guestIdToken)? mergeGuestData,
74
+ }) async {
75
  final account = await _google.signIn();
76
  if (account == null) throw fb.FirebaseAuthException(code: 'canceled');
77
  final gAuth = await account.authentication;
 
86
  cred = await current.linkWithCredential(credential); // uid preserved
87
  } on fb.FirebaseAuthException catch (e) {
88
  if (e.code != 'credential-already-in-use') rethrow;
89
+ // Google account already has a Cachy identity — linking is impossible.
90
+ // Grab the guest's token before switching so its data can be merged,
91
+ // then sign into the existing account.
92
+ final guestToken = await current.getIdToken();
93
  cred = await _auth.signInWithCredential(credential);
94
+ if (guestToken != null && guestToken.isNotEmpty && mergeGuestData != null) {
95
+ // Best-effort: a failed merge must not block sign-in.
96
+ try {
97
+ await mergeGuestData(guestToken);
98
+ } catch (_) {}
99
+ }
100
  }
101
  } else {
102
  cred = await _auth.signInWithCredential(credential);
app/lib/data/services/local_ai/local_ai_service.dart CHANGED
@@ -60,9 +60,20 @@ abstract class LocalAiService extends ChangeNotifier {
60
  });
61
  }
62
 
 
 
 
 
 
 
 
63
  /// Prompt tuned for 1B models: short instruction, one few-shot example,
64
  /// strict JSON-only suffix.
65
- String buildStructurePrompt(String bundle) => '''
 
 
 
 
66
  You turn a video's raw text into one JSON knowledge card. Reply with JSON only, no prose, no markdown fences.
67
 
68
  Schema:
@@ -78,6 +89,7 @@ Input:
78
  $bundle
79
 
80
  Output (JSON only):''';
 
81
 
82
  /// Parse + minimally validate model output into card JSON.
83
  ///
 
60
  });
61
  }
62
 
63
+ /// Char cap on the bundle fed to the on-device model. The Gemma session runs
64
+ /// with maxTokens: 2048 covering prompt + output; at ~4 chars/token the fixed
65
+ /// scaffold plus a full JSON card leaves room for roughly this much input. A fat
66
+ /// bundle (long transcript) would otherwise overflow the window and the model
67
+ /// throws — silently keeping the paragraph card it was meant to upgrade.
68
+ const kLocalAiBundleCharCap = 4000;
69
+
70
  /// Prompt tuned for 1B models: short instruction, one few-shot example,
71
  /// strict JSON-only suffix.
72
+ String buildStructurePrompt(String bundle) {
73
+ if (bundle.length > kLocalAiBundleCharCap) {
74
+ bundle = bundle.substring(0, kLocalAiBundleCharCap);
75
+ }
76
+ return '''
77
  You turn a video's raw text into one JSON knowledge card. Reply with JSON only, no prose, no markdown fences.
78
 
79
  Schema:
 
89
  $bundle
90
 
91
  Output (JSON only):''';
92
+ }
93
 
94
  /// Parse + minimally validate model output into card JSON.
95
  ///
app/lib/data/services/local_store.dart CHANGED
@@ -21,6 +21,7 @@ class LocalStore {
21
  static const _apiBaseUrlKey = 'api_base_url';
22
  static const _splitPaneFractionKey = 'split_pane_fraction';
23
  static const _localAiEnabledKey = 'local_ai_enabled';
 
24
 
25
  static Future<LocalStore> open() async =>
26
  LocalStore(await SharedPreferences.getInstance());
@@ -47,8 +48,16 @@ class LocalStore {
47
  bool get localAiEnabled => _prefs.getBool(_localAiEnabledKey) ?? false;
48
  Future<void> setLocalAiEnabled(bool v) => _prefs.setBool(_localAiEnabledKey, v);
49
 
 
 
 
 
 
 
50
  /// Clears user identity and onboarding state, effectively signing the user
51
- /// out and forcing them back through the name-entry gate on next launch.
 
 
52
  Future<void> clearUser() async {
53
  await _prefs.remove(_userNameKey);
54
  await _prefs.remove(_seenOnboardingKey);
 
21
  static const _apiBaseUrlKey = 'api_base_url';
22
  static const _splitPaneFractionKey = 'split_pane_fraction';
23
  static const _localAiEnabledKey = 'local_ai_enabled';
24
+ static const _preferLocalModelKey = 'prefer_local_model';
25
 
26
  static Future<LocalStore> open() async =>
27
  LocalStore(await SharedPreferences.getInstance());
 
48
  bool get localAiEnabled => _prefs.getBool(_localAiEnabledKey) ?? false;
49
  Future<void> setLocalAiEnabled(bool v) => _prefs.setBool(_localAiEnabledKey, v);
50
 
51
+ /// Developer toggle: route new cards through the on-device model instead of
52
+ /// the server LLM (backend skips structuring, phone structures the bundle).
53
+ bool get preferLocalModel => _prefs.getBool(_preferLocalModelKey) ?? false;
54
+ Future<void> setPreferLocalModel(bool v) =>
55
+ _prefs.setBool(_preferLocalModelKey, v);
56
+
57
  /// Clears user identity and onboarding state, effectively signing the user
58
+ /// out and forcing them back through onboarding + the login gate on next
59
+ /// launch. The legacy name key is cleared too (it only lingers for users
60
+ /// upgrading from the old name-based build).
61
  Future<void> clearUser() async {
62
  await _prefs.remove(_userNameKey);
63
  await _prefs.remove(_seenOnboardingKey);
app/lib/ui/core/app_controller.dart CHANGED
@@ -31,11 +31,15 @@ class AppController extends ChangeNotifier {
31
  /// signed out. Drives the login gate and the profile account section.
32
  AuthUser? get authUser => _authUser;
33
 
34
- /// True once the user has cleared onboarding + name but has no Firebase
35
- /// identity yet — the one moment [RootGate] shows the login screen.
36
- bool get needsLogin => seenOnboarding && hasUserName && _authUser == null;
37
-
38
- Future<void> signInWithGoogle() => _auth.signInWithGoogle();
 
 
 
 
39
  Future<void> continueAnonymously() => _auth.signInAnonymously();
40
 
41
  ThemeMode _themeMode;
 
31
  /// signed out. Drives the login gate and the profile account section.
32
  AuthUser? get authUser => _authUser;
33
 
34
+ /// True once the user has cleared onboarding but has no Firebase identity
35
+ /// yet — the one moment [RootGate] shows the login screen. Identity is now
36
+ /// the Firebase uid (Google or anonymous); no name is collected up front.
37
+ bool get needsLogin => seenOnboarding && _authUser == null;
38
+
39
+ Future<void> signInWithGoogle({
40
+ Future<void> Function(String guestIdToken)? mergeGuestData,
41
+ }) =>
42
+ _auth.signInWithGoogle(mergeGuestData: mergeGuestData);
43
  Future<void> continueAnonymously() => _auth.signInAnonymously();
44
 
45
  ThemeMode _themeMode;
app/lib/ui/core/root_gate.dart CHANGED
@@ -9,13 +9,12 @@ import 'package:provider/provider.dart';
9
  import '../../data/repositories/card_repository.dart';
10
  import '../../data/services/auth_service.dart';
11
  import '../features/onboarding/views/login_screen.dart';
12
- import '../features/onboarding/views/name_screen.dart';
13
  import '../features/onboarding/views/onboarding_screen.dart';
14
  import '../features/onboarding/views/splash_screen.dart';
15
  import 'app_controller.dart';
16
  import 'home_shell.dart';
17
 
18
- enum _Phase { splash, onboarding, nameEntry, login, shell }
19
 
20
  class RootGate extends StatefulWidget {
21
  const RootGate({super.key});
@@ -29,7 +28,6 @@ class _RootGateState extends State<RootGate> {
29
 
30
  _Phase _afterSplash(AppController app) {
31
  if (!app.seenOnboarding) return _Phase.onboarding;
32
- if (!app.hasUserName) return _Phase.nameEntry;
33
  if (app.needsLogin) return _Phase.login;
34
  return _Phase.shell;
35
  }
@@ -48,14 +46,6 @@ class _RootGateState extends State<RootGate> {
48
 
49
  Future<void> _finishOnboarding() async {
50
  await context.read<AppController>().completeOnboarding();
51
- if (!mounted) return;
52
- final app = context.read<AppController>();
53
- setState(() => _phase = app.hasUserName
54
- ? (app.needsLogin ? _Phase.login : _Phase.shell)
55
- : _Phase.nameEntry);
56
- }
57
-
58
- void _finishNameEntry() {
59
  if (!mounted) return;
60
  final app = context.read<AppController>();
61
  setState(() => _phase = app.needsLogin ? _Phase.login : _Phase.shell);
@@ -90,8 +80,6 @@ class _RootGateState extends State<RootGate> {
90
  SplashScreen(key: const ValueKey('splash'), onDone: _finishSplash),
91
  _Phase.onboarding =>
92
  OnboardingScreen(key: const ValueKey('onboarding'), onDone: _finishOnboarding),
93
- _Phase.nameEntry =>
94
- NameScreen(key: const ValueKey('nameEntry'), onDone: _finishNameEntry),
95
  _Phase.login =>
96
  LoginScreen(key: const ValueKey('login'), onDone: _finishLogin),
97
  _Phase.shell => const HomeShell(key: ValueKey('shell')),
 
9
  import '../../data/repositories/card_repository.dart';
10
  import '../../data/services/auth_service.dart';
11
  import '../features/onboarding/views/login_screen.dart';
 
12
  import '../features/onboarding/views/onboarding_screen.dart';
13
  import '../features/onboarding/views/splash_screen.dart';
14
  import 'app_controller.dart';
15
  import 'home_shell.dart';
16
 
17
+ enum _Phase { splash, onboarding, login, shell }
18
 
19
  class RootGate extends StatefulWidget {
20
  const RootGate({super.key});
 
28
 
29
  _Phase _afterSplash(AppController app) {
30
  if (!app.seenOnboarding) return _Phase.onboarding;
 
31
  if (app.needsLogin) return _Phase.login;
32
  return _Phase.shell;
33
  }
 
46
 
47
  Future<void> _finishOnboarding() async {
48
  await context.read<AppController>().completeOnboarding();
 
 
 
 
 
 
 
 
49
  if (!mounted) return;
50
  final app = context.read<AppController>();
51
  setState(() => _phase = app.needsLogin ? _Phase.login : _Phase.shell);
 
80
  SplashScreen(key: const ValueKey('splash'), onDone: _finishSplash),
81
  _Phase.onboarding =>
82
  OnboardingScreen(key: const ValueKey('onboarding'), onDone: _finishOnboarding),
 
 
83
  _Phase.login =>
84
  LoginScreen(key: const ValueKey('login'), onDone: _finishLogin),
85
  _Phase.shell => const HomeShell(key: ValueKey('shell')),
app/lib/ui/features/onboarding/views/login_screen.dart CHANGED
@@ -1,11 +1,13 @@
1
- /// Login gate, shown after onboarding + name. Google is primary; anonymous is
2
- /// a quiet escape hatch with an honest data-loss caveat.
 
3
  library;
4
 
5
  import 'package:flutter/material.dart';
6
  import 'package:phosphor_flutter/phosphor_flutter.dart';
7
  import 'package:provider/provider.dart';
8
 
 
9
  import '../../../../data/services/auth_service.dart';
10
  import '../../../core/brand.dart';
11
  import '../../../core/widgets/responsive_center.dart';
@@ -85,7 +87,10 @@ class _LoginScreenState extends State<LoginScreen> {
85
  onPressed: _busy
86
  ? null
87
  : () => _run(() async {
88
- await auth.signInWithGoogle();
 
 
 
89
  }),
90
  icon: const PhosphorIcon(PhosphorIconsRegular.googleLogo,
91
  size: 20),
 
1
+ /// Login gate, shown right after onboarding. Google is primary; "use without
2
+ /// login" runs anonymous auth and can be upgraded to Google later (the uid is
3
+ /// preserved via account linking, so guest data carries over).
4
  library;
5
 
6
  import 'package:flutter/material.dart';
7
  import 'package:phosphor_flutter/phosphor_flutter.dart';
8
  import 'package:provider/provider.dart';
9
 
10
+ import '../../../../data/repositories/card_repository.dart';
11
  import '../../../../data/services/auth_service.dart';
12
  import '../../../core/brand.dart';
13
  import '../../../core/widgets/responsive_center.dart';
 
87
  onPressed: _busy
88
  ? null
89
  : () => _run(() async {
90
+ final api = context.read<CardRepository>().api;
91
+ await auth.signInWithGoogle(
92
+ mergeGuestData: api.mergeGuestLibrary,
93
+ );
94
  }),
95
  icon: const PhosphorIcon(PhosphorIconsRegular.googleLogo,
96
  size: 20),
app/lib/ui/features/onboarding/views/name_screen.dart DELETED
@@ -1,192 +0,0 @@
1
- /// Name entry gate — shown once on first launch so the backend can isolate
2
- /// each user's cards. The entered name becomes the X-Owner-Id header on every
3
- /// API request (stored in LocalStore, read by ApiClient).
4
- library;
5
-
6
- import 'package:flutter/material.dart';
7
- import 'package:phosphor_flutter/phosphor_flutter.dart';
8
- import 'package:provider/provider.dart';
9
-
10
- import '../../../core/app_controller.dart';
11
- import '../../../core/brand.dart';
12
- import '../../../core/widgets/responsive_center.dart';
13
-
14
- class NameScreen extends StatefulWidget {
15
- const NameScreen({super.key, required this.onDone});
16
- final VoidCallback onDone;
17
-
18
- @override
19
- State<NameScreen> createState() => _NameScreenState();
20
- }
21
-
22
- class _NameScreenState extends State<NameScreen> {
23
- final _controller = TextEditingController();
24
- bool _submitting = false;
25
-
26
- @override
27
- void dispose() {
28
- _controller.dispose();
29
- super.dispose();
30
- }
31
-
32
- Future<void> _submit() async {
33
- final name = _controller.text.trim();
34
- if (name.isEmpty) return;
35
- setState(() => _submitting = true);
36
- await context.read<AppController>().setUserName(name);
37
- if (mounted) widget.onDone();
38
- }
39
-
40
- @override
41
- Widget build(BuildContext context) {
42
- final theme = Theme.of(context);
43
- final scheme = theme.colorScheme;
44
-
45
- return Scaffold(
46
- backgroundColor: scheme.surface,
47
- body: Container(
48
- decoration: BoxDecoration(
49
- gradient: RadialGradient(
50
- center: const Alignment(0, -0.45),
51
- radius: 1.3,
52
- colors: [
53
- scheme.primary.withValues(alpha: 0.12),
54
- Colors.transparent,
55
- ],
56
- ),
57
- ),
58
- child: SafeArea(
59
- child: ResponsiveCenter(
60
- child: Padding(
61
- padding: const EdgeInsets.symmetric(horizontal: 28),
62
- child: Column(
63
- crossAxisAlignment: CrossAxisAlignment.start,
64
- children: [
65
- const SizedBox(height: 48),
66
- Container(
67
- width: 56,
68
- height: 56,
69
- decoration: BoxDecoration(
70
- color: scheme.primary,
71
- borderRadius: BorderRadius.circular(16),
72
- boxShadow: [
73
- BoxShadow(
74
- color: scheme.primary.withValues(alpha: 0.4),
75
- blurRadius: 16,
76
- offset: const Offset(0, 4),
77
- ),
78
- ],
79
- ),
80
- child: PhosphorIcon(
81
- PhosphorIconsRegular.user,
82
- color: scheme.onPrimary,
83
- size: 28,
84
- ),
85
- ),
86
- const SizedBox(height: 32),
87
- RichText(
88
- text: TextSpan(
89
- style: theme.textTheme.displaySmall
90
- ?.copyWith(color: scheme.onSurface),
91
- children: [
92
- const TextSpan(text: "What's\nyour "),
93
- TextSpan(
94
- text: 'name?',
95
- style: TextStyle(color: scheme.primary),
96
- ),
97
- ],
98
- ),
99
- ),
100
- const SizedBox(height: 16),
101
- Text(
102
- 'Your library stays private. Only you see your cards.',
103
- style: theme.textTheme.bodyLarge?.copyWith(
104
- color: scheme.onSurfaceVariant,
105
- height: 1.5,
106
- ),
107
- ),
108
- const SizedBox(height: 40),
109
- TextField(
110
- controller: _controller,
111
- autofocus: true,
112
- textCapitalization: TextCapitalization.words,
113
- textInputAction: TextInputAction.done,
114
- onSubmitted: (_) => _submit(),
115
- style: theme.textTheme.titleLarge?.copyWith(
116
- fontWeight: FontWeight.w600,
117
- ),
118
- decoration: InputDecoration(
119
- hintText: 'Your name',
120
- filled: true,
121
- fillColor: scheme.surfaceContainerLow,
122
- border: OutlineInputBorder(
123
- borderRadius: BorderRadius.circular(16),
124
- borderSide: BorderSide(color: scheme.outlineVariant),
125
- ),
126
- enabledBorder: OutlineInputBorder(
127
- borderRadius: BorderRadius.circular(16),
128
- borderSide: BorderSide(color: scheme.outlineVariant),
129
- ),
130
- focusedBorder: OutlineInputBorder(
131
- borderRadius: BorderRadius.circular(16),
132
- borderSide: BorderSide(color: scheme.primary, width: 2),
133
- ),
134
- contentPadding: const EdgeInsets.symmetric(
135
- horizontal: 20,
136
- vertical: 18,
137
- ),
138
- ),
139
- ),
140
- const Spacer(),
141
- ListenableBuilder(
142
- listenable: _controller,
143
- builder: (context, _) {
144
- final ready = _controller.text.trim().isNotEmpty;
145
- return FilledButton(
146
- onPressed: (ready && !_submitting) ? _submit : null,
147
- style: FilledButton.styleFrom(
148
- minimumSize: const Size.fromHeight(56),
149
- shape: RoundedRectangleBorder(
150
- borderRadius: BorderRadius.circular(16),
151
- ),
152
- ),
153
- child: _submitting
154
- ? const SizedBox(
155
- width: 22,
156
- height: 22,
157
- child: CircularProgressIndicator(
158
- strokeWidth: 2.5,
159
- ),
160
- )
161
- : Row(
162
- mainAxisAlignment: MainAxisAlignment.center,
163
- children: [
164
- Text(
165
- 'Enter Cachy',
166
- style: Brand.label(
167
- size: 16,
168
- color: scheme.onPrimary,
169
- weight: FontWeight.w700,
170
- ),
171
- ),
172
- const SizedBox(width: 8),
173
- PhosphorIcon(
174
- PhosphorIconsRegular.arrowRight,
175
- size: 18,
176
- color: scheme.onPrimary,
177
- ),
178
- ],
179
- ),
180
- );
181
- },
182
- ),
183
- const SizedBox(height: 32),
184
- ],
185
- ),
186
- ),
187
- ),
188
- ),
189
- ),
190
- );
191
- }
192
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/lib/ui/features/profile/views/profile_screen.dart CHANGED
@@ -326,14 +326,12 @@ class _ProfileScreenState extends State<ProfileScreen> {
326
  _accountRow(theme, user)
327
  else
328
  _backupBanner(theme),
329
- if (signedIn &&
330
- (context.read<AppController>().userName ?? '').isNotEmpty)
331
  _Tile(
332
  icon: PhosphorIconsRegular.clockCounterClockwise,
333
  title: 'Restore old library',
334
- subtitle: 'Bring in cards saved under your name before sign-in.',
335
- onTap: () =>
336
- _offerClaim(context.read<AppController>().userName!),
337
  ),
338
  _Tile(
339
  icon: PhosphorIconsRegular.signOut,
@@ -434,9 +432,10 @@ class _ProfileScreenState extends State<ProfileScreen> {
434
  Future<void> _signInAndMaybeClaim() async {
435
  setState(() => _signingIn = true);
436
  final app = context.read<AppController>();
 
437
  final messenger = ScaffoldMessenger.of(context);
438
  try {
439
- await app.signInWithGoogle();
440
  } catch (_) {
441
  messenger.showSnackBar(
442
  const SnackBar(content: Text("Couldn't sign in. Try again.")));
@@ -466,6 +465,54 @@ class _ProfileScreenState extends State<ProfileScreen> {
466
  ),
467
  );
468
  if (restore != true || !mounted) return;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
469
  final api = context.read<CardRepository>().api;
470
  final messenger = ScaffoldMessenger.of(context);
471
  try {
@@ -755,6 +802,10 @@ class _DeveloperScreenState extends State<_DeveloperScreen> {
755
  subtitle: 'Clear the override and reconnect to the hosted Space.',
756
  onTap: () => _setUrl(repo, ApiClient.defaultBaseUrl),
757
  ),
 
 
 
 
758
  const SizedBox(height: 12),
759
  Text(
760
  'Changes take effect immediately and persist across launches.',
@@ -766,6 +817,56 @@ class _DeveloperScreenState extends State<_DeveloperScreen> {
766
  );
767
  }
768
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
769
  void _setUrl(CardRepository repo, String url) {
770
  repo.updateBaseUrl(url);
771
  if (!mounted) return;
 
326
  _accountRow(theme, user)
327
  else
328
  _backupBanner(theme),
329
+ if (signedIn)
 
330
  _Tile(
331
  icon: PhosphorIconsRegular.clockCounterClockwise,
332
  title: 'Restore old library',
333
+ subtitle: 'Used Cachy before with a name? Bring those cards in.',
334
+ onTap: _promptRestoreByName,
 
335
  ),
336
  _Tile(
337
  icon: PhosphorIconsRegular.signOut,
 
432
  Future<void> _signInAndMaybeClaim() async {
433
  setState(() => _signingIn = true);
434
  final app = context.read<AppController>();
435
+ final api = context.read<CardRepository>().api;
436
  final messenger = ScaffoldMessenger.of(context);
437
  try {
438
+ await app.signInWithGoogle(mergeGuestData: api.mergeGuestLibrary);
439
  } catch (_) {
440
  messenger.showSnackBar(
441
  const SnackBar(content: Text("Couldn't sign in. Try again.")));
 
465
  ),
466
  );
467
  if (restore != true || !mounted) return;
468
+ await _runClaim(name);
469
+ }
470
+
471
+ /// Legacy rescue for the old name-keyed build: the user types the name they
472
+ /// saved under and those rows re-point onto this account. First claim wins.
473
+ Future<void> _promptRestoreByName() async {
474
+ final controller = TextEditingController();
475
+ final name = await showDialog<String>(
476
+ context: context,
477
+ builder: (ctx) => AlertDialog(
478
+ title: const Text('Restore old library'),
479
+ content: Column(
480
+ mainAxisSize: MainAxisSize.min,
481
+ crossAxisAlignment: CrossAxisAlignment.start,
482
+ children: [
483
+ const Text(
484
+ 'Enter the name you used in the old version of Cachy. The cards '
485
+ 'saved under it will move into this account.'),
486
+ const SizedBox(height: 16),
487
+ TextField(
488
+ controller: controller,
489
+ autofocus: true,
490
+ textCapitalization: TextCapitalization.words,
491
+ textInputAction: TextInputAction.done,
492
+ decoration: const InputDecoration(
493
+ hintText: 'Your old name',
494
+ border: OutlineInputBorder(),
495
+ ),
496
+ onSubmitted: (v) => Navigator.pop(ctx, v.trim()),
497
+ ),
498
+ ],
499
+ ),
500
+ actions: [
501
+ TextButton(
502
+ onPressed: () => Navigator.pop(ctx),
503
+ child: const Text('Cancel')),
504
+ FilledButton(
505
+ onPressed: () => Navigator.pop(ctx, controller.text.trim()),
506
+ child: const Text('Restore')),
507
+ ],
508
+ ),
509
+ );
510
+ controller.dispose();
511
+ if (name == null || name.isEmpty || !mounted) return;
512
+ await _runClaim(name);
513
+ }
514
+
515
+ Future<void> _runClaim(String name) async {
516
  final api = context.read<CardRepository>().api;
517
  final messenger = ScaffoldMessenger.of(context);
518
  try {
 
802
  subtitle: 'Clear the override and reconnect to the hosted Space.',
803
  onTap: () => _setUrl(repo, ApiClient.defaultBaseUrl),
804
  ),
805
+ const SizedBox(height: 20),
806
+ Text('AI model', style: theme.textTheme.titleSmall),
807
+ const SizedBox(height: 8),
808
+ _localModelToggle(theme, repo),
809
  const SizedBox(height: 12),
810
  Text(
811
  'Changes take effect immediately and persist across launches.',
 
817
  );
818
  }
819
 
820
+ Widget _localModelToggle(ThemeData theme, CardRepository repo) {
821
+ final scheme = theme.colorScheme;
822
+ LocalAiService? ai;
823
+ try {
824
+ ai = context.watch<LocalAiService>();
825
+ } catch (_) {
826
+ ai = null;
827
+ }
828
+ final ready = ai?.canStructure ?? false;
829
+ final on = repo.preferLocalModel;
830
+ return Material(
831
+ color: scheme.surfaceContainerLow,
832
+ borderRadius: BorderRadius.circular(14),
833
+ child: Column(
834
+ children: [
835
+ SwitchListTile(
836
+ shape:
837
+ RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
838
+ secondary: const PhosphorIcon(PhosphorIconsRegular.cpu),
839
+ title: const Text('Use on-device model'),
840
+ subtitle: const Text(
841
+ 'Structure new cards with the local model instead of the '
842
+ 'server LLM.'),
843
+ value: on,
844
+ onChanged: (v) => repo.setPreferLocalModel(v),
845
+ ),
846
+ if (on && !ready)
847
+ Padding(
848
+ padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
849
+ child: Row(
850
+ children: [
851
+ PhosphorIcon(PhosphorIconsRegular.warning,
852
+ size: 16, color: scheme.error),
853
+ const SizedBox(width: 8),
854
+ Expanded(
855
+ child: Text(
856
+ 'On-device model not ready — download and enable it in '
857
+ 'Settings first. New cards use the server until then.',
858
+ style: theme.textTheme.bodySmall
859
+ ?.copyWith(color: scheme.error),
860
+ ),
861
+ ),
862
+ ],
863
+ ),
864
+ ),
865
+ ],
866
+ ),
867
+ );
868
+ }
869
+
870
  void _setUrl(CardRepository repo, String url) {
871
  repo.updateBaseUrl(url);
872
  if (!mounted) return;
app/lib/ui/features/share/view_models/share_view_model.dart CHANGED
@@ -65,7 +65,12 @@ class ShareViewModel extends ChangeNotifier {
65
  notifyListeners();
66
 
67
  try {
68
- final result = await _repository.share(cleaned);
 
 
 
 
 
69
  _cardId = result.cardId;
70
  _quotaDegraded = result.quotaDegraded;
71
  if (result.cached) {
 
65
  notifyListeners();
66
 
67
  try {
68
+ // Dev toggle: route through the on-device model, but only when it can
69
+ // actually structure — otherwise fall back to the server LLM so we never
70
+ // strand a card as a paragraph with no upgrade path.
71
+ final preferLocal =
72
+ _repository.preferLocalModel && (_localAi?.canStructure ?? false);
73
+ final result = await _repository.share(cleaned, preferLocal: preferLocal);
74
  _cardId = result.cardId;
75
  _quotaDegraded = result.quotaDegraded;
76
  if (result.cached) {
app/test/fakes.dart CHANGED
@@ -21,7 +21,9 @@ class FakeAuthService implements AuthService {
21
  }
22
 
23
  @override
24
- Future<AuthUser> signInWithGoogle() async {
 
 
25
  // Linking keeps the uid when the current user is anonymous.
26
  final uid = _user?.isAnonymous == true ? _user!.uid : 'google-1';
27
  _user = AuthUser(uid: uid, isAnonymous: false, email: 'a@b.c', displayName: 'A');
 
21
  }
22
 
23
  @override
24
+ Future<AuthUser> signInWithGoogle({
25
+ Future<void> Function(String guestIdToken)? mergeGuestData,
26
+ }) async {
27
  // Linking keeps the uid when the current user is anonymous.
28
  final uid = _user?.isAnonymous == true ? _user!.uid : 'google-1';
29
  _user = AuthUser(uid: uid, isAnonymous: false, email: 'a@b.c', displayName: 'A');
backend/app/api/auth_routes.py CHANGED
@@ -5,7 +5,7 @@ from __future__ import annotations
5
  from fastapi import APIRouter, HTTPException
6
  from pydantic import BaseModel
7
 
8
- from app.auth import OwnerDep
9
  from app.store import db
10
 
11
  router = APIRouter(prefix="/auth", tags=["auth"])
@@ -15,6 +15,10 @@ class ClaimRequest(BaseModel):
15
  name: str
16
 
17
 
 
 
 
 
18
  @router.post("/claim")
19
  async def claim(req: ClaimRequest, owner_id: OwnerDep) -> dict:
20
  """Adopt legacy rows keyed by the pre-auth display name. First claim wins."""
@@ -26,3 +30,26 @@ async def claim(req: ClaimRequest, owner_id: OwnerDep) -> dict:
26
  if claimed is None:
27
  raise HTTPException(status_code=409, detail="name already claimed")
28
  return {"claimed": claimed}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  from fastapi import APIRouter, HTTPException
6
  from pydantic import BaseModel
7
 
8
+ from app.auth import OwnerDep, _verify
9
  from app.store import db
10
 
11
  router = APIRouter(prefix="/auth", tags=["auth"])
 
15
  name: str
16
 
17
 
18
+ class MergeRequest(BaseModel):
19
+ guest_token: str
20
+
21
+
22
  @router.post("/claim")
23
  async def claim(req: ClaimRequest, owner_id: OwnerDep) -> dict:
24
  """Adopt legacy rows keyed by the pre-auth display name. First claim wins."""
 
30
  if claimed is None:
31
  raise HTTPException(status_code=409, detail="name already claimed")
32
  return {"claimed": claimed}
33
+
34
+
35
+ @router.post("/merge")
36
+ async def merge(req: MergeRequest, owner_id: OwnerDep) -> dict:
37
+ """Fold a guest (anonymous) account's data into the caller's account.
38
+
39
+ The caller's Bearer token is the destination. `guest_token` is the guest's
40
+ own ID token, captured client-side just before it signed into an existing
41
+ Google identity — it proves ownership of the source account. Source must be
42
+ anonymous so this can't be used to siphon a real account's data."""
43
+ token = req.guest_token.strip()
44
+ if not token:
45
+ raise HTTPException(status_code=422, detail="guest_token required")
46
+ try:
47
+ decoded = _verify(token)
48
+ except Exception:
49
+ raise HTTPException(status_code=401, detail="invalid guest token")
50
+ if decoded.get("firebase", {}).get("sign_in_provider") != "anonymous":
51
+ raise HTTPException(status_code=403, detail="source must be a guest account")
52
+ from_uid = str(decoded["uid"])
53
+ async with db.session() as s:
54
+ moved = await db.merge_owner(s, from_uid=from_uid, to_uid=owner_id)
55
+ return {"merged": moved}
backend/app/api/cards.py CHANGED
@@ -33,6 +33,10 @@ router = APIRouter(prefix="/cards", tags=["cards"])
33
 
34
  class CreateCardRequest(BaseModel):
35
  url: str
 
 
 
 
36
 
37
 
38
  class CreateCardResponse(BaseModel):
@@ -129,6 +133,9 @@ async def create_card(
129
  )
130
 
131
  within = await quota.card_budget(owner_id, request)
 
 
 
132
  card = db.CardRow(
133
  source_url=url,
134
  platform=_platform_for(url),
@@ -138,11 +145,11 @@ async def create_card(
138
  )
139
  session.add(card)
140
  await session.flush() # assign card.id
141
- job = db.JobRow(card_id=card.id, state=JobState.QUEUED.value, degraded=not within)
142
  session.add(job)
143
  await session.commit()
144
  return CreateCardResponse(
145
- card_id=card.id, state=CardState.QUEUED, quota_degraded=not within
146
  )
147
 
148
 
@@ -226,9 +233,9 @@ async def import_cards(
226
 
227
 
228
  @router.get("/{card_id}", response_model=Card)
229
- async def get_card(card_id: str) -> Card:
230
  async with db.session() as session:
231
- row = await db.get_card_row(session, card_id)
232
  if row is None:
233
  raise HTTPException(status_code=404, detail="card not found")
234
  return row.to_card()
@@ -258,9 +265,11 @@ async def list_cards(
258
 
259
 
260
  @router.patch("/{card_id}", response_model=Card)
261
- async def patch_card(card_id: str, req: PatchCardRequest) -> Card:
 
 
262
  async with db.session() as session:
263
- row = await db.get_card_row(session, card_id)
264
  if row is None:
265
  raise HTTPException(status_code=404, detail="card not found")
266
  if req.blocks is not None:
@@ -268,6 +277,9 @@ async def patch_card(card_id: str, req: PatchCardRequest) -> Card:
268
  if req.action_items is not None:
269
  row.action_items = req.action_items # follow toggle + per-item done state
270
  if req.collection_id is not None:
 
 
 
271
  row.collection_id = req.collection_id
272
  await session.commit()
273
  await session.refresh(row)
@@ -289,7 +301,7 @@ async def chat_card(
289
  raise HTTPException(status_code=422, detail="last message must be from user")
290
 
291
  async with db.session() as session:
292
- row = await db.get_card_row(session, card_id)
293
  if row is None:
294
  raise HTTPException(status_code=404, detail="card not found")
295
  if row.state != CardState.READY.value:
@@ -350,7 +362,7 @@ async def explore_rabbithole(
350
  root = (req.root or topic).strip()
351
 
352
  async with db.session() as session:
353
- row = await db.get_card_row(session, card_id)
354
  if row is None:
355
  raise HTTPException(status_code=404, detail="card not found")
356
  if row.state != CardState.READY.value:
@@ -492,9 +504,9 @@ async def upload_structure(card_id: str, payload: dict, owner_id: OwnerDep) -> C
492
 
493
 
494
  @router.delete("/{card_id}")
495
- async def delete_card(card_id: str) -> dict:
496
  async with db.session() as session:
497
- row = await db.get_card_row(session, card_id)
498
  if row is None:
499
  raise HTTPException(status_code=404, detail="card not found")
500
  thumb = row.thumbnail
 
33
 
34
  class CreateCardRequest(BaseModel):
35
  url: str
36
+ # Dev/testing: skip server-side LLM structuring and hand the raw bundle to
37
+ # the owner's device so the on-device model structures the card instead.
38
+ # Reuses the quota-degrade path (paragraph fallback + stored bundle).
39
+ prefer_local: bool = False
40
 
41
 
42
  class CreateCardResponse(BaseModel):
 
133
  )
134
 
135
  within = await quota.card_budget(owner_id, request)
136
+ # Degrade (skip server LLM, keep the bundle for on-device structuring)
137
+ # when past quota OR when the client explicitly prefers the local model.
138
+ degraded = (not within) or req.prefer_local
139
  card = db.CardRow(
140
  source_url=url,
141
  platform=_platform_for(url),
 
145
  )
146
  session.add(card)
147
  await session.flush() # assign card.id
148
+ job = db.JobRow(card_id=card.id, state=JobState.QUEUED.value, degraded=degraded)
149
  session.add(job)
150
  await session.commit()
151
  return CreateCardResponse(
152
+ card_id=card.id, state=CardState.QUEUED, quota_degraded=degraded
153
  )
154
 
155
 
 
233
 
234
 
235
  @router.get("/{card_id}", response_model=Card)
236
+ async def get_card(card_id: str, owner_id: OwnerDep) -> Card:
237
  async with db.session() as session:
238
+ row = await db.get_card_row(session, card_id, owner_id=owner_id)
239
  if row is None:
240
  raise HTTPException(status_code=404, detail="card not found")
241
  return row.to_card()
 
265
 
266
 
267
  @router.patch("/{card_id}", response_model=Card)
268
+ async def patch_card(
269
+ card_id: str, req: PatchCardRequest, owner_id: OwnerDep
270
+ ) -> Card:
271
  async with db.session() as session:
272
+ row = await db.get_card_row(session, card_id, owner_id=owner_id)
273
  if row is None:
274
  raise HTTPException(status_code=404, detail="card not found")
275
  if req.blocks is not None:
 
277
  if req.action_items is not None:
278
  row.action_items = req.action_items # follow toggle + per-item done state
279
  if req.collection_id is not None:
280
+ dest = await db.get_collection_row(session, req.collection_id)
281
+ if dest is None or dest.owner_id != owner_id:
282
+ raise HTTPException(status_code=404, detail="collection not found")
283
  row.collection_id = req.collection_id
284
  await session.commit()
285
  await session.refresh(row)
 
301
  raise HTTPException(status_code=422, detail="last message must be from user")
302
 
303
  async with db.session() as session:
304
+ row = await db.get_card_row(session, card_id, owner_id=owner_id)
305
  if row is None:
306
  raise HTTPException(status_code=404, detail="card not found")
307
  if row.state != CardState.READY.value:
 
362
  root = (req.root or topic).strip()
363
 
364
  async with db.session() as session:
365
+ row = await db.get_card_row(session, card_id, owner_id=owner_id)
366
  if row is None:
367
  raise HTTPException(status_code=404, detail="card not found")
368
  if row.state != CardState.READY.value:
 
504
 
505
 
506
  @router.delete("/{card_id}")
507
+ async def delete_card(card_id: str, owner_id: OwnerDep) -> dict:
508
  async with db.session() as session:
509
+ row = await db.get_card_row(session, card_id, owner_id=owner_id)
510
  if row is None:
511
  raise HTTPException(status_code=404, detail="card not found")
512
  thumb = row.thumbnail
backend/app/api/catalog.py CHANGED
@@ -36,48 +36,64 @@ async def list_catalog(
36
  stmt = select(db.ArtifactRow).order_by(db.ArtifactRow.created_at.desc())
37
  if type is not None:
38
  stmt = stmt.where(db.ArtifactRow.type == type.value)
39
- if card_id is None:
40
- stmt = stmt.where(db.ArtifactRow.saved.is_(True))
41
  rows = (await session.execute(stmt)).scalars().all()
42
- entries = [r.to_entry() for r in rows]
43
  owner_cards = set(
44
  (await session.execute(
45
  select(db.CardRow.id).where(db.CardRow.owner_id == owner_id)
46
  )).scalars().all()
47
  )
48
- entries = [e for e in entries if bool(set(e.source_card_ids) & owner_cards)]
49
- if card_id is not None:
50
- entries = [e for e in entries if card_id in e.source_card_ids]
 
 
 
 
 
 
 
 
 
 
51
  return entries[offset : offset + limit]
52
 
53
 
54
  @router.get("/{artifact_id}", response_model=CatalogDetail)
55
- async def get_catalog_entry(artifact_id: str) -> CatalogDetail:
56
  async with db.session() as session:
57
  row = await session.get(db.ArtifactRow, artifact_id)
58
- if row is None or not row.saved:
 
 
 
 
59
  raise HTTPException(status_code=404, detail="artifact not found")
60
  entry = row.to_entry()
 
61
  return CatalogDetail(entry=entry, source_card_ids=entry.source_card_ids)
62
 
63
 
64
  @router.post("/{artifact_id}/save", response_model=CatalogEntry)
65
- async def save_catalog_entry(artifact_id: str) -> CatalogEntry:
66
  """Add a referenced artifact to the catalog tab (long-press to save)."""
67
  async with db.session() as session:
68
- row = await db.set_artifact_saved(session, artifact_id, True)
69
  if row is None:
70
  raise HTTPException(status_code=404, detail="artifact not found")
71
  invalidate_graph_cache()
72
- return row.to_entry()
 
 
73
 
74
 
75
  @router.post("/{artifact_id}/fetch-info", response_model=CatalogEntry)
76
- async def fetch_catalog_info(artifact_id: str) -> CatalogEntry:
77
  """Generate + persist an LLM detail for the artifact (Fetch info button)."""
78
  async with db.session() as session:
79
  row = await session.get(db.ArtifactRow, artifact_id)
80
- if row is None:
 
 
81
  raise HTTPException(status_code=404, detail="artifact not found")
82
  type_ = ArtifactType(row.type) if row.type else ArtifactType.OTHER
83
  desc = await llm_catalog.describe_async(
@@ -87,16 +103,20 @@ async def fetch_catalog_info(artifact_id: str) -> CatalogEntry:
87
  raise HTTPException(
88
  status_code=502, detail="could not generate details right now"
89
  )
90
- row = await db.set_artifact_description(session, artifact_id, desc)
91
- return row.to_entry()
 
 
 
 
92
 
93
 
94
  @router.delete("/{artifact_id}")
95
- async def delete_catalog_entry(artifact_id: str) -> dict:
96
  """Remove an item from the catalog. Soft by default: the row stays so it still
97
  backs per-card references — it just leaves the catalog tab (saved=False)."""
98
  async with db.session() as session:
99
- row = await db.set_artifact_saved(session, artifact_id, False)
100
  if row is None:
101
  raise HTTPException(status_code=404, detail="artifact not found")
102
  await session.commit()
 
36
  stmt = select(db.ArtifactRow).order_by(db.ArtifactRow.created_at.desc())
37
  if type is not None:
38
  stmt = stmt.where(db.ArtifactRow.type == type.value)
 
 
39
  rows = (await session.execute(stmt)).scalars().all()
 
40
  owner_cards = set(
41
  (await session.execute(
42
  select(db.CardRow.id).where(db.CardRow.owner_id == owner_id)
43
  )).scalars().all()
44
  )
45
+ saved_ids = await db.owner_saved_artifact_ids(session, owner_id)
46
+ entries: list[CatalogEntry] = []
47
+ for r in rows:
48
+ source = set(r.source_card_ids or [])
49
+ if not (source & owner_cards):
50
+ continue # not the caller's artifact
51
+ if card_id is None and r.id not in saved_ids:
52
+ continue # catalog tab shows only what this owner saved
53
+ if card_id is not None and card_id not in source:
54
+ continue # reference view for a specific card
55
+ entry = r.to_entry()
56
+ entry.saved = r.id in saved_ids
57
+ entries.append(entry)
58
  return entries[offset : offset + limit]
59
 
60
 
61
  @router.get("/{artifact_id}", response_model=CatalogDetail)
62
+ async def get_catalog_entry(artifact_id: str, owner_id: OwnerDep) -> CatalogDetail:
63
  async with db.session() as session:
64
  row = await session.get(db.ArtifactRow, artifact_id)
65
+ # Membership implies ownership (it's only created after an ownership check),
66
+ # and the detail view is for catalog items — so gate on the saved membership.
67
+ if row is None or not await db.is_artifact_saved_by(
68
+ session, artifact_id, owner_id
69
+ ):
70
  raise HTTPException(status_code=404, detail="artifact not found")
71
  entry = row.to_entry()
72
+ entry.saved = True
73
  return CatalogDetail(entry=entry, source_card_ids=entry.source_card_ids)
74
 
75
 
76
  @router.post("/{artifact_id}/save", response_model=CatalogEntry)
77
+ async def save_catalog_entry(artifact_id: str, owner_id: OwnerDep) -> CatalogEntry:
78
  """Add a referenced artifact to the catalog tab (long-press to save)."""
79
  async with db.session() as session:
80
+ row = await db.set_artifact_saved(session, artifact_id, True, owner_id=owner_id)
81
  if row is None:
82
  raise HTTPException(status_code=404, detail="artifact not found")
83
  invalidate_graph_cache()
84
+ entry = row.to_entry()
85
+ entry.saved = True
86
+ return entry
87
 
88
 
89
  @router.post("/{artifact_id}/fetch-info", response_model=CatalogEntry)
90
+ async def fetch_catalog_info(artifact_id: str, owner_id: OwnerDep) -> CatalogEntry:
91
  """Generate + persist an LLM detail for the artifact (Fetch info button)."""
92
  async with db.session() as session:
93
  row = await session.get(db.ArtifactRow, artifact_id)
94
+ if row is None or not await db.owner_owns_any_card(
95
+ session, owner_id, row.source_card_ids or []
96
+ ):
97
  raise HTTPException(status_code=404, detail="artifact not found")
98
  type_ = ArtifactType(row.type) if row.type else ArtifactType.OTHER
99
  desc = await llm_catalog.describe_async(
 
103
  raise HTTPException(
104
  status_code=502, detail="could not generate details right now"
105
  )
106
+ row = await db.set_artifact_description(
107
+ session, artifact_id, desc, owner_id=owner_id
108
+ )
109
+ entry = row.to_entry()
110
+ entry.saved = await db.is_artifact_saved_by(session, artifact_id, owner_id)
111
+ return entry
112
 
113
 
114
  @router.delete("/{artifact_id}")
115
+ async def delete_catalog_entry(artifact_id: str, owner_id: OwnerDep) -> dict:
116
  """Remove an item from the catalog. Soft by default: the row stays so it still
117
  backs per-card references — it just leaves the catalog tab (saved=False)."""
118
  async with db.session() as session:
119
+ row = await db.set_artifact_saved(session, artifact_id, False, owner_id=owner_id)
120
  if row is None:
121
  raise HTTPException(status_code=404, detail="artifact not found")
122
  await session.commit()
backend/app/api/collections.py CHANGED
@@ -69,13 +69,13 @@ async def create_collection(
69
 
70
  @router.patch("/{collection_id}", response_model=CollectionOut)
71
  async def rename_collection(
72
- collection_id: str, req: RenameCollectionRequest
73
  ) -> CollectionOut:
74
  name = req.name.strip()
75
  if not name:
76
  raise HTTPException(status_code=422, detail="name is required")
77
  async with db.session() as session:
78
- row = await db.rename_collection(session, collection_id, name)
79
  if row is None:
80
  raise HTTPException(status_code=404, detail="collection not found")
81
  pairs = await db.list_collections(session, owner_id=row.owner_id)
@@ -84,9 +84,9 @@ async def rename_collection(
84
 
85
 
86
  @router.delete("/{collection_id}", response_class=Response, status_code=204)
87
- async def delete_collection(collection_id: str) -> Response:
88
  async with db.session() as session:
89
- ok = await db.delete_collection(session, collection_id)
90
  if not ok:
91
  raise HTTPException(
92
  status_code=404,
@@ -96,9 +96,13 @@ async def delete_collection(collection_id: str) -> Response:
96
 
97
 
98
  @router.post("/cards/{card_id}/move", response_model=dict)
99
- async def move_card(card_id: str, req: MoveCardRequest) -> dict:
 
 
100
  async with db.session() as session:
101
- row = await db.move_card_to_collection(session, card_id, req.collection_id)
 
 
102
  if row is None:
103
  raise HTTPException(status_code=404, detail="card not found")
104
  return {"card_id": card_id, "collection_id": row.collection_id}
 
69
 
70
  @router.patch("/{collection_id}", response_model=CollectionOut)
71
  async def rename_collection(
72
+ collection_id: str, req: RenameCollectionRequest, owner_id: OwnerDep
73
  ) -> CollectionOut:
74
  name = req.name.strip()
75
  if not name:
76
  raise HTTPException(status_code=422, detail="name is required")
77
  async with db.session() as session:
78
+ row = await db.rename_collection(session, collection_id, name, owner_id=owner_id)
79
  if row is None:
80
  raise HTTPException(status_code=404, detail="collection not found")
81
  pairs = await db.list_collections(session, owner_id=row.owner_id)
 
84
 
85
 
86
  @router.delete("/{collection_id}", response_class=Response, status_code=204)
87
+ async def delete_collection(collection_id: str, owner_id: OwnerDep) -> Response:
88
  async with db.session() as session:
89
+ ok = await db.delete_collection(session, collection_id, owner_id=owner_id)
90
  if not ok:
91
  raise HTTPException(
92
  status_code=404,
 
96
 
97
 
98
  @router.post("/cards/{card_id}/move", response_model=dict)
99
+ async def move_card(
100
+ card_id: str, req: MoveCardRequest, owner_id: OwnerDep
101
+ ) -> dict:
102
  async with db.session() as session:
103
+ row = await db.move_card_to_collection(
104
+ session, card_id, req.collection_id, owner_id=owner_id
105
+ )
106
  if row is None:
107
  raise HTTPException(status_code=404, detail="card not found")
108
  return {"card_id": card_id, "collection_id": row.collection_id}
backend/app/api/concepts.py CHANGED
@@ -77,11 +77,13 @@ async def get_concept(
77
 
78
 
79
  @router.post("/{concept_id}/define", response_model=ConceptEntry)
80
- async def define_concept(concept_id: str) -> ConceptEntry:
81
  """Generate + persist an LLM definition for a concept."""
82
  async with db.session() as session:
83
  row = await session.get(db.ConceptRow, concept_id)
84
- if row is None:
 
 
85
  raise HTTPException(status_code=404, detail="concept not found")
86
  definition = await llm_concept.define_async(row.name)
87
  if not definition:
@@ -94,12 +96,10 @@ async def define_concept(concept_id: str) -> ConceptEntry:
94
 
95
 
96
  @router.delete("/{concept_id}")
97
- async def delete_concept(concept_id: str) -> dict:
98
  async with db.session() as session:
99
- row = await session.get(db.ConceptRow, concept_id)
100
- if row is None:
101
  raise HTTPException(status_code=404, detail="concept not found")
102
- await session.delete(row)
103
- await session.commit()
104
  invalidate_graph_cache()
105
  return {"removed": concept_id}
 
77
 
78
 
79
  @router.post("/{concept_id}/define", response_model=ConceptEntry)
80
+ async def define_concept(concept_id: str, owner_id: OwnerDep) -> ConceptEntry:
81
  """Generate + persist an LLM definition for a concept."""
82
  async with db.session() as session:
83
  row = await session.get(db.ConceptRow, concept_id)
84
+ if row is None or not await db.owner_owns_any_card(
85
+ session, owner_id, row.source_card_ids or []
86
+ ):
87
  raise HTTPException(status_code=404, detail="concept not found")
88
  definition = await llm_concept.define_async(row.name)
89
  if not definition:
 
96
 
97
 
98
  @router.delete("/{concept_id}")
99
+ async def delete_concept(concept_id: str, owner_id: OwnerDep) -> dict:
100
  async with db.session() as session:
101
+ ok = await db.remove_owner_from_concept(session, concept_id, owner_id)
102
+ if not ok:
103
  raise HTTPException(status_code=404, detail="concept not found")
 
 
104
  invalidate_graph_cache()
105
  return {"removed": concept_id}
backend/app/api/graph.py CHANGED
@@ -128,9 +128,9 @@ async def _data_fingerprint(owner_id: str | None) -> str:
128
  card_agg = (await session.execute(card_stmt)).one()
129
  art_agg = (
130
  await session.execute(
131
- select(func.count(), func.max(db.ArtifactRow.updated_at)).where(
132
- db.ArtifactRow.saved.is_(True)
133
- )
134
  )
135
  ).one()
136
  col_count = (
@@ -324,15 +324,13 @@ async def get_graph(
324
 
325
  user_card_ids = {r.id for r in card_rows}
326
 
327
- all_artifact_rows = (
328
- await session.execute(
329
- select(db.ArtifactRow).where(db.ArtifactRow.saved.is_(True))
330
- )
331
- ).scalars().all()
332
- artifact_rows = [
333
- a for a in all_artifact_rows
334
- if bool(set(a.source_card_ids or []) & user_card_ids)
335
- ]
336
 
337
  all_concept_rows = (await session.execute(select(db.ConceptRow))).scalars().all()
338
  concept_rows = [
 
128
  card_agg = (await session.execute(card_stmt)).one()
129
  art_agg = (
130
  await session.execute(
131
+ select(
132
+ func.count(), func.max(db.ArtifactSaveRow.created_at)
133
+ ).where(db.ArtifactSaveRow.owner_id == owner_id)
134
  )
135
  ).one()
136
  col_count = (
 
324
 
325
  user_card_ids = {r.id for r in card_rows}
326
 
327
+ saved_ids = await db.owner_saved_artifact_ids(session, owner_id)
328
+ artifact_rows = (
329
+ (await session.execute(
330
+ select(db.ArtifactRow).where(db.ArtifactRow.id.in_(saved_ids))
331
+ )).scalars().all()
332
+ if saved_ids else []
333
+ )
 
 
334
 
335
  all_concept_rows = (await session.execute(select(db.ConceptRow))).scalars().all()
336
  concept_rows = [
backend/app/api/library_chat.py CHANGED
@@ -56,7 +56,7 @@ async def library_chat(
56
  raise HTTPException(status_code=422, detail="last message must be from user")
57
 
58
  history = [m.model_dump() for m in req.messages]
59
- result = await llm_library_chat.answer(history)
60
  if result is None:
61
  raise HTTPException(
62
  status_code=503, detail="chat is unavailable (no LLM backend configured)"
 
56
  raise HTTPException(status_code=422, detail="last message must be from user")
57
 
58
  history = [m.model_dump() for m in req.messages]
59
+ result = await llm_library_chat.answer(history, owner_id)
60
  if result is None:
61
  raise HTTPException(
62
  status_code=503, detail="chat is unavailable (no LLM backend configured)"
backend/app/services/llm_library_chat.py CHANGED
@@ -42,23 +42,26 @@ Use it sparingly. No other markdown (no headers, no links).
42
  --- END CARDS ---"""
43
 
44
 
45
- async def retrieve(question: str, limit: int = _TOP_K) -> list[Card]:
46
- """Top cards for a question: semantic when embeddings are on, else full-text."""
 
47
  if embeddings.embeddings_enabled():
48
- semantic = await _retrieve_semantic(question, limit)
49
  if semantic:
50
  return semantic
51
- return await _retrieve_text(question, limit)
52
 
53
 
54
- async def _retrieve_semantic(question: str, limit: int) -> list[Card]:
55
  query_vec = embeddings.embed(question)
56
  if not query_vec:
57
  return []
58
  async with db.session() as session:
59
  rows = (
60
  await session.execute(
61
- select(db.CardRow).where(db.CardRow.state == CardState.READY.value)
 
 
62
  )
63
  ).scalars().all()
64
  scored = [
@@ -71,13 +74,14 @@ async def _retrieve_semantic(question: str, limit: int) -> list[Card]:
71
  return [r.to_card() for _, r in scored[:limit]]
72
 
73
 
74
- async def _retrieve_text(question: str, limit: int) -> list[Card]:
75
  needle = f"%{question.lower()}%"
76
  async with db.session() as session:
77
  rows = (
78
  await session.execute(
79
  select(db.CardRow)
80
  .where(db.CardRow.state == CardState.READY.value)
 
81
  .where(
82
  or_(
83
  db.CardRow.one_liner.ilike(needle),
@@ -98,6 +102,7 @@ async def _retrieve_text(question: str, limit: int) -> list[Card]:
98
  await session.execute(
99
  select(db.CardRow)
100
  .where(db.CardRow.state == CardState.READY.value)
 
101
  .order_by(db.CardRow.created_at.desc())
102
  .limit(limit)
103
  )
@@ -126,10 +131,12 @@ def _latest_question(history: list[dict]) -> str:
126
  return ""
127
 
128
 
129
- async def answer(history: list[dict]) -> tuple[str, list[Card]] | None:
 
 
130
  """Return (reply, source_cards) or None if no LLM backend is configured.
131
- Retrieval is grounded on the latest user question."""
132
- cards = await retrieve(_latest_question(history))
133
  reply = await _generate(_context(cards), history)
134
  if reply is None:
135
  return None
 
42
  --- END CARDS ---"""
43
 
44
 
45
+ async def retrieve(question: str, owner_id: str, limit: int = _TOP_K) -> list[Card]:
46
+ """Top cards for a question, scoped to this owner: semantic when embeddings
47
+ are on, else full-text."""
48
  if embeddings.embeddings_enabled():
49
+ semantic = await _retrieve_semantic(question, owner_id, limit)
50
  if semantic:
51
  return semantic
52
+ return await _retrieve_text(question, owner_id, limit)
53
 
54
 
55
+ async def _retrieve_semantic(question: str, owner_id: str, limit: int) -> list[Card]:
56
  query_vec = embeddings.embed(question)
57
  if not query_vec:
58
  return []
59
  async with db.session() as session:
60
  rows = (
61
  await session.execute(
62
+ select(db.CardRow)
63
+ .where(db.CardRow.state == CardState.READY.value)
64
+ .where(db.CardRow.owner_id == owner_id)
65
  )
66
  ).scalars().all()
67
  scored = [
 
74
  return [r.to_card() for _, r in scored[:limit]]
75
 
76
 
77
+ async def _retrieve_text(question: str, owner_id: str, limit: int) -> list[Card]:
78
  needle = f"%{question.lower()}%"
79
  async with db.session() as session:
80
  rows = (
81
  await session.execute(
82
  select(db.CardRow)
83
  .where(db.CardRow.state == CardState.READY.value)
84
+ .where(db.CardRow.owner_id == owner_id)
85
  .where(
86
  or_(
87
  db.CardRow.one_liner.ilike(needle),
 
102
  await session.execute(
103
  select(db.CardRow)
104
  .where(db.CardRow.state == CardState.READY.value)
105
+ .where(db.CardRow.owner_id == owner_id)
106
  .order_by(db.CardRow.created_at.desc())
107
  .limit(limit)
108
  )
 
131
  return ""
132
 
133
 
134
+ async def answer(
135
+ history: list[dict], owner_id: str
136
+ ) -> tuple[str, list[Card]] | None:
137
  """Return (reply, source_cards) or None if no LLM backend is configured.
138
+ Retrieval is grounded on the latest user question, scoped to this owner."""
139
+ cards = await retrieve(_latest_question(history), owner_id)
140
  reply = await _generate(_context(cards), history)
141
  if reply is None:
142
  return None
backend/app/store/db.py CHANGED
@@ -16,6 +16,7 @@ from sqlalchemy import (
16
  Integer,
17
  String,
18
  Text,
 
19
  func,
20
  inspect as sa_inspect,
21
  select,
@@ -191,7 +192,9 @@ class ArtifactRow(Base):
191
  year: Mapped[int | None] = mapped_column(Integer, nullable=True)
192
  thumbnail: Mapped[str | None] = mapped_column(Text, nullable=True)
193
  source_card_ids: Mapped[list] = mapped_column(JSON, default=list)
194
- # Only saved rows show in the catalog tab; unsaved rows are card references only.
 
 
195
  saved: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
196
  # On-demand LLM detail ("what is this"), filled via the Fetch info action.
197
  description: Mapped[str | None] = mapped_column(Text, nullable=True)
@@ -215,6 +218,18 @@ class ArtifactRow(Base):
215
  )
216
 
217
 
 
 
 
 
 
 
 
 
 
 
 
 
218
  class ConceptRow(Base):
219
  """A deduplicated concept node: one evergreen idea, many source cards.
220
  Dedupe key is name_norm (no type axis). Mirrors ArtifactRow."""
@@ -373,6 +388,23 @@ async def claim_owner(db_session: AsyncSession, *, name: str, uid: str) -> int |
373
  return total
374
 
375
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
376
  # --------------------------------------------------------------------------- #
377
  # Engine / session lifecycle
378
  # --------------------------------------------------------------------------- #
@@ -509,8 +541,33 @@ def session() -> AsyncSession:
509
  # Convenience queries used across the app
510
  # --------------------------------------------------------------------------- #
511
 
512
- async def get_card_row(db: AsyncSession, card_id: str) -> CardRow | None:
513
- return await db.get(CardRow, card_id)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
514
 
515
 
516
  async def find_card_by_url(
@@ -536,10 +593,12 @@ async def upsert_artifact(
536
  creator: str | None,
537
  year: int | None,
538
  thumbnail: str | None,
539
- saved: bool = False,
540
  ) -> ArtifactRow:
541
  """Insert a catalog item or merge into the existing one (dedupe by type+title).
542
- Appends card_id to source_card_ids and backfills a missing thumbnail."""
 
 
 
543
  norm = _norm_title(title)
544
  res = await db.execute(
545
  select(ArtifactRow).where(
@@ -556,12 +615,9 @@ async def upsert_artifact(
556
  year=year,
557
  thumbnail=thumbnail,
558
  source_card_ids=[card_id],
559
- saved=saved,
560
  )
561
  db.add(row)
562
  else:
563
- if saved:
564
- row.saved = True
565
  if card_id not in (row.source_card_ids or []):
566
  row.source_card_ids = [*(row.source_card_ids or []), card_id]
567
  flag_modified(row, "source_card_ids")
@@ -576,25 +632,53 @@ async def upsert_artifact(
576
 
577
 
578
  async def set_artifact_saved(
579
- db: AsyncSession, artifact_id: str, saved: bool
580
  ) -> ArtifactRow | None:
581
- """Toggle whether an artifact appears in the catalog tab. Unsaving keeps the
582
- row (it still backs per-card references); it just leaves the catalog."""
 
583
  row = await db.get(ArtifactRow, artifact_id)
584
- if row is None:
 
 
585
  return None
586
- row.saved = saved
 
 
 
 
587
  await db.commit()
588
  return row
589
 
590
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
591
  async def set_artifact_description(
592
- db: AsyncSession, artifact_id: str, description: str
 
593
  ) -> ArtifactRow | None:
594
  """Persist the on-demand LLM detail for an artifact (Fetch info)."""
595
  row = await db.get(ArtifactRow, artifact_id)
596
  if row is None:
597
  return None
 
 
 
 
598
  row.description = description
599
  await db.commit()
600
  return row
@@ -631,6 +715,12 @@ async def get_or_create_collection(
631
  return row
632
 
633
 
 
 
 
 
 
 
634
  async def list_collections(
635
  db_session: AsyncSession, owner_id: str | None
636
  ) -> list[tuple[CollectionRow, int]]:
@@ -668,10 +758,11 @@ async def create_custom_collection(
668
 
669
 
670
  async def rename_collection(
671
- db_session: AsyncSession, collection_id: str, name: str
 
672
  ) -> CollectionRow | None:
673
  row = await db_session.get(CollectionRow, collection_id)
674
- if row is None:
675
  return None
676
  row.name = name.strip()
677
  await db_session.commit()
@@ -679,12 +770,15 @@ async def rename_collection(
679
 
680
 
681
  async def delete_collection(
682
- db_session: AsyncSession, collection_id: str
683
  ) -> bool:
684
- """Delete a custom collection; returns False if not found or not custom."""
 
685
  row = await db_session.get(CollectionRow, collection_id)
686
  if row is None or not row.is_custom:
687
  return False
 
 
688
  # Detach cards from this collection before deleting.
689
  from sqlalchemy import update as sa_update
690
  await db_session.execute(
@@ -698,11 +792,16 @@ async def delete_collection(
698
 
699
 
700
  async def move_card_to_collection(
701
- db_session: AsyncSession, card_id: str, collection_id: str | None
 
702
  ) -> CardRow | None:
703
  row = await db_session.get(CardRow, card_id)
704
- if row is None:
705
  return None
 
 
 
 
706
  row.collection_id = collection_id
707
  await db_session.commit()
708
  return row
@@ -778,6 +877,35 @@ async def set_concept_definition(
778
  return row
779
 
780
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
781
  async def cleanup_after_card_deletion(
782
  db: AsyncSession, card_id: str
783
  ) -> None:
@@ -805,6 +933,11 @@ async def cleanup_after_card_deletion(
805
  if card_id in current_ids:
806
  new_ids = [c for c in current_ids if c != card_id]
807
  if not new_ids:
 
 
 
 
 
808
  await db.delete(art)
809
  else:
810
  art.source_card_ids = new_ids
 
16
  Integer,
17
  String,
18
  Text,
19
+ delete,
20
  func,
21
  inspect as sa_inspect,
22
  select,
 
192
  year: Mapped[int | None] = mapped_column(Integer, nullable=True)
193
  thumbnail: Mapped[str | None] = mapped_column(Text, nullable=True)
194
  source_card_ids: Mapped[list] = mapped_column(JSON, default=list)
195
+ # Deprecated: catalog-tab membership is now per-owner (see [ArtifactSaveRow]).
196
+ # Kept so old rows/DBs still load; no longer read for filtering. Always False.
197
+ # ponytail: leave the dead column, drop it in a later real migration.
198
  saved: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
199
  # On-demand LLM detail ("what is this"), filled via the Fetch info action.
200
  description: Mapped[str | None] = mapped_column(Text, nullable=True)
 
218
  )
219
 
220
 
221
+ class ArtifactSaveRow(Base):
222
+ """Per-owner catalog membership: one row = this owner saved this artifact into
223
+ their catalog tab. Replaces the shared artifacts.saved flag so one user
224
+ saving/removing a catalog item never touches another user's catalog."""
225
+
226
+ __tablename__ = "artifact_saves"
227
+
228
+ owner_id: Mapped[str] = mapped_column(String, primary_key=True)
229
+ artifact_id: Mapped[str] = mapped_column(String, primary_key=True, index=True)
230
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
231
+
232
+
233
  class ConceptRow(Base):
234
  """A deduplicated concept node: one evergreen idea, many source cards.
235
  Dedupe key is name_norm (no type axis). Mirrors ArtifactRow."""
 
388
  return total
389
 
390
 
391
+ async def merge_owner(db_session: AsyncSession, *, from_uid: str, to_uid: str) -> int:
392
+ """Fold a guest account's rows into another account: re-point everything
393
+ owned by `from_uid` to `to_uid`. Used when a guest signs into a Google
394
+ account that already has a Cachy identity, so linking isn't possible and the
395
+ guest's data would otherwise be orphaned. Returns rows moved."""
396
+ if from_uid == to_uid:
397
+ return 0
398
+ total = 0
399
+ for model in (CardRow, CollectionRow, ConversationRow, ConnectionRow):
400
+ res = await db_session.execute(
401
+ update(model).where(model.owner_id == from_uid).values(owner_id=to_uid)
402
+ )
403
+ total += res.rowcount or 0
404
+ await db_session.commit()
405
+ return total
406
+
407
+
408
  # --------------------------------------------------------------------------- #
409
  # Engine / session lifecycle
410
  # --------------------------------------------------------------------------- #
 
541
  # Convenience queries used across the app
542
  # --------------------------------------------------------------------------- #
543
 
544
+ async def get_card_row(
545
+ db: AsyncSession, card_id: str, owner_id: str | None = None
546
+ ) -> CardRow | None:
547
+ """Fetch a card by id. When `owner_id` is given, a card owned by anyone else
548
+ reads as absent (None) — so callers can 404 without leaking existence."""
549
+ row = await db.get(CardRow, card_id)
550
+ if row is None:
551
+ return None
552
+ if owner_id is not None and row.owner_id != owner_id:
553
+ return None
554
+ return row
555
+
556
+
557
+ async def owner_owns_any_card(
558
+ db: AsyncSession, owner_id: str, card_ids: list[str] | set[str]
559
+ ) -> bool:
560
+ """True when `owner_id` owns at least one of `card_ids`. Used to gate access
561
+ to shared artifact/concept rows, which are owned only through their source
562
+ cards (no owner_id column of their own)."""
563
+ if not card_ids:
564
+ return False
565
+ res = await db.execute(
566
+ select(CardRow.id)
567
+ .where(CardRow.owner_id == owner_id, CardRow.id.in_(list(card_ids)))
568
+ .limit(1)
569
+ )
570
+ return res.scalar_one_or_none() is not None
571
 
572
 
573
  async def find_card_by_url(
 
593
  creator: str | None,
594
  year: int | None,
595
  thumbnail: str | None,
 
596
  ) -> ArtifactRow:
597
  """Insert a catalog item or merge into the existing one (dedupe by type+title).
598
+ Appends card_id to source_card_ids and backfills a missing thumbnail.
599
+
600
+ Catalog-tab membership is per-owner (see [ArtifactSaveRow]); this only records
601
+ the shared reference, never who saved it."""
602
  norm = _norm_title(title)
603
  res = await db.execute(
604
  select(ArtifactRow).where(
 
615
  year=year,
616
  thumbnail=thumbnail,
617
  source_card_ids=[card_id],
 
618
  )
619
  db.add(row)
620
  else:
 
 
621
  if card_id not in (row.source_card_ids or []):
622
  row.source_card_ids = [*(row.source_card_ids or []), card_id]
623
  flag_modified(row, "source_card_ids")
 
632
 
633
 
634
  async def set_artifact_saved(
635
+ db: AsyncSession, artifact_id: str, saved: bool, owner_id: str
636
  ) -> ArtifactRow | None:
637
+ """Add/remove this owner's catalog membership for an artifact. The caller must
638
+ own one of the artifact's source cards, else the row reads as absent (None).
639
+ Unsaving keeps the shared row (it still backs per-card references)."""
640
  row = await db.get(ArtifactRow, artifact_id)
641
+ if row is None or not await owner_owns_any_card(
642
+ db, owner_id, row.source_card_ids or []
643
+ ):
644
  return None
645
+ membership = await db.get(ArtifactSaveRow, (owner_id, artifact_id))
646
+ if saved and membership is None:
647
+ db.add(ArtifactSaveRow(owner_id=owner_id, artifact_id=artifact_id))
648
+ elif not saved and membership is not None:
649
+ await db.delete(membership)
650
  await db.commit()
651
  return row
652
 
653
 
654
+ async def owner_saved_artifact_ids(db: AsyncSession, owner_id: str) -> set[str]:
655
+ """Artifact ids this owner has saved into their catalog tab."""
656
+ res = await db.execute(
657
+ select(ArtifactSaveRow.artifact_id).where(
658
+ ArtifactSaveRow.owner_id == owner_id
659
+ )
660
+ )
661
+ return set(res.scalars().all())
662
+
663
+
664
+ async def is_artifact_saved_by(
665
+ db: AsyncSession, artifact_id: str, owner_id: str
666
+ ) -> bool:
667
+ return await db.get(ArtifactSaveRow, (owner_id, artifact_id)) is not None
668
+
669
+
670
  async def set_artifact_description(
671
+ db: AsyncSession, artifact_id: str, description: str,
672
+ owner_id: str | None = None,
673
  ) -> ArtifactRow | None:
674
  """Persist the on-demand LLM detail for an artifact (Fetch info)."""
675
  row = await db.get(ArtifactRow, artifact_id)
676
  if row is None:
677
  return None
678
+ if owner_id is not None and not await owner_owns_any_card(
679
+ db, owner_id, row.source_card_ids or []
680
+ ):
681
+ return None
682
  row.description = description
683
  await db.commit()
684
  return row
 
715
  return row
716
 
717
 
718
+ async def get_collection_row(
719
+ db_session: AsyncSession, collection_id: str
720
+ ) -> CollectionRow | None:
721
+ return await db_session.get(CollectionRow, collection_id)
722
+
723
+
724
  async def list_collections(
725
  db_session: AsyncSession, owner_id: str | None
726
  ) -> list[tuple[CollectionRow, int]]:
 
758
 
759
 
760
  async def rename_collection(
761
+ db_session: AsyncSession, collection_id: str, name: str,
762
+ owner_id: str | None = None,
763
  ) -> CollectionRow | None:
764
  row = await db_session.get(CollectionRow, collection_id)
765
+ if row is None or (owner_id is not None and row.owner_id != owner_id):
766
  return None
767
  row.name = name.strip()
768
  await db_session.commit()
 
770
 
771
 
772
  async def delete_collection(
773
+ db_session: AsyncSession, collection_id: str, owner_id: str | None = None
774
  ) -> bool:
775
+ """Delete a custom collection; returns False if not found, not custom, or not
776
+ owned by `owner_id`."""
777
  row = await db_session.get(CollectionRow, collection_id)
778
  if row is None or not row.is_custom:
779
  return False
780
+ if owner_id is not None and row.owner_id != owner_id:
781
+ return False
782
  # Detach cards from this collection before deleting.
783
  from sqlalchemy import update as sa_update
784
  await db_session.execute(
 
792
 
793
 
794
  async def move_card_to_collection(
795
+ db_session: AsyncSession, card_id: str, collection_id: str | None,
796
+ owner_id: str | None = None,
797
  ) -> CardRow | None:
798
  row = await db_session.get(CardRow, card_id)
799
+ if row is None or (owner_id is not None and row.owner_id != owner_id):
800
  return None
801
+ if collection_id is not None and owner_id is not None:
802
+ dest = await db_session.get(CollectionRow, collection_id)
803
+ if dest is None or dest.owner_id != owner_id:
804
+ return None
805
  row.collection_id = collection_id
806
  await db_session.commit()
807
  return row
 
877
  return row
878
 
879
 
880
+ async def remove_owner_from_concept(
881
+ db: AsyncSession, concept_id: str, owner_id: str
882
+ ) -> bool:
883
+ """Detach `owner_id`'s cards from a shared concept. If no source cards remain
884
+ (from anyone), delete the row. Returns False when the concept doesn't exist
885
+ or the caller owns none of its source cards (so nothing was removed)."""
886
+ row = await db.get(ConceptRow, concept_id)
887
+ if row is None:
888
+ return False
889
+ current = list(row.source_card_ids or [])
890
+ if not await owner_owns_any_card(db, owner_id, current):
891
+ return False
892
+ mine = set(
893
+ (await db.execute(
894
+ select(CardRow.id).where(
895
+ CardRow.owner_id == owner_id, CardRow.id.in_(current)
896
+ )
897
+ )).scalars().all()
898
+ )
899
+ remaining = [c for c in current if c not in mine]
900
+ if remaining:
901
+ row.source_card_ids = remaining
902
+ flag_modified(row, "source_card_ids")
903
+ else:
904
+ await db.delete(row)
905
+ await db.commit()
906
+ return True
907
+
908
+
909
  async def cleanup_after_card_deletion(
910
  db: AsyncSession, card_id: str
911
  ) -> None:
 
933
  if card_id in current_ids:
934
  new_ids = [c for c in current_ids if c != card_id]
935
  if not new_ids:
936
+ await db.execute(
937
+ delete(ArtifactSaveRow).where(
938
+ ArtifactSaveRow.artifact_id == art.id
939
+ )
940
+ )
941
  await db.delete(art)
942
  else:
943
  art.source_card_ids = new_ids
backend/tests/test_api.py CHANGED
@@ -89,18 +89,20 @@ async def test_catalog_upsert_dedupes_and_lists(client, database):
89
  for cid in ("c1", "c2", "c3")
90
  ])
91
  await s.commit()
92
- await database.upsert_artifact(
93
  s, card_id="c1", type_="book", title="Atomic Habits",
94
- creator="James Clear", year=2018, thumbnail="http://x/a.jpg", saved=True,
95
  )
96
- await database.upsert_artifact(
97
  s, card_id="c2", type_="book", title="atomic habits",
98
- creator=None, year=None, thumbnail=None, saved=True,
99
  )
100
- await database.upsert_artifact(
101
  s, card_id="c3", type_="movie", title="Inception",
102
- creator="Nolan", year=2010, thumbnail=None, saved=True,
103
  )
 
 
104
 
105
  r = await client.get("/catalog")
106
  entries = r.json()
@@ -114,11 +116,12 @@ async def test_catalog_upsert_dedupes_and_lists(client, database):
114
  assert [e["title"] for e in r2.json()] == ["Inception"]
115
 
116
 
117
- async def _make_ready_card(database) -> str:
118
  async with database.session() as s:
119
  row = database.CardRow(
120
  source_url="https://instagram.com/reel/chat",
121
  state="ready",
 
122
  content_type="recipe",
123
  one_liner="Easy pancakes",
124
  tldr="Mix, pour, flip.",
@@ -188,7 +191,7 @@ async def test_chat_returns_reply_when_backend_answers(client, database, monkeyp
188
  async def test_chat_persists_and_restores_per_owner(client, database, monkeypatch):
189
  """A chat turn is saved for the owner that made it, restorable via GET, and
190
  invisible to a different owner."""
191
- card_id = await _make_ready_card(database)
192
  monkeypatch.setattr(
193
  "app.api.cards.llm_chat.answer", lambda card, history: "Use 1 cup."
194
  )
@@ -217,7 +220,7 @@ async def test_chat_persists_and_restores_per_owner(client, database, monkeypatc
217
  async def test_rabbithole_persists_trail_and_restores(client, database, monkeypatch):
218
  """Successive dives build a persisted trail keyed by the root topic, and a
219
  branch taken after jumping back replaces the abandoned deeper steps."""
220
- card_id = await _make_ready_card(database)
221
 
222
  async def fake_explore(card, topic, trail):
223
  return {"explanation": f"About {topic}.", "threads": [f"{topic} deeper"]}
@@ -273,7 +276,7 @@ async def test_rabbithole_persists_trail_and_restores(client, database, monkeypa
273
 
274
 
275
  async def test_library_chat_persists_and_restores_per_owner(client, monkeypatch):
276
- async def fake_answer(history):
277
  return ("You saved 2 recipes.", [])
278
 
279
  monkeypatch.setattr(
@@ -298,11 +301,16 @@ async def test_library_chat_persists_and_restores_per_owner(client, monkeypatch)
298
 
299
  async def test_catalog_detail_and_delete(client, database):
300
  async with database.session() as s:
 
 
 
 
301
  row = await database.upsert_artifact(
302
  s, card_id="c1", type_="podcast", title="Lex Fridman",
303
- creator=None, year=None, thumbnail=None, saved=True,
304
  )
305
  artifact_id = row.id
 
306
 
307
  g = await client.get(f"/catalog/{artifact_id}")
308
  assert g.status_code == 200
@@ -374,7 +382,7 @@ async def test_library_chat_503_without_llm_backend(client):
374
 
375
 
376
  async def test_library_chat_returns_reply(client, monkeypatch):
377
- async def fake_answer(history):
378
  return ("You saved 3 recipes.", [])
379
 
380
  monkeypatch.setattr("app.api.library_chat.llm_library_chat.answer", fake_answer)
@@ -417,8 +425,8 @@ async def test_search_mode_text_works(client):
417
  async def test_delete_card_cascades_cleanup(client, database):
418
  from sqlalchemy import select
419
  async with database.session() as s:
420
- c1 = database.CardRow(id="del-1", source_url="http://x/1", state="ready")
421
- c2 = database.CardRow(id="del-2", source_url="http://x/2", state="ready")
422
  s.add_all([c1, c2])
423
  await s.commit()
424
 
@@ -429,9 +437,9 @@ async def test_delete_card_cascades_cleanup(client, database):
429
  await database.upsert_concept(s, name="Idea Shared", card_id="del-1")
430
  await database.upsert_concept(s, name="Idea Shared", card_id="del-2")
431
 
432
- await database.upsert_artifact(s, card_id="del-1", type_="book", title="Book Solo", creator=None, year=None, thumbnail=None, saved=True)
433
- await database.upsert_artifact(s, card_id="del-1", type_="book", title="Book Shared", creator=None, year=None, thumbnail=None, saved=True)
434
- await database.upsert_artifact(s, card_id="del-2", type_="book", title="Book Shared", creator=None, year=None, thumbnail=None, saved=True)
435
 
436
  res = await client.delete("/cards/del-1")
437
  assert res.status_code == 200
 
89
  for cid in ("c1", "c2", "c3")
90
  ])
91
  await s.commit()
92
+ a1 = await database.upsert_artifact(
93
  s, card_id="c1", type_="book", title="Atomic Habits",
94
+ creator="James Clear", year=2018, thumbnail="http://x/a.jpg",
95
  )
96
+ a2 = await database.upsert_artifact(
97
  s, card_id="c2", type_="book", title="atomic habits",
98
+ creator=None, year=None, thumbnail=None,
99
  )
100
+ a3 = await database.upsert_artifact(
101
  s, card_id="c3", type_="movie", title="Inception",
102
+ creator="Nolan", year=2010, thumbnail=None,
103
  )
104
+ for aid in {a1.id, a2.id, a3.id}:
105
+ await database.set_artifact_saved(s, aid, True, "test-user")
106
 
107
  r = await client.get("/catalog")
108
  entries = r.json()
 
116
  assert [e["title"] for e in r2.json()] == ["Inception"]
117
 
118
 
119
+ async def _make_ready_card(database, owner_id: str = "test-user") -> str:
120
  async with database.session() as s:
121
  row = database.CardRow(
122
  source_url="https://instagram.com/reel/chat",
123
  state="ready",
124
+ owner_id=owner_id,
125
  content_type="recipe",
126
  one_liner="Easy pancakes",
127
  tldr="Mix, pour, flip.",
 
191
  async def test_chat_persists_and_restores_per_owner(client, database, monkeypatch):
192
  """A chat turn is saved for the owner that made it, restorable via GET, and
193
  invisible to a different owner."""
194
+ card_id = await _make_ready_card(database, owner_id="alice")
195
  monkeypatch.setattr(
196
  "app.api.cards.llm_chat.answer", lambda card, history: "Use 1 cup."
197
  )
 
220
  async def test_rabbithole_persists_trail_and_restores(client, database, monkeypatch):
221
  """Successive dives build a persisted trail keyed by the root topic, and a
222
  branch taken after jumping back replaces the abandoned deeper steps."""
223
+ card_id = await _make_ready_card(database, owner_id="alice")
224
 
225
  async def fake_explore(card, topic, trail):
226
  return {"explanation": f"About {topic}.", "threads": [f"{topic} deeper"]}
 
276
 
277
 
278
  async def test_library_chat_persists_and_restores_per_owner(client, monkeypatch):
279
+ async def fake_answer(history, owner_id):
280
  return ("You saved 2 recipes.", [])
281
 
282
  monkeypatch.setattr(
 
301
 
302
  async def test_catalog_detail_and_delete(client, database):
303
  async with database.session() as s:
304
+ s.add(database.CardRow(
305
+ id="c1", source_url="http://cat/c1", state="ready", owner_id="test-user",
306
+ ))
307
+ await s.commit()
308
  row = await database.upsert_artifact(
309
  s, card_id="c1", type_="podcast", title="Lex Fridman",
310
+ creator=None, year=None, thumbnail=None,
311
  )
312
  artifact_id = row.id
313
+ await database.set_artifact_saved(s, artifact_id, True, "test-user")
314
 
315
  g = await client.get(f"/catalog/{artifact_id}")
316
  assert g.status_code == 200
 
382
 
383
 
384
  async def test_library_chat_returns_reply(client, monkeypatch):
385
+ async def fake_answer(history, owner_id):
386
  return ("You saved 3 recipes.", [])
387
 
388
  monkeypatch.setattr("app.api.library_chat.llm_library_chat.answer", fake_answer)
 
425
  async def test_delete_card_cascades_cleanup(client, database):
426
  from sqlalchemy import select
427
  async with database.session() as s:
428
+ c1 = database.CardRow(id="del-1", source_url="http://x/1", state="ready", owner_id="test-user")
429
+ c2 = database.CardRow(id="del-2", source_url="http://x/2", state="ready", owner_id="test-user")
430
  s.add_all([c1, c2])
431
  await s.commit()
432
 
 
437
  await database.upsert_concept(s, name="Idea Shared", card_id="del-1")
438
  await database.upsert_concept(s, name="Idea Shared", card_id="del-2")
439
 
440
+ await database.upsert_artifact(s, card_id="del-1", type_="book", title="Book Solo", creator=None, year=None, thumbnail=None)
441
+ await database.upsert_artifact(s, card_id="del-1", type_="book", title="Book Shared", creator=None, year=None, thumbnail=None)
442
+ await database.upsert_artifact(s, card_id="del-2", type_="book", title="Book Shared", creator=None, year=None, thumbnail=None)
443
 
444
  res = await client.delete("/cards/del-1")
445
  assert res.status_code == 200
backend/tests/test_isolation.py ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Cross-owner isolation: a card/collection/artifact/concept owned by one user
2
+ is invisible and immutable to another. Locks the owner-scoping guards on the
3
+ by-id routes (get/patch/delete card, collections, catalog, concepts)."""
4
+
5
+ from app.store import db
6
+
7
+
8
+ async def _card(database, cid: str, owner: str) -> None:
9
+ async with database.session() as s:
10
+ s.add(database.CardRow(
11
+ id=cid, source_url=f"http://iso/{cid}", state="ready", owner_id=owner,
12
+ one_liner="secret", tldr="secret body",
13
+ ))
14
+ await s.commit()
15
+
16
+
17
+ def _h(owner: str) -> dict:
18
+ return {"x-owner-id": owner}
19
+
20
+
21
+ async def test_get_patch_delete_card_denied_to_other_owner(client, database):
22
+ await _card(database, "iso-1", "alice")
23
+
24
+ assert (await client.get("/cards/iso-1", headers=_h("bob"))).status_code == 404
25
+ assert (await client.patch(
26
+ "/cards/iso-1", headers=_h("bob"), json={"blocks": []}
27
+ )).status_code == 404
28
+ assert (await client.delete("/cards/iso-1", headers=_h("bob"))).status_code == 404
29
+
30
+ # Owner still gets it, and it survived bob's delete attempt.
31
+ assert (await client.get("/cards/iso-1", headers=_h("alice"))).status_code == 200
32
+
33
+
34
+ async def test_collection_mutations_denied_to_other_owner(client, database):
35
+ await _card(database, "iso-2", "alice")
36
+ created = await client.post(
37
+ "/collections", headers=_h("alice"), json={"name": "Alice folder"}
38
+ )
39
+ col_id = created.json()["id"]
40
+
41
+ assert (await client.patch(
42
+ f"/collections/{col_id}", headers=_h("bob"), json={"name": "hijack"}
43
+ )).status_code == 404
44
+ assert (await client.delete(
45
+ f"/collections/{col_id}", headers=_h("bob")
46
+ )).status_code == 404
47
+ # Bob can't move alice's card either.
48
+ assert (await client.post(
49
+ "/collections/cards/iso-2/move", headers=_h("bob"),
50
+ json={"collection_id": col_id},
51
+ )).status_code == 404
52
+
53
+
54
+ async def test_catalog_and_concept_by_id_denied_to_other_owner(client, database):
55
+ await _card(database, "iso-3", "alice")
56
+ async with database.session() as s:
57
+ art = await db.upsert_artifact(
58
+ s, card_id="iso-3", type_="book", title="Alice Book",
59
+ creator=None, year=None, thumbnail=None,
60
+ )
61
+ await db.set_artifact_saved(s, art.id, True, "alice")
62
+ con = await db.upsert_concept(s, card_id="iso-3", name="alice concept")
63
+ art_id, con_id = art.id, con.id
64
+
65
+ assert (await client.get(
66
+ f"/catalog/{art_id}", headers=_h("bob")
67
+ )).status_code == 404
68
+ assert (await client.delete(
69
+ f"/catalog/{art_id}", headers=_h("bob")
70
+ )).status_code == 404
71
+ assert (await client.post(
72
+ f"/concepts/{con_id}/define", headers=_h("bob")
73
+ )).status_code == 404
74
+ assert (await client.delete(
75
+ f"/concepts/{con_id}", headers=_h("bob")
76
+ )).status_code == 404
77
+
78
+ # Owner still reaches the catalog entry.
79
+ assert (await client.get(
80
+ f"/catalog/{art_id}", headers=_h("alice")
81
+ )).status_code == 200
82
+
83
+
84
+ async def test_catalog_saved_state_is_per_owner(client, database):
85
+ """A single shared artifact (referenced by two owners' cards) has independent
86
+ catalog membership per owner — one saving/removing never affects the other."""
87
+ await _card(database, "shared-a", "alice")
88
+ await _card(database, "shared-b", "bob")
89
+ async with database.session() as s:
90
+ # Same title+type from both cards dedupes into ONE shared artifact row.
91
+ await db.upsert_artifact(
92
+ s, card_id="shared-a", type_="book", title="Shared Book",
93
+ creator=None, year=None, thumbnail=None,
94
+ )
95
+ art = await db.upsert_artifact(
96
+ s, card_id="shared-b", type_="book", title="Shared Book",
97
+ creator=None, year=None, thumbnail=None,
98
+ )
99
+ art_id = art.id
100
+
101
+ # Alice saves it; her catalog shows it, bob's does not.
102
+ assert (await client.post(
103
+ f"/catalog/{art_id}/save", headers=_h("alice")
104
+ )).status_code == 200
105
+ alice_titles = [e["title"] for e in (
106
+ await client.get("/catalog", headers=_h("alice"))).json()]
107
+ bob_titles = [e["title"] for e in (
108
+ await client.get("/catalog", headers=_h("bob"))).json()]
109
+ assert alice_titles == ["Shared Book"]
110
+ assert bob_titles == []
111
+
112
+ # Bob saves it too — independent membership.
113
+ assert (await client.post(
114
+ f"/catalog/{art_id}/save", headers=_h("bob")
115
+ )).status_code == 200
116
+
117
+ # Alice removes it; bob's catalog is untouched.
118
+ assert (await client.delete(
119
+ f"/catalog/{art_id}", headers=_h("alice")
120
+ )).status_code == 200
121
+ assert (await client.get("/catalog", headers=_h("alice"))).json() == []
122
+ assert [e["title"] for e in (
123
+ await client.get("/catalog", headers=_h("bob"))).json()] == ["Shared Book"]
backend/tests/test_merge.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Guest (anonymous) account data folding into an existing account."""
2
+
3
+ from sqlalchemy import select
4
+
5
+ from app.api import auth_routes
6
+ from app.auth import get_owner
7
+ from app.main import app
8
+ from app.store import db
9
+
10
+
11
+ async def _seed_card(owner_id: str) -> None:
12
+ async with db.session() as s:
13
+ s.add(db.CardRow(owner_id=owner_id, source_url=f"https://x/{owner_id}", state="ready"))
14
+ await s.commit()
15
+
16
+
17
+ async def test_merge_folds_guest_rows_into_account(client, monkeypatch) -> None:
18
+ await _seed_card("guest-uid")
19
+
20
+ # Caller (Bearer) is the destination Google account.
21
+ app.dependency_overrides[get_owner] = lambda: "google-uid"
22
+ # guest_token verifies to an anonymous guest uid.
23
+ monkeypatch.setattr(
24
+ auth_routes,
25
+ "_verify",
26
+ lambda _t: {"uid": "guest-uid", "firebase": {"sign_in_provider": "anonymous"}},
27
+ )
28
+
29
+ resp = await client.post("/auth/merge", json={"guest_token": "guest-jwt"})
30
+ assert resp.status_code == 200 and resp.json()["merged"] >= 1
31
+
32
+ async with db.session() as s:
33
+ cards = (await s.execute(select(db.CardRow))).scalars().all()
34
+ assert cards and all(c.owner_id == "google-uid" for c in cards)
35
+
36
+
37
+ async def test_merge_rejects_non_anonymous_source(client, monkeypatch) -> None:
38
+ app.dependency_overrides[get_owner] = lambda: "google-uid"
39
+ monkeypatch.setattr(
40
+ auth_routes,
41
+ "_verify",
42
+ lambda _t: {"uid": "other-real-uid", "firebase": {"sign_in_provider": "google.com"}},
43
+ )
44
+ resp = await client.post("/auth/merge", json={"guest_token": "real-jwt"})
45
+ assert resp.status_code == 403
backend/tests/test_prefer_local.py ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Dev toggle: prefer_local forces on-device structuring by degrading the job
2
+ (skip server LLM, keep the bundle for the phone) even when within quota."""
3
+
4
+ from sqlalchemy import select
5
+
6
+ from app.store import db
7
+
8
+
9
+ async def test_prefer_local_degrades_job_within_quota(client) -> None:
10
+ r = await client.post(
11
+ "/cards", json={"url": "https://example.com/local", "prefer_local": True}
12
+ )
13
+ assert r.status_code == 200
14
+ assert r.json()["quota_degraded"] is True
15
+
16
+ async with db.session() as s:
17
+ jobs = (await s.execute(select(db.JobRow))).scalars().all()
18
+ assert len(jobs) == 1 and jobs[0].degraded is True
19
+
20
+
21
+ async def test_default_uses_server_llm(client) -> None:
22
+ r = await client.post("/cards", json={"url": "https://example.com/server"})
23
+ assert r.status_code == 200
24
+ assert r.json()["quota_degraded"] is False
25
+
26
+ async with db.session() as s:
27
+ jobs = (await s.execute(select(db.JobRow))).scalars().all()
28
+ assert len(jobs) == 1 and jobs[0].degraded is False