@dxos/edge-client 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 (56) hide show
  1. package/dist/lib/neutral/chunk-J5LGTIGS.mjs +10 -0
  2. package/dist/lib/neutral/chunk-J5LGTIGS.mjs.map +7 -0
  3. package/dist/lib/neutral/cors-proxy.mjs +1 -0
  4. package/dist/lib/neutral/edge-ws-muxer.mjs +1 -0
  5. package/dist/lib/neutral/index.mjs +111 -35
  6. package/dist/lib/neutral/index.mjs.map +3 -3
  7. package/dist/lib/neutral/meta.json +1 -1
  8. package/dist/lib/neutral/service/index.mjs +134 -0
  9. package/dist/lib/neutral/service/index.mjs.map +7 -0
  10. package/dist/lib/neutral/testing/index.mjs +2 -1
  11. package/dist/lib/neutral/testing/index.mjs.map +3 -3
  12. package/dist/types/src/auth.d.ts.map +1 -1
  13. package/dist/types/src/browser-rendering.d.ts +28 -0
  14. package/dist/types/src/browser-rendering.d.ts.map +1 -0
  15. package/dist/types/src/edge-ai-http-client.d.ts +39 -0
  16. package/dist/types/src/edge-ai-http-client.d.ts.map +1 -1
  17. package/dist/types/src/edge-ai-http-client.test.d.ts +2 -0
  18. package/dist/types/src/edge-ai-http-client.test.d.ts.map +1 -0
  19. package/dist/types/src/edge-client.d.ts +3 -2
  20. package/dist/types/src/edge-client.d.ts.map +1 -1
  21. package/dist/types/src/edge-http-client.d.ts +19 -4
  22. package/dist/types/src/edge-http-client.d.ts.map +1 -1
  23. package/dist/types/src/edge-identity.d.ts +5 -1
  24. package/dist/types/src/edge-identity.d.ts.map +1 -1
  25. package/dist/types/src/hub-http-client.d.ts +8 -1
  26. package/dist/types/src/hub-http-client.d.ts.map +1 -1
  27. package/dist/types/src/service/Image.d.ts +25 -0
  28. package/dist/types/src/service/Image.d.ts.map +1 -0
  29. package/dist/types/src/service/edge-service.d.ts +36 -0
  30. package/dist/types/src/service/edge-service.d.ts.map +1 -0
  31. package/dist/types/src/service/edge-service.test.d.ts +2 -0
  32. package/dist/types/src/service/edge-service.test.d.ts.map +1 -0
  33. package/dist/types/src/service/image-service.e2e.test.d.ts +2 -0
  34. package/dist/types/src/service/image-service.e2e.test.d.ts.map +1 -0
  35. package/dist/types/src/service/index.d.ts +3 -0
  36. package/dist/types/src/service/index.d.ts.map +1 -0
  37. package/dist/types/tsconfig.tsbuildinfo +1 -1
  38. package/package.json +22 -17
  39. package/src/auth.ts +6 -6
  40. package/src/base-http-client.ts +1 -1
  41. package/src/browser-rendering.ts +39 -0
  42. package/src/edge-ai-http-client.test.ts +37 -0
  43. package/src/edge-ai-http-client.ts +87 -2
  44. package/src/edge-client.test.ts +1 -1
  45. package/src/edge-client.ts +9 -7
  46. package/src/edge-http-client.ts +25 -10
  47. package/src/edge-identity.ts +5 -1
  48. package/src/edge-ws-connection.ts +1 -1
  49. package/src/edge-ws-muxer.test.ts +1 -1
  50. package/src/hub-http-client.ts +20 -0
  51. package/src/service/Image.ts +61 -0
  52. package/src/service/edge-service.test.ts +151 -0
  53. package/src/service/edge-service.ts +139 -0
  54. package/src/service/image-service.e2e.test.ts +53 -0
  55. package/src/service/index.ts +7 -0
  56. package/src/testing/test-utils.ts +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dxos/edge-client",
