lambder 14.1.1 → 15.0.2
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/CHANGELOG.md +429 -0
- package/README.md +14 -17
- package/dist/api/LambderApiDeclarations.d.ts +11 -5
- package/dist/api/LambderApiDefinition.d.ts +2 -1
- package/dist/api/LambderApiEnvelope.d.ts +2 -2
- package/dist/api/LambderApiEnvelope.js +8 -2
- package/dist/api/LambderApiGuards.d.ts +44 -5
- package/dist/api/LambderApiGuards.js +27 -0
- package/dist/api/LambderApiIdempotency.d.ts +14 -7
- package/dist/api/LambderApiIdempotency.js +39 -27
- package/dist/api/LambderApiPipeline.d.ts +25 -11
- package/dist/api/LambderApiPipeline.js +34 -17
- package/dist/api/LambderApiRateLimits.d.ts +28 -21
- package/dist/api/LambderApiRateLimits.js +55 -35
- package/dist/api/LambderApiRefusals.d.ts +10 -4
- package/dist/api/LambderApiRefusals.js +31 -5
- package/dist/api/LambderApiRequest.d.ts +21 -1
- package/dist/api/LambderApiRequest.js +31 -2
- package/dist/build/checkApiRefusals.d.ts +8 -3
- package/dist/build/checkApiRefusals.js +80 -29
- package/dist/build/contractPrinterProcess.d.ts +11 -0
- package/dist/build/contractPrinterProcess.js +17 -0
- package/dist/build/generateApiFiles.d.ts +26 -5
- package/dist/build/generateApiFiles.js +68 -7
- package/dist/build/writeApiSchemas.d.ts +109 -0
- package/dist/build/writeApiSchemas.js +308 -0
- package/dist/build.d.ts +3 -1
- package/dist/build.js +2 -0
- package/dist/client/LambderCaller.d.ts +25 -11
- package/dist/client/LambderCaller.js +57 -49
- package/dist/client/LambderUploadRunner.d.ts +55 -27
- package/dist/client/LambderUploadRunner.js +89 -34
- package/dist/client.d.ts +6 -6
- package/dist/client.js +2 -2
- package/dist/core/Lambder.d.ts +31 -21
- package/dist/core/Lambder.js +79 -23
- package/dist/core/LambderCallSummary.d.ts +1 -1
- package/dist/core/LambderContext.d.ts +59 -7
- package/dist/core/LambderContext.js +31 -15
- package/dist/core/LambderCrashHandling.d.ts +5 -0
- package/dist/core/LambderCrashHandling.js +11 -1
- package/dist/core/LambderCreateOptions.d.ts +28 -17
- package/dist/core/LambderCreateOptions.js +14 -7
- package/dist/core/LambderResolver.d.ts +3 -2
- package/dist/core/LambderResponseBuilder.d.ts +20 -9
- package/dist/core/LambderResponseBuilder.js +39 -8
- package/dist/core/LambderTemplatingEngine.d.ts +24 -6
- package/dist/core/LambderTemplatingEngine.js +23 -3
- package/dist/index.d.ts +15 -12
- package/dist/index.js +7 -5
- package/dist/invoke/LambderInvokeCaller.d.ts +25 -4
- package/dist/invoke/LambderInvokeCaller.js +47 -9
- package/dist/invoke/LambderInvokeOutcome.d.ts +7 -6
- package/dist/mock/LambderMockAdapterDoor.d.ts +40 -0
- package/dist/mock/LambderMockAdapterDoor.js +10 -0
- package/dist/mock/LambderMockApiSchemas.d.ts +28 -0
- package/dist/mock/LambderMockApiSchemas.js +150 -0
- package/dist/mock/LambderMockApp.d.ts +56 -48
- package/dist/mock/LambderMockApp.js +107 -62
- package/dist/mock/LambderMockBrowserCookies.js +18 -5
- package/dist/mock/LambderMockCreateOptions.d.ts +51 -4
- package/dist/mock/LambderMockFailureInjector.d.ts +12 -3
- package/dist/mock/LambderMockFailureInjector.js +12 -8
- package/dist/mock/LambderMockTypes.d.ts +8 -4
- package/dist/mock/lambderMockInvokeTransport.js +2 -9
- package/dist/mock/lambderMockMswHandler.d.ts +19 -13
- package/dist/mock/lambderMockMswHandler.js +25 -22
- package/dist/mock.d.ts +1 -0
- package/dist/secrets/LambderOneShotSecrets.d.ts +1 -1
- package/dist/secrets/LambderOneShotSecrets.js +6 -36
- package/dist/secrets/LambderPasswordHasher.d.ts +56 -0
- package/dist/secrets/LambderPasswordHasher.js +168 -0
- package/dist/session/LambderSessionController.d.ts +37 -4
- package/dist/session/LambderSessionController.js +36 -6
- package/dist/session/LambderSessionManager.d.ts +33 -3
- package/dist/session/LambderSessionManager.js +55 -16
- package/dist/shared/LambderHtml.d.ts +23 -4
- package/dist/shared/LambderHtml.js +17 -5
- package/dist/shared/LambderI18n.d.ts +106 -16
- package/dist/shared/LambderI18n.js +259 -74
- package/dist/shared/contracts/LambderCache.d.ts +14 -1
- package/dist/shared/contracts/LambderRateLimiter.d.ts +5 -6
- package/dist/shared/transport/LambderApiTransport.d.ts +3 -2
- package/dist/shared/util/LambderBackoffTimer.d.ts +24 -13
- package/dist/shared/util/LambderBackoffTimer.js +40 -12
- package/dist/shared/util/LambderCallAbort.d.ts +22 -6
- package/dist/shared/util/LambderCallAbort.js +37 -9
- package/dist/shared/util/LambderErrorReportMark.d.ts +4 -0
- package/dist/shared/util/LambderErrorReportMark.js +22 -0
- package/dist/shared/util/LambderKeyFieldDigest.d.ts +46 -0
- package/dist/shared/util/LambderKeyFieldDigest.js +75 -0
- package/dist/shared/util/LambderSignedClaims.d.ts +19 -0
- package/dist/shared/util/LambderSignedClaims.js +54 -0
- package/dist/shared/util/LambderSwappableInstances.d.ts +29 -0
- package/dist/shared/util/LambderSwappableInstances.js +54 -0
- package/dist/shared/util/LambderTestingDoors.d.ts +6 -2
- package/dist/shared/util/LambderTestingDoors.js +6 -2
- package/dist/shared/util/LambderTextDigest.d.ts +5 -5
- package/dist/shared/util/LambderTextDigest.js +5 -5
- package/dist/shared/wire/LambderApiContract.d.ts +1 -1
- package/dist/shared/wire/LambderApiOptionEntries.d.ts +9 -1
- package/dist/shared/wire/LambderApiOptionEntries.js +15 -0
- package/dist/shared/wire/LambderApiOptionValues.d.ts +3 -2
- package/dist/shared/wire/LambderApiOutcome.d.ts +37 -19
- package/dist/shared/wire/LambderApiOutcome.js +1 -1
- package/dist/shared/wire/LambderApiRefusal.d.ts +39 -12
- package/dist/shared/wire/LambderApiRefusal.js +3 -3
- package/dist/shared/wire/LambderApiSchemaEntries.d.ts +21 -0
- package/dist/shared/wire/LambderApiSchemaEntries.js +18 -0
- package/dist/shared/wire/LambderCallOptions.d.ts +3 -3
- package/dist/shared/wire/LambderCallOutcome.d.ts +2 -1
- package/dist/shared/wire/LambderCallOutcome.js +3 -2
- package/dist/shared/wire/LambderIdempotencyKeyScope.d.ts +5 -3
- package/dist/shared/wire/LambderIdempotencyKeyScope.js +5 -3
- package/dist/shared/wire/LambderInvokeApiId.d.ts +3 -3
- package/dist/shared/wire/LambderInvokeApiId.js +3 -3
- package/dist/shared/wire/LambderOutcomeAssertions.d.ts +7 -6
- package/dist/shared/wire/LambderOutcomeAssertions.js +1 -1
- package/dist/stores/LambderCacheValues.d.ts +2 -5
- package/dist/stores/LambderDdbCache.d.ts +15 -9
- package/dist/stores/LambderDdbCache.js +25 -6
- package/dist/stores/LambderDdbIdempotencyStore.d.ts +6 -4
- package/dist/stores/LambderDdbIdempotencyStore.js +32 -52
- package/dist/stores/LambderDdbOneShotSecretStore.d.ts +11 -0
- package/dist/stores/LambderDdbOneShotSecretStore.js +17 -5
- package/dist/stores/LambderDdbSdk.d.ts +59 -29
- package/dist/stores/LambderDdbSdk.js +116 -44
- package/dist/stores/LambderDdbSessionStore.d.ts +26 -11
- package/dist/stores/LambderDdbSessionStore.js +133 -68
- package/dist/stores/LambderMemoryCache.d.ts +2 -2
- package/dist/stores/LambderS3FileSource.d.ts +3 -2
- package/dist/stores/LambderS3FileSource.js +11 -13
- package/dist/stores/LambderS3UploadBucket.d.ts +12 -0
- package/dist/stores/LambderS3UploadBucket.js +23 -0
- package/dist/stores/LambderStorageBackedCache.d.ts +2 -2
- package/dist/stores/LambderStoredText.d.ts +26 -0
- package/dist/stores/LambderStoredText.js +25 -0
- package/dist/testing/LambderConformanceRunner.d.ts +9 -2
- package/dist/testing/LambderConformanceRunner.js +9 -2
- package/dist/testing/LambderTestApp.d.ts +65 -7
- package/dist/testing/LambderTestApp.js +101 -6
- package/dist/testing/LambderTestVisitor.d.ts +43 -12
- package/dist/testing/LambderTestVisitor.js +35 -11
- package/dist/testing/bootLambdaPackage.d.ts +105 -0
- package/dist/testing/bootLambdaPackage.js +265 -0
- package/dist/testing/lambdaPackageBootProcess.d.ts +39 -0
- package/dist/testing/lambdaPackageBootProcess.js +130 -0
- package/dist/testing/lambderCacheConformance.d.ts +26 -0
- package/dist/testing/lambderCacheConformance.js +287 -0
- package/dist/testing/lambderCacheStorageConformance.d.ts +25 -0
- package/dist/testing/lambderCacheStorageConformance.js +159 -0
- package/dist/testing/lambderSessionStoreConformance.d.ts +3 -3
- package/dist/testing/lambderSessionStoreConformance.js +3 -3
- package/dist/testing.d.ts +7 -1
- package/dist/testing.js +5 -0
- package/package.json +1 -6
- package/dist/shared/util/boundKeyField.d.ts +0 -20
- package/dist/shared/util/boundKeyField.js +0 -34
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type LambderApiRequest } from "../api/LambderApiRequest.js";
|
|
2
2
|
import type { LambderApiAnswer } from "../api/LambderApiAnswer.js";
|
|
3
|
-
import { LambderCookieJar } from "../shared/transport/LambderCookieJar.js";
|
|
3
|
+
import type { LambderCookieJar } from "../shared/transport/LambderCookieJar.js";
|
|
4
|
+
import { LAMBDER_MOCK_ADAPTER_DOOR, type LambderMockAdapterDoor } from "./LambderMockAdapterDoor.js";
|
|
4
5
|
/** What a resolver of Lambder's adapters is: msw's, answering a Response, or `undefined` to hand the request on. */
|
|
5
6
|
type LambderMswResolver = (info: {
|
|
6
7
|
request: Request;
|
|
@@ -26,21 +27,23 @@ export type LambderMswModule = {
|
|
|
26
27
|
error(): Response;
|
|
27
28
|
};
|
|
28
29
|
};
|
|
29
|
-
/**
|
|
30
|
+
/**
|
|
31
|
+
* What the adapter needs from the mock app: a parsed request in, an answer
|
|
32
|
+
* out, and the runtime's own bookkeeping behind the adapter door, which a
|
|
33
|
+
* LambderMockApp carries and nothing outside the package can name (see
|
|
34
|
+
* LAMBDER_MOCK_ADAPTER_DOOR). So in practice the target is a LambderMockApp.
|
|
35
|
+
*/
|
|
30
36
|
export type LambderMockMswTarget = {
|
|
31
37
|
handleRequest(request: LambderApiRequest): Promise<LambderApiAnswer>;
|
|
38
|
+
/** Whether a name has an entry, a call handed on to the network, and an answer's readable cookies for the page. */
|
|
39
|
+
readonly [LAMBDER_MOCK_ADAPTER_DOOR]: LambderMockAdapterDoor;
|
|
32
40
|
/**
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
41
|
+
* The page's cookie jar, which this adapter carries its calls' cookies in
|
|
42
|
+
* unless it is given its own: the jar signIn and signOut use when given
|
|
43
|
+
* none, so a session signIn starts is the one these calls send, and the
|
|
44
|
+
* one reset() empties.
|
|
36
45
|
*/
|
|
37
|
-
|
|
38
|
-
/** Records a request this adapter handed on rather than answering. */
|
|
39
|
-
notePassthrough(request: LambderApiRequest): void;
|
|
40
|
-
/** Mirrors an answer's readable cookies into document.cookie, and remembers them for reset(). */
|
|
41
|
-
mirrorCookiesIntoDocument(setCookies: readonly string[]): void;
|
|
42
|
-
/** Takes the jar this adapter built as the runtime's own, so reset() empties it too. */
|
|
43
|
-
adoptCookieJar(jar: LambderCookieJar): void;
|
|
46
|
+
readonly pageCookieJar: LambderCookieJar;
|
|
44
47
|
/** The IP a call carrying none is read as arriving from, so this adapter and the direct transport report one address. */
|
|
45
48
|
readonly defaultClientIp: string;
|
|
46
49
|
/**
|
|
@@ -66,7 +69,9 @@ export type LambderMockMswTarget = {
|
|
|
66
69
|
* them, losing every cookie after the first. So the answer's cookies go into
|
|
67
70
|
* the jar, the next request carries them back, and the ones a page's scripts
|
|
68
71
|
* may see are mirrored into document.cookie. The Set-Cookie headers still
|
|
69
|
-
* travel on the response, where the network panel shows them.
|
|
72
|
+
* travel on the response, where the network panel shows them. The jar is the
|
|
73
|
+
* runtime's page jar unless one is given, the one signIn plants into when it
|
|
74
|
+
* is given none, so `mockApp.signIn(key, data)` signs the page in.
|
|
70
75
|
*
|
|
71
76
|
* A request's cookies are therefore the jar's and document.cookie's, never
|
|
72
77
|
* its Cookie header: MSW fills that from its own store, which captures the
|
|
@@ -86,6 +91,7 @@ export type LambderMockMswTarget = {
|
|
|
86
91
|
export declare const lambderMockMswHandler: <M extends LambderMswModule>(mockApp: LambderMockMswTarget, options: {
|
|
87
92
|
msw: M;
|
|
88
93
|
apiPath: string;
|
|
94
|
+
/** The jar this adapter's calls carry their cookies in. Default: the runtime's pageCookieJar. One passed here stays the app's, and reset() leaves it alone. */
|
|
89
95
|
cookieJar?: LambderCookieJar;
|
|
90
96
|
/**
|
|
91
97
|
* What happens to a call the runtime has no entry for. "refuse"
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { LambderMockTransportError } from "./LambderMockFailureInjector.js";
|
|
2
2
|
import { apiNameOfCallPath } from "../shared/wire/LambderApiNames.js";
|
|
3
|
-
import {
|
|
3
|
+
import { readApiEnvelopeText, cookieValuesByName, isApiCallContentType, lowercaseHeaderNames } from "../api/LambderApiRequest.js";
|
|
4
4
|
import { getAnswerHeader } from "../shared/wire/LambderAnswerHeaders.js";
|
|
5
|
-
import { LambderCookieJar } from "../shared/transport/LambderCookieJar.js";
|
|
6
5
|
import { normalizeClientIp } from "../shared/util/LambderClientIp.js";
|
|
6
|
+
import { LAMBDER_MOCK_ADAPTER_DOOR } from "./LambderMockAdapterDoor.js";
|
|
7
7
|
/**
|
|
8
8
|
* ONE MSW request handler for the whole API path, over the mock app: the
|
|
9
9
|
* opt-in that makes mocked calls appear in the browser's network panel as
|
|
@@ -17,7 +17,9 @@ import { normalizeClientIp } from "../shared/util/LambderClientIp.js";
|
|
|
17
17
|
* them, losing every cookie after the first. So the answer's cookies go into
|
|
18
18
|
* the jar, the next request carries them back, and the ones a page's scripts
|
|
19
19
|
* may see are mirrored into document.cookie. The Set-Cookie headers still
|
|
20
|
-
* travel on the response, where the network panel shows them.
|
|
20
|
+
* travel on the response, where the network panel shows them. The jar is the
|
|
21
|
+
* runtime's page jar unless one is given, the one signIn plants into when it
|
|
22
|
+
* is given none, so `mockApp.signIn(key, data)` signs the page in.
|
|
21
23
|
*
|
|
22
24
|
* A request's cookies are therefore the jar's and document.cookie's, never
|
|
23
25
|
* its Cookie header: MSW fills that from its own store, which captures the
|
|
@@ -39,12 +41,17 @@ export const lambderMockMswHandler = (mockApp, options) => {
|
|
|
39
41
|
if (!msw?.http || !msw?.HttpResponse) {
|
|
40
42
|
throw new Error('lambderMockMswHandler requires the msw module: lambderMockMswHandler(mockApp, { apiPath, msw: await import("msw") }). Install it with: npm install msw --save-dev');
|
|
41
43
|
}
|
|
42
|
-
|
|
43
|
-
//
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
44
|
+
// The runtime's bookkeeping is reached through the door a LambderMockApp
|
|
45
|
+
// carries; anything else would fail on its first call rather than here.
|
|
46
|
+
const door = mockApp?.[LAMBDER_MOCK_ADAPTER_DOOR];
|
|
47
|
+
if (typeof door?.hasRegisteredEntry !== "function") {
|
|
48
|
+
throw new Error("lambderMockMswHandler serves a LambderMockApp: pass the app initLambderMock().create() built, as lambderMockMswHandler(mockApp, { apiPath, msw }).");
|
|
49
|
+
}
|
|
50
|
+
// The page's jar unless the app passes its own: a jar of this adapter's
|
|
51
|
+
// alone would hold the session of the calls it serves and never the one
|
|
52
|
+
// signIn planted, and every session call behind the worker after a
|
|
53
|
+
// signIn would answer sessionExpired.
|
|
54
|
+
const jar = options.cookieJar ?? mockApp.pageCookieJar;
|
|
48
55
|
// The runtime's default, not one of this adapter's own, so the direct
|
|
49
56
|
// transport and the service worker agree on ctx.request.ip and a per-IP
|
|
50
57
|
// rate limit counts one client as one. Normalized the way every other
|
|
@@ -54,13 +61,6 @@ export const lambderMockMswHandler = (mockApp, options) => {
|
|
|
54
61
|
// Every call goes to `{apiPath}/{group}/{action}`; the endpoint is read
|
|
55
62
|
// off the path as the server reads it (apiNameOfCallPath).
|
|
56
63
|
const handler = msw.http.post(`${apiPath.replace(/\/+$/, "")}/:group/:action`, async ({ request }) => {
|
|
57
|
-
let post;
|
|
58
|
-
try {
|
|
59
|
-
post = await request.clone().json();
|
|
60
|
-
}
|
|
61
|
-
catch {
|
|
62
|
-
return undefined;
|
|
63
|
-
}
|
|
64
64
|
// Through the one header map every adapter builds, on
|
|
65
65
|
// Object.create(null): on a plain object, a header literally named
|
|
66
66
|
// __proto__ would be dropped here though the server keeps it as an own
|
|
@@ -68,8 +68,8 @@ export const lambderMockMswHandler = (mockApp, options) => {
|
|
|
68
68
|
// function where every other adapter gives undefined.
|
|
69
69
|
const headers = lowercaseHeaderNames(Object.fromEntries(request.headers));
|
|
70
70
|
// A POST of another type is no API call on the server either: it
|
|
71
|
-
// goes on to MSW's other handlers and the network,
|
|
72
|
-
//
|
|
71
|
+
// goes on to MSW's other handlers and the network, rather than
|
|
72
|
+
// working here and not in production.
|
|
73
73
|
if (!isApiCallContentType(headers))
|
|
74
74
|
return undefined;
|
|
75
75
|
// The cookies travel as `cookies` below, as the direct transport's do.
|
|
@@ -97,15 +97,18 @@ export const lambderMockMswHandler = (mockApp, options) => {
|
|
|
97
97
|
const apiName = apiNameOfCallPath(new URL(apiPath, url).pathname, url.pathname);
|
|
98
98
|
if (apiName === null)
|
|
99
99
|
return undefined;
|
|
100
|
-
|
|
100
|
+
// Read from the text, as the server reads it: a body that is not a
|
|
101
|
+
// JSON object, or not JSON, is a call the pipeline refuses as the
|
|
102
|
+
// server does, not one handed on to the network.
|
|
103
|
+
const parsed = readApiEnvelopeText(await request.clone().text(), {
|
|
101
104
|
headers, cookies, ip: clientIp, host: url.host, signal: request.signal,
|
|
102
105
|
}, apiName);
|
|
103
106
|
// Returning undefined hands the request back to MSW, which tries its
|
|
104
107
|
// other handlers and then the network. Noted on the runtime first:
|
|
105
108
|
// a passthrough that leaves no event and no call-log row is a
|
|
106
109
|
// mistyped endpoint name reaching the real backend in silence.
|
|
107
|
-
if (options.onUnmocked === "passthrough" && !
|
|
108
|
-
|
|
110
|
+
if (options.onUnmocked === "passthrough" && !door.hasRegisteredEntry(parsed.apiName)) {
|
|
111
|
+
door.notePassthrough(parsed);
|
|
109
112
|
return undefined;
|
|
110
113
|
}
|
|
111
114
|
let answer;
|
|
@@ -126,7 +129,7 @@ export const lambderMockMswHandler = (mockApp, options) => {
|
|
|
126
129
|
// this every session call fails its CSRF check. Done by the runtime,
|
|
127
130
|
// which owns the one mirror implementation and remembers what was
|
|
128
131
|
// planted for reset().
|
|
129
|
-
|
|
132
|
+
door.mirrorCookiesIntoDocument(setCookies);
|
|
130
133
|
const responseHeaders = new Headers();
|
|
131
134
|
for (const [key, values] of Object.entries(answer.headers)) {
|
|
132
135
|
for (const value of values)
|
package/dist/mock.d.ts
CHANGED
|
@@ -13,6 +13,7 @@ export type { LambderMockCallContext, LambderMockSessionCallContext, LambderMock
|
|
|
13
13
|
export { lambderMockPoliciesFrom } from "./mock/lambderMockPoliciesFrom.js";
|
|
14
14
|
export type { LambderCustomKeyedPolicyNames, LambderMockPolicyKeys } from "./mock/lambderMockPoliciesFrom.js";
|
|
15
15
|
export type { LambderMockGuardShapeOf } from "./mock/LambderMockCreateOptions.js";
|
|
16
|
+
export type { LambderApiSchemaEntries, LambderApiSchemaEntry, LambderJsonSchema } from "./shared/wire/LambderApiSchemaEntries.js";
|
|
16
17
|
export { lambderMockConsoleLogger } from "./mock/lambderMockConsoleLogger.js";
|
|
17
18
|
export type { LambderMockConsoleLoggerOptions } from "./mock/lambderMockConsoleLogger.js";
|
|
18
19
|
export { lambderMockMswHandler } from "./mock/lambderMockMswHandler.js";
|
|
@@ -140,7 +140,7 @@ export declare class LambderOneShotSecrets<TKinds extends Record<string, Lambder
|
|
|
140
140
|
* less than that ago is refused instead, with the second it may ask
|
|
141
141
|
* again; of two callers racing past the cooldown, exactly one is issued.
|
|
142
142
|
* `meta` is what the app wants back at redemption: an identity, an
|
|
143
|
-
* issuing
|
|
143
|
+
* issuing store, as small strings.
|
|
144
144
|
*
|
|
145
145
|
* A secret whose digest another scope holds is drawn again, up to
|
|
146
146
|
* MAX_DRAWS times; past that the kind's alphabet and length leave too few
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { keyedDigest, randomSecret } from "../shared/util/LambderSignedClaims.js";
|
|
1
|
+
import { assertCodeAlphabet, keyedDigest, randomCode, randomSecret } from "../shared/util/LambderSignedClaims.js";
|
|
2
2
|
import { constantTimeEquals } from "../shared/util/LambderTextDigest.js";
|
|
3
3
|
import { assertPositiveInteger } from "../shared/util/LambderOptionChecks.js";
|
|
4
4
|
import { joinKeyFields } from "../shared/util/joinKeyFields.js";
|
|
@@ -54,12 +54,12 @@ export class LambderOneShotSecrets {
|
|
|
54
54
|
if (kind.shape === "code") {
|
|
55
55
|
assertPositiveInteger(kind.length, `kinds.${name}.length`);
|
|
56
56
|
assertPositiveInteger(kind.maxAttempts, `kinds.${name}.maxAttempts`);
|
|
57
|
-
|
|
57
|
+
assertCodeAlphabet(kind.alphabet ?? DIGITS, `kinds.${name}.alphabet`);
|
|
58
58
|
}
|
|
59
59
|
else if (kind.shape === "token") {
|
|
60
60
|
if (kind.alphabet !== undefined || kind.length !== undefined) {
|
|
61
61
|
assertPositiveInteger(kind.length, `kinds.${name}.length`);
|
|
62
|
-
|
|
62
|
+
assertCodeAlphabet(kind.alphabet, `kinds.${name}.alphabet`);
|
|
63
63
|
}
|
|
64
64
|
else {
|
|
65
65
|
assertPositiveInteger(kind.bytes ?? 32, `kinds.${name}.bytes`);
|
|
@@ -100,7 +100,7 @@ export class LambderOneShotSecrets {
|
|
|
100
100
|
* less than that ago is refused instead, with the second it may ask
|
|
101
101
|
* again; of two callers racing past the cooldown, exactly one is issued.
|
|
102
102
|
* `meta` is what the app wants back at redemption: an identity, an
|
|
103
|
-
* issuing
|
|
103
|
+
* issuing store, as small strings.
|
|
104
104
|
*
|
|
105
105
|
* A secret whose digest another scope holds is drawn again, up to
|
|
106
106
|
* MAX_DRAWS times; past that the kind's alphabet and length leave too few
|
|
@@ -111,8 +111,8 @@ export class LambderOneShotSecrets {
|
|
|
111
111
|
const cooldown = options.cooldownSeconds === undefined ? undefined : assertPositiveInteger(options.cooldownSeconds, "cooldownSeconds");
|
|
112
112
|
const nowSeconds = this.nowSeconds();
|
|
113
113
|
for (let draw = 0; draw < MAX_DRAWS; draw += 1) {
|
|
114
|
-
const plaintext = definition.shape === "code" ?
|
|
115
|
-
: definition.alphabet !== undefined ?
|
|
114
|
+
const plaintext = definition.shape === "code" ? randomCode(definition.alphabet ?? DIGITS, definition.length)
|
|
115
|
+
: definition.alphabet !== undefined ? randomCode(definition.alphabet, definition.length)
|
|
116
116
|
: randomSecret(definition.bytes ?? 32);
|
|
117
117
|
const draft = {
|
|
118
118
|
kind,
|
|
@@ -185,33 +185,3 @@ export class LambderOneShotSecrets {
|
|
|
185
185
|
return { state: "accepted", scope: record.scope, meta: { ...record.meta }, issuedAt: record.issuedAt };
|
|
186
186
|
}
|
|
187
187
|
}
|
|
188
|
-
const assertAlphabet = (alphabet, name) => {
|
|
189
|
-
if (alphabet.length < 2 || alphabet.length > 256 || new Set(alphabet).size !== alphabet.length) {
|
|
190
|
-
throw new Error(`Lambder: kinds.${name}.alphabet must be 2 to 256 distinct characters.`);
|
|
191
|
-
}
|
|
192
|
-
};
|
|
193
|
-
/**
|
|
194
|
-
* A code of `length` characters drawn uniformly from `alphabet`. Bytes at or
|
|
195
|
-
* above the largest multiple of the alphabet's size are discarded, so the
|
|
196
|
-
* modulo cannot favour the alphabet's first characters.
|
|
197
|
-
*/
|
|
198
|
-
const drawCode = (alphabet, length) => {
|
|
199
|
-
const webCrypto = globalThis.crypto;
|
|
200
|
-
if (typeof webCrypto?.getRandomValues !== "function") {
|
|
201
|
-
throw new Error("Lambder needs crypto.getRandomValues in this runtime to draw a code. Every browser provides it; Node 20+ provides it as globalThis.crypto.");
|
|
202
|
-
}
|
|
203
|
-
const ceiling = Math.floor(256 / alphabet.length) * alphabet.length;
|
|
204
|
-
const characters = [];
|
|
205
|
-
const buffer = new Uint8Array(length * 2);
|
|
206
|
-
while (characters.length < length) {
|
|
207
|
-
webCrypto.getRandomValues(buffer);
|
|
208
|
-
for (const byte of buffer) {
|
|
209
|
-
if (byte >= ceiling)
|
|
210
|
-
continue;
|
|
211
|
-
characters.push(alphabet[byte % alphabet.length]);
|
|
212
|
-
if (characters.length === length)
|
|
213
|
-
break;
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
return characters.join("");
|
|
217
|
-
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/** The cost of a hash this instance writes. Every field is optional; the defaults are argon2id's widely used 64 MiB, 3 passes, 4 lanes. */
|
|
2
|
+
export type LambderPasswordHasherOptions = {
|
|
3
|
+
/** Memory per hash, in KiB. At least 8 per lane. Default: 65536 (64 MiB). */
|
|
4
|
+
memoryKib?: number;
|
|
5
|
+
/** Passes over that memory. Default: 3. */
|
|
6
|
+
passes?: number;
|
|
7
|
+
/** Lanes computed in parallel. Default: 4. */
|
|
8
|
+
parallelism?: number;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Hashes passwords with argon2id and verifies them against stored PHC
|
|
12
|
+
* strings, its own and those other argon2 libraries wrote (argon2id, argon2i
|
|
13
|
+
* or argon2d, any parameter order).
|
|
14
|
+
*
|
|
15
|
+
* ```ts
|
|
16
|
+
* const passwords = new LambderPasswordHasher();
|
|
17
|
+
* const stored = await passwords.hash(newPassword); // store this
|
|
18
|
+
* if(await passwords.verify(row?.passwordHash, attempt)){ // a missing row too, so it takes as long
|
|
19
|
+
* if(passwords.needsRehash(row!.passwordHash)) await savePasswordHash(await passwords.hash(attempt));
|
|
20
|
+
* }
|
|
21
|
+
* ```
|
|
22
|
+
*
|
|
23
|
+
* The password is hashed as its UTF-8 bytes, as given: an app that wants one
|
|
24
|
+
* password typed on two keyboards to match normalizes it (`normalize("NFC")`)
|
|
25
|
+
* before both calls, the same way every time.
|
|
26
|
+
*/
|
|
27
|
+
export declare class LambderPasswordHasher {
|
|
28
|
+
#private;
|
|
29
|
+
/**
|
|
30
|
+
* Throws where the runtime has no argon2 (Node before 24.7, or a browser),
|
|
31
|
+
* here rather than on the first sign-in: a hasher that cannot hash would
|
|
32
|
+
* otherwise read as a wrong password for every account.
|
|
33
|
+
*/
|
|
34
|
+
constructor(options?: LambderPasswordHasherOptions);
|
|
35
|
+
/** The PHC string of `password` under this instance's cost and a fresh random salt: the value to store. */
|
|
36
|
+
hash(password: string): Promise<string>;
|
|
37
|
+
/**
|
|
38
|
+
* Whether `password` is the one `stored` was made from, under the variant
|
|
39
|
+
* and cost `stored` names. False, never a throw, for anything that is not
|
|
40
|
+
* an argon2 PHC string within the cost ceilings: an account with no
|
|
41
|
+
* password stores null, and a value of another scheme or a corrupt one
|
|
42
|
+
* matches no password. Without a usable hash it still computes one under
|
|
43
|
+
* this instance's cost, so an account that does not exist (pass
|
|
44
|
+
* `undefined`) answers in the time a wrong password does, and the time
|
|
45
|
+
* says nothing about which accounts exist.
|
|
46
|
+
*/
|
|
47
|
+
verify(stored: string | null | undefined, password: string): Promise<boolean>;
|
|
48
|
+
/**
|
|
49
|
+
* Whether `stored` was written under something other than what this
|
|
50
|
+
* instance writes: another variant, another cost, a shorter salt or tag,
|
|
51
|
+
* or no argon2 hash at all. Asked after a successful verify, when the
|
|
52
|
+
* plaintext is at hand to hash again; that is how a raised cost reaches
|
|
53
|
+
* the passwords stored before it.
|
|
54
|
+
*/
|
|
55
|
+
needsRehash(stored: string): boolean;
|
|
56
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { assertPositiveInteger } from "../shared/util/LambderOptionChecks.js";
|
|
2
|
+
/** The only argon2 encoding version: 0x13. A string naming another is not an argon2 hash anything writes today. */
|
|
3
|
+
const ARGON2_VERSION = 19;
|
|
4
|
+
const SALT_BYTES = 16;
|
|
5
|
+
const TAG_BYTES = 32;
|
|
6
|
+
/**
|
|
7
|
+
* The most a cost may ask: 2 GiB of memory (RFC 9106's largest recommended
|
|
8
|
+
* setting), 4 GiB of memory over all passes (libsodium's strongest preset,
|
|
9
|
+
* 1 GiB over 4), and the PHC format's 255 lanes. A verify runs under the cost
|
|
10
|
+
* the stored string names, before anyone is signed in, so a string naming
|
|
11
|
+
* hours of passes or more memory than the function has (an imported hash, a
|
|
12
|
+
* corrupt row) would stall every sign-in attempt on its account. A string
|
|
13
|
+
* over them matches no password, and a hasher is not built with a cost over
|
|
14
|
+
* them, so it never writes one.
|
|
15
|
+
*/
|
|
16
|
+
const MAX_MEMORY_KIB = 2 * 1024 * 1024;
|
|
17
|
+
const MAX_MEMORY_OVER_PASSES_KIB = 4 * 1024 * 1024;
|
|
18
|
+
const MAX_PARALLELISM = 255;
|
|
19
|
+
/** The salt of the hash a verify computes when there is no stored hash to check; its result is thrown away. */
|
|
20
|
+
const NO_HASH_SALT = Buffer.alloc(SALT_BYTES);
|
|
21
|
+
/**
|
|
22
|
+
* The PHC string. The parameter section is read as an unordered set: the PHC
|
|
23
|
+
* format fixes no order, and implementations differ (the `argon2` npm package
|
|
24
|
+
* writes `m,t,p`, others `m,p,t`).
|
|
25
|
+
*/
|
|
26
|
+
const PHC_PATTERN = /^\$(argon2id|argon2i|argon2d)\$v=(\d+)\$([a-z]+=\d+(?:,[a-z]+=\d+)*)\$([A-Za-z0-9+/]+)\$([A-Za-z0-9+/]+)$/;
|
|
27
|
+
const toPhcBase64 = (bytes) => bytes.toString("base64").replace(/=+$/, "");
|
|
28
|
+
/** What is wrong with a cost of positive integers, or null for one inside argon2's floor and the ceilings above. */
|
|
29
|
+
const costProblemOf = ({ memoryKib, passes, parallelism }) => memoryKib < 8 * parallelism ? `memoryKib must be at least 8 per lane (${8 * parallelism} for parallelism ${parallelism}), got ${memoryKib}`
|
|
30
|
+
: parallelism > MAX_PARALLELISM ? `parallelism must be at most ${MAX_PARALLELISM}, got ${parallelism}`
|
|
31
|
+
: memoryKib > MAX_MEMORY_KIB ? `memoryKib must be at most ${MAX_MEMORY_KIB} (2 GiB), got ${memoryKib}`
|
|
32
|
+
: memoryKib * passes > MAX_MEMORY_OVER_PASSES_KIB ? `memoryKib times passes must be at most ${MAX_MEMORY_OVER_PASSES_KIB} (4 GiB over all passes), got ${memoryKib * passes}`
|
|
33
|
+
: null;
|
|
34
|
+
const parseCost = (section) => {
|
|
35
|
+
const values = new Map();
|
|
36
|
+
for (const pair of section.split(",")) {
|
|
37
|
+
const [key, value] = pair.split("=");
|
|
38
|
+
if (!key || !value || values.has(key))
|
|
39
|
+
return null;
|
|
40
|
+
values.set(key, Number(value));
|
|
41
|
+
}
|
|
42
|
+
const memoryKib = values.get("m"), passes = values.get("t"), parallelism = values.get("p");
|
|
43
|
+
if (memoryKib === undefined || passes === undefined || parallelism === undefined || values.size !== 3)
|
|
44
|
+
return null;
|
|
45
|
+
if (![memoryKib, passes, parallelism].every((value) => Number.isSafeInteger(value) && value >= 1))
|
|
46
|
+
return null;
|
|
47
|
+
const cost = { memoryKib, passes, parallelism };
|
|
48
|
+
return costProblemOf(cost) === null ? cost : null;
|
|
49
|
+
};
|
|
50
|
+
const parsePasswordHash = (stored) => {
|
|
51
|
+
const match = PHC_PATTERN.exec(stored);
|
|
52
|
+
if (!match)
|
|
53
|
+
return null;
|
|
54
|
+
const [, variant, version, costSection, saltBase64, tagBase64] = match;
|
|
55
|
+
if (Number(version) !== ARGON2_VERSION)
|
|
56
|
+
return null;
|
|
57
|
+
const cost = parseCost(costSection);
|
|
58
|
+
if (!cost)
|
|
59
|
+
return null;
|
|
60
|
+
const salt = Buffer.from(saltBase64, "base64");
|
|
61
|
+
const tag = Buffer.from(tagBase64, "base64");
|
|
62
|
+
// argon2's own floors: an 8-byte salt and a 4-byte tag.
|
|
63
|
+
if (salt.length < 8 || tag.length < 4)
|
|
64
|
+
return null;
|
|
65
|
+
return { variant: variant, ...cost, salt, tag };
|
|
66
|
+
};
|
|
67
|
+
/**
|
|
68
|
+
* Hashes passwords with argon2id and verifies them against stored PHC
|
|
69
|
+
* strings, its own and those other argon2 libraries wrote (argon2id, argon2i
|
|
70
|
+
* or argon2d, any parameter order).
|
|
71
|
+
*
|
|
72
|
+
* ```ts
|
|
73
|
+
* const passwords = new LambderPasswordHasher();
|
|
74
|
+
* const stored = await passwords.hash(newPassword); // store this
|
|
75
|
+
* if(await passwords.verify(row?.passwordHash, attempt)){ // a missing row too, so it takes as long
|
|
76
|
+
* if(passwords.needsRehash(row!.passwordHash)) await savePasswordHash(await passwords.hash(attempt));
|
|
77
|
+
* }
|
|
78
|
+
* ```
|
|
79
|
+
*
|
|
80
|
+
* The password is hashed as its UTF-8 bytes, as given: an app that wants one
|
|
81
|
+
* password typed on two keyboards to match normalizes it (`normalize("NFC")`)
|
|
82
|
+
* before both calls, the same way every time.
|
|
83
|
+
*/
|
|
84
|
+
export class LambderPasswordHasher {
|
|
85
|
+
#crypto;
|
|
86
|
+
#cost;
|
|
87
|
+
/**
|
|
88
|
+
* Throws where the runtime has no argon2 (Node before 24.7, or a browser),
|
|
89
|
+
* here rather than on the first sign-in: a hasher that cannot hash would
|
|
90
|
+
* otherwise read as a wrong password for every account.
|
|
91
|
+
*/
|
|
92
|
+
constructor(options = {}) {
|
|
93
|
+
const nodeCrypto = typeof process === "object" && typeof process.getBuiltinModule === "function"
|
|
94
|
+
? process.getBuiltinModule("node:crypto")
|
|
95
|
+
: undefined;
|
|
96
|
+
if (typeof nodeCrypto?.argon2 !== "function") {
|
|
97
|
+
throw new Error("Lambder: LambderPasswordHasher needs argon2 from node:crypto, which Node 24.7 and later provide; this runtime has none.");
|
|
98
|
+
}
|
|
99
|
+
this.#crypto = nodeCrypto;
|
|
100
|
+
const memoryKib = assertPositiveInteger(options.memoryKib ?? 65536, "memoryKib");
|
|
101
|
+
const passes = assertPositiveInteger(options.passes ?? 3, "passes");
|
|
102
|
+
const parallelism = assertPositiveInteger(options.parallelism ?? 4, "parallelism");
|
|
103
|
+
const problem = costProblemOf({ memoryKib, passes, parallelism });
|
|
104
|
+
if (problem)
|
|
105
|
+
throw new Error(`Lambder: LambderPasswordHasher ${problem}.`);
|
|
106
|
+
this.#cost = { memoryKib, passes, parallelism };
|
|
107
|
+
}
|
|
108
|
+
/** The PHC string of `password` under this instance's cost and a fresh random salt: the value to store. */
|
|
109
|
+
async hash(password) {
|
|
110
|
+
if (typeof password !== "string")
|
|
111
|
+
throw new TypeError("Lambder: LambderPasswordHasher.hash takes the password as a string.");
|
|
112
|
+
const { memoryKib, passes, parallelism } = this.#cost;
|
|
113
|
+
const salt = this.#crypto.randomBytes(SALT_BYTES);
|
|
114
|
+
const tag = await this.#derive("argon2id", password, salt, this.#cost, TAG_BYTES);
|
|
115
|
+
return `$argon2id$v=${ARGON2_VERSION}$m=${memoryKib},t=${passes},p=${parallelism}$${toPhcBase64(salt)}$${toPhcBase64(tag)}`;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Whether `password` is the one `stored` was made from, under the variant
|
|
119
|
+
* and cost `stored` names. False, never a throw, for anything that is not
|
|
120
|
+
* an argon2 PHC string within the cost ceilings: an account with no
|
|
121
|
+
* password stores null, and a value of another scheme or a corrupt one
|
|
122
|
+
* matches no password. Without a usable hash it still computes one under
|
|
123
|
+
* this instance's cost, so an account that does not exist (pass
|
|
124
|
+
* `undefined`) answers in the time a wrong password does, and the time
|
|
125
|
+
* says nothing about which accounts exist.
|
|
126
|
+
*/
|
|
127
|
+
async verify(stored, password) {
|
|
128
|
+
if (typeof password !== "string")
|
|
129
|
+
return false;
|
|
130
|
+
const parsed = typeof stored === "string" ? parsePasswordHash(stored) : null;
|
|
131
|
+
if (!parsed) {
|
|
132
|
+
await this.#derive("argon2id", password, NO_HASH_SALT, this.#cost, TAG_BYTES);
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
const actual = await this.#derive(parsed.variant, password, parsed.salt, parsed, parsed.tag.length);
|
|
136
|
+
return actual.length === parsed.tag.length && this.#crypto.timingSafeEqual(actual, parsed.tag);
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Whether `stored` was written under something other than what this
|
|
140
|
+
* instance writes: another variant, another cost, a shorter salt or tag,
|
|
141
|
+
* or no argon2 hash at all. Asked after a successful verify, when the
|
|
142
|
+
* plaintext is at hand to hash again; that is how a raised cost reaches
|
|
143
|
+
* the passwords stored before it.
|
|
144
|
+
*/
|
|
145
|
+
needsRehash(stored) {
|
|
146
|
+
const parsed = typeof stored === "string" ? parsePasswordHash(stored) : null;
|
|
147
|
+
if (!parsed)
|
|
148
|
+
return true;
|
|
149
|
+
return parsed.variant !== "argon2id"
|
|
150
|
+
|| parsed.memoryKib !== this.#cost.memoryKib
|
|
151
|
+
|| parsed.passes !== this.#cost.passes
|
|
152
|
+
|| parsed.parallelism !== this.#cost.parallelism
|
|
153
|
+
|| parsed.salt.length < SALT_BYTES
|
|
154
|
+
|| parsed.tag.length !== TAG_BYTES;
|
|
155
|
+
}
|
|
156
|
+
#derive(variant, password, salt, cost, tagBytes) {
|
|
157
|
+
return new Promise((resolve, reject) => {
|
|
158
|
+
this.#crypto.argon2(variant, {
|
|
159
|
+
message: Buffer.from(password, "utf8"),
|
|
160
|
+
nonce: salt,
|
|
161
|
+
memory: cost.memoryKib,
|
|
162
|
+
passes: cost.passes,
|
|
163
|
+
parallelism: cost.parallelism,
|
|
164
|
+
tagLength: tagBytes,
|
|
165
|
+
}, (error, derived) => error ? reject(error) : resolve(Buffer.from(derived)));
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
}
|
|
@@ -194,7 +194,22 @@ export default class LambderSessionController<TSessionData = any> {
|
|
|
194
194
|
* invoke caller) needs the new one to keep calling.
|
|
195
195
|
*/
|
|
196
196
|
reissueSession(): Promise<LambderCreatedSession<TSessionData>>;
|
|
197
|
-
|
|
197
|
+
/**
|
|
198
|
+
* The session the request's cookies name, checked against the posted
|
|
199
|
+
* CSRF token and renewed (its dataRefresh once due, its sliding write),
|
|
200
|
+
* onto ctx.session. Throws LambderSessionNotFoundError when there is
|
|
201
|
+
* none.
|
|
202
|
+
*
|
|
203
|
+
* `refreshData: true` renews the data on this read whatever its
|
|
204
|
+
* deadline, for a read that wants it fresh (a page load): the dataRefresh
|
|
205
|
+
* callback runs once and its result shares the read's one write. A read
|
|
206
|
+
* followed by refreshSessionData() runs both twice whenever the data was
|
|
207
|
+
* due. Requires dataRefresh, and throws without it once there is a
|
|
208
|
+
* session to renew.
|
|
209
|
+
*/
|
|
210
|
+
fetchSession(options?: {
|
|
211
|
+
refreshData?: boolean;
|
|
212
|
+
}): Promise<LambderSessionRecord<TSessionData>>;
|
|
198
213
|
/**
|
|
199
214
|
* Re-issues both cookies at this session's expiry: after a sliding write
|
|
200
215
|
* moved it, and beside the host-only eviction, which would otherwise
|
|
@@ -222,7 +237,10 @@ export default class LambderSessionController<TSessionData = any> {
|
|
|
222
237
|
* sent. A dead cookie costs a store read per request until it expires.
|
|
223
238
|
*/
|
|
224
239
|
private endWithNoSession;
|
|
225
|
-
|
|
240
|
+
/** fetchSession, answering null where it throws LambderSessionNotFoundError; it takes the same `refreshData`. */
|
|
241
|
+
fetchSessionIfExists(options?: {
|
|
242
|
+
refreshData?: boolean;
|
|
243
|
+
}): Promise<LambderSessionRecord<TSessionData> | null>;
|
|
226
244
|
/**
|
|
227
245
|
* Writes new data onto the current session. Throws
|
|
228
246
|
* LambderSessionNotFoundError when the session was ended while this
|
|
@@ -244,8 +262,14 @@ export default class LambderSessionController<TSessionData = any> {
|
|
|
244
262
|
* Deletes every session of the given sessionKey (e.g. a user id): "log
|
|
245
263
|
* this subject out everywhere". Unlike endSessionAll it needs no fetched
|
|
246
264
|
* session and touches no cookies, so it works on any subject.
|
|
265
|
+
*
|
|
266
|
+
* Answers whether it is sure none is left. False means a client
|
|
267
|
+
* rotating its session in a tight loop raced every pass of the delete,
|
|
268
|
+
* so a session of the subject may still stand (it is logged too); running
|
|
269
|
+
* it again is the remedy, and a caller ending sessions because a
|
|
270
|
+
* credential changed must not report success on false.
|
|
247
271
|
*/
|
|
248
|
-
deleteSessionAllByKey(sessionKey: string): Promise<
|
|
272
|
+
deleteSessionAllByKey(sessionKey: string): Promise<boolean>;
|
|
249
273
|
/**
|
|
250
274
|
* Marks the data of every session of the given sessionKey stale, so each
|
|
251
275
|
* renews via dataRefresh on its next read: the way to apply a change to
|
|
@@ -254,5 +278,14 @@ export default class LambderSessionController<TSessionData = any> {
|
|
|
254
278
|
*/
|
|
255
279
|
expireSessionDataAllByKey(sessionKey: string): Promise<void>;
|
|
256
280
|
endSession(): Promise<void>;
|
|
257
|
-
|
|
281
|
+
/**
|
|
282
|
+
* Ends every session of this session's subject, this one included, and
|
|
283
|
+
* clears this request's cookies: "log out everywhere".
|
|
284
|
+
*
|
|
285
|
+
* Answers false on deleteSessionAllByKey's terms: a session of the
|
|
286
|
+
* subject may still stand, and running the delete again is the remedy.
|
|
287
|
+
* This request's own session is over either way, so the second run is
|
|
288
|
+
* deleteSessionAllByKey with the sessionKey read before this call.
|
|
289
|
+
*/
|
|
290
|
+
endSessionAll(): Promise<boolean>;
|
|
258
291
|
}
|
|
@@ -286,7 +286,20 @@ export default class LambderSessionController {
|
|
|
286
286
|
return created;
|
|
287
287
|
}
|
|
288
288
|
;
|
|
289
|
-
|
|
289
|
+
/**
|
|
290
|
+
* The session the request's cookies name, checked against the posted
|
|
291
|
+
* CSRF token and renewed (its dataRefresh once due, its sliding write),
|
|
292
|
+
* onto ctx.session. Throws LambderSessionNotFoundError when there is
|
|
293
|
+
* none.
|
|
294
|
+
*
|
|
295
|
+
* `refreshData: true` renews the data on this read whatever its
|
|
296
|
+
* deadline, for a read that wants it fresh (a page load): the dataRefresh
|
|
297
|
+
* callback runs once and its result shares the read's one write. A read
|
|
298
|
+
* followed by refreshSessionData() runs both twice whenever the data was
|
|
299
|
+
* due. Requires dataRefresh, and throws without it once there is a
|
|
300
|
+
* session to renew.
|
|
301
|
+
*/
|
|
302
|
+
async fetchSession(options = {}) {
|
|
290
303
|
const { sessionTokens: candidates, csrfTokens } = this.scanSessionCookies();
|
|
291
304
|
if (!this.areRequestSessionTokensValid(candidates)) {
|
|
292
305
|
throw new LambderSessionNotFoundError("Session tokens are invalid");
|
|
@@ -361,7 +374,7 @@ export default class LambderSessionController {
|
|
|
361
374
|
// Renewed only once this session is known to be the caller's: a
|
|
362
375
|
// slide or a dataRefresh is a write on their behalf.
|
|
363
376
|
const expiresBefore = found.session.expiresAt;
|
|
364
|
-
const session = await this.manager.renewSession(found.session);
|
|
377
|
+
const session = await this.manager.renewSession(found.session, { refreshData: options.refreshData });
|
|
365
378
|
// Ended while this request read it (a logout, a password change), or
|
|
366
379
|
// by its own dataRefresh: no session either way.
|
|
367
380
|
if (!session)
|
|
@@ -431,9 +444,10 @@ export default class LambderSessionController {
|
|
|
431
444
|
throw new LambderSessionNotFoundError();
|
|
432
445
|
}
|
|
433
446
|
;
|
|
434
|
-
|
|
447
|
+
/** fetchSession, answering null where it throws LambderSessionNotFoundError; it takes the same `refreshData`. */
|
|
448
|
+
async fetchSessionIfExists(options = {}) {
|
|
435
449
|
try {
|
|
436
|
-
return await this.fetchSession();
|
|
450
|
+
return await this.fetchSession(options);
|
|
437
451
|
}
|
|
438
452
|
catch (err) {
|
|
439
453
|
// Only a "no session" exit becomes null, the ambiguous one
|
|
@@ -486,9 +500,15 @@ export default class LambderSessionController {
|
|
|
486
500
|
* Deletes every session of the given sessionKey (e.g. a user id): "log
|
|
487
501
|
* this subject out everywhere". Unlike endSessionAll it needs no fetched
|
|
488
502
|
* session and touches no cookies, so it works on any subject.
|
|
503
|
+
*
|
|
504
|
+
* Answers whether it is sure none is left. False means a client
|
|
505
|
+
* rotating its session in a tight loop raced every pass of the delete,
|
|
506
|
+
* so a session of the subject may still stand (it is logged too); running
|
|
507
|
+
* it again is the remedy, and a caller ending sessions because a
|
|
508
|
+
* credential changed must not report success on false.
|
|
489
509
|
*/
|
|
490
510
|
async deleteSessionAllByKey(sessionKey) {
|
|
491
|
-
await this.manager.deleteSessionAllByKey(sessionKey);
|
|
511
|
+
return await this.manager.deleteSessionAllByKey(sessionKey);
|
|
492
512
|
}
|
|
493
513
|
;
|
|
494
514
|
/**
|
|
@@ -509,12 +529,22 @@ export default class LambderSessionController {
|
|
|
509
529
|
this.ctx.session = null;
|
|
510
530
|
}
|
|
511
531
|
;
|
|
532
|
+
/**
|
|
533
|
+
* Ends every session of this session's subject, this one included, and
|
|
534
|
+
* clears this request's cookies: "log out everywhere".
|
|
535
|
+
*
|
|
536
|
+
* Answers false on deleteSessionAllByKey's terms: a session of the
|
|
537
|
+
* subject may still stand, and running the delete again is the remedy.
|
|
538
|
+
* This request's own session is over either way, so the second run is
|
|
539
|
+
* deleteSessionAllByKey with the sessionKey read before this call.
|
|
540
|
+
*/
|
|
512
541
|
async endSessionAll() {
|
|
513
542
|
if (!this.ctx.session)
|
|
514
543
|
throw new LambderSessionNotFoundError();
|
|
515
|
-
await this.manager.deleteSessionAll(this.ctx.session);
|
|
544
|
+
const deletedAll = await this.manager.deleteSessionAll(this.ctx.session);
|
|
516
545
|
this.clearSessionCookies();
|
|
517
546
|
this.ctx.session = null;
|
|
547
|
+
return deletedAll;
|
|
518
548
|
}
|
|
519
549
|
;
|
|
520
550
|
}
|