@stage5/lumine 0.3.31 → 0.3.32
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-receipts.js +25 -0
- package/lib/admin.js +483 -3
- package/lib/commands.js +32 -1
- package/lib/thumbnail.js +22 -15
- package/package.json +1 -1
- package/sdk/LUMINE_ADMIN.md +90 -1
package/lib/admin-receipts.js
CHANGED
|
@@ -43,6 +43,31 @@ export function receiptToKeep(operation, result) {
|
|
|
43
43
|
note: report.reviewNote ?? null,
|
|
44
44
|
});
|
|
45
45
|
}
|
|
46
|
+
// AI bucket changes answer with rule matches (emails, IPs) and member
|
|
47
|
+
// names; the receipt keeps the decision: ids, modes and counts.
|
|
48
|
+
case "ai-bucket.restrict":
|
|
49
|
+
case "ai-bucket.rule.add":
|
|
50
|
+
case "ai-bucket.rule.disable": {
|
|
51
|
+
const ruleIds = (rules) =>
|
|
52
|
+
(rules || []).map((rule) => ({
|
|
53
|
+
id: rule.id ?? null,
|
|
54
|
+
kind: rule.kind ?? null,
|
|
55
|
+
scope: rule.scope ?? null,
|
|
56
|
+
effectiveRestriction: rule.effectiveRestriction ?? null,
|
|
57
|
+
}));
|
|
58
|
+
return keep({
|
|
59
|
+
bucketId: data.bucket?.id ?? null,
|
|
60
|
+
restriction: data.bucket?.restriction?.mode ?? null,
|
|
61
|
+
previousRestriction: data.previous?.mode ?? null,
|
|
62
|
+
addedRules: ruleIds(data.addedRules),
|
|
63
|
+
disabledRules: ruleIds(data.disabledRule ? [data.disabledRule] : []),
|
|
64
|
+
kickedAccountCount: (data.kickedUserIds || []).length,
|
|
65
|
+
releasedAccountCount: (data.releasedAccounts || []).length,
|
|
66
|
+
ruleCount: data.ruleCount ?? null,
|
|
67
|
+
memberCount: data.memberCount ?? null,
|
|
68
|
+
verified: data.verified === true,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
46
71
|
default:
|
|
47
72
|
// Bridge Builder writes answer with the whole crew view: members' who-
|
|
48
73
|
// are-you answers, parents' addresses and questions, held parent emails.
|
package/lib/admin.js
CHANGED
|
@@ -12,6 +12,11 @@ import {
|
|
|
12
12
|
import path from "node:path";
|
|
13
13
|
import { assertAuthScope, resolveAuth } from "./auth.js";
|
|
14
14
|
import { requestJson } from "./http.js";
|
|
15
|
+
import { commitThumbnailImage, readThumbnailFile } from "./thumbnail.js";
|
|
16
|
+
import {
|
|
17
|
+
THUMBNAIL_CONTENT_TYPE_BY_EXTENSION,
|
|
18
|
+
THUMBNAIL_MAX_FILE_SIZE_BYTES,
|
|
19
|
+
} from "./constants.js";
|
|
15
20
|
import { receiptToKeep } from "./admin-receipts.js";
|
|
16
21
|
import { prepareAdminMutationIntent } from "./admin-request-intents.js";
|
|
17
22
|
import { formatBytes, parseStorageSizeBytes } from "./util.js";
|
|
@@ -202,7 +207,7 @@ const REVIEW_REQUEST_LABELS = {
|
|
|
202
207
|
cardcraft: "AI Card crafting",
|
|
203
208
|
};
|
|
204
209
|
const REVIEW_REQUEST_USAGE =
|
|
205
|
-
"Usage: lumine admin review list [--type rewards|project|storage|cardcraft] [--status queue|pending|approved|rejected|all] [--cursor <c>] | show <type:id> [--dir <path>] | approve <type:id> [--config <rules.json>] [--size <500MB|1GB|2GB|n MB>] [--reason <text>] | reject <type:id> [--reason <text>] | revoke <type:id> --reason <text> | propose rewards:<id> --dir <edited-snapshot> --config <rules.json> [--reason <text>]. A bare id works with --type.";
|
|
210
|
+
"Usage: lumine admin review list [--type rewards|project|storage|cardcraft] [--status queue|pending|approved|rejected|all] [--cursor <c>] | show <type:id> [--dir <path>] | approve <type:id> [--config <rules.json>] [--size <500MB|1GB|2GB|n MB>] [--reason <text>] | reject <type:id> [--reason <text>] | revoke <type:id> --reason <text> | propose rewards:<id> --dir <edited-snapshot> --config <rules.json> [--reason <text>] [--title <new app name>] [--thumbnail <image|workspace>]. A bare id works with --type.";
|
|
206
211
|
|
|
207
212
|
function parseReviewRequestType(value) {
|
|
208
213
|
const key = String(value || "")
|
|
@@ -945,6 +950,20 @@ function buildRewardProposalOperation(reviewId, options) {
|
|
|
945
950
|
);
|
|
946
951
|
}
|
|
947
952
|
const files = readRewardProposalDirectory(options.dir);
|
|
953
|
+
// --title proposes a new app name; --thumbnail <file> a new thumbnail (set
|
|
954
|
+
// on your proposal workspace copy first), or --thumbnail workspace to offer
|
|
955
|
+
// the one already set on that copy (e.g. from the website).
|
|
956
|
+
const title = String(options.title || "").trim();
|
|
957
|
+
if (title.length > 100) {
|
|
958
|
+
throw cliValidationError("--title must be at most 100 characters.");
|
|
959
|
+
}
|
|
960
|
+
const thumbnail = String(options.adminThumbnail || "").trim();
|
|
961
|
+
// Checked before signing in or opening the copy; its hash makes a retry
|
|
962
|
+
// with a different picture a new request rather than a replay.
|
|
963
|
+
const thumbnailSha256 =
|
|
964
|
+
thumbnail && thumbnail !== "workspace"
|
|
965
|
+
? hashProposalThumbnailFile(thumbnail)
|
|
966
|
+
: "";
|
|
948
967
|
return writeOperation(
|
|
949
968
|
"reward-review.propose",
|
|
950
969
|
"POST",
|
|
@@ -953,11 +972,78 @@ function buildRewardProposalOperation(reviewId, options) {
|
|
|
953
972
|
files,
|
|
954
973
|
config: readRewardConfigFile(options.adminConfigFile),
|
|
955
974
|
reason,
|
|
975
|
+
...(title ? { title } : {}),
|
|
976
|
+
...(thumbnail ? { thumbnailFrom: "workspace" } : {}),
|
|
977
|
+
...(thumbnailSha256 ? { thumbnailSha256 } : {}),
|
|
978
|
+
},
|
|
979
|
+
{
|
|
980
|
+
requiresRun: false,
|
|
981
|
+
reviewId,
|
|
982
|
+
fileCount: files.length,
|
|
983
|
+
...(thumbnail && thumbnail !== "workspace"
|
|
984
|
+
? {
|
|
985
|
+
prepare: ({ options: runOptions, auth }) =>
|
|
986
|
+
setProposalWorkspaceThumbnail({
|
|
987
|
+
options: runOptions,
|
|
988
|
+
auth,
|
|
989
|
+
reviewId,
|
|
990
|
+
filePath: thumbnail,
|
|
991
|
+
}),
|
|
992
|
+
}
|
|
993
|
+
: {}),
|
|
956
994
|
},
|
|
957
|
-
{ requiresRun: false, reviewId, fileCount: files.length },
|
|
958
995
|
);
|
|
959
996
|
}
|
|
960
997
|
|
|
998
|
+
// Puts a proposed thumbnail on the reviewer's proposal workspace copy (opened
|
|
999
|
+
// on first use), the copy the offer's thumbnailFrom 'workspace' reads.
|
|
1000
|
+
async function setProposalWorkspaceThumbnail({
|
|
1001
|
+
options,
|
|
1002
|
+
auth,
|
|
1003
|
+
reviewId,
|
|
1004
|
+
filePath,
|
|
1005
|
+
}) {
|
|
1006
|
+
const { buffer, contentType } = await readThumbnailFile(filePath);
|
|
1007
|
+
const receipt = await requestJson({
|
|
1008
|
+
method: "POST",
|
|
1009
|
+
url: `${options.apiUrl}/cli/admin/reward-reviews/${reviewId}/workspace`,
|
|
1010
|
+
authToken: auth.token,
|
|
1011
|
+
body: {},
|
|
1012
|
+
headers: { "x-lumine-idempotency-key": `cli:${randomUUID()}` },
|
|
1013
|
+
timeoutMs: options.timeoutMs,
|
|
1014
|
+
});
|
|
1015
|
+
const buildId = Number(receipt?.data?.workspace?.buildId || 0);
|
|
1016
|
+
if (!receipt?.ok || !buildId) {
|
|
1017
|
+
throw new Error(
|
|
1018
|
+
receipt?.error || "Could not open the proposal workspace copy.",
|
|
1019
|
+
);
|
|
1020
|
+
}
|
|
1021
|
+
await commitThumbnailImage({ options, auth, buildId, buffer, contentType });
|
|
1022
|
+
// stderr: --json output stays one parseable receipt.
|
|
1023
|
+
console.error(`Proposed thumbnail set on workspace copy (Build #${buildId}).`);
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
function hashProposalThumbnailFile(filePath) {
|
|
1027
|
+
const extension = path.extname(filePath).toLowerCase();
|
|
1028
|
+
if (!THUMBNAIL_CONTENT_TYPE_BY_EXTENSION[extension]) {
|
|
1029
|
+
throw cliValidationError(
|
|
1030
|
+
`--thumbnail must be a ${Object.keys(THUMBNAIL_CONTENT_TYPE_BY_EXTENSION).join(", ")} file, or "workspace".`,
|
|
1031
|
+
);
|
|
1032
|
+
}
|
|
1033
|
+
let buffer;
|
|
1034
|
+
try {
|
|
1035
|
+
buffer = readFileSync(path.resolve(filePath));
|
|
1036
|
+
} catch {
|
|
1037
|
+
throw cliValidationError(`Thumbnail file does not exist: ${filePath}`);
|
|
1038
|
+
}
|
|
1039
|
+
if (!buffer.length || buffer.length > THUMBNAIL_MAX_FILE_SIZE_BYTES) {
|
|
1040
|
+
throw cliValidationError(
|
|
1041
|
+
`Thumbnail file must be between 1 byte and ${formatBytes(THUMBNAIL_MAX_FILE_SIZE_BYTES)}.`,
|
|
1042
|
+
);
|
|
1043
|
+
}
|
|
1044
|
+
return createHash("sha256").update(buffer).digest("hex");
|
|
1045
|
+
}
|
|
1046
|
+
|
|
961
1047
|
const REWARD_PROPOSAL_MAX_FILES = 500;
|
|
962
1048
|
const REWARD_PROPOSAL_MAX_BYTES = 5 * 1024 * 1024;
|
|
963
1049
|
const REWARD_PROPOSAL_SKIPPED_DIRS = new Set([
|
|
@@ -1364,6 +1450,12 @@ export async function adminCommand(options) {
|
|
|
1364
1450
|
(operation.mutates ? `cli:${randomUUID()}` : "");
|
|
1365
1451
|
let result;
|
|
1366
1452
|
try {
|
|
1453
|
+
// A step that must land before the request (e.g. a proposed thumbnail
|
|
1454
|
+
// uploaded to the reviewer's workspace copy); its failure is reported
|
|
1455
|
+
// like the request's own.
|
|
1456
|
+
if (typeof operation.prepare === "function") {
|
|
1457
|
+
await operation.prepare({ options, auth });
|
|
1458
|
+
}
|
|
1367
1459
|
const fetchOperation = async (requestPath = operation.path, signal) => {
|
|
1368
1460
|
const receipt = await requestJson({
|
|
1369
1461
|
method: operation.method,
|
|
@@ -1576,6 +1668,9 @@ export async function adminCommand(options) {
|
|
|
1576
1668
|
if (operation.name === "ai-email-policy.set") {
|
|
1577
1669
|
assertAiEmailPolicySetResult({ operation, result });
|
|
1578
1670
|
}
|
|
1671
|
+
if (AI_BUCKET_CHANGE_OPERATIONS.has(operation.name)) {
|
|
1672
|
+
assertAiBucketChangeResult({ operation, result });
|
|
1673
|
+
}
|
|
1579
1674
|
if (operation.name === "daily-run.start") {
|
|
1580
1675
|
assertAdminTodoHandoffResult(result, operation.body.scope);
|
|
1581
1676
|
}
|
|
@@ -2045,6 +2140,42 @@ export function assertAiEmailPolicySetResult({ operation, result }) {
|
|
|
2045
2140
|
throw error;
|
|
2046
2141
|
}
|
|
2047
2142
|
|
|
2143
|
+
const AI_BUCKET_CHANGE_OPERATIONS = new Set([
|
|
2144
|
+
"ai-bucket.restrict",
|
|
2145
|
+
"ai-bucket.rule.add",
|
|
2146
|
+
"ai-bucket.rule.disable",
|
|
2147
|
+
]);
|
|
2148
|
+
|
|
2149
|
+
// A bucket change is done only when the API re-read the canonical bucket and
|
|
2150
|
+
// confirmed it (verified), and a restriction reads back as the requested mode.
|
|
2151
|
+
export function assertAiBucketChangeResult({ operation, result }) {
|
|
2152
|
+
const data = result?.data || {};
|
|
2153
|
+
const requestedMode =
|
|
2154
|
+
operation?.name === "ai-bucket.restrict" ? operation.body?.mode : null;
|
|
2155
|
+
if (
|
|
2156
|
+
result?.ok === true &&
|
|
2157
|
+
data.verified === true &&
|
|
2158
|
+
Number(data.bucket?.id) > 0 &&
|
|
2159
|
+
(!requestedMode || data.bucket?.restriction?.mode === requestedMode)
|
|
2160
|
+
) {
|
|
2161
|
+
return;
|
|
2162
|
+
}
|
|
2163
|
+
const error = new Error(
|
|
2164
|
+
"The API did not confirm the canonical AI bucket state after this change. Inspect the bucket before retrying.",
|
|
2165
|
+
);
|
|
2166
|
+
error.code = "LUMINE_ADMIN_AI_BUCKET_NOT_VERIFIED";
|
|
2167
|
+
error.data = {
|
|
2168
|
+
ok: false,
|
|
2169
|
+
status: "partial_failure",
|
|
2170
|
+
error: {
|
|
2171
|
+
code: error.code,
|
|
2172
|
+
message: error.message,
|
|
2173
|
+
details: { requestedMode, canonical: result?.data || null },
|
|
2174
|
+
},
|
|
2175
|
+
};
|
|
2176
|
+
throw error;
|
|
2177
|
+
}
|
|
2178
|
+
|
|
2048
2179
|
const RECOMMENDATION_CONTENT_TYPES = new Map([
|
|
2049
2180
|
["comment", "comment"],
|
|
2050
2181
|
["aistory", "aiStory"],
|
|
@@ -2660,6 +2791,78 @@ export function parseAdminOperation(options) {
|
|
|
2660
2791
|
},
|
|
2661
2792
|
);
|
|
2662
2793
|
}
|
|
2794
|
+
if (action === "inspect") {
|
|
2795
|
+
const reason = String(options.adminReason || "").trim();
|
|
2796
|
+
if (!reason) {
|
|
2797
|
+
throw cliValidationError(
|
|
2798
|
+
"Say why with --reason <management reason>; the inspection is audited.",
|
|
2799
|
+
);
|
|
2800
|
+
}
|
|
2801
|
+
if (reason.length > MAX_IDENTITY_INSPECTION_REASON_LENGTH) {
|
|
2802
|
+
throw cliValidationError(
|
|
2803
|
+
`An inspection reason must be at most ${MAX_IDENTITY_INSPECTION_REASON_LENGTH} characters.`,
|
|
2804
|
+
);
|
|
2805
|
+
}
|
|
2806
|
+
// A read like identity inspect: private evidence is printed, never
|
|
2807
|
+
// kept on disk; the server records who looked, when and why.
|
|
2808
|
+
return bodyReadOperation(
|
|
2809
|
+
"ai-bucket.inspect",
|
|
2810
|
+
"POST",
|
|
2811
|
+
`/cli/admin/ai-buckets/${bucketId}/inspect`,
|
|
2812
|
+
{
|
|
2813
|
+
reason,
|
|
2814
|
+
includePrivateEvidence: options.adminIncludePrivateEvidence === true,
|
|
2815
|
+
},
|
|
2816
|
+
);
|
|
2817
|
+
}
|
|
2818
|
+
if (action === "restrict") {
|
|
2819
|
+
const mode = String(options.adminMode || "");
|
|
2820
|
+
if (!["none", "signup", "full"].includes(mode)) {
|
|
2821
|
+
throw cliValidationError("Pass --mode none, signup or full.");
|
|
2822
|
+
}
|
|
2823
|
+
const body = { mode };
|
|
2824
|
+
if (options.adminMessage !== undefined) {
|
|
2825
|
+
const message = String(options.adminMessage).trim();
|
|
2826
|
+
if (message.length > 500) {
|
|
2827
|
+
throw cliValidationError("--message must be at most 500 characters.");
|
|
2828
|
+
}
|
|
2829
|
+
body.message = message;
|
|
2830
|
+
}
|
|
2831
|
+
return aiBucketChangeOperation({
|
|
2832
|
+
options,
|
|
2833
|
+
name: "ai-bucket.restrict",
|
|
2834
|
+
method: "PUT",
|
|
2835
|
+
path: `/cli/admin/ai-buckets/${bucketId}/restriction`,
|
|
2836
|
+
body,
|
|
2837
|
+
// a restriction changes only on an explicit instruction
|
|
2838
|
+
confirmRequired: true,
|
|
2839
|
+
});
|
|
2840
|
+
}
|
|
2841
|
+
if (action === "rule" && target === "add") {
|
|
2842
|
+
return aiBucketChangeOperation({
|
|
2843
|
+
options,
|
|
2844
|
+
name: "ai-bucket.rule.add",
|
|
2845
|
+
method: "POST",
|
|
2846
|
+
path: `/cli/admin/ai-buckets/${bucketId}/rules`,
|
|
2847
|
+
body: parseAiBucketRuleBody(options),
|
|
2848
|
+
confirmRequired: true,
|
|
2849
|
+
});
|
|
2850
|
+
}
|
|
2851
|
+
if (action === "rule" && target === "disable") {
|
|
2852
|
+
const ruleId = parseRequiredInteger(options.adminRuleId, "--rule-id", 1);
|
|
2853
|
+
const note = String(options.note || "").trim();
|
|
2854
|
+
if (note.length > 180) {
|
|
2855
|
+
throw cliValidationError("--note must be at most 180 characters.");
|
|
2856
|
+
}
|
|
2857
|
+
return aiBucketChangeOperation({
|
|
2858
|
+
options,
|
|
2859
|
+
name: "ai-bucket.rule.disable",
|
|
2860
|
+
method: "POST",
|
|
2861
|
+
path: `/cli/admin/ai-buckets/${bucketId}/rules/${ruleId}/disable`,
|
|
2862
|
+
body: note ? { note } : {},
|
|
2863
|
+
confirmRequired: true,
|
|
2864
|
+
});
|
|
2865
|
+
}
|
|
2663
2866
|
if (action === "note" && target === "set") {
|
|
2664
2867
|
return writeOperation(
|
|
2665
2868
|
"ai-bucket.note.set",
|
|
@@ -2748,7 +2951,7 @@ export function parseAdminOperation(options) {
|
|
|
2748
2951
|
});
|
|
2749
2952
|
if (operation) return operation;
|
|
2750
2953
|
throw cliValidationError(
|
|
2751
|
-
"Usage: lumine admin reward-review list [--status pending|approved|all] [--cursor <id>] | show <id> [--dir <path>] | approve <id> [--config <rules.json>] [--reason <text>] | propose <id> --dir <edited-snapshot> --config <rules.json> [--reason <text>] | reject <id> --reason <text> | revoke <id> --reason <text>.",
|
|
2954
|
+
"Usage: lumine admin reward-review list [--status pending|approved|all] [--cursor <id>] | show <id> [--dir <path>] | approve <id> [--config <rules.json>] [--reason <text>] | propose <id> --dir <edited-snapshot> --config <rules.json> [--reason <text>] [--title <new app name>] [--thumbnail <image|workspace>] | reject <id> --reason <text> | revoke <id> --reason <text>.",
|
|
2752
2955
|
);
|
|
2753
2956
|
}
|
|
2754
2957
|
|
|
@@ -5085,6 +5288,108 @@ export function assertAdminTodoHandoffResult(result, expectedRunScope = null) {
|
|
|
5085
5288
|
return handoff;
|
|
5086
5289
|
}
|
|
5087
5290
|
|
|
5291
|
+
// --dry-run is a read (no receipt, nothing changes); a real run is a write
|
|
5292
|
+
// that needs --confirm, checked here and again by the server.
|
|
5293
|
+
function aiBucketChangeOperation({ options, name, method, path, body, confirmRequired }) {
|
|
5294
|
+
// --allow-shared-network lets a rule reach a shared network (an academy,
|
|
5295
|
+
// a school); it is sent, and printed, only when given
|
|
5296
|
+
if (options.adminAllowSharedNetwork) {
|
|
5297
|
+
if (!["ai-bucket.restrict", "ai-bucket.rule.add"].includes(name)) {
|
|
5298
|
+
throw cliValidationError("--allow-shared-network applies only to restrict and rule add.");
|
|
5299
|
+
}
|
|
5300
|
+
body = { ...body, allowSharedNetwork: true };
|
|
5301
|
+
}
|
|
5302
|
+
if (options.adminDryRun) {
|
|
5303
|
+
return bodyReadOperation(`${name}.preview`, method, path, {
|
|
5304
|
+
...body,
|
|
5305
|
+
dryRun: true,
|
|
5306
|
+
});
|
|
5307
|
+
}
|
|
5308
|
+
const confirm = options.adminConfirm;
|
|
5309
|
+
if (confirm !== "" && confirm !== "true") {
|
|
5310
|
+
throw cliValidationError("Pass --confirm on its own, without a value.");
|
|
5311
|
+
}
|
|
5312
|
+
if (confirmRequired && confirm !== "true") {
|
|
5313
|
+
throw cliValidationError(
|
|
5314
|
+
"Every bucket change needs --confirm (a rule changes who shares AI Energy, and on a restricted bucket who is blocked). Run it with --dry-run first to see what it changes.",
|
|
5315
|
+
);
|
|
5316
|
+
}
|
|
5317
|
+
return writeOperation(name, method, path, {
|
|
5318
|
+
...body,
|
|
5319
|
+
...(confirm === "true" ? { confirm: true } : {}),
|
|
5320
|
+
});
|
|
5321
|
+
}
|
|
5322
|
+
|
|
5323
|
+
function parseAiBucketRuleBody(options) {
|
|
5324
|
+
const type = String(options.adminType || "");
|
|
5325
|
+
const note = String(options.note || "").trim();
|
|
5326
|
+
if (note.length > 180) {
|
|
5327
|
+
throw cliValidationError("--note must be at most 180 characters.");
|
|
5328
|
+
}
|
|
5329
|
+
const withNote = (body) => (note ? { ...body, note } : body);
|
|
5330
|
+
const scope = () => {
|
|
5331
|
+
const value = String(options.adminScope || "");
|
|
5332
|
+
if (!["signup", "full"].includes(value)) {
|
|
5333
|
+
throw cliValidationError(
|
|
5334
|
+
"Pass --scope signup or full for an IP or device rule.",
|
|
5335
|
+
);
|
|
5336
|
+
}
|
|
5337
|
+
return value;
|
|
5338
|
+
};
|
|
5339
|
+
if (type === "user") {
|
|
5340
|
+
return withNote({
|
|
5341
|
+
type,
|
|
5342
|
+
userId: parseRequiredInteger(options.adminUser, "--user", 1),
|
|
5343
|
+
});
|
|
5344
|
+
}
|
|
5345
|
+
if (type === "email") {
|
|
5346
|
+
const email = String(options.adminEmail || "").trim().toLowerCase();
|
|
5347
|
+
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
|
5348
|
+
throw cliValidationError("Pass a valid --email <address>.");
|
|
5349
|
+
}
|
|
5350
|
+
if (!options.adminDryRun && !options.adminConfirmEmailRule) {
|
|
5351
|
+
throw cliValidationError(
|
|
5352
|
+
"An email rule covers every account with that verified address. Pass --confirm-email-rule (run --dry-run first to see them).",
|
|
5353
|
+
);
|
|
5354
|
+
}
|
|
5355
|
+
return withNote({
|
|
5356
|
+
type,
|
|
5357
|
+
email,
|
|
5358
|
+
...(options.adminConfirmEmailRule ? { confirmEmailRule: true } : {}),
|
|
5359
|
+
});
|
|
5360
|
+
}
|
|
5361
|
+
if (type === "ip") {
|
|
5362
|
+
const ip = String(options.adminIp || "").trim();
|
|
5363
|
+
if (!ip) throw cliValidationError("Pass --ip <address>.");
|
|
5364
|
+
return withNote({
|
|
5365
|
+
type,
|
|
5366
|
+
ip,
|
|
5367
|
+
scope: scope(),
|
|
5368
|
+
...(options.adminIncludePrefix ? { includePrefix: true } : {}),
|
|
5369
|
+
});
|
|
5370
|
+
}
|
|
5371
|
+
if (type === "device") {
|
|
5372
|
+
if (!/^[a-f0-9]{16}$/.test(options.adminDeviceKey || "")) {
|
|
5373
|
+
throw cliValidationError(
|
|
5374
|
+
"Pass --device-key with the exact key from ai-bucket inspect or identity inspect.",
|
|
5375
|
+
);
|
|
5376
|
+
}
|
|
5377
|
+
if (!options.adminDryRun && !note) {
|
|
5378
|
+
throw cliValidationError("Say why with --note <reason> for a device rule.");
|
|
5379
|
+
}
|
|
5380
|
+
return withNote({
|
|
5381
|
+
type,
|
|
5382
|
+
sourceUserId: parseRequiredInteger(options.adminUser, "--user", 1),
|
|
5383
|
+
deviceKey: options.adminDeviceKey,
|
|
5384
|
+
scope: scope(),
|
|
5385
|
+
...(options.adminUserIds || !options.adminDryRun
|
|
5386
|
+
? { expectedUserIds: parseAiBucketUserIds(options.adminUserIds) }
|
|
5387
|
+
: {}),
|
|
5388
|
+
});
|
|
5389
|
+
}
|
|
5390
|
+
throw cliValidationError("Pass --type user, email, ip or device.");
|
|
5391
|
+
}
|
|
5392
|
+
|
|
5088
5393
|
function readOperation(name, path, extra = {}) {
|
|
5089
5394
|
return {
|
|
5090
5395
|
name,
|
|
@@ -5769,6 +6074,10 @@ function printRewardReviewResult({ operation, data }) {
|
|
|
5769
6074
|
);
|
|
5770
6075
|
for (const file of review.proposal.changedFiles || [])
|
|
5771
6076
|
console.log(` ${file.status}: ${file.path}`);
|
|
6077
|
+
if (review.proposal.title)
|
|
6078
|
+
console.log(` new name: ${review.proposal.title}`);
|
|
6079
|
+
if (review.proposal.thumbnailUrl)
|
|
6080
|
+
console.log(` new thumbnail: ${review.proposal.thumbnailUrl}`);
|
|
5772
6081
|
}
|
|
5773
6082
|
if (review.status === "rejected" && review.declinedByCreator) {
|
|
5774
6083
|
console.log(
|
|
@@ -6846,6 +7155,174 @@ function printRewardBankReport(data) {
|
|
|
6846
7155
|
}
|
|
6847
7156
|
}
|
|
6848
7157
|
|
|
7158
|
+
function aiBucketRestrictionLabel(restriction) {
|
|
7159
|
+
const mode = restriction?.mode || "none";
|
|
7160
|
+
const label = mode === "none" ? "unrestricted" : mode === "signup" ? "signup-only" : "full";
|
|
7161
|
+
return restriction?.message ? `${label} — "${restriction.message}"` : label;
|
|
7162
|
+
}
|
|
7163
|
+
|
|
7164
|
+
function formatAiBucketAccounts(accounts) {
|
|
7165
|
+
return (accounts || []).length
|
|
7166
|
+
? accounts.map((a) => `${a.username || "?"} (#${a.userId})`).join(", ")
|
|
7167
|
+
: "none";
|
|
7168
|
+
}
|
|
7169
|
+
|
|
7170
|
+
function printAiBucketNetworkRuleChanges(changes) {
|
|
7171
|
+
if (!changes?.length) return;
|
|
7172
|
+
console.log("Network/device rules whose restriction changes:");
|
|
7173
|
+
for (const c of changes) {
|
|
7174
|
+
const r = c.reach;
|
|
7175
|
+
const reach = !c.strengthens
|
|
7176
|
+
? "loosens"
|
|
7177
|
+
: !r?.measured
|
|
7178
|
+
? "reach NOT MEASURED"
|
|
7179
|
+
: `${r.accountCount}${r.lowerBound ? "+" : ""} account(s)${r.sharedNetwork ? " — SHARED NETWORK" : ""}`;
|
|
7180
|
+
console.log(` #${c.ruleId} ${c.kind} ${c.match}: ${c.from} → ${c.to} · ${reach}`);
|
|
7181
|
+
}
|
|
7182
|
+
}
|
|
7183
|
+
|
|
7184
|
+
function printAiBucketBlockers(blockers, overrideGiven) {
|
|
7185
|
+
for (const blocker of blockers || []) {
|
|
7186
|
+
if (blocker.overridable && overrideGiven) {
|
|
7187
|
+
console.log(`OVERRIDDEN by --allow-shared-network (${blocker.code}): ${blocker.message}`);
|
|
7188
|
+
} else {
|
|
7189
|
+
console.log(`BLOCKED (${blocker.code}): ${blocker.message}${blocker.overridable ? " Only --allow-shared-network, on Mikey's explicit word, lifts this." : ""}`);
|
|
7190
|
+
}
|
|
7191
|
+
}
|
|
7192
|
+
}
|
|
7193
|
+
|
|
7194
|
+
function printAiBucketRules(rules, indent = " ") {
|
|
7195
|
+
for (const rule of rules || []) {
|
|
7196
|
+
console.log(
|
|
7197
|
+
`${indent}#${rule.id} ${rule.kind} ${rule.match} · scope ${rule.scope} · effective ${rule.effectiveRestriction}${rule.note ? ` · ${rule.note}` : ""}`,
|
|
7198
|
+
);
|
|
7199
|
+
}
|
|
7200
|
+
}
|
|
7201
|
+
|
|
7202
|
+
// The bucket commands of todo 79 (inspect, restrict, rule add/disable and
|
|
7203
|
+
// their --dry-run previews). Returns false for the older ai-bucket commands.
|
|
7204
|
+
function printAiBucketRuleResult({ operation, data }) {
|
|
7205
|
+
const name = operation.name;
|
|
7206
|
+
const head = (bucket) =>
|
|
7207
|
+
`AI bucket #${bucket.id} (${bucket.label}): ${aiBucketRestrictionLabel(bucket.restriction)}`;
|
|
7208
|
+
if (name === "ai-bucket.inspect") {
|
|
7209
|
+
console.log(`${head(data.bucket)}; ${data.rules.length} active rule(s), ${data.memberCount} member account(s).`);
|
|
7210
|
+
if (data.bucket.note) console.log(`Note: ${data.bucket.note}`);
|
|
7211
|
+
console.log("Rules:");
|
|
7212
|
+
printAiBucketRules(data.rules);
|
|
7213
|
+
console.log(
|
|
7214
|
+
`Members: ${(data.members || []).map((m) => `${m.username || "?"} (#${m.userId}${m.viaEmailRule ? ", via email rule" : ""})`).join(", ") || "none"}`,
|
|
7215
|
+
);
|
|
7216
|
+
for (const account of data.evidence?.accounts || []) {
|
|
7217
|
+
console.log(`Evidence for ${account.username || "?"} (#${account.userId})${account.canonicalBucketId && account.canonicalBucketId !== data.bucket.id ? ` · resolves to bucket #${account.canonicalBucketId}` : ""}:`);
|
|
7218
|
+
if (!account.devices.length) console.log(" Devices: none recorded");
|
|
7219
|
+
for (const device of account.devices) {
|
|
7220
|
+
const rule = device.ruleId ? ` · rule #${device.ruleId}` : "";
|
|
7221
|
+
if (device.sharedDevice) {
|
|
7222
|
+
console.log(` Device ${device.key}: shared by ${device.accountCount} accounts (not listed; not family evidence)${rule}`);
|
|
7223
|
+
continue;
|
|
7224
|
+
}
|
|
7225
|
+
const others = device.otherAccounts.length
|
|
7226
|
+
? device.otherAccounts.map((a) => `${a.username || "?"} (#${a.userId})${a.inBucket ? "" : " OUTSIDE bucket"}`).join(", ")
|
|
7227
|
+
: "no other account";
|
|
7228
|
+
console.log(` Device ${device.key}: ${device.accountCount} account(s); others: ${others}${device.accountsTruncated ? " (more not listed)" : ""}${device.status !== "complete" ? ` · ${device.status}` : ""}${rule}`);
|
|
7229
|
+
}
|
|
7230
|
+
printNetwork(account.network, " ");
|
|
7231
|
+
const ruled = (account.network?.addresses || []).filter((a) => a.ruleId);
|
|
7232
|
+
if (ruled.length) {
|
|
7233
|
+
console.log(` IP rules matching these addresses: ${ruled.map((a) => `#${a.ruleId} [${a.addressId}]`).join(", ")}`);
|
|
7234
|
+
}
|
|
7235
|
+
}
|
|
7236
|
+
if (data.evidence?.membersNotInspected) {
|
|
7237
|
+
console.log(`Evidence covers the first ${data.evidence.membersInspected} members; ${data.evidence.membersNotInspected} not inspected.`);
|
|
7238
|
+
}
|
|
7239
|
+
console.log(data.interpretation);
|
|
7240
|
+
return true;
|
|
7241
|
+
}
|
|
7242
|
+
if (name === "ai-bucket.restrict.preview") {
|
|
7243
|
+
const p = data.preview;
|
|
7244
|
+
console.log(`DRY RUN ${head(data.bucket)} → ${aiBucketRestrictionLabel(p.to)}${p.changed ? "" : " (no change)"}.`);
|
|
7245
|
+
console.log(`Accounts it reaches: ${formatAiBucketAccounts(p.memberAccounts)}`);
|
|
7246
|
+
console.log(`Sessions dropped: ${p.sessionsKickedUserIds.length ? p.sessionsKickedUserIds.map((id) => `#${id}`).join(", ") : "none"}; existing logins ${p.existingLoginsPreserved ? "kept" : "ended"}; existing bans kept.`);
|
|
7247
|
+
if (p.fullBlockLiftedUserIds.length) console.log(`Full block lifted for: ${p.fullBlockLiftedUserIds.map((id) => `#${id}`).join(", ")} (unless another restriction still applies).`);
|
|
7248
|
+
if (p.adminConflictUserIds.length) console.log(`BLOCKED: admin account(s) ${p.adminConflictUserIds.map((id) => `#${id}`).join(", ")} cannot be fully restricted.`);
|
|
7249
|
+
printAiBucketNetworkRuleChanges(p.networkRuleChanges);
|
|
7250
|
+
printAiBucketBlockers(p.blockers, data.sharedNetworkOverride);
|
|
7251
|
+
console.log("Nothing was changed. Run again with --confirm to apply.");
|
|
7252
|
+
return true;
|
|
7253
|
+
}
|
|
7254
|
+
if (name === "ai-bucket.restrict") {
|
|
7255
|
+
console.log(`${head(data.bucket)} (was ${aiBucketRestrictionLabel(data.previous)}); verified.`);
|
|
7256
|
+
printAiBucketNetworkRuleChanges(data.networkRuleChanges);
|
|
7257
|
+
if (data.sharedNetworkOverride) console.log("OVERRIDE USED: --allow-shared-network — a shared network or unmeasured rule is now restricted.");
|
|
7258
|
+
console.log(`Sessions dropped: ${data.kickedUserIds.length ? data.kickedUserIds.map((id) => `#${id}`).join(", ") : "none"}; existing logins ${data.existingLoginsPreserved ? "kept" : "ended"}; existing bans kept.`);
|
|
7259
|
+
return true;
|
|
7260
|
+
}
|
|
7261
|
+
if (name === "ai-bucket.rule.add.preview") {
|
|
7262
|
+
console.log(`DRY RUN ${head(data.bucket)} (the real run needs --confirm).`);
|
|
7263
|
+
if (data.device) {
|
|
7264
|
+
const d = data.device;
|
|
7265
|
+
if (d.sharedDevice) {
|
|
7266
|
+
console.log(`Shared device: ${d.observedAccountCount} accounts (${d.outsideBucketCount} outside this bucket); not listed, not family evidence.`);
|
|
7267
|
+
} else {
|
|
7268
|
+
console.log(`Device ${d.key} (${d.scope}) from #${d.sourceUserId}: seen on ${d.observedUserIds.map((id) => `#${id}`).join(", ") || "no account"} in ${d.lookbackDays} days; effective ${data.effectiveRestriction}.`);
|
|
7269
|
+
if (d.outsideBucketUserIds.length) console.log(`Outside this bucket: ${d.outsideBucketUserIds.map((id) => `#${id}`).join(", ")}`);
|
|
7270
|
+
}
|
|
7271
|
+
if (d.existingRuleId) console.log(`Existing rule #${d.existingRuleId} in bucket #${d.existingRuleBucketId}.`);
|
|
7272
|
+
if (!(data.blockers || []).length) console.log(`Pass the reviewed accounts with --user-ids ${d.observedUserIds.join(",")} on the real run.`);
|
|
7273
|
+
} else {
|
|
7274
|
+
const p = data.preview;
|
|
7275
|
+
const r = p.rule;
|
|
7276
|
+
console.log(`Rule: ${r.type} ${r.userId ? `#${r.userId}` : r.email || r.ip}${r.scope ? ` · scope ${r.scope}` : ""}${r.prefixRuleTypes?.length ? ` + prefix rules ${r.prefixRuleTypes.join(", ")}` : ""}; effective ${p.effectiveRestriction}.`);
|
|
7277
|
+
if (p.existingRule) console.log(`Existing rule #${p.existingRule.ruleId} in bucket #${p.existingRule.bucketId} (${p.existingRule.bucketLabel}).`);
|
|
7278
|
+
if (p.affectedAccounts) console.log(`Accounts it names: ${formatAiBucketAccounts(p.affectedAccounts)}`);
|
|
7279
|
+
if (p.exactAddress) {
|
|
7280
|
+
const a = p.exactAddress;
|
|
7281
|
+
console.log(a.kind === "shared_network"
|
|
7282
|
+
? `Address [${a.addressId}]: shared network, ${a.accountCount} accounts (not listed; not family evidence).`
|
|
7283
|
+
: `Address [${a.addressId}]: ${a.accountCount} account(s): ${formatAiBucketAccounts(a.accounts)}${a.olderHistoryNotRead ? " · older history not read" : ""}`);
|
|
7284
|
+
}
|
|
7285
|
+
if (p.prefixReach) {
|
|
7286
|
+
const r = p.prefixReach;
|
|
7287
|
+
console.log(r.measured
|
|
7288
|
+
? `Prefix: at least ${r.accountCount} account(s) used AI from it${r.sharedNetwork ? " — shared network" : ""}${r.olderHistoryNotRead ? " · older history not read" : ""} (accounts that never used AI there are not counted).`
|
|
7289
|
+
: "Prefix: reach could not be measured.");
|
|
7290
|
+
}
|
|
7291
|
+
for (const existing of p.existingPrefixRules || []) {
|
|
7292
|
+
if (existing) console.log(`Existing prefix rule #${existing.ruleId} in bucket #${existing.bucketId} (${existing.scope}).`);
|
|
7293
|
+
}
|
|
7294
|
+
if (p.sessionsKickedUserIds.length) console.log(`Sessions dropped: ${p.sessionsKickedUserIds.map((id) => `#${id}`).join(", ")}`);
|
|
7295
|
+
}
|
|
7296
|
+
printAiBucketBlockers(data.blockers || data.preview?.blockers || [], data.sharedNetworkOverride);
|
|
7297
|
+
console.log(`Would change: ${data.wouldChange ? "yes" : "no"}. Nothing was changed.`);
|
|
7298
|
+
return true;
|
|
7299
|
+
}
|
|
7300
|
+
if (name === "ai-bucket.rule.add") {
|
|
7301
|
+
console.log(`${head(data.bucket)}; rule saved; ${data.ruleCount} active rule(s), ${data.memberCount} member(s); verified.`);
|
|
7302
|
+
printAiBucketRules(data.addedRules);
|
|
7303
|
+
if (data.sharedNetworkOverride) console.log("OVERRIDE USED: --allow-shared-network — this rule reaches a shared network.");
|
|
7304
|
+
if (data.kickedUserIds?.length) console.log(`Sessions dropped: ${data.kickedUserIds.map((id) => `#${id}`).join(", ")}`);
|
|
7305
|
+
return true;
|
|
7306
|
+
}
|
|
7307
|
+
if (name === "ai-bucket.rule.disable.preview") {
|
|
7308
|
+
const p = data.preview;
|
|
7309
|
+
console.log(`DRY RUN ${head(data.bucket)} (the real run needs --confirm).`);
|
|
7310
|
+
printAiBucketRules([p.rule]);
|
|
7311
|
+
console.log(p.alreadyDisabled ? "Already disabled." : `Released accounts: ${formatAiBucketAccounts(p.releasedAccounts)}${p.liftsRestriction ? " (each keeps any other restriction that still applies)" : ""}.`);
|
|
7312
|
+
if (p.prefixRulesRemainActive) {
|
|
7313
|
+
console.log(`${p.prefixNote}${p.companionPrefixRuleIds?.length ? ` Its prefix rules here: ${p.companionPrefixRuleIds.map((id) => `#${id}`).join(", ")}.` : ""}`);
|
|
7314
|
+
}
|
|
7315
|
+
console.log(`Would change: ${data.wouldChange ? "yes" : "no"}. Nothing was changed.`);
|
|
7316
|
+
return true;
|
|
7317
|
+
}
|
|
7318
|
+
if (name === "ai-bucket.rule.disable") {
|
|
7319
|
+
console.log(`${head(data.bucket)}; rule #${data.disabledRule.id} disabled; ${data.ruleCount} active rule(s), ${data.memberCount} member(s); verified.`);
|
|
7320
|
+
console.log(`Released accounts: ${formatAiBucketAccounts(data.releasedAccounts)}`);
|
|
7321
|
+
return true;
|
|
7322
|
+
}
|
|
7323
|
+
return false;
|
|
7324
|
+
}
|
|
7325
|
+
|
|
6849
7326
|
function printAdminResult({ operation, result }) {
|
|
6850
7327
|
const data = result?.data || {};
|
|
6851
7328
|
if (printChatSafetyResult({ operation, data })) return;
|
|
@@ -6858,6 +7335,9 @@ function printAdminResult({ operation, result }) {
|
|
|
6858
7335
|
for (const line of formatProfileReview(data)) console.log(line);
|
|
6859
7336
|
return;
|
|
6860
7337
|
}
|
|
7338
|
+
if (operation.name.startsWith("ai-bucket.") && printAiBucketRuleResult({ operation, data })) {
|
|
7339
|
+
return;
|
|
7340
|
+
}
|
|
6861
7341
|
if (operation.name === "reward-activity.report") {
|
|
6862
7342
|
printRewardActivity(data);
|
|
6863
7343
|
return;
|
package/lib/commands.js
CHANGED
|
@@ -2478,7 +2478,14 @@ export function parseArgs(args) {
|
|
|
2478
2478
|
"show",
|
|
2479
2479
|
"foreground",
|
|
2480
2480
|
"open",
|
|
2481
|
+
"dryRun",
|
|
2482
|
+
"confirmEmailRule",
|
|
2483
|
+
"includePrefix",
|
|
2484
|
+
"allowSharedNetwork",
|
|
2481
2485
|
]);
|
|
2486
|
+
// A flag that may stand alone (true) or carry a value: `--confirm` confirms
|
|
2487
|
+
// an AI bucket change; `meetup slot <crewId> --confirm <slotIndex>` picks a slot.
|
|
2488
|
+
const optionalValueFlags = new Set(["confirm"]);
|
|
2482
2489
|
|
|
2483
2490
|
for (let i = 0; i < rest.length; i += 1) {
|
|
2484
2491
|
const arg = rest[i];
|
|
@@ -2499,6 +2506,12 @@ export function parseArgs(args) {
|
|
|
2499
2506
|
: inlineValue;
|
|
2500
2507
|
} else if (booleanFlags.has(camelKey)) {
|
|
2501
2508
|
raw[camelKey] = true;
|
|
2509
|
+
} else if (
|
|
2510
|
+
optionalValueFlags.has(camelKey) &&
|
|
2511
|
+
// a following flag (`--json`, `-h`) is never taken as the value
|
|
2512
|
+
(rest[i + 1] === undefined || rest[i + 1].startsWith("-"))
|
|
2513
|
+
) {
|
|
2514
|
+
raw[camelKey] = true;
|
|
2502
2515
|
} else {
|
|
2503
2516
|
const nextValue = rest[i + 1];
|
|
2504
2517
|
if (
|
|
@@ -2732,6 +2745,13 @@ export function parseArgs(args) {
|
|
|
2732
2745
|
: "",
|
|
2733
2746
|
adminBucketId: raw.bucketId ? String(raw.bucketId) : "",
|
|
2734
2747
|
adminDeviceKey: raw.deviceKey ? String(raw.deviceKey) : "",
|
|
2748
|
+
adminRuleId: raw.ruleId ? String(raw.ruleId) : "",
|
|
2749
|
+
adminIp: raw.ip ? String(raw.ip) : "",
|
|
2750
|
+
adminMessage: raw.message === undefined ? undefined : String(raw.message),
|
|
2751
|
+
adminDryRun: Boolean(raw.dryRun),
|
|
2752
|
+
adminConfirmEmailRule: Boolean(raw.confirmEmailRule),
|
|
2753
|
+
adminIncludePrefix: Boolean(raw.includePrefix),
|
|
2754
|
+
adminAllowSharedNetwork: Boolean(raw.allowSharedNetwork),
|
|
2735
2755
|
adminLabel: raw.label ? String(raw.label) : "",
|
|
2736
2756
|
adminUserIds: raw.userIds ? String(raw.userIds) : "",
|
|
2737
2757
|
adminUser: raw.user ? String(raw.user) : "",
|
|
@@ -2786,6 +2806,8 @@ export function parseArgs(args) {
|
|
|
2786
2806
|
adminForeground: Boolean(raw.foreground),
|
|
2787
2807
|
adminOpen: Boolean(raw.open),
|
|
2788
2808
|
adminConfigFile: raw.config ? String(raw.config) : "",
|
|
2809
|
+
// admin review propose --thumbnail <image|workspace>
|
|
2810
|
+
adminThumbnail: raw.thumbnail ? String(raw.thumbnail) : "",
|
|
2789
2811
|
adminReviewedBuildVersion: raw.reviewedVersion
|
|
2790
2812
|
? String(raw.reviewedVersion)
|
|
2791
2813
|
: "",
|
|
@@ -3177,6 +3199,15 @@ export function printHelp() {
|
|
|
3177
3199
|
lumine admin ai-bucket accounts add --bucket-id <id> --user-ids <id,id,...> [--note <text>] [--json]
|
|
3178
3200
|
lumine admin ai-bucket device block-signup --bucket-id <id> --user <source-user-id> --device-key <inspection-key> --user-ids <reviewed-device-users> --note <reason> [--json]
|
|
3179
3201
|
lumine admin ai-bucket note set --bucket-id <id> --note <text> [--json]
|
|
3202
|
+
lumine admin ai-bucket inspect --bucket-id <id> --reason <management reason> [--include-private-evidence] [--json]
|
|
3203
|
+
lumine admin ai-bucket restrict --bucket-id <id> --mode none|signup|full [--message <text>] [--allow-shared-network] (--dry-run | --confirm) [--json]
|
|
3204
|
+
lumine admin ai-bucket rule add --bucket-id <id> --type user --user <id> [--note <text>] (--dry-run | --confirm) [--json]
|
|
3205
|
+
lumine admin ai-bucket rule add --bucket-id <id> --type email --email <address> [--note <text>] (--dry-run | --confirm-email-rule --confirm) [--json]
|
|
3206
|
+
lumine admin ai-bucket rule add --bucket-id <id> --type ip --ip <address> --scope signup|full [--include-prefix] [--allow-shared-network] [--note <text>] (--dry-run | --confirm) [--json]
|
|
3207
|
+
lumine admin ai-bucket rule add --bucket-id <id> --type device --user <source-user-id> --device-key <inspect-key> --scope signup|full --user-ids <reviewed-device-users> --note <reason> (--dry-run | --confirm) [--json]
|
|
3208
|
+
lumine admin ai-bucket rule disable --bucket-id <id> --rule-id <id> [--note <text>] (--dry-run | --confirm) [--json]
|
|
3209
|
+
(every real change needs --confirm; --dry-run shows exactly what would change and writes nothing;
|
|
3210
|
+
a shared network (academy, school) or an unmeasured prefix is refused unless --allow-shared-network, only on Mikey's explicit word)
|
|
3180
3211
|
lumine admin ai-email-policy get --email <address> [--json]
|
|
3181
3212
|
lumine admin ai-email-policy set --email <address> --mode <automatic|separate_accounts> --note <text> [--json]
|
|
3182
3213
|
lumine admin daily-run start [--scope full|featured|newspaper] [--identity zero|ciel|auto] [--comment-mode off|draft|post] [--run-key <key>] [--json]
|
|
@@ -3216,7 +3247,7 @@ export function printHelp() {
|
|
|
3216
3247
|
lumine admin chat-reports suspend --user <id> --note <why> [--json] (full ban: signs them out)
|
|
3217
3248
|
lumine admin reward-review show <review-id> [--dir <path>] [--json]
|
|
3218
3249
|
lumine admin reward-review approve <review-id> [--config <rules.json>] [--reason <text>] [--json] (approval publishes the approved version)
|
|
3219
|
-
lumine admin reward-review propose <review-id> --dir <edited-snapshot> --config <rules.json> [--reason <text>] [--json]
|
|
3250
|
+
lumine admin reward-review propose <review-id> --dir <edited-snapshot> --config <rules.json> [--reason <text>] [--title <new app name>] [--thumbnail <image|workspace>] [--json]
|
|
3220
3251
|
lumine admin reward-review reject|revoke <review-id> --reason <text> [--json]
|
|
3221
3252
|
lumine admin recommendations list [--since-run|--after <date>|--include-legacy] [--all --checkpoint <file> [--resume]] [--content-types comment,dailyReflection] [--unviewed|--viewed] [--cursor <cursor>] [--json]
|
|
3222
3253
|
lumine admin builds candidates [--since-run|--after <date>|--include-legacy] [--all --checkpoint <file> [--resume]] [--cursor <cursor>] [--limit <number>] [--json]
|
package/lib/thumbnail.js
CHANGED
|
@@ -134,6 +134,27 @@ export async function thumbnailSet(options) {
|
|
|
134
134
|
if (!filePath) {
|
|
135
135
|
throw new Error("Usage: lumine thumbnail set <file> (jpg, png, or webp)");
|
|
136
136
|
}
|
|
137
|
+
const { buffer, contentType } = await readThumbnailFile(filePath);
|
|
138
|
+
|
|
139
|
+
const { buildId, auth, build } = await resolveThumbnailContext(options);
|
|
140
|
+
const proceed = await confirmThumbnailOverwrite({ options, build, buildId });
|
|
141
|
+
if (!proceed) return;
|
|
142
|
+
|
|
143
|
+
const { thumbnailUrl } = await commitThumbnailImage({
|
|
144
|
+
options,
|
|
145
|
+
auth,
|
|
146
|
+
buildId,
|
|
147
|
+
buffer,
|
|
148
|
+
contentType,
|
|
149
|
+
});
|
|
150
|
+
console.log(`Thumbnail set for Build #${buildId}:`);
|
|
151
|
+
console.log(` ${thumbnailUrl}`);
|
|
152
|
+
console.log("Tip: thumbnails display ~16:9; the image was uploaded uncropped.");
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// A local jpg/png/webp checked against the thumbnail limits, ready for
|
|
156
|
+
// commitThumbnailImage (also used by `lumine admin review propose --thumbnail`).
|
|
157
|
+
export async function readThumbnailFile(filePath) {
|
|
137
158
|
const absolutePath = path.resolve(filePath);
|
|
138
159
|
const extension = path.extname(absolutePath).toLowerCase();
|
|
139
160
|
const contentType = THUMBNAIL_CONTENT_TYPE_BY_EXTENSION[extension];
|
|
@@ -159,21 +180,7 @@ export async function thumbnailSet(options) {
|
|
|
159
180
|
`Thumbnail file is too big (${formatBytes(buffer.length)}; max ${formatBytes(THUMBNAIL_MAX_FILE_SIZE_BYTES)}).`,
|
|
160
181
|
);
|
|
161
182
|
}
|
|
162
|
-
|
|
163
|
-
const { buildId, auth, build } = await resolveThumbnailContext(options);
|
|
164
|
-
const proceed = await confirmThumbnailOverwrite({ options, build, buildId });
|
|
165
|
-
if (!proceed) return;
|
|
166
|
-
|
|
167
|
-
const { thumbnailUrl } = await commitThumbnailImage({
|
|
168
|
-
options,
|
|
169
|
-
auth,
|
|
170
|
-
buildId,
|
|
171
|
-
buffer,
|
|
172
|
-
contentType,
|
|
173
|
-
});
|
|
174
|
-
console.log(`Thumbnail set for Build #${buildId}:`);
|
|
175
|
-
console.log(` ${thumbnailUrl}`);
|
|
176
|
-
console.log("Tip: thumbnails display ~16:9; the image was uploaded uncropped.");
|
|
183
|
+
return { buffer, contentType };
|
|
177
184
|
}
|
|
178
185
|
|
|
179
186
|
function decodeImageDataUrl(dataUrl) {
|
package/package.json
CHANGED
package/sdk/LUMINE_ADMIN.md
CHANGED
|
@@ -131,7 +131,8 @@ canonical bucket. Use it to distinguish quota bookkeeping from moderation;
|
|
|
131
131
|
the note itself changes no access, ban, or identity rules.
|
|
132
132
|
|
|
133
133
|
The commands above are quota bookkeeping only. They cannot ban accounts, block signup,
|
|
134
|
-
add IP/device/risk-key rules, or infer an account family
|
|
134
|
+
add IP/device/risk-key rules, or infer an account family (the explicit
|
|
135
|
+
restricted-bucket commands below can, on Mikey's instruction). Identification
|
|
135
136
|
remains a human/LLM evidence judgment and must be explicitly requested by
|
|
136
137
|
Mikey; routine administrator runs still escalate suspected alternate accounts
|
|
137
138
|
and never auto-enforce. `accounts add` and `note set` still accept only an
|
|
@@ -159,6 +160,85 @@ raw device ID. The private audit records the request before evidence is read
|
|
|
159
160
|
and records successful completion separately. Routine reviews still never
|
|
160
161
|
auto-enforce; this action requires Mikey's explicit instruction for the device.
|
|
161
162
|
|
|
163
|
+
### Restricted buckets: inspect, rules and restrictions
|
|
164
|
+
|
|
165
|
+
These are the operator path for buckets with a restriction (and for adding
|
|
166
|
+
email, IP and device rules to any bucket). They use the same shared operations
|
|
167
|
+
as the Management page, so each change has the same side effects there
|
|
168
|
+
(current-day usage re-attribution, ban cache, member notification and kicks,
|
|
169
|
+
live quota updates). Use them only on Mikey's explicit instruction for that
|
|
170
|
+
bucket, rule or restriction.
|
|
171
|
+
|
|
172
|
+
```bash
|
|
173
|
+
lumine admin ai-bucket inspect --bucket-id 4 --reason "todo 79 review" --json
|
|
174
|
+
lumine admin ai-bucket restrict --bucket-id 4 --mode signup --dry-run
|
|
175
|
+
lumine admin ai-bucket restrict --bucket-id 4 --mode signup --confirm
|
|
176
|
+
lumine admin ai-bucket rule add --bucket-id 4 --type user --user 18559 --dry-run
|
|
177
|
+
lumine admin ai-bucket rule add --bucket-id 4 --type user --user 18559 --confirm
|
|
178
|
+
lumine admin ai-bucket rule add --bucket-id 4 --type email \
|
|
179
|
+
--email family@example.com --confirm-email-rule --confirm
|
|
180
|
+
lumine admin ai-bucket rule add --bucket-id 4 --type ip --ip 58.151.12.59 \
|
|
181
|
+
--scope signup [--include-prefix] --confirm
|
|
182
|
+
lumine admin ai-bucket rule add --bucket-id 4 --type device --user 18559 \
|
|
183
|
+
--device-key 0123456789abcdef --scope signup --user-ids 18559,20001 \
|
|
184
|
+
--note "Mikey approved this reviewed device" --confirm
|
|
185
|
+
lumine admin ai-bucket rule disable --bucket-id 4 --rule-id 77 --dry-run
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
`inspect` works for unrestricted and restricted buckets. It returns the
|
|
189
|
+
canonical bucket (label, note, restriction `none|signup|full`, message), every
|
|
190
|
+
active rule (id, kind, scope, effective restriction, match), the canonical
|
|
191
|
+
members, every account a full restriction would reach, and for each member
|
|
192
|
+
(first 25) their devices (inspection keys, never raw IDs) and network
|
|
193
|
+
addresses with how many accounts share each. A device or address used by many
|
|
194
|
+
accounts (an academy, a school) is counted, marked shared and never listed or
|
|
195
|
+
treated as family evidence. Addresses and email-rule addresses (in evidence, rules and rule notes)
|
|
196
|
+
are masked unless `--include-private-evidence`; every other output, and the
|
|
197
|
+
audit, keeps them masked (with a keyed hash in the audit). `--reason` is required; the API commits an
|
|
198
|
+
access receipt before reading evidence.
|
|
199
|
+
|
|
200
|
+
`--dry-run` on `restrict`, `rule add` and `rule disable` is a read: it returns
|
|
201
|
+
the current and resulting restriction, the accounts newly affected or released,
|
|
202
|
+
the accounts whose sessions a full restriction drops, admin conflicts and any
|
|
203
|
+
blocker, and changes nothing. Device, IP and email previews read private
|
|
204
|
+
evidence, so they record an access receipt; the others record nothing.
|
|
205
|
+
|
|
206
|
+
A real run is audited (request receipt with the idempotency key and payload
|
|
207
|
+
fingerprint, then the completed result) and returns the bucket read back from
|
|
208
|
+
the database and checked (`verified: true`); the CLI refuses a result that
|
|
209
|
+
does not confirm the requested state. A failure after the change started is
|
|
210
|
+
recorded as failed; a replay with the same idempotency key returns the stored
|
|
211
|
+
result instead of running again.
|
|
212
|
+
|
|
213
|
+
- `restrict` always needs `--confirm`. `signup` blocks only new accounts:
|
|
214
|
+
existing logins and existing bans (`users.banned`) stay as they are. `full`
|
|
215
|
+
drops members' sessions and is refused while any member is an admin. `none`
|
|
216
|
+
lifts the restriction; each member gets its effective ban state pushed again.
|
|
217
|
+
Without `--message` the current message is kept. The preview and the
|
|
218
|
+
result list every IP, prefix and device rule whose effective restriction
|
|
219
|
+
changes, with how many accounts it reaches (exact IPs from site activity;
|
|
220
|
+
prefixes and devices from a year of AI usage, a lower bound). A rule that
|
|
221
|
+
gets stronger on a shared network (more than 8 accounts) or whose reach
|
|
222
|
+
can't be measured refuses the change unless `--allow-shared-network` is
|
|
223
|
+
passed; use it only on Mikey's explicit word. The CLI prints the override.
|
|
224
|
+
Saving a full bucket again (even only a new message) re-kicks its members,
|
|
225
|
+
as on the Management page; the preview lists them.
|
|
226
|
+
- `rule add` / `rule disable` always need `--confirm` too: on an unrestricted
|
|
227
|
+
bucket a rule still pools AI Energy, on a restricted one it changes who is
|
|
228
|
+
blocked. On a fully restricted bucket an account or email rule re-kicks every
|
|
229
|
+
account the bucket reaches (as on the Management page); the preview lists them.
|
|
230
|
+
Email rules cover every account with that verified address and also need
|
|
231
|
+
`--confirm-email-rule`. IP and device rules need `--scope`; IP prefix rules
|
|
232
|
+
(`--include-prefix`, the whole /24 or /64) are opt-in, their reach measured
|
|
233
|
+
as above. A shared exact address or prefix, or an unmeasured prefix under a
|
|
234
|
+
full restriction, is refused unless `--allow-shared-network`. An address or
|
|
235
|
+
prefix rule held by another bucket is refused, never moved. Device rules
|
|
236
|
+
keep the `device block-signup` guards: the reviewed `--user-ids` must equal
|
|
237
|
+
the accounts seen on the device, all inside the bucket; a device more than
|
|
238
|
+
9 accounts used is shared (counted, not listed, refused). Disabling an exact
|
|
239
|
+
IP rule leaves its prefix rules active (the preview names them).
|
|
240
|
+
`rule disable` keeps `--note` in the audit receipt only.
|
|
241
|
+
|
|
162
242
|
### Shared verified-email policies
|
|
163
243
|
|
|
164
244
|
A verified address proves that an account can use that mailbox; it does not
|
|
@@ -1320,10 +1400,19 @@ lumine admin reward-review approve 2 --config rules.json \
|
|
|
1320
1400
|
lumine admin reward-review show 2 --dir /private/tmp/reward-review-2 --json # then edit that directory…
|
|
1321
1401
|
lumine admin reward-review propose 2 --dir /private/tmp/reward-review-2 \
|
|
1322
1402
|
--config rules.json --reason "Moved the claim after the stage clears" --json # …and offer it
|
|
1403
|
+
lumine admin reward-review propose 2 --dir /private/tmp/reward-review-2 --config rules.json \
|
|
1404
|
+
--title "Word Forge" --thumbnail cover.png --json # …also offering a new name and thumbnail
|
|
1323
1405
|
lumine admin reward-review reject 2 --reason "Rewards fire on game over; nothing is earned" --json
|
|
1324
1406
|
lumine admin reward-review revoke 2 --reason "Farmable; pausing until redesigned" --json
|
|
1325
1407
|
```
|
|
1326
1408
|
|
|
1409
|
+
A proposal can also offer the app a new name (`--title`, at most 100
|
|
1410
|
+
characters) and a new thumbnail (`--thumbnail <jpg|png|webp>`, uploaded to your
|
|
1411
|
+
proposal workspace copy first; `--thumbnail workspace` offers the one already
|
|
1412
|
+
set on that copy, e.g. from the website). Both are frozen with the offer and
|
|
1413
|
+
applied together with the files and rules when the creator accepts. An offer
|
|
1414
|
+
may change only the name or thumbnail; the files can then stay as submitted.
|
|
1415
|
+
|
|
1327
1416
|
`show --dir` writes the exact reviewed snapshot (private files, including the
|
|
1328
1417
|
app's own `rewards.json`) so the agent can read it like a pulled workspace. The
|
|
1329
1418
|
result's `config` is the proposal: the declared economy with the sheet's
|