@buildinternet/uploads 0.17.0 → 0.19.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/cli.js CHANGED
@@ -105,6 +105,7 @@ const ERROR_HINTS = {
105
105
  UPLOAD_BUDGET: QUOTA_HINT,
106
106
  KEY_POLICY: "hint: use a typed destination (`--destination screenshots|gh`) or an allowed prefix; operators set allowlists with `pnpm workspace:limits --allowed-prefixes`\n",
107
107
  UNAUTHORIZED: "hint: token rejected — run `uploads login` to sign in again, or check UPLOADS_TOKEN / --token\n",
108
+ INSUFFICIENT_SCOPE: "hint: re-run `uploads login` for a full-scope token (or mint one with --scopes)\n",
108
109
  BROWSER_NOT_FOUND: "hint: no local browser found; try --via remote, or install Chrome / npx playwright install chromium\n",
109
110
  RATE_LIMITED: "hint: transient rate limit — wait ~60s and retry\n",
110
111
  };
package/dist/client.d.ts CHANGED
@@ -256,6 +256,8 @@ export interface UsageResult {
256
256
  storageRemainingBytes?: number;
257
257
  maxUploadsPerPeriod?: number;
258
258
  uploadsRemaining?: number;
259
+ /** File scopes of the presented token (servers ≥ this field's release). */
260
+ scopes?: Array<TokenScope>;
259
261
  }
