@mriekki/project-forge 4.26.1 → 4.27.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/assets/authentication/distribution.json +90 -0
  2. package/assets/components.json +17 -3
  3. package/assets/core/distribution.json +12 -0
  4. package/assets/deployment/distribution.json +292 -68
  5. package/assets/generated/ASSEMBLY.json +150 -4
  6. package/assets/generated/authentication/docs/AUTH_RECOVERY_RETENTION.md +101 -0
  7. package/assets/generated/authentication/docs/HOUSEHOLD_INFRASTRUCTURE_ADOPTION.md +55 -0
  8. package/assets/generated/authentication/docs/HOUSEHOLD_REGISTRY.md +246 -0
  9. package/assets/generated/authentication/docs/PROJECT_AUTH_REFERENCE.md +878 -0
  10. package/assets/generated/authentication/samples/ProjectForgeAuth.Api/Program.cs +40 -0
  11. package/assets/generated/authentication/samples/ProjectForgeAuth.Api/ProjectForgeAuth.Api.csproj +13 -0
  12. package/assets/generated/authentication/samples/ProjectForgeAuth.Api/wwwroot/auth.js +65 -0
  13. package/assets/generated/authentication/samples/ProjectForgeAuth.Api/wwwroot/index.html +44 -0
  14. package/assets/generated/authentication/src/ProjectForge.Auth/AdmissionPolicy.cs +167 -0
  15. package/assets/generated/authentication/src/ProjectForge.Auth/AuthenticationDiagnostics.cs +159 -0
  16. package/assets/generated/authentication/src/ProjectForge.Auth/ProjectForge.Auth.csproj +9 -0
  17. package/assets/generated/authentication/src/ProjectForge.Auth/PwaExistingProjectCompatibility.cs +64 -0
  18. package/assets/generated/authentication/src/ProjectForge.Auth.Runtime/ProjectAuthStore.cs +471 -0
  19. package/assets/generated/authentication/src/ProjectForge.Auth.Runtime/ProjectForge.Auth.Runtime.csproj +15 -0
  20. package/assets/generated/authentication/src/ProjectForge.Auth.Runtime/ProjectForgeAuthRuntimeExtensions.cs +472 -0
  21. package/assets/generated/authentication/src/ProjectForge.Auth.Runtime/ResendProjectInvitationDelivery.cs +105 -0
  22. package/assets/generated/authentication/src/ProjectForge.Transport/ProjectForge.Transport.csproj +11 -0
  23. package/assets/generated/authentication/src/ProjectForge.Transport/StrictPublicOriginTransport.cs +95 -0
  24. package/assets/generated/authentication/tools/household_connect.py +145 -0
  25. package/assets/generated/authentication/tools/project_auth_recovery.py +716 -0
  26. package/assets/generated/core/core/process/ENGINEERING_BACKLOG.md +150 -0
  27. package/assets/generated/core/core/skills/manage-backlog/SKILL.md +115 -0
  28. package/assets/generated/core/core/skills/review-user-feedback/SKILL.md +50 -0
  29. package/assets/generated/deployment/docs/RECOVERY_READINESS.md +103 -0
  30. package/assets/generated/deployment/src/project_forge_deployment/auth_recovery_capture.py +164 -0
  31. package/assets/generated/deployment/src/project_forge_deployment/auth_recovery_guard.py +225 -0
  32. package/assets/generated/deployment/src/project_forge_deployment/auth_recovery_lifecycle.py +455 -0
  33. package/assets/generated/deployment/src/project_forge_deployment/auth_recovery_retention.py +220 -0
  34. package/assets/generated/deployment/src/project_forge_deployment/auth_recovery_schedule.py +489 -0
  35. package/package.json +1 -1
  36. package/src/cli.mjs +32 -3
  37. package/src/engine.mjs +87 -5
