this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Move to hono

alice 202b16a8 6d81e183

+438 -127
+269 -2
package-lock.json
··· 8 8 "name": "budget-edge", 9 9 "version": "0.0.0", 10 10 "dependencies": { 11 + "google-auth-library": "^9.15.1", 12 + "hono": "^4.4.13", 11 13 "jose": "^6.0.11" 12 14 }, 13 15 "devDependencies": { ··· 1516 1518 "node": ">=0.4.0" 1517 1519 } 1518 1520 }, 1521 + "node_modules/agent-base": { 1522 + "version": "7.1.3", 1523 + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", 1524 + "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==", 1525 + "license": "MIT", 1526 + "engines": { 1527 + "node": ">= 14" 1528 + } 1529 + }, 1519 1530 "node_modules/as-table": { 1520 1531 "version": "1.0.55", 1521 1532 "resolved": "https://registry.npmjs.org/as-table/-/as-table-1.0.55.tgz", ··· 1536 1547 "node": ">=12" 1537 1548 } 1538 1549 }, 1550 + "node_modules/base64-js": { 1551 + "version": "1.5.1", 1552 + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", 1553 + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", 1554 + "funding": [ 1555 + { 1556 + "type": "github", 1557 + "url": "https://github.com/sponsors/feross" 1558 + }, 1559 + { 1560 + "type": "patreon", 1561 + "url": "https://www.patreon.com/feross" 1562 + }, 1563 + { 1564 + "type": "consulting", 1565 + "url": "https://feross.org/support" 1566 + } 1567 + ], 1568 + "license": "MIT" 1569 + }, 1570 + "node_modules/bignumber.js": { 1571 + "version": "9.3.0", 1572 + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.0.tgz", 1573 + "integrity": "sha512-EM7aMFTXbptt/wZdMlBv2t8IViwQL+h6SLHosp8Yf0dqJMTnY6iL32opnAB6kAdL0SZPuvcAzFr31o0c/R3/RA==", 1574 + "license": "MIT", 1575 + "engines": { 1576 + "node": "*" 1577 + } 1578 + }, 1539 1579 "node_modules/birpc": { 1540 1580 "version": "0.2.14", 1541 1581 "resolved": "https://registry.npmjs.org/birpc/-/birpc-0.2.14.tgz", ··· 1552 1592 "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", 1553 1593 "dev": true, 1554 1594 "license": "MIT" 1595 + }, 1596 + "node_modules/buffer-equal-constant-time": { 1597 + "version": "1.0.1", 1598 + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", 1599 + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", 1600 + "license": "BSD-3-Clause" 1555 1601 }, 1556 1602 "node_modules/cac": { 1557 1603 "version": "6.7.14", ··· 1667 1713 "version": "4.4.1", 1668 1714 "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", 1669 1715 "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", 1670 - "dev": true, 1671 1716 "license": "MIT", 1672 1717 "dependencies": { 1673 1718 "ms": "^2.1.3" ··· 1716 1761 "dev": true, 1717 1762 "license": "MIT" 1718 1763 }, 1764 + "node_modules/ecdsa-sig-formatter": { 1765 + "version": "1.0.11", 1766 + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", 1767 + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", 1768 + "license": "Apache-2.0", 1769 + "dependencies": { 1770 + "safe-buffer": "^5.0.1" 1771 + } 1772 + }, 1719 1773 "node_modules/es-module-lexer": { 1720 1774 "version": "1.7.0", 1721 1775 "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", ··· 1804 1858 "dev": true, 1805 1859 "license": "MIT" 1806 1860 }, 1861 + "node_modules/extend": { 1862 + "version": "3.0.2", 1863 + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", 1864 + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", 1865 + "license": "MIT" 1866 + }, 1807 1867 "node_modules/fdir": { 1808 1868 "version": "6.4.4", 1809 1869 "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz", ··· 1834 1894 "node": "^8.16.0 || ^10.6.0 || >=11.0.0" 1835 1895 } 1836 1896 }, 1897 + "node_modules/gaxios": { 1898 + "version": "6.7.1", 1899 + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz", 1900 + "integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==", 1901 + "license": "Apache-2.0", 1902 + "dependencies": { 1903 + "extend": "^3.0.2", 1904 + "https-proxy-agent": "^7.0.1", 1905 + "is-stream": "^2.0.0", 1906 + "node-fetch": "^2.6.9", 1907 + "uuid": "^9.0.1" 1908 + }, 1909 + "engines": { 1910 + "node": ">=14" 1911 + } 1912 + }, 1913 + "node_modules/gcp-metadata": { 1914 + "version": "6.1.1", 1915 + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz", 1916 + "integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==", 1917 + "license": "Apache-2.0", 1918 + "dependencies": { 1919 + "gaxios": "^6.1.1", 1920 + "google-logging-utils": "^0.0.2", 1921 + "json-bigint": "^1.0.0" 1922 + }, 1923 + "engines": { 1924 + "node": ">=14" 1925 + } 1926 + }, 1837 1927 "node_modules/get-source": { 1838 1928 "version": "2.0.12", 1839 1929 "resolved": "https://registry.npmjs.org/get-source/-/get-source-2.0.12.tgz", ··· 1852 1942 "dev": true, 1853 1943 "license": "BSD-2-Clause" 1854 1944 }, 1945 + "node_modules/google-auth-library": { 1946 + "version": "9.15.1", 1947 + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz", 1948 + "integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==", 1949 + "license": "Apache-2.0", 1950 + "dependencies": { 1951 + "base64-js": "^1.3.0", 1952 + "ecdsa-sig-formatter": "^1.0.11", 1953 + "gaxios": "^6.1.1", 1954 + "gcp-metadata": "^6.1.0", 1955 + "gtoken": "^7.0.0", 1956 + "jws": "^4.0.0" 1957 + }, 1958 + "engines": { 1959 + "node": ">=14" 1960 + } 1961 + }, 1962 + "node_modules/google-logging-utils": { 1963 + "version": "0.0.2", 1964 + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz", 1965 + "integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==", 1966 + "license": "Apache-2.0", 1967 + "engines": { 1968 + "node": ">=14" 1969 + } 1970 + }, 1971 + "node_modules/gtoken": { 1972 + "version": "7.1.0", 1973 + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz", 1974 + "integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==", 1975 + "license": "MIT", 1976 + "dependencies": { 1977 + "gaxios": "^6.0.0", 1978 + "jws": "^4.0.0" 1979 + }, 1980 + "engines": { 1981 + "node": ">=14.0.0" 1982 + } 1983 + }, 1984 + "node_modules/hono": { 1985 + "version": "4.7.9", 1986 + "resolved": "https://registry.npmjs.org/hono/-/hono-4.7.9.tgz", 1987 + "integrity": "sha512-/EsCoR5h7N4yu01TDu9GMCCJa6ZLk5ZJIWFFGNawAXmd1Tp53+Wir4xm0D2X19bbykWUlzQG0+BvPAji6p9E8Q==", 1988 + "license": "MIT", 1989 + "engines": { 1990 + "node": ">=16.9.0" 1991 + } 1992 + }, 1993 + "node_modules/https-proxy-agent": { 1994 + "version": "7.0.6", 1995 + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", 1996 + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", 1997 + "license": "MIT", 1998 + "dependencies": { 1999 + "agent-base": "^7.1.2", 2000 + "debug": "4" 2001 + }, 2002 + "engines": { 2003 + "node": ">= 14" 2004 + } 2005 + }, 1855 2006 "node_modules/is-arrayish": { 1856 2007 "version": "0.3.2", 1857 2008 "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", ··· 1860 2011 "license": "MIT", 1861 2012 "optional": true 1862 2013 }, 2014 + "node_modules/is-stream": { 2015 + "version": "2.0.1", 2016 + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", 2017 + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", 2018 + "license": "MIT", 2019 + "engines": { 2020 + "node": ">=8" 2021 + }, 2022 + "funding": { 2023 + "url": "https://github.com/sponsors/sindresorhus" 2024 + } 2025 + }, 1863 2026 "node_modules/jose": { 1864 2027 "version": "6.0.11", 1865 2028 "resolved": "https://registry.npmjs.org/jose/-/jose-6.0.11.tgz", ··· 1869 2032 "url": "https://github.com/sponsors/panva" 1870 2033 } 1871 2034 }, 2035 + "node_modules/json-bigint": { 2036 + "version": "1.0.0", 2037 + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", 2038 + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", 2039 + "license": "MIT", 2040 + "dependencies": { 2041 + "bignumber.js": "^9.0.0" 2042 + } 2043 + }, 2044 + "node_modules/jwa": { 2045 + "version": "2.0.1", 2046 + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", 2047 + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", 2048 + "license": "MIT", 2049 + "dependencies": { 2050 + "buffer-equal-constant-time": "^1.0.1", 2051 + "ecdsa-sig-formatter": "1.0.11", 2052 + "safe-buffer": "^5.0.1" 2053 + } 2054 + }, 2055 + "node_modules/jws": { 2056 + "version": "4.0.0", 2057 + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz", 2058 + "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==", 2059 + "license": "MIT", 2060 + "dependencies": { 2061 + "jwa": "^2.0.0", 2062 + "safe-buffer": "^5.0.1" 2063 + } 2064 + }, 1872 2065 "node_modules/loupe": { 1873 2066 "version": "3.1.3", 1874 2067 "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.3.tgz", ··· 1939 2132 "version": "2.1.3", 1940 2133 "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", 1941 2134 "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", 1942 - "dev": true, 1943 2135 "license": "MIT" 1944 2136 }, 1945 2137 "node_modules/mustache": { ··· 1971 2163 "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" 1972 2164 } 1973 2165 }, 2166 + "node_modules/node-fetch": { 2167 + "version": "2.7.0", 2168 + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", 2169 + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", 2170 + "license": "MIT", 2171 + "dependencies": { 2172 + "whatwg-url": "^5.0.0" 2173 + }, 2174 + "engines": { 2175 + "node": "4.x || >=6.0.0" 2176 + }, 2177 + "peerDependencies": { 2178 + "encoding": "^0.1.0" 2179 + }, 2180 + "peerDependenciesMeta": { 2181 + "encoding": { 2182 + "optional": true 2183 + } 2184 + } 2185 + }, 1974 2186 "node_modules/ohash": { 1975 2187 "version": "2.0.11", 1976 2188 "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", ··· 2097 2309 "@rollup/rollup-win32-x64-msvc": "4.40.2", 2098 2310 "fsevents": "~2.3.2" 2099 2311 } 2312 + }, 2313 + "node_modules/safe-buffer": { 2314 + "version": "5.2.1", 2315 + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", 2316 + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", 2317 + "funding": [ 2318 + { 2319 + "type": "github", 2320 + "url": "https://github.com/sponsors/feross" 2321 + }, 2322 + { 2323 + "type": "patreon", 2324 + "url": "https://www.patreon.com/feross" 2325 + }, 2326 + { 2327 + "type": "consulting", 2328 + "url": "https://feross.org/support" 2329 + } 2330 + ], 2331 + "license": "MIT" 2100 2332 }, 2101 2333 "node_modules/semver": { 2102 2334 "version": "7.7.2", ··· 2287 2519 "node": ">=14.0.0" 2288 2520 } 2289 2521 }, 2522 + "node_modules/tr46": { 2523 + "version": "0.0.3", 2524 + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", 2525 + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", 2526 + "license": "MIT" 2527 + }, 2290 2528 "node_modules/tslib": { 2291 2529 "version": "2.8.1", 2292 2530 "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", ··· 2348 2586 "ohash": "^2.0.11", 2349 2587 "pathe": "^2.0.3", 2350 2588 "ufo": "^1.5.4" 2589 + } 2590 + }, 2591 + "node_modules/uuid": { 2592 + "version": "9.0.1", 2593 + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", 2594 + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", 2595 + "funding": [ 2596 + "https://github.com/sponsors/broofa", 2597 + "https://github.com/sponsors/ctavan" 2598 + ], 2599 + "license": "MIT", 2600 + "bin": { 2601 + "uuid": "dist/bin/uuid" 2351 2602 } 2352 2603 }, 2353 2604 "node_modules/vite": { ··· 2516 2767 "jsdom": { 2517 2768 "optional": true 2518 2769 } 2770 + } 2771 + }, 2772 + "node_modules/webidl-conversions": { 2773 + "version": "3.0.1", 2774 + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", 2775 + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", 2776 + "license": "BSD-2-Clause" 2777 + }, 2778 + "node_modules/whatwg-url": { 2779 + "version": "5.0.0", 2780 + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", 2781 + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", 2782 + "license": "MIT", 2783 + "dependencies": { 2784 + "tr46": "~0.0.3", 2785 + "webidl-conversions": "^3.0.0" 2519 2786 } 2520 2787 }, 2521 2788 "node_modules/why-is-node-running": {
+2
package.json
··· 17 17 "wrangler": "^4.14.4" 18 18 }, 19 19 "dependencies": { 20 + "google-auth-library": "^9.15.1", 21 + "hono": "^4.4.13", 20 22 "jose": "^6.0.11" 21 23 } 22 24 }
+167 -125
src/index.ts
··· 1 - import { SignJWT, importPKCS8 } from 'jose'; 2 - import type { Env } from './types'; 1 + import { Hono, type Context, type Next } from 'hono'; 2 + import { JWT } from 'google-auth-library'; 3 3 4 - const SCOPE = 'https://www.googleapis.com/auth/spreadsheets'; 5 - const AUD = 'https://oauth2.googleapis.com/token'; 4 + // Define the Env type for bindings 5 + export type Env = { 6 + // KV Namespace 7 + LIST_CACHE: KVNamespace; 6 8 7 - /* --- in-memory token cache (per isolate) ----------------------- */ 8 - let tokenCache = { token: '', exp: 0 }; 9 + // Environment Variables (Secrets) 10 + API_KEY: string; 11 + SHEET_ID: string; 12 + SA_EMAIL: string; 13 + SA_PRIVATE_KEY: string; 9 14 10 - export default { 11 - async fetch(req: Request, env: Env, ctx: ExecutionContext): Promise<Response> { 12 - const url = new URL(req.url); 15 + // Environment Variables (Non-Secrets) 16 + TX_RANGE: string; 17 + PURPOSE_TAB: string; 18 + ACCOUNT_TAB: string; 19 + }; 13 20 14 - /* 0 ── simple shared-secret gate */ 15 - if (url.searchParams.get('key') !== env.API_KEY) return new Response('forbidden', { status: 403 }); 21 + const SCOPE = 'https://www.googleapis.com/auth/spreadsheets'; 22 + const LIST_CACHE_KEY = 'lists-v1'; // Memory mentioned 'v2', but code uses 'lists-v1'. Sticking to 'lists-v1'. 16 23 17 - /* 1 ── GET /lists (cached 24 h in KV) ---------------------- */ 18 - if (url.pathname === '/lists' && req.method === 'GET') { 19 - const cached = await env.LIST_CACHE.get('v1', { type: 'json' }); 20 - if (cached) return json(cached); 24 + let jwtClient: JWT; // Global JWT client for Google Auth 21 25 22 - // Check for required environment variables 23 - if (typeof env.PURPOSE_TAB !== 'string' || !env.PURPOSE_TAB) { 24 - console.error('Server configuration error: PURPOSE_TAB environment variable is not defined or not a string.'); 25 - return new Response('Server configuration error: Missing PURPOSE_TAB setting.', { status: 500 }); 26 - } 27 - if (typeof env.ACCOUNT_TAB !== 'string' || !env.ACCOUNT_TAB) { 28 - console.error('Server configuration error: ACCOUNT_TAB environment variable is not defined or not a string.'); 29 - return new Response('Server configuration error: Missing ACCOUNT_TAB setting.', { status: 500 }); 30 - } 26 + // --- Helper Functions --- (Adapted to use Env type) 31 27 32 - const [purposes, accounts] = await batchGet([`${env.PURPOSE_TAB}!A2:A`, `${env.ACCOUNT_TAB}!A2:A`], env); 33 - 34 - const payload = { 35 - purposes: purposes.filter(Boolean), 36 - accounts: accounts.filter(Boolean), 37 - }; 38 - 39 - ctx.waitUntil( 40 - // async cache write 41 - env.LIST_CACHE.put('v1', JSON.stringify(payload), { expirationTtl: 60 * 60 * 24 }) // 24 h 42 - ); 28 + async function accessToken(env: Env): Promise<string> { 29 + if (!jwtClient) { 30 + jwtClient = new JWT({ 31 + email: env.SA_EMAIL, 32 + key: env.SA_PRIVATE_KEY.replace(/\\n/g, '\n'), // Crucial for env var private keys 33 + scopes: [SCOPE], 34 + }); 35 + } 36 + await jwtClient.authorize(); 37 + const token = jwtClient.credentials.access_token; 38 + if (!token) throw new Error('Failed to obtain access token'); 39 + return token; 40 + } 43 41 44 - return json(payload); 45 - } 42 + async function batchGet(ranges: string[], env: Env): Promise<string[][]> { 43 + const token = await accessToken(env); 44 + const q = ranges.map((r) => `ranges=${encodeURIComponent(r)}`).join('&'); 45 + const res = await fetch( 46 + `https://sheets.googleapis.com/v4/spreadsheets/${env.SHEET_ID}/values:batchGet?${q}`, 47 + { headers: { Authorization: `Bearer ${token}` } }, 48 + ); 49 + if (!res.ok) { 50 + const errorText = await res.text(); 51 + throw new Error(`Sheets batchGet failed: ${res.status} ${res.statusText} - ${errorText}`); 52 + } 53 + const { valueRanges } = (await res.json()) as { valueRanges?: { values?: string[][] }[] }; 54 + // Ensure each sub-array in valueRanges.values is filtered for empty/null strings 55 + return valueRanges?.map((v) => v.values?.flat().filter(s => typeof s === 'string' && s.trim() !== '') ?? []) ?? ranges.map(() => []); 56 + } 46 57 47 - /* 2 ── POST /add ------------------------------------------- */ 48 - if (url.pathname === '/add' && req.method === 'POST') { 49 - const body = (await req.json()) as { 50 - date: string; 51 - amount: number; 52 - currency: string; 53 - description: string; 54 - purpose: string; 55 - account: string; 56 - }; 58 + async function appendRow(cells: (string | number)[], env: Env): Promise<void> { 59 + const token = await accessToken(env); 60 + const res = await fetch( 61 + `https://sheets.googleapis.com/v4/spreadsheets/${env.SHEET_ID}/values/${encodeURIComponent(env.TX_RANGE)}:append?valueInputOption=USER_ENTERED`, 62 + { 63 + method: 'POST', 64 + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, 65 + body: JSON.stringify({ values: [cells] }), 66 + }, 67 + ); 68 + if (!res.ok) { 69 + const errorText = await res.text(); 70 + throw new Error(`Append failed: ${res.status} ${res.statusText} - ${errorText}`); 71 + } 72 + } 57 73 58 - await appendRow([body.date, body.amount, body.currency, body.description, body.purpose, body.account], env); 74 + // --- Hono Application Setup --- 75 + const app = new Hono<{ Bindings: Env }>(); 59 76 60 - return json({ status: 'OK' }); 61 - } 77 + // Middleware: Server Configuration Check 78 + app.use('*', async (c: Context<{ Bindings: Env }>, next: Next) => { 79 + const { API_KEY, SHEET_ID, TX_RANGE, PURPOSE_TAB, ACCOUNT_TAB, SA_EMAIL, SA_PRIVATE_KEY, LIST_CACHE } = c.env; 80 + if ( 81 + ![API_KEY, SHEET_ID, TX_RANGE, PURPOSE_TAB, ACCOUNT_TAB, SA_EMAIL, SA_PRIVATE_KEY].every(Boolean) || 82 + !LIST_CACHE 83 + ) { 84 + return c.json({ status: 'ERROR', message: 'Server misconfigured. Essential bindings are missing.' }, 500); 85 + } 86 + await next(); 87 + }); 62 88 63 - /* 3 ── POST /flush-cache ----------------------------------- */ 64 - if (url.pathname === '/flush-cache' && req.method === 'POST') { 65 - try { 66 - await env.LIST_CACHE.delete('v1'); 67 - return json({ status: 'OK', message: "Cache key 'v1' flushed successfully." }); 68 - } catch (error) { 69 - console.error('Error flushing cache:', error); 70 - return json({ status: 'Error', message: 'Failed to flush cache.' }, 500); 71 - } 72 - } 89 + // Middleware: API Key Check (applied to all routes after config check) 90 + app.use('*', async (c: Context<{ Bindings: Env }>, next: Next) => { 91 + const url = new URL(c.req.url); 92 + // Allow root path without API key for a basic health check 93 + if (url.pathname === '/') { 94 + await next(); 95 + return; 96 + } 97 + if (url.searchParams.get('key') !== c.env.API_KEY) { 98 + return c.json({ status: 'ERROR', message: 'Forbidden: Invalid or missing API key.' }, 403); 99 + } 100 + await next(); 101 + }); 73 102 74 - return new Response('not found', { status: 404 }); 75 - }, 76 - }; 103 + // --- Route Handlers --- 77 104 78 - /* ---- Google Sheets helpers ----------------------------------- */ 79 - async function batchGet(ranges: string[], env: Env): Promise<string[][]> { 80 - const token = await accessToken(env); 81 - const q = ranges.map((r) => 'ranges=' + encodeURIComponent(r)).join('&'); 105 + // GET /lists: Fetches purposes and accounts, uses cache 106 + app.get('/lists', async (c: Context<{ Bindings: Env }>) => { 107 + const env = c.env; 108 + try { 109 + const cached = await env.LIST_CACHE.get(LIST_CACHE_KEY, { type: 'json' }) as { purposes: string[], accounts: string[] } | null; 110 + if (cached) { 111 + return c.json(cached); 112 + } 82 113 83 - const res = await fetch(`https://sheets.googleapis.com/v4/spreadsheets/${env.SHEET_ID}/values:batchGet?${q}`, { 84 - headers: { Authorization: `Bearer ${token}` }, 85 - }); 114 + const [purposesData, accountsData] = await batchGet( 115 + [`${env.PURPOSE_TAB}!A2:A`, `${env.ACCOUNT_TAB}!A2:A`], 116 + env, 117 + ); 118 + // Data from batchGet is already string[] and filtered by the helper itself. 119 + const payload = { purposes: purposesData, accounts: accountsData }; 86 120 87 - if (!res.ok) { 88 - console.error(`Google Sheets API error: ${res.status} ${res.statusText}. Response body: ${await res.text()}`); 89 - // Return an array of empty arrays, one for each requested range 90 - return ranges.map(() => []); 91 - } 121 + c.executionCtx.waitUntil( 122 + env.LIST_CACHE.put(LIST_CACHE_KEY, JSON.stringify(payload), { expirationTtl: 86400 }), // 24 hours TTL 123 + ); 124 + return c.json(payload); 125 + } catch (err) { 126 + const message = err instanceof Error ? err.message : String(err); 127 + // console.error('Error in /lists:', message, (err as Error).stack); // For server-side debugging 128 + return c.json({ status: 'ERROR', message: 'Failed to fetch lists.', detail: message }, 500); 129 + } 130 + }); 92 131 93 - // Make valueRanges optional in the type to handle cases where it might be missing 94 - const r = await res.json() as { valueRanges?: { values?: string[][] }[] }; 132 + // POST /add: Appends a new transaction row 133 + app.post('/add', async (c: Context<{ Bindings: Env }>) => { 134 + const env = c.env; 135 + try { 136 + // Define the expected type for the request body 137 + type AddRequestBody = { 138 + date: string; 139 + amount: number; 140 + currency: string; 141 + description: string; 142 + purpose: string; 143 + account: string; 144 + }; 145 + const body = await c.req.json<AddRequestBody>(); 95 146 96 - // If valueRanges is not present in the response, or is not an array 97 - if (!r.valueRanges || !Array.isArray(r.valueRanges)) { 98 - console.error('Google Sheets API response did not contain a valid valueRanges array. Response:', r); 99 - // Return an array of empty arrays, matching the number of requested ranges 100 - return ranges.map(() => []); 101 - } 147 + const { date, amount, currency, description, purpose, account } = body; 102 148 103 - console.log('Google Sheets API response:', JSON.stringify(r, null, 2)); 104 - return r.valueRanges.map((v) => v.values?.flat() ?? []); 105 - } 149 + if (!date || amount == null || !currency || !description || !purpose || !account) { 150 + return c.json({ status: 'ERROR', message: 'Missing required fields in request body.' }, 400); 151 + } 106 152 107 - async function appendRow(cells: (string | number)[], env: Env): Promise<void> { 108 - const token = await accessToken(env); 109 - await fetch(`https://sheets.googleapis.com/v4/spreadsheets/${env.SHEET_ID}/values/${env.TX_RANGE}:append?valueInputOption=USER_ENTERED`, { 110 - method: 'POST', 111 - headers: { 112 - 'Content-Type': 'application/json', 113 - Authorization: `Bearer ${token}`, 114 - }, 115 - body: JSON.stringify({ values: [cells] }), 116 - }); 117 - } 153 + await appendRow([date, amount, currency, description, purpose, account], env); 154 + return c.json({ status: 'OK', message: 'Transaction added successfully.' }); 155 + } catch (err) { 156 + const message = err instanceof Error ? err.message : String(err); 157 + // console.error('Error in /add:', message, (err as Error).stack); // For server-side debugging 158 + return c.json({ status: 'ERROR', message: 'Failed to add transaction.', detail: message }, 500); 159 + } 160 + }); 118 161 119 - /* ---- Service-account JWT → OAuth 2 access-token -------------- */ 120 - async function accessToken(env: Env): Promise<string> { 121 - const now = Math.floor(Date.now() / 1000); 122 - if (tokenCache.token && now < tokenCache.exp - 60) return tokenCache.token; 162 + // POST /flush-cache: Clears the cache for /lists 163 + app.post('/flush-cache', async (c: Context<{ Bindings: Env }>) => { 164 + const env = c.env; 165 + try { 166 + await env.LIST_CACHE.delete(LIST_CACHE_KEY); 167 + return c.json({ status: 'OK', message: `Cache key '${LIST_CACHE_KEY}' flushed successfully.` }); 168 + } catch (err) { 169 + const message = err instanceof Error ? err.message : String(err); 170 + // console.error('Error in /flush-cache:', message, (err as Error).stack); // For server-side debugging 171 + return c.json({ status: 'ERROR', message: 'Failed to flush cache.', detail: message }, 500); 172 + } 173 + }); 123 174 124 - const privateKey = await importPKCS8(env.SA_PRIVATE_KEY, 'RS256'); 175 + // Root path for basic health check (does not require API key due to middleware logic) 176 + app.get('/', (c: Context<{ Bindings: Env }>) => { 177 + return c.text('Budget Edge Worker with Hono is running!'); 178 + }); 125 179 126 - const jwt = await new SignJWT({ scope: SCOPE }) 127 - .setProtectedHeader({ alg: 'RS256' }) 128 - .setIssuer(env.SA_EMAIL) 129 - .setSubject(env.SA_EMAIL) 130 - .setAudience(AUD) 131 - .setIssuedAt(now) 132 - .setExpirationTime(now + 3600) 133 - .sign(privateKey); 180 + // --- Hono Error Handling --- 134 181 135 - const resp = await fetch(AUD, { 136 - method: 'POST', 137 - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 138 - body: 'grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&assertion=' + encodeURIComponent(jwt), 139 - }).then((r) => r.json() as Promise<{ access_token: string; expires_in: number }>); 182 + // Not Found Handler 183 + app.notFound((c: Context<{ Bindings: Env }>) => { 184 + return c.json({ status: 'ERROR', message: 'Not Found. The requested endpoint does not exist.' }, 404); 185 + }); 140 186 141 - tokenCache = { token: resp.access_token, exp: now + resp.expires_in }; 142 - return tokenCache.token; 143 - } 187 + // Global Error Handler 188 + app.onError((err: Error, c: Context<{ Bindings: Env }>) => { 189 + // console.error('Global Hono Error:', err.message, err.stack); // For server-side debugging 190 + return c.json({ status: 'ERROR', message: 'Internal Server Error.', detail: err.message }, 500); 191 + }); 144 192 145 - /* ---- small helper -------------------------------------------- */ 146 - function json(obj: unknown, status = 200): Response { 147 - return new Response(JSON.stringify(obj), { 148 - status, 149 - headers: { 'Content-Type': 'application/json' }, 150 - }); 151 - } 193 + export default app;