diff --git a/docs/online-inbox-api-contract.md b/docs/online-inbox-api-contract.md
index 377ee06..48c34ff 100644
--- a/docs/online-inbox-api-contract.md
+++ b/docs/online-inbox-api-contract.md
@@ -24,7 +24,17 @@ Sync directions (each one-way per entity → no conflict resolution needed):
- **Idle tasks**: desktop mirrors its Idle backlog up; the web can create new ones, which the
desktop pulls down and then owns.
-Single user. Both the desktop and the web client authenticate as the **same Zitadel user**.
+Single user today. Both the desktop and the web client authenticate as the **same Zitadel
+user**.
+
+**Multi-user readiness (`ownerId`).** Each resource is owned by a Zitadel subject (`sub`).
+`RemoteList`, `RemoteTask`, and `MirrorTask` carry an optional `ownerId` field. The desktop
+stamps its own `sub` (decoded from the access token) onto everything it pushes, and
+defensively ignores any pulled task whose `ownerId` is set to a *different* user; an absent
+`ownerId` is treated as unowned/legacy and still syncs. This keeps the contract ready for
+multiple users **without enforcing isolation client-side** — the server remains the
+authority that scopes every request by the token's `sub`. When the server goes multi-user it
+should partition all rows by owner and ignore (or validate) the client-supplied `ownerId`.
**Access control (as of 2026-06-10).** Access is granted by assigning the **"user" project
role** in the Zitadel project "ClaudeDo" (id `376787351902355727`, issuer
@@ -94,13 +104,17 @@ surfaces "missing 'user' role in Zitadel" and pauses sync until the user signs i
| Method & path | Caller | Body | Response |
|---|---|---|---|
-| `PUT /lists` | desktop | `[{ "id", "name" }]` — the FULL catalog | `200` |
-| `GET /lists` | web | — | `200 [{ "id", "name" }]` |
+| `PUT /lists` | desktop | `[{ "id", "name", "ownerId"? }]` — the FULL catalog | `200` |
+| `GET /lists` | web | — | `200 [{ "id", "name", "ownerId"? }]` |
| `GET /lists/{id}/tasks` | web | — | `200` tasks in that list (`404` if list unknown) |
| `POST /tasks` | web | `{ "title", "description"?, "listId" }` | `201` created task incl. `id` |
-| `GET /tasks?imported=false` | desktop | — | `200 [{ "id","listId","title","description","createdAt" }]` |
+| `GET /tasks?imported=false` | desktop | — | `200 [{ "id","listId","title","description","createdAt","ownerId"? }]` |
| `POST /tasks/{id}/imported` | desktop | — | `200` (`404` if unknown) |
-| `PUT /tasks/mirror` | desktop | `[{ "id","listId","title","description" }]` — full Idle set | `200` |
+| `PUT /tasks/mirror` | desktop | `[{ "id","listId","title","description","ownerId"? }]` — full Idle set | `200` |
+
+`ownerId` (optional, see §1) is the Zitadel `sub` of the owner. The desktop sends it on push
+and ignores pulled tasks owned by a different user; the server should derive/validate it from
+the token rather than trust the client value.
Semantics:
diff --git a/src/ClaudeDo.Worker/Online/Dtos.cs b/src/ClaudeDo.Worker/Online/Dtos.cs
index 8eccb61..77e1627 100644
--- a/src/ClaudeDo.Worker/Online/Dtos.cs
+++ b/src/ClaudeDo.Worker/Online/Dtos.cs
@@ -2,19 +2,26 @@ using System.Text.Json.Serialization;
namespace ClaudeDo.Worker.Online;
+// OwnerId carries the resource owner's Zitadel subject (sub). It is nullable and optional so
+// the contract stays multi-user-ready without changing single-user behavior: today the desktop
+// stamps it on push and defensively ignores pulled tasks owned by a different user, while the
+// server remains the authority that scopes data by the token's sub.
public sealed record RemoteList(
[property: JsonPropertyName("id")] string Id,
- [property: JsonPropertyName("name")] string Name);
+ [property: JsonPropertyName("name")] string Name,
+ [property: JsonPropertyName("ownerId")] string? OwnerId = null);
public sealed record RemoteTask(
[property: JsonPropertyName("id")] string Id,
[property: JsonPropertyName("listId")] string ListId,
[property: JsonPropertyName("title")] string Title,
[property: JsonPropertyName("description")] string? Description,
- [property: JsonPropertyName("createdAt")] DateTimeOffset CreatedAt);
+ [property: JsonPropertyName("createdAt")] DateTimeOffset CreatedAt,
+ [property: JsonPropertyName("ownerId")] string? OwnerId = null);
public sealed record MirrorTask(
[property: JsonPropertyName("id")] string Id,
[property: JsonPropertyName("listId")] string ListId,
[property: JsonPropertyName("title")] string Title,
- [property: JsonPropertyName("description")] string? Description);
+ [property: JsonPropertyName("description")] string? Description,
+ [property: JsonPropertyName("ownerId")] string? OwnerId = null);
diff --git a/src/ClaudeDo.Worker/Online/JwtClaims.cs b/src/ClaudeDo.Worker/Online/JwtClaims.cs
new file mode 100644
index 0000000..ab9f8ef
--- /dev/null
+++ b/src/ClaudeDo.Worker/Online/JwtClaims.cs
@@ -0,0 +1,49 @@
+using System.Text.Json;
+
+namespace ClaudeDo.Worker.Online;
+
+///
+/// Minimal, dependency-free reader for a JWT access token's payload claims. Used to resolve the
+/// current user's Zitadel subject (sub) so sync payloads can be stamped with an owner.
+/// Never throws — returns null when the token is absent or cannot be parsed.
+///
+public static class JwtClaims
+{
+ ///
+ /// Returns the sub claim of the JWT, or null if the token is absent/unparseable or
+ /// carries no subject.
+ ///
+ public static string? GetSubject(string? jwt)
+ {
+ if (string.IsNullOrWhiteSpace(jwt))
+ return null;
+
+ var parts = jwt.Split('.');
+ if (parts.Length < 2)
+ return null;
+
+ try
+ {
+ using var doc = JsonDocument.Parse(Base64UrlDecode(parts[1]));
+ if (doc.RootElement.TryGetProperty("sub", out var sub) &&
+ sub.ValueKind == JsonValueKind.String)
+ return sub.GetString();
+ return null;
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ private static byte[] Base64UrlDecode(string input)
+ {
+ var s = input.Replace('-', '+').Replace('_', '/');
+ switch (s.Length % 4)
+ {
+ case 2: s += "=="; break;
+ case 3: s += "="; break;
+ }
+ return Convert.FromBase64String(s);
+ }
+}
diff --git a/src/ClaudeDo.Worker/Online/OnlineSyncService.cs b/src/ClaudeDo.Worker/Online/OnlineSyncService.cs
index 383b0c0..091765a 100644
--- a/src/ClaudeDo.Worker/Online/OnlineSyncService.cs
+++ b/src/ClaudeDo.Worker/Online/OnlineSyncService.cs
@@ -73,6 +73,10 @@ public sealed class OnlineSyncService : BackgroundService
return;
}
+ // Resolve the current user's Zitadel subject so sync payloads carry an owner and pulls
+ // can be guarded. Null today (single user / server derives it from the token).
+ var ownerId = JwtClaims.GetSubject(token);
+
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
var tasks = new TaskRepository(ctx);
var lists = new ListRepository(ctx);
@@ -81,6 +85,15 @@ public sealed class OnlineSyncService : BackgroundService
var unimported = await _api.GetUnimportedTasksAsync(ct);
foreach (var remote in unimported)
{
+ // Multi-user guard: never import a task explicitly owned by a different user.
+ // Unowned tasks (ownerId == null) stay importable so single-user behavior is intact.
+ if (ownerId is not null && remote.OwnerId is not null && remote.OwnerId != ownerId)
+ {
+ _logger.LogWarning(
+ "OnlineSyncService: remote task {Id} is owned by another user; skipping", remote.Id);
+ continue;
+ }
+
var existing = await tasks.GetByIdAsync(remote.Id, ct);
if (existing is not null)
{
@@ -115,13 +128,15 @@ public sealed class OnlineSyncService : BackgroundService
_logger.LogInformation("OnlineSyncService: imported task {Id} ('{Title}')", remote.Id, remote.Title);
}
- // Step 2: push full list catalog.
+ // Step 2: push full list catalog, stamped with the owner.
var allLists = await lists.GetAllAsync(ct);
- var remoteLists = allLists.Select(l => new RemoteList(l.Id, l.Name)).ToList();
+ var remoteLists = allLists.Select(l => new RemoteList(l.Id, l.Name, ownerId)).ToList();
await _api.PutListsAsync(remoteLists, ct);
- // Step 3: push current Idle backlog mirror.
- var mirror = await OnlineBacklog.CurrentAsync(tasks, ct);
+ // Step 3: push current Idle backlog mirror, stamped with the owner.
+ var mirror = (await OnlineBacklog.CurrentAsync(tasks, ct))
+ .Select(m => m with { OwnerId = ownerId })
+ .ToList();
await _api.PutMirrorAsync(mirror, ct);
}
}
diff --git a/tests/ClaudeDo.Worker.Tests/Online/JwtClaimsTests.cs b/tests/ClaudeDo.Worker.Tests/Online/JwtClaimsTests.cs
new file mode 100644
index 0000000..d769b76
--- /dev/null
+++ b/tests/ClaudeDo.Worker.Tests/Online/JwtClaimsTests.cs
@@ -0,0 +1,40 @@
+using System.Text;
+using ClaudeDo.Worker.Online;
+
+namespace ClaudeDo.Worker.Tests.Online;
+
+public sealed class JwtClaimsTests
+{
+ // Builds a fake JWT (header.payload.signature) carrying the given JSON payload.
+ internal static string MakeToken(string payloadJson)
+ {
+ static string B64Url(string s) =>
+ Convert.ToBase64String(Encoding.UTF8.GetBytes(s))
+ .TrimEnd('=').Replace('+', '-').Replace('/', '_');
+ return $"{B64Url("{\"alg\":\"RS256\"}")}.{B64Url(payloadJson)}.sig";
+ }
+
+ [Fact]
+ public void GetSubject_ReturnsSub()
+ {
+ var token = MakeToken("{\"sub\":\"user-123\",\"email\":\"a@b.c\"}");
+ Assert.Equal("user-123", JwtClaims.GetSubject(token));
+ }
+
+ [Fact]
+ public void GetSubject_Null_WhenNoSub()
+ {
+ var token = MakeToken("{\"email\":\"a@b.c\"}");
+ Assert.Null(JwtClaims.GetSubject(token));
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData("not-a-jwt")]
+ [InlineData("opaque.token")]
+ public void GetSubject_Null_ForUnparseableInput(string? token)
+ {
+ Assert.Null(JwtClaims.GetSubject(token));
+ }
+}
diff --git a/tests/ClaudeDo.Worker.Tests/Online/OnlineSyncServiceTests.cs b/tests/ClaudeDo.Worker.Tests/Online/OnlineSyncServiceTests.cs
index a5c2535..15288d6 100644
--- a/tests/ClaudeDo.Worker.Tests/Online/OnlineSyncServiceTests.cs
+++ b/tests/ClaudeDo.Worker.Tests/Online/OnlineSyncServiceTests.cs
@@ -209,6 +209,76 @@ public sealed class OnlineSyncServiceTests : IDisposable
Assert.Equal(0, api.CallCount);
}
+ // ---- multi-user: owner stamping + guard ----
+
+ [Fact]
+ public async Task Tick_StampsOwnerId_OnPushedListsAndMirror()
+ {
+ var (listId, ctx, tasks, _) = await SeedAsync();
+ using var _ = ctx;
+ await tasks.AddAsync(new TaskEntity
+ {
+ Id = Guid.NewGuid().ToString(), ListId = listId, Title = "Idle",
+ Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow,
+ });
+
+ var token = JwtClaimsTests.MakeToken("{\"sub\":\"owner-1\"}");
+ var api = new FakeApi();
+ var svc = BuildService(api, token);
+
+ await svc.TickAsync(CancellationToken.None);
+
+ Assert.All(api.ReceivedLists, l => Assert.Equal("owner-1", l.OwnerId));
+ Assert.NotEmpty(api.ReceivedMirror);
+ Assert.All(api.ReceivedMirror, m => Assert.Equal("owner-1", m.OwnerId));
+ }
+
+ [Fact]
+ public async Task Tick_SkipsRemoteTask_OwnedByAnotherUser()
+ {
+ var (listId, ctx, tasks, _) = await SeedAsync();
+ using var _ = ctx;
+
+ var foreignId = Guid.NewGuid().ToString();
+ var api = new FakeApi
+ {
+ UnimportedTasks =
+ [
+ new RemoteTask(foreignId, listId, "Theirs", null, DateTimeOffset.UtcNow, OwnerId: "owner-2"),
+ ],
+ };
+ var svc = BuildService(api, JwtClaimsTests.MakeToken("{\"sub\":\"owner-1\"}"));
+
+ await svc.TickAsync(CancellationToken.None);
+
+ Assert.Null(await tasks.GetByIdAsync(foreignId));
+ Assert.DoesNotContain(foreignId, api.MarkedImported);
+ }
+
+ [Fact]
+ public async Task Tick_Imports_OwnTask_And_UnownedTask()
+ {
+ var (listId, ctx, tasks, _) = await SeedAsync();
+ using var _ = ctx;
+
+ var mine = Guid.NewGuid().ToString();
+ var unowned = Guid.NewGuid().ToString();
+ var api = new FakeApi
+ {
+ UnimportedTasks =
+ [
+ new RemoteTask(mine, listId, "Mine", null, DateTimeOffset.UtcNow, OwnerId: "owner-1"),
+ new RemoteTask(unowned, listId, "Unowned", null, DateTimeOffset.UtcNow),
+ ],
+ };
+ var svc = BuildService(api, JwtClaimsTests.MakeToken("{\"sub\":\"owner-1\"}"));
+
+ await svc.TickAsync(CancellationToken.None);
+
+ Assert.NotNull(await tasks.GetByIdAsync(mine));
+ Assert.NotNull(await tasks.GetByIdAsync(unowned));
+ }
+
// ---- already-imported task on server ----
[Fact]