@ursalock/server 0.3.1 → 0.4.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.
Files changed (2) hide show
  1. package/dist/index.js +733 -50
  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,177 @@ 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 stmt2 = db.prepare(`
507
+ UPDATE documents SET data = ?, hmac = ?, version = ? + 1, updated_at = unixepoch()
508
+ WHERE uid = ? AND vault_uid = ? AND user_id = ? AND version = ?
509
+ RETURNING ${DOCUMENT_COLUMNS}
510
+ `);
511
+ return stmt2.get(input.data, input.hmac ?? null, input.version, uid, vaultUid, userId, input.version);
512
+ }
513
+ const stmt = db.prepare(`
514
+ UPDATE documents SET data = ?, hmac = ?, version = version + 1, updated_at = unixepoch()
515
+ WHERE uid = ? AND vault_uid = ? AND user_id = ?
516
+ RETURNING ${DOCUMENT_COLUMNS}
517
+ `);
518
+ return stmt.get(input.data, input.hmac ?? null, uid, vaultUid, userId);
519
+ }
520
+ function softDeleteDocument(uid, vaultUid, userId) {
521
+ const db = getDb();
522
+ const stmt = db.prepare(`
523
+ UPDATE documents SET deleted_at = unixepoch(), updated_at = unixepoch()
524
+ WHERE uid = ? AND vault_uid = ? AND user_id = ? AND deleted_at IS NULL
525
+ RETURNING ${DOCUMENT_COLUMNS}
526
+ `);
527
+ return stmt.get(uid, vaultUid, userId);
528
+ }
529
+ function getDocumentsSince(vaultUid, userId, since) {
530
+ const db = getDb();
531
+ const stmt = db.prepare(`
532
+ SELECT ${DOCUMENT_COLUMNS}
533
+ FROM documents WHERE vault_uid = ? AND user_id = ? AND updated_at >= ?
534
+ ORDER BY updated_at DESC
535
+ `);
536
+ return stmt.all(vaultUid, userId, since);
537
+ }
538
+ var API_KEY_COLUMNS = `id, uid, user_id as userId, name, key_hash as keyHash, key_prefix as keyPrefix,
539
+ permissions, vault_uids as vaultUids, collections, expires_at as expiresAt,
540
+ last_used_at as lastUsedAt, created_at as createdAt, revoked_at as revokedAt`;
541
+ function createApiKey(input) {
542
+ const db = getDb();
543
+ const stmt = db.prepare(`
544
+ INSERT INTO api_keys (user_id, name, key_hash, key_prefix, permissions, vault_uids, collections, expires_at)
545
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
546
+ RETURNING ${API_KEY_COLUMNS}
547
+ `);
548
+ const permissions = input.permissions ? JSON.stringify(input.permissions) : '["read","write"]';
549
+ const vaultUids = input.vaultUids ? JSON.stringify(input.vaultUids) : null;
550
+ const collections = input.collections ? JSON.stringify(input.collections) : null;
551
+ return stmt.get(
552
+ input.userId,
553
+ input.name,
554
+ input.keyHash,
555
+ input.keyPrefix,
556
+ permissions,
557
+ vaultUids,
558
+ collections,
559
+ input.expiresAt ?? null
560
+ );
561
+ }
562
+ function getApiKeyByHash(keyHash) {
563
+ const db = getDb();
564
+ const stmt = db.prepare(`
565
+ SELECT
566
+ k.id, k.uid, k.user_id as userId, k.name, k.key_hash as keyHash, k.key_prefix as keyPrefix,
567
+ k.permissions, k.vault_uids as vaultUids, k.collections, k.expires_at as expiresAt,
568
+ k.last_used_at as lastUsedAt, k.created_at as createdAt, k.revoked_at as revokedAt,
569
+ ${USER_JOIN_COLUMNS}
570
+ FROM api_keys k
571
+ JOIN users u ON k.user_id = u.id
572
+ WHERE k.key_hash = ?
573
+ `);
574
+ const row = stmt.get(keyHash);
575
+ if (!row) return void 0;
576
+ return {
577
+ id: row["id"],
578
+ uid: row["uid"],
579
+ userId: row["userId"],
580
+ name: row["name"],
581
+ keyHash: row["keyHash"],
582
+ keyPrefix: row["keyPrefix"],
583
+ permissions: row["permissions"],
584
+ vaultUids: row["vaultUids"] ?? null,
585
+ collections: row["collections"] ?? null,
586
+ expiresAt: row["expiresAt"] ?? null,
587
+ lastUsedAt: row["lastUsedAt"] ?? null,
588
+ createdAt: row["createdAt"],
589
+ revokedAt: row["revokedAt"] ?? null,
590
+ user: userFromRow(row)
591
+ };
592
+ }
593
+ function listApiKeysByUserId(userId) {
594
+ const db = getDb();
595
+ const stmt = db.prepare(`
596
+ SELECT id, uid, user_id as userId, name, key_prefix as keyPrefix,
597
+ permissions, vault_uids as vaultUids, collections, expires_at as expiresAt,
598
+ last_used_at as lastUsedAt, created_at as createdAt, revoked_at as revokedAt
599
+ FROM api_keys WHERE user_id = ?
600
+ ORDER BY created_at DESC
601
+ `);
602
+ return stmt.all(userId);
603
+ }
604
+ function revokeApiKey(uid, userId) {
605
+ const db = getDb();
606
+ const stmt = db.prepare(`
607
+ UPDATE api_keys SET revoked_at = unixepoch()
608
+ WHERE uid = ? AND user_id = ? AND revoked_at IS NULL
609
+ `);
610
+ return stmt.run(uid, userId).changes > 0;
611
+ }
612
+ function updateApiKeyLastUsed(uid) {
613
+ const db = getDb();
614
+ const stmt = db.prepare(`UPDATE api_keys SET last_used_at = unixepoch() WHERE uid = ?`);
615
+ stmt.run(uid);
616
+ }
617
+ function deleteExpiredApiKeys() {
618
+ const db = getDb();
619
+ const stmt = db.prepare(`DELETE FROM api_keys WHERE expires_at IS NOT NULL AND expires_at <= unixepoch()`);
620
+ return stmt.run().changes;
621
+ }
403
622
 
