diff --git a/README.md b/README.md index e892b37..1e911af 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,7 @@ memorylake actor create --custom-id user-001 --display-name "Alice Chen" \ [--metadata '{"tier":"premium"}'] memorylake actor list [--type HUMAN|ASSISTANT] [--name FUZZY] [--tags vip,cn] [--page-size N] +memorylake actor me memorylake actor get [--by-custom-id] memorylake actor update [--display-name NAME] [--description D] \ [--tags vip,cn | --clear-tags] [--metadata JSON] @@ -145,6 +146,17 @@ memorylake actor list --workspace # bindings, not actors `--custom-id` is unique account-wide. On update, `--metadata` **replaces** the stored value rather than merging into it. +`actor me` reports the actor your API key represents, so a script never has to +guess which of several human actors is yours: + +```bash +memorylake actor me | jq -r .id +``` + +**It is not necessarily bound to a workspace** — the actor comes with the +account, while joining a workspace is a separate step. To know who can write in +one, use `actor list --workspace `; to put this actor there, `actor bind`. + Tags are short labels for grouping and filtering: up to 20 per actor, each 1-64 characters, no commas. Matching is exact and case-sensitive — `VIP` and `vip` are two different tags. Prefer them over `--metadata` for anything you want to filter diff --git a/crates/cli/src/commands/actor.rs b/crates/cli/src/commands/actor.rs index 21f169b..4f6a1cd 100644 --- a/crates/cli/src/commands/actor.rs +++ b/crates/cli/src/commands/actor.rs @@ -6,8 +6,8 @@ use anyhow::{Context, Result, bail}; use clap::{Args, Subcommand, ValueEnum}; use memorylake_core::api::actors::{ ActorType, CreateActorRequest, ListActorsParams, UpdateActorRequest, bind_actor, create_actor, - delete_actor, get_actor, get_actor_by_custom_id, list_actors, list_workspace_actors, - unbind_actor, update_actor, + delete_actor, get_actor, get_actor_by_custom_id, get_my_actor, list_actors, + list_workspace_actors, unbind_actor, update_actor, }; use memorylake_core::{Client, Paths, ResolveOverrides, resolve}; use serde_json::{Map, Value}; @@ -111,6 +111,11 @@ pub enum ActorCommand { #[arg(long, value_parser = parse_metadata_object)] metadata: Option>, }, + /// Show the actor representing the current API key. + /// + /// A result does not mean that actor is bound to any workspace -- it often + /// is not. Use `actor list --workspace ` to see who can write there. + Me, /// Get a single actor by id. Get { /// Actor id (or custom_id when `--by-custom-id` is set). @@ -214,6 +219,10 @@ pub fn run(command: ActorCommand, profile: Option, base_url: Option { + let data = get_my_actor(&client).context("get my actor")?; + println!("{}", serde_json::to_string_pretty(&data)?); + } ActorCommand::Get { id, by_custom_id } => { let data = if by_custom_id { get_actor_by_custom_id(&client, &id).context("get actor by custom_id")? diff --git a/crates/cli/tests/actor/live.rs b/crates/cli/tests/actor/live.rs index dc132cd..0933d51 100644 --- a/crates/cli/tests/actor/live.rs +++ b/crates/cli/tests/actor/live.rs @@ -294,6 +294,39 @@ fn actor_lifecycle_end_to_end() { let _ = fs::remove_dir_all(&home); } +/// `actor me` names a real actor, not an alias. +/// +/// Creates nothing, so it needs no guard. +#[test] +fn actor_me_returns_a_resolvable_actor() { + let api_key = require_api_key(); + let home = temp_home(); + login_default(&home, &api_key); + + let me = json_of(&home, &["actor", "me"]); + let id = me["id"].as_str().expect("me returns an id").to_string(); + assert!(!id.is_empty()); + + // The point of the cross-check: prove the id addresses the same actor + // everywhere else, rather than being an alias only this endpoint knows. + let fetched = json_of(&home, &["actor", "get", id.as_str()]); + assert_eq!(fetched["id"], Value::String(id.clone())); + assert_eq!( + fetched["display_name"], me["display_name"], + "`actor me` and `actor get` must describe the same actor" + ); + + // Documented and measured: a result says nothing about workspace + // membership. Asserting the shape of `tags` here keeps `me` honest about + // returning the same record `get` does. + assert!( + me["tags"].is_array(), + "tags must be present as an array: {me}" + ); + + let _ = fs::remove_dir_all(&home); +} + /// Tags, end to end: what the server stores, how it filters, and what each of /// the two tag flags does. /// diff --git a/crates/cli/tests/actor/offline.rs b/crates/cli/tests/actor/offline.rs index a59e3d0..ef18dd0 100644 --- a/crates/cli/tests/actor/offline.rs +++ b/crates/cli/tests/actor/offline.rs @@ -109,6 +109,37 @@ fn tags_must_not_be_empty_or_have_doubled_commas() { let _ = fs::remove_dir_all(&home); } +#[test] +fn me_takes_no_arguments() { + let home = temp_home(); + // A positional or a flag must be a usage error, not silently ignored -- + // `actor me act-1` most likely means the caller wanted `actor get act-1`. + for args in [ + ["actor", "me", "act-1"].as_slice(), + ["actor", "me", "--workspace", "ws-1"].as_slice(), + ["actor", "me", "--by-custom-id"].as_slice(), + ] { + let err = assert_failure(&run(&home, args), args); + assert!( + err.contains("unexpected argument") || err.contains("Usage"), + "unexpected error output for {args:?}: {err}" + ); + } + let _ = fs::remove_dir_all(&home); +} + +#[test] +fn me_without_login_fails_like_the_others() { + let home = temp_home(); + let args = ["actor", "me"]; + let err = assert_failure(&run(&home, &args), &args); + assert!( + err.contains("not logged in") || err.contains("resolve API credentials"), + "unexpected error output: {err}" + ); + let _ = fs::remove_dir_all(&home); +} + #[test] fn metadata_must_be_valid_json() { let home = temp_home(); @@ -178,7 +209,7 @@ fn help_lists_actor_subcommands() { let stdout = String::from_utf8_lossy(&output.stdout).into_owned(); assert!(output.status.success(), "actor --help failed: {stdout}"); for subcommand in [ - "list", "create", "get", "update", "delete", "bind", "unbind", + "list", "create", "get", "me", "update", "delete", "bind", "unbind", ] { assert!( stdout.contains(subcommand), diff --git a/crates/cli/tests/actor/wire.rs b/crates/cli/tests/actor/wire.rs index 4ebbd2f..979d7a9 100644 --- a/crates/cli/tests/actor/wire.rs +++ b/crates/cli/tests/actor/wire.rs @@ -5,7 +5,7 @@ //! prove the endpoints exist; these prove the CLI calls the documented ones. use crate::common::assert_success; -use crate::common::stub::{exchange, request_line}; +use crate::common::stub::{exchange, exchange_sequence, request_body, request_line}; const EMPTY_PAGE: &str = r#"{"success":true,"data":{"items":[],"continuation_token":null}}"#; const ONE_ACTOR: &str = r#"{"success":true,"data":{"id":"act-1","custom_id":"user-1","actor_type":"HUMAN","display_name":"Alice"}}"#; @@ -175,6 +175,62 @@ fn create_sends_tags_as_a_json_array() { ); } +#[test] +fn me_calls_the_defaults_endpoint_exactly_once() { + // One request, not a list-then-filter: the whole point of the endpoint is + // that the server names the actor, so a fan-out would defeat it. + let (requests, output) = exchange_sequence(&[ONE_ACTOR], &["actor", "me"]); + assert_success(&output, &["actor", "me"]); + assert_eq!(requests.len(), 1, "{requests:?}"); + + let line = request_line(&requests[0]); + assert_eq!(line.split(' ').next(), Some("GET"), "{line}"); + assert_eq!( + line.split(' ').nth(1), + Some("/api/v3/defaults/my-actor"), + "no query string is sent: {line}" + ); + assert!( + request_body(&requests[0]).is_empty(), + "a GET must carry no body: {:?}", + request_body(&requests[0]) + ); +} + +#[test] +fn me_prints_the_actor_as_json_like_get_does() { + let (_, output) = exchange( + r#"{"success":true,"data":{"id":"act-1","custom_id":"user::abc","actor_type":"HUMAN","display_name":"Ada","created_by":"user::abc"}}"#, + &["actor", "me"], + ); + let stdout = assert_success(&output, &["actor", "me"]); + assert!(stdout.contains("\"id\": \"act-1\""), "{stdout}"); + assert!(stdout.contains("\"custom_id\": \"user::abc\""), "{stdout}"); + // `created_by` is sent by the API; dropping it would make it invisible. + assert!(stdout.contains("\"created_by\": \"user::abc\""), "{stdout}"); +} + +#[test] +fn me_on_an_older_deployment_says_so_rather_than_not_found() { + // A 404 here can only mean the route is absent -- every key has an actor -- + // so a bare "NOT_FOUND" would read as "you have no actor", the opposite of + // the truth. + let (_, output) = exchange( + r#"{"success":false,"message":"No static resource api/v3/defaults/my-actor.","error_code":"NOT_FOUND"}"#, + &["actor", "me"], + ); + assert!(!output.status.success(), "a 404 must fail the command"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("deployment") && stderr.contains("/api/v3/defaults/my-actor"), + "the error must blame the deployment and name the endpoint: {stderr}" + ); + assert!( + stderr.contains("No static resource"), + "the server's own message must survive for diagnosis: {stderr}" + ); +} + #[test] fn get_uses_the_id_path_and_by_custom_id_query() { let (request, output) = exchange(ONE_ACTOR, &["actor", "get", "act-1"]); diff --git a/crates/core/src/api/actors/me.rs b/crates/core/src/api/actors/me.rs new file mode 100644 index 0000000..793d31a --- /dev/null +++ b/crates/core/src/api/actors/me.rs @@ -0,0 +1,140 @@ +//! The caller's own actor (`GET /api/v3/defaults/my-actor`). + +use crate::client::Client; +use crate::error::{Error, Result}; + +use super::types::Actor; + +/// Path of the endpoint. Takes no caller-supplied segment, so unlike +/// `actor_path` it needs no encoding. +const MY_ACTOR_PATH: &str = "/api/v3/defaults/my-actor"; + +/// Wire value of the API error code returned when the route does not exist. +const NOT_FOUND_CODE: &str = "NOT_FOUND"; + +/// Fetch the actor representing the API key making the call. +/// +/// Every key has one, so a successful call always yields an actor and there is +/// no "no default actor" outcome to handle. +/// +/// **A 200 does not mean the actor is bound to any workspace.** It frequently +/// is not: the actor is created with the account, while workspace membership is +/// a separate, explicit act. Callers that need an actor to write memories with +/// must still check `list_workspace_actors`, and must not assume this actor +/// appears there. +/// +/// A `NOT_FOUND` is reported as an unsupported deployment rather than passed +/// through. Since the endpoint has no "missing" case of its own, a 404 can only +/// mean the server predates it — and a bare "NOT_FOUND" would otherwise read as +/// "you have no actor", which is the opposite of the truth. +pub fn get_my_actor(client: &Client) -> Result { + client + .get_data(MY_ACTOR_PATH, &[]) + .map_err(|err| match err { + Error::Api { + code: Some(code), + message, + } if code == NOT_FOUND_CODE => Error::Api { + message: format!( + "this MemoryLake deployment has no `{MY_ACTOR_PATH}` endpoint, so the \ + calling key's actor cannot be looked up; upgrade the server or name \ + the actor explicitly\n{message}" + ), + code: Some(code), + }, + other => other, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::{json_ok, one_shot_server}; + + /// Captured from production on 2026-08-21, verbatim. + const REAL_RESPONSE: &str = r#"{"success":true,"data":{"id":"actor-fd25f63e2bc0441f80b0e10fca8335fb","tags":[],"metadata":{},"custom_id":"user::e7f4fbd1149f44109589aece3310b0eb","actor_type":"HUMAN","display_name":"1594834522","created_at":"2026-07-07T05:11:09.50066Z","created_by":"user::e7f4fbd1149f44109589aece3310b0eb","updated_at":"2026-07-07T05:11:09.50066Z"}}"#; + + fn client_for(base_url: &str) -> Client { + Client::new(base_url, "sk-test").expect("build client") + } + + #[test] + fn decodes_a_real_response() { + let (base_url, handle) = one_shot_server(json_ok(REAL_RESPONSE)); + let actor = get_my_actor(&client_for(&base_url)).expect("decode real response"); + + assert_eq!(actor.id, "actor-fd25f63e2bc0441f80b0e10fca8335fb"); + assert_eq!( + actor.custom_id.as_deref(), + Some("user::e7f4fbd1149f44109589aece3310b0eb") + ); + assert_eq!(actor.actor_type, super::super::types::ActorType::Human); + assert_eq!(actor.display_name, "1594834522"); + assert!(actor.tags.is_empty()); + + let request = handle.join().expect("server thread"); + assert!( + request.head.starts_with(&format!("GET {MY_ACTOR_PATH} ")), + "{}", + request.head + ); + } + + #[test] + fn preserves_an_actor_type_this_build_does_not_know() { + // The crate decodes leniently everywhere else; this endpoint must not + // be the one place a server-side addition breaks the command. + let body = r#"{"success":true,"data":{"id":"act-1","actor_type":"SUPERVISOR","display_name":"Ada"}}"#; + let (base_url, handle) = one_shot_server(json_ok(body)); + let actor = get_my_actor(&client_for(&base_url)).expect("decode unknown actor_type"); + + assert_eq!( + actor.actor_type, + super::super::types::ActorType::Other("SUPERVISOR".to_string()) + ); + let _ = handle.join(); + } + + #[test] + fn reports_a_missing_route_as_an_unsupported_deployment() { + let body = r#"{"success":false,"message":"No static resource api/v3/defaults/my-actor.","error_code":"NOT_FOUND"}"#; + let response = format!( + "HTTP/1.1 404 Not Found\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{body}", + body.len() + ); + let (base_url, handle) = one_shot_server(response); + + let err = get_my_actor(&client_for(&base_url)).expect_err("404 must be an error"); + let rendered = err.to_string(); + assert!( + rendered.contains("has no") && rendered.contains(MY_ACTOR_PATH), + "the message must name the endpoint and blame the deployment: {rendered}" + ); + // The server's own words are kept so the failure stays diagnosable. + assert!(rendered.contains("No static resource"), "{rendered}"); + assert!( + matches!(err, Error::Api { code: Some(ref code), .. } if code == NOT_FOUND_CODE), + "the machine-readable code must survive the rewording" + ); + let _ = handle.join(); + } + + #[test] + fn other_api_errors_pass_through_untouched() { + let body = r#"{"success":false,"message":"denied","error_code":"ACCESS_DENIED"}"#; + let response = format!( + "HTTP/1.1 403 Forbidden\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{body}", + body.len() + ); + let (base_url, handle) = one_shot_server(response); + + let err = get_my_actor(&client_for(&base_url)).expect_err("403 must be an error"); + let rendered = err.to_string(); + assert!(rendered.contains("denied"), "{rendered}"); + assert!( + !rendered.contains("deployment"), + "only NOT_FOUND is reworded: {rendered}" + ); + let _ = handle.join(); + } +} diff --git a/crates/core/src/api/actors/mod.rs b/crates/core/src/api/actors/mod.rs index df38d63..8f7773d 100644 --- a/crates/core/src/api/actors/mod.rs +++ b/crates/core/src/api/actors/mod.rs @@ -5,6 +5,7 @@ mod create; mod delete; mod get; mod list; +mod me; mod types; mod update; @@ -13,5 +14,6 @@ pub use create::{CreateActorRequest, create_actor}; pub use delete::delete_actor; pub use get::{get_actor, get_actor_by_custom_id}; pub use list::{ActorList, ListActorsParams, list_actors}; +pub use me::get_my_actor; pub use types::{ACTOR_TYPE_ASSISTANT, ACTOR_TYPE_HUMAN, Actor, ActorBinding, ActorType}; pub use update::{UpdateActorRequest, update_actor}; diff --git a/crates/core/src/api/actors/types.rs b/crates/core/src/api/actors/types.rs index c13601a..e3ecfb5 100644 --- a/crates/core/src/api/actors/types.rs +++ b/crates/core/src/api/actors/types.rs @@ -92,6 +92,13 @@ pub struct Actor { /// Creation timestamp (ISO 8601). #[serde(default)] pub created_at: Option, + /// Id of the user or agent that created this actor, in the API's subject + /// form (`user::…`, `sa::…`). + /// + /// Absent for actors created before the server began recording it, so this + /// stays optional rather than being treated as always present. + #[serde(default)] + pub created_by: Option, /// Last update timestamp (ISO 8601). #[serde(default)] pub updated_at: Option,