nestjs-ddd-cli 3.2.1 → 3.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +118 -9
- package/ddd.schema.json +11 -1
- package/dist/commands/doctor-enhanced.d.ts +1 -0
- package/dist/commands/doctor-enhanced.js +94 -79
- package/dist/commands/doctor-enhanced.js.map +1 -1
- package/dist/commands/doctor.d.ts +9 -0
- package/dist/commands/doctor.js +68 -21
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/generate-all.js +238 -176
- package/dist/commands/generate-all.js.map +1 -1
- package/dist/commands/generate-controller.d.ts +1 -0
- package/dist/commands/generate-controller.js +71 -0
- package/dist/commands/generate-controller.js.map +1 -0
- package/dist/commands/generate-domain-service.d.ts +1 -1
- package/dist/commands/generate-domain-service.js +6 -4
- package/dist/commands/generate-domain-service.js.map +1 -1
- package/dist/commands/generate-dto.d.ts +4 -0
- package/dist/commands/generate-dto.js +162 -0
- package/dist/commands/generate-dto.js.map +1 -0
- package/dist/commands/generate-entity.js +44 -35
- package/dist/commands/generate-entity.js.map +1 -1
- package/dist/commands/generate-event.js +10 -4
- package/dist/commands/generate-event.js.map +1 -1
- package/dist/commands/generate-migration-deployment.d.ts +15 -0
- package/dist/commands/generate-migration-deployment.js +326 -0
- package/dist/commands/generate-migration-deployment.js.map +1 -0
- package/dist/commands/generate-migration-deployment.spec.d.ts +1 -0
- package/dist/commands/generate-migration-deployment.spec.js +337 -0
- package/dist/commands/generate-migration-deployment.spec.js.map +1 -0
- package/dist/commands/generate-module.js +41 -31
- package/dist/commands/generate-module.js.map +1 -1
- package/dist/commands/generate-query.js +23 -4
- package/dist/commands/generate-query.js.map +1 -1
- package/dist/commands/generate-repository.d.ts +1 -1
- package/dist/commands/generate-repository.js +17 -34
- package/dist/commands/generate-repository.js.map +1 -1
- package/dist/commands/generate-service.js +19 -4
- package/dist/commands/generate-service.js.map +1 -1
- package/dist/commands/generate-usecase.js +12 -31
- package/dist/commands/generate-usecase.js.map +1 -1
- package/dist/commands/generate-value-object.js +3 -14
- package/dist/commands/generate-value-object.js.map +1 -1
- package/dist/commands/init-project.d.ts +3 -0
- package/dist/commands/init-project.js +19 -9
- package/dist/commands/init-project.js.map +1 -1
- package/dist/commands/interactive-scaffold.js +10 -0
- package/dist/commands/interactive-scaffold.js.map +1 -1
- package/dist/commands/migration.d.ts +3 -0
- package/dist/commands/migration.js +83 -48
- package/dist/commands/migration.js.map +1 -1
- package/dist/commands/recipe.d.ts +2 -0
- package/dist/commands/recipe.js +282 -91
- package/dist/commands/recipe.js.map +1 -1
- package/dist/commands/recipes/banklink-connector-contract.recipe.d.ts +1 -0
- package/dist/commands/recipes/banklink-connector-contract.recipe.js +161 -0
- package/dist/commands/recipes/banklink-connector-contract.recipe.js.map +1 -0
- package/dist/commands/recipes/bounded-json-http.recipe.d.ts +2 -0
- package/dist/commands/recipes/bounded-json-http.recipe.js +53 -0
- package/dist/commands/recipes/bounded-json-http.recipe.js.map +1 -0
- package/dist/commands/recipes/business-reference-identifiers.recipe.d.ts +1 -0
- package/dist/commands/recipes/business-reference-identifiers.recipe.js +223 -0
- package/dist/commands/recipes/business-reference-identifiers.recipe.js.map +1 -0
- package/dist/commands/recipes/event-backbone.recipe.d.ts +1 -0
- package/dist/commands/recipes/event-backbone.recipe.js +1118 -0
- package/dist/commands/recipes/event-backbone.recipe.js.map +1 -0
- package/dist/commands/recipes/external-projection-worker.recipe.d.ts +6 -0
- package/dist/commands/recipes/external-projection-worker.recipe.js +211 -0
- package/dist/commands/recipes/external-projection-worker.recipe.js.map +1 -0
- package/dist/commands/recipes/index.d.ts +12 -0
- package/dist/commands/recipes/index.js +12 -0
- package/dist/commands/recipes/index.js.map +1 -1
- package/dist/commands/recipes/jest-cjs-compat.recipe.d.ts +3 -0
- package/dist/commands/recipes/jest-cjs-compat.recipe.js +130 -0
- package/dist/commands/recipes/jest-cjs-compat.recipe.js.map +1 -0
- package/dist/commands/recipes/joi-env.recipe.d.ts +1 -0
- package/dist/commands/recipes/joi-env.recipe.js +301 -0
- package/dist/commands/recipes/joi-env.recipe.js.map +1 -0
- package/dist/commands/recipes/kafka-consumer.recipe.d.ts +1 -0
- package/dist/commands/recipes/kafka-consumer.recipe.js +65 -0
- package/dist/commands/recipes/kafka-consumer.recipe.js.map +1 -0
- package/dist/commands/recipes/oauth2-client-credentials.recipe.d.ts +1 -0
- package/dist/commands/recipes/oauth2-client-credentials.recipe.js +60 -0
- package/dist/commands/recipes/oauth2-client-credentials.recipe.js.map +1 -0
- package/dist/commands/recipes/oauth2-token-introspection.recipe.d.ts +1 -0
- package/dist/commands/recipes/oauth2-token-introspection.recipe.js +67 -0
- package/dist/commands/recipes/oauth2-token-introspection.recipe.js.map +1 -0
- package/dist/commands/recipes/oidc-bff-session.recipe.d.ts +1 -0
- package/dist/commands/recipes/oidc-bff-session.recipe.js +57 -0
- package/dist/commands/recipes/oidc-bff-session.recipe.js.map +1 -0
- package/dist/commands/recipes/oidc-dashboard.recipe.d.ts +1 -0
- package/dist/commands/recipes/oidc-dashboard.recipe.js +463 -0
- package/dist/commands/recipes/oidc-dashboard.recipe.js.map +1 -0
- package/dist/commands/recipes/oidc-resource-server.recipe.d.ts +1 -0
- package/dist/commands/recipes/oidc-resource-server.recipe.js +85 -0
- package/dist/commands/recipes/oidc-resource-server.recipe.js.map +1 -0
- package/dist/commands/recipes/platform-context.recipe.d.ts +1 -0
- package/dist/commands/recipes/platform-context.recipe.js +229 -0
- package/dist/commands/recipes/platform-context.recipe.js.map +1 -0
- package/dist/commands/recipes/platform-parc-authorization.recipe.d.ts +1 -0
- package/dist/commands/recipes/platform-parc-authorization.recipe.js +339 -0
- package/dist/commands/recipes/platform-parc-authorization.recipe.js.map +1 -0
- package/dist/commands/recipes/platform-parc-bound-authorization.recipe.d.ts +1 -0
- package/dist/commands/recipes/platform-parc-bound-authorization.recipe.js +56 -0
- package/dist/commands/recipes/platform-parc-bound-authorization.recipe.js.map +1 -0
- package/dist/commands/recipes/platform-service-access-request-context.recipe.d.ts +1 -0
- package/dist/commands/recipes/platform-service-access-request-context.recipe.js +103 -0
- package/dist/commands/recipes/platform-service-access-request-context.recipe.js.map +1 -0
- package/dist/commands/recipes/platform-service-runtime.recipe.d.ts +1 -0
- package/dist/commands/recipes/platform-service-runtime.recipe.js +415 -0
- package/dist/commands/recipes/platform-service-runtime.recipe.js.map +1 -0
- package/dist/index.js +82 -11
- package/dist/index.js.map +1 -1
- package/dist/templates/ai-context/CLAUDE.md.hbs +54 -3
- package/dist/templates/ai-context/conventions.md.hbs +20 -8
- package/dist/templates/command/create-command.hbs +8 -3
- package/dist/templates/command/delete-command.hbs +1 -1
- package/dist/templates/command/update-command.hbs +1 -1
- package/dist/templates/controller/application-controller.hbs +7 -0
- package/dist/templates/controller/controller.hbs +6 -0
- package/dist/templates/dto/create-dto.hbs +17 -5
- package/dist/templates/dto/filter-dto.hbs +1 -1
- package/dist/templates/dto/response-dto.hbs +1 -1
- package/dist/templates/entity/entity.hbs +4 -0
- package/dist/templates/mapper/mapper.hbs +6 -2
- package/dist/templates/orm-entity/orm-entity.hbs +9 -1
- package/dist/templates/prisma/prisma-mapper.hbs +6 -0
- package/dist/templates/prisma/prisma-repository.hbs +57 -5
- package/dist/templates/prisma/prisma-schema.hbs +2 -0
- package/dist/templates/query/get-all.query.hbs +10 -2
- package/dist/templates/recipes/bounded-json-http/README.md.hbs +36 -0
- package/dist/templates/recipes/external-projection-worker/README.md.hbs +157 -0
- package/dist/templates/recipes/external-projection-worker/canonical-json.ts.hbs +123 -0
- package/dist/templates/recipes/external-projection-worker/enqueuer.ts.hbs +167 -0
- package/dist/templates/recipes/external-projection-worker/index.ts.hbs +6 -0
- package/dist/templates/recipes/external-projection-worker/migration.ts.hbs +88 -0
- package/dist/templates/recipes/external-projection-worker/module.ts.hbs +36 -0
- package/dist/templates/recipes/external-projection-worker/store.ts.hbs +354 -0
- package/dist/templates/recipes/external-projection-worker/types.ts.hbs +134 -0
- package/dist/templates/recipes/external-projection-worker/worker.ts.hbs +296 -0
- package/dist/templates/recipes/kafka-consumer/README.md.hbs +50 -0
- package/dist/templates/recipes/kafka-consumer/kafka-consumer.ts.hbs +135 -0
- package/dist/templates/recipes/kafka-consumer/migration.ts.hbs +17 -0
- package/dist/templates/recipes/oauth2-client-credentials/README.md.hbs +40 -0
- package/dist/templates/recipes/oauth2-client-credentials/client.spec.ts.hbs +234 -0
- package/dist/templates/recipes/oauth2-client-credentials/client.ts.hbs +210 -0
- package/dist/templates/recipes/oauth2-client-credentials/index.ts.hbs +2 -0
- package/dist/templates/recipes/oauth2-client-credentials/module.ts.hbs +58 -0
- package/dist/templates/recipes/oauth2-token-introspection/README.md.hbs +158 -0
- package/dist/templates/recipes/oauth2-token-introspection/client.spec.ts.hbs +350 -0
- package/dist/templates/recipes/oauth2-token-introspection/client.ts.hbs +241 -0
- package/dist/templates/recipes/oauth2-token-introspection/http-client.spec.ts.hbs +411 -0
- package/dist/templates/recipes/oauth2-token-introspection/http-client.ts.hbs +343 -0
- package/dist/templates/recipes/oauth2-token-introspection/index.ts.hbs +3 -0
- package/dist/templates/recipes/oauth2-token-introspection/module.ts.hbs +58 -0
- package/dist/templates/recipes/oidc-bff-session/README.md.hbs +96 -0
- package/dist/templates/recipes/oidc-bff-session/client.test.ts.hbs +663 -0
- package/dist/templates/recipes/oidc-bff-session/client.ts.hbs +635 -0
- package/dist/templates/recipes/oidc-bff-session/index.ts.hbs +3 -0
- package/dist/templates/recipes/oidc-bff-session/store.integration.test.ts.hbs +480 -0
- package/dist/templates/recipes/oidc-bff-session/store.test.ts.hbs +87 -0
- package/dist/templates/recipes/oidc-bff-session/store.ts.hbs +549 -0
- package/dist/templates/recipes/oidc-bff-session/valkey.test.ts.hbs +158 -0
- package/dist/templates/recipes/oidc-bff-session/valkey.ts.hbs +148 -0
- package/dist/templates/recipes/oidc-resource-server/README.md.hbs +74 -0
- package/dist/templates/recipes/oidc-resource-server/index.ts.hbs +2 -0
- package/dist/templates/recipes/oidc-resource-server/module.ts.hbs +51 -0
- package/dist/templates/recipes/oidc-resource-server/verifier.spec.ts.hbs +186 -0
- package/dist/templates/recipes/oidc-resource-server/verifier.ts.hbs +440 -0
- package/dist/templates/recipes/platform-parc-bound-authorization/README.md.hbs +92 -0
- package/dist/templates/recipes/platform-parc-bound-authorization/client.ts.hbs +142 -0
- package/dist/templates/recipes/platform-service-access-request-context/docs.md.hbs +61 -0
- package/dist/templates/recipes/platform-service-access-request-context/env.example.hbs +7 -0
- package/dist/templates/recipes/platform-service-access-request-context/index.ts.hbs +7 -0
- package/dist/templates/recipes/platform-service-access-request-context/request-context-headers.ts.hbs +61 -0
- package/dist/templates/recipes/platform-service-access-request-context/request-context-introspection.parser.ts.hbs +132 -0
- package/dist/templates/recipes/platform-service-access-request-context/request-context-introspection.types.ts.hbs +40 -0
- package/dist/templates/recipes/platform-service-access-request-context/service-access-request-context.client.ts.hbs +45 -0
- package/dist/templates/recipes/platform-service-access-request-context/service-access-request-context.config.ts.hbs +142 -0
- package/dist/templates/recipes/platform-service-access-request-context/service-access-request-context.guard.ts.hbs +77 -0
- package/dist/templates/recipes/platform-service-access-request-context/service-access-request-context.module.ts.hbs +17 -0
- package/dist/templates/repository/repository.hbs +43 -5
- package/dist/templates/resolver/graphql-input.hbs +23 -6
- package/dist/templates/resolver/resolver.hbs +4 -0
- package/dist/templates/service/domain-service.hbs +2 -2
- package/dist/templates/test/controller.spec.hbs +5 -1
- package/dist/templates/test/prisma-repository.spec.hbs +192 -0
- package/dist/templates/test/repository.spec.hbs +41 -2
- package/dist/templates/test/usecase.spec.hbs +24 -5
- package/dist/templates/usecase/application-usecase.hbs +17 -0
- package/dist/templates/usecase/create-usecase.hbs +34 -3
- package/dist/templates/usecase/update-usecase.hbs +15 -1
- package/dist/utils/config.utils.d.ts +2 -0
- package/dist/utils/config.utils.js +14 -2
- package/dist/utils/config.utils.js.map +1 -1
- package/dist/utils/dependency.utils.d.ts +18 -2
- package/dist/utils/dependency.utils.js +144 -15
- package/dist/utils/dependency.utils.js.map +1 -1
- package/dist/utils/field.utils.d.ts +6 -0
- package/dist/utils/field.utils.js +78 -24
- package/dist/utils/field.utils.js.map +1 -1
- package/dist/utils/field.utils.spec.d.ts +1 -0
- package/dist/utils/field.utils.spec.js +38 -0
- package/dist/utils/field.utils.spec.js.map +1 -0
- package/dist/utils/file.utils.d.ts +39 -3
- package/dist/utils/file.utils.js +150 -13
- package/dist/utils/file.utils.js.map +1 -1
- package/dist/utils/naming.utils.js +5 -4
- package/dist/utils/naming.utils.js.map +1 -1
- package/dist/utils/static-recipe.utils.d.ts +4 -0
- package/dist/utils/static-recipe.utils.js +148 -0
- package/dist/utils/static-recipe.utils.js.map +1 -0
- package/dist/utils/template-engine.utils.js +83 -68
- package/dist/utils/template-engine.utils.js.map +1 -1
- package/package.json +20 -11
|
@@ -0,0 +1,549 @@
|
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
const HANDLE = /^[A-Za-z0-9_-]{43}$/;
|
|
4
|
+
const SESSION_HANDLE = /^v3\.([A-Za-z0-9_-]{43})$/;
|
|
5
|
+
export const SESSION_SECONDS = 3300;
|
|
6
|
+
export const LOGIN_SECONDS = 600;
|
|
7
|
+
const GENERATION_SECONDS = 31_536_000;
|
|
8
|
+
// Conservative 25-hour retirement default; validate against the provider refresh cutoff.
|
|
9
|
+
export const TOKEN_RETENTION_SECONDS = 90_000;
|
|
10
|
+
export type OpaqueSessionSettings = Readonly<{
|
|
11
|
+
sessionSeconds: number;
|
|
12
|
+
loginSeconds: number;
|
|
13
|
+
generationSeconds: number;
|
|
14
|
+
tokenRetentionSeconds: number;
|
|
15
|
+
maxProviderRefreshSeconds: number;
|
|
16
|
+
maxProviderAccessSeconds: number;
|
|
17
|
+
leaseSeconds: number;
|
|
18
|
+
retrySeconds: number;
|
|
19
|
+
}>;
|
|
20
|
+
function sessionSettings(input: Partial<OpaqueSessionSettings>): OpaqueSessionSettings {
|
|
21
|
+
const settings = Object.freeze({
|
|
22
|
+
sessionSeconds: SESSION_SECONDS,
|
|
23
|
+
loginSeconds: LOGIN_SECONDS,
|
|
24
|
+
generationSeconds: GENERATION_SECONDS,
|
|
25
|
+
tokenRetentionSeconds: TOKEN_RETENTION_SECONDS,
|
|
26
|
+
maxProviderRefreshSeconds: 86400,
|
|
27
|
+
maxProviderAccessSeconds: 3600,
|
|
28
|
+
leaseSeconds: 30,
|
|
29
|
+
retrySeconds: 60,
|
|
30
|
+
...input,
|
|
31
|
+
});
|
|
32
|
+
const maximum: OpaqueSessionSettings = {
|
|
33
|
+
sessionSeconds: 86400,
|
|
34
|
+
loginSeconds: 900,
|
|
35
|
+
generationSeconds: 31536000,
|
|
36
|
+
tokenRetentionSeconds: 7776000,
|
|
37
|
+
maxProviderRefreshSeconds: 7775999,
|
|
38
|
+
maxProviderAccessSeconds: 86400,
|
|
39
|
+
leaseSeconds: 300,
|
|
40
|
+
retrySeconds: 3600,
|
|
41
|
+
};
|
|
42
|
+
if (
|
|
43
|
+
Object.entries(settings).some(
|
|
44
|
+
([name, value]) =>
|
|
45
|
+
!Number.isSafeInteger(value) ||
|
|
46
|
+
value < 1 ||
|
|
47
|
+
value > maximum[name as keyof OpaqueSessionSettings],
|
|
48
|
+
) ||
|
|
49
|
+
settings.tokenRetentionSeconds <= settings.maxProviderRefreshSeconds ||
|
|
50
|
+
settings.tokenRetentionSeconds <= settings.maxProviderAccessSeconds ||
|
|
51
|
+
settings.sessionSeconds > settings.maxProviderAccessSeconds ||
|
|
52
|
+
settings.leaseSeconds >= settings.tokenRetentionSeconds
|
|
53
|
+
) {
|
|
54
|
+
throw new Error('Invalid BFF session duration configuration');
|
|
55
|
+
}
|
|
56
|
+
return settings;
|
|
57
|
+
}
|
|
58
|
+
export type RetirementTokens = { accessToken?: string; refreshToken?: string; expiresAt: number };
|
|
59
|
+
export type RevocationClaim = { ticket: string; lease: string; tokens: RetirementTokens };
|
|
60
|
+
|
|
61
|
+
export type TokenPair = {
|
|
62
|
+
accessToken: string;
|
|
63
|
+
refreshToken?: string;
|
|
64
|
+
expiresAt: number;
|
|
65
|
+
};
|
|
66
|
+
export type PendingLogin = {
|
|
67
|
+
browser: string;
|
|
68
|
+
generation: string;
|
|
69
|
+
state: string;
|
|
70
|
+
verifier: string;
|
|
71
|
+
nonce: string;
|
|
72
|
+
next: string;
|
|
73
|
+
startedAt: number;
|
|
74
|
+
};
|
|
75
|
+
export type SessionCommands = {
|
|
76
|
+
eval(script: string, options: { keys: string[]; arguments: string[] }): Promise<unknown>;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const BEGIN = `
|
|
80
|
+
local generation = redis.call('GET', KEYS[1])
|
|
81
|
+
if not generation then
|
|
82
|
+
generation = ARGV[1]
|
|
83
|
+
redis.call('SET', KEYS[1], generation, 'EX', ARGV[2])
|
|
84
|
+
end
|
|
85
|
+
if ARGV[5] == 'fresh' then
|
|
86
|
+
local oldkey = redis.call('GET', KEYS[3])
|
|
87
|
+
local oldraw = oldkey and redis.call('GET', oldkey)
|
|
88
|
+
if oldraw then
|
|
89
|
+
redis.call('SET', ARGV[9] .. ARGV[6], oldraw, 'EX', ARGV[8])
|
|
90
|
+
redis.call('ZADD', KEYS[4], ARGV[7], ARGV[6])
|
|
91
|
+
redis.call('DEL', oldkey)
|
|
92
|
+
end
|
|
93
|
+
redis.call('DEL', KEYS[3])
|
|
94
|
+
generation = ARGV[1]
|
|
95
|
+
redis.call('SET', KEYS[1], generation, 'EX', ARGV[2])
|
|
96
|
+
end
|
|
97
|
+
if generation ~= ARGV[1] then return false end
|
|
98
|
+
if not redis.call('SET', KEYS[2], ARGV[3], 'EX', ARGV[4], 'NX') then return false end
|
|
99
|
+
return generation`;
|
|
100
|
+
const TAKE = `
|
|
101
|
+
local raw = redis.call('GET', KEYS[1])
|
|
102
|
+
if not raw then return false end
|
|
103
|
+
local ok, pending = pcall(cjson.decode, raw)
|
|
104
|
+
if not ok then redis.call('DEL', KEYS[1]); return false end
|
|
105
|
+
if pending.browser ~= ARGV[1] or pending.generation ~= ARGV[2] or pending.state ~= ARGV[3] then return false end
|
|
106
|
+
redis.call('DEL', KEYS[1])
|
|
107
|
+
if redis.call('GET', KEYS[2]) ~= pending.generation then return false end
|
|
108
|
+
if not redis.call('SET', KEYS[3], raw, 'EX', ARGV[4], 'NX') then return false end
|
|
109
|
+
return raw`;
|
|
110
|
+
const COMPLETE = `
|
|
111
|
+
if redis.call('GET', KEYS[3]) ~= ARGV[4] then return false end
|
|
112
|
+
if redis.call('GET', KEYS[1]) ~= ARGV[1] then return false end
|
|
113
|
+
if ARGV[9] ~= '' then
|
|
114
|
+
if redis.call('EXISTS', KEYS[7]) == 1 then return false end
|
|
115
|
+
local staged = redis.call('GET', KEYS[6])
|
|
116
|
+
if not staged then return false end
|
|
117
|
+
local ok, prior = pcall(cjson.decode, staged)
|
|
118
|
+
local newok, candidate = pcall(cjson.decode, ARGV[2])
|
|
119
|
+
if not ok or not newok or prior.tokens.accessToken ~= candidate.tokens.accessToken or prior.tokens.refreshToken ~= candidate.tokens.refreshToken then return false end
|
|
120
|
+
end
|
|
121
|
+
if redis.call('EXISTS', KEYS[2]) == 1 then return false end
|
|
122
|
+
local oldkey = redis.call('GET', KEYS[4])
|
|
123
|
+
local oldraw = oldkey and redis.call('GET', oldkey)
|
|
124
|
+
if oldraw then
|
|
125
|
+
redis.call('SET', ARGV[8] .. ARGV[7], oldraw, 'EX', ARGV[6])
|
|
126
|
+
redis.call('ZADD', KEYS[5], ARGV[5], ARGV[7])
|
|
127
|
+
redis.call('DEL', oldkey)
|
|
128
|
+
end
|
|
129
|
+
redis.call('DEL', KEYS[3])
|
|
130
|
+
redis.call('SET', KEYS[2], ARGV[2], 'EX', ARGV[6])
|
|
131
|
+
redis.call('SET', KEYS[4], KEYS[2], 'EX', ARGV[6])
|
|
132
|
+
if ARGV[9] ~= '' then
|
|
133
|
+
redis.call('DEL', KEYS[6])
|
|
134
|
+
redis.call('ZREM', KEYS[5], ARGV[9])
|
|
135
|
+
end
|
|
136
|
+
return 'OK'`;
|
|
137
|
+
const READ = `
|
|
138
|
+
local raw = redis.call('GET', KEYS[1])
|
|
139
|
+
if not raw then return false end
|
|
140
|
+
local ok, session = pcall(cjson.decode, raw)
|
|
141
|
+
if not ok then redis.call('DEL', KEYS[1]); return false end
|
|
142
|
+
if session.generation ~= ARGV[1] then return false end
|
|
143
|
+
if redis.call('GET', ARGV[2] .. session.browser) ~= session.generation then return false end
|
|
144
|
+
return raw`;
|
|
145
|
+
const LOGOUT = `
|
|
146
|
+
redis.call('SET', KEYS[1], ARGV[1], 'EX', ARGV[2])
|
|
147
|
+
local current = redis.call('GET', KEYS[4])
|
|
148
|
+
local sessionkey = current or KEYS[2]
|
|
149
|
+
local raw = redis.call('GET', sessionkey)
|
|
150
|
+
local pendingRaw = redis.call('GET', KEYS[3])
|
|
151
|
+
if pendingRaw then
|
|
152
|
+
local pendingOk, pending = pcall(cjson.decode, pendingRaw)
|
|
153
|
+
if pendingOk and pending.browser == ARGV[3] then redis.call('DEL', KEYS[3]) end
|
|
154
|
+
end
|
|
155
|
+
if not raw then return false end
|
|
156
|
+
local ok, session = pcall(cjson.decode, raw)
|
|
157
|
+
if not ok or session.browser ~= ARGV[3] then return false end
|
|
158
|
+
redis.call('SET', ARGV[7] .. ARGV[4], raw, 'EX', ARGV[6])
|
|
159
|
+
redis.call('ZADD', KEYS[5], ARGV[5], ARGV[4])
|
|
160
|
+
redis.call('DEL', sessionkey, KEYS[4])
|
|
161
|
+
return raw`;
|
|
162
|
+
const ENQUEUE = `
|
|
163
|
+
if not redis.call('SET', KEYS[1], ARGV[1], 'EX', ARGV[2], 'NX') then return false end
|
|
164
|
+
redis.call('ZADD', KEYS[2], ARGV[3], ARGV[4])
|
|
165
|
+
return 'OK'`;
|
|
166
|
+
const CLAIM = `
|
|
167
|
+
local candidates
|
|
168
|
+
if ARGV[5] ~= '' then candidates = {ARGV[5]}
|
|
169
|
+
else candidates = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', ARGV[1], 'LIMIT', 0, 3) end
|
|
170
|
+
for _, ticket in ipairs(candidates) do
|
|
171
|
+
if redis.call('ZSCORE', KEYS[1], ticket) then
|
|
172
|
+
local raw = redis.call('GET', ARGV[3] .. ticket)
|
|
173
|
+
if not raw then redis.call('ZREM', KEYS[1], ticket)
|
|
174
|
+
elseif redis.call('SET', ARGV[4] .. ticket, ARGV[2], 'EX', ARGV[6], 'NX') then
|
|
175
|
+
redis.call('SET', ARGV[7] .. ticket, '1', 'EX', ARGV[8])
|
|
176
|
+
redis.call('ZADD', KEYS[1], tonumber(ARGV[1]) + tonumber(ARGV[6]), ticket)
|
|
177
|
+
return {ticket, raw}
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
return false`;
|
|
182
|
+
const SETTLE = `
|
|
183
|
+
if redis.call('GET', KEYS[3]) ~= ARGV[1] then return false end
|
|
184
|
+
if ARGV[3] == 'complete' then
|
|
185
|
+
redis.call('DEL', KEYS[2], KEYS[3], KEYS[4])
|
|
186
|
+
redis.call('ZREM', KEYS[1], ARGV[2])
|
|
187
|
+
else
|
|
188
|
+
redis.call('DEL', KEYS[3])
|
|
189
|
+
redis.call('ZADD', KEYS[1], ARGV[4], ARGV[2])
|
|
190
|
+
end
|
|
191
|
+
return 'OK'`;
|
|
192
|
+
|
|
193
|
+
function handle(): string {
|
|
194
|
+
return randomBytes(32).toString('base64url');
|
|
195
|
+
}
|
|
196
|
+
function hash(value: string): string {
|
|
197
|
+
return createHash('sha256').update(value).digest('hex');
|
|
198
|
+
}
|
|
199
|
+
function record(value: unknown): Record<string, unknown> | null {
|
|
200
|
+
if (typeof value !== 'string' || value.length > 40_000) return null;
|
|
201
|
+
try {
|
|
202
|
+
const parsed: unknown = JSON.parse(value);
|
|
203
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
|
204
|
+
? (parsed as Record<string, unknown>)
|
|
205
|
+
: null;
|
|
206
|
+
} catch {
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
function validTokens(value: unknown, now = 0): value is TokenPair {
|
|
211
|
+
if (!value || typeof value !== 'object') return false;
|
|
212
|
+
const tokens = value as TokenPair;
|
|
213
|
+
return (
|
|
214
|
+
typeof tokens.accessToken === 'string' &&
|
|
215
|
+
tokens.accessToken.length > 0 &&
|
|
216
|
+
tokens.accessToken.length <= 16_384 &&
|
|
217
|
+
!/[^\x21-\x7e]/.test(tokens.accessToken) &&
|
|
218
|
+
(tokens.refreshToken === undefined ||
|
|
219
|
+
(typeof tokens.refreshToken === 'string' &&
|
|
220
|
+
tokens.refreshToken.length > 0 &&
|
|
221
|
+
tokens.refreshToken.length <= 16_384 &&
|
|
222
|
+
!/[^\x21-\x7e]/.test(tokens.refreshToken))) &&
|
|
223
|
+
Number.isSafeInteger(tokens.expiresAt) &&
|
|
224
|
+
tokens.expiresAt > now
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
function validRetirement(value: unknown): value is RetirementTokens {
|
|
228
|
+
if (!value || typeof value !== 'object') return false;
|
|
229
|
+
const tokens = value as RetirementTokens;
|
|
230
|
+
const token = (item: unknown) =>
|
|
231
|
+
item === undefined ||
|
|
232
|
+
(typeof item === 'string' &&
|
|
233
|
+
item.length > 0 &&
|
|
234
|
+
item.length <= 16_384 &&
|
|
235
|
+
!/[^\x21-\x7e]/.test(item));
|
|
236
|
+
return (
|
|
237
|
+
token(tokens.accessToken) &&
|
|
238
|
+
token(tokens.refreshToken) &&
|
|
239
|
+
(tokens.accessToken !== undefined || tokens.refreshToken !== undefined) &&
|
|
240
|
+
Number.isSafeInteger(tokens.expiresAt) &&
|
|
241
|
+
tokens.expiresAt > 0
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** All tokens and pending PKCE secrets stay in the server store. No local fallback. */
|
|
246
|
+
export class OpaqueSessionStore {
|
|
247
|
+
private readonly prefix: string;
|
|
248
|
+
private readonly settings: Readonly<OpaqueSessionSettings>;
|
|
249
|
+
constructor(
|
|
250
|
+
private readonly commands: SessionCommands,
|
|
251
|
+
clientId: string,
|
|
252
|
+
private readonly now: () => number = () => Math.floor(Date.now() / 1000),
|
|
253
|
+
settings: Partial<OpaqueSessionSettings> = {},
|
|
254
|
+
) {
|
|
255
|
+
this.settings = sessionSettings(settings);
|
|
256
|
+
if (!clientId.trim() || clientId.length > 256) throw new Error('Invalid BFF client namespace');
|
|
257
|
+
// A stable client hash also keeps all Lua keys in one Redis Cluster slot.
|
|
258
|
+
this.prefix = `oidc:bff:{${hash(clientId)}}:`;
|
|
259
|
+
}
|
|
260
|
+
private key(kind: string, id: string): string {
|
|
261
|
+
return `${this.prefix}${kind}:${hash(id)}`;
|
|
262
|
+
}
|
|
263
|
+
private queueKey(ticket: string): string {
|
|
264
|
+
return `${this.prefix}revocation:${ticket}`;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async beginLogin(input: {
|
|
268
|
+
browser?: string;
|
|
269
|
+
generation?: string;
|
|
270
|
+
next: string;
|
|
271
|
+
fresh?: boolean;
|
|
272
|
+
}): Promise<{
|
|
273
|
+
browser: string;
|
|
274
|
+
generation: string;
|
|
275
|
+
pendingHandle: string;
|
|
276
|
+
pending: PendingLogin;
|
|
277
|
+
} | null> {
|
|
278
|
+
if (
|
|
279
|
+
!input.next.startsWith('/') ||
|
|
280
|
+
input.next.startsWith('//') ||
|
|
281
|
+
input.next.includes('\\') ||
|
|
282
|
+
/[\x00-\x1f\x7f]/.test(input.next) ||
|
|
283
|
+
input.next.length > 2048
|
|
284
|
+
)
|
|
285
|
+
throw new Error('Invalid return path');
|
|
286
|
+
const browser = input.browser && HANDLE.test(input.browser) ? input.browser : handle();
|
|
287
|
+
const generation =
|
|
288
|
+
!input.fresh && input.generation && HANDLE.test(input.generation)
|
|
289
|
+
? input.generation
|
|
290
|
+
: handle();
|
|
291
|
+
const pendingHandle = handle();
|
|
292
|
+
const pending: PendingLogin = {
|
|
293
|
+
browser: hash(browser),
|
|
294
|
+
generation,
|
|
295
|
+
state: handle(),
|
|
296
|
+
verifier: handle(),
|
|
297
|
+
nonce: handle(),
|
|
298
|
+
next: input.next,
|
|
299
|
+
startedAt: this.now(),
|
|
300
|
+
};
|
|
301
|
+
const result = await this.commands.eval(BEGIN, {
|
|
302
|
+
keys: [
|
|
303
|
+
this.key('browser', browser),
|
|
304
|
+
this.key('pending', pendingHandle),
|
|
305
|
+
this.key('current', browser),
|
|
306
|
+
`${this.prefix}revocations`,
|
|
307
|
+
],
|
|
308
|
+
arguments: [
|
|
309
|
+
generation,
|
|
310
|
+
String(this.settings.generationSeconds),
|
|
311
|
+
JSON.stringify(pending),
|
|
312
|
+
String(this.settings.loginSeconds),
|
|
313
|
+
input.fresh ? 'fresh' : 'retain',
|
|
314
|
+
handle(),
|
|
315
|
+
String(this.now()),
|
|
316
|
+
String(this.settings.tokenRetentionSeconds),
|
|
317
|
+
`${this.prefix}revocation:`,
|
|
318
|
+
],
|
|
319
|
+
});
|
|
320
|
+
return result === generation ? { browser, generation, pendingHandle, pending } : null;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async takeLogin(
|
|
324
|
+
pendingHandle: string | undefined,
|
|
325
|
+
browser: string | undefined,
|
|
326
|
+
generation: string | undefined,
|
|
327
|
+
state: string | null,
|
|
328
|
+
): Promise<PendingLogin | null> {
|
|
329
|
+
if (
|
|
330
|
+
!pendingHandle ||
|
|
331
|
+
!HANDLE.test(pendingHandle) ||
|
|
332
|
+
!browser ||
|
|
333
|
+
!HANDLE.test(browser) ||
|
|
334
|
+
!generation ||
|
|
335
|
+
!HANDLE.test(generation) ||
|
|
336
|
+
!state ||
|
|
337
|
+
!HANDLE.test(state)
|
|
338
|
+
)
|
|
339
|
+
return null;
|
|
340
|
+
const parsed = record(
|
|
341
|
+
await this.commands.eval(TAKE, {
|
|
342
|
+
keys: [
|
|
343
|
+
this.key('pending', pendingHandle),
|
|
344
|
+
this.key('browser', browser),
|
|
345
|
+
this.key('claim', state),
|
|
346
|
+
],
|
|
347
|
+
arguments: [hash(browser), generation, state, String(this.settings.loginSeconds)],
|
|
348
|
+
}),
|
|
349
|
+
);
|
|
350
|
+
if (
|
|
351
|
+
!parsed ||
|
|
352
|
+
parsed['browser'] !== hash(browser) ||
|
|
353
|
+
parsed['generation'] !== generation ||
|
|
354
|
+
parsed['state'] !== state ||
|
|
355
|
+
typeof parsed['verifier'] !== 'string' ||
|
|
356
|
+
!HANDLE.test(parsed['verifier']) ||
|
|
357
|
+
typeof parsed['nonce'] !== 'string' ||
|
|
358
|
+
!HANDLE.test(parsed['nonce']) ||
|
|
359
|
+
typeof parsed['next'] !== 'string' ||
|
|
360
|
+
typeof parsed['startedAt'] !== 'number' ||
|
|
361
|
+
parsed['startedAt'] > this.now() ||
|
|
362
|
+
this.now() - parsed['startedAt'] >= this.settings.loginSeconds
|
|
363
|
+
)
|
|
364
|
+
return null;
|
|
365
|
+
return parsed as PendingLogin;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
async completeLogin(
|
|
369
|
+
pending: PendingLogin,
|
|
370
|
+
tokens: TokenPair,
|
|
371
|
+
stagedTicket?: string,
|
|
372
|
+
): Promise<string | null> {
|
|
373
|
+
const now = this.now();
|
|
374
|
+
if (
|
|
375
|
+
!validTokens(tokens, now) ||
|
|
376
|
+
tokens.expiresAt > now + this.settings.maxProviderAccessSeconds ||
|
|
377
|
+
!/^[a-f0-9]{64}$/.test(pending.browser) ||
|
|
378
|
+
!HANDLE.test(pending.generation) ||
|
|
379
|
+
!HANDLE.test(pending.state) ||
|
|
380
|
+
!Number.isSafeInteger(pending.startedAt) ||
|
|
381
|
+
pending.startedAt > now ||
|
|
382
|
+
now - pending.startedAt >= this.settings.loginSeconds ||
|
|
383
|
+
(stagedTicket !== undefined && !HANDLE.test(stagedTicket))
|
|
384
|
+
)
|
|
385
|
+
return null;
|
|
386
|
+
const sid = `v3.${handle()}`;
|
|
387
|
+
const result = await this.commands.eval(COMPLETE, {
|
|
388
|
+
keys: [
|
|
389
|
+
`${this.prefix}browser:${pending.browser}`,
|
|
390
|
+
this.key('session', sid),
|
|
391
|
+
this.key('claim', pending.state),
|
|
392
|
+
`${this.prefix}current:${pending.browser}`,
|
|
393
|
+
`${this.prefix}revocations`,
|
|
394
|
+
this.queueKey(stagedTicket ?? 'invalid'),
|
|
395
|
+
`${this.prefix}retiring:${stagedTicket ?? 'invalid'}`,
|
|
396
|
+
],
|
|
397
|
+
arguments: [
|
|
398
|
+
pending.generation,
|
|
399
|
+
JSON.stringify({
|
|
400
|
+
browser: pending.browser,
|
|
401
|
+
generation: pending.generation,
|
|
402
|
+
activeUntil: Math.min(now + this.settings.sessionSeconds, tokens.expiresAt),
|
|
403
|
+
tokens,
|
|
404
|
+
}),
|
|
405
|
+
String(Math.min(this.settings.sessionSeconds, tokens.expiresAt - now)),
|
|
406
|
+
JSON.stringify(pending),
|
|
407
|
+
String(now),
|
|
408
|
+
String(this.settings.tokenRetentionSeconds),
|
|
409
|
+
handle(),
|
|
410
|
+
`${this.prefix}revocation:`,
|
|
411
|
+
stagedTicket ?? '',
|
|
412
|
+
],
|
|
413
|
+
});
|
|
414
|
+
return result === 'OK' ? sid : null;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
async read(sid: string | undefined, generation: string | undefined): Promise<TokenPair | null> {
|
|
418
|
+
if (!sid || !SESSION_HANDLE.test(sid) || !generation || !HANDLE.test(generation)) return null;
|
|
419
|
+
const parsed = record(
|
|
420
|
+
await this.commands.eval(READ, {
|
|
421
|
+
keys: [this.key('session', sid)],
|
|
422
|
+
arguments: [generation, `${this.prefix}browser:`],
|
|
423
|
+
}),
|
|
424
|
+
);
|
|
425
|
+
return parsed &&
|
|
426
|
+
typeof parsed['activeUntil'] === 'number' &&
|
|
427
|
+
parsed['activeUntil'] > this.now() &&
|
|
428
|
+
validTokens(parsed['tokens'], this.now())
|
|
429
|
+
? parsed['tokens']
|
|
430
|
+
: null;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
async logout(
|
|
434
|
+
browser: string | undefined,
|
|
435
|
+
sid?: string,
|
|
436
|
+
pendingHandle?: string,
|
|
437
|
+
): Promise<{
|
|
438
|
+
generation: string;
|
|
439
|
+
tokens: TokenPair | null;
|
|
440
|
+
revocationTicket: string | null;
|
|
441
|
+
} | null> {
|
|
442
|
+
if (!browser || !HANDLE.test(browser)) return null;
|
|
443
|
+
const generation = handle();
|
|
444
|
+
const ticket = handle();
|
|
445
|
+
const parsed = record(
|
|
446
|
+
await this.commands.eval(LOGOUT, {
|
|
447
|
+
keys: [
|
|
448
|
+
this.key('browser', browser),
|
|
449
|
+
this.key('session', sid && SESSION_HANDLE.test(sid) ? sid : 'invalid'),
|
|
450
|
+
this.key(
|
|
451
|
+
'pending',
|
|
452
|
+
pendingHandle && HANDLE.test(pendingHandle) ? pendingHandle : 'invalid',
|
|
453
|
+
),
|
|
454
|
+
this.key('current', browser),
|
|
455
|
+
`${this.prefix}revocations`,
|
|
456
|
+
],
|
|
457
|
+
arguments: [
|
|
458
|
+
generation,
|
|
459
|
+
String(this.settings.generationSeconds),
|
|
460
|
+
hash(browser),
|
|
461
|
+
ticket,
|
|
462
|
+
String(this.now()),
|
|
463
|
+
String(this.settings.tokenRetentionSeconds),
|
|
464
|
+
`${this.prefix}revocation:`,
|
|
465
|
+
],
|
|
466
|
+
}),
|
|
467
|
+
);
|
|
468
|
+
const tokens = parsed && validTokens(parsed['tokens']) ? parsed['tokens'] : null;
|
|
469
|
+
return { generation, tokens, revocationTicket: tokens ? ticket : null };
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** Stage immediately after token minting; session commit transfers custody atomically. */
|
|
473
|
+
async queueRevocation(tokens: RetirementTokens, delaySeconds = 0): Promise<string> {
|
|
474
|
+
if (
|
|
475
|
+
!validRetirement(tokens) ||
|
|
476
|
+
!Number.isSafeInteger(delaySeconds) ||
|
|
477
|
+
delaySeconds < 0 ||
|
|
478
|
+
delaySeconds > this.settings.loginSeconds
|
|
479
|
+
) {
|
|
480
|
+
throw new Error('Invalid token retirement record');
|
|
481
|
+
}
|
|
482
|
+
const ticket = handle();
|
|
483
|
+
const result = await this.commands.eval(ENQUEUE, {
|
|
484
|
+
keys: [this.queueKey(ticket), `${this.prefix}revocations`],
|
|
485
|
+
arguments: [
|
|
486
|
+
JSON.stringify({ tokens }),
|
|
487
|
+
String(this.settings.tokenRetentionSeconds),
|
|
488
|
+
String(this.now() + delaySeconds),
|
|
489
|
+
ticket,
|
|
490
|
+
],
|
|
491
|
+
});
|
|
492
|
+
if (result !== 'OK') throw new Error('BFF token retirement is unavailable');
|
|
493
|
+
return ticket;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
async claimRevocation(ticket?: string): Promise<RevocationClaim | null> {
|
|
497
|
+
if (ticket !== undefined && !HANDLE.test(ticket)) return null;
|
|
498
|
+
const lease = handle();
|
|
499
|
+
const result = await this.commands.eval(CLAIM, {
|
|
500
|
+
keys: [`${this.prefix}revocations`],
|
|
501
|
+
arguments: [
|
|
502
|
+
String(this.now()),
|
|
503
|
+
lease,
|
|
504
|
+
`${this.prefix}revocation:`,
|
|
505
|
+
`${this.prefix}revocation-lease:`,
|
|
506
|
+
ticket ?? '',
|
|
507
|
+
String(this.settings.leaseSeconds),
|
|
508
|
+
`${this.prefix}retiring:`,
|
|
509
|
+
String(this.settings.tokenRetentionSeconds),
|
|
510
|
+
],
|
|
511
|
+
});
|
|
512
|
+
if (!Array.isArray(result) || typeof result[0] !== 'string' || !HANDLE.test(result[0]))
|
|
513
|
+
return null;
|
|
514
|
+
const parsed = record(result[1]);
|
|
515
|
+
if (!parsed || !validRetirement(parsed['tokens']))
|
|
516
|
+
throw new Error('Invalid BFF token retirement record');
|
|
517
|
+
return { ticket: result[0], lease, tokens: parsed['tokens'] };
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
async revocationPending(ticket: string): Promise<boolean> {
|
|
521
|
+
if (!HANDLE.test(ticket)) return false;
|
|
522
|
+
return (
|
|
523
|
+
(await this.commands.eval("return redis.call('EXISTS', KEYS[1])", {
|
|
524
|
+
keys: [this.queueKey(ticket)],
|
|
525
|
+
arguments: [],
|
|
526
|
+
})) === 1
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
async settleRevocation(claim: RevocationClaim, complete: boolean): Promise<boolean> {
|
|
531
|
+
if (!HANDLE.test(claim.ticket) || !HANDLE.test(claim.lease)) return false;
|
|
532
|
+
return (
|
|
533
|
+
(await this.commands.eval(SETTLE, {
|
|
534
|
+
keys: [
|
|
535
|
+
`${this.prefix}revocations`,
|
|
536
|
+
this.queueKey(claim.ticket),
|
|
537
|
+
`${this.prefix}revocation-lease:${claim.ticket}`,
|
|
538
|
+
`${this.prefix}retiring:${claim.ticket}`,
|
|
539
|
+
],
|
|
540
|
+
arguments: [
|
|
541
|
+
claim.lease,
|
|
542
|
+
claim.ticket,
|
|
543
|
+
complete ? 'complete' : 'retry',
|
|
544
|
+
String(this.now() + this.settings.retrySeconds),
|
|
545
|
+
],
|
|
546
|
+
})) === 'OK'
|
|
547
|
+
);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { expect, test } from 'bun:test';
|
|
2
|
+
import {
|
|
3
|
+
createValkeySessionCommands,
|
|
4
|
+
SessionStorageUnavailableError,
|
|
5
|
+
type SessionConnection,
|
|
6
|
+
} from './valkey-session-store';
|
|
7
|
+
|
|
8
|
+
function connection(overrides: Partial<SessionConnection> = {}): SessionConnection {
|
|
9
|
+
return {
|
|
10
|
+
isOpen: true,
|
|
11
|
+
isReady: true,
|
|
12
|
+
on: () => {},
|
|
13
|
+
connect: () => Promise.resolve(),
|
|
14
|
+
destroy: () => {},
|
|
15
|
+
withAbortSignal: () => ({ eval: () => Promise.resolve('synthetic-result') }),
|
|
16
|
+
...overrides,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
const config = { url: 'redis://synthetic.private.test:6379', trustedInternalPlaintext: true };
|
|
20
|
+
test('shares one single-flight connection, executes bounded commands, and cannot reopen after close', async () => {
|
|
21
|
+
let created = 0,
|
|
22
|
+
destroyed = 0;
|
|
23
|
+
const commands = createValkeySessionCommands(config, {
|
|
24
|
+
createConnection: () => {
|
|
25
|
+
created++;
|
|
26
|
+
return connection({
|
|
27
|
+
destroy: () => {
|
|
28
|
+
destroyed++;
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
expect(
|
|
34
|
+
await Promise.all(
|
|
35
|
+
Array.from({ length: 4 }, () => commands.eval('return 1', { keys: [], arguments: [] })),
|
|
36
|
+
),
|
|
37
|
+
).toEqual(Array(4).fill('synthetic-result'));
|
|
38
|
+
expect(created).toBe(1);
|
|
39
|
+
commands.close();
|
|
40
|
+
expect(destroyed).toBe(1);
|
|
41
|
+
await expect(commands.eval('return 1', { keys: [], arguments: [] })).rejects.toBeInstanceOf(
|
|
42
|
+
SessionStorageUnavailableError,
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
test('requires TLS unless the owner explicitly enables trusted internal plaintext', () => {
|
|
46
|
+
for (const url of [
|
|
47
|
+
'redis://private.test:6379',
|
|
48
|
+
'https://foreign.test',
|
|
49
|
+
'rediss://private.test/0?password=secret',
|
|
50
|
+
'rediss://private.test/not-a-db',
|
|
51
|
+
]) {
|
|
52
|
+
expect(() => createValkeySessionCommands({ url })).toThrow('configuration');
|
|
53
|
+
}
|
|
54
|
+
expect(() => createValkeySessionCommands({ url: 'rediss://private.test:6379/0' })).not.toThrow();
|
|
55
|
+
});
|
|
56
|
+
test('bounds ignored-abort connection and command work, without exposing raw secret-bearing failures', async () => {
|
|
57
|
+
const slowConnect = createValkeySessionCommands(
|
|
58
|
+
{ ...config, commandTimeoutMs: 20 },
|
|
59
|
+
{
|
|
60
|
+
createConnection: () => connection({ connect: () => new Promise(() => {}) }),
|
|
61
|
+
},
|
|
62
|
+
);
|
|
63
|
+
await expect(slowConnect.eval('return 1', { keys: [], arguments: [] })).rejects.toThrow(
|
|
64
|
+
'BFF session storage is unavailable',
|
|
65
|
+
);
|
|
66
|
+
slowConnect.close();
|
|
67
|
+
const slowCommand = createValkeySessionCommands(
|
|
68
|
+
{ ...config, commandTimeoutMs: 20 },
|
|
69
|
+
{
|
|
70
|
+
createConnection: () =>
|
|
71
|
+
connection({ withAbortSignal: () => ({ eval: () => new Promise(() => {}) }) }),
|
|
72
|
+
},
|
|
73
|
+
);
|
|
74
|
+
await expect(slowCommand.eval('return 1', { keys: [], arguments: [] })).rejects.toThrow(
|
|
75
|
+
'BFF session storage is unavailable',
|
|
76
|
+
);
|
|
77
|
+
slowCommand.close();
|
|
78
|
+
const failure = createValkeySessionCommands(config, {
|
|
79
|
+
createConnection: () =>
|
|
80
|
+
connection({ connect: () => Promise.reject(new Error('secret redis://credential')) }),
|
|
81
|
+
});
|
|
82
|
+
await expect(failure.eval('return 1', { keys: [], arguments: [] })).rejects.toThrow(
|
|
83
|
+
'BFF session storage is unavailable',
|
|
84
|
+
);
|
|
85
|
+
failure.close();
|
|
86
|
+
});
|
|
87
|
+
test('never executes a command after its connecting deadline already expired', async () => {
|
|
88
|
+
let release: (() => void) | undefined,
|
|
89
|
+
executions = 0;
|
|
90
|
+
const commands = createValkeySessionCommands(
|
|
91
|
+
{ ...config, commandTimeoutMs: 20 },
|
|
92
|
+
{
|
|
93
|
+
createConnection: () =>
|
|
94
|
+
connection({
|
|
95
|
+
connect: () =>
|
|
96
|
+
new Promise<void>((resolve) => {
|
|
97
|
+
release = resolve;
|
|
98
|
+
}),
|
|
99
|
+
withAbortSignal: () => ({
|
|
100
|
+
eval: () => {
|
|
101
|
+
executions++;
|
|
102
|
+
return Promise.resolve(null);
|
|
103
|
+
},
|
|
104
|
+
}),
|
|
105
|
+
}),
|
|
106
|
+
},
|
|
107
|
+
);
|
|
108
|
+
await expect(commands.eval('return 1', { keys: [], arguments: [] })).rejects.toBeInstanceOf(
|
|
109
|
+
SessionStorageUnavailableError,
|
|
110
|
+
);
|
|
111
|
+
release?.();
|
|
112
|
+
await Promise.resolve();
|
|
113
|
+
await Promise.resolve();
|
|
114
|
+
expect(executions).toBe(0);
|
|
115
|
+
commands.close();
|
|
116
|
+
});
|
|
117
|
+
test('rejects overlarge command input before connecting', async () => {
|
|
118
|
+
let creates = 0;
|
|
119
|
+
const commands = createValkeySessionCommands(config, {
|
|
120
|
+
createConnection: () => {
|
|
121
|
+
creates++;
|
|
122
|
+
return connection();
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
await expect(
|
|
126
|
+
commands.eval('x'.repeat(16385), { keys: [], arguments: [] }),
|
|
127
|
+
).rejects.toBeInstanceOf(SessionStorageUnavailableError);
|
|
128
|
+
expect(creates).toBe(0);
|
|
129
|
+
commands.close();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('snapshots caller-owned command arrays before awaiting connection', async () => {
|
|
133
|
+
let release: (() => void) | undefined;
|
|
134
|
+
let seen: { keys: string[]; arguments: string[] } | undefined;
|
|
135
|
+
const commands = createValkeySessionCommands(config, {
|
|
136
|
+
createConnection: () =>
|
|
137
|
+
connection({
|
|
138
|
+
connect: () =>
|
|
139
|
+
new Promise<void>((resolve) => {
|
|
140
|
+
release = resolve;
|
|
141
|
+
}),
|
|
142
|
+
withAbortSignal: () => ({
|
|
143
|
+
eval: (_script, command) => {
|
|
144
|
+
seen = command;
|
|
145
|
+
return Promise.resolve(1);
|
|
146
|
+
},
|
|
147
|
+
}),
|
|
148
|
+
}),
|
|
149
|
+
});
|
|
150
|
+
const input = { keys: ['original-key'], arguments: ['original-argument'] };
|
|
151
|
+
const pending = commands.eval('return 1', input);
|
|
152
|
+
input.keys[0] = 'changed-key';
|
|
153
|
+
input.arguments[0] = 'changed-argument';
|
|
154
|
+
release?.();
|
|
155
|
+
await pending;
|
|
156
|
+
expect(seen).toEqual({ keys: ['original-key'], arguments: ['original-argument'] });
|
|
157
|
+
commands.close();
|
|
158
|
+
});
|