Monorepo for Wisp.place. A static site hosting service built on top of the AT Protocol.

Compare changes

Choose any two refs to compare.

+8
.dockerignore
···
*.log
.vscode
.idea
···
*.log
.vscode
.idea
+
server
+
.prettierrc
+
testDeploy
+
.tangled
+
.crush
+
.claude
+
server
+
hosting-service
+1
.gitignore
···
# production
/build
# misc
.DS_Store
···
# production
/build
+
/result
# misc
.DS_Store
+10 -6
Dockerfile
···
COPY public ./public
# Build the application (if needed)
-
# RUN bun run build
# Set environment variables (can be overridden at runtime)
ENV PORT=3000
···
# Expose the application port
EXPOSE 3000
-
# Health check
-
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
-
CMD bun -e "fetch('http://localhost:3000/health').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))"
-
# Start the application
-
CMD ["bun", "src/index.ts"]
···
COPY public ./public
# Build the application (if needed)
+
RUN bun build \
+
--compile \
+
--minify \
+
--outfile server \
+
src/index.ts
+
+
FROM scratch AS runtime
+
WORKDIR /app
+
COPY --from=base /app/server /app/server
# Set environment variables (can be overridden at runtime)
ENV PORT=3000
···
# Expose the application port
EXPOSE 3000
# Start the application
+
CMD ["./server"]
-41
api.md
···
-
/**
-
* AUTHENTICATION ROUTES
-
*
-
* Handles OAuth authentication flow for Bluesky/ATProto accounts
-
* All routes are on the editor.wisp.place subdomain
-
*
-
* Routes:
-
* POST /api/auth/signin - Initiate OAuth sign-in flow
-
* GET /api/auth/callback - OAuth callback handler (redirect from PDS)
-
* GET /api/auth/status - Check current authentication status
-
* POST /api/auth/logout - Sign out and clear session
-
*/
-
-
/**
-
* CUSTOM DOMAIN ROUTES
-
*
-
* Handles custom domain (BYOD - Bring Your Own Domain) management
-
* Users can claim custom domains with DNS verification (TXT + CNAME)
-
* and map them to their sites
-
*
-
* Routes:
-
* GET /api/check-domain - Fast verification check for routing (public)
-
* GET /api/custom-domains - List user's custom domains
-
* POST /api/custom-domains/check - Check domain availability and DNS config
-
* POST /api/custom-domains/claim - Claim a custom domain
-
* PUT /api/custom-domains/:id/site - Update site mapping
-
* DELETE /api/custom-domains/:id - Remove a custom domain
-
* POST /api/custom-domains/:id/verify - Manually trigger verification
-
*/
-
-
/**
-
* WISP SITE MANAGEMENT ROUTES
-
*
-
* API endpoints for managing user's Wisp sites stored in ATProto repos
-
* Handles reading site metadata, fetching content, updating sites, and uploads
-
* All routes are on the editor.wisp.place subdomain
-
*
-
* Routes:
-
* GET /wisp/sites - List all sites for authenticated user
-
* POST /wisp/upload-files - Upload and deploy files as a site
-
*/
···
+179
bun.lock
···
"lucide-react": "^0.546.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"tailwind-merge": "^3.3.1",
"tailwindcss": "4",
"tw-animate-css": "^1.4.0",
···
"@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="],
"@sinclair/typebox": ["@sinclair/typebox@0.34.41", "", {}, "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g=="],
"@tanstack/query-core": ["@tanstack/query-core@5.90.2", "", {}, "sha512-k/TcR3YalnzibscALLwxeiLUub6jN5EDLwKDiO7q5f4ICEoptJ+n9+7vcEFy5/x/i6Q+Lb/tXrsKCggf5uQJXQ=="],
···
"@ts-morph/common": ["@ts-morph/common@0.25.0", "", { "dependencies": { "minimatch": "^9.0.4", "path-browserify": "^1.0.1", "tinyglobby": "^0.2.9" } }, "sha512-kMnZz+vGGHi4GoHnLmMhGNjm44kGtKUXGnOvrKmMwAuvNjM/PgKVGfUnL7IDvK7Jb2QQ82jq3Zmp04Gy+r3Dkg=="],
"@types/node": ["@types/node@24.7.2", "", { "dependencies": { "undici-types": "~7.14.0" } }, "sha512-/NbVmcGTP+lj5oa4yiYxxeBjRivKQ5Ns1eSZeB99ExsEQ6rX5XYU1Zy/gGxY/ilqtD4Etx9mKyrPxZRetiahhA=="],
"@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="],
···
"@types/react-dom": ["@types/react-dom@19.2.1", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-/EEvYBdT3BflCWvTMO7YkYBHVE9Ci6XdqZciZANQgKpaiDRGOLIlRo91jbTNRQjgPFWVaRxcYc0luVNFitz57A=="],
"@types/shimmer": ["@types/shimmer@1.2.0", "", {}, "sha512-UE7oxhQLLd9gub6JKIAhDq06T0F6FnztwMNRvYgjeQSBeMc1ZG/tA47EwfduvkuQS8apbkM/lpLpWsaCeYsXVg=="],
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
···
"cborg": ["cborg@1.10.2", "", { "bin": { "cborg": "cli.js" } }, "sha512-b3tFPA9pUr2zCUiCfRd2+wok2/LBSNUMKOuRRok+WlvvAgEt/PlbgPTsZUcwCOs53IJvLgTp0eotwtosE6njug=="],
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"cjs-module-lexer": ["cjs-module-lexer@1.4.3", "", {}, "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q=="],
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
···
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"commander": ["commander@9.5.0", "", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="],
···
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
"debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
"destroy": ["destroy@1.2.0", "", {}, "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
···
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
···
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"http-errors": ["http-errors@2.0.0", "", { "dependencies": { "depd": "2.0.0", "inherits": "2.0.4", "setprototypeof": "1.2.0", "statuses": "2.0.1", "toidentifier": "1.0.1" } }, "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ=="],
"iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
···
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"ipaddr.js": ["ipaddr.js@2.2.0", "", {}, "sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA=="],
"iron-session": ["iron-session@8.0.4", "", { "dependencies": { "cookie": "^0.7.2", "iron-webcrypto": "^1.2.1", "uncrypto": "^0.1.3" } }, "sha512-9ivNnaKOd08osD0lJ3i6If23GFS2LsxyMU8Gf/uBUEgm8/8CC1hrrCHFDpMo3IFbpBgwoo/eairRsaD3c5itxA=="],
"iron-webcrypto": ["iron-webcrypto@1.2.1", "", {}, "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg=="],
"is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
"iso-datestring-validator": ["iso-datestring-validator@2.2.2", "", {}, "sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA=="],
"jose": ["jose@5.10.0", "", {}, "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg=="],
···
"lodash.camelcase": ["lodash.camelcase@4.3.0", "", {}, "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="],
"long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
"lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
···
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="],
"merge-descriptors": ["merge-descriptors@1.0.3", "", {}, "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ=="],
"methods": ["methods@1.1.2", "", {}, "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w=="],
"mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="],
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
···
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
"openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="],
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
···
"process-warning": ["process-warning@3.0.0", "", {}, "sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ=="],
"protobufjs": ["protobufjs@7.5.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg=="],
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
···
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
"readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="],
"real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="],
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
"require-in-the-middle": ["require-in-the-middle@7.5.2", "", { "dependencies": { "debug": "^4.3.5", "module-details-from-path": "^1.0.3", "resolve": "^1.22.8" } }, "sha512-gAZ+kLqBdHarXB64XpAe2VCjB7rIRv+mU8tfRWziHRJ5umKsIHN2tLLv6EtMw7WCdP19S0ERVMldNvxYCHnhSQ=="],
···
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
"shimmer": ["shimmer@1.2.1", "", {}, "sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw=="],
"side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
···
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
"sonic-boom": ["sonic-boom@3.8.1", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-y4Z8LCDBuum+PBP3lSV7RHrXscqksve/bi0as7mhwVnBW+/wUqKT/2Kb7um8yqcFy0duYbbPxzt89Zy2nOCaxg=="],
"split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
···
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"strtok3": ["strtok3@10.3.4", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg=="],
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
···
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
"token-types": ["token-types@6.1.1", "", { "dependencies": { "@borewit/text-codec": "^0.1.0", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-kh9LVIWH5CnL63Ipf0jhlBIy0UsrMj/NJDfpsy1SqOXlLKEVyXXYrnFxFT1yOOYVGBSApeVnjPw/sBz5BfEjAQ=="],
"ts-morph": ["ts-morph@24.0.0", "", { "dependencies": { "@ts-morph/common": "~0.25.0", "code-block-writer": "^13.0.3" } }, "sha512-2OAOg/Ob5yx9Et7ZX4CvTCc0UFoZHwLEJ+dpDPSUi5TgwwlTlX47w+iFRrEwzUZwYACjq83cgjS/Da50Ga37uw=="],
···
"undici-types": ["undici-types@7.14.0", "", {}, "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA=="],
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
"use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
···
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
"wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
"ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="],
···
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"@tokenizer/inflate/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"express/cookie": ["cookie@0.7.1", "", {}, "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w=="],
"iron-session/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
"proxy-addr/ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
"require-in-the-middle/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
···
"send/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"@tokenizer/inflate/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"require-in-the-middle/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
}
···
"lucide-react": "^0.546.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
+
"react-shiki": "^0.9.0",
"tailwind-merge": "^3.3.1",
"tailwindcss": "4",
"tw-animate-css": "^1.4.0",
···
"@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="],
+
"@shikijs/core": ["@shikijs/core@3.15.0", "", { "dependencies": { "@shikijs/types": "3.15.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-8TOG6yG557q+fMsSVa8nkEDOZNTSxjbbR8l6lF2gyr6Np+jrPlslqDxQkN6rMXCECQ3isNPZAGszAfYoJOPGlg=="],
+
+
"@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.15.0", "", { "dependencies": { "@shikijs/types": "3.15.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.3" } }, "sha512-ZedbOFpopibdLmvTz2sJPJgns8Xvyabe2QbmqMTz07kt1pTzfEvKZc5IqPVO/XFiEbbNyaOpjPBkkr1vlwS+qg=="],
+
+
"@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.15.0", "", { "dependencies": { "@shikijs/types": "3.15.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-HnqFsV11skAHvOArMZdLBZZApRSYS4LSztk2K3016Y9VCyZISnlYUYsL2hzlS7tPqKHvNqmI5JSUJZprXloMvA=="],
+
+
"@shikijs/langs": ["@shikijs/langs@3.15.0", "", { "dependencies": { "@shikijs/types": "3.15.0" } }, "sha512-WpRvEFvkVvO65uKYW4Rzxs+IG0gToyM8SARQMtGGsH4GDMNZrr60qdggXrFOsdfOVssG/QQGEl3FnJ3EZ+8w8A=="],
+
+
"@shikijs/themes": ["@shikijs/themes@3.15.0", "", { "dependencies": { "@shikijs/types": "3.15.0" } }, "sha512-8ow2zWb1IDvCKjYb0KiLNrK4offFdkfNVPXb1OZykpLCzRU6j+efkY+Y7VQjNlNFXonSw+4AOdGYtmqykDbRiQ=="],
+
+
"@shikijs/types": ["@shikijs/types@3.15.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-BnP+y/EQnhihgHy4oIAN+6FFtmfTekwOLsQbRw9hOKwqgNy8Bdsjq8B05oAt/ZgvIWWFrshV71ytOrlPfYjIJw=="],
+
+
"@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="],
+
"@sinclair/typebox": ["@sinclair/typebox@0.34.41", "", {}, "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g=="],
"@tanstack/query-core": ["@tanstack/query-core@5.90.2", "", {}, "sha512-k/TcR3YalnzibscALLwxeiLUub6jN5EDLwKDiO7q5f4ICEoptJ+n9+7vcEFy5/x/i6Q+Lb/tXrsKCggf5uQJXQ=="],
···
"@ts-morph/common": ["@ts-morph/common@0.25.0", "", { "dependencies": { "minimatch": "^9.0.4", "path-browserify": "^1.0.1", "tinyglobby": "^0.2.9" } }, "sha512-kMnZz+vGGHi4GoHnLmMhGNjm44kGtKUXGnOvrKmMwAuvNjM/PgKVGfUnL7IDvK7Jb2QQ82jq3Zmp04Gy+r3Dkg=="],
+
"@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
+
+
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
+
+
"@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="],
+
+
"@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="],
+
+
"@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
+
+
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
+
"@types/node": ["@types/node@24.7.2", "", { "dependencies": { "undici-types": "~7.14.0" } }, "sha512-/NbVmcGTP+lj5oa4yiYxxeBjRivKQ5Ns1eSZeB99ExsEQ6rX5XYU1Zy/gGxY/ilqtD4Etx9mKyrPxZRetiahhA=="],
"@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="],
···
"@types/react-dom": ["@types/react-dom@19.2.1", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-/EEvYBdT3BflCWvTMO7YkYBHVE9Ci6XdqZciZANQgKpaiDRGOLIlRo91jbTNRQjgPFWVaRxcYc0luVNFitz57A=="],
"@types/shimmer": ["@types/shimmer@1.2.0", "", {}, "sha512-UE7oxhQLLd9gub6JKIAhDq06T0F6FnztwMNRvYgjeQSBeMc1ZG/tA47EwfduvkuQS8apbkM/lpLpWsaCeYsXVg=="],
+
+
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
+
+
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
···
"cborg": ["cborg@1.10.2", "", { "bin": { "cborg": "cli.js" } }, "sha512-b3tFPA9pUr2zCUiCfRd2+wok2/LBSNUMKOuRRok+WlvvAgEt/PlbgPTsZUcwCOs53IJvLgTp0eotwtosE6njug=="],
+
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
+
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
+
"character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="],
+
+
"character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="],
+
+
"character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="],
+
+
"character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="],
+
"cjs-module-lexer": ["cjs-module-lexer@1.4.3", "", {}, "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q=="],
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
···
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
+
+
"comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
"commander": ["commander@9.5.0", "", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="],
···
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
"debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
+
+
"decode-named-character-reference": ["decode-named-character-reference@1.2.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q=="],
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
+
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
+
"destroy": ["destroy@1.2.0", "", {}, "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
+
+
"devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
···
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
+
+
"estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="],
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
···
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
+
"hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="],
+
+
"hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="],
+
+
"hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="],
+
+
"html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="],
+
"http-errors": ["http-errors@2.0.0", "", { "dependencies": { "depd": "2.0.0", "inherits": "2.0.4", "setprototypeof": "1.2.0", "statuses": "2.0.1", "toidentifier": "1.0.1" } }, "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ=="],
"iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
···
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
+
"inline-style-parser": ["inline-style-parser@0.2.6", "", {}, "sha512-gtGXVaBdl5mAes3rPcMedEBm12ibjt1kDMFfheul1wUAOVEJW60voNdMVzVkfLN06O7ZaD/rxhfKgtlgtTbMjg=="],
+
"ipaddr.js": ["ipaddr.js@2.2.0", "", {}, "sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA=="],
"iron-session": ["iron-session@8.0.4", "", { "dependencies": { "cookie": "^0.7.2", "iron-webcrypto": "^1.2.1", "uncrypto": "^0.1.3" } }, "sha512-9ivNnaKOd08osD0lJ3i6If23GFS2LsxyMU8Gf/uBUEgm8/8CC1hrrCHFDpMo3IFbpBgwoo/eairRsaD3c5itxA=="],
"iron-webcrypto": ["iron-webcrypto@1.2.1", "", {}, "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg=="],
+
"is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="],
+
+
"is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="],
+
"is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
+
"is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="],
+
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
+
"is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="],
+
"iso-datestring-validator": ["iso-datestring-validator@2.2.2", "", {}, "sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA=="],
"jose": ["jose@5.10.0", "", {}, "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg=="],
···
"lodash.camelcase": ["lodash.camelcase@4.3.0", "", {}, "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="],
"long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
+
+
"longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],
"lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
···
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
+
"mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA=="],
+
+
"mdast-util-mdx-expression": ["mdast-util-mdx-expression@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="],
+
+
"mdast-util-mdx-jsx": ["mdast-util-mdx-jsx@3.2.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "parse-entities": "^4.0.0", "stringify-entities": "^4.0.0", "unist-util-stringify-position": "^4.0.0", "vfile-message": "^4.0.0" } }, "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q=="],
+
+
"mdast-util-mdxjs-esm": ["mdast-util-mdxjs-esm@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg=="],
+
+
"mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="],
+
+
"mdast-util-to-hast": ["mdast-util-to-hast@13.2.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA=="],
+
+
"mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="],
+
+
"mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="],
+
"media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="],
"merge-descriptors": ["merge-descriptors@1.0.3", "", {}, "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ=="],
"methods": ["methods@1.1.2", "", {}, "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w=="],
+
"micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="],
+
+
"micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="],
+
+
"micromark-factory-destination": ["micromark-factory-destination@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="],
+
+
"micromark-factory-label": ["micromark-factory-label@2.0.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="],
+
+
"micromark-factory-space": ["micromark-factory-space@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg=="],
+
+
"micromark-factory-title": ["micromark-factory-title@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw=="],
+
+
"micromark-factory-whitespace": ["micromark-factory-whitespace@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ=="],
+
+
"micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="],
+
+
"micromark-util-chunked": ["micromark-util-chunked@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA=="],
+
+
"micromark-util-classify-character": ["micromark-util-classify-character@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q=="],
+
+
"micromark-util-combine-extensions": ["micromark-util-combine-extensions@2.0.1", "", { "dependencies": { "micromark-util-chunked": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg=="],
+
+
"micromark-util-decode-numeric-character-reference": ["micromark-util-decode-numeric-character-reference@2.0.2", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw=="],
+
+
"micromark-util-decode-string": ["micromark-util-decode-string@2.0.1", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ=="],
+
+
"micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="],
+
+
"micromark-util-html-tag-name": ["micromark-util-html-tag-name@2.0.1", "", {}, "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA=="],
+
+
"micromark-util-normalize-identifier": ["micromark-util-normalize-identifier@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q=="],
+
+
"micromark-util-resolve-all": ["micromark-util-resolve-all@2.0.1", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg=="],
+
+
"micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="],
+
+
"micromark-util-subtokenize": ["micromark-util-subtokenize@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA=="],
+
+
"micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="],
+
+
"micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="],
+
"mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="],
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
···
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
+
"oniguruma-parser": ["oniguruma-parser@0.12.1", "", {}, "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w=="],
+
+
"oniguruma-to-es": ["oniguruma-to-es@4.3.3", "", { "dependencies": { "oniguruma-parser": "^0.12.1", "regex": "^6.0.1", "regex-recursion": "^6.0.2" } }, "sha512-rPiZhzC3wXwE59YQMRDodUwwT9FZ9nNBwQQfsd1wfdtlKEyCdRV0avrTcSZ5xlIvGRVPd/cx6ZN45ECmS39xvg=="],
+
"openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="],
+
+
"parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="],
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
···
"process-warning": ["process-warning@3.0.0", "", {}, "sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ=="],
+
"property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="],
+
"protobufjs": ["protobufjs@7.5.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg=="],
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
···
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
+
"react-shiki": ["react-shiki@0.9.0", "", { "dependencies": { "clsx": "^2.1.1", "dequal": "^2.0.3", "hast-util-to-jsx-runtime": "^2.3.6", "shiki": "^3.11.0", "unist-util-visit": "^5.0.0" }, "peerDependencies": { "@types/react": ">=16.8.0", "@types/react-dom": ">=16.8.0", "react": ">= 16.8.0", "react-dom": ">= 16.8.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-5t+vHGglJioG3LU6uTKFaiOC+KNW7haL8e22ZHSP7m174ZD/X2KgCVJcxvcUOM3FiqjPQD09AyS9/+RcOh3PmA=="],
+
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
"readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="],
"real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="],
+
"regex": ["regex@6.0.1", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-uorlqlzAKjKQZ5P+kTJr3eeJGSVroLKoHmquUj4zHWuR+hEyNqlXsSKlYYF5F4NI6nl7tWCs0apKJ0lmfsXAPA=="],
+
+
"regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="],
+
+
"regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="],
+
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
"require-in-the-middle": ["require-in-the-middle@7.5.2", "", { "dependencies": { "debug": "^4.3.5", "module-details-from-path": "^1.0.3", "resolve": "^1.22.8" } }, "sha512-gAZ+kLqBdHarXB64XpAe2VCjB7rIRv+mU8tfRWziHRJ5umKsIHN2tLLv6EtMw7WCdP19S0ERVMldNvxYCHnhSQ=="],
···
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
+
"shiki": ["shiki@3.15.0", "", { "dependencies": { "@shikijs/core": "3.15.0", "@shikijs/engine-javascript": "3.15.0", "@shikijs/engine-oniguruma": "3.15.0", "@shikijs/langs": "3.15.0", "@shikijs/themes": "3.15.0", "@shikijs/types": "3.15.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-kLdkY6iV3dYbtPwS9KXU7mjfmDm25f5m0IPNFnaXO7TBPcvbUOY72PYXSuSqDzwp+vlH/d7MXpHlKO/x+QoLXw=="],
+
"shimmer": ["shimmer@1.2.1", "", {}, "sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw=="],
"side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
···
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
"sonic-boom": ["sonic-boom@3.8.1", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-y4Z8LCDBuum+PBP3lSV7RHrXscqksve/bi0as7mhwVnBW+/wUqKT/2Kb7um8yqcFy0duYbbPxzt89Zy2nOCaxg=="],
+
+
"space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
"split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
···
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
+
"stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
+
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"strtok3": ["strtok3@10.3.4", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg=="],
+
"style-to-js": ["style-to-js@1.1.19", "", { "dependencies": { "style-to-object": "1.0.12" } }, "sha512-Ev+SgeqiNGT1ufsXyVC5RrJRXdrkRJ1Gol9Qw7Pb72YCKJXrBvP0ckZhBeVSrw2m06DJpei2528uIpjMb4TsoQ=="],
+
+
"style-to-object": ["style-to-object@1.0.12", "", { "dependencies": { "inline-style-parser": "0.2.6" } }, "sha512-ddJqYnoT4t97QvN2C95bCgt+m7AAgXjVnkk/jxAfmp7EAB8nnqqZYEbMd3em7/vEomDb2LAQKAy1RFfv41mdNw=="],
+
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
···
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
"token-types": ["token-types@6.1.1", "", { "dependencies": { "@borewit/text-codec": "^0.1.0", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-kh9LVIWH5CnL63Ipf0jhlBIy0UsrMj/NJDfpsy1SqOXlLKEVyXXYrnFxFT1yOOYVGBSApeVnjPw/sBz5BfEjAQ=="],
+
+
"trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
"ts-morph": ["ts-morph@24.0.0", "", { "dependencies": { "@ts-morph/common": "~0.25.0", "code-block-writer": "^13.0.3" } }, "sha512-2OAOg/Ob5yx9Et7ZX4CvTCc0UFoZHwLEJ+dpDPSUi5TgwwlTlX47w+iFRrEwzUZwYACjq83cgjS/Da50Ga37uw=="],
···
"undici-types": ["undici-types@7.14.0", "", {}, "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA=="],
+
"unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="],
+
+
"unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="],
+
+
"unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="],
+
+
"unist-util-visit": ["unist-util-visit@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg=="],
+
+
"unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="],
+
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
"use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
···
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
+
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
+
+
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
+
"wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
"ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="],
···
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
+
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
+
"@tokenizer/inflate/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"express/cookie": ["cookie@0.7.1", "", {}, "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w=="],
"iron-session/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
+
"micromark/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
+
+
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
+
"proxy-addr/ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
"require-in-the-middle/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
···
"send/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"@tokenizer/inflate/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
+
+
"micromark/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"require-in-the-middle/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
}
+420 -25
claude.md
···
-
Wisp.place - Decentralized Static Site Hosting
-
Architecture Overview
-
Wisp.Place a two-service application that provides static site hosting on the AT
-
Protocol. Wisp aims to be a CDN for static sites where the content is ultimately owned by the user at their repo. The microservice is responsbile for injesting firehose events and serving a on-disk cache of the latest site files.
-
Service 1: Main App (Port 8000, Bun runtime, elysia.js)
-
- User-facing editor and API
-
- OAuth authentication (AT Protocol)
-
- File upload processing (gzip + base64 encoding)
-
- Domain management (subdomains + custom domains)
-
- DNS verification worker
-
- React frontend
-
Service 2: Hosting Service (Port 3001, Node.js runtime, hono.js)
-
- AT Protocol Firehose listener for real-time updates
-
- Serves hosted websites from local cache
-
- Multi-domain routing (custom domains, wisp.place subdomains, sites subdomain)
-
- Distributed locking for multi-instance coordination
-
Tech Stack
-
- Backend: Bun/Node.js, Elysia.js, PostgreSQL, AT Protocol SDK
-
- Frontend: React 19, Tailwind CSS v4, Shadcn UI
-
Key Features
-
- AT Protocol Integration: Sites stored as place.wisp.fs records in user repos
-
- File Processing: Validates, compresses (gzip), encodes (base64), uploads to user's PDS
-
- Domain Management: wisp.place subdomains + custom BYOD domains with DNS verification
-
- Real-time Sync: Firehose worker listens for site updates and caches files locally
-
- Atomic Updates: Safe cache swapping without downtime
···
+
# Wisp.place - Codebase Overview
+
+
**Project URL**: https://wisp.place
+
+
A decentralized static site hosting service built on the AT Protocol (Bluesky). Users can host static websites directly in their AT Protocol accounts, keeping full control and ownership while benefiting from fast CDN distribution.
+
+
---
+
+
## ๐Ÿ—๏ธ Architecture Overview
+
+
### Multi-Part System
+
1. **Main Backend** (`/src`) - OAuth, site management, custom domains
+
2. **Hosting Service** (`/hosting-service`) - Microservice that serves cached sites
+
3. **CLI Tool** (`/cli`) - Rust CLI for direct site uploads to PDS
+
4. **Frontend** (`/public`) - React UI for onboarding, editor, admin
+
+
### Tech Stack
+
- **Backend**: Elysia (Bun) + TypeScript + PostgreSQL
+
- **Frontend**: React 19 + Tailwind CSS 4 + Radix UI
+
- **CLI**: Rust with Jacquard (AT Protocol library)
+
- **Database**: PostgreSQL for session/domain/site caching
+
- **AT Protocol**: OAuth 2.0 + custom lexicons for storage
+
+
---
+
+
## ๐Ÿ“‚ Directory Structure
+
+
### `/src` - Main Backend Server
+
**Purpose**: Core server handling OAuth, site management, custom domains, admin features
+
+
**Key Routes**:
+
- `/api/auth/*` - OAuth signin/callback/logout/status
+
- `/api/domain/*` - Custom domain management (BYOD)
+
- `/wisp/*` - Site upload and management
+
- `/api/user/*` - User info and site listing
+
- `/api/admin/*` - Admin console (logs, metrics, DNS verification)
+
+
**Key Files**:
+
- `index.ts` - Express-like Elysia app setup with middleware (CORS, CSP, security headers)
+
- `lib/oauth-client.ts` - OAuth client setup with session/state persistence
+
- `lib/db.ts` - PostgreSQL schema and queries for all tables
+
- `lib/wisp-auth.ts` - Cookie-based authentication middleware
+
- `lib/wisp-utils.ts` - File compression (gzip), manifest creation, blob handling
+
- `lib/sync-sites.ts` - Syncs user's place.wisp.fs records from PDS to database cache
+
- `lib/dns-verify.ts` - DNS verification for custom domains (TXT + CNAME)
+
- `lib/dns-verification-worker.ts` - Background worker that checks domain verification every 10 minutes
+
- `lib/admin-auth.ts` - Simple username/password admin authentication
+
- `lib/observability.ts` - Logging, error tracking, metrics collection
+
- `routes/auth.ts` - OAuth flow handlers
+
- `routes/wisp.ts` - File upload and site creation (/wisp/upload-files)
+
- `routes/domain.ts` - Domain claiming/verification API
+
- `routes/user.ts` - User status/info/sites listing
+
- `routes/site.ts` - Site metadata and file retrieval
+
- `routes/admin.ts` - Admin dashboard API (logs, system health, manual DNS trigger)
+
+
### `/lexicons` & `src/lexicons/`
+
**Purpose**: AT Protocol Lexicon definitions for custom data types
+
+
**Key File**: `fs.json` - Defines `place.wisp.fs` record format
+
- **structure**: Virtual filesystem manifest with tree structure
+
- **site**: string identifier
+
- **root**: directory object containing entries
+
- **file**: blob reference + metadata (encoding, mimeType, base64 flag)
+
- **directory**: array of entries (recursive)
+
- **entry**: name + node (file or directory)
+
+
**Important**: Files are gzip-compressed and base64-encoded before upload to bypass PDS content sniffing
+
+
### `/hosting-service`
+
**Purpose**: Lightweight microservice that serves cached sites from disk
+
+
**Architecture**:
+
- Routes by domain lookup in PostgreSQL
+
- Caches site content locally on first access or firehose event
+
- Listens to AT Protocol firehose for new site records
+
- Automatically downloads and caches files from PDS
+
- SSRF-protected fetch (timeout, size limits, private IP blocking)
+
+
**Routes**:
+
1. Custom domains (`/*`) โ†’ lookup custom_domains table
+
2. Wisp subdomains (`/*.wisp.place/*`) โ†’ lookup domains table
+
3. DNS hash routing (`/hash.dns.wisp.place/*`) โ†’ lookup custom_domains by hash
+
4. Direct serving (`/s.wisp.place/:identifier/:site/*`) โ†’ fetch from PDS if not cached
+
+
**HTML Path Rewriting**: Absolute paths in HTML (`/style.css`) automatically rewritten to relative (`/:identifier/:site/style.css`)
+
+
### `/cli`
+
**Purpose**: Rust CLI tool for direct site uploads using app password or OAuth
+
+
**Flow**:
+
1. Authenticate with handle + app password or OAuth
+
2. Walk directory tree, compress files
+
3. Upload blobs to PDS via agent
+
4. Create place.wisp.fs record with manifest
+
5. Store site in database cache
+
+
**Auth Methods**:
+
- `--password` flag for app password auth
+
- OAuth loopback server for browser-based auth
+
- Supports both (password preferred if provided)
+
+
---
+
+
## ๐Ÿ” Key Concepts
+
+
### Custom Domains (BYOD - Bring Your Own Domain)
+
**Process**:
+
1. User claims custom domain via API
+
2. System generates hash (SHA256(domain + secret))
+
3. User adds DNS records:
+
- TXT at `_wisp.example.com` = their DID
+
- CNAME at `example.com` = `{hash}.dns.wisp.place`
+
4. Background worker checks verification every 10 minutes
+
5. Once verified, custom domain routes to their hosted sites
+
+
**Tables**: `custom_domains` (id, domain, did, rkey, verified, last_verified_at)
+
+
### Wisp Subdomains
+
**Process**:
+
1. Handle claimed on first signup (e.g., alice โ†’ alice.wisp.place)
+
2. Stored in `domains` table mapping domain โ†’ DID
+
3. Served by hosting service
+
+
### Site Storage
+
**Locations**:
+
- **Authoritative**: PDS (AT Protocol repo) as `place.wisp.fs` record
+
- **Cache**: PostgreSQL `sites` table (rkey, did, site_name, created_at)
+
- **File Cache**: Hosting service caches downloaded files on disk
+
+
**Limits**:
+
- MAX_SITE_SIZE: 300MB total
+
- MAX_FILE_SIZE: 100MB per file
+
- MAX_FILE_COUNT: 2000 files
+
+
### File Compression Strategy
+
**Why**: Bypass PDS content sniffing issues (was treating HTML as images)
+
+
**Process**:
+
1. All files gzip-compressed (level 9)
+
2. Compressed content base64-encoded
+
3. Uploaded as `application/octet-stream` MIME type
+
4. Blob metadata stores original MIME type + encoding flag
+
5. Hosting service decompresses on serve
+
+
---
+
+
## ๐Ÿ”„ Data Flow
+
+
### User Registration โ†’ Site Upload
+
```
+
1. OAuth signin โ†’ state/session stored in DB
+
2. Cookie set with DID
+
3. Sync sites from PDS to cache DB
+
4. If no sites/domain โ†’ redirect to onboarding
+
5. User creates site โ†’ POST /wisp/upload-files
+
6. Files compressed, uploaded as blobs
+
7. place.wisp.fs record created
+
8. Site cached in DB
+
9. Hosting service notified via firehose
+
```
+
+
### Custom Domain Setup
+
```
+
1. User claims domain (DB check + allocation)
+
2. System generates hash
+
3. User adds DNS records (_wisp.domain TXT + CNAME)
+
4. Background worker verifies every 10 min
+
5. Hosting service routes based on verification status
+
```
+
+
### Site Access
+
```
+
Hosting Service:
+
1. Request arrives at custom domain or *.wisp.place
+
2. Domain lookup in PostgreSQL
+
3. Check cache for site files
+
4. If not cached:
+
- Fetch from PDS using DID + rkey
+
- Decompress files
+
- Save to disk cache
+
5. Serve files (with HTML path rewriting)
+
```
+
+
---
+
+
## ๐Ÿ› ๏ธ Important Implementation Details
+
+
### OAuth Implementation
+
- **State & Session Storage**: PostgreSQL (with expiration)
+
- **Key Rotation**: Periodic rotation + expiration cleanup (hourly)
+
- **OAuth Flow**: Redirects to PDS, returns to /api/auth/callback
+
- **Session Timeout**: 30 days
+
- **State Timeout**: 1 hour
+
+
### Security Headers
+
- X-Frame-Options: DENY
+
- X-Content-Type-Options: nosniff
+
- Strict-Transport-Security: max-age=31536000
+
- Content-Security-Policy (configured for Elysia + React)
+
- X-XSS-Protection: 1; mode=block
+
- Referrer-Policy: strict-origin-when-cross-origin
+
+
### Admin Authentication
+
- Simple username/password (hashed with bcrypt)
+
- Session-based cookie auth (24hr expiration)
+
- Separate `admin_session` cookie
+
- Initial setup prompted on startup
+
+
### Observability
+
- **Logging**: Structured logging with service tags + event types
+
- **Error Tracking**: Captures error context (message, stack, etc.)
+
- **Metrics**: Request counts, latencies, error rates
+
- **Log Levels**: debug, info, warn, error
+
- **Collection**: Centralized log collector with in-memory buffer
+
+
---
+
+
## ๐Ÿ“ Database Schema
+
+
### oauth_states
+
- key (primary key)
+
- data (JSON)
+
- created_at, expires_at (timestamps)
+
### oauth_sessions
+
- sub (primary key - subject/DID)
+
- data (JSON with OAuth session)
+
- updated_at, expires_at
+
### oauth_keys
+
- kid (primary key - key ID)
+
- jwk (JSON Web Key)
+
- created_at
+
+
### domains
+
- domain (primary key - e.g., alice.wisp.place)
+
- did (unique - user's DID)
+
- rkey (optional - record key)
+
- created_at
+
+
### custom_domains
+
- id (primary key - UUID)
+
- domain (unique - e.g., example.com)
+
- did (user's DID)
+
- rkey (optional)
+
- verified (boolean)
+
- last_verified_at (timestamp)
+
- created_at
+
+
### sites
+
- id, did, rkey, site_name
+
- created_at, updated_at
+
- Indexes on (did), (did, rkey), (rkey)
+
+
### admin_users
+
- username (primary key)
+
- password_hash (bcrypt)
+
- created_at
+
+
---
+
+
## ๐Ÿš€ Key Workflows
+
+
### Sign In Flow
+
1. POST /api/auth/signin with handle
+
2. System generates state token
+
3. Redirects to PDS OAuth endpoint
+
4. PDS redirects back to /api/auth/callback?code=X&state=Y
+
5. Validate state (CSRF protection)
+
6. Exchange code for session
+
7. Store session in DB, set DID cookie
+
8. Sync sites from PDS
+
9. Redirect to /editor or /onboarding
+
+
### File Upload Flow
+
1. POST /wisp/upload-files with siteName + files
+
2. Validate site name (rkey format rules)
+
3. For each file:
+
- Check size limits
+
- Read as ArrayBuffer
+
- Gzip compress
+
- Base64 encode
+
4. Upload all blobs in parallel via agent.com.atproto.repo.uploadBlob()
+
5. Create manifest with all blob refs
+
6. putRecord() for place.wisp.fs with manifest
+
7. Upsert to sites table
+
8. Return URI + CID
+
### Domain Verification Flow
+
1. POST /api/custom-domains/claim
+
2. Generate hash = SHA256(domain + secret)
+
3. Store in custom_domains with verified=false
+
4. Return hash for user to configure DNS
+
5. Background worker periodically:
+
- Query custom_domains where verified=false
+
- Verify TXT record at _wisp.domain
+
- Verify CNAME points to hash.dns.wisp.place
+
- Update verified flag + last_verified_at
+
6. Hosting service routes when verified=true
+
---
+
## ๐ŸŽจ Frontend Structure
+
### `/public`
+
- **index.tsx** - Landing page with sign-in form
+
- **editor/editor.tsx** - Site editor/management UI
+
- **admin/admin.tsx** - Admin dashboard
+
- **components/ui/** - Reusable components (Button, Card, Dialog, etc.)
+
- **styles/global.css** - Tailwind + custom styles
+
### Page Flow
+
1. `/` - Landing page (sign in / get started)
+
2. `/editor` - Main app (requires auth)
+
3. `/admin` - Admin console (requires admin auth)
+
4. `/onboarding` - First-time user setup
+
---
+
+
## ๐Ÿ” Notable Implementation Patterns
+
+
### File Handling
+
- Files stored as base64-encoded gzip in PDS blobs
+
- Metadata preserves original MIME type
+
- Hosting service decompresses on serve
+
- Workaround for PDS image pipeline issues with HTML
+
+
### Error Handling
+
- Comprehensive logging with context
+
- Graceful degradation (e.g., site sync failure doesn't break auth)
+
- Structured error responses with details
+
+
### Performance
+
- Site sync: Batch fetch up to 100 records per request
+
- Blob upload: Parallel promises for all files
+
- DNS verification: Batched background worker (10 min intervals)
+
- Caching: Two-tier (DB + disk in hosting service)
+
+
### Validation
+
- Lexicon validation on manifest creation
+
- Record type checking
+
- Domain format validation
+
- Site name format validation (AT Protocol rkey rules)
+
- File size limits enforced before upload
+
+
---
+
+
## ๐Ÿ› Known Quirks & Workarounds
+
+
1. **PDS Content Sniffing**: Files must be uploaded as `application/octet-stream` (even HTML) and base64-encoded to prevent PDS from misinterpreting content
+
+
2. **Max URL Query Size**: DNS verification worker queries in batch; may need pagination for users with many custom domains
+
+
3. **File Count Limits**: Max 500 entries per directory (Lexicon constraint); large sites split across multiple directories
+
+
4. **Blob Size Limits**: Individual blobs limited to 100MB by Lexicon; large files handled differently if needed
+
+
5. **HTML Path Rewriting**: Only in hosting service for `/s.wisp.place/:identifier/:site/*` routes; custom domains handled differently
+
+
---
+
+
## ๐Ÿ“‹ Environment Variables
+
+
- `DOMAIN` - Base domain with protocol (default: `https://wisp.place`)
+
- `CLIENT_NAME` - OAuth client name (default: `PDS-View`)
+
- `DATABASE_URL` - PostgreSQL connection (default: `postgres://postgres:postgres@localhost:5432/wisp`)
+
- `NODE_ENV` - production/development
+
- `HOSTING_PORT` - Hosting service port (default: 3001)
+
- `BASE_DOMAIN` - Domain for URLs (default: wisp.place)
+
+
---
+
+
## ๐Ÿง‘โ€๐Ÿ’ป Development Notes
+
+
### Adding New Features
+
1. **New routes**: Add to `/src/routes/*.ts`, import in index.ts
+
2. **DB changes**: Add migration in db.ts
+
3. **New lexicons**: Update `/lexicons/*.json`, regenerate types
+
4. **Admin features**: Add to /api/admin endpoints
+
+
### Testing
+
- Run with `bun test`
+
- CSRF tests in lib/csrf.test.ts
+
- Utility tests in lib/wisp-utils.test.ts
+
+
### Debugging
+
- Check logs via `/api/admin/logs` (requires admin auth)
+
- DNS verification manual trigger: POST /api/admin/verify-dns
+
- Health check: GET /api/health (includes DNS verifier status)
+
+
---
+
+
## ๐Ÿš€ Deployment Considerations
+
+
1. **Secrets**: Admin password, OAuth keys, database credentials
+
2. **HTTPS**: Required (HSTS header enforces it)
+
3. **CDN**: Custom domains require DNS configuration
+
4. **Scaling**:
+
- Main server: Horizontal scaling with session DB
+
- Hosting service: Independent scaling, disk cache per instance
+
5. **Backups**: PostgreSQL database critical; firehose provides recovery
+
+
---
+
+
## ๐Ÿ“š Related Technologies
+
+
- **AT Protocol**: Decentralized identity, OAuth 2.0
+
- **Jacquard**: Rust library for AT Protocol interactions
+
- **Elysia**: Bun web framework (similar to Express/Hono)
+
- **Lexicon**: AT Protocol's schema definition language
+
- **Firehose**: Real-time event stream of repo changes
+
- **PDS**: Personal Data Server (where users' data stored)
+
+
---
+
+
## ๐ŸŽฏ Project Goals
+
+
โœ… Decentralized site hosting (data owned by users)
+
โœ… Custom domain support with DNS verification
+
โœ… Fast CDN distribution via hosting service
+
โœ… Developer tools (CLI + API)
+
โœ… Admin dashboard for monitoring
+
โœ… Zero user data retention (sites in PDS, sessions in DB only)
+
+
---
+
+
**Last Updated**: November 2025
+
**Status**: Active development
+24
cli/.gitignore
···
···
+
.DS_STORE
+
jacquard/
+
binaries/
+
# Generated by Cargo
+
# will have compiled files and executables
+
debug
+
target
+
+
# These are backup files generated by rustfmt
+
**/*.rs.bk
+
+
# MSVC Windows builds of rustc generate these, which store debugging information
+
*.pdb
+
+
# Generated by cargo mutants
+
# Contains mutation testing data
+
**/mutants.out*/
+
+
# RustRover
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
+
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
+
# and can be added to the global gitignore or merged into this file. For a more nuclear
+
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
+
#.idea/
+9 -151
cli/Cargo.lock
···
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
-
name = "foreign-types"
-
version = "0.3.2"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
-
dependencies = [
-
"foreign-types-shared",
-
]
-
-
[[package]]
-
name = "foreign-types-shared"
-
version = "0.1.1"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
-
-
[[package]]
name = "form_urlencoded"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
]
[[package]]
-
name = "hyper-tls"
-
version = "0.6.0"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
-
dependencies = [
-
"bytes",
-
"http-body-util",
-
"hyper",
-
"hyper-util",
-
"native-tls",
-
"tokio",
-
"tokio-native-tls",
-
"tower-service",
-
]
-
-
[[package]]
name = "hyper-util"
version = "0.1.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
[[package]]
name = "jacquard"
version = "0.9.0"
dependencies = [
"bytes",
"getrandom 0.2.16",
···
[[package]]
name = "jacquard-api"
version = "0.9.0"
dependencies = [
"bon",
"bytes",
···
[[package]]
name = "jacquard-common"
version = "0.9.0"
dependencies = [
"base64 0.22.1",
"bon",
···
[[package]]
name = "jacquard-derive"
version = "0.9.0"
dependencies = [
"heck 0.5.0",
"jacquard-lexicon",
···
[[package]]
name = "jacquard-identity"
-
version = "0.9.0"
dependencies = [
"bon",
"bytes",
···
[[package]]
name = "jacquard-lexicon"
-
version = "0.9.0"
dependencies = [
"cid",
"dashmap",
···
[[package]]
name = "jacquard-oauth"
version = "0.9.0"
dependencies = [
"base64 0.22.1",
"bytes",
···
]
[[package]]
-
name = "native-tls"
-
version = "0.2.14"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e"
-
dependencies = [
-
"libc",
-
"log",
-
"openssl",
-
"openssl-probe",
-
"openssl-sys",
-
"schannel",
-
"security-framework",
-
"security-framework-sys",
-
"tempfile",
-
]
-
-
[[package]]
name = "ndk-context"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
-
name = "openssl"
-
version = "0.10.74"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "24ad14dd45412269e1a30f52ad8f0664f0f4f4a89ee8fe28c3b3527021ebb654"
-
dependencies = [
-
"bitflags",
-
"cfg-if",
-
"foreign-types",
-
"libc",
-
"once_cell",
-
"openssl-macros",
-
"openssl-sys",
-
]
-
-
[[package]]
-
name = "openssl-macros"
-
version = "0.1.1"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
-
dependencies = [
-
"proc-macro2",
-
"quote",
-
"syn 2.0.108",
-
]
-
-
[[package]]
-
name = "openssl-probe"
-
version = "0.1.6"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
-
-
[[package]]
-
name = "openssl-sys"
-
version = "0.9.110"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "0a9f0075ba3c21b09f8e8b2026584b1d18d49388648f2fbbf3c97ea8deced8e2"
-
dependencies = [
-
"cc",
-
"libc",
-
"pkg-config",
-
"vcpkg",
-
]
-
-
[[package]]
name = "option-ext"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
"der",
"spki",
]
-
-
[[package]]
-
name = "pkg-config"
-
version = "0.3.32"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]]
name = "potential_utf"
···
"http-body-util",
"hyper",
"hyper-rustls",
-
"hyper-tls",
"hyper-util",
"js-sys",
"log",
"mime",
-
"native-tls",
"percent-encoding",
"pin-project-lite",
"quinn",
···
"serde_urlencoded",
"sync_wrapper",
"tokio",
-
"tokio-native-tls",
"tokio-rustls",
"tokio-util",
"tower",
···
]
[[package]]
-
name = "schannel"
-
version = "0.1.28"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1"
-
dependencies = [
-
"windows-sys 0.61.2",
-
]
-
-
[[package]]
name = "schemars"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
"pkcs8",
"subtle",
"zeroize",
-
]
-
-
[[package]]
-
name = "security-framework"
-
version = "2.11.1"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
-
dependencies = [
-
"bitflags",
-
"core-foundation 0.9.4",
-
"core-foundation-sys",
-
"libc",
-
"security-framework-sys",
-
]
-
-
[[package]]
-
name = "security-framework-sys"
-
version = "2.15.0"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0"
-
dependencies = [
-
"core-foundation-sys",
-
"libc",
]
[[package]]
···
]
[[package]]
-
name = "tokio-native-tls"
-
version = "0.3.1"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
-
dependencies = [
-
"native-tls",
-
"tokio",
-
]
-
-
[[package]]
name = "tokio-rustls"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
-
-
[[package]]
-
name = "vcpkg"
-
version = "0.2.15"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "version_check"
···
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "form_urlencoded"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
]
[[package]]
name = "hyper-util"
version = "0.1.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
[[package]]
name = "jacquard"
version = "0.9.0"
+
source = "git+https://tangled.org/@nonbinary.computer/jacquard#b5cc9b35e38e24e1890ae55e700dcfad0d6d433a"
dependencies = [
"bytes",
"getrandom 0.2.16",
···
[[package]]
name = "jacquard-api"
version = "0.9.0"
+
source = "git+https://tangled.org/@nonbinary.computer/jacquard#b5cc9b35e38e24e1890ae55e700dcfad0d6d433a"
dependencies = [
"bon",
"bytes",
···
[[package]]
name = "jacquard-common"
version = "0.9.0"
+
source = "git+https://tangled.org/@nonbinary.computer/jacquard#b5cc9b35e38e24e1890ae55e700dcfad0d6d433a"
dependencies = [
"base64 0.22.1",
"bon",
···
[[package]]
name = "jacquard-derive"
version = "0.9.0"
+
source = "git+https://tangled.org/@nonbinary.computer/jacquard#b5cc9b35e38e24e1890ae55e700dcfad0d6d433a"
dependencies = [
"heck 0.5.0",
"jacquard-lexicon",
···
[[package]]
name = "jacquard-identity"
+
version = "0.9.1"
+
source = "git+https://tangled.org/@nonbinary.computer/jacquard#b5cc9b35e38e24e1890ae55e700dcfad0d6d433a"
dependencies = [
"bon",
"bytes",
···
[[package]]
name = "jacquard-lexicon"
+
version = "0.9.1"
+
source = "git+https://tangled.org/@nonbinary.computer/jacquard#b5cc9b35e38e24e1890ae55e700dcfad0d6d433a"
dependencies = [
"cid",
"dashmap",
···
[[package]]
name = "jacquard-oauth"
version = "0.9.0"
+
source = "git+https://tangled.org/@nonbinary.computer/jacquard#b5cc9b35e38e24e1890ae55e700dcfad0d6d433a"
dependencies = [
"base64 0.22.1",
"bytes",
···
]
[[package]]
name = "ndk-context"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "option-ext"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
"der",
"spki",
]
[[package]]
name = "potential_utf"
···
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-util",
"js-sys",
"log",
"mime",
"percent-encoding",
"pin-project-lite",
"quinn",
···
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-rustls",
"tokio-util",
"tower",
···
]
[[package]]
name = "schemars"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
"pkcs8",
"subtle",
"zeroize",
]
[[package]]
···
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "version_check"
+9 -8
cli/Cargo.toml
···
place_wisp = []
[dependencies]
-
jacquard = { path = "jacquard/crates/jacquard", features = ["loopback"] }
-
jacquard-oauth = { path = "jacquard/crates/jacquard-oauth" }
-
jacquard-api = { path = "jacquard/crates/jacquard-api" }
-
jacquard-common = { path = "jacquard/crates/jacquard-common" }
-
jacquard-identity = { path = "jacquard/crates/jacquard-identity", features = ["dns"] }
-
jacquard-derive = { path = "jacquard/crates/jacquard-derive" }
-
jacquard-lexicon = { path = "jacquard/crates/jacquard-lexicon" }
clap = { version = "4.5.51", features = ["derive"] }
tokio = { version = "1.48", features = ["full"] }
miette = { version = "7.6.0", features = ["fancy"] }
serde_json = "1.0.145"
serde = { version = "1.0", features = ["derive"] }
shellexpand = "3.1.1"
-
reqwest = "0.12"
rustversion = "1.0"
flate2 = "1.0"
base64 = "0.22"
···
place_wisp = []
[dependencies]
+
jacquard = { git = "https://tangled.org/@nonbinary.computer/jacquard", features = ["loopback"] }
+
jacquard-oauth = { git = "https://tangled.org/@nonbinary.computer/jacquard" }
+
jacquard-api = { git = "https://tangled.org/@nonbinary.computer/jacquard" }
+
jacquard-common = { git = "https://tangled.org/@nonbinary.computer/jacquard" }
+
jacquard-identity = { git = "https://tangled.org/@nonbinary.computer/jacquard", features = ["dns"] }
+
jacquard-derive = { git = "https://tangled.org/@nonbinary.computer/jacquard" }
+
jacquard-lexicon = { git = "https://tangled.org/@nonbinary.computer/jacquard" }
clap = { version = "4.5.51", features = ["derive"] }
tokio = { version = "1.48", features = ["full"] }
miette = { version = "7.6.0", features = ["fancy"] }
serde_json = "1.0.145"
serde = { version = "1.0", features = ["derive"] }
shellexpand = "3.1.1"
+
#reqwest = "0.12"
+
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
rustversion = "1.0"
flate2 = "1.0"
base64 = "0.22"
+23
cli/build-linux.sh
···
···
+
#!/usr/bin/env bash
+
# Build Linux binaries (statically linked)
+
set -e
+
mkdir -p binaries
+
+
# Build Linux binaries
+
echo "Building Linux binaries..."
+
+
echo "Building Linux ARM64 (static)..."
+
nix-shell -p rustup --run '
+
rustup target add aarch64-unknown-linux-musl
+
RUSTFLAGS="-C target-feature=+crt-static" cargo zigbuild --release --target aarch64-unknown-linux-musl
+
'
+
cp target/aarch64-unknown-linux-musl/release/wisp-cli binaries/wisp-cli-aarch64-linux
+
+
echo "Building Linux x86_64 (static)..."
+
nix-shell -p rustup --run '
+
rustup target add x86_64-unknown-linux-musl
+
RUSTFLAGS="-C target-feature=+crt-static" cargo build --release --target x86_64-unknown-linux-musl
+
'
+
cp target/x86_64-unknown-linux-musl/release/wisp-cli binaries/wisp-cli-x86_64-linux
+
+
echo "Done! Binaries in ./binaries/"
+15
cli/build-macos.sh
···
···
+
#!/bin/bash
+
# Build Linux and macOS binaries
+
+
set -e
+
+
mkdir -p binaries
+
rm -rf target
+
+
# Build macOS binaries natively
+
echo "Building macOS binaries..."
+
rustup target add aarch64-apple-darwin
+
+
echo "Building macOS arm64 binary."
+
RUSTFLAGS="-C target-feature=+crt-static" cargo build --release --target aarch64-apple-darwin
+
cp target/aarch64-apple-darwin/release/wisp-cli binaries/wisp-cli-macos-arm64
-14
cli/test_headers.rs
···
-
use http::Request;
-
-
fn main() {
-
let builder = Request::builder()
-
.header(http::header::CONTENT_TYPE, "*/*")
-
.header(http::header::CONTENT_TYPE, "application/octet-stream");
-
-
let req = builder.body(()).unwrap();
-
-
println!("Content-Type headers:");
-
for value in req.headers().get_all(http::header::CONTENT_TYPE) {
-
println!(" {:?}", value);
-
}
-
}
···
+63
crates.nix
···
···
+
{...}: {
+
perSystem = {
+
pkgs,
+
config,
+
lib,
+
inputs',
+
...
+
}: {
+
# declare projects
+
nci.projects."wisp-place-cli" = {
+
path = ./cli;
+
export = false;
+
};
+
nci.toolchains.mkBuild = _:
+
with inputs'.fenix.packages;
+
combine [
+
minimal.rustc
+
minimal.cargo
+
targets.x86_64-pc-windows-gnu.latest.rust-std
+
targets.x86_64-unknown-linux-gnu.latest.rust-std
+
targets.aarch64-apple-darwin.latest.rust-std
+
];
+
# configure crates
+
nci.crates."wisp-cli" = {
+
profiles = {
+
dev.runTests = false;
+
release.runTests = false;
+
};
+
targets."x86_64-unknown-linux-gnu" = {
+
default = true;
+
};
+
targets."x86_64-pc-windows-gnu" = let
+
targetPkgs = pkgs.pkgsCross.mingwW64;
+
targetCC = targetPkgs.stdenv.cc;
+
targetCargoEnvVarTarget = targetPkgs.stdenv.hostPlatform.rust.cargoEnvVarTarget;
+
in rec {
+
depsDrvConfig.mkDerivation = {
+
nativeBuildInputs = [targetCC];
+
buildInputs = with targetPkgs; [windows.pthreads];
+
};
+
depsDrvConfig.env = rec {
+
TARGET_CC = "${targetCC.targetPrefix}cc";
+
"CARGO_TARGET_${targetCargoEnvVarTarget}_LINKER" = TARGET_CC;
+
};
+
drvConfig = depsDrvConfig;
+
};
+
targets."aarch64-apple-darwin" = let
+
targetPkgs = pkgs.pkgsCross.aarch64-darwin;
+
targetCC = targetPkgs.stdenv.cc;
+
targetCargoEnvVarTarget = targetPkgs.stdenv.hostPlatform.rust.cargoEnvVarTarget;
+
in rec {
+
depsDrvConfig.mkDerivation = {
+
nativeBuildInputs = [targetCC];
+
};
+
depsDrvConfig.env = rec {
+
TARGET_CC = "${targetCC.targetPrefix}cc";
+
"CARGO_TARGET_${targetCargoEnvVarTarget}_LINKER" = TARGET_CC;
+
};
+
drvConfig = depsDrvConfig;
+
};
+
};
+
};
+
}
+318
flake.lock
···
···
+
{
+
"nodes": {
+
"crane": {
+
"flake": false,
+
"locked": {
+
"lastModified": 1758758545,
+
"narHash": "sha256-NU5WaEdfwF6i8faJ2Yh+jcK9vVFrofLcwlD/mP65JrI=",
+
"owner": "ipetkov",
+
"repo": "crane",
+
"rev": "95d528a5f54eaba0d12102249ce42f4d01f4e364",
+
"type": "github"
+
},
+
"original": {
+
"owner": "ipetkov",
+
"ref": "v0.21.1",
+
"repo": "crane",
+
"type": "github"
+
}
+
},
+
"dream2nix": {
+
"inputs": {
+
"nixpkgs": [
+
"nci",
+
"nixpkgs"
+
],
+
"purescript-overlay": "purescript-overlay",
+
"pyproject-nix": "pyproject-nix"
+
},
+
"locked": {
+
"lastModified": 1754978539,
+
"narHash": "sha256-nrDovydywSKRbWim9Ynmgj8SBm8LK3DI2WuhIqzOHYI=",
+
"owner": "nix-community",
+
"repo": "dream2nix",
+
"rev": "fbec3263cb4895ac86ee9506cdc4e6919a1a2214",
+
"type": "github"
+
},
+
"original": {
+
"owner": "nix-community",
+
"repo": "dream2nix",
+
"type": "github"
+
}
+
},
+
"fenix": {
+
"inputs": {
+
"nixpkgs": [
+
"nixpkgs"
+
],
+
"rust-analyzer-src": "rust-analyzer-src"
+
},
+
"locked": {
+
"lastModified": 1762584108,
+
"narHash": "sha256-wZUW7dlXMXaRdvNbaADqhF8gg9bAfFiMV+iyFQiDv+Y=",
+
"owner": "nix-community",
+
"repo": "fenix",
+
"rev": "32f3ad3b6c690061173e1ac16708874975ec6056",
+
"type": "github"
+
},
+
"original": {
+
"owner": "nix-community",
+
"repo": "fenix",
+
"type": "github"
+
}
+
},
+
"flake-compat": {
+
"flake": false,
+
"locked": {
+
"lastModified": 1696426674,
+
"narHash": "sha256-kvjfFW7WAETZlt09AgDn1MrtKzP7t90Vf7vypd3OL1U=",
+
"owner": "edolstra",
+
"repo": "flake-compat",
+
"rev": "0f9255e01c2351cc7d116c072cb317785dd33b33",
+
"type": "github"
+
},
+
"original": {
+
"owner": "edolstra",
+
"repo": "flake-compat",
+
"type": "github"
+
}
+
},
+
"mk-naked-shell": {
+
"flake": false,
+
"locked": {
+
"lastModified": 1681286841,
+
"narHash": "sha256-3XlJrwlR0nBiREnuogoa5i1b4+w/XPe0z8bbrJASw0g=",
+
"owner": "90-008",
+
"repo": "mk-naked-shell",
+
"rev": "7612f828dd6f22b7fb332cc69440e839d7ffe6bd",
+
"type": "github"
+
},
+
"original": {
+
"owner": "90-008",
+
"repo": "mk-naked-shell",
+
"type": "github"
+
}
+
},
+
"nci": {
+
"inputs": {
+
"crane": "crane",
+
"dream2nix": "dream2nix",
+
"mk-naked-shell": "mk-naked-shell",
+
"nixpkgs": [
+
"nixpkgs"
+
],
+
"parts": "parts",
+
"rust-overlay": "rust-overlay",
+
"treefmt": "treefmt"
+
},
+
"locked": {
+
"lastModified": 1762582646,
+
"narHash": "sha256-MMzE4xccG+8qbLhdaZoeFDUKWUOn3B4lhp5dZmgukmM=",
+
"owner": "90-008",
+
"repo": "nix-cargo-integration",
+
"rev": "0993c449377049fa8868a664e8290ac6658e0b9a",
+
"type": "github"
+
},
+
"original": {
+
"owner": "90-008",
+
"repo": "nix-cargo-integration",
+
"type": "github"
+
}
+
},
+
"nixpkgs": {
+
"locked": {
+
"lastModified": 1762361079,
+
"narHash": "sha256-lz718rr1BDpZBYk7+G8cE6wee3PiBUpn8aomG/vLLiY=",
+
"owner": "nixos",
+
"repo": "nixpkgs",
+
"rev": "ffcdcf99d65c61956d882df249a9be53e5902ea5",
+
"type": "github"
+
},
+
"original": {
+
"owner": "nixos",
+
"ref": "nixpkgs-unstable",
+
"repo": "nixpkgs",
+
"type": "github"
+
}
+
},
+
"parts": {
+
"inputs": {
+
"nixpkgs-lib": [
+
"nci",
+
"nixpkgs"
+
]
+
},
+
"locked": {
+
"lastModified": 1762440070,
+
"narHash": "sha256-xxdepIcb39UJ94+YydGP221rjnpkDZUlykKuF54PsqI=",
+
"owner": "hercules-ci",
+
"repo": "flake-parts",
+
"rev": "26d05891e14c88eb4a5d5bee659c0db5afb609d8",
+
"type": "github"
+
},
+
"original": {
+
"owner": "hercules-ci",
+
"repo": "flake-parts",
+
"type": "github"
+
}
+
},
+
"parts_2": {
+
"inputs": {
+
"nixpkgs-lib": [
+
"nixpkgs"
+
]
+
},
+
"locked": {
+
"lastModified": 1762440070,
+
"narHash": "sha256-xxdepIcb39UJ94+YydGP221rjnpkDZUlykKuF54PsqI=",
+
"owner": "hercules-ci",
+
"repo": "flake-parts",
+
"rev": "26d05891e14c88eb4a5d5bee659c0db5afb609d8",
+
"type": "github"
+
},
+
"original": {
+
"owner": "hercules-ci",
+
"repo": "flake-parts",
+
"type": "github"
+
}
+
},
+
"purescript-overlay": {
+
"inputs": {
+
"flake-compat": "flake-compat",
+
"nixpkgs": [
+
"nci",
+
"dream2nix",
+
"nixpkgs"
+
],
+
"slimlock": "slimlock"
+
},
+
"locked": {
+
"lastModified": 1728546539,
+
"narHash": "sha256-Sws7w0tlnjD+Bjck1nv29NjC5DbL6nH5auL9Ex9Iz2A=",
+
"owner": "thomashoneyman",
+
"repo": "purescript-overlay",
+
"rev": "4ad4c15d07bd899d7346b331f377606631eb0ee4",
+
"type": "github"
+
},
+
"original": {
+
"owner": "thomashoneyman",
+
"repo": "purescript-overlay",
+
"type": "github"
+
}
+
},
+
"pyproject-nix": {
+
"inputs": {
+
"nixpkgs": [
+
"nci",
+
"dream2nix",
+
"nixpkgs"
+
]
+
},
+
"locked": {
+
"lastModified": 1752481895,
+
"narHash": "sha256-luVj97hIMpCbwhx3hWiRwjP2YvljWy8FM+4W9njDhLA=",
+
"owner": "pyproject-nix",
+
"repo": "pyproject.nix",
+
"rev": "16ee295c25107a94e59a7fc7f2e5322851781162",
+
"type": "github"
+
},
+
"original": {
+
"owner": "pyproject-nix",
+
"repo": "pyproject.nix",
+
"type": "github"
+
}
+
},
+
"root": {
+
"inputs": {
+
"fenix": "fenix",
+
"nci": "nci",
+
"nixpkgs": "nixpkgs",
+
"parts": "parts_2"
+
}
+
},
+
"rust-analyzer-src": {
+
"flake": false,
+
"locked": {
+
"lastModified": 1762438844,
+
"narHash": "sha256-ApIKJf6CcMsV2nYBXhGF95BmZMO/QXPhgfSnkA/rVUo=",
+
"owner": "rust-lang",
+
"repo": "rust-analyzer",
+
"rev": "4bf516ee5a960c1e2eee9fedd9b1c9e976a19c86",
+
"type": "github"
+
},
+
"original": {
+
"owner": "rust-lang",
+
"ref": "nightly",
+
"repo": "rust-analyzer",
+
"type": "github"
+
}
+
},
+
"rust-overlay": {
+
"inputs": {
+
"nixpkgs": [
+
"nci",
+
"nixpkgs"
+
]
+
},
+
"locked": {
+
"lastModified": 1762569282,
+
"narHash": "sha256-vINZAJpXQTZd5cfh06Rcw7hesH7sGSvi+Tn+HUieJn8=",
+
"owner": "oxalica",
+
"repo": "rust-overlay",
+
"rev": "a35a6144b976f70827c2fe2f5c89d16d8f9179d8",
+
"type": "github"
+
},
+
"original": {
+
"owner": "oxalica",
+
"repo": "rust-overlay",
+
"type": "github"
+
}
+
},
+
"slimlock": {
+
"inputs": {
+
"nixpkgs": [
+
"nci",
+
"dream2nix",
+
"purescript-overlay",
+
"nixpkgs"
+
]
+
},
+
"locked": {
+
"lastModified": 1688756706,
+
"narHash": "sha256-xzkkMv3neJJJ89zo3o2ojp7nFeaZc2G0fYwNXNJRFlo=",
+
"owner": "thomashoneyman",
+
"repo": "slimlock",
+
"rev": "cf72723f59e2340d24881fd7bf61cb113b4c407c",
+
"type": "github"
+
},
+
"original": {
+
"owner": "thomashoneyman",
+
"repo": "slimlock",
+
"type": "github"
+
}
+
},
+
"treefmt": {
+
"inputs": {
+
"nixpkgs": [
+
"nci",
+
"nixpkgs"
+
]
+
},
+
"locked": {
+
"lastModified": 1762410071,
+
"narHash": "sha256-aF5fvoZeoXNPxT0bejFUBXeUjXfHLSL7g+mjR/p5TEg=",
+
"owner": "numtide",
+
"repo": "treefmt-nix",
+
"rev": "97a30861b13c3731a84e09405414398fbf3e109f",
+
"type": "github"
+
},
+
"original": {
+
"owner": "numtide",
+
"repo": "treefmt-nix",
+
"type": "github"
+
}
+
}
+
},
+
"root": "root",
+
"version": 7
+
}
+36
flake.nix
···
···
+
{
+
inputs.nixpkgs.url = "github:nixos/nixpkgs/nixpkgs-unstable";
+
inputs.nci.url = "github:90-008/nix-cargo-integration";
+
inputs.nci.inputs.nixpkgs.follows = "nixpkgs";
+
inputs.parts.url = "github:hercules-ci/flake-parts";
+
inputs.parts.inputs.nixpkgs-lib.follows = "nixpkgs";
+
inputs.fenix = {
+
url = "github:nix-community/fenix";
+
inputs.nixpkgs.follows = "nixpkgs";
+
};
+
+
outputs = inputs @ {
+
parts,
+
nci,
+
...
+
}:
+
parts.lib.mkFlake {inherit inputs;} {
+
systems = ["x86_64-linux" "aarch64-darwin"];
+
imports = [
+
nci.flakeModule
+
./crates.nix
+
];
+
perSystem = {
+
pkgs,
+
config,
+
...
+
}: let
+
crateOutputs = config.nci.outputs."wisp-cli";
+
in {
+
devShells.default = crateOutputs.devShell;
+
packages.default = crateOutputs.packages.release;
+
packages.wisp-cli-windows = crateOutputs.allTargets."x86_64-pc-windows-gnu".packages.release;
+
packages.wisp-cli-darwin = crateOutputs.allTargets."aarch64-apple-darwin".packages.release;
+
};
+
};
+
}
+3 -2
hosting-service/package.json
···
"version": "1.0.0",
"type": "module",
"scripts": {
-
"dev": "tsx watch src/index.ts",
"build": "tsc",
-
"start": "tsx src/index.ts"
},
"dependencies": {
"@atproto/api": "^0.17.4",
···
"version": "1.0.0",
"type": "module",
"scripts": {
+
"dev": "tsx --env-file=.env watch src/index.ts",
"build": "tsc",
+
"start": "tsx --env-file=.env src/index.ts",
+
"backfill": "tsx --env-file=.env src/index.ts --backfill"
},
"dependencies": {
"@atproto/api": "^0.17.4",
+20 -1
hosting-service/src/index.ts
···
import { FirehoseWorker } from './lib/firehose';
import { logger } from './lib/observability';
import { mkdirSync, existsSync } from 'fs';
const PORT = process.env.PORT ? parseInt(process.env.PORT) : 3001;
-
const CACHE_DIR = './cache/sites';
// Ensure cache directory exists
if (!existsSync(CACHE_DIR)) {
···
});
firehose.start();
// Add health check endpoint
app.get('/health', (c) => {
···
import { FirehoseWorker } from './lib/firehose';
import { logger } from './lib/observability';
import { mkdirSync, existsSync } from 'fs';
+
import { backfillCache } from './lib/backfill';
const PORT = process.env.PORT ? parseInt(process.env.PORT) : 3001;
+
const CACHE_DIR = process.env.CACHE_DIR || './cache/sites';
+
+
// Parse CLI arguments
+
const args = process.argv.slice(2);
+
const hasBackfillFlag = args.includes('--backfill');
+
const backfillOnStartup = hasBackfillFlag || process.env.BACKFILL_ON_STARTUP === 'true';
// Ensure cache directory exists
if (!existsSync(CACHE_DIR)) {
···
});
firehose.start();
+
+
// Run backfill if requested
+
if (backfillOnStartup) {
+
console.log('๐Ÿ”„ Backfill requested, starting cache backfill...');
+
backfillCache({
+
skipExisting: true,
+
concurrency: 3,
+
}).then((stats) => {
+
console.log('โœ… Cache backfill completed');
+
}).catch((err) => {
+
console.error('โŒ Cache backfill error:', err);
+
});
+
}
// Add health check endpoint
app.get('/health', (c) => {
+136
hosting-service/src/lib/backfill.ts
···
···
+
import { getAllSites } from './db';
+
import { fetchSiteRecord, getPdsForDid, downloadAndCacheSite, isCached } from './utils';
+
import { logger } from './observability';
+
+
export interface BackfillOptions {
+
skipExisting?: boolean; // Skip sites already in cache
+
concurrency?: number; // Number of sites to cache concurrently
+
maxSites?: number; // Maximum number of sites to backfill (for testing)
+
}
+
+
export interface BackfillStats {
+
total: number;
+
cached: number;
+
skipped: number;
+
failed: number;
+
duration: number;
+
}
+
+
/**
+
* Backfill all sites from the database into the local cache
+
*/
+
export async function backfillCache(options: BackfillOptions = {}): Promise<BackfillStats> {
+
const {
+
skipExisting = true,
+
concurrency = 3,
+
maxSites,
+
} = options;
+
+
const startTime = Date.now();
+
const stats: BackfillStats = {
+
total: 0,
+
cached: 0,
+
skipped: 0,
+
failed: 0,
+
duration: 0,
+
};
+
+
logger.info('Starting cache backfill', { skipExisting, concurrency, maxSites });
+
console.log(`
+
โ•”โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•—
+
โ•‘ CACHE BACKFILL STARTING โ•‘
+
โ•šโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
+
`);
+
+
try {
+
// Get all sites from database
+
let sites = await getAllSites();
+
stats.total = sites.length;
+
+
logger.info(`Found ${sites.length} sites in database`);
+
console.log(`๐Ÿ“Š Found ${sites.length} sites in database`);
+
+
// Limit if specified
+
if (maxSites && maxSites > 0) {
+
sites = sites.slice(0, maxSites);
+
console.log(`โš™๏ธ Limited to ${maxSites} sites for backfill`);
+
}
+
+
// Process sites in batches
+
const batches: typeof sites[] = [];
+
for (let i = 0; i < sites.length; i += concurrency) {
+
batches.push(sites.slice(i, i + concurrency));
+
}
+
+
let processed = 0;
+
for (const batch of batches) {
+
await Promise.all(
+
batch.map(async (site) => {
+
try {
+
// Check if already cached
+
if (skipExisting && isCached(site.did, site.rkey)) {
+
stats.skipped++;
+
processed++;
+
logger.debug(`Skipping already cached site`, { did: site.did, rkey: site.rkey });
+
console.log(`โญ๏ธ [${processed}/${sites.length}] Skipped (cached): ${site.display_name || site.rkey}`);
+
return;
+
}
+
+
// Fetch site record
+
const siteData = await fetchSiteRecord(site.did, site.rkey);
+
if (!siteData) {
+
stats.failed++;
+
processed++;
+
logger.error('Site record not found during backfill', null, { did: site.did, rkey: site.rkey });
+
console.log(`โŒ [${processed}/${sites.length}] Failed (not found): ${site.display_name || site.rkey}`);
+
return;
+
}
+
+
// Get PDS endpoint
+
const pdsEndpoint = await getPdsForDid(site.did);
+
if (!pdsEndpoint) {
+
stats.failed++;
+
processed++;
+
logger.error('PDS not found during backfill', null, { did: site.did });
+
console.log(`โŒ [${processed}/${sites.length}] Failed (no PDS): ${site.display_name || site.rkey}`);
+
return;
+
}
+
+
// Download and cache site
+
await downloadAndCacheSite(site.did, site.rkey, siteData.record, pdsEndpoint, siteData.cid);
+
stats.cached++;
+
processed++;
+
logger.info('Successfully cached site during backfill', { did: site.did, rkey: site.rkey });
+
console.log(`โœ… [${processed}/${sites.length}] Cached: ${site.display_name || site.rkey}`);
+
} catch (err) {
+
stats.failed++;
+
processed++;
+
logger.error('Failed to cache site during backfill', err, { did: site.did, rkey: site.rkey });
+
console.log(`โŒ [${processed}/${sites.length}] Failed: ${site.display_name || site.rkey}`);
+
}
+
})
+
);
+
}
+
+
stats.duration = Date.now() - startTime;
+
+
console.log(`
+
โ•”โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•—
+
โ•‘ CACHE BACKFILL COMPLETED โ•‘
+
โ•šโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
+
+
๐Ÿ“Š Total Sites: ${stats.total}
+
โœ… Cached: ${stats.cached}
+
โญ๏ธ Skipped: ${stats.skipped}
+
โŒ Failed: ${stats.failed}
+
โฑ๏ธ Duration: ${(stats.duration / 1000).toFixed(2)}s
+
`);
+
+
logger.info('Cache backfill completed', stats);
+
} catch (err) {
+
logger.error('Cache backfill failed', err);
+
console.error('โŒ Cache backfill failed:', err);
+
}
+
+
return stats;
+
}
+19
hosting-service/src/lib/db.ts
···
}
}
/**
* Generate a numeric lock ID from a string key
* PostgreSQL advisory locks use bigint (64-bit signed integer)
···
}
}
+
export interface SiteRecord {
+
did: string;
+
rkey: string;
+
display_name?: string;
+
}
+
+
export async function getAllSites(): Promise<SiteRecord[]> {
+
try {
+
const result = await sql<SiteRecord[]>`
+
SELECT did, rkey, display_name FROM sites
+
ORDER BY created_at DESC
+
`;
+
return result;
+
} catch (err) {
+
console.error('Failed to get all sites', err);
+
return [];
+
}
+
}
+
/**
* Generate a numeric lock ID from a string key
* PostgreSQL advisory locks use bigint (64-bit signed integer)
+259 -219
hosting-service/src/lib/firehose.ts
···
-
import { existsSync, rmSync } from 'fs';
-
import { getPdsForDid, downloadAndCacheSite, extractBlobCid, fetchSiteRecord } from './utils';
-
import { upsertSite, tryAcquireLock, releaseLock } from './db';
-
import { safeFetch } from './safe-fetch';
-
import { isRecord, validateRecord } from '../lexicon/types/place/wisp/fs';
-
import { Firehose } from '@atproto/sync';
-
import { IdResolver } from '@atproto/identity';
-
const CACHE_DIR = './cache/sites';
export class FirehoseWorker {
-
private firehose: Firehose | null = null;
-
private idResolver: IdResolver;
-
private isShuttingDown = false;
-
private lastEventTime = Date.now();
-
constructor(
-
private logger?: (msg: string, data?: Record<string, unknown>) => void,
-
) {
-
this.idResolver = new IdResolver();
-
}
-
private log(msg: string, data?: Record<string, unknown>) {
-
const log = this.logger || console.log;
-
log(`[FirehoseWorker] ${msg}`, data || {});
-
}
-
start() {
-
this.log('Starting firehose worker');
-
this.connect();
-
}
-
stop() {
-
this.log('Stopping firehose worker');
-
this.isShuttingDown = true;
-
if (this.firehose) {
-
this.firehose.destroy();
-
this.firehose = null;
-
}
-
}
-
private connect() {
-
if (this.isShuttingDown) return;
-
this.log('Connecting to AT Protocol firehose');
-
this.firehose = new Firehose({
-
idResolver: this.idResolver,
-
service: 'wss://bsky.network',
-
filterCollections: ['place.wisp.fs'],
-
handleEvent: async (evt: any) => {
-
this.lastEventTime = Date.now();
-
// Watch for write events
-
if (evt.event === 'create' || evt.event === 'update') {
-
const record = evt.record;
-
// If the write is a valid place.wisp.fs record
-
if (
-
evt.collection === 'place.wisp.fs' &&
-
isRecord(record) &&
-
validateRecord(record).success
-
) {
-
this.log('Received place.wisp.fs event', {
-
did: evt.did,
-
event: evt.event,
-
rkey: evt.rkey,
-
});
-
try {
-
await this.handleCreateOrUpdate(evt.did, evt.rkey, record, evt.cid?.toString());
-
} catch (err) {
-
this.log('Error handling event', {
-
did: evt.did,
-
event: evt.event,
-
rkey: evt.rkey,
-
error: err instanceof Error ? err.message : String(err),
-
});
-
}
-
}
-
} else if (evt.event === 'delete' && evt.collection === 'place.wisp.fs') {
-
this.log('Received delete event', {
-
did: evt.did,
-
rkey: evt.rkey,
-
});
-
try {
-
await this.handleDelete(evt.did, evt.rkey);
-
} catch (err) {
-
this.log('Error handling delete', {
-
did: evt.did,
-
rkey: evt.rkey,
-
error: err instanceof Error ? err.message : String(err),
-
});
-
}
-
}
-
},
-
onError: (err: any) => {
-
this.log('Firehose error', {
-
error: err instanceof Error ? err.message : String(err),
-
stack: err instanceof Error ? err.stack : undefined,
-
fullError: err,
-
});
-
console.error('Full firehose error:', err);
-
},
-
});
-
this.firehose.start();
-
this.log('Firehose started');
-
}
-
private async handleCreateOrUpdate(did: string, site: string, record: any, eventCid?: string) {
-
this.log('Processing create/update', { did, site });
-
// Record is already validated in handleEvent
-
const fsRecord = record;
-
const pdsEndpoint = await getPdsForDid(did);
-
if (!pdsEndpoint) {
-
this.log('Could not resolve PDS for DID', { did });
-
return;
-
}
-
this.log('Resolved PDS', { did, pdsEndpoint });
-
// Verify record exists on PDS and fetch its CID
-
let verifiedCid: string;
-
try {
-
const result = await fetchSiteRecord(did, site);
-
if (!result) {
-
this.log('Record not found on PDS, skipping cache', { did, site });
-
return;
-
}
-
verifiedCid = result.cid;
-
// Verify event CID matches PDS CID (prevent cache poisoning)
-
if (eventCid && eventCid !== verifiedCid) {
-
this.log('CID mismatch detected - potential spoofed event', {
-
did,
-
site,
-
eventCid,
-
verifiedCid
-
});
-
return;
-
}
-
this.log('Record verified on PDS', { did, site, cid: verifiedCid });
-
} catch (err) {
-
this.log('Failed to verify record on PDS', {
-
did,
-
site,
-
error: err instanceof Error ? err.message : String(err),
-
});
-
return;
-
}
-
// Cache the record with verified CID (uses atomic swap internally)
-
// All instances cache locally for edge serving
-
await downloadAndCacheSite(did, site, fsRecord, pdsEndpoint, verifiedCid);
-
// Acquire distributed lock only for database write to prevent duplicate writes
-
const lockKey = `db:upsert:${did}:${site}`;
-
const lockAcquired = await tryAcquireLock(lockKey);
-
if (!lockAcquired) {
-
this.log('Another instance is writing to DB, skipping upsert', { did, site });
-
this.log('Successfully processed create/update (cached locally)', { did, site });
-
return;
-
}
-
try {
-
// Upsert site to database (only one instance does this)
-
await upsertSite(did, site, fsRecord.site);
-
this.log('Successfully processed create/update (cached + DB updated)', { did, site });
-
} finally {
-
// Always release lock, even if DB write fails
-
await releaseLock(lockKey);
-
}
-
}
-
private async handleDelete(did: string, site: string) {
-
this.log('Processing delete', { did, site });
-
// All instances should delete their local cache (no lock needed)
-
const pdsEndpoint = await getPdsForDid(did);
-
if (!pdsEndpoint) {
-
this.log('Could not resolve PDS for DID', { did });
-
return;
-
}
-
// Verify record is actually deleted from PDS
-
try {
-
const recordUrl = `${pdsEndpoint}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=place.wisp.fs&rkey=${encodeURIComponent(site)}`;
-
const recordRes = await safeFetch(recordUrl);
-
if (recordRes.ok) {
-
this.log('Record still exists on PDS, not deleting cache', {
-
did,
-
site,
-
});
-
return;
-
}
-
this.log('Verified record is deleted from PDS', {
-
did,
-
site,
-
status: recordRes.status,
-
});
-
} catch (err) {
-
this.log('Error verifying deletion on PDS', {
-
did,
-
site,
-
error: err instanceof Error ? err.message : String(err),
-
});
-
}
-
// Delete cache
-
this.deleteCache(did, site);
-
this.log('Successfully processed delete', { did, site });
-
}
-
private deleteCache(did: string, site: string) {
-
const cacheDir = `${CACHE_DIR}/${did}/${site}`;
-
if (!existsSync(cacheDir)) {
-
this.log('Cache directory does not exist, nothing to delete', {
-
did,
-
site,
-
});
-
return;
-
}
-
try {
-
rmSync(cacheDir, { recursive: true, force: true });
-
this.log('Cache deleted', { did, site, path: cacheDir });
-
} catch (err) {
-
this.log('Failed to delete cache', {
-
did,
-
site,
-
path: cacheDir,
-
error: err instanceof Error ? err.message : String(err),
-
});
-
}
-
}
-
getHealth() {
-
const isConnected = this.firehose !== null;
-
const timeSinceLastEvent = Date.now() - this.lastEventTime;
-
return {
-
connected: isConnected,
-
lastEventTime: this.lastEventTime,
-
timeSinceLastEvent,
-
healthy: isConnected && timeSinceLastEvent < 300000, // 5 minutes
-
};
-
}
}
···
+
import { existsSync, rmSync } from 'fs'
+
import {
+
getPdsForDid,
+
downloadAndCacheSite,
+
extractBlobCid,
+
fetchSiteRecord
+
} from './utils'
+
import { upsertSite, tryAcquireLock, releaseLock } from './db'
+
import { safeFetch } from './safe-fetch'
+
import { isRecord, validateRecord } from '../lexicon/types/place/wisp/fs'
+
import { Firehose } from '@atproto/sync'
+
import { IdResolver } from '@atproto/identity'
+
const CACHE_DIR = './cache/sites'
export class FirehoseWorker {
+
private firehose: Firehose | null = null
+
private idResolver: IdResolver
+
private isShuttingDown = false
+
private lastEventTime = Date.now()
+
constructor(
+
private logger?: (msg: string, data?: Record<string, unknown>) => void
+
) {
+
this.idResolver = new IdResolver()
+
}
+
private log(msg: string, data?: Record<string, unknown>) {
+
const log = this.logger || console.log
+
log(`[FirehoseWorker] ${msg}`, data || {})
+
}
+
start() {
+
this.log('Starting firehose worker')
+
this.connect()
+
}
+
stop() {
+
this.log('Stopping firehose worker')
+
this.isShuttingDown = true
+
if (this.firehose) {
+
this.firehose.destroy()
+
this.firehose = null
+
}
+
}
+
private connect() {
+
if (this.isShuttingDown) return
+
this.log('Connecting to AT Protocol firehose')
+
this.firehose = new Firehose({
+
idResolver: this.idResolver,
+
service: 'wss://bsky.network',
+
filterCollections: ['place.wisp.fs'],
+
handleEvent: async (evt: any) => {
+
this.lastEventTime = Date.now()
+
// Watch for write events
+
if (evt.event === 'create' || evt.event === 'update') {
+
const record = evt.record
+
// If the write is a valid place.wisp.fs record
+
if (
+
evt.collection === 'place.wisp.fs' &&
+
isRecord(record) &&
+
validateRecord(record).success
+
) {
+
this.log('Received place.wisp.fs event', {
+
did: evt.did,
+
event: evt.event,
+
rkey: evt.rkey
+
})
+
try {
+
await this.handleCreateOrUpdate(
+
evt.did,
+
evt.rkey,
+
record,
+
evt.cid?.toString()
+
)
+
} catch (err) {
+
this.log('Error handling event', {
+
did: evt.did,
+
event: evt.event,
+
rkey: evt.rkey,
+
error:
+
err instanceof Error
+
? err.message
+
: String(err)
+
})
+
}
+
}
+
} else if (
+
evt.event === 'delete' &&
+
evt.collection === 'place.wisp.fs'
+
) {
+
this.log('Received delete event', {
+
did: evt.did,
+
rkey: evt.rkey
+
})
+
try {
+
await this.handleDelete(evt.did, evt.rkey)
+
} catch (err) {
+
this.log('Error handling delete', {
+
did: evt.did,
+
rkey: evt.rkey,
+
error:
+
err instanceof Error ? err.message : String(err)
+
})
+
}
+
}
+
},
+
onError: (err: any) => {
+
this.log('Firehose error', {
+
error: err instanceof Error ? err.message : String(err),
+
stack: err instanceof Error ? err.stack : undefined,
+
fullError: err
+
})
+
console.error('Full firehose error:', err)
+
}
+
})
+
this.firehose.start()
+
this.log('Firehose started')
+
}
+
private async handleCreateOrUpdate(
+
did: string,
+
site: string,
+
record: any,
+
eventCid?: string
+
) {
+
this.log('Processing create/update', { did, site })
+
// Record is already validated in handleEvent
+
const fsRecord = record
+
const pdsEndpoint = await getPdsForDid(did)
+
if (!pdsEndpoint) {
+
this.log('Could not resolve PDS for DID', { did })
+
return
+
}
+
this.log('Resolved PDS', { did, pdsEndpoint })
+
// Verify record exists on PDS and fetch its CID
+
let verifiedCid: string
+
try {
+
const result = await fetchSiteRecord(did, site)
+
if (!result) {
+
this.log('Record not found on PDS, skipping cache', {
+
did,
+
site
+
})
+
return
+
}
+
verifiedCid = result.cid
+
// Verify event CID matches PDS CID (prevent cache poisoning)
+
if (eventCid && eventCid !== verifiedCid) {
+
this.log('CID mismatch detected - potential spoofed event', {
+
did,
+
site,
+
eventCid,
+
verifiedCid
+
})
+
return
+
}
+
this.log('Record verified on PDS', { did, site, cid: verifiedCid })
+
} catch (err) {
+
this.log('Failed to verify record on PDS', {
+
did,
+
site,
+
error: err instanceof Error ? err.message : String(err)
+
})
+
return
+
}
+
// Cache the record with verified CID (uses atomic swap internally)
+
// All instances cache locally for edge serving
+
await downloadAndCacheSite(
+
did,
+
site,
+
fsRecord,
+
pdsEndpoint,
+
verifiedCid
+
)
+
// Acquire distributed lock only for database write to prevent duplicate writes
+
const lockKey = `db:upsert:${did}:${site}`
+
const lockAcquired = await tryAcquireLock(lockKey)
+
if (!lockAcquired) {
+
this.log('Another instance is writing to DB, skipping upsert', {
+
did,
+
site
+
})
+
this.log('Successfully processed create/update (cached locally)', {
+
did,
+
site
+
})
+
return
+
}
+
try {
+
// Upsert site to database (only one instance does this)
+
await upsertSite(did, site, fsRecord.site)
+
this.log(
+
'Successfully processed create/update (cached + DB updated)',
+
{ did, site }
+
)
+
} finally {
+
// Always release lock, even if DB write fails
+
await releaseLock(lockKey)
+
}
+
}
+
private async handleDelete(did: string, site: string) {
+
this.log('Processing delete', { did, site })
+
// All instances should delete their local cache (no lock needed)
+
const pdsEndpoint = await getPdsForDid(did)
+
if (!pdsEndpoint) {
+
this.log('Could not resolve PDS for DID', { did })
+
return
+
}
+
// Verify record is actually deleted from PDS
+
try {
+
const recordUrl = `${pdsEndpoint}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=place.wisp.fs&rkey=${encodeURIComponent(site)}`
+
const recordRes = await safeFetch(recordUrl)
+
if (recordRes.ok) {
+
this.log('Record still exists on PDS, not deleting cache', {
+
did,
+
site
+
})
+
return
+
}
+
this.log('Verified record is deleted from PDS', {
+
did,
+
site,
+
status: recordRes.status
+
})
+
} catch (err) {
+
this.log('Error verifying deletion on PDS', {
+
did,
+
site,
+
error: err instanceof Error ? err.message : String(err)
+
})
+
}
+
// Delete cache
+
this.deleteCache(did, site)
+
this.log('Successfully processed delete', { did, site })
+
}
+
private deleteCache(did: string, site: string) {
+
const cacheDir = `${CACHE_DIR}/${did}/${site}`
+
if (!existsSync(cacheDir)) {
+
this.log('Cache directory does not exist, nothing to delete', {
+
did,
+
site
+
})
+
return
+
}
+
try {
+
rmSync(cacheDir, { recursive: true, force: true })
+
this.log('Cache deleted', { did, site, path: cacheDir })
+
} catch (err) {
+
this.log('Failed to delete cache', {
+
did,
+
site,
+
path: cacheDir,
+
error: err instanceof Error ? err.message : String(err)
+
})
+
}
+
}
+
getHealth() {
+
const isConnected = this.firehose !== null
+
const timeSinceLastEvent = Date.now() - this.lastEventTime
+
return {
+
connected: isConnected,
+
lastEventTime: this.lastEventTime,
+
timeSinceLastEvent,
+
healthy: isConnected && timeSinceLastEvent < 300000 // 5 minutes
+
}
+
}
}
+457
hosting-service/src/lib/html-rewriter.test.ts
···
···
+
import { describe, test, expect } from 'bun:test'
+
import { rewriteHtmlPaths, isHtmlContent } from './html-rewriter'
+
+
describe('rewriteHtmlPaths', () => {
+
const basePath = '/identifier/site/'
+
+
describe('absolute paths', () => {
+
test('rewrites absolute paths with leading slash', () => {
+
const html = '<img src="/image.png">'
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
+
expect(result).toBe('<img src="/identifier/site/image.png">')
+
})
+
+
test('rewrites nested absolute paths', () => {
+
const html = '<link href="/css/style.css">'
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
+
expect(result).toBe('<link href="/identifier/site/css/style.css">')
+
})
+
})
+
+
describe('relative paths from root document', () => {
+
test('rewrites relative paths with ./ prefix', () => {
+
const html = '<img src="./image.png">'
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
+
expect(result).toBe('<img src="/identifier/site/image.png">')
+
})
+
+
test('rewrites relative paths without prefix', () => {
+
const html = '<img src="image.png">'
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
+
expect(result).toBe('<img src="/identifier/site/image.png">')
+
})
+
+
test('rewrites relative paths with ../ (should stay at root)', () => {
+
const html = '<img src="../image.png">'
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
+
expect(result).toBe('<img src="/identifier/site/image.png">')
+
})
+
})
+
+
describe('relative paths from nested documents', () => {
+
test('rewrites relative path from nested document', () => {
+
const html = '<img src="./photo.jpg">'
+
const result = rewriteHtmlPaths(
+
html,
+
basePath,
+
'folder1/folder2/index.html'
+
)
+
expect(result).toBe(
+
'<img src="/identifier/site/folder1/folder2/photo.jpg">'
+
)
+
})
+
+
test('rewrites plain filename from nested document', () => {
+
const html = '<script src="app.js"></script>'
+
const result = rewriteHtmlPaths(
+
html,
+
basePath,
+
'folder1/folder2/index.html'
+
)
+
expect(result).toBe(
+
'<script src="/identifier/site/folder1/folder2/app.js"></script>'
+
)
+
})
+
+
test('rewrites ../ to go up one level', () => {
+
const html = '<img src="../image.png">'
+
const result = rewriteHtmlPaths(
+
html,
+
basePath,
+
'folder1/folder2/folder3/index.html'
+
)
+
expect(result).toBe(
+
'<img src="/identifier/site/folder1/folder2/image.png">'
+
)
+
})
+
+
test('rewrites multiple ../ to go up multiple levels', () => {
+
const html = '<link href="../../css/style.css">'
+
const result = rewriteHtmlPaths(
+
html,
+
basePath,
+
'folder1/folder2/folder3/index.html'
+
)
+
expect(result).toBe(
+
'<link href="/identifier/site/folder1/css/style.css">'
+
)
+
})
+
+
test('rewrites ../ with additional path segments', () => {
+
const html = '<img src="../assets/logo.png">'
+
const result = rewriteHtmlPaths(
+
html,
+
basePath,
+
'pages/about/index.html'
+
)
+
expect(result).toBe(
+
'<img src="/identifier/site/pages/assets/logo.png">'
+
)
+
})
+
+
test('handles complex nested relative paths', () => {
+
const html = '<script src="../../lib/vendor/jquery.js"></script>'
+
const result = rewriteHtmlPaths(
+
html,
+
basePath,
+
'pages/blog/post/index.html'
+
)
+
expect(result).toBe(
+
'<script src="/identifier/site/pages/lib/vendor/jquery.js"></script>'
+
)
+
})
+
+
test('handles ../ going past root (stays at root)', () => {
+
const html = '<img src="../../../image.png">'
+
const result = rewriteHtmlPaths(html, basePath, 'folder1/index.html')
+
expect(result).toBe('<img src="/identifier/site/image.png">')
+
})
+
})
+
+
describe('external URLs and special schemes', () => {
+
test('does not rewrite http URLs', () => {
+
const html = '<img src="http://example.com/image.png">'
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
+
expect(result).toBe('<img src="http://example.com/image.png">')
+
})
+
+
test('does not rewrite https URLs', () => {
+
const html = '<link href="https://cdn.example.com/style.css">'
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
+
expect(result).toBe(
+
'<link href="https://cdn.example.com/style.css">'
+
)
+
})
+
+
test('does not rewrite protocol-relative URLs', () => {
+
const html = '<script src="//cdn.example.com/script.js"></script>'
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
+
expect(result).toBe(
+
'<script src="//cdn.example.com/script.js"></script>'
+
)
+
})
+
+
test('does not rewrite data URIs', () => {
+
const html =
+
'<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA">'
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
+
expect(result).toBe(
+
'<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA">'
+
)
+
})
+
+
test('does not rewrite mailto links', () => {
+
const html = '<a href="mailto:test@example.com">Email</a>'
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
+
expect(result).toBe('<a href="mailto:test@example.com">Email</a>')
+
})
+
+
test('does not rewrite tel links', () => {
+
const html = '<a href="tel:+1234567890">Call</a>'
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
+
expect(result).toBe('<a href="tel:+1234567890">Call</a>')
+
})
+
})
+
+
describe('different HTML attributes', () => {
+
test('rewrites src attribute', () => {
+
const html = '<img src="/image.png">'
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
+
expect(result).toBe('<img src="/identifier/site/image.png">')
+
})
+
+
test('rewrites href attribute', () => {
+
const html = '<a href="/page.html">Link</a>'
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
+
expect(result).toBe('<a href="/identifier/site/page.html">Link</a>')
+
})
+
+
test('rewrites action attribute', () => {
+
const html = '<form action="/submit"></form>'
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
+
expect(result).toBe('<form action="/identifier/site/submit"></form>')
+
})
+
+
test('rewrites data attribute', () => {
+
const html = '<object data="/document.pdf"></object>'
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
+
expect(result).toBe(
+
'<object data="/identifier/site/document.pdf"></object>'
+
)
+
})
+
+
test('rewrites poster attribute', () => {
+
const html = '<video poster="/thumbnail.jpg"></video>'
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
+
expect(result).toBe(
+
'<video poster="/identifier/site/thumbnail.jpg"></video>'
+
)
+
})
+
+
test('rewrites srcset attribute with single URL', () => {
+
const html = '<img srcset="/image.png 1x">'
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
+
expect(result).toBe(
+
'<img srcset="/identifier/site/image.png 1x">'
+
)
+
})
+
+
test('rewrites srcset attribute with multiple URLs', () => {
+
const html = '<img srcset="/image-1x.png 1x, /image-2x.png 2x">'
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
+
expect(result).toBe(
+
'<img srcset="/identifier/site/image-1x.png 1x, /identifier/site/image-2x.png 2x">'
+
)
+
})
+
+
test('rewrites srcset with width descriptors', () => {
+
const html = '<img srcset="/small.jpg 320w, /large.jpg 1024w">'
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
+
expect(result).toBe(
+
'<img srcset="/identifier/site/small.jpg 320w, /identifier/site/large.jpg 1024w">'
+
)
+
})
+
+
test('rewrites srcset with relative paths from nested document', () => {
+
const html = '<img srcset="../img1.png 1x, ../img2.png 2x">'
+
const result = rewriteHtmlPaths(
+
html,
+
basePath,
+
'folder1/folder2/index.html'
+
)
+
expect(result).toBe(
+
'<img srcset="/identifier/site/folder1/img1.png 1x, /identifier/site/folder1/img2.png 2x">'
+
)
+
})
+
})
+
+
describe('quote handling', () => {
+
test('handles double quotes', () => {
+
const html = '<img src="/image.png">'
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
+
expect(result).toBe('<img src="/identifier/site/image.png">')
+
})
+
+
test('handles single quotes', () => {
+
const html = "<img src='/image.png'>"
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
+
expect(result).toBe("<img src='/identifier/site/image.png'>")
+
})
+
+
test('handles mixed quotes in same document', () => {
+
const html = '<img src="/img1.png"><link href=\'/style.css\'>'
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
+
expect(result).toBe(
+
'<img src="/identifier/site/img1.png"><link href=\'/identifier/site/style.css\'>'
+
)
+
})
+
})
+
+
describe('multiple rewrites in same document', () => {
+
test('rewrites multiple attributes in complex HTML', () => {
+
const html = `
+
<!DOCTYPE html>
+
<html>
+
<head>
+
<link href="/css/style.css" rel="stylesheet">
+
<script src="/js/app.js"></script>
+
</head>
+
<body>
+
<img src="/images/logo.png" alt="Logo">
+
<a href="/about.html">About</a>
+
<form action="/submit">
+
<button type="submit">Submit</button>
+
</form>
+
</body>
+
</html>
+
`
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
+
expect(result).toContain('href="/identifier/site/css/style.css"')
+
expect(result).toContain('src="/identifier/site/js/app.js"')
+
expect(result).toContain('src="/identifier/site/images/logo.png"')
+
expect(result).toContain('href="/identifier/site/about.html"')
+
expect(result).toContain('action="/identifier/site/submit"')
+
})
+
+
test('handles mix of relative and absolute paths', () => {
+
const html = `
+
<img src="/abs/image.png">
+
<img src="./rel/image.png">
+
<img src="../parent/image.png">
+
<img src="https://external.com/image.png">
+
`
+
const result = rewriteHtmlPaths(
+
html,
+
basePath,
+
'folder1/folder2/page.html'
+
)
+
expect(result).toContain('src="/identifier/site/abs/image.png"')
+
expect(result).toContain(
+
'src="/identifier/site/folder1/folder2/rel/image.png"'
+
)
+
expect(result).toContain(
+
'src="/identifier/site/folder1/parent/image.png"'
+
)
+
expect(result).toContain('src="https://external.com/image.png"')
+
})
+
})
+
+
describe('edge cases', () => {
+
test('handles empty src attribute', () => {
+
const html = '<img src="">'
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
+
expect(result).toBe('<img src="">')
+
})
+
+
test('handles basePath without trailing slash', () => {
+
const html = '<img src="/image.png">'
+
const result = rewriteHtmlPaths(html, '/identifier/site', 'index.html')
+
expect(result).toBe('<img src="/identifier/site/image.png">')
+
})
+
+
test('handles basePath with trailing slash', () => {
+
const html = '<img src="/image.png">'
+
const result = rewriteHtmlPaths(
+
html,
+
'/identifier/site/',
+
'index.html'
+
)
+
expect(result).toBe('<img src="/identifier/site/image.png">')
+
})
+
+
test('handles whitespace around equals sign', () => {
+
const html = '<img src = "/image.png">'
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
+
expect(result).toBe('<img src="/identifier/site/image.png">')
+
})
+
+
test('preserves query strings in URLs', () => {
+
const html = '<img src="/image.png?v=123">'
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
+
expect(result).toBe('<img src="/identifier/site/image.png?v=123">')
+
})
+
+
test('preserves hash fragments in URLs', () => {
+
const html = '<a href="/page.html#section">Link</a>'
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
+
expect(result).toBe(
+
'<a href="/identifier/site/page.html#section">Link</a>'
+
)
+
})
+
+
test('handles paths with special characters', () => {
+
const html = '<img src="/folder-name/file_name.png">'
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
+
expect(result).toBe(
+
'<img src="/identifier/site/folder-name/file_name.png">'
+
)
+
})
+
})
+
+
describe('real-world scenario', () => {
+
test('handles the example from the bug report', () => {
+
// HTML file at: /folder1/folder2/folder3/index.html
+
// Image at: /folder1/folder2/img.png
+
// Reference: src="../img.png"
+
const html = '<img src="../img.png">'
+
const result = rewriteHtmlPaths(
+
html,
+
basePath,
+
'folder1/folder2/folder3/index.html'
+
)
+
expect(result).toBe(
+
'<img src="/identifier/site/folder1/folder2/img.png">'
+
)
+
})
+
+
test('handles deeply nested static site structure', () => {
+
// A typical static site with nested pages and shared assets
+
const html = `
+
<!DOCTYPE html>
+
<html>
+
<head>
+
<link href="../../css/style.css" rel="stylesheet">
+
<link href="../../css/theme.css" rel="stylesheet">
+
<script src="../../js/main.js"></script>
+
</head>
+
<body>
+
<img src="../../images/logo.png" alt="Logo">
+
<img src="./post-image.jpg" alt="Post">
+
<a href="../index.html">Back to Blog</a>
+
<a href="../../index.html">Home</a>
+
</body>
+
</html>
+
`
+
const result = rewriteHtmlPaths(
+
html,
+
basePath,
+
'blog/posts/my-post.html'
+
)
+
+
// Assets two levels up
+
expect(result).toContain('href="/identifier/site/css/style.css"')
+
expect(result).toContain('href="/identifier/site/css/theme.css"')
+
expect(result).toContain('src="/identifier/site/js/main.js"')
+
expect(result).toContain('src="/identifier/site/images/logo.png"')
+
+
// Same directory
+
expect(result).toContain(
+
'src="/identifier/site/blog/posts/post-image.jpg"'
+
)
+
+
// One level up
+
expect(result).toContain('href="/identifier/site/blog/index.html"')
+
+
// Two levels up
+
expect(result).toContain('href="/identifier/site/index.html"')
+
})
+
})
+
})
+
+
describe('isHtmlContent', () => {
+
test('identifies HTML by content type', () => {
+
expect(isHtmlContent('file.txt', 'text/html')).toBe(true)
+
expect(isHtmlContent('file.txt', 'text/html; charset=utf-8')).toBe(
+
true
+
)
+
})
+
+
test('identifies HTML by .html extension', () => {
+
expect(isHtmlContent('index.html')).toBe(true)
+
expect(isHtmlContent('page.html', undefined)).toBe(true)
+
expect(isHtmlContent('/path/to/file.html')).toBe(true)
+
})
+
+
test('identifies HTML by .htm extension', () => {
+
expect(isHtmlContent('index.htm')).toBe(true)
+
expect(isHtmlContent('page.htm', undefined)).toBe(true)
+
})
+
+
test('handles case-insensitive extensions', () => {
+
expect(isHtmlContent('INDEX.HTML')).toBe(true)
+
expect(isHtmlContent('page.HTM')).toBe(true)
+
expect(isHtmlContent('File.HtMl')).toBe(true)
+
})
+
+
test('returns false for non-HTML files', () => {
+
expect(isHtmlContent('script.js')).toBe(false)
+
expect(isHtmlContent('style.css')).toBe(false)
+
expect(isHtmlContent('image.png')).toBe(false)
+
expect(isHtmlContent('data.json')).toBe(false)
+
})
+
+
test('returns false for files with no extension', () => {
+
expect(isHtmlContent('README')).toBe(false)
+
expect(isHtmlContent('Makefile')).toBe(false)
+
})
+
})
+178 -99
hosting-service/src/lib/html-rewriter.ts
···
*/
const REWRITABLE_ATTRIBUTES = [
-
'src',
-
'href',
-
'action',
-
'data',
-
'poster',
-
'srcset',
-
] as const;
/**
* Check if a path should be rewritten
*/
function shouldRewritePath(path: string): boolean {
-
// Don't rewrite empty paths
-
if (!path) return false;
-
// Don't rewrite external URLs (http://, https://, //)
-
if (path.startsWith('http://') || path.startsWith('https://') || path.startsWith('//')) {
-
return false;
-
}
-
// Don't rewrite data URIs or other schemes (except file paths)
-
if (path.includes(':') && !path.startsWith('./') && !path.startsWith('../')) {
-
return false;
-
}
-
// Don't rewrite pure anchors or paths that start with /#
-
if (path.startsWith('#') || path.startsWith('/#')) return false;
-
// Don't rewrite relative paths (./ or ../)
-
if (path.startsWith('./') || path.startsWith('../')) return false;
-
// Rewrite absolute paths (/)
-
return true;
}
/**
* Rewrite a single path
*/
-
function rewritePath(path: string, basePath: string): string {
-
if (!shouldRewritePath(path)) {
-
return path;
-
}
-
// Handle absolute paths: /file.js -> /base/file.js
-
if (path.startsWith('/')) {
-
return basePath + path.slice(1);
-
}
-
// At this point, only plain filenames without ./ or ../ prefix should reach here
-
// But since we're filtering those in shouldRewritePath, this shouldn't happen
-
return path;
}
/**
* Rewrite srcset attribute (can contain multiple URLs)
* Format: "url1 1x, url2 2x" or "url1 100w, url2 200w"
*/
-
function rewriteSrcset(srcset: string, basePath: string): string {
-
return srcset
-
.split(',')
-
.map(part => {
-
const trimmed = part.trim();
-
const spaceIndex = trimmed.indexOf(' ');
-
if (spaceIndex === -1) {
-
// No descriptor, just URL
-
return rewritePath(trimmed, basePath);
-
}
-
const url = trimmed.substring(0, spaceIndex);
-
const descriptor = trimmed.substring(spaceIndex);
-
return rewritePath(url, basePath) + descriptor;
-
})
-
.join(', ');
}
/**
-
* Rewrite absolute paths in HTML content
* Uses simple regex matching for safety (no full HTML parsing)
*/
-
export function rewriteHtmlPaths(html: string, basePath: string): string {
-
// Ensure base path ends with /
-
const normalizedBase = basePath.endsWith('/') ? basePath : basePath + '/';
-
let rewritten = html;
-
// Rewrite each attribute type
-
// Use more specific patterns to prevent ReDoS attacks
-
for (const attr of REWRITABLE_ATTRIBUTES) {
-
if (attr === 'srcset') {
-
// Special handling for srcset - use possessive quantifiers via atomic grouping simulation
-
// Limit whitespace to reasonable amount (max 5 spaces) to prevent ReDoS
-
const srcsetRegex = new RegExp(
-
`\\b${attr}[ \\t]{0,5}=[ \\t]{0,5}"([^"]*)"`,
-
'gi'
-
);
-
rewritten = rewritten.replace(srcsetRegex, (match, value) => {
-
const rewrittenValue = rewriteSrcset(value, normalizedBase);
-
return `${attr}="${rewrittenValue}"`;
-
});
-
} else {
-
// Regular attributes with quoted values
-
// Limit whitespace to prevent catastrophic backtracking
-
const doubleQuoteRegex = new RegExp(
-
`\\b${attr}[ \\t]{0,5}=[ \\t]{0,5}"([^"]*)"`,
-
'gi'
-
);
-
const singleQuoteRegex = new RegExp(
-
`\\b${attr}[ \\t]{0,5}=[ \\t]{0,5}'([^']*)'`,
-
'gi'
-
);
-
rewritten = rewritten.replace(doubleQuoteRegex, (match, value) => {
-
const rewrittenValue = rewritePath(value, normalizedBase);
-
return `${attr}="${rewrittenValue}"`;
-
});
-
rewritten = rewritten.replace(singleQuoteRegex, (match, value) => {
-
const rewrittenValue = rewritePath(value, normalizedBase);
-
return `${attr}='${rewrittenValue}'`;
-
});
-
}
-
}
-
return rewritten;
}
/**
* Check if content is HTML based on content or filename
*/
-
export function isHtmlContent(
-
filepath: string,
-
contentType?: string
-
): boolean {
-
if (contentType && contentType.includes('text/html')) {
-
return true;
-
}
-
const ext = filepath.toLowerCase().split('.').pop();
-
return ext === 'html' || ext === 'htm';
}
···
*/
const REWRITABLE_ATTRIBUTES = [
+
'src',
+
'href',
+
'action',
+
'data',
+
'poster',
+
'srcset'
+
] as const
/**
* Check if a path should be rewritten
*/
function shouldRewritePath(path: string): boolean {
+
// Don't rewrite empty paths
+
if (!path) return false
+
// Don't rewrite external URLs (http://, https://, //)
+
if (
+
path.startsWith('http://') ||
+
path.startsWith('https://') ||
+
path.startsWith('//')
+
) {
+
return false
+
}
+
// Don't rewrite data URIs or other schemes (except file paths)
+
if (
+
path.includes(':') &&
+
!path.startsWith('./') &&
+
!path.startsWith('../')
+
) {
+
return false
+
}
+
// Rewrite absolute paths (/) and relative paths (./ or ../ or plain filenames)
+
return true
+
}
+
+
/**
+
* Normalize a path by resolving . and .. segments
+
*/
+
function normalizePath(path: string): string {
+
const parts = path.split('/')
+
const result: string[] = []
+
+
for (const part of parts) {
+
if (part === '.' || part === '') {
+
// Skip current directory and empty parts (but keep leading empty for absolute paths)
+
if (part === '' && result.length === 0) {
+
result.push(part)
+
}
+
continue
+
}
+
if (part === '..') {
+
// Go up one directory (but not past root)
+
if (result.length > 0 && result[result.length - 1] !== '..') {
+
result.pop()
+
}
+
continue
+
}
+
result.push(part)
+
}
+
return result.join('/')
+
}
+
/**
+
* Get the directory path from a file path
+
* e.g., "folder1/folder2/file.html" -> "folder1/folder2/"
+
*/
+
function getDirectory(filepath: string): string {
+
const lastSlash = filepath.lastIndexOf('/')
+
if (lastSlash === -1) {
+
return ''
+
}
+
return filepath.substring(0, lastSlash + 1)
}
/**
* Rewrite a single path
*/
+
function rewritePath(
+
path: string,
+
basePath: string,
+
documentPath: string
+
): string {
+
if (!shouldRewritePath(path)) {
+
return path
+
}
+
+
// Handle absolute paths: /file.js -> /base/file.js
+
if (path.startsWith('/')) {
+
return basePath + path.slice(1)
+
}
+
+
// Handle relative paths by resolving against document directory
+
const documentDir = getDirectory(documentPath)
+
let resolvedPath: string
+
if (path.startsWith('./')) {
+
// ./file.js relative to current directory
+
resolvedPath = documentDir + path.slice(2)
+
} else if (path.startsWith('../')) {
+
// ../file.js relative to parent directory
+
resolvedPath = documentDir + path
+
} else {
+
// file.js (no prefix) - treat as relative to current directory
+
resolvedPath = documentDir + path
+
}
+
// Normalize the path to resolve .. and .
+
resolvedPath = normalizePath(resolvedPath)
+
+
return basePath + resolvedPath
}
/**
* Rewrite srcset attribute (can contain multiple URLs)
* Format: "url1 1x, url2 2x" or "url1 100w, url2 200w"
*/
+
function rewriteSrcset(
+
srcset: string,
+
basePath: string,
+
documentPath: string
+
): string {
+
return srcset
+
.split(',')
+
.map((part) => {
+
const trimmed = part.trim()
+
const spaceIndex = trimmed.indexOf(' ')
+
if (spaceIndex === -1) {
+
// No descriptor, just URL
+
return rewritePath(trimmed, basePath, documentPath)
+
}
+
const url = trimmed.substring(0, spaceIndex)
+
const descriptor = trimmed.substring(spaceIndex)
+
return rewritePath(url, basePath, documentPath) + descriptor
+
})
+
.join(', ')
}
/**
+
* Rewrite absolute and relative paths in HTML content
* Uses simple regex matching for safety (no full HTML parsing)
*/
+
export function rewriteHtmlPaths(
+
html: string,
+
basePath: string,
+
documentPath: string
+
): string {
+
// Ensure base path ends with /
+
const normalizedBase = basePath.endsWith('/') ? basePath : basePath + '/'
+
let rewritten = html
+
// Rewrite each attribute type
+
// Use more specific patterns to prevent ReDoS attacks
+
for (const attr of REWRITABLE_ATTRIBUTES) {
+
if (attr === 'srcset') {
+
// Special handling for srcset - use possessive quantifiers via atomic grouping simulation
+
// Limit whitespace to reasonable amount (max 5 spaces) to prevent ReDoS
+
const srcsetRegex = new RegExp(
+
`\\b${attr}[ \\t]{0,5}=[ \\t]{0,5}"([^"]*)"`,
+
'gi'
+
)
+
rewritten = rewritten.replace(srcsetRegex, (match, value) => {
+
const rewrittenValue = rewriteSrcset(
+
value,
+
normalizedBase,
+
documentPath
+
)
+
return `${attr}="${rewrittenValue}"`
+
})
+
} else {
+
// Regular attributes with quoted values
+
// Limit whitespace to prevent catastrophic backtracking
+
const doubleQuoteRegex = new RegExp(
+
`\\b${attr}[ \\t]{0,5}=[ \\t]{0,5}"([^"]*)"`,
+
'gi'
+
)
+
const singleQuoteRegex = new RegExp(
+
`\\b${attr}[ \\t]{0,5}=[ \\t]{0,5}'([^']*)'`,
+
'gi'
+
)
+
rewritten = rewritten.replace(doubleQuoteRegex, (match, value) => {
+
const rewrittenValue = rewritePath(
+
value,
+
normalizedBase,
+
documentPath
+
)
+
return `${attr}="${rewrittenValue}"`
+
})
+
rewritten = rewritten.replace(singleQuoteRegex, (match, value) => {
+
const rewrittenValue = rewritePath(
+
value,
+
normalizedBase,
+
documentPath
+
)
+
return `${attr}='${rewrittenValue}'`
+
})
+
}
+
}
+
return rewritten
}
/**
* Check if content is HTML based on content or filename
*/
+
export function isHtmlContent(filepath: string, contentType?: string): boolean {
+
if (contentType && contentType.includes('text/html')) {
+
return true
+
}
+
const ext = filepath.toLowerCase().split('.').pop()
+
return ext === 'html' || ext === 'htm'
}
+76 -12
hosting-service/src/lib/utils.ts
···
import { safeFetchJson, safeFetchBlob } from './safe-fetch';
import { CID } from 'multiformats';
-
const CACHE_DIR = './cache/sites';
const CACHE_TTL = 14 * 24 * 60 * 60 * 1000; // 14 days cache TTL
interface CacheMetadata {
···
cachedAt: number;
did: string;
rkey: string;
}
interface IpldLink {
···
console.log(`[DEBUG] ${filePath}: fetched ${content.length} bytes, base64=${base64}, encoding=${encoding}, mimeType=${mimeType}`);
-
// If content is base64-encoded, decode it back to binary (gzipped or not)
if (base64) {
const originalSize = content.length;
-
// The content from the blob is base64 text, decode it directly to binary
-
const buffer = Buffer.from(content);
-
const base64String = buffer.toString('ascii'); // Use ascii for base64 text, not utf-8
-
console.log(`[DEBUG] ${filePath}: base64 string first 100 chars: ${base64String.substring(0, 100)}`);
content = Buffer.from(base64String, 'base64');
-
console.log(`[DEBUG] ${filePath}: decoded from ${originalSize} bytes to ${content.length} bytes`);
// Check if it's actually gzipped by looking at magic bytes
if (content.length >= 2) {
-
const magic = content[0] === 0x1f && content[1] === 0x8b;
-
const byte0 = content[0];
-
const byte1 = content[1];
-
console.log(`[DEBUG] ${filePath}: has gzip magic bytes: ${magic} (0x${byte0?.toString(16)}, 0x${byte1?.toString(16)})`);
}
}
···
mkdirSync(fileDir, { recursive: true });
}
await writeFile(cacheFile, content);
-
// Store metadata if file is compressed
if (encoding === 'gzip' && mimeType) {
const metaFile = `${cacheFile}.meta`;
await writeFile(metaFile, JSON.stringify({ encoding, mimeType }));
···
import { safeFetchJson, safeFetchBlob } from './safe-fetch';
import { CID } from 'multiformats';
+
const CACHE_DIR = process.env.CACHE_DIR || './cache/sites';
const CACHE_TTL = 14 * 24 * 60 * 60 * 1000; // 14 days cache TTL
interface CacheMetadata {
···
cachedAt: number;
did: string;
rkey: string;
+
}
+
+
/**
+
* Determines if a MIME type should benefit from gzip compression.
+
* Returns true for text-based web assets (HTML, CSS, JS, JSON, XML, SVG).
+
* Returns false for already-compressed formats (images, video, audio, PDFs).
+
*
+
*/
+
export function shouldCompressMimeType(mimeType: string | undefined): boolean {
+
if (!mimeType) return false;
+
+
const mime = mimeType.toLowerCase();
+
+
// Text-based web assets that benefit from compression
+
const compressibleTypes = [
+
'text/html',
+
'text/css',
+
'text/javascript',
+
'application/javascript',
+
'application/x-javascript',
+
'text/xml',
+
'application/xml',
+
'application/json',
+
'text/plain',
+
'image/svg+xml',
+
];
+
+
if (compressibleTypes.some(type => mime === type || mime.startsWith(type))) {
+
return true;
+
}
+
+
// Already-compressed formats that should NOT be double-compressed
+
const alreadyCompressedPrefixes = [
+
'video/',
+
'audio/',
+
'image/',
+
'application/pdf',
+
'application/zip',
+
'application/gzip',
+
];
+
+
if (alreadyCompressedPrefixes.some(prefix => mime.startsWith(prefix))) {
+
return false;
+
}
+
+
// Default to not compressing for unknown types
+
return false;
}
interface IpldLink {
···
console.log(`[DEBUG] ${filePath}: fetched ${content.length} bytes, base64=${base64}, encoding=${encoding}, mimeType=${mimeType}`);
+
// If content is base64-encoded, decode it back to raw binary (gzipped or not)
if (base64) {
const originalSize = content.length;
+
// Decode base64 directly from raw bytes - no string conversion
+
// The blob contains base64-encoded text as raw bytes, decode it in-place
+
const textDecoder = new TextDecoder();
+
const base64String = textDecoder.decode(content);
content = Buffer.from(base64String, 'base64');
+
console.log(`[DEBUG] ${filePath}: decoded base64 from ${originalSize} bytes to ${content.length} bytes`);
// Check if it's actually gzipped by looking at magic bytes
if (content.length >= 2) {
+
const hasGzipMagic = content[0] === 0x1f && content[1] === 0x8b;
+
console.log(`[DEBUG] ${filePath}: has gzip magic bytes: ${hasGzipMagic}`);
}
}
···
mkdirSync(fileDir, { recursive: true });
}
+
// Use the shared function to determine if this should remain compressed
+
const shouldStayCompressed = shouldCompressMimeType(mimeType);
+
+
// Decompress files that shouldn't be stored compressed
+
if (encoding === 'gzip' && !shouldStayCompressed && content.length >= 2 &&
+
content[0] === 0x1f && content[1] === 0x8b) {
+
console.log(`[DEBUG] ${filePath}: decompressing non-compressible type (${mimeType}) before caching`);
+
try {
+
const { gunzipSync } = await import('zlib');
+
const decompressed = gunzipSync(content);
+
console.log(`[DEBUG] ${filePath}: decompressed from ${content.length} to ${decompressed.length} bytes`);
+
content = decompressed;
+
// Clear the encoding flag since we're storing decompressed
+
encoding = undefined;
+
} catch (error) {
+
console.log(`[DEBUG] ${filePath}: failed to decompress, storing original gzipped content. Error:`, error);
+
}
+
}
+
await writeFile(cacheFile, content);
+
// Store metadata only if file is still compressed
if (encoding === 'gzip' && mimeType) {
const metaFile = `${cacheFile}.meta`;
await writeFile(metaFile, JSON.stringify({ encoding, mimeType }));
+30 -36
hosting-service/src/server.ts
···
import { Hono } from 'hono';
import { getWispDomain, getCustomDomain, getCustomDomainByHash } from './lib/db';
-
import { resolveDid, getPdsForDid, fetchSiteRecord, downloadAndCacheSite, getCachedFilePath, isCached, sanitizePath } from './lib/utils';
import { rewriteHtmlPaths, isHtmlContent } from './lib/html-rewriter';
import { existsSync, readFileSync } from 'fs';
import { lookup } from 'mime-types';
···
// Check actual content for gzip magic bytes
if (content.length >= 2) {
const hasGzipMagic = content[0] === 0x1f && content[1] === 0x8b;
-
const byte0 = content[0];
-
const byte1 = content[1];
-
console.log(`[DEBUG SERVE] ${requestPath}: has gzip magic bytes=${hasGzipMagic} (0x${byte0?.toString(16)}, 0x${byte1?.toString(16)})`);
}
if (meta.encoding === 'gzip' && meta.mimeType) {
-
// Don't serve already-compressed media formats with Content-Encoding: gzip
-
// These formats (video, audio, images) are already compressed and the browser
-
// can't decode them if we add another layer of compression
-
const alreadyCompressedTypes = [
-
'video/', 'audio/', 'image/jpeg', 'image/jpg', 'image/png',
-
'image/gif', 'image/webp', 'application/pdf'
-
];
-
const isAlreadyCompressed = alreadyCompressedTypes.some(type =>
-
meta.mimeType.toLowerCase().startsWith(type)
-
);
-
-
if (isAlreadyCompressed) {
-
// Decompress the file before serving
-
console.log(`[DEBUG SERVE] ${requestPath}: decompressing already-compressed media type`);
const { gunzipSync } = await import('zlib');
const decompressed = gunzipSync(content);
console.log(`[DEBUG SERVE] ${requestPath}: decompressed from ${content.length} to ${decompressed.length} bytes`);
···
}
// Check if this is HTML content that needs rewriting
-
// Note: For gzipped HTML with path rewriting, we need to decompress, rewrite, and serve uncompressed
-
// This is a trade-off for the sites.wisp.place domain which needs path rewriting
if (isHtmlContent(requestPath, mimeType)) {
let content: string;
if (isGzipped) {
···
} else {
content = readFileSync(cachedFile, 'utf-8');
}
-
const rewritten = rewriteHtmlPaths(content, basePath);
-
return new Response(rewritten, {
headers: {
'Content-Type': 'text/html; charset=utf-8',
},
});
}
···
// Non-HTML files: serve gzipped content as-is with proper headers
const content = readFileSync(cachedFile);
if (isGzipped) {
-
// Don't serve already-compressed media formats with Content-Encoding: gzip
-
const alreadyCompressedTypes = [
-
'video/', 'audio/', 'image/jpeg', 'image/jpg', 'image/png',
-
'image/gif', 'image/webp', 'application/pdf'
-
];
-
const isAlreadyCompressed = alreadyCompressedTypes.some(type =>
-
mimeType.toLowerCase().startsWith(type)
-
);
-
-
if (isAlreadyCompressed) {
-
// Decompress the file before serving
const { gunzipSync } = await import('zlib');
const decompressed = gunzipSync(content);
return new Response(decompressed, {
···
}
}
-
// HTML needs path rewriting, so decompress if needed
let content: string;
if (isGzipped) {
const { gunzipSync } = await import('zlib');
···
} else {
content = readFileSync(indexFile, 'utf-8');
}
-
const rewritten = rewriteHtmlPaths(content, basePath);
-
return new Response(rewritten, {
headers: {
'Content-Type': 'text/html; charset=utf-8',
},
});
}
···
import { Hono } from 'hono';
import { getWispDomain, getCustomDomain, getCustomDomainByHash } from './lib/db';
+
import { resolveDid, getPdsForDid, fetchSiteRecord, downloadAndCacheSite, getCachedFilePath, isCached, sanitizePath, shouldCompressMimeType } from './lib/utils';
import { rewriteHtmlPaths, isHtmlContent } from './lib/html-rewriter';
import { existsSync, readFileSync } from 'fs';
import { lookup } from 'mime-types';
···
// Check actual content for gzip magic bytes
if (content.length >= 2) {
const hasGzipMagic = content[0] === 0x1f && content[1] === 0x8b;
+
console.log(`[DEBUG SERVE] ${requestPath}: has gzip magic bytes=${hasGzipMagic}`);
}
if (meta.encoding === 'gzip' && meta.mimeType) {
+
// Use shared function to determine if this should be served compressed
+
const shouldServeCompressed = shouldCompressMimeType(meta.mimeType);
+
if (!shouldServeCompressed) {
+
// This shouldn't happen if caching is working correctly, but handle it gracefully
+
console.log(`[DEBUG SERVE] ${requestPath}: decompressing file that shouldn't be compressed (${meta.mimeType})`);
const { gunzipSync } = await import('zlib');
const decompressed = gunzipSync(content);
console.log(`[DEBUG SERVE] ${requestPath}: decompressed from ${content.length} to ${decompressed.length} bytes`);
···
}
// Check if this is HTML content that needs rewriting
+
// We decompress, rewrite paths, then recompress for efficient delivery
if (isHtmlContent(requestPath, mimeType)) {
let content: string;
if (isGzipped) {
···
} else {
content = readFileSync(cachedFile, 'utf-8');
}
+
const rewritten = rewriteHtmlPaths(content, basePath, requestPath);
+
+
// Recompress the HTML for efficient delivery
+
const { gzipSync } = await import('zlib');
+
const recompressed = gzipSync(Buffer.from(rewritten, 'utf-8'));
+
+
return new Response(recompressed, {
headers: {
'Content-Type': 'text/html; charset=utf-8',
+
'Content-Encoding': 'gzip',
},
});
}
···
// Non-HTML files: serve gzipped content as-is with proper headers
const content = readFileSync(cachedFile);
if (isGzipped) {
+
// Use shared function to determine if this should be served compressed
+
const shouldServeCompressed = shouldCompressMimeType(mimeType);
+
if (!shouldServeCompressed) {
+
// This shouldn't happen if caching is working correctly, but handle it gracefully
const { gunzipSync } = await import('zlib');
const decompressed = gunzipSync(content);
return new Response(decompressed, {
···
}
}
+
// HTML needs path rewriting, decompress, rewrite, then recompress
let content: string;
if (isGzipped) {
const { gunzipSync } = await import('zlib');
···
} else {
content = readFileSync(indexFile, 'utf-8');
}
+
const indexPath = `${requestPath}/index.html`;
+
const rewritten = rewriteHtmlPaths(content, basePath, indexPath);
+
+
// Recompress the HTML for efficient delivery
+
const { gzipSync } = await import('zlib');
+
const recompressed = gzipSync(Buffer.from(rewritten, 'utf-8'));
+
+
return new Response(recompressed, {
headers: {
'Content-Type': 'text/html; charset=utf-8',
+
'Content-Encoding': 'gzip',
},
});
}
+1
package.json
···
"lucide-react": "^0.546.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"tailwind-merge": "^3.3.1",
"tailwindcss": "4",
"tw-animate-css": "^1.4.0",
···
"lucide-react": "^0.546.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
+
"react-shiki": "^0.9.0",
"tailwind-merge": "^3.3.1",
"tailwindcss": "4",
"tw-animate-css": "^1.4.0",
+23
public/components/ui/code-block.tsx
···
···
+
import ShikiHighlighter from 'react-shiki'
+
+
interface CodeBlockProps {
+
code: string
+
language?: string
+
className?: string
+
}
+
+
export function CodeBlock({ code, language = 'bash', className = '' }: CodeBlockProps) {
+
return (
+
<ShikiHighlighter
+
language={language}
+
theme={{
+
light: 'catppuccin-latte',
+
dark: 'catppuccin-mocha',
+
}}
+
defaultColor="light-dark()"
+
className={className}
+
>
+
{code.trim()}
+
</ShikiHighlighter>
+
)
+
}
+1 -1
public/components/ui/radio-group.tsx
···
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
-
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
···
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
+
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/50 aspect-square size-4 shrink-0 rounded-full border border-black/30 dark:border-white/30 shadow-inner transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
+2 -2
public/components/ui/tabs.tsx
···
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
-
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
className
)}
{...props}
···
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
-
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
···
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
+
"bg-muted dark:bg-muted/80 text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
className
)}
{...props}
···
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
+
"data-[state=active]:bg-background dark:data-[state=active]:bg-background dark:data-[state=active]:text-foreground dark:data-[state=active]:border-border focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-border dark:data-[state=active]:shadow-sm text-foreground dark:text-muted-foreground/70 inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
+268 -19
public/editor/editor.tsx
···
Settings
} from 'lucide-react'
import { RadioGroup, RadioGroupItem } from '@public/components/ui/radio-group'
import Layout from '@public/layouts'
···
</div>
<Tabs defaultValue="sites" className="space-y-6 w-full">
-
<TabsList className="grid w-full grid-cols-3 max-w-md">
<TabsTrigger value="sites">Sites</TabsTrigger>
<TabsTrigger value="domains">Domains</TabsTrigger>
<TabsTrigger value="upload">Upload</TabsTrigger>
</TabsList>
{/* Sites Tab */}
···
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-4">
-
<RadioGroup
-
value={siteMode}
-
onValueChange={(value) => setSiteMode(value as 'existing' | 'new')}
-
disabled={isUploading}
-
>
-
<div className="flex items-center space-x-2">
-
<RadioGroupItem value="existing" id="existing" />
-
<Label htmlFor="existing" className="cursor-pointer">
-
Update existing site
-
</Label>
-
</div>
-
<div className="flex items-center space-x-2">
-
<RadioGroupItem value="new" id="new" />
-
<Label htmlFor="new" className="cursor-pointer">
-
Create new site
-
</Label>
-
</div>
-
</RadioGroup>
{siteMode === 'existing' ? (
<div className="space-y-2">
···
</>
)}
</Button>
</CardContent>
</Card>
</TabsContent>
···
Settings
} from 'lucide-react'
import { RadioGroup, RadioGroupItem } from '@public/components/ui/radio-group'
+
import { CodeBlock } from '@public/components/ui/code-block'
import Layout from '@public/layouts'
···
</div>
<Tabs defaultValue="sites" className="space-y-6 w-full">
+
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="sites">Sites</TabsTrigger>
<TabsTrigger value="domains">Domains</TabsTrigger>
<TabsTrigger value="upload">Upload</TabsTrigger>
+
<TabsTrigger value="cli">CLI</TabsTrigger>
</TabsList>
{/* Sites Tab */}
···
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-4">
+
<div className="p-4 bg-muted/50 rounded-lg">
+
<RadioGroup
+
value={siteMode}
+
onValueChange={(value) => setSiteMode(value as 'existing' | 'new')}
+
disabled={isUploading}
+
>
+
<div className="flex items-center space-x-2">
+
<RadioGroupItem value="existing" id="existing" />
+
<Label htmlFor="existing" className="cursor-pointer">
+
Update existing site
+
</Label>
+
</div>
+
<div className="flex items-center space-x-2">
+
<RadioGroupItem value="new" id="new" />
+
<Label htmlFor="new" className="cursor-pointer">
+
Create new site
+
</Label>
+
</div>
+
</RadioGroup>
+
</div>
{siteMode === 'existing' ? (
<div className="space-y-2">
···
</>
)}
</Button>
+
</CardContent>
+
</Card>
+
</TabsContent>
+
+
{/* CLI Tab */}
+
<TabsContent value="cli" className="space-y-4 min-h-[400px]">
+
<Card>
+
<CardHeader>
+
<div className="flex items-center gap-2 mb-2">
+
<CardTitle>Wisp CLI Tool</CardTitle>
+
<Badge variant="secondary" className="text-xs">v0.1.0</Badge>
+
<Badge variant="outline" className="text-xs">Alpha</Badge>
+
</div>
+
<CardDescription>
+
Deploy static sites directly from your terminal
+
</CardDescription>
+
</CardHeader>
+
<CardContent className="space-y-6">
+
<div className="prose prose-sm max-w-none dark:prose-invert">
+
<p className="text-sm text-muted-foreground">
+
The Wisp CLI is a command-line tool for deploying static websites directly to your AT Protocol account.
+
Authenticate with app password or OAuth and deploy from CI/CD pipelines.
+
</p>
+
</div>
+
+
<div className="space-y-3">
+
<h3 className="text-sm font-semibold">Download CLI</h3>
+
<div className="grid gap-2">
+
<div className="p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border">
+
<a
+
href="https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-macos-arm64"
+
target="_blank"
+
rel="noopener noreferrer"
+
className="flex items-center justify-between mb-2"
+
>
+
<span className="font-mono text-sm">macOS (Apple Silicon)</span>
+
<ExternalLink className="w-4 h-4 text-muted-foreground" />
+
</a>
+
<div className="text-xs text-muted-foreground">
+
<span className="font-mono">SHA256: 637e325d9668ca745e01493d80dfc72447ef0a889b313e28913ca65c94c7aaae</span>
+
</div>
+
</div>
+
<div className="p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border">
+
<a
+
href="https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-aarch64-linux"
+
target="_blank"
+
rel="noopener noreferrer"
+
className="flex items-center justify-between mb-2"
+
>
+
<span className="font-mono text-sm">Linux (ARM64)</span>
+
<ExternalLink className="w-4 h-4 text-muted-foreground" />
+
</a>
+
<div className="text-xs text-muted-foreground">
+
<span className="font-mono">SHA256: 01561656b64826f95b39f13c65c97da8bcc63ecd9f4d7e4e369c8ba8c903c22a</span>
+
</div>
+
</div>
+
<div className="p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border">
+
<a
+
href="https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-x86_64-linux"
+
target="_blank"
+
rel="noopener noreferrer"
+
className="flex items-center justify-between mb-2"
+
>
+
<span className="font-mono text-sm">Linux (x86_64)</span>
+
<ExternalLink className="w-4 h-4 text-muted-foreground" />
+
</a>
+
<div className="text-xs text-muted-foreground">
+
<span className="font-mono">SHA256: 1ff485b9bcf89bc5721a862863c4843cf4530cbcd2489cf200cb24a44f7865a2</span>
+
</div>
+
</div>
+
</div>
+
</div>
+
+
<div className="space-y-3">
+
<h3 className="text-sm font-semibold">Basic Usage</h3>
+
<CodeBlock
+
code={`# Download and make executable
+
curl -O https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-macos-arm64
+
chmod +x wisp-cli-macos-arm64
+
+
# Deploy your site (will use OAuth)
+
./wisp-cli-macos-arm64 your-handle.bsky.social \\
+
--path ./dist \\
+
--site my-site
+
+
# Your site will be available at:
+
# https://sites.wisp.place/your-handle/my-site`}
+
language="bash"
+
/>
+
</div>
+
+
<div className="space-y-3">
+
<h3 className="text-sm font-semibold">CI/CD with Tangled Spindle</h3>
+
<p className="text-xs text-muted-foreground">
+
Deploy automatically on every push using{' '}
+
<a
+
href="https://blog.tangled.org/ci"
+
target="_blank"
+
rel="noopener noreferrer"
+
className="text-accent hover:underline"
+
>
+
Tangled Spindle
+
</a>
+
</p>
+
+
<div className="space-y-4">
+
<div>
+
<h4 className="text-xs font-semibold mb-2 flex items-center gap-2">
+
<span>Example 1: Simple Asset Publishing</span>
+
<Badge variant="secondary" className="text-xs">Copy Files</Badge>
+
</h4>
+
<CodeBlock
+
code={`when:
+
- event: ['push']
+
branch: ['main']
+
- event: ['manual']
+
+
engine: 'nixery'
+
+
clone:
+
skip: false
+
depth: 1
+
+
dependencies:
+
nixpkgs:
+
- coreutils
+
- curl
+
+
environment:
+
SITE_PATH: '.' # Copy entire repo
+
SITE_NAME: 'myWebbedSite'
+
WISP_HANDLE: 'your-handle.bsky.social'
+
+
steps:
+
- name: deploy assets to wisp
+
command: |
+
# Download Wisp CLI
+
curl https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-x86_64-linux -o wisp-cli
+
chmod +x wisp-cli
+
+
# Deploy to Wisp
+
./wisp-cli \\
+
"$WISP_HANDLE" \\
+
--path "$SITE_PATH" \\
+
--site "$SITE_NAME" \\
+
--password "$WISP_APP_PASSWORD"
+
+
# Output
+
#Deployed site 'myWebbedSite': at://did:plc:ttdrpj45ibqunmfhdsb4zdwq/place.wisp.fs/myWebbedSite
+
#Available at: https://sites.wisp.place/did:plc:ttdrpj45ibqunmfhdsb4zdwq/myWebbedSite
+
`}
+
language="yaml"
+
/>
+
</div>
+
+
<div>
+
<h4 className="text-xs font-semibold mb-2 flex items-center gap-2">
+
<span>Example 2: React/Vite Build & Deploy</span>
+
<Badge variant="secondary" className="text-xs">Full Build</Badge>
+
</h4>
+
<CodeBlock
+
code={`when:
+
- event: ['push']
+
branch: ['main']
+
- event: ['manual']
+
+
engine: 'nixery'
+
+
clone:
+
skip: false
+
depth: 1
+
submodules: false
+
+
dependencies:
+
nixpkgs:
+
- nodejs
+
- coreutils
+
- curl
+
github:NixOS/nixpkgs/nixpkgs-unstable:
+
- bun
+
+
environment:
+
SITE_PATH: 'dist'
+
SITE_NAME: 'my-react-site'
+
WISP_HANDLE: 'your-handle.bsky.social'
+
+
steps:
+
- name: build site
+
command: |
+
# necessary to ensure bun is in PATH
+
export PATH="$HOME/.nix-profile/bin:$PATH"
+
+
bun install --frozen-lockfile
+
+
# build with vite, run directly to get around env issues
+
bun node_modules/.bin/vite build
+
+
- name: deploy to wisp
+
command: |
+
# Download Wisp CLI
+
curl https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-x86_64-linux -o wisp-cli
+
chmod +x wisp-cli
+
+
# Deploy to Wisp
+
./wisp-cli \\
+
"$WISP_HANDLE" \\
+
--path "$SITE_PATH" \\
+
--site "$SITE_NAME" \\
+
--password "$WISP_APP_PASSWORD"`}
+
language="yaml"
+
/>
+
</div>
+
</div>
+
+
<div className="p-3 bg-muted/30 rounded-lg border-l-4 border-accent">
+
<p className="text-xs text-muted-foreground">
+
<strong className="text-foreground">Note:</strong> Set <code className="px-1.5 py-0.5 bg-background rounded text-xs">WISP_APP_PASSWORD</code> as a secret in your Tangled Spindle repository settings.
+
Generate an app password from your AT Protocol account settings.
+
</p>
+
</div>
+
</div>
+
+
<div className="space-y-3">
+
<h3 className="text-sm font-semibold">Learn More</h3>
+
<div className="grid gap-2">
+
<a
+
href="https://tangled.org/@nekomimi.pet/wisp.place-monorepo/tree/main/cli"
+
target="_blank"
+
rel="noopener noreferrer"
+
className="flex items-center justify-between p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border"
+
>
+
<span className="text-sm">Source Code</span>
+
<ExternalLink className="w-4 h-4 text-muted-foreground" />
+
</a>
+
<a
+
href="https://blog.tangled.org/ci"
+
target="_blank"
+
rel="noopener noreferrer"
+
className="flex items-center justify-between p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border"
+
>
+
<span className="text-sm">Tangled Spindle CI/CD</span>
+
<ExternalLink className="w-4 h-4 text-muted-foreground" />
+
</a>
+
</div>
+
</div>
</CardContent>
</Card>
</TabsContent>
+24
public/layouts/index.tsx
···
import type { PropsWithChildren } from 'react'
import { QueryClientProvider, QueryClient } from '@tanstack/react-query'
import clsx from 'clsx'
···
}
export default function Layout({ children, className }: LayoutProps) {
return (
<QueryClientProvider client={client}>
<div
···
import type { PropsWithChildren } from 'react'
+
import { useEffect } from 'react'
import { QueryClientProvider, QueryClient } from '@tanstack/react-query'
import clsx from 'clsx'
···
}
export default function Layout({ children, className }: LayoutProps) {
+
useEffect(() => {
+
// Function to update dark mode based on system preference
+
const updateDarkMode = (e: MediaQueryList | MediaQueryListEvent) => {
+
if (e.matches) {
+
document.documentElement.classList.add('dark')
+
} else {
+
document.documentElement.classList.remove('dark')
+
}
+
}
+
+
// Create media query
+
const darkModeQuery = window.matchMedia('(prefers-color-scheme: dark)')
+
+
// Set initial value
+
updateDarkMode(darkModeQuery)
+
+
// Listen for changes
+
darkModeQuery.addEventListener('change', updateDarkMode)
+
+
// Cleanup
+
return () => darkModeQuery.removeEventListener('change', updateDarkMode)
+
}, [])
+
return (
<QueryClientProvider client={client}>
<div
+62 -36
public/styles/global.css
···
@import "tailwindcss";
@import "tw-animate-css";
-
@custom-variant dark (&:is(.dark *));
:root {
/* Warm beige background inspired by Sunset design #E9DDD8 */
--background: oklch(0.90 0.012 35);
/* Very dark brown text for strong contrast #2A2420 */
···
}
.dark {
-
/* #413C58 - violet background for dark mode */
-
--background: oklch(0.28 0.04 285);
-
/* #F2E7C9 - parchment text */
-
--foreground: oklch(0.93 0.03 85);
-
--card: oklch(0.32 0.04 285);
-
--card-foreground: oklch(0.93 0.03 85);
-
--popover: oklch(0.32 0.04 285);
-
--popover-foreground: oklch(0.93 0.03 85);
-
/* #FFAAD2 - pink primary in dark mode */
-
--primary: oklch(0.78 0.15 345);
-
--primary-foreground: oklch(0.32 0.04 285);
-
--accent: oklch(0.78 0.15 345);
-
--accent-foreground: oklch(0.32 0.04 285);
-
--secondary: oklch(0.56 0.08 220);
-
--secondary-foreground: oklch(0.93 0.03 85);
-
--muted: oklch(0.38 0.03 285);
-
--muted-foreground: oklch(0.75 0.02 85);
-
--border: oklch(0.42 0.03 285);
-
--input: oklch(0.42 0.03 285);
-
--ring: oklch(0.78 0.15 345);
-
--destructive: oklch(0.577 0.245 27.325);
-
--destructive-foreground: oklch(0.985 0 0);
-
--chart-1: oklch(0.78 0.15 345);
-
--chart-2: oklch(0.93 0.03 85);
-
--chart-3: oklch(0.56 0.08 220);
-
--chart-4: oklch(0.85 0.02 130);
-
--chart-5: oklch(0.32 0.04 285);
-
--sidebar: oklch(0.205 0 0);
-
--sidebar-foreground: oklch(0.985 0 0);
-
--sidebar-primary: oklch(0.488 0.243 264.376);
-
--sidebar-primary-foreground: oklch(0.985 0 0);
-
--sidebar-accent: oklch(0.269 0 0);
-
--sidebar-accent-foreground: oklch(0.985 0 0);
-
--sidebar-border: oklch(0.269 0 0);
-
--sidebar-ring: oklch(0.439 0 0);
}
@theme inline {
···
.arrow-animate {
animation: arrow-bounce 1.5s ease-in-out infinite;
}
···
@import "tailwindcss";
@import "tw-animate-css";
+
@custom-variant dark (@media (prefers-color-scheme: dark));
:root {
+
color-scheme: light;
+
/* Warm beige background inspired by Sunset design #E9DDD8 */
--background: oklch(0.90 0.012 35);
/* Very dark brown text for strong contrast #2A2420 */
···
}
.dark {
+
color-scheme: dark;
+
/* Slate violet background - #2C2C2C with violet tint */
+
--background: oklch(0.23 0.015 285);
+
/* Light gray text - #E4E4E4 */
+
--foreground: oklch(0.90 0.005 285);
+
/* Slightly lighter slate for cards */
+
--card: oklch(0.28 0.015 285);
+
--card-foreground: oklch(0.90 0.005 285);
+
--popover: oklch(0.28 0.015 285);
+
--popover-foreground: oklch(0.90 0.005 285);
+
/* Lavender buttons - #B39CD0 */
+
--primary: oklch(0.70 0.10 295);
+
--primary-foreground: oklch(0.23 0.015 285);
+
/* Soft pink accent - #FFC1CC */
+
--accent: oklch(0.85 0.08 5);
+
--accent-foreground: oklch(0.23 0.015 285);
+
/* Light cyan secondary - #A8DADC */
+
--secondary: oklch(0.82 0.05 200);
+
--secondary-foreground: oklch(0.23 0.015 285);
+
/* Muted slate areas */
+
--muted: oklch(0.33 0.015 285);
+
--muted-foreground: oklch(0.72 0.01 285);
+
/* Subtle borders */
+
--border: oklch(0.38 0.02 285);
+
--input: oklch(0.30 0.015 285);
+
--ring: oklch(0.70 0.10 295);
+
/* Warm destructive color */
+
--destructive: oklch(0.60 0.22 27);
+
--destructive-foreground: oklch(0.98 0.01 85);
+
+
/* Chart colors using the accent palette */
+
--chart-1: oklch(0.85 0.08 5);
+
--chart-2: oklch(0.82 0.05 200);
+
--chart-3: oklch(0.70 0.10 295);
+
--chart-4: oklch(0.75 0.08 340);
+
--chart-5: oklch(0.65 0.08 180);
+
+
/* Sidebar slate */
+
--sidebar: oklch(0.20 0.015 285);
+
--sidebar-foreground: oklch(0.90 0.005 285);
+
--sidebar-primary: oklch(0.70 0.10 295);
+
--sidebar-primary-foreground: oklch(0.20 0.015 285);
+
--sidebar-accent: oklch(0.28 0.015 285);
+
--sidebar-accent-foreground: oklch(0.90 0.005 285);
+
--sidebar-border: oklch(0.32 0.02 285);
+
--sidebar-ring: oklch(0.70 0.10 295);
}
@theme inline {
···
.arrow-animate {
animation: arrow-bounce 1.5s ease-in-out infinite;
}
+
+
/* Shiki syntax highlighting styles */
+
.shiki-wrapper {
+
border-radius: 0.5rem;
+
padding: 1rem;
+
overflow-x: auto;
+
border: 1px solid hsl(var(--border));
+
}
+
+
.shiki-wrapper pre {
+
margin: 0 !important;
+
padding: 0 !important;
+
}
+16 -8
src/index.ts
···
import { Elysia } from 'elysia'
import { cors } from '@elysiajs/cors'
-
import { openapi, fromTypes } from '@elysiajs/openapi'
import { staticPlugin } from '@elysiajs/static'
import type { Config } from './lib/types'
···
dnsVerifier.start()
logger.info('DNS Verifier Started - checking custom domains every 10 minutes')
-
export const app = new Elysia()
-
.use(openapi({
-
references: fromTypes()
-
}))
// Observability middleware
.onBeforeHandle(observabilityMiddleware('main-app').beforeHandle)
-
.onAfterHandle((ctx) => {
observabilityMiddleware('main-app').afterHandle(ctx)
// Security headers middleware
const { set } = ctx
···
prefix: '/'
})
)
-
.get('/client-metadata.json', (c) => {
return createClientMetadata(config)
})
-
.get('/jwks.json', async (c) => {
const keys = await getCurrentKeys()
if (!keys.length) return { keys: [] }
···
error: error instanceof Error ? error.message : String(error)
}
}
})
.use(cors({
origin: config.domain,
···
import { Elysia } from 'elysia'
+
import type { Context } from 'elysia'
import { cors } from '@elysiajs/cors'
import { staticPlugin } from '@elysiajs/static'
import type { Config } from './lib/types'
···
dnsVerifier.start()
logger.info('DNS Verifier Started - checking custom domains every 10 minutes')
+
export const app = new Elysia({
+
serve: {
+
maxPayloadLength: 1024 * 1024 * 128 * 3,
+
development: Bun.env.NODE_ENV !== 'production' ? true : false,
+
id: Bun.env.NODE_ENV !== 'production' ? undefined : null,
+
}
+
})
// Observability middleware
.onBeforeHandle(observabilityMiddleware('main-app').beforeHandle)
+
.onAfterHandle((ctx: Context) => {
observabilityMiddleware('main-app').afterHandle(ctx)
// Security headers middleware
const { set } = ctx
···
prefix: '/'
})
)
+
.get('/client-metadata.json', () => {
return createClientMetadata(config)
})
+
.get('/jwks.json', async () => {
const keys = await getCurrentKeys()
if (!keys.length) return { keys: [] }
···
error: error instanceof Error ? error.message : String(error)
}
}
+
})
+
.get('/.well-known/atproto-did', ({ set }) => {
+
// Return plain text DID for AT Protocol domain verification
+
set.headers['Content-Type'] = 'text/plain'
+
return 'did:plc:7puq73yz2hkvbcpdhnsze2qw'
})
.use(cors({
origin: config.domain,
+35 -15
src/lib/dns-verification-worker.ts
···
};
try {
-
// Get all verified custom domains
-
const domains = await db`
-
SELECT id, domain, did FROM custom_domains WHERE verified = true
`;
if (!domains || domains.length === 0) {
-
this.log('No verified custom domains to check');
this.lastRunTime = Date.now();
return;
}
-
this.log(`Checking ${domains.length} verified custom domains`);
// Verify each domain
for (const row of domains) {
runStats.totalChecked++;
-
const { id, domain, did } = row;
try {
// Extract hash from id (SHA256 of did:domain)
···
const result = await verifyCustomDomain(domain, did, expectedHash);
if (result.verified) {
-
// Update last_verified_at timestamp
await db`
UPDATE custom_domains
-
SET last_verified_at = EXTRACT(EPOCH FROM NOW())
WHERE id = ${id}
`;
runStats.verified++;
-
this.log(`Domain verified: ${domain}`, { did });
} else {
-
// Mark domain as unverified
await db`
UPDATE custom_domains
SET verified = false,
···
WHERE id = ${id}
`;
runStats.failed++;
-
this.log(`Domain verification failed: ${domain}`, {
-
did,
-
error: result.error,
-
found: result.found,
-
});
}
} catch (error) {
runStats.errors++;
···
};
try {
+
// Get all custom domains (both verified and pending)
+
const domains = await db<Array<{
+
id: string;
+
domain: string;
+
did: string;
+
verified: boolean;
+
}>>`
+
SELECT id, domain, did, verified FROM custom_domains
`;
if (!domains || domains.length === 0) {
+
this.log('No custom domains to check');
this.lastRunTime = Date.now();
return;
}
+
const verifiedCount = domains.filter(d => d.verified).length;
+
const pendingCount = domains.filter(d => !d.verified).length;
+
this.log(`Checking ${domains.length} custom domains (${verifiedCount} verified, ${pendingCount} pending)`);
// Verify each domain
for (const row of domains) {
runStats.totalChecked++;
+
const { id, domain, did, verified: wasVerified } = row;
try {
// Extract hash from id (SHA256 of did:domain)
···
const result = await verifyCustomDomain(domain, did, expectedHash);
if (result.verified) {
+
// Update verified status and last_verified_at timestamp
await db`
UPDATE custom_domains
+
SET verified = true,
+
last_verified_at = EXTRACT(EPOCH FROM NOW())
WHERE id = ${id}
`;
runStats.verified++;
+
if (!wasVerified) {
+
this.log(`Domain newly verified: ${domain}`, { did });
+
} else {
+
this.log(`Domain re-verified: ${domain}`, { did });
+
}
} else {
+
// Mark domain as unverified or keep it pending
await db`
UPDATE custom_domains
SET verified = false,
···
WHERE id = ${id}
`;
runStats.failed++;
+
if (wasVerified) {
+
this.log(`Domain verification failed (was verified): ${domain}`, {
+
did,
+
error: result.error,
+
found: result.found,
+
});
+
} else {
+
this.log(`Domain still pending: ${domain}`, {
+
did,
+
error: result.error,
+
found: result.found,
+
});
+
}
}
} catch (error) {
runStats.errors++;
+10 -6
src/lib/observability.ts
···
service
)
-
logCollector.error(
-
`Request failed: ${request.method} ${url.pathname}`,
-
service,
-
error,
-
{ statusCode: set.status || 500 }
-
)
}
}
}
···
service
)
+
// Don't log 404 errors
+
const statusCode = set.status || 500
+
if (statusCode !== 404) {
+
logCollector.error(
+
`Request failed: ${request.method} ${url.pathname}`,
+
service,
+
error,
+
{ statusCode }
+
)
+
}
}
}
}