@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,179 @@
1
+ import type {
2
+ DeviceUserAuthoritiesResolveOutput,
3
+ DeviceUserAuthoritiesResolveProgress,
4
+ } from "@oatscenter/trellis/auth";
5
+ import type { TerminalOperation } from "@oatscenter/trellis";
6
+
7
+ export type DeviceActivationView =
8
+ | { mode: "sign_in_required"; flowId: string }
9
+ | { mode: "ready"; flowId: string }
10
+ | {
11
+ mode: "pending_review";
12
+ flowId: string;
13
+ state: DeviceUserAuthoritiesResolveProgress["state"];
14
+ }
15
+ | {
16
+ mode: "activated";
17
+ flowId: string;
18
+ instanceId: string;
19
+ deploymentId: string;
20
+ activatedAt: string;
21
+ }
22
+ | { mode: "rejected"; flowId: string; reason?: string }
23
+ | { mode: "expired"; flowId: string; reason: string }
24
+ | { mode: "invalid_flow"; reason: string; flowId?: string };
25
+
26
+ function isoString(value: string | number | bigint | Date): string {
27
+ return value instanceof Date
28
+ ? value.toISOString()
29
+ : typeof value === "number" || typeof value === "bigint"
30
+ ? new Date(Number(value)).toISOString()
31
+ : value;
32
+ }
33
+
34
+ function errorReason(error: unknown): string | undefined {
35
+ if (typeof error !== "object" || error === null || !("reason" in error)) {
36
+ return undefined;
37
+ }
38
+
39
+ const reason = Reflect.get(error, "reason");
40
+ return typeof reason === "string" ? reason : undefined;
41
+ }
42
+
43
+ /**
44
+ * Whether a companion resubmission failed because the signed-in user is not
45
+ * allowed to decide it, which leaves the activation to a reviewer. Malformed,
46
+ * stale, and transport failures are not reviewer outcomes.
47
+ */
48
+ export function isDeviceActivationReviewerRequiredFailure(
49
+ error: unknown,
50
+ ): boolean {
51
+ const reason = errorReason(error);
52
+ return (
53
+ reason === "not_authorized" ||
54
+ reason === "insufficient_permissions" ||
55
+ reason === "forbidden"
56
+ );
57
+ }
58
+
59
+ export function createDeviceActivationReadyView(
60
+ flowId: string,
61
+ ): DeviceActivationView {
62
+ return { mode: "ready", flowId };
63
+ }
64
+
65
+ export function createDeviceActivationSignInRequiredView(
66
+ flowId: string,
67
+ ): DeviceActivationView {
68
+ return { mode: "sign_in_required", flowId };
69
+ }
70
+
71
+ export function createInvalidDeviceActivationView(
72
+ reason: string,
73
+ flowId?: string,
74
+ ): DeviceActivationView {
75
+ return flowId
76
+ ? { mode: "invalid_flow", reason, flowId }
77
+ : { mode: "invalid_flow", reason };
78
+ }
79
+
80
+ export function mapDeviceActivationOutput(
81
+ flowId: string,
82
+ result: DeviceUserAuthoritiesResolveOutput,
83
+ ): DeviceActivationView {
84
+ if (result.review.state === "approved") {
85
+ return {
86
+ mode: "activated",
87
+ flowId,
88
+ instanceId: result.device.instanceId,
89
+ deploymentId: result.device.deploymentId,
90
+ activatedAt: isoString(
91
+ result.review.decidedAt ?? result.device.updatedAt,
92
+ ),
93
+ };
94
+ }
95
+
96
+ return {
97
+ mode: "rejected",
98
+ flowId,
99
+ ...(result.review.reason ? { reason: result.review.reason } : {}),
100
+ };
101
+ }
102
+
103
+ export function mapDeviceActivationProgress(
104
+ flowId: string,
105
+ progress: DeviceUserAuthoritiesResolveProgress,
106
+ ): DeviceActivationView {
107
+ return {
108
+ mode: "pending_review",
109
+ flowId,
110
+ state: progress.state,
111
+ };
112
+ }
113
+
114
+ export function mapDeviceActivationFailure(
115
+ flowId: string,
116
+ error: unknown,
117
+ ): DeviceActivationView | null {
118
+ const reason = errorReason(error);
119
+
120
+ if (reason === "device_activation_flow_not_found") {
121
+ return createInvalidDeviceActivationView(
122
+ "This activation link is no longer valid.",
123
+ flowId,
124
+ );
125
+ }
126
+
127
+ if (reason === "device_activation_flow_expired") {
128
+ return {
129
+ mode: "expired",
130
+ flowId,
131
+ reason:
132
+ "The activation request expired. Start again from the auth service.",
133
+ };
134
+ }
135
+
136
+ if (reason === "unknown_device") {
137
+ return createInvalidDeviceActivationView(
138
+ "This activation link no longer matches a known device.",
139
+ flowId,
140
+ );
141
+ }
142
+
143
+ if (reason === "device_deployment_not_found") {
144
+ return createInvalidDeviceActivationView(
145
+ "This device deployment is no longer available.",
146
+ flowId,
147
+ );
148
+ }
149
+
150
+ if (reason === "invalid_request") {
151
+ return createInvalidDeviceActivationView(
152
+ "Trellis rejected this activation request. Start again from the device.",
153
+ flowId,
154
+ );
155
+ }
156
+
157
+ if (
158
+ reason === "device_activation_revoked"
159
+ ) {
160
+ return { mode: "rejected", flowId, reason };
161
+ }
162
+
163
+ return null;
164
+ }
165
+
166
+ export function mapDeviceActivationTerminal(
167
+ flowId: string,
168
+ terminal: TerminalOperation<unknown, DeviceUserAuthoritiesResolveOutput>,
169
+ ): DeviceActivationView | null {
170
+ if (terminal.state === "completed") {
171
+ return terminal.output
172
+ ? mapDeviceActivationOutput(flowId, terminal.output)
173
+ : null;
174
+ }
175
+
176
+ return terminal.error
177
+ ? mapDeviceActivationFailure(flowId, terminal.error)
178
+ : null;
179
+ }
@@ -0,0 +1,48 @@
1
+ export type DeviceActivationCallbackState = {
2
+ flowId: string;
3
+ callbackToken: string;
4
+ };
5
+
6
+ export type StorageLike = Pick<Storage, "getItem" | "setItem" | "removeItem">;
7
+
8
+ const PRESERVED_ACTIVATION_FLOW_ID_STORAGE_KEY = "portal.activate.flowId";
9
+ const ACTIVATION_CALLBACK_TOKEN_STORAGE_KEY = "portal.activate.callbackToken";
10
+ const ACTIVATION_CALLBACK_QUERY_PARAM = "portalCallback";
11
+
12
+ export function getPreservedDeviceActivationCallbackState(
13
+ storage: StorageLike,
14
+ ): DeviceActivationCallbackState | null {
15
+ const flowId = storage.getItem(PRESERVED_ACTIVATION_FLOW_ID_STORAGE_KEY);
16
+ const callbackToken = storage.getItem(ACTIVATION_CALLBACK_TOKEN_STORAGE_KEY);
17
+
18
+ if (!flowId || !callbackToken) return null;
19
+
20
+ return { flowId, callbackToken };
21
+ }
22
+
23
+ export function preserveDeviceActivationCallbackState(
24
+ storage: StorageLike,
25
+ nextState: DeviceActivationCallbackState,
26
+ ): void {
27
+ storage.setItem(PRESERVED_ACTIVATION_FLOW_ID_STORAGE_KEY, nextState.flowId);
28
+ storage.setItem(
29
+ ACTIVATION_CALLBACK_TOKEN_STORAGE_KEY,
30
+ nextState.callbackToken,
31
+ );
32
+ }
33
+
34
+ export function clearPreservedDeviceActivationCallbackState(
35
+ storage: StorageLike,
36
+ ): void {
37
+ storage.removeItem(PRESERVED_ACTIVATION_FLOW_ID_STORAGE_KEY);
38
+ storage.removeItem(ACTIVATION_CALLBACK_TOKEN_STORAGE_KEY);
39
+ }
40
+
41
+ export function isDeviceActivationAuthCallback(
42
+ currentUrl: URL,
43
+ preservedState: DeviceActivationCallbackState | null,
44
+ ): boolean {
45
+ if (!preservedState) return false;
46
+ return currentUrl.searchParams.get(ACTIVATION_CALLBACK_QUERY_PARAM) ===
47
+ preservedState.callbackToken;
48
+ }
@@ -0,0 +1,94 @@
1
+ import type { DeviceActivationCallbackState } from "./callback_state.ts";
2
+ import { isDeviceActivationAuthCallback } from "./callback_state.ts";
3
+
4
+ export type DeviceActivationConnectAuthUrlState = {
5
+ currentUrl: URL;
6
+ redirectTo: string;
7
+ };
8
+
9
+ export type DeviceActivationUrlState = {
10
+ flowId: string | null;
11
+ isAuthCallback: boolean;
12
+ };
13
+
14
+ const ACTIVATION_CALLBACK_QUERY_PARAM = "portalCallback";
15
+ const AUTH_ERROR_QUERY_PARAM = "authError";
16
+ const DEVICE_FLOW_ID_QUERY_PARAM = "deviceFlowId";
17
+ const FLOW_ID_QUERY_PARAM = "flowId";
18
+
19
+ export function buildDeviceActivationCallbackPath(
20
+ currentUrl: URL,
21
+ callbackToken: string,
22
+ ): string {
23
+ const callbackUrl = new URL(currentUrl.pathname, currentUrl.origin);
24
+ callbackUrl.searchParams.set(ACTIVATION_CALLBACK_QUERY_PARAM, callbackToken);
25
+ const flowId = currentUrl.searchParams.get(FLOW_ID_QUERY_PARAM);
26
+ if (flowId) {
27
+ callbackUrl.searchParams.set(DEVICE_FLOW_ID_QUERY_PARAM, flowId);
28
+ }
29
+ callbackUrl.hash = currentUrl.hash;
30
+ return `${callbackUrl.pathname}${callbackUrl.search}${callbackUrl.hash}`;
31
+ }
32
+
33
+ export function buildDeviceActivationConnectAuthUrlState(
34
+ currentUrl: URL,
35
+ ): DeviceActivationConnectAuthUrlState {
36
+ const redirectUrl = new URL(currentUrl);
37
+ redirectUrl.searchParams.delete(ACTIVATION_CALLBACK_QUERY_PARAM);
38
+ redirectUrl.searchParams.delete(AUTH_ERROR_QUERY_PARAM);
39
+ redirectUrl.searchParams.delete(DEVICE_FLOW_ID_QUERY_PARAM);
40
+
41
+ const authCurrentUrl = new URL(redirectUrl);
42
+ authCurrentUrl.searchParams.delete(DEVICE_FLOW_ID_QUERY_PARAM);
43
+ authCurrentUrl.searchParams.delete(FLOW_ID_QUERY_PARAM);
44
+
45
+ return {
46
+ currentUrl: authCurrentUrl,
47
+ redirectTo: redirectUrl.toString(),
48
+ };
49
+ }
50
+
51
+ export function cleanupDeviceActivationCallbackUrl(
52
+ currentUrl: URL,
53
+ flowId: string | null,
54
+ ): string | null {
55
+ if (
56
+ !currentUrl.searchParams.has(FLOW_ID_QUERY_PARAM) &&
57
+ !currentUrl.searchParams.has(AUTH_ERROR_QUERY_PARAM) &&
58
+ !currentUrl.searchParams.has(DEVICE_FLOW_ID_QUERY_PARAM) &&
59
+ !currentUrl.searchParams.has(ACTIVATION_CALLBACK_QUERY_PARAM)
60
+ ) {
61
+ return null;
62
+ }
63
+
64
+ const nextUrl = new URL(currentUrl);
65
+ nextUrl.searchParams.delete(FLOW_ID_QUERY_PARAM);
66
+ nextUrl.searchParams.delete(AUTH_ERROR_QUERY_PARAM);
67
+ nextUrl.searchParams.delete(DEVICE_FLOW_ID_QUERY_PARAM);
68
+ nextUrl.searchParams.delete(ACTIVATION_CALLBACK_QUERY_PARAM);
69
+ if (flowId) {
70
+ nextUrl.searchParams.set(FLOW_ID_QUERY_PARAM, flowId);
71
+ }
72
+
73
+ return `${nextUrl.pathname}${nextUrl.search}${nextUrl.hash}`;
74
+ }
75
+
76
+ export function resolveDeviceActivationUrlState(
77
+ currentUrl: URL,
78
+ preservedState: DeviceActivationCallbackState | null,
79
+ ): DeviceActivationUrlState {
80
+ const callbackFlowId = currentUrl.searchParams.get(
81
+ DEVICE_FLOW_ID_QUERY_PARAM,
82
+ );
83
+ const isAuthCallback =
84
+ isDeviceActivationAuthCallback(currentUrl, preservedState) ||
85
+ (currentUrl.searchParams.has(ACTIVATION_CALLBACK_QUERY_PARAM) &&
86
+ !!callbackFlowId);
87
+
88
+ return {
89
+ flowId: isAuthCallback
90
+ ? preservedState?.flowId ?? callbackFlowId
91
+ : currentUrl.searchParams.get(FLOW_ID_QUERY_PARAM),
92
+ isAuthCallback,
93
+ };
94
+ }
@@ -0,0 +1,143 @@
1
+ import {
2
+ type BrowserPortalFlowState as PortalFlowState,
3
+ fetchPortalFlowState,
4
+ getOrCreatePortalBinding,
5
+ type PortalBinding,
6
+ portalFlowIdFromUrl,
7
+ portalProviderLoginUrl,
8
+ submitPortalApproval,
9
+ TrellisHttpError,
10
+ } from "@oatscenter/trellis/auth/browser";
11
+
12
+ type AuthConfig = { authUrl: string };
13
+
14
+ export type CreatePortalFlowConfig = AuthConfig & {
15
+ getUrl?: () => URL;
16
+ sessionStorage?: Storage;
17
+ };
18
+
19
+ function errorMessage(error: unknown): string {
20
+ return error instanceof Error ? error.message : String(error);
21
+ }
22
+
23
+ function defaultGetUrl(): URL {
24
+ return new URL(globalThis.location.href);
25
+ }
26
+
27
+ export class PortalFlowController {
28
+ flowId: string | null = $state(null);
29
+ state: PortalFlowState | null = $state(null);
30
+ loading = $state(false);
31
+ error: string | null = $state(null);
32
+ errorCode: string | null = $state(null);
33
+
34
+ #config: AuthConfig;
35
+ #getUrl: () => URL;
36
+ #sessionStorage: Storage;
37
+ #binding: PortalBinding | null = null;
38
+
39
+ constructor(config: CreatePortalFlowConfig) {
40
+ this.#config = { authUrl: config.authUrl };
41
+ this.#getUrl = config.getUrl ?? defaultGetUrl;
42
+ this.#sessionStorage = config.sessionStorage ?? globalThis.sessionStorage;
43
+ }
44
+
45
+ async load(): Promise<PortalFlowState | null> {
46
+ this.loading = true;
47
+ this.error = null;
48
+ this.errorCode = null;
49
+ this.state = null;
50
+
51
+ try {
52
+ const flowId = portalFlowIdFromUrl(this.#getUrl());
53
+ this.flowId = flowId;
54
+ if (!flowId) {
55
+ this.error = "Missing flow id.";
56
+ this.errorCode = "missing_flow_id";
57
+ return null;
58
+ }
59
+
60
+ this.#binding = await getOrCreatePortalBinding(
61
+ flowId,
62
+ this.#sessionStorage,
63
+ );
64
+ const state = await fetchPortalFlowState(
65
+ this.#config,
66
+ flowId,
67
+ this.#binding,
68
+ );
69
+ this.state = state;
70
+ return state;
71
+ } catch (error) {
72
+ this.error = errorMessage(error);
73
+ this.errorCode = error instanceof TrellisHttpError ? error.code : null;
74
+ this.state = null;
75
+ return null;
76
+ } finally {
77
+ this.loading = false;
78
+ }
79
+ }
80
+
81
+ providerUrl(providerId: string): string {
82
+ if (!this.flowId) {
83
+ throw new Error("Missing flow id.");
84
+ }
85
+
86
+ if (!this.#binding) throw new Error("Portal flow has not loaded.");
87
+ return portalProviderLoginUrl(
88
+ this.#config,
89
+ providerId,
90
+ this.flowId,
91
+ this.#binding,
92
+ );
93
+ }
94
+
95
+ get binding(): PortalBinding {
96
+ if (!this.#binding) throw new Error("Portal flow has not loaded.");
97
+ return this.#binding;
98
+ }
99
+
100
+ async approve(): Promise<PortalFlowState | null> {
101
+ return this.#submit("approved");
102
+ }
103
+
104
+ async deny(): Promise<PortalFlowState | null> {
105
+ return this.#submit("denied");
106
+ }
107
+
108
+ async #submit(
109
+ decision: "approved" | "denied",
110
+ ): Promise<PortalFlowState | null> {
111
+ if (!this.flowId) {
112
+ this.error = "Missing flow id.";
113
+ return null;
114
+ }
115
+
116
+ this.loading = true;
117
+ this.error = null;
118
+ this.errorCode = null;
119
+
120
+ try {
121
+ const state = await submitPortalApproval(
122
+ this.#config,
123
+ this.flowId,
124
+ this.binding,
125
+ decision,
126
+ );
127
+ this.state = state;
128
+ return state;
129
+ } catch (error) {
130
+ this.error = errorMessage(error);
131
+ this.errorCode = error instanceof TrellisHttpError ? error.code : null;
132
+ return null;
133
+ } finally {
134
+ this.loading = false;
135
+ }
136
+ }
137
+ }
138
+
139
+ export function createPortalFlow(
140
+ config: CreatePortalFlowConfig,
141
+ ): PortalFlowController {
142
+ return new PortalFlowController(config);
143
+ }