@lacrous/omnicloud 0.2.32 → 0.2.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lacrous/omnicloud",
3
- "version": "0.2.32",
3
+ "version": "0.2.34",
4
4
  "description": "SDK and core building blocks for OmniCloud — self-hosted cloud storage powered by your Telegram account.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -48,9 +48,9 @@
48
48
  "prisma": "^6.2.0"
49
49
  },
50
50
  "devDependencies": {
51
- "@omnicloud/core": "0.2.32",
52
- "@omnicloud/shared": "0.2.32",
53
- "@omnicloud/telegram": "0.2.32",
51
+ "@omnicloud/core": "0.2.34",
52
+ "@omnicloud/shared": "0.2.34",
53
+ "@omnicloud/telegram": "0.2.34",
54
54
  "@types/node": "^22.10.0",
55
55
  "esbuild": "0.25.12",
56
56
  "tsup": "^8.3.0",
package/server/index.js CHANGED
@@ -770,6 +770,41 @@ function truncate(value, max) {
770
770
  return value === null ? null : value.slice(0, max);
771
771
  }
772
772
 
773
+ // ../core/src/services/api-token-service.ts
774
+ import { randomBytes as randomBytes2 } from "node:crypto";
775
+ var MAX_NAME_LENGTH = 80;
776
+ var ApiTokenService = class {
777
+ constructor(repo, now = () => /* @__PURE__ */ new Date()) {
778
+ this.repo = repo;
779
+ this.now = now;
780
+ }
781
+ async issue(userId, name) {
782
+ const label = name.trim();
783
+ if (label.length === 0 || label.length > MAX_NAME_LENGTH) {
784
+ throw new ValidationError(`Token name must be 1 to ${MAX_NAME_LENGTH} characters`);
785
+ }
786
+ const token = randomBytes2(32).toString("base64url");
787
+ const record = await this.repo.create({ userId, name: label, tokenHash: hashToken(token) });
788
+ return { token, record };
789
+ }
790
+ list(userId) {
791
+ return this.repo.listForUser(userId);
792
+ }
793
+ /** Resolves a bearer token to its active record, or null if unknown or revoked. */
794
+ async resolve(token) {
795
+ if (!token || token.length < 32 || token.length > 128) return null;
796
+ const record = await this.repo.findByTokenHash(hashToken(token));
797
+ if (!record || record.revokedAt !== null) return null;
798
+ await this.repo.touch(record.id, this.now());
799
+ return record;
800
+ }
801
+ async revoke(userId, id) {
802
+ const record = await this.repo.findByIdForUser(userId, id);
803
+ if (!record) throw new NotFoundError("API token not found");
804
+ if (record.revokedAt === null) await this.repo.revoke(id, this.now());
805
+ }
806
+ };
807
+
773
808
  // ../core/src/services/version-retention.ts
774
809
  function versionsToPrune(versions, currentVersionId, policy, now) {
775
810
  const historical = versions.filter((version) => version.id !== currentVersionId).sort((a, b) => b.versionNumber - a.versionNumber);
@@ -2372,7 +2407,7 @@ async function sweepStaleSpools() {
2372
2407
  }
2373
2408
 
2374
2409
  // ../core/src/utils/secret-box.ts
2375
- import { createCipheriv, createDecipheriv, randomBytes as randomBytes2, scryptSync } from "node:crypto";
2410
+ import { createCipheriv, createDecipheriv, randomBytes as randomBytes3, scryptSync } from "node:crypto";
2376
2411
  var ALGORITHM = "aes-256-gcm";
2377
2412
  var IV_BYTES = 12;
2378
2413
  var KEY_BYTES = 32;
@@ -2398,7 +2433,7 @@ var SecretBox = class {
2398
2433
  }
2399
2434
  seal(plaintext) {
2400
2435
  const key = this.keys.get(this.currentVersion);
2401
- const iv = randomBytes2(IV_BYTES);
2436
+ const iv = randomBytes3(IV_BYTES);
2402
2437
  const cipher = createCipheriv(ALGORITHM, key, iv);
2403
2438
  const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
2404
2439
  const tag = cipher.getAuthTag();
@@ -3817,6 +3852,49 @@ function createUploadOperationRepo(prisma) {
3817
3852
  }
3818
3853
  };
3819
3854
  }
3855
+ function createApiTokenRepo(prisma) {
3856
+ return {
3857
+ async create(input) {
3858
+ const row = await prisma.apiToken.create({ data: input });
3859
+ return mapApiToken(row);
3860
+ },
3861
+ async findByTokenHash(tokenHash) {
3862
+ const row = await prisma.apiToken.findUnique({ where: { tokenHash } });
3863
+ return row ? mapApiToken(row) : null;
3864
+ },
3865
+ async findByIdForUser(userId, id) {
3866
+ const row = await prisma.apiToken.findFirst({ where: { id, userId } });
3867
+ return row ? mapApiToken(row) : null;
3868
+ },
3869
+ async listForUser(userId) {
3870
+ const rows = await prisma.apiToken.findMany({
3871
+ where: { userId },
3872
+ orderBy: { createdAt: "desc" }
3873
+ });
3874
+ return rows.map(mapApiToken);
3875
+ },
3876
+ async touch(id, at) {
3877
+ await prisma.apiToken.update({ where: { id }, data: { lastUsedAt: at } });
3878
+ },
3879
+ async revoke(id, at) {
3880
+ await prisma.apiToken.updateMany({
3881
+ where: { id, revokedAt: null },
3882
+ data: { revokedAt: at }
3883
+ });
3884
+ }
3885
+ };
3886
+ }
3887
+ function mapApiToken(row) {
3888
+ return {
3889
+ id: row.id,
3890
+ userId: row.userId,
3891
+ name: row.name,
3892
+ tokenHash: row.tokenHash,
3893
+ createdAt: row.createdAt,
3894
+ revokedAt: row.revokedAt,
3895
+ lastUsedAt: row.lastUsedAt
3896
+ };
3897
+ }
3820
3898
  function createBrowserSessionRepo(prisma) {
3821
3899
  return {
3822
3900
  async create(input) {
@@ -4258,6 +4336,7 @@ function createPrismaRepos(prisma, box = null) {
4258
4336
  users: createUserRepo(prisma),
4259
4337
  sessions: createSessionRepo(prisma, box),
4260
4338
  browserSessions: createBrowserSessionRepo(prisma),
4339
+ apiTokens: createApiTokenRepo(prisma),
4261
4340
  uploadOperations: createUploadOperationRepo(prisma),
4262
4341
  storages: createStorageRepo(prisma),
4263
4342
  folders: createFolderRepo(prisma),
@@ -4362,6 +4441,7 @@ function buildContainerFromRepos(config2, repos, overrides = {}) {
4362
4441
  reconciliation: new ReconciliationService(repos, engineFor),
4363
4442
  reconciliationFlow: new ReconciliationFlow(repos, engineFor),
4364
4443
  authSessions: new AuthSessionService(repos.browserSessions),
4444
+ apiTokens: new ApiTokenService(repos.apiTokens),
4365
4445
  engineFor,
4366
4446
  shutdown: async () => {
4367
4447
  await clientManager.disconnectAll();
@@ -4416,12 +4496,22 @@ import multipart from "@fastify/multipart";
4416
4496
  import fastifyStatic from "@fastify/static";
4417
4497
 
4418
4498
  // ../../apps/api/src/auth.ts
4419
- function registerAuthHook(app2, repos, authSessions) {
4499
+ function registerAuthHook(app2, repos, authSessions, apiTokens) {
4420
4500
  app2.addHook("preHandler", async (request) => {
4421
4501
  const url = request.routeOptions?.url ?? "";
4422
4502
  if (!url.startsWith("/api/") || url.startsWith("/api/auth/") || url.startsWith("/api/health")) {
4423
4503
  return;
4424
4504
  }
4505
+ const bearer = bearerToken(request.headers.authorization);
4506
+ if (bearer !== void 0) {
4507
+ const token = await apiTokens.resolve(bearer);
4508
+ if (!token) throw new UnauthorizedError();
4509
+ const owner = await repos.users.findById(token.userId);
4510
+ if (!owner) throw new AuthInvalidError("Account no longer exists");
4511
+ request.user = { id: owner.id };
4512
+ request.authSessionId = void 0;
4513
+ return;
4514
+ }
4425
4515
  const session = await authSessions.resolve(request.cookies[SESSION_COOKIE]);
4426
4516
  if (!session) throw new UnauthorizedError();
4427
4517
  const user = await repos.users.findById(session.userId);
@@ -4430,6 +4520,11 @@ function registerAuthHook(app2, repos, authSessions) {
4430
4520
  request.authSessionId = session.id;
4431
4521
  });
4432
4522
  }
4523
+ function bearerToken(header) {
4524
+ if (!header) return void 0;
4525
+ const match = /^Bearer\s+(\S+)$/i.exec(header.trim());
4526
+ return match ? match[1] : void 0;
4527
+ }
4433
4528
  async function issueSessionCookie(authSessions, request, reply, userId, cookieSecure) {
4434
4529
  const { token } = await authSessions.issue(userId, {
4435
4530
  userAgent: request.headers["user-agent"] ?? null,
@@ -5253,7 +5348,7 @@ function encryptionCheck(configured) {
5253
5348
  }
5254
5349
 
5255
5350
  // ../../apps/api/src/routes/storage.ts
5256
- var VERSION = "0.2.32";
5351
+ var VERSION = "0.2.34";
5257
5352
  function registerStorageRoutes(app2, container2) {
5258
5353
  app2.get("/api/health", async () => {
5259
5354
  const database = await probeDatabase(container2);
@@ -5353,6 +5448,38 @@ async function probeDatabase(container2) {
5353
5448
  }
5354
5449
  }
5355
5450
 
5451
+ // ../../apps/api/src/routes/tokens.ts
5452
+ function registerTokenRoutes(app2, container2) {
5453
+ app2.post("/api/tokens", async (request, reply) => {
5454
+ const body = request.body ?? {};
5455
+ if (typeof body.name !== "string") throw new ValidationError("A token name is required");
5456
+ const issued = await container2.apiTokens.issue(request.user.id, body.name);
5457
+ return reply.status(201).send({
5458
+ id: issued.record.id,
5459
+ name: issued.record.name,
5460
+ createdAt: issued.record.createdAt.toISOString(),
5461
+ token: issued.token
5462
+ });
5463
+ });
5464
+ app2.get("/api/tokens", async (request) => {
5465
+ const tokens = await container2.apiTokens.list(request.user.id);
5466
+ return {
5467
+ tokens: tokens.map((t) => ({
5468
+ id: t.id,
5469
+ name: t.name,
5470
+ createdAt: t.createdAt.toISOString(),
5471
+ lastUsedAt: t.lastUsedAt?.toISOString() ?? null,
5472
+ revokedAt: t.revokedAt?.toISOString() ?? null
5473
+ }))
5474
+ };
5475
+ });
5476
+ app2.delete("/api/tokens/:id", async (request, reply) => {
5477
+ const { id } = request.params;
5478
+ await container2.apiTokens.revoke(request.user.id, id);
5479
+ return reply.status(204).send();
5480
+ });
5481
+ }
5482
+
5356
5483
  // ../../apps/api/src/app.ts
5357
5484
  function sendError(reply, status, code, message, requestId, details) {
5358
5485
  void reply.status(status).send({ error: { code, message, requestId, ...details ? { details } : {} } });
@@ -5382,7 +5509,7 @@ async function createApp(container2) {
5382
5509
  }
5383
5510
  }
5384
5511
  });
5385
- registerAuthHook(app2, container2.repos, container2.authSessions);
5512
+ registerAuthHook(app2, container2.repos, container2.authSessions, container2.apiTokens);
5386
5513
  app2.setErrorHandler((error, request, reply) => {
5387
5514
  if (error instanceof DomainError) {
5388
5515
  sendError(reply, error.status, error.code, error.message, request.id, error.details);
@@ -5410,6 +5537,7 @@ async function createApp(container2) {
5410
5537
  registerFolderRoutes(app2, container2);
5411
5538
  registerCollectionRoutes(app2, container2);
5412
5539
  registerStorageRoutes(app2, container2);
5540
+ registerTokenRoutes(app2, container2);
5413
5541
  const webDistDir = container2.config.webDistDir;
5414
5542
  if (webDistDir && existsSync(webDistDir)) {
5415
5543
  await app2.register(fastifyStatic, { root: resolve(webDistDir), wildcard: false });
@@ -0,0 +1,19 @@
1
+ -- Personal API tokens. Only a SHA-256 hash of each secret is stored; revoked tokens
2
+ -- are kept so their history survives, and are rejected by the auth check.
3
+ CREATE TABLE "ApiToken" (
4
+ "id" TEXT NOT NULL,
5
+ "userId" TEXT NOT NULL,
6
+ "name" TEXT NOT NULL,
7
+ "tokenHash" TEXT NOT NULL,
8
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
9
+ "revokedAt" TIMESTAMP(3),
10
+ "lastUsedAt" TIMESTAMP(3),
11
+
12
+ CONSTRAINT "ApiToken_pkey" PRIMARY KEY ("id")
13
+ );
14
+
15
+ CREATE UNIQUE INDEX "ApiToken_tokenHash_key" ON "ApiToken"("tokenHash");
16
+
17
+ CREATE INDEX "ApiToken_userId_revokedAt_idx" ON "ApiToken"("userId", "revokedAt");
18
+
19
+ ALTER TABLE "ApiToken" ADD CONSTRAINT "ApiToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -31,6 +31,7 @@ model User {
31
31
  fileVersions FileVersion[]
32
32
  activityEvents ActivityEvent[]
33
33
  browserSessions BrowserSession[]
34
+ apiTokens ApiToken[]
34
35
  uploadOperations UploadOperation[]
35
36
  }
36
37
 
@@ -74,6 +75,21 @@ model UploadOperation {
74
75
  @@index([status, updatedAt])
75
76
  }
76
77
 
78
+ /// Personal API token for scripts and the SDK. Only a SHA-256 hash of the secret
79
+ /// is stored; the secret itself is shown once, when the token is created.
80
+ model ApiToken {
81
+ id String @id @default(cuid())
82
+ userId String
83
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
84
+ name String
85
+ tokenHash String @unique
86
+ createdAt DateTime @default(now())
87
+ revokedAt DateTime?
88
+ lastUsedAt DateTime?
89
+
90
+ @@index([userId, revokedAt])
91
+ }
92
+
77
93
  /// MTProto session string for the user's connected Telegram account.
78
94
  /// Never exposed through the API and never logged.
79
95
  model TelegramSession {