Skip to content

Commit e5a465c

Browse files
ralyodioclaude
andauthored
feat: no limit on anonymous invite links (0.8.1) (#307)
Drops the 5-per-account quota on Settings > Invite Anonymously. The issuer's disabled flag still works as a kill switch, and every minted invite is still recorded in issued_invites. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 9f3257e commit e5a465c

5 files changed

Lines changed: 99 additions & 102 deletions

File tree

‎docs/INVITES.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -91,17 +91,17 @@ Go to **Settings > Invite Anonymously** and click **Create invite link**, then s
9191
- The link is `https://qrypt.chat/anon?invite=qci1....`, an Ed25519-signed token.
9292
- It works once and expires in 7 days. Through the API, you can set `ttlSeconds` anywhere from 1 hour to 30 days.
9393
- The link does not reveal who invited them.
94-
- Each account has a limited number of invites, 5 by default (`invite_issuers.default_quota`). The panel shows how many you have left.
94+
- There is no limit on how many invites an account can create. Setting `invite_issuers.disabled` for `qryptchat-web` turns minting off for everyone.
9595
- The server needs `QRYPT_WEB_ISSUER_SEED` to be set. Without it the endpoint returns 503.
9696

9797
The person opens the link, picks a username, and their browser generates their keys. They are then asked to set a 4–12 digit **Backup PIN**, which protects the key backup. To chat, start a conversation with their username as described in the next section.
9898

9999
API:
100100

101101
```sh
102-
curl -H "Authorization: Bearer $ACCESS_TOKEN" https://qrypt.chat/api/auth/invite-anon # quota
102+
curl -H "Authorization: Bearer $ACCESS_TOKEN" https://qrypt.chat/api/auth/invite-anon # how many you have minted
103103
curl -X POST -H "Authorization: Bearer $ACCESS_TOKEN" -H 'Content-Type: application/json' \
104-
-d '{"ttlSeconds":86400}' https://qrypt.chat/api/auth/invite-anon # -> { url, remaining }
104+
-d '{"ttlSeconds":86400}' https://qrypt.chat/api/auth/invite-anon # -> { url }
105105
```
106106

107107
## Chat with someone who already has an account

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "qryptchat-web",
33
"private": true,
4-
"version": "0.8.0",
4+
"version": "0.8.1",
55
"type": "module",
66
"bin": {
77
"qc": "./bin/qc.js",

‎src/app/api/auth/invite-anon/route.js‎

Lines changed: 17 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/**
22
* @fileoverview API endpoint that lets a signed-in account mint a single-use
33
* anonymous invite for someone else. Mirrors AgentBBS's issuer role, but the
4-
* issuer is the web app itself (`qryptchat-web`), gated by a per-account quota.
4+
* issuer is the web app itself (`qryptchat-web`). There is no per-account limit.
55
*
66
* Auth: `Authorization: Bearer <supabase access_token>` (anonymous or verified).
77
* The minted token is redeemed at /anon?invite=... via /api/auth/register-anon.
@@ -16,9 +16,6 @@ import { mintInviteToken } from '@/lib/invites/mint.js';
1616
/** Issuer id registered in `invite_issuers` for the web app. */
1717
const WEB_ISSUER_ID = 'qryptchat-web';
1818

19-
/** Fallback quota if the issuer row has none. */
20-
const DEFAULT_QUOTA = 5;
21-
2219
/**
2320
* Build a service-role Supabase client (bypasses RLS).
2421
* @returns {import('@supabase/supabase-js').SupabaseClient}
@@ -39,20 +36,21 @@ function getBearerToken(authHeader) {
3936
}
4037

4138
/**
42-
* GET /api/auth/invite-anon — report remaining quota for the caller.
39+
* GET /api/auth/invite-anon — report how many invites the caller has minted.
40+
* Invites are unlimited, so `remaining` and `quota` are null.
4341
* @param {import('next/server').NextRequest} request
4442
*/
4543
export async function GET(request) {
4644
const auth = await authenticate(request);
4745
if (auth.error) return auth.error;
4846
const { serviceSupabase, authUser } = auth;
4947

50-
const { quota, used, quotaError } = await getQuota(serviceSupabase, authUser.id);
48+
const { used, quotaError } = await getQuota(serviceSupabase, authUser.id);
5149
if (quotaError) {
5250
console.error('invite-anon: failed to verify invite quota', quotaError);
5351
return NextResponse.json({ error: 'Failed to verify invite quota' }, { status: 500 });
5452
}
55-
return NextResponse.json({ remaining: Math.max(0, quota - used), quota, used });
53+
return NextResponse.json({ remaining: null, quota: null, used });
5654
}
5755

5856
/**
@@ -88,21 +86,15 @@ export async function POST(request) {
8886
return NextResponse.json({ error: 'Complete registration before inviting' }, { status: 403 });
8987
}
9088

91-
// --- Enforce the per-account quota ---
92-
const { quota, used, issuerDisabled, quotaError } = await getQuota(serviceSupabase, authUser.id);
93-
if (quotaError) {
94-
console.error('invite-anon: failed to verify invite quota', quotaError);
95-
return NextResponse.json({ error: 'Failed to verify invite quota' }, { status: 500 });
96-
}
97-
if (issuerDisabled) {
89+
// --- The issuer can still be switched off as a kill switch ---
90+
const { data: issuer } = await serviceSupabase
91+
.from('invite_issuers')
92+
.select('disabled')
93+
.eq('id', WEB_ISSUER_ID)
94+
.single();
95+
if (issuer?.disabled) {
9896
return NextResponse.json({ error: 'Anonymous invites are disabled' }, { status: 503 });
9997
}
100-
if (used >= quota) {
101-
return NextResponse.json(
102-
{ error: 'Invite limit reached', code: 'quota_exceeded', quota, used },
103-
{ status: 429 }
104-
);
105-
}
10698

10799
// --- Optional caller-supplied ttl, clamped to [1h, 30d] ---
108100
let ttlSeconds = 7 * 24 * 60 * 60;
@@ -115,7 +107,7 @@ export async function POST(request) {
115107
// no body — use default ttl
116108
}
117109

118-
// --- Mint and record the invite (record burns a quota slot) ---
110+
// --- Mint and record the invite ---
119111
const { token, payload } = mintInviteToken({ seed, iss: WEB_ISSUER_ID, ttlSeconds, uses: 1 });
120112

121113
const { error: insertError } = await serviceSupabase.from('issued_invites').insert({
@@ -136,7 +128,7 @@ export async function POST(request) {
136128
success: true,
137129
token,
138130
url,
139-
remaining: Math.max(0, quota - used - 1)
131+
remaining: null
140132
});
141133
} catch (error) {
142134
console.error('invite-anon error:', error);
@@ -168,24 +160,16 @@ async function authenticate(request) {
168160
}
169161

170162
/**
171-
* Resolve the caller's quota and current usage.
163+
* Count the invites the caller has minted.
172164
* @param {any} serviceSupabase
173165
* @param {string} authUserId
174-
* @returns {Promise<{ quota: number, used: number, issuerDisabled: boolean, quotaError: any }>}
166+
* @returns {Promise<{ used: number, quotaError: any }>}
175167
*/
176168
async function getQuota(serviceSupabase, authUserId) {
177-
const { data: issuer } = await serviceSupabase
178-
.from('invite_issuers')
179-
.select('default_quota, disabled')
180-
.eq('id', WEB_ISSUER_ID)
181-
.single();
182-
183-
const quota = issuer?.default_quota ?? DEFAULT_QUOTA;
184-
185169
const { count, error: countError } = await serviceSupabase
186170
.from('issued_invites')
187171
.select('jti', { count: 'exact', head: true })
188172
.eq('issued_by', authUserId);
189173

190-
return { quota, used: count ?? 0, issuerDisabled: !!issuer?.disabled, quotaError: countError };
174+
return { used: count ?? 0, quotaError: countError };
191175
}

‎src/app/api/auth/invite-anon/route.test.js‎

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ describe('GET /api/auth/invite-anon authentication', () => {
9393
const body = await response.json();
9494

9595
expect(response.status).toBe(200);
96-
expect(body).toEqual({ remaining: 4, quota: 5, used: 1 });
96+
expect(body).toEqual({ remaining: null, quota: null, used: 1 });
9797
expect(mocks.authGetUser).toHaveBeenCalledWith('access-token-123');
9898
});
9999

@@ -137,3 +137,63 @@ describe('GET /api/auth/invite-anon authentication', () => {
137137
expect(body).toEqual({ error: 'Failed to verify invite quota' });
138138
});
139139
});
140+
141+
describe('POST /api/auth/invite-anon', () => {
142+
beforeEach(() => {
143+
vi.resetModules();
144+
vi.clearAllMocks();
145+
process.env.QRYPT_WEB_ISSUER_SEED = 'test-seed';
146+
147+
mocks.authGetUser.mockResolvedValue({
148+
data: { user: { id: 'auth-user-id' } },
149+
error: null
150+
});
151+
mocks.createSupabaseServerClient.mockResolvedValue({
152+
auth: { getUser: mocks.authGetUser }
153+
});
154+
mocks.createClient.mockReturnValue({ from: mocks.from });
155+
});
156+
157+
function postRequest() {
158+
return new Request('https://qrypt.chat/api/auth/invite-anon', {
159+
method: 'POST',
160+
headers: { authorization: 'Bearer access-token-123' }
161+
});
162+
}
163+
164+
function mockTables({ disabled = false } = {}) {
165+
const insert = vi.fn().mockResolvedValue({ error: null });
166+
mocks.from.mockImplementation((table) => {
167+
if (table === 'users') return createSingleQuery({ data: { id: 'user-id' }, error: null });
168+
if (table === 'invite_issuers') {
169+
return createSingleQuery({ data: { default_quota: 5, disabled }, error: null });
170+
}
171+
if (table === 'issued_invites') return { insert };
172+
throw new Error(`Unexpected table: ${table}`);
173+
});
174+
return insert;
175+
}
176+
177+
it('mints an invite with no per-account limit', async () => {
178+
const insert = mockTables();
179+
const { POST } = await import('./route.js');
180+
181+
const response = await POST(postRequest());
182+
const body = await response.json();
183+
184+
expect(response.status).toBe(200);
185+
expect(body).toMatchObject({ success: true, remaining: null });
186+
expect(body.url).toBe('https://qrypt.chat/anon?invite=qci1.test-token');
187+
expect(insert).toHaveBeenCalledOnce();
188+
});
189+
190+
it('still refuses when the issuer is disabled', async () => {
191+
const insert = mockTables({ disabled: true });
192+
const { POST } = await import('./route.js');
193+
194+
const response = await POST(postRequest());
195+
196+
expect(response.status).toBe(503);
197+
expect(insert).not.toHaveBeenCalled();
198+
});
199+
});

