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/plugins/bounty.py
ADDED
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
"""Prefunded bounties with one-use identity-key proof of possession.
|
|
2
|
+
|
|
3
|
+
All account movements run inside the executor's PostgreSQL write transaction.
|
|
4
|
+
An escrow LedgerAccount is not a Subject and has no credential or public transfer operation.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
from collections.abc import Mapping
|
|
10
|
+
from datetime import timedelta
|
|
11
|
+
|
|
12
|
+
from msg.core.codec import b64, canonical, decode, digest, loads, parse_time, unb64, wire
|
|
13
|
+
from msg.core.errors import require
|
|
14
|
+
from msg.core.models import HandlerOutput, ResourceRef, Signature
|
|
15
|
+
from msg.plugins.common import (check_access, create_resource, new_id, operation_id,
|
|
16
|
+
registration)
|
|
17
|
+
from msg.plugins.money import account_requirements, CURRENCY_ID, MAX_MINOR, _balance, _post_transfer
|
|
18
|
+
from msg.plugins.schemas import IDENTIFIER, SIGNATURE, obj
|
|
19
|
+
from msg.security.crypto import verify
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
VERIFIER_ID = 'signature_pop_v1'
|
|
23
|
+
VERIFIER_VERSION = 1
|
|
24
|
+
CHALLENGE_TTL = 300
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _subject(ctx):
|
|
28
|
+
require(ctx.principal.subject is not None and
|
|
29
|
+
ctx.principal.actor == ctx.principal.subject and
|
|
30
|
+
ctx.principal.method == 'signature', 'signature_required')
|
|
31
|
+
return ctx.principal.subject
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _eligibility(value, subject):
|
|
35
|
+
if value == {'kind': 'any'}:
|
|
36
|
+
return True
|
|
37
|
+
# Unknown constraints would be published but never enforced; reject them.
|
|
38
|
+
require(isinstance(value, Mapping) and set(value) == {'kind', 'subjects'} and
|
|
39
|
+
value.get('kind') == 'allowlist' and
|
|
40
|
+
isinstance(value.get('subjects'), (list, tuple)) and
|
|
41
|
+
len(value['subjects']) <= 1000 and
|
|
42
|
+
all(isinstance(s, str) for s in value['subjects']),
|
|
43
|
+
'invalid_bounty_eligibility')
|
|
44
|
+
return subject in value['subjects']
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _row(tx, listing_id):
|
|
48
|
+
row = tx.one('''SELECT listing_id,publisher,escrow_subject,reward_minor,budget_minor,
|
|
49
|
+
max_claims,claim_limit_per_subject,verifier_id,verifier_version,eligibility,
|
|
50
|
+
state,pause_reason,expires_at,created_at FROM bounty_listings WHERE listing_id=?''',
|
|
51
|
+
(listing_id,))
|
|
52
|
+
require(row is not None, 'bounty_not_found')
|
|
53
|
+
return dict(zip(('listing_id', 'publisher', 'escrow_subject', 'reward_minor',
|
|
54
|
+
'budget_minor', 'max_claims', 'claim_limit_per_subject',
|
|
55
|
+
'verifier_id', 'verifier_version', 'eligibility', 'state',
|
|
56
|
+
'pause_reason', 'expires_at', 'created_at'),
|
|
57
|
+
(*row[:9], loads(row[9]), *row[10:])))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _public(row, balance, claims, now=None):
|
|
61
|
+
# Derive read projections without writing lifecycle state during GET.
|
|
62
|
+
row = dict(row)
|
|
63
|
+
if row['state'] != 'closed':
|
|
64
|
+
if now is not None and row['expires_at'] is not None and parse_time(row['expires_at']) <= now:
|
|
65
|
+
row.update(state='paused', pause_reason='expired')
|
|
66
|
+
elif balance < row['reward_minor']:
|
|
67
|
+
row.update(state='paused', pause_reason='out_of_budget')
|
|
68
|
+
elif claims >= row['max_claims']:
|
|
69
|
+
row.update(state='paused', pause_reason='claims_exhausted')
|
|
70
|
+
return {key: row[key] for key in ('listing_id', 'publisher', 'reward_minor',
|
|
71
|
+
'budget_minor', 'max_claims', 'claim_limit_per_subject', 'verifier_id',
|
|
72
|
+
'verifier_version', 'eligibility', 'state', 'pause_reason', 'expires_at',
|
|
73
|
+
'created_at')} | {'escrow_balance_minor': balance, 'paid_claims': claims}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _claim_count(tx, listing_id, claimant=None):
|
|
77
|
+
if claimant is None:
|
|
78
|
+
return int(tx.one("SELECT COUNT(*) FROM bounty_claims WHERE listing_id=? AND status='paid'",
|
|
79
|
+
(listing_id,))[0])
|
|
80
|
+
return int(tx.one("SELECT COUNT(*) FROM bounty_claims WHERE listing_id=? AND claimant=? AND status='paid'",
|
|
81
|
+
(listing_id, claimant))[0])
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def projection(tx, listing_id, now):
|
|
85
|
+
row = _row(tx, listing_id)
|
|
86
|
+
return _public(row, _balance(tx,row['escrow_subject']), _claim_count(tx,listing_id), now)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _ready(tx, row, now, claimant):
|
|
90
|
+
require(row['state'] == 'active', 'bounty_not_active')
|
|
91
|
+
require(row['expires_at'] is None or parse_time(row['expires_at']) > now,
|
|
92
|
+
'bounty_expired')
|
|
93
|
+
require(row['verifier_id'] == VERIFIER_ID and
|
|
94
|
+
row['verifier_version'] == VERIFIER_VERSION, 'bounty_verifier_unsupported')
|
|
95
|
+
require(_eligibility(row['eligibility'], claimant), 'bounty_ineligible')
|
|
96
|
+
require(_claim_count(tx, row['listing_id']) < row['max_claims'], 'bounty_claims_exhausted')
|
|
97
|
+
require(_claim_count(tx, row['listing_id'], claimant) < row['claim_limit_per_subject'],
|
|
98
|
+
'bounty_subject_limit')
|
|
99
|
+
require(_balance(tx, row['escrow_subject']) >= row['reward_minor'],
|
|
100
|
+
'bounty_out_of_budget')
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def install(app):
|
|
104
|
+
op, finish = registration(app, 'bounty', ('store', 'money'))
|
|
105
|
+
amount = {'type': 'integer', 'minimum': 1, 'maximum': MAX_MINOR}
|
|
106
|
+
|
|
107
|
+
@op('bounty.create', obj({
|
|
108
|
+
'name': {'type': 'string', 'minLength': 1, 'maxLength': 120},
|
|
109
|
+
'terms': {'type': 'string', 'minLength': 1, 'maxLength': 16384},
|
|
110
|
+
'reward_minor': amount, 'budget_minor': amount,
|
|
111
|
+
'max_claims': {'type': 'integer', 'minimum': 1, 'maximum': 1000000},
|
|
112
|
+
'claim_limit_per_subject': {'type': 'integer', 'minimum': 1, 'maximum': 1000},
|
|
113
|
+
'eligibility': {'type': 'object'},
|
|
114
|
+
'expires_at': {'type': ['string', 'null']},
|
|
115
|
+
'currency_id': {'const': CURRENCY_ID},
|
|
116
|
+
'verifier_id': {'const': VERIFIER_ID},
|
|
117
|
+
'verifier_version': {'const': VERIFIER_VERSION},
|
|
118
|
+
}, ('name', 'terms', 'reward_minor', 'budget_minor', 'max_claims')),
|
|
119
|
+
signature=True, requirements=account_requirements)
|
|
120
|
+
async def create(ctx, request, tx):
|
|
121
|
+
publisher = _subject(ctx)
|
|
122
|
+
await app.authorizer.require_base(ctx.principal, operation_id(request), publisher, tx)
|
|
123
|
+
await check_access(app, ctx, request, tx, 't_store', 'create')
|
|
124
|
+
args = request.arguments
|
|
125
|
+
require(args.get('currency_id', CURRENCY_ID) == CURRENCY_ID,
|
|
126
|
+
'unsupported_currency')
|
|
127
|
+
require(args.get('verifier_id', VERIFIER_ID) == VERIFIER_ID and
|
|
128
|
+
args.get('verifier_version', VERIFIER_VERSION) == VERIFIER_VERSION,
|
|
129
|
+
'bounty_verifier_unsupported')
|
|
130
|
+
eligibility = args.get('eligibility', {'kind': 'any'})
|
|
131
|
+
_eligibility(eligibility, publisher)
|
|
132
|
+
expires_at = args.get('expires_at')
|
|
133
|
+
if expires_at is not None:
|
|
134
|
+
require(parse_time(expires_at) > ctx.now, 'bounty_expired')
|
|
135
|
+
reward, budget = args['reward_minor'], args['budget_minor']
|
|
136
|
+
require(budget <= MAX_MINOR and reward <= MAX_MINOR, 'invalid_money_amount')
|
|
137
|
+
require(_balance(tx, publisher) >= budget, 'insufficient_funds')
|
|
138
|
+
owner = await tx.subject(publisher)
|
|
139
|
+
require(owner.kind in {'registered', 'custodial'} and not owner.local_only,
|
|
140
|
+
'money_subject_required')
|
|
141
|
+
listing_id = new_id('bty')
|
|
142
|
+
escrow = new_id('esc')
|
|
143
|
+
# The prefunded escrow is an account, not a signable Subject.
|
|
144
|
+
tx.execute('''INSERT INTO ledger_accounts(id,kind,subject_id,source_id)
|
|
145
|
+
VALUES (?,'bounty_escrow',NULL,?)''', (escrow,listing_id), write=True)
|
|
146
|
+
body = {'mode': 'bounty', 'terms': args['terms'], 'reward_minor': reward,
|
|
147
|
+
'currency_id': CURRENCY_ID, 'budget_minor': budget,
|
|
148
|
+
'max_claims': args['max_claims'],
|
|
149
|
+
'claim_limit_per_subject': args.get('claim_limit_per_subject', 1),
|
|
150
|
+
'verifier_id': VERIFIER_ID, 'verifier_version': VERIFIER_VERSION,
|
|
151
|
+
'eligibility': eligibility, 'expires_at': expires_at,
|
|
152
|
+
'state': 'active' if budget >= reward else 'paused'}
|
|
153
|
+
resource = await create_resource(app, ctx, request, tx, parent='t_store',
|
|
154
|
+
type='listing', name=args['name'], resource_id=listing_id,
|
|
155
|
+
body=canonical(body), media_type='application/json', mode=0o644)
|
|
156
|
+
state = body['state']
|
|
157
|
+
tx.execute('''INSERT INTO bounty_listings
|
|
158
|
+
(listing_id,publisher,escrow_subject,reward_minor,budget_minor,max_claims,
|
|
159
|
+
claim_limit_per_subject,verifier_id,verifier_version,eligibility,state,
|
|
160
|
+
pause_reason,expires_at,created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)''',
|
|
161
|
+
(listing_id, publisher, escrow, reward, budget, args['max_claims'],
|
|
162
|
+
body['claim_limit_per_subject'], VERIFIER_ID, VERIFIER_VERSION,
|
|
163
|
+
canonical(eligibility).decode(), state,
|
|
164
|
+
'out_of_budget' if state == 'paused' else None,
|
|
165
|
+
expires_at, wire(ctx.now)), write=True)
|
|
166
|
+
funding = _post_transfer(tx, sender=publisher, recipient=escrow,
|
|
167
|
+
amount=budget, actor=publisher, request_id=request.request_id,
|
|
168
|
+
now=ctx.now, receipt_signer=app.receipt_signer,
|
|
169
|
+
reference='bounty_fund:' + listing_id)
|
|
170
|
+
return HandlerOutput(resources=(ResourceRef(id=listing_id, revision=resource.revision),),
|
|
171
|
+
data={'bounty': _public(_row(tx, listing_id), budget, 0),
|
|
172
|
+
'funding': funding})
|
|
173
|
+
|
|
174
|
+
@op('bounty.get', obj({'listing_id': IDENTIFIER}, ('listing_id',)), effect='read')
|
|
175
|
+
async def get(ctx, request, tx):
|
|
176
|
+
row = _row(tx, request.arguments['listing_id'])
|
|
177
|
+
await check_access(app, ctx, request, tx, row['listing_id'], 'read')
|
|
178
|
+
return HandlerOutput(data={'bounty': projection(tx,row['listing_id'],ctx.now)})
|
|
179
|
+
|
|
180
|
+
@op('bounty.top_up', obj({'listing_id': IDENTIFIER, 'amount_minor': amount},
|
|
181
|
+
('listing_id', 'amount_minor')), signature=True, requirements=account_requirements)
|
|
182
|
+
async def top_up(ctx, request, tx):
|
|
183
|
+
publisher = _subject(ctx)
|
|
184
|
+
row = _row(tx, request.arguments['listing_id'])
|
|
185
|
+
require(row['publisher'] == publisher, 'bounty_not_found')
|
|
186
|
+
await check_access(app, ctx, request, tx, row['listing_id'], 'write')
|
|
187
|
+
require(row['state'] != 'closed', 'bounty_closed')
|
|
188
|
+
require(row['expires_at'] is None or parse_time(row['expires_at']) > ctx.now,
|
|
189
|
+
'bounty_expired')
|
|
190
|
+
amount = request.arguments['amount_minor']
|
|
191
|
+
require(row['budget_minor'] + amount <= MAX_MINOR, 'money_overflow')
|
|
192
|
+
receipt = _post_transfer(tx, sender=publisher, recipient=row['escrow_subject'],
|
|
193
|
+
amount=amount, actor=publisher, request_id=request.request_id,
|
|
194
|
+
now=ctx.now, receipt_signer=app.receipt_signer,
|
|
195
|
+
reference='bounty_top_up:' + row['listing_id'])
|
|
196
|
+
balance = _balance(tx, row['escrow_subject'])
|
|
197
|
+
state = ('active' if (row['state'] == 'paused' and
|
|
198
|
+
row['pause_reason'] == 'out_of_budget' and
|
|
199
|
+
balance >= row['reward_minor'] and
|
|
200
|
+
_claim_count(tx, row['listing_id']) < row['max_claims']) else row['state'])
|
|
201
|
+
tx.execute('''UPDATE bounty_listings SET budget_minor=?,state=?,pause_reason=?
|
|
202
|
+
WHERE listing_id=?''', (row['budget_minor'] + amount, state,
|
|
203
|
+
None if state == 'active' else row['pause_reason'], row['listing_id']),
|
|
204
|
+
write=True)
|
|
205
|
+
return HandlerOutput(data={'bounty': _public(_row(tx, row['listing_id']),
|
|
206
|
+
balance, _claim_count(tx, row['listing_id'])), 'funding': receipt})
|
|
207
|
+
|
|
208
|
+
@op('bounty.challenge', obj({'listing_id': IDENTIFIER}, ('listing_id',)),
|
|
209
|
+
signature=True, requirements=account_requirements)
|
|
210
|
+
async def challenge(ctx, request, tx):
|
|
211
|
+
claimant = _subject(ctx)
|
|
212
|
+
row = _row(tx, request.arguments['listing_id'])
|
|
213
|
+
await check_access(app, ctx, request, tx, row['listing_id'], 'read')
|
|
214
|
+
_ready(tx, row, ctx.now, claimant)
|
|
215
|
+
key = tx.one('''SELECT key_id FROM identity_keys WHERE subject=? AND
|
|
216
|
+
is_primary=1 AND retired_at IS NULL''', (claimant,))
|
|
217
|
+
require(key is not None and key[0] == ctx.principal.credential_id,
|
|
218
|
+
'current_identity_key_required')
|
|
219
|
+
payload = {'challenge_id': new_id('bc'), 'listing_id': row['listing_id'],
|
|
220
|
+
'claimant_subject_id': claimant, 'nonce': b64(os.urandom(24)),
|
|
221
|
+
'issued_at': wire(ctx.now),
|
|
222
|
+
'expires_at': wire(ctx.now + timedelta(seconds=CHALLENGE_TTL)),
|
|
223
|
+
'verifier_version': VERIFIER_VERSION}
|
|
224
|
+
tx.execute('''INSERT INTO bounty_challenges
|
|
225
|
+
(id,listing_id,claimant,key_id,nonce,issued_at,expires_at,
|
|
226
|
+
verifier_version,payload,consumed_at) VALUES (?,?,?,?,?,?,?,?,?,NULL)''',
|
|
227
|
+
(payload['challenge_id'], row['listing_id'], claimant, key[0],
|
|
228
|
+
payload['nonce'], payload['issued_at'], payload['expires_at'],
|
|
229
|
+
VERIFIER_VERSION, canonical(payload).decode()), write=True)
|
|
230
|
+
return HandlerOutput(data={'challenge': payload})
|
|
231
|
+
|
|
232
|
+
@op('bounty.close', obj({'listing_id': IDENTIFIER}, ('listing_id',)),
|
|
233
|
+
signature=True, requirements=account_requirements)
|
|
234
|
+
async def close(ctx, request, tx):
|
|
235
|
+
publisher = _subject(ctx)
|
|
236
|
+
row = _row(tx, request.arguments['listing_id'])
|
|
237
|
+
require(row['publisher'] == publisher, 'bounty_not_found')
|
|
238
|
+
await check_access(app, ctx, request, tx, row['listing_id'], 'write')
|
|
239
|
+
require(row['state'] != 'closed', 'bounty_closed')
|
|
240
|
+
remaining = _balance(tx, row['escrow_subject'])
|
|
241
|
+
refund = None
|
|
242
|
+
if remaining:
|
|
243
|
+
refund = _post_transfer(tx, sender=row['escrow_subject'],
|
|
244
|
+
recipient=publisher, amount=remaining, actor=publisher,
|
|
245
|
+
request_id=request.request_id, now=ctx.now,
|
|
246
|
+
receipt_signer=app.receipt_signer,
|
|
247
|
+
reference='bounty_close_return:' + row['listing_id'], kind='refund')
|
|
248
|
+
tx.execute("UPDATE bounty_listings SET state='closed',pause_reason=NULL WHERE listing_id=?",
|
|
249
|
+
(row['listing_id'],), write=True)
|
|
250
|
+
return HandlerOutput(data={'bounty': _public(_row(tx, row['listing_id']),
|
|
251
|
+
0, _claim_count(tx, row['listing_id'])), 'returned': refund})
|
|
252
|
+
|
|
253
|
+
@op('bounty.claim', obj({'challenge_id': IDENTIFIER,
|
|
254
|
+
'proof': SIGNATURE}, ('challenge_id', 'proof')), signature=True, requirements=account_requirements)
|
|
255
|
+
async def claim(ctx, request, tx):
|
|
256
|
+
claimant = _subject(ctx)
|
|
257
|
+
row = tx.one('''SELECT listing_id,claimant,key_id,expires_at,
|
|
258
|
+
verifier_version,payload,consumed_at,nonce,issued_at FROM bounty_challenges WHERE id=?''',
|
|
259
|
+
(request.arguments['challenge_id'],))
|
|
260
|
+
require(row is not None and row[1] == claimant, 'bounty_challenge_not_found')
|
|
261
|
+
listing = _row(tx, row[0])
|
|
262
|
+
await check_access(app, ctx, request, tx, listing['listing_id'], 'read')
|
|
263
|
+
_ready(tx, listing, ctx.now, claimant)
|
|
264
|
+
require(row[6] is None, 'bounty_challenge_consumed')
|
|
265
|
+
require(parse_time(row[3]) > ctx.now, 'bounty_challenge_expired')
|
|
266
|
+
require(row[4] == VERIFIER_VERSION, 'bounty_verifier_unsupported')
|
|
267
|
+
current = tx.one('''SELECT key_id,public_key FROM identity_keys WHERE subject=?
|
|
268
|
+
AND is_primary=1 AND retired_at IS NULL''', (claimant,))
|
|
269
|
+
require(current is not None and current[0] == row[2] and
|
|
270
|
+
current[0] == ctx.principal.credential_id,
|
|
271
|
+
'current_identity_key_required')
|
|
272
|
+
proof = decode(Signature, request.arguments['proof'])
|
|
273
|
+
require(proof.key_id == current[0], 'bounty_proof_key_mismatch')
|
|
274
|
+
payload = loads(row[5])
|
|
275
|
+
require(payload == {'challenge_id':request.arguments['challenge_id'],
|
|
276
|
+
'listing_id':row[0], 'claimant_subject_id':claimant, 'nonce':row[7],
|
|
277
|
+
'issued_at':row[8], 'expires_at':row[3], 'verifier_version':row[4]},
|
|
278
|
+
'bounty_challenge_mismatch')
|
|
279
|
+
verify(unb64(current[1], limit=32), canonical(payload), proof,
|
|
280
|
+
purpose='bounty-pop-v1')
|
|
281
|
+
proof_digest = digest({'payload': payload, 'proof': wire(proof)})
|
|
282
|
+
receipt = _post_transfer(tx, sender=listing['escrow_subject'],
|
|
283
|
+
recipient=claimant, amount=listing['reward_minor'], actor=claimant,
|
|
284
|
+
request_id=request.request_id, now=ctx.now,
|
|
285
|
+
receipt_signer=app.receipt_signer,
|
|
286
|
+
reference='bounty_claim:' + listing['listing_id'])
|
|
287
|
+
transaction_id = receipt['body']['transaction_id']
|
|
288
|
+
claim_id = new_id('bclaim')
|
|
289
|
+
tx.execute('''INSERT INTO bounty_claims
|
|
290
|
+
(id,listing_id,claimant,challenge_id,proof_digest,status,reward_minor,
|
|
291
|
+
transaction_id,claimed_at) VALUES (?,?,?,?,?,?,?,?,?)''',
|
|
292
|
+
(claim_id, listing['listing_id'], claimant,
|
|
293
|
+
request.arguments['challenge_id'], proof_digest, 'paid',
|
|
294
|
+
listing['reward_minor'], transaction_id, wire(ctx.now)), write=True)
|
|
295
|
+
consumed = tx.execute('''UPDATE bounty_challenges SET consumed_at=? WHERE id=? AND
|
|
296
|
+
consumed_at IS NULL''', (wire(ctx.now), request.arguments['challenge_id']),
|
|
297
|
+
write=True)
|
|
298
|
+
require(consumed.rowcount == 1, 'bounty_challenge_consumed')
|
|
299
|
+
balance = _balance(tx, listing['escrow_subject'])
|
|
300
|
+
paid_count = _claim_count(tx, listing['listing_id'])
|
|
301
|
+
reason = ('out_of_budget' if balance < listing['reward_minor'] else
|
|
302
|
+
'claims_exhausted' if paid_count >= listing['max_claims'] else None)
|
|
303
|
+
if reason:
|
|
304
|
+
tx.execute("UPDATE bounty_listings SET state='paused',pause_reason=? WHERE listing_id=?",
|
|
305
|
+
(reason,listing['listing_id']), write=True)
|
|
306
|
+
from msg.plugins.communication import event_id
|
|
307
|
+
notice = {'id': new_id('message'), 'sender': None, 'actor': claimant,
|
|
308
|
+
'recipient': claimant,
|
|
309
|
+
'resource': wire(ResourceRef(id=listing['listing_id'])),
|
|
310
|
+
'time': wire(ctx.now), 'state': 'delivered', 'source': 'bounty_claim',
|
|
311
|
+
'listing_id': listing['listing_id'], 'claim_id': claim_id,
|
|
312
|
+
'transaction_id': transaction_id}
|
|
313
|
+
tx.execute('INSERT INTO messages VALUES (?,?,?,?,?,?)',
|
|
314
|
+
(notice['id'], None, claimant, listing['listing_id'],
|
|
315
|
+
event_id(request, claimant), canonical(notice).decode()), write=True)
|
|
316
|
+
return HandlerOutput(resources=(ResourceRef(id=listing['listing_id']),),
|
|
317
|
+
data={'claim': {'id': claim_id,
|
|
318
|
+
'listing_id': listing['listing_id'], 'claimant': claimant,
|
|
319
|
+
'challenge_id': request.arguments['challenge_id'],
|
|
320
|
+
'proof_digest': proof_digest, 'status': 'paid',
|
|
321
|
+
'reward_minor': listing['reward_minor'],
|
|
322
|
+
'transaction_id': transaction_id, 'claimed_at': wire(ctx.now)},
|
|
323
|
+
'reward': receipt, 'escrow_balance_minor': balance})
|
|
324
|
+
|
|
325
|
+
@op('bounty.pause', obj({'listing_id':IDENTIFIER},('listing_id',)), signature=True, requirements=account_requirements)
|
|
326
|
+
async def pause(ctx,request,tx):
|
|
327
|
+
row = _row(tx,request.arguments['listing_id'])
|
|
328
|
+
require(row['publisher'] == _subject(ctx),'bounty_not_found')
|
|
329
|
+
await check_access(app,ctx,request,tx,row['listing_id'],'write')
|
|
330
|
+
require(row['state'] != 'closed','bounty_closed')
|
|
331
|
+
tx.execute("UPDATE bounty_listings SET state='paused',pause_reason='publisher_paused' WHERE listing_id=?",
|
|
332
|
+
(row['listing_id'],),write=True)
|
|
333
|
+
return HandlerOutput(data={'bounty':projection(tx,row['listing_id'],ctx.now)})
|
|
334
|
+
|
|
335
|
+
@op('bounty.resume', obj({'listing_id':IDENTIFIER},('listing_id',)), signature=True, requirements=account_requirements)
|
|
336
|
+
async def resume(ctx,request,tx):
|
|
337
|
+
row = _row(tx,request.arguments['listing_id'])
|
|
338
|
+
require(row['publisher'] == _subject(ctx),'bounty_not_found')
|
|
339
|
+
await check_access(app,ctx,request,tx,row['listing_id'],'write')
|
|
340
|
+
require(row['state'] != 'closed','bounty_closed')
|
|
341
|
+
require(row['expires_at'] is None or parse_time(row['expires_at']) > ctx.now,'bounty_expired')
|
|
342
|
+
require(_claim_count(tx,row['listing_id']) < row['max_claims'],'bounty_claims_exhausted')
|
|
343
|
+
require(_balance(tx,row['escrow_subject']) >= row['reward_minor'],'bounty_out_of_budget')
|
|
344
|
+
tx.execute("UPDATE bounty_listings SET state='active',pause_reason=NULL WHERE listing_id=?",
|
|
345
|
+
(row['listing_id'],),write=True)
|
|
346
|
+
return HandlerOutput(data={'bounty':projection(tx,row['listing_id'],ctx.now)})
|
|
347
|
+
|
|
348
|
+
@op('bounty.claims',obj({'listing_id':IDENTIFIER,'cursor':IDENTIFIER,
|
|
349
|
+
'limit':{'type':'integer','minimum':1,'maximum':100}},('listing_id',)),effect='read', requirements=account_requirements)
|
|
350
|
+
async def claims(ctx,request,tx):
|
|
351
|
+
from msg.plugins.money import _owner
|
|
352
|
+
subject = _owner(ctx)
|
|
353
|
+
row = _row(tx,request.arguments['listing_id'])
|
|
354
|
+
await check_access(app,ctx,request,tx,row['listing_id'],'read')
|
|
355
|
+
args = request.arguments
|
|
356
|
+
limit = args.get('limit',50)
|
|
357
|
+
rows = tx.rows('''SELECT id,claimant,challenge_id,proof_digest,status,reward_minor,
|
|
358
|
+
transaction_id,claimed_at FROM bounty_claims WHERE listing_id=? AND
|
|
359
|
+
(claimant=? OR ?=?) AND id>? ORDER BY id LIMIT ?''',
|
|
360
|
+
(row['listing_id'],subject,subject,row['publisher'],args.get('cursor',''),limit+1))
|
|
361
|
+
items = [dict(zip(('id','claimant','challenge_id','proof_digest','status',
|
|
362
|
+
'reward_minor','transaction_id','claimed_at'),r)) for r in rows[:limit]]
|
|
363
|
+
return HandlerOutput(data={'claims':items,
|
|
364
|
+
'next_cursor':items[-1]['id'] if len(rows)>limit else None})
|
|
365
|
+
|
|
366
|
+
finish()
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""Explicit handoff and expiring work leases; neither grants authority."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from datetime import timedelta
|
|
5
|
+
|
|
6
|
+
from msg.core.codec import canonical, loads, parse_time, wire
|
|
7
|
+
from msg.core.errors import Failure, require
|
|
8
|
+
from msg.core.models import HandlerOutput
|
|
9
|
+
from msg.plugins.common import check_access, new_id, operation_id, resolve
|
|
10
|
+
from msg.plugins.schemas import IDENTIFIER, STRING, obj
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
MAX_LEASE_TTL = timedelta(days=7)
|
|
14
|
+
MAX_HANDOFF_REFS = 16
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
async def _self(app, ctx, request, tx):
|
|
18
|
+
subject = ctx.principal.subject
|
|
19
|
+
require(subject is not None and ctx.principal.actor == subject, 'collaboration_subject_required')
|
|
20
|
+
await app.authorizer.require_base(ctx.principal, operation_id(request), subject, tx)
|
|
21
|
+
return subject
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
async def _safe_ref(app, ctx, request, tx, value):
|
|
25
|
+
rid = await resolve(tx, value)
|
|
26
|
+
resource = await tx.resource(rid)
|
|
27
|
+
chain = (*await tx.ancestors(rid), resource)
|
|
28
|
+
require(resource.state == 'active' and not any(
|
|
29
|
+
item.id in {'r_agents', 'r_rules', 't_last_will'} or
|
|
30
|
+
item.type in {'dm_conversation', 'credential', 'certificate', 'csr', 'legacy_directive', 'tool'}
|
|
31
|
+
for item in chain), 'collaboration_ref_forbidden')
|
|
32
|
+
require(not any(parent.type == 'user' and child.name in
|
|
33
|
+
{'keystore', 'SOUL.md', 'AGENTS.md', 'todos'}
|
|
34
|
+
for parent, child in zip(chain, chain[1:])), 'collaboration_ref_forbidden')
|
|
35
|
+
require(tx.one('SELECT 1 FROM dm_conversations WHERE resource_id IN (' +
|
|
36
|
+
','.join('?' for _ in chain) + ') LIMIT 1',
|
|
37
|
+
tuple(item.id for item in chain)) is None, 'collaboration_ref_forbidden')
|
|
38
|
+
require(not any(tx.setting('hosting_preview:' + item.id) or
|
|
39
|
+
tx.setting('hosting_preview_file:' + item.id) for item in chain),
|
|
40
|
+
'collaboration_ref_forbidden')
|
|
41
|
+
await check_access(app, ctx, request, tx, rid, 'read')
|
|
42
|
+
return rid
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
async def _visible_refs(app, ctx, request, tx, refs):
|
|
46
|
+
output = []
|
|
47
|
+
for rid in refs:
|
|
48
|
+
try:
|
|
49
|
+
await check_access(app, ctx, request, tx, rid, 'read')
|
|
50
|
+
except Failure as exc:
|
|
51
|
+
if exc.code in {'permission_denied', 'credential_ceiling', 'certificate_gate',
|
|
52
|
+
'delegation_scope', 'ancestor_inactive', 'not_found'}:
|
|
53
|
+
continue
|
|
54
|
+
raise
|
|
55
|
+
output.append(rid)
|
|
56
|
+
return output
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _notice(tx, *, sender, recipient, handoff_id, status, now):
|
|
60
|
+
# The resource is the recipient's own user resource: Inbox can filter it by
|
|
61
|
+
# current ACL. No handoff message, target name, or private ref is copied.
|
|
62
|
+
record = {'id': new_id('message'), 'sender': sender, 'recipient': recipient,
|
|
63
|
+
'resource': {'id': recipient}, 'source': 'handoff',
|
|
64
|
+
'handoff_id': handoff_id, 'status': status, 'time': wire(now)}
|
|
65
|
+
tx.execute('INSERT INTO messages (id,sender,recipient,resource,event_id,body) '
|
|
66
|
+
'VALUES (?,?,?,?,?,?)',
|
|
67
|
+
(record['id'], sender, recipient, recipient, None,
|
|
68
|
+
canonical(record).decode()), write=True)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _summary(record):
|
|
72
|
+
return {key: record[key] for key in ('id', 'status', 'generation')}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def install(app, op):
|
|
76
|
+
@op('communication.handoff_create', obj({
|
|
77
|
+
'to_subject': IDENTIFIER,
|
|
78
|
+
'resource_refs': {'type': 'array', 'items': IDENTIFIER,
|
|
79
|
+
'maxItems': MAX_HANDOFF_REFS, 'uniqueItems': True},
|
|
80
|
+
'message': {'type': 'string', 'maxLength': 4096},
|
|
81
|
+
'next_action': {'type': 'string', 'maxLength': 1024},
|
|
82
|
+
}, ('to_subject', 'resource_refs')))
|
|
83
|
+
async def handoff_create(ctx, request, tx):
|
|
84
|
+
sender = await _self(app, ctx, request, tx)
|
|
85
|
+
recipient = await resolve(tx, request.arguments['to_subject'])
|
|
86
|
+
target_subject = await tx.subject(recipient)
|
|
87
|
+
require(not target_subject.local_only, 'invalid_handoff_recipient')
|
|
88
|
+
require(recipient != sender, 'invalid_handoff_recipient')
|
|
89
|
+
refs = [await _safe_ref(app, ctx, request, tx, value)
|
|
90
|
+
for value in request.arguments['resource_refs']]
|
|
91
|
+
record = {'id': new_id('handoff'), 'from_subject': sender,
|
|
92
|
+
'to_subject': recipient, 'resource_refs': refs,
|
|
93
|
+
'message': request.arguments.get('message', ''),
|
|
94
|
+
'next_action': request.arguments.get('next_action'),
|
|
95
|
+
'created_at': wire(ctx.now), 'updated_at': wire(ctx.now),
|
|
96
|
+
'status': 'pending', 'generation': 1}
|
|
97
|
+
tx.execute('INSERT INTO handoffs VALUES (?,?,?,?,?,?)',
|
|
98
|
+
(record['id'], sender, recipient, 'pending', 1,
|
|
99
|
+
canonical(record).decode()), write=True)
|
|
100
|
+
_notice(tx, sender=sender, recipient=recipient, handoff_id=record['id'],
|
|
101
|
+
status='pending', now=ctx.now)
|
|
102
|
+
# Mutation results are replayed by the executor without running this
|
|
103
|
+
# handler again. Keep target refs only in ACL-checked read views.
|
|
104
|
+
return HandlerOutput(data={'handoff': _summary(record)})
|
|
105
|
+
|
|
106
|
+
@op('communication.handoff_get', obj({'id': IDENTIFIER}, ('id',)), effect='read')
|
|
107
|
+
async def handoff_get(ctx, request, tx):
|
|
108
|
+
subject = await _self(app, ctx, request, tx)
|
|
109
|
+
row = tx.one('SELECT from_subject,to_subject,body FROM handoffs WHERE id=?',
|
|
110
|
+
(request.arguments['id'],))
|
|
111
|
+
require(row is not None and subject in row[:2], 'handoff_not_found')
|
|
112
|
+
record = loads(row[2])
|
|
113
|
+
record['resource_refs'] = await _visible_refs(
|
|
114
|
+
app, ctx, request, tx, record['resource_refs'])
|
|
115
|
+
# Handoff text is authored for its participant, but it cannot carry a
|
|
116
|
+
# private target's body via our own projection.
|
|
117
|
+
return HandlerOutput(data={'handoff': record})
|
|
118
|
+
|
|
119
|
+
@op('communication.handoff_list', obj({
|
|
120
|
+
'limit': {'type': 'integer', 'minimum': 1, 'maximum': 100},
|
|
121
|
+
'after': IDENTIFIER}), effect='read')
|
|
122
|
+
async def handoff_list(ctx, request, tx):
|
|
123
|
+
subject = await _self(app, ctx, request, tx)
|
|
124
|
+
limit = request.arguments.get('limit', 50)
|
|
125
|
+
rows = tx.rows('SELECT id,body FROM handoffs WHERE '
|
|
126
|
+
'(from_subject=? OR to_subject=?) AND id>? ORDER BY id LIMIT ?',
|
|
127
|
+
(subject, subject, request.arguments.get('after', ''), limit + 1))
|
|
128
|
+
records = []
|
|
129
|
+
for _, raw in rows[:limit]:
|
|
130
|
+
record = loads(raw)
|
|
131
|
+
record['resource_refs'] = await _visible_refs(
|
|
132
|
+
app, ctx, request, tx, record['resource_refs'])
|
|
133
|
+
records.append(record)
|
|
134
|
+
return HandlerOutput(data={'items': records,
|
|
135
|
+
'next_after': rows[limit - 1][0] if len(rows) > limit else None})
|
|
136
|
+
|
|
137
|
+
@op('communication.handoff_decide', obj({
|
|
138
|
+
'id': IDENTIFIER, 'decision': {'enum': ['accept', 'reject', 'cancel']},
|
|
139
|
+
'expected_generation': {'type': 'integer', 'minimum': 1},
|
|
140
|
+
}, ('id', 'decision', 'expected_generation')))
|
|
141
|
+
async def handoff_decide(ctx, request, tx):
|
|
142
|
+
subject = await _self(app, ctx, request, tx)
|
|
143
|
+
row = tx.one('SELECT from_subject,to_subject,status,generation,body FROM handoffs WHERE id=?',
|
|
144
|
+
(request.arguments['id'],))
|
|
145
|
+
require(row is not None and subject in row[:2], 'handoff_not_found')
|
|
146
|
+
sender, recipient, state, generation, raw = row
|
|
147
|
+
decision = request.arguments['decision']
|
|
148
|
+
require(state == 'pending', 'handoff_not_pending')
|
|
149
|
+
require((decision == 'cancel' and subject == sender) or
|
|
150
|
+
(decision in {'accept', 'reject'} and subject == recipient),
|
|
151
|
+
'handoff_decision_forbidden')
|
|
152
|
+
require(generation == request.arguments['expected_generation'], 'generation_conflict')
|
|
153
|
+
status = {'accept': 'accepted', 'reject': 'rejected', 'cancel': 'cancelled'}[decision]
|
|
154
|
+
record = loads(raw)
|
|
155
|
+
record.update(status=status, generation=generation + 1,
|
|
156
|
+
updated_at=wire(ctx.now))
|
|
157
|
+
updated = tx.execute('UPDATE handoffs SET status=?,generation=?,body=? '
|
|
158
|
+
'WHERE id=? AND status=? AND generation=?',
|
|
159
|
+
(status, generation + 1, canonical(record).decode(),
|
|
160
|
+
request.arguments['id'], 'pending', generation), write=True)
|
|
161
|
+
require(updated.rowcount == 1, 'generation_conflict')
|
|
162
|
+
_notice(tx, sender=subject, recipient=recipient if subject == sender else sender,
|
|
163
|
+
handoff_id=record['id'], status=status, now=ctx.now)
|
|
164
|
+
return HandlerOutput(data={'handoff': _summary(record)})
|
|
165
|
+
|
|
166
|
+
@op('communication.lease_acquire', obj({
|
|
167
|
+
'target': IDENTIFIER, 'purpose': {'type': 'string', 'minLength': 1, 'maxLength': 1024},
|
|
168
|
+
'expires_at': STRING,
|
|
169
|
+
}, ('target', 'purpose', 'expires_at')))
|
|
170
|
+
async def lease_acquire(ctx, request, tx):
|
|
171
|
+
holder = await _self(app, ctx, request, tx)
|
|
172
|
+
target = await _safe_ref(app, ctx, request, tx, request.arguments['target'])
|
|
173
|
+
expires = parse_time(request.arguments['expires_at'])
|
|
174
|
+
require(ctx.now < expires <= ctx.now + MAX_LEASE_TTL, 'invalid_lease_expiry')
|
|
175
|
+
record = {'id': new_id('lease'), 'holder': holder, 'target': target,
|
|
176
|
+
'purpose': request.arguments['purpose'], 'acquired_at': wire(ctx.now),
|
|
177
|
+
'expires_at': wire(expires), 'status': 'active', 'generation': 1}
|
|
178
|
+
tx.execute('INSERT INTO collaboration_leases VALUES (?,?,?,?,?,?,?)',
|
|
179
|
+
(record['id'], holder, target, 'active', 1,
|
|
180
|
+
wire(expires), canonical(record).decode()), write=True)
|
|
181
|
+
return HandlerOutput(data={'lease': _summary(record)})
|
|
182
|
+
|
|
183
|
+
async def _lease(ctx, request, tx, *, mutation=False):
|
|
184
|
+
holder = await _self(app, ctx, request, tx)
|
|
185
|
+
row = tx.one('SELECT holder,target,status,generation,expires_at,body '
|
|
186
|
+
'FROM collaboration_leases WHERE id=?', (request.arguments['id'],))
|
|
187
|
+
require(row is not None and row[0] == holder, 'lease_not_found')
|
|
188
|
+
# A lease cannot keep a target visible after permission is revoked.
|
|
189
|
+
await _safe_ref(app, ctx, request, tx, row[1])
|
|
190
|
+
return row, loads(row[5])
|
|
191
|
+
|
|
192
|
+
@op('communication.lease_get', obj({'id': IDENTIFIER}, ('id',)), effect='read')
|
|
193
|
+
async def lease_get(ctx, request, tx):
|
|
194
|
+
row, record = await _lease(ctx, request, tx)
|
|
195
|
+
record['effective_status'] = ('expired' if row[2] == 'active' and
|
|
196
|
+
parse_time(row[4]) <= ctx.now else row[2])
|
|
197
|
+
return HandlerOutput(data={'lease': record})
|
|
198
|
+
|
|
199
|
+
@op('communication.lease_list', obj({
|
|
200
|
+
'limit': {'type': 'integer', 'minimum': 1, 'maximum': 100},
|
|
201
|
+
'after': IDENTIFIER}), effect='read')
|
|
202
|
+
async def lease_list(ctx, request, tx):
|
|
203
|
+
holder = await _self(app, ctx, request, tx)
|
|
204
|
+
limit = request.arguments.get('limit', 50)
|
|
205
|
+
rows = tx.rows('SELECT id,target,status,expires_at,body FROM collaboration_leases '
|
|
206
|
+
'WHERE holder=? AND id>? ORDER BY id',
|
|
207
|
+
(holder, request.arguments.get('after', '')))
|
|
208
|
+
records = []
|
|
209
|
+
more = False
|
|
210
|
+
for _, target, status, expires, raw in rows:
|
|
211
|
+
try:
|
|
212
|
+
await _safe_ref(app, ctx, request, tx, target)
|
|
213
|
+
except Failure as exc:
|
|
214
|
+
if exc.code in {'permission_denied', 'credential_ceiling', 'certificate_gate',
|
|
215
|
+
'delegation_scope', 'ancestor_inactive', 'not_found',
|
|
216
|
+
'collaboration_ref_forbidden'}:
|
|
217
|
+
continue
|
|
218
|
+
raise
|
|
219
|
+
record = loads(raw)
|
|
220
|
+
record['effective_status'] = ('expired' if status == 'active' and
|
|
221
|
+
parse_time(expires) <= ctx.now else status)
|
|
222
|
+
if len(records) == limit:
|
|
223
|
+
more = True
|
|
224
|
+
break
|
|
225
|
+
records.append(record)
|
|
226
|
+
return HandlerOutput(data={'items': records,
|
|
227
|
+
'next_after': records[-1]['id'] if more else None})
|
|
228
|
+
|
|
229
|
+
@op('communication.lease_renew', obj({
|
|
230
|
+
'id': IDENTIFIER, 'expires_at': STRING,
|
|
231
|
+
'expected_generation': {'type': 'integer', 'minimum': 1},
|
|
232
|
+
}, ('id', 'expires_at', 'expected_generation')))
|
|
233
|
+
async def lease_renew(ctx, request, tx):
|
|
234
|
+
row, record = await _lease(ctx, request, tx, mutation=True)
|
|
235
|
+
require(row[2] == 'active' and parse_time(row[4]) > ctx.now, 'lease_inactive')
|
|
236
|
+
require(row[3] == request.arguments['expected_generation'], 'generation_conflict')
|
|
237
|
+
expires = parse_time(request.arguments['expires_at'])
|
|
238
|
+
require(ctx.now < expires <= ctx.now + MAX_LEASE_TTL and
|
|
239
|
+
expires > parse_time(row[4]), 'invalid_lease_expiry')
|
|
240
|
+
record.update(expires_at=wire(expires), generation=row[3] + 1)
|
|
241
|
+
updated = tx.execute('UPDATE collaboration_leases SET generation=?,expires_at=?,body=? '
|
|
242
|
+
'WHERE id=? AND generation=? AND status=? AND expires_at=?',
|
|
243
|
+
(row[3] + 1, wire(expires), canonical(record).decode(),
|
|
244
|
+
request.arguments['id'], row[3], 'active', row[4]), write=True)
|
|
245
|
+
require(updated.rowcount == 1, 'generation_conflict')
|
|
246
|
+
return HandlerOutput(data={'lease': _summary(record)})
|
|
247
|
+
|
|
248
|
+
@op('communication.lease_release', obj({
|
|
249
|
+
'id': IDENTIFIER, 'expected_generation': {'type': 'integer', 'minimum': 1},
|
|
250
|
+
}, ('id', 'expected_generation')))
|
|
251
|
+
async def lease_release(ctx, request, tx):
|
|
252
|
+
row, record = await _lease(ctx, request, tx, mutation=True)
|
|
253
|
+
require(row[2] == 'active' and parse_time(row[4]) > ctx.now, 'lease_inactive')
|
|
254
|
+
require(row[3] == request.arguments['expected_generation'], 'generation_conflict')
|
|
255
|
+
record.update(status='released', generation=row[3] + 1)
|
|
256
|
+
updated = tx.execute('UPDATE collaboration_leases SET status=?,generation=?,body=? '
|
|
257
|
+
'WHERE id=? AND status=? AND generation=?',
|
|
258
|
+
('released', row[3] + 1, canonical(record).decode(),
|
|
259
|
+
request.arguments['id'], 'active', row[3]), write=True)
|
|
260
|
+
require(updated.rowcount == 1, 'generation_conflict')
|
|
261
|
+
return HandlerOutput(data={'lease': _summary(record)})
|