Skip to content

Commit 77fc4b5

Browse files
committed
Ajanın bağlı sitede iş yaptırdığı iki ucu ekle
`GET /v1/me/connected-sites/actions` ajanın yapabileceklerinin kataloğunu, `POST /v1/me/connected-sites/{grantId}/actions` işin kendisini taşıyor. İkisi de ajan kimliğiyle çağrılıyor; insan `sponsorAccountId`'den bulunuyor, çünkü ajan bunu insan ekranda değilken soruyor. Erişimi kapalı siteler katalogda hiç görünmüyor — göstermek ajanı kesin reddedilecek bir isteğe davet etmek olurdu. Kapatma anında etkili: yetkiyi ajanın elindeki belge değil, izin satırı belirliyor. Bayat katalog bilerek kullanılıyor. Site o an erişilemiyorsa eldeki 10 dakikalık kopya sunuluyor; alternatifi, sitenin bir dakikalık kesintisinde ajanın "yapabileceğin hiçbir şey yok" cevabı almasıydı — oysa işlemler duruyor ve çağrı muhtemelen çalışacak. Yanlışsa zaten site reddediyor. Aynı gerekçeyle tek sitenin bozuk kataloğu bütün listeyi düşürmüyor. Sitenin hata gövdesi ajana taşınmıyor, yalnız durum kodu taşınıyor: içinde ne olduğunu bilmiyoruz ve olduğu gibi geçirmek, oradan gelen bir metni Orbit'in cevabı gibi göstermek olurdu. 15 test. Çoğu reddetme yolu, çünkü katalog dosyası bize ait değil ve okuduğumuz şemayı kendi girdi doğrulamamızda koşturuyoruz. `additionalProperties` varsayılanını gevşeten mutasyon testi düşürüyor (15→14).
1 parent 2e82fe2 commit 77fc4b5

3 files changed

Lines changed: 443 additions & 1 deletion

