@samyx/preview-stacks-client 0.31.0 → 0.33.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/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # @samyx/preview-stacks-client
2
2
 
3
3
  Typed API client for a [pstack](https://github.com/samishal1998/preview-stacks) control plane —
4
- deployments, jobs, readiness, containers, logs and notifiers, plus webhook verification for the
5
- receiving end.
4
+ deployments, jobs, readiness, containers, logs, notifiers and accounts, plus webhook verification for
5
+ the receiving end.
6
6
 
7
7
  Zero dependencies. Works in Node, Bun, Deno and the browser (it uses `fetch` and WebCrypto, nothing
8
8
  else).
@@ -110,6 +110,35 @@ const script = await pstack.swarm.join({ format: 'script' }); // a SECRET: tre
110
110
  // formats: 'token' | 'command' | 'script' | 'cloud-config' (+ distro for cloud-config)
111
111
  ```
112
112
 
113
+ ### Accounts and roles
114
+
115
+ Every account holds one of four ordered roles. Each includes everything below it:
116
+
117
+ | Role | What it adds |
118
+ |---|---|
119
+ | `viewer` | every read — deployments, jobs, logs, specs, routing, notifiers, the swarm, the user roster |
120
+ | `developer` | stacks: `up` / `down` / `verify` / `sleep` / `wake`, container actions, specs, the terminal |
121
+ | `maintainer` | host configuration: host vars, registries, routing files, notifiers, the swarm join material |
122
+ | `admin` | people, and anything that can mint them: accounts, roles, SSO configuration, sealed config |
123
+
124
+ `PSTACK_TOKEN` is above all four (`me().root === true`) and holds no account. A share-link token is
125
+ not a role at all — it reaches the views its link names, on its one deployment.
126
+
127
+ ```ts
128
+ const me = await pstack.me();
129
+ if (!me.root && me.user?.role !== 'admin') throw new Error('this script mints accounts');
130
+
131
+ const ci = await pstack.users.create({ username: 'ci-bot', password, role: 'developer' });
132
+ await pstack.users.setRole(ci.id, 'maintainer'); // takes effect on that account's next request
133
+ await pstack.users.remove(ci.id);
134
+ const roster = await pstack.users.list(); // a viewer may read this
135
+ ```
136
+
137
+ **An omitted `role` means `viewer`.** `users.create` used to produce an *administrator* every time; a
138
+ script that relied on that must now say `role: 'admin'` and mean it. `user.role` is typed `string`,
139
+ not `Role`, on the way back: the column is one an operator can repair by hand, and the server ranks a
140
+ value it does not recognise *below* `viewer` — read an unfamiliar role as less, never as more.
141
+
113
142
  ### Single sign-on
114
143
 
115
144
  The sign-in itself is two browser redirects (`/api/auth/sso/start` → the provider →
@@ -153,6 +182,8 @@ Bun.serve({
153
182
  (`/api/deployments/:id/logs/stream`) and is left to `EventSource` or your own reader.
154
183
  - **No terminal.** That is a WebSocket carrying a session cookie, which belongs to a browser.
155
184
  - **No spec authoring helpers.** A spec is YAML you own; this client submits it verbatim.
185
+ - **No password changes.** `PUT /api/users/:id/password` signs that account out of everything it has;
186
+ it is a person's job at a keyboard, not a script's.
156
187
 
157
188
  Anything not wrapped is still reachable with the same auth and error handling:
158
189
 
package/dist/index.d.ts CHANGED
@@ -22,10 +22,10 @@
22
22
  *
23
23
  * `waitForJob`, `waitForReady` and `verifyWebhook` (below). They are the three things every user
24
24
  * would otherwise re-write, and the two waiters are the ones that are easy to get subtly wrong —
25
- * polling a job without a terminal-state list, or treating `readiness.state === 'watching'` as an
26
- * error rather than "ask again".
25
+ * polling a job without a terminal-state list (`queued` is not running and not finished either), or
26
+ * treating `readiness.state === 'watching'` as an error rather than "ask again".
27
27
  */
28
- import type { DeliveryRow, DeploymentRow, Health, HostVar, Job, Kind, Logs, NotifierRow, Readiness, Runtime, ShareLink, ShareView, SpecMeta, SsoConfig, SsoConfigResponse, SwarmInfo } from './types.ts';
28
+ import type { CancelStack, DeliveryRow, DeploymentRow, Health, HostSettings, HostVar, Job, Kind, Logs, Me, NotifierRow, Readiness, Role, Runtime, SettingKey, SettingWritten, ShareLink, ShareView, SpecMeta, SsoConfig, SsoConfigResponse, SwarmInfo, User } from './types.ts';
29
29
  export * from './types.ts';
30
30
  /** A non-2xx answer. `body` is the parsed JSON, which is where the server's own message lives. */
31
31
  export declare class PstackError extends Error {
@@ -46,8 +46,14 @@ export type ClientOptions = {
46
46
  * else, every route needs it.
47
47
  */
48
48
  token?: string;
49
- /** Swap in for tests, a proxy, or an agent with custom TLS. Defaults to global `fetch`. */
50
- fetch?: typeof fetch;
49
+ /**
50
+ * Swap in for tests, a proxy, or an agent with custom TLS. Defaults to global `fetch`.
51
+ *
52
+ * The SIGNATURE, not `typeof fetch`: the global carries a `preconnect` property in both Bun's and
53
+ * Node's lib types, so `typeof fetch` rejects every plain arrow function — which is the only
54
+ * shape anyone actually passes here. This is what the client calls, and the global still fits it.
55
+ */
56
+ fetch?: (input: string, init?: RequestInit) => Promise<Response>;
51
57
  /** Per request, in ms. Deploys are started, never awaited, so this bounds the HTTP call only. */
52
58
  timeoutMs?: number;
53
59
  };
@@ -57,6 +63,11 @@ export declare function createClient(opts: ClientOptions): {
57
63
  /** Escape hatch: any route this client does not wrap yet, with auth and error handling applied. */
58
64
  request: <T>(method: string, path: string, body?: unknown) => Promise<T>;
59
65
  health: () => Promise<Health>;
66
+ /**
67
+ * Who this token is. `{ root: true }` for `PSTACK_TOKEN` — which is above every role and has no
68
+ * account — otherwise `user.role`, which is what a script branching on its own privileges reads.
69
+ */
70
+ me: () => Promise<Me>;
60
71
  deployments: {
61
72
  list: (vars?: Vars) => Promise<DeploymentRow[]>;
62
73
  get: (id: string, vars?: Vars) => Promise<DeploymentRow & {
@@ -100,6 +111,12 @@ export declare function createClient(opts: ClientOptions): {
100
111
  sleep: (id: string, vars?: Vars) => Promise<Job>;
101
112
  /** `up`, recorded as a wake. */
102
113
  wake: (id: string, vars?: Vars) => Promise<Job>;
114
+ /**
115
+ * Stop everything outstanding on this deployment's stack — the running job and the one queued
116
+ * behind it — in one call. Destroys nothing and undoes nothing; a `down` also clears the stack
117
+ * (it preempts) but then tears the stack down, which this does not.
118
+ */
119
+ cancel: (id: string, vars?: Vars) => Promise<CancelStack>;
103
120
  /**
104
121
  * Mint a read-only link to this deployment: `views` default to both `details` and `logs`,
105
122
  * `ttl` to 7 days (30 at most). The token is returned once and stored nowhere; rotating
@@ -167,6 +184,42 @@ export declare function createClient(opts: ClientOptions): {
167
184
  distro?: string;
168
185
  }) => Promise<string>;
169
186
  };
187
+ /**
188
+ * Accounts and the role each holds.
189
+ *
190
+ * Reading the roster is ordinary team information (viewer); creating, deleting and re-roling
191
+ * people is ADMIN, because anything that can set a role can set its own to `admin`.
192
+ */
193
+ users: {
194
+ list: () => Promise<User[]>;
195
+ /**
196
+ * An ABSENT `role` means the host's `default_role` setting — `viewer` on a host where nobody
197
+ * has changed it, which is what this used to mean unconditionally. It is still the least
198
+ * privilege by default and never admin by omission, but it is now an OPERATOR'S choice
199
+ * (`settings.set('default_role', …)`), so a script that wants a specific role must name it
200
+ * rather than rely on the host's. This route used to create an administrator every time; a
201
+ * script that relied on THAT must say `role: 'admin'` and mean it. A role outside the four is
202
+ * a 400 rather than a silently powerless account.
203
+ */
204
+ create: (body: {
205
+ username: string;
206
+ password: string;
207
+ email?: string;
208
+ role?: Role;
209
+ }) => Promise<User>;
210
+ /**
211
+ * Promote or demote. Takes effect on that account's NEXT request — the role is read fresh per
212
+ * request, so there is no session to revoke. Demoting the last admin is refused (400).
213
+ */
214
+ setRole: (id: number, role: Role) => Promise<{
215
+ updated: number;
216
+ role: Role;
217
+ }>;
218
+ /** Deleting the last admin is refused: a host nobody can administer cannot be repaired over HTTP. */
219
+ remove: (id: number) => Promise<{
220
+ deleted: number;
221
+ }>;
222
+ };
170
223
  /**
171
224
  * The identity provider people sign in with. The sign-in flow itself is two browser redirects
172
225
  * (`/api/auth/sso/start` → the provider → `/api/auth/sso/callback`) and has nothing for an SDK
@@ -282,11 +335,39 @@ export declare function createClient(opts: ClientOptions): {
282
335
  }>;
283
336
  };
284
337
  /**
285
- * Poll a job until it stops running.
338
+ * The two host settings that change at RUNTIME: the running-job cap and the role an account
339
+ * created with no role named gets.
340
+ *
341
+ * Reading is viewer's; writing is per KEY, not per route — `max_jobs` is maintainer's and
342
+ * `default_role` is admin's, and each row says which in `minRole`. The read also says where
343
+ * each value came from (`source`), which is the only way to tell a value you set from one your
344
+ * environment is supplying.
345
+ */
346
+ settings: {
347
+ get: () => Promise<HostSettings>;
348
+ /**
349
+ * Store one. Refused (400) for an unknown key or a value the server will not take —
350
+ * `max_jobs` is an integer ≥ 1, `default_role` one of the four roles.
351
+ *
352
+ * `max_jobs` takes effect on the NEXT dispatch, with no restart: raising it starts a job that
353
+ * was waiting for a slot, and LOWERING IT CANCELS NOTHING — the jobs already running run to
354
+ * completion. The `note` in the response says so; print it rather than paraphrasing.
355
+ */
356
+ set: (key: SettingKey, value: number | Role) => Promise<SettingWritten>;
357
+ };
358
+ /**
359
+ * Poll a job until it reaches a terminal state.
360
+ *
361
+ * Returns the finished job rather than throwing on a failed one: `failed`, `leaked`,
362
+ * `cancelled` and `superseded` are ANSWERS, and a CI step usually wants to branch on which. It
363
+ * throws only when the wait itself fails — a timeout, or the API being unreachable.
286
364
  *
287
- * Returns the finished job rather than throwing on a failed one: `failed`, `leaked` and
288
- * `cancelled` are ANSWERS, and a CI step usually wants to branch on which. It throws only when
289
- * the wait itself fails a timeout, or the API being unreachable.
365
+ * WAITING IS NOT ONLY `running`. A job accepted for a busy stack, or over the host's
366
+ * concurrency cap, is `queued` it has an id and a 202 and has not started. Returning on
367
+ * "not running" reported SUCCESS for a job that never ran, which is the worst possible answer
368
+ * to give a CI pipeline: it would assert against a deployment the deploy had not touched yet.
369
+ * The list below is the TERMINAL one, so a state this build has never heard of keeps waiting
370
+ * rather than being mistaken for an answer.
290
371
  */
291
372
  waitForJob(jobId: string, o?: {
292
373
  intervalMs?: number;
package/dist/index.js CHANGED
@@ -1,3 +1,6 @@
1
+ // src/types.ts
2
+ var TERMINAL_JOB_STATES = ["ok", "failed", "leaked", "cancelled", "superseded"];
3
+
1
4
  // src/index.ts
2
5
  class PstackError extends Error {
3
6
  status;
@@ -61,6 +64,7 @@ function createClient(opts) {
61
64
  const client = {
62
65
  request,
63
66
  health: () => get("/api/health"),
67
+ me: () => get("/api/auth/me"),
64
68
  deployments: {
65
69
  list: (vars) => get(`/api/deployments${qs(vars)}`).then((r) => r.deployments),
66
70
  get: (id, vars) => get(`/api/deployments/${enc(id)}${qs(vars)}`),
@@ -71,6 +75,7 @@ function createClient(opts) {
71
75
  down: (id, body = {}, vars) => post(`/api/deployments/${enc(id)}/down${qs(vars)}`, body).then((r) => r.job),
72
76
  sleep: (id, vars) => post(`/api/deployments/${enc(id)}/sleep${qs(vars)}`).then((r) => r.job),
73
77
  wake: (id, vars) => post(`/api/deployments/${enc(id)}/wake${qs(vars)}`).then((r) => r.job),
78
+ cancel: (id, vars) => post(`/api/deployments/${enc(id)}/cancel${qs(vars)}`),
74
79
  share: (id, body = {}) => post(`/api/deployments/${enc(id)}/share`, body),
75
80
  runtime: (id, vars) => get(`/api/deployments/${enc(id)}/runtime${qs(vars)}`),
76
81
  readiness: (id, o = {}) => get(`/api/deployments/${enc(id)}/readiness${qs(o.vars, { wait: o.wait, refresh: o.refresh ? 1 : undefined, timeout: o.timeout })}`),
@@ -90,6 +95,12 @@ function createClient(opts) {
90
95
  info: () => get("/api/swarm"),
91
96
  join: (o = { format: "command" }) => get(`/api/swarm/join${qs(undefined, { format: o.format, distro: o.distro })}`)
92
97
  },
98
+ users: {
99
+ list: () => get("/api/users").then((r) => r.users),
100
+ create: (body) => post("/api/users", body).then((r) => r.user),
101
+ setRole: (id, role) => request("PATCH", `/api/users/${id}`, { role }),
102
+ remove: (id) => del(`/api/users/${id}`)
103
+ },
93
104
  sso: {
94
105
  config: () => get("/api/sso/config"),
95
106
  save: (config) => put("/api/sso/config", config),
@@ -121,15 +132,21 @@ function createClient(opts) {
121
132
  put: (name, value, secret = false) => put(`/api/host-vars/${enc(name)}`, { value, secret }),
122
133
  remove: (name) => del(`/api/host-vars/${enc(name)}`)
123
134
  },
135
+ settings: {
136
+ get: () => get("/api/settings"),
137
+ set: (key, value) => put(`/api/settings/${enc(key)}`, { value })
138
+ },
124
139
  async waitForJob(jobId, o = {}) {
125
140
  const interval = o.intervalMs ?? 2000;
126
141
  const deadline = Date.now() + (o.timeoutMs ?? 30 * 60000);
127
142
  for (;; ) {
128
143
  const job = await client.jobs.get(jobId);
129
- if (job.state !== "running")
144
+ if (TERMINAL_JOB_STATES.includes(job.state))
130
145
  return job;
131
146
  if (Date.now() > deadline) {
132
- throw new PstackError(0, jobId, { error: `job ${jobId} still running after the wait timeout` });
147
+ throw new PstackError(0, jobId, {
148
+ error: `job ${jobId} still ${job.state} after the wait timeout`
149
+ });
133
150
  }
134
151
  await new Promise((r) => setTimeout(r, interval));
135
152
  }
@@ -181,5 +198,6 @@ async function verifyWebhook(args) {
181
198
  export {
182
199
  verifyWebhook,
183
200
  createClient,
201
+ TERMINAL_JOB_STATES,
184
202
  PstackError
185
203
  };
package/dist/types.d.ts CHANGED
@@ -26,8 +26,28 @@ export type SleepRecord = {
26
26
  /** `HostRegexp` patterns (wildcard subdomains), Go syntax. */
27
27
  rules: string[];
28
28
  };
29
- /** `cancelled` = a person stopped it; nothing it had done was undone. */
30
- export type JobState = 'running' | 'ok' | 'failed' | 'leaked' | 'cancelled';
29
+ /**
30
+ * A job's state.
31
+ *
32
+ * Two of these mean it has NOT run yet, and both are easy to mistake for an answer:
33
+ *
34
+ * - `queued` — accepted, with an id, waiting its turn. A stack runs one job at a time, and the
35
+ * host runs at most `PSTACK_MAX_JOBS` across every stack; over either limit a job waits rather
36
+ * than being refused. It has no `startedAt`.
37
+ * - `superseded` — it was queued and a newer job for the same stack replaced it. The queue is one
38
+ * deep, so five rapid pushes run the first deploy and then exactly one more carrying the newest
39
+ * spec; the three in between end here. Nothing ran, so unlike `cancelled` there is no partial
40
+ * state to clean up.
41
+ *
42
+ * `cancelled` = a person stopped it. If it had started, whatever it had already done was NOT undone.
43
+ */
44
+ export type JobState = 'queued' | 'running' | 'ok' | 'failed' | 'leaked' | 'cancelled' | 'superseded';
45
+ /**
46
+ * The states a job never leaves. Exported because "is this over?" is the question every caller of
47
+ * this SDK asks, and `state !== 'running'` — the obvious way to ask it — answers YES for a queued
48
+ * job that has not started. `waitForJob` uses this list.
49
+ */
50
+ export declare const TERMINAL_JOB_STATES: readonly JobState[];
31
51
  export type ReadinessState = 'watching' | 'ready' | 'failed' | 'timedout';
32
52
  export type Health = {
33
53
  ok: boolean;
@@ -128,7 +148,8 @@ export type Job = {
128
148
  stack: string;
129
149
  action: JobAction;
130
150
  state: JobState;
131
- startedAt: number;
151
+ /** `null` while the job is `queued`, and on a `superseded` one — it never started. */
152
+ startedAt: number | null;
132
153
  endedAt?: number;
133
154
  outcome?: {
134
155
  ok: boolean;
@@ -144,6 +165,27 @@ export type Job = {
144
165
  }>;
145
166
  cancelledBy?: string;
146
167
  };
168
+ /** The four fields a 202 carries — `state` is `running` when it dispatched, `queued` when it waits. */
169
+ export type JobStub = {
170
+ id: string;
171
+ stack: string;
172
+ action: JobAction;
173
+ state: JobState;
174
+ };
175
+ /**
176
+ * `POST /api/deployments/:id/cancel` — stop everything this deployment's stack has outstanding: the
177
+ * running job AND the one queued behind it, in one call.
178
+ *
179
+ * NOT a teardown. It stops work and destroys nothing; it also undoes nothing a running job had
180
+ * already done. `cancelled` is `[]`, never null, when there was nothing outstanding, and `warning`
181
+ * differs by whether anything had actually started — a queued job leaves no partial state behind.
182
+ */
183
+ export type CancelStack = {
184
+ stack: string;
185
+ cancelled: JobStub[];
186
+ by: string;
187
+ warning: string;
188
+ };
147
189
  export type ContainerReadiness = {
148
190
  name: string;
149
191
  service: string | null;
@@ -279,6 +321,89 @@ export type PstackEvent = {
279
321
  at: number;
280
322
  data: Record<string, unknown>;
281
323
  };
324
+ /**
325
+ * The four ordered roles, weakest first — each includes everything below it:
326
+ *
327
+ * viewer every read
328
+ * developer + stacks and deployments (up/down/verify/sleep/wake, containers, specs, terminal)
329
+ * maintainer + host configuration (host vars, registries, routing, notifiers, swarm join)
330
+ * admin + people, and anything that can mint them (users, SSO config, sealed config)
331
+ *
332
+ * `root` — the `PSTACK_TOKEN` bearer — sits ABOVE all four and is not one of them. A share link is
333
+ * not a role at any rank: it reaches the reads its own deployment allows and nothing else.
334
+ */
335
+ export type Role = 'viewer' | 'developer' | 'maintainer' | 'admin';
336
+ /** An account, as every response carries it. */
337
+ export type User = {
338
+ id: number;
339
+ username: string;
340
+ /**
341
+ * `string`, not `Role`, on the way OUT: `users.role` is a plain TEXT column an operator is
342
+ * expected to be able to repair over SSH, so a value outside the four is possible. The server
343
+ * ranks such a role BELOW viewer — read an unfamiliar one as *less* than viewer, never as more.
344
+ * What you SEND is a `Role`; anything else is a 400.
345
+ */
346
+ role: string;
347
+ /** From an SSO provider's claims; null for every locally-created account. */
348
+ email: string | null;
349
+ createdAt: number;
350
+ };
351
+ /** Who a token is. `user` is present only for an account, `share` only for a share link. */
352
+ export type Me = {
353
+ /** The `PSTACK_TOKEN` bearer — above every role, and it carries no account. */
354
+ root: boolean;
355
+ user?: User;
356
+ /** Epoch ms, or absent when the link could not be re-verified. */
357
+ share?: {
358
+ deployment: string;
359
+ views: ShareView[];
360
+ expiresAt?: number | null;
361
+ };
362
+ };
363
+ /**
364
+ * The two settings that can be changed at RUNTIME, without restarting the container. The server
365
+ * refuses any other key rather than storing it, so this union is the whole surface.
366
+ */
367
+ export type SettingKey = 'max_jobs' | 'default_role';
368
+ /**
369
+ * One setting, as read and as returned by a write.
370
+ *
371
+ * `source` says where the value came from: `db` (someone set it through the API), `env`
372
+ * (`PSTACK_MAX_JOBS`), or `default` (what the binary ships with). Precedence is **database >
373
+ * environment > built-in default** — the environment variable is the DEFAULT, not the authority, so
374
+ * a stored value survives the next restart and a host that never sets one behaves exactly as it did
375
+ * before this existed.
376
+ *
377
+ * `minRole` is the least role that may WRITE this key, served from the server's own permission
378
+ * table: `max_jobs` is maintainer's, `default_role` is admin's. Reading is viewer's. Treat a name
379
+ * this build does not recognise as the most privileged, never the least.
380
+ */
381
+ export type SettingRow = {
382
+ key: SettingKey;
383
+ /** A number for `max_jobs`; a role name for `default_role`. */
384
+ value: number | string;
385
+ source: 'db' | 'env' | 'default';
386
+ minRole: string;
387
+ };
388
+ export type HostSettings = {
389
+ settings: SettingRow[];
390
+ /** `null` when `PSTACK_MAX_JOBS` is unset — never `0`, which the server reads as unset. */
391
+ env: {
392
+ PSTACK_MAX_JOBS: number | null;
393
+ };
394
+ precedence: string;
395
+ };
396
+ /**
397
+ * What a write answers: the fresh row (`source` is now `db`), and for `max_jobs` a `note`.
398
+ *
399
+ * The new cap is in force immediately — no restart — but LOWERING IT CANCELS NOTHING. Jobs already
400
+ * running run to completion; the cap applies to the next job that starts. A script that drops the
401
+ * cap to 1 has not stopped the four jobs in flight.
402
+ */
403
+ export type SettingWritten = SettingRow & {
404
+ stored: true;
405
+ note?: string;
406
+ };
282
407
  export type SsoClaimMap = {
283
408
  subject: string;
284
409
  username: string;
@@ -324,6 +449,17 @@ export type SsoConfig = {
324
449
  * rather than letting every later login fail.
325
450
  */
326
451
  requiredGroups: string[];
452
+ /**
453
+ * The role an account this provider MINTS is created at, and **empty means INHERIT** — the host's
454
+ * `default_role` setting, resolved when the account is provisioned rather than frozen when the
455
+ * provider was saved. Inherit falls back to `viewer` when the host default is unset, and never to
456
+ * admin by omission.
457
+ *
458
+ * Deliberately not narrowed to `Role`: the server stores this string without validating it, and
459
+ * one it does not recognise ranks below viewer. A provider stored before 0.33.0 carries a literal
460
+ * `"viewer"` (the server used to fill an empty value in), and keeps it until it is saved again
461
+ * with `''`.
462
+ */
327
463
  defaultRole: string;
328
464
  };
329
465
  export type SsoPreset = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@samyx/preview-stacks-client",
3
- "version": "0.31.0",
3
+ "version": "0.33.0",
4
4
  "description": "Typed API client for a pstack control plane: deployments, jobs, readiness, containers, logs, notifiers.",
5
5
  "repository": {
6
6
  "type": "git",