tacilytics 0.0.0-stage → 2.0.2

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 (307) hide show
  1. package/.env.example +23 -0
  2. package/LICENSE +21 -0
  3. package/README.md +253 -2
  4. package/cli.js +173 -0
  5. package/day-salt.js +55 -0
  6. package/package.json +56 -4
  7. package/public/app.js +788 -0
  8. package/public/assets/DISCLAIMER-simple-icons.md +51 -0
  9. package/public/assets/LICENSE-lucide.txt +43 -0
  10. package/public/assets/LICENSE-simple-icons.md +30 -0
  11. package/public/assets/cosmic-bg.webp +0 -0
  12. package/public/assets/flags/LICENSE-flag-icons.txt +21 -0
  13. package/public/assets/flags/ad.svg +150 -0
  14. package/public/assets/flags/ae.svg +6 -0
  15. package/public/assets/flags/af.svg +81 -0
  16. package/public/assets/flags/ag.svg +14 -0
  17. package/public/assets/flags/ai.svg +29 -0
  18. package/public/assets/flags/al.svg +5 -0
  19. package/public/assets/flags/am.svg +5 -0
  20. package/public/assets/flags/ao.svg +13 -0
  21. package/public/assets/flags/aq.svg +5 -0
  22. package/public/assets/flags/ar.svg +32 -0
  23. package/public/assets/flags/arab.svg +109 -0
  24. package/public/assets/flags/as.svg +72 -0
  25. package/public/assets/flags/asean.svg +13 -0
  26. package/public/assets/flags/at.svg +4 -0
  27. package/public/assets/flags/au.svg +8 -0
  28. package/public/assets/flags/aw.svg +186 -0
  29. package/public/assets/flags/ax.svg +18 -0
  30. package/public/assets/flags/az.svg +8 -0
  31. package/public/assets/flags/ba.svg +12 -0
  32. package/public/assets/flags/bb.svg +6 -0
  33. package/public/assets/flags/bd.svg +4 -0
  34. package/public/assets/flags/be.svg +7 -0
  35. package/public/assets/flags/bf.svg +7 -0
  36. package/public/assets/flags/bg.svg +5 -0
  37. package/public/assets/flags/bh.svg +4 -0
  38. package/public/assets/flags/bi.svg +15 -0
  39. package/public/assets/flags/bj.svg +14 -0
  40. package/public/assets/flags/bl.svg +5 -0
  41. package/public/assets/flags/bm.svg +97 -0
  42. package/public/assets/flags/bn.svg +36 -0
  43. package/public/assets/flags/bo.svg +673 -0
  44. package/public/assets/flags/bq.svg +5 -0
  45. package/public/assets/flags/br.svg +45 -0
  46. package/public/assets/flags/bs.svg +13 -0
  47. package/public/assets/flags/bt.svg +89 -0
  48. package/public/assets/flags/bv.svg +13 -0
  49. package/public/assets/flags/bw.svg +7 -0
  50. package/public/assets/flags/by.svg +18 -0
  51. package/public/assets/flags/bz.svg +145 -0
  52. package/public/assets/flags/ca.svg +4 -0
  53. package/public/assets/flags/cc.svg +19 -0
  54. package/public/assets/flags/cd.svg +5 -0
  55. package/public/assets/flags/cefta.svg +13 -0
  56. package/public/assets/flags/cf.svg +15 -0
  57. package/public/assets/flags/cg.svg +12 -0
  58. package/public/assets/flags/ch.svg +9 -0
  59. package/public/assets/flags/ci.svg +7 -0
  60. package/public/assets/flags/ck.svg +9 -0
  61. package/public/assets/flags/cl.svg +13 -0
  62. package/public/assets/flags/cm.svg +15 -0
  63. package/public/assets/flags/cn.svg +11 -0
  64. package/public/assets/flags/co.svg +7 -0
  65. package/public/assets/flags/cp.svg +7 -0
  66. package/public/assets/flags/cr.svg +7 -0
  67. package/public/assets/flags/cu.svg +13 -0
  68. package/public/assets/flags/cv.svg +13 -0
  69. package/public/assets/flags/cw.svg +14 -0
  70. package/public/assets/flags/cx.svg +15 -0
  71. package/public/assets/flags/cy.svg +6 -0
  72. package/public/assets/flags/cz.svg +5 -0
  73. package/public/assets/flags/de.svg +5 -0
  74. package/public/assets/flags/dg.svg +130 -0
  75. package/public/assets/flags/dj.svg +13 -0
  76. package/public/assets/flags/dk.svg +5 -0
  77. package/public/assets/flags/dm.svg +152 -0
  78. package/public/assets/flags/do.svg +121 -0
  79. package/public/assets/flags/dz.svg +5 -0
  80. package/public/assets/flags/eac.svg +48 -0
  81. package/public/assets/flags/ec.svg +138 -0
  82. package/public/assets/flags/ee.svg +5 -0
  83. package/public/assets/flags/eg.svg +38 -0
  84. package/public/assets/flags/eh.svg +16 -0
  85. package/public/assets/flags/er.svg +8 -0
  86. package/public/assets/flags/es-ct.svg +4 -0
  87. package/public/assets/flags/es-ga.svg +187 -0
  88. package/public/assets/flags/es-pv.svg +5 -0
  89. package/public/assets/flags/es.svg +544 -0
  90. package/public/assets/flags/et.svg +14 -0
  91. package/public/assets/flags/eu.svg +28 -0
  92. package/public/assets/flags/fi.svg +5 -0
  93. package/public/assets/flags/fj.svg +120 -0
  94. package/public/assets/flags/fk.svg +90 -0
  95. package/public/assets/flags/fm.svg +11 -0
  96. package/public/assets/flags/fo.svg +12 -0
  97. package/public/assets/flags/fr.svg +5 -0
  98. package/public/assets/flags/ga.svg +7 -0
  99. package/public/assets/flags/gb-eng.svg +5 -0
  100. package/public/assets/flags/gb-nir.svg +132 -0
  101. package/public/assets/flags/gb-sct.svg +4 -0
  102. package/public/assets/flags/gb-wls.svg +9 -0
  103. package/public/assets/flags/gb.svg +7 -0
  104. package/public/assets/flags/gd.svg +27 -0
  105. package/public/assets/flags/ge.svg +6 -0
  106. package/public/assets/flags/gf.svg +5 -0
  107. package/public/assets/flags/gg.svg +9 -0
  108. package/public/assets/flags/gh.svg +6 -0
  109. package/public/assets/flags/gi.svg +32 -0
  110. package/public/assets/flags/gl.svg +4 -0
  111. package/public/assets/flags/gm.svg +14 -0
  112. package/public/assets/flags/gn.svg +7 -0
  113. package/public/assets/flags/gp.svg +5 -0
  114. package/public/assets/flags/gq.svg +23 -0
  115. package/public/assets/flags/gr.svg +16 -0
  116. package/public/assets/flags/gs.svg +133 -0
  117. package/public/assets/flags/gt.svg +204 -0
  118. package/public/assets/flags/gu.svg +19 -0
  119. package/public/assets/flags/gw.svg +13 -0
  120. package/public/assets/flags/gy.svg +9 -0
  121. package/public/assets/flags/hk.svg +8 -0
  122. package/public/assets/flags/hm.svg +8 -0
  123. package/public/assets/flags/hn.svg +18 -0
  124. package/public/assets/flags/hr.svg +58 -0
  125. package/public/assets/flags/ht.svg +116 -0
  126. package/public/assets/flags/hu.svg +7 -0
  127. package/public/assets/flags/ic.svg +7 -0
  128. package/public/assets/flags/id.svg +4 -0
  129. package/public/assets/flags/ie.svg +7 -0
  130. package/public/assets/flags/il.svg +14 -0
  131. package/public/assets/flags/im.svg +36 -0
  132. package/public/assets/flags/in.svg +25 -0
  133. package/public/assets/flags/io.svg +130 -0
  134. package/public/assets/flags/iq.svg +10 -0
  135. package/public/assets/flags/ir.svg +219 -0
  136. package/public/assets/flags/is.svg +12 -0
  137. package/public/assets/flags/it.svg +7 -0
  138. package/public/assets/flags/je.svg +62 -0
  139. package/public/assets/flags/jm.svg +8 -0
  140. package/public/assets/flags/jo.svg +16 -0
  141. package/public/assets/flags/jp.svg +11 -0
  142. package/public/assets/flags/ke.svg +23 -0
  143. package/public/assets/flags/kg.svg +4 -0
  144. package/public/assets/flags/kh.svg +61 -0
  145. package/public/assets/flags/ki.svg +36 -0
  146. package/public/assets/flags/km.svg +16 -0
  147. package/public/assets/flags/kn.svg +14 -0
  148. package/public/assets/flags/kp.svg +15 -0
  149. package/public/assets/flags/kr.svg +24 -0
  150. package/public/assets/flags/kw.svg +13 -0
  151. package/public/assets/flags/ky.svg +103 -0
  152. package/public/assets/flags/kz.svg +36 -0
  153. package/public/assets/flags/la.svg +12 -0
  154. package/public/assets/flags/lb.svg +15 -0
  155. package/public/assets/flags/lc.svg +8 -0
  156. package/public/assets/flags/li.svg +43 -0
  157. package/public/assets/flags/lk.svg +22 -0
  158. package/public/assets/flags/lr.svg +14 -0
  159. package/public/assets/flags/ls.svg +8 -0
  160. package/public/assets/flags/lt.svg +7 -0
  161. package/public/assets/flags/lu.svg +5 -0
  162. package/public/assets/flags/lv.svg +6 -0
  163. package/public/assets/flags/ly.svg +13 -0
  164. package/public/assets/flags/ma.svg +4 -0
  165. package/public/assets/flags/mc.svg +6 -0
  166. package/public/assets/flags/md.svg +70 -0
  167. package/public/assets/flags/me.svg +116 -0
  168. package/public/assets/flags/mf.svg +5 -0
  169. package/public/assets/flags/mg.svg +7 -0
  170. package/public/assets/flags/mh.svg +7 -0
  171. package/public/assets/flags/mk.svg +5 -0
  172. package/public/assets/flags/ml.svg +7 -0
  173. package/public/assets/flags/mm.svg +12 -0
  174. package/public/assets/flags/mn.svg +14 -0
  175. package/public/assets/flags/mo.svg +9 -0
  176. package/public/assets/flags/mp.svg +86 -0
  177. package/public/assets/flags/mq.svg +5 -0
  178. package/public/assets/flags/mr.svg +6 -0
  179. package/public/assets/flags/ms.svg +29 -0
  180. package/public/assets/flags/mt.svg +58 -0
  181. package/public/assets/flags/mu.svg +8 -0
  182. package/public/assets/flags/mv.svg +6 -0
  183. package/public/assets/flags/mw.svg +10 -0
  184. package/public/assets/flags/mx.svg +382 -0
  185. package/public/assets/flags/my.svg +26 -0
  186. package/public/assets/flags/mz.svg +21 -0
  187. package/public/assets/flags/na.svg +16 -0
  188. package/public/assets/flags/nc.svg +13 -0
  189. package/public/assets/flags/ne.svg +6 -0
  190. package/public/assets/flags/nf.svg +9 -0
  191. package/public/assets/flags/ng.svg +6 -0
  192. package/public/assets/flags/ni.svg +129 -0
  193. package/public/assets/flags/nl.svg +5 -0
  194. package/public/assets/flags/no.svg +7 -0
  195. package/public/assets/flags/np.svg +13 -0
  196. package/public/assets/flags/nr.svg +12 -0
  197. package/public/assets/flags/nu.svg +10 -0
  198. package/public/assets/flags/nz.svg +36 -0
  199. package/public/assets/flags/om.svg +115 -0
  200. package/public/assets/flags/pa.svg +14 -0
  201. package/public/assets/flags/pc.svg +33 -0
  202. package/public/assets/flags/pe.svg +4 -0
  203. package/public/assets/flags/pf.svg +19 -0
  204. package/public/assets/flags/pg.svg +9 -0
  205. package/public/assets/flags/ph.svg +6 -0
  206. package/public/assets/flags/pk.svg +15 -0
  207. package/public/assets/flags/pl.svg +6 -0
  208. package/public/assets/flags/pm.svg +5 -0
  209. package/public/assets/flags/pn.svg +53 -0
  210. package/public/assets/flags/pr.svg +13 -0
  211. package/public/assets/flags/ps.svg +6 -0
  212. package/public/assets/flags/pt.svg +57 -0
  213. package/public/assets/flags/pw.svg +11 -0
  214. package/public/assets/flags/py.svg +157 -0
  215. package/public/assets/flags/qa.svg +4 -0
  216. package/public/assets/flags/re.svg +5 -0
  217. package/public/assets/flags/ro.svg +7 -0
  218. package/public/assets/flags/rs.svg +292 -0
  219. package/public/assets/flags/ru.svg +5 -0
  220. package/public/assets/flags/rw.svg +13 -0
  221. package/public/assets/flags/sa.svg +25 -0
  222. package/public/assets/flags/sb.svg +13 -0
  223. package/public/assets/flags/sc.svg +7 -0
  224. package/public/assets/flags/sd.svg +13 -0
  225. package/public/assets/flags/se.svg +4 -0
  226. package/public/assets/flags/sg.svg +13 -0
  227. package/public/assets/flags/sh-ac.svg +689 -0
  228. package/public/assets/flags/sh-hl.svg +164 -0
  229. package/public/assets/flags/sh-ta.svg +76 -0
  230. package/public/assets/flags/sh.svg +7 -0
  231. package/public/assets/flags/si.svg +18 -0
  232. package/public/assets/flags/sj.svg +7 -0
  233. package/public/assets/flags/sk.svg +9 -0
  234. package/public/assets/flags/sl.svg +7 -0
  235. package/public/assets/flags/sm.svg +75 -0
  236. package/public/assets/flags/sn.svg +8 -0
  237. package/public/assets/flags/so.svg +11 -0
  238. package/public/assets/flags/sr.svg +6 -0
  239. package/public/assets/flags/ss.svg +8 -0
  240. package/public/assets/flags/st.svg +16 -0
  241. package/public/assets/flags/sv.svg +593 -0
  242. package/public/assets/flags/sx.svg +56 -0
  243. package/public/assets/flags/sy.svg +6 -0
  244. package/public/assets/flags/sz.svg +34 -0
  245. package/public/assets/flags/tc.svg +50 -0
  246. package/public/assets/flags/td.svg +7 -0
  247. package/public/assets/flags/tf.svg +15 -0
  248. package/public/assets/flags/tg.svg +14 -0
  249. package/public/assets/flags/th.svg +7 -0
  250. package/public/assets/flags/tj.svg +22 -0
  251. package/public/assets/flags/tk.svg +5 -0
  252. package/public/assets/flags/tl.svg +13 -0
  253. package/public/assets/flags/tm.svg +204 -0
  254. package/public/assets/flags/tn.svg +4 -0
  255. package/public/assets/flags/to.svg +10 -0
  256. package/public/assets/flags/tr.svg +8 -0
  257. package/public/assets/flags/tt.svg +5 -0
  258. package/public/assets/flags/tv.svg +9 -0
  259. package/public/assets/flags/tw.svg +34 -0
  260. package/public/assets/flags/tz.svg +13 -0
  261. package/public/assets/flags/ua.svg +6 -0
  262. package/public/assets/flags/ug.svg +30 -0
  263. package/public/assets/flags/um.svg +9 -0
  264. package/public/assets/flags/un.svg +16 -0
  265. package/public/assets/flags/us.svg +9 -0
  266. package/public/assets/flags/uy.svg +28 -0
  267. package/public/assets/flags/uz.svg +30 -0
  268. package/public/assets/flags/va.svg +190 -0
  269. package/public/assets/flags/vc.svg +8 -0
  270. package/public/assets/flags/ve.svg +26 -0
  271. package/public/assets/flags/vg.svg +59 -0
  272. package/public/assets/flags/vi.svg +28 -0
  273. package/public/assets/flags/vn.svg +11 -0
  274. package/public/assets/flags/vu.svg +21 -0
  275. package/public/assets/flags/wf.svg +5 -0
  276. package/public/assets/flags/ws.svg +7 -0
  277. package/public/assets/flags/xk.svg +5 -0
  278. package/public/assets/flags/xx.svg +4 -0
  279. package/public/assets/flags/ye.svg +7 -0
  280. package/public/assets/flags/yt.svg +5 -0
  281. package/public/assets/flags/za.svg +17 -0
  282. package/public/assets/flags/zm.svg +27 -0
  283. package/public/assets/flags/zw.svg +21 -0
  284. package/public/assets/fonts/Inter-OFL.txt +93 -0
  285. package/public/assets/fonts/inter-latin-ext-wght-normal.woff2 +0 -0
  286. package/public/assets/fonts/inter-latin-wght-normal.woff2 +0 -0
  287. package/public/assets/icons.svg +200 -0
  288. package/public/assets/nyx-logo-256.png +0 -0
  289. package/public/assets/tacilytics-mark.svg +16 -0
  290. package/public/index.html +239 -0
  291. package/public/style.css +415 -0
  292. package/public/tacilytics.js +184 -0
  293. package/sdk/flutter/CHANGELOG.md +6 -0
  294. package/sdk/flutter/LICENSE +21 -0
  295. package/sdk/flutter/README.md +41 -0
  296. package/sdk/flutter/dart_test.yaml +3 -0
  297. package/sdk/flutter/lib/nyx_analytics.dart +13 -0
  298. package/sdk/flutter/lib/src/beacon.dart +87 -0
  299. package/sdk/flutter/lib/src/client.dart +177 -0
  300. package/sdk/flutter/lib/tacilytics.dart +13 -0
  301. package/sdk/flutter/pubspec.lock +221 -0
  302. package/sdk/flutter/pubspec.yaml +18 -0
  303. package/sdk/flutter/test/alias_test.dart +13 -0
  304. package/sdk/flutter/test/beacon_test.dart +46 -0
  305. package/sdk/flutter/test/e2e_test.dart +18 -0
  306. package/server.js +896 -0
  307. package/tz-country.js +32 -0
