@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,528 @@
1
+ import type { AsyncResult, BaseError } from "@oatscenter/result";
2
+ import type {
3
+ OperationEvent,
4
+ OperationSnapshot,
5
+ TerminalOperation,
6
+ } from "@oatscenter/trellis";
7
+ import type {
8
+ DeviceUserAuthoritiesResolveOutput,
9
+ DeviceUserAuthoritiesResolveProgress,
10
+ } from "@oatscenter/trellis/auth";
11
+ import { ulid } from "ulid";
12
+
13
+ import {
14
+ createDeviceActivationReadyView,
15
+ createDeviceActivationSignInRequiredView,
16
+ createInvalidDeviceActivationView,
17
+ type DeviceActivationView,
18
+ isDeviceActivationReviewerRequiredFailure,
19
+ mapDeviceActivationFailure,
20
+ mapDeviceActivationProgress,
21
+ mapDeviceActivationTerminal,
22
+ } from "./internal/activation_view.ts";
23
+ import {
24
+ clearPreservedDeviceActivationCallbackState,
25
+ getPreservedDeviceActivationCallbackState,
26
+ preserveDeviceActivationCallbackState,
27
+ type StorageLike,
28
+ } from "./internal/callback_state.ts";
29
+ import {
30
+ buildDeviceActivationCallbackPath,
31
+ buildDeviceActivationConnectAuthUrlState,
32
+ cleanupDeviceActivationCallbackUrl,
33
+ type DeviceActivationConnectAuthUrlState,
34
+ resolveDeviceActivationUrlState,
35
+ } from "./internal/portal_url.ts";
36
+
37
+ export type DeviceActivationSignInOptions = {
38
+ redirectTo?: string;
39
+ landingPath?: string;
40
+ context?: unknown;
41
+ };
42
+
43
+ export type DeviceActivationBindResult =
44
+ | { status: "bound" }
45
+ | { status: "approval_denied" }
46
+ | { status: "approval_required" }
47
+ | { status: "insufficient_capabilities"; missingCapabilities: string[] }
48
+ | { status: "error"; message: string };
49
+
50
+ export type DeviceActivationAuth = {
51
+ init(): Promise<unknown>;
52
+ handleCallback(
53
+ callbackUrl: string,
54
+ ): Promise<DeviceActivationBindResult | null>;
55
+ signIn(options?: DeviceActivationSignInOptions): Promise<void>;
56
+ };
57
+
58
+ export type DeviceActivationControllerConfig = {
59
+ authState: DeviceActivationAuth;
60
+ createClient(
61
+ authUrlState: DeviceActivationConnectAuthUrlState,
62
+ ): Promise<DeviceActivationClient>;
63
+ getUrl?: () => URL;
64
+ replaceUrl?: (url: string) => void;
65
+ sessionStorage?: StorageLike;
66
+ createCallbackToken?: () => string;
67
+ };
68
+
69
+ export type DeviceActivationOperationRef = {
70
+ watch(): AsyncResult<
71
+ AsyncIterable<
72
+ OperationEvent<
73
+ DeviceUserAuthoritiesResolveProgress,
74
+ DeviceUserAuthoritiesResolveOutput
75
+ >
76
+ >,
77
+ BaseError
78
+ >;
79
+ wait(): AsyncResult<
80
+ TerminalOperation<
81
+ DeviceUserAuthoritiesResolveProgress,
82
+ DeviceUserAuthoritiesResolveOutput
83
+ >,
84
+ BaseError
85
+ >;
86
+ };
87
+
88
+ type SnapshotCapableDeviceActivationOperationRef =
89
+ & DeviceActivationOperationRef
90
+ & {
91
+ get(): AsyncResult<
92
+ OperationSnapshot<
93
+ DeviceUserAuthoritiesResolveProgress,
94
+ DeviceUserAuthoritiesResolveOutput
95
+ >,
96
+ BaseError
97
+ >;
98
+ };
99
+
100
+ type CompanionConsent = NonNullable<
101
+ DeviceUserAuthoritiesResolveProgress["companionConsent"]
102
+ >;
103
+
104
+ export type DeviceCompanionApproval = {
105
+ mode: "capabilities";
106
+ installedRevision: CompanionConsent["installedRevision"];
107
+ expectedGrantRevision: CompanionConsent["expectedGrantRevision"];
108
+ decisionDigest: CompanionConsent["decisionDigest"];
109
+ approvedCapabilities: Array<{
110
+ id: CompanionConsent["capabilities"][number]["id"];
111
+ consentDigest: CompanionConsent["capabilities"][number]["consentDigest"];
112
+ }>;
113
+ approvedResources: Array<{
114
+ kind: CompanionConsent["resources"][number]["kind"];
115
+ name: CompanionConsent["resources"][number]["name"];
116
+ commitment: CompanionConsent["resources"][number]["requestedCommitment"];
117
+ }>;
118
+ companionApproved: boolean;
119
+ };
120
+
121
+ export type DeviceActivationClient = {
122
+ activateDevice(
123
+ input: {
124
+ flowId: string;
125
+ confirmationCode: string;
126
+ companionApproval?: DeviceCompanionApproval;
127
+ },
128
+ ): Promise<DeviceActivationOperationRef>;
129
+ };
130
+
131
+ export type DeviceActivationState = {
132
+ loading: boolean;
133
+ requestPending: boolean;
134
+ authError: string | null;
135
+ view: DeviceActivationView | null;
136
+ flowId: string | null;
137
+ confirmationCode: string;
138
+ };
139
+
140
+ function errorMessage(error: unknown): string {
141
+ return error instanceof Error ? error.message : String(error);
142
+ }
143
+
144
+ function buildCompanionApproval(
145
+ consent: NonNullable<
146
+ DeviceUserAuthoritiesResolveProgress["companionConsent"]
147
+ >,
148
+ ): DeviceCompanionApproval {
149
+ return {
150
+ mode: "capabilities",
151
+ installedRevision: consent.installedRevision,
152
+ expectedGrantRevision: consent.expectedGrantRevision,
153
+ decisionDigest: consent.decisionDigest,
154
+ approvedCapabilities: consent.capabilities
155
+ .filter((item) => item.required && item.eligible)
156
+ .map((item) => ({ id: item.id, consentDigest: item.consentDigest })),
157
+ approvedResources: consent.resources
158
+ .filter((item) => item.required && item.eligible)
159
+ .map((item) => ({
160
+ kind: item.kind,
161
+ name: item.name,
162
+ commitment: item.requestedCommitment,
163
+ })),
164
+ companionApproved: false,
165
+ };
166
+ }
167
+
168
+ function redirectErrorMessage(error: unknown): string | null {
169
+ const message = errorMessage(error);
170
+ return message.startsWith("Redirecting to auth for provider selection")
171
+ ? null
172
+ : message;
173
+ }
174
+
175
+ function bindErrorMessage(result: DeviceActivationBindResult): string | null {
176
+ if (result.status === "bound") return null;
177
+ if (result.status === "approval_denied") return "Portal access was denied.";
178
+ if (result.status === "approval_required") {
179
+ return "Approval is still pending.";
180
+ }
181
+ if (result.status === "insufficient_capabilities") {
182
+ return `Missing capabilities: ${result.missingCapabilities.join(", ")}`;
183
+ }
184
+ return result.message;
185
+ }
186
+
187
+ function defaultGetUrl(): URL {
188
+ return new URL(globalThis.location.href);
189
+ }
190
+
191
+ function defaultReplaceUrl(url: string): void {
192
+ globalThis.history.replaceState(globalThis.history.state, "", url);
193
+ }
194
+
195
+ function defaultCreateCallbackToken(): string {
196
+ return ulid();
197
+ }
198
+
199
+ export function createInitialDeviceActivationState(): DeviceActivationState {
200
+ return {
201
+ loading: true,
202
+ requestPending: false,
203
+ authError: null,
204
+ view: null,
205
+ flowId: null,
206
+ confirmationCode: "",
207
+ };
208
+ }
209
+
210
+ export class DeviceActivationControllerCore {
211
+ protected readonly state: DeviceActivationState;
212
+
213
+ #authState: DeviceActivationAuth;
214
+ #createClient: DeviceActivationControllerConfig["createClient"];
215
+ #getUrl: () => URL;
216
+ #replaceUrl: (url: string) => void;
217
+ #sessionStorage: StorageLike | null;
218
+ #createCallbackToken: () => string;
219
+ #client: DeviceActivationClient | null = null;
220
+ #observationRunId = 0;
221
+
222
+ constructor(
223
+ config: DeviceActivationControllerConfig,
224
+ state: DeviceActivationState = createInitialDeviceActivationState(),
225
+ ) {
226
+ this.state = state;
227
+ this.#authState = config.authState;
228
+ this.#createClient = config.createClient;
229
+ this.#getUrl = config.getUrl ?? defaultGetUrl;
230
+ this.#replaceUrl = config.replaceUrl ?? defaultReplaceUrl;
231
+ this.#sessionStorage = config.sessionStorage ?? null;
232
+ this.#createCallbackToken = config.createCallbackToken ??
233
+ defaultCreateCallbackToken;
234
+ }
235
+
236
+ get loading(): boolean {
237
+ return this.state.loading;
238
+ }
239
+
240
+ get requestPending(): boolean {
241
+ return this.state.requestPending;
242
+ }
243
+
244
+ get authError(): string | null {
245
+ return this.state.authError;
246
+ }
247
+
248
+ get view(): DeviceActivationView | null {
249
+ return this.state.view;
250
+ }
251
+
252
+ get flowId(): string | null {
253
+ return this.state.flowId;
254
+ }
255
+
256
+ get confirmationCode(): string {
257
+ return this.state.confirmationCode;
258
+ }
259
+
260
+ set confirmationCode(value: string) {
261
+ this.state.confirmationCode = value;
262
+ }
263
+
264
+ async load(): Promise<void> {
265
+ this.stop();
266
+ this.state.loading = true;
267
+ this.state.authError = null;
268
+ this.state.view = null;
269
+ this.#client = null;
270
+
271
+ const currentUrl = this.#getUrl();
272
+ let clientUrl = currentUrl;
273
+ const preservedState = this.#sessionStorage
274
+ ? getPreservedDeviceActivationCallbackState(this.#sessionStorage)
275
+ : null;
276
+ const { flowId, isAuthCallback } = resolveDeviceActivationUrlState(
277
+ currentUrl,
278
+ preservedState,
279
+ );
280
+ this.state.flowId = flowId;
281
+
282
+ if (!flowId) {
283
+ this.state.view = createInvalidDeviceActivationView("Missing flow id.");
284
+ this.state.loading = false;
285
+ return;
286
+ }
287
+
288
+ try {
289
+ await this.#authState.init();
290
+
291
+ if (isAuthCallback) {
292
+ const bindResult = await this.#authState.handleCallback(
293
+ currentUrl.toString(),
294
+ );
295
+ const cleanedUrl = cleanupDeviceActivationCallbackUrl(
296
+ currentUrl,
297
+ flowId,
298
+ );
299
+ if (cleanedUrl) {
300
+ this.#replaceUrl(cleanedUrl);
301
+ clientUrl = new URL(cleanedUrl, currentUrl.origin);
302
+ }
303
+ if (this.#sessionStorage) {
304
+ clearPreservedDeviceActivationCallbackState(this.#sessionStorage);
305
+ }
306
+
307
+ const callbackAuthError = currentUrl.searchParams.get("authError");
308
+ if (callbackAuthError && !bindResult) {
309
+ this.state.authError = callbackAuthError;
310
+ this.state.view = createDeviceActivationSignInRequiredView(flowId);
311
+ return;
312
+ }
313
+
314
+ if (bindResult) {
315
+ const bindError = bindErrorMessage(bindResult);
316
+ if (bindError) {
317
+ this.state.authError = bindError;
318
+ this.state.view = createDeviceActivationSignInRequiredView(flowId);
319
+ return;
320
+ }
321
+ }
322
+ }
323
+
324
+ try {
325
+ this.#client = await this.#createClient(
326
+ buildDeviceActivationConnectAuthUrlState(clientUrl),
327
+ );
328
+ this.state.view = createDeviceActivationReadyView(flowId);
329
+ } catch (error) {
330
+ this.#client = null;
331
+ if (isAuthCallback) {
332
+ this.state.authError = errorMessage(error);
333
+ }
334
+ this.state.view = createDeviceActivationSignInRequiredView(flowId);
335
+ if (!isAuthCallback) {
336
+ await this.signIn();
337
+ }
338
+ }
339
+ } catch (error) {
340
+ this.state.authError = errorMessage(error);
341
+ this.state.view = createDeviceActivationSignInRequiredView(flowId);
342
+ } finally {
343
+ this.state.loading = false;
344
+ }
345
+ }
346
+
347
+ stop(): void {
348
+ this.#observationRunId += 1;
349
+ }
350
+
351
+ async signIn(): Promise<void> {
352
+ this.state.authError = null;
353
+ if (!this.state.flowId || !this.#sessionStorage) return;
354
+
355
+ const callbackToken = this.#createCallbackToken();
356
+ preserveDeviceActivationCallbackState(this.#sessionStorage, {
357
+ flowId: this.state.flowId,
358
+ callbackToken,
359
+ });
360
+
361
+ try {
362
+ await this.#authState.signIn({
363
+ redirectTo: buildDeviceActivationCallbackPath(
364
+ this.#getUrl(),
365
+ callbackToken,
366
+ ),
367
+ });
368
+ } catch (error) {
369
+ const message = redirectErrorMessage(error);
370
+ if (message) {
371
+ this.state.authError = message;
372
+ }
373
+ }
374
+ }
375
+
376
+ async requestActivation(): Promise<void> {
377
+ const flowId = this.state.flowId;
378
+ const confirmationCode = this.state.confirmationCode.trim();
379
+ if (!flowId || !confirmationCode || !this.#client) return;
380
+
381
+ this.stop();
382
+ const runId = this.#observationRunId;
383
+ this.state.requestPending = true;
384
+ this.state.authError = null;
385
+
386
+ try {
387
+ let operation = await this.#client.activateDevice({
388
+ flowId,
389
+ confirmationCode,
390
+ });
391
+ if (hasOperationSnapshot(operation)) {
392
+ let snapshot = await operation.get().match({
393
+ ok: (value) => value,
394
+ err: () => null,
395
+ });
396
+ for (
397
+ let attempt = 0;
398
+ attempt < 40 &&
399
+ !snapshot?.progress?.companionConsent &&
400
+ snapshot?.state === "running" &&
401
+ this.#isRunActive(runId);
402
+ attempt++
403
+ ) {
404
+ await new Promise((resolve) => setTimeout(resolve, 250));
405
+ snapshot = await operation.get().match({
406
+ ok: (value) => value,
407
+ err: () => null,
408
+ });
409
+ }
410
+ if (snapshot?.progress && this.#isRunActive(runId)) {
411
+ this.state.view = mapDeviceActivationProgress(
412
+ flowId,
413
+ snapshot.progress,
414
+ );
415
+ }
416
+ const companionConsent = snapshot?.progress?.companionConsent;
417
+ if (companionConsent && this.#isRunActive(runId)) {
418
+ try {
419
+ operation = await this.#client.activateDevice({
420
+ flowId,
421
+ confirmationCode,
422
+ companionApproval: buildCompanionApproval(companionConsent),
423
+ });
424
+ } catch (error) {
425
+ // Only a permission outcome means a reviewer must decide later;
426
+ // malformed, stale, or transport failures stay visible/retryable.
427
+ if (!isDeviceActivationReviewerRequiredFailure(error)) {
428
+ throw error;
429
+ }
430
+ }
431
+ }
432
+ }
433
+ const watch = await operation.watch().match({
434
+ ok: (value) => value,
435
+ err: () => null,
436
+ });
437
+ const watchPromise = watch
438
+ ? this.#observeWatch(flowId, watch, runId)
439
+ : Promise.resolve(false);
440
+ const terminal = await operation.wait().orThrow();
441
+ const handledByWatch = await watchPromise;
442
+ if (!handledByWatch && this.#isRunActive(runId)) {
443
+ this.#applyTerminal(flowId, terminal);
444
+ }
445
+ } catch (error) {
446
+ if (!this.#isRunActive(runId)) return;
447
+
448
+ const nextView = mapDeviceActivationFailure(flowId, error);
449
+ if (nextView) {
450
+ this.state.view = nextView;
451
+ } else {
452
+ this.state.view = createDeviceActivationReadyView(flowId);
453
+ this.state.authError = errorMessage(error);
454
+ }
455
+ } finally {
456
+ if (this.#isRunActive(runId)) {
457
+ this.state.requestPending = false;
458
+ }
459
+ }
460
+ }
461
+
462
+ #isRunActive(runId: number): boolean {
463
+ return this.#observationRunId === runId;
464
+ }
465
+
466
+ #applyTerminal(
467
+ flowId: string,
468
+ terminal: TerminalOperation<
469
+ DeviceUserAuthoritiesResolveProgress,
470
+ DeviceUserAuthoritiesResolveOutput
471
+ >,
472
+ ): void {
473
+ const view = mapDeviceActivationTerminal(flowId, terminal);
474
+ if (view) {
475
+ this.state.view = view;
476
+ return;
477
+ }
478
+
479
+ this.state.view = createDeviceActivationReadyView(flowId);
480
+ if (terminal.error?.message) {
481
+ this.state.authError = terminal.error.message;
482
+ }
483
+ }
484
+
485
+ async #observeWatch(
486
+ flowId: string,
487
+ watch: AsyncIterable<
488
+ OperationEvent<
489
+ DeviceUserAuthoritiesResolveProgress,
490
+ DeviceUserAuthoritiesResolveOutput
491
+ >
492
+ >,
493
+ runId: number,
494
+ ): Promise<boolean> {
495
+ const iterator = watch[Symbol.asyncIterator]();
496
+
497
+ while (true) {
498
+ const next = await iterator.next();
499
+ if (next.done) {
500
+ return false;
501
+ }
502
+
503
+ const event = next.value;
504
+ if (!this.#isRunActive(runId)) {
505
+ await iterator.return?.();
506
+ return false;
507
+ }
508
+
509
+ if (
510
+ event.type === "completed" || event.type === "failed" ||
511
+ event.type === "cancelled"
512
+ ) {
513
+ this.#applyTerminal(flowId, event.snapshot);
514
+ return true;
515
+ }
516
+
517
+ if (event.type === "progress") {
518
+ this.state.view = mapDeviceActivationProgress(flowId, event.progress);
519
+ }
520
+ }
521
+ }
522
+ }
523
+
524
+ function hasOperationSnapshot(
525
+ operation: DeviceActivationOperationRef,
526
+ ): operation is SnapshotCapableDeviceActivationOperationRef {
527
+ return "get" in operation && typeof operation.get === "function";
528
+ }
package/src/index.ts ADDED
@@ -0,0 +1,31 @@
1
+ export { default as TrellisProvider } from "./components/TrellisProvider.svelte";
2
+ export type { TrellisProviderProps } from "./components/TrellisProvider.types.ts";
3
+ export type {
4
+ CreateTrellisAppOptions,
5
+ SvelteTrellisConnection,
6
+ TrellisApp,
7
+ TrellisAppOwner,
8
+ TrellisAppUrl,
9
+ TrellisAppUrlResolver,
10
+ TrellisClientFor,
11
+ TrellisContextClient,
12
+ TrellisParticipantLike,
13
+ } from "./context.svelte.ts";
14
+ export { createTrellisApp, resolveTrellisAppUrl } from "./context.svelte.ts";
15
+ export {
16
+ AuthorizationContextController,
17
+ type AuthorizationContextStatus,
18
+ } from "./authorization_context.svelte.ts";
19
+ export {
20
+ createDeviceActivationController,
21
+ type DeviceActivationAuth,
22
+ type DeviceActivationClient,
23
+ DeviceActivationController,
24
+ type DeviceActivationControllerConfig,
25
+ type DeviceActivationOperationRef,
26
+ } from "./device_activation.svelte.ts";
27
+ export {
28
+ createPortalFlow,
29
+ type CreatePortalFlowConfig,
30
+ PortalFlowController,
31
+ } from "./portal_flow.svelte.ts";