@ursalock/server 0.3.1 → 0.4.2

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.
Files changed (2) hide show
  1. package/dist/index.js +750 -64
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // src/app.ts
2
- import { Hono as Hono5 } from "hono";
2
+ import { Hono as Hono6 } from "hono";
3
3
  import { cors } from "hono/cors";
4
4
  import { logger } from "hono/logger";
5
5
  import { bodyLimit } from "hono/body-limit";
@@ -88,6 +88,13 @@ function getAllowedOrigins() {
88
88
  }
89
89
 
90
90
  // src/db/schema.ts
91
+ function parseApiKeyScopes(key) {
92
+ return {
93
+ permissions: JSON.parse(key.permissions),
94
+ vaultUids: key.vaultUids ? JSON.parse(key.vaultUids) : null,
95
+ collections: key.collections ? JSON.parse(key.collections) : null
96
+ };
97
+ }
91
98
  var CREATE_TABLES_SQL = `
92
99
  -- Users table
93
100
  CREATE TABLE IF NOT EXISTS users (
@@ -149,6 +156,47 @@ CREATE TABLE IF NOT EXISTS vaults (
149
156
  CREATE INDEX IF NOT EXISTS idx_vaults_uid ON vaults(uid);
150
157
  CREATE INDEX IF NOT EXISTS idx_vaults_user_id ON vaults(user_id);
151
158
  CREATE UNIQUE INDEX IF NOT EXISTS idx_vaults_user_name ON vaults(user_id, name);
159
+
160
+ -- Documents table (individually encrypted items within vaults)
161
+ CREATE TABLE IF NOT EXISTS documents (
162
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
163
+ uid TEXT NOT NULL UNIQUE DEFAULT (lower(hex(randomblob(16)))),
164
+ vault_uid TEXT NOT NULL,
165
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
166
+ collection TEXT NOT NULL,
167
+ data TEXT NOT NULL,
168
+ hmac TEXT,
169
+ version INTEGER NOT NULL DEFAULT 1,
170
+ created_at INTEGER NOT NULL DEFAULT (unixepoch()),
171
+ updated_at INTEGER NOT NULL DEFAULT (unixepoch()),
172
+ deleted_at INTEGER
173
+ );
174
+
175
+ CREATE INDEX IF NOT EXISTS idx_documents_vault_uid ON documents(vault_uid);
176
+ CREATE INDEX IF NOT EXISTS idx_documents_user_id ON documents(user_id);
177
+ CREATE INDEX IF NOT EXISTS idx_documents_vault_collection ON documents(vault_uid, collection);
178
+ CREATE INDEX IF NOT EXISTS idx_documents_vault_updated ON documents(vault_uid, updated_at);
179
+ CREATE INDEX IF NOT EXISTS idx_documents_vault_collection_deleted ON documents(vault_uid, collection, deleted_at);
180
+
181
+ -- API keys table (for agent/service access)
182
+ CREATE TABLE IF NOT EXISTS api_keys (
183
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
184
+ uid TEXT NOT NULL UNIQUE DEFAULT (lower(hex(randomblob(16)))),
185
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
186
+ name TEXT NOT NULL,
187
+ key_hash TEXT NOT NULL UNIQUE,
188
+ key_prefix TEXT NOT NULL,
189
+ permissions TEXT NOT NULL DEFAULT '["read","write"]',
190
+ vault_uids TEXT,
191
+ collections TEXT,
192
+ expires_at INTEGER,
193
+ last_used_at INTEGER,
194
+ created_at INTEGER NOT NULL DEFAULT (unixepoch()),
195
+ revoked_at INTEGER
196
+ );
197
+
198
+ CREATE INDEX IF NOT EXISTS idx_api_keys_key_hash ON api_keys(key_hash);
199
+ CREATE INDEX IF NOT EXISTS idx_api_keys_user_id ON api_keys(user_id);
152
200
  `;
153
201
 
154
202
  // src/db/client.ts
@@ -400,6 +448,186 @@ function deleteVault(uid, userId) {
400
448
  const stmt = db.prepare(`DELETE FROM vaults WHERE uid = ? AND user_id = ?`);
401
449
  return stmt.run(uid, userId).changes > 0;
402
450
  }
