Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions .github/workflows/subject-view-contracts.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
name: Subject view contracts
on:
push:
branches: [work/remaining-contracts-20260930]
pull_request:
paths:
- src/msg/transports/http_routes.py
- src/msg/transports/subject_views.py
- tests/test_subject_view_effect_order.py
- .github/workflows/subject-view-contracts.yml
workflow_dispatch:
permissions:
contents: read
concurrency:
group: subject-view-contracts-${{ github.head_ref || github.ref_name }}
cancel-in-progress: true
jobs:
contracts:
runs-on: ubuntu-latest
timeout-minutes: 15
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: msgtest
POSTGRES_PASSWORD: msgtest
POSTGRES_DB: postgres
ports: ['5432:5432']
options: >-
--health-cmd "pg_isready -U msgtest"
--health-interval 5s --health-timeout 5s --health-retries 10
env:
MSG_TEST_POSTGRES_URL_TEMPLATE: postgresql://msgtest:msgtest@127.0.0.1:5432/{database}
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-python@v7
with:
python-version: '3.15'
allow-prereleases: true
- name: Install locked contracts environment
run: |
sudo apt-get update -qq
sudo env NEEDRESTART_MODE=l apt-get install -y -qq age
python -m pip install uv==0.12.20
uv sync --locked --extra dev --python "$(command -v python)"
echo "$GITHUB_WORKSPACE/.venv/bin" >> "$GITHUB_PATH"
uv pip check
- name: Record exact source
run: |
mkdir -p artifacts
python - <<'PY'
import json, os, platform, subprocess
from pathlib import Path
git = lambda *args: subprocess.check_output(['git', *args], text=True).strip()
Path('artifacts/source.json').write_text(json.dumps({
'commit': git('rev-parse', 'HEAD'), 'tree': git('rev-parse', 'HEAD^{tree}'),
'run_id': os.environ['GITHUB_RUN_ID'], 'python': platform.python_version(),
}, indent=2) + '\n')
PY
- name: Require subject read boundaries and permanent aliases
run: |
python -m pytest tests/test_subject_view_effect_order.py tests/test_subject_read_aliases.py tests/test_receipt_effect_order.py tests/test_http_effect_order.py tests/test_route_effect_matrix.py tests/test_hosting_read_permissions.py --junitxml=artifacts/subject-views.xml
- uses: actions/upload-artifact@v7.0.1
if: always()
with:
name: subject-view-contracts
path: artifacts/
if-no-files-found: error
97 changes: 29 additions & 68 deletions src/msg/transports/http_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,14 @@
from msg.transports.mcp import PROTOCOL_VERSION, SUPPORTED_VERSIONS, MCPServer
from msg.transports.packet import decode_packet, path_packet, require_url_safe_packet
from msg.transports.read_tree_path import decode_read_tree_path
from msg.transports.subject_views import (
CERTIFICATE_COLLECTION_VIEWS,
SUBJECT_COLLABORATION_VIEWS,
SUBJECT_KEY_ALIASES as SUBJECT_KEY_ALIASES,
SUBJECT_OPERATION_ALIASES as SUBJECT_OPERATION_ALIASES,
SUBJECT_RESOURCE_ALIASES as SUBJECT_RESOURCE_ALIASES,
subject_view_operation,
)
from msg.transports.url_safety import require_matching_host, require_safe_request_target

