diff --git a/LAUNCH.md b/LAUNCH.md new file mode 100644 index 0000000..cb9a2e1 --- /dev/null +++ b/LAUNCH.md @@ -0,0 +1,82 @@ +# Launch checklist — closed beta + +The path from this repo to 20–50 test users. Everything below is +configuration and store work; the code is done (see Definition of Done in +the README). + +## 1. AWS + +- [ ] `cd infra && npx cdk bootstrap` (once per account/region). +- [ ] `npx cdk deploy` — note the outputs: `ApiUrl`, `UserPoolId`, + `UserPoolClientId`, `CognitoDomain`. +- [ ] Fill the `semantika/app` secret in Secrets Manager: + `OPENAI_API_KEY` (required for chat), `APPLE_SHARED_SECRET` and + `GOOGLE_SERVICE_ACCOUNT_JSON` (required before purchases work). +- [ ] Run `db/migrations/001_init.sql` against the Aurora cluster + (credentials in the RDS-managed secret; connect via a bastion or the + RDS query editor). +- [ ] Smoke test: `curl /suggestions` returns the starter list. + +## 2. Sign-in + +- [ ] Email sign-in works out of the box (Cognito hosted UI). +- [ ] Apple: create a Services ID + key in the Apple Developer portal, then + redeploy with CDK context `appleTeamId`, `appleKeyId`, + `applePrivateKeySecretName`. +- [ ] Google: create an OAuth client in Google Cloud, then redeploy with + `googleClientId`, `googleClientSecret`. +- [ ] For the beta, email-only is acceptable — Apple/Google can land in a + later build. Note: Apple's review requires Sign in with Apple if + other third-party logins are offered, so enable it before public + App Store release. + +## 3. App configuration + +- [ ] Put the CDK outputs into `apps/mobile/app.json` → `extra` + (`apiUrl`, `cognitoDomain`, `cognitoClientId`). +- [ ] Keep product ids as `semantika_monthly` / `semantika_yearly`. + +## 4. Stores + +- [ ] App Store Connect: create the app (bundle id `com.semantika.app`), + add auto-renewable subscriptions `semantika_monthly` ($5.99/month) + and `semantika_yearly` ($49.99/year) in one subscription group. + Generate the App-Specific Shared Secret → `APPLE_SHARED_SECRET`. +- [ ] Play Console: create the app (package `com.semantika.app`), add the + 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. + +## 5. Builds + +- [ ] `cd apps/mobile && npx eas build --profile preview --platform all` + (in-app purchases require a real build, not Expo Go). +- [ ] iOS: distribute via TestFlight (internal, then external testers). +- [ ] Android: distribute via Play Console internal testing track. +- [ ] Purchases in test: use TestFlight sandbox accounts / Play license + testers — no real charges. + +## 6. CI/CD + +- [ ] Create the GitHub OIDC deploy role in AWS; set repo secret + `AWS_DEPLOY_ROLE_ARN` (and optionally the `AWS_REGION` variable). +- [ ] Merge this branch to `main` — CI runs lint/typecheck/tests, + `deploy.yml` deploys the stack automatically. + +## 7. The beta itself (20–50 users) + +Measure before building anything new: + +- **Do they come back?** — active users over time (`usage` activity). +- **How deep do dialogues go?** — `messages_used` distribution. +- **When do they upgrade?** — `subscription` transitions relative to + `created_at`. +- **Which starter questions create value?** — conversations are never + stored, so ask the testers directly (short interviews or a 3-question + survey beats analytics here). + +Exit criteria for V1 → V2 decisions: a clear picture of retention, dialogue +depth, upgrade timing, and which conversation types resonate. Only then +open the Version 2 list. diff --git a/README.md b/README.md index ad9d637..4df9a73 100644 --- a/README.md +++ b/README.md @@ -172,7 +172,10 @@ After the first deploy: GitHub Actions: `ci.yml` lints, type-checks and tests every PR; `deploy.yml` deploys the CDK stack on every push to `main` (set the `AWS_DEPLOY_ROLE_ARN` secret for OIDC). Store builds ship via EAS -(`eas build`) when you choose to release. +(`eas build`, profiles in `apps/mobile/eas.json`). + +The full path to the closed beta — AWS, stores, builds, testers — is in +[LAUNCH.md](LAUNCH.md). ## Security diff --git a/apps/mobile/eas.json b/apps/mobile/eas.json new file mode 100644 index 0000000..65a3367 --- /dev/null +++ b/apps/mobile/eas.json @@ -0,0 +1,21 @@ +{ + "cli": { + "version": ">= 12.0.0", + "appVersionSource": "remote" + }, + "build": { + "development": { + "developmentClient": true, + "distribution": "internal" + }, + "preview": { + "distribution": "internal" + }, + "production": { + "autoIncrement": true + } + }, + "submit": { + "production": {} + } +} diff --git a/services/api/test/chat.test.ts b/services/api/test/chat.test.ts new file mode 100644 index 0000000..a1548b7 --- /dev/null +++ b/services/api/test/chat.test.ts @@ -0,0 +1,73 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../src/secrets.js', () => ({ + getSecret: vi.fn(async () => ({ OPENAI_API_KEY: 'test-key' })), +})); + +import { generateReply } from '../src/chat.js'; + +function openAiResponse(reply: string, analysisReady: boolean) { + return { + ok: true, + json: async () => ({ + output: [ + { + type: 'message', + content: [ + { + type: 'output_text', + text: JSON.stringify({ reply, analysis_ready: analysisReady }), + }, + ], + }, + ], + }), + text: async () => '', + }; +} + +const fetchMock = vi.fn(); + +beforeEach(() => { + process.env.KNOWLEDGE_DIR = 'knowledge'; + vi.stubGlobal('fetch', fetchMock); + fetchMock.mockReset(); +}); + +describe('generateReply', () => { + it('parses structured output and surfaces analysis_ready in free mode', async () => { + fetchMock.mockResolvedValue(openAiResponse('A transition.', true)); + const result = await generateReply([{ role: 'user', content: 'Hi' }], 'free'); + expect(result).toEqual({ reply: 'A transition.', analysisReady: true }); + }); + + it('never reports analysis_ready in premium mode', async () => { + fetchMock.mockResolvedValue(openAiResponse('Full analysis.', true)); + const result = await generateReply([{ role: 'user', content: 'Hi' }], 'premium'); + expect(result).toEqual({ reply: 'Full analysis.', analysisReady: false }); + }); + + it('sends the knowledge base and a strict JSON schema to the Responses API', async () => { + fetchMock.mockResolvedValue(openAiResponse('Ok.', false)); + await generateReply([{ role: 'user', content: 'Hi' }], 'free'); + + const [url, init] = fetchMock.mock.calls[0] as [string, { body: string }]; + expect(url).toBe('https://api.openai.com/v1/responses'); + const payload = JSON.parse(init.body) as { + instructions: string; + text: { format: { type: string; strict: boolean } }; + }; + expect(payload.instructions).toContain('Semantika'); + expect(payload.instructions).toContain('Never manufacture suspense'); + expect(payload.instructions).toContain('# Neurosemantics'); + expect(payload.text.format.type).toBe('json_schema'); + expect(payload.text.format.strict).toBe(true); + }); + + it('throws on a non-ok response', async () => { + fetchMock.mockResolvedValue({ ok: false, status: 500, text: async () => 'boom' }); + await expect(generateReply([{ role: 'user', content: 'Hi' }], 'free')).rejects.toThrow( + 'OpenAI request failed', + ); + }); +}); diff --git a/services/api/test/handler.test.ts b/services/api/test/handler.test.ts new file mode 100644 index 0000000..173dfc7 --- /dev/null +++ b/services/api/test/handler.test.ts @@ -0,0 +1,176 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../src/db.js', () => ({ + getOrCreateUser: vi.fn(), + getUsage: vi.fn(), + incrementUsage: vi.fn(), + resetUsage: vi.fn(), +})); +vi.mock('../src/chat.js', () => ({ + generateReply: vi.fn(), +})); +vi.mock('../src/subscription/index.js', () => ({ + verifyAndApplyPurchase: vi.fn(), +})); + +import { generateReply } from '../src/chat.js'; +import { getOrCreateUser, getUsage, incrementUsage } from '../src/db.js'; +import { handler } from '../src/handler.js'; +import { verifyAndApplyPurchase } from '../src/subscription/index.js'; + +interface EventOptions { + body?: unknown; + claims?: Record; + headers?: Record; +} + +function makeEvent(method: string, path: string, opts: EventOptions = {}) { + return { + rawPath: path, + headers: opts.headers ?? {}, + body: opts.body === undefined ? undefined : JSON.stringify(opts.body), + requestContext: { + http: { method }, + ...(opts.claims ? { authorizer: { jwt: { claims: opts.claims } } } : {}), + }, + // The handler only reads the fields above. + } as never; +} + +async function call(method: string, path: string, opts: EventOptions = {}) { + const result = (await handler(makeEvent(method, path, opts))) as { + statusCode: number; + body: string; + }; + return { status: result.statusCode, body: JSON.parse(result.body) as Record }; +} + +const freeUser = { + id: 'user-1', + email: 'a@b.se', + auth_provider: 'email', + subscription: 'free' as const, + created_at: new Date(), +}; +const premiumUser = { ...freeUser, subscription: 'active' as const }; +const freshUsage = { user_id: 'user-1', messages_used: 0, last_reset: new Date() }; + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getOrCreateUser).mockResolvedValue(freeUser); + vi.mocked(getUsage).mockResolvedValue(freshUsage); + vi.mocked(generateReply).mockResolvedValue({ reply: 'A reply.', analysisReady: false }); +}); + +describe('public routes', () => { + it('serves suggestions without authentication', async () => { + const { status, body } = await call('GET', '/suggestions'); + expect(status).toBe(200); + expect(Array.isArray(body.suggestions)).toBe(true); + expect((body.suggestions as string[]).length).toBeGreaterThan(0); + }); + + it('rejects guest chat without a device id', async () => { + const { status } = await call('POST', '/guest/chat', { + body: { messages: [{ role: 'user', content: 'Hi' }] }, + }); + expect(status).toBe(400); + }); + + it('creates a guest user from the device id and chats', async () => { + const { status, body } = await call('POST', '/guest/chat', { + headers: { 'x-device-id': 'aaaa-bbbb-cccc' }, + body: { messages: [{ role: 'user', content: 'Hi' }] }, + }); + expect(status).toBe(200); + expect(body.reply).toBe('A reply.'); + expect(getOrCreateUser).toHaveBeenCalledWith('guest:aaaa-bbbb-cccc', '', 'guest'); + expect(incrementUsage).toHaveBeenCalledWith('user-1'); + }); +}); + +describe('authorization', () => { + it('returns 401 on account routes without JWT claims', async () => { + const { status } = await call('GET', '/me'); + expect(status).toBe(401); + }); + + it('derives the auth provider from the identities claim', async () => { + await call('GET', '/me', { + claims: { sub: 'sub-1', email: 'a@b.se', identities: '[{"providerName":"SignInWithApple"}]' }, + }); + expect(getOrCreateUser).toHaveBeenCalledWith('sub-1', 'a@b.se', 'apple'); + }); +}); + +describe('chat and the intelligent paywall', () => { + const claims = { sub: 'sub-1', email: 'a@b.se' }; + const userMessages = { messages: [{ role: 'user', content: 'Hi' }] }; + + it('passes the paywall flag through when the analysis is ready', async () => { + vi.mocked(generateReply).mockResolvedValue({ reply: 'Transition.', analysisReady: true }); + const { status, body } = await call('POST', '/chat', { claims, body: userMessages }); + expect(status).toBe(200); + expect(body).toEqual({ reply: 'Transition.', paywall: true }); + expect(generateReply).toHaveBeenCalledWith(userMessages.messages, 'free'); + }); + + it('runs premium chats in premium mode', async () => { + vi.mocked(getOrCreateUser).mockResolvedValue(premiumUser); + await call('POST', '/chat', { claims, body: userMessages }); + expect(generateReply).toHaveBeenCalledWith(userMessages.messages, 'premium'); + }); + + it('rejects a transcript ending with an assistant message for free users', async () => { + const { status } = await call('POST', '/chat', { + claims, + body: { messages: [{ role: 'assistant', content: 'Transition.' }] }, + }); + expect(status).toBe(400); + }); + + it('accepts an assistant-final transcript for premium users (post-unlock delivery)', async () => { + vi.mocked(getOrCreateUser).mockResolvedValue(premiumUser); + const { status } = await call('POST', '/chat', { + claims, + body: { messages: [{ role: 'assistant', content: 'Transition.' }] }, + }); + expect(status).toBe(200); + }); + + it('enforces the abuse cap for free users with 402', async () => { + vi.mocked(getUsage).mockResolvedValue({ ...freshUsage, messages_used: 100000 }); + const { status, body } = await call('POST', '/chat', { claims, body: userMessages }); + expect(status).toBe(402); + expect(body.error).toBe('message_cap_reached'); + }); + + it('never blocks premium users on the cap', async () => { + vi.mocked(getOrCreateUser).mockResolvedValue(premiumUser); + vi.mocked(getUsage).mockResolvedValue({ ...freshUsage, messages_used: 100000 }); + const { status } = await call('POST', '/chat', { claims, body: userMessages }); + expect(status).toBe(200); + }); +}); + +describe('purchase verification', () => { + const claims = { sub: 'sub-1', email: 'a@b.se' }; + + it('validates the request body', async () => { + const { status } = await call('POST', '/subscription/verify', { + claims, + body: { platform: 'windows', productId: 'x' }, + }); + expect(status).toBe(400); + }); + + it('returns the resulting subscription status', async () => { + vi.mocked(verifyAndApplyPurchase).mockResolvedValue('active'); + const { status, body } = await call('POST', '/subscription/verify', { + claims, + body: { platform: 'ios', productId: 'semantika_monthly', receipt: 'abc' }, + }); + expect(status).toBe(200); + expect(body.subscriptionStatus).toBe('active'); + }); +}); diff --git a/services/api/test/subscription.test.ts b/services/api/test/subscription.test.ts new file mode 100644 index 0000000..9b3981a --- /dev/null +++ b/services/api/test/subscription.test.ts @@ -0,0 +1,124 @@ +import { generateKeyPairSync } from 'node:crypto'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { privateKey } = generateKeyPairSync('rsa', { + modulusLength: 2048, + privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, + publicKeyEncoding: { type: 'spki', format: 'pem' }, +}); + +vi.mock('../src/secrets.js', () => ({ + getSecret: vi.fn(async () => ({ + APPLE_SHARED_SECRET: 'apple-secret', + GOOGLE_SERVICE_ACCOUNT_JSON: JSON.stringify({ + client_email: 'svc@project.iam.gserviceaccount.com', + private_key: privateKey, + }), + })), +})); + +import { appleProvider } from '../src/subscription/apple.js'; +import { googleProvider } from '../src/subscription/google.js'; + +const fetchMock = vi.fn(); + +beforeEach(() => { + vi.stubGlobal('fetch', fetchMock); + fetchMock.mockReset(); +}); + +function jsonResponse(payload: unknown, status = 200) { + return { ok: status >= 200 && status < 300, status, json: async () => payload }; +} + +describe('appleProvider', () => { + const request = { platform: 'ios' as const, productId: 'semantika_monthly', receipt: 'r' }; + + it('marks an unexpired subscription active', async () => { + fetchMock.mockResolvedValue( + jsonResponse({ + status: 0, + latest_receipt_info: [ + { product_id: 'semantika_monthly', expires_date_ms: String(Date.now() + 60_000) }, + ], + }), + ); + const result = await appleProvider.verify(request); + expect(result.active).toBe(true); + }); + + it('retries against the sandbox on status 21007', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ status: 21007 })).mockResolvedValueOnce( + jsonResponse({ + status: 0, + latest_receipt_info: [ + { product_id: 'semantika_monthly', expires_date_ms: String(Date.now() + 60_000) }, + ], + }), + ); + const result = await appleProvider.verify(request); + expect(result.active).toBe(true); + expect(fetchMock.mock.calls[0]?.[0]).toContain('buy.itunes.apple.com'); + expect(fetchMock.mock.calls[1]?.[0]).toContain('sandbox.itunes.apple.com'); + }); + + it('marks an expired subscription inactive', async () => { + fetchMock.mockResolvedValue( + jsonResponse({ + status: 0, + latest_receipt_info: [ + { product_id: 'semantika_monthly', expires_date_ms: String(Date.now() - 60_000) }, + ], + }), + ); + const result = await appleProvider.verify(request); + expect(result.active).toBe(false); + }); + + it('ignores receipts for other products', async () => { + fetchMock.mockResolvedValue( + jsonResponse({ + status: 0, + latest_receipt_info: [ + { product_id: 'something_else', expires_date_ms: String(Date.now() + 60_000) }, + ], + }), + ); + const result = await appleProvider.verify(request); + expect(result.active).toBe(false); + }); +}); + +describe('googleProvider', () => { + const request = { platform: 'android' as const, productId: 'semantika_yearly', receipt: 'token' }; + + it('exchanges a signed JWT for a token and checks expiry', async () => { + fetchMock + .mockResolvedValueOnce(jsonResponse({ access_token: 'oauth-token' })) + .mockResolvedValueOnce(jsonResponse({ expiryTimeMillis: String(Date.now() + 60_000) })); + + const result = await googleProvider.verify(request); + expect(result.active).toBe(true); + expect(fetchMock.mock.calls[0]?.[0]).toBe('https://oauth2.googleapis.com/token'); + expect(fetchMock.mock.calls[1]?.[0]).toContain( + '/purchases/subscriptions/semantika_yearly/tokens/token', + ); + expect(fetchMock.mock.calls[1]?.[1]?.headers?.Authorization).toBe('Bearer oauth-token'); + }); + + it('treats a gone purchase (410) as inactive', async () => { + fetchMock + .mockResolvedValueOnce(jsonResponse({ access_token: 'oauth-token' })) + .mockResolvedValueOnce(jsonResponse({}, 410)); + const result = await googleProvider.verify(request); + expect(result.active).toBe(false); + }); + + it('marks an expired purchase inactive', async () => { + fetchMock + .mockResolvedValueOnce(jsonResponse({ access_token: 'oauth-token' })) + .mockResolvedValueOnce(jsonResponse({ expiryTimeMillis: String(Date.now() - 60_000) })); + const result = await googleProvider.verify(request); + expect(result.active).toBe(false); + }); +});