‎src/lib/components/settings/InviteAnon.jsx‎

Lines changed: 17 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,13 @@
22

33
/**
44
* @fileoverview Settings panel that lets a signed-in user mint single-use
5-
* anonymous invite links for other people, subject to a per-account quota.
5+
* anonymous invite links for other people. There is no limit.
66
*/
77

8-
import { useEffect, useState } from 'react';
8+
import { useState } from 'react';
99
import { useAuthStore } from '@/lib/stores/auth.js';
1010

1111
export default function InviteAnon() {
12-
const [remaining, setRemaining] = useState(null);
13-
const [quota, setQuota] = useState(null);
14-
const [loading, setLoading] = useState(true);
1512
const [minting, setMinting] = useState(false);
1613
const [link, setLink] = useState('');
1714
const [copied, setCopied] = useState(false);
@@ -23,27 +20,6 @@ export default function InviteAnon() {
2320
return { Authorization: `Bearer ${session.access_token}` };
2421
}
2522

26-
async function loadQuota() {
27-
setLoading(true);
28-
setError('');
29-
try {
30-
const headers = await authHeader();
31-
const res = await fetch('/api/auth/invite-anon', { headers });
32-
const data = await res.json().catch(() => ({}));
33-
if (!res.ok) throw new Error(data.error || 'Failed to load invite quota');
34-
setRemaining(data.remaining);
35-
setQuota(data.quota);
36-
} catch (e) {
37-
setError(e.message);
38-
} finally {
39-
setLoading(false);
40-
}
41-
}
42-
43-
useEffect(() => {
44-
loadQuota();
45-
}, []);
46-
4723
async function createInvite() {
4824
setMinting(true);
4925
setError('');
@@ -57,15 +33,8 @@ export default function InviteAnon() {
5733
body: JSON.stringify({})
5834
});
5935
const data = await res.json().catch(() => ({}));
60-
if (!res.ok || !data.success) {
61-
if (data.code === 'quota_exceeded') {
62-
setRemaining(0);
63-
throw new Error("You've used all your invites.");
64-
}
65-
throw new Error(data.error || 'Failed to create invite');
66-
}
36+
if (!res.ok || !data.success) throw new Error(data.error || 'Failed to create invite');
6737
setLink(data.url);
68-
setRemaining(data.remaining);
6938
} catch (e) {
7039
setError(e.message);
7140
} finally {
@@ -83,8 +52,6 @@ export default function InviteAnon() {
8352
}
8453
}
8554

