@dxos/plugin-registry 0.9.0 → 0.9.1-staging.ee54ba693a

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 (145) hide show
  1. package/dist/lib/neutral/RegistryPlugin.node.mjs +709 -18
  2. package/dist/lib/neutral/RegistryPlugin.node.mjs.map +4 -4
  3. package/dist/lib/neutral/chunk-ZY37XLVF.mjs +46 -0
  4. package/dist/lib/neutral/chunk-ZY37XLVF.mjs.map +7 -0
  5. package/dist/lib/neutral/index.mjs +1 -1
  6. package/dist/lib/neutral/meta.json +1 -1
  7. package/dist/lib/neutral/{open-plugin-registry-II735N76.mjs → open-plugin-registry-ID7LV3SF.mjs} +3 -3
  8. package/dist/lib/neutral/open-plugin-registry-ID7LV3SF.mjs.map +7 -0
  9. package/dist/lib/neutral/plugin.mjs +2 -2
  10. package/dist/lib/neutral/translations.mjs +6 -6
  11. package/dist/lib/neutral/translations.mjs.map +3 -3
  12. package/dist/types/dx.config.d.ts +28 -0
  13. package/dist/types/dx.config.d.ts.map +1 -0
  14. package/dist/types/src/capabilities/app-graph-builder.d.ts +1 -2
  15. package/dist/types/src/capabilities/app-graph-builder.d.ts.map +1 -1
  16. package/dist/types/src/capabilities/index.d.ts +4 -12
  17. package/dist/types/src/capabilities/index.d.ts.map +1 -1
  18. package/dist/types/src/capabilities/react-surface.d.ts +2 -2
  19. package/dist/types/src/capabilities/react-surface.d.ts.map +1 -1
  20. package/dist/types/src/capabilities/settings.d.ts +1 -10
  21. package/dist/types/src/capabilities/settings.d.ts.map +1 -1
  22. package/dist/types/src/categories.d.ts.map +1 -1
  23. package/dist/types/src/commands/index.d.ts +2 -2
  24. package/dist/types/src/commands/index.d.ts.map +1 -1
  25. package/dist/types/src/commands/plugin/disable.d.ts.map +1 -0
  26. package/dist/types/src/commands/plugin/enable.d.ts.map +1 -0
  27. package/dist/types/src/commands/plugin/index.d.ts +3 -0
  28. package/dist/types/src/commands/plugin/index.d.ts.map +1 -0
  29. package/dist/types/src/commands/plugin/list.d.ts.map +1 -0
  30. package/dist/types/src/commands/plugin/util.d.ts.map +1 -0
  31. package/dist/types/src/commands/registry/index.d.ts +16 -0
  32. package/dist/types/src/commands/registry/index.d.ts.map +1 -0
  33. package/dist/types/src/commands/registry/publish-package.d.ts +29 -0
  34. package/dist/types/src/commands/registry/publish-package.d.ts.map +1 -0
  35. package/dist/types/src/commands/registry/publish-publisher.d.ts +19 -0
  36. package/dist/types/src/commands/registry/publish-publisher.d.ts.map +1 -0
  37. package/dist/types/src/commands/registry/publish.d.ts +25 -0
  38. package/dist/types/src/commands/registry/publish.d.ts.map +1 -0
  39. package/dist/types/src/commands/registry/records.d.ts +14 -0
  40. package/dist/types/src/commands/registry/records.d.ts.map +1 -0
  41. package/dist/types/src/commands/registry/unpublish.d.ts +15 -0
  42. package/dist/types/src/commands/registry/unpublish.d.ts.map +1 -0
  43. package/dist/types/src/commands/registry/util.d.ts +113 -0
  44. package/dist/types/src/commands/registry/util.d.ts.map +1 -0
  45. package/dist/types/src/commands/registry/verify.d.ts +20 -0
  46. package/dist/types/src/commands/registry/verify.d.ts.map +1 -0
  47. package/dist/types/src/components/PluginDetail/PluginDetail.d.ts +2 -2
  48. package/dist/types/src/components/PluginDetail/PluginDetail.d.ts.map +1 -1
  49. package/dist/types/src/components/PluginDetail/PluginDetail.stories.d.ts +6 -5
  50. package/dist/types/src/components/PluginDetail/PluginDetail.stories.d.ts.map +1 -1
  51. package/dist/types/src/components/PluginFailureBadge/PluginFailureBadge.d.ts +1 -3
  52. package/dist/types/src/components/PluginFailureBadge/PluginFailureBadge.d.ts.map +1 -1
  53. package/dist/types/src/components/PluginList/PluginItem.d.ts.map +1 -1
  54. package/dist/types/src/components/PluginList/PluginList.d.ts.map +1 -1
  55. package/dist/types/src/components/PluginList/PluginList.stories.d.ts +5 -5
  56. package/dist/types/src/components/PluginList/PluginList.stories.d.ts.map +1 -1
  57. package/dist/types/src/components/RegistrySettings/RegistrySettings.d.ts +5 -1
  58. package/dist/types/src/components/RegistrySettings/RegistrySettings.d.ts.map +1 -1
  59. package/dist/types/src/components/RegistrySettings/RegistrySettings.stories.d.ts +78 -0
  60. package/dist/types/src/components/RegistrySettings/RegistrySettings.stories.d.ts.map +1 -0
  61. package/dist/types/src/containers/LoadPluginDialog/LoadPluginDialog.d.ts.map +1 -1
  62. package/dist/types/src/containers/PluginArticle/PluginArticle.d.ts.map +1 -1
  63. package/dist/types/src/containers/PublicRegistryArticle/PublicRegistryArticle.d.ts.map +1 -1
  64. package/dist/types/src/containers/PublicRegistryArticle/index.d.ts +1 -1
  65. package/dist/types/src/containers/PublicRegistryArticle/index.d.ts.map +1 -1
  66. package/dist/types/src/containers/RegistryArticle/index.d.ts +1 -1
  67. package/dist/types/src/containers/RegistryArticle/index.d.ts.map +1 -1
  68. package/dist/types/src/hooks/useAutoTags.d.ts +2 -2
  69. package/dist/types/src/hooks/useAutoTags.d.ts.map +1 -1
  70. package/dist/types/src/hooks/useCatalogEntry.d.ts +4 -5
  71. package/dist/types/src/hooks/useCatalogEntry.d.ts.map +1 -1
  72. package/dist/types/src/hooks/usePluginActions.d.ts +3 -3
  73. package/dist/types/src/hooks/usePluginActions.d.ts.map +1 -1
  74. package/dist/types/src/hooks/useUpdateAvailable.d.ts +2 -2
  75. package/dist/types/src/hooks/useUpdateAvailable.d.ts.map +1 -1
  76. package/dist/types/src/hooks/useVersionPicker.d.ts +7 -8
  77. package/dist/types/src/hooks/useVersionPicker.d.ts.map +1 -1
  78. package/dist/types/src/meta.d.ts +32 -2
  79. package/dist/types/src/meta.d.ts.map +1 -1
  80. package/dist/types/src/operations/open-plugin-registry.d.ts.map +1 -1
  81. package/dist/types/src/translations.d.ts +5 -5
  82. package/dist/types/src/translations.d.ts.map +1 -1
  83. package/dist/types/src/types.d.ts.map +1 -1
  84. package/dist/types/tsconfig.tsbuildinfo +1 -1
  85. package/dx.config.ts +20 -0
  86. package/package.json +40 -33
  87. package/src/RegistryPlugin.node.ts +2 -2
  88. package/src/RegistryPlugin.test.ts +1 -1
  89. package/src/capabilities/app-graph-builder.ts +46 -41
  90. package/src/capabilities/dev-plugin-loader.ts +1 -1
  91. package/src/capabilities/react-surface.tsx +1 -4
  92. package/src/capabilities/settings.ts +2 -2
  93. package/src/categories.ts +5 -4
  94. package/src/commands/index.ts +2 -10
  95. package/src/commands/{disable.ts → plugin/disable.ts} +3 -3
  96. package/src/commands/{enable.ts → plugin/enable.ts} +3 -3
  97. package/src/commands/plugin/index.ts +14 -0
  98. package/src/commands/{list.ts → plugin/list.ts} +4 -4
  99. package/src/commands/registry/index.ts +30 -0
  100. package/src/commands/registry/publish-package.ts +119 -0
  101. package/src/commands/registry/publish-publisher.ts +73 -0
  102. package/src/commands/registry/publish.ts +278 -0
  103. package/src/commands/registry/records.ts +73 -0
  104. package/src/commands/registry/unpublish.ts +70 -0
  105. package/src/commands/registry/util.ts +395 -0
  106. package/src/commands/registry/verify.ts +76 -0
  107. package/src/components/DisableDependentsAlert/DisableDependentsAlert.tsx +1 -1
  108. package/src/components/PluginDetail/PluginDetail.stories.tsx +31 -15
  109. package/src/components/PluginDetail/PluginDetail.tsx +21 -23
  110. package/src/components/PluginFailureBadge/PluginFailureBadge.tsx +21 -24
  111. package/src/components/PluginList/PluginItem.tsx +16 -11
  112. package/src/components/PluginList/PluginList.stories.tsx +1 -2
  113. package/src/components/PluginList/PluginList.tsx +18 -13
  114. package/src/components/RegistrySettings/RegistrySettings.stories.tsx +45 -0
  115. package/src/components/RegistrySettings/RegistrySettings.tsx +44 -33
  116. package/src/containers/BaseRegistryArticle/BaseRegistryArticle.tsx +2 -2
  117. package/src/containers/LoadPluginDialog/LoadPluginDialog.tsx +3 -3
  118. package/src/containers/PluginArticle/PluginArticle.tsx +8 -6
  119. package/src/containers/PublicRegistryArticle/PublicRegistryArticle.tsx +35 -28
  120. package/src/containers/PublicRegistryArticle/index.ts +1 -1
  121. package/src/containers/RegistryArticle/RegistryArticle.tsx +1 -1
  122. package/src/containers/RegistryArticle/index.ts +1 -1
  123. package/src/containers/RegistrySettingsContainer/RegistrySettingsContainer.tsx +2 -2
  124. package/src/hooks/useAutoTags.ts +3 -3
  125. package/src/hooks/useCatalogEntry.ts +6 -7
  126. package/src/hooks/useDisableConfirmation.ts +1 -1
  127. package/src/hooks/usePluginActions.ts +13 -11
  128. package/src/hooks/useUpdateAvailable.ts +5 -5
  129. package/src/hooks/useVersionPicker.ts +22 -30
  130. package/src/meta.ts +6 -15
  131. package/src/operations/open-plugin-registry.ts +2 -2
  132. package/src/translations.ts +6 -6
  133. package/src/types.ts +3 -1
  134. package/dist/lib/neutral/chunk-GRQI6MLI.mjs +0 -36
  135. package/dist/lib/neutral/chunk-GRQI6MLI.mjs.map +0 -7
  136. package/dist/lib/neutral/open-plugin-registry-II735N76.mjs.map +0 -7
  137. package/dist/types/src/commands/disable.d.ts.map +0 -1
  138. package/dist/types/src/commands/enable.d.ts.map +0 -1
  139. package/dist/types/src/commands/list.d.ts.map +0 -1
  140. package/dist/types/src/commands/util.d.ts.map +0 -1
  141. /package/dist/types/src/commands/{disable.d.ts → plugin/disable.d.ts} +0 -0
  142. /package/dist/types/src/commands/{enable.d.ts → plugin/enable.d.ts} +0 -0
  143. /package/dist/types/src/commands/{list.d.ts → plugin/list.d.ts} +0 -0
  144. /package/dist/types/src/commands/{util.d.ts → plugin/util.d.ts} +0 -0
  145. /package/src/commands/{util.ts → plugin/util.ts} +0 -0
