openvibers/openvibe.inventory Commits
Add the reviewed universal cosmetic store (#219)
Changes
nnew file mode 100644@@@ -0,0 +1,9 @@++# Universal store++++GET /api/v1/store returns staff-reviewed cosmetics, issuer, prices in coins/vibes, preview, stock and ownership. POST /api/v1/store/:id/buy requires a personal bearer and {currency,idempotency_key}; price and buyer overrides are ignored. Existing /api/v1/me/items and PUT /api/v1/me/equipped support the shared sheet with first-party bearer CORS, no cookies.++++POST /api/v1/store/:id/list requires inventory.store.list and the definition issuer. It accepts positive integer prices and always enters review. Staff approve through POST /api/v1/store/:id/review with {approve:true}. Price edits require another review. No prices are invented and no products are automatically listed. The cosmetic store sells non-stackable items.++++A durable order snapshots the reviewed price and reserves one serial under the ordinary grant lock. Billing reads that order with the buyer's bearer before charging. Inventory matches Billing's payment id, purchase id, amount and currency before granting, then records a purchased ledger row and the ordinary grant event. Alternate keys cannot buy the same item twice. An uncertain response is recovered by retrying the same item/currency. Pending stock reservations never expire automatically: reconcile uncertain payments before releasing stock. Purchases export with the account and lose their subject on deletion.++++Rollout: the review branch pins Contracts PR #371 so inventory.store.list is usable in tests; replace it with the reviewed release tag before shipping. BILLING_URL defaults to Billing's public origin. Billing needs Inventory's URL and Network's network.coins.debit grant scoped to billing. Issuers submit prices and staff approve before items appear. Consuming sites need Billing/Inventory connect-src entries when pinning the Shared wallet release. No production settings or deployments are changed here.nnew file mode 100644@@@ -0,0 +1,26 @@++-- phase: expand++-- Reviewed listings and durable purchase reservations. Network still holds OpenCoins; Billing authorizes every spend.++CREATE TABLE inventory_store_listings (++ definition_id text COLLATE "C" PRIMARY KEY REFERENCES inventory_definitions(id),++ issuer text NOT NULL,++ prices jsonb NOT NULL,++ status text NOT NULL CHECK (status IN ('in_review', 'published', 'rejected')),++ reviewed_by text,++ updated_at text NOT NULL++);++CREATE TABLE inventory_store_purchases (++ id text COLLATE "C" PRIMARY KEY,++ subject text COLLATE "C",++ definition_id text COLLATE "C" NOT NULL REFERENCES inventory_definitions(id),++ idempotency_key text NOT NULL,++ currency text NOT NULL CHECK (currency IN ('vibes', 'coins')),++ amount bigint NOT NULL CHECK (amount > 0),++ name text NOT NULL,++ issuer text NOT NULL,++ serial bigint,++ instance_id text COLLATE "C",++ payment_id text,++ created_at text NOT NULL,++ UNIQUE(subject, idempotency_key),++ UNIQUE(subject, definition_id)++);@@@ -17,7 +17,7 @@"express-rate-limit": "^7.5.0","helmet": "^8.1.0","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.338.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/heads/wallet/contracts-1108","openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.168.0",@@@ -1160,9 +1160,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.338.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.338.0",-- "integrity": "sha512-75FD6IxMsXoud3NFXKDs0/D0rhJtF2D+WL8M92ArIyabkUnM/sZvw6E6L97EqwQ3O7M/kB+uwsNSxGudbil+ng==",++ "version": "0.352.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/heads/wallet/contracts-1108",++ "integrity": "sha512-Y6bz1qpxY3Qh9TCEhfPFvX2tZAJFmLjdkyMR2k2NYeMxL6dUQxz1gUacZEEFcBKMq4QqKDisylzBNDCvtCkiqg==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -2,7 +2,7 @@"name": "openvibe-inventory","version": "0.4.1","private": true,-- "description": "OpenVibe.Inventory \u2014 Your items, on every OpenVibe site.",++ "description": "OpenVibe.Inventory — Your items, on every OpenVibe site.","main": "server/index.js","scripts": {"start": "node server/index.js",@@@ -24,7 +24,7 @@"express-rate-limit": "^7.5.0","helmet": "^8.1.0","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.338.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/heads/wallet/contracts-1108","openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.168.0",@@@ -91,6 +91,7 @@ async function createApp(opts = {}) {? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl }): async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Inventory cannot answer account events'); });const ctx = { config, s, keys, sso, principal, log, outbox, inv, accountData, searchIndex };++ ctx.commerce = require('./inventory/commerce').createCommerce(ctx, { fetchImpl });// The Workshop (ADR-054 §6): images in this service's own Media tenant, recipients by @name through the Network.ctx.workshopMedia = opts.workshopMedia || createWorkshopMedia({ config, fetchImpl: opts.fetchImpl, log });ctx.people = opts.people || createPeople({ config, fetchImpl: opts.fetchImpl });@@@ -165,7 +166,7 @@ async function createApp(opts = {}) {fontSrc: ["'self'", 'data:', 'https://fonts.gstatic.com', 'https://cdnjs.cloudflare.com'],imgSrc: ["'self'", 'data:', 'https:'],// The navbar's Messages count asks Chat (openvibe-shared 4.130.0).-- connectSrc: ["'self'", 'https://openvibe.chat', 'https://openvibe.network', 'https://cloudflareinsights.com', 'https://openvibe.events'],++ connectSrc: ["'self'", 'https://billing.openvibe.network', 'https://inventory.openvibe.network', 'https://openvibe.chat', 'https://openvibe.network', 'https://cloudflareinsights.com', 'https://openvibe.events'],frameSrc: ["'self'", 'https://openvibe.network'],frameAncestors: ["'none'"],objectSrc: ["'none'"],@@@ -244,6 +245,7 @@ async function createApp(opts = {}) {// Public reads from any site: the catalog and anyone's items and equipped set answer every origin, without// credentials (openvibe-shared/items.js draws what people wear with them, on every OpenVibe site). Everything else// keeps the same-site resource policy and no CORS. A preflight is answered here.++ app.use('/api/v1', require('./http/wallet-cors'));const PUBLIC_READ = /^\/(?:kinds(?:\/[^/]+)?|definitions(?:\/[^/]+)?|equipped|people\/[^/]+\/(?:items|equipped))$/;app.use('/api/v1', (req, res, next) => {if (!PUBLIC_READ.test(req.path) || !['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return next();@@@ -43,6 +43,7 @@ function load(env = process.env) {valkey: { url: env.VALKEY_URL || '', prefix: env.VALKEY_PREFIX || 'ov:inventory:' },// OpenVibe.Network: SSO (OAuth2 authorization server with PKCE) and its JWKS.++ billingUrl: trim(env.BILLING_URL || 'https://billing.openvibe.network'),networkUrl,networkInternalUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),networkIssuer: trim(env.OV_NETWORK_ISSUER || networkUrl),@@@ -17,8 +17,8 @@* POST /definitions, PATCH /definitions/:id inventory.definition.manage an issuer's definitions** A person acts for themself (their token or this site's session). A service or app acting for a person sends that-- * person's subject in X-OV-Subject and holds the route's capability. Nothing here sells, buys, trades or converts an-- * item (ADR-054 §5). A refusal is an RFC 9457 problem+json with a stable code.++ * person's subject in X-OV-Subject and holds the route's capability. Store purchases require the person's bearer++ * and Billing authorizes every spend. A refusal is an RFC 9457 problem+json with a stable code.*/const express = require('express');const { http } = require('openvibe-contracts');@@@ -111,6 +111,22 @@ function createApi(ctx) {}));r.patch('/definitions/:id', principal.requireCapability('inventory.definition.manage'), limits.budget('inventory.define'), wrap(async (req, res) => res.json({ definition: await inv.updateDefinition(issuerOf(req), req.params.id, req.body) })));++ r.get('/store', read, limits.reads('inventory.item.read'), wrap(async (req, res) => res.json(await ctx.commerce.list(++ req.principal.kind === 'user' ? req.principal.requester.slice(5) : null))));++ const bearerPerson = (req, res, next) => {++ if (req.principal.kind !== 'user' || req.principal.viaSession) return http.sendProblem(res, 401, 'inventory.person_bearer_required');++ return next();++ };++ r.get('/store/purchases/:id', bearerPerson, limits.reads('inventory.item.list'), wrap(async (req, res) =>++ res.json(await ctx.commerce.purchase(req.principal.requester.slice(5), req.params.id))));++ r.post('/store/:id/buy', bearerPerson, limits.budget('inventory.define'), wrap(async (req, res) =>++ res.json(await ctx.commerce.buy(req.principal.requester.slice(5), req.params.id, req.body || {}, req.get('authorization')))));++ r.post('/store/:id/list', principal.requireCapability('inventory.store.list'), limits.budget('inventory.define'), wrap(async (req, res) =>++ res.status(201).json(await ctx.commerce.submit(req.principal.requester, req.params.id, req.body || {}))));++ r.post('/store/:id/review', bearerPerson, limits.budget('inventory.define'), wrap(async (req, res) => {++ if (!['admin', 'global_mod'].includes(req.principal.role)) return http.sendProblem(res, 403, 'inventory.staff_only');++ return res.json(await ctx.commerce.review(req.principal.requester, req.params.id, (req.body || {}).approve));++ }));return r;}@@@ -25,7 +25,7 @@ const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;// route can never be guarded by a capability the service does not declare.const CAPABILITIES = ['inventory.item.read', 'inventory.item.list', 'inventory.equip.manage', 'inventory.item.grant', 'inventory.item.consume',-- 'inventory.definition.manage', 'inventory.definition.review',++ 'inventory.definition.manage', 'inventory.definition.review', 'inventory.store.list',];function decodePayload(token) {@@@ -66,7 +66,7 @@ function createPrincipal({ config, keys }) {if (!v.ok) return { error: [401, v.expired ? 'token.expired' : 'token.invalid', v.reason] };if (v.claims.typ === 'fedcm' || v.claims.actor_type !== undefined) return { error: [401, 'token.invalid', 'not a person\'s token'] };if (!ids.isSubjectId('user', v.claims.subject_id)) return { error: [403, 'identity.no_subject', 'this account has no canonical subject yet; sign in again'] };-- return { principal: { kind: 'user', requester: `user:${v.claims.subject_id}`, project: null, viaSession: false } };++ return { principal: { kind: 'user', requester: `user:${v.claims.subject_id}`, role: v.claims.role, project: null, viaSession: false } };}const viewer = req.viewer;if (viewer && viewer.kind === 'user' && ids.isSubjectId('user', viewer.subject)) {nnew file mode 100644@@@ -0,0 +1,20 @@++'use strict';++const contracts = require('openvibe-contracts');++const origins = new Set();++for (const service of contracts.services.manifests) {++ for (const domain of service.domains || []) {++ if (/^[a-z0-9.-]+\.[a-z]{2,63}$/.test(domain)) {++ origins.add(`https://${domain}`); origins.add(`https://www.${domain}`);++ }++ }++}++module.exports = (req, res, next) => {++ if (!/^\/(?:store(?:\/[^/]+(?:\/(?:buy|list|review))?|\/purchases\/[^/]+)?|me\/(?:items|equipped))$/.test(req.path)) return next();++ res.vary('Origin');++ const origin = req.get('origin');++ if (origins.has(origin)) res.set({ 'Access-Control-Allow-Origin': origin,++ 'Access-Control-Allow-Methods': 'GET, POST, PUT', 'Access-Control-Allow-Headers': 'Authorization, Content-Type, Traceparent, X-OpenVibe-Request-Id',++ 'Cross-Origin-Resource-Policy': 'cross-origin', 'Access-Control-Max-Age': '600' });++ if (req.method === 'OPTIONS') return res.sendStatus(204);++ return next();++};@@@ -16,6 +16,7 @@const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');const TABLES = [++ { table: 'inventory_store_purchases', subject: 'subject', file: 'purchases.json', columns: ['id', 'definition_id', 'currency', 'amount', 'name', 'instance_id', 'payment_id', 'created_at'], order: 'created_at', erase: { anonymize: {} } },// Equipped rows reference instances, so they go first.{ table: 'inventory_equipped', subject: 'owner_subject', file: 'equipped.json', columns: ['kind', 'slot', 'instance_id', 'updated_at'], order: 'updated_at' },{ table: 'inventory_instances', subject: 'owner_subject', file: 'items.json', columns: ['id', 'definition_id', 'origin', 'state', 'attributes', 'serial', 'acquired_at'], order: 'acquired_at' },@@@ -25,6 +26,8 @@ const TABLES = [/** A ledger row the person acted in (an equip they made) keeps the row and loses their name. */async function extraErase(t, subjects, counts) {++ await t.exec("UPDATE inventory_store_listings SET reviewed_by = NULL WHERE reviewed_by = ANY($1::text[])", [subjects.map((x) => `user:${x}`)]);++ await t.exec("UPDATE inventory_store_listings SET issuer = 'user:deleted', status = 'rejected' WHERE issuer = ANY($1::text[])", [subjects.map((x) => `user:${x}`)]);const people = subjects.map((x) => `user:${x}`);counts.add(counts.retained, 'tombstones', await t.exec("UPDATE inventory_ledger SET actor = 'user:deleted' WHERE actor = ANY($1::text[])", [people]));// Their Workshop items: the kind's issuer takes them over, retired, without the review note (ADR-054 §6).nnew file mode 100644@@@ -0,0 +1,142 @@++'use strict';++++const { InventoryError, DEF_RE, actorOf } = require('./store');++const fail = (status, code, detail) => { throw new InventoryError(status, code, detail); };++const PURCHASE_RE = /^isp_[0-9A-HJKMNP-TV-Z]{26}$/;++++function createCommerce(ctx, { fetchImpl = globalThis.fetch } = {}) {++ const { s, inv, outbox, config } = ctx;++ const db = s.db;++ function pricesOf(prices) {++ if (!prices || typeof prices !== 'object' || Array.isArray(prices) || !Object.keys(prices).length ||++ Object.entries(prices).some(([k, v]) => !['coins', 'vibes'].includes(k) || !Number.isSafeInteger(v) || v < 1)) {++ fail(422, 'inventory.invalid_prices', 'Offer positive whole prices in coins, vibes, or both');++ }++ return prices;++ }++ async function submit(issuer, id, input) {++ if (!DEF_RE.test(id)) fail(404, 'inventory.definition_not_found', 'No such item');++ const prices = pricesOf(input.prices);++ return s.tx(async () => {++ const def = await db.maybe('SELECT * FROM inventory_definitions WHERE id = $1 FOR UPDATE', [id]);++ if (!def || def.issuer !== issuer) fail(403, 'inventory.not_issuer', 'List only items issued by your app');++ if (def.status !== 'published') fail(409, 'inventory.not_published', 'Publish the item before listing it');++ const kind = await inv.getKind(def.kind);++ if (kind.stackable) fail(422, 'inventory.store_nonstackable', 'The cosmetic store sells one owned item per person');++ await db.exec(`INSERT INTO inventory_store_listings (definition_id, issuer, prices, status, updated_at)++ VALUES ($1, $2, $3, 'in_review', $4) ON CONFLICT (definition_id) DO UPDATE++ SET prices = excluded.prices, status = 'in_review', reviewed_by = NULL, updated_at = excluded.updated_at`,++ [id, issuer, JSON.stringify(prices), s.iso()]);++ return { definition_id: id, issuer, prices, status: 'in_review' };++ });++ }++ async function review(staff, id, approve) {++ if (typeof approve !== 'boolean') fail(422, 'inventory.invalid_review', 'approve is a boolean');++ const r = await db.maybe(`UPDATE inventory_store_listings SET status = $2, reviewed_by = $3, updated_at = $4++ WHERE definition_id = $1 RETURNING definition_id, issuer, prices, status`, [id, approve ? 'published' : 'rejected', staff, s.iso()]);++ if (!r) fail(404, 'inventory.listing_not_found', 'No listing to review');++ return r;++ }++ async function list(subject = null) {++ const rows = await db.many(`SELECT l.definition_id, l.issuer, l.prices, d.name, d.description, d.art, d.kind,++ d.supply_cap, d.supply_issued, k.slots, k.surfaces,++ EXISTS(SELECT 1 FROM inventory_instances i WHERE i.definition_id = d.id AND i.owner_subject = $1 AND i.state = 'owned') AS owned++ FROM inventory_store_listings l JOIN inventory_definitions d ON d.id = l.definition_id++ JOIN inventory_kinds k ON k.id = d.kind WHERE l.status = 'published' AND d.status = 'published'++ ORDER BY d.name, d.id LIMIT 200`, [subject]);++ return { items: rows.map((r) => ({ id: r.definition_id, name: r.name, description: r.description || '', issuer: r.issuer,++ prices: r.prices, preview: r.art, kind: r.kind, slots: r.slots, surfaces: r.surfaces, owned: r.owned,++ available: r.supply_cap == null || Number(r.supply_issued) < Number(r.supply_cap) })) };++ }++ function present(p) {++ return { id: p.id, subject: p.subject, item_id: p.definition_id, name: p.name, issuer: p.issuer,++ currency: p.currency, amount: Number(p.amount), status: p.instance_id ? 'fulfilled' : 'pending',++ instance_id: p.instance_id || null, payment_id: p.payment_id || null };++ }++ async function purchase(subject, id) {++ if (!PURCHASE_RE.test(id)) fail(404, 'inventory.purchase_not_found', 'No such purchase');++ const p = await db.maybe('SELECT * FROM inventory_store_purchases WHERE id = $1 AND subject = $2', [id, subject]);++ if (!p) fail(404, 'inventory.purchase_not_found', 'No such purchase');++ return present(p);++ }++ async function reserve(subject, id, input) {++ if (!DEF_RE.test(id)) fail(404, 'inventory.definition_not_found', 'No such item');++ if (!/^[A-Za-z0-9._:-]{8,128}$/.test(input.idempotency_key || '') || !['coins', 'vibes'].includes(input.currency)) {++ fail(422, 'inventory.invalid_purchase', 'Send currency and an idempotency_key of 8–128 characters');++ }++ // Lock the definition exactly as ordinary grants do; the reserved serial counts against the supply cap.++ return s.tx(async () => {++ const d = await db.maybe('SELECT * FROM inventory_definitions WHERE id = $1 FOR UPDATE', [id]);++ if (!d) fail(404, 'inventory.definition_not_found', 'No such item');++ const old = await db.maybe('SELECT * FROM inventory_store_purchases WHERE subject = $1 AND idempotency_key = $2', [subject, input.idempotency_key]);++ if (old) {++ if (old.definition_id !== id || old.currency !== input.currency) fail(409, 'inventory.key_reused', 'This key names another purchase');++ return old;++ }++ const prior = await db.maybe('SELECT * FROM inventory_store_purchases WHERE subject = $1 AND definition_id = $2', [subject, id]);++ if (prior) {++ if (prior.currency !== input.currency) fail(409, 'inventory.purchase_pending', 'Resume the existing purchase in its original currency');++ return prior;++ }++ const owned = await db.maybe("SELECT id FROM inventory_instances WHERE definition_id = $1 AND owner_subject = $2 AND state = 'owned'", [id, subject]);++ if (owned) fail(409, 'inventory.already_owned', 'You already own this item');++ const listing = await db.maybe("SELECT * FROM inventory_store_listings WHERE definition_id = $1 AND status = 'published'", [id]);++ if (!listing || d.status !== 'published') fail(404, 'inventory.listing_not_found', 'This item is not for sale');++ const amount = listing.prices[input.currency];++ if (!Number.isSafeInteger(amount) || amount <= 0) fail(422, 'inventory.currency_not_offered', 'This currency is not offered');++ if (d.supply_cap != null && Number(d.supply_issued) >= Number(d.supply_cap)) fail(409, 'inventory.supply_exhausted', 'This item is sold out');++ const serial = d.supply_cap == null ? null : Number(d.supply_issued) + 1;++ await db.exec('UPDATE inventory_definitions SET supply_issued = supply_issued + 1 WHERE id = $1', [id]);++ return db.maybe(`INSERT INTO inventory_store_purchases++ (id, subject, definition_id, idempotency_key, currency, amount, name, issuer, serial, created_at)++ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) RETURNING *`,++ [s.newId('isp'), subject, id, input.idempotency_key, input.currency, amount, d.name, d.issuer, serial, s.iso()]);++ });++ }++ async function pay(p, authorization) {++ let r, body;++ try {++ r = await fetchImpl(`${config.billingUrl}/api/v1/me/store/spend`, { method: 'POST',++ headers: { Authorization: authorization, 'Content-Type': 'application/json' }, credentials: 'omit', redirect: 'error',++ body: JSON.stringify({ purchase_id: p.id }), signal: AbortSignal.timeout(15000) });++ body = await r.json();++ } catch { fail(504, 'inventory.payment_unknown', 'Payment is not confirmed. Retry this purchase to recover its original result.'); }++ if (!r.ok) fail(r.status, body.code || 'inventory.payment_failed', body.detail || 'Payment was refused');++ if (!body.payment_id || body.purchase_id !== p.id || body.currency !== p.currency || Number(body.amount) !== Number(p.amount)) {++ fail(502, 'inventory.payment_unknown', 'Payment confirmation is invalid; retry this purchase');++ }++ return body;++ }++ async function fulfill(p, paid) {++ return s.tx(async () => {++ const row = await db.maybe('SELECT * FROM inventory_store_purchases WHERE id = $1 FOR UPDATE', [p.id]);++ if (row.instance_id) return present(row);++ // A reserved, paid item is delivered even if the listing changed while payment was in flight.++ await db.maybe('SELECT id FROM inventory_definitions WHERE id = $1 FOR UPDATE', [p.definition_id]);++ let inst = await db.maybe("SELECT id FROM inventory_instances WHERE definition_id = $1 AND owner_subject = $2 AND state = 'owned'", [p.definition_id, p.subject]);++ if (!inst) {++ inst = { id: s.newId('inv') };++ await db.exec(`INSERT INTO inventory_instances (id, definition_id, owner_subject, origin, state, attributes, serial, acquired_at, updated_at)++ VALUES ($1,$2,$3,'granted','owned','{}',$4,$5,$5)`, [inst.id, p.definition_id, p.subject, p.serial, s.iso()]);++ const def = await inv.getDefinition(p.definition_id);++ await outbox.emit({ event_type: 'inventory.item.granted', version: 1, source: 'inventory', actor: actorOf('service:inventory'),++ subject: { type: 'user', id: p.subject }, visibility: 'subject',++ payload: { instance_id: inst.id, definition_id: p.definition_id, kind: def.kind, owner: p.subject, origin: 'granted', issuer: p.issuer } });++ } else {++ await db.exec('UPDATE inventory_definitions SET supply_issued = supply_issued - 1 WHERE id = $1', [p.definition_id]);++ }++ await inv.ledger('purchased', { owner: p.subject, instance: inst.id, definition: p.definition_id, actor: `user:${p.subject}`,++ detail: { purchase_id: p.id, payment_id: paid.payment_id, currency: p.currency, amount: Number(p.amount) } });++ await db.exec('UPDATE inventory_store_purchases SET instance_id = $2, payment_id = $3 WHERE id = $1', [p.id, inst.id, paid.payment_id]);++ return present({ ...row, instance_id: inst.id, payment_id: paid.payment_id });++ });++ }++ async function buy(subject, id, input, authorization) {++ const p = await reserve(subject, id, input);++ if (p.instance_id) return present(p);++ return fulfill(p, await pay(p, authorization));++ }++ return { list, submit, review, purchase, buy };++}++++module.exports = { createCommerce };@@@ -12,7 +12,7 @@* inventory a person's instances with their definitions (public: owned ones of published or retired items)* equipped many people's equipped sets in one query; equip, clear*-- * Nothing here sells, buys, trades or converts an item (ADR-054 §5): there is no price column and no such route.++ * Reviewed sales and durable reservations live in commerce.js; Billing authorizes their payment.*/const Ajv = require('ajv/dist/2020');@@@ -294,6 +294,8 @@ function createInventory({ s, outbox, log = console }) {const kind = await getKind(def.kind);if (b.attributes !== undefined) await checkAttributes(kind.id, { ...def.attributes, ...b.attributes });if (!kind.stackable) {++ const buying = await db.maybe('SELECT id FROM inventory_store_purchases WHERE subject = $1 AND definition_id = $2 AND instance_id IS NULL', [b.subject, def.id]);++ if (buying) fail(409, 'inventory.purchase_pending', 'This person has a pending store purchase; finish it before granting');const have = await db.maybe("SELECT * FROM inventory_instances WHERE definition_id = $1 AND owner_subject = $2 AND state = 'owned' LIMIT 1", [def.id, b.subject]);if (have) {await db.exec('INSERT INTO inventory_grants (issuer, idempotency_key, instance_id, created_at) VALUES ($1, $2, $3, $4)', [requester, key, have.id, s.iso()]);@@@ -31,7 +31,7 @@ const { boot, check, done } = require('./helpers/boot');assert.match(r.headers.get('access-control-allow-methods') || '', /GET/);});await check('everything else stays same-site', async () => {-- for (const [p, method] of [['/api/v1/me/items', 'GET'], ['/api/v1/grants', 'POST'], ['/api/v1/me/equipped', 'PUT'], ['/api/v1/ping', 'GET'], ['/items', 'GET']]) {++ for (const [p, method] of [['/api/v1/grants', 'POST'], ['/api/v1/ping', 'GET'], ['/items', 'GET']]) {const r = await t.get(p, { method, headers: origin });assert.strictEqual(r.headers.get('access-control-allow-origin'), null, `${method} ${p}`);assert.strictEqual(r.headers.get('cross-origin-resource-policy'), 'same-site', `${method} ${p}`);nnew file mode 100644@@@ -0,0 +1,74 @@++'use strict';++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++const { createCommerce } = require('../server/inventory/commerce');++++(async () => {++ const t = await boot();++ const alice = t.network.addUser('alice'), bob = t.network.addUser('bob'), staff = t.network.addUser('staff', { role: 'admin' });++ const token = t.network.userToken(alice);++ const payments = new Map(); let lose = false, frozen = false;++ t.ctx.commerce = createCommerce(t.ctx, { fetchImpl: async (url, options) => {++ assert.strictEqual(url, t.config.billingUrl + '/api/v1/me/store/spend');++ assert.strictEqual(options.headers.Authorization, `Bearer ${token}`);++ const { purchase_id } = JSON.parse(options.body);++ const p = await t.ctx.commerce.purchase(alice.subject, purchase_id);++ if (frozen) return { ok: false, status: 503, json: async () => ({ code: 'billing.frozen' }) };++ payments.set(p.id, { purchase_id: p.id, payment_id: `txn:${p.id}`, currency: p.currency, amount: p.amount });++ if (lose) { lose = false; throw new Error('lost response'); }++ return { ok: true, json: async () => payments.get(p.id) };++ } });++ const api = (path, options = {}) => t.get('/api/v1' + path, options);++ const rainbow = await t.ctx.inv.byAlias('service:live', 'fx_rainbow');++ const crown = await t.ctx.inv.byAlias('service:live', 'hat_crown');++ try {++ await check('unreviewed offers stay hidden; only the issuer submits and verified staff approve', async () => {++ await assert.rejects(t.ctx.commerce.submit('service:quest', rainbow.id, { prices: { vibes: 12 } }), (e) => e.status === 403);++ const noCap = await api(`/store/${rainbow.id}/list`, { bearer: t.network.serviceToken('live', []), json: { prices: { vibes: 12 } } });++ assert.strictEqual(noCap.status, 403);++ const submitted = await api(`/store/${rainbow.id}/list`, { bearer: t.network.serviceToken('live', ['inventory.store.list']), json: { prices: { vibes: 12, coins: 20 } } });++ assert.strictEqual(submitted.status, 201, submitted.text);++ assert.deepStrictEqual((await api('/store')).json().items, []);++ assert.strictEqual((await api(`/store/${rainbow.id}/review`, { bearer: token, json: { approve: true } })).status, 403);++ const r = await api(`/store/${rainbow.id}/review`, { bearer: t.network.userToken(staff), json: { approve: true } });++ assert.strictEqual(r.status, 200, r.text);++ const item = (await api('/store')).json().items[0];++ assert.strictEqual(item.issuer, 'service:live'); assert(item.preview && item.surfaces.length);++ assert.deepStrictEqual(item.prices, { vibes: 12, coins: 20 });++ });++ await check('bearer purchase ignores a supplied price and subject; retry and alternate keys deliver once', async () => {++ const body = { currency: 'vibes', idempotency_key: 'rainbow-purchase', amount: 1, subject: bob.subject };++ const a = await api(`/store/${rainbow.id}/buy`, { bearer: token, json: body });++ assert.strictEqual(a.status, 200, a.text); assert.strictEqual(a.json().amount, 12);++ assert.strictEqual(a.json().subject, alice.subject);++ const b = await api(`/store/${rainbow.id}/buy`, { bearer: token, json: { ...body, idempotency_key: 'another-key-same-item' } });++ assert.strictEqual(b.json().instance_id, a.json().instance_id); assert.strictEqual(payments.size, 1);++ assert.strictEqual((await api('/store', { bearer: token })).json().items[0].owned, true);++ assert.strictEqual((await api(`/store/purchases/${a.json().id}`, { bearer: t.network.userToken(bob) })).status, 404);++ assert.strictEqual((await api(`/store/${rainbow.id}/buy`, { as: alice, json: body })).status, 401);++ });++ await check('lost payment response and concurrent retries deliver one item', async () => {++ await t.ctx.commerce.submit('service:live', crown.id, { prices: { coins: 30 } });++ await t.ctx.commerce.review('user:staff', crown.id, true);++ const body = { currency: 'coins', idempotency_key: 'crown-purchase-01' };++ lose = true;++ assert.strictEqual((await api(`/store/${crown.id}/buy`, { bearer: token, json: body })).status, 504);++ const [a, b] = await Promise.all([api(`/store/${crown.id}/buy`, { bearer: token, json: body }), api(`/store/${crown.id}/buy`, { bearer: token, json: body })]);++ assert.strictEqual(a.status, 200, a.text); assert.strictEqual(b.status, 200, b.text);++ assert.strictEqual(a.json().instance_id, b.json().instance_id); assert.strictEqual(payments.size, 2);++ assert.strictEqual(Number(await t.ctx.s.db.value("SELECT count(*) FROM inventory_ledger WHERE action = 'purchased'")), 2);++ const eq = await api('/me/equipped', { bearer: token, method: 'PUT', json: { kind: crown.kind, slot: 'hat', instance_id: a.json().instance_id } });++ assert.strictEqual(eq.status, 200, eq.text);++ });++ await check('price changes require another review and bearer CORS does not allow cookies or tenant origins', async () => {++ await t.ctx.commerce.submit('service:live', rainbow.id, { prices: { vibes: 99 } });++ assert(!(await api('/store')).json().items.some((i) => i.id === rainbow.id));++ const pre = await api('/me/equipped', { method: 'OPTIONS', headers: { Origin: 'https://openvibe.live' } });++ assert.strictEqual(pre.status, 204); assert.strictEqual(pre.headers.get('access-control-allow-origin'), 'https://openvibe.live');++ assert.strictEqual(pre.headers.get('access-control-allow-credentials'), null);++ const bad = await api('/store', { headers: { Origin: 'https://x.openvibe.website' } });++ assert.strictEqual(bad.headers.get('access-control-allow-origin'), null);++ });++ } finally { await t.close(); }++ done();++})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,9 @@
@@ -0,0 +1,9 @@
+# Universal store
+
+GET /api/v1/store returns staff-reviewed cosmetics, issuer, prices in coins/vibes, preview, stock and ownership. POST /api/v1/store/:id/buy requires a personal bearer and {currency,idempotency_key}; price and buyer overrides are ignored. Existing /api/v1/me/items and PUT /api/v1/me/equipped support the shared sheet with first-party bearer CORS, no cookies.
+
+POST /api/v1/store/:id/list requires inventory.store.list and the definition issuer. It accepts positive integer prices and always enters review. Staff approve through POST /api/v1/store/:id/review with {approve:true}. Price edits require another review. No prices are invented and no products are automatically listed. The cosmetic store sells non-stackable items.
+
+A durable order snapshots the reviewed price and reserves one serial under the ordinary grant lock. Billing reads that order with the buyer's bearer before charging. Inventory matches Billing's payment id, purchase id, amount and currency before granting, then records a purchased ledger row and the ordinary grant event. Alternate keys cannot buy the same item twice. An uncertain response is recovered by retrying the same item/currency. Pending stock reservations never expire automatically: reconcile uncertain payments before releasing stock. Purchases export with the account and lose their subject on deletion.
+
+Rollout: the review branch pins Contracts PR #371 so inventory.store.list is usable in tests; replace it with the reviewed release tag before shipping. BILLING_URL defaults to Billing's public origin. Billing needs Inventory's URL and Network's network.coins.debit grant scoped to billing. Issuers submit prices and staff approve before items appear. Consuming sites need Billing/Inventory connect-src entries when pinning the Shared wallet release. No production settings or deployments are changed here.
new file mode 100644
new file mode 100644
@@ -0,0 +1,26 @@
@@ -0,0 +1,26 @@
+-- phase: expand
+-- Reviewed listings and durable purchase reservations. Network still holds OpenCoins; Billing authorizes every spend.
+CREATE TABLE inventory_store_listings (
+ definition_id text COLLATE "C" PRIMARY KEY REFERENCES inventory_definitions(id),
+ issuer text NOT NULL,
+ prices jsonb NOT NULL,
+ status text NOT NULL CHECK (status IN ('in_review', 'published', 'rejected')),
+ reviewed_by text,
+ updated_at text NOT NULL
+);
+CREATE TABLE inventory_store_purchases (
+ id text COLLATE "C" PRIMARY KEY,
+ subject text COLLATE "C",
+ definition_id text COLLATE "C" NOT NULL REFERENCES inventory_definitions(id),
+ idempotency_key text NOT NULL,
+ currency text NOT NULL CHECK (currency IN ('vibes', 'coins')),
+ amount bigint NOT NULL CHECK (amount > 0),
+ name text NOT NULL,
+ issuer text NOT NULL,
+ serial bigint,
+ instance_id text COLLATE "C",
+ payment_id text,
+ created_at text NOT NULL,
+ UNIQUE(subject, idempotency_key),
+ UNIQUE(subject, definition_id)
+);
@@ -17,7 +17,7 @@
@@ -17,7 +17,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.338.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/heads/wallet/contracts-1108",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.168.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.168.0",
@@ -1160,9 +1160,9 @@
@@ -1160,9 +1160,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.338.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.338.0",
- "integrity": "sha512-75FD6IxMsXoud3NFXKDs0/D0rhJtF2D+WL8M92ArIyabkUnM/sZvw6E6L97EqwQ3O7M/kB+uwsNSxGudbil+ng==",
+ "version": "0.352.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/heads/wallet/contracts-1108",
+ "integrity": "sha512-Y6bz1qpxY3Qh9TCEhfPFvX2tZAJFmLjdkyMR2k2NYeMxL6dUQxz1gUacZEEFcBKMq4QqKDisylzBNDCvtCkiqg==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
"name": "openvibe-inventory",
"name": "openvibe-inventory",
"version": "0.4.1",
"version": "0.4.1",
"private": true,
"private": true,
- "description": "OpenVibe.Inventory \u2014 Your items, on every OpenVibe site.",
+ "description": "OpenVibe.Inventory — Your items, on every OpenVibe site.",
"main": "server/index.js",
"main": "server/index.js",
"scripts": {
"scripts": {
"start": "node server/index.js",
"start": "node server/index.js",
@@ -24,7 +24,7 @@
@@ -24,7 +24,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.338.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/heads/wallet/contracts-1108",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.168.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.168.0",
@@ -91,6 +91,7 @@ async function createApp(opts = {}) {
@@ -91,6 +91,7 @@ async function createApp(opts = {}) {
? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl })
? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl })
: async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Inventory cannot answer account events'); });
: async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Inventory cannot answer account events'); });
const ctx = { config, s, keys, sso, principal, log, outbox, inv, accountData, searchIndex };
const ctx = { config, s, keys, sso, principal, log, outbox, inv, accountData, searchIndex };
+ ctx.commerce = require('./inventory/commerce').createCommerce(ctx, { fetchImpl });
// The Workshop (ADR-054 §6): images in this service's own Media tenant, recipients by @name through the Network.
// The Workshop (ADR-054 §6): images in this service's own Media tenant, recipients by @name through the Network.
ctx.workshopMedia = opts.workshopMedia || createWorkshopMedia({ config, fetchImpl: opts.fetchImpl, log });
ctx.workshopMedia = opts.workshopMedia || createWorkshopMedia({ config, fetchImpl: opts.fetchImpl, log });
ctx.people = opts.people || createPeople({ config, fetchImpl: opts.fetchImpl });
ctx.people = opts.people || createPeople({ config, fetchImpl: opts.fetchImpl });
@@ -165,7 +166,7 @@ async function createApp(opts = {}) {
@@ -165,7 +166,7 @@ async function createApp(opts = {}) {
fontSrc: ["'self'", 'data:', 'https://fonts.gstatic.com', 'https://cdnjs.cloudflare.com'],
fontSrc: ["'self'", 'data:', 'https://fonts.gstatic.com', 'https://cdnjs.cloudflare.com'],
imgSrc: ["'self'", 'data:', 'https:'],
imgSrc: ["'self'", 'data:', 'https:'],
// The navbar's Messages count asks Chat (openvibe-shared 4.130.0).
// The navbar's Messages count asks Chat (openvibe-shared 4.130.0).
- connectSrc: ["'self'", 'https://openvibe.chat', 'https://openvibe.network', 'https://cloudflareinsights.com', 'https://openvibe.events'],
+ connectSrc: ["'self'", 'https://billing.openvibe.network', 'https://inventory.openvibe.network', 'https://openvibe.chat', 'https://openvibe.network', 'https://cloudflareinsights.com', 'https://openvibe.events'],
frameSrc: ["'self'", 'https://openvibe.network'],
frameSrc: ["'self'", 'https://openvibe.network'],
frameAncestors: ["'none'"],
frameAncestors: ["'none'"],
objectSrc: ["'none'"],
objectSrc: ["'none'"],
@@ -244,6 +245,7 @@ async function createApp(opts = {}) {
@@ -244,6 +245,7 @@ async function createApp(opts = {}) {
// Public reads from any site: the catalog and anyone's items and equipped set answer every origin, without
// Public reads from any site: the catalog and anyone's items and equipped set answer every origin, without
// credentials (openvibe-shared/items.js draws what people wear with them, on every OpenVibe site). Everything else
// credentials (openvibe-shared/items.js draws what people wear with them, on every OpenVibe site). Everything else
// keeps the same-site resource policy and no CORS. A preflight is answered here.
// keeps the same-site resource policy and no CORS. A preflight is answered here.
+ app.use('/api/v1', require('./http/wallet-cors'));
const PUBLIC_READ = /^\/(?:kinds(?:\/[^/]+)?|definitions(?:\/[^/]+)?|equipped|people\/[^/]+\/(?:items|equipped))$/;
const PUBLIC_READ = /^\/(?:kinds(?:\/[^/]+)?|definitions(?:\/[^/]+)?|equipped|people\/[^/]+\/(?:items|equipped))$/;
app.use('/api/v1', (req, res, next) => {
app.use('/api/v1', (req, res, next) => {
if (!PUBLIC_READ.test(req.path) || !['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return next();
if (!PUBLIC_READ.test(req.path) || !['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return next();
@@ -43,6 +43,7 @@ function load(env = process.env) {
@@ -43,6 +43,7 @@ function load(env = process.env) {
valkey: { url: env.VALKEY_URL || '', prefix: env.VALKEY_PREFIX || 'ov:inventory:' },
valkey: { url: env.VALKEY_URL || '', prefix: env.VALKEY_PREFIX || 'ov:inventory:' },
// OpenVibe.Network: SSO (OAuth2 authorization server with PKCE) and its JWKS.
// OpenVibe.Network: SSO (OAuth2 authorization server with PKCE) and its JWKS.
+ billingUrl: trim(env.BILLING_URL || 'https://billing.openvibe.network'),
networkUrl,
networkUrl,
networkInternalUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),
networkInternalUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),
networkIssuer: trim(env.OV_NETWORK_ISSUER || networkUrl),
networkIssuer: trim(env.OV_NETWORK_ISSUER || networkUrl),
@@ -17,8 +17,8 @@
@@ -17,8 +17,8 @@
* POST /definitions, PATCH /definitions/:id inventory.definition.manage an issuer's definitions
* POST /definitions, PATCH /definitions/:id inventory.definition.manage an issuer's definitions
*
*
* A person acts for themself (their token or this site's session). A service or app acting for a person sends that
* A person acts for themself (their token or this site's session). A service or app acting for a person sends that
- * person's subject in X-OV-Subject and holds the route's capability. Nothing here sells, buys, trades or converts an
- * item (ADR-054 §5). A refusal is an RFC 9457 problem+json with a stable code.
+ * person's subject in X-OV-Subject and holds the route's capability. Store purchases require the person's bearer
+ * and Billing authorizes every spend. A refusal is an RFC 9457 problem+json with a stable code.
*/
*/
const express = require('express');
const express = require('express');
const { http } = require('openvibe-contracts');
const { http } = require('openvibe-contracts');
@@ -111,6 +111,22 @@ function createApi(ctx) {
@@ -111,6 +111,22 @@ function createApi(ctx) {
}));
}));
r.patch('/definitions/:id', principal.requireCapability('inventory.definition.manage'), limits.budget('inventory.define'), wrap(async (req, res) => res.json({ definition: await inv.updateDefinition(issuerOf(req), req.params.id, req.body) })));
r.patch('/definitions/:id', principal.requireCapability('inventory.definition.manage'), limits.budget('inventory.define'), wrap(async (req, res) => res.json({ definition: await inv.updateDefinition(issuerOf(req), req.params.id, req.body) })));
+ r.get('/store', read, limits.reads('inventory.item.read'), wrap(async (req, res) => res.json(await ctx.commerce.list(
+ req.principal.kind === 'user' ? req.principal.requester.slice(5) : null))));
+ const bearerPerson = (req, res, next) => {
+ if (req.principal.kind !== 'user' || req.principal.viaSession) return http.sendProblem(res, 401, 'inventory.person_bearer_required');
+ return next();
+ };
+ r.get('/store/purchases/:id', bearerPerson, limits.reads('inventory.item.list'), wrap(async (req, res) =>
+ res.json(await ctx.commerce.purchase(req.principal.requester.slice(5), req.params.id))));
+ r.post('/store/:id/buy', bearerPerson, limits.budget('inventory.define'), wrap(async (req, res) =>
+ res.json(await ctx.commerce.buy(req.principal.requester.slice(5), req.params.id, req.body || {}, req.get('authorization')))));
+ r.post('/store/:id/list', principal.requireCapability('inventory.store.list'), limits.budget('inventory.define'), wrap(async (req, res) =>
+ res.status(201).json(await ctx.commerce.submit(req.principal.requester, req.params.id, req.body || {}))));
+ r.post('/store/:id/review', bearerPerson, limits.budget('inventory.define'), wrap(async (req, res) => {
+ if (!['admin', 'global_mod'].includes(req.principal.role)) return http.sendProblem(res, 403, 'inventory.staff_only');
+ return res.json(await ctx.commerce.review(req.principal.requester, req.params.id, (req.body || {}).approve));
+ }));
return r;
return r;
}
}
@@ -25,7 +25,7 @@ const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
@@ -25,7 +25,7 @@ const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
// route can never be guarded by a capability the service does not declare.
// route can never be guarded by a capability the service does not declare.
const CAPABILITIES = [
const CAPABILITIES = [
'inventory.item.read', 'inventory.item.list', 'inventory.equip.manage', 'inventory.item.grant', 'inventory.item.consume',
'inventory.item.read', 'inventory.item.list', 'inventory.equip.manage', 'inventory.item.grant', 'inventory.item.consume',
- 'inventory.definition.manage', 'inventory.definition.review',
+ 'inventory.definition.manage', 'inventory.definition.review', 'inventory.store.list',
];
];
function decodePayload(token) {
function decodePayload(token) {
@@ -66,7 +66,7 @@ function createPrincipal({ config, keys }) {
@@ -66,7 +66,7 @@ function createPrincipal({ config, keys }) {
if (!v.ok) return { error: [401, v.expired ? 'token.expired' : 'token.invalid', v.reason] };
if (!v.ok) return { error: [401, v.expired ? 'token.expired' : 'token.invalid', v.reason] };
if (v.claims.typ === 'fedcm' || v.claims.actor_type !== undefined) return { error: [401, 'token.invalid', 'not a person\'s token'] };
if (v.claims.typ === 'fedcm' || v.claims.actor_type !== undefined) return { error: [401, 'token.invalid', 'not a person\'s token'] };
if (!ids.isSubjectId('user', v.claims.subject_id)) return { error: [403, 'identity.no_subject', 'this account has no canonical subject yet; sign in again'] };
if (!ids.isSubjectId('user', v.claims.subject_id)) return { error: [403, 'identity.no_subject', 'this account has no canonical subject yet; sign in again'] };
- return { principal: { kind: 'user', requester: `user:${v.claims.subject_id}`, project: null, viaSession: false } };
+ return { principal: { kind: 'user', requester: `user:${v.claims.subject_id}`, role: v.claims.role, project: null, viaSession: false } };
}
}
const viewer = req.viewer;
const viewer = req.viewer;
if (viewer && viewer.kind === 'user' && ids.isSubjectId('user', viewer.subject)) {
if (viewer && viewer.kind === 'user' && ids.isSubjectId('user', viewer.subject)) {
new file mode 100644
new file mode 100644
@@ -0,0 +1,20 @@
@@ -0,0 +1,20 @@
+'use strict';
+const contracts = require('openvibe-contracts');
+const origins = new Set();
+for (const service of contracts.services.manifests) {
+ for (const domain of service.domains || []) {
+ if (/^[a-z0-9.-]+\.[a-z]{2,63}$/.test(domain)) {
+ origins.add(`https://${domain}`); origins.add(`https://www.${domain}`);
+ }
+ }
+}
+module.exports = (req, res, next) => {
+ if (!/^\/(?:store(?:\/[^/]+(?:\/(?:buy|list|review))?|\/purchases\/[^/]+)?|me\/(?:items|equipped))$/.test(req.path)) return next();
+ res.vary('Origin');
+ const origin = req.get('origin');
+ if (origins.has(origin)) res.set({ 'Access-Control-Allow-Origin': origin,
+ 'Access-Control-Allow-Methods': 'GET, POST, PUT', 'Access-Control-Allow-Headers': 'Authorization, Content-Type, Traceparent, X-OpenVibe-Request-Id',
+ 'Cross-Origin-Resource-Policy': 'cross-origin', 'Access-Control-Max-Age': '600' });
+ if (req.method === 'OPTIONS') return res.sendStatus(204);
+ return next();
+};
@@ -16,6 +16,7 @@
@@ -16,6 +16,7 @@
const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');
const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');
const TABLES = [
const TABLES = [
+ { table: 'inventory_store_purchases', subject: 'subject', file: 'purchases.json', columns: ['id', 'definition_id', 'currency', 'amount', 'name', 'instance_id', 'payment_id', 'created_at'], order: 'created_at', erase: { anonymize: {} } },
// Equipped rows reference instances, so they go first.
// Equipped rows reference instances, so they go first.
{ table: 'inventory_equipped', subject: 'owner_subject', file: 'equipped.json', columns: ['kind', 'slot', 'instance_id', 'updated_at'], order: 'updated_at' },
{ table: 'inventory_equipped', subject: 'owner_subject', file: 'equipped.json', columns: ['kind', 'slot', 'instance_id', 'updated_at'], order: 'updated_at' },
{ table: 'inventory_instances', subject: 'owner_subject', file: 'items.json', columns: ['id', 'definition_id', 'origin', 'state', 'attributes', 'serial', 'acquired_at'], order: 'acquired_at' },
{ table: 'inventory_instances', subject: 'owner_subject', file: 'items.json', columns: ['id', 'definition_id', 'origin', 'state', 'attributes', 'serial', 'acquired_at'], order: 'acquired_at' },
@@ -25,6 +26,8 @@ const TABLES = [
@@ -25,6 +26,8 @@ const TABLES = [
/** A ledger row the person acted in (an equip they made) keeps the row and loses their name. */
/** A ledger row the person acted in (an equip they made) keeps the row and loses their name. */
async function extraErase(t, subjects, counts) {
async function extraErase(t, subjects, counts) {
+ await t.exec("UPDATE inventory_store_listings SET reviewed_by = NULL WHERE reviewed_by = ANY($1::text[])", [subjects.map((x) => `user:${x}`)]);
+ await t.exec("UPDATE inventory_store_listings SET issuer = 'user:deleted', status = 'rejected' WHERE issuer = ANY($1::text[])", [subjects.map((x) => `user:${x}`)]);
const people = subjects.map((x) => `user:${x}`);
const people = subjects.map((x) => `user:${x}`);
counts.add(counts.retained, 'tombstones', await t.exec("UPDATE inventory_ledger SET actor = 'user:deleted' WHERE actor = ANY($1::text[])", [people]));
counts.add(counts.retained, 'tombstones', await t.exec("UPDATE inventory_ledger SET actor = 'user:deleted' WHERE actor = ANY($1::text[])", [people]));
// Their Workshop items: the kind's issuer takes them over, retired, without the review note (ADR-054 §6).
// Their Workshop items: the kind's issuer takes them over, retired, without the review note (ADR-054 §6).
new file mode 100644
new file mode 100644
@@ -0,0 +1,142 @@
@@ -0,0 +1,142 @@
+'use strict';
+
+const { InventoryError, DEF_RE, actorOf } = require('./store');
+const fail = (status, code, detail) => { throw new InventoryError(status, code, detail); };
+const PURCHASE_RE = /^isp_[0-9A-HJKMNP-TV-Z]{26}$/;
+
+function createCommerce(ctx, { fetchImpl = globalThis.fetch } = {}) {
+ const { s, inv, outbox, config } = ctx;
+ const db = s.db;
+ function pricesOf(prices) {
+ if (!prices || typeof prices !== 'object' || Array.isArray(prices) || !Object.keys(prices).length ||
+ Object.entries(prices).some(([k, v]) => !['coins', 'vibes'].includes(k) || !Number.isSafeInteger(v) || v < 1)) {
+ fail(422, 'inventory.invalid_prices', 'Offer positive whole prices in coins, vibes, or both');
+ }
+ return prices;
+ }
+ async function submit(issuer, id, input) {
+ if (!DEF_RE.test(id)) fail(404, 'inventory.definition_not_found', 'No such item');
+ const prices = pricesOf(input.prices);
+ return s.tx(async () => {
+ const def = await db.maybe('SELECT * FROM inventory_definitions WHERE id = $1 FOR UPDATE', [id]);
+ if (!def || def.issuer !== issuer) fail(403, 'inventory.not_issuer', 'List only items issued by your app');
+ if (def.status !== 'published') fail(409, 'inventory.not_published', 'Publish the item before listing it');
+ const kind = await inv.getKind(def.kind);
+ if (kind.stackable) fail(422, 'inventory.store_nonstackable', 'The cosmetic store sells one owned item per person');
+ await db.exec(`INSERT INTO inventory_store_listings (definition_id, issuer, prices, status, updated_at)
+ VALUES ($1, $2, $3, 'in_review', $4) ON CONFLICT (definition_id) DO UPDATE
+ SET prices = excluded.prices, status = 'in_review', reviewed_by = NULL, updated_at = excluded.updated_at`,
+ [id, issuer, JSON.stringify(prices), s.iso()]);
+ return { definition_id: id, issuer, prices, status: 'in_review' };
+ });
+ }
+ async function review(staff, id, approve) {
+ if (typeof approve !== 'boolean') fail(422, 'inventory.invalid_review', 'approve is a boolean');
+ const r = await db.maybe(`UPDATE inventory_store_listings SET status = $2, reviewed_by = $3, updated_at = $4
+ WHERE definition_id = $1 RETURNING definition_id, issuer, prices, status`, [id, approve ? 'published' : 'rejected', staff, s.iso()]);
+ if (!r) fail(404, 'inventory.listing_not_found', 'No listing to review');
+ return r;
+ }
+ async function list(subject = null) {
+ const rows = await db.many(`SELECT l.definition_id, l.issuer, l.prices, d.name, d.description, d.art, d.kind,
+ d.supply_cap, d.supply_issued, k.slots, k.surfaces,
+ EXISTS(SELECT 1 FROM inventory_instances i WHERE i.definition_id = d.id AND i.owner_subject = $1 AND i.state = 'owned') AS owned
+ FROM inventory_store_listings l JOIN inventory_definitions d ON d.id = l.definition_id
+ JOIN inventory_kinds k ON k.id = d.kind WHERE l.status = 'published' AND d.status = 'published'
+ ORDER BY d.name, d.id LIMIT 200`, [subject]);
+ return { items: rows.map((r) => ({ id: r.definition_id, name: r.name, description: r.description || '', issuer: r.issuer,
+ prices: r.prices, preview: r.art, kind: r.kind, slots: r.slots, surfaces: r.surfaces, owned: r.owned,
+ available: r.supply_cap == null || Number(r.supply_issued) < Number(r.supply_cap) })) };
+ }
+ function present(p) {
+ return { id: p.id, subject: p.subject, item_id: p.definition_id, name: p.name, issuer: p.issuer,
+ currency: p.currency, amount: Number(p.amount), status: p.instance_id ? 'fulfilled' : 'pending',
+ instance_id: p.instance_id || null, payment_id: p.payment_id || null };
+ }
+ async function purchase(subject, id) {
+ if (!PURCHASE_RE.test(id)) fail(404, 'inventory.purchase_not_found', 'No such purchase');
+ const p = await db.maybe('SELECT * FROM inventory_store_purchases WHERE id = $1 AND subject = $2', [id, subject]);
+ if (!p) fail(404, 'inventory.purchase_not_found', 'No such purchase');
+ return present(p);
+ }
+ async function reserve(subject, id, input) {
+ if (!DEF_RE.test(id)) fail(404, 'inventory.definition_not_found', 'No such item');
+ if (!/^[A-Za-z0-9._:-]{8,128}$/.test(input.idempotency_key || '') || !['coins', 'vibes'].includes(input.currency)) {
+ fail(422, 'inventory.invalid_purchase', 'Send currency and an idempotency_key of 8–128 characters');
+ }
+ // Lock the definition exactly as ordinary grants do; the reserved serial counts against the supply cap.
+ return s.tx(async () => {
+ const d = await db.maybe('SELECT * FROM inventory_definitions WHERE id = $1 FOR UPDATE', [id]);
+ if (!d) fail(404, 'inventory.definition_not_found', 'No such item');
+ const old = await db.maybe('SELECT * FROM inventory_store_purchases WHERE subject = $1 AND idempotency_key = $2', [subject, input.idempotency_key]);
+ if (old) {
+ if (old.definition_id !== id || old.currency !== input.currency) fail(409, 'inventory.key_reused', 'This key names another purchase');
+ return old;
+ }
+ const prior = await db.maybe('SELECT * FROM inventory_store_purchases WHERE subject = $1 AND definition_id = $2', [subject, id]);
+ if (prior) {
+ if (prior.currency !== input.currency) fail(409, 'inventory.purchase_pending', 'Resume the existing purchase in its original currency');
+ return prior;
+ }
+ const owned = await db.maybe("SELECT id FROM inventory_instances WHERE definition_id = $1 AND owner_subject = $2 AND state = 'owned'", [id, subject]);
+ if (owned) fail(409, 'inventory.already_owned', 'You already own this item');
+ const listing = await db.maybe("SELECT * FROM inventory_store_listings WHERE definition_id = $1 AND status = 'published'", [id]);
+ if (!listing || d.status !== 'published') fail(404, 'inventory.listing_not_found', 'This item is not for sale');
+ const amount = listing.prices[input.currency];
+ if (!Number.isSafeInteger(amount) || amount <= 0) fail(422, 'inventory.currency_not_offered', 'This currency is not offered');
+ if (d.supply_cap != null && Number(d.supply_issued) >= Number(d.supply_cap)) fail(409, 'inventory.supply_exhausted', 'This item is sold out');
+ const serial = d.supply_cap == null ? null : Number(d.supply_issued) + 1;
+ await db.exec('UPDATE inventory_definitions SET supply_issued = supply_issued + 1 WHERE id = $1', [id]);
+ return db.maybe(`INSERT INTO inventory_store_purchases
+ (id, subject, definition_id, idempotency_key, currency, amount, name, issuer, serial, created_at)
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) RETURNING *`,
+ [s.newId('isp'), subject, id, input.idempotency_key, input.currency, amount, d.name, d.issuer, serial, s.iso()]);
+ });
+ }
+ async function pay(p, authorization) {
+ let r, body;
+ try {
+ r = await fetchImpl(`${config.billingUrl}/api/v1/me/store/spend`, { method: 'POST',
+ headers: { Authorization: authorization, 'Content-Type': 'application/json' }, credentials: 'omit', redirect: 'error',
+ body: JSON.stringify({ purchase_id: p.id }), signal: AbortSignal.timeout(15000) });
+ body = await r.json();
+ } catch { fail(504, 'inventory.payment_unknown', 'Payment is not confirmed. Retry this purchase to recover its original result.'); }
+ if (!r.ok) fail(r.status, body.code || 'inventory.payment_failed', body.detail || 'Payment was refused');
+ if (!body.payment_id || body.purchase_id !== p.id || body.currency !== p.currency || Number(body.amount) !== Number(p.amount)) {
+ fail(502, 'inventory.payment_unknown', 'Payment confirmation is invalid; retry this purchase');
+ }
+ return body;
+ }
+ async function fulfill(p, paid) {
+ return s.tx(async () => {
+ const row = await db.maybe('SELECT * FROM inventory_store_purchases WHERE id = $1 FOR UPDATE', [p.id]);
+ if (row.instance_id) return present(row);
+ // A reserved, paid item is delivered even if the listing changed while payment was in flight.
+ await db.maybe('SELECT id FROM inventory_definitions WHERE id = $1 FOR UPDATE', [p.definition_id]);
+ let inst = await db.maybe("SELECT id FROM inventory_instances WHERE definition_id = $1 AND owner_subject = $2 AND state = 'owned'", [p.definition_id, p.subject]);
+ if (!inst) {
+ inst = { id: s.newId('inv') };
+ await db.exec(`INSERT INTO inventory_instances (id, definition_id, owner_subject, origin, state, attributes, serial, acquired_at, updated_at)
+ VALUES ($1,$2,$3,'granted','owned','{}',$4,$5,$5)`, [inst.id, p.definition_id, p.subject, p.serial, s.iso()]);
+ const def = await inv.getDefinition(p.definition_id);
+ await outbox.emit({ event_type: 'inventory.item.granted', version: 1, source: 'inventory', actor: actorOf('service:inventory'),
+ subject: { type: 'user', id: p.subject }, visibility: 'subject',
+ payload: { instance_id: inst.id, definition_id: p.definition_id, kind: def.kind, owner: p.subject, origin: 'granted', issuer: p.issuer } });
+ } else {
+ await db.exec('UPDATE inventory_definitions SET supply_issued = supply_issued - 1 WHERE id = $1', [p.definition_id]);
+ }
+ await inv.ledger('purchased', { owner: p.subject, instance: inst.id, definition: p.definition_id, actor: `user:${p.subject}`,
+ detail: { purchase_id: p.id, payment_id: paid.payment_id, currency: p.currency, amount: Number(p.amount) } });
+ await db.exec('UPDATE inventory_store_purchases SET instance_id = $2, payment_id = $3 WHERE id = $1', [p.id, inst.id, paid.payment_id]);
+ return present({ ...row, instance_id: inst.id, payment_id: paid.payment_id });
+ });
+ }
+ async function buy(subject, id, input, authorization) {
+ const p = await reserve(subject, id, input);
+ if (p.instance_id) return present(p);
+ return fulfill(p, await pay(p, authorization));
+ }
+ return { list, submit, review, purchase, buy };
+}
+
+module.exports = { createCommerce };
@@ -12,7 +12,7 @@
@@ -12,7 +12,7 @@
* inventory a person's instances with their definitions (public: owned ones of published or retired items)
* inventory a person's instances with their definitions (public: owned ones of published or retired items)
* equipped many people's equipped sets in one query; equip, clear
* equipped many people's equipped sets in one query; equip, clear
*
*
- * Nothing here sells, buys, trades or converts an item (ADR-054 §5): there is no price column and no such route.
+ * Reviewed sales and durable reservations live in commerce.js; Billing authorizes their payment.
*/
*/
const Ajv = require('ajv/dist/2020');
const Ajv = require('ajv/dist/2020');
@@ -294,6 +294,8 @@ function createInventory({ s, outbox, log = console }) {
@@ -294,6 +294,8 @@ function createInventory({ s, outbox, log = console }) {
const kind = await getKind(def.kind);
const kind = await getKind(def.kind);
if (b.attributes !== undefined) await checkAttributes(kind.id, { ...def.attributes, ...b.attributes });
if (b.attributes !== undefined) await checkAttributes(kind.id, { ...def.attributes, ...b.attributes });
if (!kind.stackable) {
if (!kind.stackable) {
+ const buying = await db.maybe('SELECT id FROM inventory_store_purchases WHERE subject = $1 AND definition_id = $2 AND instance_id IS NULL', [b.subject, def.id]);
+ if (buying) fail(409, 'inventory.purchase_pending', 'This person has a pending store purchase; finish it before granting');
const have = await db.maybe("SELECT * FROM inventory_instances WHERE definition_id = $1 AND owner_subject = $2 AND state = 'owned' LIMIT 1", [def.id, b.subject]);
const have = await db.maybe("SELECT * FROM inventory_instances WHERE definition_id = $1 AND owner_subject = $2 AND state = 'owned' LIMIT 1", [def.id, b.subject]);
if (have) {
if (have) {
await db.exec('INSERT INTO inventory_grants (issuer, idempotency_key, instance_id, created_at) VALUES ($1, $2, $3, $4)', [requester, key, have.id, s.iso()]);
await db.exec('INSERT INTO inventory_grants (issuer, idempotency_key, instance_id, created_at) VALUES ($1, $2, $3, $4)', [requester, key, have.id, s.iso()]);
@@ -31,7 +31,7 @@ const { boot, check, done } = require('./helpers/boot');
@@ -31,7 +31,7 @@ const { boot, check, done } = require('./helpers/boot');
assert.match(r.headers.get('access-control-allow-methods') || '', /GET/);
assert.match(r.headers.get('access-control-allow-methods') || '', /GET/);
});
});
await check('everything else stays same-site', async () => {
await check('everything else stays same-site', async () => {
- for (const [p, method] of [['/api/v1/me/items', 'GET'], ['/api/v1/grants', 'POST'], ['/api/v1/me/equipped', 'PUT'], ['/api/v1/ping', 'GET'], ['/items', 'GET']]) {
+ for (const [p, method] of [['/api/v1/grants', 'POST'], ['/api/v1/ping', 'GET'], ['/items', 'GET']]) {
const r = await t.get(p, { method, headers: origin });
const r = await t.get(p, { method, headers: origin });
assert.strictEqual(r.headers.get('access-control-allow-origin'), null, `${method} ${p}`);
assert.strictEqual(r.headers.get('access-control-allow-origin'), null, `${method} ${p}`);
assert.strictEqual(r.headers.get('cross-origin-resource-policy'), 'same-site', `${method} ${p}`);
assert.strictEqual(r.headers.get('cross-origin-resource-policy'), 'same-site', `${method} ${p}`);
new file mode 100644
new file mode 100644
@@ -0,0 +1,74 @@
@@ -0,0 +1,74 @@
+'use strict';
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+const { createCommerce } = require('../server/inventory/commerce');
+
+(async () => {
+ const t = await boot();
+ const alice = t.network.addUser('alice'), bob = t.network.addUser('bob'), staff = t.network.addUser('staff', { role: 'admin' });
+ const token = t.network.userToken(alice);
+ const payments = new Map(); let lose = false, frozen = false;
+ t.ctx.commerce = createCommerce(t.ctx, { fetchImpl: async (url, options) => {
+ assert.strictEqual(url, t.config.billingUrl + '/api/v1/me/store/spend');
+ assert.strictEqual(options.headers.Authorization, `Bearer ${token}`);
+ const { purchase_id } = JSON.parse(options.body);
+ const p = await t.ctx.commerce.purchase(alice.subject, purchase_id);
+ if (frozen) return { ok: false, status: 503, json: async () => ({ code: 'billing.frozen' }) };
+ payments.set(p.id, { purchase_id: p.id, payment_id: `txn:${p.id}`, currency: p.currency, amount: p.amount });
+ if (lose) { lose = false; throw new Error('lost response'); }
+ return { ok: true, json: async () => payments.get(p.id) };
+ } });
+ const api = (path, options = {}) => t.get('/api/v1' + path, options);
+ const rainbow = await t.ctx.inv.byAlias('service:live', 'fx_rainbow');
+ const crown = await t.ctx.inv.byAlias('service:live', 'hat_crown');
+ try {
+ await check('unreviewed offers stay hidden; only the issuer submits and verified staff approve', async () => {
+ await assert.rejects(t.ctx.commerce.submit('service:quest', rainbow.id, { prices: { vibes: 12 } }), (e) => e.status === 403);
+ const noCap = await api(`/store/${rainbow.id}/list`, { bearer: t.network.serviceToken('live', []), json: { prices: { vibes: 12 } } });
+ assert.strictEqual(noCap.status, 403);
+ const submitted = await api(`/store/${rainbow.id}/list`, { bearer: t.network.serviceToken('live', ['inventory.store.list']), json: { prices: { vibes: 12, coins: 20 } } });
+ assert.strictEqual(submitted.status, 201, submitted.text);
+ assert.deepStrictEqual((await api('/store')).json().items, []);
+ assert.strictEqual((await api(`/store/${rainbow.id}/review`, { bearer: token, json: { approve: true } })).status, 403);
+ const r = await api(`/store/${rainbow.id}/review`, { bearer: t.network.userToken(staff), json: { approve: true } });
+ assert.strictEqual(r.status, 200, r.text);
+ const item = (await api('/store')).json().items[0];
+ assert.strictEqual(item.issuer, 'service:live'); assert(item.preview && item.surfaces.length);
+ assert.deepStrictEqual(item.prices, { vibes: 12, coins: 20 });
+ });
+ await check('bearer purchase ignores a supplied price and subject; retry and alternate keys deliver once', async () => {
+ const body = { currency: 'vibes', idempotency_key: 'rainbow-purchase', amount: 1, subject: bob.subject };
+ const a = await api(`/store/${rainbow.id}/buy`, { bearer: token, json: body });
+ assert.strictEqual(a.status, 200, a.text); assert.strictEqual(a.json().amount, 12);
+ assert.strictEqual(a.json().subject, alice.subject);
+ const b = await api(`/store/${rainbow.id}/buy`, { bearer: token, json: { ...body, idempotency_key: 'another-key-same-item' } });
+ assert.strictEqual(b.json().instance_id, a.json().instance_id); assert.strictEqual(payments.size, 1);
+ assert.strictEqual((await api('/store', { bearer: token })).json().items[0].owned, true);
+ assert.strictEqual((await api(`/store/purchases/${a.json().id}`, { bearer: t.network.userToken(bob) })).status, 404);
+ assert.strictEqual((await api(`/store/${rainbow.id}/buy`, { as: alice, json: body })).status, 401);
+ });
+ await check('lost payment response and concurrent retries deliver one item', async () => {
+ await t.ctx.commerce.submit('service:live', crown.id, { prices: { coins: 30 } });
+ await t.ctx.commerce.review('user:staff', crown.id, true);
+ const body = { currency: 'coins', idempotency_key: 'crown-purchase-01' };
+ lose = true;
+ assert.strictEqual((await api(`/store/${crown.id}/buy`, { bearer: token, json: body })).status, 504);
+ const [a, b] = await Promise.all([api(`/store/${crown.id}/buy`, { bearer: token, json: body }), api(`/store/${crown.id}/buy`, { bearer: token, json: body })]);
+ assert.strictEqual(a.status, 200, a.text); assert.strictEqual(b.status, 200, b.text);
+ assert.strictEqual(a.json().instance_id, b.json().instance_id); assert.strictEqual(payments.size, 2);
+ assert.strictEqual(Number(await t.ctx.s.db.value("SELECT count(*) FROM inventory_ledger WHERE action = 'purchased'")), 2);
+ const eq = await api('/me/equipped', { bearer: token, method: 'PUT', json: { kind: crown.kind, slot: 'hat', instance_id: a.json().instance_id } });
+ assert.strictEqual(eq.status, 200, eq.text);
+ });
+ await check('price changes require another review and bearer CORS does not allow cookies or tenant origins', async () => {
+ await t.ctx.commerce.submit('service:live', rainbow.id, { prices: { vibes: 99 } });
+ assert(!(await api('/store')).json().items.some((i) => i.id === rainbow.id));
+ const pre = await api('/me/equipped', { method: 'OPTIONS', headers: { Origin: 'https://openvibe.live' } });
+ assert.strictEqual(pre.status, 204); assert.strictEqual(pre.headers.get('access-control-allow-origin'), 'https://openvibe.live');
+ assert.strictEqual(pre.headers.get('access-control-allow-credentials'), null);
+ const bad = await api('/store', { headers: { Origin: 'https://x.openvibe.website' } });
+ assert.strictEqual(bad.headers.get('access-control-allow-origin'), null);
+ });
+ } finally { await t.close(); }
+ done();
+})();