toolroll 0.9.49 → 0.9.51

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.
Files changed (61) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/cli-http.js +3 -0
  3. package/dist/flow-triggers.js +7 -0
  4. package/dist/mcp-http.js +6 -11
  5. package/dist/mcp-oauth.d.ts +1 -1
  6. package/dist/mcp-oauth.js +8 -23
  7. package/dist/mcp-person.d.ts +0 -9
  8. package/dist/mcp-person.js +2 -1
  9. package/dist/operate.d.ts +1 -1
  10. package/dist/operate.js +9 -2
  11. package/dist/request-budget.d.ts +5 -2
  12. package/dist/request-budget.js +7 -3
  13. package/dist/scope.d.ts +1 -0
  14. package/dist/scope.js +19 -9
  15. package/dist/serve.d.ts +2 -605
  16. package/dist/serve.js +434 -23789
  17. package/dist/server/chat.d.ts +41 -0
  18. package/dist/server/chat.js +1458 -0
  19. package/dist/server/edge-refusal.d.ts +5 -0
  20. package/dist/server/edge-refusal.js +56 -0
  21. package/dist/server/flows.d.ts +12 -0
  22. package/dist/server/flows.js +832 -0
  23. package/dist/server/guards.d.ts +72 -0
  24. package/dist/server/guards.js +357 -0
  25. package/dist/server/handler-context.d.ts +22 -0
  26. package/dist/server/handler-context.js +1 -0
  27. package/dist/server/handler-registry.d.ts +21 -0
  28. package/dist/server/handler-registry.js +34 -0
  29. package/dist/server/pages-assets.d.ts +10 -0
  30. package/dist/server/pages-assets.js +103 -0
  31. package/dist/server/pages.d.ts +11 -0
  32. package/dist/server/pages.js +910 -0
  33. package/dist/server/people-tokens.d.ts +20 -0
  34. package/dist/server/people-tokens.js +693 -0
  35. package/dist/server/remote-hooks.d.ts +29 -0
  36. package/dist/server/remote-hooks.js +130 -0
  37. package/dist/server/remote.d.ts +59 -0
  38. package/dist/server/remote.js +200 -0
  39. package/dist/server/route-policy.d.ts +18 -0
  40. package/dist/server/route-policy.js +30 -0
  41. package/dist/server/route-table.d.ts +74 -0
  42. package/dist/server/route-table.js +350 -0
  43. package/dist/server/runtime.d.ts +280 -0
  44. package/dist/server/runtime.js +25 -0
  45. package/dist/server/settings.d.ts +22 -0
  46. package/dist/server/settings.js +2388 -0
  47. package/dist/server/shared.d.ts +3334 -0
  48. package/dist/server/shared.js +12708 -0
  49. package/dist/server/task-views.d.ts +82 -0
  50. package/dist/server/task-views.js +1099 -0
  51. package/dist/server/tasks.d.ts +87 -0
  52. package/dist/server/tasks.js +3579 -0
  53. package/dist/session-http.js +3 -0
  54. package/dist/sign-in-guard.d.ts +41 -16
  55. package/dist/sign-in-guard.js +147 -24
  56. package/dist/source-key.d.ts +2 -0
  57. package/dist/source-key.js +49 -0
  58. package/dist/team-http.d.ts +8 -1
  59. package/dist/team-http.js +9 -2
  60. package/dist/teams.js +3 -0
  61. package/package.json +1 -1
@@ -1,3 +1,4 @@
1
+ import { adapterPolicy } from "./server/route-policy.js";
1
2
  import { SESSION_REQUEST_BYTES, SESSION_RESPONSE_BYTES, isSessionResponse, sessionDescriptor, validateSessionRequest, } from './session-contract.js';
2
3
  import { limitWords } from './request-budget.js';
