diff --git a/portal/docs/FLEET-DESIGN.md b/portal/docs/FLEET-DESIGN.md
index 984e0d8..9bfeb42 100644
--- a/portal/docs/FLEET-DESIGN.md
+++ b/portal/docs/FLEET-DESIGN.md
@@ -225,7 +225,7 @@ Push runs on its own DI scope and its own DbContext. If the push DB query hangs,
- Token = 32 random bytes, hex-encoded (64 chars). Generated server-side via `RandomNumberGenerator`.
- Stored as **SHA-256 hex hash**, UNIQUE indexed → ingest endpoint does a single O(log N) indexed lookup. Bcrypt is the wrong tool for a high-throughput, high-entropy machine token.
- Shown **once** in the Admin UI at creation/rotation. Admin only stores the hash. Lost token → rotate.
-- Rotation: **immediate cutover** — new value replaces old hash. Customer ops updates `.env` + restarts. Brief push gap acceptable; can revisit with dual-token grace window later.
+- Rotation: **dual-token with 24h grace window** (shipped after the initial design). On rotate, the current `TokenHash` is copied into `PreviousTokenHash` with `PreviousTokenExpiresAt = now + 24h`, then a fresh current is generated. `FindByTokenAsync` matches either current OR (previous AND not expired AND `IsActive`). Customer ops has the full window to update `.env` + restart without dropping a push tick. A second rotation within the grace window overwrites the previous slot — at most one previous token is ever honoured at a time. Grace duration is `CustomerService.DefaultTokenGracePeriod` (parameterizable on `RotateTokenAsync` for tests; lift to config if you need per-customer tuning).
- `Customers.IsActive=false` → all that customer's pushes get 401 until reactivated.
---
@@ -321,7 +321,7 @@ Database__ConnectionString=Host=timescaledb;... # central fleet DB (separ
| Bidirectional Admin → Customer commands | New WebSocket or long-poll channel on customer side; gated by mutual cert or a second token. |
| Branding sync (for the "Admin sees customer's brand when drilling in" niceness) | Push branding row from customer; Admin renders the customer's brand on Customer-detail pages. |
| Forward compatibility | Versioned URL `/v1/fleet/ingest`. Admin tolerates unknown JSON fields. Strictness on response shape only. |
-| Dual-token grace window for rotation | `Customers.PreviousTokenHash` column; ingest accepts either for 24h after rotation. |
+| ~~Dual-token grace window for rotation~~ | **Shipped.** See §6 — `Customers.PreviousTokenHash` + `PreviousTokenExpiresAt`; ingest accepts either while previous is unexpired. Default 24h. |
| Sharded Admin (10000+ customers) | Customer's `FleetIngest__Url` already supports any host — point different customers at different Admin instances; aggregate at a tier above with Promscale or similar if needed. |
| Hard-delete / GDPR | Admin Customers page → "Decommission" action: `DELETE FROM fleet.* WHERE CustomerId = …` cascade. Logged. |
diff --git a/portal/frontend/src/api/customers.ts b/portal/frontend/src/api/customers.ts
index c5c6749..08af487 100644
--- a/portal/frontend/src/api/customers.ts
+++ b/portal/frontend/src/api/customers.ts
@@ -7,6 +7,8 @@ export interface CustomerListItem {
isActive: boolean;
tokenIssuedAt: string;
tokenRotatedAt: string | null;
+ // Non-null while the previous (pre-rotation) token is still accepted by ingest.
+ previousTokenExpiresAt: string | null;
firstSeenAt: string | null;
lastSeenAt: string | null;
createdAt: string;
diff --git a/portal/frontend/src/components/customers/TokenShownOnceModal.tsx b/portal/frontend/src/components/customers/TokenShownOnceModal.tsx
index f0b17c3..685d224 100644
--- a/portal/frontend/src/components/customers/TokenShownOnceModal.tsx
+++ b/portal/frontend/src/components/customers/TokenShownOnceModal.tsx
@@ -55,6 +55,11 @@ export function TokenShownOnceModal({ open, customerCode, token, onClose }: Prop
Set this as FleetIngest__Token in the customer's .env,
alongside FleetIngest__Url and FleetIngest__Enabled=true.
+
+ If this is a rotation (not the first issue), the old token continues to
+ work for 24h. Update the customer's .env and restart their portal
+ within that window to avoid dropped pushes.
+
} onClick={copy}>Copy
diff --git a/portal/frontend/src/pages/AdminCustomersPage.tsx b/portal/frontend/src/pages/AdminCustomersPage.tsx
index 7ec1572..897240f 100644
--- a/portal/frontend/src/pages/AdminCustomersPage.tsx
+++ b/portal/frontend/src/pages/AdminCustomersPage.tsx
@@ -94,11 +94,25 @@ export function AdminCustomersPage() {
v ? new Date(v).toLocaleString() : Never,
},
{
- title: 'Token issued',
+ title: 'Token',
key: 'token',
render: (_, c) => {
const ts = c.tokenRotatedAt ?? c.tokenIssuedAt;
- return new Date(ts).toLocaleDateString();
+ const issued = {new Date(ts).toLocaleDateString()};
+ if (!c.previousTokenExpiresAt) return issued;
+ const exp = new Date(c.previousTokenExpiresAt);
+ const stillValid = exp > new Date();
+ if (!stillValid) return issued;
+ return (
+
+ {issued}
+
+
+ Old token valid until {exp.toLocaleTimeString()}
+
+
+
+ );
},
},
{
@@ -109,10 +123,10 @@ export function AdminCustomersPage() {
} onClick={() => { setFormError(null); setFormMode({ kind: 'edit', customer: c }); }}>
Edit
-
+
rotateMut.mutate(c.id)}
diff --git a/portal/src/Tau.Acuvim.Portal/DTOs/CustomerDtos.cs b/portal/src/Tau.Acuvim.Portal/DTOs/CustomerDtos.cs
index 5440830..c6f6aae 100644
--- a/portal/src/Tau.Acuvim.Portal/DTOs/CustomerDtos.cs
+++ b/portal/src/Tau.Acuvim.Portal/DTOs/CustomerDtos.cs
@@ -7,6 +7,9 @@ public sealed record CustomerListItemDto(
bool IsActive,
DateTime TokenIssuedAt,
DateTime? TokenRotatedAt,
+ // Non-null while a previous token is still accepted by /api/fleet/ingest
+ // (rotation grace window). Customer ops should finish updating .env before this.
+ DateTime? PreviousTokenExpiresAt,
DateTime? FirstSeenAt,
DateTime? LastSeenAt,
DateTime CreatedAt);
diff --git a/portal/src/Tau.Acuvim.Portal/Data/AdminDbContext.cs b/portal/src/Tau.Acuvim.Portal/Data/AdminDbContext.cs
index be6ca00..40fb4ef 100644
--- a/portal/src/Tau.Acuvim.Portal/Data/AdminDbContext.cs
+++ b/portal/src/Tau.Acuvim.Portal/Data/AdminDbContext.cs
@@ -32,6 +32,11 @@ public class AdminDbContext : IdentityDbContext x.Code).IsUnique();
entity.HasIndex(x => x.TokenHash).IsUnique();
+ // Non-unique: previous-hash slots get overwritten on subsequent rotations,
+ // and (mathematically) won't collide with current hashes since both are
+ // SHA-256 of 32 random bytes. Index just makes the OR-lookup in
+ // FindByTokenAsync cheap.
+ entity.HasIndex(x => x.PreviousTokenHash);
});
builder.Entity(entity =>
diff --git a/portal/src/Tau.Acuvim.Portal/Domain/Fleet/Customer.cs b/portal/src/Tau.Acuvim.Portal/Domain/Fleet/Customer.cs
index 86be0da..07b7d81 100644
--- a/portal/src/Tau.Acuvim.Portal/Domain/Fleet/Customer.cs
+++ b/portal/src/Tau.Acuvim.Portal/Domain/Fleet/Customer.cs
@@ -15,6 +15,13 @@ public class Customer
[MaxLength(64)]
public string TokenHash { get; set; } = string.Empty;
+ // Previous token kept after rotation for a grace window so customer ops has
+ // time to update .env + restart without dropping pushes. Null when no
+ // rotation has happened yet or when the previous slot has been cleared.
+ [MaxLength(64)]
+ public string? PreviousTokenHash { get; set; }
+ public DateTime? PreviousTokenExpiresAt { get; set; }
+
public DateTime TokenIssuedAt { get; set; } = DateTime.UtcNow;
public DateTime? TokenRotatedAt { get; set; }
diff --git a/portal/src/Tau.Acuvim.Portal/Endpoints/AdminCustomersEndpoints.cs b/portal/src/Tau.Acuvim.Portal/Endpoints/AdminCustomersEndpoints.cs
index a3255dc..c69c070 100644
--- a/portal/src/Tau.Acuvim.Portal/Endpoints/AdminCustomersEndpoints.cs
+++ b/portal/src/Tau.Acuvim.Portal/Endpoints/AdminCustomersEndpoints.cs
@@ -34,7 +34,7 @@ public static class AdminCustomersEndpoints
group.MapPost("/{id:guid}/rotate-token", async (Guid id, CustomerService svc, CancellationToken ct) =>
{
- var result = await svc.RotateTokenAsync(id, ct);
+ var result = await svc.RotateTokenAsync(id, ct: ct);
return result is null ? Results.NotFound() : Results.Ok(result);
});
diff --git a/portal/src/Tau.Acuvim.Portal/Migrations/Admin/20260518084030_AddPreviousTokenGraceWindow.Designer.cs b/portal/src/Tau.Acuvim.Portal/Migrations/Admin/20260518084030_AddPreviousTokenGraceWindow.Designer.cs
new file mode 100644
index 0000000..74e2178
--- /dev/null
+++ b/portal/src/Tau.Acuvim.Portal/Migrations/Admin/20260518084030_AddPreviousTokenGraceWindow.Designer.cs
@@ -0,0 +1,605 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+using Tau.Acuvim.Portal.Data;
+
+#nullable disable
+
+namespace Tau.Acuvim.Portal.Migrations.Admin
+{
+ [DbContext(typeof(AdminDbContext))]
+ [Migration("20260518084030_AddPreviousTokenGraceWindow")]
+ partial class AddPreviousTokenGraceWindow
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasDefaultSchema("app")
+ .HasAnnotation("ProductVersion", "10.0.8")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("text");
+
+ b.Property("ConcurrencyStamp")
+ .IsConcurrencyToken()
+ .HasColumnType("text");
+
+ b.Property("Name")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("NormalizedName")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("NormalizedName")
+ .IsUnique()
+ .HasDatabaseName("RoleNameIndex");
+
+ b.ToTable("AspNetRoles", "identity");
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ClaimType")
+ .HasColumnType("text");
+
+ b.Property("ClaimValue")
+ .HasColumnType("text");
+
+ b.Property("RoleId")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("RoleId");
+
+ b.ToTable("AspNetRoleClaims", "identity");
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ClaimType")
+ .HasColumnType("text");
+
+ b.Property("ClaimValue")
+ .HasColumnType("text");
+
+ b.Property("UserId")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("AspNetUserClaims", "identity");
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b =>
+ {
+ b.Property("LoginProvider")
+ .HasColumnType("text");
+
+ b.Property("ProviderKey")
+ .HasColumnType("text");
+
+ b.Property("ProviderDisplayName")
+ .HasColumnType("text");
+
+ b.Property("UserId")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("LoginProvider", "ProviderKey");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("AspNetUserLogins", "identity");
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("text");
+
+ b.Property("RoleId")
+ .HasColumnType("text");
+
+ b.HasKey("UserId", "RoleId");
+
+ b.HasIndex("RoleId");
+
+ b.ToTable("AspNetUserRoles", "identity");
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("text");
+
+ b.Property("LoginProvider")
+ .HasColumnType("text");
+
+ b.Property("Name")
+ .HasColumnType("text");
+
+ b.Property("Value")
+ .HasColumnType("text");
+
+ b.HasKey("UserId", "LoginProvider", "Name");
+
+ b.ToTable("AspNetUserTokens", "identity");
+ });
+
+ modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Branding.WhiteLabelSettings", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("integer");
+
+ b.Property("AccentColor")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("ApplicationName")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("FooterText")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)");
+
+ b.Property("LogoUrl")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)");
+
+ b.Property("PrimaryColor")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("SecondaryColor")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.ToTable("WhiteLabelSettings", "app");
+ });
+
+ modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Fleet.Customer", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("Code")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("FirstSeenAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("IsActive")
+ .HasColumnType("boolean");
+
+ b.Property("LastSeenAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("PreviousTokenExpiresAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("PreviousTokenHash")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)");
+
+ b.Property("TokenHash")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)");
+
+ b.Property("TokenIssuedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("TokenRotatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Code")
+ .IsUnique();
+
+ b.HasIndex("PreviousTokenHash");
+
+ b.HasIndex("TokenHash")
+ .IsUnique();
+
+ b.ToTable("Customers", "fleet");
+ });
+
+ modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Fleet.FleetDevice", b =>
+ {
+ b.Property("CustomerId")
+ .HasColumnType("uuid");
+
+ b.Property("Id")
+ .HasColumnType("uuid");
+
+ b.Property("Description")
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)");
+
+ b.Property("ExternalId")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("IsActive")
+ .HasColumnType("boolean");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("ReceivedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("SiteId")
+ .HasColumnType("uuid");
+
+ b.HasKey("CustomerId", "Id");
+
+ b.HasIndex("CustomerId", "SiteId");
+
+ b.ToTable("Devices", "fleet");
+ });
+
+ modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Fleet.FleetPowerMeasurement", b =>
+ {
+ b.Property("Time")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("CustomerId")
+ .HasColumnType("uuid");
+
+ b.Property("DeviceId")
+ .HasColumnType("uuid");
+
+ b.Property("ActivePowerKw")
+ .HasColumnType("double precision");
+
+ b.Property("ApparentPowerKva")
+ .HasColumnType("double precision");
+
+ b.Property("EnergyExportedKwh")
+ .HasColumnType("double precision");
+
+ b.Property("EnergyImportedKwh")
+ .HasColumnType("double precision");
+
+ b.Property("FrequencyHz")
+ .HasColumnType("double precision");
+
+ b.Property("PowerFactor")
+ .HasColumnType("double precision");
+
+ b.Property("ReactivePowerKvar")
+ .HasColumnType("double precision");
+
+ b.Property("Source")
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("VoltageV")
+ .HasColumnType("double precision");
+
+ b.HasKey("Time", "CustomerId", "DeviceId");
+
+ b.HasIndex("CustomerId", "Time")
+ .IsDescending(false, true);
+
+ b.HasIndex("CustomerId", "DeviceId", "Time")
+ .IsDescending(false, false, true);
+
+ b.ToTable("PowerMeasurements", "fleet");
+ });
+
+ modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Fleet.FleetSite", b =>
+ {
+ b.Property("CustomerId")
+ .HasColumnType("uuid");
+
+ b.Property("Id")
+ .HasColumnType("uuid");
+
+ b.Property("Address")
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)");
+
+ b.Property("IsActive")
+ .HasColumnType("boolean");
+
+ b.Property("LocalMunicipalityId")
+ .HasColumnType("integer");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("ReceivedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("CustomerId", "Id");
+
+ b.ToTable("Sites", "fleet");
+ });
+
+ modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Fleet.IngestEvent", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("BatchBytes")
+ .HasColumnType("integer");
+
+ b.Property("BatchType")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("ClientHwm")
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("CustomerId")
+ .HasColumnType("uuid");
+
+ b.Property("Error")
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)");
+
+ b.Property("ReceivedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("RowsAccepted")
+ .HasColumnType("integer");
+
+ b.Property("RowsRejected")
+ .HasColumnType("integer");
+
+ b.Property("TimeSpread")
+ .HasColumnType("interval");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CustomerId", "ReceivedAt")
+ .IsDescending(false, true);
+
+ b.ToTable("IngestEvents", "fleet");
+ });
+
+ modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Identity.ApplicationUser", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("text");
+
+ b.Property("AccessFailedCount")
+ .HasColumnType("integer");
+
+ b.Property("ConcurrencyStamp")
+ .IsConcurrencyToken()
+ .HasColumnType("text");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DisplayName")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("Email")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("EmailConfirmed")
+ .HasColumnType("boolean");
+
+ b.Property("IsActive")
+ .HasColumnType("boolean");
+
+ b.Property("LockoutEnabled")
+ .HasColumnType("boolean");
+
+ b.Property("LockoutEnd")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("NormalizedEmail")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("NormalizedUserName")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("PasswordHash")
+ .HasColumnType("text");
+
+ b.Property("PhoneNumber")
+ .HasColumnType("text");
+
+ b.Property("PhoneNumberConfirmed")
+ .HasColumnType("boolean");
+
+ b.Property("SecurityStamp")
+ .HasColumnType("text");
+
+ b.Property("TwoFactorEnabled")
+ .HasColumnType("boolean");
+
+ b.Property("UserName")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("NormalizedEmail")
+ .HasDatabaseName("EmailIndex");
+
+ b.HasIndex("NormalizedUserName")
+ .IsUnique()
+ .HasDatabaseName("UserNameIndex");
+
+ b.ToTable("AspNetUsers", "identity");
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b =>
+ {
+ b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
+ .WithMany()
+ .HasForeignKey("RoleId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b =>
+ {
+ b.HasOne("Tau.Acuvim.Portal.Domain.Identity.ApplicationUser", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b =>
+ {
+ b.HasOne("Tau.Acuvim.Portal.Domain.Identity.ApplicationUser", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b =>
+ {
+ b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
+ .WithMany()
+ .HasForeignKey("RoleId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("Tau.Acuvim.Portal.Domain.Identity.ApplicationUser", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b =>
+ {
+ b.HasOne("Tau.Acuvim.Portal.Domain.Identity.ApplicationUser", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Fleet.FleetDevice", b =>
+ {
+ b.HasOne("Tau.Acuvim.Portal.Domain.Fleet.Customer", "Customer")
+ .WithMany()
+ .HasForeignKey("CustomerId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("Tau.Acuvim.Portal.Domain.Fleet.FleetSite", "Site")
+ .WithMany()
+ .HasForeignKey("CustomerId", "SiteId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Customer");
+
+ b.Navigation("Site");
+ });
+
+ modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Fleet.FleetPowerMeasurement", b =>
+ {
+ b.HasOne("Tau.Acuvim.Portal.Domain.Fleet.FleetDevice", null)
+ .WithMany()
+ .HasForeignKey("CustomerId", "DeviceId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Fleet.FleetSite", b =>
+ {
+ b.HasOne("Tau.Acuvim.Portal.Domain.Fleet.Customer", "Customer")
+ .WithMany()
+ .HasForeignKey("CustomerId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Customer");
+ });
+
+ modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Fleet.IngestEvent", b =>
+ {
+ b.HasOne("Tau.Acuvim.Portal.Domain.Fleet.Customer", "Customer")
+ .WithMany()
+ .HasForeignKey("CustomerId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Customer");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/portal/src/Tau.Acuvim.Portal/Migrations/Admin/20260518084030_AddPreviousTokenGraceWindow.cs b/portal/src/Tau.Acuvim.Portal/Migrations/Admin/20260518084030_AddPreviousTokenGraceWindow.cs
new file mode 100644
index 0000000..81b45f5
--- /dev/null
+++ b/portal/src/Tau.Acuvim.Portal/Migrations/Admin/20260518084030_AddPreviousTokenGraceWindow.cs
@@ -0,0 +1,55 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace Tau.Acuvim.Portal.Migrations.Admin
+{
+ ///
+ public partial class AddPreviousTokenGraceWindow : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AddColumn(
+ name: "PreviousTokenExpiresAt",
+ schema: "fleet",
+ table: "Customers",
+ type: "timestamp with time zone",
+ nullable: true);
+
+ migrationBuilder.AddColumn(
+ name: "PreviousTokenHash",
+ schema: "fleet",
+ table: "Customers",
+ type: "character varying(64)",
+ maxLength: 64,
+ nullable: true);
+
+ migrationBuilder.CreateIndex(
+ name: "IX_Customers_PreviousTokenHash",
+ schema: "fleet",
+ table: "Customers",
+ column: "PreviousTokenHash");
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropIndex(
+ name: "IX_Customers_PreviousTokenHash",
+ schema: "fleet",
+ table: "Customers");
+
+ migrationBuilder.DropColumn(
+ name: "PreviousTokenExpiresAt",
+ schema: "fleet",
+ table: "Customers");
+
+ migrationBuilder.DropColumn(
+ name: "PreviousTokenHash",
+ schema: "fleet",
+ table: "Customers");
+ }
+ }
+}
diff --git a/portal/src/Tau.Acuvim.Portal/Migrations/Admin/AdminDbContextModelSnapshot.cs b/portal/src/Tau.Acuvim.Portal/Migrations/Admin/AdminDbContextModelSnapshot.cs
index fe14d3d..915ca35 100644
--- a/portal/src/Tau.Acuvim.Portal/Migrations/Admin/AdminDbContextModelSnapshot.cs
+++ b/portal/src/Tau.Acuvim.Portal/Migrations/Admin/AdminDbContextModelSnapshot.cs
@@ -226,6 +226,13 @@ namespace Tau.Acuvim.Portal.Migrations.Admin
.HasMaxLength(200)
.HasColumnType("character varying(200)");
+ b.Property("PreviousTokenExpiresAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("PreviousTokenHash")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)");
+
b.Property("TokenHash")
.IsRequired()
.HasMaxLength(64)
@@ -242,6 +249,8 @@ namespace Tau.Acuvim.Portal.Migrations.Admin
b.HasIndex("Code")
.IsUnique();
+ b.HasIndex("PreviousTokenHash");
+
b.HasIndex("TokenHash")
.IsUnique();
diff --git a/portal/src/Tau.Acuvim.Portal/Services/CustomerService.cs b/portal/src/Tau.Acuvim.Portal/Services/CustomerService.cs
index 232c6ad..dc5d41e 100644
--- a/portal/src/Tau.Acuvim.Portal/Services/CustomerService.cs
+++ b/portal/src/Tau.Acuvim.Portal/Services/CustomerService.cs
@@ -8,6 +8,11 @@ namespace Tau.Acuvim.Portal.Services;
public sealed class CustomerService(AdminDbContext db)
{
+ // Default grace window for the dual-token rotation flow. 24h gives customer ops
+ // a workday to update .env + restart without dropping pushes. Parameterizable
+ // on RotateTokenAsync for tests; can be lifted to config if you need to tune it.
+ public static readonly TimeSpan DefaultTokenGracePeriod = TimeSpan.FromHours(24);
+
public async Task> ListAsync(CancellationToken ct = default) =>
await db.Customers
.OrderBy(c => c.Code)
@@ -51,10 +56,23 @@ public sealed class CustomerService(AdminDbContext db)
return true;
}
- public async Task RotateTokenAsync(Guid id, CancellationToken ct = default)
+ public async Task RotateTokenAsync(
+ Guid id,
+ TimeSpan? graceWindow = null,
+ CancellationToken ct = default)
{
var c = await db.Customers.FindAsync(new object?[] { id }, ct);
if (c is null) return null;
+
+ // Move the existing token into the previous slot with an expiry timestamp.
+ // The previous slot is overwritten on every rotation — at most one previous
+ // token is honoured at a time.
+ if (!string.IsNullOrEmpty(c.TokenHash))
+ {
+ c.PreviousTokenHash = c.TokenHash;
+ c.PreviousTokenExpiresAt = DateTime.UtcNow + (graceWindow ?? DefaultTokenGracePeriod);
+ }
+
var token = GenerateToken();
c.TokenHash = HashToken(token);
c.TokenRotatedAt = DateTime.UtcNow;
@@ -75,7 +93,16 @@ public sealed class CustomerService(AdminDbContext db)
{
if (string.IsNullOrWhiteSpace(token)) return Task.FromResult(null);
var hash = HashToken(token);
- return db.Customers.FirstOrDefaultAsync(c => c.TokenHash == hash && c.IsActive, ct);
+ var now = DateTime.UtcNow;
+ // Accept either the current token OR an unexpired previous token. The DB
+ // indexes both columns so this OR-decomposes into two cheap index seeks.
+ return db.Customers.FirstOrDefaultAsync(
+ c => c.IsActive && (
+ c.TokenHash == hash ||
+ (c.PreviousTokenHash == hash &&
+ c.PreviousTokenExpiresAt != null &&
+ c.PreviousTokenExpiresAt > now)),
+ ct);
}
public static string GenerateToken()
@@ -97,5 +124,6 @@ public sealed class CustomerService(AdminDbContext db)
private static CustomerListItemDto ToDto(Customer c) => new(
c.Id, c.Code, c.Name, c.IsActive,
- c.TokenIssuedAt, c.TokenRotatedAt, c.FirstSeenAt, c.LastSeenAt, c.CreatedAt);
+ c.TokenIssuedAt, c.TokenRotatedAt, c.PreviousTokenExpiresAt,
+ c.FirstSeenAt, c.LastSeenAt, c.CreatedAt);
}
diff --git a/portal/tests/Tau.Acuvim.Portal.Tests/CustomerTokenGraceTests.cs b/portal/tests/Tau.Acuvim.Portal.Tests/CustomerTokenGraceTests.cs
new file mode 100644
index 0000000..0024ec4
--- /dev/null
+++ b/portal/tests/Tau.Acuvim.Portal.Tests/CustomerTokenGraceTests.cs
@@ -0,0 +1,104 @@
+using Microsoft.EntityFrameworkCore;
+using Tau.Acuvim.Portal.Data;
+using Tau.Acuvim.Portal.DTOs;
+using Tau.Acuvim.Portal.Services;
+
+namespace Tau.Acuvim.Portal.Tests;
+
+public class CustomerTokenGraceTests
+{
+ private static AdminDbContext NewDb()
+ {
+ var opts = new DbContextOptionsBuilder()
+ .UseInMemoryDatabase(databaseName: "grace-" + Guid.NewGuid())
+ .Options;
+ return new AdminDbContext(opts);
+ }
+
+ private static async Task<(CustomerService svc, Guid id, string originalToken)> SeedAsync()
+ {
+ var db = NewDb();
+ var svc = new CustomerService(db);
+ var created = await svc.CreateAsync(new CreateCustomerRequest("ABC0001", "Test"));
+ return (svc, created.Customer.Id, created.Token);
+ }
+
+ [Fact]
+ public async Task Create_DoesNotSetPreviousToken()
+ {
+ var (svc, id, _) = await SeedAsync();
+ var list = await svc.ListAsync();
+ var c = list.Single(x => x.Id == id);
+ Assert.Null(c.PreviousTokenExpiresAt);
+ }
+
+ [Fact]
+ public async Task Rotate_MovesCurrentTokenIntoPreviousSlot()
+ {
+ var (svc, id, originalToken) = await SeedAsync();
+
+ var rotated = await svc.RotateTokenAsync(id);
+ Assert.NotNull(rotated);
+
+ // Original token still accepted within grace window.
+ var found = await svc.FindByTokenAsync(originalToken);
+ Assert.NotNull(found);
+
+ // New token also accepted.
+ var newToken = rotated!.Token;
+ Assert.NotEqual(originalToken, newToken);
+ var foundNew = await svc.FindByTokenAsync(newToken);
+ Assert.NotNull(foundNew);
+
+ // PreviousTokenExpiresAt populated and in the near future.
+ var listed = (await svc.ListAsync()).Single(x => x.Id == id);
+ Assert.NotNull(listed.PreviousTokenExpiresAt);
+ Assert.True(listed.PreviousTokenExpiresAt > DateTime.UtcNow);
+ Assert.True(listed.PreviousTokenExpiresAt <= DateTime.UtcNow + CustomerService.DefaultTokenGracePeriod + TimeSpan.FromMinutes(1));
+ }
+
+ [Fact]
+ public async Task Rotate_WithZeroGrace_RejectsOriginalImmediately()
+ {
+ var (svc, id, originalToken) = await SeedAsync();
+
+ await svc.RotateTokenAsync(id, graceWindow: TimeSpan.Zero);
+
+ // PreviousExpiresAt is now-ish; the strict-greater-than check in FindByTokenAsync
+ // rejects it. So the original is dead.
+ var found = await svc.FindByTokenAsync(originalToken);
+ Assert.Null(found);
+ }
+
+ [Fact]
+ public async Task SecondRotation_OverwritesPreviousSlot_OriginalDies()
+ {
+ var (svc, id, originalToken) = await SeedAsync();
+
+ var firstRotation = await svc.RotateTokenAsync(id);
+ var secondRotation = await svc.RotateTokenAsync(id);
+
+ Assert.NotNull(firstRotation);
+ Assert.NotNull(secondRotation);
+
+ // Original is gone — the previous slot now holds the first-rotation token.
+ Assert.Null(await svc.FindByTokenAsync(originalToken));
+
+ // First-rotation token is still accepted (it's the new previous).
+ Assert.NotNull(await svc.FindByTokenAsync(firstRotation!.Token));
+
+ // Second-rotation token is accepted (current).
+ Assert.NotNull(await svc.FindByTokenAsync(secondRotation!.Token));
+ }
+
+ [Fact]
+ public async Task InactiveCustomer_RejectsBothCurrentAndPrevious()
+ {
+ var (svc, id, originalToken) = await SeedAsync();
+ var rotated = await svc.RotateTokenAsync(id);
+ await svc.UpdateAsync(id, new UpdateCustomerRequest("Test", IsActive: false));
+
+ Assert.Null(await svc.FindByTokenAsync(originalToken));
+ Assert.Null(await svc.FindByTokenAsync(rotated!.Token));
+ }
+}