chain-studio/lib/data/hub_auth_token.dart
flemming-it c1c60d5434
Some checks failed
Security / Security check (push) Failing after 1s
feat(studio): rich theme picker (presets + custom colour) + editor 0.11.0
Settings dialog's Theme Plugin section is now a grid of
swatched tiles:

- Built-in (none) — falls back to FaiTheme.light/.dark
- One tile per installed studio.theme.* plugin, each
  showing the plugin's primary/secondary/tertiary as
  live colour dots. Tile loads its preview lazily so a
  dozen installed themes don't block the picker.
- Custom — opens a colour-picker dialog with 12 curated
  Material presets + a hex input + live preview. Selecting
  applies ColorScheme.fromSeed for both brightnesses.

main.dart's _pluginThemes parses a 'custom:#RRGGBB' sigil
in the same notifier slot as plugin capability ids, so the
existing persistence + restoration paths cover the custom
case with no new state.

Bumps editor to 0.11.0 (type-checked port connections +
dynamic card width fix + card-height border allowance) and
Studio to 0.58.0.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
2026-06-02 01:29:34 +02:00

126 lines
4.5 KiB
Dart

import 'dart:io';
import 'package:path/path.dart' as p;
/// Operator-managed gRPC bearer token used to authenticate
/// Studio against a hub that has `auth.tokens:` configured
/// (RBAC Level 2). Stored at `~/.fai/hub-auth-token` (mode
/// 0600 on Unix). Sister of [RegistryToken] — same on-disk
/// hygiene, different secret.
///
/// When the file is present and non-empty Studio constructs
/// `HubClient(authToken: ...)`, which attaches
/// `Authorization: Bearer <token>` to every gRPC and
/// gRPC-Web call. When the file is missing Studio stays in
/// anonymous mode; the hub then either accepts or rejects
/// based on its own `auth.tokens:` config.
class HubAuthToken {
static String _faiHome() {
final home =
Platform.environment['HOME'] ?? Platform.environment['USERPROFILE'];
if (home == null || home.isEmpty) {
throw StateError('Cannot resolve home directory (no HOME / USERPROFILE)');
}
return p.join(home, '.fai');
}
/// Absolute path to the token file.
static String get path => p.join(_faiHome(), 'hub-auth-token');
/// True iff the file exists with a non-empty trimmed body.
static Future<bool> isConfigured() async {
final f = File(path);
if (!await f.exists()) return false;
final content = await f.readAsString();
return content.trim().isNotEmpty;
}
/// Length of the trimmed token, or null when the file is
/// missing / empty. Used for status display ("Configured
/// (40 chars)") without ever surfacing the secret.
static Future<int?> charCount() async {
final f = File(path);
if (!await f.exists()) return null;
final content = await f.readAsString();
final trimmed = content.trim();
return trimmed.isEmpty ? null : trimmed.length;
}
/// Read the trimmed token, or `null` when missing/empty.
/// Studio calls this at startup so [HubService] can supply
/// it to the underlying gRPC client.
static Future<String?> read() async {
final f = File(path);
if (!await f.exists()) return null;
final content = (await f.readAsString()).trim();
return content.isEmpty ? null : content;
}
/// Persist [token] to disk, creating `~/.fai/` if needed.
///
/// Atomic write: writes to `<path>.tmp`, chmods 0600 on
/// Unix BEFORE moving into place, then renames. This
/// closes the TOCTOU window where the historical
/// `writeAsString` + later `chmod 600` sequence left the
/// file world-readable for the duration of the chmod call.
///
/// Also sets `~/.fai/` itself to 0700 on Unix on first
/// creation so other users on a shared host can't even
/// enumerate the directory (the 0600 token-file mode is
/// fine in isolation; combining it with a 0755 parent
/// directory leaks the file's existence).
static Future<void> write(String token) async {
final f = File(path);
final tmp = File('${f.path}.tmp');
final parent = f.parent;
final parentExisted = await parent.exists();
await parent.create(recursive: true);
if ((Platform.isLinux || Platform.isMacOS) && !parentExisted) {
try {
await Process.run('chmod', ['700', parent.path]);
} catch (_) {
/* best-effort */
}
}
// Write to the temp path with restrictive mode set up
// BEFORE the rename — the final file is never visible
// to other processes with the default umask mode.
await tmp.writeAsString(token.trim(), flush: true);
if (Platform.isLinux || Platform.isMacOS) {
try {
await Process.run('chmod', ['600', tmp.path]);
} catch (_) {
/* best-effort */
}
}
// Atomic rename: on POSIX, replaces the destination
// in one syscall. On Windows, Dart's File.rename uses
// MoveFileEx with MOVEFILE_REPLACE_EXISTING which is
// equally atomic for this use case.
try {
await tmp.rename(f.path);
} catch (_) {
// On rare cross-mount setups, rename may fail; fall
// back to copy + delete. The token is then briefly
// visible at the .tmp path with 0600 mode — still
// safe, but not atomic.
await tmp.copy(f.path);
if (Platform.isLinux || Platform.isMacOS) {
try {
await Process.run('chmod', ['600', f.path]);
} catch (_) {
/* best-effort */
}
}
await tmp.delete();
}
}
/// Remove the token file. Returns true iff the file existed
/// before deletion.
static Future<bool> clear() async {
final f = File(path);
if (!await f.exists()) return false;
await f.delete();
return true;
}
}