@addozhang/dsh-discord 0.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/LICENSE +21 -0
- package/README.md +68 -0
- package/cordis.patch.yml +14 -0
- package/lib/client/DiscordSettingsCard.d.ts +6 -0
- package/lib/client/PluginCard.d.ts +43 -0
- package/lib/client/card-controller.d.ts +22 -0
- package/lib/client/card-form.d.ts +111 -0
- package/lib/client/fields.d.ts +50 -0
- package/lib/client/index.d.ts +12 -0
- package/lib/client/nav-icon.d.ts +21 -0
- package/lib/client/settings-model.d.ts +49 -0
- package/lib/client/slot-contract.d.ts +57 -0
- package/lib/client/snapshot-store.d.ts +9 -0
- package/lib/client.js +1622 -0
- package/lib/compose.d.ts +158 -0
- package/lib/compose.js +227 -0
- package/lib/credential.d.ts +20 -0
- package/lib/credential.js +24 -0
- package/lib/discord/commands.d.ts +45 -0
- package/lib/discord/commands.js +87 -0
- package/lib/discord/components.d.ts +29 -0
- package/lib/discord/components.js +57 -0
- package/lib/discord/delivery.d.ts +21 -0
- package/lib/discord/delivery.js +31 -0
- package/lib/discord/interaction-lifecycle.d.ts +42 -0
- package/lib/discord/interaction-lifecycle.js +78 -0
- package/lib/discord/rest.d.ts +74 -0
- package/lib/discord/rest.js +149 -0
- package/lib/discord/selector.d.ts +39 -0
- package/lib/discord/selector.js +70 -0
- package/lib/discord/thread-port.d.ts +37 -0
- package/lib/discord/thread-port.js +58 -0
- package/lib/dsh/api-proxy-face.d.ts +253 -0
- package/lib/dsh/api-proxy-face.js +370 -0
- package/lib/features/adapter-status.d.ts +111 -0
- package/lib/features/adapter-status.js +103 -0
- package/lib/features/approval-expiry.d.ts +27 -0
- package/lib/features/approval-expiry.js +36 -0
- package/lib/features/approval-routing.d.ts +50 -0
- package/lib/features/approval-routing.js +57 -0
- package/lib/features/approval-store.d.ts +86 -0
- package/lib/features/approval-store.js +119 -0
- package/lib/features/approval-view.d.ts +47 -0
- package/lib/features/approval-view.js +48 -0
- package/lib/features/ask-wiring.d.ts +74 -0
- package/lib/features/ask-wiring.js +151 -0
- package/lib/features/host-status.d.ts +41 -0
- package/lib/features/host-status.js +24 -0
- package/lib/features/image-collection.d.ts +56 -0
- package/lib/features/image-collection.js +79 -0
- package/lib/features/image-download.d.ts +48 -0
- package/lib/features/image-download.js +77 -0
- package/lib/features/image-submission.d.ts +67 -0
- package/lib/features/image-submission.js +57 -0
- package/lib/features/interaction-router.d.ts +84 -0
- package/lib/features/interaction-router.js +655 -0
- package/lib/features/model-control.d.ts +79 -0
- package/lib/features/model-control.js +51 -0
- package/lib/features/preset-control.d.ts +84 -0
- package/lib/features/preset-control.js +43 -0
- package/lib/features/project-bind.d.ts +69 -0
- package/lib/features/project-bind.js +57 -0
- package/lib/features/project-info.d.ts +25 -0
- package/lib/features/project-info.js +22 -0
- package/lib/features/project-list.d.ts +79 -0
- package/lib/features/project-list.js +93 -0
- package/lib/features/prompt-submission.d.ts +53 -0
- package/lib/features/prompt-submission.js +47 -0
- package/lib/features/question-expiry.d.ts +53 -0
- package/lib/features/question-expiry.js +56 -0
- package/lib/features/question-routing.d.ts +108 -0
- package/lib/features/question-routing.js +162 -0
- package/lib/features/question-store.d.ts +124 -0
- package/lib/features/question-store.js +186 -0
- package/lib/features/question-view.d.ts +58 -0
- package/lib/features/question-view.js +81 -0
- package/lib/features/queue-control.d.ts +63 -0
- package/lib/features/queue-control.js +31 -0
- package/lib/features/reconcile-bindings.d.ts +75 -0
- package/lib/features/reconcile-bindings.js +54 -0
- package/lib/features/reconcile-deliveries.d.ts +29 -0
- package/lib/features/reconcile-deliveries.js +28 -0
- package/lib/features/reconcile-events.d.ts +49 -0
- package/lib/features/reconcile-events.js +50 -0
- package/lib/features/reconcile-intents.d.ts +32 -0
- package/lib/features/reconcile-intents.js +32 -0
- package/lib/features/reconcile-interactions.d.ts +59 -0
- package/lib/features/reconcile-interactions.js +54 -0
- package/lib/features/session-adopt.d.ts +89 -0
- package/lib/features/session-adopt.js +77 -0
- package/lib/features/session-creation.d.ts +49 -0
- package/lib/features/session-creation.js +43 -0
- package/lib/features/session-mainline.d.ts +75 -0
- package/lib/features/session-mainline.js +96 -0
- package/lib/features/session-resume.d.ts +44 -0
- package/lib/features/session-resume.js +40 -0
- package/lib/features/skill-run.d.ts +48 -0
- package/lib/features/skill-run.js +25 -0
- package/lib/features/steer-control.d.ts +37 -0
- package/lib/features/steer-control.js +28 -0
- package/lib/features/stop-control.d.ts +38 -0
- package/lib/features/stop-control.js +31 -0
- package/lib/features/task-admission.d.ts +29 -0
- package/lib/features/task-admission.js +21 -0
- package/lib/features/thread-continuation.d.ts +36 -0
- package/lib/features/thread-continuation.js +18 -0
- package/lib/features/thread-creation.d.ts +81 -0
- package/lib/features/thread-creation.js +81 -0
- package/lib/features/thread-routing.d.ts +49 -0
- package/lib/features/thread-routing.js +34 -0
- package/lib/features/turn-ownership.d.ts +39 -0
- package/lib/features/turn-ownership.js +40 -0
- package/lib/features/unbound-mention.d.ts +49 -0
- package/lib/features/unbound-mention.js +31 -0
- package/lib/features/workspace-channel.d.ts +41 -0
- package/lib/features/workspace-channel.js +47 -0
- package/lib/gateway/gateway.d.ts +50 -0
- package/lib/gateway/gateway.js +229 -0
- package/lib/gateway/inbound.d.ts +85 -0
- package/lib/gateway/inbound.js +186 -0
- package/lib/gateway/ingress.d.ts +15 -0
- package/lib/gateway/ingress.js +22 -0
- package/lib/i18n.d.ts +92 -0
- package/lib/i18n.js +180 -0
- package/lib/index.d.ts +17 -0
- package/lib/index.js +869 -0
- package/lib/lifecycle.d.ts +44 -0
- package/lib/lifecycle.js +84 -0
- package/lib/policy/authorization.d.ts +41 -0
- package/lib/policy/authorization.js +64 -0
- package/lib/policy/disclosure.d.ts +93 -0
- package/lib/policy/disclosure.js +153 -0
- package/lib/policy/guard.d.ts +31 -0
- package/lib/policy/guard.js +46 -0
- package/lib/policy/suppress.d.ts +13 -0
- package/lib/policy/suppress.js +20 -0
- package/lib/settings-namespace.d.ts +7 -0
- package/lib/settings-namespace.js +6 -0
- package/lib/settings.d.ts +37 -0
- package/lib/settings.js +102 -0
- package/lib/startup.d.ts +16 -0
- package/lib/startup.js +60 -0
- package/lib/state/bindings.d.ts +46 -0
- package/lib/state/bindings.js +55 -0
- package/lib/state/channel-bindings.d.ts +37 -0
- package/lib/state/channel-bindings.js +43 -0
- package/lib/state/domain.d.ts +82 -0
- package/lib/state/domain.js +86 -0
- package/lib/state/effect-machine.d.ts +37 -0
- package/lib/state/effect-machine.js +52 -0
- package/lib/state/fail-closed.d.ts +44 -0
- package/lib/state/fail-closed.js +57 -0
- package/lib/state/intents.d.ts +51 -0
- package/lib/state/intents.js +73 -0
- package/lib/state/records.d.ts +37 -0
- package/lib/state/records.js +30 -0
- package/lib/state/retention.d.ts +74 -0
- package/lib/state/retention.js +68 -0
- package/lib/state/session-owners.d.ts +50 -0
- package/lib/state/session-owners.js +50 -0
- package/lib/stream/finalizer.d.ts +49 -0
- package/lib/stream/finalizer.js +67 -0
- package/lib/stream/icons.d.ts +15 -0
- package/lib/stream/icons.js +36 -0
- package/lib/stream/live.d.ts +140 -0
- package/lib/stream/live.js +515 -0
- package/lib/stream/markdown.d.ts +24 -0
- package/lib/stream/markdown.js +95 -0
- package/lib/stream/outbound.d.ts +27 -0
- package/lib/stream/outbound.js +25 -0
- package/lib/stream/render-fence.d.ts +27 -0
- package/lib/stream/render-fence.js +37 -0
- package/lib/stream/render-model.d.ts +48 -0
- package/lib/stream/render-model.js +68 -0
- package/lib/stream/splitter.d.ts +8 -0
- package/lib/stream/splitter.js +58 -0
- package/lib/stream/tool-view.d.ts +40 -0
- package/lib/stream/tool-view.js +39 -0
- package/lib/stream/typing.d.ts +22 -0
- package/lib/stream/typing.js +58 -0
- package/lib/stream/update-scheduler.d.ts +20 -0
- package/lib/stream/update-scheduler.js +65 -0
- package/package.json +86 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sanitized adapter status for the settings surface (design.md §3, task 2.3;
|
|
3
|
+
* plugin-foundation spec). The Host distills credential presence and Gateway
|
|
4
|
+
* observations into one small value — machine-readable condition plus a
|
|
5
|
+
* stable hint key the card resolves to copy — that by construction has no
|
|
6
|
+
* slot for the token or any raw provider response. The value reaches the Web
|
|
7
|
+
* card through the plugin's loopback RPC channel, the same seam dsh
|
|
8
|
+
* establishes for plugin management surfaces.
|
|
9
|
+
*/
|
|
10
|
+
/** Credential presence for the fixed bot-token reference (never its value). */
|
|
11
|
+
export type TokenPresence = 'configured' | 'unconfigured';
|
|
12
|
+
/** What the Gateway is doing, distilled from the composition layer's watches. */
|
|
13
|
+
export type GatewayObservation = 'connected' | 'connecting' | 'disconnected' | {
|
|
14
|
+
kind: 'terminal-close';
|
|
15
|
+
code: number;
|
|
16
|
+
};
|
|
17
|
+
/** Non-connection facts worth surfacing (channel permissions, 5.x territory). */
|
|
18
|
+
export type StatusDetail = 'missing-channel-permissions';
|
|
19
|
+
/** The conditions the settings surface can name. */
|
|
20
|
+
export type ConnectionCondition = 'connected' | 'connecting' | 'disconnected' | 'invalid-token' | 'intents-blocked' | 'permissions-blocked';
|
|
21
|
+
/** Stable hint keys; the card owns the human copy. */
|
|
22
|
+
export type StatusHint = 'configure-token' | 'token-rejected' | 'enable-intents' | 'gateway-closed' | 'channel-permissions';
|
|
23
|
+
export interface AdapterStatusView {
|
|
24
|
+
token: TokenPresence;
|
|
25
|
+
connection: ConnectionCondition;
|
|
26
|
+
hint?: StatusHint | undefined;
|
|
27
|
+
}
|
|
28
|
+
export interface AdapterStatusInput {
|
|
29
|
+
token: TokenPresence;
|
|
30
|
+
gateway: GatewayObservation;
|
|
31
|
+
detail?: StatusDetail | undefined;
|
|
32
|
+
}
|
|
33
|
+
export declare function projectAdapterStatus(input: AdapterStatusInput): AdapterStatusView;
|
|
34
|
+
/** Latest observed facts; the projection is always derivable on demand. */
|
|
35
|
+
export interface AdapterStatusTracker {
|
|
36
|
+
setCredential(view: {
|
|
37
|
+
configured: boolean;
|
|
38
|
+
source?: string;
|
|
39
|
+
writable?: boolean;
|
|
40
|
+
}): void;
|
|
41
|
+
setGateway(observation: GatewayObservation): void;
|
|
42
|
+
setDetail(detail: StatusDetail | undefined): void;
|
|
43
|
+
project(): AdapterStatusView;
|
|
44
|
+
}
|
|
45
|
+
export declare function createAdapterStatusTracker(): AdapterStatusTracker;
|
|
46
|
+
/** The plugin RPC channel; the client bundle calls the same constant. */
|
|
47
|
+
export declare const DISCORD_RPC_CHANNEL = "/dsh-discord";
|
|
48
|
+
/** The one endpoint this channel serves. */
|
|
49
|
+
export declare const STATUS_ENDPOINT = "adapter.status";
|
|
50
|
+
/** The Host connection service's RPC face (duck-typed; dsh owns the real type). */
|
|
51
|
+
export interface ConnectionRpc {
|
|
52
|
+
rpc: {
|
|
53
|
+
handle(channel: string, handler: (endpoint: string, payload: unknown, signal: {
|
|
54
|
+
aborted: boolean;
|
|
55
|
+
} | undefined) => Promise<{
|
|
56
|
+
ok: true;
|
|
57
|
+
value: unknown;
|
|
58
|
+
} | {
|
|
59
|
+
ok: false;
|
|
60
|
+
error: {
|
|
61
|
+
code: string;
|
|
62
|
+
message: string;
|
|
63
|
+
};
|
|
64
|
+
}>, options: {
|
|
65
|
+
authority: 'loopback' | 'trusted-host';
|
|
66
|
+
}): () => void;
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
type RpcAnswer = {
|
|
70
|
+
ok: true;
|
|
71
|
+
value: AdapterStatusView | Record<string, unknown>;
|
|
72
|
+
} | {
|
|
73
|
+
ok: false;
|
|
74
|
+
error: {
|
|
75
|
+
code: string;
|
|
76
|
+
message: string;
|
|
77
|
+
};
|
|
78
|
+
};
|
|
79
|
+
/**
|
|
80
|
+
* The channel's handler: exactly one endpoint, value-shaped failures, and no
|
|
81
|
+
* access to anything beyond the projection.
|
|
82
|
+
*/
|
|
83
|
+
export declare function createAdapterStatusRpcHandler(tracker: AdapterStatusTracker): (endpoint: string, payload: unknown, signal: {
|
|
84
|
+
aborted: boolean;
|
|
85
|
+
} | undefined) => Promise<RpcAnswer>;
|
|
86
|
+
/** Extra host faces the management channel can exercise. */
|
|
87
|
+
export interface ManagementChannelDeps {
|
|
88
|
+
tracker: AdapterStatusTracker;
|
|
89
|
+
/** Store the bot token into the writable credential layer. */
|
|
90
|
+
setToken?: ((value: string) => Promise<void>) | undefined;
|
|
91
|
+
/** Re-run the adapter start chain (the card's Connect button). */
|
|
92
|
+
connect?: (() => void) | undefined;
|
|
93
|
+
/** Operator-initiated offline (the card's Disconnect button). */
|
|
94
|
+
disconnect?: (() => void) | undefined;
|
|
95
|
+
/** Guilds the bot is a member of, sanitized (id + name only). */
|
|
96
|
+
guilds?: (() => Promise<Array<{
|
|
97
|
+
id: string;
|
|
98
|
+
name: string;
|
|
99
|
+
}>>) | undefined;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* The management channel's handler: status (with guild names when
|
|
103
|
+
* discoverable), credential write, and a connect trigger. Unknown endpoints
|
|
104
|
+
* fail as values; nothing here can read the token back.
|
|
105
|
+
*/
|
|
106
|
+
export declare function createAdapterManagementHandler(deps: ManagementChannelDeps): (endpoint: string, payload: unknown, signal: {
|
|
107
|
+
aborted: boolean;
|
|
108
|
+
} | undefined) => Promise<RpcAnswer>;
|
|
109
|
+
/** Register the management channel; returns the connection service's disposer. */
|
|
110
|
+
export declare function installAdapterStatusRpc(connection: ConnectionRpc, tracker: AdapterStatusTracker, deps?: ManagementChannelDeps): () => void;
|
|
111
|
+
export {};
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sanitized adapter status for the settings surface (design.md §3, task 2.3;
|
|
3
|
+
* plugin-foundation spec). The Host distills credential presence and Gateway
|
|
4
|
+
* observations into one small value — machine-readable condition plus a
|
|
5
|
+
* stable hint key the card resolves to copy — that by construction has no
|
|
6
|
+
* slot for the token or any raw provider response. The value reaches the Web
|
|
7
|
+
* card through the plugin's loopback RPC channel, the same seam dsh
|
|
8
|
+
* establishes for plugin management surfaces.
|
|
9
|
+
*/
|
|
10
|
+
/** Discord's authentication-failure close: the token itself is rejected. */
|
|
11
|
+
const AUTH_FAILURE_CLOSE = 4004;
|
|
12
|
+
/** Discord's invalid/disallowed-intent closes: the config, not the token. */
|
|
13
|
+
const INTENT_REJECTION_CLOSES = new Set([4013, 4014]);
|
|
14
|
+
export function projectAdapterStatus(input) {
|
|
15
|
+
if (input.token === 'unconfigured') {
|
|
16
|
+
return { token: input.token, connection: 'disconnected', hint: 'configure-token' };
|
|
17
|
+
}
|
|
18
|
+
if (input.detail === 'missing-channel-permissions' && input.gateway !== 'connected') {
|
|
19
|
+
return { token: input.token, connection: 'permissions-blocked', hint: 'channel-permissions' };
|
|
20
|
+
}
|
|
21
|
+
if (typeof input.gateway === 'object') {
|
|
22
|
+
if (input.gateway.code === AUTH_FAILURE_CLOSE) {
|
|
23
|
+
return { token: input.token, connection: 'invalid-token', hint: 'token-rejected' };
|
|
24
|
+
}
|
|
25
|
+
if (INTENT_REJECTION_CLOSES.has(input.gateway.code)) {
|
|
26
|
+
return { token: input.token, connection: 'intents-blocked', hint: 'enable-intents' };
|
|
27
|
+
}
|
|
28
|
+
return { token: input.token, connection: 'disconnected', hint: 'gateway-closed' };
|
|
29
|
+
}
|
|
30
|
+
// connected / connecting / disconnected-by-observation carry no hint of
|
|
31
|
+
// their own: nothing actionable has been observed yet.
|
|
32
|
+
return { token: input.token, connection: input.gateway };
|
|
33
|
+
}
|
|
34
|
+
export function createAdapterStatusTracker() {
|
|
35
|
+
let token = 'unconfigured';
|
|
36
|
+
let gateway = 'disconnected';
|
|
37
|
+
let detail = undefined;
|
|
38
|
+
return {
|
|
39
|
+
setCredential(view) {
|
|
40
|
+
token = view.configured ? 'configured' : 'unconfigured';
|
|
41
|
+
},
|
|
42
|
+
setGateway(observation) {
|
|
43
|
+
gateway = observation;
|
|
44
|
+
},
|
|
45
|
+
setDetail(next) {
|
|
46
|
+
detail = next;
|
|
47
|
+
},
|
|
48
|
+
project() {
|
|
49
|
+
return projectAdapterStatus({ token, gateway, ...(detail === undefined ? {} : { detail }) });
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/** The plugin RPC channel; the client bundle calls the same constant. */
|
|
54
|
+
export const DISCORD_RPC_CHANNEL = '/dsh-discord';
|
|
55
|
+
/** The one endpoint this channel serves. */
|
|
56
|
+
export const STATUS_ENDPOINT = 'adapter.status';
|
|
57
|
+
/**
|
|
58
|
+
* The channel's handler: exactly one endpoint, value-shaped failures, and no
|
|
59
|
+
* access to anything beyond the projection.
|
|
60
|
+
*/
|
|
61
|
+
export function createAdapterStatusRpcHandler(tracker) {
|
|
62
|
+
return createAdapterManagementHandler({ tracker });
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* The management channel's handler: status (with guild names when
|
|
66
|
+
* discoverable), credential write, and a connect trigger. Unknown endpoints
|
|
67
|
+
* fail as values; nothing here can read the token back.
|
|
68
|
+
*/
|
|
69
|
+
export function createAdapterManagementHandler(deps) {
|
|
70
|
+
return (endpoint, payload, signal) => {
|
|
71
|
+
if (signal?.aborted) {
|
|
72
|
+
return Promise.resolve({ ok: false, error: { code: 'cancelled', message: 'The request was cancelled.' } });
|
|
73
|
+
}
|
|
74
|
+
if (endpoint === STATUS_ENDPOINT) {
|
|
75
|
+
if (deps.guilds === undefined)
|
|
76
|
+
return Promise.resolve({ ok: true, value: deps.tracker.project() });
|
|
77
|
+
return deps.guilds().then((guilds) => ({ ok: true, value: { ...deps.tracker.project(), guilds } }), () => ({ ok: true, value: deps.tracker.project() }));
|
|
78
|
+
}
|
|
79
|
+
if (endpoint === 'adapter.connect' && deps.connect !== undefined) {
|
|
80
|
+
deps.connect();
|
|
81
|
+
return Promise.resolve({ ok: true, value: { connecting: true } });
|
|
82
|
+
}
|
|
83
|
+
if (endpoint === 'adapter.disconnect' && deps.disconnect !== undefined) {
|
|
84
|
+
deps.disconnect();
|
|
85
|
+
return Promise.resolve({ ok: true, value: { stopped: true } });
|
|
86
|
+
}
|
|
87
|
+
if (endpoint === 'credentials.set' && deps.setToken !== undefined) {
|
|
88
|
+
const value = payload?.value;
|
|
89
|
+
const text = typeof value === 'string' ? value.trim() : '';
|
|
90
|
+
if (text === '') {
|
|
91
|
+
return Promise.resolve({ ok: false, error: { code: 'bad-request', message: 'empty token' } });
|
|
92
|
+
}
|
|
93
|
+
return deps.setToken(text).then(() => ({ ok: true, value: { saved: true } }), (cause) => ({ ok: false, error: { code: 'credential-rejected', message: String(cause) } }));
|
|
94
|
+
}
|
|
95
|
+
return Promise.resolve({ ok: false, error: { code: 'bad-request', message: `Unknown ${DISCORD_RPC_CHANNEL} endpoint.` } });
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
/** Register the management channel; returns the connection service's disposer. */
|
|
99
|
+
export function installAdapterStatusRpc(connection, tracker, deps = { tracker }) {
|
|
100
|
+
return connection.rpc.handle(DISCORD_RPC_CHANNEL, createAdapterManagementHandler(deps), {
|
|
101
|
+
authority: 'loopback',
|
|
102
|
+
});
|
|
103
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Approval expiry sweep (design.md §8, task 13.4). An approval whose deadline
|
|
3
|
+
* passes while still pending fails closed: the sweep claims it atomically —
|
|
4
|
+
* losing to any concurrent click — submits `rejected` through the record's
|
|
5
|
+
* rpcId, and marks the Discord controls expired only AFTER the outcome is
|
|
6
|
+
* recorded. When DSH does not confirm the rejection, the record keeps an
|
|
7
|
+
* explicit unresolved state and the controls stay live: nothing is claimed
|
|
8
|
+
* as answered, and the user's own click remains the retry path (the sweep
|
|
9
|
+
* never auto-retries).
|
|
10
|
+
*/
|
|
11
|
+
import type { DshApprovalRespondPort } from './approval-routing.js';
|
|
12
|
+
import type { ApprovalStore } from './approval-store.js';
|
|
13
|
+
/** Face that retires the rendered Allow once / Reject controls. */
|
|
14
|
+
export interface ExpiredControls {
|
|
15
|
+
disable(approvalId: string): Promise<void>;
|
|
16
|
+
}
|
|
17
|
+
export interface ApprovalExpiryDeps {
|
|
18
|
+
store: ApprovalStore;
|
|
19
|
+
port: DshApprovalRespondPort;
|
|
20
|
+
controls: ExpiredControls;
|
|
21
|
+
nowMs: () => number;
|
|
22
|
+
}
|
|
23
|
+
export interface ExpirySweepResult {
|
|
24
|
+
/** Approval ids this sweep rejected and retired. */
|
|
25
|
+
handled: string[];
|
|
26
|
+
}
|
|
27
|
+
export declare function sweepExpiredApprovals(deps: ApprovalExpiryDeps): Promise<ExpirySweepResult>;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Approval expiry sweep (design.md §8, task 13.4). An approval whose deadline
|
|
3
|
+
* passes while still pending fails closed: the sweep claims it atomically —
|
|
4
|
+
* losing to any concurrent click — submits `rejected` through the record's
|
|
5
|
+
* rpcId, and marks the Discord controls expired only AFTER the outcome is
|
|
6
|
+
* recorded. When DSH does not confirm the rejection, the record keeps an
|
|
7
|
+
* explicit unresolved state and the controls stay live: nothing is claimed
|
|
8
|
+
* as answered, and the user's own click remains the retry path (the sweep
|
|
9
|
+
* never auto-retries).
|
|
10
|
+
*/
|
|
11
|
+
export async function sweepExpiredApprovals(deps) {
|
|
12
|
+
const nowMs = deps.nowMs();
|
|
13
|
+
const handled = [];
|
|
14
|
+
for (const record of deps.store.listPendingExpired(nowMs)) {
|
|
15
|
+
// Atomic claim: a click that beat the sweep owns the answer instead.
|
|
16
|
+
const claim = await deps.store.claim(record.approvalId);
|
|
17
|
+
if (claim.outcome !== 'claimed')
|
|
18
|
+
continue;
|
|
19
|
+
const submitted = await deps.port.respond({
|
|
20
|
+
rpcId: record.rpcId,
|
|
21
|
+
sessionId: record.sessionId,
|
|
22
|
+
approvalId: record.approvalId,
|
|
23
|
+
outcome: 'rejected',
|
|
24
|
+
});
|
|
25
|
+
if (submitted.outcome === 'confirmed') {
|
|
26
|
+
await deps.store.markResolved(record.approvalId, 'rejected', deps.nowMs());
|
|
27
|
+
// Controls expire only after the outcome is on the record.
|
|
28
|
+
await deps.controls.disable(record.approvalId);
|
|
29
|
+
handled.push(record.approvalId);
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
await deps.store.markUnresolved(record.approvalId, deps.nowMs());
|
|
33
|
+
handled.push(record.approvalId);
|
|
34
|
+
}
|
|
35
|
+
return { handled };
|
|
36
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Approval click routing (design.md §8, tasks 13.2/13.3). One pipeline per
|
|
3
|
+
* component click: resolve the opaque custom_id, validate ownership, claim
|
|
4
|
+
* the record atomically, submit exactly one DSH response, and settle the
|
|
5
|
+
* record from what DSH actually confirmed. Losers of a race, stale controls,
|
|
6
|
+
* and denials never reach DSH; an unconfirmed submit parks the approval in
|
|
7
|
+
* an explicit unresolved state — it is never reported as answered.
|
|
8
|
+
*/
|
|
9
|
+
import type { ComponentRegistry } from '../discord/components.js';
|
|
10
|
+
import { type ApprovalOutcome, type ApprovalStore } from './approval-store.js';
|
|
11
|
+
/** The DSH respond face: the answerable server-request echo (approvals.d.ts). */
|
|
12
|
+
export interface DshApprovalRespondPort {
|
|
13
|
+
respond(input: {
|
|
14
|
+
rpcId: string;
|
|
15
|
+
sessionId: string;
|
|
16
|
+
approvalId: string;
|
|
17
|
+
outcome: ApprovalOutcome;
|
|
18
|
+
}): Promise<{
|
|
19
|
+
outcome: 'confirmed';
|
|
20
|
+
} | {
|
|
21
|
+
outcome: 'rejected';
|
|
22
|
+
reason: string;
|
|
23
|
+
} | {
|
|
24
|
+
outcome: 'unknown';
|
|
25
|
+
}>;
|
|
26
|
+
}
|
|
27
|
+
export interface ApprovalRoutingDeps {
|
|
28
|
+
registry: ComponentRegistry;
|
|
29
|
+
store: ApprovalStore;
|
|
30
|
+
port: DshApprovalRespondPort;
|
|
31
|
+
nowMs: () => number;
|
|
32
|
+
}
|
|
33
|
+
export interface ApprovalClickInput {
|
|
34
|
+
customId: string;
|
|
35
|
+
userId: string;
|
|
36
|
+
threadId: string;
|
|
37
|
+
}
|
|
38
|
+
export type ApprovalClickOutcome = {
|
|
39
|
+
outcome: 'submitted';
|
|
40
|
+
action: 'allow' | 'reject';
|
|
41
|
+
} | {
|
|
42
|
+
outcome: 'already-resolved';
|
|
43
|
+
} | {
|
|
44
|
+
outcome: 'denied';
|
|
45
|
+
} | {
|
|
46
|
+
outcome: 'unknown-control';
|
|
47
|
+
} | {
|
|
48
|
+
outcome: 'unresolved';
|
|
49
|
+
};
|
|
50
|
+
export declare function handleApprovalClick(deps: ApprovalRoutingDeps, click: ApprovalClickInput): Promise<ApprovalClickOutcome>;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Approval click routing (design.md §8, tasks 13.2/13.3). One pipeline per
|
|
3
|
+
* component click: resolve the opaque custom_id, validate ownership, claim
|
|
4
|
+
* the record atomically, submit exactly one DSH response, and settle the
|
|
5
|
+
* record from what DSH actually confirmed. Losers of a race, stale controls,
|
|
6
|
+
* and denials never reach DSH; an unconfirmed submit parks the approval in
|
|
7
|
+
* an explicit unresolved state — it is never reported as answered.
|
|
8
|
+
*/
|
|
9
|
+
import { authorizeApprovalClick } from './approval-store.js';
|
|
10
|
+
/** Registry contexts carry only these two facts plus their expiry. */
|
|
11
|
+
function parseContext(context) {
|
|
12
|
+
const approvalId = context['approvalId'];
|
|
13
|
+
const action = context['action'];
|
|
14
|
+
if (typeof approvalId !== 'string' || (action !== 'allow' && action !== 'reject'))
|
|
15
|
+
return undefined;
|
|
16
|
+
return { approvalId, action };
|
|
17
|
+
}
|
|
18
|
+
export async function handleApprovalClick(deps, click) {
|
|
19
|
+
const resolution = deps.registry.resolve(click.customId, deps.nowMs());
|
|
20
|
+
if (!resolution.found)
|
|
21
|
+
return { outcome: 'unknown-control' };
|
|
22
|
+
const context = parseContext(resolution.context);
|
|
23
|
+
if (context === undefined)
|
|
24
|
+
return { outcome: 'unknown-control' };
|
|
25
|
+
const record = deps.store.get(context.approvalId);
|
|
26
|
+
if (record === undefined)
|
|
27
|
+
return { outcome: 'unknown-control' };
|
|
28
|
+
const decision = authorizeApprovalClick(record, click);
|
|
29
|
+
if (!decision.allowed)
|
|
30
|
+
return { outcome: 'denied' };
|
|
31
|
+
const claim = await deps.store.claim(context.approvalId);
|
|
32
|
+
if (claim.outcome !== 'claimed')
|
|
33
|
+
return { outcome: 'already-resolved' };
|
|
34
|
+
const outcome = context.action === 'allow' ? 'allowed-once' : 'rejected';
|
|
35
|
+
// A port that THROWS (distinct from an unknown outcome) is still an
|
|
36
|
+
// unconfirmed submit: park unresolved so the expiry sweep and the user's
|
|
37
|
+
// own retry stay available — never leave the approval in submitting.
|
|
38
|
+
let submitted;
|
|
39
|
+
try {
|
|
40
|
+
submitted = await deps.port.respond({
|
|
41
|
+
rpcId: decision.respond.rpcId,
|
|
42
|
+
sessionId: decision.respond.sessionId,
|
|
43
|
+
approvalId: decision.respond.approvalId,
|
|
44
|
+
outcome,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
await deps.store.markUnresolved(context.approvalId, deps.nowMs());
|
|
49
|
+
return { outcome: 'unresolved' };
|
|
50
|
+
}
|
|
51
|
+
if (submitted.outcome === 'confirmed') {
|
|
52
|
+
await deps.store.markResolved(context.approvalId, outcome, deps.nowMs());
|
|
53
|
+
return { outcome: 'submitted', action: context.action };
|
|
54
|
+
}
|
|
55
|
+
await deps.store.markUnresolved(context.approvalId, deps.nowMs());
|
|
56
|
+
return { outcome: 'unresolved' };
|
|
57
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pending approval records and ownership validation (design.md §6/§8, tasks
|
|
3
|
+
* 13.2/13.3). One record binds every ownership fact — Session, Discord
|
|
4
|
+
* Thread, the owning adapter-submitted request ID, the DSH rpcId, the
|
|
5
|
+
* approval ID, and the originating Discord user — and is the ONLY source of
|
|
6
|
+
* respond data: the wire carries an opaque key, so nothing can be spoofed
|
|
7
|
+
* through a click. Opening is idempotent per approval ID (the mux replays
|
|
8
|
+
* still-pending requests on reconnect), and Milestone 1 authorization is
|
|
9
|
+
* exactly the originating user on the owning thread — administrators
|
|
10
|
+
* included are denied.
|
|
11
|
+
*/
|
|
12
|
+
export type ApprovalState = 'pending' | 'submitting' | 'resolved' | 'unresolved';
|
|
13
|
+
export type ApprovalOutcome = 'allowed-once' | 'rejected';
|
|
14
|
+
export interface ApprovalRecord {
|
|
15
|
+
approvalId: string;
|
|
16
|
+
sessionId: string;
|
|
17
|
+
/** The owning Discord thread; clicks from any other thread are denied. */
|
|
18
|
+
threadId: string;
|
|
19
|
+
/** The owning adapter-submitted turn's durable request ID. */
|
|
20
|
+
requestId: string;
|
|
21
|
+
/** The answerable server-request rpcId the response must echo. */
|
|
22
|
+
rpcId: string;
|
|
23
|
+
/** The Discord user who submitted the owning turn. */
|
|
24
|
+
actorUserId: string;
|
|
25
|
+
toolName: string;
|
|
26
|
+
reason?: string | undefined;
|
|
27
|
+
expiresAtMs: number;
|
|
28
|
+
state: ApprovalState;
|
|
29
|
+
resolvedOutcome?: ApprovalOutcome | undefined;
|
|
30
|
+
resolvedAtMs?: number | undefined;
|
|
31
|
+
}
|
|
32
|
+
/** Minimal durable face (the domain's KvTable provides it). */
|
|
33
|
+
export interface ApprovalTable {
|
|
34
|
+
get(approvalId: string): ApprovalRecord | undefined;
|
|
35
|
+
put(approvalId: string, record: ApprovalRecord): Promise<void>;
|
|
36
|
+
}
|
|
37
|
+
export interface ApprovalStore {
|
|
38
|
+
get(approvalId: string): ApprovalRecord | undefined;
|
|
39
|
+
/** Register a pending approval; a replay keeps the original record. */
|
|
40
|
+
open(record: ApprovalRecord): void;
|
|
41
|
+
/**
|
|
42
|
+
* Atomically claim the approval for submission: pending (or an explicit
|
|
43
|
+
* user retry after unresolved) flips to submitting exactly once per
|
|
44
|
+
* serialized key; submitting and resolved records never re-claim.
|
|
45
|
+
*/
|
|
46
|
+
claim(approvalId: string): Promise<{
|
|
47
|
+
outcome: 'claimed';
|
|
48
|
+
record: ApprovalRecord;
|
|
49
|
+
} | {
|
|
50
|
+
outcome: 'not-claimable';
|
|
51
|
+
record: ApprovalRecord;
|
|
52
|
+
} | {
|
|
53
|
+
outcome: 'unknown';
|
|
54
|
+
}>;
|
|
55
|
+
/** Record a DSH-confirmed outcome; terminal. */
|
|
56
|
+
markResolved(approvalId: string, outcome: ApprovalOutcome, atMs: number): Promise<void>;
|
|
57
|
+
/** Retain the record in an explicit unresolved state when DSH is silent. */
|
|
58
|
+
markUnresolved(approvalId: string, atMs: number): Promise<void>;
|
|
59
|
+
/** Pending records whose deadline has passed, for the expiry sweep (13.4). */
|
|
60
|
+
listPendingExpired(atMs: number): ApprovalRecord[];
|
|
61
|
+
}
|
|
62
|
+
export declare function createApprovalStore(table: ApprovalTable): ApprovalStore;
|
|
63
|
+
export interface ApprovalClick {
|
|
64
|
+
userId: string;
|
|
65
|
+
threadId: string;
|
|
66
|
+
/** Present for context only: administrators hold no approval privilege. */
|
|
67
|
+
isAdministrator?: boolean | undefined;
|
|
68
|
+
}
|
|
69
|
+
export type ApprovalDecision = {
|
|
70
|
+
allowed: true;
|
|
71
|
+
/** Respond data built solely from the record, for apiProxy.respond. */
|
|
72
|
+
respond: {
|
|
73
|
+
rpcId: string;
|
|
74
|
+
sessionId: string;
|
|
75
|
+
approvalId: string;
|
|
76
|
+
};
|
|
77
|
+
requestId: string;
|
|
78
|
+
} | {
|
|
79
|
+
allowed: false;
|
|
80
|
+
reason: 'not-owner';
|
|
81
|
+
};
|
|
82
|
+
/**
|
|
83
|
+
* Milestone 1 approval authorization: the originating user on the owning
|
|
84
|
+
* thread, point. Administrative or moderator standing grants nothing.
|
|
85
|
+
*/
|
|
86
|
+
export declare function authorizeApprovalClick(record: ApprovalRecord, click: ApprovalClick): ApprovalDecision;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pending approval records and ownership validation (design.md §6/§8, tasks
|
|
3
|
+
* 13.2/13.3). One record binds every ownership fact — Session, Discord
|
|
4
|
+
* Thread, the owning adapter-submitted request ID, the DSH rpcId, the
|
|
5
|
+
* approval ID, and the originating Discord user — and is the ONLY source of
|
|
6
|
+
* respond data: the wire carries an opaque key, so nothing can be spoofed
|
|
7
|
+
* through a click. Opening is idempotent per approval ID (the mux replays
|
|
8
|
+
* still-pending requests on reconnect), and Milestone 1 authorization is
|
|
9
|
+
* exactly the originating user on the owning thread — administrators
|
|
10
|
+
* included are denied.
|
|
11
|
+
*/
|
|
12
|
+
/** Per-key serialization: operations on one approval never interleave. */
|
|
13
|
+
function serialized(chains, key, op) {
|
|
14
|
+
const tail = chains.get(key) ?? Promise.resolve();
|
|
15
|
+
const run = tail.then(op, op);
|
|
16
|
+
chains.set(key, run.then(() => undefined, () => undefined));
|
|
17
|
+
return run;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* How long a terminal approval record lingers in the id index past its
|
|
21
|
+
* deadline before listing prunes it — the resolved-interaction retention
|
|
22
|
+
* semantics of design.md §10 (7 days), applied to the store's own index.
|
|
23
|
+
*/
|
|
24
|
+
const RESOLVED_INTERACTION_RETENTION_MS = 7 * 24 * 60 * 60_000;
|
|
25
|
+
export function createApprovalStore(table) {
|
|
26
|
+
const chains = new Map();
|
|
27
|
+
/** In-memory id index; enumeration reads through and prunes itself. */
|
|
28
|
+
const knownIds = new Set();
|
|
29
|
+
function snapshot() {
|
|
30
|
+
const records = [];
|
|
31
|
+
for (const approvalId of knownIds) {
|
|
32
|
+
const record = table.get(approvalId);
|
|
33
|
+
if (record === undefined) {
|
|
34
|
+
knownIds.delete(approvalId);
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
records.push(record);
|
|
38
|
+
}
|
|
39
|
+
return records;
|
|
40
|
+
}
|
|
41
|
+
function prunePastRetention(nowMs) {
|
|
42
|
+
for (const record of snapshot()) {
|
|
43
|
+
const settled = record.state === 'resolved' || record.state === 'unresolved';
|
|
44
|
+
if (settled && nowMs - record.expiresAtMs > RESOLVED_INTERACTION_RETENTION_MS) {
|
|
45
|
+
knownIds.delete(record.approvalId);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function mutate(approvalId, change) {
|
|
50
|
+
return serialized(chains, approvalId, async () => {
|
|
51
|
+
const existing = table.get(approvalId);
|
|
52
|
+
if (existing === undefined)
|
|
53
|
+
return undefined;
|
|
54
|
+
const next = change(existing);
|
|
55
|
+
await table.put(approvalId, next);
|
|
56
|
+
return next;
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
get: approvalId => table.get(approvalId),
|
|
61
|
+
open(record) {
|
|
62
|
+
const existing = table.get(record.approvalId);
|
|
63
|
+
if (existing !== undefined)
|
|
64
|
+
return;
|
|
65
|
+
knownIds.add(record.approvalId);
|
|
66
|
+
void table.put(record.approvalId, record);
|
|
67
|
+
},
|
|
68
|
+
claim(approvalId) {
|
|
69
|
+
return serialized(chains, approvalId, async () => {
|
|
70
|
+
const existing = table.get(approvalId);
|
|
71
|
+
if (existing === undefined)
|
|
72
|
+
return { outcome: 'unknown' };
|
|
73
|
+
// An unresolved record still demands an answer; a fresh explicit
|
|
74
|
+
// click is that answer. Everything else is settled or in flight.
|
|
75
|
+
const claimable = existing.state === 'pending' || existing.state === 'unresolved';
|
|
76
|
+
if (!claimable)
|
|
77
|
+
return { outcome: 'not-claimable', record: existing };
|
|
78
|
+
const claimed = { ...existing, state: 'submitting' };
|
|
79
|
+
await table.put(approvalId, claimed);
|
|
80
|
+
return { outcome: 'claimed', record: claimed };
|
|
81
|
+
});
|
|
82
|
+
},
|
|
83
|
+
markResolved(approvalId, outcome, atMs) {
|
|
84
|
+
return mutate(approvalId, record => ({
|
|
85
|
+
...record,
|
|
86
|
+
state: 'resolved',
|
|
87
|
+
resolvedOutcome: outcome,
|
|
88
|
+
resolvedAtMs: atMs,
|
|
89
|
+
})).then(() => undefined);
|
|
90
|
+
},
|
|
91
|
+
markUnresolved(approvalId, atMs) {
|
|
92
|
+
return mutate(approvalId, record => ({ ...record, state: 'unresolved', resolvedAtMs: atMs }))
|
|
93
|
+
.then(() => undefined);
|
|
94
|
+
},
|
|
95
|
+
listPendingExpired(atMs) {
|
|
96
|
+
prunePastRetention(atMs);
|
|
97
|
+
const expired = [];
|
|
98
|
+
for (const record of snapshot()) {
|
|
99
|
+
if (record.state === 'pending' && atMs >= record.expiresAtMs)
|
|
100
|
+
expired.push(record);
|
|
101
|
+
}
|
|
102
|
+
return expired;
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Milestone 1 approval authorization: the originating user on the owning
|
|
108
|
+
* thread, point. Administrative or moderator standing grants nothing.
|
|
109
|
+
*/
|
|
110
|
+
export function authorizeApprovalClick(record, click) {
|
|
111
|
+
if (click.userId !== record.actorUserId || click.threadId !== record.threadId) {
|
|
112
|
+
return { allowed: false, reason: 'not-owner' };
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
allowed: true,
|
|
116
|
+
respond: { rpcId: record.rpcId, sessionId: record.sessionId, approvalId: record.approvalId },
|
|
117
|
+
requestId: record.requestId,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Approval control rendering (design.md §8, task 13.1). A pending DSH
|
|
3
|
+
* approval renders as Allow once / Reject buttons whose custom_ids are
|
|
4
|
+
* opaque registry keys minted per control — session, rpc, approval, tool,
|
|
5
|
+
* and reason data never ride the Discord wire. Visible text reuses the
|
|
6
|
+
* activity surface's safe allowlisted labels (generic fallback) and the
|
|
7
|
+
* outbound mention posture: the host-supplied reason is mention-neutralized
|
|
8
|
+
* and bounded before display.
|
|
9
|
+
*/
|
|
10
|
+
import type { ComponentRegistry } from '../discord/components.js';
|
|
11
|
+
import { DISCORD_SUPPRESS_NOTIFICATIONS_FLAG } from '../policy/disclosure.js';
|
|
12
|
+
/** Discord interactive-component ids (action row = 1, button = 2). */
|
|
13
|
+
declare const ACTION_ROW = 1;
|
|
14
|
+
declare const BUTTON = 2;
|
|
15
|
+
declare const BUTTON_STYLE_SUCCESS = 3;
|
|
16
|
+
declare const BUTTON_STYLE_DANGER = 4;
|
|
17
|
+
export interface ApprovalViewInput {
|
|
18
|
+
registry: ComponentRegistry;
|
|
19
|
+
sessionId: string;
|
|
20
|
+
rpcId: string;
|
|
21
|
+
approvalId: string;
|
|
22
|
+
toolName: string;
|
|
23
|
+
reason?: string | undefined;
|
|
24
|
+
/** The approval deadline; the controls expire with it (13.4). */
|
|
25
|
+
expiresAtMs: number;
|
|
26
|
+
}
|
|
27
|
+
export interface ApprovalButton {
|
|
28
|
+
type: typeof BUTTON;
|
|
29
|
+
style: typeof BUTTON_STYLE_SUCCESS | typeof BUTTON_STYLE_DANGER;
|
|
30
|
+
label: string;
|
|
31
|
+
custom_id: string;
|
|
32
|
+
}
|
|
33
|
+
export interface ApprovalViewPayload {
|
|
34
|
+
content: string;
|
|
35
|
+
flags: typeof DISCORD_SUPPRESS_NOTIFICATIONS_FLAG;
|
|
36
|
+
components: Array<{
|
|
37
|
+
type: typeof ACTION_ROW;
|
|
38
|
+
components: ApprovalButton[];
|
|
39
|
+
}>;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Render one pending approval. Both controls resolve through the registry to
|
|
43
|
+
* `{ approvalId, action, expiresAtMs }`, so routing (13.2/13.3) recovers the
|
|
44
|
+
* pending record from durable state, never from the wire.
|
|
45
|
+
*/
|
|
46
|
+
export declare function renderApprovalControls(input: ApprovalViewInput): ApprovalViewPayload;
|
|
47
|
+
export {};
|