Skip to content

Commit 3fae35e

Browse files
sametbasbugclaude
andcommitted
Open registration, and put ceilings under it
The invitation gate is gone. Anyone with a GitHub account can register. What held the door before was a person handing out a key; what holds it now is written down here. Consent. The key also used to be a conversation — the person who gave it explained the rules. With that link cut, the only thing saying the terms were read is a box the person ticks. So the tick is recorded, not assumed. It lands on the OAuth flow row, which is created server-side before GitHub is ever contacted; a return trip whose flow carries no consent does not complete. It is then written to the account in the same statement that creates it, and refreshed on every later sign-in — what we hold is not "once agreed" but "when, and to which text". The version comes from the same constant the legal pages print, so the text cannot drift from the record silently. Registration rate. Five accounts per connection per day, two hundred per hour platform-wide, counted from account_sign_in_events, which already records every registration with its IP. I wrote the global ceiling at thirty first, then noticed it handed anyone with thirty throwaway GitHub accounts a way to close the door on everyone — a cheaper attack than the one it prevented. It is a flood ceiling now, not a rate ceiling. The per-connection limit started at three and moved to five because CGNAT puts thousands of mobile subscribers behind one address. The cost of loosening it is small: a new agent is born in approval_required and cannot publish until a moderator says so. Registration volume is a database question, not a content question. Outbound mail. Ninety attempts per rolling day, drained security first, then moderation, then announcements. Exhausting the quota does not cost us an announcement, it costs us the security notice queued behind it. A single announcement is capped at sixty recipients, and the panel shows the count before the box is ticked — queued mail is written in the same batch as the publish and cannot be recalled. The GitHub callback answers a browser with a page instead of JSON. That path was nearly unreachable while invitations gated registration; it is now the exit for anyone who hits a limit. Contact moved into the main navigation. A link only in the footer is a link nobody finds, and the people who need to reach us — complaints, objections, takedown requests — are about to become more numerous. What was kept: existing invitations and redemptions stay in the database and in backups. Deleting them would erase how the current accounts got in. ORBIT_OPEN_REGISTRATION survives as an emergency brake that stops new accounts without touching anyone already inside. Found on the way: accounts.announcement_emails_enabled was missing from the backup spec, so a restore silently re-enabled announcement mail for everyone who had turned it off. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent fec33f9 commit 3fae35e

35 files changed

Lines changed: 1372 additions & 920 deletions

‎docs/V6_PROJECT_LEDGER.md‎

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Orbit V6 Project Ledger
22

3-
Orbit'in sunuculu, davetli ve insan sponsorlu AI ajan ağına dönüşümünün kanonik çalışma günlüğüdür.
3+
Orbit'in sunuculu ve insan sponsorlu AI ajan ağına dönüşümünün kanonik çalışma günlüğüdür.
44

55
Bu dosya yalnız sonuçları değil; kararları, reddedilen alternatifleri, migration adımlarını, riskleri, testleri, commitleri ve deploy durumlarını da kaydeder. Yeni bir V6 çalışma turu başlamadan önce bu ledger ve exact git state doğrudan okunur. Kayıtlar geriye dönük sessizce silinmez; değişen kararlar yeni bir `supersedes` notuyla düzeltilir.
66

@@ -20,10 +20,22 @@ Bu dosya yalnız sonuçları değil; kararları, reddedilen alternatifleri, migr
2020

2121
## Durable product direction
2222

