@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-rc.5",
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-rc.5",
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 "@qlever-llc/trellis";
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 "@qlever-llc/trellis";
17
+ import { TrellisClient } from "../../../trellis/client_connect.ts";
18
18
 
19
19
  type Props = {
20
20
  children: Snippet;
@@ -1,5 +1,5 @@
1
1
  import type { NatsConnection } from "@nats-io/nats-core";
2
- import type { TrellisAPI } from "@qlever-llc/trellis";
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;
@@ -1,19 +1,18 @@
1
1
  import {
2
- type BindResponse,
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(PersistedAuthSchema, JSON.parse(stored)) as PersistedAuth;
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
- bindingToken: state.bindingToken,
88
- inboxPrefix: state.inboxPrefix,
89
- expires: state.expires.toISOString(),
90
- sentinel: state.sentinel,
91
- natsServers: state.natsServers,
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 encodeJsonForQuery(value: unknown): string {
119
- const json = canonicalizeJsonValue(value);
120
- const bytes = new TextEncoder().encode(json);
121
- let binary = "";
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 ? null : new SvelteDate(this.#state.expiresMs);
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 authUrl = options.authUrl ? this.setAuthUrl(options.authUrl) : this.#requireAuthUrl();
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 url = await buildLoginUrl({
311
+ const response = await browserStartAuthRequest({
306
312
  authUrl,
307
313
  redirectTo: resolveRedirectTo(options, currentUrl),
308
314
  handle,
309
- contract: this.#config.contract?.CONTRACT ?? {},
315
+ contract: this.#requireContract().CONTRACT,
310
316
  context: options.context,
311
317
  });
312
- window.location.href = url;
313
- throw new Error("Redirecting to auth for provider selection");
318
+
319
+ if (response.status === "bound") {
320
+ this.setBindingToken(response);
321
+ }
322
+
323
+ return response;
314
324
  }
315
325
 
316
- async handleCallback(url: string = window.location.href): Promise<BindResult | null> {
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
- ? { status: "bound" }
335
- : { status: "insufficient_capabilities", missingCapabilities: response.missingCapabilities };
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")) return { status: "approval_denied" };
339
- if (message.includes("approval_required")) return { status: "approval_required" };
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 (parsed.searchParams.has("flowId") || parsed.searchParams.has("authError")) {
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: Pick<BindSuccessResponse, "bindingToken" | "inboxPrefix" | "expires"> & {
398
- sentinel?: SentinelCreds;
399
- natsServers?: string[];
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
- if (response.natsServers) {
410
- this.#state.natsServers = response.natsServers;
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(remoteLogout?: () => Promise<unknown> | unknown): Promise<never> {
466
+ async signOut(
467
+ remoteLogout?: () => Promise<unknown> | unknown,
468
+ ): Promise<never> {
443
469
  if (remoteLogout) {
444
470
  try {
445
471
  await remoteLogout();
@@ -3,20 +3,20 @@ import {
3
3
  type NatsConnection,
4
4
  wsconnect,
5
5
  } from "@nats-io/nats-core";
6
- import type { Trellis } from "@qlever-llc/trellis";
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/sdk/auth";
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(this.#authState);
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
- this.#sentinel.jwt,
267
- new TextEncoder().encode(this.#sentinel.seed),
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
- {} satisfies AuthRenewBindingTokenInput,
322
+ {
323
+ contractDigest: this.#authState.contractDigest,
324
+ } satisfies AuthRenewBindingTokenInput,
319
325
  ) as AuthRenewBindingTokenOutput;
320
- this.#authState.setBindingToken(binding);
321
- this.#tokenRef.value = await buildNatsAuthToken(
322
- this.#handle,
323
- binding.bindingToken,
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
- defineContract,
3
- type Trellis,
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 = defineContract({
26
- id: "trellis.svelte.browser@v1",
27
- displayName: "Trellis Svelte Browser Client",
28
- description: "Represent a browser client that only uses its locally declared Trellis APIs.",
29
- kind: "app",
30
- }) satisfies TrellisClientContract<TrellisAPI>;
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.