feat: zero-cost PaymentCondition as membership gate#249
Conversation
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
There was a problem hiding this comment.
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
PaymentConditionhandling by checking ledger entry existence (and skipping debits). - Keep paid
PaymentConditionbehavior (balance check + debit) intact. - Add a unit test ensuring zero-cost
PaymentConditionis 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.
| 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 }; | ||
| } |
There was a problem hiding this comment.
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).
| 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); |
There was a problem hiding this comment.
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).
| assert.strictEqual(condition.type, 'PaymentCondition'); | ||
| assert.strictEqual(condition.amount, '0'); | ||
| }); | ||
|
|
There was a problem hiding this comment.
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).
| 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); | |
| }); |
Summary
PaymentConditionwithamount: "0"now acts as a membership gate — grants access if the user has ever deposited (has a ledger entry), without deducting anything.balance >= 0(which returns 0 for non-existent users too)Fixes #244