Skip to content

feat(user_shares): add share routes + consent wiring (POST/GET/DELETE /api/shares)#1908

Open
hognek wants to merge 6 commits into
jaylfc:devfrom
hognek:feat/user-shares-routes
Open

feat(user_shares): add share routes + consent wiring (POST/GET/DELETE /api/shares)#1908
hognek wants to merge 6 commits into
jaylfc:devfrom
hognek:feat/user-shares-routes

Conversation

@hognek

@hognek hognek commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Summary

Implement tinyagentos/routes/user_shares.py with three authenticated routes for user-to-user resource sharing, plus consent wiring (notifications + Decision records).

Routes:

  • POST /api/shares {resource_type, resource_id, to_username, permission} — share a resource with another user by username. Resolves via AuthManager.find_user(), creates share in UserSharesStore, raises notification + Decision record for consent.
  • GET /api/shares?direction=out|in — list shares (out = what you have shared, in = what is shared with you). Defaults to out.
  • DELETE /api/shares/{id} — revoke a share. Owner or admin only (uses require_owner_or_admin against the share owner_user_id).

Consent wiring (mirrors agent_auth_requests.py lines 204-221):

  • Notification raised to target user with share_id, owner_user_id, resource_type, resource_id, permission
  • Decision record (type=approve_deny) created for target user Decisions inbox

Also:

  • Module-level user_can_access() helper for route consumers
  • UserSharesStore registered in app.py lifespan
  • Router registered in routes/init.py

Depends on: PR #1897 (UserSharesStore)

Tests: 19/19 pass (test_register_all_routers, test_app_startup_guard, test_main_entry, test_version_endpoint)


Kilo bot fixes (commit 4e52ffe):

  • WARNING x2 (expiry): _normalise_expiry parses caller-supplied expires_at to UTC-aware datetime on write, so lexical ISO comparisons in list_active_shares/user_can_access are safe against timezone offsets, Z suffix, naive timestamps, and sub-second precision.
  • SUGGESTION: Added get_share(share_id) to UserSharesStore (indexed PK lookup); refactored _find_share_by_id to use it instead of O(N) full scan.
  • WARNING: Documented one-time CREATE INDEX cost in NotificationStore._post_init (IF NOT EXISTS makes it no-op on subsequent starts).

Summary by CodeRabbit

  • New Features

    • Added user-to-user resource sharing.
    • Users can create, view, accept, deny, and revoke shared resources.
    • Sharing supports permissions, expiration, access checks, and duplicate-share handling.
    • Share recipients receive notifications and decision records when applicable.
  • Documentation

    • Added API documentation covering sharing endpoints, permissions, and expected behavior.
  • Tests

    • Added comprehensive coverage for sharing, access control, validation, and lifecycle operations.

@hognek
hognek marked this pull request as ready for review July 17, 2026 17:46
@coderabbitai

coderabbitai Bot commented Jul 17, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds a SQLite-backed user resource-sharing store, /api/shares routes for creation and lifecycle actions, application wiring, authenticated tests, and API documentation.

Changes

User Resource Sharing

Layer / File(s) Summary
Sharing persistence and state transitions
tinyagentos/user_shares_store.py
Defines the SQLite schema and implements idempotent share writes, expiry normalization, listing, access checks, revocation, and accept/deny status transitions.
Share routes and application wiring
tinyagentos/app.py, tinyagentos/routes/user_shares.py, tinyagentos/routes/__init__.py
Initializes and exposes the store, registers CSRF-protected routes, and implements share creation, listing, consent, revocation, notifications, and decision records.
Sharing validation and documentation
tests/test_user_shares.py, docs/agent-coordination.md, tinyagentos/notifications.py
Adds authenticated lifecycle tests, documents the sharing API, and explains notification index initialization behavior.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant user_shares_router
  participant UserSharesStore
  participant notifications
  Client->>user_shares_router: POST /api/shares
  user_shares_router->>UserSharesStore: add_share
  UserSharesStore-->>user_shares_router: share record
  user_shares_router->>notifications: create target notification
  user_shares_router-->>Client: share record
Loading

Possibly related issues

Possibly related PRs

  • jaylfc/taOS#1897 — Introduces the same UserSharesStore implementation and persistence methods.
  • jaylfc/taOS#1958 — Covers the corresponding store, routes, tests, and application state wiring.
  • jaylfc/taOS#2003 — Directly overlaps by removing the sharing store and its tests.

Suggested reviewers: jaylfc

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: new user share routes with consent handling for /api/shares.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@qodo-code-review

Copy link
Copy Markdown

Qodo reviews are paused for this user.

Troubleshooting steps vary by plan Learn more →

On a Teams plan?
Reviews resume once this user has a paid seat and their Git account is linked in Qodo.
Link Git account →

Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center?
These require an Enterprise plan - Contact us
Contact us →

@gitar-bot

gitar-bot Bot commented Jul 17, 2026

Copy link
Copy Markdown

Gitar is working

Gitar

