patchwork-os 0.2.0-beta.6.canary.21 → 0.2.0-beta.6.canary.23
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/dist/connectorRoutes.js +384 -0
- package/dist/connectorRoutes.js.map +1 -1
- package/dist/connectors/airtable.d.ts +108 -0
- package/dist/connectors/airtable.js +396 -0
- package/dist/connectors/airtable.js.map +1 -0
- package/dist/connectors/connectorRegistry.js +63 -0
- package/dist/connectors/connectorRegistry.js.map +1 -1
- package/dist/connectors/elasticsearch.d.ts +141 -0
- package/dist/connectors/elasticsearch.js +601 -0
- package/dist/connectors/elasticsearch.js.map +1 -0
- package/dist/connectors/figma.d.ts +130 -0
- package/dist/connectors/figma.js +387 -0
- package/dist/connectors/figma.js.map +1 -0
- package/dist/connectors/gmail.js +9 -0
- package/dist/connectors/gmail.js.map +1 -1
- package/dist/connectors/googleDrive.d.ts +12 -0
- package/dist/connectors/googleDrive.js +27 -0
- package/dist/connectors/googleDrive.js.map +1 -1
- package/dist/connectors/mongodb.d.ts +139 -0
- package/dist/connectors/mongodb.js +455 -0
- package/dist/connectors/mongodb.js.map +1 -0
- package/dist/connectors/postgres.d.ts +127 -0
- package/dist/connectors/postgres.js +512 -0
- package/dist/connectors/postgres.js.map +1 -0
- package/dist/connectors/redis.d.ts +140 -0
- package/dist/connectors/redis.js +571 -0
- package/dist/connectors/redis.js.map +1 -0
- package/dist/connectors/sendgrid.d.ts +102 -0
- package/dist/connectors/sendgrid.js +423 -0
- package/dist/connectors/sendgrid.js.map +1 -0
- package/dist/connectors/twilio.d.ts +118 -0
- package/dist/connectors/twilio.js +475 -0
- package/dist/connectors/twilio.js.map +1 -0
- package/dist/connectors/webflow.d.ts +118 -0
- package/dist/connectors/webflow.js +393 -0
- package/dist/connectors/webflow.js.map +1 -0
- package/dist/recipes/tools/gmail.js +27 -5
- package/dist/recipes/tools/gmail.js.map +1 -1
- package/dist/recipes/tools/googleDrive.js +64 -0
- package/dist/recipes/tools/googleDrive.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Redis connector — read-only operations against a Redis server.
|
|
3
|
+
*
|
|
4
|
+
* Auth: connection URL (redis:// or rediss://) optionally with password.
|
|
5
|
+
* - Stored: getSecretJsonSync("redis") → RedisTokens
|
|
6
|
+
*
|
|
7
|
+
* Tools (READ-ONLY only — no SET/DEL/FLUSHDB/CONFIG mutators):
|
|
8
|
+
* info, dbsize, keys (SCAN-based), type, get, hgetall, lrange,
|
|
9
|
+
* smembers, zrange, ttl, command_run (allowlist-gated)
|
|
10
|
+
*
|
|
11
|
+
* Driver loaded lazily via `await import("redis")` (node-redis v4+).
|
|
12
|
+
* Hermetic tests inject a fake module via `__setRedisModuleForTest`.
|
|
13
|
+
*
|
|
14
|
+
* Extends BaseConnector for unified status, error normalization,
|
|
15
|
+
* token persistence. apiCall is unused because Redis is not HTTP —
|
|
16
|
+
* errors flow through normalizeError directly.
|
|
17
|
+
*/
|
|
18
|
+
import { type AuthContext, BaseConnector, type ConnectorError, type ConnectorStatus } from "./baseConnector.js";
|
|
19
|
+
export interface RedisTokens {
|
|
20
|
+
url: string;
|
|
21
|
+
username?: string;
|
|
22
|
+
password?: string;
|
|
23
|
+
database?: number;
|
|
24
|
+
tls?: boolean;
|
|
25
|
+
connected_at: string;
|
|
26
|
+
}
|
|
27
|
+
/** Minimal subset of the node-redis v4 client surface we exercise. */
|
|
28
|
+
export interface RedisClientLike {
|
|
29
|
+
connect(): Promise<unknown>;
|
|
30
|
+
quit(): Promise<unknown>;
|
|
31
|
+
ping(): Promise<string>;
|
|
32
|
+
info(section?: string): Promise<string>;
|
|
33
|
+
dbSize(): Promise<number>;
|
|
34
|
+
type(key: string): Promise<string>;
|
|
35
|
+
get(key: string): Promise<string | null>;
|
|
36
|
+
hGetAll(key: string): Promise<Record<string, string>>;
|
|
37
|
+
lRange(key: string, start: number, stop: number): Promise<string[]>;
|
|
38
|
+
sMembers(key: string): Promise<string[]>;
|
|
39
|
+
zRangeWithScores(key: string, start: number, stop: number): Promise<Array<{
|
|
40
|
+
value: string;
|
|
41
|
+
score: number;
|
|
42
|
+
}>>;
|
|
43
|
+
zRange(key: string, start: number, stop: number): Promise<string[]>;
|
|
44
|
+
ttl(key: string): Promise<number>;
|
|
45
|
+
scan(cursor: number | string, opts?: {
|
|
46
|
+
MATCH?: string;
|
|
47
|
+
COUNT?: number;
|
|
48
|
+
}): Promise<{
|
|
49
|
+
cursor: number | string;
|
|
50
|
+
keys: string[];
|
|
51
|
+
}>;
|
|
52
|
+
sendCommand(args: string[]): Promise<unknown>;
|
|
53
|
+
on(event: string, listener: (...args: unknown[]) => void): unknown;
|
|
54
|
+
}
|
|
55
|
+
export interface RedisModuleLike {
|
|
56
|
+
createClient(opts: {
|
|
57
|
+
url: string;
|
|
58
|
+
username?: string;
|
|
59
|
+
password?: string;
|
|
60
|
+
database?: number;
|
|
61
|
+
}): RedisClientLike;
|
|
62
|
+
}
|
|
63
|
+
/** Test-only: inject a fake `redis` module so tests run hermetically. */
|
|
64
|
+
export declare function __setRedisModuleForTest(mod: RedisModuleLike | null): void;
|
|
65
|
+
/**
|
|
66
|
+
* Commands the connector permits. First token is matched case-insensitively.
|
|
67
|
+
* Multi-word commands (e.g. "DEBUG OBJECT") match against the first token
|
|
68
|
+
* AND the joined first-two-token form so callers can issue either shape.
|
|
69
|
+
*/
|
|
70
|
+
export declare const READ_ONLY_COMMANDS: ReadonlySet<string>;
|
|
71
|
+
export declare function isReadOnlyCommand(cmd: string, args?: string[]): boolean;
|
|
72
|
+
export declare function loadTokens(): RedisTokens | null;
|
|
73
|
+
export declare function saveTokens(tokens: RedisTokens): void;
|
|
74
|
+
export declare function clearTokens(): void;
|
|
75
|
+
export declare function parseInfo(raw: string): Record<string, string>;
|
|
76
|
+
export declare class RedisConnector extends BaseConnector {
|
|
77
|
+
readonly providerName = "redis";
|
|
78
|
+
protected cachedTokens: RedisTokens | null;
|
|
79
|
+
private client;
|
|
80
|
+
protected getOAuthConfig(): null;
|
|
81
|
+
authenticate(): Promise<AuthContext>;
|
|
82
|
+
/** Get (or lazily create + connect) the singleton client. */
|
|
83
|
+
getClient(): Promise<RedisClientLike>;
|
|
84
|
+
/** Tear down the client (used by disconnect HTTP handler + tests). */
|
|
85
|
+
disconnect(): Promise<void>;
|
|
86
|
+
healthCheck(): Promise<{
|
|
87
|
+
ok: boolean;
|
|
88
|
+
error?: ConnectorError;
|
|
89
|
+
}>;
|
|
90
|
+
normalizeError(error: unknown): ConnectorError;
|
|
91
|
+
getStatus(): ConnectorStatus;
|
|
92
|
+
info(section?: string): Promise<Record<string, string>>;
|
|
93
|
+
dbsize(): Promise<number>;
|
|
94
|
+
/**
|
|
95
|
+
* SCAN-based key iteration. Never uses `KEYS *` because that blocks the
|
|
96
|
+
* server. Stops once `limit` keys are gathered OR the cursor wraps to 0.
|
|
97
|
+
*/
|
|
98
|
+
keys(pattern: string, limit?: number): Promise<string[]>;
|
|
99
|
+
type(key: string): Promise<string>;
|
|
100
|
+
get(key: string): Promise<string | null>;
|
|
101
|
+
hgetall(key: string): Promise<Record<string, string>>;
|
|
102
|
+
lrange(key: string, start?: number, stop?: number): Promise<string[]>;
|
|
103
|
+
smembers(key: string, limit?: number): Promise<string[]>;
|
|
104
|
+
zrange(key: string, start?: number, stop?: number, withScores?: boolean): Promise<Array<{
|
|
105
|
+
value: string;
|
|
106
|
+
score: number;
|
|
107
|
+
}> | string[]>;
|
|
108
|
+
ttl(key: string): Promise<number>;
|
|
109
|
+
/**
|
|
110
|
+
* Generic escape hatch. The command MUST appear in the read-only allowlist.
|
|
111
|
+
* Anything else is rejected before it ever touches the wire.
|
|
112
|
+
*/
|
|
113
|
+
command_run(cmd: string, args?: string[]): Promise<unknown>;
|
|
114
|
+
}
|
|
115
|
+
/** Hide the password from any redis:// URL before logging or display. */
|
|
116
|
+
export declare function redactUrl(url: string): string;
|
|
117
|
+
export declare function getRedisConnector(): RedisConnector;
|
|
118
|
+
export declare function resetRedisConnector(): Promise<void>;
|
|
119
|
+
export { loadTokens as isConnected };
|
|
120
|
+
export interface ConnectorHandlerResult {
|
|
121
|
+
status: number;
|
|
122
|
+
body: string;
|
|
123
|
+
contentType?: string;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* POST /connections/redis/connect
|
|
127
|
+
* { url: "redis://...", username?, password?, database?, tls? }
|
|
128
|
+
*
|
|
129
|
+
* Verifies the URL by opening a transient client and PINGing. Stores tokens
|
|
130
|
+
* on success.
|
|
131
|
+
*/
|
|
132
|
+
export declare function handleRedisConnect(body: string): Promise<ConnectorHandlerResult>;
|
|
133
|
+
/**
|
|
134
|
+
* POST /connections/redis/test — verify stored connection still works.
|
|
135
|
+
*/
|
|
136
|
+
export declare function handleRedisTest(): Promise<ConnectorHandlerResult>;
|
|
137
|
+
/**
|
|
138
|
+
* DELETE /connections/redis — remove stored tokens and tear down the client.
|
|
139
|
+
*/
|
|
140
|
+
export declare function handleRedisDisconnect(): Promise<ConnectorHandlerResult>;
|
|
@@ -0,0 +1,571 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Redis connector — read-only operations against a Redis server.
|
|
3
|
+
*
|
|
4
|
+
* Auth: connection URL (redis:// or rediss://) optionally with password.
|
|
5
|
+
* - Stored: getSecretJsonSync("redis") → RedisTokens
|
|
6
|
+
*
|
|
7
|
+
* Tools (READ-ONLY only — no SET/DEL/FLUSHDB/CONFIG mutators):
|
|
8
|
+
* info, dbsize, keys (SCAN-based), type, get, hgetall, lrange,
|
|
9
|
+
* smembers, zrange, ttl, command_run (allowlist-gated)
|
|
10
|
+
*
|
|
11
|
+
* Driver loaded lazily via `await import("redis")` (node-redis v4+).
|
|
12
|
+
* Hermetic tests inject a fake module via `__setRedisModuleForTest`.
|
|
13
|
+
*
|
|
14
|
+
* Extends BaseConnector for unified status, error normalization,
|
|
15
|
+
* token persistence. apiCall is unused because Redis is not HTTP —
|
|
16
|
+
* errors flow through normalizeError directly.
|
|
17
|
+
*/
|
|
18
|
+
import { BaseConnector, } from "./baseConnector.js";
|
|
19
|
+
import { deleteSecretJsonSync, getSecretJsonSync, storeSecretJsonSync, } from "./tokenStorage.js";
|
|
20
|
+
// ------------------------------------------------------------------ lazy driver
|
|
21
|
+
let _injectedModule = null;
|
|
22
|
+
/** Test-only: inject a fake `redis` module so tests run hermetically. */
|
|
23
|
+
export function __setRedisModuleForTest(mod) {
|
|
24
|
+
_injectedModule = mod;
|
|
25
|
+
}
|
|
26
|
+
async function loadRedisModule() {
|
|
27
|
+
if (_injectedModule)
|
|
28
|
+
return _injectedModule;
|
|
29
|
+
try {
|
|
30
|
+
// The `redis` package is an optional peer — typecheck environments may
|
|
31
|
+
// not have it installed. The runtime guard below catches the missing
|
|
32
|
+
// module and tells the operator to install it.
|
|
33
|
+
// @ts-expect-error optional peer; resolved at runtime
|
|
34
|
+
const mod = (await import("redis"));
|
|
35
|
+
if (!mod || typeof mod.createClient !== "function") {
|
|
36
|
+
throw new Error("redis module missing createClient export");
|
|
37
|
+
}
|
|
38
|
+
return mod;
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
42
|
+
throw new Error(`Redis driver not installed (${msg}). Run: npm install redis`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
// ------------------------------------------------------------------ read-only allowlist
|
|
46
|
+
/**
|
|
47
|
+
* Commands the connector permits. First token is matched case-insensitively.
|
|
48
|
+
* Multi-word commands (e.g. "DEBUG OBJECT") match against the first token
|
|
49
|
+
* AND the joined first-two-token form so callers can issue either shape.
|
|
50
|
+
*/
|
|
51
|
+
export const READ_ONLY_COMMANDS = new Set([
|
|
52
|
+
"GET",
|
|
53
|
+
"MGET",
|
|
54
|
+
"EXISTS",
|
|
55
|
+
"TYPE",
|
|
56
|
+
"TTL",
|
|
57
|
+
"PTTL",
|
|
58
|
+
"HGET",
|
|
59
|
+
"HGETALL",
|
|
60
|
+
"HMGET",
|
|
61
|
+
"HKEYS",
|
|
62
|
+
"HVALS",
|
|
63
|
+
"HLEN",
|
|
64
|
+
"HEXISTS",
|
|
65
|
+
"LRANGE",
|
|
66
|
+
"LLEN",
|
|
67
|
+
"LINDEX",
|
|
68
|
+
"SMEMBERS",
|
|
69
|
+
"SCARD",
|
|
70
|
+
"SISMEMBER",
|
|
71
|
+
"ZRANGE",
|
|
72
|
+
"ZREVRANGE",
|
|
73
|
+
"ZSCORE",
|
|
74
|
+
"ZCARD",
|
|
75
|
+
"ZRANGEBYSCORE",
|
|
76
|
+
"SCAN",
|
|
77
|
+
"HSCAN",
|
|
78
|
+
"SSCAN",
|
|
79
|
+
"ZSCAN",
|
|
80
|
+
"DBSIZE",
|
|
81
|
+
"INFO",
|
|
82
|
+
"PING",
|
|
83
|
+
"CLIENT",
|
|
84
|
+
"MEMORY",
|
|
85
|
+
"DEBUG OBJECT",
|
|
86
|
+
]);
|
|
87
|
+
export function isReadOnlyCommand(cmd, args = []) {
|
|
88
|
+
if (typeof cmd !== "string" || cmd.length === 0)
|
|
89
|
+
return false;
|
|
90
|
+
const head = cmd.trim().toUpperCase();
|
|
91
|
+
if (READ_ONLY_COMMANDS.has(head))
|
|
92
|
+
return true;
|
|
93
|
+
// Two-word form (e.g. "DEBUG OBJECT")
|
|
94
|
+
if (args.length > 0 && typeof args[0] === "string") {
|
|
95
|
+
const joined = `${head} ${args[0].toUpperCase()}`;
|
|
96
|
+
if (READ_ONLY_COMMANDS.has(joined))
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
// ------------------------------------------------------------------ token helpers
|
|
102
|
+
export function loadTokens() {
|
|
103
|
+
return getSecretJsonSync("redis");
|
|
104
|
+
}
|
|
105
|
+
export function saveTokens(tokens) {
|
|
106
|
+
storeSecretJsonSync("redis", tokens);
|
|
107
|
+
}
|
|
108
|
+
export function clearTokens() {
|
|
109
|
+
deleteSecretJsonSync("redis");
|
|
110
|
+
}
|
|
111
|
+
// ------------------------------------------------------------------ INFO parsing
|
|
112
|
+
export function parseInfo(raw) {
|
|
113
|
+
const out = {};
|
|
114
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
115
|
+
if (!line || line.startsWith("#"))
|
|
116
|
+
continue;
|
|
117
|
+
const idx = line.indexOf(":");
|
|
118
|
+
if (idx <= 0)
|
|
119
|
+
continue;
|
|
120
|
+
const k = line.slice(0, idx).trim();
|
|
121
|
+
const v = line.slice(idx + 1).trim();
|
|
122
|
+
if (k)
|
|
123
|
+
out[k] = v;
|
|
124
|
+
}
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
127
|
+
// ------------------------------------------------------------------ connector
|
|
128
|
+
export class RedisConnector extends BaseConnector {
|
|
129
|
+
providerName = "redis";
|
|
130
|
+
cachedTokens = null;
|
|
131
|
+
client = null;
|
|
132
|
+
getOAuthConfig() {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
async authenticate() {
|
|
136
|
+
const tokens = loadTokens();
|
|
137
|
+
if (!tokens) {
|
|
138
|
+
throw new Error("Redis not connected. Run: patchwork connect redis (provide URL)");
|
|
139
|
+
}
|
|
140
|
+
this.cachedTokens = tokens;
|
|
141
|
+
// Redis isn't a bearer-token API; the auth context is informational.
|
|
142
|
+
return { token: tokens.password ?? "" };
|
|
143
|
+
}
|
|
144
|
+
/** Get (or lazily create + connect) the singleton client. */
|
|
145
|
+
async getClient() {
|
|
146
|
+
if (this.client)
|
|
147
|
+
return this.client;
|
|
148
|
+
const tokens = this.cachedTokens ?? loadTokens();
|
|
149
|
+
if (!tokens) {
|
|
150
|
+
throw new Error("Redis not connected. Run: patchwork connect redis (provide URL)");
|
|
151
|
+
}
|
|
152
|
+
this.cachedTokens = tokens;
|
|
153
|
+
const mod = await loadRedisModule();
|
|
154
|
+
const client = mod.createClient({
|
|
155
|
+
url: tokens.url,
|
|
156
|
+
username: tokens.username,
|
|
157
|
+
password: tokens.password,
|
|
158
|
+
database: tokens.database,
|
|
159
|
+
});
|
|
160
|
+
// Swallow background error events so an idle connection drop doesn't
|
|
161
|
+
// bubble up as an unhandled error; commands surface their own errors.
|
|
162
|
+
try {
|
|
163
|
+
client.on("error", () => { });
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
/* some fakes don't implement .on */
|
|
167
|
+
}
|
|
168
|
+
await client.connect();
|
|
169
|
+
this.client = client;
|
|
170
|
+
return client;
|
|
171
|
+
}
|
|
172
|
+
/** Tear down the client (used by disconnect HTTP handler + tests). */
|
|
173
|
+
async disconnect() {
|
|
174
|
+
const c = this.client;
|
|
175
|
+
this.client = null;
|
|
176
|
+
if (!c)
|
|
177
|
+
return;
|
|
178
|
+
try {
|
|
179
|
+
await c.quit();
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
/* ignore quit errors */
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
async healthCheck() {
|
|
186
|
+
try {
|
|
187
|
+
const c = await this.getClient();
|
|
188
|
+
const pong = await c.ping();
|
|
189
|
+
if (typeof pong === "string" && pong.toUpperCase() === "PONG") {
|
|
190
|
+
return { ok: true };
|
|
191
|
+
}
|
|
192
|
+
return {
|
|
193
|
+
ok: false,
|
|
194
|
+
error: {
|
|
195
|
+
code: "provider_error",
|
|
196
|
+
message: `Unexpected PING reply: ${String(pong)}`,
|
|
197
|
+
retryable: false,
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
catch (err) {
|
|
202
|
+
return { ok: false, error: this.normalizeError(err) };
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
normalizeError(error) {
|
|
206
|
+
const msg = error instanceof Error ? error.message : String(error ?? "unknown");
|
|
207
|
+
if (/WRONGPASS|NOAUTH/i.test(msg)) {
|
|
208
|
+
return {
|
|
209
|
+
code: "auth_expired",
|
|
210
|
+
message: "Redis authentication failed",
|
|
211
|
+
retryable: false,
|
|
212
|
+
suggestedAction: "Reconnect: patchwork connect redis",
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
if (/NOPERM/i.test(msg)) {
|
|
216
|
+
return {
|
|
217
|
+
code: "permission_denied",
|
|
218
|
+
message: "Redis user lacks permission for this command",
|
|
219
|
+
retryable: false,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
if (/ECONNREFUSED|ETIMEDOUT|ENOTFOUND|EHOSTUNREACH/i.test(msg)) {
|
|
223
|
+
return {
|
|
224
|
+
code: "network_error",
|
|
225
|
+
message: `Cannot reach Redis server: ${msg}`,
|
|
226
|
+
retryable: true,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
return {
|
|
230
|
+
code: "provider_error",
|
|
231
|
+
message: msg,
|
|
232
|
+
retryable: false,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
getStatus() {
|
|
236
|
+
const tokens = loadTokens();
|
|
237
|
+
return {
|
|
238
|
+
id: "redis",
|
|
239
|
+
status: tokens ? "connected" : "disconnected",
|
|
240
|
+
lastSync: tokens?.connected_at,
|
|
241
|
+
workspace: tokens ? redactUrl(tokens.url) : undefined,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
// ---------------------------------------------------------------- read ops
|
|
245
|
+
async info(section) {
|
|
246
|
+
const c = await this.getClient();
|
|
247
|
+
try {
|
|
248
|
+
const raw = section ? await c.info(section) : await c.info();
|
|
249
|
+
return parseInfo(raw);
|
|
250
|
+
}
|
|
251
|
+
catch (err) {
|
|
252
|
+
throw new Error(this.normalizeError(err).message);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
async dbsize() {
|
|
256
|
+
const c = await this.getClient();
|
|
257
|
+
try {
|
|
258
|
+
return await c.dbSize();
|
|
259
|
+
}
|
|
260
|
+
catch (err) {
|
|
261
|
+
throw new Error(this.normalizeError(err).message);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* SCAN-based key iteration. Never uses `KEYS *` because that blocks the
|
|
266
|
+
* server. Stops once `limit` keys are gathered OR the cursor wraps to 0.
|
|
267
|
+
*/
|
|
268
|
+
async keys(pattern, limit = 100) {
|
|
269
|
+
if (typeof pattern !== "string" || pattern.length === 0) {
|
|
270
|
+
throw new Error("pattern must be a non-empty string");
|
|
271
|
+
}
|
|
272
|
+
const cap = Math.max(1, Math.min(limit, 10_000));
|
|
273
|
+
const c = await this.getClient();
|
|
274
|
+
const collected = [];
|
|
275
|
+
let cursor = 0;
|
|
276
|
+
try {
|
|
277
|
+
// Guard against pathological servers that never wrap by capping iterations.
|
|
278
|
+
for (let i = 0; i < 1000; i++) {
|
|
279
|
+
const res = await c.scan(cursor, { MATCH: pattern, COUNT: 100 });
|
|
280
|
+
if (Array.isArray(res.keys)) {
|
|
281
|
+
for (const k of res.keys) {
|
|
282
|
+
collected.push(k);
|
|
283
|
+
if (collected.length >= cap)
|
|
284
|
+
return collected;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
cursor = res.cursor;
|
|
288
|
+
// node-redis returns cursor 0 (number or string) when iteration done.
|
|
289
|
+
if (cursor === 0 || cursor === "0")
|
|
290
|
+
break;
|
|
291
|
+
}
|
|
292
|
+
return collected;
|
|
293
|
+
}
|
|
294
|
+
catch (err) {
|
|
295
|
+
throw new Error(this.normalizeError(err).message);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
async type(key) {
|
|
299
|
+
const c = await this.getClient();
|
|
300
|
+
try {
|
|
301
|
+
return await c.type(key);
|
|
302
|
+
}
|
|
303
|
+
catch (err) {
|
|
304
|
+
throw new Error(this.normalizeError(err).message);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
async get(key) {
|
|
308
|
+
const c = await this.getClient();
|
|
309
|
+
try {
|
|
310
|
+
const t = await c.type(key);
|
|
311
|
+
if (t !== "string" && t !== "none") {
|
|
312
|
+
throw new Error(`WRONGTYPE: GET only supports string keys (key is ${t})`);
|
|
313
|
+
}
|
|
314
|
+
return await c.get(key);
|
|
315
|
+
}
|
|
316
|
+
catch (err) {
|
|
317
|
+
throw new Error(this.normalizeError(err).message);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
async hgetall(key) {
|
|
321
|
+
const c = await this.getClient();
|
|
322
|
+
try {
|
|
323
|
+
return await c.hGetAll(key);
|
|
324
|
+
}
|
|
325
|
+
catch (err) {
|
|
326
|
+
throw new Error(this.normalizeError(err).message);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
async lrange(key, start = 0, stop = 99) {
|
|
330
|
+
const c = await this.getClient();
|
|
331
|
+
try {
|
|
332
|
+
return await c.lRange(key, start, stop);
|
|
333
|
+
}
|
|
334
|
+
catch (err) {
|
|
335
|
+
throw new Error(this.normalizeError(err).message);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
async smembers(key, limit = 100) {
|
|
339
|
+
const c = await this.getClient();
|
|
340
|
+
try {
|
|
341
|
+
const all = await c.sMembers(key);
|
|
342
|
+
const cap = Math.max(1, Math.min(limit, 10_000));
|
|
343
|
+
return all.slice(0, cap);
|
|
344
|
+
}
|
|
345
|
+
catch (err) {
|
|
346
|
+
throw new Error(this.normalizeError(err).message);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
async zrange(key, start = 0, stop = 99, withScores = true) {
|
|
350
|
+
const c = await this.getClient();
|
|
351
|
+
try {
|
|
352
|
+
if (withScores)
|
|
353
|
+
return await c.zRangeWithScores(key, start, stop);
|
|
354
|
+
return await c.zRange(key, start, stop);
|
|
355
|
+
}
|
|
356
|
+
catch (err) {
|
|
357
|
+
throw new Error(this.normalizeError(err).message);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
async ttl(key) {
|
|
361
|
+
const c = await this.getClient();
|
|
362
|
+
try {
|
|
363
|
+
return await c.ttl(key);
|
|
364
|
+
}
|
|
365
|
+
catch (err) {
|
|
366
|
+
throw new Error(this.normalizeError(err).message);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* Generic escape hatch. The command MUST appear in the read-only allowlist.
|
|
371
|
+
* Anything else is rejected before it ever touches the wire.
|
|
372
|
+
*/
|
|
373
|
+
async command_run(cmd, args = []) {
|
|
374
|
+
if (!isReadOnlyCommand(cmd, args)) {
|
|
375
|
+
const err = {
|
|
376
|
+
code: "permission_denied",
|
|
377
|
+
message: `Command "${cmd}" is not in the read-only allowlist`,
|
|
378
|
+
retryable: false,
|
|
379
|
+
suggestedAction: "Patchwork Redis connector is read-only; use a separate tool for mutations.",
|
|
380
|
+
};
|
|
381
|
+
throw new Error(err.message);
|
|
382
|
+
}
|
|
383
|
+
if (!Array.isArray(args)) {
|
|
384
|
+
throw new Error("args must be an array of strings");
|
|
385
|
+
}
|
|
386
|
+
for (const a of args) {
|
|
387
|
+
if (typeof a !== "string") {
|
|
388
|
+
throw new Error("all args must be strings");
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
const c = await this.getClient();
|
|
392
|
+
try {
|
|
393
|
+
return await c.sendCommand([cmd, ...args]);
|
|
394
|
+
}
|
|
395
|
+
catch (err) {
|
|
396
|
+
throw new Error(this.normalizeError(err).message);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
// ------------------------------------------------------------------ helpers
|
|
401
|
+
/** Hide the password from any redis:// URL before logging or display. */
|
|
402
|
+
export function redactUrl(url) {
|
|
403
|
+
try {
|
|
404
|
+
const u = new URL(url);
|
|
405
|
+
if (u.password)
|
|
406
|
+
u.password = "***";
|
|
407
|
+
return u.toString();
|
|
408
|
+
}
|
|
409
|
+
catch {
|
|
410
|
+
return url;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
// ------------------------------------------------------------------ singleton
|
|
414
|
+
let _instance = null;
|
|
415
|
+
export function getRedisConnector() {
|
|
416
|
+
if (!_instance)
|
|
417
|
+
_instance = new RedisConnector();
|
|
418
|
+
return _instance;
|
|
419
|
+
}
|
|
420
|
+
export async function resetRedisConnector() {
|
|
421
|
+
if (_instance) {
|
|
422
|
+
try {
|
|
423
|
+
await _instance.disconnect();
|
|
424
|
+
}
|
|
425
|
+
catch {
|
|
426
|
+
/* ignore */
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
_instance = null;
|
|
430
|
+
}
|
|
431
|
+
export { loadTokens as isConnected };
|
|
432
|
+
function jsonRes(status, body) {
|
|
433
|
+
return {
|
|
434
|
+
status,
|
|
435
|
+
contentType: "application/json",
|
|
436
|
+
body: JSON.stringify(body),
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* POST /connections/redis/connect
|
|
441
|
+
* { url: "redis://...", username?, password?, database?, tls? }
|
|
442
|
+
*
|
|
443
|
+
* Verifies the URL by opening a transient client and PINGing. Stores tokens
|
|
444
|
+
* on success.
|
|
445
|
+
*/
|
|
446
|
+
export async function handleRedisConnect(body) {
|
|
447
|
+
let parsed;
|
|
448
|
+
try {
|
|
449
|
+
parsed = JSON.parse(body);
|
|
450
|
+
}
|
|
451
|
+
catch {
|
|
452
|
+
return jsonRes(400, { ok: false, error: "Invalid JSON body" });
|
|
453
|
+
}
|
|
454
|
+
if (typeof parsed.url !== "string" || parsed.url.length === 0) {
|
|
455
|
+
return jsonRes(400, {
|
|
456
|
+
ok: false,
|
|
457
|
+
error: 'Missing "url" (e.g. redis://localhost:6379 or rediss://...)',
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
let parsedUrl;
|
|
461
|
+
try {
|
|
462
|
+
parsedUrl = new URL(parsed.url);
|
|
463
|
+
}
|
|
464
|
+
catch {
|
|
465
|
+
return jsonRes(400, { ok: false, error: "Malformed Redis URL" });
|
|
466
|
+
}
|
|
467
|
+
if (parsedUrl.protocol !== "redis:" && parsedUrl.protocol !== "rediss:") {
|
|
468
|
+
return jsonRes(400, {
|
|
469
|
+
ok: false,
|
|
470
|
+
error: 'URL must use the "redis://" or "rediss://" scheme',
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
const tokens = {
|
|
474
|
+
url: parsed.url,
|
|
475
|
+
username: typeof parsed.username === "string" ? parsed.username : undefined,
|
|
476
|
+
password: typeof parsed.password === "string" ? parsed.password : undefined,
|
|
477
|
+
database: typeof parsed.database === "number" && Number.isInteger(parsed.database)
|
|
478
|
+
? parsed.database
|
|
479
|
+
: undefined,
|
|
480
|
+
tls: parsed.tls === true || parsedUrl.protocol === "rediss:",
|
|
481
|
+
connected_at: new Date().toISOString(),
|
|
482
|
+
};
|
|
483
|
+
let mod;
|
|
484
|
+
try {
|
|
485
|
+
mod = await loadRedisModule();
|
|
486
|
+
}
|
|
487
|
+
catch (err) {
|
|
488
|
+
return jsonRes(500, {
|
|
489
|
+
ok: false,
|
|
490
|
+
error: err instanceof Error ? err.message : String(err),
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
const client = mod.createClient({
|
|
494
|
+
url: tokens.url,
|
|
495
|
+
username: tokens.username,
|
|
496
|
+
password: tokens.password,
|
|
497
|
+
database: tokens.database,
|
|
498
|
+
});
|
|
499
|
+
try {
|
|
500
|
+
try {
|
|
501
|
+
client.on("error", () => { });
|
|
502
|
+
}
|
|
503
|
+
catch {
|
|
504
|
+
/* fakes may lack .on */
|
|
505
|
+
}
|
|
506
|
+
await client.connect();
|
|
507
|
+
const pong = await client.ping();
|
|
508
|
+
if (typeof pong !== "string" || pong.toUpperCase() !== "PONG") {
|
|
509
|
+
return jsonRes(401, {
|
|
510
|
+
ok: false,
|
|
511
|
+
error: `Unexpected PING reply: ${String(pong)}`,
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
catch (err) {
|
|
516
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
517
|
+
const status = /WRONGPASS|NOAUTH/i.test(msg) ? 401 : 502;
|
|
518
|
+
try {
|
|
519
|
+
await client.quit();
|
|
520
|
+
}
|
|
521
|
+
catch {
|
|
522
|
+
/* ignore */
|
|
523
|
+
}
|
|
524
|
+
return jsonRes(status, { ok: false, error: msg });
|
|
525
|
+
}
|
|
526
|
+
try {
|
|
527
|
+
await client.quit();
|
|
528
|
+
}
|
|
529
|
+
catch {
|
|
530
|
+
/* ignore */
|
|
531
|
+
}
|
|
532
|
+
saveTokens(tokens);
|
|
533
|
+
await resetRedisConnector();
|
|
534
|
+
return jsonRes(200, {
|
|
535
|
+
ok: true,
|
|
536
|
+
workspace: redactUrl(tokens.url),
|
|
537
|
+
connectedAt: tokens.connected_at,
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
/**
|
|
541
|
+
* POST /connections/redis/test — verify stored connection still works.
|
|
542
|
+
*/
|
|
543
|
+
export async function handleRedisTest() {
|
|
544
|
+
const tokens = loadTokens();
|
|
545
|
+
if (!tokens) {
|
|
546
|
+
return jsonRes(400, { ok: false, error: "Redis not connected" });
|
|
547
|
+
}
|
|
548
|
+
try {
|
|
549
|
+
const connector = getRedisConnector();
|
|
550
|
+
const check = await connector.healthCheck();
|
|
551
|
+
return jsonRes(check.ok ? 200 : 401, {
|
|
552
|
+
ok: check.ok,
|
|
553
|
+
...(check.ok ? {} : { error: check.error?.message }),
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
catch (err) {
|
|
557
|
+
return jsonRes(500, {
|
|
558
|
+
ok: false,
|
|
559
|
+
error: err instanceof Error ? err.message : String(err),
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
/**
|
|
564
|
+
* DELETE /connections/redis — remove stored tokens and tear down the client.
|
|
565
|
+
*/
|
|
566
|
+
export async function handleRedisDisconnect() {
|
|
567
|
+
clearTokens();
|
|
568
|
+
await resetRedisConnector();
|
|
569
|
+
return jsonRes(200, { ok: true });
|
|
570
|
+
}
|
|
571
|
+
//# sourceMappingURL=redis.js.map
|