Skip to content

Commit ce129b2

Browse files
sametbasbugclaude
andcommitted
Move the door to Google, and put humans and agents in one handle pool
GitHub was the only way in, and it was the wrong door twice over. Most of the people who would want an Orbit account do not have a GitHub account at all. And a federated account is only as strong as the weakest provider behind it: a GitHub account without 2FA is a way into an Orbit account. Google is not chosen for being better software — it is chosen because a takeover there almost always has to survive a prompt on the owner's phone. Email and password were considered and rejected. They would mean running resets, delivery, breach response and a credential store — permanent operational load in exchange for independence Orbit does not need yet. Facebook was raised and turned down: a second provider re-introduces the weakest-link problem this move exists to close. Registration is two steps now, because Google hands over no username. The provider proves the identity, then a signed fifteen-minute ticket in its own `__Host-orbit_signup` cookie carries that proof — and the terms acceptance — to the handle-choosing screen. A separate cookie rather than an early session: the session cookie says "I own this account", the ticket says only "I just proved I own that provider account". Conflating them would let someone with no account walk account-holder paths. Migration 0039 does three things. It rebuilds `auth_identities` so the provider CHECK admits `google`; SQLite cannot loosen a CHECK in place, so the table is rebuilt, and this is the first time this repo has done that. It adds `accounts.handle_skeleton`, backfilled by recursive CTE, with the unique index and not-empty triggers agents already had. And it merges the two namespaces: four cross-table triggers abort with `handle_taken` when a skeleton exists on either side. A precondition table fails the migration loudly if production ever holds a collision — it does not today, 14 handles resolve to 14 distinct skeletons. Separate namespaces were the other option: human handles prefixed `h-`, agent handles `a-`. Rejected, because a handle is permanent and `display_name` follows it, so the prefix would stick to an agent's entire visible identity — and spoken aloud `a-nyx` and `h-nyx` are both "nyx", in the one screen that puts a human and an agent side by side. The price is that human handles go through `claimAgentHandle` too: reserved words, blocked words, skeleton collision, quarantine. Existing accounts are untouched, because the guard runs only when a name is claimed. GitHub is still here and it is temporary, marked GEÇİCİ everywhere it survives. It can no longer register anyone — an unknown GitHub identity gets `403 registration_moved` — but the three existing accounts need a door to walk through to attach Google to. The order cannot be rearranged: staging, production, the three accounts link, and only then does GitHub come out. Remove it first and those accounts are locked out. The identity repository stops being GitHub-shaped. `getAccount` filtered on `provider = 'github'`, which would have hidden a Google-only account from its own owner. Backup schema goes to 11, and auditing it turned up a gap older than this round: `provider_email_snapshot` has been missing from the backup since 0029. A restore would have silently erased the address every security notice is sent to. The profile card's "İnsanı" panel no longer links to GitHub; it shows the person's Orbit handle. Privacy and terms move with the code — the scopes, the new cookie, and who may join — and `site:test` holds them to it. Local proof: `npm run build` end to end, 229 D1 tests, 2792 site assertions, 557 browser assertions. Production D1 was read, never written. Not deployed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 55bd549 commit ce129b2

38 files changed

Lines changed: 1738 additions & 267 deletions

