@qlever-llc/trellis-svelte 0.5.1 → 0.6.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 CHANGED
@@ -4,6 +4,6 @@ Svelte integration for Trellis browser applications.
4
4
 
5
5
  Provides `TrellisProvider` for app-level wiring, along with reactive helpers for auth state, NATS connection state, and Trellis client state.
6
6
 
7
- Prefer `getTrellisFor(contract)` in app code so the client stays typed from the same contract passed to `TrellisProvider`.
7
+ Prefer `createTrellisApp(...)` for app-scoped auth creation and typed `app.getTrellis()` access without passing the contract around at every call site.
8
8
 
9
- Uses the contract/runtime model from `@qlever-llc/trellis-contracts` and `@qlever-llc/trellis`.
9
+ Uses the contract/runtime model from `@qlever-llc/trellis/contracts` and `@qlever-llc/trellis`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qlever-llc/trellis-svelte",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "description": "Svelte components and state helpers for Trellis browser applications.",
6
6
  "license": "Apache-2.0",
@@ -28,11 +28,7 @@
28
28
  },
29
29
  "dependencies": {
30
30
  "@nats-io/nats-core": "^3.3.1",
31
- "@qlever-llc/trellis-auth": "^0.5.1",
32
- "@qlever-llc/trellis-contracts": "^0.5.1",
33
- "@qlever-llc/trellis-result": "^0.5.1",
34
- "@qlever-llc/trellis-sdk-auth": "^0.5.1",
35
- "@qlever-llc/trellis": "^0.5.1",
31
+ "@qlever-llc/trellis": "^0.6.0",
36
32
  "typebox": "^1.0.15"
37
33
  },
38
34
  "peerDependencies": {
@@ -1,29 +1,27 @@
1
1
  <script lang="ts">
2
2
  import type { NatsConnection } from "@nats-io/nats-core";
3
- import { AsyncResult } from "@qlever-llc/trellis-result";
4
- import type { Snippet } from "svelte";
3
+ import { AsyncResult } from "@qlever-llc/result";
5
4
  import { onDestroy } from "svelte";
5
+ import type { Snippet } from "svelte";
6
6
  import {
7
+ setAppContext,
7
8
  setAuthContext,
8
9
  setNatsStateContext,
9
10
  setTrellisContext,
10
11
  } from "../context.svelte.ts";
11
- import { type AuthState, type AuthStateConfig, type BindErrorResult, createAuthState } from "../state/auth.svelte.ts";
12
+ import type { AuthState, BindErrorResult } from "../state/auth.svelte.ts";
12
13
  import { createNatsState, type NatsState } from "../state/nats.svelte.ts";
13
- import {
14
- createTrellisState,
15
- type TrellisClientContract,
16
- type TrellisState,
17
- } from "../state/trellis.svelte.ts";
14
+ import { createTrellisState, type TrellisState } from "../state/trellis.svelte.ts";
15
+
16
+ type TrellisApp = {
17
+ auth: AuthState;
18
+ };
18
19
 
19
20
  type Props = {
20
21
  children: Snippet;
21
22
  loading?: Snippet;
22
- authUrl: string;
23
- natsServers: string[];
24
- serviceName?: string;
25
- contract?: TrellisClientContract;
26
- loginPath?: string;
23
+ bindError?: Snippet<[BindErrorResult]>;
24
+ app: TrellisApp;
27
25
  onAuthExpired?: () => void;
28
26
  onAuthFailed?: (error: unknown) => void;
29
27
  onAuthRequired?: (redirectTo: string) => void;
@@ -36,14 +34,11 @@
36
34
  onNatsError?: (error: Error) => void;
37
35
  };
38
36
 
39
- const {
37
+ let {
40
38
  children,
41
39
  loading,
42
- authUrl,
43
- natsServers,
44
- serviceName = "app",
45
- contract,
46
- loginPath = "/login",
40
+ bindError,
41
+ app,
47
42
  onAuthExpired,
48
43
  onAuthFailed,
49
44
  onAuthRequired,
@@ -56,63 +51,59 @@
56
51
  onNatsError,
57
52
  }: Props = $props();
58
53
 
54
+ let bindErrorResult = $state<BindErrorResult | null>(null);
55
+
59
56
  type InitContext = {
60
57
  auth: AuthState;
61
58
  nats: NatsState;
62
59
  trellis: TrellisState;
63
60
  };
61
+ const isBrowser = typeof window !== "undefined";
64
62
 
65
- class AuthRequiredError extends Error {
66
- constructor() {
67
- super("Authentication required");
63
+ function getRedirectTo(): string {
64
+ if (typeof window === "undefined") {
65
+ return "/";
68
66
  }
69
- }
70
67
 
71
- class BindFailedError extends Error {
72
- constructor() {
73
- super("Bind failed");
74
- }
68
+ return window.location.pathname + window.location.search;
75
69
  }
76
70
 
77
- function createProviderAuthState(): AuthState {
78
- const config: AuthStateConfig = {
79
- authUrl,
80
- loginPath,
81
- contract,
82
- };
71
+ function redirectToLogin(redirectTo: string): void {
72
+ if (typeof window === "undefined") return;
83
73
 
84
- return createAuthState(config);
74
+ const url = new URL(app.auth.loginPath, window.location.origin);
75
+ url.searchParams.set("redirectTo", redirectTo);
76
+ window.location.href = url.toString();
85
77
  }
86
78
 
87
- const authState = createProviderAuthState();
88
-
89
- function getRedirectTo(): string {
90
- if (typeof window === "undefined") {
91
- return "/";
79
+ function handleAuthRequired(): void {
80
+ const redirectTo = getRedirectTo();
81
+ onAuthRequired?.(redirectTo);
82
+ if (!onAuthRequired) {
83
+ redirectToLogin(redirectTo);
92
84
  }
93
-
94
- return window.location.pathname + window.location.search;
95
85
  }
96
86
 
97
- async function initialize(): Promise<InitContext> {
87
+ async function initialize(): Promise<InitContext | null> {
98
88
  const result = await AsyncResult.try(async () => {
89
+ const authState = app.auth;
90
+
99
91
  await authState.init();
100
92
  const bindResult = await authState.handleCallback();
101
93
  authState.cleanupCallbackUrl();
102
94
 
103
95
  if (bindResult !== null && bindResult.status !== "bound") {
96
+ bindErrorResult = bindResult;
104
97
  onBindError?.(bindResult);
105
- throw new BindFailedError();
98
+ return null;
106
99
  }
107
100
 
108
101
  if (!authState.isAuthenticated) {
109
- onAuthRequired?.(getRedirectTo());
110
- throw new AuthRequiredError();
102
+ handleAuthRequired();
103
+ return null;
111
104
  }
112
105
 
113
- const effectiveNatsServers = authState.natsServers ?? natsServers;
114
106
  const natsState = await createNatsState(authState, {
115
- servers: effectiveNatsServers,
116
107
  onConnecting: onNatsConnecting,
117
108
  onConnected: onNatsConnected,
118
109
  onDisconnect: onNatsDisconnect,
@@ -120,13 +111,13 @@
120
111
  onReconnect: onNatsReconnect,
121
112
  onError: onNatsError,
122
113
  onAuthRequired: () => {
123
- onAuthRequired?.(getRedirectTo());
114
+ onAuthExpired?.();
115
+ handleAuthRequired();
124
116
  },
125
117
  });
126
118
 
127
119
  const trellisState = await createTrellisState(authState, natsState, {
128
- serviceName,
129
- contract,
120
+ contract: authState.contract,
130
121
  });
131
122
 
132
123
  return {
@@ -138,10 +129,7 @@
138
129
 
139
130
  if (result.isErr()) {
140
131
  const error = result.error;
141
- if (error instanceof AuthRequiredError || error instanceof BindFailedError) {
142
- throw error;
143
- }
144
- authState.clearAuth();
132
+ app.auth.clearAuth();
145
133
  onAuthFailed?.(error);
146
134
  throw error;
147
135
  }
@@ -154,30 +142,54 @@
154
142
  });
155
143
  }
156
144
 
157
- const initPromise = initialize();
158
- const natsStatePromise = initPromise.then((ctx) => ctx.nats) as Promise<NatsState>;
159
- const trellisPromise = initPromise.then((ctx) => ctx.trellis.trellis) as Promise<unknown>;
160
- const natsPromise = initPromise.then((ctx) => ctx.nats.nc) as Promise<NatsConnection>;
161
-
162
- setAuthContext(authState);
163
- setNatsStateContext(natsStatePromise);
164
- setTrellisContext({
165
- trellis: trellisPromise as never,
166
- nats: natsPromise,
167
- });
145
+ const initPromise = isBrowser ? initialize() : null;
146
+ const readyPromise: Promise<InitContext> | null = initPromise?.then((ctx) => {
147
+ if (!ctx) {
148
+ throw new Error("Trellis context is not available");
149
+ }
150
+ return ctx;
151
+ }) ?? null;
152
+ const natsStatePromise: Promise<NatsState> | null = readyPromise?.then((ctx) => ctx.nats) ?? null;
153
+ const trellisPromise: Promise<TrellisState["trellis"]> | null = readyPromise?.then((ctx) => ctx.trellis.trellis) ?? null;
154
+ const natsPromise: Promise<NatsConnection> | null = readyPromise?.then((ctx) => ctx.nats.nc) ?? null;
155
+
156
+ if (readyPromise && natsStatePromise && trellisPromise && natsPromise) {
157
+ void readyPromise.catch(() => {});
158
+ setAppContext(() => app);
159
+ setAuthContext(() => app.auth);
160
+ setNatsStateContext(natsStatePromise);
161
+ setTrellisContext({
162
+ trellis: trellisPromise,
163
+ nats: natsPromise,
164
+ });
165
+ }
168
166
 
169
167
  onDestroy(() => {
170
- void initPromise.then((ctx) => {
168
+ if (!readyPromise) return;
169
+
170
+ void readyPromise.then((ctx) => {
171
171
  ctx.trellis.stop();
172
172
  void ctx.nats.disconnect();
173
173
  });
174
174
  });
175
175
  </script>
176
176
 
177
- {#await initPromise}
177
+ {#if !isBrowser}
178
178
  {#if loading}
179
179
  {@render loading()}
180
180
  {/if}
181
- {:then}
182
- {@render children()}
183
- {/await}
181
+ {:else}
182
+ {#await initPromise}
183
+ {#if loading}
184
+ {@render loading()}
185
+ {/if}
186
+ {:then ctx}
187
+ {#if bindErrorResult}
188
+ {#if bindError}
189
+ {@render bindError(bindErrorResult)}
190
+ {/if}
191
+ {:else if ctx}
192
+ {@render children()}
193
+ {/if}
194
+ {/await}
195
+ {/if}
@@ -1,17 +1,12 @@
1
+ import type { BaseError, Result } from "@qlever-llc/result";
1
2
  import type { NatsConnection } from "@nats-io/nats-core";
2
- import type { Trellis } from "@qlever-llc/trellis";
3
- import type { InferSchemaType, TrellisAPI } from "@qlever-llc/trellis-contracts";
4
- import { getContext, setContext } from "svelte";
5
- import type { AuthState } from "./state/auth.svelte.ts";
3
+ import type { InferSchemaType, Trellis, TrellisAPI } from "@qlever-llc/trellis";
4
+ import { createContext } from "svelte";
5
+ import { createAuthState, type AuthState, type AuthStateConfig, type SignInOptions } from "./state/auth.svelte.ts";
6
6
  import type { NatsState } from "./state/nats.svelte.ts";
7
7
 
8
- const TRELLIS_KEY = Symbol("trellis");
9
- const NATS_KEY = Symbol("nats");
10
- const NATS_STATE_KEY = Symbol("nats-state");
11
- const AUTH_KEY = Symbol("auth");
12
-
13
8
  type TrellisContext = {
14
- trellis: Promise<unknown>;
9
+ trellis: Promise<Trellis<TrellisAPI>>;
15
10
  nats: Promise<NatsConnection>;
16
11
  };
17
12
 
@@ -25,60 +20,83 @@ type RequestOpts = {
25
20
  timeout?: number;
26
21
  };
27
22
 
28
- type RpcMapFor<TA extends TrellisAPI> = {
29
- [M in keyof TA["rpc"] & string]: {
30
- input: InferSchemaType<TA["rpc"][M]["input"]>;
31
- output: InferSchemaType<TA["rpc"][M]["output"]>;
32
- };
33
- };
34
-
35
- type TypedRequestSurface<TA extends TrellisAPI> = {
36
- requestOrThrow<M extends keyof RpcMapFor<TA> & string>(
23
+ type TypedTrellis<TA extends TrellisAPI> = Omit<Trellis<TrellisAPI>, "request" | "requestOrThrow"> & {
24
+ request<M extends keyof TA["rpc"] & string>(
37
25
  method: M,
38
- input: RpcMapFor<TA>[M]["input"],
26
+ input: InferSchemaType<TA["rpc"][M]["input"]>,
39
27
  opts?: RequestOpts,
40
- ): Promise<RpcMapFor<TA>[M]["output"]>;
28
+ ): Promise<Result<InferSchemaType<TA["rpc"][M]["output"]>, BaseError>>;
29
+ requestOrThrow<M extends keyof TA["rpc"] & string>(
30
+ method: M,
31
+ input: InferSchemaType<TA["rpc"][M]["input"]>,
32
+ opts?: RequestOpts,
33
+ ): Promise<InferSchemaType<TA["rpc"][M]["output"]>>;
34
+ };
35
+
36
+ function createTypedTrellis<TA extends TrellisAPI>(trellis: Trellis<TrellisAPI>): TypedTrellis<TA> {
37
+ return trellis as TypedTrellis<TA>;
38
+ }
39
+
40
+ export type BoundTrellisApp<TContract extends TrellisContractLike> = {
41
+ auth: AuthState;
42
+ signIn: (options?: SignInOptions) => Promise<never>;
43
+ getTrellis: () => Promise<TypedTrellis<TContract["API"]["trellis"]>>;
41
44
  };
42
45
 
43
- type TypedTrellis<TA extends TrellisAPI> =
44
- & Omit<Trellis<TA>, "requestOrThrow">
45
- & TypedRequestSurface<TA>;
46
+ const [getTrellisContext, setTrellisContextValue] = createContext<TrellisContext>();
47
+ const [getNatsStateContext, setNatsStateContextValue] = createContext<Promise<NatsState>>();
48
+ const [getAuthContext, setAuthContextValue] = createContext<() => AuthState>();
49
+ const [getAppContext, setAppContextValue] = createContext<() => unknown>();
46
50
 
47
- export function setTrellisContext<TA extends TrellisAPI>(
48
- ctx: { trellis: Promise<Trellis<TA>>; nats: Promise<NatsConnection> },
51
+ export function setTrellisContext(
52
+ ctx: TrellisContext,
49
53
  ): void {
50
- setContext(TRELLIS_KEY, ctx.trellis as unknown as Promise<unknown>);
51
- setContext(NATS_KEY, ctx.nats);
54
+ setTrellisContextValue(ctx);
52
55
  }
53
56
 
54
57
  export function setNatsStateContext(natsState: Promise<NatsState>): void {
55
- setContext(NATS_STATE_KEY, natsState);
58
+ setNatsStateContextValue(natsState);
56
59
  }
57
60
 
58
- export function setAuthContext(auth: AuthState): void {
59
- setContext(AUTH_KEY, auth);
61
+ export function setAuthContext(getAuth: () => AuthState): void {
62
+ setAuthContextValue(getAuth);
60
63
  }
61
64
 
62
- export function getTrellis<TA extends TrellisAPI = TrellisAPI>(): Promise<Trellis<TA>> {
63
- return getContext<Promise<unknown>>(TRELLIS_KEY) as Promise<Trellis<TA>>;
65
+ export function setAppContext(getApp: () => unknown): void {
66
+ setAppContextValue(getApp);
64
67
  }
65
68
 
66
- export function getTrellisFor<TContract extends TrellisContractLike>(
67
- _contract: TContract,
68
- ): Promise<TypedTrellis<TContract["API"]["trellis"]>> {
69
- return getTrellis<TContract["API"]["trellis"]>() as unknown as Promise<
70
- TypedTrellis<TContract["API"]["trellis"]>
71
- >;
69
+ export function createTrellisApp<TContract extends TrellisContractLike>(
70
+ config: AuthStateConfig & { contract: TContract },
71
+ ): BoundTrellisApp<TContract> {
72
+ const auth = createAuthState(config);
73
+
74
+ let app!: BoundTrellisApp<TContract>;
75
+ app = {
76
+ auth,
77
+ signIn: (options) => auth.signIn(options),
78
+ getTrellis: () => {
79
+ if (getAppContext()() !== app) {
80
+ throw new Error("getTrellis() was called outside the matching TrellisProvider");
81
+ }
82
+
83
+ return getTrellisContext().trellis.then((trellis) =>
84
+ createTypedTrellis<TContract["API"]["trellis"]>(trellis)
85
+ );
86
+ },
87
+ };
88
+
89
+ return app;
72
90
  }
73
91
 
74
92
  export function getNats(): Promise<NatsConnection> {
75
- return getContext<Promise<NatsConnection>>(NATS_KEY);
93
+ return getTrellisContext().nats;
76
94
  }
77
95
 
78
96
  export function getNatsState(): Promise<NatsState> {
79
- return getContext<Promise<NatsState>>(NATS_STATE_KEY);
97
+ return getNatsStateContext();
80
98
  }
81
99
 
82
100
  export function getAuth(): AuthState {
83
- return getContext<AuthState>(AUTH_KEY);
101
+ return getAuthContext()();
84
102
  }
package/src/index.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  export { default as TrellisProvider } from "./components/TrellisProvider.svelte";
2
- export { getAuth, getNats, getNatsState, getTrellis, getTrellisFor, setTrellisContext } from "./context.svelte.ts";
3
- export { AuthState, type BindErrorResult, type BindResult, createAuthState } from "./state/auth.svelte.ts";
2
+ export { createTrellisApp, getAuth, getNats, getNatsState, setTrellisContext } from "./context.svelte.ts";
3
+ export type { BoundTrellisApp } from "./context.svelte.ts";
4
+ export { createPortalFlow, PortalFlowController, type CreatePortalFlowConfig } from "./portal_flow.svelte.ts";
5
+ export { AuthState, type BindErrorResult, type BindResult, createAuthState, type SignInOptions } from "./state/auth.svelte.ts";
4
6
  export type { NatsStateConfig, Status as NatsStatus } from "./state/nats.svelte.ts";
5
7
  export { createNatsState, NatsState } from "./state/nats.svelte.ts";
8
+ export type { TrellisClientContract, TrellisStateConfig } from "./state/trellis.svelte.ts";
6
9
  export { createTrellisState, TrellisState } from "./state/trellis.svelte.ts";
@@ -0,0 +1,101 @@
1
+ import {
2
+ fetchPortalFlowState,
3
+ portalFlowIdFromUrl,
4
+ portalProviderLoginUrl,
5
+ type PortalFlowState,
6
+ submitPortalApproval,
7
+ type AuthConfig,
8
+ } from "@qlever-llc/trellis/auth";
9
+
10
+ export type CreatePortalFlowConfig = AuthConfig & {
11
+ getUrl?: () => URL;
12
+ };
13
+
14
+ function errorMessage(error: unknown): string {
15
+ return error instanceof Error ? error.message : String(error);
16
+ }
17
+
18
+ function defaultGetUrl(): URL {
19
+ return new URL(globalThis.location.href);
20
+ }
21
+
22
+ export class PortalFlowController {
23
+ flowId: string | null = $state(null);
24
+ state: PortalFlowState | null = $state(null);
25
+ loading = $state(false);
26
+ error: string | null = $state(null);
27
+
28
+ #config: AuthConfig;
29
+ #getUrl: () => URL;
30
+
31
+ constructor(config: CreatePortalFlowConfig) {
32
+ this.#config = { authUrl: config.authUrl };
33
+ this.#getUrl = config.getUrl ?? defaultGetUrl;
34
+ }
35
+
36
+ async load(): Promise<PortalFlowState | null> {
37
+ this.loading = true;
38
+ this.error = null;
39
+ this.state = null;
40
+
41
+ try {
42
+ const flowId = portalFlowIdFromUrl(this.#getUrl());
43
+ this.flowId = flowId;
44
+ if (!flowId) {
45
+ this.error = "Missing flow id.";
46
+ return null;
47
+ }
48
+
49
+ const state = await fetchPortalFlowState(this.#config, flowId);
50
+ this.state = state;
51
+ return state;
52
+ } catch (error) {
53
+ this.error = errorMessage(error);
54
+ this.state = null;
55
+ return null;
56
+ } finally {
57
+ this.loading = false;
58
+ }
59
+ }
60
+
61
+ providerUrl(providerId: string): string {
62
+ if (!this.flowId) {
63
+ throw new Error("Missing flow id.");
64
+ }
65
+
66
+ return portalProviderLoginUrl(this.#config, providerId, this.flowId);
67
+ }
68
+
69
+ async approve(): Promise<PortalFlowState | null> {
70
+ return this.#submit("approved");
71
+ }
72
+
73
+ async deny(): Promise<PortalFlowState | null> {
74
+ return this.#submit("denied");
75
+ }
76
+
77
+ async #submit(decision: "approved" | "denied"): Promise<PortalFlowState | null> {
78
+ if (!this.flowId) {
79
+ this.error = "Missing flow id.";
80
+ return null;
81
+ }
82
+
83
+ this.loading = true;
84
+ this.error = null;
85
+
86
+ try {
87
+ const state = await submitPortalApproval(this.#config, this.flowId, decision);
88
+ this.state = state;
89
+ return state;
90
+ } catch (error) {
91
+ this.error = errorMessage(error);
92
+ return null;
93
+ } finally {
94
+ this.loading = false;
95
+ }
96
+ }
97
+ }
98
+
99
+ export function createPortalFlow(config: CreatePortalFlowConfig): PortalFlowController {
100
+ return new PortalFlowController(config);
101
+ }
@@ -1,20 +1,22 @@
1
1
  import {
2
2
  type BindResponse,
3
3
  type BindSuccessResponse,
4
+ bindFlow,
4
5
  bindSession,
5
- buildLoginUrl,
6
6
  clearSessionKey,
7
- extractAuthErrorFromFragment,
8
- extractAuthTokenFromFragment,
9
7
  getOrCreateSessionKey,
10
8
  getPublicSessionKey,
11
9
  type SentinelCreds,
12
10
  type SessionKeyHandle,
13
- } from "@qlever-llc/trellis-auth";
14
- import { Result } from "@qlever-llc/trellis-result";
11
+ } from "@qlever-llc/trellis/auth";
12
+ import { canonicalizeJsonValue } from "../../../auth/utils.ts";
13
+ import { oauthInitSig } from "../../../auth/browser/session.ts";
14
+ import { Result } from "@qlever-llc/result";
15
15
  import { SvelteDate } from "svelte/reactivity";
16
+ import type { TrellisContractV1 } from "@qlever-llc/trellis";
16
17
  import { Type } from "typebox";
17
18
  import { Value } from "typebox/value";
19
+ import type { TrellisClientContract } from "./trellis.svelte.ts";
18
20
 
19
21
  export type BindErrorResult =
20
22
  | { status: "insufficient_capabilities"; missingCapabilities: string[] }
@@ -24,15 +26,8 @@ export type BindErrorResult =
24
26
 
25
27
  export type BindResult = { status: "bound" } | BindErrorResult;
26
28
 
27
- const buildLoginHref = buildLoginUrl as unknown as (
28
- config: { authUrl: string },
29
- provider: string | undefined,
30
- redirectTo: string,
31
- handle: SessionKeyHandle,
32
- contract: Record<string, unknown>,
33
- ) => Promise<string>;
34
-
35
29
  const STORAGE_KEY = "trellis_auth";
30
+ const AUTH_URL_STORAGE_KEY = "trellis_auth_url";
36
31
 
37
32
  type AuthStateData = {
38
33
  handle: SessionKeyHandle | null;
@@ -104,11 +99,88 @@ function clearPersistedAuth(): void {
104
99
  }
105
100
 
106
101
  export type AuthStateConfig = {
107
- authUrl: string; // https://auth.example.com
102
+ authUrl?: string; // https://auth.example.com
108
103
  loginPath?: string;
109
- contract?: { CONTRACT: Record<string, unknown> };
104
+ contract?: TrellisClientContract;
105
+ };
106
+
107
+ export type SignInOptions = {
108
+ authUrl?: string;
109
+ redirectTo?: string;
110
+ landingPath?: string;
111
+ context?: unknown;
110
112
  };
111
113
 
114
+ function normalizeAuthUrl(authUrl: string): string {
115
+ return new URL(authUrl).toString().replace(/\/$/, "");
116
+ }
117
+
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;
146
+ }
147
+
148
+ function loadPersistedAuthUrl(): string | null {
149
+ if (typeof localStorage === "undefined") return null;
150
+ const stored = localStorage.getItem(AUTH_URL_STORAGE_KEY);
151
+ if (!stored) return null;
152
+
153
+ return Result.try(() => normalizeAuthUrl(stored)).unwrapOr(null);
154
+ }
155
+
156
+ function persistAuthUrl(authUrl: string): string {
157
+ const normalized = normalizeAuthUrl(authUrl);
158
+ if (typeof localStorage !== "undefined") {
159
+ localStorage.setItem(AUTH_URL_STORAGE_KEY, normalized);
160
+ }
161
+ return normalized;
162
+ }
163
+
164
+ function resolveRedirectTo(
165
+ options: SignInOptions,
166
+ currentUrl: URL,
167
+ ): string {
168
+ if (options.redirectTo) {
169
+ return new URL(options.redirectTo, currentUrl.origin).toString();
170
+ }
171
+
172
+ const queryRedirect = currentUrl.searchParams.get("redirectTo");
173
+ if (queryRedirect) {
174
+ return new URL(queryRedirect, currentUrl.origin).toString();
175
+ }
176
+
177
+ if (options.landingPath) {
178
+ return new URL(options.landingPath, currentUrl.origin).toString();
179
+ }
180
+
181
+ return currentUrl.toString();
182
+ }
183
+
112
184
  /**
113
185
  * Svelte 5 runes-based reactive authentication state.
114
186
  *
@@ -132,12 +204,48 @@ export class AuthState {
132
204
  #bindingInProgress: Promise<BindResult> | null = null;
133
205
 
134
206
  constructor(config: AuthStateConfig) {
135
- this.#config = config;
207
+ this.#config = {
208
+ ...config,
209
+ authUrl: config.authUrl ? normalizeAuthUrl(config.authUrl) : undefined,
210
+ };
211
+ }
212
+
213
+ #getConfiguredAuthUrl(): string | null {
214
+ if (this.#config.authUrl) return this.#config.authUrl;
215
+
216
+ const persisted = loadPersistedAuthUrl();
217
+ if (!persisted) return null;
218
+
219
+ this.#config.authUrl = persisted;
220
+ return persisted;
221
+ }
222
+
223
+ #requireAuthUrl(): string {
224
+ const authUrl = this.#getConfiguredAuthUrl();
225
+ if (!authUrl) {
226
+ throw new Error("Auth URL is not configured");
227
+ }
228
+ return authUrl;
229
+ }
230
+
231
+ setAuthUrl(authUrl: string): string {
232
+ const normalized = persistAuthUrl(authUrl);
233
+ this.#config.authUrl = normalized;
234
+ return normalized;
136
235
  }
137
236
 
138
237
  get handle(): SessionKeyHandle | null {
139
238
  return this.#state.handle;
140
239
  }
240
+ get authUrl(): string | null {
241
+ return this.#getConfiguredAuthUrl();
242
+ }
243
+ get loginPath(): string {
244
+ return this.#config.loginPath ?? "/login";
245
+ }
246
+ get contract(): TrellisClientContract | undefined {
247
+ return this.#config.contract;
248
+ }
141
249
  get sessionKey(): string | null {
142
250
  return this.#state.handle ? getPublicSessionKey(this.#state.handle) : null;
143
251
  }
@@ -169,6 +277,8 @@ export class AuthState {
169
277
  async init(): Promise<SessionKeyHandle> {
170
278
  if (this.#state.handle) return this.#state.handle;
171
279
 
280
+ this.#getConfiguredAuthUrl();
281
+
172
282
  const handle = await getOrCreateSessionKey();
173
283
  this.#state.handle = handle;
174
284
 
@@ -188,30 +298,28 @@ export class AuthState {
188
298
  * Initiate OAuth sign-in flow by redirecting to the auth provider.
189
299
  * This method does not return - it redirects the browser.
190
300
  */
191
- async signIn(provider: string | undefined, redirectTo: string): Promise<never> {
301
+ async signIn(options: SignInOptions = {}): Promise<never> {
302
+ const authUrl = options.authUrl ? this.setAuthUrl(options.authUrl) : this.#requireAuthUrl();
192
303
  const handle = await this.init();
193
- const url = await buildLoginHref(
194
- { authUrl: this.#config.authUrl },
195
- provider,
196
- redirectTo,
304
+ const currentUrl = new URL(window.location.href);
305
+ const url = await buildLoginUrl({
306
+ authUrl,
307
+ redirectTo: resolveRedirectTo(options, currentUrl),
197
308
  handle,
198
- this.#config.contract?.CONTRACT ?? {},
199
- );
309
+ contract: this.#config.contract?.CONTRACT ?? {},
310
+ context: options.context,
311
+ });
200
312
  window.location.href = url;
201
- throw new Error(provider ? `Redirecting to ${provider} for authentication` : "Redirecting to auth for provider selection");
313
+ throw new Error("Redirecting to auth for provider selection");
202
314
  }
203
315
 
204
316
  async handleCallback(url: string = window.location.href): Promise<BindResult | null> {
205
317
  if (this.#bindingInProgress) return this.#bindingInProgress;
206
318
 
207
- const authError = extractAuthErrorFromFragment(url);
208
- if (authError === "approval_denied") return { status: "approval_denied" };
209
- if (authError) return { status: "error", message: authError };
210
-
211
- const authToken = extractAuthTokenFromFragment(url);
212
- if (!authToken) return null;
319
+ const flowId = new URL(url).searchParams.get("flowId");
320
+ if (!flowId) return null;
213
321
 
214
- this.#bindingInProgress = this.#resolveCallback(authToken);
322
+ this.#bindingInProgress = this.#resolveCallback(flowId);
215
323
  try {
216
324
  return await this.#bindingInProgress;
217
325
  } finally {
@@ -219,9 +327,9 @@ export class AuthState {
219
327
  }
220
328
  }
221
329
 
222
- async #resolveCallback(authToken: string): Promise<BindResult> {
330
+ async #resolveCallback(flowId: string): Promise<BindResult> {
223
331
  try {
224
- const response = await this.bind(authToken);
332
+ const response = await this.bindFlow(flowId);
225
333
  return response.status === "bound"
226
334
  ? { status: "bound" }
227
335
  : { status: "insufficient_capabilities", missingCapabilities: response.missingCapabilities };
@@ -234,12 +342,13 @@ export class AuthState {
234
342
  }
235
343
 
236
344
  /**
237
- * Clean up the callback URL by removing the authToken fragment.
345
+ * Clean up the callback URL by removing the auth flow query params.
238
346
  */
239
347
  cleanupCallbackUrl(url: string = window.location.href): void {
240
348
  const parsed = new URL(url);
241
- if (parsed.hash) {
242
- parsed.hash = "";
349
+ if (parsed.searchParams.has("flowId") || parsed.searchParams.has("authError")) {
350
+ parsed.searchParams.delete("flowId");
351
+ parsed.searchParams.delete("authError");
243
352
  window.history.replaceState({}, "", parsed.pathname + parsed.search);
244
353
  }
245
354
  }
@@ -255,7 +364,7 @@ export class AuthState {
255
364
  async #bind(authToken: string): Promise<BindResponse> {
256
365
  const handle = await this.init();
257
366
  const response = await bindSession(
258
- { authUrl: this.#config.authUrl },
367
+ { authUrl: this.#requireAuthUrl() },
259
368
  handle,
260
369
  authToken,
261
370
  );
@@ -267,6 +376,21 @@ export class AuthState {
267
376
  return response;
268
377
  }
269
378
 
379
+ async bindFlow(flowId: string): Promise<BindResponse> {
380
+ const handle = await this.init();
381
+ const response = await bindFlow(
382
+ { authUrl: this.#requireAuthUrl() },
383
+ handle,
384
+ flowId,
385
+ );
386
+
387
+ if (response.status === "bound") {
388
+ this.setBindingToken(response);
389
+ }
390
+
391
+ return response;
392
+ }
393
+
270
394
  setBindingToken(
271
395
  response: Pick<BindSuccessResponse, "bindingToken" | "inboxPrefix" | "expires"> & {
272
396
  sentinel?: SentinelCreds;
@@ -326,8 +450,7 @@ export class AuthState {
326
450
  this.clearAuth();
327
451
  this.#state.handle = null;
328
452
 
329
- const loginPath = this.#config.loginPath ?? "/login";
330
- window.location.href = loginPath;
453
+ window.location.href = this.loginPath;
331
454
  throw new Error("Signed out, redirecting to login");
332
455
  }
333
456
  }
@@ -3,20 +3,20 @@ import {
3
3
  type NatsConnection,
4
4
  wsconnect,
5
5
  } from "@nats-io/nats-core";
6
- import { createClient } from "@qlever-llc/trellis";
6
+ import { createClient, type Trellis } from "@qlever-llc/trellis";
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";
14
- import { AsyncResult, isErr, UnexpectedError } from "@qlever-llc/trellis-result";
13
+ } from "@qlever-llc/trellis/auth";
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
 
22
22
  const AUTH_RENEW_API = {
@@ -25,19 +25,34 @@ const AUTH_RENEW_API = {
25
25
  },
26
26
  events: {},
27
27
  subjects: {},
28
+ operations: {},
28
29
  } as const;
29
30
 
30
- type AuthRenewClient = {
31
- request(
32
- method: "Auth.RenewBindingToken",
33
- input: AuthRenewBindingTokenInput,
34
- ): Promise<{ take(): AuthRenewBindingTokenOutput | unknown }>;
31
+ const AUTH_RENEW_CONTRACT = {
32
+ API: {
33
+ trellis: AUTH_RENEW_API,
34
+ },
35
+ } as const;
36
+
37
+ function createAuthRenewClient(
38
+ nc: NatsConnection,
39
+ handle: SessionKeyHandle,
40
+ ): Trellis<typeof AUTH_RENEW_API> {
41
+ return createClient<typeof AUTH_RENEW_API>(
42
+ AUTH_RENEW_CONTRACT,
43
+ nc,
44
+ {
45
+ sessionKey: getPublicSessionKey(handle),
46
+ sign: (data: Uint8Array) => signBytes(handle, data),
47
+ },
48
+ { name: "auth-renew" },
49
+ );
35
50
  };
36
51
 
37
52
  export type Status = "disconnected" | "connecting" | "connected" | "error";
38
53
 
39
54
  export type NatsStateConfig = {
40
- servers: string[];
55
+ servers?: string[];
41
56
  onConnecting?: () => void;
42
57
  onConnected?: () => void;
43
58
  onDisconnect?: () => void;
@@ -63,19 +78,15 @@ function requireBrowserAuth(authState: AuthState): {
63
78
  return { handle, bindingToken, sentinel };
64
79
  }
65
80
 
66
- function createAuthRenewClient(
67
- nc: NatsConnection,
68
- handle: SessionKeyHandle,
69
- ) : AuthRenewClient {
70
- return createClient(
71
- { API: { trellis: AUTH_RENEW_API } },
72
- nc,
73
- {
74
- sessionKey: getPublicSessionKey(handle),
75
- sign: (data: Uint8Array) => signBytes(handle, data),
76
- },
77
- { name: "auth-renew" },
78
- ) as unknown as AuthRenewClient;
81
+ function resolveServers(
82
+ authState: AuthState,
83
+ config: NatsStateConfig,
84
+ ): string[] {
85
+ const servers = config.servers ?? authState.natsServers;
86
+ if (!servers || servers.length === 0) {
87
+ throw new Error("Not authenticated: missing natsServers from auth state");
88
+ }
89
+ return servers;
79
90
  }
80
91
 
81
92
  async function buildNatsAuthToken(
@@ -105,7 +116,7 @@ export class NatsState {
105
116
  #handle: SessionKeyHandle;
106
117
  #tokenRef: { value: string };
107
118
  #sentinel: SentinelCreds;
108
- #trellis: AuthRenewClient;
119
+ #trellis: ReturnType<typeof createAuthRenewClient>;
109
120
  #renewTimer: ReturnType<typeof setTimeout> | undefined;
110
121
 
111
122
  private constructor(
@@ -151,6 +162,7 @@ export class NatsState {
151
162
  }
152
163
 
153
164
  const { handle, bindingToken, sentinel } = requireBrowserAuth(authState);
165
+ const servers = resolveServers(authState, config);
154
166
  const inboxPrefix = authState.inboxPrefix ?? undefined;
155
167
  const tokenRef = { value: await buildNatsAuthToken(handle, bindingToken) };
156
168
 
@@ -161,7 +173,7 @@ export class NatsState {
161
173
  );
162
174
 
163
175
  const nc = await wsconnect({
164
- servers: config.servers,
176
+ servers,
165
177
  authenticator,
166
178
  token: tokenRef.value, // auth_token for auth callout
167
179
  reconnect: true,
@@ -175,7 +187,7 @@ export class NatsState {
175
187
  const state = new NatsState(
176
188
  nc,
177
189
  "connected",
178
- config.servers,
190
+ servers,
179
191
  authState,
180
192
  config,
181
193
  handle,
@@ -258,13 +270,14 @@ export class NatsState {
258
270
  async #renewBindingToken(): Promise<void> {
259
271
  const renew = async () => {
260
272
  if (this.status !== "connected") return;
261
- const res = await this.#trellis.request(
273
+ const requestOrThrow = this.#trellis.requestOrThrow.bind(this.#trellis) as (
274
+ method: string,
275
+ input: unknown,
276
+ ) => Promise<unknown>;
277
+ const binding = await requestOrThrow(
262
278
  "Auth.RenewBindingToken",
263
279
  {} satisfies AuthRenewBindingTokenInput,
264
- );
265
- const v = res.take();
266
- if (isErr(v)) return;
267
- const binding = v as AuthRenewBindingTokenOutput;
280
+ ) as AuthRenewBindingTokenOutput;
268
281
  this.#authState.setBindingToken(binding);
269
282
  this.#tokenRef.value = await buildNatsAuthToken(
270
283
  this.#handle,
@@ -1,12 +1,16 @@
1
- import type { NatsConnection } from "@nats-io/nats-core";
2
- import { type ClientOpts, createClient, type Trellis, type TrellisAuth } from "@qlever-llc/trellis";
3
- import { getPublicSessionKey, signBytes } from "@qlever-llc/trellis-auth";
4
- import { defineContract, type TrellisAPI } from "@qlever-llc/trellis-contracts";
1
+ import {
2
+ createClient,
3
+ defineContract,
4
+ type Trellis,
5
+ type TrellisAPI,
6
+ type TrellisContractV1,
7
+ } from "@qlever-llc/trellis";
8
+ import { getPublicSessionKey, signBytes } from "@qlever-llc/trellis/auth";
5
9
  import type { AuthState } from "./auth.svelte.ts";
6
10
  import type { NatsState } from "./nats.svelte.ts";
7
11
 
8
12
  export type TrellisClientContract<TApi extends TrellisAPI = TrellisAPI> = {
9
- CONTRACT: Record<string, unknown>;
13
+ CONTRACT: TrellisContractV1;
10
14
  CONTRACT_DIGEST: string;
11
15
  API: {
12
16
  trellis: TApi;
@@ -14,7 +18,6 @@ export type TrellisClientContract<TApi extends TrellisAPI = TrellisAPI> = {
14
18
  };
15
19
 
16
20
  export type TrellisStateConfig<TApi extends TrellisAPI = TrellisAPI> = {
17
- serviceName: string;
18
21
  contract?: TrellisClientContract<TApi>;
19
22
  };
20
23
 
@@ -23,7 +26,7 @@ const DEFAULT_TRELLIS_CONTRACT = defineContract({
23
26
  displayName: "Trellis Svelte Browser Client",
24
27
  description: "Represent a browser client that only uses its locally declared Trellis APIs.",
25
28
  kind: "browser",
26
- });
29
+ }) satisfies TrellisClientContract<TrellisAPI>;
27
30
 
28
31
  /**
29
32
  * Svelte 5 wrapper for Trellis client.
@@ -32,33 +35,36 @@ const DEFAULT_TRELLIS_CONTRACT = defineContract({
32
35
  * - Trellis client instance
33
36
  * - Session-key based request signing
34
37
  */
35
- export class TrellisState {
36
- readonly trellis: Trellis<TrellisAPI>;
38
+ export class TrellisState<TApi extends TrellisAPI = TrellisAPI> {
39
+ readonly trellis: Trellis<TApi>;
37
40
 
38
- private constructor(trellis: Trellis<TrellisAPI>) {
41
+ private constructor(trellis: Trellis<TApi>) {
39
42
  this.trellis = trellis;
40
43
  }
41
44
 
42
45
  /**
43
46
  * Create a TrellisState instance with proper authentication.
44
47
  */
45
- static async create<TApi extends TrellisAPI = TrellisAPI>(
48
+ static async create<TApi extends TrellisAPI>(
46
49
  authState: AuthState,
47
50
  natsState: NatsState,
48
51
  config: TrellisStateConfig<TApi>,
49
- ): Promise<TrellisState> {
52
+ ): Promise<TrellisState<TApi>> {
50
53
  const handle = await authState.init();
51
54
  const contract = (config.contract ?? DEFAULT_TRELLIS_CONTRACT) as TrellisClientContract<TApi>;
52
- const trellis = createClient(
55
+ const clientName = typeof contract.CONTRACT.id === "string" && contract.CONTRACT.id.length > 0
56
+ ? contract.CONTRACT.id
57
+ : "client";
58
+ const trellis = createClient<TApi>(
53
59
  contract,
54
60
  natsState.nc,
55
61
  {
56
62
  sessionKey: getPublicSessionKey(handle),
57
63
  sign: (data: Uint8Array) => signBytes(handle, data),
58
64
  },
59
- { name: config.serviceName },
60
- ) as unknown as Trellis<TrellisAPI>;
61
- return new TrellisState(trellis);
65
+ { name: clientName },
66
+ );
67
+ return new TrellisState<TApi>(trellis);
62
68
  }
63
69
 
64
70
  stop(): void {
@@ -69,10 +75,10 @@ export class TrellisState {
69
75
  /**
70
76
  * Factory function to create a TrellisState instance.
71
77
  */
72
- export async function createTrellisState<TApi extends TrellisAPI = TrellisAPI>(
78
+ export async function createTrellisState<TApi extends TrellisAPI>(
73
79
  authState: AuthState,
74
80
  natsState: NatsState,
75
81
  config: TrellisStateConfig<TApi>,
76
- ): Promise<TrellisState> {
77
- return TrellisState.create(authState, natsState, config);
82
+ ): Promise<TrellisState<TApi>> {
83
+ return TrellisState.create<TApi>(authState, natsState, config);
78
84
  }