diff --git a/lith/server.mjs b/lith/server.mjs index 9aa0627160..9c87877287 100644 --- a/lith/server.mjs +++ b/lith/server.mjs @@ -1510,6 +1510,23 @@ if (!DEV) { setInterval(reconcileCredits,60_000).unref(); } +// Refund retries remain active even when new App Store sales are paused. +if (!DEV) { + let reconcilingWhistlegraph = false; + const reconcileWhistlegraph = async () => { + if (reconcilingWhistlegraph) return; + reconcilingWhistlegraph = true; + try { + const { reconcileWhistlegraphPurchases } = await import(pathToFileURL(join(SYSTEM, "backend", "whistlegraph-iap-store.mjs")).href); + const result = await reconcileWhistlegraphPurchases(); + if (result.applied) console.log("[lith] reconciled Whistlegraph purchases:", result.applied); + } catch { console.error("[lith] Whistlegraph purchase reconciliation failed; will retry"); } + finally { reconcilingWhistlegraph = false; } + }; + setTimeout(reconcileWhistlegraph, 45_000).unref(); + setInterval(reconcileWhistlegraph, 60_000).unref(); +} + // --- Daily metrics --- // Folds each finished day of visits, downloads and app opens into // `metrics-daily` (system/backend/metrics-daily.mjs). Idempotent, so an diff --git a/system/backend/account-deletion.mjs b/system/backend/account-deletion.mjs index 5026db51d2..77266134ea 100644 --- a/system/backend/account-deletion.mjs +++ b/system/backend/account-deletion.mjs @@ -133,8 +133,43 @@ const EXPORT = [ ["chat-clock", "user"], ["tells", "from"], ["calendar", "user"], + ["walkieware-threads", "owner"], + ["whistlegraph-roblox-rooms", "_id"], ]; +// Export receipts, mint artwork and delivery state without Apple account +// tokens, signed payloads or mint capabilities. An allowlist also keeps +// future provider credentials out of this download. +const PRIVATE_EXPORT = [ + ["whistlegraph-iap-accounts", "_id", ["createdAt"]], + ["whistlegraph-iap-purchases", "user", [ + "_id", "transactionId", "productId", "credits", "environment", "createdAt", + "deliveredAt", "refundedAt", "refundAppliedAt", "reconciliationRequired", + ["state", ["signedDate", "refundedCredits", "type"]], + ]], + ["whistlegraph-iap-notifications", "user", [ + "_id", "type", "purchaseId", "signedDate", "refundedCredits", "receivedAt", "status", "appliedAt", + ]], + ["ac-credit-wallets", "_id", ["balance", "spent", "createdAt", "updatedAt"]], + ["whistlegraph-mints", "user", [ + "code", "version", "source", "sourceHash", "density", "aspect", "title", + "description", "editions", "royalties", "createdAt", "status", "sender", + "artifactUri", "htmlUri", "zipUri", "coverUri", "thumbnailUri", "metadataUri", + "artifactMimeType", "packageVersion", "operationHash", "tokenId", "mintedAt", + ]], +]; + +function exportFields(doc, fields) { + const selected = {}; + for (const field of fields) { + const [key, nested] = Array.isArray(field) ? field : [field]; + if (!Object.hasOwn(doc, key)) continue; + if (nested && (!doc[key] || typeof doc[key] !== "object" || Array.isArray(doc[key]))) continue; + selected[key] = nested ? exportFields(doc[key], nested) : doc[key]; + } + return selected; +} + export async function exportAccount(deps, { user, now = new Date() }) { const sub = user?.sub; if (!sub) throw new Error("No account to export."); @@ -144,6 +179,12 @@ export async function exportAccount(deps, { user, now = new Date() }) { const docs = await deps.db.collection(name).find({ [field]: sub }).toArray(); if (docs.length) records[name] = docs; } + for (const [name, field, fields] of PRIVATE_EXPORT) { + const docs = await deps.db.collection(name).find({ [field]: sub }).toArray(); + if (docs.length) { + records[name] = docs.map(doc => exportFields(doc, fields)); + } + } const files = await deps.storage.list("user", `${sub}/`); return { exportedAt: now.toISOString(), @@ -295,6 +336,8 @@ const DELETE = [ ["moods", (sub) => ({ user: sub })], ["push-tokens", (sub) => ({ user: sub })], ["easel-transcripts-private", (sub) => ({ owner: sub })], + ["walkieware-threads", (sub) => ({ owner: sub })], + ["whistlegraph-roblox-rooms", (sub) => ({ _id: sub })], ["tells", (sub) => ({ $or: [{ to: sub }, { from: sub }] })], ["tapes", (sub) => ({ user: sub })], ["tape-drafts", (sub) => ({ user: sub })], @@ -413,6 +456,7 @@ async function survey(deps, sub, snap) { counts: { paintings: await count(db, "paintings", { user: sub }), pieces: await count(db, "pieces", { user: sub }), + whistlegraphs: await count(db, "walkieware-threads", { owner: sub }), moods: moods.length, tapes: inv.tapeCodes.length, news: inv.newsCodes.length, @@ -446,6 +490,43 @@ export const STEPS = [ }; }, }, + { + // Remove the Apple account mapping before touching the balance. The IAP + // service also checks this deletion job/tombstone, so a late notification + // cannot recreate the deleted wallet. Keep transaction IDs as immutable + // anti-replay claims, with a hash instead of an account or Apple token. + name: "whistlegraph-purchases", + async run({ db, sub, now }) { + await db.collection("whistlegraph-iap-accounts").deleteOne({ _id: sub }); + await db.collection("whistlegraph-iap-purchases").updateMany( + { user: sub }, + { + $set: { deletedAt: now, userHash: hash(sub) }, + $unset: { user: "", appAccountToken: "", signedPayload: "" }, + }, + ); + const notifications = await db.collection("whistlegraph-iap-notifications").deleteMany({ user: sub }); + return { notifications: notifications?.deletedCount || 0 }; + }, + }, + { + // Mint links are bearer capabilities. Retire their hashed IDs so old links + // cannot resume a mint or be reused, while removing drafts, wallet links, + // personal metadata and previews. The artwork already on chain/IPFS stays. + name: "whistlegraph-mints", + async run({ db, sub, now }) { + const collection = db.collection("whistlegraph-mints"); + const rows = await collection.find({ user: sub }).toArray(); + for (const row of rows) { + const retired = { _id: row._id, status: "deleted", deletedAt: now }; + for (const key of ["operationHash", "tokenId"]) { + if (typeof row[key] === "string") retired[key] = row[key]; + } + await collection.replaceOne({ _id: row._id, user: sub }, retired); + } + return { retired: rows.length }; + }, + }, { // Cancels monthly gifts and removes the Stripe customer (card details // and addresses). Stripe keeps the charges themselves as tax records. diff --git a/system/backend/whistlegraph-iap-store.mjs b/system/backend/whistlegraph-iap-store.mjs new file mode 100644 index 0000000000..40f395572b --- /dev/null +++ b/system/backend/whistlegraph-iap-store.mjs @@ -0,0 +1,25 @@ +import { whistlegraphPurchases } from './whistlegraph-iap.mjs'; +import { connect } from './database.mjs'; + +export const sandboxAccounts = () => new Set((process.env.WHISTLEGRAPH_IAP_SANDBOX_USERS || '').split(',').map(s => s.trim()).filter(Boolean)); + +export async function withWhistlegraphPurchases(run, { verifiers = [] } = {}) { + const connection = await connect(); + try { + const collection = name => connection.db.collection(name); + const accounts = collection('whistlegraph-iap-accounts'); + await accounts.createIndex({ appAccountToken: 1 }, { unique: true }); + const notifications = collection('whistlegraph-iap-notifications'); + await notifications.createIndex({ status: 1, nextAttemptAt: 1, receivedAt: 1 }); + return await run(whistlegraphPurchases({ accounts, notifications, + purchases: collection('whistlegraph-iap-purchases'), wallets: collection('ac-credit-wallets'), + deletions: collection('account-deletions'), tombstones: collection('account-tombstones'), + verifiers, sandboxUsers: sandboxAccounts() })); + } finally { await connection.disconnect(); } +} + +// Verified rows carry the desired refund state. Lith retries independently of +// Apple's notification retry window and without storing replayable signed JWS. +export async function reconcileWhistlegraphPurchases() { + return withWhistlegraphPurchases(service => service.reconcilePending()); +} diff --git a/system/backend/whistlegraph-iap.mjs b/system/backend/whistlegraph-iap.mjs new file mode 100644 index 0000000000..78de5cb11b --- /dev/null +++ b/system/backend/whistlegraph-iap.mjs @@ -0,0 +1,246 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { SignedDataVerifier, Environment } from '@apple/app-store-server-library'; +import { appleRootCertificates } from './apple-roots.mjs'; + +export const BUNDLE_ID = 'computer.aesthetic.walkieware'; +export const PRODUCT_ID = 'computer.aesthetic.walkieware.braincells.1m'; +export const CREDITS = 1_000_000; +const uuid = /^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i; +const fail = (status, message) => Object.assign(new Error(message), { status }); + +// Apple's signedDate orders snapshots, including retries delivered out of order: +// https://developer.apple.com/documentation/appstoreservernotifications/signeddate +export function purchaseState(transaction, notification) { + const signedDate = (notification || transaction).signedDate; + if (!Number.isSafeInteger(signedDate) || signedDate < 1) throw fail(400, 'Missing Apple signature date.'); + const type = notification?.notificationType || (transaction.revocationDate != null ? 'REFUND' : 'PURCHASE'); + let refundedCredits = 0; + if (type === 'REFUND' || type === 'REVOKE') { + // Server JWS percentages are milliunits (100000 = 100%), not the decimal + // percentage exposed by the Swift Transaction API. Legacy refunds are full. + const percent = transaction.revocationPercentage ?? (transaction.revocationType === 'REFUND_PRORATED' ? NaN : 100_000); + if (!Number.isSafeInteger(percent) || percent < 0 || percent > 100_000) throw fail(400, 'Invalid Apple refund percentage.'); + refundedCredits = Math.round(CREDITS * percent / 100_000); + } + return { signedDate, refundedCredits, type }; +} + +const newer = (path, state) => ({ $or: [ + { [path]: { $exists: false } }, + { [`${path}.signedDate`]: { $lt: state.signedDate } }, + // In the unlikely event of equal timestamps, the larger refund wins. + { [`${path}.signedDate`]: state.signedDate, [`${path}.refundedCredits`]: { $lt: state.refundedCredits } }, +] }); + +// One wallet update grants/revokes/restores the difference, never a second pack. +// It also handles a refund arriving before redemption without exposing a +// temporarily spendable balance between an increment and a reversal. +export function walletStateUpdate(grant, state) { + const refund = `refunds.${grant.id}`, marker = `applePurchases.${grant.id}`; + const grants = { $ifNull: ['$grants', []] }; + const initial = { $cond: [{ $in: [grant.id, grants] }, 0, grant.credits] }; + return { + filter: { _id: grant.user, ...newer(marker, state) }, + update: [{ $set: { + balance: { $add: [{ $ifNull: ['$balance', 0] }, initial, + { $subtract: [{ $ifNull: [`$${refund}`, 0] }, state.refundedCredits] }] }, + grants: { $setUnion: [grants, [grant.id]] }, + [refund]: state.refundedCredits, [marker]: { $literal: state }, updatedAt: '$$NOW', + } }], + }; +} + +export function makeVerifiers({ appAppleId, sandbox = false, online = true, roots = appleRootCertificates() }) { + if (!Number.isSafeInteger(appAppleId) || appAppleId < 1) throw fail(503, 'App Store purchases are not configured yet.'); + const verifiers = [new SignedDataVerifier(roots, online, Environment.PRODUCTION, BUNDLE_ID, appAppleId)]; + if (sandbox) verifiers.push(new SignedDataVerifier(roots, online, Environment.SANDBOX, BUNDLE_ID, appAppleId)); + return verifiers; +} + +export function purchaseGrant(transaction, environment) { + if (!['Production', 'Sandbox'].includes(environment) || transaction?.environment !== environment) throw fail(400, 'Wrong purchase environment.'); + if (transaction.bundleId !== BUNDLE_ID) throw fail(400, 'Wrong app.'); + if (transaction.productId !== PRODUCT_ID || transaction.type !== 'Consumable') throw fail(400, 'Unknown braincell product.'); + // This app sells one pack per purchase. Never infer quantity from missing data. + if (transaction.quantity !== 1) throw fail(400, 'Invalid purchase quantity.'); + if (typeof transaction.transactionId !== 'string' || !/^\d{1,30}$/.test(transaction.transactionId)) throw fail(400, 'Invalid transaction ID.'); + if (typeof transaction.appAccountToken !== 'string' || !uuid.test(transaction.appAccountToken)) throw fail(409, 'This purchase is not bound to an AC account.'); + return { id: `apple:whistlegraph:${environment}:${transaction.transactionId}`, transactionId: transaction.transactionId, + productId: PRODUCT_ID, credits: CREDITS, environment, appAccountToken: transaction.appAccountToken.toLowerCase() }; +} + +// Every claim has one immutable AC owner before touching their wallet. A crash +// after either write is safe to retry; the wallet grants each claim only once. +export function whistlegraphPurchases({ accounts, purchases, notifications, wallets, verifiers, deletions, tombstones, sandboxUsers = new Set(), now = () => new Date() }) { + async function decode(method, jws, environment) { + if (typeof jws !== 'string' || !jws.length || jws.length > 48_000) throw fail(400, 'Missing signed purchase.'); + for (const verifier of verifiers) { + if (environment && verifier.environment !== environment) continue; + try { return { payload: await verifier[method](jws), environment: verifier.environment }; } catch {} + } + throw fail(401, 'Apple could not verify this purchase.'); + } + function allowed(user, environment) { + if (environment === 'Sandbox' && !sandboxUsers.has(user)) throw fail(403, 'This account is not enabled for App Store sandbox testing.'); + } + async function active(user) { + const hash = createHash('sha256').update(user).digest('hex'); + const [job, tombstone] = await Promise.all([deletions?.findOne({ _id: user }), tombstones?.findOne({ _id: hash })]); + if (tombstone || (job && job.state !== 'cancelled')) throw fail(409, 'This AC account is being deleted.'); + } + async function retire(grant) { + const userHash = createHash('sha256').update(grant.user).digest('hex'); + const [job, tombstone, saved] = await Promise.all([deletions?.findOne({ _id: grant.user }), + tombstones?.findOne({ _id: userHash }), purchases.findOne({ _id: grant.id })]); + if (!tombstone && !saved?.deletedAt && !['running', 'failed', 'complete'].includes(job?.state)) return false; + // The purge may have swept a collection before this in-flight handler wrote + // it. Repeat the narrow cleanup after every attempt, including failures. + await accounts.deleteOne({ _id: grant.user, appAccountToken: grant.appAccountToken }); + await purchases.updateOne({ _id: grant.id, user: grant.user }, { + $set: { deletedAt: now(), userHash }, $unset: { user: '', appAccountToken: '' }, + }); + await notifications?.deleteMany({ purchaseId: grant.id, user: grant.user }); + await wallets.deleteOne({ _id: grant.user, iapCreationID: { $exists: true }, grants: [], balance: 0 }); + return true; + } + async function withGrant(grant, run, notification = false) { + let result, error; + try { result = await run(); } catch (cause) { error = cause; } + if (await retire(grant)) { + if (notification) return { received: true }; + throw fail(410, 'The account for this purchase was deleted.'); + } + if (error) throw error; + return result; + } + async function owner(grant, user) { + const saved = await purchases.findOne({ _id: grant.id }); + if (saved?.deletedAt) { + if (user) throw fail(410, 'The account for this purchase was deleted.'); + return null; + } + const account = await accounts.findOne({ appAccountToken: grant.appAccountToken }); + if (!account || account.deletedAt || (user && account._id !== user)) throw fail(403, 'Sign in to the AC account that bought these braincells.'); + if (user) await active(account._id); + // Removing a sandbox tester must not prevent reversing their old grant. + if (user) allowed(account._id, grant.environment); + else if (grant.environment === 'Sandbox' && !sandboxUsers.has(account._id) && !saved?.sandboxAuthorized) + throw fail(403, 'This account is not enabled for App Store sandbox testing.'); + return { ...grant, user: account._id }; + } + async function claim(grant) { + const { id, ...fields } = grant; + try { await purchases.insertOne({ _id: id, ...fields, + sandboxAuthorized: grant.environment === 'Sandbox' && sandboxUsers.has(grant.user), createdAt: now() }); } + catch (error) { if (error.code !== 11000) throw error; } + const saved = await purchases.findOne({ _id: id }); + if (!saved || saved.deletedAt || ['user', 'appAccountToken', 'productId', 'credits', 'environment', 'transactionId'].some(key => saved[key] !== grant[key])) + throw fail(409, 'This transaction already belongs to another purchase.'); + return saved; + } + async function apply(grant, state, initialOnly = false) { + await claim(grant); + // Save the intended state before delivery. Both documents reject stale + // snapshots independently, so a crash or concurrent retry cannot regress it. + await purchases.updateOne({ _id: grant.id, deletedAt: { $exists: false }, + ...(initialOnly ? { state: { $exists: false } } : newer('state', state)) }, { $set: { state } }); + const saved = await purchases.findOne({ _id: grant.id }); + if (!saved?.state || saved.deletedAt) throw fail(410, 'The account for this purchase was deleted.'); + await active(grant.user); + const iapCreationID = randomUUID(); + try { await wallets.updateOne({ _id: grant.user }, { + $setOnInsert: { balance: 0, grants: [], createdAt: now(), iapCreationID }, + }, { upsert: true }); } catch (error) { if (error.code !== 11000) throw error; } + // Check durable deletion state after ensuring the wallet; financial writes + // never upsert. A purge that removed it cannot be undone by a late update. + await active(grant.user); + const account = await accounts.findOne({ _id: grant.user }); + if (!account || account.deletedAt || account.appAccountToken !== grant.appAccountToken) throw fail(410, 'The account for this purchase was deleted.'); + const { filter, update } = walletStateUpdate(grant, saved.state); + update.push({ $unset: 'iapCreationID' }); + const delivered = await wallets.updateOne(filter, update); + const wallet = await wallets.findOne({ _id: grant.user }); + const applied = wallet?.applePurchases?.[grant.id]; + if (!applied || applied.signedDate < saved.state.signedDate) throw fail(503, 'Purchase delivery is pending. Reopen Whistlegraph to retry.'); + await purchases.updateOne({ _id: grant.id, deletedAt: { $exists: false } }, { $set: { deliveredAt: now() } }); + return { credited: delivered.modifiedCount === 1, state: applied }; + } + return { + async account(user) { + await active(user); + const token = randomUUID(); + try { await accounts.insertOne({ _id: user, appAccountToken: token, createdAt: now() }); } + catch (error) { if (error.code !== 11000) throw error; } + try { await active(user); } catch (error) { + // Only remove this attempt's insertion. A restorable account's existing + // mapping belongs to its earlier request and must survive the grace period. + if (error.status === 409) await accounts.deleteOne({ _id: user, appAccountToken: token }); + throw error; + } + const account = await accounts.findOne({ _id: user }); + if (account?.deletedAt || !uuid.test(account?.appAccountToken || '')) throw fail(503, 'Could not prepare the purchase account.'); + return { appAccountToken: account.appAccountToken }; + }, + async redeem(user, jws) { + const { payload, environment } = await decode('verifyAndDecodeTransaction', jws); + const grant = await owner(purchaseGrant(payload, environment), user); + // A non-revoked client receipt never clears a server refund. Only Apple's + // ordered REFUND_REVERSED notification may restore refunded credits. + return withGrant(grant, async () => { + const { credited, state } = await apply(grant, purchaseState(payload), payload.revocationDate == null); + if (state.refundedCredits > 0) throw fail(409, 'This purchase was refunded.'); + return { credited, transactionId: grant.transactionId, credits: grant.credits, environment }; + }); + }, + async notification(jws) { + const { payload, environment } = await decode('verifyAndDecodeNotification', jws); + if (!['REFUND', 'REVOKE', 'REFUND_REVERSED'].includes(payload.notificationType)) return { received: true }; + if (payload.data?.bundleId !== BUNDLE_ID || payload.data?.environment !== environment) throw fail(400, 'Notification app or environment mismatch.'); + const transaction = await decode('verifyAndDecodeTransaction', payload.data.signedTransactionInfo, environment); + const grant = await owner(purchaseGrant(transaction.payload, environment)); + if (!grant) return { received: true }; // Anonymized claim; never recreate its wallet. + const state = purchaseState(transaction.payload, payload); + if (typeof payload.notificationUUID !== 'string' || !/^[a-f0-9]{8}(-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i.test(payload.notificationUUID)) throw fail(400, 'Invalid notification ID.'); + const id = `${environment}:${payload.notificationUUID}`; + return withGrant(grant, async () => { + await claim(grant); + if (notifications) { + try { await notifications.insertOne({ _id: id, purchaseId: grant.id, user: grant.user, + ...state, receivedAt: now(), status: 'pending' }); } + catch (error) { if (error.code !== 11000) throw error; } + } + await apply(grant, state); + // No success response until the balance and its ordering marker are durable. + await notifications?.updateOne({ _id: id }, { $set: { status: 'applied', appliedAt: now() } }); + return { received: true }; + }, true); + }, + async reconcilePending({ limit = 50 } = {}) { + let applied = 0, pending = 0; + // These rows are written only after Apple verification and immutable claim + // creation. Replaying them needs no signing keys or stored receipt payload. + const rows = await notifications.find({ status: 'pending', $or: [ + { nextAttemptAt: { $exists: false } }, { nextAttemptAt: { $lte: now() } }, + ] }).sort({ receivedAt: 1 }).limit(limit).toArray(); + for (const row of rows) { + const saved = await purchases.findOne({ _id: row.purchaseId }); + if (!saved || saved.deletedAt) { await notifications.deleteOne({ _id: row._id }); continue; } + const { _id: id, user, appAccountToken, productId, credits, environment, transactionId } = saved; + const grant = { id, user, appAccountToken, productId, credits, environment, transactionId }; + try { + await withGrant(grant, async () => { + const { signedDate, refundedCredits, type } = row; + await apply(grant, { signedDate, refundedCredits, type }); + await notifications.updateOne({ _id: row._id }, { $set: { status: 'applied', appliedAt: now() } }); + }, true); + applied++; + } catch { + pending++; + // Restorable deletion jobs must not occupy the first batch forever. + await notifications.updateOne({ _id: row._id }, { $set: { nextAttemptAt: new Date(+now() + 15 * 60_000) } }); + } + } + return { applied, pending }; + }, + }; +} diff --git a/system/netlify/functions/whistlegraph-iap.mjs b/system/netlify/functions/whistlegraph-iap.mjs new file mode 100644 index 0000000000..11de22313d --- /dev/null +++ b/system/netlify/functions/whistlegraph-iap.mjs @@ -0,0 +1,44 @@ +import { makeVerifiers } from '../../backend/whistlegraph-iap.mjs'; +import { sandboxAccounts, withWhistlegraphPurchases } from '../../backend/whistlegraph-iap-store.mjs'; +const headers = { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'Authorization, Content-Type', 'Access-Control-Allow-Methods': 'POST, OPTIONS' }; +const reply = (statusCode, value) => ({ statusCode, headers, body: JSON.stringify(value) }); + +export function createHandler({ service, authorize, salesEnabled = true }) { + return async event => { + if (event.httpMethod === 'OPTIONS') return reply(204, null); + if (event.httpMethod !== 'POST') return reply(405, { error: 'POST only' }); + if (Buffer.byteLength(event.body || '', 'utf8') > 64_000) return reply(413, { error: 'Request too large.' }); + let body; + try { body = JSON.parse(event.body || '{}'); } catch { return reply(400, { error: 'Invalid request.' }); } + if (!body || typeof body !== 'object' || Array.isArray(body)) return reply(400, { error: 'Invalid request.' }); + try { + if (typeof body.signedPayload === 'string') return reply(200, await service.notification(body.signedPayload)); + const user = await authorize(event.headers || {}).catch(() => null); + if (!user?.sub) return reply(401, { error: 'Sign in to AC to buy braincells.' }); + if (body.action === 'account') return salesEnabled + ? reply(200, await service.account(user.sub)) + : reply(503, { error: 'App Store purchases are not available yet.' }); + if (body.action === 'redeem') return reply(200, await service.redeem(user.sub, body.jws)); + return reply(400, { error: 'Unknown action.' }); + } catch (error) { + return reply(error.status || 503, { error: error.status ? error.message : 'Purchase delivery is pending. Reopen Whistlegraph to retry.' }); + } + }; +} + +export async function handler(event) { + if (event.httpMethod === 'OPTIONS' || event.httpMethod !== 'POST') return createHandler({})(event); + // Pausing new sales must not strand already-paid transactions or refunds. + const salesEnabled = process.env.WHISTLEGRAPH_IAP_ENABLED === 'true'; + let verifiers; + const sandboxUsers = sandboxAccounts(); + try { + verifiers = makeVerifiers({ appAppleId: Number(process.env.WHISTLEGRAPH_APPLE_ID), + sandbox: process.env.WHISTLEGRAPH_IAP_ALLOW_SANDBOX === 'true' && sandboxUsers.size > 0 }); + } catch { return reply(503, { error: 'App Store purchases are not configured yet.' }); } + try { + const { authorize } = await import('../../backend/authorization.mjs'); + return await withWhistlegraphPurchases(service => createHandler({ service, authorize, salesEnabled })(event), { verifiers }); + } catch { return reply(503, { error: 'Purchase delivery is pending. Reopen Whistlegraph to retry.' }); } +} diff --git a/system/public/aesthetic.computer/lib/disk.mjs b/system/public/aesthetic.computer/lib/disk.mjs index cff7571ac2..991821a46b 100644 --- a/system/public/aesthetic.computer/lib/disk.mjs +++ b/system/public/aesthetic.computer/lib/disk.mjs @@ -1131,6 +1131,9 @@ import { setPackMode, getPackMode, checkPackMode } from "./pack-mode.mjs"; // Captures console output during a piece's lifetime. Each piece load // gets a fresh pieceId; events are batched and flushed every 2s. const previewEvidence = createPreviewEvidence(message => send(message)); +// Whistlegraph drafts use local preview evidence. Do not upload their console +// content or create IP/geography-linked public piece-run telemetry. +const privateWhistlegraphPreview = new URLSearchParams(location.search).get("preview") === "walkieware"; const pieceRuns = (() => { let current = null; const startPerf = performance.now(); @@ -1151,6 +1154,7 @@ const pieceRuns = (() => { } function post(phase, pieceId, body = {}) { + if (privateWhistlegraphPreview) return; try { fetch("/api/piece-log", { method: "POST", @@ -1192,6 +1196,7 @@ const pieceRuns = (() => { return { start({ slug, params, colon, host, user }) { + if (privateWhistlegraphPreview) return null; const prev = current; const pieceId = (typeof crypto !== "undefined" && crypto.randomUUID?.()) || diff --git a/system/public/privacy-policy.html b/system/public/privacy-policy.html index a21255cd8e..24c316f382 100644 --- a/system/public/privacy-policy.html +++ b/system/public/privacy-policy.html @@ -37,7 +37,7 @@ We may request camera access for camera and handtracking, and microphone access for recording.

