Community
안전한 공개 feed와 hosted 고객 댓글, 독립 readback을 완료합니다.
Community
준비
community와 의존 기능 customers를 활성화하세요. Published-visible이고 moderation으로 hidden/rejected가 아니며 잠기지 않고 allowsComments가 활성화된 글을 준비합니다. Moderation·지원 담당자를 정하세요. 공개 읽기는 Publishable Key, 참여는 같은 tenant의 고객 JWT를 사용하며 Secret Key가 고객을 대리하지 않습니다. Customers의 파일을 모두 복사해 hosted client·origin·redirect → state/PKCE callback → Secure HttpOnly session → me() → hosted logout을 연결합니다. 기존 direct 앱만 해당 가이드의 명시적 legacy 호환 절을 사용하세요.
예제는 공개 SDK 0.65.1, Next.js 16.3.8 App Router, React 19.2.4, Node 24.15.0 조합입니다. 자신의 앱에 npm install @01.software/sdk@0.65.1로 설치하세요. force-dynamic으로 SDK 호출을 build가 아닌 요청 때 실행합니다. cacheComponents와 static export를 쓰지 않는 구성입니다. 컴파일과 통제된 로컬 transport 검사는 hosted 제공 여부나 원격 tenant admission의 증거가 아닙니다.
공개 읽기와 고객 참여 구현
세 파일을 모두 복사하세요. Feed가 비었는지 먼저 확인하고 nullable detail도 따로 처리합니다. 공개 projection 필드만 표시하며 Server Action은 댓글 작성 전 신원을 확인하고 별도의 공개 댓글 조회를 수행합니다. 새 댓글의 기본값은 published/approved이며 사전 moderation 대기열이 아닙니다. 이후 moderation 변경이나 제한된 조회 페이지 때문에 생성한 댓글이 readback에 없을 수 있으므로 실제 조회 결과를 표시합니다.
// app/community/page.tsx
import { createClient } from '@01.software/sdk'
export const dynamic = 'force-dynamic'
export default async function CommunityPage() {
const client = createClient({
publishableKey: process.env.NEXT_PUBLIC_SOFTWARE_PUBLISHABLE_KEY!,
apiUrl: process.env.SOFTWARE_API_URL,
})
const feed = await client.community.listPosts({ limit: 10 })
if (feed.docs.length === 0) return <p>No public posts yet.</p>
const post = await client.community.getPost({
postId: String(feed.docs[0].id),
})
if (!post) return <p>This post is no longer available.</p>
const comments = await client.community.listComments({
postId: String(post.id),
limit: 10,
})
return (
<main>
<h1>{post.title}</h1>
{comments.docs.length === 0 ? (
<p>No public comments yet.</p>
) : (
<ul>
{comments.docs.map((comment) => (
<li key={comment.id}>{comment.body}</li>
))}
</ul>
)}
<a href={`/community/${encodeURIComponent(String(post.id))}`}>
Join the conversation
</a>
</main>
)
}// app/community/[postId]/actions.ts
'use server'
import { currentCustomer } from '@/lib/customer'
export async function addComment(postId: string, form: FormData) {
const { client, customer } = await currentCustomer()
if (!customer) return { status: 'sign_in_required' }
const body = String(form.get('body') ?? '').trim()
if (!body || body.length > 1000) return { status: 'invalid_comment' }
// Author/tenant/moderation fields are never supplied by the app.
const created = await client.community.createComment({ postId, body })
const comments = await client.community.listComments({
postId,
limit: 10,
sort: '-createdAt',
})
return {
status: comments.docs.some((comment) => comment.id === created.id)
? 'visible'
: 'submitted',
}
}// app/community/[postId]/page.tsx
import { createClient } from '@01.software/sdk'
import { notFound } from 'next/navigation'
import { revalidatePath } from 'next/cache'
import { addComment } from './actions'
export const dynamic = 'force-dynamic'
export default async function Conversation({
params,
}: {
params: Promise<{ postId: string }>
}) {
const { postId } = await params
const client = createClient({
publishableKey: process.env.NEXT_PUBLIC_SOFTWARE_PUBLISHABLE_KEY!,
apiUrl: process.env.SOFTWARE_API_URL,
})
const post = await client.community.getPost({ postId })
if (!post) return notFound()
const comments = await client.community.listComments({
postId,
limit: 10,
sort: '-createdAt',
})
async function submit(form: FormData) {
'use server'
const result = await addComment(postId, form)
if (result.status === 'sign_in_required') throw new Error('Sign in first')
if (result.status === 'invalid_comment')
throw new Error('Write a comment of 1–1000 characters')
revalidatePath(`/community/${encodeURIComponent(postId)}`)
}
return (
<main>
<h1>{post.title}</h1>
{comments.docs.length === 0 ? (
<p>No public comments yet.</p>
) : (
<ul>
{comments.docs.map((comment) => (
<li key={comment.id}>{comment.body}</li>
))}
</ul>
)}
<a href="/account">Account</a>
<form action={submit}>
<textarea name="body" required maxLength={1000} aria-label="Comment" />
<button>Submit comment</button>
</form>
</main>
)
}정상·빈 결과·실패
/community에서 글 0개, 이어서 공개 글 1개를 확인하세요. Feed 이후 사라진 글은 unavailable 상태입니다. 대화를 열고 /account에서 로그인한 뒤 댓글을 한 번 제출하고 listComments()와 새 페이지 로드로 확인합니다. 빈 댓글 목록과 요청 실패는 다릅니다. 제한된 첫 페이지에 새 댓글이 없을 수 있으므로 submitted는 거부·공개의 증거가 아닙니다. 쓰기를 반복하기 전에 해당 페이지를 readback하거나 moderation 지원을 요청하세요.
없음·moderation으로 hidden/rejected된 글·다른 tenant인 글은 canonical not_found 응답에 한해서 detail이 null입니다. 무관한 배포 404, auth·tenant·feature·네트워크 오류는 전파해야 합니다. 앱 읽기·action 오류 UI를 추가하고 모든 예외를 빈 feed로 바꾸지 마세요. 미로그인·다른 tenant 쓰기, hidden/rejected 글, banned 고객, locked 글과 댓글 비활성화를 검사합니다. Denial 후 새 댓글이 없어야 하며 HTTP 성공만으로 readback이 되지는 않습니다.
서버가 검증된 고객으로부터 author/profile·tenant를 정합니다. 앱은 moderation·author·counter·provenance 필드를 지정하지 않습니다. parent/reply는 같은 글에 속하고 nesting 제한을 지켜야 합니다. Raw posts, comments, reaction, report, ban은 server-only collection이며 브라우저 CRUD 대안이 아닙니다. 다른 client를 사용해 고객이 moderation 권한을 얻을 수 없습니다.
다음 액션
Moderation·지원은 콘텐츠 운영, reaction·bookmark는 SDK, 브라우저 origin·hosted session 검사는 배포를 따릅니다. 로그아웃 때 앱 쿠키 삭제와 hosted 서버 철회를 구분하세요. Customers를 참고합니다. 원격 tenant·denial·readback·cleanup과 hosted 여정은 같은 staging SHA에서 관찰할 때까지 pending입니다.