Store readiness: in-app account deletion, privacy policy, terms, listing copy
Closes the remaining store-compliance gaps before App Store / Play submission: - Account deletion (App Store guideline 5.1.1): DELETE /me removes the user row (usage cascades). The app exposes it through one quiet 'Account' caption link under the chat, visible only when signed in, driving two native dialogs (Sign out / Delete account with a destructive confirm) — no new views, no menus, minimalism intact. Deletion signs out and resets the app; copy notes that store subscriptions are cancelled in App Store / Play settings. - docs/store/privacy-policy.md: the complete data inventory (matching the actual schema), transient OpenAI processing with no training, no profiling or ads, GDPR legal bases and rights, in-app erasure. - docs/store/terms-of-service.md: not-therapy positioning with crisis guidance, AI-generated-content caveat, 18+ eligibility, auto-renewal/cancellation terms, liability, Swedish governing law. - docs/store/listing.md: App Store and Play copy written to the honest-claims rule (subtitle 'Think Beyond Thought', keywords, descriptions), plus App Privacy and Data safety questionnaire mappings. - LAUNCH.md updated: host the policy/terms, set store URLs, use the prepared listing copy. - Tests: 30 passing (adds DELETE /me coverage). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0118DaxZR36RpnY524vRqx3z
This commit is contained in:
@@ -46,8 +46,15 @@ the README).
|
||||
two subscriptions with the same ids and prices. Create a service
|
||||
account with Play Developer API access → its JSON key becomes
|
||||
`GOOGLE_SERVICE_ACCOUNT_JSON`.
|
||||
- [ ] Store copy follows the honest-claims rule: inspiration, not proven
|
||||
effects. Positioning: reflection partner — not therapy.
|
||||
- [ ] Store copy: use `docs/store/listing.md` (already written to the
|
||||
honest-claims rule — inspiration, not proven effects; reflection
|
||||
partner, not therapy).
|
||||
- [ ] Fill the [PLACEHOLDERS] in `docs/store/privacy-policy.md` and
|
||||
`docs/store/terms-of-service.md`, host them on a public URL (GitHub
|
||||
Pages is enough), and set the URLs in both store consoles.
|
||||
- [ ] App Privacy / Data safety forms: mappings are in
|
||||
`docs/store/listing.md`. In-app account deletion (App Store 5.1.1)
|
||||
is implemented: Account → Delete account.
|
||||
|
||||
## 5. Builds
|
||||
|
||||
|
||||
+18
-1
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import type { ChatMessage } from './src/api/client';
|
||||
import { getAccessToken } from './src/auth/session';
|
||||
import { ChatScreen } from './src/screens/ChatScreen';
|
||||
import { PaywallScreen } from './src/screens/PaywallScreen';
|
||||
|
||||
@@ -15,6 +16,19 @@ export default function App() {
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [paywalled, setPaywalled] = useState(false);
|
||||
const [deliverAnalysis, setDeliverAnalysis] = useState(false);
|
||||
const [signedIn, setSignedIn] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
getAccessToken().then((token) => setSignedIn(token !== null));
|
||||
}, []);
|
||||
|
||||
function resetToSignedOut() {
|
||||
setSignedIn(false);
|
||||
setMessages([]);
|
||||
setPaywalled(false);
|
||||
setDeliverAnalysis(false);
|
||||
setView('chat');
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -28,11 +42,14 @@ export default function App() {
|
||||
onContinueWithPremium={() => setView('paywall')}
|
||||
deliverAnalysis={deliverAnalysis}
|
||||
onAnalysisDelivered={() => setDeliverAnalysis(false)}
|
||||
signedIn={signedIn}
|
||||
onSignedOut={resetToSignedOut}
|
||||
/>
|
||||
)}
|
||||
{view === 'paywall' && (
|
||||
<PaywallScreen
|
||||
onSubscribed={() => {
|
||||
setSignedIn(true);
|
||||
setPaywalled(false);
|
||||
setDeliverAnalysis(true);
|
||||
setView('chat');
|
||||
|
||||
@@ -89,6 +89,11 @@ export async function fetchMe(): Promise<Me> {
|
||||
return request<Me>('/me', {}, await authHeader());
|
||||
}
|
||||
|
||||
/** Permanently deletes the account and its usage data (App Store 5.1.1). */
|
||||
export async function deleteAccount(): Promise<void> {
|
||||
await request<{ deleted: boolean }>('/me', { method: 'DELETE' }, await authHeader());
|
||||
}
|
||||
|
||||
export async function verifyPurchase(body: {
|
||||
platform: 'ios' | 'android';
|
||||
productId: string;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
FlatList,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
@@ -9,7 +10,8 @@ import {
|
||||
TextInput,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { fetchSuggestions, sendChat, type ChatMessage } from '../api/client';
|
||||
import { deleteAccount, fetchSuggestions, sendChat, type ChatMessage } from '../api/client';
|
||||
import { signOut } from '../auth/session';
|
||||
import { colors, spacing, type } from '../theme';
|
||||
|
||||
interface Props {
|
||||
@@ -22,6 +24,9 @@ interface Props {
|
||||
/** Set right after unlock: fetch the promised analysis automatically. */
|
||||
deliverAnalysis: boolean;
|
||||
onAnalysisDelivered: () => void;
|
||||
/** Shows the quiet Account affordance (sign out / delete account). */
|
||||
signedIn: boolean;
|
||||
onSignedOut: () => void;
|
||||
}
|
||||
|
||||
const SUGGESTIONS_SHOWN = 4;
|
||||
@@ -39,6 +44,8 @@ export function ChatScreen({
|
||||
onContinueWithPremium,
|
||||
deliverAnalysis,
|
||||
onAnalysisDelivered,
|
||||
signedIn,
|
||||
onSignedOut,
|
||||
}: Props) {
|
||||
const [suggestions, setSuggestions] = useState<string[]>([]);
|
||||
const [draft, setDraft] = useState('');
|
||||
@@ -92,6 +99,42 @@ export function ChatScreen({
|
||||
|
||||
const showSuggestions = messages.length === 0 && suggestions.length > 0;
|
||||
|
||||
// No menus, no settings view: account management lives in two native
|
||||
// dialogs behind one quiet link (in-app deletion per App Store 5.1.1).
|
||||
function openAccount() {
|
||||
Alert.alert('Account', undefined, [
|
||||
{
|
||||
text: 'Sign out',
|
||||
onPress: () => {
|
||||
signOut().then(onSignedOut);
|
||||
},
|
||||
},
|
||||
{
|
||||
text: 'Delete account',
|
||||
style: 'destructive',
|
||||
onPress: () =>
|
||||
Alert.alert(
|
||||
'Delete account?',
|
||||
'This permanently deletes your account and usage data. Conversations are never stored. An active subscription is cancelled in your App Store or Google Play settings.',
|
||||
[
|
||||
{
|
||||
text: 'Delete',
|
||||
style: 'destructive',
|
||||
onPress: () => {
|
||||
deleteAccount()
|
||||
.then(() => signOut())
|
||||
.then(onSignedOut)
|
||||
.catch(() => setError('Something went wrong. Please try again.'));
|
||||
},
|
||||
},
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
],
|
||||
),
|
||||
},
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
]);
|
||||
}
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
style={styles.container}
|
||||
@@ -150,6 +193,11 @@ export function ChatScreen({
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
{signedIn ? (
|
||||
<Pressable style={styles.accountLink} onPress={openAccount}>
|
||||
<Text style={styles.accountText}>Account</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
@@ -191,8 +239,10 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'flex-end',
|
||||
gap: spacing.s,
|
||||
paddingHorizontal: spacing.l,
|
||||
paddingBottom: spacing.xl,
|
||||
paddingBottom: spacing.m,
|
||||
},
|
||||
accountLink: { alignItems: 'center', paddingBottom: spacing.l },
|
||||
accountText: { ...type.caption },
|
||||
input: {
|
||||
flex: 1,
|
||||
...type.body,
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
# Store listing copy
|
||||
|
||||
All copy follows the honest-claims rule: neurosemantics/NLP as inspiration
|
||||
and models for reflection — never "scientifically proven", never promised
|
||||
results. Tone: calm, clear, no hype.
|
||||
|
||||
## App Store (iOS)
|
||||
|
||||
- **Name:** Semantika
|
||||
- **Subtitle** (≤30 chars): `Think Beyond Thought`
|
||||
- **Category:** Lifestyle (primary), Education (secondary)
|
||||
- **Age rating:** 17+ (unrestricted web-like AI content; matches 18+ terms)
|
||||
- **Keywords** (≤100 chars):
|
||||
`reflection,thinking,clarity,communication,meaning,mindset,self insight,NLP,decision,conversation`
|
||||
- **Promotional text:**
|
||||
A quiet conversation partner for your thinking. Explore how you create
|
||||
meaning — one structured conversation at a time.
|
||||
- **Description:**
|
||||
|
||||
Semantika is a minimalist AI conversation partner for reflection,
|
||||
inspired by neurosemantic principles.
|
||||
|
||||
Start a conversation about what is on your mind — a difficult
|
||||
conversation ahead, a decision you keep postponing, a pattern you keep
|
||||
repeating. Semantika listens, asks considered follow-up questions, and
|
||||
helps you see how you interpret your situation. When the picture is
|
||||
clear, it offers a structured analysis, concrete recommendations, and
|
||||
practical exercises.
|
||||
|
||||
No feeds. No streaks. No noise. One conversation at a time.
|
||||
|
||||
— Begin immediately: no registration before your first question
|
||||
— Conversations are never stored, and never used for training
|
||||
— A calm, structured method: acknowledge, explore, lift, challenge
|
||||
— Premium: full analyses, personalised action plans, unlimited dialogue
|
||||
|
||||
Semantika is not therapy and does not replace professional care. Its
|
||||
models are offered as perspectives for reflection, inspired by
|
||||
neurosemantics and NLP.
|
||||
|
||||
Subscriptions ($5.99/month or $49.99/year) renew automatically and can
|
||||
be cancelled anytime in your App Store settings.
|
||||
Privacy policy and terms: [PRIVACY_POLICY_URL] · [TERMS_URL]
|
||||
|
||||
## Google Play (Android)
|
||||
|
||||
- **App name:** Semantika
|
||||
- **Short description** (≤80 chars):
|
||||
`A quiet AI conversation partner for reflection, clarity and better decisions.`
|
||||
- **Full description:** reuse the App Store description above.
|
||||
- **Category:** Lifestyle
|
||||
|
||||
## App Privacy (Apple questionnaire mapping)
|
||||
|
||||
- **Contact info → Email address:** collected, linked to identity, for app
|
||||
functionality only. No tracking.
|
||||
- **Identifiers → User ID:** account id / device id, app functionality only.
|
||||
- **Purchases:** subscription status, app functionality only.
|
||||
- **User content (chat):** processed but **not collected/stored**; declare
|
||||
as not collected (transient processing), and describe in the privacy
|
||||
policy.
|
||||
- **Tracking:** none. No ads, no data sold or shared for advertising.
|
||||
|
||||
## Data safety (Google Play form)
|
||||
|
||||
- Collected: email, user ids, purchase history (subscription status).
|
||||
- Shared: none. Encrypted in transit: yes. Deletable: yes, in-app.
|
||||
- Chat content: processed ephemerally, not stored.
|
||||
|
||||
## Required URLs
|
||||
|
||||
Host `privacy-policy.md` and `terms-of-service.md` on a public URL (GitHub
|
||||
Pages is enough for the beta) and set them in both store consoles. Replace
|
||||
the [PLACEHOLDERS] in both documents first.
|
||||
@@ -0,0 +1,62 @@
|
||||
# Semantika — Privacy Policy
|
||||
|
||||
_Last updated: [DATE]. Controller: [COMPANY NAME, ADDRESS, ORG NUMBER].
|
||||
Contact: [support@semantika.app]._
|
||||
|
||||
Semantika is built to know as little about you as possible. This policy
|
||||
describes exactly what we process, why, and what we never do.
|
||||
|
||||
## What we store
|
||||
|
||||
| Data | Purpose | Kept until |
|
||||
| ----------------------------------------------------------- | ------------------------------------------- | -------------------------------------- |
|
||||
| Email address and sign-in provider (Apple, Google or email) | Your account | Account deletion |
|
||||
| Subscription status (`free`/`active`) | Unlocking Premium | Account deletion |
|
||||
| Message counters and their reset date | Free-tier limits and abuse prevention | Account deletion |
|
||||
| A random device identifier (guests only) | Letting you chat before creating an account | Removed when you delete the app's data |
|
||||
|
||||
That is the complete list. We do not collect names, birthdays, locations,
|
||||
contacts, photos, advertising identifiers, or analytics profiles.
|
||||
|
||||
## Your conversations
|
||||
|
||||
- Conversations are **never stored on our servers**. They live in your
|
||||
app's memory and disappear when you close the conversation.
|
||||
- To generate each reply, the current conversation is transmitted over
|
||||
encrypted connections to our AI provider, **OpenAI** (acting as a data
|
||||
processor), and processed transiently. Under OpenAI's API terms, API data
|
||||
is **not used to train their models**.
|
||||
- We never use your conversations for advertising, profiling, or training
|
||||
of any kind.
|
||||
- Error logs never contain message content.
|
||||
|
||||
## Payments
|
||||
|
||||
Purchases are handled entirely by Apple (App Store) or Google (Google
|
||||
Play). We never see your card details — only a receipt that we verify with
|
||||
the store and your resulting subscription status.
|
||||
|
||||
## Legal bases (GDPR)
|
||||
|
||||
- **Performance of contract** — providing the service you signed up for.
|
||||
- **Legitimate interest** — preventing abuse of the free tier.
|
||||
|
||||
## Your rights
|
||||
|
||||
You can access, correct, export, or erase your data at any time. Deleting
|
||||
your account (**Account → Delete account** in the app) permanently removes
|
||||
everything in the table above. Cancel an active subscription in your App
|
||||
Store or Google Play settings — deleting the account does not cancel the
|
||||
store subscription. For anything else, contact [support@semantika.app].
|
||||
You may also lodge a complaint with your supervisory authority (in Sweden:
|
||||
IMY).
|
||||
|
||||
## Security
|
||||
|
||||
All traffic is encrypted (HTTPS). Authentication uses AWS Cognito.
|
||||
Databases are encrypted at rest and unreachable from the public internet.
|
||||
|
||||
## Changes
|
||||
|
||||
If this policy changes materially, we will note it in the app before the
|
||||
change takes effect.
|
||||
@@ -0,0 +1,62 @@
|
||||
# Semantika — Terms of Service
|
||||
|
||||
_Last updated: [DATE]. Provider: [COMPANY NAME, ADDRESS, ORG NUMBER].
|
||||
Contact: [support@semantika.app]._
|
||||
|
||||
## What Semantika is — and is not
|
||||
|
||||
Semantika is an AI conversation partner for reflection, inspired by
|
||||
neurosemantic and NLP models. It helps you explore how you create meaning,
|
||||
interpret your experiences, and communicate.
|
||||
|
||||
Semantika is **not** therapy, medical or psychological care, or
|
||||
professional advice of any kind. Its models are offered as perspectives for
|
||||
reflection — not as scientifically proven methods — and its replies are
|
||||
generated by an AI system and may be incorrect. You remain responsible for
|
||||
your decisions.
|
||||
|
||||
**If you are in crisis or experiencing acute distress, do not use the app —
|
||||
contact your local emergency number or a health professional immediately.**
|
||||
|
||||
## Eligibility
|
||||
|
||||
You must be at least 18 years old to use Semantika.
|
||||
|
||||
## Subscriptions
|
||||
|
||||
- Plans: Monthly and Yearly, purchased through Apple App Store or Google
|
||||
Play. Prices are shown in the app before purchase.
|
||||
- Subscriptions renew automatically until cancelled in your App Store or
|
||||
Google Play settings, at least 24 hours before the current period ends.
|
||||
- Deleting your account does not cancel a store subscription — cancel it in
|
||||
the store first.
|
||||
- Refunds are handled by Apple and Google under their policies.
|
||||
|
||||
## Acceptable use
|
||||
|
||||
Do not use the service unlawfully, attempt to disrupt or reverse-engineer
|
||||
it, or circumvent usage limits. We may suspend accounts that do.
|
||||
|
||||
## Privacy
|
||||
|
||||
How we handle data is described in the [Privacy Policy](privacy-policy.md).
|
||||
In short: conversations are never stored, and we keep the absolute minimum.
|
||||
|
||||
## Liability
|
||||
|
||||
To the extent permitted by law, Semantika is provided "as is" and our
|
||||
liability is limited to the amount you paid for the service in the twelve
|
||||
months preceding the claim. Nothing in these terms limits liability that
|
||||
cannot be limited under applicable law.
|
||||
|
||||
## Changes and termination
|
||||
|
||||
We may update these terms; material changes will be announced in the app.
|
||||
You can stop using the service and delete your account at any time
|
||||
(**Account → Delete account**).
|
||||
|
||||
## Governing law
|
||||
|
||||
These terms are governed by the laws of Sweden, and disputes are resolved
|
||||
by Swedish courts, without limiting mandatory consumer protections in your
|
||||
country of residence.
|
||||
@@ -156,7 +156,7 @@ export class SemantikaStack extends Stack {
|
||||
);
|
||||
const api = new HttpApi(this, 'HttpApi', { defaultAuthorizer: authorizer });
|
||||
const integration = new HttpLambdaIntegration('ApiIntegration', apiFunction);
|
||||
api.addRoutes({ path: '/me', methods: [HttpMethod.GET], integration });
|
||||
api.addRoutes({ path: '/me', methods: [HttpMethod.GET, HttpMethod.DELETE], integration });
|
||||
api.addRoutes({ path: '/chat', methods: [HttpMethod.POST], integration });
|
||||
api.addRoutes({ path: '/subscription/verify', methods: [HttpMethod.POST], integration });
|
||||
|
||||
|
||||
@@ -75,6 +75,12 @@ export async function incrementUsage(userId: string): Promise<void> {
|
||||
await db.query(`update usage set messages_used = messages_used + 1 where user_id = $1`, [userId]);
|
||||
}
|
||||
|
||||
/** Permanently removes the user; the usage row cascades. */
|
||||
export async function deleteUser(userId: string): Promise<void> {
|
||||
const db = await getPool();
|
||||
await db.query(`delete from users where id = $1`, [userId]);
|
||||
}
|
||||
|
||||
export async function setSubscriptionStatus(
|
||||
userId: string,
|
||||
status: 'free' | 'active',
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import type { APIGatewayProxyEventV2, APIGatewayProxyResultV2 } from 'aws-lambda';
|
||||
import { generateReply, type ChatMessage } from './chat.js';
|
||||
import { config } from './config.js';
|
||||
import { getOrCreateUser, getUsage, incrementUsage, resetUsage, type UserRow } from './db.js';
|
||||
import {
|
||||
deleteUser,
|
||||
getOrCreateUser,
|
||||
getUsage,
|
||||
incrementUsage,
|
||||
resetUsage,
|
||||
type UserRow,
|
||||
} from './db.js';
|
||||
import { verifyAndApplyPurchase } from './subscription/index.js';
|
||||
import type { VerifyPurchaseRequest } from './subscription/types.js';
|
||||
import { canSendMessage, shouldResetUsage } from './usage.js';
|
||||
@@ -142,6 +149,12 @@ export async function handler(event: APIGatewayProxyEventV2): Promise<APIGateway
|
||||
switch (route) {
|
||||
case 'GET /me':
|
||||
return await handleMe(user);
|
||||
case 'DELETE /me':
|
||||
// In-app account deletion (App Store guideline 5.1.1). Removes the
|
||||
// user and usage rows; store subscriptions are cancelled by the user
|
||||
// in App Store / Play settings.
|
||||
await deleteUser(user.id);
|
||||
return json(200, { deleted: true });
|
||||
case 'POST /chat':
|
||||
return await handleChat(user, event.body);
|
||||
case 'POST /subscription/verify':
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../src/db.js', () => ({
|
||||
deleteUser: vi.fn(),
|
||||
getOrCreateUser: vi.fn(),
|
||||
getUsage: vi.fn(),
|
||||
incrementUsage: vi.fn(),
|
||||
@@ -14,7 +15,7 @@ vi.mock('../src/subscription/index.js', () => ({
|
||||
}));
|
||||
|
||||
import { generateReply } from '../src/chat.js';
|
||||
import { getOrCreateUser, getUsage, incrementUsage } from '../src/db.js';
|
||||
import { deleteUser, getOrCreateUser, getUsage, incrementUsage } from '../src/db.js';
|
||||
import { handler } from '../src/handler.js';
|
||||
import { verifyAndApplyPurchase } from '../src/subscription/index.js';
|
||||
|
||||
@@ -101,6 +102,15 @@ describe('authorization', () => {
|
||||
});
|
||||
expect(getOrCreateUser).toHaveBeenCalledWith('sub-1', 'a@b.se', 'apple');
|
||||
});
|
||||
|
||||
it('deletes the account on DELETE /me', async () => {
|
||||
const { status, body } = await call('DELETE', '/me', {
|
||||
claims: { sub: 'sub-1', email: 'a@b.se' },
|
||||
});
|
||||
expect(status).toBe(200);
|
||||
expect(body.deleted).toBe(true);
|
||||
expect(deleteUser).toHaveBeenCalledWith('user-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('chat and the intelligent paywall', () => {
|
||||
|
||||
Reference in New Issue
Block a user