@setluca/mcp 0.2.1 → 0.3.0
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 +316 -0
- package/LICENSE +21 -0
- package/README.md +19 -288
- package/dist/annotations.js +33 -18
- package/dist/client-address.js +64 -0
- package/dist/completions.js +24 -27
- package/dist/config.js +89 -38
- package/dist/errors.js +94 -13
- package/dist/fields.js +68 -0
- package/dist/fixed-window.js +46 -0
- package/dist/generated/openapi-inputs.js +216 -5820
- package/dist/generated/openapi-outputs.js +19014 -14001
- package/dist/generated/route-catalog.js +80 -0
- package/dist/http.js +103 -41
- package/dist/index.js +4 -1
- package/dist/oauth-challenge.js +74 -0
- package/dist/oauth-resolver.js +58 -61
- package/dist/observability.js +30 -15
- package/dist/openapi-schema.js +175 -0
- package/dist/operations/catalog.js +17 -0
- package/dist/operations/groups/analytics.js +20 -10
- package/dist/operations/groups/bookings.js +43 -15
- package/dist/operations/groups/broadcasts.js +29 -58
- package/dist/operations/groups/cadences.js +3 -1
- package/dist/operations/groups/callEvents.js +19 -72
- package/dist/operations/groups/campaigns.js +28 -32
- package/dist/operations/groups/channels.js +23 -0
- package/dist/operations/groups/conversations.js +14 -25
- package/dist/operations/groups/insights.js +4 -0
- package/dist/operations/groups/integrations.js +15 -17
- package/dist/operations/groups/knowledge.js +5 -1
- package/dist/operations/groups/leads.js +22 -23
- package/dist/operations/groups/learning.js +13 -1
- package/dist/operations/groups/memory.js +4 -6
- package/dist/operations/groups/reports.js +1 -1
- package/dist/operations/groups/reviewQueue.js +17 -14
- package/dist/operations/groups/safety.js +1 -1
- package/dist/operations/groups/voice.js +0 -1
- package/dist/operations/groups/webhooks.js +15 -3
- package/dist/operations/manifest.js +7 -6
- package/dist/operations/registry.js +102 -108
- package/dist/operations.js +4 -3
- package/dist/optional-field.js +15 -3
- package/dist/page-contract.js +74 -0
- package/dist/pagination.js +147 -67
- package/dist/prompts.js +122 -97
- package/dist/rate-limit.js +40 -0
- package/dist/remote.js +145 -65
- package/dist/resilience.js +107 -52
- package/dist/resolvers.js +125 -0
- package/dist/scopes.js +70 -17
- package/dist/serialization.js +32 -22
- package/dist/server.js +148 -132
- package/dist/standard-schema.js +215 -0
- package/dist/task-tools/analytics.js +145 -86
- package/dist/task-tools/bookings.js +168 -81
- package/dist/task-tools/cadences.js +30 -45
- package/dist/task-tools/calls.js +131 -124
- package/dist/task-tools/inbox.js +37 -47
- package/dist/task-tools/leads.js +49 -77
- package/dist/task-tools/runtime.js +75 -15
- package/dist/token-verification.js +42 -0
- package/dist/tool-cache.js +3 -2
- package/dist/tool.js +151 -96
- package/dist/version.js +1 -1
- package/package.json +37 -16
- package/dist/openapi-zod.js +0 -335
- package/dist/page-shape.js +0 -62
- package/dist/tsconfig.tsbuildinfo +0 -1
package/dist/resilience.js
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
1
|
+
import * as Duration from "effect/Duration";
|
|
2
|
+
import * as Effect from "effect/Effect";
|
|
3
|
+
import * as HashSet from "effect/HashSet";
|
|
4
|
+
import * as Match from "effect/Match";
|
|
5
|
+
import * as Option from "effect/Option";
|
|
6
|
+
import * as Ref from "effect/Ref";
|
|
7
|
+
import * as Schedule from "effect/Schedule";
|
|
8
|
+
import * as Str from "effect/String";
|
|
9
|
+
import { formatError, LucaHttpError, LucaTimeoutError, } from "./errors.js";
|
|
3
10
|
/**
|
|
4
11
|
* The statuses worth sending again. All four mean the request never reached the
|
|
5
12
|
* handler, so a replay cannot repeat an effect the first attempt already had.
|
|
@@ -8,9 +15,9 @@ import { LucaHttpError, LucaTimeoutError } from "./errors.js";
|
|
|
8
15
|
* may well be after it wrote something. Replaying that without an idempotency
|
|
9
16
|
* key can double the write.
|
|
10
17
|
*/
|
|
11
|
-
const RETRYABLE_STATUSES =
|
|
18
|
+
const RETRYABLE_STATUSES = HashSet.fromIterable([429, 502, 503, 504]);
|
|
12
19
|
/** Methods with no effect to repeat, so a replay is safe with or without a key. */
|
|
13
|
-
const REPLAYABLE_METHODS =
|
|
20
|
+
const REPLAYABLE_METHODS = HashSet.fromIterable(["GET", "HEAD"]);
|
|
14
21
|
/**
|
|
15
22
|
* The longest server-requested wait we will sit through. Past this the server
|
|
16
23
|
* is not asking for a retry, it is asking us to come back later. Holding a
|
|
@@ -24,7 +31,10 @@ const RETRY_ATTEMPTS = 3;
|
|
|
24
31
|
* `Schedule.recurs` caps the number of attempts; `Schedule.upTo` caps elapsed
|
|
25
32
|
* time instead, which is a different job.
|
|
26
33
|
*/
|
|
27
|
-
const httpRetrySchedule = Schedule.
|
|
34
|
+
const httpRetrySchedule = Schedule.max([
|
|
35
|
+
Schedule.exponential(Duration.millis(200), 2).pipe(Schedule.jittered),
|
|
36
|
+
Schedule.recurs(RETRY_ATTEMPTS),
|
|
37
|
+
]);
|
|
28
38
|
/**
|
|
29
39
|
* The idempotency key an attempt carries, absent for operations that do not
|
|
30
40
|
* require one. An operation that requires a key the caller did not supply gets
|
|
@@ -38,12 +48,12 @@ function idempotencyKeyFor(request) {
|
|
|
38
48
|
}
|
|
39
49
|
/** How long the server asked us to wait, absent when it asked for nothing. */
|
|
40
50
|
function requestedDelay(error) {
|
|
41
|
-
return Match.value(error).pipe(Match.when(Match.instanceOf(LucaHttpError), (http) => Option.
|
|
51
|
+
return Match.value(error).pipe(Match.when(Match.instanceOf(LucaHttpError), (http) => Option.fromNullishOr(http.retryAfterMs)), Match.orElse(() => Option.none()));
|
|
42
52
|
}
|
|
43
53
|
/**
|
|
44
54
|
* The wait to sit out before replaying. No cap is applied here: {@link mayRetry}
|
|
45
|
-
* already refused an overlong wait, and
|
|
46
|
-
* to replay.
|
|
55
|
+
* already refused an overlong wait, and the schedule only reads this for a
|
|
56
|
+
* failure it agreed to replay.
|
|
47
57
|
*/
|
|
48
58
|
function serverDelay(error) {
|
|
49
59
|
return Option.map(requestedDelay(error), Duration.millis);
|
|
@@ -53,12 +63,15 @@ function serverDelay(error) {
|
|
|
53
63
|
* timeout both leave the request's fate unknown; every other tag means the
|
|
54
64
|
* server answered, so the answer is the outcome.
|
|
55
65
|
*/
|
|
56
|
-
const TRANSIENT_TAGS =
|
|
66
|
+
const TRANSIENT_TAGS = HashSet.fromIterable([
|
|
67
|
+
"LucaNetworkError",
|
|
68
|
+
"LucaTimeoutError",
|
|
69
|
+
]);
|
|
57
70
|
/** A failure that says nothing about whether the request was handled. */
|
|
58
71
|
function isTransient(error) {
|
|
59
|
-
return error
|
|
60
|
-
?
|
|
61
|
-
:
|
|
72
|
+
return error instanceof LucaHttpError
|
|
73
|
+
? HashSet.has(RETRYABLE_STATUSES, error.status)
|
|
74
|
+
: HashSet.has(TRANSIENT_TAGS, error._tag);
|
|
62
75
|
}
|
|
63
76
|
/**
|
|
64
77
|
* Whether this failure may be sent again. Reads are always replayable. A write
|
|
@@ -67,7 +80,7 @@ function isTransient(error) {
|
|
|
67
80
|
* replay which cannot file the same write twice.
|
|
68
81
|
*
|
|
69
82
|
* A server that asked for a wait longer than we are willing to hold is refused
|
|
70
|
-
* here too, so the
|
|
83
|
+
* here too, so the schedule's wait and this decision never disagree.
|
|
71
84
|
*/
|
|
72
85
|
export function mayRetry(policy, error) {
|
|
73
86
|
if (!isTransient(error)) {
|
|
@@ -76,52 +89,94 @@ export function mayRetry(policy, error) {
|
|
|
76
89
|
if (Option.exists(requestedDelay(error), (ms) => ms > MAX_RETRY_AFTER_MS)) {
|
|
77
90
|
return false;
|
|
78
91
|
}
|
|
79
|
-
return (
|
|
92
|
+
return (HashSet.has(REPLAYABLE_METHODS, policy.method) ||
|
|
93
|
+
policy.idempotencyKey !== undefined);
|
|
80
94
|
}
|
|
81
95
|
/**
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
* anyway.
|
|
96
|
+
* The backoff schedule with a `Retry-After` folded in: the next replay waits
|
|
97
|
+
* for whichever is longer, the backoff or the server's delay. The delay is read
|
|
98
|
+
* only when the schedule recurs, so the last attempt fails at once instead of
|
|
99
|
+
* sitting out a wait no replay will follow.
|
|
87
100
|
*/
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
101
|
+
const retrySchedule = httpRetrySchedule.pipe(Schedule.modifyDelay(({ input, duration }) => Effect.succeed(Option.match(serverDelay(input), {
|
|
102
|
+
onNone: () => duration,
|
|
103
|
+
onSome: (wait) => Duration.max(duration, wait),
|
|
104
|
+
}))));
|
|
105
|
+
/**
|
|
106
|
+
* The longest one call may take across every attempt and every wait between
|
|
107
|
+
* them. Each attempt is still bounded on its own; this caps the sum, so a slow
|
|
108
|
+
* API cannot hold a tool call open past the point a hosted client gives up.
|
|
109
|
+
*/
|
|
110
|
+
const CALL_DEADLINE_MS = 45_000;
|
|
111
|
+
const LAST_FAILURE_EXCERPT = 300;
|
|
112
|
+
/**
|
|
113
|
+
* What the last failed attempt said, for a deadline that cut the call short. A
|
|
114
|
+
* 503 or 429 that was being retried is the real reason the call went nowhere,
|
|
115
|
+
* and the bare deadline message would hide it. A timeout adds nothing the
|
|
116
|
+
* message does not already say, so it is left out.
|
|
117
|
+
*/
|
|
118
|
+
function lastFailureNote(lastFailure) {
|
|
119
|
+
return Option.match(lastFailure, {
|
|
120
|
+
onNone: () => "",
|
|
121
|
+
onSome: (error) => error instanceof LucaTimeoutError
|
|
122
|
+
? ""
|
|
123
|
+
: `. Last failure: ${Str.slice(0, LAST_FAILURE_EXCERPT)(formatError(error))}`,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* The timeout a caller reads. A read can be sent again. A write that timed out
|
|
128
|
+
* may or may not have landed, so the message says so and names the key that
|
|
129
|
+
* makes a second send safe.
|
|
130
|
+
*/
|
|
131
|
+
function timeoutError(policy, ms, lastFailure = Option.none()) {
|
|
132
|
+
const base = `Luca API did not answer within ${ms}ms${lastFailureNote(lastFailure)}`;
|
|
133
|
+
if (HashSet.has(REPLAYABLE_METHODS, policy.method)) {
|
|
134
|
+
return new LucaTimeoutError({ message: base });
|
|
135
|
+
}
|
|
136
|
+
const replay = policy.idempotencyKey === undefined
|
|
137
|
+
? "Check whether it took effect before sending it again."
|
|
138
|
+
: `Check whether it took effect, or send it again with idempotencyKey "${policy.idempotencyKey}" so the API cannot apply it twice.`;
|
|
139
|
+
return new LucaTimeoutError({
|
|
140
|
+
message: `${base}. The outcome is unknown: this ${policy.method} may or may not have been applied. ${replay}`,
|
|
93
141
|
});
|
|
94
142
|
}
|
|
95
143
|
/**
|
|
96
|
-
* One
|
|
144
|
+
* One request, bounded and replayed under the rules above.
|
|
97
145
|
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
* leaking a `
|
|
146
|
+
* Two bounds apply. The per-attempt timeout guards one socket that never
|
|
147
|
+
* answers, and lets a replayable request try again. {@link CALL_DEADLINE_MS}
|
|
148
|
+
* caps the whole run, waits included. `timeoutOrElse` keeps both failures
|
|
149
|
+
* inside `LucaError` instead of leaking a `TimeoutError` as a defect.
|
|
102
150
|
*
|
|
103
|
-
* `Retry-After` is honoured
|
|
104
|
-
*
|
|
105
|
-
* `modifyDelayEffect` over a `Ref` to move the error across, and the result is
|
|
106
|
-
* harder to read than the sleep for the same behaviour.
|
|
151
|
+
* `Retry-After` is honoured by {@link retrySchedule}, which stretches the
|
|
152
|
+
* backoff to the server's delay before a replay.
|
|
107
153
|
*/
|
|
108
|
-
export function resilient(request, timeoutMs, attempt) {
|
|
109
|
-
// Minted
|
|
110
|
-
// to the sender. The API dedupes a write by this key, so a header
|
|
111
|
-
// replay decision derived from two different values would file the
|
|
112
|
-
// write twice.
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
idempotencyKey
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
154
|
+
export function resilient(request, timeoutMs, attempt, deadlineMs = CALL_DEADLINE_MS) {
|
|
155
|
+
// Minted when the program runs, once per run rather than once per attempt,
|
|
156
|
+
// and handed to the sender. The API dedupes a write by this key, so a header
|
|
157
|
+
// and a replay decision derived from two different values would file the
|
|
158
|
+
// same write twice. Minting at build time instead would hand every run of
|
|
159
|
+
// the same program one key, and the API would drop the second run as a
|
|
160
|
+
// replay of the first.
|
|
161
|
+
return Effect.gen(function* () {
|
|
162
|
+
const idempotencyKey = idempotencyKeyFor(request);
|
|
163
|
+
const lastFailure = yield* Ref.make(Option.none());
|
|
164
|
+
const policy = {
|
|
165
|
+
method: request.operation.method,
|
|
166
|
+
idempotencyKey,
|
|
167
|
+
};
|
|
168
|
+
return yield* attempt(idempotencyKey).pipe(Effect.timeoutOrElse({
|
|
169
|
+
duration: Duration.millis(timeoutMs),
|
|
170
|
+
orElse: () => Effect.fail(timeoutError(policy, timeoutMs)),
|
|
171
|
+
}),
|
|
172
|
+
// Recorded after the per-attempt timeout so a hung attempt counts too,
|
|
173
|
+
// and before the retry so every failure the schedule swallows is seen.
|
|
174
|
+
Effect.tapError((error) => Ref.set(lastFailure, Option.some(error))), Effect.retry({
|
|
175
|
+
schedule: retrySchedule,
|
|
176
|
+
while: (error) => mayRetry(policy, error),
|
|
177
|
+
}), Effect.timeoutOrElse({
|
|
178
|
+
duration: Duration.millis(deadlineMs),
|
|
179
|
+
orElse: () => Ref.get(lastFailure).pipe(Effect.flatMap((last) => Effect.fail(timeoutError(policy, deadlineMs, last)))),
|
|
180
|
+
}));
|
|
181
|
+
});
|
|
127
182
|
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import * as Arr from "effect/Array";
|
|
2
|
+
import * as Clock from "effect/Clock";
|
|
3
|
+
import * as Effect from "effect/Effect";
|
|
4
|
+
import * as Option from "effect/Option";
|
|
5
|
+
import * as Redacted from "effect/Redacted";
|
|
6
|
+
import * as Schema from "effect/Schema";
|
|
7
|
+
import { TokenVerificationUnavailable } from "./errors.js";
|
|
8
|
+
import { createFixedWindowTable, } from "./fixed-window.js";
|
|
9
|
+
import { callApi, sha256Hex, unavailable, } from "./token-verification.js";
|
|
10
|
+
/**
|
|
11
|
+
* Whether a token belongs to `apiKeyResolver` rather than `oauthResolver`.
|
|
12
|
+
* Developer keys start with `luca_`. Refresh tokens start with `luca_ort_` and
|
|
13
|
+
* are not developer keys, so they go to the OAuth resolver, which refuses them
|
|
14
|
+
* without a network call. A malformed `luca_` token goes to `apiKeyResolver`
|
|
15
|
+
* and is refused there, so it never leaves the process.
|
|
16
|
+
*/
|
|
17
|
+
export function looksLikeDeveloperKey(token) {
|
|
18
|
+
return /^luca_(?!ort_)/i.test(token);
|
|
19
|
+
}
|
|
20
|
+
const DEVELOPER_KEY = /^luca_[a-f0-9]{64}$/i;
|
|
21
|
+
/** The cheapest authenticated read in the public API. */
|
|
22
|
+
const KEY_CHECK_PATH = "/api/capabilities";
|
|
23
|
+
/** How long a passed key check is trusted. */
|
|
24
|
+
const KEY_CHECK_TTL_MS = 60_000;
|
|
25
|
+
export function createKeyCheckCache() {
|
|
26
|
+
return createFixedWindowTable(KEY_CHECK_TTL_MS);
|
|
27
|
+
}
|
|
28
|
+
/** One cache per isolate: the Worker builds a resolver per request. */
|
|
29
|
+
const ISOLATE_KEY_CHECKS = createKeyCheckCache();
|
|
30
|
+
/**
|
|
31
|
+
* The shape of an error from the Luca API. A 403 carrying it is the API's own
|
|
32
|
+
* scope answer, as opposed to an edge rule's.
|
|
33
|
+
*/
|
|
34
|
+
const LucaErrorBody = Schema.Struct({
|
|
35
|
+
error: Schema.Struct({
|
|
36
|
+
code: Schema.String,
|
|
37
|
+
message: Schema.String,
|
|
38
|
+
requestId: Schema.String,
|
|
39
|
+
}),
|
|
40
|
+
});
|
|
41
|
+
/** The session middleware's answer to a key with several workspaces and none chosen. */
|
|
42
|
+
const WorkspaceRequiredBody = Schema.Struct({
|
|
43
|
+
error: Schema.Literal("workspace_required"),
|
|
44
|
+
});
|
|
45
|
+
/** The session middleware's answer to a key that reaches no workspace. */
|
|
46
|
+
const NoWorkspaceAccessBody = Schema.Struct({
|
|
47
|
+
error: Schema.Literal("forbidden"),
|
|
48
|
+
});
|
|
49
|
+
/**
|
|
50
|
+
* The verdict an error answer carries, by status and body. Any other pairing
|
|
51
|
+
* (an edge rule's page, an unknown body) says nothing about the key.
|
|
52
|
+
*/
|
|
53
|
+
const BODY_VERDICTS = [
|
|
54
|
+
// The key authenticated; the check request named no workspace.
|
|
55
|
+
[400, Schema.is(WorkspaceRequiredBody), true],
|
|
56
|
+
// The key authenticated but lacks a scope for this route.
|
|
57
|
+
[403, Schema.is(LucaErrorBody), true],
|
|
58
|
+
// The key authenticated but reaches no workspace, so no call can succeed.
|
|
59
|
+
[403, Schema.is(NoWorkspaceAccessBody), false],
|
|
60
|
+
];
|
|
61
|
+
function keyHeaders(settings, apiKey) {
|
|
62
|
+
return settings.authHeader === "authorization"
|
|
63
|
+
? { accept: "application/json", authorization: `Bearer ${apiKey}` }
|
|
64
|
+
: { accept: "application/json", "x-api-key": apiKey };
|
|
65
|
+
}
|
|
66
|
+
const verdictFromBody = (response) => Effect.tryPromise({
|
|
67
|
+
try: () => response.json(),
|
|
68
|
+
catch: unavailable("Key verification returned non-JSON content"),
|
|
69
|
+
}).pipe(Effect.flatMap((body) => Arr.findFirst(BODY_VERDICTS, ([status, matches]) => status === response.status && matches(body)).pipe(Option.match({
|
|
70
|
+
onNone: () => Effect.fail(new TokenVerificationUnavailable({
|
|
71
|
+
message: `Key verification answered ${response.status} outside the Luca API`,
|
|
72
|
+
})),
|
|
73
|
+
onSome: ([, , valid]) => Effect.succeed(valid),
|
|
74
|
+
}))));
|
|
75
|
+
/** Whether the API vouches for the key: `false` is a refusal, sent as a 401. */
|
|
76
|
+
const keyVerdict = (response) => {
|
|
77
|
+
if (response.status === 401) {
|
|
78
|
+
return Effect.succeed(false);
|
|
79
|
+
}
|
|
80
|
+
if (response.ok) {
|
|
81
|
+
return Effect.succeed(true);
|
|
82
|
+
}
|
|
83
|
+
return response.status === 400 || response.status === 403
|
|
84
|
+
? verdictFromBody(response)
|
|
85
|
+
: Effect.fail(new TokenVerificationUnavailable({
|
|
86
|
+
message: `Key verification answered ${response.status}`,
|
|
87
|
+
}));
|
|
88
|
+
};
|
|
89
|
+
/**
|
|
90
|
+
* Resolver where the bearer token IS a Luca developer API key. Zero new
|
|
91
|
+
* privilege surface: the token resolves to exactly the coach scope that key
|
|
92
|
+
* already grants, enforced server-side by the Luca API on each call. A token
|
|
93
|
+
* that is not a well-formed key resolves to `None` with no network call. A
|
|
94
|
+
* well-formed one is checked with one authenticated API call first, so a
|
|
95
|
+
* fabricated key gets a 401 instead of a server.
|
|
96
|
+
*
|
|
97
|
+
* A 2xx accepts the key, and so do a 400 `workspace_required` and a 403 in the
|
|
98
|
+
* API's error shape, because both mean the key authenticated. A 401, or a 403
|
|
99
|
+
* `forbidden` for a key with no workspace, resolves to `None`. Anything else
|
|
100
|
+
* fails as {@link TokenVerificationUnavailable}. A passed check is trusted for
|
|
101
|
+
* 60 seconds, so only acceptances are cached.
|
|
102
|
+
*/
|
|
103
|
+
export function apiKeyResolver(settings, fetchImpl = fetch, checks = ISOLATE_KEY_CHECKS) {
|
|
104
|
+
const accepted = (token) => Option.some({ ...settings, apiKey: Redacted.make(token) });
|
|
105
|
+
const verify = (token, fingerprint) => callApi(settings, fetchImpl, {
|
|
106
|
+
path: KEY_CHECK_PATH,
|
|
107
|
+
init: { method: "GET", headers: keyHeaders(settings, token) },
|
|
108
|
+
}, keyVerdict).pipe(Effect.flatMap((valid) => valid
|
|
109
|
+
? Effect.map(Clock.currentTimeMillis, (now) => {
|
|
110
|
+
// A full table skips the entry, so the key is checked again
|
|
111
|
+
// next time.
|
|
112
|
+
checks.charge(fingerprint, now);
|
|
113
|
+
return accepted(token);
|
|
114
|
+
})
|
|
115
|
+
: Effect.succeedNone));
|
|
116
|
+
return (token) => DEVELOPER_KEY.test(token)
|
|
117
|
+
? Effect.gen(function* () {
|
|
118
|
+
const fingerprint = yield* sha256Hex(token).pipe(Effect.mapError(unavailable("Could not fingerprint the key")));
|
|
119
|
+
const now = yield* Clock.currentTimeMillis;
|
|
120
|
+
return checks.isLive(fingerprint, now)
|
|
121
|
+
? accepted(token)
|
|
122
|
+
: yield* verify(token, fingerprint);
|
|
123
|
+
})
|
|
124
|
+
: Effect.succeedNone;
|
|
125
|
+
}
|
package/dist/scopes.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import * as Arr from "effect/Array";
|
|
2
|
+
import * as Order from "effect/Order";
|
|
1
3
|
/**
|
|
2
4
|
* The OAuth scopes this server advertises, and the API-key scopes each one
|
|
3
5
|
* grants.
|
|
@@ -28,6 +30,13 @@ export const LUCA_OAUTH_SCOPES = [
|
|
|
28
30
|
"luca:full_content",
|
|
29
31
|
"offline_access",
|
|
30
32
|
];
|
|
33
|
+
/**
|
|
34
|
+
* The scopes this resource accepts, for the RFC 9728 `scopes_supported` list.
|
|
35
|
+
* `offline_access` asks the authorization server for a refresh token and
|
|
36
|
+
* grants nothing here, so a client that copied it from this list onto its
|
|
37
|
+
* access-token request would be asking the resource for a scope it never checks.
|
|
38
|
+
*/
|
|
39
|
+
export const LUCA_RESOURCE_SCOPES = Arr.filter(LUCA_OAUTH_SCOPES, (scope) => scope !== "offline_access");
|
|
31
40
|
/** Least-privilege default for a new OAuth connection. */
|
|
32
41
|
export const OAUTH_DEFAULT_SCOPE = "luca:read luca:draft";
|
|
33
42
|
const READ_TIER_SCOPES = [
|
|
@@ -36,6 +45,7 @@ const READ_TIER_SCOPES = [
|
|
|
36
45
|
"broadcasts:read",
|
|
37
46
|
"call_events:read",
|
|
38
47
|
"campaigns:read",
|
|
48
|
+
"channels:read",
|
|
39
49
|
"coach:read",
|
|
40
50
|
"conversations:read",
|
|
41
51
|
"integrations:read",
|
|
@@ -88,6 +98,18 @@ const FULL_TIER_SCOPES = [
|
|
|
88
98
|
"integrations:write",
|
|
89
99
|
"webhooks:write",
|
|
90
100
|
];
|
|
101
|
+
/** The capability tiers from least to most privilege. */
|
|
102
|
+
const CAPABILITY_TIERS = [
|
|
103
|
+
"read",
|
|
104
|
+
"draft",
|
|
105
|
+
"queue_ops",
|
|
106
|
+
"full",
|
|
107
|
+
];
|
|
108
|
+
function scopeForTier(tier) {
|
|
109
|
+
return `luca:${tier}`;
|
|
110
|
+
}
|
|
111
|
+
/** The capability scopes from least to most privilege. */
|
|
112
|
+
export const CAPABILITY_LADDER = CAPABILITY_TIERS.map(scopeForTier);
|
|
91
113
|
/**
|
|
92
114
|
* Capability scope to the API scopes it grants. The tiers are strict supersets,
|
|
93
115
|
* so `luca:full` covers everything `luca:queue_ops` does.
|
|
@@ -117,19 +139,57 @@ export const NON_TIER_OAUTH_SCOPES = {
|
|
|
117
139
|
export function allGrantedApiScopes() {
|
|
118
140
|
return OAUTH_CAPABILITY_SCOPES["luca:full"];
|
|
119
141
|
}
|
|
142
|
+
/** Whether a capability scope grants `apiScope`. */
|
|
143
|
+
export function tierGrants(scope, apiScope) {
|
|
144
|
+
return Arr.contains(OAUTH_CAPABILITY_SCOPES[scope], apiScope);
|
|
145
|
+
}
|
|
146
|
+
/** A capability scope's position on the ladder, `0` for `luca:read`. */
|
|
147
|
+
export function capabilityRank(scope) {
|
|
148
|
+
return CAPABILITY_LADDER.indexOf(scope);
|
|
149
|
+
}
|
|
120
150
|
/**
|
|
121
151
|
* The lowest capability scope that grants `apiScope`, or `undefined` when no
|
|
122
152
|
* tier grants it. Reading the ladder from the bottom is what makes the answer
|
|
123
153
|
* the least-privilege one rather than merely a correct one.
|
|
124
154
|
*/
|
|
125
155
|
export function lowestCapabilityScopeFor(apiScope) {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
156
|
+
return CAPABILITY_LADDER.find((scope) => tierGrants(scope, apiScope));
|
|
157
|
+
}
|
|
158
|
+
/** The higher of two capability scopes. */
|
|
159
|
+
function higherCapabilityScope(left, right) {
|
|
160
|
+
return capabilityRank(left) >= capabilityRank(right) ? left : right;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* The least-privilege capability tier that lets an OAuth connection call an
|
|
164
|
+
* operation. Two rules apply, and the answer is the higher of the two:
|
|
165
|
+
*
|
|
166
|
+
* - The API accepts a key holding any one of the route's API scopes
|
|
167
|
+
* (`apps/api/src/middleware/api-access.ts` checks `policy.scopes.some`), and
|
|
168
|
+
* the tiers nest, so the lowest tier granting any of them is enough.
|
|
169
|
+
* - The tier the operation's own effect asks for (`requiredScope.capability`),
|
|
170
|
+
* which the API checks once agent-scope enforcement is on.
|
|
171
|
+
*
|
|
172
|
+
* An operation whose API scopes no tier grants needs `full`. `docs:generate`
|
|
173
|
+
* already fails on that drift, so the fallback only keeps the answer
|
|
174
|
+
* conservative.
|
|
175
|
+
*/
|
|
176
|
+
export function effectiveCapability(operation) {
|
|
177
|
+
const routeTier = CAPABILITY_TIERS.find((tier) => operation.scopes.some((apiScope) => tierGrants(scopeForTier(tier), apiScope))) ?? "full";
|
|
178
|
+
return CAPABILITY_TIERS.indexOf(routeTier) >=
|
|
179
|
+
CAPABILITY_TIERS.indexOf(operation.requiredScope.capability)
|
|
180
|
+
? routeTier
|
|
181
|
+
: operation.requiredScope.capability;
|
|
182
|
+
}
|
|
183
|
+
/** The OAuth scope that requests {@link effectiveCapability}. */
|
|
184
|
+
export function oauthScopeForOperation(operation) {
|
|
185
|
+
return scopeForTier(effectiveCapability(operation));
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* The least-privilege capability scope for a task tool: the highest scope any
|
|
189
|
+
* operation it composes needs, because the tool runs all of them.
|
|
190
|
+
*/
|
|
191
|
+
export function oauthScopeForTool(operations) {
|
|
192
|
+
return Arr.reduce(operations, scopeForTier("read"), (scope, operation) => higherCapabilityScope(scope, oauthScopeForOperation(operation)));
|
|
133
193
|
}
|
|
134
194
|
/**
|
|
135
195
|
* API scopes the operations need that no capability tier grants, sorted and
|
|
@@ -141,14 +201,7 @@ export function lowestCapabilityScopeFor(apiScope) {
|
|
|
141
201
|
* with a hole in it.
|
|
142
202
|
*/
|
|
143
203
|
export function unknownApiScopes(operations) {
|
|
144
|
-
const granted =
|
|
145
|
-
const unknown =
|
|
146
|
-
|
|
147
|
-
for (const scope of operation.scopes) {
|
|
148
|
-
if (!granted.has(scope)) {
|
|
149
|
-
unknown.add(scope);
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
return [...unknown].sort();
|
|
204
|
+
const granted = allGrantedApiScopes();
|
|
205
|
+
const unknown = Arr.filter(Arr.flatMap(operations, (operation) => operation.scopes), (scope) => !Arr.contains(granted, scope));
|
|
206
|
+
return Arr.sort(Arr.dedupe(unknown), Order.String);
|
|
154
207
|
}
|
package/dist/serialization.js
CHANGED
|
@@ -3,34 +3,44 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Every JSON boundary the server has is a wire boundary it does not control:
|
|
5
5
|
* MCP content blocks are specified as text, an HTTP request body is a string,
|
|
6
|
-
* and a rejection Response carries a JSON body. Keeping the
|
|
7
|
-
*
|
|
8
|
-
* how Luca serializes
|
|
9
|
-
* one place to happen.
|
|
6
|
+
* and a rejection Response carries a JSON body. Keeping the `JSON.*` calls and
|
|
7
|
+
* the JSON schemas here means the rest of the package works in values, and a
|
|
8
|
+
* change to how Luca serializes has exactly one place to happen.
|
|
10
9
|
*/
|
|
11
|
-
import * as
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
10
|
+
import * as Arr from "effect/Array";
|
|
11
|
+
import * as P from "effect/Predicate";
|
|
12
|
+
import * as Schema from "effect/Schema";
|
|
13
|
+
/** Any JSON value. */
|
|
14
|
+
export const JsonValue = Schema.Json;
|
|
15
|
+
/** Any JSON value keyed by string. */
|
|
16
|
+
export const JsonObject = Schema.JsonObject;
|
|
17
|
+
/**
|
|
18
|
+
* Whether a JSON value is an object rather than an array or a scalar. The
|
|
19
|
+
* value is JSON already, so one shallow check is enough: validating the whole
|
|
20
|
+
* subtree again on every call would make a recursive walk quadratic.
|
|
21
|
+
*/
|
|
22
|
+
export function isJsonObject(value) {
|
|
23
|
+
return P.isObject(value);
|
|
24
|
+
}
|
|
25
|
+
/** Recognize a JSON array while retaining the type of its elements. */
|
|
26
|
+
export function isJsonArray(value) {
|
|
27
|
+
return Arr.isArray(value);
|
|
28
|
+
}
|
|
29
|
+
const decodeJsonText = Schema.decodeUnknownEffect(Schema.fromJsonString(JsonValue));
|
|
30
|
+
/**
|
|
31
|
+
* Parses JSON text into a JSON value. Malformed text fails with a
|
|
32
|
+
* `SchemaError` rather than throwing.
|
|
33
|
+
*/
|
|
34
|
+
export function parseJson(text) {
|
|
35
|
+
return decodeJsonText(text);
|
|
36
|
+
}
|
|
22
37
|
/** Pretty JSON for a human- or agent-readable text block. */
|
|
23
38
|
export function toJsonText(value) {
|
|
39
|
+
// oxlint-disable-next-line effect/avoid-direct-json -- this file is the package's one JSON boundary. The input may hold `undefined` fields, which `JSON.stringify` drops and a `Schema.Json` encoder would reject.
|
|
24
40
|
return JSON.stringify(value, null, 2) ?? "null";
|
|
25
41
|
}
|
|
26
42
|
/** Compact JSON for a wire payload: a request body, a response body, a log line. */
|
|
27
43
|
export function toJsonPayload(value) {
|
|
44
|
+
// oxlint-disable-next-line effect/avoid-direct-json -- this file is the package's one JSON boundary. The input may hold `undefined` fields, which `JSON.stringify` drops and a `Schema.Json` encoder would reject.
|
|
28
45
|
return JSON.stringify(value) ?? "null";
|
|
29
46
|
}
|
|
30
|
-
/**
|
|
31
|
-
* Parses JSON text, throwing on malformed input. Callers run this inside an
|
|
32
|
-
* `Effect.try` so the throw becomes a typed decode failure rather than a defect.
|
|
33
|
-
*/
|
|
34
|
-
export function fromJson(raw) {
|
|
35
|
-
return JsonValueSchema.parse(JSON.parse(raw));
|
|
36
|
-
}
|