diff --git a/package.json b/package.json index 120e0c936..8b137593f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@salesforce/packaging", - "version": "5.0.9-spi.0", + "version": "5.0.9-spi.1", "description": "Packaging library for the Salesforce packaging platform", "main": "lib/exported", "types": "lib/exported.d.ts", diff --git a/src/interfaces/packagingInterfacesAndType.ts b/src/interfaces/packagingInterfacesAndType.ts index 09a9079e9..26c1d203f 100644 --- a/src/interfaces/packagingInterfacesAndType.ts +++ b/src/interfaces/packagingInterfacesAndType.ts @@ -225,6 +225,24 @@ export type PackageTrustLinkRequestResult = { Status: string; }; +export type PackageTrustLinkStatusResult = { + // Current state of the authoring org's trust link: 'Not Linked' when no link exists, otherwise one + // of the trust-link statuses (Pending, Accepted, Declined, Revoked, Failed). + Status: string; + // Whether a trust link record exists for the authoring org. false for the 'Not Linked' state. + linked: boolean; + // Id of the trust link record, when one exists. + LinkRequestId?: string; + // Org ID of the Verified PBO the trust link points to, when one exists. + VerifiedOrgId?: string; + // When the link was requested (record created), when one exists. + RequestedDate?: string; + // When the link was accepted/established, when set. + EstablishedDate?: string; + // When the link was revoked, when set. + RevokedDate?: string; +}; + /** CLI --status values for `sf package trust link list` (PBO-admin). */ export type PackageTrustLinkListStatusFilter = 'pending' | 'approved' | 'declined' | 'revoked'; diff --git a/src/package/packageTrustLink.ts b/src/package/packageTrustLink.ts index 94ea4bd21..7840cd218 100644 --- a/src/package/packageTrustLink.ts +++ b/src/package/packageTrustLink.ts @@ -21,6 +21,7 @@ import { PackageTrustLinkRequestOptions, PackageTrustLinkRequestResult, PackageTrustLinkStatus, + PackageTrustLinkStatusResult, PackageTrustLinkUnlinkResult, } from '../interfaces'; import { combineSaveErrors } from '../utils/packageUtils'; @@ -53,10 +54,17 @@ const toApiStatus = (status: PackageTrustLinkListStatusFilter): PackageTrustLink return STATUS_FILTER_TO_API[status]; }; +// The absence of a trust link record is a valid state the CLI reports; the SObject Status +// picklist itself only covers the states an existing link can be in. +const NOT_LINKED = 'Not Linked'; + type TrustLinkRecord = { Id: string; VerifiedOrg: string; Status: string; + EstablishedDate: string | null; + RevokedDate: string | null; + CreatedDate: string | null; }; export class PackageTrustLink { @@ -111,6 +119,38 @@ export class PackageTrustLink { }; } + /** + * Report the connected authoring org's Public Secure (VerifiedDev) trust link state. + * + * Read-only developer/authoring-org side operation. An authoring org holds at most one trust + * relationship, so this returns that org's link if one exists — its status (`Pending`, `Accepted`, + * `Declined`, `Revoked`, or `Failed`) and the relevant timestamps — or the synthetic `Not Linked` + * state when the org has no link at all. It never mutates anything. + * + * @param connection - Connection to the authoring org (the 1GP namespace org or 2GP Dev Hub). + * @returns the current link state and, when a link exists, its Id, verified org ID, and timestamps. + */ + public static async status(connection: Connection): Promise { + // The Tooling API query runs against the connected authoring org (AuthoringOrg is server-set), + // so this returns that org's own link if any. No record means the org was never linked. + const existing = await queryExistingTrustLink(connection); + if (!existing) { + return { Status: NOT_LINKED, linked: false }; + } + + // Only surface timestamps that are actually set — a Pending link has no EstablishedDate, and + // only a Revoked link has a RevokedDate. Emitting undefined keys would leak nulls into --json. + return { + Status: existing.Status, + linked: true, + LinkRequestId: existing.Id, + VerifiedOrgId: existing.VerifiedOrg, + ...(existing.CreatedDate ? { RequestedDate: existing.CreatedDate } : {}), + ...(existing.EstablishedDate ? { EstablishedDate: existing.EstablishedDate } : {}), + ...(existing.RevokedDate ? { RevokedDate: existing.RevokedDate } : {}), + }; + } + /** * List inbound Public Secure trust-link requests for the connected Verified PBO. * @@ -188,7 +228,7 @@ export class PackageTrustLink { } async function queryExistingTrustLink(connection: Connection): Promise { - const query = `SELECT Id, VerifiedOrg, Status FROM ${TRUST_LINK_SOBJECT} LIMIT 1`; + const query = `SELECT Id, VerifiedOrg, Status, EstablishedDate, RevokedDate, CreatedDate FROM ${TRUST_LINK_SOBJECT} LIMIT 1`; const result = await connection.autoFetchQuery(query, { tooling: true }); return result.records?.[0]; } diff --git a/test/package/packageTrustLink.test.ts b/test/package/packageTrustLink.test.ts index 355fdc73e..4dd665e87 100644 --- a/test/package/packageTrustLink.test.ts +++ b/test/package/packageTrustLink.test.ts @@ -123,6 +123,99 @@ describe('PackageTrustLink', () => { }); }); + describe('status', () => { + it('reports Not Linked when the org has no trust link', async () => { + const autoFetchQuery = sinon.stub().resolves({ records: [] }); + const connection = createConnection({ autoFetchQuery }); + + const result = await PackageTrustLink.status(connection); + + expect(result).to.deep.equal({ Status: 'Not Linked', linked: false }); + }); + + it('reports the existing link status, verified org, and timestamps', async () => { + const autoFetchQuery = sinon.stub().resolves({ + records: [ + { + Id: trustLinkId, + VerifiedOrg: verifiedOrgId15, + Status: 'Accepted', + EstablishedDate: '2026-08-20T10:00:00.000+0000', + RevokedDate: null, + CreatedDate: '2026-08-19T09:00:00.000+0000', + }, + ], + }); + const connection = createConnection({ autoFetchQuery }); + + const result = await PackageTrustLink.status(connection); + + expect(result).to.deep.equal({ + Status: 'Accepted', + linked: true, + LinkRequestId: trustLinkId, + VerifiedOrgId: verifiedOrgId15, + RequestedDate: '2026-08-19T09:00:00.000+0000', + EstablishedDate: '2026-08-20T10:00:00.000+0000', + }); + }); + + it('omits unset timestamps (e.g. a Pending link never established)', async () => { + const autoFetchQuery = sinon.stub().resolves({ + records: [ + { + Id: trustLinkId, + VerifiedOrg: verifiedOrgId15, + Status: 'Pending', + EstablishedDate: null, + RevokedDate: null, + CreatedDate: '2026-08-19T09:00:00.000+0000', + }, + ], + }); + const connection = createConnection({ autoFetchQuery }); + + const result = await PackageTrustLink.status(connection); + + expect(result.Status).to.equal('Pending'); + expect(result.linked).to.equal(true); + expect(result.RequestedDate).to.equal('2026-08-19T09:00:00.000+0000'); + expect(result).to.not.have.property('EstablishedDate'); + expect(result).to.not.have.property('RevokedDate'); + }); + + it('surfaces a Revoked link with its revoked timestamp', async () => { + const autoFetchQuery = sinon.stub().resolves({ + records: [ + { + Id: trustLinkId, + VerifiedOrg: verifiedOrgId15, + Status: 'Revoked', + EstablishedDate: '2026-08-20T10:00:00.000+0000', + RevokedDate: '2026-08-25T12:00:00.000+0000', + CreatedDate: '2026-08-19T09:00:00.000+0000', + }, + ], + }); + const connection = createConnection({ autoFetchQuery }); + + const result = await PackageTrustLink.status(connection); + + expect(result.Status).to.equal('Revoked'); + expect(result.RevokedDate).to.equal('2026-08-25T12:00:00.000+0000'); + }); + + it('does not scope the query by verified org (an org holds at most one link)', async () => { + const autoFetchQuery = sinon.stub().resolves({ records: [] }); + const connection = createConnection({ autoFetchQuery }); + + await PackageTrustLink.status(connection); + + expect(autoFetchQuery.firstCall.args[0]).to.not.contain('WHERE'); + expect(autoFetchQuery.firstCall.args[1]).to.deep.equal({ tooling: true }); + }); + }); + describe('list', () => { const verifiedOrg15 = '00D000000000001'; const verifiedOrg18 = '00D000000000001EAA';