- Some features use AI and voice services. Multiplayer games connect to realtime servers where your presence and chat are visible to others. + Multiplayer games connect to realtime servers where your presence and chat are visible to others.

We use cookies and third-party services for login, analytics, and payments. @@ -51,18 +51,37 @@

We do not sell your data.

+

Whistlegraph: AI, voice, and purchases

+

+ Whistlegraph saves drafts, source code, version history, requests, and diagnostic receipts with your Aesthetic Computer account. Voice recordings and cached story audio are stored on your device. +

+

+ AI creation requires your permission. Requests, relevant source and version history, drawings and their stroke timing, generated-preview images used for visual review, and sound measurements needed for a request are sent through Aesthetic Computer to OpenRouter and your selected model provider. Available providers include DeepSeek, Moonshot AI, Alibaba/Qwen, MiniMax, and Z.ai. Eligible personal-model accounts can also use Anthropic or OpenAI through our relay. +

+

+ Cloud speech and cloud narration each require separate permission. Cloud speech sends microphone audio to OpenAI for transcription, directly or through our relay. Cloud narration sends caption text through Aesthetic Computer to ElevenLabs. Without those permissions, speech recognition and narration use the device when available. Typed input remains available. +

+

+ You can withdraw these permissions in Whistlegraph's AI & privacy settings. This stops future transfers for the disabled feature; it cannot recall data already sent. Account deletion removes the drafts and history we store for your account. Provider processing and retention follow their own policies, including OpenRouter, OpenAI, Anthropic, and ElevenLabs. +