86-
const outOfInvites = remaining === 0;
87-
8855
return (
8956
<div className="invite-anon">
9057
<p className="invite-desc">
@@ -93,43 +60,29 @@ export default function InviteAnon() {
9360
them.
9461
</p>
9562

96-
{loading ? (
97-
<p className="invite-muted">Loading…</p>
98-
) : (
99-
<>
100-
{remaining !== null && (
101-
<p className="invite-quota">
102-
{remaining} of {quota} invites remaining
103-
</p>
104-
)}
63+
<button
64+
type="button"
65+
className="invite-button"
66+
onClick={createInvite}
67+
disabled={minting}
68+
>
69+
{minting ? 'Creating…' : 'Create invite link'}
70+
</button>
10571

106-
<button
107-
type="button"
108-
className="invite-button"
109-
onClick={createInvite}
110-
disabled={minting || outOfInvites}
111-
>
112-
{minting ? 'Creating…' : outOfInvites ? 'No invites left' : 'Create invite link'}
72+
{link && (
73+
<div className="invite-result">
74+
<input className="invite-link" type="text" readOnly value={link} onFocus={(e) => e.target.select()} />
75+
<button type="button" className="invite-copy" onClick={copyLink}>
76+
{copied ? 'Copied!' : 'Copy'}
11377
</button>
114-
115-
{link && (
116-
<div className="invite-result">
117-
<input className="invite-link" type="text" readOnly value={link} onFocus={(e) => e.target.select()} />
118-
<button type="button" className="invite-copy" onClick={copyLink}>
119-
{copied ? 'Copied!' : 'Copy'}
120-
</button>
121-
</div>
122-
)}
123-
</>
78+
</div>
12479
)}
12580

12681
{error && <p className="invite-error">{error}</p>}
12782

12883
<style>{`
12984
.invite-anon { display: flex; flex-direction: column; gap: .75rem; }
13085
.invite-desc { color: var(--color-text-secondary); font-size: .875rem; line-height: 1.5; margin: 0; }
131-
.invite-quota { color: var(--color-text-primary); font-weight: 500; font-size: .875rem; margin: 0; }
132-
.invite-muted { color: var(--color-text-secondary); font-size: .875rem; margin: 0; }
13386
.invite-button { padding: .625rem 1rem; background: var(--color-brand-primary); color: #fff; border: none; border-radius: .5rem; font-weight: 500; cursor: pointer; align-self: flex-start; }
13487
.invite-button:hover:not(:disabled) { background: var(--color-brand-secondary); }
13588
.invite-button:disabled { opacity: .6; cursor: not-allowed; }

0 commit comments

Comments
 (0)