@mandujs/core 0.53.3 → 0.54.1
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/README.md +654 -654
- package/package.json +1 -1
- package/src/a11y/__tests__/run-audit.test.ts +333 -333
- package/src/a11y/fix-hints.ts +76 -76
- package/src/a11y/index.ts +18 -18
- package/src/a11y/run-audit.ts +394 -394
- package/src/a11y/types.ts +125 -125
- package/src/auth/__tests__/login.test.ts +1 -1
- package/src/auth/__tests__/password.test.ts +122 -122
- package/src/auth/__tests__/tokens.test.ts +274 -274
- package/src/auth/__tests__/verification.test.ts +274 -274
- package/src/auth/index.ts +76 -76
- package/src/auth/login.ts +225 -225
- package/src/auth/password.ts +120 -120
- package/src/auth/reset.ts +243 -243
- package/src/auth/tokens.ts +612 -612
- package/src/auth/verification.ts +253 -253
- package/src/brain/__tests__/redactor.test.ts +94 -94
- package/src/brain/adapters/__tests__/_helpers.ts +83 -83
- package/src/brain/adapters/__tests__/anthropic-oauth.test.ts +196 -196
- package/src/brain/adapters/__tests__/chatgpt-auth.test.ts +193 -193
- package/src/brain/adapters/__tests__/openai-oauth.test.ts +209 -209
- package/src/brain/adapters/__tests__/resolver.test.ts +143 -143
- package/src/brain/adapters/anthropic-oauth.ts +1 -1
- package/src/brain/adapters/chatgpt-auth.ts +300 -300
- package/src/brain/adapters/index.ts +319 -319
- package/src/brain/adapters/oauth-flow.ts +439 -439
- package/src/brain/consent.ts +240 -240
- package/src/brain/credentials.ts +396 -396
- package/src/bundler/__tests__/build-runner.ts +113 -113
- package/src/bundler/__tests__/dev-reliability.test.ts +619 -619
- package/src/bundler/__tests__/extended-watch.test.ts +711 -711
- package/src/bundler/__tests__/fast-refresh.test.ts +10 -10
- package/src/bundler/__tests__/hdr.test.ts +353 -353
- package/src/bundler/__tests__/hmr-client.test.ts +532 -532
- package/src/bundler/__tests__/manifest-schema.test.ts +266 -266
- package/src/bundler/__tests__/prod-smoke.test.ts +138 -138
- package/src/bundler/__tests__/reverse-import-graph.test.ts +519 -519
- package/src/bundler/__tests__/slot-dispatch.test.ts +573 -573
- package/src/bundler/__tests__/url-cap-and-slot-regex.test.ts +286 -286
- package/src/bundler/__tests__/vendor-cache.test.ts +455 -455
- package/src/bundler/budget.ts +404 -404
- package/src/bundler/build.test.ts +179 -179
- package/src/bundler/build.ts +55 -55
- package/src/bundler/dev.ts +42 -42
- package/src/bundler/manifest-schema.ts +301 -301
- package/src/bundler/plugins/__tests__/block-generated-imports.test.ts +263 -263
- package/src/bundler/plugins/__tests__/react-compiler-config.test.ts +83 -83
- package/src/bundler/plugins/__tests__/react-compiler-lint.test.ts +110 -110
- package/src/bundler/plugins/block-generated-imports.ts +155 -155
- package/src/bundler/plugins/index.ts +83 -83
- package/src/bundler/plugins/react-compiler-config.ts +108 -108
- package/src/bundler/plugins/react-compiler-lint.ts +253 -253
- package/src/bundler/plugins/react-compiler.ts +162 -162
- package/src/bundler/reverse-import-graph.ts +339 -339
- package/src/bundler/safe-build.test.ts +201 -201
- package/src/bundler/safe-build.ts +103 -103
- package/src/bundler/scenario-matrix.ts +229 -229
- package/src/bundler/types.ts +10 -10
- package/src/bundler/vendor-cache-types.ts +130 -130
- package/src/bundler/vendor-cache.ts +526 -526
- package/src/change/snapshot.ts +18 -18
- package/src/client/Form.tsx +105 -105
- package/src/client/Link.tsx +9 -9
- package/src/client/__tests__/use-sse.test.ts +153 -153
- package/src/client/globals.ts +1 -1
- package/src/client/hooks.ts +362 -362
- package/src/client/hydrate.ts +340 -340
- package/src/client/prefetch-helper.ts +55 -55
- package/src/client/router.ts +11 -11
- package/src/client/runtime.ts +47 -47
- package/src/client/serialize.ts +404 -404
- package/src/client/use-fetch.ts +6 -6
- package/src/client/use-head.ts +197 -197
- package/src/client/use-sse.ts +378 -378
- package/src/client/window-state.ts +101 -101
- package/src/components/Image.tsx +162 -162
- package/src/config/validate.ts +3 -3
- package/src/config/watcher.ts +311 -311
- package/src/constants.ts +40 -40
- package/src/content/collection.ts +8 -8
- package/src/content/content-layer.ts +7 -7
- package/src/content/data-store.ts +245 -245
- package/src/content/frontmatter.ts +189 -189
- package/src/content/loader-context.ts +171 -171
- package/src/content/loaders/api.ts +216 -216
- package/src/content/loaders/file.ts +172 -172
- package/src/content/loaders/glob.ts +253 -253
- package/src/content/loaders/index.ts +34 -34
- package/src/content/loaders/types.ts +137 -137
- package/src/content/meta-store.ts +209 -209
- package/src/content/prebuild.test.ts +571 -571
- package/src/content/prebuild.ts +636 -636
- package/src/content/schema.ts +20 -20
- package/src/content/sidebar.ts +630 -630
- package/src/content/slug.ts +110 -110
- package/src/content/types.ts +282 -282
- package/src/content/watcher.ts +135 -135
- package/src/contract/client-safe.test.ts +42 -42
- package/src/contract/client-safe.ts +114 -114
- package/src/contract/define.ts +11 -11
- package/src/contract/index.ts +1 -1
- package/src/contract/normalize.test.ts +276 -276
- package/src/contract/normalize.ts +410 -410
- package/src/contract/registry.test.ts +206 -206
- package/src/contract/route-helpers.ts +1 -1
- package/src/contract/rpc.ts +443 -443
- package/src/contract/schema.ts +48 -48
- package/src/contract/types.ts +58 -58
- package/src/db/__tests__/db.test.ts +482 -482
- package/src/db/index.ts +138 -138
- package/src/db/migrations/history-table.ts +345 -345
- package/src/db/migrations/index.ts +3 -3
- package/src/db/migrations/lock.ts +324 -324
- package/src/db/migrations/runner.ts +650 -650
- package/src/deploy/cache.ts +140 -140
- package/src/deploy/compile/vercel.ts +344 -344
- package/src/deploy/index.ts +87 -87
- package/src/deploy/inference/brain.ts +268 -268
- package/src/deploy/inference/context.ts +182 -182
- package/src/deploy/inference/filling-extract.ts +245 -245
- package/src/deploy/inference/heuristic.ts +182 -182
- package/src/deploy/intent.ts +173 -173
- package/src/deploy/plan.ts +178 -178
- package/src/design/__tests__/agents-link.test.ts +109 -109
- package/src/design/__tests__/extract-patch-diff.test.ts +265 -265
- package/src/design/__tests__/lint.test.ts +110 -110
- package/src/design/__tests__/parser.test.ts +195 -195
- package/src/design/__tests__/tailwind-theme.test.ts +229 -229
- package/src/design/agents-link.ts +165 -165
- package/src/design/diff.ts +138 -138
- package/src/design/extract.ts +285 -285
- package/src/design/index.ts +102 -102
- package/src/design/lint.ts +209 -209
- package/src/design/parser.ts +555 -555
- package/src/design/patch.ts +241 -241
- package/src/design/scaffold.ts +147 -147
- package/src/design/tailwind-theme.ts +441 -441
- package/src/design/types.ts +210 -210
- package/src/desktop/__tests__/webview-fallback.test.ts +254 -254
- package/src/desktop/__tests__/window.test.ts +248 -248
- package/src/desktop/__tests__/worker.test.ts +266 -266
- package/src/desktop/index.ts +43 -43
- package/src/desktop/types.ts +158 -158
- package/src/desktop/worker.ts +180 -180
- package/src/dev-error-overlay/__tests__/overlay-injector.test.ts +241 -241
- package/src/dev-error-overlay/index.ts +30 -30
- package/src/dev-error-overlay/overlay-injector.ts +243 -243
- package/src/dev-error-overlay/overlay-styles.ts +52 -52
- package/src/dev-error-overlay/types.ts +66 -66
- package/src/devtools/ai/context-builder.ts +375 -375
- package/src/devtools/ai/index.ts +25 -25
- package/src/devtools/ai/mcp-connector.ts +25 -25
- package/src/devtools/client/catchers/error-catcher.ts +344 -344
- package/src/devtools/client/catchers/index.ts +18 -18
- package/src/devtools/client/components/index.ts +39 -39
- package/src/devtools/client/components/mandu-character.tsx +331 -331
- package/src/devtools/client/components/overlay.tsx +368 -368
- package/src/devtools/client/components/panel/errors-panel.tsx +259 -259
- package/src/devtools/client/components/panel/guard-panel.tsx +30 -30
- package/src/devtools/client/components/panel/islands-panel.tsx +320 -320
- package/src/devtools/client/components/panel/network-panel.tsx +291 -291
- package/src/devtools/client/components/panel/panel-container.tsx +500 -500
- package/src/devtools/client/components/panel/preview-panel.tsx +46 -46
- package/src/devtools/client/filters/context-filters.ts +282 -282
- package/src/devtools/client/filters/index.ts +16 -16
- package/src/devtools/client/index.ts +63 -63
- package/src/devtools/client/persistence.ts +335 -335
- package/src/devtools/hook/create-hook.ts +207 -207
- package/src/devtools/hook/index.ts +13 -13
- package/src/devtools/index.ts +439 -439
- package/src/devtools/init.ts +265 -265
- package/src/devtools/protocol.ts +237 -237
- package/src/devtools/server/index.ts +17 -17
- package/src/devtools/server/source-context.ts +450 -450
- package/src/devtools/types.ts +35 -35
- package/src/devtools/worker/index.ts +25 -25
- package/src/devtools/worker/redaction-worker.ts +233 -233
- package/src/devtools/worker/worker-manager.ts +410 -410
- package/src/diagnose/__tests__/checks.test.ts +451 -451
- package/src/diagnose/checks.ts +832 -832
- package/src/diagnose/index.ts +17 -17
- package/src/diagnose/run.ts +93 -93
- package/src/diagnose/types.ts +53 -53
- package/src/email/__tests__/email.test.ts +355 -355
- package/src/email/index.ts +282 -282
- package/src/email/smtp.ts +64 -64
- package/src/error/domains.ts +265 -265
- package/src/error/result.ts +60 -60
- package/src/error/types.ts +6 -6
- package/src/errors/extractor.ts +409 -409
- package/src/errors/index.ts +19 -19
- package/src/filling/__tests__/session-sqlite.test.ts +454 -454
- package/src/filling/auth.ts +308 -308
- package/src/filling/cookie-codec.ts +299 -299
- package/src/filling/deps.ts +265 -265
- package/src/filling/session-sqlite.ts +617 -617
- package/src/filling/sse.ts +5 -5
- package/src/filling/ws.ts +78 -78
- package/src/generator/index.ts +3 -3
- package/src/guard/__tests__/design-inline-class.test.ts +219 -219
- package/src/guard/__tests__/tsgolint-bridge.test.ts +347 -347
- package/src/guard/analyzer.ts +360 -360
- package/src/guard/auto-correct.ts +1 -1
- package/src/guard/contract-guard.ts +9 -9
- package/src/guard/define-rule.ts +243 -243
- package/src/guard/design-inline-class.ts +393 -393
- package/src/guard/file-type.test.ts +24 -24
- package/src/guard/graph.ts +1 -1
- package/src/guard/healing.ts +36 -36
- package/src/guard/presets/atomic.ts +70 -70
- package/src/guard/presets/clean.ts +77 -77
- package/src/guard/presets/fsd.ts +79 -79
- package/src/guard/presets/hexagonal.ts +68 -68
- package/src/guard/reporter.ts +442 -442
- package/src/guard/rule-presets.ts +379 -379
- package/src/guard/semantic-slots.ts +1 -1
- package/src/guard/suggestions.ts +358 -358
- package/src/guard/tsgolint-bridge.ts +512 -512
- package/src/guard/types.ts +348 -348
- package/src/guard/watcher.ts +405 -405
- package/src/i18n/define.ts +126 -126
- package/src/i18n/index.ts +52 -52
- package/src/i18n/message-registry.ts +173 -173
- package/src/i18n/types.ts +112 -112
- package/src/id/index.ts +105 -105
- package/src/index.ts +2 -2
- package/src/kitchen/api/agent-devtools-api.ts +779 -544
- package/src/kitchen/api/errors-grouping.ts +126 -0
- package/src/kitchen/kitchen-handler.ts +192 -63
- package/src/kitchen/kitchen-ui.ts +842 -464
- package/src/logging/index.ts +22 -22
- package/src/logging/transports.ts +365 -365
- package/src/middleware/bridge.ts +147 -147
- package/src/middleware/compose.ts +134 -134
- package/src/middleware/compress.ts +62 -62
- package/src/middleware/cors.ts +47 -47
- package/src/middleware/csrf.ts +328 -328
- package/src/middleware/define.ts +132 -132
- package/src/middleware/jwt.ts +134 -134
- package/src/middleware/logger.ts +58 -58
- package/src/middleware/oauth/__tests__/oauth.test.ts +1 -1
- package/src/middleware/oauth/index.ts +505 -505
- package/src/middleware/oauth/providers.ts +115 -115
- package/src/middleware/rate-limit/index.ts +522 -522
- package/src/middleware/rate-limit/sqlite-store.ts +382 -382
- package/src/middleware/scheduler-cron.ts +96 -96
- package/src/middleware/secure/__tests__/secure.test.ts +360 -360
- package/src/middleware/secure/csp.ts +193 -193
- package/src/middleware/session.ts +174 -174
- package/src/middleware/timeout.ts +55 -55
- package/src/observability/logger-adapter.ts +36 -36
- package/src/observability/sqlite-store.ts +254 -254
- package/src/openapi/generator.ts +1 -1
- package/src/openapi/openapi.test.ts +43 -43
- package/src/perf/__tests__/user-marks.test.ts +354 -354
- package/src/perf/index.ts +133 -133
- package/src/plugins/__tests__/lifecycle-integration.test.ts +272 -272
- package/src/plugins/__tests__/runner.test.ts +409 -409
- package/src/plugins/define.ts +124 -124
- package/src/plugins/examples/dep-check-plugin.ts +80 -80
- package/src/plugins/examples/prerender-cache-plugin.ts +111 -111
- package/src/plugins/examples/sitemap-plugin.ts +65 -65
- package/src/plugins/runner.ts +361 -361
- package/src/plugins/types.ts +368 -368
- package/src/report/index.ts +1 -1
- package/src/resource/__tests__/generator.test.ts +32 -32
- package/src/resource/ddl/__tests__/diff.test.ts +639 -639
- package/src/resource/ddl/__tests__/emit.test.ts +823 -823
- package/src/resource/ddl/__tests__/snapshot.test.ts +499 -499
- package/src/resource/ddl/emit.ts +559 -559
- package/src/resource/ddl/persistence-types.ts +218 -218
- package/src/resource/ddl/type-map.ts +223 -223
- package/src/resource/ddl/types.ts +232 -232
- package/src/resource/generator-repo.ts +630 -630
- package/src/resource/schema.ts +1 -1
- package/src/router/fs-patterns.test.ts +96 -96
- package/src/router/fs-routes.ts +6 -6
- package/src/routes/index.ts +74 -74
- package/src/routes/metadata-routes.ts +427 -427
- package/src/routes/types.ts +341 -341
- package/src/runtime/__tests__/error-boundary-redaction.test.ts +141 -141
- package/src/runtime/__tests__/hdr-client.test.ts +223 -223
- package/src/runtime/__tests__/http-errors.test.ts +117 -117
- package/src/runtime/__tests__/not-found.test.ts +152 -152
- package/src/runtime/adapter.ts +47 -47
- package/src/runtime/boundary.tsx +252 -252
- package/src/runtime/cache.ts +494 -494
- package/src/runtime/compose.ts +222 -222
- package/src/runtime/fast-refresh-runtime.ts +322 -322
- package/src/runtime/handler.ts +65 -65
- package/src/runtime/handlers.ts +300 -300
- package/src/runtime/http-errors.ts +113 -113
- package/src/runtime/image-handler.ts +1 -1
- package/src/runtime/lifecycle.ts +381 -381
- package/src/runtime/logger.test.ts +345 -345
- package/src/runtime/middleware.ts +264 -264
- package/src/runtime/not-found.ts +93 -93
- package/src/runtime/openapi-endpoint.ts +236 -236
- package/src/runtime/ppr.ts +74 -74
- package/src/runtime/registry.ts +171 -171
- package/src/runtime/router.ts +105 -105
- package/src/runtime/server.ts +67 -67
- package/src/runtime/shims.ts +48 -48
- package/src/runtime/trace.ts +144 -144
- package/src/scheduler/validate.ts +169 -169
- package/src/seo/index.ts +219 -219
- package/src/seo/integration/ssr.ts +306 -306
- package/src/seo/render/basic.ts +435 -435
- package/src/seo/render/index.ts +143 -143
- package/src/seo/render/jsonld.ts +539 -539
- package/src/seo/render/opengraph.ts +197 -197
- package/src/seo/render/robots.ts +116 -116
- package/src/seo/render/sitemap.ts +137 -137
- package/src/seo/render/twitter.ts +127 -127
- package/src/seo/resolve/opengraph.ts +143 -143
- package/src/seo/resolve/robots.ts +73 -73
- package/src/seo/resolve/title.ts +94 -94
- package/src/seo/resolve/twitter.ts +73 -73
- package/src/seo/resolve/url.ts +104 -104
- package/src/seo/routes/index.ts +290 -290
- package/src/seo/types.ts +588 -588
- package/src/slot/validator.ts +39 -39
- package/src/storage/s3/__tests__/s3.test.ts +479 -479
- package/src/storage/s3/index.ts +412 -412
- package/src/testing/__tests__/assertions.test.ts +632 -632
- package/src/testing/__tests__/reporter.test.ts +454 -454
- package/src/testing/assertions.ts +986 -986
- package/src/testing/db.ts +157 -157
- package/src/testing/mocks.ts +203 -203
- package/src/testing/session.ts +190 -190
- package/src/types/branded.ts +56 -56
- package/src/types/index.ts +1 -1
- package/src/utils/safe-io.ts +188 -188
- package/src/utils/string-safe.ts +298 -298
- package/src/watcher/watcher.ts +18 -18
|
@@ -1,617 +1,617 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @mandujs/core/filling/session-sqlite
|
|
3
|
-
*
|
|
4
|
-
* SQLite-backed `SessionStorage` — drop-in replacement for
|
|
5
|
-
* {@link createCookieSessionStorage} when session payloads outgrow the
|
|
6
|
-
* 4 KB cookie budget or when you need server-side invalidation.
|
|
7
|
-
*
|
|
8
|
-
* ## Contract
|
|
9
|
-
*
|
|
10
|
-
* Implements the same `SessionStorage` interface as
|
|
11
|
-
* `createCookieSessionStorage`. The existing `session()` middleware and
|
|
12
|
-
* the `saveSession` / `destroySession` helpers work unchanged — only the
|
|
13
|
-
* construction call changes.
|
|
14
|
-
*
|
|
15
|
-
* ## Cookie shape
|
|
16
|
-
*
|
|
17
|
-
* Only a signed **session id** travels in the cookie. The actual session
|
|
18
|
-
* `data` lives in SQLite keyed by id. Cookie value is
|
|
19
|
-
* `encodeURIComponent(<uuidv7>) + "." + <hmac-sha256-base64>`, identical
|
|
20
|
-
* in shape to `CookieManager.setSigned` output.
|
|
21
|
-
*
|
|
22
|
-
* ## Phase 4a Appendix D compliance
|
|
23
|
-
*
|
|
24
|
-
* - **D.4 WAL mode**: issued at init via `PRAGMA journal_mode = WAL` so
|
|
25
|
-
* concurrent writers don't serialise on the default rollback journal.
|
|
26
|
-
* The Bun.SQL wrapper deliberately does not auto-enable WAL (some
|
|
27
|
-
* embedded deployments need rollback journals) — we opt in here.
|
|
28
|
-
* - **D.5 use `createDb`**: connection goes through `@mandujs/core/db`,
|
|
29
|
-
* never `new Bun.SQL` directly. Keeps URL/options translation in
|
|
30
|
-
* exactly one place.
|
|
31
|
-
*
|
|
32
|
-
* ## TTL GC
|
|
33
|
-
*
|
|
34
|
-
* Expired rows are swept by a cron job registered via
|
|
35
|
-
* `@mandujs/core/scheduler`. If `Bun.cron` is unavailable (pre-1.3.12),
|
|
36
|
-
* we warn once and continue — the caller can still invoke
|
|
37
|
-
* {@link SqliteSessionStorage.gcNow} manually from their own boot hook.
|
|
38
|
-
*
|
|
39
|
-
* @example
|
|
40
|
-
* ```ts
|
|
41
|
-
* import { createSqliteSessionStorage } from "@mandujs/core/filling/session-sqlite";
|
|
42
|
-
* import { session } from "@mandujs/core/middleware";
|
|
43
|
-
*
|
|
44
|
-
* const storage = createSqliteSessionStorage({
|
|
45
|
-
* cookie: { secrets: [process.env.SESSION_SECRET!] },
|
|
46
|
-
* dbPath: ".mandu/sessions.db",
|
|
47
|
-
* ttlSeconds: 60 * 60 * 24 * 7, // 7 days
|
|
48
|
-
* });
|
|
49
|
-
*
|
|
50
|
-
* // on shutdown:
|
|
51
|
-
* await storage.close();
|
|
52
|
-
* ```
|
|
53
|
-
*
|
|
54
|
-
* @module filling/session-sqlite
|
|
55
|
-
*/
|
|
56
|
-
|
|
57
|
-
import { createDb, type Db } from "../db";
|
|
58
|
-
import { defineCron, type CronRegistration } from "../scheduler";
|
|
59
|
-
import type { CookieManager, CookieOptions } from "./context";
|
|
60
|
-
import {
|
|
61
|
-
Session,
|
|
62
|
-
type CookieSessionOptions,
|
|
63
|
-
type SessionData,
|
|
64
|
-
type SessionStorage,
|
|
65
|
-
} from "./session";
|
|
66
|
-
|
|
67
|
-
// ─── Public API ─────────────────────────────────────────────────────────────
|
|
68
|
-
|
|
69
|
-
/** Construction options for {@link createSqliteSessionStorage}. */
|
|
70
|
-
export interface SqliteSessionStorageOptions {
|
|
71
|
-
/**
|
|
72
|
-
* Cookie-layer settings — reused verbatim from
|
|
73
|
-
* {@link CookieSessionOptions}. The cookie only carries a signed id,
|
|
74
|
-
* but name / secrets / flags / max-age still apply.
|
|
75
|
-
*/
|
|
76
|
-
cookie: CookieSessionOptions["cookie"];
|
|
77
|
-
/**
|
|
78
|
-
* SQLite database path. Accepts `":memory:"` for transient tests or a
|
|
79
|
-
* filesystem path for persisted sessions. Default: `".mandu/sessions.db"`.
|
|
80
|
-
*
|
|
81
|
-
* The string is appended to `sqlite:` to form a URL that
|
|
82
|
-
* `@mandujs/core/db` accepts.
|
|
83
|
-
*/
|
|
84
|
-
dbPath?: string;
|
|
85
|
-
/**
|
|
86
|
-
* Table name for the session rows. Default: `"mandu_sessions"`.
|
|
87
|
-
*
|
|
88
|
-
* Not parameterisable at query time (SQLite does not bind identifiers),
|
|
89
|
-
* so we validate against `SAFE_IDENT_RE` at construction to keep the
|
|
90
|
-
* name out of injection-prone string interpolation territory.
|
|
91
|
-
*/
|
|
92
|
-
table?: string;
|
|
93
|
-
/**
|
|
94
|
-
* Absolute session lifetime in seconds. Default: `604800` (7 days). A
|
|
95
|
-
* row's `expires_at` is (re)set on every commit; old values are wiped
|
|
96
|
-
* by {@link SqliteSessionStorage.gcNow}.
|
|
97
|
-
*/
|
|
98
|
-
ttlSeconds?: number;
|
|
99
|
-
/**
|
|
100
|
-
* Cron schedule for the TTL sweep. Default: `"0 * * * *"` (hourly).
|
|
101
|
-
* Set to `false` to disable the cron entirely — callers can still
|
|
102
|
-
* invoke `gcNow()` manually.
|
|
103
|
-
*/
|
|
104
|
-
gcSchedule?: string | false;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
* `SessionStorage` + SQLite-specific affordances. Returned by
|
|
109
|
-
* {@link createSqliteSessionStorage}.
|
|
110
|
-
*/
|
|
111
|
-
export interface SqliteSessionStorage extends SessionStorage {
|
|
112
|
-
/**
|
|
113
|
-
* Immediately sweep expired rows. Safe to invoke at any time — the
|
|
114
|
-
* cron job calls the same underlying delete.
|
|
115
|
-
*
|
|
116
|
-
* @returns The number of rows deleted.
|
|
117
|
-
*/
|
|
118
|
-
gcNow(): Promise<number>;
|
|
119
|
-
/**
|
|
120
|
-
* Stop the GC cron (if started) and close the DB pool. Call from your
|
|
121
|
-
* shutdown hook to release file handles.
|
|
122
|
-
*/
|
|
123
|
-
close(): Promise<void>;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
// ─── Constants ──────────────────────────────────────────────────────────────
|
|
127
|
-
|
|
128
|
-
const DEFAULT_DB_PATH = ".mandu/sessions.db";
|
|
129
|
-
const DEFAULT_TABLE = "mandu_sessions";
|
|
130
|
-
const DEFAULT_TTL_SECONDS = 60 * 60 * 24 * 7; // 7 days
|
|
131
|
-
const DEFAULT_GC_SCHEDULE = "0 * * * *"; // hourly
|
|
132
|
-
|
|
133
|
-
/**
|
|
134
|
-
* Safe identifier pattern for the user-supplied `table` name. SQLite does
|
|
135
|
-
* not bind identifiers, so the table name is interpolated directly into
|
|
136
|
-
* DDL/DML — we constrain it to `[A-Za-z_][A-Za-z0-9_]*` to eliminate any
|
|
137
|
-
* injection surface.
|
|
138
|
-
*/
|
|
139
|
-
const SAFE_IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
140
|
-
|
|
141
|
-
// ─── HMAC helpers ────────────────────────────────────────────────────────────
|
|
142
|
-
//
|
|
143
|
-
// Structurally identical to the private `hmacSign` inside `context.ts` and
|
|
144
|
-
// the signing logic in `createCookieSessionStorage`. Duplicated rather than
|
|
145
|
-
// shared to avoid widening `filling/context.ts`'s public surface; TODO:
|
|
146
|
-
// extract into a private `filling/hmac.ts` once a third call site appears.
|
|
147
|
-
|
|
148
|
-
async function hmacSign(data: string, secret: string): Promise<string> {
|
|
149
|
-
const encoder = new TextEncoder();
|
|
150
|
-
const key = await crypto.subtle.importKey(
|
|
151
|
-
"raw",
|
|
152
|
-
encoder.encode(secret),
|
|
153
|
-
{ name: "HMAC", hash: "SHA-256" },
|
|
154
|
-
false,
|
|
155
|
-
["sign"],
|
|
156
|
-
);
|
|
157
|
-
const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(data));
|
|
158
|
-
return btoa(String.fromCharCode(...new Uint8Array(sig))).replace(/=+$/, "");
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
/**
|
|
162
|
-
* Verify a signed cookie value against each secret in rotation order.
|
|
163
|
-
* Returns the raw session id on success; `null` when none of the secrets
|
|
164
|
-
* validate (invalid signature, tampered value, or no cookie).
|
|
165
|
-
*/
|
|
166
|
-
async function verifySignedId(
|
|
167
|
-
rawCookieValue: string | undefined,
|
|
168
|
-
secrets: readonly string[],
|
|
169
|
-
): Promise<string | null> {
|
|
170
|
-
if (!rawCookieValue) return null;
|
|
171
|
-
const dot = rawCookieValue.lastIndexOf(".");
|
|
172
|
-
if (dot <= 0) return null;
|
|
173
|
-
const payload = rawCookieValue.slice(0, dot);
|
|
174
|
-
const signature = rawCookieValue.slice(dot + 1);
|
|
175
|
-
if (!payload || !signature) return null;
|
|
176
|
-
for (const secret of secrets) {
|
|
177
|
-
const expected = await hmacSign(payload, secret);
|
|
178
|
-
if (expected === signature) {
|
|
179
|
-
try {
|
|
180
|
-
return decodeURIComponent(payload);
|
|
181
|
-
} catch {
|
|
182
|
-
return null;
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
return null;
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
// ─── Set-Cookie serialisation ───────────────────────────────────────────────
|
|
190
|
-
|
|
191
|
-
function serializeSetCookie(
|
|
192
|
-
name: string,
|
|
193
|
-
value: string,
|
|
194
|
-
opts: CookieOptions,
|
|
195
|
-
): string {
|
|
196
|
-
const parts = [`${name}=${encodeURIComponent(value)}`];
|
|
197
|
-
if (opts.path) parts.push(`Path=${opts.path}`);
|
|
198
|
-
if (opts.domain) parts.push(`Domain=${opts.domain}`);
|
|
199
|
-
if (typeof opts.maxAge === "number") parts.push(`Max-Age=${opts.maxAge}`);
|
|
200
|
-
if (opts.httpOnly) parts.push("HttpOnly");
|
|
201
|
-
if (opts.secure) parts.push("Secure");
|
|
202
|
-
if (opts.sameSite) parts.push(`SameSite=${opts.sameSite}`);
|
|
203
|
-
return parts.join("; ");
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
// ─── Row shape ──────────────────────────────────────────────────────────────
|
|
207
|
-
|
|
208
|
-
interface SessionRow {
|
|
209
|
-
id: string;
|
|
210
|
-
data: string;
|
|
211
|
-
expires_at: number;
|
|
212
|
-
// Index signature so it satisfies the `Record<string, unknown>` constraint
|
|
213
|
-
// that `queryOne`'s generic expects. Property-level types above still win
|
|
214
|
-
// for known keys.
|
|
215
|
-
[key: string]: unknown;
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
// ─── Factory ────────────────────────────────────────────────────────────────
|
|
219
|
-
|
|
220
|
-
/**
|
|
221
|
-
* Construct a SQLite-backed session storage.
|
|
222
|
-
*
|
|
223
|
-
* Initialisation is performed lazily on the first call to
|
|
224
|
-
* {@link SessionStorage.getSession} / {@link SessionStorage.commitSession} /
|
|
225
|
-
* {@link SessionStorage.destroySession} / {@link SqliteSessionStorage.gcNow}
|
|
226
|
-
* — the constructor never opens a connection itself. This matches
|
|
227
|
-
* `@mandujs/core/db`'s own laziness and keeps environment-driven
|
|
228
|
-
* construction cheap at boot.
|
|
229
|
-
*
|
|
230
|
-
* @throws {Error} Synchronously when `cookie.secrets` is empty or when
|
|
231
|
-
* `table` fails the safe-identifier check.
|
|
232
|
-
*/
|
|
233
|
-
export function createSqliteSessionStorage(
|
|
234
|
-
options: SqliteSessionStorageOptions,
|
|
235
|
-
): SqliteSessionStorage {
|
|
236
|
-
const {
|
|
237
|
-
cookie,
|
|
238
|
-
dbPath = DEFAULT_DB_PATH,
|
|
239
|
-
table = DEFAULT_TABLE,
|
|
240
|
-
ttlSeconds = DEFAULT_TTL_SECONDS,
|
|
241
|
-
gcSchedule = DEFAULT_GC_SCHEDULE,
|
|
242
|
-
} = options;
|
|
243
|
-
|
|
244
|
-
const {
|
|
245
|
-
name: cookieName = "__session",
|
|
246
|
-
secrets,
|
|
247
|
-
httpOnly = true,
|
|
248
|
-
secure = process.env.NODE_ENV === "production",
|
|
249
|
-
sameSite = "lax",
|
|
250
|
-
maxAge = ttlSeconds,
|
|
251
|
-
path = "/",
|
|
252
|
-
domain,
|
|
253
|
-
} = cookie;
|
|
254
|
-
|
|
255
|
-
if (!secrets || secrets.length === 0) {
|
|
256
|
-
throw new Error(
|
|
257
|
-
"[Mandu Session SQLite] At least one cookie.secret is required.",
|
|
258
|
-
);
|
|
259
|
-
}
|
|
260
|
-
if (!SAFE_IDENT_RE.test(table)) {
|
|
261
|
-
throw new Error(
|
|
262
|
-
`[Mandu Session SQLite] Invalid table name ${JSON.stringify(table)}. ` +
|
|
263
|
-
`Must match ${SAFE_IDENT_RE}.`,
|
|
264
|
-
);
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
const cookieOpts: CookieOptions = {
|
|
268
|
-
httpOnly,
|
|
269
|
-
secure,
|
|
270
|
-
sameSite,
|
|
271
|
-
maxAge,
|
|
272
|
-
path,
|
|
273
|
-
domain,
|
|
274
|
-
};
|
|
275
|
-
|
|
276
|
-
// ─── DB init (idempotent) ─────────────────────────────────────────────────
|
|
277
|
-
|
|
278
|
-
const url = `sqlite:${dbPath}`;
|
|
279
|
-
const db: Db = createDb({ url });
|
|
280
|
-
|
|
281
|
-
// Init is run at most once. Each public method awaits this promise to
|
|
282
|
-
// guarantee the schema + PRAGMAs are in place before any other query.
|
|
283
|
-
let initPromise: Promise<void> | null = null;
|
|
284
|
-
let closed = false;
|
|
285
|
-
|
|
286
|
-
function ensureInit(): Promise<void> {
|
|
287
|
-
if (initPromise) return initPromise;
|
|
288
|
-
initPromise = (async () => {
|
|
289
|
-
// Appendix D.4: enable WAL explicitly. Concurrent session writes on
|
|
290
|
-
// the default rollback journal serialise hard; WAL gives us
|
|
291
|
-
// readers-don't-block-writers semantics. Idempotent — if the file
|
|
292
|
-
// is already in WAL, this is a no-op reflected in the return row.
|
|
293
|
-
//
|
|
294
|
-
// `PRAGMA journal_mode = WAL` must be run on the handle, not in a
|
|
295
|
-
// transaction. `:memory:` databases accept the pragma but silently
|
|
296
|
-
// remain in "memory" mode — we don't assert the return value here
|
|
297
|
-
// to keep `:memory:` valid for tests; the WAL-mode test asserts on
|
|
298
|
-
// a file-backed DB instead.
|
|
299
|
-
//
|
|
300
|
-
// Interpolation is safe here — the SQL text is a literal, no user
|
|
301
|
-
// input.
|
|
302
|
-
await db`PRAGMA journal_mode = WAL`;
|
|
303
|
-
|
|
304
|
-
// Identifier was validated above, so direct interpolation into DDL
|
|
305
|
-
// is safe and unavoidable (SQLite does not bind identifiers).
|
|
306
|
-
const createTableSql = `CREATE TABLE IF NOT EXISTS ${table} (
|
|
307
|
-
id TEXT PRIMARY KEY,
|
|
308
|
-
data TEXT NOT NULL,
|
|
309
|
-
expires_at INTEGER NOT NULL
|
|
310
|
-
)`;
|
|
311
|
-
const createIndexSql = `CREATE INDEX IF NOT EXISTS ${table}_expires ON ${table}(expires_at)`;
|
|
312
|
-
|
|
313
|
-
await execRaw(db, createTableSql);
|
|
314
|
-
await execRaw(db, createIndexSql);
|
|
315
|
-
})();
|
|
316
|
-
return initPromise;
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
// ─── Cron (optional) ──────────────────────────────────────────────────────
|
|
320
|
-
|
|
321
|
-
let cronReg: CronRegistration | null = null;
|
|
322
|
-
function startCronIfEnabled(): void {
|
|
323
|
-
if (gcSchedule === false) return;
|
|
324
|
-
if (cronReg) return;
|
|
325
|
-
try {
|
|
326
|
-
const reg = defineCron({
|
|
327
|
-
[`${table}:gc`]: {
|
|
328
|
-
schedule: gcSchedule,
|
|
329
|
-
run: async () => {
|
|
330
|
-
await gcNow();
|
|
331
|
-
},
|
|
332
|
-
},
|
|
333
|
-
});
|
|
334
|
-
reg.start();
|
|
335
|
-
cronReg = reg;
|
|
336
|
-
} catch (err) {
|
|
337
|
-
// Older Bun (< 1.3.12) lacks `Bun.cron`. Warn once — the caller
|
|
338
|
-
// can still call `gcNow()` manually — then keep serving traffic.
|
|
339
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
340
|
-
console.warn(
|
|
341
|
-
`[Mandu Session SQLite] GC cron disabled: ${msg}. ` +
|
|
342
|
-
`Sessions will still persist; call storage.gcNow() manually.`,
|
|
343
|
-
);
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
// Register cron after the first init completes so the scheduler's first
|
|
348
|
-
// tick never races with table creation. We fire-and-forget the init —
|
|
349
|
-
// any error surfaces on the next real query.
|
|
350
|
-
void ensureInit().then(startCronIfEnabled);
|
|
351
|
-
|
|
352
|
-
// ─── SessionStorage methods ───────────────────────────────────────────────
|
|
353
|
-
|
|
354
|
-
async function getSession(cookies: CookieManager): Promise<Session> {
|
|
355
|
-
if (closed) {
|
|
356
|
-
throw new Error("[Mandu Session SQLite] storage is closed.");
|
|
357
|
-
}
|
|
358
|
-
await ensureInit();
|
|
359
|
-
|
|
360
|
-
const raw = cookies.get(cookieName);
|
|
361
|
-
const decoded = typeof raw === "string" ? safeDecode(raw) : null;
|
|
362
|
-
const id = await verifySignedId(decoded ?? undefined, secrets);
|
|
363
|
-
if (!id) return new Session();
|
|
364
|
-
|
|
365
|
-
const now = Date.now();
|
|
366
|
-
// Direct identifier interpolation is safe (table validated at
|
|
367
|
-
// construction). The user-controlled `id` and `now` go through Bun.SQL
|
|
368
|
-
// placeholder binding.
|
|
369
|
-
const sql = `SELECT id, data, expires_at FROM ${table} WHERE id = $1 AND expires_at > $2 LIMIT 1`;
|
|
370
|
-
const row = await queryOne<SessionRow>(db, sql, [id, now]);
|
|
371
|
-
if (!row) return new Session();
|
|
372
|
-
|
|
373
|
-
let parsed: SessionData;
|
|
374
|
-
try {
|
|
375
|
-
parsed = JSON.parse(row.data) as SessionData;
|
|
376
|
-
} catch {
|
|
377
|
-
// Corrupted row — treat as a missed session rather than throwing at
|
|
378
|
-
// the end user. A later commit will overwrite it.
|
|
379
|
-
return new Session();
|
|
380
|
-
}
|
|
381
|
-
return rehydrateSession(parsed, id);
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
async function commitSession(session: Session): Promise<string> {
|
|
385
|
-
if (closed) {
|
|
386
|
-
throw new Error("[Mandu Session SQLite] storage is closed.");
|
|
387
|
-
}
|
|
388
|
-
await ensureInit();
|
|
389
|
-
|
|
390
|
-
// Clean + already-persisted sessions emit no Set-Cookie — matches the
|
|
391
|
-
// "no-op when nothing changed" contract.
|
|
392
|
-
if (!session.isDirty()) return "";
|
|
393
|
-
|
|
394
|
-
// Always ensure the row has a stable id. Sessions loaded by
|
|
395
|
-
// `getSession` keep their DB id; freshly-constructed sessions use
|
|
396
|
-
// the UUID v7 generated in the `Session` constructor.
|
|
397
|
-
const id = session.id;
|
|
398
|
-
if (!id || typeof id !== "string") {
|
|
399
|
-
throw new Error("[Mandu Session SQLite] session.id missing.");
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
const expiresAt = Date.now() + ttlSeconds * 1000;
|
|
403
|
-
const dataJson = JSON.stringify(session.toJSON());
|
|
404
|
-
|
|
405
|
-
// INSERT OR REPLACE — last write wins, concurrent writes resolve
|
|
406
|
-
// under WAL without corruption.
|
|
407
|
-
const sql = `INSERT OR REPLACE INTO ${table} (id, data, expires_at) VALUES ($1, $2, $3)`;
|
|
408
|
-
await execWithParams(db, sql, [id, dataJson, expiresAt]);
|
|
409
|
-
|
|
410
|
-
// Cookie carries ONLY the signed id. Never the data.
|
|
411
|
-
const signedValue = await signPayload(id, secrets[0]);
|
|
412
|
-
return serializeSetCookie(cookieName, signedValue, cookieOpts);
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
async function destroySession(session: Session): Promise<string> {
|
|
416
|
-
if (closed) {
|
|
417
|
-
throw new Error("[Mandu Session SQLite] storage is closed.");
|
|
418
|
-
}
|
|
419
|
-
await ensureInit();
|
|
420
|
-
|
|
421
|
-
const id = session.id;
|
|
422
|
-
if (typeof id === "string" && id.length > 0) {
|
|
423
|
-
const sql = `DELETE FROM ${table} WHERE id = $1`;
|
|
424
|
-
await execWithParams(db, sql, [id]);
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
// Emit an expiring cookie so the browser drops its copy. Shape
|
|
428
|
-
// matches `createCookieSessionStorage`.
|
|
429
|
-
const parts = [`${cookieName}=`, `Path=${path}`, "Max-Age=0"];
|
|
430
|
-
if (domain) parts.push(`Domain=${domain}`);
|
|
431
|
-
if (httpOnly) parts.push("HttpOnly");
|
|
432
|
-
if (secure) parts.push("Secure");
|
|
433
|
-
if (sameSite) parts.push(`SameSite=${sameSite}`);
|
|
434
|
-
return parts.join("; ");
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
async function gcNow(): Promise<number> {
|
|
438
|
-
if (closed) {
|
|
439
|
-
throw new Error("[Mandu Session SQLite] storage is closed.");
|
|
440
|
-
}
|
|
441
|
-
await ensureInit();
|
|
442
|
-
|
|
443
|
-
const now = Date.now();
|
|
444
|
-
// Count-then-delete inside a transaction so we return an accurate
|
|
445
|
-
// number even when another writer races us. Under WAL this is cheap.
|
|
446
|
-
// Identifier is interpolated (validated at construction); the time
|
|
447
|
-
// parameter is bound.
|
|
448
|
-
let deleted = 0;
|
|
449
|
-
await db.transaction(async (tx) => {
|
|
450
|
-
const countSql = `SELECT COUNT(*) AS n FROM ${table} WHERE expires_at <= $1`;
|
|
451
|
-
const cnt = await queryOne<{ n: number | bigint }>(tx, countSql, [now]);
|
|
452
|
-
deleted = cnt ? Number(cnt.n) : 0;
|
|
453
|
-
const delSql = `DELETE FROM ${table} WHERE expires_at <= $1`;
|
|
454
|
-
await execWithParams(tx, delSql, [now]);
|
|
455
|
-
});
|
|
456
|
-
return deleted;
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
async function close(): Promise<void> {
|
|
460
|
-
if (closed) return;
|
|
461
|
-
closed = true;
|
|
462
|
-
if (cronReg) {
|
|
463
|
-
try {
|
|
464
|
-
await cronReg.stop();
|
|
465
|
-
} catch {
|
|
466
|
-
// Best-effort shutdown — don't mask the caller's shutdown flow.
|
|
467
|
-
}
|
|
468
|
-
cronReg = null;
|
|
469
|
-
}
|
|
470
|
-
await db.close();
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
return {
|
|
474
|
-
getSession,
|
|
475
|
-
commitSession,
|
|
476
|
-
destroySession,
|
|
477
|
-
gcNow,
|
|
478
|
-
close,
|
|
479
|
-
};
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
// ─── Signing ────────────────────────────────────────────────────────────────
|
|
483
|
-
|
|
484
|
-
/**
|
|
485
|
-
* Sign a session id with the primary secret. The cookie value carries
|
|
486
|
-
* `encodeURIComponent(id) + "." + <sig>`, matching the shape produced by
|
|
487
|
-
* `CookieManager.setSigned` so `CookieManager.getSigned` could consume it
|
|
488
|
-
* symmetrically in a pinch.
|
|
489
|
-
*/
|
|
490
|
-
async function signPayload(id: string, secret: string): Promise<string> {
|
|
491
|
-
const encoded = encodeURIComponent(id);
|
|
492
|
-
const sig = await hmacSign(encoded, secret);
|
|
493
|
-
return `${encoded}.${sig}`;
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
/**
|
|
497
|
-
* URL-decode a cookie value, returning `null` on malformed input. We
|
|
498
|
-
* never throw on untrusted cookie data.
|
|
499
|
-
*/
|
|
500
|
-
function safeDecode(value: string): string | null {
|
|
501
|
-
try {
|
|
502
|
-
return decodeURIComponent(value);
|
|
503
|
-
} catch {
|
|
504
|
-
return null;
|
|
505
|
-
}
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
/**
|
|
509
|
-
* Rebuild a {@link Session} from its persisted JSON blob while preserving
|
|
510
|
-
* the supplied `id`.
|
|
511
|
-
*
|
|
512
|
-
* `Session.fromJSON` mints a fresh UUID for the rebuilt instance —
|
|
513
|
-
* correct for cookie storage (the id lives on the cookie itself) but
|
|
514
|
-
* wrong for SQLite where the row's primary key must stay stable across
|
|
515
|
-
* requests. We go through the public constructor with `(data, id)` and
|
|
516
|
-
* then replay flash keys so `session.get(k)` returns the flash on first
|
|
517
|
-
* read, empty on subsequent reads — same contract as `fromJSON`.
|
|
518
|
-
*
|
|
519
|
-
* The replay needs write access to the private `flash` map and to the
|
|
520
|
-
* `_dirty` flag; we reuse the same narrow-interface cast the cookie
|
|
521
|
-
* codepath uses internally.
|
|
522
|
-
*/
|
|
523
|
-
function rehydrateSession(data: SessionData, id: string): Session {
|
|
524
|
-
const session = new Session({}, id);
|
|
525
|
-
const flashKeys: string[] = [];
|
|
526
|
-
|
|
527
|
-
// Narrow structural view of the private fields we need to populate.
|
|
528
|
-
// Session's public API does not expose a "load raw JSON with id" entry,
|
|
529
|
-
// so we reach in carefully — the shape matches session.ts:42-53.
|
|
530
|
-
const internal = session as unknown as {
|
|
531
|
-
data: SessionData;
|
|
532
|
-
flash: Map<string, unknown>;
|
|
533
|
-
_dirty: boolean;
|
|
534
|
-
};
|
|
535
|
-
|
|
536
|
-
for (const [key, value] of Object.entries(data)) {
|
|
537
|
-
if (key.startsWith("__flash_")) {
|
|
538
|
-
const realKey = key.slice(8);
|
|
539
|
-
internal.flash.set(realKey, value);
|
|
540
|
-
flashKeys.push(key);
|
|
541
|
-
} else {
|
|
542
|
-
internal.data[key] = value;
|
|
543
|
-
}
|
|
544
|
-
}
|
|
545
|
-
for (const key of flashKeys) {
|
|
546
|
-
delete internal.data[key];
|
|
547
|
-
}
|
|
548
|
-
// Just-loaded state is clean by definition.
|
|
549
|
-
internal._dirty = false;
|
|
550
|
-
return session;
|
|
551
|
-
}
|
|
552
|
-
|
|
553
|
-
// ─── DB helpers ─────────────────────────────────────────────────────────────
|
|
554
|
-
//
|
|
555
|
-
// `@mandujs/core/db` exposes a tagged-template API. Our DDL/DML strings
|
|
556
|
-
// are dynamic (they interpolate the table name, which SQLite doesn't
|
|
557
|
-
// bind), so we construct the TemplateStringsArray ourselves via the
|
|
558
|
-
// pattern used by Bun.SQL: split the string at `$1`, `$2`, … markers and
|
|
559
|
-
// forward values in positional order.
|
|
560
|
-
|
|
561
|
-
/** Run a SQL string with positional `$N` placeholders. No result rows consumed. */
|
|
562
|
-
async function execWithParams(
|
|
563
|
-
dbOrTx: Db,
|
|
564
|
-
sql: string,
|
|
565
|
-
params: unknown[],
|
|
566
|
-
): Promise<void> {
|
|
567
|
-
const parts = splitPlaceholders(sql, params.length);
|
|
568
|
-
const strings = Object.assign(parts.slice(), { raw: parts.slice() }) as unknown as TemplateStringsArray;
|
|
569
|
-
await dbOrTx(strings, ...params);
|
|
570
|
-
}
|
|
571
|
-
|
|
572
|
-
/** Run a SQL string and return at most one row, or `null`. */
|
|
573
|
-
async function queryOne<T extends Record<string, unknown>>(
|
|
574
|
-
dbOrTx: Db,
|
|
575
|
-
sql: string,
|
|
576
|
-
params: unknown[],
|
|
577
|
-
): Promise<T | null> {
|
|
578
|
-
const parts = splitPlaceholders(sql, params.length);
|
|
579
|
-
const strings = Object.assign(parts.slice(), { raw: parts.slice() }) as unknown as TemplateStringsArray;
|
|
580
|
-
const rows = await dbOrTx<T>(strings, ...params);
|
|
581
|
-
if (!rows || rows.length === 0) return null;
|
|
582
|
-
return rows[0] as T;
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
/** Run a parameter-less DDL/DML statement. */
|
|
586
|
-
async function execRaw(dbOrTx: Db, sql: string): Promise<void> {
|
|
587
|
-
const strings = Object.assign([sql], { raw: [sql] }) as unknown as TemplateStringsArray;
|
|
588
|
-
await dbOrTx(strings);
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
/**
|
|
592
|
-
* Split a SQL string with `$1`, `$2`, … markers into the string segments
|
|
593
|
-
* that bracket each placeholder. The resulting array has
|
|
594
|
-
* `placeholderCount + 1` entries — matches the shape of a
|
|
595
|
-
* `TemplateStringsArray` produced by literal interpolation.
|
|
596
|
-
*
|
|
597
|
-
* Throws when the detected placeholder count does not match the expected
|
|
598
|
-
* count — a diagnostic for mismatched SQL + params pairs.
|
|
599
|
-
*/
|
|
600
|
-
function splitPlaceholders(sql: string, expected: number): string[] {
|
|
601
|
-
const parts: string[] = [];
|
|
602
|
-
let rest = sql;
|
|
603
|
-
for (let i = 1; i <= expected; i++) {
|
|
604
|
-
const marker = `$${i}`;
|
|
605
|
-
const idx = rest.indexOf(marker);
|
|
606
|
-
if (idx === -1) {
|
|
607
|
-
throw new Error(
|
|
608
|
-
`[Mandu Session SQLite] placeholder ${marker} missing in SQL: ${sql}`,
|
|
609
|
-
);
|
|
610
|
-
}
|
|
611
|
-
parts.push(rest.slice(0, idx));
|
|
612
|
-
rest = rest.slice(idx + marker.length);
|
|
613
|
-
}
|
|
614
|
-
parts.push(rest);
|
|
615
|
-
return parts;
|
|
616
|
-
}
|
|
617
|
-
|
|
1
|
+
/**
|
|
2
|
+
* @mandujs/core/filling/session-sqlite
|
|
3
|
+
*
|
|
4
|
+
* SQLite-backed `SessionStorage` — drop-in replacement for
|
|
5
|
+
* {@link createCookieSessionStorage} when session payloads outgrow the
|
|
6
|
+
* 4 KB cookie budget or when you need server-side invalidation.
|
|
7
|
+
*
|
|
8
|
+
* ## Contract
|
|
9
|
+
*
|
|
10
|
+
* Implements the same `SessionStorage` interface as
|
|
11
|
+
* `createCookieSessionStorage`. The existing `session()` middleware and
|
|
12
|
+
* the `saveSession` / `destroySession` helpers work unchanged — only the
|
|
13
|
+
* construction call changes.
|
|
14
|
+
*
|
|
15
|
+
* ## Cookie shape
|
|
16
|
+
*
|
|
17
|
+
* Only a signed **session id** travels in the cookie. The actual session
|
|
18
|
+
* `data` lives in SQLite keyed by id. Cookie value is
|
|
19
|
+
* `encodeURIComponent(<uuidv7>) + "." + <hmac-sha256-base64>`, identical
|
|
20
|
+
* in shape to `CookieManager.setSigned` output.
|
|
21
|
+
*
|
|
22
|
+
* ## Phase 4a Appendix D compliance
|
|
23
|
+
*
|
|
24
|
+
* - **D.4 WAL mode**: issued at init via `PRAGMA journal_mode = WAL` so
|
|
25
|
+
* concurrent writers don't serialise on the default rollback journal.
|
|
26
|
+
* The Bun.SQL wrapper deliberately does not auto-enable WAL (some
|
|
27
|
+
* embedded deployments need rollback journals) — we opt in here.
|
|
28
|
+
* - **D.5 use `createDb`**: connection goes through `@mandujs/core/db`,
|
|
29
|
+
* never `new Bun.SQL` directly. Keeps URL/options translation in
|
|
30
|
+
* exactly one place.
|
|
31
|
+
*
|
|
32
|
+
* ## TTL GC
|
|
33
|
+
*
|
|
34
|
+
* Expired rows are swept by a cron job registered via
|
|
35
|
+
* `@mandujs/core/scheduler`. If `Bun.cron` is unavailable (pre-1.3.12),
|
|
36
|
+
* we warn once and continue — the caller can still invoke
|
|
37
|
+
* {@link SqliteSessionStorage.gcNow} manually from their own boot hook.
|
|
38
|
+
*
|
|
39
|
+
* @example
|
|
40
|
+
* ```ts
|
|
41
|
+
* import { createSqliteSessionStorage } from "@mandujs/core/filling/session-sqlite";
|
|
42
|
+
* import { session } from "@mandujs/core/middleware";
|
|
43
|
+
*
|
|
44
|
+
* const storage = createSqliteSessionStorage({
|
|
45
|
+
* cookie: { secrets: [process.env.SESSION_SECRET!] },
|
|
46
|
+
* dbPath: ".mandu/sessions.db",
|
|
47
|
+
* ttlSeconds: 60 * 60 * 24 * 7, // 7 days
|
|
48
|
+
* });
|
|
49
|
+
*
|
|
50
|
+
* // on shutdown:
|
|
51
|
+
* await storage.close();
|
|
52
|
+
* ```
|
|
53
|
+
*
|
|
54
|
+
* @module filling/session-sqlite
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
import { createDb, type Db } from "../db";
|
|
58
|
+
import { defineCron, type CronRegistration } from "../scheduler";
|
|
59
|
+
import type { CookieManager, CookieOptions } from "./context";
|
|
60
|
+
import {
|
|
61
|
+
Session,
|
|
62
|
+
type CookieSessionOptions,
|
|
63
|
+
type SessionData,
|
|
64
|
+
type SessionStorage,
|
|
65
|
+
} from "./session";
|
|
66
|
+
|
|
67
|
+
// ─── Public API ─────────────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
/** Construction options for {@link createSqliteSessionStorage}. */
|
|
70
|
+
export interface SqliteSessionStorageOptions {
|
|
71
|
+
/**
|
|
72
|
+
* Cookie-layer settings — reused verbatim from
|
|
73
|
+
* {@link CookieSessionOptions}. The cookie only carries a signed id,
|
|
74
|
+
* but name / secrets / flags / max-age still apply.
|
|
75
|
+
*/
|
|
76
|
+
cookie: CookieSessionOptions["cookie"];
|
|
77
|
+
/**
|
|
78
|
+
* SQLite database path. Accepts `":memory:"` for transient tests or a
|
|
79
|
+
* filesystem path for persisted sessions. Default: `".mandu/sessions.db"`.
|
|
80
|
+
*
|
|
81
|
+
* The string is appended to `sqlite:` to form a URL that
|
|
82
|
+
* `@mandujs/core/db` accepts.
|
|
83
|
+
*/
|
|
84
|
+
dbPath?: string;
|
|
85
|
+
/**
|
|
86
|
+
* Table name for the session rows. Default: `"mandu_sessions"`.
|
|
87
|
+
*
|
|
88
|
+
* Not parameterisable at query time (SQLite does not bind identifiers),
|
|
89
|
+
* so we validate against `SAFE_IDENT_RE` at construction to keep the
|
|
90
|
+
* name out of injection-prone string interpolation territory.
|
|
91
|
+
*/
|
|
92
|
+
table?: string;
|
|
93
|
+
/**
|
|
94
|
+
* Absolute session lifetime in seconds. Default: `604800` (7 days). A
|
|
95
|
+
* row's `expires_at` is (re)set on every commit; old values are wiped
|
|
96
|
+
* by {@link SqliteSessionStorage.gcNow}.
|
|
97
|
+
*/
|
|
98
|
+
ttlSeconds?: number;
|
|
99
|
+
/**
|
|
100
|
+
* Cron schedule for the TTL sweep. Default: `"0 * * * *"` (hourly).
|
|
101
|
+
* Set to `false` to disable the cron entirely — callers can still
|
|
102
|
+
* invoke `gcNow()` manually.
|
|
103
|
+
*/
|
|
104
|
+
gcSchedule?: string | false;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* `SessionStorage` + SQLite-specific affordances. Returned by
|
|
109
|
+
* {@link createSqliteSessionStorage}.
|
|
110
|
+
*/
|
|
111
|
+
export interface SqliteSessionStorage extends SessionStorage {
|
|
112
|
+
/**
|
|
113
|
+
* Immediately sweep expired rows. Safe to invoke at any time — the
|
|
114
|
+
* cron job calls the same underlying delete.
|
|
115
|
+
*
|
|
116
|
+
* @returns The number of rows deleted.
|
|
117
|
+
*/
|
|
118
|
+
gcNow(): Promise<number>;
|
|
119
|
+
/**
|
|
120
|
+
* Stop the GC cron (if started) and close the DB pool. Call from your
|
|
121
|
+
* shutdown hook to release file handles.
|
|
122
|
+
*/
|
|
123
|
+
close(): Promise<void>;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ─── Constants ──────────────────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
const DEFAULT_DB_PATH = ".mandu/sessions.db";
|
|
129
|
+
const DEFAULT_TABLE = "mandu_sessions";
|
|
130
|
+
const DEFAULT_TTL_SECONDS = 60 * 60 * 24 * 7; // 7 days
|
|
131
|
+
const DEFAULT_GC_SCHEDULE = "0 * * * *"; // hourly
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Safe identifier pattern for the user-supplied `table` name. SQLite does
|
|
135
|
+
* not bind identifiers, so the table name is interpolated directly into
|
|
136
|
+
* DDL/DML — we constrain it to `[A-Za-z_][A-Za-z0-9_]*` to eliminate any
|
|
137
|
+
* injection surface.
|
|
138
|
+
*/
|
|
139
|
+
const SAFE_IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
140
|
+
|
|
141
|
+
// ─── HMAC helpers ────────────────────────────────────────────────────────────
|
|
142
|
+
//
|
|
143
|
+
// Structurally identical to the private `hmacSign` inside `context.ts` and
|
|
144
|
+
// the signing logic in `createCookieSessionStorage`. Duplicated rather than
|
|
145
|
+
// shared to avoid widening `filling/context.ts`'s public surface; TODO:
|
|
146
|
+
// extract into a private `filling/hmac.ts` once a third call site appears.
|
|
147
|
+
|
|
148
|
+
async function hmacSign(data: string, secret: string): Promise<string> {
|
|
149
|
+
const encoder = new TextEncoder();
|
|
150
|
+
const key = await crypto.subtle.importKey(
|
|
151
|
+
"raw",
|
|
152
|
+
encoder.encode(secret),
|
|
153
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
154
|
+
false,
|
|
155
|
+
["sign"],
|
|
156
|
+
);
|
|
157
|
+
const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(data));
|
|
158
|
+
return btoa(String.fromCharCode(...new Uint8Array(sig))).replace(/=+$/, "");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Verify a signed cookie value against each secret in rotation order.
|
|
163
|
+
* Returns the raw session id on success; `null` when none of the secrets
|
|
164
|
+
* validate (invalid signature, tampered value, or no cookie).
|
|
165
|
+
*/
|
|
166
|
+
async function verifySignedId(
|
|
167
|
+
rawCookieValue: string | undefined,
|
|
168
|
+
secrets: readonly string[],
|
|
169
|
+
): Promise<string | null> {
|
|
170
|
+
if (!rawCookieValue) return null;
|
|
171
|
+
const dot = rawCookieValue.lastIndexOf(".");
|
|
172
|
+
if (dot <= 0) return null;
|
|
173
|
+
const payload = rawCookieValue.slice(0, dot);
|
|
174
|
+
const signature = rawCookieValue.slice(dot + 1);
|
|
175
|
+
if (!payload || !signature) return null;
|
|
176
|
+
for (const secret of secrets) {
|
|
177
|
+
const expected = await hmacSign(payload, secret);
|
|
178
|
+
if (expected === signature) {
|
|
179
|
+
try {
|
|
180
|
+
return decodeURIComponent(payload);
|
|
181
|
+
} catch {
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ─── Set-Cookie serialisation ───────────────────────────────────────────────
|
|
190
|
+
|
|
191
|
+
function serializeSetCookie(
|
|
192
|
+
name: string,
|
|
193
|
+
value: string,
|
|
194
|
+
opts: CookieOptions,
|
|
195
|
+
): string {
|
|
196
|
+
const parts = [`${name}=${encodeURIComponent(value)}`];
|
|
197
|
+
if (opts.path) parts.push(`Path=${opts.path}`);
|
|
198
|
+
if (opts.domain) parts.push(`Domain=${opts.domain}`);
|
|
199
|
+
if (typeof opts.maxAge === "number") parts.push(`Max-Age=${opts.maxAge}`);
|
|
200
|
+
if (opts.httpOnly) parts.push("HttpOnly");
|
|
201
|
+
if (opts.secure) parts.push("Secure");
|
|
202
|
+
if (opts.sameSite) parts.push(`SameSite=${opts.sameSite}`);
|
|
203
|
+
return parts.join("; ");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ─── Row shape ──────────────────────────────────────────────────────────────
|
|
207
|
+
|
|
208
|
+
interface SessionRow {
|
|
209
|
+
id: string;
|
|
210
|
+
data: string;
|
|
211
|
+
expires_at: number;
|
|
212
|
+
// Index signature so it satisfies the `Record<string, unknown>` constraint
|
|
213
|
+
// that `queryOne`'s generic expects. Property-level types above still win
|
|
214
|
+
// for known keys.
|
|
215
|
+
[key: string]: unknown;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ─── Factory ────────────────────────────────────────────────────────────────
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Construct a SQLite-backed session storage.
|
|
222
|
+
*
|
|
223
|
+
* Initialisation is performed lazily on the first call to
|
|
224
|
+
* {@link SessionStorage.getSession} / {@link SessionStorage.commitSession} /
|
|
225
|
+
* {@link SessionStorage.destroySession} / {@link SqliteSessionStorage.gcNow}
|
|
226
|
+
* — the constructor never opens a connection itself. This matches
|
|
227
|
+
* `@mandujs/core/db`'s own laziness and keeps environment-driven
|
|
228
|
+
* construction cheap at boot.
|
|
229
|
+
*
|
|
230
|
+
* @throws {Error} Synchronously when `cookie.secrets` is empty or when
|
|
231
|
+
* `table` fails the safe-identifier check.
|
|
232
|
+
*/
|
|
233
|
+
export function createSqliteSessionStorage(
|
|
234
|
+
options: SqliteSessionStorageOptions,
|
|
235
|
+
): SqliteSessionStorage {
|
|
236
|
+
const {
|
|
237
|
+
cookie,
|
|
238
|
+
dbPath = DEFAULT_DB_PATH,
|
|
239
|
+
table = DEFAULT_TABLE,
|
|
240
|
+
ttlSeconds = DEFAULT_TTL_SECONDS,
|
|
241
|
+
gcSchedule = DEFAULT_GC_SCHEDULE,
|
|
242
|
+
} = options;
|
|
243
|
+
|
|
244
|
+
const {
|
|
245
|
+
name: cookieName = "__session",
|
|
246
|
+
secrets,
|
|
247
|
+
httpOnly = true,
|
|
248
|
+
secure = process.env.NODE_ENV === "production",
|
|
249
|
+
sameSite = "lax",
|
|
250
|
+
maxAge = ttlSeconds,
|
|
251
|
+
path = "/",
|
|
252
|
+
domain,
|
|
253
|
+
} = cookie;
|
|
254
|
+
|
|
255
|
+
if (!secrets || secrets.length === 0) {
|
|
256
|
+
throw new Error(
|
|
257
|
+
"[Mandu Session SQLite] At least one cookie.secret is required.",
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
if (!SAFE_IDENT_RE.test(table)) {
|
|
261
|
+
throw new Error(
|
|
262
|
+
`[Mandu Session SQLite] Invalid table name ${JSON.stringify(table)}. ` +
|
|
263
|
+
`Must match ${SAFE_IDENT_RE}.`,
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const cookieOpts: CookieOptions = {
|
|
268
|
+
httpOnly,
|
|
269
|
+
secure,
|
|
270
|
+
sameSite,
|
|
271
|
+
maxAge,
|
|
272
|
+
path,
|
|
273
|
+
domain,
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
// ─── DB init (idempotent) ─────────────────────────────────────────────────
|
|
277
|
+
|
|
278
|
+
const url = `sqlite:${dbPath}`;
|
|
279
|
+
const db: Db = createDb({ url });
|
|
280
|
+
|
|
281
|
+
// Init is run at most once. Each public method awaits this promise to
|
|
282
|
+
// guarantee the schema + PRAGMAs are in place before any other query.
|
|
283
|
+
let initPromise: Promise<void> | null = null;
|
|
284
|
+
let closed = false;
|
|
285
|
+
|
|
286
|
+
function ensureInit(): Promise<void> {
|
|
287
|
+
if (initPromise) return initPromise;
|
|
288
|
+
initPromise = (async () => {
|
|
289
|
+
// Appendix D.4: enable WAL explicitly. Concurrent session writes on
|
|
290
|
+
// the default rollback journal serialise hard; WAL gives us
|
|
291
|
+
// readers-don't-block-writers semantics. Idempotent — if the file
|
|
292
|
+
// is already in WAL, this is a no-op reflected in the return row.
|
|
293
|
+
//
|
|
294
|
+
// `PRAGMA journal_mode = WAL` must be run on the handle, not in a
|
|
295
|
+
// transaction. `:memory:` databases accept the pragma but silently
|
|
296
|
+
// remain in "memory" mode — we don't assert the return value here
|
|
297
|
+
// to keep `:memory:` valid for tests; the WAL-mode test asserts on
|
|
298
|
+
// a file-backed DB instead.
|
|
299
|
+
//
|
|
300
|
+
// Interpolation is safe here — the SQL text is a literal, no user
|
|
301
|
+
// input.
|
|
302
|
+
await db`PRAGMA journal_mode = WAL`;
|
|
303
|
+
|
|
304
|
+
// Identifier was validated above, so direct interpolation into DDL
|
|
305
|
+
// is safe and unavoidable (SQLite does not bind identifiers).
|
|
306
|
+
const createTableSql = `CREATE TABLE IF NOT EXISTS ${table} (
|
|
307
|
+
id TEXT PRIMARY KEY,
|
|
308
|
+
data TEXT NOT NULL,
|
|
309
|
+
expires_at INTEGER NOT NULL
|
|
310
|
+
)`;
|
|
311
|
+
const createIndexSql = `CREATE INDEX IF NOT EXISTS ${table}_expires ON ${table}(expires_at)`;
|
|
312
|
+
|
|
313
|
+
await execRaw(db, createTableSql);
|
|
314
|
+
await execRaw(db, createIndexSql);
|
|
315
|
+
})();
|
|
316
|
+
return initPromise;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// ─── Cron (optional) ──────────────────────────────────────────────────────
|
|
320
|
+
|
|
321
|
+
let cronReg: CronRegistration | null = null;
|
|
322
|
+
function startCronIfEnabled(): void {
|
|
323
|
+
if (gcSchedule === false) return;
|
|
324
|
+
if (cronReg) return;
|
|
325
|
+
try {
|
|
326
|
+
const reg = defineCron({
|
|
327
|
+
[`${table}:gc`]: {
|
|
328
|
+
schedule: gcSchedule,
|
|
329
|
+
run: async () => {
|
|
330
|
+
await gcNow();
|
|
331
|
+
},
|
|
332
|
+
},
|
|
333
|
+
});
|
|
334
|
+
reg.start();
|
|
335
|
+
cronReg = reg;
|
|
336
|
+
} catch (err) {
|
|
337
|
+
// Older Bun (< 1.3.12) lacks `Bun.cron`. Warn once — the caller
|
|
338
|
+
// can still call `gcNow()` manually — then keep serving traffic.
|
|
339
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
340
|
+
console.warn(
|
|
341
|
+
`[Mandu Session SQLite] GC cron disabled: ${msg}. ` +
|
|
342
|
+
`Sessions will still persist; call storage.gcNow() manually.`,
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Register cron after the first init completes so the scheduler's first
|
|
348
|
+
// tick never races with table creation. We fire-and-forget the init —
|
|
349
|
+
// any error surfaces on the next real query.
|
|
350
|
+
void ensureInit().then(startCronIfEnabled);
|
|
351
|
+
|
|
352
|
+
// ─── SessionStorage methods ───────────────────────────────────────────────
|
|
353
|
+
|
|
354
|
+
async function getSession(cookies: CookieManager): Promise<Session> {
|
|
355
|
+
if (closed) {
|
|
356
|
+
throw new Error("[Mandu Session SQLite] storage is closed.");
|
|
357
|
+
}
|
|
358
|
+
await ensureInit();
|
|
359
|
+
|
|
360
|
+
const raw = cookies.get(cookieName);
|
|
361
|
+
const decoded = typeof raw === "string" ? safeDecode(raw) : null;
|
|
362
|
+
const id = await verifySignedId(decoded ?? undefined, secrets);
|
|
363
|
+
if (!id) return new Session();
|
|
364
|
+
|
|
365
|
+
const now = Date.now();
|
|
366
|
+
// Direct identifier interpolation is safe (table validated at
|
|
367
|
+
// construction). The user-controlled `id` and `now` go through Bun.SQL
|
|
368
|
+
// placeholder binding.
|
|
369
|
+
const sql = `SELECT id, data, expires_at FROM ${table} WHERE id = $1 AND expires_at > $2 LIMIT 1`;
|
|
370
|
+
const row = await queryOne<SessionRow>(db, sql, [id, now]);
|
|
371
|
+
if (!row) return new Session();
|
|
372
|
+
|
|
373
|
+
let parsed: SessionData;
|
|
374
|
+
try {
|
|
375
|
+
parsed = JSON.parse(row.data) as SessionData;
|
|
376
|
+
} catch {
|
|
377
|
+
// Corrupted row — treat as a missed session rather than throwing at
|
|
378
|
+
// the end user. A later commit will overwrite it.
|
|
379
|
+
return new Session();
|
|
380
|
+
}
|
|
381
|
+
return rehydrateSession(parsed, id);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
async function commitSession(session: Session): Promise<string> {
|
|
385
|
+
if (closed) {
|
|
386
|
+
throw new Error("[Mandu Session SQLite] storage is closed.");
|
|
387
|
+
}
|
|
388
|
+
await ensureInit();
|
|
389
|
+
|
|
390
|
+
// Clean + already-persisted sessions emit no Set-Cookie — matches the
|
|
391
|
+
// "no-op when nothing changed" contract.
|
|
392
|
+
if (!session.isDirty()) return "";
|
|
393
|
+
|
|
394
|
+
// Always ensure the row has a stable id. Sessions loaded by
|
|
395
|
+
// `getSession` keep their DB id; freshly-constructed sessions use
|
|
396
|
+
// the UUID v7 generated in the `Session` constructor.
|
|
397
|
+
const id = session.id;
|
|
398
|
+
if (!id || typeof id !== "string") {
|
|
399
|
+
throw new Error("[Mandu Session SQLite] session.id missing.");
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const expiresAt = Date.now() + ttlSeconds * 1000;
|
|
403
|
+
const dataJson = JSON.stringify(session.toJSON());
|
|
404
|
+
|
|
405
|
+
// INSERT OR REPLACE — last write wins, concurrent writes resolve
|
|
406
|
+
// under WAL without corruption.
|
|
407
|
+
const sql = `INSERT OR REPLACE INTO ${table} (id, data, expires_at) VALUES ($1, $2, $3)`;
|
|
408
|
+
await execWithParams(db, sql, [id, dataJson, expiresAt]);
|
|
409
|
+
|
|
410
|
+
// Cookie carries ONLY the signed id. Never the data.
|
|
411
|
+
const signedValue = await signPayload(id, secrets[0]);
|
|
412
|
+
return serializeSetCookie(cookieName, signedValue, cookieOpts);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
async function destroySession(session: Session): Promise<string> {
|
|
416
|
+
if (closed) {
|
|
417
|
+
throw new Error("[Mandu Session SQLite] storage is closed.");
|
|
418
|
+
}
|
|
419
|
+
await ensureInit();
|
|
420
|
+
|
|
421
|
+
const id = session.id;
|
|
422
|
+
if (typeof id === "string" && id.length > 0) {
|
|
423
|
+
const sql = `DELETE FROM ${table} WHERE id = $1`;
|
|
424
|
+
await execWithParams(db, sql, [id]);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Emit an expiring cookie so the browser drops its copy. Shape
|
|
428
|
+
// matches `createCookieSessionStorage`.
|
|
429
|
+
const parts = [`${cookieName}=`, `Path=${path}`, "Max-Age=0"];
|
|
430
|
+
if (domain) parts.push(`Domain=${domain}`);
|
|
431
|
+
if (httpOnly) parts.push("HttpOnly");
|
|
432
|
+
if (secure) parts.push("Secure");
|
|
433
|
+
if (sameSite) parts.push(`SameSite=${sameSite}`);
|
|
434
|
+
return parts.join("; ");
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
async function gcNow(): Promise<number> {
|
|
438
|
+
if (closed) {
|
|
439
|
+
throw new Error("[Mandu Session SQLite] storage is closed.");
|
|
440
|
+
}
|
|
441
|
+
await ensureInit();
|
|
442
|
+
|
|
443
|
+
const now = Date.now();
|
|
444
|
+
// Count-then-delete inside a transaction so we return an accurate
|
|
445
|
+
// number even when another writer races us. Under WAL this is cheap.
|
|
446
|
+
// Identifier is interpolated (validated at construction); the time
|
|
447
|
+
// parameter is bound.
|
|
448
|
+
let deleted = 0;
|
|
449
|
+
await db.transaction(async (tx) => {
|
|
450
|
+
const countSql = `SELECT COUNT(*) AS n FROM ${table} WHERE expires_at <= $1`;
|
|
451
|
+
const cnt = await queryOne<{ n: number | bigint }>(tx, countSql, [now]);
|
|
452
|
+
deleted = cnt ? Number(cnt.n) : 0;
|
|
453
|
+
const delSql = `DELETE FROM ${table} WHERE expires_at <= $1`;
|
|
454
|
+
await execWithParams(tx, delSql, [now]);
|
|
455
|
+
});
|
|
456
|
+
return deleted;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
async function close(): Promise<void> {
|
|
460
|
+
if (closed) return;
|
|
461
|
+
closed = true;
|
|
462
|
+
if (cronReg) {
|
|
463
|
+
try {
|
|
464
|
+
await cronReg.stop();
|
|
465
|
+
} catch {
|
|
466
|
+
// Best-effort shutdown — don't mask the caller's shutdown flow.
|
|
467
|
+
}
|
|
468
|
+
cronReg = null;
|
|
469
|
+
}
|
|
470
|
+
await db.close();
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
return {
|
|
474
|
+
getSession,
|
|
475
|
+
commitSession,
|
|
476
|
+
destroySession,
|
|
477
|
+
gcNow,
|
|
478
|
+
close,
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// ─── Signing ────────────────────────────────────────────────────────────────
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Sign a session id with the primary secret. The cookie value carries
|
|
486
|
+
* `encodeURIComponent(id) + "." + <sig>`, matching the shape produced by
|
|
487
|
+
* `CookieManager.setSigned` so `CookieManager.getSigned` could consume it
|
|
488
|
+
* symmetrically in a pinch.
|
|
489
|
+
*/
|
|
490
|
+
async function signPayload(id: string, secret: string): Promise<string> {
|
|
491
|
+
const encoded = encodeURIComponent(id);
|
|
492
|
+
const sig = await hmacSign(encoded, secret);
|
|
493
|
+
return `${encoded}.${sig}`;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* URL-decode a cookie value, returning `null` on malformed input. We
|
|
498
|
+
* never throw on untrusted cookie data.
|
|
499
|
+
*/
|
|
500
|
+
function safeDecode(value: string): string | null {
|
|
501
|
+
try {
|
|
502
|
+
return decodeURIComponent(value);
|
|
503
|
+
} catch {
|
|
504
|
+
return null;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Rebuild a {@link Session} from its persisted JSON blob while preserving
|
|
510
|
+
* the supplied `id`.
|
|
511
|
+
*
|
|
512
|
+
* `Session.fromJSON` mints a fresh UUID for the rebuilt instance —
|
|
513
|
+
* correct for cookie storage (the id lives on the cookie itself) but
|
|
514
|
+
* wrong for SQLite where the row's primary key must stay stable across
|
|
515
|
+
* requests. We go through the public constructor with `(data, id)` and
|
|
516
|
+
* then replay flash keys so `session.get(k)` returns the flash on first
|
|
517
|
+
* read, empty on subsequent reads — same contract as `fromJSON`.
|
|
518
|
+
*
|
|
519
|
+
* The replay needs write access to the private `flash` map and to the
|
|
520
|
+
* `_dirty` flag; we reuse the same narrow-interface cast the cookie
|
|
521
|
+
* codepath uses internally.
|
|
522
|
+
*/
|
|
523
|
+
function rehydrateSession(data: SessionData, id: string): Session {
|
|
524
|
+
const session = new Session({}, id);
|
|
525
|
+
const flashKeys: string[] = [];
|
|
526
|
+
|
|
527
|
+
// Narrow structural view of the private fields we need to populate.
|
|
528
|
+
// Session's public API does not expose a "load raw JSON with id" entry,
|
|
529
|
+
// so we reach in carefully — the shape matches session.ts:42-53.
|
|
530
|
+
const internal = session as unknown as {
|
|
531
|
+
data: SessionData;
|
|
532
|
+
flash: Map<string, unknown>;
|
|
533
|
+
_dirty: boolean;
|
|
534
|
+
};
|
|
535
|
+
|
|
536
|
+
for (const [key, value] of Object.entries(data)) {
|
|
537
|
+
if (key.startsWith("__flash_")) {
|
|
538
|
+
const realKey = key.slice(8);
|
|
539
|
+
internal.flash.set(realKey, value);
|
|
540
|
+
flashKeys.push(key);
|
|
541
|
+
} else {
|
|
542
|
+
internal.data[key] = value;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
for (const key of flashKeys) {
|
|
546
|
+
delete internal.data[key];
|
|
547
|
+
}
|
|
548
|
+
// Just-loaded state is clean by definition.
|
|
549
|
+
internal._dirty = false;
|
|
550
|
+
return session;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// ─── DB helpers ─────────────────────────────────────────────────────────────
|
|
554
|
+
//
|
|
555
|
+
// `@mandujs/core/db` exposes a tagged-template API. Our DDL/DML strings
|
|
556
|
+
// are dynamic (they interpolate the table name, which SQLite doesn't
|
|
557
|
+
// bind), so we construct the TemplateStringsArray ourselves via the
|
|
558
|
+
// pattern used by Bun.SQL: split the string at `$1`, `$2`, … markers and
|
|
559
|
+
// forward values in positional order.
|
|
560
|
+
|
|
561
|
+
/** Run a SQL string with positional `$N` placeholders. No result rows consumed. */
|
|
562
|
+
async function execWithParams(
|
|
563
|
+
dbOrTx: Db,
|
|
564
|
+
sql: string,
|
|
565
|
+
params: unknown[],
|
|
566
|
+
): Promise<void> {
|
|
567
|
+
const parts = splitPlaceholders(sql, params.length);
|
|
568
|
+
const strings = Object.assign(parts.slice(), { raw: parts.slice() }) as unknown as TemplateStringsArray;
|
|
569
|
+
await dbOrTx(strings, ...params);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/** Run a SQL string and return at most one row, or `null`. */
|
|
573
|
+
async function queryOne<T extends Record<string, unknown>>(
|
|
574
|
+
dbOrTx: Db,
|
|
575
|
+
sql: string,
|
|
576
|
+
params: unknown[],
|
|
577
|
+
): Promise<T | null> {
|
|
578
|
+
const parts = splitPlaceholders(sql, params.length);
|
|
579
|
+
const strings = Object.assign(parts.slice(), { raw: parts.slice() }) as unknown as TemplateStringsArray;
|
|
580
|
+
const rows = await dbOrTx<T>(strings, ...params);
|
|
581
|
+
if (!rows || rows.length === 0) return null;
|
|
582
|
+
return rows[0] as T;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/** Run a parameter-less DDL/DML statement. */
|
|
586
|
+
async function execRaw(dbOrTx: Db, sql: string): Promise<void> {
|
|
587
|
+
const strings = Object.assign([sql], { raw: [sql] }) as unknown as TemplateStringsArray;
|
|
588
|
+
await dbOrTx(strings);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* Split a SQL string with `$1`, `$2`, … markers into the string segments
|
|
593
|
+
* that bracket each placeholder. The resulting array has
|
|
594
|
+
* `placeholderCount + 1` entries — matches the shape of a
|
|
595
|
+
* `TemplateStringsArray` produced by literal interpolation.
|
|
596
|
+
*
|
|
597
|
+
* Throws when the detected placeholder count does not match the expected
|
|
598
|
+
* count — a diagnostic for mismatched SQL + params pairs.
|
|
599
|
+
*/
|
|
600
|
+
function splitPlaceholders(sql: string, expected: number): string[] {
|
|
601
|
+
const parts: string[] = [];
|
|
602
|
+
let rest = sql;
|
|
603
|
+
for (let i = 1; i <= expected; i++) {
|
|
604
|
+
const marker = `$${i}`;
|
|
605
|
+
const idx = rest.indexOf(marker);
|
|
606
|
+
if (idx === -1) {
|
|
607
|
+
throw new Error(
|
|
608
|
+
`[Mandu Session SQLite] placeholder ${marker} missing in SQL: ${sql}`,
|
|
609
|
+
);
|
|
610
|
+
}
|
|
611
|
+
parts.push(rest.slice(0, idx));
|
|
612
|
+
rest = rest.slice(idx + marker.length);
|
|
613
|
+
}
|
|
614
|
+
parts.push(rest);
|
|
615
|
+
return parts;
|
|
616
|
+
}
|
|
617
|
+
|