260
262
  export interface ReconcileResult {
261
263
  workspace: string;
@@ -427,11 +429,13 @@ export declare function mintWorkspaceToken(apiUrl: string, accessToken: string,
427
429
  export declare function extractErrorFields(body: unknown, fallback?: string): {
428
430
  message: string;
429
431
  code?: string;
432
+ requiredScope?: string;
430
433
  };
431
434
  /** Fetch + parse an error-response body via {@link extractErrorFields}. */
432
435
  export declare function parseErrorEnvelope(res: Response, fallback?: string): Promise<{
433
436
  message: string;
434
437
  code?: string;
438
+ requiredScope?: string;
435
439
  }>;
436
440
  export declare function createUploadsClient(config: UploadsClientConfig): {
437
441
  put(body: Uint8Array, opts: PutOptions & {
package/dist/client.js CHANGED
@@ -188,11 +188,17 @@ function usageBase(config) {
188
188
  function galleriesBase(config) {
189
189
  return `${config.apiUrl}/v1/${encodeURIComponent(config.workspace)}/galleries`;
190
190
  }
191
- function mapApiError(status, error, code) {
191
+ function mapApiError(status, error, code, requiredScope) {
192
192
  const normalized = error.toLowerCase();
193
193
  if (status === 401 || code === "unauthorized" || normalized === "unauthorized") {
194
194
  return new UploadsError(error, "UNAUTHORIZED", status);
195
195
  }
196
+ if (code === "insufficient_scope") {
197
+ // The server's message is a bare "forbidden" — name the missing scope so
198
+ // the failure is actionable without a doctor run.
199
+ const message = requiredScope ? `token lacks the ${requiredScope} scope` : error;
200
+ return new UploadsError(message, "INSUFFICIENT_SCOPE", status);
201
+ }
196
202
  if (status === 404 || code === "not_found" || normalized === "not found") {
197
203
  return new UploadsError(error, "NOT_FOUND", status);
198
204
  }
@@ -226,9 +232,13 @@ export function extractErrorFields(body, fallback = "request failed") {
226
232
  const err = body.error;
227
233
  if (typeof err === "object" && err && "message" in err) {
228
234
  const nested = err;
235
+ const details = nested.details;
229
236
  return {
230
237
  message: typeof nested.message === "string" ? nested.message : fallback,
231
238
  code: typeof nested.code === "string" ? nested.code : undefined,
239
+ ...(typeof details?.required_scope === "string"
240
+ ? { requiredScope: details.required_scope }
241
+ : {}),
232
242
  };
233
243
  }
234
244
  if (typeof err === "string") {
@@ -246,8 +256,8 @@ export async function parseErrorEnvelope(res, fallback = "request failed") {
246
256
  return extractErrorFields(body, fallback);
247
257
  }
248
258
  async function parseErrorResponse(res) {
249
- const { message, code } = await parseErrorEnvelope(res, res.statusText || "request failed");
250
- return mapApiError(res.status, message, code);
259
+ const { message, code, requiredScope } = await parseErrorEnvelope(res, res.statusText || "request failed");
260
+ return mapApiError(res.status, message, code, requiredScope);
251
261
  }
252
262
  export function createUploadsClient(config) {
253
263
  async function request(method, path, opts) {
@@ -19,7 +19,8 @@ Options:
19
19
  --create With --workspace: create the workspace first if your
20
20
  account doesn't have it yet (device flow only) — lets
21
21
  scripted/agent logins provision without a prompt
22
- --scopes <list> Comma-separated scopes (default: files:read,files:write)
22
+ --scopes <list> Comma-separated scopes (default:
23
+ files:read,files:write,files:delete)
23
24
  --label <text> Token label (default: this machine's hostname)
24
25
  --auth-url <url> Auth base (default: https://auth.uploads.sh)
25
26
  --no-open Don't try to open a browser automatically
@@ -201,7 +202,16 @@ export async function obtainDeviceAccessToken(authUrl, opts = {}, io = defaultDe
201
202
  * it in a browser, poll for the session token, then mint a workspace token.
202
203
  */
203
204
  async function runDeviceLogin(parsed, opts, io) {
204
- const scopes = parseScopes(flagString(parsed.flags, "--scopes"));
205
+ // Interactive login is the user's own credential: default to the full file
206
+ // scope set (including delete) so the CLI's own `delete` command works out
207
+ // of the box. Automation tokens minted elsewhere keep the server's
208
+ // conservative read+write default — narrowness there is a deliberate
209
+ // choice, not a surprise. `--scopes` still overrides.
210
+ const scopes = parseScopes(flagString(parsed.flags, "--scopes")) ?? [
211
+ "files:read",
212
+ "files:write",
213
+ "files:delete",
214
+ ];
205
215
  const label = flagString(parsed.flags, "--label") ?? safeHostname();
206
216
  const requestedWorkspace = flagString(parsed.flags, "--workspace");
207
217
  // Make the target explicit: a bare `uploads login` on a self-hosted install
@@ -284,6 +284,8 @@ export interface DoctorReport {
284
284
  uploadsInPeriod?: number;
285
285
  error?: string;
286
286
  };
287
+ /** File scopes of the presented token (absent against pre-scopes servers). */
288
+ scopes?: string[];
287
289
  /** Workspace/token mismatch warning (also present in hints). */
288
290
  warning?: string;
289
291
  hints: string[];
package/dist/commands.js CHANGED
@@ -2052,6 +2052,7 @@ export async function buildDoctorReport(config, client, detectRoots) {
2052
2052
  }
2053
2053
  }
2054
2054
  let usage;
2055
+ let scopes;
2055
2056
  if (authOk) {
2056
2057
  try {
2057
2058
  const snap = await client.usage();
@@ -2061,6 +2062,10 @@ export async function buildDoctorReport(config, client, detectRoots) {
2061
2062
  objects: snap.objects,
2062
2063
  uploadsInPeriod: snap.uploadsInPeriod,
2063
2064
  };
2065
+ scopes = snap.scopes;
2066
+ if (scopes && !scopes.includes("files:delete")) {
2067
+ hints.push("token lacks files:delete (`uploads delete` will be forbidden) — re-run `uploads login` for a full-scope token");
2068
+ }
2064
2069
  }
2065
2070
  catch (err) {
2066
2071
  usage = {
@@ -2084,6 +2089,7 @@ export async function buildDoctorReport(config, client, detectRoots) {
2084
2089
  health,
2085
2090
  auth: { ok: authOk, error: authError },
2086
2091
  usage,
2092
+ scopes,
2087
2093
  warning: mismatch,
2088
2094
  hints,
2089
2095
  browser,
@@ -2106,6 +2112,8 @@ export async function runDoctor(ctx, args, help = false) {
2106
2112
  `workspace: ${report.workspace}`,
2107
2113
  `auth: ${report.auth.ok ? "ok" : `failed — ${report.auth.error ?? "no token"}`}`,
2108
2114
  ];
2115
+ if (report.scopes)
2116
+ lines.push(`scopes: ${report.scopes.join(", ")}`);
2109
2117
  if (report.usage) {
2110
2118
  lines.push(report.usage.ok
2111
2119
  ? `usage: ${formatByteSize(report.usage.bytes ?? 0)}, ${formatCount(report.usage.objects ?? 0)} objects, ${formatCount(report.usage.uploadsInPeriod ?? 0)} uploads this period`
package/dist/errors.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export type UploadsErrorCode = "MISSING_TOKEN" | "NO_PUBLIC_URL" | "FILE_NOT_FOUND" | "NOT_FOUND" | "UNAUTHORIZED" | "INVALID_KEY" | "KEY_POLICY" | "STORAGE_QUOTA" | "UPLOAD_BUDGET" | "GITHUB_REQUIRED" | "API_ERROR" | "NETWORK" | "USAGE" | "BROWSER_NOT_FOUND" | "RENDER_FAILED" | "RATE_LIMITED";
1
+ export type UploadsErrorCode = "MISSING_TOKEN" | "NO_PUBLIC_URL" | "FILE_NOT_FOUND" | "NOT_FOUND" | "UNAUTHORIZED" | "INSUFFICIENT_SCOPE" | "INVALID_KEY" | "KEY_POLICY" | "STORAGE_QUOTA" | "UPLOAD_BUDGET" | "GITHUB_REQUIRED" | "API_ERROR" | "NETWORK" | "USAGE" | "BROWSER_NOT_FOUND" | "RENDER_FAILED" | "RATE_LIMITED";
2
2
  export declare class UploadsError extends Error {
3
3
  readonly code: UploadsErrorCode;
4
4
  readonly status?: number;
package/dist/github.js CHANGED
@@ -99,6 +99,11 @@ export function ghMetadataForBranch(repo, branch, now = new Date()) {
99
99
  "gh.kind": "branch",
100
100
  "gh.branch": branchLower,
101
101
  "gh.staged-at": now.toISOString().replace(/\.\d{3}Z$/, "Z"),
102
+ // Lifecycle tag (issue #339): flipped to "promoted" by server-side
103
+ // promotion, so "in-flight staged media" is a plain equality query
104
+ // (`meta.gh.status=staged`) — the metadata filter API can't express
105
+ // "gh.promoted-at absent".
106
+ "gh.status": "staged",
102
107
  };
103
108
  }
104
109
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@buildinternet/uploads",
3
- "version": "0.17.0",
3
+ "version": "0.19.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,