@plosson/agentio 0.8.1 → 0.8.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/server/oauth.test.ts +23 -0
- package/src/server/oauth.ts +4 -1
- package/src/server/setup-page.test.ts +51 -3
- package/src/server/setup-page.ts +38 -5
package/package.json
CHANGED
package/src/server/oauth.test.ts
CHANGED
|
@@ -809,6 +809,29 @@ describe('handleAuthorizePost — happy path', () => {
|
|
|
809
809
|
expect(url.searchParams.get('state')).toBe('STATE_XYZ');
|
|
810
810
|
});
|
|
811
811
|
|
|
812
|
+
test('api_key with trailing whitespace still authorizes (trim)', async () => {
|
|
813
|
+
// Regression: operators often paste the key with a trailing newline
|
|
814
|
+
// from terminals. Byte-for-byte compare would silently reject.
|
|
815
|
+
const ctx = makeCtx();
|
|
816
|
+
const clientId = await registerTestClient(ctx);
|
|
817
|
+
for (const suffix of [' ', '\n', '\r\n', ' \t']) {
|
|
818
|
+
const res = await handleAuthorizePost(
|
|
819
|
+
authorizePost({
|
|
820
|
+
client_id: clientId,
|
|
821
|
+
redirect_uri: 'http://localhost:53682/callback',
|
|
822
|
+
response_type: 'code',
|
|
823
|
+
code_challenge: 'CHAL',
|
|
824
|
+
code_challenge_method: 'S256',
|
|
825
|
+
state: '',
|
|
826
|
+
scope: 'gchat:default',
|
|
827
|
+
api_key: TEST_API_KEY + suffix,
|
|
828
|
+
}),
|
|
829
|
+
ctx
|
|
830
|
+
);
|
|
831
|
+
expect(res.status).toBe(302);
|
|
832
|
+
}
|
|
833
|
+
});
|
|
834
|
+
|
|
812
835
|
test('issued code is consumable from the store', async () => {
|
|
813
836
|
const ctx = makeCtx();
|
|
814
837
|
const clientId = await registerTestClient(ctx);
|
package/src/server/oauth.ts
CHANGED
|
@@ -539,7 +539,10 @@ export async function handleAuthorizePost(
|
|
|
539
539
|
|
|
540
540
|
const params = result.params;
|
|
541
541
|
const client = ctx.oauthStore.findClient(params.clientId);
|
|
542
|
-
|
|
542
|
+
// Trim pasted whitespace — same UX issue as setup-page.ts: a stray
|
|
543
|
+
// newline or trailing space from copy-paste would otherwise fail the
|
|
544
|
+
// byte-for-byte compare with a confusing error.
|
|
545
|
+
const apiKey = (form.get('api_key') ?? '').trim();
|
|
543
546
|
|
|
544
547
|
if (!apiKey || !constantTimeEquals(apiKey, ctx.apiKey)) {
|
|
545
548
|
// Re-render the form with an error. We deliberately do NOT redirect
|
|
@@ -32,10 +32,14 @@ interface FakeProfile {
|
|
|
32
32
|
// Mutable container the mock consults on each call.
|
|
33
33
|
let currentProfiles: FakeProfile[] = [];
|
|
34
34
|
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
type ProfileFixture = string | { name: string; readOnly?: boolean };
|
|
36
|
+
|
|
37
|
+
function setProfiles(fixture: Record<string, ProfileFixture[]>): void {
|
|
38
|
+
currentProfiles = Object.entries(fixture).map(([service, entries]) => ({
|
|
37
39
|
service,
|
|
38
|
-
profiles:
|
|
40
|
+
profiles: entries.map((e) =>
|
|
41
|
+
typeof e === 'string' ? { name: e } : { name: e.name, readOnly: e.readOnly }
|
|
42
|
+
),
|
|
39
43
|
}));
|
|
40
44
|
}
|
|
41
45
|
|
|
@@ -160,6 +164,23 @@ describe('POST / — login', () => {
|
|
|
160
164
|
expect(res.headers.get('set-cookie')).toBeNull();
|
|
161
165
|
});
|
|
162
166
|
|
|
167
|
+
test('correct api_key with trailing whitespace (newline/space) → 302', async () => {
|
|
168
|
+
// Regression: operators routinely paste the key with a trailing
|
|
169
|
+
// newline from terminals. Byte-for-byte compare would silently
|
|
170
|
+
// reject an otherwise-valid key; trim makes it forgiving.
|
|
171
|
+
for (const suffix of [' ', '\n', '\r\n', ' \t']) {
|
|
172
|
+
const res = await handleRequest(
|
|
173
|
+
req('POST', '/', {
|
|
174
|
+
contentType: FORM,
|
|
175
|
+
body: formBody({ api_key: API_KEY + suffix }),
|
|
176
|
+
}),
|
|
177
|
+
ctx
|
|
178
|
+
);
|
|
179
|
+
expect(res.status).toBe(302);
|
|
180
|
+
expect(res.headers.get('set-cookie')).toContain(expectedCookieValue(API_KEY));
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
|
|
163
184
|
test('correct api_key → 302 to /, sets cookie with expected attributes', async () => {
|
|
164
185
|
const res = await handleRequest(
|
|
165
186
|
req('POST', '/', {
|
|
@@ -246,6 +267,33 @@ describe('GET / — authenticated (valid cookie)', () => {
|
|
|
246
267
|
expect(html).not.toContain('<h2>jira</h2>');
|
|
247
268
|
});
|
|
248
269
|
|
|
270
|
+
test('read-only profiles render an RO badge; writable profiles do not', async () => {
|
|
271
|
+
setProfiles({
|
|
272
|
+
gmail: [
|
|
273
|
+
'work', // writable
|
|
274
|
+
{ name: 'archive', readOnly: true },
|
|
275
|
+
],
|
|
276
|
+
});
|
|
277
|
+
const res = await handleRequest(
|
|
278
|
+
req('GET', '/', { cookie: validCookie() }),
|
|
279
|
+
ctx
|
|
280
|
+
);
|
|
281
|
+
const html = await res.text();
|
|
282
|
+
|
|
283
|
+
// Find the two profile rows and check each one independently.
|
|
284
|
+
const workRow = html.match(
|
|
285
|
+
/<label class="profile">[^<]*<input[^>]*value="gmail:work"[^>]*>[^<]*(?:<span[^<]*<\/span>)?[^<]*<\/label>/
|
|
286
|
+
);
|
|
287
|
+
const archiveRow = html.match(
|
|
288
|
+
/<label class="profile">[^<]*<input[^>]*value="gmail:archive"[^>]*>[^<]*(?:<span[^<]*<\/span>)?[^<]*<\/label>/
|
|
289
|
+
);
|
|
290
|
+
expect(workRow).not.toBeNull();
|
|
291
|
+
expect(archiveRow).not.toBeNull();
|
|
292
|
+
expect(workRow![0]).not.toContain('ro-badge');
|
|
293
|
+
expect(archiveRow![0]).toContain('ro-badge');
|
|
294
|
+
expect(archiveRow![0]).toContain('>RO<');
|
|
295
|
+
});
|
|
296
|
+
|
|
249
297
|
test('no configured profiles → empty-state message, no checkboxes', async () => {
|
|
250
298
|
setProfiles({});
|
|
251
299
|
const res = await handleRequest(
|
package/src/server/setup-page.ts
CHANGED
|
@@ -147,6 +147,24 @@ label.profile {
|
|
|
147
147
|
cursor: pointer;
|
|
148
148
|
}
|
|
149
149
|
label.profile input { margin-right: 0.5rem; }
|
|
150
|
+
.ro-badge {
|
|
151
|
+
display: inline-block;
|
|
152
|
+
margin-left: 0.5rem;
|
|
153
|
+
padding: 0 0.4rem;
|
|
154
|
+
font-size: 0.7rem;
|
|
155
|
+
font-weight: 600;
|
|
156
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
|
|
157
|
+
letter-spacing: 0.03em;
|
|
158
|
+
text-transform: uppercase;
|
|
159
|
+
color: #92400e;
|
|
160
|
+
background: #fef3c7;
|
|
161
|
+
border: 1px solid #fde68a;
|
|
162
|
+
border-radius: 4px;
|
|
163
|
+
vertical-align: 1px;
|
|
164
|
+
}
|
|
165
|
+
@media (prefers-color-scheme: dark) {
|
|
166
|
+
.ro-badge { color: #fde68a; background: #3f2e08; border-color: #5c4510; }
|
|
167
|
+
}
|
|
150
168
|
.output { display: flex; flex-direction: column; gap: 0.4rem; margin-top: 1.4rem; }
|
|
151
169
|
.output label { font-weight: 600; font-size: 0.9rem; }
|
|
152
170
|
.output .row { display: flex; gap: 0.5rem; }
|
|
@@ -186,9 +204,14 @@ ${errorBlock}
|
|
|
186
204
|
</html>`;
|
|
187
205
|
}
|
|
188
206
|
|
|
207
|
+
interface ProfileView {
|
|
208
|
+
name: string;
|
|
209
|
+
readOnly: boolean;
|
|
210
|
+
}
|
|
211
|
+
|
|
189
212
|
interface ServiceProfileList {
|
|
190
213
|
service: string;
|
|
191
|
-
profiles:
|
|
214
|
+
profiles: ProfileView[];
|
|
192
215
|
}
|
|
193
216
|
|
|
194
217
|
function profilesPageHtml(origin: string, configured: ServiceProfileList[]): string {
|
|
@@ -198,10 +221,13 @@ function profilesPageHtml(origin: string, configured: ServiceProfileList[]): str
|
|
|
198
221
|
.map((s) => {
|
|
199
222
|
const items = s.profiles
|
|
200
223
|
.map((p) => {
|
|
201
|
-
const value = `${s.service}:${p}`;
|
|
224
|
+
const value = `${s.service}:${p.name}`;
|
|
225
|
+
const badge = p.readOnly
|
|
226
|
+
? ' <span class="ro-badge" title="read-only">RO</span>'
|
|
227
|
+
: '';
|
|
202
228
|
return ` <label class="profile"><input type="checkbox" name="sp" value="${escapeHtml(
|
|
203
229
|
value
|
|
204
|
-
)}"> ${escapeHtml(p)}</label>`;
|
|
230
|
+
)}"> ${escapeHtml(p.name)}${badge}</label>`;
|
|
205
231
|
})
|
|
206
232
|
.join('\n');
|
|
207
233
|
return ` <section class="service">
|
|
@@ -314,7 +340,10 @@ async function handleSetupGet(
|
|
|
314
340
|
.filter((s) => s.profiles.length > 0)
|
|
315
341
|
.map((s) => ({
|
|
316
342
|
service: s.service,
|
|
317
|
-
profiles: s.profiles.map((p) =>
|
|
343
|
+
profiles: s.profiles.map((p) => ({
|
|
344
|
+
name: p.name,
|
|
345
|
+
readOnly: p.readOnly === true,
|
|
346
|
+
})),
|
|
318
347
|
}));
|
|
319
348
|
|
|
320
349
|
return new Response(profilesPageHtml(origin, configured), {
|
|
@@ -338,7 +367,11 @@ async function handleSetupPost(
|
|
|
338
367
|
});
|
|
339
368
|
}
|
|
340
369
|
|
|
341
|
-
|
|
370
|
+
// Trim: operators routinely paste the key with a trailing newline or
|
|
371
|
+
// space from terminals / password managers. constantTimeEquals is a
|
|
372
|
+
// byte-for-byte compare, so an extra whitespace character silently
|
|
373
|
+
// fails the login with a confusing "Invalid API key" error.
|
|
374
|
+
const apiKey = (form.get('api_key') ?? '').trim();
|
|
342
375
|
if (!apiKey || !constantTimeEquals(apiKey, ctx.apiKey)) {
|
|
343
376
|
return new Response(loginFormHtml('Invalid API key.'), {
|
|
344
377
|
status: 401,
|