@stage5/lumine 0.3.31 → 0.3.33
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-craft.js +235 -0
- package/lib/admin-receipts.js +25 -0
- package/lib/admin.js +495 -3
- package/lib/commands.js +41 -1
- package/lib/thumbnail.js +22 -15
- package/package.json +1 -1
- package/sdk/BUILD_SDK_INDEX.md +35 -4
- package/sdk/LUMINE_ADMIN.md +183 -1
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";
|
|
@@ -39,6 +44,7 @@ import {
|
|
|
39
44
|
printOwnerTrace,
|
|
40
45
|
} from "./admin-owner-trace.js";
|
|
41
46
|
import { buildRolesOperation, printRoles } from "./admin-roles.js";
|
|
47
|
+
import { buildCraftOperation, formatCraftResult } from "./admin-craft.js";
|
|
42
48
|
import { formatChatReportResult } from "./admin-chat-reports.js";
|
|
43
49
|
import { formatIdentityDeviceUsage } from "./admin-identity-inspection.js";
|
|
44
50
|
import {
|
|
@@ -202,7 +208,7 @@ const REVIEW_REQUEST_LABELS = {
|
|
|
202
208
|
cardcraft: "AI Card crafting",
|
|
203
209
|
};
|
|
204
210
|
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.";
|
|
211
|
+
"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
212
|
|
|
207
213
|
function parseReviewRequestType(value) {
|
|
208
214
|
const key = String(value || "")
|
|
@@ -945,6 +951,20 @@ function buildRewardProposalOperation(reviewId, options) {
|
|
|
945
951
|
);
|
|
946
952
|
}
|
|
947
953
|
const files = readRewardProposalDirectory(options.dir);
|
|
954
|
+
// --title proposes a new app name; --thumbnail <file> a new thumbnail (set
|
|
955
|
+
// on your proposal workspace copy first), or --thumbnail workspace to offer
|
|
956
|
+
// the one already set on that copy (e.g. from the website).
|
|
957
|
+
const title = String(options.title || "").trim();
|
|
958
|
+
if (title.length > 100) {
|
|
959
|
+
throw cliValidationError("--title must be at most 100 characters.");
|
|
960
|
+
}
|
|
961
|
+
const thumbnail = String(options.adminThumbnail || "").trim();
|
|
962
|
+
// Checked before signing in or opening the copy; its hash makes a retry
|
|
963
|
+
// with a different picture a new request rather than a replay.
|
|
964
|
+
const thumbnailSha256 =
|
|
965
|
+
thumbnail && thumbnail !== "workspace"
|
|
966
|
+
? hashProposalThumbnailFile(thumbnail)
|
|
967
|
+
: "";
|
|
948
968
|
return writeOperation(
|
|
949
969
|
"reward-review.propose",
|
|
950
970
|
"POST",
|
|
@@ -953,11 +973,78 @@ function buildRewardProposalOperation(reviewId, options) {
|
|
|
953
973
|
files,
|
|
954
974
|
config: readRewardConfigFile(options.adminConfigFile),
|
|
955
975
|
reason,
|
|
976
|
+
...(title ? { title } : {}),
|
|
977
|
+
...(thumbnail ? { thumbnailFrom: "workspace" } : {}),
|
|
978
|
+
...(thumbnailSha256 ? { thumbnailSha256 } : {}),
|
|
979
|
+
},
|
|
980
|
+
{
|
|
981
|
+
requiresRun: false,
|
|
982
|
+
reviewId,
|
|
983
|
+
fileCount: files.length,
|
|
984
|
+
...(thumbnail && thumbnail !== "workspace"
|
|
985
|
+
? {
|
|
986
|
+
prepare: ({ options: runOptions, auth }) =>
|
|
987
|
+
setProposalWorkspaceThumbnail({
|
|
988
|
+
options: runOptions,
|
|
989
|
+
auth,
|
|
990
|
+
reviewId,
|
|
991
|
+
filePath: thumbnail,
|
|
992
|
+
}),
|
|
993
|
+
}
|
|
994
|
+
: {}),
|
|
956
995
|
},
|
|
957
|
-
{ requiresRun: false, reviewId, fileCount: files.length },
|
|
958
996
|
);
|
|
959
997
|
}
|
|
960
998
|
|
|
999
|
+
// Puts a proposed thumbnail on the reviewer's proposal workspace copy (opened
|
|
1000
|
+
// on first use), the copy the offer's thumbnailFrom 'workspace' reads.
|
|
1001
|
+
async function setProposalWorkspaceThumbnail({
|
|
1002
|
+
options,
|
|
1003
|
+
auth,
|
|
1004
|
+
reviewId,
|
|
1005
|
+
filePath,
|
|
1006
|
+
}) {
|
|
1007
|
+
const { buffer, contentType } = await readThumbnailFile(filePath);
|
|
1008
|
+
const receipt = await requestJson({
|
|
1009
|
+
method: "POST",
|
|
1010
|
+
url: `${options.apiUrl}/cli/admin/reward-reviews/${reviewId}/workspace`,
|
|
1011
|
+
authToken: auth.token,
|
|
1012
|
+
body: {},
|
|
1013
|
+
headers: { "x-lumine-idempotency-key": `cli:${randomUUID()}` },
|
|
1014
|
+
timeoutMs: options.timeoutMs,
|
|
1015
|
+
});
|
|
1016
|
+
const buildId = Number(receipt?.data?.workspace?.buildId || 0);
|
|
1017
|
+
if (!receipt?.ok || !buildId) {
|
|
1018
|
+
throw new Error(
|
|
1019
|
+
receipt?.error || "Could not open the proposal workspace copy.",
|
|
1020
|
+
);
|
|
1021
|
+
}
|
|
1022
|
+
await commitThumbnailImage({ options, auth, buildId, buffer, contentType });
|
|
1023
|
+
// stderr: --json output stays one parseable receipt.
|
|
1024
|
+
console.error(`Proposed thumbnail set on workspace copy (Build #${buildId}).`);
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
function hashProposalThumbnailFile(filePath) {
|
|
1028
|
+
const extension = path.extname(filePath).toLowerCase();
|
|
1029
|
+
if (!THUMBNAIL_CONTENT_TYPE_BY_EXTENSION[extension]) {
|
|
1030
|
+
throw cliValidationError(
|
|
1031
|
+
`--thumbnail must be a ${Object.keys(THUMBNAIL_CONTENT_TYPE_BY_EXTENSION).join(", ")} file, or "workspace".`,
|
|
1032
|
+
);
|
|
1033
|
+
}
|
|
1034
|
+
let buffer;
|
|
1035
|
+
try {
|
|
1036
|
+
buffer = readFileSync(path.resolve(filePath));
|
|
1037
|
+
} catch {
|
|
1038
|
+
throw cliValidationError(`Thumbnail file does not exist: ${filePath}`);
|
|
1039
|
+
}
|
|
1040
|
+
if (!buffer.length || buffer.length > THUMBNAIL_MAX_FILE_SIZE_BYTES) {
|
|
1041
|
+
throw cliValidationError(
|
|
1042
|
+
`Thumbnail file must be between 1 byte and ${formatBytes(THUMBNAIL_MAX_FILE_SIZE_BYTES)}.`,
|
|
1043
|
+
);
|
|
1044
|
+
}
|
|
1045
|
+
return createHash("sha256").update(buffer).digest("hex");
|
|
1046
|
+
}
|
|
1047
|
+
|
|
961
1048
|
const REWARD_PROPOSAL_MAX_FILES = 500;
|
|
962
1049
|
const REWARD_PROPOSAL_MAX_BYTES = 5 * 1024 * 1024;
|
|
963
1050
|
const REWARD_PROPOSAL_SKIPPED_DIRS = new Set([
|
|
@@ -1364,6 +1451,12 @@ export async function adminCommand(options) {
|
|
|
1364
1451
|
(operation.mutates ? `cli:${randomUUID()}` : "");
|
|
1365
1452
|
let result;
|
|
1366
1453
|
try {
|
|
1454
|
+
// A step that must land before the request (e.g. a proposed thumbnail
|
|
1455
|
+
// uploaded to the reviewer's workspace copy); its failure is reported
|
|
1456
|
+
// like the request's own.
|
|
1457
|
+
if (typeof operation.prepare === "function") {
|
|
1458
|
+
await operation.prepare({ options, auth });
|
|
1459
|
+
}
|
|
1367
1460
|
const fetchOperation = async (requestPath = operation.path, signal) => {
|
|
1368
1461
|
const receipt = await requestJson({
|
|
1369
1462
|
method: operation.method,
|
|
@@ -1576,6 +1669,9 @@ export async function adminCommand(options) {
|
|
|
1576
1669
|
if (operation.name === "ai-email-policy.set") {
|
|
1577
1670
|
assertAiEmailPolicySetResult({ operation, result });
|
|
1578
1671
|
}
|
|
1672
|
+
if (AI_BUCKET_CHANGE_OPERATIONS.has(operation.name)) {
|
|
1673
|
+
assertAiBucketChangeResult({ operation, result });
|
|
1674
|
+
}
|
|
1579
1675
|
if (operation.name === "daily-run.start") {
|
|
1580
1676
|
assertAdminTodoHandoffResult(result, operation.body.scope);
|
|
1581
1677
|
}
|
|
@@ -2045,6 +2141,42 @@ export function assertAiEmailPolicySetResult({ operation, result }) {
|
|
|
2045
2141
|
throw error;
|
|
2046
2142
|
}
|
|
2047
2143
|
|
|
2144
|
+
const AI_BUCKET_CHANGE_OPERATIONS = new Set([
|
|
2145
|
+
"ai-bucket.restrict",
|
|
2146
|
+
"ai-bucket.rule.add",
|
|
2147
|
+
"ai-bucket.rule.disable",
|
|
2148
|
+
]);
|
|
2149
|
+
|
|
2150
|
+
// A bucket change is done only when the API re-read the canonical bucket and
|
|
2151
|
+
// confirmed it (verified), and a restriction reads back as the requested mode.
|
|
2152
|
+
export function assertAiBucketChangeResult({ operation, result }) {
|
|
2153
|
+
const data = result?.data || {};
|
|
2154
|
+
const requestedMode =
|
|
2155
|
+
operation?.name === "ai-bucket.restrict" ? operation.body?.mode : null;
|
|
2156
|
+
if (
|
|
2157
|
+
result?.ok === true &&
|
|
2158
|
+
data.verified === true &&
|
|
2159
|
+
Number(data.bucket?.id) > 0 &&
|
|
2160
|
+
(!requestedMode || data.bucket?.restriction?.mode === requestedMode)
|
|
2161
|
+
) {
|
|
2162
|
+
return;
|
|
2163
|
+
}
|
|
2164
|
+
const error = new Error(
|
|
2165
|
+
"The API did not confirm the canonical AI bucket state after this change. Inspect the bucket before retrying.",
|
|
2166
|
+
);
|
|
2167
|
+
error.code = "LUMINE_ADMIN_AI_BUCKET_NOT_VERIFIED";
|
|
2168
|
+
error.data = {
|
|
2169
|
+
ok: false,
|
|
2170
|
+
status: "partial_failure",
|
|
2171
|
+
error: {
|
|
2172
|
+
code: error.code,
|
|
2173
|
+
message: error.message,
|
|
2174
|
+
details: { requestedMode, canonical: result?.data || null },
|
|
2175
|
+
},
|
|
2176
|
+
};
|
|
2177
|
+
throw error;
|
|
2178
|
+
}
|
|
2179
|
+
|
|
2048
2180
|
const RECOMMENDATION_CONTENT_TYPES = new Map([
|
|
2049
2181
|
["comment", "comment"],
|
|
2050
2182
|
["aistory", "aiStory"],
|
|
@@ -2452,6 +2584,10 @@ export function parseAdminOperation(options) {
|
|
|
2452
2584
|
if (namespace === "roles") {
|
|
2453
2585
|
return buildRolesOperation(options);
|
|
2454
2586
|
}
|
|
2587
|
+
// Twinkle.craft universe curation (admin-craft.js); no run.
|
|
2588
|
+
if (namespace === "craft") {
|
|
2589
|
+
return buildCraftOperation({ action, options });
|
|
2590
|
+
}
|
|
2455
2591
|
|
|
2456
2592
|
if (namespace === "correction" || namespace === "corrections") {
|
|
2457
2593
|
if (!action || action === "status") {
|
|
@@ -2660,6 +2796,78 @@ export function parseAdminOperation(options) {
|
|
|
2660
2796
|
},
|
|
2661
2797
|
);
|
|
2662
2798
|
}
|
|
2799
|
+
if (action === "inspect") {
|
|
2800
|
+
const reason = String(options.adminReason || "").trim();
|
|
2801
|
+
if (!reason) {
|
|
2802
|
+
throw cliValidationError(
|
|
2803
|
+
"Say why with --reason <management reason>; the inspection is audited.",
|
|
2804
|
+
);
|
|
2805
|
+
}
|
|
2806
|
+
if (reason.length > MAX_IDENTITY_INSPECTION_REASON_LENGTH) {
|
|
2807
|
+
throw cliValidationError(
|
|
2808
|
+
`An inspection reason must be at most ${MAX_IDENTITY_INSPECTION_REASON_LENGTH} characters.`,
|
|
2809
|
+
);
|
|
2810
|
+
}
|
|
2811
|
+
// A read like identity inspect: private evidence is printed, never
|
|
2812
|
+
// kept on disk; the server records who looked, when and why.
|
|
2813
|
+
return bodyReadOperation(
|
|
2814
|
+
"ai-bucket.inspect",
|
|
2815
|
+
"POST",
|
|
2816
|
+
`/cli/admin/ai-buckets/${bucketId}/inspect`,
|
|
2817
|
+
{
|
|
2818
|
+
reason,
|
|
2819
|
+
includePrivateEvidence: options.adminIncludePrivateEvidence === true,
|
|
2820
|
+
},
|
|
2821
|
+
);
|
|
2822
|
+
}
|
|
2823
|
+
if (action === "restrict") {
|
|
2824
|
+
const mode = String(options.adminMode || "");
|
|
2825
|
+
if (!["none", "signup", "full"].includes(mode)) {
|
|
2826
|
+
throw cliValidationError("Pass --mode none, signup or full.");
|
|
2827
|
+
}
|
|
2828
|
+
const body = { mode };
|
|
2829
|
+
if (options.adminMessage !== undefined) {
|
|
2830
|
+
const message = String(options.adminMessage).trim();
|
|
2831
|
+
if (message.length > 500) {
|
|
2832
|
+
throw cliValidationError("--message must be at most 500 characters.");
|
|
2833
|
+
}
|
|
2834
|
+
body.message = message;
|
|
2835
|
+
}
|
|
2836
|
+
return aiBucketChangeOperation({
|
|
2837
|
+
options,
|
|
2838
|
+
name: "ai-bucket.restrict",
|
|
2839
|
+
method: "PUT",
|
|
2840
|
+
path: `/cli/admin/ai-buckets/${bucketId}/restriction`,
|
|
2841
|
+
body,
|
|
2842
|
+
// a restriction changes only on an explicit instruction
|
|
2843
|
+
confirmRequired: true,
|
|
2844
|
+
});
|
|
2845
|
+
}
|
|
2846
|
+
if (action === "rule" && target === "add") {
|
|
2847
|
+
return aiBucketChangeOperation({
|
|
2848
|
+
options,
|
|
2849
|
+
name: "ai-bucket.rule.add",
|
|
2850
|
+
method: "POST",
|
|
2851
|
+
path: `/cli/admin/ai-buckets/${bucketId}/rules`,
|
|
2852
|
+
body: parseAiBucketRuleBody(options),
|
|
2853
|
+
confirmRequired: true,
|
|
2854
|
+
});
|
|
2855
|
+
}
|
|
2856
|
+
if (action === "rule" && target === "disable") {
|
|
2857
|
+
const ruleId = parseRequiredInteger(options.adminRuleId, "--rule-id", 1);
|
|
2858
|
+
const note = String(options.note || "").trim();
|
|
2859
|
+
if (note.length > 180) {
|
|
2860
|
+
throw cliValidationError("--note must be at most 180 characters.");
|
|
2861
|
+
}
|
|
2862
|
+
return aiBucketChangeOperation({
|
|
2863
|
+
options,
|
|
2864
|
+
name: "ai-bucket.rule.disable",
|
|
2865
|
+
method: "POST",
|
|
2866
|
+
path: `/cli/admin/ai-buckets/${bucketId}/rules/${ruleId}/disable`,
|
|
2867
|
+
body: note ? { note } : {},
|
|
2868
|
+
confirmRequired: true,
|
|
2869
|
+
});
|
|
2870
|
+
}
|
|
2663
2871
|
if (action === "note" && target === "set") {
|
|
2664
2872
|
return writeOperation(
|
|
2665
2873
|
"ai-bucket.note.set",
|
|
@@ -2748,7 +2956,7 @@ export function parseAdminOperation(options) {
|
|
|
2748
2956
|
});
|
|
2749
2957
|
if (operation) return operation;
|
|
2750
2958
|
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>.",
|
|
2959
|
+
"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
2960
|
);
|
|
2753
2961
|
}
|
|
2754
2962
|
|
|
@@ -5085,6 +5293,108 @@ export function assertAdminTodoHandoffResult(result, expectedRunScope = null) {
|
|
|
5085
5293
|
return handoff;
|
|
5086
5294
|
}
|
|
5087
5295
|
|
|
5296
|
+
// --dry-run is a read (no receipt, nothing changes); a real run is a write
|
|
5297
|
+
// that needs --confirm, checked here and again by the server.
|
|
5298
|
+
function aiBucketChangeOperation({ options, name, method, path, body, confirmRequired }) {
|
|
5299
|
+
// --allow-shared-network lets a rule reach a shared network (an academy,
|
|
5300
|
+
// a school); it is sent, and printed, only when given
|
|
5301
|
+
if (options.adminAllowSharedNetwork) {
|
|
5302
|
+
if (!["ai-bucket.restrict", "ai-bucket.rule.add"].includes(name)) {
|
|
5303
|
+
throw cliValidationError("--allow-shared-network applies only to restrict and rule add.");
|
|
5304
|
+
}
|
|
5305
|
+
body = { ...body, allowSharedNetwork: true };
|
|
5306
|
+
}
|
|
5307
|
+
if (options.adminDryRun) {
|
|
5308
|
+
return bodyReadOperation(`${name}.preview`, method, path, {
|
|
5309
|
+
...body,
|
|
5310
|
+
dryRun: true,
|
|
5311
|
+
});
|
|
5312
|
+
}
|
|
5313
|
+
const confirm = options.adminConfirm;
|
|
5314
|
+
if (confirm !== "" && confirm !== "true") {
|
|
5315
|
+
throw cliValidationError("Pass --confirm on its own, without a value.");
|
|
5316
|
+
}
|
|
5317
|
+
if (confirmRequired && confirm !== "true") {
|
|
5318
|
+
throw cliValidationError(
|
|
5319
|
+
"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.",
|
|
5320
|
+
);
|
|
5321
|
+
}
|
|
5322
|
+
return writeOperation(name, method, path, {
|
|
5323
|
+
...body,
|
|
5324
|
+
...(confirm === "true" ? { confirm: true } : {}),
|
|
5325
|
+
});
|
|
5326
|
+
}
|
|
5327
|
+
|
|
5328
|
+
function parseAiBucketRuleBody(options) {
|
|
5329
|
+
const type = String(options.adminType || "");
|
|
5330
|
+
const note = String(options.note || "").trim();
|
|
5331
|
+
if (note.length > 180) {
|
|
5332
|
+
throw cliValidationError("--note must be at most 180 characters.");
|
|
5333
|
+
}
|
|
5334
|
+
const withNote = (body) => (note ? { ...body, note } : body);
|
|
5335
|
+
const scope = () => {
|
|
5336
|
+
const value = String(options.adminScope || "");
|
|
5337
|
+
if (!["signup", "full"].includes(value)) {
|
|
5338
|
+
throw cliValidationError(
|
|
5339
|
+
"Pass --scope signup or full for an IP or device rule.",
|
|
5340
|
+
);
|
|
5341
|
+
}
|
|
5342
|
+
return value;
|
|
5343
|
+
};
|
|
5344
|
+
if (type === "user") {
|
|
5345
|
+
return withNote({
|
|
5346
|
+
type,
|
|
5347
|
+
userId: parseRequiredInteger(options.adminUser, "--user", 1),
|
|
5348
|
+
});
|
|
5349
|
+
}
|
|
5350
|
+
if (type === "email") {
|
|
5351
|
+
const email = String(options.adminEmail || "").trim().toLowerCase();
|
|
5352
|
+
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
|
5353
|
+
throw cliValidationError("Pass a valid --email <address>.");
|
|
5354
|
+
}
|
|
5355
|
+
if (!options.adminDryRun && !options.adminConfirmEmailRule) {
|
|
5356
|
+
throw cliValidationError(
|
|
5357
|
+
"An email rule covers every account with that verified address. Pass --confirm-email-rule (run --dry-run first to see them).",
|
|
5358
|
+
);
|
|
5359
|
+
}
|
|
5360
|
+
return withNote({
|
|
5361
|
+
type,
|
|
5362
|
+
email,
|
|
5363
|
+
...(options.adminConfirmEmailRule ? { confirmEmailRule: true } : {}),
|
|
5364
|
+
});
|
|
5365
|
+
}
|
|
5366
|
+
if (type === "ip") {
|
|
5367
|
+
const ip = String(options.adminIp || "").trim();
|
|
5368
|
+
if (!ip) throw cliValidationError("Pass --ip <address>.");
|
|
5369
|
+
return withNote({
|
|
5370
|
+
type,
|
|
5371
|
+
ip,
|
|
5372
|
+
scope: scope(),
|
|
5373
|
+
...(options.adminIncludePrefix ? { includePrefix: true } : {}),
|
|
5374
|
+
});
|
|
5375
|
+
}
|
|
5376
|
+
if (type === "device") {
|
|
5377
|
+
if (!/^[a-f0-9]{16}$/.test(options.adminDeviceKey || "")) {
|
|
5378
|
+
throw cliValidationError(
|
|
5379
|
+
"Pass --device-key with the exact key from ai-bucket inspect or identity inspect.",
|
|
5380
|
+
);
|
|
5381
|
+
}
|
|
5382
|
+
if (!options.adminDryRun && !note) {
|
|
5383
|
+
throw cliValidationError("Say why with --note <reason> for a device rule.");
|
|
5384
|
+
}
|
|
5385
|
+
return withNote({
|
|
5386
|
+
type,
|
|
5387
|
+
sourceUserId: parseRequiredInteger(options.adminUser, "--user", 1),
|
|
5388
|
+
deviceKey: options.adminDeviceKey,
|
|
5389
|
+
scope: scope(),
|
|
5390
|
+
...(options.adminUserIds || !options.adminDryRun
|
|
5391
|
+
? { expectedUserIds: parseAiBucketUserIds(options.adminUserIds) }
|
|
5392
|
+
: {}),
|
|
5393
|
+
});
|
|
5394
|
+
}
|
|
5395
|
+
throw cliValidationError("Pass --type user, email, ip or device.");
|
|
5396
|
+
}
|
|
5397
|
+
|
|
5088
5398
|
function readOperation(name, path, extra = {}) {
|
|
5089
5399
|
return {
|
|
5090
5400
|
name,
|
|
@@ -5769,6 +6079,10 @@ function printRewardReviewResult({ operation, data }) {
|
|
|
5769
6079
|
);
|
|
5770
6080
|
for (const file of review.proposal.changedFiles || [])
|
|
5771
6081
|
console.log(` ${file.status}: ${file.path}`);
|
|
6082
|
+
if (review.proposal.title)
|
|
6083
|
+
console.log(` new name: ${review.proposal.title}`);
|
|
6084
|
+
if (review.proposal.thumbnailUrl)
|
|
6085
|
+
console.log(` new thumbnail: ${review.proposal.thumbnailUrl}`);
|
|
5772
6086
|
}
|
|
5773
6087
|
if (review.status === "rejected" && review.declinedByCreator) {
|
|
5774
6088
|
console.log(
|
|
@@ -6846,6 +7160,174 @@ function printRewardBankReport(data) {
|
|
|
6846
7160
|
}
|
|
6847
7161
|
}
|
|
6848
7162
|
|
|
7163
|
+
function aiBucketRestrictionLabel(restriction) {
|
|
7164
|
+
const mode = restriction?.mode || "none";
|
|
7165
|
+
const label = mode === "none" ? "unrestricted" : mode === "signup" ? "signup-only" : "full";
|
|
7166
|
+
return restriction?.message ? `${label} — "${restriction.message}"` : label;
|
|
7167
|
+
}
|
|
7168
|
+
|
|
7169
|
+
function formatAiBucketAccounts(accounts) {
|
|
7170
|
+
return (accounts || []).length
|
|
7171
|
+
? accounts.map((a) => `${a.username || "?"} (#${a.userId})`).join(", ")
|
|
7172
|
+
: "none";
|
|
7173
|
+
}
|
|
7174
|
+
|
|
7175
|
+
function printAiBucketNetworkRuleChanges(changes) {
|
|
7176
|
+
if (!changes?.length) return;
|
|
7177
|
+
console.log("Network/device rules whose restriction changes:");
|
|
7178
|
+
for (const c of changes) {
|
|
7179
|
+
const r = c.reach;
|
|
7180
|
+
const reach = !c.strengthens
|
|
7181
|
+
? "loosens"
|
|
7182
|
+
: !r?.measured
|
|
7183
|
+
? "reach NOT MEASURED"
|
|
7184
|
+
: `${r.accountCount}${r.lowerBound ? "+" : ""} account(s)${r.sharedNetwork ? " — SHARED NETWORK" : ""}`;
|
|
7185
|
+
console.log(` #${c.ruleId} ${c.kind} ${c.match}: ${c.from} → ${c.to} · ${reach}`);
|
|
7186
|
+
}
|
|
7187
|
+
}
|
|
7188
|
+
|
|
7189
|
+
function printAiBucketBlockers(blockers, overrideGiven) {
|
|
7190
|
+
for (const blocker of blockers || []) {
|
|
7191
|
+
if (blocker.overridable && overrideGiven) {
|
|
7192
|
+
console.log(`OVERRIDDEN by --allow-shared-network (${blocker.code}): ${blocker.message}`);
|
|
7193
|
+
} else {
|
|
7194
|
+
console.log(`BLOCKED (${blocker.code}): ${blocker.message}${blocker.overridable ? " Only --allow-shared-network, on Mikey's explicit word, lifts this." : ""}`);
|
|
7195
|
+
}
|
|
7196
|
+
}
|
|
7197
|
+
}
|
|
7198
|
+
|
|
7199
|
+
function printAiBucketRules(rules, indent = " ") {
|
|
7200
|
+
for (const rule of rules || []) {
|
|
7201
|
+
console.log(
|
|
7202
|
+
`${indent}#${rule.id} ${rule.kind} ${rule.match} · scope ${rule.scope} · effective ${rule.effectiveRestriction}${rule.note ? ` · ${rule.note}` : ""}`,
|
|
7203
|
+
);
|
|
7204
|
+
}
|
|
7205
|
+
}
|
|
7206
|
+
|
|
7207
|
+
// The bucket commands of todo 79 (inspect, restrict, rule add/disable and
|
|
7208
|
+
// their --dry-run previews). Returns false for the older ai-bucket commands.
|
|
7209
|
+
function printAiBucketRuleResult({ operation, data }) {
|
|
7210
|
+
const name = operation.name;
|
|
7211
|
+
const head = (bucket) =>
|
|
7212
|
+
`AI bucket #${bucket.id} (${bucket.label}): ${aiBucketRestrictionLabel(bucket.restriction)}`;
|
|
7213
|
+
if (name === "ai-bucket.inspect") {
|
|
7214
|
+
console.log(`${head(data.bucket)}; ${data.rules.length} active rule(s), ${data.memberCount} member account(s).`);
|
|
7215
|
+
if (data.bucket.note) console.log(`Note: ${data.bucket.note}`);
|
|
7216
|
+
console.log("Rules:");
|
|
7217
|
+
printAiBucketRules(data.rules);
|
|
7218
|
+
console.log(
|
|
7219
|
+
`Members: ${(data.members || []).map((m) => `${m.username || "?"} (#${m.userId}${m.viaEmailRule ? ", via email rule" : ""})`).join(", ") || "none"}`,
|
|
7220
|
+
);
|
|
7221
|
+
for (const account of data.evidence?.accounts || []) {
|
|
7222
|
+
console.log(`Evidence for ${account.username || "?"} (#${account.userId})${account.canonicalBucketId && account.canonicalBucketId !== data.bucket.id ? ` · resolves to bucket #${account.canonicalBucketId}` : ""}:`);
|
|
7223
|
+
if (!account.devices.length) console.log(" Devices: none recorded");
|
|
7224
|
+
for (const device of account.devices) {
|
|
7225
|
+
const rule = device.ruleId ? ` · rule #${device.ruleId}` : "";
|
|
7226
|
+
if (device.sharedDevice) {
|
|
7227
|
+
console.log(` Device ${device.key}: shared by ${device.accountCount} accounts (not listed; not family evidence)${rule}`);
|
|
7228
|
+
continue;
|
|
7229
|
+
}
|
|
7230
|
+
const others = device.otherAccounts.length
|
|
7231
|
+
? device.otherAccounts.map((a) => `${a.username || "?"} (#${a.userId})${a.inBucket ? "" : " OUTSIDE bucket"}`).join(", ")
|
|
7232
|
+
: "no other account";
|
|
7233
|
+
console.log(` Device ${device.key}: ${device.accountCount} account(s); others: ${others}${device.accountsTruncated ? " (more not listed)" : ""}${device.status !== "complete" ? ` · ${device.status}` : ""}${rule}`);
|
|
7234
|
+
}
|
|
7235
|
+
printNetwork(account.network, " ");
|
|
7236
|
+
const ruled = (account.network?.addresses || []).filter((a) => a.ruleId);
|
|
7237
|
+
if (ruled.length) {
|
|
7238
|
+
console.log(` IP rules matching these addresses: ${ruled.map((a) => `#${a.ruleId} [${a.addressId}]`).join(", ")}`);
|
|
7239
|
+
}
|
|
7240
|
+
}
|
|
7241
|
+
if (data.evidence?.membersNotInspected) {
|
|
7242
|
+
console.log(`Evidence covers the first ${data.evidence.membersInspected} members; ${data.evidence.membersNotInspected} not inspected.`);
|
|
7243
|
+
}
|
|
7244
|
+
console.log(data.interpretation);
|
|
7245
|
+
return true;
|
|
7246
|
+
}
|
|
7247
|
+
if (name === "ai-bucket.restrict.preview") {
|
|
7248
|
+
const p = data.preview;
|
|
7249
|
+
console.log(`DRY RUN ${head(data.bucket)} → ${aiBucketRestrictionLabel(p.to)}${p.changed ? "" : " (no change)"}.`);
|
|
7250
|
+
console.log(`Accounts it reaches: ${formatAiBucketAccounts(p.memberAccounts)}`);
|
|
7251
|
+
console.log(`Sessions dropped: ${p.sessionsKickedUserIds.length ? p.sessionsKickedUserIds.map((id) => `#${id}`).join(", ") : "none"}; existing logins ${p.existingLoginsPreserved ? "kept" : "ended"}; existing bans kept.`);
|
|
7252
|
+
if (p.fullBlockLiftedUserIds.length) console.log(`Full block lifted for: ${p.fullBlockLiftedUserIds.map((id) => `#${id}`).join(", ")} (unless another restriction still applies).`);
|
|
7253
|
+
if (p.adminConflictUserIds.length) console.log(`BLOCKED: admin account(s) ${p.adminConflictUserIds.map((id) => `#${id}`).join(", ")} cannot be fully restricted.`);
|
|
7254
|
+
printAiBucketNetworkRuleChanges(p.networkRuleChanges);
|
|
7255
|
+
printAiBucketBlockers(p.blockers, data.sharedNetworkOverride);
|
|
7256
|
+
console.log("Nothing was changed. Run again with --confirm to apply.");
|
|
7257
|
+
return true;
|
|
7258
|
+
}
|
|
7259
|
+
if (name === "ai-bucket.restrict") {
|
|
7260
|
+
console.log(`${head(data.bucket)} (was ${aiBucketRestrictionLabel(data.previous)}); verified.`);
|
|
7261
|
+
printAiBucketNetworkRuleChanges(data.networkRuleChanges);
|
|
7262
|
+
if (data.sharedNetworkOverride) console.log("OVERRIDE USED: --allow-shared-network — a shared network or unmeasured rule is now restricted.");
|
|
7263
|
+
console.log(`Sessions dropped: ${data.kickedUserIds.length ? data.kickedUserIds.map((id) => `#${id}`).join(", ") : "none"}; existing logins ${data.existingLoginsPreserved ? "kept" : "ended"}; existing bans kept.`);
|
|
7264
|
+
return true;
|
|
7265
|
+
}
|
|
7266
|
+
if (name === "ai-bucket.rule.add.preview") {
|
|
7267
|
+
console.log(`DRY RUN ${head(data.bucket)} (the real run needs --confirm).`);
|
|
7268
|
+
if (data.device) {
|
|
7269
|
+
const d = data.device;
|
|
7270
|
+
if (d.sharedDevice) {
|
|
7271
|
+
console.log(`Shared device: ${d.observedAccountCount} accounts (${d.outsideBucketCount} outside this bucket); not listed, not family evidence.`);
|
|
7272
|
+
} else {
|
|
7273
|
+
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}.`);
|
|
7274
|
+
if (d.outsideBucketUserIds.length) console.log(`Outside this bucket: ${d.outsideBucketUserIds.map((id) => `#${id}`).join(", ")}`);
|
|
7275
|
+
}
|
|
7276
|
+
if (d.existingRuleId) console.log(`Existing rule #${d.existingRuleId} in bucket #${d.existingRuleBucketId}.`);
|
|
7277
|
+
if (!(data.blockers || []).length) console.log(`Pass the reviewed accounts with --user-ids ${d.observedUserIds.join(",")} on the real run.`);
|
|
7278
|
+
} else {
|
|
7279
|
+
const p = data.preview;
|
|
7280
|
+
const r = p.rule;
|
|
7281
|
+
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}.`);
|
|
7282
|
+
if (p.existingRule) console.log(`Existing rule #${p.existingRule.ruleId} in bucket #${p.existingRule.bucketId} (${p.existingRule.bucketLabel}).`);
|
|
7283
|
+
if (p.affectedAccounts) console.log(`Accounts it names: ${formatAiBucketAccounts(p.affectedAccounts)}`);
|
|
7284
|
+
if (p.exactAddress) {
|
|
7285
|
+
const a = p.exactAddress;
|
|
7286
|
+
console.log(a.kind === "shared_network"
|
|
7287
|
+
? `Address [${a.addressId}]: shared network, ${a.accountCount} accounts (not listed; not family evidence).`
|
|
7288
|
+
: `Address [${a.addressId}]: ${a.accountCount} account(s): ${formatAiBucketAccounts(a.accounts)}${a.olderHistoryNotRead ? " · older history not read" : ""}`);
|
|
7289
|
+
}
|
|
7290
|
+
if (p.prefixReach) {
|
|
7291
|
+
const r = p.prefixReach;
|
|
7292
|
+
console.log(r.measured
|
|
7293
|
+
? `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).`
|
|
7294
|
+
: "Prefix: reach could not be measured.");
|
|
7295
|
+
}
|
|
7296
|
+
for (const existing of p.existingPrefixRules || []) {
|
|
7297
|
+
if (existing) console.log(`Existing prefix rule #${existing.ruleId} in bucket #${existing.bucketId} (${existing.scope}).`);
|
|
7298
|
+
}
|
|
7299
|
+
if (p.sessionsKickedUserIds.length) console.log(`Sessions dropped: ${p.sessionsKickedUserIds.map((id) => `#${id}`).join(", ")}`);
|
|
7300
|
+
}
|
|
7301
|
+
printAiBucketBlockers(data.blockers || data.preview?.blockers || [], data.sharedNetworkOverride);
|
|
7302
|
+
console.log(`Would change: ${data.wouldChange ? "yes" : "no"}. Nothing was changed.`);
|
|
7303
|
+
return true;
|
|
7304
|
+
}
|
|
7305
|
+
if (name === "ai-bucket.rule.add") {
|
|
7306
|
+
console.log(`${head(data.bucket)}; rule saved; ${data.ruleCount} active rule(s), ${data.memberCount} member(s); verified.`);
|
|
7307
|
+
printAiBucketRules(data.addedRules);
|
|
7308
|
+
if (data.sharedNetworkOverride) console.log("OVERRIDE USED: --allow-shared-network — this rule reaches a shared network.");
|
|
7309
|
+
if (data.kickedUserIds?.length) console.log(`Sessions dropped: ${data.kickedUserIds.map((id) => `#${id}`).join(", ")}`);
|
|
7310
|
+
return true;
|
|
7311
|
+
}
|
|
7312
|
+
if (name === "ai-bucket.rule.disable.preview") {
|
|
7313
|
+
const p = data.preview;
|
|
7314
|
+
console.log(`DRY RUN ${head(data.bucket)} (the real run needs --confirm).`);
|
|
7315
|
+
printAiBucketRules([p.rule]);
|
|
7316
|
+
console.log(p.alreadyDisabled ? "Already disabled." : `Released accounts: ${formatAiBucketAccounts(p.releasedAccounts)}${p.liftsRestriction ? " (each keeps any other restriction that still applies)" : ""}.`);
|
|
7317
|
+
if (p.prefixRulesRemainActive) {
|
|
7318
|
+
console.log(`${p.prefixNote}${p.companionPrefixRuleIds?.length ? ` Its prefix rules here: ${p.companionPrefixRuleIds.map((id) => `#${id}`).join(", ")}.` : ""}`);
|
|
7319
|
+
}
|
|
7320
|
+
console.log(`Would change: ${data.wouldChange ? "yes" : "no"}. Nothing was changed.`);
|
|
7321
|
+
return true;
|
|
7322
|
+
}
|
|
7323
|
+
if (name === "ai-bucket.rule.disable") {
|
|
7324
|
+
console.log(`${head(data.bucket)}; rule #${data.disabledRule.id} disabled; ${data.ruleCount} active rule(s), ${data.memberCount} member(s); verified.`);
|
|
7325
|
+
console.log(`Released accounts: ${formatAiBucketAccounts(data.releasedAccounts)}`);
|
|
7326
|
+
return true;
|
|
7327
|
+
}
|
|
7328
|
+
return false;
|
|
7329
|
+
}
|
|
7330
|
+
|
|
6849
7331
|
function printAdminResult({ operation, result }) {
|
|
6850
7332
|
const data = result?.data || {};
|
|
6851
7333
|
if (printChatSafetyResult({ operation, data })) return;
|
|
@@ -6858,6 +7340,9 @@ function printAdminResult({ operation, result }) {
|
|
|
6858
7340
|
for (const line of formatProfileReview(data)) console.log(line);
|
|
6859
7341
|
return;
|
|
6860
7342
|
}
|
|
7343
|
+
if (operation.name.startsWith("ai-bucket.") && printAiBucketRuleResult({ operation, data })) {
|
|
7344
|
+
return;
|
|
7345
|
+
}
|
|
6861
7346
|
if (operation.name === "reward-activity.report") {
|
|
6862
7347
|
printRewardActivity(data);
|
|
6863
7348
|
return;
|
|
@@ -6874,6 +7359,13 @@ function printAdminResult({ operation, result }) {
|
|
|
6874
7359
|
printRoles(data);
|
|
6875
7360
|
return;
|
|
6876
7361
|
}
|
|
7362
|
+
if (operation.name.startsWith("craft.")) {
|
|
7363
|
+
const lines = formatCraftResult({ operation, data });
|
|
7364
|
+
if (lines) {
|
|
7365
|
+
for (const line of lines) console.log(line);
|
|
7366
|
+
return;
|
|
7367
|
+
}
|
|
7368
|
+
}
|
|
6877
7369
|
if (operation.name.startsWith("reward-bank.")) {
|
|
6878
7370
|
if (operation.name === "reward-bank.seed") {
|
|
6879
7371
|
console.log(
|