@openchamber/sdk 2.4.0 → 2.5.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.
@@ -15,12 +15,14 @@ export const BROWSER_CONTROL_ACTIONS = [
15
15
  'browser.snapshot',
16
16
  'browser.click',
17
17
  'browser.type',
18
+ 'browser.select',
18
19
  'browser.scroll',
19
20
  'browser.back',
20
21
  'browser.forward',
21
22
  'browser.inspect',
22
23
  'browser.capture',
23
24
  'browser.resize',
25
+ 'browser.close',
24
26
  ];
25
27
  export const BROWSER_VIEWPORT_MODES = ['mobile', 'tablet', 'desktop', 'fill'];
26
28
  export const BROWSER_SCROLL_DIRECTIONS = ['up', 'down', 'top', 'bottom'];
@@ -71,3 +73,218 @@ export const readBrowserProviderRequest = (body) => {
71
73
  // and the host validated the parameters for this action before posting.
72
74
  return { requestId, action, parameters, context: readContext(context) };
73
75
  };
76
+ /*
77
+ * Source control: a service with `provides: ["source-control"]` is the Git
78
+ * host `contributes.sourceControl` describes. OpenChamber keeps the user's
79
+ * accounts and tokens, the bindings, the idempotent mutation records and the
80
+ * caches; it forwards the resource reads and the three mutation steps to the
81
+ * service at `SOURCE_CONTROL_PROVIDER_PATH`. The service never sees a token: it
82
+ * reaches the forge through OpenChamber's outbound proxy
83
+ * (`createSourceControlForge`), which pins the instance origin and adds the
84
+ * stored token itself.
85
+ */
86
+ /** Route on the service loopback OpenChamber posts every source-control call to. */
87
+ export const SOURCE_CONTROL_PROVIDER_PATH = '/source-control';
88
+ /** The `v` of every request; a service answers a version it does not know with HTTP 400. */
89
+ export const SOURCE_CONTROL_PROVIDER_WIRE_VERSION = 1;
90
+ /**
91
+ * Every call OpenChamber forwards. `auth.verify` reads who a candidate token
92
+ * belongs to and `auth.probe` whether the instance is this forge; the fifteen
93
+ * reads take the shell's own input without the session; the three
94
+ * `mutation.*` steps are the part of a write the service owns, while
95
+ * OpenChamber owns the kind, the normalized input, its digest, the
96
+ * idempotency record and when each step runs.
97
+ */
98
+ export const SOURCE_CONTROL_PROVIDER_METHODS = [
99
+ 'auth.verify',
100
+ 'auth.probe',
101
+ 'changeRequestStatus',
102
+ 'listChangeRequests',
103
+ 'changeRequestContext',
104
+ 'listIssues',
105
+ 'getIssue',
106
+ 'issueComments',
107
+ 'projectUpstream',
108
+ 'projectBranches',
109
+ 'listLabels',
110
+ 'listReviewerCandidates',
111
+ 'readChangeRequestPatch',
112
+ 'readChangeRequestFile',
113
+ 'liveSummaries',
114
+ 'readChangeRequestHead',
115
+ 'resolveChangeRequestSource',
116
+ 'mutation.resolveTarget',
117
+ 'mutation.perform',
118
+ 'mutation.reconcile',
119
+ ];
120
+ /** `changeRequestStatus` answers within the badge's own deadline; past it the last known status shows. */
121
+ export const SOURCE_CONTROL_PROVIDER_STATUS_TIMEOUT_MS = 12_000;
122
+ export const SOURCE_CONTROL_PROVIDER_READ_TIMEOUT_MS = 20_000;
123
+ export const SOURCE_CONTROL_PROVIDER_MUTATION_TIMEOUT_MS = 30_000;
124
+ export const SOURCE_CONTROL_PROVIDER_AUTH_TIMEOUT_MS = 15_000;
125
+ /** Answers are read up to this many bytes, enough for a change request's patch or one file. */
126
+ export const SOURCE_CONTROL_PROVIDER_RESPONSE_MAX = 8_000_000;
127
+ /** Characters of `error.message` OpenChamber reads. */
128
+ export const SOURCE_CONTROL_PROVIDER_ERROR_MESSAGE_MAX = 2_000;
129
+ /** No call for this long stops the service; the next call starts it again. */
130
+ export const SOURCE_CONTROL_PROVIDER_IDLE_MS = 10 * 60_000;
131
+ /** Characters of a mutation's `providerTarget` JSON, which OpenChamber stores with the record. */
132
+ export const SOURCE_CONTROL_PROVIDER_TARGET_MAX = 4_096;
133
+ /** How long OpenChamber waits for the service to answer `method`. */
134
+ export const sourceControlProviderTimeoutMs = (method) => {
135
+ if (method === 'changeRequestStatus')
136
+ return SOURCE_CONTROL_PROVIDER_STATUS_TIMEOUT_MS;
137
+ if (method === 'auth.verify' || method === 'auth.probe')
138
+ return SOURCE_CONTROL_PROVIDER_AUTH_TIMEOUT_MS;
139
+ if (method.startsWith('mutation.'))
140
+ return SOURCE_CONTROL_PROVIDER_MUTATION_TIMEOUT_MS;
141
+ return SOURCE_CONTROL_PROVIDER_READ_TIMEOUT_MS;
142
+ };
143
+ /**
144
+ * Environment variable with the base URL of OpenChamber's callback listener
145
+ * (`http://127.0.0.1:<port>`). Set only for a service that provides
146
+ * `source-control`; `createSourceControlForge` reads it.
147
+ */
148
+ export const OPENCHAMBER_HOST_CALLBACK_URL = 'OPENCHAMBER_HOST_CALLBACK_URL';
149
+ /** Route on the callback listener a service sends a forge request to. */
150
+ export const SOURCE_CONTROL_OUTBOUND_PATH = '/source-control/outbound';
151
+ export const SOURCE_CONTROL_OUTBOUND_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
152
+ /** Request headers OpenChamber passes to the forge; it sets every other header itself. */
153
+ export const SOURCE_CONTROL_OUTBOUND_REQUEST_HEADERS = ['accept', 'content-type', 'if-none-match', 'range'];
154
+ /** Response headers OpenChamber hands back, plus any starting with `SOURCE_CONTROL_OUTBOUND_RESPONSE_HEADER_PREFIX`. */
155
+ export const SOURCE_CONTROL_OUTBOUND_RESPONSE_HEADERS = ['content-type', 'link', 'etag', 'x-total-count', 'x-total', 'retry-after'];
156
+ export const SOURCE_CONTROL_OUTBOUND_RESPONSE_HEADER_PREFIX = 'x-ratelimit-';
157
+ /** Characters of an outbound request body. */
158
+ export const SOURCE_CONTROL_OUTBOUND_BODY_MAX = 1_000_000;
159
+ /** Bytes of a forge answer OpenChamber reads; a longer one is refused whole. */
160
+ export const SOURCE_CONTROL_OUTBOUND_RESPONSE_MAX = 8_000_000;
161
+ /** One forge request may take this long; the call's own deadline may end it sooner. */
162
+ export const SOURCE_CONTROL_OUTBOUND_TIMEOUT_MS = 20_000;
163
+ export const isSourceControlProviderMethod = (value) => (SOURCE_CONTROL_PROVIDER_METHODS.some((method) => method === value));
164
+ /** `undefined` is a user that is neither `null` nor `{ id, login }`. */
165
+ const readSessionUser = (user) => {
166
+ if (user === null)
167
+ return null;
168
+ if (Object(user) !== user || user === undefined)
169
+ return undefined;
170
+ const { id, login } = user;
171
+ if (!Number.isSafeInteger(id) || String(login) !== login || login.length === 0)
172
+ return undefined;
173
+ return { id: Number(id), login };
174
+ };
175
+ /** `undefined` is a session that is neither `null` nor `{ instance, handle, user }`. */
176
+ const readSession = (session) => {
177
+ if (session === null)
178
+ return null;
179
+ if (Object(session) !== session || session === undefined)
180
+ return undefined;
181
+ const { instance, handle } = session;
182
+ if (String(instance) !== instance || instance.length === 0 || String(handle) !== handle || handle.length === 0)
183
+ return undefined;
184
+ const user = readSessionUser(session.user);
185
+ if (user === undefined)
186
+ return undefined;
187
+ return { instance, handle, user };
188
+ };
189
+ /**
190
+ * A service's read of the body OpenChamber posted to
191
+ * `SOURCE_CONTROL_PROVIDER_PATH`. OpenChamber built and validated `input`, so
192
+ * this checks the envelope, not every field, and the service bundle needs no
193
+ * schema library. `null` is a body that is not a source-control call of this
194
+ * wire version; answer it with HTTP 400.
195
+ */
196
+ export const readSourceControlProviderRequest = (body) => {
197
+ let wire;
198
+ try {
199
+ const parsed = JSON.parse(body);
200
+ if (Object(parsed) !== parsed || parsed === null)
201
+ return null;
202
+ wire = parsed;
203
+ }
204
+ catch {
205
+ return null;
206
+ }
207
+ const { v, requestId, method, provider, input } = wire;
208
+ if (v !== SOURCE_CONTROL_PROVIDER_WIRE_VERSION)
209
+ return null;
210
+ if (String(requestId) !== requestId || requestId.length === 0)
211
+ return null;
212
+ if (String(provider) !== provider || provider.length === 0)
213
+ return null;
214
+ if (String(method) !== method || !isSourceControlProviderMethod(method))
215
+ return null;
216
+ if (Object(input) !== input || Array.isArray(input))
217
+ return null;
218
+ const session = readSession(wire.session);
219
+ if (session === undefined)
220
+ return null;
221
+ // SAFETY: `input` is a plain object parsed from JSON, so every value in it is JSON.
222
+ return { v: SOURCE_CONTROL_PROVIDER_WIRE_VERSION, requestId, method, provider, session, input: input };
223
+ };
224
+ /**
225
+ * OpenChamber refused the request (an expired handle, a path off the
226
+ * instance, a body too large), could not reach the forge, or did not answer.
227
+ * `status` is the callback listener's HTTP status, `0` when it gave none;
228
+ * `code` is its error code (`HOST_UNAVAILABLE` when there was no usable answer).
229
+ */
230
+ export class SourceControlForgeError extends Error {
231
+ code;
232
+ status;
233
+ constructor(message, code, status) {
234
+ super(message);
235
+ this.name = 'SourceControlForgeError';
236
+ this.code = code;
237
+ this.status = status;
238
+ }
239
+ }
240
+ /**
241
+ * The service's way to the forge: OpenChamber's callback listener
242
+ * (`OPENCHAMBER_HOST_CALLBACK_URL`), which checks the handle, keeps the request
243
+ * on the instance origin and adds the token. Plain `fetch`, no dependencies.
244
+ * Throws `SourceControlForgeError` when OpenChamber refuses the request; a
245
+ * forge answer of any status, 401 included, resolves.
246
+ */
247
+ export const createSourceControlForge = ({ env }) => {
248
+ const base = env[OPENCHAMBER_HOST_CALLBACK_URL];
249
+ if (!base)
250
+ throw new Error(`${OPENCHAMBER_HOST_CALLBACK_URL} is not set: only a service that provides "source-control" can reach a forge.`);
251
+ const url = `${base.replace(/\/+$/, '')}${SOURCE_CONTROL_OUTBOUND_PATH}`;
252
+ return {
253
+ async request(handle, request) {
254
+ let response;
255
+ try {
256
+ response = await fetch(url, {
257
+ method: 'POST',
258
+ headers: { authorization: `Bearer ${handle}`, 'content-type': 'application/json' },
259
+ body: JSON.stringify(request),
260
+ // A little past OpenChamber's own deadline, so its answer wins.
261
+ signal: AbortSignal.timeout(SOURCE_CONTROL_OUTBOUND_TIMEOUT_MS + 5_000),
262
+ });
263
+ }
264
+ catch (error) {
265
+ throw new SourceControlForgeError(`OpenChamber did not answer: ${error instanceof Error ? error.message : String(error)}`, 'HOST_UNAVAILABLE', 0);
266
+ }
267
+ let answer;
268
+ try {
269
+ answer = Object(await response.json());
270
+ }
271
+ catch {
272
+ throw new SourceControlForgeError('OpenChamber answered with something other than JSON', 'HOST_UNAVAILABLE', response.status);
273
+ }
274
+ if (!response.ok) {
275
+ const message = answer.error?.message;
276
+ const code = answer.error?.code;
277
+ throw new SourceControlForgeError(String(message) === message && message.length > 0 ? message : `OpenChamber refused the request (${response.status})`, String(code) === code && code.length > 0 ? code : 'OUTBOUND_REFUSED', response.status);
278
+ }
279
+ if (!Number.isSafeInteger(answer.status) || String(answer.body) !== answer.body) {
280
+ throw new SourceControlForgeError('OpenChamber relayed a malformed forge answer', 'HOST_UNAVAILABLE', response.status);
281
+ }
282
+ const headers = {};
283
+ for (const [name, text] of Object.entries(Object(answer.headers ?? {}))) {
284
+ if (String(text) === text)
285
+ headers[name] = text;
286
+ }
287
+ return { status: Number(answer.status), headers, body: answer.body };
288
+ },
289
+ };
290
+ };
package/dist/ui/style.js CHANGED
@@ -61,9 +61,14 @@ const focusFill = `background-image: linear-gradient(${hover}, ${hover});`;
61
61
  * top line and one short shadow, and wash a field halfway toward the page so it
