diff --git a/docs/nostr-event-schemas.md b/docs/nostr-event-schemas.md index 068b6c46..0752ca51 100644 --- a/docs/nostr-event-schemas.md +++ b/docs/nostr-event-schemas.md @@ -198,6 +198,7 @@ Video posts should treat `videoRootId` as the stable series identifier that rema | Zap receipt (`NOTE_TYPES.ZAP_RECEIPT`) | `9735` | `['bolt11', ]`, `['description', ]`, `['p', ]` plus optional `['e', ]` and `['a', ]` | Empty content; receipts are published by the recipient's LNURL server | | Zap tally (`NOTE_TYPES.ZAP_TALLY`) | `30081` (addressable, `d = payment_hash`) | `['d', ]`, `['p', ]`, optional `['e', ]`/`['a', ]`, `['amount', ]`, `['bolt11', ]`, `['preimage', ]`, `['description', ]`, `['client', 'bitvid']`, `['t', 'zap']` | bitvid-native, payer-signed, **preimage-verified** stand-in for a 9735 when the recipient's wallet doesn't publish one. Verified via `sha256(preimage)==payment_hash` + `description_hash==sha256(zapRequest)`. Counted alongside 9735s (deduped by `payment_hash`). See `docs/zap-tally-plan.md`. | | Playlist (`NOTE_TYPES.PLAYLIST`) | `PLAYLIST_KIND` (`30082`, addressable, `d = playlist id`) | `['t','playlist']`, `['d', ]`, optional `['title', ...]`/`['description', ...]`/`['image', ...]`, then **ordered** `['a', '30078:pubkey:d']` refs per video (`['e', ]` also accepted). Item order = tag order. | Creator-curated video playlist (#37). bitvid-native — deliberately NOT NIP-51's `30005` "video curation set", which bitvid already reads as legacy interest-sets (would collide). Built/parsed by `js/playlists/playlistService.js`. | +| Submission (`NOTE_TYPES.SUBMISSION`) | `SUBMISSION_KIND` (`30083`, addressable, `d = submission id`) | `['t','bitvid-submission']`, `['d', ]`, `['k', 'application'\|'appeal'\|'bug'\|'feature'\|'feedback']`, `['applicant', ]`, `['p', ]`; free-text body in `content`. | Structured, PUBLIC application/appeal/bug/feature/feedback (#23) — replaces the old kind-4 encrypted DM forms so an admin "Submissions" tab can list + approve/deny. Published from an EPHEMERAL key (no login), so `applicant` is the CLAIMED npub. Built/parsed by `js/submissions/submissionService.js`. | | HTTP Auth (`NOTE_TYPES.HTTP_AUTH`) | `27235` | `['u', ]`, `['method', ]`, `['payload', ]` | Optional plaintext content (NIP-98 recommends empty, but implementations may vary) | | Report (`NOTE_TYPES.REPORT`) | `1984` | `['e', , ]`, `['p', , ]`, and optional `['t', ]` | Plaintext report reason | diff --git a/js/constants.js b/js/constants.js index 53360d9b..e3a035b6 100644 --- a/js/constants.js +++ b/js/constants.js @@ -202,6 +202,7 @@ const DEFAULT_FLAGS = Object.freeze({ FEATURE_MOST_ZAPPED_FEED: true, // "Most Zapped" tab: recently-added ranked by zap total. Disable to hide the tab. FEATURE_ZAP_TALLY: true, // bitvid-native preimage-verified zap tally (publish + count). Rollback: set false. FEATURE_PLAYLISTS: true, // Creator playlists (#37): channel section + playlist view + add-to-playlist (⋯ menu). Rollback: set false to hide all playlist UI. + FEATURE_SUBMISSIONS: false, // Structured submissions (#23): admin Submissions tab + forms publish kind-30083 instead of DMs. Default OFF until the UI is complete. TRUSTED_MUTE_HIDE_THRESHOLD: DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD, TRUSTED_SPAM_HIDE_THRESHOLD: DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD, WSS_TRACKERS: DEFAULT_WSS_TRACKERS, @@ -228,6 +229,7 @@ const runtimeFlags = (() => { FEATURE_MOST_ZAPPED_FEED: DEFAULT_FLAGS.FEATURE_MOST_ZAPPED_FEED, FEATURE_ZAP_TALLY: DEFAULT_FLAGS.FEATURE_ZAP_TALLY, FEATURE_PLAYLISTS: DEFAULT_FLAGS.FEATURE_PLAYLISTS, + FEATURE_SUBMISSIONS: DEFAULT_FLAGS.FEATURE_SUBMISSIONS, TRUSTED_MUTE_HIDE_THRESHOLD: DEFAULT_FLAGS.TRUSTED_MUTE_HIDE_THRESHOLD, TRUSTED_SPAM_HIDE_THRESHOLD: DEFAULT_FLAGS.TRUSTED_SPAM_HIDE_THRESHOLD, WSS_TRACKERS: [...DEFAULT_FLAGS.WSS_TRACKERS], @@ -288,6 +290,11 @@ export let FEATURE_PLAYLISTS = coerceBoolean( DEFAULT_FLAGS.FEATURE_PLAYLISTS ); +export let FEATURE_SUBMISSIONS = coerceBoolean( + runtimeFlags.FEATURE_SUBMISSIONS, + DEFAULT_FLAGS.FEATURE_SUBMISSIONS +); + export let FEATURE_TRUST_SEEDS = coerceBoolean( runtimeFlags.FEATURE_TRUST_SEEDS, DEFAULT_FLAGS.FEATURE_TRUST_SEEDS diff --git a/js/nostrEventSchemas.js b/js/nostrEventSchemas.js index 0064d14a..3a72e24b 100644 --- a/js/nostrEventSchemas.js +++ b/js/nostrEventSchemas.js @@ -49,6 +49,7 @@ export const NOTE_TYPES = Object.freeze({ ZAP_RECEIPT: "zapReceipt", ZAP_TALLY: "zapTally", PLAYLIST: "playlist", + SUBMISSION: "submission", WATCH_HISTORY: "watchHistory", SUBSCRIPTION_LIST: "subscriptionList", SUBSCRIPTION_BACKUP: "subscriptionBackup", @@ -107,6 +108,13 @@ export const ZAP_TALLY_KIND = 30081; // (30078:pubkey:d) reference the videos; order = tag order. export const PLAYLIST_KIND = 30082; +// bitvid submission (#23): applications / appeals / bug / feature / feedback, +// PUBLIC + structured so an admin "Submissions" tab can list + approve/deny them +// (replacing the old kind-4 encrypted DM flow). Addressable (30000–39999) in +// bitvid's family; published from an EPHEMERAL key (no login), so the `applicant` +// tag carries the CLAIMED npub the admin vouches for. `d` = ephemeral id. +export const SUBMISSION_KIND = 30083; + let cachedUtf8Encoder = null; const ADDITIONAL_POINTER_TAGS = new Set(["p", "e"]); @@ -361,6 +369,19 @@ const BASE_SCHEMAS = { "Reserved for future notes; playlist data lives in tags (title/description/image + ordered `a` video refs).", }, }, + [NOTE_TYPES.SUBMISSION]: { + type: NOTE_TYPES.SUBMISSION, + label: "Submission", + kind: SUBMISSION_KIND, + topicTag: { name: "t", value: "bitvid-submission" }, + identifierTag: { name: "d" }, + appendTags: DEFAULT_APPEND_TAGS, + content: { + format: "text", + description: + "Free-text submission body (application reason, appeal, bug report, etc.). Structured fields live in tags: k (type), applicant (npub), p (recipient admin).", + }, + }, [NOTE_TYPES.VIDEO_MIRROR]: { type: NOTE_TYPES.VIDEO_MIRROR, label: "NIP-94 mirror", @@ -1541,6 +1562,79 @@ export function buildPlaylistEvent(params) { return event; } +/** + * Builds a bitvid Submission event (Kind 30083, addressable, PUBLIC). Structured + * so an admin "Submissions" tab can list + approve/deny. Published from an + * ephemeral key, so the `applicant` tag carries the CLAIMED npub. + * + * @param {Object} params + * @param {string} params.pubkey the (ephemeral) author pubkey + * @param {number} params.created_at + * @param {string} params.dTagValue stable submission id (required) + * @param {string} [params.submissionType] application | appeal | bug | feature | feedback + * @param {string} [params.applicantNpub] the npub the submission is about + * @param {string} [params.recipientPubkey] the admin hex pubkey (`p` tag) + * @param {string} [params.content] free-text body + * @returns {Object} Unsigned event. + */ +export function buildSubmissionEvent(params) { + const { + pubkey, + created_at, + dTagValue, + submissionType = "application", + applicantNpub = "", + recipientPubkey = "", + content = "", + } = params || {}; + + const identifier = typeof dTagValue === "string" ? dTagValue.trim() : ""; + if (!identifier) { + throw new Error("A submission requires a d-tag identifier."); + } + + const schema = getNostrEventSchema(NOTE_TYPES.SUBMISSION); + const tags = []; + + if (schema?.topicTag?.name && schema?.topicTag?.value) { + tags.push([schema.topicTag.name, schema.topicTag.value]); + } + tags.push([schema?.identifierTag?.name || "d", identifier]); + + const type = + typeof submissionType === "string" && submissionType.trim() + ? submissionType.trim() + : "application"; + tags.push(["k", type]); + + const applicant = + typeof applicantNpub === "string" ? applicantNpub.trim() : ""; + if (applicant) { + tags.push(["applicant", applicant]); + } + + const recipient = normalizePointerIdentifier(recipientPubkey); + if (recipient) { + tags.push(["p", recipient]); + } + + appendSchemaTags(tags, schema); + + const event = { + kind: schema?.kind ?? SUBMISSION_KIND, + pubkey, + created_at, + tags, + content: ensureValidUtf8Content(content), + }; + + if (isDevMode) { + validateEventAgainstSchema(NOTE_TYPES.SUBMISSION, event); + } + + return event; +} + /** * Builds a Repost (Kind 6) or Generic Repost (Kind 16) event. * diff --git a/js/submissions/submissionService.js b/js/submissions/submissionService.js new file mode 100644 index 00000000..56fcb8d2 --- /dev/null +++ b/js/submissions/submissionService.js @@ -0,0 +1,120 @@ +// js/submissions/submissionService.js +// +// Structured bitvid submissions (#23) — pure core. A submission is a public, +// addressable kind-30083 event (SUBMISSION_KIND) published from an ephemeral key +// so a public form needs no login; the `applicant` tag carries the CLAIMED npub +// an admin vouches for. This module builds/parses those events with no relay or +// signer, so it's fully unit-testable. Fetch/publish/approve/deny live in the +// facade that wires this to the live client. + +import { buildSubmissionEvent, SUBMISSION_KIND } from "../nostrEventSchemas.js"; + +export { SUBMISSION_KIND }; + +export const SUBMISSION_TYPES = Object.freeze({ + APPLICATION: "application", + APPEAL: "appeal", + BUG: "bug", + FEATURE: "feature", + FEEDBACK: "feedback", +}); + +const KNOWN_TYPES = new Set(Object.values(SUBMISSION_TYPES)); + +// Human labels for the admin UI. +export const SUBMISSION_TYPE_LABELS = Object.freeze({ + application: "Whitelist application", + appeal: "Content appeal", + bug: "Bug report", + feature: "Feature request", + feedback: "Feedback", + other: "Submission", +}); + +function firstTagValue(tags, name) { + const tag = tags.find( + (t) => Array.isArray(t) && t[0] === name && typeof t[1] === "string", + ); + return tag ? tag[1] : ""; +} + +/** + * Parse a kind-30083 event into a structured submission. Returns null for a + * non-submission event or one missing a `d` identifier. + * @param {Object} event + * @returns {null | { + * id: string, eventId: string, type: string, applicant: string, + * recipient: string, content: string, createdAt: number, pubkey: string, + * }} + */ +export function parseSubmissionEvent(event) { + if (!event || typeof event !== "object" || !Array.isArray(event.tags)) { + return null; + } + if (Number.isFinite(event.kind) && event.kind !== SUBMISSION_KIND) { + return null; + } + const tags = event.tags; + const id = firstTagValue(tags, "d").trim(); + if (!id) { + return null; + } + const rawType = firstTagValue(tags, "k").trim() || "application"; + return { + id, + eventId: typeof event.id === "string" ? event.id : "", + type: KNOWN_TYPES.has(rawType) ? rawType : "other", + applicant: firstTagValue(tags, "applicant").trim(), + recipient: firstTagValue(tags, "p").trim(), + content: typeof event.content === "string" ? event.content : "", + createdAt: Number.isFinite(event.created_at) ? event.created_at : 0, + pubkey: + typeof event.pubkey === "string" ? event.pubkey.trim().toLowerCase() : "", + }; +} + +/** + * Build an unsigned, signable submission event. + * @param {{ + * pubkey: string, id: string, type?: string, applicantNpub?: string, + * recipientPubkey?: string, content?: string, created_at?: number, + * }} submission + * @returns {Object} unsigned kind-30083 event + */ +export function buildSubmission({ + pubkey, + id, + type = "application", + applicantNpub = "", + recipientPubkey = "", + content = "", + created_at, +} = {}) { + return buildSubmissionEvent({ + pubkey, + created_at: Number.isFinite(created_at) + ? created_at + : Math.floor(Date.now() / 1000), + dTagValue: id, + submissionType: type, + applicantNpub, + recipientPubkey, + content, + }); +} + +/** Generate a stable-ish submission id for the `d` tag. */ +export function generateSubmissionId() { + const time = Date.now().toString(36); + const rand = Math.random().toString(36).slice(2, 10); + return `sub-${time}-${rand}`; +} + +export default { + SUBMISSION_KIND, + SUBMISSION_TYPES, + SUBMISSION_TYPE_LABELS, + parseSubmissionEvent, + buildSubmission, + generateSubmissionId, +}; diff --git a/scripts/check-file-size.mjs b/scripts/check-file-size.mjs index 113a6a69..b8097b93 100644 --- a/scripts/check-file-size.mjs +++ b/scripts/check-file-size.mjs @@ -32,7 +32,7 @@ const GRANDFATHERED = { "js/channelProfile.js": 5529, "js/nostr/client.js": 4356, "js/ui/components/VideoCard.js": 3129, - "js/nostrEventSchemas.js": 3250, + "js/nostrEventSchemas.js": 3541, "js/ui/loginModalController.js": 2552, "js/historyView.js": 2613, "js/services/moderationService.js": 2512, diff --git a/tests/submission-service.test.mjs b/tests/submission-service.test.mjs new file mode 100644 index 00000000..48cb9d9e --- /dev/null +++ b/tests/submission-service.test.mjs @@ -0,0 +1,118 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { + SUBMISSION_KIND, + SUBMISSION_TYPES, + parseSubmissionEvent, + buildSubmission, + generateSubmissionId, +} from "../js/submissions/submissionService.js"; +import { buildSubmissionEvent } from "../js/nostrEventSchemas.js"; + +const EPHEMERAL = "e".repeat(64); +const ADMIN = "a".repeat(64); +const APPLICANT = "npub1exampleapplicant"; + +test("buildSubmissionEvent emits kind, d, type, applicant, recipient, topic", () => { + const event = buildSubmissionEvent({ + pubkey: EPHEMERAL, + created_at: 1000, + dTagValue: "sub-1", + submissionType: "application", + applicantNpub: APPLICANT, + recipientPubkey: ADMIN, + content: "Please whitelist me, I make Blender tutorials.", + }); + + assert.equal(event.kind, SUBMISSION_KIND); + assert.equal(event.tags.find((t) => t[0] === "d")?.[1], "sub-1"); + assert.equal(event.tags.find((t) => t[0] === "t")?.[1], "bitvid-submission"); + assert.equal(event.tags.find((t) => t[0] === "k")?.[1], "application"); + assert.equal(event.tags.find((t) => t[0] === "applicant")?.[1], APPLICANT); + assert.equal(event.tags.find((t) => t[0] === "p")?.[1], ADMIN); + assert.equal(event.content, "Please whitelist me, I make Blender tutorials."); +}); + +test("buildSubmissionEvent requires a d-tag", () => { + assert.throws( + () => buildSubmissionEvent({ pubkey: EPHEMERAL, created_at: 1, dTagValue: " " }), + /requires a d-tag/i, + ); +}); + +test("buildSubmissionEvent defaults type to application", () => { + const event = buildSubmissionEvent({ + pubkey: EPHEMERAL, + created_at: 1, + dTagValue: "d", + }); + assert.equal(event.tags.find((t) => t[0] === "k")?.[1], "application"); +}); + +test("parseSubmissionEvent structures a submission", () => { + const event = buildSubmissionEvent({ + pubkey: EPHEMERAL, + created_at: 4242, + dTagValue: "sub-7", + submissionType: "bug", + applicantNpub: APPLICANT, + recipientPubkey: ADMIN, + content: "the zap button is off-center", + }); + event.id = "event-id-9"; + + const parsed = parseSubmissionEvent(event); + assert.equal(parsed.id, "sub-7"); + assert.equal(parsed.eventId, "event-id-9"); + assert.equal(parsed.type, "bug"); + assert.equal(parsed.applicant, APPLICANT); + assert.equal(parsed.recipient, ADMIN); + assert.equal(parsed.content, "the zap button is off-center"); + assert.equal(parsed.createdAt, 4242); + assert.equal(parsed.pubkey, EPHEMERAL); +}); + +test("parseSubmissionEvent normalizes an unknown type to 'other'", () => { + const parsed = parseSubmissionEvent({ + kind: SUBMISSION_KIND, + pubkey: EPHEMERAL, + created_at: 1, + tags: [["d", "x"], ["k", "totally-made-up"]], + }); + assert.equal(parsed.type, "other"); +}); + +test("parseSubmissionEvent rejects the wrong kind and a missing d", () => { + assert.equal(parseSubmissionEvent({ kind: 1, tags: [["d", "x"]] }), null); + assert.equal( + parseSubmissionEvent({ kind: SUBMISSION_KIND, tags: [["k", "application"]] }), + null, + ); + assert.equal(parseSubmissionEvent(null), null); +}); + +test("build → parse round-trips every known type", () => { + for (const type of Object.values(SUBMISSION_TYPES)) { + const event = buildSubmission({ + pubkey: EPHEMERAL, + id: `rt-${type}`, + type, + applicantNpub: APPLICANT, + recipientPubkey: ADMIN, + content: `body for ${type}`, + created_at: 5, + }); + const parsed = parseSubmissionEvent({ ...event, id: `signed-${type}` }); + assert.equal(parsed.type, type); + assert.equal(parsed.content, `body for ${type}`); + assert.equal(parsed.applicant, APPLICANT); + } +}); + +test("generateSubmissionId produces distinct, tag-safe ids", () => { + const a = generateSubmissionId(); + const b = generateSubmissionId(); + assert.notEqual(a, b); + assert.match(a, /^sub-[0-9a-z]+-[0-9a-z]+$/); +});