451
+ var DOCUMENT_COLUMNS = `id, uid, vault_uid as vaultUid, user_id as userId, collection,
452
+ data, hmac, version, created_at as createdAt, updated_at as updatedAt, deleted_at as deletedAt`;
453
+ function createDocument(input) {
454
+ const db = getDb();
455
+ const stmt = db.prepare(`
456
+ INSERT INTO documents (vault_uid, user_id, collection, data, hmac)
457
+ VALUES (?, ?, ?, ?, ?)
458
+ RETURNING ${DOCUMENT_COLUMNS}
459
+ `);
460
+ return stmt.get(
461
+ input.vaultUid,
462
+ input.userId,
463
+ input.collection,
464
+ input.data,
465
+ input.hmac ?? null
466
+ );
467
+ }
468
+ function getDocumentByUid(uid, vaultUid, userId) {
469
+ const db = getDb();
470
+ const stmt = db.prepare(`
471
+ SELECT ${DOCUMENT_COLUMNS}
472
+ FROM documents WHERE uid = ? AND vault_uid = ? AND user_id = ?
473
+ `);
474
+ return stmt.get(uid, vaultUid, userId);
475
+ }
476
+ function listDocuments(vaultUid, userId, opts) {
477
+ const db = getDb();
478
+ const conditions = ["vault_uid = ?", "user_id = ?"];
479
+ const params = [vaultUid, userId];
480
+ if (opts?.collection) {
481
+ conditions.push("collection = ?");
482
+ params.push(opts.collection);
483
+ }
484
+ if (opts?.since != null) {
485
+ conditions.push("updated_at >= ?");
486
+ params.push(opts.since);
487
+ }
488
+ if (!opts?.includeDeleted) {
489
+ conditions.push("deleted_at IS NULL");
490
+ }
491
+ let query = `SELECT ${DOCUMENT_COLUMNS} FROM documents WHERE ${conditions.join(" AND ")} ORDER BY updated_at DESC`;
492
+ if (opts?.limit != null) {
493
+ query += ` LIMIT ?`;
494
+ params.push(opts.limit);
495
+ }
496
+ if (opts?.offset != null) {
497
+ query += ` OFFSET ?`;
498
+ params.push(opts.offset);
499
+ }
500
+ const stmt = db.prepare(query);
501
+ return stmt.all(...params);
502
+ }
503
+ function updateDocument(uid, vaultUid, userId, input) {
504
+ const db = getDb();
505
+ if (input.version != null) {
506
+ const result = db.transaction(() => {
507
+ const stmt2 = db.prepare(`
508
+ UPDATE documents SET data = ?, hmac = ?, version = ? + 1, updated_at = unixepoch()
509
+ WHERE uid = ? AND vault_uid = ? AND user_id = ? AND version = ?
510
+ RETURNING ${DOCUMENT_COLUMNS}
511
+ `);
512
+ const doc2 = stmt2.get(input.data, input.hmac ?? null, input.version, uid, vaultUid, userId, input.version);
513
+ if (doc2) return { document: doc2, conflict: false };
514
+ const exists = db.prepare(
515
+ `SELECT 1 FROM documents WHERE uid = ? AND vault_uid = ? AND user_id = ?`
516
+ ).get(uid, vaultUid, userId);
517
+ return { document: void 0, conflict: !!exists };
518
+ })();
519
+ return result;
520
+ }
521
+ const stmt = db.prepare(`
522
+ UPDATE documents SET data = ?, hmac = ?, version = version + 1, updated_at = unixepoch()
523
+ WHERE uid = ? AND vault_uid = ? AND user_id = ?
524
+ RETURNING ${DOCUMENT_COLUMNS}
525
+ `);
526
+ const doc = stmt.get(input.data, input.hmac ?? null, uid, vaultUid, userId);
527
+ return { document: doc, conflict: false };
528
+ }
529
+ function softDeleteDocument(uid, vaultUid, userId) {
530
+ const db = getDb();
531
+ const stmt = db.prepare(`
532
+ UPDATE documents SET deleted_at = unixepoch(), updated_at = unixepoch()
533
+ WHERE uid = ? AND vault_uid = ? AND user_id = ? AND deleted_at IS NULL
534
+ RETURNING ${DOCUMENT_COLUMNS}
535
+ `);
536
+ return stmt.get(uid, vaultUid, userId);
537
+ }
538
+ function getDocumentsSince(vaultUid, userId, since) {
539
+ const db = getDb();
540
+ const stmt = db.prepare(`
541
+ SELECT ${DOCUMENT_COLUMNS}
542
+ FROM documents WHERE vault_uid = ? AND user_id = ? AND updated_at >= ?
543
+ ORDER BY updated_at DESC
544
+ `);
545
+ return stmt.all(vaultUid, userId, since);
546
+ }
547
+ var API_KEY_COLUMNS = `id, uid, user_id as userId, name, key_hash as keyHash, key_prefix as keyPrefix,
548
+ permissions, vault_uids as vaultUids, collections, expires_at as expiresAt,
549
+ last_used_at as lastUsedAt, created_at as createdAt, revoked_at as revokedAt`;
550
+ function createApiKey(input) {
551
+ const db = getDb();
552
+ const stmt = db.prepare(`
553
+ INSERT INTO api_keys (user_id, name, key_hash, key_prefix, permissions, vault_uids, collections, expires_at)
554
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
555
+ RETURNING ${API_KEY_COLUMNS}
556
+ `);
557
+ const permissions = input.permissions ? JSON.stringify(input.permissions) : '["read","write"]';
558
+ const vaultUids = input.vaultUids ? JSON.stringify(input.vaultUids) : null;
559
+ const collections = input.collections ? JSON.stringify(input.collections) : null;
560
+ return stmt.get(
561
+ input.userId,
562
+ input.name,
563
+ input.keyHash,
564
+ input.keyPrefix,
565
+ permissions,
566
+ vaultUids,
567
+ collections,
568
+ input.expiresAt ?? null
569
+ );
570
+ }
571
+ function getApiKeyByHash(keyHash) {
572
+ const db = getDb();
573
+ const stmt = db.prepare(`
574
+ SELECT
575
+ k.id, k.uid, k.user_id as userId, k.name, k.key_hash as keyHash, k.key_prefix as keyPrefix,
576
+ k.permissions, k.vault_uids as vaultUids, k.collections, k.expires_at as expiresAt,
577
+ k.last_used_at as lastUsedAt, k.created_at as createdAt, k.revoked_at as revokedAt,
578
+ ${USER_JOIN_COLUMNS}
579
+ FROM api_keys k
580
+ JOIN users u ON k.user_id = u.id
581
+ WHERE k.key_hash = ?
582
+ `);
583
+ const row = stmt.get(keyHash);
584
+ if (!row) return void 0;
585
+ return {
586
+ id: row["id"],
587
+ uid: row["uid"],
588
+ userId: row["userId"],
589
+ name: row["name"],
590
+ keyHash: row["keyHash"],
591
+ keyPrefix: row["keyPrefix"],
592
+ permissions: row["permissions"],
593
+ vaultUids: row["vaultUids"] ?? null,
594
+ collections: row["collections"] ?? null,
595
+ expiresAt: row["expiresAt"] ?? null,
596
+ lastUsedAt: row["lastUsedAt"] ?? null,
597
+ createdAt: row["createdAt"],
598
+ revokedAt: row["revokedAt"] ?? null,
599
+ user: userFromRow(row)
600
+ };
601
+ }
602
+ function listApiKeysByUserId(userId) {
603
+ const db = getDb();
604
+ const stmt = db.prepare(`
605
+ SELECT id, uid, user_id as userId, name, key_prefix as keyPrefix,
606
+ permissions, vault_uids as vaultUids, collections, expires_at as expiresAt,
607
+ last_used_at as lastUsedAt, created_at as createdAt, revoked_at as revokedAt
608
+ FROM api_keys WHERE user_id = ?
609
+ ORDER BY created_at DESC
610
+ `);
611
+ return stmt.all(userId);
612
+ }
613
+ function revokeApiKey(uid, userId) {
614
+ const db = getDb();
615
+ const stmt = db.prepare(`
616
+ UPDATE api_keys SET revoked_at = unixepoch()
617
+ WHERE uid = ? AND user_id = ? AND revoked_at IS NULL
618
+ `);
619
+ return stmt.run(uid, userId).changes > 0;
620
+ }
621
+ function updateApiKeyLastUsed(uid) {
622
+ const db = getDb();
623
+ const stmt = db.prepare(`UPDATE api_keys SET last_used_at = unixepoch() WHERE uid = ?`);
624
+ stmt.run(uid);
625
+ }
626
+ function deleteExpiredApiKeys() {
627
+ const db = getDb();
628
+ const stmt = db.prepare(`DELETE FROM api_keys WHERE expires_at IS NOT NULL AND expires_at <= unixepoch()`);
629
+ return stmt.run().changes;
630
+ }
403
631
 
