profileShare

rasmusjy / profileshare

Read-only snapshot

No repository description.

main default branch 54 files Expires Sep 13, 2026, 9:06 AM

Commit

Build secure GitHub snapshot sharing

commit 6a8cee5

42 changed files with +5749 and −0

Jump to a changed file
  1. .env.example +18 −0
  2. .gitignore +5 −0
  3. README.md +127 −0
  4. package-lock.json +2912 −0
  5. package.json +25 −0
  6. spec/PLAN.md +100 −0
  7. spec/SPEC.md +57 −0
  8. spec/TASKS.md +80 −0
  9. spec/VERIFICATION.md +120 −0
  10. spec/sources.json +202 −0
  11. src/app.js +349 −0
  12. src/config.js +20 −0
  13. src/db.js +163 −0
  14. src/github.js +238 −0
  15. src/public/app.js +30 −0
  16. src/public/styles.css +248 −0
  17. src/server.js +13 −0
  18. src/views/created.ejs +27 −0
  19. src/views/dashboard.ejs +83 −0
  20. src/views/error.ejs +7 −0
  21. src/views/expired.ejs +7 −0
  22. src/views/partials/foot.ejs +2 −0
  23. src/views/partials/head.ejs +10 −0
  24. src/views/profile.ejs +78 −0
  25. src/views/repository.ejs +221 −0
  26. tests/auth.test.js +61 −0
  27. tests/commits.test.js +21 −0
  28. tests/config.test.js +28 −0
  29. tests/e2e.test.js +25 −0
  30. tests/expiry.test.js +17 −0
  31. tests/file-browser.test.js +16 −0
  32. tests/github-client.test.js +128 −0
  33. tests/helpers.js +152 −0
  34. tests/layout.test.js +14 −0
  35. tests/link-generation.test.js +16 −0
  36. tests/navigation.test.js +16 −0
  37. tests/profile-page.test.js +23 −0
  38. tests/read-only.test.js +15 −0
  39. tests/repo-navigation.test.js +15 −0
  40. tests/repo-selection.test.js +16 −0
  41. tests/repo-view.test.js +25 −0
  42. tests/snapshot.test.js +19 −0
