secryn 1.0.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.
@@ -0,0 +1,363 @@
1
+ import { logger } from "./logger.js";
2
+ export class SecrynApiError extends Error {
3
+ statusCode;
4
+ code;
5
+ details;
6
+ /**
7
+ * @param message - Human-readable error description from the API.
8
+ * @param statusCode - HTTP status code returned by the server.
9
+ * @param code - Machine-readable error identifier (e.g. ``"NOT_FOUND"``).
10
+ * @param details - Optional structured context (validation errors, etc.).
11
+ */
12
+ constructor(message, statusCode, code, details) {
13
+ super(message);
14
+ this.statusCode = statusCode;
15
+ this.code = code;
16
+ this.details = details;
17
+ this.name = "SecrynApiError";
18
+ }
19
+ }
20
+ /**
21
+ * Normalise a base URL and a relative path into a single absolute URL.
22
+ *
23
+ * Strips a trailing ``/`` from the base and ensures the path starts with
24
+ * ``/`` so that the resulting URL has exactly one separator between host
25
+ * and path regardless of the caller's formatting.
26
+ *
27
+ * @param base - Base URL with optional trailing slash.
28
+ * @param path - Relative API path with optional leading slash.
29
+ * @returns Fully-qualified URL string.
30
+ */
31
+ function buildUrl(base, path) {
32
+ const normalizedBase = base.endsWith("/") ? base.slice(0, -1) : base;
33
+ const normalizedPath = path.startsWith("/") ? path : `/${path}`;
34
+ return `${normalizedBase}${normalizedPath}`;
35
+ }
36
+ // ---------------------------------------------------------------------------
37
+ // Cookie jar — persists auth tokens across requests in Node.js.
38
+ //
39
+ // Node's native ``fetch`` does not automatically store or send cookies the
40
+ // way browsers do. This class manually parses ``Set-Cookie`` response headers
41
+ // and injects a ``Cookie`` header on subsequent requests so that cookie-based
42
+ // authentication works server-side.
43
+ // ---------------------------------------------------------------------------
44
+ class CookieJar {
45
+ cookies = new Map();
46
+ /**
47
+ * Extract a cookie name/value pair from a ``Set-Cookie`` header value.
48
+ * Only the first ``name=value`` segment is kept; attributes (``Path``,
49
+ * ``HttpOnly``, etc.) are ignored.
50
+ */
51
+ setFromHeader(setCookieHeader) {
52
+ const parts = setCookieHeader.split(";");
53
+ const first = parts[0]?.trim();
54
+ if (!first)
55
+ return;
56
+ const eq = first.indexOf("=");
57
+ if (eq === -1)
58
+ return;
59
+ const name = first.slice(0, eq).trim();
60
+ const value = first.slice(eq + 1).trim();
61
+ if (value) {
62
+ this.cookies.set(name, value);
63
+ }
64
+ }
65
+ /**
66
+ * Serialise all stored cookies into a single ``Cookie`` header value.
67
+ * Returns an empty string when no cookies are stored.
68
+ */
69
+ getCookieHeader() {
70
+ if (this.cookies.size === 0)
71
+ return "";
72
+ return Array.from(this.cookies.entries())
73
+ .map(([n, v]) => `${n}=${v}`)
74
+ .join("; ");
75
+ }
76
+ clear() {
77
+ this.cookies.clear();
78
+ }
79
+ }
80
+ // ---------------------------------------------------------------------------
81
+ // SecrynClient — HTTP client for the full Secryn REST API.
82
+ //
83
+ // Supports two authentication modes:
84
+ // • Cookie-based — after ``auth.login``, session cookies are persisted
85
+ // across requests via an internal ``CookieJar``.
86
+ // • API-key-based — pass ``apiKey`` in the constructor options.
87
+ //
88
+ // All API resources are exposed as namespaced sub-objects (``auth``,
89
+ // ``projects``, ``secrets``, etc.) for discoverable auto-completion.
90
+ // ---------------------------------------------------------------------------
91
+ export class SecrynClient {
92
+ baseUrl;
93
+ apiKey;
94
+ cookieJar = new CookieJar();
95
+ /**
96
+ * @param options - Optional configuration.
97
+ * @param options.baseUrl - Base URL including the ``/api/v1`` prefix.
98
+ * Defaults to ``http://localhost:3000/api/v1``.
99
+ * @param options.apiKey - Optional API key for programmatic access.
100
+ */
101
+ constructor(options = {}) {
102
+ this.baseUrl = options.baseUrl ?? "http://localhost:3000/api/v1";
103
+ this.apiKey = options.apiKey;
104
+ }
105
+ // ---- raw HTTP -----------------------------------------------------------
106
+ /**
107
+ * Execute an HTTP request and handle common response semantics.
108
+ *
109
+ * Key behaviours:
110
+ * - Injects stored cookies from the internal cookie jar as the
111
+ * ``Cookie`` header (enabling session-based auth).
112
+ * - Injects the ``api-key`` header when the client was configured with one.
113
+ * - Persists ``Set-Cookie`` response headers back into the cookie jar.
114
+ * - Returns ``undefined`` for 204 No Content responses.
115
+ * - Parses the body as JSON; falls back to returning the raw text on
116
+ * parse failure.
117
+ * - Throws {@link SecrynApiError} when ``response.ok`` is ``false``,
118
+ * extracting ``message``, ``code``, and ``details`` from the JSON body
119
+ * when available.
120
+ *
121
+ * @param opts - Request definition.
122
+ * @template T - Expected shape of the parsed JSON response.
123
+ * @returns Parsed JSON typed as ``T``, or ``undefined`` for 204.
124
+ * @throws {SecrynApiError} When the API responds with a non-2xx status.
125
+ */
126
+ async request(opts) {
127
+ const url = buildUrl(this.baseUrl, opts.path);
128
+ const headers = {
129
+ "Content-Type": "application/json",
130
+ Accept: "application/json",
131
+ };
132
+ const cookieHeader = this.cookieJar.getCookieHeader();
133
+ if (cookieHeader) {
134
+ headers["Cookie"] = cookieHeader;
135
+ }
136
+ if (this.apiKey) {
137
+ headers["api-key"] = this.apiKey;
138
+ }
139
+ const init = {
140
+ method: opts.method,
141
+ headers,
142
+ };
143
+ if (opts.body !== undefined) {
144
+ init.body = JSON.stringify(opts.body);
145
+ }
146
+ const response = await fetch(url, init);
147
+ // Persist cookies
148
+ const setCookie = response.headers.get("set-cookie");
149
+ if (setCookie) {
150
+ this.cookieJar.setFromHeader(setCookie);
151
+ }
152
+ // 204 No Content
153
+ if (response.status === 204) {
154
+ return undefined;
155
+ }
156
+ const text = await response.text();
157
+ let data;
158
+ try {
159
+ data = text ? JSON.parse(text) : null;
160
+ }
161
+ catch {
162
+ data = text;
163
+ }
164
+ if (!response.ok) {
165
+ const err = data;
166
+ throw new SecrynApiError(err?.message ?? `HTTP ${response.status}`, response.status, err?.code ?? "UNKNOWN", err?.details);
167
+ }
168
+ return data;
169
+ }
170
+ // ---- convenience shortcuts ----------------------------------------------
171
+ get(path) {
172
+ return this.request({ method: "GET", path });
173
+ }
174
+ post(path, body) {
175
+ return this.request({ method: "POST", path, body });
176
+ }
177
+ put(path, body) {
178
+ return this.request({ method: "PUT", path, body });
179
+ }
180
+ del(path) {
181
+ return this.request({ method: "DELETE", path });
182
+ }
183
+ // =====================================================================
184
+ // Auth
185
+ // =====================================================================
186
+ auth = {
187
+ login: async (email, password) => {
188
+ const body = { email, password };
189
+ const result = await this.post("/auth/login", body);
190
+ // Emit an audit log on every login attempt (success or failure is
191
+ // reflected by whether this call throws).
192
+ logger.audit("SDK_LOGIN", email);
193
+ return result;
194
+ },
195
+ register: async (email, password, username) => {
196
+ const body = { email, password, username };
197
+ return this.post("/auth/register", body);
198
+ },
199
+ /**
200
+ * Send a logout request and unconditionally clear the local cookie jar.
201
+ *
202
+ * The cookie jar is cleared inside a ``finally`` block so that even if
203
+ * the server returns an error the local session is still discarded.
204
+ */
205
+ logout: async () => {
206
+ try {
207
+ await this.post("/auth/logout");
208
+ }
209
+ finally {
210
+ this.cookieJar.clear();
211
+ }
212
+ },
213
+ refresh: async () => {
214
+ await this.post("/auth/refresh");
215
+ },
216
+ forgotPassword: async (email) => {
217
+ const body = { email };
218
+ return this.post("/auth/forgot-password", body);
219
+ },
220
+ resetPassword: async (token, password) => {
221
+ const body = { token, password };
222
+ return this.post("/auth/reset-password", body);
223
+ },
224
+ /**
225
+ * Check whether the client currently holds any session cookies.
226
+ * Does not validate the cookie with the server.
227
+ */
228
+ isAuthenticated: () => this.cookieJar.getCookieHeader() !== "",
229
+ };
230
+ // =====================================================================
231
+ // MFA
232
+ // =====================================================================
233
+ mfa = {
234
+ setup: () => this.get("/auth/mfa/setup"),
235
+ enable: (token) => {
236
+ const body = { token };
237
+ return this.post("/auth/mfa/enable", body);
238
+ },
239
+ disable: () => this.post("/auth/mfa/disable"),
240
+ confirm: (token, mfaToken) => {
241
+ const body = { token, mfaToken };
242
+ return this.post("/auth/mfa/confirm", body);
243
+ },
244
+ recovery: (code, mfaToken) => {
245
+ const body = { code, mfaToken };
246
+ return this.post("/auth/mfa/recovery", body);
247
+ },
248
+ recoveryCodes: () => this.get("/auth/mfa/recovery-codes"),
249
+ regenerateCodes: () => this.post("/auth/mfa/recovery-codes/regenerate"),
250
+ sendBackupCode: (email) => this.post("/auth/mfa/send-backup-code", { email }),
251
+ status: () => this.get("/auth/mfa/status"),
252
+ };
253
+ // =====================================================================
254
+ // Users
255
+ // =====================================================================
256
+ users = {
257
+ me: () => this.get("/users/@me"),
258
+ get: (userId) => this.get(`/users/${userId}`),
259
+ update: (data) => this.put("/users", data),
260
+ delete: () => this.del("/users"),
261
+ };
262
+ // =====================================================================
263
+ // API Keys
264
+ // =====================================================================
265
+ apiKeys = {
266
+ create: (name, permissions = ["read", "write"]) => {
267
+ const body = { name, permissions };
268
+ return this.post("/api-keys", body);
269
+ },
270
+ list: () => this.get("/api-keys/@all-user"),
271
+ get: (id) => this.get(`/api-keys/${id}`),
272
+ update: (id, data) => this.put(`/api-keys/${id}`, data),
273
+ delete: (id) => this.del(`/api-keys/${id}`),
274
+ };
275
+ // =====================================================================
276
+ // Projects
277
+ // =====================================================================
278
+ projects = {
279
+ create: (name, description) => {
280
+ const body = {
281
+ name,
282
+ description: description ?? "",
283
+ };
284
+ return this.post("/projects", body);
285
+ },
286
+ list: () => this.get("/projects/@all"),
287
+ get: (id) => this.get(`/projects/${id}`),
288
+ update: (id, data) => this.put(`/projects/${id}`, data),
289
+ delete: (id) => this.del(`/projects/${id}`),
290
+ transfer: (id, newOwnerId) => this.post(`/projects/${id}/transfer`, { newOwnerId }),
291
+ };
292
+ // =====================================================================
293
+ // Invites
294
+ // =====================================================================
295
+ invites = {
296
+ create: (projectId, email) => {
297
+ const body = {};
298
+ if (email)
299
+ body.email = email;
300
+ // Send body as-is (empty object when no email) so the server creates an
301
+ // open invite that any authenticated user can accept.
302
+ return this.post(`/projects/${projectId}/invites`, body);
303
+ },
304
+ /**
305
+ * Accept a project invitation by its unique slug.
306
+ *
307
+ * Uses ``GET`` instead of ``POST`` because the server identifies the
308
+ * invite via the URL slug and does not require a request body.
309
+ */
310
+ accept: (slug) => this.get(`/projects/invites/${slug}`),
311
+ };
312
+ // =====================================================================
313
+ // Members
314
+ // =====================================================================
315
+ members = {
316
+ remove: (projectId, memberId) => this.del(`/projects/${projectId}/members/${memberId}`),
317
+ addPermissions: (projectId, memberId, permissions) => this.post(`/projects/${projectId}/members/${memberId}/permissions`, { permissions }),
318
+ /**
319
+ * Remove permissions from a member.
320
+ *
321
+ * The ``.then()`` chain discards the API response body because the
322
+ * endpoint returns updated permissions on success but the caller only
323
+ * cares that the operation completed without error.
324
+ */
325
+ removePermissions: (projectId, memberId) => this.del(`/projects/${projectId}/members/${memberId}/permissions`).then(() => undefined),
326
+ };
327
+ // =====================================================================
328
+ // Secrets
329
+ // =====================================================================
330
+ secrets = {
331
+ create: (projectId, name, value, notes) => {
332
+ const body = {
333
+ name,
334
+ value,
335
+ notes: notes ?? "",
336
+ };
337
+ return this.post(`/projects/${projectId}/secrets`, body);
338
+ },
339
+ get: (id) => this.get(`/projects/secrets/${id}`),
340
+ list: (projectId) => this.get(`/projects/${projectId}/secrets`),
341
+ update: (id, data) => this.put(`/projects/secrets/${id}`, data),
342
+ delete: (id) => this.del(`/projects/secrets/${id}`),
343
+ /**
344
+ * Export all secrets of a project as a dotenv-formatted string.
345
+ *
346
+ * Uses raw ``fetch`` instead of the internal ``request`` helper because
347
+ * the endpoint returns ``text/plain``, not ``application/json``, and
348
+ * the response body must not be parsed as JSON.
349
+ *
350
+ * @throws {SecrynApiError} With code ``"EXPORT_ERROR"`` on non-2xx.
351
+ */
352
+ exportDotenv: (projectId) => fetch(buildUrl(this.baseUrl, `/projects/${projectId}/secrets/export`), {
353
+ headers: {
354
+ ...(this.cookieJar.getCookieHeader() ? { Cookie: this.cookieJar.getCookieHeader() } : {}),
355
+ ...(this.apiKey ? { "api-key": this.apiKey } : {}),
356
+ },
357
+ }).then((r) => {
358
+ if (!r.ok)
359
+ throw new SecrynApiError("Export failed", r.status, "EXPORT_ERROR");
360
+ return r.text();
361
+ }),
362
+ };
363
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Namespaced logger for the Secryn SDK.
3
+ *
4
+ * ``error`` and ``warn`` always write to the console.
5
+ * ``info``, ``debug``, and ``audit`` are gated behind the
6
+ * ``SECRYN_SDK_DEBUG`` environment variable (set to ``"1"`` to enable).
7
+ *
8
+ * All messages are prefixed with ``[secryn]`` for grep-friendly output.
9
+ */
10
+ export declare const logger: {
11
+ error(message: string, meta?: unknown): void;
12
+ warn(message: string, meta?: unknown): void;
13
+ info(message: string, meta?: unknown): void;
14
+ debug(message: string, meta?: unknown): void;
15
+ /**
16
+ * Emit a structured audit log entry.
17
+ *
18
+ * Format: ``[secryn] [AUDIT] <action> actor=<actor> resource=<resource>``.
19
+ * Gated behind ``SECRYN_SDK_DEBUG`` like ``info`` and ``debug``.
20
+ *
21
+ * @param action - CRUD verb or custom action identifier (e.g. ``"login"``).
22
+ * @param actor - Identifier of the principal (user ID, API key prefix).
23
+ * @param resource - Optional resource path or ID affected by the action.
24
+ * @param meta - Optional unstructured context (serialized inline).
25
+ */
26
+ audit(action: string, actor: string, resource?: string, meta?: unknown): void;
27
+ };
@@ -0,0 +1,43 @@
1
+ /** Debug gate: set ``SECRYN_SDK_DEBUG=1`` to enable info/debug/audit output. */
2
+ const isDebug = process.env["SECRYN_SDK_DEBUG"] === "1";
3
+ /**
4
+ * Namespaced logger for the Secryn SDK.
5
+ *
6
+ * ``error`` and ``warn`` always write to the console.
7
+ * ``info``, ``debug``, and ``audit`` are gated behind the
8
+ * ``SECRYN_SDK_DEBUG`` environment variable (set to ``"1"`` to enable).
9
+ *
10
+ * All messages are prefixed with ``[secryn]`` for grep-friendly output.
11
+ */
12
+ export const logger = {
13
+ error(message, meta) {
14
+ console.error(`[secryn] ERROR ${message}`, meta ?? "");
15
+ },
16
+ warn(message, meta) {
17
+ console.warn(`[secryn] WARN ${message}`, meta ?? "");
18
+ },
19
+ info(message, meta) {
20
+ if (isDebug)
21
+ console.info(`[secryn] INFO ${message}`, meta ?? "");
22
+ },
23
+ debug(message, meta) {
24
+ if (isDebug)
25
+ console.debug(`[secryn] DEBUG ${message}`, meta ?? "");
26
+ },
27
+ /**
28
+ * Emit a structured audit log entry.
29
+ *
30
+ * Format: ``[secryn] [AUDIT] <action> actor=<actor> resource=<resource>``.
31
+ * Gated behind ``SECRYN_SDK_DEBUG`` like ``info`` and ``debug``.
32
+ *
33
+ * @param action - CRUD verb or custom action identifier (e.g. ``"login"``).
34
+ * @param actor - Identifier of the principal (user ID, API key prefix).
35
+ * @param resource - Optional resource path or ID affected by the action.
36
+ * @param meta - Optional unstructured context (serialized inline).
37
+ */
38
+ audit(action, actor, resource, meta) {
39
+ if (isDebug) {
40
+ console.info(`[secryn] [AUDIT] ${action} actor=${actor} resource=${resource ?? "-"}`, meta ?? "");
41
+ }
42
+ },
43
+ };
@@ -0,0 +1,74 @@
1
+ /** Request/response DTOs for the Secryn API — mirrored from @repo/shared. */
2
+ export interface LoginBody {
3
+ email: string;
4
+ password: string;
5
+ }
6
+ export interface RegisterBody {
7
+ email: string;
8
+ password: string;
9
+ username?: string;
10
+ }
11
+ export interface LoginMFAResponse {
12
+ mfaRequired: true;
13
+ mfaToken: string;
14
+ }
15
+ export interface MFAConfirmBody {
16
+ token: string;
17
+ mfaToken: string;
18
+ }
19
+ export interface MFARecoveryBody {
20
+ code: string;
21
+ mfaToken: string;
22
+ }
23
+ export interface MFASetupResponse {
24
+ secret: string;
25
+ qrCode: string;
26
+ otpauthUrl: string;
27
+ }
28
+ export interface MFAEnableBody {
29
+ token: string;
30
+ }
31
+ export interface MFAStatusResponse {
32
+ enabled: boolean;
33
+ }
34
+ export interface MFARecoveryCodesResponse {
35
+ codes: string[];
36
+ }
37
+ export interface ForgotPasswordBody {
38
+ email: string;
39
+ }
40
+ export interface ResetPasswordBody {
41
+ token: string;
42
+ password: string;
43
+ }
44
+ export interface UpdateUserInput {
45
+ name?: string;
46
+ email?: string;
47
+ currentPassword?: string;
48
+ newPassword?: string;
49
+ }
50
+ export interface CreateProjectInput {
51
+ name: string;
52
+ description?: string;
53
+ }
54
+ export interface CreateSecretInput {
55
+ name: string;
56
+ value: string;
57
+ notes: string;
58
+ }
59
+ export interface UpdateSecretInput {
60
+ name?: string;
61
+ value?: string;
62
+ notes?: string;
63
+ }
64
+ export type ApiKeyPermission = "read" | "write";
65
+ export interface CreateApiKeyInput {
66
+ name: string;
67
+ permissions: ApiKeyPermission[];
68
+ }
69
+ export interface UpdateApiKeyInput {
70
+ name?: string;
71
+ isActive?: boolean;
72
+ addPermissions?: ApiKeyPermission[];
73
+ removePermissions?: ApiKeyPermission[];
74
+ }
@@ -0,0 +1,2 @@
1
+ /** Request/response DTOs for the Secryn API — mirrored from @repo/shared. */
2
+ export {};
package/index.ts ADDED
@@ -0,0 +1 @@
1
+ export { SecrynClient, SecrynApiError } from "./src/client.js";
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "secryn",
3
+ "version": "1.0.0",
4
+ "description": "Secryn TypeScript SDK — manage secrets, projects, and API keys programmatically",
5
+ "author": "Secryn",
6
+ "license": "Apache-2.0",
7
+ "type": "module",
8
+ "private": false,
9
+ "keywords": [
10
+ "secryn",
11
+ "secrets",
12
+ "management",
13
+ "encryption",
14
+ "api",
15
+ "sdk"
16
+ ],
17
+ "main": "./dist/index.js",
18
+ "types": "./dist/index.d.ts",
19
+ "exports": {
20
+ ".": {
21
+ "types": "./dist/index.d.ts",
22
+ "import": "./dist/index.js",
23
+ "default": "./dist/index.js"
24
+ }
25
+ },
26
+ "devDependencies": {
27
+ "@types/node": "^25.9.1",
28
+ "typescript": "^6.0.3"
29
+ },
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "https://github.com/P4ciuf/secryn.git",
33
+ "directory": "packages/sdk-ts"
34
+ },
35
+ "homepage": "https://secryn.xyz",
36
+ "publishConfig": {
37
+ "access": "public"
38
+ },
39
+ "dependencies": {},
40
+ "scripts": {
41
+ "build": "tsc",
42
+ "typecheck": "tsc --noEmit"
43
+ }
44
+ }