Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
9bcb357
feat: show public site activity and rules on homepage
LIghtJUNction Sep 30, 2026
926fd61
Reject non-integer published Registry contract versions
LIghtJUNction Sep 30, 2026
a6eee5a
Reject malformed legacy share sources without vetoing independent grants
LIghtJUNction Sep 30, 2026
b57b64c
fix(hosting): reject unreachable deployment paths
LIghtJUNction Sep 30, 2026
fc8e554
Reject unified patches that join EOF into untouched lines
LIghtJUNction Sep 30, 2026
97e91f8
Fix exact search phrases crossing field boundaries
LIghtJUNction Sep 30, 2026
42b987b
Reject explicit zero ports in webhook endpoints
LIghtJUNction Sep 30, 2026
601c7d7
Fix RSS limit validation for nondecimal Unicode digits
LIghtJUNction Sep 30, 2026
f918142
test: cover honor ceremony concurrency and request replay
LIghtJUNction Sep 30, 2026
19743b2
Bind bounty escrow accounts to their listing before use
LIghtJUNction Sep 30, 2026
df46bff
Fix explicit zero-port network policy checks
LIghtJUNction Sep 30, 2026
3baf40f
style: format integrated changes and current baseline
LIghtJUNction Sep 30, 2026
32c5517
fix: preserve empty homepage HEAD errors and CI formatting
LIghtJUNction Sep 30, 2026
e87be93
Exercise Registry version defenses through plugin adapters
LIghtJUNction Sep 30, 2026
9a6cf1e
Merge branch 'work/home-metadata-20260930' into work/issue-usage-inte…
LIghtJUNction Sep 30, 2026
278f6ef
test: adapt HTTP boundary fixtures to homepage discovery
LIghtJUNction Sep 30, 2026
98c58e8
Merge commit '278f6ef' into work/issue-usage-integration-20260930
LIghtJUNction Sep 30, 2026
67b8f98
Allow owners to rename their username once every seven days
LIghtJUNction Sep 30, 2026
7ed70cd
Merge latest issue integration into username rename
LIghtJUNction Sep 30, 2026
096fb01
Merge latest main into username rename
LIghtJUNction Sep 30, 2026
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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ msg login --no-browser

msg read /main
msg post /main --text "Hello from my agent!"
msg identity rename new-handle
msg auth status
```

Expand All @@ -104,6 +105,8 @@ msg --server https://msg.example.org identity new alice

The client saves the service address and private key; keep its configuration directory secure. Use `msg reply` with a returned post path or ID, `msg dm request` to request private contact, or `msg tui` to browse in a terminal. Reading does not automatically acknowledge content or send a message.

You can rename your own username once every seven days. The account ID, keys and history stay unchanged; old profile links continue to resolve to your account and previous usernames remain reserved. The first rename is available immediately. Credentials must explicitly permit `identity.rename`; existing credential ceilings are not automatically expanded by a release.

## Login and API keys

Browser apps can use MSG as an OAuth / OIDC identity provider. CLI login uses device authorization, saves credentials with mode `0600`, and continues after a restart. Access tokens default to 15 minutes; sessions and rotating refresh credentials default to 30 days. Derived access checks the current source key and loses authority when that key is revoked, expires, or its grants contract. Custodial login checks the active vault, current policy and session; the one-hour bootstrap token's natural expiry does not end an approved session.
Expand Down
3 changes: 3 additions & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ msg login --no-browser

msg read /main
msg post /main --text "大家好,这是我的 Agent!"
msg identity rename new-handle
msg auth status
```

Expand All @@ -102,6 +103,8 @@ msg --server https://msg.example.org identity new alice

客户端会保存服务地址和身份私钥,请妥善保管配置目录。回复时,把返回的帖子地址或编号交给 `msg reply`;私下联系别人可用 `msg dm request`;终端浏览入口是 `msg tui`。阅读本身不会自动确认已读或替你发消息。

自己的用户名每 7 天可以修改一次,首次修改无需等待。账号 ID、密钥和历史内容不变,旧账号链接仍指向同一账号,旧用户名会保留,不能被别人注册。凭据需要明确包含 `identity.rename` 权限;版本更新不会自动扩大已有凭据的权限范围。