3
- "version": "0.9.0",
3
+ "version": "0.9.1-staging.ee54ba693a",
4
4
  "description": "EDGE Client",
5
5
  "homepage": "https://dxos.org",
6
6
  "bugs": "https://github.com/dxos/dxos/issues",
@@ -28,6 +28,11 @@
28
28
  "types": "./dist/types/src/edge-ws-muxer.d.ts",
29
29
  "default": "./dist/lib/neutral/edge-ws-muxer.mjs"
30
30
  },
31
+ "./service": {
32
+ "source": "./src/service/index.ts",
33
+ "types": "./dist/types/src/service/index.d.ts",
34
+ "default": "./dist/lib/neutral/service/index.mjs"
35
+ },
31
36
  "./testing": {
32
37
  "source": "./src/testing/index.ts",
33
38
  "types": "./dist/types/src/testing/index.d.ts",
@@ -41,29 +46,29 @@
41
46
  "README.md"
42
47
  ],
43
48
  "dependencies": {
44
- "@effect/platform": "0.96.1",
49
+ "@effect/platform": "0.96.2",
45
50
  "@opentelemetry/api": "^1.9.1",
46
51
  "isomorphic-ws": "^5.0.0",
47
52
  "ws": "^8.17.1",
48
- "@dxos/async": "0.9.0",
49
- "@dxos/context": "0.9.0",
50
- "@dxos/credentials": "0.9.0",
51
- "@dxos/crypto": "0.9.0",
52
- "@dxos/effect": "0.9.0",
53
- "@dxos/errors": "0.9.0",
54
- "@dxos/invariant": "0.9.0",
55
- "@dxos/keyring": "0.9.0",
56
- "@dxos/keys": "0.9.0",
57
- "@dxos/log": "0.9.0",
58
- "@dxos/util": "0.9.0",
59
- "@dxos/node-std": "0.9.0",
60
- "@dxos/protocols": "0.9.0"
53
+ "@dxos/async": "0.9.1-staging.ee54ba693a",
54
+ "@dxos/context": "0.9.1-staging.ee54ba693a",
55
+ "@dxos/crypto": "0.9.1-staging.ee54ba693a",
56
+ "@dxos/effect": "0.9.1-staging.ee54ba693a",
57
+ "@dxos/invariant": "0.9.1-staging.ee54ba693a",
58
+ "@dxos/credentials": "0.9.1-staging.ee54ba693a",
59
+ "@dxos/errors": "0.9.1-staging.ee54ba693a",
60
+ "@dxos/keyring": "0.9.1-staging.ee54ba693a",
61
+ "@dxos/keys": "0.9.1-staging.ee54ba693a",
62
+ "@dxos/log": "0.9.1-staging.ee54ba693a",
63
+ "@dxos/node-std": "0.9.1-staging.ee54ba693a",
64
+ "@dxos/util": "0.9.1-staging.ee54ba693a",
65
+ "@dxos/protocols": "0.9.1-staging.ee54ba693a"
61
66
  },
62
67
  "devDependencies": {
63
- "@dxos/test-utils": "0.9.0"
68
+ "@dxos/test-utils": "0.9.1-staging.ee54ba693a"
64
69
  },
65
70
  "peerDependencies": {
66
- "effect": "3.21.3"
71
+ "effect": "3.21.4"
67
72
  },
