@oatscenter/trellis-svelte 0.100.0-rc.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.
Files changed (38) hide show
  1. package/README.md +16 -0
  2. package/dist/authorization_context.d.ts +18 -0
  3. package/dist/authorization_context.js +150 -0
  4. package/dist/components/TrellisContextProvider.svelte +24 -0
  5. package/dist/components/TrellisContextProvider.svelte.d.ts +11 -0
  6. package/dist/components/TrellisProvider.svelte +192 -0
  7. package/dist/components/TrellisProvider.svelte.d.ts +6 -0
  8. package/dist/components/TrellisProvider.types.d.ts +15 -0
  9. package/dist/components/TrellisProvider.types.js +1 -0
  10. package/dist/context.d.ts +72 -0
  11. package/dist/context.js +116 -0
  12. package/dist/device_activation.d.ts +6 -0
  13. package/dist/device_activation.js +19 -0
  14. package/dist/device_activation_controller.d.ts +91 -0
  15. package/dist/device_activation_controller.js +318 -0
  16. package/dist/index.d.ts +7 -0
  17. package/dist/index.js +5 -0
  18. package/dist/internal/activation_view.d.ts +44 -0
  19. package/dist/internal/activation_view.js +95 -0
  20. package/dist/internal/callback_state.d.ts +9 -0
  21. package/dist/internal/callback_state.js +24 -0
  22. package/dist/internal/portal_url.d.ts +13 -0
  23. package/dist/internal/portal_url.js +57 -0
  24. package/dist/portal_flow.d.ts +24 -0
  25. package/dist/portal_flow.js +173 -0
  26. package/package.json +42 -0
  27. package/src/authorization_context.svelte.ts +153 -0
  28. package/src/components/TrellisContextProvider.svelte +24 -0
  29. package/src/components/TrellisProvider.svelte +192 -0
  30. package/src/components/TrellisProvider.types.ts +28 -0
  31. package/src/context.svelte.ts +205 -0
  32. package/src/device_activation.svelte.ts +28 -0
  33. package/src/device_activation_controller.ts +528 -0
  34. package/src/index.ts +31 -0
  35. package/src/internal/activation_view.ts +179 -0
  36. package/src/internal/callback_state.ts +48 -0
  37. package/src/internal/portal_url.ts +94 -0
  38. package/src/portal_flow.svelte.ts +143 -0
