@@ -5,7 +5,7 @@ import fs from 'node:fs'
55import { atomicWriteJsonSync , isPlainObject , sha256Hex } from 'hypaware/core/util'
66
77/**
8- * @import { AcquireSource, PersistedIdentity } from './types.js'
8+ * @import { AcquireSource, PersistedIdentity, RemoteDestination } from './types.js'
99 */
1010
1111/**
@@ -110,6 +110,14 @@ export class IdentityClient {
110110 )
111111 }
112112 this . identity = persisted
113+ // An identity written before destination-scoped progress has no stable
114+ // organization beside it. Refresh once against the upgraded server
115+ // before any sink state is selected, then persist the authoritative org.
116+ // @ref LLP 0315#destination-identity [implements]: legacy identities acquire the server-assigned org before export progress is bound to a destination
117+ if ( persisted . org === undefined ) {
118+ await this . refresh ( )
119+ return 'refreshed'
120+ }
113121 const remainingSec = persisted . expires_at - Math . floor ( this . now ( ) / 1000 )
114122 if ( remainingSec <= REFRESH_WINDOW_SECONDS ) {
115123 await this . refresh ( )
@@ -200,13 +208,23 @@ export class IdentityClient {
200208 throw new Error ( `identity refresh failed: ${ await readErrorDetail ( response ) } ` )
201209 }
202210 const parsed = await readJsonResponse ( response , 'refresh' )
203- const identity = identityFromPayload ( parsed , this . identity . gateway_id )
211+ const previous = this . identity
212+ const identity = identityFromPayload ( parsed , previous . gateway_id )
213+ // A credential rotation must not silently move a running sink to another
214+ // destination. A legacy identity has no prior org and adopts the response;
215+ // every subsequent refresh must preserve it exactly.
216+ // @ref LLP 0315#destination-identity [constrained-by]: credential refresh preserves destination identity; an org change requires a new enrollment and state scope
217+ if ( previous . org !== undefined && identity . org !== previous . org ) {
218+ throw new Error (
219+ `identity refresh failed: central server changed organization from '${ previous . org } ' to '${ identity . org } '`
220+ )
221+ }
204222 // Preserve the mint provenance across refresh; the bootstrap token is
205223 // typically absent in steady state, so re-derive it from the prior
206224 // persisted identity rather than recomputing.
207- identity . central_url = this . identity . central_url
208- identity . bootstrap_token_fp = this . identity . bootstrap_token_fp
209- if ( this . identity . origin !== undefined ) identity . origin = this . identity . origin
225+ identity . central_url = previous . central_url
226+ identity . bootstrap_token_fp = previous . bootstrap_token_fp
227+ if ( previous . origin !== undefined ) identity . origin = previous . origin
210228 this . identity = identity
211229 writePersistedFile ( this . persistedPath , identity )
212230 }
@@ -230,6 +248,21 @@ export class IdentityClient {
230248 }
231249 return this . identity . jwt
232250 }
251+
252+ /**
253+ * Return the authenticated destination identity used to scope export state.
254+ * `acquire()` must run first so a legacy identity has already refreshed its
255+ * missing organization.
256+ *
257+ * @returns {RemoteDestination }
258+ */
259+ getDestination ( ) {
260+ if ( ! this . identity || this . identity . org === undefined ) {
261+ throw new Error ( 'identity destination not acquired - call acquire() first' )
262+ }
263+ // @ref LLP 0315#destination-identity [implements]: progress keys on canonical server origin plus stable organization, never gateway credentials
264+ return { origin : new URL ( this . centralUrl ) . origin , org : this . identity . org }
265+ }
233266}
234267
235268/**
@@ -255,7 +288,7 @@ function readPersistedFile(filePath) {
255288 if ( ! isPlainObject ( parsed ) ) {
256289 throw new Error ( `persisted identity ${ filePath } must be an object` )
257290 }
258- const { jwt, expires_at, gateway_id, central_url, bootstrap_token_fp, origin } =
291+ const { jwt, expires_at, gateway_id, org , central_url, bootstrap_token_fp, origin } =
259292 /** @type {Record<string, unknown> } */ ( parsed )
260293 if ( typeof jwt !== 'string' || jwt . length === 0 ) {
261294 throw new Error ( `persisted identity ${ filePath } : missing or invalid jwt` )
@@ -268,6 +301,10 @@ function readPersistedFile(filePath) {
268301 }
269302 /** @type {PersistedIdentity } */
270303 const identity = { jwt, expires_at, gateway_id }
304+ if ( org !== undefined && typeof org !== 'string' ) {
305+ throw new Error ( `persisted identity ${ filePath } : invalid org` )
306+ }
307+ if ( typeof org === 'string' ) identity . org = org
271308 if ( typeof central_url === 'string' ) identity . central_url = central_url
272309 if ( typeof bootstrap_token_fp === 'string' ) identity . bootstrap_token_fp = bootstrap_token_fp
273310 if ( origin === 'login' ) identity . origin = origin
@@ -328,18 +365,21 @@ function identityFromPayload(parsed, fallbackGatewayId) {
328365 if ( ! isPlainObject ( parsed ) ) {
329366 throw new Error ( 'central server response is not an object' )
330367 }
331- const { jwt, expires_at } = /** @type {Record<string, unknown> } */ ( parsed )
368+ const { jwt, expires_at, org } = /** @type {Record<string, unknown> } */ ( parsed )
332369 if ( typeof jwt !== 'string' || jwt . length === 0 ) {
333370 throw new Error ( 'central server response missing jwt' )
334371 }
335372 if ( typeof expires_at !== 'number' || ! Number . isInteger ( expires_at ) ) {
336373 throw new Error ( 'central server response missing expires_at' )
337374 }
375+ if ( typeof org !== 'string' ) {
376+ throw new Error ( 'central server response missing org' )
377+ }
338378 const gateway_id = decodeJwtSub ( jwt ) ?? fallbackGatewayId
339379 if ( typeof gateway_id !== 'string' || gateway_id . length === 0 ) {
340380 throw new Error ( 'central server response missing gateway identity (sub claim)' )
341381 }
342- return { jwt, expires_at, gateway_id }
382+ return { jwt, expires_at, gateway_id, org }
343383}
344384
345385/**
0 commit comments