+

+ Apple processes App Store payments; we do not receive your payment-card details. We store transaction and product identifiers, an account-binding token, braincell credits, and delivery and refund records to deliver purchases and prevent duplicate credit. When your account is deleted, we remove the binding token and account-linked Apple notification records. Transaction records remain with a hash in place of your account identity so old purchases cannot be replayed. +

Deleting your account

- Enter delete-erase-and-forget-me, or use Delete account in the settings of the Aesel app. Before you confirm, it shows what will be deleted, what will stay, and any braincells you would lose. The web page also lets you download a copy of your data first. + Enter delete-erase-and-forget-me, or use Delete account in the settings of Aesel or Whistlegraph. Before you confirm, it shows what will be deleted, what will stay, and any braincells you would lose. The web page also lets you download a copy of your data first.

Your account locks right away and is deleted 14 days later. We email you a link to keep it until then, and another email when it is gone.

- We delete your @handle, paintings, pieces, moods, tapes, clocks, news posts, chat messages, mail, uploaded files, saved device keys, and the account itself, including its ATProto account at at.aesthetic.computer. Monthly gifts made with the same verified email stop. + We delete your @handle, paintings, pieces, Whistlegraph drafts, version history and saved Roblox rooms, moods, tapes, clocks, news posts, chat messages, mail, uploaded files, saved device keys, and the account itself, including its ATProto account at at.aesthetic.computer. Monthly gifts made with the same verified email stop. +