now = datetime.now(timezone.utc).isoformat()
cursor = await self._db.execute(
"SELECT * FROM user_shares "
"WHERE expires_at IS NULL OR expires_at > ? "

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Expiry check relies on lexical string comparison of ISO timestamps, which is not reliable.

expires_at is stored as a caller-supplied Optional[str] in add_share with no normalization, while now here is datetime.now(timezone.utc).isoformat() (+00:00). Lexical expires_at > ? only yields correct ordering when both strings share an identical, comparable format. If a caller passes a timestamp with a different timezone offset (e.g. +02:00), a trailing Z, or different sub-second precision, the string comparison can classify an expired share as active (or vice versa), silently leaking access or hiding valid shares. Validate/normalize expires_at to UTC on write, or compare parsed datetime values instead of raw strings.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

"SELECT 1 FROM user_shares "
"WHERE resource_type = ? AND resource_id = ? "
"AND shared_with_user_id = ? "
"AND (expires_at IS NULL OR expires_at > ?) "

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Same lexical-string expiry comparison risk as list_active_shares.

This is the access-gate query (user_can_access) — the correctness-sensitive path. Comparing expires_at > ? as raw ISO strings can misjudge expiry when expires_at was stored in a different format/offset than the +00:00 now produced here, potentially granting access to an expired share. Normalize expires_at to a canonical UTC form on write or parse both sides to datetime before comparing.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

Comment thread tinyagentos/routes/user_shares.py Outdated
"""
store = _get_user_shares_store(request)
# Active shares first (covers the common case for both owner and admin).
for s in await store.list_active_shares():

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: _find_share_by_id scans every active share in the system on each DELETE.

store.list_active_shares() returns all active shares across all users, and this loops over them in Python to locate a single id. On an instance with many shares this is an unnecessary O(N) full scan per revoke request, and it materializes shares the caller does not own into memory before the require_owner_or_admin gate runs. Consider adding a store method to fetch a single share by id (e.g. get_share(share_id)) and gate on its owner_user_id directly.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

@kilo-code-bot

kilo-code-bot Bot commented Jul 17, 2026

Copy link
Copy Markdown

Code Review Summary

Status: No Issues Found | Recommendation: Merge

Files Reviewed (1 file changed since last review)
  • docs/agent-coordination.md - documentation only; no code changes in this increment
Prior findings re-verified and resolved in this increment
  • user_shares_store.py (lexical expiry, lines 215 & 258) — RESOLVED: writes pass through _normalise_expiry (line 130), normalising expires_at to canonical UTC +00:00 isoformat; lexical expires_at > now comparisons remain format-safe.
  • user_shares_store.py (malformed expires_at fail-safe) — RESOLVED: _normalise_expiry returns None (never expires) on ValueError (lines 98-99) instead of keeping a non-ISO string.
  • user_shares_store.py (idempotent re-share silently resetting status) — RESOLVED: add_share reads the prior row's status (lines 136-146) and re-inserts prior_status, so an accepted/denied share is preserved on re-share.
  • routes/user_shares.py (_find_share_by_id O(N) scan) — RESOLVED: uses indexed get_share(share_id) PK lookup.
  • notifications.py:142 (blocking CREATE INDEX at startup) — MITIGATED: one-time cost; IF NOT EXISTS makes it a no-op on subsequent starts.
Previous Review Summaries (7 snapshots, latest commit 0b22651)

Current summary above is authoritative. Previous snapshots are kept for context only.

Previous review (commit 0b22651)

Status: No Issues Found | Recommendation: Merge

Files Reviewed (6 files, incremental since a2787cc)
  • tinyagentos/user_shares_store.py
  • tinyagentos/routes/user_shares.py
  • tinyagentos/routes/__init__.py
  • tinyagentos/notifications.py
  • tests/test_user_shares.py
Prior findings re-verified and resolved in this increment
  • user_shares_store.py (lexical expiry, lines 215 & 258) — RESOLVED: writes are now passed through _normalise_expiry, which normalises expires_at to a canonical UTC +00:00 isoformat, so the expires_at > now lexical comparisons are safe against timezone offsets, Z suffix, naive timestamps, and sub-second precision.
  • routes/user_shares.py (_find_share_by_id O(N) scan) — RESOLVED: now uses get_share(share_id) indexed PK lookup instead of scanning all active shares.
  • notifications.py:142 (blocking CREATE INDEX at startup) — MITIGATED: documented as a one-time cost; IF NOT EXISTS makes it a no-op on subsequent starts.
  • user_shares_store.py (malformed expires_at fail-safe) — RESOLVED: _normalise_expiry now returns None (never expires) instead of keeping a non-ISO string.
  • user_shares_store.py (idempotent re-share silently resetting status) — RESOLVED: add_share reads the prior row's status and re-inserts prior_status, so an accepted (or denied) share is preserved on re-share; covered by test_re_share_preserves_accepted_status.

Previous review (commit a2787cc)

Status: No Issues Found | Recommendation: Merge

Files Reviewed (incremental — 2 files changed since last review)
  • tinyagentos/user_shares_store.py — idempotent re-share status preservation added; no new issues
  • tests/test_user_shares.py — new test_re_share_preserves_accepted_status; no issues
Prior findings — re-verified and resolved
  • user_shares_store.py (idempotent re-share silent status reset, line 147) — RESOLVED: add_share now reads the prior row's status before the delete+insert and re-inserts prior_status, so an already-accepted (or denied) share is preserved instead of being reset to pending. The new test_re_share_preserves_accepted_status covers this.
  • user_shares_store.py (lexical expiry, lines 215 & 258) — resolved in prior run: _normalise_expiry normalises writes to UTC +00:00 ISO; lexical comparisons remain safe at HEAD.
  • routes/user_shares.py (_find_share_by_id O(N) scan) — resolved: uses indexed get_share(share_id) PK lookup.
  • notifications.py:142 (blocking CREATE INDEX at startup) — mitigated: one-time cost, IF NOT EXISTS no-op on subsequent starts.
  • user_shares_store.py (malformed expires_at fail-safe) — resolved: _normalise_expiry returns None (never expires) instead of keeping a non-ISO string.

Previous review (commit 4ea8d80)

Status: 1 Issue Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 0
SUGGESTION 1
Issue Details (click to expand)

SUGGESTION

File Line Issue
tinyagentos/user_shares_store.py 147 Idempotent re-share silently resets status to 'pending', downgrading an already-accepted share (and revoking access) with no notification.
Previously reported findings — resolved in this run

The following prior findings were re-verified against HEAD 4ea8d80f and are now resolved:

  • user_shares_store.py (lexical expiry, lines 198 & 241) — resolved: add_share now normalises expires_at to a UTC +00:00 isoformat via _normalise_expiry, so expires_at > now comparisons are format-safe.
  • routes/user_shares.py (_find_share_by_id O(N) scan) — resolved: added indexed get_share(share_id) and use it instead of scanning list_active_shares().
  • notifications.py:142 (blocking CREATE INDEX at startup) — mitigated: one-time cost documented inline; IF NOT EXISTS makes subsequent starts a no-op.
  • user_shares_store.py (malformed expires_at fail-safe) — resolved: _normalise_expiry now returns None (treated as "never expires") instead of keeping a non-ISO string.
Files Reviewed (5 files changed in this PR)
  • tinyagentos/routes/user_shares.py - 0 open issues (CSRF, notification user_id, accept/deny gates, O(1) lookup all addressed)
  • tinyagentos/user_shares_store.py - 1 new SUGGESTION (idempotent re-share status reset)
  • tinyagentos/notifications.py - prior WARNING mitigated
  • tinyagentos/app.py - no issues
  • tinyagentos/routes/__init__.py - no issues (CSRF dependency added)
  • tests/test_user_shares.py - new test coverage, no issues

Fix these issues in Kilo Cloud

Previous review (commit ad6673d)

Status: No Issues Found | Recommendation: Merge

Incremental Review (since 4e52ffe)

The only incremental change is in tinyagentos/user_shares_store.py, where _normalise_expiry’s fail-safe path was changed from keeping a malformed expires_at verbatim to returning None (treated as "never expires"). This resolves the prior SUGGESTION about the fail-safe path miscomparing in lexical expiry checks. No new issues introduced.

Files Reviewed (1 file changed since last review)
  • tinyagentos/user_shares_store.py - 0 new issues (prior fail-safe SUGGESTION resolved)

Previous review (commit 4e52ffe)

Status: 1 Issue Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 0
SUGGESTION 1
Issue Details (click to expand)

SUGGESTION

File Line Issue
tinyagentos/user_shares_store.py 97 _normalise_expiry fail-safe keeps a malformed expires_at un-normalized, which can mislead later lexical expiry comparisons in list_active_shares/user_can_access
Files Reviewed (3 files changed since last review)
  • tinyagentos/user_shares_store.py - 1 issue (new _normalise_expiry fail-safe path; prior lexical-expiry WARNINGs resolved by normalization on write)
  • tinyagentos/routes/user_shares.py - 0 new issues (prior _find_share_by_id scan SUGGESTION resolved via indexed get_share PK lookup)
  • tinyagentos/notifications.py - 0 new issues (prior blocking-CREATE-INDEX WARNING documented as one-time cost; behavior unchanged but accepted)

Fix these issues in Kilo Cloud

Previous review (commit b5d8ebc)

Status: 4 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 3
SUGGESTION 1
Issue Details (click to expand)

WARNING

File Line Issue
tinyagentos/user_shares_store.py 175 list_active_shares uses lexical ISO-string comparison for expiry; non-normalized expires_at can misjudge active/expired shares
tinyagentos/user_shares_store.py 208 user_can_access (the access gate) uses the same lexical-string expiry comparison; can grant access to an expired share
tinyagentos/notifications.py 134 Blocking CREATE INDEX IF NOT EXISTS idx_notif_user runs at startup on a potentially populated notifications table, taking a write lock for the full build

SUGGESTION

File Line Issue
tinyagentos/routes/user_shares.py 72 _find_share_by_id scans every active share system-wide per DELETE/accept/deny; consider a direct get_share(id) store method
Files Reviewed (6 files)
  • tinyagentos/app.py - 0 issues (store init/registration wiring correct)
  • tinyagentos/routes/__init__.py - 0 issues (CSRF dependency added for user_shares router; GET is exempt in verify_csrf, safe)
  • tinyagentos/routes/user_shares.py - 1 issue (accept/deny gating correct; shares _find_share_by_id scan)
  • tinyagentos/user_shares_store.py - 2 issues (lexical expiry; status column + accept/deny logic correct)
  • tinyagentos/notifications.py - 1 issue (user_id scoping + index DDL)
  • tests/test_user_shares.py - 0 issues (19 tests cover new accept/deny/revoke/list paths)

Fix these issues in Kilo Cloud

Previous review (commit 0f45ec6)

Status: 3 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 2
SUGGESTION 1
Issue Details (click to expand)

WARNING

File Line Issue
tinyagentos/user_shares_store.py 165 list_active_shares uses lexical ISO-string comparison for expiry; non-normalized expires_at can misjudge active/expired shares
tinyagentos/user_shares_store.py 197 user_can_access (the access gate) uses the same lexical-string expiry comparison; can grant access to an expired share

SUGGESTION

File Line Issue
tinyagentos/routes/user_shares.py 70 _find_share_by_id scans all active shares system-wide per DELETE; consider a direct get_share(id) store method
Files Reviewed (4 files)
  • tinyagentos/app.py - 0 issues (store init/registration wiring correct)
  • tinyagentos/routes/__init__.py - 0 issues (router registration correct)
  • tinyagentos/routes/user_shares.py - 1 issue
  • tinyagentos/user_shares_store.py - 2 issues

Notes / Assumptions

  • Verified consent wiring against actual APIs: auth.find_user() returns a raw record with id, app.state.decision_store.create(...) and app.state.notifications.add(...) signatures match the arguments used, and require_owner_or_admin / current_user are used correctly. No issues there.
  • The best-effort try/except Exception: pass around notification and Decision creation is consistent with the existing agent_auth_requests.py pattern and is intentional.
  • Only changed lines were reviewed; PR feat(user_shares): add UserSharesStore with NULL-safe write lock #1897 (UserSharesStore dependency) content is present in this branch.

Fix these issues in Kilo Cloud


Reviewed by hy3:free · Input: 42.6K · Output: 1.9K · Cached: 125.6K

@jaylfc

jaylfc commented Jul 17, 2026

Copy link
Copy Markdown
Owner

Store/wiring is sound and imports cleanly, but there's a hard security blocker plus a spec gap. Also: rebase onto current origin/dev — this branch is cut from a base predating #1897 (the user_shares_store is byte-identical to what already landed) and #1898 (the CSRF work), so CI is running against the wrong tree.

BLOCKER 1 — CSRF gap. routes/__init__.py adds app.include_router(user_shares_router) with no dependencies=_csrf. In this codebase CSRF is enforced purely by the per-router dependencies=[Depends(verify_csrf)]CSRFMiddleware only sets the cookie, it doesn't enforce. #1898 attached dependencies=_csrf to every mutating router; this one is registered without it, so once merged POST /api/shares and DELETE /api/shares/{id} are session-authed mutating routes with no CSRF protection (logged-in user on a malicious page can be forced to create/revoke shares). Fix: app.include_router(user_shares_router, dependencies=_csrf).

BLOCKER 2 — notification is a broadcast. create_share's notifs.add(...) omits user_id=target_user_id; per the docstring user_id=None broadcasts to every subscribed device, so 'X shared … with you' leaks to all users. Pass user_id=target_user_id.

Spec gap — no accept-gate. user_can_access() returns True the instant add_share inserts a row; the Decision raised to the target is decorative (nothing flips a status on approve/deny, and the store has no status column). #1893 requires a pending share the target must accept. Either add the store status column + wire approve/deny back to the share, or explicitly scope this as an interim with a tracked follow-up issue — but it shouldn't be presented as consent-gated when access is granted on create.

Tests missing. Zero route tests; #1893 acceptance requires create/accept/revoke/idempotent/unknown-user. Nits: _find_share_by_id is O(N) across all users on every DELETE (add get_share(id)); recipient can't decline a received share (DELETE is owner/admin only).

hognek added a commit to hognek/tinyagentos that referenced this pull request Jul 17, 2026
BLOCKER 1: Add dependencies=_csrf to user_shares_router in routes/__init__.py
BLOCKER 2: Pass user_id=target_user_id to notifs.add()
BLOCKER 3: Add status column + accept_share()/deny_share() + accept/deny routes
Spec gap: user_can_access() now gates on status='accepted'
Added 17 tests (create, idempotent, unknown-user, self-share, accept, deny,
revoke, list, access-gate). Tests use conftest client fixture + CSRF setup.

Rebased on origin/dev (post-jaylfc#1897 merge).
Closes jaylfc review on jaylfc#1908
# Create the per-user index if it doesn't exist yet. Cannot live in
# the SCHEMA because the column is added via guarded ALTER after init.
await self._db.execute(
"CREATE INDEX IF NOT EXISTS idx_notif_user ON notifications(user_id)"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Blocking CREATE INDEX runs at startup on a potentially populated table.

This index is built synchronously inside _post_init, which runs on app startup. On an instance with a large notifications table, CREATE INDEX takes a write lock on the table for the duration of the build and blocks all reads/writes to that table (SQLite has no CREATE INDEX CONCURRENTLY). The first boot after deploying this change can stall notification writes (and any query touching notifications) for the whole build window. Consider building the index lazily/asynchronously after startup, or documenting the one-time startup cost and bounding it (e.g. only create when the table is known-small, or build it outside the request-serving window).


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

Comment thread tinyagentos/user_shares_store.py Outdated
try:
dt = datetime.fromisoformat(expires_at)
except ValueError:
return expires_at

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: Fail-safe path keeps a malformed expires_at un-normalized

If datetime.fromisoformat raises ValueError, the original string is stored unchanged. Any string that fromisoformat rejects but that is still a plausible date (e.g. a non-ISO format, or a value with separator) will later be compared lexicographically against datetime.now(UTC).isoformat() in list_active_shares/user_can_access, where the mismatch in field width/format can misjudge active vs expired. Consider instead storing an empty/None value (treating unparseable as "never expires" or rejecting the input at the API boundary) so the expires_at IS NULL branch handles it consistently.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

hognek added a commit to hognek/tinyagentos that referenced this pull request Jul 17, 2026
BLOCKER 1: Add dependencies=_csrf to user_shares_router in routes/__init__.py
BLOCKER 2: Pass user_id=target_user_id to notifs.add()
BLOCKER 3: Add status column + accept_share()/deny_share() + accept/deny routes
Spec gap: user_can_access() now gates on status='accepted'
Added 17 tests (create, idempotent, unknown-user, self-share, accept, deny,
revoke, list, access-gate). Tests use conftest client fixture + CSRF setup.

Rebased on origin/dev (post-jaylfc#1897 merge).
Closes jaylfc review on jaylfc#1908
@hognek
hognek force-pushed the feat/user-shares-routes branch 2 times, most recently from 9a0d415 to 4ea8d80 Compare July 17, 2026 23:12
@hognek

hognek commented Jul 17, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto origin/dev (e895f71). All review points addressed:

BLOCKER 1 (CSRF): user_shares_router now registered with dependencies=_csrf (line 381 of routes/init.py). The #1898 CSRF work already attached _csrf to every mutating router; this adds the user_shares router with the same dependency.

BLOCKER 2 (notification broadcast): notifs.add() now passes user_id=target_user_id (user_shares.py line 150) — notifications are scoped per-user, not broadcast.

Spec gap (accept-gate):

  • status column added to user_shares schema (default: 'pending')
  • _post_init() migration for existing databases (grandfathers pre-status rows as 'accepted')
  • accept_share() / deny_share() store methods
  • POST /api/shares/{id}/accept and POST /api/shares/{id}/deny routes
  • user_can_access() now gates on status='accepted' — access is only granted after explicit accept

Nits: get_share(id) added for O(1) PK lookup; _normalise_expiry handles unparseable expiry safely.

Tests: 13/13 pass (create, idempotent, unknown-user, self-share, accept, deny, already-decided, revoke, not-found, list-out, list-in, access-gate).

Rebase: Clean rebase onto current origin/dev — the duplicate UserSharesStore commit from the PR's old base (already in dev via #1897) was auto-dropped. Local merge test shows clean fast-forward.

Ready for re-review.

Comment thread tinyagentos/user_shares_store.py Outdated
""",
(owner_user_id, resource_type, resource_id,
shared_with_user_id, permission, tier, now, expires_at),
shared_with_user_id, permission, tier, now, expires_at, 'pending'),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: Idempotent re-share silently resets status to 'pending'