62
62
  * sits a tone below the buttons beside it. Without the attribute (a preview
63
63
  * outside the host) the light form applies.
64
+ *
65
+ * Buttons, triggers, tabs and popups keep a 1px border in their geometry so a
66
+ * tinted variant, whose border is coloured, has the same size. Their hairline
67
+ * is that border: an inset ring would paint inside it, 1px in from the edge,
68
+ * with a strip of fill around it. Fields have no border and keep the ring.
64
69
  */
65
70
  const field = (edge) => `box-shadow: inset 0 0 0 1px ${edge}, var(--oc-sdk-field-lift);`;
66
- const raised = 'box-shadow: inset 0 0 0 1px var(--oc-sdk-edge), var(--oc-sdk-raised-lift);';
71
+ const raised = 'border-color: var(--oc-sdk-edge); box-shadow: var(--oc-sdk-raised-lift);';
67
72
  const litButton = 'box-shadow: var(--oc-sdk-lit);';
68
73
  const darkLift = `inset 0 1px 0 0 ${mix(fg, 1.5)}, 0 1px 1px 0 rgb(0 0 0 / 0.28), 0 2px 3px -2px rgb(0 0 0 / 0.2)`;
69
74
  const THEME_VARS = `
@@ -74,7 +79,8 @@ const THEME_VARS = `
74
79
  --oc-sdk-field-lift: inset 0 1px 2px 0 rgb(0 0 0 / 0.04);
