@qlever-llc/trellis-svelte 0.7.0-rc.5 → 0.7.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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@qlever-llc/trellis-svelte",
|
|
3
|
-
"version": "0.7.0
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Svelte components and state helpers for Trellis browser applications.",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@nats-io/nats-core": "^3.3.1",
|
|
31
|
-
"@qlever-llc/trellis": "^0.7.0
|
|
31
|
+
"@qlever-llc/trellis": "^0.7.0",
|
|
32
32
|
"typebox": "^1.0.15"
|
|
33
33
|
},
|
|
34
34
|
"peerDependencies": {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import type { NatsConnection } from "@nats-io/nats-core";
|
|
3
3
|
import { AsyncResult } from "@qlever-llc/result";
|
|
4
|
-
import type { Trellis, TrellisAPI } from "
|
|
4
|
+
import type { Trellis, TrellisAPI } from "../../../trellis/trellis.ts";
|
|
5
5
|
import { onDestroy } from "svelte";
|
|
6
6
|
import type { Snippet } from "svelte";
|
|
7
7
|
import {
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
import {
|
|
15
15
|
type TrellisClientContract,
|
|
16
16
|
} from "../state/trellis.svelte.ts";
|
|
17
|
-
import { TrellisClient } from "
|
|
17
|
+
import { TrellisClient } from "../../../trellis/client_connect.ts";
|
|
18
18
|
|
|
19
19
|
type Props = {
|
|
20
20
|
children: Snippet;
|
package/src/context.svelte.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { NatsConnection } from "@nats-io/nats-core";
|
|
2
|
-
import type { TrellisAPI } from "
|
|
2
|
+
import type { TrellisAPI } from "../../trellis/contracts.ts";
|
|
3
3
|
import { createContext } from "svelte";
|
|
4
4
|
import type { AuthState } from "./state/auth.svelte.ts";
|
|
5
5
|
import type { NatsState } from "./state/nats.svelte.ts";
|
|
@@ -2,10 +2,10 @@ import {
|
|
|
2
2
|
fetchPortalFlowState,
|
|
3
3
|
portalFlowIdFromUrl,
|
|
4
4
|
portalProviderLoginUrl,
|
|
5
|
-
type PortalFlowState,
|
|
5
|
+
type BrowserPortalFlowState as PortalFlowState,
|
|
6
6
|
submitPortalApproval,
|
|
7
7
|
type AuthConfig,
|
|
8
|
-
} from "@qlever-llc/trellis/auth";
|
|
8
|
+
} from "@qlever-llc/trellis/auth/browser";
|
|
9
9
|
|
|
10
10
|
export type CreatePortalFlowConfig = AuthConfig & {
|
|
11
11
|
getUrl?: () => URL;
|
package/src/state/auth.svelte.ts
CHANGED
|
@@ -1,19 +1,18 @@
|
|
|
1
1
|
import {
|
|
2
|
-
type
|
|
3
|
-
type BindSuccessResponse,
|
|
2
|
+
type AuthStartResponse,
|
|
4
3
|
bindFlow,
|
|
4
|
+
type BindResponse,
|
|
5
5
|
bindSession,
|
|
6
|
+
type BindSuccessResponse,
|
|
6
7
|
clearSessionKey,
|
|
7
8
|
getOrCreateSessionKey,
|
|
8
9
|
getPublicSessionKey,
|
|
10
|
+
startAuthRequest as browserStartAuthRequest,
|
|
9
11
|
type SentinelCreds,
|
|
10
12
|
type SessionKeyHandle,
|
|
11
|
-
} from "@qlever-llc/trellis/auth";
|
|
12
|
-
import { canonicalizeJsonValue } from "../../../auth/utils.ts";
|
|
13
|
-
import { oauthInitSig } from "../../../auth/browser/session.ts";
|
|
13
|
+
} from "@qlever-llc/trellis/auth/browser";
|
|
14
14
|
import { Result } from "@qlever-llc/result";
|
|
15
15
|
import { SvelteDate } from "svelte/reactivity";
|
|
16
|
-
import type { TrellisContractV1 } from "@qlever-llc/trellis";
|
|
17
16
|
import { Type } from "typebox";
|
|
18
17
|
import { Value } from "typebox/value";
|
|
19
18
|
import type { TrellisClientContract } from "./trellis.svelte.ts";
|
|
@@ -62,7 +61,10 @@ function loadPersistedAuth(): PersistedAuth | null {
|
|
|
62
61
|
const result = Result.try(() => {
|
|
63
62
|
const stored = localStorage.getItem(STORAGE_KEY);
|
|
64
63
|
if (!stored) return null;
|
|
65
|
-
const parsed = Value.Parse(
|
|
64
|
+
const parsed = Value.Parse(
|
|
65
|
+
PersistedAuthSchema,
|
|
66
|
+
JSON.parse(stored),
|
|
67
|
+
) as PersistedAuth;
|
|
66
68
|
if (new Date(parsed.expires) < new Date()) {
|
|
67
69
|
localStorage.removeItem(STORAGE_KEY);
|
|
68
70
|
return null;
|
|
@@ -84,12 +86,12 @@ function persistAuth(state: {
|
|
|
84
86
|
localStorage.setItem(
|
|
85
87
|
STORAGE_KEY,
|
|
86
88
|
JSON.stringify({
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
89
|
+
bindingToken: state.bindingToken,
|
|
90
|
+
inboxPrefix: state.inboxPrefix,
|
|
91
|
+
expires: state.expires.toISOString(),
|
|
92
|
+
sentinel: state.sentinel,
|
|
93
|
+
natsServers: state.natsServers,
|
|
94
|
+
}),
|
|
93
95
|
);
|
|
94
96
|
}
|
|
95
97
|
|
|
@@ -115,34 +117,10 @@ function normalizeAuthUrl(authUrl: string): string {
|
|
|
115
117
|
return new URL(authUrl).toString().replace(/\/$/, "");
|
|
116
118
|
}
|
|
117
119
|
|
|
118
|
-
function
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
123
|
-
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
async function buildLoginUrl(options: {
|
|
127
|
-
authUrl: string;
|
|
128
|
-
redirectTo: string;
|
|
129
|
-
handle: SessionKeyHandle;
|
|
130
|
-
contract: Record<string, unknown>;
|
|
131
|
-
context?: unknown;
|
|
132
|
-
}): Promise<string> {
|
|
133
|
-
const sessionKey = getPublicSessionKey(options.handle);
|
|
134
|
-
const sig = await oauthInitSig(options.handle, options.redirectTo, options.context);
|
|
135
|
-
const url = new URL(`${options.authUrl}/auth/login`);
|
|
136
|
-
|
|
137
|
-
url.searchParams.set("redirectTo", options.redirectTo);
|
|
138
|
-
url.searchParams.set("sessionKey", sessionKey);
|
|
139
|
-
url.searchParams.set("sig", sig);
|
|
140
|
-
url.searchParams.set("contract", encodeJsonForQuery(options.contract));
|
|
141
|
-
if (options.context !== undefined) {
|
|
142
|
-
url.searchParams.set("context", encodeJsonForQuery(options.context));
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
return url.href;
|
|
120
|
+
function websocketTransportServers(response: {
|
|
121
|
+
transports?: { websocket?: { natsServers: string[] } };
|
|
122
|
+
}): string[] | undefined {
|
|
123
|
+
return response.transports?.websocket?.natsServers;
|
|
146
124
|
}
|
|
147
125
|
|
|
148
126
|
function loadPersistedAuthUrl(): string | null {
|
|
@@ -228,6 +206,14 @@ export class AuthState {
|
|
|
228
206
|
return authUrl;
|
|
229
207
|
}
|
|
230
208
|
|
|
209
|
+
#requireContract(): TrellisClientContract {
|
|
210
|
+
const contract = this.#config.contract;
|
|
211
|
+
if (!contract) {
|
|
212
|
+
throw new Error("Auth contract is not configured");
|
|
213
|
+
}
|
|
214
|
+
return contract;
|
|
215
|
+
}
|
|
216
|
+
|
|
231
217
|
setAuthUrl(authUrl: string): string {
|
|
232
218
|
const normalized = persistAuthUrl(authUrl);
|
|
233
219
|
this.#config.authUrl = normalized;
|
|
@@ -246,6 +232,9 @@ export class AuthState {
|
|
|
246
232
|
get contract(): TrellisClientContract | undefined {
|
|
247
233
|
return this.#config.contract;
|
|
248
234
|
}
|
|
235
|
+
get contractDigest(): string {
|
|
236
|
+
return this.#requireContract().CONTRACT_DIGEST;
|
|
237
|
+
}
|
|
249
238
|
get sessionKey(): string | null {
|
|
250
239
|
return this.#state.handle ? getPublicSessionKey(this.#state.handle) : null;
|
|
251
240
|
}
|
|
@@ -256,7 +245,9 @@ export class AuthState {
|
|
|
256
245
|
return this.#state.inboxPrefix;
|
|
257
246
|
}
|
|
258
247
|
get expires(): Date | null {
|
|
259
|
-
return this.#state.expiresMs === null
|
|
248
|
+
return this.#state.expiresMs === null
|
|
249
|
+
? null
|
|
250
|
+
: new SvelteDate(this.#state.expiresMs);
|
|
260
251
|
}
|
|
261
252
|
get sentinel(): SentinelCreds | null {
|
|
262
253
|
return this.#state.sentinel;
|
|
@@ -299,21 +290,42 @@ export class AuthState {
|
|
|
299
290
|
* This method does not return - it redirects the browser.
|
|
300
291
|
*/
|
|
301
292
|
async signIn(options: SignInOptions = {}): Promise<never> {
|
|
302
|
-
const
|
|
293
|
+
const currentUrl = new URL(window.location.href);
|
|
294
|
+
const redirectTo = resolveRedirectTo(options, currentUrl);
|
|
295
|
+
const response = await this.startAuthRequest({
|
|
296
|
+
...options,
|
|
297
|
+
redirectTo,
|
|
298
|
+
});
|
|
299
|
+
window.location.href = response.status === "bound"
|
|
300
|
+
? redirectTo
|
|
301
|
+
: response.loginUrl;
|
|
302
|
+
throw new Error("Redirecting to auth for provider selection");
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async startAuthRequest(options: SignInOptions = {}): Promise<AuthStartResponse> {
|
|
306
|
+
const authUrl = options.authUrl
|
|
307
|
+
? this.setAuthUrl(options.authUrl)
|
|
308
|
+
: this.#requireAuthUrl();
|
|
303
309
|
const handle = await this.init();
|
|
304
310
|
const currentUrl = new URL(window.location.href);
|
|
305
|
-
const
|
|
311
|
+
const response = await browserStartAuthRequest({
|
|
306
312
|
authUrl,
|
|
307
313
|
redirectTo: resolveRedirectTo(options, currentUrl),
|
|
308
314
|
handle,
|
|
309
|
-
contract: this.#
|
|
315
|
+
contract: this.#requireContract().CONTRACT,
|
|
310
316
|
context: options.context,
|
|
311
317
|
});
|
|
312
|
-
|
|
313
|
-
|
|
318
|
+
|
|
319
|
+
if (response.status === "bound") {
|
|
320
|
+
this.setBindingToken(response);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return response;
|
|
314
324
|
}
|
|
315
325
|
|
|
316
|
-
async handleCallback(
|
|
326
|
+
async handleCallback(
|
|
327
|
+
url: string = window.location.href,
|
|
328
|
+
): Promise<BindResult | null> {
|
|
317
329
|
if (this.#bindingInProgress) return this.#bindingInProgress;
|
|
318
330
|
|
|
319
331
|
const flowId = new URL(url).searchParams.get("flowId");
|
|
@@ -330,13 +342,18 @@ export class AuthState {
|
|
|
330
342
|
async #resolveCallback(flowId: string): Promise<BindResult> {
|
|
331
343
|
try {
|
|
332
344
|
const response = await this.bindFlow(flowId);
|
|
333
|
-
return response.status === "bound"
|
|
334
|
-
|
|
335
|
-
|
|
345
|
+
return response.status === "bound" ? { status: "bound" } : {
|
|
346
|
+
status: "insufficient_capabilities",
|
|
347
|
+
missingCapabilities: response.missingCapabilities,
|
|
348
|
+
};
|
|
336
349
|
} catch (error) {
|
|
337
350
|
const message = error instanceof Error ? error.message : String(error);
|
|
338
|
-
if (message.includes("approval_denied"))
|
|
339
|
-
|
|
351
|
+
if (message.includes("approval_denied")) {
|
|
352
|
+
return { status: "approval_denied" };
|
|
353
|
+
}
|
|
354
|
+
if (message.includes("approval_required")) {
|
|
355
|
+
return { status: "approval_required" };
|
|
356
|
+
}
|
|
340
357
|
return { status: "error", message };
|
|
341
358
|
}
|
|
342
359
|
}
|
|
@@ -346,7 +363,9 @@ export class AuthState {
|
|
|
346
363
|
*/
|
|
347
364
|
cleanupCallbackUrl(url: string = window.location.href): string | null {
|
|
348
365
|
const parsed = new URL(url);
|
|
349
|
-
if (
|
|
366
|
+
if (
|
|
367
|
+
parsed.searchParams.has("flowId") || parsed.searchParams.has("authError")
|
|
368
|
+
) {
|
|
350
369
|
parsed.searchParams.delete("flowId");
|
|
351
370
|
parsed.searchParams.delete("authError");
|
|
352
371
|
return `${parsed.pathname}${parsed.search}${parsed.hash}`;
|
|
@@ -394,10 +413,14 @@ export class AuthState {
|
|
|
394
413
|
}
|
|
395
414
|
|
|
396
415
|
setBindingToken(
|
|
397
|
-
response:
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
416
|
+
response:
|
|
417
|
+
& Pick<BindSuccessResponse, "bindingToken" | "inboxPrefix" | "expires">
|
|
418
|
+
& {
|
|
419
|
+
sentinel?: SentinelCreds;
|
|
420
|
+
transports?: {
|
|
421
|
+
websocket?: { natsServers: string[] };
|
|
422
|
+
};
|
|
423
|
+
},
|
|
401
424
|
): void {
|
|
402
425
|
this.#state.bindingToken = response.bindingToken;
|
|
403
426
|
this.#state.inboxPrefix = response.inboxPrefix;
|
|
@@ -406,8 +429,9 @@ export class AuthState {
|
|
|
406
429
|
if (response.sentinel) {
|
|
407
430
|
this.#state.sentinel = response.sentinel;
|
|
408
431
|
}
|
|
409
|
-
|
|
410
|
-
|
|
432
|
+
const websocketServers = websocketTransportServers(response);
|
|
433
|
+
if (websocketServers) {
|
|
434
|
+
this.#state.natsServers = websocketServers;
|
|
411
435
|
}
|
|
412
436
|
|
|
413
437
|
// Only persist if we have sentinel credentials
|
|
@@ -439,7 +463,9 @@ export class AuthState {
|
|
|
439
463
|
* Sign out by clearing all credentials and redirecting to login.
|
|
440
464
|
* This method does not return - it redirects the browser.
|
|
441
465
|
*/
|
|
442
|
-
async signOut(
|
|
466
|
+
async signOut(
|
|
467
|
+
remoteLogout?: () => Promise<unknown> | unknown,
|
|
468
|
+
): Promise<never> {
|
|
443
469
|
if (remoteLogout) {
|
|
444
470
|
try {
|
|
445
471
|
await remoteLogout();
|
package/src/state/nats.svelte.ts
CHANGED
|
@@ -3,20 +3,20 @@ import {
|
|
|
3
3
|
type NatsConnection,
|
|
4
4
|
wsconnect,
|
|
5
5
|
} from "@nats-io/nats-core";
|
|
6
|
-
import type { Trellis } from "
|
|
6
|
+
import type { Trellis } from "../../../trellis/trellis.ts";
|
|
7
7
|
import {
|
|
8
8
|
getPublicSessionKey,
|
|
9
9
|
natsConnectSigForBindingToken,
|
|
10
10
|
type SentinelCreds,
|
|
11
11
|
type SessionKeyHandle,
|
|
12
12
|
signBytes,
|
|
13
|
-
} from "@qlever-llc/trellis/auth";
|
|
13
|
+
} from "@qlever-llc/trellis/auth/browser";
|
|
14
14
|
import { AsyncResult, UnexpectedError } from "@qlever-llc/result";
|
|
15
15
|
import {
|
|
16
16
|
API as AUTH_API,
|
|
17
17
|
type AuthRenewBindingTokenInput,
|
|
18
18
|
type AuthRenewBindingTokenOutput,
|
|
19
|
-
} from "@qlever-llc/trellis
|
|
19
|
+
} from "@qlever-llc/trellis-sdk/auth";
|
|
20
20
|
import type { AuthState } from "./auth.svelte.ts";
|
|
21
21
|
import { createClient } from "../../../trellis/client.ts";
|
|
22
22
|
|
|
@@ -258,13 +258,17 @@ export class NatsState {
|
|
|
258
258
|
context: { message: "Not authenticated: binding token expired" },
|
|
259
259
|
});
|
|
260
260
|
}
|
|
261
|
-
const { handle, bindingToken } = requireBrowserAuth(
|
|
261
|
+
const { handle, bindingToken, sentinel } = requireBrowserAuth(
|
|
262
|
+
this.#authState,
|
|
263
|
+
);
|
|
264
|
+
this.#servers = resolveServers(this.#authState, this.#config);
|
|
265
|
+
this.#sentinel = sentinel;
|
|
262
266
|
const inboxPrefix = this.#authState.inboxPrefix ?? undefined;
|
|
263
267
|
this.#tokenRef.value = await buildNatsAuthToken(handle, bindingToken);
|
|
264
268
|
|
|
265
269
|
const authenticator = jwtAuthenticator(
|
|
266
|
-
|
|
267
|
-
new TextEncoder().encode(
|
|
270
|
+
sentinel.jwt,
|
|
271
|
+
new TextEncoder().encode(sentinel.seed),
|
|
268
272
|
);
|
|
269
273
|
|
|
270
274
|
this.nc = await wsconnect({
|
|
@@ -315,13 +319,34 @@ export class NatsState {
|
|
|
315
319
|
) => Promise<unknown>;
|
|
316
320
|
const binding = await requestOrThrow(
|
|
317
321
|
"Auth.RenewBindingToken",
|
|
318
|
-
{
|
|
322
|
+
{
|
|
323
|
+
contractDigest: this.#authState.contractDigest,
|
|
324
|
+
} satisfies AuthRenewBindingTokenInput,
|
|
319
325
|
) as AuthRenewBindingTokenOutput;
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
this.#
|
|
323
|
-
|
|
324
|
-
|
|
326
|
+
if (binding.status === "bound") {
|
|
327
|
+
this.#authState.setBindingToken(binding);
|
|
328
|
+
this.#sentinel = this.#authState.sentinel ?? this.#sentinel;
|
|
329
|
+
this.#tokenRef.value = await buildNatsAuthToken(
|
|
330
|
+
this.#handle,
|
|
331
|
+
binding.bindingToken,
|
|
332
|
+
);
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (binding.status !== "contract_changed") {
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const authStart = await this.#authState.startAuthRequest({
|
|
341
|
+
redirectTo: window.location.href,
|
|
342
|
+
});
|
|
343
|
+
if (authStart.status === "bound") {
|
|
344
|
+
this.#authState.setBindingToken(authStart);
|
|
345
|
+
await this.reconnect();
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
window.location.href = authStart.loginUrl;
|
|
325
350
|
};
|
|
326
351
|
|
|
327
352
|
await AsyncResult.try(renew);
|
|
@@ -1,12 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
type TrellisAPI,
|
|
5
|
-
type TrellisContractV1,
|
|
6
|
-
} from "@qlever-llc/trellis";
|
|
1
|
+
import { defineAppContract } from "../../../trellis/contract.ts";
|
|
2
|
+
import type { TrellisAPI, TrellisContractV1 } from "../../../trellis/contracts.ts";
|
|
3
|
+
import type { Trellis } from "../../../trellis/trellis.ts";
|
|
7
4
|
import { TrellisClient } from "../../../trellis/client_connect.ts";
|
|
8
5
|
import { createClient } from "../../../trellis/client.ts";
|
|
9
|
-
import { getPublicSessionKey, signBytes } from "@qlever-llc/trellis/auth";
|
|
6
|
+
import { getPublicSessionKey, signBytes } from "@qlever-llc/trellis/auth/browser";
|
|
10
7
|
import type { AuthState } from "./auth.svelte.ts";
|
|
11
8
|
import type { NatsState } from "./nats.svelte.ts";
|
|
12
9
|
|
|
@@ -22,12 +19,13 @@ export type TrellisStateConfig<TApi extends TrellisAPI = TrellisAPI> = {
|
|
|
22
19
|
contract?: TrellisClientContract<TApi>;
|
|
23
20
|
};
|
|
24
21
|
|
|
25
|
-
const DEFAULT_TRELLIS_CONTRACT =
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
})
|
|
22
|
+
const DEFAULT_TRELLIS_CONTRACT = defineAppContract(
|
|
23
|
+
() => ({
|
|
24
|
+
id: "trellis.svelte.browser@v1",
|
|
25
|
+
displayName: "Trellis Svelte Browser Client",
|
|
26
|
+
description: "Represent a browser client that only uses its locally declared Trellis APIs.",
|
|
27
|
+
}),
|
|
28
|
+
) satisfies TrellisClientContract<TrellisAPI>;
|
|
31
29
|
|
|
32
30
|
/**
|
|
33
31
|
* Svelte 5 wrapper for Trellis client.
|