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.
Files changed (176) hide show
  1. msg/__init__.py +2 -0
  2. msg/admin/__init__.py +0 -0
  3. msg/admin/backup_retirement.py +56 -0
  4. msg/admin/backups.py +341 -0
  5. msg/admin/custodial_check.py +77 -0
  6. msg/admin/diagnostics.py +750 -0
  7. msg/admin/market.py +91 -0
  8. msg/admin/market_check.py +341 -0
  9. msg/admin/money.py +322 -0
  10. msg/admin/preflight.py +142 -0
  11. msg/admin/recovery_replay.py +281 -0
  12. msg/admin/restore_database.py +37 -0
  13. msg/admin/root.py +347 -0
  14. msg/admin/rotation.py +197 -0
  15. msg/admin/token_delivery_check.py +54 -0
  16. msg/admin/upgrade_check.py +58 -0
  17. msg/application.py +163 -0
  18. msg/bootstrap.py +305 -0
  19. msg/cli.py +537 -0
  20. msg/client.py +725 -0
  21. msg/client_certificates.py +98 -0
  22. msg/client_content.py +43 -0
  23. msg/client_custodial.py +206 -0
  24. msg/client_market.py +104 -0
  25. msg/client_recovery.py +186 -0
  26. msg/client_secrets.py +58 -0
  27. msg/client_tokens.py +99 -0
  28. msg/client_upgrade.py +188 -0
  29. msg/config.py +318 -0
  30. msg/constants.py +11 -0
  31. msg/core/__init__.py +0 -0
  32. msg/core/batching.py +23 -0
  33. msg/core/codec.py +253 -0
  34. msg/core/contracts.py +128 -0
  35. msg/core/cursors.py +68 -0
  36. msg/core/email_address.py +33 -0
  37. msg/core/errors.py +22 -0
  38. msg/core/events.py +6 -0
  39. msg/core/execution_ports.py +20 -0
  40. msg/core/executor.py +193 -0
  41. msg/core/models.py +517 -0
  42. msg/core/packet.py +71 -0
  43. msg/core/permissions.py +14 -0
  44. msg/core/query.py +44 -0
  45. msg/core/read_query.py +60 -0
  46. msg/core/registry.py +173 -0
  47. msg/core/requests.py +58 -0
  48. msg/core/schema_policy.py +28 -0
  49. msg/core/schemas.py +30 -0
  50. msg/core/tags.py +21 -0
  51. msg/core/template_dsl.py +94 -0
  52. msg/core/text_patch.py +283 -0
  53. msg/core/tool_execution.py +28 -0
  54. msg/core/transfer.py +183 -0
  55. msg/daemon.py +255 -0
  56. msg/data/__init__.py +0 -0
  57. msg/data/bootstrap.json +72 -0
  58. msg/data/favicon.png +0 -0
  59. msg/data/logo-dark.svg +5 -0
  60. msg/data/logo.svg +5 -0
  61. msg/data/recovery-checkpoint.example.json +1 -0
  62. msg/data/recovery-checkpoint.schema.json +118 -0
  63. msg/data/shortcodes.json +1 -0
  64. msg/data/system/AGENTS.md +6 -0
  65. msg/data/system/rules/_index.md +17 -0
  66. msg/data/system/rules/auth.md +6 -0
  67. msg/data/system/rules/files.md +6 -0
  68. msg/data/system/rules/identity.md +6 -0
  69. msg/data/system/rules/protocol.md +6 -0
  70. msg/data/system/rules/read-write.md +6 -0
  71. msg/data/system/rules/recovery.md +6 -0
  72. msg/data/system/rules/security.md +10 -0
  73. msg/data/system/rules/topics.md +6 -0
  74. msg/extensions/__init__.py +1 -0
  75. msg/extensions/hosting.py +259 -0
  76. msg/extensions/keystore.py +96 -0
  77. msg/extensions/repositories.py +769 -0
  78. msg/extensions/rss.py +67 -0
  79. msg/extensions/ssh.py +264 -0
  80. msg/extensions/ssh_git.py +300 -0
  81. msg/extensions/tools.py +150 -0
  82. msg/hosting_runtime.py +138 -0
  83. msg/market/__init__.py +1 -0
  84. msg/market/arbitration.py +481 -0
  85. msg/market/delivery.py +338 -0
  86. msg/market/delivery_notifications.py +110 -0
  87. msg/market/delivery_targets.py +76 -0
  88. msg/market/email.py +100 -0
  89. msg/market/escrow.py +345 -0
  90. msg/market/orders.py +218 -0
  91. msg/market/policy.py +147 -0
  92. msg/market/rationale.py +91 -0
  93. msg/market/references.py +19 -0
  94. msg/market/targets.py +157 -0
  95. msg/plugins/__init__.py +28 -0
  96. msg/plugins/achievements.py +316 -0
  97. msg/plugins/batch.py +32 -0
  98. msg/plugins/bounty.py +366 -0
  99. msg/plugins/collaboration.py +261 -0
  100. msg/plugins/common.py +217 -0
  101. msg/plugins/communication.py +853 -0
  102. msg/plugins/content.py +855 -0
  103. msg/plugins/custodial_lifecycle.py +224 -0
  104. msg/plugins/delivery.py +230 -0
  105. msg/plugins/discovery.py +1266 -0
  106. msg/plugins/discussion.py +162 -0
  107. msg/plugins/extensions.py +10 -0
  108. msg/plugins/following.py +78 -0
  109. msg/plugins/hosting_capacity.py +52 -0
  110. msg/plugins/identity.py +1654 -0
  111. msg/plugins/money.py +221 -0
  112. msg/plugins/offers.py +183 -0
  113. msg/plugins/orders.py +273 -0
  114. msg/plugins/recovery.py +376 -0
  115. msg/plugins/schemas.py +5 -0
  116. msg/plugins/sharing.py +300 -0
  117. msg/plugins/store.py +249 -0
  118. msg/plugins/system.py +75 -0
  119. msg/plugins/transfer.py +249 -0
  120. msg/py.typed +0 -0
  121. msg/security/__init__.py +0 -0
  122. msg/security/age_keys.py +112 -0
  123. msg/security/authentication.py +141 -0
  124. msg/security/authorization.py +300 -0
  125. msg/security/backup_retirement.py +135 -0
  126. msg/security/capabilities.py +157 -0
  127. msg/security/certificates.py +183 -0
  128. msg/security/crypto.py +102 -0
  129. msg/security/custodial_migration.py +292 -0
  130. msg/security/custody_history.py +146 -0
  131. msg/security/network.py +81 -0
  132. msg/security/policy.py +67 -0
  133. msg/security/quarantine.py +19 -0
  134. msg/security/root_files.py +67 -0
  135. msg/security/rotation_journal.py +60 -0
  136. msg/security/sealed_box.py +70 -0
  137. msg/security/sharing_policy.py +29 -0
  138. msg/security/token_delivery.py +93 -0
  139. msg/security/vault.py +186 -0
  140. msg/storage/__init__.py +0 -0
  141. msg/storage/capacity.py +100 -0
  142. msg/storage/custodial_migration.py +22 -0
  143. msg/storage/git.py +435 -0
  144. msg/storage/ledger_migration.py +307 -0
  145. msg/storage/market_migration.py +49 -0
  146. msg/storage/postgres.py +663 -0
  147. msg/storage/query.py +39 -0
  148. msg/storage/read_only.py +126 -0
  149. msg/storage/session.py +403 -0
  150. msg/storage/sqlite.py +360 -0
  151. msg/storage/topic_event_migration.py +26 -0
  152. msg/storage/valkey_bus.py +45 -0
  153. msg/transports/__init__.py +0 -0
  154. msg/transports/client.py +183 -0
  155. msg/transports/dictionary.py +387 -0
  156. msg/transports/graphql.py +75 -0
  157. msg/transports/http.py +72 -0
  158. msg/transports/http_routes.py +1577 -0
  159. msg/transports/mcp.py +127 -0
  160. msg/transports/packet.py +42 -0
  161. msg/transports/read_tree_path.py +65 -0
  162. msg/transports/stdio.py +36 -0
  163. msg/transports/url_safety.py +123 -0
  164. msg/tui.py +368 -0
  165. msg/workers/__init__.py +1 -0
  166. msg/workers/effects.py +406 -0
  167. msg/workers/leases.py +19 -0
  168. msg/workers/mail.py +63 -0
  169. msg/workers/maintenance.py +310 -0
  170. msg/workers/sandbox.py +74 -0
  171. msg/workers/sandbox_child.py +189 -0
  172. msg/workers/webhook.py +160 -0
  173. msgctl-0.1.0a1.dist-info/METADATA +96 -0
  174. msgctl-0.1.0a1.dist-info/RECORD +176 -0
  175. msgctl-0.1.0a1.dist-info/WHEEL +4 -0
  176. 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}))
@@ -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'