@codixus/server 0.1.4 → 0.1.6

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/dist/index.js CHANGED
@@ -5,6 +5,8 @@ var ConnectionManager = class {
5
5
  this.uri = uri;
6
6
  this.dbName = dbName;
7
7
  }
8
+ uri;
9
+ dbName;
8
10
  client = null;
9
11
  db = null;
10
12
  async connect() {
@@ -829,6 +831,7 @@ var RestError = class extends Error {
829
831
  super(message);
830
832
  this.code = code;
831
833
  }
834
+ code;
832
835
  };
833
836
  function param(req, name) {
834
837
  const val = req.params[name];
@@ -1260,6 +1263,724 @@ function createRestRouter(model2, options = {}) {
1260
1263
  return router;
1261
1264
  }
1262
1265
 
1266
+ // src/push/router.ts
1267
+ import { Router as Router3 } from "express";
1268
+ import { randomUUID } from "crypto";
1269
+ function checkAuth(req, res) {
1270
+ const deviceId = req.user?.deviceId;
1271
+ if (!deviceId) {
1272
+ res.status(401).json({ success: false, error: "Missing authorization header" });
1273
+ return null;
1274
+ }
1275
+ return deviceId;
1276
+ }
1277
+ function isNonEmptyString(value) {
1278
+ return typeof value === "string" && value.length > 0;
1279
+ }
1280
+ function createPushRouter(db) {
1281
+ const router = Router3();
1282
+ const devicesCol = db.collection("push_devices");
1283
+ const deliveriesCol = db.collection("push_deliveries");
1284
+ router.post("/register", async (req, res) => {
1285
+ const deviceId = checkAuth(req, res);
1286
+ if (!deviceId) return;
1287
+ const { token, provider, platform, locale, timezone, appVersion, permissionStatus } = req.body ?? {};
1288
+ if (!isNonEmptyString(token) || !isNonEmptyString(provider) || platform !== "ios" && platform !== "android") {
1289
+ res.status(400).json({ success: false, error: "Invalid request body" });
1290
+ return;
1291
+ }
1292
+ const now = /* @__PURE__ */ new Date();
1293
+ const existing = await devicesCol.findOne({ token });
1294
+ if (existing) {
1295
+ const update = {
1296
+ deviceId,
1297
+ enabled: true,
1298
+ lastSeenAt: now,
1299
+ updatedAt: now,
1300
+ provider,
1301
+ platform
1302
+ };
1303
+ if (locale !== void 0) update.locale = locale;
1304
+ if (timezone !== void 0) update.timezone = timezone;
1305
+ if (appVersion !== void 0) update.appVersion = appVersion;
1306
+ if (permissionStatus !== void 0)
1307
+ update.permissionStatus = permissionStatus;
1308
+ await devicesCol.updateOne({ token }, { $set: update });
1309
+ res.json({ success: true });
1310
+ return;
1311
+ }
1312
+ const doc = {
1313
+ _id: randomUUID(),
1314
+ deviceId,
1315
+ token,
1316
+ provider,
1317
+ platform,
1318
+ enabled: true,
1319
+ lastSeenAt: now,
1320
+ createdAt: now,
1321
+ updatedAt: now
1322
+ };
1323
+ if (locale !== void 0) doc.locale = locale;
1324
+ if (timezone !== void 0) doc.timezone = timezone;
1325
+ if (appVersion !== void 0) doc.appVersion = appVersion;
1326
+ if (permissionStatus !== void 0) doc.permissionStatus = permissionStatus;
1327
+ try {
1328
+ await devicesCol.insertOne(doc);
1329
+ } catch (err) {
1330
+ if (!isDuplicateKeyError(err)) throw err;
1331
+ const update = {
1332
+ deviceId,
1333
+ enabled: true,
1334
+ lastSeenAt: now,
1335
+ updatedAt: now,
1336
+ provider,
1337
+ platform
1338
+ };
1339
+ if (locale !== void 0) update.locale = locale;
1340
+ if (timezone !== void 0) update.timezone = timezone;
1341
+ if (appVersion !== void 0) update.appVersion = appVersion;
1342
+ if (permissionStatus !== void 0)
1343
+ update.permissionStatus = permissionStatus;
1344
+ await devicesCol.updateOne({ token }, { $set: update });
1345
+ }
1346
+ res.json({ success: true });
1347
+ });
1348
+ router.post("/unregister", async (req, res) => {
1349
+ const deviceId = checkAuth(req, res);
1350
+ if (!deviceId) return;
1351
+ await devicesCol.updateMany(
1352
+ { deviceId },
1353
+ { $set: { enabled: false, updatedAt: /* @__PURE__ */ new Date() } }
1354
+ );
1355
+ res.json({ success: true });
1356
+ });
1357
+ router.post("/open", async (req, res) => {
1358
+ const deviceId = checkAuth(req, res);
1359
+ if (!deviceId) return;
1360
+ const { deliveryId } = req.body ?? {};
1361
+ if (!isNonEmptyString(deliveryId)) {
1362
+ res.status(400).json({ success: false, error: "Invalid request body" });
1363
+ return;
1364
+ }
1365
+ const delivery = await deliveriesCol.findOne({ _id: deliveryId });
1366
+ if (!delivery || delivery.deviceId !== deviceId) {
1367
+ res.status(404).json({ success: false, error: "NOT_FOUND" });
1368
+ return;
1369
+ }
1370
+ const update = { status: "opened" };
1371
+ if (!delivery.openedAt) {
1372
+ update.openedAt = /* @__PURE__ */ new Date();
1373
+ }
1374
+ await deliveriesCol.updateOne({ _id: deliveryId }, { $set: update });
1375
+ res.json({ success: true });
1376
+ });
1377
+ return router;
1378
+ }
1379
+ function isDuplicateKeyError(err) {
1380
+ return typeof err === "object" && err !== null && "code" in err && err.code === 11e3;
1381
+ }
1382
+
1383
+ // src/admin/router.ts
1384
+ import { Router as Router4 } from "express";
1385
+ import { randomUUID as randomUUID2 } from "crypto";
1386
+ import { z } from "zod";
1387
+
1388
+ // src/admin/guard.ts
1389
+ import { createHash, timingSafeEqual } from "crypto";
1390
+ var ADMIN_HEADER = "x-codixus-admin";
1391
+ function digestToken(token) {
1392
+ return createHash("sha256").update(token, "utf8").digest();
1393
+ }
1394
+ function tokensMatch(provided, expected) {
1395
+ return timingSafeEqual(digestToken(provided), digestToken(expected));
1396
+ }
1397
+ function readHeader(req) {
1398
+ const value = req.headers[ADMIN_HEADER];
1399
+ if (typeof value === "string") return value;
1400
+ if (Array.isArray(value)) return value[0];
1401
+ return void 0;
1402
+ }
1403
+ function createAdminGuard(expectedToken) {
1404
+ return (req, res, next) => {
1405
+ const token = readHeader(req);
1406
+ if (!token || !tokensMatch(token, expectedToken)) {
1407
+ res.status(401).json({ success: false, error: "UNAUTHORIZED" });
1408
+ return;
1409
+ }
1410
+ next();
1411
+ };
1412
+ }
1413
+
1414
+ // src/admin/router.ts
1415
+ var USERS_FIELDS = ["_id", "deviceId", "locale", "createdAt"];
1416
+ var PUSH_DEVICE_FIELDS = [
1417
+ "_id",
1418
+ "deviceId",
1419
+ "token",
1420
+ "provider",
1421
+ "platform",
1422
+ "locale",
1423
+ "timezone",
1424
+ "appVersion",
1425
+ "permissionStatus",
1426
+ "enabled",
1427
+ "lastSeenAt",
1428
+ "properties",
1429
+ "createdAt",
1430
+ "updatedAt"
1431
+ ];
1432
+ var PUSH_DELIVERY_FIELDS = [
1433
+ "_id",
1434
+ "deviceId",
1435
+ "token",
1436
+ "provider",
1437
+ "title",
1438
+ "body",
1439
+ "data",
1440
+ "status",
1441
+ "ticketId",
1442
+ "errorCode",
1443
+ "idempotencyKey",
1444
+ "createdAt",
1445
+ "openedAt",
1446
+ "receiptedAt"
1447
+ ];
1448
+ function param2(req, name) {
1449
+ const val = req.params[name];
1450
+ return Array.isArray(val) ? val[0] : val ?? "";
1451
+ }
1452
+ function isNonEmptyString2(value) {
1453
+ return typeof value === "string" && value.length > 0;
1454
+ }
1455
+ function getModelSchema(model2) {
1456
+ return model2.definition.schema;
1457
+ }
1458
+ function getModelFields(model2) {
1459
+ const schema = getModelSchema(model2);
1460
+ const fields = schema instanceof z.ZodObject ? Object.keys(schema.shape) : [];
1461
+ if (!fields.includes("_id")) {
1462
+ return ["_id", ...fields];
1463
+ }
1464
+ return fields;
1465
+ }
1466
+ function escapeRegex(value) {
1467
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1468
+ }
1469
+ function parseJsonObject(value, res) {
1470
+ if (value === void 0 || value === "") {
1471
+ return {};
1472
+ }
1473
+ if (typeof value !== "string") {
1474
+ res.status(400).json({ success: false, error: "Invalid request body" });
1475
+ return null;
1476
+ }
1477
+ try {
1478
+ const parsed = JSON.parse(value);
1479
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
1480
+ res.status(400).json({ success: false, error: "Invalid request body" });
1481
+ return null;
1482
+ }
1483
+ return parsed;
1484
+ } catch {
1485
+ res.status(400).json({ success: false, error: "Invalid request body" });
1486
+ return null;
1487
+ }
1488
+ }
1489
+ function parseSort(value, res) {
1490
+ if (value === void 0 || value === "") {
1491
+ return {};
1492
+ }
1493
+ const parsed = parseJsonObject(value, res);
1494
+ if (parsed === null) return null;
1495
+ const sort = {};
1496
+ for (const [field, direction] of Object.entries(parsed)) {
1497
+ if (direction !== 1 && direction !== -1) {
1498
+ res.status(400).json({ success: false, error: "Invalid request body" });
1499
+ return null;
1500
+ }
1501
+ sort[field] = direction;
1502
+ }
1503
+ return sort;
1504
+ }
1505
+ function parseLimit(value) {
1506
+ const parsed = Number.parseInt(String(value ?? ""), 10);
1507
+ if (Number.isNaN(parsed) || parsed < 1) return 50;
1508
+ return Math.min(parsed, 100);
1509
+ }
1510
+ function parseSkip(value) {
1511
+ const parsed = Number.parseInt(String(value ?? ""), 10);
1512
+ if (Number.isNaN(parsed) || parsed < 0) return 0;
1513
+ return parsed;
1514
+ }
1515
+ var NATIVE_DATE_FIELDS = /* @__PURE__ */ new Set([
1516
+ "createdAt",
1517
+ "updatedAt",
1518
+ "lastSeenAt",
1519
+ "openedAt",
1520
+ "receiptedAt"
1521
+ ]);
1522
+ function prepareSetValues(setValues) {
1523
+ const next = { ...setValues };
1524
+ delete next._id;
1525
+ for (const [key, value] of Object.entries(next)) {
1526
+ if (!NATIVE_DATE_FIELDS.has(key) || typeof value !== "string") {
1527
+ continue;
1528
+ }
1529
+ const parsed = new Date(value);
1530
+ if (!Number.isNaN(parsed.getTime())) {
1531
+ next[key] = parsed;
1532
+ }
1533
+ }
1534
+ return next;
1535
+ }
1536
+ function extractSetValues(body) {
1537
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
1538
+ return {};
1539
+ }
1540
+ const obj = body;
1541
+ const update = obj.update;
1542
+ if (update !== void 0) {
1543
+ if (!update || typeof update !== "object" || Array.isArray(update)) {
1544
+ return null;
1545
+ }
1546
+ const setValues = update.$set;
1547
+ if (setValues === void 0 || typeof setValues !== "object" || setValues === null || Array.isArray(setValues)) {
1548
+ return null;
1549
+ }
1550
+ return setValues;
1551
+ }
1552
+ return obj;
1553
+ }
1554
+ function buildCollectionsList(hasPush) {
1555
+ const byName = /* @__PURE__ */ new Map();
1556
+ for (const model2 of getModelRegistry()) {
1557
+ byName.set(model2.collectionName, {
1558
+ name: model2.collectionName,
1559
+ fields: getModelFields(model2),
1560
+ kind: "model"
1561
+ });
1562
+ }
1563
+ if (!byName.has("users")) {
1564
+ byName.set("users", {
1565
+ name: "users",
1566
+ fields: [...USERS_FIELDS],
1567
+ kind: "users"
1568
+ });
1569
+ }
1570
+ if (hasPush) {
1571
+ if (!byName.has("push_devices")) {
1572
+ byName.set("push_devices", {
1573
+ name: "push_devices",
1574
+ fields: [...PUSH_DEVICE_FIELDS],
1575
+ kind: "push_devices"
1576
+ });
1577
+ }
1578
+ if (!byName.has("push_deliveries")) {
1579
+ byName.set("push_deliveries", {
1580
+ name: "push_deliveries",
1581
+ fields: [...PUSH_DELIVERY_FIELDS],
1582
+ kind: "push_deliveries"
1583
+ });
1584
+ }
1585
+ }
1586
+ return Array.from(byName.values()).sort(
1587
+ (a, b) => a.name.localeCompare(b.name)
1588
+ );
1589
+ }
1590
+ function resolveCollection(name, hasPush) {
1591
+ if (name === "refresh_tokens") return null;
1592
+ const model2 = getModelRegistry().find((m) => m.collectionName === name);
1593
+ if (model2) {
1594
+ return { kind: "model", name, model: model2 };
1595
+ }
1596
+ if (name === "users") {
1597
+ return { kind: "users", name: "users" };
1598
+ }
1599
+ if (hasPush && name === "push_devices") {
1600
+ return { kind: "push_devices", name: "push_devices" };
1601
+ }
1602
+ if (hasPush && name === "push_deliveries") {
1603
+ return { kind: "push_deliveries", name: "push_deliveries" };
1604
+ }
1605
+ return null;
1606
+ }
1607
+ function notFound(res) {
1608
+ res.status(404).json({ success: false, error: "NOT_FOUND" });
1609
+ }
1610
+ function invalidBody(res) {
1611
+ res.status(400).json({ success: false, error: "Invalid request body" });
1612
+ }
1613
+ function createAdminRouter(deps) {
1614
+ const router = Router4();
1615
+ router.use(createAdminGuard(deps.token));
1616
+ router.get("/session", (_req, res) => {
1617
+ res.status(204).send();
1618
+ });
1619
+ router.get("/collections", (_req, res) => {
1620
+ res.json({
1621
+ success: true,
1622
+ data: buildCollectionsList(deps.hasPush)
1623
+ });
1624
+ });
1625
+ router.get("/collections/:name", async (req, res) => {
1626
+ const name = param2(req, "name");
1627
+ const resolved = resolveCollection(name, deps.hasPush);
1628
+ if (!resolved) {
1629
+ notFound(res);
1630
+ return;
1631
+ }
1632
+ const filter = parseJsonObject(req.query.filter, res);
1633
+ if (filter === null) return;
1634
+ const sort = parseSort(req.query.sort, res);
1635
+ if (sort === null) return;
1636
+ const limit = parseLimit(req.query.limit);
1637
+ const skip = parseSkip(req.query.skip);
1638
+ const docs = await deps.db.collection(name).find(filter).sort(sort).skip(skip).limit(limit).toArray();
1639
+ const payload = {
1640
+ success: true,
1641
+ data: docs
1642
+ };
1643
+ if (docs.length === limit) {
1644
+ payload.nextSkip = skip + limit;
1645
+ }
1646
+ res.json(payload);
1647
+ });
1648
+ router.post("/collections/:name", async (req, res) => {
1649
+ const name = param2(req, "name");
1650
+ const resolved = resolveCollection(name, deps.hasPush);
1651
+ if (!resolved) {
1652
+ notFound(res);
1653
+ return;
1654
+ }
1655
+ const body = req.body ?? {};
1656
+ if (resolved.kind === "model") {
1657
+ try {
1658
+ const created = await resolved.model.create(body);
1659
+ res.status(200).json({ success: true, data: created });
1660
+ } catch {
1661
+ invalidBody(res);
1662
+ }
1663
+ return;
1664
+ }
1665
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
1666
+ invalidBody(res);
1667
+ return;
1668
+ }
1669
+ const doc = { ...body };
1670
+ if (!doc._id) {
1671
+ doc._id = randomUUID2();
1672
+ }
1673
+ await deps.db.collection(name).insertOne(doc);
1674
+ res.status(200).json({ success: true, data: doc });
1675
+ });
1676
+ router.get("/collections/:name/:id", async (req, res) => {
1677
+ const name = param2(req, "name");
1678
+ const id = param2(req, "id");
1679
+ const resolved = resolveCollection(name, deps.hasPush);
1680
+ if (!resolved) {
1681
+ notFound(res);
1682
+ return;
1683
+ }
1684
+ const doc = await deps.db.collection(name).findOne({ _id: id });
1685
+ if (!doc) {
1686
+ notFound(res);
1687
+ return;
1688
+ }
1689
+ res.json({ success: true, data: doc });
1690
+ });
1691
+ router.patch(
1692
+ "/collections/:name/:id",
1693
+ async (req, res) => {
1694
+ const name = param2(req, "name");
1695
+ const id = param2(req, "id");
1696
+ const resolved = resolveCollection(name, deps.hasPush);
1697
+ if (!resolved) {
1698
+ notFound(res);
1699
+ return;
1700
+ }
1701
+ const setValues = extractSetValues(req.body);
1702
+ if (setValues === null) {
1703
+ invalidBody(res);
1704
+ return;
1705
+ }
1706
+ const col = deps.db.collection(name);
1707
+ const existing = await col.findOne({ _id: id });
1708
+ if (!existing) {
1709
+ notFound(res);
1710
+ return;
1711
+ }
1712
+ if (resolved.kind === "model") {
1713
+ const schema = getModelSchema(resolved.model);
1714
+ try {
1715
+ schema.parse({ ...existing, ...setValues });
1716
+ } catch {
1717
+ invalidBody(res);
1718
+ return;
1719
+ }
1720
+ }
1721
+ const prepared = prepareSetValues(setValues);
1722
+ if (Object.keys(prepared).length > 0) {
1723
+ await col.updateOne({ _id: id }, { $set: prepared });
1724
+ }
1725
+ const updated = await col.findOne({ _id: id });
1726
+ res.json({ success: true, data: updated });
1727
+ }
1728
+ );
1729
+ router.delete(
1730
+ "/collections/:name/:id",
1731
+ async (req, res) => {
1732
+ const name = param2(req, "name");
1733
+ const id = param2(req, "id");
1734
+ const resolved = resolveCollection(name, deps.hasPush);
1735
+ if (!resolved) {
1736
+ notFound(res);
1737
+ return;
1738
+ }
1739
+ const result = await deps.db.collection(name).deleteOne({ _id: id });
1740
+ if (result.deletedCount === 0) {
1741
+ notFound(res);
1742
+ return;
1743
+ }
1744
+ res.status(204).send();
1745
+ }
1746
+ );
1747
+ router.get("/push/devices", async (req, res) => {
1748
+ if (!deps.hasPush) {
1749
+ notFound(res);
1750
+ return;
1751
+ }
1752
+ const q = req.query.q;
1753
+ if (!isNonEmptyString2(q)) {
1754
+ invalidBody(res);
1755
+ return;
1756
+ }
1757
+ const devicesCol = deps.db.collection("push_devices");
1758
+ const pattern = escapeRegex(q);
1759
+ const docs = await devicesCol.find({
1760
+ $or: [
1761
+ { deviceId: { $regex: pattern } },
1762
+ { token: { $regex: pattern } }
1763
+ ]
1764
+ }).sort({ enabled: -1, lastSeenAt: -1 }).limit(50).toArray();
1765
+ res.json({ success: true, data: docs });
1766
+ });
1767
+ router.post("/push/send", async (req, res) => {
1768
+ if (!deps.hasPush || !deps.pushSend) {
1769
+ notFound(res);
1770
+ return;
1771
+ }
1772
+ const { deviceId, title, body, data } = req.body ?? {};
1773
+ if (!isNonEmptyString2(deviceId) || !isNonEmptyString2(title) || !isNonEmptyString2(body)) {
1774
+ invalidBody(res);
1775
+ return;
1776
+ }
1777
+ const devicesCol = deps.db.collection("push_devices");
1778
+ const device = await devicesCol.findOne({ deviceId, enabled: true });
1779
+ if (!device) {
1780
+ res.status(404).json({ success: false, error: "NO_DEVICE" });
1781
+ return;
1782
+ }
1783
+ const input = { deviceId, title, body };
1784
+ if (data !== void 0) {
1785
+ input.data = data;
1786
+ }
1787
+ await deps.pushSend(input);
1788
+ res.status(200).json({ success: true });
1789
+ });
1790
+ router.get("/push/deliveries", async (req, res) => {
1791
+ if (!deps.hasPush) {
1792
+ notFound(res);
1793
+ return;
1794
+ }
1795
+ const deviceId = req.query.deviceId;
1796
+ if (!isNonEmptyString2(deviceId)) {
1797
+ invalidBody(res);
1798
+ return;
1799
+ }
1800
+ const deliveriesCol = deps.db.collection("push_deliveries");
1801
+ const docs = await deliveriesCol.find({ deviceId }).sort({ createdAt: -1 }).limit(50).toArray();
1802
+ res.json({ success: true, data: docs });
1803
+ });
1804
+ return router;
1805
+ }
1806
+
1807
+ // src/push/service.ts
1808
+ import { createHash as createHash2, randomUUID as randomUUID3 } from "crypto";
1809
+ var PERMANENT_DISABLE_CODES = /* @__PURE__ */ new Set([
1810
+ "DeviceNotRegistered",
1811
+ "InvalidCredentials"
1812
+ ]);
1813
+ var RECEIPT_BATCH_SIZE = 100;
1814
+ var PushService = class {
1815
+ constructor(db, transport) {
1816
+ this.transport = transport;
1817
+ this.devicesCol = db.collection("push_devices");
1818
+ this.deliveriesCol = db.collection("push_deliveries");
1819
+ }
1820
+ transport;
1821
+ devicesCol;
1822
+ deliveriesCol;
1823
+ async send(input) {
1824
+ const deviceIds = Array.isArray(input.deviceId) ? [...new Set(input.deviceId)] : [input.deviceId];
1825
+ if (deviceIds.length === 0) return;
1826
+ const digest = computeDigest(
1827
+ input.title,
1828
+ input.body,
1829
+ input.idempotencyKey
1830
+ );
1831
+ const pending = [];
1832
+ for (const deviceId of deviceIds) {
1833
+ const devices = await this.devicesCol.find({ deviceId, enabled: true }).toArray();
1834
+ for (const device of devices) {
1835
+ const idempotencyKey = `${deviceId}:${device.token}:${digest}`;
1836
+ const existing = await this.deliveriesCol.findOne({ idempotencyKey });
1837
+ if (existing) continue;
1838
+ const deliveryId = randomUUID3();
1839
+ const data = input.data ? { ...input.data } : {};
1840
+ if (!("deliveryId" in data)) {
1841
+ data.deliveryId = deliveryId;
1842
+ }
1843
+ pending.push({
1844
+ token: device.token,
1845
+ deviceId,
1846
+ provider: device.provider,
1847
+ deliveryId,
1848
+ idempotencyKey,
1849
+ message: {
1850
+ token: device.token,
1851
+ title: input.title,
1852
+ body: input.body,
1853
+ data
1854
+ }
1855
+ });
1856
+ }
1857
+ }
1858
+ if (pending.length === 0) return;
1859
+ const messages = pending.map((p) => p.message);
1860
+ const tickets = await this.transport.send(messages);
1861
+ for (let i = 0; i < pending.length; i++) {
1862
+ const item = pending[i];
1863
+ const ticket = tickets[i];
1864
+ if (ticket && ticket.status === "error" && ticket.errorCode && PERMANENT_DISABLE_CODES.has(ticket.errorCode)) {
1865
+ await this.devicesCol.updateOne(
1866
+ { token: item.token },
1867
+ { $set: { enabled: false, updatedAt: /* @__PURE__ */ new Date() } }
1868
+ );
1869
+ }
1870
+ const doc = {
1871
+ _id: item.deliveryId,
1872
+ deviceId: item.deviceId,
1873
+ token: item.token,
1874
+ provider: item.provider,
1875
+ title: input.title,
1876
+ body: input.body,
1877
+ data: item.message.data,
1878
+ status: "failed",
1879
+ idempotencyKey: item.idempotencyKey,
1880
+ createdAt: /* @__PURE__ */ new Date()
1881
+ };
1882
+ if (ticket && ticket.status === "ok") {
1883
+ doc.status = "submitted";
1884
+ if (ticket.ticketId) doc.ticketId = ticket.ticketId;
1885
+ } else {
1886
+ doc.errorCode = ticket?.errorCode ?? "TRANSIENT";
1887
+ if (ticket?.ticketId) doc.ticketId = ticket.ticketId;
1888
+ }
1889
+ try {
1890
+ await this.deliveriesCol.insertOne(doc);
1891
+ } catch (err) {
1892
+ if (!isDuplicateKeyError2(err)) throw err;
1893
+ }
1894
+ }
1895
+ }
1896
+ async sendTo(input) {
1897
+ const devices = await this.devicesCol.find({ $and: [input.filter, { enabled: true }] }).toArray();
1898
+ const seen = /* @__PURE__ */ new Set();
1899
+ const deviceIds = [];
1900
+ for (const device of devices) {
1901
+ if (!seen.has(device.deviceId)) {
1902
+ seen.add(device.deviceId);
1903
+ deviceIds.push(device.deviceId);
1904
+ }
1905
+ }
1906
+ if (deviceIds.length === 0) return;
1907
+ await this.send({
1908
+ deviceId: deviceIds,
1909
+ title: input.title,
1910
+ body: input.body,
1911
+ data: input.data,
1912
+ idempotencyKey: input.idempotencyKey
1913
+ });
1914
+ }
1915
+ async pollReceipts() {
1916
+ if (!this.transport.getReceipts) return;
1917
+ const deliveries = await this.deliveriesCol.find({
1918
+ status: "submitted",
1919
+ ticketId: { $exists: true, $ne: "" },
1920
+ receiptedAt: { $exists: false }
1921
+ }).toArray();
1922
+ if (deliveries.length === 0) return;
1923
+ const ticketIds = [
1924
+ ...new Set(
1925
+ deliveries.map((d) => d.ticketId).filter((id) => !!id)
1926
+ )
1927
+ ];
1928
+ const receipts = [];
1929
+ try {
1930
+ for (let i = 0; i < ticketIds.length; i += RECEIPT_BATCH_SIZE) {
1931
+ const chunk = ticketIds.slice(i, i + RECEIPT_BATCH_SIZE);
1932
+ const batch = await this.transport.getReceipts(chunk);
1933
+ receipts.push(...batch);
1934
+ }
1935
+ } catch {
1936
+ return;
1937
+ }
1938
+ const receiptMap = new Map(receipts.map((r) => [r.ticketId, r]));
1939
+ for (const delivery of deliveries) {
1940
+ if (!delivery.ticketId) continue;
1941
+ const receipt = receiptMap.get(delivery.ticketId);
1942
+ if (!receipt) continue;
1943
+ const now = /* @__PURE__ */ new Date();
1944
+ if (receipt.status === "ok") {
1945
+ await this.deliveriesCol.updateOne(
1946
+ { _id: delivery._id },
1947
+ { $set: { receiptedAt: now } }
1948
+ );
1949
+ } else {
1950
+ const errorCode = receipt.errorCode ?? "UNKNOWN";
1951
+ await this.deliveriesCol.updateOne(
1952
+ { _id: delivery._id },
1953
+ {
1954
+ $set: {
1955
+ status: "failed",
1956
+ errorCode,
1957
+ receiptedAt: now
1958
+ }
1959
+ }
1960
+ );
1961
+ if (PERMANENT_DISABLE_CODES.has(errorCode)) {
1962
+ await this.devicesCol.updateOne(
1963
+ { token: delivery.token },
1964
+ { $set: { enabled: false, updatedAt: now } }
1965
+ );
1966
+ }
1967
+ }
1968
+ }
1969
+ }
1970
+ async find(filter) {
1971
+ return this.devicesCol.find(filter).toArray();
1972
+ }
1973
+ };
1974
+ function computeDigest(title, body, idempotencyKey) {
1975
+ if (idempotencyKey) return idempotencyKey;
1976
+ const utcDay = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
1977
+ const input = title + "\0" + body + "\0" + utcDay;
1978
+ return createHash2("sha256").update(input, "utf8").digest("hex");
1979
+ }
1980
+ function isDuplicateKeyError2(err) {
1981
+ return typeof err === "object" && err !== null && "code" in err && err.code === 11e3;
1982
+ }
1983
+
1263
1984
  // src/codixus-server.ts