68
73
  "publishConfig": {
69
74
  "access": "public"
package/src/auth.ts CHANGED
@@ -2,11 +2,11 @@
2
2
  // Copyright 2024 DXOS.org
3
3
  //
4
4
 
5
- import { createCredential, signPresentation } from '@dxos/credentials';
5
+ import { createCredential, createDidFromIdentityKey, signPresentation } from '@dxos/credentials';
6
6
  import { type Signer } from '@dxos/crypto';
7
7
  import { invariant } from '@dxos/invariant';
8
8
  import { Keyring } from '@dxos/keyring';
9
- import { PublicKey } from '@dxos/keys';
9
+ import { IdentityDid, PublicKey } from '@dxos/keys';
10
10
  import { type Chain, type Credential } from '@dxos/protocols/proto/dxos/halo/credentials';
11
11
 
12
12
  import type { EdgeIdentity } from './edge-identity';
@@ -16,7 +16,7 @@ import type { EdgeIdentity } from './edge-identity';
16
16
  */
17
17
  export const createDeviceEdgeIdentity = async (signer: Signer, key: PublicKey): Promise<EdgeIdentity> => {
18
18
  return {
19
- identityKey: key.toHex(),
19
+ identityDid: await createDidFromIdentityKey(key),
20
20
  peerKey: key.toHex(),
21
21
  presentCredentials: async ({ challenge }) => {
22
22
  return signPresentation({
@@ -68,7 +68,7 @@ export const createChainEdgeIdentity = async (
68
68
  ];
69
69
 
70
70
  return {
71
- identityKey: identityKey.toHex(),
71
+ identityDid: await createDidFromIdentityKey(identityKey),
72
72
  peerKey: peerKey.toHex(),
73
73
  presentCredentials: async ({ challenge }) => {
74
74
  // TODO: make chain required after device invitation flow update release
@@ -126,10 +126,10 @@ export const createTestHaloEdgeIdentity = async (
126
126
  };
127
127
 
128
128
  export const createStubEdgeIdentity = (): EdgeIdentity => {
129
- const identityKey = PublicKey.random();
130
129
  const deviceKey = PublicKey.random();
131
130
  return {
132
- identityKey: identityKey.toHex(),
131
+ // Random placeholder DID — the stub never authenticates or connects; a real identity replaces it.
132
+ identityDid: IdentityDid.random(),
133
133
  peerKey: deviceKey.toHex(),
134
134
  presentCredentials: async () => {
135
135
  throw new Error('Stub identity does not support authentication.');
@@ -71,7 +71,7 @@ export abstract class BaseHttpClient {
71
71
  }
72
72
 
73
73
  setIdentity(identity: EdgeIdentity): void {
74
- if (this._edgeIdentity?.identityKey !== identity.identityKey || this._edgeIdentity?.peerKey !== identity.peerKey) {
74
+ if (this._edgeIdentity?.identityDid !== identity.identityDid || this._edgeIdentity?.peerKey !== identity.peerKey) {
75
75
  this._edgeIdentity = identity;
76
76
  this._authHeader = undefined;
77
77
  }
@@ -0,0 +1,39 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import * as Context from 'effect/Context';
6
+ import * as Schema from 'effect/Schema';
7
+
8
+ import { type EdgeHttpClient } from './edge-http-client';
9
+
10
+ /** Request body for EDGE `/ai/browser-rendering/markdown` (ai-service Browser Run markdown quick action). */
11
+ export const MarkdownRequest = Schema.Struct({
12
+ url: Schema.optional(Schema.String),
13
+ html: Schema.optional(Schema.String),
14
+ gotoOptions: Schema.optional(
15
+ Schema.Struct({
16
+ waitUntil: Schema.optional(Schema.Literal('load', 'domcontentloaded', 'networkidle0', 'networkidle2')),
17
+ timeout: Schema.optional(Schema.Number),
18
+ }),
19
+ ),
20
+ rejectRequestPattern: Schema.optional(Schema.Array(Schema.String)),
21
+ userAgent: Schema.optional(Schema.String),
22
+ waitForSelector: Schema.optional(Schema.String),
23
+ });
24
+
25
+ export type MarkdownRequest = Schema.Schema.Type<typeof MarkdownRequest>;
26
+
27
+ /** JSON body returned by Cloudflare Browser Run markdown quick action. */
28
+ export const MarkdownResponse = Schema.Struct({
29
+ success: Schema.Boolean,
30
+ result: Schema.String,
31
+ });
32
+
33
+ export type MarkdownResponse = Schema.Schema.Type<typeof MarkdownResponse>;
34
+
35
+ /** Authenticated EDGE HTTP client for operation handlers (e.g. browser markdown fetch). */
36
+ export class EdgeHttpClientService extends Context.Tag('@dxos/edge-client/EdgeHttpClientService')<
37
+ EdgeHttpClientService,
38
+ EdgeHttpClient
39
+ >() {}
@@ -0,0 +1,37 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { describe, test } from 'vitest';
6
+
7
+ import { patchAnthropicMessagesRequestBody } from './edge-ai-http-client';
8
+
9
+ describe('patchAnthropicMessagesRequestBody', () => {
10
+ test('adds eager_input_streaming to user-defined tools', ({ expect }) => {
11
+ const body = JSON.stringify({
12
+ model: 'claude-sonnet-4-6',
13
+ stream: true,
14
+ tools: [
15
+ {
16
+ name: 'delegate-task',
17
+ description: 'Delegate work',
18
+ input_schema: { type: 'object', properties: { title: { type: 'string' } } },
19
+ },
20
+ {
21
+ name: 'bash',
22
+ type: 'bash_20250124',
23
+ },
24
+ ],
25
+ });
26
+
27
+ const patched = JSON.parse(patchAnthropicMessagesRequestBody(body) as string);
28
+
29
+ expect(patched.tools[0]).toMatchObject({ eager_input_streaming: true });
30
+ expect(patched.tools[1]).not.toHaveProperty('eager_input_streaming');
31
+ });
32
+
33
+ test('leaves non-json bodies unchanged', ({ expect }) => {
34
+ const body = 'not-json';
35
+ expect(patchAnthropicMessagesRequestBody(body)).toBe(body);
36
+ });
37
+ });
@@ -31,11 +31,72 @@ export class ByokError extends BaseError.extend('ByokError', 'BYOK authenticatio
31
31
  }
32
32
  }
33
33
 
34
+ /**
35
+ * Thrown by {@link EdgeAiHttpClient} when EDGE rejects an AI request with 429 because the
36
+ * authenticated profile exceeded a metering limit. Wrapped as the `cause` of an
37
+ * {@link HttpClientError.ResponseError} so it survives `@effect/ai`'s error mapping.
38
+ */
39
+ export class UsageQuotaExceededError extends BaseError.extend('UsageQuotaExceededError', 'Usage quota exceeded') {}
40
+
34
41
  /**
35
42
  * Copy pasted from https://github.com/Effect-TS/effect/blob/main/packages/platform/src/internal/fetchHttpClient.ts
36
43
  */
37
44
  export const requestInitTagKey = '@effect/platform/FetchHttpClient/FetchOptions';
38
45
 
46
+ type AnthropicMessagesPayload = {
47
+ tools?: ReadonlyArray<Record<string, unknown>>;
48
+ };
49
+
50
+ const isUserDefinedAnthropicTool = (tool: Record<string, unknown>): boolean =>
51
+ tool.input_schema != null && typeof tool.input_schema === 'object';
52
+
53
+ /**
54
+ * Enables Anthropic fine-grained tool input streaming for client-defined tools.
55
+ * Provider tools (bash, web_search, etc.) are left unchanged.
56
+ */
57
+ export const patchAnthropicMessagesRequestBody = (body: BodyInit | undefined): BodyInit | undefined => {
58
+ if (body == null) {
59
+ return body;
60
+ }
61
+
62
+ const decodeBody = (): string | undefined => {
63
+ if (typeof body === 'string') {
64
+ return body;
65
+ }
66
+ if (body instanceof Uint8Array) {
67
+ return new TextDecoder().decode(body);
68
+ }
69
+ return undefined;
70
+ };
71
+
72
+ const text = decodeBody();
73
+ if (text == null) {
74
+ return body;
75
+ }
76
+
77
+ try {
78
+ const payload = JSON.parse(text) as AnthropicMessagesPayload;
79
+ if (!Array.isArray(payload.tools)) {
80
+ return body;
81
+ }
82
+
83
+ payload.tools = payload.tools.map((tool) =>
84
+ isUserDefinedAnthropicTool(tool) ? { ...tool, eager_input_streaming: true } : tool,
85
+ );
86
+
87
+ const patched = JSON.stringify(payload);
88
+ return typeof body === 'string' ? patched : new TextEncoder().encode(patched);
89
+ } catch {
90
+ return body;
91
+ }
92
+ };
93
+
94
+ const readStreamBody = (stream: ReadableStream<Uint8Array>): Effect.Effect<string | undefined> =>
95
+ Effect.promise(async () => {
96
+ const response = new Response(stream);
97
+ return await response.text();
98
+ });
99
+
39
100
  /**
40
101
  * An `@effect/platform` {@link HttpClient.HttpClient} that routes requests through the
41
102
  * authenticated EDGE AI endpoint via {@link EdgeHttpClient.anthropicAiRequest}, instead of
@@ -67,7 +128,7 @@ export class EdgeAiHttpClient {
67
128
  ...options,
68
129
  method: request.method,
69
130
  headers,
70
- body,
131
+ body: patchAnthropicMessagesRequestBody(body),
71
132
  signal,
72
133
  }),
73
134
  ),
@@ -107,6 +168,27 @@ export class EdgeAiHttpClient {
107
168
  ),
108
169
  );
109
170
  }
171
+ // Platform quota (not BYOK): reserve/commit rejected the request before upstream AI.
172
+ if (!carriedByok && response.status === 429) {
173
+ return Effect.tryPromise({
174
+ try: () => response.clone().json() as Promise<{ error?: { message?: string } } | undefined>,
175
+ catch: () => undefined,
176
+ }).pipe(
177
+ Effect.orElseSucceed(() => undefined),
178
+ Effect.flatMap((body) =>
179
+ Effect.fail(
180
+ new HttpClientError.ResponseError({
181
+ request,
182
+ response: httpResponse,
183
+ reason: 'StatusCode',
184
+ cause: new UsageQuotaExceededError({
185
+ message: body?.error?.message,
186
+ }),
187
+ }),
188
+ ),
189
+ ),
190
+ );
191
+ }
110
192
  return Effect.succeed(httpResponse);
111
193
  }),
112
194
  );
@@ -118,7 +200,10 @@ export class EdgeAiHttpClient {
118
200
  case 'FormData':
119
201
  return send(request.body.formData);
120
202
  case 'Stream':
121
- return Stream.toReadableStreamEffect(request.body.stream).pipe(Effect.flatMap(send));
203
+ return Stream.toReadableStreamEffect(request.body.stream).pipe(
204
+ Effect.flatMap((readable) => readStreamBody(readable)),
205
+ Effect.flatMap((text) => send(text)),
206
+ );
122
207
  }
123
208
 
124
209
  return send(undefined);
@@ -158,7 +158,7 @@ describe('EdgeClient', () => {
158
158
 
159
159
  const textMessage = (message: string, source?: EdgeIdentity) =>
160
160
  protocol.createMessage(TextMessageSchema, {
161
- source: source && { peerKey: source.peerKey, identityKey: source.identityKey },
161
+ source: source && { peerKey: source.peerKey, identityDid: source.identityDid },
162
162
  payload: { message },
163
163
  });
164
164
 
@@ -43,7 +43,8 @@ export type MessengerConfig = {
43
43
  export interface EdgeConnection extends Required<Lifecycle> {
44
44
  statusChanged: Event<EdgeStatus>;
45
45
  get info(): any;
46
- get identityKey(): string;
46
+ /** Identity DID (`did:halo:…`) of the connected identity. */
47
+ get identityDid(): string;
47
48
  get peerKey(): string;
48
49
  get isOpen(): boolean;
49
50
  get status(): EdgeStatus;
@@ -88,7 +89,7 @@ export class EdgeClient extends Resource implements EdgeConnection {
88
89
  return {
89
90
  open: this.isOpen,
90
91
  status: this.status,
91
- identity: this._identity.identityKey,
92
+ identity: this._identity.identityDid,
92
93
  device: this._identity.peerKey,
93
94
  };
94
95
  }
@@ -108,8 +109,8 @@ export class EdgeClient extends Resource implements EdgeConnection {
108
109
  };
109
110
  }
110
111
 
111
- get identityKey() {
112
- return this._identity.identityKey;
112
+ get identityDid() {
113
+ return this._identity.identityDid;
113
114
  }
114
115
 
115
116
  get peerKey() {
@@ -117,7 +118,7 @@ export class EdgeClient extends Resource implements EdgeConnection {
117
118
  }
118
119
 
119
120
  setIdentity(identity: EdgeIdentity) {
120
- if (identity.identityKey !== this._identity.identityKey || identity.peerKey !== this._identity.peerKey) {
121
+ if (identity.identityDid !== this._identity.identityDid || identity.peerKey !== this._identity.peerKey) {
121
122
  log('Edge identity changed', { identity, oldIdentity: this._identity });
122
123
  this._identity = identity;
123
124
  this._closeCurrentConnection(new EdgeIdentityChangedError());
@@ -139,9 +140,10 @@ export class EdgeClient extends Resource implements EdgeConnection {
139
140
  throw new EdgeConnectionClosedError();
140
141
  }
141
142
 
143
+ // DX-1059: sources are DID-only; validate against identityDid.
142
144
  if (
143
145
  message.source &&
144
- (message.source.peerKey !== this._identity.peerKey || message.source.identityKey !== this.identityKey)
146
+ (message.source.peerKey !== this._identity.peerKey || message.source.identityDid !== this.identityDid)
145
147
  ) {
146
148
  throw new EdgeIdentityChangedError();
147
149
  }
@@ -219,7 +221,7 @@ export class EdgeClient extends Resource implements EdgeConnection {
219
221
  }
220
222
 
221
223
  const identity = this._identity;
222
- const path = `/ws/${identity.identityKey}/${identity.peerKey}`;
224
+ const path = `/ws/${identity.identityDid}/${identity.peerKey}`;
223
225
  const protocolHeader = this._config.disableAuth ? undefined : await this._createAuthHeader(path);
224
226
  if (this._identity !== identity) {
225
227
  log('identity changed during auth header request');
@@ -11,7 +11,7 @@ import * as Function from 'effect/Function';
11
11
  import { type Context } from '@dxos/context';
12
12
  import { EffectEx } from '@dxos/effect';
13
13
  import { invariant } from '@dxos/invariant';
14
- import { type PublicKey, type SpaceId } from '@dxos/keys';
14
+ import { type SpaceId } from '@dxos/keys';
15
15
  import { log } from '@dxos/log';
16
16
  import {
17
17
  type CompleteOAuthRegistrationRequest,
@@ -28,7 +28,6 @@ import {
28
28
  type FeedProtocol,
29
29
  type GetAgentStatusResponseBody,
30
30
  type GetNotarizationResponseBody,
31
- type GetPluginVersionsResponseBody,
32
31
  type GetPluginsResponseBody,
33
32
  type ImportBundleRequest,
34
33
  type InitiateOAuthFlowRequest,
@@ -63,6 +62,12 @@ export type EdgeQueryQueueResponse = {
63
62
  prevCursor?: string;
64
63
  };
65
64
 
65
+ export type UploadPluginBundleRequest = {
66
+ slug: string;
67
+ version: string;
68
+ files: { path: string; content: string }[];
69
+ };
70
+
66
71
  export type TriggersDispatcherStatus = {
67
72
  isActive: boolean;
68
73
  nextCronTaskRunTimestamp?: number;
@@ -112,10 +117,10 @@ export class EdgeHttpClient extends BaseHttpClient {
112
117
 
113
118
  public getAgentStatus(
114
119
  ctx: Context,
115
- request: { ownerIdentityKey: PublicKey },
120
+ request: { ownerIdentityDid: string },
116
121
  args?: EdgeHttpCallArgs,
117
122
  ): Promise<GetAgentStatusResponseBody> {
118
- return this._call(ctx, new URL(`/users/${request.ownerIdentityKey.toHex()}/agent/status`, this.baseUrl), {
123
+ return this._call(ctx, new URL(`/users/${request.ownerIdentityDid}/agent/status`, this.baseUrl), {
119
124
  ...args,
120
125
  method: 'GET',
121
126
  });
@@ -266,7 +271,10 @@ export class EdgeHttpClient extends BaseHttpClient {
266
271
  const formData = new FormData();
267
272
  formData.append('name', body.name ?? '');
268
273
  formData.append('version', body.version);
269
- formData.append('ownerPublicKey', body.ownerPublicKey);
274
+ // The function owner is the authenticated identity (edge requires ownerUri === presenter DID).
275
+ // Prefer the connected identity's DID; otherwise use the DID supplied on the request body.
276
+ const ownerUri = this._edgeIdentity?.identityDid ?? body.ownerUri;
277
+ formData.append('ownerUri', ownerUri);
270
278
  formData.append('entryPoint', body.entryPoint);
271
279
  body.runtime && formData.append('runtime', body.runtime);
272
280
  for (const [filename, content] of Object.entries(body.assets)) {
@@ -379,14 +387,21 @@ export class EdgeHttpClient extends BaseHttpClient {
379
387
  return this._call(ctx, new URL('/registry/plugins', this.baseUrl), { ...args, method: 'GET' });
380
388
  }
381
389
 
382
- public async getRegistryPluginVersions(
390
+ /**
391
+ * Uploads a built plugin bundle to the registry's R2-backed hosting. Authenticated
392
+ * with the caller's hub identity (verifiable presentation) — `setIdentity` must
393
+ * have been called. Returns the canonical `moduleUrl` (the hosted `manifest.json`).
394
+ */
395
+ public async uploadPluginBundle(
383
396
  ctx: Context,
384
- repo: string,
397
+ request: UploadPluginBundleRequest,
385
398
  args?: EdgeHttpCallArgs,
386
- ): Promise<GetPluginVersionsResponseBody> {
387
- return this._call(ctx, new URL(`/registry/plugins/${encodeURIComponent(repo)}/versions`, this.baseUrl), {
399
+ ): Promise<{ moduleUrl: string }> {
400
+ return this._call(ctx, new URL('/registry/upload', this.baseUrl), {
401
+ body: request,
402
+ method: 'POST',
403
+ auth: true,
388
404
  ...args,
389
- method: 'GET',
390
405
  });
391
406
  }
392
407
 
@@ -8,7 +8,11 @@ import { type Presentation } from '@dxos/protocols/proto/dxos/halo/credentials';
8
8
 
9
9
  export interface EdgeIdentity {
10
10
  peerKey: string;
11
- identityKey: string;
11
+ /**
12
+ * Identity DID (`did:halo:…`) — the public identity segment of the edge WebSocket path.
13
+ * The router keys connections by the DID.
14
+ */
15
+ identityDid: string;
12
16
  /**
13
17
  * Returns credential presentation issued by the identity key.
14
18
  * Presentation must have the provided challenge.
@@ -60,7 +60,7 @@ export class EdgeWsConnection extends Resource {
60
60
  public get info() {
61
61
  return {
62
62
  open: this.isOpen,
63
- identity: this._identity.identityKey,
63
+ identity: this._identity.identityDid,
64
64
  device: this._identity.peerKey,
65
65
  };
66
66
  }
@@ -35,7 +35,7 @@ describe('WebSocketMuxerTest', () => {
35
35
 
36
36
  const textMessage = (message: string, source?: EdgeIdentity) =>
37
37
  protocol.createMessage(TextMessageSchema, {
38
- source: source && { peerKey: source.peerKey, identityKey: source.identityKey },
38
+ source: source && { peerKey: source.peerKey, identityDid: source.identityDid },
39
39
  serviceId: 'test-service',
40
40
  payload: { message },
41
41
  });
@@ -6,6 +6,7 @@ import { type Context } from '@dxos/context';
6
6
  import {
7
7
  type CheckEmailExistsResponse,
8
8
  type GetAccountResponse,
9
+ type GetProfileUsageResponse,
9
10
  type IssueInvitationResponse,
10
11
  type ListAccountInvitationsResponse,
11
12
  type LoginRequest,
@@ -17,6 +18,7 @@ import {
17
18
  type ResendVerificationEmailResponse,
18
19
  type ValidateInvitationCodeResponse,
19
20
  } from '@dxos/protocols';
21
+ import { createUrl } from '@dxos/util';
20
22
 
21
23
  import { BaseHttpClient, type BaseHttpClientOptions, type EdgeHttpCallArgs } from './base-http-client';
22
24
 
@@ -115,4 +117,22 @@ export class HubHttpClient extends BaseHttpClient {
115
117
  ): Promise<ResendVerificationEmailResponse> {
116
118
  return this._call(ctx, new URL('/account/email/resend-verification', this.baseUrl), { ...args, method: 'POST' });
117
119
  }
120
+
121
+ /**
122
+ * Rolling-window usage and effective limits for the authenticated identity.
123
+ * Served from the per-user metering DO; optional `windowSeconds` defaults to the largest limit window.
124
+ */
125
+ public async getProfileUsage(
126
+ ctx: Context,
127
+ query?: { windowSeconds?: number },
128
+ args?: EdgeHttpCallArgs,
129
+ ): Promise<GetProfileUsageResponse> {
130
+ return this._call(
131
+ ctx,
132
+ createUrl(new URL('/api/metering/profile/usage', this.baseUrl), {
133
+ windowSeconds: query?.windowSeconds,
134
+ }),
135
+ { ...args, method: 'GET' },
136
+ );
137
+ }
118
138
  }
@@ -0,0 +1,61 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ // @import-as-namespace
6
+
7
+ // Image service helpers built on the generic {@link EdgeServiceClient}. The
8
+ // worker exposes two contract-identical routes (`/upload` and `/thumbnail`,
9
+ // both returning `{ id, url }`); we mirror them so callers stay on whichever
10
+ // route they already use.
11
+
12
+ import * as Effect from 'effect/Effect';
13
+ import * as Schema from 'effect/Schema';
14
+
15
+ import { type EdgeServiceClient, type EdgeServiceError } from './edge-service';
16
+
17
+ /**
18
+ * Default Composer image-service base URL — the Cloudflare Worker used in
19
+ * production. Override per-environment via the client's `baseUrl`.
20
+ */
21
+ // TODO(burdon): Get from config.
22
+ export const DEFAULT_IMAGE_SERVICE_URL = 'https://image-service-main.dxos.workers.dev';
23
+
24
+ /** Hosted image returned by the service: a CDN `url` and its storage `id`. */
25
+ export const Result = Schema.Struct({
26
+ id: Schema.optional(Schema.String),
27
+ url: Schema.String,
28
+ });
29
+ export type Result = typeof Result.Type;
30
+
31
+ export type UploadOptions = {
32
+ /** Multipart field name. */
33
+ field?: string;
34
+ /** Upload filename. */
35
+ filename?: string;
36
+ };
37
+
38
+ /** Store an image and return its hosted CDN URL (POST `/upload`). */
39
+ export const upload = (
40
+ client: EdgeServiceClient,
41
+ blob: Blob,
42
+ opts?: UploadOptions,
43
+ ): Effect.Effect<Result, EdgeServiceError> => uploadToPath(client, blob, '/upload', opts);
44
+
45
+ /** Store an image and create a thumbnail, returning its hosted CDN URL (POST `/thumbnail`). */
46
+ export const thumbnail = (
47
+ client: EdgeServiceClient,
48
+ blob: Blob,
49
+ opts?: UploadOptions,
50
+ ): Effect.Effect<Result, EdgeServiceError> => uploadToPath(client, blob, '/thumbnail', opts);
51
+
52
+ const uploadToPath = (
53
+ client: EdgeServiceClient,
54
+ blob: Blob,
55
+ path: string,
56
+ opts?: UploadOptions,
57
+ ): Effect.Effect<Result, EdgeServiceError> => {
58
+ const form = new FormData();
59
+ form.append(opts?.field ?? 'file', blob, opts?.filename ?? 'image');
60
+ return client.postForm(path, form, Result);
61
+ };