@dxos/cli-util 0.9.0 → 0.9.1-main.c7dcc2e112

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dxos/cli-util",
3
- "version": "0.9.0",
3
+ "version": "0.9.1-main.c7dcc2e112",
4
4
  "description": "Shared CLI utilities for DXOS CLI commands and plugins",
5
5
  "homepage": "https://dxos.org",
6
6
  "bugs": "https://github.com/dxos/dxos/issues",
@@ -31,25 +31,29 @@
31
31
  ],
32
32
  "dependencies": {
33
33
  "@effect/cli": "0.75.2",
34
+ "@effect/platform": "0.96.1",
35
+ "@effect/platform-bun": "0.90.0",
34
36
  "@effect/printer": "0.49.0",
35
37
  "@effect/printer-ansi": "0.49.0",
36
- "@dxos/app-toolkit": "0.9.0",
37
- "@dxos/client": "0.9.0",
38
- "@dxos/compute": "0.9.0",
39
- "@dxos/debug": "0.9.0",
40
- "@dxos/echo": "0.9.0",
41
- "@dxos/echo-client": "0.9.0",
42
- "@dxos/effect": "0.9.0",
43
- "@dxos/functions": "0.9.0",
44
- "@dxos/log": "0.9.0",
45
- "@dxos/protocols": "0.9.0",
46
- "@dxos/util": "0.9.0",
47
- "@dxos/errors": "0.9.0"
38
+ "get-port-please": "^3.1.1",
39
+ "@dxos/app-toolkit": "0.9.1-main.c7dcc2e112",
40
+ "@dxos/client": "0.9.1-main.c7dcc2e112",
41
+ "@dxos/echo": "0.9.1-main.c7dcc2e112",
42
+ "@dxos/echo-client": "0.9.1-main.c7dcc2e112",
43
+ "@dxos/compute": "0.9.1-main.c7dcc2e112",
44
+ "@dxos/effect": "0.9.1-main.c7dcc2e112",
45
+ "@dxos/functions": "0.9.1-main.c7dcc2e112",
46
+ "@dxos/keys": "0.9.1-main.c7dcc2e112",
47
+ "@dxos/errors": "0.9.1-main.c7dcc2e112",
48
+ "@dxos/log": "0.9.1-main.c7dcc2e112",
49
+ "@dxos/protocols": "0.9.1-main.c7dcc2e112",
50
+ "@dxos/util": "0.9.1-main.c7dcc2e112",
51
+ "@dxos/debug": "0.9.1-main.c7dcc2e112"
48
52
  },
49
53
  "devDependencies": {
50
54
  "effect": "3.21.3",
51
55
  "typescript": "^6.0.3",
52
- "vitest": "4.1.7"
56
+ "vitest": "4.1.8"
53
57
  },
