Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
61f9fd2
feat(auth): resolve_caller module + ResolvedIdentity (LIFIC-8)
zorro432 Aug 1, 2026
0806493
feat(auth): REST gates consume ResolvedIdentity — fix auth-off bug (L…
zorro432 Aug 2, 2026
c680832
feat(auth): MCP gates consume ResolvedIdentity — delete mcp_gate (LIF…
zorro432 Aug 2, 2026
0915285
refactor(auth): delete operator carrier mechanisms (LIFIC-14)
zorro432 Aug 2, 2026
dc6fe6f
feat(cli): init creates first passwordless admin (LIFIC-9)
zorro432 Aug 3, 2026
b0ab4b4
test(auth): behavior-named one-assertion tests for passwordless admin…
zorro432 Aug 3, 2026
6c83e28
refactor(auth): apply LIFIC-9 code-review findings
zorro432 Aug 3, 2026
c1fb0f6
feat(auth): OAuth per-tool bot minting at approval (LIFIC-13)
zorro432 Aug 3, 2026
adce27f
fix(auth): disconnect/delete a bot revokes its OAuth tokens (LIFIC-13…
zorro432 Aug 4, 2026
ef0c3a8
feat(auth): approval tool pick-list — Option A single-choice reveal (…
zorro432 Aug 4, 2026
47cf096
refactor(auth): dedupe approval tool pick-list widget (LIFIC-13)
zorro432 Aug 4, 2026
5312ec5
feat(auth): remember each client's tool choice across reconnects (LIF…
zorro432 Aug 4, 2026
899bfc9
feat(auth): connected state includes a live OAuth token (LIFIC-13 fol…
zorro432 Aug 8, 2026
aa66e75
feat(auth): stable agent dedupe on (owner, tool) (LIFIC-17)
zorro432 Aug 8, 2026
433d80d
chore: stop ignoring .lific.json in this project
zorro432 Aug 8, 2026
0046ac8
feat(auth): stdio agents carry LIFIC_TOKEN identity (LIFIC-18)
zorro432 Aug 8, 2026
94188a2
feat(auth): interactive transport menu with stdio preselected (LIFIC-19)
zorro432 Aug 8, 2026
14d75b3
test(auth): pin stdio session identity at the entrypoint seam (LIFIC-18)
zorro432 Aug 8, 2026
27d45d6
refactor(auth): address LIFIC-18/19 code-review findings
zorro432 Aug 8, 2026
5687faf
test(auth): connect --stdio heals a token-less config idempotently
zorro432 Aug 8, 2026
c3b96b2
test(auth): reconnect heals stale configs across all transports
zorro432 Aug 8, 2026
b205d03
fix(web): self-heal expired session in single-user mode
zorro432 Aug 8, 2026
e9462b3
refactor(auth): shared plain-language login-free caution (LIFIC-22)
zorro432 Aug 8, 2026
6de2c63
feat(config): merge-preserving auth-mode editor (LIFIC-23)
zorro432 Aug 8, 2026
677d290
fix(auth): login-free start guard checks the bind, not public_url (LI…
zorro432 Aug 8, 2026
dac2343
feat(init): auth-mode menu with login-free safety (LIFIC-25)
zorro432 Aug 8, 2026
37335d4
refactor(db): dedupe first-admin insert + guard empty password (review)
zorro432 Aug 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@ cargo test
The whole suite runs in seconds. Every new MCP tool and REST endpoint ships with tests. Conventions:

- All tests use in-memory SQLite via `crate::db::open_memory()`.
- **Exception:** full-command tests of `lific init` (_`cmd_init`_) exercise the
real filesystem and a genuine on-disk DB, because init writes a config file
and opens the DB from a path — it cannot run against an in-memory database.
These use a self-cleaning temp dir (`TempDir` in `init_target_tests`) and
stay out of the repo tree.
- MCP tool tests call methods directly via `Parameters(...)` on a `LificMcp` instance.
- REST API tests use `tower::ServiceExt::oneshot` against the axum router.
- Test names describe behavior, not implementation.
Expand Down
8 changes: 8 additions & 0 deletions migrations/036_oauth_client_tool.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
-- LIFIC-15: remember which tool a registered OAuth client is, so a reconnect
-- pre-fills (rather than re-asks) the approval pick-list.
--
-- `client_id` comes from DCR (minted once, reused across reconnects), so the
-- tool choice is a stable attribute of the persistent client, not something to
-- re-derive on every visit. NULL for clients registered before this migration
-- and for clients that have never been approved (no tool chosen yet).
ALTER TABLE oauth_clients ADD COLUMN tool_id TEXT;
8 changes: 8 additions & 0 deletions migrations/037_users_tool_id.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
-- LIFIC-17: stable agent identity.
--
-- A connected agent is deduplicated on (owner_id, tool_id), NOT the derived
-- `{tool}-{owner.username}` string. Renaming the owner changes the string but
-- not the pair, so the agent must survive a rename. NULL for human users and
-- for bots minted before this migration (legacy bots are backfilled lazily on
-- their next connect).
ALTER TABLE users ADD COLUMN tool_id TEXT;
28 changes: 14 additions & 14 deletions src/api/activity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ use crate::authz;
use crate::db::queries::activity::{ActivityScope, actor_stats, list_activity};
use crate::db::{
DbPool,
models::{ActivityFeed, ActorStat, AuthUser, Role},
models::{ActivityFeed, ActorStat, Role},
};
use crate::error::LificError;

Expand All @@ -22,7 +22,7 @@ use super::with_read;
/// (`project_id = None`) fall back to admin-only.
async fn require_scope_viewer(
db: &DbPool,
auth_user: &Option<AuthUser>,
identity: &Option<crate::resolve_caller::ResolvedIdentity>,
scope: &ActivityScope,
) -> Result<(), LificError> {
let project_id: Option<i64> = match *scope {
Expand All @@ -38,8 +38,8 @@ async fn require_scope_viewer(
ActivityScope::Project(id) => Some(id),
};
match project_id {
Some(pid) => authz::require_role(db, auth_user, pid, Role::Viewer),
None => authz::require_workspace_admin(db, auth_user),
Some(pid) => authz::require_role(db, identity, pid, Role::Viewer),
None => authz::require_workspace_admin(db, identity),
}
}

Expand All @@ -53,61 +53,61 @@ pub(super) struct ActivityQuery {
/// comments, label attach/detach, and relation link/unlink events.
pub(super) async fn issue_activity(
State(db): State<DbPool>,
Extension(auth_user): Extension<Option<AuthUser>>,
Extension(identity): Extension<Option<crate::resolve_caller::ResolvedIdentity>>,
Path(id): Path<i64>,
Query(q): Query<ActivityQuery>,
) -> Result<Json<ActivityFeed>, LificError> {
let scope = ActivityScope::Issue(id);
require_scope_viewer(&db, &auth_user, &scope).await?;
require_scope_viewer(&db, &identity, &scope).await?;
with_read(&db, |conn| list_activity(conn, scope, q.limit, q.offset)).map(Json)
}

/// GET /api/pages/{id}/activity
pub(super) async fn page_activity(
State(db): State<DbPool>,
Extension(auth_user): Extension<Option<AuthUser>>,
Extension(identity): Extension<Option<crate::resolve_caller::ResolvedIdentity>>,
Path(id): Path<i64>,
Query(q): Query<ActivityQuery>,
) -> Result<Json<ActivityFeed>, LificError> {
let scope = ActivityScope::Page(id);
require_scope_viewer(&db, &auth_user, &scope).await?;
require_scope_viewer(&db, &identity, &scope).await?;
with_read(&db, |conn| list_activity(conn, scope, q.limit, q.offset)).map(Json)
}

/// GET /api/plans/{id}/activity — the plan's own edits plus every step
/// create/edit/done/move/delete and the issue-driven cascade rows.
pub(super) async fn plan_activity(
State(db): State<DbPool>,
Extension(auth_user): Extension<Option<AuthUser>>,
Extension(identity): Extension<Option<crate::resolve_caller::ResolvedIdentity>>,
Path(id): Path<i64>,
Query(q): Query<ActivityQuery>,
) -> Result<Json<ActivityFeed>, LificError> {
let scope = ActivityScope::Plan(id);
require_scope_viewer(&db, &auth_user, &scope).await?;
require_scope_viewer(&db, &identity, &scope).await?;
with_read(&db, |conn| list_activity(conn, scope, q.limit, q.offset)).map(Json)
}

/// GET /api/projects/{id}/activity — everything in the project, newest
/// first: issues, pages, comments, modules, labels, folders.
pub(super) async fn project_activity(
State(db): State<DbPool>,
Extension(auth_user): Extension<Option<AuthUser>>,
Extension(identity): Extension<Option<crate::resolve_caller::ResolvedIdentity>>,
Path(id): Path<i64>,
Query(q): Query<ActivityQuery>,
) -> Result<Json<ActivityFeed>, LificError> {
let scope = ActivityScope::Project(id);
require_scope_viewer(&db, &auth_user, &scope).await?;
require_scope_viewer(&db, &identity, &scope).await?;
with_read(&db, |conn| list_activity(conn, scope, q.limit, q.offset)).map(Json)
}

/// GET /api/projects/{id}/activity/actors — per-actor rollup, most
/// active first (LIF-158: actor rail + expanded-entry stats).
pub(super) async fn project_activity_actors(
State(db): State<DbPool>,
Extension(auth_user): Extension<Option<AuthUser>>,
Extension(identity): Extension<Option<crate::resolve_caller::ResolvedIdentity>>,
Path(id): Path<i64>,
) -> Result<Json<Vec<ActorStat>>, LificError> {
authz::require_role(&db, &auth_user, id, Role::Viewer)?;
authz::require_role(&db, &identity, id, Role::Viewer)?;
with_read(&db, |conn| actor_stats(conn, id)).map(Json)
}

Expand Down
72 changes: 55 additions & 17 deletions src/api/attachments.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,15 +72,16 @@ pub struct UploadResponse {
/// unlinked until the entity's markdown is saved and re-scanned.
pub(super) async fn upload_attachment(
State(db): State<DbPool>,
Extension(auth_user): Extension<Option<AuthUser>>,
Extension(identity): Extension<Option<crate::resolve_caller::ResolvedIdentity>>,
Extension(realtime): Extension<RealtimeHub>,
Extension(store): Extension<AttachmentStore>,
Extension(config): Extension<AttachmentConfig>,
Extension(limiter): Extension<Arc<AttachmentUploadLimiter>>,
mut multipart: Multipart,
) -> Result<Response, LificError> {
let user = auth_user
.clone()
let user = identity
.as_ref()
.map(|i| i.user.clone())
.ok_or_else(|| LificError::Forbidden("authentication required to upload".into()))?;

// Per-user rate limit (mirrors the signup/login limiter pattern).
Expand Down Expand Up @@ -194,7 +195,7 @@ pub(super) struct ListForEntityQuery {
/// reading the entity itself).
pub(super) async fn list_entity_attachments(
State(db): State<DbPool>,
Extension(auth_user): Extension<Option<AuthUser>>,
Extension(identity): Extension<Option<crate::resolve_caller::ResolvedIdentity>>,
Query(query): Query<ListForEntityQuery>,
) -> Result<axum::Json<Vec<Attachment>>, LificError> {
let entity: AttachmentEntity = query.entity_type.parse().map_err(LificError::BadRequest)?;
Expand All @@ -203,8 +204,8 @@ pub(super) async fn list_entity_attachments(
// pages (no project) fall back to workspace-admin.
let project_id = resolve_entity_project(&db, entity, query.entity_id)?;
match project_id {
Some(pid) => authz::require_role(&db, &auth_user, pid, Role::Viewer)?,
None => authz::require_workspace_admin(&db, &auth_user)?,
Some(pid) => authz::require_role(&db, &identity, pid, Role::Viewer)?,
None => authz::require_workspace_admin(&db, &identity)?,
}

let items = with_read(&db, |conn| {
Expand Down Expand Up @@ -243,7 +244,7 @@ fn resolve_entity_project(
/// addressed, so the response is immutable-cacheable forever.
pub(super) async fn download_attachment(
State(db): State<DbPool>,
Extension(auth_user): Extension<Option<AuthUser>>,
Extension(identity): Extension<Option<crate::resolve_caller::ResolvedIdentity>>,
Extension(store): Extension<AttachmentStore>,
Path(id): Path<i64>,
) -> Result<Response, LificError> {
Expand All @@ -252,7 +253,7 @@ pub(super) async fn download_attachment(
// Authorize: the caller must be able to view SOME project this attachment
// is linked into (Viewer), or be the uploader / an admin for a still-
// unlinked attachment.
authorize_read(&db, &auth_user, &attachment)?;
authorize_read(&db, &identity, &attachment)?;

let bytes = store.read(&attachment.sha256)?;
let is_image = storage::is_image_mime(&attachment.mime);
Expand Down Expand Up @@ -290,17 +291,18 @@ pub(super) async fn download_attachment(
/// the sidecar file if no other row shares the content hash.
pub(super) async fn delete_attachment(
State(db): State<DbPool>,
Extension(auth_user): Extension<Option<AuthUser>>,
Extension(identity): Extension<Option<crate::resolve_caller::ResolvedIdentity>>,
Extension(realtime): Extension<RealtimeHub>,
Extension(store): Extension<AttachmentStore>,
Path(id): Path<i64>,
) -> Result<axum::Json<serde_json::Value>, LificError> {
let user = auth_user
.clone()
let user = identity
.as_ref()
.map(|i| i.user.clone())
.ok_or_else(|| LificError::Forbidden("authentication required".into()))?;
let attachment = with_read(&db, |conn| q::get_attachment(conn, id))?;

authorize_delete(&db, &auth_user, &user, &attachment)?;
authorize_delete(&db, &identity, &user, &attachment)?;

let events = with_write(&db, |conn| {
let events = linked_attachment_events(conn, id)?;
Expand Down Expand Up @@ -458,7 +460,7 @@ fn owning_project_ids(
/// behavior — matching every other GET while the flag is off.
fn authorize_read(
db: &DbPool,
auth_user: &Option<AuthUser>,
identity: &Option<crate::resolve_caller::ResolvedIdentity>,
attachment: &Attachment,
) -> Result<(), LificError> {
let project_ids = with_read(db, |conn| owning_project_ids(conn, attachment.id))?;
Expand All @@ -467,7 +469,7 @@ fn authorize_read(
// Unlinked: only the uploader or an admin can read it. (When
// enforcement is off we still restrict unlinked reads to the uploader
// to avoid an enumeration hole on freshly-uploaded blobs.)
match auth_user {
match identity.as_ref().map(|i| &i.user) {
Some(u) if u.is_admin => Ok(()),
Some(u) if Some(u.id) == attachment.uploader_id => Ok(()),
_ => Err(LificError::Forbidden(
Expand All @@ -478,7 +480,7 @@ fn authorize_read(
// Viewer on ANY linked project is enough to read.
let mut last_err = None;
for pid in project_ids {
match authz::require_role(db, auth_user, pid, Role::Viewer) {
match authz::require_role(db, identity, pid, Role::Viewer) {
Ok(()) => return Ok(()),
Err(e) => last_err = Some(e),
}
Expand All @@ -492,7 +494,7 @@ fn authorize_read(
/// Delete gate: uploader, admin, or Maintainer on any owning project.
fn authorize_delete(
db: &DbPool,
auth_user: &Option<AuthUser>,
identity: &Option<crate::resolve_caller::ResolvedIdentity>,
user: &AuthUser,
attachment: &Attachment,
) -> Result<(), LificError> {
Expand All @@ -501,7 +503,7 @@ fn authorize_delete(
}
let project_ids = with_read(db, |conn| owning_project_ids(conn, attachment.id))?;
for pid in project_ids {
if authz::require_role(db, auth_user, pid, Role::Maintainer).is_ok() {
if authz::require_role(db, identity, pid, Role::Maintainer).is_ok() {
return Ok(());
}
}
Expand Down Expand Up @@ -811,6 +813,24 @@ mod api_tests {
username: "a".into(),
display_name: "A".into(),
is_admin: true,
})))
.layer(Extension(Some(crate::resolve_caller::ResolvedIdentity {
user: AuthUser {
id: admin_id,
username: "a".into(),
display_name: "A".into(),
is_admin: true,
},
transport: crate::actor::Transport::Web,
})))
.layer(Extension(Some(crate::resolve_caller::ResolvedIdentity {
user: AuthUser {
id: admin_id,
username: "a".into(),
display_name: "A".into(),
is_admin: true,
},
transport: crate::actor::Transport::Web,
})));

let resp = upload(&app, "big.png", "image/png", &png_bytes(), None).await;
Expand Down Expand Up @@ -847,6 +867,15 @@ mod api_tests {
username: lead.username.clone(),
display_name: lead.display_name.clone(),
is_admin: false,
})))
.layer(axum::Extension(Some(crate::resolve_caller::ResolvedIdentity {
user: AuthUser {
id: lead.id,
username: lead.username.clone(),
display_name: lead.display_name.clone(),
is_admin: false,
},
transport: crate::actor::Transport::Web,
})));

let issue = parse_json(
Expand Down Expand Up @@ -1002,6 +1031,15 @@ mod api_tests {
username: "gc".into(),
display_name: "GC".into(),
is_admin: true,
})))
.layer(axum::Extension(Some(crate::resolve_caller::ResolvedIdentity {
user: AuthUser {
id: admin_id,
username: "gc".into(),
display_name: "GC".into(),
is_admin: true,
},
transport: crate::actor::Transport::Web,
})));
let (project_id2, _) = seed_project(&app).await;

Expand Down
Loading