@@ -0,0 +1,471 @@
1
+ using System;
2
+ using System.Collections.Generic;
3
+ using System.Globalization;
4
+ using System.Security.Cryptography;
5
+ using System.Text;
6
+ using System.Text.RegularExpressions;
7
+ using Microsoft.Data.Sqlite;
8
+
9
+ namespace ProjectForge.Auth.Runtime;
10
+
11
+ public sealed record ProjectAccount(
12
+ string Id, string Email, string NormalizedEmail, bool EmailVerified,
13
+ bool IsAdministrator, bool Disabled, long CreatedAt);
14
+ public sealed record ProjectAuthSession(
15
+ string Token, string AccountId, DateTimeOffset ExpiresAt);
16
+ public sealed record VerifiedGoogleIdentity(
17
+ string Issuer, string Subject, string Email, bool EmailVerified);
18
+ public sealed record ProjectInvitation(string Token, DateTimeOffset ExpiresAt);
19
+ public sealed record ProjectAuthResult(
20
+ bool Succeeded, string ReasonCode, ProjectAuthSession? Session, ProjectAccount? Account);
21
+
22
+ /// <summary>
23
+ /// Application-local, durable SQLite identity and opaque-session store.
24
+ /// Does not read Household Registry during login or copy application users.
25
+ /// No email-only account linking, no implicit admin bootstrap, no tokens in DB.
26
+ /// </summary>
27
+ public sealed class ProjectAuthStore
28
+ {
29
+ private readonly string _connString;
30
+ private const string GoogleIssuer = "https://accounts.google.com";
31
+ private static readonly Regex Email = new(
32
+ @"^[^@\s]{1,100}@[^@\s]{1,180}$", RegexOptions.CultureInvariant);
33
+ private static readonly Regex TokenPattern = new(
34
+ @"^[0-9a-f]{64}$", RegexOptions.CultureInvariant);
35
+
36
+ public ProjectAuthStore(string databasePath)
37
+ {
38
+ if (string.IsNullOrWhiteSpace(databasePath))
39
+ throw new ArgumentException("App-owned SQLite database path is required.");
40
+ _connString = new SqliteConnectionStringBuilder
41
+ {
42
+ DataSource = databasePath,
43
+ Mode = SqliteOpenMode.ReadWriteCreate,
44
+ Pooling = false
45
+ }.ToString();
46
+ }
47
+
48
+ private SqliteConnection Open()
49
+ {
50
+ var db = new SqliteConnection(_connString);
51
+ db.Open();
52
+ using var pragma = db.CreateCommand();
53
+ pragma.CommandText = "PRAGMA foreign_keys=ON; PRAGMA busy_timeout=5000;";
54
+ pragma.ExecuteNonQuery();
55
+ return db;
56
+ }
57
+
58
+ private static SqliteCommand Sql(SqliteConnection db, SqliteTransaction? tx, string sql,
59
+ params (string, object?)[] bindings)
60
+ {
61
+ var cmd = db.CreateCommand();
62
+ cmd.CommandText = sql;
63
+ cmd.Transaction = tx;
64
+ foreach (var (name, value) in bindings)
65
+ cmd.Parameters.AddWithValue(name, value ?? DBNull.Value);
66
+ return cmd;
67
+ }
68
+ private static string Id() => Guid.NewGuid().ToString("N");
69
+ private static string Utc(DateTimeOffset now) => now.UtcDateTime.ToString("O", CultureInfo.InvariantCulture);
70
+ private static DateTimeOffset ReadUtc(string value) =>
71
+ DateTimeOffset.Parse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind);
72
+ private static string NormalizeEmail(string email)
73
+ {
74
+ if (email is null) throw new ArgumentException("Email is required.");
75
+ var normalized = email.Trim();
76
+ if (!Email.IsMatch(normalized) || normalized.Length > 254)
77
+ throw new ArgumentException("Valid verified email is required.");
78
+ return normalized.ToUpperInvariant();
79
+ }
80
+ private static bool ValidIdentity(VerifiedGoogleIdentity principal) =>
81
+ principal is not null &&
82
+ principal.Issuer == GoogleIssuer &&
83
+ !string.IsNullOrWhiteSpace(principal.Subject) &&
84
+ principal.Subject.Length <= 512 &&
85
+ principal.EmailVerified &&
86
+ Email.IsMatch(principal.Email ?? "");
87
+
88
+ private static string Hash(string token) =>
89
+ Convert.ToHexString(SHA256.HashData(Encoding.ASCII.GetBytes(token)));
90
+ private static string NewToken() => Convert.ToHexString(RandomNumberGenerator.GetBytes(32)).ToLowerInvariant();
91
+
92
+ /// <summary>
93
+ /// Read-only application liveness probe for a *currently running* Project.
94
+ /// A missing/corrupt/stale identity database is unhealthy; a health result
95
+ /// does not prove recovered sessions, DataProtection continuity, external
96
+ /// Google/Resend access, backup freshness, or normal traffic acceptance.
97
+ /// Unlike Initialize(), this must NEVER create the missing SQLite file.
98
+ /// </summary>
99
+ public bool IsReadableAndCurrent()
100
+ {
101
+ try
102
+ {
103
+ var options = new SqliteConnectionStringBuilder(_connString)
104
+ {
105
+ Mode = SqliteOpenMode.ReadOnly,
106
+ Pooling = false
107
+ };
108
+ using var db = new SqliteConnection(options.ToString());
109
+ db.Open();
110
+ using var schema = db.CreateCommand();
111
+ schema.CommandText = "PRAGMA user_version;";
112
+ if (Convert.ToInt32(schema.ExecuteScalar(), CultureInfo.InvariantCulture) != 1)
113
+ return false;
114
+ schema.CommandText = """
115
+ SELECT COUNT(*) FROM sqlite_master
116
+ WHERE type = 'table' AND name IN ('accounts', 'sessions');
117
+ """;
118
+ return Convert.ToInt32(schema.ExecuteScalar(), CultureInfo.InvariantCulture) == 2;
119
+ }
120
+ catch (SqliteException)
121
+ {
122
+ // Only a coarse status may reach the caller. Never reveal a
123
+ // physical DB path or SQLite diagnostic over HTTP.
124
+ return false;
125
+ }
126
+ }
127
+
128
+ public void Initialize()
129
+ {
130
+ using var db = Open();
131
+ using var cmd = Sql(db, null, "PRAGMA user_version;");
132
+ var version = Convert.ToInt32(cmd.ExecuteScalar(), CultureInfo.InvariantCulture);
133
+ if (version > 1) throw new InvalidOperationException("Application auth store schema is newer than this Kit.");
134
+ if (version == 1) return;
135
+ using var tx = db.BeginTransaction(deferred: false);
136
+ using var schema = Sql(db, tx, """
137
+ CREATE TABLE IF NOT EXISTS accounts (
138
+ id TEXT PRIMARY KEY NOT NULL,
139
+ email TEXT NOT NULL,
140
+ normalized_email TEXT NOT NULL UNIQUE,
141
+ email_verified INTEGER NOT NULL CHECK (email_verified IN (0,1)),
142
+ is_admin INTEGER NOT NULL CHECK (is_admin IN (0,1)),
143
+ disabled INTEGER NOT NULL CHECK (disabled IN (0,1)) DEFAULT 0,
144
+ created_utc TEXT NOT NULL
145
+ );
146
+ CREATE TABLE IF NOT EXISTS external_identities (
147
+ account_id TEXT NOT NULL REFERENCES accounts(id),
148
+ issuer TEXT NOT NULL,
149
+ subject TEXT NOT NULL,
150
+ PRIMARY KEY (issuer,subject),
151
+ UNIQUE (account_id,issuer,subject)
152
+ );
153
+ CREATE TABLE IF NOT EXISTS invitations (
154
+ id TEXT PRIMARY KEY NOT NULL,
155
+ recipient_email TEXT NOT NULL,
156
+ recipient_normalized TEXT NOT NULL,
157
+ token_hash TEXT NOT NULL UNIQUE,
158
+ expires_utc TEXT NOT NULL,
159
+ consumed_utc TEXT,
160
+ created_by TEXT NOT NULL REFERENCES accounts(id),
161
+ created_utc TEXT NOT NULL
162
+ );
163
+ CREATE INDEX IF NOT EXISTS ix_invites_created_by ON invitations(created_by,created_utc);
164
+ CREATE INDEX IF NOT EXISTS ix_invites_recipient ON invitations(recipient_normalized,created_utc);
165
+ CREATE TABLE IF NOT EXISTS sessions (
166
+ token_hash TEXT PRIMARY KEY NOT NULL,
167
+ account_id TEXT NOT NULL REFERENCES accounts(id),
168
+ created_utc TEXT NOT NULL,
169
+ expires_utc TEXT NOT NULL,
170
+ revoked_utc TEXT
171
+ );
172
+ CREATE INDEX IF NOT EXISTS ix_sessions_account ON sessions(account_id);
173
+ PRAGMA user_version=1;
174
+ """);
175
+ schema.ExecuteNonQuery();
176
+ tx.Commit();
177
+ }
178
+
179
+ /// <summary>
180
+ /// First administrator is provisioned only through trusted local host
181
+ /// configuration, with a preapproved immutable Google subject.
182
+ /// Email remains unverified until actual Google OIDC assertion.
183
+ /// </summary>
184
+ public string BootstrapAdministrator(
185
+ string issuer, string subject, string email, DateTimeOffset now)
186
+ {
187
+ if (issuer != GoogleIssuer || string.IsNullOrWhiteSpace(subject) ||
188
+ subject.Length > 512)
189
+ throw new ArgumentException("Preapproved exact Google issuer and subject required.");
190
+ var normalized = NormalizeEmail(email);
191
+ using var db = Open();
192
+ using var tx = db.BeginTransaction(deferred: false);
193
+ using (var count = Sql(db, tx, "SELECT COUNT(*) FROM accounts"))
194
+ {
195
+ if (Convert.ToInt32(count.ExecuteScalar(), CultureInfo.InvariantCulture) != 0)
196
+ throw new InvalidOperationException("First administrator is already provisioned.");
197
+ }
198
+ var id = Id();
199
+ using (var cmd = Sql(db, tx, """
200
+ INSERT INTO accounts (id,email,normalized_email,email_verified,is_admin,disabled,created_utc)
201
+ VALUES ($id,$email,$norm,0,1,0,$time)
202
+ """, ("$id", id), ("$email", email.Trim()), ("$norm", normalized),
203
+ ("$time", Utc(now))))
204
+ cmd.ExecuteNonQuery();
205
+ using (var link = Sql(db, tx, """
206
+ INSERT INTO external_identities (account_id,issuer,subject)
207
+ VALUES ($id,$issuer,$subject)
208
+ """, ("$id", id), ("$issuer", issuer), ("$subject", subject)))
209
+ link.ExecuteNonQuery();
210
+ tx.Commit();
211
+ return id;
212
+ }
213
+
214
+ private static ProjectAccount ReadAccount(SqliteDataReader r) =>
215
+ new(r.GetString(0), r.GetString(1), r.GetString(2),
216
+ r.GetInt32(3) != 0, r.GetInt32(4) != 0,
217
+ r.GetInt32(5) != 0, 0);
218
+
219
+ private static ProjectAccount? AccountById(
220
+ SqliteConnection db, SqliteTransaction? tx, string id)
221
+ {
222
+ using var cmd = Sql(db, tx, """
223
+ SELECT id,email,normalized_email,email_verified,is_admin,disabled
224
+ FROM accounts WHERE id=$id
225
+ """, ("$id", id));
226
+ using var reader = cmd.ExecuteReader();
227
+ return reader.Read() ? ReadAccount(reader) : null;
228
+ }
229
+
230
+ public ProjectAccount? FindGoogleAccount(VerifiedGoogleIdentity principal)
231
+ {
232
+ if (!ValidIdentity(principal)) return null;
233
+ using var db = Open();
234
+ using var cmd = Sql(db, null, """
235
+ SELECT a.id,a.email,a.normalized_email,a.email_verified,a.is_admin,a.disabled
236
+ FROM accounts a JOIN external_identities i ON i.account_id=a.id
237
+ WHERE i.issuer=$iss AND i.subject=$sub
238
+ """, ("$iss", principal.Issuer), ("$sub", principal.Subject));
239
+ using var r = cmd.ExecuteReader();
240
+ return r.Read() ? ReadAccount(r) : null;
241
+ }
242
+
243
+ private static ProjectAuthSession IssueWithin(
244
+ SqliteConnection db, SqliteTransaction tx, string accountId, DateTimeOffset now)
245
+ {
246
+ var token = NewToken();
247
+ var expiry = now.AddDays(7);
248
+ using var cmd = Sql(db, tx, """
249
+ INSERT INTO sessions(token_hash,account_id,created_utc,expires_utc)
250
+ VALUES($t,$a,$c,$e)
251
+ """, ("$t", Hash(token)), ("$a", accountId), ("$c", Utc(now)),
252
+ ("$e", Utc(expiry)));
253
+ cmd.ExecuteNonQuery();
254
+ return new ProjectAuthSession(token, accountId, expiry);
255
+ }
256
+
257
+ public ProjectAuthResult SignIn(VerifiedGoogleIdentity principal, DateTimeOffset now)
258
+ {
259
+ if (!ValidIdentity(principal))
260
+ return new(false, "google-identity-not-verified", null, null);
261
+ using var db = Open();
262
+ using var tx = db.BeginTransaction(deferred: false);
263
+ string? accountId;
264
+ using (var find = Sql(db, tx, """
265
+ SELECT account_id FROM external_identities
266
+ WHERE issuer=$iss AND subject=$sub
267
+ """, ("$iss", principal.Issuer), ("$sub", principal.Subject)))
268
+ accountId = find.ExecuteScalar() as string;
269
+ if (accountId is null)
270
+ return new(false, "account-not-approved", null, null);
271
+ var account = AccountById(db, tx, accountId);
272
+ if (account is null || account.Disabled)
273
+ return new(false, "account-not-approved", null, null);
274
+ // The bound issuer+subject is identity authority, not the current
275
+ // email claim. A changed email never auto-links to another account.
276
+ if (!account.EmailVerified)
277
+ {
278
+ using var promote = Sql(db, tx,
279
+ "UPDATE accounts SET email_verified=1 WHERE id=$id",
280
+ ("$id", accountId));
281
+ promote.ExecuteNonQuery();
282
+ account = account with { EmailVerified = true };
283
+ }
284
+ var session = IssueWithin(db, tx, accountId, now);
285
+ tx.Commit();
286
+ return new(true, "approved-google-account", session, account);
287
+ }
288
+
289
+ public ProjectInvitation CreateInvitation(string actorId, string email, DateTimeOffset now)
290
+ {
291
+ var normalized = NormalizeEmail(email);
292
+ using var db = Open();
293
+ using var tx = db.BeginTransaction(deferred: false);
294
+ var admin = AccountById(db, tx, actorId);
295
+ if (admin is not { IsAdministrator: true, Disabled: false })
296
+ throw new UnauthorizedAccessException("Approved administrator required.");
297
+ // Persistent, transactional abuse controls: at most five invitations
298
+ // per administrator per hour and three per address per 24 hours.
299
+ // These constraints hold across process restarts and parallel requests.
300
+ using (var adminCount = Sql(db, tx, """
301
+ SELECT COUNT(*) FROM invitations
302
+ WHERE created_by=$admin AND created_utc>$since
303
+ """, ("$admin", actorId), ("$since", Utc(now.AddHours(-1)))))
304
+ {
305
+ if (Convert.ToInt32(adminCount.ExecuteScalar(), CultureInfo.InvariantCulture) >= 5)
306
+ throw new InvalidOperationException("Invitation rate limit reached.");
307
+ }
308
+ using (var recipientCount = Sql(db, tx, """
309
+ SELECT COUNT(*) FROM invitations
310
+ WHERE recipient_normalized=$recipient AND created_utc>$since
311
+ """, ("$recipient", normalized), ("$since", Utc(now.AddDays(-1)))))
312
+ {
313
+ if (Convert.ToInt32(recipientCount.ExecuteScalar(), CultureInfo.InvariantCulture) >= 3)
314
+ throw new InvalidOperationException("Recipient invitation rate limit reached.");
315
+ }
316
+ var token = NewToken();
317
+ var expiry = now.AddHours(48);
318
+ using var cmd = Sql(db, tx, """
319
+ INSERT INTO invitations(id,recipient_email,recipient_normalized,
320
+ token_hash,expires_utc,created_by,created_utc)
321
+ VALUES($id,$email,$norm,$hash,$expires,$actor,$created)
322
+ """, ("$id", Id()), ("$email", email.Trim()), ("$norm", normalized),
323
+ ("$hash", Hash(token)), ("$expires", Utc(expiry)),
324
+ ("$actor", actorId), ("$created", Utc(now)));
325
+ cmd.ExecuteNonQuery();
326
+ tx.Commit();
327
+ return new ProjectInvitation(token, expiry);
328
+ }
329
+
330
+ public ProjectAuthResult RedeemInvitation(
331
+ VerifiedGoogleIdentity principal, string token, DateTimeOffset now)
332
+ {
333
+ if (!ValidIdentity(principal) || token is null || !TokenPattern.IsMatch(token))
334
+ return new(false, "invitation-unavailable", null, null);
335
+ var normalized = NormalizeEmail(principal.Email);
336
+ using var db = Open();
337
+ using var tx = db.BeginTransaction(deferred: false);
338
+ string inviteId, recipient, expiry;
339
+ using (var cmd = Sql(db, tx, """
340
+ SELECT id,recipient_normalized,expires_utc
341
+ FROM invitations WHERE token_hash=$hash AND consumed_utc IS NULL
342
+ """, ("$hash", Hash(token)))
343
+ )
344
+ using (var r = cmd.ExecuteReader())
345
+ {
346
+ if (!r.Read()) return new(false, "invitation-unavailable", null, null);
347
+ inviteId = r.GetString(0);
348
+ recipient = r.GetString(1);
349
+ expiry = r.GetString(2);
350
+ }
351
+ if (recipient != normalized || now >= ReadUtc(expiry))
352
+ return new(false, "invitation-unavailable", null, null);
353
+ var accountId = Id();
354
+ try
355
+ {
356
+ using (var cmd = Sql(db, tx, """
357
+ INSERT INTO accounts (id,email,normalized_email,email_verified,is_admin,disabled,created_utc)
358
+ VALUES($id,$email,$norm,1,0,0,$created)
359
+ """, ("$id", accountId), ("$email", principal.Email.Trim()),
360
+ ("$norm", normalized), ("$created", Utc(now))))
361
+ cmd.ExecuteNonQuery();
362
+ using (var identity = Sql(db, tx, """
363
+ INSERT INTO external_identities(account_id,issuer,subject)
364
+ VALUES($id,$iss,$sub)
365
+ """, ("$id", accountId), ("$iss", principal.Issuer),
366
+ ("$sub", principal.Subject)))
367
+ identity.ExecuteNonQuery();
368
+ using (var consume = Sql(db, tx, """
369
+ UPDATE invitations SET consumed_utc=$time
370
+ WHERE id=$id AND consumed_utc IS NULL
371
+ """, ("$time", Utc(now)), ("$id", inviteId)))
372
+ {
373
+ if (consume.ExecuteNonQuery() != 1)
374
+ return new(false, "invitation-unavailable", null, null);
375
+ }
376
+ var session = IssueWithin(db, tx, accountId, now);
377
+ var account = AccountById(db, tx, accountId);
378
+ tx.Commit();
379
+ return new(true, "invited-account-created", session, account);
380
+ }
381
+ catch (SqliteException ex) when (ex.SqliteErrorCode == 19)
382
+ {
383
+ return new(false, "invitation-unavailable", null, null);
384
+ }
385
+ }
386
+
387
+ public ProjectAccount? GetSession(string token, DateTimeOffset now)
388
+ {
389
+ if (token is null || !TokenPattern.IsMatch(token)) return null;
390
+ using var db = Open();
391
+ using var cmd = Sql(db, null, """
392
+ SELECT a.id,a.email,a.normalized_email,a.email_verified,a.is_admin,a.disabled,
393
+ s.expires_utc,s.revoked_utc
394
+ FROM sessions s JOIN accounts a ON a.id=s.account_id
395
+ WHERE s.token_hash=$token
396
+ """, ("$token", Hash(token)));
397
+ using var reader = cmd.ExecuteReader();
398
+ if (!reader.Read() || reader.GetInt32(5) != 0 ||
399
+ !reader.IsDBNull(7) || now >= ReadUtc(reader.GetString(6)))
400
+ return null;
401
+ return ReadAccount(reader);
402
+ }
403
+
404
+ public bool RevokeSession(string token, DateTimeOffset now)
405
+ {
406
+ if (token is null || !TokenPattern.IsMatch(token)) return false;
407
+ using var db = Open();
408
+ using var cmd = Sql(db, null, """
409
+ UPDATE sessions SET revoked_utc=$time
410
+ WHERE token_hash=$token AND revoked_utc IS NULL
411
+ """, ("$time", Utc(now)), ("$token", Hash(token)));
412
+ return cmd.ExecuteNonQuery() == 1;
413
+ }
414
+
415
+ public bool DisableAccount(string adminSession, string accountId, DateTimeOffset now)
416
+ {
417
+ var admin = GetSession(adminSession, now);
418
+ if (admin is not { IsAdministrator: true, Disabled: false })
419
+ throw new UnauthorizedAccessException("Administrator required.");
420
+ if (admin.Id == accountId) return false;
421
+ using var db = Open();
422
+ using var tx = db.BeginTransaction(deferred: false);
423
+ using var cmd = Sql(db, tx,
424
+ "UPDATE accounts SET disabled=1 WHERE id=$id AND disabled=0 AND is_admin=0",
425
+ ("$id", accountId));
426
+ var changed = cmd.ExecuteNonQuery() == 1;
427
+ tx.Commit();
428
+ return changed;
429
+ }
430
+
431
+ public void Backup(string destination)
432
+ {
433
+ if (string.IsNullOrWhiteSpace(destination) ||
434
+ !Directory.Exists(Path.GetDirectoryName(Path.GetFullPath(destination))))
435
+ throw new ArgumentException("A new protected backup destination is required.");
436
+
437
+ // CreateNew is atomic: it refuses existing paths, including dangling
438
+ // symlinks. A separate File.Exists + File.Create pair has a race that
439
+ // could overwrite another backup between the existence check and open.
440
+ try
441
+ {
442
+ using var reserved = new FileStream(
443
+ destination, FileMode.CreateNew, FileAccess.Write, FileShare.None);
444
+ }
445
+ catch (IOException error)
446
+ {
447
+ throw new ArgumentException(
448
+ "A new protected backup destination is required.", nameof(destination), error);
449
+ }
450
+ try
451
+ {
452
+ if (!OperatingSystem.IsWindows())
453
+ File.SetUnixFileMode(destination, UnixFileMode.UserRead | UnixFileMode.UserWrite);
454
+ using var origin = Open();
455
+ using var target = new SqliteConnection(new SqliteConnectionStringBuilder
456
+ {
457
+ DataSource = destination, Mode = SqliteOpenMode.ReadWrite, Pooling = false
458
+ }.ToString());
459
+ target.Open();
460
+ origin.BackupDatabase(target);
461
+ using var check = Sql(target, null, "PRAGMA integrity_check;");
462
+ if (check.ExecuteScalar() is not string status || status != "ok")
463
+ throw new InvalidOperationException("SQLite backup integrity failed.");
464
+ }
465
+ catch
466
+ {
467
+ try { File.Delete(destination); } catch (IOException) { }
468
+ throw;
469
+ }
470
+ }
471
+ }
@@ -0,0 +1,15 @@
1
+ <Project Sdk="Microsoft.NET.Sdk">
2
+ <PropertyGroup>
3
+ <TargetFramework>net10.0</TargetFramework>
4
+ <Nullable>enable</Nullable>
5
+ <ImplicitUsings>enable</ImplicitUsings>
6
+ <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
7
+ </PropertyGroup>
8
+ <ItemGroup>
9
+ <PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.14" />
10
+ <PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="10.0.14" />
11
+ <FrameworkReference Include="Microsoft.AspNetCore.App" />
12
+ <ProjectReference Include="../ProjectForge.Auth/ProjectForge.Auth.csproj" />
13
+ <ProjectReference Include="../ProjectForge.Transport/ProjectForge.Transport.csproj" />
14
+ </ItemGroup>
15
+ </Project>