Skip to content

Commit 03ab02a

Browse files
committed
Bağlı sitelerde ajan erişimi anahtarı
Kullanıcı, Orbit hesabıyla girdiği bir siteyi ajanına açabiliyor: panelde "Bağlı siteler" kartındaki her satırda "Ajan erişimini aç/kapat". Açık olduğunda ajan o sitede kişinin YAPABİLDİKLERİNİ kişinin ADINA yapıyor — ayrı ajan hesabı, ayrı ajan listesi doğmuyor. Orbit'in kendisi istisna kalıyor; orada ajanlar kendi adına davranıyor ve o yol `mcp_authorization_grants` üzerinden yürüyor. Bu, kopyala-yapıştır anahtar tasarımının yerine geçiyor. Öncekinde son kullanıcı bir yenileme anahtarını elle taşıyordu; Rota herkese açık bir site ve oraya konan şeyin ürün olması gerekiyor. İzin ayrı tabloda değil, `oauth_client_grants` satırının içinde: site bağlantısı kesildiğinde ajan erişimi de düşmeli ve bunun ayrı bir temizlik adımı gerektirmemesi lazım — unutulan temizlik açık kalan kapıdır. Test tam bunu tutuyor: iptal edilmiş bir izinde anahtar açılmıyor. Depo katmanındaki `revoked_at IS NULL` koşulunu kaldıran mutasyon testi düşürüyor (23→22). Varsayılan kapalı. Bağlı her siteyi ajana açık saymak, kullanıcının hiç sorulmamış bir soruya "evet" demesi olurdu. Ayrıca tsconfig'den `baseUrl` kaldırıldı: TypeScript 6'da hata veriyordu ve `tsc --noEmit` hiç koşmuyordu. `paths` zaten kullanılmıyor.
1 parent ba3f09b commit 03ab02a

9 files changed

Lines changed: 207 additions & 4 deletions
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
-- Ajanlar bağlı sitelerde insanın adına iş yapabilsin.
2+
--
3+
-- Kural insanın kendi cümlesiyle: alt sitelerde ajan, insanın yapabildiğini
4+
-- insanın adına yapar; ayrı ajan hesabı, ayrı ajan listesi olmaz. Tek istisna
5+
-- Orbit'in kendisi — orada ajanlar kendi adına davranır ve o yol
6+
-- `mcp_authorization_grants` üzerinden yürüyor, buraya karışmıyor.
7+
--
8+
-- İzin neden ayrı bir sütun, ayrı bir tablo değil: ajan erişimi insanın o
9+
-- siteye verdiği iznin İÇİNDE yaşıyor. Site bağlantısı kesildiğinde ajanın
10+
-- erişimi de kesilmeli ve bunun ayrı bir temizlik adımı gerektirmemesi lazım —
11+
-- unutulan temizlik, açık kalan kapıdır. Aynı satırda durunca `revoked_at`
12+
-- ikisini birden düşürüyor.
13+
--
14+
-- NULL = kapalı. Varsayılan bilerek kapalı: bağlı olan her site için ajan
15+
-- erişimini açık saymak, kullanıcının hiç sormadığımız bir soruya "evet"
16+
-- demesi olurdu.
17+
ALTER TABLE oauth_client_grants ADD COLUMN agent_access_at INTEGER;
18+
19+
-- Açılma anı iznin kendisinden önce olamaz.
20+
-- SQLite ALTER TABLE ile CHECK eklenemediği için kontrol trigger'da.
21+
CREATE TRIGGER oauth_client_grants_agent_access_sane
22+
BEFORE UPDATE OF agent_access_at ON oauth_client_grants
23+
BEGIN
24+
SELECT RAISE(ABORT, 'oauth_grant_agent_access_before_grant')
25+
WHERE NEW.agent_access_at IS NOT NULL
26+
AND NEW.agent_access_at < NEW.created_at;
27+
END;
28+
29+
-- Ajan token'ı isterken bu sorgu koşuyor: "bu hesabın bu siteye verdiği,
30+
-- iptal edilmemiş ve ajana açık izni var mı".
31+
CREATE INDEX oauth_client_grants_agent_access_idx
32+
ON oauth_client_grants (account_id, agent_access_at)
33+
WHERE agent_access_at IS NOT NULL AND revoked_at IS NULL;

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -863,6 +863,22 @@ async function handleAction(body: ActionRequest, env: Environment): Promise<Resp
863863
});
864864
}
865865

