테마
bp-manifest.yaml 레퍼런스
업무패키지(Business Package, 이하 BP) 산출물 디렉터리 최상단에 놓이는 설치 서술자다. 포탈이 코드를 적재하기 전에 읽는 유일한 파일이며, 그래서 여기 저장되는 것은 "적재 여부를 판단하는 데 필요한 것"뿐이다.
정본 예제: examples/task-bp/bp-manifest.yaml(선언)과 examples/task-bp/scripts/gen-manifest.ts(타입 검사되는 생성).
무엇이 여기 오고 무엇이 코드에 남나
| 어디에 | 왜 | |
|---|---|---|
| 정체성(id/name/version/의존성), 산출물 위치, 호환성 | bp-manifest.yaml | 코드를 적재하기 전에 읽어야 판단할 수 있다 |
| 화면(apps) · 메뉴 배치 · 카드 · 권한 · 알림 카테고리 · 화면 배포 방식 | register() 반환값 | 코드와 함께 변한다. YAML에 두면 어긋나고, 어긋나도 아무도 인지하지 못한다 |
App을 하나 추가하면 그 App을 처리하는 컨트롤러도 함께 생긴다. 두 개가 다른 파일에 있으면 반드시 언젠가 어긋난다 — 그래서 능력 표면은 코드가 반환한다.
같은 이름의 값이 두 곳에 있고 쓰임이 다른 항목이 하나 있다. frontend.mode(YAML)와 frontendDeliveryMode(코드)다. 포탈이 실제로 읽는 것은 코드 쪽이며, YAML의 frontend에서 포탈이 읽는 값은 bundle 하나다(§frontend).
전체 예시
yaml
apiVersion: 1 # 서술자 포맷 버전. 포탈이 모르는 값이면 그 BP를 건너뛴다
id: task-bp # 포탈 전역 고유. 레코드 upsert 기준이므로 한 번 정하면 변경하지 않는다
name: 일감 관리
version: 1.0.0 # semver
mandatory: false # true면 관리자가 비활성화할 수 없다. 3rd-party BP는 사용하지 않는다
dependsOn: # 선택 — 다른 BP를 요구할 때만
- businessPackageId: hr
versionRange: "^2.0.0"
required: true # true면 이 BP가 비활성일 때 내 활성화가 차단된다
runtime:
hostContract: "^1.2.0" # 필수. 포탈의 HOST_CONTRACT_VERSION과 대조된다(아래 §runtime)
backend: # 필수 — 화면만 있는 BP도 백엔드 번들이 필요하다(§backend)
entry: backend/index.mjs # register()를 export하는 ESM 번들(설치 디렉터리 기준 상대경로)
database:
providers: [sqlite, postgresql] # 지원 방언. 실행 중인 방언이 없으면 적재를 건너뛴다
schemaDir: backend/prisma # <schemaDir>/<provider>/schema.prisma 를 찾는다
backwardCompatible: true # 선택(계약 2.1.0). 확장 마이그레이션이 이전 코드를 깨뜨리지 않는다는 선언. 기본 false
contractDir: backend/prisma-contract # 선택(계약 2.1.0). 정리 마이그레이션 루트. backwardCompatible: true 가 필수
frontend: # 선택 — Portal Shell(포탈 셸) 안에 화면을 렌더링하는 BP만
mode: csr-widget
bundle: frontend/bundle.jsprisma.config.ts는 이 산출물에 포함하지 않는다. 포탈이 자기 config 파일을 소유하며 BP마다 스키마·마이그레이션 경로만 환경변수로 전달한다 — backend-guide.md §7.
필드
최상위
| 필드 | 필수 | 타입 | 내용 |
|---|---|---|---|
apiVersion | ✅ | number | 현재 1. 포탈이 모르는 값이면 그 BP를 건너뛴다. 타입이 number라 2도 컴파일을 통과하므로 값 자체를 확인해야 한다 |
id | ✅ | string | 포탈 전역 고유 식별자. 레코드 upsert 기준이라 변경하면 기존 설치와 이어지지 않는다. 설치 디렉터리 이름과 위젯 번들 서빙 경로에도 쓰이므로 URL에 그대로 넣을 수 있는 문자만 사용한다 |
name | ✅ | string | 관리자 화면에 표시되는 이름 |
version | ✅ | string | semver. 이 문자열이 처음 등장할 때마다 onInstall이 재실행된다(semver 비교가 아니라 문자열 일치 판정이다) — backend-guide.md §8. 타입이 string이라 비-semver 값도 컴파일을 통과한다 |
mandatory | boolean | true면 비활성화 자체가 차단된다. 포탈 내장 BP용이며 3rd-party BP는 사용하지 않는다 | |
dependsOn | 배열 | 아래 참고 |
dependsOn[]
| 필드 | 필수 | 타입 | 내용 |
|---|---|---|---|
businessPackageId | ✅ | string | 요구하는 BP의 id |
versionRange | ✅ | string | semver 범위 |
required | ✅ | boolean | true면 그 BP 없이는 활성화되지 않고, 그 BP를 비활성화하려 할 때 차단된다. 생략할 수 없다 |
runtime
| 필드 | 필수 | 타입 | 내용 |
|---|---|---|---|
hostContract | ✅ | string | 이 BP가 요구하는 호스트 계약 semver 범위. 자세히는 host-contract.md §4 |
어느 범위를 선언할지는 실제로 사용하는 표면이 정한다. 이 SDK가 공표하는 계약 버전은 1.5.0이며, 1.0.0에 멤버를 추가한 뒤(1.1.0) 그 표면들의 경계를 좁히고(1.2.0) 서버가 만든 파일의 저장 통로와 Destination 차단 사유의 사용자 토큰 실패를 더하고(1.3.0) 설치된 기능의 확인과 첨부 삭제의 미설치 거부를 더하고(1.4.0) 스프레드시트·이미지 기능의 대여와 위젯의 기능 확인을 더한(1.5.0) minor 상승이다. 대상 포탈이 제공하는 버전은 GET /bp/runtime-info로 확인한다(host-contract.md §5).
| 이 BP가 사용하는 것 | 선언 | 이유 |
|---|---|---|
| 계약 1.0.0의 표면만 | "^1.0.0" | 1.0.0 포탈과 그 이후 포탈 모두에 적재된다 |
| 계약 1.1.0에서 추가된 표면 | "^1.1.0" | ^1.0.0을 선언하면 1.0.0만 제공하는 포탈에도 적재되고, 그 표면을 호출하는 시점에 실행 오류가 발생한다. 게이트가 미리 걸러내도록 요구 범위를 올린다 |
host.files.claim() | "^1.2.0" | 1.2.0에서 두 번째 인자가 업로더 번호에서 요청 객체로 바뀌었다. ^1.1.0인 채 1.1.0 포탈에 설치하면 결속이 항상 실패하고, 그 실패가 오류 메시지 없이 "첨부가 저장되지 않는" 형태로만 나타난다 |
host.files.create() | "^1.3.0" | 1.3.0에서 추가됐다. 1.2.0 포탈에는 이 함수가 없어 호출하는 시점에 실행 오류가 발생한다 |
host.features | "^1.4.0" | 1.4.0에서 추가됐다. 1.3.0 이하 포탈에는 이 멤버가 없어 호출하는 시점에 실행 오류가 발생한다. 게이트가 유일한 방어다 |
host.workbook · host.images | "^1.5.0" | 1.5.0에서 추가됐다. 1.4.0 이하 포탈에는 이 멤버가 없어 호출하는 시점에 실행 오류가 발생한다. 게이트가 유일한 방어다 |
위젯의 features(hasHostFeature) | "^1.5.0" | 1.5.0에서 추가됐다. 구버전 셸에서는 답이 false라 실행 오류는 나지 않지만, 기능이 설치된 배포에서도 버튼이 감춰진다 |
대여의 상한·판별 함수(BP_RENTAL_LIMITS, bpRentalRejectReason 등)만 | 올리지 않는다 | SDK에 들어 있는 값과 함수이며 포탈 멤버가 아니다 |
미설치 거부의 판별 함수(isBpCapabilityNotInstalled 등)만 | 올리지 않는다 | SDK에 들어 있는 함수이며 포탈 멤버가 아니다. 1.3.0 포탈도 files.create·read의 미설치 거부를 같은 형태로 던진다 |
| 2.0.0 포탈에 설치하는 모든 BP | "^2.0.0" | 2.0.0에서 데이터 접속이 전용 계정과 전용 이름공간으로 바뀐다. 2.0.0 포탈은 1.x 범위를 선언한 BP를 적재하지 않는다(host-contract.md §4 변경 이력) |
| Destination 차단 사유의 사용자 토큰 실패 넷(계약 1.3.0) | 올리지 않는다 | 응답 헤더의 값이 늘었을 뿐이다. 1.2.0 이하 포탈에서는 그 값이 오지 않으므로 처리 코드가 있어도 해가 없다 |
설치 컨텍스트의 onExisting | "^1.3.0" | 1.3.0에서 추가됐다. 1.2.0 포탈은 이 필드를 오류 없이 무시하고 덮어쓴다 — "keep"을 지정해도 관리자가 고친 공통코드가 시드 값으로 되돌아간다 |
1.1.0에서 추가된 표면은 호스트 런타임의 notifications / codes / users / destinations / services / audit / files(backend-guide.md §13~§19), 위젯 호스트 컨텍스트의 viewer / canWrite와 호스트 액션 4종(frontend-guide.md §3·§6), 그리고 아래 notificationCategories[]다.
정본 예제 examples/task-bp는 이 중 notifications / codes / users / destinations / audit / files와 notificationCategories[], 그리고 위젯의 viewer / canWrite와 호스트 액션을 사용하고, 계약 1.5.0의 workbook과 위젯의 features로 일감 목록 내보내기를 하므로 "^1.5.0"을 선언한다. 선언 값은 그 예제의 scripts/gen-manifest.ts가 소유하며, 산출물에서 확인한다.
bash
grep -A1 '^runtime:' examples/task-bp/bp-manifest.yaml대상 포탈이 제공하는 값은 GET /bp/runtime-info로 조회한다 — host-contract.md §5.
backend
backend 객체 자체가 필수다. 화면만 있고 서버 로직이 없는 BP도 backend.entry가 필요하다 — 화면 목록(apps[])·메뉴·권한이 전부 register() 반환값으로만 등록되기 때문이다. 그런 BP는 컨트롤러 없이 manifest만 반환하는 최소 register()를 담은 번들을 만든다.
| 필드 | 필수 | 타입 | 내용 |
|---|---|---|---|
entry | ✅ | string | register()를 export하는 ESM 번들 경로 |
database.providers | ("sqlite"|"postgresql")[] | 이 BP가 지원하는 DB 방언. 생략하면 sqlite만 지원하는 것으로 본다. 포탈은 접속 URL이 file:로 시작하면 sqlite, 그 외 전부를 postgresql로 판정한다 | |
database.schemaDir | string | 방언별 스키마·마이그레이션 루트. 생략하면 포탈이 이 BP의 마이그레이션을 적용하지 않는다(BP가 직접 관리하겠다는 선언) | |
database.backwardCompatible | boolean | 계약 2.1.0. 아직 적용하지 않은 마이그레이션(확장 마이그레이션)이 이전 버전 코드를 깨뜨리지 않는다는 선언. 기본값은 false다. true이면 포탈이 반입할 때와 적용하기 직전에 문장을 검사하고 위반이면 거부한다 | |
database.contractDir | string | 계약 2.1.0. 정리 마이그레이션 루트. 모든 포탈 인스턴스가 새 버전을 적재한 뒤 포탈이 실행한다. backwardCompatible: true가 없으면 반입이 거부된다 |
schemaDir을 쓸 때 배치 규칙이 하나 있다 — schema.prisma와 migrations/가 같은 폴더에 있어야 하고, 폴더 이름은 sqlite / postgresql 리터럴이어야 한다.
backend/prisma/
sqlite/{schema.prisma, migrations/<타임스탬프>_<이름>/migration.sql, migrations/migration_lock.toml}
postgresql/{schema.prisma, migrations/<타임스탬프>_<이름>/migration.sql, migrations/migration_lock.toml}Prisma CLI에 마이그레이션 경로를 따로 지정하는 옵션이 없고 스키마 파일과 같은 디렉터리에서 찾기 때문이다. 마이그레이션 디렉터리 이름의 규칙과 방언별 적용 방식의 차이는 backend-guide.md §7에 있다.
backwardCompatible과 contractDir (계약 2.1.0)
포탈은 업그레이드할 때 새 코드가 기동하기 전에 마이그레이션을 적용한다. 그래서 포탈 인스턴스를 한 대씩 재시작하는 동안이나 새 버전이 자동으로 되돌려진 뒤에는 이전 버전 코드가 새 스키마에서 동작한다. 두 키는 이 상황을 다룬다. 왜 필요하고 마이그레이션을 어떻게 나누는지는 backend-guide.md §7-5에 있다.
backwardCompatible: true를 선언하면 포탈이 확장 마이그레이션을 검사한다. 이미 있던 테이블에서 열·테이블 삭제, 이름 변경, 자료형 변경,SET NOT NULL·DROP DEFAULT, 기본값 없는NOT NULL열 추가, 제약 추가,DELETE를 찾으면 반입이 거부된다. 통과한 BP는 업데이트가 실패해 이전 버전으로 되돌려져도 포탈이 업데이트 중 거부를 풀고 이전 버전으로 다시 서비스한다. 선언하지 않으면 되돌린 뒤에도 운영자가 풀 때까지 그 BP를 사용할 수 없다.contractDir에는 이전 코드를 깨뜨리는 변경(정리 마이그레이션)을 둔다. 확장 마이그레이션과 같은 구조다.
backend/prisma/ ← schemaDir(확장 마이그레이션)
sqlite/{schema.prisma, migrations/20261101090000_add-assignee-name/migration.sql, …}
postgresql/{schema.prisma, migrations/20261101090000_add-assignee-name/migration.sql, …}
backend/prisma-contract/ ← contractDir(정리 마이그레이션)
sqlite/migrations/20261101090500_drop-assignee/migration.sql
postgresql/migrations/20261101090500_drop-assignee/migration.sqlcontractDir의 규칙은 다음과 같다. 어기면 포탈이 반입을 거부한다.
backwardCompatible: true가 함께 있어야 한다.- 설치 디렉터리 기준 상대 경로이고,
schemaDir과 같거나 서로 포함하면 안 된다. 위 예처럼 형제 폴더로 둔다. - 선언한 방언마다
<contractDir>/<방언>/migrations/가 있어야 하고, 폴더 이름이 방언끼리 같아야 한다.schema.prisma는 두지 않는다. - 확장 마이그레이션과 같은 폴더 이름을 쓰지 않는다.
- 폴더 이름은 같은 릴리스의 확장 마이그레이션보다 뒤에 정렬되어야 한다. 포탈은 확장과 정리를 폴더 이름 순으로 합쳐 재생해 이전 코드가 아는 테이블을 계산한다. 위 예에서
20261101090500_drop-assignee는20261101090000_add-assignee-name뒤에 온다. - 이미 적용한 정리 마이그레이션도 다음 버전의 아카이브에 남겨 두고 고치지 않는다.
두 키를 아카이브 매니페스트(starj-package.json)에 옮기는 것은 starj package pack이며 starj CLI 0.3.0 이상이 필요하다. 0.2.0 이하로 묶으면 두 키가 빠지고 포탈이 V2로 거부한다. 두 키 모두 2.0.0 포탈은 읽지 않으며 오류도 내지 않는다. 2.0.0 포탈은 정리 마이그레이션을 실행하지 않는다. 개발 하네스(bp-dev-shell)도 두 키를 읽지 않는다.
frontend
선택이다. 이 블록에서 포탈이 실제로 읽는 값은 bundle 하나뿐이다. mode는 포탈이 읽지 않으며, 화면 배포 방식을 결정하는 것은 register() 반환값의 frontendDeliveryMode다. 두 값을 같게 유지하는 것은 BP의 책임이며, 예제의 배포 전 점검이 이 일치를 검사한다.
| 필드 | 필수 | 타입 | 내용 |
|---|---|---|---|
mode | ✅(블록이 있으면) | string | 코드의 frontendDeliveryMode와 같은 값을 적는다. 포탈은 이 값을 읽지 않는다 |
bundle | ✅(블록이 있으면) | string | csr-widget 번들 경로. 포탈이 실행 시점에 서빙한다. 타입상 모든 모드에서 필수다 |
번들 경로가 선언되어 있는데 그 파일이 없으면 포탈 부팅 로그에 경고가 남고 화면 적재만 실패한다. 반대로 frontend 블록 자체를 생략하고 코드에서 frontendDeliveryMode: "csr-widget"을 선언하면 경고조차 남지 않는다 — 아래를 참고한다.
frontendDeliveryMode — 화면 배포 방식
register() 반환값의 manifest.frontendDeliveryMode가 화면 배포 방식을 결정한다. Portal Shell(포탈 셸)은 App 화면을 이 값 하나로 분기하며, transport는 보지 않는다. 타입은 네 값을 허용하지만 실제 동작은 아래와 같다.
| 값 | 실제 동작 |
|---|---|
csr-widget | 동작한다. Portal Shell이 이 값에서만 위젯 마운트 경로로 분기한다. 제3자 BP가 화면을 제공하는 유일한 방식이다 |
ssr | 선언하지 않았을 때의 포탈 기본값이다. 다만 Portal Shell은 이 값으로 분기하지 않고 포탈 내장 BP를 이름으로 판별한다. 제3자 BP가 이 값을 선언하면 화면 자리에 "연동되지 않은 App" 안내 패널이 표시된다 |
iframe | 외부 BP(transport: "external")가 화면을 노출하는 방식이다. Portal Shell이 App 영역에 iframe을 두고 /portal/app/dest/{BP id}/{App uri에서 BP id 접두어를 뺀 경로}?domainId={현재 도메인}을 연다. 이 경로가 동작하려면 BP id와 같은 이름의 Destination(외부 연결처)이 그 도메인에서 쓸 수 있어야 하고, 용도가 FRONTEND_IFRAME 또는 BOTH이며, 소유 업무패키지가 이 BP여야 한다. 셸은 domainId를 iframe의 첫 문서 URL에만 추가하므로, iframe 안의 화면이 내는 모든 프록시 경로 요청에는 BP가 직접 domainId를 추가한다(아래 §iframe 화면의 하위 요청). transport: "external"만 선언하고 이 값을 선언하지 않으면 iframe이 아니라 안내 패널이 표시된다. iframe 안의 화면에는 호스트 컨텍스트가 전달되지 않고, Portal Shell과 메시지를 주고받는 규약도 없다 |
external-proxy | 미구현이다. 포탈 코드가 이 값을 읽지 않는다 |
연결할 Destination이 없는 iframe App은 "연동되지 않은 App" 안내 패널을 표시한다. 서버는 사용자에게 App 목록을 내릴 때 위 세 조건(이름이 BP id, 용도 FRONTEND_IFRAME/BOTH, 소유 업무패키지가 이 BP)을 그 도메인에서 확인한다. 하나라도 맞지 않으면 frontendDeliveryMode를 unbound로 바꿔 내리고, Portal Shell은 iframe 대신 안내 패널을 표시한다. 이름이 같아도 소유 업무패키지가 지정되지 않았거나 다른 BP가 소유한 Destination은 연결로 인정하지 않는다. 메뉴는 이 판정과 무관하게 권한과 활성화로만 정해지므로, 연결이 없어도 메뉴에는 보이고 열면 안내 패널이 표시된다.
iframe 화면의 하위 요청에는 domainId를 추가한다
외부 BP는 자신이 내는 모든 프록시 경로 URL(/portal/app/dest/{BP id}/…)에 셸에서 받은 domainId를 추가한다. XHR·fetch 호출뿐 아니라 스크립트·스타일·이미지 같은 자산 URL도 포함한다. 셸은 첫 문서 URL에만 ?domainId=를 추가하고, 포탈은 Referer나 쿠키로 도메인을 추정하지 않는다. domainId가 없는 요청은 호출한 사용자의 소속 도메인으로 해석된다. 그 도메인에서 이 BP가 비활성이면 요청은 404가 된다. 단일 도메인 사용자에게는 증상이 없고 다중 도메인 사용자에게만 404로 드러나므로 개발 중에는 발견하기 어렵다. 포탈이 게시할 예정인 @starj-portal/sap-odata-client(ADR-078, 아직 게시 전)의 proxyUrl은 domainId를 인자로 받도록 설계되어 있으므로, 게시된 뒤 그 라이브러리를 쓰면 이 값을 넘긴다.
브라우저 경유 프록시는 다음 순서로 판정한다.
- 사용자가 요청한 도메인에 접근할 수 없으면 403이다.
- 그 도메인에서 쓸 Destination을 해석한다. 없으면 404다.
- 소유 업무패키지가 그 도메인에서 비활성이면 404다. 존재하지 않는 대상과 같은 응답이며, 접근할 수 없는 도메인의 활성 여부를 드러내지 않으려고 이 순서를 둔다. 도메인을 정할 수 없는 요청도 같은 404로 막는다.
이 활성 검사는 브라우저 경유 프록시에만 걸린다. 포탈 안에서 실행되는 BP의 서버측 호출(host.destinations.fetch)에는 적용되지 않는다.
화면이 없는 BP는 csr-widget을 선언하지 않는다
화면 없이 API·DB만 제공하는 BP는 frontend 블록을 생략한다. 그런데 코드에서 frontendDeliveryMode: "csr-widget"을 선언하면 다음이 벌어진다.
- 적재 시점: 정상적으로 적재된다. 번들 경로가 없다는 경고는
frontend.bundle이 선언되어 있을 때만 출력되므로 어떤 경고도 남지 않는다. 부팅 로그는 건너뛴 BP가 없다고 표시한다. 컨트롤러·App 카탈로그·메뉴·기본 권한은 전부 정상 등록된다. - 렌더 시점: 사용자가 그 App을 열면 Portal Shell이 빈 영역을 만들고 위젯 번들을 요청하는데, 포탈이 404를 반환한다. Portal Shell은 이 실패에 대한 오류 화면도 재시도도 제공하지 않는다.
- 결과: 브라우저 콘솔의 처리되지 않은 promise 거부와, 영구히 비어 있는 화면 영역이다. 사용자에게는 아무 메시지도 표시되지 않는다.
따라서 화면이 없는 BP는 frontendDeliveryMode를 "ssr"(포탈 기본값) 으로 선언한다. 그러면 그 App을 열었을 때 "연동되지 않은 App" 안내 패널이 표시된다. 화면이 없는 App을 메뉴에 노출하지 않으려면 apps[].dispYn: false를 함께 선언한다 — App 선언 자체는 생략할 수 없다. 권한 판정(checkAppAccess)의 단위가 App이기 때문이다.
transport — 배포 형태 축
register() 반환값의 manifest.transport는 "in-process"(기본) / "microservice:redis" / "microservice:grpc" / "external" 중 하나다. 3rd-party BP는 이 필드를 선언하지 않는다 — 생략하면 in-process로 조립되며, 이 문서 집합이 다루는 것은 그 형태다.
transport: "external"은 포탈 프로세스 밖에 있는 별도 스택 BP를 Destination(외부 연결처) 프록시로 연결하는 형태이며, 별도 안내를 받아 진행한다. 이 값은 화면 렌더링 방식을 정하지 않는다. 외부 BP가 화면을 노출하려면 frontendDeliveryMode: "iframe"을 함께 선언해야 한다(위 §frontendDeliveryMode). 포탈은 이 BP로 가는 요청에 BP id와 같은 이름의 Destination을 쓴다. 외부 매니페스트(ExternalBusinessPackageManifest)의 destinationName은 폐기 예정 필드이며 포탈이 읽지 않는다. 생략하고, 적더라도 BP id와 같게 둔다. 다른 값이면 포탈이 BP_MANIFEST_INVALID로 부팅을 멈춘다. 외부 BP의 iframe 화면에는 호스트 컨텍스트가 전달되지 않고, 라이프사이클 훅도 호출되지 않는다. 홈 인사이트 카드의 데이터 경로는 /bp/{BP id}가 아니라 /portal/app/dest/{BP id}로 시작한다.
외부 BP의 id는 Destination 이름 규칙을 따라야 한다. 포탈은 Destination 이름을 ^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$로 제한한다(포탈 ADR-097). 영문·숫자로 시작하고 영문·숫자·.·_·-만 쓰며 최대 64자다. 대소문자는 구분한다. 포탈은 Destination을 새로 만들거나 이름을 바꿀 때 이 규칙을 검사하며, 규칙 도입 전에 저장된 이름은 그대로 동작한다. 외부 BP의 id가 이 규칙을 벗어나면 운영자가 그 이름으로 Destination을 새로 등록할 수 없으므로, 포탈이 그 BP로 가는 요청을 보낼 곳이 없다. 외부 BP를 만들 때는 id를 이 규칙 안에서 정한다.
타입으로 확인하기
스키마는 SDK가 타입으로도 보관한다. 매니페스트를 TypeScript로 생성하면 오탈자와 잘못된 열거값이 컴파일 단계에서 검출된다. 정본: examples/task-bp/scripts/gen-manifest.ts.
ts
import type { BpInstallManifest } from "@starj-portal/bp-backend-sdk";
import { BP_MANIFEST_API_VERSION } from "@starj-portal/bp-backend-sdk";
const manifest: BpInstallManifest = {
apiVersion: BP_MANIFEST_API_VERSION,
id: "task-bp",
name: "일감 관리",
version: "1.0.0",
runtime: { hostContract: "^1.2.0" },
backend: { entry: "backend/index.mjs" },
};타입이 검출하는 것과 검출하지 못하는 것을 구분해 둔다.
| 오류 | 타입이 검출하는가 |
|---|---|
존재하지 않는 필드 이름(entrypoint 등) | ✅ |
잘못된 열거값(providers: [mysql], mode: csr_widget) | ✅ |
필수 필드 누락(dependsOn[].required 등) | ✅ |
apiVersion: 2 | ✖ 타입이 number다 |
비-semver version | ✖ 타입이 string이다 |
hostContract 범위가 대상 포탈과 어긋남 | ✖ 포탈 기동 시점에만 판정된다 |
값의 범위까지 확인하려면 배포 전 점검을 사용한다 — 정본은 examples/task-bp/scripts/preflight.mjs다. 매니페스트를 검증하는 CLI나 JSON Schema는 SDK가 제공하지 않는다.
능력 표면 — register()가 반환하는 쪽
매니페스트 파일에 없는 것들이다.
| 필드 | 필수 | 내용 |
|---|---|---|
apps[] | ✅ | 화면 카탈로그. 아래 필드 표 참고 |
menu[] | ✅ | 메뉴 배치 — id / parentId / appId 또는 name / sortNo. apps와 분리된 목록이다 |
cards[] | 홈 인사이트 카드. 필드 표와 데이터 응답 형태는 backend-guide.md §5 | |
permissions[] | ✅ | 권한 스코프 키. 설명용이며 자동 프로비저닝에 사용되지 않는다 |
defaultAuthorities[] | 설치 시 자동 생성되는 권한 템플릿. rw 값은 backend-guide.md §4 | |
notificationCategories[] | 이 BP가 발행하는 알림의 종류(계약 1.1.0). 아래 §notificationCategories 참고 | |
frontendDeliveryMode | ✅ | 위 §frontendDeliveryMode 참고 |
transport | 위 §transport 참고 |
apps[]
| 필드 | 필수 | 내용 |
|---|---|---|
id | ✅ | 안정 식별자이자 레지스트리 upsert 기준. 권한·카드·메뉴가 이 값을 참조한다. 변경하면 기존 권한 부여와 이어지지 않는다 |
name | ✅ | 메뉴·카탈로그에 표시되는 이름 |
uri | ✅ | 포탈 전역에서 고유해야 한다. App 경계 URL /apps/{domainId}/{appUri}의 마지막 세그먼트이며 슬래시를 포함할 수 있다. {businessPackageId}/ 접두어로 시작해야 한다(예: hr/employees). Portal Shell이 이 접두어를 제거해 하위 경로를 재조립하므로 관례가 아니라 요구사항이다 |
moduleCode | ✅ | 의미가 확정되어 있지 않다. 포탈은 이 값을 App 레코드에 저장만 하고 검증하거나 분기에 사용하지 않는다. 포탈 내장 BP는 "ADM" 같은 짧은 대문자 코드를 사용한다. 같은 BP 안에서는 같은 값을 쓰고, 확정된 규칙이 필요하면 포탈 운영자에게 확인한다 |
dispYn | 메뉴·카탈로그 노출 여부. 생략 시 노출된다. 화면이 없는 App이나 내부용 App은 false로 선언한다 |
menu[]
| 필드 | 필수 | 내용 |
|---|---|---|
id | ✅ | 안정 식별자이자 upsert 기준 |
parentId | 같은 매니페스트 안의 다른 menu[].id. 다른 BP의 메뉴 아래에는 배치할 수 없다 | |
name | 메뉴그룹(폴더)일 때만 사용한다(appId가 없을 때) | |
appId | leaf 배치일 때만 사용한다. 같은 매니페스트의 apps[].id를 참조한다 | |
sortNo | 형제 노드 사이의 정렬 순서 |
name과 appId는 배타적이다. 관리자가 이미 이동시킨 메뉴 노드는 다음 기동 시 상위 노드와 정렬 순서를 덮어쓰지 않는다. 매니페스트에서 제거된 App과 그것을 참조하는 메뉴는 삭제되지 않고 숨김 처리된다.
notificationCategories[]
계약 1.1.0에서 추가된 선택 필드이며, bp-manifest.yaml이 아니라 register() 반환값 쪽에 선언한다. 이 BP가 발행하는 알림의 종류를 포탈의 알림 카테고리 카탈로그에 등록한다.
ts
notificationCategories: [
{ key: "eaccounting.approval-requested", name: "결재 요청", defaultSubscribed: true, sortNo: 1 },
]| 필드 | 필수 | 타입 | 내용 |
|---|---|---|---|
key | ✅ | string | 알림 발행 시 사용하는 category 값. BP id를 접두어로 두는 것을 권장한다(예: eaccounting.approval-requested). 카탈로그가 포탈 전역의 유일 키이고 포탈이 접두어를 강제하지 않으므로, 다른 BP나 포탈 코어가 이미 사용하는 키를 선언하면 그 카테고리의 소유와 표시 이름이 이 BP의 것으로 교체된다. 권장이 아니라 자기 방어로 취급한다 |
name | ✅ | string | 관리 화면과 사용자 수신 설정 화면에 표시되는 이름 |
description | string | 그 알림이 언제 발행되는지에 대한 설명 | |
defaultSubscribed | boolean | 사용자가 별도 설정을 하지 않았을 때 수신하는가(기본 true). 참고용 알림은 false로 선언한다 | |
sortNo | number | 설정 화면에서의 정렬 순서 |
선언하지 않으면 무슨 일이 일어나는가. 포탈은 카탈로그에 없는 category 문자열로 발행해도 거부하지 않는다. 즉 선언이 없어도 알림은 전달된다 — 다만 관리 화면의 카테고리 목록과 사용자의 수신 설정 화면에 나타나지 않아 사용자가 수신을 비활성화할 수 없는 알림이 된다. 설치 시 이 목록이 포탈 카테고리로 upsert되어 그 문제를 해소한다.
- 매 부팅의 설치(탐색) 단계에서 매니페스트 기준으로 재동기화된다 — App·카드와 같은 규칙이다(install-to-portal.md §3).
description/defaultSubscribed/sortNo도 함께 반영된다. - 선언에서 제거된 카테고리는 삭제되지 않고 비활성 상태로 전환된다. 사용자별 수신 설정과 관리자의 수신 권한 설정이
key를 참조하기 때문이다. 선언을 복원해도 자동으로 다시 활성화되지 않는다 — 활성 여부는 매니페스트가 선언하는 값이 아니라 관리자의 판단이며, 재기동이 그것을 되돌리지 않기 때문이다. 관리자가 알림 카테고리 관리 화면에서 다시 활성화할 때까지 그 카테고리로 발행한 알림은 계속null을 반환한다. - 발행 방법과 수신자 지정은 backend-guide.md §13에 있다.
함께 볼 것
- backend-guide.md — 능력 표면의 각 항목을 실제로 구현하는 방법
- host-contract.md — 버전 게이트와 호스트 제공 모듈