msgctl 0.1.0a1__py3-none-any.whl
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.
- msg/__init__.py +2 -0
- msg/admin/__init__.py +0 -0
- msg/admin/backup_retirement.py +56 -0
- msg/admin/backups.py +341 -0
- msg/admin/custodial_check.py +77 -0
- msg/admin/diagnostics.py +750 -0
- msg/admin/market.py +91 -0
- msg/admin/market_check.py +341 -0
- msg/admin/money.py +322 -0
- msg/admin/preflight.py +142 -0
- msg/admin/recovery_replay.py +281 -0
- msg/admin/restore_database.py +37 -0
- msg/admin/root.py +347 -0
- msg/admin/rotation.py +197 -0
- msg/admin/token_delivery_check.py +54 -0
- msg/admin/upgrade_check.py +58 -0
- msg/application.py +163 -0
- msg/bootstrap.py +305 -0
- msg/cli.py +537 -0
- msg/client.py +725 -0
- msg/client_certificates.py +98 -0
- msg/client_content.py +43 -0
- msg/client_custodial.py +206 -0
- msg/client_market.py +104 -0
- msg/client_recovery.py +186 -0
- msg/client_secrets.py +58 -0
- msg/client_tokens.py +99 -0
- msg/client_upgrade.py +188 -0
- msg/config.py +318 -0
- msg/constants.py +11 -0
- msg/core/__init__.py +0 -0
- msg/core/batching.py +23 -0
- msg/core/codec.py +253 -0
- msg/core/contracts.py +128 -0
- msg/core/cursors.py +68 -0
- msg/core/email_address.py +33 -0
- msg/core/errors.py +22 -0
- msg/core/events.py +6 -0
- msg/core/execution_ports.py +20 -0
- msg/core/executor.py +193 -0
- msg/core/models.py +517 -0
- msg/core/packet.py +71 -0
- msg/core/permissions.py +14 -0
- msg/core/query.py +44 -0
- msg/core/read_query.py +60 -0
- msg/core/registry.py +173 -0
- msg/core/requests.py +58 -0
- msg/core/schema_policy.py +28 -0
- msg/core/schemas.py +30 -0
- msg/core/tags.py +21 -0
- msg/core/template_dsl.py +94 -0
- msg/core/text_patch.py +283 -0
- msg/core/tool_execution.py +28 -0
- msg/core/transfer.py +183 -0
- msg/daemon.py +255 -0
- msg/data/__init__.py +0 -0
- msg/data/bootstrap.json +72 -0
- msg/data/favicon.png +0 -0
- msg/data/logo-dark.svg +5 -0
- msg/data/logo.svg +5 -0
- msg/data/recovery-checkpoint.example.json +1 -0
- msg/data/recovery-checkpoint.schema.json +118 -0
- msg/data/shortcodes.json +1 -0
- msg/data/system/AGENTS.md +6 -0
- msg/data/system/rules/_index.md +17 -0
- msg/data/system/rules/auth.md +6 -0
- msg/data/system/rules/files.md +6 -0
- msg/data/system/rules/identity.md +6 -0
- msg/data/system/rules/protocol.md +6 -0
- msg/data/system/rules/read-write.md +6 -0
- msg/data/system/rules/recovery.md +6 -0
- msg/data/system/rules/security.md +10 -0
- msg/data/system/rules/topics.md +6 -0
- msg/extensions/__init__.py +1 -0
- msg/extensions/hosting.py +259 -0
- msg/extensions/keystore.py +96 -0
- msg/extensions/repositories.py +769 -0
- msg/extensions/rss.py +67 -0
- msg/extensions/ssh.py +264 -0
- msg/extensions/ssh_git.py +300 -0
- msg/extensions/tools.py +150 -0
- msg/hosting_runtime.py +138 -0
- msg/market/__init__.py +1 -0
- msg/market/arbitration.py +481 -0
- msg/market/delivery.py +338 -0
- msg/market/delivery_notifications.py +110 -0
- msg/market/delivery_targets.py +76 -0
- msg/market/email.py +100 -0
- msg/market/escrow.py +345 -0
- msg/market/orders.py +218 -0
- msg/market/policy.py +147 -0
- msg/market/rationale.py +91 -0
- msg/market/references.py +19 -0
- msg/market/targets.py +157 -0
- msg/plugins/__init__.py +28 -0
- msg/plugins/achievements.py +316 -0
- msg/plugins/batch.py +32 -0
- msg/plugins/bounty.py +366 -0
- msg/plugins/collaboration.py +261 -0
- msg/plugins/common.py +217 -0
- msg/plugins/communication.py +853 -0
- msg/plugins/content.py +855 -0
- msg/plugins/custodial_lifecycle.py +224 -0
- msg/plugins/delivery.py +230 -0
- msg/plugins/discovery.py +1266 -0
- msg/plugins/discussion.py +162 -0
- msg/plugins/extensions.py +10 -0
- msg/plugins/following.py +78 -0
- msg/plugins/hosting_capacity.py +52 -0
- msg/plugins/identity.py +1654 -0
- msg/plugins/money.py +221 -0
- msg/plugins/offers.py +183 -0
- msg/plugins/orders.py +273 -0
- msg/plugins/recovery.py +376 -0
- msg/plugins/schemas.py +5 -0
- msg/plugins/sharing.py +300 -0
- msg/plugins/store.py +249 -0
- msg/plugins/system.py +75 -0
- msg/plugins/transfer.py +249 -0
- msg/py.typed +0 -0
- msg/security/__init__.py +0 -0
- msg/security/age_keys.py +112 -0
- msg/security/authentication.py +141 -0
- msg/security/authorization.py +300 -0
- msg/security/backup_retirement.py +135 -0
- msg/security/capabilities.py +157 -0
- msg/security/certificates.py +183 -0
- msg/security/crypto.py +102 -0
- msg/security/custodial_migration.py +292 -0
- msg/security/custody_history.py +146 -0
- msg/security/network.py +81 -0
- msg/security/policy.py +67 -0
- msg/security/quarantine.py +19 -0
- msg/security/root_files.py +67 -0
- msg/security/rotation_journal.py +60 -0
- msg/security/sealed_box.py +70 -0
- msg/security/sharing_policy.py +29 -0
- msg/security/token_delivery.py +93 -0
- msg/security/vault.py +186 -0
- msg/storage/__init__.py +0 -0
- msg/storage/capacity.py +100 -0
- msg/storage/custodial_migration.py +22 -0
- msg/storage/git.py +435 -0
- msg/storage/ledger_migration.py +307 -0
- msg/storage/market_migration.py +49 -0
- msg/storage/postgres.py +663 -0
- msg/storage/query.py +39 -0
- msg/storage/read_only.py +126 -0
- msg/storage/session.py +403 -0
- msg/storage/sqlite.py +360 -0
- msg/storage/topic_event_migration.py +26 -0
- msg/storage/valkey_bus.py +45 -0
- msg/transports/__init__.py +0 -0
- msg/transports/client.py +183 -0
- msg/transports/dictionary.py +387 -0
- msg/transports/graphql.py +75 -0
- msg/transports/http.py +72 -0
- msg/transports/http_routes.py +1577 -0
- msg/transports/mcp.py +127 -0
- msg/transports/packet.py +42 -0
- msg/transports/read_tree_path.py +65 -0
- msg/transports/stdio.py +36 -0
- msg/transports/url_safety.py +123 -0
- msg/tui.py +368 -0
- msg/workers/__init__.py +1 -0
- msg/workers/effects.py +406 -0
- msg/workers/leases.py +19 -0
- msg/workers/mail.py +63 -0
- msg/workers/maintenance.py +310 -0
- msg/workers/sandbox.py +74 -0
- msg/workers/sandbox_child.py +189 -0
- msg/workers/webhook.py +160 -0
- msgctl-0.1.0a1.dist-info/METADATA +96 -0
- msgctl-0.1.0a1.dist-info/RECORD +176 -0
- msgctl-0.1.0a1.dist-info/WHEEL +4 -0
- msgctl-0.1.0a1.dist-info/entry_points.txt +4 -0
msg/core/read_query.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Version selection and shared budgets for explicit nested read projections."""
|
|
2
|
+
from collections.abc import Mapping
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
from msg.core.codec import canonical
|
|
7
|
+
from msg.core.errors import require
|
|
8
|
+
|
|
9
|
+
MAX_READ_DEPTH=4
|
|
10
|
+
MAX_READ_NODES=100
|
|
11
|
+
MAX_READ_COST=1000
|
|
12
|
+
MAX_READ_SCANNED=4096
|
|
13
|
+
NESTED_FIELDS=('id','name','type','path','revision')
|
|
14
|
+
ROOT_FIELDS=(*NESTED_FIELDS,'generation','created_at','modified_at','owner','group','mode')
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def read_query_version(arguments):
|
|
18
|
+
if arguments.get('query_version')==3 or isinstance(arguments.get('expand'),Mapping):
|
|
19
|
+
return 3
|
|
20
|
+
return 2 if any(name in arguments for name in ('expand','collection','nested_first')) else 1
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def expansion_schema(depth=MAX_READ_DEPTH):
|
|
24
|
+
if depth==0:
|
|
25
|
+
return {'type':'object','maxProperties':0}
|
|
26
|
+
node={'type':'object','properties':{
|
|
27
|
+
'limit':{'type':'integer','minimum':1,'maximum':10},
|
|
28
|
+
'fields':{'type':'array','items':{'enum':list(NESTED_FIELDS)},
|
|
29
|
+
'minItems':1,'maxItems':len(NESTED_FIELDS),'uniqueItems':True},
|
|
30
|
+
'expand':expansion_schema(depth-1)},'additionalProperties':False}
|
|
31
|
+
return {'type':'object','properties':{'children':node,'replies':node},
|
|
32
|
+
'additionalProperties':False}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class ReadBudget:
|
|
37
|
+
deadline: float
|
|
38
|
+
max_bytes: int
|
|
39
|
+
nodes: int=0
|
|
40
|
+
cost: int=0
|
|
41
|
+
scanned: int=0
|
|
42
|
+
|
|
43
|
+
def check(self):
|
|
44
|
+
require(time.monotonic()<self.deadline,'query_cost_exceeded')
|
|
45
|
+
|
|
46
|
+
def scan(self):
|
|
47
|
+
self.check()
|
|
48
|
+
self.scanned+=1
|
|
49
|
+
require(self.scanned<=MAX_READ_SCANNED,'query_cost_exceeded')
|
|
50
|
+
|
|
51
|
+
def node(self,fields):
|
|
52
|
+
self.check()
|
|
53
|
+
self.nodes+=1
|
|
54
|
+
self.cost+=1+fields
|
|
55
|
+
require(self.nodes<=MAX_READ_NODES and self.cost<=MAX_READ_COST,
|
|
56
|
+
'query_cost_exceeded')
|
|
57
|
+
|
|
58
|
+
def output(self,data):
|
|
59
|
+
self.check()
|
|
60
|
+
require(len(canonical(data))<=self.max_bytes,'response_too_large')
|
msg/core/registry.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Finite, explicit plugin contracts. No code loading from resources."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from copy import deepcopy
|
|
5
|
+
|
|
6
|
+
from referencing.exceptions import Unresolvable
|
|
7
|
+
from msg.core.schema_policy import local_validator
|
|
8
|
+
from msg.core.codec import digest,wire
|
|
9
|
+
from msg.core.errors import Failure,require
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# Older credential contracts remain in the registry so their published short
|
|
13
|
+
# codes and schemas can explain the migration path, but the executor rejects
|
|
14
|
+
# them. Keep this metadata separate from OperationSpec so contract identity and
|
|
15
|
+
# published operation rows remain unchanged.
|
|
16
|
+
_DEPRECATED_OPERATIONS={
|
|
17
|
+
('identity.temporary',1):('identity.temporary',3),
|
|
18
|
+
('identity.temporary',2):('identity.temporary',3),
|
|
19
|
+
('identity.custodial_create',1):('identity.custodial_create',2),
|
|
20
|
+
('identity.token_rotate',1):('identity.token_rotate',2),
|
|
21
|
+
('identity.token_create',1):('identity.token_create',2),
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Registry:
|
|
26
|
+
def __init__(self):
|
|
27
|
+
self._types={}
|
|
28
|
+
self._capabilities={}
|
|
29
|
+
self._operations={}
|
|
30
|
+
self._schemas={}
|
|
31
|
+
self._validators={}
|
|
32
|
+
self._plugins={}
|
|
33
|
+
self._frozen=False
|
|
34
|
+
|
|
35
|
+
def _insert(self,collection,spec,kind):
|
|
36
|
+
require(not self._frozen,'registry_frozen')
|
|
37
|
+
require(spec.version>=1 and spec.name and '*' not in spec.name,'invalid_registry_name')
|
|
38
|
+
key=(spec.name,spec.version)
|
|
39
|
+
require(key not in collection,'duplicate_'+kind)
|
|
40
|
+
collection[key]=spec
|
|
41
|
+
|
|
42
|
+
def add_resource_type(self,spec):
|
|
43
|
+
self._insert(self._types,spec,'resource_type')
|
|
44
|
+
|
|
45
|
+
def add_capability(self,spec):
|
|
46
|
+
self._insert(self._capabilities,spec,'capability')
|
|
47
|
+
|
|
48
|
+
def add_operation(self,spec):
|
|
49
|
+
require(not spec.name.startswith('root.') or spec.entries==frozenset({'local_admin'}),'local_only_contract')
|
|
50
|
+
require(spec.entries and spec.entries<=frozenset({'local_admin','network','worker'}),'invalid_entries')
|
|
51
|
+
self._insert(self._operations,spec,'operation')
|
|
52
|
+
|
|
53
|
+
def add_schema(self,ref,schema):
|
|
54
|
+
require(not self._frozen and ref.id not in self._schemas,'schema_conflict')
|
|
55
|
+
# Keep one owned snapshot for both validation and publication. Caller
|
|
56
|
+
# dictionaries and lookup results must not mutate a frozen contract.
|
|
57
|
+
schema=deepcopy(schema)
|
|
58
|
+
validator=local_validator(schema)
|
|
59
|
+
self._schemas[ref.id]=schema
|
|
60
|
+
self._validators[ref.id]=validator
|
|
61
|
+
|
|
62
|
+
def add(self,manifest):
|
|
63
|
+
require(not self._frozen,'registry_frozen')
|
|
64
|
+
require(manifest.name not in self._plugins,'duplicate_plugin')
|
|
65
|
+
require(all(d in self._plugins for d in manifest.dependencies),'missing_plugin_dependency')
|
|
66
|
+
# Validate the entire manifest before modifying any registration map.
|
|
67
|
+
for collection,values in ((self._types,manifest.resource_types),
|
|
68
|
+
(self._capabilities,manifest.capabilities),
|
|
69
|
+
(self._operations,manifest.operations)):
|
|
70
|
+
keys=[(v.name,v.version) for v in values]
|
|
71
|
+
require(len(keys)==len(set(keys)) and not set(keys)&collection.keys(),'plugin_conflict')
|
|
72
|
+
for item in (*manifest.resource_types,*manifest.capabilities,*manifest.operations):
|
|
73
|
+
require(item.version>=1 and item.name and '*' not in item.name,'invalid_registry_name')
|
|
74
|
+
for item in manifest.operations:
|
|
75
|
+
require(not item.name.startswith('root.') or item.entries==frozenset({'local_admin'}),'local_only_contract')
|
|
76
|
+
require(item.entries and item.entries<=frozenset({'local_admin','network','worker'}),'invalid_entries')
|
|
77
|
+
for item in manifest.resource_types:
|
|
78
|
+
self.add_resource_type(item)
|
|
79
|
+
for item in manifest.capabilities:
|
|
80
|
+
self.add_capability(item)
|
|
81
|
+
for item in manifest.operations:
|
|
82
|
+
self.add_operation(item)
|
|
83
|
+
self._plugins[manifest.name]=manifest
|
|
84
|
+
|
|
85
|
+
def freeze(self):
|
|
86
|
+
require('identity' in self._plugins,'identity_plugin_required')
|
|
87
|
+
for spec in self._operations.values():
|
|
88
|
+
require(spec.input_schema.id in self._schemas and spec.output_schema.id in self._schemas,'missing_schema')
|
|
89
|
+
for cap in self._capabilities.values():
|
|
90
|
+
require(cap.scope_types<=set(n for n,v in self._types),'unknown_scope_type')
|
|
91
|
+
require(all(op in {f'{s.name}@{s.version}' for s in self._operations.values()}
|
|
92
|
+
for op in cap.operations),'unknown_capability_operation')
|
|
93
|
+
self._frozen=True
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def frozen(self):
|
|
97
|
+
return self._frozen
|
|
98
|
+
|
|
99
|
+
def _get(self,collection,name,version,kind):
|
|
100
|
+
try:
|
|
101
|
+
return collection[(name,version)]
|
|
102
|
+
except KeyError as exc:
|
|
103
|
+
raise Failure('unknown_'+kind) from exc
|
|
104
|
+
|
|
105
|
+
def operation(self,name,version=1):
|
|
106
|
+
return self._get(self._operations,name,version,'operation')
|
|
107
|
+
|
|
108
|
+
def capability(self,name,version=1):
|
|
109
|
+
return self._get(self._capabilities,name,version,'capability')
|
|
110
|
+
|
|
111
|
+
def resource_type(self,name,version=1):
|
|
112
|
+
return self._get(self._types,name,version,'resource_type')
|
|
113
|
+
|
|
114
|
+
def capabilities(self):
|
|
115
|
+
return tuple(self._capabilities[k] for k in sorted(self._capabilities))
|
|
116
|
+
|
|
117
|
+
def operations(self,entry=None):
|
|
118
|
+
return tuple(self._operations[k] for k in sorted(self._operations)
|
|
119
|
+
if entry is None or entry in self._operations[k].entries)
|
|
120
|
+
|
|
121
|
+
def resource_types(self):
|
|
122
|
+
return tuple(self._types[k] for k in sorted(self._types))
|
|
123
|
+
|
|
124
|
+
def schema(self,ref):
|
|
125
|
+
key=ref.id if hasattr(ref,'id') else ref
|
|
126
|
+
require(key in self._schemas,'schema_not_found')
|
|
127
|
+
return deepcopy(self._schemas[key])
|
|
128
|
+
|
|
129
|
+
def validate(self,ref,value):
|
|
130
|
+
try:
|
|
131
|
+
errors=sorted(self._validators[ref.id if hasattr(ref,"id") else ref].iter_errors(wire(value)),key=lambda e:str(e.path))
|
|
132
|
+
except Unresolvable as exc:
|
|
133
|
+
# Reference exception messages can include filesystem paths/URIs.
|
|
134
|
+
raise Failure('schema_reference_unresolvable') from exc
|
|
135
|
+
if errors:
|
|
136
|
+
raise Failure('schema_validation','.'.join(str(i) for i in errors[0].path) or 'arguments')
|
|
137
|
+
|
|
138
|
+
def describe(self,spec):
|
|
139
|
+
from msg.bootstrap import RULE_PATHS
|
|
140
|
+
name=spec.name
|
|
141
|
+
if name.startswith('identity.'):
|
|
142
|
+
rules=('identity','auth')
|
|
143
|
+
elif name.startswith('content.topic_') or name.startswith('discussion.'):
|
|
144
|
+
rules=('topics','read-write')
|
|
145
|
+
elif name.startswith('content.'):
|
|
146
|
+
rules=('read-write',)
|
|
147
|
+
elif name.startswith(('transfer.','keystore.','git.')):
|
|
148
|
+
rules=('files','protocol')
|
|
149
|
+
elif name.startswith(('cert.','group.')):
|
|
150
|
+
rules=('auth',)
|
|
151
|
+
elif name.startswith(('system.','tool.')):
|
|
152
|
+
rules=('security','protocol')
|
|
153
|
+
elif name.startswith('discovery.'):
|
|
154
|
+
rules=('read-write','protocol')
|
|
155
|
+
else:
|
|
156
|
+
rules=('protocol',)
|
|
157
|
+
rule_ids=tuple('msg.'+rule for rule in rules)
|
|
158
|
+
require(len(set(rule_ids))==len(rule_ids) and all(rid in RULE_PATHS for rid in rule_ids),
|
|
159
|
+
'dangling_requires_rules',name)
|
|
160
|
+
result={'name':spec.name,'version':spec.version,'effect':spec.effect,
|
|
161
|
+
'entries':sorted(spec.entries),'require_signature':spec.require_signature,
|
|
162
|
+
'input_schema':wire(spec.input_schema),'output_schema':wire(spec.output_schema),
|
|
163
|
+
'requires_rules':[{'rule_id':rule_id,'path':RULE_PATHS[rule_id]}
|
|
164
|
+
for rule_id in rule_ids]}
|
|
165
|
+
replacement=_DEPRECATED_OPERATIONS.get((spec.name,spec.version))
|
|
166
|
+
if replacement is not None:
|
|
167
|
+
result.update(deprecated=True,
|
|
168
|
+
replaced_by=f'{replacement[0]}@{replacement[1]}')
|
|
169
|
+
return result
|
|
170
|
+
|
|
171
|
+
def catalog(self,entry='network'):
|
|
172
|
+
values=[self.describe(s) for s in self.operations(entry)]
|
|
173
|
+
return {'version':1,'digest':digest(values),'operations':values}
|
msg/core/requests.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""The same signed envelope is used by HTTP, path GET, GraphQL, MCP and msg."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
from dataclasses import replace
|
|
4
|
+
from datetime import UTC,datetime,timedelta
|
|
5
|
+
from uuid import uuid4
|
|
6
|
+
from msg.core.codec import canonical,digest,wire
|
|
7
|
+
from msg.core.models import OperationRequest,SignatureProof,TokenProof
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
# Old issuance contracts did not bind independent recovery material before
|
|
11
|
+
# commit. Never execute or replay them: a historical result cannot prove
|
|
12
|
+
# whether its secret was already delivered to the caller.
|
|
13
|
+
SECRET_DELIVERY_MIN_VERSION = {
|
|
14
|
+
'identity.temporary': 3,
|
|
15
|
+
'identity.custodial_create': 2,
|
|
16
|
+
'identity.token_rotate': 2,
|
|
17
|
+
'identity.token_create': 2,
|
|
18
|
+
'identity.token_recover': 1,
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def payload_fields(request):
|
|
23
|
+
data=wire(request)
|
|
24
|
+
for field in ('proof','payload_digest','source','expires_at'):
|
|
25
|
+
data.pop(field)
|
|
26
|
+
return data
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def signing_bytes(request):
|
|
30
|
+
data=wire(request)
|
|
31
|
+
data.pop('proof')
|
|
32
|
+
return canonical(data)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def request_for(operation,arguments,service, *, subject=None,signer=None,token=None,
|
|
36
|
+
certificates=(),request_id=None,expires_at=None,source='msg',expected=(),return_fields=(),
|
|
37
|
+
contract_version=1):
|
|
38
|
+
request=OperationRequest(request_id=request_id or uuid4().hex,protocol_version=1,
|
|
39
|
+
operation=operation,contract_version=contract_version,target_service=service,subject=subject,
|
|
40
|
+
arguments=arguments,expected_generations=tuple(expected),
|
|
41
|
+
expires_at=expires_at or datetime.now(UTC)+timedelta(minutes=3),payload_digest='',proof=None,
|
|
42
|
+
return_fields=tuple(return_fields),source=source)
|
|
43
|
+
request=replace(request,payload_digest=digest(payload_fields(request)))
|
|
44
|
+
if signer is not None:
|
|
45
|
+
request=replace(request,proof=SignatureProof(signature=signer.sign(signing_bytes(request),purpose='request'),
|
|
46
|
+
certificates=tuple(certificates)))
|
|
47
|
+
elif token is not None:
|
|
48
|
+
request=replace(request,proof=TokenProof(credential_id=token[0],token=token[1]))
|
|
49
|
+
return request
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def receipt_bytes(result):
|
|
53
|
+
value=wire(result)
|
|
54
|
+
for field in ('receipt','replayed','prefer_cli','cli_url'):
|
|
55
|
+
value.pop(field,None)
|
|
56
|
+
if result.operation in SECRET_DELIVERY_MIN_VERSION and value.get('data'):
|
|
57
|
+
value['data'].pop('token',None)
|
|
58
|
+
return canonical(value)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Self-contained JSON Schema contracts with no implicit filesystem/network I/O."""
|
|
2
|
+
from jsonschema import Draft202012Validator
|
|
3
|
+
from referencing import Registry as ReferenceRegistry
|
|
4
|
+
from referencing.jsonschema import DRAFT202012
|
|
5
|
+
|
|
6
|
+
from msg.core.errors import require
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def local_validator(schema):
|
|
10
|
+
Draft202012Validator.check_schema(schema)
|
|
11
|
+
pending = [DRAFT202012.create_resource(schema)]
|
|
12
|
+
while pending:
|
|
13
|
+
resource = pending.pop()
|
|
14
|
+
contents = resource.contents
|
|
15
|
+
if isinstance(contents, dict):
|
|
16
|
+
for keyword in ('$ref', '$dynamicRef', '$recursiveRef'):
|
|
17
|
+
if keyword in contents:
|
|
18
|
+
reference = contents[keyword]
|
|
19
|
+
require(isinstance(reference, str) and
|
|
20
|
+
(reference == '' or reference.startswith('#')),
|
|
21
|
+
'remote_schema_reference_forbidden')
|
|
22
|
+
# Follow schema-bearing keywords, not properties named "$ref" or ordinary
|
|
23
|
+
# instance data in default/examples/const. Nested dialects are respected.
|
|
24
|
+
pending.extend(resource.subresources())
|
|
25
|
+
# Fragment pointers may target an annotation containing another schema. An
|
|
26
|
+
# explicit registry also blocks that path: its default retriever performs no
|
|
27
|
+
# I/O, unlike jsonschema's legacy automatic reference retrieval behavior.
|
|
28
|
+
return Draft202012Validator(schema, registry=ReferenceRegistry())
|
msg/core/schemas.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Small explicit JSON schemas; no open-ended executable configuration."""
|
|
2
|
+
STRING={'type':'string'}
|
|
3
|
+
IDENTIFIER={'type':'string','minLength':1,'maxLength':160}
|
|
4
|
+
INTEGER={'type':'integer','minimum':0}
|
|
5
|
+
BOOLEAN={'type':'boolean'}
|
|
6
|
+
BYTES={'type':'string','pattern':'^[A-Za-z0-9_-]*$'}
|
|
7
|
+
REF={'type':'object','properties':{'id':IDENTIFIER,'revision':{'type':['string','null']}},'required':['id'],'additionalProperties':False}
|
|
8
|
+
SCOPE={'type':'object','properties':{'resource_id':IDENTIFIER,'descendants':BOOLEAN},'required':['resource_id'],'additionalProperties':False}
|
|
9
|
+
NETWORK_CONSTRAINTS={'type':'object','properties':{
|
|
10
|
+
'hosts':{'type':'array','items':STRING},'schemes':{'type':'array','items':{'enum':['http','https']}},
|
|
11
|
+
'methods':{'type':'array','items':{'enum':['GET','HEAD','POST','PUT','PATCH','DELETE','OPTIONS']}},
|
|
12
|
+
'ports':{'type':'array','items':{'type':'integer','minimum':1,'maximum':65535}},
|
|
13
|
+
'timeout_ms':{'type':'integer','minimum':1},'max_response_bytes':{'type':'integer','minimum':1},
|
|
14
|
+
'max_redirects':{'type':'integer','minimum':0}},'additionalProperties':False}
|
|
15
|
+
GRANT={'type':'object','properties':{'capability':IDENTIFIER,'version':{'const':1},'scope':SCOPE,
|
|
16
|
+
'operations':{'type':'array','items':IDENTIFIER,'minItems':1,'uniqueItems':True},
|
|
17
|
+
'constraints':{'type':'object'}},'required':['capability','version','scope','operations','constraints'],
|
|
18
|
+
'additionalProperties':False}
|
|
19
|
+
GRANTS={'type':'array','items':GRANT}
|
|
20
|
+
ISSUANCE={'type':['object','null'],'properties':{'issue_grants':GRANTS,'max_cert_ttl_seconds':INTEGER,
|
|
21
|
+
'max_child_ca_depth':INTEGER,'max_delegation_depth':INTEGER},
|
|
22
|
+
'required':['issue_grants','max_cert_ttl_seconds','max_child_ca_depth','max_delegation_depth'],'additionalProperties':False}
|
|
23
|
+
SIGNATURE={'type':'object','properties':{'key_id':IDENTIFIER,'algorithm':{'const':'ed25519'},'value':BYTES},
|
|
24
|
+
'required':['key_id','algorithm','value'],'additionalProperties':False}
|
|
25
|
+
OUTPUT={'type':'object','properties':{'resources':{'type':'array','items':REF},'data':{'type':['object','null']},
|
|
26
|
+
'output':{'anyOf':[REF,{'type':'null'}]}},'required':['resources','data','output'],'additionalProperties':False}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def obj(properties=None,required=()):
|
|
30
|
+
return {'type':'object','properties':properties or {},'required':list(required),'additionalProperties':False}
|
msg/core/tags.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Canonical user tags; labels never participate in authorization."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import unicodedata
|
|
5
|
+
|
|
6
|
+
from msg.core.errors import require
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def normalize_tag(value: str) -> str:
|
|
10
|
+
require(type(value) is str, 'invalid_tag')
|
|
11
|
+
tag = unicodedata.normalize('NFKC', value).strip().casefold()
|
|
12
|
+
tag = unicodedata.normalize('NFC', tag)
|
|
13
|
+
require(1 <= len(tag) <= 48 and len(tag.encode('utf-8')) <= 96, 'invalid_tag')
|
|
14
|
+
require(tag[0].isalnum() and all(char.isalnum() or char in {'-', '_'} for char in tag),
|
|
15
|
+
'invalid_tag')
|
|
16
|
+
return tag
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def normalize_tags(values) -> tuple[str, ...]:
|
|
20
|
+
require(type(values) in {list, tuple} and len(values) <= 16, 'invalid_tags')
|
|
21
|
+
return tuple(sorted({normalize_tag(value) for value in values}))
|
msg/core/template_dsl.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""A non-executable, immutable template DSL."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from msg.core.codec import canonical,freeze_json,loads,decode
|
|
6
|
+
from msg.core.errors import Failure,require
|
|
7
|
+
from msg.core.models import FieldSpec,ResourceRef
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True,slots=True)
|
|
11
|
+
class TemplateDefinition:
|
|
12
|
+
name: str
|
|
13
|
+
version: int
|
|
14
|
+
fields: tuple[FieldSpec,...]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
TemplateSyntaxError=Failure
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _check(spec,value):
|
|
21
|
+
if value is None and not spec.required:
|
|
22
|
+
return None
|
|
23
|
+
if spec.type in {'str','text','enum'}:
|
|
24
|
+
require(type(value) is str,'template_type_error',spec.name)
|
|
25
|
+
elif spec.type=='int':
|
|
26
|
+
require(type(value) is int,'template_type_error',spec.name)
|
|
27
|
+
elif spec.type=='bool':
|
|
28
|
+
require(type(value) is bool,'template_type_error',spec.name)
|
|
29
|
+
elif spec.type in {'ref','file'}:
|
|
30
|
+
if not isinstance(value,str):
|
|
31
|
+
decode(ResourceRef,value)
|
|
32
|
+
else:
|
|
33
|
+
require(bool(re.fullmatch(r'[A-Za-z0-9_.:-]{1,128}',value)),'template_reference_error',spec.name)
|
|
34
|
+
if spec.type=='enum':
|
|
35
|
+
require(value in spec.choices,'template_enum_error',spec.name)
|
|
36
|
+
return freeze_json(value)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def parse_template(source):
|
|
40
|
+
require(type(source) is str,'invalid_template')
|
|
41
|
+
lines=[s.strip() for s in source.splitlines() if s.strip() and not s.lstrip().startswith('#')]
|
|
42
|
+
require(bool(lines),'empty_template')
|
|
43
|
+
header=re.fullmatch(r'([A-Za-z][\w.-]*)@([1-9][0-9]*)',lines[0])
|
|
44
|
+
require(header is not None,'invalid_template_header')
|
|
45
|
+
result=[]
|
|
46
|
+
names=set()
|
|
47
|
+
for line in lines[1:]:
|
|
48
|
+
match=re.fullmatch(r'([A-Za-z][\w.-]*):([a-z]+(?:\([^)]*\))?)([!?]?)(?:=(.*))?',line)
|
|
49
|
+
require(match is not None,'invalid_template_field')
|
|
50
|
+
name,type_name,marker,default=match.groups()
|
|
51
|
+
require(name not in names,'duplicate_field',name)
|
|
52
|
+
names.add(name)
|
|
53
|
+
choices=()
|
|
54
|
+
if type_name.startswith('enum('):
|
|
55
|
+
choices=tuple(x.strip() for x in type_name[5:-1].split(','))
|
|
56
|
+
require(all(choices) and len(choices)==len(set(choices)),'invalid_enum',name)
|
|
57
|
+
type_name='enum'
|
|
58
|
+
require(type_name in {'str','text','int','bool','enum','ref','file'},'unknown_field_type',name)
|
|
59
|
+
require(marker!='!' or default is None,'required_field_has_default',name)
|
|
60
|
+
spec=FieldSpec(name=name,type=type_name,required=marker=='!',choices=choices)
|
|
61
|
+
if default is not None:
|
|
62
|
+
try:
|
|
63
|
+
value=loads(default)
|
|
64
|
+
except Failure:
|
|
65
|
+
require(type_name in {'str','text','enum','ref','file'},'invalid_default',name)
|
|
66
|
+
value=default
|
|
67
|
+
value=_check(spec,value)
|
|
68
|
+
spec=FieldSpec(name=name,type=type_name,required=False,choices=choices,default_json=canonical(value))
|
|
69
|
+
result.append(spec)
|
|
70
|
+
return TemplateDefinition(name=header[1],version=int(header[2]),fields=tuple(result))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def normalize_values(template,values):
|
|
74
|
+
require(isinstance(values,dict) or hasattr(values,'items'),'invalid_template_values')
|
|
75
|
+
names={s.name for s in template.fields}
|
|
76
|
+
require(set(values)<=names,'unknown_template_field')
|
|
77
|
+
result={}
|
|
78
|
+
for spec in template.fields:
|
|
79
|
+
if spec.name in values:
|
|
80
|
+
result[spec.name]=_check(spec,values[spec.name])
|
|
81
|
+
elif spec.default_json is not None:
|
|
82
|
+
result[spec.name]=loads(spec.default_json)
|
|
83
|
+
else:
|
|
84
|
+
require(not spec.required,'missing_required',spec.name)
|
|
85
|
+
return result
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def canonical_values_json(values):
|
|
89
|
+
return canonical(values)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def render_values(values):
|
|
93
|
+
return '\n\n'.join(f'**{name}**\n\n{value if isinstance(value,str) else canonical(value).decode()}'
|
|
94
|
+
for name,value in values.items())+'\n'
|