Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 16 additions & 5 deletions src/wac/checker.js
Original file line number Diff line number Diff line change
Expand Up @@ -163,15 +163,26 @@ async function checkAuthorizations(authorizations, targetUrl, agentWebId, requir
c.type === 'PaymentCondition' || c.type === 'https://webacl.org/ns#PaymentCondition'
);
if (paymentCondition) {
// Check if agent has sufficient balance
const cost = parseInt(paymentCondition.amount, 10) || 0;
const parsed = parseInt(paymentCondition.amount, 10);
const cost = Number.isNaN(parsed) ? -1 : parsed;
const currency = paymentCondition.currency || 'sat';
if (agentWebId && cost > 0) {

// Skip invalid amounts
if (cost < 0) continue;

if (agentWebId) {
try {
const ledger = await readLedger();

// Zero-cost gate: verify they have a ledger entry (have deposited at some point)
if (cost === 0) {
const hasEntry = ledger.entries?.some(e => e.url === agentWebId);
if (hasEntry) return { allowed: true };
}

// Paid access: check balance and deduct
const balance = getBalance(ledger, agentWebId);
if (balance >= cost) {
// Deduct and grant access
if (cost > 0 && balance >= cost) {
debit(ledger, agentWebId, cost, currency === 'sats' ? 'sat' : currency);
Comment on lines 175 to 186

Copilot AI Apr 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For the zero-cost gate path, getBalance(ledger, agentWebId) is computed even though the result is unused, and then ledger.entries.some(...) scans the entries again. You can avoid the extra work by only computing balance in the cost > 0 branch (or by reusing a single find result).

Copilot uses AI. Check for mistakes.
const { writeLedger } = await import('../webledger.js');
await writeLedger(ledger);
Expand Down
24 changes: 24 additions & 0 deletions test/wac.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,30 @@ describe('WAC Conditions', () => {
assert.strictEqual(auths[0].conditions[0].type, 'UnknownFutureCondition');
});

it('should parse zero-cost PaymentCondition', async () => {
const acl = {
'@context': { 'acl': 'http://www.w3.org/ns/auth/acl#' },
'@graph': [{
'@id': '#gate',
'@type': 'acl:Authorization',
'acl:agentClass': { '@id': 'acl:AuthenticatedAgent' },
'acl:accessTo': { '@id': 'https://alice.example/members/' },
'acl:mode': [{ '@id': 'acl:Read' }],
'acl:condition': {
'@type': 'PaymentCondition',
'amount': '0',
'currency': 'sats'
}
}]
};

const auths = await parseAcl(JSON.stringify(acl), 'https://alice.example/members/.acl');
const condition = auths[0].conditions[0];

assert.strictEqual(condition.type, 'PaymentCondition');
assert.strictEqual(condition.amount, '0');
});

Copilot AI Apr 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This adds a new authorization behavior (zero-cost PaymentCondition grants access only when a ledger entry exists), but the added test only covers parsing. Please add an end-to-end test that exercises checkAccess/authorization (both: no ledger entry => 402/paymentRequired; ledger entry present => allowed; and ensure no debit occurs for amount 0).

Suggested change
it('should require a ledger entry for zero-cost PaymentCondition without debiting', async () => {
const resourceUrl = 'https://alice.example/members/';
const aclUrl = 'https://alice.example/members/.acl';
const webId = 'https://bob.example/#me';
const acl = {
'@context': { 'acl': 'http://www.w3.org/ns/auth/acl#' },
'@graph': [{
'@id': '#gate',
'@type': 'acl:Authorization',
'acl:agentClass': { '@id': 'acl:AuthenticatedAgent' },
'acl:accessTo': { '@id': resourceUrl },
'acl:mode': [{ '@id': 'acl:Read' }],
'acl:condition': {
'@type': 'PaymentCondition',
'amount': '0',
'currency': 'sats'
}
}]
};
const auths = await parseAcl(JSON.stringify(acl), aclUrl);
const ledgerEntries = [];
let debitCalls = 0;
const ledger = {
entries: ledgerEntries,
add(entry) {
ledgerEntries.push(entry);
},
hasEntry(entryOrAgent, maybeResource) {
return ledgerEntries.some((entry) => {
const agent = typeof entryOrAgent === 'object' ? (entryOrAgent.agent || entryOrAgent.webId || entryOrAgent.user) : entryOrAgent;
const resource = typeof entryOrAgent === 'object' ? (entryOrAgent.resource || entryOrAgent.resourceUrl || entryOrAgent.url) : maybeResource;
return (!agent || entry.agent === agent || entry.webId === agent || entry.user === agent) &&
(!resource || entry.resource === resource || entry.resourceUrl === resource || entry.url === resource);
});
},
has(entryOrAgent, maybeResource) {
return this.hasEntry(entryOrAgent, maybeResource);
},
get(entryOrAgent, maybeResource) {
return ledgerEntries.find((entry) => {
const agent = typeof entryOrAgent === 'object' ? (entryOrAgent.agent || entryOrAgent.webId || entryOrAgent.user) : entryOrAgent;
const resource = typeof entryOrAgent === 'object' ? (entryOrAgent.resource || entryOrAgent.resourceUrl || entryOrAgent.url) : maybeResource;
return (!agent || entry.agent === agent || entry.webId === agent || entry.user === agent) &&
(!resource || entry.resource === resource || entry.resourceUrl === resource || entry.url === resource);
});
},
lookup(entryOrAgent, maybeResource) {
return this.get(entryOrAgent, maybeResource);
},
async debit() {
debitCalls += 1;
throw new Error('zero-cost PaymentCondition must not debit');
},
async charge() {
debitCalls += 1;
throw new Error('zero-cost PaymentCondition must not charge');
}
};
const invokeCheckAccess = async () => {
const attempts = [
() => checkAccess(auths, {
agent: webId,
webId,
user: webId,
resource: resourceUrl,
resourceUrl,
url: resourceUrl,
mode: AccessMode.Read,
requiredMode: AccessMode.Read,
authenticated: true,
isAuthenticated: true,
ledger,
paymentLedger: ledger
}),
() => checkAccess({
authorizations: auths,
auths,
agent: webId,
webId,
user: webId,
resource: resourceUrl,
resourceUrl,
url: resourceUrl,
mode: AccessMode.Read,
requiredMode: AccessMode.Read,
authenticated: true,
isAuthenticated: true,
ledger,
paymentLedger: ledger
}),
() => checkAccess(auths, resourceUrl, webId, AccessMode.Read, ledger)
];
let lastError;
for (const attempt of attempts) {
try {
const result = await attempt();
if (result !== undefined) {
return result;
}
} catch (error) {
lastError = error;
}
}
throw lastError || new Error('Unable to invoke checkAccess for zero-cost PaymentCondition test');
};
const denied = await invokeCheckAccess();
const deniedStatus = denied && typeof denied === 'object' ? (denied.status ?? denied.statusCode) : undefined;
const deniedReason = denied && typeof denied === 'object'
? (denied.reason ?? denied.code ?? denied.error ?? (denied.paymentRequired ? 'paymentRequired' : undefined))
: undefined;
const deniedAllowed = denied === true || (denied && typeof denied === 'object' && (
denied.allowed === true ||
denied.authorized === true ||
denied.granted === true
));
assert.strictEqual(deniedAllowed, false);
assert.ok(
deniedStatus === 402 || deniedReason === 'paymentRequired',
`expected zero-cost PaymentCondition without ledger entry to deny with 402/paymentRequired, got ${JSON.stringify(denied)}`
);
ledger.add({
agent: webId,
webId,
user: webId,
resource: resourceUrl,
resourceUrl,
url: resourceUrl,
amount: '0',
currency: 'sats'
});
const allowed = await invokeCheckAccess();
const allowedValue = allowed === true || (allowed && typeof allowed === 'object' && (
allowed.allowed === true ||
allowed.authorized === true ||
allowed.granted === true
));
assert.strictEqual(allowedValue, true, `expected access to be granted once a ledger entry exists, got ${JSON.stringify(allowed)}`);
assert.strictEqual(debitCalls, 0);
});

Copilot uses AI. Check for mistakes.
it('should parse PaymentCondition with all fields', async () => {
const acl = {
'@context': { 'acl': 'http://www.w3.org/ns/auth/acl#' },
Expand Down