File tree

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
"staging:slice5:images-cpu": "tsx scripts/orbit-slice5-images-cpu-staging-e2e.ts",
3737
"orbit:import:verify": "tsx scripts/orbit-slice3-manifest.ts",
3838
"orbit:import:local": "tsx scripts/orbit-slice3-import.ts --local --database=orbit-v6-local --config=wrangler.test.jsonc",
39-
"test:d1": "tsx --test --test-concurrency=5 scripts/orbit-test-ports-tests.ts scripts/orbit-agent-identity-tests.ts scripts/orbit-api-contract-tests.ts scripts/orbit-handle-policy-tests.ts scripts/orbit-d1-tests.ts scripts/orbit-email-tests.ts scripts/orbit-mcp-authorization-tests.ts scripts/orbit-site-authorization-tests.ts scripts/orbit-site-client-register-tests.ts scripts/orbit-site-client-registration-tests.ts scripts/orbit-site-signin-e2e-tests.ts scripts/orbit-public-page-tests.ts scripts/orbit-public-repository-tests.ts scripts/orbit-single-renderer-tests.ts scripts/orbit-style-scale-tests.ts scripts/orbit-slice1-tests.ts scripts/orbit-slice1-worker-tests.ts scripts/orbit-slice3-tests.ts scripts/orbit-slice4-tests.ts scripts/orbit-slice5-tests.ts",
39+
"test:d1": "tsx --test --test-concurrency=5 scripts/orbit-test-ports-tests.ts scripts/orbit-agent-identity-tests.ts scripts/orbit-api-contract-tests.ts scripts/orbit-handle-policy-tests.ts scripts/orbit-d1-tests.ts scripts/orbit-email-tests.ts scripts/orbit-mcp-authorization-tests.ts scripts/orbit-site-authorization-tests.ts scripts/orbit-site-action-tests.ts scripts/orbit-site-client-register-tests.ts scripts/orbit-site-client-registration-tests.ts scripts/orbit-site-signin-e2e-tests.ts scripts/orbit-public-page-tests.ts scripts/orbit-public-repository-tests.ts scripts/orbit-single-renderer-tests.ts scripts/orbit-style-scale-tests.ts scripts/orbit-slice1-tests.ts scripts/orbit-slice1-worker-tests.ts scripts/orbit-slice3-tests.ts scripts/orbit-slice4-tests.ts scripts/orbit-slice5-tests.ts",
4040
"test:d1:core": "tsx --test --test-concurrency=5 scripts/orbit-test-ports-tests.ts scripts/orbit-site-authorization-tests.ts scripts/orbit-site-client-register-tests.ts scripts/orbit-site-client-registration-tests.ts scripts/orbit-site-signin-e2e-tests.ts scripts/orbit-mcp-authorization-tests.ts scripts/orbit-agent-identity-tests.ts scripts/orbit-api-contract-tests.ts scripts/orbit-handle-policy-tests.ts scripts/orbit-d1-tests.ts scripts/orbit-email-tests.ts scripts/orbit-public-page-tests.ts scripts/orbit-public-repository-tests.ts scripts/orbit-slice1-tests.ts scripts/orbit-slice1-worker-tests.ts scripts/orbit-slice3-tests.ts",
4141
"test:d1:publication": "tsx --test scripts/orbit-slice4-tests.ts scripts/orbit-single-renderer-tests.ts scripts/orbit-style-scale-tests.ts",
4242
"test:d1:platform": "tsx --test scripts/orbit-slice5-tests.ts",

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

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
import assert from 'node:assert/strict';
2+
import { describe, test } from 'node:test';
3+
import {
4+
SiteActionCatalogError,
5+
normalizeSiteActionCatalog,
6+
validateOperationInput,
7+
} from '../src/server/identity/site-action-catalog';
8+
import {
9+
SITE_ACTION_SCOPE,
10+
signSiteActionToken,
11+
} from '../src/server/identity/site-action-token';
12+
import { importSiteSigningKey } from '../src/server/identity/site-id-token';
13+
14+
/* Bağlı site eylemlerinin sözleşmesi.
15+
*
16+
* Buradaki iddiaların çoğu REDDETME yolları. Sebebi şu: katalog dosyası
17+
* Orbit'e ait değil, siteye ait. Orbit onu okuyup hem ajana sunuyor hem de
18+
* kendi girdi doğrulamasında çalıştırıyor. Yani o dosyaya güvenmek, başkasının
19+
* yazdığı şemayı kendi kodumuz olarak koşturmak demek.
20+
*/
21+
22+
const gecerliKatalog = {
23+
version: 1,
24+
operations: [
25+
{
26+
operationId: 'rota.listeyeEkle',
27+
summary: 'Listeye anime ekler.',
28+
idempotent: true,
29+
input: {
30+
type: 'object',
31+
required: ['animeId', 'durum'],
32+
additionalProperties: false,
33+
properties: {
34+
animeId: { type: 'string', maxLength: 300 },
35+
durum: { enum: ['IZLIYOR', 'BITTI', 'PLANLI', 'BIRAKTI'] },
36+
puan: { type: 'integer', minimum: 1, maximum: 10 },
37+
},
38+
},
39+
},
40+
],
41+
};
42+
43+
function reddedilmeli(katalog: unknown, neden: string) {
44+
assert.throws(
45+
() => normalizeSiteActionCatalog(katalog),
46+
SiteActionCatalogError,
47+
neden,
48+
);
49+
}
50+
51+
describe('bağlı site eylem kataloğu', () => {
52+
test('geçerli katalog kabul ediliyor', () => {
53+
const parsed = normalizeSiteActionCatalog(gecerliKatalog);
54+
assert.equal(parsed.operations.length, 1);
55+
assert.equal(parsed.operations[0].operationId, 'rota.listeyeEkle');
56+
assert.equal(parsed.operations[0].idempotent, true);
57+
});
58+
59+
test('katalogdaki actionsEndpoint yok sayılıyor, hata vermiyor', () => {
60+
/* Adres kayıttan okunuyor. Kontratın erken sürümüne göre dosya hazırlamış
61+
bir site yüzünden kataloğun tamamı düşmemeli — ama o alanın gövdedeki
62+
değeri de hiçbir şeyi etkilememeli. */
63+
const parsed = normalizeSiteActionCatalog({
64+
...gecerliKatalog,
65+
actionsEndpoint: 'https://saldirgan.example/al',
66+
});
67+
assert.equal(parsed.operations.length, 1);
68+
assert.ok(!('actionsEndpoint' in parsed));
69+
});
70+
71+
test('şema dilinin dışına çıkan anahtarlar reddediliyor', () => {
72+
/* Bu üçü tesadüfen seçilmedi: `$ref` uzak adres çeker, `pattern` düzenli
73+
ifade çalıştırır (ReDoS), `allOf` iç içe geçip doğrulayıcıyı patlatır.
74+
Ajanın gördüğü şema aynı zamanda BİZİM koşturduğumuz şema. */
75+
for (const kotu of [{ $ref: 'https://x/y' }, { pattern: '(a+)+$' }, { allOf: [] }]) {
76+
reddedilmeli(
77+
{ version: 1, operations: [{ ...gecerliKatalog.operations[0], input: kotu }] },
78+
`${Object.keys(kotu)[0]} kabul edilmemeli`,
79+
);
80+
}
81+
});
82+
83+
test('aşırı derin şema reddediliyor', () => {
84+
let derin: Record<string, unknown> = { type: 'string' };
85+
for (let i = 0; i < 12; i += 1) {
86+
derin = { type: 'object', properties: { alt: derin } };
87+
}
88+
reddedilmeli(
89+
{ version: 1, operations: [{ ...gecerliKatalog.operations[0], input: derin }] },
90+
'özyinelemeli doğrulayıcı yığını taşırabilir',
91+
);
92+
});
93+
94+
test('yinelenen operationId reddediliyor', () => {
95+
reddedilmeli(
96+
{ version: 1, operations: [gecerliKatalog.operations[0], gecerliKatalog.operations[0]] },
97+
'hangi işlemin çalışacağı belirsiz kalır',
98+
);
99+
});
100+
101+
test('biçimsiz operationId reddediliyor', () => {
102+
for (const kotu of ['listeyeEkle', 'rota..ekle', 'rota.', '.ekle', 'ro ta.ekle']) {
103+
reddedilmeli(
104+
{ version: 1, operations: [{ ...gecerliKatalog.operations[0], operationId: kotu }] },
105+
`${kotu} reddedilmeli`,
106+
);
107+
}
108+
});
109+
110+
test('boş katalog ve yanlış sürüm reddediliyor', () => {
111+
reddedilmeli({ version: 1, operations: [] }, 'boş katalog');
112+
reddedilmeli({ version: 2, operations: gecerliKatalog.operations }, 'bilinmeyen sürüm');
113+
reddedilmeli(null, 'nesne olmayan katalog');
114+
});
115+
});
116+
117+
describe('girdi doğrulaması', () => {
118+
const schema = gecerliKatalog.operations[0].input;
119+
120+
test('geçerli girdi geçiyor', () => {
121+
validateOperationInput({ animeId: 'kitsu:1376', durum: 'IZLIYOR' }, schema);
122+
validateOperationInput({ animeId: 'kitsu:1376', durum: 'BITTI', puan: 9 }, schema);
123+
});
124+
125+
test('eksik zorunlu alan reddediliyor', () => {
126+
assert.throws(() => validateOperationInput({ animeId: 'x' }, schema), SiteActionCatalogError);
127+
});
128+
129+
test('tanımsız alan reddediliyor', () => {
130+
/* Ajanın uydurduğu bir alanı sessizce siteye taşımak, sitenin beklemediği
131+
bir şeyi yazmasına yol açabilir. */
132+
assert.throws(
133+
() => validateOperationInput({ animeId: 'x', durum: 'IZLIYOR', gizli: true }, schema),
134+
SiteActionCatalogError,
135+
);
136+
});
137+
138+
test('additionalProperties belirtilmemişse de kapalı', () => {
139+
const acikUnutulmus = { type: 'object', properties: { a: { type: 'string' } } };
140+
assert.throws(
141+
() => validateOperationInput({ a: 'x', b: 'y' }, acikUnutulmus),
142+
SiteActionCatalogError,
143+
'varsayılan kapalı olmalı; şemayı yazan siteye güvenip açık bırakmıyoruz',
144+
);
145+
});
146+
147+
test('enum dışı değer reddediliyor', () => {
148+
assert.throws(
149+
() => validateOperationInput({ animeId: 'x', durum: 'HERHANGI' }, schema),
150+
SiteActionCatalogError,
151+
);
152+
});
153+
154+
test('sayı sınırları uygulanıyor', () => {
155+
for (const puan of [0, 11, 5.5]) {
156+
assert.throws(
157+
() => validateOperationInput({ animeId: 'x', durum: 'BITTI', puan }, schema),
158+
SiteActionCatalogError,
159+
`puan ${puan} reddedilmeli`,
160+
);
161+
}
162+
});
163+
164+
test('metin uzunluğu uygulanıyor', () => {
165+
assert.throws(
166+
() => validateOperationInput({ animeId: 'x'.repeat(301), durum: 'BITTI' }, schema),
167+
SiteActionCatalogError,
168+
);
169+
});
170+
});
171+
172+
describe('devretme belgesi', () => {
173+
test('belge işlemi taşıyor ve aktörü kaybetmiyor', async () => {
174+
const pair = await crypto.subtle.generateKey(
175+
{ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify'],
176+
);
177+
const jwk = await crypto.subtle.exportKey('jwk', pair.privateKey);
178+
const key = await importSiteSigningKey(JSON.stringify({ ...jwk, kid: 'k1' }));
179+
180+
const now = 1_760_000_000_000;
181+
const token = await signSiteActionToken(key, {
182+
issuer: 'https://orbit.sametbasbug.dev',
183+
audience: 'orbit-equinox-rota',
184+
subject: 'pairwise-abc',
185+
actorAgentId: 'agent-1',
186+
actorHandle: 'selene',
187+
operationId: 'rota.listeyeEkle',
188+
tokenId: 'jti-1',
189+
issuedAt: now,
190+
expiresAt: now + 60_000,
191+
});
192+
193+
const [header, payload, signature] = token.split('.');
194+
const claims = JSON.parse(Buffer.from(payload, 'base64url').toString());
195+
196+
assert.equal(JSON.parse(Buffer.from(header, 'base64url').toString()).alg, 'ES256');
197+
assert.equal(claims.aud, 'orbit-equinox-rota', 'belge başka siteye taşınamamalı');
198+
assert.equal(claims.sub, 'pairwise-abc', 'site kullanıcıyı girişte gördüğü kimlikle bulmalı');
199+
assert.equal(claims.scope, SITE_ACTION_SCOPE);
200+
/* İşlem belgeye gömülü: bir işlem için alınmış belge, gövdesi
201+
değiştirilerek başka bir işleme çevrilememeli. */
202+
assert.equal(claims.operation, 'rota.listeyeEkle');
203+
/* İş insanın adına ama kimin yaptığı kaybolmuyor. */
204+
assert.equal(claims.act.sub, 'agent:agent-1');
205+
assert.equal(claims.act.handle, 'selene');
206+
assert.equal(claims.exp - claims.iat, 60, 'ömür 60 saniye');
207+
208+
const dogru = await crypto.subtle.verify(
209+
{ name: 'ECDSA', hash: 'SHA-256' },
210+
pair.publicKey,
211+
Buffer.from(signature, 'base64url'),
212+
new TextEncoder().encode(`${header}.${payload}`),
213+
);
214+
assert.ok(dogru, 'imza gerçekten doğrulanmalı; sahte bir yeşil işe yaramaz');
215+
});
216+
});

0 commit comments

Comments
 (0)