diff --git a/docs/code-bridge/requests/REQ-2026-04-15-subscriber-wiki-auth.md b/docs/code-bridge/archive/REQ-2026-04-15-subscriber-wiki-auth.md similarity index 100% rename from docs/code-bridge/requests/REQ-2026-04-15-subscriber-wiki-auth.md rename to docs/code-bridge/archive/REQ-2026-04-15-subscriber-wiki-auth.md diff --git a/docs/code-bridge/requests/REQ-2026-04-16-modpack-installer.md b/docs/code-bridge/archive/REQ-2026-04-16-modpack-installer.md similarity index 100% rename from docs/code-bridge/requests/REQ-2026-04-16-modpack-installer.md rename to docs/code-bridge/archive/REQ-2026-04-16-modpack-installer.md diff --git a/docs/code-bridge/requests/RES-2026-04-15-subscriber-wiki-auth.md b/docs/code-bridge/archive/RES-2026-04-15-subscriber-wiki-auth.md similarity index 100% rename from docs/code-bridge/requests/RES-2026-04-15-subscriber-wiki-auth.md rename to docs/code-bridge/archive/RES-2026-04-15-subscriber-wiki-auth.md diff --git a/services/arbiter-3.0/.env.example b/services/arbiter-3.0/.env.example index 9ee14cc..306bb75 100644 --- a/services/arbiter-3.0/.env.example +++ b/services/arbiter-3.0/.env.example @@ -28,3 +28,12 @@ MINECRAFT_NEST_IDS=1,6,7 STRIPE_SECRET_KEY=sk_test_... # or sk_live_... for production STRIPE_WEBHOOK_SECRET=whsec_... # Get from Stripe Dashboard webhook settings BASE_URL=https://discord-bot.firefrostgaming.com # For checkout redirect URLs + +# Wiki.js Subscriber Wiki (subscribers.firefrostgaming.com) +# Generate key in Wiki.js Admin → API Access. Groups (Awakened thru Sovereign) +# must be pre-created in Wiki.js admin panel before syncing. +WIKIJS_URL=https://subscribers.firefrostgaming.com +WIKIJS_API_KEY= + +# Discord Webhooks (optional — silent-skip if unset) +DISCORD_ISSUE_WEBHOOK_URL= diff --git a/services/arbiter-3.0/src/routes/stripe.js b/services/arbiter-3.0/src/routes/stripe.js index cad932d..1eb0f0f 100644 --- a/services/arbiter-3.0/src/routes/stripe.js +++ b/services/arbiter-3.0/src/routes/stripe.js @@ -15,6 +15,7 @@ const { syncRole, removeAllRoles, downgradeToAwakened } = require('../services/d const { welcomeNewMember } = require('../services/awakenedConcierge'); const { syncLuckPermsMeta } = require('../services/luckpermsSync'); const { logAction } = require('../services/discordActionLog'); +const { syncWikiUser, demoteToAwakened: demoteWikiToAwakened } = require('../services/wikijsSync'); // CORS configuration for checkout endpoint const corsOptions = { @@ -298,6 +299,12 @@ router.post('/webhook', express.raw({ type: 'application/json' }), async (req, r } }).catch(err => console.error('[LPSync] Background sync error:', err.message)); + // Sync Wiki.js subscriber group (non-blocking, silent-fail) + // REQ-2026-04-15-subscriber-wiki-auth (Task #138) + syncWikiUser(discordId, req.user?.username || discordId, tierLevel) + .then(r => { if (r.success) console.log(`📚 WikiSync checkout: ${discordId} → ${r.group}`); }) + .catch(err => console.error('[WikiSync] checkout error:', err.message)); + // Carl-bot migration: link-reminder DM for Awakened+ subscribers (non-blocking, silent-fail) // REQ-2026-04-15-reaction-roles (Chronicler #92) (async () => { @@ -368,6 +375,10 @@ router.post('/webhook', express.raw({ type: 'application/json' }), async (req, r const discordId = subRow.rows[0].discord_id; const roleResult = await downgradeToAwakened(discordId); console.log(`⬇️ Subscription ended for ${discordId}, demoted to Awakened: ${roleResult.message}`); + + // Demote Wiki.js group to Awakened (non-blocking, silent-fail) + demoteWikiToAwakened(discordId) + .catch(err => console.error('[WikiSync] demote error:', err.message)); await client.query(` INSERT INTO admin_audit_log (action_type, target_identifier, details) @@ -462,6 +473,8 @@ router.post('/webhook', express.raw({ type: 'application/json' }), async (req, r // Remove all Discord roles const roleResult = await removeAllRoles(discordId); console.log(`🚫 Chargeback role removal for ${discordId}: ${roleResult.message}`); + demoteWikiToAwakened(discordId) + .catch(err => console.error('[WikiSync] chargeback demote error:', err.message)); } await client.query(` @@ -528,6 +541,8 @@ router.post('/webhook', express.raw({ type: 'application/json' }), async (req, r // Remove ALL roles including Awakened (hard ban) const roleResult = await removeAllRoles(discordId); console.log(`🚫 Refund ban for ${discordId}: ${roleResult.message}`); + demoteWikiToAwakened(discordId) + .catch(err => console.error('[WikiSync] refund ban demote error:', err.message)); } await client.query(` diff --git a/services/arbiter-3.0/src/services/wikijsSync.js b/services/arbiter-3.0/src/services/wikijsSync.js new file mode 100644 index 0000000..7057a73 --- /dev/null +++ b/services/arbiter-3.0/src/services/wikijsSync.js @@ -0,0 +1,175 @@ +/** + * Wiki.js Tier Sync — syncs subscriber tier groups in Wiki.js via GraphQL API. + * REQ-2026-04-15-subscriber-wiki-auth (Chronicler #92, Task #138) + * + * Wiki.js is deployed at subscribers.firefrostgaming.com. Auth via API key. + * User email convention: {discordId}@firefrost.local (unique, internal). + * + * Pre-requisites (Michael does once in Wiki.js admin panel): + * 1. Create 6 groups: Awakened, Elemental, Knight, Master, Legend, Sovereign + * 2. Generate API key: Admin → API Access → add key → copy to .env + * + * Silent-fail pattern: never let wiki sync break the Stripe webhook. + */ + +const axios = require('axios'); + +const WIKIJS_URL = process.env.WIKIJS_URL || 'https://subscribers.firefrostgaming.com'; +const WIKIJS_API_KEY = process.env.WIKIJS_API_KEY || ''; + +// tier_level (from Stripe webhook) → Wiki.js group name +const TIER_GROUP_MAP = { + 1: 'Awakened', + 2: 'Elemental', + 3: 'Knight', + 4: 'Master', + 5: 'Legend', + 6: 'Sovereign' +}; + +/** + * Execute a GraphQL query/mutation against Wiki.js. + */ +async function gql(query, variables) { + if (!WIKIJS_API_KEY) { + console.warn('[WikiSync] WIKIJS_API_KEY not set — skipping'); + return null; + } + const resp = await axios.post( + `${WIKIJS_URL}/graphql`, + { query, variables }, + { + headers: { + 'Authorization': `Bearer ${WIKIJS_API_KEY}`, + 'Content-Type': 'application/json' + }, + timeout: 10000 + } + ); + return resp.data; +} + +/** + * Fetch all Wiki.js groups and return a map of name → id. + */ +async function getGroupMap() { + const result = await gql(` + query { + groups { + list { id name } + } + } + `); + const groups = result?.data?.groups?.list || []; + const map = {}; + for (const g of groups) { + map[g.name] = g.id; + } + return map; +} + +/** + * Search for a Wiki.js user by their internal email ({discordId}@firefrost.local). + * Returns { id, email } or null. + */ +async function findUser(discordId) { + const email = `${discordId}@firefrost.local`; + const result = await gql(` + query ($query: String!) { + users { + search(query: $query) { id email } + } + } + `, { query: email }); + const users = result?.data?.users?.search || []; + return users.find(u => u.email === email) || null; +} + +/** + * Create a Wiki.js user for the given Discord subscriber. + */ +async function createUser(discordId, username, groupIds) { + const email = `${discordId}@firefrost.local`; + const result = await gql(` + mutation ($email: String!, $name: String!, $groups: [Int!]!) { + users { + create(email: $email, name: $name, passwordRaw: "", providerKey: "discord", groups: $groups) { + responseResult { succeeded message } + } + } + } + `, { email, name: username || discordId, groups: groupIds }); + const res = result?.data?.users?.create?.responseResult; + if (res && !res.succeeded) { + throw new Error(`Wiki.js create failed: ${res.message}`); + } + return res; +} + +/** + * Update a Wiki.js user's group assignment. + */ +async function updateUserGroups(userId, groupIds) { + const result = await gql(` + mutation ($id: Int!, $groups: [Int!]!) { + users { + update(id: $id, groups: $groups) { + responseResult { succeeded message } + } + } + } + `, { id: userId, groups: groupIds }); + const res = result?.data?.users?.update?.responseResult; + if (res && !res.succeeded) { + throw new Error(`Wiki.js update failed: ${res.message}`); + } + return res; +} + +/** + * Sync a subscriber's Wiki.js group to match their Stripe tier. + * Creates user if not exists, updates group if they do. + * + * @param {string} discordId + * @param {string} username — Discord display name + * @param {number} tierLevel — 1-6 from Stripe webhook + */ +async function syncWikiUser(discordId, username, tierLevel) { + if (!WIKIJS_API_KEY) { + console.warn('[WikiSync] WIKIJS_API_KEY not set — skipping'); + return { success: false, reason: 'no API key' }; + } + + const groupName = TIER_GROUP_MAP[tierLevel]; + if (!groupName) { + console.warn(`[WikiSync] Unknown tier level ${tierLevel} — skipping`); + return { success: false, reason: `unknown tier ${tierLevel}` }; + } + + const groupMap = await getGroupMap(); + const groupId = groupMap[groupName]; + if (!groupId) { + console.error(`[WikiSync] Group '${groupName}' not found in Wiki.js — did Michael create it?`); + return { success: false, reason: `group '${groupName}' missing` }; + } + + const existing = await findUser(discordId); + if (existing) { + await updateUserGroups(existing.id, [groupId]); + console.log(`📚 [WikiSync] Updated ${username} (${discordId}) → ${groupName}`); + } else { + await createUser(discordId, username, [groupId]); + console.log(`📚 [WikiSync] Created ${username} (${discordId}) → ${groupName}`); + } + + return { success: true, group: groupName }; +} + +/** + * Demote a subscriber to Awakened group (on cancellation/grace period). + */ +async function demoteToAwakened(discordId) { + return syncWikiUser(discordId, null, 1); +} + +module.exports = { syncWikiUser, demoteToAwakened };