Tier langganan & peran redaksi
Portalmu punya dua pertanyaan otorisasi yang sama sekali berbeda: boleh membaca apa, dan boleh mengubah apa. Mencampurnya adalah sumber lubang izin yang paling umum.
Intisari
- Tier menjawab "boleh baca apa"; peran menjawab "boleh ubah apa". Pisahkan keduanya.
- Keputusan izin tinggal di satu fungsi, bukan tersebar sebagai
ifdi halaman. - Batasi operasi tulis lewat
WHERE, bukan lewatifโ kepemilikan jadi bagian dari kueri. - Tier kedaluwarsa itu keadaan normal, bukan kesalahan. Rancang masa tenggang sejak awal.
- Default harus menolak. Peran yang tidak dikenal tidak boleh berarti akses penuh.
Dua sistem, satu aplikasi
| Tier langganan | Peran | |
|---|---|---|
| Menjawab | Boleh membaca apa | Boleh mengubah apa |
| Contoh nilai | gratis, dasar, pro | pembaca, penulis, editor, admin |
| Berubah karena | Pembayaran | Keputusan manusia |
| Bisa kedaluwarsa | Ya | Tidak |
| Diperiksa di | Server island paywall | Action & endpoint mutasi |
Ini dua kolom terpisah di tabel member, bukan satu. Menggabungkannya jadi satu
kolom level terasa hemat sampai kamu perlu seorang editor yang juga berlangganan
tier pro, atau admin yang langganannya kedaluwarsa. Dua pertanyaan berbeda butuh dua jawaban terpisah.
Tier: satu fungsi, satu tempat
// src/lib/tier.ts
export type Tier = "gratis" | "dasar" | "pro";
const URUTAN: Record<Tier, number> = { gratis: 0, dasar: 1, pro: 2 };
const MASA_TENGGANG_HARI = 3;
export function tierEfektif(m: {
tier: Tier;
langgananSampai: Date | null;
}): Tier {
if (m.tier === "gratis") return "gratis";
if (!m.langgananSampai) return "gratis";
const batas = new Date(m.langgananSampai);
batas.setDate(batas.getDate() + MASA_TENGGANG_HARI);
return new Date() <= batas ? m.tier : "gratis";
}
export function tierCukup(punya: Tier, butuh: Tier): boolean {
return URUTAN[punya] >= URUTAN[butuh];
}
Masa tenggang tiga hari itu keputusan bisnis yang menghemat banyak tiket dukungan. Pembayaran berulang gagal karena hal-hal sepele: kartu kedaluwarsa, saldo kurang sesaat, gangguan di sisi gateway. Memutus akses tepat pada detik langganan berakhir berarti pelanggan yang membayar tiap bulan selama dua tahun tiba-tiba diperlakukan seperti orang asing โ dan menghubungi dukungan dengan marah. Tiga hari cukup untuk percobaan penagihan ulang berhasil.
Peran: default menolak
// src/lib/izin.ts
export type Peran = "pembaca" | "penulis" | "editor" | "admin";
export type Aksi =
| "artikel:buat" | "artikel:ubah-sendiri" | "artikel:ubah-semua"
| "artikel:terbitkan" | "artikel:hapus"
| "member:lihat" | "member:ubah";
const IZIN: Record<Peran, Aksi[]> = {
pembaca: [],
penulis: ["artikel:buat", "artikel:ubah-sendiri"],
editor: [
"artikel:buat", "artikel:ubah-sendiri", "artikel:ubah-semua",
"artikel:terbitkan", "member:lihat",
],
admin: [
"artikel:buat", "artikel:ubah-sendiri", "artikel:ubah-semua",
"artikel:terbitkan", "artikel:hapus", "member:lihat", "member:ubah",
],
};
export function boleh(peran: Peran | undefined, aksi: Aksi): boolean {
if (!peran) return false; // tidak login โ tidak boleh
return IZIN[peran]?.includes(aksi) ?? false; // peran asing โ tidak boleh
}
Dua ?? false itu yang membuat sistemnya aman secara bawaan. Kalau suatu saat ada baris di
database dengan peran "superadmin" yang tidak pernah kamu definisikan โ hasil migrasi data
yang tidak sempurna, misalnya โ ia mendapat nol izin, bukan semuanya.
Menegakkannya di action
export const terbitkanArtikel = defineAction({
input: z.object({ id: z.number().int().positive() }),
handler: async ({ id }, ctx) => {
const member = ctx.locals.member;
if (!boleh(member?.peran, "artikel:terbitkan")) {
throw new ActionError({ code: "FORBIDDEN", message: "Tidak berwenang." });
}
const hasil = await dbTulis
.updateTable("artikel")
.set({ status: "terbit", terbit_pada: new Date() })
.where("id", "=", id)
.where("status", "=", "tinjau")
.executeTakeFirstOrThrow();
if (hasil.numUpdatedRows === 0n) {
throw new ActionError({ code: "CONFLICT", message: "Artikel tidak dalam status tinjau." });
}
await purgeCacheTag(`artikel-${id}`); // Fase 8
return { ok: true };
},
});
Kepemilikan lewat WHERE
export const ubahArtikel = defineAction({
input: z.object({ id: z.number().int().positive(), judul: z.string().min(3) }),
handler: async ({ id, judul }, ctx) => {
const m = ctx.locals.member;
if (!boleh(m?.peran, "artikel:ubah-sendiri")) {
throw new ActionError({ code: "FORBIDDEN" });
}
let q = dbTulis.updateTable("artikel").set({ judul }).where("id", "=", id);
// Penulis biasa hanya bisa menyentuh miliknya sendiri.
if (!boleh(m!.peran, "artikel:ubah-semua")) {
q = q.where("penulis_id", "=", m!.id);
}
const hasil = await q.executeTakeFirstOrThrow();
if (hasil.numUpdatedRows === 0n) {
// Sengaja tidak membedakan "tidak ada" dan "bukan milikmu" โ
// membedakannya membocorkan keberadaan artikel orang lain.
throw new ActionError({ code: "NOT_FOUND" });
}
return { ok: true };
},
});
Pola ini menghapus seluruh kelas bug otorisasi. Artikel milik orang lain tidak pernah
ditemukan, jadi tidak mungkin tersunting โ bahkan kalau seseorang nanti menghapus pemeriksaan
boleh() di atasnya. Pertahanan yang menempel di kueri tidak bisa lupa dipasang.
Tier di paywall
export async function cekHak(member, artikel, โฆ): Promise<Hak> {
const punya = member ? tierEfektif(member) : "gratis";
if (tierCukup(punya, artikel.tierMinimal)) {
return { boleh: true, alasan: "langganan" };
}
// โฆ lanjut ke paywall terukur
}
Kolom artikel.tierMinimal memberi redaksi kendali per artikel: laporan mendalam bisa
pro, artikel harian gratis, analisis pasar dasar. Tanpa kolom itu,
satu-satunya pilihan adalah premium atau tidak โ dan model bisnismu terkunci di sana.
Yang harus dicatat
| Peristiwa | Kenapa dicatat |
|---|---|
| Perubahan peran | Siapa menaikkan siapa jadi admin, dan kapan |
| Artikel diterbitkan / ditarik | Jejak redaksional; sering diminta saat ada sengketa |
| Perubahan tier di luar pembayaran | Pemberian manual harus punya alasan tertulis |
| Percobaan akses yang ditolak | Pola berulang berarti ada yang menyelidiki sistemmu |
Latihan: implementasikan tierEfektif, boleh(), dan action
ubahArtikel dengan pembatasan lewat WHERE. Lalu uji tiga hal: penulis A tidak
bisa menyunting artikel penulis B (dan mendapat 404, bukan 403); member dengan
langgananSampai kemarin masih berhak hari ini karena masa tenggang; dan member dengan peran
"entah-apa" di database mendapat nol izin.
Rangkuman ini sengaja dipangkas ke bagian yang dipakai di roadmap. Buka sumber aslinya saat kamu butuh detail lengkap atau referensi parameter.