@aikdna/kdna-core 0.9.1 → 0.10.1
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/package.json +7 -2
- package/schema/checksums.schema.json +43 -0
- package/schema/load-contract.schema.json +41 -0
- package/schema/manifest.schema.json +198 -0
- package/schema/payload-profile-v1.schema.json +70 -0
- package/src/index.js +2 -0
- package/src/v1/index.js +833 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aikdna/kdna-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.1",
|
|
4
4
|
"description": "KDNA core library — load, validate, lint, and render KDNA domain judgment assets. Supports KDNA Container format (payload.kdnab via CBOR).",
|
|
5
5
|
"type": "commonjs",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -12,7 +12,12 @@
|
|
|
12
12
|
"types": "./src/types.d.ts"
|
|
13
13
|
},
|
|
14
14
|
"./package.json": "./package.json",
|
|
15
|
-
"./schema/*": "./schema/*"
|
|
15
|
+
"./schema/*": "./schema/*",
|
|
16
|
+
"./v1": {
|
|
17
|
+
"import": "./src/v1/index.mjs",
|
|
18
|
+
"require": "./src/v1/index.js",
|
|
19
|
+
"types": "./src/types.d.ts"
|
|
20
|
+
}
|
|
16
21
|
},
|
|
17
22
|
"types": "src/types.d.ts",
|
|
18
23
|
"scripts": {
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://github.com/aikdna/kdna/schema/checksums.schema.json",
|
|
4
|
+
"title": "KDNA Checksums v1",
|
|
5
|
+
"description": "Per-entry digests for a .kdna container. Stored as checksums.json at the container root.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"required": ["algorithm"],
|
|
8
|
+
"additionalProperties": true,
|
|
9
|
+
"properties": {
|
|
10
|
+
"algorithm": {
|
|
11
|
+
"type": "string",
|
|
12
|
+
"description": "Hash algorithm. Phase 1 uses sha256.",
|
|
13
|
+
"enum": ["sha256", "sha512", "blake2b-256"]
|
|
14
|
+
},
|
|
15
|
+
"manifest_digest": {
|
|
16
|
+
"type": "string",
|
|
17
|
+
"description": "Digest of the kdna.json entry.",
|
|
18
|
+
"pattern": "^[a-z0-9-]+:.+$"
|
|
19
|
+
},
|
|
20
|
+
"payload_digest": {
|
|
21
|
+
"type": "string",
|
|
22
|
+
"description": "Digest of the payload.kdnab entry.",
|
|
23
|
+
"pattern": "^[a-z0-9-]+:.+$"
|
|
24
|
+
},
|
|
25
|
+
"asset_digest": {
|
|
26
|
+
"type": "string",
|
|
27
|
+
"description": "Digest of the whole container (per-entry digests combined deterministically).",
|
|
28
|
+
"pattern": "^[a-z0-9-]+:.+$"
|
|
29
|
+
},
|
|
30
|
+
"entries": {
|
|
31
|
+
"type": "object",
|
|
32
|
+
"description": "Per-entry digests, keyed by entry name within the container.",
|
|
33
|
+
"additionalProperties": {
|
|
34
|
+
"type": "object",
|
|
35
|
+
"required": ["algorithm", "value"],
|
|
36
|
+
"properties": {
|
|
37
|
+
"algorithm": { "type": "string" },
|
|
38
|
+
"value": { "type": "string" }
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://github.com/aikdna/kdna/schema/load-contract.schema.json",
|
|
4
|
+
"title": "KDNA Load Contract",
|
|
5
|
+
"description": "Manifest block that describes how a loader may read the asset. See docs/core/load-contract.md.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"required": ["default_profile", "profiles"],
|
|
8
|
+
"additionalProperties": true,
|
|
9
|
+
"properties": {
|
|
10
|
+
"default_profile": {
|
|
11
|
+
"type": "string",
|
|
12
|
+
"enum": ["index", "compact", "scenario", "full"]
|
|
13
|
+
},
|
|
14
|
+
"profiles": {
|
|
15
|
+
"type": "object",
|
|
16
|
+
"required": ["index", "compact", "scenario", "full"],
|
|
17
|
+
"additionalProperties": false,
|
|
18
|
+
"properties": {
|
|
19
|
+
"index": { "$ref": "#/$defs/profile" },
|
|
20
|
+
"compact": { "$ref": "#/$defs/profile" },
|
|
21
|
+
"scenario": { "$ref": "#/$defs/profile" },
|
|
22
|
+
"full": { "$ref": "#/$defs/profile" }
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"$defs": {
|
|
27
|
+
"profile": {
|
|
28
|
+
"type": "object",
|
|
29
|
+
"additionalProperties": true,
|
|
30
|
+
"properties": {
|
|
31
|
+
"requires_decryption": { "type": "boolean" },
|
|
32
|
+
"max_tokens_hint": { "type": "integer", "minimum": 0 },
|
|
33
|
+
"selection": { "type": "string" },
|
|
34
|
+
"intended_for": {
|
|
35
|
+
"type": "array",
|
|
36
|
+
"items": { "type": "string" }
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://github.com/aikdna/kdna/schema/manifest.schema.json",
|
|
4
|
+
"title": "KDNA Manifest v1",
|
|
5
|
+
"description": "Schema for kdna.json, the public metadata layer of a .kdna container.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"required": [
|
|
8
|
+
"kdna_version",
|
|
9
|
+
"asset_id",
|
|
10
|
+
"asset_uid",
|
|
11
|
+
"asset_type",
|
|
12
|
+
"title",
|
|
13
|
+
"version",
|
|
14
|
+
"judgment_version",
|
|
15
|
+
"created_at",
|
|
16
|
+
"updated_at",
|
|
17
|
+
"creator",
|
|
18
|
+
"compatibility",
|
|
19
|
+
"payload"
|
|
20
|
+
],
|
|
21
|
+
"additionalProperties": true,
|
|
22
|
+
"properties": {
|
|
23
|
+
"kdna_version": {
|
|
24
|
+
"type": "string",
|
|
25
|
+
"description": "KDNA Core version this manifest conforms to.",
|
|
26
|
+
"const": "1.0"
|
|
27
|
+
},
|
|
28
|
+
"asset_id": {
|
|
29
|
+
"type": "string",
|
|
30
|
+
"description": "Human-readable identifier with format namespace prefix (e.g. kdna:example:foo).",
|
|
31
|
+
"pattern": "^[a-zA-Z][a-zA-Z0-9_-]*(:[a-zA-Z0-9_.-]+)+$"
|
|
32
|
+
},
|
|
33
|
+
"asset_uid": {
|
|
34
|
+
"type": "string",
|
|
35
|
+
"description": "Globally unique identifier. URN form recommended.",
|
|
36
|
+
"format": "uri"
|
|
37
|
+
},
|
|
38
|
+
"asset_type": {
|
|
39
|
+
"type": "string",
|
|
40
|
+
"enum": ["domain", "cluster", "tool", "sample", "fixture"],
|
|
41
|
+
"description": "What kind of asset this is. Container format is the same; the value tells callers how to interpret the payload."
|
|
42
|
+
},
|
|
43
|
+
"title": {
|
|
44
|
+
"type": "string",
|
|
45
|
+
"minLength": 1,
|
|
46
|
+
"description": "Short, human-readable title."
|
|
47
|
+
},
|
|
48
|
+
"version": {
|
|
49
|
+
"type": "string",
|
|
50
|
+
"description": "Release version of this .kdna file (semver).",
|
|
51
|
+
"pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+([+-].+)?$"
|
|
52
|
+
},
|
|
53
|
+
"judgment_version": {
|
|
54
|
+
"type": "string",
|
|
55
|
+
"description": "Semantic version of the encoded judgment system.",
|
|
56
|
+
"pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+([+-].+)?$"
|
|
57
|
+
},
|
|
58
|
+
"created_at": {
|
|
59
|
+
"type": "string",
|
|
60
|
+
"format": "date-time",
|
|
61
|
+
"description": "When the asset was first created (ISO 8601)."
|
|
62
|
+
},
|
|
63
|
+
"updated_at": {
|
|
64
|
+
"type": "string",
|
|
65
|
+
"format": "date-time",
|
|
66
|
+
"description": "When this release of the asset was produced (ISO 8601)."
|
|
67
|
+
},
|
|
68
|
+
"creator": {
|
|
69
|
+
"type": "object",
|
|
70
|
+
"required": ["name"],
|
|
71
|
+
"additionalProperties": true,
|
|
72
|
+
"properties": {
|
|
73
|
+
"name": { "type": "string", "minLength": 1 },
|
|
74
|
+
"id": { "type": "string" }
|
|
75
|
+
},
|
|
76
|
+
"description": "Who produced the asset. Provenance only; not a trust claim."
|
|
77
|
+
},
|
|
78
|
+
"compatibility": {
|
|
79
|
+
"type": "object",
|
|
80
|
+
"required": ["min_loader_version", "profile"],
|
|
81
|
+
"additionalProperties": true,
|
|
82
|
+
"properties": {
|
|
83
|
+
"min_loader_version": {
|
|
84
|
+
"type": "string",
|
|
85
|
+
"pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+([+-].+)?$"
|
|
86
|
+
},
|
|
87
|
+
"profile": {
|
|
88
|
+
"type": "string",
|
|
89
|
+
"description": "Payload profile identifier (e.g. judgment-profile-v1).",
|
|
90
|
+
"const": "judgment-profile-v1"
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
"payload": {
|
|
95
|
+
"type": "object",
|
|
96
|
+
"required": ["path", "encoding", "encrypted"],
|
|
97
|
+
"additionalProperties": true,
|
|
98
|
+
"properties": {
|
|
99
|
+
"path": { "type": "string", "const": "payload.kdnab" },
|
|
100
|
+
"encoding": { "type": "string", "enum": ["json", "cbor"] },
|
|
101
|
+
"encrypted": { "type": "boolean" }
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
"summary": { "type": "string" },
|
|
105
|
+
"description": { "type": "string" },
|
|
106
|
+
"language": { "type": "string" },
|
|
107
|
+
"languages": { "type": "array", "items": { "type": "string" } },
|
|
108
|
+
"license": {
|
|
109
|
+
"oneOf": [
|
|
110
|
+
{ "type": "string" },
|
|
111
|
+
{
|
|
112
|
+
"type": "object",
|
|
113
|
+
"required": ["type"],
|
|
114
|
+
"properties": {
|
|
115
|
+
"type": { "type": "string" },
|
|
116
|
+
"url": { "type": "string" }
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
]
|
|
120
|
+
},
|
|
121
|
+
"keywords": { "type": "array", "items": { "type": "string" } },
|
|
122
|
+
"domain_field": { "type": "array", "items": { "type": "string" } },
|
|
123
|
+
"lineage": {
|
|
124
|
+
"type": "object",
|
|
125
|
+
"description": "Provenance object describing how this asset relates to other assets. Phase 1 keeps this a single object (not an array) to avoid the unstable multi-source shape; future phases may add a separate `sources` or `references` field if multi-parent derivation becomes a real requirement.",
|
|
126
|
+
"required": ["type"],
|
|
127
|
+
"additionalProperties": true,
|
|
128
|
+
"properties": {
|
|
129
|
+
"type": {
|
|
130
|
+
"type": "string",
|
|
131
|
+
"enum": [
|
|
132
|
+
"original",
|
|
133
|
+
"fork",
|
|
134
|
+
"adaptation",
|
|
135
|
+
"translation",
|
|
136
|
+
"private_variant",
|
|
137
|
+
"organization_variant",
|
|
138
|
+
"course_variant"
|
|
139
|
+
]
|
|
140
|
+
},
|
|
141
|
+
"fork_of": {
|
|
142
|
+
"oneOf": [
|
|
143
|
+
{ "type": "null" },
|
|
144
|
+
{ "type": "string" },
|
|
145
|
+
{
|
|
146
|
+
"type": "object",
|
|
147
|
+
"required": ["asset_uid"],
|
|
148
|
+
"properties": {
|
|
149
|
+
"asset_uid": { "type": "string" },
|
|
150
|
+
"version": { "type": "string" }
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
]
|
|
154
|
+
},
|
|
155
|
+
"derived_from": {
|
|
156
|
+
"oneOf": [
|
|
157
|
+
{ "type": "null" },
|
|
158
|
+
{ "type": "string" },
|
|
159
|
+
{ "type": "array", "items": { "type": "string" } },
|
|
160
|
+
{
|
|
161
|
+
"type": "object",
|
|
162
|
+
"additionalProperties": true
|
|
163
|
+
}
|
|
164
|
+
]
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
},
|
|
168
|
+
"digests": {
|
|
169
|
+
"type": "object",
|
|
170
|
+
"description": "Per-entry digests. Alternative to a separate checksums.json.",
|
|
171
|
+
"additionalProperties": {
|
|
172
|
+
"type": "object",
|
|
173
|
+
"required": ["algorithm", "value"],
|
|
174
|
+
"properties": {
|
|
175
|
+
"algorithm": { "type": "string" },
|
|
176
|
+
"value": { "type": "string" }
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
"signatures": {
|
|
181
|
+
"type": "array",
|
|
182
|
+
"items": {
|
|
183
|
+
"type": "object",
|
|
184
|
+
"required": ["role", "path", "algorithm"],
|
|
185
|
+
"properties": {
|
|
186
|
+
"role": { "type": "string" },
|
|
187
|
+
"path": { "type": "string" },
|
|
188
|
+
"algorithm": { "type": "string" },
|
|
189
|
+
"key_id": { "type": "string" }
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
},
|
|
193
|
+
"encryption": { "type": "object" },
|
|
194
|
+
"load_contract": { "$ref": "load-contract.schema.json" },
|
|
195
|
+
"scope": { "type": "object" },
|
|
196
|
+
"evidence_claims": { "type": "object" }
|
|
197
|
+
}
|
|
198
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://github.com/aikdna/kdna/schema/payload-profile-v1.schema.json",
|
|
4
|
+
"title": "Judgment Profile v1",
|
|
5
|
+
"description": "Schema for the payload entry of a .kdna container. The payload is the actual judgment data.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"required": ["profile", "core"],
|
|
8
|
+
"additionalProperties": true,
|
|
9
|
+
"properties": {
|
|
10
|
+
"profile": {
|
|
11
|
+
"type": "string",
|
|
12
|
+
"const": "judgment-profile-v1",
|
|
13
|
+
"description": "Profile identifier. Must be the literal string 'judgment-profile-v1'."
|
|
14
|
+
},
|
|
15
|
+
"core": {
|
|
16
|
+
"type": "object",
|
|
17
|
+
"required": ["highest_question", "axioms"],
|
|
18
|
+
"additionalProperties": true,
|
|
19
|
+
"properties": {
|
|
20
|
+
"highest_question": {
|
|
21
|
+
"type": "string",
|
|
22
|
+
"description": "The single most important question this judgment system answers."
|
|
23
|
+
},
|
|
24
|
+
"axioms": {
|
|
25
|
+
"type": "array",
|
|
26
|
+
"description": "The list of axioms. Phase 1 accepts an empty array."
|
|
27
|
+
},
|
|
28
|
+
"boundaries": {
|
|
29
|
+
"type": "array",
|
|
30
|
+
"description": "Explicit out-of-scope statements."
|
|
31
|
+
},
|
|
32
|
+
"risk_model": {
|
|
33
|
+
"type": "object",
|
|
34
|
+
"description": "Structural risk metadata."
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
"patterns": {
|
|
39
|
+
"type": "array",
|
|
40
|
+
"description": "Recognized patterns."
|
|
41
|
+
},
|
|
42
|
+
"scenarios": {
|
|
43
|
+
"type": "array",
|
|
44
|
+
"description": "Scenario descriptions."
|
|
45
|
+
},
|
|
46
|
+
"cases": {
|
|
47
|
+
"type": "array",
|
|
48
|
+
"description": "Worked cases."
|
|
49
|
+
},
|
|
50
|
+
"reasoning": {
|
|
51
|
+
"type": "object",
|
|
52
|
+
"additionalProperties": true,
|
|
53
|
+
"properties": {
|
|
54
|
+
"self_checks": { "type": "array", "items": { "type": "string" } },
|
|
55
|
+
"failure_modes": {
|
|
56
|
+
"type": "array",
|
|
57
|
+
"items": { "type": "object" }
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
"evolution": {
|
|
62
|
+
"type": "object",
|
|
63
|
+
"additionalProperties": true,
|
|
64
|
+
"properties": {
|
|
65
|
+
"changelog": { "type": "array", "items": { "type": "object" } },
|
|
66
|
+
"version_notes": { "type": "array", "items": { "type": "string" } }
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
package/src/index.js
CHANGED
|
@@ -10,6 +10,7 @@ const assetReader = require('./asset-reader');
|
|
|
10
10
|
const cryptoProfile = require('./crypto-profile');
|
|
11
11
|
const publicApi = require('./public-api');
|
|
12
12
|
const workpackPure = require('./workpack-pure');
|
|
13
|
+
const v1 = require('./v1');
|
|
13
14
|
|
|
14
15
|
module.exports = {
|
|
15
16
|
...publicApi,
|
|
@@ -21,4 +22,5 @@ module.exports = {
|
|
|
21
22
|
...assetReader,
|
|
22
23
|
...cryptoProfile,
|
|
23
24
|
...workpackPure,
|
|
25
|
+
...v1,
|
|
24
26
|
};
|
package/src/v1/index.js
ADDED
|
@@ -0,0 +1,833 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v1-cli.js — KDNA Core v1 inspect / validate / pack / unpack for the
|
|
3
|
+
* kdna monorepo CLI shim.
|
|
4
|
+
*
|
|
5
|
+
* KDNA Core is the official KDNA judgment-asset format and runtime
|
|
6
|
+
* loading contract. .kdna assets are created, inspected, packed,
|
|
7
|
+
* unpacked, and validated through the official KDNA toolchain. This
|
|
8
|
+
* module is the v1 component of that toolchain.
|
|
9
|
+
*
|
|
10
|
+
* The KDNA Core v1 file format is documented in docs/core/file-format.md.
|
|
11
|
+
* This module is the shared implementation that:
|
|
12
|
+
*
|
|
13
|
+
* - packages/kdna/bin/kdna.js uses as a v1-aware router
|
|
14
|
+
* - scripts/v1-*.mjs delegate to (via child_process) so the legacy
|
|
15
|
+
* scripts and the official CLI cannot drift
|
|
16
|
+
*
|
|
17
|
+
* Hard rules from the format spec:
|
|
18
|
+
*
|
|
19
|
+
* - mimetype must equal "application/vnd.kdna.asset" (no trailing newline)
|
|
20
|
+
* - mimetype must be the first entry in a .kdna container
|
|
21
|
+
* - mimetype must be STORED (compression method 0) in a .kdna container
|
|
22
|
+
* - the source directory must contain mimetype, kdna.json, payload.kdnab
|
|
23
|
+
* - checksums.json and signatures/ are optional
|
|
24
|
+
* - lineage must be a single object (not an array)
|
|
25
|
+
* - pack output must be deterministic: same input → same SHA-256
|
|
26
|
+
*
|
|
27
|
+
* Output language must stay content-neutral. We never say "trusted",
|
|
28
|
+
* "recommended", "high_quality", or "officially_approved". We say
|
|
29
|
+
* "format_valid", "schema_valid", "payload_valid", "compatible", etc.
|
|
30
|
+
*
|
|
31
|
+
* Third-party products integrate KDNA through the official SDK, CLI,
|
|
32
|
+
* Loader, or API.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
'use strict';
|
|
36
|
+
|
|
37
|
+
const fs = require('node:fs');
|
|
38
|
+
const path = require('node:path');
|
|
39
|
+
const zlib = require('node:zlib');
|
|
40
|
+
const crypto = require('node:crypto');
|
|
41
|
+
|
|
42
|
+
const MIMETYPE_V1 = 'application/vnd.kdna.asset';
|
|
43
|
+
const MIMETYPE_V2 = 'application/vnd.aikdna.kdna+zip';
|
|
44
|
+
const V1_REQUIRED_DIR_ENTRIES = ['mimetype', 'kdna.json', 'payload.kdnab'];
|
|
45
|
+
const V1_OPTIONAL_DIR_ENTRIES = ['checksums.json', 'signatures', 'attachments'];
|
|
46
|
+
|
|
47
|
+
// Words that must never appear in v1 CLI output as positive claims.
|
|
48
|
+
// Schema-valid, signature-valid, compatible — those are fine.
|
|
49
|
+
// "trusted", "recommended", "high_quality", "officially_approved" — never.
|
|
50
|
+
const FORBIDDEN_OUTPUT_TERMS = Object.freeze([
|
|
51
|
+
'trusted',
|
|
52
|
+
'recommended',
|
|
53
|
+
'high_quality',
|
|
54
|
+
'officially_approved',
|
|
55
|
+
'quality_badge',
|
|
56
|
+
]);
|
|
57
|
+
|
|
58
|
+
// ─── Schema loading ─────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
let _ajv = null;
|
|
61
|
+
let _validators = null;
|
|
62
|
+
|
|
63
|
+
function getRepoRoot() {
|
|
64
|
+
// Walk up from this file to find the repo root (where schema/ lives).
|
|
65
|
+
// Works whether this module is loaded from packages/kdna/src/ or
|
|
66
|
+
// from a copied/linked location.
|
|
67
|
+
let dir = __dirname;
|
|
68
|
+
for (let i = 0; i < 6; i++) {
|
|
69
|
+
if (fs.existsSync(path.join(dir, 'schema', 'manifest.schema.json'))) {
|
|
70
|
+
return dir;
|
|
71
|
+
}
|
|
72
|
+
dir = path.dirname(dir);
|
|
73
|
+
}
|
|
74
|
+
// Fallback: cwd, useful for installed/linked setups.
|
|
75
|
+
return process.cwd();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function loadSchemas() {
|
|
79
|
+
if (_validators) return _validators;
|
|
80
|
+
let Ajv;
|
|
81
|
+
let addFormats;
|
|
82
|
+
try {
|
|
83
|
+
Ajv = require('ajv/dist/2020.js');
|
|
84
|
+
addFormats = require('ajv-formats');
|
|
85
|
+
} catch {
|
|
86
|
+
// Ajv is an optional devDependency at the monorepo root. If the
|
|
87
|
+
// CLI is installed elsewhere without it, validation is reduced
|
|
88
|
+
// to structural checks (no JSON-schema enforcement).
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
const repoRoot = getRepoRoot();
|
|
92
|
+
const schemaDir = path.join(repoRoot, 'schema');
|
|
93
|
+
const manifestSchema = JSON.parse(fs.readFileSync(path.join(schemaDir, 'manifest.schema.json'), 'utf8'));
|
|
94
|
+
const payloadSchema = JSON.parse(
|
|
95
|
+
fs.readFileSync(path.join(schemaDir, 'payload-profile-v1.schema.json'), 'utf8'),
|
|
96
|
+
);
|
|
97
|
+
const checksumsSchema = JSON.parse(
|
|
98
|
+
fs.readFileSync(path.join(schemaDir, 'checksums.schema.json'), 'utf8'),
|
|
99
|
+
);
|
|
100
|
+
const loadContractSchema = JSON.parse(
|
|
101
|
+
fs.readFileSync(path.join(schemaDir, 'load-contract.schema.json'), 'utf8'),
|
|
102
|
+
);
|
|
103
|
+
const ajv = new Ajv({ allErrors: true, strict: false });
|
|
104
|
+
addFormats(ajv);
|
|
105
|
+
ajv.addSchema(loadContractSchema, 'load-contract.schema.json');
|
|
106
|
+
_ajv = ajv;
|
|
107
|
+
_validators = {
|
|
108
|
+
manifest: ajv.compile(manifestSchema),
|
|
109
|
+
payload: ajv.compile(payloadSchema),
|
|
110
|
+
checksums: ajv.compile(checksumsSchema),
|
|
111
|
+
};
|
|
112
|
+
return _validators;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ─── Format detection ──────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Detect whether a directory is a v1 source layout.
|
|
119
|
+
* Required entries: mimetype, kdna.json, payload.kdnab.
|
|
120
|
+
* mimetype content must equal "application/vnd.kdna.asset".
|
|
121
|
+
*/
|
|
122
|
+
function isV1SourceDir(absPath) {
|
|
123
|
+
if (!fs.existsSync(absPath) || !fs.statSync(absPath).isDirectory()) return false;
|
|
124
|
+
for (const f of V1_REQUIRED_DIR_ENTRIES) {
|
|
125
|
+
if (!fs.existsSync(path.join(absPath, f))) return false;
|
|
126
|
+
}
|
|
127
|
+
const mime = fs.readFileSync(path.join(absPath, 'mimetype'), 'utf8');
|
|
128
|
+
return mime === MIMETYPE_V1;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Detect whether a file is a v1 .kdna container.
|
|
133
|
+
* Returns 'v1' | 'v2' | null. null = not a .kdna file or unreadable.
|
|
134
|
+
*/
|
|
135
|
+
function detectContainerFormat(absPath) {
|
|
136
|
+
if (!fs.existsSync(absPath) || !fs.statSync(absPath).isFile()) return null;
|
|
137
|
+
// Quick header check: must look like a ZIP.
|
|
138
|
+
const fd = fs.openSync(absPath, 'r');
|
|
139
|
+
const head = Buffer.alloc(4);
|
|
140
|
+
fs.readSync(fd, head, 0, 4, 0);
|
|
141
|
+
fs.closeSync(fd);
|
|
142
|
+
if (head[0] !== 0x50 || head[1] !== 0x4b) return null;
|
|
143
|
+
|
|
144
|
+
// Read the first entry's name + content. We re-use listZipEntries.
|
|
145
|
+
let entries;
|
|
146
|
+
try {
|
|
147
|
+
entries = listZipEntries(absPath);
|
|
148
|
+
} catch {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
if (entries.length === 0) return null;
|
|
152
|
+
const first = entries[0];
|
|
153
|
+
if (first.name !== 'mimetype') return null;
|
|
154
|
+
// The mimetype entry must be STORED (method 0).
|
|
155
|
+
if (first.method !== 0) return null;
|
|
156
|
+
const mime = first.method === 0 ? first.data.toString('utf8') : '';
|
|
157
|
+
if (mime === MIMETYPE_V1) return 'v1';
|
|
158
|
+
if (mime === MIMETYPE_V2) return 'v2';
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ─── ZIP I/O ────────────────────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Minimal ZIP container entry lister. Returns a list of entries:
|
|
166
|
+
* { name, method, compressedSize, uncompressedSize, localOffset, data }
|
|
167
|
+
* `data` is already decompressed. Throws on unsupported methods or
|
|
168
|
+
* truncated input.
|
|
169
|
+
*/
|
|
170
|
+
function listZipEntries(absPath) {
|
|
171
|
+
const buf = fs.readFileSync(absPath);
|
|
172
|
+
|
|
173
|
+
// Locate EOCD — search backwards within the 64KiB comment window.
|
|
174
|
+
let eocdOff = -1;
|
|
175
|
+
const minStart = Math.max(0, buf.length - 65557);
|
|
176
|
+
for (let i = buf.length - 22; i >= minStart; i--) {
|
|
177
|
+
if (buf.readUInt32LE(i) === 0x06054b50) {
|
|
178
|
+
eocdOff = i;
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (eocdOff < 0) throw new Error('not a ZIP/.kdna container (no EOCD)');
|
|
183
|
+
|
|
184
|
+
const totalEntries = buf.readUInt16LE(eocdOff + 10);
|
|
185
|
+
const cdOffset = buf.readUInt32LE(eocdOff + 16);
|
|
186
|
+
|
|
187
|
+
const entries = [];
|
|
188
|
+
let p = cdOffset;
|
|
189
|
+
for (let i = 0; i < totalEntries; i++) {
|
|
190
|
+
if (buf.readUInt32LE(p) !== 0x02014b50) {
|
|
191
|
+
throw new Error(`bad central-directory entry at offset ${p}`);
|
|
192
|
+
}
|
|
193
|
+
const method = buf.readUInt16LE(p + 10);
|
|
194
|
+
const compSize = buf.readUInt32LE(p + 20);
|
|
195
|
+
const uncompSize = buf.readUInt32LE(p + 24);
|
|
196
|
+
const nameLen = buf.readUInt16LE(p + 28);
|
|
197
|
+
const extraLen = buf.readUInt16LE(p + 30);
|
|
198
|
+
const commentLen = buf.readUInt16LE(p + 32);
|
|
199
|
+
const localOff = buf.readUInt32LE(p + 42);
|
|
200
|
+
const name = buf.slice(p + 46, p + 46 + nameLen).toString('utf8');
|
|
201
|
+
|
|
202
|
+
if (buf.readUInt32LE(localOff) !== 0x04034b50) {
|
|
203
|
+
throw new Error(`bad local-file-header for entry ${name}`);
|
|
204
|
+
}
|
|
205
|
+
const lNameLen = buf.readUInt16LE(localOff + 26);
|
|
206
|
+
const lExtraLen = buf.readUInt16LE(localOff + 28);
|
|
207
|
+
const compStart = localOff + 30 + lNameLen + lExtraLen;
|
|
208
|
+
const comp = buf.slice(compStart, compStart + compSize);
|
|
209
|
+
|
|
210
|
+
let data;
|
|
211
|
+
if (method === 0) data = comp;
|
|
212
|
+
else if (method === 8) data = zlib.inflateRawSync(comp);
|
|
213
|
+
else throw new Error(`unsupported compression method ${method} for ${name}`);
|
|
214
|
+
|
|
215
|
+
entries.push({
|
|
216
|
+
name,
|
|
217
|
+
method,
|
|
218
|
+
compressedSize: compSize,
|
|
219
|
+
uncompressedSize: uncompSize,
|
|
220
|
+
localOffset: localOff,
|
|
221
|
+
data,
|
|
222
|
+
});
|
|
223
|
+
p += 46 + nameLen + extraLen + commentLen;
|
|
224
|
+
}
|
|
225
|
+
return entries;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* CRC-32 (IEEE 802.3) used by ZIP.
|
|
230
|
+
*/
|
|
231
|
+
const CRC_TABLE = (() => {
|
|
232
|
+
const t = new Uint32Array(256);
|
|
233
|
+
for (let n = 0; n < 256; n++) {
|
|
234
|
+
let c = n;
|
|
235
|
+
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
236
|
+
t[n] = c >>> 0;
|
|
237
|
+
}
|
|
238
|
+
return t;
|
|
239
|
+
})();
|
|
240
|
+
function crc32(buf) {
|
|
241
|
+
let c = 0xffffffff;
|
|
242
|
+
for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
|
|
243
|
+
return (c ^ 0xffffffff) >>> 0;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ZIP epoch: 1980-01-01 00:00:00 — fixed so pack is deterministic.
|
|
247
|
+
const DOS_EPOCH = Object.freeze({ time: 0, date: 1 });
|
|
248
|
+
|
|
249
|
+
function buildLocalHeader(nameBytes, data, method) {
|
|
250
|
+
const compressed = method === 8 ? zlib.deflateRawSync(data) : data;
|
|
251
|
+
const crc = crc32(data);
|
|
252
|
+
const { time, date } = DOS_EPOCH;
|
|
253
|
+
const local = Buffer.alloc(30 + nameBytes.length);
|
|
254
|
+
local.writeUInt32LE(0x04034b50, 0);
|
|
255
|
+
local.writeUInt16LE(20, 4);
|
|
256
|
+
local.writeUInt16LE(0, 6);
|
|
257
|
+
local.writeUInt16LE(method, 8);
|
|
258
|
+
local.writeUInt16LE(time, 10);
|
|
259
|
+
local.writeUInt16LE(date, 12);
|
|
260
|
+
local.writeUInt32LE(crc, 14);
|
|
261
|
+
local.writeUInt32LE(compressed.length, 18);
|
|
262
|
+
local.writeUInt32LE(data.length, 22);
|
|
263
|
+
local.writeUInt16LE(nameBytes.length, 26);
|
|
264
|
+
local.writeUInt16LE(0, 28);
|
|
265
|
+
nameBytes.copy(local, 30);
|
|
266
|
+
return { local, compressed, crc, time, date, dataLength: data.length };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function buildCentral(entry, nameBytes) {
|
|
270
|
+
const c = Buffer.alloc(46 + nameBytes.length);
|
|
271
|
+
c.writeUInt32LE(0x02014b50, 0);
|
|
272
|
+
c.writeUInt16LE(20, 4);
|
|
273
|
+
c.writeUInt16LE(20, 6);
|
|
274
|
+
c.writeUInt16LE(0, 8);
|
|
275
|
+
c.writeUInt16LE(entry.method, 10);
|
|
276
|
+
c.writeUInt16LE(entry.time, 12);
|
|
277
|
+
c.writeUInt16LE(entry.date, 14);
|
|
278
|
+
c.writeUInt32LE(entry.crc, 16);
|
|
279
|
+
c.writeUInt32LE(entry.compressed.length, 20);
|
|
280
|
+
c.writeUInt32LE(entry.dataLength, 24);
|
|
281
|
+
c.writeUInt16LE(nameBytes.length, 28);
|
|
282
|
+
c.writeUInt16LE(0, 30);
|
|
283
|
+
c.writeUInt16LE(0, 32);
|
|
284
|
+
c.writeUInt16LE(0, 34);
|
|
285
|
+
c.writeUInt16LE(0, 36);
|
|
286
|
+
c.writeUInt32LE(0, 38);
|
|
287
|
+
c.writeUInt32LE(entry.offset, 42);
|
|
288
|
+
nameBytes.copy(c, 46);
|
|
289
|
+
return c;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Collect a directory's files deterministically. Skips junk like
|
|
294
|
+
* .DS_Store, .git, node_modules, the user's own output dir, etc.
|
|
295
|
+
*/
|
|
296
|
+
function listSourceDir(dir, opts = {}) {
|
|
297
|
+
const skip = new Set(['.DS_Store', '.git', '.gitignore', 'node_modules', 'Thumbs.db']);
|
|
298
|
+
if (opts.skipNames) for (const n of opts.skipNames) skip.add(n);
|
|
299
|
+
const out = [];
|
|
300
|
+
function walk(base) {
|
|
301
|
+
for (const name of fs.readdirSync(base)) {
|
|
302
|
+
if (skip.has(name)) continue;
|
|
303
|
+
const full = path.join(base, name);
|
|
304
|
+
const rel = path.relative(dir, full).split(path.sep).join('/');
|
|
305
|
+
if (rel.startsWith('..')) continue; // defensive
|
|
306
|
+
const st = fs.statSync(full);
|
|
307
|
+
if (st.isDirectory()) {
|
|
308
|
+
walk(full);
|
|
309
|
+
} else if (st.isFile()) {
|
|
310
|
+
out.push({ rel, full });
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
walk(dir);
|
|
315
|
+
out.sort((a, b) => (a.rel < b.rel ? -1 : a.rel > b.rel ? 1 : 0));
|
|
316
|
+
return out;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// ─── Read v1 from either source dir or container ───────────────────────
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Read a v1 layout (source dir or .kdna container) and return a single
|
|
323
|
+
* normalized map of { mimetype, kdna.json, payload.kdnab, checksums.json? }.
|
|
324
|
+
* `where` describes the origin for error messages.
|
|
325
|
+
*
|
|
326
|
+
* Throws an Error with a clear, content-neutral message if the layout
|
|
327
|
+
* is malformed (missing entry, wrong mimetype, etc.).
|
|
328
|
+
*/
|
|
329
|
+
function readV1Layout(absPath) {
|
|
330
|
+
let stat;
|
|
331
|
+
try {
|
|
332
|
+
stat = fs.statSync(absPath);
|
|
333
|
+
} catch (e) {
|
|
334
|
+
throw new Error(`path not found: ${absPath}`);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
let map = {};
|
|
338
|
+
let entries = null; // ZIP entries if container
|
|
339
|
+
let kind = null; // 'dir' | 'file'
|
|
340
|
+
|
|
341
|
+
if (stat.isDirectory()) {
|
|
342
|
+
kind = 'dir';
|
|
343
|
+
for (const f of V1_REQUIRED_DIR_ENTRIES) {
|
|
344
|
+
const full = path.join(absPath, f);
|
|
345
|
+
if (!fs.existsSync(full)) {
|
|
346
|
+
throw new Error(`not a KDNA v1 source dir: missing ${f}`);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
for (const f of [...V1_REQUIRED_DIR_ENTRIES, ...V1_OPTIONAL_DIR_ENTRIES]) {
|
|
350
|
+
const full = path.join(absPath, f);
|
|
351
|
+
if (fs.existsSync(full)) {
|
|
352
|
+
if (fs.statSync(full).isFile()) {
|
|
353
|
+
map[f] = fs.readFileSync(full);
|
|
354
|
+
} else {
|
|
355
|
+
// subdirectory like signatures/ — record its presence but not contents here
|
|
356
|
+
map[f] = null;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
} else if (stat.isFile()) {
|
|
361
|
+
kind = 'file';
|
|
362
|
+
entries = listZipEntries(absPath);
|
|
363
|
+
if (entries.length === 0 || entries[0].name !== 'mimetype') {
|
|
364
|
+
throw new Error('not a KDNA v1 container: first entry is not mimetype');
|
|
365
|
+
}
|
|
366
|
+
if (entries[0].method !== 0) {
|
|
367
|
+
throw new Error('not a KDNA v1 container: mimetype must be uncompressed');
|
|
368
|
+
}
|
|
369
|
+
for (const e of entries) {
|
|
370
|
+
// We only need the well-known entries; signatures/ attachments/ etc.
|
|
371
|
+
// are passed through unchanged by the loader but not parsed here.
|
|
372
|
+
if (
|
|
373
|
+
e.name === 'mimetype' ||
|
|
374
|
+
e.name === 'kdna.json' ||
|
|
375
|
+
e.name === 'payload.kdnab' ||
|
|
376
|
+
e.name === 'checksums.json'
|
|
377
|
+
) {
|
|
378
|
+
map[e.name] = e.data;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
for (const f of V1_REQUIRED_DIR_ENTRIES) {
|
|
382
|
+
if (!map[f]) {
|
|
383
|
+
throw new Error(`not a KDNA v1 container: missing ${f}`);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
} else {
|
|
387
|
+
throw new Error(`not a file or directory: ${absPath}`);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// mimetype content must equal the literal v1 media type.
|
|
391
|
+
const mime = map.mimetype.toString('utf8');
|
|
392
|
+
if (mime !== MIMETYPE_V1) {
|
|
393
|
+
throw new Error(
|
|
394
|
+
`not a KDNA v1 layout: mimetype is "${mime}", expected "${MIMETYPE_V1}"`,
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Lineage must be a single object, not an array. (Format rule from
|
|
399
|
+
// docs/core/manifest.md / schema/manifest.schema.json.)
|
|
400
|
+
let manifest;
|
|
401
|
+
try {
|
|
402
|
+
manifest = JSON.parse(map['kdna.json'].toString('utf8'));
|
|
403
|
+
} catch (e) {
|
|
404
|
+
throw new Error(`kdna.json is not valid JSON: ${e.message}`);
|
|
405
|
+
}
|
|
406
|
+
if (manifest.lineage !== undefined && Array.isArray(manifest.lineage)) {
|
|
407
|
+
throw new Error('kdna.json.lineage must be an object, not an array');
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return { kind, map, manifest, entries };
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// ─── inspect ───────────────────────────────────────────────────────────
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Print a content-neutral manifest summary. Always JSON. Never emits
|
|
417
|
+
* the words trusted / recommended / high_quality / officially_approved.
|
|
418
|
+
*/
|
|
419
|
+
function buildInspectOutput(v1) {
|
|
420
|
+
const m = v1.manifest;
|
|
421
|
+
const out = {
|
|
422
|
+
kdna_version: m.kdna_version ?? null,
|
|
423
|
+
asset_id: m.asset_id ?? null,
|
|
424
|
+
asset_uid: m.asset_uid ?? null,
|
|
425
|
+
asset_type: m.asset_type ?? null,
|
|
426
|
+
title: m.title ?? null,
|
|
427
|
+
version: m.version ?? null,
|
|
428
|
+
judgment_version: m.judgment_version ?? null,
|
|
429
|
+
payload: m.payload ? m.payload.path : null,
|
|
430
|
+
payload_encrypted: m.payload ? m.payload.encrypted : null,
|
|
431
|
+
profile: m.compatibility ? m.compatibility.profile : null,
|
|
432
|
+
load_contract_default_profile: m.load_contract ? m.load_contract.default_profile : null,
|
|
433
|
+
};
|
|
434
|
+
if (m.signatures !== undefined) out.signature_count = Array.isArray(m.signatures) ? m.signatures.length : 0;
|
|
435
|
+
if (v1.map['checksums.json']) out.checksums_present = true;
|
|
436
|
+
return out;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// ─── validate ──────────────────────────────────────────────────────────
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Run structural + JSON-Schema checks. Returns a result object that
|
|
443
|
+
* reports each gate independently. Never includes trust / recommended
|
|
444
|
+
* / high_quality / officially_approved as a positive claim.
|
|
445
|
+
*/
|
|
446
|
+
function runValidate(v1) {
|
|
447
|
+
const result = {
|
|
448
|
+
format_valid: true,
|
|
449
|
+
schema_valid: true,
|
|
450
|
+
payload_valid: true,
|
|
451
|
+
checksums_valid: true,
|
|
452
|
+
load_contract_valid: true,
|
|
453
|
+
};
|
|
454
|
+
const problems = [];
|
|
455
|
+
|
|
456
|
+
// format gate — already proven by readV1Layout, but we re-state the gates
|
|
457
|
+
// so the report matches the spec.
|
|
458
|
+
for (const f of V1_REQUIRED_DIR_ENTRIES) {
|
|
459
|
+
if (!v1.map[f]) {
|
|
460
|
+
result.format_valid = false;
|
|
461
|
+
problems.push(`format: missing required entry ${f}`);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
if (v1.map.mimetype && v1.map.mimetype.toString('utf8') !== MIMETYPE_V1) {
|
|
465
|
+
result.format_valid = false;
|
|
466
|
+
problems.push(`format: mimetype is not ${MIMETYPE_V1}`);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// schema gate — kdna.json against manifest.schema.json
|
|
470
|
+
const validators = loadSchemas();
|
|
471
|
+
if (!validators) {
|
|
472
|
+
result.schema_valid = false;
|
|
473
|
+
problems.push(
|
|
474
|
+
'schema: ajv not available (install ajv + ajv-formats in the consumer env to enable JSON-Schema validation)',
|
|
475
|
+
);
|
|
476
|
+
return finalizeValidate(result, problems);
|
|
477
|
+
}
|
|
478
|
+
if (!validators.manifest(v1.manifest)) {
|
|
479
|
+
result.schema_valid = false;
|
|
480
|
+
for (const err of validators.manifest.errors) {
|
|
481
|
+
problems.push(`manifest: ${err.instancePath || '<root>'} ${err.message}`);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// payload gate — payload.kdnab against payload-profile-v1.schema.json
|
|
486
|
+
let payload;
|
|
487
|
+
try {
|
|
488
|
+
payload = JSON.parse(v1.map['payload.kdnab'].toString('utf8'));
|
|
489
|
+
} catch (e) {
|
|
490
|
+
result.payload_valid = false;
|
|
491
|
+
problems.push(`payload: not valid JSON (${e.message})`);
|
|
492
|
+
return finalizeValidate(result, problems);
|
|
493
|
+
}
|
|
494
|
+
if (!validators.payload(payload)) {
|
|
495
|
+
result.payload_valid = false;
|
|
496
|
+
for (const err of validators.payload.errors) {
|
|
497
|
+
problems.push(`payload: ${err.instancePath || '<root>'} ${err.message}`);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// checksums gate — checksums.json against checksums.schema.json
|
|
502
|
+
if (v1.map['checksums.json']) {
|
|
503
|
+
let checks;
|
|
504
|
+
try {
|
|
505
|
+
checks = JSON.parse(v1.map['checksums.json'].toString('utf8'));
|
|
506
|
+
} catch (e) {
|
|
507
|
+
result.checksums_valid = false;
|
|
508
|
+
problems.push(`checksums: not valid JSON (${e.message})`);
|
|
509
|
+
}
|
|
510
|
+
if (checks && !validators.checksums(checks)) {
|
|
511
|
+
result.checksums_valid = false;
|
|
512
|
+
for (const err of validators.checksums.errors) {
|
|
513
|
+
problems.push(`checksums: ${err.instancePath || '<root>'} ${err.message}`);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
// Digest matching verification — compute actual hashes and compare.
|
|
517
|
+
if (checks) {
|
|
518
|
+
result.checksums_valid = verifyDigests(checks, v1.map, problems, result);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// load_contract gate — only if manifest references a load_contract block
|
|
523
|
+
if (v1.manifest.load_contract) {
|
|
524
|
+
const lc = v1.manifest.load_contract;
|
|
525
|
+
const validLc = _ajv.getSchema('load-contract.schema.json');
|
|
526
|
+
if (validLc && !validLc(lc)) {
|
|
527
|
+
result.load_contract_valid = false;
|
|
528
|
+
for (const err of validLc.errors) {
|
|
529
|
+
problems.push(`load_contract: ${err.instancePath || '<root>'} ${err.message}`);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
} else {
|
|
533
|
+
// No load_contract → nothing to validate. We don't fail the gate.
|
|
534
|
+
result.load_contract_valid = true;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
return finalizeValidate(result, problems);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function verifyDigests(checksums, map, problems, result) {
|
|
541
|
+
const algo = checksums.algorithm || 'sha256';
|
|
542
|
+
if (algo !== 'sha256') {
|
|
543
|
+
problems.push(`checksums: unsupported digest algorithm ${algo} (supported: sha256)`);
|
|
544
|
+
result.checksums_valid = false;
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
const entryMap = {
|
|
548
|
+
manifest_digest: 'kdna.json',
|
|
549
|
+
payload_digest: 'payload.kdnab',
|
|
550
|
+
};
|
|
551
|
+
let stillValid = true;
|
|
552
|
+
for (const [digestKey, entryName] of Object.entries(entryMap)) {
|
|
553
|
+
const declared = checksums[digestKey];
|
|
554
|
+
if (!declared) continue;
|
|
555
|
+
if (!map[entryName]) {
|
|
556
|
+
problems.push(`checksums: ${digestKey} references missing entry ${entryName}`);
|
|
557
|
+
stillValid = false;
|
|
558
|
+
continue;
|
|
559
|
+
}
|
|
560
|
+
const entryBytes = map[entryName];
|
|
561
|
+
const actual = crypto.createHash('sha256').update(entryBytes).digest('hex');
|
|
562
|
+
const expected = declared.replace(/^sha256:/, '');
|
|
563
|
+
if (actual !== expected) {
|
|
564
|
+
problems.push(`checksums: ${digestKey} mismatch (declared ${expected.slice(0, 8)}..., actual ${actual.slice(0, 8)}...)`);
|
|
565
|
+
stillValid = false;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
if (!stillValid) result.checksums_valid = false;
|
|
569
|
+
return stillValid;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function finalizeValidate(result, problems) {
|
|
573
|
+
result.overall_valid =
|
|
574
|
+
result.format_valid &&
|
|
575
|
+
result.schema_valid &&
|
|
576
|
+
result.payload_valid &&
|
|
577
|
+
result.checksums_valid &&
|
|
578
|
+
result.load_contract_valid;
|
|
579
|
+
result.problems = problems;
|
|
580
|
+
return result;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// ─── pack ──────────────────────────────────────────────────────────────
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* Pack a v1 source directory into a .kdna container. Output is
|
|
587
|
+
* deterministic: the same source directory packed twice produces
|
|
588
|
+
* byte-identical output (fixed DOS timestamps, fixed entry order,
|
|
589
|
+
* mimetype first).
|
|
590
|
+
*/
|
|
591
|
+
function pack(sourceDir, outputPath) {
|
|
592
|
+
const absSrc = path.resolve(sourceDir);
|
|
593
|
+
if (!fs.existsSync(absSrc) || !fs.statSync(absSrc).isDirectory()) {
|
|
594
|
+
throw new Error(`not a directory: ${absSrc}`);
|
|
595
|
+
}
|
|
596
|
+
for (const f of V1_REQUIRED_DIR_ENTRIES) {
|
|
597
|
+
if (!fs.existsSync(path.join(absSrc, f))) {
|
|
598
|
+
throw new Error(`cannot pack: missing required entry ${f}`);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
const mime = fs.readFileSync(path.join(absSrc, 'mimetype'), 'utf8');
|
|
602
|
+
if (mime !== MIMETYPE_V1) {
|
|
603
|
+
throw new Error(`cannot pack: mimetype is "${mime}", expected "${MIMETYPE_V1}"`);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// Collect deterministically; mimetype is forced first.
|
|
607
|
+
const collected = listSourceDir(absSrc);
|
|
608
|
+
const order = ['mimetype', ...collected.map((e) => e.rel).filter((n) => n !== 'mimetype')];
|
|
609
|
+
|
|
610
|
+
// Build the ZIP body.
|
|
611
|
+
const localChunks = [];
|
|
612
|
+
const centralChunks = [];
|
|
613
|
+
let offset = 0;
|
|
614
|
+
for (const rel of order) {
|
|
615
|
+
let data;
|
|
616
|
+
if (rel === 'mimetype') {
|
|
617
|
+
data = Buffer.from(MIMETYPE_V1, 'utf8');
|
|
618
|
+
} else {
|
|
619
|
+
const found = collected.find((e) => e.rel === rel);
|
|
620
|
+
if (!found) continue;
|
|
621
|
+
data = fs.readFileSync(found.full);
|
|
622
|
+
}
|
|
623
|
+
const nameBytes = Buffer.from(rel, 'utf8');
|
|
624
|
+
const method = rel === 'mimetype' ? 0 : 8;
|
|
625
|
+
const built = buildLocalHeader(nameBytes, data, method);
|
|
626
|
+
localChunks.push(built.local, built.compressed);
|
|
627
|
+
centralChunks.push(
|
|
628
|
+
buildCentral(
|
|
629
|
+
{
|
|
630
|
+
method,
|
|
631
|
+
crc: built.crc,
|
|
632
|
+
time: built.time,
|
|
633
|
+
date: built.date,
|
|
634
|
+
compressed: built.compressed,
|
|
635
|
+
dataLength: built.dataLength,
|
|
636
|
+
offset,
|
|
637
|
+
},
|
|
638
|
+
nameBytes,
|
|
639
|
+
),
|
|
640
|
+
);
|
|
641
|
+
offset += built.local.length + built.compressed.length;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
const centralOffset = offset;
|
|
645
|
+
let centralSize = 0;
|
|
646
|
+
for (const c of centralChunks) centralSize += c.length;
|
|
647
|
+
const eocd = Buffer.alloc(22);
|
|
648
|
+
eocd.writeUInt32LE(0x06054b50, 0);
|
|
649
|
+
eocd.writeUInt16LE(0, 4);
|
|
650
|
+
eocd.writeUInt16LE(0, 6);
|
|
651
|
+
eocd.writeUInt16LE(order.length, 8);
|
|
652
|
+
eocd.writeUInt16LE(order.length, 10);
|
|
653
|
+
eocd.writeUInt32LE(centralSize, 12);
|
|
654
|
+
eocd.writeUInt32LE(centralOffset, 16);
|
|
655
|
+
eocd.writeUInt16LE(0, 20);
|
|
656
|
+
|
|
657
|
+
fs.mkdirSync(path.dirname(path.resolve(outputPath)), { recursive: true });
|
|
658
|
+
fs.writeFileSync(outputPath, Buffer.concat([...localChunks, ...centralChunks, eocd]));
|
|
659
|
+
return { outputPath, entries: order };
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// ─── unpack ────────────────────────────────────────────────────────────
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* Unpack a v1 .kdna container to a directory. Refuses path traversal.
|
|
666
|
+
* Does not auto-execute any entry.
|
|
667
|
+
*/
|
|
668
|
+
function unpack(inputPath, outputDir) {
|
|
669
|
+
const absIn = path.resolve(inputPath);
|
|
670
|
+
if (!fs.existsSync(absIn) || !fs.statSync(absIn).isFile()) {
|
|
671
|
+
throw new Error(`not a file: ${absIn}`);
|
|
672
|
+
}
|
|
673
|
+
const entries = listZipEntries(absIn);
|
|
674
|
+
// Sanity: v1 container must have mimetype as first entry with the v1 media type.
|
|
675
|
+
if (entries.length === 0 || entries[0].name !== 'mimetype') {
|
|
676
|
+
throw new Error('not a KDNA v1 container: first entry is not mimetype');
|
|
677
|
+
}
|
|
678
|
+
if (entries[0].method !== 0) {
|
|
679
|
+
throw new Error('not a KDNA v1 container: mimetype must be uncompressed');
|
|
680
|
+
}
|
|
681
|
+
if (entries[0].data.toString('utf8') !== MIMETYPE_V1) {
|
|
682
|
+
throw new Error(
|
|
683
|
+
`not a KDNA v1 container: mimetype is "${entries[0].data.toString('utf8')}", expected "${MIMETYPE_V1}"`,
|
|
684
|
+
);
|
|
685
|
+
}
|
|
686
|
+
const absOut = path.resolve(outputDir);
|
|
687
|
+
fs.mkdirSync(absOut, { recursive: true });
|
|
688
|
+
const written = [];
|
|
689
|
+
for (const e of entries) {
|
|
690
|
+
const dest = path.join(absOut, e.name);
|
|
691
|
+
const rel = path.relative(absOut, dest);
|
|
692
|
+
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
693
|
+
throw new Error(`refusing to write outside target: ${e.name}`);
|
|
694
|
+
}
|
|
695
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
696
|
+
fs.writeFileSync(dest, e.data);
|
|
697
|
+
written.push(e.name);
|
|
698
|
+
}
|
|
699
|
+
return { outputDir: absOut, entries: written };
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// ─── Public router entry points ────────────────────────────────────────
|
|
703
|
+
|
|
704
|
+
function inspect(inputPath, opts = {}) {
|
|
705
|
+
const v1 = readV1Layout(path.resolve(inputPath));
|
|
706
|
+
const out = buildInspectOutput(v1);
|
|
707
|
+
// Guard against accidental forbidden wording in any future field additions.
|
|
708
|
+
assertNoForbiddenTerms(out);
|
|
709
|
+
return out;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
function validate(inputPath, opts = {}) {
|
|
713
|
+
const v1 = readV1Layout(path.resolve(inputPath));
|
|
714
|
+
return runValidate(v1);
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
function assertNoForbiddenTerms(obj) {
|
|
718
|
+
const seen = new Set();
|
|
719
|
+
function walk(o) {
|
|
720
|
+
if (o === null || typeof o !== 'object') return;
|
|
721
|
+
if (Array.isArray(o)) {
|
|
722
|
+
o.forEach(walk);
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
for (const k of Object.keys(o)) {
|
|
726
|
+
if (FORBIDDEN_OUTPUT_TERMS.includes(k)) seen.add(k);
|
|
727
|
+
walk(o[k]);
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
walk(obj);
|
|
731
|
+
if (seen.size > 0) {
|
|
732
|
+
throw new Error(
|
|
733
|
+
`internal: v1 inspect output contains forbidden terms: ${[...seen].join(', ')}`,
|
|
734
|
+
);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
module.exports = {
|
|
739
|
+
MIMETYPE: MIMETYPE_V1,
|
|
740
|
+
MIMETYPE_V1,
|
|
741
|
+
MIMETYPE_V2,
|
|
742
|
+
V1_REQUIRED_DIR_ENTRIES,
|
|
743
|
+
isV1SourceDir,
|
|
744
|
+
detectContainerFormat,
|
|
745
|
+
readV1Layout,
|
|
746
|
+
inspect,
|
|
747
|
+
validate,
|
|
748
|
+
pack,
|
|
749
|
+
unpack,
|
|
750
|
+
loadV1,
|
|
751
|
+
FORBIDDEN_OUTPUT_TERMS,
|
|
752
|
+
};
|
|
753
|
+
|
|
754
|
+
// ─── loadV1 — v1 runtime loading / load contract ──────────────────────
|
|
755
|
+
|
|
756
|
+
function loadV1(inputPath, opts = {}) {
|
|
757
|
+
const v1 = readV1Layout(path.resolve(inputPath));
|
|
758
|
+
const m = v1.manifest;
|
|
759
|
+
const profile = opts.profile || (m.load_contract ? m.load_contract.default_profile : 'compact') || 'compact';
|
|
760
|
+
const as = opts.as || 'json';
|
|
761
|
+
|
|
762
|
+
let payload;
|
|
763
|
+
try {
|
|
764
|
+
payload = JSON.parse(v1.map['payload.kdnab'].toString('utf8'));
|
|
765
|
+
} catch (e) {
|
|
766
|
+
throw new Error(`payload.kdnab is not valid JSON: ${e.message}`);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
if (payload.encrypted === true || (v1.map.mimetype && v1.map.mimetype.includes('encrypted'))) {
|
|
770
|
+
const err = new Error('payload is encrypted and cannot be decrypted without a key');
|
|
771
|
+
err.code = 'requires_decryption';
|
|
772
|
+
throw err;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// Digest verification — refuse to load if checksums.json is present and digests mismatch.
|
|
776
|
+
if (v1.map['checksums.json']) {
|
|
777
|
+
try {
|
|
778
|
+
const checks = JSON.parse(v1.map['checksums.json'].toString('utf8'));
|
|
779
|
+
const problems = [];
|
|
780
|
+
const ok = verifyDigests(checks, v1.map, problems, {});
|
|
781
|
+
if (!ok) {
|
|
782
|
+
const err = new Error(`checksum verification failed: ${problems.join('; ')}`);
|
|
783
|
+
err.code = 'checksum_mismatch';
|
|
784
|
+
throw err;
|
|
785
|
+
}
|
|
786
|
+
} catch (e) {
|
|
787
|
+
if (e.code === 'checksum_mismatch') throw e;
|
|
788
|
+
// Invalid JSON — already caught by validate, but still refuse to load.
|
|
789
|
+
const err = new Error(`checksums.json is not valid: ${e.message}`);
|
|
790
|
+
err.code = 'checksum_parse_error';
|
|
791
|
+
throw err;
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
const result = { status: 'loaded', profile, asset_id: m.asset_id, title: m.title };
|
|
796
|
+
|
|
797
|
+
if (profile === 'index') {
|
|
798
|
+
result.content = { asset_id: m.asset_id, asset_uid: m.asset_uid, title: m.title, version: m.version, judgment_version: m.judgment_version, asset_type: m.asset_type, summary: m.summary || null, language: m.language || null, keywords: m.keywords || [], profiles_available: m.load_contract ? Object.keys(m.load_contract.profiles || {}) : [] };
|
|
799
|
+
} else if (profile === 'compact') {
|
|
800
|
+
const core = payload.core || {};
|
|
801
|
+
result.content = { highest_question: core.highest_question || null, axioms: (core.axioms || []).map((a) => a.one_sentence || a).filter(Boolean), boundaries: core.boundaries || [], self_checks: (payload.reasoning && payload.reasoning.self_checks) || [], failure_modes: (payload.reasoning && payload.reasoning.failure_modes) || [], patterns: (payload.patterns || []).slice(0, 3) };
|
|
802
|
+
if (m.load_contract && m.load_contract.profiles && m.load_contract.profiles.compact && m.load_contract.profiles.compact.max_tokens_hint) {
|
|
803
|
+
result.max_tokens_hint = m.load_contract.profiles.compact.max_tokens_hint;
|
|
804
|
+
}
|
|
805
|
+
} else if (profile === 'scenario') {
|
|
806
|
+
result.content = { note: 'scenario profile: no scenarios present in minimal example, returning compact fallback' };
|
|
807
|
+
if (payload.scenarios && payload.scenarios.length > 0) {
|
|
808
|
+
result.content = { scenarios: payload.scenarios };
|
|
809
|
+
}
|
|
810
|
+
} else if (profile === 'full') {
|
|
811
|
+
result.content = { manifest: m, payload };
|
|
812
|
+
} else {
|
|
813
|
+
throw new Error(`unknown load profile: ${profile}`);
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
if (as === 'prompt') {
|
|
817
|
+
const c = result.content;
|
|
818
|
+
let text = 'KDNA Judgment Asset: ' + (result.title || 'untitled') + '\n';
|
|
819
|
+
text += 'Asset ID: ' + (result.asset_id || 'unknown') + '\n';
|
|
820
|
+
text += 'Profile: ' + result.profile + '\n';
|
|
821
|
+
if (result.max_tokens_hint) text += 'Max tokens hint: ' + result.max_tokens_hint + '\n';
|
|
822
|
+
if (c.highest_question) text += 'Highest question:\n' + c.highest_question + '\n';
|
|
823
|
+
if (c.axioms && c.axioms.length) text += 'Axioms:\n' + c.axioms.map((a) => '- ' + (typeof a === 'string' ? a : JSON.stringify(a))).join('\n') + '\n';
|
|
824
|
+
if (c.boundaries && c.boundaries.length) text += 'Boundaries:\n' + c.boundaries.map((b) => '- ' + (typeof b === 'string' ? b : JSON.stringify(b))).join('\n') + '\n';
|
|
825
|
+
if (c.self_checks && c.self_checks.length) text += 'Self-checks:\n' + c.self_checks.map((s) => '- ' + (typeof s === 'string' ? s : JSON.stringify(s))).join('\n') + '\n';
|
|
826
|
+
if (c.failure_modes && c.failure_modes.length) text += 'Failure modes:\n' + c.failure_modes.map((f) => '- ' + (f.mode || f)).join('\n') + '\n';
|
|
827
|
+
if (c.patterns && c.patterns.length) text += 'Patterns:\n' + c.patterns.map((p) => '- ' + (p.name || p)).join('\n') + '\n';
|
|
828
|
+
if (c.note) text += 'Note: ' + c.note + '\n';
|
|
829
|
+
return { status: result.status, profile: result.profile, text: text.trim() };
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
return result;
|
|
833
|
+
}
|