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.
- package/CHANGELOG.md +17 -0
- package/dist/cli-http.js +3 -0
- package/dist/flow-triggers.js +7 -0
- package/dist/mcp-http.js +6 -11
- package/dist/mcp-oauth.d.ts +1 -1
- package/dist/mcp-oauth.js +8 -23
- package/dist/mcp-person.d.ts +0 -9
- package/dist/mcp-person.js +2 -1
- package/dist/operate.d.ts +1 -1
- package/dist/operate.js +9 -2
- package/dist/request-budget.d.ts +5 -2
- package/dist/request-budget.js +7 -3
- package/dist/scope.d.ts +1 -0
- package/dist/scope.js +19 -9
- package/dist/serve.d.ts +2 -605
- package/dist/serve.js +434 -23789
- package/dist/server/chat.d.ts +41 -0
- package/dist/server/chat.js +1458 -0
- package/dist/server/edge-refusal.d.ts +5 -0
- package/dist/server/edge-refusal.js +56 -0
- package/dist/server/flows.d.ts +12 -0
- package/dist/server/flows.js +832 -0
- package/dist/server/guards.d.ts +72 -0
- package/dist/server/guards.js +357 -0
- package/dist/server/handler-context.d.ts +22 -0
- package/dist/server/handler-context.js +1 -0
- package/dist/server/handler-registry.d.ts +21 -0
- package/dist/server/handler-registry.js +34 -0
- package/dist/server/pages-assets.d.ts +10 -0
- package/dist/server/pages-assets.js +103 -0
- package/dist/server/pages.d.ts +11 -0
- package/dist/server/pages.js +910 -0
- package/dist/server/people-tokens.d.ts +20 -0
- package/dist/server/people-tokens.js +693 -0
- package/dist/server/remote-hooks.d.ts +29 -0
- package/dist/server/remote-hooks.js +130 -0
- package/dist/server/remote.d.ts +59 -0
- package/dist/server/remote.js +200 -0
- package/dist/server/route-policy.d.ts +18 -0
- package/dist/server/route-policy.js +30 -0
- package/dist/server/route-table.d.ts +74 -0
- package/dist/server/route-table.js +350 -0
- package/dist/server/runtime.d.ts +280 -0
- package/dist/server/runtime.js +25 -0
- package/dist/server/settings.d.ts +22 -0
- package/dist/server/settings.js +2388 -0
- package/dist/server/shared.d.ts +3334 -0
- package/dist/server/shared.js +12708 -0
- package/dist/server/task-views.d.ts +82 -0
- package/dist/server/task-views.js +1099 -0
- package/dist/server/tasks.d.ts +87 -0
- package/dist/server/tasks.js +3579 -0
- package/dist/session-http.js +3 -0
- package/dist/sign-in-guard.d.ts +41 -16
- package/dist/sign-in-guard.js +147 -24
- package/dist/source-key.d.ts +2 -0
- package/dist/source-key.js +49 -0
- package/dist/team-http.d.ts +8 -1
- package/dist/team-http.js +9 -2
- package/dist/teams.js +3 -0
- package/package.json +1 -1
package/dist/session-http.js
CHANGED
|
@@ -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.');
|
package/dist/sign-in-guard.d.ts
CHANGED
|
@@ -1,31 +1,56 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
1
2
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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(
|
|
44
|
-
failed(
|
|
68
|
+
waitFor(address: string, now: number): number;
|
|
69
|
+
failed(address: string, now: number): void;
|
|
45
70
|
}
|
package/dist/sign-in-guard.js
CHANGED
|
@@ -1,39 +1,161 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
35
|
-
|
|
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(
|
|
63
|
-
const recent = (this.sources.get(
|
|
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(
|
|
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,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
|
+
}
|
package/dist/team-http.d.ts
CHANGED
|
@@ -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) =>
|
|
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 {
|
|
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.
|
|
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"
|