@runlot/api 0.1.0-rc.12 → 0.1.0-rc.121

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/index.js CHANGED
@@ -1,5 +1,23 @@
1
1
  export const API_VERSION = "v1";
2
- /** 서버가 돌려준 오류 봉투를 그대로 들고 있는 예외. */
2
+ /** The shared query for browsing. Empty values are not carried — the server reads "empty means default". */
3
+ function gitQuery(ref, path) {
4
+ const q = new URLSearchParams();
5
+ if (ref !== "")
6
+ q.set("ref", ref);
7
+ if (path !== "")
8
+ q.set("path", path);
9
+ return q.size > 0 ? `?${q.toString()}` : "";
10
+ }
11
+ export const API_SCOPES = [
12
+ "project:read",
13
+ "deploy",
14
+ "db:read",
15
+ "db:write",
16
+ "repo:read",
17
+ "repo:write",
18
+ "org:admin",
19
+ ];
20
+ /** The exception that carries the server's error envelope unchanged. */
3
21
  export class ApiError extends Error {
4
22
  status;
5
23
  code;
@@ -14,10 +32,11 @@ export class ApiError extends Error {
14
32
  }
15
33
  }
16
34
  /**
17
- * cp-public 클라이언트.
35
+ * The cp-public client.
18
36
  *
19
- * CLI와 대시보드가 같은 것을 쓴다. 각자 fetch를 부르면 오류 해석이 조금씩
20
- * 달라지고, 그 차이는 장애 중에 드러난다.
37
+ * The CLI and the dashboard use the same one. If each called fetch itself, their
38
+ * error handling would drift a little, and that difference shows up during an
39
+ * incident.
21
40
  */
22
41
  function dbPath(org, name) {
23
42
  return `/v1/orgs/${encodeURIComponent(org)}/projects/${encodeURIComponent(name)}/database`;
@@ -45,8 +64,21 @@ export class Client {
45
64
  body = (await res.json());
46
65
  }
47
66
  catch {
48
- // 오류 응답이 JSON이 아닌 경우(프록시가 만든 502 등)도 같은 예외로
49
- // 올린다 — 호출부가 두 가지 실패 모양을 다루지 않게.
67
+ // An error response that is not JSON (a 502 made by a proxy, for instance)
68
+ // is raised as the same exception — so the caller does not have to handle
69
+ // two shapes of failure.
70
+ }
71
+ if (body === undefined && res.status >= 502 && res.status <= 504) {
72
+ // The edge replaced the 5xx body with its own HTML. The code and reason
73
+ // the server wrote are not here — so say that, rather than "unknown". On
74
+ // 2026-09-10 a registrar rejection came out this way as
75
+ // "unknown: POST … → 502".
76
+ body = {
77
+ error: {
78
+ code: "edge_" + res.status,
79
+ message: "The server returned an error (" + res.status + "), and the error details could not be read. Try again shortly.",
80
+ },
81
+ };
50
82
  }
51
83
  throw new ApiError(res.status, body, `${method} ${path} → ${res.status}`);
52
84
  }
@@ -54,6 +86,21 @@ export class Client {
54
86
  return undefined;
55
87
  return (await res.json());
56
88
  }