404
632
  // src/features/auth/jwt.ts
405
633
  import { createHash } from "crypto";
@@ -460,11 +688,18 @@ var ErrorCode = z2.enum([
460
688
  "passkey_not_found",
461
689
  "session_expired",
462
690
  "invalid_origin",
691
+ "api_key_not_found",
692
+ "api_key_revoked",
693
+ "insufficient_permissions",
463
694
  // Vault errors
464
695
  "vault_not_found",
465
696
  "vault_already_exists",
466
697
  "vault_conflict",
467
698
  "invalid_vault_data",
699
+ // Document errors
700
+ "document_not_found",
701
+ "document_conflict",
702
+ "document_already_exists",
468
703
  // Validation errors
469
704
  "validation_error",
470
705
  "invalid_request",
@@ -488,29 +723,37 @@ var errors = {
488
723
  passkey_not_found: { code: "passkey_not_found", message: "Passkey not found" },
489
724
  session_expired: { code: "session_expired", message: "Session expired" },
490
725
  invalid_origin: { code: "invalid_origin", message: "Origin not allowed" },
726
+ api_key_not_found: { code: "api_key_not_found", message: "API key not found" },
727
+ api_key_revoked: { code: "api_key_revoked", message: "API key has been revoked" },
728
+ insufficient_permissions: { code: "insufficient_permissions", message: "Insufficient permissions" },
491
729
  // Vault errors
492
730
  vault_not_found: { code: "vault_not_found", message: "Vault not found" },
493
- vault_already_exists: (name) => ({
494
- code: "vault_already_exists",
495
- message: `Vault "${name}" already exists`
496
- }),
497
731
  vault_conflict: { code: "vault_conflict", message: "Version conflict - vault has been modified. Please refresh and retry." },
498
732
  invalid_vault_data: { code: "invalid_vault_data", message: "Invalid vault data" },
733
+ // Document errors
734
+ document_not_found: { code: "document_not_found", message: "Document not found" },
735
+ document_conflict: { code: "document_conflict", message: "Version conflict - document has been modified. Please refresh and retry." },
736
+ document_already_exists: { code: "document_already_exists", message: "Document already exists" },
499
737
  // Validation errors
500
- validation_error: (details) => ({
501
- code: "validation_error",
502
- message: details
503
- }),
504
738
  invalid_request: { code: "invalid_request", message: "Invalid request" },
505
739
  // Server errors
506
740
  internal_error: { code: "internal_error", message: "Internal server error" }
507
741
  };
508
- function getError(code, arg) {
509
- const factory = errors[code];
510
- if (typeof factory === "function") {
511
- return factory(arg ?? "");
742
+ var errorBuilders = {
743
+ vaultAlreadyExists: (name) => ({
744
+ code: "vault_already_exists",
745
+ message: `Vault "${name}" already exists`
746
+ }),
747
+ validationError: (details) => ({
748
+ code: "validation_error",
749
+ message: details
750
+ })
751
+ };
752
+ function getError(code) {
753
+ if (code in errors) {
754
+ return errors[code];
512
755
  }
513
- return factory;
756
+ return { code, message: code.replace(/_/g, " ") };
514
757
  }
515
758
 
516
759
  // src/features/auth/middleware.ts
@@ -538,7 +781,8 @@ var optionalAuthMiddleware = createMiddleware(async (c, next) => {
538
781
  uid: session.user.uid,
539
782
  email: session.user.email
540
783
  },
541
- sessionId: session.id
784
+ sessionId: session.id,
785
+ authType: "jwt"
542
786
  });
543
787
  return next();
544
788
  });
@@ -548,6 +792,44 @@ var requireAuthMiddleware = createMiddleware(async (c, next) => {
548
792
  throw new ApiException(getError("unauthorized"), 401);
549
793
  }
550
794
  const token = authHeader.slice(7);
795
+ if (token.startsWith("ulk_")) {
796
+ if (token.length !== 52) {
797
+ throw new ApiException(getError("unauthorized"), 401);
798
+ }
799
+ const keyHash = hashToken(token);
800
+ const keyRecord = getApiKeyByHash(keyHash);
801
+ if (!keyRecord) {
802
+ throw new ApiException(getError("unauthorized"), 401);
803
+ }
804
+ if (keyRecord.revokedAt !== null) {
805
+ throw new ApiException(getError("api_key_revoked"), 401);
806
+ }
807
+ const now = Math.floor(Date.now() / 1e3);
808
+ if (keyRecord.expiresAt !== null && keyRecord.expiresAt <= now) {
809
+ throw new ApiException(getError("unauthorized"), 401);
810
+ }
811
+ setImmediate(() => {
812
+ try {
813
+ updateApiKeyLastUsed(keyRecord.uid);
814
+ } catch {
815
+ }
816
+ });
817
+ const scopes = parseApiKeyScopes(keyRecord);
818
+ c.set("session", {
819
+ user: {
820
+ id: keyRecord.user.id,
821
+ uid: keyRecord.user.uid,
822
+ email: keyRecord.user.email
823
+ },
824
+ sessionId: keyRecord.id,
825
+ authType: "apiKey",
826
+ apiKey: {
827
+ uid: keyRecord.uid,
828
+ ...scopes
829
+ }
830
+ });
831
+ return next();
832
+ }
551
833
  const payload = await verifyToken(token);
552
834
  if (!payload?.sub) {
553
835
  throw new ApiException(getError("unauthorized"), 401);
@@ -563,10 +845,39 @@ var requireAuthMiddleware = createMiddleware(async (c, next) => {
563
845
  uid: session.user.uid,
564
846
  email: session.user.email
565
847
  },
566
- sessionId: session.id
848
+ sessionId: session.id,
849
+ authType: "jwt"
850
+ });
851
+ return next();
852
+ });
853
+ var requirePermission = (permission) => {
854
+ return createMiddleware(async (c, next) => {
855
+ const session = c.get("session");
856
+ if (!session.apiKey) return next();
857
+ if (!session.apiKey.permissions.includes(permission)) {
858
+ throw new ApiException(getError("insufficient_permissions"), 403);
859
+ }
860
+ return next();
567
861
  });
862
+ };
863
+ var requireVaultAccess = createMiddleware(async (c, next) => {
864
+ const session = c.get("session");
865
+ if (!session.apiKey) return next();
866
+ if (session.apiKey.vaultUids === null) return next();
867
+ const vaultUid = c.req.param("vaultUid") ?? c.req.param("uid");
868
+ if (!vaultUid) return next();
869
+ if (!session.apiKey.vaultUids.includes(vaultUid)) {
870
+ throw new ApiException(getError("vault_not_found"), 404);
871
+ }
568
872
  return next();
569
873
  });
