Skip to content

feat: zero-cost PaymentCondition as membership gate#249

Merged
melvincarvalho merged 2 commits into
gh-pagesfrom
issue-244-zero-cost-gate
Apr 3, 2026
Merged

feat: zero-cost PaymentCondition as membership gate#249
melvincarvalho merged 2 commits into
gh-pagesfrom
issue-244-zero-cost-gate

Conversation

@melvincarvalho

Copy link
Copy Markdown
Contributor

Summary

PaymentCondition with amount: "0" now acts as a membership gate — grants access if the user has ever deposited (has a ledger entry), without deducting anything.

  • Checks for ledger entry existence, not balance >= 0 (which returns 0 for non-existent users too)
  • No sats deducted for zero-cost conditions
  • Paid conditions (amount > 0) work as before
  • 353 tests pass (1 new)

Fixes #244

PaymentCondition with amount=0 grants access if the user has a
ledger entry (has deposited at some point). No sats deducted.

Checks for entry existence, not balance >= 0, since getBalance
returns 0 for both "no entry" and "entry with zero balance".

Fixes #244

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Adds support for treating PaymentCondition with amount: "0" as a “membership gate” in the WAC authorization checker by granting access only if the authenticated agent has an existing ledger entry (i.e., has deposited before), without debiting.

Changes:

  • Implement zero-cost PaymentCondition handling by checking ledger entry existence (and skipping debits).
  • Keep paid PaymentCondition behavior (balance check + debit) intact.
  • Add a unit test ensuring zero-cost PaymentCondition is parsed correctly.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.

File Description
test/wac.test.js Adds a parsing test for a zero-cost PaymentCondition.
src/wac/checker.js Updates WAC condition evaluation to treat amount === 0 as a ledger-entry membership gate and avoid debiting.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/wac/checker.js Outdated
Comment on lines +166 to +178
const cost = parseInt(paymentCondition.amount, 10) || 0;
const currency = paymentCondition.currency || 'sat';
if (agentWebId && cost > 0) {

if (agentWebId) {
try {
const ledger = await readLedger();
const balance = getBalance(ledger, agentWebId);
if (balance >= cost) {
// Deduct and grant access

// 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 };
}

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.

cost is computed as parseInt(amount) || 0, which treats any non-numeric/invalid amount (e.g. 'abc', '') as zero. With the new zero-cost gate logic, an invalid amount can unintentionally behave like a membership gate and grant access to any identity that has a ledger entry. Consider validating paymentCondition.amount explicitly (e.g. reject/ignore the condition when parsing yields NaN, and only treat it as zero-cost when the value is exactly 0).

Copilot uses AI. Check for mistakes.
Comment thread src/wac/checker.js
Comment on lines 171 to 182
const ledger = await readLedger();
const balance = getBalance(ledger, agentWebId);
if (balance >= cost) {
// Deduct and grant access

// 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
if (cost > 0 && balance >= cost) {
debit(ledger, agentWebId, cost, currency === 'sats' ? 'sat' : currency);

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.
Comment thread test/wac.test.js
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.
@melvincarvalho
melvincarvalho merged commit 148f9d0 into gh-pages Apr 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: zero-cost payment gate and balance-based rate limits

2 participants