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 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. +
- 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