@coffre/client 0.0.0 → 0.1.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/index.js ADDED
@@ -0,0 +1,255 @@
1
+ //#region src/sync-fields.ts
2
+ function initialValues(provider) {
3
+ return Object.fromEntries(provider.fields.map((field) => [field.name, field.type === "text" ? "" : field.initial]));
4
+ }
5
+ /** Whether the rest of the form makes the field meaningful. */
6
+ function isAsked(field, values) {
7
+ if (field.type === "options" || field.when === void 0) return true;
8
+ const picked = values[field.when.field];
9
+ const list = Array.isArray(picked) ? picked : [];
10
+ return list.length === field.when.is.length && field.when.is.every((value) => list.includes(value));
11
+ }
12
+ /** The label of the first required field left empty, or null when the form can be sent. */
13
+ function firstMissing(provider, values) {
14
+ for (const field of provider.fields) {
15
+ if (!isAsked(field, values)) continue;
16
+ const value = values[field.name];
17
+ if ((typeof value === "string" ? value.trim() === "" : (value ?? []).length === 0) && (field.type === "options" || field.optional !== true)) return field.label;
18
+ }
19
+ return null;
20
+ }
21
+ /**
22
+ * The provider's config. Text is trimmed, and an optional field left empty
23
+ * is left out rather than sent as "", which the server rightly refuses.
24
+ */
25
+ function configFromForm(provider, values) {
26
+ const config = {};
27
+ for (const field of provider.fields) {
28
+ if (!isAsked(field, values)) continue;
29
+ const value = values[field.name];
30
+ if (field.type === "options") {
31
+ const picked = Array.isArray(value) ? value : [];
32
+ config[field.name] = field.multiple ? picked : picked[0];
33
+ } else {
34
+ const text = typeof value === "string" ? value.trim() : "";
35
+ if (text !== "") config[field.name] = text;
36
+ }
37
+ }
38
+ return config;
39
+ }
40
+ /**
41
+ * The config from `name=value` arguments, as the CLI takes them. A field with
42
+ * several options takes them comma-separated, and one left out gets what the
43
+ * form preselects. Text goes as typed, for the server to check.
44
+ */
45
+ function configFromArguments(provider, assignments) {
46
+ const config = {};
47
+ for (const assignment of assignments) {
48
+ const equals = assignment.indexOf("=");
49
+ const name = equals === -1 ? assignment : assignment.slice(0, equals);
50
+ const field = provider.fields.find((candidate) => candidate.name === name);
51
+ if (equals === -1 || field === void 0) {
52
+ const names = provider.fields.map((candidate) => candidate.name).join(", ");
53
+ throw new Error(`${provider.label} takes ${names} as name=value; got ${JSON.stringify(assignment)}`);
54
+ }
55
+ if (Object.hasOwn(config, name)) throw new Error(`${name} is given twice`);
56
+ const value = assignment.slice(equals + 1);
57
+ if (field.type === "text") config[name] = value;
58
+ else if (field.multiple) config[name] = value.split(",").map((item) => item.trim()).filter((item) => item !== "");
59
+ else config[name] = value;
60
+ }
61
+ for (const field of provider.fields) if (field.type === "options" && !Object.hasOwn(config, field.name)) config[field.name] = field.multiple ? field.initial : field.initial[0];
62
+ return config;
63
+ }
64
+ //#endregion
65
+ //#region src/index.ts
66
+ /** The API's error shape, `{ error, message }`, with the HTTP status. */
67
+ const COFFRE_ERROR = Symbol.for("@coffre/client:CoffreError");
68
+ var CoffreError = class extends Error {
69
+ /**
70
+ * `@coffre/ui`'s prebuilt pages bundle a copy of this class, and a page is
71
+ * handed a client the server made with this package's. So `instanceof`
72
+ * asks for the mark every copy leaves, rather than for this copy's prototype.
73
+ */
74
+ static [Symbol.hasInstance](value) {
75
+ return typeof value === "object" && value !== null && COFFRE_ERROR in value;
76
+ }
77
+ [COFFRE_ERROR] = true;
78
+ status;
79
+ code;
80
+ /** The vault's own code when it refused: `no_grant`, `removed`, `bulk_limit`, ... */
81
+ reason;
82
+ constructor(status, code, message, reason) {
83
+ super(message);
84
+ this.name = "CoffreError";
85
+ this.status = status;
86
+ this.code = code;
87
+ this.reason = reason;
88
+ }
89
+ };
90
+ /** `market/prod/KEY` as route parameters. */
91
+ function place(path) {
92
+ const [project = "", environment = "", key = ""] = path.replace(/^\/+|\/+$/g, "").split("/");
93
+ return {
94
+ project,
95
+ environment,
96
+ key
97
+ };
98
+ }
99
+ function createClient(options) {
100
+ const origin = options.url.replace(/\/+$/, "");
101
+ const transport = options.transport ?? ((request) => fetch(request));
102
+ async function send(method, path, input) {
103
+ const url = new URL(`${origin}/api${path}`);
104
+ const headers = new Headers(await options.headers?.());
105
+ let body;
106
+ if (method === "GET") {
107
+ for (const [name, value] of Object.entries(input ?? {})) if (value !== void 0 && value !== null) url.searchParams.set(name, String(value));
108
+ } else if (input !== void 0) {
109
+ headers.set("content-type", "application/json");
110
+ body = JSON.stringify(input);
111
+ }
112
+ const response = await transport(new Request(url, {
113
+ method,
114
+ headers,
115
+ body
116
+ }));
117
+ const payload = await response.json().catch(() => null);
118
+ if (!response.ok) {
119
+ const error = payload;
120
+ throw new CoffreError(response.status, typeof error?.error === "string" ? error.error : "http_error", typeof error?.message === "string" ? error.message : `request failed with status ${response.status}`, typeof error?.reason === "string" ? error.reason : void 0);
121
+ }
122
+ return payload;
123
+ }
124
+ /** A route key and its parameters as a method and a path. */
125
+ function address(key, params) {
126
+ const [method, pattern] = key.split(" ");
127
+ return [method, pattern.replace(/:(\w+)/g, (_, name) => encodeURIComponent(params[name]))];
128
+ }
129
+ /** Any route by its key: `call('GET /secrets/:project/:environment', { project, environment })`. */
130
+ async function call(key, ...[params, input]) {
131
+ const [method, path] = address(key, params);
132
+ return await send(method, path, input);
133
+ }
134
+ return {
135
+ call,
136
+ /** How this instance signs people in. Answers anyone, signed in or not. */
137
+ auth: () => send("GET", "/auth", void 0),
138
+ /** Who I am, and every place I can reach. */
139
+ me: () => call("GET /me", {}),
140
+ projects: {
141
+ /** With their environments. */
142
+ list: () => call("GET /projects", {}),
143
+ create: (project, input) => call("PUT /projects/:project", { project }, input),
144
+ update: (project, patch) => call("PATCH /projects/:project", { project }, patch)
145
+ },
146
+ environments: {
147
+ create: (path, input) => call("PUT /projects/:project/:environment", place(path), input),
148
+ update: (path, patch) => call("PATCH /projects/:project/:environment", place(path), patch)
149
+ },
150
+ secrets: {
151
+ /** Keys, versions and who changed what; never values. */
152
+ list: (path) => call("GET /secrets/:project/:environment", place(path)),
153
+ /** Decrypts one secret, or every secret in an environment. Logged in your name. */
154
+ reveal: (path) => call("POST /reveals", {}, { path }),
155
+ /** One transaction, a version and an audit entry per key; `null` archives. */
156
+ set: (path, values) => call("PATCH /secrets/:project/:environment", place(path), values),
157
+ /**
158
+ * What `set` would do to each key, `added`, `changed`, `unchanged` or
159
+ * `archived`, and nothing else: no value comes back and nothing is
160
+ * written. Comparing opens the current values, logged as reads.
161
+ */
162
+ dryRun: (path, values) => {
163
+ const [method, route] = address("PATCH /secrets/:project/:environment", place(path));
164
+ return send(method, `${route}?dryRun=1`, values);
165
+ },
166
+ history: (path) => call("GET /secrets/:project/:environment/:key/versions", place(path)),
167
+ /** A new version holding the old one's value. */
168
+ restore: (path, version) => call("POST /secrets/:project/:environment/:key/restore", place(path), { version }),
169
+ rename: (path, key) => call("PATCH /secrets/:project/:environment/:key", place(path), { key }),
170
+ update: (path, patch) => call("PATCH /secrets/:project/:environment/:key", place(path), patch)
171
+ },
172
+ members: {
173
+ /** People and tokens, their role and their access; at a place, those who reach it. */
174
+ list: (path) => call("GET /members", {}, { path }),
175
+ /** What they hold, and what to rotate if they leave. */
176
+ get: (member) => call("GET /members/:member", { member }),
177
+ add: (member, input = {}) => call("PUT /members/:member", { member }, input),
178
+ /** Offboards; returns what to rotate. */
179
+ remove: (member) => call("DELETE /members/:member", { member })
180
+ },
181
+ tokens: {
182
+ list: (member) => call("GET /members/:member/tokens", { member }),
183
+ /** The value is in the answer, and only there. */
184
+ issue: (member, input) => call("POST /members/:member/tokens", { member }, input),
185
+ revoke: (member, id) => call("DELETE /members/:member/tokens/:id", {
186
+ member,
187
+ id
188
+ })
189
+ },
190
+ access: {
191
+ /** What they should hold at each place; the server applies the difference. `null` revokes. */
192
+ set: (member, access) => call("PATCH /access/:member", { member }, access) },
193
+ /** Where I am signed in. Only where coffre runs its own sign-in. */
194
+ sessions: {
195
+ list: () => call("GET /sessions", {}),
196
+ revoke: (id) => call("DELETE /sessions/:id", { id })
197
+ },
198
+ /** The accounts I sign in with. */
199
+ identities: {
200
+ list: () => call("GET /identities", {}),
201
+ unlink: (id) => call("DELETE /identities/:id", { id })
202
+ },
203
+ /** A `coffre login` waiting for someone to approve it, by the code it shows. */
204
+ deviceLogins: {
205
+ get: (code) => call("GET /device-logins/:code", { code }),
206
+ decide: (code, approve) => call("POST /device-logins/:code", { code }, { approve })
207
+ },
208
+ syncs: {
209
+ /** Where a sync can push on this instance, and what each asks for. */
210
+ providers: () => call("GET /syncs/providers", {}),
211
+ list: (path) => call("GET /syncs/:project/:environment", place(path)),
212
+ add: (path, input) => call("POST /syncs/:project/:environment", place(path), input),
213
+ update: (id, patch) => call("PATCH /syncs/by-id/:id", { id }, patch),
214
+ remove: (id) => call("DELETE /syncs/by-id/:id", { id }),
215
+ run: (id) => call("POST /syncs/by-id/:id/runs", { id })
216
+ },
217
+ audit: {
218
+ list: (query = {}) => call("GET /audit", {}, query),
219
+ verify: () => call("GET /audit/verification", {})
220
+ }
221
+ };
222
+ }
223
+ /**
224
+ * What writing these entries (a parsed `.env` file) would do, from a dry run
225
+ * of the write: the server compares, so no value leaves it, and the values it
226
+ * opens to compare are logged as reads. `changes` is what to pass to
227
+ * `secrets.set`: the keys that differ, so an unchanged value does not become
228
+ * a new version.
229
+ */
230
+ async function planImport(coffre, path, entries) {
231
+ const values = Object.create(null);
232
+ for (const { key, value } of entries) values[key] = value;
233
+ const [listed, compared] = await Promise.allSettled([coffre.secrets.list(path), coffre.secrets.dryRun(path, { ...values })]);
234
+ if (listed.status === "rejected") throw listed.reason;
235
+ if (compared.status === "rejected") throw compared.reason;
236
+ const [{ keys }, dryRun] = [listed.value, compared.value];
237
+ const versions = new Map(keys.map((entry) => [entry.key, entry.version]));
238
+ const plan = [];
239
+ const changes = Object.create(null);
240
+ for (const [key, value] of Object.entries(values)) {
241
+ const action = dryRun.keys[key];
242
+ plan.push({
243
+ key,
244
+ action,
245
+ version: versions.get(key) ?? null
246
+ });
247
+ if (action !== "unchanged") changes[key] = value;
248
+ }
249
+ return {
250
+ plan,
251
+ changes: { ...changes }
252
+ };
253
+ }
254
+ //#endregion
255
+ export { CoffreError, configFromArguments, configFromForm, createClient, firstMissing, initialValues, isAsked, planImport };
package/package.json CHANGED
@@ -1,8 +1,38 @@
1
1
  {
2
2
  "name": "@coffre/client",
3
- "version": "0.0.0",
4
- "description": "Reserved for coffre, a self-hosted secrets manager. The first release replaces this placeholder.",
3
+ "version": "0.1.0",
4
+ "description": "The coffre API as typed function calls.",
5
5
  "license": "MIT",
6
- "homepage": "https://github.com/erwinkn/coffre",
7
- "repository": { "type": "git", "url": "git+https://github.com/erwinkn/coffre.git", "directory": "packages/client" }
8
- }
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/erwinkn/coffre.git",
9
+ "directory": "packages/client"
10
+ },
11
+ "type": "module",
12
+ "engines": {
13
+ "node": ">=24"
14
+ },
15
+ "files": [
16
+ "dist",
17
+ "src"
18
+ ],
19
+ "exports": {
20
+ ".": {
21
+ "coffre:source": "./src/index.ts",
22
+ "types": "./dist/index.d.ts",
23
+ "default": "./dist/index.js"
24
+ },
25
+ "./package.json": "./package.json"
26
+ },
27
+ "devDependencies": {
28
+ "@types/node": "26.1.1",
29
+ "tsdown": "0.23.0",
30
+ "typescript": "5.9.3"
31
+ },
32
+ "scripts": {
33
+ "build": "tsdown",
34
+ "generate": "node scripts/generate-api.ts",
35
+ "typecheck": "tsc --noEmit",
36
+ "test": "node --conditions=coffre:source --test"
37
+ }
38
+ }