23-
- Orbit will become a server-backed, invitation-only social platform for AI agents.
24-
- External agents will initially be limited to agents operated by people Samet knows.
25-
- Every external agent must have a verified human sponsor/owner.
26-
- Open anonymous bot registration is out of scope for the initial release.
23+
- Orbit is a server-backed social platform for AI agents.
24+
- **Supersedes (2026-08-08):** the platform is no longer invitation-only, and
25+
external agents are no longer limited to people Samet knows. Anyone with a
26+
GitHub account can register. The invitation issuing and redemption paths were
27+
removed; historical `invitations` and `invitation_redemptions` rows stay in
28+
the database and in backups, because deleting them would erase how the
29+
existing accounts came in.
30+
- What replaced the invitation as a gate: a per-connection registration ceiling,
31+
a platform-wide flood ceiling, a single `ORBIT_OPEN_REGISTRATION` emergency
32+
brake, and a recorded acceptance of the Privacy Policy and Terms — captured on
33+
the server-side OAuth flow row before GitHub is contacted, then written to the
34+
account and refreshed on every later sign-in.
35+
- Every external agent must still have a verified human sponsor/owner.
36+
- New agents still default to `approval_required`: registration volume is a
37+
database-size question, not a content question. The content gate is elsewhere
38+
and it is still closed.
2739
- The current web experience, record model, and menu-driven CLI should be preserved where practical.
2840
- Security, revocation, moderation, rate limits, auditability, and prompt-injection boundaries are first-class product requirements.
2941

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
-- Davet kapısı kalkmadan önce iki tavan.
2+
--
3+
-- Bugün Orbit'e kayıt olmak için elde bir davet olması gerekiyor ve o davet
4+
-- tek tek veriliyor. Kapı kalktığında GitHub hesabı olan herkes kayıt
5+
-- olabilecek — yani kayıt hacmini bir insanın kararı değil, internet
6+
-- belirleyecek. Bu iki tavan o devri karşılamak için.
7+
8+
-- 1. Kayıt hızı.
9+
--
10+
-- Kayıt sayacının kaynağı yeni bir tablo değil: account_sign_in_events zaten
11+
-- her kaydı 'registration' olarak yazıyor, IP'siyle birlikte. Sayaç için ayrı
12+
-- bir tablo açmak, aynı olayı iki yere yazıp ikisinin birbirinden kaymasını
13+
-- beklemek olurdu.
14+
--
15+
-- Bunun bir sonucu var ve bilerek kabul ediliyor: sayılan şey BAŞARILI
16+
-- kayıtlar, denemeler değil. Yani bu bir "brute force" savunması değil —
17+
-- GitHub OAuth'u zaten o işi yapıyor. Buradaki tavan hacim tavanı: bir gecede
18+
-- yüzlerce hesabın açılmasını engelliyor.
19+
--
20+
-- Mevcut indeks (account_id, created_at DESC) bu sorguya yaramıyor; sorgu
21+
-- hesaptan değil zamandan ve IP'den giriyor. Kısmi indeks, tabloda çoğunluğu
22+
-- oluşturan 'sign_in' satırlarını dışarıda bırakıyor.
23+
CREATE INDEX account_sign_in_events_registration_rate_idx
24+
ON account_sign_in_events (created_at)
25+
WHERE event_type = 'registration';
26+
27+
CREATE INDEX account_sign_in_events_registration_ip_idx
28+
ON account_sign_in_events (ip, created_at)
29+
WHERE event_type = 'registration' AND ip IS NOT NULL;
30+
31+
-- 2. Posta bütçesi.
32+
--
33+
-- Gönderim sağlayıcısının ücretsiz katmanı günde 100, ayda 3000 posta
34+
-- veriyor. Bugün abone sayısı bir avuç olduğu için bu sınır uzak duruyor;
35+
-- kapı açıldığında duyuru postası alan kişi sayısı kayıt sayısıyla birlikte
36+
-- artacak ve sınıra ilk çarpan şey bir duyuru olacak.
37+
--
38+
-- Sınıra çarpmanın bedeli "duyuru gitmedi" değil: kota bittiğinde SIRADAKİ
39+
-- posta da gitmiyor, ve sıradaki posta bir güvenlik veya moderasyon bildirimi
40+
-- olabilir. Yani önemsiz bir duyuru, hesabıyla ilgili bir kararı insana
41+
-- ulaştırmamıza mal olabilir.
42+
--
43+
-- Bütçeyi ölçebilmek için denemenin ne zaman yapıldığını bilmek gerekiyor.
44+
-- sent_at yetmiyor: başarısız denemeler de sağlayıcının kotasından düşüyor
45+
-- ama sent_at'leri hiç dolmuyor. attempts sütunu kaç denendiğini söylüyor,
46+
-- ne zaman denendiğini söylemiyor — 24 saatlik pencereyi ondan çıkaramayız.
47+
--
48+
-- Nullable ve geriye doldurulmuyor: göç anında kuyrukta bekleyen satırlar
49+
-- henüz denenmemiş sayılır. Geçmiş gönderimleri sent_at'ten türetip bütçeye
50+
-- yazmak, göçün ertesi günü bütçeyi olduğundan dolu göstermek olurdu.
51+
ALTER TABLE email_deliveries ADD COLUMN last_attempt_at INTEGER;
52+
53+
-- Bütçe sorgusu her boşaltma turunda çalışıyor ve tablonun tamamını değil,
54+
-- son 24 saati okuyor.
55+
CREATE INDEX email_deliveries_attempt_budget_idx
56+
ON email_deliveries (last_attempt_at)
57+
WHERE last_attempt_at IS NOT NULL;
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
-- Kapı açıldı; onay kayda geçiyor.
2+
--
3+
-- Davet sistemi kalktı. Davet, bir insanın bir insanı tanıyıp içeri
4+
-- almasıydı ve o insan koşulları da anlatıyordu. O halka koptuğuna göre
5+
-- koşulların okunduğunu söyleyen tek şey, kişinin kendi işaretlediği kutu.
6+
-- Ve o kutunun bir kıymeti olması için işaretlendiğinin yazılı olması
7+
-- gerekiyor: "kabul etti" diyemiyorsak, kutu yalnız bir süstür.
8+
9+
-- 1. Onay OAuth akışına yazılıyor.
10+
--
11+
-- Neden hesaba değil de önce akışa: kutu tarayıcıda işaretleniyor ve
12+
-- tarayıcıdan gelen hiçbir şey kanıt değil. Akış satırı ise sunucuda
13+
-- doğuyor — GitHub'a gitmeden ÖNCE, /v1/auth/github/start içinde. Dönüşte
14+
-- bu satır boşsa giriş tamamlanmıyor. Yani onaysız bir turu tamamlamanın
15+
-- yolu, sunucuda onaylı bir akış satırı uydurmaktan geçiyor ki mümkün değil.
16+
--
17+
-- Sürüm de yazılıyor: bugün kabul edilen metin ile yarın kabul edilecek
18+
-- metin aynı olmayabilir ve "hangi metni kabul etti" sorusunun cevabı
19+
-- sonradan üretilemez.
20+
ALTER TABLE oauth_flows ADD COLUMN terms_accepted_at INTEGER;
21+
ALTER TABLE oauth_flows ADD COLUMN terms_version TEXT;
22+
23+
-- 2. Onay hesaba taşınıyor.
24+
--
25+
-- Akış satırı bir gün sonra temizleniyor (OAUTH_FLOW_RETENTION_MS); kalıcı
26+
-- cevap hesapta durmalı. Her girişte tazeleniyor, çünkü kutu her girişte
27+
-- işaretleniyor: elimizdeki değer "bir zamanlar kabul etmişti" değil, "en
28+
-- son ne zaman ve hangi sürümü kabul etti".
29+
--
30+
-- Mevcut hesaplarda NULL kalıyor ve geriye doldurulmuyor. Onlar davetle
31+
-- geldi ve o dönemde böyle bir kutu yoktu; onlar adına bir onay yazmak,
32+
-- olmayan bir imzayı kayda geçirmek olurdu. Bir sonraki girişlerinde kendi
33+
-- işaretleriyle dolacak.
34+
ALTER TABLE accounts ADD COLUMN terms_accepted_at INTEGER;
35+
ALTER TABLE accounts ADD COLUMN terms_version TEXT;
36+
37+
-- İkisi birlikte anlamlı: tarihi olup sürümü olmayan bir satır, hangi
38+
-- metnin kabul edildiğini söyleyemediği için hiçbir soruya cevap vermiyor.
39+
CREATE TRIGGER accounts_terms_consent_is_complete_insert
40+
BEFORE INSERT ON accounts
41+
WHEN (NEW.terms_accepted_at IS NULL) <> (NEW.terms_version IS NULL)
42+
BEGIN
43+
SELECT RAISE(ABORT, 'account_terms_consent_incomplete');
44+
END;
45+
46+
CREATE TRIGGER accounts_terms_consent_is_complete_update
47+
BEFORE UPDATE ON accounts
48+
WHEN (NEW.terms_accepted_at IS NULL) <> (NEW.terms_version IS NULL)
49+
BEGIN
50+
SELECT RAISE(ABORT, 'account_terms_consent_incomplete');
51+
END;

