mirror of
https://github.com/hanzo-apps/cms.git
synced 2026-10-30 14:47:33 +00:00
Pages.access.read now returns {_status: published} for anonymous requests
and true for authenticated callers. The multi-tenant plugin composes this
(withTenantAccess): a logged-in caller stays org-scoped; an anonymous caller
has no user, so no tenant constraint is added and published docs of ANY tenant
are world-readable — correct for a headless CMS serving many brands' storefronts
(karma.style/journal fetches with no token). Drafts and every write stay gated
exactly as 3.86.1.
proof.ts step 4b: anon read sees published, hides drafts (+ by-id negative
control). Verified on fresh SQLite: publicRead=true.
Co-authored-by: Hanzo Dev <[email protected]>
229 lines
8.6 KiB
TypeScript
229 lines
8.6 KiB
TypeScript
/**
|
|
* Hanzo CMS — real-slice acceptance proof (headless, Local API).
|
|
*
|
|
* Proves, with real artifacts (no stubs):
|
|
* 1. @hanzo/cms boots on Base/SQLite (per-org libsql file)
|
|
* 2. a real media upload lands in SeaweedFS S3 (real object)
|
|
* 3. Hanzo IAM SSO: a REAL IAM token verifies via the auth strategy (JWKS)
|
|
* 4. draft -> publish (CMS-native versions)
|
|
* 5. per-org isolation (two orgs, separate SQLite, no cross-read)
|
|
*
|
|
* Run: HANZO_ORG=... S3_*=... IAM_TOKEN=... tsx scripts/proof.ts
|
|
*/
|
|
import { getCMS } from '@hanzo/cms'
|
|
import { hanzoIAMStrategy } from '@hanzo/cms-auth-iam'
|
|
import { readFileSync } from 'fs'
|
|
import path from 'path'
|
|
import { fileURLToPath } from 'url'
|
|
|
|
const dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
|
|
const ok = (m: string) => console.log(` ✓ ${m}`)
|
|
const step = (m: string) => console.log(`\n=== ${m} ===`)
|
|
const fail = (m: string) => {
|
|
console.error(` ✗ ${m}`)
|
|
process.exitCode = 1
|
|
}
|
|
|
|
const loadConfig = async () => (await import('../src/payload.config.js')).default
|
|
|
|
const run = async () => {
|
|
const org = process.env.HANZO_ORG || 'proofco'
|
|
process.env.HANZO_ORG = org
|
|
|
|
// ---- 1. BOOT on Base/SQLite (per-org) --------------------------------
|
|
step(`1. BOOT @hanzo/cms on Base/SQLite (org=${org})`)
|
|
const config = await loadConfig()
|
|
const cms = await getCMS({ config })
|
|
ok(`booted; db adapter = ${cms.db.name}`)
|
|
const dbUrl = (cms.db as unknown as { client?: { url?: string } }).client?.url
|
|
ok(`per-org SQLite = ${dbUrl ?? 'file (libsql)'}`)
|
|
|
|
// provision the tenant for this org (org == tenant). Idempotent.
|
|
const existingTenant = await cms.find({
|
|
collection: 'tenants',
|
|
limit: 1,
|
|
where: { slug: { equals: org } },
|
|
})
|
|
const tenant =
|
|
existingTenant.docs[0] ??
|
|
(await cms.create({ collection: 'tenants', data: { name: org, slug: org } }))
|
|
ok(`tenant (== IAM org) id=${tenant.id} slug=${(tenant as { slug?: string }).slug}`)
|
|
|
|
// ---- 2. real media upload -> SeaweedFS S3 ----------------------------
|
|
step('2. Media upload -> SeaweedFS S3 (real object)')
|
|
const haveS3 = Boolean(process.env.S3_ACCESS_KEY_ID && process.env.S3_SECRET_ACCESS_KEY)
|
|
let uploadedKey: string | undefined
|
|
if (haveS3) {
|
|
const pngPath = path.resolve(dirname, 'fixture.png')
|
|
const bytes = readFileSync(pngPath)
|
|
const media = await cms.create({
|
|
collection: 'media',
|
|
data: { alt: 'proof pixel', tenant: tenant.id },
|
|
file: {
|
|
name: `proof-${Date.now()}.png`,
|
|
data: bytes,
|
|
mimetype: 'image/png',
|
|
size: bytes.length,
|
|
},
|
|
})
|
|
uploadedKey = `${org}/${media.filename}`
|
|
ok(`created media doc id=${media.id} filename=${media.filename}`)
|
|
ok(`expected S3 key (per-org prefix) = ${uploadedKey}`)
|
|
} else {
|
|
console.log(' (skipped: no S3 creds in env — set S3_ACCESS_KEY_ID/S3_SECRET_ACCESS_KEY)')
|
|
}
|
|
|
|
// ---- 3. Hanzo IAM SSO: verify a REAL token ---------------------------
|
|
step('3. Hanzo IAM SSO — verify a real IAM token via JWKS')
|
|
const token = process.env.IAM_TOKEN
|
|
if (token) {
|
|
const strat = hanzoIAMStrategy()
|
|
const headers = new Headers({ authorization: `Bearer ${token}` })
|
|
const res = await strat.authenticate({
|
|
canSetHeaders: true,
|
|
cms,
|
|
headers,
|
|
} as Parameters<typeof strat.authenticate>[0])
|
|
if (res.user) {
|
|
ok(`IAM token verified; user email=${(res.user as { email?: string }).email}`)
|
|
ok(`mapped iamOrg (== tenant) = ${(res.user as { iamOrg?: string }).iamOrg}`)
|
|
const setCookie = res.responseHeaders?.get('set-cookie')
|
|
ok(`cms-tenant cookie set: ${setCookie ? 'yes' : 'no'}`)
|
|
} else {
|
|
fail('IAM token did NOT verify (expected a real valid token)')
|
|
}
|
|
// negative control: a garbage token must be rejected
|
|
const bad = await strat.authenticate({
|
|
canSetHeaders: true,
|
|
cms,
|
|
headers: new Headers({ authorization: 'Bearer not.a.jwt' }),
|
|
} as Parameters<typeof strat.authenticate>[0])
|
|
if (!bad.user) {
|
|
ok('negative control: invalid token rejected (user=null)')
|
|
} else {
|
|
fail('SECURITY: invalid token was accepted')
|
|
}
|
|
} else {
|
|
console.log(' (skipped: no IAM_TOKEN in env)')
|
|
}
|
|
|
|
// ---- 4. draft -> publish --------------------------------------------
|
|
step('4. Draft -> Publish (CMS-native versions)')
|
|
const draft = await cms.create({
|
|
collection: 'pages',
|
|
data: { slug: 'launch', _status: 'draft', tenant: tenant.id, title: 'Launch Announcement' },
|
|
})
|
|
ok(`created DRAFT page id=${draft.id} status=${(draft as { _status?: string })._status}`)
|
|
|
|
// before publish: there must be NO row whose published status is 'published'
|
|
const publishedBefore = await cms.find({
|
|
collection: 'pages',
|
|
where: { and: [{ slug: { equals: 'launch' } }, { _status: { equals: 'published' } }] },
|
|
})
|
|
if (publishedBefore.totalDocs === 0) {
|
|
ok('no published version before publish -> 0 (draft not yet published)')
|
|
} else {
|
|
fail(`unexpected published version before publish: ${publishedBefore.totalDocs}`)
|
|
}
|
|
|
|
const published = await cms.update({
|
|
id: draft.id,
|
|
collection: 'pages',
|
|
data: { _status: 'published' },
|
|
})
|
|
ok(`published page id=${published.id} status=${(published as { _status?: string })._status}`)
|
|
|
|
const publishedAfter = await cms.find({
|
|
collection: 'pages',
|
|
where: { and: [{ slug: { equals: 'launch' } }, { _status: { equals: 'published' } }] },
|
|
})
|
|
if (publishedAfter.totalDocs === 1) {
|
|
ok(`published version after publish -> 1 (draft->publish works)`)
|
|
} else {
|
|
fail(`expected 1 published doc, got ${publishedAfter.totalDocs}`)
|
|
}
|
|
|
|
const versions = await cms.findVersions({
|
|
collection: 'pages',
|
|
where: { parent: { equals: draft.id } },
|
|
})
|
|
ok(`version history rows = ${versions.totalDocs} (>=1)`)
|
|
|
|
// ---- 4b. PUBLIC (unauthenticated) read: published visible, drafts hidden --
|
|
// Headless storefronts (e.g. karma.style/journal) fetch pages with NO token.
|
|
// Pages.access.read returns {_status: published} for an anonymous request, and
|
|
// the multi-tenant plugin adds no tenant constraint without a user — so
|
|
// published docs are world-readable while drafts and writes stay gated.
|
|
step('4b. Public read — anon sees PUBLISHED only (headless storefront)')
|
|
const hiddenDraft = await cms.create({
|
|
collection: 'pages',
|
|
data: { slug: 'unpublished-draft', _status: 'draft', tenant: tenant.id, title: 'Draft Only' },
|
|
})
|
|
// overrideAccess:false with no req.user == an anonymous public API request.
|
|
const anon = await cms.find({
|
|
collection: 'pages',
|
|
overrideAccess: false,
|
|
where: { slug: { in: ['launch', 'unpublished-draft'] } },
|
|
})
|
|
const anonSlugs = anon.docs.map((d) => (d as { slug?: string }).slug)
|
|
const publicReadOk = anonSlugs.includes('launch') && !anonSlugs.includes('unpublished-draft')
|
|
if (publicReadOk) {
|
|
ok(`anon read = [${anonSlugs.join(', ')}] — published 'launch' visible, draft hidden`)
|
|
} else {
|
|
fail(`anon read wrong: [${anonSlugs.join(', ')}] (expected published only, no drafts)`)
|
|
}
|
|
// negative control: an anonymous by-id GET of a draft must NOT return it.
|
|
const anonDraftById = await cms.findByID({
|
|
id: hiddenDraft.id,
|
|
collection: 'pages',
|
|
disableErrors: true,
|
|
overrideAccess: false,
|
|
})
|
|
if (!anonDraftById) {
|
|
ok('negative control: anon by-id read of a DRAFT is blocked (null)')
|
|
} else {
|
|
fail('SECURITY: anonymous read exposed a draft document')
|
|
}
|
|
|
|
// ---- 5. per-org isolation -------------------------------------------
|
|
// org == tenant means each org gets its OWN SQLite database (Base). We prove
|
|
// isolation by pointing a second, independent Payload build at org2's DB and
|
|
// confirming org1's page is invisible there. (Runs only when PROOF_ORG2=1 to
|
|
// keep the primary run single-instance; the isolation run is a separate
|
|
// process — see the runner.)
|
|
if (process.env.PROOF_ORG2 === '1') {
|
|
step('5. Per-org isolation — org2 on its OWN SQLite')
|
|
const pages2 = await cms.find({ collection: 'pages' })
|
|
if (pages2.totalDocs === 0) {
|
|
ok(`org2 (${org}) sees 0 pages — org1 data NOT visible -> isolated`)
|
|
} else {
|
|
fail(`ISOLATION BREACH: org2 sees ${pages2.totalDocs} page(s) from another org`)
|
|
}
|
|
}
|
|
|
|
console.log('\n=== PROOF COMPLETE ===')
|
|
console.log(
|
|
JSON.stringify(
|
|
{
|
|
boot: true,
|
|
dbAdapter: cms.db.name,
|
|
draftThenPublish: publishedAfter.totalDocs === 1,
|
|
iamVerified: Boolean(token),
|
|
org,
|
|
publicRead: publicReadOk,
|
|
s3ObjectKey: uploadedKey ?? null,
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
)
|
|
|
|
process.exit(process.exitCode || 0)
|
|
}
|
|
|
|
run().catch((e) => {
|
|
console.error('PROOF FAILED:', e)
|
|
process.exit(1)
|
|
})
|