added .env.example +18 −0
@@ -0,0 +1,18 @@
1 +# From GitHub Settings > Developer settings > GitHub Apps
2 +GITHUB_CLIENT_ID=
3 +GITHUB_CLIENT_SECRET=
4 +GITHUB_APP_SLUG=
5 +
6 +# Use production for a deployed instance.
7 +NODE_ENV=development
8 +
9 +# Generate once and keep stable while using the same database:
10 +# node -e "console.log(require('node:crypto').randomBytes(32).toString('hex'))"
11 +SESSION_SECRET=replace-with-a-stable-random-value
12 +
13 +# Public origin. Keep GitHub App callback and setup URLs on this origin.
14 +BASE_URL=http://localhost:3000
15 +PORT=3000
16 +
17 +# SQLite database file. Its parent directory is created automatically.
18 +DATABASE_PATH=data/profileshare.db
added .gitignore +5 −0
@@ -0,0 +1,5 @@
1 +node_modules/
2 +.env
3 +data/
4 +coverage/
5 +
added README.md +127 −0
@@ -0,0 +1,127 @@
1 +# profileShare
2 +
3 +profileShare creates time-limited, read-only snapshots of selected GitHub repositories. Anyone with a valid link can view the profile, files, and commit history without a GitHub account.
4 +
5 +## Requirements
6 +
7 +- Node.js 24 or later
8 +- A GitHub account that can create a GitHub App
9 +
10 +Check your Node.js version:
11 +
12 +```sh
13 +node --version
14 +```
15 +
16 +## Local setup
17 +
18 +1. Install dependencies:
19 +
20 + ```sh
21 + npm install
22 + ```
23 +
24 +2. Create a GitHub App at **GitHub Settings > Developer settings > GitHub Apps > New GitHub App**. Use these settings:
25 +
26 + | Setting | Local value |
27 + | --- | --- |
28 + | GitHub App name | Any unique name |
29 + | Homepage URL | `http://localhost:3000` |
30 + | Callback URL | `http://localhost:3000/auth/github/callback` |
31 + | Setup URL | `http://localhost:3000/github/installed` |
32 + | Webhook | Clear **Active** |
33 + | Repository permissions: Contents | Read-only |
34 + | Repository permissions: Metadata | Read-only |
35 + | Where can this GitHub App be installed? | Only on this account |
36 +
37 + No webhook secret, private key, or user permission is needed. Keep **Request user authorization (OAuth) during installation** cleared because profileShare authorizes the owner before starting installation.
38 +
39 +3. Open the new GitHub App's settings page and create a client secret. Copy the **Client ID**, generated client secret, and app slug. The slug is the final part of the app's public URL, such as `my-profile-share` in `https://github.com/apps/my-profile-share`.
40 +
41 +4. Copy the example environment file:
42 +
43 + PowerShell:
44 +
45 + ```powershell
46 + Copy-Item .env.example .env
47 + ```
48 +
49 + macOS or Linux:
50 +
51 + ```sh
52 + cp .env.example .env
53 + ```
54 +
55 +5. Fill in `.env`. Generate a stable session secret with:
56 +
57 + ```sh
58 + node -e "console.log(require('node:crypto').randomBytes(32).toString('hex'))"
59 + ```
60 +
61 + Keep this secret unchanged while using the same database. It is used to encrypt stored GitHub access tokens. Changing it makes existing owner records unreadable.
62 +
63 +6. Start the app:
64 +
65 + ```sh
66 + npm start
67 + ```
68 +
69 +7. Open `http://localhost:3000`, connect GitHub, and choose **Only select repositories** during GitHub App installation. Back in profileShare, select repositories, choose an expiry from 1 to 365 days, and create the link.
70 +
71 +For automatic restart while editing:
72 +
73 +```sh
74 +npm run dev
75 +```
76 +
77 +## Configuration
78 +
79 +| Variable | Required | Default | Purpose |
80 +| --- | --- | --- | --- |
81 +| `NODE_ENV` | No | None | Set to `production` for deployment safety checks |
82 +| `GITHUB_CLIENT_ID` | Yes | None | GitHub App client ID |
83 +| `GITHUB_CLIENT_SECRET` | Yes | None | GitHub App client secret |
84 +| `GITHUB_APP_SLUG` | Yes | None | GitHub App URL slug |
85 +| `SESSION_SECRET` | Yes | Development fallback | Token-encryption secret |
86 +| `BASE_URL` | No | `http://localhost:3000` | Public origin used for OAuth callbacks and generated share links |
87 +| `PORT` | No | `3000` | HTTP port |
88 +| `DATABASE_PATH` | No | `data/profileshare.db` | SQLite database path |
89 +
90 +For a deployed instance, set `NODE_ENV=production`, set `BASE_URL` to its HTTPS origin, and update the GitHub App callback and setup URLs to the same origin. Generated links use `BASE_URL`, so it must be reachable by viewers.
91 +
92 +Snapshots are stored in the SQLite database. When an expired link is opened, its stored snapshot content is removed and the expired message is shown.
93 +
94 +## Verify
95 +
96 +Run the complete test suite:
97 +
98 +```sh
99 +npm test
100 +```
101 +
102 +Run an individual spec check:
103 +
104 +```sh
105 +npm test -- auth
106 +npm test -- snapshot
107 +npm test -- e2e
108 +```
109 +
110 +Manual acceptance check:
111 +
112 +1. Create a link with a known set of repositories.
113 +2. Open it in a private browser window and confirm no viewer login is requested.
114 +3. Confirm only the selected repositories appear.
115 +4. Browse a nested folder, open a file, and open a commit diff.
116 +5. Confirm the full commit list appears for each selected repository.
117 +6. Change a source repository and confirm the existing link remains unchanged.
118 +
119 +## Troubleshooting
120 +
121 +- **GitHub is not configured:** Confirm all three `GITHUB_*` values are present in `.env`, then restart the server.
122 +- **Callback error or sign-in verification error:** Make sure `BASE_URL` and the GitHub App callback URL use the same origin, port, and protocol. Start sign-in again from the home page.
123 +- **No repositories appear:** Open the GitHub App installation settings and grant it access to the intended repositories. The app lists only repositories selected for that installation.
124 +- **Repository snapshot fails:** Confirm the GitHub App has read-only **Contents** and **Metadata** repository permissions and that the owner still has access.
125 +- **Stored token errors after changing `SESSION_SECRET`:** Restore the previous secret, or move the local database aside and sign in again. Do not delete a database that contains snapshots you need.
126 +- **The generated link points to localhost:** Set `BASE_URL` to the viewer-reachable HTTPS origin before creating the link.
127 +- **Port conflict:** Set `PORT` and use the same port in `BASE_URL`, the callback URL, and the setup URL.
added package-lock.json +2912 −0
@@ -0,0 +1,2912 @@
1 +{
2 + "name": "profileshare",
3 + "version": "1.0.0",
4 + "lockfileVersion": 3,
5 + "requires": true,
6 + "packages": {
7 + "": {
8 + "name": "profileshare",
9 + "version": "1.0.0",
10 + "dependencies": {
11 + "ejs": "^3.1.10",
12 + "express": "^5.1.0",
13 + "marked": "^16.1.2",
14 + "sanitize-html": "^2.17.0"
15 + },
16 + "devDependencies": {
17 + "supertest": "^7.1.1",
18 + "vitest": "^3.2.4"
19 + },
20 + "engines": {
21 + "node": ">=24"
22 + }
23 + },
24 + "node_modules/@esbuild/aix-ppc64": {
25 + "version": "0.28.1",
26 + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
27 + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
28 + "cpu": [
29 + "ppc64"
30 + ],
31 + "dev": true,
32 + "license": "MIT",
33 + "optional": true,
34 + "os": [
35 + "aix"
36 + ],
37 + "engines": {
38 + "node": ">=18"
39 + }
40 + },
41 + "node_modules/@esbuild/android-arm": {
42 + "version": "0.28.1",
43 + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
44 + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
45 + "cpu": [
46 + "arm"
47 + ],
48 + "dev": true,
49 + "license": "MIT",
50 + "optional": true,
51 + "os": [
52 + "android"
53 + ],
54 + "engines": {
55 + "node": ">=18"
56 + }
57 + },
58 + "node_modules/@esbuild/android-arm64": {
59 + "version": "0.28.1",
60 + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
61 + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
62 + "cpu": [
63 + "arm64"
64 + ],
65 + "dev": true,
66 + "license": "MIT",
67 + "optional": true,
68 + "os": [
69 + "android"
70 + ],
71 + "engines": {
72 + "node": ">=18"
73 + }
74 + },
75 + "node_modules/@esbuild/android-x64": {
76 + "version": "0.28.1",
77 + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
78 + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
79 + "cpu": [
80 + "x64"
81 + ],
82 + "dev": true,
83 + "license": "MIT",
84 + "optional": true,
85 + "os": [
86 + "android"
87 + ],
88 + "engines": {
89 + "node": ">=18"
90 + }
91 + },
92 + "node_modules/@esbuild/darwin-arm64": {
93 + "version": "0.28.1",
94 + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
95 + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
96 + "cpu": [
97 + "arm64"
98 + ],
99 + "dev": true,
100 + "license": "MIT",
101 + "optional": true,
102 + "os": [
103 + "darwin"
104 + ],
105 + "engines": {
106 + "node": ">=18"
107 + }
108 + },
109 + "node_modules/@esbuild/darwin-x64": {
110 + "version": "0.28.1",
111 + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
112 + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
113 + "cpu": [
114 + "x64"
115 + ],
116 + "dev": true,
117 + "license": "MIT",
118 + "optional": true,
119 + "os": [
120 + "darwin"
121 + ],
122 + "engines": {
123 + "node": ">=18"
124 + }
125 + },
126 + "node_modules/@esbuild/freebsd-arm64": {
127 + "version": "0.28.1",
128 + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
129 + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
130 + "cpu": [
131 + "arm64"
132 + ],
133 + "dev": true,
134 + "license": "MIT",
135 + "optional": true,
136 + "os": [
137 + "freebsd"
138 + ],
139 + "engines": {
140 + "node": ">=18"
141 + }
142 + },
143 + "node_modules/@esbuild/freebsd-x64": {
144 + "version": "0.28.1",
145 + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
146 + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
147 + "cpu": [
148 + "x64"
149 + ],
150 + "dev": true,
151 + "license": "MIT",
152 + "optional": true,
153 + "os": [
154 + "freebsd"
155 + ],
156 + "engines": {
157 + "node": ">=18"
158 + }
159 + },
160 + "node_modules/@esbuild/linux-arm": {
161 + "version": "0.28.1",
162 + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
163 + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
164 + "cpu": [
165 + "arm"
166 + ],
167 + "dev": true,
168 + "license": "MIT",
169 + "optional": true,
170 + "os": [
171 + "linux"
172 + ],
173 + "engines": {
174 + "node": ">=18"
175 + }
176 + },
177 + "node_modules/@esbuild/linux-arm64": {
178 + "version": "0.28.1",
179 + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
180 + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
181 + "cpu": [
182 + "arm64"
183 + ],
184 + "dev": true,
185 + "license": "MIT",
186 + "optional": true,
187 + "os": [
188 + "linux"
189 + ],
190 + "engines": {
191 + "node": ">=18"
192 + }
193 + },
194 + "node_modules/@esbuild/linux-ia32": {
195 + "version": "0.28.1",
196 + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
197 + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
198 + "cpu": [
199 + "ia32"
200 + ],
201 + "dev": true,
202 + "license": "MIT",
203 + "optional": true,
204 + "os": [
205 + "linux"
206 + ],
207 + "engines": {
208 + "node": ">=18"
209 + }
210 + },
211 + "node_modules/@esbuild/linux-loong64": {
212 + "version": "0.28.1",
213 + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
214 + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
215 + "cpu": [
216 + "loong64"
217 + ],
218 + "dev": true,
219 + "license": "MIT",
220 + "optional": true,
221 + "os": [
222 + "linux"
223 + ],
224 + "engines": {
225 + "node": ">=18"
226 + }
227 + },
228 + "node_modules/@esbuild/linux-mips64el": {
229 + "version": "0.28.1",
230 + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
231 + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
232 + "cpu": [
233 + "mips64el"
234 + ],
235 + "dev": true,
236 + "license": "MIT",
237 + "optional": true,
238 + "os": [
239 + "linux"
240 + ],
241 + "engines": {
242 + "node": ">=18"
243 + }
244 + },
245 + "node_modules/@esbuild/linux-ppc64": {
246 + "version": "0.28.1",
247 + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
248 + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
249 + "cpu": [
250 + "ppc64"
251 + ],
252 + "dev": true,
253 + "license": "MIT",
254 + "optional": true,
255 + "os": [
256 + "linux"
257 + ],
258 + "engines": {
259 + "node": ">=18"
260 + }
261 + },
262 + "node_modules/@esbuild/linux-riscv64": {
263 + "version": "0.28.1",
264 + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
265 + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
266 + "cpu": [
267 + "riscv64"
268 + ],
269 + "dev": true,
270 + "license": "MIT",
271 + "optional": true,
272 + "os": [
273 + "linux"
274 + ],
275 + "engines": {
276 + "node": ">=18"
277 + }
278 + },
279 + "node_modules/@esbuild/linux-s390x": {
280 + "version": "0.28.1",
281 + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
282 + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
283 + "cpu": [
284 + "s390x"
285 + ],
286 + "dev": true,
287 + "license": "MIT",
288 + "optional": true,
289 + "os": [
290 + "linux"
291 + ],
292 + "engines": {
293 + "node": ">=18"
294 + }
295 + },
296 + "node_modules/@esbuild/linux-x64": {
297 + "version": "0.28.1",
298 + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
299 + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
300 + "cpu": [
301 + "x64"
302 + ],
303 + "dev": true,
304 + "license": "MIT",
305 + "optional": true,
306 + "os": [
307 + "linux"
308 + ],
309 + "engines": {
310 + "node": ">=18"
311 + }
312 + },
313 + "node_modules/@esbuild/netbsd-arm64": {
314 + "version": "0.28.1",
315 + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
316 + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
317 + "cpu": [
318 + "arm64"
319 + ],
320 + "dev": true,
321 + "license": "MIT",
322 + "optional": true,
323 + "os": [
324 + "netbsd"
325 + ],
326 + "engines": {
327 + "node": ">=18"
328 + }
329 + },
330 + "node_modules/@esbuild/netbsd-x64": {
331 + "version": "0.28.1",
332 + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
333 + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
334 + "cpu": [
335 + "x64"
336 + ],
337 + "dev": true,
338 + "license": "MIT",
339 + "optional": true,
340 + "os": [
341 + "netbsd"
342 + ],
343 + "engines": {
344 + "node": ">=18"
345 + }
346 + },
347 + "node_modules/@esbuild/openbsd-arm64": {
348 + "version": "0.28.1",
349 + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
350 + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
351 + "cpu": [
352 + "arm64"
353 + ],
354 + "dev": true,
355 + "license": "MIT",
356 + "optional": true,
357 + "os": [
358 + "openbsd"
359 + ],
360 + "engines": {
361 + "node": ">=18"
362 + }
363 + },
364 + "node_modules/@esbuild/openbsd-x64": {
365 + "version": "0.28.1",
366 + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
367 + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
368 + "cpu": [
369 + "x64"
370 + ],
371 + "dev": true,
372 + "license": "MIT",
373 + "optional": true,
374 + "os": [
375 + "openbsd"
376 + ],
377 + "engines": {
378 + "node": ">=18"
379 + }
380 + },
381 + "node_modules/@esbuild/openharmony-arm64": {
382 + "version": "0.28.1",
383 + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
384 + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
385 + "cpu": [
386 + "arm64"
387 + ],
388 + "dev": true,
389 + "license": "MIT",
390 + "optional": true,
391 + "os": [
392 + "openharmony"
393 + ],
394 + "engines": {
395 + "node": ">=18"
396 + }
397 + },
398 + "node_modules/@esbuild/sunos-x64": {
399 + "version": "0.28.1",
400 + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
401 + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
402 + "cpu": [
403 + "x64"
404 + ],
405 + "dev": true,
406 + "license": "MIT",
407 + "optional": true,
408 + "os": [
409 + "sunos"
410 + ],
411 + "engines": {
412 + "node": ">=18"
413 + }
414 + },
415 + "node_modules/@esbuild/win32-arm64": {
416 + "version": "0.28.1",
417 + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
418 + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
419 + "cpu": [
420 + "arm64"
421 + ],
422 + "dev": true,
423 + "license": "MIT",
424 + "optional": true,
425 + "os": [
426 + "win32"
427 + ],
428 + "engines": {
429 + "node": ">=18"
430 + }
431 + },
432 + "node_modules/@esbuild/win32-ia32": {
433 + "version": "0.28.1",
434 + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
435 + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
436 + "cpu": [
437 + "ia32"
438 + ],
439 + "dev": true,
440 + "license": "MIT",
441 + "optional": true,
442 + "os": [
443 + "win32"
444 + ],
445 + "engines": {
446 + "node": ">=18"
447 + }
448 + },
449 + "node_modules/@esbuild/win32-x64": {
450 + "version": "0.28.1",
451 + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
452 + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
453 + "cpu": [
454 + "x64"
455 + ],
456 + "dev": true,
457 + "license": "MIT",
458 + "optional": true,
459 + "os": [
460 + "win32"
461 + ],
462 + "engines": {
463 + "node": ">=18"
464 + }
465 + },
466 + "node_modules/@jridgewell/sourcemap-codec": {
467 + "version": "1.5.5",
468 + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
469 + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
470 + "dev": true,
471 + "license": "MIT"
472 + },
473 + "node_modules/@noble/hashes": {
474 + "version": "1.8.0",
475 + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
476 + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
477 + "dev": true,
478 + "license": "MIT",
479 + "engines": {
480 + "node": "^14.21.3 || >=16"
481 + },
482 + "funding": {
483 + "url": "https://paulmillr.com/funding/"
484 + }
485 + },
486 + "node_modules/@paralleldrive/cuid2": {
487 + "version": "2.3.1",
488 + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz",
489 + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==",
490 + "dev": true,
491 + "license": "MIT",
492 + "dependencies": {
493 + "@noble/hashes": "^1.1.5"
494 + }
495 + },
496 + "node_modules/@rollup/rollup-android-arm-eabi": {
497 + "version": "4.62.2",
498 + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz",
499 + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==",
500 + "cpu": [
501 + "arm"
502 + ],
503 + "dev": true,
504 + "license": "MIT",
505 + "optional": true,
506 + "os": [
507 + "android"
508 + ]
509 + },
510 + "node_modules/@rollup/rollup-android-arm64": {
511 + "version": "4.62.2",
512 + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz",
513 + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==",
514 + "cpu": [
515 + "arm64"
516 + ],
517 + "dev": true,
518 + "license": "MIT",
519 + "optional": true,
520 + "os": [
521 + "android"
522 + ]
523 + },
524 + "node_modules/@rollup/rollup-darwin-arm64": {
525 + "version": "4.62.2",
526 + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz",
527 + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==",
528 + "cpu": [
529 + "arm64"
530 + ],
531 + "dev": true,
532 + "license": "MIT",
533 + "optional": true,
534 + "os": [
535 + "darwin"
536 + ]
537 + },
538 + "node_modules/@rollup/rollup-darwin-x64": {
539 + "version": "4.62.2",
540 + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz",
541 + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==",
542 + "cpu": [
543 + "x64"
544 + ],
545 + "dev": true,
546 + "license": "MIT",
547 + "optional": true,
548 + "os": [
549 + "darwin"
550 + ]
551 + },
552 + "node_modules/@rollup/rollup-freebsd-arm64": {
553 + "version": "4.62.2",
554 + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz",
555 + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==",
556 + "cpu": [
557 + "arm64"
558 + ],
559 + "dev": true,
560 + "license": "MIT",
561 + "optional": true,
562 + "os": [
563 + "freebsd"
564 + ]
565 + },
566 + "node_modules/@rollup/rollup-freebsd-x64": {
567 + "version": "4.62.2",
568 + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz",
569 + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==",
570 + "cpu": [
571 + "x64"
572 + ],
573 + "dev": true,
574 + "license": "MIT",
575 + "optional": true,
576 + "os": [
577 + "freebsd"
578 + ]
579 + },
580 + "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
581 + "version": "4.62.2",
582 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz",
583 + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==",
584 + "cpu": [
585 + "arm"
586 + ],
587 + "dev": true,
588 + "license": "MIT",
589 + "optional": true,
590 + "os": [
591 + "linux"
592 + ]
593 + },
594 + "node_modules/@rollup/rollup-linux-arm-musleabihf": {
595 + "version": "4.62.2",
596 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz",
597 + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==",
598 + "cpu": [
599 + "arm"
600 + ],
601 + "dev": true,
602 + "license": "MIT",
603 + "optional": true,
604 + "os": [
605 + "linux"
606 + ]
607 + },
608 + "node_modules/@rollup/rollup-linux-arm64-gnu": {
609 + "version": "4.62.2",
610 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz",
611 + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==",
612 + "cpu": [
613 + "arm64"
614 + ],
615 + "dev": true,
616 + "license": "MIT",
617 + "optional": true,
618 + "os": [
619 + "linux"
620 + ]
621 + },
622 + "node_modules/@rollup/rollup-linux-arm64-musl": {
623 + "version": "4.62.2",
624 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz",
625 + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==",
626 + "cpu": [
627 + "arm64"
628 + ],
629 + "dev": true,
630 + "license": "MIT",
631 + "optional": true,
632 + "os": [
633 + "linux"
634 + ]
635 + },
636 + "node_modules/@rollup/rollup-linux-loong64-gnu": {
637 + "version": "4.62.2",
638 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz",
639 + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==",
640 + "cpu": [
641 + "loong64"
642 + ],
643 + "dev": true,
644 + "license": "MIT",
645 + "optional": true,
646 + "os": [
647 + "linux"
648 + ]
649 + },
650 + "node_modules/@rollup/rollup-linux-loong64-musl": {
651 + "version": "4.62.2",
652 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz",
653 + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==",
654 + "cpu": [
655 + "loong64"
656 + ],
657 + "dev": true,
658 + "license": "MIT",
659 + "optional": true,
660 + "os": [
661 + "linux"
662 + ]
663 + },
664 + "node_modules/@rollup/rollup-linux-ppc64-gnu": {
665 + "version": "4.62.2",
666 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz",
667 + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==",
668 + "cpu": [
669 + "ppc64"
670 + ],
671 + "dev": true,
672 + "license": "MIT",
673 + "optional": true,
674 + "os": [
675 + "linux"
676 + ]
677 + },
678 + "node_modules/@rollup/rollup-linux-ppc64-musl": {
679 + "version": "4.62.2",
680 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz",
681 + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==",
682 + "cpu": [
683 + "ppc64"
684 + ],
685 + "dev": true,
686 + "license": "MIT",
687 + "optional": true,
688 + "os": [
689 + "linux"
690 + ]
691 + },
692 + "node_modules/@rollup/rollup-linux-riscv64-gnu": {
693 + "version": "4.62.2",
694 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz",
695 + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==",
696 + "cpu": [
697 + "riscv64"
698 + ],
699 + "dev": true,
700 + "license": "MIT",
701 + "optional": true,
702 + "os": [
703 + "linux"
704 + ]
705 + },
706 + "node_modules/@rollup/rollup-linux-riscv64-musl": {
707 + "version": "4.62.2",
708 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz",
709 + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==",
710 + "cpu": [
711 + "riscv64"
712 + ],
713 + "dev": true,
714 + "license": "MIT",
715 + "optional": true,
716 + "os": [
717 + "linux"
718 + ]
719 + },
720 + "node_modules/@rollup/rollup-linux-s390x-gnu": {
721 + "version": "4.62.2",
722 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz",
723 + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==",
724 + "cpu": [
725 + "s390x"
726 + ],
727 + "dev": true,
728 + "license": "MIT",
729 + "optional": true,
730 + "os": [
731 + "linux"
732 + ]
733 + },
734 + "node_modules/@rollup/rollup-linux-x64-gnu": {
735 + "version": "4.62.2",
736 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz",
737 + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==",
738 + "cpu": [
739 + "x64"
740 + ],
741 + "dev": true,
742 + "license": "MIT",
743 + "optional": true,
744 + "os": [
745 + "linux"
746 + ]
747 + },
748 + "node_modules/@rollup/rollup-linux-x64-musl": {
749 + "version": "4.62.2",
750 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz",
751 + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==",
752 + "cpu": [
753 + "x64"
754 + ],
755 + "dev": true,
756 + "license": "MIT",
757 + "optional": true,
758 + "os": [
759 + "linux"
760 + ]
761 + },
762 + "node_modules/@rollup/rollup-openbsd-x64": {
763 + "version": "4.62.2",
764 + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz",
765 + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==",
766 + "cpu": [
767 + "x64"
768 + ],
769 + "dev": true,
770 + "license": "MIT",
771 + "optional": true,
772 + "os": [
773 + "openbsd"
774 + ]
775 + },
776 + "node_modules/@rollup/rollup-openharmony-arm64": {
777 + "version": "4.62.2",
778 + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz",
779 + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==",
780 + "cpu": [
781 + "arm64"
782 + ],
783 + "dev": true,
784 + "license": "MIT",
785 + "optional": true,
786 + "os": [
787 + "openharmony"
788 + ]
789 + },
790 + "node_modules/@rollup/rollup-win32-arm64-msvc": {
791 + "version": "4.62.2",
792 + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz",
793 + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==",
794 + "cpu": [
795 + "arm64"
796 + ],
797 + "dev": true,
798 + "license": "MIT",
799 + "optional": true,
800 + "os": [
801 + "win32"
802 + ]
803 + },
804 + "node_modules/@rollup/rollup-win32-ia32-msvc": {
805 + "version": "4.62.2",
806 + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz",
807 + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==",
808 + "cpu": [
809 + "ia32"
810 + ],
811 + "dev": true,
812 + "license": "MIT",
813 + "optional": true,
814 + "os": [
815 + "win32"
816 + ]
817 + },
818 + "node_modules/@rollup/rollup-win32-x64-gnu": {
819 + "version": "4.62.2",
820 + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz",
821 + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==",
822 + "cpu": [
823 + "x64"
824 + ],
825 + "dev": true,
826 + "license": "MIT",
827 + "optional": true,
828 + "os": [
829 + "win32"
830 + ]
831 + },
832 + "node_modules/@rollup/rollup-win32-x64-msvc": {
833 + "version": "4.62.2",
834 + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz",
835 + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==",
836 + "cpu": [
837 + "x64"
838 + ],
839 + "dev": true,
840 + "license": "MIT",
841 + "optional": true,
842 + "os": [
843 + "win32"
844 + ]
845 + },
846 + "node_modules/@types/chai": {
847 + "version": "5.2.3",
848 + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
849 + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
850 + "dev": true,
851 + "license": "MIT",
852 + "dependencies": {
853 + "@types/deep-eql": "*",
854 + "assertion-error": "^2.0.1"
855 + }
856 + },
857 + "node_modules/@types/deep-eql": {
858 + "version": "4.0.2",
859 + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
860 + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
861 + "dev": true,
862 + "license": "MIT"
863 + },
864 + "node_modules/@types/estree": {
865 + "version": "1.0.9",
866 + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
867 + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
868 + "dev": true,
869 + "license": "MIT"
870 + },
871 + "node_modules/@vitest/expect": {
872 + "version": "3.2.7",
873 + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz",
874 + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==",
875 + "dev": true,
876 + "license": "MIT",
877 + "dependencies": {
878 + "@types/chai": "^5.2.2",
879 + "@vitest/spy": "3.2.7",
880 + "@vitest/utils": "3.2.7",
881 + "chai": "^5.2.0",
882 + "tinyrainbow": "^2.0.0"
883 + },
884 + "funding": {
885 + "url": "https://opencollective.com/vitest"
886 + }
887 + },
888 + "node_modules/@vitest/mocker": {
889 + "version": "3.2.7",
890 + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz",
891 + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==",
892 + "dev": true,
893 + "license": "MIT",
894 + "dependencies": {
895 + "@vitest/spy": "3.2.7",
896 + "estree-walker": "^3.0.3",
897 + "magic-string": "^0.30.17"
898 + },
899 + "funding": {
900 + "url": "https://opencollective.com/vitest"
901 + },
902 + "peerDependencies": {
903 + "msw": "^2.4.9",
904 + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
905 + },
906 + "peerDependenciesMeta": {
907 + "msw": {
908 + "optional": true
909 + },
910 + "vite": {
911 + "optional": true
912 + }
913 + }
914 + },
915 + "node_modules/@vitest/pretty-format": {
916 + "version": "3.2.7",
917 + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz",
918 + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==",
919 + "dev": true,
920 + "license": "MIT",
921 + "dependencies": {
922 + "tinyrainbow": "^2.0.0"
923 + },
924 + "funding": {
925 + "url": "https://opencollective.com/vitest"
926 + }
927 + },
928 + "node_modules/@vitest/runner": {
929 + "version": "3.2.7",
930 + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz",
931 + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==",
932 + "dev": true,
933 + "license": "MIT",
934 + "dependencies": {
935 + "@vitest/utils": "3.2.7",
936 + "pathe": "^2.0.3",
937 + "strip-literal": "^3.0.0"
938 + },
939 + "funding": {
940 + "url": "https://opencollective.com/vitest"
941 + }
942 + },
943 + "node_modules/@vitest/snapshot": {
944 + "version": "3.2.7",
945 + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz",
946 + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==",
947 + "dev": true,
948 + "license": "MIT",
949 + "dependencies": {
950 + "@vitest/pretty-format": "3.2.7",
951 + "magic-string": "^0.30.17",
952 + "pathe": "^2.0.3"
953 + },
954 + "funding": {
955 + "url": "https://opencollective.com/vitest"
956 + }
957 + },
958 + "node_modules/@vitest/spy": {
959 + "version": "3.2.7",
960 + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz",
961 + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==",
962 + "dev": true,
963 + "license": "MIT",
964 + "dependencies": {
965 + "tinyspy": "^4.0.3"
966 + },
967 + "funding": {
968 + "url": "https://opencollective.com/vitest"
969 + }
970 + },
971 + "node_modules/@vitest/utils": {
972 + "version": "3.2.7",
973 + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz",
974 + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==",
975 + "dev": true,
976 + "license": "MIT",
977 + "dependencies": {
978 + "@vitest/pretty-format": "3.2.7",
979 + "loupe": "^3.1.4",
980 + "tinyrainbow": "^2.0.0"
981 + },
982 + "funding": {
983 + "url": "https://opencollective.com/vitest"
984 + }
985 + },
986 + "node_modules/accepts": {
987 + "version": "2.0.0",
988 + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
989 + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
990 + "license": "MIT",
991 + "dependencies": {
992 + "mime-types": "^3.0.0",
993 + "negotiator": "^1.0.0"
994 + },
995 + "engines": {
996 + "node": ">= 0.6"
997 + }
998 + },
999 + "node_modules/asap": {
1000 + "version": "2.0.6",
1001 + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz",
1002 + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==",
1003 + "dev": true,
1004 + "license": "MIT"
1005 + },
1006 + "node_modules/assertion-error": {
1007 + "version": "2.0.1",
1008 + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
1009 + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
1010 + "dev": true,
1011 + "license": "MIT",
1012 + "engines": {
1013 + "node": ">=12"
1014 + }
1015 + },
1016 + "node_modules/async": {
1017 + "version": "3.2.6",
1018 + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
1019 + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
1020 + "license": "MIT"
1021 + },
1022 + "node_modules/asynckit": {
1023 + "version": "0.4.0",
1024 + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
1025 + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
1026 + "dev": true,
1027 + "license": "MIT"
1028 + },
1029 + "node_modules/balanced-match": {
1030 + "version": "1.0.2",
1031 + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
1032 + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
1033 + "license": "MIT"
1034 + },
1035 + "node_modules/body-parser": {
1036 + "version": "2.3.0",
1037 + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
1038 + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
1039 + "license": "MIT",
1040 + "dependencies": {
1041 + "bytes": "^3.1.2",
1042 + "content-type": "^2.0.0",
1043 + "debug": "^4.4.3",
1044 + "http-errors": "^2.0.1",
1045 + "iconv-lite": "^0.7.2",
1046 + "on-finished": "^2.4.1",
1047 + "qs": "^6.15.2",
1048 + "raw-body": "^3.0.2",
1049 + "type-is": "^2.1.0"
1050 + },
1051 + "engines": {
1052 + "node": ">=18"
1053 + },
1054 + "funding": {
1055 + "type": "opencollective",
1056 + "url": "https://opencollective.com/express"
1057 + }
1058 + },
1059 + "node_modules/body-parser/node_modules/content-type": {
1060 + "version": "2.0.0",
1061 + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
1062 + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
1063 + "license": "MIT",
1064 + "engines": {
1065 + "node": ">=18"
1066 + },
1067 + "funding": {
1068 + "type": "opencollective",
1069 + "url": "https://opencollective.com/express"
1070 + }
1071 + },
1072 + "node_modules/brace-expansion": {
1073 + "version": "2.1.2",
1074 + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
1075 + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
1076 + "license": "MIT",
1077 + "dependencies": {
1078 + "balanced-match": "^1.0.0"
1079 + }
1080 + },
1081 + "node_modules/bytes": {
1082 + "version": "3.1.2",
1083 + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
1084 + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
1085 + "license": "MIT",
1086 + "engines": {
1087 + "node": ">= 0.8"
1088 + }
1089 + },
1090 + "node_modules/cac": {
1091 + "version": "6.7.14",
1092 + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
1093 + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
1094 + "dev": true,
1095 + "license": "MIT",
1096 + "engines": {
1097 + "node": ">=8"
1098 + }
1099 + },
1100 + "node_modules/call-bind-apply-helpers": {
1101 + "version": "1.0.2",
1102 + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
1103 + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
1104 + "license": "MIT",
1105 + "dependencies": {
1106 + "es-errors": "^1.3.0",
1107 + "function-bind": "^1.1.2"
1108 + },
1109 + "engines": {
1110 + "node": ">= 0.4"
1111 + }
1112 + },
1113 + "node_modules/call-bound": {
1114 + "version": "1.0.4",
1115 + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
1116 + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
1117 + "license": "MIT",
1118 + "dependencies": {
1119 + "call-bind-apply-helpers": "^1.0.2",
1120 + "get-intrinsic": "^1.3.0"
1121 + },
1122 + "engines": {
1123 + "node": ">= 0.4"
1124 + },
1125 + "funding": {
1126 + "url": "https://github.com/sponsors/ljharb"
1127 + }
1128 + },
1129 + "node_modules/chai": {
1130 + "version": "5.3.3",
1131 + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
1132 + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==",
1133 + "dev": true,
1134 + "license": "MIT",
1135 + "dependencies": {
1136 + "assertion-error": "^2.0.1",
1137 + "check-error": "^2.1.1",
1138 + "deep-eql": "^5.0.1",
1139 + "loupe": "^3.1.0",
1140 + "pathval": "^2.0.0"
1141 + },
1142 + "engines": {
1143 + "node": ">=18"
1144 + }
1145 + },
1146 + "node_modules/check-error": {
1147 + "version": "2.1.3",
1148 + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
1149 + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==",
1150 + "dev": true,
1151 + "license": "MIT",
1152 + "engines": {
1153 + "node": ">= 16"
1154 + }
1155 + },
1156 + "node_modules/combined-stream": {
1157 + "version": "1.0.8",
1158 + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
1159 + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
1160 + "dev": true,
1161 + "license": "MIT",
1162 + "dependencies": {
1163 + "delayed-stream": "~1.0.0"
1164 + },
1165 + "engines": {
1166 + "node": ">= 0.8"
1167 + }
1168 + },
1169 + "node_modules/component-emitter": {
1170 + "version": "1.3.1",
1171 + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz",
1172 + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==",
1173 + "dev": true,
1174 + "license": "MIT",
1175 + "funding": {
1176 + "url": "https://github.com/sponsors/sindresorhus"
1177 + }
1178 + },
1179 + "node_modules/content-disposition": {
1180 + "version": "1.1.0",
1181 + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
1182 + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==",
1183 + "license": "MIT",
1184 + "engines": {
1185 + "node": ">=18"
1186 + },
1187 + "funding": {
1188 + "type": "opencollective",
1189 + "url": "https://opencollective.com/express"
1190 + }
1191 + },
1192 + "node_modules/content-type": {
1193 + "version": "1.0.5",
1194 + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
1195 + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
1196 + "license": "MIT",
1197 + "engines": {
1198 + "node": ">= 0.6"
1199 + }
1200 + },
1201 + "node_modules/cookie": {
1202 + "version": "0.7.2",
1203 + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
1204 + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
1205 + "license": "MIT",
1206 + "engines": {
1207 + "node": ">= 0.6"
1208 + }
1209 + },
1210 + "node_modules/cookie-signature": {
1211 + "version": "1.2.2",
1212 + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
1213 + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
1214 + "license": "MIT",
1215 + "engines": {
1216 + "node": ">=6.6.0"
1217 + }
1218 + },
1219 + "node_modules/cookiejar": {
1220 + "version": "2.1.4",
1221 + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz",
1222 + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==",
1223 + "dev": true,
1224 + "license": "MIT"
1225 + },
1226 + "node_modules/dayjs": {
1227 + "version": "1.11.21",
1228 + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz",
1229 + "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==",
1230 + "license": "MIT"
1231 + },
1232 + "node_modules/debug": {
1233 + "version": "4.4.3",
1234 + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
1235 + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
1236 + "license": "MIT",
1237 + "dependencies": {
1238 + "ms": "^2.1.3"
1239 + },
1240 + "engines": {
1241 + "node": ">=6.0"
1242 + },
1243 + "peerDependenciesMeta": {
1244 + "supports-color": {
1245 + "optional": true
1246 + }
1247 + }
1248 + },
1249 + "node_modules/deep-eql": {
1250 + "version": "5.0.2",
1251 + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
1252 + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
1253 + "dev": true,
1254 + "license": "MIT",
1255 + "engines": {
1256 + "node": ">=6"
1257 + }
1258 + },
1259 + "node_modules/deepmerge": {
1260 + "version": "4.3.1",
1261 + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
1262 + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
1263 + "license": "MIT",
1264 + "engines": {
1265 + "node": ">=0.10.0"
1266 + }
1267 + },
1268 + "node_modules/delayed-stream": {
1269 + "version": "1.0.0",
1270 + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
1271 + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
1272 + "dev": true,
1273 + "license": "MIT",
1274 + "engines": {
1275 + "node": ">=0.4.0"
1276 + }
1277 + },
1278 + "node_modules/depd": {
1279 + "version": "2.0.0",
1280 + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
1281 + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
1282 + "license": "MIT",
1283 + "engines": {
1284 + "node": ">= 0.8"
1285 + }
1286 + },
1287 + "node_modules/dezalgo": {
1288 + "version": "1.0.4",
1289 + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz",
1290 + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==",
1291 + "dev": true,
1292 + "license": "ISC",
1293 + "dependencies": {
1294 + "asap": "^2.0.0",
1295 + "wrappy": "1"
1296 + }
1297 + },
1298 + "node_modules/dom-serializer": {
1299 + "version": "3.1.1",
1300 + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-3.1.1.tgz",
1301 + "integrity": "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==",
1302 + "license": "MIT",
1303 + "dependencies": {
1304 + "domelementtype": "^3.0.0",
1305 + "domhandler": "^6.0.0",
1306 + "entities": "^8.0.0"
1307 + },
1308 + "engines": {
1309 + "node": ">=20.19.0"
1310 + },
1311 + "funding": {
1312 + "type": "github",
1313 + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
1314 + }
1315 + },
1316 + "node_modules/domelementtype": {
1317 + "version": "3.0.0",
1318 + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-3.0.0.tgz",
1319 + "integrity": "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==",
1320 + "funding": [
1321 + {
1322 + "type": "github",
1323 + "url": "https://github.com/sponsors/fb55"
1324 + }
1325 + ],
1326 + "license": "BSD-2-Clause",
1327 + "engines": {
1328 + "node": ">=20.19.0"
1329 + }
1330 + },
1331 + "node_modules/domhandler": {
1332 + "version": "6.0.1",
1333 + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-6.0.1.tgz",
1334 + "integrity": "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==",
1335 + "license": "BSD-2-Clause",
1336 + "dependencies": {
1337 + "domelementtype": "^3.0.0"
1338 + },
1339 + "engines": {
1340 + "node": ">=20.19.0"
1341 + },
1342 + "funding": {
1343 + "type": "github",
1344 + "url": "https://github.com/fb55/domhandler?sponsor=1"
1345 + }
1346 + },
1347 + "node_modules/domutils": {
1348 + "version": "4.0.2",
1349 + "resolved": "https://registry.npmjs.org/domutils/-/domutils-4.0.2.tgz",
1350 + "integrity": "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==",
1351 + "license": "BSD-2-Clause",
1352 + "dependencies": {
1353 + "dom-serializer": "^3.0.0",
1354 + "domelementtype": "^3.0.0",
1355 + "domhandler": "^6.0.0"
1356 + },
1357 + "engines": {
1358 + "node": ">=20.19.0"
1359 + },
1360 + "funding": {
1361 + "type": "github",
1362 + "url": "https://github.com/fb55/domutils?sponsor=1"
1363 + }
1364 + },
1365 + "node_modules/dunder-proto": {
1366 + "version": "1.0.1",
1367 + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
1368 + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
1369 + "license": "MIT",
1370 + "dependencies": {
1371 + "call-bind-apply-helpers": "^1.0.1",
1372 + "es-errors": "^1.3.0",
1373 + "gopd": "^1.2.0"
1374 + },
1375 + "engines": {
1376 + "node": ">= 0.4"
1377 + }
1378 + },
1379 + "node_modules/ee-first": {
1380 + "version": "1.1.1",
1381 + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
1382 + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
1383 + "license": "MIT"
1384 + },
1385 + "node_modules/ejs": {
1386 + "version": "3.1.10",
1387 + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz",
1388 + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==",
1389 + "license": "Apache-2.0",
1390 + "dependencies": {
1391 + "jake": "^10.8.5"
1392 + },
1393 + "bin": {
1394 + "ejs": "bin/cli.js"
1395 + },
1396 + "engines": {
1397 + "node": ">=0.10.0"
1398 + }
1399 + },
1400 + "node_modules/encodeurl": {
1401 + "version": "2.0.0",
1402 + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
1403 + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
1404 + "license": "MIT",
1405 + "engines": {
1406 + "node": ">= 0.8"
1407 + }
1408 + },
1409 + "node_modules/entities": {
1410 + "version": "8.0.0",
1411 + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
1412 + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
1413 + "license": "BSD-2-Clause",
1414 + "engines": {
1415 + "node": ">=20.19.0"
1416 + },
1417 + "funding": {
1418 + "url": "https://github.com/fb55/entities?sponsor=1"
1419 + }
1420 + },
1421 + "node_modules/es-define-property": {
1422 + "version": "1.0.1",
1423 + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
1424 + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
1425 + "license": "MIT",
1426 + "engines": {
1427 + "node": ">= 0.4"
1428 + }
1429 + },
1430 + "node_modules/es-errors": {
1431 + "version": "1.3.0",
1432 + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
1433 + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
1434 + "license": "MIT",
1435 + "engines": {
1436 + "node": ">= 0.4"
1437 + }
1438 + },
1439 + "node_modules/es-module-lexer": {
1440 + "version": "1.7.0",
1441 + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
1442 + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
1443 + "dev": true,
1444 + "license": "MIT"
1445 + },
1446 + "node_modules/es-object-atoms": {
1447 + "version": "1.1.2",
1448 + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
1449 + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
1450 + "license": "MIT",
1451 + "dependencies": {
1452 + "es-errors": "^1.3.0"
1453 + },
1454 + "engines": {
1455 + "node": ">= 0.4"
1456 + }
1457 + },
1458 + "node_modules/es-set-tostringtag": {
1459 + "version": "2.1.0",
1460 + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
1461 + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
1462 + "dev": true,
1463 + "license": "MIT",
1464 + "dependencies": {
1465 + "es-errors": "^1.3.0",
1466 + "get-intrinsic": "^1.2.6",
1467 + "has-tostringtag": "^1.0.2",
1468 + "hasown": "^2.0.2"
1469 + },
1470 + "engines": {
1471 + "node": ">= 0.4"
1472 + }
1473 + },
1474 + "node_modules/esbuild": {
1475 + "version": "0.28.1",
1476 + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
1477 + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
1478 + "dev": true,
1479 + "hasInstallScript": true,
1480 + "license": "MIT",
1481 + "bin": {
1482 + "esbuild": "bin/esbuild"
1483 + },
1484 + "engines": {
1485 + "node": ">=18"
1486 + },
1487 + "optionalDependencies": {
1488 + "@esbuild/aix-ppc64": "0.28.1",
1489 + "@esbuild/android-arm": "0.28.1",
1490 + "@esbuild/android-arm64": "0.28.1",
1491 + "@esbuild/android-x64": "0.28.1",
1492 + "@esbuild/darwin-arm64": "0.28.1",
1493 + "@esbuild/darwin-x64": "0.28.1",
1494 + "@esbuild/freebsd-arm64": "0.28.1",
1495 + "@esbuild/freebsd-x64": "0.28.1",
1496 + "@esbuild/linux-arm": "0.28.1",
1497 + "@esbuild/linux-arm64": "0.28.1",
1498 + "@esbuild/linux-ia32": "0.28.1",
1499 + "@esbuild/linux-loong64": "0.28.1",
1500 + "@esbuild/linux-mips64el": "0.28.1",
1501 + "@esbuild/linux-ppc64": "0.28.1",
1502 + "@esbuild/linux-riscv64": "0.28.1",
1503 + "@esbuild/linux-s390x": "0.28.1",
1504 + "@esbuild/linux-x64": "0.28.1",
1505 + "@esbuild/netbsd-arm64": "0.28.1",
1506 + "@esbuild/netbsd-x64": "0.28.1",
1507 + "@esbuild/openbsd-arm64": "0.28.1",
1508 + "@esbuild/openbsd-x64": "0.28.1",
1509 + "@esbuild/openharmony-arm64": "0.28.1",
1510 + "@esbuild/sunos-x64": "0.28.1",
1511 + "@esbuild/win32-arm64": "0.28.1",
1512 + "@esbuild/win32-ia32": "0.28.1",
1513 + "@esbuild/win32-x64": "0.28.1"
1514 + }
1515 + },
1516 + "node_modules/escape-html": {
1517 + "version": "1.0.3",
1518 + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
1519 + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
1520 + "license": "MIT"
1521 + },
1522 + "node_modules/escape-string-regexp": {
1523 + "version": "4.0.0",
1524 + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
1525 + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
1526 + "license": "MIT",
1527 + "engines": {
1528 + "node": ">=10"
1529 + },
1530 + "funding": {
1531 + "url": "https://github.com/sponsors/sindresorhus"
1532 + }
1533 + },
1534 + "node_modules/estree-walker": {
1535 + "version": "3.0.3",
1536 + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
1537 + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
1538 + "dev": true,
1539 + "license": "MIT",
1540 + "dependencies": {
1541 + "@types/estree": "^1.0.0"
1542 + }
1543 + },
1544 + "node_modules/etag": {
1545 + "version": "1.8.1",
1546 + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
1547 + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
1548 + "license": "MIT",
1549 + "engines": {
1550 + "node": ">= 0.6"
1551 + }
1552 + },
1553 + "node_modules/expect-type": {
1554 + "version": "1.4.0",
1555 + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
1556 + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
1557 + "dev": true,
1558 + "license": "Apache-2.0",
1559 + "engines": {
1560 + "node": ">=12.0.0"
1561 + }
1562 + },
1563 + "node_modules/express": {
1564 + "version": "5.2.1",
1565 + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
1566 + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
1567 + "license": "MIT",
1568 + "dependencies": {
1569 + "accepts": "^2.0.0",
1570 + "body-parser": "^2.2.1",
1571 + "content-disposition": "^1.0.0",
1572 + "content-type": "^1.0.5",
1573 + "cookie": "^0.7.1",
1574 + "cookie-signature": "^1.2.1",
1575 + "debug": "^4.4.0",
1576 + "depd": "^2.0.0",
1577 + "encodeurl": "^2.0.0",
1578 + "escape-html": "^1.0.3",
1579 + "etag": "^1.8.1",
1580 + "finalhandler": "^2.1.0",
1581 + "fresh": "^2.0.0",
1582 + "http-errors": "^2.0.0",
1583 + "merge-descriptors": "^2.0.0",
1584 + "mime-types": "^3.0.0",
1585 + "on-finished": "^2.4.1",
1586 + "once": "^1.4.0",
1587 + "parseurl": "^1.3.3",
1588 + "proxy-addr": "^2.0.7",
1589 + "qs": "^6.14.0",
1590 + "range-parser": "^1.2.1",
1591 + "router": "^2.2.0",
1592 + "send": "^1.1.0",
1593 + "serve-static": "^2.2.0",
1594 + "statuses": "^2.0.1",
1595 + "type-is": "^2.0.1",
1596 + "vary": "^1.1.2"
1597 + },
1598 + "engines": {
1599 + "node": ">= 18"
1600 + },
1601 + "funding": {
1602 + "type": "opencollective",
1603 + "url": "https://opencollective.com/express"
1604 + }
1605 + },
1606 + "node_modules/fast-safe-stringify": {
1607 + "version": "2.1.1",
1608 + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz",
1609 + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==",
1610 + "dev": true,
1611 + "license": "MIT"
1612 + },
1613 + "node_modules/fdir": {
1614 + "version": "6.5.0",
1615 + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
1616 + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
1617 + "dev": true,
1618 + "license": "MIT",
1619 + "engines": {
1620 + "node": ">=12.0.0"
1621 + },
1622 + "peerDependencies": {
1623 + "picomatch": "^3 || ^4"
1624 + },
1625 + "peerDependenciesMeta": {
1626 + "picomatch": {
1627 + "optional": true
1628 + }
1629 + }
1630 + },
1631 + "node_modules/filelist": {
1632 + "version": "1.0.6",
1633 + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz",
1634 + "integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==",
1635 + "license": "Apache-2.0",
1636 + "dependencies": {
1637 + "minimatch": "^5.0.1"
1638 + }
1639 + },
1640 + "node_modules/finalhandler": {
1641 + "version": "2.1.1",
1642 + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
1643 + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
1644 + "license": "MIT",
1645 + "dependencies": {
1646 + "debug": "^4.4.0",
1647 + "encodeurl": "^2.0.0",
1648 + "escape-html": "^1.0.3",
1649 + "on-finished": "^2.4.1",
1650 + "parseurl": "^1.3.3",
1651 + "statuses": "^2.0.1"
1652 + },
1653 + "engines": {
1654 + "node": ">= 18.0.0"
1655 + },
1656 + "funding": {
1657 + "type": "opencollective",
1658 + "url": "https://opencollective.com/express"
1659 + }
1660 + },
1661 + "node_modules/form-data": {
1662 + "version": "4.0.6",
1663 + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
1664 + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
1665 + "dev": true,
1666 + "license": "MIT",
1667 + "dependencies": {
1668 + "asynckit": "^0.4.0",
1669 + "combined-stream": "^1.0.8",
1670 + "es-set-tostringtag": "^2.1.0",
1671 + "hasown": "^2.0.4",
1672 + "mime-types": "^2.1.35"
1673 + },
1674 + "engines": {
1675 + "node": ">= 6"
1676 + }
1677 + },
1678 + "node_modules/form-data/node_modules/mime-db": {
1679 + "version": "1.52.0",
1680 + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
1681 + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
1682 + "dev": true,
1683 + "license": "MIT",
1684 + "engines": {
1685 + "node": ">= 0.6"
1686 + }
1687 + },
1688 + "node_modules/form-data/node_modules/mime-types": {
1689 + "version": "2.1.35",
1690 + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
1691 + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
1692 + "dev": true,
1693 + "license": "MIT",
1694 + "dependencies": {
1695 + "mime-db": "1.52.0"
1696 + },
1697 + "engines": {
1698 + "node": ">= 0.6"
1699 + }
1700 + },
1701 + "node_modules/formidable": {
1702 + "version": "3.5.4",
1703 + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz",
1704 + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==",
1705 + "dev": true,
1706 + "license": "MIT",
1707 + "dependencies": {
1708 + "@paralleldrive/cuid2": "^2.2.2",
1709 + "dezalgo": "^1.0.4",
1710 + "once": "^1.4.0"
1711 + },
1712 + "engines": {
1713 + "node": ">=14.0.0"
1714 + },
1715 + "funding": {
1716 + "url": "https://ko-fi.com/tunnckoCore/commissions"
1717 + }
1718 + },
1719 + "node_modules/forwarded": {
1720 + "version": "0.2.0",
1721 + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
1722 + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
1723 + "license": "MIT",
1724 + "engines": {
1725 + "node": ">= 0.6"
1726 + }
1727 + },
1728 + "node_modules/fresh": {
1729 + "version": "2.0.0",
1730 + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
1731 + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
1732 + "license": "MIT",
1733 + "engines": {
1734 + "node": ">= 0.8"
1735 + }
1736 + },
1737 + "node_modules/fsevents": {
1738 + "version": "2.3.3",
1739 + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
1740 + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
1741 + "dev": true,
1742 + "hasInstallScript": true,
1743 + "license": "MIT",
1744 + "optional": true,
1745 + "os": [
1746 + "darwin"
1747 + ],
1748 + "engines": {
1749 + "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
1750 + }
1751 + },
1752 + "node_modules/function-bind": {
1753 + "version": "1.1.2",
1754 + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
1755 + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
1756 + "license": "MIT",
1757 + "funding": {
1758 + "url": "https://github.com/sponsors/ljharb"
1759 + }
1760 + },
1761 + "node_modules/get-intrinsic": {
1762 + "version": "1.3.0",
1763 + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
1764 + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
1765 + "license": "MIT",
1766 + "dependencies": {
1767 + "call-bind-apply-helpers": "^1.0.2",
1768 + "es-define-property": "^1.0.1",
1769 + "es-errors": "^1.3.0",
1770 + "es-object-atoms": "^1.1.1",
1771 + "function-bind": "^1.1.2",
1772 + "get-proto": "^1.0.1",
1773 + "gopd": "^1.2.0",
1774 + "has-symbols": "^1.1.0",
1775 + "hasown": "^2.0.2",
1776 + "math-intrinsics": "^1.1.0"
1777 + },
1778 + "engines": {
1779 + "node": ">= 0.4"
1780 + },
1781 + "funding": {
1782 + "url": "https://github.com/sponsors/ljharb"
1783 + }
1784 + },
1785 + "node_modules/get-proto": {
1786 + "version": "1.0.1",
1787 + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
1788 + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
1789 + "license": "MIT",
1790 + "dependencies": {
1791 + "dunder-proto": "^1.0.1",
1792 + "es-object-atoms": "^1.0.0"
1793 + },
1794 + "engines": {
1795 + "node": ">= 0.4"
1796 + }
1797 + },
1798 + "node_modules/gopd": {
1799 + "version": "1.2.0",
1800 + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
1801 + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
1802 + "license": "MIT",
1803 + "engines": {
1804 + "node": ">= 0.4"
1805 + },
1806 + "funding": {
1807 + "url": "https://github.com/sponsors/ljharb"
1808 + }
1809 + },
1810 + "node_modules/has-symbols": {
1811 + "version": "1.1.0",
1812 + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
1813 + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
1814 + "license": "MIT",
1815 + "engines": {
1816 + "node": ">= 0.4"
1817 + },
1818 + "funding": {
1819 + "url": "https://github.com/sponsors/ljharb"
1820 + }
1821 + },
1822 + "node_modules/has-tostringtag": {
1823 + "version": "1.0.2",
1824 + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
1825 + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
1826 + "dev": true,
1827 + "license": "MIT",
1828 + "dependencies": {
1829 + "has-symbols": "^1.0.3"
1830 + },
1831 + "engines": {
1832 + "node": ">= 0.4"
1833 + },
1834 + "funding": {
1835 + "url": "https://github.com/sponsors/ljharb"
1836 + }
1837 + },
1838 + "node_modules/hasown": {
1839 + "version": "2.0.4",
1840 + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
1841 + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
1842 + "license": "MIT",
1843 + "dependencies": {
1844 + "function-bind": "^1.1.2"
1845 + },
1846 + "engines": {
1847 + "node": ">= 0.4"
1848 + }
1849 + },
1850 + "node_modules/htmlparser2": {
1851 + "version": "12.0.0",
1852 + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-12.0.0.tgz",
1853 + "integrity": "sha512-Tz7u1i95/g2x2jz81+x0FBVhBhY5aRTvD3tXXdFaljuNdzDLJ8UGNRrTcj2cgQvAg3iW/h77Fz15nLW0L0CrZw==",
1854 + "funding": [
1855 + "https://github.com/fb55/htmlparser2?sponsor=1",
1856 + {
1857 + "type": "github",
1858 + "url": "https://github.com/sponsors/fb55"
1859 + }
1860 + ],
1861 + "license": "MIT",
1862 + "dependencies": {
1863 + "domelementtype": "^3.0.0",
1864 + "domhandler": "^6.0.0",
1865 + "domutils": "^4.0.2",
1866 + "entities": "^8.0.0"
1867 + },
1868 + "engines": {
1869 + "node": ">=20.19.0"
1870 + }
1871 + },
1872 + "node_modules/http-errors": {
1873 + "version": "2.0.1",
1874 + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
1875 + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
1876 + "license": "MIT",
1877 + "dependencies": {
1878 + "depd": "~2.0.0",
1879 + "inherits": "~2.0.4",
1880 + "setprototypeof": "~1.2.0",
1881 + "statuses": "~2.0.2",
1882 + "toidentifier": "~1.0.1"
1883 + },
1884 + "engines": {
1885 + "node": ">= 0.8"
1886 + },
1887 + "funding": {
1888 + "type": "opencollective",
1889 + "url": "https://opencollective.com/express"
1890 + }
1891 + },
1892 + "node_modules/iconv-lite": {
1893 + "version": "0.7.3",
1894 + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
1895 + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
1896 + "license": "MIT",
1897 + "dependencies": {
1898 + "safer-buffer": ">= 2.1.2 < 3.0.0"
1899 + },
1900 + "engines": {
1901 + "node": ">=0.10.0"
1902 + },
1903 + "funding": {
1904 + "type": "opencollective",
1905 + "url": "https://opencollective.com/express"
1906 + }
1907 + },
1908 + "node_modules/inherits": {
1909 + "version": "2.0.4",
1910 + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
1911 + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
1912 + "license": "ISC"
1913 + },
1914 + "node_modules/ipaddr.js": {
1915 + "version": "1.9.1",
1916 + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
1917 + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
1918 + "license": "MIT",
1919 + "engines": {
1920 + "node": ">= 0.10"
1921 + }
1922 + },
1923 + "node_modules/is-plain-object": {
1924 + "version": "5.0.0",
1925 + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
1926 + "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==",
1927 + "license": "MIT",
1928 + "engines": {
1929 + "node": ">=0.10.0"
1930 + }
1931 + },
1932 + "node_modules/is-promise": {
1933 + "version": "4.0.0",
1934 + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
1935 + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
1936 + "license": "MIT"
1937 + },
1938 + "node_modules/jake": {
1939 + "version": "10.9.4",
1940 + "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz",
1941 + "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==",
1942 + "license": "Apache-2.0",
1943 + "dependencies": {
1944 + "async": "^3.2.6",
1945 + "filelist": "^1.0.4",
1946 + "picocolors": "^1.1.1"
1947 + },
1948 + "bin": {
1949 + "jake": "bin/cli.js"
1950 + },
1951 + "engines": {
1952 + "node": ">=10"
1953 + }
1954 + },
1955 + "node_modules/js-tokens": {
1956 + "version": "9.0.1",
1957 + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
1958 + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
1959 + "dev": true,
1960 + "license": "MIT"
1961 + },
1962 + "node_modules/launder": {
1963 + "version": "1.7.1",
1964 + "resolved": "https://registry.npmjs.org/launder/-/launder-1.7.1.tgz",
1965 + "integrity": "sha512-mU6WRz5EusL9ZZuiZ5SO4Y6C0P9PAUR9iwdb6bzj4KDihm28DiHFw+/yk9DBH4f+Pv1wuzQ4e2jV3oQ7mkIqvw==",
1966 + "license": "MIT",
1967 + "dependencies": {
1968 + "dayjs": "^1.11.7"
1969 + }
1970 + },
1971 + "node_modules/loupe": {
1972 + "version": "3.2.1",
1973 + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
1974 + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
1975 + "dev": true,
1976 + "license": "MIT"
1977 + },
1978 + "node_modules/magic-string": {
1979 + "version": "0.30.21",
1980 + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
1981 + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
1982 + "dev": true,
1983 + "license": "MIT",
1984 + "dependencies": {
1985 + "@jridgewell/sourcemap-codec": "^1.5.5"
1986 + }
1987 + },
1988 + "node_modules/marked": {
1989 + "version": "16.4.2",
1990 + "resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz",
1991 + "integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==",
1992 + "license": "MIT",
1993 + "bin": {
1994 + "marked": "bin/marked.js"
1995 + },
1996 + "engines": {
1997 + "node": ">= 20"
1998 + }
1999 + },
2000 + "node_modules/math-intrinsics": {
2001 + "version": "1.1.0",
2002 + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
2003 + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
2004 + "license": "MIT",
2005 + "engines": {
2006 + "node": ">= 0.4"
2007 + }
2008 + },
2009 + "node_modules/media-typer": {
2010 + "version": "1.1.0",
2011 + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
2012 + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
2013 + "license": "MIT",
2014 + "engines": {
2015 + "node": ">= 0.8"
2016 + }
2017 + },
2018 + "node_modules/merge-descriptors": {
2019 + "version": "2.0.0",
2020 + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
2021 + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
2022 + "license": "MIT",
2023 + "engines": {
2024 + "node": ">=18"
2025 + },
2026 + "funding": {
2027 + "url": "https://github.com/sponsors/sindresorhus"
2028 + }
2029 + },
2030 + "node_modules/methods": {
2031 + "version": "1.1.2",
2032 + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
2033 + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
2034 + "dev": true,
2035 + "license": "MIT",
2036 + "engines": {
2037 + "node": ">= 0.6"
2038 + }
2039 + },
2040 + "node_modules/mime": {
2041 + "version": "2.6.0",
2042 + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz",
2043 + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==",
2044 + "dev": true,
2045 + "license": "MIT",
2046 + "bin": {
2047 + "mime": "cli.js"
2048 + },
2049 + "engines": {
2050 + "node": ">=4.0.0"
2051 + }
2052 + },
2053 + "node_modules/mime-db": {
2054 + "version": "1.54.0",
2055 + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
2056 + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
2057 + "license": "MIT",
2058 + "engines": {
2059 + "node": ">= 0.6"
2060 + }
2061 + },
2062 + "node_modules/mime-types": {
2063 + "version": "3.0.2",
2064 + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
2065 + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
2066 + "license": "MIT",
2067 + "dependencies": {
2068 + "mime-db": "^1.54.0"
2069 + },
2070 + "engines": {
2071 + "node": ">=18"
2072 + },
2073 + "funding": {
2074 + "type": "opencollective",
2075 + "url": "https://opencollective.com/express"
2076 + }
2077 + },
2078 + "node_modules/minimatch": {
2079 + "version": "5.1.9",
2080 + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
2081 + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
2082 + "license": "ISC",
2083 + "dependencies": {
2084 + "brace-expansion": "^2.0.1"
2085 + },
2086 + "engines": {
2087 + "node": ">=10"
2088 + }
2089 + },
2090 + "node_modules/ms": {
2091 + "version": "2.1.3",
2092 + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
2093 + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
2094 + "license": "MIT"
2095 + },
2096 + "node_modules/nanoid": {
2097 + "version": "3.3.16",
2098 + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
2099 + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
2100 + "funding": [
2101 + {
2102 + "type": "github",
2103 + "url": "https://github.com/sponsors/ai"
2104 + }
2105 + ],
2106 + "license": "MIT",
2107 + "bin": {
2108 + "nanoid": "bin/nanoid.cjs"
2109 + },
2110 + "engines": {
2111 + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
2112 + }
2113 + },
2114 + "node_modules/negotiator": {
2115 + "version": "1.0.0",
2116 + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
2117 + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
2118 + "license": "MIT",
2119 + "engines": {
2120 + "node": ">= 0.6"
2121 + }
2122 + },
2123 + "node_modules/object-inspect": {
2124 + "version": "1.13.4",
2125 + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
2126 + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
2127 + "license": "MIT",
2128 + "engines": {
2129 + "node": ">= 0.4"
2130 + },
2131 + "funding": {
2132 + "url": "https://github.com/sponsors/ljharb"
2133 + }
2134 + },
2135 + "node_modules/on-finished": {
2136 + "version": "2.4.1",
2137 + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
2138 + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
2139 + "license": "MIT",
2140 + "dependencies": {
2141 + "ee-first": "1.1.1"
2142 + },
2143 + "engines": {
2144 + "node": ">= 0.8"
2145 + }
2146 + },
2147 + "node_modules/once": {
2148 + "version": "1.4.0",
2149 + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
2150 + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
2151 + "license": "ISC",
2152 + "dependencies": {
2153 + "wrappy": "1"
2154 + }
2155 + },
2156 + "node_modules/parse-srcset": {
2157 + "version": "1.0.2",
2158 + "resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz",
2159 + "integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==",
2160 + "license": "MIT"
2161 + },
2162 + "node_modules/parseurl": {
2163 + "version": "1.3.3",
2164 + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
2165 + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
2166 + "license": "MIT",
2167 + "engines": {
2168 + "node": ">= 0.8"
2169 + }
2170 + },
2171 + "node_modules/path-to-regexp": {
2172 + "version": "8.4.2",
2173 + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
2174 + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
2175 + "license": "MIT",
2176 + "funding": {
2177 + "type": "opencollective",
2178 + "url": "https://opencollective.com/express"
2179 + }
2180 + },
2181 + "node_modules/pathe": {
2182 + "version": "2.0.3",
2183 + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
2184 + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
2185 + "dev": true,
2186 + "license": "MIT"
2187 + },
2188 + "node_modules/pathval": {
2189 + "version": "2.0.1",
2190 + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz",
2191 + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==",
2192 + "dev": true,
2193 + "license": "MIT",
2194 + "engines": {
2195 + "node": ">= 14.16"
2196 + }
2197 + },
2198 + "node_modules/picocolors": {
2199 + "version": "1.1.1",
2200 + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
2201 + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
2202 + "license": "ISC"
2203 + },
2204 + "node_modules/picomatch": {
2205 + "version": "4.0.5",
2206 + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
2207 + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
2208 + "dev": true,
2209 + "license": "MIT",
2210 + "engines": {
2211 + "node": ">=12"
2212 + },
2213 + "funding": {
2214 + "url": "https://github.com/sponsors/jonschlinkert"
2215 + }
2216 + },
2217 + "node_modules/postcss": {
2218 + "version": "8.5.22",
2219 + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.22.tgz",
2220 + "integrity": "sha512-KBDEIpLrvpv16pp3K0Fw+UCoZfopFjjgeB+0tA/aaThfEE74kKDLrgg603YvOWJyg3+WYtyq3xYsQWsIyZlPqQ==",
2221 + "funding": [
2222 + {
2223 + "type": "opencollective",
2224 + "url": "https://opencollective.com/postcss/"
2225 + },
2226 + {
2227 + "type": "tidelift",
2228 + "url": "https://tidelift.com/funding/github/npm/postcss"
2229 + },
2230 + {
2231 + "type": "github",
2232 + "url": "https://github.com/sponsors/ai"
2233 + }
2234 + ],
2235 + "license": "MIT",
2236 + "dependencies": {
2237 + "nanoid": "^3.3.16",
2238 + "picocolors": "^1.1.1",
2239 + "source-map-js": "^1.2.1"
2240 + },
2241 + "engines": {
2242 + "node": "^10 || ^12 || >=14"
2243 + }
2244 + },
2245 + "node_modules/proxy-addr": {
2246 + "version": "2.0.7",
2247 + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
2248 + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
2249 + "license": "MIT",
2250 + "dependencies": {
2251 + "forwarded": "0.2.0",
2252 + "ipaddr.js": "1.9.1"
2253 + },
2254 + "engines": {
2255 + "node": ">= 0.10"
2256 + }
2257 + },
2258 + "node_modules/qs": {
2259 + "version": "6.15.3",
2260 + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
2261 + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
2262 + "license": "BSD-3-Clause",
2263 + "dependencies": {
2264 + "es-define-property": "^1.0.1",
2265 + "side-channel": "^1.1.1"
2266 + },
2267 + "engines": {
2268 + "node": ">=0.6"
2269 + },
2270 + "funding": {
2271 + "url": "https://github.com/sponsors/ljharb"
2272 + }
2273 + },
2274 + "node_modules/range-parser": {
2275 + "version": "1.3.0",
2276 + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz",
2277 + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==",
2278 + "license": "MIT",
2279 + "engines": {
2280 + "node": ">= 0.6"
2281 + },
2282 + "funding": {
2283 + "type": "opencollective",
2284 + "url": "https://opencollective.com/express"
2285 + }
2286 + },
2287 + "node_modules/raw-body": {
2288 + "version": "3.0.2",
2289 + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
2290 + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
2291 + "license": "MIT",
2292 + "dependencies": {
2293 + "bytes": "~3.1.2",
2294 + "http-errors": "~2.0.1",
2295 + "iconv-lite": "~0.7.0",
2296 + "unpipe": "~1.0.0"
2297 + },
2298 + "engines": {
2299 + "node": ">= 0.10"
2300 + }
2301 + },
2302 + "node_modules/rollup": {
2303 + "version": "4.62.2",
2304 + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
2305 + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==",
2306 + "dev": true,
2307 + "license": "MIT",
2308 + "dependencies": {
2309 + "@types/estree": "1.0.9"
2310 + },
2311 + "bin": {
2312 + "rollup": "dist/bin/rollup"
2313 + },
2314 + "engines": {
2315 + "node": ">=18.0.0",
2316 + "npm": ">=8.0.0"
2317 + },
2318 + "optionalDependencies": {
2319 + "@rollup/rollup-android-arm-eabi": "4.62.2",
2320 + "@rollup/rollup-android-arm64": "4.62.2",
2321 + "@rollup/rollup-darwin-arm64": "4.62.2",
2322 + "@rollup/rollup-darwin-x64": "4.62.2",
2323 + "@rollup/rollup-freebsd-arm64": "4.62.2",
2324 + "@rollup/rollup-freebsd-x64": "4.62.2",
2325 + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2",
2326 + "@rollup/rollup-linux-arm-musleabihf": "4.62.2",
2327 + "@rollup/rollup-linux-arm64-gnu": "4.62.2",
2328 + "@rollup/rollup-linux-arm64-musl": "4.62.2",
2329 + "@rollup/rollup-linux-loong64-gnu": "4.62.2",
2330 + "@rollup/rollup-linux-loong64-musl": "4.62.2",
2331 + "@rollup/rollup-linux-ppc64-gnu": "4.62.2",
2332 + "@rollup/rollup-linux-ppc64-musl": "4.62.2",
2333 + "@rollup/rollup-linux-riscv64-gnu": "4.62.2",
2334 + "@rollup/rollup-linux-riscv64-musl": "4.62.2",
2335 + "@rollup/rollup-linux-s390x-gnu": "4.62.2",
2336 + "@rollup/rollup-linux-x64-gnu": "4.62.2",
2337 + "@rollup/rollup-linux-x64-musl": "4.62.2",
2338 + "@rollup/rollup-openbsd-x64": "4.62.2",
2339 + "@rollup/rollup-openharmony-arm64": "4.62.2",
2340 + "@rollup/rollup-win32-arm64-msvc": "4.62.2",
2341 + "@rollup/rollup-win32-ia32-msvc": "4.62.2",
2342 + "@rollup/rollup-win32-x64-gnu": "4.62.2",
2343 + "@rollup/rollup-win32-x64-msvc": "4.62.2",
2344 + "fsevents": "~2.3.2"
2345 + }
2346 + },
2347 + "node_modules/router": {
2348 + "version": "2.2.0",
2349 + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
2350 + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
2351 + "license": "MIT",
2352 + "dependencies": {
2353 + "debug": "^4.4.0",
2354 + "depd": "^2.0.0",
2355 + "is-promise": "^4.0.0",
2356 + "parseurl": "^1.3.3",
2357 + "path-to-regexp": "^8.0.0"
2358 + },
2359 + "engines": {
2360 + "node": ">= 18"
2361 + }
2362 + },
2363 + "node_modules/safer-buffer": {
2364 + "version": "2.1.2",
2365 + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
2366 + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
2367 + "license": "MIT"
2368 + },
2369 + "node_modules/sanitize-html": {
2370 + "version": "2.17.6",
2371 + "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.6.tgz",
2372 + "integrity": "sha512-M4bo9tfv1yfhQZZKkc6dL07ALrGJtfvNOuhX3hU9AVPR/uPQ+nKOJBqTYc7LfMQblTW04mtSWDJWEyLvygJsLA==",
2373 + "license": "MIT",
2374 + "dependencies": {
2375 + "deepmerge": "^4.2.2",
2376 + "escape-string-regexp": "^4.0.0",
2377 + "htmlparser2": "^12.0.0",
2378 + "is-plain-object": "^5.0.0",
2379 + "launder": "^1.7.1",
2380 + "parse-srcset": "^1.0.2",
2381 + "postcss": "^8.3.11"
2382 + },
2383 + "engines": {
2384 + "node": ">=22.12.0"
2385 + }
2386 + },
2387 + "node_modules/send": {
2388 + "version": "1.2.1",
2389 + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
2390 + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
2391 + "license": "MIT",
2392 + "dependencies": {
2393 + "debug": "^4.4.3",
2394 + "encodeurl": "^2.0.0",
2395 + "escape-html": "^1.0.3",
2396 + "etag": "^1.8.1",
2397 + "fresh": "^2.0.0",
2398 + "http-errors": "^2.0.1",
2399 + "mime-types": "^3.0.2",
2400 + "ms": "^2.1.3",
2401 + "on-finished": "^2.4.1",
2402 + "range-parser": "^1.2.1",
2403 + "statuses": "^2.0.2"
2404 + },
2405 + "engines": {
2406 + "node": ">= 18"
2407 + },
2408 + "funding": {
2409 + "type": "opencollective",
2410 + "url": "https://opencollective.com/express"
2411 + }
2412 + },
2413 + "node_modules/serve-static": {
2414 + "version": "2.2.1",
2415 + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
2416 + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
2417 + "license": "MIT",
2418 + "dependencies": {
2419 + "encodeurl": "^2.0.0",
2420 + "escape-html": "^1.0.3",
2421 + "parseurl": "^1.3.3",
2422 + "send": "^1.2.0"
2423 + },
2424 + "engines": {
2425 + "node": ">= 18"
2426 + },
2427 + "funding": {
2428 + "type": "opencollective",
2429 + "url": "https://opencollective.com/express"
2430 + }
2431 + },
2432 + "node_modules/setprototypeof": {
2433 + "version": "1.2.0",
2434 + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
2435 + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
2436 + "license": "ISC"
2437 + },
2438 + "node_modules/side-channel": {
2439 + "version": "1.1.1",
2440 + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
2441 + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
2442 + "license": "MIT",
2443 + "dependencies": {
2444 + "es-errors": "^1.3.0",
2445 + "object-inspect": "^1.13.4",
2446 + "side-channel-list": "^1.0.1",
2447 + "side-channel-map": "^1.0.1",
2448 + "side-channel-weakmap": "^1.0.2"
2449 + },
2450 + "engines": {
2451 + "node": ">= 0.4"
2452 + },
2453 + "funding": {
2454 + "url": "https://github.com/sponsors/ljharb"
2455 + }
2456 + },
2457 + "node_modules/side-channel-list": {
2458 + "version": "1.0.1",
2459 + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
2460 + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
2461 + "license": "MIT",
2462 + "dependencies": {
2463 + "es-errors": "^1.3.0",
2464 + "object-inspect": "^1.13.4"
2465 + },
2466 + "engines": {
2467 + "node": ">= 0.4"
2468 + },
2469 + "funding": {
2470 + "url": "https://github.com/sponsors/ljharb"
2471 + }
2472 + },
2473 + "node_modules/side-channel-map": {
2474 + "version": "1.0.1",
2475 + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
2476 + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
2477 + "license": "MIT",
2478 + "dependencies": {
2479 + "call-bound": "^1.0.2",
2480 + "es-errors": "^1.3.0",
2481 + "get-intrinsic": "^1.2.5",
2482 + "object-inspect": "^1.13.3"
2483 + },
2484 + "engines": {
2485 + "node": ">= 0.4"
2486 + },
2487 + "funding": {
2488 + "url": "https://github.com/sponsors/ljharb"
2489 + }
2490 + },
2491 + "node_modules/side-channel-weakmap": {
2492 + "version": "1.0.2",
2493 + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
2494 + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
2495 + "license": "MIT",
2496 + "dependencies": {
2497 + "call-bound": "^1.0.2",
2498 + "es-errors": "^1.3.0",
2499 + "get-intrinsic": "^1.2.5",
2500 + "object-inspect": "^1.13.3",
2501 + "side-channel-map": "^1.0.1"
2502 + },
2503 + "engines": {
2504 + "node": ">= 0.4"
2505 + },
2506 + "funding": {
2507 + "url": "https://github.com/sponsors/ljharb"
2508 + }
2509 + },
2510 + "node_modules/siginfo": {
2511 + "version": "2.0.0",
2512 + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
2513 + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
2514 + "dev": true,
2515 + "license": "ISC"
2516 + },
2517 + "node_modules/source-map-js": {
2518 + "version": "1.2.1",
2519 + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
2520 + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
2521 + "license": "BSD-3-Clause",
2522 + "engines": {
2523 + "node": ">=0.10.0"
2524 + }
2525 + },
2526 + "node_modules/stackback": {
2527 + "version": "0.0.2",
2528 + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
2529 + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
2530 + "dev": true,
2531 + "license": "MIT"
2532 + },
2533 + "node_modules/statuses": {
2534 + "version": "2.0.2",
2535 + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
2536 + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
2537 + "license": "MIT",
2538 + "engines": {
2539 + "node": ">= 0.8"
2540 + }
2541 + },
2542 + "node_modules/std-env": {
2543 + "version": "3.10.0",
2544 + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
2545 + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
2546 + "dev": true,
2547 + "license": "MIT"
2548 + },
2549 + "node_modules/strip-literal": {
2550 + "version": "3.1.0",
2551 + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz",
2552 + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==",
2553 + "dev": true,
2554 + "license": "MIT",
2555 + "dependencies": {
2556 + "js-tokens": "^9.0.1"
2557 + },
2558 + "funding": {
2559 + "url": "https://github.com/sponsors/antfu"
2560 + }
2561 + },
2562 + "node_modules/superagent": {
2563 + "version": "10.3.0",
2564 + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz",
2565 + "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==",
2566 + "dev": true,
2567 + "license": "MIT",
2568 + "dependencies": {
2569 + "component-emitter": "^1.3.1",
2570 + "cookiejar": "^2.1.4",
2571 + "debug": "^4.3.7",
2572 + "fast-safe-stringify": "^2.1.1",
2573 + "form-data": "^4.0.5",
2574 + "formidable": "^3.5.4",
2575 + "methods": "^1.1.2",
2576 + "mime": "2.6.0",
2577 + "qs": "^6.14.1"
2578 + },
2579 + "engines": {
2580 + "node": ">=14.18.0"
2581 + }
2582 + },
2583 + "node_modules/supertest": {
2584 + "version": "7.2.2",
2585 + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz",
2586 + "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==",
2587 + "dev": true,
2588 + "license": "MIT",
2589 + "dependencies": {
2590 + "cookie-signature": "^1.2.2",
2591 + "methods": "^1.1.2",
2592 + "superagent": "^10.3.0"
2593 + },
2594 + "engines": {
2595 + "node": ">=14.18.0"
2596 + }
2597 + },
2598 + "node_modules/tinybench": {
2599 + "version": "2.9.0",
2600 + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
2601 + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
2602 + "dev": true,
2603 + "license": "MIT"
2604 + },
2605 + "node_modules/tinyexec": {
2606 + "version": "0.3.2",
2607 + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
2608 + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
2609 + "dev": true,
2610 + "license": "MIT"
2611 + },
2612 + "node_modules/tinyglobby": {
2613 + "version": "0.2.17",
2614 + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
2615 + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
2616 + "dev": true,
2617 + "license": "MIT",
2618 + "dependencies": {
2619 + "fdir": "^6.5.0",
2620 + "picomatch": "^4.0.4"
2621 + },
2622 + "engines": {
2623 + "node": ">=12.0.0"
2624 + },
2625 + "funding": {
2626 + "url": "https://github.com/sponsors/SuperchupuDev"
2627 + }
2628 + },
2629 + "node_modules/tinypool": {
2630 + "version": "1.1.1",
2631 + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
2632 + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==",
2633 + "dev": true,
2634 + "license": "MIT",
2635 + "engines": {
2636 + "node": "^18.0.0 || >=20.0.0"
2637 + }
2638 + },
2639 + "node_modules/tinyrainbow": {
2640 + "version": "2.0.0",
2641 + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz",
2642 + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==",
2643 + "dev": true,
2644 + "license": "MIT",
2645 + "engines": {
2646 + "node": ">=14.0.0"
2647 + }
2648 + },
2649 + "node_modules/tinyspy": {
2650 + "version": "4.0.4",
2651 + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz",
2652 + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==",
2653 + "dev": true,
2654 + "license": "MIT",
2655 + "engines": {
2656 + "node": ">=14.0.0"
2657 + }
2658 + },
2659 + "node_modules/toidentifier": {
2660 + "version": "1.0.1",
2661 + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
2662 + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
2663 + "license": "MIT",
2664 + "engines": {
2665 + "node": ">=0.6"
2666 + }
2667 + },
2668 + "node_modules/type-is": {
2669 + "version": "2.1.0",
2670 + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
2671 + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
2672 + "license": "MIT",
2673 + "dependencies": {
2674 + "content-type": "^2.0.0",
2675 + "media-typer": "^1.1.0",
2676 + "mime-types": "^3.0.0"
2677 + },
2678 + "engines": {
2679 + "node": ">= 18"
2680 + },
2681 + "funding": {
2682 + "type": "opencollective",
2683 + "url": "https://opencollective.com/express"
2684 + }
2685 + },
2686 + "node_modules/type-is/node_modules/content-type": {
2687 + "version": "2.0.0",
2688 + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
2689 + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
2690 + "license": "MIT",
2691 + "engines": {
2692 + "node": ">=18"
2693 + },
2694 + "funding": {
2695 + "type": "opencollective",
2696 + "url": "https://opencollective.com/express"
2697 + }
2698 + },
2699 + "node_modules/unpipe": {
2700 + "version": "1.0.0",
2701 + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
2702 + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
2703 + "license": "MIT",
2704 + "engines": {
2705 + "node": ">= 0.8"
2706 + }
2707 + },
2708 + "node_modules/vary": {
2709 + "version": "1.1.2",
2710 + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
2711 + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
2712 + "license": "MIT",
2713 + "engines": {
2714 + "node": ">= 0.8"
2715 + }
2716 + },
2717 + "node_modules/vite": {
2718 + "version": "7.3.6",
2719 + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz",
2720 + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==",
2721 + "dev": true,
2722 + "license": "MIT",
2723 + "dependencies": {
2724 + "esbuild": "^0.27.0 || ^0.28.0",
2725 + "fdir": "^6.5.0",
2726 + "picomatch": "^4.0.3",
2727 + "postcss": "^8.5.6",
2728 + "rollup": "^4.43.0",
2729 + "tinyglobby": "^0.2.15"
2730 + },
2731 + "bin": {
2732 + "vite": "bin/vite.js"
2733 + },
2734 + "engines": {
2735 + "node": "^20.19.0 || >=22.12.0"
2736 + },
2737 + "funding": {
2738 + "url": "https://github.com/vitejs/vite?sponsor=1"
2739 + },
2740 + "optionalDependencies": {
2741 + "fsevents": "~2.3.3"
2742 + },
2743 + "peerDependencies": {
2744 + "@types/node": "^20.19.0 || >=22.12.0",
2745 + "jiti": ">=1.21.0",
2746 + "less": "^4.0.0",
2747 + "lightningcss": "^1.21.0",
2748 + "sass": "^1.70.0",
2749 + "sass-embedded": "^1.70.0",
2750 + "stylus": ">=0.54.8",
2751 + "sugarss": "^5.0.0",
2752 + "terser": "^5.16.0",
2753 + "tsx": "^4.8.1",
2754 + "yaml": "^2.4.2"
2755 + },
2756 + "peerDependenciesMeta": {
2757 + "@types/node": {
2758 + "optional": true
2759 + },
2760 + "jiti": {
2761 + "optional": true
2762 + },
2763 + "less": {
2764 + "optional": true
2765 + },
2766 + "lightningcss": {
2767 + "optional": true
2768 + },
2769 + "sass": {
2770 + "optional": true
2771 + },
2772 + "sass-embedded": {
2773 + "optional": true
2774 + },
2775 + "stylus": {
2776 + "optional": true
2777 + },
2778 + "sugarss": {
2779 + "optional": true
2780 + },
2781 + "terser": {
2782 + "optional": true
2783 + },
2784 + "tsx": {
2785 + "optional": true
2786 + },
2787 + "yaml": {
2788 + "optional": true
2789 + }
2790 + }
2791 + },
2792 + "node_modules/vite-node": {
2793 + "version": "3.2.4",
2794 + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz",
2795 + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==",
2796 + "dev": true,
2797 + "license": "MIT",
2798 + "dependencies": {
2799 + "cac": "^6.7.14",
2800 + "debug": "^4.4.1",
2801 + "es-module-lexer": "^1.7.0",
2802 + "pathe": "^2.0.3",
2803 + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
2804 + },
2805 + "bin": {
2806 + "vite-node": "vite-node.mjs"
2807 + },
2808 + "engines": {
2809 + "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
2810 + },
2811 + "funding": {
2812 + "url": "https://opencollective.com/vitest"
2813 + }
2814 + },
2815 + "node_modules/vitest": {
2816 + "version": "3.2.7",
2817 + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz",
2818 + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==",
2819 + "dev": true,
2820 + "license": "MIT",
2821 + "dependencies": {
2822 + "@types/chai": "^5.2.2",
2823 + "@vitest/expect": "3.2.7",
2824 + "@vitest/mocker": "3.2.7",
2825 + "@vitest/pretty-format": "^3.2.7",
2826 + "@vitest/runner": "3.2.7",
2827 + "@vitest/snapshot": "3.2.7",
2828 + "@vitest/spy": "3.2.7",
2829 + "@vitest/utils": "3.2.7",
2830 + "chai": "^5.2.0",
2831 + "debug": "^4.4.1",
2832 + "expect-type": "^1.2.1",
2833 + "magic-string": "^0.30.17",
2834 + "pathe": "^2.0.3",
2835 + "picomatch": "^4.0.2",
2836 + "std-env": "^3.9.0",
2837 + "tinybench": "^2.9.0",
2838 + "tinyexec": "^0.3.2",
2839 + "tinyglobby": "^0.2.14",
2840 + "tinypool": "^1.1.1",
2841 + "tinyrainbow": "^2.0.0",
2842 + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0",
2843 + "vite-node": "3.2.4",
2844 + "why-is-node-running": "^2.3.0"
2845 + },
2846 + "bin": {
2847 + "vitest": "vitest.mjs"
2848 + },
2849 + "engines": {
2850 + "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
2851 + },
2852 + "funding": {
2853 + "url": "https://opencollective.com/vitest"
2854 + },
2855 + "peerDependencies": {
2856 + "@edge-runtime/vm": "*",
2857 + "@types/debug": "^4.1.12",
2858 + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
2859 + "@vitest/browser": "3.2.7",
2860 + "@vitest/ui": "3.2.7",
2861 + "happy-dom": "*",
2862 + "jsdom": "*"
2863 + },
2864 + "peerDependenciesMeta": {
2865 + "@edge-runtime/vm": {
2866 + "optional": true
2867 + },
2868 + "@types/debug": {
2869 + "optional": true
2870 + },
2871 + "@types/node": {
2872 + "optional": true
2873 + },
2874 + "@vitest/browser": {
2875 + "optional": true
2876 + },
2877 + "@vitest/ui": {
2878 + "optional": true
2879 + },
2880 + "happy-dom": {
2881 + "optional": true
2882 + },
2883 + "jsdom": {
2884 + "optional": true
2885 + }
2886 + }
2887 + },
2888 + "node_modules/why-is-node-running": {
2889 + "version": "2.3.0",
2890 + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
2891 + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
2892 + "dev": true,
2893 + "license": "MIT",
2894 + "dependencies": {
2895 + "siginfo": "^2.0.0",
2896 + "stackback": "0.0.2"
2897 + },
2898 + "bin": {
2899 + "why-is-node-running": "cli.js"
2900 + },
2901 + "engines": {
2902 + "node": ">=8"
2903 + }
2904 + },
2905 + "node_modules/wrappy": {
2906 + "version": "1.0.2",
2907 + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
2908 + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
2909 + "license": "ISC"
2910 + }
2911 + }
2912 +}
added package.json +25 −0
@@ -0,0 +1,25 @@
1 +{
2 + "name": "profileshare",
3 + "version": "1.0.0",
4 + "private": true,
5 + "type": "module",
6 + "engines": {
7 + "node": ">=24"
8 + },
9 + "scripts": {
10 + "dev": "node --env-file-if-exists=.env --watch src/server.js",
11 + "start": "node --env-file-if-exists=.env src/server.js",
12 + "test": "vitest run",
13 + "test:watch": "vitest"
14 + },
15 + "dependencies": {
16 + "ejs": "^3.1.10",
17 + "express": "^5.1.0",
18 + "marked": "^16.1.2",
19 + "sanitize-html": "^2.17.0"
20 + },
21 + "devDependencies": {
22 + "supertest": "^7.1.1",
23 + "vitest": "^3.2.4"
24 + }
25 +}
added spec/PLAN.md +100 −0
@@ -0,0 +1,100 @@
1 +# PLAN.md — profileShare
2 +
3 +## Overview
4 +
5 +profileShare is a tool for sharing a read-only overview of your GitHub work — your profile and selected repositories — through a simple, time-limited link. The goal is to let people (mainly potential employers) view chosen private repos and profile information without making those repos fully public and without adding viewers as collaborators. [S1][S5][S13]
6 +
7 +## Core Concept
8 +
9 +- A GitHub-like read-only view, shareable by URL. [S1][S11]
10 +- Lets the owner share **selected** repositories plus profile and activity, not everything. [S3][S9][S13]
11 +- Viewers do **not** need a GitHub account of their own. [S5]
12 +- Each link is a **snapshot** taken at creation time, not a live view that updates. [S14][S15]
13 +- Links expire after a customizable time period (e.g. 7 days). [S9][S20]
14 +
15 +## Users
16 +
17 +- **Owner / creator:** initially the developer themselves; possibly opened up to other sign-ups later (undecided). [S6][S7]
18 +- **Viewers:** mainly people who might hire the owner; they only view, they do not sign in. [S5]
19 +
20 +## Data Source & Permissions
21 +
22 +- Content is pulled from the owner's **GitHub** account. [S16][S17]
23 +- Owner authorizes access via GitHub login/approval. [S18]
24 +- Access should be limited to **only the repositories the owner chooses to share**, not all repositories. [S19]
25 +
26 +## Owner Flow
27 +
28 +1. Owner logs in and connects GitHub. [S17][S18]
29 +2. Owner sees their repos and clicks to select which ones to share. [S9][S37]
30 +3. Owner sets an expiration period (customizable, e.g. 7 days). [S9]
31 +4. The tool generates a profile page and a simple shareable URL. [S9]
32 +5. Owner sends the link to whomever they choose. [S9]
33 +
34 +## Viewer Flow
35 +
36 +1. Viewer opens the link and lands on the **main profile page**. [S39]
37 +2. From the profile, they click into a shared **repository**. [S38][S39]
38 +3. Inside a repository they can browse **files** and view **commit history**. [S11][S40]
39 +4. Files open on screen to read the code; nested folders can be opened. [S44][S45]
40 +5. A **button at the top** returns them to the profile page. [S47]
41 +
42 +## Page Content
43 +
44 +Main profile page should include standard GitHub/GitLab-style profile information (e.g. name, photo, bio) and the list of selected projects, plus profile activity. [S3][S12][S13]
45 +
46 +Per-repository view should include:
47 +- List of files (browsable, including nested folders). [S44][S45]
48 +- Code viewable on screen. [S44]
49 +- Commit history — a list of what changed and when. [S11][S42]
50 +- Commits should be clickable to see the changed lines **if feasible**. [S43]
51 +
52 +## Expiration Behavior
53 +
54 +- When a link is opened after it has expired, show a simple message: **"the URL you have opened is expired."** [S20][S21]
55 +
56 +## Access / Security Model
57 +
58 +- Link-based access only; no per-viewer identity checks. [S22][S23]
59 +- If a link is forwarded, that is acceptable — the point is only to avoid making repos public to the whole world, not to lock down to specific individuals. [S23]
60 +
61 +## Explicitly Out of Scope
62 +
63 +- No comments. [S30][S31]
64 +- No downloading of code. [S30][S31]
65 +- No editing by viewers. [S32][S33]
66 +- Not a full clone of everything GitHub does. [S32][S33]
67 +- Not team-oriented — a single-person overview of code. [S32][S33]
68 +
69 +## Platform
70 +
71 +- Primarily intended to be viewed on a **computer/desktop**. [S28][S29]
72 +
73 +## Verification (Owner's Acceptance Check)
74 +
75 +- Owner opens the generated link themselves to review it. [S34][S35]
76 +- It is "right" if it shows **all the projects the owner selected** and **all the commits**. [S36][S37]
77 +
78 +## Open / Undecided Questions
79 +
80 +- Whether to support other users signing up to share their own profiles. [S7]
81 +- Whether to delete the stored snapshot immediately on expiration or retain it afterward — owner deferred to "best solution." [S26][S27]
82 +- Size/quantity/performance limits — none specified; should "just work normally." [S24][S25]
83 +- Detailed in-project navigation (back-out of files, stepping up through nested folders, jump to project root) left to the implementer. [S40][S41][S48][S49]
84 +
85 +## Decisions Already Made
86 +
87 +| Decision | Choice | Source |
88 +|---|---|---|
89 +| Sharing mechanism | Simple shareable URL | [S9] |
90 +| Content freshness | Snapshot at creation, not live | [S14][S15] |
91 +| Link lifetime | Time-limited, customizable (e.g. 7 days) | [S9] |
92 +| Expired link | Show "expired" message | [S20][S21] |
93 +| Source of data | GitHub | [S16][S17] |
94 +| Repo access scope | Only selected repos | [S19] |
95 +| Viewer accounts | Not required | [S5] |
96 +| Access control | Link-based only, forwarding tolerated | [S22][S23] |
97 +| Viewer capabilities | Read-only overview; no comments/download/edit | [S30][S31][S32][S33] |
98 +| Target platform | Desktop/computer | [S28][S29] |
99 +| Navigation | Profile → repo → files/commits; top button back to profile | [S38][S39][S47] |
100 +| Commit detail | Clickable diffs if feasible | [S43] |
No newline at end of file
added spec/SPEC.md +57 −0
@@ -0,0 +1,57 @@
1 +# SPEC.md — profileShare
2 +
3 +## Goal
4 +
5 +profileShare is a tool for privately sharing selected GitHub repositories and profile information via a shareable link, without adding viewers as collaborators [S1]. The primary aim is to give people an overview of the developer's work without making the underlying GitHub repositories fully public to the whole world [S13][S23][S31][S33]. Access via the link is time-limited [S1][S9].
6 +
7 +## Users
8 +
9 +- **Link creators:** Initially the developer sharing their own work; possibly extendable to other people who want to share their own profiles (undecided) [S6][S7].
10 +- **Viewers:** Mainly people who might hire the developer; they do not need a GitHub account of their own to view the work [S4][S5].
11 +
12 +## User stories
13 +
14 +- As a creator, I want to select specific repositories from my account and generate a simple shareable URL, so I can send it to people without making everything public [S8][S9].
15 +- As a creator, I want the link to expire after a customizable time period (e.g. seven days), so people cannot view it indefinitely [S1][S9].
16 +- As a viewer, I want to open the link and see the creator's profile and selected projects, including code and commit history, so I can get an overview of their work [S3][S11].
17 +- As a creator, I want to verify a link myself before sharing it with a hiring person, so I can confirm the right projects and commits appear [S34][S35][S37].
18 +
19 +## Functional requirements
20 +
21 +- FR-001: The system shall allow a creator to view their GitHub repositories and select which ones to share [S9][S17].
22 +- FR-002: The system shall generate a shareable profile page and a simple URL for the selected repositories and profile information [S9].
23 +- FR-003: The shared link shall have a time period after which it expires, with a customizable duration (e.g. seven days) [S1][S9].
24 +- FR-004: The shared page shall present a snapshot of the code and profile taken at the moment the link is created, rather than always reflecting the latest changes [S14][S15].
25 +- FR-005: The system shall pull the creator's projects and code from their GitHub account [S16][S17].
26 +- FR-006: The system shall request access only to the repositories the creator chooses to share, not all repositories [S18][S19].
27 +- FR-007: When a link is opened after it has expired, the viewer shall be shown a message that the URL is expired [S20][S21].
28 +- FR-008: The shared page shall display the basic profile and repository information found on GitHub/GitLab, including projects, code, and commit history [S11][S12][S13].
29 +- FR-009: The main page shall show the creator's profile, from which the viewer can click into the shared repositories [S38][S39].
30 +- FR-010: The viewer shall be able to open a project and browse its files, including navigating into nested folders, and click a file to read its code on screen [S44][S45].
31 +- FR-011: The viewer shall be able to see the commit history within a project as a list of changes and dates [S37][S42].
32 +- FR-012: Commits should be clickable to show which lines of code changed, if feasible [S43].
33 +- FR-013: A button at the top of the page shall let the viewer return to the main profile page [S47].
34 +- FR-014: Anyone who has the link shall be able to open it while it is valid; no restriction on forwarding is required [S22][S23].
35 +
36 +## Edge cases
37 +
38 +- A viewer opening the link after expiry sees a simple "the URL that you have opened is expired" message [S20][S21].
39 +- A link forwarded to an unintended person will still open while valid; this is acceptable because the code contains no secrets and limited exposure is fine [S22][S23].
40 +
41 +## Out of scope
42 +
43 +- Making repositories fully public to the whole world [S13][S31].
44 +- Comments from viewers [S30][S31][S32].
45 +- Downloading the code [S30][S31].
46 +- Viewers editing anything [S32].
47 +- Being a full copy of everything GitHub does [S32][S33].
48 +- Team-oriented use rather than a single person's overview [S32][S33].
49 +- Mobile/phone optimization; intended for viewing on a computer [S28][S29].
50 +
51 +## Assumptions
52 +
53 +- Creators authenticate with and grant access to their GitHub account to import selected projects [S16][S18].
54 +- The tool need only "work normally"; no specific limits were defined for project size, number of concurrent links, or page load speed [S24][S25].
55 +- The retention behavior of the stored snapshot after expiry is left to the implementer to choose the best solution [S26][S27].
56 +- Detailed in-project navigation layout (e.g. file list vs. commit list arrangement, stepping up folder levels) is left to the implementer [S40][S41][S48].
57 +- Whether the tool supports only the developer or multiple sign-up users is undecided [S6][S7].
No newline at end of file
added spec/TASKS.md +80 −0
@@ -0,0 +1,80 @@
1 +# TASKS.md — profileShare
2 +
3 +A tool to share selected private GitHub repositories and profile info via a time-limited, read-only public link, without adding viewers as collaborators. [S1][S13]
4 +
5 +---
6 +
7 +- [ ] **1. Project setup and test harness**
8 + Initialize the project repository, dependency management, and a test runner so every later task can be verified.
9 + Depends: none
10 + Verify: `npm install && npm test`
11 +
12 +- [ ] **2. GitHub OAuth login for the owner**
13 + Let the owner log in with their GitHub account and approve access. Access should be limited to only the repositories the owner points to, not all repositories. [S16][S17][S18][S19]
14 + Depends: 1
15 + Verify: `npm test -- auth`
16 +
17 +- [ ] **3. List the owner's repositories for selection**
18 + After login, show all of the owner's repositories and let them click to select which ones to share. [S9][S19]
19 + Depends: 2
20 + Verify: `npm test -- repo-selection`
21 +
22 +- [ ] **4. Snapshot selected repositories at link-creation time**
23 + When a link is created, capture a frozen snapshot of the selected repositories (code, commit history, commit dates) rather than tracking live changes. [S14][S15]
24 + Depends: 3
25 + Verify: `npm test -- snapshot`
26 +
27 +- [ ] **5. Generate a shareable link with an expiry period**
28 + Generate a simple URL for the snapshot. Allow the owner to set a time period (e.g. seven days), which is customizable. [S9][S13]
29 + Depends: 4
30 + Verify: `npm test -- link-generation`
31 +
32 +- [ ] **6. Expiry enforcement and expired-link message**
33 + When a link is opened after its time period ends, show a simple message that the URL has expired. Any holder of a valid link can open it (no per-viewer restriction). [S20][S21][S22][S23]
34 + Depends: 5
35 + Verify: `npm test -- expiry`
36 +
37 +- [ ] **7. Public profile page (no viewer account required)**
38 + Render the shared profile as the main landing page for a link. Viewers do not need a GitHub account. The page shows profile info and the list of selected repositories, and includes profile activity. [S4][S5][S12][S13][S3][S39]
39 + Depends: 6
40 + Verify: `npm test -- profile-page`
41 +
42 +- [ ] **8. Navigate from profile into a repository**
43 + From the main profile page, let the viewer click a repository to open it. [S38][S39]
44 + Depends: 7
45 + Verify: `npm test -- repo-navigation`
46 +
47 +- [ ] **9. Repository view: files and commit history**
48 + Inside a repository, show the files and the commit history. [S11][S40][S42]
49 + Depends: 8
50 + Verify: `npm test -- repo-view`
51 +
52 +- [ ] **10. Browse files and folders, and read code**
53 + Let the viewer click a file name to open and read its code on screen, and open nested folders to find files. [S44][S45]
54 + Depends: 9
55 + Verify: `npm test -- file-browser`
56 +
57 +- [ ] **11. Commit history with clickable diffs**
58 + Show a list of commits with what changed and when; make commits clickable to view which lines changed, if doable. [S42][S43]
59 + Depends: 9
60 + Verify: `npm test -- commits`
61 +
62 +- [ ] **12. Navigation controls (back to profile, step up folders)**
63 + Provide a button at the top to return to the profile page, and a way to step back up folder levels or jump to the top of a project without losing place. [S46][S47][S48]
64 + Depends: 10
65 + Verify: `npm test -- navigation`
66 +
67 +- [ ] **13. Desktop-focused layout**
68 + Ensure the shared page is intended for viewing on a computer. [S28][S29]
69 + Depends: 7
70 + Verify: `npm test -- layout`
71 +
72 +- [ ] **14. Read-only constraints (no comments, no downloading, no editing)**
73 + Ensure viewers can only get an overview of the work: no comments, no code downloading, and no editing. [S30][S31][S32][S33]
74 + Depends: 7
75 + Verify: `npm test -- read-only`
76 +
77 +- [ ] **15. End-to-end owner verification flow**
78 + Confirm that opening a generated link shows all the projects the owner selected and all the commits, so the owner can verify a link before sharing it. [S34][S35][S36][S37]
79 + Depends: 8, 9, 10, 11, 12
80 + Verify: `npm test -- e2e`
No newline at end of file
added spec/VERIFICATION.md +120 −0
@@ -0,0 +1,120 @@
1 +# VERIFICATION.md — profileShare
2 +
3 +This document describes what "done" looks like for **profileShare** and how a coding agent should verify it. It is based only on the specification interview. Where the developer explicitly left a decision open, this is noted rather than assumed.
4 +
5 +## Purpose
6 +
7 +profileShare lets a developer share a read-only overview of selected GitHub projects (including private ones) via a link, without making repositories public or adding viewers as collaborators. Viewers are primarily potential hiring managers. The link works for a limited time period, then expires.
8 +
9 +The core goal, restated by the developer: **"an overview of my code."** Nothing more complex.
10 +
11 +---
12 +
13 +## Definition of Done
14 +
15 +The project is done when all of the following behaviors hold.
16 +
17 +### 1. Connecting to GitHub and selecting repositories
18 +- The owner can authenticate with their GitHub account and grant access.
19 +- Access is granted **only to the repositories the owner points to**, not to all repositories. [S19]
20 +- The owner sees their repositories and can click to select which ones to share. [S9]
21 +
22 +### 2. Creating a share link
23 +- The owner selects specific repositories and generates a **simple URL** to share. [S9]
24 +- The owner can set a **time period** for how long the link is valid (e.g. 7 days), and this period is customizable. [S9]
25 +- The shared content is a **snapshot** taken at the moment the link is created — it does not update as the source repositories change. [S15]
26 +
27 +### 3. Viewing a shared link (no account required)
28 +- A viewer can open the link **without needing a GitHub account**. [S5]
29 +- **Anyone with the link** can open it while it is valid; there is no per-person restriction, and forwarding the link is acceptable. [S23]
30 +- The landing page is the owner's **profile page**. [S39]
31 +
32 +### 4. Profile page contents
33 +The profile page shows GitHub-style basic profile information and the list of selected projects. Supported items mentioned: name, photo, short bio, and the list of projects; profile activity is also desirable. [S12][S3]
34 +- From the profile page, the viewer clicks a repository to open it. [S39]
35 +
36 +### 5. Inside a project
37 +- The viewer can browse the project's **files**, opening a file by name to read the code on screen. [S44]
38 +- Folders (including nested folders) can be opened to find files. [S44][S45]
39 +- The viewer can see the project's **commit history**: a list of what changed and when. [S42]
40 +- If feasible, a commit should be **clickable to show which lines changed**. [S43]
41 +
42 +### 6. Navigation
43 +- A **button at the top** returns the viewer to the profile page. [S47]
44 +- The viewer can step back up through nested folders and back out of a project. [S48][S38]
45 +
46 +### 7. Expiration
47 +- When a link is opened after its time period has ended, the viewer sees a simple message that **the URL is expired**. [S21]
48 +
49 +---
50 +
51 +## Explicitly Out of Scope
52 +
53 +Do **not** implement these:
54 +- Comments. [S31]
55 +- Downloading code. [S31]
56 +- Editing by the viewer. [S32]
57 +- A full clone of all GitHub functionality — only an overview. [S32][S33]
58 +- Per-recipient access control / preventing link forwarding. [S23]
59 +- Live/continuously-updating content (snapshot only). [S15]
60 +
61 +---
62 +
63 +## Open / Undecided Items (do not treat as requirements)
64 +
65 +- Whether the tool supports **only the owner** or **multiple sign-up users**. [S7]
66 +- Whether stored snapshots are **deleted on expiry** or retained. [S26][S27]
67 +- Size, link-count, or performance limits — developer only said it should "work normally." [S25]
68 +- **Mobile support** was not requested; viewing is oriented to a computer. [S28][S29]
69 +
70 +---
71 +
72 +## How to Verify
73 +
74 +Since automated commands are not specified in the interview, verification is primarily behavioral. A coding agent should provide runnable build/start commands and then confirm the behaviors below.
75 +
76 +### Build / run
77 +- Provide and run the project's standard install and start commands (e.g. install dependencies, start the server).
78 +- Confirm the app launches without errors and the owner can reach the GitHub authentication flow.
79 +
80 +### Manual acceptance walkthrough (owner's own check) [S34][S35][S36][S37]
81 +The developer's own acceptance test is to **open the generated link himself** and confirm:
82 +1. **All the projects he selected appear** — no more, no less. [S37]
83 +2. **All commits appear** for those projects. [S37]
84 +
85 +Extend this into the full check:
86 +
87 +1. **Repo selection & access scope**
88 + - Authenticate with GitHub.
89 + - Confirm only the pointed-to repositories are accessed, not all repos. [S19]
90 + - Select specific repositories and generate a link.
91 +
92 +2. **Link generation**
93 + - Confirm a simple shareable URL is produced.
94 + - Confirm a customizable expiration period can be set. [S9]
95 +
96 +3. **Snapshot behavior**
97 + - After creating a link, change the source repository.
98 + - Reopen the link and confirm the page still shows the original snapshot, not the change. [S15]
99 +
100 +4. **Viewer experience (no login)**
101 + - Open the link in a context without a GitHub session/account and confirm it loads. [S5]
102 + - Confirm the profile page shows at least name, photo, bio, and the selected project list. [S12]
103 +
104 +5. **Navigation into projects**
105 + - From the profile, click a repository and confirm you enter it. [S39]
106 + - Open files (including files inside nested folders) and read code on screen. [S44][S45]
107 + - View the commit list (what changed and when). [S42]
108 + - If implemented, click a commit to view changed lines. [S43]
109 + - Use the top button to return to the profile. [S47]
110 + - Step back up through nested folders / out of a project. [S48]
111 +
112 +6. **Expiration**
113 + - Open (or simulate opening) a link after its period ends.
114 + - Confirm a simple "URL is expired" message is shown and content is not served. [S21]
115 +
116 +7. **Out-of-scope confirmation**
117 + - Confirm there is no comment feature, no download option, and no way for the viewer to edit content. [S31][S32]
118 +
119 +### Pass criteria
120 +The build succeeds and starts; the owner can connect GitHub, select specific repos, generate a time-limited link showing a snapshot; a viewer without an account can browse the profile, projects, files (including nested folders), and commits, and navigate back to the profile; and an expired link shows the expired message. Comments, downloads, and editing are absent.
No newline at end of file
added spec/sources.json +202 −0
@@ -0,0 +1,202 @@
1 +{
2 + "S1": {
3 + "text": "It should be some kind of a GitHub clone, but it's a shareable with a link or some way that we can basically share our private GitHub repos, I mean, all of the profile and the chosen repositories with a link, with a time period as well, so I wouldn't have to put some people who want to see my work as a collaborator, and therefore I can just easily share the link where you can see basically my work that I have been doing.",
4 + "ts": "2026-07-23T10:45:55.211Z"
5 + },
6 + "S2": {
7 + "text": "What's the main thing you want people to be able to do when they open one of your shared links — just look at your code and profile, or something more?",
8 + "ts": "2026-07-23T10:46:00.641Z"
9 + },
10 + "S3": {
11 + "text": "Yeah, basically, like in GitHub, but it would also be nice to see the activity of your profile and so on.",
12 + "ts": "2026-07-23T10:46:31.238Z"
13 + },
14 + "S4": {
15 + "text": "Who do you picture actually opening these links — for example, people hiring you, teammates, friends — and do they need a GitHub account of their own to view your work?",
16 + "ts": "2026-07-23T10:46:36.511Z"
17 + },
18 + "S5": {
19 + "text": "Mainly people who are willing to hire me, and they don't have to have a good top account.",
20 + "ts": "2026-07-23T10:47:03.043Z"
21 + },
22 + "S6": {
23 + "text": "Got it — the viewers are mostly people who might hire you. Now, who will be creating and sharing these links? Is it just you for your own work, or do you imagine lots of different people signing up to share their own profiles too?",
24 + "ts": "2026-07-23T10:47:10.726Z"
25 + },
26 + "S7": {
27 + "text": "I'm not really sure about what's the better solution here. Firstly, I would like to, of course, do it for me, myself, but maybe it's useful for other people as well, then therefore we can just make it the other way.",
28 + "ts": "2026-07-23T10:47:57.732Z"
29 + },
30 + "S8": {
31 + "text": "Walk me through it step by step from your side: once you're ready to share, what would you do to pick which repositories and profile info go into a link, and then how would you hand that link to someone?",
32 + "ts": "2026-07-23T10:48:03.596Z"
33 + },
34 + "S9": {
35 + "text": "This should be all of the repos that I have and I can just click on the ones I want to basically share on my account or under my account. And therefore, I can just, it generates some kind of a profile and a simple URL which I can just share and send it to other people. And also, I think it should have some kind of a time period that people cannot see it indefinitely. So yeah, for example, like seven days or something like that. And that's also just customizable for people themselves.",
36 + "ts": "2026-07-23T10:49:26.671Z"
37 + },
38 + "S10": {
39 + "text": "Now picture the person you sent the link to opening it. Walk me through what they see first and what they can click on or move through from there.",
40 + "ts": "2026-07-23T10:49:33.186Z"
41 + },
42 + "S11": {
43 + "text": "You do ChatGPT like a GitHub, similar to it. And you can see all of the projects, the code, also the commit history, and so on.",
44 + "ts": "2026-07-23T10:50:07.781Z"
45 + },
46 + "S12": {
47 + "text": "When someone opens your shared link, what exact pieces of information should show up on that page — for example your name, a photo, a short bio, the list of projects, the code inside them, dates of when you worked on things, or anything else?",
48 + "ts": "2026-07-23T10:50:13.840Z"
49 + },
50 + "S13": {
51 + "text": "Yes, basically all of the GitHub or GitLab things that are basic. Just my main goal is to not make my GitHub projects fully public to the whole world, and I just want to share this one of my profile publicly to a fixed amount of people that I have shared the link to.",
52 + "ts": "2026-07-23T10:50:59.809Z"
53 + },
54 + "S14": {
55 + "text": "When you pick your projects to share, should the shared page always show the very latest version of your work as it changes, or should it freeze a snapshot of how things looked the moment you made the link?",
56 + "ts": "2026-07-23T10:51:07.570Z"
57 + },
58 + "S15": {
59 + "text": "I think it's erased to just from the snapshot, because if it changes all the time, then therefore we would just need a lot more complicated code, and I don't think that's necessary. With a snapshot, we just can take the moment's code and that's it.",
60 + "ts": "2026-07-23T10:52:13.693Z"
61 + },
62 + "S16": {
63 + "text": "Where would this tool actually get your projects and code from? For example, would it connect directly to your existing GitHub or GitLab account to pull everything in, or would you upload your files some other way?",
64 + "ts": "2026-07-23T10:52:19.100Z"
65 + },
66 + "S17": {
67 + "text": "That's something for you to think about, but mainly, yeah, from my GitHub. I have all of my projects in GitHub.",
68 + "ts": "2026-07-23T10:52:44.551Z"
69 + },
70 + "S18": {
71 + "text": "When you first set this up to pull from your GitHub, how would you expect to give it permission to see your private projects — for example logging in with your GitHub account and approving access, and would you be okay with it having access to all your repositories or only the ones you point it to?",
72 + "ts": "2026-07-23T10:52:50.627Z"
73 + },
74 + "S19": {
75 + "text": "I'm not really sure, but why should I give access to all of the repositories when I just think the ones that I want to make public just have access to them.",
76 + "ts": "2026-07-23T10:53:44.894Z"
77 + },
78 + "S20": {
79 + "text": "Let's think about when that sharing time runs out. Say you set a link to work for seven days and someone tries to open it on day eight — what should they see or be told at that point?",
80 + "ts": "2026-07-23T10:53:52.543Z"
81 + },
82 + "S21": {
83 + "text": "Just a simple that the URL that you have opened is expired.",
84 + "ts": "2026-07-23T10:54:12.884Z"
85 + },
86 + "S22": {
87 + "text": "Imagine you send the link to one person, but they forward it to someone else you didn't mean to share it with. Should anyone who has the link be able to open it, or do you want some way to make sure only the people you intended can get in?",
88 + "ts": "2026-07-23T10:54:18.681Z"
89 + },
90 + "S23": {
91 + "text": "I don't think we need to go that complicated, just if the send it or forward it to someone else, then that's it. Because the code is still not filled with some secrets, but just I don't want to make all of the GitHub repositories public to the whole world. If just a couple of people see it, it's okay.",
92 + "ts": "2026-07-23T10:55:08.560Z"
93 + },
94 + "S24": {
95 + "text": "Are there any limits this tool needs to respect — for example, how big your projects can be, how many links you can have going at once, or how fast the page should load when someone opens it?",
96 + "ts": "2026-07-23T10:55:15.757Z"
97 + },
98 + "S25": {
99 + "text": "I'm not really sure, but it should just work normally and same normal, and main thing is to just have the right features that it shows all of the commits and all of the details that are needed.",
100 + "ts": "2026-07-23T10:55:54.492Z"
101 + },
102 + "S26": {
103 + "text": "Since the page shows a frozen copy of your work, that copy has to be stored somewhere until the link runs out. Once a link expires, do you want that stored copy of your code and profile to be deleted right away, or is it fine for it to sit around somewhere afterward?",
104 + "ts": "2026-07-23T10:56:04.613Z"
105 + },
106 + "S27": {
107 + "text": "I'm not sure about this, I just picked the most best solution.",
108 + "ts": "2026-07-23T10:56:46.277Z"
109 + },
110 + "S28": {
111 + "text": "When someone opens your shared link, do they need it to look good and work on a phone screen too, or is it fine if it's really only meant to be viewed on a computer?",
112 + "ts": "2026-07-23T10:56:55.060Z"
113 + },
114 + "S29": {
115 + "text": "Menim komputerim.",
116 + "ts": "2026-07-23T10:57:09.000Z"
117 + },
118 + "S30": {
119 + "text": "When someone opens your link, do you want them to only look at your work, or should they also be able to do things like leave comments, download your code, or send you a message — and are there any of those things you'd rather they definitely can't do?",
120 + "ts": "2026-07-23T10:57:16.826Z"
121 + },
122 + "S31": {
123 + "text": "No comments and I don't think downloading code is necessary for it. Just the main objective is to just them having an overview of my work that I have done and the code not being openly public to the world.",
124 + "ts": "2026-07-23T10:58:23.953Z"
125 + },
126 + "S32": {
127 + "text": "You've said no comments and no downloading. Are there other things you want to make sure this tool does NOT try to do — for example letting the viewer edit anything, being a full copy of everything GitHub does, or working for teams instead of just one person — things you'd rather leave out to keep it simple?",
128 + "ts": "2026-07-23T10:58:41.168Z"
129 + },
130 + "S33": {
131 + "text": "It's basically a overview of my code. That's it. Simple. Just an overview of my code.",
132 + "ts": "2026-07-23T10:59:10.328Z"
133 + },
134 + "S34": {
135 + "text": "Imagine you've just made your first link and sent it to someone. How would you check for yourself that it's working the way you want — what would you look at or try before you felt confident sharing it with a real hiring person?",
136 + "ts": "2026-07-23T10:59:19.328Z"
137 + },
138 + "S35": {
139 + "text": "just looking at it openly link myself",
140 + "ts": "2026-07-23T10:59:44.926Z"
141 + },
142 + "S36": {
143 + "text": "When you open that link yourself to check it, what would make you say 'yes, this is right' versus 'something's wrong here'? For example, would you be checking that the correct projects show up, that the code and commit dates look right, or something else?",
144 + "ts": "2026-07-23T10:59:50.508Z"
145 + },
146 + "S37": {
147 + "text": "has all the projects that i selected and all the commits and ues",
148 + "ts": "2026-07-23T11:00:27.742Z"
149 + },
150 + "S38": {
151 + "text": "When the hiring person is looking at your page and you've shared, say, three projects, how would they move from one project to another — is there a main list they start on and click into each one, and can they then dig into the files and commit history inside a project and come back out?",
152 + "ts": "2026-07-23T11:00:35.122Z"
153 + },
154 + "S39": {
155 + "text": "Yes, basically that's it. Just the main page is my profile and there you can just click on the repositories.",
156 + "ts": "2026-07-23T11:01:01.808Z"
157 + },
158 + "S40": {
159 + "text": "Once the hiring person clicks into one of your projects, what would they see inside it — for example the list of files that they can open to read the code, and a separate list of your past commits — and how would they get back to your main profile page when they're done looking?",
160 + "ts": "2026-07-23T11:01:08.327Z"
161 + },
162 + "S41": {
163 + "text": "thats your job",
164 + "ts": "2026-07-23T11:01:27.027Z"
165 + },
166 + "S42": {
167 + "text": "When the hiring person looks at your past commits inside a project, do you just want them to see a simple list of what you changed and when, or should they be able to click a commit and actually see which lines of code changed in that update?",
168 + "ts": "2026-07-23T11:01:39.917Z"
169 + },
170 + "S43": {
171 + "text": "if doable then just clickable also",
172 + "ts": "2026-07-23T11:02:16.848Z"
173 + },
174 + "S44": {
175 + "text": "Inside one of your projects, when the hiring person wants to actually read your code, how do you picture them getting to it — do they click on a file name from a list and the code opens up to read on screen, and if your project has folders inside folders, should they be able to open those folders to find files too?",
176 + "ts": "2026-07-23T11:02:26.830Z"
177 + },
178 + "S45": {
179 + "text": "yes",
180 + "ts": "2026-07-23T11:02:53.057Z"
181 + },
182 + "S46": {
183 + "text": "When the hiring person has opened one of your files and finished reading the code, how would they get back — do they step back to the list of files in that project first, and then have a clear way back to your main profile page to pick a different project?",
184 + "ts": "2026-07-23T11:03:09.282Z"
185 + },
186 + "S47": {
187 + "text": "just at the top some button to profile",
188 + "ts": "2026-07-23T11:03:51.522Z"
189 + },
190 + "S48": {
191 + "text": "You mentioned a button at the top to get back to your profile. When the hiring person has clicked down through several folders inside a project — say a folder inside a folder inside a folder — how would they step back up one level at a time, or jump straight back to the top of that project, without losing their place?",
192 + "ts": "2026-07-23T11:04:02.130Z"
193 + },
194 + "S49": {
195 + "text": "done",
196 + "ts": "2026-07-23T11:04:13.798Z"
197 + },
198 + "S50": {
199 + "text": "Interview complete.",
200 + "ts": "2026-07-23T11:04:13.800Z"
201 + }
202 +}
No newline at end of file
added src/app.js +349 −0
@@ -0,0 +1,349 @@
1 +import express from "express";
2 +import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
3 +import { fileURLToPath } from "node:url";
4 +import { dirname, join, posix } from "node:path";
5 +import { marked } from "marked";
6 +import sanitizeHtml from "sanitize-html";
7 +
8 +const here = dirname(fileURLToPath(import.meta.url));
9 +
10 +function randomId(bytes = 24) {
11 + return randomBytes(bytes).toString("base64url");
12 +}
13 +
14 +function sameValue(left, right) {
15 + const a = Buffer.from(left || "");
16 + const b = Buffer.from(right || "");
17 + return a.length === b.length && timingSafeEqual(a, b);
18 +}
19 +
20 +function cookieValue(header, name) {
21 + const item = (header || "").split(";").map((part) => part.trim()).find((part) => part.startsWith(`${name}=`));
22 + return item ? decodeURIComponent(item.slice(name.length + 1)) : undefined;
23 +}
24 +
25 +function foldersFor(files, currentPath) {
26 + const prefix = currentPath ? `${currentPath}/` : "";
27 + const folders = new Set();
28 + const directFiles = [];
29 + for (const file of files) {
30 + if (!file.path.startsWith(prefix)) continue;
31 + const rest = file.path.slice(prefix.length);
32 + if (rest.includes("/")) folders.add(rest.split("/")[0]);
33 + else directFiles.push(file);
34 + }
35 + return { folders: [...folders].sort(), files: directFiles.sort((a, b) => a.path.localeCompare(b.path)) };
36 +}
37 +
38 +export function createApp({ config, store, github, now = () => new Date() }) {
39 + const app = express();
40 + const tokenRefreshes = new Map();
41 + app.disable("x-powered-by");
42 + app.set("view engine", "ejs");
43 + app.set("views", join(here, "views"));
44 + app.locals.formatDate = (value) => new Intl.DateTimeFormat("en", {
45 + dateStyle: "medium",
46 + timeStyle: "short",
47 + }).format(new Date(value));
48 + app.locals.encodeURIComponent = encodeURIComponent;
49 + app.locals.renderMarkdown = (value, options = {}) => {
50 + const html = marked.parse(value || "", { gfm: true, breaks: false });
51 + return sanitizeHtml(html, {
52 + allowedTags: [
53 + "h1", "h2", "h3", "h4", "h5", "h6", "p", "a", "blockquote", "pre", "code",
54 + "ul", "ol", "li", "strong", "em", "del", "hr", "br", "table", "thead",
55 + "tbody", "tr", "th", "td", "details", "summary",
56 + ],
57 + allowedAttributes: {
58 + a: ["href", "title"],
59 + code: ["class"],
60 + },
61 + allowedSchemes: ["http", "https", "mailto"],
62 + transformTags: {
63 + a(tagName, attributes) {
64 + const href = attributes.href || "";
65 + if (!options.baseUrl || !href || href.startsWith("#") || /^[a-z][a-z\d+.-]*:/i.test(href)) {
66 + return { tagName, attribs: attributes };
67 + }
68 + const relativePath = href.split("#")[0].split("?")[0];
69 + const target = posix.normalize(posix.join(
70 + posix.dirname(options.readmePath || ""),
71 + relativePath,
72 + ));
73 + if (target.startsWith("../")) return { tagName, attribs: { ...attributes, href: "#" } };
74 + const file = options.files?.find((item) => item.path === target);
75 + const folder = options.files?.some((item) => item.path.startsWith(`${target}/`));
76 + if (file) {
77 + return {
78 + tagName,
79 + attribs: { ...attributes, href: `${options.baseUrl}?file=${encodeURIComponent(target)}` },
80 + };
81 + }
82 + if (folder) {
83 + return {
84 + tagName,
85 + attribs: { ...attributes, href: `${options.baseUrl}?path=${encodeURIComponent(target)}` },
86 + };
87 + }
88 + return { tagName, attribs: { ...attributes, href: "#" } };
89 + },
90 + },
91 + });
92 + };
93 + app.use(express.urlencoded({ extended: false, limit: "100kb" }));
94 + app.use((req, res, next) => {
95 + res.set({
96 + "Content-Security-Policy": "default-src 'self'; img-src 'self' https: data:; style-src 'self'; script-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'",
97 + "Referrer-Policy": "no-referrer",
98 + "X-Content-Type-Options": "nosniff",
99 + "X-Frame-Options": "DENY",
100 + });
101 + next();
102 + });
103 + app.use("/assets", express.static(join(here, "public"), {
104 + maxAge: 0,
105 + etag: true,
106 + }));
107 +
108 + app.use((req, res, next) => {
109 + if (req.path.startsWith("/s/")) {
110 + res.set("Cache-Control", "private, no-store");
111 + return next();
112 + }
113 + let sessionId = cookieValue(req.headers.cookie, "profileshare_session");
114 + let session = sessionId && store.getSession(sessionId);
115 + req.sessionId = sessionId;
116 + req.session = session;
117 + req.owner = session?.owner_id ? store.getOwner(session.owner_id) : undefined;
118 + next();
119 + });
120 +
121 + function ensureSession(req, res) {
122 + if (req.session) return;
123 + store.pruneSessions(now());
124 + req.sessionId = randomId();
125 + store.createSession(req.sessionId, now());
126 + req.session = store.getSession(req.sessionId);
127 + res.cookie("profileshare_session", req.sessionId, {
128 + httpOnly: true,
129 + sameSite: "lax",
130 + secure: config.baseUrl.startsWith("https://"),
131 + maxAge: 30 * 24 * 60 * 60 * 1000,
132 + });
133 + }
134 +
135 + function signOAuthState(state) {
136 + return createHmac("sha256", config.sessionSecret).update(state).digest("base64url");
137 + }
138 +
139 + function setOAuthState(res, state) {
140 + res.cookie("profileshare_oauth", `${state}.${signOAuthState(state)}`, {
141 + httpOnly: true,
142 + sameSite: "lax",
143 + secure: config.baseUrl.startsWith("https://"),
144 + maxAge: 10 * 60 * 1000,
145 + });
146 + }
147 +
148 + function verifyOAuthState(req) {
149 + const value = cookieValue(req.headers.cookie, "profileshare_oauth") || "";
150 + const separator = value.lastIndexOf(".");
151 + if (separator < 1) return false;
152 + const state = value.slice(0, separator);
153 + const signature = value.slice(separator + 1);
154 + const issuedAt = Number(state.slice(state.lastIndexOf(".") + 1));
155 + const age = now().getTime() - issuedAt;
156 + return Number.isSafeInteger(issuedAt)
157 + && age >= 0
158 + && age <= 10 * 60 * 1000
159 + && sameValue(req.query.state, state)
160 + && sameValue(signature, signOAuthState(state));
161 + }
162 +
163 + async function ownerToken(req) {
164 + req.owner = store.getOwner(req.owner.id);
165 + const expiresAt = req.owner.token_expires_at && new Date(req.owner.token_expires_at);
166 + if (!expiresAt || expiresAt.getTime() > now().getTime() + 60_000 || !req.owner.refresh_token) {
167 + return req.owner.github_token;
168 + }
169 + let refresh = tokenRefreshes.get(req.owner.id);
170 + if (!refresh) {
171 + refresh = (async () => {
172 + const current = store.getOwner(req.owner.id);
173 + const currentExpiry = current.token_expires_at && new Date(current.token_expires_at);
174 + if (!currentExpiry || currentExpiry.getTime() > now().getTime() + 60_000) return current;
175 + const credentials = await github.refreshUserToken(current.refresh_token);
176 + return store.updateOwnerTokens(current.id, credentials);
177 + })();
178 + tokenRefreshes.set(req.owner.id, refresh);
179 + }
180 + try {
181 + req.owner = await refresh;
182 + } finally {
183 + if (tokenRefreshes.get(req.owner.id) === refresh) tokenRefreshes.delete(req.owner.id);
184 + }
185 + return req.owner.github_token;
186 + }
187 +
188 + app.get("/", async (req, res, next) => {
189 + try {
190 + const repos = req.owner?.installation_id
191 + ? await github.listRepositories(await ownerToken(req), req.owner.installation_id)
192 + : [];
193 + res.render("dashboard", {
194 + owner: req.owner,
195 + repos,
196 + appConfigured: Boolean(config.github.clientId && config.github.clientSecret && config.github.appSlug),
197 + error: req.query.error,
198 + });
199 + } catch (error) {
200 + next(error);
201 + }
202 + });
203 +
204 + app.get("/auth/github", (req, res) => {
205 + if (!config.github.clientId) return res.redirect("/?error=GitHub+is+not+configured");
206 + const state = `${randomId()}.${now().getTime()}`;
207 + setOAuthState(res, state);
208 + res.redirect(github.authorizationUrl(state));
209 + });
210 +
211 + app.get("/auth/github/callback", async (req, res, next) => {
212 + try {
213 + if (!verifyOAuthState(req)) return res.status(400).render("error", { message: "The sign-in request could not be verified." });
214 + const credentials = await github.exchangeCode(req.query.code);
215 + const viewer = await github.getViewer(credentials.accessToken);
216 + const owner = store.upsertOwner(viewer, credentials);
217 + ensureSession(req, res);
218 + store.attachOwner(req.sessionId, owner.id);
219 + res.clearCookie("profileshare_oauth");
220 + res.redirect("/");
221 + } catch (error) {
222 + next(error);
223 + }
224 + });
225 +
226 + app.get("/github/install", (req, res) => {
227 + if (!req.owner) return res.redirect("/auth/github");
228 + const state = randomId();
229 + store.setInstallationState(req.sessionId, state);
230 + res.redirect(github.installationUrl(state));
231 + });
232 +
233 + app.get("/github/installed", async (req, res, next) => {
234 + try {
235 + if (!req.owner) return res.redirect("/");
236 + const session = store.getSession(req.sessionId);
237 + if (!sameValue(req.query.state, session?.installation_state)) {
238 + return res.status(400).render("error", { message: "The repository access request could not be verified." });
239 + }
240 + const installationId = Number(req.query.installation_id);
241 + if (!Number.isSafeInteger(installationId)) return res.status(400).render("error", { message: "The repository access selection was not valid." });
242 + await github.listRepositories(await ownerToken(req), installationId);
243 + store.setInstallation(req.owner.id, installationId);
244 + store.clearInstallationState(req.sessionId);
245 + res.redirect("/");
246 + } catch (error) {
247 + next(error);
248 + }
249 + });
250 +
251 + app.post("/shares", async (req, res, next) => {
252 + try {
253 + if (!req.owner?.installation_id) return res.status(401).render("error", { message: "Connect GitHub and select repository access first." });
254 + const ids = new Set([].concat(req.body.repositories || []).map(Number));
255 + const days = Number(req.body.days);
256 + if (!ids.size) return res.status(400).render("error", { message: "Select at least one repository." });
257 + if (!Number.isInteger(days) || days < 1 || days > 365) return res.status(400).render("error", { message: "Choose an expiry from 1 to 365 days." });
258 + const token = await ownerToken(req);
259 + const [available, viewer] = await Promise.all([
260 + github.listRepositories(token, req.owner.installation_id),
261 + github.getViewer(token),
262 + ]);
263 + req.owner = store.updateOwnerProfile(viewer);
264 + const selected = available.filter((repo) => ids.has(repo.id));
265 + if (selected.length !== ids.size) return res.status(400).render("error", { message: "One or more selected repositories are not available." });
266 + const repositories = await github.snapshotRepositories(token, selected);
267 + const createdAt = now();
268 + const expiresAt = new Date(createdAt.getTime() + days * 86_400_000);
269 + const id = randomId(18);
270 + store.createShare({
271 + id,
272 + ownerId: req.owner.id,
273 + createdAt: createdAt.toISOString(),
274 + expiresAt: expiresAt.toISOString(),
275 + snapshot: {
276 + profile: {
277 + login: viewer.login,
278 + name: viewer.name,
279 + avatarUrl: viewer.avatar_url,
280 + bio: viewer.bio,
281 + },
282 + repositories,
283 + },
284 + });
285 + res.status(201).render("created", {
286 + url: `${config.baseUrl}/s/${id}`,
287 + expiresAt: expiresAt.toISOString(),
288 + repositories,
289 + });
290 + } catch (error) {
291 + next(error);
292 + }
293 + });
294 +
295 + function loadShare(req, res, next) {
296 + const share = store.getShare(req.params.shareId);
297 + if (!share) return res.status(404).render("error", { message: "This shared URL does not exist." });
298 + if (new Date(share.expires_at) <= now()) {
299 + if (share.snapshot) store.purgeShareSnapshot(share.id);
300 + return res.status(410).render("expired");
301 + }
302 + req.share = share;
303 + next();
304 + }
305 +
306 + app.get("/s/:shareId", loadShare, (req, res) => {
307 + const commits = req.share.snapshot.repositories
308 + .flatMap((repo) => repo.commits.map((commit) => ({
309 + ...commit,
310 + repository: repo.name,
311 + repositoryId: repo.id,
312 + })))
313 + .sort((a, b) => new Date(b.date) - new Date(a.date));
314 + res.render("profile", { share: req.share, commits });
315 + });
316 +
317 + app.get("/s/:shareId/repositories/:repoId", loadShare, (req, res) => {
318 + const repository = req.share.snapshot.repositories.find((repo) => String(repo.id) === req.params.repoId);
319 + if (!repository) return res.status(404).render("error", { message: "This repository is not part of the snapshot." });
320 + const path = String(req.query.path || "").replace(/^\/+|\/+$/g, "");
321 + const filePath = req.query.file ? String(req.query.file) : "";
322 + const commitSha = req.query.commit ? String(req.query.commit) : "";
323 + const tab = req.query.tab === "commits" ? "commits" : "code";
324 + const file = filePath ? repository.files.find((item) => item.path === filePath) : undefined;
325 + const commit = commitSha ? repository.commits.find((item) => item.sha === commitSha) : undefined;
326 + const readme = !path && !file && !commit
327 + ? repository.files.find((item) => /^readme(?:\.[^/]+)?$/i.test(item.path) && item.content)
328 + : undefined;
329 + if ((filePath && !file) || (commitSha && !commit)) return res.status(404).render("error", { message: "That item is not part of the snapshot." });
330 + res.render("repository", {
331 + share: req.share,
332 + repository,
333 + path,
334 + browser: foldersFor(repository.files, path),
335 + file,
336 + commit,
337 + readme,
338 + tab,
339 + });
340 + });
341 +
342 + app.use((error, req, res, next) => {
343 + console.error(error);
344 + if (res.headersSent) return next(error);
345 + res.status(500).render("error", { message: "The request could not be completed." });
346 + });
347 +
348 + return app;
349 +}
added src/config.js +20 −0
@@ -0,0 +1,20 @@
1 +export function loadConfig(env = process.env) {
2 + const githubConfigured = Boolean(env.GITHUB_CLIENT_ID || env.GITHUB_CLIENT_SECRET || env.GITHUB_APP_SLUG);
3 + const weakSecret = !env.SESSION_SECRET
4 + || env.SESSION_SECRET.length < 32
5 + || env.SESSION_SECRET.startsWith("replace-");
6 + if ((env.NODE_ENV === "production" || githubConfigured) && weakSecret) {
7 + throw new Error("SESSION_SECRET must contain at least 32 random characters.");
8 + }
9 + return {
10 + port: Number(env.PORT || 3000),
11 + baseUrl: env.BASE_URL || "http://localhost:3000",
12 + databasePath: env.DATABASE_PATH || "data/profileshare.db",
13 + sessionSecret: env.SESSION_SECRET || "development-only-secret",
14 + github: {
15 + clientId: env.GITHUB_CLIENT_ID || "",
16 + clientSecret: env.GITHUB_CLIENT_SECRET || "",
17 + appSlug: env.GITHUB_APP_SLUG || "",
18 + },
19 + };
20 +}
added src/db.js +163 −0
@@ -0,0 +1,163 @@
1 +import { DatabaseSync } from "node:sqlite";
2 +import { mkdirSync } from "node:fs";
3 +import { dirname } from "node:path";
4 +import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
5 +
6 +export function createStore(path = ":memory:", secret = "test-only-secret") {
7 + const key = createHash("sha256").update(secret).digest();
8 + function protect(value) {
9 + if (!value) return null;
10 + const iv = randomBytes(12);
11 + const cipher = createCipheriv("aes-256-gcm", key, iv);
12 + const encrypted = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]);
13 + return ["v1", iv.toString("base64url"), cipher.getAuthTag().toString("base64url"), encrypted.toString("base64url")].join(".");
14 + }
15 + function reveal(value) {
16 + if (!value?.startsWith("v1.")) return value;
17 + const [, iv, tag, encrypted] = value.split(".");
18 + const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(iv, "base64url"));
19 + decipher.setAuthTag(Buffer.from(tag, "base64url"));
20 + return Buffer.concat([
21 + decipher.update(Buffer.from(encrypted, "base64url")),
22 + decipher.final(),
23 + ]).toString("utf8");
24 + }
25 + if (path !== ":memory:") mkdirSync(dirname(path), { recursive: true });
26 + const db = new DatabaseSync(path);
27 + db.exec(`
28 + PRAGMA journal_mode = WAL;
29 + CREATE TABLE IF NOT EXISTS owners (
30 + id INTEGER PRIMARY KEY,
31 + login TEXT NOT NULL,
32 + name TEXT,
33 + avatar_url TEXT,
34 + bio TEXT,
35 + github_token TEXT NOT NULL,
36 + installation_id INTEGER
37 + );
38 + CREATE TABLE IF NOT EXISTS sessions (
39 + id TEXT PRIMARY KEY,
40 + owner_id INTEGER,
41 + oauth_state TEXT,
42 + created_at TEXT NOT NULL
43 + );
44 + CREATE TABLE IF NOT EXISTS shares (
45 + id TEXT PRIMARY KEY,
46 + owner_id INTEGER NOT NULL,
47 + created_at TEXT NOT NULL,
48 + expires_at TEXT NOT NULL,
49 + snapshot TEXT NOT NULL
50 + );
51 + `);
52 + const sessionColumns = db.prepare("PRAGMA table_info(sessions)").all().map((column) => column.name);
53 + if (!sessionColumns.includes("installation_state")) {
54 + db.exec("ALTER TABLE sessions ADD COLUMN installation_state TEXT");
55 + }
56 + const ownerColumns = db.prepare("PRAGMA table_info(owners)").all().map((column) => column.name);
57 + if (!ownerColumns.includes("refresh_token")) {
58 + db.exec("ALTER TABLE owners ADD COLUMN refresh_token TEXT");
59 + }
60 + if (!ownerColumns.includes("token_expires_at")) {
61 + db.exec("ALTER TABLE owners ADD COLUMN token_expires_at TEXT");
62 + }
63 +
64 + return {
65 + createSession(id, now = new Date()) {
66 + db.prepare("INSERT INTO sessions (id, created_at) VALUES (?, ?)").run(id, now.toISOString());
67 + },
68 + pruneSessions(currentTime = new Date()) {
69 + const abandonedBefore = new Date(currentTime.getTime() - 60 * 60 * 1000).toISOString();
70 + const expiredBefore = new Date(currentTime.getTime() - 30 * 24 * 60 * 60 * 1000).toISOString();
71 + db.prepare(`
72 + DELETE FROM sessions
73 + WHERE (owner_id IS NULL AND created_at < ?) OR created_at < ?
74 + `).run(abandonedBefore, expiredBefore);
75 + },
76 + getSession(id) {
77 + return db.prepare("SELECT * FROM sessions WHERE id = ?").get(id);
78 + },
79 + setSessionState(id, state) {
80 + db.prepare("UPDATE sessions SET oauth_state = ? WHERE id = ?").run(state, id);
81 + },
82 + setInstallationState(id, state) {
83 + db.prepare("UPDATE sessions SET installation_state = ? WHERE id = ?").run(state, id);
84 + },
85 + clearInstallationState(id) {
86 + db.prepare("UPDATE sessions SET installation_state = NULL WHERE id = ?").run(id);
87 + },
88 + attachOwner(id, ownerId) {
89 + db.prepare("UPDATE sessions SET owner_id = ? WHERE id = ?").run(ownerId, id);
90 + },
91 + upsertOwner(owner, credentials) {
92 + db.prepare(`
93 + INSERT INTO owners (id, login, name, avatar_url, bio, github_token, refresh_token, token_expires_at)
94 + VALUES (?, ?, ?, ?, ?, ?, ?, ?)
95 + ON CONFLICT(id) DO UPDATE SET
96 + login = excluded.login, name = excluded.name, avatar_url = excluded.avatar_url,
97 + bio = excluded.bio, github_token = excluded.github_token,
98 + refresh_token = excluded.refresh_token, token_expires_at = excluded.token_expires_at
99 + `).run(
100 + owner.id,
101 + owner.login,
102 + owner.name,
103 + owner.avatar_url,
104 + owner.bio,
105 + protect(credentials.accessToken),
106 + protect(credentials.refreshToken),
107 + credentials.expiresAt,
108 + );
109 + return this.getOwner(owner.id);
110 + },
111 + updateOwnerTokens(ownerId, credentials) {
112 + db.prepare(`
113 + UPDATE owners
114 + SET github_token = ?, refresh_token = ?, token_expires_at = ?
115 + WHERE id = ?
116 + `).run(
117 + protect(credentials.accessToken),
118 + protect(credentials.refreshToken),
119 + credentials.expiresAt,
120 + ownerId,
121 + );
122 + return this.getOwner(ownerId);
123 + },
124 + updateOwnerProfile(owner) {
125 + db.prepare(`
126 + UPDATE owners
127 + SET login = ?, name = ?, avatar_url = ?, bio = ?
128 + WHERE id = ?
129 + `).run(owner.login, owner.name, owner.avatar_url, owner.bio, owner.id);
130 + return this.getOwner(owner.id);
131 + },
132 + getOwner(id) {
133 + const owner = db.prepare("SELECT * FROM owners WHERE id = ?").get(id);
134 + return owner ? {
135 + ...owner,
136 + github_token: reveal(owner.github_token),
137 + refresh_token: reveal(owner.refresh_token),
138 + } : undefined;
139 + },
140 + setInstallation(ownerId, installationId) {
141 + db.prepare("UPDATE owners SET installation_id = ? WHERE id = ?").run(installationId, ownerId);
142 + },
143 + createShare(share) {
144 + db.prepare(`
145 + INSERT INTO shares (id, owner_id, created_at, expires_at, snapshot)
146 + VALUES (?, ?, ?, ?, ?)
147 + `).run(share.id, share.ownerId, share.createdAt, share.expiresAt, JSON.stringify(share.snapshot));
148 + },
149 + getShare(id) {
150 + const row = db.prepare("SELECT * FROM shares WHERE id = ?").get(id);
151 + return row ? { ...row, snapshot: JSON.parse(row.snapshot) } : undefined;
152 + },
153 + purgeShareSnapshot(id) {
154 + db.prepare("UPDATE shares SET snapshot = 'null' WHERE id = ?").run(id);
155 + },
156 + close() {
157 + db.close();
158 + },
159 + sessionCount() {
160 + return db.prepare("SELECT COUNT(*) AS count FROM sessions").get().count;
161 + },
162 + };
163 +}
added src/github.js +238 −0
@@ -0,0 +1,238 @@
1 +const API = "https://api.github.com";
2 +
3 +async function request(path, token, options = {}) {
4 + let response;
5 + for (let attempt = 0; attempt < 3; attempt += 1) {
6 + response = await fetch(`${API}${path}`, {
7 + ...options,
8 + signal: options.signal || AbortSignal.timeout(20_000),
9 + headers: {
10 + Accept: "application/vnd.github+json",
11 + Authorization: `Bearer ${token}`,
12 + "X-GitHub-Api-Version": "2022-11-28",
13 + "Content-Type": "application/json",
14 + ...options.headers,
15 + },
16 + });
17 + if (![429, 502, 503, 504].includes(response.status) || attempt === 2) break;
18 + await new Promise((resolve) => setTimeout(resolve, 250 * (attempt + 1)));
19 + }
20 + if (!response.ok) {
21 + const detail = await response.text();
22 + if (response.status === 403 && response.headers.get("x-ratelimit-remaining") === "0") {
23 + throw new Error("GitHub API rate limit reached. Try creating the snapshot after the reset time.");
24 + }
25 + throw new Error(`GitHub request failed (${response.status}): ${detail}`);
26 + }
27 + return response.json();
28 +}
29 +
30 +async function allPages(path, token) {
31 + const rows = [];
32 + for (let page = 1; ; page += 1) {
33 + const separator = path.includes("?") ? "&" : "?";
34 + const result = await request(`${path}${separator}per_page=100&page=${page}`, token);
35 + const pageRows = Array.isArray(result) ? result : result.repositories;
36 + rows.push(...pageRows);
37 + if (pageRows.length < 100) return rows;
38 + }
39 +}
40 +
41 +async function mapLimit(items, limit, worker) {
42 + const results = new Array(items.length);
43 + let cursor = 0;
44 + async function run() {
45 + while (cursor < items.length) {
46 + const index = cursor;
47 + cursor += 1;
48 + results[index] = await worker(items[index], index);
49 + }
50 + }
51 + await Promise.all(Array.from({ length: Math.min(limit, items.length) }, run));
52 + return results;
53 +}
54 +
55 +async function completeTree(root, branch, token) {
56 + const recursive = await request(`${root}/git/trees/${encodeURIComponent(branch)}?recursive=1`, token);
57 + if (!recursive.truncated) return recursive.tree;
58 +
59 + const files = [];
60 + const pending = [{ sha: branch, prefix: "" }];
61 + while (pending.length) {
62 + const batch = pending.splice(0, 8);
63 + const trees = await Promise.all(batch.map((item) => request(
64 + `${root}/git/trees/${encodeURIComponent(item.sha)}`,
65 + token,
66 + ).then((tree) => ({ ...item, tree: tree.tree }))));
67 + for (const current of trees) {
68 + for (const entry of current.tree) {
69 + const path = current.prefix ? `${current.prefix}/${entry.path}` : entry.path;
70 + if (entry.type === "tree") pending.push({ sha: entry.sha, prefix: path });
71 + else files.push({ ...entry, path });
72 + }
73 + }
74 + }
75 + return files;
76 +}
77 +
78 +async function commitWithAllFiles(root, sha, token) {
79 + let detail;
80 + const files = [];
81 + for (let page = 1; page <= 30; page += 1) {
82 + const current = await request(
83 + `${root}/commits/${encodeURIComponent(sha)}?per_page=100&page=${page}`,
84 + token,
85 + );
86 + if (!detail) detail = current;
87 + files.push(...(current.files || []));
88 + if (!current.files || current.files.length < 100) break;
89 + }
90 + return { ...detail, files };
91 +}
92 +
93 +function decodeFile(content) {
94 + const buffer = Buffer.from(content.replace(/\n/g, ""), "base64");
95 + if (buffer.includes(0)) return null;
96 + return buffer.toString("utf8");
97 +}
98 +
99 +export function createGitHubClient(config) {
100 + async function requestUserToken(body) {
101 + let response;
102 + try {
103 + response = await fetch("https://github.com/login/oauth/access_token", {
104 + method: "POST",
105 + signal: AbortSignal.timeout(20_000),
106 + headers: { Accept: "application/json", "Content-Type": "application/json" },
107 + body: JSON.stringify({
108 + client_id: config.clientId,
109 + client_secret: config.clientSecret,
110 + ...body,
111 + }),
112 + });
113 + } catch (error) {
114 + if (error.name === "TimeoutError" || error.name === "AbortError") {
115 + throw new Error("GitHub did not respond in time. Start the sign-in again.");
116 + }
117 + throw error;
118 + }
119 + const data = await response.json();
120 + if (!response.ok || data.error || !data.access_token) {
121 + throw new Error(data.error_description || "GitHub sign-in failed");
122 + }
123 + return {
124 + accessToken: data.access_token,
125 + refreshToken: data.refresh_token || null,
126 + expiresAt: data.expires_in
127 + ? new Date(Date.now() + data.expires_in * 1000).toISOString()
128 + : null,
129 + };
130 + }
131 +
132 + return {
133 + authorizationUrl(state) {
134 + const params = new URLSearchParams({
135 + client_id: config.clientId,
136 + redirect_uri: `${config.baseUrl}/auth/github/callback`,
137 + state,
138 + });
139 + return `https://github.com/login/oauth/authorize?${params}`;
140 + },
141 + installationUrl(state) {
142 + return `https://github.com/apps/${encodeURIComponent(config.appSlug)}/installations/new?state=${encodeURIComponent(state)}`;
143 + },
144 + async exchangeCode(code) {
145 + return requestUserToken({ code });
146 + },
147 + refreshUserToken(refreshToken) {
148 + return requestUserToken({
149 + grant_type: "refresh_token",
150 + refresh_token: refreshToken,
151 + });
152 + },
153 + getViewer(token) {
154 + return request("/user", token);
155 + },
156 + async listRepositories(token, installationId) {
157 + const repos = await allPages(`/user/installations/${installationId}/repositories`, token);
158 + return repos.map((repo) => ({
159 + id: repo.id,
160 + name: repo.name,
161 + fullName: repo.full_name,
162 + description: repo.description,
163 + private: repo.private,
164 + language: repo.language,
165 + updatedAt: repo.updated_at,
166 + defaultBranch: repo.default_branch,
167 + owner: repo.owner.login,
168 + }));
169 + },
170 + async snapshotRepositories(token, selectedRepos) {
171 + return mapLimit(selectedRepos, 2, async (selected) => {
172 + const root = `/repos/${selected.fullName}`;
173 + const repo = await request(root, token);
174 + if (!repo.default_branch) {
175 + return {
176 + id: repo.id,
177 + name: repo.name,
178 + fullName: repo.full_name,
179 + description: repo.description,
180 + language: repo.language,
181 + defaultBranch: null,
182 + headSha: null,
183 + files: [],
184 + commits: [],
185 + };
186 + }
187 + const head = await request(`${root}/commits/${encodeURIComponent(repo.default_branch)}`, token);
188 + const [tree, commits] = await Promise.all([
189 + completeTree(root, head.commit.tree.sha, token),
190 + allPages(`${root}/commits?sha=${encodeURIComponent(head.sha)}`, token),
191 + ]);
192 + const files = await mapLimit(
193 + tree.filter((entry) => entry.type === "blob"),
194 + 8,
195 + async (entry) => {
196 + if (entry.size > 500_000) return { path: entry.path, size: entry.size, content: null, truncated: true };
197 + const blob = await request(`${root}/git/blobs/${entry.sha}`, token);
198 + return {
199 + path: entry.path,
200 + size: entry.size,
201 + content: blob.encoding === "base64" ? decodeFile(blob.content) : null,
202 + truncated: false,
203 + };
204 + },
205 + );
206 + const commitDetails = await mapLimit(commits, 4, async (commit) => {
207 + const detail = await commitWithAllFiles(root, commit.sha, token);
208 + return {
209 + sha: commit.sha,
210 + message: commit.commit.message,
211 + author: commit.commit.author.name,
212 + date: commit.commit.author.date,
213 + additions: detail.stats?.additions || 0,
214 + deletions: detail.stats?.deletions || 0,
215 + files: (detail.files || []).map((file) => ({
216 + filename: file.filename,
217 + status: file.status,
218 + additions: file.additions,
219 + deletions: file.deletions,
220 + patch: file.patch || "",
221 + })),
222 + };
223 + });
224 + return {
225 + id: repo.id,
226 + name: repo.name,
227 + fullName: repo.full_name,
228 + description: repo.description,
229 + language: repo.language,
230 + defaultBranch: repo.default_branch,
231 + headSha: head.sha,
232 + files,
233 + commits: commitDetails,
234 + };
235 + });
236 + },
237 + };
238 +}
added src/public/app.js +30 −0
@@ -0,0 +1,30 @@
1 +const copyButton = document.querySelector("#copy-link");
2 +
3 +copyButton?.addEventListener("click", async () => {
4 + const shareUrl = document.querySelector("#share-url");
5 + await navigator.clipboard.writeText(shareUrl.value);
6 + copyButton.textContent = "Copied";
7 +});
8 +
9 +const shareForm = document.querySelector(".share-form");
10 +const repositoryInputs = [...document.querySelectorAll('input[name="repositories"]')];
11 +const selectionCount = document.querySelector("#selection-count");
12 +const createButton = shareForm?.querySelector('button[type="submit"]');
13 +const creationStatus = document.querySelector("#creation-status");
14 +
15 +function updateSelection() {
16 + const count = repositoryInputs.filter((input) => input.checked).length;
17 + if (selectionCount) {
18 + selectionCount.textContent = `${count} ${count === 1 ? "repository" : "repositories"} selected`;
19 + }
20 + if (createButton) createButton.disabled = count === 0;
21 +}
22 +
23 +repositoryInputs.forEach((input) => input.addEventListener("change", updateSelection));
24 +updateSelection();
25 +
26 +shareForm?.addEventListener("submit", () => {
27 + createButton.disabled = true;
28 + createButton.textContent = "Creating snapshot...";
29 + creationStatus.textContent = "Creating the snapshot. Large repositories may take a few minutes.";
30 +});
added src/public/styles.css +248 −0
@@ -0,0 +1,248 @@
1 +:root {
2 + --ink: #101a2c;
3 + --ink-soft: #536078;
4 + --paper: #f6f8fc;
5 + --white: #ffffff;
6 + --line: #dce3ef;
7 + --blue: #3158d4;
8 + --blue-dark: #223d9a;
9 + --blue-pale: #e8edff;
10 + --cyan: #75d5e8;
11 + --green: #17865b;
12 + --red: #c6404b;
13 + --viewer: #111a2b;
14 + --shadow: 0 18px 50px rgba(21, 40, 82, .11);
15 +}
16 +
17 +* { box-sizing: border-box; }
18 +html { min-width: 1024px; background: var(--paper); }
19 +body { margin: 0; color: var(--ink); font-family: "Segoe UI", Arial, sans-serif; line-height: 1.5; }
20 +a { color: inherit; }
21 +button, input { font: inherit; }
22 +.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0, 0, 0, 0); }
23 +.wordmark { font-family: Georgia, serif; font-size: 22px; font-weight: 700; letter-spacing: -.04em; text-decoration: none; }
24 +.wordmark span { color: var(--blue); }
25 +.wordmark-light { color: white; }
26 +.wordmark-light span { color: var(--cyan); }
27 +.eyebrow, .step-label { margin: 0 0 9px; color: var(--blue); font-size: 11px; font-weight: 800; letter-spacing: .14em; text-transform: uppercase; }
28 +.button { display: inline-flex; min-height: 44px; align-items: center; justify-content: center; padding: 0 18px; border: 1px solid transparent; border-radius: 7px; font-weight: 700; text-decoration: none; cursor: pointer; }
29 +.button-primary { background: var(--blue); color: white; box-shadow: 0 6px 18px rgba(49, 88, 212, .2); }
30 +.button-primary:hover { background: var(--blue-dark); }
31 +.button-secondary { border-color: var(--line); background: white; color: var(--ink); }
32 +.button:disabled { opacity: .45; cursor: not-allowed; }
33 +.text-link { color: var(--blue); font-weight: 700; text-decoration: none; }
34 +a:focus-visible, button:focus-visible, input:focus-visible { outline: 3px solid var(--cyan); outline-offset: 3px; }
35 +
36 +.owner-page { min-height: 100vh; background: radial-gradient(circle at 82% 10%, rgba(117, 213, 232, .2), transparent 24%), var(--paper); }
37 +.owner-header { height: 74px; display: flex; align-items: center; justify-content: space-between; padding: 0 5vw; border-bottom: 1px solid var(--line); background: rgba(255,255,255,.82); }
38 +.owner-identity { display: flex; align-items: center; gap: 10px; font-weight: 700; }
39 +.owner-identity img { width: 32px; height: 32px; border-radius: 50%; }
40 +.owner-shell { width: min(1180px, 90vw); margin: 64px auto; }
41 +.welcome-panel { width: min(730px, 65vw); padding: 70px; border: 1px solid var(--line); border-radius: 20px; background: rgba(255,255,255,.94); box-shadow: var(--shadow); }
42 +.welcome-panel h1 { max-width: 620px; margin: 0; font: 700 54px/1.05 Georgia, serif; letter-spacing: -.045em; }
43 +.lede { max-width: 590px; margin: 24px 0 34px; color: var(--ink-soft); font-size: 19px; }
44 +.privacy-note { display: flex; gap: 10px; margin-top: 34px; padding-top: 23px; border-top: 1px solid var(--line); color: var(--ink-soft); font-size: 14px; }
45 +.privacy-note strong { color: var(--ink); }
46 +.notice { padding: 13px 16px; border: 1px solid #c7d6f3; border-radius: 7px; background: #edf3ff; }
47 +.notice-error { border-color: #f0b9bf; background: #fff0f1; color: #8d2631; }
48 +.setup-panel { max-width: 660px; padding: 50px; border: 1px solid var(--line); border-radius: 16px; background: white; box-shadow: var(--shadow); }
49 +.setup-panel h1, .workspace-heading h1 { margin: 0 0 12px; font: 700 40px/1.1 Georgia, serif; letter-spacing: -.035em; }
50 +.setup-panel p:not(.step-label) { color: var(--ink-soft); font-size: 17px; }
51 +.setup-panel .button { margin-top: 15px; }
52 +.workspace-heading { display: flex; align-items: flex-end; justify-content: space-between; margin-bottom: 28px; }
53 +.share-form { display: grid; grid-template-columns: minmax(0, 1fr) 310px; gap: 28px; align-items: start; }
54 +.share-form fieldset { min-width: 0; margin: 0; padding: 0; border: 0; }
55 +.repo-selection { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
56 +.repo-choice { position: relative; display: flex; min-height: 142px; gap: 15px; padding: 21px; border: 1px solid var(--line); border-radius: 11px; background: white; cursor: pointer; transition: border-color .15s, transform .15s; }
57 +.repo-choice:hover { border-color: #97a9d6; transform: translateY(-1px); }
58 +.repo-choice:has(input:checked) { border-color: var(--blue); box-shadow: inset 0 0 0 1px var(--blue); background: #f8faff; }
59 +.repo-choice:has(input:focus-visible) { outline: 3px solid var(--cyan); outline-offset: 3px; }
60 +.repo-choice input { position: absolute; opacity: 0; }
61 +.choice-mark { width: 20px; height: 20px; flex: 0 0 auto; border: 1.5px solid #a8b4c8; border-radius: 5px; }
62 +.repo-choice input:checked + .choice-mark { border-color: var(--blue); background: var(--blue); box-shadow: inset 0 0 0 4px white; }
63 +.repo-choice-copy { display: flex; min-width: 0; flex: 1; flex-direction: column; }
64 +.repo-choice-title { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
65 +.repo-choice-title strong { overflow: hidden; text-overflow: ellipsis; font-size: 17px; }
66 +.visibility { padding: 2px 7px; border: 1px solid var(--line); border-radius: 20px; color: var(--ink-soft); font-size: 10px; text-transform: uppercase; }
67 +.repo-description { margin: 10px 0; color: var(--ink-soft); font-size: 14px; }
68 +.repo-meta { margin-top: auto; color: #738098; font-size: 12px; }
69 +.publish-panel { position: sticky; top: 25px; padding: 26px; border-radius: 12px; background: var(--ink); color: white; box-shadow: var(--shadow); }
70 +.publish-panel .step-label { color: var(--cyan); }
71 +.publish-panel label { display: block; font-weight: 700; }
72 +.duration-input { display: flex; align-items: center; margin: 9px 0 15px; border: 1px solid #41506a; border-radius: 7px; background: #19243a; }
73 +.duration-input input { width: 100%; padding: 12px; border: 0; outline: 0; background: transparent; color: white; font-size: 19px; }
74 +.duration-input span { padding-right: 13px; color: #b6c1d5; }
75 +.publish-panel p:not(.step-label) { color: #b6c1d5; font-size: 13px; }
76 +.publish-panel .selection-count { color: white; font-weight: 700; }
77 +.publish-panel .button { width: 100%; margin-top: 8px; }
78 +.center-shell { min-height: 100vh; display: grid; place-items: center; padding: 50px; }
79 +.created-panel { width: min(720px, 80vw); padding: 56px; border: 1px solid var(--line); border-radius: 18px; background: white; box-shadow: var(--shadow); }
80 +.created-panel h1 { margin: 15px 0; font: 700 44px/1.1 Georgia, serif; }
81 +.snapshot-seal { display: inline-block; margin: 0; padding: 6px 10px; border: 1px solid var(--blue); color: var(--blue); font-size: 11px; font-weight: 800; letter-spacing: .12em; text-transform: uppercase; transform: rotate(-1.5deg); }
82 +.copy-row { display: flex; gap: 8px; margin: 28px 0 15px; }
83 +.copy-row input { min-width: 0; flex: 1; padding: 0 13px; border: 1px solid var(--line); border-radius: 7px; background: var(--paper); }
84 +.created-actions { display: flex; align-items: center; gap: 20px; }
85 +.review-summary { margin: 28px 0; padding: 20px; border: 1px solid var(--line); border-radius: 9px; background: var(--paper); }
86 +.review-summary h2 { margin: 0; font: 700 20px Georgia, serif; }
87 +.review-summary p { margin: 6px 0 15px; color: var(--ink-soft); font-size: 13px; }
88 +.review-summary ul { margin: 0; padding: 0; list-style: none; }
89 +.review-summary li { display: flex; justify-content: space-between; padding: 8px 0; border-top: 1px solid var(--line); font-size: 13px; }
90 +.review-summary li span { color: var(--ink-soft); }
91 +
92 +.viewer-page { min-height: 100vh; background: #f4f6fa; }
93 +.viewer-header { height: 68px; display: flex; align-items: center; justify-content: space-between; padding: 0 4vw; background: var(--viewer); color: #b8c3d7; }
94 +.snapshot-meta, .top-nav { display: flex; align-items: center; gap: 10px; font-size: 12px; }
95 +.top-nav a { color: white; font-weight: 700; text-decoration: none; }
96 +.status-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--cyan); box-shadow: 0 0 0 4px rgba(117, 213, 232, .1); }
97 +.meta-divider { width: 1px; height: 15px; background: #40506b; }
98 +.profile-shell { width: min(1260px, 92vw); display: grid; grid-template-columns: 285px minmax(0, 1fr); gap: 58px; margin: 58px auto 90px; align-items: start; }
99 +.profile-card { position: sticky; top: 30px; padding: 28px; border: 1px solid var(--line); border-radius: 12px; background: white; box-shadow: 0 10px 30px rgba(24, 43, 78, .07); }
100 +.profile-avatar { width: 108px; height: 108px; margin-bottom: 24px; border-radius: 12px; object-fit: cover; filter: saturate(.85); }
101 +.profile-card h1 { margin: 0; font: 700 31px/1.08 Georgia, serif; letter-spacing: -.035em; }
102 +.profile-login { margin: 5px 0 18px; color: var(--ink-soft); }
103 +.profile-bio { font-size: 14px; }
104 +.profile-count { display: flex; align-items: baseline; gap: 8px; margin-top: 25px; padding-top: 18px; border-top: 1px solid var(--line); }
105 +.profile-count strong { font: 700 29px Georgia, serif; }
106 +.profile-count span { color: var(--ink-soft); font-size: 12px; }
107 +.section-heading { display: flex; align-items: end; justify-content: space-between; margin-bottom: 20px; }
108 +.section-heading h2 { margin: 0; font: 700 27px Georgia, serif; letter-spacing: -.025em; }
109 +.read-only-badge { padding: 5px 9px; border: 1px solid #bfd1c9; border-radius: 20px; color: var(--green); font-size: 10px; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; }
110 +.repo-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
111 +.repo-card { display: flex; min-height: 210px; flex-direction: column; padding: 23px; border: 1px solid var(--line); border-radius: 10px; background: white; color: inherit; text-decoration: none; transition: transform .15s, border-color .15s, box-shadow .15s; }
112 +.repo-card:hover { border-color: #9badd9; box-shadow: 0 12px 28px rgba(32, 57, 110, .09); transform: translateY(-2px); }
113 +.repo-card-top { display: flex; justify-content: space-between; color: var(--blue); }
114 +.folder-icon { font-size: 23px; font-weight: 700; }
115 +.language { color: var(--ink-soft); font-size: 11px; text-transform: uppercase; }
116 +.repo-card h3 { margin: 20px 0 7px; font: 700 21px Georgia, serif; }
117 +.repo-card p { margin: 0; color: var(--ink-soft); font-size: 14px; }
118 +.repo-card-foot { display: flex; gap: 16px; margin-top: auto; padding-top: 19px; color: #728098; font-size: 12px; }
119 +.open-mark { margin-left: auto; color: var(--blue); font-weight: 700; }
120 +.activity-section { margin-top: 55px; }
121 +.activity-list { position: relative; margin: 0; padding: 0; list-style: none; }
122 +.activity-list:before { position: absolute; top: 11px; bottom: 11px; left: 5px; width: 1px; background: #cbd5e5; content: ""; }
123 +.activity-list li { position: relative; display: flex; gap: 19px; padding: 0 0 22px; }
124 +.activity-node { z-index: 1; width: 11px; height: 11px; flex: 0 0 auto; margin-top: 6px; border: 3px solid #f4f6fa; border-radius: 50%; background: var(--blue); box-shadow: 0 0 0 1px var(--blue); }
125 +.activity-list strong, .activity-list span { display: block; }
126 +.activity-list a { color: inherit; text-decoration: none; }
127 +.activity-list a:hover strong { color: var(--blue); }
128 +.activity-list strong { font-size: 14px; }
129 +.activity-list span { margin-top: 3px; color: var(--ink-soft); font-size: 12px; }
130 +
131 +.repository-page { background: #f6f8fa; color: #1f2328; }
132 +.repo-mast { border-bottom: 1px solid #d8dee4; background: #fff; }
133 +.repo-mast-inner { width: min(1180px, 92vw); margin: 0 auto; padding: 20px 0 18px; }
134 +.repo-identity { display: flex; align-items: center; gap: 9px; }
135 +.repo-mark { width: 20px; height: 20px; color: #636c76; }
136 +.repo-icon { display: block; flex: none; fill: currentColor; overflow: visible; }
137 +.repo-icon-mark { width: 20px; height: 20px; max-width: 20px; max-height: 20px; }
138 +.repo-icon-tab, .repo-icon-file { width: 16px; height: 16px; max-width: 16px; max-height: 16px; }
139 +.repo-icon-branch { width: 14px; height: 14px; max-width: 14px; max-height: 14px; }
140 +.repo-identity h1 { display: flex; align-items: baseline; gap: 7px; margin: 0; font: 400 22px/1.3 "Segoe UI", Arial, sans-serif; letter-spacing: -.02em; }
141 +.repo-identity h1 a { color: #0969da; text-decoration: none; }
142 +.repo-identity h1 span { color: #636c76; }
143 +.repo-identity h1 strong { color: #0969da; font-weight: 650; }
144 +.repo-visibility { padding: 2px 8px; border: 1px solid #d0d7de; border-radius: 20px; color: #636c76; font-size: 12px; font-weight: 600; }
145 +.repo-summary { max-width: 760px; margin: 12px 0 14px; color: #4d5661; font-size: 14px; }
146 +.repo-facts { display: flex; gap: 22px; color: #636c76; font-size: 12px; }
147 +.repo-facts strong { color: #1f2328; font-weight: 600; }
148 +.repo-tabs { border-bottom: 1px solid #d8dee4; background: #fff; }
149 +.repo-tabs-inner { width: min(1180px, 92vw); height: 49px; display: flex; align-items: stretch; gap: 4px; margin: 0 auto; }
150 +.repo-tabs a { position: relative; display: flex; align-items: center; gap: 7px; padding: 0 14px; color: #1f2328; font-size: 14px; text-decoration: none; }
151 +.repo-tabs a:hover { background: #f6f8fa; border-radius: 6px 6px 0 0; }
152 +.repo-tabs a.active:after { position: absolute; right: 9px; bottom: -1px; left: 9px; height: 2px; border-radius: 2px; background: #fd8c73; content: ""; }
153 +.repo-tabs .repo-icon { color: #636c76; }
154 +.tab-count { min-width: 22px; padding: 0 6px; border-radius: 20px; background: #eaeef2; font-size: 12px; font-weight: 600; text-align: center; }
155 +.repository-shell { width: min(1180px, 92vw); margin: 24px auto 80px; }
156 +.code-toolbar { height: 34px; display: flex; align-items: center; gap: 14px; margin-bottom: 12px; }
157 +.branch-pill, .quiet-button { height: 32px; display: inline-flex; align-items: center; gap: 7px; padding: 0 12px; border: 1px solid #d0d7de; border-radius: 6px; background: #f6f8fa; color: #24292f; font-size: 13px; font-weight: 600; line-height: 1; text-decoration: none; box-shadow: 0 1px 0 rgba(27,31,36,.04); }
158 +.head-reference { color: #636c76; font-size: 12px; }
159 +.head-reference code { color: #24292f; }
160 +.commit-shortcut { display: flex; align-items: center; gap: 6px; margin-left: auto; color: #57606a; font-size: 13px; text-decoration: none; }
161 +.commit-shortcut:hover { color: #0969da; }
162 +.browser-card, .readme-card, .commits-page, .file-view-card, .commit-detail-card { overflow: hidden; border: 1px solid #d0d7de; border-radius: 7px; background: #fff; box-shadow: 0 1px 0 rgba(27,31,36,.03); }
163 +.latest-commit { min-height: 52px; display: grid; grid-template-columns: 28px auto minmax(120px, 1fr) auto auto; gap: 9px; align-items: center; padding: 0 16px; border-bottom: 1px solid #d8dee4; color: #1f2328; font-size: 12px; text-decoration: none; }
164 +.latest-commit:hover .latest-message { color: #0969da; }
165 +.commit-avatar { width: 28px; height: 28px; display: grid; place-items: center; flex: 0 0 auto; border-radius: 50%; background: #dde7ff; color: #3158d4; font-size: 11px; font-weight: 750; }
166 +.latest-message { overflow: hidden; color: #4d5661; text-overflow: ellipsis; white-space: nowrap; }
167 +.latest-commit code, .latest-commit time { color: #636c76; white-space: nowrap; }
168 +.path-bar, .repo-breadcrumbs { min-height: 43px; display: flex; align-items: center; gap: 7px; padding: 0 16px; border-bottom: 1px solid #d8dee4; color: #636c76; font-size: 13px; }
169 +.path-bar a, .repo-breadcrumbs a { color: #0969da; font-weight: 600; text-decoration: none; }
170 +.repo-breadcrumbs { width: fit-content; min-height: auto; margin-bottom: 14px; padding: 0; border: 0; }
171 +.file-row { min-height: 44px; display: grid; grid-template-columns: 24px minmax(0, 1fr) auto; align-items: center; gap: 7px; padding: 0 16px; border-bottom: 1px solid #eaeef2; color: #1f2328; font-size: 13px; text-decoration: none; }
172 +.file-row:last-child { border-bottom: 0; }
173 +.file-row:hover { background: #f6f8fa; }
174 +.file-row strong { color: #0969da; font-weight: 500; }
175 +.file-row > span:last-child { color: #636c76; font-size: 12px; }
176 +.file-symbol { width: 16px; height: 16px; display: grid; place-items: center; color: #54aeff; }
177 +.file-symbol-document { color: #636c76; }
178 +.back-symbol { color: #636c76; font-size: 17px; }
179 +.readme-card { margin-top: 20px; }
180 +.readme-card > header { height: 49px; display: flex; align-items: center; justify-content: space-between; padding: 0 18px; border-bottom: 1px solid #d8dee4; background: #f6f8fa; font-size: 13px; }
181 +.readme-card > header div { display: flex; align-items: center; gap: 8px; }
182 +.readme-card > header .repo-icon { color: #636c76; }
183 +.readme-card > header a { color: #0969da; font-size: 12px; text-decoration: none; }
184 +.markdown-body { max-width: 980px; padding: 34px 42px 50px; color: #1f2328; font-size: 16px; line-height: 1.6; overflow-wrap: anywhere; }
185 +.markdown-body > :first-child { margin-top: 0 !important; }
186 +.markdown-body > :last-child { margin-bottom: 0 !important; }
187 +.markdown-body h1, .markdown-body h2 { margin: 24px 0 16px; padding-bottom: 9px; border-bottom: 1px solid #d8dee4; font-family: "Segoe UI", Arial, sans-serif; line-height: 1.25; letter-spacing: -.025em; }
188 +.markdown-body h1 { font-size: 30px; }
189 +.markdown-body h2 { font-size: 24px; }
190 +.markdown-body h3 { margin: 24px 0 12px; font-size: 19px; }
191 +.markdown-body p, .markdown-body ul, .markdown-body ol, .markdown-body blockquote, .markdown-body table, .markdown-body pre { margin: 0 0 16px; }
192 +.markdown-body a { color: #0969da; text-decoration: none; }
193 +.markdown-body a:hover { text-decoration: underline; }
194 +.markdown-body code { padding: 2px 5px; border-radius: 4px; background: rgba(175,184,193,.2); font: 85% Consolas, monospace; }
195 +.markdown-body pre { padding: 16px; border-radius: 6px; background: #f6f8fa; overflow: auto; }
196 +.markdown-body pre code { padding: 0; background: transparent; font-size: 13px; }
197 +.markdown-body blockquote { padding: 0 16px; border-left: 4px solid #d0d7de; color: #636c76; }
198 +.markdown-body table { width: max-content; max-width: 100%; border-collapse: collapse; overflow: auto; }
199 +.markdown-body th, .markdown-body td { padding: 7px 13px; border: 1px solid #d0d7de; }
200 +.markdown-body tr:nth-child(2n) { background: #f6f8fa; }
201 +.markdown-body hr { height: 4px; margin: 24px 0; border: 0; background: #d8dee4; }
202 +.readme-empty { margin-top: 20px; padding: 30px; border: 1px dashed #afb8c1; border-radius: 7px; background: #fff; text-align: center; }
203 +.readme-empty p { margin: 5px 0 0; color: #636c76; font-size: 13px; }
204 +.section-title-row { min-height: 84px; display: flex; align-items: center; justify-content: space-between; padding: 0 22px; border-bottom: 1px solid #d8dee4; }
205 +.section-title-row h2 { margin: 0; font: 650 22px/1.2 "Segoe UI", Arial, sans-serif; }
206 +.section-kicker { margin: 0 0 3px; color: #636c76; font-size: 12px; }
207 +.commit-feed { margin: 0; padding: 0; list-style: none; }
208 +.commit-feed li { min-height: 72px; display: grid; grid-template-columns: 34px minmax(0, 1fr) auto 68px; gap: 12px; align-items: center; padding: 10px 18px; border-bottom: 1px solid #eaeef2; }
209 +.commit-feed li:last-child { border-bottom: 0; }
210 +.commit-feed li:hover { background: #f6f8fa; }
211 +.commit-main { min-width: 0; color: #1f2328; text-decoration: none; }
212 +.commit-main strong, .commit-main span { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
213 +.commit-main strong { font-size: 14px; }
214 +.commit-main span { margin-top: 4px; color: #636c76; font-size: 12px; }
215 +.commit-feed code { padding: 3px 6px; border: 1px solid #d0d7de; border-radius: 5px; color: #0969da; font-size: 11px; text-align: center; }
216 +.commit-change { display: flex; gap: 7px; font: 600 11px Consolas, monospace; }
217 +.commit-change b { color: #1a7f37; }
218 +.commit-change i { color: #cf222e; font-style: normal; }
219 +.file-view-header { min-height: 54px; display: flex; align-items: center; justify-content: space-between; padding: 0 17px; border-bottom: 1px solid #d8dee4; background: #f6f8fa; font-size: 13px; }
220 +.file-view-header div { display: flex; gap: 10px; }
221 +.file-view-header span { color: #636c76; }
222 +.file-view-header a { color: #0969da; font-weight: 600; text-decoration: none; }
223 +.code-view { max-height: 760px; margin: 0; padding: 22px 26px; overflow: auto; background: #fff; color: #24292f; font: 13px/1.65 Consolas, monospace; tab-size: 2; }
224 +.commit-detail-heading { min-height: 112px; display: flex; align-items: center; justify-content: space-between; padding: 22px 24px; border-bottom: 1px solid #d8dee4; background: #f6f8fa; }
225 +.commit-detail-heading h2 { margin: 4px 0 7px; font: 650 22px/1.3 "Segoe UI", Arial, sans-serif; }
226 +.commit-detail-heading p { margin: 0; color: #636c76; font-size: 12px; }
227 +.diff-total { display: flex; gap: 12px; font: 700 13px Consolas, monospace; }
228 +.diff-total span:first-child { color: #1a7f37; }
229 +.diff-total span:last-child { color: #cf222e; }
230 +.diff-file { margin: 20px 22px; border: 1px solid #d0d7de; border-radius: 6px; overflow: hidden; }
231 +.diff-file header { display: flex; justify-content: space-between; padding: 10px 13px; background: #f6f8fa; font-size: 12px; }
232 +.diff-file header span { color: #636c76; }
233 +.diff-file pre { margin: 0; overflow: auto; background: #fff; font: 12px/1.55 Consolas, monospace; }
234 +.diff-file pre span { display: block; min-height: 19px; padding: 0 12px; white-space: pre; }
235 +.diff-file .addition { background: #dafbe1; color: #116329; }
236 +.diff-file .deletion { background: #ffebe9; color: #82071e; }
237 +.empty-state { margin: 0; padding: 32px; color: var(--ink-soft); text-align: center; }
238 +
239 +.state-page { min-height: 100vh; display: grid; place-items: center; background: var(--viewer); color: white; }
240 +.state-shell { max-width: 620px; padding: 50px; text-align: center; }
241 +.state-code { color: var(--cyan); font-size: 12px; font-weight: 800; letter-spacing: .15em; text-transform: uppercase; }
242 +.state-shell h1 { margin: 10px 0 15px; font: 700 46px/1.1 Georgia, serif; }
243 +.state-shell > p:not(.state-code) { color: #b8c3d7; }
244 +.state-shell .text-link { color: var(--cyan); }
245 +
246 +@media (prefers-reduced-motion: reduce) {
247 + *, *:before, *:after { scroll-behavior: auto !important; transition: none !important; }
248 +}
added src/server.js +13 −0
@@ -0,0 +1,13 @@
1 +import { loadConfig } from "./config.js";
2 +import { createStore } from "./db.js";
3 +import { createGitHubClient } from "./github.js";
4 +import { createApp } from "./app.js";
5 +
6 +const config = loadConfig();
7 +const store = createStore(config.databasePath, config.sessionSecret);
8 +const github = createGitHubClient({ ...config.github, baseUrl: config.baseUrl });
9 +const app = createApp({ config, store, github });
10 +
11 +app.listen(config.port, () => {
12 + console.log(`profileShare is running at ${config.baseUrl}`);
13 +});
added src/views/created.ejs +27 −0
@@ -0,0 +1,27 @@
1 +<%- include("partials/head", { title: "Link created", bodyClass: "owner-page" }) %>
2 +<main class="center-shell">
3 + <section class="created-panel">
4 + <p class="snapshot-seal">Snapshot created</p>
5 + <h1>Your review link is ready.</h1>
6 + <p>Anyone with this link can view the selected work until <strong><%= formatDate(expiresAt) %></strong>.</p>
7 + <section class="review-summary">
8 + <h2>Review before sharing</h2>
9 + <p>Open the snapshot and confirm every selected repository and all commits appear.</p>
10 + <ul>
11 + <% repositories.forEach((repository) => { %>
12 + <li><strong><%= repository.name %></strong><span><%= repository.commits.length %> <%= repository.commits.length === 1 ? "commit" : "commits" %></span></li>
13 + <% }) %>
14 + </ul>
15 + </section>
16 + <div class="copy-row">
17 + <input id="share-url" value="<%= url %>" readonly aria-label="Share URL">
18 + <button class="button button-primary" id="copy-link" type="button">Copy link</button>
19 + </div>
20 + <div class="created-actions">
21 + <a class="button button-secondary" href="<%= url %>">Review snapshot before sharing</a>
22 + <a class="text-link" href="/">Create another</a>
23 + </div>
24 + </section>
25 +</main>
26 +<script src="/assets/app.js" defer></script>
27 +<%- include("partials/foot") %>
added src/views/dashboard.ejs +83 −0
@@ -0,0 +1,83 @@
1 +<%- include("partials/head", { title: "Owner workspace", bodyClass: "owner-page" }) %>
2 +<header class="owner-header">
3 + <a class="wordmark" href="/">profile<span>Share</span></a>
4 + <% if (owner) { %>
5 + <div class="owner-identity">
6 + <img src="<%= owner.avatar_url %>" alt="">
7 + <span><%= owner.name || owner.login %></span>
8 + </div>
9 + <% } %>
10 +</header>
11 +
12 +<main class="owner-shell">
13 + <% if (error) { %><p class="notice notice-error"><%= error %></p><% } %>
14 + <% if (!owner) { %>
15 + <section class="welcome-panel">
16 + <p class="eyebrow">Private work, shared on your terms</p>
17 + <h1>Open a window into your GitHub work.</h1>
18 + <p class="lede">Choose specific repositories, freeze them into a read-only snapshot, and send one expiring link.</p>
19 + <% if (appConfigured) { %>
20 + <a class="button button-primary" href="/auth/github">Continue with GitHub</a>
21 + <% } else { %>
22 + <p class="notice">Add the GitHub App settings from <code>.env.example</code> to begin.</p>
23 + <% } %>
24 + <div class="privacy-note">
25 + <strong>Repository access stays specific.</strong>
26 + <span>You choose which repositories the GitHub App can read.</span>
27 + </div>
28 + </section>
29 + <% } else if (!owner.installation_id) { %>
30 + <section class="setup-panel">
31 + <p class="step-label">One step left</p>
32 + <h1>Choose repository access</h1>
33 + <p>GitHub will ask which repositories profileShare can read. Select only the work you may want to share.</p>
34 + <a class="button button-primary" href="/github/install">Choose repositories on GitHub</a>
35 + </section>
36 + <% } else { %>
37 + <section class="workspace-heading">
38 + <div>
39 + <p class="eyebrow">New snapshot</p>
40 + <h1>Select the work to share</h1>
41 + </div>
42 + <a class="text-link" href="/github/install">Change GitHub access</a>
43 + </section>
44 + <form method="post" action="/shares" class="share-form">
45 + <fieldset>
46 + <legend class="sr-only">Repositories</legend>
47 + <div class="repo-selection">
48 + <% repos.forEach((repo) => { %>
49 + <label class="repo-choice">
50 + <input type="checkbox" name="repositories" value="<%= repo.id %>">
51 + <span class="choice-mark" aria-hidden="true"></span>
52 + <span class="repo-choice-copy">
53 + <span class="repo-choice-title">
54 + <strong><%= repo.name %></strong>
55 + <span class="visibility"><%= repo.private ? "Private" : "Public" %></span>
56 + </span>
57 + <span class="repo-description"><%= repo.description || "No description" %></span>
58 + <span class="repo-meta"><%= repo.language || "Mixed" %> · Updated <%= formatDate(repo.updatedAt) %></span>
59 + </span>
60 + </label>
61 + <% }) %>
62 + <% if (!repos.length) { %>
63 + <p class="empty-state">No repositories are available to this GitHub App installation.</p>
64 + <% } %>
65 + </div>
66 + </fieldset>
67 + <aside class="publish-panel">
68 + <p class="step-label">Link settings</p>
69 + <label for="days">Expires after</label>
70 + <div class="duration-input">
71 + <input id="days" name="days" type="number" min="1" max="365" value="7" required>
72 + <span>days</span>
73 + </div>
74 + <p>The shared view is frozen when you create it. Later GitHub changes will not appear.</p>
75 + <p id="selection-count" class="selection-count" aria-live="polite">0 repositories selected</p>
76 + <p id="creation-status" class="sr-only" aria-live="polite"></p>
77 + <button class="button button-primary" type="submit" <%= repos.length ? "" : "disabled" %>>Create snapshot link</button>
78 + </aside>
79 + </form>
80 + <% } %>
81 +</main>
82 +<script src="/assets/app.js" defer></script>
83 +<%- include("partials/foot") %>
added src/views/error.ejs +7 −0
@@ -0,0 +1,7 @@
1 +<%- include("partials/head", { title: "Unable to open", bodyClass: "state-page" }) %>
2 +<main class="state-shell">
3 + <p class="state-code">profileShare</p>
4 + <h1><%= message %></h1>
5 + <a class="text-link" href="/">Return to the start</a>
6 +</main>
7 +<%- include("partials/foot") %>
added src/views/expired.ejs +7 −0
@@ -0,0 +1,7 @@
1 +<%- include("partials/head", { title: "Link expired", bodyClass: "state-page" }) %>
2 +<main class="state-shell">
3 + <p class="state-code">410</p>
4 + <h1>The URL you have opened is expired.</h1>
5 + <p>The owner set a time limit for this snapshot, and its contents are no longer available.</p>
6 +</main>
7 +<%- include("partials/foot") %>
added src/views/partials/foot.ejs +2 −0
@@ -0,0 +1,2 @@
1 +</body>
2 +</html>
added src/views/partials/head.ejs +10 −0
@@ -0,0 +1,10 @@
1 +<!doctype html>
2 +<html lang="en">
3 +<head>
4 + <meta charset="utf-8">
5 + <meta name="viewport" content="width=device-width, initial-scale=1">
6 + <meta name="color-scheme" content="light">
7 + <title><%= title %> | profileShare</title>
8 + <link rel="stylesheet" href="/assets/styles.css">
9 +</head>
10 +<body class="<%= bodyClass || '' %>">
added src/views/profile.ejs +78 −0
@@ -0,0 +1,78 @@
1 +<%- include("partials/head", { title: share.snapshot.profile.name || share.snapshot.profile.login, bodyClass: "viewer-page" }) %>
2 +<header class="viewer-header">
3 + <a class="wordmark wordmark-light" href="/s/<%= share.id %>">profile<span>Share</span></a>
4 + <div class="snapshot-meta">
5 + <span class="status-dot"></span>
6 + Snapshot from <%= formatDate(share.created_at) %>
7 + <span class="meta-divider"></span>
8 + Expires <%= formatDate(share.expires_at) %>
9 + </div>
10 +</header>
11 +
12 +<main class="profile-shell">
13 + <aside class="profile-card">
14 + <img class="profile-avatar" src="<%= share.snapshot.profile.avatarUrl %>" alt="<%= share.snapshot.profile.name || share.snapshot.profile.login %>">
15 + <p class="eyebrow">Developer profile</p>
16 + <h1><%= share.snapshot.profile.name || share.snapshot.profile.login %></h1>
17 + <p class="profile-login">@<%= share.snapshot.profile.login %></p>
18 + <p class="profile-bio"><%= share.snapshot.profile.bio || "GitHub work selected for private review." %></p>
19 + <div class="profile-count">
20 + <strong><%= share.snapshot.repositories.length %></strong>
21 + <span>shared <%= share.snapshot.repositories.length === 1 ? "repository" : "repositories" %></span>
22 + </div>
23 + </aside>
24 +
25 + <section class="profile-content">
26 + <div class="section-heading">
27 + <div>
28 + <p class="eyebrow">Selected work</p>
29 + <h2>Repositories in this snapshot</h2>
30 + </div>
31 + <span class="read-only-badge">Read only</span>
32 + </div>
33 + <div class="repo-grid">
34 + <% share.snapshot.repositories.forEach((repo) => { %>
35 + <a class="repo-card" href="/s/<%= share.id %>/repositories/<%= repo.id %>">
36 + <div class="repo-card-top">
37 + <span class="folder-icon">↳</span>
38 + <span class="language"><%= repo.language || "Mixed" %></span>
39 + </div>
40 + <h3><%= repo.name %></h3>
41 + <p><%= repo.description || "No repository description." %></p>
42 + <div class="repo-card-foot">
43 + <span><%= repo.files.length %> files</span>
44 + <span><%= repo.commits.length %> commits</span>
45 + <span class="open-mark">Open →</span>
46 + </div>
47 + </a>
48 + <% }) %>
49 + <% if (!share.snapshot.repositories.length) { %>
50 + <p class="empty-state">No repositories are included in this snapshot.</p>
51 + <% } %>
52 + </div>
53 +
54 + <section class="activity-section">
55 + <div class="section-heading">
56 + <div>
57 + <p class="eyebrow">Profile activity</p>
58 + <h2>Recent work across the snapshot</h2>
59 + </div>
60 + </div>
61 + <ol class="activity-list">
62 + <% commits.slice(0, 12).forEach((commit) => { %>
63 + <li>
64 + <span class="activity-node"></span>
65 + <a href="/s/<%= share.id %>/repositories/<%= commit.repositoryId %>?commit=<%= commit.sha %>">
66 + <strong><%= commit.message.split("\n")[0] %></strong>
67 + <span><%= commit.repository %> · <%= formatDate(commit.date) %></span>
68 + </a>
69 + </li>
70 + <% }) %>
71 + <% if (!commits.length) { %>
72 + <li class="empty-state">No commit activity is included in this snapshot.</li>
73 + <% } %>
74 + </ol>
75 + </section>
76 + </section>
77 +</main>
78 +<%- include("partials/foot") %>
added src/views/repository.ejs +221 −0
@@ -0,0 +1,221 @@
1 +<%- include("partials/head", { title: repository.name, bodyClass: "viewer-page repository-page" }) %>
2 +<header class="viewer-header">
3 + <a class="wordmark wordmark-light" href="/s/<%= share.id %>">profile<span>Share</span></a>
4 + <nav class="top-nav" aria-label="Snapshot navigation">
5 + <a href="/s/<%= share.id %>">&larr; Back to profile</a>
6 + <span class="meta-divider"></span>
7 + <span>Snapshot from <%= formatDate(share.created_at) %></span>
8 + </nav>
9 +</header>
10 +
11 +<section class="repo-mast">
12 + <div class="repo-mast-inner">
13 + <div class="repo-identity">
14 + <span class="repo-mark" aria-hidden="true">
15 + <svg class="repo-icon repo-icon-mark" width="20" height="20" viewBox="0 0 24 24"><path d="M4 4.75A2.75 2.75 0 0 1 6.75 2h10.5A2.75 2.75 0 0 1 20 4.75v14.5A2.75 2.75 0 0 1 17.25 22H6.75A2.75 2.75 0 0 1 4 19.25V4.75Zm3.5-.75A1.5 1.5 0 0 0 6 5.5v13A1.5 1.5 0 0 0 7.5 20H9V4H7.5Z"/></svg>
16 + </span>
17 + <h1>
18 + <a href="/s/<%= share.id %>"><%= share.snapshot.profile.login %></a>
19 + <span>/</span>
20 + <strong><%= repository.name %></strong>
21 + </h1>
22 + <span class="repo-visibility">Read-only snapshot</span>
23 + </div>
24 + <p class="repo-summary"><%= repository.description || "No repository description." %></p>
25 + <div class="repo-facts">
26 + <span><strong><%= repository.defaultBranch || "No branch" %></strong> default branch</span>
27 + <span><strong><%= repository.files.length %></strong> files</span>
28 + <span>Expires <strong><%= formatDate(share.expires_at) %></strong></span>
29 + </div>
30 + </div>
31 +</section>
32 +
33 +<nav class="repo-tabs" aria-label="Repository sections">
34 + <div class="repo-tabs-inner">
35 + <a class="<%= tab === 'code' && !commit ? 'active' : '' %>" href="/s/<%= share.id %>/repositories/<%= repository.id %>" <%- tab === "code" && !commit ? 'aria-current="page"' : "" %>>
36 + <svg class="repo-icon repo-icon-tab" width="16" height="16" viewBox="0 0 24 24" aria-hidden="true"><path d="m8.7 17.3-5-5a1 1 0 0 1 0-1.4l5-5 1.4 1.4L5.8 11.6l4.3 4.3-1.4 1.4Zm6.6 0-1.4-1.4 4.3-4.3-4.3-4.3 1.4-1.4 5 5a1 1 0 0 1 0 1.4l-5 5Z"/></svg>
37 + Code
38 + </a>
39 + <a class="<%= tab === 'commits' || commit ? 'active' : '' %>" href="/s/<%= share.id %>/repositories/<%= repository.id %>?tab=commits" <%- tab === "commits" || commit ? 'aria-current="page"' : "" %>>
40 + <svg class="repo-icon repo-icon-tab" width="16" height="16" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 7a5 5 0 1 1-4.9 6H2v-2h5.1A5 5 0 0 1 12 7Zm0 2a3 3 0 1 0 0 6 3 3 0 0 0 0-6Zm4.9 2H22v2h-5.1v-2Z"/></svg>
41 + Commits
42 + <span class="tab-count"><%= repository.commits.length %></span>
43 + </a>
44 + </div>
45 +</nav>
46 +
47 +<main class="repository-shell">
48 + <% if (commit) { %>
49 + <nav class="repo-breadcrumbs" aria-label="Commit navigation">
50 + <a href="/s/<%= share.id %>/repositories/<%= repository.id %>?tab=commits">Commits</a>
51 + <span>/</span>
52 + <span><%= commit.sha.slice(0, 7) %></span>
53 + </nav>
54 + <section class="commit-detail-card">
55 + <header class="commit-detail-heading">
56 + <div>
57 + <p class="section-kicker">Commit <code><%= commit.sha.slice(0, 7) %></code></p>
58 + <h2><%= commit.message.split("\n")[0] %></h2>
59 + <p><strong><%= commit.author %></strong> committed <%= formatDate(commit.date) %></p>
60 + </div>
61 + <div class="diff-total" aria-label="Commit changes">
62 + <span>+<%= commit.additions %></span>
63 + <span>&minus;<%= commit.deletions %></span>
64 + </div>
65 + </header>
66 + <% commit.files.forEach((changed) => { %>
67 + <section class="diff-file">
68 + <header>
69 + <strong><%= changed.filename %></strong>
70 + <span><%= changed.status %> &middot; +<%= changed.additions %> &minus;<%= changed.deletions %></span>
71 + </header>
72 + <% if (!changed.patch) { %>
73 + <p class="empty-state">Line changes are not available for this file.</p>
74 + <% } else { %>
75 + <pre><% changed.patch.split("\n").forEach((line) => { let kind = line.startsWith("+") && !line.startsWith("+++") ? "addition" : line.startsWith("-") && !line.startsWith("---") ? "deletion" : ""; %><span class="<%= kind %>"><%= line %></span>
76 +<% }) %></pre>
77 + <% } %>
78 + </section>
79 + <% }) %>
80 + <% if (!commit.files.length) { %>
81 + <p class="empty-state">No changed files are available for this commit.</p>
82 + <% } %>
83 + </section>
84 + <% } else if (file) { %>
85 + <nav class="repo-breadcrumbs" aria-label="File path">
86 + <a href="/s/<%= share.id %>/repositories/<%= repository.id %>"><%= repository.name %></a>
87 + <% let filePath = ""; file.path.split("/").forEach((part, index, parts) => { filePath += (filePath ? "/" : "") + part; %>
88 + <span>/</span>
89 + <% if (index < parts.length - 1) { %>
90 + <a href="?path=<%= encodeURIComponent(filePath) %>"><%= part %></a>
91 + <% } else { %>
92 + <span><%= part %></span>
93 + <% } %>
94 + <% }) %>
95 + </nav>
96 + <section class="file-view-card">
97 + <header class="file-view-header">
98 + <div>
99 + <strong><%= file.path.split("/").pop() %></strong>
100 + <span><%= file.size.toLocaleString() %> bytes</span>
101 + </div>
102 + <a href="?path=<%= encodeURIComponent(file.path.includes('/') ? file.path.slice(0, file.path.lastIndexOf('/')) : '') %>">&larr; Back to files</a>
103 + </header>
104 + <% if (file.content === null) { %>
105 + <div class="empty-state">This file cannot be displayed as text<%= file.truncated ? " because it is larger than the snapshot preview limit." : "." %></div>
106 + <% } else { %>
107 + <pre class="code-view"><code><%= file.content %></code></pre>
108 + <% } %>
109 + </section>
110 + <% } else if (tab === "commits") { %>
111 + <section class="commits-page">
112 + <header class="section-title-row">
113 + <div>
114 + <p class="section-kicker"><%= repository.defaultBranch || "Repository" %> history</p>
115 + <h2><%= repository.commits.length %> <%= repository.commits.length === 1 ? "commit" : "commits" %></h2>
116 + </div>
117 + <a class="quiet-button" href="/s/<%= share.id %>/repositories/<%= repository.id %>">Browse code</a>
118 + </header>
119 + <ol class="commit-feed">
120 + <% repository.commits.forEach((item) => { %>
121 + <li>
122 + <span class="commit-avatar" aria-hidden="true"><%= item.author.slice(0, 1).toUpperCase() %></span>
123 + <a class="commit-main" href="?commit=<%= item.sha %>">
124 + <strong><%= item.message.split("\n")[0] %></strong>
125 + <span><%= item.author %> committed <%= formatDate(item.date) %></span>
126 + </a>
127 + <span class="commit-change"><b>+<%= item.additions %></b> <i>&minus;<%= item.deletions %></i></span>
128 + <code><%= item.sha.slice(0, 7) %></code>
129 + </li>
130 + <% }) %>
131 + <% if (!repository.commits.length) { %>
132 + <li class="empty-state">No commits are included in this snapshot.</li>
133 + <% } %>
134 + </ol>
135 + </section>
136 + <% } else { %>
137 + <section class="code-overview">
138 + <div class="code-toolbar">
139 + <span class="branch-pill">
140 + <svg class="repo-icon repo-icon-branch" width="14" height="14" viewBox="0 0 24 24" aria-hidden="true"><path d="M7 3a3 3 0 1 1-1 5.83v6.34A3.001 3.001 0 1 1 8 18v-3h4a4 4 0 0 0 4-4V8.83a3.001 3.001 0 1 1 2 0V11a6 6 0 0 1-6 6H8v1a3 3 0 0 1-2-2.83V8.83A3.001 3.001 0 0 1 7 3Z"/></svg>
141 + <%= repository.defaultBranch || "No branch" %>
142 + </span>
143 + <% if (repository.headSha) { %>
144 + <span class="head-reference">Snapshot head <code><%= repository.headSha.slice(0, 7) %></code></span>
145 + <% } %>
146 + <a class="commit-shortcut" href="?tab=commits">
147 + <svg class="repo-icon repo-icon-branch" width="14" height="14" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 7a5 5 0 1 1-4.9 6H2v-2h5.1A5 5 0 0 1 12 7Zm0 2a3 3 0 1 0 0 6 3 3 0 0 0 0-6Zm4.9 2H22v2h-5.1v-2Z"/></svg>
148 + <strong><%= repository.commits.length %></strong> commits
149 + </a>
150 + </div>
151 +
152 + <section class="browser-card">
153 + <% const latestCommit = repository.commits[0]; %>
154 + <% if (latestCommit) { %>
155 + <a class="latest-commit" href="?commit=<%= latestCommit.sha %>">
156 + <span class="commit-avatar" aria-hidden="true"><%= latestCommit.author.slice(0, 1).toUpperCase() %></span>
157 + <strong><%= latestCommit.author %></strong>
158 + <span class="latest-message"><%= latestCommit.message.split("\n")[0] %></span>
159 + <code><%= latestCommit.sha.slice(0, 7) %></code>
160 + <time><%= formatDate(latestCommit.date) %></time>
161 + </a>
162 + <% } %>
163 + <nav class="path-bar" aria-label="File path">
164 + <a href="/s/<%= share.id %>/repositories/<%= repository.id %>"><%= repository.name %></a>
165 + <% let builtPath = ""; path.split("/").filter(Boolean).forEach((part) => { builtPath += (builtPath ? "/" : "") + part; %>
166 + <span>/</span><a href="?path=<%= encodeURIComponent(builtPath) %>"><%= part %></a>
167 + <% }) %>
168 + </nav>
169 + <div class="file-table" role="table" aria-label="Repository files">
170 + <% if (path) { const parent = path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : ""; %>
171 + <a class="file-row" href="?path=<%= encodeURIComponent(parent) %>">
172 + <span class="file-symbol back-symbol" aria-hidden="true">&larr;</span>
173 + <strong>..</strong>
174 + <span>Parent folder</span>
175 + </a>
176 + <% } %>
177 + <% browser.folders.forEach((folder) => { const nextPath = path ? path + "/" + folder : folder; %>
178 + <a class="file-row" href="?path=<%= encodeURIComponent(nextPath) %>">
179 + <span class="file-symbol" aria-hidden="true"><svg class="repo-icon repo-icon-file" width="16" height="16" viewBox="0 0 24 24"><path d="M3 5.75C3 4.78 3.78 4 4.75 4h5.08c.46 0 .9.18 1.23.51L12.55 6H19.25C20.22 6 21 6.78 21 7.75v9.5c0 .97-.78 1.75-1.75 1.75H4.75C3.78 19 3 18.22 3 17.25V5.75Z"/></svg></span>
180 + <strong><%= folder %></strong>
181 + <span>Folder</span>
182 + </a>
183 + <% }) %>
184 + <% browser.files.forEach((item) => { %>
185 + <a class="file-row" href="?path=<%= encodeURIComponent(path) %>&file=<%= encodeURIComponent(item.path) %>">
186 + <span class="file-symbol file-symbol-document" aria-hidden="true"><svg class="repo-icon repo-icon-file" width="16" height="16" viewBox="0 0 24 24"><path d="M6 2h8l5 5v13a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2Zm7 2H6v16h11V8h-4V4Z"/></svg></span>
187 + <strong><%= item.path.split("/").pop() %></strong>
188 + <span><%= item.size.toLocaleString() %> bytes</span>
189 + </a>
190 + <% }) %>
191 + <% if (!browser.folders.length && !browser.files.length) { %>
192 + <p class="empty-state">This folder contains no files.</p>
193 + <% } %>
194 + </div>
195 + </section>
196 +
197 + <% if (!path && readme) { %>
198 + <article class="readme-card">
199 + <header>
200 + <div>
201 + <svg class="repo-icon repo-icon-file" width="16" height="16" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 3h6a3 3 0 0 1 2 .76A3 3 0 0 1 14 3h6a1 1 0 0 1 1 1v15a1 1 0 0 1-1 1h-6a2 2 0 0 0-1.45.62L12 21.2l-.55-.58A2 2 0 0 0 10 20H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1Zm1 2v13h5c.35 0 .69.04 1 .13V6a1 1 0 0 0-1-1H5Zm9 0a1 1 0 0 0-1 1v12.13c.31-.09.65-.13 1-.13h5V5h-5Z"/></svg>
202 + <strong><%= readme.path %></strong>
203 + </div>
204 + <a href="?file=<%= encodeURIComponent(readme.path) %>">View source</a>
205 + </header>
206 + <div class="markdown-body"><%- renderMarkdown(readme.content, {
207 + baseUrl: "/s/" + share.id + "/repositories/" + repository.id,
208 + readmePath: readme.path,
209 + files: repository.files
210 + }) %></div>
211 + </article>
212 + <% } else if (!path) { %>
213 + <section class="readme-empty">
214 + <strong>No README found</strong>
215 + <p>Browse the repository files above to understand this project.</p>
216 + </section>
217 + <% } %>
218 + </section>
219 + <% } %>
220 +</main>
221 +<%- include("partials/foot") %>
added tests/auth.test.js +61 −0
@@ -0,0 +1,61 @@
1 +import request from "supertest";
2 +import { afterEach, describe, expect, it } from "vitest";
3 +import { makeHarness } from "./helpers.js";
4 +
5 +describe("auth", () => {
6 + let harness;
7 + afterEach(() => harness.close());
8 +
9 + it("does not create owner sessions for anonymous home page visits", async () => {
10 + harness = makeHarness();
11 + const page = await harness.agent.get("/").expect(200);
12 + expect(page.headers["set-cookie"]).toBeUndefined();
13 + expect(harness.store.sessionCount()).toBe(0);
14 + });
15 +
16 + it("does not persist sessions for repeated OAuth starts without completed authentication", async () => {
17 + harness = makeHarness();
18 + for (let attempt = 0; attempt < 35; attempt += 1) {
19 + await request(harness.app).get("/auth/github").expect(302);
20 + }
21 + expect(harness.store.sessionCount()).toBe(0);
22 + });
23 +
24 + it("uses a verified GitHub OAuth state and then asks for selected repository access", async () => {
25 + harness = makeHarness();
26 + const start = await harness.agent.get("/auth/github").expect(302);
27 + const state = new URL(start.headers.location).searchParams.get("state");
28 + await harness.agent.get("/auth/github/callback?code=ok&state=wrong").expect(400);
29 + await harness.agent.get(`/auth/github/callback?code=ok&state=${state}`).expect(302);
30 + const install = await harness.agent.get("/github/install").expect(302);
31 + const installState = new URL(install.headers.location).searchParams.get("state");
32 + await harness.agent.get("/github/installed?installation_id=333&state=wrong").expect(400);
33 + await harness.agent.get(`/github/installed?installation_id=333&state=${installState}`).expect(302);
34 + });
35 +
36 + it("rejects an OAuth state after its ten minute lifetime", async () => {
37 + harness = makeHarness();
38 + const start = await harness.agent.get("/auth/github").expect(302);
39 + const state = new URL(start.headers.location).searchParams.get("state");
40 + harness.setNow("2026-07-23T12:11:00.000Z");
41 + await harness.agent.get(`/auth/github/callback?code=ok&state=${state}`).expect(400);
42 + });
43 +
44 + it("refreshes an expiring GitHub App user token before accessing repositories", async () => {
45 + harness = makeHarness();
46 + const start = await harness.agent.get("/auth/github").expect(302);
47 + const state = new URL(start.headers.location).searchParams.get("state");
48 + await harness.agent.get(`/auth/github/callback?code=ok&state=${state}`).expect(302);
49 + harness.store.updateOwnerTokens(77, {
50 + accessToken: "expired-token",
51 + refreshToken: "refresh-me",
52 + expiresAt: "2026-07-23T11:59:00.000Z",
53 + });
54 + const install = await harness.agent.get("/github/install").expect(302);
55 + const installState = new URL(install.headers.location).searchParams.get("state");
56 + await harness.agent.get(`/github/installed?installation_id=333&state=${installState}`).expect(302);
57 + await harness.agent.get("/").expect(200);
58 + expect(harness.calls.refreshes).toEqual(["refresh-me"]);
59 + expect(harness.calls.repositoryTokens).toContain("refreshed-token");
60 + });
61 +});
added tests/commits.test.js +21 −0
@@ -0,0 +1,21 @@
1 +import { afterEach, describe, expect, it } from "vitest";
2 +import { createShare, makeHarness } from "./helpers.js";
3 +
4 +describe("commits", () => {
5 + let harness;
6 + afterEach(() => harness.close());
7 +
8 + it("opens a commit and shows its changed lines", async () => {
9 + harness = makeHarness();
10 + const { id } = await createShare(harness);
11 + const sha = "101".padEnd(40, "a");
12 + const page = await harness.agent.get(`/s/${id}/repositories/101?commit=${sha}`).expect(200);
13 + expect(page.text).toContain("Finish atlas viewer");
14 + expect(page.text).toContain("-false");
15 + expect(page.text).toContain("+true");
16 + expect(page.text).toContain("Line changes are not available for this file.");
17 + const emptySha = "101".padEnd(40, "b");
18 + const empty = await harness.agent.get(`/s/${id}/repositories/101?commit=${emptySha}`).expect(200);
19 + expect(empty.text).toContain("No changed files are available for this commit.");
20 + });
21 +});
added tests/config.test.js +28 −0
@@ -0,0 +1,28 @@
1 +import { describe, expect, it } from "vitest";
2 +import { loadConfig } from "../src/config.js";
3 +
4 +describe("production configuration", () => {
5 + it("rejects a missing or weak token-encryption secret", () => {
6 + expect(() => loadConfig({ NODE_ENV: "production" })).toThrow(/SESSION_SECRET/);
7 + expect(() => loadConfig({
8 + NODE_ENV: "production",
9 + SESSION_SECRET: "too-short",
10 + })).toThrow(/SESSION_SECRET/);
11 + });
12 +
13 + it("accepts a production secret with at least 32 characters", () => {
14 + const config = loadConfig({
15 + NODE_ENV: "production",
16 + SESSION_SECRET: "0123456789abcdef0123456789abcdef",
17 + });
18 + expect(config.sessionSecret).toHaveLength(32);
19 + });
20 +
21 + it("requires a strong secret whenever GitHub credentials are configured", () => {
22 + expect(() => loadConfig({
23 + GITHUB_CLIENT_ID: "client",
24 + GITHUB_CLIENT_SECRET: "secret",
25 + GITHUB_APP_SLUG: "app",
26 + })).toThrow(/SESSION_SECRET/);
27 + });
28 +});
added tests/e2e.test.js +25 −0
@@ -0,0 +1,25 @@
1 +import request from "supertest";
2 +import { afterEach, describe, expect, it } from "vitest";
3 +import { createShare, makeHarness } from "./helpers.js";
4 +
5 +describe("end to end owner verification", () => {
6 + let harness;
7 + afterEach(() => harness.close());
8 +
9 + it("shows exactly all selected projects and all of their commits without viewer login", async () => {
10 + harness = makeHarness();
11 + const { id, response } = await createShare(harness, [101, 202]);
12 + expect(response.text).toContain("Review snapshot before sharing");
13 + expect(response.text).toContain("atlas");
14 + expect(response.text).toContain("ledger");
15 + const publicClient = request(harness.app);
16 + const profile = await publicClient.get(`/s/${id}`).expect(200);
17 + expect(profile.text).toContain("atlas");
18 + expect(profile.text).toContain("ledger");
19 + for (const repo of [{ id: 101, name: "atlas" }, { id: 202, name: "ledger" }]) {
20 + const page = await publicClient.get(`/s/${id}/repositories/${repo.id}?tab=commits`).expect(200);
21 + expect(page.text).toContain(`Finish ${repo.name} viewer`);
22 + expect(page.text).toContain(`Start ${repo.name}`);
23 + }
24 + });
25 +});
added tests/expiry.test.js +17 −0
@@ -0,0 +1,17 @@
1 +import { afterEach, describe, expect, it } from "vitest";
2 +import { createShare, makeHarness } from "./helpers.js";
3 +
4 +describe("expiry", () => {
5 + let harness;
6 + afterEach(() => harness.close());
7 +
8 + it("stops serving content when the link reaches its expiry", async () => {
9 + harness = makeHarness();
10 + const { id } = await createShare(harness, [101], 1);
11 + harness.setNow("2026-07-24T12:00:00.000Z");
12 + const page = await harness.agent.get(`/s/${id}`).expect(410);
13 + expect(page.text).toContain("The URL you have opened is expired.");
14 + expect(page.text).not.toContain("atlas");
15 + expect(harness.store.getShare(id).snapshot).toBeNull();
16 + });
17 +});
added tests/file-browser.test.js +16 −0
@@ -0,0 +1,16 @@
1 +import { afterEach, describe, expect, it } from "vitest";
2 +import { createShare, makeHarness } from "./helpers.js";
3 +
4 +describe("file browser", () => {
5 + let harness;
6 + afterEach(() => harness.close());
7 +
8 + it("opens nested folders and displays file contents as code", async () => {
9 + harness = makeHarness();
10 + const { id } = await createShare(harness);
11 + const folder = await harness.agent.get(`/s/${id}/repositories/101?path=src`).expect(200);
12 + expect(folder.text).toContain("index.js");
13 + const file = await harness.agent.get(`/s/${id}/repositories/101?path=src&file=src%2Findex.js`).expect(200);
14 + expect(file.text).toContain("export const ready = true;");
15 + });
16 +});
added tests/github-client.test.js +128 −0
@@ -0,0 +1,128 @@
1 +import { afterEach, describe, expect, it, vi } from "vitest";
2 +import { createGitHubClient } from "../src/github.js";
3 +
4 +function json(data, init = {}) {
5 + return new Response(JSON.stringify(data), {
6 + status: 200,
7 + headers: { "Content-Type": "application/json" },
8 + ...init,
9 + });
10 +}
11 +
12 +function client() {
13 + return createGitHubClient({
14 + clientId: "client",
15 + clientSecret: "secret",
16 + appSlug: "profileshare",
17 + baseUrl: "http://profileshare.test",
18 + });
19 +}
20 +
21 +describe("production GitHub client", () => {
22 + afterEach(() => vi.unstubAllGlobals());
23 +
24 + it("snapshots an empty repository without requesting a branch, tree, or commits", async () => {
25 + const calls = [];
26 + vi.stubGlobal("fetch", vi.fn(async (url) => {
27 + calls.push(String(url));
28 + return json({
29 + id: 1,
30 + name: "empty",
31 + full_name: "owner/empty",
32 + description: null,
33 + language: null,
34 + default_branch: null,
35 + });
36 + }));
37 + const snapshots = await client().snapshotRepositories("token", [{
38 + id: 1,
39 + fullName: "owner/empty",
40 + defaultBranch: null,
41 + }]);
42 + expect(snapshots[0].files).toEqual([]);
43 + expect(snapshots[0].commits).toEqual([]);
44 + expect(calls).toHaveLength(1);
45 + });
46 +
47 + it("anchors files and commit history to one resolved head SHA", async () => {
48 + const calls = [];
49 + vi.stubGlobal("fetch", vi.fn(async (url) => {
50 + const target = new URL(url);
51 + calls.push(`${target.pathname}${target.search}`);
52 + if (target.pathname === "/repos/owner/project") {
53 + return json({
54 + id: 2,
55 + name: "project",
56 + full_name: "owner/project",
57 + description: null,
58 + language: "JavaScript",
59 + default_branch: "main",
60 + });
61 + }
62 + if (target.pathname.endsWith("/commits/main")) {
63 + return json({ sha: "fixed-head", commit: { tree: { sha: "fixed-tree" } } });
64 + }
65 + if (target.pathname.endsWith("/git/trees/fixed-tree")) {
66 + return json({ truncated: false, tree: [] });
67 + }
68 + if (target.pathname.endsWith("/commits")) return json([]);
69 + throw new Error(`Unexpected request: ${target}`);
70 + }));
71 + const snapshots = await client().snapshotRepositories("token", [{
72 + id: 2,
73 + fullName: "owner/project",
74 + defaultBranch: "main",
75 + }]);
76 + expect(snapshots[0].headSha).toBe("fixed-head");
77 + expect(calls).toContain("/repos/owner/project/git/trees/fixed-tree?recursive=1");
78 + expect(calls.some((call) => call.includes("/commits?sha=fixed-head"))).toBe(true);
79 + });
80 +
81 + it("collects every changed-file page for a large commit", async () => {
82 + vi.stubGlobal("fetch", vi.fn(async (url) => {
83 + const target = new URL(url);
84 + if (target.pathname === "/repos/owner/project") {
85 + return json({
86 + id: 2,
87 + name: "project",
88 + full_name: "owner/project",
89 + description: null,
90 + language: "JavaScript",
91 + default_branch: "main",
92 + });
93 + }
94 + if (target.pathname.endsWith("/commits/main")) {
95 + return json({ sha: "head", commit: { tree: { sha: "tree" } } });
96 + }
97 + if (target.pathname.endsWith("/git/trees/tree")) return json({ truncated: false, tree: [] });
98 + if (target.pathname.endsWith("/commits") && !target.searchParams.has("page")) {
99 + throw new Error("Pagination parameters expected");
100 + }
101 + if (target.pathname.endsWith("/commits") && target.searchParams.has("sha")) {
102 + return json(target.searchParams.get("page") === "1"
103 + ? [{ sha: "commit", commit: { message: "Large change", author: { name: "Owner", date: "2026-01-01T00:00:00Z" } } }]
104 + : []);
105 + }
106 + if (target.pathname.endsWith("/commits/commit")) {
107 + const page = Number(target.searchParams.get("page"));
108 + return json({
109 + stats: { additions: 101, deletions: 0 },
110 + files: Array.from({ length: page === 1 ? 100 : 1 }, (_, index) => ({
111 + filename: `file-${page}-${index}.js`,
112 + status: "modified",
113 + additions: 1,
114 + deletions: 0,
115 + patch: "+line",
116 + })),
117 + });
118 + }
119 + throw new Error(`Unexpected request: ${target}`);
120 + }));
121 + const snapshots = await client().snapshotRepositories("token", [{
122 + id: 2,
123 + fullName: "owner/project",
124 + defaultBranch: "main",
125 + }]);
126 + expect(snapshots[0].commits[0].files).toHaveLength(101);
127 + });
128 +});
added tests/helpers.js +152 −0
@@ -0,0 +1,152 @@
1 +import request from "supertest";
2 +import { createStore } from "../src/db.js";
3 +import { createApp } from "../src/app.js";
4 +
5 +export const repositories = [
6 + {
7 + id: 101,
8 + name: "atlas",
9 + fullName: "rasmus/atlas",
10 + description: "A mapping tool",
11 + private: true,
12 + language: "JavaScript",
13 + updatedAt: "2026-07-20T10:00:00.000Z",
14 + defaultBranch: "main",
15 + owner: "rasmus",
16 + },
17 + {
18 + id: 202,
19 + name: "ledger",
20 + fullName: "rasmus/ledger",
21 + description: "An expense journal",
22 + private: true,
23 + language: "TypeScript",
24 + updatedAt: "2026-07-21T10:00:00.000Z",
25 + defaultBranch: "main",
26 + owner: "rasmus",
27 + },
28 +];
29 +
30 +function snapshot(repo) {
31 + return {
32 + ...repo,
33 + files: [
34 + {
35 + path: "README.md",
36 + size: 114,
37 + content: `# ${repo.name}\n\nSnapshot\n\n## Highlights\n\n- Read-only review\n- [Read the guide](docs/guide.md)\n\n<script>alert("x")</script>`,
38 + truncated: false,
39 + },
40 + { path: "docs/guide.md", size: 26, content: "# Guide\n\nProject details.", truncated: false },
41 + { path: "src/index.js", size: 22, content: "export const ready = true;", truncated: false },
42 + ],
43 + commits: [
44 + {
45 + sha: `${repo.id}`.padEnd(40, "a"),
46 + message: `Finish ${repo.name} viewer`,
47 + author: "Rasmus",
48 + date: "2026-07-22T09:00:00.000Z",
49 + additions: 4,
50 + deletions: 1,
51 + files: [{
52 + filename: "src/index.js",
53 + status: "modified",
54 + additions: 4,
55 + deletions: 1,
56 + patch: "@@ -1 +1 @@\n-false\n+true",
57 + }, {
58 + filename: "package-lock.json",
59 + status: "modified",
60 + additions: 1,
61 + deletions: 1,
62 + patch: "",
63 + }],
64 + },
65 + {
66 + sha: `${repo.id}`.padEnd(40, "b"),
67 + message: `Start ${repo.name}`,
68 + author: "Rasmus",
69 + date: "2026-07-19T09:00:00.000Z",
70 + additions: 10,
71 + deletions: 0,
72 + files: [],
73 + },
74 + ],
75 + };
76 +}
77 +
78 +export function makeHarness(options = {}) {
79 + const store = createStore();
80 + let clock = options.now || new Date("2026-07-23T12:00:00.000Z");
81 + const calls = { snapshots: [], refreshes: [], repositoryTokens: [], viewerReads: 0 };
82 + const github = {
83 + authorizationUrl: (state) => `https://github.test/authorize?state=${state}`,
84 + installationUrl: (state) => `https://github.test/install?state=${state}`,
85 + exchangeCode: async () => ({
86 + accessToken: "owner-token",
87 + refreshToken: null,
88 + expiresAt: null,
89 + }),
90 + refreshUserToken: async (refreshToken) => {
91 + calls.refreshes.push(refreshToken);
92 + return {
93 + accessToken: "refreshed-token",
94 + refreshToken: "next-refresh",
95 + expiresAt: "2026-07-24T12:00:00.000Z",
96 + };
97 + },
98 + getViewer: async () => {
99 + calls.viewerReads += 1;
100 + return {
101 + id: 77,
102 + login: "rasmus",
103 + name: "Rasmus",
104 + avatar_url: "https://avatars.test/rasmus",
105 + bio: "Building useful software.",
106 + };
107 + },
108 + listRepositories: async (token) => {
109 + calls.repositoryTokens.push(token);
110 + return repositories;
111 + },
112 + snapshotRepositories: async (token, selected) => {
113 + calls.snapshots.push(selected.map((repo) => repo.id));
114 + return selected.map(snapshot);
115 + },
116 + };
117 + const config = {
118 + baseUrl: "http://profileshare.test",
119 + sessionSecret: "0123456789abcdef0123456789abcdef",
120 + github: { clientId: "client", clientSecret: "secret", appSlug: "profileshare" },
121 + };
122 + const app = createApp({ config, store, github, now: () => new Date(clock) });
123 + return {
124 + app,
125 + agent: request.agent(app),
126 + store,
127 + github,
128 + calls,
129 + setNow(value) { clock = new Date(value); },
130 + close() { store.close(); },
131 + };
132 +}
133 +
134 +export async function authenticate(harness) {
135 + const start = await harness.agent.get("/auth/github");
136 + const state = new URL(start.headers.location).searchParams.get("state");
137 + await harness.agent.get(`/auth/github/callback?code=ok&state=${state}`).expect(302);
138 + const install = await harness.agent.get("/github/install").expect(302);
139 + const installState = new URL(install.headers.location).searchParams.get("state");
140 + await harness.agent.get(`/github/installed?installation_id=333&state=${installState}`).expect(302);
141 +}
142 +
143 +export async function createShare(harness, repoIds = [101], days = 7) {
144 + await authenticate(harness);
145 + const response = await harness.agent
146 + .post("/shares")
147 + .type("form")
148 + .send({ repositories: repoIds, days })
149 + .expect(201);
150 + const match = response.text.match(/http:\/\/profileshare\.test\/s\/([A-Za-z0-9_-]+)/);
151 + return { id: match[1], response };
152 +}
added tests/layout.test.js +14 −0
@@ -0,0 +1,14 @@
1 +import { readFileSync } from "node:fs";
2 +import { describe, expect, it } from "vitest";
3 +
4 +describe("desktop layout", () => {
5 + it("uses a desktop canvas and structured repository navigation", () => {
6 + const css = readFileSync(new URL("../src/public/styles.css", import.meta.url), "utf8");
7 + expect(css).toContain("min-width: 1024px");
8 + expect(css).toMatch(/repo-tabs-inner\s*\{[^}]*display: flex/);
9 + expect(css).toMatch(/latest-commit\s*\{[^}]*grid-template-columns:/);
10 + expect(css).toMatch(/markdown-body\s*\{[^}]*padding:/);
11 + expect(css).toContain(".repo-icon-branch { width: 14px; height: 14px; max-width: 14px; max-height: 14px; }");
12 + expect(css).not.toMatch(/code-toolbar svg\s*\{[^}]*width:\s*100%/);
13 + });
14 +});
added tests/link-generation.test.js +16 −0
@@ -0,0 +1,16 @@
1 +import { afterEach, describe, expect, it } from "vitest";
2 +import { createShare, makeHarness } from "./helpers.js";
3 +
4 +describe("link generation", () => {
5 + let harness;
6 + afterEach(() => harness.close());
7 +
8 + it("creates a simple public URL with the chosen duration", async () => {
9 + harness = makeHarness();
10 + const { id, response } = await createShare(harness, [101], 12);
11 + expect(response.text).toContain(`http://profileshare.test/s/${id}`);
12 + expect(harness.store.getShare(id).expires_at).toBe("2026-08-04T12:00:00.000Z");
13 + expect(response.text).toContain("Review snapshot before sharing");
14 + expect(response.text).toContain("2 commits");
15 + });
16 +});
added tests/navigation.test.js +16 −0
@@ -0,0 +1,16 @@
1 +import { afterEach, describe, expect, it } from "vitest";
2 +import { createShare, makeHarness } from "./helpers.js";
3 +
4 +describe("navigation controls", () => {
5 + let harness;
6 + afterEach(() => harness.close());
7 +
8 + it("offers profile, repository root, breadcrumb, and parent folder navigation", async () => {
9 + harness = makeHarness();
10 + const { id } = await createShare(harness);
11 + const page = await harness.agent.get(`/s/${id}/repositories/101?path=src`).expect(200);
12 + expect(page.text).toContain("Back to profile");
13 + expect(page.text).toContain(`href="/s/${id}/repositories/101"`);
14 + expect(page.text).toContain("Parent folder");
15 + });
16 +});
added tests/profile-page.test.js +23 −0
@@ -0,0 +1,23 @@
1 +import request from "supertest";
2 +import { afterEach, describe, expect, it } from "vitest";
3 +import { createShare, makeHarness } from "./helpers.js";
4 +
5 +describe("profile page", () => {
6 + let harness;
7 + afterEach(() => harness.close());
8 +
9 + it("is public and shows the profile, selected projects, and activity", async () => {
10 + harness = makeHarness();
11 + const { id } = await createShare(harness, [101, 202]);
12 + const sessionsBefore = harness.store.sessionCount();
13 + const page = await request(harness.app).get(`/s/${id}`).expect(200);
14 + expect(page.text).toContain("Rasmus");
15 + expect(page.text).toContain("atlas");
16 + expect(page.text).toContain("ledger");
17 + expect(page.text).toContain("Recent work across the snapshot");
18 + expect(page.text).toContain(`/s/${id}/repositories/101?commit=101`);
19 + expect(page.headers["cache-control"]).toBe("private, no-store");
20 + expect(page.headers["set-cookie"]).toBeUndefined();
21 + expect(harness.store.sessionCount()).toBe(sessionsBefore);
22 + });
23 +});
added tests/read-only.test.js +15 −0
@@ -0,0 +1,15 @@
1 +import { afterEach, describe, expect, it } from "vitest";
2 +import { createShare, makeHarness } from "./helpers.js";
3 +
4 +describe("read only", () => {
5 + let harness;
6 + afterEach(() => harness.close());
7 +
8 + it("has no viewer mutation, comment, or download controls", async () => {
9 + harness = makeHarness();
10 + const { id } = await createShare(harness);
11 + const page = await harness.agent.get(`/s/${id}/repositories/101`).expect(200);
12 + expect(page.text).not.toMatch(/<form|download=|add comment|edit file/i);
13 + expect(page.text).toContain("Read-only snapshot");
14 + });
15 +});
added tests/repo-navigation.test.js +15 −0
@@ -0,0 +1,15 @@
1 +import { afterEach, describe, expect, it } from "vitest";
2 +import { createShare, makeHarness } from "./helpers.js";
3 +
4 +describe("repository navigation", () => {
5 + let harness;
6 + afterEach(() => harness.close());
7 +
8 + it("links each selected repository from the profile", async () => {
9 + harness = makeHarness();
10 + const { id } = await createShare(harness);
11 + const page = await harness.agent.get(`/s/${id}`).expect(200);
12 + expect(page.text).toContain(`/s/${id}/repositories/101`);
13 + await harness.agent.get(`/s/${id}/repositories/101`).expect(200);
14 + });
15 +});
added tests/repo-selection.test.js +16 −0
@@ -0,0 +1,16 @@
1 +import { afterEach, describe, expect, it } from "vitest";
2 +import { authenticate, makeHarness } from "./helpers.js";
3 +
4 +describe("repo selection", () => {
5 + let harness;
6 + afterEach(() => harness.close());
7 +
8 + it("shows every repository made available by the selected GitHub App installation", async () => {
9 + harness = makeHarness();
10 + await authenticate(harness);
11 + const page = await harness.agent.get("/").expect(200);
12 + expect(page.text).toContain("atlas");
13 + expect(page.text).toContain("ledger");
14 + expect(page.text).toContain('name="repositories"');
15 + });
16 +});
added tests/repo-view.test.js +25 −0
@@ -0,0 +1,25 @@
1 +import { afterEach, describe, expect, it } from "vitest";
2 +import { createShare, makeHarness } from "./helpers.js";
3 +
4 +describe("repository view", () => {
5 + let harness;
6 + afterEach(() => harness.close());
7 +
8 + it("shows a GitHub-style code overview with a rendered README and commit tab", async () => {
9 + harness = makeHarness();
10 + const { id } = await createShare(harness);
11 + const page = await harness.agent.get(`/s/${id}/repositories/101`).expect(200);
12 + expect(page.text).toContain("README.md");
13 + expect(page.text).toContain("src");
14 + expect(page.text).toContain("Finish atlas viewer");
15 + expect(page.text).toContain("<h1>atlas</h1>");
16 + expect(page.text).toContain("<h2>Highlights</h2>");
17 + expect(page.text).toContain(`/s/${id}/repositories/101?file=docs%2Fguide.md`);
18 + expect(page.text).not.toContain("<script>");
19 + expect(page.text).toContain("?tab=commits");
20 + expect(page.text).toContain('class="repo-icon repo-icon-branch" width="14" height="14"');
21 + const commits = await harness.agent.get(`/s/${id}/repositories/101?tab=commits`).expect(200);
22 + expect(commits.text).toContain("Finish atlas viewer");
23 + expect(commits.text).toContain("Start atlas");
24 + });
25 +});
added tests/snapshot.test.js +19 −0
@@ -0,0 +1,19 @@
1 +import { afterEach, describe, expect, it } from "vitest";
2 +import { createShare, makeHarness } from "./helpers.js";
3 +
4 +describe("snapshot", () => {
5 + let harness;
6 + afterEach(() => harness.close());
7 +
8 + it("stores only selected repositories and serves their frozen content", async () => {
9 + harness = makeHarness();
10 + const { id } = await createShare(harness, [202]);
11 + expect(harness.calls.snapshots).toEqual([[202]]);
12 + expect(harness.calls.viewerReads).toBe(2);
13 + const row = harness.store.getShare(id);
14 + expect(row.snapshot.repositories.map((repo) => repo.name)).toEqual(["ledger"]);
15 + expect(row.snapshot.profile.bio).toBe("Building useful software.");
16 + row.snapshot.repositories[0].name = "changed outside storage";
17 + expect(harness.store.getShare(id).snapshot.repositories[0].name).toBe("ledger");
18 + });
19 +});