874
+ function assertCollectionAccess(session, collection) {
875
+ if (!session.apiKey) return;
876
+ if (session.apiKey.collections === null) return;
877
+ if (!session.apiKey.collections.includes(collection)) {
878
+ throw new ApiException(getError("insufficient_permissions"), 403);
879
+ }
880
+ }
570
881
 
571
882
  // src/api/schemas.ts
572
883
  import { z as z3 } from "zod";
@@ -633,6 +944,70 @@ var VaultResponse = z3.object({
633
944
  var VaultsListResponse = z3.object({
634
945
  vaults: z3.array(VaultResponse)
635
946
  });
947
+ var COLLECTION_NAME_RE = /^[A-Za-z0-9_-]+$/;
948
+ var HMAC_HEX_RE = /^[0-9a-f]{64}$/;
949
+ var CreateDocumentRequest = z3.object({
950
+ collection: z3.string().min(1).max(255).regex(COLLECTION_NAME_RE, "Collection name must be alphanumeric (hyphens and underscores allowed)"),
951
+ data: z3.string().max(MAX_DATA_SIZE, `Data must not exceed ${MAX_DATA_SIZE} bytes`).regex(BASE64_RE, "Data must be valid base64"),
952
+ hmac: z3.string().regex(HMAC_HEX_RE, "HMAC must be a valid SHA-256 hex string (64 characters)").optional()
953
+ });
954
+ var UpdateDocumentRequest = z3.object({
955
+ data: z3.string().max(MAX_DATA_SIZE, `Data must not exceed ${MAX_DATA_SIZE} bytes`).regex(BASE64_RE, "Data must be valid base64"),
956
+ hmac: z3.string().regex(HMAC_HEX_RE, "HMAC must be a valid SHA-256 hex string (64 characters)").optional(),
957
+ version: z3.number().optional()
958
+ });
959
+ var DocumentResponse = z3.object({
960
+ uid: z3.string(),
961
+ collection: z3.string(),
962
+ data: z3.string(),
963
+ hmac: z3.string().nullable(),
964
+ version: z3.number(),
965
+ createdAt: z3.number(),
966
+ updatedAt: z3.number(),
967
+ deletedAt: z3.number().nullable()
968
+ });
969
+ var DocumentListResponse = z3.object({
970
+ documents: z3.array(DocumentResponse)
971
+ });
972
+ var DocumentSyncResponse = z3.object({
973
+ documents: z3.array(DocumentResponse),
974
+ syncedAt: z3.number()
975
+ });
976
+ var CreateApiKeyRequest = z3.object({
977
+ name: z3.string().min(1).max(255),
978
+ permissions: z3.array(z3.enum(["read", "write", "delete"])).optional(),
979
+ vaultUids: z3.array(z3.string()).optional(),
980
+ collections: z3.array(z3.string()).optional(),
981
+ expiresAt: z3.number().optional()
982
+ });
983
+ var ApiKeyResponse = z3.object({
984
+ uid: z3.string(),
985
+ name: z3.string(),
986
+ keyPrefix: z3.string(),
987
+ permissions: z3.array(z3.string()),
988
+ vaultUids: z3.array(z3.string()).nullable(),
989
+ collections: z3.array(z3.string()).nullable(),
990
+ expiresAt: z3.number().nullable(),
991
+ lastUsedAt: z3.number().nullable(),
992
+ createdAt: z3.number(),
993
+ revokedAt: z3.number().nullable()
994
+ });
995
+ var ApiKeyCreatedResponse = ApiKeyResponse.extend({
996
+ key: z3.string()
997
+ });
998
+ var ApiKeysListResponse = z3.object({
999
+ apiKeys: z3.array(ApiKeyResponse)
1000
+ });
1001
+
1002
+ // src/features/auth/key-gen.ts
1003
+ import { randomBytes } from "crypto";
1004
+ function generateApiKey() {
1005
+ const randomPart = randomBytes(24).toString("hex");
1006
+ return `ulk_${randomPart}`;
1007
+ }
1008
+ function getKeyPrefix(key) {
1009
+ return key.substring(0, 8);
1010
+ }
636
1011
 
637
1012
  // src/api/auth/passkey.ts
638
1013
  import { Hono } from "hono";
@@ -1058,6 +1433,12 @@ var zkcRouter = new Hono2().post(
1058
1433
  );
1059
1434
 
1060
1435
  // src/api/auth/router.ts
1436
+ var MAX_API_KEYS_PER_USER = 50;
1437
+ function requireJwtSession(session) {
1438
+ if (session.apiKey) {
1439
+ throw new ApiException(errors.insufficient_permissions, 403);
1440
+ }
1441
+ }
1061
1442
  var authRouter = new Hono3().post(
1062
1443
  "/email/register",
1063
1444
  zValidator3("json", EmailRegisterRequest),
@@ -1154,6 +1535,82 @@ var authRouter = new Hono3().post(
1154
1535
  }
1155
1536
  return c.json({ success: true });
1156
1537
  }
1538
+ ).post(
1539
+ "/api-keys",
1540
+ requireAuthMiddleware,
1541
+ zValidator3("json", CreateApiKeyRequest),
1542
+ async (c) => {
1543
+ const session = c.get("session");
1544
+ requireJwtSession(session);
1545
+ const existing = listApiKeysByUserId(session.user.id);
1546
+ if (existing.length >= MAX_API_KEYS_PER_USER) {
1547
+ throw new ApiException(
1548
+ { code: "rate_limited", message: `Maximum ${MAX_API_KEYS_PER_USER} API keys per user` },
1549
+ 429
1550
+ );
1551
+ }
1552
+ const input = c.req.valid("json");
1553
+ const key = generateApiKey();
1554
+ const keyHash = hashToken(key);
1555
+ const apiKey = createApiKey({
1556
+ userId: session.user.id,
1557
+ name: input.name,
1558
+ keyHash,
1559
+ keyPrefix: getKeyPrefix(key),
1560
+ permissions: input.permissions,
1561
+ vaultUids: input.vaultUids,
1562
+ collections: input.collections,
1563
+ expiresAt: input.expiresAt
1564
+ });
1565
+ const scopes = parseApiKeyScopes(apiKey);
1566
+ return c.json({
1567
+ uid: apiKey.uid,
1568
+ name: apiKey.name,
1569
+ key,
1570
+ // Only returned on creation!
1571
+ keyPrefix: apiKey.keyPrefix,
1572
+ ...scopes,
1573
+ expiresAt: apiKey.expiresAt,
1574
+ lastUsedAt: apiKey.lastUsedAt,
1575
+ createdAt: apiKey.createdAt,
1576
+ revokedAt: apiKey.revokedAt
1577
+ });
1578
+ }
1579
+ ).get(
1580
+ "/api-keys",
1581
+ requireAuthMiddleware,
1582
+ (c) => {
1583
+ const session = c.get("session");
1584
+ requireJwtSession(session);
1585
+ const apiKeys = listApiKeysByUserId(session.user.id);
1586
+ return c.json({
1587
+ apiKeys: apiKeys.map((key) => {
1588
+ const scopes = parseApiKeyScopes(key);
1589
+ return {
1590
+ uid: key.uid,
1591
+ name: key.name,
1592
+ keyPrefix: key.keyPrefix,
1593
+ ...scopes,
1594
+ expiresAt: key.expiresAt,
1595
+ lastUsedAt: key.lastUsedAt,
1596
+ createdAt: key.createdAt,
1597
+ revokedAt: key.revokedAt
1598
+ };
1599
+ })
1600
+ });
1601
+ }
1602
+ ).delete(
1603
+ "/api-keys/:uid",
1604
+ requireAuthMiddleware,
1605
+ (c) => {
1606
+ const session = c.get("session");
1607
+ requireJwtSession(session);
1608
+ const uid = c.req.param("uid");
1609
+ if (!revokeApiKey(uid, session.user.id)) {
1610
+ throw new ApiException(errors.api_key_not_found, 404);
1611
+ }
1612
+ return c.json({ success: true });
1613
+ }
1157
1614
  ).route("/passkey", passkeyRouter).route("/zkc", zkcRouter);
1158
1615
 
1159
1616
  // src/api/vault/router.ts
@@ -1172,8 +1629,8 @@ function toVaultResponse(vault) {
1172
1629
  };
1173
1630
  }