HOME_LOGO = files('msg.data').joinpath('logo.svg').read_text(encoding='utf-8')
Expand Down Expand Up @@ -70,34 +78,6 @@
'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'; img-src 'self'; base-uri 'none'; "
"form-action 'none'; frame-ancestors 'none'; sandbox",
}
SUBJECT_RESOURCE_ALIASES = {
'cert': 'certificates',
'certificates': 'certificates',
'ks': 'keystore',
'keystore': 'keystore',
'ssh': 'keys',
'ssh-keys': 'keys',
}
SUBJECT_KEY_ALIASES = {
'pk': ('identity.identity_key_get', 'pk'),
'pubkey': ('identity.identity_key_get', 'pk'),
'k': ('identity.identity_key_list', 'k'),
'keys': ('identity.identity_key_list', 'k'),
'ek': ('identity.encryption_key_get', 'ek'),
'encryption-key': ('identity.encryption_key_get', 'ek'),
'e': ('identity.encryption_key_list', 'e'),
'encryption-keys': ('identity.encryption_key_list', 'e'),
}
SUBJECT_OPERATION_ALIASES = {
'ach': 'achievement.list',
'achievements': 'achievement.list',
'in': 'communication.inbox',
'inbox': 'communication.inbox',
'out': 'communication.outbox',
'outbox': 'communication.outbox',
'dm': 'communication.dm_list',
'following': 'communication.following',
}
SEARCH_V2_SEGMENTS = {
'scope': 's',
'terms': 't',
Expand Down Expand Up @@ -218,6 +198,11 @@ def classify_route(path, method, registry):
return operation_route(registry.operation('git.lfs_read_batch'))
if re.fullmatch(r'/[@&][^/]+/[^/]+\.git/(?:info/refs|git-upload-pack|HEAD)', path):
return operation_route(registry.operation('git.refs'))
if path.startswith(('/@', '/&')):
operation = subject_view_operation(path)
if operation is None:
operation = 'discovery.raw' if parse_view(path)[1] == 'raw' else 'discovery.get'
return operation_route(registry.operation(operation))
return RouteSpec('read', RouteEffect.PURE_READ)


Expand Down Expand Up @@ -732,13 +717,12 @@ async def dispatch(request: Request):
and 'x-method-override' not in request.headers,
'method_not_allowed',
)
receipt_alias = re.fullmatch(r'/@[^/]+/receipts(?:/(.*))?', request.url.path)
if receipt_alias and request.method in {'GET', 'HEAD'}:
# Hosting probes this namespace before the ordinary router. Reject
# changed receipt effects before that probe can query a subject.
tail = (receipt_alias.group(1) or '').strip('/')
operation = 'communication.receipt_' + ('list' if tail in {'', 'json'} else 'get')
require(service.registry.operation(operation).effect == 'read', 'effect_mismatch')
subject_route = None
if request.url.path.startswith(('/@', '/&')) and request.method in {'GET', 'HEAD'}:
# Hosting probes this namespace before the ordinary router.
# Fence the selected operation before any business query.
subject_route = classify_route(request.url.path, request.method, service.registry)
require(subject_route.effect == RouteEffect.PURE_READ, 'effect_mismatch')
if request.url.path.startswith(('/@', '/&')):
from msg.extensions.hosting import serve_hosted