@@ -0,0 +1,24 @@
1
+ import { type BrowserPortalFlowState as PortalFlowState, type PortalBinding } from "@oatscenter/trellis/auth/browser";
2
+ type AuthConfig = {
3
+ authUrl: string;
4
+ };
5
+ export type CreatePortalFlowConfig = AuthConfig & {
6
+ getUrl?: () => URL;
7
+ sessionStorage?: Storage;
8
+ };
9
+ export declare class PortalFlowController {
10
+ #private;
11
+ flowId: string | null;
12
+ state: PortalFlowState | null;
13
+ loading: boolean;
14
+ error: string | null;
15
+ errorCode: string | null;
16
+ constructor(config: CreatePortalFlowConfig);
17
+ load(): Promise<PortalFlowState | null>;
18
+ providerUrl(providerId: string): string;
19
+ get binding(): PortalBinding;
20
+ approve(): Promise<PortalFlowState | null>;
21
+ deny(): Promise<PortalFlowState | null>;
22
+ }
23
+ export declare function createPortalFlow(config: CreatePortalFlowConfig): PortalFlowController;
24
+ export {};
@@ -0,0 +1,173 @@
1
+ /* portal_flow.js generated by Svelte v5.55.5 */
2
+ import * as $ from 'svelte/internal/client';
3
+
4
+ import {
5
+ fetchPortalFlowState,
6
+ getOrCreatePortalBinding,
7
+ portalFlowIdFromUrl,
8
+ portalProviderLoginUrl,
9
+ submitPortalApproval,
10
+ TrellisHttpError
11
+ } from "@oatscenter/trellis/auth/browser";
12
+
13
+ function errorMessage(error) {
14
+ return error instanceof Error ? error.message : String(error);
15
+ }
16
+
17
+ function defaultGetUrl() {
18
+ return new URL(globalThis.location.href);
19
+ }
20
+
21
+ export class PortalFlowController {
22
+ #flowId = $.state(null);
23
+
24
+ get flowId() {
25
+ return $.get(this.#flowId);
26
+ }
27
+
28
+ set flowId(value) {
29
+ $.set(this.#flowId, value, true);
30
+ }
31
+
32
+ #state = $.state(null);
33
+
34
+ get state() {
35
+ return $.get(this.#state);
36
+ }
37
+
38
+ set state(value) {
39
+ $.set(this.#state, value, true);
40
+ }
41
+
42
+ #loading = $.state(false);
43
+
44
+ get loading() {
45
+ return $.get(this.#loading);
46
+ }
47
+
48
+ set loading(value) {
49
+ $.set(this.#loading, value, true);
50
+ }
51
+
52
+ #error = $.state(null);
53
+
54
+ get error() {
55
+ return $.get(this.#error);
56
+ }
57
+
58
+ set error(value) {
59
+ $.set(this.#error, value, true);
60
+ }
61
+
62
+ #errorCode = $.state(null);
63
+
64
+ get errorCode() {
65
+ return $.get(this.#errorCode);
66
+ }
67
+
68
+ set errorCode(value) {
69
+ $.set(this.#errorCode, value, true);
70
+ }
71
+
72
+ #config;
73
+ #getUrl;
74
+ #sessionStorage;
75
+ #binding = null;
76
+
77
+ constructor(config) {
78
+ this.#config = { authUrl: config.authUrl };
79
+ this.#getUrl = config.getUrl ?? defaultGetUrl;
80
+ this.#sessionStorage = config.sessionStorage ?? globalThis.sessionStorage;
81
+ }
82
+
83
+ async load() {
84
+ this.loading = true;
85
+ this.error = null;
86
+ this.errorCode = null;
87
+ this.state = null;
88
+
89
+ try {
90
+ const flowId = portalFlowIdFromUrl(this.#getUrl());
91
+
92
+ this.flowId = flowId;
93
+
94
+ if (!flowId) {
95
+ this.error = "Missing flow id.";
96
+ this.errorCode = "missing_flow_id";
97
+
98
+ return null;
99
+ }
100
+
101
+ this.#binding = await getOrCreatePortalBinding(flowId, this.#sessionStorage);
102
+
103
+ const state = await fetchPortalFlowState(this.#config, flowId, this.#binding);
104
+
105
+ this.state = state;
106
+
107
+ return state;
108
+ } catch(error) {
109
+ this.error = errorMessage(error);
110
+ this.errorCode = error instanceof TrellisHttpError ? error.code : null;
111
+ this.state = null;
112
+
113
+ return null;
114
+ } finally {
115
+ this.loading = false;
116
+ }
117
+ }
118
+
119
+ providerUrl(providerId) {
120
+ if (!this.flowId) {
121
+ throw new Error("Missing flow id.");
122
+ }
123
+
124
+ if (!this.#binding) throw new Error("Portal flow has not loaded.");
125
+
126
+ return portalProviderLoginUrl(this.#config, providerId, this.flowId, this.#binding);
127
+ }
128
+
129
+ get binding() {
130
+ if (!this.#binding) throw new Error("Portal flow has not loaded.");
131
+
132
+ return this.#binding;
133
+ }
134
+
135
+ async approve() {
136
+ return this.#submit("approved");
137
+ }
138
+
139
+ async deny() {
140
+ return this.#submit("denied");
141
+ }
142
+
143
+ async #submit(decision) {
144
+ if (!this.flowId) {
145
+ this.error = "Missing flow id.";
146
+
147
+ return null;
148
+ }
149
+
150
+ this.loading = true;
151
+ this.error = null;
152
+ this.errorCode = null;
153
+
154
+ try {
155
+ const state = await submitPortalApproval(this.#config, this.flowId, this.binding, decision);
156
+
157
+ this.state = state;
158
+
159
+ return state;
160
+ } catch(error) {
161
+ this.error = errorMessage(error);
162
+ this.errorCode = error instanceof TrellisHttpError ? error.code : null;
163
+
164
+ return null;
165
+ } finally {
166
+ this.loading = false;
167
+ }
168
+ }
169
+ }
170
+
171
+ export function createPortalFlow(config) {
172
+ return new PortalFlowController(config);
173
+ }
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@oatscenter/trellis-svelte",
3
+ "version": "0.100.0-rc.1",
4
+ "type": "module",
5
+ "description": "Svelte components and state helpers for Trellis browser applications.",
6
+ "license": "Apache-2.0",
7
+ "homepage": "https://github.com/OATS-Center/trellis#readme",
8
+ "bugs": {
9
+ "url": "https://github.com/OATS-Center/trellis/issues"
10
+ },
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/OATS-Center/trellis.git"
14
+ },
15
+ "publishConfig": {
16
+ "access": "public"
17
+ },
18
+ "files": [
19
+ "dist",
20
+ "src",
21
+ "README.md"
22
+ ],
23
+ "types": "./dist/index.d.ts",
24
+ "exports": {
25
+ ".": {
26
+ "types": "./dist/index.d.ts",
27
+ "svelte": "./dist/index.js",
28
+ "default": "./dist/index.js"
29
+ }
30
+ },
31
+ "dependencies": {
32
+ "@nats-io/nats-core": "^3.3.1",
33
+ "@oatscenter/result": "0.100.0-rc.1",
34
+ "@oatscenter/trellis": "0.100.0-rc.1",
35
+ "typebox": "^1.0.15",
36
+ "ulid": "^3.0.2"
37
+ },
38
+ "peerDependencies": {
39
+ "svelte": "^5.0.0"
40
+ },
41
+ "svelte": "./dist/index.js"
42
+ }
@@ -0,0 +1,153 @@
1
+ import {
2
+ type AuthorizationContextBundle,
3
+ AuthorizationContextCache,
4
+ AuthorizationContextRefreshError,
5
+ type AuthorizationRoutingMaterial,
6
+ refreshAuthorizationContext,
7
+ startAuthorizationContextRefresh,
8
+ type TrellisAuth,
9
+ type VerifiedAuthorizationContext,
10
+ } from "@oatscenter/trellis/auth";
11
+
12
+ export type AuthorizationContextStatus =
13
+ | "empty"
14
+ | "loading"
15
+ | "ready"
16
+ | "refreshing"
17
+ | "degraded"
18
+ | "expired"
19
+ | "authRequired"
20
+ | "failed";
21
+
22
+ /** Reactive projection of the shared TypeScript authorization context cache. */
23
+ export class AuthorizationContextController {
24
+ #cache: AuthorizationContextCache;
25
+ #context = $state.raw<VerifiedAuthorizationContext>();
26
+ #status = $state<AuthorizationContextStatus>("empty");
27
+ #error = $state.raw<unknown>();
28
+ #stop?: () => void;
29
+ #epoch = 0;
30
+
31
+ constructor(
32
+ trellisUrl: string,
33
+ fetch?: typeof globalThis.fetch,
34
+ ) {
35
+ this.#cache = new AuthorizationContextCache(trellisUrl, fetch);
36
+ }
37
+
38
+ get context(): VerifiedAuthorizationContext | undefined {
39
+ return this.#context;
40
+ }
41
+
42
+ get status(): AuthorizationContextStatus {
43
+ return this.#status;
44
+ }
45
+
46
+ get error(): unknown {
47
+ return this.#error;
48
+ }
49
+
50
+ get refreshAt(): number | undefined {
51
+ return this.#context?.refreshAt;
52
+ }
53
+
54
+ get expiresAt(): number | undefined {
55
+ return this.#context?.context.expiresAt;
56
+ }
57
+
58
+ async install(
59
+ bundle: AuthorizationContextBundle,
60
+ routing: AuthorizationRoutingMaterial,
61
+ ): Promise<void> {
62
+ this.#status = "loading";
63
+ this.#error = undefined;
64
+ try {
65
+ this.#context = await this.#cache.install(bundle, routing);
66
+ this.#status = "ready";
67
+ } catch (error) {
68
+ this.#status = "failed";
69
+ this.#error = error;
70
+ throw error;
71
+ }
72
+ }
73
+
74
+ async refresh(sessionId: string, auth: TrellisAuth): Promise<void> {
75
+ const epoch = this.#epoch;
76
+ const clearGuard = this.#cache.clearGuard();
77
+ this.#status = "refreshing";
78
+ try {
79
+ this.#context = await refreshAuthorizationContext({
80
+ trellisUrl: this.#cache.trellisUrl,
81
+ sessionId,
82
+ auth,
83
+ cache: this.#cache,
84
+ shouldInstall: () => epoch === this.#epoch,
85
+ });
86
+ this.#status = "ready";
87
+ this.#error = undefined;
88
+ } catch (error) {
89
+ if (epoch !== this.#epoch) return;
90
+ if (error instanceof AuthorizationContextRefreshError && error.terminal) {
91
+ if (!(await this.#cache.clearIfCurrent(clearGuard))) {
92
+ this.#context = this.#cache.current();
93
+ this.#status = "ready";
94
+ this.#error = undefined;
95
+ return;
96
+ }
97
+ this.#context = undefined;
98
+ }
99
+ this.#status = error instanceof AuthorizationContextRefreshError &&
100
+ error.terminal
101
+ ? "authRequired"
102
+ : this.#context?.context.expiresAt !== undefined &&
103
+ this.#context.context.expiresAt <= this.#cache.correctedNowSeconds()
104
+ ? "expired"
105
+ : "degraded";
106
+ this.#error = error;
107
+ throw error;
108
+ }
109
+ }
110
+
111
+ async refreshNow(sessionId: string, auth: TrellisAuth): Promise<void> {
112
+ await this.refresh(sessionId, auth);
113
+ }
114
+
115
+ start(sessionId: string, auth: TrellisAuth): void {
116
+ this.stop();
117
+ if (!this.#context) this.#status = "refreshing";
118
+ this.#stop = startAuthorizationContextRefresh({
119
+ trellisUrl: this.#cache.trellisUrl,
120
+ sessionId,
121
+ auth,
122
+ cache: this.#cache,
123
+ onRefresh: (context) => {
124
+ this.#context = context;
125
+ this.#status = "ready";
126
+ this.#error = undefined;
127
+ },
128
+ onTerminalFailure: (error) => {
129
+ this.#context = undefined;
130
+ this.#status = "authRequired";
131
+ this.#error = error;
132
+ },
133
+ onTransientFailure: (error) => {
134
+ this.#status = "degraded";
135
+ this.#error = error;
136
+ },
137
+ });
138
+ }
139
+
140
+ stop(): void {
141
+ this.#stop?.();
142
+ this.#stop = undefined;
143
+ }
144
+
145
+ async clear(): Promise<void> {
146
+ this.#epoch += 1;
147
+ this.stop();
148
+ await this.#cache.clear();
149
+ this.#context = undefined;
150
+ this.#status = "empty";
151
+ this.#error = undefined;
152
+ }
153
+ }
@@ -0,0 +1,24 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from "svelte";
3
+ import {
4
+ provideConnectedTrellisContext,
5
+ type TrellisAppOwner,
6
+ type TrellisContextClient,
7
+ } from "../context.svelte.ts";
8
+
9
+ type Props = {
10
+ trellisApp: TrellisAppOwner;
11
+ trellis: TrellisContextClient;
12
+ children: Snippet;
13
+ };
14
+
15
+ const { trellisApp, trellis, children }: Props = $props();
16
+
17
+ function installContext(): void {
18
+ provideConnectedTrellisContext(trellisApp, trellis);
19
+ }
20
+
21
+ installContext();
22
+ </script>
23
+
24
+ {@render children()}
@@ -0,0 +1,192 @@
1
+ <script lang="ts" generics="TContract extends TrellisParticipantLike">
2
+ import {
3
+ classifyBrowserAuthError,
4
+ ClientAuthHandledError,
5
+ TrellisClient,
6
+ type ClientAuthOptions,
7
+ } from "@oatscenter/trellis";
8
+ import { onMount } from "svelte";
9
+ import type {
10
+ TrellisClientFor,
11
+ TrellisParticipantLike,
12
+ } from "../context.svelte.ts";
13
+ import { resolveTrellisAppUrl } from "../context.svelte.ts";
14
+ import TrellisContextProvider from "./TrellisContextProvider.svelte";
15
+ import type { TrellisProviderProps } from "./TrellisProvider.types.ts";
16
+
17
+ const {
18
+ trellisApp,
19
+ auth,
20
+ client,
21
+ children,
22
+ loading,
23
+ recoveringAuth,
24
+ error: errorSnippet,
25
+ onAuthRequired,
26
+ onRecoverableAuthError,
27
+ }: TrellisProviderProps<TContract> = $props();
28
+
29
+ type ProviderState = "connecting" | "connected" | "auth_handled" | "failed";
30
+
31
+ let trellis = $state<TrellisClientFor<TContract> | null>(null);
32
+ let connectError = $state<unknown>(null);
33
+ let providerState = $state<ProviderState>("connecting");
34
+
35
+ type SerializableTrellisError = {
36
+ message?: unknown;
37
+ code?: unknown;
38
+ hint?: unknown;
39
+ context?: unknown;
40
+ };
41
+
42
+ function maybeSerializableError(
43
+ value: unknown,
44
+ ): SerializableTrellisError | undefined {
45
+ if (!value || typeof value !== "object" || !("toSerializable" in value)) {
46
+ return undefined;
47
+ }
48
+ const serialize = value.toSerializable;
49
+ if (typeof serialize !== "function") return undefined;
50
+ const serialized = serialize.call(value);
51
+ return serialized && typeof serialized === "object"
52
+ ? (serialized as SerializableTrellisError)
53
+ : undefined;
54
+ }
55
+
56
+ function contextRecord(value: unknown): Record<string, unknown> | undefined {
57
+ return value && typeof value === "object" && !Array.isArray(value)
58
+ ? (value as Record<string, unknown>)
59
+ : undefined;
60
+ }
61
+
62
+ function logConnectionError(error: unknown): void {
63
+ const serialized = maybeSerializableError(error);
64
+ const context = contextRecord(serialized?.context);
65
+ const causeMessage =
66
+ typeof context?.causeMessage === "string"
67
+ ? context.causeMessage
68
+ : undefined;
69
+ const message =
70
+ typeof serialized?.message === "string"
71
+ ? serialized.message
72
+ : error instanceof Error
73
+ ? error.message
74
+ : String(error);
75
+
76
+ console.error("TrellisProvider failed to connect", {
77
+ message,
78
+ code: serialized?.code,
79
+ hint: serialized?.hint,
80
+ causeMessage,
81
+ context: serialized?.context,
82
+ error,
83
+ });
84
+ }
85
+
86
+ onMount(() => {
87
+ let active = true;
88
+
89
+ function withBrowserAuthDefaults(
90
+ authOptions: ClientAuthOptions | undefined,
91
+ ): ClientAuthOptions {
92
+ if (authOptions?.mode === "session_key") {
93
+ return authOptions;
94
+ }
95
+
96
+ return {
97
+ ...authOptions,
98
+ currentUrl:
99
+ authOptions?.currentUrl ?? (() => new URL(window.location.href)),
100
+ };
101
+ }
102
+
103
+ const connectAuth = withBrowserAuthDefaults(auth);
104
+ const trellisUrl = resolveTrellisAppUrl(trellisApp.trellisUrl);
105
+ if (!trellisUrl) {
106
+ connectError = new TypeError(
107
+ "Expected trellisApp to resolve a Trellis URL",
108
+ );
109
+ providerState = "failed";
110
+ return;
111
+ }
112
+
113
+ void (async () => {
114
+ try {
115
+ const connected = await TrellisClient.connect({
116
+ ...client,
117
+ trellisUrl,
118
+ participant: trellisApp.participant,
119
+ auth: connectAuth,
120
+ onAuthRequired: onAuthRequired
121
+ ? async (ctx) => {
122
+ await onAuthRequired(ctx.loginUrl, ctx);
123
+ return { status: "handled" };
124
+ }
125
+ : undefined,
126
+ }).orThrow();
127
+
128
+ if (active) {
129
+ trellis = connected;
130
+ providerState = "connected";
131
+ } else {
132
+ await connected.connection.close();
133
+ }
134
+ } catch (error) {
135
+ if (!active) return;
136
+ if (error instanceof ClientAuthHandledError) {
137
+ providerState = "auth_handled";
138
+ return;
139
+ }
140
+ const authRecovery = classifyBrowserAuthError(error);
141
+ if (authRecovery.recoverable) {
142
+ if (onRecoverableAuthError) {
143
+ providerState = "auth_handled";
144
+ try {
145
+ await onRecoverableAuthError(error);
146
+ return;
147
+ } catch (recoveryError) {
148
+ if (!active) return;
149
+ console.error("TrellisProvider auth recovery callback failed", {
150
+ recoveryError,
151
+ error,
152
+ });
153
+ logConnectionError(recoveryError);
154
+ connectError = recoveryError;
155
+ providerState = "failed";
156
+ return;
157
+ }
158
+ }
159
+ if (recoveringAuth) {
160
+ providerState = "auth_handled";
161
+ return;
162
+ }
163
+ }
164
+ logConnectionError(error);
165
+ connectError = error;
166
+ providerState = "failed";
167
+ }
168
+ })();
169
+
170
+ return () => {
171
+ active = false;
172
+ const connected = trellis;
173
+ trellis = null;
174
+ providerState = "connecting";
175
+ if (connected) {
176
+ void connected.connection.close();
177
+ }
178
+ };
179
+ });
180
+ </script>
181
+
182
+ {#if trellis}
183
+ <TrellisContextProvider {trellisApp} {trellis} {children} />
184
+ {:else if providerState === "failed" && connectError}
185
+ {#if errorSnippet}
186
+ {@render errorSnippet(connectError)}
187
+ {/if}
188
+ {:else if providerState === "auth_handled" && recoveringAuth}
189
+ {@render recoveringAuth()}
190
+ {:else if loading}
191
+ {@render loading()}
192
+ {/if}
@@ -0,0 +1,28 @@
1
+ import type {
2
+ ClientAuthOptions,
3
+ ClientAuthRequiredContext,
4
+ ClientOpts,
5
+ } from "@oatscenter/trellis";
6
+ import type { Snippet } from "svelte";
7
+ import type {
8
+ TrellisAppOwner,
9
+ TrellisParticipantLike,
10
+ } from "../context.svelte.ts";
11
+
12
+ /** Props accepted by the Svelte Trellis provider component. */
13
+ export type TrellisProviderProps<
14
+ TContract extends TrellisParticipantLike = TrellisParticipantLike,
15
+ > = {
16
+ trellisApp: TrellisAppOwner<TContract>;
17
+ auth?: ClientAuthOptions;
18
+ client?: ClientOpts;
19
+ children: Snippet;
20
+ loading?: Snippet;
21
+ recoveringAuth?: Snippet;
22
+ error?: Snippet<[unknown]>;
23
+ onAuthRequired?: (
24
+ loginUrl: string,
25
+ context: ClientAuthRequiredContext,
26
+ ) => void | Promise<void>;
27
+ onRecoverableAuthError?: (error: unknown) => void | Promise<void>;
28
+ };