@lanes-sh/link 0.2.1 → 0.2.2
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/instructions/skills/lanes-link/SKILL.md +17 -0
- package/package.json +1 -1
- package/src/auth/index.ts +3 -1
- package/src/auth/oauth/metadata.ts +83 -9
- package/src/auth/oauth/redirects.ts +70 -0
- package/src/auth/oauth/server.ts +49 -69
- package/src/auth/oauth/store.ts +19 -5
- package/src/deployments/gcp/driver.ts +6 -0
- package/src/deployments/gcp/survey.ts +3 -0
- package/src/profile/authorization.ts +13 -4
- package/src/profile/schema.ts +26 -1
- package/src/server/endpoint.ts +12 -3
- package/src/server/generation.ts +1 -0
- package/src/server/generations.ts +2 -0
- package/src/server/harness.ts +13 -3
- package/src/server/index.ts +31 -6
- package/src/server/mcp/build.ts +1 -1
- package/src/server/mcp/instructions.ts +35 -5
- package/src/server/mcp/visibility.ts +9 -0
|
@@ -185,3 +185,20 @@ URL and a token.
|
|
|
185
185
|
user the command rather than backgrounding it silently on their behalf.
|
|
186
186
|
Registration works while it is down — the harness simply cannot reach it yet,
|
|
187
187
|
and the first symptom is a failed call much later.
|
|
188
|
+
|
|
189
|
+
## When a call does not land
|
|
190
|
+
|
|
191
|
+
Different from the above, and more common: calls were working, and then one does
|
|
192
|
+
not go through. A deployed endpoint is one machine its owner runs, and a client
|
|
193
|
+
can report it unreachable while it is up — sometimes without sending anything at
|
|
194
|
+
all, which is why the endpoint's own log can show no trace of the attempt.
|
|
195
|
+
|
|
196
|
+
Treat it as ordinary. **Say the call did not land, and stop there.** It is not a
|
|
197
|
+
fault to diagnose, and it is not authorization that has lapsed — do not tell them
|
|
198
|
+
to sign in again unless the endpoint itself said so.
|
|
199
|
+
|
|
200
|
+
**Do not redo what already succeeded.** A call that returned is done, and the
|
|
201
|
+
next one failing does not undo it. Re-deriving a finished answer, or rewriting a
|
|
202
|
+
memory entry that was already written, is the expensive mistake here and the one
|
|
203
|
+
that actually gets made. Say which parts landed, which did not, and offer to
|
|
204
|
+
retry the rest.
|
package/package.json
CHANGED
package/src/auth/index.ts
CHANGED
|
@@ -212,9 +212,11 @@ export {
|
|
|
212
212
|
challenge,
|
|
213
213
|
protectedResourceMetadata,
|
|
214
214
|
MCP_SCOPE,
|
|
215
|
+
type ChallengeError,
|
|
215
216
|
type ResourceIdentity,
|
|
216
217
|
} from './oauth/metadata.ts';
|
|
217
|
-
export { OAuthServer,
|
|
218
|
+
export { OAuthServer, pkceChallengeFor, type AuthorizeRequest, type OAuthResult } from './oauth/server.ts';
|
|
219
|
+
export { matchesRegistered } from './oauth/redirects.ts';
|
|
218
220
|
export { OAuthStore, hashToken, randomToken } from './oauth/store.ts';
|
|
219
221
|
export { OidcVerifier, type OidcVerifierOptions, type VerifiedSubject } from './oidc.ts';
|
|
220
222
|
export { IssuedTokenAuthenticator, OidcAuthenticator } from './remote.ts';
|
|
@@ -22,21 +22,62 @@ export interface ResourceIdentity {
|
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
/**
|
|
25
|
-
*
|
|
25
|
+
* Two scopes, and neither is a permission axis.
|
|
26
26
|
*
|
|
27
27
|
* What a caller may do is decided by the profile's policy, per capability, per
|
|
28
28
|
* call, and recorded in the audit log. A second permission system expressed as
|
|
29
29
|
* scopes could only either duplicate that or disagree with it, and a client
|
|
30
|
-
* cannot be trusted to ask for less than it wants anyway. The
|
|
31
|
-
* because the protocol has a slot for
|
|
30
|
+
* cannot be trusted to ask for less than it wants anyway. The scopes exist
|
|
31
|
+
* because the protocol has a slot for them, and because a client reads that
|
|
32
|
+
* slot to decide what this endpoint will do for it.
|
|
32
33
|
*/
|
|
33
34
|
export const MCP_SCOPE = 'mcp';
|
|
34
35
|
|
|
36
|
+
/**
|
|
37
|
+
* OIDC Core §11's name for "issue me a refresh token", and the reason this
|
|
38
|
+
* endpoint stopped sending its owner back to a browser.
|
|
39
|
+
*
|
|
40
|
+
* A refresh token has always been issued here, unconditionally. What was missing
|
|
41
|
+
* was saying so. A client's requested scope defaults to whatever the *resource*
|
|
42
|
+
* document lists, and the reference MCP client appends `offline_access` only
|
|
43
|
+
* when the *authorization server* document advertises it:
|
|
44
|
+
*
|
|
45
|
+
* ```js
|
|
46
|
+
* let effectiveScope = requestedScope || resourceMetadata?.scopes_supported?.join(" ") || …
|
|
47
|
+
* if (effectiveScope && authServerMetadata?.scopes_supported?.includes("offline_access") && …)
|
|
48
|
+
* effectiveScope = `${effectiveScope} offline_access`;
|
|
49
|
+
* ```
|
|
50
|
+
*
|
|
51
|
+
* So both documents matter and they matter differently. Advertising it in
|
|
52
|
+
* neither left a client no grounds to request, persist, or use the refresh
|
|
53
|
+
* token it was being handed — and a client with no grounds reconnects, which
|
|
54
|
+
* means its owner approving in a browser.
|
|
55
|
+
*/
|
|
56
|
+
export const OFFLINE_ACCESS_SCOPE = 'offline_access';
|
|
57
|
+
|
|
58
|
+
/** Everything grantable here. A request for anything else is narrowed, not refused. */
|
|
59
|
+
export const SUPPORTED_SCOPES = [MCP_SCOPE, OFFLINE_ACCESS_SCOPE] as const;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The grantable part of what was asked for.
|
|
63
|
+
*
|
|
64
|
+
* Empty means the request named nothing we recognise, and the caller falls back
|
|
65
|
+
* to `MCP_SCOPE` — refusing with `invalid_scope` would turn an unknown token in
|
|
66
|
+
* a client's default string into a connector that cannot be added at all, and
|
|
67
|
+
* scope is not the thing protecting anything here.
|
|
68
|
+
*/
|
|
69
|
+
export function grantableScope(requested: string | null | undefined): string {
|
|
70
|
+
const asked = new Set((requested ?? '').split(/\s+/).filter(Boolean));
|
|
71
|
+
return SUPPORTED_SCOPES.filter((scope) => asked.has(scope)).join(' ');
|
|
72
|
+
}
|
|
73
|
+
|
|
35
74
|
export function protectedResourceMetadata(identity: ResourceIdentity): Record<string, unknown> {
|
|
36
75
|
return {
|
|
37
76
|
resource: identity.resource,
|
|
38
77
|
authorization_servers: [identity.issuer],
|
|
39
|
-
|
|
78
|
+
// Where a client's *default* requested scope comes from, so this is the
|
|
79
|
+
// list that decides what an untouched connector asks for.
|
|
80
|
+
scopes_supported: [...SUPPORTED_SCOPES],
|
|
40
81
|
bearer_methods_supported: ['header'],
|
|
41
82
|
};
|
|
42
83
|
}
|
|
@@ -47,7 +88,9 @@ export function authorizationServerMetadata(origin: string): Record<string, unkn
|
|
|
47
88
|
authorization_endpoint: `${origin}/authorize`,
|
|
48
89
|
token_endpoint: `${origin}/token`,
|
|
49
90
|
registration_endpoint: `${origin}/register`,
|
|
50
|
-
|
|
91
|
+
// And this is the list that gates whether `offline_access` is appended at
|
|
92
|
+
// all. Both documents have to carry it; neither one alone is enough.
|
|
93
|
+
scopes_supported: [...SUPPORTED_SCOPES],
|
|
51
94
|
response_types_supported: ['code'],
|
|
52
95
|
grant_types_supported: ['authorization_code', 'refresh_token'],
|
|
53
96
|
// Advertised because a spec-compliant client checks for it before starting
|
|
@@ -60,6 +103,27 @@ export function authorizationServerMetadata(origin: string): Record<string, unkn
|
|
|
60
103
|
};
|
|
61
104
|
}
|
|
62
105
|
|
|
106
|
+
/**
|
|
107
|
+
* Why a credential was refused, in RFC 6750 §3.1's vocabulary.
|
|
108
|
+
*
|
|
109
|
+
* There is one code worth sending and it carries the whole distinction a client
|
|
110
|
+
* needs: `invalid_token` says the credential was *rejected*, where an otherwise
|
|
111
|
+
* identical challenge says only that authorization is required. A client that
|
|
112
|
+
* cannot tell those apart cannot tell "refresh — you hold a refresh token for
|
|
113
|
+
* this" from "start a new authorization", and the safe-looking guess is the
|
|
114
|
+
* second, which means the owner approving in a browser for a credential a
|
|
115
|
+
* silent refresh would have replaced.
|
|
116
|
+
*
|
|
117
|
+
* Deliberately absent when nothing was presented. RFC 6750 §3: a resource
|
|
118
|
+
* server SHOULD NOT include an error code where the request carried no
|
|
119
|
+
* authentication information — and sending one would set a client refreshing a
|
|
120
|
+
* credential it does not have.
|
|
121
|
+
*/
|
|
122
|
+
export interface ChallengeError {
|
|
123
|
+
readonly code: 'invalid_token';
|
|
124
|
+
readonly description: string;
|
|
125
|
+
}
|
|
126
|
+
|
|
63
127
|
/**
|
|
64
128
|
* The `WWW-Authenticate` value on a 401.
|
|
65
129
|
*
|
|
@@ -67,9 +131,19 @@ export function authorizationServerMetadata(origin: string): Record<string, unkn
|
|
|
67
131
|
* has to guess the document's location by probing well-known paths, which costs
|
|
68
132
|
* round trips and fails entirely on a host that does not serve them. Clients do
|
|
69
133
|
* not honour this header on a `200`, so the status has to be right too.
|
|
134
|
+
*
|
|
135
|
+
* It stays on the header even when a token was rejected. A client that decides
|
|
136
|
+
* to authorize after all — because the refresh was refused too — must not have
|
|
137
|
+
* to go and find the document a second time.
|
|
70
138
|
*/
|
|
71
|
-
export function challenge(metadataUrl: string | null): string {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
139
|
+
export function challenge(metadataUrl: string | null, error?: ChallengeError): string {
|
|
140
|
+
// Every value is a quoted-string, so none may contain a quote. Both of these
|
|
141
|
+
// are constants in this repository and the types keep them that way.
|
|
142
|
+
const parts = [
|
|
143
|
+
'realm="lanes-link"',
|
|
144
|
+
...(error ? [`error="${error.code}"`, `error_description="${error.description}"`] : []),
|
|
145
|
+
...(metadataUrl ? [`resource_metadata="${metadataUrl}"`] : []),
|
|
146
|
+
];
|
|
147
|
+
|
|
148
|
+
return `Bearer ${parts.join(', ')}`;
|
|
75
149
|
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which redirect URIs this server will send an authorization code to.
|
|
3
|
+
*
|
|
4
|
+
* Its own file because it is its own subject. `server.ts` is the flow as
|
|
5
|
+
* decisions — a code exchanged, a token rotated, an owner approving — and none
|
|
6
|
+
* of it is about URL shapes. Both halves stayed inside the file-size budget
|
|
7
|
+
* until they did not, and the budget exists to point at exactly this: it was
|
|
8
|
+
* not too long, it was two things.
|
|
9
|
+
*
|
|
10
|
+
* Nothing here consults configuration. What a client registered is checked
|
|
11
|
+
* against what it now presents, and the rules are RFC 8252's rather than ours.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* https, or loopback for a native client.
|
|
16
|
+
*
|
|
17
|
+
* A native client cannot receive an https redirect, so RFC 8252 has it listen
|
|
18
|
+
* on a loopback port instead. Everything else is refused: a redirect to `http://`
|
|
19
|
+
* on a routable host puts an authorization code on the wire in clear text.
|
|
20
|
+
*/
|
|
21
|
+
export function isSafeRedirect(uri: string): boolean {
|
|
22
|
+
let parsed: URL;
|
|
23
|
+
try {
|
|
24
|
+
parsed = new URL(uri);
|
|
25
|
+
} catch {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (parsed.protocol === 'https:') return true;
|
|
30
|
+
return parsed.protocol === 'http:' && isLoopbackHost(parsed.hostname);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function isLoopbackHost(hostname: string): boolean {
|
|
34
|
+
return hostname === '127.0.0.1' || hostname === '::1' || hostname === '[::1]' || hostname === 'localhost';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Exact match, except for the port of a loopback URI.
|
|
39
|
+
*
|
|
40
|
+
* RFC 8252 §7.3 requires ignoring the port for the IP-literal form, because a
|
|
41
|
+
* native client binds an ephemeral one it cannot know at registration time.
|
|
42
|
+
* Claude Code declares `http://localhost/callback` and `http://127.0.0.1/callback`
|
|
43
|
+
* and then listens on whatever port it got, so the same allowance has to cover
|
|
44
|
+
* `localhost` or it never connects.
|
|
45
|
+
*/
|
|
46
|
+
export function matchesRegistered(candidate: string, registered: readonly string[]): boolean {
|
|
47
|
+
if (registered.includes(candidate)) return true;
|
|
48
|
+
|
|
49
|
+
let parsed: URL;
|
|
50
|
+
try {
|
|
51
|
+
parsed = new URL(candidate);
|
|
52
|
+
} catch {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
if (!isLoopbackHost(parsed.hostname)) return false;
|
|
56
|
+
|
|
57
|
+
return registered.some((uri) => {
|
|
58
|
+
try {
|
|
59
|
+
const other = new URL(uri);
|
|
60
|
+
return (
|
|
61
|
+
isLoopbackHost(other.hostname) &&
|
|
62
|
+
other.protocol === parsed.protocol &&
|
|
63
|
+
other.hostname === parsed.hostname &&
|
|
64
|
+
other.pathname === parsed.pathname
|
|
65
|
+
);
|
|
66
|
+
} catch {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
}
|
package/src/auth/oauth/server.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { MCP_SCOPE } from './metadata.ts';
|
|
1
|
+
import { grantableScope, MCP_SCOPE } from './metadata.ts';
|
|
2
|
+
import { isSafeRedirect, matchesRegistered } from './redirects.ts';
|
|
2
3
|
import {
|
|
3
4
|
hashToken,
|
|
4
5
|
randomToken,
|
|
@@ -26,6 +27,21 @@ import {
|
|
|
26
27
|
const CODE_TTL_MS = 60_000;
|
|
27
28
|
const REFRESH_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
28
29
|
|
|
30
|
+
/**
|
|
31
|
+
* How long a spent refresh token still answers.
|
|
32
|
+
*
|
|
33
|
+
* A client whose refresh succeeded but whose *response* was lost holds a token
|
|
34
|
+
* the server has already spent, and retrying with it is the only move it has.
|
|
35
|
+
* Without a window that retry is `invalid_grant`, and the reference MCP client
|
|
36
|
+
* rethrows every `OAuthError` but `server_error` rather than recovering — so
|
|
37
|
+
* the connector dies and its owner is sent to a browser, over a network blip.
|
|
38
|
+
*
|
|
39
|
+
* Thirty seconds is the band Auth0's reuse interval (0–60 s) and Okta's grace
|
|
40
|
+
* period occupy. What it costs: a captured refresh token keeps working for up
|
|
41
|
+
* to this long after the real client next rotates it.
|
|
42
|
+
*/
|
|
43
|
+
const REFRESH_REUSE_MS = 30_000;
|
|
44
|
+
|
|
29
45
|
export type OAuthResult =
|
|
30
46
|
| { readonly kind: 'json'; readonly status: number; readonly body: unknown }
|
|
31
47
|
| { readonly kind: 'redirect'; readonly location: string }
|
|
@@ -61,6 +77,9 @@ export interface OAuthServerOptions {
|
|
|
61
77
|
/** Proof of being the owner. The same token the endpoint already accepts. */
|
|
62
78
|
readonly verifyOwner: (presented: string) => Promise<boolean>;
|
|
63
79
|
readonly accessTokenTtlMs: number;
|
|
80
|
+
/** Where a replayed refresh token is recorded. Structural, because this layer
|
|
81
|
+
* may not import `#connectivity`; the endpoint's own logger satisfies it. */
|
|
82
|
+
readonly log?: { warn(message: string, detail?: Record<string, unknown>): void };
|
|
64
83
|
readonly now?: () => number;
|
|
65
84
|
}
|
|
66
85
|
|
|
@@ -163,7 +182,11 @@ export class OAuthServer {
|
|
|
163
182
|
redirectUri,
|
|
164
183
|
codeChallenge: challenge,
|
|
165
184
|
state: params.get('state') ?? undefined,
|
|
166
|
-
|
|
185
|
+
// The grantable part of what was asked for, not the request verbatim.
|
|
186
|
+
// Echoing it back through `#issue` was granting by echo, which was inert
|
|
187
|
+
// while `mcp` was the only scope and stops being inert now that there is
|
|
188
|
+
// a second one that means something.
|
|
189
|
+
scope: grantableScope(params.get('scope')) || MCP_SCOPE,
|
|
167
190
|
resource: params.get('resource') ?? undefined,
|
|
168
191
|
},
|
|
169
192
|
};
|
|
@@ -198,7 +221,12 @@ export class OAuthServer {
|
|
|
198
221
|
clientId: request.clientId,
|
|
199
222
|
redirectUri: request.redirectUri,
|
|
200
223
|
codeChallenge: request.codeChallenge,
|
|
201
|
-
|
|
224
|
+
// Narrowed here as well as in `authorize`, and this is the one that
|
|
225
|
+
// matters: the request arrives back through hidden form fields, so a
|
|
226
|
+
// caller can post any scope it likes straight to this endpoint. Nothing
|
|
227
|
+
// round-tripped through the form is trusted — the client and the redirect
|
|
228
|
+
// URI are re-checked above for the same reason.
|
|
229
|
+
scope: grantableScope(request.scope) || MCP_SCOPE,
|
|
202
230
|
...(request.resource ? { resource: request.resource } : {}),
|
|
203
231
|
expiresAt: this.#now() + CODE_TTL_MS,
|
|
204
232
|
};
|
|
@@ -252,15 +280,25 @@ export class OAuthServer {
|
|
|
252
280
|
return invalid('invalid_grant', 'That refresh token is unknown or expired.');
|
|
253
281
|
}
|
|
254
282
|
|
|
255
|
-
// A spent token presented again
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
-
//
|
|
259
|
-
// So the whole chain goes. A client retrying a response it never saw and a
|
|
260
|
-
// thief replaying are indistinguishable from here, and re-authorising is
|
|
261
|
-
// the cheaper of the two mistakes.
|
|
283
|
+
// A spent token presented again used to take its whole family with it, on
|
|
284
|
+
// the reading that a replay is a theft. Against a real connector that was
|
|
285
|
+
// wrong twice over, and ADR-035 has the evidence. Two answers replace it,
|
|
286
|
+
// and the tombstone's age is what tells them apart.
|
|
262
287
|
if (record.kind === 'consumed') {
|
|
263
|
-
|
|
288
|
+
// Inside the window it is a retry of a request already answered, and the
|
|
289
|
+
// client is owed the answer rather than a dead connector. Not re-consumed:
|
|
290
|
+
// a client retrying twice is still retrying.
|
|
291
|
+
const spentAt = record.consumedAt;
|
|
292
|
+
if (spentAt !== undefined && this.#now() - spentAt <= REFRESH_REUSE_MS) {
|
|
293
|
+
return this.#issue(record.clientId, record.scope, randomToken('llr'), record.family);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Outside it, refused on its own — and the family survives, which is the
|
|
297
|
+
// half that was taking live sessions down with it.
|
|
298
|
+
this.#options.log?.warn('refresh token replayed', {
|
|
299
|
+
clientId: record.clientId,
|
|
300
|
+
family: record.family,
|
|
301
|
+
});
|
|
264
302
|
return invalid('invalid_grant', 'That refresh token has already been used.');
|
|
265
303
|
}
|
|
266
304
|
|
|
@@ -324,62 +362,4 @@ function invalid(error: string, description: string): OAuthResult {
|
|
|
324
362
|
return { kind: 'json', status: 400, body: { error, error_description: description } };
|
|
325
363
|
}
|
|
326
364
|
|
|
327
|
-
/**
|
|
328
|
-
* https, or loopback for a native client.
|
|
329
|
-
*
|
|
330
|
-
* A native client cannot receive an https redirect, so RFC 8252 has it listen
|
|
331
|
-
* on a loopback port instead. Everything else is refused: a redirect to `http://`
|
|
332
|
-
* on a routable host puts an authorization code on the wire in clear text.
|
|
333
|
-
*/
|
|
334
|
-
function isSafeRedirect(uri: string): boolean {
|
|
335
|
-
let parsed: URL;
|
|
336
|
-
try {
|
|
337
|
-
parsed = new URL(uri);
|
|
338
|
-
} catch {
|
|
339
|
-
return false;
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
if (parsed.protocol === 'https:') return true;
|
|
343
|
-
return parsed.protocol === 'http:' && isLoopbackHost(parsed.hostname);
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
function isLoopbackHost(hostname: string): boolean {
|
|
347
|
-
return hostname === '127.0.0.1' || hostname === '::1' || hostname === '[::1]' || hostname === 'localhost';
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
/**
|
|
351
|
-
* Exact match, except for the port of a loopback URI.
|
|
352
|
-
*
|
|
353
|
-
* RFC 8252 §7.3 requires ignoring the port for the IP-literal form, because a
|
|
354
|
-
* native client binds an ephemeral one it cannot know at registration time.
|
|
355
|
-
* Claude Code declares `http://localhost/callback` and `http://127.0.0.1/callback`
|
|
356
|
-
* and then listens on whatever port it got, so the same allowance has to cover
|
|
357
|
-
* `localhost` or it never connects.
|
|
358
|
-
*/
|
|
359
|
-
export function matchesRegistered(candidate: string, registered: readonly string[]): boolean {
|
|
360
|
-
if (registered.includes(candidate)) return true;
|
|
361
|
-
|
|
362
|
-
let parsed: URL;
|
|
363
|
-
try {
|
|
364
|
-
parsed = new URL(candidate);
|
|
365
|
-
} catch {
|
|
366
|
-
return false;
|
|
367
|
-
}
|
|
368
|
-
if (!isLoopbackHost(parsed.hostname)) return false;
|
|
369
|
-
|
|
370
|
-
return registered.some((uri) => {
|
|
371
|
-
try {
|
|
372
|
-
const other = new URL(uri);
|
|
373
|
-
return (
|
|
374
|
-
isLoopbackHost(other.hostname) &&
|
|
375
|
-
other.protocol === parsed.protocol &&
|
|
376
|
-
other.hostname === parsed.hostname &&
|
|
377
|
-
other.pathname === parsed.pathname
|
|
378
|
-
);
|
|
379
|
-
} catch {
|
|
380
|
-
return false;
|
|
381
|
-
}
|
|
382
|
-
});
|
|
383
|
-
}
|
|
384
|
-
|
|
385
365
|
export { hashToken };
|
package/src/auth/oauth/store.ts
CHANGED
|
@@ -53,6 +53,9 @@ export interface AuthorizationCode {
|
|
|
53
53
|
* it arrived. A tombstone keeps the family id and nothing else useful, and it
|
|
54
54
|
* opens no more than a deleted row does — every check that admits a credential
|
|
55
55
|
* tests for `access` by name.
|
|
56
|
+
*
|
|
57
|
+
* What is *done* about a detected replay changed in ADR-035: the presented
|
|
58
|
+
* token is refused and the replay logged, rather than the family revoked.
|
|
56
59
|
*/
|
|
57
60
|
export type TokenKind = 'access' | 'refresh' | 'consumed';
|
|
58
61
|
|
|
@@ -71,6 +74,15 @@ export interface IssuedToken {
|
|
|
71
74
|
* the theft and the retry look identical from here.
|
|
72
75
|
*/
|
|
73
76
|
readonly family: string;
|
|
77
|
+
/**
|
|
78
|
+
* When this token was spent, on a `consumed` tombstone and nowhere else.
|
|
79
|
+
*
|
|
80
|
+
* What makes the reuse interval possible: without it a spent token carries no
|
|
81
|
+
* hint whether it was spent a second ago or a month ago, and those are a retry
|
|
82
|
+
* and a replay. A tombstone written before this existed has no `consumedAt`
|
|
83
|
+
* and is read as the older one, which is the safe direction.
|
|
84
|
+
*/
|
|
85
|
+
readonly consumedAt?: number;
|
|
74
86
|
}
|
|
75
87
|
|
|
76
88
|
export function hashToken(value: string): string {
|
|
@@ -182,16 +194,18 @@ export class OAuthStore {
|
|
|
182
194
|
const key = hashToken(token);
|
|
183
195
|
const record = await this.#read<IssuedToken>(TOKENS, key);
|
|
184
196
|
if (!record) return;
|
|
185
|
-
|
|
197
|
+
const spent: IssuedToken = { ...record, kind: 'consumed', consumedAt: this.#now() };
|
|
198
|
+
await this.#state.set(TOKENS, key, JSON.stringify(spent));
|
|
186
199
|
}
|
|
187
200
|
|
|
188
201
|
/**
|
|
189
202
|
* Drop every token in a refresh family.
|
|
190
203
|
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
204
|
+
* A replay no longer calls this, and a replay was the only thing that did —
|
|
205
|
+
* see `OAuthServer.#refresh` and ADR-035. Kept because it is the shape a
|
|
206
|
+
* deliberate revocation takes: one authorization's whole chain, dropped on
|
|
207
|
+
* purpose. Nothing in `src/` reaches it today, so read a call site as new
|
|
208
|
+
* policy rather than as the old one returning.
|
|
195
209
|
*/
|
|
196
210
|
async revokeFamily(family: string): Promise<void> {
|
|
197
211
|
for (const key of await this.#state.keys(TOKENS)) {
|
|
@@ -113,6 +113,12 @@ export function deployPlan(input: PlanInput): DeployStep[] {
|
|
|
113
113
|
// harness can mint one — so a target reached by a remote MCP client
|
|
114
114
|
// declares `public` and gates the request in the application instead.
|
|
115
115
|
cloudrun.access === 'iam' ? '--no-allow-unauthenticated' : '--allow-unauthenticated',
|
|
116
|
+
// Always passed, including the zero. Config is the source of truth here
|
|
117
|
+
// (ADR-004), and a flag sent only when non-zero would let a value be
|
|
118
|
+
// raised and never lowered — the revision would keep whatever the last
|
|
119
|
+
// deploy that bothered to mention it had set.
|
|
120
|
+
'--min-instances',
|
|
121
|
+
String(cloudrun.min_instances),
|
|
116
122
|
],
|
|
117
123
|
},
|
|
118
124
|
];
|
|
@@ -127,6 +127,9 @@ export async function surveyCloudRun(input: SurveyInput): Promise<SurveyResult>
|
|
|
127
127
|
|
|
128
128
|
const deploy: DeployConfig = {
|
|
129
129
|
platform: 'cloudrun',
|
|
130
|
+
// Not asked about. Zero is right for almost every target and the question
|
|
131
|
+
// would cost every operator a decision to buy one of them a knob.
|
|
132
|
+
min_instances: current.min_instances ?? 0,
|
|
130
133
|
project,
|
|
131
134
|
region,
|
|
132
135
|
service,
|
|
@@ -34,11 +34,20 @@ const selfAuthorizationSchema = z.object({
|
|
|
34
34
|
/**
|
|
35
35
|
* How long an issued access token lives.
|
|
36
36
|
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
37
|
+
* Twelve hours, where this used to be one. The old reading — short by design,
|
|
38
|
+
* refreshed rather than lengthened — assumed the refresh happens. Expiry is
|
|
39
|
+
* in practice where a remote client loses its session: one observed against
|
|
40
|
+
* this endpoint let its access token lapse and reported needing authorization
|
|
41
|
+
* while the matching refresh token sat in the store unused, weeks from its
|
|
42
|
+
* own expiry. That is the client's bug and nothing here can fix it. What the
|
|
43
|
+
* endpoint can do is stop offering the chance twenty-four times a day.
|
|
44
|
+
*
|
|
45
|
+
* What is given up is real and bounded: a stolen access token is useful for
|
|
46
|
+
* longer. The revocable half is unchanged — the refresh path is still what a
|
|
47
|
+
* dropped row closes — and the refresh token was already the longer-lived of
|
|
48
|
+
* the pair at thirty days. `1440` is the ceiling, for one window a day.
|
|
40
49
|
*/
|
|
41
|
-
access_token_ttl_minutes: z.number().int().positive().max(1440).default(
|
|
50
|
+
access_token_ttl_minutes: z.number().int().positive().max(1440).default(720),
|
|
42
51
|
});
|
|
43
52
|
|
|
44
53
|
const oidcAuthorizationSchema = z.object({
|
package/src/profile/schema.ts
CHANGED
|
@@ -178,6 +178,20 @@ export const deployTargetSchema = z.object({
|
|
|
178
178
|
billing_account: z.string().optional(),
|
|
179
179
|
/** The identity the running revision assumes. Needs read access to the credential store. */
|
|
180
180
|
service_account: z.string().optional(),
|
|
181
|
+
/**
|
|
182
|
+
* Instances kept running when nothing is calling.
|
|
183
|
+
*
|
|
184
|
+
* Zero is the default and the right answer for almost everything here: a cold
|
|
185
|
+
* start on the MCP path measures under three seconds, and the platform queues
|
|
186
|
+
* the request behind it, so scaling to zero is invisible to a caller.
|
|
187
|
+
*
|
|
188
|
+
* It is a knob because one path is not a caller. A client refreshes its token
|
|
189
|
+
* exactly when it wakes after an idle gap — which is exactly when the instance
|
|
190
|
+
* is cold — and a refresh that fails at the network level sends a remote
|
|
191
|
+
* client through a fresh browser authorization rather than surfacing an error.
|
|
192
|
+
* Raise it if a re-authorization ever lines up with a cold `/token`.
|
|
193
|
+
*/
|
|
194
|
+
min_instances: z.number().int().min(0).max(10).default(0),
|
|
181
195
|
});
|
|
182
196
|
|
|
183
197
|
/**
|
|
@@ -221,7 +235,18 @@ export const targetSchema = z
|
|
|
221
235
|
.transform(({ cloudrun, ...target }) =>
|
|
222
236
|
target.deploy || !cloudrun
|
|
223
237
|
? target
|
|
224
|
-
: {
|
|
238
|
+
: {
|
|
239
|
+
...target,
|
|
240
|
+
// The pre-`deploy` spelling predates both of these, so it gets the
|
|
241
|
+
// same defaults the current one would: the closed door, and no
|
|
242
|
+
// instance kept warm.
|
|
243
|
+
deploy: {
|
|
244
|
+
...cloudrun,
|
|
245
|
+
platform: 'cloudrun' as const,
|
|
246
|
+
access: 'iam' as const,
|
|
247
|
+
min_instances: 0,
|
|
248
|
+
},
|
|
249
|
+
},
|
|
225
250
|
);
|
|
226
251
|
|
|
227
252
|
/**
|
package/src/server/endpoint.ts
CHANGED
|
@@ -169,6 +169,7 @@ function closeAll(runtimes: ReadonlyMap<string, Runtime>): Promise<unknown> {
|
|
|
169
169
|
*/
|
|
170
170
|
async function openAuthorization(
|
|
171
171
|
primary: Runtime,
|
|
172
|
+
log: Logger,
|
|
172
173
|
): Promise<{ surface: AuthorizationSurface; authenticator: Authenticator } | null> {
|
|
173
174
|
const declared = primary.config.auth.authorization;
|
|
174
175
|
if (!declared) return null;
|
|
@@ -210,6 +211,10 @@ async function openAuthorization(
|
|
|
210
211
|
const server = new OAuthServer({
|
|
211
212
|
store,
|
|
212
213
|
accessTokenTtlMs: declared.access_token_ttl_minutes * 60_000,
|
|
214
|
+
// So a replayed refresh token leaves a line. It is refused rather than
|
|
215
|
+
// acted on (ADR-035), and a refusal nobody can see is how a connector
|
|
216
|
+
// losing its authorization came to need log forensics to explain.
|
|
217
|
+
log,
|
|
213
218
|
// Approval is proof of holding the endpoint token, compared the same way
|
|
214
219
|
// the request path compares it. There is one person behind this endpoint
|
|
215
220
|
// and they already have exactly one credential; a second one invented for
|
|
@@ -228,6 +233,7 @@ async function openAuthorization(
|
|
|
228
233
|
|
|
229
234
|
export async function startEndpoint(options: EndpointOptions): Promise<RunningEndpoint> {
|
|
230
235
|
const reporter = options.reporter ?? SILENT;
|
|
236
|
+
const log = options.log ?? silentLogger();
|
|
231
237
|
const { primary, runtimes } = await openReconciled(options);
|
|
232
238
|
|
|
233
239
|
try {
|
|
@@ -251,7 +257,7 @@ export async function startEndpoint(options: EndpointOptions): Promise<RunningEn
|
|
|
251
257
|
}
|
|
252
258
|
}
|
|
253
259
|
|
|
254
|
-
const gate = await openAuthorization(primary);
|
|
260
|
+
const gate = await openAuthorization(primary, log);
|
|
255
261
|
|
|
256
262
|
// The authenticator and the authorization gate are built once, from the
|
|
257
263
|
// runtime this endpoint booted with, and are deliberately not part of what
|
|
@@ -273,7 +279,10 @@ export async function startEndpoint(options: EndpointOptions): Promise<RunningEn
|
|
|
273
279
|
// not refresh skills`, and every `mcp handler error` the endpoint raises
|
|
274
280
|
// all went to those empty methods. A silent endpoint is not a quiet one —
|
|
275
281
|
// it is one whose failures have to be reconstructed from request sizes.
|
|
276
|
-
|
|
282
|
+
// `remoteClients` is the gate's existence, not a second setting: a profile
|
|
283
|
+
// declaring `auth.authorization` is one a connector reaches by URL, which
|
|
284
|
+
// is exactly the client the extra paragraph is written for.
|
|
285
|
+
{ primary: primary.resolution.profile, log, ...(gate ? { remoteClients: true } : {}) },
|
|
277
286
|
);
|
|
278
287
|
|
|
279
288
|
const server = serve({
|
|
@@ -282,7 +291,7 @@ export async function startEndpoint(options: EndpointOptions): Promise<RunningEn
|
|
|
282
291
|
authenticator: gate
|
|
283
292
|
? new AuthenticatorChain([primary.authenticator, gate.authenticator])
|
|
284
293
|
: primary.authenticator,
|
|
285
|
-
log
|
|
294
|
+
log,
|
|
286
295
|
...(gate ? { authorization: gate.surface } : {}),
|
|
287
296
|
...(options.port !== undefined ? { port: options.port } : {}),
|
|
288
297
|
...(options.host !== undefined ? { host: options.host } : {}),
|
package/src/server/generation.ts
CHANGED
|
@@ -52,6 +52,8 @@ export interface GenerationDeps {
|
|
|
52
52
|
readonly primary: string;
|
|
53
53
|
readonly log: Logger;
|
|
54
54
|
readonly version?: string | undefined;
|
|
55
|
+
/** Whether an authorization surface is published. See `BuildServerOptions`. */
|
|
56
|
+
readonly remoteClients?: boolean | undefined;
|
|
55
57
|
}
|
|
56
58
|
|
|
57
59
|
/** What a reload did, as the `/reload` route reports it. */
|
package/src/server/harness.ts
CHANGED
|
@@ -86,6 +86,14 @@ export { parseConfig } from '#profile';
|
|
|
86
86
|
export interface HarnessOptions {
|
|
87
87
|
profile: string;
|
|
88
88
|
log?: Logger;
|
|
89
|
+
/**
|
|
90
|
+
* The clock the authorization server and its store share.
|
|
91
|
+
*
|
|
92
|
+
* Shared deliberately: a tombstone's `consumedAt` is written by the store and
|
|
93
|
+
* compared by the server, so two clocks would make the reuse interval
|
|
94
|
+
* untestable in the one direction that matters. Absent means `Date.now`.
|
|
95
|
+
*/
|
|
96
|
+
now?: () => number;
|
|
89
97
|
port: number;
|
|
90
98
|
policy: string;
|
|
91
99
|
token?: string;
|
|
@@ -218,13 +226,17 @@ export function startHarness(options: HarnessOptions): Harness {
|
|
|
218
226
|
// The real wiring from `endpoint.ts`, not a stand-in: the flow under test is
|
|
219
227
|
// the one a connector drives over HTTP, and a fake authorization server would
|
|
220
228
|
// demonstrate that the fake works.
|
|
221
|
-
const
|
|
229
|
+
const log = options.log ?? silentLogger();
|
|
230
|
+
|
|
231
|
+
const store = options.authorization ? new OAuthStore(state.kv, options.now) : null;
|
|
222
232
|
const gate = store
|
|
223
233
|
? {
|
|
224
234
|
surface: {
|
|
225
235
|
server: new OAuthServer({
|
|
226
236
|
store,
|
|
227
237
|
accessTokenTtlMs: 3_600_000,
|
|
238
|
+
log,
|
|
239
|
+
...(options.now ? { now: options.now } : {}),
|
|
228
240
|
verifyOwner: (presented) => Promise.resolve(tokensMatch(presented, token)),
|
|
229
241
|
}),
|
|
230
242
|
issuer: (origin: string) => origin,
|
|
@@ -235,8 +247,6 @@ export function startHarness(options: HarnessOptions): Harness {
|
|
|
235
247
|
}
|
|
236
248
|
: null;
|
|
237
249
|
|
|
238
|
-
const log = options.log ?? silentLogger();
|
|
239
|
-
|
|
240
250
|
const nothing = () => Promise.resolve();
|
|
241
251
|
const generations = new Generations(
|
|
242
252
|
{ profiles, close: nothing },
|
package/src/server/index.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { challenge, type Authenticator } from '#auth';
|
|
1
|
+
import { challenge, type Authenticator, type AuthOutcome, type ChallengeError } from '#auth';
|
|
2
2
|
import type { Logger } from '#connectivity';
|
|
3
3
|
import { capabilityIdForToolName } from '#server/mcp';
|
|
4
4
|
import { ATTACHMENTS_PATH, stageAttachment } from './attachments.ts';
|
|
@@ -51,6 +51,34 @@ export interface ServerOptions {
|
|
|
51
51
|
readonly allowedHostnames?: readonly string[] | undefined;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
type RefusalReason = Extract<AuthOutcome, { ok: false }>['reason'];
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* What a caller should do about each refusal.
|
|
58
|
+
*
|
|
59
|
+
* `invalid` is the only one a client can act on by itself: it presented a
|
|
60
|
+
* credential and this endpoint did not accept it, which is what a refresh is
|
|
61
|
+
* for. RFC 6750 §3.1 has a name for that and clients branch on it; the others
|
|
62
|
+
* mean there is nothing to refresh, and §3 says to stay quiet rather than send
|
|
63
|
+
* a client after a token it does not hold. `malformed` says nothing either —
|
|
64
|
+
* `invalid_request` carries a SHOULD of a 400 status, and changing that path's
|
|
65
|
+
* status is a larger question than this answers.
|
|
66
|
+
*/
|
|
67
|
+
const CHALLENGE: Partial<Record<RefusalReason, ChallengeError>> = {
|
|
68
|
+
invalid: {
|
|
69
|
+
code: 'invalid_token',
|
|
70
|
+
description: 'The credential is expired, revoked, or not one this endpoint issued.',
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/** The same four, for whoever is reading the body rather than the header. */
|
|
75
|
+
const HINTS: Record<RefusalReason, string> = {
|
|
76
|
+
missing: 'Present the profile token as: Authorization: Bearer <token>',
|
|
77
|
+
malformed: 'Present the profile token as: Authorization: Bearer <token>',
|
|
78
|
+
invalid: 'Refresh the credential. Authorize again only if the refresh is refused too.',
|
|
79
|
+
not_configured: 'This profile has no token yet. Run: lanes link token rotate',
|
|
80
|
+
};
|
|
81
|
+
|
|
54
82
|
export const MCP_PATH = '/mcp';
|
|
55
83
|
export const RELOAD_PATH = '/reload';
|
|
56
84
|
|
|
@@ -174,16 +202,13 @@ export function createRequestHandler(options: ServerOptions): RequestHandler {
|
|
|
174
202
|
JSON.stringify({
|
|
175
203
|
error: 'unauthorized',
|
|
176
204
|
reason: outcome.reason,
|
|
177
|
-
hint:
|
|
178
|
-
outcome.reason === 'not_configured'
|
|
179
|
-
? 'This profile has no token yet. Run: lanes link token rotate'
|
|
180
|
-
: 'Present the profile token as: Authorization: Bearer <token>',
|
|
205
|
+
hint: HINTS[outcome.reason],
|
|
181
206
|
}),
|
|
182
207
|
{
|
|
183
208
|
status: 401,
|
|
184
209
|
headers: {
|
|
185
210
|
'content-type': 'application/json',
|
|
186
|
-
'www-authenticate': challenge(metadata),
|
|
211
|
+
'www-authenticate': challenge(metadata, CHALLENGE[outcome.reason]),
|
|
187
212
|
},
|
|
188
213
|
},
|
|
189
214
|
);
|
package/src/server/mcp/build.ts
CHANGED
|
@@ -46,7 +46,7 @@ export function buildMcpServer(options: BuildServerOptions): McpServer {
|
|
|
46
46
|
// and `Implementation` would take it as an unknown extra and drop it from
|
|
47
47
|
// `initialize` without complaining.
|
|
48
48
|
{
|
|
49
|
-
instructions: serverInstructions(names, merged),
|
|
49
|
+
instructions: serverInstructions(names, merged, options.remoteClients),
|
|
50
50
|
// Declared `false` because it is false, and the SDK defaults it to `true`.
|
|
51
51
|
//
|
|
52
52
|
// `listChanged` is a promise to send `notifications/tools/list_changed`
|
|
@@ -34,10 +34,10 @@ import type { MergedCapability } from './visibility.ts';
|
|
|
34
34
|
/**
|
|
35
35
|
* The habits, in the order they are needed.
|
|
36
36
|
*
|
|
37
|
-
* Routing first because it gates every call;
|
|
38
|
-
* an agent is most tempted to improvise. Second person, and
|
|
39
|
-
* *not* to do — "ask which profile" is advice, "do not
|
|
40
|
-
* a rule.
|
|
37
|
+
* Routing first because it gates every call; the two ways a call ends badly last,
|
|
38
|
+
* because that is when an agent is most tempted to improvise. Second person, and
|
|
39
|
+
* specific about what *not* to do — "ask which profile" is advice, "do not
|
|
40
|
+
* default to the first" is a rule.
|
|
41
41
|
*
|
|
42
42
|
* **Four of these are conditional**, and that is a correctness property rather
|
|
43
43
|
* than a saving. This used to be one fixed string that told every client to
|
|
@@ -95,6 +95,27 @@ const REFUSAL = `**A refused call is the permission system working**, not an obs
|
|
|
95
95
|
around. Report what was refused and let the owner decide whether to widen it.
|
|
96
96
|
Every call, including a refused one, is recorded.`;
|
|
97
97
|
|
|
98
|
+
/**
|
|
99
|
+
* The one about not reaching here at all.
|
|
100
|
+
*
|
|
101
|
+
* Only for a client that authorises against this endpoint over the network —
|
|
102
|
+
* the one that cannot be handed the bundled skill, and the one whose connector
|
|
103
|
+
* decides on its own whether this endpoint is available. Observed: with the
|
|
104
|
+
* endpoint up and idle, a connector reported it unreachable without issuing a
|
|
105
|
+
* request at all, and the model read that as a fault, then re-derived an answer
|
|
106
|
+
* it had already given and re-composed an entry it had already written. Nothing
|
|
107
|
+
* here can prevent it, because nothing here is consulted — the call never
|
|
108
|
+
* arrives. Telling the model what the state means is the whole of what is left.
|
|
109
|
+
*
|
|
110
|
+
* Deliberately *not* "the endpoint is asleep". Usually it is not, and prose
|
|
111
|
+
* asserting a cause the model cannot check is how a wrong diagnosis gets
|
|
112
|
+
* repeated with confidence.
|
|
113
|
+
*/
|
|
114
|
+
const AVAILABILITY = `**A call may simply not go through.** This endpoint is one machine its owner
|
|
115
|
+
runs, and a client can report it unreachable while it is up. That is ordinary —
|
|
116
|
+
not a fault to diagnose, and not authorization you have lost. Say the call did
|
|
117
|
+
not land, do not redo what already succeeded, and offer to retry.`;
|
|
118
|
+
|
|
98
119
|
/** Which paragraph each owner-layer provider brings, when it is reachable. */
|
|
99
120
|
const OWNER_HABITS: Record<string, string> = {
|
|
100
121
|
memory: MEMORY,
|
|
@@ -111,6 +132,11 @@ const OWNER_HABITS: Record<string, string> = {
|
|
|
111
132
|
* it is the prompt to ask whether the paragraph belongs in the skill instead,
|
|
112
133
|
* where it is loaded only when relevant.
|
|
113
134
|
*
|
|
135
|
+
* It was raised once, from 2000, for `AVAILABILITY` — and that question was
|
|
136
|
+
* asked and answered the other way: the client that paragraph exists for is
|
|
137
|
+
* precisely the one that holds no skills directory, so the skill is not a place
|
|
138
|
+
* it can go. Only an endpoint serving remote clients spends it.
|
|
139
|
+
*
|
|
114
140
|
* Exported because the test asserted `2000` as a literal while the code
|
|
115
141
|
* reserved room against a second, differently-derived number — so the two could
|
|
116
142
|
* disagree, and did. There is no separate listing allowance any more: `spent`
|
|
@@ -118,7 +144,7 @@ const OWNER_HABITS: Record<string, string> = {
|
|
|
118
144
|
* exactly the final length, because `join` adds the same two characters the
|
|
119
145
|
* reduce already counted.
|
|
120
146
|
*/
|
|
121
|
-
export const MAX_INSTRUCTIONS =
|
|
147
|
+
export const MAX_INSTRUCTIONS = 2300;
|
|
122
148
|
|
|
123
149
|
/** Which of the owner-layer providers this principal can actually reach. */
|
|
124
150
|
function ownerProviders(merged: ReadonlyMap<string, MergedCapability>): string[] {
|
|
@@ -172,6 +198,9 @@ function connectionsByProfile(
|
|
|
172
198
|
export function serverInstructions(
|
|
173
199
|
profiles: readonly string[],
|
|
174
200
|
merged: ReadonlyMap<string, MergedCapability>,
|
|
201
|
+
/** Whether a client authorises against this endpoint rather than being handed
|
|
202
|
+
* a token — see `AVAILABILITY`, the only paragraph that reads it. */
|
|
203
|
+
remoteClients = false,
|
|
175
204
|
): string {
|
|
176
205
|
const reachable = connectionsByProfile(profiles, merged);
|
|
177
206
|
const owner = ownerProviders(merged);
|
|
@@ -185,6 +214,7 @@ export function serverInstructions(
|
|
|
185
214
|
...owner.map((id) => OWNER_HABITS[id]).filter((habit): habit is string => habit !== undefined),
|
|
186
215
|
FILES,
|
|
187
216
|
REFUSAL,
|
|
217
|
+
...(remoteClients ? [AVAILABILITY] : []),
|
|
188
218
|
];
|
|
189
219
|
|
|
190
220
|
if (reachable.size === 0) {
|
|
@@ -53,6 +53,15 @@ export interface BuildServerOptions {
|
|
|
53
53
|
/** Self-reported by the client. Recorded in audit; never used to authorize. */
|
|
54
54
|
readonly clientLabel?: string | undefined;
|
|
55
55
|
readonly version?: string;
|
|
56
|
+
/**
|
|
57
|
+
* Whether this endpoint publishes an authorization surface, and therefore
|
|
58
|
+
* serves clients that arrived by URL alone.
|
|
59
|
+
*
|
|
60
|
+
* Read only by the instructions, which gain a paragraph for them. Absent over
|
|
61
|
+
* a pipe and on a loopback endpoint, where the client holds the skill and the
|
|
62
|
+
* transport cannot fail the way this describes.
|
|
63
|
+
*/
|
|
64
|
+
readonly remoteClients?: boolean | undefined;
|
|
56
65
|
}
|
|
57
66
|
|
|
58
67
|
/** One profile as the map the builder wants. */
|