toolroll 0.9.48 → 0.9.49
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/CHANGELOG.md +9 -0
- package/dist/desktop-update.d.ts +2 -2
- package/dist/mcp-http.d.ts +1 -0
- package/dist/mcp-http.js +6 -4
- package/dist/mcp-oauth.d.ts +3 -0
- package/dist/mcp-oauth.js +8 -0
- package/dist/public-access.d.ts +3 -1
- package/dist/public-access.js +5 -1
- package/dist/request-budget.d.ts +30 -3
- package/dist/request-budget.js +44 -1
- package/dist/serve.js +185 -96
- package/dist/session-http.d.ts +5 -0
- package/dist/session-http.js +13 -0
- package/dist/session-server.d.ts +5 -0
- package/dist/session-server.js +2 -0
- package/dist/team-cli.js +28 -6
- package/dist/team-contract.d.ts +7 -0
- package/dist/team-contract.js +7 -0
- package/dist/team-http.d.ts +5 -0
- package/dist/team-http.js +19 -3
- package/dist/team-leads.d.ts +5 -1
- package/dist/team-leads.js +42 -4
- package/dist/team-runtime.d.ts +1 -1
- package/dist/team-runtime.js +3 -0
- package/dist/teams.d.ts +3 -0
- package/dist/teams.js +15 -0
- package/package.json +1 -1
package/dist/session-http.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
2
|
import { type SessionOperation, type SessionRequests, type SessionResponse } from './session-contract.js';
|
|
3
|
+
import { type Admission } from './request-budget.js';
|
|
3
4
|
export type SessionHttpActor = {
|
|
4
5
|
name: string;
|
|
5
6
|
generation: number;
|
|
@@ -13,6 +14,10 @@ export type SessionHttpOptions = {
|
|
|
13
14
|
/** The existing live owner's operation. Null means no owner is ready.
|
|
14
15
|
* The service rechecks actor generation and project access at execution. */
|
|
15
16
|
execute: SessionHttpExecute | null;
|
|
17
|
+
/** The bearer request's budget by source (request-budget.ts): charged once, before the credential is checked or a body read. */
|
|
18
|
+
admit?: (request: IncomingMessage) => Admission;
|
|
19
|
+
/** Account admission after successful authentication, before any body read or owner call. */
|
|
20
|
+
admitAuthenticated?: (request: IncomingMessage, actor: SessionHttpActor) => Admission;
|
|
16
21
|
};
|
|
17
22
|
/** Handle this API family before the browser's form parser or login redirects.
|
|
18
23
|
* This adapter dispatches at most once. It owns no catalog, database or provider. */
|
package/dist/session-http.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { SESSION_REQUEST_BYTES, SESSION_RESPONSE_BYTES, isSessionResponse, sessionDescriptor, validateSessionRequest, } from './session-contract.js';
|
|
2
|
+
import { limitWords } from './request-budget.js';
|
|
2
3
|
function readBody(request) {
|
|
3
4
|
return new Promise(resolve => {
|
|
4
5
|
let size = 0;
|
|
@@ -99,6 +100,16 @@ export async function handleSessionHttp(request, response, options) {
|
|
|
99
100
|
if (authorizationCount !== 1 || !/^Bearer (.+):(.+)$/.test(request.headers.authorization ?? '')) {
|
|
100
101
|
return reject(401, 'unauthenticated', 'Current operator bearer credentials are required.');
|
|
101
102
|
}
|
|
103
|
+
const refuseAdmission = (admitted) => {
|
|
104
|
+
if (admitted.ok)
|
|
105
|
+
return false;
|
|
106
|
+
if (admitted.status === 503)
|
|
107
|
+
return reject(503, 'limits-unavailable', 'Request limits could not be checked; nothing ran. Try again shortly.');
|
|
108
|
+
response.setHeader('retry-after', String(admitted.retryAfter));
|
|
109
|
+
return reject(429, 'rate-limited', limitWords(admitted.limit, admitted.retryAfter));
|
|
110
|
+
};
|
|
111
|
+
if (options.admit && refuseAdmission(options.admit(request)))
|
|
112
|
+
return true;
|
|
102
113
|
let actor;
|
|
103
114
|
try {
|
|
104
115
|
actor = await options.authenticate(request);
|
|
@@ -109,6 +120,8 @@ export async function handleSessionHttp(request, response, options) {
|
|
|
109
120
|
if (actor === null || typeof actor.name !== 'string' || !actor.name.trim() || !Number.isSafeInteger(actor.generation) || actor.generation < 1) {
|
|
110
121
|
return reject(401, 'unauthenticated', 'Current operator bearer credentials are required.');
|
|
111
122
|
}
|
|
123
|
+
if (options.admitAuthenticated && refuseAdmission(options.admitAuthenticated(request, actor)))
|
|
124
|
+
return true;
|
|
112
125
|
const media = request.headers['content-type']?.split(';')[0]?.trim().toLowerCase();
|
|
113
126
|
if (media !== 'application/json')
|
|
114
127
|
return reject(415, 'unsupported-media-type', 'Send the session request as application/json.');
|
package/dist/session-server.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
2
|
import type { Store } from './store.js';
|
|
3
3
|
import type { CodingWorkspace } from './coding-workspace.js';
|
|
4
|
+
import { type SessionHttpActor } from './session-http.js';
|
|
5
|
+
import type { Admission } from './request-budget.js';
|
|
4
6
|
/** Bind the transport to createDecisionServer's existing catalog owner and
|
|
5
7
|
* live project admission. No cookie, coordinator token or caller-supplied actor
|
|
6
8
|
* can acquire installation-wide native coding authority through this adapter. */
|
|
@@ -9,4 +11,7 @@ export declare function createSessionEndpoint(options: {
|
|
|
9
11
|
workspace: CodingWorkspace | null;
|
|
10
12
|
projects: () => readonly string[];
|
|
11
13
|
projectAllowed: (repo: string) => boolean;
|
|
14
|
+
/** The request's source budget (request-budget.ts), charged once before the password is checked. */
|
|
15
|
+
admit?: (request: IncomingMessage) => Admission;
|
|
16
|
+
admitAuthenticated?: (request: IncomingMessage, actor: SessionHttpActor) => Admission;
|
|
12
17
|
}): (request: IncomingMessage, response: ServerResponse) => Promise<boolean>;
|
package/dist/session-server.js
CHANGED
|
@@ -21,6 +21,8 @@ export function createSessionEndpoint(options) {
|
|
|
21
21
|
},
|
|
22
22
|
});
|
|
23
23
|
return (request, response) => handleSessionHttp(request, response, {
|
|
24
|
+
...(options.admit === undefined ? {} : { admit: options.admit }),
|
|
25
|
+
...(options.admitAuthenticated === undefined ? {} : { admitAuthenticated: options.admitAuthenticated }),
|
|
24
26
|
authenticate: request => {
|
|
25
27
|
const bearer = /^Bearer (.+):(.+)$/.exec(request.headers.authorization ?? '');
|
|
26
28
|
if (!bearer)
|
package/dist/team-cli.js
CHANGED
|
@@ -41,6 +41,14 @@ existing local commands. Credentials stay in a private file, never in URLs or ar
|
|
|
41
41
|
Reads and attachment do not start model work. Server permissions remain authoritative.`;
|
|
42
42
|
export class UsageError extends Error {
|
|
43
43
|
}
|
|
44
|
+
/** Transport metadata stays local to the CLI; only an actual HTTP 429 permits a follower read retry. */
|
|
45
|
+
const RETRY_AFTER_MS = Symbol('retry-after-ms');
|
|
46
|
+
function retryDelay(header) {
|
|
47
|
+
const raw = header?.trim() ?? '';
|
|
48
|
+
const delay = /^\d+$/.test(raw) ? Number(raw) * 1000
|
|
49
|
+
: /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun), /.test(raw) ? Date.parse(raw) - Date.now() : NaN;
|
|
50
|
+
return Number.isFinite(delay) && delay >= 0 ? Math.max(1000, Math.min(300_000, delay)) : 5000;
|
|
51
|
+
}
|
|
44
52
|
const value = (flags, name) => typeof flags[name] === 'string' ? flags[name] : '';
|
|
45
53
|
const required = (flags, name) => { const result = value(flags, name); if (!result)
|
|
46
54
|
throw new UsageError(`--${name} is required.`); return result; };
|
|
@@ -244,13 +252,18 @@ async function responseBody(response) {
|
|
|
244
252
|
function failure(code, message, result) { return { version: 1, ok: false, code, message, ...(result === undefined ? {} : { result }) }; }
|
|
245
253
|
async function perform(profile, request, options) {
|
|
246
254
|
const reading = ['list', 'show'].includes(request.operation);
|
|
247
|
-
const
|
|
255
|
+
const receipt = { operation: request.operation, ...(request.args.conversationId ? { conversationId: request.args.conversationId } : {}), ...(request.args.requestId ? { requestId: request.args.requestId } : {}) };
|
|
256
|
+
const unconfirmed = () => failure(reading ? 'service-unavailable' : 'delivery-unconfirmed', reading ? 'The service response could not be read.' : 'The response could not be confirmed. Inspect saved messages before continuing; this request was not retried.', receipt);
|
|
248
257
|
try {
|
|
249
258
|
const response = await (options.fetch ?? fetch)(`${profile.origin}/api/team`, { method: 'POST', redirect: 'manual', credentials: 'omit', signal: options.signal ? AbortSignal.any([options.signal, AbortSignal.timeout(30_000)]) : AbortSignal.timeout(30_000), headers: { authorization: bearerOf(profile), 'content-type': 'application/json', accept: 'application/json' }, body: JSON.stringify(request) });
|
|
250
259
|
if (response.status >= 300 && response.status < 400) {
|
|
251
260
|
await response.body?.cancel();
|
|
252
261
|
return unconfirmed();
|
|
253
262
|
}
|
|
263
|
+
if (response.status === 429) {
|
|
264
|
+
await response.body?.cancel();
|
|
265
|
+
return { ...failure('rate-limited', reading ? 'Request limit reached. Try again shortly.' : 'Request limit reached. Inspect saved messages before continuing; this request was not retried.', receipt), [RETRY_AFTER_MS]: retryDelay(response.headers.get('retry-after')) };
|
|
266
|
+
}
|
|
254
267
|
const payload = await responseBody(response);
|
|
255
268
|
if (!record(payload) || payload.version !== 1 || typeof payload.ok !== 'boolean' || typeof payload.code !== 'string' || typeof payload.message !== 'string' || payload.snapshot !== undefined && !isSnapshot(payload.snapshot) || payload.ok && !response.ok)
|
|
256
269
|
return unconfirmed();
|
|
@@ -396,7 +409,7 @@ export async function maybeRunTeamCommand(argv, write, options = {}) {
|
|
|
396
409
|
if (!profile || !Object.hasOwn(saved.profiles, profileName))
|
|
397
410
|
throw new UsageError('No saved connection. Use connect with your individual sign-in first.');
|
|
398
411
|
secret = profile.token;
|
|
399
|
-
const call = (operation, args) => perform(profile, { operation, args }, options);
|
|
412
|
+
const call = (operation, args, signal) => perform(profile, { operation, args }, { ...options, ...(signal === undefined ? {} : { signal }) });
|
|
400
413
|
if (command === 'chat' || command === 'brief')
|
|
401
414
|
return await chat(command, flags, profile.account, call, emit, options, stderr);
|
|
402
415
|
let operation, args = {};
|
|
@@ -476,8 +489,8 @@ async function chat(command, flags, account, call, emit, options, stderr) {
|
|
|
476
489
|
throw new UsageError('--terms-digest requires --authorize.');
|
|
477
490
|
if (command === 'chat' && requestId && !flags.say)
|
|
478
491
|
throw new UsageError('Use brief --request-id to inspect a previous message.');
|
|
479
|
-
const show = async () => {
|
|
480
|
-
const reply = await call('show', { conversationId });
|
|
492
|
+
const show = async (signal) => {
|
|
493
|
+
const reply = await call('show', { conversationId }, signal);
|
|
481
494
|
if (reply.ok && (!reply.snapshot || reply.snapshot.selected?.id !== conversationId || leadId && reply.snapshot.selected.leadId !== leadId))
|
|
482
495
|
return failure('conversation-mismatch', 'The service returned a different conversation or lead. Nothing was sent.');
|
|
483
496
|
return reply;
|
|
@@ -577,22 +590,31 @@ async function chat(command, flags, account, call, emit, options, stderr) {
|
|
|
577
590
|
};
|
|
578
591
|
let pollResult = 0;
|
|
579
592
|
const poll = async () => {
|
|
593
|
+
let waitMs = 2_000;
|
|
580
594
|
while (!signal.aborted) {
|
|
581
595
|
try {
|
|
582
|
-
await (options.sleep ?? ((ms, abort) => delay(ms, undefined, { signal: abort })))(
|
|
596
|
+
await (options.sleep ?? ((ms, abort) => delay(ms, undefined, { signal: abort })))(waitMs, signal);
|
|
583
597
|
}
|
|
584
598
|
catch {
|
|
585
599
|
break;
|
|
586
600
|
}
|
|
587
601
|
if (signal.aborted)
|
|
588
602
|
break;
|
|
589
|
-
const next = await show();
|
|
603
|
+
const next = await show(signal);
|
|
604
|
+
if (signal.aborted)
|
|
605
|
+
break;
|
|
606
|
+
// Only this follower's read poll retries. Sends, authorizations and uncertain deliveries are never replayed.
|
|
607
|
+
if (!next.ok && next[RETRY_AFTER_MS] !== undefined) {
|
|
608
|
+
waitMs = next[RETRY_AFTER_MS];
|
|
609
|
+
continue;
|
|
610
|
+
}
|
|
590
611
|
if (!next.ok) {
|
|
591
612
|
emit(next);
|
|
592
613
|
pollResult = 1;
|
|
593
614
|
controller.abort();
|
|
594
615
|
break;
|
|
595
616
|
}
|
|
617
|
+
waitMs = 2_000;
|
|
596
618
|
current = next;
|
|
597
619
|
updated(next);
|
|
598
620
|
}
|
package/dist/team-contract.d.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import type { WorkIndexPage } from './work-index.js';
|
|
2
2
|
import type { BrowserActionCard } from './browser-workspace.js';
|
|
3
|
+
import type { Principal } from './operate-remote.js';
|
|
3
4
|
/** Shared central-team transport contract. Requests never establish actor identity. */
|
|
4
5
|
export type TeamActor = {
|
|
5
6
|
name: string;
|
|
6
7
|
generation: number;
|
|
8
|
+
/** Set when an `so_` API token signed in: its scope and project limit narrow every check, never widen one. */
|
|
9
|
+
principal?: Principal;
|
|
7
10
|
};
|
|
8
11
|
export type TeamRole = 'viewer' | 'contributor' | 'manager';
|
|
9
12
|
export type TeamLead = {
|
|
@@ -81,6 +84,10 @@ export type TeamSnapshot = {
|
|
|
81
84
|
};
|
|
82
85
|
export declare const TEAM_OPERATIONS: readonly ['list', 'show', 'create-lead', 'update-lead', 'create-conversation', 'member', 'send', 'edit', 'withdraw', 'read', 'authorize', 'follow', 'stop', 'transfer'];
|
|
83
86
|
export type TeamOperation = typeof TEAM_OPERATIONS[number];
|
|
87
|
+
/** Whether each operation writes anything (a read receipt included). Exhaustive, so a new operation can't default to read. */
|
|
88
|
+
export declare const TEAM_MUTATIONS: Readonly<Record<TeamOperation, boolean>>;
|
|
89
|
+
/** Whether this actor may run `operation`: a read-scope token never writes. */
|
|
90
|
+
export declare const teamScopeAllows: (actor: TeamActor, operation: TeamOperation) => boolean;
|
|
84
91
|
export type TeamRequest = {
|
|
85
92
|
operation: TeamOperation;
|
|
86
93
|
args: Record<string, unknown>;
|
package/dist/team-contract.js
CHANGED
|
@@ -1 +1,8 @@
|
|
|
1
1
|
export const TEAM_OPERATIONS = ['list', 'show', 'create-lead', 'update-lead', 'create-conversation', 'member', 'send', 'edit', 'withdraw', 'read', 'authorize', 'follow', 'stop', 'transfer'];
|
|
2
|
+
/** Whether each operation writes anything (a read receipt included). Exhaustive, so a new operation can't default to read. */
|
|
3
|
+
export const TEAM_MUTATIONS = {
|
|
4
|
+
list: false, show: false, 'create-lead': true, 'update-lead': true, 'create-conversation': true, member: true, send: true, edit: true,
|
|
5
|
+
withdraw: true, read: true, authorize: true, follow: true, stop: true, transfer: true,
|
|
6
|
+
};
|
|
7
|
+
/** Whether this actor may run `operation`: a read-scope token never writes. */
|
|
8
|
+
export const teamScopeAllows = (actor, operation) => !TEAM_MUTATIONS[operation] || actor.principal === undefined || actor.principal.scope === 'act';
|
package/dist/team-http.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
2
|
import { type TeamActor, type TeamExecute } from './team-contract.js';
|
|
3
|
+
import { type Admission } from './request-budget.js';
|
|
3
4
|
export declare const TEAM_REQUEST_BYTES: number;
|
|
4
5
|
export type TeamHttpOptions = {
|
|
5
6
|
authenticate: (request: IncomingMessage) => TeamActor | null | Promise<TeamActor | null>;
|
|
@@ -9,6 +10,10 @@ export type TeamHttpOptions = {
|
|
|
9
10
|
execute: TeamExecute;
|
|
10
11
|
/** Current, authorized event cursor. null means the audience is no longer available. */
|
|
11
12
|
cursor?: (actor: TeamActor, conversationId?: string) => number | null;
|
|
13
|
+
/** Source admission before credential verification. Streams are charged once when opened. */
|
|
14
|
+
admit?: (request: IncomingMessage) => Admission;
|
|
15
|
+
/** Proved account/token admission, before reading the body or executing any operation. */
|
|
16
|
+
admitAuthenticated?: (request: IncomingMessage, actor: TeamActor) => Admission;
|
|
12
17
|
streamIntervalMs?: number;
|
|
13
18
|
streams?: Set<ServerResponse>;
|
|
14
19
|
};
|
package/dist/team-http.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { TEAM_OPERATIONS } from './team-contract.js';
|
|
1
|
+
import { TEAM_OPERATIONS, teamScopeAllows } from './team-contract.js';
|
|
2
|
+
import { limitWords } from './request-budget.js';
|
|
2
3
|
export const TEAM_REQUEST_BYTES = 64 * 1024;
|
|
3
4
|
const failure = (code, message) => ({ version: 1, ok: false, code, message });
|
|
4
|
-
function send(response, status, value) {
|
|
5
|
+
function send(response, status, value, headers = {}) {
|
|
5
6
|
if (response.destroyed || response.writableEnded)
|
|
6
7
|
return;
|
|
7
|
-
response.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' });
|
|
8
|
+
response.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff', ...headers });
|
|
8
9
|
response.end(JSON.stringify(value));
|
|
9
10
|
}
|
|
10
11
|
async function body(request) {
|
|
@@ -40,6 +41,17 @@ export async function handleTeamHttp(request, response, options) {
|
|
|
40
41
|
const count = request.rawHeaders.filter((value, index) => index % 2 === 0 && value.toLowerCase() === 'authorization').length;
|
|
41
42
|
if (count > 1)
|
|
42
43
|
return reject(400, 'ambiguous-credentials', 'Send one authorization header.');
|
|
44
|
+
const refuseAdmission = (admitted) => {
|
|
45
|
+
if (admitted.ok)
|
|
46
|
+
return false;
|
|
47
|
+
if (admitted.status === 503)
|
|
48
|
+
return reject(503, 'limits-unavailable', 'Request limits could not be checked; nothing ran. Try again shortly.');
|
|
49
|
+
send(response, 429, failure('rate-limited', limitWords(admitted.limit, admitted.retryAfter)), { 'retry-after': String(admitted.retryAfter) });
|
|
50
|
+
request.resume();
|
|
51
|
+
return true;
|
|
52
|
+
};
|
|
53
|
+
if (options.admit && refuseAdmission(options.admit(request)))
|
|
54
|
+
return true;
|
|
43
55
|
let actor;
|
|
44
56
|
try {
|
|
45
57
|
actor = await options.authenticate(request);
|
|
@@ -49,6 +61,8 @@ export async function handleTeamHttp(request, response, options) {
|
|
|
49
61
|
}
|
|
50
62
|
if (!actor)
|
|
51
63
|
return reject(401, 'unauthenticated', 'Sign in to continue.');
|
|
64
|
+
if (options.admitAuthenticated && refuseAdmission(options.admitAuthenticated(request, actor)))
|
|
65
|
+
return true;
|
|
52
66
|
const conversationId = url.searchParams.get('conversation') ?? undefined;
|
|
53
67
|
if (url.pathname === '/api/team/events') {
|
|
54
68
|
if (request.method !== 'GET')
|
|
@@ -133,6 +147,8 @@ export async function handleTeamHttp(request, response, options) {
|
|
|
133
147
|
return reject(405, 'method-not-allowed', 'Use GET or POST.');
|
|
134
148
|
if (!options.revalidate(request, actor))
|
|
135
149
|
return reject(401, 'unauthenticated', 'Sign in to continue.');
|
|
150
|
+
if (!teamScopeAllows(actor, input.operation))
|
|
151
|
+
return reject(403, 'read-only', 'Your token reads only. Use an act token for this.');
|
|
136
152
|
try {
|
|
137
153
|
const result = await options.execute(actor, input);
|
|
138
154
|
send(response, result.ok ? 200 : /forbidden|access|unauthor|member/.test(result.code) ? 403 : 409, result);
|
package/dist/team-leads.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Store } from './store.js';
|
|
2
|
-
import type
|
|
2
|
+
import { type TeamActor, type TeamConversation, type TeamLead, type TeamRequest, type TeamResponse, type TeamRole, type TeamSnapshot } from './team-contract.js';
|
|
3
3
|
export type TeamAccess = {
|
|
4
4
|
conversation: TeamConversation;
|
|
5
5
|
lead: TeamLead;
|
|
@@ -25,7 +25,11 @@ export declare class TeamLeads {
|
|
|
25
25
|
readonly enrolled: () => readonly string[];
|
|
26
26
|
constructor(store: Store, enrolled: () => readonly string[]);
|
|
27
27
|
private get db();
|
|
28
|
+
/** The account as this actor may use it now. An API token's principal narrows it: a read token acts as a viewer, and a
|
|
29
|
+
* project-limited token sees only its projects. Every role and project check below reads this, never the raw row. */
|
|
28
30
|
private account;
|
|
31
|
+
/** Whether the actor may use `repo` now: their account's access, narrowed by any token limit. */
|
|
32
|
+
private reaches;
|
|
29
33
|
private leadRow;
|
|
30
34
|
private leadProjects;
|
|
31
35
|
private leadOf;
|
package/dist/team-leads.js
CHANGED
|
@@ -2,6 +2,8 @@ import { createHash, randomUUID } from 'node:crypto';
|
|
|
2
2
|
import { scanForSecrets } from './evidence.js';
|
|
3
3
|
import { ceilingDigestOf } from './principal.js';
|
|
4
4
|
import { normalizeProjectAccess, projectAccessAllows, readProjectAccess } from './project-access.js';
|
|
5
|
+
import { tokenProjects } from './api-tokens.js';
|
|
6
|
+
import { teamScopeAllows } from './team-contract.js';
|
|
5
7
|
class TeamRefusal extends Error {
|
|
6
8
|
code;
|
|
7
9
|
constructor(code, message) {
|
|
@@ -53,11 +55,22 @@ export class TeamLeads {
|
|
|
53
55
|
this.enrolled = enrolled;
|
|
54
56
|
}
|
|
55
57
|
get db() { return this.store.handle; }
|
|
58
|
+
/** The account as this actor may use it now. An API token's principal narrows it: a read token acts as a viewer, and a
|
|
59
|
+
* project-limited token sees only its projects. Every role and project check below reads this, never the raw row. */
|
|
56
60
|
account(actor) {
|
|
57
61
|
const account = this.store.accountOf(actor.name);
|
|
58
62
|
if (account === null || account.revokedAt !== null || account.generation !== actor.generation)
|
|
59
63
|
refuse('signed-out', 'Sign in again to use this conversation.');
|
|
60
|
-
|
|
64
|
+
const principal = actor.principal;
|
|
65
|
+
if (principal === undefined)
|
|
66
|
+
return account;
|
|
67
|
+
if (principal.account !== actor.name || principal.generation !== actor.generation)
|
|
68
|
+
refuse('signed-out', 'Sign in again to use this conversation.');
|
|
69
|
+
return { ...account, role: principal.scope === 'act' ? account.role : 'viewer', projects: tokenProjects(account.projects, principal.projects) };
|
|
70
|
+
}
|
|
71
|
+
/** Whether the actor may use `repo` now: their account's access, narrowed by any token limit. */
|
|
72
|
+
reaches(actor, repo) {
|
|
73
|
+
return this.store.accountCanAccess(actor.name, repo) && projectAccessAllows(this.account(actor).projects, repo);
|
|
61
74
|
}
|
|
62
75
|
leadRow(id) {
|
|
63
76
|
const row = this.db.prepare('SELECT * FROM team_lead WHERE id=?').get(id);
|
|
@@ -124,6 +137,28 @@ export class TeamLeads {
|
|
|
124
137
|
const { lead } = this.access(actor, conversationId);
|
|
125
138
|
return Number(this.db.prepare('SELECT COALESCE(MAX(id),0) AS n FROM team_event WHERE (conversation=? OR (conversation IS NULL AND lead=?))').get(conversationId, lead.id)?.['n'] ?? 0);
|
|
126
139
|
}
|
|
140
|
+
// A project-limited token hears only of the leads and conversations it can open, never activity beyond its projects.
|
|
141
|
+
if (actor.principal?.projects != null) {
|
|
142
|
+
const account = this.account(actor);
|
|
143
|
+
const leads = this.db.prepare('SELECT l.id FROM team_lead l JOIN team_lead_member m ON m.lead=l.id WHERE m.account=? AND m.active=1').all(actor.name).map(r => String(r['id']))
|
|
144
|
+
.filter(id => this.leadProjects(id).every(p => projectAccessAllows(account.projects, p) && this.enrolled().includes(p)));
|
|
145
|
+
const conversations = this.db.prepare('SELECT conversation FROM team_participant WHERE account=? AND active=1').all(actor.name).map(r => String(r['conversation']))
|
|
146
|
+
.filter(id => { try {
|
|
147
|
+
this.access(actor, id);
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
catch (e) {
|
|
151
|
+
if (e instanceof TeamRefusal)
|
|
152
|
+
return false;
|
|
153
|
+
throw e;
|
|
154
|
+
} });
|
|
155
|
+
let n = 0;
|
|
156
|
+
for (const id of leads)
|
|
157
|
+
n = Math.max(n, Number(this.db.prepare('SELECT COALESCE(MAX(id),0) AS n FROM team_event WHERE lead=? AND conversation IS NULL').get(id)?.['n'] ?? 0));
|
|
158
|
+
for (const id of conversations)
|
|
159
|
+
n = Math.max(n, Number(this.db.prepare('SELECT COALESCE(MAX(id),0) AS n FROM team_event WHERE conversation=?').get(id)?.['n'] ?? 0));
|
|
160
|
+
return n;
|
|
161
|
+
}
|
|
127
162
|
return Number(this.db.prepare(`SELECT COALESCE(MAX(e.id),0) AS n FROM team_event e JOIN team_lead_member m ON m.lead=e.lead AND m.account=? AND m.active=1
|
|
128
163
|
WHERE e.conversation IS NULL OR EXISTS(SELECT 1 FROM team_participant p JOIN team_conversation c ON c.id=p.conversation WHERE p.conversation=e.conversation AND p.account=? AND p.active=1 AND (c.visibility='team' OR c.created_by=?))`).get(actor.name, actor.name, actor.name)?.['n'] ?? 0);
|
|
129
164
|
}
|
|
@@ -159,9 +194,12 @@ export class TeamLeads {
|
|
|
159
194
|
try {
|
|
160
195
|
const result = this.store.transact(() => {
|
|
161
196
|
this.account(actor);
|
|
197
|
+
if (!teamScopeAllows(actor, request.operation))
|
|
198
|
+
refuse('read-only', 'Your token reads only. Use an act token for this.');
|
|
162
199
|
const requestId = request.args['requestId'];
|
|
163
200
|
const key = requestId === undefined ? null : str(requestId, 'Request ID', 128);
|
|
164
|
-
|
|
201
|
+
// A token's scope and project limit are part of what was asked: a narrower token never replays a wider one's receipt.
|
|
202
|
+
const digest = hash(stable(actor.principal === undefined ? request : { request, scope: actor.principal.scope, projects: actor.principal.projects }));
|
|
165
203
|
if (key) {
|
|
166
204
|
const seen = this.db.prepare('SELECT payload_hash,response_json FROM team_request WHERE account=? AND generation=? AND request_id=?').get(actor.name, actor.generation, key);
|
|
167
205
|
if (seen) {
|
|
@@ -527,7 +565,7 @@ export class TeamLeads {
|
|
|
527
565
|
if (ancestry.problem !== null)
|
|
528
566
|
return false;
|
|
529
567
|
const ref = this.store.lookupRef(ancestry.chain[ancestry.chain.length - 1] ?? taskId);
|
|
530
|
-
if (!ref || ref.repo === null || !lead.projects.includes(ref.repo) || !this.
|
|
568
|
+
if (!ref || ref.repo === null || !lead.projects.includes(ref.repo) || !this.reaches(actor, ref.repo))
|
|
531
569
|
return false;
|
|
532
570
|
if (conversationId) {
|
|
533
571
|
const access = this.access(actor, conversationId, 'contributor');
|
|
@@ -546,7 +584,7 @@ export class TeamLeads {
|
|
|
546
584
|
if (ancestry.problem !== null)
|
|
547
585
|
refuse('invalid-task', 'Task history needs inspection before ownership can change.');
|
|
548
586
|
const ref = this.store.lookupRef(ancestry.chain[ancestry.chain.length - 1] ?? taskId);
|
|
549
|
-
if (!ref || ref.repo === null || !target.projects.includes(ref.repo) || !this.
|
|
587
|
+
if (!ref || ref.repo === null || !target.projects.includes(ref.repo) || !this.reaches(actor, ref.repo))
|
|
550
588
|
refuse('forbidden', 'The receiving lead must have access to this task’s project.');
|
|
551
589
|
const old = this.taskOwner(taskId), expected = integer(a['expectedRevision']);
|
|
552
590
|
if ((old?.revision ?? 0) !== expected)
|
package/dist/team-runtime.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Store } from './store.js';
|
|
2
2
|
import type { WorkspaceRevision } from './workspace-revision.js';
|
|
3
3
|
import { TeamLeads } from './team-leads.js';
|
|
4
|
-
import type
|
|
4
|
+
import { type TeamActor, type TeamExecute } from './team-contract.js';
|
|
5
5
|
import { type MateTurnInput } from './mate.js';
|
|
6
6
|
import { type TeamChatProviderResolver } from './team-chat-authorization.js';
|
|
7
7
|
export type TeamRuntimeOptions = {
|
package/dist/team-runtime.js
CHANGED
|
@@ -4,6 +4,7 @@ import { createHash, randomUUID } from 'node:crypto';
|
|
|
4
4
|
import { deliverTeamUpdates, startTeamUpdates } from './team-updates.js';
|
|
5
5
|
import { workIndexPage } from './work-index.js';
|
|
6
6
|
import { TeamLeads } from './team-leads.js';
|
|
7
|
+
import { teamScopeAllows } from './team-contract.js';
|
|
7
8
|
import { verifyApproverStanding } from './principal.js';
|
|
8
9
|
import { credentialKeyOf, isDirectChatProvider, priceForConfig, subscriptionCredentialKey, mateWorstCaseForPrice } from './converse.js';
|
|
9
10
|
import { runMateTurn } from './mate.js';
|
|
@@ -188,6 +189,8 @@ export function createTeamRuntime(options) {
|
|
|
188
189
|
}
|
|
189
190
|
const execute = async (actor, request) => {
|
|
190
191
|
try {
|
|
192
|
+
if (!teamScopeAllows(actor, request.operation))
|
|
193
|
+
return response(false, 'read-only', 'Your token reads only. Use an act token for this.');
|
|
191
194
|
if (request.operation === 'authorize')
|
|
192
195
|
return authorize(actor, request.args);
|
|
193
196
|
if (request.operation === 'send' || request.operation === 'follow' && request.args['enabled'] === true) {
|
package/dist/teams.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { IncomingMessage, ServerResponse } from "node:http";
|
|
|
2
2
|
import { chatObject as object } from "./chat-delivery.js";
|
|
3
3
|
import { type TeamsChatOptions } from "./teams-chat.js";
|
|
4
4
|
import type { Store } from "./store.js";
|
|
5
|
+
import { type Admission } from "./request-budget.js";
|
|
5
6
|
export declare const TEAMS_ACTIVITY_BYTES: number;
|
|
6
7
|
export declare const TEAMS_MESSAGES_PATH = "/teams/messages";
|
|
7
8
|
/** The receiver: prove the Bot Framework token for this app, save the activity, acknowledge. Nothing runs here. */
|
|
@@ -10,6 +11,8 @@ export declare function handleTeamsHttp(request: IncomingMessage, response: Serv
|
|
|
10
11
|
dir: string;
|
|
11
12
|
fetcher?: typeof fetch;
|
|
12
13
|
clock?: () => Date;
|
|
14
|
+
admitSource: (request: IncomingMessage) => Admission;
|
|
15
|
+
admitTenant: (tenant: string) => Admission;
|
|
13
16
|
}): Promise<boolean>;
|
|
14
17
|
/** The worker-side loop: a lease per installation, one pass a second. */
|
|
15
18
|
export declare function followTeams(options: Omit<TeamsChatOptions, "identity" | "api" | "owner" | "current"> & {
|
package/dist/teams.js
CHANGED
|
@@ -8,6 +8,7 @@ import { ChatState } from "./chat-delivery-state.js";
|
|
|
8
8
|
import { chatObject as object } from "./chat-delivery.js";
|
|
9
9
|
import { loadTeamsCredentials, teamsAccessToken, teamsApi, verifyTeamsToken, TeamsError } from "./teams-api.js";
|
|
10
10
|
import { deliverTeamsPart, planTeamsNotifications, planTeamsRooms, processTeamsEvent, receiveTeams } from "./teams-chat.js";
|
|
11
|
+
import { limitWords } from "./request-budget.js";
|
|
11
12
|
export const TEAMS_ACTIVITY_BYTES = 64 * 1024;
|
|
12
13
|
export const TEAMS_MESSAGES_PATH = "/teams/messages";
|
|
13
14
|
/** The receiver: prove the Bot Framework token for this app, save the activity, acknowledge. Nothing runs here. */
|
|
@@ -18,12 +19,26 @@ export async function handleTeamsHttp(request, response, options) {
|
|
|
18
19
|
const reply = (status, body = "{}") => { response.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" }); response.end(body); request.resume(); return true; };
|
|
19
20
|
if (request.method !== "POST")
|
|
20
21
|
return reply(405);
|
|
22
|
+
const refuseAdmission = (admitted) => {
|
|
23
|
+
if (admitted.ok)
|
|
24
|
+
return false;
|
|
25
|
+
if (admitted.status === 429)
|
|
26
|
+
response.setHeader("retry-after", String(admitted.retryAfter));
|
|
27
|
+
return reply(admitted.status, JSON.stringify({ error: admitted.status === 429 ? "rate-limited" : "limits-unavailable",
|
|
28
|
+
message: admitted.status === 429 ? limitWords(admitted.limit, admitted.retryAfter) : "Request limits could not be checked; nothing was saved." }));
|
|
29
|
+
};
|
|
30
|
+
// Source admission precedes JWT verification (including key fetches). Only proved app credentials consume
|
|
31
|
+
// the configured tenant's allowance; an untrusted activity cannot select or evade that allowance.
|
|
32
|
+
if (refuseAdmission(options.admitSource(request)))
|
|
33
|
+
return true;
|
|
21
34
|
const credentials = loadTeamsCredentials(options.dir);
|
|
22
35
|
if (credentials === null)
|
|
23
36
|
return reply(404);
|
|
24
37
|
const claims = await verifyTeamsToken(request.headers.authorization, credentials.app, options.fetcher ?? fetch);
|
|
25
38
|
if (claims === null)
|
|
26
39
|
return reply(401);
|
|
40
|
+
if (refuseAdmission(options.admitTenant(credentials.tenant)))
|
|
41
|
+
return true;
|
|
27
42
|
if (Number(request.headers["content-length"] ?? 0) > TEAMS_ACTIVITY_BYTES)
|
|
28
43
|
return reply(413);
|
|
29
44
|
let raw = "";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "toolroll",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.49",
|
|
4
4
|
"description": "A control plane for unattended coding agents — queue tasks, walk away, come back to pull requests. Agents build in leased worktrees, never touch your default branch, and interrupt you only for decisions that need a human.",
|
|
5
5
|
"directories": {
|
|
6
6
|
"doc": "docs"
|