mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
submissions(#23): foundation — kind-30083 schema, builder, service + tests
First slice of structured submissions (replacing the kind-4 encrypted DM forms). Pure, hermetic core — no UI or network yet. - nostrEventSchemas.js: SUBMISSION_KIND=30083 (+ NOTE_TYPES.SUBMISSION, BASE_SCHEMAS entry) and buildSubmissionEvent(). PUBLIC + addressable; data in tags: d (id), k (type), applicant (npub), p (admin), t=bitvid-submission; free-text in content. - js/submissions/submissionService.js: SUBMISSION_TYPES + labels, parseSubmissionEvent, buildSubmission, generateSubmissionId. Pure — unit-testable with no relays/signer. - constants.js: FEATURE_SUBMISSIONS (default OFF until the admin UI + forms ship). - tests/submission-service.test.mjs: 8 scenarios (build tags/type/default, d-tag required, parse structure/reject/unknown-type→other, build→parse round-trip of every type, id generation). - check-file-size.mjs: bump nostrEventSchemas grandfather 3250→3541 (adding a new event type to the schema registry is the prescribed pattern). - docs/nostr-event-schemas.md: catalogue row. Schema validation ✨, lint, build all pass; tests 8/8. Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
1 parent
87e31d9853
commit
a18230eb70
6 files changed
+341
-1
No files matched your search
@@ -198,6 +198,7 @@ Video posts should treat `videoRootId` as the stable series identifier that rema
|
||||
| Zap receipt (`NOTE_TYPES.ZAP_RECEIPT`) | `9735` | `['bolt11', <invoice>]`, `['description', <zap request JSON>]`, `['p', <recipient pubkey>]` plus optional `['e', <event id>]` and `['a', <coordinate>]` | Empty content; receipts are published by the recipient's LNURL server |
|
||||
| Zap tally (`NOTE_TYPES.ZAP_TALLY`) | `30081` (addressable, `d = payment_hash`) | `['d', <payment_hash>]`, `['p', <recipient>]`, optional `['e', <event id>]`/`['a', <coordinate>]`, `['amount', <msats>]`, `['bolt11', <invoice>]`, `['preimage', <hex>]`, `['description', <zap request JSON>]`, `['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', <playlist id>]`, optional `['title', ...]`/`['description', ...]`/`['image', ...]`, then **ordered** `['a', '30078:pubkey:d']` refs per video (`['e', <event id>]` 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', <id>]`, `['k', 'application'\|'appeal'\|'bug'\|'feature'\|'feedback']`, `['applicant', <npub>]`, `['p', <admin hex>]`; 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', <url>]`, `['method', <http method>]`, `['payload', <payload hash>]` | Optional plaintext content (NIP-98 recommends empty, but implementations may vary) |
|
||||
| Report (`NOTE_TYPES.REPORT`) | `1984` | `['e', <event id>, <report type>]`, `['p', <pubkey>, <report type>]`, and optional `['t', <report type>]` | Plaintext report reason |
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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]+$/);
|
||||
});
|
||||
Reference in new issue
Block a user