@codixus/server 0.1.5 → 0.1.7

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