@qlever-llc/trellis-svelte 0.4.0 → 0.5.1

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 `getTrellisFor(contract)` in app code so the client stays typed from the same contract passed to `TrellisProvider`.
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.5.1",
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,11 @@
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-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",
28
36
  "typebox": "^1.0.15"
29
37
  },
30
38
  "peerDependencies": {
@@ -8,7 +8,7 @@
8
8
  setNatsStateContext,
9
9
  setTrellisContext,
10
10
  } from "../context.svelte.ts";
11
- import { type AuthState, createAuthState } from "../state/auth.svelte.ts";
11
+ import { type AuthState, type AuthStateConfig, type BindErrorResult, createAuthState } from "../state/auth.svelte.ts";
12
12
  import { createNatsState, type NatsState } from "../state/nats.svelte.ts";
13
13
  import {
14
14
  createTrellisState,
@@ -27,6 +27,7 @@
27
27
  onAuthExpired?: () => void;
28
28
  onAuthFailed?: (error: unknown) => void;
29
29
  onAuthRequired?: (redirectTo: string) => void;
30
+ onBindError?: (result: BindErrorResult) => void;
30
31
  onNatsConnecting?: () => void;
31
32
  onNatsConnected?: () => void;
32
33
  onNatsDisconnect?: () => void;
@@ -46,6 +47,7 @@
46
47
  onAuthExpired,
47
48
  onAuthFailed,
48
49
  onAuthRequired,
50
+ onBindError,
49
51
  onNatsConnecting,
50
52
  onNatsConnected,
51
53
  onNatsDisconnect,
@@ -66,12 +68,20 @@
66
68
  }
67
69
  }
68
70
 
71
+ class BindFailedError extends Error {
72
+ constructor() {
73
+ super("Bind failed");
74
+ }
75
+ }
76
+
69
77
  function createProviderAuthState(): AuthState {
70
- return createAuthState({
78
+ const config: AuthStateConfig = {
71
79
  authUrl,
72
80
  loginPath,
73
81
  contract,
74
- });
82
+ };
83
+
84
+ return createAuthState(config);
75
85
  }
76
86
 
77
87
  const authState = createProviderAuthState();
