Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/unbind-identity-residual.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@objectstack/cloud-connection": patch
---

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.
16 changes: 15 additions & 1 deletion packages/cloud-connection/src/cloud-connection-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -416,7 +416,21 @@ export class CloudConnectionPlugin implements Plugin {
revoked = resp.ok;
} catch { /* control plane unreachable — local clear still proceeds */ }
}
const cleared = (() => { try { return this.store.clear(); } catch { return false; } })();
// Clear the CREDENTIAL but keep the IDENTITY: a residual
// {runtimeId} lets a later re-bind to the same org claim the
// same registration (revive the revoked row) instead of
// piling up a new device per disconnect cycle — same as an
// iCloud device record surviving sign-out.
const residualId = this.store.read()?.runtimeId;
const cleared = (() => {
try {
if (residualId) {
this.store.write({ runtimeToken: '', runtimeId: residualId });
return true;
}
return this.store.clear();
} catch { return false; }
})();
return c.json({ success: true, data: { environmentId: environmentId ?? null, revoked, cleared } });
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,34 @@ describe('CloudConnectionPlugin credential behavior', () => {
expect(res.payload.data).toEqual({ environmentId: null, revoked: true, cleared: true });
expect((fetchSpy.mock.calls[0]![1] as any).body).toBe('{}');
expect((fetchSpy.mock.calls[0]![1] as any).headers.Authorization).toBe('Bearer oscc_stored');
expect(store.read()).toBeNull();
// Credential gone; identity residual kept for the re-bind claim.
expect(store.read()?.runtimeToken).toBe('');
expect(store.read()?.runtimeId).toBe('rt-1');
});

it('unbind keeps an identity residual: token cleared, runtimeId survives for the re-bind claim', async () => {
const credPath = join(dir, 'cc.json');
const store = new ConnectionCredentialStore(credPath);
store.write({ runtimeToken: 'oscc_stored', runtimeId: 'rt-keep' });
const rawApp = makeRawApp();
const { ctx, fire } = makeCtx(rawApp, { auth: sessionAuth('admin'), manifest: { register: vi.fn() } });
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ success: true }), { status: 200 })));
await new CloudConnectionPlugin({
singleEnvironment: true, controlPlaneUrl: 'http://cloud.test', controlPlaneApiKey: '', credentialPath: credPath,
}).start(ctx as any);
await fire();

const res = await rawApp.routes.get('POST /api/v1/cloud-connection/unbind')!(makeC('http://localhost:3000/x'));
expect(res.payload.data.revoked).toBe(true);
// Residual: identity kept, credential gone.
const residual = store.read();
expect(residual?.runtimeId).toBe('rt-keep');
expect(residual?.runtimeToken).toBe('');

// Status reads the residual as UNBOUND (no credential) but surfaces the id.
const status = await rawApp.routes.get('GET /api/v1/cloud-connection/status')!(makeC('http://localhost:3000/x'));
expect(status.payload.data.bound).toBe(false);
expect(status.payload.data.runtimeId).toBe('rt-keep');
});

it('org-packages without an environment id forwards bearer-only (org from the connection)', async () => {
Expand Down
12 changes: 10 additions & 2 deletions packages/cloud-connection/src/connection-credential-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,12 +60,20 @@ export class ConnectionCredentialStore {
: resolve(process.cwd(), DEFAULT_CONNECTION_CREDENTIAL_PATH);
}

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