@cosmicdrift/kumiko-bundled-features 0.285.2 → 0.286.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.
@@ -0,0 +1,136 @@
1
+ // Generic Redis-backed pre-activation token store: bidirectional
2
+ // token↔subject mapping plus single-use burn/unburn semantics. Extracted
3
+ // from auth-email-password/signup-token-store.ts and
4
+ // auth-email-password/invite-token-store.ts (infra#446) — both were the
5
+ // same Redis layout, differing only in their key prefixes and which field
6
+ // (email vs. invitationId) plays the "subject" role.
7
+ //
8
+ // Public subpath export (./shared/single-use-token-store in package.json):
9
+ // offlot-app (a separate repo, external consumer) carries its own copy of
10
+ // this exact logic under `src/features/waitlist/signup-token-store.ts`,
11
+ // with a comment noting it "must stay byte-compatible with
12
+ // auth-email-password/signup-token-store" because signup-confirm resolves
13
+ // tokens via the same Redis key layout. offlot-app#418 (separate issue,
14
+ // after this ships) will replace that copy with
15
+ // `createSingleUseTokenStore({ tokenPrefix: "signup:by-token:", subjectPrefix:
16
+ // "signup:by-email:", burnPrefix: "signup:burn:" })` — i.e. the exact same
17
+ // prefix strings the framework's own signup store below uses, so both stay
18
+ // byte-compatible by construction instead of by hand-copied logic.
19
+ //
20
+ // Token material: opaque random 256-bit (e.g. crypto.randomBytes,
21
+ // base64url-encoded). Not designed for human typing — the subject clicks a
22
+ // mail link, nobody types the token.
23
+ //
24
+ // Why a server-side lookup at all (not a stateless HMAC-signed token, like
25
+ // password-reset/email-verification)? Some subjects (e.g. a not-yet-created
26
+ // signup) have no stable identity claim yet for an HMAC to bind to. We map
27
+ // token ↔ subject bidirectionally in Redis and delete the pair on confirm.
28
+ // Bidirectional because:
29
+ // - forward (by-token): confirm/accept needs token → subject
30
+ // - reverse (by-subject): the create/request flow needs to know whether a
31
+ // token is still live for this subject, so a resend can invalidate it
32
+ // instead of leaving two valid tokens around
33
+ //
34
+ // Every key is derived from sha256(token), never the raw token — Redis key
35
+ // names, MONITOR output, replica traffic, and memory/backup dumps never
36
+ // carry the bearer secret in the clear (#2174). The by-subject entry stores
37
+ // the *hash* of the live token, not the token itself, so it can only be
38
+ // used to invalidate (delete the matching forward entry) — never to
39
+ // recover or resend the original token. A resend therefore always mints a
40
+ // fresh token and invalidates the previous one, rather than reusing the
41
+ // same link.
42
+ //
43
+ // Single-use burn: `SET burn:<hash> "1" EX 3600 NX` — first caller to
44
+ // confirm/accept wins ("OK"), a concurrent second tab racing the same link
45
+ // gets "already-used". TTL is 1h (short enough that the burn-key doesn't
46
+ // permanently tax Redis, long enough to catch replays inside any realistic
47
+ // race window).
48
+
49
+ import { createHash } from "node:crypto";
50
+ import type Redis from "ioredis";
51
+
52
+ function hashToken(token: string): string {
53
+ return createHash("sha256").update(token).digest("hex");
54
+ }
55
+
56
+ export function createSingleUseTokenStore(prefixes: {
57
+ readonly tokenPrefix: string;
58
+ readonly subjectPrefix: string;
59
+ readonly burnPrefix: string;
60
+ }) {
61
+ function tokenKey(token: string): string {
62
+ return `${prefixes.tokenPrefix}${hashToken(token)}`;
63
+ }
64
+ // Builds the forward key from an already-hashed value (e.g. read back
65
+ // from the by-subject entry) — does NOT hash again. Kept separate from
66
+ // tokenKey() (which hashes a raw token) so a double-hash mistake is
67
+ // visible at the call site instead of silently no-op'ing a delete.
68
+ function forwardKeyForHash(tokenHash: string): string {
69
+ return `${prefixes.tokenPrefix}${tokenHash}`;
70
+ }
71
+ function subjectKey(subjectId: string): string {
72
+ return `${prefixes.subjectPrefix}${subjectId}`;
73
+ }
74
+ function burnKey(token: string): string {
75
+ return `${prefixes.burnPrefix}${hashToken(token)}`;
76
+ }
77
+
78
+ // Stores the pair bidirectionally and sets TTL on both keys. Idempotent —
79
+ // re-writing the same token/subject pair is fine. The by-subject value is
80
+ // the token's hash, not the token — see file header.
81
+ async function store(
82
+ redis: Redis,
83
+ args: { subjectId: string; token: string; ttlSeconds: number },
84
+ ): Promise<void> {
85
+ await Promise.all([
86
+ redis.set(tokenKey(args.token), args.subjectId, "EX", args.ttlSeconds),
87
+ redis.set(subjectKey(args.subjectId), hashToken(args.token), "EX", args.ttlSeconds),
88
+ ]);
89
+ }
90
+
91
+ // Lookup: subject for a token. Null when the token doesn't (or no longer)
92
+ // exist (expired, already consumed, or invalid).
93
+ async function getSubjectForToken(redis: Redis, token: string): Promise<string | null> {
94
+ return redis.get(tokenKey(token));
95
+ }
96
+
97
+ // Deletes a still-live token for this subject, if one exists — both the
98
+ // forward entry (built from the hash already stored in the by-subject
99
+ // entry, never recovers the raw token) and the by-subject entry itself.
100
+ // Returns whether a live token existed. Deleting the by-subject entry
101
+ // here too (not just the forward key) avoids leaving a dangling hash
102
+ // pointing at nothing if the caller crashes before the following store().
103
+ async function invalidateExistingBySubject(redis: Redis, subjectId: string): Promise<boolean> {
104
+ const existingHash = await redis.get(subjectKey(subjectId));
105
+ if (existingHash === null) return false;
106
+ await Promise.all([
107
+ redis.del(forwardKeyForHash(existingHash)),
108
+ redis.del(subjectKey(subjectId)),
109
+ ]);
110
+ return true;
111
+ }
112
+
113
+ // SET NX EX — atomic check-and-set. Returns "OK" when the key is new,
114
+ // null when it's already there.
115
+ async function burn(redis: Redis, token: string): Promise<"burned" | "already-used"> {
116
+ const result = await redis.set(burnKey(token), "1", "EX", 3600, "NX");
117
+ return result === "OK" ? "burned" : "already-used";
118
+ }
119
+
120
+ // Cleanup after a successful confirm/accept — both lookup keys. The
121
+ // burn-key stays (prevents a replay for the rest of the burn TTL).
122
+ async function deleteBoth(
123
+ redis: Redis,
124
+ args: { subjectId: string; token: string },
125
+ ): Promise<void> {
126
+ await Promise.all([redis.del(tokenKey(args.token)), redis.del(subjectKey(args.subjectId))]);
127
+ }
128
+
129
+ // Burn-release for a failed confirm/accept path (e.g. a DB error) so a
130
+ // legitimate retry isn't blocked by a stale burn marker.
131
+ async function unburn(redis: Redis, token: string): Promise<void> {
132
+ await redis.del(burnKey(token));
133
+ }
134
+
135
+ return { store, getSubjectForToken, invalidateExistingBySubject, burn, deleteBoth, unburn };
136
+ }