@@ -87,16 +97,22 @@
87
97
  async function initialize(): Promise<InitContext> {
88
98
  const result = await AsyncResult.try(async () => {
89
99
  await authState.init();
90
- await authState.handleCallback();
100
+ const bindResult = await authState.handleCallback();
91
101
  authState.cleanupCallbackUrl();
92
102
 
103
+ if (bindResult !== null && bindResult.status !== "bound") {
104
+ onBindError?.(bindResult);
105
+ throw new BindFailedError();
106
+ }
107
+
93
108
  if (!authState.isAuthenticated) {
94
109
  onAuthRequired?.(getRedirectTo());
95
110
  throw new AuthRequiredError();
96
111
  }
97
112
 
113
+ const effectiveNatsServers = authState.natsServers ?? natsServers;
98
114
  const natsState = await createNatsState(authState, {
99
- servers: natsServers,
115
+ servers: effectiveNatsServers,
100
116
  onConnecting: onNatsConnecting,
101
117
  onConnected: onNatsConnected,
102
118
  onDisconnect: onNatsDisconnect,
@@ -121,11 +137,12 @@
121
137
  });
122
138
 
123
139
  if (result.isErr()) {
124
- authState.clearAuth();
125
140
  const error = result.error;
126
- if (!(error instanceof AuthRequiredError)) {
127
- onAuthFailed?.(error);
141
+ if (error instanceof AuthRequiredError || error instanceof BindFailedError) {
142
+ throw error;
128
143
  }
144
+ authState.clearAuth();
145
+ onAuthFailed?.(error);
129
146
  throw error;
130
147
  }
131
148
 
@@ -1,7 +1,7 @@
1
- import { getContext, setContext } from "svelte";
2
- import type { TrellisAPI } from "@qlever-llc/trellis-contracts";
3
- import type { Trellis } from "@qlever-llc/trellis-trellis";
4
1
  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
5
  import type { AuthState } from "./state/auth.svelte.ts";
6
6
  import type { NatsState } from "./state/nats.svelte.ts";
7
7
 
@@ -15,6 +15,35 @@ type TrellisContext = {
15
15
  nats: Promise<NatsConnection>;
16
16
  };
17
17
 
18
+ type TrellisContractLike<TA extends TrellisAPI = TrellisAPI> = {
19
+ API: {
20
+ trellis: TA;
21
+ };
22
+ };
23
+
24
+ type RequestOpts = {
25
+ timeout?: number;
26
+ };
27
+
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>(
37
+ method: M,
38
+ input: RpcMapFor<TA>[M]["input"],
39
+ opts?: RequestOpts,
40
+ ): Promise<RpcMapFor<TA>[M]["output"]>;
41
+ };
42
+
43
+ type TypedTrellis<TA extends TrellisAPI> =
44
+ & Omit<Trellis<TA>, "requestOrThrow">
45
+ & TypedRequestSurface<TA>;
46
+
18
47
  export function setTrellisContext<TA extends TrellisAPI>(
19
48
  ctx: { trellis: Promise<Trellis<TA>>; nats: Promise<NatsConnection> },
20
49
  ): void {
@@ -34,6 +63,14 @@ export function getTrellis<TA extends TrellisAPI = TrellisAPI>(): Promise<Trelli
34
63
  return getContext<Promise<unknown>>(TRELLIS_KEY) as Promise<Trellis<TA>>;
35
64
  }
36
65
 
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
+ >;
72
+ }
73
+
37
74
  export function getNats(): Promise<NatsConnection> {
38
75
  return getContext<Promise<NatsConnection>>(NATS_KEY);
39
76
  }
package/src/index.ts CHANGED
@@ -1,6 +1,6 @@
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 { getAuth, getNats, getNatsState, getTrellis, getTrellisFor, setTrellisContext } from "./context.svelte.ts";
3
+ export { AuthState, type BindErrorResult, type BindResult, createAuthState } from "./state/auth.svelte.ts";
4
+ export type { NatsStateConfig, Status as NatsStatus } from "./state/nats.svelte.ts";
5
+ export { createNatsState, NatsState } from "./state/nats.svelte.ts";
6
+ export { createTrellisState, TrellisState } from "./state/trellis.svelte.ts";
@@ -4,6 +4,7 @@ import {
4
4
  bindSession,
5
5
  buildLoginUrl,
6
6
  clearSessionKey,
7
+ extractAuthErrorFromFragment,
7
8
  extractAuthTokenFromFragment,
8
9
  getOrCreateSessionKey,
9
10
  getPublicSessionKey,
@@ -15,6 +16,22 @@ import { SvelteDate } from "svelte/reactivity";
15
16
  import { Type } from "typebox";
16
17
  import { Value } from "typebox/value";
17
18
 
19
+ export type BindErrorResult =
20
+ | { status: "insufficient_capabilities"; missingCapabilities: string[] }
21
+ | { status: "approval_required" }
22
+ | { status: "approval_denied" }
23
+ | { status: "error"; message: string };
24
+
25
+ export type BindResult = { status: "bound" } | BindErrorResult;
26
+
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
+
18
35
  const STORAGE_KEY = "trellis_auth";
19
36
 
20
37
  type AuthStateData = {
@@ -23,6 +40,7 @@ type AuthStateData = {
23
40
  inboxPrefix: string | null;
24
41
  expiresMs: number | null;
25
42
  sentinel: SentinelCreds | null;
43
+ natsServers: string[] | null;
26
44
  };
27
45
 
28
46
  type PersistedAuth = {
@@ -30,6 +48,7 @@ type PersistedAuth = {
30
48
  inboxPrefix: string;
31
49
  expires: string;
32
50
  sentinel: SentinelCreds;
51
+ natsServers: string[];
33
52
  };
34
53
 
35
54
  const PersistedAuthSchema = Type.Object({
@@ -40,6 +59,7 @@ const PersistedAuthSchema = Type.Object({
40
59
  jwt: Type.String(),
41
60
  seed: Type.String(),
42
61
  }, { additionalProperties: false }),
62
+ natsServers: Type.Array(Type.String()),
43
63
  }, { additionalProperties: false });
44
64
 
45
65
  function loadPersistedAuth(): PersistedAuth | null {
@@ -63,16 +83,18 @@ function persistAuth(state: {
63
83
  expires: Date;
64
84
  inboxPrefix: string;
65
85
  sentinel: SentinelCreds;
86
+ natsServers: string[];
66
87
  }): void {
67
88
  if (typeof localStorage === "undefined") return;
68
89
  localStorage.setItem(
69
90
  STORAGE_KEY,
70
91
  JSON.stringify({
71
- bindingToken: state.bindingToken,
72
- inboxPrefix: state.inboxPrefix,
73
- expires: state.expires.toISOString(),
74
- sentinel: state.sentinel,
75
- }),
92
+ bindingToken: state.bindingToken,
93
+ inboxPrefix: state.inboxPrefix,
94
+ expires: state.expires.toISOString(),
95
+ sentinel: state.sentinel,
96
+ natsServers: state.natsServers,
97
+ }),
76
98
  );
77
99
  }
78
100
 
@@ -84,7 +106,7 @@ function clearPersistedAuth(): void {
84
106
  export type AuthStateConfig = {
85
107
  authUrl: string; // https://auth.example.com
86
108
  loginPath?: string;
87
- contract: { CONTRACT: Record<string, unknown> };
109
+ contract?: { CONTRACT: Record<string, unknown> };
88
110
  };
89
111
 
90
112
  /**
@@ -103,10 +125,11 @@ export class AuthState {
103
125
  inboxPrefix: null,
104
126
  expiresMs: null,
105
127
  sentinel: null,
128
+ natsServers: null,
106
129
  });
107
130
 
108
131
  #config: AuthStateConfig;
109
- #bindingInProgress: Promise<BindResponse> | null = null;
132
+ #bindingInProgress: Promise<BindResult> | null = null;
110
133
 
111
134
  constructor(config: AuthStateConfig) {
112
135
  this.#config = config;
@@ -130,6 +153,9 @@ export class AuthState {
130
153
  get sentinel(): SentinelCreds | null {
131
154
  return this.#state.sentinel;
132
155
  }
156
+ get natsServers(): string[] | null {
157
+ return this.#state.natsServers;
158
+ }
133
159
  get isAuthenticated(): boolean {
134
160
  if (!this.#state.bindingToken) return false;
135
161
  if (this.#state.expiresMs === null) return false;
@@ -152,6 +178,7 @@ export class AuthState {
152
178
  this.#state.inboxPrefix = persisted.inboxPrefix;
153
179
  this.#state.expiresMs = Date.parse(persisted.expires);
154
180
  this.#state.sentinel = persisted.sentinel;
181
+ this.#state.natsServers = persisted.natsServers;
155
182
  }
156
183
 
157
184
  return handle;
@@ -161,34 +188,30 @@ export class AuthState {
161
188
  * Initiate OAuth sign-in flow by redirecting to the auth provider.
162
189
  * This method does not return - it redirects the browser.
163
190
  */
164
- async signIn(provider: string, redirectTo: string): Promise<never> {
191
+ async signIn(provider: string | undefined, redirectTo: string): Promise<never> {
165
192
  const handle = await this.init();
166
- const url = await buildLoginUrl(
193
+ const url = await buildLoginHref(
167
194
  { authUrl: this.#config.authUrl },
168
195
  provider,
169
196
  redirectTo,
170
197
  handle,
171
- this.#config.contract.CONTRACT,
198
+ this.#config.contract?.CONTRACT ?? {},
172
199
  );
173
200
  window.location.href = url;
174
- throw new Error(`Redirecting to ${provider} for authentication`);
201
+ throw new Error(provider ? `Redirecting to ${provider} for authentication` : "Redirecting to auth for provider selection");
175
202
  }
176
203
 
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
204
+ async handleCallback(url: string = window.location.href): Promise<BindResult | null> {
186
205
  if (this.#bindingInProgress) return this.#bindingInProgress;
187
206
 
207
+ const authError = extractAuthErrorFromFragment(url);
208
+ if (authError === "approval_denied") return { status: "approval_denied" };
209
+ if (authError) return { status: "error", message: authError };
210
+
188
211
  const authToken = extractAuthTokenFromFragment(url);
189
212
  if (!authToken) return null;
190
213
 
191
- this.#bindingInProgress = this.bind(authToken);
214
+ this.#bindingInProgress = this.#resolveCallback(authToken);
192
215
  try {
193
216
  return await this.#bindingInProgress;
194
217
  } finally {
@@ -196,6 +219,20 @@ export class AuthState {
196
219
  }
197
220
  }
198
221
 
222
+ async #resolveCallback(authToken: string): Promise<BindResult> {
223
+ try {
224
+ const response = await this.bind(authToken);
225
+ return response.status === "bound"
226
+ ? { status: "bound" }
227
+ : { status: "insufficient_capabilities", missingCapabilities: response.missingCapabilities };
228
+ } catch (error) {
229
+ const message = error instanceof Error ? error.message : String(error);
230
+ if (message.includes("approval_denied")) return { status: "approval_denied" };
231
+ if (message.includes("approval_required")) return { status: "approval_required" };
232
+ return { status: "error", message };
233
+ }
234
+ }
235
+
199
236
  /**
200
237
  * Clean up the callback URL by removing the authToken fragment.
201
238
  */
@@ -233,6 +270,7 @@ export class AuthState {
233
270
  setBindingToken(
234
271
  response: Pick<BindSuccessResponse, "bindingToken" | "inboxPrefix" | "expires"> & {
235
272
  sentinel?: SentinelCreds;
273
+ natsServers?: string[];
236
274
  },
237
275
  ): void {
238
276
  this.#state.bindingToken = response.bindingToken;
@@ -242,14 +280,18 @@ export class AuthState {
242
280
  if (response.sentinel) {
243
281
  this.#state.sentinel = response.sentinel;
244
282
  }
283
+ if (response.natsServers) {
284
+ this.#state.natsServers = response.natsServers;
285
+ }
245
286
 
246
287
  // Only persist if we have sentinel credentials
247
- if (this.#state.sentinel) {
288
+ if (this.#state.sentinel && this.#state.natsServers) {
248
289
  persistAuth({
249
290
  bindingToken: response.bindingToken,
250
291
  inboxPrefix: response.inboxPrefix,
251
292
  expires: new SvelteDate(String(response.expires)),
252
293
  sentinel: this.#state.sentinel,
294
+ natsServers: this.#state.natsServers,
253
295
  });
254
296
  }
255
297
  }
@@ -264,6 +306,7 @@ export class AuthState {
264
306
  this.#state.inboxPrefix = null;
265
307
  this.#state.expiresMs = null;
266
308
  this.#state.sentinel = null;
309
+ this.#state.natsServers = null;
267
310
  }
268
311
 
269
312
  /**
@@ -3,6 +3,7 @@ import {
3
3
  type NatsConnection,
4
4
  wsconnect,
5
5
  } from "@nats-io/nats-core";
6
+ import { createClient } from "@qlever-llc/trellis";
6
7
  import {
7
8
  getPublicSessionKey,
8
9
  natsConnectSigForBindingToken,
@@ -16,7 +17,6 @@ import {
16
17
  type AuthRenewBindingTokenInput,
17
18
  type AuthRenewBindingTokenOutput,
18
19
  } from "@qlever-llc/trellis-sdk-auth";
19
- import { createClient } from "@qlever-llc/trellis-trellis";
20
20
  import type { AuthState } from "./auth.svelte.ts";
21
21
 
22
22
  const AUTH_RENEW_API = {
@@ -1,7 +1,7 @@
1
1
  import type { NatsConnection } from "@nats-io/nats-core";
2
+ import { type ClientOpts, createClient, type Trellis, type TrellisAuth } from "@qlever-llc/trellis";
2
3
  import { getPublicSessionKey, signBytes } from "@qlever-llc/trellis-auth";
3
4
  import { defineContract, type TrellisAPI } from "@qlever-llc/trellis-contracts";
4
- import { type ClientOpts, createClient, type Trellis, type TrellisAuth } from "@qlever-llc/trellis-trellis";
5
5
  import type { AuthState } from "./auth.svelte.ts";
6
6
  import type { NatsState } from "./nats.svelte.ts";
7
7