1174
1631
  var VaultService = class {
1175
- constructor(vaultRepo2) {
1176
- this.vaultRepo = vaultRepo2;
1632
+ constructor(vaultRepo3) {
1633
+ this.vaultRepo = vaultRepo3;
1177
1634
  }
1178
1635
  /**
1179
1636
  * List all vaults for a user
@@ -1210,7 +1667,7 @@ var VaultService = class {
1210
1667
  createVault(userId, data) {
1211
1668
  const existing = this.vaultRepo.findByName(data.name, userId);
1212
1669
  if (existing) {
1213
- throw new ApiException(errors.vault_already_exists(data.name), 409);
1670
+ throw new ApiException(errorBuilders.vaultAlreadyExists(data.name), 409);
1214
1671
  }
1215
1672
  const vault = this.vaultRepo.create({
1216
1673
  userId,
@@ -1275,19 +1732,32 @@ var vaultRepo = new VaultRepository();
1275
1732
  var vaultService = new VaultService(vaultRepo);
1276
1733
  var vaultRouter = new Hono4().use("/*", requireAuthMiddleware).get(
1277
1734
  "/",
1735
+ requirePermission("read"),
1278
1736
  (c) => {
1279
1737
  const session = c.get("session");
1280
- return c.json(vaultService.listVaults(session.user.id));
1738
+ const result = vaultService.listVaults(session.user.id);
1739
+ if (session.apiKey?.vaultUids) {
1740
+ const allowed = new Set(session.apiKey.vaultUids);
1741
+ result.vaults = result.vaults.filter((v) => allowed.has(v.uid));
1742
+ }
1743
+ return c.json(result);
1281
1744
  }
1282
1745
  ).get(
1283
1746
  "/by-name/:name",
1747
+ requirePermission("read"),
1284
1748
  (c) => {
1285
1749
  const session = c.get("session");
1286
1750
  const { name } = c.req.param();
1287
- return c.json(vaultService.getVaultByName(name, session.user.id));
1751
+ const vault = vaultService.getVaultByName(name, session.user.id);
1752
+ if (session.apiKey?.vaultUids && !session.apiKey.vaultUids.includes(vault.uid)) {
1753
+ throw new ApiException(errors.vault_not_found, 404);
1754
+ }
1755
+ return c.json(vault);
1288
1756
  }
1289
1757
  ).get(
1290
1758
  "/:uid",
1759
+ requirePermission("read"),
1760
+ requireVaultAccess,
1291
1761
  (c) => {
1292
1762
  const session = c.get("session");
1293
1763
  const { uid } = c.req.param();
@@ -1295,6 +1765,7 @@ var vaultRouter = new Hono4().use("/*", requireAuthMiddleware).get(
1295
1765
  }
1296
1766
  ).post(
1297
1767
  "/",
1768
+ requirePermission("write"),
1298
1769
  zValidator4("json", CreateVaultRequest),
1299
1770
  (c) => {
1300
1771
  const session = c.get("session");
@@ -1306,6 +1777,8 @@ var vaultRouter = new Hono4().use("/*", requireAuthMiddleware).get(
1306
1777
  }
1307
1778
  ).put(
1308
1779
  "/:uid",
1780
+ requirePermission("write"),
1781
+ requireVaultAccess,
1309
1782
  zValidator4("json", UpdateVaultRequest),
1310
1783
  (c) => {
1311
1784
  const session = c.get("session");
@@ -1317,6 +1790,8 @@ var vaultRouter = new Hono4().use("/*", requireAuthMiddleware).get(
1317
1790
  }
1318
1791
  ).delete(
1319
1792
  "/:uid",
1793
+ requirePermission("delete"),
1794
+ requireVaultAccess,
1320
1795
  (c) => {
1321
1796
  const session = c.get("session");
1322
1797
  const { uid } = c.req.param();
@@ -1324,6 +1799,245 @@ var vaultRouter = new Hono4().use("/*", requireAuthMiddleware).get(
1324
1799
  }
1325
1800
  );
1326
1801
 
1802
+ // src/api/document/router.ts
1803
+ import { Hono as Hono5 } from "hono";
1804
+ import { zValidator as zValidator5 } from "@hono/zod-validator";
1805
+ import { z as z6 } from "zod";
1806
+
1807
+ // src/services/document-service.ts
1808
+ function toDocumentResponse(document) {
1809
+ return {
1810
+ uid: document.uid,
1811
+ collection: document.collection,
1812
+ data: document.data,
1813
+ hmac: document.hmac,
1814
+ version: document.version,
1815
+ createdAt: document.createdAt,
1816
+ updatedAt: document.updatedAt,
1817
+ deletedAt: document.deletedAt
1818
+ };
1819
+ }
1820
+ var DocumentService = class {
1821
+ constructor(documentRepo2, vaultRepo3) {
1822
+ this.documentRepo = documentRepo2;
1823
+ this.vaultRepo = vaultRepo3;
1824
+ }
1825
+ /**
1826
+ * Verify vault exists and belongs to user.
1827
+ * Only needed for create (to prevent orphan documents referencing non-existent vaults).
1828
+ * Read/update/delete queries already filter by userId — no vault exists = empty result.
1829
+ */
1830
+ verifyVaultOwnership(vaultUid, userId) {
1831
+ const vault = this.vaultRepo.findByUid(vaultUid, userId);
1832
+ if (!vault) {
1833
+ throw new ApiException(errors.vault_not_found, 404);
1834
+ }
1835
+ }
1836
+ /**
1837
+ * Create a new document
1838
+ */
1839
+ createDocument(userId, vaultUid, data) {
1840
+ this.verifyVaultOwnership(vaultUid, userId);
1841
+ const document = this.documentRepo.create({
1842
+ vaultUid,
1843
+ userId,
1844
+ collection: data.collection,
1845
+ data: data.data,
1846
+ hmac: data.hmac
1847
+ });
1848
+ return toDocumentResponse(document);
1849
+ }
1850
+ /**
1851
+ * Get document by UID
1852
+ */
1853
+ getDocument(uid, vaultUid, userId) {
1854
+ const document = this.documentRepo.findByUid(uid, vaultUid, userId);
1855
+ if (!document) {
1856
+ throw new ApiException(errors.document_not_found, 404);
1857
+ }
1858
+ return toDocumentResponse(document);
1859
+ }
1860
+ /**
1861
+ * List documents in a vault
1862
+ */
1863
+ listDocuments(vaultUid, userId, opts) {
1864
+ const documents = this.documentRepo.list(vaultUid, userId, opts);
1865
+ return {
1866
+ documents: documents.map(toDocumentResponse)
1867
+ };
1868
+ }
1869
+ /**
1870
+ * Update a document
1871
+ */
1872
+ updateDocument(uid, vaultUid, userId, data) {
1873
+ const { document, conflict } = this.documentRepo.update(uid, vaultUid, userId, data);
1874
+ if (!document) {
1875
+ if (conflict) {
1876
+ throw new ApiException(errors.document_conflict, 409);
1877
+ }
1878
+ throw new ApiException(errors.document_not_found, 404);
1879
+ }
1880
+ return toDocumentResponse(document);
1881
+ }
1882
+ /**
1883
+ * Soft delete a document
1884
+ */
1885
+ deleteDocument(uid, vaultUid, userId) {
1886
+ const document = this.documentRepo.softDelete(uid, vaultUid, userId);
1887
+ if (!document) {
1888
+ throw new ApiException(errors.document_not_found, 404);
1889
+ }
1890
+ return { success: true };
1891
+ }
1892
+ /**
1893
+ * Delta sync - get documents modified since timestamp
1894
+ */
1895
+ syncDocuments(vaultUid, userId, since) {
1896
+ const documents = this.documentRepo.getSince(vaultUid, userId, since);
1897
+ return {
1898
+ documents: documents.map(toDocumentResponse),
1899
+ syncedAt: Math.floor(Date.now() / 1e3)
1900
+ };
1901
+ }
1902
+ };
1903
+
1904
+ // src/repositories/document-repository.ts
1905
+ var DocumentRepository = class {
1906
+ create(document) {
1907
+ return createDocument(document);
1908
+ }
1909
+ findByUid(uid, vaultUid, userId) {
1910
+ return getDocumentByUid(uid, vaultUid, userId);
1911
+ }
1912
+ list(vaultUid, userId, opts) {
1913
+ return listDocuments(vaultUid, userId, opts);
1914
+ }
1915
+ update(uid, vaultUid, userId, data) {
1916
+ return updateDocument(uid, vaultUid, userId, data);
1917
+ }
1918
+ softDelete(uid, vaultUid, userId) {
1919
+ return softDeleteDocument(uid, vaultUid, userId);
1920
+ }
1921
+ getSince(vaultUid, userId, since) {
1922
+ return getDocumentsSince(vaultUid, userId, since);
1923
+ }
1924
+ };
1925
+
1926
+ // src/api/document/router.ts
1927
+ var documentRepo = new DocumentRepository();
1928
+ var vaultRepo2 = new VaultRepository();
1929
+ var documentService = new DocumentService(documentRepo, vaultRepo2);
1930
+ var ListQuerySchema = z6.object({
1931
+ collection: z6.string().optional(),
1932
+ since: z6.coerce.number().optional(),
1933
+ includeDeleted: z6.enum(["true", "false"]).optional().transform((val) => val === "true"),
1934
+ limit: z6.coerce.number().optional(),
1935
+ offset: z6.coerce.number().optional()
1936
+ });
1937
+ var SyncQuerySchema = z6.object({
1938
+ since: z6.coerce.number()
1939
+ });
1940
+ var documentRouter = new Hono5().use("/*", requireAuthMiddleware).get(
1941
+ "/vault/:vaultUid/documents",
1942
+ requirePermission("read"),
1943
+ requireVaultAccess,
1944
+ zValidator5("query", ListQuerySchema),
1945
+ (c) => {
1946
+ const session = c.get("session");
1947
+ const { vaultUid } = c.req.param();
1948
+ const query = c.req.valid("query");
1949
+ if (query.collection) {
1950
+ assertCollectionAccess(session, query.collection);
1951
+ }
1952
+ const result = documentService.listDocuments(vaultUid, session.user.id, {
1953
+ collection: query.collection,
1954
+ since: query.since,
1955
+ includeDeleted: query.includeDeleted,
1956
+ limit: query.limit,
1957
+ offset: query.offset
1958
+ });
1959
+ if (!query.collection && session.apiKey?.collections) {
1960
+ const allowed = new Set(session.apiKey.collections);
1961
+ result.documents = result.documents.filter((doc) => allowed.has(doc.collection));
1962
+ }
1963
+ return c.json(result);
1964
+ }
1965
+ ).get(
1966
+ "/vault/:vaultUid/documents/sync",
1967
+ requirePermission("read"),
1968
+ requireVaultAccess,
1969
+ zValidator5("query", SyncQuerySchema),
1970
+ (c) => {
1971
+ const session = c.get("session");
1972
+ const { vaultUid } = c.req.param();
1973
+ const { since } = c.req.valid("query");
1974
+ const result = documentService.syncDocuments(vaultUid, session.user.id, since);
1975
+ if (session.apiKey?.collections) {
1976
+ const allowed = new Set(session.apiKey.collections);
1977
+ result.documents = result.documents.filter((doc) => allowed.has(doc.collection));
1978
+ }
1979
+ return c.json(result);
1980
+ }
1981
+ ).get(
1982
+ "/vault/:vaultUid/documents/:uid",
1983
+ requirePermission("read"),
1984
+ requireVaultAccess,
1985
+ (c) => {
1986
+ const session = c.get("session");
1987
+ const { vaultUid, uid } = c.req.param();
1988
+ return c.json(
1989
+ documentService.getDocument(uid, vaultUid, session.user.id)
1990
+ );
1991
+ }
1992
+ ).post(
1993
+ "/vault/:vaultUid/documents",
1994
+ requirePermission("write"),
1995
+ requireVaultAccess,
1996
+ zValidator5("json", CreateDocumentRequest),
1997
+ (c) => {
1998
+ const session = c.get("session");
1999
+ const { vaultUid } = c.req.param();
2000
+ const { collection, data, hmac } = c.req.valid("json");
2001
+ assertCollectionAccess(session, collection);
2002
+ return c.json(
2003
+ documentService.createDocument(session.user.id, vaultUid, {
2004
+ collection,
2005
+ data,
2006
+ hmac
2007
+ }),
2008
+ 201
2009
+ );
2010
+ }
2011
+ ).put(
2012
+ "/vault/:vaultUid/documents/:uid",
2013
+ requirePermission("write"),
2014
+ requireVaultAccess,
2015
+ zValidator5("json", UpdateDocumentRequest),
2016
+ (c) => {
2017
+ const session = c.get("session");
2018
+ const { vaultUid, uid } = c.req.param();
2019
+ const { data, hmac, version } = c.req.valid("json");
2020
+ return c.json(
2021
+ documentService.updateDocument(uid, vaultUid, session.user.id, {
2022
+ data,
2023
+ hmac,
2024
+ version
2025
+ })
2026
+ );
2027
+ }
2028
+ ).delete(
2029
+ "/vault/:vaultUid/documents/:uid",
2030
+ requirePermission("delete"),
2031
+ requireVaultAccess,
2032
+ (c) => {
2033
+ const session = c.get("session");
2034
+ const { vaultUid, uid } = c.req.param();
2035
+ return c.json(
2036
+ documentService.deleteDocument(uid, vaultUid, session.user.id)
2037
+ );
2038
+ }
2039
+ );
2040
+
1327
2041
  // src/features/auth/rate-limit.ts
1328
2042
  import { createMiddleware as createMiddleware2 } from "hono/factory";
1329
2043
  var DEFAULT_CONFIG = { max: 100, windowMs: 6e4 };
@@ -1386,47 +2100,6 @@ function rateLimit(config = {}) {
1386
2100
  });