75
80
  --oc-sdk-raised-lift: 0 1px 2px 0 rgb(0 0 0 / 0.06);
76
81
  --oc-sdk-lit: inset 0 1px 0 0 rgb(255 255 255 / 0.45), 0 1px 2px 0 rgb(0 0 0 / 0.08);
77
- --oc-sdk-float: inset 0 1px 0 0 rgb(255 255 255 / 0.8), inset 0 0 0 1px rgb(0 0 0 / 0.04), 0 0 0 1px rgb(0 0 0 / 0.1), 0 1px 2px -0.5px rgb(0 0 0 / 0.08), 0 4px 8px -2px rgb(0 0 0 / 0.08), 0 12px 20px -4px rgb(0 0 0 / 0.08);
82
+ --oc-sdk-float-edge: rgb(0 0 0 / 0.1);
83
+ --oc-sdk-float: inset 0 1px 0 0 rgb(255 255 255 / 0.8), inset 0 0 0 1px rgb(0 0 0 / 0.04), 0 1px 2px -0.5px rgb(0 0 0 / 0.08), 0 4px 8px -2px rgb(0 0 0 / 0.08), 0 12px 20px -4px rgb(0 0 0 / 0.08);
78
84
  }
79
85
  :root[data-oc-theme="dark"] {
80
86
  --oc-sdk-edge: ${mix(fg, 6)};
@@ -83,7 +89,8 @@ const THEME_VARS = `
83
89
  --oc-sdk-field-lift: ${darkLift};
84
90
  --oc-sdk-raised-lift: ${darkLift};
85
91
  --oc-sdk-lit: inset 0 1px 0 0 ${mix(fg, 1.5)}, 0 1px 1px 0 rgb(0 0 0 / 0.28), 0 2px 3px -2px rgb(0 0 0 / 0.2);
86
- --oc-sdk-float: inset 0 1px 0 0 rgb(255 255 255 / 0.06), inset 0 0 0 1px rgb(255 255 255 / 0.08), 0 0 0 1px rgb(0 0 0 / 0.36), 0 1px 1px -0.5px rgb(0 0 0 / 0.22), 0 3px 3px -1.5px rgb(0 0 0 / 0.2), 0 6px 6px -3px rgb(0 0 0 / 0.16);
92
+ --oc-sdk-float-edge: rgb(0 0 0 / 0.36);
93
+ --oc-sdk-float: inset 0 1px 0 0 rgb(255 255 255 / 0.06), inset 0 0 0 1px rgb(255 255 255 / 0.08), 0 1px 1px -0.5px rgb(0 0 0 / 0.22), 0 3px 3px -1.5px rgb(0 0 0 / 0.2), 0 6px 6px -3px rgb(0 0 0 / 0.16);
87
94
  }`;
88
95
  const tone = (name) => {
89
96
  const color = v(`status-${name}`, 'currentColor');
@@ -216,9 +223,9 @@ ${tone('success')}${tone('warning')}${tone('error')}${tone('info')}
216
223
  .oc-sdk-btn[data-variant="secondary"] { background: ${secondary}; color: var(--oc-fg); }
217
224
  .oc-sdk-btn[data-variant="secondary"]:hover { background-image: linear-gradient(${hover}, ${hover}); }
218
225
  .oc-sdk-btn[data-variant="secondary"]:active { background-image: linear-gradient(${active}, ${active}); }
219
- .oc-sdk-btn[data-variant="outline"] { background: var(--oc-sdk-raised-bg); color: ${elevatedFg}; border-color: transparent; ${raised} }
226
+ .oc-sdk-btn[data-variant="outline"] { background: var(--oc-sdk-raised-bg); color: ${elevatedFg}; ${raised} }
220
227
  .oc-sdk-btn[data-variant="outline"]:hover { background-image: linear-gradient(${hover}, ${hover}); }
221
- .oc-sdk-btn[data-variant="outline"]:active { background-image: linear-gradient(${active}, ${active}); box-shadow: inset 0 0 0 1px var(--oc-sdk-edge); }
228
+ .oc-sdk-btn[data-variant="outline"]:active { background-image: linear-gradient(${active}, ${active}); box-shadow: none; }
222
229
  .oc-sdk-btn[data-variant="ghost"] { background: transparent; }
223
230
  .oc-sdk-btn[data-variant="ghost"]:hover { background: ${hover}; }
224
231
  .oc-sdk-btn[data-variant="ghost"]:active { background: ${active}; }
@@ -240,13 +247,13 @@ ${tone('success')}${tone('warning')}${tone('error')}${tone('info')}
240
247
  .oc-sdk-search-clear { border-radius: ${smallRadius}; color: ${muted}; }
241
248
  .oc-sdk-search-clear:hover { background: ${hover}; color: ${fg}; }
242
249
 
243
- .oc-sdk-trigger { border-color: transparent; border-radius: ${controlRadius}; background: var(--oc-sdk-raised-bg); color: ${elevatedFg}; ${raised} transition: background 150ms ease-out; }
250
+ .oc-sdk-trigger { border-radius: ${controlRadius}; background: var(--oc-sdk-raised-bg); color: ${elevatedFg}; ${raised} transition: background 150ms ease-out; }
244
251
  .oc-sdk-trigger:hover { background-image: linear-gradient(${hover}, ${hover}); }
245
252
  .oc-sdk-trigger[aria-expanded="true"] { background-image: linear-gradient(${active}, ${active}); }
246
253
  .oc-sdk-trigger-value[data-empty="true"] { color: ${muted}; }
247
254
  .oc-sdk-trigger-chevron { color: ${muted}; }
248
- /* The app's dropdown light: lit top, hairline rings and a layered shadow, no border. */
249
- .oc-sdk-popup { --surface-foreground: ${elevatedFg}; border-color: transparent; border-radius: 12px; background: ${elevated}; color: ${elevatedFg}; box-shadow: var(--oc-sdk-float); }
255
+ /* The app's dropdown light: lit top, hairline rings and a layered shadow. Its outer ring is the border, over the page like the app's. */
256
+ .oc-sdk-popup { --surface-foreground: ${elevatedFg}; border-color: var(--oc-sdk-float-edge); border-radius: 12px; background: ${elevated}; background-clip: padding-box; color: ${elevatedFg}; box-shadow: var(--oc-sdk-float); }
250
257
  .oc-sdk-option { border-radius: 8px; }
251
258
  .oc-sdk-option[data-active="true"] { background: ${hover}; }
252
259
  /* A selected option shows a check mark in the text colour, like the app's own pickers. Options without the mark come
@@ -274,7 +281,7 @@ ${tone('success')}${tone('warning')}${tone('error')}${tone('info')}
274
281
  .oc-sdk-tab { border-radius: 8px; color: ${muted}; transition: color 150ms ease-out, background 150ms ease-out; }
275
282
  .oc-sdk-tab:hover { color: ${fg}; }
276
283
  .oc-sdk-tab[aria-selected="true"] { color: ${selectionFg}; background: ${selection}; border-color: ${border}; }
277
- .oc-sdk-tabs[data-track="true"] .oc-sdk-tab[aria-selected="true"] { color: ${elevatedFg}; background: var(--oc-sdk-raised-bg); border-color: transparent; ${raised} }
284
+ .oc-sdk-tabs[data-track="true"] .oc-sdk-tab[aria-selected="true"] { color: ${elevatedFg}; background: var(--oc-sdk-raised-bg); ${raised} }
278
285
  .oc-sdk-tab-count { color: ${muted}; }
279
286
 
280
287
  .oc-sdk-badge { border-radius: 9999px; background: ${hover}; color: ${muted}; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openchamber/sdk",
3
- "version": "2.4.0",
3
+ "version": "2.5.1",
4
4
  "description": "Contract for OpenChamber guests: manifest parse and iframe host bridge.",
5
5
  "private": false,
6
6
  "license": "MIT",
@@ -34,7 +34,7 @@
34
34
  },
35
35
  "scripts": {
36
36
  "build": "tsc -p tsconfig.build.json",
37
- "type-check": "tsc --noEmit",
37
+ "type-check": "bun check",
38
38
  "lint": "oxlint -c ../../oxlint.config.ts --report-unused-disable-directives src examples",
39
39
  "bundle": "bun scripts/bundle-guest.ts",
40
40
  "test": "node ../../scripts/run-isolated-tests.mjs src scripts",