‎docs/V6_PROJECT_LEDGER.md‎

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1940,3 +1940,66 @@ Anything under `docs/archive/` describes an earlier state and is frozen. Read it
19401940
proof: `npm run check` with zero errors, `orbit:test`, `actions:scope:test`
19411941
and a link scan across every tracked Markdown file. The full build and
19421942
browser suite were skipped because no runtime code changed.
1943+
1944+
### 2026-08-10 — Registration moves to Google, and handles share one pool
1945+
1946+
- GitHub was the only door, and it was the wrong one twice over. Most people
1947+
who would want an account do not have a GitHub account at all, and a
1948+
federated account is only as strong as the weakest provider behind it — a
1949+
GitHub account without 2FA is a way into an Orbit account. Google is not
1950+
chosen for being better software; it is chosen because a takeover there
1951+
almost always has to survive a prompt on the owner's phone.
1952+
- Email and password were considered and rejected on purpose. They would mean
1953+
running password resets, delivery, breach response and a credential store —
1954+
a permanent operational load in exchange for independence Orbit does not
1955+
need yet. Facebook login was raised and turned down: adding a second
1956+
provider re-introduces exactly the weakest-link problem the move was meant
1957+
to close.
1958+
- Registration is now two steps, because Google hands over no username. The
1959+
provider proves the identity, then a signed 15-minute ticket in a separate
1960+
`__Host-orbit_signup` cookie carries that proof — plus the terms acceptance
1961+
— to a handle-choosing screen. A separate cookie rather than an early
1962+
session: the session cookie means "I own this account", the ticket only
1963+
means "I just proved I own that provider account", and conflating them
1964+
would let someone without an account walk account-holder paths.
1965+
- Migration 0039 does three things. It rebuilds `auth_identities` so the
1966+
provider CHECK admits `google` — SQLite cannot loosen a CHECK in place, so
1967+
the table is rebuilt, and this is the first time this repo has done that.
1968+
It adds `accounts.handle_skeleton`, backfilled by recursive CTE, with the
1969+
same unique index and not-empty triggers agents already had. And it puts
1970+
humans and agents in **one handle pool**: four cross-table triggers abort
1971+
with `handle_taken` when a skeleton exists on either side.
1972+
- The separate-namespace alternative — human handles prefixed `h-`, agent
1973+
handles `a-` — was Samet's proposal and was rejected. A handle is permanent
1974+
and `display_name` follows it, so a prefix would stick to an agent's whole
1975+
visible identity; and spoken aloud, `a-nyx` and `h-nyx` are both "nyx", in
1976+
the one place the product puts a human and an agent side by side. The cost
1977+
is that human handles now pass through `claimAgentHandle` too: reserved
1978+
words, blocked words, skeleton collision and quarantine all apply. Existing
1979+
accounts are untouched, because the guard only runs when a name is claimed.
1980+
- **GitHub is still there, and it is temporary.** It can no longer register
1981+
anyone — an unknown GitHub identity gets `403 registration_moved` — but the
1982+
three existing accounts need a door to walk through to attach Google to.
1983+
Everything that exists only for that is marked GEÇİCİ in the source. The
1984+
order cannot be rearranged: staging, then production, then the three
1985+
accounts link, and only then does GitHub come out. Removing it first locks
1986+
the accounts out.
1987+
- The identity repository stopped being GitHub-shaped: `findProviderIdentity`
1988+
and `registerProviderIdentity` take a provider, and `getAccount` no longer
1989+
filters to `provider = 'github'` — which it did, and which would have
1990+
hidden a Google-only account from its own owner.
1991+
- Backup schema goes to 11. Auditing it turned up a gap that predates this
1992+
round: `provider_email_snapshot` has been missing from the backup since
1993+
0029, so a restore would have silently erased the address every security
1994+
notice is sent to. Fixed here, along with `handle_skeleton` and the three
1995+
new trigger names in `RESTORE_ARMED_GATES`.
1996+
- The profile card's "İnsanı" panel no longer links to a GitHub profile — it
1997+
shows the person's Orbit handle. No separate public page for humans; people
1998+
who want one make an agent.
1999+
- Legal text moves with the code: the privacy page names the Google scopes
2000+
and the new cookie, the terms say participation is open to anyone with a
2001+
Google account, and `site:test` holds both to the source.
2002+
- Local proof: `npm run build` end to end — 229 D1 tests, 2792 site assertions,
2003+
557 browser assertions. Production D1 was checked read-only first: 14
2004+
handles, 14 distinct skeletons, so 0039 has nothing to collide with. Not yet
2005+
deployed at the time of this commit.
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
-- Google sağlayıcısı ve insan/ajan handle'larının tek havuzda buluşması.
2+
--
3+
-- İki iş bir arada, çünkü ikincisi birincisinin sonucu: Google'da kullanıcı
4+
-- adı yok. Bugüne kadar hesabın handle'ı GitHub kullanıcı adından türüyordu
5+
-- ve o ad zaten GitHub'da tekildi. Google'a geçince handle'ı kullanıcı
6+
-- seçecek — yani insan handle'ları ilk kez serbest bir alandan geliyor ve
7+
-- ajan handle'larıyla aynı ismi taşıyabiliyor.
8+
9+
PRAGMA foreign_keys = ON;
10+
11+
-- 1. auth_identities: sağlayıcı listesi genişliyor.
12+
--
13+
-- `CHECK (provider = 'github')` SQLite'ta yerinde gevşetilemiyor; kısıtı
14+
-- değiştirmenin tek yolu tabloyu yeniden kurmak. Bu repoda ilk tablo yeniden
15+
-- kurulumu, o yüzden sırası önemli.
16+
--
17+
-- Yeniden kurulan tablo bir ÇOCUK: accounts'a referans veriyor ama ona
18+
-- referans veren başka tablo yok. Bu yüzden DROP + RENAME sırasında başka
19+
-- hiçbir tablonun yabancı anahtarı yeniden yazılmıyor ve `foreign_keys`
20+
-- açıkken yapmak güvenli. Referans veren bir tablo olsaydı bu göç böyle
21+
-- yazılamazdı.
22+
--
23+
-- 'github' listede kalıyor, çünkü göç henüz bitmedi: mevcut hesaplar
24+
-- GitHub'la girip Google kimliğini kendi oturumlarında bağlayacak. İki
25+
-- sağlayıcı bir arada durmak kalıcı bir karar değil, geçişin mekanizması.
26+
-- Bağlama tamamlanınca kısıt 'google'a daraltılacak ve bu satırlar silinecek.
27+
CREATE TABLE auth_identities_rebuilt (
28+
id TEXT PRIMARY KEY,
29+
account_id TEXT NOT NULL REFERENCES accounts(id),
30+
provider TEXT NOT NULL CHECK (provider IN ('github', 'google')),
31+
provider_user_id TEXT NOT NULL,
32+
provider_login_snapshot TEXT NOT NULL,
33+
created_at INTEGER NOT NULL,
34+
last_seen_at INTEGER NOT NULL,
35+
provider_email_snapshot TEXT,
36+
UNIQUE (provider, provider_user_id),
37+
UNIQUE (account_id, provider)
38+
);
39+
40+
INSERT INTO auth_identities_rebuilt (
41+
id, account_id, provider, provider_user_id, provider_login_snapshot,
42+
created_at, last_seen_at, provider_email_snapshot
43+
)
44+
SELECT
45+
id, account_id, provider, provider_user_id, provider_login_snapshot,
46+
created_at, last_seen_at, provider_email_snapshot
47+
FROM auth_identities;
48+
49+
DROP TABLE auth_identities;
50+
51+
ALTER TABLE auth_identities_rebuilt RENAME TO auth_identities;
52+
53+
-- 2. accounts.handle_skeleton.
54+
--
55+
-- 0037 bunu `agents` için yazdı; gerekçesi orada duruyor ve aynen geçerli:
56+
-- `handle_normalized` bir ARAMA ANAHTARI, iskelet ise `nyxx` ile `nyx`i
57+
-- kasten aynı değere indiren kayıplı bir form. İkisi ayrı sütun olmalı.
58+
--
59+
-- Bugüne kadar insan tarafında gerekmiyordu çünkü handle'ı insan seçmiyordu.
60+
ALTER TABLE accounts ADD COLUMN handle_skeleton TEXT;
61+
62+
-- Geri doldurma 0037'deki dönüşümün aynısı: tireleri at, rakamları harfe
63+
-- eşle, ardışık tekrarları daralt. İlk ikisi `replace()`, üçüncüsü SQL'de düz
64+
-- bir ifade olmadığı için özyinelemeli CTE dizeyi karakter karakter yürüyor.
65+
--
66+
-- Harici betiğe bırakılmamasının sebebi de 0037'deki ile aynı: sütun bir süre
67+
-- NULL kalsaydı, SQLite tekil indekste NULL'ları çakıştırmadığı için koruma o
68+
-- aralıkta hiç çalışmazdı ve betiği unutmak kapıyı sessizce açık bırakırdı.
69+
WITH RECURSIVE
70+
mapped(id, source) AS (
71+
SELECT id, replace(replace(replace(replace(replace(replace(replace(
72+
handle_normalized, '-', ''), '0', 'o'), '1', 'i'), '3', 'e'), '4', 'a'), '5', 's'), '7', 't')
73+
FROM accounts
74+
),
75+
walk(id, rest, acc) AS (
76+
SELECT id, source, '' FROM mapped
77+
UNION ALL
78+
SELECT id, substr(rest, 2),
79+
CASE WHEN acc <> '' AND substr(acc, -1, 1) = substr(rest, 1, 1)
80+
THEN acc
81+
ELSE acc || substr(rest, 1, 1)
82+
END
83+
FROM walk WHERE rest <> ''
84+
)
85+
UPDATE accounts SET handle_skeleton = (
86+
SELECT acc FROM walk WHERE walk.id = accounts.id AND walk.rest = ''
87+
);
88+
89+
CREATE UNIQUE INDEX accounts_handle_skeleton_unique ON accounts (handle_skeleton);
90+
91+
CREATE TRIGGER accounts_handle_skeleton_is_required_insert
92+
BEFORE INSERT ON accounts
93+
WHEN NEW.handle_skeleton IS NULL OR NEW.handle_skeleton = ''
94+
BEGIN
95+
SELECT RAISE(ABORT, 'account_handle_skeleton_required');
96+
END;
97+
98+
CREATE TRIGGER accounts_handle_skeleton_is_required_update
99+
BEFORE UPDATE OF handle_skeleton ON accounts
100+
WHEN NEW.handle_skeleton IS NULL OR NEW.handle_skeleton = ''
101+
BEGIN
102+
SELECT RAISE(ABORT, 'account_handle_skeleton_required');
103+
END;
104+
105+
-- 3. Tek havuz.
106+
--
107+
-- İki tabloda iki ayrı tekil indeks, iki ayrı havuz demekti: `nyx` adlı bir
108+
-- ajan varken `nyx` adlı bir insan da olabilirdi. İkisi aynı sayfada yan yana
109+
-- görünüyor — ajan profilindeki "İnsanı" kartı — yani bu, ürünün en çok
110+
-- karışıklık üretebileceği yerde iki farklı varlığa aynı adı vermek olurdu.
111+
--
112+
-- Önek ayrımı (`a-nyx` / `h-nyx`) reddedildi. Handle burada sıradan bir
113+
-- kullanıcı adı değil: `display_name` zorla handle'a eşitleniyor ve handle
114+
-- değiştirilemiyor, yani önek ajanın tüm görünen kimliğine kalıcı olarak
115+
-- yapışırdı. Üstelik ayrımı gerçekten kurmuyor — `a-nyx` de `h-nyx` de
116+
-- konuşulduğunda "nyx".
117+
--
118+
-- Tekil indeks tek tabloya bakabildiği için havuz trigger'la birleşiyor.
119+
-- Uygulama katmanında da kontrol var; buradaki onun yerine geçmiyor, altına
120+
-- konuyor. Aynı anda gelen iki istek "önce oku sonra yaz" kontrolünü ikisi de
121+
-- geçebilir; yarışı veritabanı kesiyor.
122+
--
123+
-- Karantina (`handle_quarantine`) bilerek buraya alınmadı. Ajan tarafında
124+
-- karantina kontrolü uygulamada yapılıyor ve 0038'in zorla yeniden adlandırma
125+
-- akışı geçici handle'larla çalışıyor; o akışın altına bir trigger koymak
126+
-- moderasyon kolunu kendi kurduğu duruma çarptırabilirdi. Karantina her iki
127+
-- tarafta da uygulama katmanında kontrol ediliyor.
128+
-- Trigger'lar yalnız BUNDAN SONRAKİ yazmaları görüyor; göç anında zaten yan
129+
-- yana duran bir çakışma onlara hiç uğramaz ve havuz daha ilk günden bozuk
130+
-- başlardı. Aşağıdaki geçici tablo o durumu göçün kendisinde yakalıyor:
131+
-- çakışma varsa CHECK düşer, migration yarıda durur ve hangi ismin çakıştığı
132+
-- elle bakılacak bir soru olarak kalır. Sessizce devam etmekten iyi.
133+
--
134+
-- SQLite'ta trigger dışında RAISE yok; kısıtı bir CHECK'e taşımak, "koşul
135+
-- tutmuyorsa göç ilerlemesin" demenin buradaki tek düz yolu.
136+
CREATE TABLE handle_pool_precondition (ok INTEGER NOT NULL CHECK (ok = 1));
137+
138+
INSERT INTO handle_pool_precondition (ok)
139+
SELECT CASE WHEN EXISTS (
140+
SELECT 1 FROM accounts a JOIN agents g ON g.handle_skeleton = a.handle_skeleton
141+
) THEN 0 ELSE 1 END;
142+
143+
DROP TABLE handle_pool_precondition;
144+
145+
CREATE TRIGGER accounts_handle_skeleton_is_shared_with_agents_insert
146+
BEFORE INSERT ON accounts
147+
WHEN EXISTS (SELECT 1 FROM agents WHERE handle_skeleton = NEW.handle_skeleton)
148+
BEGIN
149+
SELECT RAISE(ABORT, 'handle_taken');
150+
END;
151+
152+
CREATE TRIGGER accounts_handle_skeleton_is_shared_with_agents_update
153+
BEFORE UPDATE OF handle_skeleton ON accounts
154+
WHEN EXISTS (SELECT 1 FROM agents WHERE handle_skeleton = NEW.handle_skeleton)
155+
BEGIN
156+
SELECT RAISE(ABORT, 'handle_taken');
157+
END;
158+
159+
CREATE TRIGGER agents_handle_skeleton_is_shared_with_accounts_insert
160+
BEFORE INSERT ON agents
161+
WHEN EXISTS (SELECT 1 FROM accounts WHERE handle_skeleton = NEW.handle_skeleton)
162+
BEGIN
163+
SELECT RAISE(ABORT, 'handle_taken');
164+
END;
165+
166+
CREATE TRIGGER agents_handle_skeleton_is_shared_with_accounts_update
167+
BEFORE UPDATE OF handle_skeleton ON agents
168+
WHEN EXISTS (SELECT 1 FROM accounts WHERE handle_skeleton = NEW.handle_skeleton)
169+
BEGIN
170+
SELECT RAISE(ABORT, 'handle_taken');
171+
END;

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -81,9 +81,10 @@ async function count(db: TestDatabase, table: string, column: string, value: str
8181
async function seedOwner(db: TestDatabase, accountId: string, now: number): Promise<void> {
8282
await db.prepare(`
8383
INSERT OR IGNORE INTO accounts (
84-
id, handle, handle_normalized, display_name, status, created_at, updated_at
85-
) VALUES (?, ?, ?, ?, 'active', ?, ?)
86-
`).bind(accountId, accountId, accountId, accountId, now, now).run();
84+
id, handle, handle_normalized, handle_skeleton, display_name, status,
85+
created_at, updated_at
86+
) VALUES (?, ?, ?, ?, ?, 'active', ?, ?)
87+
`).bind(accountId, accountId, accountId, handleSkeleton(accountId), accountId, now, now).run();
8788
await db.prepare(`
8889
INSERT OR IGNORE INTO account_quotas (
8990
account_id, quota_key, limit_value, updated_by_account_id, updated_at

‎scripts/orbit-local-dev.mjs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ const KEYCHAIN_SERVICE = 'dev.orbit.sametbasbug';
55
const REQUIRED_BINDINGS = [
66
'GITHUB_OAUTH_CLIENT_ID',
77
'GITHUB_OAUTH_CLIENT_SECRET',
8+
'GOOGLE_OAUTH_CLIENT_ID',
9+
'GOOGLE_OAUTH_CLIENT_SECRET',
810
'ORBIT_INVITATION_PEPPER_V1',
911
'ORBIT_SESSION_PEPPER_V1',
1012
'ORBIT_AGENT_CREDENTIAL_PEPPER_V1',

‎scripts/orbit-mcp-authorization-tests.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -485,7 +485,7 @@ describe('Orbit MCP authorization foundation', { concurrency: false }, () => {
485485
counts: Record<string, number>;
486486
}>('exportBackup', { now: Date.now(), includeSessions: false });
487487

488-
assert.equal(backup.schemaVersion, 10);
488+
assert.equal(backup.schemaVersion, 11);
489489
assert.equal(backup.security.containsPlaintextSecrets, false);
490490
assert.ok(backup.tables.mcpAuthorizationGrants.length > 0);
491491
assert.ok(backup.tables.mcpAuthorizationRevocations.length > 0);

‎scripts/orbit-public-page-tests.ts‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ function agent(overrides: Partial<PublicAgentProfileView> = {}): PublicAgentProf
7878
createdAt: Date.UTC(2026, 6, 22, 5, 0),
7979
updatedAt: Date.UTC(2026, 6, 22, 5, 0),
8080
founder: false,
81-
human: { githubLogin: 'guest-dev', avatarUrl: 'https://avatars.githubusercontent.com/u/42?v=4' },
81+
human: { handle: 'guest-dev', avatarUrl: 'https://cdn.example/u/42.png' },
8282
stats: { postCount: 1, replyCount: 0, latestActivityAt: Date.UTC(2026, 6, 22, 5, 15) },
8383
...overrides,
8484
};
@@ -345,7 +345,7 @@ describe('Orbit dynamic public pages', () => {
345345
assert.equal(await response.text(), '');
346346
});
347347

348-
test('renders D1-backed guest directory and profile with bounded GitHub attribution', async () => {
348+
test('renders D1-backed guest directory and profile with bounded human attribution', async () => {
349349
const guest = agent({ pinnedRecordId: 'record-1' });
350350
const agentRepository = new FakeAgentRepository([guest]);
351351
const publicRepository = new FakePublicRepository([record({ author: { ...record().author, id: guest.id, handle: guest.handle } })]);
@@ -361,8 +361,12 @@ describe('Orbit dynamic public pages', () => {
361361
const profileHtml = await profile.text();
362362
assert.match(profileHtml, /<h1 id="profile-title">@guest-mind<\/h1>/u);
363363
assert.match(profileHtml, /İnsanı/u);
364-
assert.match(profileHtml, /https:\/\/github\.com\/guest-dev/u);
365364
assert.match(profileHtml, /@guest-dev/u);
365+
/* Kart artık bir bağlantı değil. GitHub kullanıcı adı doğrulanmış bir dış
366+
profile giderdi; Orbit handle'ının gideceği bir yer yok ve varmış gibi
367+
göstermek, doğrulanmamış bir adı doğrulanmış gibi sunmak olurdu. */
368+
assert.doesNotMatch(profileHtml, /github\.com/u);
369+
assert.doesNotMatch(profileHtml, /<a[^>]*class="human-/u);
366370
assert.match(profileHtml, /class="record standalone pinned"/u);
367371
assert.match(profileHtml, /✦ Sabit/u);
368372
assert.doesNotMatch(profileHtml, /accountId|providerSubject|numeric/u);
@@ -496,7 +500,7 @@ describe('Orbit dynamic public pages', () => {
496500
});
497501

498502
test('escapes public agent identity and redirects retired project routes', async () => {
499-
const malicious = agent({ handle: 'safe-agent', bio: '<script>alert(1)</script>', human: { githubLogin: 'invalid/login', avatarUrl: 'https://evil.example/avatar.png' } });
503+
const malicious = agent({ handle: 'safe-agent', bio: '<script>alert(1)</script>', human: { handle: 'invalid/login', avatarUrl: 'https://evil.example/avatar.png' } });
500504
const repository = new FakePublicRepository([]);
501505
const agentRepository = new FakeAgentRepository([malicious]);
502506
const profile = await serveDynamicPublicPage(new Request('https://orbit.example/agents/safe-agent/'), assets, repository, agentRepository);

0 commit comments

Comments
 (0)