@arkyc/core 1.0.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/dist/context.d.mts +48 -0
- package/dist/context.d.mts.map +1 -0
- package/dist/context.mjs +60 -0
- package/dist/context.mjs.map +1 -0
- package/dist/decision.d.mts +61 -0
- package/dist/decision.d.mts.map +1 -0
- package/dist/decision.mjs +43 -0
- package/dist/decision.mjs.map +1 -0
- package/dist/index.d.mts +8 -0
- package/dist/index.mjs +8 -0
- package/dist/normalize.d.mts +38 -0
- package/dist/normalize.d.mts.map +1 -0
- package/dist/normalize.mjs +61 -0
- package/dist/normalize.mjs.map +1 -0
- package/dist/risk.d.mts +28 -0
- package/dist/risk.d.mts.map +1 -0
- package/dist/risk.mjs +44 -0
- package/dist/risk.mjs.map +1 -0
- package/dist/session.d.mts +40 -0
- package/dist/session.d.mts.map +1 -0
- package/dist/session.mjs +54 -0
- package/dist/session.mjs.map +1 -0
- package/dist/status.d.mts +56 -0
- package/dist/status.d.mts.map +1 -0
- package/dist/status.mjs +113 -0
- package/dist/status.mjs.map +1 -0
- package/dist/thresholds.d.mts +21 -0
- package/dist/thresholds.d.mts.map +1 -0
- package/dist/thresholds.mjs +30 -0
- package/dist/thresholds.mjs.map +1 -0
- package/package.json +28 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { Id, OrganizationScoped } from "@arkyc/types";
|
|
2
|
+
|
|
3
|
+
//#region src/context.d.ts
|
|
4
|
+
/** Identifies the organization + project a request or entity belongs to. */
|
|
5
|
+
interface OrganizationProjectContext {
|
|
6
|
+
organizationId: Id;
|
|
7
|
+
projectId: Id;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Thrown when an entity is accessed outside its organization/project scope.
|
|
11
|
+
*/
|
|
12
|
+
declare class OrganizationScopeError extends Error {
|
|
13
|
+
constructor(message?: string);
|
|
14
|
+
}
|
|
15
|
+
/** Organization/project scoping and storage-path helpers. */
|
|
16
|
+
declare class OrganizationContext {
|
|
17
|
+
/**
|
|
18
|
+
* Whether `entity` belongs to the given organization.
|
|
19
|
+
*
|
|
20
|
+
* @param entity
|
|
21
|
+
* @param organizationId
|
|
22
|
+
* @returns
|
|
23
|
+
*/
|
|
24
|
+
static belongsTo(entity: OrganizationScoped, organizationId: Id): boolean;
|
|
25
|
+
/**
|
|
26
|
+
* Assert that `entity` belongs to `organizationId`, throwing {@link OrganizationScopeError}
|
|
27
|
+
* otherwise. Returns the entity (narrowed) for inline use.
|
|
28
|
+
*
|
|
29
|
+
* @param entity
|
|
30
|
+
* @param organizationId
|
|
31
|
+
* @returns
|
|
32
|
+
*/
|
|
33
|
+
static assertScope<T extends OrganizationScoped>(entity: T, organizationId: Id): T;
|
|
34
|
+
/**
|
|
35
|
+
* Build the canonical, organization/project-scoped storage key for a session object.
|
|
36
|
+
*
|
|
37
|
+
* e.g. `organizations/t1/projects/p1/sessions/s1/documents/front.jpg`
|
|
38
|
+
*
|
|
39
|
+
* @param ctx
|
|
40
|
+
* @param sessionId
|
|
41
|
+
* @param parts
|
|
42
|
+
* @returns
|
|
43
|
+
*/
|
|
44
|
+
static storagePath(ctx: OrganizationProjectContext, sessionId: Id, ...parts: string[]): string;
|
|
45
|
+
}
|
|
46
|
+
//#endregion
|
|
47
|
+
export { OrganizationContext, OrganizationProjectContext, OrganizationScopeError };
|
|
48
|
+
//# sourceMappingURL=context.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"context.d.mts","names":[],"sources":["../src/context.ts"],"mappings":";;;;UAGiB,0BAAA;EACf,cAAA,EAAgB,EAAA;EAChB,SAAA,EAAW,EAAE;AAAA;;;;cAMF,sBAAA,SAA+B,KAAK;cACnC,OAAA;AAAA;AAPC;AAAA,cAcF,mBAAA;EARuB;;;;;;;EAAA,OAgB3B,SAAA,CAAU,MAAA,EAAQ,kBAAA,EAAoB,cAAA,EAAgB,EAAA;EARlD;;;;;;;;EAAA,OAoBJ,WAAA,WAAsB,kBAAA,EAAoB,MAAA,EAAQ,CAAA,EAAG,cAAA,EAAgB,EAAA,GAAK,CAAA;EAiBzD;;;;;;;;;;EAAA,OAAjB,WAAA,CAAY,GAAA,EAAK,0BAAA,EAA4B,SAAA,EAAW,EAAA,KAAO,KAAA;AAAA"}
|
package/dist/context.mjs
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
//#region src/context.ts
|
|
2
|
+
/**
|
|
3
|
+
* Thrown when an entity is accessed outside its organization/project scope.
|
|
4
|
+
*/
|
|
5
|
+
var OrganizationScopeError = class extends Error {
|
|
6
|
+
constructor(message = "Entity is outside the active organization scope") {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = "OrganizationScopeError";
|
|
9
|
+
}
|
|
10
|
+
};
|
|
11
|
+
/** Organization/project scoping and storage-path helpers. */
|
|
12
|
+
var OrganizationContext = class OrganizationContext {
|
|
13
|
+
/**
|
|
14
|
+
* Whether `entity` belongs to the given organization.
|
|
15
|
+
*
|
|
16
|
+
* @param entity
|
|
17
|
+
* @param organizationId
|
|
18
|
+
* @returns
|
|
19
|
+
*/
|
|
20
|
+
static belongsTo(entity, organizationId) {
|
|
21
|
+
return entity.organization_id === organizationId;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Assert that `entity` belongs to `organizationId`, throwing {@link OrganizationScopeError}
|
|
25
|
+
* otherwise. Returns the entity (narrowed) for inline use.
|
|
26
|
+
*
|
|
27
|
+
* @param entity
|
|
28
|
+
* @param organizationId
|
|
29
|
+
* @returns
|
|
30
|
+
*/
|
|
31
|
+
static assertScope(entity, organizationId) {
|
|
32
|
+
if (!OrganizationContext.belongsTo(entity, organizationId)) throw new OrganizationScopeError();
|
|
33
|
+
return entity;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Build the canonical, organization/project-scoped storage key for a session object.
|
|
37
|
+
*
|
|
38
|
+
* e.g. `organizations/t1/projects/p1/sessions/s1/documents/front.jpg`
|
|
39
|
+
*
|
|
40
|
+
* @param ctx
|
|
41
|
+
* @param sessionId
|
|
42
|
+
* @param parts
|
|
43
|
+
* @returns
|
|
44
|
+
*/
|
|
45
|
+
static storagePath(ctx, sessionId, ...parts) {
|
|
46
|
+
return [
|
|
47
|
+
"organizations",
|
|
48
|
+
ctx.organizationId,
|
|
49
|
+
"projects",
|
|
50
|
+
ctx.projectId,
|
|
51
|
+
"sessions",
|
|
52
|
+
sessionId,
|
|
53
|
+
...parts
|
|
54
|
+
].join("/");
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
//#endregion
|
|
58
|
+
export { OrganizationContext, OrganizationScopeError };
|
|
59
|
+
|
|
60
|
+
//# sourceMappingURL=context.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"context.mjs","names":[],"sources":["../src/context.ts"],"sourcesContent":["import type { Id, OrganizationScoped } from '@arkyc/types'\n\n/** Identifies the organization + project a request or entity belongs to. */\nexport interface OrganizationProjectContext {\n organizationId: Id\n projectId: Id\n}\n\n/**\n * Thrown when an entity is accessed outside its organization/project scope.\n */\nexport class OrganizationScopeError extends Error {\n constructor(message = 'Entity is outside the active organization scope') {\n super(message)\n this.name = 'OrganizationScopeError'\n }\n}\n\n/** Organization/project scoping and storage-path helpers. */\nexport class OrganizationContext {\n /**\n * Whether `entity` belongs to the given organization.\n *\n * @param entity\n * @param organizationId\n * @returns\n */\n static belongsTo(entity: OrganizationScoped, organizationId: Id): boolean {\n return entity.organization_id === organizationId\n }\n\n /**\n * Assert that `entity` belongs to `organizationId`, throwing {@link OrganizationScopeError}\n * otherwise. Returns the entity (narrowed) for inline use.\n *\n * @param entity\n * @param organizationId\n * @returns\n */\n static assertScope<T extends OrganizationScoped>(entity: T, organizationId: Id): T {\n if (!OrganizationContext.belongsTo(entity, organizationId)) {\n throw new OrganizationScopeError()\n }\n return entity\n }\n\n /**\n * Build the canonical, organization/project-scoped storage key for a session object.\n *\n * e.g. `organizations/t1/projects/p1/sessions/s1/documents/front.jpg`\n *\n * @param ctx\n * @param sessionId\n * @param parts\n * @returns\n */\n static storagePath(ctx: OrganizationProjectContext, sessionId: Id, ...parts: string[]): string {\n const segments = ['organizations', ctx.organizationId, 'projects', ctx.projectId, 'sessions', sessionId, ...parts]\n return segments.join('/')\n }\n}\n"],"mappings":";;;;AAWA,IAAa,yBAAb,cAA4C,MAAM;CAChD,YAAY,UAAU,mDAAmD;EACvE,MAAM,OAAO;EACb,KAAK,OAAO;CACd;AACF;;AAGA,IAAa,sBAAb,MAAa,oBAAoB;;;;;;;;CAQ/B,OAAO,UAAU,QAA4B,gBAA6B;EACxE,OAAO,OAAO,oBAAoB;CACpC;;;;;;;;;CAUA,OAAO,YAA0C,QAAW,gBAAuB;EACjF,IAAI,CAAC,oBAAoB,UAAU,QAAQ,cAAc,GACvD,MAAM,IAAI,uBAAuB;EAEnC,OAAO;CACT;;;;;;;;;;;CAYA,OAAO,YAAY,KAAiC,WAAe,GAAG,OAAyB;EAE7F,OAAO;GADW;GAAiB,IAAI;GAAgB;GAAY,IAAI;GAAW;GAAY;GAAW,GAAG;EAC9F,CAAC,CAAC,KAAK,GAAG;CAC1B;AACF"}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { DecisionReason, VerificationDecision, VerificationThresholds } from "@arkyc/types";
|
|
2
|
+
|
|
3
|
+
//#region src/decision.d.ts
|
|
4
|
+
/** Document signals fed to the decision engine. */
|
|
5
|
+
interface DecisionDocumentInput {
|
|
6
|
+
/** Document image quality in [0, 1]. */
|
|
7
|
+
qualityScore: number;
|
|
8
|
+
/** OCR extraction confidence in [0, 1]. */
|
|
9
|
+
ocrConfidence: number;
|
|
10
|
+
/** Whether the document is past its expiry date. */
|
|
11
|
+
expired: boolean;
|
|
12
|
+
}
|
|
13
|
+
/** Liveness signals fed to the decision engine. */
|
|
14
|
+
interface DecisionLivenessInput {
|
|
15
|
+
passed: boolean;
|
|
16
|
+
/** Liveness confidence in [0, 1]. */
|
|
17
|
+
score: number;
|
|
18
|
+
/** Whether more than one face was detected in the selfie/liveness frame. */
|
|
19
|
+
multipleFaces?: boolean;
|
|
20
|
+
}
|
|
21
|
+
/** Face-match signals fed to the decision engine. */
|
|
22
|
+
interface DecisionFaceMatchInput {
|
|
23
|
+
passed: boolean;
|
|
24
|
+
/** Similarity between document portrait and selfie in [0, 1]. */
|
|
25
|
+
similarityScore: number;
|
|
26
|
+
}
|
|
27
|
+
/** The complete set of signals the decision engine evaluates. */
|
|
28
|
+
interface DecisionInput {
|
|
29
|
+
document: DecisionDocumentInput;
|
|
30
|
+
liveness: DecisionLivenessInput;
|
|
31
|
+
faceMatch: DecisionFaceMatchInput;
|
|
32
|
+
}
|
|
33
|
+
/** The decision engine's verdict for a session. */
|
|
34
|
+
interface DecisionResult {
|
|
35
|
+
decision: VerificationDecision;
|
|
36
|
+
reason: DecisionReason;
|
|
37
|
+
/** Aggregate risk score in [0, 1] (higher = riskier). */
|
|
38
|
+
riskScore: number;
|
|
39
|
+
}
|
|
40
|
+
/** Deterministic verification decision engine. */
|
|
41
|
+
declare class DecisionEngine {
|
|
42
|
+
/**
|
|
43
|
+
* Decide a verification outcome from the gathered signals.
|
|
44
|
+
*
|
|
45
|
+
* Hard failures reject outright; borderline/low-confidence signals route to
|
|
46
|
+
* manual review; everything clearing its threshold is auto-approved. Checks are
|
|
47
|
+
* evaluated in a fixed priority order so the result is deterministic.
|
|
48
|
+
*
|
|
49
|
+
* Reject precedence: expired → liveness failed → face-match failed
|
|
50
|
+
* Review precedence: multiple faces → low quality → low OCR
|
|
51
|
+
* → low liveness → low face-match
|
|
52
|
+
*
|
|
53
|
+
* @param input
|
|
54
|
+
* @param thresholds
|
|
55
|
+
* @returns
|
|
56
|
+
*/
|
|
57
|
+
static decide(input: DecisionInput, thresholds?: Partial<VerificationThresholds>): DecisionResult;
|
|
58
|
+
}
|
|
59
|
+
//#endregion
|
|
60
|
+
export { DecisionDocumentInput, DecisionEngine, DecisionFaceMatchInput, DecisionInput, DecisionLivenessInput, DecisionResult };
|
|
61
|
+
//# sourceMappingURL=decision.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"decision.d.mts","names":[],"sources":["../src/decision.ts"],"mappings":";;;;UAMiB,qBAAA;EAAA;EAEf,YAAA;;EAEA,aAAA;EAFA;EAIA,OAAA;AAAA;;UAIe,qBAAA;EACf,MAAA;EADoC;EAGpC,KAAA;EAHoC;EAKpC,aAAA;AAAA;;UAIe,sBAAA;EACf,MAAA;EADe;EAGf,eAAe;AAAA;;UAIA,aAAA;EACf,QAAA,EAAU,qBAAA;EACV,QAAA,EAAU,qBAAA;EACV,SAAA,EAAW,sBAAA;AAAA;;UAII,cAAA;EACf,QAAA,EAAU,oBAAA;EACV,MAAA,EAAQ,cAAc;EARtB;EAUA,SAAA;AAAA;;cAIW,cAAA;EAZA;;AAAsB;AAInC;;;;;;;;;;AAIW;AAIX;EAZa,OA4BJ,MAAA,CAAO,KAAA,EAAO,aAAA,EAAe,UAAA,GAAa,OAAA,CAAQ,sBAAA,IAA0B,cAAA;AAAA"}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { Thresholds } from "./thresholds.mjs";
|
|
2
|
+
import { Risk } from "./risk.mjs";
|
|
3
|
+
//#region src/decision.ts
|
|
4
|
+
/** Deterministic verification decision engine. */
|
|
5
|
+
var DecisionEngine = class {
|
|
6
|
+
/**
|
|
7
|
+
* Decide a verification outcome from the gathered signals.
|
|
8
|
+
*
|
|
9
|
+
* Hard failures reject outright; borderline/low-confidence signals route to
|
|
10
|
+
* manual review; everything clearing its threshold is auto-approved. Checks are
|
|
11
|
+
* evaluated in a fixed priority order so the result is deterministic.
|
|
12
|
+
*
|
|
13
|
+
* Reject precedence: expired → liveness failed → face-match failed
|
|
14
|
+
* Review precedence: multiple faces → low quality → low OCR
|
|
15
|
+
* → low liveness → low face-match
|
|
16
|
+
*
|
|
17
|
+
* @param input
|
|
18
|
+
* @param thresholds
|
|
19
|
+
* @returns
|
|
20
|
+
*/
|
|
21
|
+
static decide(input, thresholds) {
|
|
22
|
+
const t = Thresholds.resolve(thresholds);
|
|
23
|
+
const riskScore = Risk.score(input);
|
|
24
|
+
const verdict = (decision, reason) => ({
|
|
25
|
+
decision,
|
|
26
|
+
reason,
|
|
27
|
+
riskScore
|
|
28
|
+
});
|
|
29
|
+
if (input.document.expired) return verdict("rejected", "DOCUMENT_EXPIRED");
|
|
30
|
+
if (!input.liveness.passed) return verdict("rejected", "LIVENESS_FAILED");
|
|
31
|
+
if (!input.faceMatch.passed) return verdict("rejected", "FACE_MATCH_FAILED");
|
|
32
|
+
if (input.liveness.multipleFaces === true) return verdict("requires_review", "MULTIPLE_FACES_DETECTED");
|
|
33
|
+
if (input.document.qualityScore < t.documentQualityThreshold) return verdict("requires_review", "LOW_DOCUMENT_QUALITY");
|
|
34
|
+
if (input.document.ocrConfidence < t.ocrConfidenceThreshold) return verdict("requires_review", "OCR_LOW_CONFIDENCE");
|
|
35
|
+
if (input.liveness.score < t.livenessThreshold) return verdict("requires_review", "LIVENESS_LOW_CONFIDENCE");
|
|
36
|
+
if (input.faceMatch.similarityScore < t.faceMatchThreshold) return verdict("requires_review", "FACE_MATCH_LOW_CONFIDENCE");
|
|
37
|
+
return verdict("approved", "AUTO_APPROVED");
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
//#endregion
|
|
41
|
+
export { DecisionEngine };
|
|
42
|
+
|
|
43
|
+
//# sourceMappingURL=decision.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"decision.mjs","names":[],"sources":["../src/decision.ts"],"sourcesContent":["import type { DecisionReason, VerificationDecision, VerificationThresholds } from '@arkyc/types'\n\nimport { Risk } from './risk'\nimport { Thresholds } from './thresholds'\n\n/** Document signals fed to the decision engine. */\nexport interface DecisionDocumentInput {\n /** Document image quality in [0, 1]. */\n qualityScore: number\n /** OCR extraction confidence in [0, 1]. */\n ocrConfidence: number\n /** Whether the document is past its expiry date. */\n expired: boolean\n}\n\n/** Liveness signals fed to the decision engine. */\nexport interface DecisionLivenessInput {\n passed: boolean\n /** Liveness confidence in [0, 1]. */\n score: number\n /** Whether more than one face was detected in the selfie/liveness frame. */\n multipleFaces?: boolean\n}\n\n/** Face-match signals fed to the decision engine. */\nexport interface DecisionFaceMatchInput {\n passed: boolean\n /** Similarity between document portrait and selfie in [0, 1]. */\n similarityScore: number\n}\n\n/** The complete set of signals the decision engine evaluates. */\nexport interface DecisionInput {\n document: DecisionDocumentInput\n liveness: DecisionLivenessInput\n faceMatch: DecisionFaceMatchInput\n}\n\n/** The decision engine's verdict for a session. */\nexport interface DecisionResult {\n decision: VerificationDecision\n reason: DecisionReason\n /** Aggregate risk score in [0, 1] (higher = riskier). */\n riskScore: number\n}\n\n/** Deterministic verification decision engine. */\nexport class DecisionEngine {\n /**\n * Decide a verification outcome from the gathered signals.\n *\n * Hard failures reject outright; borderline/low-confidence signals route to\n * manual review; everything clearing its threshold is auto-approved. Checks are\n * evaluated in a fixed priority order so the result is deterministic.\n *\n * Reject precedence: expired → liveness failed → face-match failed\n * Review precedence: multiple faces → low quality → low OCR\n * → low liveness → low face-match\n *\n * @param input\n * @param thresholds\n * @returns\n */\n static decide(input: DecisionInput, thresholds?: Partial<VerificationThresholds>): DecisionResult {\n const t = Thresholds.resolve(thresholds)\n const riskScore = Risk.score(input)\n const verdict = (decision: VerificationDecision, reason: DecisionReason): DecisionResult => ({\n decision,\n reason,\n riskScore,\n })\n\n // --- Hard rejections (highest priority) ---\n if (input.document.expired) {\n return verdict('rejected', 'DOCUMENT_EXPIRED')\n }\n if (!input.liveness.passed) {\n return verdict('rejected', 'LIVENESS_FAILED')\n }\n if (!input.faceMatch.passed) {\n return verdict('rejected', 'FACE_MATCH_FAILED')\n }\n\n // --- Manual review (borderline / ambiguous signals) ---\n if (input.liveness.multipleFaces === true) {\n return verdict('requires_review', 'MULTIPLE_FACES_DETECTED')\n }\n if (input.document.qualityScore < t.documentQualityThreshold) {\n return verdict('requires_review', 'LOW_DOCUMENT_QUALITY')\n }\n if (input.document.ocrConfidence < t.ocrConfidenceThreshold) {\n return verdict('requires_review', 'OCR_LOW_CONFIDENCE')\n }\n if (input.liveness.score < t.livenessThreshold) {\n return verdict('requires_review', 'LIVENESS_LOW_CONFIDENCE')\n }\n if (input.faceMatch.similarityScore < t.faceMatchThreshold) {\n return verdict('requires_review', 'FACE_MATCH_LOW_CONFIDENCE')\n }\n\n // --- All checks cleared ---\n return verdict('approved', 'AUTO_APPROVED')\n }\n}\n"],"mappings":";;;;AA+CA,IAAa,iBAAb,MAA4B;;;;;;;;;;;;;;;;CAgB1B,OAAO,OAAO,OAAsB,YAA8D;EAChG,MAAM,IAAI,WAAW,QAAQ,UAAU;EACvC,MAAM,YAAY,KAAK,MAAM,KAAK;EAClC,MAAM,WAAW,UAAgC,YAA4C;GAC3F;GACA;GACA;EACF;EAGA,IAAI,MAAM,SAAS,SACjB,OAAO,QAAQ,YAAY,kBAAkB;EAE/C,IAAI,CAAC,MAAM,SAAS,QAClB,OAAO,QAAQ,YAAY,iBAAiB;EAE9C,IAAI,CAAC,MAAM,UAAU,QACnB,OAAO,QAAQ,YAAY,mBAAmB;EAIhD,IAAI,MAAM,SAAS,kBAAkB,MACnC,OAAO,QAAQ,mBAAmB,yBAAyB;EAE7D,IAAI,MAAM,SAAS,eAAe,EAAE,0BAClC,OAAO,QAAQ,mBAAmB,sBAAsB;EAE1D,IAAI,MAAM,SAAS,gBAAgB,EAAE,wBACnC,OAAO,QAAQ,mBAAmB,oBAAoB;EAExD,IAAI,MAAM,SAAS,QAAQ,EAAE,mBAC3B,OAAO,QAAQ,mBAAmB,yBAAyB;EAE7D,IAAI,MAAM,UAAU,kBAAkB,EAAE,oBACtC,OAAO,QAAQ,mBAAmB,2BAA2B;EAI/D,OAAO,QAAQ,YAAY,eAAe;CAC5C;AACF"}
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { Thresholds } from "./thresholds.mjs";
|
|
2
|
+
import { InvalidStatusTransitionError, StatusMachine } from "./status.mjs";
|
|
3
|
+
import { DecisionDocumentInput, DecisionEngine, DecisionFaceMatchInput, DecisionInput, DecisionLivenessInput, DecisionResult } from "./decision.mjs";
|
|
4
|
+
import { Risk } from "./risk.mjs";
|
|
5
|
+
import { SessionRules } from "./session.mjs";
|
|
6
|
+
import { Normalize, SessionSignals } from "./normalize.mjs";
|
|
7
|
+
import { OrganizationContext, OrganizationProjectContext, OrganizationScopeError } from "./context.mjs";
|
|
8
|
+
export { DecisionDocumentInput, DecisionEngine, DecisionFaceMatchInput, DecisionInput, DecisionLivenessInput, DecisionResult, InvalidStatusTransitionError, Normalize, OrganizationContext, OrganizationProjectContext, OrganizationScopeError, Risk, SessionRules, SessionSignals, StatusMachine, Thresholds };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { Thresholds } from "./thresholds.mjs";
|
|
2
|
+
import { InvalidStatusTransitionError, StatusMachine } from "./status.mjs";
|
|
3
|
+
import { Risk } from "./risk.mjs";
|
|
4
|
+
import { DecisionEngine } from "./decision.mjs";
|
|
5
|
+
import { SessionRules } from "./session.mjs";
|
|
6
|
+
import { Normalize } from "./normalize.mjs";
|
|
7
|
+
import { OrganizationContext, OrganizationScopeError } from "./context.mjs";
|
|
8
|
+
export { DecisionEngine, InvalidStatusTransitionError, Normalize, OrganizationContext, OrganizationScopeError, Risk, SessionRules, StatusMachine, Thresholds };
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { DecisionInput } from "./decision.mjs";
|
|
2
|
+
import { FaceMatchResultData, IsoDateTime, LivenessResultData, OcrResultData, WebhookChecks } from "@arkyc/types";
|
|
3
|
+
|
|
4
|
+
//#region src/normalize.d.ts
|
|
5
|
+
/**
|
|
6
|
+
* The raw provider outputs gathered for a session, plus the document quality.
|
|
7
|
+
*/
|
|
8
|
+
interface SessionSignals {
|
|
9
|
+
documentQualityScore: number;
|
|
10
|
+
ocr: OcrResultData;
|
|
11
|
+
liveness: LivenessResultData;
|
|
12
|
+
faceMatch: FaceMatchResultData;
|
|
13
|
+
}
|
|
14
|
+
/** Normalisation of gathered provider results into engine/webhook shapes. */
|
|
15
|
+
declare class Normalize {
|
|
16
|
+
/**
|
|
17
|
+
* Normalise gathered provider results into the flat {@link DecisionInput} the
|
|
18
|
+
* decision engine consumes. Document expiry is derived from the OCR `expiryDate`
|
|
19
|
+
* field relative to `now`.
|
|
20
|
+
*
|
|
21
|
+
* @param signals
|
|
22
|
+
* @param now
|
|
23
|
+
* @returns
|
|
24
|
+
*/
|
|
25
|
+
static toDecisionInput(signals: SessionSignals, now: IsoDateTime | Date): DecisionInput;
|
|
26
|
+
/**
|
|
27
|
+
* Build the `checks` summary embedded in webhook payloads from the same
|
|
28
|
+
* gathered signals.
|
|
29
|
+
*
|
|
30
|
+
* @param signals
|
|
31
|
+
* @param now
|
|
32
|
+
* @returns
|
|
33
|
+
*/
|
|
34
|
+
static toWebhookChecks(signals: SessionSignals, now: IsoDateTime | Date): WebhookChecks;
|
|
35
|
+
}
|
|
36
|
+
//#endregion
|
|
37
|
+
export { Normalize, SessionSignals };
|
|
38
|
+
//# sourceMappingURL=normalize.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"normalize.d.mts","names":[],"sources":["../src/normalize.ts"],"mappings":";;;;;;AAQA;UAAiB,cAAA;EACf,oBAAA;EACA,GAAA,EAAK,aAAA;EACL,QAAA,EAAU,kBAAA;EACV,SAAA,EAAW,mBAAA;AAAA;;cAIA,SAAA;EANX;;;;;;;AAE8B;AAIhC;EANE,OAgBO,eAAA,CAAgB,OAAA,EAAS,cAAA,EAAgB,GAAA,EAAK,WAAA,GAAc,IAAA,GAAO,aAAA;;;;;;;;;SA2BnE,eAAA,CAAgB,OAAA,EAAS,cAAA,EAAgB,GAAA,EAAK,WAAA,GAAc,IAAA,GAAO,aAAA;AAAA"}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { SessionRules } from "./session.mjs";
|
|
2
|
+
//#region src/normalize.ts
|
|
3
|
+
/** Normalisation of gathered provider results into engine/webhook shapes. */
|
|
4
|
+
var Normalize = class {
|
|
5
|
+
/**
|
|
6
|
+
* Normalise gathered provider results into the flat {@link DecisionInput} the
|
|
7
|
+
* decision engine consumes. Document expiry is derived from the OCR `expiryDate`
|
|
8
|
+
* field relative to `now`.
|
|
9
|
+
*
|
|
10
|
+
* @param signals
|
|
11
|
+
* @param now
|
|
12
|
+
* @returns
|
|
13
|
+
*/
|
|
14
|
+
static toDecisionInput(signals, now) {
|
|
15
|
+
return {
|
|
16
|
+
document: {
|
|
17
|
+
qualityScore: signals.documentQualityScore,
|
|
18
|
+
ocrConfidence: signals.ocr.confidence,
|
|
19
|
+
expired: SessionRules.isDocumentExpired(signals.ocr.fields.expiryDate, now)
|
|
20
|
+
},
|
|
21
|
+
liveness: {
|
|
22
|
+
passed: signals.liveness.passed,
|
|
23
|
+
score: signals.liveness.score,
|
|
24
|
+
multipleFaces: signals.liveness.spoofSignals.multipleFaces === true
|
|
25
|
+
},
|
|
26
|
+
faceMatch: {
|
|
27
|
+
passed: signals.faceMatch.passed,
|
|
28
|
+
similarityScore: signals.faceMatch.similarityScore
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Build the `checks` summary embedded in webhook payloads from the same
|
|
34
|
+
* gathered signals.
|
|
35
|
+
*
|
|
36
|
+
* @param signals
|
|
37
|
+
* @param now
|
|
38
|
+
* @returns
|
|
39
|
+
*/
|
|
40
|
+
static toWebhookChecks(signals, now) {
|
|
41
|
+
return {
|
|
42
|
+
document: {
|
|
43
|
+
quality_score: signals.documentQualityScore,
|
|
44
|
+
ocr_confidence: signals.ocr.confidence,
|
|
45
|
+
expired: SessionRules.isDocumentExpired(signals.ocr.fields.expiryDate, now)
|
|
46
|
+
},
|
|
47
|
+
liveness: {
|
|
48
|
+
passed: signals.liveness.passed,
|
|
49
|
+
score: signals.liveness.score
|
|
50
|
+
},
|
|
51
|
+
face_match: {
|
|
52
|
+
passed: signals.faceMatch.passed,
|
|
53
|
+
similarity_score: signals.faceMatch.similarityScore
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
//#endregion
|
|
59
|
+
export { Normalize };
|
|
60
|
+
|
|
61
|
+
//# sourceMappingURL=normalize.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"normalize.mjs","names":[],"sources":["../src/normalize.ts"],"sourcesContent":["import type { FaceMatchResultData, IsoDateTime, LivenessResultData, OcrResultData, WebhookChecks } from '@arkyc/types'\n\nimport type { DecisionInput } from './decision'\nimport { SessionRules } from './session'\n\n/**\n * The raw provider outputs gathered for a session, plus the document quality.\n */\nexport interface SessionSignals {\n documentQualityScore: number\n ocr: OcrResultData\n liveness: LivenessResultData\n faceMatch: FaceMatchResultData\n}\n\n/** Normalisation of gathered provider results into engine/webhook shapes. */\nexport class Normalize {\n /**\n * Normalise gathered provider results into the flat {@link DecisionInput} the\n * decision engine consumes. Document expiry is derived from the OCR `expiryDate`\n * field relative to `now`.\n *\n * @param signals\n * @param now\n * @returns\n */\n static toDecisionInput(signals: SessionSignals, now: IsoDateTime | Date): DecisionInput {\n return {\n document: {\n qualityScore: signals.documentQualityScore,\n ocrConfidence: signals.ocr.confidence,\n expired: SessionRules.isDocumentExpired(signals.ocr.fields.expiryDate, now),\n },\n liveness: {\n passed: signals.liveness.passed,\n score: signals.liveness.score,\n multipleFaces: signals.liveness.spoofSignals.multipleFaces === true,\n },\n faceMatch: {\n passed: signals.faceMatch.passed,\n similarityScore: signals.faceMatch.similarityScore,\n },\n }\n }\n\n /**\n * Build the `checks` summary embedded in webhook payloads from the same\n * gathered signals.\n *\n * @param signals\n * @param now\n * @returns\n */\n static toWebhookChecks(signals: SessionSignals, now: IsoDateTime | Date): WebhookChecks {\n return {\n document: {\n quality_score: signals.documentQualityScore,\n ocr_confidence: signals.ocr.confidence,\n expired: SessionRules.isDocumentExpired(signals.ocr.fields.expiryDate, now),\n },\n liveness: {\n passed: signals.liveness.passed,\n score: signals.liveness.score,\n },\n face_match: {\n passed: signals.faceMatch.passed,\n similarity_score: signals.faceMatch.similarityScore,\n },\n }\n }\n}\n"],"mappings":";;;AAgBA,IAAa,YAAb,MAAuB;;;;;;;;;;CAUrB,OAAO,gBAAgB,SAAyB,KAAwC;EACtF,OAAO;GACL,UAAU;IACR,cAAc,QAAQ;IACtB,eAAe,QAAQ,IAAI;IAC3B,SAAS,aAAa,kBAAkB,QAAQ,IAAI,OAAO,YAAY,GAAG;GAC5E;GACA,UAAU;IACR,QAAQ,QAAQ,SAAS;IACzB,OAAO,QAAQ,SAAS;IACxB,eAAe,QAAQ,SAAS,aAAa,kBAAkB;GACjE;GACA,WAAW;IACT,QAAQ,QAAQ,UAAU;IAC1B,iBAAiB,QAAQ,UAAU;GACrC;EACF;CACF;;;;;;;;;CAUA,OAAO,gBAAgB,SAAyB,KAAwC;EACtF,OAAO;GACL,UAAU;IACR,eAAe,QAAQ;IACvB,gBAAgB,QAAQ,IAAI;IAC5B,SAAS,aAAa,kBAAkB,QAAQ,IAAI,OAAO,YAAY,GAAG;GAC5E;GACA,UAAU;IACR,QAAQ,QAAQ,SAAS;IACzB,OAAO,QAAQ,SAAS;GAC1B;GACA,YAAY;IACV,QAAQ,QAAQ,UAAU;IAC1B,kBAAkB,QAAQ,UAAU;GACtC;EACF;CACF;AACF"}
|
package/dist/risk.d.mts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { DecisionInput } from "./decision.mjs";
|
|
2
|
+
|
|
3
|
+
//#region src/risk.d.ts
|
|
4
|
+
/** Aggregate risk scoring for a verification session. */
|
|
5
|
+
declare class Risk {
|
|
6
|
+
/**
|
|
7
|
+
* Clamp a number into the [0, 1] range.
|
|
8
|
+
*
|
|
9
|
+
* @param value
|
|
10
|
+
* @returns
|
|
11
|
+
*/
|
|
12
|
+
static clamp01(value: number): number;
|
|
13
|
+
/**
|
|
14
|
+
* Compute an aggregate risk score in [0, 1] for a session (higher = riskier).
|
|
15
|
+
*
|
|
16
|
+
* The baseline is the inverse of the mean of the positive signals (document
|
|
17
|
+
* quality, OCR confidence, liveness score, face-match similarity). Hard failure
|
|
18
|
+
* signals (expired document, failed liveness/face-match, multiple faces) floor
|
|
19
|
+
* the risk near the top of the range so risky sessions never look safe.
|
|
20
|
+
*
|
|
21
|
+
* @param input
|
|
22
|
+
* @returns
|
|
23
|
+
*/
|
|
24
|
+
static score(input: DecisionInput): number;
|
|
25
|
+
}
|
|
26
|
+
//#endregion
|
|
27
|
+
export { Risk };
|
|
28
|
+
//# sourceMappingURL=risk.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"risk.d.mts","names":[],"sources":["../src/risk.ts"],"mappings":";;;;cASa,IAAA;EAAA;;;;;;EAAA,OAOJ,OAAA,CAAQ,KAAA;EAgBK;;;AAAa;;;;;;;;EAAb,OAAb,KAAA,CAAM,KAAA,EAAO,aAAa;AAAA"}
|
package/dist/risk.mjs
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
//#region src/risk.ts
|
|
2
|
+
/** Average of a list of numbers; 0 for an empty list. */
|
|
3
|
+
function mean(values) {
|
|
4
|
+
if (values.length === 0) return 0;
|
|
5
|
+
return values.reduce((sum, v) => sum + v, 0) / values.length;
|
|
6
|
+
}
|
|
7
|
+
/** Aggregate risk scoring for a verification session. */
|
|
8
|
+
var Risk = class Risk {
|
|
9
|
+
/**
|
|
10
|
+
* Clamp a number into the [0, 1] range.
|
|
11
|
+
*
|
|
12
|
+
* @param value
|
|
13
|
+
* @returns
|
|
14
|
+
*/
|
|
15
|
+
static clamp01(value) {
|
|
16
|
+
if (Number.isNaN(value)) return 0;
|
|
17
|
+
return Math.min(1, Math.max(0, value));
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Compute an aggregate risk score in [0, 1] for a session (higher = riskier).
|
|
21
|
+
*
|
|
22
|
+
* The baseline is the inverse of the mean of the positive signals (document
|
|
23
|
+
* quality, OCR confidence, liveness score, face-match similarity). Hard failure
|
|
24
|
+
* signals (expired document, failed liveness/face-match, multiple faces) floor
|
|
25
|
+
* the risk near the top of the range so risky sessions never look safe.
|
|
26
|
+
*
|
|
27
|
+
* @param input
|
|
28
|
+
* @returns
|
|
29
|
+
*/
|
|
30
|
+
static score(input) {
|
|
31
|
+
let risk = 1 - mean([
|
|
32
|
+
input.document.qualityScore,
|
|
33
|
+
input.document.ocrConfidence,
|
|
34
|
+
input.liveness.score,
|
|
35
|
+
input.faceMatch.similarityScore
|
|
36
|
+
].map(Risk.clamp01));
|
|
37
|
+
if (input.document.expired || !input.liveness.passed || !input.faceMatch.passed || input.liveness.multipleFaces === true) risk = Math.max(risk, .9);
|
|
38
|
+
return Risk.clamp01(risk);
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
//#endregion
|
|
42
|
+
export { Risk };
|
|
43
|
+
|
|
44
|
+
//# sourceMappingURL=risk.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"risk.mjs","names":[],"sources":["../src/risk.ts"],"sourcesContent":["import type { DecisionInput } from './decision'\n\n/** Average of a list of numbers; 0 for an empty list. */\nfunction mean(values: number[]): number {\n if (values.length === 0) return 0\n return values.reduce((sum, v) => sum + v, 0) / values.length\n}\n\n/** Aggregate risk scoring for a verification session. */\nexport class Risk {\n /**\n * Clamp a number into the [0, 1] range.\n *\n * @param value\n * @returns\n */\n static clamp01(value: number): number {\n if (Number.isNaN(value)) return 0\n return Math.min(1, Math.max(0, value))\n }\n\n /**\n * Compute an aggregate risk score in [0, 1] for a session (higher = riskier).\n *\n * The baseline is the inverse of the mean of the positive signals (document\n * quality, OCR confidence, liveness score, face-match similarity). Hard failure\n * signals (expired document, failed liveness/face-match, multiple faces) floor\n * the risk near the top of the range so risky sessions never look safe.\n *\n * @param input\n * @returns\n */\n static score(input: DecisionInput): number {\n const positives = [\n input.document.qualityScore,\n input.document.ocrConfidence,\n input.liveness.score,\n input.faceMatch.similarityScore,\n ].map(Risk.clamp01)\n\n let risk = 1 - mean(positives)\n\n const hardFailure =\n input.document.expired ||\n !input.liveness.passed ||\n !input.faceMatch.passed ||\n input.liveness.multipleFaces === true\n\n if (hardFailure) {\n risk = Math.max(risk, 0.9)\n }\n\n return Risk.clamp01(risk)\n }\n}\n"],"mappings":";;AAGA,SAAS,KAAK,QAA0B;CACtC,IAAI,OAAO,WAAW,GAAG,OAAO;CAChC,OAAO,OAAO,QAAQ,KAAK,MAAM,MAAM,GAAG,CAAC,IAAI,OAAO;AACxD;;AAGA,IAAa,OAAb,MAAa,KAAK;;;;;;;CAOhB,OAAO,QAAQ,OAAuB;EACpC,IAAI,OAAO,MAAM,KAAK,GAAG,OAAO;EAChC,OAAO,KAAK,IAAI,GAAG,KAAK,IAAI,GAAG,KAAK,CAAC;CACvC;;;;;;;;;;;;CAaA,OAAO,MAAM,OAA8B;EAQzC,IAAI,OAAO,IAAI,KAPG;GAChB,MAAM,SAAS;GACf,MAAM,SAAS;GACf,MAAM,SAAS;GACf,MAAM,UAAU;EAClB,CAAC,CAAC,IAAI,KAAK,OAEiB,CAAC;EAQ7B,IALE,MAAM,SAAS,WACf,CAAC,MAAM,SAAS,UAChB,CAAC,MAAM,UAAU,UACjB,MAAM,SAAS,kBAAkB,MAGjC,OAAO,KAAK,IAAI,MAAM,EAAG;EAG3B,OAAO,KAAK,QAAQ,IAAI;CAC1B;AACF"}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { IsoDate, IsoDateTime, VerificationStatus } from "@arkyc/types";
|
|
2
|
+
|
|
3
|
+
//#region src/session.d.ts
|
|
4
|
+
/** Pure session/document expiry rules. */
|
|
5
|
+
declare class SessionRules {
|
|
6
|
+
/**
|
|
7
|
+
* Whether a session has passed its `expires_at` instant.
|
|
8
|
+
*
|
|
9
|
+
* `now` is injected (not read from the clock) so this stays pure and testable.
|
|
10
|
+
*
|
|
11
|
+
* @param expiresAt
|
|
12
|
+
* @param now
|
|
13
|
+
* @returns
|
|
14
|
+
*/
|
|
15
|
+
static isSessionExpired(expiresAt: IsoDateTime | Date, now: IsoDateTime | Date): boolean;
|
|
16
|
+
/**
|
|
17
|
+
* Whether a document is expired relative to `now`.
|
|
18
|
+
*
|
|
19
|
+
* Expiry is treated as end-of-day: a document expiring on `now`'s date is still
|
|
20
|
+
* valid that day. Returns `false` when no expiry date is known.
|
|
21
|
+
*
|
|
22
|
+
* @param expiryDate
|
|
23
|
+
* @param now
|
|
24
|
+
* @returns
|
|
25
|
+
*/
|
|
26
|
+
static isDocumentExpired(expiryDate: IsoDate | null | undefined, now: IsoDateTime | Date): boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Whether a session in `status` should be auto-expired given its `expires_at`.
|
|
29
|
+
* Terminal sessions are never re-expired.
|
|
30
|
+
*
|
|
31
|
+
* @param status
|
|
32
|
+
* @param expiresAt
|
|
33
|
+
* @param now
|
|
34
|
+
* @returns
|
|
35
|
+
*/
|
|
36
|
+
static shouldExpire(status: VerificationStatus, expiresAt: IsoDateTime | Date, now: IsoDateTime | Date): boolean;
|
|
37
|
+
}
|
|
38
|
+
//#endregion
|
|
39
|
+
export { SessionRules };
|
|
40
|
+
//# sourceMappingURL=session.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"session.d.mts","names":[],"sources":["../src/session.ts"],"mappings":";;;;cAUa,YAAA;EAAA;;;;;;;;;EAAA,OAUJ,gBAAA,CAAiB,SAAA,EAAW,WAAA,GAAc,IAAA,EAAM,GAAA,EAAK,WAAA,GAAc,IAAA;EA8B9C;;;;;;;;;;EAAA,OAhBrB,iBAAA,CAAkB,UAAA,EAAY,OAAA,qBAA4B,GAAA,EAAK,WAAA,GAAc,IAAA;EAdxB;;;;;;;;;EAAA,OA8BrD,YAAA,CAAa,MAAA,EAAQ,kBAAA,EAAoB,SAAA,EAAW,WAAA,GAAc,IAAA,EAAM,GAAA,EAAK,WAAA,GAAc,IAAA;AAAA"}
|
package/dist/session.mjs
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { StatusMachine } from "./status.mjs";
|
|
2
|
+
//#region src/session.ts
|
|
3
|
+
/** Coerce an ISO string or Date into epoch milliseconds. */
|
|
4
|
+
function toMillis(value) {
|
|
5
|
+
return value instanceof Date ? value.getTime() : new Date(value).getTime();
|
|
6
|
+
}
|
|
7
|
+
/** Pure session/document expiry rules. */
|
|
8
|
+
var SessionRules = class SessionRules {
|
|
9
|
+
/**
|
|
10
|
+
* Whether a session has passed its `expires_at` instant.
|
|
11
|
+
*
|
|
12
|
+
* `now` is injected (not read from the clock) so this stays pure and testable.
|
|
13
|
+
*
|
|
14
|
+
* @param expiresAt
|
|
15
|
+
* @param now
|
|
16
|
+
* @returns
|
|
17
|
+
*/
|
|
18
|
+
static isSessionExpired(expiresAt, now) {
|
|
19
|
+
return toMillis(now) >= toMillis(expiresAt);
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Whether a document is expired relative to `now`.
|
|
23
|
+
*
|
|
24
|
+
* Expiry is treated as end-of-day: a document expiring on `now`'s date is still
|
|
25
|
+
* valid that day. Returns `false` when no expiry date is known.
|
|
26
|
+
*
|
|
27
|
+
* @param expiryDate
|
|
28
|
+
* @param now
|
|
29
|
+
* @returns
|
|
30
|
+
*/
|
|
31
|
+
static isDocumentExpired(expiryDate, now) {
|
|
32
|
+
if (!expiryDate) return false;
|
|
33
|
+
const expiryEndOfDay = (/* @__PURE__ */ new Date(`${expiryDate}T23:59:59.999Z`)).getTime();
|
|
34
|
+
if (Number.isNaN(expiryEndOfDay)) return false;
|
|
35
|
+
return toMillis(now) > expiryEndOfDay;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Whether a session in `status` should be auto-expired given its `expires_at`.
|
|
39
|
+
* Terminal sessions are never re-expired.
|
|
40
|
+
*
|
|
41
|
+
* @param status
|
|
42
|
+
* @param expiresAt
|
|
43
|
+
* @param now
|
|
44
|
+
* @returns
|
|
45
|
+
*/
|
|
46
|
+
static shouldExpire(status, expiresAt, now) {
|
|
47
|
+
if (StatusMachine.isTerminal(status)) return false;
|
|
48
|
+
return SessionRules.isSessionExpired(expiresAt, now);
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
//#endregion
|
|
52
|
+
export { SessionRules };
|
|
53
|
+
|
|
54
|
+
//# sourceMappingURL=session.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"session.mjs","names":[],"sources":["../src/session.ts"],"sourcesContent":["import type { IsoDate, IsoDateTime, VerificationStatus } from '@arkyc/types'\n\nimport { StatusMachine } from './status'\n\n/** Coerce an ISO string or Date into epoch milliseconds. */\nfunction toMillis(value: IsoDateTime | IsoDate | Date): number {\n return value instanceof Date ? value.getTime() : new Date(value).getTime()\n}\n\n/** Pure session/document expiry rules. */\nexport class SessionRules {\n /**\n * Whether a session has passed its `expires_at` instant.\n *\n * `now` is injected (not read from the clock) so this stays pure and testable.\n *\n * @param expiresAt\n * @param now\n * @returns\n */\n static isSessionExpired(expiresAt: IsoDateTime | Date, now: IsoDateTime | Date): boolean {\n return toMillis(now) >= toMillis(expiresAt)\n }\n\n /**\n * Whether a document is expired relative to `now`.\n *\n * Expiry is treated as end-of-day: a document expiring on `now`'s date is still\n * valid that day. Returns `false` when no expiry date is known.\n *\n * @param expiryDate\n * @param now\n * @returns\n */\n static isDocumentExpired(expiryDate: IsoDate | null | undefined, now: IsoDateTime | Date): boolean {\n if (!expiryDate) return false\n const expiryEndOfDay = new Date(`${expiryDate}T23:59:59.999Z`).getTime()\n if (Number.isNaN(expiryEndOfDay)) return false\n return toMillis(now) > expiryEndOfDay\n }\n\n /**\n * Whether a session in `status` should be auto-expired given its `expires_at`.\n * Terminal sessions are never re-expired.\n *\n * @param status\n * @param expiresAt\n * @param now\n * @returns\n */\n static shouldExpire(status: VerificationStatus, expiresAt: IsoDateTime | Date, now: IsoDateTime | Date): boolean {\n if (StatusMachine.isTerminal(status)) return false\n return SessionRules.isSessionExpired(expiresAt, now)\n }\n}\n"],"mappings":";;;AAKA,SAAS,SAAS,OAA6C;CAC7D,OAAO,iBAAiB,OAAO,MAAM,QAAQ,IAAI,IAAI,KAAK,KAAK,CAAC,CAAC,QAAQ;AAC3E;;AAGA,IAAa,eAAb,MAAa,aAAa;;;;;;;;;;CAUxB,OAAO,iBAAiB,WAA+B,KAAkC;EACvF,OAAO,SAAS,GAAG,KAAK,SAAS,SAAS;CAC5C;;;;;;;;;;;CAYA,OAAO,kBAAkB,YAAwC,KAAkC;EACjG,IAAI,CAAC,YAAY,OAAO;EACxB,MAAM,kCAAiB,IAAI,KAAK,GAAG,WAAW,eAAe,EAAA,CAAE,QAAQ;EACvE,IAAI,OAAO,MAAM,cAAc,GAAG,OAAO;EACzC,OAAO,SAAS,GAAG,IAAI;CACzB;;;;;;;;;;CAWA,OAAO,aAAa,QAA4B,WAA+B,KAAkC;EAC/G,IAAI,cAAc,WAAW,MAAM,GAAG,OAAO;EAC7C,OAAO,aAAa,iBAAiB,WAAW,GAAG;CACrD;AACF"}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { VerificationStatus } from "@arkyc/types";
|
|
2
|
+
|
|
3
|
+
//#region src/status.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* Thrown when an illegal status transition is attempted.
|
|
6
|
+
*/
|
|
7
|
+
declare class InvalidStatusTransitionError extends Error {
|
|
8
|
+
readonly from: VerificationStatus;
|
|
9
|
+
readonly to: VerificationStatus;
|
|
10
|
+
constructor(from: VerificationStatus, to: VerificationStatus);
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Verification session status-transition rules.
|
|
14
|
+
*/
|
|
15
|
+
declare class StatusMachine {
|
|
16
|
+
/**
|
|
17
|
+
* Allowed status transitions for a verification session.
|
|
18
|
+
*
|
|
19
|
+
* The happy path is:
|
|
20
|
+
* pending → started → document_submitted → liveness_submitted → processing
|
|
21
|
+
* → (approved | rejected | requires_review)
|
|
22
|
+
*
|
|
23
|
+
* `requires_review` can move to a terminal decision (manual) or back for a
|
|
24
|
+
* retry. `expired` and `cancelled` are reachable from any non-terminal state.
|
|
25
|
+
*/
|
|
26
|
+
static readonly TRANSITIONS: Readonly<Record<VerificationStatus, readonly VerificationStatus[]>>;
|
|
27
|
+
/** Statuses from which no further transition is possible. */
|
|
28
|
+
static readonly TERMINAL: readonly VerificationStatus[];
|
|
29
|
+
/**
|
|
30
|
+
* Whether `status` is terminal (no further transitions allowed).
|
|
31
|
+
*
|
|
32
|
+
* @param status
|
|
33
|
+
* @returns
|
|
34
|
+
*/
|
|
35
|
+
static isTerminal(status: VerificationStatus): boolean;
|
|
36
|
+
/**
|
|
37
|
+
* Whether moving from `from` to `to` is a permitted transition.
|
|
38
|
+
*
|
|
39
|
+
* @param from
|
|
40
|
+
* @param to
|
|
41
|
+
* @returns
|
|
42
|
+
*/
|
|
43
|
+
static canTransition(from: VerificationStatus, to: VerificationStatus): boolean;
|
|
44
|
+
/**
|
|
45
|
+
* Assert that `from → to` is permitted, throwing {@link InvalidStatusTransitionError}
|
|
46
|
+
* otherwise. Returns `to` so it can be used inline.
|
|
47
|
+
*
|
|
48
|
+
* @param from
|
|
49
|
+
* @param to
|
|
50
|
+
* @returns
|
|
51
|
+
*/
|
|
52
|
+
static assert(from: VerificationStatus, to: VerificationStatus): VerificationStatus;
|
|
53
|
+
}
|
|
54
|
+
//#endregion
|
|
55
|
+
export { InvalidStatusTransitionError, StatusMachine };
|
|
56
|
+
//# sourceMappingURL=status.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"status.d.mts","names":[],"sources":["../src/status.ts"],"mappings":";;;;;AAKA;cAAa,4BAAA,SAAqC,KAAA;EAAA,SAE9B,IAAA,EAAM,kBAAA;EAAA,SACN,EAAA,EAAI,kBAAA;cADJ,IAAA,EAAM,kBAAA,EACN,EAAA,EAAI,kBAAA;AAAA;;;;cAUX,aAAA;EAbqC;;;;;;;;;;EAAA,gBAwBhC,WAAA,EAAa,QAAA,CAAS,MAAA,CAAO,kBAAA,WAA6B,kBAAA;EArBlC;EAAA,gBAqCxB,QAAA,WAAmB,kBAAA;EA3BX;;;;;;EAAA,OAmCjB,UAAA,CAAW,MAAA,EAAQ,kBAAA;EAAA;;;;;;;EAAA,OAWnB,aAAA,CAAc,IAAA,EAAM,kBAAA,EAAoB,EAAA,EAAI,kBAAA;EAnCnC;;;;;;;;EAAA,OA+CT,MAAA,CAAO,IAAA,EAAM,kBAAA,EAAoB,EAAA,EAAI,kBAAA,GAAqB,kBAAA;AAAA"}
|
package/dist/status.mjs
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
//#region src/status.ts
|
|
2
|
+
/**
|
|
3
|
+
* Thrown when an illegal status transition is attempted.
|
|
4
|
+
*/
|
|
5
|
+
var InvalidStatusTransitionError = class extends Error {
|
|
6
|
+
from;
|
|
7
|
+
to;
|
|
8
|
+
constructor(from, to) {
|
|
9
|
+
super(`Invalid verification status transition: ${from} → ${to}`);
|
|
10
|
+
this.from = from;
|
|
11
|
+
this.to = to;
|
|
12
|
+
this.name = "InvalidStatusTransitionError";
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Verification session status-transition rules.
|
|
17
|
+
*/
|
|
18
|
+
var StatusMachine = class StatusMachine {
|
|
19
|
+
/**
|
|
20
|
+
* Allowed status transitions for a verification session.
|
|
21
|
+
*
|
|
22
|
+
* The happy path is:
|
|
23
|
+
* pending → started → document_submitted → liveness_submitted → processing
|
|
24
|
+
* → (approved | rejected | requires_review)
|
|
25
|
+
*
|
|
26
|
+
* `requires_review` can move to a terminal decision (manual) or back for a
|
|
27
|
+
* retry. `expired` and `cancelled` are reachable from any non-terminal state.
|
|
28
|
+
*/
|
|
29
|
+
static TRANSITIONS = {
|
|
30
|
+
pending: [
|
|
31
|
+
"started",
|
|
32
|
+
"expired",
|
|
33
|
+
"cancelled"
|
|
34
|
+
],
|
|
35
|
+
started: [
|
|
36
|
+
"document_submitted",
|
|
37
|
+
"liveness_submitted",
|
|
38
|
+
"expired",
|
|
39
|
+
"cancelled"
|
|
40
|
+
],
|
|
41
|
+
document_submitted: [
|
|
42
|
+
"liveness_submitted",
|
|
43
|
+
"processing",
|
|
44
|
+
"expired",
|
|
45
|
+
"cancelled"
|
|
46
|
+
],
|
|
47
|
+
liveness_submitted: [
|
|
48
|
+
"processing",
|
|
49
|
+
"expired",
|
|
50
|
+
"cancelled"
|
|
51
|
+
],
|
|
52
|
+
processing: [
|
|
53
|
+
"approved",
|
|
54
|
+
"rejected",
|
|
55
|
+
"requires_review",
|
|
56
|
+
"expired",
|
|
57
|
+
"cancelled"
|
|
58
|
+
],
|
|
59
|
+
requires_review: [
|
|
60
|
+
"approved",
|
|
61
|
+
"rejected",
|
|
62
|
+
"started",
|
|
63
|
+
"document_submitted",
|
|
64
|
+
"cancelled"
|
|
65
|
+
],
|
|
66
|
+
approved: [],
|
|
67
|
+
rejected: [],
|
|
68
|
+
expired: [],
|
|
69
|
+
cancelled: []
|
|
70
|
+
};
|
|
71
|
+
/** Statuses from which no further transition is possible. */
|
|
72
|
+
static TERMINAL = [
|
|
73
|
+
"approved",
|
|
74
|
+
"rejected",
|
|
75
|
+
"expired",
|
|
76
|
+
"cancelled"
|
|
77
|
+
];
|
|
78
|
+
/**
|
|
79
|
+
* Whether `status` is terminal (no further transitions allowed).
|
|
80
|
+
*
|
|
81
|
+
* @param status
|
|
82
|
+
* @returns
|
|
83
|
+
*/
|
|
84
|
+
static isTerminal(status) {
|
|
85
|
+
return StatusMachine.TERMINAL.includes(status);
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Whether moving from `from` to `to` is a permitted transition.
|
|
89
|
+
*
|
|
90
|
+
* @param from
|
|
91
|
+
* @param to
|
|
92
|
+
* @returns
|
|
93
|
+
*/
|
|
94
|
+
static canTransition(from, to) {
|
|
95
|
+
return StatusMachine.TRANSITIONS[from].includes(to);
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Assert that `from → to` is permitted, throwing {@link InvalidStatusTransitionError}
|
|
99
|
+
* otherwise. Returns `to` so it can be used inline.
|
|
100
|
+
*
|
|
101
|
+
* @param from
|
|
102
|
+
* @param to
|
|
103
|
+
* @returns
|
|
104
|
+
*/
|
|
105
|
+
static assert(from, to) {
|
|
106
|
+
if (!StatusMachine.canTransition(from, to)) throw new InvalidStatusTransitionError(from, to);
|
|
107
|
+
return to;
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
//#endregion
|
|
111
|
+
export { InvalidStatusTransitionError, StatusMachine };
|
|
112
|
+
|
|
113
|
+
//# sourceMappingURL=status.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"status.mjs","names":[],"sources":["../src/status.ts"],"sourcesContent":["import type { VerificationStatus } from '@arkyc/types'\n\n/**\n * Thrown when an illegal status transition is attempted.\n */\nexport class InvalidStatusTransitionError extends Error {\n constructor(\n public readonly from: VerificationStatus,\n public readonly to: VerificationStatus,\n ) {\n super(`Invalid verification status transition: ${from} → ${to}`)\n this.name = 'InvalidStatusTransitionError'\n }\n}\n\n/**\n * Verification session status-transition rules.\n */\nexport class StatusMachine {\n /**\n * Allowed status transitions for a verification session.\n *\n * The happy path is:\n * pending → started → document_submitted → liveness_submitted → processing\n * → (approved | rejected | requires_review)\n *\n * `requires_review` can move to a terminal decision (manual) or back for a\n * retry. `expired` and `cancelled` are reachable from any non-terminal state.\n */\n static readonly TRANSITIONS: Readonly<Record<VerificationStatus, readonly VerificationStatus[]>> = {\n pending: ['started', 'expired', 'cancelled'],\n // `liveness_submitted` is reachable directly when a workflow runs liveness\n // before (or instead of) document capture.\n started: ['document_submitted', 'liveness_submitted', 'expired', 'cancelled'],\n document_submitted: ['liveness_submitted', 'processing', 'expired', 'cancelled'],\n liveness_submitted: ['processing', 'expired', 'cancelled'],\n processing: ['approved', 'rejected', 'requires_review', 'expired', 'cancelled'],\n requires_review: ['approved', 'rejected', 'started', 'document_submitted', 'cancelled'],\n approved: [],\n rejected: [],\n expired: [],\n cancelled: [],\n }\n\n /** Statuses from which no further transition is possible. */\n static readonly TERMINAL: readonly VerificationStatus[] = ['approved', 'rejected', 'expired', 'cancelled']\n\n /**\n * Whether `status` is terminal (no further transitions allowed).\n *\n * @param status\n * @returns\n */\n static isTerminal(status: VerificationStatus): boolean {\n return StatusMachine.TERMINAL.includes(status)\n }\n\n /**\n * Whether moving from `from` to `to` is a permitted transition.\n *\n * @param from\n * @param to\n * @returns\n */\n static canTransition(from: VerificationStatus, to: VerificationStatus): boolean {\n return StatusMachine.TRANSITIONS[from].includes(to)\n }\n\n /**\n * Assert that `from → to` is permitted, throwing {@link InvalidStatusTransitionError}\n * otherwise. Returns `to` so it can be used inline.\n *\n * @param from\n * @param to\n * @returns\n */\n static assert(from: VerificationStatus, to: VerificationStatus): VerificationStatus {\n if (!StatusMachine.canTransition(from, to)) {\n throw new InvalidStatusTransitionError(from, to)\n }\n return to\n }\n}\n"],"mappings":";;;;AAKA,IAAa,+BAAb,cAAkD,MAAM;CAEpC;CACA;CAFlB,YACE,MACA,IACA;EACA,MAAM,2CAA2C,KAAK,KAAK,IAAI;EAH/C,KAAA,OAAA;EACA,KAAA,KAAA;EAGhB,KAAK,OAAO;CACd;AACF;;;;AAKA,IAAa,gBAAb,MAAa,cAAc;;;;;;;;;;;CAWzB,OAAgB,cAAmF;EACjG,SAAS;GAAC;GAAW;GAAW;EAAW;EAG3C,SAAS;GAAC;GAAsB;GAAsB;GAAW;EAAW;EAC5E,oBAAoB;GAAC;GAAsB;GAAc;GAAW;EAAW;EAC/E,oBAAoB;GAAC;GAAc;GAAW;EAAW;EACzD,YAAY;GAAC;GAAY;GAAY;GAAmB;GAAW;EAAW;EAC9E,iBAAiB;GAAC;GAAY;GAAY;GAAW;GAAsB;EAAW;EACtF,UAAU,CAAC;EACX,UAAU,CAAC;EACX,SAAS,CAAC;EACV,WAAW,CAAC;CACd;;CAGA,OAAgB,WAA0C;EAAC;EAAY;EAAY;EAAW;CAAW;;;;;;;CAQzG,OAAO,WAAW,QAAqC;EACrD,OAAO,cAAc,SAAS,SAAS,MAAM;CAC/C;;;;;;;;CASA,OAAO,cAAc,MAA0B,IAAiC;EAC9E,OAAO,cAAc,YAAY,KAAK,CAAC,SAAS,EAAE;CACpD;;;;;;;;;CAUA,OAAO,OAAO,MAA0B,IAA4C;EAClF,IAAI,CAAC,cAAc,cAAc,MAAM,EAAE,GACvC,MAAM,IAAI,6BAA6B,MAAM,EAAE;EAEjD,OAAO;CACT;AACF"}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { VerificationThresholds } from "@arkyc/types";
|
|
2
|
+
|
|
3
|
+
//#region src/thresholds.d.ts
|
|
4
|
+
/** Verification threshold defaults and override resolution. */
|
|
5
|
+
declare class Thresholds {
|
|
6
|
+
/**
|
|
7
|
+
* Platform default verification thresholds. Projects may override any subset
|
|
8
|
+
* via `ProjectSettings.thresholds`; {@link Thresholds.resolve} merges them.
|
|
9
|
+
*/
|
|
10
|
+
static readonly DEFAULTS: VerificationThresholds;
|
|
11
|
+
/**
|
|
12
|
+
* Merge a partial set of project overrides over the platform defaults.
|
|
13
|
+
*
|
|
14
|
+
* @param overrides
|
|
15
|
+
* @returns
|
|
16
|
+
*/
|
|
17
|
+
static resolve(overrides?: Partial<VerificationThresholds>): VerificationThresholds;
|
|
18
|
+
}
|
|
19
|
+
//#endregion
|
|
20
|
+
export { Thresholds };
|
|
21
|
+
//# sourceMappingURL=thresholds.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"thresholds.d.mts","names":[],"sources":["../src/thresholds.ts"],"mappings":";;;;cAGa,UAAA;EAAA;;;;EAAA,gBAKK,QAAA,EAAU,sBAAA;EAaC;;;;;;EAAA,OAApB,OAAA,CAAQ,SAAA,GAAY,OAAA,CAAQ,sBAAA,IAA0B,sBAAA;AAAA"}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
//#region src/thresholds.ts
|
|
2
|
+
/** Verification threshold defaults and override resolution. */
|
|
3
|
+
var Thresholds = class Thresholds {
|
|
4
|
+
/**
|
|
5
|
+
* Platform default verification thresholds. Projects may override any subset
|
|
6
|
+
* via `ProjectSettings.thresholds`; {@link Thresholds.resolve} merges them.
|
|
7
|
+
*/
|
|
8
|
+
static DEFAULTS = {
|
|
9
|
+
documentQualityThreshold: .75,
|
|
10
|
+
ocrConfidenceThreshold: .8,
|
|
11
|
+
livenessThreshold: .85,
|
|
12
|
+
faceMatchThreshold: .75
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Merge a partial set of project overrides over the platform defaults.
|
|
16
|
+
*
|
|
17
|
+
* @param overrides
|
|
18
|
+
* @returns
|
|
19
|
+
*/
|
|
20
|
+
static resolve(overrides) {
|
|
21
|
+
return {
|
|
22
|
+
...Thresholds.DEFAULTS,
|
|
23
|
+
...overrides ?? {}
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
//#endregion
|
|
28
|
+
export { Thresholds };
|
|
29
|
+
|
|
30
|
+
//# sourceMappingURL=thresholds.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"thresholds.mjs","names":[],"sources":["../src/thresholds.ts"],"sourcesContent":["import type { VerificationThresholds } from '@arkyc/types'\n\n/** Verification threshold defaults and override resolution. */\nexport class Thresholds {\n /**\n * Platform default verification thresholds. Projects may override any subset\n * via `ProjectSettings.thresholds`; {@link Thresholds.resolve} merges them.\n */\n static readonly DEFAULTS: VerificationThresholds = {\n documentQualityThreshold: 0.75,\n ocrConfidenceThreshold: 0.8,\n livenessThreshold: 0.85,\n faceMatchThreshold: 0.75,\n }\n\n /**\n * Merge a partial set of project overrides over the platform defaults.\n *\n * @param overrides\n * @returns\n */\n static resolve(overrides?: Partial<VerificationThresholds>): VerificationThresholds {\n return { ...Thresholds.DEFAULTS, ...(overrides ?? {}) }\n }\n}\n"],"mappings":";;AAGA,IAAa,aAAb,MAAa,WAAW;;;;;CAKtB,OAAgB,WAAmC;EACjD,0BAA0B;EAC1B,wBAAwB;EACxB,mBAAmB;EACnB,oBAAoB;CACtB;;;;;;;CAQA,OAAO,QAAQ,WAAqE;EAClF,OAAO;GAAE,GAAG,WAAW;GAAU,GAAI,aAAa,CAAC;EAAG;CACxD;AACF"}
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@arkyc/core",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Decision engine, status transitions, and scoring logic",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./dist/index.mjs",
|
|
8
|
+
"module": "./dist/index.mjs",
|
|
9
|
+
"types": "./dist/index.d.mts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./dist/index.d.mts",
|
|
13
|
+
"import": "./dist/index.mjs"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist"
|
|
18
|
+
],
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@arkyc/types": "^1.0.0"
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"typecheck": "tsc --noEmit",
|
|
24
|
+
"test": "vitest run",
|
|
25
|
+
"lint": "eslint src",
|
|
26
|
+
"clean": "rm -rf dist"
|
|
27
|
+
}
|
|
28
|
+
}
|