@stage5/lumine 0.2.90 → 0.2.91
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/admin.js +303 -0
- package/lib/commands.js +11 -0
- package/lib/rewards.js +1 -1
- package/package.json +1 -1
- package/sdk/BUILD_SDK_INDEX.md +47 -14
- package/sdk/LUMINE_ADMIN.md +103 -0
package/lib/admin.js
CHANGED
|
@@ -138,6 +138,8 @@ function readBuildReviewContextFile(filePath) {
|
|
|
138
138
|
|
|
139
139
|
const MAX_REWARD_CONFIG_FILE_BYTES = 256 * 1024;
|
|
140
140
|
const REWARD_REVIEW_STATUSES = ["pending", "approved", "all"];
|
|
141
|
+
const CHAT_REPORT_STATUSES = ["open", "reviewing", "resolved", "dismissed"];
|
|
142
|
+
const CHAT_REPORT_LIST_STATUSES = ["pending", ...CHAT_REPORT_STATUSES, "all"];
|
|
141
143
|
const REWARD_REVIEW_DECISIONS = ["approve", "reject", "revoke"];
|
|
142
144
|
const REWARD_PROPOSAL_MAX_FILES = 500;
|
|
143
145
|
const REWARD_PROPOSAL_MAX_BYTES = 5 * 1024 * 1024;
|
|
@@ -648,6 +650,29 @@ export async function adminCommand(options) {
|
|
|
648
650
|
},
|
|
649
651
|
};
|
|
650
652
|
}
|
|
653
|
+
if (operation.name === "chat-reports.export" && operation.evidenceDir) {
|
|
654
|
+
// The package goes to disk only; the printed result keeps paths, sizes
|
|
655
|
+
// and hashes, never the evidence itself.
|
|
656
|
+
const written = writeEvidencePackage({
|
|
657
|
+
directory: operation.evidenceDir,
|
|
658
|
+
files: result?.data?.files,
|
|
659
|
+
});
|
|
660
|
+
const { files: _files, manifest: _manifest, ...summary } = result?.data || {};
|
|
661
|
+
if (
|
|
662
|
+
summary.manifestSha256 &&
|
|
663
|
+
summary.manifestSha256 !== written.manifestSha256
|
|
664
|
+
) {
|
|
665
|
+
const error = new Error(
|
|
666
|
+
"The written manifest.json does not match the hash the server recorded. Export again into a new directory.",
|
|
667
|
+
);
|
|
668
|
+
error.code = "LUMINE_ADMIN_EVIDENCE_HASH_MISMATCH";
|
|
669
|
+
throw error;
|
|
670
|
+
}
|
|
671
|
+
result = {
|
|
672
|
+
...result,
|
|
673
|
+
data: { ...summary, evidenceDirectory: written.directory, files: written.files },
|
|
674
|
+
};
|
|
675
|
+
}
|
|
651
676
|
if (operation.name === "news.claim") {
|
|
652
677
|
const artifacts = writeNewsClaimArtifacts({
|
|
653
678
|
result,
|
|
@@ -1295,6 +1320,86 @@ function noActiveRunError() {
|
|
|
1295
1320
|
return error;
|
|
1296
1321
|
}
|
|
1297
1322
|
|
|
1323
|
+
function requireChatSafetyNote(value) {
|
|
1324
|
+
const note = String(value || "").trim();
|
|
1325
|
+
if (!note) {
|
|
1326
|
+
throw cliValidationError(
|
|
1327
|
+
"Say why with --note <text>; it is kept in the safety audit trail.",
|
|
1328
|
+
);
|
|
1329
|
+
}
|
|
1330
|
+
if (note.length > 2000) {
|
|
1331
|
+
throw cliValidationError("--note must be at most 2000 characters.");
|
|
1332
|
+
}
|
|
1333
|
+
return note;
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
function parseIdList(value, label) {
|
|
1337
|
+
const ids = String(value || "")
|
|
1338
|
+
.split(",")
|
|
1339
|
+
.map((part) => part.trim())
|
|
1340
|
+
.filter(Boolean)
|
|
1341
|
+
.map((part) => parseRequiredInteger(part, label, 1));
|
|
1342
|
+
if (!ids.length) throw cliValidationError(`${label} needs at least one id.`);
|
|
1343
|
+
return Array.from(new Set(ids));
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
// Writes a child-safety evidence package into a new or empty private
|
|
1347
|
+
// directory and checks every written file against the server's SHA-256
|
|
1348
|
+
// manifest, so what lands on disk is exactly what the server hashed.
|
|
1349
|
+
export function writeEvidencePackage({ directory, files }) {
|
|
1350
|
+
const list = Array.isArray(files) ? files : [];
|
|
1351
|
+
const manifestFile = list.find((file) => file?.path === "manifest.json");
|
|
1352
|
+
if (!manifestFile) {
|
|
1353
|
+
throw cliValidationError("The evidence package has no manifest.json.");
|
|
1354
|
+
}
|
|
1355
|
+
let manifest;
|
|
1356
|
+
try {
|
|
1357
|
+
manifest = JSON.parse(String(manifestFile.content || ""));
|
|
1358
|
+
} catch {
|
|
1359
|
+
throw cliValidationError("The evidence manifest is not valid JSON.");
|
|
1360
|
+
}
|
|
1361
|
+
let written;
|
|
1362
|
+
try {
|
|
1363
|
+
written = writeRewardReviewSnapshot({ directory, files: list });
|
|
1364
|
+
} catch (error) {
|
|
1365
|
+
error.message = String(error.message || "").replaceAll("--dir", "--out");
|
|
1366
|
+
throw error;
|
|
1367
|
+
}
|
|
1368
|
+
const sha = (text) => createHash("sha256").update(text).digest("hex");
|
|
1369
|
+
const mismatches = [];
|
|
1370
|
+
for (const entry of manifest.files || []) {
|
|
1371
|
+
const target = path.join(written.directory, entry.path);
|
|
1372
|
+
const actual = existsSync(target) ? sha(readFileSync(target)) : "missing";
|
|
1373
|
+
if (actual !== entry.sha256) mismatches.push(entry.path);
|
|
1374
|
+
}
|
|
1375
|
+
const listed = new Set((manifest.files || []).map((entry) => entry.path));
|
|
1376
|
+
for (const file of list) {
|
|
1377
|
+
if (file.path !== "manifest.json" && !listed.has(file.path)) {
|
|
1378
|
+
mismatches.push(file.path);
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
if (mismatches.length) {
|
|
1382
|
+
const error = new Error(
|
|
1383
|
+
`The evidence package in ${written.directory} does not match its manifest (${mismatches.join(", ")}). Do not hand it over; export again into a new directory.`,
|
|
1384
|
+
);
|
|
1385
|
+
error.code = "LUMINE_ADMIN_EVIDENCE_HASH_MISMATCH";
|
|
1386
|
+
throw error;
|
|
1387
|
+
}
|
|
1388
|
+
return {
|
|
1389
|
+
directory: written.directory,
|
|
1390
|
+
manifestSha256: sha(readFileSync(path.join(written.directory, "manifest.json"))),
|
|
1391
|
+
files: written.files.map((file) => ({
|
|
1392
|
+
path: file.path.replace(/^\//, ""),
|
|
1393
|
+
bytes: file.bytes,
|
|
1394
|
+
sha256:
|
|
1395
|
+
file.path === "/manifest.json"
|
|
1396
|
+
? null
|
|
1397
|
+
: (manifest.files || []).find((entry) => `/${entry.path}` === file.path)
|
|
1398
|
+
?.sha256 || null,
|
|
1399
|
+
})),
|
|
1400
|
+
};
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1298
1403
|
export function parseAdminOperation(options) {
|
|
1299
1404
|
const [namespace = "", action = "", target = "", extra = ""] =
|
|
1300
1405
|
options.positional;
|
|
@@ -1594,6 +1699,159 @@ export function parseAdminOperation(options) {
|
|
|
1594
1699
|
);
|
|
1595
1700
|
}
|
|
1596
1701
|
|
|
1702
|
+
if (namespace === "chat-reports" || namespace === "chat-report") {
|
|
1703
|
+
// Members' in-chat message reports. Like reward reviews they are handled
|
|
1704
|
+
// any time, never inside a daily-run lease; recording an outcome only
|
|
1705
|
+
// annotates the report and never acts against a member.
|
|
1706
|
+
if (!action || action === "list") {
|
|
1707
|
+
return readOperation(
|
|
1708
|
+
"chat-reports.list",
|
|
1709
|
+
withQuery("/cli/admin/chat-reports", {
|
|
1710
|
+
status: parseChoice(
|
|
1711
|
+
options.adminStatus || "pending",
|
|
1712
|
+
"--status",
|
|
1713
|
+
CHAT_REPORT_LIST_STATUSES,
|
|
1714
|
+
),
|
|
1715
|
+
beforeId: options.adminCursor
|
|
1716
|
+
? parseRequiredInteger(options.adminCursor, "--cursor", 1)
|
|
1717
|
+
: "",
|
|
1718
|
+
limit: options.limit,
|
|
1719
|
+
}),
|
|
1720
|
+
{ requiresRun: false },
|
|
1721
|
+
);
|
|
1722
|
+
}
|
|
1723
|
+
if (action === "show" || action === "get") {
|
|
1724
|
+
const reportId = parseRequiredInteger(target, "Chat report ID", 1);
|
|
1725
|
+
return readOperation(
|
|
1726
|
+
"chat-reports.show",
|
|
1727
|
+
`/cli/admin/chat-reports/${reportId}`,
|
|
1728
|
+
{ requiresRun: false },
|
|
1729
|
+
);
|
|
1730
|
+
}
|
|
1731
|
+
if (action === "set") {
|
|
1732
|
+
const reportId = parseRequiredInteger(target, "Chat report ID", 1);
|
|
1733
|
+
const status = parseChoice(
|
|
1734
|
+
options.adminStatus,
|
|
1735
|
+
"--status",
|
|
1736
|
+
CHAT_REPORT_STATUSES,
|
|
1737
|
+
);
|
|
1738
|
+
const note = String(options.note || "").trim();
|
|
1739
|
+
if (!note) {
|
|
1740
|
+
throw cliValidationError(
|
|
1741
|
+
"Record what was decided or done with --note <text>.",
|
|
1742
|
+
);
|
|
1743
|
+
}
|
|
1744
|
+
if (note.length > 2000) {
|
|
1745
|
+
throw cliValidationError("--note must be at most 2000 characters.");
|
|
1746
|
+
}
|
|
1747
|
+
return writeOperation(
|
|
1748
|
+
"chat-reports.set",
|
|
1749
|
+
"PUT",
|
|
1750
|
+
`/cli/admin/chat-reports/${reportId}`,
|
|
1751
|
+
{ status, note },
|
|
1752
|
+
{ requiresRun: false, reportId },
|
|
1753
|
+
);
|
|
1754
|
+
}
|
|
1755
|
+
// Child-safety holds, evidence export and suspension (owner only; the
|
|
1756
|
+
// API admits only the owner's login). A hold keeps a protected copy of
|
|
1757
|
+
// anything the held conversation or members delete or edit.
|
|
1758
|
+
if (action === "hold") {
|
|
1759
|
+
const note = requireChatSafetyNote(options.note);
|
|
1760
|
+
const body = { note };
|
|
1761
|
+
if (target) body.reportId = parseRequiredInteger(target, "Chat report ID", 1);
|
|
1762
|
+
if (options.adminUser) {
|
|
1763
|
+
body.userIds = parseIdList(options.adminUser, "--user");
|
|
1764
|
+
}
|
|
1765
|
+
if (options.adminChannel) {
|
|
1766
|
+
body.channelId = parseRequiredInteger(options.adminChannel, "--channel", 1);
|
|
1767
|
+
}
|
|
1768
|
+
if (!body.reportId && !body.userIds && !body.channelId) {
|
|
1769
|
+
throw cliValidationError(
|
|
1770
|
+
"Usage: lumine admin chat-reports hold <report-id> | --user <id>[,<id>] [--channel <id>] | --channel <id> --note <why>.",
|
|
1771
|
+
);
|
|
1772
|
+
}
|
|
1773
|
+
return writeOperation(
|
|
1774
|
+
"chat-reports.hold",
|
|
1775
|
+
"POST",
|
|
1776
|
+
"/cli/admin/chat-reports/holds",
|
|
1777
|
+
body,
|
|
1778
|
+
{ requiresRun: false },
|
|
1779
|
+
);
|
|
1780
|
+
}
|
|
1781
|
+
if (action === "release") {
|
|
1782
|
+
const holdId = parseRequiredInteger(
|
|
1783
|
+
target || options.adminHold,
|
|
1784
|
+
"Safety hold ID",
|
|
1785
|
+
1,
|
|
1786
|
+
);
|
|
1787
|
+
return writeOperation(
|
|
1788
|
+
"chat-reports.release",
|
|
1789
|
+
"PUT",
|
|
1790
|
+
`/cli/admin/chat-reports/holds/${holdId}/release`,
|
|
1791
|
+
{ note: requireChatSafetyNote(options.note) },
|
|
1792
|
+
{ requiresRun: false, holdId },
|
|
1793
|
+
);
|
|
1794
|
+
}
|
|
1795
|
+
if (action === "list-holds" || action === "holds") {
|
|
1796
|
+
return readOperation(
|
|
1797
|
+
"chat-reports.list-holds",
|
|
1798
|
+
withQuery("/cli/admin/chat-reports/holds", {
|
|
1799
|
+
status: parseChoice(
|
|
1800
|
+
options.adminStatus || "active",
|
|
1801
|
+
"--status",
|
|
1802
|
+
["active", "released", "all"],
|
|
1803
|
+
),
|
|
1804
|
+
}),
|
|
1805
|
+
{ requiresRun: false },
|
|
1806
|
+
);
|
|
1807
|
+
}
|
|
1808
|
+
if (action === "export") {
|
|
1809
|
+
const out = String(options.out || "").trim();
|
|
1810
|
+
if (!out) {
|
|
1811
|
+
throw cliValidationError(
|
|
1812
|
+
"Pass a new or empty directory with --out <dir> for the evidence package.",
|
|
1813
|
+
);
|
|
1814
|
+
}
|
|
1815
|
+
if (target && options.adminHold) {
|
|
1816
|
+
throw cliValidationError("Export either one report ID or one --hold <id>, not both.");
|
|
1817
|
+
}
|
|
1818
|
+
const body = target
|
|
1819
|
+
? { reportId: parseRequiredInteger(target, "Chat report ID", 1) }
|
|
1820
|
+
: options.adminHold
|
|
1821
|
+
? { holdId: parseRequiredInteger(options.adminHold, "--hold", 1) }
|
|
1822
|
+
: null;
|
|
1823
|
+
if (!body) {
|
|
1824
|
+
throw cliValidationError(
|
|
1825
|
+
"Usage: lumine admin chat-reports export <report-id> | --hold <hold-id> --out <dir>.",
|
|
1826
|
+
);
|
|
1827
|
+
}
|
|
1828
|
+
// A POST because the export is audited server-side (who, when, the
|
|
1829
|
+
// manifest hash); it changes no member data.
|
|
1830
|
+
return writeOperation(
|
|
1831
|
+
"chat-reports.export",
|
|
1832
|
+
"POST",
|
|
1833
|
+
"/cli/admin/chat-reports/export",
|
|
1834
|
+
body,
|
|
1835
|
+
{ requiresRun: false, evidenceDir: out },
|
|
1836
|
+
);
|
|
1837
|
+
}
|
|
1838
|
+
if (action === "suspend") {
|
|
1839
|
+
return writeOperation(
|
|
1840
|
+
"chat-reports.suspend",
|
|
1841
|
+
"POST",
|
|
1842
|
+
"/cli/admin/chat-reports/suspend",
|
|
1843
|
+
{
|
|
1844
|
+
userId: parseRequiredInteger(options.adminUser || target, "--user", 1),
|
|
1845
|
+
note: requireChatSafetyNote(options.note),
|
|
1846
|
+
},
|
|
1847
|
+
{ requiresRun: false },
|
|
1848
|
+
);
|
|
1849
|
+
}
|
|
1850
|
+
throw cliValidationError(
|
|
1851
|
+
"Usage: lumine admin chat-reports list [--status pending|open|reviewing|resolved|dismissed|all] [--cursor <id>] | show <id> | set <id> --status reviewing|resolved|dismissed|open --note <decision> | hold <report-id>|--user <id>|--channel <id> --note <why> | release <hold-id> --note <why> | list-holds [--status active|released|all] | export <report-id>|--hold <id> --out <dir> | suspend --user <id> --note <why>.",
|
|
1852
|
+
);
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1597
1855
|
if (namespace === "reward-activity" || namespace === "reward-telemetry") {
|
|
1598
1856
|
// Read-only claim telemetry for the daily run: no run lease, no mutation.
|
|
1599
1857
|
const date = options.adminDate
|
|
@@ -4037,8 +4295,53 @@ function printRewardActivity(data) {
|
|
|
4037
4295
|
);
|
|
4038
4296
|
}
|
|
4039
4297
|
|
|
4298
|
+
function describeSafetyHold(hold) {
|
|
4299
|
+
const covers = [
|
|
4300
|
+
...(hold.channelIds || []).map((id) => `channel ${id}`),
|
|
4301
|
+
...(hold.userIds || []).map((id) => `user ${id}`),
|
|
4302
|
+
].join(", ");
|
|
4303
|
+
const placed = hold.placedAt
|
|
4304
|
+
? new Date(hold.placedAt * 1000).toISOString()
|
|
4305
|
+
: "unknown";
|
|
4306
|
+
return `Hold #${hold.id} ${hold.status} (${hold.source}, placed ${placed}${hold.placedByUserId ? ` by user ${hold.placedByUserId}` : " automatically"}): ${covers || "nothing"}; reports ${(hold.reportIds || []).map((id) => `#${id}`).join(", ") || "none"}; ${hold.preservedRecords || 0} preserved cop${hold.preservedRecords === 1 ? "y" : "ies"}. ${hold.reason || ""}`;
|
|
4307
|
+
}
|
|
4308
|
+
|
|
4309
|
+
function printChatSafetyResult({ operation, data }) {
|
|
4310
|
+
if (operation.name === "chat-reports.hold" || operation.name === "chat-reports.release") {
|
|
4311
|
+
if (data.hold) console.log(describeSafetyHold(data.hold));
|
|
4312
|
+
return true;
|
|
4313
|
+
}
|
|
4314
|
+
if (operation.name === "chat-reports.list-holds") {
|
|
4315
|
+
const holds = data.holds || [];
|
|
4316
|
+
console.log(`${holds.length} ${data.filter || ""} safety hold(s).`);
|
|
4317
|
+
for (const hold of holds) console.log(` ${describeSafetyHold(hold)}`);
|
|
4318
|
+
return true;
|
|
4319
|
+
}
|
|
4320
|
+
if (operation.name === "chat-reports.export") {
|
|
4321
|
+
console.log(
|
|
4322
|
+
`Evidence package written to ${data.evidenceDirectory} (${(data.files || []).length} files, hashes verified).`,
|
|
4323
|
+
);
|
|
4324
|
+
console.log(`manifest.json SHA-256: ${data.manifestSha256}`);
|
|
4325
|
+
for (const file of data.files || []) {
|
|
4326
|
+
console.log(` ${file.path} ${file.bytes} bytes${file.sha256 ? ` ${file.sha256}` : ""}`);
|
|
4327
|
+
}
|
|
4328
|
+
console.log(
|
|
4329
|
+
"Keep this folder private. Give it only to police or child-protection staff handling the case, and write the manifest hash in your records.",
|
|
4330
|
+
);
|
|
4331
|
+
return true;
|
|
4332
|
+
}
|
|
4333
|
+
if (operation.name === "chat-reports.suspend") {
|
|
4334
|
+
console.log(
|
|
4335
|
+
`${data.user?.username || "user"} (user ${data.user?.id}) ${data.alreadySuspended ? "was already" : "is now"} suspended (full ban: signed out, cannot use the site).${(data.holdIds || []).length ? ` Safety hold(s): #${data.holdIds.join(", #")}.` : " No safety hold covers this account yet; place one with chat-reports hold."}`,
|
|
4336
|
+
);
|
|
4337
|
+
return true;
|
|
4338
|
+
}
|
|
4339
|
+
return false;
|
|
4340
|
+
}
|
|
4341
|
+
|
|
4040
4342
|
function printAdminResult({ operation, result }) {
|
|
4041
4343
|
const data = result?.data || {};
|
|
4344
|
+
if (printChatSafetyResult({ operation, data })) return;
|
|
4042
4345
|
if (operation.name === "reward-activity.report") {
|
|
4043
4346
|
printRewardActivity(data);
|
|
4044
4347
|
return;
|
package/lib/commands.js
CHANGED
|
@@ -2578,6 +2578,9 @@ export function parseArgs(args) {
|
|
|
2578
2578
|
adminBucketId: raw.bucketId ? String(raw.bucketId) : "",
|
|
2579
2579
|
adminLabel: raw.label ? String(raw.label) : "",
|
|
2580
2580
|
adminUserIds: raw.userIds ? String(raw.userIds) : "",
|
|
2581
|
+
adminUser: raw.user ? String(raw.user) : "",
|
|
2582
|
+
adminChannel: raw.channel ? String(raw.channel) : "",
|
|
2583
|
+
adminHold: raw.hold ? String(raw.hold) : "",
|
|
2581
2584
|
adminEmail: raw.email ? String(raw.email) : "",
|
|
2582
2585
|
adminMode: raw.mode ? String(raw.mode) : "",
|
|
2583
2586
|
adminType: raw.type ? String(raw.type) : "",
|
|
@@ -2984,6 +2987,14 @@ export function printHelp() {
|
|
|
2984
2987
|
lumine admin sponsor integrity review <case-id> --decision clear|hold|flag|disqualify [--note <evidence>] [--json]
|
|
2985
2988
|
lumine admin reward-review list [--status pending|approved|all] [--cursor <id>] [--json]
|
|
2986
2989
|
lumine admin reward-activity [--date YYYY-MM-DD] [--days <1..31>] [--build <id>] [--json]
|
|
2990
|
+
lumine admin chat-reports list [--status pending|open|reviewing|resolved|dismissed|all] [--cursor <id>] [--json]
|
|
2991
|
+
lumine admin chat-reports show <report-id> [--json] (reported message snapshot + surrounding context)
|
|
2992
|
+
lumine admin chat-reports set <report-id> --status reviewing|resolved|dismissed|open --note <what was decided> [--json]
|
|
2993
|
+
lumine admin chat-reports hold <report-id> | --user <id>[,<id>] [--channel <id>] | --channel <id> --note <why> [--json] (child-safety evidence hold)
|
|
2994
|
+
lumine admin chat-reports release <hold-id> --note <why> [--json]
|
|
2995
|
+
lumine admin chat-reports list-holds [--status active|released|all] [--json]
|
|
2996
|
+
lumine admin chat-reports export <report-id> | --hold <hold-id> --out <new-dir> [--json] (evidence package + SHA-256 manifest)
|
|
2997
|
+
lumine admin chat-reports suspend --user <id> --note <why> [--json] (full ban: signs them out)
|
|
2987
2998
|
lumine admin reward-review show <review-id> [--dir <path>] [--json]
|
|
2988
2999
|
lumine admin reward-review approve <review-id> [--config <rules.json>] [--reason <text>] [--json] (approval publishes the approved version)
|
|
2989
3000
|
lumine admin reward-review propose <review-id> --dir <edited-snapshot> --config <rules.json> [--reason <text>] [--json]
|
package/lib/rewards.js
CHANGED
|
@@ -238,7 +238,7 @@ function printRewardsHelp() {
|
|
|
238
238
|
|
|
239
239
|
rewards.json (project root) declares the economy the reviewer approves:
|
|
240
240
|
{ "userDailyXP", "userDailyCoins", "userDailyClaims"?,
|
|
241
|
-
"rules": [{ "id", "title", "xp", "coins", "verifier": "numeric-quiz" | "completion",
|
|
241
|
+
"rules": [{ "id", "title", "howTo"? (how to earn it, in the player's words), "xp", "coins", "verifier": "numeric-quiz" | "completion",
|
|
242
242
|
"maxAttempts"?, "retry"?: { "xpPercent", "coinsPercent", "paidAttempts"? }, "minSeconds"? (completion), "progression"?: "dated" | "until-earned" (quiz) }] }
|
|
243
243
|
Questions and answer keys never go in project files; they belong in the sheet.`);
|
|
244
244
|
}
|
package/package.json
CHANGED
package/sdk/BUILD_SDK_INDEX.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# Build SDK Index
|
|
2
2
|
|
|
3
|
-
Version: 1.
|
|
4
|
-
Updated: 2026-09-
|
|
5
|
-
Generated: 2026-09-
|
|
3
|
+
Version: 1.60.0
|
|
4
|
+
Updated: 2026-09-27
|
|
5
|
+
Generated: 2026-09-27T07:17:40.033Z
|
|
6
6
|
|
|
7
7
|
## Notes
|
|
8
8
|
- This SDK is injected into Build iframes via the Build preview/runtime.
|
|
@@ -36,7 +36,7 @@ Generated: 2026-09-26T05:55:48.545Z
|
|
|
36
36
|
- The creator's agent designs the rewards. Declare the economy in a project file `rewards.json` at the root: budgets (userDailyXP, userDailyCoins, optional userDailyClaims; there is no app-wide daily or lifetime budget, only what one learner can earn per day) and rules [{ id, title, xp, coins, verifier: 'numeric-quiz' | 'completion', maxAttempts?, retry?: { xpPercent, coinsPercent, paidAttempts? }, minSeconds? (completion), progression?: 'dated' | 'until-earned' (quiz) }]. Wire the matching Twinkle.rewards calls with those literal rule ids. Questions and answer keys NEVER go in project files (published source is readable by every player): quiz rules get them from the private question sheet uploaded with `lumine rewards sheet <file.json>` ({ rules: { <ruleId>: { questions?, sets? } } }); `lumine rewards check` validates both together. A review request freezes the code and proposes rewards.json merged with the sheet; the administrator reads the code, checks the amounts and whether the app is exploitable, may change any amount, and approves. Creators are kids and teens: show approval status and one Send for review action; do not ask them to fill in technical forms. Every code update that retains rewards needs a new approval before publishing. Removing the SDK automatically clears its gate. Apps read amounts, tries and sets from getStatus, never from their own file.
|
|
37
37
|
- Verifiers: 'numeric-quiz' pays for server-checked numeric answers (retry share, attempt limits, dated sets or until-earned sets that stay up until somebody earns them, after-answer guides). 'completion' pays when the app reports an activity finished — a cleared stage, a finished round — at least minSeconds after start({ ruleId }); the server checks only the elapsed time, once per learner per site day (UTC midnight), and the budgets. Call start when the activity begins and claim({ challengeId }) with no answers when it ends; keep completion amounts and userDailyXP small enough that a player scripting the calls would not matter, because nothing else is verified.
|
|
38
38
|
- Numeric quiz answers are verified on the server; client scores, privateDb state, timers and completion booleans are not verified reward evidence. Limits reset at UTC midnight. Rules are earned once per viewer per UTC day; attempt limits and retry payouts come from the approved rule. Challenges expire at the UTC day boundary. Budgets apply across release changes.
|
|
39
|
-
- Optional reward rule controls: maxLifetimeClaims caps one learner’s receipts for that rule across every day and release; completionProof: classic-tower-v1 requires a server-simulated Classic Tower finish in addition to minSeconds. These are server-enforced controls. Existing completion rules without completionProof still verify elapsed time only. Registered proof profiles also include breadface-v1, breadface-v2 and breadface-v3 (server-simulated Breadface inputs; each is one reviewed release's exact physics, chosen by the reviewer) study-record-v1 (a private study record reviewed by JEV, billed to the learner’s AI Energy), and Groove Lab's groove-lab-song-v1 (a song the learner published today, checked on the server for length, notes and originality) and groove-lab-heard-v1 (three established accounts finished the learner's songs today). Profiles are platform-owned; an app cannot invent a verifier or authorize its own reward.
|
|
39
|
+
- Optional reward rule controls: maxLifetimeClaims caps one learner’s receipts for that rule across every day and release; completionProof: classic-tower-v1 requires a server-simulated Classic Tower finish in addition to minSeconds. These are server-enforced controls. Existing completion rules without completionProof still verify elapsed time only. Registered proof profiles also include breadface-v1, breadface-v2 and breadface-v3 (server-simulated Breadface inputs; each is one reviewed release's exact physics, chosen by the reviewer) study-record-v1 (a private study record reviewed by JEV, billed to the learner’s AI Energy), and Groove Lab's groove-lab-song-v1 (a song the learner published today, checked on the server for length, notes and originality) and groove-lab-heard-v1 (three established accounts finished the learner's songs today), Ashen Vigil's vigil-guest-coplay-v1 (a new guest spent 10 minutes with the learner in a private world room, measured by the world relay; each guest pays once) and minecraft-first-link-v1 (the learner is the first Twinkle account ever linked to that Minecraft player; each player pays once). Profiles are platform-owned; an app cannot invent a verifier or authorize its own reward.
|
|
40
40
|
|
|
41
41
|
## AI decision design
|
|
42
42
|
- When planning a new app or an improvement, consider whether model-based judgments would materially improve the requested experience. Twinkle.ai.decide runs JEV, a model for narrow decisions over supplied text or structured state. Potential uses include interpreting a player's request to an NPC, choosing among legal game actions, classifying user content, ranking supplied candidates, and adapting an activity to evidence about the learner. These are examples to reason from, not a keyword checklist or a requirement to add AI to every app.
|
|
@@ -608,9 +608,10 @@ const result = await Twinkle.characters.chat({ character: 'zero', thinkingMode:
|
|
|
608
608
|
- Returns subject ids plus rootType/rootId metadata for picker UIs. Empty queries return an empty result set.
|
|
609
609
|
- Example: const { subjects } = await Twinkle.subjects.search({ query: searchText, limit: 12 });
|
|
610
610
|
- async getSubject(subjectId) | scopes: content:read
|
|
611
|
-
- Returns: { subject: { id, title, description, filePath, fileName, fileSize, thumbUrl, secretAnswer, secretAttachment, timeStamp, userId, username, profilePicUrl, rootType, rootId, rewardLevel } }
|
|
611
|
+
- Returns: { subject: { id, title, description, filePath, fileName, fileSize, thumbUrl, secretAnswer, secretAttachment, hasSecretAnswer, hasSecretAttachment, secretShown, timeStamp, userId, username, profilePicUrl, rootType, rootId, rewardLevel } }
|
|
612
612
|
- Returns full detail for a single subject, including uploader info and attachments.
|
|
613
613
|
- Any subject can be fetched (not limited to viewer's own).
|
|
614
|
+
- secretAnswer and secretAttachment follow the site's rule: they are filled only when the viewer posted the subject or has responded to it (secretShown true). Otherwise they are null and hasSecretAnswer / hasSecretAttachment say a secret exists.
|
|
614
615
|
- async getSubjectComments(subjectId, { limit, cursor } = {}) | scopes: content:read
|
|
615
616
|
- Returns: { comments: [{ id, content, filePath, fileName, fileSize, thumbUrl, timeStamp }], cursor? }
|
|
616
617
|
- Returns only the current viewer's own comments on the given subject.
|
|
@@ -661,6 +662,7 @@ const result = await Twinkle.characters.chat({ character: 'zero', thinkingMode:
|
|
|
661
662
|
- Lists completed existing AI Stories newest first by default; order:'oldest' is allowed only with difficulty, type, and topicKey for chronological book pages.
|
|
662
663
|
- Use difficulty with type and topicKey to load one exact AI Story book without scanning the full corpus in the iframe.
|
|
663
664
|
- Filter with hasImage or hasQuestions when building visual galleries or quiz apps.
|
|
665
|
+
- A story the signed-in viewer is still playing (an AI Story attempt they have not submitted) comes back without answerIndex on its questions; the site reveals that key only after the viewer submits.
|
|
664
666
|
- Example: const { stories } = await Twinkle.aiStories.list({ difficulty: 1, type: 'science', topicKey: 'Astronomy', order: 'oldest', limit: 20 });
|
|
665
667
|
- async chapters({ limit, cursor, groupBy, difficulty, type, topicKey, storyBy, isListening, userId, hasImage, hasQuestions } = {}) | scopes: content:read
|
|
666
668
|
- Returns: Default (groupBy:'topicKey'): { chapters: [{ difficulty, type, topicKey, title, sampleTopic, storyCount, readingCount, listeningCount, imageCount, questionCount, latestStoryId, latestTimeStamp }], cursor?, pagination, filters }. groupBy:'type': { books: [{ difficulty, type, title, sampleTopic, chapterCount, storyCount, readingCount, listeningCount, imageCount, questionCount, latestStoryId, latestTimeStamp }], ... } — one row per (level, topic) book. groupBy:'author': { authors: [{ storyBy, title, bookCount, chapterCount, storyCount, minDifficulty, maxDifficulty, latestStoryId }], ... } — one row per generating model (the story's author); an index-only landing, so it omits media counts (use a scoped books/chapters call for those).
|
|
@@ -676,29 +678,41 @@ const result = await Twinkle.characters.chat({ character: 'zero', thinkingMode:
|
|
|
676
678
|
- Searches completed existing AI Stories by topic/story text.
|
|
677
679
|
- Use difficulty, type, and topicKey to search within one book of the AI Story corpus; order:'oldest' is rejected without all three filters.
|
|
678
680
|
- Returned questions are normalized to an array even when stored as JSON text.
|
|
681
|
+
- A story the signed-in viewer is still playing (an AI Story attempt they have not submitted) comes back without answerIndex on its questions; the site reveals that key only after the viewer submits.
|
|
679
682
|
- Example: const { stories } = await Twinkle.aiStories.search({ query: searchText, difficulty: 2, type: 'history', topicKey: 'Ancient Rome', order: 'oldest', limit: 12 });
|
|
680
683
|
- async get(storyId) | scopes: content:read
|
|
681
684
|
- Returns: { story: { id, contentType, contentId, topic, topicKey, type, story, explanation, difficulty, isListening, imagePath, imageUrl, audioPath, audioUrl, questions, questionsBy, hasImage, hasQuestions, userId, username, profilePicUrl, timeStamp } }
|
|
682
685
|
- Fetch one completed existing AI Story by id, including story text for passage typing, media URLs, and normalized questions when available.
|
|
683
686
|
- Fetches one completed existing AI Story by id.
|
|
687
|
+
- A story the signed-in viewer is still playing (an AI Story attempt they have not submitted) comes back without answerIndex on its questions; the site reveals that key only after the viewer submits.
|
|
684
688
|
- This namespace is read-only and does not generate new AI Stories.
|
|
685
689
|
- Example: const { story } = await Twinkle.aiStories.get(storyId);
|
|
686
690
|
|
|
687
691
|
### Twinkle.grammarbles
|
|
688
692
|
- async listQuestions({ level, limit, cursor } = {}) | scopes: content:read
|
|
689
|
-
- Returns: { questions: [{ id, level, rating, question, choices,
|
|
690
|
-
- Read
|
|
691
|
-
- Questions
|
|
693
|
+
- Returns: { questions: [{ id, level, rating, question, choices, isChecked }], cursor?, pagination: { level, limit, hasMore, nextCursor } }
|
|
694
|
+
- Read Grammarbles questions and choices by level with rating/id cursor pagination; answers stay on the server.
|
|
695
|
+
- Questions do not include the answer, answerIndex or explanation: Grammarbles is a rewarded game, so its key stays on the server. Check a pick with Twinkle.grammarbles.checkAnswer.
|
|
696
|
+
- Each question's choices come in a fixed order; pass the index of the picked choice in that order to checkAnswer.
|
|
692
697
|
- level is clamped from 1 through 5.
|
|
693
698
|
- Pagination is stable by rating then id. Pass cursor from the previous response to load more questions in the same level.
|
|
694
699
|
- This method does not expose daily attempt state, XP, coins, or daily-task progression.
|
|
695
700
|
- Example: const page = await Twinkle.grammarbles.listQuestions({ level: 3, limit: 100 }); const question = page.questions[Math.floor(Math.random() * page.questions.length)];
|
|
701
|
+
- async checkAnswer({ questionId, choiceIndex }) | scopes: content:read
|
|
702
|
+
- Returns: { questionId, choiceIndex, isCorrect, explanation }
|
|
703
|
+
- Check one Grammarbles pick on the server: right or wrong, plus the review explanation on a right pick.
|
|
704
|
+
- choiceIndex is 0-3 in the order listQuestions returned the choices.
|
|
705
|
+
- The right position is never returned. Let the learner try again until the pick is right, as Grammarbles itself does.
|
|
706
|
+
- explanation is null unless the pick is right and the question has a reviewed explanation.
|
|
707
|
+
- Each viewer has a daily allowance of answer checks (500 per UTC day); past it the call fails with code grammarbles_check_limit.
|
|
708
|
+
- Checking pays nothing and does not touch daily Grammarbles attempts, XP, coins, or daily tasks.
|
|
709
|
+
- Example: const { isCorrect, explanation } = await Twinkle.grammarbles.checkAnswer({ questionId: question.id, choiceIndex: pickedIndex }); if (!isCorrect) showTryAgain(); else showCorrect(explanation);
|
|
696
710
|
- async getMyQuestionHistory({ level, limit, cursor } = {}) | scopes: content:read
|
|
697
711
|
- Returns: { attempts: [{ id, questionId, level, grade, gradeRank, isCorrect, attemptNumber, timeStamp }], cursor?, pagination: { level, limit, hasMore, nextCursor } }
|
|
698
712
|
- Read the signed-in viewer's real Grammarbles attempt rows for trainer filtering.
|
|
699
713
|
- Returns real Grammarbles attempt outcome rows for the signed-in viewer, newest first.
|
|
700
714
|
- History rows intentionally omit choice indexes because real Grammarbles choices are shuffled per run and the per-run shuffle order is not persisted.
|
|
701
|
-
- Use Twinkle.grammarbles.listQuestions for
|
|
715
|
+
- Use Twinkle.grammarbles.listQuestions for question text and choices, and Twinkle.grammarbles.checkAnswer to check a pick.
|
|
702
716
|
- Use app-private history in Twinkle.privateDb for trainer-only results, and combine it with this method only when the viewer chooses to include real Grammarbles history.
|
|
703
717
|
- This method is read-only and does not submit, cancel, or mutate daily Grammarbles attempts.
|
|
704
718
|
- Example: const history = await Twinkle.grammarbles.getMyQuestionHistory({ level: selectedLevel, limit: 500 }); const answeredIds = new Set(history.attempts.map((attempt) => attempt.questionId));
|
|
@@ -802,19 +816,20 @@ const result = await Twinkle.characters.chat({ character: 'zero', thinkingMode:
|
|
|
802
816
|
- limit is 1-50 (default 10). kind filters to helper or workshop builds.
|
|
803
817
|
- Example: const { builds } = await Twinkle.minecraft.getZeroBuilds({ limit: 10, kind: 'workshop' });
|
|
804
818
|
- async getPeople() | scopes: content:read
|
|
805
|
-
- Returns: { canManage, people: [{ uuid, name, role: 'visitor'|'member'|'builder'|'moderator', op, online, isZero, twinkle: { userId, username } | null, ...owner: groups, banned, protected, firstSeenAt, lastSeenAt | ...others: seen: 'online'|'today'|'week'|'month'|'older'|null }], roles }
|
|
819
|
+
- Returns: { canManage, people: [{ uuid, name, role: 'visitor'|'member'|'builder'|'moderator', op, online, isZero, twinkle: { userId, username } | null, ...owner: groups, banned, protected, firstSeenAt, lastSeenAt, endorsements: [{ voucherName, voucherUuid, endorsedAt }] | ...others: seen: 'online'|'today'|'week'|'month'|'older'|null }], roles }
|
|
806
820
|
- Everyone who has joined the server with their in-game rank, for a player directory; the server owner also gets the full records for role management.
|
|
807
821
|
- Every viewer gets the list. Only the server owner, in an app they own, gets canManage: true with full records (groups, bans, exact first/last seen); everyone else gets canManage: false and the public view: banned players left out, and seen is a rough bucket instead of exact times. Show role controls only when canManage is true.
|
|
808
822
|
- Roles: visitor (play and chat), member (/tpa, /home, /back, may ask Zero to build), builder (member + /fly and creative/survival), moderator (builder + teleport others, CoreProtect rollback, /kick). op: true players are server operators and have every power regardless of role.
|
|
809
823
|
- protected: true players (the owner and Zero's account) can't be changed from the app. Sorted online first, then most recently seen.
|
|
810
824
|
- Not cached; call on screen open or after a change, not on a timer.
|
|
811
825
|
- twinkle is the Twinkle account linked to that player with /link, or null.
|
|
826
|
+
- endorsements (owner view only) lists who backed this player's Builder application: a moderator or anyone above typed /vouch for a player already on Twinkle. voucherUuid is the voucher's Minecraft uuid (null when endorsed from the app or console); endorsedAt is an ISO time. Empty when there are none, or when the server's vouch list could not be read.
|
|
812
827
|
- Example: const { canManage, people } = await Twinkle.minecraft.getPeople(); renderDirectory(people, { editable: canManage });
|
|
813
828
|
- async setPlayerRole({ uuid, role }) | scopes: content:write
|
|
814
829
|
- Returns: { player: { uuid, name, role, op } }
|
|
815
830
|
- Change a player's in-game role; it applies immediately, even while they are online.
|
|
816
831
|
- Server owner only, in an app they own; others get 403 with code minecraft_roles_forbidden.
|
|
817
|
-
- role is visitor, member, builder or moderator. 400 codes: minecraft_bad_uuid, minecraft_bad_role, minecraft_protected_player, minecraft_unknown_player. 503 minecraft_unavailable while the server restarts.
|
|
832
|
+
- role is visitor, member, builder, moderator, or a Partner rank above moderator: associate, senior_associate, partner, senior_partner (each keeps every moderator power). A promotion also sends the player's linked Twinkle account a DM, and the player gets a title and perks list in game. 400 codes: minecraft_bad_uuid, minecraft_bad_role, minecraft_protected_player, minecraft_unknown_player. 503 minecraft_unavailable while the server restarts.
|
|
818
833
|
- Every change is logged on the server and emailed to the owner; the player is told in game.
|
|
819
834
|
- Example: await Twinkle.minecraft.setPlayerRole({ uuid: person.uuid, role: 'builder' });
|
|
820
835
|
- async getChat({ since } = {}) | scopes: content:read
|
|
@@ -938,6 +953,23 @@ const result = await Twinkle.characters.chat({ character: 'zero', thinkingMode:
|
|
|
938
953
|
- Delete a snap from the Gallery.
|
|
939
954
|
- Who may: the player who took it (by a linked Minecraft account), moderators, and the server owner. Others get 403 minecraft_snap_forbidden.
|
|
940
955
|
- Example: await Twinkle.minecraft.deleteSnap({ id });
|
|
956
|
+
- async getRecruitTours() | scopes: content:read
|
|
957
|
+
- Returns: { enabled, title, landmarks: [{ id, name, world, x, z, radius }], requirements: { playHours, days }, tours: [{ uuid, name, introducerUuid, introducerName, startedAt, completedAt, visited: [landmarkId], playSeconds, days }] }
|
|
958
|
+
- Recruit tours: each player someone brought to the server, who brought them, and their progress (landmarks visited, hours and days played). A completed tour is one recruiter point for the introducer.
|
|
959
|
+
- Everyone can read it; one shared copy refreshes every 15 seconds.
|
|
960
|
+
- The landmark list and requirements come from the server's config, so show them from here rather than hard-coding them.
|
|
961
|
+
- Example: const { landmarks, tours } = await Twinkle.minecraft.getRecruitTours();
|
|
962
|
+
- async startRecruitTour({ uuid, introducerUuid }) | scopes: content:write
|
|
963
|
+
- Returns: { uuid, introducerUuid }
|
|
964
|
+
- Server owner only: record that introducerUuid brought player uuid to the server and start that player's landmark tour (a quest list in game).
|
|
965
|
+
- 403 minecraft_roles_forbidden for anyone but the server owner.
|
|
966
|
+
- 400 minecraft_already_credited when someone else was already credited; minecraft_self_introduced for the same player; minecraft_unknown_player when either never joined.
|
|
967
|
+
- Example: await Twinkle.minecraft.startRecruitTour({ uuid: recruit.uuid, introducerUuid: friend.uuid });
|
|
968
|
+
- async cancelRecruitTour({ uuid }) | scopes: content:write
|
|
969
|
+
- Returns: { removed }
|
|
970
|
+
- Server owner only: remove a player's recruit tour (a mistaken credit).
|
|
971
|
+
- 403 minecraft_roles_forbidden for anyone but the server owner.
|
|
972
|
+
- Example: await Twinkle.minecraft.cancelRecruitTour({ uuid });
|
|
941
973
|
- async getArrivalPoints() | scopes: content:read
|
|
942
974
|
- Returns: { accounts: [{ uuid, name, online, world, worldLabel, points: [{ world, label, x, y, z, savedAt }] }] }
|
|
943
975
|
- The viewer's private arrival points: where the Twinkle Gate portals drop their linked Minecraft account in each world.
|
|
@@ -1303,12 +1335,13 @@ world.updatePresence({ x, y, z, facing });
|
|
|
1303
1335
|
|
|
1304
1336
|
### Twinkle.rewards
|
|
1305
1337
|
- await Twinkle.rewards.getStatus() | scopes: rewards:claim
|
|
1306
|
-
- Returns: { mode: "live", dayKey, userDailyClaims, claimsToday, budgets: { userDailyXP, userDailyCoins }, rules: [{ id, title, xp, coins, verifier: "numeric-quiz" | "completion", minSeconds?, progression?, retryReward: { xp, coins }, maxAttempts, available, setKey, questionCount }], challenges: [{ challengeId, ruleId, attempts, attemptsRemaining, state: "open" | "finished" | "earned", setKey, questions: [{ prompt, hint?, guide? }] }], history: [{ ruleId, xp, coins, attempt, createdAt }], balances: { xp, coins } } | { mode: "preview", dayKey?, rules, challenges: [], history: [], balances?, problems?: string[], message }
|
|
1338
|
+
- Returns: { mode: "live", dayKey, userDailyClaims, claimsToday, budgets: { userDailyXP, userDailyCoins }, rules: [{ id, title, howTo?, xp, coins, verifier: "numeric-quiz" | "completion", minSeconds?, progression?, retryReward: { xp, coins }, maxAttempts, available, setKey, questionCount }], challenges: [{ challengeId, ruleId, attempts, attemptsRemaining, state: "open" | "finished" | "earned", setKey, questions: [{ prompt, hint?, guide? }] }], history: [{ ruleId, xp, coins, attempt, createdAt }], balances: { xp, coins } } | { mode: "preview", dayKey?, rules, challenges: [], history: [], balances?, problems?: string[], message }
|
|
1307
1339
|
- Read canonical earning rules (without answer keys), today’s started challenges, today’s receipts and balances. Drafts return preview mode: for the app's owner the rules come from the draft's own rewards.json and question sheet (problems lists what is still wrong with them); anyone else sees no rules. Unapproved or revoked published releases return an error.
|
|
1308
1340
|
- rules[].available is false on a site day (UTC) the reviewer scheduled no questions for; show the rule as not available instead of starting it. xp/coins are the first-try amounts; retryReward is what a correct answer pays after a wrong one (equal to xp/coins unless the reviewer set a retry share). maxAttempts null means unlimited wrong answers until the site's daily reset (UTC midnight, 9:00 AM in Korea). retry.paidAttempts, when set, is the last attempt number a correct answer is still paid on: a later correct answer is recorded as solved (receipt xp 0, coins 0) and pays nothing — tell the learner before they pass it.
|
|
1309
1341
|
- challenges lists challenges this viewer already started today with their questions, so an app can resume after a reload without calling start. A question's guide (reviewer-approved JSON teaching content: explanation, interactive-model configuration) is present only once the viewer has answered at least once, right or wrong; render it as the after-attempt lesson. claimsToday against userDailyClaims (null = uncapped) tells whether another bounty can still pay today.
|
|
1310
1342
|
- Under progression 'until-earned' the same set stays up day after day until somebody earns it; setKey names the set currently up. Completion rules are always available and have questionCount 0.
|
|
1311
1343
|
- Rules may set maxLifetimeClaims, a per-learner limit for that rule across days and releases. rules[].lifetime contains the server-confirmed limit and remaining claims. App storage never enforces this limit.
|
|
1344
|
+
- howTo is the rule's optional plain-words steps from rewards.json (up to 300 characters, e.g. "Save a new design of at least 64 blocks in Designs"). The Earn page shows it under the title; show it in the app's own rewards list too so players always know exactly what earns each reward.
|
|
1312
1345
|
- await Twinkle.rewards.getReceipt({ challengeId }) | scopes: rewards:claim
|
|
1313
1346
|
- Returns: { mode: "live", status: "awarded" | "pending" | "expired" | "not_found", receipt: { id, challengeId, ruleId, reviewId, artifactVersionId, dayKey, xp, coins, attempt, createdAt } | null, balances: { xp, coins } } | { mode: "preview", status: "not_found", receipt: null, message }
|
|
1314
1347
|
- Read an existing receipt for this app and signed-in viewer by server-issued challengeId, including previous UTC days and previous approved versions. Requires the current approved published release and runtime grant; a stale frame must reload first. Never awards, retries a claim, returns answer keys, or restores removed rewards permission.
|
|
@@ -1319,7 +1352,7 @@ world.updatePresence({ x, y, z, facing });
|
|
|
1319
1352
|
- Errors: build_reward_not_scheduled when the rule has no questions for today; build_reward_daily_claims_reached when the viewer already earned today’s cap. attemptsRemaining is null for unlimited rules.
|
|
1320
1353
|
- For a completion rule call start when the activity begins (the moment the stage starts); the challenge's age is what the claim is measured against. In preview mode start also works for the owner (a stateless simulation).
|
|
1321
1354
|
- For completionProof: classic-tower-v1, start also returns completion { profile, token, maxFrames, completed, failed }. A new start resets only the simulated climb to its canonical spawn; it cannot reset daily or lifetime rewards. Record inputs from the first physics frame. The completion token is bound to the viewer, challenge, rule and published release.
|
|
1322
|
-
- For breadface-v1, breadface-v2 and breadface-v3, pass the zero-based canonical levelIndex. Record [dt, inputBits] from the first physics frame; start returns its server token and maxFrames. For study-record-v1, start returns completion { profile, usesAiEnergy: true }; there is no client-authored proof token. For groove-lab-song-v1
|
|
1355
|
+
- For breadface-v1, breadface-v2 and breadface-v3, pass the zero-based canonical levelIndex. Record [dt, inputBits] from the first physics frame; start returns its server token and maxFrames. For study-record-v1, start returns completion { profile, usesAiEnergy: true }; there is no client-authored proof token. For groove-lab-song-v1, groove-lab-heard-v1, vigil-guest-coplay-v1 and minecraft-first-link-v1, start returns completion { profile }; there is no progress step.
|
|
1323
1356
|
- await Twinkle.rewards.progress({ challengeId, completionToken?, frames?, record?, requestId? }) | scopes: rewards:claim
|
|
1324
1357
|
- Returns: { mode: "live" | "preview", completion: { profile, token?, completed, failed?, decision?, message?, maxFrames? }, aiUsagePolicy? }
|
|
1325
1358
|
- Verify a bounded batch of inputs for an approved server-simulated climb.
|
|
@@ -1335,7 +1368,7 @@ world.updatePresence({ x, y, z, facing });
|
|
|
1335
1368
|
- A wrong answer within two seconds of the previous one is refused with build_reward_throttled (HTTP 429) and does not count; wait for the person to try again rather than retry-looping.
|
|
1336
1369
|
- Completion rules take no answers: claim({ challengeId }) when the activity is finished. build_reward_too_fast (HTTP 409) means fewer than minSeconds passed since start; show nothing and let play continue. In preview mode the receipt carries preview: true and nothing is paid.
|
|
1337
1370
|
- A completionProof rule also requires the signed completionToken from a successful rewards.progress response. The server simulates the registered game physics and must reach the goal. A timer, forged position, client win flag, altered inventory or token from another viewer, challenge or release cannot authorize payment. maxLifetimeClaims is enforced from receipts in the same award transaction. build_reward_lifetime_claims_reached means all rewards for this rule have been collected; do not retry it.
|
|
1338
|
-
- For study-record-v1, the server verifies the settled private review row and its viewer, app, exact release, challenge and day binding. It never trusts the client’s decision, requested award amount or AI answer. Each successful daily study record is claimable once; display only canonical receipt/balances. For groove-lab-song-v1, pass completionToken as the published song's sharedDb entry id; for groove-lab-heard-v1, pass none. The server reads the app's own groove-lab-songs, groove-lab-song-parts and groove-lab-listens rows and refuses with build_reward_completion_proof_required and a plain reason when the song or listeners do not qualify.
|
|
1371
|
+
- For study-record-v1, the server verifies the settled private review row and its viewer, app, exact release, challenge and day binding. It never trusts the client’s decision, requested award amount or AI answer. Each successful daily study record is claimable once; display only canonical receipt/balances. For groove-lab-song-v1, pass completionToken as the published song's sharedDb entry id; for groove-lab-heard-v1, pass none; for vigil-guest-coplay-v1, pass the guest's world guestId (from world events) or the co-play record id; for minecraft-first-link-v1, pass nothing or the Minecraft UUID to use. The server reads the app's own groove-lab-songs, groove-lab-song-parts and groove-lab-listens rows and refuses with build_reward_completion_proof_required and a plain reason when the song or listeners do not qualify.
|
|
1339
1372
|
- await Twinkle.rewards.getLeaderboard({ metric?: "xp" | "coins", period?: "day" | "week" | "all", limit? }) | scopes: rewards:claim
|
|
1340
1373
|
- Returns: { mode: "live", metric, period, limit, dayKey, from, available: { xp, coins }, entries: [{ rank, userId, username, profilePicUrl, xp, coins, claims, lastAt }], me: { rank, xp, coins, claims } | null } | { mode: "preview", metric, period, available, entries: [], me: null, message }
|
|
1341
1374
|
- Standings of who earned the most XP or Coins in THIS app, computed by Twinkle from its own receipts (never from anything the app submits). period 'day' is today (site day, UTC), 'week' the last 7 site days, 'all' (default) every day since approval. limit defaults to 20, max 100.
|
package/sdk/LUMINE_ADMIN.md
CHANGED
|
@@ -1299,6 +1299,109 @@ ids (`reward-review show <id>` lists its events). An `accept` without a
|
|
|
1299
1299
|
the comparison; mention it, it is not a fault. Refusals are the concurrency
|
|
1300
1300
|
guards working; report them, and escalate only a repeated pattern on one app.
|
|
1301
1301
|
|
|
1302
|
+
### Members' chat reports (any time; also a full-daily-review duty, added 2026-09-27)
|
|
1303
|
+
|
|
1304
|
+
Any member can report another member's chat message from the message's "…"
|
|
1305
|
+
menu (reasons: bullying or harassment, sexual content, asking for personal
|
|
1306
|
+
info, spam or scam, something else; optional note). The API snapshots the
|
|
1307
|
+
message and a few messages around it, so a later edit or delete does not
|
|
1308
|
+
erase the evidence, and Zero DMs Mikey on the first report of each message.
|
|
1309
|
+
The reporter only sees "Thanks. Our team will look at this."; the reported
|
|
1310
|
+
member is never told. The full-run intake and report carry `chatReports`
|
|
1311
|
+
(pending = `open` + `reviewing`), like `rewardReviews`. These commands need no
|
|
1312
|
+
daily run:
|
|
1313
|
+
|
|
1314
|
+
```bash
|
|
1315
|
+
lumine admin chat-reports list --json # pending (default)
|
|
1316
|
+
lumine admin chat-reports list --status all --cursor 40 --json
|
|
1317
|
+
lumine admin chat-reports show 12 --json # snapshot + context
|
|
1318
|
+
lumine admin chat-reports set 12 --status resolved --note "What was decided or done" --json
|
|
1319
|
+
```
|
|
1320
|
+
|
|
1321
|
+
Read every pending report's snapshot and context in each full run. A report
|
|
1322
|
+
about a child's safety or wellbeing (sexual content, requests for personal
|
|
1323
|
+
information or photos, threats, targeted bullying) belongs at the top of the
|
|
1324
|
+
escalation list for Mikey with the report id, both usernames and a one-line
|
|
1325
|
+
summary; set it to `reviewing` with a note saying so. Spam, obvious
|
|
1326
|
+
misclicks and ordinary disagreements can be `dismissed` with a note.
|
|
1327
|
+
Recording a status only annotates the report: never message, ban or
|
|
1328
|
+
otherwise act against either member without Mikey's explicit go-ahead.
|
|
1329
|
+
|
|
1330
|
+
### Child-safety holds, evidence export and incident runbook (Mikey only, added 2026-09-27)
|
|
1331
|
+
|
|
1332
|
+
> Not legal advice. Have a Korean lawyer review this procedure.
|
|
1333
|
+
|
|
1334
|
+
Mikey, 2026-09-27: "if someone did something wrong - especially if for
|
|
1335
|
+
example a teacher was being inappropriate to a kid - shouldnt we be able to
|
|
1336
|
+
help police investigate?"
|
|
1337
|
+
|
|
1338
|
+
**Automatic holds.** A report for sexual content or asking for personal
|
|
1339
|
+
info, or any report between a minor and an adult where both ages are known
|
|
1340
|
+
(an admin-approved birthdate, otherwise the birthdate on the profile), places
|
|
1341
|
+
a safety hold on the whole conversation and on both accounts. A second such
|
|
1342
|
+
report in the same conversation joins the existing hold. Zero, Ciel and the
|
|
1343
|
+
site-wide General channel are never held (a General report holds the two
|
|
1344
|
+
accounts). Zero's alert DM to Mikey says when a hold was placed, and
|
|
1345
|
+
`chat-reports show <id>` lists `safetyHoldIds`.
|
|
1346
|
+
|
|
1347
|
+
**What a hold does**, silently, until it is released: a member's delete or
|
|
1348
|
+
edit still works for everyone, but the message row stays and the text before
|
|
1349
|
+
an edit (or the row at deletion) is copied to the hold; topic edits, removed
|
|
1350
|
+
archived profile pictures and removed previous usernames are copied too.
|
|
1351
|
+
Permanent deletion of a held message or of a held member's post or comment is
|
|
1352
|
+
refused with a neutral "can't be permanently deleted right now". Twinkle has
|
|
1353
|
+
no account deletion, so accounts are never erased. Nothing about a hold is
|
|
1354
|
+
shown to members.
|
|
1355
|
+
|
|
1356
|
+
These commands are for Mikey. A daily run may read `list-holds` and report
|
|
1357
|
+
it, but never places, releases or exports a hold or suspends anyone without
|
|
1358
|
+
Mikey's explicit instruction for that case:
|
|
1359
|
+
|
|
1360
|
+
```bash
|
|
1361
|
+
lumine admin chat-reports hold 12 --note "Why this case is held" --json
|
|
1362
|
+
lumine admin chat-reports hold --user 7,9 --channel 40 --note "Parent phoned" --json
|
|
1363
|
+
lumine admin chat-reports list-holds [--status active|released|all] --json
|
|
1364
|
+
lumine admin chat-reports suspend --user 7 --note "Adult under investigation" --json
|
|
1365
|
+
lumine admin chat-reports export 12 --out ~/twinkle-case-12 --json # or --hold <id>
|
|
1366
|
+
lumine admin chat-reports release 3 --note "Police closed the case" --json
|
|
1367
|
+
```
|
|
1368
|
+
|
|
1369
|
+
Every hold, release, export and suspension is recorded with who and when
|
|
1370
|
+
(`chat_safety_actions`), and released holds keep their copies.
|
|
1371
|
+
|
|
1372
|
+
**Incident runbook** (for example, an adult or a teacher being inappropriate
|
|
1373
|
+
to a child):
|
|
1374
|
+
|
|
1375
|
+
1. **Suspend the adult's access**: `chat-reports suspend --user <id> --note
|
|
1376
|
+
<why>`. This is the website's full ban: it signs them out and blocks the
|
|
1377
|
+
site. Lift it later from the website's Management ban editor.
|
|
1378
|
+
2. **Place or confirm the hold**: `chat-reports hold <report-id> --note <why>`
|
|
1379
|
+
(or `--user`/`--channel` when there is no report yet, for example after a
|
|
1380
|
+
parent's call), then `list-holds` to check that it covers the conversation
|
|
1381
|
+
and both accounts.
|
|
1382
|
+
3. **Export the evidence**: `chat-reports export <report-id> --out <new
|
|
1383
|
+
folder>`. The folder holds the whole conversation with UTC and KST times,
|
|
1384
|
+
deletions and held edits; both accounts (username, id, join date, verified
|
|
1385
|
+
email, birthdates, username history, linked Minecraft players, and the IP
|
|
1386
|
+
addresses the site stores for logouts, searches and page visits); the
|
|
1387
|
+
report(s); the actions taken; and `manifest.json` with each file's
|
|
1388
|
+
SHA-256. Write down the printed manifest hash, keep the folder private,
|
|
1389
|
+
and never edit it. Attachments are listed by storage key, not included.
|
|
1390
|
+
4. **Contact the police**: 112 for an emergency or ongoing danger, or the
|
|
1391
|
+
cyber crime report system at https://ecrm.police.go.kr. Give them the
|
|
1392
|
+
export when they ask for it.
|
|
1393
|
+
5. **Contact child protection**: report suspected child abuse to 112 (the
|
|
1394
|
+
national child-abuse report line), which connects to the local
|
|
1395
|
+
아동보호전문기관 (child protection agency).
|
|
1396
|
+
6. **Teachers and academy staff are mandatory reporters**: under Korea's Act
|
|
1397
|
+
on Special Cases Concerning the Punishment of Child Abuse Crimes, teachers
|
|
1398
|
+
and academy (hagwon) staff must report suspected child abuse. If the adult
|
|
1399
|
+
is a teacher, their employer may also need to be told; ask the lawyer.
|
|
1400
|
+
7. **Do not** confront or warn the adult, delete anything, or share the
|
|
1401
|
+
evidence with anyone but the police or child-protection staff.
|
|
1402
|
+
8. **Release the hold** only after the case is closed: `chat-reports release
|
|
1403
|
+
<hold-id> --note <why>`.
|
|
1404
|
+
|
|
1302
1405
|
## Private carry-over todos
|
|
1303
1406
|
|
|
1304
1407
|
```bash
|