@reddb-io/client 1.0.1
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 +661 -0
- package/README.md +97 -0
- package/index.d.ts +223 -0
- package/package.json +49 -0
- package/postinstall.js +97 -0
- package/src/cache.js +137 -0
- package/src/config.js +66 -0
- package/src/embedded-rejection.js +90 -0
- package/src/http.js +200 -0
- package/src/index.js +336 -0
- package/src/internal/asset-fetcher/asset-name.js +37 -0
- package/src/internal/asset-fetcher/checksum.js +23 -0
- package/src/internal/asset-fetcher/download.js +89 -0
- package/src/internal/asset-fetcher/index.js +52 -0
- package/src/kv.js +70 -0
- package/src/protocol.js +157 -0
- package/src/redwire.js +723 -0
- package/src/url.js +271 -0
- package/src/vault.js +58 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Embedded-URI rejection for the thin remote-only client.
|
|
3
|
+
*
|
|
4
|
+
* The `@reddb-io/client` package ships only the `red_client` binary,
|
|
5
|
+
* which has no embedded engine. Any URI that asks for an in-memory
|
|
6
|
+
* or file-backed database must be rejected at parse time with the
|
|
7
|
+
* same wording the Rust `red_client` binary prints, so users get a
|
|
8
|
+
* uniform error across language drivers and the CLI.
|
|
9
|
+
*
|
|
10
|
+
* Mirrors `is_embedded_uri` in
|
|
11
|
+
* `crates/reddb-client/src/bin/red_client.rs` (the rejected forms):
|
|
12
|
+
*
|
|
13
|
+
* "red://" — bare red:// with no host
|
|
14
|
+
* "red:" — degenerate
|
|
15
|
+
* "red:///" — explicit empty path
|
|
16
|
+
* "red:///<path>" — any red:// URL with a leading-slash path
|
|
17
|
+
* "red://:memory" — SQLite-style alias
|
|
18
|
+
* "red://:memory:" — SQLite-style alias
|
|
19
|
+
*
|
|
20
|
+
* The legacy `memory://`, `memory:`, and `file://<path>` schemes are
|
|
21
|
+
* also rejected because they were always shorthand for the embedded
|
|
22
|
+
* engine.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { RedDBError } from './protocol.js'
|
|
26
|
+
|
|
27
|
+
/** Wording is intentionally identical to the Rust binary stderr message. */
|
|
28
|
+
export const EMBEDDED_REJECTION_MESSAGE =
|
|
29
|
+
'embedded schemes (memory:// / file://) are not supported.\n'
|
|
30
|
+
+ 'Use the full `red` binary for in-memory or file-backed engines.'
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Specialised error raised when an embedded URI is passed to the
|
|
34
|
+
* thin client. Always carries `code === 'EmbeddedNotSupported'` and
|
|
35
|
+
* the wording from `EMBEDDED_REJECTION_MESSAGE`, surfacing the same
|
|
36
|
+
* actionable hint as the underlying Rust binary.
|
|
37
|
+
*/
|
|
38
|
+
export class EmbeddedNotSupported extends RedDBError {
|
|
39
|
+
constructor(uri) {
|
|
40
|
+
super('EmbeddedNotSupported', EMBEDDED_REJECTION_MESSAGE, { uri })
|
|
41
|
+
this.name = 'EmbeddedNotSupported'
|
|
42
|
+
this.uri = uri
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Return true when `uri` selects the embedded engine.
|
|
48
|
+
*
|
|
49
|
+
* @param {string} uri
|
|
50
|
+
* @returns {boolean}
|
|
51
|
+
*/
|
|
52
|
+
export function isEmbeddedUri(uri) {
|
|
53
|
+
if (typeof uri !== 'string') return false
|
|
54
|
+
const trimmed = uri.trim()
|
|
55
|
+
if (
|
|
56
|
+
trimmed === 'red://'
|
|
57
|
+
|| trimmed === 'red:'
|
|
58
|
+
|| trimmed === 'red:/'
|
|
59
|
+
|| trimmed === 'red:///'
|
|
60
|
+
|| trimmed === 'red://:memory'
|
|
61
|
+
|| trimmed === 'red://:memory:'
|
|
62
|
+
) {
|
|
63
|
+
return true
|
|
64
|
+
}
|
|
65
|
+
// Any `red:///<path>` form is the embedded persistent engine.
|
|
66
|
+
if (trimmed.startsWith('red:///')) return true
|
|
67
|
+
// Legacy shorthands that always meant embedded.
|
|
68
|
+
if (trimmed === 'memory://' || trimmed === 'memory:') return true
|
|
69
|
+
if (trimmed.startsWith('file://')) return true
|
|
70
|
+
return false
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Throws `EmbeddedNotSupported` if `uri` is an embedded shape.
|
|
75
|
+
* Otherwise returns the trimmed URI for downstream consumption.
|
|
76
|
+
*
|
|
77
|
+
* @param {string} uri
|
|
78
|
+
* @returns {string}
|
|
79
|
+
*/
|
|
80
|
+
export function rejectEmbeddedUri(uri) {
|
|
81
|
+
if (typeof uri !== 'string' || uri.length === 0) {
|
|
82
|
+
throw new TypeError(
|
|
83
|
+
"connect() requires a URI string (e.g. 'red://localhost:5050' or 'grpc://host:5055')",
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
if (isEmbeddedUri(uri)) {
|
|
87
|
+
throw new EmbeddedNotSupported(uri)
|
|
88
|
+
}
|
|
89
|
+
return uri.trim()
|
|
90
|
+
}
|
package/src/http.js
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP transport for the JS driver.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors the public surface of `RpcClient` (call, close) but talks
|
|
5
|
+
* straight to the RedDB HTTP server via fetch() — no binary spawn.
|
|
6
|
+
* Each `RedDB` method is mapped to a REST endpoint; method names
|
|
7
|
+
* stay identical to the JSON-RPC ones so `RedDB` doesn't need to
|
|
8
|
+
* know which transport it's using.
|
|
9
|
+
*
|
|
10
|
+
* Endpoint mapping (server-side defined in src/server/routing.rs):
|
|
11
|
+
*
|
|
12
|
+
* query / explain → POST /query, POST /query/explain
|
|
13
|
+
* insert → POST /collections/:name/rows
|
|
14
|
+
* bulk_insert → POST /collections/:name/bulk/rows
|
|
15
|
+
* get → GET /collections/:name/{id} (entity scan + filter)
|
|
16
|
+
* delete → DELETE /collections/:name/{id}
|
|
17
|
+
* health → GET /health
|
|
18
|
+
* version → GET /admin/version
|
|
19
|
+
* auth.login → POST /auth/login
|
|
20
|
+
* auth.whoami → GET /auth/whoami
|
|
21
|
+
* auth.create_api_key → POST /auth/api-keys
|
|
22
|
+
* auth.revoke_api_key → DELETE /auth/api-keys/:key
|
|
23
|
+
* auth.change_password→ POST /auth/change-password
|
|
24
|
+
*
|
|
25
|
+
* cache.get → GET /cache/ns/:ns/:key
|
|
26
|
+
* cache.put → PUT /cache/ns/:ns/:key
|
|
27
|
+
* cache.exists → GET /cache/ns/:ns/:key/exists
|
|
28
|
+
* cache.invalidate → DELETE /cache/ns/:ns/:key
|
|
29
|
+
* cache.invalidate_prefix → DELETE /cache/ns/:ns?prefix=...
|
|
30
|
+
* cache.invalidate_tags → DELETE /cache/ns/:ns/tags (body: {tags})
|
|
31
|
+
* cache.flush_namespace → POST /admin/blob_cache/flush_namespace
|
|
32
|
+
*
|
|
33
|
+
* Auth: every request after construction carries `Authorization:
|
|
34
|
+
* Bearer <token>` (when set). `setToken(token)` updates it in
|
|
35
|
+
* place — used by the login flow that exchanges credentials for
|
|
36
|
+
* a fresh bearer.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import { RedDBError } from './protocol.js'
|
|
40
|
+
|
|
41
|
+
export class HttpRpcClient {
|
|
42
|
+
/**
|
|
43
|
+
* @param {object} opts
|
|
44
|
+
* @param {string} opts.baseUrl Server origin, e.g. 'https://reddb.example.com:8443'
|
|
45
|
+
* @param {string} [opts.token] Bearer token / API key
|
|
46
|
+
*/
|
|
47
|
+
constructor({ baseUrl, token }) {
|
|
48
|
+
if (typeof baseUrl !== 'string' || baseUrl.length === 0) {
|
|
49
|
+
throw new TypeError('HttpRpcClient: baseUrl required')
|
|
50
|
+
}
|
|
51
|
+
this.baseUrl = baseUrl.replace(/\/$/, '')
|
|
52
|
+
this.token = token ?? null
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
setToken(token) {
|
|
56
|
+
this.token = token
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async close() {
|
|
60
|
+
// HTTP is stateless — nothing to close.
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Generic RPC entry point. Routes the named method to the
|
|
65
|
+
* corresponding HTTP endpoint and returns the parsed JSON body.
|
|
66
|
+
*/
|
|
67
|
+
async call(method, params = {}) {
|
|
68
|
+
const route = ROUTES[method]
|
|
69
|
+
if (!route) {
|
|
70
|
+
throw new RedDBError(
|
|
71
|
+
'UNKNOWN_METHOD',
|
|
72
|
+
`HTTP transport has no route for method '${method}'`,
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
const { url, init } = route(this.baseUrl, params)
|
|
76
|
+
const response = await fetch(url, this.attachAuth(init))
|
|
77
|
+
return parseResponse(response)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
attachAuth(init) {
|
|
81
|
+
const headers = new Headers(init.headers || {})
|
|
82
|
+
if (this.token && !headers.has('authorization')) {
|
|
83
|
+
headers.set('authorization', `Bearer ${this.token}`)
|
|
84
|
+
}
|
|
85
|
+
if (init.body && !headers.has('content-type')) {
|
|
86
|
+
headers.set('content-type', 'application/json')
|
|
87
|
+
}
|
|
88
|
+
return { ...init, headers }
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function parseResponse(response) {
|
|
93
|
+
const text = await response.text()
|
|
94
|
+
let body = null
|
|
95
|
+
if (text) {
|
|
96
|
+
try {
|
|
97
|
+
body = JSON.parse(text)
|
|
98
|
+
} catch {
|
|
99
|
+
body = { raw: text }
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (!response.ok) {
|
|
103
|
+
const code = body?.error_code || `HTTP_${response.status}`
|
|
104
|
+
const message = body?.error || body?.message || `request failed with status ${response.status}`
|
|
105
|
+
throw new RedDBError(code, message, body)
|
|
106
|
+
}
|
|
107
|
+
// RedDB envelope is `{ ok, result, error? }` for some endpoints
|
|
108
|
+
// and bare JSON for others. Unwrap the envelope when present.
|
|
109
|
+
if (body && typeof body === 'object' && 'ok' in body) {
|
|
110
|
+
if (body.ok === false) {
|
|
111
|
+
const code = body.error_code || 'RPC_ERROR'
|
|
112
|
+
throw new RedDBError(code, body.error || 'unknown error', body)
|
|
113
|
+
}
|
|
114
|
+
return body.result ?? body
|
|
115
|
+
}
|
|
116
|
+
return body
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
// Method → HTTP route mapping
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
const ROUTES = {
|
|
124
|
+
health: (base) => ({ url: `${base}/health`, init: { method: 'GET' } }),
|
|
125
|
+
version: (base) => ({ url: `${base}/admin/version`, init: { method: 'GET' } }),
|
|
126
|
+
query: (base, { sql }) => ({
|
|
127
|
+
url: `${base}/query`,
|
|
128
|
+
init: { method: 'POST', body: JSON.stringify({ query: sql }) },
|
|
129
|
+
}),
|
|
130
|
+
insert: (base, { collection, payload }) => ({
|
|
131
|
+
url: `${base}/collections/${encodeURIComponent(collection)}/rows`,
|
|
132
|
+
init: { method: 'POST', body: JSON.stringify(payload) },
|
|
133
|
+
}),
|
|
134
|
+
bulk_insert: (base, { collection, payloads }) => ({
|
|
135
|
+
url: `${base}/collections/${encodeURIComponent(collection)}/bulk/rows`,
|
|
136
|
+
init: { method: 'POST', body: JSON.stringify({ rows: payloads }) },
|
|
137
|
+
}),
|
|
138
|
+
get: (base, { collection, id }) => ({
|
|
139
|
+
url: `${base}/collections/${encodeURIComponent(collection)}/${encodeURIComponent(id)}`,
|
|
140
|
+
init: { method: 'GET' },
|
|
141
|
+
}),
|
|
142
|
+
delete: (base, { collection, id }) => ({
|
|
143
|
+
url: `${base}/collections/${encodeURIComponent(collection)}/${encodeURIComponent(id)}`,
|
|
144
|
+
init: { method: 'DELETE' },
|
|
145
|
+
}),
|
|
146
|
+
'auth.login': (base, { username, password }) => ({
|
|
147
|
+
url: `${base}/auth/login`,
|
|
148
|
+
init: { method: 'POST', body: JSON.stringify({ username, password }) },
|
|
149
|
+
}),
|
|
150
|
+
'auth.whoami': (base) => ({
|
|
151
|
+
url: `${base}/auth/whoami`,
|
|
152
|
+
init: { method: 'GET' },
|
|
153
|
+
}),
|
|
154
|
+
'auth.create_api_key': (base, params = {}) => ({
|
|
155
|
+
url: `${base}/auth/api-keys`,
|
|
156
|
+
init: { method: 'POST', body: JSON.stringify(params) },
|
|
157
|
+
}),
|
|
158
|
+
'auth.revoke_api_key': (base, { key }) => ({
|
|
159
|
+
url: `${base}/auth/api-keys/${encodeURIComponent(key)}`,
|
|
160
|
+
init: { method: 'DELETE' },
|
|
161
|
+
}),
|
|
162
|
+
'auth.change_password': (base, { current_password, new_password }) => ({
|
|
163
|
+
url: `${base}/auth/change-password`,
|
|
164
|
+
init: {
|
|
165
|
+
method: 'POST',
|
|
166
|
+
body: JSON.stringify({
|
|
167
|
+
current_password,
|
|
168
|
+
new_password,
|
|
169
|
+
}),
|
|
170
|
+
},
|
|
171
|
+
}),
|
|
172
|
+
'cache.get': (base, { namespace, key }) => ({
|
|
173
|
+
url: `${base}/cache/ns/${encodeURIComponent(namespace)}/${encodeURIComponent(key)}`,
|
|
174
|
+
init: { method: 'GET' },
|
|
175
|
+
}),
|
|
176
|
+
'cache.put': (base, { namespace, key, value, ttl_ms, tags, policy }) => ({
|
|
177
|
+
url: `${base}/cache/ns/${encodeURIComponent(namespace)}/${encodeURIComponent(key)}`,
|
|
178
|
+
init: { method: 'PUT', body: JSON.stringify({ value, ttl_ms, tags, policy }) },
|
|
179
|
+
}),
|
|
180
|
+
'cache.exists': (base, { namespace, key }) => ({
|
|
181
|
+
url: `${base}/cache/ns/${encodeURIComponent(namespace)}/${encodeURIComponent(key)}/exists`,
|
|
182
|
+
init: { method: 'GET' },
|
|
183
|
+
}),
|
|
184
|
+
'cache.invalidate': (base, { namespace, key }) => ({
|
|
185
|
+
url: `${base}/cache/ns/${encodeURIComponent(namespace)}/${encodeURIComponent(key)}`,
|
|
186
|
+
init: { method: 'DELETE' },
|
|
187
|
+
}),
|
|
188
|
+
'cache.invalidate_prefix': (base, { namespace, prefix }) => ({
|
|
189
|
+
url: `${base}/cache/ns/${encodeURIComponent(namespace)}?prefix=${encodeURIComponent(prefix)}`,
|
|
190
|
+
init: { method: 'DELETE' },
|
|
191
|
+
}),
|
|
192
|
+
'cache.invalidate_tags': (base, { namespace, tags }) => ({
|
|
193
|
+
url: `${base}/cache/ns/${encodeURIComponent(namespace)}/tags`,
|
|
194
|
+
init: { method: 'DELETE', body: JSON.stringify({ tags }) },
|
|
195
|
+
}),
|
|
196
|
+
'cache.flush_namespace': (base, { namespace }) => ({
|
|
197
|
+
url: `${base}/admin/blob_cache/flush_namespace`,
|
|
198
|
+
init: { method: 'POST', body: JSON.stringify({ namespace }) },
|
|
199
|
+
}),
|
|
200
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @reddb-io/client — thin remote-only RedDB driver.
|
|
3
|
+
*
|
|
4
|
+
* Public API:
|
|
5
|
+
* import { connect } from '@reddb-io/client'
|
|
6
|
+
* const db = await connect('red://reddb.example.com:5050')
|
|
7
|
+
* const result = await db.query('SELECT * FROM users LIMIT 10')
|
|
8
|
+
* await db.close()
|
|
9
|
+
*
|
|
10
|
+
* Accepted URIs:
|
|
11
|
+
* - 'red://host:port' — RedWire TCP (default)
|
|
12
|
+
* - 'reds://host:port' — RedWire over TLS
|
|
13
|
+
* - 'grpc://host:port' — gRPC
|
|
14
|
+
* - 'grpcs://host:port' — gRPC over TLS
|
|
15
|
+
* - 'http://host:port' — HTTP JSON-RPC
|
|
16
|
+
* - 'https://host:port' — HTTPS JSON-RPC
|
|
17
|
+
*
|
|
18
|
+
* Rejected URIs (use @reddb-io/sdk for these):
|
|
19
|
+
* - 'memory://', 'memory:' — in-memory embedded engine
|
|
20
|
+
* - 'file:///abs/path' — file-backed embedded engine
|
|
21
|
+
* - 'red://', 'red:///path' — same shapes via the unified scheme
|
|
22
|
+
* - 'red://:memory[:]' — SQLite-style embedded alias
|
|
23
|
+
*
|
|
24
|
+
* The thin `red_client` binary does not bundle the storage engine —
|
|
25
|
+
* the package is roughly 10x smaller than `@reddb-io/sdk`. If you
|
|
26
|
+
* need an embedded engine, install `@reddb-io/sdk` instead.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { RedDBError } from './protocol.js'
|
|
30
|
+
import { HttpRpcClient } from './http.js'
|
|
31
|
+
import { connectRedwire } from './redwire.js'
|
|
32
|
+
import { parseUri, deriveLoginUrl } from './url.js'
|
|
33
|
+
import {
|
|
34
|
+
EmbeddedNotSupported,
|
|
35
|
+
EMBEDDED_REJECTION_MESSAGE,
|
|
36
|
+
isEmbeddedUri,
|
|
37
|
+
rejectEmbeddedUri,
|
|
38
|
+
} from './embedded-rejection.js'
|
|
39
|
+
import { CacheClient } from './cache.js'
|
|
40
|
+
import { KvClient } from './kv.js'
|
|
41
|
+
import { ConfigClient } from './config.js'
|
|
42
|
+
import { VaultClient } from './vault.js'
|
|
43
|
+
|
|
44
|
+
export { RedDBError, EmbeddedNotSupported, EMBEDDED_REJECTION_MESSAGE, isEmbeddedUri }
|
|
45
|
+
export { CacheClient } from './cache.js'
|
|
46
|
+
export { KvClient } from './kv.js'
|
|
47
|
+
export { ConfigClient } from './config.js'
|
|
48
|
+
export { VaultClient } from './vault.js'
|
|
49
|
+
export { parseUri, deriveLoginUrl } from './url.js'
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Connect to a remote RedDB instance.
|
|
53
|
+
*
|
|
54
|
+
* @param {string} uri Connection URI. See module docstring for accepted schemes.
|
|
55
|
+
* @param {object} [options]
|
|
56
|
+
* @param {object} [options.auth] Authentication credentials.
|
|
57
|
+
* @param {string} [options.auth.token] Bearer / API-key token.
|
|
58
|
+
* @param {string} [options.auth.apiKey] Alias for `token`.
|
|
59
|
+
* @param {string} [options.auth.username] Username for password login.
|
|
60
|
+
* @param {string} [options.auth.password] Password for password login.
|
|
61
|
+
* @param {string} [options.auth.loginUrl] Override URL for the password
|
|
62
|
+
* exchange (defaults to deriving `/auth/login` from `uri`).
|
|
63
|
+
* @param {object} [options.tls] TLS / mTLS options for redwire(s)://.
|
|
64
|
+
* @returns {Promise<RedDB>}
|
|
65
|
+
*/
|
|
66
|
+
export async function connect(uri, options = {}) {
|
|
67
|
+
// Reject embedded shapes upfront with the same wording the Rust
|
|
68
|
+
// binary uses, before the URL parser would map them to kind=embedded.
|
|
69
|
+
rejectEmbeddedUri(uri)
|
|
70
|
+
|
|
71
|
+
const parsed = parseUri(uri)
|
|
72
|
+
|
|
73
|
+
// Belt-and-braces: if the parser still produced an embedded kind
|
|
74
|
+
// (e.g. via a URI shape we forgot to enumerate above), reject it.
|
|
75
|
+
if (parsed.kind === 'embedded') {
|
|
76
|
+
throw new EmbeddedNotSupported(uri)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const merged = mergeAuthFromUri(parsed, options.auth)
|
|
80
|
+
|
|
81
|
+
if (parsed.kind === 'http' || parsed.kind === 'https') {
|
|
82
|
+
const baseUrl = `${parsed.kind}://${parsed.host}:${parsed.port}`
|
|
83
|
+
let token = merged.token
|
|
84
|
+
if (!token && merged.username && merged.password) {
|
|
85
|
+
const loginUrl = merged.loginUrl ?? `${baseUrl}/auth/login`
|
|
86
|
+
const session = await login(loginUrl, {
|
|
87
|
+
username: merged.username,
|
|
88
|
+
password: merged.password,
|
|
89
|
+
})
|
|
90
|
+
token = session.token
|
|
91
|
+
}
|
|
92
|
+
const client = new HttpRpcClient({ baseUrl, token })
|
|
93
|
+
await client.call('health', {})
|
|
94
|
+
return new RedDB(client)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (
|
|
98
|
+
parsed.kind === 'red'
|
|
99
|
+
|| parsed.kind === 'reds'
|
|
100
|
+
|| parsed.kind === 'grpc'
|
|
101
|
+
|| parsed.kind === 'grpcs'
|
|
102
|
+
) {
|
|
103
|
+
let token = merged.token
|
|
104
|
+
if (!token && merged.username && merged.password) {
|
|
105
|
+
const loginUrl = merged.loginUrl ?? deriveLoginUrl(parsed)
|
|
106
|
+
const session = await login(loginUrl, {
|
|
107
|
+
username: merged.username,
|
|
108
|
+
password: merged.password,
|
|
109
|
+
})
|
|
110
|
+
token = session.token
|
|
111
|
+
}
|
|
112
|
+
const auth = token ? { kind: 'bearer', token } : { kind: 'anonymous' }
|
|
113
|
+
const tls = buildTlsOpts(parsed, options.tls)
|
|
114
|
+
const client = await connectRedwire({
|
|
115
|
+
host: parsed.host,
|
|
116
|
+
port: parsed.port,
|
|
117
|
+
auth,
|
|
118
|
+
...(tls ? { tls } : {}),
|
|
119
|
+
})
|
|
120
|
+
return new RedDB(client)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (parsed.kind === 'pg') {
|
|
124
|
+
throw new RedDBError(
|
|
125
|
+
'PG_TRANSPORT_NOT_WIRED',
|
|
126
|
+
"PostgreSQL wire (proto=pg) requires a node-pg-style client; "
|
|
127
|
+
+ "the JS thin client doesn't bundle one. Use a separate `pg` "
|
|
128
|
+
+ 'package against the same host:port.',
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
throw new RedDBError(
|
|
133
|
+
'UNSUPPORTED_KIND',
|
|
134
|
+
`internal: parsed kind '${parsed.kind}' has no transport`,
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Resolve TLS options for a redwire(s) connection. Source order:
|
|
140
|
+
* 1. caller-supplied `options.tls` object.
|
|
141
|
+
* 2. `parsed.kind === 'reds' | 'grpcs'`.
|
|
142
|
+
* 3. `?tls=true` URL param.
|
|
143
|
+
* 4. `?ca=`, `?cert=`, `?key=`, `?servername=`, `?rejectUnauthorized=false`
|
|
144
|
+
* URL params (path or PEM string).
|
|
145
|
+
*/
|
|
146
|
+
function buildTlsOpts(parsed, callerTls) {
|
|
147
|
+
if (callerTls && typeof callerTls === 'object') {
|
|
148
|
+
return callerTls
|
|
149
|
+
}
|
|
150
|
+
const params = parsed.params
|
|
151
|
+
const wantsTls =
|
|
152
|
+
parsed.kind === 'reds'
|
|
153
|
+
|| parsed.kind === 'grpcs'
|
|
154
|
+
|| params?.get?.('tls') === 'true'
|
|
155
|
+
|| params?.get?.('tls') === '1'
|
|
156
|
+
if (!wantsTls) return null
|
|
157
|
+
return {
|
|
158
|
+
ca: params?.get?.('ca') ?? undefined,
|
|
159
|
+
cert: params?.get?.('cert') ?? undefined,
|
|
160
|
+
key: params?.get?.('key') ?? undefined,
|
|
161
|
+
servername: params?.get?.('servername') ?? undefined,
|
|
162
|
+
rejectUnauthorized:
|
|
163
|
+
params?.get?.('rejectUnauthorized') === 'false' ? false : true,
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function mergeAuthFromUri(parsed, optionAuth) {
|
|
168
|
+
const out = {
|
|
169
|
+
token: parsed.token ?? parsed.apiKey ?? null,
|
|
170
|
+
username: parsed.username ?? null,
|
|
171
|
+
password: parsed.password ?? null,
|
|
172
|
+
loginUrl: parsed.loginUrl ?? null,
|
|
173
|
+
}
|
|
174
|
+
if (optionAuth == null) return out
|
|
175
|
+
if (typeof optionAuth !== 'object') {
|
|
176
|
+
throw new TypeError('options.auth must be an object')
|
|
177
|
+
}
|
|
178
|
+
if (optionAuth.token != null) {
|
|
179
|
+
if (typeof optionAuth.token !== 'string' || optionAuth.token.length === 0) {
|
|
180
|
+
throw new TypeError('options.auth.token must be a non-empty string')
|
|
181
|
+
}
|
|
182
|
+
out.token = optionAuth.token
|
|
183
|
+
}
|
|
184
|
+
if (optionAuth.apiKey != null) {
|
|
185
|
+
if (typeof optionAuth.apiKey !== 'string' || optionAuth.apiKey.length === 0) {
|
|
186
|
+
throw new TypeError('options.auth.apiKey must be a non-empty string')
|
|
187
|
+
}
|
|
188
|
+
out.token = optionAuth.apiKey
|
|
189
|
+
}
|
|
190
|
+
if (optionAuth.username != null) {
|
|
191
|
+
if (typeof optionAuth.username !== 'string' || optionAuth.username.length === 0) {
|
|
192
|
+
throw new TypeError('options.auth.username must be a non-empty string')
|
|
193
|
+
}
|
|
194
|
+
out.username = optionAuth.username
|
|
195
|
+
}
|
|
196
|
+
if (optionAuth.password != null) {
|
|
197
|
+
if (typeof optionAuth.password !== 'string' || optionAuth.password.length === 0) {
|
|
198
|
+
throw new TypeError('options.auth.password must be a non-empty string')
|
|
199
|
+
}
|
|
200
|
+
out.password = optionAuth.password
|
|
201
|
+
}
|
|
202
|
+
if (optionAuth.loginUrl != null) {
|
|
203
|
+
out.loginUrl = optionAuth.loginUrl
|
|
204
|
+
}
|
|
205
|
+
return out
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Exchange username + password for a bearer token via the server's
|
|
210
|
+
* `POST /auth/login` HTTP endpoint. Same flow used by `connect()` when
|
|
211
|
+
* the caller passes `auth: { username, password }`.
|
|
212
|
+
*
|
|
213
|
+
* @param {string} loginUrl Full URL of the server's auth endpoint.
|
|
214
|
+
* @param {{ username: string, password: string }} credentials
|
|
215
|
+
* @returns {Promise<{ token: string, username: string, role: string, expires_at: number }>}
|
|
216
|
+
*/
|
|
217
|
+
export async function login(loginUrl, { username, password }) {
|
|
218
|
+
if (typeof loginUrl !== 'string' || !loginUrl.startsWith('http')) {
|
|
219
|
+
throw new TypeError("login() requires an http(s):// URL pointing at /auth/login")
|
|
220
|
+
}
|
|
221
|
+
if (typeof username !== 'string' || username.length === 0) {
|
|
222
|
+
throw new TypeError('login() requires a non-empty username')
|
|
223
|
+
}
|
|
224
|
+
if (typeof password !== 'string' || password.length === 0) {
|
|
225
|
+
throw new TypeError('login() requires a non-empty password')
|
|
226
|
+
}
|
|
227
|
+
const response = await fetch(loginUrl, {
|
|
228
|
+
method: 'POST',
|
|
229
|
+
headers: { 'content-type': 'application/json' },
|
|
230
|
+
body: JSON.stringify({ username, password }),
|
|
231
|
+
})
|
|
232
|
+
const body = await response.json().catch(() => ({}))
|
|
233
|
+
if (!response.ok || body.ok === false) {
|
|
234
|
+
const code = body.error_code || `HTTP_${response.status}`
|
|
235
|
+
const message = body.error || `auth/login returned ${response.status}`
|
|
236
|
+
throw new RedDBError(code, message, body)
|
|
237
|
+
}
|
|
238
|
+
if (typeof body.token !== 'string') {
|
|
239
|
+
throw new RedDBError(
|
|
240
|
+
'AUTH_LOGIN_BAD_RESPONSE',
|
|
241
|
+
'auth/login response missing string token',
|
|
242
|
+
body,
|
|
243
|
+
)
|
|
244
|
+
}
|
|
245
|
+
return body
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Connection handle. Methods map 1:1 to JSON-RPC methods on the server.
|
|
250
|
+
* Identical surface to `@reddb-io/sdk`'s `RedDB`, minus the local-spawn
|
|
251
|
+
* lifecycle.
|
|
252
|
+
*/
|
|
253
|
+
export class RedDB {
|
|
254
|
+
/** @param {HttpRpcClient | import('./redwire.js').RedWireClient} client */
|
|
255
|
+
constructor(client) {
|
|
256
|
+
this.client = client
|
|
257
|
+
this.cache = new CacheClient(client)
|
|
258
|
+
const defaultKv = new KvClient(client)
|
|
259
|
+
this.kv = Object.assign((collection = 'kv_default') => new KvClient(client, collection), {
|
|
260
|
+
put: defaultKv.put.bind(defaultKv),
|
|
261
|
+
invalidateTags: defaultKv.invalidateTags.bind(defaultKv),
|
|
262
|
+
watch: defaultKv.watch.bind(defaultKv),
|
|
263
|
+
watchPrefix: defaultKv.watchPrefix.bind(defaultKv),
|
|
264
|
+
})
|
|
265
|
+
this.config = (collection = 'red.config') => new ConfigClient(client, collection)
|
|
266
|
+
this.vault = (collection = 'red.vault') => new VaultClient(client, collection)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Execute a SQL query. Returns `{ statement, affected, columns, rows }`. */
|
|
270
|
+
query(sql) {
|
|
271
|
+
return this.client.call('query', { sql })
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Insert one row. Returns `{ affected, id? }`. */
|
|
275
|
+
insert(collection, payload) {
|
|
276
|
+
return this.client.call('insert', { collection, payload })
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Insert many rows in one call. Returns `{ affected }`. */
|
|
280
|
+
bulkInsert(collection, payloads) {
|
|
281
|
+
return this.client.call('bulk_insert', { collection, payloads })
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Get an entity by id. Returns `{ entity }` (entity is `null` if not found). */
|
|
285
|
+
get(collection, id) {
|
|
286
|
+
return this.client.call('get', { collection, id: String(id) })
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Delete an entity by id. Returns `{ affected }`. */
|
|
290
|
+
delete(collection, id) {
|
|
291
|
+
return this.client.call('delete', { collection, id: String(id) })
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Probe the server. Returns `{ ok: true, version }`. */
|
|
295
|
+
health() {
|
|
296
|
+
return this.client.call('health', {})
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Server version + protocol version. */
|
|
300
|
+
version() {
|
|
301
|
+
return this.client.call('version', {})
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Exchange username + password for a bearer token. */
|
|
305
|
+
login(username, password) {
|
|
306
|
+
return this.client.call('auth.login', { username, password })
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Identify the current caller. */
|
|
310
|
+
whoami() {
|
|
311
|
+
return this.client.call('auth.whoami', {})
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Change the current caller's password. */
|
|
315
|
+
changePassword(currentPassword, newPassword) {
|
|
316
|
+
return this.client.call('auth.change_password', {
|
|
317
|
+
current_password: currentPassword,
|
|
318
|
+
new_password: newPassword,
|
|
319
|
+
})
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Mint a long-lived API key. */
|
|
323
|
+
createApiKey({ username, role } = {}) {
|
|
324
|
+
return this.client.call('auth.create_api_key', { username, role })
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Revoke an API key by its public id. */
|
|
328
|
+
revokeApiKey(key) {
|
|
329
|
+
return this.client.call('auth.revoke_api_key', { key })
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Close the underlying transport. */
|
|
333
|
+
close() {
|
|
334
|
+
return this.client.close()
|
|
335
|
+
}
|
|
336
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve the GitHub release asset filename for a given Node
|
|
3
|
+
* `process.platform` / `process.arch` pair and binary base name.
|
|
4
|
+
*
|
|
5
|
+
* Mapping is the existing scheme from `drivers/js/postinstall.js`:
|
|
6
|
+
* linux + x64 → <bin>-linux-x86_64
|
|
7
|
+
* linux + arm64 → <bin>-linux-aarch64
|
|
8
|
+
* linux + arm/armv7l→ <bin>-linux-armv7
|
|
9
|
+
* darwin + x64 → <bin>-macos-x86_64
|
|
10
|
+
* darwin + arm64 → <bin>-macos-aarch64
|
|
11
|
+
* win32 + x64 → <bin>-windows-x86_64.exe
|
|
12
|
+
*
|
|
13
|
+
* Throws `UnsupportedPlatformError` for any other combination.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export class UnsupportedPlatformError extends Error {
|
|
17
|
+
constructor(platform, arch) {
|
|
18
|
+
super(`unsupported platform/arch combination: ${platform}/${arch}`)
|
|
19
|
+
this.name = 'UnsupportedPlatformError'
|
|
20
|
+
this.code = 'UNSUPPORTED_PLATFORM'
|
|
21
|
+
this.platform = platform
|
|
22
|
+
this.arch = arch
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function composeAssetName({ platform, arch, binName }) {
|
|
27
|
+
if (typeof binName !== 'string' || binName === '') {
|
|
28
|
+
throw new TypeError('composeAssetName: `binName` must be a non-empty string')
|
|
29
|
+
}
|
|
30
|
+
if (platform === 'linux' && arch === 'x64') return `${binName}-linux-x86_64`
|
|
31
|
+
if (platform === 'linux' && arch === 'arm64') return `${binName}-linux-aarch64`
|
|
32
|
+
if (platform === 'linux' && (arch === 'arm' || arch === 'armv7l')) return `${binName}-linux-armv7`
|
|
33
|
+
if (platform === 'darwin' && arch === 'x64') return `${binName}-macos-x86_64`
|
|
34
|
+
if (platform === 'darwin' && arch === 'arm64') return `${binName}-macos-aarch64`
|
|
35
|
+
if (platform === 'win32' && arch === 'x64') return `${binName}-windows-x86_64.exe`
|
|
36
|
+
throw new UnsupportedPlatformError(platform, arch)
|
|
37
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
export class ChecksumMismatchError extends Error {
|
|
4
|
+
constructor(expected, actual) {
|
|
5
|
+
super(`checksum mismatch: expected sha256=${expected}, got sha256=${actual}`)
|
|
6
|
+
this.name = 'ChecksumMismatchError'
|
|
7
|
+
this.code = 'CHECKSUM_MISMATCH'
|
|
8
|
+
this.expected = expected
|
|
9
|
+
this.actual = actual
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function sha256Hex(buf) {
|
|
14
|
+
return createHash('sha256').update(buf).digest('hex')
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function verifySha256(buf, expected) {
|
|
18
|
+
const expectedNorm = String(expected).trim().toLowerCase()
|
|
19
|
+
const actual = sha256Hex(buf)
|
|
20
|
+
if (actual !== expectedNorm) {
|
|
21
|
+
throw new ChecksumMismatchError(expectedNorm, actual)
|
|
22
|
+
}
|
|
23
|
+
}
|