54
58
  "peerDependencies": {
55
59
  "effect": "3.21.3"
package/src/index.ts CHANGED
@@ -2,5 +2,6 @@
2
2
  // Copyright 2025 DXOS.org
3
3
  //
4
4
 
5
+ export * from './oauth';
5
6
  export * from './services';
6
7
  export * from './util';
@@ -0,0 +1,6 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ export * from './server';
6
+ export * from './recovery';
@@ -0,0 +1,76 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import * as Effect from 'effect/Effect';
6
+
7
+ import { EntityId, SpaceId } from '@dxos/keys';
8
+
9
+ import { OAUTH_TIMEOUT_MS, startOAuthCallbackServer } from './server';
10
+
11
+ /** Path Edge redirects the browser to after a recovery/register OAuth round-trip. */
12
+ const RECOVERY_CALLBACK_PATH = '/redirect/oauth-recovery';
13
+
14
+ export type RecoveryOAuthParams = {
15
+ /** Edge services base URL (from client config `runtime.services.edge.url`). */
16
+ readonly edgeBaseUrl: string;
17
+ /** OAuth provider id (e.g. `'atproto'`). */
18
+ readonly provider: string;
19
+ /** Requested scopes. */
20
+ readonly scopes: readonly string[];
21
+ /** atproto handle or DID — required so Edge can resolve the user's PDS / auth server. */
22
+ readonly loginHint: string;
23
+ };
24
+
25
+ type InitiateEnvelope = { success: boolean; data?: { authUrl?: string }; error?: { message?: string } };
26
+
27
+ /**
28
+ * Drives the gate's OAuth identity-recovery flow from a CLI: starts a local callback server, asks
29
+ * Edge to begin a `recovery`-purpose OAuth flow (advertising the local server as the redirect
30
+ * origin via the `Origin` header), opens the browser, and resolves with the one-time
31
+ * `recoveryProof` Edge carries back in its redirect. The caller redeems the proof via
32
+ * `client.halo.recoverIdentity({ recoveryProof })`.
33
+ *
34
+ * Recovery runs before any identity exists, so no auth header is sent — Edge resolves the user's
35
+ * space / token server-side from the recovery binding, and the `spaceId` / `accessTokenId` in the
36
+ * request are unused (random values satisfy request validation).
37
+ */
38
+ export const performRecoveryOAuthFlow = Effect.fn(function* (params: RecoveryOAuthParams) {
39
+ const server = yield* startOAuthCallbackServer(RECOVERY_CALLBACK_PATH);
40
+ return yield* Effect.gen(function* () {
41
+ const initiateUrl = new URL('/oauth/initiate', params.edgeBaseUrl).toString();
42
+ const response = yield* Effect.tryPromise({
43
+ try: () =>
44
+ fetch(initiateUrl, {
45
+ method: 'POST',
46
+ headers: { 'Content-Type': 'application/json', Origin: server.origin },
47
+ body: JSON.stringify({
48
+ provider: params.provider,
49
+ scopes: params.scopes,
50
+ spaceId: SpaceId.random(),
51
+ accessTokenId: EntityId.random(),
52
+ purpose: 'recovery',
53
+ loginHint: params.loginHint,
54
+ }),
55
+ }),
56
+ catch: (error) =>
57
+ new Error(`OAuth initiate request failed: ${error instanceof Error ? error.message : String(error)}`),
58
+ });
59
+
60
+ const envelope = yield* Effect.tryPromise({
61
+ try: () => response.json() as Promise<InitiateEnvelope>,
62
+ catch: (error) => new Error(`OAuth initiate response parse failed: ${String(error)}`),
63
+ });
64
+ if (!envelope.success || !envelope.data?.authUrl) {
65
+ return yield* Effect.fail(new Error(`OAuth initiation failed: ${envelope.error?.message ?? 'unknown error'}`));
66
+ }
67
+
68
+ yield* server.open(envelope.data.authUrl);
69
+ const callbackParams = yield* server.waitForResult(OAUTH_TIMEOUT_MS);
70
+ const recoveryProof = callbackParams.recoveryProof;
71
+ if (!recoveryProof) {
72
+ return yield* Effect.fail(new Error('OAuth recovery completed but no recoveryProof was returned.'));
73
+ }
74
+ return { recoveryProof };
75
+ }).pipe(Effect.ensuring(server.stop()));
76
+ });
@@ -0,0 +1,157 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import * as BunHttpServer from '@effect/platform-bun/BunHttpServer';
6
+ import * as HttpRouter from '@effect/platform/HttpRouter';
7
+ import * as HttpServer from '@effect/platform/HttpServer';
8
+ import * as HttpServerRequest from '@effect/platform/HttpServerRequest';
9
+ import * as HttpServerResponse from '@effect/platform/HttpServerResponse';
10
+ import * as Effect from 'effect/Effect';
11
+ import * as Exit from 'effect/Exit';
12
+ import * as Layer from 'effect/Layer';
13
+ import * as Option from 'effect/Option';
14
+ import * as Ref from 'effect/Ref';
15
+ import * as Scope from 'effect/Scope';
16
+ import { getPort } from 'get-port-please';
17
+
18
+ import { openBrowser } from '../util/platform';
19
+
20
+ /** Default timeout for a full OAuth browser round-trip. */
21
+ export const OAUTH_TIMEOUT_MS = 5 * 60 * 1000;
22
+
23
+ /**
24
+ * Relay page that performs a top-level redirect to Edge's `authUrl`. Edge finalizes the flow
25
+ * by redirecting the browser back to this server's callback path (bsky.social nullifies
26
+ * `window.opener`, so a popup + postMessage relay can't be used).
27
+ */
28
+ const getRelayPageHtml = (authUrl: string) => `
29
+ <!DOCTYPE html>
30
+ <html>
31
+ <head>
32
+ <meta charset="UTF-8">
33
+ <script>
34
+ window.location.href = '${authUrl.replace(/'/g, "\\'")}';
35
+ </script>
36
+ </head>
37
+ <body></body>
38
+ </html>
39
+ `;
40
+
41
+ type CallbackOutcome = { success: true; params: Record<string, string> } | { success: false; reason: string };
42
+
43
+ /**
44
+ * A running local OAuth callback server.
45
+ */
46
+ export type OAuthCallbackServer = {
47
+ /** Port the server is listening on. */
48
+ readonly port: number;
49
+ /** Origin (e.g. `http://localhost:1234`) to advertise to Edge as the redirect target. */
50
+ readonly origin: string;
51
+ /** Opens `authUrl` in the browser via the local relay page. */
52
+ readonly open: (authUrl: string) => Effect.Effect<void, Error>;
53
+ /** Resolves with the captured callback query params, or fails on an `error` param / timeout. */
54
+ readonly waitForResult: (timeoutMs?: number) => Effect.Effect<Record<string, string>, Error>;
55
+ /** Stops the server. */
56
+ readonly stop: () => Effect.Effect<void>;
57
+ };
58
+
59
+ /**
60
+ * Starts a local Bun HTTP server that relays the browser to Edge's auth URL and captures Edge's
61
+ * eventual top-level redirect back to `callbackPath`. Generic over the callback shape: all query
62
+ * params are captured and returned; an `error` query param fails the wait.
63
+ *
64
+ * Used by both the integration-connect flow (`/redirect/oauth`) and the identity-recovery login
65
+ * flow (`/redirect/oauth-recovery`).
66
+ */
67
+ export const startOAuthCallbackServer = (callbackPath: `/${string}`): Effect.Effect<OAuthCallbackServer, Error> =>
68
+ Effect.gen(function* () {
69
+ const port = yield* Effect.promise(() => getPort({ random: true }));
70
+ const origin = `http://localhost:${port}`;
71
+ const received = yield* Ref.make(false);
72
+ const outcome = yield* Ref.make<Option.Option<CallbackOutcome>>(Option.none());
73
+
74
+ // req.url is only the path + query, so reconstruct a full URL to parse the query string.
75
+ const parseUrl = (rawUrl: string) => new URL(rawUrl.startsWith('http') ? rawUrl : `http://localhost${rawUrl}`);
76
+
77
+ const router = HttpRouter.empty.pipe(
78
+ HttpRouter.get(
79
+ '/oauth-relay',
80
+ Effect.gen(function* () {
81
+ const request = yield* HttpServerRequest.HttpServerRequest;
82
+ const authUrl = parseUrl(request.url).searchParams.get('authUrl');
83
+ if (!authUrl) {
84
+ return yield* HttpServerResponse.text('Missing authUrl parameter', { status: 400 });
85
+ }
86
+ return yield* HttpServerResponse.text(getRelayPageHtml(authUrl), {
87
+ headers: { 'Content-Type': 'text/html' },
88
+ });
89
+ }),
90
+ ),
91
+ HttpRouter.get(
92
+ callbackPath,
93
+ Effect.gen(function* () {
94
+ if (Option.isSome(yield* Ref.get(outcome))) {
95
+ return yield* HttpServerResponse.text('Already received.', { status: 400 });
96
+ }
97
+
98
+ const request = yield* HttpServerRequest.HttpServerRequest;
99
+ const params = parseUrl(request.url).searchParams;
100
+ const error = params.get('error');
101
+ if (error) {
102
+ yield* Ref.set(outcome, Option.some({ success: false, reason: error }));
103
+ yield* Ref.set(received, true);
104
+ return yield* HttpServerResponse.text(
105
+ `<html><body><h1>Authentication failed</h1><p>${error}</p></body></html>`,
106
+ { status: 400, headers: { 'Content-Type': 'text/html' } },
107
+ );
108
+ }
109
+
110
+ const captured: Record<string, string> = {};
111
+ for (const [key, value] of params.entries()) {
112
+ captured[key] = value;
113
+ }
114
+ yield* Ref.set(outcome, Option.some({ success: true, params: captured }));
115
+ yield* Ref.set(received, true);
116
+ return yield* HttpServerResponse.text(
117
+ '<html><body><h1>Authentication successful! You can close this window.</h1></body></html>',
118
+ { headers: { 'Content-Type': 'text/html' } },
119
+ );
120
+ }),
121
+ ),
122
+ );
123
+
124
+ const app = router.pipe(HttpServer.serve());
125
+ const serverLayer = app.pipe(Layer.provide(BunHttpServer.layer({ port })));
126
+ const scope = yield* Scope.make();
127
+ yield* Layer.build(serverLayer).pipe(Scope.extend(scope));
128
+
129
+ const waitForResult = (timeoutMs: number = OAUTH_TIMEOUT_MS) =>
130
+ Effect.race(
131
+ Effect.gen(function* () {
132
+ while (true) {
133
+ if (yield* Ref.get(received)) {
134
+ break;
135
+ }
136
+ yield* Effect.sleep('500 millis');
137
+ }
138
+ const result = yield* Ref.get(outcome);
139
+ return yield* Option.match(result, {
140
+ onNone: () => Effect.fail(new Error('OAuth callback received but no result')),
141
+ onSome: (value) =>
142
+ value.success
143
+ ? Effect.succeed(value.params)
144
+ : Effect.fail(new Error(`OAuth flow failed: ${value.reason}`)),
145
+ });
146
+ }),
147
+ Effect.sleep(`${timeoutMs} millis`).pipe(Effect.flatMap(() => Effect.fail(new Error('OAuth flow timed out')))),
148
+ );
149
+
150
+ return {
151
+ port,
152
+ origin,
153
+ open: (authUrl: string) => openBrowser(`${origin}/oauth-relay?authUrl=${encodeURIComponent(authUrl)}`),
154
+ waitForResult,
155
+ stop: () => Scope.close(scope, Exit.void).pipe(Effect.catchAll(() => Effect.void)),
156
+ } satisfies OAuthCallbackServer;
157
+ });
package/src/util/space.ts CHANGED
@@ -7,7 +7,7 @@ import * as Effect from 'effect/Effect';
7
7
  import * as Layer from 'effect/Layer';
8
8
  import * as Option from 'effect/Option';
9
9
 
10
- import { getPersonalSpace } from '@dxos/app-toolkit';
10
+ import { AppSpace } from '@dxos/app-toolkit';
11
11
  import { ClientService } from '@dxos/client';
12
12
  import { type Space } from '@dxos/client/echo';
13
13
  import { Database, Feed, type Key } from '@dxos/echo';
@@ -27,7 +27,7 @@ export const spaceIdWithDefault = (spaceId: Option.Option<Key.SpaceId>) =>
27
27
  Effect.gen(function* () {
28
28
  const client = yield* ClientService;
29
29
  return Option.getOrElse(spaceId, () => {
30
- const personal = getPersonalSpace(client);
30
+ const personal = AppSpace.getPersonalSpace(client);
31
31
  if (!personal) {
32
32
  throw new Error('No space ID provided and no personal space found.');
33
33
  }
@@ -56,7 +56,7 @@ export const spaceLayer = (
56
56
  }
57
57
  return spaceId$.pipe(
58
58
  Option.flatMap((id) => Option.fromNullable(client.spaces.get(id))),
59
- Option.orElse(() => Option.fromNullable(getPersonalSpace(client))),
59
+ Option.orElse(() => Option.fromNullable(AppSpace.getPersonalSpace(client))),
60
60
  Option.orElse(() => Option.fromNullable(client.spaces.get()[0])),
61
61
  );
62
62
  };
@@ -99,7 +99,7 @@ export const spaceLayer = (
99
99
  if (!space) {
100
100
  return Feed.notAvailable;
101
101
  }
102
- return createFeedServiceLayer(space.queues);
102
+ return createFeedServiceLayer(space.internal.db);
103
103
  }),
104
104
  );
105
105