404
623
  // src/features/auth/jwt.ts
405
624
  import { createHash } from "crypto";
@@ -460,11 +679,18 @@ var ErrorCode = z2.enum([
460
679
  "passkey_not_found",
461
680
  "session_expired",
462
681
  "invalid_origin",
682
+ "api_key_not_found",
683
+ "api_key_revoked",
684
+ "insufficient_permissions",
463
685
  // Vault errors
464
686
  "vault_not_found",
465
687
  "vault_already_exists",
466
688
  "vault_conflict",
467
689
  "invalid_vault_data",
690
+ // Document errors
691
+ "document_not_found",
692
+ "document_conflict",
693
+ "document_already_exists",
468
694
  // Validation errors
469
695
  "validation_error",
470
696
  "invalid_request",
@@ -488,6 +714,9 @@ var errors = {
488
714
  passkey_not_found: { code: "passkey_not_found", message: "Passkey not found" },
489
715
  session_expired: { code: "session_expired", message: "Session expired" },
490
716
  invalid_origin: { code: "invalid_origin", message: "Origin not allowed" },
717
+ api_key_not_found: { code: "api_key_not_found", message: "API key not found" },
718
+ api_key_revoked: { code: "api_key_revoked", message: "API key has been revoked" },
719
+ insufficient_permissions: { code: "insufficient_permissions", message: "Insufficient permissions" },
491
720
  // Vault errors
492
721
  vault_not_found: { code: "vault_not_found", message: "Vault not found" },
493
722
  vault_already_exists: (name) => ({
@@ -496,6 +725,10 @@ var errors = {
496
725
  }),
497
726
  vault_conflict: { code: "vault_conflict", message: "Version conflict - vault has been modified. Please refresh and retry." },
498
727
  invalid_vault_data: { code: "invalid_vault_data", message: "Invalid vault data" },
728
+ // Document errors
729
+ document_not_found: { code: "document_not_found", message: "Document not found" },
730
+ document_conflict: { code: "document_conflict", message: "Version conflict - document has been modified. Please refresh and retry." },
731
+ document_already_exists: { code: "document_already_exists", message: "Document already exists" },
499
732
  // Validation errors
500
733
  validation_error: (details) => ({
501
734
  code: "validation_error",
@@ -538,7 +771,8 @@ var optionalAuthMiddleware = createMiddleware(async (c, next) => {
538
771
  uid: session.user.uid,
539
772
  email: session.user.email
540
773
  },
541
- sessionId: session.id
774
+ sessionId: session.id,
775
+ authType: "jwt"
542
776
  });
543
777
  return next();
544
778
  });
@@ -548,6 +782,44 @@ var requireAuthMiddleware = createMiddleware(async (c, next) => {
548
782
  throw new ApiException(getError("unauthorized"), 401);
549
783
  }
550
784
  const token = authHeader.slice(7);
785
+ if (token.startsWith("ulk_")) {
786
+ if (token.length !== 52) {
787
+ throw new ApiException(getError("unauthorized"), 401);
788
+ }
789
+ const keyHash = hashToken(token);
790
+ const keyRecord = getApiKeyByHash(keyHash);
791
+ if (!keyRecord) {
792
+ throw new ApiException(getError("unauthorized"), 401);
793
+ }
794
+ if (keyRecord.revokedAt !== null) {
795
+ throw new ApiException(getError("api_key_revoked"), 401);
796
+ }
797
+ const now = Math.floor(Date.now() / 1e3);
798
+ if (keyRecord.expiresAt !== null && keyRecord.expiresAt <= now) {
799
+ throw new ApiException(getError("unauthorized"), 401);
800
+ }
801
+ setImmediate(() => {
802
+ try {
803
+ updateApiKeyLastUsed(keyRecord.uid);
804
+ } catch {
805
+ }
806
+ });
807
+ const scopes = parseApiKeyScopes(keyRecord);
808
+ c.set("session", {
809
+ user: {
810
+ id: keyRecord.user.id,
811
+ uid: keyRecord.user.uid,
812
+ email: keyRecord.user.email
813
+ },
814
+ sessionId: keyRecord.id,
815
+ authType: "apiKey",
816
+ apiKey: {
817
+ uid: keyRecord.uid,
818
+ ...scopes
819
+ }
820
+ });
821
+ return next();
822
+ }
551
823
  const payload = await verifyToken(token);
552
824
  if (!payload?.sub) {
553
825
  throw new ApiException(getError("unauthorized"), 401);
@@ -563,10 +835,39 @@ var requireAuthMiddleware = createMiddleware(async (c, next) => {
563
835
  uid: session.user.uid,
564
836
  email: session.user.email
565
837
  },
566
- sessionId: session.id
838
+ sessionId: session.id,
839
+ authType: "jwt"
567
840
  });
568
841
  return next();
569
842
  });
843
+ var requirePermission = (permission) => {
844
+ return createMiddleware(async (c, next) => {
845
+ const session = c.get("session");
846
+ if (!session.apiKey) return next();
847
+ if (!session.apiKey.permissions.includes(permission)) {
848
+ throw new ApiException(getError("insufficient_permissions"), 403);
849
+ }
850
+ return next();
851
+ });
852
+ };
853
+ var requireVaultAccess = createMiddleware(async (c, next) => {
854
+ const session = c.get("session");
855
+ if (!session.apiKey) return next();
856
+ if (session.apiKey.vaultUids === null) return next();
857
+ const vaultUid = c.req.param("vaultUid") ?? c.req.param("uid");
858
+ if (!vaultUid) return next();
859
+ if (!session.apiKey.vaultUids.includes(vaultUid)) {
860
+ throw new ApiException(getError("vault_not_found"), 404);
861
+ }
862
+ return next();
863
+ });
864
+ function assertCollectionAccess(session, collection) {
865
+ if (!session.apiKey) return;
866
+ if (session.apiKey.collections === null) return;
867
+ if (!session.apiKey.collections.includes(collection)) {
868
+ throw new ApiException(getError("insufficient_permissions"), 403);
869
+ }
870
+ }
570
871
 
571
872
  // src/api/schemas.ts
572
873
  import { z as z3 } from "zod";
@@ -633,6 +934,70 @@ var VaultResponse = z3.object({
633
934
  var VaultsListResponse = z3.object({
634
935
  vaults: z3.array(VaultResponse)
635
936
  });
937
+ var COLLECTION_NAME_RE = /^[A-Za-z0-9_-]+$/;
938
+ var HMAC_HEX_RE = /^[0-9a-f]{64}$/;
939
+ var CreateDocumentRequest = z3.object({
940
+ collection: z3.string().min(1).max(255).regex(COLLECTION_NAME_RE, "Collection name must be alphanumeric (hyphens and underscores allowed)"),
941
+ data: z3.string().max(MAX_DATA_SIZE, `Data must not exceed ${MAX_DATA_SIZE} bytes`).regex(BASE64_RE, "Data must be valid base64"),
942
+ hmac: z3.string().regex(HMAC_HEX_RE, "HMAC must be a valid SHA-256 hex string (64 characters)").optional()
943
+ });
944
+ var UpdateDocumentRequest = z3.object({
945
+ data: z3.string().max(MAX_DATA_SIZE, `Data must not exceed ${MAX_DATA_SIZE} bytes`).regex(BASE64_RE, "Data must be valid base64"),
946
+ hmac: z3.string().regex(HMAC_HEX_RE, "HMAC must be a valid SHA-256 hex string (64 characters)").optional(),
947
+ version: z3.number().optional()
948
+ });
949
+ var DocumentResponse = z3.object({
950
+ uid: z3.string(),
951
+ collection: z3.string(),
952
+ data: z3.string(),
953
+ hmac: z3.string().nullable(),
954
+ version: z3.number(),
955
+ createdAt: z3.number(),
956
+ updatedAt: z3.number(),
957
+ deletedAt: z3.number().nullable()
958
+ });
959
+ var DocumentListResponse = z3.object({
960
+ documents: z3.array(DocumentResponse)
961
+ });
962
+ var DocumentSyncResponse = z3.object({
963
+ documents: z3.array(DocumentResponse),
964
+ syncedAt: z3.number()
965
+ });
966
+ var CreateApiKeyRequest = z3.object({
967
+ name: z3.string().min(1).max(255),
968
+ permissions: z3.array(z3.enum(["read", "write", "delete"])).optional(),
969
+ vaultUids: z3.array(z3.string()).optional(),
970
+ collections: z3.array(z3.string()).optional(),
971
+ expiresAt: z3.number().optional()
972
+ });
973
+ var ApiKeyResponse = z3.object({
974
+ uid: z3.string(),
975
+ name: z3.string(),
976
+ keyPrefix: z3.string(),
977
+ permissions: z3.array(z3.string()),
978
+ vaultUids: z3.array(z3.string()).nullable(),
979
+ collections: z3.array(z3.string()).nullable(),
980
+ expiresAt: z3.number().nullable(),
981
+ lastUsedAt: z3.number().nullable(),
982
+ createdAt: z3.number(),
983
+ revokedAt: z3.number().nullable()
984
+ });
985
+ var ApiKeyCreatedResponse = ApiKeyResponse.extend({
986
+ key: z3.string()
987
+ });
988
+ var ApiKeysListResponse = z3.object({
989
+ apiKeys: z3.array(ApiKeyResponse)
990
+ });
991
+
992
+ // src/features/auth/key-gen.ts
993
+ import { randomBytes } from "crypto";
994
+ function generateApiKey() {
995
+ const randomPart = randomBytes(24).toString("hex");
996
+ return `ulk_${randomPart}`;
997
+ }
998
+ function getKeyPrefix(key) {
999
+ return key.substring(0, 8);
1000
+ }
636
1001
 
637
1002
  // src/api/auth/passkey.ts
638
1003
  import { Hono } from "hono";
@@ -1058,6 +1423,12 @@ var zkcRouter = new Hono2().post(
1058
1423
  );
1059
1424
 
1060
1425
  // src/api/auth/router.ts
1426
+ var MAX_API_KEYS_PER_USER = 50;
1427
+ function requireJwtSession(session) {
1428
+ if (session.apiKey) {
1429
+ throw new ApiException(errors.insufficient_permissions, 403);
1430
+ }
1431
+ }
1061
1432
  var authRouter = new Hono3().post(
1062
1433
  "/email/register",
1063
1434
  zValidator3("json", EmailRegisterRequest),
@@ -1154,6 +1525,82 @@ var authRouter = new Hono3().post(
1154
1525
  }
1155
1526
  return c.json({ success: true });
1156
1527
  }
1528
+ ).post(
1529
+ "/api-keys",
1530
+ requireAuthMiddleware,
1531
+ zValidator3("json", CreateApiKeyRequest),
1532
+ async (c) => {
1533
+ const session = c.get("session");
1534
+ requireJwtSession(session);
1535
+ const existing = listApiKeysByUserId(session.user.id);
1536
+ if (existing.length >= MAX_API_KEYS_PER_USER) {
1537
+ throw new ApiException(
1538
+ { code: "rate_limited", message: `Maximum ${MAX_API_KEYS_PER_USER} API keys per user` },
1539
+ 429
1540
+ );
1541
+ }
1542
+ const input = c.req.valid("json");
1543
+ const key = generateApiKey();
1544
+ const keyHash = hashToken(key);
1545
+ const apiKey = createApiKey({
1546
+ userId: session.user.id,
1547
+ name: input.name,
1548
+ keyHash,
1549
+ keyPrefix: getKeyPrefix(key),
1550
+ permissions: input.permissions,
1551
+ vaultUids: input.vaultUids,
1552
+ collections: input.collections,
1553
+ expiresAt: input.expiresAt
1554
+ });
1555
+ const scopes = parseApiKeyScopes(apiKey);
1556
+ return c.json({
1557
+ uid: apiKey.uid,
1558
+ name: apiKey.name,
1559
+ key,
1560
+ // Only returned on creation!
1561
+ keyPrefix: apiKey.keyPrefix,
1562
+ ...scopes,
1563
+ expiresAt: apiKey.expiresAt,
1564
+ lastUsedAt: apiKey.lastUsedAt,
1565
+ createdAt: apiKey.createdAt,
1566
+ revokedAt: apiKey.revokedAt
1567
+ });
1568
+ }
1569
+ ).get(
1570
+ "/api-keys",
1571
+ requireAuthMiddleware,
1572
+ (c) => {
1573
+ const session = c.get("session");
1574
+ requireJwtSession(session);
1575
+ const apiKeys = listApiKeysByUserId(session.user.id);
1576
+ return c.json({
1577
+ apiKeys: apiKeys.map((key) => {
1578
+ const scopes = parseApiKeyScopes(key);
1579
+ return {
1580
+ uid: key.uid,
1581
+ name: key.name,
1582
+ keyPrefix: key.keyPrefix,
1583
+ ...scopes,
1584
+ expiresAt: key.expiresAt,
1585
+ lastUsedAt: key.lastUsedAt,
1586
+ createdAt: key.createdAt,
1587
+ revokedAt: key.revokedAt
1588
+ };
1589
+ })
1590
+ });
1591
+ }
1592
+ ).delete(
1593
+ "/api-keys/:uid",
1594
+ requireAuthMiddleware,
1595
+ (c) => {
1596
+ const session = c.get("session");
1597
+ requireJwtSession(session);
1598
+ const uid = c.req.param("uid");
1599
+ if (!revokeApiKey(uid, session.user.id)) {
1600
+ throw new ApiException(errors.api_key_not_found, 404);
1601
+ }
1602
+ return c.json({ success: true });
1603
+ }
1157
1604
  ).route("/passkey", passkeyRouter).route("/zkc", zkcRouter);
1158
1605
 
1159
1606
  // src/api/vault/router.ts
@@ -1172,8 +1619,8 @@ function toVaultResponse(vault) {
1172
1619
  };
1173
1620
  }
1174
1621
  var VaultService = class {
1175
- constructor(vaultRepo2) {
1176
- this.vaultRepo = vaultRepo2;
1622
+ constructor(vaultRepo3) {
1623
+ this.vaultRepo = vaultRepo3;
1177
1624
  }
1178
1625
  /**
1179
1626
  * List all vaults for a user
@@ -1275,19 +1722,32 @@ var vaultRepo = new VaultRepository();
1275
1722
  var vaultService = new VaultService(vaultRepo);
1276
1723
  var vaultRouter = new Hono4().use("/*", requireAuthMiddleware).get(
1277
1724
  "/",
1725
+ requirePermission("read"),
1278
1726
  (c) => {
1279
1727
  const session = c.get("session");
1280
- return c.json(vaultService.listVaults(session.user.id));
1728
+ const result = vaultService.listVaults(session.user.id);
1729
+ if (session.apiKey?.vaultUids) {
1730
+ const allowed = new Set(session.apiKey.vaultUids);
1731
+ result.vaults = result.vaults.filter((v) => allowed.has(v.uid));
1732
+ }
1733
+ return c.json(result);
1281
1734
  }
1282
1735
  ).get(
1283
1736
  "/by-name/:name",
1737
+ requirePermission("read"),
1284
1738
  (c) => {
1285
1739
  const session = c.get("session");
1286
1740
  const { name } = c.req.param();
1287
- return c.json(vaultService.getVaultByName(name, session.user.id));
1741
+ const vault = vaultService.getVaultByName(name, session.user.id);
1742
+ if (session.apiKey?.vaultUids && !session.apiKey.vaultUids.includes(vault.uid)) {
1743
+ throw new ApiException(errors.vault_not_found, 404);
1744
+ }
1745
+ return c.json(vault);
1288
1746
  }
1289
1747
  ).get(
1290
1748
  "/:uid",
1749
+ requirePermission("read"),
1750
+ requireVaultAccess,
1291
1751
  (c) => {
1292
1752
  const session = c.get("session");
1293
1753
  const { uid } = c.req.param();
@@ -1295,6 +1755,7 @@ var vaultRouter = new Hono4().use("/*", requireAuthMiddleware).get(
1295
1755
  }
1296
1756
  ).post(
1297
1757
  "/",
1758
+ requirePermission("write"),
1298
1759
  zValidator4("json", CreateVaultRequest),
1299
1760
  (c) => {
1300
1761
  const session = c.get("session");
@@ -1306,6 +1767,8 @@ var vaultRouter = new Hono4().use("/*", requireAuthMiddleware).get(
1306
1767
  }
1307
1768
  ).put(
1308
1769
  "/:uid",
1770
+ requirePermission("write"),
1771
+ requireVaultAccess,
1309
1772
  zValidator4("json", UpdateVaultRequest),
1310
1773
  (c) => {
1311
1774
  const session = c.get("session");
@@ -1317,6 +1780,8 @@ var vaultRouter = new Hono4().use("/*", requireAuthMiddleware).get(
1317
1780
  }
1318
1781
  ).delete(
1319
1782
  "/:uid",
1783
+ requirePermission("delete"),
1784
+ requireVaultAccess,
1320
1785
  (c) => {
1321
1786
  const session = c.get("session");
1322
1787
  const { uid } = c.req.param();
@@ -1324,6 +1789,252 @@ var vaultRouter = new Hono4().use("/*", requireAuthMiddleware).get(
1324
1789
  }
1325
1790
  );
1326
1791
 
1792
+ // src/api/document/router.ts
1793
+ import { Hono as Hono5 } from "hono";
1794
+ import { zValidator as zValidator5 } from "@hono/zod-validator";
1795
+ import { z as z6 } from "zod";
1796
+
1797
+ // src/services/document-service.ts
1798
+ function toDocumentResponse(document) {
1799
+ return {
1800
+ uid: document.uid,
1801
+ collection: document.collection,
1802
+ data: document.data,
1803
+ hmac: document.hmac,
1804
+ version: document.version,
1805
+ createdAt: document.createdAt,
1806
+ updatedAt: document.updatedAt,
1807
+ deletedAt: document.deletedAt
1808
+ };
1809
+ }
1810
+ var DocumentService = class {
1811
+ constructor(documentRepo2, vaultRepo3) {
1812
+ this.documentRepo = documentRepo2;
1813
+ this.vaultRepo = vaultRepo3;
1814
+ }
1815
+ /**
1816
+ * Verify vault ownership before any operation
1817
+ * Throws 404 if vault doesn't exist or doesn't belong to user
1818
+ */
1819
+ verifyVaultOwnership(vaultUid, userId) {
1820
+ const vault = this.vaultRepo.findByUid(vaultUid, userId);
1821
+ if (!vault) {
1822
+ throw new ApiException(errors.vault_not_found, 404);
1823
+ }
1824
+ }
1825
+ /**
1826
+ * Create a new document
1827
+ */
1828
+ createDocument(userId, vaultUid, data) {
1829
+ this.verifyVaultOwnership(vaultUid, userId);
1830
+ const document = this.documentRepo.create({
1831
+ vaultUid,
1832
+ userId,
1833
+ collection: data.collection,
1834
+ data: data.data,
1835
+ hmac: data.hmac
1836
+ });
1837
+ return toDocumentResponse(document);
1838
+ }
1839
+ /**
1840
+ * Get document by UID
1841
+ */
1842
+ getDocument(uid, vaultUid, userId) {
1843
+ this.verifyVaultOwnership(vaultUid, userId);
1844
+ const document = this.documentRepo.findByUid(uid, vaultUid, userId);
1845
+ if (!document) {
1846
+ throw new ApiException(errors.document_not_found, 404);
1847
+ }
1848
+ return toDocumentResponse(document);
1849
+ }
1850
+ /**
1851
+ * List documents in a vault
1852
+ */
1853
+ listDocuments(vaultUid, userId, opts) {
1854
+ this.verifyVaultOwnership(vaultUid, userId);
1855
+ const documents = this.documentRepo.list(vaultUid, userId, opts);
1856
+ return {
1857
+ documents: documents.map(toDocumentResponse)
1858
+ };
1859
+ }
1860
+ /**
1861
+ * Update a document
1862
+ */
1863
+ updateDocument(uid, vaultUid, userId, data) {
1864
+ this.verifyVaultOwnership(vaultUid, userId);
1865
+ const document = this.documentRepo.update(uid, vaultUid, userId, data);
1866
+ if (!document) {
1867
+ if (data.version != null) {
1868
+ const existing = this.documentRepo.findByUid(uid, vaultUid, userId);
1869
+ if (existing) {
1870
+ throw new ApiException(errors.document_conflict, 409);
1871
+ }
1872
+ }
1873
+ throw new ApiException(errors.document_not_found, 404);
1874
+ }
1875
+ return toDocumentResponse(document);
1876
+ }
1877
+ /**
1878
+ * Soft delete a document
1879
+ */
1880
+ deleteDocument(uid, vaultUid, userId) {
1881
+ this.verifyVaultOwnership(vaultUid, userId);
1882
+ const document = this.documentRepo.softDelete(uid, vaultUid, userId);
1883
+ if (!document) {
1884
+ throw new ApiException(errors.document_not_found, 404);
1885
+ }
1886
+ return { success: true };
1887
+ }
1888
+ /**
1889
+ * Delta sync - get documents modified since timestamp
1890
+ */
1891
+ syncDocuments(vaultUid, userId, since) {
1892
+ this.verifyVaultOwnership(vaultUid, userId);
1893
+ const documents = this.documentRepo.getSince(vaultUid, userId, since);
1894
+ return {
1895
+ documents: documents.map(toDocumentResponse),
1896
+ syncedAt: Math.floor(Date.now() / 1e3)
1897
+ };
1898
+ }
1899
+ };
1900
+
1901
+ // src/repositories/document-repository.ts
1902
+ var DocumentRepository = class {
1903
+ create(document) {
1904
+ return createDocument(document);
1905
+ }
1906
+ findByUid(uid, vaultUid, userId) {
1907
+ return getDocumentByUid(uid, vaultUid, userId);
1908
+ }
1909
+ list(vaultUid, userId, opts) {
1910
+ return listDocuments(vaultUid, userId, opts);
1911
+ }
1912
+ update(uid, vaultUid, userId, data) {
1913
+ return updateDocument(uid, vaultUid, userId, data);
1914
+ }
1915
+ softDelete(uid, vaultUid, userId) {
1916
+ return softDeleteDocument(uid, vaultUid, userId);
1917
+ }
1918
+ getSince(vaultUid, userId, since) {
1919
+ return getDocumentsSince(vaultUid, userId, since);
1920
+ }
1921
+ };
1922
+
1923
+ // src/api/document/router.ts
1924
+ var documentRepo = new DocumentRepository();
1925
+ var vaultRepo2 = new VaultRepository();
1926
+ var documentService = new DocumentService(documentRepo, vaultRepo2);
1927
+ var ListQuerySchema = z6.object({
1928
+ collection: z6.string().optional(),
1929
+ since: z6.coerce.number().optional(),
1930
+ includeDeleted: z6.enum(["true", "false"]).optional().transform((val) => val === "true"),
1931
+ limit: z6.coerce.number().optional(),
1932
+ offset: z6.coerce.number().optional()
1933
+ });
1934
+ var SyncQuerySchema = z6.object({
1935
+ since: z6.coerce.number()
1936
+ });
1937
+ var documentRouter = new Hono5().use("/*", requireAuthMiddleware).get(
1938
+ "/vault/:vaultUid/documents",
1939
+ requirePermission("read"),
1940
+ requireVaultAccess,
1941
+ zValidator5("query", ListQuerySchema),
1942
+ (c) => {
1943
+ const session = c.get("session");
1944
+ const { vaultUid } = c.req.param();
1945
+ const query = c.req.valid("query");
1946
+ if (query.collection) {
1947
+ assertCollectionAccess(session, query.collection);
1948
+ }
1949
+ const result = documentService.listDocuments(vaultUid, session.user.id, {
1950
+ collection: query.collection,
1951
+ since: query.since,
1952
+ includeDeleted: query.includeDeleted,
1953
+ limit: query.limit,
1954
+ offset: query.offset
1955
+ });
1956
+ if (!query.collection && session.apiKey?.collections) {
1957
+ const allowed = new Set(session.apiKey.collections);
1958
+ result.documents = result.documents.filter((doc) => allowed.has(doc.collection));
1959
+ }
1960
+ return c.json(result);
1961
+ }
1962
+ ).get(
1963
+ "/vault/:vaultUid/documents/sync",
1964
+ requirePermission("read"),
1965
+ requireVaultAccess,
1966
+ zValidator5("query", SyncQuerySchema),
1967
+ (c) => {
1968
+ const session = c.get("session");
1969
+ const { vaultUid } = c.req.param();
1970
+ const { since } = c.req.valid("query");
1971
+ const result = documentService.syncDocuments(vaultUid, session.user.id, since);
1972
+ if (session.apiKey?.collections) {
1973
+ const allowed = new Set(session.apiKey.collections);
1974
+ result.documents = result.documents.filter((doc) => allowed.has(doc.collection));
1975
+ }
1976
+ return c.json(result);
1977
+ }
1978
+ ).get(
1979
+ "/vault/:vaultUid/documents/:uid",
1980
+ requirePermission("read"),
1981
+ requireVaultAccess,
1982
+ (c) => {
1983
+ const session = c.get("session");
1984
+ const { vaultUid, uid } = c.req.param();
1985
+ return c.json(
1986
+ documentService.getDocument(uid, vaultUid, session.user.id)
1987
+ );
1988
+ }
1989
+ ).post(
1990
+ "/vault/:vaultUid/documents",
1991
+ requirePermission("write"),
1992
+ requireVaultAccess,
1993
+ zValidator5("json", CreateDocumentRequest),
1994
+ (c) => {
1995
+ const session = c.get("session");
1996
+ const { vaultUid } = c.req.param();
1997
+ const { collection, data, hmac } = c.req.valid("json");
1998
+ assertCollectionAccess(session, collection);
1999
+ return c.json(
2000
+ documentService.createDocument(session.user.id, vaultUid, {
2001
+ collection,
2002
+ data,
2003
+ hmac
2004
+ }),
2005
+ 201
2006
+ );
2007
+ }
2008
+ ).put(
2009
+ "/vault/:vaultUid/documents/:uid",
2010
+ requirePermission("write"),
2011
+ requireVaultAccess,
2012
+ zValidator5("json", UpdateDocumentRequest),
2013
+ (c) => {
2014
+ const session = c.get("session");
2015
+ const { vaultUid, uid } = c.req.param();
2016
+ const { data, hmac, version } = c.req.valid("json");
2017
+ return c.json(
2018
+ documentService.updateDocument(uid, vaultUid, session.user.id, {
2019
+ data,
2020
+ hmac,
2021
+ version
2022
+ })
2023
+ );
2024
+ }
2025
+ ).delete(
2026
+ "/vault/:vaultUid/documents/:uid",
2027
+ requirePermission("delete"),
2028
+ requireVaultAccess,
2029
+ (c) => {
2030
+ const session = c.get("session");
2031
+ const { vaultUid, uid } = c.req.param();
2032
+ return c.json(
2033
+ documentService.deleteDocument(uid, vaultUid, session.user.id)
2034
+ );
2035
+ }
2036
+ );
2037
+
1327
2038
  // src/features/auth/rate-limit.ts
1328
2039
  import { createMiddleware as createMiddleware2 } from "hono/factory";
1329
2040
  var DEFAULT_CONFIG = { max: 100, windowMs: 6e4 };
@@ -1386,47 +2097,6 @@ function rateLimit(config = {}) {
1386
2097
  });
1387
2098
  }
1388
2099
 
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
2100
  // src/app.ts
1431
2101
  var errorHandler = (error, c) => {
1432
2102
  const requestId = c.req.header("x-request-id") ?? crypto.randomUUID();
@@ -1457,7 +2127,7 @@ var errorHandler = (error, c) => {
1457
2127
  );
1458
2128
  };
1459
2129
  function createApp() {
1460
- const app = new Hono5();
2130
+ const app = new Hono6();
1461
2131
  app.use("*", bodyLimit({ maxSize: 11 * 1024 * 1024 }));
1462
2132
  app.use(
1463
2133
  "*",
@@ -1489,11 +2159,15 @@ function createApp() {
1489
2159
  })
1490
2160
  );
1491
2161
  app.use("*", rateLimit({ max: 100, windowMs: 6e4 }));
1492
- app.use("*", csrfProtection);
1493
2162
  app.get("/health", (c) => c.json({ status: "ok", timestamp: Date.now() }));
2163
+ try {
2164
+ deleteExpiredApiKeys();
2165
+ } catch {
2166
+ }
1494
2167
  app.use("/auth/*", rateLimit({ max: 10, windowMs: 6e4 }));
1495
2168
  app.route("/auth", authRouter);
1496
2169
  app.route("/vault", vaultRouter);
2170
+ app.route("/", documentRouter);
1497
2171
  app.onError(errorHandler);
1498
2172
  app.notFound((c) => {
1499
2173
  const requestId = c.req.header("x-request-id") ?? crypto.randomUUID();
@@ -1506,8 +2180,16 @@ function createApp() {
1506
2180
  }
1507
2181
  export {
1508
2182
  ApiException,
2183
+ ApiKeyCreatedResponse,
2184
+ ApiKeyResponse,
2185
+ ApiKeysListResponse,
1509
2186
  AuthResponse,
2187
+ CreateApiKeyRequest,
2188
+ CreateDocumentRequest,
1510
2189
  CreateVaultRequest,
2190
+ DocumentListResponse,
2191
+ DocumentResponse,
2192
+ DocumentSyncResponse,
1511
2193
  EmailLoginRequest,
1512
2194
  EmailRegisterRequest,
1513
2195
  MeResponse,
@@ -1516,6 +2198,7 @@ export {
1516
2198
  PasskeyRegisterOptionsRequest,
1517
2199
  PasskeyRegisterVerifyRequest,
1518
2200
  RefreshResponse,
2201
+ UpdateDocumentRequest,
1519
2202
  UpdateVaultRequest,
1520
2203
  UserResponse,
1521
2204
  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.0",
4
4
  "description": "Self-hostable ursalock server with SQLite and ZKCredentials support",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",