1387
2101
  }
1388
2102
 
1389
- // src/features/auth/csrf.ts
1390
- import { createMiddleware as createMiddleware3 } from "hono/factory";
1391
- import { getCookie, setCookie } from "hono/cookie";
1392
- var CSRF_COOKIE_NAME = "__csrf";
1393
- var CSRF_HEADER_NAME = "x-csrf-token";
1394
- var TOKEN_BYTES = 32;
1395
- var SAFE_METHODS = /* @__PURE__ */ new Set(["GET", "HEAD", "OPTIONS"]);
1396
- function csrfCookieOptions() {
1397
- return {
1398
- path: "/",
1399
- httpOnly: false,
1400
- // Client JS must read this value
1401
- sameSite: "Strict",
1402
- secure: env.NODE_ENV === "production"
1403
- };
1404
- }
1405
- function generateToken() {
1406
- const bytes = new Uint8Array(TOKEN_BYTES);
1407
- crypto.getRandomValues(bytes);
1408
- return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
1409
- }
1410
- var csrfProtection = createMiddleware3(async (c, next) => {
1411
- if (SAFE_METHODS.has(c.req.method)) {
1412
- const existing = getCookie(c, CSRF_COOKIE_NAME);
1413
- if (!existing) {
1414
- setCookie(c, CSRF_COOKIE_NAME, generateToken(), csrfCookieOptions());
1415
- }
1416
- return next();
1417
- }
1418
- const cookieToken = getCookie(c, CSRF_COOKIE_NAME);
1419
- const headerToken = c.req.header(CSRF_HEADER_NAME);
1420
- if (!cookieToken || !headerToken || cookieToken !== headerToken) {
1421
- throw new ApiException(
1422
- { code: "invalid_request", message: "Invalid or missing CSRF token" },
1423
- 403
1424
- );
1425
- }
1426
- setCookie(c, CSRF_COOKIE_NAME, generateToken(), csrfCookieOptions());
1427
- await next();
1428
- });
1429
-
1430
2103
  // src/app.ts
