@lanes-sh/link 0.7.2 → 0.9.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/README.md +20 -10
- package/instructions/agents/lanes-link-scout.md +2 -2
- package/instructions/skills/lanes-link/SKILL.md +148 -73
- package/package.json +2 -1
- package/src/audit/index.ts +8 -1
- package/src/auth/index.ts +58 -2
- package/src/auth/lanes/assertion.ts +256 -0
- package/src/auth/lanes/callback.ts +135 -0
- package/src/auth/lanes/federation.ts +50 -0
- package/src/auth/lanes/login.ts +294 -0
- package/src/auth/lanes/members.ts +103 -0
- package/src/auth/lanes/session.ts +97 -0
- package/src/auth/oauth/grant.ts +183 -0
- package/src/auth/oauth/result.ts +27 -0
- package/src/auth/oauth/server.ts +176 -203
- package/src/auth/oauth/store.ts +85 -4
- package/src/auth/remote.ts +32 -9
- package/src/cli/accepts.ts +109 -0
- package/src/cli/argv.ts +57 -3
- package/src/cli/audit-change.ts +140 -0
- package/src/cli/callback-page.ts +36 -115
- package/src/cli/commands/auth-dispatch.ts +48 -0
- package/src/cli/commands/auth.ts +229 -0
- package/src/cli/commands/connect/accounts.ts +4 -4
- package/src/cli/commands/connect/authorise.ts +4 -4
- package/src/cli/commands/connect/bind-credential.ts +2 -1
- package/src/cli/commands/connect/custom/index.ts +1 -1
- package/src/cli/commands/connect/custom/write.ts +2 -2
- package/src/cli/commands/connect/grant.ts +29 -14
- package/src/cli/commands/connect/index.ts +90 -88
- package/src/cli/commands/connect/options.ts +83 -0
- package/src/cli/commands/connect/registration.ts +50 -0
- package/src/cli/commands/connect/requirements.ts +1 -1
- package/src/cli/commands/connect/settle.ts +16 -6
- package/src/cli/commands/connect/target-note.ts +7 -2
- package/src/cli/commands/connect/unknown.ts +1 -1
- package/src/cli/commands/connect/variables.ts +3 -2
- package/src/cli/commands/connection-list.ts +116 -0
- package/src/cli/commands/connection.ts +183 -165
- package/src/cli/commands/grant.ts +140 -0
- package/src/cli/commands/identity.ts +24 -12
- package/src/cli/commands/knowledge/index.ts +49 -89
- package/src/cli/commands/knowledge/migrate.ts +79 -13
- package/src/cli/commands/knowledge/show.ts +97 -0
- package/src/cli/commands/knowledge.ts +2 -1
- package/src/cli/commands/mcp/harnesses.ts +30 -8
- package/src/cli/commands/mcp/onboarding.ts +86 -0
- package/src/cli/commands/mcp/register.ts +16 -2
- package/src/cli/commands/mcp.ts +1 -0
- package/src/cli/commands/members.ts +288 -0
- package/src/cli/commands/operate/attach.ts +3 -3
- package/src/cli/commands/operate/audit.ts +11 -7
- package/src/cli/commands/operate/auth.ts +28 -11
- package/src/cli/commands/operate/findings.ts +2 -1
- package/src/cli/commands/operate/inspect.ts +37 -19
- package/src/cli/commands/operate/migrate.ts +33 -13
- package/src/cli/commands/operate/outputs.ts +3 -3
- package/src/cli/commands/operate/pair-certificate.ts +141 -0
- package/src/cli/commands/operate/pair.ts +324 -0
- package/src/cli/commands/operate/policy.ts +73 -22
- package/src/cli/commands/operate/serve.ts +53 -5
- package/src/cli/commands/operate/status.ts +18 -10
- package/src/cli/commands/operate/tools.ts +2 -2
- package/src/cli/commands/operate.ts +2 -0
- package/src/cli/commands/owner/assets.ts +2 -2
- package/src/cli/commands/owner/entities.ts +2 -2
- package/src/cli/commands/owner/memory.ts +2 -2
- package/src/cli/commands/owner/shared.ts +13 -2
- package/src/cli/commands/owner/skills.ts +28 -8
- package/src/cli/commands/owner/tasks.ts +2 -2
- package/src/cli/commands/owner/vault.ts +3 -3
- package/src/cli/commands/profile/disposition.ts +236 -0
- package/src/cli/commands/profile/removal.ts +154 -64
- package/src/cli/commands/profile/remove.ts +83 -7
- package/src/cli/commands/profile.ts +79 -16
- package/src/cli/commands/relabel.ts +112 -0
- package/src/cli/commands/secrets.ts +39 -17
- package/src/cli/commands/set-workspace.ts +96 -0
- package/src/cli/commands/setup.ts +2 -2
- package/src/cli/commands/sync.ts +8 -8
- package/src/cli/commands/target.ts +9 -7
- package/src/cli/commands/update-migration.ts +54 -0
- package/src/cli/commands/update.ts +78 -24
- package/src/cli/config-edit.ts +99 -143
- package/src/cli/config-migrate.ts +82 -64
- package/src/cli/config-repair-sweep.ts +119 -0
- package/src/cli/config-repair.ts +131 -125
- package/src/cli/config-templates.ts +200 -0
- package/src/cli/contract3-credentials.ts +294 -0
- package/src/cli/contract3-data.ts +262 -0
- package/src/cli/contract3-layout.ts +46 -0
- package/src/cli/contract3-shape.ts +212 -0
- package/src/cli/contract3.ts +399 -0
- package/src/cli/contract4-credentials.ts +207 -0
- package/src/cli/contract4-data.ts +399 -0
- package/src/cli/contract4-rename.ts +73 -0
- package/src/cli/contract4-yaml.ts +223 -0
- package/src/cli/contract4.ts +342 -0
- package/src/cli/endpoint-url.ts +1 -1
- package/src/cli/identity.ts +44 -26
- package/src/cli/lanes.ts +25 -1
- package/src/cli/main.ts +94 -14
- package/src/cli/migrate-move.ts +166 -0
- package/src/cli/migrate-plan.ts +12 -6
- package/src/cli/output.ts +34 -1
- package/src/cli/publish.ts +6 -7
- package/src/cli/runtime/open.ts +64 -99
- package/src/cli/runtime/registry.ts +6 -7
- package/src/cli/runtime/select.ts +2 -11
- package/src/cli/runtime/stores.ts +58 -0
- package/src/cli/runtime/types.ts +106 -0
- package/src/cli/runtime/vault.ts +19 -4
- package/src/cli/runtime/workspace.ts +60 -0
- package/src/cli/runtime.ts +2 -1
- package/src/cli/selection-require.ts +44 -13
- package/src/cli/selection.ts +127 -145
- package/src/cli/usage.ts +40 -20
- package/src/cli/workspace-migrate.ts +152 -22
- package/src/connectivity/manifest/provider.ts +34 -13
- package/src/connectivity/manifest/requirements.ts +1 -1
- package/src/connectivity/transports/imap/parser.ts +70 -9
- package/src/deployments/adapters/filesystem.ts +18 -3
- package/src/deployments/bind.ts +1 -1
- package/src/deployments/deploy.ts +38 -29
- package/src/deployments/gcp/bucket.ts +58 -11
- package/src/deployments/gcp/provision.ts +7 -7
- package/src/deployments/knowledge.ts +9 -4
- package/src/deployments/prepare.ts +72 -24
- package/src/deployments/record.ts +1 -1
- package/src/deployments/report.ts +2 -2
- package/src/deployments/serving.ts +15 -74
- package/src/deployments/target.ts +34 -13
- package/src/deployments/upload.ts +60 -27
- package/src/dispatch/deps.ts +88 -0
- package/src/dispatch/dispatch.ts +21 -62
- package/src/policy/index.ts +47 -15
- package/src/profile/connections.ts +195 -0
- package/src/profile/deployments.ts +86 -8
- package/src/profile/index.ts +35 -6
- package/src/profile/knowledge.ts +18 -5
- package/src/profile/layout.ts +163 -90
- package/src/profile/load.ts +133 -64
- package/src/profile/pairing.ts +32 -0
- package/src/profile/primitives.ts +35 -1
- package/src/profile/registry.ts +6 -6
- package/src/profile/schema.ts +181 -21
- package/src/profile/targets.ts +21 -9
- package/src/profile/testing.ts +104 -2
- package/src/profile/workspace.ts +124 -33
- package/src/providers/assets/provider.ts +6 -6
- package/src/providers/custom/index.ts +1 -1
- package/src/providers/custom/load.ts +2 -3
- package/src/providers/entities/provider.ts +6 -6
- package/src/providers/entities/writes.ts +1 -1
- package/src/providers/identity/provider.ts +2 -2
- package/src/providers/memory/provider.ts +26 -8
- package/src/providers/setup/plan.ts +1 -1
- package/src/providers/setup/provider.ts +3 -3
- package/src/providers/skills/provider.ts +2 -2
- package/src/providers/slack/index.ts +2 -2
- package/src/providers/tasks/provider.ts +6 -6
- package/src/providers/vault/provider.ts +1 -1
- package/src/registry/policy-bridge.ts +33 -11
- package/src/registry/reconcile.ts +4 -4
- package/src/registry/registry.ts +1 -1
- package/src/server/authorization.ts +94 -0
- package/src/server/edge.ts +14 -1
- package/src/server/endpoint.ts +89 -104
- package/src/server/generation.ts +10 -1
- package/src/server/harness.ts +71 -13
- package/src/server/index.ts +31 -0
- package/src/server/mcp/build.ts +20 -1
- package/src/server/mcp/client-info.ts +54 -0
- package/src/server/mcp/guide.ts +120 -0
- package/src/server/mcp/instructions.ts +22 -22
- package/src/server/mcp/prompts.ts +7 -3
- package/src/server/mcp/resources.ts +16 -8
- package/src/server/mcp/routing.ts +3 -3
- package/src/server/mcp/tools.ts +25 -6
- package/src/server/mcp/visibility.ts +74 -7
- package/src/server/oauth.ts +29 -109
- package/src/server/read/credential.ts +134 -0
- package/src/server/read/deployed.ts +56 -0
- package/src/server/read/listener.ts +54 -0
- package/src/server/read/open.ts +101 -0
- package/src/server/read/routes.ts +247 -0
- package/src/server/read/state.ts +171 -0
- package/src/stores/blobs/conformance.ts +19 -0
- package/src/stores/state/index.ts +76 -10
- package/src/stores/state/testing.ts +5 -1
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
+
import { awaitCallback, plainPage, type LandingPage } from './callback.ts';
|
|
3
|
+
import { isFresh, readSession, writeSession, type LanesSession } from './session.ts';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Signing in to Lanes, from a terminal.
|
|
7
|
+
*
|
|
8
|
+
* The same flow the desktop app runs, and deliberately not a second one: a
|
|
9
|
+
* loopback listener on a port the kernel picks, PKCE, Google's consent screen,
|
|
10
|
+
* and the exchange brokered by the Lanes API so no client holds the client
|
|
11
|
+
* secret. `core/src/auth/store.ts` is the reference implementation; this is it
|
|
12
|
+
* without a webview.
|
|
13
|
+
*
|
|
14
|
+
* **Nothing about the identity provider is hardcoded here.** The client id, the
|
|
15
|
+
* Firebase project and its web API key all come from `/v1/auth/google/config`.
|
|
16
|
+
* That is partly hygiene — this repository is public and a real Google Cloud
|
|
17
|
+
* project id must never be committed to it — and partly that a client which
|
|
18
|
+
* discovers its configuration cannot drift from the service that issues it.
|
|
19
|
+
*
|
|
20
|
+
* There are two tokens and they are not interchangeable. Google's `id_token`
|
|
21
|
+
* proves who signed in *to Google*; exchanging it at Firebase produces the Lanes
|
|
22
|
+
* session, whose `sub` is what a profile's `members:` names (ADR-060). Storing
|
|
23
|
+
* the first would be storing a token no part of Lanes accepts.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** Where the API is, unless told otherwise. */
|
|
27
|
+
export const DEFAULT_API_URL = 'https://api.lanes.sh';
|
|
28
|
+
|
|
29
|
+
/** The half of `fetch` this module uses. */
|
|
30
|
+
export type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
|
31
|
+
|
|
32
|
+
export interface LoginOptions {
|
|
33
|
+
readonly apiUrl?: string | undefined;
|
|
34
|
+
/**
|
|
35
|
+
* Injected for tests, and for nothing else.
|
|
36
|
+
*
|
|
37
|
+
* Typed as what is called rather than as `typeof fetch`: Bun's carries a
|
|
38
|
+
* `preconnect` property, and requiring a double to grow one is requiring it
|
|
39
|
+
* to stop standing for the thing it replaces.
|
|
40
|
+
*/
|
|
41
|
+
readonly fetch?: FetchLike | undefined;
|
|
42
|
+
/** Opens the consent screen. Injected so a test never launches a browser. */
|
|
43
|
+
readonly open?: ((url: string) => Promise<void>) | undefined;
|
|
44
|
+
/** Called with the URL, so a headless run can be told where to go. */
|
|
45
|
+
readonly onUrl?: ((url: string) => void) | undefined;
|
|
46
|
+
/**
|
|
47
|
+
* Where the session is written. Injected for tests, and only for tests.
|
|
48
|
+
*
|
|
49
|
+
* It exists because the alternative was found the hard way: without it, the
|
|
50
|
+
* test suite signs the developer's own machine in as a fixture. A function
|
|
51
|
+
* that writes to `$HOME` and takes no way to say otherwise is one every test
|
|
52
|
+
* of it has to either skip or damage something to run.
|
|
53
|
+
*/
|
|
54
|
+
readonly home?: string | undefined;
|
|
55
|
+
/**
|
|
56
|
+
* How the page the browser lands on is rendered.
|
|
57
|
+
*
|
|
58
|
+
* Injected because this component may not reach the CLI's, and the layering is
|
|
59
|
+
* right rather than merely enforced: signing somebody in has no business
|
|
60
|
+
* knowing what a page looks like. `lanes auth login` passes the same branded
|
|
61
|
+
* card a provider connect flow ends on, so the two look like one product.
|
|
62
|
+
*
|
|
63
|
+
* The fallback below is deliberately plain and exists for tests and for any
|
|
64
|
+
* caller that has no opinion. It is not what a person sees.
|
|
65
|
+
*/
|
|
66
|
+
readonly page?: ((outcome: LandingPage) => Response) | undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface BrokerConfig {
|
|
70
|
+
readonly clientId: string;
|
|
71
|
+
readonly firebaseApiKey: string;
|
|
72
|
+
readonly firebaseProjectId: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** RFC 7636 S256. The verifier never leaves this process until the exchange. */
|
|
76
|
+
function pkce(): { verifier: string; challenge: string } {
|
|
77
|
+
const verifier = randomBytes(32).toString('base64url');
|
|
78
|
+
const challenge = createHash('sha256').update(verifier).digest('base64url');
|
|
79
|
+
return { verifier, challenge };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function brokerConfig(
|
|
83
|
+
apiUrl: string,
|
|
84
|
+
call: FetchLike,
|
|
85
|
+
): Promise<BrokerConfig> {
|
|
86
|
+
const response = await call(`${apiUrl}/v1/auth/google/config`);
|
|
87
|
+
if (!response.ok) {
|
|
88
|
+
throw new Error(
|
|
89
|
+
`${apiUrl} would not say how to sign in (${response.status}). ` +
|
|
90
|
+
'If this is a self-hosted API, set LANES_API_URL to reach it.',
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const body = (await response.json()) as { data?: Record<string, unknown> };
|
|
95
|
+
const data = body.data ?? {};
|
|
96
|
+
|
|
97
|
+
const clientId = typeof data['client_id'] === 'string' ? data['client_id'] : '';
|
|
98
|
+
const firebaseApiKey =
|
|
99
|
+
typeof data['firebase_api_key'] === 'string' ? data['firebase_api_key'] : '';
|
|
100
|
+
const firebaseProjectId =
|
|
101
|
+
typeof data['firebase_project_id'] === 'string' ? data['firebase_project_id'] : '';
|
|
102
|
+
|
|
103
|
+
if (!clientId || !firebaseApiKey) {
|
|
104
|
+
throw new Error(
|
|
105
|
+
`${apiUrl} is not configured for sign-in. It answered without a client id or a ` +
|
|
106
|
+
'Firebase key, which means the deployment is missing GOOGLE_OAUTH_CLIENT_ID or ' +
|
|
107
|
+
'FIREBASE_API_KEY.',
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return { clientId, firebaseApiKey, firebaseProjectId };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function login(options: LoginOptions = {}): Promise<LanesSession> {
|
|
115
|
+
const apiUrl = options.apiUrl ?? process.env['LANES_API_URL'] ?? DEFAULT_API_URL;
|
|
116
|
+
const call = options.fetch ?? globalThis.fetch;
|
|
117
|
+
|
|
118
|
+
const config = await brokerConfig(apiUrl, call);
|
|
119
|
+
const { verifier, challenge } = pkce();
|
|
120
|
+
const state = randomBytes(16).toString('base64url');
|
|
121
|
+
|
|
122
|
+
const { code, redirectUri } = await awaitCallback(
|
|
123
|
+
state,
|
|
124
|
+
async (redirectUri) => {
|
|
125
|
+
const url = new URL('https://accounts.google.com/o/oauth2/v2/auth');
|
|
126
|
+
url.searchParams.set('client_id', config.clientId);
|
|
127
|
+
url.searchParams.set('redirect_uri', redirectUri);
|
|
128
|
+
url.searchParams.set('response_type', 'code');
|
|
129
|
+
url.searchParams.set('scope', 'email profile openid');
|
|
130
|
+
url.searchParams.set('code_challenge', challenge);
|
|
131
|
+
url.searchParams.set('code_challenge_method', 'S256');
|
|
132
|
+
url.searchParams.set('state', state);
|
|
133
|
+
// Google issues a refresh token only when asked, and only on a consent
|
|
134
|
+
// screen it has actually shown. Without both, a second sign-in on the same
|
|
135
|
+
// machine returns no refresh token and the session lasts an hour.
|
|
136
|
+
url.searchParams.set('access_type', 'offline');
|
|
137
|
+
url.searchParams.set('prompt', 'consent');
|
|
138
|
+
|
|
139
|
+
options.onUrl?.(url.toString());
|
|
140
|
+
await options.open?.(url.toString());
|
|
141
|
+
},
|
|
142
|
+
options.page ?? plainPage,
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
const google = await exchangeWithBroker(apiUrl, call, {
|
|
146
|
+
code,
|
|
147
|
+
codeVerifier: verifier,
|
|
148
|
+
redirectUri,
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const session = await firebaseSession(config, call, google.idToken, apiUrl);
|
|
152
|
+
await writeSession(session, options.home);
|
|
153
|
+
return session;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function exchangeWithBroker(
|
|
157
|
+
apiUrl: string,
|
|
158
|
+
call: FetchLike,
|
|
159
|
+
input: { code: string; codeVerifier: string; redirectUri: string },
|
|
160
|
+
): Promise<{ idToken: string }> {
|
|
161
|
+
const response = await call(`${apiUrl}/v1/auth/google/exchange`, {
|
|
162
|
+
method: 'POST',
|
|
163
|
+
headers: { 'content-type': 'application/json' },
|
|
164
|
+
body: JSON.stringify({
|
|
165
|
+
code: input.code,
|
|
166
|
+
code_verifier: input.codeVerifier,
|
|
167
|
+
redirect_uri: input.redirectUri,
|
|
168
|
+
}),
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
const body = (await response.json().catch(() => ({}))) as {
|
|
172
|
+
data?: { id_token?: unknown };
|
|
173
|
+
error?: unknown;
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
if (!response.ok || typeof body.data?.id_token !== 'string') {
|
|
177
|
+
throw new Error(
|
|
178
|
+
`The sign-in could not be completed: ${String(body.error ?? response.status)}`,
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return { idToken: body.data.id_token };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Google's id token, exchanged for the Lanes one.
|
|
187
|
+
*
|
|
188
|
+
* Firebase's `signInWithIdp` over REST, because there is no browser here to run
|
|
189
|
+
* the web SDK in. The API key is a public identifier rather than a secret —
|
|
190
|
+
* it is in the web bundle — which is why it can be served to a CLI at all.
|
|
191
|
+
*/
|
|
192
|
+
async function firebaseSession(
|
|
193
|
+
config: BrokerConfig,
|
|
194
|
+
call: FetchLike,
|
|
195
|
+
googleIdToken: string,
|
|
196
|
+
apiUrl: string,
|
|
197
|
+
): Promise<LanesSession> {
|
|
198
|
+
const response = await call(
|
|
199
|
+
`https://identitytoolkit.googleapis.com/v1/accounts:signInWithIdp?key=${config.firebaseApiKey}`,
|
|
200
|
+
{
|
|
201
|
+
method: 'POST',
|
|
202
|
+
headers: { 'content-type': 'application/json' },
|
|
203
|
+
body: JSON.stringify({
|
|
204
|
+
postBody: `id_token=${googleIdToken}&providerId=google.com`,
|
|
205
|
+
requestUri: 'http://127.0.0.1',
|
|
206
|
+
returnSecureToken: true,
|
|
207
|
+
returnIdpCredential: true,
|
|
208
|
+
}),
|
|
209
|
+
},
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
const body = (await response.json().catch(() => ({}))) as {
|
|
213
|
+
localId?: unknown;
|
|
214
|
+
email?: unknown;
|
|
215
|
+
idToken?: unknown;
|
|
216
|
+
refreshToken?: unknown;
|
|
217
|
+
expiresIn?: unknown;
|
|
218
|
+
error?: { message?: unknown };
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
if (
|
|
222
|
+
!response.ok ||
|
|
223
|
+
typeof body.localId !== 'string' ||
|
|
224
|
+
typeof body.idToken !== 'string' ||
|
|
225
|
+
typeof body.refreshToken !== 'string'
|
|
226
|
+
) {
|
|
227
|
+
throw new Error(
|
|
228
|
+
`Lanes would not accept the Google sign-in: ${String(body.error?.message ?? response.status)}`,
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const seconds = Number(body.expiresIn ?? 3600);
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
// The prefix is what keeps a subject out of `secret-detection.ts`'s
|
|
236
|
+
// high-entropy rule and says which provider vouched for it — see
|
|
237
|
+
// `profile/primitives.ts`, where both halves are one decision.
|
|
238
|
+
subject: `lanes:${body.localId}`,
|
|
239
|
+
email: typeof body.email === 'string' ? body.email : null,
|
|
240
|
+
idToken: body.idToken,
|
|
241
|
+
refreshToken: body.refreshToken,
|
|
242
|
+
expiresAt: new Date(Date.now() + seconds * 1000).toISOString(),
|
|
243
|
+
apiUrl,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* A token this machine can present, refreshing it if it has lapsed.
|
|
249
|
+
*
|
|
250
|
+
* The refresh is against Google's secure-token endpoint rather than the Lanes
|
|
251
|
+
* API, which is what the Firebase SDK does in the desktop app and web — the API
|
|
252
|
+
* verifies tokens and does not mint them.
|
|
253
|
+
*/
|
|
254
|
+
export async function currentIdToken(
|
|
255
|
+
options: { fetch?: FetchLike; apiUrl?: string; home?: string } = {},
|
|
256
|
+
): Promise<string | null> {
|
|
257
|
+
const session = await readSession(options.home);
|
|
258
|
+
if (session === null) return null;
|
|
259
|
+
if (isFresh(session)) return session.idToken;
|
|
260
|
+
|
|
261
|
+
const call = options.fetch ?? globalThis.fetch;
|
|
262
|
+
const config = await brokerConfig(session.apiUrl, call).catch(() => null);
|
|
263
|
+
if (config === null) return null;
|
|
264
|
+
|
|
265
|
+
const response = await call(
|
|
266
|
+
`https://securetoken.googleapis.com/v1/token?key=${config.firebaseApiKey}`,
|
|
267
|
+
{
|
|
268
|
+
method: 'POST',
|
|
269
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
270
|
+
body: `grant_type=refresh_token&refresh_token=${encodeURIComponent(session.refreshToken)}`,
|
|
271
|
+
},
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
const body = (await response.json().catch(() => ({}))) as {
|
|
275
|
+
id_token?: unknown;
|
|
276
|
+
refresh_token?: unknown;
|
|
277
|
+
expires_in?: unknown;
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
if (!response.ok || typeof body.id_token !== 'string') return null;
|
|
281
|
+
|
|
282
|
+
const refreshed: LanesSession = {
|
|
283
|
+
...session,
|
|
284
|
+
idToken: body.id_token,
|
|
285
|
+
refreshToken:
|
|
286
|
+
typeof body.refresh_token === 'string' ? body.refresh_token : session.refreshToken,
|
|
287
|
+
expiresAt: new Date(Date.now() + Number(body.expires_in ?? 3600) * 1000).toISOString(),
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
await writeSession(refreshed, options.home);
|
|
291
|
+
return refreshed.idToken;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export type { LandingPage };
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { currentIdToken, type FetchLike } from './login.ts';
|
|
2
|
+
import { readSession } from './session.ts';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The people a Lanes workspace already holds.
|
|
6
|
+
*
|
|
7
|
+
* A profile's `members:` is a *selection from* this list, not a second list
|
|
8
|
+
* beside it. Membership of a workspace is managed on the dashboard — invited,
|
|
9
|
+
* accepted, given a role, removed — and reproducing any of that here would be a
|
|
10
|
+
* second place to say who somebody is, which is the failure this whole release
|
|
11
|
+
* is about on the connection axis.
|
|
12
|
+
*
|
|
13
|
+
* So the flow is: somebody is added to the workspace on the dashboard, and they
|
|
14
|
+
* appear here the moment they accept. Granting them a profile is then a local
|
|
15
|
+
* edit naming a subject the server already vouches for.
|
|
16
|
+
*
|
|
17
|
+
* **A pending invitation is not delegatable, and that is the useful part.** An
|
|
18
|
+
* invited person has an email and no `user_uid` until they accept, so there is
|
|
19
|
+
* no subject to write into a profile — and a `members:` entry naming a guess
|
|
20
|
+
* would look like a working delegation right up until they tried to use it.
|
|
21
|
+
* They are listed, marked pending, and refused.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export interface WorkspaceMember {
|
|
25
|
+
/** `lanes:<uid>`, or null while the invitation is unaccepted. */
|
|
26
|
+
readonly subject: string | null;
|
|
27
|
+
readonly email: string | null;
|
|
28
|
+
readonly role: string;
|
|
29
|
+
readonly status: 'pending' | 'active';
|
|
30
|
+
readonly displayName: string | null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface MembersResult {
|
|
34
|
+
readonly members: readonly WorkspaceMember[];
|
|
35
|
+
/** Why the list is empty, when it is, so a caller can say something useful. */
|
|
36
|
+
readonly unavailable: string | null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Everyone in the Lanes workspace this one is bound to.
|
|
41
|
+
*
|
|
42
|
+
* Returns a reason rather than throwing when it cannot answer. Every caller is a
|
|
43
|
+
* listing or a validation that has something to say either way, and a network
|
|
44
|
+
* failure while editing a local file should not be fatal — the endpoint checks
|
|
45
|
+
* the subject at token time regardless.
|
|
46
|
+
*/
|
|
47
|
+
export async function workspaceMembers(
|
|
48
|
+
lanesWorkspace: string | undefined,
|
|
49
|
+
options: { fetch?: FetchLike; home?: string } = {},
|
|
50
|
+
): Promise<MembersResult> {
|
|
51
|
+
if (lanesWorkspace === undefined) {
|
|
52
|
+
return {
|
|
53
|
+
members: [],
|
|
54
|
+
unavailable: 'this workspace is not bound to a Lanes workspace',
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const session = await readSession(options.home);
|
|
59
|
+
if (session === null) return { members: [], unavailable: 'not signed in' };
|
|
60
|
+
|
|
61
|
+
const token = await currentIdToken({ ...(options.fetch ? { fetch: options.fetch } : {}), ...(options.home ? { home: options.home } : {}) });
|
|
62
|
+
if (token === null) return { members: [], unavailable: 'the session could not be refreshed' };
|
|
63
|
+
|
|
64
|
+
const call = options.fetch ?? globalThis.fetch;
|
|
65
|
+
|
|
66
|
+
const response = await call(`${session.apiUrl}/v1/workspaces/${lanesWorkspace}/members`, {
|
|
67
|
+
headers: { authorization: `Bearer ${token}` },
|
|
68
|
+
}).catch(() => null);
|
|
69
|
+
|
|
70
|
+
if (response === null) return { members: [], unavailable: `${session.apiUrl} is unreachable` };
|
|
71
|
+
if (!response.ok) {
|
|
72
|
+
return { members: [], unavailable: `${session.apiUrl} answered ${response.status}` };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const body = (await response.json().catch(() => ({}))) as {
|
|
76
|
+
data?: {
|
|
77
|
+
user_uid?: unknown;
|
|
78
|
+
email?: unknown;
|
|
79
|
+
role?: unknown;
|
|
80
|
+
status?: unknown;
|
|
81
|
+
display_name?: unknown;
|
|
82
|
+
}[];
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
members: (body.data ?? []).map((row) => ({
|
|
87
|
+
// The subject a profile names is the uid with the prefix `subjectRef`
|
|
88
|
+
// requires — the same string `lanes auth login` stores, derived the same
|
|
89
|
+
// way so the two cannot disagree about who somebody is.
|
|
90
|
+
subject: typeof row.user_uid === 'string' ? `lanes:${row.user_uid}` : null,
|
|
91
|
+
email: typeof row.email === 'string' ? row.email : null,
|
|
92
|
+
role: typeof row.role === 'string' ? row.role : 'member',
|
|
93
|
+
status: row.status === 'active' ? 'active' : 'pending',
|
|
94
|
+
displayName: typeof row.display_name === 'string' ? row.display_name : null,
|
|
95
|
+
})),
|
|
96
|
+
unavailable: null,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** How to show somebody whose subject is the only thing known about them. */
|
|
101
|
+
export function describeMember(member: WorkspaceMember): string {
|
|
102
|
+
return member.displayName ?? member.email ?? member.subject ?? 'unknown';
|
|
103
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The Lanes identity this machine is signed in as.
|
|
7
|
+
*
|
|
8
|
+
* Its own store, beside the desktop app's `~/.lanes/auth.json` rather than
|
|
9
|
+
* inside it: that file holds a uid, an email and a photo URL and no credential,
|
|
10
|
+
* and merging a refresh token into it would change what an existing file means.
|
|
11
|
+
* Two files, one of which is `0600`, is the honest arrangement.
|
|
12
|
+
*
|
|
13
|
+
* **Not in the workspace.** A workspace is a set of accounts and profiles and
|
|
14
|
+
* may be a bucket; who is at the keyboard is neither. Putting this in
|
|
15
|
+
* `~/.lanes-link` would also mean a second workspace signs you out of the
|
|
16
|
+
* first, and would upload the session to a bucket on the next deploy.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** Where the session lives. Overridable for tests, and for nothing else. */
|
|
20
|
+
export function sessionPath(home = homedir()): string {
|
|
21
|
+
return join(home, '.lanes', 'credentials.json');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface LanesSession {
|
|
25
|
+
/** The Firebase subject, which is what a profile's `members:` names. */
|
|
26
|
+
readonly subject: string;
|
|
27
|
+
readonly email: string | null;
|
|
28
|
+
/** Exchanged for a fresh id token; the long-lived half. */
|
|
29
|
+
readonly refreshToken: string;
|
|
30
|
+
/** The current id token, and when it stops being accepted. */
|
|
31
|
+
readonly idToken: string;
|
|
32
|
+
readonly expiresAt: string;
|
|
33
|
+
/** Which API issued it, so a session cannot be replayed against another. */
|
|
34
|
+
readonly apiUrl: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function readSession(home?: string): Promise<LanesSession | null> {
|
|
38
|
+
try {
|
|
39
|
+
const text = await readFile(sessionPath(home), 'utf8');
|
|
40
|
+
const parsed = JSON.parse(text) as Partial<LanesSession>;
|
|
41
|
+
|
|
42
|
+
// Shape-checked rather than trusted. This file is written by us and read on
|
|
43
|
+
// every command, so a half-written or hand-edited one should read as "not
|
|
44
|
+
// signed in" and send somebody to `lanes auth login` — not throw a JSON
|
|
45
|
+
// error out of a command that was about to list their memory.
|
|
46
|
+
if (
|
|
47
|
+
typeof parsed.subject !== 'string' ||
|
|
48
|
+
typeof parsed.refreshToken !== 'string' ||
|
|
49
|
+
typeof parsed.idToken !== 'string' ||
|
|
50
|
+
typeof parsed.expiresAt !== 'string' ||
|
|
51
|
+
typeof parsed.apiUrl !== 'string'
|
|
52
|
+
) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
subject: parsed.subject,
|
|
58
|
+
email: typeof parsed.email === 'string' ? parsed.email : null,
|
|
59
|
+
refreshToken: parsed.refreshToken,
|
|
60
|
+
idToken: parsed.idToken,
|
|
61
|
+
expiresAt: parsed.expiresAt,
|
|
62
|
+
apiUrl: parsed.apiUrl,
|
|
63
|
+
};
|
|
64
|
+
} catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function writeSession(session: LanesSession, home?: string): Promise<void> {
|
|
70
|
+
const path = sessionPath(home);
|
|
71
|
+
await mkdir(dirname(path), { recursive: true });
|
|
72
|
+
|
|
73
|
+
// `0600` before the content, and again after: the file holds a refresh token,
|
|
74
|
+
// and a token written world-readable for the instant between two syscalls is
|
|
75
|
+
// still a token that was world-readable.
|
|
76
|
+
await writeFile(path, `${JSON.stringify(session, null, 2)}\n`, { mode: 0o600 });
|
|
77
|
+
await chmod(path, 0o600);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function clearSession(home?: string): Promise<void> {
|
|
81
|
+
// Overwritten rather than deleted. An absent file and an empty one both read
|
|
82
|
+
// as signed out, and writing is the operation that cannot half-succeed on a
|
|
83
|
+
// store somebody else is reading.
|
|
84
|
+
await writeFile(sessionPath(home), '{}\n', { mode: 0o600 }).catch(() => {});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Whether the id token is still worth presenting.
|
|
89
|
+
*
|
|
90
|
+
* A minute of slack, because the thing being avoided is a request that leaves
|
|
91
|
+
* here valid and arrives expired. Refreshing slightly early costs one round
|
|
92
|
+
* trip; refreshing slightly late costs a failed command.
|
|
93
|
+
*/
|
|
94
|
+
export function isFresh(session: LanesSession, now = Date.now()): boolean {
|
|
95
|
+
const expires = Date.parse(session.expiresAt);
|
|
96
|
+
return Number.isFinite(expires) && expires - now > 60_000;
|
|
97
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { grantableScope, MCP_SCOPE } from './metadata.ts';
|
|
2
|
+
import { invalid, type OAuthResult } from './result.ts';
|
|
3
|
+
import { randomToken, type OAuthStore } from './store.ts';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Turning a code, or a refresh token, into an access token.
|
|
7
|
+
*
|
|
8
|
+
* Split from the browser leg because the two halves have nothing to say to each
|
|
9
|
+
* other: this one never sees a person, a redirect or a consent screen, and the
|
|
10
|
+
* other never mints a token. What passes between them is an `AuthorizationCode`
|
|
11
|
+
* row, which is the whole interface.
|
|
12
|
+
*
|
|
13
|
+
* Free functions over a context rather than methods, so the split is a real one
|
|
14
|
+
* — this file cannot reach the server's state except through what it is handed.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const REFRESH_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* How long a spent refresh token still answers.
|
|
21
|
+
*
|
|
22
|
+
* A client whose refresh succeeded but whose *response* was lost holds a token
|
|
23
|
+
* the server has already spent, and retrying with it is the only move it has.
|
|
24
|
+
* Without a window that retry is `invalid_grant`, and the reference MCP client
|
|
25
|
+
* rethrows every `OAuthError` but `server_error` rather than recovering — so
|
|
26
|
+
* the connector dies and its owner is sent to a browser, over a network blip.
|
|
27
|
+
*
|
|
28
|
+
* Thirty seconds is the band Auth0's reuse interval (0–60 s) and Okta's grace
|
|
29
|
+
* period occupy. What it costs: a captured refresh token keeps working for up
|
|
30
|
+
* to this long after the real client next rotates it.
|
|
31
|
+
*/
|
|
32
|
+
const REFRESH_REUSE_MS = 30_000;
|
|
33
|
+
|
|
34
|
+
export interface GrantContext {
|
|
35
|
+
readonly store: OAuthStore;
|
|
36
|
+
readonly accessTokenTtlMs: number;
|
|
37
|
+
/** Where a replayed refresh token is recorded. */
|
|
38
|
+
readonly log?: { warn(message: string, detail?: Record<string, unknown>): void } | undefined;
|
|
39
|
+
readonly now: () => number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function exchangeCode(
|
|
43
|
+
form: URLSearchParams,
|
|
44
|
+
context: GrantContext,
|
|
45
|
+
): Promise<OAuthResult> {
|
|
46
|
+
const record = await context.store.takeCode(form.get('code') ?? '');
|
|
47
|
+
if (!record) return invalid('invalid_grant', 'That code is unknown, used, or expired.');
|
|
48
|
+
|
|
49
|
+
if (record.clientId !== form.get('client_id')) {
|
|
50
|
+
return invalid('invalid_grant', 'That code was issued to a different client.');
|
|
51
|
+
}
|
|
52
|
+
// Checked even though the code is already bound to it: a client that sends a
|
|
53
|
+
// different redirect_uri here than it started with is not the client that
|
|
54
|
+
// started, and the spec requires the comparison.
|
|
55
|
+
if (record.redirectUri !== form.get('redirect_uri')) {
|
|
56
|
+
return invalid('invalid_grant', 'redirect_uri does not match the authorization request.');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const verifier = form.get('code_verifier') ?? '';
|
|
60
|
+
if (!verifier || pkceChallengeFor(verifier) !== record.codeChallenge) {
|
|
61
|
+
return invalid('invalid_grant', 'code_verifier does not match the code_challenge.');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return issue(context, record.clientId, record.scope, randomToken('llr'), undefined, who(record));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function refresh(
|
|
68
|
+
form: URLSearchParams,
|
|
69
|
+
context: GrantContext,
|
|
70
|
+
): Promise<OAuthResult> {
|
|
71
|
+
const presented = form.get('refresh_token') ?? '';
|
|
72
|
+
const record = await context.store.token(presented);
|
|
73
|
+
|
|
74
|
+
if (!record || record.kind === 'access') {
|
|
75
|
+
return invalid('invalid_grant', 'That refresh token is unknown or expired.');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// A spent token presented again used to take its whole family with it, on
|
|
79
|
+
// the reading that a replay is a theft. Against a real connector that was
|
|
80
|
+
// wrong twice over, and ADR-035 has the evidence. Two answers replace it,
|
|
81
|
+
// and the tombstone's age is what tells them apart.
|
|
82
|
+
if (record.kind === 'consumed') {
|
|
83
|
+
// The client first, before the window is even considered. A spent token
|
|
84
|
+
// presented by a *different* client is not a retry however recent it is,
|
|
85
|
+
// and accepting one meant a captured refresh token needed only a single
|
|
86
|
+
// request inside the window to be turned into a fresh 30-day chain member
|
|
87
|
+
// carrying the original holder's subject and profiles. The retry this
|
|
88
|
+
// window exists for always comes from the client that spent it.
|
|
89
|
+
if (record.clientId !== form.get('client_id')) {
|
|
90
|
+
return invalid('invalid_grant', 'That refresh token was issued to a different client.');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Inside the window it is a retry of a request already answered, and the
|
|
94
|
+
// client is owed the answer rather than a dead connector. Not re-consumed:
|
|
95
|
+
// a client retrying twice is still retrying.
|
|
96
|
+
const spentAt = record.consumedAt;
|
|
97
|
+
if (spentAt !== undefined && context.now() - spentAt <= REFRESH_REUSE_MS) {
|
|
98
|
+
return issue(context, record.clientId, record.scope, randomToken('llr'), record.family, who(record));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Outside it, refused on its own — and the family survives, which is the
|
|
102
|
+
// half that was taking live sessions down with it.
|
|
103
|
+
context.log?.warn('refresh token replayed', {
|
|
104
|
+
clientId: record.clientId,
|
|
105
|
+
family: record.family,
|
|
106
|
+
});
|
|
107
|
+
return invalid('invalid_grant', 'That refresh token has already been used.');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (record.clientId !== form.get('client_id')) {
|
|
111
|
+
return invalid('invalid_grant', 'That refresh token was issued to a different client.');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
await context.store.consumeToken(presented);
|
|
115
|
+
return issue(context, record.clientId, record.scope, randomToken('llr'), record.family, who(record));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Mint an access token and the refresh that replaces it.
|
|
120
|
+
*
|
|
121
|
+
* `holder` rides along unchanged through every rotation. A refresh is the same
|
|
122
|
+
* authorization continuing, so re-deciding who it belongs to would mean a
|
|
123
|
+
* client silently changing identity between two requests — and re-reading
|
|
124
|
+
* membership here would revoke a live session on a config edit, which is
|
|
125
|
+
* exactly what ADR-060 says rotation is for instead.
|
|
126
|
+
*/
|
|
127
|
+
async function issue(
|
|
128
|
+
context: GrantContext,
|
|
129
|
+
clientId: string,
|
|
130
|
+
scope: string,
|
|
131
|
+
refreshToken: string,
|
|
132
|
+
family = randomToken('llf'),
|
|
133
|
+
holder: Holder = {},
|
|
134
|
+
): Promise<OAuthResult> {
|
|
135
|
+
const accessToken = randomToken('lla');
|
|
136
|
+
const expiresIn = Math.floor(context.accessTokenTtlMs / 1000);
|
|
137
|
+
|
|
138
|
+
await context.store.putToken(accessToken, {
|
|
139
|
+
clientId,
|
|
140
|
+
kind: 'access',
|
|
141
|
+
scope: grantableScope(scope) || MCP_SCOPE,
|
|
142
|
+
family,
|
|
143
|
+
...holder,
|
|
144
|
+
expiresAt: context.now() + context.accessTokenTtlMs,
|
|
145
|
+
});
|
|
146
|
+
await context.store.putToken(refreshToken, {
|
|
147
|
+
clientId,
|
|
148
|
+
kind: 'refresh',
|
|
149
|
+
scope: grantableScope(scope) || MCP_SCOPE,
|
|
150
|
+
family,
|
|
151
|
+
...holder,
|
|
152
|
+
expiresAt: context.now() + REFRESH_TTL_MS,
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
kind: 'json',
|
|
157
|
+
status: 200,
|
|
158
|
+
body: {
|
|
159
|
+
access_token: accessToken,
|
|
160
|
+
token_type: 'Bearer',
|
|
161
|
+
expires_in: expiresIn,
|
|
162
|
+
refresh_token: refreshToken,
|
|
163
|
+
scope,
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
type Holder = { subject?: string | undefined; profiles?: readonly string[] | undefined };
|
|
169
|
+
|
|
170
|
+
/** The identity half of a stored record, in the shape `issue` spreads. */
|
|
171
|
+
function who(record: Holder): Holder {
|
|
172
|
+
return {
|
|
173
|
+
...(record.subject !== undefined ? { subject: record.subject } : {}),
|
|
174
|
+
...(record.profiles !== undefined ? { profiles: record.profiles } : {}),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** `base64url(sha256(verifier))`, which is what S256 means. */
|
|
179
|
+
export function pkceChallengeFor(verifier: string): string {
|
|
180
|
+
return Buffer.from(
|
|
181
|
+
new Bun.CryptoHasher('sha256').update(verifier, 'utf8').digest(),
|
|
182
|
+
).toString('base64url');
|
|
183
|
+
}
|