Expand Down Expand Up @@ -1927,15 +1911,7 @@ async def dispatch(request: Request):
),
'not_found',
)
operation = (
'orders.list'
if listing
else 'orders.payment'
if len(parts) == 2 and parts[1] == '_payment'
else 'delivery.get'
if len(parts) == 2
else 'orders.get'
)
operation = subject_route.name
pairs = request.query_params.multi_items()
require(
len(pairs) == len({key for key, _ in pairs}), 'duplicate_query_parameter'
Expand Down Expand Up @@ -1988,7 +1964,7 @@ async def dispatch(request: Request):
if name in {'bal', 'balance', 'ledger'}:
require(remainder in {None, '/', '/json'}, 'not_found')
require(b'%' not in raw_path, 'not_found')
operation = 'money.ledger' if name == 'ledger' else 'money.balance'
operation = subject_route.name
pairs = request.query_params.multi_items()
require(
len(pairs) == len({key for key, _ in pairs}), 'duplicate_query_parameter'
Expand Down Expand Up @@ -2036,15 +2012,7 @@ async def dispatch(request: Request):
media_type='application/json',
headers=headers,
)
if name in {
'handoffs',
'leases',
'requests',
'offers',
'checkpoints',
'proposals',
'watches',
}:
if name in SUBJECT_COLLABORATION_VIEWS:
require(request.method in {'GET', 'HEAD'}, 'method_not_allowed')
require(
raw_path.decode('utf-8') == request.url.path and b'%' not in raw_path,
Expand All @@ -2057,8 +2025,7 @@ async def dispatch(request: Request):
re.fullmatch(r'[A-Za-z0-9_.:-]{1,160}(?:/json)?', tail) is not None,
'not_found',
)
singular = 'watch' if name == 'watches' else name[:-1]
operation = 'communication.' + singular + ('_list' if listing else '_get')
operation = subject_route.name
pairs = request.query_params.multi_items()
require(
len(pairs) == len({key for key, _ in pairs}), 'duplicate_query_parameter'
Expand Down Expand Up @@ -2122,7 +2089,7 @@ async def dispatch(request: Request):
re.fullmatch(r'[A-Za-z0-9_.:-]{1,128}(?:/json)?', tail) is not None,
'not_found',
)
operation = 'communication.receipt_' + ('list' if listing else 'get')
operation = subject_route.name
pairs = request.query_params.multi_items()
require(
len(pairs) == len({key for key, _ in pairs}), 'duplicate_query_parameter'
Expand Down Expand Up @@ -2180,13 +2147,10 @@ async def dispatch(request: Request):
media_type='application/json',
headers=headers,
)
certificate_detail = name in {'cert', 'certificates'} and remainder not in {
None,
'/',
'/json',
'/meta',
'/history',
}
certificate_detail = (
name in {'cert', 'certificates'}
and remainder not in CERTIFICATE_COLLECTION_VIEWS
)
if (
name in SUBJECT_KEY_ALIASES
or name in SUBJECT_OPERATION_ALIASES
Expand All @@ -2204,16 +2168,15 @@ async def dispatch(request: Request):
async with service.metadata.transaction(write=False) as tx:
subject_id = await tx.resolve('/@' + handle)
require((await tx.resource(subject_id)).type == 'user', 'not_found')
operation = subject_route.name
if certificate_detail:
tail = remainder.strip('/')
require(
re.fullmatch(r'[A-Za-z0-9_.:-]{1,160}(?:/json)?', tail) is not None,
'not_found',
)
operation = 'cert.get'
args = {'id': tail.removesuffix('/json')}
elif key_alias:
operation, _canonical = SUBJECT_KEY_ALIASES[name]
tail = (remainder or '').strip('/')
if tail == 'json':
tail = ''
Expand All @@ -2223,12 +2186,10 @@ async def dispatch(request: Request):
'not_found',
)
key_id = tail.removesuffix('/json')
operation = operation.replace('_list', '_get')
args = {'subject_id': subject_id}
if tail:
args['key_id'] = key_id
else:
operation = SUBJECT_OPERATION_ALIASES[name]
args = {'subject_id': subject_id} if operation == 'achievement.list' else {}
if operation in {
'communication.inbox',
Expand Down
76 changes: 76 additions & 0 deletions src/msg/transports/subject_views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
"""Pure operation selection for published subject views and permanent aliases."""

import re

SUBJECT_RESOURCE_ALIASES = {
'cert': 'certificates',
'certificates': 'certificates',
'ks': 'keystore',
'keystore': 'keystore',
'ssh': 'keys',
'ssh-keys': 'keys',
}
SUBJECT_KEY_ALIASES = {
'pk': ('identity.identity_key_get', 'pk'),
'pubkey': ('identity.identity_key_get', 'pk'),
'k': ('identity.identity_key_list', 'k'),
'keys': ('identity.identity_key_list', 'k'),
'ek': ('identity.encryption_key_get', 'ek'),
'encryption-key': ('identity.encryption_key_get', 'ek'),
'e': ('identity.encryption_key_list', 'e'),
'encryption-keys': ('identity.encryption_key_list', 'e'),
}
SUBJECT_OPERATION_ALIASES = {
'ach': 'achievement.list',
'achievements': 'achievement.list',
'in': 'communication.inbox',
'inbox': 'communication.inbox',
'out': 'communication.outbox',
'outbox': 'communication.outbox',
'dm': 'communication.dm_list',
'following': 'communication.following',
}
SUBJECT_COLLABORATION_VIEWS = frozenset({
'handoffs',
'leases',
'requests',
'offers',
'checkpoints',
'proposals',
'watches',
})
CERTIFICATE_COLLECTION_VIEWS = frozenset({None, '/', '/json', '/meta', '/history'})


def subject_view_operation(path):
"""Select metadata without resolving a subject, resource or hosting site.

The router validates path spelling and arguments after the effect fence.
Both the fence and execution use this same selection.
"""
match = re.fullmatch(r'/@([^/]+)/([^/]+)(/.*)?', path)
if match is None:
return None
_, name, remainder = match.groups()
tail = (remainder or '').strip('/')
if name == 'orders':
tail = tail.removesuffix('/json')
if tail in {'', 'json'}:
return 'orders.list'
parts = tail.split('/')
if len(parts) == 2:
return 'orders.payment' if parts[1] == '_payment' else 'delivery.get'
return 'orders.get'
if name in {'bal', 'balance', 'ledger'}:
return 'money.ledger' if name == 'ledger' else 'money.balance'
if name in SUBJECT_COLLABORATION_VIEWS:
singular = 'watch' if name == 'watches' else name[:-1]
return 'communication.' + singular + ('_list' if tail in {'', 'json'} else '_get')
if name == 'receipts':
return 'communication.receipt_' + ('list' if tail in {'', 'json'} else 'get')
if name in {'cert', 'certificates'} and remainder not in CERTIFICATE_COLLECTION_VIEWS:
return 'cert.get'
if name in SUBJECT_KEY_ALIASES:
operation, _ = SUBJECT_KEY_ALIASES[name]
return operation.replace('_list', '_get') if tail not in {'', 'json'} else operation
return SUBJECT_OPERATION_ALIASES.get(name)
Loading
Loading