1431
2104
  var errorHandler = (error, c) => {
1432
2105
  const requestId = c.req.header("x-request-id") ?? crypto.randomUUID();
@@ -1457,7 +2130,7 @@ var errorHandler = (error, c) => {
1457
2130
  );
1458
2131
  };
1459
2132
  function createApp() {
1460
- const app = new Hono5();
2133
+ const app = new Hono6();
1461
2134
  app.use("*", bodyLimit({ maxSize: 11 * 1024 * 1024 }));
1462
2135
  app.use(
1463
2136
  "*",
@@ -1489,11 +2162,15 @@ function createApp() {
1489
2162
  })
1490
2163
  );
1491
2164
  app.use("*", rateLimit({ max: 100, windowMs: 6e4 }));
1492
- app.use("*", csrfProtection);
1493
2165
  app.get("/health", (c) => c.json({ status: "ok", timestamp: Date.now() }));
2166
+ try {
2167
+ deleteExpiredApiKeys();
2168
+ } catch {
2169
+ }
1494
2170
  app.use("/auth/*", rateLimit({ max: 10, windowMs: 6e4 }));
1495
2171
  app.route("/auth", authRouter);
1496
2172
  app.route("/vault", vaultRouter);
2173
+ app.route("/", documentRouter);
1497
2174
  app.onError(errorHandler);
