UNPKG

@bsv/wallet-toolbox

Version:

BRC100 conforming wallet, wallet storage and wallet signer components

1,116 lines 180 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.WalletPermissionsManager = void 0; const sdk_1 = require("@bsv/sdk"); const brc114ActionTimeLabels_1 = require("./utility/brc114ActionTimeLabels"); ////// TODO: ADD SUPPORT FOR ADMIN COUNTERPARTIES BASED ON WALLET STORAGE ////// PROHIBITION OF SPECIAL OPERATIONS IS ALSO CRITICAL. ////// !!!!!!!! SECURITY-CRITICAL ADDITION — DO NOT USE UNTIL IMPLEMENTED. function deepEqual(object1, object2) { if (object1 === null || object1 === undefined || object2 === null || object2 === undefined) { return object1 === object2; } const keys1 = Object.keys(object1); const keys2 = Object.keys(object2); if (keys1.length !== keys2.length) { return false; } for (const key of keys1) { const val1 = object1[key]; const val2 = object2[key]; const areObjects = isObject(val1) && isObject(val2); if ((areObjects && !deepEqual(val1, val2)) || (!areObjects && val1 !== val2)) { return false; } } return true; } function isObject(object) { return object != null && typeof object === 'object'; } /** * A map from each permission type to a special "admin basket" name used for storing * the tokens. The tokens themselves are unspent transaction outputs (UTXOs) with a * specialized PushDrop script that references the originator, expiry, etc. */ const BASKET_MAP = { protocol: 'admin protocol-permission', basket: 'admin basket-access', certificate: 'admin certificate-access', spending: 'admin spending-authorization' }; /** * @class WalletPermissionsManager * * Wraps an underlying BRC-100 `Wallet` implementation with permissions management capabilities. * The manager intercepts calls from external applications (identified by originators), checks if the request is allowed, * and if not, orchestrates user permission flows. It creates or renews on-chain tokens in special * admin baskets to track these authorizations. Finally, it proxies the actual call to the underlying wallet. * * ### Key Responsibilities: * - **Permission Checking**: Before standard wallet operations (e.g. `encrypt`), * the manager checks if a valid permission token exists. If not, it attempts to request permission from the user. * - **On-Chain Tokens**: When permission is granted, the manager stores it as an unspent "PushDrop" output. * This can be spent later to revoke or renew the permission. * - **Callbacks**: The manager triggers user-defined callbacks on permission requests (to show a UI prompt), * on grants/denials, and on internal processes. * * ### Implementation Notes: * - The manager follows the BRC-100 `createAction` + `signAction` pattern for building or spending these tokens. * - Token revocation or renewal uses standard BRC-100 flows: we build a transaction that consumes * the old token UTXO and outputs a new one (or none, if fully revoked). */ class WalletPermissionsManager { /** * Constructs a new Permissions Manager instance. * * @param underlyingWallet The underlying BRC-100 wallet, where requests are forwarded after permission is granted * @param adminOriginator The domain or FQDN that is automatically allowed everything * @param config A set of boolean flags controlling how strictly permissions are enforced */ constructor(underlyingWallet, adminOriginator, config = {}) { /** * Event callbacks that external code can subscribe to, e.g. to show a UI prompt * or log events. Each event can have multiple handlers. */ this.callbacks = { onProtocolPermissionRequested: [], onBasketAccessRequested: [], onCertificateAccessRequested: [], onSpendingAuthorizationRequested: [], onGroupedPermissionRequested: [], onCounterpartyPermissionRequested: [] }; /** * We queue parallel requests for the same resource so that only one * user prompt is created for a single resource. If multiple calls come * in at once for the same "protocol:domain:privileged:counterparty" etc., * they get merged. * * The key is a string derived from the operation; the value is an object with a reference to the * associated request and an array of pending promise resolve/reject pairs, one for each active * operation that's waiting on the particular resource described by the key. */ this.activeRequests = new Map(); /** Cache recently confirmed permissions to avoid repeated lookups. */ this.permissionCache = new Map(); this.recentGrants = new Map(); this.manifestCache = new Map(); this.manifestFetchInProgress = new Map(); this.groupedPermissionFlowTail = new Map(); this.pactEstablishedCache = new Map(); this.underlying = underlyingWallet; this.adminOriginator = this.normalizeOriginator(adminOriginator) || adminOriginator; // Default all config options to true unless specified this.config = { seekProtocolPermissionsForSigning: true, seekProtocolPermissionsForEncrypting: true, seekProtocolPermissionsForHMAC: true, seekPermissionsForKeyLinkageRevelation: true, seekPermissionsForPublicKeyRevelation: true, seekPermissionsForIdentityKeyRevelation: true, seekPermissionsForIdentityResolution: true, seekBasketInsertionPermissions: true, seekBasketRemovalPermissions: true, seekBasketListingPermissions: true, seekPermissionWhenApplyingActionLabels: true, seekPermissionWhenListingActionsByLabel: true, seekCertificateDisclosurePermissions: true, seekCertificateAcquisitionPermissions: true, seekCertificateRelinquishmentPermissions: true, seekCertificateListingPermissions: true, encryptWalletMetadata: true, seekSpendingPermissions: true, seekGroupedPermission: true, differentiatePrivilegedOperations: true, whitelistedCounterparties: {}, ...config // override with user-specified config }; } /* --------------------------------------------------------------------- * HELPER METHODS FOR P-MODULE DELEGATION * --------------------------------------------------------------------- */ /** * Delegates a wallet method call to a P-module if the basket or protocol name uses a P-scheme. * Handles the full request/response transformation flow. * * @param basketOrProtocolName - The basket or protocol name to check for p-module delegation * @param method - The wallet method name being called * @param args - The original args passed to the method * @param originator - The originator of the request * @param underlyingCall - Callback that executes the underlying wallet method with transformed args * @returns The transformed response, or null if not a P-basket/protocol (caller should continue normal flow) */ async delegateToPModuleIfNeeded(basketOrProtocolName, method, args, originator, underlyingCall) { var _a; // Check if this is a P-protocol/basket if (!basketOrProtocolName.startsWith('p ')) { return null; // If not, caller should continue normal flow } const schemeID = basketOrProtocolName.split(' ')[1]; const module = (_a = this.config.permissionModules) === null || _a === void 0 ? void 0 : _a[schemeID]; if (!module) { throw new Error(`Unsupported P-module scheme: p ${schemeID}`); } // Transform request with module const transformedReq = await module.onRequest({ method, args, originator }); // Call underlying method with transformed request const results = await underlyingCall(transformedReq.args, originator); // Transform response with module return await module.onResponse(results, { method, originator }); } /** * Adds a permission module for the given schemeID if needed, throwing if unsupported. */ addPModuleByScheme(schemeID, kind, pModulesByScheme) { var _a; if (pModulesByScheme.has(schemeID)) return; const module = (_a = this.config.permissionModules) === null || _a === void 0 ? void 0 : _a[schemeID]; if (!module) { throw new Error(`Unsupported P-${kind} scheme: p ${schemeID}`); } pModulesByScheme.set(schemeID, module); } /** * Splits labels into P and non-P lists, registering any P-modules encountered. * * P-labels follow BRC-111 format: `p <moduleId> <payload>` * - Must start with "p " (lowercase p + space) * - Module ID must be at least 1 character with no spaces * - Single space separates module ID from payload * - Payload must be at least 1 character * * @example Valid: "p btms token123", "p invoicing invoice 2026-02-02" * @example Invalid: "p btms" (no payload), "p btms " (empty payload), "p data" (empty moduleId) * * @param labels - Array of label strings to process * @param pModulesByScheme - Map to populate with discovered p-modules * @returns Array of non-P labels for normal permission checks * @throws Error if p-label format is invalid or module is unsupported */ splitLabelsByPermissionModule(labels, pModulesByScheme) { const nonPLabels = []; if (!labels) return nonPLabels; for (const label of labels) { if (label.startsWith('p ')) { // Remove "p " prefix to get "moduleId payload" const remainder = label.slice(2); // Find the space that separates moduleId from payload const separatorIndex = remainder.indexOf(' '); // Validate: must have a space (separatorIndex > 0) and payload after it // separatorIndex <= 0 means no space found or moduleId is empty // separatorIndex === remainder.length - 1 means space is last char (no payload) if (separatorIndex <= 0 || separatorIndex === remainder.length - 1) { throw new Error(`Invalid P-label format: ${label}`); } // Reject double spaces after moduleId (payload can't start with space) if (remainder[separatorIndex + 1] === ' ') { throw new Error(`Invalid P-label format: ${label}`); } // Extract moduleId (substring before first space) const schemeID = remainder.slice(0, separatorIndex); // Register the module (throws if unsupported) this.addPModuleByScheme(schemeID, 'label', pModulesByScheme); } else { // Regular label - add to list for normal permission checks nonPLabels.push(label); } } return nonPLabels; } /** * Decrypts custom instructions in listOutputs results if encryption is configured. */ async decryptListOutputsMetadata(results) { if (results.outputs) { for (let i = 0; i < results.outputs.length; i++) { if (results.outputs[i].customInstructions) { results.outputs[i].customInstructions = await this.maybeDecryptMetadata(results.outputs[i].customInstructions); } } } return results; } /** * Decrypts metadata in listActions results if encryption is configured. */ async decryptListActionsMetadata(results) { if (results.actions) { for (let i = 0; i < results.actions.length; i++) { if (results.actions[i].description) { results.actions[i].description = await this.maybeDecryptMetadata(results.actions[i].description); } if (results.actions[i].inputs) { for (let j = 0; j < results.actions[i].inputs.length; j++) { if (results.actions[i].inputs[j].inputDescription) { results.actions[i].inputs[j].inputDescription = await this.maybeDecryptMetadata(results.actions[i].inputs[j].inputDescription); } } } if (results.actions[i].outputs) { for (let j = 0; j < results.actions[i].outputs.length; j++) { if (results.actions[i].outputs[j].outputDescription) { results.actions[i].outputs[j].outputDescription = await this.maybeDecryptMetadata(results.actions[i].outputs[j].outputDescription); } if (results.actions[i].outputs[j].customInstructions) { results.actions[i].outputs[j].customInstructions = await this.maybeDecryptMetadata(results.actions[i].outputs[j].customInstructions); } } } } } return results; } /* --------------------------------------------------------------------- * 1) PUBLIC API FOR REGISTERING CALLBACKS (UI PROMPTS, LOGGING, ETC.) * --------------------------------------------------------------------- */ /** * Binds a callback function to a named event, such as `onProtocolPermissionRequested`. * * @param eventName The name of the event to listen to * @param handler A function that handles the event * @returns A numeric ID you can use to unbind later */ bindCallback(eventName, handler) { const arr = this.callbacks[eventName]; arr.push(handler); return arr.length - 1; } /** * Unbinds a previously registered callback by either its numeric ID (returned by `bindCallback`) * or by exact function reference. * * @param eventName The event name, e.g. "onProtocolPermissionRequested" * @param reference Either the numeric ID or the function reference * @returns True if successfully unbound, false otherwise */ unbindCallback(eventName, reference) { if (!this.callbacks[eventName]) return false; const arr = this.callbacks[eventName]; if (typeof reference === 'number') { if (arr[reference]) { arr[reference] = null; return true; } return false; } else { const index = arr.indexOf(reference); if (index !== -1) { arr[index] = null; return true; } return false; } } /** * Internally triggers a named event, calling all subscribed listeners. * Each callback is awaited in turn (though errors are swallowed so that * one failing callback doesn't prevent the others). * * @param eventName The event name * @param param The parameter object passed to all listeners */ async callEvent(eventName, param) { const arr = this.callbacks[eventName] || []; for (const cb of arr) { if (typeof cb === 'function') { try { await cb(param); } catch (e) { // Intentionally swallow errors from user-provided callbacks } } } } /* --------------------------------------------------------------------- * 2) PERMISSION (GRANT / DENY) METHODS * --------------------------------------------------------------------- */ /** * Grants a previously requested permission. * This method: * 1) Resolves all pending promise calls waiting on this request * 2) Optionally creates or renews an on-chain PushDrop token (unless `ephemeral===true`) * * @param params requestID to identify which request is granted, plus optional expiry * or `ephemeral` usage, etc. */ async grantPermission(params) { // 1) Identify the matching queued requests in `activeRequests` const matching = this.activeRequests.get(params.requestID); if (!matching) { throw new Error('Request ID not found.'); } // 2) Mark all matching requests as resolved, deleting the entry for (const x of matching.pending) { x.resolve(true); } this.activeRequests.delete(params.requestID); // 3) If `ephemeral !== true`, we create or renew an on-chain token if (!params.ephemeral) { const request = matching.request; if (!request.renewal) { // brand-new permission token await this.createPermissionOnChain(request, params.expiry || 0, // default: never expires params.amount); } else { // renewal => spend the old token, produce a new one await this.renewPermissionOnChain(request.previousToken, request, params.expiry || 0, // default: never expires params.amount); } } // Only cache non-ephemeral permissions // Ephemeral permissions should not be cached as they are one-time authorizations if (!params.ephemeral) { const expiry = params.expiry || 0; // default: never expires const key = this.buildRequestKey(matching.request); this.cachePermission(key, expiry); this.markRecentGrant(matching.request); } } /** * Denies a previously requested permission. * This method rejects all pending promise calls waiting on that request * * @param requestID requestID identifying which request to deny */ async denyPermission(requestID) { // 1) Identify the matching requests const matching = this.activeRequests.get(requestID); if (!matching) { throw new Error('Request ID not found.'); } // 2) Reject all matching requests, deleting the entry for (const x of matching.pending) { x.reject(new Error('Permission denied.')); } this.activeRequests.delete(requestID); } /** * Grants a previously requested grouped permission. * @param params.requestID The ID of the request being granted. * @param params.granted A subset of the originally requested permissions that the user has granted. * @param params.expiry An optional expiry time (in seconds) for the new permission tokens. */ async grantGroupedPermission(params) { var _a, _b, _c; const matching = this.activeRequests.get(params.requestID); if (!matching) { throw new Error('Request ID not found.'); } try { const originalRequest = matching.request; const { originator, permissions: requestedPermissions, displayOriginator } = originalRequest; const originLookupValues = this.buildOriginatorLookupValues(displayOriginator, originator); // --- Validation: Ensure granted permissions are a subset of what was requested --- if (params.granted.spendingAuthorization && !requestedPermissions.spendingAuthorization) { throw new Error('Granted spending authorization was not part of the original request.'); } if ((_a = params.granted.protocolPermissions) === null || _a === void 0 ? void 0 : _a.some(g => { var _a; return !((_a = requestedPermissions.protocolPermissions) === null || _a === void 0 ? void 0 : _a.find(r => deepEqual(r, g))); })) { throw new Error('Granted protocol permissions are not a subset of the original request.'); } if ((_b = params.granted.basketAccess) === null || _b === void 0 ? void 0 : _b.some(g => { var _a; return !((_a = requestedPermissions.basketAccess) === null || _a === void 0 ? void 0 : _a.find(r => deepEqual(r, g))); })) { throw new Error('Granted basket access permissions are not a subset of the original request.'); } if ((_c = params.granted.certificateAccess) === null || _c === void 0 ? void 0 : _c.some(g => { var _a; return !((_a = requestedPermissions.certificateAccess) === null || _a === void 0 ? void 0 : _a.find(r => deepEqual(r, g))); })) { throw new Error('Granted certificate access permissions are not a subset of the original request.'); } // --- End Validation --- const expiry = params.expiry || 0; // default: never expires const toCreate = []; const toRenew = []; if (params.granted.spendingAuthorization) { toCreate.push({ request: { type: 'spending', originator, spending: { satoshis: params.granted.spendingAuthorization.amount }, reason: params.granted.spendingAuthorization.description }, expiry: 0, amount: params.granted.spendingAuthorization.amount }); } const grantedProtocols = params.granted.protocolPermissions || []; const protocolTokens = await this.mapWithConcurrency(grantedProtocols, 8, async (p) => { var _a, _b; const counterparty = ((_b = (_a = p.protocolID) === null || _a === void 0 ? void 0 : _a[0]) !== null && _b !== void 0 ? _b : 0) === 1 ? '' : p.counterparty || 'self'; const token = await this.findProtocolToken(originator, false, p.protocolID, counterparty, true, originLookupValues); return { p, token, counterparty }; }); for (const { p, token, counterparty } of protocolTokens) { const request = { type: 'protocol', originator, privileged: false, protocolID: p.protocolID, counterparty, reason: p.description }; if (token) { toRenew.push({ oldToken: token, request, expiry }); } else { toCreate.push({ request, expiry }); } } for (const b of params.granted.basketAccess || []) { toCreate.push({ request: { type: 'basket', originator, basket: b.basket, reason: b.description }, expiry }); } for (const c of params.granted.certificateAccess || []) { toCreate.push({ request: { type: 'certificate', originator, privileged: false, certificate: { verifier: c.verifierPublicKey, certType: c.type, fields: c.fields }, reason: c.description }, expiry }); } const created = await this.createPermissionTokensBestEffort(toCreate); const renewed = await this.renewPermissionTokensBestEffort(toRenew); for (const req of [...created, ...renewed]) { this.markRecentGrant(req); } // Success - resolve all pending promises for this request for (const p of matching.pending) { p.resolve(true); } } catch (error) { // Failure - reject all pending promises so callers don't hang forever for (const p of matching.pending) { p.reject(error); } throw error; } finally { // Always clean up the request entry this.activeRequests.delete(params.requestID); } } /** * Denies a previously requested grouped permission. * @param requestID The ID of the request being denied. */ async denyGroupedPermission(requestID) { const matching = this.activeRequests.get(requestID); if (!matching) { throw new Error('Request ID not found.'); } const err = new Error('The user has denied the request for permission.'); err.code = 'ERR_PERMISSION_DENIED'; for (const p of matching.pending) { p.reject(err); } this.activeRequests.delete(requestID); } async dismissGroupedPermission(requestID) { const matching = this.activeRequests.get(requestID); if (!matching) { throw new Error('Request ID not found.'); } for (const p of matching.pending) { p.resolve(true); } this.activeRequests.delete(requestID); } async grantCounterpartyPermission(params) { var _a; const matching = this.activeRequests.get(params.requestID); if (!matching) { throw new Error('Request ID not found.'); } const originalRequest = matching.request; const { originator, counterparty, permissions: requestedPermissions, displayOriginator } = originalRequest; const originLookupValues = this.buildOriginatorLookupValues(displayOriginator, originator); const getProtoName = (p) => { if (!p) return undefined; if (typeof p.protocolName === 'string') return p.protocolName; if (Array.isArray(p.protocolID) && typeof p.protocolID[1] === 'string') return p.protocolID[1]; return undefined; }; if ((_a = params.granted.protocols) === null || _a === void 0 ? void 0 : _a.some(g => { const gName = getProtoName(g); if (!gName) return true; return !requestedPermissions.protocols.some(r => r.protocolName === gName); })) { throw new Error('Granted protocol permissions are not a subset of the original request.'); } const expiry = params.expiry || 0; const toCreate = []; const toRenew = []; const grantedProtocols = params.granted.protocols || []; const protocolTokens = await this.mapWithConcurrency(grantedProtocols, 8, async (p) => { const protocolName = getProtoName(p); if (!protocolName) { throw new Error('Invalid counterparty permission protocol entry: missing protocolName/protocolID.'); } const protocolID = [2, protocolName]; const token = await this.findProtocolToken(originator, false, protocolID, counterparty, true, originLookupValues); return { p, token, protocolID }; }); for (const { p, token, protocolID } of protocolTokens) { const request = { type: 'protocol', originator, privileged: false, protocolID, counterparty, reason: p.description }; if (token) { toRenew.push({ oldToken: token, request, expiry }); } else { toCreate.push({ request, expiry }); } } const created = await this.createPermissionTokensBestEffort(toCreate); const renewed = await this.renewPermissionTokensBestEffort(toRenew); for (const req of [...created, ...renewed]) { this.markRecentGrant(req); } for (const p of matching.pending) { p.resolve(true); } this.activeRequests.delete(params.requestID); } async denyCounterpartyPermission(requestID) { const matching = this.activeRequests.get(requestID); if (!matching) { throw new Error('Request ID not found.'); } const err = new Error('The user has denied the request for permission.'); err.code = 'ERR_PERMISSION_DENIED'; for (const p of matching.pending) { p.reject(err); } this.activeRequests.delete(requestID); } /* --------------------------------------------------------------------- * 3) THE "ENSURE" METHODS: CHECK IF PERMISSION EXISTS, OTHERWISE PROMPT * --------------------------------------------------------------------- */ /** * Ensures the originator has protocol usage permission. * If no valid (unexpired) permission token is found, triggers a permission request flow. */ async ensureProtocolPermission({ originator, privileged, protocolID, counterparty, reason, seekPermission = true, usageType }) { const { normalized: normalizedOriginator, lookupValues } = this.prepareOriginator(originator); originator = normalizedOriginator; // 1) adminOriginator can do anything if (this.isAdminOriginator(originator)) return true; // 2) If security level=0, we consider it "open" usage const [level, protoName] = protocolID; if (level === 0) return true; if (level === 1) { counterparty = ''; } // 3) If protocol is admin-reserved, block if (this.isAdminProtocol(protocolID)) { throw new Error(`Protocol “${protoName}” is admin-only.`); } // Allow the configured exceptions. if (usageType === 'signing' && !this.config.seekProtocolPermissionsForSigning) { return true; } if (usageType === 'encrypting' && !this.config.seekProtocolPermissionsForEncrypting) { return true; } if (usageType === 'hmac' && !this.config.seekProtocolPermissionsForHMAC) { return true; } if (usageType === 'publicKey' && !this.config.seekPermissionsForPublicKeyRevelation) { return true; } if (usageType === 'identityKey' && !this.config.seekPermissionsForIdentityKeyRevelation) { return true; } if (usageType === 'linkageRevelation' && !this.config.seekPermissionsForKeyLinkageRevelation) { return true; } if (!this.config.differentiatePrivilegedOperations) { privileged = false; } if (!privileged && this.isWhitelistedCounterpartyProtocol(counterparty, protocolID)) { return true; } const cacheKey = this.buildRequestKey({ type: 'protocol', originator, privileged, protocolID, counterparty }); if (this.isPermissionCached(cacheKey)) { return true; } if (this.isRecentlyGranted(cacheKey)) { return true; } // 4) Attempt to find a valid token in the internal basket const token = await this.findProtocolToken(originator, privileged, protocolID, counterparty, /*includeExpired=*/ true, lookupValues); if (token) { if (!this.isTokenExpired(token.expiry)) { // valid and unexpired this.cachePermission(cacheKey, token.expiry); return true; } else { // has a token but expired => request renewal if allowed if (!seekPermission) { throw new Error(`Protocol permission expired and no further user consent allowed (seekPermission=false).`); } return await this.requestPermissionFlow({ type: 'protocol', originator, privileged, protocolID, counterparty, usageType, reason, renewal: true, previousToken: token }); } } else { // No token found => request a new one if allowed if (!seekPermission) { throw new Error(`No protocol permission token found (seekPermission=false).`); } const granted = await this.requestPermissionFlow({ type: 'protocol', originator, privileged, protocolID, counterparty, usageType, reason, renewal: false }); return granted; } } /** * Ensures the originator has basket usage permission for the specified basket. * If not, triggers a permission request flow. */ async ensureBasketAccess({ originator, basket, reason, seekPermission = true, usageType }) { const { normalized: normalizedOriginator, lookupValues } = this.prepareOriginator(originator); originator = normalizedOriginator; if (this.isAdminOriginator(originator)) return true; if (this.isAdminBasket(basket)) { throw new Error(`Basket “${basket}” is admin-only.`); } if (usageType === 'insertion' && !this.config.seekBasketInsertionPermissions) return true; if (usageType === 'removal' && !this.config.seekBasketRemovalPermissions) return true; if (usageType === 'listing' && !this.config.seekBasketListingPermissions) return true; const cacheKey = this.buildRequestKey({ type: 'basket', originator, basket }); if (this.isPermissionCached(cacheKey)) { return true; } if (this.isRecentlyGranted(cacheKey)) { return true; } const token = await this.findBasketToken(originator, basket, true, lookupValues); if (token) { if (!this.isTokenExpired(token.expiry)) { this.cachePermission(cacheKey, token.expiry); return true; } else { if (!seekPermission) { throw new Error(`Basket permission expired (seekPermission=false).`); } return await this.requestPermissionFlow({ type: 'basket', originator, basket, usageType, reason, renewal: true, previousToken: token }); } } else { // none if (!seekPermission) { throw new Error(`No basket permission found, and no user consent allowed (seekPermission=false).`); } const granted = await this.requestPermissionFlow({ type: 'basket', originator, basket, usageType, reason, renewal: false }); return granted; } } /** * Ensures the originator has a valid certificate permission. * This is relevant when revealing certificate fields in DCAP contexts. */ async ensureCertificateAccess({ originator, privileged, verifier, certType, fields, reason, seekPermission = true, usageType }) { const { normalized: normalizedOriginator, lookupValues } = this.prepareOriginator(originator); originator = normalizedOriginator; if (this.isAdminOriginator(originator)) return true; if (usageType === 'disclosure' && !this.config.seekCertificateDisclosurePermissions) { return true; } if (!this.config.differentiatePrivilegedOperations) { privileged = false; } const cacheKey = this.buildRequestKey({ type: 'certificate', originator, privileged, certificate: { verifier, certType, fields } }); if (this.isPermissionCached(cacheKey)) { return true; } if (this.isRecentlyGranted(cacheKey)) { return true; } const token = await this.findCertificateToken(originator, privileged, verifier, certType, fields, /*includeExpired=*/ true, lookupValues); if (token) { if (!this.isTokenExpired(token.expiry)) { this.cachePermission(cacheKey, token.expiry); return true; } else { if (!seekPermission) { throw new Error(`Certificate permission expired (seekPermission=false).`); } return await this.requestPermissionFlow({ type: 'certificate', originator, privileged, certificate: { verifier, certType, fields }, usageType, reason, renewal: true, previousToken: token }); } } else { if (!seekPermission) { throw new Error(`No certificate permission found (seekPermission=false).`); } const granted = await this.requestPermissionFlow({ type: 'certificate', originator, privileged, certificate: { verifier, certType, fields }, usageType, reason, renewal: false }); return granted; } } /** * Ensures the originator has spending authorization (DSAP) for a certain satoshi amount. * If the existing token limit is insufficient, attempts to renew. If no token, attempts to create one. */ async ensureSpendingAuthorization({ originator, satoshis, lineItems, reason, seekPermission = true }) { const { normalized: normalizedOriginator, lookupValues } = this.prepareOriginator(originator); originator = normalizedOriginator; if (this.isAdminOriginator(originator)) return true; if (!this.config.seekSpendingPermissions) { // We skip spending permission entirely return true; } const cacheKey = this.buildRequestKey({ type: 'spending', originator, spending: { satoshis } }); if (this.isPermissionCached(cacheKey)) { return true; } const token = await this.findSpendingToken(originator, lookupValues); if (token === null || token === void 0 ? void 0 : token.authorizedAmount) { // Check how much has been spent so far const spentSoFar = await this.querySpentSince(token); if (spentSoFar + satoshis <= token.authorizedAmount) { this.cachePermission(cacheKey, token.expiry); return true; } else { // Renew if possible if (!seekPermission) { throw new Error(`Spending authorization insufficient for ${satoshis}, no user consent (seekPermission=false).`); } return await this.requestPermissionFlow({ type: 'spending', originator, spending: { satoshis, lineItems }, reason, renewal: true, previousToken: token }); } } else { // no token if (!seekPermission) { throw new Error(`No spending authorization found, (seekPermission=false).`); } return await this.requestPermissionFlow({ type: 'spending', originator, spending: { satoshis, lineItems }, reason, renewal: false }); } } /** * Ensures the originator has label usage permission. * If no valid (unexpired) permission token is found, triggers a permission request flow. */ async ensureLabelAccess({ originator, label, reason, seekPermission = true, usageType }) { const { normalized: normalizedOriginator } = this.prepareOriginator(originator); originator = normalizedOriginator; // 1) adminOriginator can do anything if (this.isAdminOriginator(originator)) return true; // 2) If label is admin-reserved, block if (this.isAdminLabel(label)) { throw new Error(`Label “${label}” is admin-only.`); } if (usageType === 'apply' && !this.config.seekPermissionWhenApplyingActionLabels) { return true; } if (usageType === 'list' && !this.config.seekPermissionWhenListingActionsByLabel) { return true; } const cacheKey = this.buildRequestKey({ type: 'protocol', originator, privileged: false, protocolID: [1, `action label ${label}`], counterparty: 'self' }); if (this.isPermissionCached(cacheKey)) { return true; } // 3) Let ensureProtocolPermission handle the rest. return await this.ensureProtocolPermission({ originator, privileged: false, protocolID: [1, `action label ${label}`], counterparty: 'self', reason, seekPermission, usageType: 'generic' }); } isProtocolInCounterpartyPermissions(protocolID, counterpartyPermissions) { const [, name] = protocolID; return !!counterpartyPermissions.protocols.find(p => p.protocolName === name); } validateCounterpartyPermissions(raw) { if (!raw || !Array.isArray(raw.protocols) || raw.protocols.length === 0) return null; const getCI = (obj, key) => { if (!obj || typeof obj !== 'object') return undefined; const lower = key.toLowerCase(); const found = Object.keys(obj).find(k => k.toLowerCase() === lower); return found ? obj[found] : undefined; }; const parseProtocolId = (v) => { if (!Array.isArray(v)) return null; if (v[0] !== 2) return null; if (typeof v[1] !== 'string') return null; return v[1]; }; const validProtocols = raw.protocols .map((p) => { var _a, _b; const protocolName = getCI(p, 'protocolName'); if (typeof protocolName === 'string') { return { protocolName, protocolID: [2, protocolName], description: typeof (p === null || p === void 0 ? void 0 : p.description) === 'string' ? p.description : undefined }; } const protocolIdRaw = (_b = (_a = getCI(p, 'protocolID')) !== null && _a !== void 0 ? _a : getCI(p, 'protocolId')) !== null && _b !== void 0 ? _b : getCI(p, 'protocolid'); const parsed = parseProtocolId(protocolIdRaw); if (!parsed) return null; return { protocolName: parsed, protocolID: [2, parsed], description: typeof (p === null || p === void 0 ? void 0 : p.description) === 'string' ? p.description : undefined }; }) .filter(Boolean); if (validProtocols.length === 0) return null; return { description: typeof raw.description === 'string' ? raw.description : undefined, protocols: validProtocols }; } async fetchManifestPermissions(originator) { const cached = this.manifestCache.get(originator); if (cached && Date.now() - cached.fetchedAt < WalletPermissionsManager.MANIFEST_CACHE_TTL_MS) { return { groupPermissions: cached.groupPermissions, counterpartyPermissions: cached.counterpartyPermissions }; } const inProgress = this.manifestFetchInProgress.get(originator); if (inProgress) { return inProgress; } const fetchPromise = (async () => { try { const proto = originator.startsWith('localhost:') ? 'http' : 'https'; const response = await fetch(`${proto}://${originator}/manifest.json`); if (response.ok) { const manifest = await response.json(); const namespace = (manifest === null || manifest === void 0 ? void 0 : manifest.metanet) || (manifest === null || manifest === void 0 ? void 0 : manifest.babbage); const groupPermissions = (namespace === null || namespace === void 0 ? void 0 : namespace.groupPermissions) || null; const counterpartyPermissionsDeclared = this.validateCounterpartyPermissions(namespace === null || namespace === void 0 ? void 0 : namespace.counterpartyPermissions); this.manifestCache.set(originator, { groupPermissions, counterpartyPermissions: counterpartyPermissionsDeclared, fetchedAt: Date.now() }); return { groupPermissions, counterpartyPermissions: counterpartyPermissionsDeclared }; } } catch (e) { } const result = { groupPermissions: null, counterpartyPermissions: null }; this.manifestCache.set(originator, { ...result, fetchedAt: Date.now() }); return result; })(); this.manifestFetchInProgress.set(originator, fetchPromise); try { return await fetchPromise; } finally { this.manifestFetchInProgress.delete(originator); } } async fetchManifestGroupPermissions(originator) { const { groupPermissions } = await this.fetchManifestPermissions(originator); return groupPermissions; } async filterAlreadyGrantedPermissions(originator, groupPermissions) { const permissionsToRequest = { description: groupPermissions.description, protocolPermissions: [], basketAccess: [], certificateAccess: [] }; const [spendingAuthorization, protocolPermissions, basketAccess, certificateAccess] = await Promise.all([ (async () => { if (!groupPermissions.spendingAuthorization) return undefined; const hasAuth = await this.hasSpendingAuthorization({ originator, satoshis: groupPermissions.spendingAuthorization.amount }); return hasAuth ? undefined : groupPermissions.spendingAuthorization; })(), (async () => { const protocolChecks = await Promise.all((groupPermissions.protocolPermissions || []).map(async (p) => { var _a; if (p.counterparty === '') return null; const [level] = p.protocolID || [0]; if (level === 2 && (p.counterparty === undefined || p.counterparty === null)) { return null; } const hasPerm = await this.hasProtocolPermission({ originator, privileged: false, protocolID: p.protocolID, counterparty: (_a = p.counterparty) !== null && _a !== void 0 ? _a : 'self' }); return hasPerm ? null : p; })); return protocolChecks.filter(Boolean); })(), (async () => { const basketChecks = await Promise.all((groupPermissions.basketAccess || []).map(async (b) => { const hasAccess = await this.hasBasketAccess({ originator, basket: b.basket }); return hasAccess ? null : b; })); return basketChecks.filter(Boolean); })(), (async () => { const certChecks = await Promise.all((groupPermissions.certificateAccess || []).map(async (c) => { const hasAccess = await this.hasCertificateAccess({ originator, privileged: false, verifier: c.verifierPublicKey, certType: c.type, fields: c.fields }); return hasAccess ? null : c; })); return certChecks.filter(Boolean); })() ]); if (spendingAuthorization) { permissionsToRequest.spendingAuthorization = spendingAuthorization; } permissionsToRequest.protocolPermissions = protocolPermissions; permissionsToRequest.basketAccess = basketAccess; permissionsToRequest.certificateAccess = certificateAccess; return permissionsToRequest; } hasAnyPermissionsToRequest(permissions) { var _a, _b, _c, _d, _e, _f; return !!(permissions.spendingAuthorization || ((_b = (_a = permissions.protocolPermissions) === null