‎scripts/orbit-d1-test-worker.ts‎

Lines changed: 0 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -297,78 +297,6 @@ async function handleAction(body: ActionRequest, env: Environment): Promise<Resp
297297
case 'health':
298298
return json({ ok: true });
299299

300-
case 'seedInvitation': {
301-
const ownerId = stringValue(data, 'ownerId');
302-
const invitationId = stringValue(data, 'invitationId');
303-
const expectedGithubUserId = data.expectedGithubUserId;
304-
const now = numberValue(data, 'now');
305-
await seedOwner(env.DB, ownerId, now);
306-
await env.DB.prepare(`
307-
INSERT INTO invitations (
308-
id, secret_digest, hash_version, expected_github_user_id,
309-
agent_quota, created_by_account_id, created_at, expires_at
310-
) VALUES (?, ?, 1, ?, 1, ?, ?, ?)
311-
`).bind(
312-
invitationId,
313-
`digest:${invitationId}`,
314-
typeof expectedGithubUserId === 'string' ? expectedGithubUserId : null,
315-
ownerId,
316-
now,
317-
now + 72 * 60 * 60 * 1000,
318-
).run();
319-
return json({ ok: true });
320-
}
321-
322-
case 'redeemInvitation': {
323-
const now = numberValue(data, 'now');
324-
const accountId = stringValue(data, 'accountId');
325-
const invitationId = stringValue(data, 'invitationId');
326-
const githubUserId = stringValue(data, 'githubUserId');
327-
const auditEventId = stringValue(data, 'auditEventId');
328-
await repository.redeemInvitation({
329-
invitationId,
330-
githubIdentityId: stringValue(data, 'githubIdentityId'),
331-
githubUserId,
332-
githubLogin: stringValue(data, 'githubLogin'),
333-
account: {
334-
id: accountId,
335-
handle: stringValue(data, 'handle'),
336-
displayName: stringValue(data, 'displayName'),
337-
},
338-
session: {
339-
id: stringValue(data, 'sessionId'),
340-
secretDigest: stringValue(data, 'sessionDigest'),
341-
hashVersion: 1,
342-
csrfDigest: stringValue(data, 'csrfDigest'),
343-
idleExpiresAt: now + 7 * 24 * 60 * 60 * 1000,
344-
absoluteExpiresAt: now + 30 * 24 * 60 * 60 * 1000,
345-
},
346-
agentQuota: 1,
347-
auditEventId,
348-
requestId: stringValue(data, 'requestId'),
349-
now,
350-
});
351-
return json({
352-
accountCount: await count(env.DB, 'accounts', 'id', accountId),
353-
sessionCount: await count(env.DB, 'sessions', 'account_id', accountId),
354-
redemptionCount: await count(env.DB, 'invitation_redemptions', 'invitation_id', invitationId),
355-
auditCount: await count(env.DB, 'audit_events', 'id', auditEventId),
356-
metrics: repository.metrics(),
357-
});
358-
}
359-
360-
case 'registrationState': {
361-
const accountId = stringValue(data, 'accountId');
362-
const invitationId = stringValue(data, 'invitationId');
363-
const auditEventId = stringValue(data, 'auditEventId');
364-
return json({
365-
accountCount: await count(env.DB, 'accounts', 'id', accountId),
366-
sessionCount: await count(env.DB, 'sessions', 'account_id', accountId),
367-
redemptionCount: await count(env.DB, 'invitation_redemptions', 'invitation_id', invitationId),
368-
auditCount: await count(env.DB, 'audit_events', 'id', auditEventId),
369-
});
370-
}
371-
372300
case 'seedMcpAgent': {
373301
await seedMcpAgent(
374302
env.DB,

‎scripts/orbit-d1-tests.ts‎

Lines changed: 9 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -18,24 +18,6 @@ const callAction = async <T>(
1818
return await harness.callAction<T>(action, data, expectedStatus);
1919
};
2020

21-
function registrationData(prefix: string, invitationId: string, githubUserId: string, now: number) {
22-
return {
23-
invitationId,
24-
githubIdentityId: `${prefix}-identity`,
25-
githubUserId,
26-
githubLogin: `${prefix}-login`,
27-
accountId: `${prefix}-account`,
28-
handle: `${prefix}-handle`,
29-
displayName: `${prefix} display`,
30-
sessionId: `${prefix}-session`,
31-
sessionDigest: `${prefix}-session-digest`,
32-
csrfDigest: `${prefix}-csrf-digest`,
33-
auditEventId: `${prefix}-audit`,
34-
requestId: `${prefix}-request`,
35-
now,
36-
};
37-
}
38-
3921
before(async () => {
4022
harness = await startTestWorker();
4123
[firstMigrationOutput, secondMigrationOutput] = harness.migrationOutputs;
@@ -63,76 +45,11 @@ describe('Orbit V6 Slice 0 local-D1 foundation', { concurrency: false }, () => {
6345
assert.deepEqual(check.rows, []);
6446
});
6547

66-
test('invitation redemption rolls the entire registration back on late validation failure', async () => {
67-
const now = Date.now();
68-
const invitationId = 'invite-rollback';
69-
await callAction('seedInvitation', {
70-
ownerId: 'owner-rollback',
71-
invitationId,
72-
expectedGithubUserId: 'github-expected',
73-
now,
74-
});
75-
const registration = registrationData('rollback', invitationId, 'github-mismatch', now + 1);
76-
77-
await callAction('redeemInvitation', registration, 409);
78-
const state = await callAction<{
79-
accountCount: number;
80-
sessionCount: number;
81-
redemptionCount: number;
82-
auditCount: number;
83-
}>('registrationState', registration);
84-
85-
assert.deepEqual(state, {
86-
accountCount: 0,
87-
sessionCount: 0,
88-
redemptionCount: 0,
89-
auditCount: 0,
90-
});
91-
});
92-
93-
test('a second redemption of the same invitation is rejected without orphan writes', async () => {
94-
const now = Date.now();
95-
const invitationId = 'invite-single-use';
96-
await callAction('seedInvitation', {
97-
ownerId: 'owner-single-use',
98-
invitationId,
99-
now,
100-
});
101-
102-
const first = registrationData('first-use', invitationId, 'github-first', now + 1);
103-
const firstResult = await callAction<{
104-
accountCount: number;
105-
sessionCount: number;
106-
redemptionCount: number;
107-
auditCount: number;
108-
metrics: { batches: number; statements: number };
109-
}>('redeemInvitation', first);
110-
assert.equal(firstResult.accountCount, 1);
111-
assert.equal(firstResult.sessionCount, 1);
112-
assert.equal(firstResult.redemptionCount, 1);
113-
assert.equal(firstResult.auditCount, 1);
114-
assert.deepEqual(firstResult.metrics, {
115-
batches: 1,
116-
statements: 7,
117-
operations: { 'invitation.redeem': 7 },
118-
});
119-
120-
const second = registrationData('second-use', invitationId, 'github-second', now + 2);
121-
await callAction('redeemInvitation', second, 409);
122-
const secondState = await callAction<{
123-
accountCount: number;
124-
sessionCount: number;
125-
redemptionCount: number;
126-
auditCount: number;
127-
}>('registrationState', second);
128-
assert.deepEqual(secondState, {
129-
accountCount: 0,
130-
sessionCount: 0,
131-
redemptionCount: 1,
132-
auditCount: 0,
133-
});
134-
});
135-
48+
/* Buradaki iki test davet kullanımının atomikliğini ölçüyordu. Davet
49+
sistemi emekliye ayrıldı ve ölçtükleri özellik — bir batch'in geç
50+
düşen bir kısıtta tamamen geri sarması — aşağıdaki kimlik bilgisi
51+
döndürme testinde aynen duruyor. Ölü bir yolu yeşil tutan test,
52+
hiçbir şey ölçmeyen testtir. */
13653
test('API credential rotation is atomic on late failure, success and stale retry', async () => {
13754
const now = Date.now();
13855
const seed = {
@@ -276,11 +193,11 @@ describe('Orbit V6 Slice 0 local-D1 foundation', { concurrency: false }, () => {
276193

277194
test('error envelopes expose the stable request ID', () => {
278195
assert.deepEqual(
279-
createErrorEnvelope('invalid_invitation', 'The invitation is not valid.', 'req_test'),
196+
createErrorEnvelope('terms_not_accepted', 'Terms were not accepted.', 'req_test'),
280197
{
281198
error: {
282-
code: 'invalid_invitation',
283-
message: 'The invitation is not valid.',
199+
code: 'terms_not_accepted',
200+
message: 'Terms were not accepted.',
284201
requestId: 'req_test',
285202
details: {},
286203
},
@@ -293,7 +210,7 @@ describe('Orbit V6 Slice 0 local-D1 foundation', { concurrency: false }, () => {
293210
redactSecrets({
294211
authorization: 'Bearer orb_agent_v1_selector_supersecret',
295212
nested: {
296-
message: 'received orb_inv_v1_selector_invitationsecret',
213+
message: 'received orb_sess_v1_selector_sessionsecret',
297214
safe: 'visible',
298215
},
299216
}),

‎scripts/orbit-production-config-tests.mjs‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,13 @@ const expectedCommonVars = {
111111
ORBIT_PLATFORM_OWNER_GITHUB_ID: '126420524',
112112
ORBIT_BACKUP_ENABLED: 'true',
113113
ORBIT_MEDIA_ENABLED: 'true',
114+
/* Kayıt kapısı. Davet sistemi emekliye ayrıldı ve kapı açıldı; bu değer
115+
artık bir davet mekanizması değil, acil fren. 'false' yapmak yeni
116+
kayıtları tamamen durdurur — mevcut hesapların girişini etkilemez,
117+
çünkü kapıyı kapatmak içeridekileri dışarı atmak değil.
118+
Kilitte durmasının sebebi aynı: değeri değiştirmek iki dosyaya
119+
dokunmayı gerektiriyor, yani kimse yanlışlıkla değiştiremiyor. */
120+
ORBIT_OPEN_REGISTRATION: 'true',
114121
/* Gönderen adresi doğrulanmış alan adıyla eşleşmek zorunda: Resend'de
115122
* sametbasbug.dev doğrulandı, orbit@ ondan türüyor. Yanıt adresi ise
116123
* gizlilik ve iletişim sayfalarında yazan adresin aynısı — kullanıcıya
@@ -200,6 +207,7 @@ function validateConfig(config, expected) {
200207
ORBIT_ALLOWED_ORIGIN: expected.origin,
201208
ORBIT_GITHUB_CALLBACK_URL: `${expected.origin}/v1/auth/github/callback`,
202209
ORBIT_PLATFORM_OWNER_GITHUB_ID: '126420524',
210+
ORBIT_OPEN_REGISTRATION: 'true',
203211
ORBIT_BACKUP_ENABLED: 'true',
204212
ORBIT_MEDIA_ENABLED: 'true',
205213
ORBIT_EMAIL_FROM: 'Orbit <orbit@sametbasbug.dev>',

0 commit comments

Comments
 (0)