1498
2175
  app.notFound((c) => {
1499
2176
  const requestId = c.req.header("x-request-id") ?? crypto.randomUUID();
@@ -1506,8 +2183,16 @@ function createApp() {
1506
2183
  }
1507
2184
  export {
1508
2185
  ApiException,
2186
+ ApiKeyCreatedResponse,
2187
+ ApiKeyResponse,
2188
+ ApiKeysListResponse,
1509
2189
  AuthResponse,
2190
+ CreateApiKeyRequest,
2191
+ CreateDocumentRequest,
1510
2192
  CreateVaultRequest,
2193
+ DocumentListResponse,
2194
+ DocumentResponse,
2195
+ DocumentSyncResponse,
1511
2196
  EmailLoginRequest,
1512
2197
  EmailRegisterRequest,
1513
2198
  MeResponse,
@@ -1516,6 +2201,7 @@ export {
1516
2201
  PasskeyRegisterOptionsRequest,
1517
2202
  PasskeyRegisterVerifyRequest,
1518
2203
  RefreshResponse,
2204
+ UpdateDocumentRequest,
1519
2205
  UpdateVaultRequest,
1520
2206
  UserResponse,
1521
2207
  VaultResponse,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ursalock/server",
3
- "version": "0.3.1",
3
+ "version": "0.4.2",
4
4
  "description": "Self-hostable ursalock server with SQLite and ZKCredentials support",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",