package/.env.example ADDED
@@ -0,0 +1,23 @@
1
+ # ── Tacilytics configuration — copy to .env and fill in ──
2
+ # Port (bound to 127.0.0.1; put a reverse proxy / TLS in front)
3
+ PORT=3920
4
+
5
+ # Dashboard passphrase (plaintext; keep .env at chmod 600)
6
+ WEB_PASSWORD=change-me-to-a-strong-passphrase
7
+
8
+ # Secret for signing session tokens — long random string
9
+ # node -e "console.log(require('crypto').randomBytes(24).toString('hex'))"
10
+ SESSION_SECRET=change-me
11
+
12
+ # Salt for the daily one-way visitor hash (rotate to reset all visitor identities)
13
+ # node -e "console.log(require('crypto').randomBytes(24).toString('hex'))"
14
+ INGEST_SALT=change-me
15
+
16
+ # SQLite database path
17
+ DB_PATH=./data/analytics.db
18
+
19
+ # API key for agents / CLI (Bearer token) — generate a long random string
20
+ API_KEY=change-me
21
+
22
+ # Data minimisation: auto-purge raw events older than N days (0 = keep forever)
23
+ DATA_RETENTION_DAYS=0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nyx & Fabian Budde
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,254 @@
1
- # Temporary Holding Version
1
+ <div align="center">
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ <img src="public/assets/tacilytics-mark.svg" width="96" alt="Tacilytics" />
4
+
5
+ # Tacilytics
6
+
7
+ **Privacy-first, cookieless analytics for websites and apps — self-hosted in minutes.**
8
+ *Tacit* (adj.): understood without being said. Tacilytics counts what matters and keeps quiet about who did it — one tiny script, one Node process, one SQLite file. No cookies, no consent banner, no creepy tracking. (And in Italian, *taci* simply means "be quiet".)
9
+
10
+ > Formerly **Nyx Analytics**. Existing installs keep working: `/nyx.js`, `window.nyx()` and the old Flutter class names are still supported as aliases.
11
+
12
+ [![License: MIT](https://img.shields.io/badge/License-MIT-a855f7.svg)](LICENSE)
13
+ ![Cookieless](https://img.shields.io/badge/cookies-zero-e879a8)
14
+ ![No tracking](https://img.shields.io/badge/IPs%20stored-never-67e8f9)
15
+ ![Node](https://img.shields.io/badge/node-%3E%3D18-4ade80)
16
+
17
+ <img src="docs/dashboard.png" width="820" alt="Tacilytics dashboard" />
18
+
19
+ </div>
20
+
21
+ ---
22
+
23
+ ## Why Tacilytics
24
+
25
+ - 🍪 **Cookieless & banner-free.** No cookies, no `localStorage`, no fingerprinting — nothing to consent to.
26
+ - 🛡️ **IPs are never stored.** Unique visitors are counted with a *daily, salted, one-way hash* that’s irreversible and rotated every day.
27
+ - 🌍 **Region from the browser timezone**, never from IP geolocation.
28
+ - 🔍 **Segment everything.** Click any country, page, source, browser or campaign to filter the whole dashboard — stacked, with one-click chips.
29
+ - 🚦 **Campaigns & funnels.** Full UTM (`source` / `medium` / `campaign`), entry & exit pages, **goal conversion rates** and **custom event properties**.
30
+ - ⏱️ **Engagement time** — average time-on-page, measured privately (visible seconds only, no extra identifier).
31
+ - 🤖 **Bots & crawlers dropped** automatically, so your numbers stay honest.
32
+ - ⚡ **~2 KB tracker**, SPA-aware (tracks `pushState` / `popstate` route changes), optional **DNT / Global Privacy Control** honoring.
33
+ - 🗓️ **Data minimisation built in** — optional auto-purge of raw events after a retention window you choose.
34
+ - 🎯 **Custom events & goals**, **live visitors**, **trends**, **custom date ranges**, **CSV export**.
35
+ - 🛰️ **Agent-ready:** a clean `/api/v1` REST API (with segmentation params) + a `tacilytics` CLI (with `--json`).
36
+ - 🗄️ **Zero infra:** Express + SQLite. No Redis, no ClickHouse, no cloud.
37
+
38
+ ## Quick start
39
+
40
+ **With npm** (fastest):
41
+
42
+ ```bash
43
+ mkdir my-analytics && cd my-analytics
44
+ npx tacilytics serve # http://127.0.0.1:3920 — data/ and .env live in this folder
45
+ ```
46
+
47
+ Or install it globally: `npm i -g tacilytics`, then `tacilytics serve`.
48
+ Set your own `WEB_PASSWORD`, `SESSION_SECRET`, `INGEST_SALT` and `API_KEY` in a `.env` file in that folder (see [`.env.example`](.env.example)).
49
+
50
+ **From source:**
51
+
52
+ ```bash
53
+ git clone https://github.com/Open-Cosmic-Software/tacilytics.git
54
+ cd tacilytics
55
+ npm install
56
+ cp .env.example .env # set WEB_PASSWORD, SESSION_SECRET, INGEST_SALT, API_KEY
57
+ npm start # http://127.0.0.1:3920
58
+ ```
59
+
60
+ The server binds to `127.0.0.1` — put TLS in front. Example **Caddy**:
61
+
62
+ ```
63
+ analytics.example.com {
64
+ encode gzip
65
+ reverse_proxy 127.0.0.1:3920
66
+ }
67
+ ```
68
+
69
+ Open the dashboard, log in, click **+ Add site**, and drop the snippet into your `<head>`:
70
+
71
+ ```html
72
+ <script defer data-domain="example.com" src="https://analytics.example.com/tacilytics.js"></script>
73
+ ```
74
+
75
+ That’s it. Pageviews start flowing in — no cookie banner required.
76
+
77
+ ## Custom events, goals & properties
78
+
79
+ Track conversions from anywhere in your code — optionally with **properties** to break them down:
80
+
81
+ ```js
82
+ tacilytics('Signup');
83
+ tacilytics('Upgrade', { plan: 'pro' });
84
+ tacilytics('Purchase', { plan: 'pro', amount: '49' });
85
+ ```
86
+
87
+ Goals show up in the **Goals & conversions** panel with a **conversion rate** (% of visitors). Click a goal to drill into it — the whole dashboard re-segments to converters, and each property’s top values appear as their own breakdown. Pageviews stay separate, so your view counts stay clean.
88
+
89
+ ## Segment everything
90
+
91
+ Click any row — a country, page, source, browser, OS, device, language or campaign — to **filter the entire dashboard** to that segment. Filters stack and show as removable chips. The same segmentation is available over the API and CLI:
92
+
93
+ ```bash
94
+ tacilytics stats example.com 30d --country=DE --source=twitter.com
95
+ curl -H "Authorization: Bearer $TACILYTICS_API_KEY" \
96
+ "https://analytics.example.com/api/v1/stats?site=example.com&period=30d&country=DE&medium=email"
97
+ ```
98
+
99
+ ## Campaigns, entry/exit & engagement
100
+
101
+ - **UTM campaigns** — `utm_source` / `utm_medium` / `utm_campaign` are captured automatically; switch the Sources panel between **Source / Medium / Campaign**.
102
+ - **Entry & exit pages** — switch the Pages panel between **Top / Entry / Exit** to see where visits start and end.
103
+ - **Engagement time** — the tracker reports how long each page was actually *visible* (a follow-up beacon, no new identifier), surfaced as **Avg. visit time**.
104
+ - **Custom date range** — pick any `from`/`to` window in the toolbar, or pass `--from`/`--to` to the API/CLI.
105
+
106
+ ## Privacy controls on the tag
107
+
108
+ Opt-in flags on the `<script>` tag:
109
+
110
+ ```html
111
+ <script defer data-domain="example.com"
112
+ data-honor-dnt <!-- skip tracking if DNT / Global Privacy Control is on -->
113
+ data-track-outbound <!-- auto-record clicks to other domains as events -->
114
+ src="https://analytics.example.com/tacilytics.js"></script>
115
+ ```
116
+
117
+ ## Mobile & desktop apps
118
+
119
+ Track your **native app in the same dashboard as your website**. Every row gets a platform
120
+ (`web`, `android`, `ios`, `macos`, `windows`, `linux`), so you can see everything together or
121
+ click a platform to filter. Once an app reports in, you get a **Platforms** panel (plus app
122
+ versions) and **Revenue by platform**.
123
+
124
+ **Flutter** — a ready-made package lives in [`sdk/flutter`](sdk/flutter):
125
+
126
+ ```dart
127
+ Tacilytics.init(endpoint: 'https://analytics.example.com', domain: 'example.com', appVersion: '1.4.0');
128
+ Tacilytics.instance.setEnabled(userConsented); // off until you say so — wire it to your consent
129
+ MaterialApp(navigatorObservers: [TacilyticsRouteObserver()]); // automatic screen views
130
+ Tacilytics.instance.search('vinegar', 6);
131
+ Tacilytics.instance.purchase(value: 49.90, currency: 'EUR', items: 2);
132
+ ```
133
+
134
+ **Any other stack** (Swift, Kotlin, React Native, …) — it is one JSON `POST /api/collect`:
135
+
136
+ | field | meaning |
137
+ |---|---|
138
+ | `d` | site domain (as registered) |
139
+ | `p` | screen, written like a path (`/product/42`) |
140
+ | `pf` | platform: `android` · `ios` · `macos` · `windows` · `linux` |
141
+ | `sid` | random id drawn **per app launch**, kept in memory only (16–64 chars `A-Za-z0-9_-`) |
142
+ | `av` | app version (optional) |
143
+ | `dv` | `mobile` · `tablet` · `desktop` (optional) |
144
+ | `l` / `cc` | language tag / 2-letter region of the device locale (optional) |
145
+ | `n` / `pr` | event name + flat properties, same as the web `tacilytics()` call (optional) |
146
+ | `s` / `um` / `uc` | utm source / medium / campaign, e.g. from a deep link (optional) |
147
+
148
+ Send any non-empty, non-bot `User-Agent` (e.g. `MyApp/1.4 (android)`).
149
+
150
+ Privacy stays the same as on the web: **no device or advertising id, no cookies, no IP stored.**
151
+ The server hashes the launch id with a random daily salt that is deleted when the day ends, so a visitor is never followed across days.
152
+ Without `sid`, the usual IP + user-agent daily hash is used. Region comes from the timezone or
153
+ device locale, never from IP geolocation.
154
+
155
+ ## Live visitors
156
+
157
+ Click **“● online now”** for a realtime view: a 30-minute pulse and a live feed of
158
+ who’s on the site right now — page, goal, country, browser/device and source —
159
+ auto-refreshing every few seconds.
160
+
161
+ <div align="center"><img src="docs/live.png" width="560" alt="Live visitors" /></div>
162
+
163
+ ## Agent API
164
+
165
+ Everything the dashboard shows is available over a stable REST API, authenticated with a Bearer key:
166
+
167
+ ```bash
168
+ curl -H "Authorization: Bearer $TACILYTICS_API_KEY" \
169
+ "https://analytics.example.com/api/v1/stats?site=example.com&period=30d"
170
+ ```
171
+
172
+ | Method & path | Description |
173
+ |---|---|
174
+ | `GET /api/v1` | API discovery |
175
+ | `GET /api/v1/sites` | list tracked sites |
176
+ | `POST /api/v1/sites` | add a site `{ domain }` |
177
+ | `GET /api/v1/stats?site=&period=` | aggregated stats — `today \| 7d \| 30d \| 90d \| 12mo`, or `from=YYYY-MM-DD&to=YYYY-MM-DD` |
178
+ | `GET /api/v1/realtime?site=` | online count, 30-min pulse + recent events |
179
+
180
+ Add any of `country, source, page, browser, os, device, lang, medium, campaign, goal` to `/stats` to segment, e.g. `…/stats?site=example.com&period=30d&country=DE&medium=email`.
181
+
182
+ ## CLI
183
+
184
+ ```bash
185
+ export TACILYTICS_URL=https://analytics.example.com
186
+ export TACILYTICS_API_KEY=xxx
187
+
188
+ tacilytics sites
189
+ tacilytics stats example.com 30d
190
+ tacilytics live example.com --watch
191
+ tacilytics add example.com
192
+ ```
193
+
194
+ Add `--json` to any command for machine-readable output — perfect for agents and scripts.
195
+
196
+ ## How it works
197
+
198
+ `tacilytics.js` sends a tiny beacon (`navigator.sendBeacon`, `text/plain` so the cross-origin
199
+ request stays preflight-free) with the path, referrer, timezone and language — no
200
+ personal data, no cookies. The server parses the User-Agent (browser/OS/device),
201
+ drops bots, maps timezone → country, derives the **daily visitor hash**, and stores one
202
+ row per pageview. The dashboard aggregates it with plain SQL.
203
+
204
+ **The cookieless trade-off, honestly:** because there’s no persistent identifier, Tacilytics
205
+ counts unique visitors *per day* and can’t follow the same person across days or
206
+ devices. You get the 95 % that matters (how many, which pages, from where) with 0 %
207
+ cookie banner — the same approach as Plausible/Fathom.
208
+
209
+ ## Configuration
210
+
211
+ | Env var | Default | Purpose |
212
+ |---|---|---|
213
+ | `PORT` | `3920` | Port (bound to 127.0.0.1) |
214
+ | `WEB_PASSWORD` | `tacilytics` | Dashboard passphrase |
215
+ | `SESSION_SECRET` | random | Session-token secret |
216
+ | `INGEST_SALT` | fixed | Extra server-side secret mixed into the visitor hash (on top of the random daily salt) |
217
+ | `API_KEY` | — | Bearer key for the API / CLI |
218
+ | `DB_PATH` | `./data/analytics.db` | SQLite path |
219
+ | `DATA_RETENTION_DAYS` | `0` | `0` = keep forever; `>0` purges raw events older than N days, daily (data minimisation) |
220
+
221
+ ## Privacy & security
222
+
223
+ - No cookies, no cross-site tracking, **IPs never persisted**.
224
+ - Daily one-way visitor hash with a **random salt per day**. It lives in its own small file (`data/.day-salt`, mode 600), never in the database, and is overwritten in place when the next day starts. Past days can't be re-linked afterwards, not even with the database, a known IP or user agent, or the server's own secrets. Only the server clock picks the day; late or out-of-day events get an unlinkable one-off hash.
225
+ - **Honest limits of "overwritten":** on ext4 an in-place overwrite really replaces the bytes. On copy-on-write filesystems (btrfs, ZFS), in filesystem or VM snapshots, and on SSDs (wear levelling) the old block can physically survive. **Backups of `data/` contain the salt of the day they were taken**, so treat them like the live server or exclude `data/.day-salt` from them.
226
+ - **One process only.** The salt is drawn by the single Node process. Running several processes against the same `data/` would let each draw its own salt at midnight.
227
+ - **Data minimisation:** set `DATA_RETENTION_DAYS` to auto-purge raw events after your chosen window.
228
+ - **Respects DNT / Global Privacy Control** when you add `data-honor-dnt` to the tag.
229
+ - Engagement time adds **no new identifier** — it’s matched to the visitor’s own daily hash server-side.
230
+ - Custom event properties are capped (scalars only, size-limited) so they can’t become a PII dumping ground.
231
+ - Timing-safe auth; login and the collect endpoint are rate-limited.
232
+ - All visitor-supplied values are HTML-escaped before they reach the DOM.
233
+ - Run behind TLS; keep `.env` at `chmod 600`.
234
+
235
+ ## Tacilytics vs. Google Analytics
236
+
237
+ | | Tacilytics | Google Analytics |
238
+ |---|---|---|
239
+ | Cookies / consent banner | ❌ none | ✅ required |
240
+ | Stores IPs / personal data | ❌ never | ✅ yes |
241
+ | Self-hosted, you own the data | ✅ | ❌ |
242
+ | Weight | ~2 KB | ~45 KB+ |
243
+ | Setup | 1 script + 1 process | tag manager, config… |
244
+
245
+ ## Credits
246
+
247
+ - UI icons: [Lucide](https://lucide.dev) (ISC)
248
+ - Brand marks: [Simple Icons](https://simpleicons.org) (CC0). Brand names and logos are trademarks of their owners; see `public/assets/DISCLAIMER-simple-icons.md`. Windows and Edge have no free official mark, so a neutral icon is shown.
249
+ - Flags: [flag-icons](https://flagicons.lipis.dev) (MIT)
250
+ - Font: [Inter](https://rsms.me/inter/) (SIL OFL 1.1), self-hosted
251
+
252
+ ## License
253
+
254
+ [MIT](LICENSE) — built by **Nyx & Fabian** 🦞
package/cli.js ADDED
@@ -0,0 +1,173 @@
1
+ #!/usr/bin/env node
2
+ /* Tacilytics CLI — query your analytics from the terminal or an agent.
3
+ *
4
+ * export TACILYTICS_URL=https://analytics.example.com
5
+ * export TACILYTICS_API_KEY=xxx
6
+ * tacilytics serve (start the server; data/ and .env in the current directory)
7
+ * tacilytics serve start the server (data/ and .env in the current folder)
8
+ * tacilytics sites
9
+ * tacilytics stats example.com 30d
10
+ * tacilytics live example.com --watch
11
+ * tacilytics add example.com
12
+ *
13
+ * NYX_URL / NYX_API_KEY are still read for setups from before the rename.
14
+ *
15
+ * Add --json to any command for raw machine-readable output (for agents).
16
+ */
17
+ 'use strict';
18
+
19
+ const URL_BASE = (process.env.TACILYTICS_URL || process.env.NYX_URL || 'http://localhost:3920').replace(/\/$/, '');
20
+ const KEY = process.env.TACILYTICS_API_KEY || process.env.NYX_API_KEY || '';
21
+ const args = process.argv.slice(2);
22
+ const JSON_OUT = args.includes('--json');
23
+ const WATCH = args.includes('--watch');
24
+ const pos = args.filter((a) => !a.startsWith('--'));
25
+ const cmd = pos[0];
26
+ // --key=value flags → extra query params (filters: country,source,page,browser,os,device,lang,medium,campaign,goal · range: from,to)
27
+ const flags = {};
28
+ args.filter((a) => a.startsWith('--') && a.includes('=')).forEach((a) => { const i = a.indexOf('='); flags[a.slice(2, i)] = a.slice(i + 1); });
29
+ function extraQuery() { return Object.keys(flags).filter((k) => k !== 'json' && k !== 'watch').map((k) => '&' + encodeURIComponent(k) + '=' + encodeURIComponent(flags[k])).join(''); }
30
+ const fmtDur = (s) => (s == null ? '—' : s < 60 ? s + 's' : Math.floor(s / 60) + 'm' + (s % 60 ? ' ' + (s % 60) + 's' : ''));
31
+
32
+ // ── tiny ANSI helpers ──
33
+ const C = process.stdout.isTTY && !JSON_OUT;
34
+ const c = (n, s) => (C ? `\x1b[${n}m${s}\x1b[0m` : s);
35
+ const pink = (s) => c('38;5;211', s), purple = (s) => c('38;5;141', s), cyan = (s) => c('38;5;87', s);
36
+ const dim = (s) => c('2', s), bold = (s) => c('1', s), green = (s) => c('38;5;120', s), red = (s) => c('38;5;210', s);
37
+
38
+ function die(msg) { console.error(red('✖ ' + msg)); process.exit(1); }
39
+
40
+ async function api(path) {
41
+ if (!KEY) die('Set TACILYTICS_API_KEY (and optionally TACILYTICS_URL).');
42
+ const res = await fetch(URL_BASE + path, { headers: { Authorization: 'Bearer ' + KEY } });
43
+ if (res.status === 401) die('Unauthorized — check TACILYTICS_API_KEY.');
44
+ const j = await res.json().catch(() => ({}));
45
+ if (!res.ok) die(j.error || ('HTTP ' + res.status));
46
+ return j;
47
+ }
48
+ async function post(path, body) {
49
+ if (!KEY) die('Set TACILYTICS_API_KEY (and optionally TACILYTICS_URL).');
50
+ const res = await fetch(URL_BASE + path, { method: 'POST', headers: { Authorization: 'Bearer ' + KEY, 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
51
+ const j = await res.json().catch(() => ({}));
52
+ if (!res.ok) die(j.error || ('HTTP ' + res.status));
53
+ return j;
54
+ }
55
+
56
+ const flag = (cc) => (cc && cc.length === 2 ? cc.toUpperCase().replace(/./g, (x) => String.fromCodePoint(127397 + x.charCodeAt(0))) : '🌐');
57
+ const pad = (s, n) => (String(s) + ' '.repeat(n)).slice(0, n);
58
+ function bar(rows, key) {
59
+ if (!rows || !rows.length) return ' ' + dim('(none)');
60
+ const max = Math.max.apply(null, rows.map((r) => r[key] || 0)) || 1;
61
+ return rows.map((r) => {
62
+ const w = Math.round(((r[key] || 0) / max) * 22);
63
+ return ' ' + pad(r.label || r.name, 30) + ' ' + purple('▇'.repeat(w) || '▏') + ' ' + bold(r[key]);
64
+ }).join('\n');
65
+ }
66
+
67
+ function banner(t) { if (!JSON_OUT) console.log('\n' + pink('🦞 ') + bold(t)); }
68
+
69
+ async function cmdSites() {
70
+ const sites = await api('/api/v1/sites');
71
+ if (JSON_OUT) return console.log(JSON.stringify(sites, null, 2));
72
+ banner('Sites');
73
+ if (!sites.length) return console.log(' ' + dim('No sites yet. Add one: tacilytics add example.com'));
74
+ sites.forEach((s) => console.log(' ' + cyan(pad(s.domain, 28)) + dim(' id ' + s.id)));
75
+ }
76
+
77
+ async function cmdStats() {
78
+ const domain = pos[1] || die('Usage: tacilytics stats <domain> [period] [--country=DE] [--from=YYYY-MM-DD --to=YYYY-MM-DD]');
79
+ const period = pos[2] || '7d';
80
+ const range = flags.from && flags.to ? '' : `&period=${period}`;
81
+ const s = await api(`/api/v1/stats?site=${encodeURIComponent(domain)}${range}${extraQuery()}`);
82
+ if (JSON_OUT) return console.log(JSON.stringify(s, null, 2));
83
+ const t = s.totals, tr = s.trend || {};
84
+ const trend = (v) => (v == null ? '' : ' ' + (v >= 0 ? green('▲' + v + '%') : red('▼' + Math.abs(v) + '%')));
85
+ const label = s.range ? `${s.range.from} → ${s.range.to}` : period;
86
+ banner(`${domain} · ${label}`);
87
+ const active = Object.keys(s.filters || {});
88
+ if (active.length) console.log(' ' + dim('filtered by ' + active.map((k) => k + '=' + s.filters[k]).join(', ')));
89
+ console.log(' ' + pad('Visitors', 16) + bold(t.visitors) + trend(tr.visitors));
90
+ console.log(' ' + pad('Pageviews', 16) + bold(t.pageviews) + trend(tr.pageviews));
91
+ console.log(' ' + pad('Bounce', 16) + bold(t.bounce + '%'));
92
+ console.log(' ' + pad('Views/visitor', 16) + bold(t.viewsPerVisitor));
93
+ console.log(' ' + pad('Avg. visit time', 16) + bold(fmtDur(t.duration)));
94
+ console.log('\n' + dim(' Top pages')); console.log(bar(s.pages, 'visitors'));
95
+ if (s.entryPages && s.entryPages.length) { console.log('\n' + dim(' Entry pages')); console.log(bar(s.entryPages, 'visitors')); }
96
+ console.log('\n' + dim(' Sources')); console.log(bar(s.sources, 'visitors'));
97
+ if (s.campaigns && s.campaigns.length) { console.log('\n' + dim(' Campaigns')); console.log(bar(s.campaigns, 'visitors')); }
98
+ console.log('\n' + dim(' Locations')); console.log(bar((s.countries || []).map((r) => ({ name: flag(r.name) + ' ' + r.name, visitors: r.visitors })), 'visitors'));
99
+ if (s.goals && s.goals.length) {
100
+ console.log('\n' + dim(' Goals ' + dim('(conversion)')));
101
+ console.log(bar(s.goals.map((g) => ({ name: g.name + dim(' ' + (g.cr != null ? g.cr + '%' : '')), visitors: g.visitors })), 'visitors'));
102
+ }
103
+ }
104
+
105
+ async function renderLive(domain) {
106
+ const r = await api(`/api/v1/realtime?site=${encodeURIComponent(domain)}`);
107
+ if (JSON_OUT) return console.log(JSON.stringify(r, null, 2));
108
+ if (WATCH) process.stdout.write('\x1b[2J\x1b[H'); // clear
109
+ banner(`Live · ${domain}`);
110
+ console.log(' ' + green('● ') + bold(r.online) + ' online now' + dim(' (last 5 min)'));
111
+ const spark = '▁▂▃▄▅▆▇█';
112
+ const max = Math.max.apply(null, r.minutes.map((m) => m.pageviews).concat([1]));
113
+ const line = r.minutes.map((m) => (m.pageviews ? spark[Math.min(7, Math.round((m.pageviews / max) * 7))] : dim('·'))).join('');
114
+ console.log(' ' + purple(line) + dim(' ← 30 min'));
115
+ console.log('\n' + dim(' Recent activity'));
116
+ if (!r.recent.length) return console.log(' ' + dim('(quiet)'));
117
+ r.recent.slice(0, 20).forEach((e) => {
118
+ const ago = Math.round((r.now - e.ts) / 1000);
119
+ const t = ago < 8 ? 'now' : ago < 60 ? ago + 's' : Math.round(ago / 60) + 'm';
120
+ const what = e.name ? pink('🎯 ' + e.name) : e.path;
121
+ const src = e.source && e.source !== 'Direct' ? dim(' ↗ ' + e.source) : '';
122
+ console.log(' ' + flag(e.country) + ' ' + pad(what, 32) + ' ' + dim(pad((e.browser || '') + ' · ' + (e.device || ''), 20)) + src + dim(' ' + t));
123
+ });
124
+ }
125
+ async function cmdLive() {
126
+ const domain = pos[1] || die('Usage: tacilytics live <domain> [--watch]');
127
+ await renderLive(domain);
128
+ if (WATCH && !JSON_OUT) setInterval(() => renderLive(domain).catch(() => {}), 5000);
129
+ }
130
+
131
+ async function cmdAdd() {
132
+ const domain = pos[1] || die('Usage: tacilytics add <domain>');
133
+ const s = await post('/api/v1/sites', { domain });
134
+ if (JSON_OUT) return console.log(JSON.stringify(s, null, 2));
135
+ banner('Site added: ' + s.domain);
136
+ console.log('\n Embed this in your site <head>:\n');
137
+ console.log(' ' + cyan(`<script defer data-domain="${s.domain}" src="${URL_BASE}/tacilytics.js"></script>`));
138
+ }
139
+
140
+ function help() {
141
+ console.log(`
142
+ ${pink('Tacilytics CLI')}
143
+
144
+ ${bold('Usage')} tacilytics <command> [--json] [--watch]
145
+
146
+ ${cyan('serve')} start the server (data/ + .env in the current folder)
147
+ ${cyan('sites')} list tracked sites
148
+ ${cyan('stats')} <domain> [period] key metrics + breakdowns (today|7d|30d|90d|12mo)
149
+ ${cyan('live')} <domain> [--watch] online visitors + last-30-min feed
150
+ ${cyan('add')} <domain> add a site, print the embed snippet
151
+
152
+ ${bold('Segment & range')} (on stats)
153
+ ${dim('--country=DE --source=… --page=… --browser=… --medium=… --campaign=… --goal=…')}
154
+ ${dim('--from=2026-01-01 --to=2026-01-31')} custom date range
155
+
156
+ ${bold('Config')} (env)
157
+ TACILYTICS_URL ${dim(URL_BASE)}
158
+ TACILYTICS_API_KEY ${KEY ? green('set') : red('not set')}
159
+
160
+ ${dim('Add --json for machine-readable output (agents).')}
161
+ `);
162
+ }
163
+
164
+ (async () => {
165
+ try {
166
+ if (cmd === 'serve' || cmd === 'start') { require('./server.js'); return; }
167
+ if (cmd === 'sites') await cmdSites();
168
+ else if (cmd === 'stats') await cmdStats();
169
+ else if (cmd === 'live' || cmd === 'realtime') await cmdLive();
170
+ else if (cmd === 'add') await cmdAdd();
171
+ else help();
172
+ } catch (e) { die(e.message || String(e)); }
173
+ })();
package/day-salt.js ADDED
@@ -0,0 +1,55 @@
1
+ 'use strict';
2
+ // Daily salt (13.10.2026, hardened after two reviews by Tyto 🦉): a fresh random secret per
3
+ // UTC day, kept in its own small file, never in the database. See README "Privacy & security".
4
+ //
5
+ // Rules:
6
+ // - Only the server clock decides the day. A salt is only ever created for *today*.
7
+ // - A file with today's date but an invalid salt (e.g. truncated by a crash) is repaired by
8
+ // drawing a new salt. Otherwise every visitor would get a one-off hash until midnight.
9
+ // - Only a file from a *later* day than our clock (clock went backwards) is left alone: then
10
+ // we return null and the caller uses an unlinkable one-off hash.
11
+ // - Single process only. nyx-analytics runs one Node process (one app.listen, no cluster).
12
+ // With several processes, each would draw its own salt at midnight and the last writer
13
+ // would win; that would need an O_EXCL create + re-read instead.
14
+ const fs = require('fs');
15
+ const crypto = require('crypto');
16
+
17
+ const SIZE = 128; // fixed size → the old value is fully overwritten in place
18
+ const VALID = /^[0-9a-f]{64}$/;
19
+
20
+ function dayStr(ts) { return new Date(ts).toISOString().slice(0, 10); }
21
+
22
+ function createDaySalt(file, now = Date.now) {
23
+ let cache = { day: null, salt: null };
24
+
25
+ function writeInPlace(content) {
26
+ const buf = Buffer.alloc(SIZE, 0); buf.write(content);
27
+ const fd = fs.openSync(file, fs.existsSync(file) ? 'r+' : 'w', 0o600);
28
+ try { fs.fchmodSync(fd, 0o600); fs.writeSync(fd, buf, 0, buf.length, 0); fs.fsyncSync(fd); }
29
+ finally { fs.closeSync(fd); }
30
+ }
31
+
32
+ function read() {
33
+ try { return fs.readFileSync(file, 'utf8').replace(/\0+$/, '').split(' '); } catch { return null; }
34
+ }
35
+
36
+ return function todaysSalt() {
37
+ const today = dayStr(now());
38
+ if (cache.day === today) return cache.salt;
39
+ const stored = read();
40
+ const storedDay = stored && stored[0];
41
+ if (storedDay === today && VALID.test(stored[1] || '')) {
42
+ cache = { day: today, salt: stored[1] };
43
+ } else if (!storedDay || storedDay <= today) {
44
+ // missing, older day, or today's date with a broken salt → draw a new one
45
+ const salt = crypto.randomBytes(32).toString('hex');
46
+ writeInPlace(`${today} ${salt}`);
47
+ cache = { day: today, salt };
48
+ } else {
49
+ return null; // file is from a later day than our clock: never overwrite a newer salt
50
+ }
51
+ return cache.salt;
52
+ };
53
+ }
54
+
55
+ module.exports = { createDaySalt, dayStr };
package/package.json CHANGED
@@ -1,6 +1,58 @@
1
1
  {
2
2
  "name": "tacilytics",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "2.0.2",
4
+ "description": "Privacy-first, cookieless analytics for websites and apps. Self-hosted: one Node process, one SQLite file.",
5
+ "main": "server.js",
6
+ "type": "commonjs",
7
+ "scripts": {
8
+ "start": "node server.js",
9
+ "cli": "node cli.js",
10
+ "test": "node --test test/*.test.js"
11
+ },
12
+ "author": "Nyx 🦞 & Fabian Budde",
13
+ "license": "MIT",
14
+ "dependencies": {
15
+ "better-sqlite3": "^12.10.0",
16
+ "express": "^4.21.2"
17
+ },
18
+ "bin": {
19
+ "tacilytics": "cli.js",
20
+ "nyx": "cli.js"
21
+ },
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "https://github.com/Open-Cosmic-Software/tacilytics.git"
25
+ },
26
+ "homepage": "https://github.com/Open-Cosmic-Software/tacilytics#readme",
27
+ "keywords": [
28
+ "analytics",
29
+ "privacy",
30
+ "cookieless",
31
+ "gdpr",
32
+ "self-hosted",
33
+ "web-analytics",
34
+ "sqlite",
35
+ "plausible-alternative",
36
+ "flutter",
37
+ "app-analytics"
38
+ ],
39
+ "bugs": {
40
+ "url": "https://github.com/Open-Cosmic-Software/tacilytics/issues"
41
+ },
42
+ "engines": {
43
+ "node": ">=18"
44
+ },
45
+ "files": [
46
+ "server.js",
47
+ "cli.js",
48
+ "day-salt.js",
49
+ "tz-country.js",
50
+ "public/",
51
+ "sdk/flutter/",
52
+ "!sdk/flutter/.dart_tool/",
53
+ "!sdk/flutter/build/",
54
+ ".env.example",
55
+ "LICENSE",
56
+ "README.md"
57
+ ]
58
+ }