feat: implement permanent audio access grants with pay-to-unlock
This commit is contained in:
78
src/lib/access.js
Normal file
78
src/lib/access.js
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
class AudioAccessStore {
|
||||||
|
constructor() {
|
||||||
|
this.grants = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
_key(audioId) {
|
||||||
|
return String(audioId);
|
||||||
|
}
|
||||||
|
|
||||||
|
hasGrant(audioId, userId) {
|
||||||
|
const key = this._key(audioId);
|
||||||
|
const set = this.grants.get(key);
|
||||||
|
return Boolean(set && set.has(userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
grantAccess(audioId, userId) {
|
||||||
|
const key = this._key(audioId);
|
||||||
|
if (!this.grants.has(key)) {
|
||||||
|
this.grants.set(key, new Set());
|
||||||
|
}
|
||||||
|
this.grants.get(key).add(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
canAccess({ audio, userId }) {
|
||||||
|
if (!audio || !audio.id) {
|
||||||
|
throw new Error("audio_required");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return { allowed: false, reason: "auth_required" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (audio.ownerUserId === userId) {
|
||||||
|
return { allowed: true, reason: "owner" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.hasGrant(audio.id, userId)) {
|
||||||
|
return { allowed: true, reason: "grant" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
reason: "payment_required",
|
||||||
|
creditsRequired: audio.creditsCharged,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
unlockWithCredits({ audio, userId, wallet }) {
|
||||||
|
if (!audio || !audio.id) {
|
||||||
|
throw new Error("audio_required");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
throw new Error("auth_required");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (audio.ownerUserId === userId || this.hasGrant(audio.id, userId)) {
|
||||||
|
return { unlocked: true, charged: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
wallet.applyTransaction({
|
||||||
|
userId,
|
||||||
|
type: "debit",
|
||||||
|
amount: audio.creditsCharged,
|
||||||
|
reason: "audio_unlock",
|
||||||
|
idempotencyKey: `unlock:${audio.id}:${userId}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.grantAccess(audio.id, userId);
|
||||||
|
return { unlocked: true, charged: audio.creditsCharged };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
AudioAccessStore,
|
||||||
|
};
|
||||||
67
test/access.test.js
Normal file
67
test/access.test.js
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
const test = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const { WalletStore } = require("../src/lib/wallet");
|
||||||
|
const { AudioAccessStore } = require("../src/lib/access");
|
||||||
|
|
||||||
|
const audio = {
|
||||||
|
id: "a1",
|
||||||
|
ownerUserId: "owner-1",
|
||||||
|
creditsCharged: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
test("owner can access without payment", () => {
|
||||||
|
const access = new AudioAccessStore();
|
||||||
|
const result = access.canAccess({ audio, userId: "owner-1" });
|
||||||
|
assert.equal(result.allowed, true);
|
||||||
|
assert.equal(result.reason, "owner");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("non-owner requires payment", () => {
|
||||||
|
const access = new AudioAccessStore();
|
||||||
|
const result = access.canAccess({ audio, userId: "u2" });
|
||||||
|
assert.equal(result.allowed, false);
|
||||||
|
assert.equal(result.reason, "payment_required");
|
||||||
|
assert.equal(result.creditsRequired, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("unlock debits same credits and creates permanent grant", () => {
|
||||||
|
const access = new AudioAccessStore();
|
||||||
|
const wallet = new WalletStore();
|
||||||
|
|
||||||
|
wallet.applyTransaction({
|
||||||
|
userId: "u2",
|
||||||
|
type: "credit",
|
||||||
|
amount: 10,
|
||||||
|
reason: "topup",
|
||||||
|
idempotencyKey: "credit-u2",
|
||||||
|
});
|
||||||
|
|
||||||
|
const unlocked = access.unlockWithCredits({ audio, userId: "u2", wallet });
|
||||||
|
assert.equal(unlocked.unlocked, true);
|
||||||
|
assert.equal(unlocked.charged, 3);
|
||||||
|
assert.equal(wallet.getBalance("u2"), 7);
|
||||||
|
|
||||||
|
const canAccess = access.canAccess({ audio, userId: "u2" });
|
||||||
|
assert.equal(canAccess.allowed, true);
|
||||||
|
assert.equal(canAccess.reason, "grant");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("second unlock call is idempotent and does not double charge", () => {
|
||||||
|
const access = new AudioAccessStore();
|
||||||
|
const wallet = new WalletStore();
|
||||||
|
|
||||||
|
wallet.applyTransaction({
|
||||||
|
userId: "u2",
|
||||||
|
type: "credit",
|
||||||
|
amount: 10,
|
||||||
|
reason: "topup",
|
||||||
|
idempotencyKey: "credit-u2-2",
|
||||||
|
});
|
||||||
|
|
||||||
|
access.unlockWithCredits({ audio, userId: "u2", wallet });
|
||||||
|
access.unlockWithCredits({ audio, userId: "u2", wallet });
|
||||||
|
|
||||||
|
assert.equal(wallet.getBalance("u2"), 7);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user