cursedbelt-server 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +15 -0
- package/dist/server/activity/index.d.ts +36 -0
- package/dist/server/activity/index.js +36 -0
- package/dist/server/activity/migrations.d.ts +21 -0
- package/dist/server/activity/migrations.js +55 -0
- package/dist/server/activity/shareSource.d.ts +11 -0
- package/dist/server/activity/shareSource.js +45 -0
- package/dist/server/activity/store.d.ts +123 -0
- package/dist/server/activity/store.js +178 -0
- package/dist/server/app.d.ts +56 -0
- package/dist/server/app.js +45 -0
- package/dist/server/audit/index.d.ts +2 -0
- package/dist/server/audit/index.js +2 -0
- package/dist/server/audit/migrations.d.ts +2 -0
- package/dist/server/audit/migrations.js +79 -0
- package/dist/server/audit/sqliteSink.d.ts +36 -0
- package/dist/server/audit/sqliteSink.js +95 -0
- package/dist/server/audit/sqliteSink.test.d.ts +1 -0
- package/dist/server/audit/sqliteSink.test.js +180 -0
- package/dist/server/auth/cookie.d.ts +22 -0
- package/dist/server/auth/cookie.js +35 -0
- package/dist/server/auth/csrf.d.ts +42 -0
- package/dist/server/auth/csrf.js +36 -0
- package/dist/server/auth/fileTokenSigner.d.ts +10 -0
- package/dist/server/auth/fileTokenSigner.js +22 -0
- package/dist/server/auth/getOnly.d.ts +20 -0
- package/dist/server/auth/getOnly.js +27 -0
- package/dist/server/auth/index.d.ts +12 -0
- package/dist/server/auth/index.js +15 -0
- package/dist/server/auth/jwt.d.ts +18 -0
- package/dist/server/auth/jwt.js +56 -0
- package/dist/server/auth/loginThrottle.d.ts +118 -0
- package/dist/server/auth/loginThrottle.js +204 -0
- package/dist/server/auth/middleware.d.ts +14 -0
- package/dist/server/auth/middleware.js +47 -0
- package/dist/server/auth/password.d.ts +14 -0
- package/dist/server/auth/password.js +65 -0
- package/dist/server/auth/sessionStore.d.ts +19 -0
- package/dist/server/auth/sessionStore.js +221 -0
- package/dist/server/auth/totp.d.ts +34 -0
- package/dist/server/auth/totp.js +81 -0
- package/dist/server/auth/types.d.ts +133 -0
- package/dist/server/auth/types.js +1 -0
- package/dist/server/auth/vault.d.ts +27 -0
- package/dist/server/auth/vault.js +57 -0
- package/dist/server/context.d.ts +35 -0
- package/dist/server/context.js +1 -0
- package/dist/server/ctgr.d.ts +32 -0
- package/dist/server/ctgr.js +69 -0
- package/dist/server/db/kysely.d.ts +14 -0
- package/dist/server/db/kysely.js +16 -0
- package/dist/server/db/plumbingTypes.d.ts +116 -0
- package/dist/server/db/plumbingTypes.js +12 -0
- package/dist/server/dropzone/duplicates.d.ts +82 -0
- package/dist/server/dropzone/duplicates.js +90 -0
- package/dist/server/dropzone/index.d.ts +13 -0
- package/dist/server/dropzone/index.js +13 -0
- package/dist/server/dropzone/scan.d.ts +166 -0
- package/dist/server/dropzone/scan.js +272 -0
- package/dist/server/errors.d.ts +7 -0
- package/dist/server/errors.js +7 -0
- package/dist/server/guard/argon2Binding.d.ts +97 -0
- package/dist/server/guard/argon2Binding.js +138 -0
- package/dist/server/guard/index.d.ts +4 -0
- package/dist/server/guard/index.js +6 -0
- package/dist/server/guard/kdfWorker.d.ts +101 -0
- package/dist/server/guard/kdfWorker.js +301 -0
- package/dist/server/guard/kdfWorkerEntry.d.ts +1 -0
- package/dist/server/guard/kdfWorkerEntry.js +15 -0
- package/dist/server/guard/passwordVerifier.d.ts +65 -0
- package/dist/server/guard/passwordVerifier.js +122 -0
- package/dist/server/guard/revocationStore.d.ts +132 -0
- package/dist/server/guard/revocationStore.js +211 -0
- package/dist/server/guard/sessionSigner.d.ts +132 -0
- package/dist/server/guard/sessionSigner.js +303 -0
- package/dist/server/guard/verifier.d.ts +21 -0
- package/dist/server/guard/verifier.js +1 -0
- package/dist/server/incidents/channels.d.ts +161 -0
- package/dist/server/incidents/channels.js +357 -0
- package/dist/server/incidents/checks.d.ts +61 -0
- package/dist/server/incidents/checks.js +55 -0
- package/dist/server/incidents/index.d.ts +51 -0
- package/dist/server/incidents/index.js +51 -0
- package/dist/server/incidents/migrations.d.ts +20 -0
- package/dist/server/incidents/migrations.js +58 -0
- package/dist/server/incidents/notifier.d.ts +79 -0
- package/dist/server/incidents/notifier.js +141 -0
- package/dist/server/incidents/reporter.d.ts +60 -0
- package/dist/server/incidents/reporter.js +49 -0
- package/dist/server/incidents/routes.d.ts +91 -0
- package/dist/server/incidents/routes.js +202 -0
- package/dist/server/incidents/store.d.ts +75 -0
- package/dist/server/incidents/store.js +176 -0
- package/dist/server/incidents/types.d.ts +107 -0
- package/dist/server/incidents/types.js +32 -0
- package/dist/server/index.d.ts +25 -0
- package/dist/server/index.js +53 -0
- package/dist/server/jobs/fireQueue.d.ts +12 -0
- package/dist/server/jobs/fireQueue.js +37 -0
- package/dist/server/jobs/index.d.ts +6 -0
- package/dist/server/jobs/index.js +10 -0
- package/dist/server/jobs/logger.d.ts +10 -0
- package/dist/server/jobs/logger.js +14 -0
- package/dist/server/jobs/plainjobAdapter.d.ts +19 -0
- package/dist/server/jobs/plainjobAdapter.js +48 -0
- package/dist/server/jobs/polling.d.ts +17 -0
- package/dist/server/jobs/polling.js +79 -0
- package/dist/server/jobs/runner.d.ts +24 -0
- package/dist/server/jobs/runner.js +186 -0
- package/dist/server/jobs/timeout.d.ts +28 -0
- package/dist/server/jobs/timeout.js +47 -0
- package/dist/server/jobs/types.d.ts +167 -0
- package/dist/server/jobs/types.js +12 -0
- package/dist/server/master-lock/guard.d.ts +53 -0
- package/dist/server/master-lock/guard.js +205 -0
- package/dist/server/master-lock/index.d.ts +21 -0
- package/dist/server/master-lock/index.js +21 -0
- package/dist/server/master-lock/lockPage.d.ts +34 -0
- package/dist/server/master-lock/lockPage.js +281 -0
- package/dist/server/master-lock/masterLock.d.ts +294 -0
- package/dist/server/master-lock/masterLock.js +523 -0
- package/dist/server/master-lock/principals.d.ts +76 -0
- package/dist/server/master-lock/principals.js +157 -0
- package/dist/server/master-lock/seed.d.ts +47 -0
- package/dist/server/master-lock/seed.js +93 -0
- package/dist/server/master-lock/store.d.ts +19 -0
- package/dist/server/master-lock/store.js +20 -0
- package/dist/server/media-bun/image.d.ts +7 -0
- package/dist/server/media-bun/image.js +96 -0
- package/dist/server/media-bun/index.d.ts +4 -0
- package/dist/server/media-bun/index.js +10 -0
- package/dist/server/media-bun/streaming.d.ts +86 -0
- package/dist/server/media-bun/streaming.js +237 -0
- package/dist/server/media-bun/video.d.ts +35 -0
- package/dist/server/media-bun/video.js +181 -0
- package/dist/server/metrics/metricsBuffer.d.ts +39 -0
- package/dist/server/metrics/metricsBuffer.js +36 -0
- package/dist/server/metrics/normalizeRoute.d.ts +5 -0
- package/dist/server/metrics/normalizeRoute.js +58 -0
- package/dist/server/middleware/correlationId.d.ts +9 -0
- package/dist/server/middleware/correlationId.js +20 -0
- package/dist/server/middleware/corsAllowlist.d.ts +38 -0
- package/dist/server/middleware/corsAllowlist.js +37 -0
- package/dist/server/middleware/errorEnvelope.d.ts +17 -0
- package/dist/server/middleware/errorEnvelope.js +32 -0
- package/dist/server/middleware/index.d.ts +9 -0
- package/dist/server/middleware/index.js +10 -0
- package/dist/server/middleware/localOrigin.d.ts +71 -0
- package/dist/server/middleware/localOrigin.js +119 -0
- package/dist/server/middleware/rateLimit.d.ts +56 -0
- package/dist/server/middleware/rateLimit.js +86 -0
- package/dist/server/middleware/requestLogger.d.ts +29 -0
- package/dist/server/middleware/requestLogger.js +47 -0
- package/dist/server/middleware/securityHeaders.d.ts +29 -0
- package/dist/server/middleware/securityHeaders.js +44 -0
- package/dist/server/middleware/zValidator.d.ts +29 -0
- package/dist/server/middleware/zValidator.js +57 -0
- package/dist/server/migration/runner.d.ts +42 -0
- package/dist/server/migration/runner.js +70 -0
- package/dist/server/migration/types.d.ts +16 -0
- package/dist/server/migration/types.js +1 -0
- package/dist/server/net/outboundGuard.d.ts +30 -0
- package/dist/server/net/outboundGuard.js +199 -0
- package/dist/server/notifications/hub.d.ts +67 -0
- package/dist/server/notifications/hub.js +187 -0
- package/dist/server/notifications/index.d.ts +47 -0
- package/dist/server/notifications/index.js +46 -0
- package/dist/server/notifications/migrations.d.ts +13 -0
- package/dist/server/notifications/migrations.js +20 -0
- package/dist/server/notifications/routes.d.ts +58 -0
- package/dist/server/notifications/routes.js +113 -0
- package/dist/server/notifications/service.d.ts +58 -0
- package/dist/server/notifications/service.js +41 -0
- package/dist/server/paths/appDataHome.d.ts +19 -0
- package/dist/server/paths/appDataHome.js +68 -0
- package/dist/server/private-media/crypto.d.ts +37 -0
- package/dist/server/private-media/crypto.js +111 -0
- package/dist/server/private-media/index-store.d.ts +31 -0
- package/dist/server/private-media/index-store.js +36 -0
- package/dist/server/private-media/mountPrivateRoutes.d.ts +26 -0
- package/dist/server/private-media/mountPrivateRoutes.js +219 -0
- package/dist/server/private-media/pipeline.d.ts +37 -0
- package/dist/server/private-media/pipeline.js +97 -0
- package/dist/server/private-media/unlock.d.ts +52 -0
- package/dist/server/private-media/unlock.js +55 -0
- package/dist/server/resume/index.d.ts +2 -0
- package/dist/server/resume/index.js +2 -0
- package/dist/server/resume/routes.d.ts +41 -0
- package/dist/server/resume/routes.js +50 -0
- package/dist/server/resume/store.d.ts +37 -0
- package/dist/server/resume/store.js +38 -0
- package/dist/server/retention/archive.d.ts +78 -0
- package/dist/server/retention/archive.js +284 -0
- package/dist/server/retention/fleet.d.ts +50 -0
- package/dist/server/retention/fleet.js +66 -0
- package/dist/server/retention/index.d.ts +38 -0
- package/dist/server/retention/index.js +38 -0
- package/dist/server/retention/policy.d.ts +263 -0
- package/dist/server/retention/policy.js +264 -0
- package/dist/server/retention/store.d.ts +101 -0
- package/dist/server/retention/store.js +251 -0
- package/dist/server/retention/sweeper.d.ts +69 -0
- package/dist/server/retention/sweeper.js +289 -0
- package/dist/server/retention/target.d.ts +143 -0
- package/dist/server/retention/target.js +438 -0
- package/dist/server/search/index.d.ts +2 -0
- package/dist/server/search/index.js +6 -0
- package/dist/server/search/pgSearchProvider.d.ts +91 -0
- package/dist/server/search/pgSearchProvider.js +330 -0
- package/dist/server/search/pgSearchProvider.pg.test.d.ts +1 -0
- package/dist/server/search/pgSearchProvider.pg.test.js +338 -0
- package/dist/server/search/pgSearchSchema.d.ts +81 -0
- package/dist/server/search/pgSearchSchema.js +89 -0
- package/dist/server/sharing/index.d.ts +18 -0
- package/dist/server/sharing/index.js +16 -0
- package/dist/server/sharing/migrations.d.ts +6 -0
- package/dist/server/sharing/migrations.js +79 -0
- package/dist/server/sharing/router.d.ts +111 -0
- package/dist/server/sharing/router.js +93 -0
- package/dist/server/sharing/sql.d.ts +67 -0
- package/dist/server/sharing/sql.js +88 -0
- package/dist/server/sharing/store.d.ts +173 -0
- package/dist/server/sharing/store.js +484 -0
- package/dist/server/sqlite/backup.d.ts +48 -0
- package/dist/server/sqlite/backup.js +97 -0
- package/dist/server/sqlite/browse.d.ts +42 -0
- package/dist/server/sqlite/browse.js +84 -0
- package/dist/server/sqlite/export.d.ts +32 -0
- package/dist/server/sqlite/export.js +42 -0
- package/dist/server/sqlite/fts5.d.ts +106 -0
- package/dist/server/sqlite/fts5.js +154 -0
- package/dist/server/sqlite/health.d.ts +44 -0
- package/dist/server/sqlite/health.js +52 -0
- package/dist/server/sqlite/import.d.ts +36 -0
- package/dist/server/sqlite/import.js +108 -0
- package/dist/server/sqlite/index.d.ts +9 -0
- package/dist/server/sqlite/index.js +15 -0
- package/dist/server/sqlite/readonlySql.d.ts +15 -0
- package/dist/server/sqlite/readonlySql.js +65 -0
- package/dist/server/sqlite/stats.d.ts +22 -0
- package/dist/server/sqlite/stats.js +32 -0
- package/dist/server/sqlite/viewer.d.ts +17 -0
- package/dist/server/sqlite/viewer.js +56 -0
- package/dist/server/storage/binaryServerProcessing.d.ts +23 -0
- package/dist/server/storage/binaryServerProcessing.js +81 -0
- package/dist/server/storage/binaryServerStore.d.ts +3 -0
- package/dist/server/storage/binaryServerStore.js +95 -0
- package/dist/server/storage/diskStore.d.ts +2 -0
- package/dist/server/storage/diskStore.js +88 -0
- package/dist/server/storage/files/adapter.d.ts +33 -0
- package/dist/server/storage/files/adapter.js +203 -0
- package/dist/server/storage/files/catalogue.d.ts +72 -0
- package/dist/server/storage/files/catalogue.js +1033 -0
- package/dist/server/storage/files/chunkedUpload.d.ts +56 -0
- package/dist/server/storage/files/chunkedUpload.js +70 -0
- package/dist/server/storage/files/fileRoutes.d.ts +51 -0
- package/dist/server/storage/files/fileRoutes.js +405 -0
- package/dist/server/storage/files/index.d.ts +9 -0
- package/dist/server/storage/files/index.js +11 -0
- package/dist/server/storage/files/migrations.d.ts +2 -0
- package/dist/server/storage/files/migrations.js +163 -0
- package/dist/server/storage/files/mimeByExt.d.ts +1 -0
- package/dist/server/storage/files/mimeByExt.js +13 -0
- package/dist/server/storage/files/stack.d.ts +136 -0
- package/dist/server/storage/files/stack.js +132 -0
- package/dist/server/storage/files/storageOps.d.ts +54 -0
- package/dist/server/storage/files/storageOps.js +110 -0
- package/dist/server/storage/files/types.d.ts +261 -0
- package/dist/server/storage/files/types.js +22 -0
- package/dist/server/storage/imageThumbnail.d.ts +3 -0
- package/dist/server/storage/imageThumbnail.js +50 -0
- package/dist/server/storage/index.d.ts +15 -0
- package/dist/server/storage/index.js +27 -0
- package/dist/server/storage/mountMediaRoutes.d.ts +28 -0
- package/dist/server/storage/mountMediaRoutes.js +141 -0
- package/dist/server/storage/objectStoreMediaStore.d.ts +4 -0
- package/dist/server/storage/objectStoreMediaStore.js +57 -0
- package/dist/server/storage/s3ObjectStore.d.ts +63 -0
- package/dist/server/storage/s3ObjectStore.js +187 -0
- package/dist/server/storage/sources.d.ts +47 -0
- package/dist/server/storage/sources.js +148 -0
- package/dist/server/storage/streamRange.d.ts +19 -0
- package/dist/server/storage/streamRange.js +69 -0
- package/dist/server/storage/streamToken.d.ts +16 -0
- package/dist/server/storage/streamToken.js +54 -0
- package/dist/server/storage/transcodeJobs.d.ts +65 -0
- package/dist/server/storage/transcodeJobs.js +104 -0
- package/dist/server/storage/types.d.ts +193 -0
- package/dist/server/storage/types.js +41 -0
- package/dist/server/storage/videoDerivativeKeys.d.ts +127 -0
- package/dist/server/storage/videoDerivativeKeys.js +149 -0
- package/dist/server/storage/videoProcessor.d.ts +30 -0
- package/dist/server/storage/videoProcessor.js +148 -0
- package/dist/server/sync/alarm.d.ts +34 -0
- package/dist/server/sync/alarm.js +91 -0
- package/dist/server/sync/commands.d.ts +87 -0
- package/dist/server/sync/commands.js +241 -0
- package/dist/server/sync/engine.d.ts +62 -0
- package/dist/server/sync/engine.js +184 -0
- package/dist/server/sync/http.d.ts +106 -0
- package/dist/server/sync/http.js +243 -0
- package/dist/server/sync/index.d.ts +41 -0
- package/dist/server/sync/index.js +41 -0
- package/dist/server/sync/opLog.d.ts +61 -0
- package/dist/server/sync/opLog.js +96 -0
- package/dist/server/sync/planner.d.ts +31 -0
- package/dist/server/sync/planner.js +30 -0
- package/dist/server/sync/status.d.ts +63 -0
- package/dist/server/sync/status.js +49 -0
- package/dist/server/sync/timer.d.ts +52 -0
- package/dist/server/sync/timer.js +170 -0
- package/dist/server/sync/tokens.d.ts +25 -0
- package/dist/server/sync/tokens.js +51 -0
- package/dist/server/sync/types.d.ts +101 -0
- package/dist/server/sync/types.js +29 -0
- package/dist/server/ws/eventBus.d.ts +14 -0
- package/dist/server/ws/eventBus.js +42 -0
- package/dist/server/ws/hub.d.ts +20 -0
- package/dist/server/ws/hub.js +154 -0
- package/dist/server/ws/index.d.ts +5 -0
- package/dist/server/ws/index.js +7 -0
- package/dist/server/ws/presence.d.ts +11 -0
- package/dist/server/ws/presence.js +20 -0
- package/dist/server/ws/room.d.ts +17 -0
- package/dist/server/ws/room.js +23 -0
- package/dist/server/ws/types.d.ts +122 -0
- package/dist/server/ws/types.js +1 -0
- package/package.json +234 -0
- package/src/declaredDepsAreImported.spec.ts +87 -0
- package/src/leafSubpathsImportNothing.spec.ts +358 -0
- package/src/lockfileIsTracked.spec.ts +50 -0
- package/src/namedSubpathsResolve.spec.ts +122 -0
- package/src/noPathDeps.spec.ts +24 -0
- package/src/publishShape.spec.ts +89 -0
- package/src/server/activity/index.ts +59 -0
- package/src/server/activity/migrations.ts +67 -0
- package/src/server/activity/shareSource.ts +57 -0
- package/src/server/activity/store.spec.ts +255 -0
- package/src/server/activity/store.ts +368 -0
- package/src/server/app.spec.ts +243 -0
- package/src/server/app.ts +89 -0
- package/src/server/audit/index.ts +2 -0
- package/src/server/audit/migrations.ts +88 -0
- package/src/server/audit/sqliteSink.test.ts +200 -0
- package/src/server/audit/sqliteSink.ts +162 -0
- package/src/server/auth/cookie.spec.ts +46 -0
- package/src/server/auth/cookie.ts +42 -0
- package/src/server/auth/csrf.spec.ts +81 -0
- package/src/server/auth/csrf.ts +83 -0
- package/src/server/auth/fileTokenSigner.ts +40 -0
- package/src/server/auth/getOnly.spec.ts +53 -0
- package/src/server/auth/getOnly.ts +32 -0
- package/src/server/auth/index.ts +51 -0
- package/src/server/auth/jwt.spec.ts +106 -0
- package/src/server/auth/jwt.ts +75 -0
- package/src/server/auth/loginThrottle.spec.ts +213 -0
- package/src/server/auth/loginThrottle.ts +261 -0
- package/src/server/auth/middleware.spec.ts +148 -0
- package/src/server/auth/middleware.ts +56 -0
- package/src/server/auth/password.spec.ts +48 -0
- package/src/server/auth/password.ts +71 -0
- package/src/server/auth/sessionStore.spec.ts +189 -0
- package/src/server/auth/sessionStore.ts +261 -0
- package/src/server/auth/totp.spec.ts +73 -0
- package/src/server/auth/totp.ts +92 -0
- package/src/server/auth/types.ts +146 -0
- package/src/server/auth/vault.spec.ts +51 -0
- package/src/server/auth/vault.ts +71 -0
- package/src/server/context.ts +37 -0
- package/src/server/ctgr.spec.ts +138 -0
- package/src/server/ctgr.ts +108 -0
- package/src/server/db/kysely.spec.ts +62 -0
- package/src/server/db/kysely.ts +21 -0
- package/src/server/db/plumbingTypes.ts +126 -0
- package/src/server/dropzone/duplicates.ts +121 -0
- package/src/server/dropzone/index.ts +30 -0
- package/src/server/dropzone/scan.spec.ts +255 -0
- package/src/server/dropzone/scan.ts +342 -0
- package/src/server/errors.spec.ts +76 -0
- package/src/server/errors.ts +22 -0
- package/src/server/guard/argon2Binding.spec.ts +117 -0
- package/src/server/guard/argon2Binding.ts +195 -0
- package/src/server/guard/argon2Kat.spec.ts +250 -0
- package/src/server/guard/guard.spec.ts +390 -0
- package/src/server/guard/index.ts +6 -0
- package/src/server/guard/kdfWorker.spec.ts +283 -0
- package/src/server/guard/kdfWorker.ts +367 -0
- package/src/server/guard/kdfWorkerEntry.ts +16 -0
- package/src/server/guard/passwordVerifier.spec.ts +71 -0
- package/src/server/guard/passwordVerifier.ts +175 -0
- package/src/server/guard/revocationStore.spec.ts +135 -0
- package/src/server/guard/revocationStore.ts +291 -0
- package/src/server/guard/sessionSigner.ts +425 -0
- package/src/server/guard/verifier.ts +27 -0
- package/src/server/incidents/channels.ts +487 -0
- package/src/server/incidents/checks.ts +120 -0
- package/src/server/incidents/incidents.spec.ts +463 -0
- package/src/server/incidents/index.ts +96 -0
- package/src/server/incidents/migrations.ts +66 -0
- package/src/server/incidents/notifier.ts +215 -0
- package/src/server/incidents/reporter.ts +110 -0
- package/src/server/incidents/routes.ts +314 -0
- package/src/server/incidents/store.ts +329 -0
- package/src/server/incidents/types.ts +116 -0
- package/src/server/index.ts +248 -0
- package/src/server/jobs/fireQueue.spec.ts +85 -0
- package/src/server/jobs/fireQueue.ts +37 -0
- package/src/server/jobs/index.ts +26 -0
- package/src/server/jobs/logger.ts +16 -0
- package/src/server/jobs/plainjobAdapter.spec.ts +34 -0
- package/src/server/jobs/plainjobAdapter.ts +54 -0
- package/src/server/jobs/polling.spec.ts +219 -0
- package/src/server/jobs/polling.ts +81 -0
- package/src/server/jobs/runner.spec.ts +213 -0
- package/src/server/jobs/runner.ts +213 -0
- package/src/server/jobs/timeout.spec.ts +35 -0
- package/src/server/jobs/timeout.ts +53 -0
- package/src/server/jobs/types.ts +188 -0
- package/src/server/master-lock/guard.spec.ts +347 -0
- package/src/server/master-lock/guard.ts +290 -0
- package/src/server/master-lock/index.ts +44 -0
- package/src/server/master-lock/lockPage.spec.ts +88 -0
- package/src/server/master-lock/lockPage.ts +313 -0
- package/src/server/master-lock/masterLock.spec.ts +338 -0
- package/src/server/master-lock/masterLock.ts +627 -0
- package/src/server/master-lock/principals.spec.ts +378 -0
- package/src/server/master-lock/principals.ts +175 -0
- package/src/server/master-lock/seed.spec.ts +69 -0
- package/src/server/master-lock/seed.ts +112 -0
- package/src/server/master-lock/store.ts +35 -0
- package/src/server/media-bun/image.spec.ts +115 -0
- package/src/server/media-bun/image.ts +134 -0
- package/src/server/media-bun/index.ts +52 -0
- package/src/server/media-bun/streaming.spec.ts +134 -0
- package/src/server/media-bun/streaming.ts +347 -0
- package/src/server/media-bun/video.spec.ts +115 -0
- package/src/server/media-bun/video.ts +232 -0
- package/src/server/metrics/metricsBuffer.ts +92 -0
- package/src/server/metrics/normalizeRoute.spec.ts +51 -0
- package/src/server/metrics/normalizeRoute.ts +63 -0
- package/src/server/middleware/correlationId.ts +24 -0
- package/src/server/middleware/corsAllowlist.spec.ts +118 -0
- package/src/server/middleware/corsAllowlist.ts +69 -0
- package/src/server/middleware/errorEnvelope.ts +38 -0
- package/src/server/middleware/index.ts +19 -0
- package/src/server/middleware/localOrigin.spec.ts +168 -0
- package/src/server/middleware/localOrigin.ts +139 -0
- package/src/server/middleware/rateLimit.spec.ts +72 -0
- package/src/server/middleware/rateLimit.ts +125 -0
- package/src/server/middleware/requestLogger.ts +95 -0
- package/src/server/middleware/securityHeaders.ts +78 -0
- package/src/server/middleware/zValidator.spec.ts +251 -0
- package/src/server/middleware/zValidator.ts +114 -0
- package/src/server/migration/runner.spec.ts +88 -0
- package/src/server/migration/runner.ts +87 -0
- package/src/server/migration/types.ts +17 -0
- package/src/server/net/outboundGuard.spec.ts +90 -0
- package/src/server/net/outboundGuard.ts +205 -0
- package/src/server/notifications/hub.ts +245 -0
- package/src/server/notifications/index.ts +77 -0
- package/src/server/notifications/migrations.ts +39 -0
- package/src/server/notifications/routes.ts +177 -0
- package/src/server/notifications/service.spec.ts +305 -0
- package/src/server/notifications/service.ts +112 -0
- package/src/server/paths/appDataHome.spec.ts +87 -0
- package/src/server/paths/appDataHome.ts +84 -0
- package/src/server/private-media/blobRoute.spec.ts +165 -0
- package/src/server/private-media/crypto.spec.ts +81 -0
- package/src/server/private-media/crypto.ts +155 -0
- package/src/server/private-media/gatePolicy.spec.ts +98 -0
- package/src/server/private-media/index-store.ts +83 -0
- package/src/server/private-media/mountPrivateRoutes.ts +265 -0
- package/src/server/private-media/pipeline.spec.ts +111 -0
- package/src/server/private-media/pipeline.ts +170 -0
- package/src/server/private-media/routes.spec.ts +265 -0
- package/src/server/private-media/unlock.ts +112 -0
- package/src/server/resume/index.ts +8 -0
- package/src/server/resume/resume.spec.ts +118 -0
- package/src/server/resume/routes.ts +92 -0
- package/src/server/resume/store.ts +82 -0
- package/src/server/retention/archive.spec.ts +181 -0
- package/src/server/retention/archive.ts +353 -0
- package/src/server/retention/fleet.ts +104 -0
- package/src/server/retention/index.ts +101 -0
- package/src/server/retention/policy.spec.ts +132 -0
- package/src/server/retention/policy.ts +392 -0
- package/src/server/retention/store.spec.ts +236 -0
- package/src/server/retention/store.ts +338 -0
- package/src/server/retention/sweeper.spec.ts +321 -0
- package/src/server/retention/sweeper.ts +367 -0
- package/src/server/retention/target.spec.ts +592 -0
- package/src/server/retention/target.ts +613 -0
- package/src/server/search/index.ts +12 -0
- package/src/server/search/pgSearchProvider.pg.test.ts +392 -0
- package/src/server/search/pgSearchProvider.ts +421 -0
- package/src/server/search/pgSearchSchema.ts +147 -0
- package/src/server/sharing/index.ts +65 -0
- package/src/server/sharing/migrations.ts +128 -0
- package/src/server/sharing/router.spec.ts +167 -0
- package/src/server/sharing/router.ts +194 -0
- package/src/server/sharing/sql.ts +109 -0
- package/src/server/sharing/store.spec.ts +408 -0
- package/src/server/sharing/store.ts +737 -0
- package/src/server/sqlite/backup.spec.ts +114 -0
- package/src/server/sqlite/backup.ts +154 -0
- package/src/server/sqlite/browse.spec.ts +70 -0
- package/src/server/sqlite/browse.ts +120 -0
- package/src/server/sqlite/export.spec.ts +43 -0
- package/src/server/sqlite/export.ts +74 -0
- package/src/server/sqlite/fts5.spec.ts +175 -0
- package/src/server/sqlite/fts5.ts +255 -0
- package/src/server/sqlite/health.spec.ts +79 -0
- package/src/server/sqlite/health.ts +105 -0
- package/src/server/sqlite/import.spec.ts +75 -0
- package/src/server/sqlite/import.ts +141 -0
- package/src/server/sqlite/index.ts +64 -0
- package/src/server/sqlite/readonlySql.spec.ts +58 -0
- package/src/server/sqlite/readonlySql.ts +67 -0
- package/src/server/sqlite/stats.spec.ts +21 -0
- package/src/server/sqlite/stats.ts +61 -0
- package/src/server/sqlite/viewer.spec.ts +127 -0
- package/src/server/sqlite/viewer.ts +72 -0
- package/src/server/storage/binaryServerProcessing.integration.spec.ts +222 -0
- package/src/server/storage/binaryServerProcessing.spec.ts +150 -0
- package/src/server/storage/binaryServerProcessing.ts +127 -0
- package/src/server/storage/binaryServerStore.ts +102 -0
- package/src/server/storage/chunked.integration.spec.ts +185 -0
- package/src/server/storage/diskStore.ts +83 -0
- package/src/server/storage/files/adapter.photo.display.spec.ts +169 -0
- package/src/server/storage/files/adapter.photo.spec.ts +128 -0
- package/src/server/storage/files/adapter.spec.ts +121 -0
- package/src/server/storage/files/adapter.ts +255 -0
- package/src/server/storage/files/catalogue.media-host.spec.ts +184 -0
- package/src/server/storage/files/catalogue.orderings.spec.ts +157 -0
- package/src/server/storage/files/catalogue.private.spec.ts +106 -0
- package/src/server/storage/files/catalogue.recover.spec.ts +131 -0
- package/src/server/storage/files/catalogue.sort.spec.ts +154 -0
- package/src/server/storage/files/catalogue.spec.ts +133 -0
- package/src/server/storage/files/catalogue.trash-import.spec.ts +125 -0
- package/src/server/storage/files/catalogue.ts +1326 -0
- package/src/server/storage/files/chunkedUpload.spec.ts +111 -0
- package/src/server/storage/files/chunkedUpload.ts +146 -0
- package/src/server/storage/files/collections.spec.ts +294 -0
- package/src/server/storage/files/fileRoutes.replace.spec.ts +88 -0
- package/src/server/storage/files/fileRoutes.spec.ts +327 -0
- package/src/server/storage/files/fileRoutes.ts +462 -0
- package/src/server/storage/files/index.ts +66 -0
- package/src/server/storage/files/migrations.ts +187 -0
- package/src/server/storage/files/mimeByExt.ts +14 -0
- package/src/server/storage/files/stack.spec.ts +258 -0
- package/src/server/storage/files/stack.ts +284 -0
- package/src/server/storage/files/storageOps.spec.ts +126 -0
- package/src/server/storage/files/storageOps.ts +213 -0
- package/src/server/storage/files/types.ts +285 -0
- package/src/server/storage/imageThumbnail.spec.ts +81 -0
- package/src/server/storage/imageThumbnail.ts +60 -0
- package/src/server/storage/index.ts +122 -0
- package/src/server/storage/mountMediaRoutes.ts +231 -0
- package/src/server/storage/objectStore.spec.ts +272 -0
- package/src/server/storage/objectStoreMediaStore.ts +63 -0
- package/src/server/storage/s3ObjectStore.ts +239 -0
- package/src/server/storage/sources.ts +245 -0
- package/src/server/storage/storage.spec.ts +219 -0
- package/src/server/storage/streamRange.ts +92 -0
- package/src/server/storage/streamToken.ts +76 -0
- package/src/server/storage/transcode.integration.spec.ts +134 -0
- package/src/server/storage/transcodeJobs.spec.ts +152 -0
- package/src/server/storage/transcodeJobs.ts +171 -0
- package/src/server/storage/types.ts +230 -0
- package/src/server/storage/videoDerivativeKeys.spec.ts +112 -0
- package/src/server/storage/videoDerivativeKeys.ts +172 -0
- package/src/server/storage/videoProcessor.posterOnly.spec.ts +106 -0
- package/src/server/storage/videoProcessor.ts +229 -0
- package/src/server/sync/alarm.spec.ts +149 -0
- package/src/server/sync/alarm.ts +137 -0
- package/src/server/sync/commands.spec.ts +145 -0
- package/src/server/sync/commands.ts +361 -0
- package/src/server/sync/engine.spec.ts +496 -0
- package/src/server/sync/engine.ts +255 -0
- package/src/server/sync/http.ts +316 -0
- package/src/server/sync/httpRemote.spec.ts +158 -0
- package/src/server/sync/index.ts +90 -0
- package/src/server/sync/opLog.spec.ts +110 -0
- package/src/server/sync/opLog.ts +189 -0
- package/src/server/sync/planner.spec.ts +53 -0
- package/src/server/sync/planner.ts +62 -0
- package/src/server/sync/status.spec.ts +92 -0
- package/src/server/sync/status.ts +108 -0
- package/src/server/sync/timer.spec.ts +150 -0
- package/src/server/sync/timer.ts +207 -0
- package/src/server/sync/tokens.spec.ts +38 -0
- package/src/server/sync/tokens.ts +94 -0
- package/src/server/sync/types.ts +108 -0
- package/src/server/ws/eventBus.spec.ts +67 -0
- package/src/server/ws/eventBus.ts +46 -0
- package/src/server/ws/hub.integration.spec.ts +93 -0
- package/src/server/ws/hub.spec.ts +282 -0
- package/src/server/ws/hub.ts +168 -0
- package/src/server/ws/index.ts +20 -0
- package/src/server/ws/presence.spec.ts +27 -0
- package/src/server/ws/presence.ts +26 -0
- package/src/server/ws/room.spec.ts +36 -0
- package/src/server/ws/room.ts +29 -0
- package/src/server/ws/types.ts +134 -0
- package/src/skippedTestsAreAnswered.spec.ts +215 -0
- package/src/testTmpRoot.spec.ts +102 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { SignJWT } from 'jose';
|
|
3
|
+
import { signToken, verifyPurposeToken, verifyToken } from './jwt';
|
|
4
|
+
import type { AppJwtPayload } from './types';
|
|
5
|
+
|
|
6
|
+
const SECRET = 'a-test-secret-at-least-32-chars-long!!';
|
|
7
|
+
|
|
8
|
+
describe('signToken / verifyToken', () => {
|
|
9
|
+
test('roundtrips the claims, defaulting getOnly handling to the caller', async () => {
|
|
10
|
+
const token = await signToken(
|
|
11
|
+
{ userId: 'u1', sessionId: 's1', roles: ['owner'], getOnly: false },
|
|
12
|
+
SECRET,
|
|
13
|
+
'15m',
|
|
14
|
+
);
|
|
15
|
+
const p = await verifyToken(token, SECRET);
|
|
16
|
+
expect(p.userId).toBe('u1');
|
|
17
|
+
expect(p.sessionId).toBe('s1');
|
|
18
|
+
expect(p.roles).toEqual(['owner']);
|
|
19
|
+
expect(p.getOnly).toBe(false);
|
|
20
|
+
// iat/exp are stamped automatically
|
|
21
|
+
expect(typeof p.iat).toBe('number');
|
|
22
|
+
expect(typeof p.exp).toBe('number');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('carries getOnly=true', async () => {
|
|
26
|
+
const token = await signToken(
|
|
27
|
+
{ userId: 'u1', sessionId: 's1', roles: [], getOnly: true },
|
|
28
|
+
SECRET,
|
|
29
|
+
'15m',
|
|
30
|
+
);
|
|
31
|
+
const p = await verifyToken(token, SECRET);
|
|
32
|
+
expect(p.getOnly).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('rejects a wrong secret', async () => {
|
|
36
|
+
const token = await signToken(
|
|
37
|
+
{ userId: 'u1', sessionId: 's1', roles: [], getOnly: false },
|
|
38
|
+
SECRET,
|
|
39
|
+
'15m',
|
|
40
|
+
);
|
|
41
|
+
await expect(verifyToken(token, `${SECRET}-different`)).rejects.toThrow();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('rejects an expired token', async () => {
|
|
45
|
+
const token = await signToken(
|
|
46
|
+
{ userId: 'u1', sessionId: 's1', roles: [], getOnly: false },
|
|
47
|
+
SECRET,
|
|
48
|
+
'0s',
|
|
49
|
+
);
|
|
50
|
+
// exp == iat == now → already expired (jose treats exp as "must be in the future")
|
|
51
|
+
await new Promise((r) => setTimeout(r, 1100));
|
|
52
|
+
await expect(verifyToken(token, SECRET)).rejects.toThrow();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('a numeric expiresIn is RELATIVE seconds-from-now (not an absolute epoch)', async () => {
|
|
56
|
+
const token = await signToken(
|
|
57
|
+
{ userId: 'u1', sessionId: 's1', roles: [], getOnly: false },
|
|
58
|
+
SECRET,
|
|
59
|
+
3600,
|
|
60
|
+
);
|
|
61
|
+
const p = await verifyToken(token, SECRET);
|
|
62
|
+
const now = Math.floor(Date.now() / 1000);
|
|
63
|
+
// exp should be ~1h ahead, NOT 3600 (1970) — the jose bare-number footgun.
|
|
64
|
+
expect(p.exp).toBeGreaterThan(now + 3000);
|
|
65
|
+
expect(p.exp).toBeLessThan(now + 4200);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('verifies a standard HS256 token NOT minted via signToken (jsonwebtoken-compat)', async () => {
|
|
69
|
+
// jose verifies any RFC-7519 HS256 JWT regardless of signer — the whole cutover
|
|
70
|
+
// guarantee. Mint one without the signToken sugar (no getOnly claim, like an old
|
|
71
|
+
// pre-getOnly jsonwebtoken token) and confirm verify accepts it + getOnly is absent.
|
|
72
|
+
const legacy = await new SignJWT({ userId: 'u1', sessionId: 's1', roles: [] })
|
|
73
|
+
.setProtectedHeader({ alg: 'HS256' })
|
|
74
|
+
.setIssuedAt()
|
|
75
|
+
.setExpirationTime('15m')
|
|
76
|
+
.sign(new TextEncoder().encode(SECRET));
|
|
77
|
+
const p = await verifyToken<AppJwtPayload>(legacy, SECRET);
|
|
78
|
+
expect(p.userId).toBe('u1');
|
|
79
|
+
expect(p.getOnly).toBeUndefined();
|
|
80
|
+
expect(p.getOnly ?? false).toBe(false);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe('verifyPurposeToken', () => {
|
|
85
|
+
test('accepts a matching purpose, rejects a mismatch', async () => {
|
|
86
|
+
const token = await signToken(
|
|
87
|
+
{ userId: 'u1', sessionId: 's1', roles: [], getOnly: false, purpose: 'vault-unlock' },
|
|
88
|
+
SECRET,
|
|
89
|
+
'15m',
|
|
90
|
+
);
|
|
91
|
+
const p = await verifyPurposeToken(token, SECRET, 'vault-unlock');
|
|
92
|
+
expect(p.userId).toBe('u1');
|
|
93
|
+
await expect(verifyPurposeToken(token, SECRET, 'pm-unlock')).rejects.toThrow(
|
|
94
|
+
/purpose mismatch/,
|
|
95
|
+
);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('rejects a plain access token replayed as a purpose token', async () => {
|
|
99
|
+
const access = await signToken(
|
|
100
|
+
{ userId: 'u1', sessionId: 's1', roles: [], getOnly: false },
|
|
101
|
+
SECRET,
|
|
102
|
+
'15m',
|
|
103
|
+
);
|
|
104
|
+
await expect(verifyPurposeToken(access, SECRET, 'vault-unlock')).rejects.toThrow();
|
|
105
|
+
});
|
|
106
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { type JWTPayload, jwtVerify, SignJWT } from 'jose';
|
|
2
|
+
import type { AppJwtPayload } from './types';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* JWT sign/verify on `jose` (Web Crypto HMAC — native on Bun + Node, no native
|
|
6
|
+
* deps; replaces `jsonwebtoken`). Every token system in the apps — the access
|
|
7
|
+
* token plus the short-lived vault/private-media/files/yt-oauth unlock tokens —
|
|
8
|
+
* goes through these, so there is exactly ONE jose integration to reason about.
|
|
9
|
+
*
|
|
10
|
+
* jose verifies any standard HS256 JWT regardless of which library signed it, so a
|
|
11
|
+
* token previously minted by `jsonwebtoken` validates here unchanged — that is the
|
|
12
|
+
* whole cutover guarantee (no dual-verify shim or `jsonwebtoken` dependency is
|
|
13
|
+
* carried into this from-scratch toolkit). A pre-`getOnly` token simply has no
|
|
14
|
+
* `getOnly` claim; the auth middleware reads it as `false`.
|
|
15
|
+
*
|
|
16
|
+
* NOTE: Web Crypto HMAC is async, so these are async (jsonwebtoken was sync — every
|
|
17
|
+
* caller becomes async/await).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const keyOf = (secret: string): Uint8Array => new TextEncoder().encode(secret);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Normalize jose's expiration input. jose treats a bare **number** as an absolute
|
|
24
|
+
* UNIX timestamp (seconds) — so `3600` would mean "1970" and expire instantly. We
|
|
25
|
+
* treat a number as RELATIVE seconds-from-now (the obviously-intended meaning) by
|
|
26
|
+
* handing jose a `"<n>s"` duration; a string (`'15m'`, `'24h'`) passes through as a
|
|
27
|
+
* jose duration unchanged.
|
|
28
|
+
*/
|
|
29
|
+
const asDuration = (expiresIn: string | number): string =>
|
|
30
|
+
typeof expiresIn === 'number' ? `${expiresIn}s` : expiresIn;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Sign an HS256 token. `expiresIn` is a jose duration string (`'15m'`, `'24h'`) or a
|
|
34
|
+
* number of seconds-from-now. `iat`/`exp` are set automatically — pass everything
|
|
35
|
+
* else (`userId`, `sessionId`, `roles`, `getOnly`, optional `purpose`).
|
|
36
|
+
*/
|
|
37
|
+
export async function signToken(
|
|
38
|
+
payload: Omit<AppJwtPayload, 'iat' | 'exp'>,
|
|
39
|
+
secret: string,
|
|
40
|
+
expiresIn: string | number,
|
|
41
|
+
): Promise<string> {
|
|
42
|
+
return new SignJWT(payload as JWTPayload)
|
|
43
|
+
.setProtectedHeader({ alg: 'HS256' })
|
|
44
|
+
.setIssuedAt()
|
|
45
|
+
.setExpirationTime(asDuration(expiresIn))
|
|
46
|
+
.sign(keyOf(secret));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Verify signature + claims and return the decoded payload (throws on bad
|
|
50
|
+
* signature / expiry / malformed). */
|
|
51
|
+
export async function verifyToken<T extends JWTPayload = AppJwtPayload>(
|
|
52
|
+
token: string,
|
|
53
|
+
secret: string,
|
|
54
|
+
): Promise<T> {
|
|
55
|
+
const { payload } = await jwtVerify(token, keyOf(secret));
|
|
56
|
+
return payload as T;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Verify a single-purpose token (vault/private-media/files/yt-oauth) AND assert its
|
|
61
|
+
* `purpose` claim matches — so an access token (or a token for a *different*
|
|
62
|
+
* feature) can never be replayed as, say, a vault-unlock token. Throws on a bad
|
|
63
|
+
* signature, expiry, or a purpose mismatch.
|
|
64
|
+
*/
|
|
65
|
+
export async function verifyPurposeToken<T extends AppJwtPayload = AppJwtPayload>(
|
|
66
|
+
token: string,
|
|
67
|
+
secret: string,
|
|
68
|
+
purpose: string,
|
|
69
|
+
): Promise<T> {
|
|
70
|
+
const payload = await verifyToken<T>(token, secret);
|
|
71
|
+
if (payload.purpose !== purpose) {
|
|
72
|
+
throw new Error(`token purpose mismatch: expected '${purpose}', got '${payload.purpose}'`);
|
|
73
|
+
}
|
|
74
|
+
return payload;
|
|
75
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import { clientKeyOf, createLoginThrottle } from './loginThrottle';
|
|
3
|
+
|
|
4
|
+
const clock = (start = 1_000_000) => {
|
|
5
|
+
let t = start;
|
|
6
|
+
return { now: () => t, advance: (ms: number) => (t += ms) };
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
describe('clientKeyOf', () => {
|
|
10
|
+
// The security-relevant case. `$proxy_add_x_forwarded_for` APPENDS the socket
|
|
11
|
+
// peer, so the LAST entry is what our own nginx observed; the first is whatever
|
|
12
|
+
// the client typed. Reading the first is the classic version of this bug, and it
|
|
13
|
+
// would let an attacker mint a fresh per-IP budget on every request.
|
|
14
|
+
it('reads the LAST forwarded hop, not the client-supplied first one', () => {
|
|
15
|
+
const req = new Request('https://x.example/api/auth/login', {
|
|
16
|
+
headers: { 'x-forwarded-for': '1.2.3.4, 203.0.113.9' },
|
|
17
|
+
});
|
|
18
|
+
expect(clientKeyOf(req)).toBe('203.0.113.9');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("ignores a spoofed chain's padding entirely", () => {
|
|
22
|
+
const req = new Request('https://x.example/', {
|
|
23
|
+
headers: { 'x-forwarded-for': '9.9.9.9, 8.8.8.8, 7.7.7.7, 203.0.113.9' },
|
|
24
|
+
});
|
|
25
|
+
expect(clientKeyOf(req)).toBe('203.0.113.9');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('falls back to x-real-ip, then the socket, then ONE shared key', () => {
|
|
29
|
+
expect(
|
|
30
|
+
clientKeyOf(new Request('https://x.example/', { headers: { 'x-real-ip': '198.51.100.7' } })),
|
|
31
|
+
).toBe('198.51.100.7');
|
|
32
|
+
expect(clientKeyOf(new Request('https://x.example/'), '192.0.2.5')).toBe('192.0.2.5');
|
|
33
|
+
// Deliberately NOT a unique key per request — that would silently disable the
|
|
34
|
+
// per-IP layer for exactly the clients we can least identify.
|
|
35
|
+
expect(clientKeyOf(new Request('https://x.example/'))).toBe('unknown');
|
|
36
|
+
expect(clientKeyOf(new Request('https://x.example/'), ' ')).toBe('unknown');
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('createLoginThrottle', () => {
|
|
41
|
+
it('allows the first attempts, then delays with exponential backoff', () => {
|
|
42
|
+
const c = clock();
|
|
43
|
+
const t = createLoginThrottle({ now: c.now, threshold: 3, baseDelayMs: 1_000 });
|
|
44
|
+
expect(t.check('ip')).toEqual({ allowed: true });
|
|
45
|
+
|
|
46
|
+
// Three free failures — a real person mistyping a password.
|
|
47
|
+
for (let i = 0; i < 3; i++) t.recordFailure('ip');
|
|
48
|
+
expect(t.check('ip')).toEqual({ allowed: true });
|
|
49
|
+
|
|
50
|
+
const fourth = t.recordFailure('ip');
|
|
51
|
+
expect(fourth).toEqual({ allowed: false, scope: 'ip', retryAfterSeconds: 1 });
|
|
52
|
+
|
|
53
|
+
c.advance(1_000);
|
|
54
|
+
expect(t.check('ip')).toEqual({ allowed: true });
|
|
55
|
+
|
|
56
|
+
const fifth = t.recordFailure('ip');
|
|
57
|
+
expect(fifth.allowed).toBe(false);
|
|
58
|
+
if (!fifth.allowed) expect(fifth.retryAfterSeconds).toBe(2); // doubled
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('caps the per-IP delay instead of growing without bound', () => {
|
|
62
|
+
const c = clock();
|
|
63
|
+
// globalThreshold high, so this test observes the per-IP cap in isolation —
|
|
64
|
+
// the global lock would otherwise decide (and rightly so, in production).
|
|
65
|
+
const t = createLoginThrottle({
|
|
66
|
+
now: c.now,
|
|
67
|
+
threshold: 1,
|
|
68
|
+
baseDelayMs: 1_000,
|
|
69
|
+
maxDelayMs: 10_000,
|
|
70
|
+
globalThreshold: 10_000,
|
|
71
|
+
});
|
|
72
|
+
for (let i = 0; i < 40; i++) {
|
|
73
|
+
t.recordFailure('ip');
|
|
74
|
+
c.advance(60_000); // wait out each lockout so failures keep compounding
|
|
75
|
+
}
|
|
76
|
+
const v = t.recordFailure('ip');
|
|
77
|
+
expect(v.allowed).toBe(false);
|
|
78
|
+
// Capped, and specifically NOT Infinity/NaN from an overflowing 2**n.
|
|
79
|
+
if (!v.allowed) expect(v.retryAfterSeconds).toBe(10);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('a quiet window forgives, so an old mistype does not compound', () => {
|
|
83
|
+
const c = clock();
|
|
84
|
+
const t = createLoginThrottle({
|
|
85
|
+
now: c.now,
|
|
86
|
+
threshold: 2,
|
|
87
|
+
baseDelayMs: 1_000,
|
|
88
|
+
windowMs: 60_000,
|
|
89
|
+
});
|
|
90
|
+
t.recordFailure('ip');
|
|
91
|
+
t.recordFailure('ip');
|
|
92
|
+
c.advance(120_000); // longer than the window
|
|
93
|
+
// Counter reset: this is failure #1 again, still inside the free allowance.
|
|
94
|
+
expect(t.recordFailure('ip')).toEqual({ allowed: true });
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("one client's failures do not lock a different client out", () => {
|
|
98
|
+
const c = clock();
|
|
99
|
+
const t = createLoginThrottle({ now: c.now, threshold: 1, globalThreshold: 100 });
|
|
100
|
+
for (let i = 0; i < 5; i++) t.recordFailure('noisy');
|
|
101
|
+
expect(t.check('noisy').allowed).toBe(false);
|
|
102
|
+
expect(t.check('quiet')).toEqual({ allowed: true });
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// The layer that actually defends the door: rotating X-Forwarded-For gives an
|
|
106
|
+
// attacker a fresh per-IP budget every time, and the global counter is what
|
|
107
|
+
// makes that pointless.
|
|
108
|
+
it('the global counter catches an attacker rotating their apparent IP', () => {
|
|
109
|
+
const c = clock();
|
|
110
|
+
const t = createLoginThrottle({
|
|
111
|
+
now: c.now,
|
|
112
|
+
threshold: 5,
|
|
113
|
+
globalThreshold: 10,
|
|
114
|
+
baseDelayMs: 1_000,
|
|
115
|
+
});
|
|
116
|
+
// Every attempt from a brand-new 'IP', so no per-IP counter ever trips.
|
|
117
|
+
for (let i = 0; i < 10; i++) t.recordFailure(`ip-${i}`);
|
|
118
|
+
const next = t.recordFailure('ip-fresh');
|
|
119
|
+
expect(next.allowed).toBe(false);
|
|
120
|
+
if (!next.allowed) expect(next.scope).toBe('global');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('the global ceiling stays low, so the owner is never locked out for long', () => {
|
|
124
|
+
const c = clock();
|
|
125
|
+
const t = createLoginThrottle({
|
|
126
|
+
now: c.now,
|
|
127
|
+
globalThreshold: 1,
|
|
128
|
+
baseDelayMs: 1_000,
|
|
129
|
+
globalMaxDelayMs: 60_000,
|
|
130
|
+
});
|
|
131
|
+
for (let i = 0; i < 30; i++) {
|
|
132
|
+
t.recordFailure(`ip-${i}`);
|
|
133
|
+
c.advance(120_000);
|
|
134
|
+
}
|
|
135
|
+
const v = t.recordFailure('ip-final');
|
|
136
|
+
expect(v.allowed).toBe(false);
|
|
137
|
+
// A minute, not the 15 the per-IP layer allows — see the module's DoS note.
|
|
138
|
+
if (!v.allowed) expect(v.retryAfterSeconds).toBeLessThanOrEqual(60);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('a success clears both the key and the global counter', () => {
|
|
142
|
+
const c = clock();
|
|
143
|
+
const t = createLoginThrottle({
|
|
144
|
+
now: c.now,
|
|
145
|
+
threshold: 1,
|
|
146
|
+
globalThreshold: 1,
|
|
147
|
+
baseDelayMs: 5_000,
|
|
148
|
+
});
|
|
149
|
+
t.recordFailure('ip');
|
|
150
|
+
t.recordFailure('ip');
|
|
151
|
+
expect(t.check('ip').allowed).toBe(false);
|
|
152
|
+
|
|
153
|
+
t.recordSuccess('ip');
|
|
154
|
+
expect(t.check('ip')).toEqual({ allowed: true });
|
|
155
|
+
// And a different client is not left serving the attacker's sentence.
|
|
156
|
+
expect(t.check('other')).toEqual({ allowed: true });
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// An unbounded map keyed by attacker-controlled input is a memory-exhaustion
|
|
160
|
+
// vector, so the cap has to be a real limit and not a comment.
|
|
161
|
+
it('bounds the table, and keeps defending after eviction', () => {
|
|
162
|
+
const c = clock();
|
|
163
|
+
const t = createLoginThrottle({ now: c.now, maxKeys: 50, globalThreshold: 10, threshold: 1 });
|
|
164
|
+
for (let i = 0; i < 5_000; i++) t.recordFailure(`ip-${i}`);
|
|
165
|
+
expect(t.size).toBeLessThanOrEqual(50);
|
|
166
|
+
// The global counter survived the eviction that dropped 4,950 keys.
|
|
167
|
+
expect(t.check('ip-brand-new').allowed).toBe(false);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// The hole the satellites' copy had, found when the two implementations were
|
|
171
|
+
// consolidated (2026-08-02). The recommended key is `<email>:<ip>`, and email is
|
|
172
|
+
// case-insensitive, so an unnormalized key lets an attacker mint a fresh per-account
|
|
173
|
+
// budget by flipping one letter's case.
|
|
174
|
+
it('normalizes keys, so varying case cannot evade the per-account budget', () => {
|
|
175
|
+
const c = clock();
|
|
176
|
+
const t = createLoginThrottle({ now: c.now, threshold: 1, baseDelayMs: 1_000, globalThreshold: 10_000 });
|
|
177
|
+
t.recordFailure('Victim@example.com:1.2.3.4');
|
|
178
|
+
t.recordFailure('victim@EXAMPLE.com:1.2.3.4 ');
|
|
179
|
+
expect(t.check('VICTIM@example.com:1.2.3.4').allowed).toBe(false);
|
|
180
|
+
expect(t.size).toBe(1);
|
|
181
|
+
// A genuinely different account is untouched.
|
|
182
|
+
expect(t.check('other@example.com:1.2.3.4')).toEqual({ allowed: true });
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('reset() clears one key without pardoning the global counter', () => {
|
|
186
|
+
const c = clock();
|
|
187
|
+
const t = createLoginThrottle({ now: c.now, threshold: 1, baseDelayMs: 5_000, globalThreshold: 2 });
|
|
188
|
+
t.recordFailure('ip');
|
|
189
|
+
t.recordFailure('ip');
|
|
190
|
+
t.recordFailure('ip');
|
|
191
|
+
expect(t.check('ip').allowed).toBe(false);
|
|
192
|
+
t.reset('ip');
|
|
193
|
+
// Still refused — by the GLOBAL layer, which an admin unlock must not clear.
|
|
194
|
+
const after = t.check('ip');
|
|
195
|
+
expect(after.allowed).toBe(false);
|
|
196
|
+
if (!after.allowed) expect(after.scope).toBe('global');
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('forgets stale keys once their window passes', () => {
|
|
200
|
+
const c = clock();
|
|
201
|
+
const t = createLoginThrottle({
|
|
202
|
+
now: c.now,
|
|
203
|
+
windowMs: 60_000,
|
|
204
|
+
threshold: 10,
|
|
205
|
+
globalThreshold: 1000,
|
|
206
|
+
});
|
|
207
|
+
for (let i = 0; i < 20; i++) t.recordFailure(`ip-${i}`);
|
|
208
|
+
expect(t.size).toBe(20);
|
|
209
|
+
c.advance(120_000);
|
|
210
|
+
t.recordFailure('someone-new'); // any activity triggers the prune
|
|
211
|
+
expect(t.size).toBe(1);
|
|
212
|
+
});
|
|
213
|
+
});
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Failed-login backoff for every password door in the fleet — cc's `guard` mixin and
|
|
3
|
+
* the satellites' own auth stacks alike.
|
|
4
|
+
*
|
|
5
|
+
* ── Why this shape (consolidated 2026-08-02) ────────────────────────────────
|
|
6
|
+
* There were two of these. This file used to hold a fixed-window lockout (N failures
|
|
7
|
+
* in a window → locked for M minutes); `cursed-satellites` grew a second, better one
|
|
8
|
+
* in `satellite-kit` because at the time `cursed-core` was treated as read-only from
|
|
9
|
+
* there. That precedent is repealed, so the EVOLVED implementation lands here and the
|
|
10
|
+
* satellites import it back. Everything below that reads like over-engineering is a
|
|
11
|
+
* lesson the fixed-window version had already lost.
|
|
12
|
+
*
|
|
13
|
+
* ── The trust model, which is the whole design ───────────────────────────────
|
|
14
|
+
* A client-supplied `X-Forwarded-For` is forgeable, so per-IP limiting alone is
|
|
15
|
+
* defeated by rotating the header. Two things follow, and both are deliberate:
|
|
16
|
+
*
|
|
17
|
+
* 1. **The IP is read from the LAST hop, not the first.** Behind nginx,
|
|
18
|
+
* `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for` APPENDS the socket
|
|
19
|
+
* peer to whatever the client sent. The final entry is therefore the address our
|
|
20
|
+
* own proxy observed; the first is whatever the client typed. Reading the first is
|
|
21
|
+
* the classic version of this bug.
|
|
22
|
+
* 2. **The GLOBAL counter is the actual defense.** For a single-principal door
|
|
23
|
+
* ("somebody is failing a lot" is enough to slow everybody down) it cannot be
|
|
24
|
+
* evaded by rotating a header at all. Per-IP limiting is the courtesy layer that
|
|
25
|
+
* keeps one noisy client from being the reason the legitimate user waits.
|
|
26
|
+
*
|
|
27
|
+
* ── The lockout-vs-DoS trade, stated plainly ─────────────────────────────────
|
|
28
|
+
* A global lock means an attacker can make a legitimate user wait. That is why the
|
|
29
|
+
* global ceiling is 60s while the per-IP ceiling is 15 minutes: a minute of delay per
|
|
30
|
+
* attempt reduces a distributed brute force to a rounding error against argon2id, and
|
|
31
|
+
* never locks someone out of their own dashboard for a quarter of an hour. A
|
|
32
|
+
* successful sign-in clears both counters.
|
|
33
|
+
*
|
|
34
|
+
* ── Exponential, not fixed-window ───────────────────────────────────────────
|
|
35
|
+
* Delay is `base·2^(n-1)` past the threshold rather than a flat lockout, so an honest
|
|
36
|
+
* typo costs a second and a scripted run costs progressively everything. A quiet
|
|
37
|
+
* window forgives: someone who mistyped twice last week starts fresh.
|
|
38
|
+
*
|
|
39
|
+
* ── Why in-memory, and why bounded ──────────────────────────────────────────
|
|
40
|
+
* The state is per-process and does not survive a restart. That is acceptable: an
|
|
41
|
+
* attacker cannot cause a restart, and the only thing that does — a deploy — is
|
|
42
|
+
* operator-initiated and rare. What is NOT acceptable is an unbounded map keyed by
|
|
43
|
+
* attacker-controlled input, so the table has a hard key cap and evicts the
|
|
44
|
+
* least-recently-seen entry when full. The cap is a real limit, not a comment: the
|
|
45
|
+
* global counter keeps working after eviction, which is precisely the case where it
|
|
46
|
+
* matters most.
|
|
47
|
+
*
|
|
48
|
+
* Pair with the coarse per-IP `createRateLimiter` (`../middleware/rateLimit`); this is
|
|
49
|
+
* the strict per-identity layer.
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
export interface LoginThrottleOptions {
|
|
53
|
+
/** Injected clock, so backoff is testable without waiting. */
|
|
54
|
+
now?: () => number;
|
|
55
|
+
/**
|
|
56
|
+
* Failures that incur NO delay. Default 5.
|
|
57
|
+
*
|
|
58
|
+
* Read it precisely: with `threshold: 5`, failures 1–5 are answered 401 with no
|
|
59
|
+
* lock, failure 6 is ALSO answered 401 and is the one that arms the first delay, so
|
|
60
|
+
* attempt 7 is the first refusal. The extra attempt is deliberate — the alternative
|
|
61
|
+
* (lock on the Nth failure itself) means a 429 whose cause the user cannot
|
|
62
|
+
* distinguish from a wrong password.
|
|
63
|
+
*/
|
|
64
|
+
threshold?: number;
|
|
65
|
+
/** First delay after the threshold, doubling per further failure. Default 1s. */
|
|
66
|
+
baseDelayMs?: number;
|
|
67
|
+
/** Ceiling on the per-key delay. Default 15 minutes. */
|
|
68
|
+
maxDelayMs?: number;
|
|
69
|
+
/** Free failures, across ALL clients, before everyone is delayed. Default 20. */
|
|
70
|
+
globalThreshold?: number;
|
|
71
|
+
/** Ceiling on the global delay — deliberately lower; see the header. Default 60s. */
|
|
72
|
+
globalMaxDelayMs?: number;
|
|
73
|
+
/** A key with no failures for this long is forgotten. Default 1 hour. */
|
|
74
|
+
windowMs?: number;
|
|
75
|
+
/** Hard cap on tracked keys, so the table cannot be a memory vector. Default 4096. */
|
|
76
|
+
maxKeys?: number;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export type ThrottleVerdict =
|
|
80
|
+
| { allowed: true }
|
|
81
|
+
| { allowed: false; retryAfterSeconds: number; scope: 'ip' | 'global' };
|
|
82
|
+
|
|
83
|
+
export interface LoginThrottle {
|
|
84
|
+
/** May this key attempt a login right now? */
|
|
85
|
+
check(key: string): ThrottleVerdict;
|
|
86
|
+
/** Record a failed attempt. Returns the verdict the NEXT attempt would get. */
|
|
87
|
+
recordFailure(key: string): ThrottleVerdict;
|
|
88
|
+
/** Record a success — clears this key and the global counter. */
|
|
89
|
+
recordSuccess(key: string): void;
|
|
90
|
+
/** Clear one key without touching the global counter (admin unlock, verified reset). */
|
|
91
|
+
reset(key: string): void;
|
|
92
|
+
/** Tracked-key count, for the bounded-growth test. */
|
|
93
|
+
readonly size: number;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The client address to key on — the LAST `X-Forwarded-For` entry (what our proxy
|
|
98
|
+
* saw), falling back to `X-Real-IP`, the socket peer, and finally a shared constant.
|
|
99
|
+
*
|
|
100
|
+
* The fallback deliberately collapses to ONE key rather than inventing a unique one
|
|
101
|
+
* per request. A per-request key would make every attempt look like a first attempt
|
|
102
|
+
* from a new client, i.e. it would silently disable the per-IP layer. Collapsing means
|
|
103
|
+
* an unidentifiable client shares a budget with every other unidentifiable client,
|
|
104
|
+
* which is the conservative direction.
|
|
105
|
+
*/
|
|
106
|
+
export function clientKeyOf(req: Request, socketAddress?: string | null): string {
|
|
107
|
+
const forwarded = req.headers.get('x-forwarded-for');
|
|
108
|
+
if (forwarded) {
|
|
109
|
+
const hops = forwarded
|
|
110
|
+
.split(',')
|
|
111
|
+
.map((h) => h.trim())
|
|
112
|
+
.filter((h) => h.length > 0);
|
|
113
|
+
// LAST, not first — see the header.
|
|
114
|
+
const observed = hops[hops.length - 1];
|
|
115
|
+
if (observed) return observed;
|
|
116
|
+
}
|
|
117
|
+
const real = req.headers.get('x-real-ip')?.trim();
|
|
118
|
+
if (real) return real;
|
|
119
|
+
return socketAddress?.trim() || 'unknown';
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The same, from inside a Hono handler.
|
|
124
|
+
*
|
|
125
|
+
* Under Bun, Hono's `env` IS the `Server` (the second argument to `app.fetch`), and
|
|
126
|
+
* `Server.requestIP(req)` is the only source of the real socket peer. Every step is
|
|
127
|
+
* defensive because that plumbing is the least stable thing here: `env` is `undefined`
|
|
128
|
+
* under `app.request()` in tests, and a future adapter could pass something else
|
|
129
|
+
* entirely. A missing address is not an error — `clientKeyOf` falls back to the
|
|
130
|
+
* headers, which is what production actually uses behind nginx.
|
|
131
|
+
*/
|
|
132
|
+
export function clientKeyOfContext(req: Request, env: unknown): string {
|
|
133
|
+
let address: string | null = null;
|
|
134
|
+
const requestIP = (env as { requestIP?: unknown } | null | undefined)?.requestIP;
|
|
135
|
+
if (typeof requestIP === 'function') {
|
|
136
|
+
try {
|
|
137
|
+
address = (requestIP.call(env, req) as { address?: string } | null)?.address ?? null;
|
|
138
|
+
} catch {
|
|
139
|
+
address = null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return clientKeyOf(req, address);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
interface Attempt {
|
|
146
|
+
failures: number;
|
|
147
|
+
/** When the current lockout ends; 0 when not locked. */
|
|
148
|
+
lockedUntil: number;
|
|
149
|
+
lastSeen: number;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Keys are compared case- and whitespace-insensitively.
|
|
154
|
+
*
|
|
155
|
+
* This is NOT cosmetic, and it is the one behavior the satellites' version had lost:
|
|
156
|
+
* the recommended key is `"<email>:<ip>"`, and an email address is case-insensitive,
|
|
157
|
+
* so without this `Victim@example.com` and `victim@example.com` are separate budgets
|
|
158
|
+
* and an attacker evades the per-account layer by varying the case of a letter. The
|
|
159
|
+
* global counter still caught that, but only after 20 free guesses per attacker.
|
|
160
|
+
*/
|
|
161
|
+
const normalizeKey = (key: string): string => key.trim().toLowerCase();
|
|
162
|
+
|
|
163
|
+
export function createLoginThrottle(options: LoginThrottleOptions = {}): LoginThrottle {
|
|
164
|
+
const now = options.now ?? Date.now;
|
|
165
|
+
const threshold = options.threshold ?? 5;
|
|
166
|
+
const baseDelayMs = options.baseDelayMs ?? 1_000;
|
|
167
|
+
const maxDelayMs = options.maxDelayMs ?? 15 * 60_000;
|
|
168
|
+
const globalThreshold = options.globalThreshold ?? 20;
|
|
169
|
+
const globalMaxDelayMs = options.globalMaxDelayMs ?? 60_000;
|
|
170
|
+
const windowMs = options.windowMs ?? 60 * 60_000;
|
|
171
|
+
const maxKeys = options.maxKeys ?? 4096;
|
|
172
|
+
|
|
173
|
+
const attempts = new Map<string, Attempt>();
|
|
174
|
+
const global: Attempt = { failures: 0, lockedUntil: 0, lastSeen: 0 };
|
|
175
|
+
|
|
176
|
+
/** Delay for the nth failure past a threshold: base·2^(n-1), capped. */
|
|
177
|
+
const delayFor = (failures: number, limit: number, cap: number): number => {
|
|
178
|
+
const past = failures - limit;
|
|
179
|
+
if (past <= 0) return 0;
|
|
180
|
+
// `Math.min` before the shift would still overflow for large `past`; clamp the
|
|
181
|
+
// exponent instead so a client that keeps hammering cannot reach Infinity.
|
|
182
|
+
const exponent = Math.min(past - 1, 32);
|
|
183
|
+
return Math.min(baseDelayMs * 2 ** exponent, cap);
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
/** Drop stale entries; then, if still over the cap, evict least-recently-seen. */
|
|
187
|
+
const prune = (nowMs: number): void => {
|
|
188
|
+
for (const [key, entry] of attempts) {
|
|
189
|
+
if (nowMs - entry.lastSeen > windowMs && entry.lockedUntil <= nowMs) attempts.delete(key);
|
|
190
|
+
}
|
|
191
|
+
if (attempts.size <= maxKeys) return;
|
|
192
|
+
const byAge = [...attempts.entries()].sort((a, b) => a[1].lastSeen - b[1].lastSeen);
|
|
193
|
+
for (const [key] of byAge.slice(0, attempts.size - maxKeys)) attempts.delete(key);
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const verdict = (rawKey: string, nowMs: number): ThrottleVerdict => {
|
|
197
|
+
const key = normalizeKey(rawKey);
|
|
198
|
+
// Global first: it is the layer that cannot be evaded by rotating a header, so it
|
|
199
|
+
// should also be the one that decides.
|
|
200
|
+
if (global.lockedUntil > nowMs) {
|
|
201
|
+
return {
|
|
202
|
+
allowed: false,
|
|
203
|
+
scope: 'global',
|
|
204
|
+
retryAfterSeconds: Math.ceil((global.lockedUntil - nowMs) / 1000),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
const entry = attempts.get(key);
|
|
208
|
+
if (entry && entry.lockedUntil > nowMs) {
|
|
209
|
+
return {
|
|
210
|
+
allowed: false,
|
|
211
|
+
scope: 'ip',
|
|
212
|
+
retryAfterSeconds: Math.ceil((entry.lockedUntil - nowMs) / 1000),
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
return { allowed: true };
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
check(key) {
|
|
220
|
+
return verdict(key, now());
|
|
221
|
+
},
|
|
222
|
+
|
|
223
|
+
recordFailure(rawKey) {
|
|
224
|
+
const key = normalizeKey(rawKey);
|
|
225
|
+
const nowMs = now();
|
|
226
|
+
const entry = attempts.get(key) ?? { failures: 0, lockedUntil: 0, lastSeen: nowMs };
|
|
227
|
+
// A quiet window forgives: someone who mistyped twice last week starts fresh,
|
|
228
|
+
// while a run of failures keeps compounding.
|
|
229
|
+
if (nowMs - entry.lastSeen > windowMs) entry.failures = 0;
|
|
230
|
+
entry.failures += 1;
|
|
231
|
+
entry.lastSeen = nowMs;
|
|
232
|
+
entry.lockedUntil = nowMs + delayFor(entry.failures, threshold, maxDelayMs);
|
|
233
|
+
attempts.set(key, entry);
|
|
234
|
+
|
|
235
|
+
if (nowMs - global.lastSeen > windowMs) global.failures = 0;
|
|
236
|
+
global.failures += 1;
|
|
237
|
+
global.lastSeen = nowMs;
|
|
238
|
+
global.lockedUntil = nowMs + delayFor(global.failures, globalThreshold, globalMaxDelayMs);
|
|
239
|
+
|
|
240
|
+
prune(nowMs);
|
|
241
|
+
return verdict(key, nowMs);
|
|
242
|
+
},
|
|
243
|
+
|
|
244
|
+
recordSuccess(rawKey) {
|
|
245
|
+
attempts.delete(normalizeKey(rawKey));
|
|
246
|
+
// The user got in, so whatever the global counter was reacting to is over.
|
|
247
|
+
// Leaving it armed would punish the legitimate user for the attack.
|
|
248
|
+
global.failures = 0;
|
|
249
|
+
global.lockedUntil = 0;
|
|
250
|
+
global.lastSeen = now();
|
|
251
|
+
},
|
|
252
|
+
|
|
253
|
+
reset(rawKey) {
|
|
254
|
+
attempts.delete(normalizeKey(rawKey));
|
|
255
|
+
},
|
|
256
|
+
|
|
257
|
+
get size() {
|
|
258
|
+
return attempts.size;
|
|
259
|
+
},
|
|
260
|
+
};
|
|
261
|
+
}
|