+

+ Art minted on Tezos remains on the public blockchain and IPFS, including any creator attribution or wallet addresses already published in its metadata. Deleting your account does not remove those public records.

- Some things stay, without your name. KidLisp that is minted on Tezos is permanent on the blockchain and IPFS. KidLisp used in other people's pieces stays so their work keeps running. Purchase records stay as long as tax law requires. Usage logs keep their times and pages, but not who you were. + Locally retained records lose your account identity. KidLisp used in other people's pieces stays so their work keeps running. Purchase records stay as long as tax law requires. Usage logs keep their times and pages, but not who you were.

Your @handle cannot be claimed by anyone else for 90 days. If a Sotce Net account shares your email and handle, that account keeps the handle. @@ -83,7 +102,7 @@ src="https://pals-aesthetic-computer.sfo3.cdn.digitaloceanspaces.com/painting-2023.8.21.10.45.png">

- September 2026 + October 2026 diff --git a/system/tests/account-deletion.test.mjs b/system/tests/account-deletion.test.mjs index fd0fe0e7e1..d9974931bc 100644 --- a/system/tests/account-deletion.test.mjs +++ b/system/tests/account-deletion.test.mjs @@ -197,10 +197,39 @@ function world() { { user: OTHER, code: "theirs", source: "(embed $shared)" }, ], "ac-credit-wallets": [{ _id: SUB, balance: 5 }], + "walkieware-threads": [ + { _id: "my-thread", owner: SUB, code: "wgMine", ledger: { head: 1, versions: [{ id: 1, source: "(wipe red)", request: "red please" }] } }, + { _id: "their-thread", owner: OTHER, code: "wgOther", ledger: { head: 1, versions: [{ id: 1, source: "(wipe blue)", request: "blue please" }] } }, + ], + "whistlegraph-roblox-rooms": [ + { _id: SUB, revision: 1, room: { name: "my room" } }, + { _id: OTHER, revision: 3, room: { name: "their room" } }, + ], boots: [{ meta: { user: { sub: SUB, handle: "@me" } }, server: { ip: "1.2.3.4", country: "US" } }], }; } +function purchaseWorld() { + const receipt = (who, transactionId) => ({ + _id: `apple:whistlegraph:Production:${transactionId}`, + user: who, + appAccountToken: who === SUB ? "4adcdf8d-d1cb-4e68-a965-c6c4f0a57375" : "dd9d7e65-ef88-4c93-b279-5d072a5ad1f6", + transactionId, productId: "computer.aesthetic.walkieware.braincells.1m", + credits: 1_000_000, environment: "Production", createdAt: T0, deliveredAt: T0, + }); + const purchases = [receipt(SUB, "1001"), receipt(OTHER, "1002")]; + return { + ...world(), + "whistlegraph-iap-accounts": purchases.map(p => ({ _id: p.user, appAccountToken: p.appAccountToken, createdAt: T0 })), + "whistlegraph-iap-purchases": purchases, + "whistlegraph-iap-notifications": purchases.map(p => ({ + _id: `Production:${p.transactionId}`, purchaseId: p._id, user: p.user, + type: "REFUND_REVERSED", signedPayload: `private-jws-${p.transactionId}`, + receivedAt: T0, status: "pending", + })), + }; +} + test("asking locks the account, schedules the purge after the grace period and mails a restore link", async () => { const db = fakeDb(world()); const deps = fakeDeps(db); @@ -264,11 +293,13 @@ test("the purge removes the account everywhere and keeps only what it must, with const results = await runDueDeletions(deps, { now: later(GRACE_MS) }); assert.deepEqual(results, [{ state: "completed" }]); - for (const name of ["paintings", "moods", "tapes", "chat-system", "chat-clock", "logs", "users", "@handles"]) { + for (const name of ["paintings", "moods", "tapes", "chat-system", "chat-clock", "logs", "users", "@handles", "walkieware-threads", "whistlegraph-roblox-rooms"]) { assert.ok(!JSON.stringify(db.all(name)).includes(SUB), `${name} still names the account`); } assert.equal(db.all("paintings").length, 1, "other people's work stays"); assert.equal(db.all("chat-system").length, 1); + assert.equal(db.all("walkieware-threads").length, 1, "other people's Whistlegraph drafts stay"); + assert.deepEqual(db.all("whistlegraph-roblox-rooms"), [{ _id: OTHER, revision: 3, room: { name: "their room" } }]); assert.deepEqual(db.all("account-activity"), [{ user: OTHER, tenant: "aesthetic", action: "piece_opened" }, { user: SUB, tenant: "sotce", action: "piece_opened" }]); assert.equal(db.all("device-creds").length, 0, "saved device secrets are deleted"); @@ -302,6 +333,75 @@ test("the purge removes the account everywhere and keeps only what it must, with assert.equal(await handleQuarantined(db, "them", later(GRACE_MS)), false); }); +test("Apple account links and signed notifications are deleted but transaction anti-replay claims remain", async () => { + const seed = purchaseWorld(); + const db = fakeDb(seed), deps = fakeDeps(db); + await requestDeletion(deps, { user, now: T0 }); + assert.deepEqual(await runDueDeletions(deps, { now: later(GRACE_MS - 1) }), []); + assert.deepEqual(db.all("whistlegraph-iap-accounts"), seed["whistlegraph-iap-accounts"], "the grace period preserves restorable purchases"); + assert.deepEqual(await runDueDeletions(deps, { now: later(GRACE_MS) }), [{ state: "completed" }]); + + assert.deepEqual(db.all("whistlegraph-iap-accounts"), [seed["whistlegraph-iap-accounts"][1]]); + assert.deepEqual(db.all("whistlegraph-iap-notifications"), [seed["whistlegraph-iap-notifications"][1]]); + const [deleted, other] = db.all("whistlegraph-iap-purchases"); + const { user: removedUser, appAccountToken, ...receipt } = seed["whistlegraph-iap-purchases"][0]; + assert.deepEqual(deleted, { ...receipt, deletedAt: later(GRACE_MS), userHash: hash(SUB) }); + assert.deepEqual(other, seed["whistlegraph-iap-purchases"][1]); + assert.equal(await db.collection("ac-credit-wallets").findOne({ _id: SUB }), null); + assert.deepEqual(db.all(TOMBSTONES), [{ _id: hash(SUB), completedAt: later(GRACE_MS) }]); +}); + +test("a purchase cleanup failure keeps the deletion retryable before removing the wallet", async () => { + const db = fakeDb(purchaseWorld()), deps = fakeDeps(db); + const collection = db.collection; + let unavailable = true; + db.collection = name => { + const result = collection(name); + if (name === "whistlegraph-iap-purchases") { + const update = result.updateMany; + result.updateMany = async (...args) => { + if (unavailable) throw new Error("purchase ledger unavailable"); + return update(...args); + }; + } + return result; + }; + await requestDeletion(deps, { user, now: T0 }); + assert.deepEqual(await runDueDeletions(deps, { now: later(GRACE_MS) }), [{ state: "failed", step: "whistlegraph-purchases" }]); + assert.equal(await db.collection("whistlegraph-iap-accounts").findOne({ _id: SUB }), null); + assert.equal((await db.collection("ac-credit-wallets").findOne({ _id: SUB })).balance, 5); + assert.ok(!deps.calls.some(call => call[0] === "identity")); + unavailable = false; + assert.deepEqual(await runDueDeletions(deps, { now: later(GRACE_MS + DAY) }), [{ state: "completed" }]); + assert.equal(db.all("whistlegraph-iap-purchases")[0].userHash, hash(SUB)); + assert.ok(!db.all("whistlegraph-iap-notifications").some(row => row.user === SUB)); +}); + +test("Whistlegraph mint drafts export without capabilities and retire without personal data", async () => { + const seed = world(); + seed["whistlegraph-mints"] = [ + { _id: "draft-capability-hash", user: SUB, handle: "me", source: "(wipe red)", cover: "private-png", nonce: "secret-nonce", status: "packing", title: "my draft" }, + { _id: "minted-capability-hash", user: SUB, handle: "me", sender: "tz1MyWallet", status: "minted", artifactUri: "ipfs://QmMinted", operationHash: "ooMinted", tokenId: "77", title: "my artwork" }, + { _id: "their-capability-hash", user: OTHER, handle: "them", source: "(wipe blue)", status: "packing" }, + ]; + const db = fakeDb(seed), deps = fakeDeps(db); + const copy = await exportAccount(deps, { user, now: T0 }); + assert.deepEqual(copy.records["whistlegraph-mints"], [ + { source: "(wipe red)", status: "packing", title: "my draft" }, + { sender: "tz1MyWallet", status: "minted", artifactUri: "ipfs://QmMinted", operationHash: "ooMinted", tokenId: "77", title: "my artwork" }, + ]); + assert.ok(!JSON.stringify(copy).includes("capability-hash")); + assert.ok(!JSON.stringify(copy).includes("secret-nonce")); + await requestDeletion(deps, { user, now: T0 }); + assert.deepEqual(await runDueDeletions(deps, { now: later(GRACE_MS) }), [{ state: "completed" }]); + assert.deepEqual(db.all("whistlegraph-mints"), [ + { _id: "draft-capability-hash", status: "deleted", deletedAt: later(GRACE_MS) }, + { _id: "minted-capability-hash", status: "deleted", deletedAt: later(GRACE_MS), operationHash: "ooMinted", tokenId: "77" }, + seed["whistlegraph-mints"][2], + ]); + assert.ok(!deps.calls.some(call => call[0] === "unpin" && call[1] === "QmMinted"), "on-chain artwork is left intact"); +}); + test("a failed step stops before the identity and resumes there after a backoff", async () => { const db = fakeDb(world()); let pdsUp = false; @@ -542,7 +642,7 @@ test("the preview names what goes, what stays and the braincells lost, and chang handleHoldDays: 90, handleGoesToSotce: false, braincells: 5, - counts: { paintings: 1, pieces: 0, moods: 1, tapes: 1, news: 1, chat: 2, kidlispDeleted: 1, kidlispKept: 2 }, + counts: { paintings: 1, pieces: 0, whistlegraphs: 1, moods: 1, tapes: 1, news: 1, chat: 2, kidlispDeleted: 1, kidlispKept: 2 }, }); assert.deepEqual(deps.calls, []); assert.deepEqual(db.all(LEDGER), []); @@ -577,11 +677,38 @@ test("the export holds what the account made and nobody else's, and changes noth assert.equal(copy.account.handle, "me"); assert.deepEqual(copy.records.paintings.map((p) => p.code), ["p1"]); assert.deepEqual(copy.records["chat-system"].map((m) => m.text), ["hello"]); + assert.deepEqual(copy.records["walkieware-threads"].map(row => row.code), ["wgMine"]); + assert.equal(copy.records["walkieware-threads"][0].ledger.versions[0].request, "red please"); + assert.deepEqual(copy.records["whistlegraph-roblox-rooms"], [{ _id: SUB, revision: 1, room: { name: "my room" } }]); assert.ok(!JSON.stringify(copy).includes(OTHER), "no one else's records"); assert.equal(copy.files[0].key, `${SUB}/painting/p1.png`); assert.deepEqual(deps.calls, []); }); +test("billing exports include only the account's receipts and status, without replayable tokens", async () => { + const seed = purchaseWorld(); + seed["whistlegraph-iap-purchases"][0].futureProviderCredential = "secret-future-field"; + seed["whistlegraph-iap-purchases"][0].state = { signedDate: +T0, refundedCredits: 250_000, type: "REFUND", futureProviderCredential: "secret-nested-field" }; + Object.assign(seed["whistlegraph-iap-notifications"][0], { signedDate: +T0, refundedCredits: 0 }); + const db = fakeDb(seed), deps = fakeDeps(db); + const copy = await exportAccount(deps, { user, now: T0 }); + assert.deepEqual(copy.records["whistlegraph-iap-accounts"], [{ createdAt: T0 }]); + assert.deepEqual(copy.records["ac-credit-wallets"], [{ balance: 5 }]); + const [purchase] = copy.records["whistlegraph-iap-purchases"]; + assert.equal(purchase.transactionId, "1001"); + assert.equal(purchase.credits, 1_000_000); + assert.deepEqual(purchase.state, { signedDate: +T0, refundedCredits: 250_000, type: "REFUND" }); + assert.deepEqual(copy.records["whistlegraph-iap-notifications"], [{ + _id: "Production:1001", type: "REFUND_REVERSED", purchaseId: purchase._id, + signedDate: +T0, refundedCredits: 0, receivedAt: T0, status: "pending", + }]); + const serialized = JSON.stringify(copy); + for (const secret of [OTHER, "private-jws-", "secret-future-field", "secret-nested-field", seed["whistlegraph-iap-accounts"][0].appAccountToken]) { + assert.ok(!serialized.includes(secret), `${secret} should not be exported`); + } + assert.deepEqual(db.all("whistlegraph-iap-purchases"), seed["whistlegraph-iap-purchases"], "export does not mutate billing records"); +}); + test("the export is a signed-in download", async () => { const anonymous = await endpoint({ authorized: false }); assert.equal((await anonymous.handler({ httpMethod: "GET", queryStringParameters: { export: "" }, headers: {} })).statusCode, 401); diff --git a/system/tests/whistlegraph-iap.test.mjs b/system/tests/whistlegraph-iap.test.mjs new file mode 100644 index 0000000000..bb481d1655 --- /dev/null +++ b/system/tests/whistlegraph-iap.test.mjs @@ -0,0 +1,381 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createHash, randomUUID } from 'node:crypto'; +import { BUNDLE_ID, PRODUCT_ID, purchaseGrant, purchaseState, makeVerifiers, whistlegraphPurchases } from '../backend/whistlegraph-iap.mjs'; +import { createHandler, handler as liveHandler } from '../netlify/functions/whistlegraph-iap.mjs'; + +const copy = value => value == null ? value : structuredClone(value); +const duplicate = () => Object.assign(new Error('duplicate'), { code: 11000 }); +const get = (doc, path) => path.split('.').reduce((value, key) => value?.[key], doc); +function set(doc, path, value) { + const keys = path.split('.'), last = keys.pop(); + for (const key of keys) doc = doc[key] ??= {}; + doc[last] = copy(value); +} +function matches(doc, query) { + return Object.entries(query).every(([key, expected]) => { + if (key === '$or') return expected.some(q => matches(doc, q)); + const actual = get(doc, key); + if (Array.isArray(expected)) return JSON.stringify(actual) === JSON.stringify(expected); + if (expected && typeof expected === 'object') return Object.entries(expected).every(([op, value]) => { + if (op === '$exists') return (actual !== undefined) === value; + if (op === '$lt') return actual !== undefined && actual < value; + if (op === '$lte') return actual !== undefined && actual <= value; + throw Error(`Unexpected query operator: ${op}`); + }); + return actual === expected; + }); +} +// Evaluate the small aggregation subset used by the production update, rather +// than replacing that update with a test-only refund algorithm. +function evaluate(expr, doc) { + if (expr === '$$NOW') return new Date(); + if (typeof expr === 'string' && expr.startsWith('$')) return get(doc, expr.slice(1)); + if (Array.isArray(expr)) return expr.map(value => evaluate(value, doc)); + if (!expr || typeof expr !== 'object') return expr; + const [[op, raw]] = Object.entries(expr); + if (op === '$literal') return raw; + const values = evaluate(raw, doc); + if (op === '$ifNull') return values[0] ?? values[1]; + if (op === '$in') return values[1].includes(values[0]); + if (op === '$cond') return values[0] ? values[1] : values[2]; + if (op === '$add') return values.reduce((a, b) => a + b, 0); + if (op === '$subtract') return values[0] - values[1]; + if (op === '$setUnion') return [...new Set(values.flat())]; + throw Error(`Unexpected aggregation expression: ${op}`); +} +function collection() { + const rows = new Map(); + return { + rows, + async insertOne(doc) { if (rows.has(doc._id)) throw duplicate(); rows.set(doc._id, copy(doc)); }, + async findOne(query) { return copy([...rows.values()].find(doc => matches(doc, query))); }, + find(query) { + let found = [...rows.values()].filter(doc => matches(doc, query)); + return { sort() { return this; }, limit(limit) { found = found.slice(0, limit); return this; }, async toArray() { return copy(found); } }; + }, + async deleteOne(query) { for (const [id, doc] of rows) if (matches(doc, query)) { rows.delete(id); return { deletedCount: 1 }; } return { deletedCount: 0 }; }, + async deleteMany(query) { for (const [id, doc] of rows) if (matches(doc, query)) rows.delete(id); }, + async updateOne(query, update, options = {}) { + let doc = rows.get(query._id); + if (!doc && options.upsert) { doc = { _id: query._id, ...copy(update.$setOnInsert) }; rows.set(doc._id, doc); } + if (!doc || !matches(doc, query)) return { modifiedCount: 0, matchedCount: 0 }; + for (const stage of Array.isArray(update) ? update : [update]) { + const before = copy(doc); + for (const [key, value] of Object.entries(stage.$set || {})) set(doc, key, Array.isArray(update) ? evaluate(value, before) : value); + for (const key of typeof stage.$unset === 'string' ? [stage.$unset] : Object.keys(stage.$unset || {})) delete doc[key]; + } + return { modifiedCount: 1, matchedCount: 1 }; + }, + }; +} +function walletCollection() { + const base = collection(); + return { ...base, failRefund: false, + async updateOne(filter, update, options) { + if (Array.isArray(update)) { + if (this.failRefund) { this.failRefund = false; throw Error('wallet temporarily offline'); } + } + return base.updateOne(filter, update, options); + }, + }; +} +function fixture({ sandboxUsers = [] } = {}) { + const accounts = collection(), purchases = collection(), notifications = collection(), wallets = walletCollection(), deletions = collection(), tombstones = collection(); + const payloads = new Map(); + const verifier = environment => ({ environment, + async verifyAndDecodeTransaction(jws) { const p = payloads.get(jws); if (!p || p.environment !== environment) throw Error('signature or environment'); return copy(p); }, + async verifyAndDecodeNotification(jws) { const p = payloads.get(jws); if (!p || p.data?.environment !== environment) throw Error('signature or environment'); return copy(p); }, + }); + const service = whistlegraphPurchases({ accounts, purchases, notifications, wallets, deletions, tombstones, verifiers: ['Production', 'Sandbox'].map(verifier), sandboxUsers: new Set(sandboxUsers) }); + const handle = createHandler({ service, authorize: async headers => { if (!headers.authorization) throw Error('no auth'); return { sub: headers.authorization }; } }); + const post = (body, user = 'alice') => handle({ httpMethod: 'POST', headers: user ? { authorization: user } : {}, body: JSON.stringify(body) }); + async function tx(name = 'purchase', user = 'alice', changes = {}) { + const account = await service.account(user); + const value = { transactionId: '2000000001', bundleId: BUNDLE_ID, productId: PRODUCT_ID, type: 'Consumable', quantity: 1, + environment: 'Production', appAccountToken: account.appAccountToken, signedDate: 1000, ...changes }; + payloads.set(name, value); return value; + } + let signedDate = 2000; + function note(name, transaction, environment = 'Production', changes = {}) { + payloads.set(name, { notificationType: 'REFUND', notificationUUID: randomUUID(), signedDate: signedDate++, + data: { bundleId: BUNDLE_ID, environment, signedTransactionInfo: transaction }, ...changes }); + } + return { accounts, purchases, notifications, wallets, deletions, tombstones, payloads, service, post, tx, note }; +} + +test('purchase account UUID is stable under retries and unique to the signed-in account', async () => { + const f = fixture(); + const accounts = await Promise.all(Array.from({ length: 20 }, () => f.service.account('alice'))); + assert.equal(new Set(accounts.map(x => x.appAccountToken)).size, 1); + assert.notEqual((await f.service.account('bob')).appAccountToken, accounts[0].appAccountToken); + assert.equal((await f.post({ action: 'account' }, null)).statusCode, 401); +}); + +test('one verified purchase adds one pack across concurrent retries', async () => { + const f = fixture(); await f.tx(); + const responses = await Promise.all(Array.from({ length: 20 }, () => f.post({ action: 'redeem', jws: 'purchase' }))); + assert.ok(responses.every(r => r.statusCode === 200)); + assert.equal(responses.filter(r => JSON.parse(r.body).credited).length, 1); + assert.equal(f.wallets.rows.get('alice').balance, 1_000_000); + assert.equal(f.purchases.rows.size, 1); +}); + +test('a signed transaction cannot be redeemed by a different AC account', async () => { + const f = fixture(); await f.tx(); + assert.equal((await f.post({ action: 'redeem', jws: 'purchase' }, 'bob')).statusCode, 403); + assert.equal(f.wallets.rows.size, 0); + assert.equal((await f.post({ action: 'redeem', jws: 'purchase' })).statusCode, 200); + assert.equal((await f.post({ action: 'redeem', jws: 'purchase' }, 'bob')).statusCode, 403); + assert.equal(f.wallets.rows.size, 1); +}); + +test('transaction claims remain globally bound even if a different signed payload reuses its ID', async () => { + const f = fixture(); await f.tx(); await f.service.redeem('alice', 'purchase'); + await f.tx('other-owner', 'bob'); + await assert.rejects(f.service.redeem('bob', 'other-owner'), /another purchase/); + assert.equal(f.wallets.rows.has('bob'), false); +}); + +test('rejects invalid app, product, type, quantity, token, transaction ID and environment', async () => { + const f = fixture(); const transaction = await f.tx(); + for (const patch of [{ bundleId: 'wrong' }, { productId: 'wrong' }, { type: 'Auto-Renewable Subscription' }, + { quantity: undefined }, { quantity: 0 }, { quantity: 2 }, { transactionId: '../bad' }, + { appAccountToken: undefined }, { appAccountToken: 'not-a-uuid' }, { environment: 'Xcode' }]) { + assert.throws(() => purchaseGrant({ ...transaction, ...patch }, 'Production')); + } + assert.equal((await f.post({ action: 'redeem', jws: 'forged' })).statusCode, 401); + assert.equal(f.wallets.rows.size, 0); +}); + +test('sandbox money is restricted to explicitly allowed test accounts', async () => { + const f = fixture({ sandboxUsers: ['reviewer'] }); + await f.tx('alice-sandbox', 'alice', { environment: 'Sandbox' }); + assert.equal((await f.post({ action: 'redeem', jws: 'alice-sandbox' })).statusCode, 403); + await f.tx('review-sandbox', 'reviewer', { environment: 'Sandbox' }); + assert.equal((await f.post({ action: 'redeem', jws: 'review-sandbox' }, 'reviewer')).statusCode, 200); + assert.equal(f.wallets.rows.has('alice'), false); + assert.equal(f.wallets.rows.get('reviewer').balance, 1_000_000); +}); + +test('refunds before redemption cannot be bypassed with the original valid transaction', async () => { + const f = fixture(); await f.tx(); f.note('refund', 'purchase'); + assert.equal((await f.post({ signedPayload: 'refund' }, null)).statusCode, 200); + assert.equal(f.wallets.rows.get('alice').balance, 0); + assert.equal((await f.post({ action: 'redeem', jws: 'purchase' })).statusCode, 409); + assert.equal(f.wallets.rows.get('alice').balance, 0); +}); + +test('a refund is applied once and spent credits become debt', async () => { + const f = fixture(); await f.tx(); await f.service.redeem('alice', 'purchase'); + f.wallets.rows.get('alice').balance = 600_000; f.note('refund', 'purchase'); + await Promise.all(Array.from({ length: 5 }, () => f.service.notification('refund'))); + assert.equal(f.wallets.rows.get('alice').balance, -400_000); + assert.equal((await f.post({ action: 'redeem', jws: 'purchase' })).statusCode, 409); + assert.equal(f.wallets.rows.get('alice').balance, -400_000); +}); + +test('wallet failure after refund claim is recoverable and stale redemption cannot mint credits', async () => { + const f = fixture(); await f.tx(); await f.service.redeem('alice', 'purchase'); f.note('refund', 'purchase'); + f.wallets.failRefund = true; + assert.equal((await f.post({ signedPayload: 'refund' }, null)).statusCode, 503); + assert.equal((await f.post({ action: 'redeem', jws: 'purchase' })).statusCode, 409); + assert.equal(f.wallets.rows.get('alice').balance, 0); + assert.equal((await f.post({ signedPayload: 'refund' }, null)).statusCode, 200); + assert.equal(f.wallets.rows.get('alice').balance, 0); +}); + +test('a crash after the wallet grant but before acknowledgment does not double-credit', async () => { + const f = fixture(); await f.tx(); + const update = f.purchases.updateOne.bind(f.purchases); let failOnce = true; + f.purchases.updateOne = async (q, u) => { if (u.$set.deliveredAt && failOnce) { failOnce = false; throw Error('write interrupted'); } return update(q, u); }; + assert.equal((await f.post({ action: 'redeem', jws: 'purchase' })).statusCode, 503); + const retry = await f.post({ action: 'redeem', jws: 'purchase' }); + assert.equal(retry.statusCode, 200); assert.equal(JSON.parse(retry.body).credited, false); + assert.equal(f.wallets.rows.get('alice').balance, 1_000_000); +}); + +test('refund reversals restore exactly the revoked credits and duplicate delivery cannot double-credit', async () => { + const f = fixture(); await f.tx(); await f.service.redeem('alice', 'purchase'); + f.note('refund', 'purchase'); await f.service.notification('refund'); + f.note('reversed', 'purchase', 'Production', { notificationType: 'REFUND_REVERSED' }); + await f.service.notification('reversed'); await f.service.notification('reversed'); + assert.equal(f.notifications.rows.size, 2); + assert.ok([...f.notifications.rows.values()].every(row => row.status === 'applied' && !row.signedPayload)); + assert.equal(f.wallets.rows.get('alice').balance, 1_000_000); + await f.service.notification('refund'); + assert.equal(f.wallets.rows.get('alice').balance, 1_000_000, 'older refund cannot undo reversal'); + assert.equal((await f.service.redeem('alice', 'purchase')).credited, false); +}); + +test('notification signature, app and environment must all match', async () => { + const f = fixture(); await f.tx('sandbox', 'alice', { environment: 'Sandbox' }); + assert.equal((await f.post({ signedPayload: 'forged' }, null)).statusCode, 401); + f.note('mixed', 'sandbox'); + assert.equal((await f.post({ signedPayload: 'mixed' }, null)).statusCode, 401); + f.note('wrong-app', 'sandbox', 'Sandbox', { data: { bundleId: 'wrong', environment: 'Sandbox', signedTransactionInfo: 'sandbox' } }); + assert.equal((await f.post({ signedPayload: 'wrong-app' }, null)).statusCode, 400); + assert.equal(f.wallets.rows.size, 0); +}); + +test('partial refunds use Apple milliunits and a reversal restores only the revoked amount', async () => { + const f = fixture(); await f.tx(); await f.service.redeem('alice', 'purchase'); + f.wallets.rows.get('alice').balance = 600_000; // 400k already spent. + await f.tx('partial', 'alice', { revocationDate: 1900, revocationType: 'REFUND_PRORATED', revocationPercentage: 25_000 }); + f.note('refund-quarter', 'partial'); await f.service.notification('refund-quarter'); + assert.equal(f.wallets.rows.get('alice').balance, 350_000); + f.note('reversal', 'purchase', 'Production', { notificationType: 'REFUND_REVERSED' }); + await f.service.notification('reversal'); + assert.equal(f.wallets.rows.get('alice').balance, 600_000); + assert.equal(f.wallets.rows.get('alice').grants.length, 1); + for (const percent of [-1, 100_001, 1.5, '25', undefined]) { + assert.throws(() => purchaseState({ signedDate: 1, revocationDate: 1, revocationType: 'REFUND_PRORATED', revocationPercentage: percent })); + } + assert.throws(() => purchaseState({ signedDate: undefined })); +}); + +test('all event orders converge to the newest signed state, including a second refund after a reversal', async () => { + const permutations = values => values.length ? values.flatMap((v, i) => permutations(values.filter((_, j) => i !== j)).map(rest => [v, ...rest])) : [[]]; + for (const order of permutations(['first', 'reversal', 'last'])) { + const f = fixture(); await f.tx(); + f.note('first', 'purchase', 'Production', { signedDate: 2000 }); + f.note('reversal', 'purchase', 'Production', { signedDate: 3000, notificationType: 'REFUND_REVERSED' }); + f.note('last', 'purchase', 'Production', { signedDate: 4000 }); + for (const event of order) await f.service.notification(event); + assert.equal(f.wallets.rows.get('alice').balance, 0, order.join(',')); + assert.equal((await f.post({ action: 'redeem', jws: 'purchase' })).statusCode, 409); + assert.equal(f.wallets.rows.get('alice').balance, 0); + } +}); + +test('a reversal arriving first does not lose credit when the old refund eventually arrives', async () => { + const f = fixture(); await f.tx(); + f.note('old-refund', 'purchase', 'Production', { signedDate: 2000 }); + f.note('reversed', 'purchase', 'Production', { signedDate: 3000, notificationType: 'REFUND_REVERSED' }); + await Promise.all(Array.from({ length: 20 }, (_, i) => f.service.notification(i % 2 ? 'old-refund' : 'reversed'))); + assert.equal(f.wallets.rows.get('alice').balance, 1_000_000); + assert.equal(f.wallets.rows.get('alice').grants.length, 1); +}); + +test('a failed reversal remains retryable and a client retry repairs the intended balance', async () => { + const f = fixture(); await f.tx(); f.note('refund', 'purchase'); await f.service.notification('refund'); + f.note('reversed', 'purchase', 'Production', { notificationType: 'REFUND_REVERSED' }); + f.wallets.failRefund = true; + assert.equal((await f.post({ signedPayload: 'reversed' }, null)).statusCode, 503); + assert.equal(f.wallets.rows.get('alice').balance, 0); + assert.equal((await f.post({ action: 'redeem', jws: 'purchase' })).statusCode, 200); + assert.equal(f.wallets.rows.get('alice').balance, 1_000_000); + await f.service.notification('reversed'); + assert.equal(f.wallets.rows.get('alice').balance, 1_000_000); +}); + +test('notifications cannot bypass the sandbox account allowlist', async () => { + const f = fixture(); await f.tx('sandbox', 'alice', { environment: 'Sandbox' }); + f.note('refund', 'sandbox', 'Sandbox'); + f.note('reversed', 'sandbox', 'Sandbox', { notificationType: 'REFUND_REVERSED' }); + assert.equal((await f.post({ signedPayload: 'refund' }, null)).statusCode, 403); + assert.equal((await f.post({ signedPayload: 'reversed' }, null)).statusCode, 403); + assert.equal(f.wallets.rows.size, 0); +}); + +test('deleted claims acknowledge Apple without recreating wallets or rebinding the purchase', async () => { + const f = fixture(); const tx = await f.tx(); await f.service.redeem('alice', 'purchase'); + const row = [...f.purchases.rows.values()][0]; row.deletedAt = new Date(); delete row.user; delete row.appAccountToken; + f.accounts.rows.clear(); f.wallets.rows.clear(); + f.note('reversed', 'purchase', 'Production', { notificationType: 'REFUND_REVERSED' }); + assert.equal((await f.post({ signedPayload: 'reversed' }, null)).statusCode, 200); + assert.equal((await f.post({ action: 'redeem', jws: 'purchase' })).statusCode, 410); + assert.equal(f.accounts.rows.size, 0); assert.equal(f.wallets.rows.size, 0); assert.equal(f.notifications.rows.size, 0); + assert.equal(row.transactionId, tx.transactionId); +}); + +test('durable deletion locks reject account creation and redemption; cancelled deletion can reconcile queued refund', async () => { + const f = fixture(); await f.tx(); await f.service.redeem('alice', 'purchase'); + f.deletions.rows.set('alice', { _id: 'alice', state: 'scheduled' }); + assert.equal((await f.post({ action: 'account' })).statusCode, 409); + assert.equal((await f.post({ action: 'redeem', jws: 'purchase' })).statusCode, 409); + f.note('refund', 'purchase'); assert.equal((await f.post({ signedPayload: 'refund' }, null)).statusCode, 409); + assert.equal(f.wallets.rows.get('alice').balance, 1_000_000); + f.deletions.rows.clear(); + assert.equal((await f.post({ action: 'redeem', jws: 'purchase' })).statusCode, 409); + assert.equal(f.wallets.rows.get('alice').balance, 0); + const hash = createHash('sha256').update('alice').digest('hex'); + f.tombstones.rows.set(hash, { _id: hash, completedAt: new Date() }); + assert.equal((await f.post({ action: 'account' })).statusCode, 409); +}); + +test('the operational runner repairs refunds after failures without waiting for another client or Apple request', async () => { + const f = fixture(); await f.tx(); await f.service.redeem('alice', 'purchase'); + f.note('refund', 'purchase'); f.wallets.failRefund = true; + assert.equal((await f.post({ signedPayload: 'refund' }, null)).statusCode, 503); + assert.deepEqual(await f.service.reconcilePending(), { applied: 1, pending: 0 }); + assert.equal(f.wallets.rows.get('alice').balance, 0); + f.note('reversed', 'purchase', 'Production', { notificationType: 'REFUND_REVERSED' }); + f.deletions.rows.set('alice', { _id: 'alice', state: 'scheduled' }); + await f.post({ signedPayload: 'reversed' }, null); + assert.deepEqual(await f.service.reconcilePending(), { applied: 0, pending: 1 }); + f.deletions.rows.clear(); + for (const row of f.notifications.rows.values()) row.nextAttemptAt = new Date(0); + assert.deepEqual(await f.service.reconcilePending(), { applied: 1, pending: 0 }); + assert.equal(f.wallets.rows.get('alice').balance, 1_000_000); + assert.deepEqual(await f.service.reconcilePending(), { applied: 0, pending: 0 }); +}); + +test('pausing new sales preserves paid delivery and Apple refund handling', async () => { + const f = fixture(); await f.tx(); f.note('refund', 'purchase'); + const handler = createHandler({ service: f.service, authorize: async () => ({ sub: 'alice' }), salesEnabled: false }); + const request = body => handler({ httpMethod: 'POST', headers: {}, body: JSON.stringify(body) }); + assert.equal((await request({ action: 'account' })).statusCode, 503); + assert.equal((await request({ action: 'redeem', jws: 'purchase' })).statusCode, 200); + assert.equal((await request({ signedPayload: 'refund' })).statusCode, 200); + assert.equal(f.wallets.rows.get('alice').balance, 0); +}); + +test('in-flight inserts crossing irreversible deletion cannot recreate raw account data', async () => { + function purge(f) { + f.accounts.rows.clear(); f.wallets.rows.clear(); f.notifications.rows.clear(); + for (const row of f.purchases.rows.values()) { row.deletedAt = new Date(); delete row.user; delete row.appAccountToken; } + const hash = createHash('sha256').update('alice').digest('hex'); + f.tombstones.rows.set(hash, { _id: hash, completedAt: new Date() }); + } + for (const stage of ['account', 'claim', 'wallet', 'notification']) { + const f = fixture(); + if (stage !== 'account') await f.tx(); + const target = stage === 'account' ? f.accounts : stage === 'claim' ? f.purchases : stage === 'wallet' ? f.wallets : f.notifications; + const method = stage === 'wallet' ? 'updateOne' : 'insertOne', original = target[method].bind(target); + let once = true; + target[method] = async (...args) => { + if (once && (stage !== 'wallet' || args[1].$setOnInsert)) { once = false; purge(f); } + return original(...args); + }; + const body = stage === 'account' ? { action: 'account' } : { action: 'redeem', jws: 'purchase' }; + if (stage === 'notification') { f.note('refund', 'purchase'); delete body.action; delete body.jws; body.signedPayload = 'refund'; } + const response = await f.post(body); + assert.equal(response.statusCode, stage === 'account' ? 409 : stage === 'notification' ? 200 : 410, stage); + assert.equal(f.accounts.rows.size, 0, stage); assert.equal(f.wallets.rows.size, 0, stage); assert.equal(f.notifications.rows.size, 0, stage); + for (const row of f.purchases.rows.values()) { + assert.ok(row.deletedAt, stage); assert.equal(row.user, undefined, stage); assert.equal(row.appAccountToken, undefined, stage); + } + } +}); + +test('the actual Apple verifier rejects an unsigned payload before delivery', async () => { + const accounts = collection(), purchases = collection(), wallets = walletCollection(); + const service = whistlegraphPurchases({ accounts, purchases, wallets, + verifiers: makeVerifiers({ appAppleId: 1, online: false }) }); // Fixture app ID; no network. + const unsigned = Buffer.from(JSON.stringify({ alg: 'none' })).toString('base64url') + '.' + + Buffer.from(JSON.stringify({ bundleId: BUNDLE_ID, productId: PRODUCT_ID })).toString('base64url') + '.'; + await assert.rejects(service.redeem('alice', unsigned), /Apple could not verify/); + assert.equal(purchases.rows.size, 0); + assert.equal(wallets.rows.size, 0); +}); + +test('unconfigured production verifier and malformed requests fail closed', async () => { + assert.throws(() => makeVerifiers({ appAppleId: undefined }), /not configured/); + const f = fixture(); + for (const body of [null, [], 12, 'text']) assert.equal((await f.post(body)).statusCode, 400); + assert.equal((await f.post({ action: 'redeem', jws: 'a'.repeat(70_000) })).statusCode, 413); + const saved = process.env.WHISTLEGRAPH_IAP_ENABLED; delete process.env.WHISTLEGRAPH_IAP_ENABLED; + try { assert.equal((await liveHandler({ httpMethod: 'POST', body: '{}' })).statusCode, 503); } + finally { if (saved === undefined) delete process.env.WHISTLEGRAPH_IAP_ENABLED; else process.env.WHISTLEGRAPH_IAP_ENABLED = saved; } +}); diff --git a/system/tests/whistlegraph-preview-privacy.test.mjs b/system/tests/whistlegraph-preview-privacy.test.mjs new file mode 100644 index 0000000000..0dc5cefa8b --- /dev/null +++ b/system/tests/whistlegraph-preview-privacy.test.mjs @@ -0,0 +1,31 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import vm from 'node:vm'; + +const disk = await readFile(new URL('../public/aesthetic.computer/lib/disk.mjs', import.meta.url), 'utf8'); +// Exercise the actual runtime recorder without booting the graphics worker. +const recorder = disk.slice(disk.indexOf('const previewEvidence ='), disk.indexOf('const alphabet =')); +function runtime(search) { + const requests = [], evidence = []; + const context = vm.createContext({ location: { search }, URLSearchParams, Date, JSON, Math, + console: { log() {}, warn() {}, error() {}, info() {} }, performance: { now: () => 0 }, + createPreviewEvidence: () => ({ record: (...args) => evidence.push(args) }), + send() {}, fetch: async (...args) => { requests.push(args); }, setTimeout: () => 1, clearTimeout() {}, + }); + vm.runInContext(recorder + '\nglobalThis.recorder = pieceRuns;', context); + return { context, requests, evidence }; +} +test('private Whistlegraph previews keep evidence locally and never upload console content', () => { + const { context, requests, evidence } = runtime('?noauth=true&preview=walkieware'); + assert.equal(context.recorder.start({ slug: 'private-draft' }), null); + vm.runInContext("console.log('private prompt'); recorder.error(Error('private source')); recorder.flush();", context); + assert.equal(requests.length, 0); + assert.equal(evidence[0][1], 'private prompt'); +}); +test('ordinary public pieces retain their existing run/error logging', () => { + const { context, requests } = runtime(''); + assert.ok(context.recorder.start({ slug: 'notepat' })); + vm.runInContext("console.log('public'); recorder.error(Error('test')); recorder.flush();", context); + assert.deepEqual(requests.map(([, options]) => JSON.parse(options.body).phase), ['start', 'error', 'log']); +});