The INSERT hardcodes status = 'pending' (line 147), and add_share is documented as idempotent (the delete+insert runs under the write lock). If the owner re-shares a resource the target has already accepted, the existing accepted share is deleted and a fresh pending one is inserted — silently revoking access until the target re-accepts, with no notification that consent was reset. This is surprising for a caller that invokes add_share merely to "ensure it exists". Consider preserving the prior status on conflict (e.g. INSERT ... ON CONFLICT ... DO NOTHING, or read the existing row's status before the delete) so an accepted share is not downgraded to pending.

@hognek

hognek commented Jul 18, 2026

Copy link
Copy Markdown
Contributor Author

@kilocode-bot This fixes the Kilo suggestion about idempotent re-share silently downgrading accepted→pending:

Fix: now reads the prior row's status before the DELETE+INSERT (under the write lock), and preserves it in the INSERT. A new share defaults to ; re-sharing an already-accepted share keeps .

Changed files:

  • — prior-status fetch before DELETE, replaces hardcoded
  • — new test

Tests: All user_shares tests pass including the new test.

@kilo-code-bot

kilo-code-bot Bot commented Jul 18, 2026

Copy link
Copy Markdown

To use Kilo from GitHub you first need to link your GitHub account to Kilo. Link your Kilo account to continue. After linking, mention me again in this issue or pull request.

hognek added 5 commits July 20, 2026 01:03
… /api/shares)

