Phase 5A: implement tenant auth foundation across broker, app, and agent
This commit is contained in:
@@ -111,20 +111,22 @@ Status: ✅ Completed (2026-05-18)
|
|||||||
- Off-LAN or failed direct attempts still open fullscreen photo through broker fallback.
|
- Off-LAN or failed direct attempts still open fullscreen photo through broker fallback.
|
||||||
- User-facing fullscreen flow remains one tap with no extra prompts.
|
- User-facing fullscreen flow remains one tap with no extra prompts.
|
||||||
|
|
||||||
## Phase 5: Platform Integration & Hardening
|
## Phase 5A: Multi-user Foundation (Google + Tenant Isolation)
|
||||||
|
|
||||||
- Align Linux service + Windows task launchers with new network/config settings.
|
- Introduce explicit ownership in data model:
|
||||||
- Add rate limits and upload size checks on LAN endpoint.
|
- `users`, `agents`, `photos` keyed by `user_id`
|
||||||
- Add migration/upload concurrency controls.
|
- One agent owned by exactly one user
|
||||||
- Add operational logs/metrics for:
|
- Scope all photo/agent API reads and writes by authenticated `user_id`.
|
||||||
- Auth failures
|
- Add Google auth entry flow for app users:
|
||||||
- Direct vs broker upload ratio
|
- Android Google sign-in
|
||||||
- Migration outcomes and duration.
|
- Broker verifies Google ID token
|
||||||
|
- Broker issues short-lived app JWT plus refresh/session token
|
||||||
|
- Migrate existing single-user records into one seeded owner account.
|
||||||
|
|
||||||
### Acceptance
|
### Acceptance
|
||||||
|
|
||||||
- Windows and Linux agents behave consistently with LAN mode enabled.
|
- Cross-user access is blocked on all covered endpoints.
|
||||||
- Security and reliability checks pass in repeated real-network tests.
|
- Existing single-user records are migrated to one seeded owner account.
|
||||||
|
|
||||||
## Public Interfaces / Config Additions
|
## Public Interfaces / Config Additions
|
||||||
|
|
||||||
|
|||||||
32
PLAN.md
32
PLAN.md
@@ -12,7 +12,7 @@ that works end-to-end, not a half-finished layer.
|
|||||||
| 0 | POC — tunnel, upload relay, reverse fetch | ✅ Done |
|
| 0 | POC — tunnel, upload relay, reverse fetch | ✅ Done |
|
||||||
| 1 | Android app — pick and upload photos | ✅ Done |
|
| 1 | Android app — pick and upload photos | ✅ Done |
|
||||||
| 2 | Agent UI v1 — status + storage settings (LAN API/UI) | ✅ Done (2026-05-18) |
|
| 2 | Agent UI v1 — status + storage settings (LAN API/UI) | ✅ Done (2026-05-18) |
|
||||||
| TODO | Remaining work backlog (phase grouping TBD) | ⬜ Pending |
|
| 5A | Multi-user foundation (Google auth + tenant boundaries) | ⬜ Pending |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -55,6 +55,28 @@ that works end-to-end, not a half-finished layer.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Phase 5A — Multi-user Foundation (Google Auth + Ownership)
|
||||||
|
|
||||||
|
**Goal:** Move from single-user assumptions to shared broker operation with strict
|
||||||
|
tenant isolation by authenticated user identity.
|
||||||
|
|
||||||
|
**Ownership model:**
|
||||||
|
- `users`, `agents`, and `photos` have explicit `user_id` ownership boundaries
|
||||||
|
- One agent is owned by exactly one user
|
||||||
|
- All photo/agent API reads and writes are scoped by authenticated `user_id`
|
||||||
|
|
||||||
|
**Auth entry:**
|
||||||
|
- Android app uses Google sign-in
|
||||||
|
- Broker verifies Google ID token
|
||||||
|
- Broker issues app JWT (short-lived) and refresh/session token
|
||||||
|
|
||||||
|
### Acceptance
|
||||||
|
|
||||||
|
- Cross-user access is blocked on all covered endpoints.
|
||||||
|
- Existing single-user records are migrated to one seeded owner account.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## TODO (Post-Phase 2 Backlog)
|
## TODO (Post-Phase 2 Backlog)
|
||||||
|
|
||||||
Phase grouping beyond Phase 1 is intentionally deferred. Use this backlog to
|
Phase grouping beyond Phase 1 is intentionally deferred. Use this backlog to
|
||||||
@@ -83,12 +105,8 @@ pick what will become the next implementation phase(s).
|
|||||||
- [ ] Basic pagination or infinite scroll
|
- [ ] Basic pagination or infinite scroll
|
||||||
|
|
||||||
### Multi-user foundation
|
### Multi-user foundation
|
||||||
- [ ] Data model boundaries: `users`, `agents`, `photos` with explicit ownership keys
|
- [ ] Phase 5A scoped in `## Phase 5A — Multi-user Foundation (Google Auth + Ownership)`
|
||||||
- [ ] Add `user_id` scoping to all photo/agent queries and APIs
|
- [ ] Remaining auth/pairing lifecycle design follows Phase 5B (to be scoped next)
|
||||||
- [ ] Auth contracts: user JWT + per-agent token format and lifecycle
|
|
||||||
- [ ] Pairing flow design: agent token issue, rotate, revoke
|
|
||||||
- [ ] Endpoint auth matrix (public/private, required scopes)
|
|
||||||
- [ ] Migration plan from current single-user data to multi-user-safe schema
|
|
||||||
|
|
||||||
### Android backup and release
|
### Android backup and release
|
||||||
- [ ] WorkManager job — runs on WiFi, respects battery saver
|
- [ ] WorkManager job — runs on WiFi, respects battery saver
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
@@ -917,13 +918,17 @@ func main() {
|
|||||||
|
|
||||||
go state.startHTTPServer(cfg.AllowedSubnets)
|
go state.startHTTPServer(cfg.AllowedSubnets)
|
||||||
|
|
||||||
conn, _, err := websocket.DefaultDialer.Dial(brokerURL, nil)
|
wsURL := brokerURL
|
||||||
|
if state.token != "" {
|
||||||
|
wsURL = brokerURL + "?token=" + url.QueryEscape(state.token)
|
||||||
|
}
|
||||||
|
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal("connect:", err)
|
log.Fatal("connect:", err)
|
||||||
}
|
}
|
||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
state.setConnected(true)
|
state.setConnected(true)
|
||||||
log.Println("connected to broker at", brokerURL)
|
log.Println("connected to broker at", wsURL)
|
||||||
hello, _ := json.Marshal(Message{
|
hello, _ := json.Marshal(Message{
|
||||||
Type: "agent_hello",
|
Type: "agent_hello",
|
||||||
LANEndpoints: getLANEndpoints(cfg.BindHost, cfg.BindPort),
|
LANEndpoints: getLANEndpoints(cfg.BindHost, cfg.BindPort),
|
||||||
|
|||||||
@@ -81,11 +81,16 @@ class _UploadPageState extends State<UploadPage> {
|
|||||||
Timer? _statusTimer;
|
Timer? _statusTimer;
|
||||||
DirectUploadConfig _directConfig = DirectUploadConfig.defaults;
|
DirectUploadConfig _directConfig = DirectUploadConfig.defaults;
|
||||||
late final TextEditingController _tokenController;
|
late final TextEditingController _tokenController;
|
||||||
|
late final TextEditingController _googleIdTokenController;
|
||||||
|
String _accessToken = '';
|
||||||
|
String _refreshToken = '';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_tokenController = TextEditingController();
|
_tokenController = TextEditingController();
|
||||||
|
_googleIdTokenController = TextEditingController();
|
||||||
|
_loadBrokerSession();
|
||||||
_loadDirectUploadConfig();
|
_loadDirectUploadConfig();
|
||||||
_loadGallery();
|
_loadGallery();
|
||||||
_statusTimer = Timer.periodic(
|
_statusTimer = Timer.periodic(
|
||||||
@@ -98,9 +103,95 @@ class _UploadPageState extends State<UploadPage> {
|
|||||||
void dispose() {
|
void dispose() {
|
||||||
_statusTimer?.cancel();
|
_statusTimer?.cancel();
|
||||||
_tokenController.dispose();
|
_tokenController.dispose();
|
||||||
|
_googleIdTokenController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _loadBrokerSession() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_accessToken = prefs.getString('broker_access_token') ?? '';
|
||||||
|
_refreshToken = prefs.getString('broker_refresh_token') ?? '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _saveBrokerSession({
|
||||||
|
required String accessToken,
|
||||||
|
required String refreshToken,
|
||||||
|
}) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString('broker_access_token', accessToken);
|
||||||
|
await prefs.setString('broker_refresh_token', refreshToken);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_accessToken = accessToken;
|
||||||
|
_refreshToken = refreshToken;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _clearBrokerSession() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.remove('broker_access_token');
|
||||||
|
await prefs.remove('broker_refresh_token');
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_accessToken = '';
|
||||||
|
_refreshToken = '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _signInWithGoogleIdToken() async {
|
||||||
|
final idToken = _googleIdTokenController.text.trim();
|
||||||
|
if (idToken.isEmpty) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final res = await http.post(
|
||||||
|
Uri.parse('$brokerUrl/auth/google'),
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: jsonEncode({'id_token': idToken}),
|
||||||
|
);
|
||||||
|
if (res.statusCode != 200) return false;
|
||||||
|
final body = jsonDecode(res.body);
|
||||||
|
final accessToken = (body['access_token'] ?? '').toString();
|
||||||
|
final refreshToken = (body['refresh_token'] ?? '').toString();
|
||||||
|
if (accessToken.isEmpty || refreshToken.isEmpty) return false;
|
||||||
|
await _saveBrokerSession(
|
||||||
|
accessToken: accessToken,
|
||||||
|
refreshToken: refreshToken,
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String?> _getValidAccessToken() async {
|
||||||
|
if (_accessToken.isNotEmpty) return _accessToken;
|
||||||
|
if (_refreshToken.isEmpty) return null;
|
||||||
|
try {
|
||||||
|
final res = await http.post(
|
||||||
|
Uri.parse('$brokerUrl/auth/refresh'),
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: jsonEncode({'refresh_token': _refreshToken}),
|
||||||
|
);
|
||||||
|
if (res.statusCode != 200) {
|
||||||
|
await _clearBrokerSession();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final body = jsonDecode(res.body);
|
||||||
|
final accessToken = (body['access_token'] ?? '').toString();
|
||||||
|
if (accessToken.isEmpty) return null;
|
||||||
|
await _saveBrokerSession(
|
||||||
|
accessToken: accessToken,
|
||||||
|
refreshToken: _refreshToken,
|
||||||
|
);
|
||||||
|
return accessToken;
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _loadDirectUploadConfig() async {
|
Future<void> _loadDirectUploadConfig() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
final cfg = DirectUploadConfig(
|
final cfg = DirectUploadConfig(
|
||||||
@@ -207,7 +298,12 @@ class _UploadPageState extends State<UploadPage> {
|
|||||||
|
|
||||||
Future<List<Uri>> _fetchAgentHints() async {
|
Future<List<Uri>> _fetchAgentHints() async {
|
||||||
try {
|
try {
|
||||||
final res = await http.get(Uri.parse('$brokerUrl/agent/hints'));
|
final token = await _getValidAccessToken();
|
||||||
|
if (token == null) return const [];
|
||||||
|
final res = await http.get(
|
||||||
|
Uri.parse('$brokerUrl/agent/hints'),
|
||||||
|
headers: {'Authorization': 'Bearer $token'},
|
||||||
|
);
|
||||||
if (res.statusCode != 200) return const [];
|
if (res.statusCode != 200) return const [];
|
||||||
final body = jsonDecode(res.body);
|
final body = jsonDecode(res.body);
|
||||||
if (body is! Map<String, dynamic>) return const [];
|
if (body is! Map<String, dynamic>) return const [];
|
||||||
@@ -244,11 +340,16 @@ class _UploadPageState extends State<UploadPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<http.StreamedResponse> _uploadViaBroker(File file) async {
|
Future<http.StreamedResponse> _uploadViaBroker(File file) async {
|
||||||
|
final token = await _getValidAccessToken();
|
||||||
|
if (token == null) {
|
||||||
|
throw const HttpException('missing broker auth session');
|
||||||
|
}
|
||||||
final filename = p.basename(file.path);
|
final filename = p.basename(file.path);
|
||||||
final req = http.MultipartRequest(
|
final req = http.MultipartRequest(
|
||||||
'POST',
|
'POST',
|
||||||
Uri.parse('$brokerUrl/upload/$filename'),
|
Uri.parse('$brokerUrl/upload/$filename'),
|
||||||
);
|
);
|
||||||
|
req.headers['Authorization'] = 'Bearer $token';
|
||||||
req.files.add(await http.MultipartFile.fromPath('file', file.path));
|
req.files.add(await http.MultipartFile.fromPath('file', file.path));
|
||||||
return req.send().timeout(const Duration(seconds: 20));
|
return req.send().timeout(const Duration(seconds: 20));
|
||||||
}
|
}
|
||||||
@@ -271,12 +372,15 @@ class _UploadPageState extends State<UploadPage> {
|
|||||||
|
|
||||||
Future<void> _indexDirectUploadToBroker(File file) async {
|
Future<void> _indexDirectUploadToBroker(File file) async {
|
||||||
try {
|
try {
|
||||||
|
final token = await _getValidAccessToken();
|
||||||
|
if (token == null) return;
|
||||||
final thumbBytes = await _buildPhoneThumbnail(file);
|
final thumbBytes = await _buildPhoneThumbnail(file);
|
||||||
if (thumbBytes == null) return;
|
if (thumbBytes == null) return;
|
||||||
final req = http.MultipartRequest(
|
final req = http.MultipartRequest(
|
||||||
'POST',
|
'POST',
|
||||||
Uri.parse('$brokerUrl/upload-index'),
|
Uri.parse('$brokerUrl/upload-index'),
|
||||||
);
|
);
|
||||||
|
req.headers['Authorization'] = 'Bearer $token';
|
||||||
req.fields['filename'] = p.basename(file.path);
|
req.fields['filename'] = p.basename(file.path);
|
||||||
req.fields['size_bytes'] = (await file.length()).toString();
|
req.fields['size_bytes'] = (await file.length()).toString();
|
||||||
req.fields['content_type'] = 'image/jpeg';
|
req.fields['content_type'] = 'image/jpeg';
|
||||||
@@ -317,7 +421,18 @@ class _UploadPageState extends State<UploadPage> {
|
|||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
await _refreshAgentStatus();
|
await _refreshAgentStatus();
|
||||||
final res = await http.get(Uri.parse('$brokerUrl/photos'));
|
final token = await _getValidAccessToken();
|
||||||
|
if (token == null) {
|
||||||
|
setState(() {
|
||||||
|
_galleryLoading = false;
|
||||||
|
_galleryError = 'Sign in is required to load gallery.';
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final res = await http.get(
|
||||||
|
Uri.parse('$brokerUrl/photos'),
|
||||||
|
headers: {'Authorization': 'Bearer $token'},
|
||||||
|
);
|
||||||
if (res.statusCode != 200) {
|
if (res.statusCode != 200) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_galleryLoading = false;
|
_galleryLoading = false;
|
||||||
@@ -329,7 +444,7 @@ class _UploadPageState extends State<UploadPage> {
|
|||||||
final body = jsonDecode(res.body);
|
final body = jsonDecode(res.body);
|
||||||
final items = _extractPhotoItems(body);
|
final items = _extractPhotoItems(body);
|
||||||
final photos = items
|
final photos = items
|
||||||
.map(_toGalleryPhoto)
|
.map((x) => _toGalleryPhoto(x, token))
|
||||||
.whereType<GalleryPhoto>()
|
.whereType<GalleryPhoto>()
|
||||||
.toList(growable: false);
|
.toList(growable: false);
|
||||||
|
|
||||||
@@ -361,7 +476,17 @@ class _UploadPageState extends State<UploadPage> {
|
|||||||
|
|
||||||
Future<void> _refreshAgentStatus() async {
|
Future<void> _refreshAgentStatus() async {
|
||||||
try {
|
try {
|
||||||
final res = await http.get(Uri.parse('$brokerUrl/'));
|
final token = await _getValidAccessToken();
|
||||||
|
if (token == null) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _agentConnected = false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final res = await http.get(
|
||||||
|
Uri.parse('$brokerUrl/agent/hints'),
|
||||||
|
headers: {'Authorization': 'Bearer $token'},
|
||||||
|
);
|
||||||
if (res.statusCode != 200) {
|
if (res.statusCode != 200) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() => _agentConnected = false);
|
setState(() => _agentConnected = false);
|
||||||
@@ -392,11 +517,11 @@ class _UploadPageState extends State<UploadPage> {
|
|||||||
.toList(growable: false);
|
.toList(growable: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
GalleryPhoto? _toGalleryPhoto(Map<String, dynamic> item) {
|
GalleryPhoto? _toGalleryPhoto(Map<String, dynamic> item, String accessToken) {
|
||||||
final thumbRaw = item['thumbnail_url'];
|
final thumbRaw = item['thumbnail_url'];
|
||||||
if (thumbRaw is! String) return null;
|
if (thumbRaw is! String) return null;
|
||||||
|
|
||||||
final thumb = _resolveUrl(thumbRaw);
|
final thumb = _resolveUrl(thumbRaw, accessToken);
|
||||||
if (thumb.isEmpty) return null;
|
if (thumb.isEmpty) return null;
|
||||||
|
|
||||||
final rawFilename = item['filename'];
|
final rawFilename = item['filename'];
|
||||||
@@ -407,7 +532,7 @@ class _UploadPageState extends State<UploadPage> {
|
|||||||
String full;
|
String full;
|
||||||
|
|
||||||
if (explicitFull is String && explicitFull.trim().isNotEmpty) {
|
if (explicitFull is String && explicitFull.trim().isNotEmpty) {
|
||||||
full = _resolveUrl(explicitFull);
|
full = _resolveUrl(explicitFull, accessToken);
|
||||||
} else if (filename != null) {
|
} else if (filename != null) {
|
||||||
final encoded = Uri.encodeComponent(filename);
|
final encoded = Uri.encodeComponent(filename);
|
||||||
full = '$brokerUrl/photo/$encoded';
|
full = '$brokerUrl/photo/$encoded';
|
||||||
@@ -424,12 +549,15 @@ class _UploadPageState extends State<UploadPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
String _resolveUrl(String value) {
|
String _resolveUrl(String value, String accessToken) {
|
||||||
final trimmed = value.trim();
|
final trimmed = value.trim();
|
||||||
if (trimmed.isEmpty) return '';
|
if (trimmed.isEmpty) return '';
|
||||||
final uri = Uri.tryParse(trimmed);
|
final uri = Uri.tryParse(trimmed);
|
||||||
if (uri != null && uri.hasScheme && uri.hasAuthority) return trimmed;
|
final resolved = (uri != null && uri.hasScheme && uri.hasAuthority)
|
||||||
return Uri.parse(brokerUrl).resolve(trimmed).toString();
|
? trimmed
|
||||||
|
: Uri.parse(brokerUrl).resolve(trimmed).toString();
|
||||||
|
final sep = resolved.contains('?') ? '&' : '?';
|
||||||
|
return '$resolved${sep}access_token=${Uri.encodeQueryComponent(accessToken)}';
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _openFullPhoto(GalleryPhoto photo) async {
|
Future<void> _openFullPhoto(GalleryPhoto photo) async {
|
||||||
@@ -580,6 +708,42 @@ class _UploadPageState extends State<UploadPage> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
Text(
|
||||||
|
_accessToken.isEmpty
|
||||||
|
? 'Broker session: not signed in'
|
||||||
|
: 'Broker session: signed in',
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
TextField(
|
||||||
|
controller: _googleIdTokenController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Google ID token',
|
||||||
|
helperText: 'Paste Google ID token, then sign in.',
|
||||||
|
),
|
||||||
|
obscureText: true,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
child: FilledButton(
|
||||||
|
onPressed: () async {
|
||||||
|
final ok = await _signInWithGoogleIdToken();
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
ok ? 'Broker sign-in successful.' : 'Broker sign-in failed.',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (ok) {
|
||||||
|
await _loadGallery();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: const Text('Sign In'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Divider(height: 20),
|
||||||
const Text(
|
const Text(
|
||||||
'Direct upload is automatic using broker-provided agent LAN hints. '
|
'Direct upload is automatic using broker-provided agent LAN hints. '
|
||||||
'If direct fails, upload falls back to broker.',
|
'If direct fails, upload falls back to broker.',
|
||||||
|
|||||||
2
memory
2
memory
Submodule memory updated: f4ddac7a07...ab50b9a0f5
Reference in New Issue
Block a user