89
+ /** A body that is not JSON (the raw mail). Error handling is the same as request. */
90
+ async text(method, path) {
91
+ const res = await fetch(new URL(path, this.opts.baseUrl), { method, headers: this.headers() });
92
+ if (!res.ok) {
93
+ let body;
94
+ try {
95
+ body = (await res.json());
96
+ }
97
+ catch {
98
+ // The same reason as in request
99
+ }
100
+ throw new ApiError(res.status, body, `${method} ${path} → ${res.status}`);
101
+ }
102
+ return await res.text();
103
+ }
57
104
  json(method, path, body) {
58
105
  if (body === undefined)
59
106
  return this.request(method, path);
@@ -65,38 +112,205 @@ export class Client {
65
112
  devLogin(subject) {
66
113
  return this.json("POST", "/v1/auth/dev/login", { subject });
67
114
  }
68
- /** 켜진 로그인 방식. 로그인 화면이 버튼을 그리는 데 쓴다. */
69
- authProviders() {
70
- return this.json("GET", "/v1/auth/providers");
115
+ /**
116
+ * Takes the sign-up and sends the confirmation mail. Neither a session nor a
117
+ * password is created here — the password is chosen in `verifyEmail` by
118
+ * whoever opens the mailbox.
119
+ */
120
+ passwordSignUp(email, name) {
121
+ return this.json("POST", "/v1/auth/password/sign-up", {
122
+ email,
123
+ ...(name === undefined ? {} : { name }),
124
+ });
125
+ }
126
+ passwordSignIn(email, password) {
127
+ return this.json("POST", "/v1/auth/password/sign-in", { email, password });
128
+ }
129
+ /** The same accepted response comes back whether or not the email exists. */
130
+ passwordForgot(email) {
131
+ return this.json("POST", "/v1/auth/password/forgot", { email });
132
+ }
133
+ passwordReset(token, password) {
134
+ return this.json("POST", "/v1/auth/password/reset", { token, password });
135
+ }
136
+ /** Uses the confirmation link and sets the account's first password. */
137
+ verifyEmail(token, password) {
138
+ return this.json("POST", "/v1/auth/email/verify", { token, password });
139
+ }
140
+ /**
141
+ * Finishes a sign-in that came back `{mfa:"totp"}`.
142
+ *
143
+ * Callable without a token: `pending` is the credential, and it is not a
144
+ * session — no other endpoint accepts it.
145
+ */
146
+ completeTotpLogin(pending, factor) {
147
+ return this.json("POST", "/v1/auth/totp", { pending, ...factor });
148
+ }
149
+ /** This account's two-factor state, including which orgs require it. */
150
+ getTotp() {
151
+ return this.json("GET", "/v1/me/totp");
152
+ }
153
+ /**
154
+ * Draws a secret to scan. It does **not** turn the factor on — `confirmTotp`
155
+ * does, after the app has proved it can produce a code.
156
+ */
157
+ enrollTotp() {
158
+ return this.json("POST", "/v1/me/totp/enroll");
159
+ }
160
+ /** Turns the factor on and hands back the ten recovery codes. They are shown once. */
161
+ confirmTotp(code) {
162
+ return this.json("POST", "/v1/me/totp/confirm", { code });
163
+ }
164
+ /** Turns it off. A code or a recovery code is required — the session alone is not. */
165
+ disableTotp(factor) {
166
+ return this.json("DELETE", "/v1/me/totp", factor);
71
167
  }
72
168
  /**
73
- * GitHub OAuth 시작 URL. 브라우저가 따라가는 302 라 fetch 로 부르지 않고
74
- * `window.location` 에 넣는다. `redirect` 는 서버 허용 목록에 있어야 한다.
169
+ * The GitHub OAuth start URL. It is a 302 the browser follows, so it goes into
170
+ * `window.location` rather than being called with fetch. `redirect` has to be
171
+ * on the server's allow-list.
75
172
  */
76
173
  githubStartUrl(redirect) {
77
174
  const u = new URL("/v1/auth/github/start", this.opts.baseUrl);
78
175
  u.searchParams.set("redirect", redirect);
79
176
  return u.toString();
80
177
  }
178
+ /** The Google OAuth start URL. Uses the same dashboard callback shape as GitHub. */
179
+ googleStartUrl(redirect) {
180
+ const u = new URL("/v1/auth/google/start", this.opts.baseUrl);
181
+ u.searchParams.set("redirect", redirect);
182
+ return u.toString();
183
+ }
184
+ /**
185
+ * The generic OIDC start URL (docs/onprem-delivery.md §7.7). Same 302-and-fragment
186
+ * shape as the other two; which provider is behind it is the installation's choice.
187
+ */
188
+ oidcStartUrl(redirect) {
189
+ const u = new URL("/v1/auth/oidc/start", this.opts.baseUrl);
190
+ u.searchParams.set("redirect", redirect);
191
+ return u.toString();
192
+ }
193
+ /**
194
+ * Does this deployment federate an identity provider?
195
+ *
196
+ * **There is no endpoint that answers "which logins are on"** — one existed and was
197
+ * deleted, because its only reader was the login screen and it put a round trip in
198
+ * front of every login button (cp/internal/public/public.go). This is not that
199
+ * endpoint coming back: cp-public **does not register** the two OIDC routes when no
200
+ * issuer is configured, so the mux's own 404 is the answer, and the probe is a HEAD
201
+ * that no handler runs.
202
+ *
203
+ * Any other status means the route is there — a 400 (this probe carries no
204
+ * `redirect`) or a 502 (the provider's discovery document could not be read) both say
205
+ * the handler exists, and a button that leads to a readable error beats no button.
206
+ * A network failure answers false: on SaaS, where the answer is false anyway, a dead
207
+ * API address must not draw a login that cannot work.
208
+ */
209
+ async oidcAvailable() {
210
+ try {
211
+ const res = await fetch(new URL("/v1/auth/oidc/start", this.opts.baseUrl), { method: "HEAD" });
212
+ return res.status !== 404;
213
+ }
214
+ catch {
215
+ return false;
216
+ }
217
+ }
218
+ /**
219
+ * This installation's licence (docs/onprem-delivery.md §6.5). Readable by any
220
+ * signed-in user: the footer it feeds is on every page for everybody.
221
+ *
222
+ * On SaaS it answers `onPrem: false` rather than 404, so the caller has one shape to
223
+ * read and the surfaces draw nothing on the flag.
224
+ */
225
+ license() {
226
+ return this.json("GET", "/v1/license");
227
+ }
228
+ /**
229
+ * The names of this deployment (app domain, docs address). Callable without auth.
230
+ *
231
+ * This exists so the dashboard does not hard-code a domain into the screen
232
+ * (docs/environments.md §3.2) — hard-coded, the dev screen tells people a
233
+ * production address, and because that address opens, nobody ever says it is
234
+ * wrong.
235
+ */
236
+ siteConfig() {
237
+ return this.json("GET", "/v1/config");
238
+ }
81
239
  me() {
82
240
  return this.json("GET", "/v1/me");
83
241
  }
84
242
  listOrgs() {
85
243
  return this.json("GET", "/v1/orgs");
86
244
  }
245
+ /** Changes the display name only (admin). The slug is the name that becomes an address, so it is not on this surface. */
246
+ /**
247
+ * `requireTotp` is left out when it is not given, on purpose: the server reads
248
+ * an absent field as "leave it alone". Sending `false` by default would mean a
249
+ * rename silently switched the org's second-factor requirement off.
250
+ */
251
+ updateOrg(org, name, requireTotp) {
252
+ return this.json("PATCH", `/v1/orgs/${encodeURIComponent(org)}`, {
253
+ name,
254
+ ...(requireTotp === undefined ? {} : { requireTotp }),
255
+ });
256
+ }
257
+ /**
258
+ * Deletes the organization (admin). **This cannot be undone.**
259
+ *
260
+ * `confirm` has to be the organization slug exactly — the confirmation lives on
261
+ * the server because three places call this surface (the screen, the CLI, and
262
+ * scripts). If a live project, domain, repository, or DNS zone is left, it is a
263
+ * 409 `org_not_empty` and `details.holdings` says what is left — **projects
264
+ * already deleted are not counted.**
265
+ *
266
+ * The `purgeAfter` in the answer is when the rows actually disappear. By the
267
+ * moment it returns the organization is already gone from every surface; what
268
+ * remains is retention until that time.
269
+ */
270
+ deleteOrg(org, confirm) {
271
+ return this.json("DELETE", `/v1/orgs/${encodeURIComponent(org)}`, { confirm });
272
+ }
87
273
  createOrg(slug, name) {
88
274
  return this.json("POST", "/v1/orgs", { slug, name });
89
275
  }
90
276
  /**
91
- * org 의 최근 알림 (docs/limits.md §3.4).
277
+ * The organization's recent notifications (docs/limits.md §3.4).
92
278
  *
93
- * `sentAt` 이 없는 행은 아직 큐에 있거나 받을 이메일이 없는 것이다 — 후자에게
94
- * 이 목록이 유일한 통로라, 화면은 두 상태를 같게 그린다.
279
+ * A row with no `sentAt` is either still queued or has no email address to
280
+ * reach — and for the second, this list is the only channel, so the screen
281
+ * draws both states the same way.
95
282
  */
96
283
  listNotifications(org, limit) {
97
284
  const q = limit === undefined ? "" : `?limit=${limit}`;
98
285
  return this.json("GET", `/v1/orgs/${encodeURIComponent(org)}/notifications${q}`);
99
286
  }
287
+ /**
288
+ * The plan and billing state (docs/billing.md). If `enabled` is false, this
289
+ * deployment has no billing — the screen shows only a contact address.
290
+ */
291
+ getBilling(org) {
292
+ return this.json("GET", `/v1/orgs/${encodeURIComponent(org)}/billing`);
293
+ }
294
+ /**
295
+ * A Pro checkout session. Send the browser to the `url` that comes back. Even
296
+ * once payment finishes, the thing that changes the plan is the webhook, so
297
+ * read `getBilling` again after the browser returns.
298
+ */
299
+ createBillingCheckout(org, returnTo) {
300
+ return this.json("POST", `/v1/orgs/${encodeURIComponent(org)}/billing/checkout`, { returnTo });
301
+ }
302
+ /** A one-time `url` for the customer portal (payment method, receipts, cancellation). */
303
+ createBillingPortal(org, returnTo) {
304
+ return this.json("POST", `/v1/orgs/${encodeURIComponent(org)}/billing/portal`, { returnTo });
305
+ }
306
+ /** The customer session token for the payment-method embed (1 hour). An org with no payment history gets a 404. */
307
+ createBillingSession(org) {
308
+ return this.json("POST", `/v1/orgs/${encodeURIComponent(org)}/billing/session`, {});
309
+ }
310
+ /** Schedules cancellation at the end of the period (`cancel: true`), or undoes it. There is no immediate revocation. */
311
+ cancelBilling(org, body) {
312
+ return this.json("POST", `/v1/orgs/${encodeURIComponent(org)}/billing/cancel`, body);
313
+ }
100
314
  listProjects(org) {
101
315
  return this.json("GET", `/v1/orgs/${encodeURIComponent(org)}/projects`);
102
316
  }
@@ -106,17 +320,210 @@ export class Client {
106
320
  getProject(org, name) {
107
321
  return this.json("GET", `/v1/orgs/${encodeURIComponent(org)}/projects/${encodeURIComponent(name)}`);
108
322
  }
323
+ /**
324
+ * Renames the project (admin). **The default address moves with it and there is
325
+ * no alias** — which is why the response carries the new address. Unlike the
326
+ * org slug, this name can change because it is the inner label of the org
327
+ * wildcard certificate.
328
+ */
329
+ updateProject(org, name, next) {
330
+ return this.json("PATCH", this.projectPath(org, name), { name: next });
331
+ }
332
+ /**
333
+ * Starts tearing the project down (admin). **This cannot be undone and leaves
334
+ * no recovery generation.** `confirm` has to be the project name exactly.
335
+ */
336
+ deleteProject(org, name, confirm) {
337
+ return this.json("DELETE", this.projectPath(org, name), { confirm });
338
+ }
339
+ /**
340
+ * Disables a project (admin): it stops serving and taking new backups, and
341
+ * keeps everything it has. A project disabled for a year is deleted for good.
342
+ */
343
+ disableProject(org, name) {
344
+ return this.json("POST", `${this.projectPath(org, name)}/disable`);
345
+ }
346
+ /** Enables a disabled project (admin). 409 `project_limit` when there is no room for it. */
347
+ enableProject(org, name) {
348
+ return this.json("POST", `${this.projectPath(org, name)}/enable`);
349
+ }
350
+ /** Watches the teardown operation to its end even after the project is gone. */
351
+ getOrgOperation(org, opId) {
352
+ return this.json("GET", `/v1/orgs/${encodeURIComponent(org)}/operations/${encodeURIComponent(opId)}`);
353
+ }
354
+ /** Everything the project's first screen takes in one call. Usage calls `getUsage` alongside it. */
355
+ getProjectOverview(org, name) {
356
+ return this.json("GET", `${this.projectPath(org, name)}/overview`);
357
+ }
109
358
  listDeployments(org, name) {
110
359
  return this.json("GET", `/v1/orgs/${encodeURIComponent(org)}/projects/${encodeURIComponent(name)}/deployments`);
111
360
  }
112
- /** 본문이 곧 번들이다. 다이제스트 하나가 코드와 자산 전부를 덮는다. */
113
- deploy(org, name, bundle) {
361
+ /**
362
+ * One deployment, with `migrations` when the control plane applied its
363
+ * `migrations/` — which today means a preview deployed with `db=branch`
364
+ * (docs/studio.md §6.6 step 2).
365
+ *
366
+ * A preview's deployment is read under **the parent project's name**, the name
367
+ * the caller deployed through: the preview is a project of its own, but the
368
+ * caller never learns its name.
369
+ */
370
+ getDeployment(org, name, deploymentId) {
371
+ return this.json("GET", `${this.projectPath(org, name)}/deployments/${encodeURIComponent(deploymentId)}`);
372
+ }
373
+ /**
374
+ * Scheduled runs: what the live deployment declares, when each fires next, and
375
+ * the recent history (docs/cron.md §8). `limit` bounds the history rows only —
376
+ * the schedules and the per-schedule last run are never paged.
377
+ */
378
+ getProjectCron(org, name, limit) {
379
+ const qs = limit ? `?limit=${limit}` : "";
380
+ return this.json("GET", `${this.projectPath(org, name)}/cron${qs}`);
381
+ }
382
+ /**
383
+ * Queues: what the live deployment declares, how deep each one is, and the
384
+ * recent batch history (docs/queues.md §6). The depth is read from the
385
+ * project's own database; `depthError` on the answer says when it could not be.
386
+ */
387
+ getProjectQueues(org, name, limit) {
388
+ const qs = limit ? `?limit=${limit}` : "";
389
+ return this.json("GET", `${this.projectPath(org, name)}/queues${qs}`);
390
+ }
391
+ /**
392
+ * Durable Objects: how many this project has placed, against the two caps
393
+ * (docs/durable-objects.md).
394
+ *
395
+ * **It answers how much, never which.** There is no actor id in the response
396
+ * and none is coming — those name individual objects, and a list of them would
397
+ * be an object enumeration wearing a quota's name. `nodes` is keyed by node id
398
+ * because `nodeLimit` is a separate bound that can refuse a placement while
399
+ * the project-wide cap still has room.
400
+ */
401
+ getProjectDurableObjects(org, name) {
402
+ return this.json("GET", `${this.projectPath(org, name)}/durable-objects`);
403
+ }
404
+ queuePath(org, name, queue) {
405
+ return `${this.projectPath(org, name)}/queues/${encodeURIComponent(queue)}`;
406
+ }
407
+ listQueueRuns(org, name, queue, limit) {
408
+ const qs = limit ? `?limit=${limit}` : "";
409
+ return this.json("GET", `${this.queuePath(org, name, queue)}/runs${qs}`);
410
+ }
411
+ /** The head of the queue without leasing it — looking cannot delay a delivery. */
412
+ peekQueue(org, name, queue, limit) {
413
+ const qs = limit ? `?limit=${limit}` : "";
414
+ return this.json("GET", `${this.queuePath(org, name, queue)}/messages${qs}`);
415
+ }
416
+ /** Irreversible. The confirmation is the caller's — the server holds none. */
417
+ purgeQueue(org, name, queue) {
418
+ return this.json("DELETE", `${this.queuePath(org, name, queue)}/messages`);
419
+ }
420
+ /**
421
+ * Moves a dead-letter queue's messages back. `to` is required only when the
422
+ * dead-letter queue belongs to more than one consumer, or to none.
423
+ */
424
+ redriveQueue(org, name, queue, to) {
425
+ return this.json("POST", `${this.queuePath(org, name, queue)}/redrive`, to === undefined ? {} : { to });
426
+ }
427
+ /**
428
+ * The vector store: every namespace with its count and width, and the plan's
429
+ * bounds (docs/vectors.md §4). Read from the project's own database;
430
+ * `readError` on the answer says when it could not be.
431
+ */
432
+ getProjectVectors(org, name) {
433
+ return this.json("GET", `${this.projectPath(org, name)}/vectors`);
434
+ }
435
+ /** Irreversible. The confirmation is the caller's — the server holds none. */
436
+ deleteVectorNamespace(org, name, namespace) {
437
+ return this.json("DELETE", `${this.projectPath(org, name)}/vectors/${encodeURIComponent(namespace)}`);
438
+ }
439
+ /** The body is the bundle. One digest covers all of the code and the assets. */
440
+ /**
441
+ * Uploads the bundle. `headers` carries the provenance headers
442
+ * (`X-Runlot-Git-*`, openapi createDeployment) — the body is the bundle
443
+ * itself, so the metadata can only travel in headers.
444
+ */
445
+ deploy(org, name, bundle, headers = {}) {
114
446
  return this.request("POST", `/v1/orgs/${encodeURIComponent(org)}/projects/${encodeURIComponent(name)}/deployments`, {
115
447
  body: bundle,
116
- headers: { "Content-Type": "application/gzip" },
448
+ headers: { ...headers, "Content-Type": "application/gzip" },
449
+ });
450
+ }
451
+ /**
452
+ * The same upload, aimed at a preview (docs/preview.md). **It does not move
453
+ * production's live pointer** — that is the whole difference, and it is why
454
+ * this is a named method rather than an optional argument on `deploy`: a
455
+ * caller that forgets the argument deploys to production.
456
+ *
457
+ * `db` picks what `env.db` points at (`branch` by default, `shared` at
458
+ * production's own database, `none` for no database), and `from` pours the
459
+ * branch from a named generation instead of the newest one.
460
+ */
461
+ deployPreview(org, name, bundle,
462
+ // `| undefined` spelled out: under exactOptionalPropertyTypes a caller that
463
+ // forwards an unset flag is passing the property, not omitting it.
464
+ opts, headers = {}) {
465
+ const q = new URLSearchParams({ preview: opts.preview });
466
+ // Empty values are not carried: the server reads "absent" as its default, and
467
+ // `?db=` would be neither absent nor a member of the enum.
468
+ if (opts.db)
469
+ q.set("db", opts.db);
470
+ if (opts.from)
471
+ q.set("from", opts.from);
472
+ return this.request("POST", `${this.projectPath(org, name)}/deployments?${q.toString()}`, {
473
+ body: bundle,
474
+ headers: { ...headers, "Content-Type": "application/gzip" },
117
475
  });
118
476
  }
119
- /** 멱등이다. 두 번 불러도 비밀번호는 굴지 않고 같은 접속 정보가 온다. */
477
+ /** The parent's live previews (viewer). A deleted preview is not listed. */
478
+ listPreviews(org, name) {
479
+ return this.json("GET", `${this.projectPath(org, name)}/previews`);
480
+ }
481
+ /**
482
+ * Deletes a preview and its branch (member).
483
+ *
484
+ * No confirmation string, unlike `deleteProject`: a branch is a copy taken at a
485
+ * moment and nothing in it is the only copy of anything.
486
+ */
487
+ deletePreview(org, name, preview) {
488
+ return this.json("DELETE", `${this.projectPath(org, name)}/previews/${encodeURIComponent(preview)}`);
489
+ }
490
+ /**
491
+ * Makes the preview's artifact the parent's live deployment (member).
492
+ *
493
+ * **The code, never the data** (docs/preview.md §7). The branch is not merged
494
+ * and production's database is not replaced. The preview stays alive.
495
+ */
496
+ promotePreview(org, name, preview) {
497
+ return this.json("POST", `${this.projectPath(org, name)}/previews/${encodeURIComponent(preview)}/promote`);
498
+ }
499
+ /**
500
+ * The project's share link — `<label>.share.<app-domain>`, the one address
501
+ * Studio hands to somebody who is not a developer (docs/studio.md §6.9).
502
+ *
503
+ * 404 when the project has never been shared: a link is minted by
504
+ * `putShareLink`, never as a side effect of reading.
505
+ */
506
+ getShareLink(org, name) {
507
+ return this.json("GET", `${this.projectPath(org, name)}/share`);
508
+ }
509
+ /**
510
+ * Points the share link at a preview, minting the label if there is none.
511
+ *
512
+ * Idempotent: the label is kept across shares, so somebody holding the link
513
+ * keeps working. `rotate` mints a new label and retires the old name in the
514
+ * same request ("링크 재발급"), so the project never has two live links.
515
+ */
516
+ putShareLink(org, name, opts = {}) {
517
+ return this.json("PUT", `${this.projectPath(org, name)}/share`, {
518
+ ...(opts.preview === undefined ? {} : { preview: opts.preview }),
519
+ ...(opts.rotate === undefined ? {} : { rotate: opts.rotate }),
520
+ });
521
+ }
522
+ /** Stops the link answering and forgets the label. The preview keeps running. */
523
+ deleteShareLink(org, name) {
524
+ return this.json("DELETE", `${this.projectPath(org, name)}/share`);
525
+ }
526
+ /** Idempotent. Called twice, the password is not rolled and the same connection details come back. */
120
527
  createDatabase(org, name) {
121
528
  return this.json("POST", `/v1/orgs/${encodeURIComponent(org)}/projects/${encodeURIComponent(name)}/database`);
122
529
  }
@@ -124,17 +531,18 @@ export class Client {
124
531
  return this.json("GET", `/v1/orgs/${encodeURIComponent(org)}/projects/${encodeURIComponent(name)}/database`);
125
532
  }
126
533
  /**
127
- * 접속 자격까지. `getDatabase` 와 라우트가 갈린 것은 역할 때문이다 —
128
- * viewer 는 여부를 알아도 되지만 비밀번호는 아니다 (proto/openapi.yaml).
534
+ * The connection credentials as well. The route is split from `getDatabase`
535
+ * because of roles — a viewer may know whether there is a database, but not
536
+ * the password (proto/openapi.yaml).
129
537
  */
130
538
  getDatabaseConnect(org, name) {
131
539
  return this.json("GET", `/v1/orgs/${encodeURIComponent(org)}/projects/${encodeURIComponent(name)}/database/connect`);
132
540
  }
133
- /** 접속 끝점만 — 자격 없음. viewer 도 받는다. `runlot port-forward` 가 쓴다. */
541
+ /** The endpoint only — no credentials. A viewer gets it too. Used by `runlot port-forward`. */
134
542
  getDatabaseEndpoint(org, name) {
135
543
  return this.json("GET", `${dbPath(org, name)}/endpoint`);
136
544
  }
137
- /** 단기 기계 자격 발급. 비밀번호는 이 응답에만 실린다. */
545
+ /** Issues a short-lived machine credential. The password is carried in this response and nowhere else. */
138
546
  createDatabaseToken(org, name, ttlSeconds) {
139
547
  return this.json("POST", `${dbPath(org, name)}/tokens`, ttlSeconds === undefined ? {} : { ttlSeconds });
140
548
  }
@@ -145,52 +553,84 @@ export class Client {
145
553
  return this.json("DELETE", `${dbPath(org, name)}/tokens/${encodeURIComponent(user)}`);
146
554
  }
147
555
  /**
148
- * 진단 이미지를 하나 만든다.
556
+ * Makes one diagnostic image.
149
557
  *
150
- * **멱등이 아니다** — 부를 때마다 이미지가 하나 생긴다. 그리고 느리다:
151
- * 응답은 덤프가 끝난 뒤에 오고, 그동안 그 프로젝트의 액터는 붙잡혀 있다
152
- * (proto/openapi.yaml).
558
+ * **Not idempotent** — every call makes another image. And it is slow: the
559
+ * response comes after the dump finishes, and that project's actor is held
560
+ * for the whole of it (proto/openapi.yaml).
153
561
  */
154
562
  exportDatabase(org, name) {
155
563
  return this.json("POST", `/v1/orgs/${encodeURIComponent(org)}/projects/${encodeURIComponent(name)}/database/export`);
156
564
  }
157
565
  /**
158
- * 복원이 고를 수 있는 세대 전부. 최신 순이고, `lastCheckedAt` 은 무변경
159
- * 스킵의 하트비트다 — 세대가 늘지 않는 것과 백업이 죽은 것을 가른다.
566
+ * Every generation a restore can choose, newest first. `lastCheckedAt` is the
567
+ * heartbeat of the no-change skip — it is what tells "no new generations"
568
+ * apart from "the backup is dead".
160
569
  */
161
570
  listGenerations(org, name) {
162
571
  return this.json("GET", `${this.dbPath(org, name)}/generations`);
163
572
  }
164
573
  /**
165
- * 세대로 되돌린다. **그 사이의 쓰기는 사라진다.**
574
+ * Runs one statement in the project's database (the dashboard console).
575
+ *
576
+ * `readOnly` is **omitted, not sent as false**, when the caller wants the safe
577
+ * mode: the server treats an absent field as read-only, so leaving it out is the
578
+ * one spelling that cannot be misread. Sending `false` is the explicit ask for
579
+ * write mode, and only then does the server run outside a transaction.
166
580
  *
167
- * 202 로 끝난다 — 돌아오는 것은 연산 id 이고, 진행은
168
- * `getDatabaseOperation` 으로 본다. `latest` 는 서버가 풀지 않는다:
169
- * 목록에서 고른 `(epoch, seq)` 를 그대로 싣는다 (proto/openapi.yaml).
581
+ * SQL failures arrive as an `ApiError` whose body carries `details.sqlstate` —
582
+ * PostgreSQL's own code, which is what the error panel shows.
583
+ */
584
+ executeSQL(org, name, sql, opts = {}) {
585
+ const body = { sql };
586
+ if (opts.readOnly === false)
587
+ body.readOnly = false;
588
+ if (opts.params !== undefined && opts.params.length > 0)
589
+ body.params = opts.params;
590
+ return this.json("POST", `${this.dbPath(org, name)}/execute`, body);
591
+ }
592
+ /**
593
+ * Rolls back to a generation. **Writes made since then are lost.**
594
+ *
595
+ * It ends at 202 — what comes back is an operation id, and progress is read
596
+ * through `getDatabaseOperation`. The server does not resolve `latest`: the
597
+ * `(epoch, seq)` chosen from the list is carried as-is (proto/openapi.yaml).
170
598
  */
171
599
  restoreDatabase(org, name, epoch, seq) {
172
600
  return this.json("POST", `${this.dbPath(org, name)}/restore`, { epoch, seq });
173
601
  }
174
602
  /**
175
- * 데이터베이스를 없앤다. `confirm` 은 프로젝트 이름과 **정확히** 같아야
176
- * 하고, 다르면 서버가 400 `confirm_mismatch` 로 거절한다.
603
+ * Rolls back to a restore point **inside** a backup (pg-feather docs/37):
604
+ * `txnId` is that commit, `at` is the last commit at or before that time. One
605
+ * backup carries the commits in between in order, so the place you can roll
606
+ * back to is not tied to a backup boundary. Everything else is as in
607
+ * restoreDatabase — 202, an operation id, and one safety backup before the
608
+ * rollback.
609
+ */
610
+ restoreDatabaseTo(org, name, target) {
611
+ return this.json("POST", `${this.dbPath(org, name)}/restore`, target);
612
+ }
613
+ /**
614
+ * Removes the database. `confirm` has to match the project name **exactly**;
615
+ * if it does not, the server refuses with 400 `confirm_mismatch`.
177
616
  */
178
617
  deleteDatabase(org, name, confirm) {
179
618
  return this.json("POST", `${this.dbPath(org, name)}/delete`, { confirm });
180
619
  }
181
- /** 연산 하나의 진행. 연산 id 는 자격이 아니라 프로젝트 경로 아래에 있다. */
620
+ /** The progress of one operation. The operation id is not a credential; it lives under the project path. */
182
621
  getDatabaseOperation(org, name, opId) {
183
622
  return this.json("GET", `${this.dbPath(org, name)}/operations/${encodeURIComponent(opId)}`);
184
623
  }
185
- /** 되돌릴 수 없는 phase 앞의 연산을 중단한다. */
624
+ /** Aborts an operation that has not yet reached a phase it cannot come back from. */
186
625
  abortDatabaseOperation(org, name, opId) {
187
626
  return this.json("POST", `${this.dbPath(org, name)}/operations/${encodeURIComponent(opId)}/abort`);
188
627
  }
189
628
  // ---- Phase 6 (docs/phase6.md) ----
190
629
  /**
191
- * 로그 라이브 테일. **JSON 이 아니라 스트림**이라 `request` 를 안 지난다 —
192
- * 돌려주는 것은 줄 단위 `LogEvent` 의 async iterable 이고, `signal` 을 abort
193
- * 하면 연결이 끊긴다. 오류 응답(4xx/5xx)은 다른 메서드와 같은 `ApiError` 다.
630
+ * A live tail of the logs. It is **a stream, not JSON**, so it does not go
631
+ * through `request` — what comes back is an async iterable of line-by-line
632
+ * `LogEvent`, and aborting `signal` closes the connection. An error response
633
+ * (4xx/5xx) is the same `ApiError` as in every other method.
194
634
  */
195
635
  async *tailLogs(org, name, opts = {}) {
196
636
  const q = new URLSearchParams();
@@ -210,7 +650,7 @@ export class Client {
210
650
  body = (await res.json());
211
651
  }
212
652
  catch {
213
- // 위의 request 와 같은 이유.
653
+ // The same reason as in request above.
214
654
  }
215
655
  throw new ApiError(res.status, body, `GET ${path} → ${res.status}`);
216
656
  }
@@ -238,14 +678,35 @@ export class Client {
238
678
  yield JSON.parse(rest);
239
679
  }
240
680
  finally {
241
- // 소비자가 일찍 끝내면(break) 서버 쪽 follow 도 끝나야 한다.
681
+ // If the consumer ends early (break), the server's follow has to end too.
242
682
  await reader.cancel().catch(() => { });
243
683
  }
244
684
  }
685
+ /**
686
+ * The stored lane (0062). It is a plain JSON page, not a stream: the history
687
+ * does not reach a node, so it answers for a project that is cold, parked, or
688
+ * has moved since the lines were written — every one of which is a 409 on
689
+ * `tailLogs`.
690
+ *
691
+ * `q` is a plain substring, matched case-insensitively. It is not a regular
692
+ * expression — `%` and `_` match themselves.
693
+ *
694
+ * Paging is keyset: pass the `cursor` from a page back to get the next, older
695
+ * one. A page with no `cursor` is the end of what is kept.
696
+ */
697
+ getLogHistory(org, name, opts = {}) {
698
+ const params = new URLSearchParams();
699
+ for (const [k, v] of Object.entries(opts)) {
700
+ if (v !== undefined && v !== "")
701
+ params.set(k, String(v));
702
+ }
703
+ const qs = params.size ? `?${params}` : "";
704
+ return this.json("GET", `${this.projectPath(org, name)}/logs/history${qs}`);
705
+ }
245
706
  listSecrets(org, name) {
246
707
  return this.json("GET", `${this.projectPath(org, name)}/secrets`);
247
708
  }
248
- /** 값은 이 호출로만 나간다. 어떤 응답에도 값은 없다. */
709
+ /** The value travels in this call only. It is in no response at all. */
249
710
  setSecret(org, name, secret, value) {
250
711
  return this.json("PUT", `${this.projectPath(org, name)}/secrets/${encodeURIComponent(secret)}`, { value });
251
712
  }
@@ -256,111 +717,268 @@ export class Client {
256
717
  const q = hours === undefined ? "" : `?hours=${hours}`;
257
718
  return this.json("GET", `${this.projectPath(org, name)}/usage${q}`);
258
719
  }
259
- // ---- 오브젝트 스토리지 (docs/storage.md §8) ----
260
- /** 부여 여부·접두·쿼터·사용량. `usedBytes` 는 노드 보고 주기만큼 늦다. */
720
+ // ---- Object storage (docs/storage.md §8) ----
721
+ /** Grant, prefix, quota, usage. `usedBytes` lags by the node's reporting interval. */
261
722
  getStorage(org, name) {
262
723
  return this.json("GET", `${this.projectPath(org, name)}/storage`);
263
724
  }
264
725
  /**
265
- * 스토리지를 부여한다. 멱등이라 두 번 눌러도 접두는 그대로다. 접두·쿼터를
266
- * 인자로 받지 않는 것이 요점이다 — 접두는 격리의 경계이고 쿼터는 요금제가
267
- * 정한다 (proto/openapi.yaml).
726
+ * Grants storage. It is idempotent, so pressing twice leaves the prefix as it
727
+ * was. Taking neither the prefix nor the quota as an argument is the point —
728
+ * the prefix is the isolation boundary and the quota is set by the plan
729
+ * (proto/openapi.yaml).
268
730
  */
269
731
  grantStorage(org, name) {
270
732
  return this.json("POST", `${this.projectPath(org, name)}/storage`);
271
733
  }
272
- /** 부여를 뗀다. **객체는 지워지지 않는다** (docs/storage.md §7). */
734
+ /** Removes the grant. **The objects are not deleted** (docs/storage.md §7). */
273
735
  revokeStorage(org, name) {
274
736
  return this.json("DELETE", `${this.projectPath(org, name)}/storage`);
275
737
  }
276
- // ---- 최종 사용자 인증 (docs/auth.md §9) ----
277
738
  /**
278
- * 부여 여부·설정·프로바이더·호스트. **가입자는 안 온다** — 그 표는 프로젝트
279
- * DB 안이고 (docs/auth.md §6) 워커의 `env.auth` 만 본다.
739
+ * The key prefixes this project serves unsigned (docs/storage.md §4.7).
740
+ *
741
+ * A prefix is stored normalized — `assets` becomes `assets/`, and `/` becomes
742
+ * the empty string, which means every key. The value that comes back is the
743
+ * value that governs, so a screen must render the response rather than what
744
+ * the user typed.
745
+ */
746
+ listStoragePublic(org, name) {
747
+ return this.json("GET", `${this.projectPath(org, name)}/storage/public`);
748
+ }
749
+ /** Publishes a prefix. Idempotent. All three verbs answer with the whole list. */
750
+ addStoragePublic(org, name, prefix) {
751
+ return this.json("POST", `${this.projectPath(org, name)}/storage/public`, { prefix });
752
+ }
753
+ /**
754
+ * Unpublishes a prefix. Idempotent.
280
755
  *
281
- * `providers` 에는 client id 만 있다. 클라이언트 시크릿은 봉인돼 어느
282
- * 응답에도 안 나온다.
756
+ * **The world keeps reading for up to a minute.** The object front caches the
757
+ * list, so this call is not a revocation that takes effect at the instant it
758
+ * returns — a screen that says "no longer public" the moment this resolves is
759
+ * telling a small lie.
760
+ */
761
+ removeStoragePublic(org, name, prefix) {
762
+ return this.json("DELETE", `${this.projectPath(org, name)}/storage/public`, { prefix });
763
+ }
764
+ // ---- Email (docs/email.md §5) ----
765
+ /** Grant, address, sender, limits, recent counts. The address comes back as a preview even without a grant. */
766
+ getEmail(org, name) {
767
+ return this.json("GET", `${this.projectPath(org, name)}/email`);
768
+ }
769
+ /** Turns it on. **Idempotent** — we choose the address. The deploy reads `"email": true` and calls this. */
770
+ grantEmail(org, name) {
771
+ return this.json("POST", `${this.projectPath(org, name)}/email`);
772
+ }
773
+ /** Turns it off. Mail arriving at that address now gets a 550. */
774
+ revokeEmail(org, name) {
775
+ return this.json("DELETE", `${this.projectPath(org, name)}/email`);
776
+ }
777
+ // ---- Custom sending domains (docs/email.md §2.4) ----
778
+ /** The project's own sending domains, each with the records to publish. */
779
+ listEmailDomains(org, name) {
780
+ return this.json("GET", `${this.projectPath(org, name)}/email/domains`);
781
+ }
782
+ /**
783
+ * Creates the provider identity and answers with the records to publish.
784
+ * `publish` writes them into the zone when we host one covering the domain.
785
+ */
786
+ addEmailDomain(org, name, domain, publish = false) {
787
+ return this.json("POST", `${this.projectPath(org, name)}/email/domains`, { domain, publish });
788
+ }
789
+ /** Re-checks now. The background loop does the same once a minute while pending. */
790
+ verifyEmailDomain(org, name, domain) {
791
+ return this.json("POST", `${this.projectPath(org, name)}/email/domains/${encodeURIComponent(domain)}/verify`);
792
+ }
793
+ /** Deletes the identity and the row. Sending from it is refused from the next convergence. */
794
+ removeEmailDomain(org, name, domain) {
795
+ return this.json("DELETE", `${this.projectPath(org, name)}/email/domains/${encodeURIComponent(domain)}`);
796
+ }
797
+ // ---- AI (docs/ai.md §6) ----
798
+ /**
799
+ * The model catalogue. **Being logged in is enough to see it** — what you can
800
+ * buy is not an organization's secret, and this list is the price table.
801
+ *
802
+ * Prices are integer **micro-cents** per million tokens (1 µ¢ = 1e-8 USD).
803
+ * Not letting the server pick the decimal place is the point — a small call
804
+ * rounded to cents becomes 0.
805
+ */
806
+ listAIModels() {
807
+ return this.json("GET", "/v1/ai/models");
808
+ }
809
+ /** Grant, limits, the org balance, 24 h usage. The limits come back as a preview even without a grant. */
810
+ getAI(org, name) {
811
+ return this.json("GET", `${this.projectPath(org, name)}/ai`);
812
+ }
813
+ /** Turns it on. **Idempotent.** The deploy reads `"ai": true` and calls this. */
814
+ grantAI(org, name) {
815
+ return this.json("POST", `${this.projectPath(org, name)}/ai`);
816
+ }
817
+ /** Turns it off. **The ledger and the balance are untouched** — the balance belongs to the org and the grant belongs to the project. */
818
+ revokeAI(org, name) {
819
+ return this.json("DELETE", `${this.projectPath(org, name)}/ai`);
820
+ }
821
+ /** The org's credit balance and its automatic top-up settings. The balance is in micro-cents and can be negative. */
822
+ getAICredit(org) {
823
+ return this.json("GET", `/v1/orgs/${encodeURIComponent(org)}/ai/credit`);
824
+ }
825
+ /** One page of the org ledger. The cursor is `ledger_id` — cutting on time splits the same millisecond. */
826
+ listAILedger(org, opts = {}) {
827
+ const q = new URLSearchParams();
828
+ if (opts.limit)
829
+ q.set("limit", String(opts.limit));
830
+ if (opts.before)
831
+ q.set("before", String(opts.before));
832
+ const qs = q.toString();
833
+ return this.json("GET", `/v1/orgs/${encodeURIComponent(org)}/ai/ledger${qs ? "?" + qs : ""}`);
834
+ }
835
+ /**
836
+ * The credit top-up checkout. **A browser coming back is not evidence** — the
837
+ * one thing that raises the balance is the `order.paid` webhook, and the
838
+ * screen polls the balance after the success event.
839
+ */
840
+ createAICreditCheckout(org, microCents, returnTo) {
841
+ return this.json("POST", `/v1/orgs/${encodeURIComponent(org)}/ai/credit/checkout`, { microCents, returnTo });
842
+ }
843
+ /**
844
+ * Turns automatic top-up on or off. **Turning it on is explicit consent**
845
+ * (docs/ai.md §3.5) — the screen shows the charging terms verbatim and calls
846
+ * this only after the box is checked.
847
+ */
848
+ setAIAutoTopup(org, body) {
849
+ return this.json("PUT", `/v1/orgs/${encodeURIComponent(org)}/ai/autotopup`, body);
850
+ }
851
+ /** One page of the mail log (docs/email.md §6.1). Newest first, cursor-paged. */
852
+ listMailLog(org, name, opts = {}) {
853
+ const q = new URLSearchParams();
854
+ if (opts.direction)
855
+ q.set("direction", opts.direction);
856
+ if (opts.cursor)
857
+ q.set("cursor", opts.cursor);
858
+ if (opts.limit)
859
+ q.set("limit", String(opts.limit));
860
+ const qs = q.toString();
861
+ return this.json("GET", `${this.projectPath(org, name)}/email/log${qs ? "?" + qs : ""}`);
862
+ }
863
+ /** The metadata of one message. */
864
+ getMailLog(org, name, mailId) {
865
+ return this.json("GET", `${this.projectPath(org, name)}/email/log/${encodeURIComponent(mailId)}`);
866
+ }
867
+ /** The URL of the raw message — the screen uses it as a download link (.eml). */
868
+ mailLogRawURL(org, name, mailId) {
869
+ return new URL(`${this.projectPath(org, name)}/email/log/${encodeURIComponent(mailId)}/raw`, this.opts.baseUrl).toString();
870
+ }
871
+ /** The raw text. What the detail view draws the headers and body from. */
872
+ getMailLogRaw(org, name, mailId) {
873
+ return this.text("GET", `${this.projectPath(org, name)}/email/log/${encodeURIComponent(mailId)}/raw`);
874
+ }
875
+ // ---- End-user authentication (docs/auth.md §9) ----
876
+ /**
877
+ * Grant, settings, providers, hosts. **The signed-up users do not come** —
878
+ * that table is inside the project DB (docs/auth.md §6) and only the worker's
879
+ * `env.auth` sees it.
880
+ *
881
+ * `providers` carries the client id only. The client secret is sealed and
882
+ * appears in no response.
283
883
  */
284
884
  getAuth(org, name) {
285
885
  return this.json("GET", `${this.projectPath(org, name)}/auth`);
286
886
  }
287
887
  /**
288
- * 인증을 켠다 (member). 멱등이다.
888
+ * Turns auth on (member). Idempotent.
289
889
  *
290
- * **데이터베이스가 없으면 409 `database_required`** 다 — 가입자 표가
291
- * 프로젝트 DB 안이라 부여만 세우면 워커가 뜨는 순간 표가 없다. 부르는 쪽은
292
- * 그 코드를 잡아 `runlot pg create` 를 안내한다.
890
+ * **With no database it is 409 `database_required`** — the user table lives
891
+ * inside the project DB, so standing up the grant alone leaves no table the
892
+ * moment the worker starts. The caller catches that code and points at the
893
+ * `"database": true` declaration — the deploy never sees this code, because
894
+ * its ordering rules it out.
293
895
  */
294
896
  grantAuth(org, name) {
295
897
  return this.json("POST", `${this.projectPath(org, name)}/auth`);
296
898
  }
297
899
  /**
298
- * 설정을 바꾼다 (member). **부분 갱신이다** — 준 칸만 바뀐다. 스위치 하나에
299
- * 전체를 실어 보내면 두 사람이 같은 카드를 만질 때 나중 저장이 앞 저장을
300
- * 덮는다. 부여가 없으면 404 `not_granted`.
900
+ * Changes the settings (member). **A partial update** — only the fields given
901
+ * change. Sending the whole thing on one switch means that when two people
902
+ * touch the same card, the later save overwrites the earlier one. With no
903
+ * grant it is 404 `not_granted`.
301
904
  */
302
905
  updateAuthSettings(org, name, settings) {
303
906
  return this.json("PATCH", `${this.projectPath(org, name)}/auth`, { settings });
304
907
  }
305
908
  /**
306
- * 인증을 해제한다 (admin). **가입자는 안 지워진다** — `runlot_auth` 의 표는
307
- * 프로젝트 DB 에 남고, 다시 켜면 같은 사람들이 그대로 로그인한다.
909
+ * Turns auth off (admin). **The signed-up users are not deleted** — the
910
+ * `runlot_auth` tables stay in the project DB, and turning it back on lets the
911
+ * same people log in unchanged.
308
912
  */
309
913
  revokeAuth(org, name) {
310
914
  return this.json("DELETE", `${this.projectPath(org, name)}/auth`);
311
915
  }
312
916
  /**
313
- * 소셜 프로바이더를 등록하거나 교체한다 (admin). 같은 PUT 이라 멱등이다.
917
+ * Registers or replaces a social provider (admin). It is the same PUT, so it
918
+ * is idempotent.
314
919
  *
315
- * **`clientSecret` 은 다시 볼 수 없다.** 콜백 URL 은 호스트마다
316
- * `https://<host>/__runlot/auth/callback/<provider>` 이고, 호스트 목록은
317
- * 응답의 `hosts` 에 있다.
920
+ * **`clientSecret` cannot be read back.** The callback URL is
921
+ * `https://<host>/__runlot/auth/callback/<provider>` for each host, and the
922
+ * list of hosts is in the response's `hosts`.
923
+ *
924
+ * `issuer` is the base URL of your own GitHub Enterprise Server installation,
925
+ * and only `github` takes one. **This PUT is the whole row** — the same reason
926
+ * `clientSecret` has to be sent every time — so an empty string clears it and
927
+ * puts that provider back on github.com. The key is left out entirely when the
928
+ * argument is omitted, so a caller that does not know about issuers sends the
929
+ * body it always sent.
318
930
  */
319
- setAuthProvider(org, name, provider, clientId, clientSecret) {
320
- return this.json("PUT", `${this.projectPath(org, name)}/auth/providers/${encodeURIComponent(provider)}`, { clientId, clientSecret });
931
+ setAuthProvider(org, name, provider, clientId, clientSecret, issuer) {
932
+ return this.json("PUT", `${this.projectPath(org, name)}/auth/providers/${encodeURIComponent(provider)}`, { clientId, clientSecret, ...(issuer === undefined ? {} : { issuer }) });
321
933
  }
322
934
  /**
323
- * 프로바이더를 뗀다 (admin). 그 프로바이더로만 가입한 사람은 로그인할 길을
324
- * 잃지만 사용자 행은 남는다 — 다시 등록하면 같은 계정으로 돌아온다.
935
+ * Removes a provider (admin). Someone who signed up only through that provider
936
+ * loses their way in, but the user row stays — register it again and they come
937
+ * back to the same account.
325
938
  */
326
939
  deleteAuthProvider(org, name, provider) {
327
940
  return this.json("DELETE", `${this.projectPath(org, name)}/auth/providers/${encodeURIComponent(provider)}`);
328
941
  }
329
- // ---- 배포 접근 제어 (docs/access.md §7) ----
942
+ // ---- Deployment access control (docs/access.md §7) ----
330
943
  /**
331
- * 이 배포에 누가 닿을 수 있는가. **값은 없다** — 비밀번호도 우회 시크릿도
332
- * 해시로만 살고, 여기 오는 것은 `hasPassword`·`hasBypass` 라는 사실이다.
944
+ * Who can reach this deployment. **No values** — both the password and the
945
+ * bypass secret live only as hashes, and what comes back here is the fact that
946
+ * there is one: `hasPassword` and `hasBypass`.
333
947
  *
334
- * 정책을 한 번도 안 정한 프로젝트는 `mode: "public"` 에 `updatedAt` 이
335
- * 없다. 화면이 그 둘을 가르는 이유는 §7 — 빈 상태 대신 상태 문장이다.
948
+ * A project that has never set a policy is `mode: "public"` with no
949
+ * `updatedAt`. The screen tells those two apart for the reason in §7 — a
950
+ * sentence about the state instead of an empty state.
336
951
  */
337
952
  getAccess(org, name) {
338
953
  return this.json("GET", `${this.projectPath(org, name)}/access`);
339
954
  }
340
955
  /**
341
- * 정책을 바꾼다 (admin). `password` 를 안 보내면 **있던 비밀번호가 남는다** —
342
- * 모드만 바꾸는 저장이 비밀번호를 지우면 되돌아왔을 때 다시 정해야 한다.
956
+ * Changes the policy (admin). Without a `password`, **the existing password
957
+ * stays** — if a save that only changes the mode wiped the password, coming
958
+ * back to that mode would mean setting it again.
343
959
  */
344
960
  setAccess(org, name, mode, password) {
345
961
  return this.json("PUT", `${this.projectPath(org, name)}/access`, { mode, password });
346
962
  }
347
963
  /**
348
- * 자동화 우회 시크릿을 만든다 (admin). **값은 이 답에만 있다** — 화면은
349
- * 받은 즉시 한 번 보여주고 다시 못 가져온다. 다시 부르면 옛 값이 죽는다.
964
+ * Makes a bypass secret for automation (admin). **The value is in this answer
965
+ * and nowhere else** — the screen shows it once on arrival and can never fetch
966
+ * it again. Calling again kills the old value.
350
967
  */
351
968
  newAccessBypass(org, name) {
352
969
  return this.json("POST", `${this.projectPath(org, name)}/access/bypass`);
353
970
  }
354
- /** 우회를 뗀다 (admin). 없는 우회를 떼는 것도 성공이다. */
971
+ /** Removes the bypass (admin). Removing one that does not exist also succeeds. */
355
972
  revokeAccessBypass(org, name) {
356
973
  return this.json("DELETE", `${this.projectPath(org, name)}/access/bypass`);
357
974
  }
358
975
  /**
359
- * 보호된 배포로 돌아가는 1 회용 주소를 받는다 (docs/access.md §3.4-3).
976
+ * Gets a one-time address back into a protected deployment (docs/access.md §3.4-3).
360
977
  *
361
- * 프로젝트 경로 아래가 **아니다**: 부르는 화면이 아는 것은 호스트네임뿐이고
362
- * (원 요청이 그리로 갔다), org·프로젝트 이름은 서버가 그것에서 유도한다.
363
- * 받은 `redirect` 로 `location.replace` 하는 것이 호출부의 일이다.
978
+ * It is **not** under the project path: all the calling screen knows is the
979
+ * hostname (the original request went there), and the server derives the org
980
+ * and project names from it. Doing `location.replace` with the `redirect` that
981
+ * comes back is the caller's job.
364
982
  */
365
983
  authorizeAccess(to, next) {
366
984
  return this.json("POST", "/v1/access/authorize", { to, next });
@@ -368,44 +986,79 @@ export class Client {
368
986
  listMembers(org) {
369
987
  return this.json("GET", `/v1/orgs/${encodeURIComponent(org)}/members`);
370
988
  }
371
- /** 초대가 아니다 — 사용자는 이미 한 번 로그인해 있어야 한다 (404 `user_not_found`). */
989
+ /** This is not an invite — the user must already have logged in once (404 `user_not_found`). */
372
990
  addMember(org, provider, subject, role) {
373
991
  return this.json("POST", `/v1/orgs/${encodeURIComponent(org)}/members`, { provider, subject, role });
374
992
  }
375
993
  setMemberRole(org, userId, role) {
376
994
  return this.json("PUT", `/v1/orgs/${encodeURIComponent(org)}/members/${encodeURIComponent(userId)}`, { role });
377
995
  }
996
+ /**
997
+ * Removing someone else is admin; **removing yourself is leaving**, which
998
+ * anyone can do (docs/members.md). The last admin can do neither
999
+ * (409 `last_admin`).
1000
+ */
378
1001
  removeMember(org, userId) {
379
1002
  return this.json("DELETE", `/v1/orgs/${encodeURIComponent(org)}/members/${encodeURIComponent(userId)}`);
380
1003
  }
1004
+ // --- Invites (docs/members.md) ---
1005
+ /** Pending and expired invites (admin). Accepted and revoked ones are history, so they do not come. */
1006
+ listInvites(org) {
1007
+ return this.json("GET", `/v1/orgs/${encodeURIComponent(org)}/invites`);
1008
+ }
1009
+ /**
1010
+ * Invites by email (admin). **Calling again with the same address is a
1011
+ * resend** — the same row is updated rather than a new one created, and the
1012
+ * server gives 200 instead of 201.
1013
+ */
1014
+ createInvite(org, email, role) {
1015
+ return this.json("POST", `/v1/orgs/${encodeURIComponent(org)}/invites`, { email, role });
1016
+ }
1017
+ /** Revokes an invite (admin). Mail that has not gone out yet does not go out. */
1018
+ revokeInvite(org, inviteId) {
1019
+ return this.json("DELETE", `/v1/orgs/${encodeURIComponent(org)}/invites/${encodeURIComponent(inviteId)}`);
1020
+ }
1021
+ /**
1022
+ * A preview of the invite. **No session needed** — whoever got the link has
1023
+ * not logged in yet, and they need to know what they are accepting before
1024
+ * they do.
1025
+ */
1026
+ getInvite(token) {
1027
+ return this.json("GET", `/v1/invites/${encodeURIComponent(token)}`);
1028
+ }
1029
+ /** Accepts (login required). If they are already a member the role does not change. */
1030
+ acceptInvite(token) {
1031
+ return this.json("POST", `/v1/invites/${encodeURIComponent(token)}/accept`);
1032
+ }
381
1033
  // --- domains: D1 hostnames methods ---
382
- /** 기본 호스트네임과 커스텀 도메인이 함께 온다 (viewer). */
1034
+ /** The default hostname and the custom domains come together (viewer). */
383
1035
  listHostnames(org, name) {
384
1036
  return this.json("GET", `${this.projectPath(org, name)}/hostnames`);
385
1037
  }
386
1038
  /**
387
- * 커스텀 도메인을 건다 (member).
1039
+ * Attaches a custom domain (member).
388
1040
  *
389
- * 멱등이다 — 같은 이름을 다시 보내면 있던 행이 그대로 온다. 응답의 `verify`
390
- * 가 고객이 넣어야 하는 CNAME·TXT 다.
1041
+ * Idempotent — sending the same name again returns the existing row
1042
+ * unchanged. The response's `verify` is the CNAME and TXT the customer has to
1043
+ * add.
391
1044
  */
392
1045
  addHostname(org, name, hostname) {
393
1046
  return this.json("POST", `${this.projectPath(org, name)}/hostnames`, { hostname });
394
1047
  }
395
- /** 배경 검증과 같은 판정을 즉시 부른다 (member). */
1048
+ /** Runs the same check the background verifier makes, right now (member). */
396
1049
  verifyHostname(org, name, host) {
397
1050
  return this.json("POST", `${this.projectPath(org, name)}/hostnames/${encodeURIComponent(host)}/verify`);
398
1051
  }
399
- /** 커스텀 도메인을 뗀다 (member). 기본 호스트네임은 400 이다. */
1052
+ /** Detaches a custom domain (member). The default hostname gives a 400. */
400
1053
  deleteHostname(org, name, host) {
401
1054
  return this.json("DELETE", `${this.projectPath(org, name)}/hostnames/${encodeURIComponent(host)}`);
402
1055
  }
403
1056
  // --- domains: D2 dns methods ---
404
- /** 이 조직의 존 목록. `rrsets` 는 없다 — 단건 조회에만 실려요. */
1057
+ /** The organization's zones. No `rrsets` — those ride only on a single-zone lookup. */
405
1058
  listZones(org) {
406
1059
  return this.json("GET", this.dnsPath(org));
407
1060
  }
408
- /** 멱등이에요. 이미 있는 존은 그대로 돌아와요. */
1061
+ /** Idempotent. A zone that already exists comes back unchanged. */
409
1062
  createZone(org, name) {
410
1063
  return this.json("POST", this.dnsPath(org), { name });
411
1064
  }
@@ -416,8 +1069,9 @@ export class Client {
416
1069
  return this.json("DELETE", `${this.dnsPath(org)}/${encodeURIComponent(zone)}`);
417
1070
  }
418
1071
  /**
419
- * rrset 하나를 **통째로** 바꿔요. `records` 가 그 (이름, 타입) 의 새 전부라,
420
- * 값 하나를 더하려면 지금 값을 함께 보내야 해요.
1072
+ * Replaces one rrset **whole**. `records` is the complete new set for that
1073
+ * (name, type), so adding a single value means sending the current ones with
1074
+ * it.
421
1075
  */
422
1076
  putRRset(org, zone, name, type, ttl, records) {
423
1077
  return this.json("PUT", this.rrsetPath(org, zone, name, type), { ttl, records });
@@ -433,39 +1087,40 @@ export class Client {
433
1087
  setZoneDS(org, zone, ds) {
434
1088
  return this.json("PUT", `${this.dnsPath(org)}/${encodeURIComponent(zone)}/dnssec/ds`, { ds });
435
1089
  }
436
- /** DNS 표면의 공통 접두. */
1090
+ /** The shared prefix of the DNS surface. */
437
1091
  dnsPath(org) {
438
1092
  return `/v1/orgs/${encodeURIComponent(org)}/dns/zones`;
439
1093
  }
440
- /** `@` 도 `*` 도 이름으로 와요. 네 군데에 같은 문자열을 적지 않아요. */
1094
+ /** Both `@` and `*` arrive as a name. Keeps the same string out of four places. */
441
1095
  rrsetPath(org, zone, name, type) {
442
1096
  return (`${this.dnsPath(org)}/${encodeURIComponent(zone)}` +
443
1097
  `/rrsets/${encodeURIComponent(name)}/${encodeURIComponent(type)}`);
444
1098
  }
445
1099
  // --- domains: D3 domains methods ---
446
1100
  /**
447
- * 이름을 찾아본다 (member 이상).
1101
+ * Looks a name up (member or above).
448
1102
  *
449
- * `priceCents` 는 첫 등록 1 년, `renewalCents` 는 갱신 1 년이다. 둘이
450
- * 나뉘어 있는 이유는 첫해 할인이 흔해서다 — 하나로 접으면 "1 달러에 사서
451
- * 다음 해 15 달러" 를 화면이 말할 수 없다.
1103
+ * `priceCents` is the first year of registration; `renewalCents` is one year
1104
+ * of renewal. They are separate because a first-year discount is common —
1105
+ * folded into one, the screen cannot say "buy it for $1, then $15 next year".
452
1106
  */
453
1107
  checkDomains(org, names) {
454
1108
  return this.json("POST", `${this.domainsPath(org)}/check`, { names });
455
1109
  }
456
1110
  /**
457
- * 도메인을 산다 (admin — 돈을 써요).
1111
+ * Buys a domain (admin — this spends money).
458
1112
  *
459
- * **ICANN 검증 메일이 `contact.email` 로 가요.** 화면은 사기 전에 그
460
- * 사실을 알려야 해요 (docs/domains.md §5.5).
1113
+ * **The ICANN verification mail goes to `contact.email`.** The screen has to
1114
+ * say so before the purchase (docs/domains.md §5.5).
461
1115
  *
462
- * 같은 요청을 다시 보내는 것이 안전해요 — 끝난 도메인이면 그대로 오고,
463
- * 중간에 멈춘 구매면 그 자리에서 이어 가요.
1116
+ * Sending the same request again is safe — a finished domain comes back
1117
+ * unchanged, and a purchase that stopped part way resumes from where it
1118
+ * stopped.
464
1119
  */
465
1120
  purchaseDomain(org, body) {
466
1121
  return this.json("POST", this.domainsPath(org), body);
467
1122
  }
468
- /** 이미 가진 도메인을 가져와요 (admin). 등록기관을 부르지 않고 존만 만들어요. */
1123
+ /** Imports a domain you already own (admin). Creates the zone only; the registrar is not called. */
469
1124
  importDomain(org, name) {
470
1125
  return this.json("POST", `${this.domainsPath(org)}/import`, { name });
471
1126
  }
@@ -479,11 +1134,11 @@ export class Client {
479
1134
  return this.json("POST", `${this.domainPath(org, name)}/renew`, { years });
480
1135
  }
481
1136
  /**
482
- * 자동 갱신 **의사**만 바꿔요.
1137
+ * Changes only the **intent** to auto-renew.
483
1138
  *
484
- * 등록기관 쪽 자동 갱신은 그대로 켜져 있어요 — 도메인은 하루 늦으면 잃는
485
- * 자산이라 우리가 먼저 갱신하고 뒤에 청구해요. 화면은 이 사실을 함께
486
- * 말해야 해요.
1139
+ * Auto-renew at the registrar stays on — a domain is an asset you lose if you
1140
+ * are a day late, so we renew first and bill afterwards. The screen has to say
1141
+ * this alongside.
487
1142
  */
488
1143
  setDomainAutoRenew(org, name, enabled) {
489
1144
  return this.json("POST", `${this.domainPath(org, name)}/auto-renew`, { enabled });
@@ -491,29 +1146,131 @@ export class Client {
491
1146
  setDomainLock(org, name, locked) {
492
1147
  return this.json("POST", `${this.domainPath(org, name)}/lock`, { locked });
493
1148
  }
494
- /** 이전 코드예요. 감사에 발급 사실이 남고 **코드는 남지 않아요**. */
1149
+ /** The transfer auth code. The audit records that one was issued and **not the code itself**. */
495
1150
  domainAuthCode(org, name) {
496
1151
  return this.json("POST", `${this.domainPath(org, name)}/auth-code`);
497
1152
  }
498
- /** 지금 등록기관에서 다시 읽어요 (member). */
1153
+ /** Re-reads from the registrar right now (member). */
499
1154
  syncDomain(org, name) {
500
1155
  return this.json("POST", `${this.domainPath(org, name)}/sync`);
501
1156
  }
502
- /** 도메인 표면의 공통 접두. 열 군데에 같은 문자열을 적지 않아요. */
1157
+ /** The shared prefix of the domain surface. Keeps the same string out of ten places. */
503
1158
  domainsPath(org) {
504
1159
  return `/v1/orgs/${encodeURIComponent(org)}/domains`;
505
1160
  }
506
1161
  domainPath(org, name) {
507
1162
  return `${this.domainsPath(org)}/${encodeURIComponent(name)}`;
508
1163
  }
509
- /** 프로젝트 표면의 공통 접두다. */
1164
+ /** The shared prefix of the project surface. */
510
1165
  projectPath(org, name) {
511
1166
  return `/v1/orgs/${encodeURIComponent(org)}/projects/${encodeURIComponent(name)}`;
512
1167
  }
513
- /** 데이터베이스 표면의 공통 접두다. 다섯 군데에 같은 문자열을 적지 않는다. */
1168
+ /** The shared prefix of the database surface. Keeps the same string out of five places. */
514
1169
  dbPath(org, name) {
515
1170
  return `/v1/orgs/${encodeURIComponent(org)}/projects/${encodeURIComponent(name)}/database`;
516
1171
  }
1172
+ // ---- Self-hosted Git (docs/git-hosting.md §12.1, §19) ----
1173
+ //
1174
+ // **git does the repository work and we handle only the metadata.** That is
1175
+ // why there is nothing like `pushRepo` here — an API that wraps git either
1176
+ // reimplements every one of git's flags or boxes the user in.
1177
+ //
1178
+ // A repository **belongs to a project**. That is why every method starts with
1179
+ // (org, project) — the same shape as the database and the secrets.
1180
+ listRepos(org, project) {
1181
+ return this.json("GET", this.repoPath(org, project));
1182
+ }
1183
+ createRepo(org, project, body) {
1184
+ return this.json("POST", this.repoPath(org, project), body);
1185
+ }
1186
+ getRepo(org, project, name) {
1187
+ return this.json("GET", `${this.repoPath(org, project)}/${encodeURIComponent(name)}`);
1188
+ }
1189
+ updateRepo(org, project, name, body) {
1190
+ return this.json("PATCH", `${this.repoPath(org, project)}/${encodeURIComponent(name)}`, body);
1191
+ }
1192
+ deleteRepo(org, project, name) {
1193
+ return this.json("DELETE", `${this.repoPath(org, project)}/${encodeURIComponent(name)}`);
1194
+ }
1195
+ /** The ref history. The lead for recovering a commit lost to a force-push is here. */
1196
+ listRefUpdates(org, project, name) {
1197
+ return this.json("GET", `${this.repoPath(org, project)}/${encodeURIComponent(name)}/ref-updates`);
1198
+ }
1199
+ // Push-to-deploy (docs/code/push-to-deploy). The hook hangs off the
1200
+ // repository and the builds off the **project**, because a build outlives the
1201
+ // repository it came from.
1202
+ getDeployHook(org, project, name) {
1203
+ return this.json("GET", `${this.repoPath(org, project)}/${encodeURIComponent(name)}/deploy-hook`);
1204
+ }
1205
+ setDeployHook(org, project, name, body) {
1206
+ return this.json("PUT", `${this.repoPath(org, project)}/${encodeURIComponent(name)}/deploy-hook`, body);
1207
+ }
1208
+ deleteDeployHook(org, project, name) {
1209
+ return this.json("DELETE", `${this.repoPath(org, project)}/${encodeURIComponent(name)}/deploy-hook`);
1210
+ }
1211
+ /** Build a commit without pushing. Omit `commitSha` for the hooked branch's tip. */
1212
+ deployRepoNow(org, project, name, commitSha) {
1213
+ return this.json("POST", `${this.repoPath(org, project)}/${encodeURIComponent(name)}/deploy`, commitSha === undefined ? {} : { commitSha });
1214
+ }
1215
+ listBuilds(org, project, limit) {
1216
+ const q = limit === undefined ? "" : `?limit=${limit}`;
1217
+ return this.json("GET", `${this.projectPath(org, project)}/builds${q}`);
1218
+ }
1219
+ getBuild(org, project, buildId) {
1220
+ return this.json("GET", `${this.projectPath(org, project)}/builds/${encodeURIComponent(buildId)}`);
1221
+ }
1222
+ /**
1223
+ * A build's output. It comes back as **text**, not JSON — see the endpoint's
1224
+ * description. `json()` would try to parse it and fail on the first line.
1225
+ */
1226
+ getBuildLog(org, project, buildId) {
1227
+ return this.text("GET", `${this.projectPath(org, project)}/builds/${encodeURIComponent(buildId)}/log`);
1228
+ }
1229
+ cancelBuild(org, project, buildId) {
1230
+ return this.json("POST", `${this.projectPath(org, project)}/builds/${encodeURIComponent(buildId)}/cancel`);
1231
+ }
1232
+ listSSHKeys() {
1233
+ return this.json("GET", "/v1/me/ssh-keys");
1234
+ }
1235
+ addSSHKey(publicKey, name) {
1236
+ return this.json("POST", "/v1/me/ssh-keys", { publicKey, name });
1237
+ }
1238
+ deleteSSHKey(keyId) {
1239
+ return this.json("DELETE", `/v1/me/ssh-keys/${encodeURIComponent(keyId)}`);
1240
+ }
1241
+ listAccessTokens() {
1242
+ return this.json("GET", "/v1/me/tokens");
1243
+ }
1244
+ /** The plaintext is carried in this response **once** and never again. */
1245
+ createAccessToken(body) {
1246
+ return this.json("POST", "/v1/me/tokens", body);
1247
+ }
1248
+ revokeAccessToken(tokenId) {
1249
+ return this.json("DELETE", `/v1/me/tokens/${encodeURIComponent(tokenId)}`);
1250
+ }
1251
+ // ---- Browsing (docs/git-hosting.md §12.2) ----
1252
+ getRepoRefs(org, project, name) {
1253
+ return this.json("GET", `${this.repoPath(org, project)}/${encodeURIComponent(name)}/refs`);
1254
+ }
1255
+ getRepoTree(org, project, name, ref = "", path = "") {
1256
+ return this.json("GET", `${this.repoPath(org, project)}/${encodeURIComponent(name)}/tree${gitQuery(ref, path)}`);
1257
+ }
1258
+ getRepoBlob(org, project, name, ref = "", path = "") {
1259
+ return this.json("GET", `${this.repoPath(org, project)}/${encodeURIComponent(name)}/blob${gitQuery(ref, path)}`);
1260
+ }
1261
+ getRepoCommits(org, project, name, ref = "", limit) {
1262
+ const q = new URLSearchParams();
1263
+ if (ref !== "")
1264
+ q.set("ref", ref);
1265
+ if (limit !== undefined)
1266
+ q.set("limit", String(limit));
1267
+ const qs = q.size > 0 ? `?${q.toString()}` : "";
1268
+ return this.json("GET", `${this.repoPath(org, project)}/${encodeURIComponent(name)}/commits${qs}`);
1269
+ }
1270
+ /** The shared prefix of the repository surface. It sits under the project (§19). */
1271
+ repoPath(org, project) {
1272
+ return `${this.projectPath(org, project)}/repos`;
1273
+ }
517
1274
  rollback(org, name, version) {
518
1275
  return this.json("POST", `/v1/orgs/${encodeURIComponent(org)}/projects/${encodeURIComponent(name)}/rollback`, { version });
519
1276
  }