@@ -431,6 +431,25 @@ export interface ShareLinkServiceOptions {
431431 recordId : string ,
432432 context : ExecutionContext ,
433433 ) => Promise < boolean > ;
434+ /**
435+ * [ADR-0111 D8 rule 1 — ruling 5950188467, A′] Late-bound mint-authority
436+ * probe (the sharing service's `canMintWithoutVisibility`): may the caller
437+ * mint on a record their visibility read refused, because they are its
438+ * OWNER or hold the explicit Modify-All bypass? `createLink` asks it only
439+ * after that read refused. It answers with `canManageShares`' own owner and
440+ * bypass branches and never with its hierarchy-depth branch, and it is
441+ * withheld where an organization wall is in force.
442+ *
443+ * Absent → visibility alone admits (the pre-A′ rule), so a deployment
444+ * without the sharing service never widens who may mint. A throwing probe
445+ * is a refusal. It receives the caller's COMPLETE envelope, like every
446+ * enforcement probe here.
447+ */
448+ canMintWithoutVisibility ?: (
449+ object : string ,
450+ recordId : string ,
451+ context : ExecutionContext ,
452+ ) => Promise < boolean > ;
434453 /** [#5190] Optional logger for the record-delete cascade / orphan sweep. */
435454 logger ?: {
436455 info ?: ( msg : any , ...rest : any [ ] ) => void ;
@@ -474,6 +493,7 @@ export class ShareLinkService implements IShareLinkService {
474493 recordId : string ,
475494 context : ExecutionContext ,
476495 ) => Promise < boolean > ;
496+ private readonly canMintWithoutVisibility ?: ShareLinkServiceOptions [ 'canMintWithoutVisibility' ] ;
477497 private readonly logger ?: ShareLinkServiceOptions [ 'logger' ] ;
478498 /**
479499 * [#12981] Latched by the FIRST refused usage stamp on this instance and
@@ -488,6 +508,7 @@ export class ShareLinkService implements IShareLinkService {
488508 this . hashPassword = opts . hashPassword ?? defaultHashPassword ;
489509 this . verifyPassword = opts . verifyPassword ?? defaultVerifyPassword ;
490510 this . canManageShares = opts . canManageShares ;
511+ this . canMintWithoutVisibility = opts . canMintWithoutVisibility ;
491512 this . logger = opts . logger ;
492513 }
493514
@@ -501,11 +522,13 @@ export class ShareLinkService implements IShareLinkService {
501522 const schema = this . engine . getSchema ?.( input . object ) ;
502523 const policy = getPolicy ( schema ) ;
503524
504- // [ADR-0111 D8] Mint authority = the object's `publicSharing` opt-in (this
505- // check) AND the caller's visibility of the record (the RLS-scoped read
506- // below). An object that opts into publicSharing deliberately delegates
507- // re-share power to anyone who can SEE the record — a stated decision, not
508- // an accident. Objects that do not opt in cannot be link-shared at all.
525+ // [ADR-0111 D8 rule 1] Mint authority = the object's `publicSharing` opt-in
526+ // (this check, FIRST) AND — after the request-shape checks below — the
527+ // caller's authority over the record: visibility (the RLS-scoped read), or
528+ // the record owner, or an explicit Modify-All bypass (ruling 5950188467,
529+ // A′). An object that opts into publicSharing deliberately delegates
530+ // re-share power to anyone who can SEE the record. Objects that do not opt
531+ // in cannot be link-shared at all, by their owner included.
509532 if ( ! policy . enabled && ! this . permissive && ! context . isSystem ) {
510533 throw makeError (
511534 422 ,
@@ -537,10 +560,11 @@ export class ShareLinkService implements IShareLinkService {
537560 }
538561
539562 // Confirm the target record exists AND — for an HTTP caller — that the
540- // caller may actually SEE it. [Finding-2] Reading under the caller's own
541- // context (positions/permissions/RLS) means you can only mint a link for a
542- // record you can access; a client can no longer share arbitrary rows of a
543- // publicSharing-enabled object it cannot see. Internal (isSystem) callers
563+ // caller holds authority over it. [Finding-2] Reading under the caller's
564+ // own context (positions/permissions/RLS) means a client can no longer
565+ // share arbitrary rows of a publicSharing-enabled object it cannot see;
566+ // the only callers who mint past a refused read are the record's owner and
567+ // an explicit Modify-All holder (A′, below). Internal (isSystem) callers
544568 // read under the system context as before.
545569 //
546570 // [commit 8e13ca876] `context` is passed through UNCHANGED — it is the caller's whole
@@ -556,32 +580,59 @@ export class ShareLinkService implements IShareLinkService {
556580 // a second query being issued, and it widens ONLY then, so an object
557581 // without the key keeps the exact `id`-only read it always had.
558582 const eligibility = policy . enabled ? policy . eligibility : undefined ;
559- const exists = await this . engine . find ( input . object , {
560- where : { id : input . recordId } ,
561- ...( eligibility ? { } : { fields : [ 'id' ] } ) ,
562- limit : 1 ,
563- context : context . isSystem ? SYSTEM_CTX : context ,
564- } as any ) ;
565- if ( ! Array . isArray ( exists ) || exists . length === 0 ) {
583+ const readRecord = async ( readContext : ExecutionContext | typeof SYSTEM_CTX ) => {
584+ const rows = await this . engine . find ( input . object , {
585+ where : { id : input . recordId } ,
586+ ...( eligibility ? { } : { fields : [ 'id' ] } ) ,
587+ limit : 1 ,
588+ context : readContext ,
589+ } as any ) ;
590+ return Array . isArray ( rows ) ? ( rows [ 0 ] as Record < string , unknown > | undefined ) : undefined ;
591+ } ;
592+
593+ // Visibility first. A refusal the read THROWS (the CRUD gate's, on an
594+ // object the caller holds no read grant on) is kept, not swallowed: it is
595+ // re-thrown below unless an alternative admits, so a caller refused before
596+ // A′ is refused with the same envelope after it.
597+ let record : Record < string , unknown > | undefined ;
598+ let visibilityRefusal : unknown ;
599+ try {
600+ record = await readRecord ( context . isSystem ? SYSTEM_CTX : context ) ;
601+ } catch ( err ) {
602+ if ( context . isSystem ) throw err ;
603+ visibilityRefusal = err ;
604+ }
605+
606+ // [ruling 5950188467, A′] The owner and Modify-All alternatives, asked only
607+ // once visibility has refused. The probe is the sharing service's
608+ // `canMintWithoutVisibility`: `canManageShares`' owner and bypass branches
609+ // WITHOUT its hierarchy-depth branch — a hierarchy manager still needs
610+ // visibility to mint — and withheld under an organization wall. Admitted,
611+ // the record is read under the system context: the caller's authority is
612+ // established, and the eligibility gate below must judge the row the
613+ // anonymous holder will be served (`resolveToken` reads it the same way).
614+ if ( ! record && ! context . isSystem && this . canMintWithoutVisibility ) {
615+ const admitted = await this . canMintWithoutVisibility ( input . object , input . recordId , context )
616+ . catch ( ( ) => false ) ;
617+ if ( admitted ) record = await readRecord ( SYSTEM_CTX ) ;
618+ }
619+
620+ if ( ! record ) {
621+ if ( visibilityRefusal !== undefined ) throw visibilityRefusal ;
566622 // Don't distinguish "missing" from "not visible" for an untrusted caller.
567623 throw context . isSystem
568624 ? makeError ( 404 , 'RECORD_NOT_FOUND' , `${ input . object } /${ input . recordId } does not exist` )
569625 : makeError ( 403 , 'FORBIDDEN' , `Not permitted to share ${ input . object } /${ input . recordId } ` ) ;
570626 }
571627
572- // [#7861] The declared eligibility gate. Placed AFTER the visibility read
573- // (it needs the record, and a caller who cannot see the row must not learn
574- // anything about its contents from the refusal) and BEFORE the insert, so
575- // an ineligible link is never MINTED — which is the only placement that
576- // helps, since `resolveToken` serves an existing row anonymously under
577- // `SYSTEM_CTX` with no auth check to fall back on.
628+ // [#7861] The declared eligibility gate. Placed AFTER the authority check
629+ // (it needs the record, and a caller with no authority over the row must
630+ // not learn anything about its contents from the refusal) and BEFORE the
631+ // insert, so an ineligible link is never MINTED — which is the only
632+ // placement that helps, since `resolveToken` serves an existing row
633+ // anonymously under `SYSTEM_CTX` with no auth check to fall back on.
578634 if ( eligibility ) {
579- assertEligible (
580- eligibility ,
581- ( exists [ 0 ] ?? { } ) as Record < string , unknown > ,
582- schema ,
583- input . object ,
584- ) ;
635+ assertEligible ( eligibility , record , schema , input . object ) ;
585636 }
586637
587638 const maxDays = policy . maxExpiryDays ?? DEFAULT_MAX_EXPIRY_DAYS ;
0 commit comments