사람함께에이전트▾
사람 — 직접 정하고 책임지는 비즈니스 규칙과 흐름. 직접 읽어보세요.
함께 — 개념은 알아두고, 세부 규칙은 에이전트가 따릅니다.
에이전트 — 에이전트가 따르는 규칙과 레퍼런스. 필요할 때 찾아보세요.
인증과 권한
지점이 여러 개인 가게의 주문 목록을 배포했습니다. 지점 id를 URL에서 꺼내니 화면의 모든 행이 제대로 보입니다. 그런데 고객이 URL을 고치자, 같은 엔드포인트가 남의 주문을 그대로 내어 줍니다.
권한 검사는 모든 엔드포인트 앞에서 두 가지를 묻습니다. 누가 호출했는가, 그리고 이 일을 해도 되는가입니다. HTTP, 웹소켓, MCP 엔드포인트 어느 쪽으로 오든 같은 세 단계로 답합니다:
- 미들웨어가 호출자를 읽습니다.
libs/shared의AccountMiddleware가 토큰을 검증하고, 그 결과를 account로 호출에 붙여 둡니다. - 가드가 판정합니다.
guards배열의 가드가 선언 순서대로canPass(context)에 답하고, 처음 거절한 곳에서 호출이 끝납니다. - 내부 인자가 핸들러에 값을 넘깁니다.
.with(Self)같은 인자는 클라이언트가 보낸 값이 아니라 서버가 채운 값입니다.
이 페이지에서 쓰는 말
용어설명
account
미들웨어가 호출에 붙여 두는 값입니다. 비로그인 호출자의 account에는
self도 me도 없습니다.selfme
account가 가질 수 있는 두 신원입니다.
self는 사용자, me는 관리자입니다.가드가 판정에 필요로 하는 것입니다. 호출자만 보거나(
account), 호출 인자까지 봅니다(resource).사람 대신 MCP 엔드포인트나 OAuth 토큰으로 호출하는 AI 모델입니다.
호출 하나가 핸들러에 닿기까지
요청HTTP · websocket · MCP
인자 파싱
미들웨어Logging · Timeout · Account
guards 배열선언 순서대로
내부 인자.with(Self) · .with(Me)
exec() 핸들러
resolveReturnhidden·secret 필드 마스킹
401 · 403
요청HTTP · websocket · MCP
인자 파싱
미들웨어Logging · Timeout · Account
guards 배열선언 순서대로
첫 거절
내부 인자.with(Self) · .with(Me)
401 · 403
exec() 핸들러
resolveReturnhidden·secret 필드 마스킹



guards 배열이 없으면 검사도 없습니다. Akan에는 기본 정책이 없어서,
guards를 적지 않은 엔드포인트는 검사를 하나도 하지 않습니다. 가드 없는 mutation은 라우트에 닿는 누구나 호출할 수 있고, 로그인을 요구하는 곳도 없습니다.기본 제공 가드
가드는
canPass 메서드 하나를 가진 클래스입니다. 슬라이스는 guards 맵에, 커스텀 엔드포인트는 자기 guards 배열에 가드를 적습니다:apps/koyo/lib/icecreamOrder/icecreamOrder.signal.ts
- 모든
slice()에 guards 맵을 주고,root:는 항상Admin입니다. root 슬라이스는 모델에 선언된 필터를 호출자가 골라 쓰는 API라서 관리자의 일입니다. - 이름 있는 슬라이스와 커스텀 엔드포인트는 이 맵을 물려받지 않습니다.
init({ guards: [...] })나mutation(..., { guards: [...] })처럼 자기 배열을 직접 적으므로, 다른 파일을 열지 않고도 누가 호출할 수 있는지 보입니다. - 이 예제에서 자동 생성 CRUD는 관리자 전용입니다. 고객은 호출자를 읽는 엔드포인트로 자기 주문에 닿으며, 그 방법은 아래에서 다룹니다.
슬라이스 guards 맵의 키
각 키는 슬라이스가 모델에 대해 만들어 주는 엔드포인트를 지킵니다:
rootGuardCls | GuardCls[]
root 슬라이스입니다. 필터 이름과 그 인자를 받는 관리자용 목록 API라서 항상
Admin입니다.getGuardCls | GuardCls[]
문서 하나를 읽는
icecreamOrder(id)와 lightIcecreamOrder(id)입니다.cruGuardCls | GuardCls[]
createIcecreamOrder, updateIcecreamOrder, removeIcecreamOrder를 함께 맡습니다.createGuardCls | GuardCls[]기본값 cru
createIcecreamOrder에만 cru 대신 적용됩니다.updateGuardCls | GuardCls[]기본값 cru
updateIcecreamOrder에만 cru 대신 적용됩니다.removeGuardCls | GuardCls[]기본값 cru
removeIcecreamOrder에만 cru 대신 적용됩니다.기본 제공 가드 목록
akanjs/signal에는 누구도 가려내지 않는 가드 두 개가 있습니다. @libs/shared/srvkit에는 libs/shared를 마운트한 모든 앱이 물려받는 역할 사다리가 있습니다.가드
비로그인
user
admin
superAdmin
akanjs/signal
Public
✓
✓
✓
✓
비로그인 호출자와 에이전트까지 모두 통과시킵니다. 슬라이스의
get:에 쓰고, mutation에는 쓰지 않습니다.None
모두 거절합니다. 자동 생성된 엔드포인트를 명시적으로 닫을 때 씁니다.
@libs/shared/srvkit
Every
✓
✓
✓
로그인한 호출자라면
user, admin, superAdmin 누구든 통과합니다.User
✓
user 역할만 통과합니다. 사용자를 겸하지 않은 관리자는 거절됩니다.Admin
✓
✓
admin 또는 superAdmin입니다. 관리자 콘솔용 가드이자 모든 슬라이스의 root:입니다.SuperAdmin
✓
superAdmin만 통과합니다.Owner
✓
✓
✓
허용 역할은
Every와 같지만 resource scope라서, 목록에서는 평가하지 않고 호출 시점에 판정합니다.SelfOrAdmin
✓
✓
✓
resource scope입니다. userId 인자가 가리키는 사용자나 관리자만 통과하고, userId가 없으면 모두 거절합니다.Person
✓
✓
✓
✓
사람은 누구든 통과시키고 에이전트는 거절합니다. 역할 가드와 함께 씁니다:
guards: [Every, Person].✓통과거절
에이전트의 토큰에는 접근을 허락한 사람과 같은
self나 me가 실려 있습니다. 그래서 위 표에서 Person을 뺀 모든 가드는 에이전트를 그 사람처럼 판정합니다.401인가 403인가
거절된 호출이 받는 상태 코드는 어디서 거절됐는지에 따라 다릅니다:
| 거절 이유 | 상태 |
|---|---|
| ↳ 호출자가 받는 것 | |
| 역할 가드: 신원이 아예 없음 | 401 |
| MCP 클라이언트는 이 상태 코드를 “토큰을 받아 오라”로 읽습니다. | |
| 역할 가드: 로그인했지만 역할이 없음 | 403 |
| 필요한 역할과 호출자가 가진 역할을 함께 알려 줍니다. | |
모든 가드: false를 돌려줌 | 403 |
거절한 가드를 static name으로 알려 줍니다. | |
내부 인자: 필수 .with() 값이 null | 401 |
빠진 인자의 이름을 알려 주며, 그 값 없이도 도는 핸들러라면 { nullable: true }를 붙입니다. | |
scope 선언하기
모든 가드 클래스는
static scope: GuardScope도 선언해야 하며, 기본값은 없습니다. 이 값은 가드가 판정에 무엇이 필요한지 알려 주고, 덕분에 에이전트 카탈로그는 호출이 오기 전에 일부 가드를 미리 평가할 수 있습니다.호출자만 읽는 가드는
"account"로 표시합니다:apps/koyo/srvkit/guards.ts
scope = "account"
SignedIn · Every · Admin · Person호출자만 보고 호출 내용은 보지 않으므로 인자 없이도 평가할 수 있습니다. 에이전트 목록은 이 값으로 호출자가 확실히 못 쓰는 항목을 숨깁니다.
scope = "resource"
Can<Verb><Model> · Owner · SelfOrAdmincontext.getArg()로 호출 인자를 읽고 인자가 없으면 거절하므로, 목록에서는 평가하지 않습니다. 항목은 보이게 두고 실제 호출 시점에 막습니다.잘못 표시했을 때
어느 쪽으로 잘못 적어도 타입 에러는 나지 않습니다. 두 문자열 모두
GuardScope를 만족하고, canPass가 인자를 읽는지는 컴파일러가 알 수 없기 때문입니다. 두 실수는 이렇게 다르게 실패합니다:| 실수 |
|---|
| ↳ 결과 |
resource 가드에 "account"를 표시함 |
| 목록이 인자 없이 평가하므로 거절하거나 예외를 던지고, 쓸 수 있는 호출자에게서도 항목이 숨겨집니다. |
account 가드에 "resource"를 표시함 |
| 아무것도 걸러내지 못합니다. 호출 시점에 거절할 호출자에게까지 항목이 목록에 실립니다. |
기준:
SignedIn, Admin, 모든 역할 검사는 "account"이고, 모든 Can<Verb><Model>은 "resource"입니다. 기본 제공 가드 중 "resource"는 Owner와 SelfOrAdmin뿐입니다.리소스 가드: 모르면 거절
역할 가드는 호출자가 누구인지만 답할 뿐, 이 레코드가 호출자의 것인지는 답하지 못합니다. 그 판단은
srvkit/guards.ts의 Can<Verb><Model> 클래스가 맡으며, 호출이 가리키는 레코드를 직접 불러온 뒤 판정합니다.1. 가드 작성하기
srvkit/guards.ts에 다른 가드와 나란히 추가합니다:apps/koyo/srvkit/guards.ts
이 본문에서 다음 네 가지는 이 모델의 사정이 아니라 패턴입니다:
- 관리자 우회를 맨 앞에 둡니다. 관리자는 레코드의 소유자가 아니므로, 소유권 검사를 우회보다 위에 두면 관리자 콘솔이 자기 데이터에서 잠깁니다.
- 가리키는 리소스가 없으면
false입니다. 여기서true를 돌려주면 인자를 빠뜨린 모든 호출이 통과합니다. - 로드가 실패하면 warn을 남기고
false입니다. 데이터베이스가 잠깐 흔들린 것이 허가로 읽혀서는 안 되고, warn은 가드가 엉뚱한 이유로 거절하고 있다는 유일한 흔적입니다. static name은 지우지 않습니다. 죽은 코드처럼 보이지만, fetch가 가드 이름을 모든 엔드포인트에 직렬화하고 API 탐색기가 그 이름으로 거릅니다. 지우면 그 UI가 깨집니다.
리소스 가드뿐 아니라 직접 만드는 모든 가드에 세 가지가 더 적용됩니다:
- 호출자는
context.get("account")로 읽습니다. HTTP, 웹소켓, MCP 어디서든 같은 값을 주지만,getHttpContext()로 분기하면 그렇지 않습니다. - 부수 효과를 만들지 않습니다. 웹소켓의 자격 증명이 바뀌면, 그 소켓이 구독한 모든 방의 가드가 요청 밖에서 다시 실행됩니다.
- 인스턴스에 호출별 상태를 두지 않습니다. 가드 클래스마다 인스턴스 하나가 모든 호출을 처리하므로, 한 호출에서 쓴 필드를 다음 호출이 봅니다.
2. 엔드포인트에 적기
엔드포인트의
guards 배열에서 역할 가드 뒤에 둡니다:apps/koyo/lib/icecreamOrder/icecreamOrder.signal.ts
순서가 중요합니다. 가드는 선언 순서대로 실행되고 첫 거절에서 멈추므로,
Every가 비로그인 호출자에게 먼저 401로 답하고 레코드는 불러오지도 않습니다.서로 독립된 두 개의 문
- 가드는 모델을 가진 라이브러리와 함께 배포됩니다. 그 라이브러리의 signal이 직접 import하므로, 라이브러리를 마운트한 앱은 권한 검사를 그대로 물려받고 빠뜨릴 수 없습니다.
- 그래도 서비스는 소유권을 한 번 더 확인합니다. 서비스 메서드는 다른 서비스, cron 트리거, 큐 작업에서도 불리고, 이 경로들은 가드를 거치지 않습니다.
호출자 정보는 서버가 넣어 줍니다
가드는 호출을 실행할지 정하고, 내부 인자는 누가 실행하는지 핸들러에 알려 줍니다.
.with(...)는 가드를 통과한 뒤 미들웨어가 검증해 둔 account에서 그 값을 꺼내며, 요청 본문에서 읽는 일은 없습니다.호출자를 읽는 엔드포인트 두 개입니다:
apps/koyo/lib/icecreamOrder/icecreamOrder.signal.ts
.with(Self)가listMyIcecreamOrders에 로그인한 사용자를 넘겨주므로, 클라이언트는 id를 보내지 않습니다.{ nullable: true }를 붙였기 때문에refundIcecreamOrder는 관리자가 아니어도 실행됩니다. 붙이지 않으면 값이null일 때 401 Unauthorized로 답하며, 이것이 안전한 기본값입니다..with(AgentCall)는 에이전트도 환불할 수 있게 두되, 에이전트가 호출하면 고객 메일은 보내지 않습니다. 에이전트를 아예 막으려면guards: [Every, Person]을 씁니다.
기본 제공 내부 인자
앞의 네 개는
@libs/shared/srvkit에 있습니다. 직접 만들 때도 같은 모양으로, getArg(context) 하나를 가진 클래스를 srvkit/에 둡니다.내부 인자설명
.with(Self)
로그인한 사용자, 없으면
null입니다. 사용자용 엔드포인트에서 기본으로 씁니다..with(Me)
로그인한 관리자, 없으면
null입니다. Self와 Me는 한 신원의 두 역할이 아니라, 한 account의 두 신원입니다..with(Account)
account 전체입니다. 두 신원을 한꺼번에 봐야 하는 핸들러에서 씁니다.
.with(AgentCall)
사람이 아니라 에이전트가 호출하고 있으면
true입니다. 누가 호출할 수 있는지가 아니라 그 호출이 무엇을 일으킬지를 좁힙니다.CurrentUserId
워크스페이스 스캐폴드가
srvkit/internalArgs.ts에 넣어 주는 인자로, id만 필요한 핸들러용입니다.IpWsReqRes
akanjs/signal에 있습니다. 호출자의 IP, 웹소켓, 가공하지 않은 HTTP 요청과 응답입니다.

호출자를 body나 param으로 받지 마세요. 클라이언트가 보낸
userId는 클라이언트가 고른 값입니다. 핸들러는 그 값을 호출자 자신의 id와 구분할 수 없고, 이미 통과한 가드도 그 값에 대해서는 아무것도 보장하지 않습니다.가드가 에이전트 공개도 정합니다
모든 signal은 기본으로 마운트되는
POST /mcp에서 MCP 서버로도 AI 에이전트에게 제공됩니다. 엔드포인트별로 켜는 스위치는 없고, 이미 적은 가드가 무엇을 공개할지 정합니다.가드가 이미 권한 결정이므로 스위치를 하나 더 두어도 더해지는 것이 없습니다. 오히려 나중에 추가되는 엔드포인트마다, 누군가 스위치를 켜기 전까지 에이전트에게 보이지 않게 될 뿐입니다.
엔드포인트
HTTP
MCP
가드가 정하는 것
guards: [Every]
✓
✓
실제로 판정하는 가드가 있으면 공개되고, 양쪽 모두에서 호출마다 판정하는 것도 그 가드입니다.
guards 없음
✓
HTTP로는 누구나 부를 수 있고, 에이전트에게는 공개되지 않습니다. 익명 접근이 의도라면
guards: [Public]이라고 적으세요.query · [Public]
✓
✓
익명 접근을 명시한 것이므로 query는 공개됩니다.
mutation · [Public]
✓
공개되지 않습니다. mutation의
[Public]은 가드가 없다는 말을 적어 둔 것과 같습니다.[Every, Person]
✓
Person은 static agents = false를 선언하므로, 호출자별로 숨는 것이 아니라 카탈로그에서 아예 빠집니다.직접 고르는 것
mcp: false
✓
가드는 그대로 두고 에이전트 목록에서만 내립니다. 권한이 아니라 큐레이션이라 HTTP는 이전처럼 제공합니다.
mcp: { cru: false }
✓
slice()에서 같은 일을 하려면 guards 맵과 키가 같은 맵으로 적습니다.✓제공제외


거절 메시지를 더 친절하게 만들지 마세요. 공개되지 않은 엔드포인트는 존재하지 않는 엔드포인트와 똑같은 unknown tool 에러로 답하고, 가드의 401·403도 에이전트에게는 똑같은 문장 하나로 전달됩니다. “그런 툴은 없다”와 “그 툴은 부를 수 없다”의 차이가 바로 비공개 표면을 드러내는 단서입니다.
함께 볼 페이지