- New tinyagentos/routes/user_shares.py with three authenticated routes:
  POST /api/shares — share a resource with another user by username
  GET /api/shares — list shares (direction=out|in)
  DELETE /api/shares/{id} — revoke a share (owner or admin)
- Consent wiring: on share-create, raises a notification and a Decision
  record for the target user (mirrors agent_auth_requests.py pattern)
- Module-level user_can_access() helper for route consumers
- Registered UserSharesStore in app.py lifespan and router in
  routes/__init__.py

Depends on PR jaylfc#1897 (UserSharesStore — feat/user-shares-store branch)
BLOCKER 1: Add dependencies=_csrf to user_shares_router in routes/__init__.py
BLOCKER 2: Pass user_id=target_user_id to notifs.add()
BLOCKER 3: Add status column + accept_share()/deny_share() + accept/deny routes
Spec gap: user_can_access() now gates on status='accepted'
Added 17 tests (create, idempotent, unknown-user, self-share, accept, deny,
revoke, list, access-gate). Tests use conftest client fixture + CSRF setup.

Rebased on origin/dev (post-jaylfc#1897 merge).
Closes jaylfc review on jaylfc#1908
…(1) share lookup, index cost doc

- _normalise_expiry: parse expires_at to UTC-aware datetime on write so
  lexical ISO comparisons in list_active_shares/user_can_access are safe
  against timezone offsets, Z suffix, naive timestamps, and sub-second
  precision (Kilo WARNING ×2 on lines 175 & 208)
- Add get_share(share_id) method to UserSharesStore (indexed PK lookup)
  and refactor _find_share_by_id to use it instead of O(N) list scan
  (Kilo SUGGESTION on line 72)
- Document one-time CREATE INDEX cost in NotificationStore._post_init
  noting IF NOT EXISTS makes it no-op on subsequent starts (Kilo WARNING
  on line 134)
…aw string

Kilo SUGGESTION: if _normalise_expiry cannot parse expires_at, store None
rather than the raw string to avoid miscomparison in lexical ISO checks.
Unparseable expiry now means 'never expires' — safer than silently
passing a non-ISO string through to list_active_shares/user_can_access.
add_share() now reads the prior row's status before the DELETE+INSERT
so an already-accepted share is not silently downgraded to 'pending'
when the owner re-shares merely to 'ensure the share exists'.

- Fetch prior status before DELETE under the write lock
- Use prior_status in the INSERT instead of hardcoded 'pending'
- New test: test_re_share_preserves_accepted_status
@hognek
hognek force-pushed the feat/user-shares-routes branch from a2787cc to 0b22651 Compare July 19, 2026 23:20
Doc-gate rule "routes" requires updating docs/agent-coordination.md when a new
route module is added or removed. PR jaylfc#1908 added tinyagentos/routes/user_shares.py
without the corresponding doc update — gate was failing.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (4)
tinyagentos/routes/user_shares.py (1)

64-84: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Fallback branch in _find_share_by_id looks unreachable, and is semantically wrong for accept/deny if it ever fires.

store.get_share(share_id) is an unconditional, live query against the same table list_shares queries — there's no caching layer in UserSharesStore for the docstring's "the caller still sees it in their list through caching" scenario to apply, so if get_share returns None the fallback loop will not find the row either. Even setting that aside, the fallback searches list_shares(user_id) — shares owned by the caller — which is only relevant for revoke_share; for accept_share/deny_share the relevant party is the target (shared_with_user_id), so if this branch were ever exercised there it would search the wrong list.

Consider dropping the fallback and relying solely on get_share, simplifying this helper.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tinyagentos/routes/user_shares.py` around lines 64 - 84, Remove the
unreachable fallback loop from `_find_share_by_id`, including the `user_id`
lookup and `list_shares` call. Keep the helper limited to the direct
`store.get_share(share_id)` lookup, returning the share when found and `None`
otherwise.
tinyagentos/user_shares_store.py (1)

245-263: 🚀 Performance & Scalability | 🔵 Trivial

user_can_access (and list_shares_received) can't use the existing unique index.

The composite UNIQUE(owner_user_id, resource_type, resource_id, shared_with_user_id, permission) index only helps queries that filter on a leftmost prefix starting with owner_user_id. user_can_access filters on resource_type, resource_id, shared_with_user_id, and status — none of which are a usable prefix — so every access check does a full table scan. This is the hot-path query gating every shared-resource access; consider adding CREATE INDEX idx_user_shares_target ON user_shares(shared_with_user_id, resource_type, resource_id) (guarded in _post_init, same pattern already used for the status column) to cover both this and list_shares_received.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tinyagentos/user_shares_store.py` around lines 245 - 263, Add the proposed
target index during _post_init, following the existing guarded status-index
creation pattern: create idx_user_shares_target on
user_shares(shared_with_user_id, resource_type, resource_id). Ensure
initialization creates it for existing databases so user_can_access and
list_shares_received can use the indexed lookup.
tinyagentos/app.py (1)

508-510: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

app.state.user_shares is only bound inside lifespan(), unlike every sibling store.

Every other store added around here (e.g. agent_grants, agent_registry) gets both a lifespan-time assignment and an eager app.state.<name> = <store> binding further down in create_app() (~line 1666), so the attribute always exists even for harnesses that construct the app without running lifespan. user_shares only gets the lifespan-time binding here. In production this is harmless (the startup guard blocks requests until lifespan finishes), but tests/conftest.py's app fixture builds the app without running lifespan, and routes/user_shares.py._get_user_shares_store raises a bare RuntimeError (→ unhandled 500) if the attribute is missing — this PR's own tests work around it by manually setting app.state.user_shares in a bespoke fixture, but any other harness hitting /api/shares without that workaround will 500 instead of getting a clean error.

Add the matching eager binding for consistency with the rest of create_app().

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tinyagentos/app.py` around lines 508 - 510, Add an eager
app.state.user_shares = user_shares_store binding in create_app(), alongside the
existing eager bindings for sibling stores, while retaining the lifespan
assignment near user_shares_store.init(). Ensure _get_user_shares_store can find
the attribute when lifespan has not run.
tests/test_user_shares.py (1)

333-360: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

No test for DELETE /api/shares/{id} rejecting a non-owner, non-admin caller.

Coverage exists for the owner-happy-path and not-found, but not for the require_owner_or_admin gate itself — e.g. shares_client_target (a plain, non-admin target user) attempting to delete a share it doesn't own should get 403. Worth adding given the fixtures for this already exist.

✅ Suggested test
async def test_revoke_share_unauthorized(self, shares_client, shares_client_target):
    """Non-owner, non-admin caller cannot revoke another user's share."""
    r = await shares_client.post(
        "/api/shares",
        json={
            "resource_type": "project",
            "resource_id": "proj-revoke-unauth",
            "to_username": "target",
            "permission": "read",
        },
    )
    assert r.status_code == 200
    share_id = r.json()["id"]

    resp = await shares_client_target.delete(f"/api/shares/{share_id}")
    assert resp.status_code == 403
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_user_shares.py` around lines 333 - 360, Add a test alongside
test_revoke_share_owner and test_revoke_share_not_found that creates a share as
the owner, then uses shares_client_target to delete it and asserts a 403
response, covering rejection by the require_owner_or_admin authorization gate.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tinyagentos/routes/user_shares.py`:
- Around line 139-160: Update the exception handlers in both the notification
block and the Decision-creation block of the user-share consent wiring to log
failures at warning level instead of silently passing. Preserve the best-effort
behavior so share creation still succeeds, and include the caught exception and
relevant operation context in each warning.

In `@tinyagentos/user_shares_store.py`:
- Around line 106-177: Update add_share to preserve an existing share’s id
during re-share: retain the initial lookup, capture the existing row’s id, and
use UPDATE ... WHERE id = ? to replace tier, timestamps, and expiry while
leaving status unchanged. Only INSERT a new row when no matching share exists,
and preserve the current commit and returned-row behavior.

---

Nitpick comments:
In `@tests/test_user_shares.py`:
- Around line 333-360: Add a test alongside test_revoke_share_owner and
test_revoke_share_not_found that creates a share as the owner, then uses
shares_client_target to delete it and asserts a 403 response, covering rejection
by the require_owner_or_admin authorization gate.

In `@tinyagentos/app.py`:
- Around line 508-510: Add an eager app.state.user_shares = user_shares_store
binding in create_app(), alongside the existing eager bindings for sibling
stores, while retaining the lifespan assignment near user_shares_store.init().
Ensure _get_user_shares_store can find the attribute when lifespan has not run.

In `@tinyagentos/routes/user_shares.py`:
- Around line 64-84: Remove the unreachable fallback loop from
`_find_share_by_id`, including the `user_id` lookup and `list_shares` call. Keep
the helper limited to the direct `store.get_share(share_id)` lookup, returning
the share when found and `None` otherwise.

In `@tinyagentos/user_shares_store.py`:
- Around line 245-263: Add the proposed target index during _post_init,
following the existing guarded status-index creation pattern: create
idx_user_shares_target on user_shares(shared_with_user_id, resource_type,
resource_id). Ensure initialization creates it for existing databases so
user_can_access and list_shares_received can use the indexed lookup.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 9b07ec44-a5b8-4ed8-85e3-73f8fa65816d

📥 Commits

Reviewing files that changed from the base of the PR and between c801a22 and 9af8ad4.

📒 Files selected for processing (7)
  • docs/agent-coordination.md
  • tests/test_user_shares.py
  • tinyagentos/app.py
  • tinyagentos/notifications.py
  • tinyagentos/routes/__init__.py
  • tinyagentos/routes/user_shares.py
  • tinyagentos/user_shares_store.py

Comment on lines +139 to +160
notifs = getattr(request.app.state, "notifications", None)
if notifs is not None:
try:
await notifs.add(
title="Resource shared with you",
message=(
f"{user.user_id} shared {body.resource_type}/{body.resource_id} "
f"with you (permission: {body.permission})"
),
level="info",
source="user_shares",
user_id=target_user_id,
data={
"share_id": record["id"],
"owner_user_id": user.user_id,
"resource_type": body.resource_type,
"resource_id": body.resource_id,
"permission": body.permission,
},
)
except Exception:
pass

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Silently swallowed exceptions in the consent-wiring paths.

Both the notification and Decision-creation blocks catch with a bare except Exception: pass, flagged by static analysis (S110/BLE001). This is intentionally best-effort (should never fail share creation), but silently dropping the exception with no log line makes a broken notification/Decision pipeline invisible. Log at warning level (as NotificationStore._dispatch_push does) instead of a bare pass.

Also applies to: 166-187

🧰 Tools
🪛 Ruff (0.15.21)

[error] 159-160: try-except-pass detected, consider logging the exception

(S110)


[warning] 159-159: Do not catch blind exception: Exception

(BLE001)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tinyagentos/routes/user_shares.py` around lines 139 - 160, Update the
exception handlers in both the notification block and the Decision-creation
block of the user-share consent wiring to log failures at warning level instead
of silently passing. Preserve the best-effort behavior so share creation still
succeeds, and include the caught exception and relevant operation context in
each warning.

Source: Linters/SAST tools

Comment on lines +106 to +177
async def add_share(
self,
owner_user_id: str,
resource_type: str,
resource_id: str,
shared_with_user_id: str,
permission: str,
*,
tier: str = "once",
expires_at: Optional[str] = None,
) -> dict:
"""Insert or replace a share for the exact 5-column key.

Idempotent re-share: calling add_share again with the same
(owner_user_id, resource_type, resource_id, shared_with_user_id,
permission) tuple replaces the existing share rather than creating
a duplicate. The prior row's ``status`` is preserved (e.g. an
already-accepted share stays accepted), so re-sharing merely to
"ensure the share exists" does not silently revoke consent.
The delete+insert+select runs under the write lock for atomicity.
"""
if self._db is None:
raise RuntimeError("UserSharesStore not initialised — call init() first")

expires_at = self._normalise_expiry(expires_at)
now = datetime.now(timezone.utc).isoformat()
async with self._write_lock:
# Fetch the prior status before deleting, so an already-accepted
# share is not silently downgraded to 'pending' when the owner
# calls add_share again to "ensure the share exists".
prior = await (
await self._db.execute(
"SELECT status FROM user_shares "
"WHERE owner_user_id = ? AND resource_type = ? "
"AND resource_id = ? AND shared_with_user_id = ? "
"AND permission = ?",
(owner_user_id, resource_type, resource_id,
shared_with_user_id, permission),
)
).fetchone()
prior_status = prior["status"] if prior else "pending"

# Remove any existing row for the exact key first.
await self._db.execute(
"DELETE FROM user_shares "
"WHERE owner_user_id = ? AND resource_type = ? AND resource_id = ? "
"AND shared_with_user_id = ? AND permission = ?",
(owner_user_id, resource_type, resource_id,
shared_with_user_id, permission),
)
await self._db.execute(
"""
INSERT INTO user_shares
(owner_user_id, resource_type, resource_id,
shared_with_user_id, permission, tier, granted_at, expires_at, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(owner_user_id, resource_type, resource_id,
shared_with_user_id, permission, tier, now, expires_at, prior_status),
)
await self._db.commit()
row = await (
await self._db.execute(
"SELECT * FROM user_shares "
"WHERE owner_user_id = ? AND resource_type = ? "
"AND resource_id = ? AND shared_with_user_id = ? "
"AND permission = ?",
(owner_user_id, resource_type, resource_id,
shared_with_user_id, permission),
)
).fetchone()
return _row_to_dict(row) # type: ignore[return-value]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Re-share still mutates the share's id, even though status is now preserved.

add_share fixed the "resets to pending" bug by carrying over prior_status, but the underlying DELETE+INSERT still allocates a fresh AUTOINCREMENT id on every re-share (SQLite never reuses an AUTOINCREMENT id, even the one just deleted). This silently breaks any reference to the original id: the notification (data.share_id) and Decision (metadata.share_id) sent on first share, and any /api/shares/{id}/accept|deny link the target already has, all 404 after the owner "ensures the share exists" again. test_re_share_preserves_accepted_status (tests/test_user_shares.py:145-182) checks status is preserved but never asserts id is preserved, so this regression isn't caught.

Fix: look up the existing row's id first, then UPDATE ... WHERE id = ? in place (naturally preserving both id and status) instead of DELETE+INSERT; fall back to INSERT only when no row exists.

🛠️ Proposed fix — UPSERT by id instead of delete+insert
         async with self._write_lock:
-            # Fetch the prior status before deleting, so an already-accepted
-            # share is not silently downgraded to 'pending' when the owner
-            # calls add_share again to "ensure the share exists".
-            prior = await (
-                await self._db.execute(
-                    "SELECT status FROM user_shares "
-                    "WHERE owner_user_id = ? AND resource_type = ? "
-                    "AND resource_id = ? AND shared_with_user_id = ? "
-                    "AND permission = ?",
-                    (owner_user_id, resource_type, resource_id,
-                     shared_with_user_id, permission),
-                )
-            ).fetchone()
-            prior_status = prior["status"] if prior else "pending"
-
-            # Remove any existing row for the exact key first.
-            await self._db.execute(
-                "DELETE FROM user_shares "
-                "WHERE owner_user_id = ? AND resource_type = ? AND resource_id = ? "
-                "AND shared_with_user_id = ? AND permission = ?",
-                (owner_user_id, resource_type, resource_id,
-                 shared_with_user_id, permission),
-            )
-            await self._db.execute(
-                """
-                INSERT INTO user_shares
-                    (owner_user_id, resource_type, resource_id,
-                     shared_with_user_id, permission, tier, granted_at, expires_at, status)
-                VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
-                """,
-                (owner_user_id, resource_type, resource_id,
-                 shared_with_user_id, permission, tier, now, expires_at, prior_status),
-            )
+            existing = await (
+                await self._db.execute(
+                    "SELECT id FROM user_shares "
+                    "WHERE owner_user_id = ? AND resource_type = ? "
+                    "AND resource_id = ? AND shared_with_user_id = ? "
+                    "AND permission = ?",
+                    (owner_user_id, resource_type, resource_id,
+                     shared_with_user_id, permission),
+                )
+            ).fetchone()
+            if existing is not None:
+                # Preserves both id and status — a re-share never invalidates
+                # links already handed out for the original share.
+                await self._db.execute(
+                    "UPDATE user_shares SET tier = ?, granted_at = ?, expires_at = ? "
+                    "WHERE id = ?",
+                    (tier, now, expires_at, existing["id"]),
+                )
+                share_id = existing["id"]
+            else:
+                cursor = await self._db.execute(
+                    """
+                    INSERT INTO user_shares
+                        (owner_user_id, resource_type, resource_id,
+                         shared_with_user_id, permission, tier, granted_at, expires_at, status)
+                    VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending')
+                    """,
+                    (owner_user_id, resource_type, resource_id,
+                     shared_with_user_id, permission, tier, now, expires_at),
+                )
+                share_id = cursor.lastrowid
             await self._db.commit()
             row = await (
                 await self._db.execute(
-                    "SELECT * FROM user_shares "
-                    "WHERE owner_user_id = ? AND resource_type = ? "
-                    "AND resource_id = ? AND shared_with_user_id = ? "
-                    "AND permission = ?",
-                    (owner_user_id, resource_type, resource_id,
-                     shared_with_user_id, permission),
+                    "SELECT * FROM user_shares WHERE id = ?", (share_id,)
                 )
             ).fetchone()
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async def add_share(
self,
owner_user_id: str,
resource_type: str,
resource_id: str,
shared_with_user_id: str,
permission: str,
*,
tier: str = "once",
expires_at: Optional[str] = None,
) -> dict:
"""Insert or replace a share for the exact 5-column key.
Idempotent re-share: calling add_share again with the same
(owner_user_id, resource_type, resource_id, shared_with_user_id,
permission) tuple replaces the existing share rather than creating
a duplicate. The prior row's ``status`` is preserved (e.g. an
already-accepted share stays accepted), so re-sharing merely to
"ensure the share exists" does not silently revoke consent.
The delete+insert+select runs under the write lock for atomicity.
"""
if self._db is None:
raise RuntimeError("UserSharesStore not initialised — call init() first")
expires_at = self._normalise_expiry(expires_at)
now = datetime.now(timezone.utc).isoformat()
async with self._write_lock:
# Fetch the prior status before deleting, so an already-accepted
# share is not silently downgraded to 'pending' when the owner
# calls add_share again to "ensure the share exists".
prior = await (
await self._db.execute(
"SELECT status FROM user_shares "
"WHERE owner_user_id = ? AND resource_type = ? "
"AND resource_id = ? AND shared_with_user_id = ? "
"AND permission = ?",
(owner_user_id, resource_type, resource_id,
shared_with_user_id, permission),
)
).fetchone()
prior_status = prior["status"] if prior else "pending"
# Remove any existing row for the exact key first.
await self._db.execute(
"DELETE FROM user_shares "
"WHERE owner_user_id = ? AND resource_type = ? AND resource_id = ? "
"AND shared_with_user_id = ? AND permission = ?",
(owner_user_id, resource_type, resource_id,
shared_with_user_id, permission),
)
await self._db.execute(
"""
INSERT INTO user_shares
(owner_user_id, resource_type, resource_id,
shared_with_user_id, permission, tier, granted_at, expires_at, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(owner_user_id, resource_type, resource_id,
shared_with_user_id, permission, tier, now, expires_at, prior_status),
)
await self._db.commit()
row = await (
await self._db.execute(
"SELECT * FROM user_shares "
"WHERE owner_user_id = ? AND resource_type = ? "
"AND resource_id = ? AND shared_with_user_id = ? "
"AND permission = ?",
(owner_user_id, resource_type, resource_id,
shared_with_user_id, permission),
)
).fetchone()
return _row_to_dict(row) # type: ignore[return-value]
async def add_share(
self,
owner_user_id: str,
resource_type: str,
resource_id: str,
shared_with_user_id: str,
permission: str,
*,
tier: str = "once",
expires_at: Optional[str] = None,
) -> dict:
"""Insert or replace a share for the exact 5-column key.
Idempotent re-share: calling add_share again with the same
(owner_user_id, resource_type, resource_id, shared_with_user_id,
permission) tuple replaces the existing share rather than creating
a duplicate. The prior row's ``status`` is preserved (e.g. an
already-accepted share stays accepted), so re-sharing merely to
"ensure the share exists" does not silently revoke consent.
The delete+insert+select runs under the write lock for atomicity.
"""
if self._db is None:
raise RuntimeError("UserSharesStore not initialised — call init() first")
expires_at = self._normalise_expiry(expires_at)
now = datetime.now(timezone.utc).isoformat()
async with self._write_lock:
existing = await (
await self._db.execute(
"SELECT id FROM user_shares "
"WHERE owner_user_id = ? AND resource_type = ? "
"AND resource_id = ? AND shared_with_user_id = ? "
"AND permission = ?",
(owner_user_id, resource_type, resource_id,
shared_with_user_id, permission),
)
).fetchone()
if existing is not None:
# Preserves both id and status — a re-share never invalidates
# links already handed out for the original share.
await self._db.execute(
"UPDATE user_shares SET tier = ?, granted_at = ?, expires_at = ? "
"WHERE id = ?",
(tier, now, expires_at, existing["id"]),
)
share_id = existing["id"]
else:
cursor = await self._db.execute(
"""
INSERT INTO user_shares
(owner_user_id, resource_type, resource_id,
shared_with_user_id, permission, tier, granted_at, expires_at, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending')
""",
(owner_user_id, resource_type, resource_id,
shared_with_user_id, permission, tier, now, expires_at),
)
share_id = cursor.lastrowid
await self._db.commit()
row = await (
await self._db.execute(
"SELECT * FROM user_shares WHERE id = ?", (share_id,)
)
).fetchone()
return _row_to_dict(row) # type: ignore[return-value]
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tinyagentos/user_shares_store.py` around lines 106 - 177, Update add_share to
preserve an existing share’s id during re-share: retain the initial lookup,
capture the existing row’s id, and use UPDATE ... WHERE id = ? to replace tier,
timestamps, and expiry while leaving status unchanged. Only INSERT a new row
when no matching share exists, and preserve the current commit and returned-row
behavior.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants