@qlever-llc/trellis-svelte 0.4.0 → 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,4 +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
- Uses the contract/runtime model from `@qlever-llc/trellis-contracts` and `@qlever-llc/trellis-trellis`.
7
+ Prefer `createTrellisApp(...)` for app-scoped auth creation and typed `app.getTrellis()` access without passing the contract around at every call site.
8
+
9
+ Uses the contract/runtime model from `@qlever-llc/trellis/contracts` and `@qlever-llc/trellis`.
package/package.json CHANGED
@@ -1,11 +1,19 @@
1
1
  {
2
2
  "name": "@qlever-llc/trellis-svelte",
3
- "version": "0.4.0",
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",
7
+ "homepage": "https://github.com/Qlever-LLC/trellis#readme",
8
+ "bugs": {
9
+ "url": "https://github.com/Qlever-LLC/trellis/issues"
10
+ },
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "https://github.com/Qlever-LLC/trellis"
14
+ },
7
15
  "publishConfig": {
8
- "access": "restricted"
16
+ "access": "public"
9
17
  },
10
18
  "files": [
11
19
  "src",
@@ -20,11 +28,7 @@
20
28
  },
21
29
  "dependencies": {
22
30
  "@nats-io/nats-core": "^3.3.1",
23
- "@qlever-llc/trellis-auth": "^0.4.0",
24
- "@qlever-llc/trellis-contracts": "^0.4.0",
25
- "@qlever-llc/trellis-result": "^0.4.0",
26
- "@qlever-llc/trellis-sdk-auth": "^0.4.0",
27
- "@qlever-llc/trellis-trellis": "^0.4.0",
31
+ "@qlever-llc/trellis": "^0.6.0",
28
32
  "typebox": "^1.0.15"
29
33
  },
30
34
  "peerDependencies": {
@@ -1,32 +1,31 @@
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, 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;
28
+ onBindError?: (result: BindErrorResult) => void;
30
29
  onNatsConnecting?: () => void;
31
30
  onNatsConnected?: () => void;
32
31
  onNatsDisconnect?: () => void;
@@ -35,17 +34,15 @@
35
34
  onNatsError?: (error: Error) => void;
36
35
  };
37
36
 
38
- const {
37
+ let {
39
38
  children,
40
39
  loading,
41
- authUrl,
42
- natsServers,
43
- serviceName = "app",
44
- contract,
45
- loginPath = "/login",
40
+ bindError,
41
+ app,
46
42
  onAuthExpired,
47
43
  onAuthFailed,
48
44
  onAuthRequired,
45
+ onBindError,
49
46
  onNatsConnecting,
50
47
  onNatsConnected,
51
48
  onNatsDisconnect,
@@ -54,27 +51,14 @@
54
51
  onNatsError,
55
52
  }: Props = $props();
56
53
 
54
+ let bindErrorResult = $state<BindErrorResult | null>(null);
55
+
57
56
  type InitContext = {
58
57
  auth: AuthState;
59
58
  nats: NatsState;
60
59
  trellis: TrellisState;
61
60
  };
62
-
63
- class AuthRequiredError extends Error {
64
- constructor() {
65
- super("Authentication required");
66
- }
67
- }
68
-
69
- function createProviderAuthState(): AuthState {
70
- return createAuthState({
71
- authUrl,
72
- loginPath,
73
- contract,
74
- });
75
- }
76
-
77
- const authState = createProviderAuthState();
61
+ const isBrowser = typeof window !== "undefined";
78
62
 
79
63
  function getRedirectTo(): string {
80
64
  if (typeof window === "undefined") {
@@ -84,19 +68,42 @@
84
68
  return window.location.pathname + window.location.search;
85
69
  }
86
70
 
87
- async function initialize(): Promise<InitContext> {
71
+ function redirectToLogin(redirectTo: string): void {
72
+ if (typeof window === "undefined") return;
73
+
74
+ const url = new URL(app.auth.loginPath, window.location.origin);
75
+ url.searchParams.set("redirectTo", redirectTo);
76
+ window.location.href = url.toString();
77
+ }
78
+
79
+ function handleAuthRequired(): void {
80
+ const redirectTo = getRedirectTo();
81
+ onAuthRequired?.(redirectTo);
82
+ if (!onAuthRequired) {
83
+ redirectToLogin(redirectTo);
84
+ }
85
+ }
86
+
87
+ async function initialize(): Promise<InitContext | null> {
88
88
  const result = await AsyncResult.try(async () => {
89
+ const authState = app.auth;
90
+
89
91
  await authState.init();
90
- await authState.handleCallback();
92
+ const bindResult = await authState.handleCallback();
91
93
  authState.cleanupCallbackUrl();
92
94
 
95
+ if (bindResult !== null && bindResult.status !== "bound") {
96
+ bindErrorResult = bindResult;
97
+ onBindError?.(bindResult);
98
+ return null;
99
+ }
100
+
93
101
  if (!authState.isAuthenticated) {
94
- onAuthRequired?.(getRedirectTo());
95
- throw new AuthRequiredError();
102
+ handleAuthRequired();
103
+ return null;
96
104
  }
97
105
 
98
106
  const natsState = await createNatsState(authState, {
99
- servers: natsServers,
100
107
  onConnecting: onNatsConnecting,
101
108
  onConnected: onNatsConnected,
102
109
  onDisconnect: onNatsDisconnect,
@@ -104,13 +111,13 @@
104
111
  onReconnect: onNatsReconnect,
105
112
  onError: onNatsError,
106
113
  onAuthRequired: () => {
107
- onAuthRequired?.(getRedirectTo());
114
+ onAuthExpired?.();
115
+ handleAuthRequired();
108
116
  },
109
117
  });
110
118
 
111
119
  const trellisState = await createTrellisState(authState, natsState, {
112
- serviceName,
113
- contract,
120
+ contract: authState.contract,
114
121
  });
115
122
 
116
123
  return {
@@ -121,11 +128,9 @@
121
128
  });
122
129
 
123
130
  if (result.isErr()) {
124
- authState.clearAuth();
125
131
  const error = result.error;
126
- if (!(error instanceof AuthRequiredError)) {
127
- onAuthFailed?.(error);
128
- }
132
+ app.auth.clearAuth();
133
+ onAuthFailed?.(error);
129
134
  throw error;
130
135
  }
131
136
 
@@ -137,30 +142,54 @@
137
142
  });
138
143
  }
139
144
 
140
- const initPromise = initialize();
141
- const natsStatePromise = initPromise.then((ctx) => ctx.nats) as Promise<NatsState>;
142
- const trellisPromise = initPromise.then((ctx) => ctx.trellis.trellis) as Promise<unknown>;
143
- const natsPromise = initPromise.then((ctx) => ctx.nats.nc) as Promise<NatsConnection>;
144
-
145
- setAuthContext(authState);
146
- setNatsStateContext(natsStatePromise);
147
- setTrellisContext({
148
- trellis: trellisPromise as never,
149
- nats: natsPromise,
150
- });
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
+ }
151
166
 
152
167
  onDestroy(() => {
153
- void initPromise.then((ctx) => {
168
+ if (!readyPromise) return;
169
+
170
+ void readyPromise.then((ctx) => {
154
171
  ctx.trellis.stop();
155
172
  void ctx.nats.disconnect();
156
173
  });
157
174
  });
158
175
  </script>
159
176
 
160
- {#await initPromise}
177
+ {#if !isBrowser}
161
178
  {#if loading}
162
179
  {@render loading()}
163
180
  {/if}
164
- {:then}
165
- {@render children()}
166
- {/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,47 +1,102 @@
1
- import { getContext, setContext } from "svelte";
2
- import type { TrellisAPI } from "@qlever-llc/trellis-contracts";
3
- import type { Trellis } from "@qlever-llc/trellis-trellis";
1
+ import type { BaseError, Result } from "@qlever-llc/result";
4
2
  import type { NatsConnection } from "@nats-io/nats-core";
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
 
18
- export function setTrellisContext<TA extends TrellisAPI>(
19
- ctx: { trellis: Promise<Trellis<TA>>; nats: Promise<NatsConnection> },
13
+ type TrellisContractLike<TA extends TrellisAPI = TrellisAPI> = {
14
+ API: {
15
+ trellis: TA;
16
+ };
17
+ };
18
+
19
+ type RequestOpts = {
20
+ timeout?: number;
21
+ };
22
+
23
+ type TypedTrellis<TA extends TrellisAPI> = Omit<Trellis<TrellisAPI>, "request" | "requestOrThrow"> & {
24
+ request<M extends keyof TA["rpc"] & string>(
25
+ method: M,
26
+ input: InferSchemaType<TA["rpc"][M]["input"]>,
27
+ opts?: RequestOpts,
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"]>>;
44
+ };
45
+
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>();
50
+
51
+ export function setTrellisContext(
52
+ ctx: TrellisContext,
20
53
  ): void {
21
- setContext(TRELLIS_KEY, ctx.trellis as unknown as Promise<unknown>);
22
- setContext(NATS_KEY, ctx.nats);
54
+ setTrellisContextValue(ctx);
23
55
  }
24
56
 
25
57
  export function setNatsStateContext(natsState: Promise<NatsState>): void {
26
- setContext(NATS_STATE_KEY, natsState);
58
+ setNatsStateContextValue(natsState);
59
+ }
60
+
61
+ export function setAuthContext(getAuth: () => AuthState): void {
62
+ setAuthContextValue(getAuth);
27
63
  }
28
64
 
29
- export function setAuthContext(auth: AuthState): void {
30
- setContext(AUTH_KEY, auth);
65
+ export function setAppContext(getApp: () => unknown): void {
66
+ setAppContextValue(getApp);
31
67
  }
32
68
 
33
- export function getTrellis<TA extends TrellisAPI = TrellisAPI>(): Promise<Trellis<TA>> {
34
- return getContext<Promise<unknown>>(TRELLIS_KEY) as Promise<Trellis<TA>>;
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;
35
90
  }
36
91
 
37
92
  export function getNats(): Promise<NatsConnection> {
38
- return getContext<Promise<NatsConnection>>(NATS_KEY);
93
+ return getTrellisContext().nats;
39
94
  }
40
95
 
41
96
  export function getNatsState(): Promise<NatsState> {
42
- return getContext<Promise<NatsState>>(NATS_STATE_KEY);
97
+ return getNatsStateContext();
43
98
  }
44
99
 
45
100
  export function getAuth(): AuthState {
46
- return getContext<AuthState>(AUTH_KEY);
101
+ return getAuthContext()();
47
102
  }
package/src/index.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  export { default as TrellisProvider } from "./components/TrellisProvider.svelte";
2
- export { setTrellisContext, getTrellis, getNats, getNatsState, getAuth } from "./context.svelte.ts";
3
- export { AuthState, createAuthState } from "./state/auth.svelte.ts";
4
- export { NatsState, createNatsState } from "./state/nats.svelte.ts";
5
- export type { Status as NatsStatus, NatsStateConfig } from "./state/nats.svelte.ts";
6
- export { TrellisState, createTrellisState } from "./state/trellis.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";
6
+ export type { NatsStateConfig, Status as NatsStatus } from "./state/nats.svelte.ts";
7
+ export { createNatsState, NatsState } from "./state/nats.svelte.ts";
8
+ export type { TrellisClientContract, TrellisStateConfig } from "./state/trellis.svelte.ts";
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,21 +1,33 @@
1
1
  import {
2
2
  type BindResponse,
3
3
  type BindSuccessResponse,
4
+ bindFlow,
4
5
  bindSession,
5
- buildLoginUrl,
6
6
  clearSessionKey,
7
- extractAuthTokenFromFragment,
8
7
  getOrCreateSessionKey,
9
8
  getPublicSessionKey,
10
9
  type SentinelCreds,
11
10
  type SessionKeyHandle,
12
- } from "@qlever-llc/trellis-auth";
13
- 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";
14
15
  import { SvelteDate } from "svelte/reactivity";
16
+ import type { TrellisContractV1 } from "@qlever-llc/trellis";
15
17
  import { Type } from "typebox";
16
18
  import { Value } from "typebox/value";
19
+ import type { TrellisClientContract } from "./trellis.svelte.ts";
20
+
21
+ export type BindErrorResult =
22
+ | { status: "insufficient_capabilities"; missingCapabilities: string[] }
23
+ | { status: "approval_required" }
24
+ | { status: "approval_denied" }
25
+ | { status: "error"; message: string };
26
+
27
+ export type BindResult = { status: "bound" } | BindErrorResult;
17
28
 
18
29
  const STORAGE_KEY = "trellis_auth";
30
+ const AUTH_URL_STORAGE_KEY = "trellis_auth_url";
19
31
 
20
32
  type AuthStateData = {
21
33
  handle: SessionKeyHandle | null;
@@ -23,6 +35,7 @@ type AuthStateData = {
23
35
  inboxPrefix: string | null;
24
36
  expiresMs: number | null;
25
37
  sentinel: SentinelCreds | null;
38
+ natsServers: string[] | null;
26
39
  };
27
40
 
28
41
  type PersistedAuth = {
@@ -30,6 +43,7 @@ type PersistedAuth = {
30
43
  inboxPrefix: string;
31
44
  expires: string;
32
45
  sentinel: SentinelCreds;
46
+ natsServers: string[];
33
47
  };
34
48
 
35
49
  const PersistedAuthSchema = Type.Object({
@@ -40,6 +54,7 @@ const PersistedAuthSchema = Type.Object({
40
54
  jwt: Type.String(),
41
55
  seed: Type.String(),
42
56
  }, { additionalProperties: false }),
57
+ natsServers: Type.Array(Type.String()),
43
58
  }, { additionalProperties: false });
44
59
 
45
60
  function loadPersistedAuth(): PersistedAuth | null {
@@ -63,16 +78,18 @@ function persistAuth(state: {
63
78
  expires: Date;
64
79
  inboxPrefix: string;
65
80
  sentinel: SentinelCreds;
81
+ natsServers: string[];
66
82
  }): void {
67
83
  if (typeof localStorage === "undefined") return;
68
84
  localStorage.setItem(
69
85
  STORAGE_KEY,
70
86
  JSON.stringify({
71
- bindingToken: state.bindingToken,
72
- inboxPrefix: state.inboxPrefix,
73
- expires: state.expires.toISOString(),
74
- sentinel: state.sentinel,
75
- }),
87
+ bindingToken: state.bindingToken,
88
+ inboxPrefix: state.inboxPrefix,
89
+ expires: state.expires.toISOString(),
90
+ sentinel: state.sentinel,
91
+ natsServers: state.natsServers,
92
+ }),
76
93
  );
77
94
  }
78
95
 
@@ -82,11 +99,88 @@ function clearPersistedAuth(): void {
82
99
  }
83
100
 
84
101
  export type AuthStateConfig = {
85
- authUrl: string; // https://auth.example.com
102
+ authUrl?: string; // https://auth.example.com
86
103
  loginPath?: string;
87
- 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;
88
112
  };
89
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
+
90
184
  /**
91
185
  * Svelte 5 runes-based reactive authentication state.
92
186
  *
@@ -103,18 +197,55 @@ export class AuthState {
103
197
  inboxPrefix: null,
104
198
  expiresMs: null,
105
199
  sentinel: null,
200
+ natsServers: null,
106
201
  });
107
202
 
108
203
  #config: AuthStateConfig;
109
- #bindingInProgress: Promise<BindResponse> | null = null;
204
+ #bindingInProgress: Promise<BindResult> | null = null;
110
205
 
111
206
  constructor(config: AuthStateConfig) {
112
- 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;
113
235
  }
114
236
 
115
237
  get handle(): SessionKeyHandle | null {
116
238
  return this.#state.handle;
117
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
+ }
118
249
  get sessionKey(): string | null {
119
250
  return this.#state.handle ? getPublicSessionKey(this.#state.handle) : null;
120
251
  }
@@ -130,6 +261,9 @@ export class AuthState {
130
261
  get sentinel(): SentinelCreds | null {
131
262
  return this.#state.sentinel;
132
263
  }
264
+ get natsServers(): string[] | null {
265
+ return this.#state.natsServers;
266
+ }
133
267
  get isAuthenticated(): boolean {
134
268
  if (!this.#state.bindingToken) return false;
135
269
  if (this.#state.expiresMs === null) return false;
@@ -143,6 +277,8 @@ export class AuthState {
143
277
  async init(): Promise<SessionKeyHandle> {
144
278
  if (this.#state.handle) return this.#state.handle;
145
279
 
280
+ this.#getConfiguredAuthUrl();
281
+
146
282
  const handle = await getOrCreateSessionKey();
147
283
  this.#state.handle = handle;
148
284
 
@@ -152,6 +288,7 @@ export class AuthState {
152
288
  this.#state.inboxPrefix = persisted.inboxPrefix;
153
289
  this.#state.expiresMs = Date.parse(persisted.expires);
154
290
  this.#state.sentinel = persisted.sentinel;
291
+ this.#state.natsServers = persisted.natsServers;
155
292
  }
156
293
 
157
294
  return handle;
@@ -161,34 +298,28 @@ export class AuthState {
161
298
  * Initiate OAuth sign-in flow by redirecting to the auth provider.
162
299
  * This method does not return - it redirects the browser.
163
300
  */
164
- async signIn(provider: string, redirectTo: string): Promise<never> {
301
+ async signIn(options: SignInOptions = {}): Promise<never> {
302
+ const authUrl = options.authUrl ? this.setAuthUrl(options.authUrl) : this.#requireAuthUrl();
165
303
  const handle = await this.init();
166
- const url = await buildLoginUrl(
167
- { authUrl: this.#config.authUrl },
168
- provider,
169
- redirectTo,
304
+ const currentUrl = new URL(window.location.href);
305
+ const url = await buildLoginUrl({
306
+ authUrl,
307
+ redirectTo: resolveRedirectTo(options, currentUrl),
170
308
  handle,
171
- this.#config.contract.CONTRACT,
172
- );
309
+ contract: this.#config.contract?.CONTRACT ?? {},
310
+ context: options.context,
311
+ });
173
312
  window.location.href = url;
174
- throw new Error(`Redirecting to ${provider} for authentication`);
313
+ throw new Error("Redirecting to auth for provider selection");
175
314
  }
176
315
 
177
- /**
178
- * Handle OAuth callback by extracting the authToken from the URL fragment
179
- * and binding it to the session key.
180
- *
181
- * Returns the bind response if a fragment exists, null otherwise.
182
- * Includes a guard to prevent double binding when multiple components call this.
183
- */
184
- async handleCallback(url: string = window.location.href): Promise<BindResponse | null> {
185
- // Guard to prevent race conditions when multiple components call handleCallback
316
+ async handleCallback(url: string = window.location.href): Promise<BindResult | null> {
186
317
  if (this.#bindingInProgress) return this.#bindingInProgress;
187
318
 
188
- const authToken = extractAuthTokenFromFragment(url);
189
- if (!authToken) return null;
319
+ const flowId = new URL(url).searchParams.get("flowId");
320
+ if (!flowId) return null;
190
321
 
191
- this.#bindingInProgress = this.bind(authToken);
322
+ this.#bindingInProgress = this.#resolveCallback(flowId);
192
323
  try {
193
324
  return await this.#bindingInProgress;
194
325
  } finally {
@@ -196,13 +327,28 @@ export class AuthState {
196
327
  }
197
328
  }
198
329
 
330
+ async #resolveCallback(flowId: string): Promise<BindResult> {
331
+ try {
332
+ const response = await this.bindFlow(flowId);
333
+ return response.status === "bound"
334
+ ? { status: "bound" }
335
+ : { status: "insufficient_capabilities", missingCapabilities: response.missingCapabilities };
336
+ } catch (error) {
337
+ 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" };
340
+ return { status: "error", message };
341
+ }
342
+ }
343
+
199
344
  /**
200
- * Clean up the callback URL by removing the authToken fragment.
345
+ * Clean up the callback URL by removing the auth flow query params.
201
346
  */
202
347
  cleanupCallbackUrl(url: string = window.location.href): void {
203
348
  const parsed = new URL(url);
204
- if (parsed.hash) {
205
- parsed.hash = "";
349
+ if (parsed.searchParams.has("flowId") || parsed.searchParams.has("authError")) {
350
+ parsed.searchParams.delete("flowId");
351
+ parsed.searchParams.delete("authError");
206
352
  window.history.replaceState({}, "", parsed.pathname + parsed.search);
207
353
  }
208
354
  }
@@ -218,7 +364,7 @@ export class AuthState {
218
364
  async #bind(authToken: string): Promise<BindResponse> {
219
365
  const handle = await this.init();
220
366
  const response = await bindSession(
221
- { authUrl: this.#config.authUrl },
367
+ { authUrl: this.#requireAuthUrl() },
222
368
  handle,
223
369
  authToken,
224
370
  );
@@ -230,9 +376,25 @@ export class AuthState {
230
376
  return response;
231
377
  }
232
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
+
233
394
  setBindingToken(
234
395
  response: Pick<BindSuccessResponse, "bindingToken" | "inboxPrefix" | "expires"> & {
235
396
  sentinel?: SentinelCreds;
397
+ natsServers?: string[];
236
398
  },
237
399
  ): void {
238
400
  this.#state.bindingToken = response.bindingToken;
@@ -242,14 +404,18 @@ export class AuthState {
242
404
  if (response.sentinel) {
243
405
  this.#state.sentinel = response.sentinel;
244
406
  }
407
+ if (response.natsServers) {
408
+ this.#state.natsServers = response.natsServers;
409
+ }
245
410
 
246
411
  // Only persist if we have sentinel credentials
247
- if (this.#state.sentinel) {
412
+ if (this.#state.sentinel && this.#state.natsServers) {
248
413
  persistAuth({
249
414
  bindingToken: response.bindingToken,
250
415
  inboxPrefix: response.inboxPrefix,
251
416
  expires: new SvelteDate(String(response.expires)),
252
417
  sentinel: this.#state.sentinel,
418
+ natsServers: this.#state.natsServers,
253
419
  });
254
420
  }
255
421
  }
@@ -264,6 +430,7 @@ export class AuthState {
264
430
  this.#state.inboxPrefix = null;
265
431
  this.#state.expiresMs = null;
266
432
  this.#state.sentinel = null;
433
+ this.#state.natsServers = null;
267
434
  }
268
435
 
269
436
  /**
@@ -283,8 +450,7 @@ export class AuthState {
283
450
  this.clearAuth();
284
451
  this.#state.handle = null;
285
452
 
286
- const loginPath = this.#config.loginPath ?? "/login";
287
- window.location.href = loginPath;
453
+ window.location.href = this.loginPath;
288
454
  throw new Error("Signed out, redirecting to login");
289
455
  }
290
456
  }
@@ -3,20 +3,20 @@ import {
3
3
  type NatsConnection,
4
4
  wsconnect,
5
5
  } from "@nats-io/nats-core";
6
+ import { createClient, type Trellis } from "@qlever-llc/trellis";
6
7
  import {
7
8
  getPublicSessionKey,
8
9
  natsConnectSigForBindingToken,
9
10
  type SentinelCreds,
10
11
  type SessionKeyHandle,
11
12
  signBytes,
12
- } from "@qlever-llc/trellis-auth";
13
- import { AsyncResult, isErr, UnexpectedError } from "@qlever-llc/trellis-result";
13
+ } from "@qlever-llc/trellis/auth";
14
+ import { AsyncResult, UnexpectedError } from "@qlever-llc/result";
14
15
  import {
15
16
  API as AUTH_API,
16
17
  type AuthRenewBindingTokenInput,
17
18
  type AuthRenewBindingTokenOutput,
18
- } from "@qlever-llc/trellis-sdk-auth";
19
- import { createClient } from "@qlever-llc/trellis-trellis";
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 { getPublicSessionKey, signBytes } from "@qlever-llc/trellis-auth";
3
- import { defineContract, type TrellisAPI } from "@qlever-llc/trellis-contracts";
4
- import { type ClientOpts, createClient, type Trellis, type TrellisAuth } from "@qlever-llc/trellis-trellis";
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
  }