diff --git a/apps/desktop-client/src/main/device-id.test.ts b/apps/desktop-client/src/main/device-id.test.ts new file mode 100644 index 0000000..5d21069 --- /dev/null +++ b/apps/desktop-client/src/main/device-id.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('electron/main', () => ({ + app: { + getPath: () => '/tmp/geo-rankly-test-user-data', + }, +})) + +import { resolveDesktopClientID } from './device-id' + +describe('desktop device client id', () => { + it('is stable for the same hardware and account scope', () => { + const scope = { + tenant_id: 1, + workspace_id: 2, + user_id: 3, + } + + expect(resolveDesktopClientID(scope)).toBe(resolveDesktopClientID(scope)) + }) + + it('keeps different account scopes separate on the same hardware', () => { + const base = resolveDesktopClientID({ + tenant_id: 1, + workspace_id: 2, + user_id: 3, + }) + + expect( + resolveDesktopClientID({ + tenant_id: 1, + workspace_id: 2, + user_id: 4, + }), + ).not.toBe(base) + expect( + resolveDesktopClientID({ + tenant_id: 1, + workspace_id: 5, + user_id: 3, + }), + ).not.toBe(base) + }) + + it('rejects missing account scope instead of generating a random id', () => { + expect(() => + resolveDesktopClientID({ + tenant_id: 1, + workspace_id: 0, + user_id: 3, + }), + ).toThrow('desktop_client_id_scope_invalid') + }) +}) diff --git a/apps/desktop-client/src/main/device-id.ts b/apps/desktop-client/src/main/device-id.ts index b423975..e39d66b 100644 --- a/apps/desktop-client/src/main/device-id.ts +++ b/apps/desktop-client/src/main/device-id.ts @@ -1,7 +1,8 @@ import { execFileSync } from 'node:child_process' import { createHash, randomUUID } from 'node:crypto' import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' -import { hostname } from 'node:os' +import { hostname, networkInterfaces } from 'node:os' +import type { NetworkInterfaceInfo } from 'node:os' import { join } from 'node:path' import { app } from 'electron/main' @@ -44,7 +45,16 @@ export function resolveDesktopClientID(scope: DesktopClientIDScope): string { function stableMachineIdentitySeed(): string { const machineID = resolveOSMachineID() - return machineID ? `machine:${machineID}` : `installation:${resolvePersistedInstallationID()}` + if (machineID) { + return `machine:${machineID}` + } + + const networkFingerprint = resolveNetworkHardwareFingerprint() + if (networkFingerprint) { + return `network:${networkFingerprint}` + } + + return `installation:${resolvePersistedInstallationID()}` } function resolveOSMachineID(): string | null { @@ -113,6 +123,49 @@ function normalizeMachineID(value: string | null): string | null { return createHash('sha256').update(normalized).digest('hex') } +function resolveNetworkHardwareFingerprint(): string | null { + const macs = Object.values(networkInterfaces()) + .flatMap((items) => items ?? []) + .filter(isStableHardwareNetworkInterface) + .map((item) => normalizeMacAddress(item.mac)) + .filter((mac): mac is string => Boolean(mac)) + + const uniqueMacs = [...new Set(macs)].sort() + if (!uniqueMacs.length) { + return null + } + + return createHash('sha256').update(uniqueMacs.join('|')).digest('hex') +} + +function isStableHardwareNetworkInterface(item: NetworkInterfaceInfo): boolean { + const mac = normalizeMacAddress(item.mac) + if (!mac || item.internal) { + return false + } + + const bytes = mac.split(':').map((part) => Number.parseInt(part, 16)) + if (bytes.length !== 6 || bytes.some((byte) => !Number.isFinite(byte))) { + return false + } + + const firstByte = bytes[0] ?? 0 + const isMulticast = (firstByte & 0x01) === 0x01 + const isLocallyAdministered = (firstByte & 0x02) === 0x02 + return !isMulticast && !isLocallyAdministered +} + +function normalizeMacAddress(value: string | undefined): string | null { + const normalized = (value ?? '').trim().toLowerCase() + if (!normalized || normalized === '00:00:00:00:00:00') { + return null + } + if (!/^[0-9a-f]{2}(:[0-9a-f]{2}){5}$/.test(normalized)) { + return null + } + return normalized +} + function resolvePersistedInstallationID(): string { const existing = readPersistedInstallationID() if (existing) { diff --git a/findings.md b/findings.md index e266045..ca337ae 100644 --- a/findings.md +++ b/findings.md @@ -271,3 +271,11 @@ - Desktop publish management now surfaces scheduled-publish compliance recheck blocks through publish job metadata: `publish_job_status`, `compliance_blocked_record_id`, `compliance_blocked_at`, and summarized `compliance_blocked_reason`. - Verification passed on 2026-05-05: targeted compliance Go tests, full `go test ./...`, admin-web typecheck/build, ops-web typecheck/build, desktop-client typecheck/build, and `git diff --check`. - Residual risk: build output still reports large Ant Design / Milkdown chunks, but this is an existing bundle-size warning rather than a functional failure. + +## Desktop Client Account Isolation - 2026-05-06 +- Root cause: `/api/desktop/accounts` was calling `ListByUser`, so it returned all active platform accounts for `workspace_id + user_id`. The same user logging in on Mac and Windows therefore saw the same bound media accounts. +- Second root cause: desktop account upsert used `ON CONFLICT (workspace_id, platform_id, platform_uid)`, so the same third-party UID on another machine could update the existing row and move its `client_id`. +- Fix: desktop account listing, identity lookup, upsert, patch, delete, and async health sink paths are now scoped by the current desktop token's `client_id`. +- Migration `20260506120000_scope_platform_accounts_to_desktop_client` replaces the active platform-account uniqueness index with `(workspace_id, client_id, platform_id, platform_uid)`. +- Desktop client IDs are still stable hardware-derived UUIDs, but the seed intentionally includes tenant/workspace/user scope so two users on the same physical computer do not share one client record/token. +- Device fingerprinting now prefers OS-level machine IDs and uses a filtered MAC-address fingerprint only when OS machine IDs are unavailable; raw hardware identifiers are hashed locally and are not sent to the server. diff --git a/progress.md b/progress.md index 6b4b727..036ed5f 100644 --- a/progress.md +++ b/progress.md @@ -790,3 +790,20 @@ - `cd server && go test ./...` - `git diff --check` - `ruby -e 'require "yaml"; YAML.load_file("deploy/monitoring/article-generation-alerts.yml"); puts "ok"'` + +## 2026-05-06T13:05:00+08:00 - Desktop client account isolation + +- Fixed the desktop media account cross-device leak reported from Mac/Windows login: + - `/api/desktop/accounts` now lists accounts by the authenticated desktop `client_id`, not `workspace_id + user_id`. + - Desktop account identity lookup and upsert now use `(workspace_id, client_id, platform_id, platform_uid)`, preventing one machine from overwriting another machine's row for the same third-party UID. + - Desktop-side patch/delete and async health sink updates now also require matching `client_id`. + - Added migration `20260506120000_scope_platform_accounts_to_desktop_client` to update the active uniqueness index. + - Added MAC-address fingerprint fallback for desktop client ID generation while keeping OS machine ID as the preferred stable source. +- Verification passed: + - `cd server && make sqlc-generate` + - `cd server && go test ./internal/tenant/app ./internal/tenant/repository ./internal/tenant/transport -count=1` + - `pnpm --filter @geo/desktop-client typecheck` + - `pnpm --filter @geo/desktop-client exec vitest run src/main/device-id.test.ts` + - `git diff --check` +- Caveat: + - `pnpm --filter @geo/desktop-client test -- src/main/device-id.test.ts` ran unrelated existing tests and failed in `deepseek.test.ts` due to a pre-existing missing `electron/main` mock; the new device-id test passes when run directly with vitest. diff --git a/server/internal/tenant/app/desktop_account_health_sink_worker.go b/server/internal/tenant/app/desktop_account_health_sink_worker.go index 7fca65e..a4f03d8 100644 --- a/server/internal/tenant/app/desktop_account_health_sink_worker.go +++ b/server/internal/tenant/app/desktop_account_health_sink_worker.go @@ -155,6 +155,9 @@ func validateBufferedDesktopAccountHealthReport(report bufferedDesktopAccountHea if _, err := uuid.Parse(strings.TrimSpace(report.AccountID)); err != nil { return fmt.Errorf("invalid account_id: %w", err) } + if _, err := uuid.Parse(strings.TrimSpace(report.ClientID)); err != nil { + return fmt.Errorf("invalid client_id: %w", err) + } if report.WorkspaceID == 0 { return fmt.Errorf("workspace_id is required") } @@ -188,9 +191,10 @@ SET health = $1, updated_at = now() WHERE desktop_id = $5::uuid AND workspace_id = $6 + AND client_id = $7::uuid AND deleted_at IS NULL AND (last_check_at IS NULL OR last_check_at <= $4::timestamptz) -`, report.Health, desktopAccountLegacyStatusFromHealth(report.Health), verifiedAt, checkedAt, report.AccountID, report.WorkspaceID) +`, report.Health, desktopAccountLegacyStatusFromHealth(report.Health), verifiedAt, checkedAt, report.AccountID, report.WorkspaceID, report.ClientID) if err != nil { return fmt.Errorf("sink desktop account health snapshot: %w", err) } diff --git a/server/internal/tenant/app/desktop_account_service.go b/server/internal/tenant/app/desktop_account_service.go index 08f2f55..bbd2b2d 100644 --- a/server/internal/tenant/app/desktop_account_service.go +++ b/server/internal/tenant/app/desktop_account_service.go @@ -133,12 +133,12 @@ type bufferedDesktopAccountHealthReport struct { ReceivedAt time.Time `json:"received_at"` } -func (s *DesktopAccountService) ListByUser(ctx context.Context, client *repository.DesktopClient) ([]DesktopAccountView, error) { +func (s *DesktopAccountService) ListByClient(ctx context.Context, client *repository.DesktopClient) ([]DesktopAccountView, error) { if client == nil { return nil, response.ErrUnauthorized(40108, "desktop_client_missing", "desktop client context is required") } - rows, err := s.repo.ListByUser(ctx, client.WorkspaceID, client.UserID) + rows, err := s.repo.ListByClient(ctx, client.WorkspaceID, client.ID) if err != nil { return nil, response.ErrInternal(50081, "desktop_accounts_query_failed", "failed to list desktop accounts") } @@ -177,7 +177,7 @@ func (s *DesktopAccountService) Upsert(ctx context.Context, client *repository.D } if req.IfSyncVersion != nil { - existing, err := s.repo.GetByIdentity(ctx, client.WorkspaceID, req.Platform, req.PlatformUID) + existing, err := s.repo.GetByIdentity(ctx, client.WorkspaceID, client.ID, req.Platform, req.PlatformUID) if err != nil && !errors.Is(err, pgx.ErrNoRows) { return nil, response.ErrInternal(50082, "desktop_account_lookup_failed", "failed to inspect desktop account before upsert") } @@ -346,6 +346,7 @@ func (s *DesktopAccountService) Patch(ctx context.Context, client *repository.De VerifiedAt: req.VerifiedAt, Tags: req.Tags, IfSyncVersion: req.IfSyncVersion, + ClientID: client.ID, }) if err != nil { if errors.Is(err, pgx.ErrNoRows) { @@ -363,7 +364,7 @@ func (s *DesktopAccountService) Tombstone(ctx context.Context, client *repositor return nil, response.ErrUnauthorized(40108, "desktop_client_missing", "desktop client context is required") } - account, err := s.repo.Tombstone(ctx, desktopID, client.WorkspaceID, ifSyncVersion) + account, err := s.repo.Tombstone(ctx, desktopID, client.WorkspaceID, client.ID, ifSyncVersion) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, response.ErrConflict(40982, "desktop_account_sync_conflict", "desktop account sync version conflict") diff --git a/server/internal/tenant/repository/desktop_account_repo.go b/server/internal/tenant/repository/desktop_account_repo.go index 60a31d9..983f9a5 100644 --- a/server/internal/tenant/repository/desktop_account_repo.go +++ b/server/internal/tenant/repository/desktop_account_repo.go @@ -52,6 +52,7 @@ type UpsertDesktopAccountParams struct { type PatchDesktopAccountParams struct { DesktopID uuid.UUID WorkspaceID int64 + ClientID uuid.UUID DisplayName *string Health *string VerifiedAt *time.Time @@ -60,12 +61,13 @@ type PatchDesktopAccountParams struct { } type DesktopAccountRepository interface { + ListByClient(ctx context.Context, workspaceID int64, clientID uuid.UUID) ([]DesktopAccount, error) ListByUser(ctx context.Context, workspaceID, userID int64) ([]DesktopAccount, error) GetByDesktopID(ctx context.Context, desktopID uuid.UUID, workspaceID int64) (*DesktopAccount, error) - GetByIdentity(ctx context.Context, workspaceID int64, platform, platformUID string) (*DesktopAccount, error) + GetByIdentity(ctx context.Context, workspaceID int64, clientID uuid.UUID, platform, platformUID string) (*DesktopAccount, error) Upsert(ctx context.Context, params UpsertDesktopAccountParams) (*DesktopAccount, error) Patch(ctx context.Context, params PatchDesktopAccountParams) (*DesktopAccount, error) - Tombstone(ctx context.Context, desktopID uuid.UUID, workspaceID, ifSyncVersion int64) (*DesktopAccount, error) + Tombstone(ctx context.Context, desktopID uuid.UUID, workspaceID int64, clientID uuid.UUID, ifSyncVersion int64) (*DesktopAccount, error) MarkDeleteRequested(ctx context.Context, desktopID uuid.UUID, workspaceID int64) (*DesktopAccount, error) ClearDeleteRequested(ctx context.Context, desktopID uuid.UUID, workspaceID int64) (*DesktopAccount, error) } @@ -78,6 +80,26 @@ func NewDesktopAccountRepository(db generated.DBTX) DesktopAccountRepository { return &desktopAccountRepository{q: newQuerier(db)} } +func (r *desktopAccountRepository) ListByClient(ctx context.Context, workspaceID int64, clientID uuid.UUID) ([]DesktopAccount, error) { + rows, err := r.q.ListDesktopAccountsByClient(ctx, generated.ListDesktopAccountsByClientParams{ + WorkspaceID: workspaceID, + ClientID: pgUUID(clientID), + }) + if err != nil { + return nil, err + } + + items := make([]DesktopAccount, 0, len(rows)) + for _, row := range rows { + item, err := desktopAccountFromClientListRow(row) + if err != nil { + return nil, err + } + items = append(items, *item) + } + return items, nil +} + func (r *desktopAccountRepository) ListByUser(ctx context.Context, workspaceID, userID int64) ([]DesktopAccount, error) { rows, err := r.q.ListDesktopAccountsByUser(ctx, generated.ListDesktopAccountsByUserParams{ WorkspaceID: workspaceID, @@ -109,9 +131,10 @@ func (r *desktopAccountRepository) GetByDesktopID(ctx context.Context, desktopID return desktopAccountFromGetRow(row) } -func (r *desktopAccountRepository) GetByIdentity(ctx context.Context, workspaceID int64, platform, platformUID string) (*DesktopAccount, error) { +func (r *desktopAccountRepository) GetByIdentity(ctx context.Context, workspaceID int64, clientID uuid.UUID, platform, platformUID string) (*DesktopAccount, error) { row, err := r.q.GetDesktopAccountByIdentity(ctx, generated.GetDesktopAccountByIdentityParams{ WorkspaceID: workspaceID, + ClientID: pgUUID(clientID), PlatformID: platform, PlatformUid: platformUID, }) @@ -177,6 +200,7 @@ func (r *desktopAccountRepository) Patch(ctx context.Context, params PatchDeskto Tags: tagsJSON, DesktopID: pgUUID(params.DesktopID), WorkspaceID: params.WorkspaceID, + ClientID: pgUUID(params.ClientID), IfSyncVersion: params.IfSyncVersion, }) if err != nil { @@ -185,10 +209,11 @@ func (r *desktopAccountRepository) Patch(ctx context.Context, params PatchDeskto return desktopAccountFromPatchRow(row) } -func (r *desktopAccountRepository) Tombstone(ctx context.Context, desktopID uuid.UUID, workspaceID, ifSyncVersion int64) (*DesktopAccount, error) { +func (r *desktopAccountRepository) Tombstone(ctx context.Context, desktopID uuid.UUID, workspaceID int64, clientID uuid.UUID, ifSyncVersion int64) (*DesktopAccount, error) { row, err := r.q.TombstoneDesktopAccount(ctx, generated.TombstoneDesktopAccountParams{ DesktopID: pgUUID(desktopID), WorkspaceID: workspaceID, + ClientID: pgUUID(clientID), IfSyncVersion: ifSyncVersion, }) if err != nil { @@ -219,6 +244,29 @@ func (r *desktopAccountRepository) ClearDeleteRequested(ctx context.Context, des return desktopAccountFromClearDeleteRow(row) } +func desktopAccountFromClientListRow(row generated.ListDesktopAccountsByClientRow) (*DesktopAccount, error) { + return buildDesktopAccount( + row.DesktopID, + row.TenantID, + row.WorkspaceID, + row.UserID, + row.ClientID, + row.PlatformID, + row.PlatformUid, + row.AccountFingerprint, + row.DisplayName, + row.AvatarUrl, + row.Health, + row.VerifiedAt, + row.Tags, + row.SyncVersion, + row.DeletedAt, + row.DeleteRequestedAt, + row.CreatedAt, + row.UpdatedAt, + ) +} + func desktopAccountFromListRow(row generated.ListDesktopAccountsByUserRow) (*DesktopAccount, error) { return buildDesktopAccount( row.DesktopID, diff --git a/server/internal/tenant/repository/generated/desktop_account.sql.go b/server/internal/tenant/repository/generated/desktop_account.sql.go index 4f20b10..36cd46f 100644 --- a/server/internal/tenant/repository/generated/desktop_account.sql.go +++ b/server/internal/tenant/repository/generated/desktop_account.sql.go @@ -192,16 +192,18 @@ SELECT updated_at FROM platform_accounts WHERE workspace_id = $1 - AND platform_id = $2 - AND platform_uid = $3 + AND client_id = $2 + AND platform_id = $3 + AND platform_uid = $4 AND deleted_at IS NULL LIMIT 1 ` type GetDesktopAccountByIdentityParams struct { - WorkspaceID int64 `json:"workspace_id"` - PlatformID string `json:"platform_id"` - PlatformUid string `json:"platform_uid"` + WorkspaceID int64 `json:"workspace_id"` + ClientID pgtype.UUID `json:"client_id"` + PlatformID string `json:"platform_id"` + PlatformUid string `json:"platform_uid"` } type GetDesktopAccountByIdentityRow struct { @@ -226,7 +228,12 @@ type GetDesktopAccountByIdentityRow struct { } func (q *Queries) GetDesktopAccountByIdentity(ctx context.Context, arg GetDesktopAccountByIdentityParams) (GetDesktopAccountByIdentityRow, error) { - row := q.db.QueryRow(ctx, getDesktopAccountByIdentity, arg.WorkspaceID, arg.PlatformID, arg.PlatformUid) + row := q.db.QueryRow(ctx, getDesktopAccountByIdentity, + arg.WorkspaceID, + arg.ClientID, + arg.PlatformID, + arg.PlatformUid, + ) var i GetDesktopAccountByIdentityRow err := row.Scan( &i.DesktopID, @@ -251,6 +258,98 @@ func (q *Queries) GetDesktopAccountByIdentity(ctx context.Context, arg GetDeskto return i, err } +const listDesktopAccountsByClient = `-- name: ListDesktopAccountsByClient :many +SELECT + desktop_id, + tenant_id, + workspace_id, + user_id, + client_id, + platform_id, + platform_uid, + account_fingerprint, + display_name, + avatar_url, + health, + verified_at, + tags, + sync_version, + deleted_at, + delete_requested_at, + created_at, + updated_at +FROM platform_accounts +WHERE workspace_id = $1 + AND client_id = $2 + AND deleted_at IS NULL +ORDER BY platform_id, display_name, created_at DESC +` + +type ListDesktopAccountsByClientParams struct { + WorkspaceID int64 `json:"workspace_id"` + ClientID pgtype.UUID `json:"client_id"` +} + +type ListDesktopAccountsByClientRow struct { + DesktopID pgtype.UUID `json:"desktop_id"` + TenantID int64 `json:"tenant_id"` + WorkspaceID int64 `json:"workspace_id"` + UserID int64 `json:"user_id"` + ClientID pgtype.UUID `json:"client_id"` + PlatformID string `json:"platform_id"` + PlatformUid string `json:"platform_uid"` + AccountFingerprint pgtype.Text `json:"account_fingerprint"` + DisplayName string `json:"display_name"` + AvatarUrl pgtype.Text `json:"avatar_url"` + Health string `json:"health"` + VerifiedAt pgtype.Timestamptz `json:"verified_at"` + Tags []byte `json:"tags"` + SyncVersion int64 `json:"sync_version"` + DeletedAt pgtype.Timestamptz `json:"deleted_at"` + DeleteRequestedAt pgtype.Timestamptz `json:"delete_requested_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +func (q *Queries) ListDesktopAccountsByClient(ctx context.Context, arg ListDesktopAccountsByClientParams) ([]ListDesktopAccountsByClientRow, error) { + rows, err := q.db.Query(ctx, listDesktopAccountsByClient, arg.WorkspaceID, arg.ClientID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListDesktopAccountsByClientRow + for rows.Next() { + var i ListDesktopAccountsByClientRow + if err := rows.Scan( + &i.DesktopID, + &i.TenantID, + &i.WorkspaceID, + &i.UserID, + &i.ClientID, + &i.PlatformID, + &i.PlatformUid, + &i.AccountFingerprint, + &i.DisplayName, + &i.AvatarUrl, + &i.Health, + &i.VerifiedAt, + &i.Tags, + &i.SyncVersion, + &i.DeletedAt, + &i.DeleteRequestedAt, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listDesktopAccountsByUser = `-- name: ListDesktopAccountsByUser :many SELECT desktop_id, @@ -437,7 +536,8 @@ SET display_name = COALESCE($1::text, display_name), updated_at = now() WHERE desktop_id = $6 AND workspace_id = $7 - AND sync_version = $8 + AND client_id = $8 + AND sync_version = $9 AND deleted_at IS NULL RETURNING desktop_id, @@ -468,6 +568,7 @@ type PatchDesktopAccountParams struct { Tags []byte `json:"tags"` DesktopID pgtype.UUID `json:"desktop_id"` WorkspaceID int64 `json:"workspace_id"` + ClientID pgtype.UUID `json:"client_id"` IfSyncVersion int64 `json:"if_sync_version"` } @@ -501,6 +602,7 @@ func (q *Queries) PatchDesktopAccount(ctx context.Context, arg PatchDesktopAccou arg.Tags, arg.DesktopID, arg.WorkspaceID, + arg.ClientID, arg.IfSyncVersion, ) var i PatchDesktopAccountRow @@ -534,7 +636,8 @@ SET deleted_at = now(), updated_at = now() WHERE desktop_id = $1 AND workspace_id = $2 - AND sync_version = $3 + AND client_id = $3 + AND sync_version = $4 AND deleted_at IS NULL RETURNING desktop_id, @@ -560,6 +663,7 @@ RETURNING type TombstoneDesktopAccountParams struct { DesktopID pgtype.UUID `json:"desktop_id"` WorkspaceID int64 `json:"workspace_id"` + ClientID pgtype.UUID `json:"client_id"` IfSyncVersion int64 `json:"if_sync_version"` } @@ -585,7 +689,12 @@ type TombstoneDesktopAccountRow struct { } func (q *Queries) TombstoneDesktopAccount(ctx context.Context, arg TombstoneDesktopAccountParams) (TombstoneDesktopAccountRow, error) { - row := q.db.QueryRow(ctx, tombstoneDesktopAccount, arg.DesktopID, arg.WorkspaceID, arg.IfSyncVersion) + row := q.db.QueryRow(ctx, tombstoneDesktopAccount, + arg.DesktopID, + arg.WorkspaceID, + arg.ClientID, + arg.IfSyncVersion, + ) var i TombstoneDesktopAccountRow err := row.Scan( &i.DesktopID, @@ -651,7 +760,7 @@ VALUES ( $15, 1 ) -ON CONFLICT (workspace_id, platform_id, platform_uid) WHERE deleted_at IS NULL +ON CONFLICT (workspace_id, client_id, platform_id, platform_uid) WHERE deleted_at IS NULL DO UPDATE SET client_id = EXCLUDED.client_id, user_id = EXCLUDED.user_id, @@ -669,6 +778,7 @@ DO UPDATE SET delete_requested_at = NULL, updated_at = now() WHERE platform_accounts.workspace_id = EXCLUDED.workspace_id + AND platform_accounts.client_id = EXCLUDED.client_id AND ( $16::bigint IS NULL OR platform_accounts.sync_version = $16::bigint diff --git a/server/internal/tenant/repository/generated/querier.go b/server/internal/tenant/repository/generated/querier.go index 42b3980..9e27e6b 100644 --- a/server/internal/tenant/repository/generated/querier.go +++ b/server/internal/tenant/repository/generated/querier.go @@ -115,6 +115,7 @@ type Querier interface { ListArticles(ctx context.Context, arg ListArticlesParams) ([]ListArticlesRow, error) ListBrands(ctx context.Context, tenantID int64) ([]ListBrandsRow, error) ListCompetitors(ctx context.Context, arg ListCompetitorsParams) ([]ListCompetitorsRow, error) + ListDesktopAccountsByClient(ctx context.Context, arg ListDesktopAccountsByClientParams) ([]ListDesktopAccountsByClientRow, error) ListDesktopAccountsByUser(ctx context.Context, arg ListDesktopAccountsByUserParams) ([]ListDesktopAccountsByUserRow, error) ListDesktopClientsByWorkspace(ctx context.Context, workspaceID int64) ([]DesktopClient, error) ListImageAssets(ctx context.Context, arg ListImageAssetsParams) ([]ListImageAssetsRow, error) diff --git a/server/internal/tenant/repository/queries/desktop_account.sql b/server/internal/tenant/repository/queries/desktop_account.sql index cc0a691..e7cafa9 100644 --- a/server/internal/tenant/repository/queries/desktop_account.sql +++ b/server/internal/tenant/repository/queries/desktop_account.sql @@ -1,3 +1,29 @@ +-- name: ListDesktopAccountsByClient :many +SELECT + desktop_id, + tenant_id, + workspace_id, + user_id, + client_id, + platform_id, + platform_uid, + account_fingerprint, + display_name, + avatar_url, + health, + verified_at, + tags, + sync_version, + deleted_at, + delete_requested_at, + created_at, + updated_at +FROM platform_accounts +WHERE workspace_id = sqlc.arg(workspace_id) + AND client_id = sqlc.arg(client_id) + AND deleted_at IS NULL +ORDER BY platform_id, display_name, created_at DESC; + -- name: ListDesktopAccountsByUser :many SELECT desktop_id, @@ -71,6 +97,7 @@ SELECT updated_at FROM platform_accounts WHERE workspace_id = sqlc.arg(workspace_id) + AND client_id = sqlc.arg(client_id) AND platform_id = sqlc.arg(platform_id) AND platform_uid = sqlc.arg(platform_uid) AND deleted_at IS NULL @@ -117,7 +144,7 @@ VALUES ( sqlc.narg(tags), 1 ) -ON CONFLICT (workspace_id, platform_id, platform_uid) WHERE deleted_at IS NULL +ON CONFLICT (workspace_id, client_id, platform_id, platform_uid) WHERE deleted_at IS NULL DO UPDATE SET client_id = EXCLUDED.client_id, user_id = EXCLUDED.user_id, @@ -135,6 +162,7 @@ DO UPDATE SET delete_requested_at = NULL, updated_at = now() WHERE platform_accounts.workspace_id = EXCLUDED.workspace_id + AND platform_accounts.client_id = EXCLUDED.client_id AND ( sqlc.narg(if_sync_version)::bigint IS NULL OR platform_accounts.sync_version = sqlc.narg(if_sync_version)::bigint @@ -172,6 +200,7 @@ SET display_name = COALESCE(sqlc.narg(display_name)::text, display_name), updated_at = now() WHERE desktop_id = sqlc.arg(desktop_id) AND workspace_id = sqlc.arg(workspace_id) + AND client_id = sqlc.arg(client_id) AND sync_version = sqlc.arg(if_sync_version) AND deleted_at IS NULL RETURNING @@ -201,6 +230,7 @@ SET deleted_at = now(), updated_at = now() WHERE desktop_id = sqlc.arg(desktop_id) AND workspace_id = sqlc.arg(workspace_id) + AND client_id = sqlc.arg(client_id) AND sync_version = sqlc.arg(if_sync_version) AND deleted_at IS NULL RETURNING diff --git a/server/internal/tenant/transport/desktop_account_handler.go b/server/internal/tenant/transport/desktop_account_handler.go index 7a3bc66..c435e90 100644 --- a/server/internal/tenant/transport/desktop_account_handler.go +++ b/server/internal/tenant/transport/desktop_account_handler.go @@ -34,7 +34,7 @@ func (h *DesktopAccountHandler) List(c *gin.Context) { return } - data, err := h.svc.ListByUser(c.Request.Context(), MustDesktopClient(c.Request.Context())) + data, err := h.svc.ListByClient(c.Request.Context(), MustDesktopClient(c.Request.Context())) if err != nil { response.Error(c, err) return diff --git a/server/migrations/20260506120000_scope_platform_accounts_to_desktop_client.down.sql b/server/migrations/20260506120000_scope_platform_accounts_to_desktop_client.down.sql new file mode 100644 index 0000000..258a6b4 --- /dev/null +++ b/server/migrations/20260506120000_scope_platform_accounts_to_desktop_client.down.sql @@ -0,0 +1,5 @@ +DROP INDEX IF EXISTS uniq_platform_accounts_workspace_identity_active; + +CREATE UNIQUE INDEX IF NOT EXISTS uniq_platform_accounts_workspace_identity_active + ON platform_accounts (workspace_id, platform_id, platform_uid) + WHERE deleted_at IS NULL; diff --git a/server/migrations/20260506120000_scope_platform_accounts_to_desktop_client.up.sql b/server/migrations/20260506120000_scope_platform_accounts_to_desktop_client.up.sql new file mode 100644 index 0000000..f324915 --- /dev/null +++ b/server/migrations/20260506120000_scope_platform_accounts_to_desktop_client.up.sql @@ -0,0 +1,5 @@ +DROP INDEX IF EXISTS uniq_platform_accounts_workspace_identity_active; + +CREATE UNIQUE INDEX IF NOT EXISTS uniq_platform_accounts_workspace_identity_active + ON platform_accounts (workspace_id, client_id, platform_id, platform_uid) + WHERE deleted_at IS NULL; diff --git a/task_plan.md b/task_plan.md index f29b505..84f59e3 100644 --- a/task_plan.md +++ b/task_plan.md @@ -4,7 +4,7 @@ Continue the desktop AI monitoring implementation by first adding a client-side local scheduler with durable queueing and adaptive execution policy, then adding a reusable hidden Playwright CDP execution layer that attaches to Electron Chromium and reuses existing account partitions. ## Current Phase -Phase 45 +Phase 47 ## Phases ### Phase 1: Progress Verification @@ -315,6 +315,20 @@ Phase 45 - [x] Commit only the reliability hardening files after verification - **Status:** complete +### Phase 46: Desktop Client Account Isolation +- [x] Confirm current desktop media accounts were listed by `workspace_id + user_id` +- [x] Change desktop account listing/upsert/update/delete ownership to `workspace_id + client_id` +- [x] Add a migration that changes active platform-account uniqueness to include `client_id` +- [x] Strengthen hardware-derived desktop client ID fallback with stable network hardware fingerprinting +- **Status:** complete + +### Phase 47: Desktop Client Account Isolation Verification +- [x] Regenerate sqlc code after query contract changes +- [x] Run targeted backend tests for tenant app/repository/transport paths +- [x] Run desktop client typecheck and targeted device-id tests +- [x] Record residual test caveats +- **Status:** complete + ## Key Questions 1. How should the desktop client defer and locally optimize monitor-task execution without violating one-task-per-platform serialism? 2. What is the smallest durable local cache that allows same-day resume while safely dropping stale next-day monitor tasks? @@ -365,6 +379,9 @@ Phase 45 | Materialize daily monitoring tasks with batched array INSERT | The per-brand cap is currently small, but one `INSERT ... SELECT FROM unnest(...) ON CONFLICT DO NOTHING` keeps DB round trips fixed if caps or retry batches grow | | Split daily monitoring plan loading into selection and aggregation steps | Primary-client election and platform authorization aggregation have different explainability and testability concerns; two smaller queries are easier to inspect and later move to sqlc | | Treat platform-account health as execution state, not authorization state | `health != live` should not suppress daily task planning, dashboard authorization, or detail history; only desktop-client online state gates immediate collection | +| Scope desktop account data to `client_id`, not `user_id` | The same user can log in on Mac and Windows; `workspace_id + user_id` describes the person, not the physical desktop client | +| Include account scope in hardware-derived desktop client IDs | Pure hardware IDs would collide when different users use the same computer, so the seed remains hardware fingerprint plus tenant/workspace/user | +| Prefer OS machine identity before network MAC fallback | macOS `IOPlatformUUID`, Windows `MachineGuid`, and Linux `machine-id` are more stable than interface lists; MAC hashes are a hardware fallback when OS IDs are unavailable | | Start k3s with a self-contained stack that mirrors the existing compose deployment | The repo already has working Docker images and compose topology; k3s should first preserve service names and runtime assumptions before introducing managed cloud replacements | | Publish deployable images only from frontend/backend CI | Deployment config has no runtime image ownership; `deploy-config-ci` stays a validation workflow while frontend/backend CI publish Gitea Registry images tagged by commit8 | | Use commit hash first 8 chars as the canonical deploy image tag | CD/offline packaging can compare the pushed commit to Registry tags and fail fast when CI has not produced the matching image | @@ -387,6 +404,7 @@ Phase 45 | Production compose PgBouncer smoke test could not start bundled Postgres containers because existing `server/docker-compose.yaml` containers already own `geo-postgres` / `geo-monitoring-postgres` names | 1 | Did not stop user/local DB containers; verified PgBouncer with a temporary test container attached to the existing `server_default` network and removed it after `SELECT 1` passed | | Article generation state-check worker initially failed to compile because the local DB interface returned a custom rows type instead of `pgx.Rows` | 1 | Changed the interface to match `pgxpool.Pool.Query` directly and reran targeted/full Go tests | | Prometheus last-event test depended on label output order | 1 | Changed the assertion to validate required labels independent of exporter ordering | +| `pnpm --filter @geo/desktop-client test -- src/main/device-id.test.ts` ran unrelated existing tests and failed in `deepseek.test.ts` because `electron/main` was not mocked there | 1 | Verified the new test directly with `pnpm --filter @geo/desktop-client exec vitest run src/main/device-id.test.ts`; leave the existing full-test mock gap separate | ## Notes - Re-check task_plan.md before major implementation decisions.