1264
1985
  var CodixusServer = class {
1265
1986
  constructor(config) {
@@ -1273,6 +1994,7 @@ var CodixusServer = class {
1273
1994
  refreshTokenTtl: config.refreshTokenTtl ?? "30d"
1274
1995
  });
1275
1996
  }
1997
+ config;
1276
1998
  connection;
1277
1999
  jwt;
1278
2000
  banService;
@@ -1282,6 +2004,8 @@ var CodixusServer = class {
1282
2004
  _appSecret;
1283
2005
  auth;
1284
2006
  db;
2007
+ push;
2008
+ admin;
1285
2009
  async connect() {
1286
2010
  this._db = await this.connection.connect();
1287
2011
  this.banService = new BanService(this._db);
@@ -1297,6 +2021,27 @@ var CodixusServer = class {
1297
2021
  { expiresAt: 1 },
1298
2022
  { expireAfterSeconds: 0 }
1299
2023
  );
2024
+ if (this.config.push) {
2025
+ const pushDevicesCol = this._db.collection("push_devices");
2026
+ await pushDevicesCol.createIndex({ token: 1 }, { unique: true });
2027
+ await pushDevicesCol.createIndex({ deviceId: 1 });
2028
+ await pushDevicesCol.createIndex({ enabled: 1, lastSeenAt: -1 });
2029
+ const pushDeliveriesCol = this._db.collection("push_deliveries");
2030
+ await pushDeliveriesCol.createIndex(
2031
+ { idempotencyKey: 1 },
2032
+ { unique: true }
2033
+ );
2034
+ await pushDeliveriesCol.createIndex({ ticketId: 1 });
2035
+ await pushDeliveriesCol.createIndex({ deviceId: 1 });
2036
+ const pushService = new PushService(this._db, this.config.push.transport);
2037
+ this.push = {
2038
+ router: () => createPushRouter(this._db),
2039
+ send: (input) => pushService.send(input),
2040
+ sendTo: (input) => pushService.sendTo(input),
2041
+ pollReceipts: () => pushService.pollReceipts(),
2042
+ find: (filter) => pushService.find(filter)
2043
+ };
2044
+ }
1300
2045
  this.auth = {
1301
2046
  sign: (subject, claims) => this.jwt.sign(subject, claims),
1302
2047
  verify: (token) => this.jwt.verify(token),
@@ -1316,6 +2061,16 @@ var CodixusServer = class {
1316
2061
  transaction: (fn) => runTransaction(this.connection.getClient(), fn),
1317
2062
  getDb: () => this._db
1318
2063
  };
2064
+ if (this.config.admin) {
2065
+ this.admin = {
2066
+ router: () => createAdminRouter({
2067
+ db: this._db,
2068
+ token: this.config.admin.token,
2069
+ hasPush: !!this.config.push,
2070
+ pushSend: this.push?.send
2071
+ })
2072
+ };
2073
+ }
1319
2074
  }
1320
2075
  /**
1321
2076
  * HMAC request signing middleware.
@@ -1368,6 +2123,149 @@ function validate(schema) {
1368
2123
  };
1369
2124
  }
1370
2125
 
2126
+ // src/push/expo-transport.ts
2127
+ var SEND_URL = "https://exp.host/--/api/v2/push/send";
2128
+ var RECEIPTS_URL = "https://exp.host/--/api/v2/push/getReceipts";
2129
+ var BATCH_SIZE = 100;
2130
+ function transientTickets(messages) {
2131
+ return messages.map((m) => ({
2132
+ token: m.token,
2133
+ status: "error",
2134
+ errorCode: "TRANSIENT"
2135
+ }));
2136
+ }
2137
+ function invalidPayloadTickets(messages) {
2138
+ return messages.map((m) => ({
2139
+ token: m.token,
2140
+ status: "error",
2141
+ errorCode: "INVALID_PAYLOAD"
2142
+ }));
2143
+ }
2144
+ function mapExpoTickets(messages, data) {
2145
+ const tickets = [];
2146
+ for (let i = 0; i < messages.length; i++) {
2147
+ const msg = messages[i];
2148
+ const ticket = data[i];
2149
+ if (!ticket) {
2150
+ tickets.push({
2151
+ token: msg.token,
2152
+ status: "error",
2153
+ errorCode: "TRANSIENT"
2154
+ });
2155
+ continue;
2156
+ }
2157
+ if (ticket.status === "ok") {
2158
+ tickets.push({
2159
+ token: msg.token,
2160
+ status: "ok",
2161
+ ticketId: ticket.id
2162
+ });
2163
+ } else {
2164
+ tickets.push({
2165
+ token: msg.token,
2166
+ status: "error",
2167
+ errorCode: ticket.details?.error ?? "UNKNOWN",
2168
+ errorMessage: ticket.message
2169
+ });
2170
+ }
2171
+ }
2172
+ return tickets;
2173
+ }
2174
+ function createExpoTransport(opts) {
2175
+ const fetchImpl = opts?.fetchImpl ?? globalThis.fetch;
2176
+ const accessToken = opts?.accessToken;
2177
+ function headers() {
2178
+ const h = {
2179
+ "Content-Type": "application/json"
2180
+ };
2181
+ if (accessToken) {
2182
+ h["Authorization"] = `Bearer ${accessToken}`;
2183
+ }
2184
+ return h;
2185
+ }
2186
+ return {
2187
+ name: "expo",
2188
+ async send(messages) {
2189
+ const allTickets = [];
2190
+ for (let i = 0; i < messages.length; i += BATCH_SIZE) {
2191
+ const batch = messages.slice(i, i + BATCH_SIZE);
2192
+ const body = batch.map((m) => {
2193
+ const item = {
2194
+ to: m.token,
2195
+ title: m.title,
2196
+ body: m.body
2197
+ };
2198
+ if (m.data !== void 0) item.data = m.data;
2199
+ if (m.ttl !== void 0) item.ttl = m.ttl;
2200
+ if (m.badge !== void 0) item.badge = m.badge;
2201
+ if (m.sound !== void 0) item.sound = m.sound;
2202
+ if (m.channelId !== void 0) item.channelId = m.channelId;
2203
+ return item;
2204
+ });
2205
+ try {
2206
+ const res = await fetchImpl(SEND_URL, {
2207
+ method: "POST",
2208
+ headers: headers(),
2209
+ body: JSON.stringify(body)
2210
+ });
2211
+ if (res.status === 429 || res.status >= 500) {
2212
+ allTickets.push(...transientTickets(batch));
2213
+ continue;
2214
+ }
2215
+ if (res.status >= 400 && res.status < 500) {
2216
+ allTickets.push(...invalidPayloadTickets(batch));
2217
+ continue;
2218
+ }
2219
+ const json = await res.json();
2220
+ if (!Array.isArray(json.data)) {
2221
+ allTickets.push(...transientTickets(batch));
2222
+ continue;
2223
+ }
2224
+ allTickets.push(
2225
+ ...mapExpoTickets(batch, json.data)
2226
+ );
2227
+ } catch {
2228
+ allTickets.push(...transientTickets(batch));
2229
+ }
2230
+ }
2231
+ return allTickets;
2232
+ },
2233
+ async getReceipts(ticketIds) {
2234
+ const allReceipts = [];
2235
+ for (let i = 0; i < ticketIds.length; i += BATCH_SIZE) {
2236
+ const batch = ticketIds.slice(i, i + BATCH_SIZE);
2237
+ const res = await fetchImpl(RECEIPTS_URL, {
2238
+ method: "POST",
2239
+ headers: headers(),
2240
+ body: JSON.stringify({ ids: batch })
2241
+ });
2242
+ if (!res.ok) {
2243
+ throw new Error(`Expo getReceipts HTTP ${res.status}`);
2244
+ }
2245
+ const json = await res.json();
2246
+ if (typeof json.data !== "object" || json.data === null || Array.isArray(json.data)) {
2247
+ throw new Error("Expo getReceipts invalid response");
2248
+ }
2249
+ const data = json.data;
2250
+ for (const id of batch) {
2251
+ const receipt = data[id];
2252
+ if (!receipt) continue;
2253
+ if (receipt.status === "ok") {
2254
+ allReceipts.push({ ticketId: id, status: "ok" });
2255
+ } else {
2256
+ allReceipts.push({
2257
+ ticketId: id,
2258
+ status: "error",
2259
+ errorCode: receipt.status === "error" ? receipt.details?.error ?? "UNKNOWN" : "UNKNOWN"
2260
+ });
2261
+ }
2262
+ }
2263
+ }
2264
+ return allReceipts;
2265
+ }
2266
+ };
2267
+ }
2268
+
1371
2269
  // src/index.ts
1372
2270
  import {
1373
2271
  CodixusError as CodixusError3,
@@ -1381,6 +2279,7 @@ export {
1381
2279
  Query,
1382
2280
  RestErrorCode,
1383
2281
  SubCollection,
2282
+ createExpoTransport,
1384
2283
  model,
1385
2284
  validate
1386
2285
  };