@mudraforge/statutory 2.4.1 → 2.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 +25 -0
- package/README.md +53 -12
- package/bin/cli.js +102 -8
- package/docs/ARCHITECTURE_DECISIONS.md +13 -9
- package/docs/CATALOG.md +164 -0
- package/docs/CORPUS_QUALITY.md +9 -6
- package/docs/PILOT_EVALUATION_GUIDE.md +149 -0
- package/okf/direct_tax/tds_commission_194h/circulars/finance_act_2024_rate_cut_memo.md +29 -0
- package/okf/direct_tax/tds_commission_194h/forms/form_26q_quarterly_return.json +30 -0
- package/okf/direct_tax/tds_commission_194h/section.json +63 -0
- package/okf/direct_tax/tds_contractor_194c/circulars/cbdt_circular_23_2017_gst_unbundling.md +27 -0
- package/okf/direct_tax/tds_contractor_194c/forms/form_26q_quarterly_return.json +31 -0
- package/okf/direct_tax/tds_contractor_194c/forms/transporter_194c_declaration.json +14 -0
- package/okf/direct_tax/tds_contractor_194c/section.json +66 -0
- package/okf/direct_tax/tds_rent_194i/circulars/cbdt_circular_23_2017_gst_unbundling.md +27 -0
- package/okf/direct_tax/tds_rent_194i/forms/form_26q_quarterly_return.json +31 -0
- package/okf/direct_tax/tds_rent_194i/section.json +63 -0
- package/okf/indirect_tax_gst/composite_mixed_supplies_sec8/circulars/cbic_circular_47_2018_composite_mixed_supplies.md +41 -0
- package/okf/indirect_tax_gst/composite_mixed_supplies_sec8/forms/composite_mixed_supply_assessment_matrix.json +51 -0
- package/okf/indirect_tax_gst/composite_mixed_supplies_sec8/section.json +58 -0
- package/okf/indirect_tax_gst/itc_eligibility_16_2/circulars/cbic_circular_170_2022_itc_reporting.md +53 -0
- package/okf/indirect_tax_gst/itc_eligibility_16_2/forms/gstr_3b_table_4_itc_reversal.json +32 -0
- package/okf/indirect_tax_gst/itc_eligibility_16_2/section.json +84 -0
- package/okf/indirect_tax_gst/rcm_liability_sec9_3/circulars/cbic_circular_102_2019_rcm_clarifications.md +33 -0
- package/okf/indirect_tax_gst/rcm_liability_sec9_3/forms/rcm_self_invoice_payment_voucher.json +65 -0
- package/okf/indirect_tax_gst/rcm_liability_sec9_3/section.json +67 -0
- package/okf/international_tax/cross_border_remittance_195/circulars/cbdt_circular_03_2022_form10f_electronic.md +31 -0
- package/okf/international_tax/cross_border_remittance_195/circulars/supreme_court_engineering_analysis_doctrine.md +41 -0
- package/okf/international_tax/cross_border_remittance_195/forms/form_15ca_15cb_compliance_checklist.json +73 -0
- package/okf/international_tax/cross_border_remittance_195/section.json +74 -0
- package/okf/manifest.json +66 -13
- package/okf/msme_commercial/enterprise_classification_sec7/circulars/msme_notification_2119_e_composite_criteria.md +37 -0
- package/okf/msme_commercial/enterprise_classification_sec7/forms/udyam_composite_calculation_worksheet.json +46 -0
- package/okf/msme_commercial/enterprise_classification_sec7/section.json +64 -0
- package/okf/msme_commercial/mca_msme_1_filing/circulars/mca_order_2019_specified_companies.md +42 -0
- package/okf/msme_commercial/mca_msme_1_filing/forms/form_msme_1_half_yearly_return.json +93 -0
- package/okf/msme_commercial/mca_msme_1_filing/section.json +56 -0
- package/okf/msme_commercial/statutory_disclosures_sec22/circulars/icai_guidance_note_schedule_iii_trade_payables.md +42 -0
- package/okf/msme_commercial/statutory_disclosures_sec22/forms/schedule_iii_msme_aging_table.json +60 -0
- package/okf/msme_commercial/statutory_disclosures_sec22/forms/section_23_tax_addback_schedule.json +35 -0
- package/okf/msme_commercial/statutory_disclosures_sec22/section.json +65 -0
- package/package.json +2 -2
- package/src/index.js +360 -37
- package/types/index.d.ts +27 -0
package/src/index.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import fs from 'node:fs';
|
|
12
12
|
import path from 'node:path';
|
|
13
|
+
import os from 'node:os';
|
|
13
14
|
import crypto from 'node:crypto';
|
|
14
15
|
import { fileURLToPath } from 'node:url';
|
|
15
16
|
|
|
@@ -17,13 +18,18 @@ const __filename = fileURLToPath(import.meta.url);
|
|
|
17
18
|
const __dirname = path.dirname(__filename);
|
|
18
19
|
export const DEFAULT_LOCAL_OKF_DIR = path.resolve(__dirname, '..', 'okf');
|
|
19
20
|
export const DEFAULT_BASE_URL = 'https://api.mudraforge.in';
|
|
20
|
-
export const STATUTORY_CLIENT_VERSION = '2.
|
|
21
|
+
export const STATUTORY_CLIENT_VERSION = '2.5.0';
|
|
21
22
|
|
|
22
23
|
/**
|
|
23
24
|
* Pinned official release Merkle root digests.
|
|
24
25
|
* Prevents unauthorized bundle substitution attacks even if an attacker controls the remote endpoint.
|
|
25
26
|
*/
|
|
26
27
|
export const PINNED_RELEASE_ROOTS = new Set([
|
|
28
|
+
'sha256-c32be389bfb82a52ffc40670b74103216c45f3b284c66d2b0020097eb36a25f9', // v2.5.0 Merkle Manifest Root (18 Sections across 5 Domains)
|
|
29
|
+
'sha256-f3b230a0774eb55d607403b5a656bbe2950a32d07145a46f0eb083232ec12986', // v2.6.0 Edge Sync Bundle Root (12 Sections across 5 Domains)
|
|
30
|
+
'sha256-4e9d9c064e55748dc6389eae1b5cb2fec1ad7f1c811097afd57e4fb0a6f6454e', // v2.6.0 Local Static Manifest Root (12 Sections across 5 Domains)
|
|
31
|
+
'sha256-b421910e35f4f16321f6a72382377db66a05ead521ff09d18a91ec9d534d0069', // v2.6.0 Edge Sync Bundle Root (11 Sections)
|
|
32
|
+
'sha256-dbbd3bc618b7807c4e76861329bd8c6903ef7f9cb28efc9a8693abc06915c9c1', // v2.6.0 Local Static Manifest Root (11 Sections)
|
|
27
33
|
'sha256-7fe98cf432db5d31ee6b65bbb9d77575ce8c9192eec6f318e3ad3382af0ba900', // v2.5.0 Edge Sync Bundle Root
|
|
28
34
|
'sha256-96089fe2835d0b9493293a1e5205e0b6b46f16771c9380d1f571969b453ba80d', // v2.4.0 Local Static Manifest Root
|
|
29
35
|
'sha256-d9612074241f7c2cc1d05fa5eee254996f21db56ad1ab71ac36a084ffd4169e1' // Baseline Release Root
|
|
@@ -33,14 +39,16 @@ export const PINNED_RELEASE_ROOTS = new Set([
|
|
|
33
39
|
* Canonical MudraForge Statutory Root Ed25519 Public Key (SPKI DER Base64).
|
|
34
40
|
* Used for verifying digital signatures on remote statutory update bundles.
|
|
35
41
|
*/
|
|
36
|
-
export const MUDRAFORGE_STATUTORY_ROOT_PUBLIC_KEY = '
|
|
42
|
+
export const MUDRAFORGE_STATUTORY_ROOT_PUBLIC_KEY = 'MCowBQYDK2VwAyEAYN4ZtkUBOwybN68SiVUI9YMDlEBNiCa1CAgUNx6rXUo=';
|
|
43
|
+
export const LEGACY_STATUTORY_ROOT_PUBLIC_KEY = 'MCowBQYDK2VwAyEAw/XsE6QGtQzdq3EUP3xFpPFHu1x87VSNreXmXr6W17s=';
|
|
37
44
|
|
|
38
45
|
/**
|
|
39
46
|
* Authoritative set of trusted MudraForge Root Public Keys.
|
|
40
47
|
* Public keys supplied through options that are not present in this set are rejected.
|
|
41
48
|
*/
|
|
42
49
|
export const TRUSTED_STATUTORY_PUBLIC_KEYS = new Set([
|
|
43
|
-
MUDRAFORGE_STATUTORY_ROOT_PUBLIC_KEY
|
|
50
|
+
MUDRAFORGE_STATUTORY_ROOT_PUBLIC_KEY,
|
|
51
|
+
LEGACY_STATUTORY_ROOT_PUBLIC_KEY
|
|
44
52
|
]);
|
|
45
53
|
|
|
46
54
|
/**
|
|
@@ -50,7 +58,15 @@ export const TRUSTED_STATUTORY_KEYS_METADATA = {
|
|
|
50
58
|
[MUDRAFORGE_STATUTORY_ROOT_PUBLIC_KEY]: {
|
|
51
59
|
keyId: 'mudraforge-root-2026-v1',
|
|
52
60
|
algorithm: 'Ed25519',
|
|
53
|
-
purpose: 'MudraForge Open Knowledge Format Statutory Root Authority',
|
|
61
|
+
purpose: 'MudraForge Open Knowledge Format Statutory Root Authority (Active)',
|
|
62
|
+
validFrom: '2026-01-01T00:00:00Z',
|
|
63
|
+
validTo: '2027-12-31T23:59:59Z',
|
|
64
|
+
status: 'ACTIVE'
|
|
65
|
+
},
|
|
66
|
+
[LEGACY_STATUTORY_ROOT_PUBLIC_KEY]: {
|
|
67
|
+
keyId: 'mudraforge-root-2026-v0',
|
|
68
|
+
algorithm: 'Ed25519',
|
|
69
|
+
purpose: 'MudraForge Statutory Genesis Anchor (Legacy)',
|
|
54
70
|
validFrom: '2026-01-01T00:00:00Z',
|
|
55
71
|
validTo: '2027-12-31T23:59:59Z',
|
|
56
72
|
status: 'ACTIVE'
|
|
@@ -304,6 +320,7 @@ export function lookupLocalStatute(section, okfDir = DEFAULT_LOCAL_OKF_DIR) {
|
|
|
304
320
|
}
|
|
305
321
|
|
|
306
322
|
const files = findJsonFilesRecursively(okfDir);
|
|
323
|
+
const records = [];
|
|
307
324
|
for (const filePath of files) {
|
|
308
325
|
let data;
|
|
309
326
|
try {
|
|
@@ -311,55 +328,68 @@ export function lookupLocalStatute(section, okfDir = DEFAULT_LOCAL_OKF_DIR) {
|
|
|
311
328
|
} catch (_) {
|
|
312
329
|
continue;
|
|
313
330
|
}
|
|
314
|
-
|
|
315
|
-
// Must be a valid statutory record with sectionCode
|
|
316
331
|
if (!data || !data.sectionCode || typeof data.sectionCode !== 'string') continue;
|
|
332
|
+
records.push({ filePath, data });
|
|
333
|
+
}
|
|
317
334
|
|
|
318
|
-
|
|
335
|
+
let matchedRecord = null;
|
|
336
|
+
|
|
337
|
+
// Pass 1: Authoritative exact match on sectionCode, sectionId, or aliases
|
|
338
|
+
for (const rec of records) {
|
|
339
|
+
const { data } = rec;
|
|
319
340
|
const secCode = data.sectionCode.toLowerCase();
|
|
320
341
|
const secId = (data.sectionId && typeof data.sectionId === 'string') ? data.sectionId.toLowerCase() : '';
|
|
321
342
|
|
|
322
343
|
// 1. Direct sectionCode / sectionId match
|
|
323
344
|
if (secCode === rawClean || (secId && secId === rawClean)) {
|
|
324
|
-
|
|
345
|
+
matchedRecord = rec;
|
|
346
|
+
break;
|
|
325
347
|
}
|
|
326
348
|
|
|
327
349
|
// 2. Normalized alphanumeric match (handles "17_5", "17(5)", "17-5", "msmed_15_16")
|
|
328
350
|
if (
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
(secId && secId.replace(/[()_-\s]+/g, '') === normalized)
|
|
332
|
-
)
|
|
351
|
+
secCode.replace(/[()_-\s]+/g, '') === normalized ||
|
|
352
|
+
(secId && secId.replace(/[()_-\s]+/g, '') === normalized)
|
|
333
353
|
) {
|
|
334
|
-
|
|
354
|
+
matchedRecord = rec;
|
|
355
|
+
break;
|
|
335
356
|
}
|
|
336
357
|
|
|
337
358
|
// 3. Aliases match
|
|
338
359
|
if (
|
|
339
|
-
!isMatch &&
|
|
340
360
|
data.aliases &&
|
|
341
361
|
data.aliases.some(
|
|
342
362
|
a => a.toLowerCase() === rawClean || a.toLowerCase().replace(/[()_-\s]+/g, '') === normalized
|
|
343
363
|
)
|
|
344
364
|
) {
|
|
345
|
-
|
|
365
|
+
matchedRecord = rec;
|
|
366
|
+
break;
|
|
346
367
|
}
|
|
368
|
+
}
|
|
347
369
|
|
|
348
|
-
|
|
349
|
-
|
|
370
|
+
// Pass 2: Heuristic folder or filename match (only if no authoritative code match found)
|
|
371
|
+
if (!matchedRecord) {
|
|
372
|
+
const escaped = normalized.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
373
|
+
const tokenRegex = new RegExp(`(^|[^a-z0-9])${escaped}([^a-z0-9]|$)`, 'i');
|
|
374
|
+
for (const rec of records) {
|
|
375
|
+
const { filePath } = rec;
|
|
350
376
|
const fileName = path.basename(filePath).toLowerCase().replace(/\.json$/, '');
|
|
351
377
|
const folderName = path.basename(path.dirname(filePath)).toLowerCase();
|
|
378
|
+
|
|
352
379
|
if (
|
|
353
|
-
fileName.startsWith(rawClean) || fileName.startsWith(normalized) ||
|
|
354
|
-
|
|
380
|
+
fileName.startsWith(`${rawClean}-`) || fileName.startsWith(`${normalized}-`) ||
|
|
381
|
+
tokenRegex.test(folderName) || tokenRegex.test(fileName)
|
|
355
382
|
) {
|
|
356
|
-
|
|
383
|
+
matchedRecord = rec;
|
|
384
|
+
break;
|
|
357
385
|
}
|
|
358
386
|
}
|
|
387
|
+
}
|
|
359
388
|
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
389
|
+
if (matchedRecord) {
|
|
390
|
+
const { filePath, data } = matchedRecord;
|
|
391
|
+
// Attach supporting circulars and compliance forms if present in domain hierarchy
|
|
392
|
+
const parentDir = path.dirname(filePath);
|
|
363
393
|
const circularsDir = path.join(parentDir, 'circulars');
|
|
364
394
|
const formsDir = path.join(parentDir, 'forms');
|
|
365
395
|
|
|
@@ -369,7 +399,6 @@ export function lookupLocalStatute(section, okfDir = DEFAULT_LOCAL_OKF_DIR) {
|
|
|
369
399
|
};
|
|
370
400
|
|
|
371
401
|
return data;
|
|
372
|
-
}
|
|
373
402
|
}
|
|
374
403
|
return null;
|
|
375
404
|
}
|
|
@@ -430,6 +459,78 @@ export async function checkUpdates(options = {}) {
|
|
|
430
459
|
};
|
|
431
460
|
}
|
|
432
461
|
|
|
462
|
+
/**
|
|
463
|
+
* Check if a newer version of the @mudraforge/statutory npm package is published on npmjs.com.
|
|
464
|
+
* Uses a lightweight cached file in os.tmpdir() to prevent frequent network roundtrips.
|
|
465
|
+
*
|
|
466
|
+
* @param {Object} [options]
|
|
467
|
+
* @param {string} [options.currentVersion] - Current installed version (defaults to STATUTORY_CLIENT_VERSION).
|
|
468
|
+
* @param {number} [options.cacheTtlMs] - Cache duration in milliseconds (default: 24 hours).
|
|
469
|
+
* @param {number} [options.timeoutMs] - Request timeout in milliseconds (default: 1500ms).
|
|
470
|
+
* @param {string} [options.registryUrl] - Registry endpoint (default: https://registry.npmjs.org/@mudraforge/statutory/latest).
|
|
471
|
+
* @returns {Promise<{ updateAvailable: boolean, currentVersion: string, latestVersion: string } | null>}
|
|
472
|
+
*/
|
|
473
|
+
export async function checkForPackageUpdate(options = {}) {
|
|
474
|
+
if (process.env.NO_UPDATE_NOTIFIER === '1' || process.env.CI === 'true') {
|
|
475
|
+
return null;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
const currentVersion = options.currentVersion || STATUTORY_CLIENT_VERSION;
|
|
479
|
+
const cacheTtlMs = options.cacheTtlMs !== undefined ? options.cacheTtlMs : 24 * 60 * 60 * 1000;
|
|
480
|
+
const timeoutMs = options.timeoutMs || 1500;
|
|
481
|
+
const cacheFile = path.join(os.tmpdir(), '.mudraforge_statutory_npm_update.json');
|
|
482
|
+
|
|
483
|
+
let cached = null;
|
|
484
|
+
if (cacheTtlMs > 0) {
|
|
485
|
+
try {
|
|
486
|
+
if (fs.existsSync(cacheFile)) {
|
|
487
|
+
const data = JSON.parse(fs.readFileSync(cacheFile, 'utf8'));
|
|
488
|
+
if (data && typeof data.lastChecked === 'number' && (Date.now() - data.lastChecked < cacheTtlMs)) {
|
|
489
|
+
cached = data;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
} catch (_) {}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
let latestVersion = cached?.latestVersion;
|
|
496
|
+
|
|
497
|
+
if (!latestVersion) {
|
|
498
|
+
try {
|
|
499
|
+
const url = options.registryUrl || 'https://registry.npmjs.org/@mudraforge/statutory/latest';
|
|
500
|
+
const res = await fetch(url, {
|
|
501
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
502
|
+
headers: { 'Accept': 'application/json' }
|
|
503
|
+
});
|
|
504
|
+
if (res.ok) {
|
|
505
|
+
const json = await res.json();
|
|
506
|
+
if (json && json.version && typeof json.version === 'string') {
|
|
507
|
+
latestVersion = json.version;
|
|
508
|
+
if (cacheTtlMs > 0) {
|
|
509
|
+
try {
|
|
510
|
+
fs.writeFileSync(cacheFile, JSON.stringify({
|
|
511
|
+
lastChecked: Date.now(),
|
|
512
|
+
latestVersion
|
|
513
|
+
}), 'utf8');
|
|
514
|
+
} catch (_) {}
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
} catch (_) {
|
|
519
|
+
// Fail open and silent on network error, offline, or timeout
|
|
520
|
+
return null;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
if (!latestVersion) return null;
|
|
525
|
+
|
|
526
|
+
const cmp = compareSemver(latestVersion, currentVersion);
|
|
527
|
+
return {
|
|
528
|
+
updateAvailable: cmp > 0,
|
|
529
|
+
currentVersion,
|
|
530
|
+
latestVersion
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
|
|
433
534
|
/**
|
|
434
535
|
* Download and synchronize the latest OKF knowledge bundle to disk.
|
|
435
536
|
* Hardened with:
|
|
@@ -466,7 +567,6 @@ export async function syncOkf(options = {}) {
|
|
|
466
567
|
assertSecureEndpoint(baseUrl);
|
|
467
568
|
|
|
468
569
|
const okfDir = options.okfDir || DEFAULT_LOCAL_OKF_DIR;
|
|
469
|
-
const sectionsDir = path.join(okfDir, 'sections');
|
|
470
570
|
const localManifest = getLocalManifest(okfDir);
|
|
471
571
|
|
|
472
572
|
const bundleUrl = new URL('/api/v1/statutory/bundle.json', baseUrl);
|
|
@@ -554,6 +654,7 @@ export async function syncOkf(options = {}) {
|
|
|
554
654
|
const fileHashes = {};
|
|
555
655
|
const syncedSectionCodes = [];
|
|
556
656
|
const seenCodes = new Set();
|
|
657
|
+
const stagedRelPaths = [];
|
|
557
658
|
|
|
558
659
|
for (const statute of bundle.statutes) {
|
|
559
660
|
// Verify core required statutory fields
|
|
@@ -567,20 +668,30 @@ export async function syncOkf(options = {}) {
|
|
|
567
668
|
}
|
|
568
669
|
seenCodes.add(codeKey);
|
|
569
670
|
|
|
570
|
-
|
|
571
|
-
|
|
671
|
+
// Determine relative deployment target:
|
|
672
|
+
// If statute provides relPath, preserve domain hierarchy (e.g. "direct_tax/tds_goods_194q/section.json")
|
|
673
|
+
// Otherwise, fall back to flat "sections/<filename>.json" for legacy compatibility
|
|
674
|
+
const relTarget = statute.relPath
|
|
675
|
+
? statute.relPath.replace(/\\/g, '/')
|
|
676
|
+
: path.join('sections', resolveSafeStatuteFilename(statute));
|
|
677
|
+
|
|
678
|
+
const targetPath = path.resolve(stagingDir, relTarget);
|
|
572
679
|
|
|
573
680
|
// Path Traversal Containment Assertion
|
|
574
681
|
if (!targetPath.startsWith(path.resolve(stagingDir) + path.sep)) {
|
|
575
|
-
throw new Error(`[@mudraforge/statutory] Security violation: Path traversal detected in
|
|
682
|
+
throw new Error(`[@mudraforge/statutory] Security violation: Path traversal detected in target "${relTarget}".`);
|
|
576
683
|
}
|
|
577
684
|
|
|
685
|
+
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
|
686
|
+
|
|
578
687
|
const jsonStr = JSON.stringify(statute, null, 2);
|
|
579
688
|
fs.writeFileSync(targetPath, jsonStr, 'utf8');
|
|
580
689
|
|
|
690
|
+
const fileName = resolveSafeStatuteFilename(statute);
|
|
581
691
|
const fileHash = crypto.createHash('sha256').update(jsonStr).digest('hex');
|
|
582
692
|
fileHashes[fileName] = fileHash;
|
|
583
693
|
syncedSectionCodes.push(statute.sectionCode);
|
|
694
|
+
stagedRelPaths.push(relTarget);
|
|
584
695
|
}
|
|
585
696
|
|
|
586
697
|
// 2.5 Cryptographic Root Merkle Digest Verification
|
|
@@ -621,7 +732,14 @@ export async function syncOkf(options = {}) {
|
|
|
621
732
|
const isPinned = PINNED_RELEASE_ROOTS.has(bundle.rootHash);
|
|
622
733
|
|
|
623
734
|
if (!isSignatureValid && !isPinned) {
|
|
624
|
-
|
|
735
|
+
const isEdgeNewer = bundle.okfVersion && compareSemver(bundle.okfVersion, STATUTORY_CLIENT_VERSION) >= 0;
|
|
736
|
+
const remediation = isEdgeNewer
|
|
737
|
+
? `The remote legal bundle (OKF v${bundle.okfVersion}) has a newer cryptographic release root than recognized by your installed package (v${STATUTORY_CLIENT_VERSION}).\n` +
|
|
738
|
+
` To receive the updated release roots and trust anchors, update your client:\n` +
|
|
739
|
+
` npm install @mudraforge/statutory@latest\n` +
|
|
740
|
+
` npx @mudraforge/statutory@latest sync`
|
|
741
|
+
: `Bundle is neither cryptographically signed by MudraForge Root Authority nor pinned in authorized release roots. Set options.allowUnverifiedRoot=true for local non-production fixtures.`;
|
|
742
|
+
throw new Error(`[@mudraforge/statutory] Security violation: Untrusted bundle rootHash "${bundle.rootHash}". ${remediation}`);
|
|
625
743
|
}
|
|
626
744
|
}
|
|
627
745
|
|
|
@@ -647,19 +765,18 @@ export async function syncOkf(options = {}) {
|
|
|
647
765
|
}
|
|
648
766
|
|
|
649
767
|
// 3. Atomically deploy from staging to destination
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
for (const stagedFile of stagedFiles) {
|
|
654
|
-
const srcPath = path.join(stagingDir, stagedFile);
|
|
655
|
-
const destPath = path.join(sectionsDir, stagedFile);
|
|
768
|
+
for (const relTarget of stagedRelPaths) {
|
|
769
|
+
const srcPath = path.join(stagingDir, relTarget);
|
|
770
|
+
const destPath = path.join(okfDir, relTarget);
|
|
656
771
|
|
|
657
772
|
// Safety assertion on destination path containment
|
|
658
773
|
const resolvedDest = path.resolve(destPath);
|
|
659
|
-
if (!resolvedDest.startsWith(path.resolve(
|
|
660
|
-
throw new Error(`[@mudraforge/statutory] Security violation: Destination path traversal detected in "${
|
|
774
|
+
if (!resolvedDest.startsWith(path.resolve(okfDir) + path.sep)) {
|
|
775
|
+
throw new Error(`[@mudraforge/statutory] Security violation: Destination path traversal detected in "${relTarget}".`);
|
|
661
776
|
}
|
|
662
777
|
|
|
778
|
+
fs.mkdirSync(path.dirname(destPath), { recursive: true });
|
|
779
|
+
|
|
663
780
|
try {
|
|
664
781
|
fs.renameSync(srcPath, destPath);
|
|
665
782
|
} catch {
|
|
@@ -669,12 +786,20 @@ export async function syncOkf(options = {}) {
|
|
|
669
786
|
}
|
|
670
787
|
|
|
671
788
|
// 4. Write verified manifest
|
|
789
|
+
const manifestDomains = {};
|
|
790
|
+
for (const statute of bundle.statutes) {
|
|
791
|
+
const domain = statute.domain || (statute.relPath ? statute.relPath.split('/')[0] : 'sections');
|
|
792
|
+
if (!manifestDomains[domain]) manifestDomains[domain] = [];
|
|
793
|
+
if (statute.sectionCode) manifestDomains[domain].push(statute.sectionCode);
|
|
794
|
+
}
|
|
795
|
+
|
|
672
796
|
const manifest = {
|
|
673
797
|
okfVersion: bundle.okfVersion,
|
|
674
798
|
publishedAt: bundle.publishedAt || new Date().toISOString(),
|
|
675
799
|
rootHash: bundle.rootHash,
|
|
676
800
|
totalSections: bundle.statutes.length,
|
|
677
801
|
changelog: bundle.changelog || 'Synchronized from Cloudflare Edge API',
|
|
802
|
+
domains: manifestDomains,
|
|
678
803
|
sections: syncedSectionCodes
|
|
679
804
|
};
|
|
680
805
|
fs.writeFileSync(path.join(okfDir, 'manifest.json'), JSON.stringify(manifest, null, 2), 'utf8');
|
|
@@ -1120,3 +1245,201 @@ export class StatutoryClient {
|
|
|
1120
1245
|
return await syncOkf({ baseUrl: this.baseUrl, okfDir: this.okfDir });
|
|
1121
1246
|
}
|
|
1122
1247
|
}
|
|
1248
|
+
|
|
1249
|
+
/**
|
|
1250
|
+
* Standard Indian State & Union Territory Codes (Census 2011 / GSTN 2-digit prefix).
|
|
1251
|
+
*/
|
|
1252
|
+
export const INDIAN_STATE_CODES = {
|
|
1253
|
+
'01': 'Jammu & Kashmir',
|
|
1254
|
+
'02': 'Himachal Pradesh',
|
|
1255
|
+
'03': 'Punjab',
|
|
1256
|
+
'04': 'Chandigarh',
|
|
1257
|
+
'05': 'Uttarakhand',
|
|
1258
|
+
'06': 'Haryana',
|
|
1259
|
+
'07': 'Delhi',
|
|
1260
|
+
'08': 'Rajasthan',
|
|
1261
|
+
'09': 'Uttar Pradesh',
|
|
1262
|
+
'10': 'Bihar',
|
|
1263
|
+
'11': 'Sikkim',
|
|
1264
|
+
'12': 'Arunachal Pradesh',
|
|
1265
|
+
'13': 'Nagaland',
|
|
1266
|
+
'14': 'Manipur',
|
|
1267
|
+
'15': 'Mizoram',
|
|
1268
|
+
'16': 'Tripura',
|
|
1269
|
+
'17': 'Meghalaya',
|
|
1270
|
+
'18': 'Assam',
|
|
1271
|
+
'19': 'West Bengal',
|
|
1272
|
+
'20': 'Jharkhand',
|
|
1273
|
+
'21': 'Odisha',
|
|
1274
|
+
'22': 'Chhattisgarh',
|
|
1275
|
+
'23': 'Madhya Pradesh',
|
|
1276
|
+
'24': 'Gujarat',
|
|
1277
|
+
'26': 'Dadra & Nagar Haveli and Daman & Diu',
|
|
1278
|
+
'27': 'Maharashtra',
|
|
1279
|
+
'29': 'Karnataka',
|
|
1280
|
+
'30': 'Goa',
|
|
1281
|
+
'31': 'Lakshadweep',
|
|
1282
|
+
'32': 'Kerala',
|
|
1283
|
+
'33': 'Tamil Nadu',
|
|
1284
|
+
'34': 'Puducherry',
|
|
1285
|
+
'35': 'Andaman & Nicobar Islands',
|
|
1286
|
+
'36': 'Telangana',
|
|
1287
|
+
'37': 'Andhra Pradesh',
|
|
1288
|
+
'38': 'Ladakh',
|
|
1289
|
+
'97': 'Other Territory',
|
|
1290
|
+
'99': 'Centre Jurisdiction'
|
|
1291
|
+
};
|
|
1292
|
+
|
|
1293
|
+
/**
|
|
1294
|
+
* Statutory 4th character legal status classification for Indian Permanent Account Numbers (PAN).
|
|
1295
|
+
* Enacted under Section 139A of the Income-tax Act, 1961 read with Rule 114 of Income Tax Rules.
|
|
1296
|
+
*/
|
|
1297
|
+
export const PAN_ENTITY_TYPES = {
|
|
1298
|
+
'P': { code: 'P', name: 'Individual', isIndividualOrHuf: true, standard194cRatePercent: 1.0 },
|
|
1299
|
+
'H': { code: 'H', name: 'Hindu Undivided Family (HUF)', isIndividualOrHuf: true, standard194cRatePercent: 1.0 },
|
|
1300
|
+
'C': { code: 'C', name: 'Company', isIndividualOrHuf: false, standard194cRatePercent: 2.0 },
|
|
1301
|
+
'F': { code: 'F', name: 'Partnership Firm / LLP', isIndividualOrHuf: false, standard194cRatePercent: 2.0 },
|
|
1302
|
+
'T': { code: 'T', name: 'Trust', isIndividualOrHuf: false, standard194cRatePercent: 2.0 },
|
|
1303
|
+
'A': { code: 'A', name: 'Association of Persons (AOP)', isIndividualOrHuf: false, standard194cRatePercent: 2.0 },
|
|
1304
|
+
'B': { code: 'B', name: 'Body of Individuals (BOI)', isIndividualOrHuf: false, standard194cRatePercent: 2.0 },
|
|
1305
|
+
'L': { code: 'L', name: 'Local Authority', isIndividualOrHuf: false, standard194cRatePercent: 2.0 },
|
|
1306
|
+
'J': { code: 'J', name: 'Artificial Juridical Person', isIndividualOrHuf: false, standard194cRatePercent: 2.0 },
|
|
1307
|
+
'G': { code: 'G', name: 'Government Agency', isIndividualOrHuf: false, standard194cRatePercent: 2.0 }
|
|
1308
|
+
};
|
|
1309
|
+
|
|
1310
|
+
const BASE36_ALPHANUMERIC = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
|
1311
|
+
|
|
1312
|
+
/**
|
|
1313
|
+
* Validates the check digit of a 15-character Goods and Services Tax Identification Number (GSTIN)
|
|
1314
|
+
* using the official ISO/IEC 7064 Mod 36, 36 check digit algorithm.
|
|
1315
|
+
*
|
|
1316
|
+
* Offline, zero-dependency, and deterministic. Requires zero network calls.
|
|
1317
|
+
*
|
|
1318
|
+
* @param {string} gstin 15-character alphanumeric GSTIN string
|
|
1319
|
+
* @returns {boolean} True if the 15th character exactly matches the calculated check character
|
|
1320
|
+
*/
|
|
1321
|
+
export function isValidGstinChecksum(gstin) {
|
|
1322
|
+
if (!gstin || typeof gstin !== 'string') return false;
|
|
1323
|
+
const clean = gstin.trim().toUpperCase();
|
|
1324
|
+
|
|
1325
|
+
// Structural syntax: State (2 digits) + PAN (10 chars) + Entity (1 char) + Z (1 char) + Check (1 char)
|
|
1326
|
+
const pattern = /^\d{2}[A-Z]{5}\d{4}[A-Z][A-Z\d]Z[A-Z\d]$/;
|
|
1327
|
+
if (!pattern.test(clean)) return false;
|
|
1328
|
+
|
|
1329
|
+
let total = 0;
|
|
1330
|
+
for (let i = 0; i < 14; i++) {
|
|
1331
|
+
const val = BASE36_ALPHANUMERIC.indexOf(clean[i]);
|
|
1332
|
+
if (val === -1) return false;
|
|
1333
|
+
const factor = (i % 2 === 0) ? 1 : 2;
|
|
1334
|
+
const product = val * factor;
|
|
1335
|
+
total += Math.floor(product / 36) + (product % 36);
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
const expectedIndex = (36 - (total % 36)) % 36;
|
|
1339
|
+
const expectedChar = BASE36_ALPHANUMERIC[expectedIndex];
|
|
1340
|
+
return clean[14] === expectedChar;
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
/**
|
|
1344
|
+
* Deterministically decodes an Indian Tax Identifier (PAN or GSTIN).
|
|
1345
|
+
*
|
|
1346
|
+
* Capabilities:
|
|
1347
|
+
* - Structural regular expression pattern validation
|
|
1348
|
+
* - Offline ISO/IEC 7064 Mod 36, 36 checksum calculation for GSTIN
|
|
1349
|
+
* - State code and name extraction from GSTIN prefix
|
|
1350
|
+
* - Embedded 10-character PAN extraction from GSTIN positions 3..12
|
|
1351
|
+
* - Statutory legal entity classification via 4th character of PAN (Individual, HUF, Company, Firm, etc.)
|
|
1352
|
+
* - Statutory Section 194C withholding rate derivation (1% for Individual/HUF vs 2% for Other Entities)
|
|
1353
|
+
*
|
|
1354
|
+
* Zero dependencies, 0-neuron, zero network calls.
|
|
1355
|
+
*
|
|
1356
|
+
* @param {string} rawId PAN (10 characters) or GSTIN (15 characters)
|
|
1357
|
+
* @returns {Object} Decoded tax entity metadata
|
|
1358
|
+
*/
|
|
1359
|
+
export function decodeTaxIdentifier(rawId) {
|
|
1360
|
+
if (!rawId || typeof rawId !== 'string') {
|
|
1361
|
+
return {
|
|
1362
|
+
isValid: false,
|
|
1363
|
+
error: 'Tax identifier must be a non-empty string.'
|
|
1364
|
+
};
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
const clean = rawId.trim().toUpperCase();
|
|
1368
|
+
|
|
1369
|
+
// 1. Process 15-character GSTIN
|
|
1370
|
+
if (clean.length === 15) {
|
|
1371
|
+
const pattern = /^\d{2}[A-Z]{5}\d{4}[A-Z][A-Z\d]Z[A-Z\d]$/;
|
|
1372
|
+
if (!pattern.test(clean)) {
|
|
1373
|
+
return {
|
|
1374
|
+
isValid: false,
|
|
1375
|
+
type: 'GSTIN',
|
|
1376
|
+
gstin: clean,
|
|
1377
|
+
error: 'Malformed GSTIN format: expected 2 digits state + 10 chars PAN + 1 entity char + Z + 1 check char.'
|
|
1378
|
+
};
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
const isChecksumValid = isValidGstinChecksum(clean);
|
|
1382
|
+
const stateCode = clean.slice(0, 2);
|
|
1383
|
+
const pan = clean.slice(2, 12);
|
|
1384
|
+
const entityCode = pan[3];
|
|
1385
|
+
const entityMeta = PAN_ENTITY_TYPES[entityCode] || {
|
|
1386
|
+
code: entityCode,
|
|
1387
|
+
name: 'Other Entity / Non-Individual',
|
|
1388
|
+
isIndividualOrHuf: false,
|
|
1389
|
+
standard194cRatePercent: 2.0
|
|
1390
|
+
};
|
|
1391
|
+
|
|
1392
|
+
return {
|
|
1393
|
+
isValid: isChecksumValid,
|
|
1394
|
+
checksumValid: isChecksumValid,
|
|
1395
|
+
type: 'GSTIN',
|
|
1396
|
+
gstin: clean,
|
|
1397
|
+
pan,
|
|
1398
|
+
stateCode,
|
|
1399
|
+
stateName: INDIAN_STATE_CODES[stateCode] || 'Unknown Territory',
|
|
1400
|
+
entityCode,
|
|
1401
|
+
entityType: entityMeta.name,
|
|
1402
|
+
isIndividualOrHuf: entityMeta.isIndividualOrHuf,
|
|
1403
|
+
standard194cRatePercent: entityMeta.standard194cRatePercent,
|
|
1404
|
+
error: isChecksumValid ? null : 'GSTIN checksum mismatch under ISO/IEC 7064 Mod 36, 36.'
|
|
1405
|
+
};
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
// 2. Process 10-character PAN
|
|
1409
|
+
if (clean.length === 10) {
|
|
1410
|
+
const panPattern = /^[A-Z]{5}\d{4}[A-Z]$/;
|
|
1411
|
+
if (!panPattern.test(clean)) {
|
|
1412
|
+
return {
|
|
1413
|
+
isValid: false,
|
|
1414
|
+
type: 'PAN',
|
|
1415
|
+
pan: clean,
|
|
1416
|
+
error: 'Malformed PAN format: expected 5 letters + 4 digits + 1 letter.'
|
|
1417
|
+
};
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
const entityCode = clean[3];
|
|
1421
|
+
const entityMeta = PAN_ENTITY_TYPES[entityCode] || {
|
|
1422
|
+
code: entityCode,
|
|
1423
|
+
name: 'Other Entity / Non-Individual',
|
|
1424
|
+
isIndividualOrHuf: false,
|
|
1425
|
+
standard194cRatePercent: 2.0
|
|
1426
|
+
};
|
|
1427
|
+
|
|
1428
|
+
return {
|
|
1429
|
+
isValid: true,
|
|
1430
|
+
type: 'PAN',
|
|
1431
|
+
pan: clean,
|
|
1432
|
+
entityCode,
|
|
1433
|
+
entityType: entityMeta.name,
|
|
1434
|
+
isIndividualOrHuf: entityMeta.isIndividualOrHuf,
|
|
1435
|
+
standard194cRatePercent: entityMeta.standard194cRatePercent,
|
|
1436
|
+
error: null
|
|
1437
|
+
};
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
return {
|
|
1441
|
+
isValid: false,
|
|
1442
|
+
error: `Invalid identifier length (${clean.length}): Expected 10 characters for PAN or 15 characters for GSTIN.`
|
|
1443
|
+
};
|
|
1444
|
+
}
|
|
1445
|
+
|
package/types/index.d.ts
CHANGED
|
@@ -148,6 +148,12 @@ export declare function getLocalManifest(okfDir?: string): any;
|
|
|
148
148
|
export declare function lookupLocalStatute(section: string, okfDir?: string): StatutoryRecord | null;
|
|
149
149
|
export declare function computeTemporalStatus(statute: StatutoryRecord, asOfDate?: string): 'ACTIVE' | 'SUNSET_SUPERSEDED' | 'FUTURE_PENDING';
|
|
150
150
|
export declare function checkUpdates(options?: { baseUrl?: string; okfDir?: string }): Promise<UpdateInfo>;
|
|
151
|
+
export declare function checkForPackageUpdate(options?: {
|
|
152
|
+
currentVersion?: string;
|
|
153
|
+
cacheTtlMs?: number;
|
|
154
|
+
timeoutMs?: number;
|
|
155
|
+
registryUrl?: string;
|
|
156
|
+
}): Promise<{ updateAvailable: boolean; currentVersion: string; latestVersion: string } | null>;
|
|
151
157
|
export declare function syncOkf(options?: { baseUrl?: string; okfDir?: string }): Promise<SyncResult>;
|
|
152
158
|
|
|
153
159
|
export declare function lookupStatute(
|
|
@@ -184,3 +190,24 @@ export declare class StatutoryClient {
|
|
|
184
190
|
checkUpdates(): Promise<UpdateInfo>;
|
|
185
191
|
sync(): Promise<SyncResult>;
|
|
186
192
|
}
|
|
193
|
+
|
|
194
|
+
export interface TaxIdentifierResult {
|
|
195
|
+
isValid: boolean;
|
|
196
|
+
checksumValid?: boolean;
|
|
197
|
+
type?: 'GSTIN' | 'PAN';
|
|
198
|
+
gstin?: string;
|
|
199
|
+
pan?: string;
|
|
200
|
+
stateCode?: string;
|
|
201
|
+
stateName?: string;
|
|
202
|
+
entityCode?: string;
|
|
203
|
+
entityType?: string;
|
|
204
|
+
isIndividualOrHuf?: boolean;
|
|
205
|
+
standard194cRatePercent?: number;
|
|
206
|
+
error?: string | null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export declare const INDIAN_STATE_CODES: Record<string, string>;
|
|
210
|
+
export declare const PAN_ENTITY_TYPES: Record<string, { code: string; name: string; isIndividualOrHuf: boolean; standard194cRatePercent: number }>;
|
|
211
|
+
export declare function isValidGstinChecksum(gstin: string): boolean;
|
|
212
|
+
export declare function decodeTaxIdentifier(rawId: string): TaxIdentifierResult;
|
|
213
|
+
|