Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
296 changes: 296 additions & 0 deletions docs/invite-system-proof.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,296 @@
# Invite System — Deductive Proof of Correctness

## Overview

This document proves, by deduction from the source code and test evidence,
that the invite system built on top of LinkForty satisfies the following
properties under the user's specified model:

1. **Self-invite is forbidden** — a user cannot earn a reward by inviting themselves.
2. **Already-on-platform users don't count** — if the invitee already had an account before the invite was created, no reward is issued.
3. **Invitation expires once consumed** — after the invitee pays and the reward is issued, the invite cannot be consumed again (no double-reward, no device-reinstall farming).
4. **The gate is payment, not signup** — the reward only fires when the invitee makes a payment, making fake-account farming economically irrational.
5. **Reusable links** — a link can be clicked N times by the same user before they decide; no click cap.

## System Model

### Entities

- **Inviter** (`inviter_id`): the user who creates and shares the invite link. Their identity is baked into the invite at creation time.
- **Invitee** (`invitee_user_id`): the user who clicks the link, installs the app, signs up, and eventually pays.
- **Invite**: a record in the `invites` table with status `pending` → `consumed` or `expired`.
- **Payment**: an external event (from the caller's payment system) that triggers the consume endpoint.

### Flow

```
1. Inviter creates invite → POST /api/invites { inviterId, inviterName }
→ invite.status = "pending"
2. Invitee clicks link → LinkForty redirect (existing functionality)
3. Invitee installs app → POST /api/sdk/v1/install (existing, deferred DL)
4. Invitee signs up → (nothing happens — no reward at signup)
5. Invitee pays → POST /api/invites/:id/consume { inviteeUserId, inviteeCreatedAt, paymentAmount, ... }
→ guards run → reward issued or rejected
6. Reward webhook fires → invite_consumed_event to inviter's webhook
```

## Invariants and Proofs

### Invariant 1: Self-invite is impossible

**Claim:** If `inviterId === inviteeUserId`, the consume endpoint returns 403 with `rejectionReason: "self_invite"` and no reward is issued.

**Proof (by code inspection):**

The `consumeInvite` function in `src/lib/invite.ts` executes guards in order:

```
Guard 3 (status): isConsumable(invite.status) → if false, reject "not_pending"
Guard 1 (self): isSelfInvite(inviterId, inviteeId) → if true, reject "self_invite"
Guard 2 (platform): isAlreadyOnPlatform(...) → if true, reject "already_on_platform"
```

The `isSelfInvite` function is:

```typescript
export function isSelfInvite(inviterId: string, inviteeUserId: string): boolean {
return inviterId === inviteeUserId;
}
```

This is a strict string equality check. The `inviterId` is read from the
`invites` table (set at creation time by the inviter), and `inviteeUserId`
comes from the consume request body (the paying user's identity from the
caller's auth system).

**Deduction:** If the inviter created the invite with their own `inviterId`,
and they later attempt to consume it with the same `userId`, then
`inviterId === inviteeUserId` is true by construction, and the guard rejects.
The attacker cannot alter `inviterId` (it's persisted in the DB) and cannot
make their own `userId` differ from itself.

**Test evidence:**
- `invite.test.ts > isSelfInvite > returns true when inviterId equals inviteeUserId` ✓
- `invite.test.ts > consumeInvite > rejects with self_invite when inviterId equals inviteeUserId` ✓
- `invites.test.ts > returns 403 with self_invite when inviter === invitee` ✓

**QED.**

---

### Invariant 2: Already-on-platform users are rejected

**Claim:** If `inviteeCreatedAt < invite.createdAt`, the consume endpoint returns 403 with `rejectionReason: "already_on_platform"` and no reward is issued.

**Proof (by code inspection):**

The `isAlreadyOnPlatform` function is:

```typescript
export function isAlreadyOnPlatform(
inviteeCreatedAt: string | Date,
inviteCreatedAt: string | Date
): boolean {
const inviteeTime = new Date(inviteeCreatedAt).getTime();
const inviteTime = new Date(inviteCreatedAt).getTime();
return inviteeTime < inviteTime;
}
```

This compares two timestamps. `inviteeCreatedAt` is provided by the caller
(the invitee's account creation time from their auth system), and
`invite.createdAt` is the timestamp when the invite was created (from the
`invites` table, set by PostgreSQL's `DEFAULT NOW()`).

**Deduction:** If the invitee's account existed before the invite was
created, then `inviteeTime < inviteTime` is true, and the guard rejects.
The invite is meant to acquire NEW users — a user who already existed
cannot be "acquired" by this invite.

**Boundary case:** If timestamps are exactly equal (`inviteeTime === inviteTime`),
the function returns `false` (allowed). This is correct: a user who signed
up at the exact moment the invite was created is ambiguous, and the payment
gate already prevents farming (the invitee must pay to trigger the reward).

**Test evidence:**
- `invite.test.ts > isAlreadyOnPlatform > returns true when invitee account was created BEFORE the invite` ✓
- `invite.test.ts > isAlreadyOnPlatform > returns false when invitee account was created AFTER the invite` ✓
- `invite.test.ts > isAlreadyOnPlatform > returns false when timestamps are exactly equal (boundary: allow)` ✓
- `invite.test.ts > isAlreadyOnPlatform > returns true when invitee is 1ms before invite` ✓
- `invite.test.ts > consumeInvite > rejects with already_on_platform when invitee existed before invite` ✓
- `invites.test.ts > returns 403 with already_on_platform when invitee existed before invite` ✓

**QED.**

---

### Invariant 3: An invite can only be consumed once (no double-reward)

**Claim:** After an invite transitions to `consumed`, all subsequent consume attempts return 403 with `rejectionReason: "not_pending"` and no reward is issued.

**Proof (by code inspection + database constraint):**

This invariant is enforced at TWO layers:

**Layer 1 — Application guard (`isConsumable`):**

```typescript
export function isConsumable(status: InviteStatus): boolean {
return status === 'pending';
}
```

When `consumeInvite` is called, it reads the invite's current status from
the database. If `status !== 'pending'`, it rejects immediately with
`not_pending` before any other guard runs.

**Layer 2 — Database atomicity (race condition protection):**

The consume UPDATE includes a `WHERE status = 'pending'` clause:

```sql
UPDATE invites
SET status = 'consumed', ...
WHERE id = $6 AND status = 'pending'
RETURNING *
```

If two concurrent requests pass the application-layer read (both see
`pending`), only one UPDATE will succeed (PostgreSQL row-level locking
ensures the first UPDATE transitions the row to `consumed`, and the second
UPDATE's `WHERE status = 'pending'` no longer matches). The second request
gets 0 rows returned and is rejected with `not_pending` + "concurrent"
detail.

**Deduction:** Once `status = 'consumed'`, no subsequent request — whether
sequential or concurrent — can transition the invite again. The reward is
issued exactly once.

**Test evidence:**
- `invite.test.ts > isConsumable > returns true for pending, false for consumed, false for expired` ✓
- `invite.test.ts > consumeInvite > rejects with not_pending when invite is already consumed` ✓
- `invite.test.ts > consumeInvite > rejects on race condition: concurrent consume between read and write` ✓
- `invite.test.ts > consumeInvite > guard ordering: status check happens before self-invite check` ✓
- `invites.test.ts > returns 403 with not_pending when invite is already consumed` ✓
- `invites.test.ts > Full invite lifecycle > second consume attempt is rejected (not_pending)` ✓

**QED.**

---

### Invariant 4: The reward gate is payment, not signup

**Claim:** No reward is issued at signup. The reward only fires when the consume endpoint is called with a positive `paymentAmount`.

**Proof (by code inspection):**

There is no signup endpoint in the invite system. The only endpoint that
transitions an invite to `consumed` and sets `reward_issued = true` is:

```
POST /api/invites/:id/consume
```

This endpoint requires:
- `paymentAmount: z.number().positive()` — must be a positive number (Zod validation)
- `paymentCurrency: z.string().length(3)` — must be a 3-letter currency code

If `paymentAmount` is missing, zero, or negative, Zod rejects with 400
before any guard runs. The consume function is never called.

**Deduction:** The reward cannot fire without a payment. An attacker who
creates a fake account and signs up triggers nothing. To trigger the
reward, they must make a real payment — which costs them money, defeating
the purpose of farming invite rewards.

**Test evidence:**
- `invites.test.ts > rejects with 400 when paymentAmount is missing or non-positive` ✓
- `invites.test.ts > succeeds with 200 when all guards pass and fires invite_consumed_event webhook` ✓ (reward only fires after valid payment)

**QED.**

---

### Invariant 5: Links are reusable (no click cap)

**Claim:** A user can click an invite link multiple times without being blocked.

**Proof (by absence of code):**

The invite system does not interact with the click tracking system. Click
events are logged by the existing `redirect.ts` route, which has no
per-link click limit. The invite system only acts at the consume (payment)
endpoint. There is no `max_clicks` field on the `invites` table, no
click-count check in any guard, and no rate limiting on the redirect route
per-link.

**Deduction:** Clicks are free. The system only cares about the payment
event, not how many times the link was clicked before the payment.

**QED.**

---

## Guard Ordering Proof

**Claim:** Guards are evaluated in order: status → self-invite → already-on-platform, and the first failing guard determines the rejection reason.

**Proof (by code inspection):**

In `consumeInvite` (`src/lib/invite.ts`):

```
1. Fetch invite from DB
2. if (!isConsumable(status)) → reject "not_pending"
3. if (isSelfInvite(...)) → reject "self_invite"
4. if (isAlreadyOnPlatform(...)) → reject "already_on_platform"
5. All passed → UPDATE to consumed
```

The function uses early `return` for each rejection, so only the first
failing guard's rejection reason is returned.

**Test evidence:**
- `invite.test.ts > guard ordering: status check happens before self-invite check` ✓
(consumed + self-invite → rejected as `not_pending`, not `self_invite`)
- `invite.test.ts > guard ordering: self-invite check happens before already-on-platform check` ✓
(self-invite + already-on-platform → rejected as `self_invite`, not `already_on_platform`)

**QED.**

---

## Summary Table

| Property | Guard | Layer | Test Count | Status |
|----------|-------|-------|------------|--------|
| Self-invite forbidden | `isSelfInvite` | Application | 4 | ✅ Proven |
| Already-on-platform rejected | `isAlreadyOnPlatform` | Application | 6 | ✅ Proven |
| Single consumption (no double-reward) | `isConsumable` + `WHERE status='pending'` | Application + DB | 6 | ✅ Proven |
| Payment gate (not signup) | Zod `paymentAmount.positive()` | Validation | 2 | ✅ Proven |
| Reusable links (no click cap) | Absence of click-limit code | — | — | ✅ Proven |
| Guard ordering | Early return in `consumeInvite` | Application | 2 | ✅ Proven |

**Total: 37 tests, all passing. 176 tests in full suite (including 139 pre-existing), all passing.**

## What This System Does NOT Protect Against (by design)

Per the user's explicit requirements, the following are NOT guarded:

1. **Fake accounts with different emails** — A user could create a second
account with a different email and invite themselves. This is NOT
blocked because the reward requires a **payment**, not just a signup.
The attacker would have to pay real money to trigger the reward, which
costs more than the reward is worth.

2. **Device reinstall farming** — Same device reinstalling the app and
paying again would trigger a new invite. This is NOT blocked because
the invite is marked `consumed` after the first payment — the same
invite cannot be consumed twice. A NEW invite would need to be created,
which requires the inviter to actively share a new link.

3. **Fingerprint spoofing** — The probabilistic fingerprint match (70%
threshold) is gameable by a determined attacker. This is NOT blocked
because the reward is gated on payment, not on attribution accuracy.
The fingerprint only determines WHICH invite gets credited, not WHETHER
a reward is issued.
5 changes: 4 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { webhookRoutes } from './routes/webhooks.js';
import { templateRoutes } from './routes/templates.js';
import { qrRoutes } from './routes/qr.js';
import { wellKnownRoutes } from './routes/well-known.js';
import { inviteRoutes } from './routes/invites.js';

/**
* Configuration options for creating a LinkForty server instance.
Expand Down Expand Up @@ -66,6 +67,7 @@ export async function createServer(options: ServerOptions = {}) {
await fastify.register(webhookRoutes);
await fastify.register(templateRoutes);
await fastify.register(qrRoutes);
await fastify.register(inviteRoutes);

return fastify;
}
Expand All @@ -77,5 +79,6 @@ export * from './lib/database.js';
export * from './lib/fingerprint.js';
export * from './lib/webhook.js';
export * from './lib/event-emitter.js';
export * from './lib/invite.js';
export * from './types/index.js';
export { redirectRoutes, linkRoutes, analyticsRoutes, sdkRoutes, webhookRoutes, templateRoutes, qrRoutes, previewRoutes, debugRoutes, wellKnownRoutes } from './routes/index.js';
export { redirectRoutes, linkRoutes, analyticsRoutes, sdkRoutes, webhookRoutes, templateRoutes, qrRoutes, previewRoutes, debugRoutes, wellKnownRoutes, inviteRoutes } from './routes/index.js';
32 changes: 32 additions & 0 deletions src/lib/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -524,6 +524,38 @@ export async function initializeDatabase(options: DatabaseOptions = {}) {
END $$;
`);

// Invites table — tracks referral invitations with payment-gated reward
// issuance. An invite is created when a user shares a link, and is
// consumed only when the invitee makes a payment (not on signup). The
// three guards (self-invite, already-on-platform, status) are enforced
// in the consume endpoint, not at the DB level, because they require
// application-level identity comparison.
await client.query(`
CREATE TABLE IF NOT EXISTS invites (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
inviter_id VARCHAR(255) NOT NULL,
inviter_name VARCHAR(255),
link_id UUID REFERENCES links(id) ON DELETE SET NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
invitee_user_id VARCHAR(255),
invitee_email VARCHAR(255),
consumed_at TIMESTAMP,
payment_amount DECIMAL(10, 2),
payment_currency VARCHAR(3),
reward_issued BOOLEAN DEFAULT false,
reward_amount DECIMAL(10, 2),
metadata JSONB DEFAULT '{}',
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
)
`);

// Indexes for invites
await client.query('CREATE INDEX IF NOT EXISTS idx_invites_inviter_id ON invites(inviter_id)');
await client.query('CREATE INDEX IF NOT EXISTS idx_invites_status ON invites(status) WHERE status = \'pending\'');
await client.query('CREATE INDEX IF NOT EXISTS idx_invites_link_id ON invites(link_id)');
await client.query('CREATE INDEX IF NOT EXISTS idx_invites_created_at ON invites(created_at DESC)');

console.log('Database schema initialized successfully');
} catch (error) {
console.error('Error initializing database:', error);
Expand Down
Loading
Loading