ioredis-toolkit 0.0.9 → 0.5.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/CHANGELOG.md +67 -0
- package/LICENSE +1 -1
- package/README.md +68 -1058
- package/dist/cache/cache.d.ts +30 -0
- package/dist/cache/cache.d.ts.map +1 -0
- package/dist/cache/cache.js +59 -0
- package/dist/cache/cache.js.map +1 -0
- package/dist/cache/config.d.ts +12 -0
- package/dist/cache/config.d.ts.map +1 -0
- package/dist/cache/config.js +13 -0
- package/dist/cache/config.js.map +1 -0
- package/dist/cache/types.d.ts +32 -0
- package/dist/cache/types.d.ts.map +1 -0
- package/dist/cache/types.js +5 -0
- package/dist/cache/types.js.map +1 -0
- package/dist/index.d.ts +43 -51
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +32 -44
- package/dist/index.js.map +1 -0
- package/dist/lock/config.d.ts +12 -0
- package/dist/lock/config.d.ts.map +1 -0
- package/dist/lock/config.js +8 -0
- package/dist/lock/config.js.map +1 -0
- package/dist/lock/lock.d.ts +20 -0
- package/dist/lock/lock.d.ts.map +1 -0
- package/dist/lock/lock.js +44 -0
- package/dist/lock/lock.js.map +1 -0
- package/dist/lock/types.d.ts +19 -0
- package/dist/lock/types.d.ts.map +1 -0
- package/dist/lock/types.js +2 -0
- package/dist/lock/types.js.map +1 -0
- package/dist/modules-config.d.ts +3 -0
- package/dist/modules-config.d.ts.map +1 -0
- package/dist/modules-config.js +2 -0
- package/dist/modules-config.js.map +1 -0
- package/dist/pubsub/config.d.ts +11 -0
- package/dist/pubsub/config.d.ts.map +1 -0
- package/dist/pubsub/config.js +6 -0
- package/dist/pubsub/config.js.map +1 -0
- package/dist/pubsub/pubsub.d.ts +20 -0
- package/dist/pubsub/pubsub.d.ts.map +1 -0
- package/dist/pubsub/pubsub.js +54 -0
- package/dist/pubsub/pubsub.js.map +1 -0
- package/dist/pubsub/types.d.ts +24 -0
- package/dist/pubsub/types.d.ts.map +1 -0
- package/dist/pubsub/types.js +2 -0
- package/dist/pubsub/types.js.map +1 -0
- package/dist/rate-limit/config.d.ts +12 -0
- package/dist/rate-limit/config.d.ts.map +1 -0
- package/dist/rate-limit/config.js +6 -0
- package/dist/rate-limit/config.js.map +1 -0
- package/dist/rate-limit/rate-limiter.d.ts +18 -0
- package/dist/rate-limit/rate-limiter.d.ts.map +1 -0
- package/dist/rate-limit/rate-limiter.js +37 -0
- package/dist/rate-limit/rate-limiter.js.map +1 -0
- package/dist/rate-limit/types.d.ts +27 -0
- package/dist/rate-limit/types.d.ts.map +1 -0
- package/dist/rate-limit/types.js +2 -0
- package/dist/rate-limit/types.js.map +1 -0
- package/dist/redis/client-facade.d.ts +77 -0
- package/dist/redis/client-facade.d.ts.map +1 -0
- package/dist/redis/client-facade.js +102 -0
- package/dist/redis/client-facade.js.map +1 -0
- package/dist/redis/client.d.ts +10 -0
- package/dist/redis/client.d.ts.map +1 -0
- package/dist/redis/client.js +29 -0
- package/dist/redis/client.js.map +1 -0
- package/dist/redis/cluster.d.ts +7 -0
- package/dist/redis/cluster.d.ts.map +1 -0
- package/dist/redis/cluster.js +47 -0
- package/dist/redis/cluster.js.map +1 -0
- package/dist/redis/config.d.ts +39 -0
- package/dist/redis/config.d.ts.map +1 -0
- package/dist/redis/config.js +52 -0
- package/dist/redis/config.js.map +1 -0
- package/dist/redis/errors.d.ts +5 -0
- package/dist/redis/errors.d.ts.map +1 -0
- package/dist/redis/errors.js +5 -0
- package/dist/redis/errors.js.map +1 -0
- package/dist/redis/types.d.ts +135 -0
- package/dist/redis/types.d.ts.map +1 -0
- package/dist/redis/types.js +2 -0
- package/dist/redis/types.js.map +1 -0
- package/dist/redis/wrapper.d.ts +88 -0
- package/dist/redis/wrapper.d.ts.map +1 -0
- package/dist/redis/wrapper.js +206 -0
- package/dist/redis/wrapper.js.map +1 -0
- package/dist/session/config.d.ts +47 -0
- package/dist/session/config.d.ts.map +1 -0
- package/dist/session/config.js +101 -0
- package/dist/session/config.js.map +1 -0
- package/dist/session/cookie.d.ts +16 -0
- package/dist/session/cookie.d.ts.map +1 -0
- package/dist/session/cookie.js +28 -0
- package/dist/session/cookie.js.map +1 -0
- package/dist/session/errors.d.ts +56 -0
- package/dist/session/errors.d.ts.map +1 -0
- package/dist/session/errors.js +58 -0
- package/dist/session/errors.js.map +1 -0
- package/dist/session/factory.d.ts +21 -0
- package/dist/session/factory.d.ts.map +1 -0
- package/dist/session/factory.js +30 -0
- package/dist/session/factory.js.map +1 -0
- package/dist/session/health.d.ts +12 -0
- package/dist/session/health.d.ts.map +1 -0
- package/dist/session/health.js +23 -0
- package/dist/session/health.js.map +1 -0
- package/dist/session/keys.d.ts +23 -0
- package/dist/session/keys.d.ts.map +1 -0
- package/dist/session/keys.js +27 -0
- package/dist/session/keys.js.map +1 -0
- package/dist/session/manager.d.ts +34 -0
- package/dist/session/manager.d.ts.map +1 -0
- package/dist/session/manager.js +31 -0
- package/dist/session/manager.js.map +1 -0
- package/dist/session/metrics.d.ts +11 -0
- package/dist/session/metrics.d.ts.map +1 -0
- package/dist/session/metrics.js +10 -0
- package/dist/session/metrics.js.map +1 -0
- package/dist/session/repository.d.ts +49 -0
- package/dist/session/repository.d.ts.map +1 -0
- package/dist/session/repository.js +203 -0
- package/dist/session/repository.js.map +1 -0
- package/dist/session/revocation.d.ts +22 -0
- package/dist/session/revocation.d.ts.map +1 -0
- package/dist/session/revocation.js +41 -0
- package/dist/session/revocation.js.map +1 -0
- package/dist/session/script-sources.d.ts +11 -0
- package/dist/session/script-sources.d.ts.map +1 -0
- package/dist/session/script-sources.js +140 -0
- package/dist/session/script-sources.js.map +1 -0
- package/dist/session/scripts.d.ts +15 -0
- package/dist/session/scripts.d.ts.map +1 -0
- package/dist/session/scripts.js +41 -0
- package/dist/session/scripts.js.map +1 -0
- package/dist/session/serializer.d.ts +12 -0
- package/dist/session/serializer.d.ts.map +1 -0
- package/dist/session/serializer.js +77 -0
- package/dist/session/serializer.js.map +1 -0
- package/dist/session/service.d.ts +48 -0
- package/dist/session/service.d.ts.map +1 -0
- package/dist/session/service.js +235 -0
- package/dist/session/service.js.map +1 -0
- package/dist/session/token.d.ts +16 -0
- package/dist/session/token.d.ts.map +1 -0
- package/dist/session/token.js +32 -0
- package/dist/session/token.js.map +1 -0
- package/dist/session/types.d.ts +134 -0
- package/dist/session/types.d.ts.map +1 -0
- package/dist/session/types.js +2 -0
- package/dist/session/types.js.map +1 -0
- package/dist/streams/config.d.ts +12 -0
- package/dist/streams/config.d.ts.map +1 -0
- package/dist/streams/config.js +6 -0
- package/dist/streams/config.js.map +1 -0
- package/dist/streams/streams.d.ts +24 -0
- package/dist/streams/streams.d.ts.map +1 -0
- package/dist/streams/streams.js +55 -0
- package/dist/streams/streams.js.map +1 -0
- package/dist/streams/types.d.ts +32 -0
- package/dist/streams/types.d.ts.map +1 -0
- package/dist/streams/types.js +2 -0
- package/dist/streams/types.js.map +1 -0
- package/docs/ACCEPTANCE-REPORT.md +70 -0
- package/docs/ARCHITECTURE.md +61 -0
- package/docs/CAPACITY.md +33 -0
- package/docs/DEPLOYMENT.md +22 -0
- package/docs/README-API.md +15 -0
- package/docs/STATE-MACHINE.md +38 -0
- package/docs/TESTING.md +37 -0
- package/docs/THREAT-MODEL.md +23 -0
- package/docs/TYPE-SAFETY.md +34 -0
- package/docs/modules/cache/README.md +7 -0
- package/docs/modules/cache/usage.md +156 -0
- package/docs/modules/lock/README.md +7 -0
- package/docs/modules/lock/usage.md +105 -0
- package/docs/modules/pubsub/README.md +7 -0
- package/docs/modules/pubsub/usage.md +106 -0
- package/docs/modules/rate-limit/README.md +7 -0
- package/docs/modules/rate-limit/usage.md +100 -0
- package/docs/modules/sessions/README.md +7 -0
- package/docs/modules/sessions/usage.md +262 -0
- package/docs/modules/streams/README.md +7 -0
- package/docs/modules/streams/usage.md +141 -0
- package/package.json +50 -60
- package/src/scripts/cleanup-index.lua +4 -0
- package/src/scripts/conditional-update.lua +21 -0
- package/src/scripts/consume-session.lua +21 -0
- package/src/scripts/create-session.lua +28 -0
- package/src/scripts/delete.lua +2 -0
- package/src/scripts/destroy-user.lua +13 -0
- package/src/scripts/enforce-limit.lua +17 -0
- package/src/scripts/revoke-session.lua +13 -0
- package/src/scripts/rotate.lua +24 -0
- package/src/scripts/touch-session.lua +28 -0
- package/src/scripts/update-session.lua +18 -0
- package/dist/cache.d.ts +0 -796
- package/dist/cache.js +0 -1120
- package/dist/client.d.ts +0 -284
- package/dist/client.js +0 -1114
- package/dist/cluster-slot.d.ts +0 -4
- package/dist/cluster-slot.js +0 -31
- package/dist/cluster.d.ts +0 -79
- package/dist/cluster.js +0 -156
- package/dist/errors.d.ts +0 -30
- package/dist/errors.js +0 -63
- package/dist/health.d.ts +0 -180
- package/dist/health.js +0 -239
- package/dist/lock.d.ts +0 -248
- package/dist/lock.js +0 -397
- package/dist/logger.d.ts +0 -12
- package/dist/logger.js +0 -40
- package/dist/pubsub.d.ts +0 -423
- package/dist/pubsub.js +0 -537
- package/dist/ratelimiter.d.ts +0 -441
- package/dist/ratelimiter.js +0 -539
- package/dist/session/index.d.ts +0 -23
- package/dist/session/index.js +0 -16
- package/dist/session/revocation-store.d.ts +0 -176
- package/dist/session/revocation-store.js +0 -318
- package/dist/session/scripts/cleanup-index.lua +0 -21
- package/dist/session/scripts/conditional-update-encrypted.lua +0 -60
- package/dist/session/scripts/conditional-update.lua +0 -63
- package/dist/session/scripts/create.lua +0 -83
- package/dist/session/scripts/delete-by-user.lua +0 -29
- package/dist/session/scripts/delete.lua +0 -15
- package/dist/session/scripts/enforce-limit.lua +0 -38
- package/dist/session/scripts/revoke.lua +0 -61
- package/dist/session/scripts/rotate-encrypted.lua +0 -110
- package/dist/session/scripts/rotate.lua +0 -122
- package/dist/session/scripts/touch-encrypted.lua +0 -89
- package/dist/session/scripts/touch.lua +0 -72
- package/dist/session/scripts/validate.lua +0 -90
- package/dist/session/session-circuit-breaker.d.ts +0 -42
- package/dist/session/session-circuit-breaker.js +0 -129
- package/dist/session/session-config.d.ts +0 -335
- package/dist/session/session-config.js +0 -162
- package/dist/session/session-cookie.d.ts +0 -72
- package/dist/session/session-cookie.js +0 -101
- package/dist/session/session-encryption.d.ts +0 -87
- package/dist/session/session-encryption.js +0 -139
- package/dist/session/session-errors.d.ts +0 -85
- package/dist/session/session-errors.js +0 -145
- package/dist/session/session-health.d.ts +0 -38
- package/dist/session/session-health.js +0 -60
- package/dist/session/session-keys.d.ts +0 -51
- package/dist/session/session-keys.js +0 -113
- package/dist/session/session-manager.d.ts +0 -73
- package/dist/session/session-manager.js +0 -94
- package/dist/session/session-metrics.d.ts +0 -33
- package/dist/session/session-metrics.js +0 -112
- package/dist/session/session-repository.d.ts +0 -161
- package/dist/session/session-repository.js +0 -683
- package/dist/session/session-scripts.d.ts +0 -36
- package/dist/session/session-scripts.js +0 -130
- package/dist/session/session-serializer.d.ts +0 -42
- package/dist/session/session-serializer.js +0 -248
- package/dist/session/session-service.d.ts +0 -104
- package/dist/session/session-service.js +0 -611
- package/dist/session/session-token.d.ts +0 -38
- package/dist/session/session-token.js +0 -86
- package/dist/session/session-types.d.ts +0 -253
- package/dist/session/session-types.js +0 -16
- package/dist/types.d.ts +0 -924
- package/dist/types.js +0 -151
- package/dist/utils/deepmerge.d.ts +0 -9
- package/dist/utils/deepmerge.js +0 -61
|
@@ -1,176 +0,0 @@
|
|
|
1
|
-
import type { RevocationRecord, RevocationStore } from './session-types.js';
|
|
2
|
-
import type { RedisClientWrapper } from '../client.js';
|
|
3
|
-
import z from 'zod';
|
|
4
|
-
export interface RedisRevocationStoreOptions {
|
|
5
|
-
/**
|
|
6
|
-
* Redis client.
|
|
7
|
-
*
|
|
8
|
-
* Compatible with:
|
|
9
|
-
* - ioredis standalone
|
|
10
|
-
* - ioredis Sentinel
|
|
11
|
-
* - ioredis Cluster
|
|
12
|
-
*/
|
|
13
|
-
client: RedisClientWrapper;
|
|
14
|
-
/**
|
|
15
|
-
* Key prefix, so multiple apps can share a Redis instance safely.
|
|
16
|
-
* Default: `authcore:revoked:`.
|
|
17
|
-
*
|
|
18
|
-
* @example
|
|
19
|
-
* ```ts
|
|
20
|
-
* new RedisRevocationStore({ client, keyPrefix: 'auth:revoked:' });
|
|
21
|
-
* ```
|
|
22
|
-
*/
|
|
23
|
-
keyPrefix?: string | undefined;
|
|
24
|
-
}
|
|
25
|
-
export declare const RedisRevocationStoreOptionsSchema: z.ZodObject<{
|
|
26
|
-
keyPrefix: z.ZodOptional<z.ZodString>;
|
|
27
|
-
}, z.z.core.$strip>;
|
|
28
|
-
export type RedisRevocationStoreOptionsInput = z.input<typeof RedisRevocationStoreOptionsSchema>;
|
|
29
|
-
/**
|
|
30
|
-
* Redis-backed revocation store. Each revoked jti is stored as
|
|
31
|
-
* `{prefix}{jti} -> reason`, with the Redis key TTL itself set to the
|
|
32
|
-
* token's remaining lifetime — expired entries are reclaimed automatically
|
|
33
|
-
* by Redis, no sweep job required.
|
|
34
|
-
*
|
|
35
|
-
* Every operation here is a single-key command, so this store works
|
|
36
|
-
* identically on standalone, Sentinel, and Cluster with no hash tags
|
|
37
|
-
* required (unlike the session store, there's no multi-key atomicity
|
|
38
|
-
* requirement to satisfy).
|
|
39
|
-
*
|
|
40
|
-
* Batched operations (`revokeMany`, `isRevokedMany`) group their commands
|
|
41
|
-
* by hash slot and issue one pipeline per slot, so they never trigger
|
|
42
|
-
* `CROSSSLOT` errors on Redis Cluster. Pipeline failures are surfaced via
|
|
43
|
-
* {@link RevocationBatchError} instead of being silently swallowed —
|
|
44
|
-
* a missed revocation is a security bug.
|
|
45
|
-
*
|
|
46
|
-
* Validation fails fast and typed: invalid records throw
|
|
47
|
-
* {@link RevocationError} before any network call, and reads fail closed
|
|
48
|
-
* (an infra error is never treated as "not revoked").
|
|
49
|
-
*
|
|
50
|
-
* @example
|
|
51
|
-
* ```ts
|
|
52
|
-
* const revocations = new RedisRevocationStore({ client });
|
|
53
|
-
*
|
|
54
|
-
* await revocations.revoke({
|
|
55
|
-
* jti: 'a1b2c3d4',
|
|
56
|
-
* reason: 'password-change',
|
|
57
|
-
* expiresAt: Math.floor(Date.now() / 1000) + 3600,
|
|
58
|
-
* });
|
|
59
|
-
*
|
|
60
|
-
* if (await revocations.isRevoked('a1b2c3d4')) {
|
|
61
|
-
* // token was rotated or revoked - reject it
|
|
62
|
-
* }
|
|
63
|
-
* ```
|
|
64
|
-
*/
|
|
65
|
-
export declare class RedisRevocationStore implements RevocationStore {
|
|
66
|
-
private readonly client;
|
|
67
|
-
private readonly keyPrefix;
|
|
68
|
-
/**
|
|
69
|
-
* Creates a Redis-backed revocation store.
|
|
70
|
-
*
|
|
71
|
-
* @param options - Client connection and key-prefix configuration.
|
|
72
|
-
*
|
|
73
|
-
* @example
|
|
74
|
-
* ```ts
|
|
75
|
-
* const store = new RedisRevocationStore({
|
|
76
|
-
* client,
|
|
77
|
-
* keyPrefix: 'myapp:revoked:',
|
|
78
|
-
* });
|
|
79
|
-
* ```
|
|
80
|
-
*/
|
|
81
|
-
constructor(options: RedisRevocationStoreOptions);
|
|
82
|
-
/**
|
|
83
|
-
* Marks a jti as revoked for the remainder of its lifetime.
|
|
84
|
-
*
|
|
85
|
-
* Stores `{prefix}{jti} -> reason` with a Redis TTL equal to the
|
|
86
|
-
* record's remaining lifetime (`expiresAt - now`), so the entry is
|
|
87
|
-
* garbage-collected automatically once the original token would have
|
|
88
|
-
* expired anyway. Overwriting an existing entry extends/refreshes its
|
|
89
|
-
* TTL to the new expiry.
|
|
90
|
-
*
|
|
91
|
-
* @param record - The revocation entry (`jti`, `expiresAt`, optional
|
|
92
|
-
* `reason`). `expiresAt` must be a finite Unix-seconds timestamp in
|
|
93
|
-
* the future.
|
|
94
|
-
* @throws {RevocationError} when `record.expiresAt` is missing, not a
|
|
95
|
-
* finite number, or not in the future (fails fast instead of sending
|
|
96
|
-
* an invalid `EX` to Redis).
|
|
97
|
-
*
|
|
98
|
-
* @example
|
|
99
|
-
* ```ts
|
|
100
|
-
* await revocations.revoke({
|
|
101
|
-
* jti: 'a1b2c3d4',
|
|
102
|
-
* reason: 'logout',
|
|
103
|
-
* expiresAt: Math.floor(Date.now() / 1000) + 86400,
|
|
104
|
-
* });
|
|
105
|
-
* ```
|
|
106
|
-
*/
|
|
107
|
-
revoke(record: RevocationRecord): Promise<void>;
|
|
108
|
-
/**
|
|
109
|
-
* Revokes many jtis in one batched call.
|
|
110
|
-
*
|
|
111
|
-
* All records are validated up front — an invalid `expiresAt` fails
|
|
112
|
-
* before any network call is issued, rather than partway through a
|
|
113
|
-
* batch. Commands are grouped by hash slot (one pipeline per slot) so
|
|
114
|
-
* the batch stays Cluster-safe, and every pipeline result is inspected:
|
|
115
|
-
* any failed command throws {@link RevocationBatchError} listing the
|
|
116
|
-
* affected jtis, because a silently-missed revocation is a security bug.
|
|
117
|
-
*
|
|
118
|
-
* @param records - The revocation entries to create/refresh.
|
|
119
|
-
* @throws {RevocationError} when any record is invalid (validation is
|
|
120
|
-
* all-or-nothing, before any network call).
|
|
121
|
-
* @throws {RevocationBatchError} when one or more pipeline commands
|
|
122
|
-
* fail; carries the exact jtis that were not revoked.
|
|
123
|
-
*
|
|
124
|
-
* @example
|
|
125
|
-
* ```ts
|
|
126
|
-
* await revocations.revokeMany([
|
|
127
|
-
* { jti: 'a1', reason: 'logout-all', expiresAt: expiry },
|
|
128
|
-
* { jti: 'b2', reason: 'logout-all', expiresAt: expiry },
|
|
129
|
-
* ]);
|
|
130
|
-
* ```
|
|
131
|
-
*/
|
|
132
|
-
revokeMany(records: RevocationRecord[]): Promise<void>;
|
|
133
|
-
/**
|
|
134
|
-
* Checks whether a jti is currently revoked.
|
|
135
|
-
*
|
|
136
|
-
* Fail-closed: infrastructure errors are wrapped in a typed error and
|
|
137
|
-
* must NOT be treated as "not revoked".
|
|
138
|
-
*
|
|
139
|
-
* @param jti - The token/session id to check.
|
|
140
|
-
* @returns `true` when the jti has a live revocation entry.
|
|
141
|
-
* @throws {RevocationError} when the check itself fails (caller must
|
|
142
|
-
* treat the outcome as unknown).
|
|
143
|
-
*
|
|
144
|
-
* @example
|
|
145
|
-
* ```ts
|
|
146
|
-
* if (await revocations.isRevoked(token.jti)) {
|
|
147
|
-
* return 401; // token was rotated away or explicitly revoked
|
|
148
|
-
* }
|
|
149
|
-
* ```
|
|
150
|
-
*/
|
|
151
|
-
isRevoked(jti: string): Promise<boolean>;
|
|
152
|
-
/**
|
|
153
|
-
* Batched revocation check - one network round trip instead of N.
|
|
154
|
-
*
|
|
155
|
-
* Useful for validating a whole family of rotated tokens, or a batch
|
|
156
|
-
* of refresh attempts, at once. Commands are grouped by hash slot
|
|
157
|
-
* (one pipeline per slot) to stay Cluster-safe, and the check fails
|
|
158
|
-
* closed: if any command errors, {@link RevocationBatchError} is thrown
|
|
159
|
-
* rather than silently treating the jti as "not revoked".
|
|
160
|
-
*
|
|
161
|
-
* @param jtis - The token/session ids to check.
|
|
162
|
-
* @returns A `Set` containing exactly the revoked jtis.
|
|
163
|
-
* @throws {RevocationBatchError} when a pipeline command fails -
|
|
164
|
-
* the caller must treat the outcome as unknown, not as "valid".
|
|
165
|
-
*
|
|
166
|
-
* @example
|
|
167
|
-
* ```ts
|
|
168
|
-
* const revoked = await revocations.isRevokedMany(['a1', 'b2', 'c3']);
|
|
169
|
-
* if (revoked.has('b2')) {
|
|
170
|
-
* // b2 must not be accepted
|
|
171
|
-
* }
|
|
172
|
-
* ```
|
|
173
|
-
*/
|
|
174
|
-
isRevokedMany(jtis: string[]): Promise<Set<string>>;
|
|
175
|
-
private key;
|
|
176
|
-
}
|
|
@@ -1,318 +0,0 @@
|
|
|
1
|
-
import { RevocationError, RevocationBatchError, redactIdentifier } from './session-errors.js';
|
|
2
|
-
import z from 'zod';
|
|
3
|
-
// ============================================================================
|
|
4
|
-
// Redis Revocations config
|
|
5
|
-
// ============================================================================
|
|
6
|
-
//
|
|
7
|
-
export const RedisRevocationStoreOptionsSchema = z.object({
|
|
8
|
-
keyPrefix: z.string().optional(),
|
|
9
|
-
});
|
|
10
|
-
/**
|
|
11
|
-
* Redis-backed revocation store. Each revoked jti is stored as
|
|
12
|
-
* `{prefix}{jti} -> reason`, with the Redis key TTL itself set to the
|
|
13
|
-
* token's remaining lifetime — expired entries are reclaimed automatically
|
|
14
|
-
* by Redis, no sweep job required.
|
|
15
|
-
*
|
|
16
|
-
* Every operation here is a single-key command, so this store works
|
|
17
|
-
* identically on standalone, Sentinel, and Cluster with no hash tags
|
|
18
|
-
* required (unlike the session store, there's no multi-key atomicity
|
|
19
|
-
* requirement to satisfy).
|
|
20
|
-
*
|
|
21
|
-
* Batched operations (`revokeMany`, `isRevokedMany`) group their commands
|
|
22
|
-
* by hash slot and issue one pipeline per slot, so they never trigger
|
|
23
|
-
* `CROSSSLOT` errors on Redis Cluster. Pipeline failures are surfaced via
|
|
24
|
-
* {@link RevocationBatchError} instead of being silently swallowed —
|
|
25
|
-
* a missed revocation is a security bug.
|
|
26
|
-
*
|
|
27
|
-
* Validation fails fast and typed: invalid records throw
|
|
28
|
-
* {@link RevocationError} before any network call, and reads fail closed
|
|
29
|
-
* (an infra error is never treated as "not revoked").
|
|
30
|
-
*
|
|
31
|
-
* @example
|
|
32
|
-
* ```ts
|
|
33
|
-
* const revocations = new RedisRevocationStore({ client });
|
|
34
|
-
*
|
|
35
|
-
* await revocations.revoke({
|
|
36
|
-
* jti: 'a1b2c3d4',
|
|
37
|
-
* reason: 'password-change',
|
|
38
|
-
* expiresAt: Math.floor(Date.now() / 1000) + 3600,
|
|
39
|
-
* });
|
|
40
|
-
*
|
|
41
|
-
* if (await revocations.isRevoked('a1b2c3d4')) {
|
|
42
|
-
* // token was rotated or revoked - reject it
|
|
43
|
-
* }
|
|
44
|
-
* ```
|
|
45
|
-
*/
|
|
46
|
-
export class RedisRevocationStore {
|
|
47
|
-
client;
|
|
48
|
-
keyPrefix;
|
|
49
|
-
/**
|
|
50
|
-
* Creates a Redis-backed revocation store.
|
|
51
|
-
*
|
|
52
|
-
* @param options - Client connection and key-prefix configuration.
|
|
53
|
-
*
|
|
54
|
-
* @example
|
|
55
|
-
* ```ts
|
|
56
|
-
* const store = new RedisRevocationStore({
|
|
57
|
-
* client,
|
|
58
|
-
* keyPrefix: 'myapp:revoked:',
|
|
59
|
-
* });
|
|
60
|
-
* ```
|
|
61
|
-
*/
|
|
62
|
-
constructor(options) {
|
|
63
|
-
this.client = options.client;
|
|
64
|
-
this.keyPrefix = options.keyPrefix ?? 'cache:revoked:';
|
|
65
|
-
}
|
|
66
|
-
/* ------------------------------------------------------------------------ */
|
|
67
|
-
/* Revoke */
|
|
68
|
-
/* ------------------------------------------------------------------------ */
|
|
69
|
-
/**
|
|
70
|
-
* Marks a jti as revoked for the remainder of its lifetime.
|
|
71
|
-
*
|
|
72
|
-
* Stores `{prefix}{jti} -> reason` with a Redis TTL equal to the
|
|
73
|
-
* record's remaining lifetime (`expiresAt - now`), so the entry is
|
|
74
|
-
* garbage-collected automatically once the original token would have
|
|
75
|
-
* expired anyway. Overwriting an existing entry extends/refreshes its
|
|
76
|
-
* TTL to the new expiry.
|
|
77
|
-
*
|
|
78
|
-
* @param record - The revocation entry (`jti`, `expiresAt`, optional
|
|
79
|
-
* `reason`). `expiresAt` must be a finite Unix-seconds timestamp in
|
|
80
|
-
* the future.
|
|
81
|
-
* @throws {RevocationError} when `record.expiresAt` is missing, not a
|
|
82
|
-
* finite number, or not in the future (fails fast instead of sending
|
|
83
|
-
* an invalid `EX` to Redis).
|
|
84
|
-
*
|
|
85
|
-
* @example
|
|
86
|
-
* ```ts
|
|
87
|
-
* await revocations.revoke({
|
|
88
|
-
* jti: 'a1b2c3d4',
|
|
89
|
-
* reason: 'logout',
|
|
90
|
-
* expiresAt: Math.floor(Date.now() / 1000) + 86400,
|
|
91
|
-
* });
|
|
92
|
-
* ```
|
|
93
|
-
*/
|
|
94
|
-
async revoke(record) {
|
|
95
|
-
const ttl = computeTtl(record);
|
|
96
|
-
try {
|
|
97
|
-
await this.client.set(this.key(record.jti), record.reason ?? '1', ttl);
|
|
98
|
-
}
|
|
99
|
-
catch (error) {
|
|
100
|
-
throw wrapStorageError(error, { jti: record.jti });
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
/**
|
|
104
|
-
* Revokes many jtis in one batched call.
|
|
105
|
-
*
|
|
106
|
-
* All records are validated up front — an invalid `expiresAt` fails
|
|
107
|
-
* before any network call is issued, rather than partway through a
|
|
108
|
-
* batch. Commands are grouped by hash slot (one pipeline per slot) so
|
|
109
|
-
* the batch stays Cluster-safe, and every pipeline result is inspected:
|
|
110
|
-
* any failed command throws {@link RevocationBatchError} listing the
|
|
111
|
-
* affected jtis, because a silently-missed revocation is a security bug.
|
|
112
|
-
*
|
|
113
|
-
* @param records - The revocation entries to create/refresh.
|
|
114
|
-
* @throws {RevocationError} when any record is invalid (validation is
|
|
115
|
-
* all-or-nothing, before any network call).
|
|
116
|
-
* @throws {RevocationBatchError} when one or more pipeline commands
|
|
117
|
-
* fail; carries the exact jtis that were not revoked.
|
|
118
|
-
*
|
|
119
|
-
* @example
|
|
120
|
-
* ```ts
|
|
121
|
-
* await revocations.revokeMany([
|
|
122
|
-
* { jti: 'a1', reason: 'logout-all', expiresAt: expiry },
|
|
123
|
-
* { jti: 'b2', reason: 'logout-all', expiresAt: expiry },
|
|
124
|
-
* ]);
|
|
125
|
-
* ```
|
|
126
|
-
*/
|
|
127
|
-
async revokeMany(records) {
|
|
128
|
-
if (records.length === 0)
|
|
129
|
-
return;
|
|
130
|
-
// Validate every record up front - fail before issuing any network
|
|
131
|
-
// calls rather than partway through a batch.
|
|
132
|
-
const ttls = records.map((record) => computeTtl(record));
|
|
133
|
-
// Group by hash slot: the jti keys are not hash-tagged, so they may
|
|
134
|
-
// scatter across Cluster slots. One pipeline per slot avoids
|
|
135
|
-
// CROSSSLOT errors.
|
|
136
|
-
const groups = new Map();
|
|
137
|
-
for (let i = 0; i < records.length; i++) {
|
|
138
|
-
const record = records[i];
|
|
139
|
-
const key = this.key(record.jti);
|
|
140
|
-
const slot = this.client.calculateSlot(key);
|
|
141
|
-
const entry = { jti: record.jti, value: record.reason ?? '1', ttl: ttls[i] };
|
|
142
|
-
const group = groups.get(slot);
|
|
143
|
-
if (group) {
|
|
144
|
-
group.push(entry);
|
|
145
|
-
}
|
|
146
|
-
else {
|
|
147
|
-
groups.set(slot, [entry]);
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
const failures = [];
|
|
151
|
-
for (const entries of groups.values()) {
|
|
152
|
-
const pipeline = this.client.pipeline();
|
|
153
|
-
for (const entry of entries) {
|
|
154
|
-
pipeline.set(this.key(entry.jti), entry.value, 'EX', entry.ttl);
|
|
155
|
-
}
|
|
156
|
-
const results = await pipeline.exec();
|
|
157
|
-
// pipeline.exec() resolves to [error, result][] - a failed command
|
|
158
|
-
// does NOT reject the pipeline promise. Check every result.
|
|
159
|
-
for (let i = 0; i < entries.length; i++) {
|
|
160
|
-
const result = results?.[i];
|
|
161
|
-
const error = Array.isArray(result) ? result[0] : undefined;
|
|
162
|
-
if (error) {
|
|
163
|
-
failures.push({ jti: entries[i].jti, error });
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
if (failures.length > 0) {
|
|
168
|
-
throw new RevocationBatchError(failures);
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
/* ------------------------------------------------------------------------ */
|
|
172
|
-
/* Check */
|
|
173
|
-
/* ------------------------------------------------------------------------ */
|
|
174
|
-
/**
|
|
175
|
-
* Checks whether a jti is currently revoked.
|
|
176
|
-
*
|
|
177
|
-
* Fail-closed: infrastructure errors are wrapped in a typed error and
|
|
178
|
-
* must NOT be treated as "not revoked".
|
|
179
|
-
*
|
|
180
|
-
* @param jti - The token/session id to check.
|
|
181
|
-
* @returns `true` when the jti has a live revocation entry.
|
|
182
|
-
* @throws {RevocationError} when the check itself fails (caller must
|
|
183
|
-
* treat the outcome as unknown).
|
|
184
|
-
*
|
|
185
|
-
* @example
|
|
186
|
-
* ```ts
|
|
187
|
-
* if (await revocations.isRevoked(token.jti)) {
|
|
188
|
-
* return 401; // token was rotated away or explicitly revoked
|
|
189
|
-
* }
|
|
190
|
-
* ```
|
|
191
|
-
*/
|
|
192
|
-
async isRevoked(jti) {
|
|
193
|
-
try {
|
|
194
|
-
const exists = await this.client.exists(this.key(jti));
|
|
195
|
-
return exists === 1;
|
|
196
|
-
}
|
|
197
|
-
catch (error) {
|
|
198
|
-
throw wrapStorageError(error, { jti });
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
/**
|
|
202
|
-
* Batched revocation check - one network round trip instead of N.
|
|
203
|
-
*
|
|
204
|
-
* Useful for validating a whole family of rotated tokens, or a batch
|
|
205
|
-
* of refresh attempts, at once. Commands are grouped by hash slot
|
|
206
|
-
* (one pipeline per slot) to stay Cluster-safe, and the check fails
|
|
207
|
-
* closed: if any command errors, {@link RevocationBatchError} is thrown
|
|
208
|
-
* rather than silently treating the jti as "not revoked".
|
|
209
|
-
*
|
|
210
|
-
* @param jtis - The token/session ids to check.
|
|
211
|
-
* @returns A `Set` containing exactly the revoked jtis.
|
|
212
|
-
* @throws {RevocationBatchError} when a pipeline command fails -
|
|
213
|
-
* the caller must treat the outcome as unknown, not as "valid".
|
|
214
|
-
*
|
|
215
|
-
* @example
|
|
216
|
-
* ```ts
|
|
217
|
-
* const revoked = await revocations.isRevokedMany(['a1', 'b2', 'c3']);
|
|
218
|
-
* if (revoked.has('b2')) {
|
|
219
|
-
* // b2 must not be accepted
|
|
220
|
-
* }
|
|
221
|
-
* ```
|
|
222
|
-
*/
|
|
223
|
-
async isRevokedMany(jtis) {
|
|
224
|
-
if (jtis.length === 0)
|
|
225
|
-
return new Set();
|
|
226
|
-
const groups = new Map();
|
|
227
|
-
for (const jti of jtis) {
|
|
228
|
-
const key = this.key(jti);
|
|
229
|
-
const slot = this.client.calculateSlot(key);
|
|
230
|
-
const group = groups.get(slot);
|
|
231
|
-
if (group) {
|
|
232
|
-
group.push(jti);
|
|
233
|
-
}
|
|
234
|
-
else {
|
|
235
|
-
groups.set(slot, [jti]);
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
const revoked = new Set();
|
|
239
|
-
for (const groupJtis of groups.values()) {
|
|
240
|
-
const pipeline = this.client.pipeline();
|
|
241
|
-
for (const jti of groupJtis) {
|
|
242
|
-
pipeline.exists(this.key(jti));
|
|
243
|
-
}
|
|
244
|
-
const results = await pipeline.exec();
|
|
245
|
-
for (let i = 0; i < groupJtis.length; i++) {
|
|
246
|
-
const result = results?.[i];
|
|
247
|
-
const error = Array.isArray(result) ? result[0] : undefined;
|
|
248
|
-
const value = Array.isArray(result) ? result[1] : undefined;
|
|
249
|
-
if (error) {
|
|
250
|
-
// Fail closed: if we can't confirm a jti's status, don't silently
|
|
251
|
-
// treat it as "not revoked".
|
|
252
|
-
throw new RevocationBatchError([{ jti: groupJtis[i], error }]);
|
|
253
|
-
}
|
|
254
|
-
if (value === 1) {
|
|
255
|
-
revoked.add(groupJtis[i]);
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
return revoked;
|
|
260
|
-
}
|
|
261
|
-
key(jti) {
|
|
262
|
-
return `${this.keyPrefix}${jti}`;
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
/* -------------------------------------------------------------------------- */
|
|
266
|
-
/* Helpers */
|
|
267
|
-
/* -------------------------------------------------------------------------- */
|
|
268
|
-
function nowSeconds() {
|
|
269
|
-
return Math.floor(Date.now() / 1000);
|
|
270
|
-
}
|
|
271
|
-
/**
|
|
272
|
-
* Validates and computes the Redis TTL for a revocation record.
|
|
273
|
-
*
|
|
274
|
-
* Throws a typed {@link RevocationError} early with a redacted message
|
|
275
|
-
* instead of letting a malformed `expiresAt` (undefined/NaN/past) turn
|
|
276
|
-
* into `Math.max(1, NaN) === NaN`, which would otherwise reach Redis as
|
|
277
|
-
* an invalid `EX` argument and fail with an opaque "value is not an
|
|
278
|
-
* integer" error deep inside the client.
|
|
279
|
-
*
|
|
280
|
-
* @param record - The revocation record to validate.
|
|
281
|
-
* @param now - Reference timestamp (Unix seconds); overridable for tests.
|
|
282
|
-
* @returns The TTL in seconds, at least 1.
|
|
283
|
-
* @throws {RevocationError} on invalid records.
|
|
284
|
-
*/
|
|
285
|
-
function computeTtl(record, now = nowSeconds()) {
|
|
286
|
-
const safe = redactIdentifier(record.jti);
|
|
287
|
-
if (!record.jti || typeof record.jti !== 'string') {
|
|
288
|
-
throw new RevocationError({ reason: 'missing_jti' });
|
|
289
|
-
}
|
|
290
|
-
if (!Number.isFinite(record.expiresAt)) {
|
|
291
|
-
throw new RevocationError({
|
|
292
|
-
reason: 'invalid_expires_at',
|
|
293
|
-
jti: safe,
|
|
294
|
-
detail: 'expiresAt must be a finite number',
|
|
295
|
-
});
|
|
296
|
-
}
|
|
297
|
-
if (record.expiresAt <= now) {
|
|
298
|
-
throw new RevocationError({
|
|
299
|
-
reason: 'expires_at_in_past',
|
|
300
|
-
jti: safe,
|
|
301
|
-
detail: 'expiresAt must be in the future',
|
|
302
|
-
});
|
|
303
|
-
}
|
|
304
|
-
return Math.max(1, record.expiresAt - now);
|
|
305
|
-
}
|
|
306
|
-
/**
|
|
307
|
-
* Wraps an underlying storage failure in a typed, redacted error so the
|
|
308
|
-
* caller can fail closed without losing the root cause.
|
|
309
|
-
*/
|
|
310
|
-
function wrapStorageError(error, context) {
|
|
311
|
-
if (error instanceof RevocationError)
|
|
312
|
-
return error;
|
|
313
|
-
return new RevocationError({
|
|
314
|
-
reason: 'storage_failure',
|
|
315
|
-
jti: redactIdentifier(context.jti),
|
|
316
|
-
cause: error instanceof Error ? error.message : String(error),
|
|
317
|
-
});
|
|
318
|
-
}
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
-- cleanup-index.lua (version 1)
|
|
2
|
-
-- Lazily removes stale members (whose session records no longer exist,
|
|
3
|
-
-- e.g. expired and naturally reclaimed by Redis TTL) from the user index.
|
|
4
|
-
-- Bounded: the caller chunks the user's jtis into batches.
|
|
5
|
-
--
|
|
6
|
-
-- KEYS[1] = user session index key
|
|
7
|
-
-- KEYS[2..] = session record keys (same slot as KEYS[1])
|
|
8
|
-
--
|
|
9
|
-
-- ARGV[1..] = matching jtis (index i pairs with KEYS[i + 1])
|
|
10
|
-
--
|
|
11
|
-
-- Returns: the jtis removed from the index.
|
|
12
|
-
local removed = {}
|
|
13
|
-
|
|
14
|
-
for i = 2, #KEYS do
|
|
15
|
-
if redis.call('EXISTS', KEYS[i]) == 0 then
|
|
16
|
-
redis.call('ZREM', KEYS[1], ARGV[i - 1])
|
|
17
|
-
table.insert(removed, ARGV[i - 1])
|
|
18
|
-
end
|
|
19
|
-
end
|
|
20
|
-
|
|
21
|
-
return removed
|
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
-- conditional-update-encrypted.lua (version 1) - encrypted (v2) envelopes
|
|
2
|
-
-- Optimistic-concurrency patch update for encrypted sessions.
|
|
3
|
-
--
|
|
4
|
-
-- The app decrypts the record, applies the whitelisted patch, re-encrypts
|
|
5
|
-
-- and passes the new envelope. The script CAS's the plaintext version
|
|
6
|
-
-- mirror and enforces status/expiry; the absolute expiry mirror is
|
|
7
|
-
-- preserved from the stored record (identity/security fields can never be
|
|
8
|
-
-- changed through update).
|
|
9
|
-
--
|
|
10
|
-
-- KEYS[1] = session record key
|
|
11
|
-
--
|
|
12
|
-
-- ARGV[1] = expected version ('' = no check)
|
|
13
|
-
-- ARGV[2] = new serialized envelope (app-built, re-encrypted)
|
|
14
|
-
-- ARGV[3] = new version (expected + 1)
|
|
15
|
-
-- ARGV[4] = new TTL (clamped >= 1)
|
|
16
|
-
--
|
|
17
|
-
-- Returns:
|
|
18
|
-
-- {1, newVersion} applied
|
|
19
|
-
-- 0 not found
|
|
20
|
-
-- -1 consumed or revoked
|
|
21
|
-
-- -2 expired (record removed)
|
|
22
|
-
-- -3 version conflict
|
|
23
|
-
-- 6 envelope is plain (use the plain path)
|
|
24
|
-
local raw = redis.call('GET', KEYS[1])
|
|
25
|
-
|
|
26
|
-
if not raw then
|
|
27
|
-
return 0
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
local env = cjson.decode(raw)
|
|
31
|
-
|
|
32
|
-
if env.v ~= 2 then
|
|
33
|
-
return 6
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
if env.st ~= 'active' then
|
|
37
|
-
return -1
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
local now = tonumber(redis.call('TIME')[1])
|
|
41
|
-
|
|
42
|
-
if tonumber(env.exp) <= now then
|
|
43
|
-
redis.call('DEL', KEYS[1])
|
|
44
|
-
return -2
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
if ARGV[1] ~= '' and tostring(env.ver) ~= ARGV[1] then
|
|
48
|
-
return -3
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
local newEnv = cjson.decode(ARGV[2])
|
|
52
|
-
newEnv.st = 'active'
|
|
53
|
-
newEnv.ver = tonumber(ARGV[3])
|
|
54
|
-
newEnv.exp = env.exp
|
|
55
|
-
newEnv.rn = env.rn
|
|
56
|
-
newEnv.rj = env.rj
|
|
57
|
-
|
|
58
|
-
redis.call('SET', KEYS[1], cjson.encode(newEnv), 'EX', tonumber(ARGV[4]))
|
|
59
|
-
|
|
60
|
-
return { 1, ARGV[3] }
|
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
-- conditional-update.lua (version 1) - plain (v1) envelopes
|
|
2
|
-
-- Optimistic-concurrency patch update. The script is the authority for:
|
|
3
|
-
-- - existence / status / expiry checks
|
|
4
|
-
-- - the version compare-and-swap
|
|
5
|
-
-- - the mutable-field whitelist (identity and security-critical fields
|
|
6
|
-
-- are never touched, regardless of what the caller sends)
|
|
7
|
-
--
|
|
8
|
-
-- KEYS[1] = session record key
|
|
9
|
-
--
|
|
10
|
-
-- ARGV[1] = expected version ('' = no check)
|
|
11
|
-
-- ARGV[2] = patch JSON: only { deviceId?, ipAddress?, userAgent?, metadata? }
|
|
12
|
-
-- may be present; other keys are ignored (never applied)
|
|
13
|
-
--
|
|
14
|
-
-- Returns:
|
|
15
|
-
-- {1, newVersion} applied
|
|
16
|
-
-- 0 not found
|
|
17
|
-
-- -1 consumed or revoked
|
|
18
|
-
-- -2 expired (record removed)
|
|
19
|
-
-- -3 version conflict
|
|
20
|
-
-- 5 envelope is encrypted (use the encrypted path)
|
|
21
|
-
local raw = redis.call('GET', KEYS[1])
|
|
22
|
-
|
|
23
|
-
if not raw then
|
|
24
|
-
return 0
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
local env = cjson.decode(raw)
|
|
28
|
-
|
|
29
|
-
if env.v ~= 1 then
|
|
30
|
-
return 5
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
local s = env.s
|
|
34
|
-
|
|
35
|
-
if s.status ~= 'active' then
|
|
36
|
-
return -1
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
local now = tonumber(redis.call('TIME')[1])
|
|
40
|
-
|
|
41
|
-
if tonumber(s.absoluteExpiresAt) <= now then
|
|
42
|
-
redis.call('DEL', KEYS[1])
|
|
43
|
-
return -2
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
if ARGV[1] ~= '' and tostring(s.version) ~= ARGV[1] then
|
|
47
|
-
return -3
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
local patch = cjson.decode(ARGV[2])
|
|
51
|
-
|
|
52
|
-
if patch.deviceId ~= nil then s.deviceId = patch.deviceId end
|
|
53
|
-
if patch.ipAddress ~= nil then s.ipAddress = patch.ipAddress end
|
|
54
|
-
if patch.userAgent ~= nil then s.userAgent = patch.userAgent end
|
|
55
|
-
if patch.metadata ~= nil then s.metadata = patch.metadata end
|
|
56
|
-
|
|
57
|
-
s.version = tonumber(s.version) + 1
|
|
58
|
-
|
|
59
|
-
local ttl = math.max(1, tonumber(s.absoluteExpiresAt) - now)
|
|
60
|
-
|
|
61
|
-
redis.call('SET', KEYS[1], cjson.encode(env), 'EX', ttl)
|
|
62
|
-
|
|
63
|
-
return { 1, tostring(s.version) }
|