u-chan-secrets 5.0.0 → 5.1.0
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/README.md +233 -91
- package/dist/cli.js +115 -19
- package/dist/client/claude-setup.js +97 -0
- package/dist/client/codex-setup.js +6 -5
- package/dist/client/discord.js +67 -0
- package/dist/client/github.js +28 -0
- package/dist/client/setup.js +40 -6
- package/dist/client/update-package.js +166 -0
- package/dist/client/update.js +138 -0
- package/dist/client/windows-command.js +54 -0
- package/dist/core/browser.js +0 -5
- package/dist/core/config.js +1 -0
- package/dist/core/doctor.js +5 -1
- package/dist/core/vault.js +110 -5
- package/dist/discord/http.js +84 -0
- package/dist/discord/policy.js +47 -0
- package/dist/discord/service.js +349 -0
- package/dist/discord/store.js +85 -0
- package/dist/github/http.js +93 -0
- package/dist/github/policy.js +24 -0
- package/dist/github/service.js +185 -0
- package/dist/github/store.js +54 -0
- package/dist/index.js +1 -1
- package/dist/ipc/client.js +74 -6
- package/dist/ipc/server.js +38 -3
- package/dist/mcp/server.js +25 -2
- package/dist/platform/windows/assets/prompt-src.js +160 -38
- package/dist/platform/windows/doctor.js +2 -2
- package/dist/platform/windows/install.js +4 -0
- package/dist/service/control.js +47 -5
- package/dist/service/doctor.js +3 -0
- package/dist/service/main.js +34 -17
- package/dist/service/manage.js +20 -16
- package/dist/service/state.js +28 -0
- package/dist/service/update/coordinator.js +171 -0
- package/dist/service/update/files.js +47 -0
- package/dist/service/update/gate.js +21 -0
- package/dist/service/update/journal.js +157 -0
- package/dist/service/update/native.js +50 -0
- package/dist/service/update/network.js +28 -0
- package/dist/service/update/protocol.js +28 -0
- package/dist/service/update/release.js +102 -0
- package/dist/service/update/worker-main.js +116 -0
- package/dist/service/update/worker.js +58 -0
- package/package.json +1 -1
- package/scripts/debug-install.bat +69 -69
- package/scripts/desktop-browser.ps1 +110 -103
- package/scripts/login-bw.bat +21 -21
- package/scripts/migrate.bat +4 -4
- package/scripts/start-browser.ps1 +33 -33
- package/scripts/windows-security/desktop-matrix.mjs +54 -54
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { abortable } from '../core/abort.js';
|
|
2
|
+
export class DiscordHttpError extends Error {
|
|
3
|
+
outcome;
|
|
4
|
+
retryAfter;
|
|
5
|
+
constructor(message, outcome, retryAfter) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.outcome = outcome;
|
|
8
|
+
this.retryAfter = retryAfter;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
async function bodyJson(response, signal) {
|
|
12
|
+
let bytes = 0;
|
|
13
|
+
const chunks = [];
|
|
14
|
+
const reader = response.body?.getReader();
|
|
15
|
+
try {
|
|
16
|
+
if (!reader || Number(response.headers.get('content-length')) > 1024 * 1024)
|
|
17
|
+
throw new Error();
|
|
18
|
+
for (;;) {
|
|
19
|
+
const { done, value } = await abortable(reader.read(), signal);
|
|
20
|
+
if (done)
|
|
21
|
+
break;
|
|
22
|
+
bytes += value.byteLength;
|
|
23
|
+
if (bytes > 1024 * 1024)
|
|
24
|
+
throw new Error();
|
|
25
|
+
chunks.push(value);
|
|
26
|
+
}
|
|
27
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
28
|
+
}
|
|
29
|
+
finally {
|
|
30
|
+
await reader?.cancel().catch(() => { });
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export class DiscordHttp {
|
|
34
|
+
fetcher;
|
|
35
|
+
blockedUntil = 0;
|
|
36
|
+
constructor(fetcher = fetch) {
|
|
37
|
+
this.fetcher = fetcher;
|
|
38
|
+
}
|
|
39
|
+
async request(token, method, path, body, signal) {
|
|
40
|
+
if (!/^\/(guilds|channels|users)\/[A-Za-z0-9/@_-]+$/.test(path))
|
|
41
|
+
throw new DiscordHttpError('invalid Discord route', 'failed');
|
|
42
|
+
signal.throwIfAborted();
|
|
43
|
+
if (Date.now() < this.blockedUntil)
|
|
44
|
+
throw new DiscordHttpError('Discord rate limited; retry later', 'failed', Math.ceil((this.blockedUntil - Date.now()) / 1000));
|
|
45
|
+
const write = method !== 'GET';
|
|
46
|
+
let response;
|
|
47
|
+
try {
|
|
48
|
+
response = await abortable(this.fetcher(`https://discord.com/api/v10${path}`, {
|
|
49
|
+
method, redirect: 'error', signal,
|
|
50
|
+
headers: { Authorization: `Bot ${token}`, 'Content-Type': 'application/json', 'User-Agent': 'U-Chan-Secrets (https://github.com/anchuwithdeadline/u-chan-secrets)' },
|
|
51
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
52
|
+
}), signal);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
throw new DiscordHttpError(write ? 'Discord write outcome unknown; inspect before retrying' : 'Discord request failed', write ? 'unknown' : 'failed');
|
|
56
|
+
}
|
|
57
|
+
const delay = (v) => (typeof v === 'string' || typeof v === 'number') && Number.isFinite(Number(v)) && Number(v) > 0 ? Number(v) : 0;
|
|
58
|
+
if (response.status === 429) {
|
|
59
|
+
// Never replay a write automatically, including a rate-limited write.
|
|
60
|
+
let body;
|
|
61
|
+
try {
|
|
62
|
+
body = await bodyJson(response, signal);
|
|
63
|
+
}
|
|
64
|
+
catch { }
|
|
65
|
+
const retry = Math.max(delay(response.headers.get('retry-after')), delay(response.headers.get('x-ratelimit-reset-after')), body && typeof body === 'object' ? delay(body.retry_after) : 0) || 60;
|
|
66
|
+
this.blockedUntil = Date.now() + retry * 1000;
|
|
67
|
+
throw new DiscordHttpError('Discord rate limited; retry later', 'failed', retry);
|
|
68
|
+
}
|
|
69
|
+
if (response.headers.get('x-ratelimit-remaining') === '0')
|
|
70
|
+
this.blockedUntil = Date.now() + (delay(response.headers.get('x-ratelimit-reset-after')) || 1) * 1000;
|
|
71
|
+
if (!response.ok) {
|
|
72
|
+
await response.body?.cancel().catch(() => { });
|
|
73
|
+
throw new DiscordHttpError(write && response.status >= 500 ? 'Discord write outcome unknown; inspect before retrying' : `Discord request rejected (HTTP ${response.status})`, write && response.status >= 500 ? 'unknown' : 'failed');
|
|
74
|
+
}
|
|
75
|
+
if (response.status === 204)
|
|
76
|
+
return null;
|
|
77
|
+
try {
|
|
78
|
+
return await bodyJson(response, signal);
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
throw new DiscordHttpError(write ? 'Discord write outcome unknown; inspect before retrying' : 'Discord response unavailable or too large', write ? 'unknown' : 'failed');
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export const snowflake = z.string().refine(v => /^[1-9][0-9]{0,19}$/.test(v) && BigInt(v) <= 18446744073709551615n);
|
|
3
|
+
const text = (max) => z.string().min(1).max(max).refine(v => !/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u.test(v));
|
|
4
|
+
export const aliasSchema = z.string().regex(/^[a-z][a-z0-9_-]{0,39}$/);
|
|
5
|
+
export const tokenFieldSchema = z.discriminatedUnion('kind', [z.strictObject({ kind: z.literal('password') }), z.strictObject({ kind: z.literal('hidden'), name: text(100) })]);
|
|
6
|
+
export const connectionSchema = z.strictObject({ alias: aliasSchema, item: text(200).refine(v => !v.startsWith('-') && v.trim() === v), field: tokenFieldSchema, guildId: snowflake });
|
|
7
|
+
/** Discovery requests omit both unknown fields; persisted connections always require exact resolved values. */
|
|
8
|
+
export const connectionRequestSchema = z.union([connectionSchema, connectionSchema.omit({ field: true, guildId: true })]);
|
|
9
|
+
// Deliberately supported permission subset. Unknown/new Discord bits require a reviewed upgrade.
|
|
10
|
+
export const PERMISSIONS = ['CREATE_INSTANT_INVITE', 'KICK_MEMBERS', 'BAN_MEMBERS', 'ADMINISTRATOR', 'MANAGE_CHANNELS', 'MANAGE_GUILD', 'ADD_REACTIONS', 'VIEW_AUDIT_LOG', 'PRIORITY_SPEAKER', 'STREAM', 'VIEW_CHANNEL', 'SEND_MESSAGES', 'SEND_TTS_MESSAGES', 'MANAGE_MESSAGES', 'EMBED_LINKS', 'ATTACH_FILES', 'READ_MESSAGE_HISTORY', 'MENTION_EVERYONE', 'USE_EXTERNAL_EMOJIS', 'VIEW_GUILD_INSIGHTS', 'CONNECT', 'SPEAK', 'MUTE_MEMBERS', 'DEAFEN_MEMBERS', 'MOVE_MEMBERS', 'USE_VAD', 'CHANGE_NICKNAME', 'MANAGE_NICKNAMES', 'MANAGE_ROLES', 'MANAGE_WEBHOOKS', 'MANAGE_GUILD_EXPRESSIONS', 'USE_APPLICATION_COMMANDS', 'REQUEST_TO_SPEAK', 'MANAGE_EVENTS', 'MANAGE_THREADS', 'CREATE_PUBLIC_THREADS', 'CREATE_PRIVATE_THREADS', 'USE_EXTERNAL_STICKERS', 'SEND_MESSAGES_IN_THREADS', 'USE_EMBEDDED_ACTIVITIES', 'MODERATE_MEMBERS'];
|
|
11
|
+
export const SUPPORTED_PERMISSION_MASK = (1n << BigInt(PERMISSIONS.length)) - 1n;
|
|
12
|
+
export function permissionNames(bits) {
|
|
13
|
+
const known = PERMISSIONS.filter((_, i) => (BigInt(bits) & (1n << BigInt(i))) !== 0n);
|
|
14
|
+
const unknown = BigInt(bits) & ~SUPPORTED_PERMISSION_MASK;
|
|
15
|
+
return unknown ? [...known, `UNSUPPORTED_BITS(${unknown})`] : known;
|
|
16
|
+
}
|
|
17
|
+
const validPermissions = (v) => /^(0|[1-9][0-9]{0,12})$/.test(v) && BigInt(v) < (1n << BigInt(PERMISSIONS.length)) && (BigInt(v) & 8n) === 0n;
|
|
18
|
+
const permissions = z.string().refine(validPermissions);
|
|
19
|
+
const roleFields = { name: text(100), permissions, color: z.number().int().min(0).max(0xffffff), hoist: z.boolean(), mentionable: z.boolean() };
|
|
20
|
+
const overwrite = z.strictObject({ id: snowflake, type: z.union([z.literal(0), z.literal(1)]), allow: permissions, deny: permissions }).refine(o => validPermissions(o.allow) && validPermissions(o.deny) && (BigInt(o.allow) & BigInt(o.deny)) === 0n);
|
|
21
|
+
const channelFields = { name: text(100), parentId: snowflake.nullable(), topic: text(1024).or(z.literal('')), overwrites: z.array(overwrite).max(20).refine(v => new Set(v.map(o => o.id)).size === v.length) };
|
|
22
|
+
const position = z.strictObject({ id: snowflake, position: z.number().int().min(0).max(500) });
|
|
23
|
+
const positions = z.array(position).min(1).max(30).refine(v => new Set(v.map(p => p.id)).size === v.length);
|
|
24
|
+
export const operationSchema = z.discriminatedUnion('type', [
|
|
25
|
+
z.strictObject({ type: z.literal('role_create'), ...roleFields, permissions: permissions.optional(), color: roleFields.color.optional(), hoist: z.boolean().optional(), mentionable: z.boolean().optional() }),
|
|
26
|
+
z.strictObject({ type: z.literal('role_edit'), roleId: snowflake, changes: z.strictObject(roleFields).partial().refine(v => Object.keys(v).length > 0) }),
|
|
27
|
+
z.strictObject({ type: z.literal('role_reorder'), positions }),
|
|
28
|
+
z.strictObject({ type: z.literal('channel_create'), kind: z.enum(['category', 'text', 'voice']), ...channelFields, parentId: snowflake.nullable().optional(), topic: channelFields.topic.optional(), overwrites: channelFields.overwrites.optional() }),
|
|
29
|
+
z.strictObject({ type: z.literal('channel_edit'), channelId: snowflake, changes: z.strictObject(channelFields).partial().refine(v => Object.keys(v).length > 0) }),
|
|
30
|
+
z.strictObject({ type: z.literal('channel_reorder'), positions }),
|
|
31
|
+
z.strictObject({ type: z.enum(['member_role_add', 'member_role_remove']), memberId: snowflake, roleId: snowflake }),
|
|
32
|
+
]);
|
|
33
|
+
function independentTargets(operations) {
|
|
34
|
+
const targets = operations.map(op => {
|
|
35
|
+
if ('memberId' in op)
|
|
36
|
+
return { writes: ['member:' + op.memberId], reads: ['role:' + op.roleId] };
|
|
37
|
+
if ('roleId' in op)
|
|
38
|
+
return { writes: ['role:' + op.roleId], reads: [] };
|
|
39
|
+
if ('channelId' in op)
|
|
40
|
+
return { writes: ['channel:' + op.channelId], reads: [] };
|
|
41
|
+
if ('positions' in op)
|
|
42
|
+
return { writes: op.positions.map(p => (op.type === 'role_reorder' ? 'role:' : 'channel:') + p.id), reads: [] };
|
|
43
|
+
return { writes: [], reads: [] };
|
|
44
|
+
});
|
|
45
|
+
return targets.every((a, i) => targets.every((b, j) => i === j || !a.writes.some(id => b.writes.includes(id) || b.reads.includes(id))));
|
|
46
|
+
}
|
|
47
|
+
export const batchSchema = z.array(operationSchema).min(1).max(10).refine(v => Buffer.byteLength(JSON.stringify(v)) <= 8192 && independentTargets(v));
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { abortable } from '../core/abort.js';
|
|
3
|
+
import { UserFacingError } from '../core/types.js';
|
|
4
|
+
import { DiscordHttpError } from './http.js';
|
|
5
|
+
import { aliasSchema, batchSchema, connectionSchema, connectionRequestSchema, permissionNames, snowflake, SUPPORTED_PERMISSION_MASK } from './policy.js';
|
|
6
|
+
const bits = z.string().regex(/^(0|[1-9][0-9]{0,19})$/);
|
|
7
|
+
const roleSchema = z.object({ id: snowflake, name: z.string().max(100), permissions: bits, position: z.number().int().min(0), managed: z.boolean(), color: z.number().optional(), hoist: z.boolean().optional(), mentionable: z.boolean().optional() });
|
|
8
|
+
const overwriteSchema = z.object({ id: snowflake, type: z.union([z.literal(0), z.literal(1)]), allow: bits, deny: bits });
|
|
9
|
+
const channelSchema = z.object({ id: snowflake, guild_id: snowflake.optional(), name: z.string().max(100), type: z.number().int(), parent_id: snowflake.nullable().optional(), topic: z.string().max(4096).nullable().optional(), position: z.number().optional(), permission_overwrites: z.array(overwriteSchema).max(100).optional() });
|
|
10
|
+
const memberSchema = z.object({ user: z.object({ id: snowflake }), roles: z.array(snowflake).max(1000) });
|
|
11
|
+
class Refused extends Error {
|
|
12
|
+
}
|
|
13
|
+
const refuse = (message) => { throw new Refused(message); };
|
|
14
|
+
const safeJson = (value) => JSON.stringify(value).replace(/[\p{Cf}\p{Zl}\p{Zp}]/gu, c => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`);
|
|
15
|
+
const permissionsView = (value) => ({ bits: value, names: permissionNames(value) });
|
|
16
|
+
/** Fixed-purpose broker. Credentials never cross this privileged module's boundary. */
|
|
17
|
+
export class DiscordService {
|
|
18
|
+
d;
|
|
19
|
+
constructor(d) {
|
|
20
|
+
this.d = d;
|
|
21
|
+
}
|
|
22
|
+
run(work) {
|
|
23
|
+
return this.d.queue(async () => {
|
|
24
|
+
if (this.d.platform !== 'win32')
|
|
25
|
+
return { ok: false, message: 'Discord broker supports Windows only; complete protected review is required' };
|
|
26
|
+
const secrets = [];
|
|
27
|
+
try {
|
|
28
|
+
const result = await work(secrets);
|
|
29
|
+
const scrub = (s) => secrets.reduce((v, secret) => secret ? v.split(secret).join('[REDACTED]') : v, s);
|
|
30
|
+
try {
|
|
31
|
+
result.message = safeJson(JSON.parse(JSON.stringify(JSON.parse(result.message), (_key, value) => typeof value === 'string' ? scrub(value) : value)));
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
result.message = scrub(result.message);
|
|
35
|
+
}
|
|
36
|
+
if (Buffer.byteLength(result.message) > 60000)
|
|
37
|
+
return { ok: false, message: 'Discord result exceeds the safe response limit; request a smaller metadata page' };
|
|
38
|
+
return result;
|
|
39
|
+
}
|
|
40
|
+
catch (e) {
|
|
41
|
+
const message = e instanceof Refused || e instanceof DiscordHttpError ? e.message : 'Discord operation failed; check vault, policy, and service diagnostics';
|
|
42
|
+
return { ok: false, message: secrets.reduce((s, secret) => secret ? s.split(secret).join('[REDACTED]') : s, message) };
|
|
43
|
+
}
|
|
44
|
+
}).catch(() => ({ ok: false, message: 'Discord service unavailable' }));
|
|
45
|
+
}
|
|
46
|
+
async audit(action, connection, result) {
|
|
47
|
+
await this.d.audit.record({ action, domain: `discord:${connection.alias}:${connection.guildId}`, result });
|
|
48
|
+
}
|
|
49
|
+
discordRequestAllow(proposal) {
|
|
50
|
+
const parsed = connectionRequestSchema.safeParse(proposal);
|
|
51
|
+
if (!parsed.success)
|
|
52
|
+
return Promise.resolve({ ok: false, message: 'invalid Discord connection policy' });
|
|
53
|
+
const request = parsed.data;
|
|
54
|
+
return this.run(async () => {
|
|
55
|
+
let c;
|
|
56
|
+
if ('guildId' in request)
|
|
57
|
+
c = request;
|
|
58
|
+
else {
|
|
59
|
+
const target = { alias: request.alias, guildId: 'pending' };
|
|
60
|
+
const approved = await this.d.approver.ask('U-Chan Secrets - Discord vault discovery', [
|
|
61
|
+
'Read setup metadata from this exact Bitwarden item?', `Connection: ${safeJson(request.alias)}`, `Bitwarden item: ${safeJson(request.item)}`,
|
|
62
|
+
'Extract only a labeled server ID and a password/hidden-token field selector. Token values and raw notes stay inside the service.',
|
|
63
|
+
'This does not create a Discord grant or send a Discord request. A second protected prompt must approve the exact server and token field.',
|
|
64
|
+
]);
|
|
65
|
+
await this.audit('discord_allow', target, approved ? 'discovery approved' : 'discovery denied');
|
|
66
|
+
if (!approved)
|
|
67
|
+
return { ok: false, message: 'denied' };
|
|
68
|
+
if (!this.d.vault.getDiscordSetup)
|
|
69
|
+
return refuse('service does not support protected Discord metadata discovery');
|
|
70
|
+
const signal = AbortSignal.timeout(30000);
|
|
71
|
+
if (await abortable(this.d.vault.status(signal), signal) !== 'unlocked')
|
|
72
|
+
return refuse('vault locked or unavailable; run u-chan unlock');
|
|
73
|
+
try {
|
|
74
|
+
const setup = await abortable(this.d.vault.getDiscordSetup(request.item, signal), signal);
|
|
75
|
+
c = connectionSchema.parse({ alias: request.alias, item: setup.item, guildId: setup.guildId, field: setup.field });
|
|
76
|
+
}
|
|
77
|
+
catch (e) {
|
|
78
|
+
return refuse(e instanceof UserFacingError ? e.message : 'Discord setup metadata is unavailable or ambiguous');
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const approved = await this.d.approver.ask('U-Chan Secrets - Discord connection', [
|
|
82
|
+
'Allow server metadata reads? Writes always require a separate exact-batch approval.',
|
|
83
|
+
`Connection: ${safeJson(c.alias)}`, `Bitwarden item: ${safeJson(c.item)}`,
|
|
84
|
+
`Token field: ${safeJson(c.field)}`, `Server ID: ${c.guildId}`,
|
|
85
|
+
'Duration: permanent metadata reads until revoked. No message access.',
|
|
86
|
+
'The token stays inside the privileged service. No credential is fetched for this approval.',
|
|
87
|
+
]);
|
|
88
|
+
await this.audit('discord_allow', c, approved ? 'approved' : 'denied');
|
|
89
|
+
if (!approved)
|
|
90
|
+
return { ok: false, message: 'denied' };
|
|
91
|
+
await this.d.store.upsert(c);
|
|
92
|
+
return { ok: true, message: 'Discord connection approved; every write batch still requires approval' };
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
discordListAllowed() {
|
|
96
|
+
return this.run(async () => ({ ok: true, message: safeJson({ connections: await this.d.store.list(), scope: 'server metadata; protected approval per write batch' }) }));
|
|
97
|
+
}
|
|
98
|
+
discordRevoke(alias) {
|
|
99
|
+
return this.run(async () => {
|
|
100
|
+
const c = await this.connection(alias);
|
|
101
|
+
await this.audit('discord_revoke', c, 'intent');
|
|
102
|
+
await this.d.store.remove(alias);
|
|
103
|
+
await this.audit('discord_revoke', c, 'revoked');
|
|
104
|
+
return { ok: true, message: 'Discord connection revoked; completed changes are not undone' };
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
async connection(alias) {
|
|
108
|
+
if (!aliasSchema.safeParse(alias).success)
|
|
109
|
+
return refuse('invalid Discord alias');
|
|
110
|
+
const c = await this.d.store.get(alias);
|
|
111
|
+
if (!c)
|
|
112
|
+
return refuse('Discord connection is not approved; use discord_request_allow');
|
|
113
|
+
return c;
|
|
114
|
+
}
|
|
115
|
+
async token(c, secrets, signal) {
|
|
116
|
+
if (await abortable(this.d.vault.status(signal), signal) !== 'unlocked')
|
|
117
|
+
return refuse('vault locked or unavailable; run u-chan unlock');
|
|
118
|
+
const token = await abortable(this.d.vault.getToken(c.item, c.field, signal), signal);
|
|
119
|
+
secrets.push(token);
|
|
120
|
+
return token;
|
|
121
|
+
}
|
|
122
|
+
async snapshot(c, token, signal, memberIds = []) {
|
|
123
|
+
const get = (path) => this.d.http.request(token, 'GET', path, undefined, signal);
|
|
124
|
+
const guild = z.object({ id: snowflake, name: z.string().max(100) }).parse(await get(`/guilds/${c.guildId}`));
|
|
125
|
+
if (guild.id !== c.guildId)
|
|
126
|
+
return refuse('Discord server does not match approval');
|
|
127
|
+
const roles = z.array(roleSchema).max(1000).parse(await get(`/guilds/${c.guildId}/roles`));
|
|
128
|
+
const channels = z.array(channelSchema).max(1000).parse(await get(`/guilds/${c.guildId}/channels`));
|
|
129
|
+
if (new Set(roles.map(r => r.id)).size !== roles.length || new Set(channels.map(r => r.id)).size !== channels.length || channels.some(ch => ch.guild_id !== undefined && ch.guild_id !== c.guildId))
|
|
130
|
+
return refuse('Discord metadata does not match approved server');
|
|
131
|
+
const user = z.object({ id: snowflake }).parse(await get('/users/@me'));
|
|
132
|
+
const bot = memberSchema.parse(await get(`/guilds/${c.guildId}/members/${user.id}`));
|
|
133
|
+
if (bot.user.id !== user.id || bot.roles.some(id => !roles.some(r => r.id === id)))
|
|
134
|
+
return refuse('Discord bot membership is invalid');
|
|
135
|
+
const members = new Map([[bot.user.id, bot]]);
|
|
136
|
+
for (const id of new Set(memberIds)) {
|
|
137
|
+
if (!snowflake.safeParse(id).success)
|
|
138
|
+
return refuse('invalid member ID');
|
|
139
|
+
const member = memberSchema.parse(await get(`/guilds/${c.guildId}/members/${id}`));
|
|
140
|
+
if (member.user.id !== id)
|
|
141
|
+
return refuse('member is outside approved server');
|
|
142
|
+
members.set(id, member);
|
|
143
|
+
}
|
|
144
|
+
return { guild, roles, channels, bot, members };
|
|
145
|
+
}
|
|
146
|
+
discordInspect(alias, memberId, page = 0) {
|
|
147
|
+
return this.run(async (secrets) => {
|
|
148
|
+
if (!Number.isInteger(page) || page < 0 || page > 999 || (memberId !== undefined && !snowflake.safeParse(memberId).success))
|
|
149
|
+
return refuse('invalid metadata page or member ID');
|
|
150
|
+
const c = await this.connection(alias), signal = AbortSignal.timeout(30000), token = await this.token(c, secrets, signal);
|
|
151
|
+
const s = await this.snapshot(c, token, signal, memberId ? [memberId] : []);
|
|
152
|
+
await this.audit('discord_read', c, `ok; metadata page ${page}`);
|
|
153
|
+
const channels = s.channels.slice(page, page + 1), roles = s.roles.slice(page, page + 1);
|
|
154
|
+
return { ok: true, message: safeJson({ guild: s.guild, channels, roles, member: memberId ? s.members.get(memberId) : undefined, page, nextPage: page + 1 < Math.max(s.channels.length, s.roles.length) ? page + 1 : null, totals: { channels: s.channels.length, roles: s.roles.length } }) };
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
membersFor(operations) {
|
|
158
|
+
return operations.flatMap(op => {
|
|
159
|
+
if ('memberId' in op)
|
|
160
|
+
return [op.memberId];
|
|
161
|
+
const fields = 'changes' in op ? op.changes : op;
|
|
162
|
+
return 'overwrites' in fields ? (fields.overwrites ?? []).filter(o => o.type === 1).map(o => o.id) : [];
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
validate(s, operations) {
|
|
166
|
+
const botRoles = s.roles.filter(r => r.id === s.guild.id || s.bot.roles.includes(r.id));
|
|
167
|
+
const highest = Math.max(0, ...botRoles.map(r => r.position));
|
|
168
|
+
const botPermissions = botRoles.reduce((bits, r) => bits | BigInt(r.permissions), 0n);
|
|
169
|
+
const can = (bit) => (botPermissions & 8n) !== 0n || (botPermissions & bit) !== 0n;
|
|
170
|
+
const role = (id) => s.roles.find(r => r.id === id) ?? refuse('role is outside approved server');
|
|
171
|
+
const channel = (id) => s.channels.find(c => c.id === id) ?? refuse('channel is outside approved server');
|
|
172
|
+
const editable = (id) => {
|
|
173
|
+
const r = role(id);
|
|
174
|
+
if (r.managed || s.bot.roles.includes(id) || (id !== s.guild.id && r.position >= highest))
|
|
175
|
+
return refuse('role is managed or controls bot authority');
|
|
176
|
+
return r;
|
|
177
|
+
};
|
|
178
|
+
for (const op of operations) {
|
|
179
|
+
if (op.type.startsWith('role_') || 'memberId' in op) {
|
|
180
|
+
if (!can(1n << 28n))
|
|
181
|
+
return refuse('bot requires MANAGE_ROLES');
|
|
182
|
+
}
|
|
183
|
+
else if (!can(16n))
|
|
184
|
+
return refuse('bot requires MANAGE_CHANNELS');
|
|
185
|
+
if ('memberId' in op) {
|
|
186
|
+
const r = editable(op.roleId), m = s.members.get(op.memberId);
|
|
187
|
+
if (!m || op.memberId === s.bot.user.id || op.roleId === s.guild.id)
|
|
188
|
+
return refuse('invalid member-role target');
|
|
189
|
+
if (m.roles.some(id => role(id).position >= highest))
|
|
190
|
+
return refuse('member is above bot role hierarchy');
|
|
191
|
+
if (op.type === 'member_role_add' && (BigInt(r.permissions) & 8n) !== 0n)
|
|
192
|
+
return refuse('administrator role assignment is not supported');
|
|
193
|
+
if (op.type === 'member_role_add' && (BigInt(r.permissions) & ~SUPPORTED_PERMISSION_MASK) !== 0n)
|
|
194
|
+
return refuse('role contains unsupported permission bits; assignment is refused');
|
|
195
|
+
}
|
|
196
|
+
if (op.type === 'role_edit') {
|
|
197
|
+
editable(op.roleId);
|
|
198
|
+
if (op.roleId === s.guild.id && (op.changes.name !== undefined || op.changes.hoist !== undefined || op.changes.mentionable !== undefined || op.changes.color !== undefined))
|
|
199
|
+
return refuse('only explicit permissions changes are supported for @everyone');
|
|
200
|
+
}
|
|
201
|
+
if (op.type === 'role_reorder')
|
|
202
|
+
for (const p of op.positions) {
|
|
203
|
+
editable(p.id);
|
|
204
|
+
if (p.id === s.guild.id || p.position >= highest)
|
|
205
|
+
return refuse('role order would cross bot authority');
|
|
206
|
+
}
|
|
207
|
+
if (op.type === 'channel_reorder')
|
|
208
|
+
for (const p of op.positions)
|
|
209
|
+
channel(p.id);
|
|
210
|
+
if (op.type === 'channel_create' || op.type === 'channel_edit') {
|
|
211
|
+
const current = op.type === 'channel_edit' ? channel(op.channelId) : undefined;
|
|
212
|
+
const fields = op.type === 'channel_edit' ? op.changes : op;
|
|
213
|
+
const kind = current?.type ?? (op.type === 'channel_create' ? { text: 0, voice: 2, category: 4 }[op.kind] : -1);
|
|
214
|
+
if (![0, 2, 4].includes(kind))
|
|
215
|
+
return refuse('unsupported channel kind');
|
|
216
|
+
if (fields.topic !== undefined && kind !== 0)
|
|
217
|
+
return refuse('topic is supported only for text channels');
|
|
218
|
+
if (fields.parentId && (kind === 4 || channel(fields.parentId).type !== 4))
|
|
219
|
+
return refuse('parent must be an approved-server category');
|
|
220
|
+
if (fields.overwrites) {
|
|
221
|
+
if (!can(1n << 28n))
|
|
222
|
+
return refuse('permission overwrites require MANAGE_ROLES');
|
|
223
|
+
for (const o of fields.overwrites) {
|
|
224
|
+
if (o.type === 0)
|
|
225
|
+
role(o.id);
|
|
226
|
+
else if (!s.members.has(o.id))
|
|
227
|
+
return refuse('overwrite member is outside approved server');
|
|
228
|
+
if (s.bot.roles.includes(o.id) || o.id === s.bot.user.id)
|
|
229
|
+
return refuse('cannot change bot authority overwrites');
|
|
230
|
+
}
|
|
231
|
+
// Replacement would silently remove an existing bot overwrite, too.
|
|
232
|
+
if (current?.permission_overwrites?.some(o => s.bot.roles.includes(o.id) || o.id === s.bot.user.id))
|
|
233
|
+
return refuse('channel has bot authority overwrites; edit manually');
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
review(c, s, operations) {
|
|
239
|
+
const before = operations.map(op => {
|
|
240
|
+
if ('memberId' in op)
|
|
241
|
+
return { role: s.roles.find(r => r.id === op.roleId), member: s.members.get(op.memberId) };
|
|
242
|
+
if ('roleId' in op)
|
|
243
|
+
return s.roles.find(r => r.id === op.roleId);
|
|
244
|
+
if ('channelId' in op)
|
|
245
|
+
return s.channels.find(ch => ch.id === op.channelId);
|
|
246
|
+
if ('positions' in op)
|
|
247
|
+
return op.positions.map(p => op.type === 'role_reorder' ? s.roles.find(r => r.id === p.id) : s.channels.find(ch => ch.id === p.id));
|
|
248
|
+
return undefined;
|
|
249
|
+
});
|
|
250
|
+
const named = (value) => {
|
|
251
|
+
if (Array.isArray(value))
|
|
252
|
+
return value.map(named);
|
|
253
|
+
if (!value || typeof value !== 'object')
|
|
254
|
+
return value;
|
|
255
|
+
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, ['permissions', 'allow', 'deny'].includes(k) && typeof v === 'string' ? permissionsView(v) : named(v)]));
|
|
256
|
+
};
|
|
257
|
+
const lines = ['Approve this exact Discord write batch once?', `Connection: ${c.alias}; server: ${safeJson(s.guild.name)}; server ID: ${c.guildId}`,
|
|
258
|
+
'No deletion. No administrator grants. Completed changes are not rolled back.',
|
|
259
|
+
`Current targets: ${safeJson(named(before))}`, `Exact operations: ${safeJson(named(operations))}`,
|
|
260
|
+
'An overwrite list REPLACES existing overwrites. It may expose private channels.',
|
|
261
|
+
'The server ID also identifies @everyone. Any target or overwrite with that ID affects EVERYONE.',
|
|
262
|
+
];
|
|
263
|
+
if (Buffer.byteLength(JSON.stringify(lines)) > 12000)
|
|
264
|
+
return refuse('batch is too large for complete protected review; split into smaller batches');
|
|
265
|
+
return lines;
|
|
266
|
+
}
|
|
267
|
+
dispatch(c, op) {
|
|
268
|
+
const fields = (v) => Object.fromEntries(Object.entries(v).map(([k, val]) => [k === 'parentId' ? 'parent_id' : k === 'overwrites' ? 'permission_overwrites' : k, val]));
|
|
269
|
+
switch (op.type) {
|
|
270
|
+
case 'role_create': {
|
|
271
|
+
const { type, ...body } = op;
|
|
272
|
+
return { method: 'POST', path: `/guilds/${c.guildId}/roles`, body: { ...body, permissions: body.permissions ?? '0' } };
|
|
273
|
+
}
|
|
274
|
+
case 'role_edit': return { method: 'PATCH', path: `/guilds/${c.guildId}/roles/${op.roleId}`, body: op.changes };
|
|
275
|
+
case 'role_reorder': return { method: 'PATCH', path: `/guilds/${c.guildId}/roles`, body: op.positions };
|
|
276
|
+
case 'channel_create': {
|
|
277
|
+
const { type, kind, ...body } = op;
|
|
278
|
+
return { method: 'POST', path: `/guilds/${c.guildId}/channels`, body: { ...fields(body), type: { text: 0, voice: 2, category: 4 }[kind] } };
|
|
279
|
+
}
|
|
280
|
+
case 'channel_edit': return { method: 'PATCH', path: `/channels/${op.channelId}`, body: fields(op.changes) };
|
|
281
|
+
case 'channel_reorder': return { method: 'PATCH', path: `/guilds/${c.guildId}/channels`, body: op.positions };
|
|
282
|
+
case 'member_role_add':
|
|
283
|
+
case 'member_role_remove': return { method: op.type === 'member_role_add' ? 'PUT' : 'DELETE', path: `/guilds/${c.guildId}/members/${op.memberId}/roles/${op.roleId}` };
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
discordApply(alias, input) {
|
|
287
|
+
// Parse synchronously: callers cannot mutate a queued or approved proposal by reference.
|
|
288
|
+
const parsed = batchSchema.safeParse(input);
|
|
289
|
+
if (!parsed.success)
|
|
290
|
+
return Promise.resolve({ ok: false, message: 'invalid Discord batch; use independent targets, at most 10 typed operations and 8 KiB JSON' });
|
|
291
|
+
const operations = parsed.data;
|
|
292
|
+
return this.run(async (secrets) => {
|
|
293
|
+
const c = await this.connection(alias), preflight = AbortSignal.timeout(30000), token = await this.token(c, secrets, preflight);
|
|
294
|
+
const snapshot = await this.snapshot(c, token, preflight, this.membersFor(operations));
|
|
295
|
+
this.validate(snapshot, operations);
|
|
296
|
+
const lines = this.review(c, snapshot, operations);
|
|
297
|
+
if (!await this.d.approver.ask('U-Chan Secrets - Discord write batch', lines)) {
|
|
298
|
+
await this.audit('discord_write', c, 'denied');
|
|
299
|
+
return { ok: false, message: 'denied' };
|
|
300
|
+
}
|
|
301
|
+
const signal = AbortSignal.timeout(60000);
|
|
302
|
+
const completed = [];
|
|
303
|
+
let stopped = 0;
|
|
304
|
+
try {
|
|
305
|
+
for (const [index, op] of operations.entries()) {
|
|
306
|
+
stopped = index;
|
|
307
|
+
// Lock and policy may change during the protected prompt. Do not reuse the old credential blindly.
|
|
308
|
+
const current = await this.connection(alias);
|
|
309
|
+
if (safeJson(current) !== safeJson(c))
|
|
310
|
+
return refuse('connection changed; request a new batch approval');
|
|
311
|
+
if (await this.d.vault.status(signal) !== 'unlocked')
|
|
312
|
+
return refuse('vault locked; remaining operations were not sent');
|
|
313
|
+
const live = await this.snapshot(c, token, signal, this.membersFor([op]));
|
|
314
|
+
this.validate(live, [op]);
|
|
315
|
+
if (safeJson(this.review(c, live, [op])) !== safeJson(this.review(c, snapshot, [op])))
|
|
316
|
+
return { ok: false, message: safeJson({ completed, stopped: index, outcome: 'not_sent', reason: 'server state changed after approval; inspect and propose again' }) };
|
|
317
|
+
const request = this.dispatch(c, op);
|
|
318
|
+
const auditTarget = `operation ${index}; ${op.type}; route ${request.path}`;
|
|
319
|
+
await this.audit('discord_write', c, `intent; ${auditTarget}`);
|
|
320
|
+
let raw;
|
|
321
|
+
try {
|
|
322
|
+
raw = await this.d.http.request(token, request.method, request.path, request.body, signal);
|
|
323
|
+
}
|
|
324
|
+
catch (e) {
|
|
325
|
+
const outcome = e instanceof DiscordHttpError ? e.outcome : 'unknown';
|
|
326
|
+
await this.audit('discord_write', c, `${outcome}; ${auditTarget}`).catch(() => { });
|
|
327
|
+
return { ok: false, message: safeJson({ completed, stopped: index, outcome, reason: e instanceof DiscordHttpError ? e.message : 'inspect before retrying', retryAfter: e instanceof DiscordHttpError ? e.retryAfter : undefined }) };
|
|
328
|
+
}
|
|
329
|
+
const id = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw.id : undefined;
|
|
330
|
+
if ((op.type === 'role_create' || op.type === 'channel_create') && !snowflake.safeParse(id).success) {
|
|
331
|
+
await this.audit('discord_write', c, `unknown; ${auditTarget}; invalid create result`).catch(() => { });
|
|
332
|
+
return { ok: false, message: safeJson({ completed, stopped: index, outcome: 'unknown', reason: 'write may have succeeded but no valid ID was returned; inspect before retrying' }) };
|
|
333
|
+
}
|
|
334
|
+
completed.push({ index, type: op.type, ...(snowflake.safeParse(id).success ? { id } : {}), outcome: 'completed' });
|
|
335
|
+
try {
|
|
336
|
+
await this.audit('discord_write', c, `completed; ${auditTarget}${typeof id === 'string' && snowflake.safeParse(id).success ? '; target ' + id : ''}`);
|
|
337
|
+
}
|
|
338
|
+
catch {
|
|
339
|
+
return { ok: false, message: safeJson({ completed, stopped: index, outcome: 'executed_audit_failed', reason: 'write executed; audit failed; inspect before retrying' }) };
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
catch (e) {
|
|
344
|
+
return { ok: false, message: safeJson({ completed, stopped, outcome: 'not_sent', reason: e instanceof Refused ? e.message : 'validation, credential, or audit check failed; remaining operations were not sent' }) };
|
|
345
|
+
}
|
|
346
|
+
return { ok: true, message: safeJson({ completed }) };
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { lstat, readFile, open, rename, unlink } from 'node:fs/promises';
|
|
2
|
+
import { dirname, resolve, parse } from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { connectionSchema } from './policy.js';
|
|
5
|
+
export class DiscordStore {
|
|
6
|
+
path;
|
|
7
|
+
beforeWrite;
|
|
8
|
+
constructor(path, beforeWrite = async () => { }) {
|
|
9
|
+
this.path = path;
|
|
10
|
+
this.beforeWrite = beforeWrite;
|
|
11
|
+
}
|
|
12
|
+
async safePath() {
|
|
13
|
+
for (let p = resolve(this.path);;) {
|
|
14
|
+
try {
|
|
15
|
+
if ((await lstat(p)).isSymbolicLink())
|
|
16
|
+
throw new Error('Discord state refuses links');
|
|
17
|
+
}
|
|
18
|
+
catch (e) {
|
|
19
|
+
if (e.code !== 'ENOENT')
|
|
20
|
+
throw e;
|
|
21
|
+
}
|
|
22
|
+
if (p === parse(p).root)
|
|
23
|
+
break;
|
|
24
|
+
p = dirname(p);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
async list() {
|
|
28
|
+
await this.safePath();
|
|
29
|
+
let raw;
|
|
30
|
+
try {
|
|
31
|
+
if ((await lstat(this.path)).size > 65536)
|
|
32
|
+
throw new Error('Discord connection state is invalid');
|
|
33
|
+
raw = await readFile(this.path, 'utf8');
|
|
34
|
+
}
|
|
35
|
+
catch (e) {
|
|
36
|
+
if (e.code === 'ENOENT')
|
|
37
|
+
return [];
|
|
38
|
+
throw e;
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
const data = JSON.parse(raw);
|
|
42
|
+
if (!Array.isArray(data) || data.length > 50)
|
|
43
|
+
throw new Error();
|
|
44
|
+
const entries = data.map(e => connectionSchema.parse(e));
|
|
45
|
+
if (new Set(entries.map(e => e.alias)).size !== entries.length)
|
|
46
|
+
throw new Error();
|
|
47
|
+
return entries;
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
throw new Error('Discord connection state is invalid');
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
async get(alias) { return (await this.list()).find(e => e.alias === alias); }
|
|
54
|
+
async upsert(connection) {
|
|
55
|
+
const entry = connectionSchema.parse(connection);
|
|
56
|
+
const entries = (await this.list()).filter(e => e.alias !== entry.alias);
|
|
57
|
+
await this.write([...entries, entry]);
|
|
58
|
+
}
|
|
59
|
+
async remove(alias) {
|
|
60
|
+
const entries = await this.list(), rest = entries.filter(e => e.alias !== alias);
|
|
61
|
+
if (rest.length === entries.length)
|
|
62
|
+
return false;
|
|
63
|
+
await this.write(rest);
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
async write(entries) {
|
|
67
|
+
if (entries.length > 50 || Buffer.byteLength(JSON.stringify(entries)) > 65536)
|
|
68
|
+
throw new Error('Discord connection state is full');
|
|
69
|
+
await this.beforeWrite();
|
|
70
|
+
await this.safePath();
|
|
71
|
+
const temp = `${this.path}.${randomUUID()}.tmp`;
|
|
72
|
+
const file = await open(temp, 'wx', 0o600);
|
|
73
|
+
try {
|
|
74
|
+
await file.writeFile(JSON.stringify(entries, null, 2));
|
|
75
|
+
await file.sync();
|
|
76
|
+
await file.close();
|
|
77
|
+
await this.safePath();
|
|
78
|
+
await rename(temp, this.path);
|
|
79
|
+
}
|
|
80
|
+
finally {
|
|
81
|
+
await file.close();
|
|
82
|
+
await unlink(temp).catch(() => { });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|