@bsv/wallet-toolbox
Version:
BRC100 conforming wallet, wallet storage and wallet signer components
1,116 lines • 180 kB
JavaScript
"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