@lacrous/omnicloud 0.2.7 → 0.2.9

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.7",
3
+ "version": "0.2.9",
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.7",
52
- "@omnicloud/shared": "0.2.7",
53
- "@omnicloud/telegram": "0.2.7",
51
+ "@omnicloud/core": "0.2.9",
52
+ "@omnicloud/shared": "0.2.9",
53
+ "@omnicloud/telegram": "0.2.9",
54
54
  "@types/node": "^22.10.0",
55
55
  "esbuild": "0.25.12",
56
56
  "tsup": "^8.3.0",
package/server/index.js CHANGED
@@ -2912,10 +2912,13 @@ var TelegramStorageProvider = class {
2912
2912
  };
2913
2913
 
2914
2914
  // ../telegram/src/connection.ts
2915
+ import { randomUUID } from "node:crypto";
2915
2916
  import { Api as Api3 } from "telegram";
2916
2917
  import { computeCheck } from "telegram/Password.js";
2917
2918
  var TELEGRAM_PROVIDER = "telegram";
2918
2919
  var LOGIN_FLOW_TTL_MS = 5 * 60 * 1e3;
2920
+ var QR_FLOW_TTL_MS = 5 * 60 * 1e3;
2921
+ var QR_APPROVAL_POLL_MS = 2e3;
2919
2922
  var TelegramConnectionService = class {
2920
2923
  constructor(repos, clientManager, credentials) {
2921
2924
  this.repos = repos;
@@ -2923,6 +2926,7 @@ var TelegramConnectionService = class {
2923
2926
  this.credentials = credentials;
2924
2927
  }
2925
2928
  flows = /* @__PURE__ */ new Map();
2929
+ qrFlows = /* @__PURE__ */ new Map();
2926
2930
  /** Sends the Telegram confirmation code for the phone number. */
2927
2931
  async startLogin(phone) {
2928
2932
  this.sweepExpiredFlows();
@@ -2971,6 +2975,167 @@ var TelegramConnectionService = class {
2971
2975
  }
2972
2976
  return this.finalizeLogin(flow);
2973
2977
  }
2978
+ /**
2979
+ * Starts a QR sign-in. Returns a flow id and the first login token. The phone
2980
+ * approves the token in the Telegram app; `pollQrLogin` reports the result.
2981
+ */
2982
+ async startQrLogin() {
2983
+ this.sweepExpiredQrFlows();
2984
+ const client = newTelegramClient(this.credentials);
2985
+ try {
2986
+ await client.connect();
2987
+ const flow = {
2988
+ client,
2989
+ createdAt: Date.now(),
2990
+ approved: false,
2991
+ needsPassword: false,
2992
+ failure: null
2993
+ };
2994
+ const flowId = randomUUID();
2995
+ const token = await this.exportQrToken(client);
2996
+ this.qrFlows.set(flowId, flow);
2997
+ this.watchQrApproval(flowId, flow);
2998
+ return { flowId, token };
2999
+ } catch (error) {
3000
+ await this.disconnectQuietly(client);
3001
+ throw mapTelegramError(error, "Could not start Telegram QR sign-in");
3002
+ }
3003
+ }
3004
+ /**
3005
+ * Reports where a QR sign-in stands. While waiting, returns a fresh token
3006
+ * (Telegram rotates them every few seconds). On approval, completes the login.
3007
+ */
3008
+ async pollQrLogin(flowId) {
3009
+ const flow = this.requireQrFlow(flowId);
3010
+ if (flow.failure) {
3011
+ this.qrFlows.delete(flowId);
3012
+ await this.disconnectQuietly(flow.client);
3013
+ throw new TelegramConnectionError(flow.failure);
3014
+ }
3015
+ if (flow.needsPassword) return { status: "password_required" };
3016
+ if (!flow.approved) {
3017
+ const token = await this.exportQrToken(flow.client).catch((error) => {
3018
+ this.qrFlows.delete(flowId);
3019
+ void this.disconnectQuietly(flow.client);
3020
+ throw mapTelegramError(error, "Could not refresh the Telegram QR code");
3021
+ });
3022
+ return { status: "waiting", token };
3023
+ }
3024
+ return this.completeQrLogin(flowId, flow);
3025
+ }
3026
+ /** Completes a QR sign-in for an account with two-factor authentication. */
3027
+ async submitQrPassword(flowId, password) {
3028
+ const flow = this.requireQrFlow(flowId);
3029
+ try {
3030
+ const passwordInfo = await flow.client.invoke(new Api3.account.GetPassword());
3031
+ const check = await computeCheck(passwordInfo, password);
3032
+ await flow.client.invoke(new Api3.auth.CheckPassword({ password: check }));
3033
+ } catch (error) {
3034
+ throw mapTelegramError(error, "Telegram sign-in failed");
3035
+ }
3036
+ return this.completeQrLogin(flowId, flow);
3037
+ }
3038
+ async exportQrToken(client) {
3039
+ const result = await client.invoke(
3040
+ new Api3.auth.ExportLoginToken({
3041
+ apiId: this.credentials.apiId,
3042
+ apiHash: this.credentials.apiHash,
3043
+ exceptIds: []
3044
+ })
3045
+ );
3046
+ if (!(result instanceof Api3.auth.LoginToken)) {
3047
+ throw new TelegramConnectionError("Telegram did not issue a QR login token");
3048
+ }
3049
+ const tokenBase64 = Buffer.from(result.token).toString("base64url");
3050
+ return {
3051
+ url: `tg://login?token=${tokenBase64}`,
3052
+ expiresAt: result.expires * 1e3
3053
+ };
3054
+ }
3055
+ /** Waits for the phone to approve the token, then marks the flow approved. */
3056
+ watchQrApproval(flowId, flow) {
3057
+ const timer = setInterval(() => {
3058
+ if (Date.now() - flow.createdAt > QR_FLOW_TTL_MS) {
3059
+ clearInterval(timer);
3060
+ this.qrFlows.delete(flowId);
3061
+ void this.disconnectQuietly(flow.client);
3062
+ return;
3063
+ }
3064
+ if (flow.approved || flow.needsPassword || flow.failure) {
3065
+ clearInterval(timer);
3066
+ return;
3067
+ }
3068
+ this.probeQrApproval(flow).then(
3069
+ (state) => {
3070
+ if (state === "approved") {
3071
+ flow.approved = true;
3072
+ clearInterval(timer);
3073
+ } else if (state === "password") {
3074
+ flow.needsPassword = true;
3075
+ clearInterval(timer);
3076
+ }
3077
+ },
3078
+ (error) => {
3079
+ flow.failure = mapTelegramError(error, "Telegram QR sign-in failed").message;
3080
+ clearInterval(timer);
3081
+ }
3082
+ );
3083
+ }, QR_APPROVAL_POLL_MS);
3084
+ timer.unref();
3085
+ }
3086
+ /**
3087
+ * Asks Telegram whether the token was approved. Approval is observed by
3088
+ * `ExportLoginToken` returning `LoginTokenSuccess` (or a DC migration).
3089
+ */
3090
+ async probeQrApproval(flow) {
3091
+ try {
3092
+ const result = await flow.client.invoke(
3093
+ new Api3.auth.ExportLoginToken({
3094
+ apiId: this.credentials.apiId,
3095
+ apiHash: this.credentials.apiHash,
3096
+ exceptIds: []
3097
+ })
3098
+ );
3099
+ if (result instanceof Api3.auth.LoginTokenSuccess) return "approved";
3100
+ if (result instanceof Api3.auth.LoginTokenMigrateTo) {
3101
+ await flow.client._switchDC(result.dcId);
3102
+ const migrated = await flow.client.invoke(
3103
+ new Api3.auth.ImportLoginToken({ token: result.token })
3104
+ );
3105
+ if (migrated instanceof Api3.auth.LoginTokenSuccess) return "approved";
3106
+ }
3107
+ return "pending";
3108
+ } catch (error) {
3109
+ if (isPasswordRequiredError(error)) return "password";
3110
+ throw error;
3111
+ }
3112
+ }
3113
+ async completeQrLogin(flowId, flow) {
3114
+ this.qrFlows.delete(flowId);
3115
+ const user = await this.finalizeConnectedClient(flow.client, null);
3116
+ return { status: "approved", user };
3117
+ }
3118
+ requireQrFlow(flowId) {
3119
+ const flow = this.qrFlows.get(flowId);
3120
+ if (!flow) {
3121
+ throw new NotFoundError("This Telegram QR sign-in has ended. Start again.");
3122
+ }
3123
+ if (Date.now() - flow.createdAt > QR_FLOW_TTL_MS) {
3124
+ this.qrFlows.delete(flowId);
3125
+ void this.disconnectQuietly(flow.client);
3126
+ throw new ValidationError("The QR sign-in expired. Start again.");
3127
+ }
3128
+ return flow;
3129
+ }
3130
+ sweepExpiredQrFlows() {
3131
+ const now = Date.now();
3132
+ for (const [flowId, flow] of this.qrFlows) {
3133
+ if (now - flow.createdAt > QR_FLOW_TTL_MS) {
3134
+ this.qrFlows.delete(flowId);
3135
+ void this.disconnectQuietly(flow.client);
3136
+ }
3137
+ }
3138
+ }
2974
3139
  /** Aborts a pending login flow (disconnects the client). */
2975
3140
  async cancelLogin(phone) {
2976
3141
  const flow = this.flows.get(phone);
@@ -3032,11 +3197,20 @@ var TelegramConnectionService = class {
3032
3197
  }
3033
3198
  async finalizeLogin(flow) {
3034
3199
  this.flows.delete(flow.phone);
3200
+ const user = await this.finalizeConnectedClient(flow.client, flow.phone);
3201
+ return { status: "ok", user };
3202
+ }
3203
+ /**
3204
+ * Saves an authorized client's session, upserts the user and creates storage.
3205
+ * `phone` is the number the user typed; QR sign-in passes null and relies on
3206
+ * the number Telegram reports.
3207
+ */
3208
+ async finalizeConnectedClient(client, phone) {
3035
3209
  let stringSession;
3036
3210
  let user;
3037
3211
  try {
3038
- stringSession = flow.client.session.save();
3039
- const me = await flow.client.getMe();
3212
+ stringSession = client.session.save();
3213
+ const me = await client.getMe();
3040
3214
  if (!(me instanceof Api3.User)) {
3041
3215
  throw new Error("Telegram did not return the authenticated user");
3042
3216
  }
@@ -3045,16 +3219,16 @@ var TelegramConnectionService = class {
3045
3219
  username: me.username ?? null,
3046
3220
  firstName: me.firstName ?? null,
3047
3221
  lastName: me.lastName ?? null,
3048
- phone: me.phone ?? flow.phone
3222
+ phone: me.phone ?? phone ?? ""
3049
3223
  });
3050
3224
  } catch (error) {
3051
- await this.disconnectQuietly(flow.client);
3225
+ await this.disconnectQuietly(client);
3052
3226
  throw mapTelegramError(error, "Could not complete the Telegram connection");
3053
3227
  }
3054
3228
  await this.repos.sessions.save(user.id, stringSession);
3055
- await this.ensureStorageForClient(user.id, flow.client);
3056
- this.clientManager.cacheClient(user.id, flow.client);
3057
- return { status: "ok", user };
3229
+ await this.ensureStorageForClient(user.id, client);
3230
+ this.clientManager.cacheClient(user.id, client);
3231
+ return user;
3058
3232
  }
3059
3233
  sweepExpiredFlows() {
3060
3234
  const now = Date.now();
@@ -3975,11 +4149,11 @@ var LOGGER_OPTIONS = (level) => ({
3975
4149
  });
3976
4150
 
3977
4151
  // ../../apps/api/src/http.ts
3978
- import { randomUUID } from "node:crypto";
4152
+ import { randomUUID as randomUUID2 } from "node:crypto";
3979
4153
  function resolveRequestId(headers) {
3980
4154
  const inbound = headers[REQUEST_ID_HEADER];
3981
4155
  const candidate = Array.isArray(inbound) ? inbound[0] : inbound;
3982
- return typeof candidate === "string" && candidate.length > 0 && candidate.length <= 128 ? candidate : randomUUID();
4156
+ return typeof candidate === "string" && candidate.length > 0 && candidate.length <= 128 ? candidate : randomUUID2();
3983
4157
  }
3984
4158
  function attachRequestId(request, reply) {
3985
4159
  reply.header(REQUEST_ID_HEADER, request.id);
@@ -4298,6 +4472,42 @@ function registerAuthRoutes(app2, container2) {
4298
4472
  );
4299
4473
  return reply.send({ status: "ok", user: toUserDTO(result.user) });
4300
4474
  });
4475
+ app2.post("/api/auth/telegram/qr/start", async (request, reply) => {
4476
+ limiter(request.ip);
4477
+ const started = await container2.connection.startQrLogin();
4478
+ return reply.send({ flowId: started.flowId, token: started.token });
4479
+ });
4480
+ app2.get("/api/auth/telegram/qr/status", async (request, reply) => {
4481
+ limiter(request.ip);
4482
+ const flowId = requireString(request.query, "flowId");
4483
+ const result = await container2.connection.pollQrLogin(flowId);
4484
+ if (result.status === "approved") {
4485
+ await issueSessionCookie(
4486
+ container2.authSessions,
4487
+ request,
4488
+ reply,
4489
+ result.user.id,
4490
+ container2.config.cookieSecure
4491
+ );
4492
+ return reply.send({ status: "ok", user: toUserDTO(result.user) });
4493
+ }
4494
+ return reply.send(result);
4495
+ });
4496
+ app2.post("/api/auth/telegram/qr/password", async (request, reply) => {
4497
+ limiter(request.ip);
4498
+ const body = requireBody(request);
4499
+ const flowId = requireString(body, "flowId");
4500
+ const password = requireString(body, "password");
4501
+ const result = await container2.connection.submitQrPassword(flowId, password);
4502
+ await issueSessionCookie(
4503
+ container2.authSessions,
4504
+ request,
4505
+ reply,
4506
+ result.user.id,
4507
+ container2.config.cookieSecure
4508
+ );
4509
+ return reply.send({ status: "ok", user: toUserDTO(result.user) });
4510
+ });
4301
4511
  app2.get("/api/auth/me", async (request) => {
4302
4512
  const user = await currentUser(container2.repos, container2.authSessions, request.cookies);
4303
4513
  if (!user) return { user: null, storage: null, health: null };
@@ -4685,7 +4895,7 @@ function encryptionCheck(configured) {
4685
4895
  }
4686
4896
 
4687
4897
  // ../../apps/api/src/routes/storage.ts
4688
- var VERSION = "0.2.7";
4898
+ var VERSION = "0.2.9";
4689
4899
  function registerStorageRoutes(app2, container2) {
4690
4900
  app2.get("/api/health", async () => {
4691
4901
  const database = await probeDatabase(container2);