3
4
  function readBody(request) {
@@ -122,6 +123,8 @@ export async function handleSessionHttp(request, response, options) {
122
123
  }
123
124
  if (options.admitAuthenticated && refuseAdmission(options.admitAuthenticated(request, actor)))
124
125
  return true;
126
+ if (!adapterPolicy({ caller: "bearer", capability: "act" }, spec.mutation ? "act" : "read").ok)
127
+ return reject(403, "forbidden", "Current operator access is required.");
125
128
  const media = request.headers['content-type']?.split(';')[0]?.trim().toLowerCase();
126
129
  if (media !== 'application/json')
127
130
  return reject(415, 'unsupported-media-type', 'Send the session request as application/json.');
@@ -1,31 +1,56 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
1
2
  /**
2
- * Password guessing (v99). Five wrong passwords in a row lock that account
3
- * for 15 minutes, doubling with each further lock up to a day; a right one
4
- * clears the count. Every password check counts: signing in, a request that
5
- * carries a password, and each step-up inside the console. Unknown names
6
- * count too, so guessing names gains nothing.
7
- *
8
- * Kept in memory, per database: a restart forgives, which someone locked out
9
- * by another's guessing may need. The console reports each lock to the
10
- * action ledger through onLock.
3
+ * Five failures lock a source/account pair, with exponential backoff. Across sources, failures slow an account and
4
+ * admit only a few unproven checks per minute before password derivation. A validated same-account browser session
5
+ * bypasses that shared admission budget, never a source's own lock. Fabricated names follow the same admission path.
6
+ * Source and name records expire by time. Both use bounded, non-evicting keyed storage, regardless of whether the
7
+ * account exists. Saturation shares overflow buckets without displacing or changing individually tracked state.
11
8
  */
12
9
  export type GuardPolicy = {
13
10
  failuresBeforeLock: number;
14
11
  firstLockMs: number;
15
12
  maxLockMs: number;
13
+ firstSlowMs: number;
16
14
  };
17
15
  export declare const DEFAULT_GUARD_POLICY: GuardPolicy;
16
+ /** Default/loopback source for callers without an explicit native CLI context. */
17
+ export declare const LOCAL_SOURCE = "local";
18
+ /** Native CLI only: no HTTP peer or forwarded header can select this source. */
19
+ export declare const CLI_PASSWORD_SOURCE = "local-cli";
20
+ export declare const ACCOUNT_PASSWORD_TRIES = 3;
21
+ export declare const ACCOUNT_PASSWORD_WINDOW_MS = 60000;
22
+ /** Run `work` with every password check inside it counted against `source`. */
23
+ export declare const withPasswordSource: <T>(source: string, work: () => T) => T;
24
+ /** Where the password being checked now came from (LOCAL_SOURCE outside a request). */
25
+ export declare const currentPasswordSource: () => string;
26
+ /** Set only after the server validates a browser session, never from submitted form fields. */
27
+ export declare const provenPasswordAccount: AsyncLocalStorage<{
28
+ name: string;
29
+ generation: number;
30
+ } | null>;
18
31
  export declare class PasswordGuard {
19
32
  readonly policy: GuardPolicy;
20
- private readonly accounts;
33
+ private readonly secret;
34
+ private readonly sources;
35
+ private readonly slowdowns;
36
+ private nextPruneAt;
21
37
  /** Told once per lock: the account (as typed), and for how long. */
22
38
  onLock: ((account: string, lockMs: number) => void) | null;
23
39
  constructor(policy?: GuardPolicy);
24
40
  private static key;
25
- /** Milliseconds this account stays locked (0: it may try). */
26
- lockedFor(account: string, now: number): number;
27
- failed(account: string, now: number): void;
28
- succeeded(account: string): void;
41
+ private static pair;
42
+ /** Prune by the supplied time, even below capacity; retain a lock's escalation history for a day. */
43
+ private prune;
44
+ /** Source-local snapshot for existing callers. Authentication must use preflight to spend the shared budget. */
45
+ lockedFor(account: string, now: number, source?: string): number;
46
+ /** Reserve one password check, or return its wait. No password derivation may precede this call. */
47
+ preflight(account: string, now: number, source?: string, proven?: boolean): number;
48
+ /** A wrong password; only a database lookup may establish `exists`. */
49
+ failed(account: string, now: number, source?: string, exists?: boolean): void;
50
+ /** The right password clears individually tracked state; shared overflow buckets must expire by time. */
51
+ succeeded(account: string, source?: string): void;
52
+ /** Individually tracked source-and-name entries (at most TRACKED), excluding fixed shared overflow buckets. */
53
+ get size(): number;
29
54
  }
30
55
  /** The guard for one database (a store): every password check against it counts. */
31
56
  export declare function passwordGuardOf(owner: object): PasswordGuard;
@@ -40,6 +65,6 @@ export declare class SourceBudget {
40
65
  private readonly sources;
41
66
  constructor(tries?: number, windowMs?: number);
42
67
  /** Milliseconds until this address may try again (0: it may). */
43
- waitFor(source: string, now: number): number;
44
- failed(source: string, now: number): void;
68
+ waitFor(address: string, now: number): number;
69
+ failed(address: string, now: number): void;
45
70
  }
@@ -1,39 +1,161 @@
1
- export const DEFAULT_GUARD_POLICY = { failuresBeforeLock: 5, firstLockMs: 15 * 60_000, maxLockMs: 24 * 60 * 60_000 };
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { createHmac, randomBytes } from "node:crypto";
3
+ import { sourceKey } from "./source-key.js";
4
+ export const DEFAULT_GUARD_POLICY = { failuresBeforeLock: 5, firstLockMs: 15 * 60_000, maxLockMs: 24 * 60 * 60_000, firstSlowMs: 60_000 };
2
5
  const TRACKED = 10_000;
6
+ /** Default/loopback source for callers without an explicit native CLI context. */
7
+ export const LOCAL_SOURCE = "local";
8
+ /** Native CLI only: no HTTP peer or forwarded header can select this source. */
9
+ export const CLI_PASSWORD_SOURCE = "local-cli";
10
+ export const ACCOUNT_PASSWORD_TRIES = 3;
11
+ export const ACCOUNT_PASSWORD_WINDOW_MS = 60_000;
12
+ const LOCAL_PEERS = new Set(["127.0.0.1", "::1"]);
13
+ const passwordSource = new AsyncLocalStorage();
14
+ /** Run `work` with every password check inside it counted against `source`. */
15
+ export const withPasswordSource = (source, work) => passwordSource.run(source, work);
16
+ /** Where the password being checked now came from (LOCAL_SOURCE outside a request). */
17
+ export const currentPasswordSource = () => passwordSource.getStore() ?? LOCAL_SOURCE;
18
+ /** Set only after the server validates a browser session, never from submitted form fields. */
19
+ export const provenPasswordAccount = new AsyncLocalStorage();
20
+ /**
21
+ * Keep the first TRACKED live keys exactly; further keys share TRACKED overflow buckets. Hashes use the guard's
22
+ * private secret, so callers cannot choose collisions. Neither tier evicts live state. Overflow never mutates the
23
+ * exact tier, and cannot move into a freed exact slot until all overflow state expires: moving would reset budgets.
24
+ * A success may clear its exact record, but never a shared bucket (which could also hold another name's failures).
25
+ */
26
+ class KeyedGuardState {
27
+ secret;
28
+ domain;
29
+ entries = new Map();
30
+ overflow = new Map();
31
+ constructor(secret, domain) {
32
+ this.secret = secret;
33
+ this.domain = domain;
34
+ }
35
+ hash(key) { return createHmac("sha256", this.secret).update(this.domain).update("\0").update(key).digest("hex"); }
36
+ bucket(hash) { return parseInt(hash.slice(0, 8), 16) % TRACKED; }
37
+ get(key) {
38
+ const hash = this.hash(key);
39
+ return this.entries.get(hash) ?? this.overflow.get(this.bucket(hash));
40
+ }
41
+ retain(key, create) {
42
+ const hash = this.hash(key), kept = this.entries.get(hash);
43
+ if (kept !== undefined)
44
+ return kept;
45
+ if (this.overflow.size === 0 && this.entries.size < TRACKED) {
46
+ const entry = create();
47
+ this.entries.set(hash, entry);
48
+ return entry;
49
+ }
50
+ const bucket = this.bucket(hash), shared = this.overflow.get(bucket);
51
+ if (shared !== undefined)
52
+ return shared;
53
+ const entry = create();
54
+ this.overflow.set(bucket, entry);
55
+ return entry;
56
+ }
57
+ clear(key) { this.entries.delete(this.hash(key)); }
58
+ prune(now, lifetime) {
59
+ let next = Infinity;
60
+ const pruneEntries = (entries) => {
61
+ for (const [key, entry] of entries) {
62
+ const expires = entry.lastAt + lifetime;
63
+ if (expires <= now)
64
+ entries.delete(key);
65
+ else
66
+ next = Math.min(next, expires);
67
+ }
68
+ };
69
+ pruneEntries(this.entries);
70
+ pruneEntries(this.overflow);
71
+ return next;
72
+ }
73
+ /** Individual source/name records; shared overflow storage has its own fixed TRACKED-bucket bound. */
74
+ get size() { return this.entries.size; }
75
+ }
3
76
  export class PasswordGuard {
4
77
  policy;
5
- accounts = new Map();
78
+ secret = randomBytes(32);
79
+ sources = new KeyedGuardState(this.secret, "source/name");
80
+ slowdowns = new KeyedGuardState(this.secret, "name");
81
+ nextPruneAt = Infinity;
6
82
  /** Told once per lock: the account (as typed), and for how long. */
7
83
  onLock = null;
8
84
  constructor(policy = DEFAULT_GUARD_POLICY) {
9
85
  this.policy = policy;
10
86
  }
11
87
  static key(account) { return account.trim().toLowerCase().slice(0, 64); }
12
- /** Milliseconds this account stays locked (0: it may try). */
13
- lockedFor(account, now) {
14
- const state = this.accounts.get(PasswordGuard.key(account));
15
- return state === undefined ? 0 : Math.max(0, state.lockedUntil - now);
88
+ static pair(account, source) {
89
+ const key = sourceKey(source);
90
+ return `${LOCAL_PEERS.has(key) ? LOCAL_SOURCE : key}\u0000${PasswordGuard.key(account)}`;
91
+ }
92
+ /** Prune by the supplied time, even below capacity; retain a lock's escalation history for a day. */
93
+ prune(now) {
94
+ if (now < this.nextPruneAt)
95
+ return;
96
+ this.nextPruneAt = Math.min(this.sources.prune(now, this.policy.maxLockMs), this.slowdowns.prune(now, this.policy.maxLockMs));
16
97
  }
17
- failed(account, now) {
98
+ /** Source-local snapshot for existing callers. Authentication must use preflight to spend the shared budget. */
99
+ lockedFor(account, now, source = currentPasswordSource()) {
100
+ this.prune(now);
101
+ const pair = PasswordGuard.pair(account, source);
102
+ const tries = this.sources.get(pair);
103
+ if (tries === undefined)
104
+ return 0;
105
+ const slowdown = this.slowdowns.get(PasswordGuard.key(account));
106
+ // Legacy observation only: account existence never affects authentication or state retention.
107
+ return Math.max(0, tries.lockedUntil - now, slowdown?.observedAccount === true ? slowdown.until - now : 0);
108
+ }
109
+ /** Reserve one password check, or return its wait. No password derivation may precede this call. */
110
+ preflight(account, now, source = currentPasswordSource(), proven = false) {
111
+ this.prune(now);
112
+ const tries = this.sources.get(PasswordGuard.pair(account, source));
18
113
  const key = PasswordGuard.key(account);
19
- const state = this.accounts.get(key) ?? { failures: 0, lockedUntil: 0, locks: 0 };
20
- this.accounts.delete(key);
21
- state.failures += 1;
22
- if (state.failures >= this.policy.failuresBeforeLock) {
23
- const lockMs = Math.min(this.policy.maxLockMs, this.policy.firstLockMs * 2 ** state.locks);
24
- state.lockedUntil = now + lockMs;
25
- state.locks += 1;
26
- state.failures = 0;
114
+ const slowdown = this.slowdowns.get(key);
115
+ const sourceWait = Math.max(0, (tries?.lockedUntil ?? 0) - now, tries === undefined ? 0 : (slowdown?.until ?? 0) - now);
116
+ if (sourceWait > 0)
117
+ return sourceWait;
118
+ if (proven || slowdown === undefined || slowdown.until <= now)
119
+ return 0;
120
+ slowdown.admissions = slowdown.admissions.filter(at => at > now - ACCOUNT_PASSWORD_WINDOW_MS);
121
+ if (slowdown.admissions.length >= ACCOUNT_PASSWORD_TRIES) {
122
+ return slowdown.admissions[0] + ACCOUNT_PASSWORD_WINDOW_MS - now;
123
+ }
124
+ slowdown.admissions.push(now);
125
+ return 0;
126
+ }
127
+ /** A wrong password; only a database lookup may establish `exists`. */
128
+ failed(account, now, source = currentPasswordSource(), exists = false) {
129
+ this.prune(now);
130
+ const pair = PasswordGuard.pair(account, source);
131
+ const tries = this.sources.retain(pair, () => ({ failures: 0, lockedUntil: 0, locks: 0, lastAt: now }));
132
+ tries.failures += 1;
133
+ tries.lastAt = now;
134
+ this.nextPruneAt = Math.min(this.nextPruneAt, now + this.policy.maxLockMs);
135
+ if (tries.failures >= this.policy.failuresBeforeLock) {
136
+ const lockMs = Math.min(this.policy.maxLockMs, this.policy.firstLockMs * 2 ** tries.locks);
137
+ tries.lockedUntil = now + lockMs;
138
+ tries.locks += 1;
139
+ tries.failures = 0;
27
140
  this.onLock?.(account.trim().slice(0, 64), lockMs);
28
141
  }
29
- this.accounts.set(key, state);
30
- // Bounded: the oldest tracked names go first.
31
- while (this.accounts.size > TRACKED)
32
- this.accounts.delete(this.accounts.keys().next().value);
142
+ const key = PasswordGuard.key(account);
143
+ const slowdown = this.slowdowns.retain(key, () => ({ failures: 0, until: 0, lastAt: now, admissions: [], observedAccount: false }));
144
+ slowdown.observedAccount = exists;
145
+ slowdown.failures += 1;
146
+ slowdown.lastAt = now;
147
+ const over = slowdown.failures - this.policy.failuresBeforeLock;
148
+ if (over >= 0)
149
+ slowdown.until = now + Math.min(this.policy.maxLockMs, this.policy.firstSlowMs * 2 ** Math.min(over, 32));
33
150
  }
34
- succeeded(account) {
35
- this.accounts.delete(PasswordGuard.key(account));
151
+ /** The right password clears individually tracked state; shared overflow buckets must expire by time. */
152
+ succeeded(account, source = currentPasswordSource()) {
153
+ const pair = PasswordGuard.pair(account, source);
154
+ this.sources.clear(pair);
155
+ this.slowdowns.clear(PasswordGuard.key(account));
36
156
  }
157
+ /** Individually tracked source-and-name entries (at most TRACKED), excluding fixed shared overflow buckets. */
158
+ get size() { return this.sources.size; }
37
159
  }
38
160
  const guards = new WeakMap();
39
161
  /** The guard for one database (a store): every password check against it counts. */
@@ -59,11 +181,12 @@ export class SourceBudget {
59
181
  this.windowMs = windowMs;
60
182
  }
61
183
  /** Milliseconds until this address may try again (0: it may). */
62
- waitFor(source, now) {
63
- const recent = (this.sources.get(source) ?? []).filter(at => now - at < this.windowMs);
184
+ waitFor(address, now) {
185
+ const recent = (this.sources.get(sourceKey(address)) ?? []).filter(at => now - at < this.windowMs);
64
186
  return recent.length < this.tries ? 0 : this.windowMs - (now - recent[0]);
65
187
  }
66
- failed(source, now) {
188
+ failed(address, now) {
189
+ const source = sourceKey(address);
67
190
  const recent = (this.sources.get(source) ?? []).filter(at => now - at < this.windowMs);
68
191
  recent.push(now);
69
192
  this.sources.delete(source);
@@ -0,0 +1,2 @@
1
+ /** The budget key for one address, as described above. */
2
+ export declare function sourceKey(source: string): string;
@@ -0,0 +1,49 @@
1
+ /**
2
+ * The key a per-source budget counts under. One IPv6 subscriber is usually handed a whole /64, so keying by the full
3
+ * address would give one caller 2^64 fresh budgets: a native IPv6 address counts as its /64 (`2001:db8:1:2::/64`),
4
+ * however it is spelled. IPv4 stays exact, and an IPv4-mapped IPv6 address (`::ffff:203.0.113.5`) is that IPv4 address.
5
+ * `::1` stays itself, so this computer is never pooled with anything else. The trusted proxy's `fwd:` marker is kept,
6
+ * and anything that isn't an address ("unknown", a tenant id) is returned unchanged.
7
+ */
8
+ const IPV4 = /^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/;
9
+ /** The eight 16-bit groups of an IPv6 address (brackets and a zone ignored), or null when it isn't one. */
10
+ function ipv6Groups(text) {
11
+ let bare = text.trim().replace(/^\[(.*)\]$/, "$1").replace(/%.*$/, "").toLowerCase();
12
+ if (!bare.includes(":") || !/^[0-9a-f:.]+$/.test(bare))
13
+ return null;
14
+ // An embedded IPv4 tail is two groups.
15
+ const tail = /^(.*:)(\d+\.\d+\.\d+\.\d+)$/.exec(bare);
16
+ if (tail !== null) {
17
+ if (!IPV4.test(tail[2]))
18
+ return null;
19
+ const [a, b, c, d] = tail[2].split(".").map(Number);
20
+ bare = `${tail[1]}${((a << 8) | b).toString(16)}:${((c << 8) | d).toString(16)}`;
21
+ }
22
+ const halves = bare.split("::");
23
+ if (halves.length > 2)
24
+ return null;
25
+ const part = (one) => one === "" ? [] : one.split(":").every(group => /^[0-9a-f]{1,4}$/.test(group)) ? one.split(":") : null;
26
+ const head = part(halves[0]), rest = halves.length === 2 ? part(halves[1]) : [];
27
+ if (head === null || rest === null)
28
+ return null;
29
+ const missing = 8 - head.length - rest.length;
30
+ if (halves.length === 2 ? missing < 1 : missing !== 0)
31
+ return null;
32
+ return [...head, ...Array(halves.length === 2 ? missing : 0).fill("0"), ...rest].map(group => parseInt(group, 16));
33
+ }
34
+ /** The budget key for one address, as described above. */
35
+ export function sourceKey(source) {
36
+ if (source.startsWith("fwd:"))
37
+ return `fwd:${sourceKey(source.slice(4))}`;
38
+ if (IPV4.test(source))
39
+ return source;
40
+ const groups = ipv6Groups(source);
41
+ if (groups === null)
42
+ return source;
43
+ if (groups.slice(0, 5).every(group => group === 0) && groups[5] === 0xffff) {
44
+ return `${groups[6] >> 8}.${groups[6] & 0xff}.${groups[7] >> 8}.${groups[7] & 0xff}`;
45
+ }
46
+ if (groups.slice(0, 7).every(group => group === 0) && groups[7] === 1)
47
+ return "::1";
48
+ return `${groups.slice(0, 4).map(group => group.toString(16)).join(":")}::/64`;
49
+ }
@@ -2,8 +2,15 @@ import type { IncomingMessage, ServerResponse } from 'node:http';
2
2
  import { type TeamActor, type TeamExecute } from './team-contract.js';
3
3
  import { type Admission } from './request-budget.js';
4
4
  export declare const TEAM_REQUEST_BYTES: number;
5
+ /** How long a password bearer's proof stands on a long /api/team/events stream before its password is checked again.
6
+ * The account, its revocation and its credential generation are still rechecked on every tick. */
7
+ export declare const TEAM_PASSWORD_REVERIFY_MS: number;
8
+ /** Account role belongs to HTTP authentication; API tokens carry their own scope. */
9
+ export type TeamHttpActor = TeamActor & {
10
+ role?: 'approver' | 'viewer';
11
+ };
5
12
  export type TeamHttpOptions = {
6
- authenticate: (request: IncomingMessage) => TeamActor | null | Promise<TeamActor | null>;
13
+ authenticate: (request: IncomingMessage) => TeamHttpActor | null | Promise<TeamHttpActor | null>;
7
14
  /** Recheck the original credential/session and current account, without extending idle expiry. */
8
15
  revalidate: (request: IncomingMessage, actor: TeamActor) => boolean;
9
16
  authorizeMutation: (request: IncomingMessage, actor: TeamActor) => boolean;
package/dist/team-http.js CHANGED
@@ -1,6 +1,10 @@
1
- import { TEAM_OPERATIONS, teamScopeAllows } from './team-contract.js';
1
+ import { adapterPolicy } from "./server/route-policy.js";
2
+ import { TEAM_OPERATIONS, TEAM_MUTATIONS, teamScopeAllows } from './team-contract.js';
2
3
  import { limitWords } from './request-budget.js';
3
4
  export const TEAM_REQUEST_BYTES = 64 * 1024;
5
+ /** How long a password bearer's proof stands on a long /api/team/events stream before its password is checked again.
6
+ * The account, its revocation and its credential generation are still rechecked on every tick. */
7
+ export const TEAM_PASSWORD_REVERIFY_MS = 5 * 60_000;
4
8
  const failure = (code, message) => ({ version: 1, ok: false, code, message });
5
9
  function send(response, status, value, headers = {}) {
6
10
  if (response.destroyed || response.writableEnded)
@@ -63,6 +67,9 @@ export async function handleTeamHttp(request, response, options) {
63
67
  return reject(401, 'unauthenticated', 'Sign in to continue.');
64
68
  if (options.admitAuthenticated && refuseAdmission(options.admitAuthenticated(request, actor)))
65
69
  return true;
70
+ const policyPrincipal = () => ({ caller: request.headers.authorization ? 'bearer' : 'cookie', capability: actor.principal?.scope ?? (actor.role === 'approver' ? 'act' : 'read'), token: actor.principal !== undefined });
71
+ if (!adapterPolicy(policyPrincipal()).ok)
72
+ return reject(403, 'read-only', 'Your token reads only. Use an act token for this.');
66
73
  const conversationId = url.searchParams.get('conversation') ?? undefined;
67
74
  if (url.pathname === '/api/team/events') {
68
75
  if (request.method !== 'GET')
@@ -147,7 +154,7 @@ export async function handleTeamHttp(request, response, options) {
147
154
  return reject(405, 'method-not-allowed', 'Use GET or POST.');
148
155
  if (!options.revalidate(request, actor))
149
156
  return reject(401, 'unauthenticated', 'Sign in to continue.');
150
- if (!teamScopeAllows(actor, input.operation))
157
+ if (!adapterPolicy(policyPrincipal(), TEAM_MUTATIONS[input.operation] ? "act" : "read").ok || !teamScopeAllows(actor, input.operation))
151
158
  return reject(403, 'read-only', 'Your token reads only. Use an act token for this.');
152
159
  try {
153
160
  const result = await options.execute(actor, input);
package/dist/teams.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { adapterPolicy } from "./server/route-policy.js";
1
2
  /** Microsoft Teams: the leased delivery loop hosted by the ordinary worker,
2
3
  * and the HTTPS receiver the console mounts for the Bot Framework. Teams
3
4
  * has no client-initiated socket, so inbound activities arrive over HTTPS
@@ -37,6 +38,8 @@ export async function handleTeamsHttp(request, response, options) {
37
38
  const claims = await verifyTeamsToken(request.headers.authorization, credentials.app, options.fetcher ?? fetch);
38
39
  if (claims === null)
39
40
  return reply(401);
41
+ if (!adapterPolicy({ caller: "service", capability: "none" }).ok)
42
+ return reply(403);
40
43
  if (refuseAdmission(options.admitTenant(credentials.tenant)))
41
44
  return true;
42
45
  if (Number(request.headers["content-length"] ?? 0) > TEAMS_ACTIVITY_BYTES)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolroll",
3
- "version": "0.9.49",
3
+ "version": "0.9.51",
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"