Skip to content

Commit 545fcf0

Browse files
os-zhuangclaude
andcommitted
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>
1 parent 19fdc4b commit 545fcf0

4 files changed

Lines changed: 55 additions & 3 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: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,31 @@ describe('CloudConnectionPlugin credential behavior', () => {
235235
expect(store.read()).toBeNull();
236236
});
237237

238+
it('unbind keeps an identity residual: token cleared, runtimeId survives for the re-bind claim', async () => {
239+
const credPath = join(dir, 'cc.json');
240+
const store = new ConnectionCredentialStore(credPath);
241+
store.write({ runtimeToken: 'oscc_stored', runtimeId: 'rt-keep' });
242+
const rawApp = makeRawApp();
243+
const { ctx, fire } = makeCtx(rawApp, { auth: sessionAuth('admin'), manifest: { register: vi.fn() } });
244+
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ success: true }), { status: 200 })));
245+
await new CloudConnectionPlugin({
246+
singleEnvironment: true, controlPlaneUrl: 'http://cloud.test', controlPlaneApiKey: '', credentialPath: credPath,
247+
}).start(ctx as any);
248+
await fire();
249+
250+
const res = await rawApp.routes.get('POST /api/v1/cloud-connection/unbind')!(makeC('http://localhost:3000/x'));
251+
expect(res.payload.data.revoked).toBe(true);
252+
// Residual: identity kept, credential gone.
253+
const residual = store.read();
254+
expect(residual?.runtimeId).toBe('rt-keep');
255+
expect(residual?.runtimeToken).toBe('');
256+
257+
// Status reads the residual as UNBOUND (no credential) but surfaces the id.
258+
const status = await rawApp.routes.get('GET /api/v1/cloud-connection/status')!(makeC('http://localhost:3000/x'));
259+
expect(status.payload.data.bound).toBe(false);
260+
expect(status.payload.data.runtimeId).toBe('rt-keep');
261+
});
262+
238263
it('org-packages without an environment id forwards bearer-only (org from the connection)', async () => {
239264
const credPath = join(dir, 'cc.json');
240265
new ConnectionCredentialStore(credPath).write({ runtimeToken: 'oscc_stored', runtimeId: 'rt-1' });

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)