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.
@@ -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. */
@@ -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.');
@@ -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>;
@@ -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 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.', { operation: request.operation, ...(request.args.conversationId ? { conversationId: request.args.conversationId } : {}), ...(request.args.requestId ? { requestId: request.args.requestId } : {}) });
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 })))(2_000, signal);
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
  }
@@ -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>;
@@ -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';
@@ -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);
@@ -1,5 +1,5 @@
1
1
  import type { Store } from './store.js';
2
- import type { TeamActor, TeamConversation, TeamLead, TeamRequest, TeamResponse, TeamRole, TeamSnapshot } from './team-contract.js';
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;
@@ -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
- return account;
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
- const digest = hash(stable(request));
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.store.accountCanAccess(actor.name, ref.repo))
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.store.accountCanAccess(actor.name, ref.repo))
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)
@@ -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 { TeamActor, TeamExecute } from './team-contract.js';
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 = {
@@ -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.48",
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"