@jini-ai/user-management 0.3.4 → 0.6.11
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 +20 -1
- package/dist/server/auth-challenge.d.ts +111 -0
- package/dist/server/auth-challenge.js +64 -0
- package/dist/server/index.d.ts +1 -0
- package/dist/server/index.js +1 -0
- package/package.json +12 -12
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.6.11
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Pure email sign-in challenge primitive (member auth, shard A).
|
|
8
|
+
- Updated dependencies
|
|
9
|
+
- Updated dependencies
|
|
10
|
+
- @jini-ai/core@0.6.11
|
|
11
|
+
|
|
12
|
+
## 0.6.10
|
|
13
|
+
|
|
14
|
+
### Patch Changes
|
|
15
|
+
|
|
16
|
+
- 82ed2d3: Members requires FetchQueryProvider. Await the authoritative roster refresh before reporting a completed disable, and restore settlement rationale beside the keyed panel/loading logic.
|
|
17
|
+
- ea8d883: Preserve successful member-disable state and detail when the follow-up roster read fails.
|
|
18
|
+
- Updated dependencies [22f0619]
|
|
19
|
+
- Updated dependencies [82ed2d3]
|
|
20
|
+
- Updated dependencies [5a3de86]
|
|
21
|
+
- @jini-ai/core@0.6.10
|
|
22
|
+
|
|
3
23
|
## 0.3.4 — 2026-10-22
|
|
4
24
|
|
|
5
25
|
- 2026-10-22: Named-argument identity guards, optional translate with a custom Users hook, cancellation kept across confirm-to-run, coverage.
|
|
@@ -77,7 +97,6 @@
|
|
|
77
97
|
- Add `sessionTtlMs` and replaceable authentication messages options; preserve the 30-day default. Invalid lifetimes raise RangeError before persistence. Sessions retain required workspace scope because repositories require it.
|
|
78
98
|
- Merge the job-specific test configuration into the existing config and rename its setup file.
|
|
79
99
|
|
|
80
|
-
|
|
81
100
|
- Remove specification identifiers from admin safety errors and direct-grant tool text; authorization and ownership checks retain their behavior.
|
|
82
101
|
|
|
83
102
|
- Reject reserved wildcard registration with a typed validation error and converge
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/** Pure email-challenge primitives. Hosts own persistence, delivery and atomic consumption. */
|
|
2
|
+
import type { PasswordHasherPort } from "../core/ports.js";
|
|
3
|
+
import type { SessionTokenPort } from "../core/runtime-ports.js";
|
|
4
|
+
/** Persisted metadata only; raw link tokens and codes must never enter this record. */
|
|
5
|
+
export interface AuthChallengeRecord {
|
|
6
|
+
readonly id: string;
|
|
7
|
+
readonly workspaceId: string;
|
|
8
|
+
readonly realm: string;
|
|
9
|
+
readonly purpose: string;
|
|
10
|
+
readonly tokenHash: string;
|
|
11
|
+
/** Null for legacy link-only challenges. */
|
|
12
|
+
readonly codeHash: string | null;
|
|
13
|
+
readonly expiresAt: string;
|
|
14
|
+
readonly consumedAt?: string | null;
|
|
15
|
+
/**
|
|
16
|
+
* Code attempts spent. Each is reserved before its code is verified, and a correct code
|
|
17
|
+
* consumes the challenge, so on a live challenge every counted attempt failed or is in flight.
|
|
18
|
+
*/
|
|
19
|
+
readonly failedAttempts: number;
|
|
20
|
+
}
|
|
21
|
+
export interface ChallengeScope {
|
|
22
|
+
readonly workspaceId: string;
|
|
23
|
+
readonly realm: string;
|
|
24
|
+
readonly purpose: string;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Storage contract only: implementations live in the host. Reads are scope-bound.
|
|
28
|
+
* Link completion: findByTokenHash -> decideChallenge -> consume.
|
|
29
|
+
* Code completion: findById -> decideChallenge -> reserveCodeAttempt -> verifyChallengeCode -> consume.
|
|
30
|
+
* Any false or "invalid" step gives the caller the same uniform failure.
|
|
31
|
+
*/
|
|
32
|
+
export interface ChallengeStorePort {
|
|
33
|
+
save(record: AuthChallengeRecord): Promise<void>;
|
|
34
|
+
findById(input: ChallengeScope & {
|
|
35
|
+
id: string;
|
|
36
|
+
}): Promise<AuthChallengeRecord | null>;
|
|
37
|
+
findByTokenHash(input: ChallengeScope & {
|
|
38
|
+
tokenHash: string;
|
|
39
|
+
}): Promise<AuthChallengeRecord | null>;
|
|
40
|
+
/**
|
|
41
|
+
* Atomically (lock/CAS) increment failedAttempts while decideChallenge is "ok" on current
|
|
42
|
+
* state; false when none remain. It runs BEFORE the code is verified, so parallel guesses
|
|
43
|
+
* cannot each read a stale counter and outrun the limit. Never resets the counter.
|
|
44
|
+
*/
|
|
45
|
+
reserveCodeAttempt(input: ChallengeScope & {
|
|
46
|
+
id: string;
|
|
47
|
+
nowIso: string;
|
|
48
|
+
maxAttempts?: number;
|
|
49
|
+
}): Promise<boolean>;
|
|
50
|
+
/**
|
|
51
|
+
* Atomically stamp consumedAt while in scope, unconsumed and unexpired; link and code share
|
|
52
|
+
* this one stamp. Attempts are not rechecked: link completion checked them in decideChallenge
|
|
53
|
+
* and code completion already spent one. Returns false if another completion won.
|
|
54
|
+
*/
|
|
55
|
+
consume(input: ChallengeScope & {
|
|
56
|
+
id: string;
|
|
57
|
+
nowIso: string;
|
|
58
|
+
}): Promise<boolean>;
|
|
59
|
+
}
|
|
60
|
+
export interface ChallengeSecretDependencies {
|
|
61
|
+
/** Inject NodeSessionTokens (32 cryptographic random bytes, SHA-256 digest), or equivalent. */
|
|
62
|
+
readonly tokens: SessionTokenPort;
|
|
63
|
+
/** Inject crypto.randomInt via ({ min, max }) => randomInt(min, max); max is exclusive. */
|
|
64
|
+
readonly randomInt: (input: {
|
|
65
|
+
min: number;
|
|
66
|
+
max: number;
|
|
67
|
+
}) => number;
|
|
68
|
+
}
|
|
69
|
+
export interface ChallengeSecrets {
|
|
70
|
+
readonly token: string;
|
|
71
|
+
readonly tokenHash: string;
|
|
72
|
+
readonly code: string;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Generate one 256-bit link bearer and uniform six-digit code through injected entropy.
|
|
76
|
+
* Only tokenHash and hashChallengeCode's result may be persisted. No clock or I/O is read.
|
|
77
|
+
* @throws RangeError if the supplied random-integer adapter violates its bounds.
|
|
78
|
+
* @complexity O(1), with one token generation/digest and one bounded integer draw.
|
|
79
|
+
*/
|
|
80
|
+
export declare function newChallengeSecrets({ tokens, randomInt }: ChallengeSecretDependencies, _options?: Record<string, never>): ChallengeSecrets;
|
|
81
|
+
/**
|
|
82
|
+
* Hash the low-entropy code using the existing password hasher, never a fast digest.
|
|
83
|
+
* @throws RangeError for malformed codes; hashing adapter errors propagate.
|
|
84
|
+
* @complexity O(1) adapter calls; hashing cost belongs to PasswordHasherPort.
|
|
85
|
+
*/
|
|
86
|
+
export declare function hashChallengeCode({ code, hasher }: {
|
|
87
|
+
code: string;
|
|
88
|
+
hasher: PasswordHasherPort;
|
|
89
|
+
}, _options?: Record<string, never>): Promise<string>;
|
|
90
|
+
/**
|
|
91
|
+
* Verify through PasswordHasherPort's timing-safe verifier (Argon2PasswordHasher in Node).
|
|
92
|
+
* Legacy link-only hashes and malformed codes return false. Adapter errors propagate.
|
|
93
|
+
* @complexity O(1) adapter calls; no repository access or attempt mutation.
|
|
94
|
+
*/
|
|
95
|
+
export declare function verifyChallengeCode({ code, hash, hasher }: {
|
|
96
|
+
code: string;
|
|
97
|
+
hash: string | null;
|
|
98
|
+
hasher: PasswordHasherPort;
|
|
99
|
+
}, _options?: Record<string, never>): Promise<boolean>;
|
|
100
|
+
/**
|
|
101
|
+
* Decide metadata eligibility only; callers must also verify the token/code and use the
|
|
102
|
+
* store's atomic steps above. nowIso is the caller's injected clock value. Expiry is exclusive.
|
|
103
|
+
* Every denial returns the same result, including malformed timestamps/counters.
|
|
104
|
+
* @complexity O(1) for fixed-size metadata; no I/O, clock reads or input mutation.
|
|
105
|
+
*/
|
|
106
|
+
export declare function decideChallenge({ record, workspaceId, realm, purpose, nowIso, maxAttempts }: ChallengeScope & {
|
|
107
|
+
record: AuthChallengeRecord | null;
|
|
108
|
+
nowIso: string;
|
|
109
|
+
maxAttempts?: number;
|
|
110
|
+
}, _options?: Record<string, never>): "ok" | "invalid";
|
|
111
|
+
//# sourceMappingURL=auth-challenge.d.ts.map
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generate one 256-bit link bearer and uniform six-digit code through injected entropy.
|
|
3
|
+
* Only tokenHash and hashChallengeCode's result may be persisted. No clock or I/O is read.
|
|
4
|
+
* @throws RangeError if the supplied random-integer adapter violates its bounds.
|
|
5
|
+
* @complexity O(1), with one token generation/digest and one bounded integer draw.
|
|
6
|
+
*/
|
|
7
|
+
export function newChallengeSecrets({ tokens, randomInt }, _options = {}) {
|
|
8
|
+
const value = randomInt({ min: 0, max: 1_000_000 });
|
|
9
|
+
if (!Number.isInteger(value) || value < 0 || value >= 1_000_000) {
|
|
10
|
+
throw new RangeError("Challenge entropy must be an integer in [0, 1000000).");
|
|
11
|
+
}
|
|
12
|
+
const token = tokens.newToken({});
|
|
13
|
+
return { token, tokenHash: tokens.hashToken({ rawToken: token }), code: String(value).padStart(6, "0") };
|
|
14
|
+
}
|
|
15
|
+
/** Exact ASCII shape; without the m flag, $ also refuses trailing line terminators. O(1). */
|
|
16
|
+
function isChallengeCode(code) {
|
|
17
|
+
return /^[0-9]{6}$/.test(code);
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Hash the low-entropy code using the existing password hasher, never a fast digest.
|
|
21
|
+
* @throws RangeError for malformed codes; hashing adapter errors propagate.
|
|
22
|
+
* @complexity O(1) adapter calls; hashing cost belongs to PasswordHasherPort.
|
|
23
|
+
*/
|
|
24
|
+
export function hashChallengeCode({ code, hasher }, _options = {}) {
|
|
25
|
+
if (!isChallengeCode(code))
|
|
26
|
+
throw new RangeError("Challenge code must contain exactly six ASCII digits.");
|
|
27
|
+
return hasher.hash({ password: code });
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Verify through PasswordHasherPort's timing-safe verifier (Argon2PasswordHasher in Node).
|
|
31
|
+
* Legacy link-only hashes and malformed codes return false. Adapter errors propagate.
|
|
32
|
+
* @complexity O(1) adapter calls; no repository access or attempt mutation.
|
|
33
|
+
*/
|
|
34
|
+
export async function verifyChallengeCode({ code, hash, hasher }, _options = {}) {
|
|
35
|
+
if (!hash || !isChallengeCode(code))
|
|
36
|
+
return false;
|
|
37
|
+
return hasher.verify({ hash, password: code });
|
|
38
|
+
}
|
|
39
|
+
/** Invalid counters or limits deny access; O(1), without changing the stored counter. */
|
|
40
|
+
function hasAttemptsRemaining(failedAttempts, maxAttempts) {
|
|
41
|
+
return Number.isSafeInteger(failedAttempts) && failedAttempts >= 0
|
|
42
|
+
&& Number.isSafeInteger(maxAttempts) && maxAttempts > 0 && failedAttempts < maxAttempts;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Decide metadata eligibility only; callers must also verify the token/code and use the
|
|
46
|
+
* store's atomic steps above. nowIso is the caller's injected clock value. Expiry is exclusive.
|
|
47
|
+
* Every denial returns the same result, including malformed timestamps/counters.
|
|
48
|
+
* @complexity O(1) for fixed-size metadata; no I/O, clock reads or input mutation.
|
|
49
|
+
*/
|
|
50
|
+
export function decideChallenge({ record, workspaceId, realm, purpose, nowIso, maxAttempts = 5 }, _options = {}) {
|
|
51
|
+
if (!record)
|
|
52
|
+
return "invalid";
|
|
53
|
+
if (record.workspaceId !== workspaceId || record.realm !== realm || record.purpose !== purpose)
|
|
54
|
+
return "invalid";
|
|
55
|
+
if (record.consumedAt != null)
|
|
56
|
+
return "invalid";
|
|
57
|
+
if (!hasAttemptsRemaining(record.failedAttempts, maxAttempts))
|
|
58
|
+
return "invalid";
|
|
59
|
+
const now = Date.parse(nowIso);
|
|
60
|
+
const expires = Date.parse(record.expiresAt);
|
|
61
|
+
// Invalid dates parse to NaN, so the comparison also denies malformed timestamps.
|
|
62
|
+
return expires > now ? "ok" : "invalid";
|
|
63
|
+
}
|
|
64
|
+
//# sourceMappingURL=auth-challenge.js.map
|
package/dist/server/index.d.ts
CHANGED
|
@@ -13,6 +13,7 @@ export * from "./permission-migrations.js";
|
|
|
13
13
|
export { identityAgentToolCatalog, type IdentityAgentToolDefinition, } from "./agent-tools.js";
|
|
14
14
|
export { parseIdentityToolInput, type IdentityToolInputResult } from "./agent-tool-input.js";
|
|
15
15
|
export * from "./session-tokens.js";
|
|
16
|
+
export * from "./auth-challenge.js";
|
|
16
17
|
export { buildIdentityRegistrations, identityDerivedRisk, type IdentityToolDeps } from "./agent-tool-registrations.js";
|
|
17
18
|
export { defaultUserManagementMessages, type UserManagementMessages } from "./messages.js";
|
|
18
19
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/server/index.js
CHANGED
|
@@ -15,6 +15,7 @@ export * from "./permission-migrations.js";
|
|
|
15
15
|
export { identityAgentToolCatalog, } from "./agent-tools.js";
|
|
16
16
|
export { parseIdentityToolInput } from "./agent-tool-input.js";
|
|
17
17
|
export * from "./session-tokens.js";
|
|
18
|
+
export * from "./auth-challenge.js";
|
|
18
19
|
export { buildIdentityRegistrations, identityDerivedRisk } from "./agent-tool-registrations.js";
|
|
19
20
|
export { defaultUserManagementMessages } from "./messages.js";
|
|
20
21
|
//# sourceMappingURL=index.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jini-ai/user-management",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.11",
|
|
4
4
|
"description": "Runtime-separated user management contracts and Node authentication, authorization, and administration services.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -87,16 +87,16 @@
|
|
|
87
87
|
"registry": "https://registry.npmjs.org"
|
|
88
88
|
},
|
|
89
89
|
"dependencies": {
|
|
90
|
-
"@jini-ai/core": "^0.
|
|
90
|
+
"@jini-ai/core": "^0.6.11"
|
|
91
91
|
},
|
|
92
92
|
"peerDependencies": {
|
|
93
93
|
"argon2": "^0.44.0",
|
|
94
94
|
"react": "^18.3.0 || ^19.0.0",
|
|
95
95
|
"react-dom": "^18.3.0 || ^19.0.0",
|
|
96
|
-
"@jini-ai/ui": "^0.
|
|
97
|
-
"@jini-ai/admin": "^0.
|
|
98
|
-
"@jini-ai/agentic": "^0.
|
|
99
|
-
"@jini-ai/ui-kit": "^0.
|
|
96
|
+
"@jini-ai/ui": "^0.6.10",
|
|
97
|
+
"@jini-ai/admin": "^0.6.10",
|
|
98
|
+
"@jini-ai/agentic": "^0.6.10",
|
|
99
|
+
"@jini-ai/ui-kit": "^0.6.10"
|
|
100
100
|
},
|
|
101
101
|
"peerDependenciesMeta": {
|
|
102
102
|
"argon2": {
|
|
@@ -134,12 +134,12 @@
|
|
|
134
134
|
"@testing-library/user-event": "^14.5.0",
|
|
135
135
|
"@testing-library/jest-dom": "^6.6.3",
|
|
136
136
|
"jsdom": "^25.0.1",
|
|
137
|
-
"@jini-ai/ui": "0.
|
|
138
|
-
"@jini-ai/
|
|
139
|
-
"@jini-ai/core": "^0.
|
|
140
|
-
"@jini-ai/
|
|
141
|
-
"@jini-ai/
|
|
142
|
-
"@jini-ai/
|
|
137
|
+
"@jini-ai/ui": "0.6.11",
|
|
138
|
+
"@jini-ai/cms": "0.6.11",
|
|
139
|
+
"@jini-ai/core": "^0.6.11",
|
|
140
|
+
"@jini-ai/admin": "0.6.11",
|
|
141
|
+
"@jini-ai/agentic": "0.6.11",
|
|
142
|
+
"@jini-ai/ui-kit": "^0.6.11"
|
|
143
143
|
},
|
|
144
144
|
"scripts": {
|
|
145
145
|
"build": "tsc -p tsconfig.json",
|