@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 +3 -1
- package/package.json +11 -7
- package/src/components/TrellisProvider.svelte +92 -63
- package/src/context.svelte.ts +77 -22
- package/src/index.ts +8 -5
- package/src/portal_flow.svelte.ts +101 -0
- package/src/state/auth.svelte.ts +206 -40
- package/src/state/nats.svelte.ts +44 -31
- package/src/state/trellis.svelte.ts +25 -19
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
|
-
|
|
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.
|
|
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": "
|
|
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
|
|
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/
|
|
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 {
|
|
12
|
+
import type { AuthState, BindErrorResult } from "../state/auth.svelte.ts";
|
|
12
13
|
import { createNatsState, type NatsState } from "../state/nats.svelte.ts";
|
|
13
|
-
import {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
}
|
|
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
|
-
|
|
23
|
-
|
|
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
|
-
|
|
37
|
+
let {
|
|
39
38
|
children,
|
|
40
39
|
loading,
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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
|
-
|
|
95
|
-
|
|
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
|
-
|
|
114
|
+
onAuthExpired?.();
|
|
115
|
+
handleAuthRequired();
|
|
108
116
|
},
|
|
109
117
|
});
|
|
110
118
|
|
|
111
119
|
const trellisState = await createTrellisState(authState, natsState, {
|
|
112
|
-
|
|
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
|
-
|
|
127
|
-
|
|
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
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
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
|
-
{#
|
|
177
|
+
{#if !isBrowser}
|
|
161
178
|
{#if loading}
|
|
162
179
|
{@render loading()}
|
|
163
180
|
{/if}
|
|
164
|
-
{:
|
|
165
|
-
{
|
|
166
|
-
{
|
|
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}
|
package/src/context.svelte.ts
CHANGED
|
@@ -1,47 +1,102 @@
|
|
|
1
|
-
import {
|
|
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 {
|
|
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<
|
|
9
|
+
trellis: Promise<Trellis<TrellisAPI>>;
|
|
15
10
|
nats: Promise<NatsConnection>;
|
|
16
11
|
};
|
|
17
12
|
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
22
|
-
setContext(NATS_KEY, ctx.nats);
|
|
54
|
+
setTrellisContextValue(ctx);
|
|
23
55
|
}
|
|
24
56
|
|
|
25
57
|
export function setNatsStateContext(natsState: Promise<NatsState>): void {
|
|
26
|
-
|
|
58
|
+
setNatsStateContextValue(natsState);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function setAuthContext(getAuth: () => AuthState): void {
|
|
62
|
+
setAuthContextValue(getAuth);
|
|
27
63
|
}
|
|
28
64
|
|
|
29
|
-
export function
|
|
30
|
-
|
|
65
|
+
export function setAppContext(getApp: () => unknown): void {
|
|
66
|
+
setAppContextValue(getApp);
|
|
31
67
|
}
|
|
32
68
|
|
|
33
|
-
export function
|
|
34
|
-
|
|
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
|
|
93
|
+
return getTrellisContext().nats;
|
|
39
94
|
}
|
|
40
95
|
|
|
41
96
|
export function getNatsState(): Promise<NatsState> {
|
|
42
|
-
return
|
|
97
|
+
return getNatsStateContext();
|
|
43
98
|
}
|
|
44
99
|
|
|
45
100
|
export function getAuth(): AuthState {
|
|
46
|
-
return
|
|
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 {
|
|
3
|
-
export {
|
|
4
|
-
export {
|
|
5
|
-
export type
|
|
6
|
-
export {
|
|
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
|
+
}
|
package/src/state/auth.svelte.ts
CHANGED
|
@@ -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
|
|
13
|
-
import {
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
|
102
|
+
authUrl?: string; // https://auth.example.com
|
|
86
103
|
loginPath?: string;
|
|
87
|
-
contract
|
|
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<
|
|
204
|
+
#bindingInProgress: Promise<BindResult> | null = null;
|
|
110
205
|
|
|
111
206
|
constructor(config: AuthStateConfig) {
|
|
112
|
-
this.#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(
|
|
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
|
|
167
|
-
|
|
168
|
-
|
|
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
|
|
172
|
-
|
|
309
|
+
contract: this.#config.contract?.CONTRACT ?? {},
|
|
310
|
+
context: options.context,
|
|
311
|
+
});
|
|
173
312
|
window.location.href = url;
|
|
174
|
-
throw new Error(
|
|
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
|
|
189
|
-
if (!
|
|
319
|
+
const flowId = new URL(url).searchParams.get("flowId");
|
|
320
|
+
if (!flowId) return null;
|
|
190
321
|
|
|
191
|
-
this.#bindingInProgress = this
|
|
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
|
|
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.
|
|
205
|
-
parsed.
|
|
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.#
|
|
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
|
-
|
|
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
|
}
|
package/src/state/nats.svelte.ts
CHANGED
|
@@ -3,20 +3,20 @@ import {
|
|
|
3
3
|
type NatsConnection,
|
|
4
4
|
wsconnect,
|
|
5
5
|
} from "@nats-io/nats-core";
|
|
6
|
+
import { 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
|
|
13
|
-
import { AsyncResult,
|
|
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
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
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
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
)
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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:
|
|
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<
|
|
38
|
+
export class TrellisState<TApi extends TrellisAPI = TrellisAPI> {
|
|
39
|
+
readonly trellis: Trellis<TApi>;
|
|
37
40
|
|
|
38
|
-
private constructor(trellis: Trellis<
|
|
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
|
|
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
|
|
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:
|
|
60
|
-
)
|
|
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
|
|
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
|
}
|