Implement per-user scoped organization storage - #1886
Conversation
There was a problem hiding this comment.
Pull request overview
This PR updates Jetstream Desktop’s persisted org storage to be per-user scoped (instead of a single shared orgs.json), and adds a one-time migration path so existing installs can move legacy data into the correct user-scoped file. This aligns the on-disk org data model with per-user encryption keys and avoids cross-account data loss on shared machines.
Changes:
- Introduce user-scoped org storage files (
orgs-<hash>.json) and requireuserId + encryptionKeyto bind org persistence to a specific user. - Add legacy migration from the pre-scope shared
orgs.jsoninto the signed-in user’s scoped file (with safeStorage-token re-encryption when needed). - Update IPC login/auth flows and expand Vitest coverage for multi-account isolation + migration scenarios.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| apps/jetstream-desktop/src/services/persistence.service.ts | Implements per-user org file scoping, legacy migration, and updated read/write guards around user-bound storage. |
| apps/jetstream-desktop/src/services/ipc.service.ts | Passes userId alongside the encryption key when initializing org persistence after auth. |
| apps/jetstream-desktop/src/services/tests/persistence.service.spec.ts | Updates and expands tests to cover scoped filenames, multi-account isolation, and legacy file migration. |
8a54466 to
4d16a51
Compare
4d16a51 to
aad5f47
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (1)
apps/jetstream-desktop/src/services/persistence.service.ts:498
- When quarantining a corrupt per-user orgs file, the backup path is created via rename only. If the file’s permissions were too loose (e.g. restored from a backup or created by an older build), the
.corrupt-*copy will retain those mode bits and can remain readable by other local accounts. Consider chmod’ing the backup best-effort to SECURE_FILE_MODE after the rename succeeds.
renameSync(ORG_FILE_PATH, corruptBackupPath);
logger.info(`Backed up unreadable orgs file to ${corruptBackupPath}`);
aad5f47 to
abe6b1b
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (1)
apps/jetstream-desktop/src/services/persistence.service.ts:150
- The migration log message claims the legacy orgs file "belongs to a different account" whenever decryption fails. For legacy safeStorage files, a decrypt failure can also mean the file was encrypted on a different machine/VDI session or is corrupted, so this message is misleading for troubleshooting. Consider logging a message that distinguishes portable-vs-safeStorage failure reasons (while still leaving the file untouched).
} catch (decryptError) {
logger.info('Legacy orgs file did not decrypt for this user — it belongs to a different account, leaving it untouched', decryptError);
return;
abe6b1b to
eab0c1c
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.
Suppressed comments (1)
apps/jetstream-desktop/src/services/persistence.service.ts:88
- The thrown error for a missing userId is labeled as an "org encryption key" problem, but the actual invalid input is the missing userId. This makes logs/tests harder to interpret when org storage isn’t scoped correctly.
export function bindOrgStorageToUser({ userId, encryptionKey }: { userId: string; encryptionKey: string }): void {
if (!userId) {
throw new Error('Invalid org encryption key: a userId is required to resolve the user-scoped org file');
}
eab0c1c to
5544086
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
apps/jetstream-desktop/src/services/persistence.service.ts:33
- The comment for LEGACY_SFDC_ORGS_FILE says the legacy orgs.json is "read once per user", but the implementation retries adoption on later cold-cache reads when the scoped file still doesn’t exist (e.g. decrypt failure, transient read error, or file belonging to another account). Updating this comment will avoid misleading future maintainers about the retry behavior.
/**
* Pre-user-scoping org file, shared by every account that signed in on this machine.
* Read once per user for migration, never written to again.
*/
const LEGACY_SFDC_ORGS_FILE = join(userData, 'orgs.json');
5544086 to
36c8604
Compare
|
Went through Copilot's suppressed (low-confidence) comments — one was valid:
The rest didn't apply: the |
36c8604 to
d78acbd
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
apps/jetstream-desktop/src/services/persistence.service.ts:510
- If the legacy shared orgs file exists but cannot be decrypted (wrong per-user key, safeStorage DPAPI change, or corruption), the code intentionally leaves it in place — but it may also retain overly-permissive mode bits forever because nothing will ever rewrite it. Consider best-effort chmod’ing the legacy file to 0600 on this path to prevent other local accounts from reading the encrypted token material, without changing the contents.
? 'Legacy orgs file did not decrypt for this user — it belongs to a different account, leaving it untouched'
: 'Legacy safeStorage orgs file did not decrypt — the OS credential store cannot read it (different machine or VDI session, or corruption), leaving it untouched',
decryptError,
);
return null;
|
Went through Copilot's suppressed (low-confidence) comments — one was valid:
The earlier suppressed findings (the |
d78acbd to
baea20c
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
apps/jetstream-desktop/src/services/persistence.service.ts:423
readOrgs()now returns empty any time org storage is not yet bound (ORG_SESSIONis null). This means that on cold start wherecheckAuthhits a network error (and intentionally keeps the cached session), the app will still have a validuserProfile/accessTokenbut orgs/groups will remain empty even for installs that still have a legacy safeStorageorgs.jsonthat could be decrypted locally without the portable key. Confirm this loss of offline/local-read behavior is intentional, since it can make the desktop app appear "logged in" but with no org state until the server is reachable again.
const session = ORG_SESSION;
// No authenticated user yet (cold start before auth completes). There is nothing to read
// and nothing can be decrypted without the key — the caller retries once auth lands.
if (!session) {
logger.info('Orgs requested before org storage is bound to a user — returning empty');
return { jetstreamOrganizations: [], salesforceOrgs: [] };
}
baea20c to
a13d581
Compare
|
Went through Copilot's suppressed (low-confidence) comments across all the review rounds — one was worth acting on:
The rest were already handled: the |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.
Suppressed comments (1)
apps/jetstream-desktop/src/services/persistence.service.ts:35
- This comment says the legacy orgs file is retired once its contents are "confirmed" in the scoped file, but the migration flow only checks that the scoped file exists (it doesn’t verify the decrypted contents match) before renaming the legacy file. Either add a verification step or adjust the comment to match the current behavior so future maintainers don’t assume stronger guarantees than implemented.
* subsequent read, not just once. It is retired to a timestamped backup once its contents are
* confirmed in the scoped file; see `adoptLegacyOrgsFile` and `retireLegacyOrgsFile`.
a13d581 to
8b0d2d4
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
Suppressed comments (2)
apps/jetstream-desktop/src/services/ipc.service.ts:482
- Same as the login path:
encryptionKeyis mandatory on a successful/desktop-app/auth/verifyresponse perAuthResponseSuccessSchema, so the conditional check is redundant. Binding unconditionally also makes it harder to end up in a “signed in but org storage not bound” state if the response shape changes unexpectedly.
if (successResponse.encryptionKey) {
dataService.bindOrgStorageToUser({ userId: successResponse.userProfile.id, encryptionKey: successResponse.encryptionKey });
}
apps/jetstream-desktop/src/services/ipc.service.ts:152
successResponse.encryptionKeyis required byAuthResponseSuccessSchema(z.string().length(64)), so this conditional is redundant and can hide future contract regressions (the app would stay signed in but org storage would remain unbound). Bind org storage unconditionally on the success path.
This issue also appears on line 480 of the same file.
if (successResponse.encryptionKey) {
dataService.bindOrgStorageToUser({ userId: successResponse.userProfile.id, encryptionKey: successResponse.encryptionKey });
}
|
Copilot's suppressed comments this round were both the same finding, and both were right:
Also closed out the |
8b0d2d4 to
9856ef9
Compare
Introduce per-user scoped organization storage and migrate legacy organization files to accommodate this change. This enhances data management by associating encryption keys with individual users.