@@ -0,0 +1,395 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import * as HttpClient from '@effect/platform/HttpClient';
6
+ import * as HttpClientRequest from '@effect/platform/HttpClientRequest';
7
+ import type * as HttpClientResponse from '@effect/platform/HttpClientResponse';
8
+ import * as Config from 'effect/Config';
9
+ import * as Effect from 'effect/Effect';
10
+ import * as Option from 'effect/Option';
11
+ import * as Schema from 'effect/Schema';
12
+
13
+ import { AppSpace } from '@dxos/app-toolkit';
14
+ import { type Client } from '@dxos/client';
15
+ import { Filter } from '@dxos/echo';
16
+ import { ALL_NSIDS, NSID } from '@dxos/protocols';
17
+ import { AccessToken } from '@dxos/types';
18
+
19
+ export { ALL_NSIDS, NSID };
20
+
21
+ /**
22
+ * Shared helpers for the `dx registry *` commands.
23
+ *
24
+ * These commands write `org.dxos.experimental.*` records to a publisher's PDS via AT Protocol
25
+ * XRPC. Auth has two modes (see {@link resolveSession}):
26
+ *
27
+ * - **Personal-space (default):** when logged in (`dx account login`), the publisher's atproto
28
+ * session lives in their personal space as the "Atmosphere" `AccessToken`. Record writes are
29
+ * signed by Edge's DPoP proxy (`/atproto/proxy`) — no app password required.
30
+ * - **App password (fallback):** explicit `--handle` / `--app-password` (or `$ATPROTO_HANDLE` /
31
+ * `$ATPROTO_APP_PASSWORD`) authenticate directly against the PDS with a session token.
32
+ */
33
+
34
+ // `AccessToken.source` of the default atproto / login integration ("Atmosphere"). Mirrors
35
+ // `ATMOSPHERE_SOURCE` in plugin-connector; inlined to avoid a plugin-registry -> plugin-connector
36
+ // dependency.
37
+ const ATMOSPHERE_SOURCE = 'atproto.local';
38
+
39
+ // Public read-only XRPC base used for identity resolution. Works for any
40
+ // AT Protocol identity regardless of which PDS hosts it.
41
+ const BSKY_PUBLIC_API = 'https://public.api.bsky.app/xrpc';
42
+ const PLC_DIRECTORY = 'https://plc.directory';
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // Schemas (subset of XRPC response shapes we consume)
46
+ // ---------------------------------------------------------------------------
47
+
48
+ const CreateSessionResponseSchema = Schema.Struct({
49
+ did: Schema.String,
50
+ handle: Schema.String,
51
+ accessJwt: Schema.String,
52
+ refreshJwt: Schema.String,
53
+ });
54
+
55
+ const ResolveHandleResponseSchema = Schema.Struct({ did: Schema.String });
56
+
57
+ const DidServiceSchema = Schema.Struct({
58
+ id: Schema.String,
59
+ type: Schema.optional(Schema.String),
60
+ serviceEndpoint: Schema.Unknown,
61
+ });
62
+ const DidDocumentSchema = Schema.Struct({
63
+ service: Schema.optional(Schema.Array(DidServiceSchema)),
64
+ });
65
+
66
+ const PutRecordResponseSchema = Schema.Struct({
67
+ uri: Schema.String,
68
+ cid: Schema.String,
69
+ });
70
+ export type PutRecordResponse = Schema.Schema.Type<typeof PutRecordResponseSchema>;
71
+
72
+ const ListRecordsEntrySchema = Schema.Struct({
73
+ uri: Schema.String,
74
+ cid: Schema.String,
75
+ value: Schema.Unknown,
76
+ });
77
+ const ListRecordsResponseSchema = Schema.Struct({
78
+ cursor: Schema.optional(Schema.String),
79
+ records: Schema.Array(ListRecordsEntrySchema),
80
+ });
81
+ export type ListRecordsEntry = Schema.Schema.Type<typeof ListRecordsEntrySchema>;
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // XRPC primitives
85
+ // ---------------------------------------------------------------------------
86
+
87
+ const decodeJson =
88
+ <T>(schema: Schema.Schema<T>) =>
89
+ (response: HttpClientResponse.HttpClientResponse) =>
90
+ Effect.flatMap(response.json, Schema.decodeUnknown(schema));
91
+
92
+ /** GET an XRPC endpoint and decode the JSON response. */
93
+ const xrpcGet = <T>(url: string, query: Record<string, string>, schema: Schema.Schema<T>) =>
94
+ Effect.gen(function* () {
95
+ const client = yield* HttpClient.HttpClient;
96
+ return yield* HttpClientRequest.get(url).pipe(
97
+ HttpClientRequest.setUrlParams(query),
98
+ client.execute,
99
+ Effect.flatMap(decodeJson(schema)),
100
+ Effect.scoped,
101
+ );
102
+ });
103
+
104
+ /** POST a JSON body to an XRPC endpoint and decode the JSON response. */
105
+ const xrpcPost = <T>(
106
+ url: string,
107
+ body: Record<string, unknown>,
108
+ schema: Schema.Schema<T> | undefined,
109
+ headers: Record<string, string> = {},
110
+ ) =>
111
+ Effect.gen(function* () {
112
+ const client = yield* HttpClient.HttpClient;
113
+ const response = yield* HttpClientRequest.post(url).pipe(
114
+ HttpClientRequest.setHeaders(headers),
115
+ HttpClientRequest.bodyJson(body),
116
+ Effect.flatMap((req) => client.execute(req)),
117
+ Effect.scoped,
118
+ );
119
+ if (schema === undefined) {
120
+ return undefined as T;
121
+ }
122
+ return yield* decodeJson(schema)(response);
123
+ });
124
+
125
+ /** Resolve a handle (e.g. `alice.bsky.social`) to a DID via the public XRPC. */
126
+ export const resolveHandle = (handle: string) =>
127
+ xrpcGet(`${BSKY_PUBLIC_API}/com.atproto.identity.resolveHandle`, { handle }, ResolveHandleResponseSchema).pipe(
128
+ Effect.map((response) => response.did),
129
+ );
130
+
131
+ /** Resolve a DID to its PDS service endpoint via the PLC directory (or did:web well-known). */
132
+ export const resolvePds = (did: string) =>
133
+ Effect.gen(function* () {
134
+ let doc: Schema.Schema.Type<typeof DidDocumentSchema>;
135
+ if (did.startsWith('did:plc:')) {
136
+ doc = yield* xrpcGet(`${PLC_DIRECTORY}/${did}`, {}, DidDocumentSchema);
137
+ } else if (did.startsWith('did:web:')) {
138
+ const rest = did.slice('did:web:'.length);
139
+ const [host, ...segments] = rest.split(':').map((part) => decodeURIComponent(part));
140
+ const path = segments.length > 0 ? `/${segments.join('/')}/did.json` : '/.well-known/did.json';
141
+ doc = yield* xrpcGet(`https://${host}${path}`, {}, DidDocumentSchema);
142
+ } else {
143
+ return yield* Effect.fail(new Error(`Unsupported DID method: ${did}`));
144
+ }
145
+ const service = doc.service?.find(
146
+ (entry) => entry.id === '#atproto_pds' || entry.type === 'AtprotoPersonalDataServer',
147
+ );
148
+ const endpoint = service?.serviceEndpoint;
149
+ if (typeof endpoint !== 'string' || endpoint.length === 0) {
150
+ return yield* Effect.fail(new Error(`PDS endpoint missing in DID document for ${did}`));
151
+ }
152
+ return endpoint;
153
+ });
154
+
155
+ /** Resolve a handle or DID to its `{ did, pdsBaseUrl }`. */
156
+ const resolveRepo = (handleOrDid: string) =>
157
+ Effect.gen(function* () {
158
+ const did = handleOrDid.startsWith('did:') ? handleOrDid : yield* resolveHandle(handleOrDid);
159
+ const pdsBaseUrl = yield* resolvePds(did);
160
+ return { did, pdsBaseUrl };
161
+ });
162
+
163
+ // ---------------------------------------------------------------------------
164
+ // Session — an authenticated writer to a publisher's PDS repo.
165
+ // ---------------------------------------------------------------------------
166
+
167
+ /** Direct PDS auth via an app-password session token. */
168
+ type AppPasswordSession = {
169
+ readonly mode: 'app-password';
170
+ readonly did: string;
171
+ readonly handle: string;
172
+ readonly pdsBaseUrl: string;
173
+ readonly accessJwt: string;
174
+ };
175
+
176
+ /** Indirect auth: writes are DPoP-signed by Edge's `/atproto/proxy` using a personal-space token. */
177
+ type DpopProxySession = {
178
+ readonly mode: 'dpop-proxy';
179
+ readonly did: string;
180
+ readonly handle: string;
181
+ readonly pdsBaseUrl: string;
182
+ readonly edgeBaseUrl: string;
183
+ readonly spaceId: string;
184
+ readonly accessTokenId: string;
185
+ readonly token: string;
186
+ readonly authHeader: string | undefined;
187
+ };
188
+
189
+ export type Session = AppPasswordSession | DpopProxySession;
190
+
191
+ /**
192
+ * App-password session — call `com.atproto.server.createSession` against the caller's resolved
193
+ * PDS, then return the access JWT, DID, and PDS endpoint needed for subsequent writes.
194
+ */
195
+ export const createSession = (handle: string, appPassword: string) =>
196
+ Effect.gen(function* () {
197
+ // Issue the session against the resolved PDS — bsky.social's monolithic host won't accept
198
+ // logins for users that have been migrated to a shard.
199
+ const { did, pdsBaseUrl } = yield* resolveRepo(handle);
200
+ const response = yield* xrpcPost(
201
+ `${pdsBaseUrl}/xrpc/com.atproto.server.createSession`,
202
+ { identifier: handle, password: appPassword },
203
+ CreateSessionResponseSchema,
204
+ );
205
+ return {
206
+ mode: 'app-password',
207
+ did: response.did,
208
+ handle: response.handle,
209
+ accessJwt: response.accessJwt,
210
+ pdsBaseUrl,
211
+ } satisfies AppPasswordSession;
212
+ });
213
+
214
+ export type ResolveSessionOptions = {
215
+ /** `--handle` flag value (if any). */
216
+ handle: string | undefined;
217
+ /** `--app-password` flag value (if any). */
218
+ appPassword: string | undefined;
219
+ /** The DXOS client, used to read default credentials from the personal space. */
220
+ client: Client;
221
+ };
222
+
223
+ /**
224
+ * Resolve an authenticated {@link Session} for the registry commands, in precedence order:
225
+ *
226
+ * 1. Explicit `--handle` + `--app-password` (or `$ATPROTO_HANDLE` / `$ATPROTO_APP_PASSWORD`) →
227
+ * app-password session (works without a DXOS identity).
228
+ * 2. The logged-in identity's personal-space "Atmosphere" `AccessToken` → DPoP-proxy session.
229
+ * 3. A context-specific error guiding the user to log in / connect an integration / pass creds.
230
+ */
231
+ export const resolveSession = (options: ResolveSessionOptions) =>
232
+ Effect.gen(function* () {
233
+ const handle = options.handle ?? Option.getOrUndefined(yield* Config.option(Config.string('ATPROTO_HANDLE')));
234
+ const appPassword =
235
+ options.appPassword ?? Option.getOrUndefined(yield* Config.option(Config.string('ATPROTO_APP_PASSWORD')));
236
+ if (handle && appPassword) {
237
+ return yield* createSession(handle, appPassword);
238
+ }
239
+
240
+ const fromPersonalSpace = yield* resolvePersonalSpaceSession(options.client);
241
+ if (fromPersonalSpace) {
242
+ return fromPersonalSpace;
243
+ }
244
+
245
+ if (options.client.halo.identity.get()) {
246
+ return yield* Effect.fail(
247
+ new Error(
248
+ 'No atproto integration connected. Connect one with `dx integration add`, or pass --handle/--app-password. ' +
249
+ '(`dx account login` cannot add an integration to an existing identity.)',
250
+ ),
251
+ );
252
+ }
253
+ return yield* Effect.fail(
254
+ new Error('No DXOS identity. Run `dx account login` first, or pass --handle/--app-password.'),
255
+ );
256
+ });
257
+
258
+ /**
259
+ * Build a DPoP-proxy session from the personal space's "Atmosphere" `AccessToken`, or `undefined`
260
+ * if the user isn't logged in or hasn't connected an atproto integration.
261
+ */
262
+ const resolvePersonalSpaceSession = (client: Client) =>
263
+ Effect.gen(function* () {
264
+ const space = AppSpace.getPersonalSpace(client);
265
+ if (!space) {
266
+ return undefined;
267
+ }
268
+ const tokens = (yield* Effect.promise(() =>
269
+ space.db.query(Filter.type(AccessToken.AccessToken)).run(),
270
+ )) as AccessToken.AccessToken[];
271
+ const token = tokens.find((object) => object.source === ATMOSPHERE_SOURCE && !!object.account && !!object.token);
272
+ if (!token?.account) {
273
+ return undefined;
274
+ }
275
+ const { did, pdsBaseUrl } = yield* resolveRepo(token.account);
276
+ return {
277
+ mode: 'dpop-proxy',
278
+ did,
279
+ handle: token.account,
280
+ pdsBaseUrl,
281
+ edgeBaseUrl: client.edge.http.baseUrl,
282
+ spaceId: space.id,
283
+ accessTokenId: token.id,
284
+ token: token.token,
285
+ // Best-effort VP auth header for the proxy. The `/atproto/proxy` route currently skips auth
286
+ // in dev; production reads the caller's verifiable presentation when present.
287
+ authHeader: (client.edge.http as unknown as { _authHeader?: string })._authHeader,
288
+ } satisfies DpopProxySession;
289
+ });
290
+
291
+ // ---------------------------------------------------------------------------
292
+ // Record operations — auth-mode agnostic; branch on the session.
293
+ // ---------------------------------------------------------------------------
294
+
295
+ /**
296
+ * Proxy an XRPC call through Edge's `/atproto/proxy`, which attaches the DPoP proof signed with
297
+ * the key Edge stored at OAuth time. The proxy forwards `request` verbatim and returns the
298
+ * upstream PDS response body, so callers decode the same schema as the direct path.
299
+ */
300
+ const proxyCall = <T>(
301
+ session: DpopProxySession,
302
+ params: {
303
+ xrpcMethod: string;
304
+ method: 'GET' | 'POST';
305
+ query?: Record<string, string>;
306
+ jsonBody?: Record<string, unknown>;
307
+ },
308
+ schema: Schema.Schema<T> | undefined,
309
+ ) => {
310
+ const query = params.query ? `?${new URLSearchParams(params.query).toString()}` : '';
311
+ const innerHeaders: Record<string, string> = { Accept: 'application/json', Authorization: `DPoP ${session.token}` };
312
+ if (params.jsonBody) {
313
+ innerHeaders['Content-Type'] = 'application/json';
314
+ }
315
+ const outerHeaders: Record<string, string> = {};
316
+ if (session.authHeader) {
317
+ outerHeaders.Authorization = session.authHeader;
318
+ }
319
+ return xrpcPost(
320
+ `${session.edgeBaseUrl.replace(/\/$/, '')}/atproto/proxy`,
321
+ {
322
+ spaceId: session.spaceId,
323
+ accessTokenId: session.accessTokenId,
324
+ request: {
325
+ endpoint: `${session.pdsBaseUrl}/xrpc/${params.xrpcMethod}${query}`,
326
+ method: params.method,
327
+ headers: innerHeaders,
328
+ // Edge's proxy forwards the body to `fetch` as-is, so it must be a pre-serialized string.
329
+ body: params.jsonBody ? JSON.stringify(params.jsonBody) : null,
330
+ },
331
+ },
332
+ schema,
333
+ outerHeaders,
334
+ );
335
+ };
336
+
337
+ /** Write a record at `com.atproto.repo.putRecord` (idempotent on rkey). */
338
+ export const putRecord = (session: Session, collection: string, rkey: string, record: Record<string, unknown>) => {
339
+ const body = { repo: session.did, collection, rkey, record: { $type: collection, ...record } };
340
+ return session.mode === 'app-password'
341
+ ? xrpcPost(`${session.pdsBaseUrl}/xrpc/com.atproto.repo.putRecord`, body, PutRecordResponseSchema, {
342
+ Authorization: `Bearer ${session.accessJwt}`,
343
+ })
344
+ : proxyCall(
345
+ session,
346
+ { xrpcMethod: 'com.atproto.repo.putRecord', method: 'POST', jsonBody: body },
347
+ PutRecordResponseSchema,
348
+ );
349
+ };
350
+
351
+ /** Delete a record at `com.atproto.repo.deleteRecord`. */
352
+ export const deleteRecord = (session: Session, collection: string, rkey: string) => {
353
+ const body = { repo: session.did, collection, rkey };
354
+ return session.mode === 'app-password'
355
+ ? xrpcPost(`${session.pdsBaseUrl}/xrpc/com.atproto.repo.deleteRecord`, body, undefined, {
356
+ Authorization: `Bearer ${session.accessJwt}`,
357
+ })
358
+ : proxyCall(session, { xrpcMethod: 'com.atproto.repo.deleteRecord', method: 'POST', jsonBody: body }, undefined);
359
+ };
360
+
361
+ /**
362
+ * List records in a single collection on the authenticated repo. Paging is left to the caller (the
363
+ * four registry collections are tiny in practice); the cursor is surfaced on the return.
364
+ */
365
+ export const listRecords = (
366
+ session: Session,
367
+ collection: string,
368
+ options: { limit?: number; cursor?: string } = {},
369
+ ) => {
370
+ const query: Record<string, string> = {
371
+ repo: session.did,
372
+ collection,
373
+ ...(options.limit !== undefined ? { limit: String(options.limit) } : {}),
374
+ ...(options.cursor !== undefined ? { cursor: options.cursor } : {}),
375
+ };
376
+ return session.mode === 'app-password'
377
+ ? xrpcGet(`${session.pdsBaseUrl}/xrpc/com.atproto.repo.listRecords`, query, ListRecordsResponseSchema)
378
+ : proxyCall(
379
+ session,
380
+ { xrpcMethod: 'com.atproto.repo.listRecords', method: 'GET', query },
381
+ ListRecordsResponseSchema,
382
+ );
383
+ };
384
+
385
+ // ---------------------------------------------------------------------------
386
+ // Shared CLI option fragment for auth flags.
387
+ // ---------------------------------------------------------------------------
388
+
389
+ export const AUTH_OPTION_DESCRIPTIONS = {
390
+ handle:
391
+ 'AT Protocol handle (e.g. alice.bsky.social). Defaults to the logged-in identity; falls back to $ATPROTO_HANDLE.',
392
+ appPassword:
393
+ 'AT Protocol app password (https://bsky.app/settings/app-passwords). Falls back to $ATPROTO_APP_PASSWORD. ' +
394
+ 'Not needed when logged in via `dx account login`.',
395
+ } as const;
@@ -0,0 +1,76 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import * as Command from '@effect/cli/Command';
6
+ import * as Options from '@effect/cli/Options';
7
+ import * as FetchHttpClient from '@effect/platform/FetchHttpClient';
8
+ import * as Console from 'effect/Console';
9
+ import * as Effect from 'effect/Effect';
10
+ import * as Function from 'effect/Function';
11
+ import * as Option from 'effect/Option';
12
+
13
+ import { ClientService } from '@dxos/client';
14
+
15
+ import { AUTH_OPTION_DESCRIPTIONS, NSID, putRecord, resolveHandle, resolveSession } from './util';
16
+
17
+ /**
18
+ * `dx registry verify` — publishes a `org.dxos.experimental.publisher.verification`
19
+ * record on the authenticated repo, with rkey = subject DID. Subject is given as a
20
+ * handle or DID; handles are resolved through the public XRPC.
21
+ *
22
+ * Verification records are public and anyone may author them. The registry indexer only
23
+ * honors records authored by the configured verifier (`REGISTRY_CURATOR_DID`), so this
24
+ * command only affects discovery when run with that verifier's credentials.
25
+ */
26
+ export const verify = Command.make(
27
+ 'verify',
28
+ {
29
+ handle: Options.text('handle').pipe(Options.withDescription(AUTH_OPTION_DESCRIPTIONS.handle), Options.optional),
30
+ appPassword: Options.text('app-password').pipe(
31
+ Options.withDescription(AUTH_OPTION_DESCRIPTIONS.appPassword),
32
+ Options.optional,
33
+ ),
34
+ subject: Options.text('subject').pipe(Options.withDescription('Handle or DID of the publisher being verified.')),
35
+ subjectHandle: Options.text('subject-handle').pipe(
36
+ Options.withDescription(
37
+ 'Optional human-readable handle to record for the verified subject (defaults to --subject if it is a handle).',
38
+ ),
39
+ Options.optional,
40
+ ),
41
+ displayName: Options.text('display-name').pipe(
42
+ Options.withDescription('Display name to record for the verified subject.'),
43
+ ),
44
+ },
45
+ (options) =>
46
+ Function.pipe(
47
+ Effect.gen(function* () {
48
+ const client = yield* ClientService;
49
+ const session = yield* resolveSession({
50
+ handle: Option.getOrUndefined(options.handle),
51
+ appPassword: Option.getOrUndefined(options.appPassword),
52
+ client,
53
+ });
54
+
55
+ // Subject may be passed as either a handle or a DID; resolve to a DID so the rkey is stable.
56
+ const subjectDid = options.subject.startsWith('did:') ? options.subject : yield* resolveHandle(options.subject);
57
+ const subjectHandle =
58
+ Option.getOrUndefined(options.subjectHandle) ?? (options.subject.startsWith('did:') ? '' : options.subject);
59
+
60
+ const record = {
61
+ subject: subjectDid,
62
+ ...(subjectHandle ? { handle: subjectHandle } : {}),
63
+ displayName: options.displayName,
64
+ createdAt: new Date().toISOString(),
65
+ };
66
+
67
+ const result = yield* putRecord(session, NSID.PublisherVerification, subjectDid, record);
68
+ yield* Console.log(`Verified ${subjectDid} -> ${result.uri}`);
69
+ }),
70
+ Effect.provide(FetchHttpClient.layer),
71
+ ),
72
+ ).pipe(
73
+ Command.withDescription(
74
+ 'Write a publisher.verification record (indexed only if authored by the configured verifier, REGISTRY_CURATOR_DID).',
75
+ ),
76
+ );
@@ -36,7 +36,7 @@ export const DisableDependentsAlert = ({
36
36
  onResolvePluginName,
37
37
  onConfirm,
38
38
  }: DisableDependentsAlertProps) => {
39
- const { t } = useTranslation(meta.id);
39
+ const { t } = useTranslation(meta.profile.key);
40
40
  const resolveName = onResolvePluginName ?? ((id: string) => id);
41
41
  return (
42
42
  <AlertDialog.Content>
@@ -13,6 +13,23 @@ import { translations } from '#translations';
13
13
 
14
14
  import { PluginDetail } from './PluginDetail';
15
15
 
16
+ const plugin = Plugin.define(
17
+ Plugin.makeMeta({
18
+ key: DXN.make('com.example.plugin.test'),
19
+ name: 'Test Plugin',
20
+ author: 'DXOS',
21
+ description: random.lorem.paragraphs(2),
22
+ icon: { key: 'ph--bug--regular', hue: 'sky' },
23
+ homePage: 'https://example.com',
24
+ source: 'https://github.com/example/test-plugin',
25
+ screenshots: [
26
+ {
27
+ light: 'https://placehold.co/1728x990',
28
+ },
29
+ ],
30
+ }),
31
+ );
32
+
16
33
  const meta = {
17
34
  title: 'plugins/plugin-registry/components/PluginDetail',
18
35
  component: PluginDetail,
@@ -29,20 +46,19 @@ type Story = StoryObj<typeof meta>;
29
46
 
30
47
  export const Default: Story = {
31
48
  args: {
32
- plugin: Plugin.define(
33
- Plugin.makeMeta({
34
- key: DXN.make('com.example.plugin.test'),
35
- name: 'Test Plugin',
36
- author: 'DXOS',
37
- description: random.lorem.paragraphs(2),
38
- icon: 'ph--bug--regular',
39
- iconHue: 'sky',
40
- homePage: 'https://example.com',
41
- source: 'https://github.com/example/test-plugin',
42
- screenshots: [
43
- 'https://media.gcflearnfree.org/content/55e073de7dd48174331f51b3_01_17_2014/getting_started_interactive2.png',
44
- ],
45
- }),
46
- ).pipe(Plugin.make)(),
49
+ plugin: plugin.pipe(Plugin.make)(),
50
+ },
51
+ };
52
+
53
+ export const Failure: Story = {
54
+ args: {
55
+ plugin: plugin.pipe(Plugin.make)(),
56
+ failure: {
57
+ id: 'com.example.plugin.test',
58
+ phase: 'activation',
59
+ reason: 'error',
60
+ error: new Error(random.lorem.paragraph(2)),
61
+ timestamp: Date.now(),
62
+ },
47
63
  },
48
64
  };
@@ -4,7 +4,7 @@
4
4
 
5
5
  import React, { type PropsWithChildren } from 'react';
6
6
 
7
- import { type Plugin, type PluginManager, type Registry } from '@dxos/app-framework';
7
+ import { type Plugin, type PluginManager } from '@dxos/app-framework';
8
8
  import {
9
9
  Button,
10
10
  Carousel,
@@ -15,6 +15,7 @@ import {
15
15
  Select,
16
16
  Tag,
17
17
  ThemedClassName,
18
+ useThemeContext,
18
19
  useTranslation,
19
20
  } from '@dxos/react-ui';
20
21
  import { composable, composableProps } from '@dxos/react-ui';
@@ -39,7 +40,7 @@ export type PluginDetailProps = {
39
40
  /** Currently selected version tag in the picker. */
40
41
  selectedVersionTag?: string;
41
42
  /** Available versions of this plugin from the catalog. When non-empty, a version picker is shown. */
42
- versions?: readonly Registry.PluginVersion[];
43
+ versions?: readonly Plugin.Release[];
43
44
  /**
44
45
  * Ids of plugins this plugin declares as dependencies (direct only). Rendered
45
46
  * under a "Requires" heading so the user can see what enabling this plugin
@@ -129,20 +130,17 @@ export const PluginDetail = composable<HTMLDivElement, PluginDetailProps>(
129
130
  },
130
131
  forwardedRef,
131
132
  ) => {
132
- const { t } = useTranslation(meta.id);
133
- const {
134
- id,
135
- name,
136
- author,
137
- description,
138
- homePage,
139
- source,
140
- screenshots,
141
- icon = 'ph--circle--regular',
142
- iconHue = 'neutral',
143
- } = plugin.meta;
133
+ const { t } = useTranslation(meta.profile.key);
134
+ const { themeMode } = useThemeContext();
135
+ const { key: slug, name, author, description, homePage, source, screenshots, icon: rawIcon } = plugin.meta.profile;
136
+ const iconKey = rawIcon?.key ?? 'ph--circle--regular';
137
+ const iconHue = rawIcon?.hue ?? 'neutral';
144
138
  const styles = getStyles(iconHue);
145
139
 
140
+ const resolvedScreenshots = (screenshots ?? [])
141
+ .map((entry) => (themeMode === 'dark' ? (entry.dark ?? entry.light) : (entry.light ?? entry.dark)))
142
+ .filter((url): url is string => typeof url === 'string' && url.length > 0);
143
+
146
144
  return (
147
145
  <ScrollArea.Root {...composableProps(props)} orientation='vertical' ref={forwardedRef}>
148
146
  <ScrollArea.Viewport>
@@ -156,7 +154,7 @@ export const PluginDetail = composable<HTMLDivElement, PluginDetailProps>(
156
154
  * in col 3.
157
155
  */}
158
156
  <div className='dx-document grid grid-cols-[4rem_minmax(0,1fr)_4rem] gap-x-4 p-4 items-start'>
159
- <Icon classNames={mx('row-start-1 p-1 rounded-md', styles.bg, styles.fg)} icon={icon} size={14} />
157
+ <Icon classNames={mx('row-start-1 p-1 rounded-md', styles.bg, styles.fg)} icon={iconKey} size={14} />
160
158
 
161
159
  <div className='row-start-1 col-start-2 col-span-2 grid grid-cols-[1fr_min-content] gap-x-3 w-full pt-1'>
162
160
  <div className='flex items-center gap-2'>
@@ -173,7 +171,7 @@ export const PluginDetail = composable<HTMLDivElement, PluginDetailProps>(
173
171
  </Input.Root>
174
172
  )}
175
173
  <div className='flex items-center gap-1 pt-0.5 text-sm text-description'>
176
- {id}
174
+ {slug}
177
175
  {author && <span className='dx-tag dx-tag--info'>{author}</span>}
178
176
  </div>
179
177
  </div>
@@ -187,14 +185,14 @@ export const PluginDetail = composable<HTMLDivElement, PluginDetailProps>(
187
185
  </Section.Root>
188
186
  )}
189
187
 
190
- {screenshots && screenshots.length > 0 && (
188
+ {resolvedScreenshots.length > 0 && (
191
189
  <Section.Root>
192
190
  <Section.Heading title={t('preview.label')} />
193
191
  <Section.Body>
194
- <Carousel.Root count={screenshots.length}>
192
+ <Carousel.Root count={resolvedScreenshots.length}>
195
193
  <Carousel.Content classNames='contents'>
196
194
  <Carousel.Viewport>
197
- {screenshots.map((src, index) => (
195
+ {resolvedScreenshots.map((src, index) => (
198
196
  <Carousel.Slide key={src} index={index} src={src} alt={name} />
199
197
  ))}
200
198
  </Carousel.Viewport>
@@ -223,7 +221,7 @@ export const PluginDetail = composable<HTMLDivElement, PluginDetailProps>(
223
221
  </Link>
224
222
  )}
225
223
 
226
- {onOpenSpec && <Chip id={id} name={t('open-spec.label')} onClick={onOpenSpec} />}
224
+ {onOpenSpec && <Chip id={slug} name={t('open-spec.label')} onClick={onOpenSpec} />}
227
225
  </div>
228
226
  </Section.Body>
229
227
  </Section.Root>
@@ -275,9 +273,9 @@ export const PluginDetail = composable<HTMLDivElement, PluginDetailProps>(
275
273
  <Select.Content>
276
274
  <Select.Viewport>
277
275
  {versions.map((versionEntry) => (
278
- <Select.Option key={versionEntry.tag} value={versionEntry.tag}>
279
- {versionEntry.tag}
280
- {installedVersionTag === versionEntry.tag ? ` (${t('installed.label')})` : ''}
276
+ <Select.Option key={versionEntry.version} value={versionEntry.version}>
277
+ {versionEntry.version}
278
+ {installedVersionTag === versionEntry.version ? ` (${t('installed.label')})` : ''}
281
279
  </Select.Option>
282
280
  ))}
283
281
  </Select.Viewport>