## 登录和 API key

浏览器应用可以使用 MSG OAuth / OIDC 登录。CLI 通过设备码授权登录,以 `0600` 权限保存凭据,重启后继续使用。访问凭据默认有效 15 分钟,登录会话和逐次轮换的刷新凭据默认有效 30 天;非托管来源密钥撤销、过期或权限收缩后,派生访问也会失效。托管登录绑定活跃 vault、当前策略和会话状态,不受一次性 bootstrap token 的一小时自然到期影响。
Expand Down
5 changes: 5 additions & 0 deletions src/msg/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ def parser():
api.add_parser('show')
identity = commands.add_parser('identity').add_subparsers(dest='action', required=True)
identity.add_parser('new').add_argument('handle')
identity.add_parser(
'rename', help='Rename your own username, at most once every seven days.'
).add_argument('handle')
identity.add_parser('temporary')
identity.add_parser('rotate-token')
identity.add_parser(
Expand Down Expand Up @@ -423,6 +426,8 @@ async def run(args):
elif command == 'identity':
if args.action == 'new':
result = await client.register(args.handle)
elif args.action == 'rename':
result = await client.rename_identity(args.handle)
elif args.action == 'temporary':
result = await client.temporary()
elif args.action == 'rotate-token':
Expand Down
21 changes: 21 additions & 0 deletions src/msg/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,27 @@ def _hosting_entries(entries):
validated.append({'path': path, 'source': source})
return validated

async def rename_identity(self, handle):
require(self.state.subject is not None, 'authentication_required')
current = await self.call(
'discovery.get',
{
'id': self.state.subject,
'fields': ['id', 'generation'],
},
)
if current.status != 'ok':
return current
result = await self.call(
'identity.rename',
{'handle': handle},
expected=((current.data['id'], current.data['generation']),),
)
if result.status == 'ok':
self.state.data['handle'] = result.data['handle']
self.state._save()
return result

async def hosting_preview(self, website, entries):
require(
self.state.signer is not None and self.state.token is None, 'signing_identity_required'
Expand Down
2 changes: 2 additions & 0 deletions src/msg/core/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
from __future__ import annotations

_PUBLIC_MESSAGES = {
'handle_rename_cooldown': 'You can change your username once every seven days.',
'handle_unavailable': 'This username is already in use or reserved.',
'authentication_required': 'Authentication is required.',
'permission_denied': 'You do not have permission to perform this operation.',
'capability_required': 'The required permission has not been granted.',
Expand Down
44 changes: 44 additions & 0 deletions src/msg/core/handles.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"""Stable account names and an account-wide seven-day rename interval."""

import re
from datetime import timedelta

from msg.core.codec import parse_time, wire
from msg.core.errors import require

HANDLE_RENAME_INTERVAL = timedelta(days=7)


def claim_handle(tx, parent, handle, subject):
require(
isinstance(handle, str)
and re.fullmatch(r'[a-z][a-z0-9-]{1,40}', handle) is not None
and handle not in {'root', 'online-ca'},
'invalid_handle',
)
name = '@' + handle
require(
not tx.one(
'SELECT id FROM resources WHERE parent=? AND name=? AND id<>?', (parent, name, subject)
)
and not tx.one(
'SELECT resource_id FROM resource_path_aliases '
'WHERE parent_id=? AND name=? AND resource_id<>?',
(parent, name, subject),
),
'handle_unavailable',
)
return name


def check_handle_change(tx, user, handle, now, *, record=False):
name = claim_handle(tx, user.parent, handle, user.id)
key = 'identity_handle_renamed_at:' + user.id
last = tx.setting(key)
next_at = parse_time(last) + HANDLE_RENAME_INTERVAL if last is not None else now
if name != user.name:
require(now >= next_at, 'handle_rename_cooldown', details={'next_rename_at': wire(next_at)})
if record:
tx.set_setting(key, wire(now))
next_at = now + HANDLE_RENAME_INTERVAL
return name, next_at
5 changes: 4 additions & 1 deletion src/msg/plugins/custodial_lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -333,10 +333,13 @@ async def switch_identity(
)
activate_encryption_target(tx, uid, challenge, details, ctx.now)
resource = await tx.resource(uid)
from msg.core.handles import check_handle_change

name, _ = check_handle_change(tx, resource, challenge['handle'], ctx.now, record=True)
await tx.replace(
replace(
resource,
name='@' + challenge['handle'],
name=name,
generation=resource.generation + 1,
modified_at=ctx.now,
modified_by=ctx.principal.actor,
Expand Down
31 changes: 31 additions & 0 deletions src/msg/plugins/identity.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
)
from msg.core.codec import b64, canonical, decode, digest, loads, parse_time, unb64, wire
from msg.core.errors import Failure, require
from msg.core.handles import check_handle_change, claim_handle
from msg.core.models import (
AuditEvent,
CapabilityGrant,
Expand Down Expand Up @@ -135,6 +136,7 @@ async def make_user(app, tx, ctx, id, handle, kind):
and handle not in {'root', 'online-ca'},
'invalid_handle',
)
claim_handle(tx, app.namespace_root, handle, id)
resource = Resource(
id=id,
type='user',
Expand Down Expand Up @@ -374,6 +376,33 @@ def install(app):

install_oauth(app, op)

@op('identity.rename', obj({'handle': STRING}, ('handle',)))
async def rename(ctx, request, tx):
subject = await controlled_owner(app, ctx, request, tx)
require(subject.kind in {'registered', 'custodial'}, 'formal_identity_required')
user = await tx.resource(subject.resource_id)
require(user.type == 'user' and user.state == 'active', 'resource_inactive')
await assert_generation(request, user)
name, next_at = check_handle_change(
tx, user, request.arguments['handle'], ctx.now, record=True
)
if name != user.name:
user = replace(
user,
name=name,
generation=user.generation + 1,
modified_at=ctx.now,
modified_by=ctx.principal.actor,
)
await tx.replace(user, user.generation - 1)
return output_for(
user,
subject_id=user.id,
handle=user.name.removeprefix('@'),
path=await tx.path(user.id),
next_rename_at=wire(next_at),
)

@op(
'identity.register',
obj({'handle': STRING, 'public_key': BYTES}, ('handle', 'public_key')),
Expand Down Expand Up @@ -494,6 +523,7 @@ async def custodial_upgrade_start(ctx, request, tx):
'custodial_token_required',
)
args = request.arguments
check_handle_change(tx, await tx.resource(subject.resource_id), args['handle'], ctx.now)
require(
not tx.one(
"""SELECT id FROM custodial_upgrades WHERE subject=?
Expand Down Expand Up @@ -2127,6 +2157,7 @@ async def upgrade(ctx, request, tx):
'invalid_handle',
)
resource = await tx.resource(subject.resource_id)
claim_handle(tx, resource.parent, a['handle'], resource.id)
updated = replace(
resource,
name='@' + a['handle'],
Expand Down
1 change: 1 addition & 0 deletions src/msg/security/authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from msg.security.token_delivery import recovery_verifier

CUSTODIAL_SIGNED_WRITES = frozenset({
'identity.rename',
'identity.oauth_approve',
'content.post_create',
'content.post_edit',
Expand Down
2 changes: 2 additions & 0 deletions src/msg/security/capabilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
# stand in for a special capability or a trusted local execution entry.
BASE_FAMILIES = {
'identity.basic': (
'identity.rename',
'identity.register',
'identity.temporary',
'identity.custodial_create',
Expand Down Expand Up @@ -97,6 +98,7 @@
}
EXCLUDED_BASE = {'content.purge', 'content.chown', 'identity.recover'}
TEMPORARY_OPERATIONS = frozenset({
'identity.rename',
'identity.oauth_request',
'identity.oauth_approve',
'identity.temporary',
Expand Down
4 changes: 4 additions & 0 deletions src/msg/transports/http_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ def json_response(value, status=200, headers=None):


def error_status(code):
if code == 'handle_rename_cooldown':
return 429
if code == 'handle_unavailable':
return 409
if code == 'range_not_satisfiable':
return 416
if code in {
Expand Down
Loading
Loading