@stream44.studio/t44 0.4.0-rc.24
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/.dco-signatures +9 -0
- package/.github/workflows/dco.yaml +12 -0
- package/.github/workflows/gordian-open-integrity.yaml +13 -0
- package/.github/workflows/test.yaml +31 -0
- package/.o/GordianOpenIntegrity-CurrentLifehash.svg +1026 -0
- package/.o/GordianOpenIntegrity-InceptionLifehash.svg +1026 -0
- package/.o/GordianOpenIntegrity.yaml +21 -0
- package/.o/assets/Hero-Terminal44-v0.jpeg +0 -0
- package/.o/stream44.studio/assets/Icon-v1.svg +1170 -0
- package/.repo-identifier +1 -0
- package/DCO.md +34 -0
- package/LICENSE.txt +186 -0
- package/README.md +189 -0
- package/bin/activate +36 -0
- package/bin/activate.ts +30 -0
- package/bin/postinstall.sh +19 -0
- package/bin/shell +27 -0
- package/bin/t44 +27 -0
- package/caps/ConfigSchemaStruct.ts +55 -0
- package/caps/Home.ts +57 -0
- package/caps/HomeRegistry.ts +319 -0
- package/caps/HomeRegistryFile.ts +144 -0
- package/caps/JsonSchemas.ts +220 -0
- package/caps/OpenApiSchema.ts +67 -0
- package/caps/PackageDescriptor.ts +88 -0
- package/caps/ProjectCatalogs.ts +153 -0
- package/caps/ProjectDeployment.ts +426 -0
- package/caps/ProjectDevelopment.ts +257 -0
- package/caps/ProjectPublishing.ts +654 -0
- package/caps/ProjectPulling.ts +234 -0
- package/caps/ProjectRack.ts +155 -0
- package/caps/ProjectRepository.ts +332 -0
- package/caps/ProjectTest.ts +251 -0
- package/caps/ProjectTestLib.ts +257 -0
- package/caps/RootKey.ts +219 -0
- package/caps/SigningKey.ts +243 -0
- package/caps/TaskWorkflow.ts +192 -0
- package/caps/WorkspaceCli.ts +448 -0
- package/caps/WorkspaceConfig.ts +268 -0
- package/caps/WorkspaceConfig.yaml +87 -0
- package/caps/WorkspaceConfigFile.ts +902 -0
- package/caps/WorkspaceConnection.ts +329 -0
- package/caps/WorkspaceEntityConfig.ts +78 -0
- package/caps/WorkspaceEntityConfig.v0.ts +77 -0
- package/caps/WorkspaceEntityFact.ts +218 -0
- package/caps/WorkspaceInfo.ts +619 -0
- package/caps/WorkspaceInit.ts +30 -0
- package/caps/WorkspaceKey.ts +338 -0
- package/caps/WorkspaceModel.ts +373 -0
- package/caps/WorkspaceProjects.ts +636 -0
- package/caps/WorkspacePrompt.ts +430 -0
- package/caps/WorkspaceShell.sh +39 -0
- package/caps/WorkspaceShell.ts +104 -0
- package/caps/WorkspaceShell.yaml +64 -0
- package/caps/WorkspaceShellCli.ts +109 -0
- package/caps/patterns/README.md +2 -0
- package/caps/patterns/git-scm.com/ProjectPublishing.ts +507 -0
- package/caps/patterns/semver.org/ProjectPublishing.ts +458 -0
- package/docs/Overview.drawio +248 -0
- package/docs/Overview.svg +4 -0
- package/examples/01-Lifecycle/main.test.ts +223 -0
- package/lib/crypto.ts +53 -0
- package/lib/key.ts +381 -0
- package/lib/schema-console-renderer.ts +181 -0
- package/lib/schema-resolver.ts +349 -0
- package/lib/ucan.ts +137 -0
- package/package.json +91 -0
- package/standalone-rt.test.ts +150 -0
- package/standalone-rt.ts +140 -0
- package/structs/HomeRegistry.ts +55 -0
- package/structs/HomeRegistryConfig.ts +60 -0
- package/structs/ProjectCatalogsConfig.ts +53 -0
- package/structs/ProjectDeploymentConfig.ts +56 -0
- package/structs/ProjectDeploymentFact.ts +106 -0
- package/structs/ProjectPublishingConfig.ts +78 -0
- package/structs/ProjectPublishingFact.ts +68 -0
- package/structs/ProjectPullingConfig.ts +52 -0
- package/structs/ProjectRack.ts +51 -0
- package/structs/ProjectRackConfig.ts +56 -0
- package/structs/RepositoryOriginDescriptor.ts +51 -0
- package/structs/RootKeyConfig.ts +64 -0
- package/structs/SigningKeyConfig.ts +64 -0
- package/structs/Workspace.ts +56 -0
- package/structs/WorkspaceCatalogs.ts +56 -0
- package/structs/WorkspaceCliConfig.ts +53 -0
- package/structs/WorkspaceConfig.ts +64 -0
- package/structs/WorkspaceConfigFile.ts +50 -0
- package/structs/WorkspaceConfigFileMeta.ts +70 -0
- package/structs/WorkspaceKey.ts +55 -0
- package/structs/WorkspaceKeyConfig.ts +56 -0
- package/structs/WorkspaceMappingsConfig.ts +56 -0
- package/structs/WorkspaceProject.ts +104 -0
- package/structs/WorkspaceProjectsConfig.ts +67 -0
- package/structs/WorkspaceShellConfig.ts +83 -0
- package/structs/patterns/README.md +2 -0
- package/structs/patterns/git-scm.com/ProjectPublishingFact.ts +46 -0
- package/tsconfig.json +33 -0
- package/workspace-rt.ts +152 -0
- package/workspace.yaml +3 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import chalk from 'chalk'
|
|
2
|
+
|
|
3
|
+
export interface RenderOptions {
|
|
4
|
+
indent?: number
|
|
5
|
+
maxDepth?: number
|
|
6
|
+
currentDepth?: number
|
|
7
|
+
showTypes?: boolean
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Generic schema-based console renderer that formats data based on JSON Schema properties
|
|
12
|
+
*/
|
|
13
|
+
export class SchemaConsoleRenderer {
|
|
14
|
+
/**
|
|
15
|
+
* Render entity data based on its schema
|
|
16
|
+
*/
|
|
17
|
+
static renderEntity(
|
|
18
|
+
data: any,
|
|
19
|
+
schema: any,
|
|
20
|
+
options: RenderOptions = {}
|
|
21
|
+
): string {
|
|
22
|
+
const {
|
|
23
|
+
indent = 0,
|
|
24
|
+
maxDepth = 5,
|
|
25
|
+
currentDepth = 0,
|
|
26
|
+
showTypes = false
|
|
27
|
+
} = options
|
|
28
|
+
|
|
29
|
+
// maxDepth of -1 means unlimited depth
|
|
30
|
+
if (maxDepth !== -1 && currentDepth >= maxDepth) {
|
|
31
|
+
return chalk.gray(' '.repeat(indent) + '[max depth reached]')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const lines: string[] = []
|
|
35
|
+
const properties = schema?.properties || {}
|
|
36
|
+
const required = new Set(schema?.required || [])
|
|
37
|
+
|
|
38
|
+
// If data is primitive, render directly
|
|
39
|
+
if (typeof data !== 'object' || data === null) {
|
|
40
|
+
return chalk.yellow(String(data))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Render each property based on schema
|
|
44
|
+
for (const [key, value] of Object.entries(data)) {
|
|
45
|
+
const propSchema = properties[key]
|
|
46
|
+
const isRequired = required.has(key)
|
|
47
|
+
const prefix = ' '.repeat(indent)
|
|
48
|
+
|
|
49
|
+
// Format key with required indicator
|
|
50
|
+
const keyDisplay = isRequired
|
|
51
|
+
? chalk.bold.cyan(key)
|
|
52
|
+
: chalk.cyan(key)
|
|
53
|
+
|
|
54
|
+
// Add type annotation if requested
|
|
55
|
+
const typeAnnotation = showTypes && propSchema?.type
|
|
56
|
+
? chalk.gray(` (${propSchema.type})`)
|
|
57
|
+
: ''
|
|
58
|
+
|
|
59
|
+
// Render value based on type
|
|
60
|
+
if (value === null || value === undefined) {
|
|
61
|
+
lines.push(`${prefix}${keyDisplay}${typeAnnotation}: ${chalk.gray('null')}`)
|
|
62
|
+
} else if (Array.isArray(value)) {
|
|
63
|
+
lines.push(`${prefix}${keyDisplay}${typeAnnotation}: ${chalk.gray(`[${value.length} items]`)}`)
|
|
64
|
+
|
|
65
|
+
// Render array items if not too deep
|
|
66
|
+
if ((maxDepth === -1 || currentDepth < maxDepth - 1) && value.length > 0) {
|
|
67
|
+
const itemSchema = propSchema?.items
|
|
68
|
+
value.forEach((item, idx) => {
|
|
69
|
+
if (typeof item === 'object' && item !== null) {
|
|
70
|
+
lines.push(`${prefix} ${chalk.gray(`${idx}`)}:`)
|
|
71
|
+
lines.push(this.renderEntity(item, itemSchema, {
|
|
72
|
+
indent: indent + 3,
|
|
73
|
+
maxDepth,
|
|
74
|
+
currentDepth: currentDepth + 1,
|
|
75
|
+
showTypes
|
|
76
|
+
}))
|
|
77
|
+
} else {
|
|
78
|
+
lines.push(`${prefix} ${chalk.gray(`${idx}`)}: ${this.formatValue(item, propSchema)}`)
|
|
79
|
+
}
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
} else if (typeof value === 'object') {
|
|
83
|
+
const description = propSchema?.description
|
|
84
|
+
const descSuffix = description ? chalk.gray(` // ${description}`) : ''
|
|
85
|
+
lines.push(`${prefix}${keyDisplay}${typeAnnotation}:${descSuffix}`)
|
|
86
|
+
|
|
87
|
+
// Recursively render nested object
|
|
88
|
+
lines.push(this.renderEntity(value, propSchema, {
|
|
89
|
+
indent: indent + 1,
|
|
90
|
+
maxDepth,
|
|
91
|
+
currentDepth: currentDepth + 1,
|
|
92
|
+
showTypes
|
|
93
|
+
}))
|
|
94
|
+
} else {
|
|
95
|
+
const description = propSchema?.description
|
|
96
|
+
const descSuffix = description ? chalk.gray(` // ${description}`) : ''
|
|
97
|
+
lines.push(`${prefix}${keyDisplay}${typeAnnotation}: ${this.formatValue(value, propSchema)}${descSuffix}`)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return lines.join('\n')
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Format a primitive value based on its schema
|
|
106
|
+
*/
|
|
107
|
+
private static formatValue(value: any, schema?: any): string {
|
|
108
|
+
if (value === null || value === undefined) {
|
|
109
|
+
return chalk.gray('null')
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const type = schema?.type || typeof value
|
|
113
|
+
const format = schema?.format
|
|
114
|
+
|
|
115
|
+
switch (type) {
|
|
116
|
+
case 'string':
|
|
117
|
+
if (format === 'date-time') {
|
|
118
|
+
return chalk.green(value)
|
|
119
|
+
} else if (format === 'uri' || format === 'url') {
|
|
120
|
+
return chalk.blue.underline(value)
|
|
121
|
+
} else if (schema?.enum) {
|
|
122
|
+
return chalk.magenta(value)
|
|
123
|
+
}
|
|
124
|
+
return chalk.yellow(JSON.stringify(value))
|
|
125
|
+
|
|
126
|
+
case 'number':
|
|
127
|
+
case 'integer':
|
|
128
|
+
return chalk.cyan(String(value))
|
|
129
|
+
|
|
130
|
+
case 'boolean':
|
|
131
|
+
return value ? chalk.green('true') : chalk.red('false')
|
|
132
|
+
|
|
133
|
+
default:
|
|
134
|
+
return chalk.yellow(JSON.stringify(value))
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Render a summary line for an entity
|
|
140
|
+
*/
|
|
141
|
+
static renderSummary(
|
|
142
|
+
entityName: string,
|
|
143
|
+
data: any,
|
|
144
|
+
schema: any
|
|
145
|
+
): string {
|
|
146
|
+
const properties = schema?.properties || {}
|
|
147
|
+
const parts: string[] = []
|
|
148
|
+
|
|
149
|
+
// Try to find key identifying properties
|
|
150
|
+
const identifiers = ['name', 'identifier', 'id', 'title']
|
|
151
|
+
for (const key of identifiers) {
|
|
152
|
+
if (data[key] && properties[key]) {
|
|
153
|
+
parts.push(chalk.yellow(data[key]))
|
|
154
|
+
break
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Add status if present
|
|
159
|
+
if (data.status && properties.status) {
|
|
160
|
+
const statusColor = data.status === 'READY' ? chalk.green :
|
|
161
|
+
data.status === 'ERROR' ? chalk.red :
|
|
162
|
+
chalk.yellow
|
|
163
|
+
parts.push(statusColor(`[${data.status}]`))
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return parts.length > 0 ? parts.join(' ') : ''
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Render validation errors
|
|
171
|
+
*/
|
|
172
|
+
static renderErrors(errors: any[]): string {
|
|
173
|
+
if (errors.length === 0) return ''
|
|
174
|
+
|
|
175
|
+
const lines: string[] = [chalk.red.bold('Validation Errors:')]
|
|
176
|
+
for (const err of errors) {
|
|
177
|
+
lines.push(chalk.red(` ${err.path}: ${err.message}`))
|
|
178
|
+
}
|
|
179
|
+
return lines.join('\n')
|
|
180
|
+
}
|
|
181
|
+
}
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
|
|
2
|
+
import { join, relative } from 'path'
|
|
3
|
+
import { readdir, readFile } from 'fs/promises'
|
|
4
|
+
import { RefResolver } from 'json-schema-ref-resolver'
|
|
5
|
+
import Ajv from 'ajv'
|
|
6
|
+
import addFormats from 'ajv-formats'
|
|
7
|
+
|
|
8
|
+
export interface ResolvedEntity {
|
|
9
|
+
schemaId: string
|
|
10
|
+
name: string
|
|
11
|
+
data: any
|
|
12
|
+
filePath: string
|
|
13
|
+
relPath: string
|
|
14
|
+
line?: number
|
|
15
|
+
valid: boolean
|
|
16
|
+
errors: any[]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ResolverContext {
|
|
20
|
+
workspaceRootDir: string
|
|
21
|
+
workspaceName: string
|
|
22
|
+
schemasDir: string
|
|
23
|
+
factsDir: string
|
|
24
|
+
metaCacheDir: string
|
|
25
|
+
homeRegistryConnectionsDir: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface WorkspaceResolverResult {
|
|
29
|
+
schemas: Map<string, any>
|
|
30
|
+
entities: Map<string, ResolvedEntity[]>
|
|
31
|
+
configEntities: Map<string, any>
|
|
32
|
+
factEntities: Map<string, ResolvedEntity[]>
|
|
33
|
+
connectionEntities: Map<string, ResolvedEntity[]>
|
|
34
|
+
refResolver: RefResolver
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function createAjvInstance(): Ajv {
|
|
38
|
+
const ajv = new Ajv({
|
|
39
|
+
allErrors: true,
|
|
40
|
+
strict: false,
|
|
41
|
+
validateFormats: true,
|
|
42
|
+
logger: false
|
|
43
|
+
})
|
|
44
|
+
addFormats(ajv)
|
|
45
|
+
return ajv
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function Resolver(context: ResolverContext) {
|
|
49
|
+
const refResolver = new RefResolver()
|
|
50
|
+
const ajv = createAjvInstance()
|
|
51
|
+
const schemas = new Map<string, any>()
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
async loadSchemas(): Promise<Map<string, any>> {
|
|
55
|
+
await loadSchemasInternal(context.schemasDir, refResolver, schemas)
|
|
56
|
+
return schemas
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
async loadConfigEntities(): Promise<{ configEntities: Map<string, any>; entities: Map<string, ResolvedEntity[]> }> {
|
|
60
|
+
const configEntities = new Map<string, any>()
|
|
61
|
+
const entities = new Map<string, ResolvedEntity[]>()
|
|
62
|
+
await loadConfigFileEntities(context.metaCacheDir, ajv, schemas, configEntities, entities)
|
|
63
|
+
return { configEntities, entities }
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
async loadFactEntities(): Promise<{ factEntities: Map<string, ResolvedEntity[]>; entities: Map<string, ResolvedEntity[]> }> {
|
|
67
|
+
const factEntities = new Map<string, ResolvedEntity[]>()
|
|
68
|
+
const entities = new Map<string, ResolvedEntity[]>()
|
|
69
|
+
await loadEntityFiles({
|
|
70
|
+
entityDir: context.factsDir,
|
|
71
|
+
workspaceRootDir: context.workspaceRootDir,
|
|
72
|
+
ajv,
|
|
73
|
+
schemas,
|
|
74
|
+
entityMap: factEntities,
|
|
75
|
+
allEntities: entities
|
|
76
|
+
})
|
|
77
|
+
return { factEntities, entities }
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
async loadConnectionEntities(): Promise<{ connectionEntities: Map<string, ResolvedEntity[]>; entities: Map<string, ResolvedEntity[]> }> {
|
|
81
|
+
const connectionEntities = new Map<string, ResolvedEntity[]>()
|
|
82
|
+
const entities = new Map<string, ResolvedEntity[]>()
|
|
83
|
+
await loadEntityFiles({
|
|
84
|
+
entityDir: context.homeRegistryConnectionsDir,
|
|
85
|
+
workspaceRootDir: context.workspaceRootDir,
|
|
86
|
+
ajv,
|
|
87
|
+
schemas,
|
|
88
|
+
entityMap: connectionEntities,
|
|
89
|
+
allEntities: entities,
|
|
90
|
+
isFlat: true
|
|
91
|
+
})
|
|
92
|
+
return { connectionEntities, entities }
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
getRefResolver(): RefResolver {
|
|
96
|
+
return refResolver
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
getSchemas(): Map<string, any> {
|
|
100
|
+
return schemas
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function loadSchemasInternal(
|
|
106
|
+
schemasDir: string,
|
|
107
|
+
refResolver: RefResolver,
|
|
108
|
+
schemas: Map<string, any>
|
|
109
|
+
): Promise<void> {
|
|
110
|
+
let files: string[]
|
|
111
|
+
try {
|
|
112
|
+
files = await readdir(schemasDir)
|
|
113
|
+
} catch {
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
for (const file of files) {
|
|
118
|
+
if (!file.endsWith('.json')) continue
|
|
119
|
+
const filePath = join(schemasDir, file)
|
|
120
|
+
try {
|
|
121
|
+
const content = await readFile(filePath, 'utf-8')
|
|
122
|
+
const schema = JSON.parse(content)
|
|
123
|
+
if (schema.$id) {
|
|
124
|
+
refResolver.addSchema(schema)
|
|
125
|
+
// Store by $id (with version)
|
|
126
|
+
schemas.set(schema.$id, schema)
|
|
127
|
+
// Also store by entity name (without version) for easier lookup
|
|
128
|
+
const entityName = schema.$id.replace(/\.v\d+$/, '')
|
|
129
|
+
schemas.set(entityName, schema)
|
|
130
|
+
}
|
|
131
|
+
} catch {
|
|
132
|
+
// Skip files that can't be parsed
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function loadConfigFileEntities(
|
|
138
|
+
metaCacheDir: string,
|
|
139
|
+
ajv: Ajv,
|
|
140
|
+
schemas: Map<string, any>,
|
|
141
|
+
configEntities: Map<string, any>,
|
|
142
|
+
entities: Map<string, ResolvedEntity[]>
|
|
143
|
+
): Promise<void> {
|
|
144
|
+
|
|
145
|
+
let metaFiles: string[]
|
|
146
|
+
try {
|
|
147
|
+
metaFiles = await readdir(metaCacheDir)
|
|
148
|
+
} catch {
|
|
149
|
+
return
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
for (const metaFile of metaFiles) {
|
|
153
|
+
if (!metaFile.endsWith('.json')) continue
|
|
154
|
+
|
|
155
|
+
try {
|
|
156
|
+
const metaPath = join(metaCacheDir, metaFile)
|
|
157
|
+
const metaContent = await readFile(metaPath, 'utf-8')
|
|
158
|
+
const metadata = JSON.parse(metaContent)
|
|
159
|
+
|
|
160
|
+
if (!metadata.entities || typeof metadata.entities !== 'object') continue
|
|
161
|
+
|
|
162
|
+
for (const [entityKey, entityMeta] of Object.entries(metadata.entities as Record<string, any>)) {
|
|
163
|
+
if (!entityKey.startsWith('#')) continue
|
|
164
|
+
const entityName = entityKey.substring(1)
|
|
165
|
+
|
|
166
|
+
configEntities.set(entityKey, entityMeta.data)
|
|
167
|
+
|
|
168
|
+
// Try to validate against schema
|
|
169
|
+
const schemaId = entityName + '.v0'
|
|
170
|
+
const schema = schemas.get(schemaId)
|
|
171
|
+
|
|
172
|
+
const resolved: ResolvedEntity = {
|
|
173
|
+
schemaId,
|
|
174
|
+
name: entityName,
|
|
175
|
+
data: entityMeta.data,
|
|
176
|
+
filePath: metadata.filePath,
|
|
177
|
+
relPath: metadata.relPath,
|
|
178
|
+
line: entityMeta.line,
|
|
179
|
+
valid: true,
|
|
180
|
+
errors: []
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (schema) {
|
|
184
|
+
try {
|
|
185
|
+
const validate = ajv.compile(schema)
|
|
186
|
+
if (!validate(entityMeta.data)) {
|
|
187
|
+
resolved.valid = false
|
|
188
|
+
resolved.errors = validate.errors?.map(e => ({
|
|
189
|
+
path: e.instancePath || '/',
|
|
190
|
+
message: e.message,
|
|
191
|
+
keyword: e.keyword
|
|
192
|
+
})) || []
|
|
193
|
+
}
|
|
194
|
+
} catch {
|
|
195
|
+
// Schema compilation failed
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (!entities.has(entityKey)) entities.set(entityKey, [])
|
|
200
|
+
entities.get(entityKey)!.push(resolved)
|
|
201
|
+
}
|
|
202
|
+
} catch {
|
|
203
|
+
// Skip unparseable metadata files
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
interface LoadEntityFilesOptions {
|
|
209
|
+
entityDir: string
|
|
210
|
+
workspaceRootDir: string
|
|
211
|
+
ajv: Ajv
|
|
212
|
+
schemas: Map<string, any>
|
|
213
|
+
entityMap: Map<string, ResolvedEntity[]>
|
|
214
|
+
allEntities: Map<string, ResolvedEntity[]>
|
|
215
|
+
isFlat?: boolean // true for connections (flat .json files), false for facts (subdirectories)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function loadEntityFiles(options: LoadEntityFilesOptions): Promise<void> {
|
|
219
|
+
const { entityDir, workspaceRootDir, ajv, schemas, entityMap, allEntities, isFlat = false } = options
|
|
220
|
+
|
|
221
|
+
let items: string[]
|
|
222
|
+
try {
|
|
223
|
+
items = await readdir(entityDir)
|
|
224
|
+
} catch {
|
|
225
|
+
return
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (isFlat) {
|
|
229
|
+
// Flat structure: files are directly in entityDir (e.g., connections)
|
|
230
|
+
for (const file of items) {
|
|
231
|
+
if (!file.startsWith('@') || !file.endsWith('.json')) continue
|
|
232
|
+
|
|
233
|
+
const entityTypeDir = file.replace(/\.json$/, '')
|
|
234
|
+
const entityName = entityTypeDir.replace(/~/g, '/')
|
|
235
|
+
const schema = schemas.get(entityName)
|
|
236
|
+
const filePath = join(entityDir, file)
|
|
237
|
+
const fileName = file.replace(/\.json$/, '')
|
|
238
|
+
|
|
239
|
+
try {
|
|
240
|
+
const content = await readFile(filePath, 'utf-8')
|
|
241
|
+
const data = JSON.parse(content)
|
|
242
|
+
const cleanData = { ...data }
|
|
243
|
+
delete cleanData.$schema
|
|
244
|
+
delete cleanData.$defs
|
|
245
|
+
delete cleanData.$id
|
|
246
|
+
|
|
247
|
+
const resolved: ResolvedEntity = {
|
|
248
|
+
schemaId: entityName,
|
|
249
|
+
name: fileName,
|
|
250
|
+
data: cleanData,
|
|
251
|
+
filePath,
|
|
252
|
+
relPath: relative(workspaceRootDir, filePath),
|
|
253
|
+
valid: true,
|
|
254
|
+
errors: []
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (schema) {
|
|
258
|
+
try {
|
|
259
|
+
const validate = ajv.compile(schema)
|
|
260
|
+
if (!validate(cleanData)) {
|
|
261
|
+
resolved.valid = false
|
|
262
|
+
resolved.errors = validate.errors?.map(e => ({
|
|
263
|
+
path: e.instancePath || '/',
|
|
264
|
+
message: e.message,
|
|
265
|
+
keyword: e.keyword
|
|
266
|
+
})) || []
|
|
267
|
+
}
|
|
268
|
+
} catch {
|
|
269
|
+
// Schema compilation failed
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (!entityMap.has(entityName)) entityMap.set(entityName, [])
|
|
274
|
+
entityMap.get(entityName)!.push(resolved)
|
|
275
|
+
|
|
276
|
+
const entityKey = `#${entityName}`
|
|
277
|
+
if (!allEntities.has(entityKey)) allEntities.set(entityKey, [])
|
|
278
|
+
allEntities.get(entityKey)!.push(resolved)
|
|
279
|
+
} catch {
|
|
280
|
+
// Skip unparseable files
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
} else {
|
|
284
|
+
// Nested structure: subdirectories contain entity files (e.g., facts)
|
|
285
|
+
for (const entityTypeDir of items) {
|
|
286
|
+
if (!entityTypeDir.startsWith('@')) continue
|
|
287
|
+
const entityTypePath = join(entityDir, entityTypeDir)
|
|
288
|
+
const entityName = entityTypeDir.replace(/~/g, '/')
|
|
289
|
+
const schema = schemas.get(entityName)
|
|
290
|
+
|
|
291
|
+
let entityFiles: string[]
|
|
292
|
+
try {
|
|
293
|
+
entityFiles = await readdir(entityTypePath)
|
|
294
|
+
} catch {
|
|
295
|
+
continue
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
for (const entityFile of entityFiles) {
|
|
299
|
+
if (!entityFile.endsWith('.json')) continue
|
|
300
|
+
const entityFilePath = join(entityTypePath, entityFile)
|
|
301
|
+
const entityFileName = entityFile.slice(0, -5) // strip .json
|
|
302
|
+
|
|
303
|
+
try {
|
|
304
|
+
const content = await readFile(entityFilePath, 'utf-8')
|
|
305
|
+
const data = JSON.parse(content)
|
|
306
|
+
const cleanData = { ...data }
|
|
307
|
+
delete cleanData.$schema
|
|
308
|
+
delete cleanData.$defs
|
|
309
|
+
delete cleanData.$id
|
|
310
|
+
|
|
311
|
+
const resolved: ResolvedEntity = {
|
|
312
|
+
schemaId: entityName,
|
|
313
|
+
name: entityFileName,
|
|
314
|
+
data: cleanData,
|
|
315
|
+
filePath: entityFilePath,
|
|
316
|
+
relPath: relative(workspaceRootDir, entityFilePath),
|
|
317
|
+
valid: true,
|
|
318
|
+
errors: []
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (schema) {
|
|
322
|
+
try {
|
|
323
|
+
const validate = ajv.compile(schema)
|
|
324
|
+
if (!validate(cleanData)) {
|
|
325
|
+
resolved.valid = false
|
|
326
|
+
resolved.errors = validate.errors?.map(e => ({
|
|
327
|
+
path: e.instancePath || '/',
|
|
328
|
+
message: e.message,
|
|
329
|
+
keyword: e.keyword
|
|
330
|
+
})) || []
|
|
331
|
+
}
|
|
332
|
+
} catch {
|
|
333
|
+
// Schema compilation failed
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (!entityMap.has(entityName)) entityMap.set(entityName, [])
|
|
338
|
+
entityMap.get(entityName)!.push(resolved)
|
|
339
|
+
|
|
340
|
+
const entityKey = `#${entityName}`
|
|
341
|
+
if (!allEntities.has(entityKey)) allEntities.set(entityKey, [])
|
|
342
|
+
allEntities.get(entityKey)!.push(resolved)
|
|
343
|
+
} catch {
|
|
344
|
+
// Skip unparseable files
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
package/lib/ucan.ts
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
|
|
2
|
+
import { ed25519 } from '@ucanto/principal';
|
|
3
|
+
import * as Server from '@ucanto/server';
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
export async function generateKeypair(): Promise<{ did: string; privateKey: string }> {
|
|
7
|
+
const principal = await ed25519.generate();
|
|
8
|
+
// The principal itself is a Uint8Array containing the private key
|
|
9
|
+
const privateKeyBytes = new Uint8Array(principal as any);
|
|
10
|
+
return {
|
|
11
|
+
did: principal.did(),
|
|
12
|
+
privateKey: Buffer.from(privateKeyBytes).toString('base64'),
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Extract DID from a base64-encoded private key
|
|
18
|
+
* @param privateKeyBase64 - Base64-encoded private key
|
|
19
|
+
* @returns The DID string
|
|
20
|
+
*/
|
|
21
|
+
export function didForPrivateKey(privateKeyBase64: string): string {
|
|
22
|
+
const keyBytes = Buffer.from(privateKeyBase64, 'base64');
|
|
23
|
+
const principal = ed25519.decode(new Uint8Array(keyBytes));
|
|
24
|
+
return principal.did();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Issue a UCAN capability delegation
|
|
29
|
+
* @param options.issuerPrivateKey - Base64-encoded private key of the issuer
|
|
30
|
+
* @param options.audienceDID - DID of the audience (recipient)
|
|
31
|
+
* @param options.capabilities - Array of capabilities to delegate
|
|
32
|
+
* @param options.expiresIn - Expiration time in seconds from now (default: 1 year)
|
|
33
|
+
* @returns Base64-encoded UCAN token
|
|
34
|
+
*/
|
|
35
|
+
export async function issueCapability(options: {
|
|
36
|
+
issuerPrivateKey: string;
|
|
37
|
+
audienceDID: string;
|
|
38
|
+
capabilities: Array<{ with: string; can: string }>;
|
|
39
|
+
expiresIn?: number;
|
|
40
|
+
}): Promise<string> {
|
|
41
|
+
// Decode the issuer's private key
|
|
42
|
+
const keyBytes = Buffer.from(options.issuerPrivateKey, 'base64');
|
|
43
|
+
const issuer = ed25519.decode(new Uint8Array(keyBytes));
|
|
44
|
+
|
|
45
|
+
// Parse the audience DID
|
|
46
|
+
const audience = ed25519.Verifier.parse(options.audienceDID);
|
|
47
|
+
|
|
48
|
+
const expiresIn = options.expiresIn || 365 * 24 * 60 * 60; // 1 year
|
|
49
|
+
const expiration = Math.floor(Date.now() / 1000) + expiresIn;
|
|
50
|
+
|
|
51
|
+
const delegation = await Server.delegate({
|
|
52
|
+
issuer,
|
|
53
|
+
audience,
|
|
54
|
+
capabilities: options.capabilities,
|
|
55
|
+
expiration,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const archive = await delegation.archive();
|
|
59
|
+
if (!archive.ok) {
|
|
60
|
+
throw new Error('Failed to archive delegation');
|
|
61
|
+
}
|
|
62
|
+
return Buffer.from(archive.ok).toString('base64');
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Validate a UCAN capability delegation
|
|
67
|
+
* @param options.ucanToken - Base64-encoded UCAN token
|
|
68
|
+
* @param options.issuerDID - Expected DID of the issuer
|
|
69
|
+
* @param options.expectedCapability - Expected capability (optional)
|
|
70
|
+
* @returns Validation result with delegation details
|
|
71
|
+
*/
|
|
72
|
+
export async function validateCapability(options: {
|
|
73
|
+
ucanToken: string;
|
|
74
|
+
issuerDID: string;
|
|
75
|
+
expectedCapability?: { can: string };
|
|
76
|
+
}): Promise<{
|
|
77
|
+
valid: boolean;
|
|
78
|
+
error?: string;
|
|
79
|
+
issuer?: string;
|
|
80
|
+
audience?: string;
|
|
81
|
+
capabilities?: Array<{ with: string; can: string }>;
|
|
82
|
+
expiration?: number;
|
|
83
|
+
}> {
|
|
84
|
+
try {
|
|
85
|
+
// Parse the UCAN from the token
|
|
86
|
+
const carBytes = Buffer.from(options.ucanToken, 'base64');
|
|
87
|
+
const result: any = await Server.Delegation.extract(carBytes);
|
|
88
|
+
if (!result.ok) {
|
|
89
|
+
return {
|
|
90
|
+
valid: false,
|
|
91
|
+
error: `Failed to parse UCAN: ${result.error.message}`,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
const delegation: any = result.ok;
|
|
95
|
+
|
|
96
|
+
// Basic validation: check expiration
|
|
97
|
+
const now = Math.floor(Date.now() / 1000);
|
|
98
|
+
if (delegation.expiration && delegation.expiration < now) {
|
|
99
|
+
return {
|
|
100
|
+
valid: false,
|
|
101
|
+
error: 'UCAN has expired',
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Verify issuer matches expected
|
|
106
|
+
if (delegation.issuer.did() !== options.issuerDID) {
|
|
107
|
+
return {
|
|
108
|
+
valid: false,
|
|
109
|
+
error: `UCAN not issued by expected issuer. Expected: ${options.issuerDID}, Got: ${delegation.issuer.did()}`,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Validate capability if specified
|
|
114
|
+
if (options.expectedCapability) {
|
|
115
|
+
const capability = delegation.capabilities[0];
|
|
116
|
+
if (capability.can !== options.expectedCapability.can) {
|
|
117
|
+
return {
|
|
118
|
+
valid: false,
|
|
119
|
+
error: `Invalid capability: expected '${options.expectedCapability.can}', got '${capability.can}'`,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
valid: true,
|
|
126
|
+
issuer: delegation.issuer.did(),
|
|
127
|
+
audience: delegation.audience.did(),
|
|
128
|
+
capabilities: delegation.capabilities,
|
|
129
|
+
expiration: delegation.expiration,
|
|
130
|
+
};
|
|
131
|
+
} catch (error: any) {
|
|
132
|
+
return {
|
|
133
|
+
valid: false,
|
|
134
|
+
error: `Failed to validate UCAN: ${error.message}`,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
}
|