Skip to content

Commit d01c427

Browse files
xuyushun441-sysos-zhuangclaude
authored
feat(cloud-connection): unbind keeps an identity residual for re-bind continuity (#1797)
* feat(cloud-connection): unbind keeps an identity residual for re-bind continuity Disconnect clears the credential (revoking cloud-side first) but keeps {runtimeId} in the store — a later re-bind to the same org claims and revives the same registration (cloud claim rule reactivates the revoked row) instead of minting a new device per disconnect cycle. Same shape as an iCloud device record surviving sign-out. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: unbind expectation matches the identity-residual behavior Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 19fdc4b commit d01c427

4 files changed

Lines changed: 58 additions & 4 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@objectstack/cloud-connection": patch
3+
---
4+
5+
Unbind keeps an identity residual: the credential is cleared (and revoked cloud-side first) but `runtimeId` survives in the store, so a later re-bind to the same org claims — and revives — the same registration instead of minting a new device per disconnect cycle. `ConnectionCredentialStore.read()` accepts token-less residual records.

packages/cloud-connection/src/cloud-connection-plugin.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -416,7 +416,21 @@ export class CloudConnectionPlugin implements Plugin {
416416
revoked = resp.ok;
417417
} catch { /* control plane unreachable — local clear still proceeds */ }
418418
}
419-
const cleared = (() => { try { return this.store.clear(); } catch { return false; } })();
419+
// Clear the CREDENTIAL but keep the IDENTITY: a residual
420+
// {runtimeId} lets a later re-bind to the same org claim the
421+
// same registration (revive the revoked row) instead of
422+
// piling up a new device per disconnect cycle — same as an
423+
// iCloud device record surviving sign-out.
424+
const residualId = this.store.read()?.runtimeId;
425+
const cleared = (() => {
426+
try {
427+
if (residualId) {
428+
this.store.write({ runtimeToken: '', runtimeId: residualId });
429+
return true;
430+
}
431+
return this.store.clear();
432+
} catch { return false; }
433+
})();
420434
return c.json({ success: true, data: { environmentId: environmentId ?? null, revoked, cleared } });
421435
});
422436

packages/cloud-connection/src/connection-credential-store.test.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,34 @@ describe('CloudConnectionPlugin credential behavior', () => {
232232
expect(res.payload.data).toEqual({ environmentId: null, revoked: true, cleared: true });
233233
expect((fetchSpy.mock.calls[0]![1] as any).body).toBe('{}');
234234
expect((fetchSpy.mock.calls[0]![1] as any).headers.Authorization).toBe('Bearer oscc_stored');
235-
expect(store.read()).toBeNull();
235+
// Credential gone; identity residual kept for the re-bind claim.
236+
expect(store.read()?.runtimeToken).toBe('');
237+
expect(store.read()?.runtimeId).toBe('rt-1');
238+
});
239+
240+
it('unbind keeps an identity residual: token cleared, runtimeId survives for the re-bind claim', async () => {
241+
const credPath = join(dir, 'cc.json');
242+
const store = new ConnectionCredentialStore(credPath);
243+
store.write({ runtimeToken: 'oscc_stored', runtimeId: 'rt-keep' });
244+
const rawApp = makeRawApp();
245+
const { ctx, fire } = makeCtx(rawApp, { auth: sessionAuth('admin'), manifest: { register: vi.fn() } });
246+
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ success: true }), { status: 200 })));
247+
await new CloudConnectionPlugin({
248+
singleEnvironment: true, controlPlaneUrl: 'http://cloud.test', controlPlaneApiKey: '', credentialPath: credPath,
249+
}).start(ctx as any);
250+
await fire();
251+
252+
const res = await rawApp.routes.get('POST /api/v1/cloud-connection/unbind')!(makeC('http://localhost:3000/x'));
253+
expect(res.payload.data.revoked).toBe(true);
254+
// Residual: identity kept, credential gone.
255+
const residual = store.read();
256+
expect(residual?.runtimeId).toBe('rt-keep');
257+
expect(residual?.runtimeToken).toBe('');
258+
259+
// Status reads the residual as UNBOUND (no credential) but surfaces the id.
260+
const status = await rawApp.routes.get('GET /api/v1/cloud-connection/status')!(makeC('http://localhost:3000/x'));
261+
expect(status.payload.data.bound).toBe(false);
262+
expect(status.payload.data.runtimeId).toBe('rt-keep');
236263
});
237264

238265
it('org-packages without an environment id forwards bearer-only (org from the connection)', async () => {

packages/cloud-connection/src/connection-credential-store.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,12 +60,20 @@ export class ConnectionCredentialStore {
6060
: resolve(process.cwd(), DEFAULT_CONNECTION_CREDENTIAL_PATH);
6161
}
6262

63-
/** Read the stored credential; null when absent or unreadable. */
63+
/**
64+
* Read the stored credential; null when absent or unreadable.
65+
*
66+
* An IDENTITY RESIDUAL — `runtimeToken: ''` with a `runtimeId` — is a
67+
* valid record: unbind leaves one behind so a later re-bind to the same
68+
* org claims the same registration (ADR runtime-identity-binding §2.1).
69+
* Callers already treat the empty token as "no credential".
70+
*/
6471
read(): StoredConnectionCredential | null {
6572
if (!existsSync(this.path)) return null;
6673
try {
6774
const parsed = JSON.parse(readFileSync(this.path, 'utf8'));
68-
if (!parsed || typeof parsed.runtimeToken !== 'string' || !parsed.runtimeToken) return null;
75+
if (!parsed || typeof parsed.runtimeToken !== 'string') return null;
76+
if (!parsed.runtimeToken && !(typeof parsed.runtimeId === 'string' && parsed.runtimeId)) return null;
6977
return parsed as StoredConnectionCredential;
7078
} catch {
7179
return null;

0 commit comments

Comments
 (0)