@buildinternet/uploads 0.21.0 → 0.22.0

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/dist/client.d.ts CHANGED
@@ -41,6 +41,8 @@ export interface ListOptions {
41
41
  prefix?: string;
42
42
  limit?: number;
43
43
  cursor?: string;
44
+ /** Hydrate each row's queryable D1 metadata (`?metadata=1`). */
45
+ metadata?: boolean;
44
46
  }
45
47
  export interface FindFilesOptions {
46
48
  prefix?: string;
@@ -90,6 +92,8 @@ export interface ListItem {
90
92
  pageUrl?: string;
91
93
  size?: number;
92
94
  uploaded?: string;
95
+ /** Present only when listed with `metadata: true`, and only for keys that have rows. */
96
+ metadata?: Record<string, string>;
93
97
  }
94
98
  export interface ListResult {
95
99
  items: ListItem[];
@@ -351,7 +355,14 @@ export interface DeviceCodeResponse {
351
355
  interval: number;
352
356
  }
353
357
  /** POST /api/auth/device/code — start a device flow. Throws on a non-2xx. */
354
- export declare function requestDeviceCode(authUrl: string, clientId?: string): Promise<DeviceCodeResponse>;
358
+ export declare function requestDeviceCode(authUrl: string, clientId?: string,
359
+ /**
360
+ * RFC 8628 `scope`. Carries the requested workspace (`workspace:<slug>`,
361
+ * plus `create`) so the approval page can validate it before approving —
362
+ * issue #362. Stored on the device-code row and echoed back at token
363
+ * exchange, possibly rewritten by the page.
364
+ */
365
+ scope?: string): Promise<DeviceCodeResponse>;
355
366
  /**
356
367
  * One poll of POST /api/auth/device/token. Unlike most calls, the "not ready
357
368
  * yet" outcomes (`authorization_pending`, `slow_down`) are EXPECTED 400s, so
package/dist/client.js CHANGED
@@ -56,14 +56,21 @@ export function cliUserAgent(purpose = "device-login") {
56
56
  return `@buildinternet/uploads/${packageVersion()} (${purpose})`;
57
57
  }
58
58
  /** POST /api/auth/device/code — start a device flow. Throws on a non-2xx. */
59
- export function requestDeviceCode(authUrl, clientId = DEVICE_CLIENT_ID) {
59
+ export function requestDeviceCode(authUrl, clientId = DEVICE_CLIENT_ID,
60
+ /**
61
+ * RFC 8628 `scope`. Carries the requested workspace (`workspace:<slug>`,
62
+ * plus `create`) so the approval page can validate it before approving —
63
+ * issue #362. Stored on the device-code row and echoed back at token
64
+ * exchange, possibly rewritten by the page.
65
+ */
66
+ scope) {
60
67
  return jsonRequest(`${authUrl.replace(/\/$/, "")}/api/auth/device/code`, {
61
68
  method: "POST",
62
69
  headers: {
63
70
  "Content-Type": "application/json",
64
71
  "User-Agent": cliUserAgent("device-code"),
65
72
  },
66
- body: JSON.stringify({ client_id: clientId }),
73
+ body: JSON.stringify({ client_id: clientId, ...(scope ? { scope } : {}) }),
67
74
  });
68
75
  }
69
76
  export async function requestDeviceToken(authUrl, input) {
@@ -296,6 +303,8 @@ export function createUploadsClient(config) {
296
303
  params.set("limit", String(opts.limit));
297
304
  if (opts.cursor)
298
305
  params.set("cursor", opts.cursor);
306
+ if (opts.metadata)
307
+ params.set("metadata", "1");
299
308
  const qs = params.toString();
300
309
  const page = await request("GET", `${filesBase(config)}${qs ? `?${qs}` : ""}`);
301
310
  return {
@@ -1,5 +1,16 @@
1
1
  import { parseCommandArgs } from "../cli-args.js";
2
2
  export declare function validateEnrollmentCode(raw: string): string;
3
+ /** The scope the CLI sends with its device-code request. No workspace requested → no scope. */
4
+ export declare function formatDeviceScope(workspace: string | undefined, create: boolean): string | undefined;
5
+ /**
6
+ * Read back what the approval page decided. A surviving `create` token means
7
+ * the page left the scope alone and deferred provisioning to the CLI; a bare
8
+ * `workspace:<slug>` means the browser recorded a choice and wins.
9
+ */
10
+ export declare function parseDeviceScope(scope: string | undefined): {
11
+ workspace: string | undefined;
12
+ create: boolean;
13
+ };
3
14
  export declare function resolveEnrollmentCode(parsed: ReturnType<typeof parseCommandArgs>, io?: {
4
15
  isTTY: boolean;
5
16
  readLine: () => Promise<string>;
@@ -23,10 +34,21 @@ export interface DeviceLoginIo {
23
34
  promptWorkspaceName: () => Promise<string>;
24
35
  }
25
36
  export declare const defaultDeviceIo: DeviceLoginIo;
37
+ /** A completed device authorization: the session bearer plus the (possibly rewritten) scope. */
38
+ export interface DeviceSession {
39
+ accessToken: string;
40
+ scope: string;
41
+ }
26
42
  /**
27
43
  * Browser device-authorization session only (no workspace token mint).
28
44
  * Shared by `uploads login` and `uploads invite create`.
29
45
  */
46
+ export declare function obtainDeviceSession(authUrl: string, opts?: {
47
+ noOpen?: boolean;
48
+ prompt?: string;
49
+ scope?: string;
50
+ }, io?: DeviceLoginIo): Promise<DeviceSession>;
51
+ /** Session bearer only — `invite create` has no workspace to resolve. */
30
52
  export declare function obtainDeviceAccessToken(authUrl: string, opts?: {
31
53
  noOpen?: boolean;
32
54
  prompt?: string;
@@ -36,7 +58,7 @@ export declare function pollForDeviceToken(authUrl: string, code: {
36
58
  device_code: string;
37
59
  interval: number;
38
60
  expires_in: number;
39
- }, io: DeviceLoginIo): Promise<string>;
61
+ }, io: DeviceLoginIo): Promise<DeviceSession>;
40
62
  export declare function runLogin(args: string[], opts: {
41
63
  json?: boolean;
42
64
  apiUrl?: string;
@@ -7,15 +7,17 @@ import { createUploadsClient, createWorkspaceRequest, exchangeEnrollment, listMi
7
7
  import { flagBool, flagString, parseCommandArgs, UsageError } from "../cli-args.js";
8
8
  import { parseScopes } from "./admin-enrollment.js";
9
9
  import { writeCommandHelp } from "../cli-style.js";
10
+ import { UploadsError } from "../errors.js";
10
11
  const HELP = `uploads login [options]
11
12
 
12
13
  Sign in and save workspace credentials. With no flags, opens a browser to
13
- authorize this device — the recommended way to sign in. Pass an enrollment
14
- code only if you were given one from before device login (fallback path).
14
+ authorize this device — the recommended way to sign in. The browser asks which
15
+ workspace to sign in to, so --workspace is optional. Pass an enrollment code
16
+ only if you were given one from before device login (fallback path).
15
17
 
16
18
  Options:
17
- --workspace <name> Workspace to mint a token for (device flow; required if
18
- your account can access more than one)
19
+ --workspace <name> Preselect this workspace in the browser (device flow);
20
+ you can still change it there
19
21
  --create With --workspace: create the workspace first if your
20
22
  account doesn't have it yet (device flow only) — lets
21
23
  scripted/agent logins provision without a prompt
@@ -46,6 +48,34 @@ export function validateEnrollmentCode(raw) {
46
48
  throw new UsageError("invalid enrollment code");
47
49
  return code;
48
50
  }
51
+ /**
52
+ * Device-code scope vocabulary (issue #362). Mirrors `parseDeviceScope` /
53
+ * `workspaceScopeValue` in apps/auth/src/device-workspace.ts — this package
54
+ * ships with no workspace dependencies, so the two copies are deliberately
55
+ * independent. Keep the vocabulary in sync.
56
+ */
57
+ const WORKSPACE_SCOPE_PREFIX = "workspace:";
58
+ const CREATE_SCOPE_TOKEN = "create";
59
+ /** The scope the CLI sends with its device-code request. No workspace requested → no scope. */
60
+ export function formatDeviceScope(workspace, create) {
61
+ if (!workspace)
62
+ return undefined;
63
+ return create
64
+ ? `${WORKSPACE_SCOPE_PREFIX}${workspace} ${CREATE_SCOPE_TOKEN}`
65
+ : `${WORKSPACE_SCOPE_PREFIX}${workspace}`;
66
+ }
67
+ /**
68
+ * Read back what the approval page decided. A surviving `create` token means
69
+ * the page left the scope alone and deferred provisioning to the CLI; a bare
70
+ * `workspace:<slug>` means the browser recorded a choice and wins.
71
+ */
72
+ export function parseDeviceScope(scope) {
73
+ const tokens = (scope ?? "").split(/\s+/).filter(Boolean);
74
+ const slug = tokens
75
+ .find((t) => t.startsWith(WORKSPACE_SCOPE_PREFIX))
76
+ ?.slice(WORKSPACE_SCOPE_PREFIX.length) ?? "";
77
+ return { workspace: slug || undefined, create: tokens.includes(CREATE_SCOPE_TOKEN) };
78
+ }
49
79
  async function readLine() {
50
80
  let out = "";
51
81
  for await (const chunk of stdin) {
@@ -187,8 +217,8 @@ export const defaultDeviceIo = {
187
217
  * Browser device-authorization session only (no workspace token mint).
188
218
  * Shared by `uploads login` and `uploads invite create`.
189
219
  */
190
- export async function obtainDeviceAccessToken(authUrl, opts = {}, io = defaultDeviceIo) {
191
- const code = await requestDeviceCode(authUrl);
220
+ export async function obtainDeviceSession(authUrl, opts = {}, io = defaultDeviceIo) {
221
+ const code = await requestDeviceCode(authUrl, undefined, opts.scope);
192
222
  const verifyUrl = code.verification_uri_complete ?? code.verification_uri;
193
223
  const prompt = opts.prompt ?? "To sign in, open:";
194
224
  io.write(`${prompt}\n\n ${verifyUrl}\n\nand confirm this code:\n\n ${code.user_code}\n\n`);
@@ -197,6 +227,31 @@ export async function obtainDeviceAccessToken(authUrl, opts = {}, io = defaultDe
197
227
  io.write("Waiting for approval…\n");
198
228
  return pollForDeviceToken(authUrl, code, io);
199
229
  }
230
+ /** Session bearer only — `invite create` has no workspace to resolve. */
231
+ export async function obtainDeviceAccessToken(authUrl, opts = {}, io = defaultDeviceIo) {
232
+ return (await obtainDeviceSession(authUrl, opts, io)).accessToken;
233
+ }
234
+ /**
235
+ * Turn the API's deliberately opaque 403 (`no access to this workspace` — it
236
+ * refuses to distinguish "doesn't exist" from "you're not a member", see
237
+ * apps/api/src/routes/tokens.ts) into something the user can act on, by
238
+ * listing the workspaces their own account CAN reach. Backstop only: since
239
+ * #362 the approval page catches this before approving.
240
+ */
241
+ async function describeMintFailure(apiUrl, accessToken, workspace, err) {
242
+ if (!(err instanceof UploadsError) || err.status !== 403)
243
+ throw err;
244
+ let names = [];
245
+ try {
246
+ names = (await listMintWorkspaces(apiUrl, accessToken)).workspaces.map((w) => w.workspace);
247
+ }
248
+ catch {
249
+ // Listing is best-effort — fall through to the generic hint below.
250
+ }
251
+ throw new UsageError(names.length
252
+ ? `no access to workspace "${workspace}" — this account can use: ${names.join(", ")}`
253
+ : `no access to workspace "${workspace}" — this account has no workspaces yet; pass --workspace <name> --create to provision one`);
254
+ }
200
255
  /**
201
256
  * Device-authorization login (RFC 8628): request a code, have the user approve
202
257
  * it in a browser, poll for the session token, then mint a workspace token.
@@ -217,9 +272,22 @@ async function runDeviceLogin(parsed, opts, io) {
217
272
  // Make the target explicit: a bare `uploads login` on a self-hosted install
218
273
  // would otherwise silently sign in to the cloud service.
219
274
  io.write(`signing in to ${opts.authUrl} (self-hosted? pass --api-url or set UPLOADS_API_URL)\n\n`);
220
- const accessToken = await obtainDeviceAccessToken(opts.authUrl, { noOpen: opts.noOpen }, io);
221
- const workspace = await resolveMintWorkspace(opts.apiUrl, accessToken, requestedWorkspace, io, flagBool(parsed.flags, "--create"));
222
- const minted = await mintWorkspaceToken(opts.apiUrl, accessToken, { workspace, scopes, label });
275
+ const create = flagBool(parsed.flags, "--create");
276
+ const session = await obtainDeviceSession(opts.authUrl, { noOpen: opts.noOpen, scope: formatDeviceScope(requestedWorkspace, create) }, io);
277
+ // The approval page is authoritative: it validated the workspace against the
278
+ // signed-in account's memberships (and may have created a new one) before
279
+ // approving. A scope that still carries `create` means the page deferred to
280
+ // the CLI, and an empty one means an older server that doesn't echo a
281
+ // choice — both fall back to the local resolution below.
282
+ const chosen = parseDeviceScope(session.scope);
283
+ const workspace = chosen.workspace && !chosen.create
284
+ ? chosen.workspace
285
+ : await resolveMintWorkspace(opts.apiUrl, session.accessToken, requestedWorkspace, io, create);
286
+ const minted = await mintWorkspaceToken(opts.apiUrl, session.accessToken, {
287
+ workspace,
288
+ scopes,
289
+ label,
290
+ }).catch((err) => describeMintFailure(opts.apiUrl, session.accessToken, workspace, err));
223
291
  return { workspace: minted.workspace, token: minted.token, apiUrl: opts.apiUrl };
224
292
  }
225
293
  function safeHostname() {
@@ -247,7 +315,7 @@ export async function pollForDeviceToken(authUrl, code, io) {
247
315
  }
248
316
  switch (result.status) {
249
317
  case "ok":
250
- return result.accessToken;
318
+ return { accessToken: result.accessToken, scope: result.scope };
251
319
  case "pending":
252
320
  continue;
253
321
  case "slow_down":
package/dist/commands.js CHANGED
@@ -464,12 +464,27 @@ export async function syncAttachmentsComment(client, target, run, workspace) {
464
464
  }
465
465
  }
466
466
  // gh fallback: gather from this workspace's own data and post via local `gh`.
467
- // Note (issue #304): this CLI process has no server-side WorkspaceRecord in
468
- // scope, so it cannot honor a workspace's githubCommentLinkToFilePage=false
469
- // it always links to the file page here, matching the default. This only
470
- // diverges from the bot-posted comment for a workspace that both sets the
471
- // flag false and falls through to this gh-fallback path.
472
- const items = (await client.listAll({ prefix: ghKeyPrefix(target) })).map(({ key, url, embedUrl, pageUrl }) => ({ key, url, embedUrl, pageUrl }));
467
+ // Note (issues #304, #365): this CLI process has no server-side
468
+ // WorkspaceRecord in scope, so it cannot honor a workspace's
469
+ // githubCommentLinkToFilePage=false or githubCommentShowMetadata=false it
470
+ // always links to the file page and always shows metadata here, matching the
471
+ // defaults. This only diverges from the bot-posted comment for a workspace
472
+ // that both sets one of those flags false and falls through to this path.
473
+ const items = (await client.listAll({ prefix: ghKeyPrefix(target), metadata: true })).map(({ key, url, embedUrl, pageUrl, metadata }) => {
474
+ // The list endpoint returns every metadata key; the comment renders only
475
+ // these two. Narrowing here keeps both render paths byte-identical.
476
+ const path = metadata?.path;
477
+ const state = metadata?.state;
478
+ return {
479
+ key,
480
+ url,
481
+ embedUrl,
482
+ pageUrl,
483
+ ...(path || state
484
+ ? { meta: { ...(path ? { path } : {}), ...(state ? { state } : {}) } }
485
+ : {}),
486
+ };
487
+ });
473
488
  const galleries = [];
474
489
  let cursor;
475
490
  do {
package/dist/github.d.ts CHANGED
@@ -75,6 +75,17 @@ export interface AttachmentItem {
75
75
  embedUrl?: string | null;
76
76
  /** Canonical `/f/` file-page URL (server-computed). Preferred click-through target; falls back to `url`. */
77
77
  pageUrl?: string | null;
78
+ /**
79
+ * The only canonical metadata the managed comment renders (issue #365).
80
+ * Deliberately two named fields rather than `Record<string, string>`: the
81
+ * comment is posted publicly, and keeping the set narrow at the type level
82
+ * mirrors the server-side query filter that never fetches EXIF-derived
83
+ * keys like `device`/`software` for this path.
84
+ */
85
+ meta?: {
86
+ path?: string;
87
+ state?: string;
88
+ };
78
89
  }
79
90
  /** A public gallery linked to the PR or issue whose managed comment is syncing. */
80
91
  export interface GalleryCommentItem {
package/dist/github.js CHANGED
@@ -177,6 +177,51 @@ function escapeHtmlAttr(s) {
177
177
  function escapeHtmlText(s) {
178
178
  return escapeHtmlAttr(s).replace(/'/g, "&#39;").replace(/>/g, "&gt;");
179
179
  }
180
+ /**
181
+ * Backslash-escape the markdown metacharacters that can appear in a metadata
182
+ * value. `~` is in the set because GitHub's strikethrough extension treats a
183
+ * matching pair of ONE or two tildes as markup, so an unescaped `/a~b~c` would
184
+ * render with `b` struck through.
185
+ */
186
+ function escapeMarkdownText(s) {
187
+ return s.replace(/([\\`*_[\]~])/g, "\\$1");
188
+ }
189
+ /**
190
+ * An attachment's caption parts — `path`, then `state` (issue #365). Empty
191
+ * when neither is usable, so callers emit nothing at all and a body with no
192
+ * metadata stays byte-identical to the pre-#365 render.
193
+ *
194
+ * Neither value is pre-sanitized: metadata values are printable ASCII up to
195
+ * 512 chars, and while the CLI validates `--state` against a closed enum,
196
+ * `PATCH /v1/:workspace/files/:key` can set any valid metadata value. A
197
+ * whitespace-only value passes that validation (length-1 printable ASCII), so
198
+ * treat it as absent rather than rendering a dangling separator.
199
+ */
200
+ function metaCaptionParts(meta) {
201
+ const parts = [];
202
+ for (const value of [meta?.path, meta?.state]) {
203
+ const trimmed = value?.trim();
204
+ if (trimmed)
205
+ parts.push(trimmed);
206
+ }
207
+ return parts;
208
+ }
209
+ /** `<sub>` caption body for an inline image, or null when there is nothing to say. */
210
+ function metaCaptionHtml(meta) {
211
+ const parts = metaCaptionParts(meta);
212
+ return parts.length > 0 ? parts.map(escapeHtmlText).join(" · ") : null;
213
+ }
214
+ /**
215
+ * ` · …` suffix for a markdown list row, or `""` when there is nothing to add.
216
+ * HTML-escapes first, then markdown-escapes: HTML escaping introduces no
217
+ * backslashes or brackets, so the markdown pass cannot corrupt its entities.
218
+ */
219
+ function metaCaptionMarkdown(meta) {
220
+ const parts = metaCaptionParts(meta);
221
+ if (parts.length === 0)
222
+ return "";
223
+ return ` · ${parts.map((p) => escapeMarkdownText(escapeHtmlText(p))).join(" · ")}`;
224
+ }
180
225
  /**
181
226
  * Render the one marker-owned GitHub comment. When there are no galleries this
182
227
  * intentionally preserves the legacy attachment-only body byte-for-byte.
@@ -224,13 +269,16 @@ export function attachmentsCommentBody(items, galleries = [], marker = ATTACHMEN
224
269
  const href = escapeHtmlAttr(link ?? src);
225
270
  const imgSrc = escapeHtmlAttr(src);
226
271
  lines.push(`<a href="${href}"><img width="${w}" alt="${alt}" src="${imgSrc}"></a>`);
272
+ const caption = metaCaptionHtml(item.meta);
273
+ if (caption)
274
+ lines.push(`<sub>${caption}</sub>`);
227
275
  lines.push("");
228
276
  }
229
277
  else if (link) {
230
- lines.push(`- [${name}](${link})`);
278
+ lines.push(`- [${name}](${link})${metaCaptionMarkdown(item.meta)}`);
231
279
  }
232
280
  else {
233
- lines.push(`- ${name}`);
281
+ lines.push(`- ${name}${metaCaptionMarkdown(item.meta)}`);
234
282
  }
235
283
  }
236
284
  if (overflowImages.length > 0) {
@@ -239,7 +287,8 @@ export function attachmentsCommentBody(items, galleries = [], marker = ATTACHMEN
239
287
  for (const item of overflowImages) {
240
288
  const name = item.key.slice(item.key.lastIndexOf("/") + 1);
241
289
  const link = item.pageUrl ?? item.url;
242
- lines.push(link ? `- [${name}](${link})` : `- ${name}`);
290
+ const suffix = metaCaptionMarkdown(item.meta);
291
+ lines.push(link ? `- [${name}](${link})${suffix}` : `- ${name}${suffix}`);
243
292
  }
244
293
  lines.push("", "</details>", "");
245
294
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@buildinternet/uploads",
3
- "version": "0.21.0",
3
+ "version": "0.22.0",
4
4
  "description": "CLI and client for uploads.sh — workspace-scoped image hosting for GitHub embeds",
5
5
  "type": "module",
6
6
  "sideEffects": false,