@astram/guard 0.1.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/LICENSE +21 -0
- package/README.md +84 -0
- package/dist/authentication/argon2id-password-hasher.d.ts +61 -0
- package/dist/authentication/argon2id-password-hasher.d.ts.map +1 -0
- package/dist/authentication/argon2id-password-hasher.js +108 -0
- package/dist/boot/boot-sequence.d.ts +43 -0
- package/dist/boot/boot-sequence.d.ts.map +1 -0
- package/dist/boot/boot-sequence.js +90 -0
- package/dist/boot/boot.module.d.ts +8 -0
- package/dist/boot/boot.module.d.ts.map +1 -0
- package/dist/boot/boot.module.js +24 -0
- package/dist/config/app-config.d.ts +112 -0
- package/dist/config/app-config.d.ts.map +1 -0
- package/dist/config/app-config.js +94 -0
- package/dist/config/config.module.d.ts +12 -0
- package/dist/config/config.module.d.ts.map +1 -0
- package/dist/config/config.module.js +35 -0
- package/dist/enforcement/classification.decorator.d.ts +41 -0
- package/dist/enforcement/classification.decorator.d.ts.map +1 -0
- package/dist/enforcement/classification.decorator.js +46 -0
- package/dist/enforcement/core-seam.d.ts +40 -0
- package/dist/enforcement/core-seam.d.ts.map +1 -0
- package/dist/enforcement/core-seam.js +38 -0
- package/dist/enforcement/enforcement.module.d.ts +18 -0
- package/dist/enforcement/enforcement.module.d.ts.map +1 -0
- package/dist/enforcement/enforcement.module.js +40 -0
- package/dist/enforcement/global-enforcement.guard.d.ts +31 -0
- package/dist/enforcement/global-enforcement.guard.d.ts.map +1 -0
- package/dist/enforcement/global-enforcement.guard.js +76 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +88 -0
- package/dist/module/shield.module.d.ts +79 -0
- package/dist/module/shield.module.d.ts.map +1 -0
- package/dist/module/shield.module.js +108 -0
- package/dist/session/crypto-adapters.d.ts +60 -0
- package/dist/session/crypto-adapters.d.ts.map +1 -0
- package/dist/session/crypto-adapters.js +80 -0
- package/dist/session/index.d.ts +17 -0
- package/dist/session/index.d.ts.map +1 -0
- package/dist/session/index.js +32 -0
- package/dist/session/mariadb-session-record-store.d.ts +50 -0
- package/dist/session/mariadb-session-record-store.d.ts.map +1 -0
- package/dist/session/mariadb-session-record-store.js +162 -0
- package/dist/session/redis-hot-session-store.d.ts +30 -0
- package/dist/session/redis-hot-session-store.d.ts.map +1 -0
- package/dist/session/redis-hot-session-store.js +102 -0
- package/package.json +52 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* `RedisHotSessionStore` — the concrete hot {@link HotSessionStore} for
|
|
4
|
+
* `@astram/guard`, backed by `ioredis` against the Layer-2 Redis (ADR-0013 hot
|
|
5
|
+
* tier). Serves the per-request read path and is (re)populated at lifecycle events.
|
|
6
|
+
*
|
|
7
|
+
* ADR-0003 (why HERE, not Core): `ioredis` is a real runtime dependency; Core
|
|
8
|
+
* defines only the PORT. Injected into `createSessionAuthority({ hotStore })` and
|
|
9
|
+
* used by the free `readHotSession(hotStore, id)` per-request path.
|
|
10
|
+
*
|
|
11
|
+
* ⛔ THE CRITICAL CONTRACT (ADR-0013/0016): `getHotSession` MUST distinguish
|
|
12
|
+
* - `{ kind: 'found', state }` — a live hot record,
|
|
13
|
+
* - `{ kind: 'absent' }` — Redis is UP and simply has no such key (a miss),
|
|
14
|
+
* - `{ kind: 'unavailable' }` — Redis CANNOT answer (down/timeout/refused).
|
|
15
|
+
* The engine fails CLOSED on `unavailable` and NEVER falls back to the durable
|
|
16
|
+
* tier. Conflating unavailable→absent would fail open on a blip; conflating
|
|
17
|
+
* absent→unavailable would log users out on a normal miss. So we catch connection
|
|
18
|
+
* errors and map ONLY those to `unavailable`; a clean `null` GET reply is `absent`.
|
|
19
|
+
*/
|
|
20
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
21
|
+
exports.RedisHotSessionStore = exports.SHIELD_HOT_SESSION_STORE = void 0;
|
|
22
|
+
/** The DI token the store is bound under. */
|
|
23
|
+
exports.SHIELD_HOT_SESSION_STORE = Symbol('SHIELD_HOT_SESSION_STORE');
|
|
24
|
+
/**
|
|
25
|
+
* A connection-level failure — Redis genuinely cannot answer. ioredis surfaces
|
|
26
|
+
* these as errors whose `code` is a socket/connection code, or (when the client
|
|
27
|
+
* is offline and not queuing) a MaxRetriesPerRequest/"Connection is closed"
|
|
28
|
+
* message. We treat any GET-time error as "cannot answer" → `unavailable`
|
|
29
|
+
* (fail-closed) rather than risk mis-reading it as a normal miss.
|
|
30
|
+
*/
|
|
31
|
+
function isConnectionFailure(err) {
|
|
32
|
+
if (err === null || typeof err !== 'object')
|
|
33
|
+
return false;
|
|
34
|
+
const e = err;
|
|
35
|
+
const code = typeof e.code === 'string' ? e.code : '';
|
|
36
|
+
const message = typeof e.message === 'string' ? e.message : '';
|
|
37
|
+
const CONN_CODES = new Set([
|
|
38
|
+
'ECONNREFUSED',
|
|
39
|
+
'ECONNRESET',
|
|
40
|
+
'ETIMEDOUT',
|
|
41
|
+
'EPIPE',
|
|
42
|
+
'ENOTFOUND',
|
|
43
|
+
'EAI_AGAIN',
|
|
44
|
+
'NR_CLOSED',
|
|
45
|
+
]);
|
|
46
|
+
if (CONN_CODES.has(code))
|
|
47
|
+
return true;
|
|
48
|
+
return (/connection is closed/i.test(message) ||
|
|
49
|
+
/max retries per request/i.test(message) ||
|
|
50
|
+
/connection to redis/i.test(message) ||
|
|
51
|
+
/stream isn't writeable/i.test(message));
|
|
52
|
+
}
|
|
53
|
+
class RedisHotSessionStore {
|
|
54
|
+
redis;
|
|
55
|
+
constructor(redis) {
|
|
56
|
+
this.redis = redis;
|
|
57
|
+
}
|
|
58
|
+
async putHotSession(key, state, ttlMs) {
|
|
59
|
+
// JSON blob under the key with a PX (ms) TTL = the access-token TTL. cachedFacts
|
|
60
|
+
// is an opaque slot — we serialize whatever the engine passes (null at seed).
|
|
61
|
+
const payload = JSON.stringify(state);
|
|
62
|
+
await this.redis.set(key, payload, 'PX', Math.max(1, Math.floor(ttlMs)));
|
|
63
|
+
}
|
|
64
|
+
async getHotSession(key) {
|
|
65
|
+
let raw;
|
|
66
|
+
try {
|
|
67
|
+
raw = await this.redis.get(key);
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
// ⛔ Cannot answer → fail-closed signal. NEVER a silent miss.
|
|
71
|
+
if (isConnectionFailure(err))
|
|
72
|
+
return { kind: 'unavailable' };
|
|
73
|
+
// Any other error is also treated as cannot-answer (conservative: never
|
|
74
|
+
// downgrade an unknown fault to a normal `absent` that could fail open).
|
|
75
|
+
return { kind: 'unavailable' };
|
|
76
|
+
}
|
|
77
|
+
if (raw === null)
|
|
78
|
+
return { kind: 'absent' }; // Redis is UP; genuine miss.
|
|
79
|
+
try {
|
|
80
|
+
const state = JSON.parse(raw);
|
|
81
|
+
if (state === null || typeof state !== 'object' || typeof state.sessionId !== 'string') {
|
|
82
|
+
// Corrupt payload — treat as a miss (the value will be re-populated at the
|
|
83
|
+
// next refresh); do NOT surface `found` with a malformed state.
|
|
84
|
+
return { kind: 'absent' };
|
|
85
|
+
}
|
|
86
|
+
return { kind: 'found', state };
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return { kind: 'absent' };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
async deleteHotSession(key) {
|
|
93
|
+
try {
|
|
94
|
+
await this.redis.del(key);
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
// Best-effort delete; a delete against a down Redis is retried at the next
|
|
98
|
+
// lifecycle event, and the durable revoke already refuses future refreshes.
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
exports.RedisHotSessionStore = RedisHotSessionStore;
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@astram/guard",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"description": "Astram framework — the enforcement guard + real-world adapters (the 'hands'). One global deny-by-default gate every request passes through (ADR-0002), classification decorators, boot-pool assertion (ADR-0018), the forRoot() registration seam, and the DB/Redis/crypto adapters that connect the sealed core to a real backend. Depends on @astram/core (ADR-0003, ADR-0023).",
|
|
6
|
+
"type": "commonjs",
|
|
7
|
+
"main": "dist/index.js",
|
|
8
|
+
"types": "dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"default": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist"
|
|
17
|
+
],
|
|
18
|
+
"sideEffects": false,
|
|
19
|
+
"publishConfig": {
|
|
20
|
+
"access": "public"
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "tsc -p tsconfig.build.json",
|
|
24
|
+
"lint": "eslint \"src/**/*.ts\" \"test/**/*.ts\"",
|
|
25
|
+
"test": "vitest run"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@astram/core": "workspace:*",
|
|
29
|
+
"argon2": "^0.41.1",
|
|
30
|
+
"ioredis": "^5.4.1",
|
|
31
|
+
"mariadb": "^3.4.0"
|
|
32
|
+
},
|
|
33
|
+
"peerDependencies": {
|
|
34
|
+
"@nestjs/common": "^11",
|
|
35
|
+
"@nestjs/core": "^11",
|
|
36
|
+
"reflect-metadata": "^0.2",
|
|
37
|
+
"rxjs": "^7"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@nestjs/common": "^11.0.0",
|
|
41
|
+
"@nestjs/core": "^11.0.0",
|
|
42
|
+
"@nestjs/testing": "^11.0.0",
|
|
43
|
+
"@types/node": "^22.0.0",
|
|
44
|
+
"@types/supertest": "^6.0.2",
|
|
45
|
+
"reflect-metadata": "^0.2.2",
|
|
46
|
+
"rxjs": "^7.8.1",
|
|
47
|
+
"supertest": "^7.0.0",
|
|
48
|
+
"typescript": "^5.7.0",
|
|
49
|
+
"unplugin-swc": "^1.5.1",
|
|
50
|
+
"vitest": "^2.1.8"
|
|
51
|
+
}
|
|
52
|
+
}
|