Skip to content

Commit 41b5199

Browse files
Let Plus devices share a short restore code or QR
Stripe receipt links never include a checkout session id, so "Already paid?" could not unlock a second device. Mint a 30-minute code on the subscribed device (About → Use Plus on another device) and redeem it by typing the code or scanning the QR. Checkout now returns in the same tab so the redirect lands in the same browser profile. Co-authored-by: me <me@kentcdodds.com>
1 parent f74e3c0 commit 41b5199

21 files changed

Lines changed: 810 additions & 147 deletions

README.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -254,8 +254,11 @@ Stripe redirects back to `/unlocked?session_id=…`, and a single Cloudflare
254254
Pages Function (`functions/api/verify-purchase.ts`) verifies the session
255255
server-side before the entitlement is stored in IndexedDB. 100%-off
256256
promotion codes (friends / the developer) flow through the exact same
257-
verification. Restore on another device: "Already paid?" on the export
258-
sheet or a locked slot's upsell.
257+
verification. Restore on another device: on the phone or computer that
258+
already has Plus, open About and tap "Use Plus on another
259+
device" — type the short code or scan the QR. "Already paid?"
260+
on the export sheet still accepts that code (or a checkout
261+
session id).
259262

260263
Projects are also created lazily — "New project" opens the camera at
261264
`/project/new` and nothing is persisted until the first clip is recorded, so

functions/api/restore-codes.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
/**
2+
* Cloudflare Pages Function: mint a short-lived restore code for a verified
3+
* Plus checkout session. The subscribed device shows the code / QR; the new
4+
* device redeems it via /api/verify-purchase?code=.
5+
*/
6+
7+
import { handleRestoreCodesRequest, type PurchaseHttpEnv } from '../../src/lib/purchase-http'
8+
9+
interface PagesContext {
10+
request: Request
11+
env: PurchaseHttpEnv
12+
}
13+
14+
export async function onRequestPost(context: PagesContext): Promise<Response> {
15+
return handleRestoreCodesRequest(context.request, context.env)
16+
}

functions/api/verify-purchase.ts

Lines changed: 5 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -1,78 +1,16 @@
11
/**
22
* Cloudflare Pages Function: verify a Stripe Checkout session for the
3-
* Kody Video Plus purchase. The only backend surface in the app — it
4-
* never stores anything and never sees media; it just asks Stripe whether
5-
* the session really completed (including 100%-off promo checkouts).
6-
*
7-
* Requires the STRIPE_SECRET_KEY environment variable on the Pages project
8-
* (a restricted key with Checkout Sessions read access is enough).
3+
* Kody Video Plus purchase. Accepts a checkout session id or a short
4+
* restore code minted by the device that already has Plus.
95
*/
106

11-
interface Env {
12-
STRIPE_SECRET_KEY?: string
13-
/** Optional override; defaults to the production payment link. */
14-
STRIPE_PAYMENT_LINK_ID?: string
15-
}
7+
import { handleVerifyPurchaseRequest, type PurchaseHttpEnv } from '../../src/lib/purchase-http'
168

179
interface PagesContext {
1810
request: Request
19-
env: Env
20-
}
21-
22-
const PRODUCTION_PAYMENT_LINK_ID = 'plink_1TxcxULAQpAnsYszr2bLuqOl'
23-
const SESSION_ID_PATTERN = /^cs_[a-zA-Z0-9_]+$/
24-
25-
function json(body: unknown, status: number): Response {
26-
return new Response(JSON.stringify(body), {
27-
status,
28-
headers: {
29-
'content-type': 'application/json',
30-
'cache-control': 'no-store',
31-
},
32-
})
11+
env: PurchaseHttpEnv
3312
}
3413

3514
export async function onRequestGet(context: PagesContext): Promise<Response> {
36-
const { request, env } = context
37-
const secretKey = env.STRIPE_SECRET_KEY
38-
if (!secretKey) {
39-
return json({ unlocked: false, error: 'Purchase verification is not configured yet.' }, 503)
40-
}
41-
42-
const sessionId = new URL(request.url).searchParams.get('session_id') ?? ''
43-
if (!SESSION_ID_PATTERN.test(sessionId)) {
44-
return json({ unlocked: false, error: 'Invalid session id.' }, 400)
45-
}
46-
47-
const response = await fetch(
48-
`https://api.stripe.com/v1/checkout/sessions/${encodeURIComponent(sessionId)}`,
49-
{ headers: { authorization: `Bearer ${secretKey}` } },
50-
)
51-
if (response.status === 404) {
52-
return json({ unlocked: false, error: 'No such purchase.' }, 404)
53-
}
54-
if (!response.ok) {
55-
return json({ unlocked: false, error: 'Could not reach Stripe. Try again shortly.' }, 502)
56-
}
57-
58-
const session = (await response.json()) as {
59-
status?: string
60-
payment_status?: string
61-
payment_link?: string
62-
}
63-
64-
const expectedLink = env.STRIPE_PAYMENT_LINK_ID ?? PRODUCTION_PAYMENT_LINK_ID
65-
const complete = session.status === 'complete'
66-
// 100%-off promotion-code checkouts complete with 'no_payment_required'.
67-
const paid =
68-
session.payment_status === 'paid' || session.payment_status === 'no_payment_required'
69-
const rightProduct = session.payment_link === expectedLink
70-
71-
if (complete && paid && rightProduct) {
72-
return json({ unlocked: true }, 200)
73-
}
74-
return json(
75-
{ unlocked: false, error: 'This checkout session is not a completed watermark purchase.' },
76-
403,
77-
)
15+
return handleVerifyPurchaseRequest(context.request, context.env)
7816
}