866+
case 'setSiteAgentAccess': {
867+
return json({
868+
grant: await siteRepository.setAgentAccess({
869+
grantId: stringValue(data, 'grantId'),
870+
allowed: data.allowed === true,
871+
now: numberValue(data, 'now'),
872+
}),
873+
});
874+
}
875+
876+
case 'listSiteAccountGrants': {
877+
return json({
878+
grants: await siteRepository.listAccountGrants(stringValue(data, 'accountId')),
879+
});
880+
}
881+
866882
case 'revokeSiteGrant': {
867883
await siteRepository.revokeGrant({
868884
grantId: stringValue(data, 'grantId'),

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

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ interface GrantView {
128128
lastUsedAt: number | null;
129129
revokedAt: number | null;
130130
revokedReason: string | null;
131+
agentAccessAt: number | null;
131132
}
132133

133134
interface StateView {
@@ -599,6 +600,59 @@ describe('Orbit as a sign-in door for other sites', { concurrency: false }, () =
599600
assert.equal(afterReplay.tokens.filter((row) => row.revoked_at === null).length, 0);
600601
});
601602

603+
test('agent access is off until the person turns it on, and dies with the grant', async () => {
604+
const now = 1_760_000_900_000;
605+
const accountId = 'door-agent';
606+
await seedClientAndAccount(accountId, now);
607+
const grant = await consent({ prefix: 'agent', accountId, now });
608+
609+
/* Varsayılan kapalı. Bağlı olan her siteye ajan erişimini açık saymak,
610+
kullanıcının hiç sorulmamış bir soruya "evet" demesi olurdu. */
611+
assert.equal(grant.agentAccessAt, null, 'agent access must start closed');
612+
613+
const opened = await callAction<{ grant: GrantView }>('setSiteAgentAccess', {
614+
grantId: grant.id, allowed: true, now: now + 1_000,
615+
});
616+
assert.equal(opened.grant.agentAccessAt, now + 1_000);
617+
618+
const listed = await callAction<{ grants: GrantView[] }>('listSiteAccountGrants', { accountId });
619+
assert.equal(
620+
listed.grants.find((row) => row.id === grant.id)?.agentAccessAt,
621+
now + 1_000,
622+
'the dashboard reads this list; the switch has to be visible there',
623+
);
624+
625+
const closed = await callAction<{ grant: GrantView }>('setSiteAgentAccess', {
626+
grantId: grant.id, allowed: false, now: now + 2_000,
627+
});
628+
assert.equal(closed.grant.agentAccessAt, null, 'closing must clear it, not just stamp a second time');
629+
630+
/* Asıl mesele: site bağlantısı kesildiğinde ajan erişimi de ölmeli ve bu
631+
AYRI BİR TEMİZLİK ADIMI gerektirmemeli. Ajan erişimi izin satırının
632+
içinde durduğu için `revoked_at` ikisini birden düşürüyor. Ayrı tabloda
633+
olsaydı burası "iptal edildi ama ajan hâlâ açık" durumunu yakalardı. */
634+
await callAction<{ grant: GrantView }>('setSiteAgentAccess', {
635+
grantId: grant.id, allowed: true, now: now + 3_000,
636+
});
637+
await callAction('revokeSiteGrant', {
638+
grantId: grant.id,
639+
actorAccountId: accountId,
640+
reason: 'disconnected_by_owner',
641+
auditEventId: 'agent-access-audit',
642+
requestId: 'agent-access-request',
643+
now: now + 4_000,
644+
});
645+
646+
const afterRevoke = await callAction<{ grant: GrantView }>('setSiteAgentAccess', {
647+
grantId: grant.id, allowed: true, now: now + 5_000,
648+
});
649+
assert.notEqual(
650+
afterRevoke.grant.agentAccessAt,
651+
now + 5_000,
652+
'a revoked grant must not accept agent access; that switch would look open and never work',
653+
);
654+
});
655+
602656
test('revoking a grant kills its tokens and unredeemed codes in one step', async () => {
603657
const now = 1_760_000_800_000;
604658
const accountId = 'door-eight';

‎src/pages/dashboard.astro‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@ import { LEGAL_LAST_UPDATED } from '../data/legal';
177177
kişinin neyi kestiğini bilmemesi olurdu. -->
178178
<article id="connected-sites-card" class="dashboard-card account-card section-card">
179179
<h2>Bağlı siteler</h2>
180-
<p class="section-help">Orbit hesabınla giriş yaptığın siteler. Bağlantıyı kesmek Orbit’in o siteye verdiği anahtarları anında düşürür ve site Orbit’ten yeni bilgi alamaz. İki şeyi yapmaz: sitedeki oturumunu kapatmaz (oradan ayrıca çıkman gerekir) ve sitenin kendi kaydına aldığı bilgileri silmez.</p>
180+
<p class="section-help">Orbit hesabınla giriş yaptığın siteler. Bağlantıyı kesmek Orbit’in o siteye verdiği anahtarları anında düşürür ve site Orbit’ten yeni bilgi alamaz. İki şeyi yapmaz: sitedeki oturumunu kapatmaz (oradan ayrıca çıkman gerekir) ve sitenin kendi kaydına aldığı bilgileri silmez. Ajan erişimini açtığın sitede ajanın, senin yapabildiklerini senin adına yapabilir — ayrı bir ajan hesabı ya da ayrı bir liste oluşmaz. Kapattığında erişim anında biter.</p>
181181
<div id="connected-sites" class="dashboard-list"></div>
182182
</article>
183183
<div class="truth-note">

‎src/scripts/dashboard.js‎

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -356,12 +356,36 @@ async function loadConnectedSites() {
356356
const lastUsed = site.lastUsedAt
357357
? ` · son giriş ${new Date(site.lastUsedAt).toLocaleDateString('tr-TR')}`
358358
: '';
359-
item.innerHTML = `<strong>${escapeHtml(site.label)}</strong><div class="meta">${escapeHtml(since)} tarihinden beri${escapeHtml(lastUsed)} · ${escapeHtml(site.scopes.join(', '))}</div>`;
359+
item.innerHTML = `<strong>${escapeHtml(site.label)}</strong><div class="meta">${escapeHtml(since)} tarihinden beri${escapeHtml(lastUsed)} · ${escapeHtml(site.scopes.join(', '))}${site.agentAccess ? ' · <b>ajanın da kullanabilir</b>' : ''}</div>`;
360360
/* Uyarı metni bilerek "oturumun kapanır" DEMİYOR, çünkü kapanmıyor.
361361
İptal Orbit'in o siteye verdiği anahtarları düşürüyor; sitenin kendi
362362
açtığı oturum bize ait değil ve ona uzaktan dokunamıyoruz. Eski metin
363363
kapandığını söylüyordu ve bu, paylaşılan bir bilgisayarda "kestim,
364364
çıktım" diye kalkan birini açık oturumla bırakır. */
365+
/* Ajan anahtarı. Kural insanın kendi cümlesiyle: alt sitelerde ajan,
366+
insanın yapabildiğini insanın ADINA yapar — ayrı ajan listesi olmaz.
367+
Açık olduğunda ajan bu site için Orbit'ten anahtar alabiliyor; kapalıysa
368+
alamıyor ve elindeki bir sonraki istekte düşüyor.
369+
370+
Anahtar metni durumu değil EYLEMİ söylüyor ("Ajan erişimini aç/kapat"):
371+
"Ajan erişimi açık" yazan bir düğmede basınca ne olacağı belirsiz
372+
kalıyor. */
373+
item.append(actionButton(
374+
site.agentAccess ? 'Ajan erişimini kapat' : 'Ajan erişimini aç',
375+
async () => {
376+
try {
377+
await mutate(
378+
`/v1/me/connected-sites/${encodeURIComponent(site.id)}/agent-access`,
379+
'POST',
380+
{ allowed: !site.agentAccess },
381+
);
382+
await loadConnectedSites();
383+
flash(site.agentAccess
384+
? `${site.label} için ajan erişimi kapatıldı.`
385+
: `${site.label} için ajan erişimi açıldı. Ajanın oradaki işleri senin adına yapabilir.`);
386+
} catch (error) { flash(error.message, 'error'); }
387+
},
388+
));
365389
item.append(actionButton('Bağlantıyı kes', async () => {
366390
if (!window.confirm(`${site.label} bağlantısı kesilsin mi? Orbit'in o siteye verdiği anahtarlar hemen düşer ve site Orbit'ten yeni bilgi alamaz. Sitedeki oturumun açık kalır; oradan ayrıca çıkman gerekir.`)) return;
367391
try {

‎src/server/http/api.ts‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7413,6 +7413,10 @@ export async function handleApiRequest(
74137413
scopes: grant.scopes,
74147414
createdAt: grant.createdAt,
74157415
lastUsedAt: grant.lastUsedAt,
7416+
/* Ajan erişimi. Panel bunu bir anahtar olarak gösteriyor; açık
7417+
* olması, kişinin ajanının o sitede KENDİ ADINA değil KİŞİNİN
7418+
* adına iş yapabildiği anlamına geliyor. */
7419+
agentAccess: grant.agentAccessAt !== null,
74167420
})),
74177421
});
74187422
}
@@ -7444,6 +7448,42 @@ export async function handleApiRequest(
74447448
return json({ connectedSite: { id: grantId, revoked: true } });
74457449
}
74467450

7451+
/* Ajanın bu sitede insanın adına iş yapmasını açar/kapatır.
7452+
*
7453+
* Kapatmak ANINDA etkili olmalı: ajanın elindeki token bu satıra bakarak
7454+
* veriliyor ve kapatıldığında bir sonraki istek reddediliyor. Kullanıcı
7455+
* anahtarı kapattıktan sonra "acaba hâlâ yazabiliyor mu" diye
7456+
* düşünmemeli. */
7457+
const connectedSiteAgentAccessMatch =
7458+
/^\/v1\/me\/connected-sites\/([^/]+)\/agent-access$/u.exec(path);
7459+
if (request.method === 'POST' && connectedSiteAgentAccessMatch) {
7460+
const auth = await authenticateHuman(request, env, repository, now, true);
7461+
const body = await readJson(request);
7462+
requireExactFields(body, ['allowed'], 'invalid_agent_access_fields');
7463+
if (typeof body.allowed !== 'boolean') {
7464+
throw new ApiError(400, 'invalid_agent_access_fields', '`allowed` must be a boolean.');
7465+
}
7466+
const grantId = decodeURIComponent(connectedSiteAgentAccessMatch[1]);
7467+
const grant = await siteRepository.getGrantById(grantId);
7468+
/* Başkasının izni "bulunamadı" diye dönüyor — iptal ucundaki gerekçenin
7469+
* aynısı: "yetkin yok" demek, izin kimliklerinin taranmasına izin
7470+
* verirdi. */
7471+
if (!grant || grant.accountId !== auth.account.id) {
7472+
throw new ApiError(404, 'connected_site_not_found', 'Connected site was not found.');
7473+
}
7474+
if (grant.revokedAt !== null) {
7475+
throw new ApiError(409, 'connected_site_already_revoked', 'Connected site is already revoked.');
7476+
}
7477+
const updated = await siteRepository.setAgentAccess({
7478+
grantId,
7479+
allowed: body.allowed,
7480+
now,
7481+
});
7482+
return json({
7483+
connectedSite: { id: grantId, agentAccess: updated?.agentAccessAt != null },
7484+
});
7485+
}
7486+
74477487
const mcpAuthorizationRevokeMatch = /^\/v1\/mcp\/authorizations\/([^/]+)\/revoke$/u.exec(path);
74487488
if (request.method === 'POST' && mcpAuthorizationRevokeMatch) {
74497489
const auth = await authenticateHuman(request, env, repository, now, true);

‎src/server/repositories/d1/d1-site-authorization-repository.ts‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ interface GrantSqlRow {
3939
last_used_at: number | null;
4040
revoked_at: number | null;
4141
revoked_reason: string | null;
42+
agent_access_at: number | null;
4243
}
4344

4445
interface CodeSqlRow {
@@ -74,7 +75,7 @@ const GRANT_SELECT = `
7475
client.site_url AS client_site_url, grant_row.account_id,
7576
grant_row.scopes, grant_row.consent_version, grant_row.created_at,
7677
grant_row.updated_at, grant_row.last_used_at, grant_row.revoked_at,
77-
grant_row.revoked_reason
78+
grant_row.revoked_reason, grant_row.agent_access_at
7879
FROM oauth_client_grants grant_row
7980
JOIN oauth_clients client ON client.id = grant_row.client_id
8081
`;
@@ -134,6 +135,7 @@ function grantFromSql(row: GrantSqlRow): SiteGrantView {
134135
lastUsedAt: row.last_used_at,
135136
revokedAt: row.revoked_at,
136137
revokedReason: row.revoked_reason,
138+
agentAccessAt: row.agent_access_at,
137139
};
138140
}
139141

@@ -294,6 +296,27 @@ export class D1SiteAuthorizationRepository implements SiteAuthorizationRepositor
294296
return rows.results.map(grantFromSql);
295297
}
296298

299+
async setAgentAccess(input: {
300+
grantId: string;
301+
allowed: boolean;
302+
now: number;
303+
}): Promise<SiteGrantView | null> {
304+
/* `revoked_at IS NULL` koşulu WHERE'de, çağıranın kontrolüne bırakılmadı:
305+
* iptal edilmiş bir izne ajan erişimi açmak sessizce "başarılı" dönerse
306+
* panelde açık görünen ama hiçbir zaman çalışmayacak bir anahtar doğar. */
307+
await this.#db.prepare(`
308+
UPDATE oauth_client_grants
309+
SET agent_access_at = ?, updated_at = ?
310+
WHERE id = ? AND revoked_at IS NULL
311+
`).bind(input.allowed ? input.now : null, input.now, input.grantId).run();
312+
313+
const row = await this.#db.prepare(`
314+
${GRANT_SELECT}
315+
WHERE grant_row.id = ?
316+
`).bind(input.grantId).first<GrantSqlRow>();
317+
return row ? grantFromSql(row) : null;
318+
}
319+
297320
async recordConsentWithCode(
298321
input: Parameters<SiteAuthorizationRepository['recordConsentWithCode']>[0],
299322
): Promise<SiteGrantView> {

‎src/server/repositories/site-authorization-repository.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,11 @@ export interface SiteGrantView {
3333
lastUsedAt: number | null;
3434
revokedAt: number | null;
3535
revokedReason: string | null;
36+
/* Ajan erişimi bu iznin İÇİNDE yaşıyor: NULL ise kapalı, doluysa insanın
37+
* ajanına o siteyi açtığı an. Ayrı tabloda olsaydı site bağlantısı
38+
* kesildiğinde ayrıca temizlenmesi gerekirdi ve unutulan temizlik açık
39+
* kalan kapıdır. */
40+
agentAccessAt: number | null;
3641
}
3742

3843
export interface SiteAuthorizationCodeView {
@@ -113,6 +118,15 @@ export interface SiteAuthorizationRepository {
113118

114119
listAccountGrants(accountId: string): Promise<SiteGrantView[]>;
115120

121+
/* Ajan erişimini açar veya kapatır. Yalnız yürürlükteki bir izin üzerinde
122+
* çalışır: iptal edilmiş bir siteye ajan erişimi açmak, kapalı bir kapının
123+
* kilidini değiştirmek gibi anlamsız olurdu. */
124+
setAgentAccess(input: {
125+
grantId: string;
126+
allowed: boolean;
127+
now: number;
128+
}): Promise<SiteGrantView | null>;
129+
116130
/* Onay ekranının yazdığı yer: izin satırı (yeni ya da tazelenmiş) ve ona
117131
* bağlı tek kullanımlık kod aynı işlemde doğuyor. */
118132
recordConsentWithCode(input: {

‎tsconfig.json‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
{
22
"extends": "astro/tsconfigs/strict",
33
"compilerOptions": {
4-
"baseUrl": "."
54
}
65
}

0 commit comments

Comments
 (0)