functions/lib/agent-markdown.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ Photos can be added to the timeline as still clips. Desktop can record a screen
4444
4545
## Kody Video Plus
4646
47-
A one-time $0.99 Stripe Payment Link. It removes the export watermark and unlocks six project slots, background music, landscape projects, optional location tagging, and send-to-another-device. Restore with **Already paid?** using the Stripe receipt — there is no login.
47+
A one-time $0.99 Stripe Payment Link. It removes the export watermark and unlocks six project slots, background music, landscape projects, optional location tagging, and send-to-another-device. Restore from the device that already has Plus (About → Use Plus on another device) with a short code or QR — there is no login.
4848
4949
## Support
5050
@@ -81,6 +81,7 @@ The app's only own network traffic:
8181
- Cookieless Fathom page-view counts
8282
- The home-screen tour video from \`media.kody.video\` if you tap play
8383
- A short-lived \`/api/sync\` matchmaking room if you tap Send to device (code + WebRTC descriptions, never clips)
84+
- A short-lived Plus restore code if you share Plus with another device (session id only)
8485
8586
## Made for phones
8687
@@ -136,7 +137,7 @@ Location tagging is an optional Plus feature and is off by default. When it is o
136137
137138
## Watermark removal purchase
138139
139-
The one-time Plus purchase is processed by Stripe on Stripe's pages — their privacy policy applies. The app's verification endpoint sees only the checkout session id, never your media or location.
140+
The one-time Plus purchase is processed by Stripe on Stripe's pages — their privacy policy applies. The app's verification endpoint sees only the checkout session id, never your media or location. Sharing Plus with another device mints a short-lived restore code that maps to that same session id and expires in minutes.
140141
141142
## Exports and sharing
142143
@@ -171,7 +172,7 @@ You own your recordings entirely. The app claims no rights to any of your conten
171172
172173
## Kody Video Plus
173174
174-
Kody Video Plus is a one-time $0.99 purchase that unlocks watermark-free exports, optional location tagging, sending a project to another device, and up to six project slots (the free plan includes one project) for the browser profile where it is verified. You can restore the purchase on another device via the Stripe receipt link. Payments are handled by Stripe. For refunds or purchase trouble, email [team@kody.video](mailto:team@kody.video).
175+
Kody Video Plus is a one-time $0.99 purchase that unlocks watermark-free exports, optional location tagging, sending a project to another device, and up to six project slots (the free plan includes one project) for the browser profile where it is verified. You can restore the purchase on another device with a short code or QR from the device that already has Plus. Payments are handled by Stripe. For refunds or purchase trouble, email [team@kody.video](mailto:team@kody.video).
175176
176177
## Recording responsibly
177178
@@ -219,11 +220,13 @@ Kody Video has **no accounts**.
219220
220221
Plus is a one-time $0.99 [Stripe Payment Link](https://buy.stripe.com/00wfZi71ibU30rk9hU2Ry07). After checkout, Stripe redirects to \`/unlocked?session_id=<CHECKOUT_SESSION_ID>\`. \`/api/verify-purchase\` checks that session server-side; the entitlement is then stored on the device. 100%-off promotion codes use the same verification.
221222
222-
Restore on another device with **Already paid?** (export sheet or a locked slot). That is the same Stripe session, not a password.
223+
Restore on another device from the device that already has Plus: About → **Use Plus on another device** shows a short code and QR. The new device types the code under **Already paid?** or opens \`/unlocked?code=\`. That mints a short-lived mapping to the same Stripe sessionnot a password. A checkout session id still works if you have one. Stripe receipt URLs do not include that session id.
223224
224225
## Other network calls
225226
226227
- \`/api/sync\` — short-lived send-to-device matchmaking (room code + WebRTC descriptions). Never media.
228+
- \`/api/restore-codes\` — short-lived Plus restore codes (session id only, 30 minutes).
229+
- \`/api/verify-purchase\` — Stripe session or restore-code check. Never media.
227230
- \`/api/diag\` and \`/api/recover\` — on-device shell repair. Recover never touches IndexedDB.
228231
229232
See [Privacy](/privacy) and [About](/about).

public/auth.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,13 @@ Kody Video has **no accounts**.
1717

1818
Plus is a one-time $0.99 [Stripe Payment Link](https://buy.stripe.com/00wfZi71ibU30rk9hU2Ry07). After checkout, Stripe redirects to `/unlocked?session_id=<CHECKOUT_SESSION_ID>`. `/api/verify-purchase` checks that session server-side; the entitlement is then stored on the device. 100%-off promotion codes use the same verification.
1919

20-
Restore on another device with **Already paid?** (export sheet or a locked slot). That is the same Stripe session, not a password.
20+
Restore on another device from the device that already has Plus: About → **Use Plus on another device** shows a short code and QR. The new device types the code under **Already paid?** or opens `/unlocked?code=`. That mints a short-lived mapping to the same Stripe sessionnot a password. A checkout session id still works if you have one. Stripe receipt URLs do not include that session id.
2121

2222
## Other network calls
2323

2424
- `/api/sync` — short-lived send-to-device matchmaking (room code + WebRTC descriptions). Never media.
25+
- `/api/restore-codes` — short-lived Plus restore codes (session id only, 30 minutes).
26+
- `/api/verify-purchase` — Stripe session or restore-code check. Never media.
2527
- `/api/diag` and `/api/recover` — on-device shell repair. Recover never touches IndexedDB.
2628

2729
See [Privacy](/privacy) and [About](/about).

scripts/vite-sync-api-plugin.ts

Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import type { IncomingMessage, ServerResponse } from 'node:http'
22
import type { Plugin } from 'vite'
3-
import { handleSyncRequest, memorySyncKv } from '../src/lib/sync-rooms'
3+
import { createRestoreCode, getRestoreSessionId } from '../src/lib/restore-codes'
4+
import { jsonResponse, handleSyncRequest, memorySyncKv, SyncRoomError } from '../src/lib/sync-rooms'
45

56
const kv = memorySyncKv()
7+
const LOCAL_TEST_SESSION = /^cs_test_[a-zA-Z0-9]+$/
68

79
async function nodeToRequest(req: IncomingMessage): Promise<Request> {
810
const host = req.headers.host ?? '127.0.0.1'
@@ -34,18 +36,63 @@ async function writeResponse(res: ServerResponse, response: Response): Promise<v
3436
res.end(bytes)
3537
}
3638

37-
/** In-memory /api/sync/* so Vite and Playwright can exercise send-to-device. */
39+
/** Local-only: accept cs_test_* sessions so Playwright can pair devices without Stripe. */
40+
async function handleLocalPurchase(request: Request): Promise<Response> {
41+
const url = new URL(request.url)
42+
try {
43+
if (url.pathname === '/api/restore-codes') {
44+
if (request.method !== 'POST') return jsonResponse({ error: 'Method not allowed.' }, 405)
45+
const posted = (await request.json().catch(() => null)) as { session_id?: unknown } | null
46+
const sessionId = typeof posted?.session_id === 'string' ? posted.session_id : ''
47+
if (!LOCAL_TEST_SESSION.test(sessionId)) {
48+
return jsonResponse({ error: 'Local restore codes only accept cs_test_ sessions.' }, 400)
49+
}
50+
const code = await createRestoreCode(kv, sessionId, 'local')
51+
return jsonResponse({ code }, 201)
52+
}
53+
54+
if (url.pathname === '/api/verify-purchase') {
55+
if (request.method !== 'GET') return jsonResponse({ unlocked: false, error: 'Method not allowed.' }, 405)
56+
let sessionId = url.searchParams.get('session_id') ?? ''
57+
const code = url.searchParams.get('code') ?? ''
58+
if (code) sessionId = await getRestoreSessionId(kv, code)
59+
if (!LOCAL_TEST_SESSION.test(sessionId)) {
60+
return jsonResponse({ unlocked: false, error: 'No such purchase.' }, 404)
61+
}
62+
return jsonResponse({ unlocked: true, sessionId }, 200)
63+
}
64+
} catch (error) {
65+
if (error instanceof SyncRoomError) {
66+
const payload =
67+
url.pathname === '/api/verify-purchase'
68+
? { unlocked: false, error: error.message }
69+
: { error: error.message }
70+
return jsonResponse(payload, error.status)
71+
}
72+
throw error
73+
}
74+
return jsonResponse({ error: 'Not found.' }, 404)
75+
}
76+
77+
/** In-memory /api/sync/* and Plus restore-code endpoints for Vite and Playwright. */
3878
export function syncApiPlugin(): Plugin {
3979
return {
4080
name: 'kody-sync-api',
4181
configureServer(server) {
4282
server.middlewares.use((req, res, next) => {
43-
if (!req.url?.startsWith('/api/sync/')) {
83+
const path = req.url?.split('?')[0] ?? ''
84+
const purchase =
85+
path === '/api/restore-codes' || path === '/api/verify-purchase'
86+
if (!path.startsWith('/api/sync/') && !purchase) {
4487
next()
4588
return
4689
}
4790
void nodeToRequest(req)
48-
.then((request) => handleSyncRequest(request, { SYNC_ROOMS: kv }))
91+
.then((request) =>
92+
purchase
93+
? handleLocalPurchase(request)
94+
: handleSyncRequest(request, { SYNC_ROOMS: kv }),
95+
)
4996
.then((response) => writeResponse(res, response))
5097
.catch((error: unknown) => {
5198
res.statusCode = 500

src/components/restore-sheet.tsx

Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,40 @@
11
import type { Handle } from 'remix/ui'
22
import { on, ref } from 'remix/ui'
33
import { attachSheetModal } from '../lib/sheet-modal'
4-
import { extractSessionId, verifyPurchaseSession } from '../lib/entitlement'
4+
import {
5+
extractRestoreToken,
6+
looksLikeStripeReceipt,
7+
verifyPurchase,
8+
} from '../lib/entitlement'
59

610
interface RestoreSheetProps {
711
onRestored: () => void
812
onClose: () => void
913
}
1014

1115
/**
12-
* Restore the Kody Video Plus purchase on a new device: paste the link
13-
* from the Stripe receipt email (or the checkout session id) and re-verify.
16+
* Restore Kody Video Plus on a new device: paste a short code from the
17+
* device that already has Plus, scan its QR (opens /unlocked?code=), or
18+
* paste a checkout session id.
1419
*/
1520
export function RestoreSheet(handle: Handle<RestoreSheetProps>) {
1621
let value = ''
1722
let busy = false
1823
let error: string | null = null
1924

2025
const restore = async () => {
21-
const sessionId = extractSessionId(value)
22-
if (!sessionId) return
26+
const token = extractRestoreToken(value)
27+
if (!token) {
28+
error = looksLikeStripeReceipt(value)
29+
? 'Stripe receipt links do not include a restore handle. On the device that already has Plus, open About and tap Use Plus on another device.'
30+
: 'Enter the short code from the other device, or a checkout session id starting with “cs_”.'
31+
void handle.update()
32+
return
33+
}
2334
busy = true
2435
error = null
2536
void handle.update()
26-
const result = await verifyPurchaseSession(sessionId)
37+
const result = await verifyPurchase(token)
2738
busy = false
2839
if (result.unlocked) {
2940
handle.props.onRestored()
@@ -58,16 +69,21 @@ export function RestoreSheet(handle: Handle<RestoreSheetProps>) {
5869
>
5970
<h3>Restore purchase</h3>
6071
<p className="muted sheet-lede">
61-
Paste the confirmation link from your Stripe receipt email (or the checkout session id
62-
starting with “cs_”). We’ll verify it and remove the watermark on this device.
72+
On the device that already has Plus, open About and tap{' '}
73+
<strong>Use Plus on another device</strong>. Type that short code here, or scan its QR.
74+
A checkout session id starting with “cs_” still works too.
6375
</p>
6476
<div className="field">
65-
<label htmlFor="restore-input">Receipt link or session id</label>
77+
<label htmlFor="restore-input">Restore code or session id</label>
6678
<input
6779
id="restore-input"
6880
type="text"
81+
inputMode="text"
82+
autoCapitalize="characters"
83+
autoCorrect="off"
84+
spellCheck={false}
6985
value={value}
70-
placeholder="https://… or cs_live_…"
86+
placeholder="ABC-234 or cs_live_…"
7187
mix={[
7288
ref((node) => (node as HTMLInputElement).focus()),
7389
on('input', (event) => {
@@ -90,7 +106,7 @@ export function RestoreSheet(handle: Handle<RestoreSheetProps>) {
90106
<button
91107
type="button"
92108
className="btn btn-primary"
93-
disabled={busy || !extractSessionId(value)}
109+
disabled={busy || !extractRestoreToken(value)}
94110
mix={on('click', () => void restore())}
95111
>
96112
{busy ? 'Verifying…' : 'Restore'}

0 commit comments

Comments
 (0)