@riloservices/base 0.0.0-stage → 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 +249 -2
- package/dist/browser.cjs +39 -0
- package/dist/browser.d.cts +21 -0
- package/dist/browser.d.mts +21 -0
- package/dist/browser.mjs +37 -0
- package/dist/cli.mjs +307 -0
- package/dist/errors-DwVg5fNj.d.cts +24 -0
- package/dist/errors-DwVg5fNj.d.mts +24 -0
- package/dist/index.cjs +124 -0
- package/dist/index.d.cts +57 -0
- package/dist/index.d.mts +57 -0
- package/dist/index.mjs +121 -0
- package/dist/key-B7koHuaE.mjs +272 -0
- package/dist/key-DPXUxbGX.cjs +289 -0
- package/dist/mongoose.cjs +21 -0
- package/dist/mongoose.d.cts +29 -0
- package/dist/mongoose.d.mts +29 -0
- package/dist/mongoose.mjs +19 -0
- package/dist/types-CD9NBWdA.d.cts +88 -0
- package/dist/types-CD9NBWdA.d.mts +88 -0
- package/package.json +72 -4
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { n as RiloFilesError, r as RiloKeyError, t as resolveTarget } from "./key-B7koHuaE.mjs";
|
|
2
|
+
import { MongoClient } from "mongodb";
|
|
3
|
+
// src/files.ts
|
|
4
|
+
function isRecord(value) {
|
|
5
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
6
|
+
}
|
|
7
|
+
/** One part of a path through `encodeURIComponent`, plus the `!'()*` it leaves as they are. */
|
|
8
|
+
function encodeSegment(segment) {
|
|
9
|
+
return encodeURIComponent(segment).replace(/[!'()*]/g, (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
10
|
+
}
|
|
11
|
+
/** A file path as it goes in a URL: each part encoded, the slashes between them kept. */
|
|
12
|
+
function encodePath(path) {
|
|
13
|
+
return path.split("/").map(encodeSegment).join("/");
|
|
14
|
+
}
|
|
15
|
+
/** The Files API's `error` body, as a `RiloFilesError`. Anything that isn't one still gets a status. */
|
|
16
|
+
async function toError(response) {
|
|
17
|
+
let error = {};
|
|
18
|
+
try {
|
|
19
|
+
const body = await response.json();
|
|
20
|
+
if (isRecord(body) && isRecord(body.error)) error = body.error;
|
|
21
|
+
} catch {}
|
|
22
|
+
const { code, message, details, fields } = error;
|
|
23
|
+
return new RiloFilesError({
|
|
24
|
+
status: response.status,
|
|
25
|
+
code: typeof code === "string" ? code : `http_${response.status}`,
|
|
26
|
+
message: typeof message === "string" ? message : `The Files API answered with status ${response.status}.`,
|
|
27
|
+
details: isRecord(details) ? details : null,
|
|
28
|
+
fields: isRecord(fields) ? fields : null
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The Files API client. `filesKey` is the `rdbf_…` part of the app key: it is the only secret that
|
|
33
|
+
* is ever sent, and only in the `Authorization` header.
|
|
34
|
+
*/
|
|
35
|
+
function createFiles(baseUrl, filesKey) {
|
|
36
|
+
async function call(method, path, body) {
|
|
37
|
+
const headers = {
|
|
38
|
+
authorization: `Bearer ${filesKey}`,
|
|
39
|
+
accept: "application/json"
|
|
40
|
+
};
|
|
41
|
+
if (body !== void 0) headers["content-type"] = "application/json";
|
|
42
|
+
const response = await fetch(`${baseUrl}${path}`, {
|
|
43
|
+
method,
|
|
44
|
+
headers,
|
|
45
|
+
body: body === void 0 ? void 0 : JSON.stringify(body)
|
|
46
|
+
});
|
|
47
|
+
if (!response.ok) throw await toError(response);
|
|
48
|
+
if (response.status === 204) return void 0;
|
|
49
|
+
return response.json();
|
|
50
|
+
}
|
|
51
|
+
function bucket(name) {
|
|
52
|
+
const root = `/v1/buckets/${encodeURIComponent(name)}`;
|
|
53
|
+
async function makeLinks(target, options) {
|
|
54
|
+
const body = options?.expiresInSeconds === void 0 ? target : {
|
|
55
|
+
...target,
|
|
56
|
+
expiresInSeconds: options.expiresInSeconds
|
|
57
|
+
};
|
|
58
|
+
return (await call("POST", `${root}/links`, body)).links;
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
createUpload: async (body) => await call("POST", `${root}/uploads`, body),
|
|
62
|
+
cancelUpload: async (uploadId) => {
|
|
63
|
+
await call("DELETE", `/v1/uploads/${encodeURIComponent(uploadId)}`);
|
|
64
|
+
},
|
|
65
|
+
link: async (path, options) => {
|
|
66
|
+
const [link] = await makeLinks({ path }, options);
|
|
67
|
+
if (link === void 0) throw new Error("The Files API answered without a link.");
|
|
68
|
+
return link;
|
|
69
|
+
},
|
|
70
|
+
links: (paths, options) => makeLinks({ paths }, options),
|
|
71
|
+
list: async (query = {}) => {
|
|
72
|
+
const params = new URLSearchParams();
|
|
73
|
+
if (query.prefix !== void 0) params.set("prefix", query.prefix);
|
|
74
|
+
if (query.delimiter !== void 0) params.set("delimiter", query.delimiter);
|
|
75
|
+
if (query.cursor !== void 0) params.set("cursor", query.cursor);
|
|
76
|
+
if (query.limit !== void 0) params.set("limit", String(query.limit));
|
|
77
|
+
const search = params.size === 0 ? "" : `?${params.toString()}`;
|
|
78
|
+
return await call("GET", `${root}/files${search}`);
|
|
79
|
+
},
|
|
80
|
+
get: async (path) => await call("GET", `${root}/files/${encodePath(path)}`),
|
|
81
|
+
delete: async (path) => {
|
|
82
|
+
await call("DELETE", `${root}/files/${encodePath(path)}`);
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
buckets: async () => await call("GET", "/v1/buckets"),
|
|
88
|
+
bucket
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
// src/index.ts
|
|
92
|
+
const FILES_NEED_KEY = "Files needs your app key (RILO_KEY). A rilodb:// link holds only the database part.";
|
|
93
|
+
/**
|
|
94
|
+
* Turns an app key, or a `rilodb://` link, into everything an app needs. The app key (`rilo_…`)
|
|
95
|
+
* works for databases and Files. A `rilodb://` link works for databases only, and brings its own
|
|
96
|
+
* address and default database. Throws a `RiloKeyError` when the input is missing or is neither;
|
|
97
|
+
* the message says what was given and never repeats it. Nothing connects until `db()` is first used.
|
|
98
|
+
*/
|
|
99
|
+
function createClient(keyOrLink, options = {}) {
|
|
100
|
+
const target = resolveTarget(keyOrLink, options);
|
|
101
|
+
const files = target.files === null ? null : createFiles(target.files.url, target.files.key);
|
|
102
|
+
let mongo;
|
|
103
|
+
return {
|
|
104
|
+
db(name) {
|
|
105
|
+
const database = name ?? options.database ?? target.database ?? void 0;
|
|
106
|
+
if (database === void 0 || database === "") throw new TypeError("Say which database to open: db(\"name\"), or give createClient a `database` option.");
|
|
107
|
+
mongo ??= new MongoClient(target.connectLink, options.appName === void 0 ? void 0 : { appName: options.appName });
|
|
108
|
+
return mongo.db(`${database}_${target.spaceId}`);
|
|
109
|
+
},
|
|
110
|
+
get files() {
|
|
111
|
+
if (files === null) throw new RiloKeyError(FILES_NEED_KEY);
|
|
112
|
+
return files;
|
|
113
|
+
},
|
|
114
|
+
async close() {
|
|
115
|
+
const open = mongo;
|
|
116
|
+
mongo = void 0;
|
|
117
|
+
await open?.close();
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
export { RiloFilesError, RiloKeyError, createClient };
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
// src/errors.ts
|
|
2
|
+
/** The key or link is missing or isn't an app key or a `rilodb://` link. The message never repeats it. */
|
|
3
|
+
var RiloKeyError = class extends Error {
|
|
4
|
+
name = "RiloKeyError";
|
|
5
|
+
};
|
|
6
|
+
/** What the Files API answers when a call fails: its `error` body, plus the HTTP status. */
|
|
7
|
+
var RiloFilesError = class extends Error {
|
|
8
|
+
name = "RiloFilesError";
|
|
9
|
+
/** The HTTP status. */
|
|
10
|
+
status;
|
|
11
|
+
/** A stable name for the error, like `read_only_key`. Decide what to do from this. */
|
|
12
|
+
code;
|
|
13
|
+
/** Extra data for some codes, like `maxFileSize` on `file_too_large`. */
|
|
14
|
+
details;
|
|
15
|
+
/** On `validation_failed`: each field that needs fixing, with what to fix. */
|
|
16
|
+
fields;
|
|
17
|
+
constructor(init) {
|
|
18
|
+
super(init.message);
|
|
19
|
+
this.status = init.status;
|
|
20
|
+
this.code = init.code;
|
|
21
|
+
this.details = init.details ?? null;
|
|
22
|
+
this.fields = init.fields ?? null;
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
// ../shared/src/connect.ts
|
|
26
|
+
/**
|
|
27
|
+
* The fixed options of every link, in this order. `directConnection` keeps the driver talking only
|
|
28
|
+
* to the Gateway; DocumentDB has no retryable writes; logins live in `admin`; `maxPoolSize=10`
|
|
29
|
+
* fits 4 app instances in the Seed limit of 50, with 2 monitoring connections each (decision Q3).
|
|
30
|
+
*/
|
|
31
|
+
const CONNECT_OPTIONS = {
|
|
32
|
+
tls: true,
|
|
33
|
+
directConnection: true,
|
|
34
|
+
retryWrites: false,
|
|
35
|
+
authSource: "admin",
|
|
36
|
+
maxPoolSize: 10
|
|
37
|
+
};
|
|
38
|
+
// ../shared/src/logins.ts
|
|
39
|
+
/**
|
|
40
|
+
* The rule for a password the person chooses (step 11): 16 to 64 letters, digits, `-` and `_`.
|
|
41
|
+
* These are the characters a `mongodb://` link carries without encoding. Rilo's own suggestion is
|
|
42
|
+
* 24 random bytes in base64url (32 characters), which always passes.
|
|
43
|
+
*/
|
|
44
|
+
const LOGIN_PASSWORD = {
|
|
45
|
+
min: 16,
|
|
46
|
+
max: 64,
|
|
47
|
+
pattern: /^[A-Za-z0-9_-]+$/
|
|
48
|
+
};
|
|
49
|
+
// ../shared/src/regions.ts
|
|
50
|
+
const REGION_CODES = ["in", "au"];
|
|
51
|
+
const SPACE_ID_PATTERN = new RegExp(`^[bcdfghjkmnpqrstvwxz23456789]{8}$`);
|
|
52
|
+
function isSpaceId(value) {
|
|
53
|
+
return SPACE_ID_PATTERN.test(value);
|
|
54
|
+
}
|
|
55
|
+
// ../shared/src/app-keys.ts
|
|
56
|
+
/** Every app key starts with this, so people, scanners and the Files service recognise one. */
|
|
57
|
+
const APP_KEY_PREFIX = "rilo_";
|
|
58
|
+
/** The environment variable an app keeps its key in. */
|
|
59
|
+
const APP_KEY_ENV_VAR = "RILO_KEY";
|
|
60
|
+
/**
|
|
61
|
+
* An app key's name is also its login's and its files key's name, so it follows both rules:
|
|
62
|
+
* lowercase letters, digits and single hyphens, starting with a letter, 3 to 32 characters.
|
|
63
|
+
*/
|
|
64
|
+
const APP_KEY_NAME = {
|
|
65
|
+
min: 3,
|
|
66
|
+
max: 32,
|
|
67
|
+
pattern: /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/
|
|
68
|
+
};
|
|
69
|
+
/** The files secret: 32 random bytes in base64url, 43 characters. */
|
|
70
|
+
const FILES_SECRET = /^[A-Za-z0-9_-]{43}$/;
|
|
71
|
+
const IDENTITY = /^rilo_([a-z]+)_([a-z0-9]+)$/;
|
|
72
|
+
/** What is wrong with an app key name, in plain words, or null. Nothing is trimmed or lowered. */
|
|
73
|
+
function appKeyNameProblem(name) {
|
|
74
|
+
if (name === "") return "Enter a name for the key.";
|
|
75
|
+
if (name.trim() !== name) return "Remove the spaces at the start or end of the name.";
|
|
76
|
+
if (/\s/.test(name)) return "Use hyphens instead of spaces, like web-app.";
|
|
77
|
+
if (/[A-Z]/.test(name)) return "Use lowercase letters only, like web-app.";
|
|
78
|
+
if (!/^[a-z0-9-]+$/.test(name)) return "Use only lowercase letters, digits and hyphens.";
|
|
79
|
+
if (!/^[a-z]/.test(name)) return "Start the name with a letter.";
|
|
80
|
+
if (name.includes("--")) return "Use single hyphens, never two in a row.";
|
|
81
|
+
if (name.endsWith("-")) return "End the name with a letter or digit, not a hyphen.";
|
|
82
|
+
if (name.length < APP_KEY_NAME.min) return `Use at least ${APP_KEY_NAME.min} characters.`;
|
|
83
|
+
if (name.length > APP_KEY_NAME.max) return `Use ${APP_KEY_NAME.max} characters or fewer.`;
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
function isDatabaseSecret(value) {
|
|
87
|
+
return LOGIN_PASSWORD.pattern.test(value) && value.length >= LOGIN_PASSWORD.min && value.length <= LOGIN_PASSWORD.max;
|
|
88
|
+
}
|
|
89
|
+
function isRegion(value) {
|
|
90
|
+
return REGION_CODES.includes(value);
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* The parts of an app key, or null when the text isn't exactly one. Strict: anything else, even
|
|
94
|
+
* with the right prefix, is refused. It says nothing about whether the key is real.
|
|
95
|
+
*/
|
|
96
|
+
function parseAppKey(key) {
|
|
97
|
+
if (!key.startsWith("rilo_")) return null;
|
|
98
|
+
const pieces = key.split(".");
|
|
99
|
+
if (pieces.length !== 4) return null;
|
|
100
|
+
const [identity = "", name = "", databaseSecret = "", filesSecret = ""] = pieces;
|
|
101
|
+
const match = IDENTITY.exec(identity);
|
|
102
|
+
const region = match?.[1];
|
|
103
|
+
const spaceId = match?.[2];
|
|
104
|
+
if (region === void 0 || spaceId === void 0) return null;
|
|
105
|
+
if (!isRegion(region) || !isSpaceId(spaceId)) return null;
|
|
106
|
+
if (appKeyNameProblem(name) !== null) return null;
|
|
107
|
+
if (!isDatabaseSecret(databaseSecret) || !FILES_SECRET.test(filesSecret)) return null;
|
|
108
|
+
return {
|
|
109
|
+
region,
|
|
110
|
+
spaceId,
|
|
111
|
+
name,
|
|
112
|
+
databaseSecret,
|
|
113
|
+
filesSecret
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
/** The port every Space's databases answer on in production; a `rilodb://` link leaves it out. */
|
|
117
|
+
const RILODB_DEFAULT_PORT = 27017;
|
|
118
|
+
/** The `mongodb://` link MongoDB drivers and tools need, with the password in it. */
|
|
119
|
+
function mongoLink(link) {
|
|
120
|
+
const options = Object.entries(CONNECT_OPTIONS).map(([key, value]) => `${key}=${value}`);
|
|
121
|
+
if (link.tlsCAFile) options.push(`tlsCAFile=${encodeURIComponent(link.tlsCAFile)}`);
|
|
122
|
+
const path = link.databaseRealName ? encodeURIComponent(link.databaseRealName) : "";
|
|
123
|
+
return `mongodb://${`${encodeURIComponent(link.loginRealName)}:${encodeURIComponent(link.password)}`}@${link.host}:${link.port}/${path}?${options.join("&")}`;
|
|
124
|
+
}
|
|
125
|
+
/** Everything an app needs from its key: the same values the Connect card shows. */
|
|
126
|
+
function appKeyValues(parts, addresses, database) {
|
|
127
|
+
const loginRealName = `${parts.name}_${parts.spaceId}`;
|
|
128
|
+
const host = `${parts.spaceId}.${parts.region}.${addresses.spacesDomain}`;
|
|
129
|
+
return {
|
|
130
|
+
loginRealName,
|
|
131
|
+
filesKey: `rdbf_${parts.spaceId}_${parts.filesSecret}`,
|
|
132
|
+
host,
|
|
133
|
+
filesUrl: addresses.filesUrl,
|
|
134
|
+
connectLink: mongoLink({
|
|
135
|
+
loginRealName,
|
|
136
|
+
password: parts.databaseSecret,
|
|
137
|
+
host,
|
|
138
|
+
port: addresses.port,
|
|
139
|
+
databaseRealName: database ? `${database}_${parts.spaceId}` : null,
|
|
140
|
+
tlsCAFile: addresses.tlsCAFile
|
|
141
|
+
}),
|
|
142
|
+
rilodbLink: formatRilodbLink(parts, addresses, database)
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
/** A database name, as `DATABASE_NAME` in names.ts (repeated here to stay free of dependencies). */
|
|
146
|
+
const DATABASE_NAME = /^[a-z][a-z0-9_-]{0,39}$/;
|
|
147
|
+
const RESERVED_DATABASES = [
|
|
148
|
+
"admin",
|
|
149
|
+
"local",
|
|
150
|
+
"config"
|
|
151
|
+
];
|
|
152
|
+
/** The `rilodb://` link of a key, naming `database` when one is given. */
|
|
153
|
+
function formatRilodbLink(parts, addresses, database) {
|
|
154
|
+
const port = addresses.port === 27017 ? "" : `:${addresses.port}`;
|
|
155
|
+
const host = `${parts.spaceId}.${parts.region}.${addresses.spacesDomain}${port}`;
|
|
156
|
+
const path = database ? encodeURIComponent(database) : "";
|
|
157
|
+
return `rilodb://${parts.name}:${parts.databaseSecret}@${host}/${path}`;
|
|
158
|
+
}
|
|
159
|
+
/** The parts of a `rilodb://` link, or null when the text isn't exactly one. Strict, like keys. */
|
|
160
|
+
function parseRilodbLink(link) {
|
|
161
|
+
if (!link.startsWith(`rilodb://`)) return null;
|
|
162
|
+
let url;
|
|
163
|
+
try {
|
|
164
|
+
url = new URL(link);
|
|
165
|
+
} catch {
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
if (url.search !== "" || url.hash !== "") return null;
|
|
169
|
+
const name = decodeURIComponent(url.username);
|
|
170
|
+
const databaseSecret = decodeURIComponent(url.password);
|
|
171
|
+
if (appKeyNameProblem(name) !== null || !isDatabaseSecret(databaseSecret)) return null;
|
|
172
|
+
const [spaceId = "", region = "", ...domain] = url.hostname.split(".");
|
|
173
|
+
if (!isSpaceId(spaceId) || !isRegion(region) || domain.length < 2) return null;
|
|
174
|
+
const port = url.port === "" ? RILODB_DEFAULT_PORT : Number(url.port);
|
|
175
|
+
const path = decodeURIComponent(url.pathname.replace(/^\//, ""));
|
|
176
|
+
if (path !== "" && (!DATABASE_NAME.test(path) || RESERVED_DATABASES.includes(path))) return null;
|
|
177
|
+
return {
|
|
178
|
+
region,
|
|
179
|
+
spaceId,
|
|
180
|
+
name,
|
|
181
|
+
databaseSecret,
|
|
182
|
+
spacesDomain: domain.join("."),
|
|
183
|
+
port,
|
|
184
|
+
database: path === "" ? null : path
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
/** The `mongodb://` link a driver needs for a `rilodb://` link's parts. */
|
|
188
|
+
function mongoLinkOfRilodb(parts, options = {}) {
|
|
189
|
+
return mongoLink({
|
|
190
|
+
loginRealName: `${parts.name}_${parts.spaceId}`,
|
|
191
|
+
password: parts.databaseSecret,
|
|
192
|
+
host: `${parts.spaceId}.${parts.region}.${parts.spacesDomain}`,
|
|
193
|
+
port: parts.port,
|
|
194
|
+
databaseRealName: parts.database ? `${parts.database}_${parts.spaceId}` : null,
|
|
195
|
+
tlsCAFile: options.tlsCAFile
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
const MISSING = `No app key was given. Set ${APP_KEY_ENV_VAR} to your app key from the App keys page of your Space, on your server, and pass it in.`;
|
|
199
|
+
const MISSING_EITHER = `${MISSING} A rilodb:// link works too, for databases only.`;
|
|
200
|
+
const FILES_KEY = `That is a files key (it starts with rdbf_), not an app key. Use the app key, which starts with ${APP_KEY_PREFIX}. It works for databases and Files.`;
|
|
201
|
+
const MONGO_LINK = `That is a mongodb:// connect link, not an app key. Use the app key, which starts with ${APP_KEY_PREFIX}. It works for databases and Files. MongoDB drivers and tools use the mongodb:// link; this client reads the app key.`;
|
|
202
|
+
const MONGO_LINK_EITHER = `That is a mongodb:// connect link, not an app key or a rilodb:// link. Use the app key, which starts with ${APP_KEY_PREFIX}, or the rilodb:// link. MongoDB drivers and tools use the mongodb:// link; this client reads the app key or the rilodb:// link.`;
|
|
203
|
+
const RILODB_LINK = `That is a rilodb:// link, not an app key. This needs the app key (${APP_KEY_ENV_VAR}), which starts with ${APP_KEY_PREFIX}. A rilodb:// link holds only the database part.`;
|
|
204
|
+
const MALFORMED = `That is not a valid app key. An app key starts with ${APP_KEY_PREFIX} and has four parts separated by dots. Copy the whole key again from the App keys page. A lost key can't be shown again, so reset it there.`;
|
|
205
|
+
const MALFORMED_EITHER = `That is not a valid app key or rilodb:// link. An app key starts with ${APP_KEY_PREFIX} and has four parts separated by dots. Copy the whole key again from the App keys page. A lost key can't be shown again, so reset it there.`;
|
|
206
|
+
const MALFORMED_RILODB_LINK = `That is not a valid rilodb:// link. It looks like rilodb://<key name>:<database secret>@<space>.<region>.<domain>/<database>. Copy the whole link again, or use your app key (${APP_KEY_ENV_VAR}). A lost key can't be shown again, so reset it on the App keys page.`;
|
|
207
|
+
const isRilodbLink = (text) => /^rilodb:\/\//i.test(text);
|
|
208
|
+
const isMongoLink = (text) => /^mongodb(\+srv)?:\/\//i.test(text);
|
|
209
|
+
/** `parseRilodbLink`, with the one way it can still throw (a bad `%` escape) turned into null. */
|
|
210
|
+
function readRilodbLink(text) {
|
|
211
|
+
try {
|
|
212
|
+
return parseRilodbLink(text);
|
|
213
|
+
} catch {
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* The text of a key, trimmed, or a `RiloKeyError` that says what was given instead. `orLink` words
|
|
219
|
+
* the refusals for a place that takes a `rilodb://` link too. No message repeats any of the input.
|
|
220
|
+
*/
|
|
221
|
+
function keyText(key, orLink) {
|
|
222
|
+
if (key === void 0 || key === null || typeof key === "string" && key.trim() === "") throw new RiloKeyError(orLink ? MISSING_EITHER : MISSING);
|
|
223
|
+
if (typeof key !== "string") throw new RiloKeyError(orLink ? MALFORMED_EITHER : MALFORMED);
|
|
224
|
+
const text = key.trim();
|
|
225
|
+
if (text.startsWith("rdbf_")) throw new RiloKeyError(FILES_KEY);
|
|
226
|
+
if (isMongoLink(text)) throw new RiloKeyError(orLink ? MONGO_LINK_EITHER : MONGO_LINK);
|
|
227
|
+
if (isRilodbLink(text)) throw new RiloKeyError(RILODB_LINK);
|
|
228
|
+
return text;
|
|
229
|
+
}
|
|
230
|
+
function resolveAppKey(text, orLink, options, database) {
|
|
231
|
+
const parts = parseAppKey(text);
|
|
232
|
+
if (parts === null) throw new RiloKeyError(orLink ? MALFORMED_EITHER : MALFORMED);
|
|
233
|
+
const spacesDomain = options.spacesDomain ?? "db.services.rilo.studio";
|
|
234
|
+
const filesUrl = (options.filesUrl ?? `https://files.${parts.region}.${spacesDomain}`).replace(/\/+$/, "");
|
|
235
|
+
return {
|
|
236
|
+
parts,
|
|
237
|
+
values: appKeyValues(parts, {
|
|
238
|
+
spacesDomain,
|
|
239
|
+
port: options.port ?? 27017,
|
|
240
|
+
filesUrl,
|
|
241
|
+
tlsCAFile: options.tlsCAFile
|
|
242
|
+
}, database)
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* What an app key or a `rilodb://` link says, or a `RiloKeyError`. A link brings its own address
|
|
247
|
+
* (`spacesDomain` and `port` come from it); only `tlsCAFile` still applies. The error says what was
|
|
248
|
+
* given and what to give, and never contains any part of the input.
|
|
249
|
+
*/
|
|
250
|
+
function resolveTarget(input, options = {}) {
|
|
251
|
+
if (typeof input === "string" && isRilodbLink(input.trim())) {
|
|
252
|
+
const link = readRilodbLink(input.trim());
|
|
253
|
+
if (link === null) throw new RiloKeyError(MALFORMED_RILODB_LINK);
|
|
254
|
+
return {
|
|
255
|
+
connectLink: mongoLinkOfRilodb(link, { tlsCAFile: options.tlsCAFile }),
|
|
256
|
+
spaceId: link.spaceId,
|
|
257
|
+
database: link.database,
|
|
258
|
+
files: null
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
const { parts, values } = resolveAppKey(keyText(input, true), true, options);
|
|
262
|
+
return {
|
|
263
|
+
connectLink: values.connectLink,
|
|
264
|
+
spaceId: parts.spaceId,
|
|
265
|
+
database: null,
|
|
266
|
+
files: {
|
|
267
|
+
url: values.filesUrl,
|
|
268
|
+
key: values.filesKey
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
export { RiloFilesError as n, RiloKeyError as r, resolveTarget as t };
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
// src/errors.ts
|
|
2
|
+
/** The key or link is missing or isn't an app key or a `rilodb://` link. The message never repeats it. */
|
|
3
|
+
var RiloKeyError = class extends Error {
|
|
4
|
+
name = "RiloKeyError";
|
|
5
|
+
};
|
|
6
|
+
/** What the Files API answers when a call fails: its `error` body, plus the HTTP status. */
|
|
7
|
+
var RiloFilesError = class extends Error {
|
|
8
|
+
name = "RiloFilesError";
|
|
9
|
+
/** The HTTP status. */
|
|
10
|
+
status;
|
|
11
|
+
/** A stable name for the error, like `read_only_key`. Decide what to do from this. */
|
|
12
|
+
code;
|
|
13
|
+
/** Extra data for some codes, like `maxFileSize` on `file_too_large`. */
|
|
14
|
+
details;
|
|
15
|
+
/** On `validation_failed`: each field that needs fixing, with what to fix. */
|
|
16
|
+
fields;
|
|
17
|
+
constructor(init) {
|
|
18
|
+
super(init.message);
|
|
19
|
+
this.status = init.status;
|
|
20
|
+
this.code = init.code;
|
|
21
|
+
this.details = init.details ?? null;
|
|
22
|
+
this.fields = init.fields ?? null;
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
// ../shared/src/connect.ts
|
|
26
|
+
/**
|
|
27
|
+
* The fixed options of every link, in this order. `directConnection` keeps the driver talking only
|
|
28
|
+
* to the Gateway; DocumentDB has no retryable writes; logins live in `admin`; `maxPoolSize=10`
|
|
29
|
+
* fits 4 app instances in the Seed limit of 50, with 2 monitoring connections each (decision Q3).
|
|
30
|
+
*/
|
|
31
|
+
const CONNECT_OPTIONS = {
|
|
32
|
+
tls: true,
|
|
33
|
+
directConnection: true,
|
|
34
|
+
retryWrites: false,
|
|
35
|
+
authSource: "admin",
|
|
36
|
+
maxPoolSize: 10
|
|
37
|
+
};
|
|
38
|
+
// ../shared/src/logins.ts
|
|
39
|
+
/**
|
|
40
|
+
* The rule for a password the person chooses (step 11): 16 to 64 letters, digits, `-` and `_`.
|
|
41
|
+
* These are the characters a `mongodb://` link carries without encoding. Rilo's own suggestion is
|
|
42
|
+
* 24 random bytes in base64url (32 characters), which always passes.
|
|
43
|
+
*/
|
|
44
|
+
const LOGIN_PASSWORD = {
|
|
45
|
+
min: 16,
|
|
46
|
+
max: 64,
|
|
47
|
+
pattern: /^[A-Za-z0-9_-]+$/
|
|
48
|
+
};
|
|
49
|
+
// ../shared/src/regions.ts
|
|
50
|
+
const REGION_CODES = ["in", "au"];
|
|
51
|
+
const SPACE_ID_PATTERN = new RegExp(`^[bcdfghjkmnpqrstvwxz23456789]{8}$`);
|
|
52
|
+
function isSpaceId(value) {
|
|
53
|
+
return SPACE_ID_PATTERN.test(value);
|
|
54
|
+
}
|
|
55
|
+
// ../shared/src/app-keys.ts
|
|
56
|
+
/** Every app key starts with this, so people, scanners and the Files service recognise one. */
|
|
57
|
+
const APP_KEY_PREFIX = "rilo_";
|
|
58
|
+
/** The environment variable an app keeps its key in. */
|
|
59
|
+
const APP_KEY_ENV_VAR = "RILO_KEY";
|
|
60
|
+
/**
|
|
61
|
+
* An app key's name is also its login's and its files key's name, so it follows both rules:
|
|
62
|
+
* lowercase letters, digits and single hyphens, starting with a letter, 3 to 32 characters.
|
|
63
|
+
*/
|
|
64
|
+
const APP_KEY_NAME = {
|
|
65
|
+
min: 3,
|
|
66
|
+
max: 32,
|
|
67
|
+
pattern: /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/
|
|
68
|
+
};
|
|
69
|
+
/** The files secret: 32 random bytes in base64url, 43 characters. */
|
|
70
|
+
const FILES_SECRET = /^[A-Za-z0-9_-]{43}$/;
|
|
71
|
+
const IDENTITY = /^rilo_([a-z]+)_([a-z0-9]+)$/;
|
|
72
|
+
/** What is wrong with an app key name, in plain words, or null. Nothing is trimmed or lowered. */
|
|
73
|
+
function appKeyNameProblem(name) {
|
|
74
|
+
if (name === "") return "Enter a name for the key.";
|
|
75
|
+
if (name.trim() !== name) return "Remove the spaces at the start or end of the name.";
|
|
76
|
+
if (/\s/.test(name)) return "Use hyphens instead of spaces, like web-app.";
|
|
77
|
+
if (/[A-Z]/.test(name)) return "Use lowercase letters only, like web-app.";
|
|
78
|
+
if (!/^[a-z0-9-]+$/.test(name)) return "Use only lowercase letters, digits and hyphens.";
|
|
79
|
+
if (!/^[a-z]/.test(name)) return "Start the name with a letter.";
|
|
80
|
+
if (name.includes("--")) return "Use single hyphens, never two in a row.";
|
|
81
|
+
if (name.endsWith("-")) return "End the name with a letter or digit, not a hyphen.";
|
|
82
|
+
if (name.length < APP_KEY_NAME.min) return `Use at least ${APP_KEY_NAME.min} characters.`;
|
|
83
|
+
if (name.length > APP_KEY_NAME.max) return `Use ${APP_KEY_NAME.max} characters or fewer.`;
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
function isDatabaseSecret(value) {
|
|
87
|
+
return LOGIN_PASSWORD.pattern.test(value) && value.length >= LOGIN_PASSWORD.min && value.length <= LOGIN_PASSWORD.max;
|
|
88
|
+
}
|
|
89
|
+
function isRegion(value) {
|
|
90
|
+
return REGION_CODES.includes(value);
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* The parts of an app key, or null when the text isn't exactly one. Strict: anything else, even
|
|
94
|
+
* with the right prefix, is refused. It says nothing about whether the key is real.
|
|
95
|
+
*/
|
|
96
|
+
function parseAppKey(key) {
|
|
97
|
+
if (!key.startsWith("rilo_")) return null;
|
|
98
|
+
const pieces = key.split(".");
|
|
99
|
+
if (pieces.length !== 4) return null;
|
|
100
|
+
const [identity = "", name = "", databaseSecret = "", filesSecret = ""] = pieces;
|
|
101
|
+
const match = IDENTITY.exec(identity);
|
|
102
|
+
const region = match?.[1];
|
|
103
|
+
const spaceId = match?.[2];
|
|
104
|
+
if (region === void 0 || spaceId === void 0) return null;
|
|
105
|
+
if (!isRegion(region) || !isSpaceId(spaceId)) return null;
|
|
106
|
+
if (appKeyNameProblem(name) !== null) return null;
|
|
107
|
+
if (!isDatabaseSecret(databaseSecret) || !FILES_SECRET.test(filesSecret)) return null;
|
|
108
|
+
return {
|
|
109
|
+
region,
|
|
110
|
+
spaceId,
|
|
111
|
+
name,
|
|
112
|
+
databaseSecret,
|
|
113
|
+
filesSecret
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
/** The port every Space's databases answer on in production; a `rilodb://` link leaves it out. */
|
|
117
|
+
const RILODB_DEFAULT_PORT = 27017;
|
|
118
|
+
/** The `mongodb://` link MongoDB drivers and tools need, with the password in it. */
|
|
119
|
+
function mongoLink(link) {
|
|
120
|
+
const options = Object.entries(CONNECT_OPTIONS).map(([key, value]) => `${key}=${value}`);
|
|
121
|
+
if (link.tlsCAFile) options.push(`tlsCAFile=${encodeURIComponent(link.tlsCAFile)}`);
|
|
122
|
+
const path = link.databaseRealName ? encodeURIComponent(link.databaseRealName) : "";
|
|
123
|
+
return `mongodb://${`${encodeURIComponent(link.loginRealName)}:${encodeURIComponent(link.password)}`}@${link.host}:${link.port}/${path}?${options.join("&")}`;
|
|
124
|
+
}
|
|
125
|
+
/** Everything an app needs from its key: the same values the Connect card shows. */
|
|
126
|
+
function appKeyValues(parts, addresses, database) {
|
|
127
|
+
const loginRealName = `${parts.name}_${parts.spaceId}`;
|
|
128
|
+
const host = `${parts.spaceId}.${parts.region}.${addresses.spacesDomain}`;
|
|
129
|
+
return {
|
|
130
|
+
loginRealName,
|
|
131
|
+
filesKey: `rdbf_${parts.spaceId}_${parts.filesSecret}`,
|
|
132
|
+
host,
|
|
133
|
+
filesUrl: addresses.filesUrl,
|
|
134
|
+
connectLink: mongoLink({
|
|
135
|
+
loginRealName,
|
|
136
|
+
password: parts.databaseSecret,
|
|
137
|
+
host,
|
|
138
|
+
port: addresses.port,
|
|
139
|
+
databaseRealName: database ? `${database}_${parts.spaceId}` : null,
|
|
140
|
+
tlsCAFile: addresses.tlsCAFile
|
|
141
|
+
}),
|
|
142
|
+
rilodbLink: formatRilodbLink(parts, addresses, database)
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
/** A database name, as `DATABASE_NAME` in names.ts (repeated here to stay free of dependencies). */
|
|
146
|
+
const DATABASE_NAME = /^[a-z][a-z0-9_-]{0,39}$/;
|
|
147
|
+
const RESERVED_DATABASES = [
|
|
148
|
+
"admin",
|
|
149
|
+
"local",
|
|
150
|
+
"config"
|
|
151
|
+
];
|
|
152
|
+
/** The `rilodb://` link of a key, naming `database` when one is given. */
|
|
153
|
+
function formatRilodbLink(parts, addresses, database) {
|
|
154
|
+
const port = addresses.port === 27017 ? "" : `:${addresses.port}`;
|
|
155
|
+
const host = `${parts.spaceId}.${parts.region}.${addresses.spacesDomain}${port}`;
|
|
156
|
+
const path = database ? encodeURIComponent(database) : "";
|
|
157
|
+
return `rilodb://${parts.name}:${parts.databaseSecret}@${host}/${path}`;
|
|
158
|
+
}
|
|
159
|
+
/** The parts of a `rilodb://` link, or null when the text isn't exactly one. Strict, like keys. */
|
|
160
|
+
function parseRilodbLink(link) {
|
|
161
|
+
if (!link.startsWith(`rilodb://`)) return null;
|
|
162
|
+
let url;
|
|
163
|
+
try {
|
|
164
|
+
url = new URL(link);
|
|
165
|
+
} catch {
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
if (url.search !== "" || url.hash !== "") return null;
|
|
169
|
+
const name = decodeURIComponent(url.username);
|
|
170
|
+
const databaseSecret = decodeURIComponent(url.password);
|
|
171
|
+
if (appKeyNameProblem(name) !== null || !isDatabaseSecret(databaseSecret)) return null;
|
|
172
|
+
const [spaceId = "", region = "", ...domain] = url.hostname.split(".");
|
|
173
|
+
if (!isSpaceId(spaceId) || !isRegion(region) || domain.length < 2) return null;
|
|
174
|
+
const port = url.port === "" ? RILODB_DEFAULT_PORT : Number(url.port);
|
|
175
|
+
const path = decodeURIComponent(url.pathname.replace(/^\//, ""));
|
|
176
|
+
if (path !== "" && (!DATABASE_NAME.test(path) || RESERVED_DATABASES.includes(path))) return null;
|
|
177
|
+
return {
|
|
178
|
+
region,
|
|
179
|
+
spaceId,
|
|
180
|
+
name,
|
|
181
|
+
databaseSecret,
|
|
182
|
+
spacesDomain: domain.join("."),
|
|
183
|
+
port,
|
|
184
|
+
database: path === "" ? null : path
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
/** The `mongodb://` link a driver needs for a `rilodb://` link's parts. */
|
|
188
|
+
function mongoLinkOfRilodb(parts, options = {}) {
|
|
189
|
+
return mongoLink({
|
|
190
|
+
loginRealName: `${parts.name}_${parts.spaceId}`,
|
|
191
|
+
password: parts.databaseSecret,
|
|
192
|
+
host: `${parts.spaceId}.${parts.region}.${parts.spacesDomain}`,
|
|
193
|
+
port: parts.port,
|
|
194
|
+
databaseRealName: parts.database ? `${parts.database}_${parts.spaceId}` : null,
|
|
195
|
+
tlsCAFile: options.tlsCAFile
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
const MISSING = `No app key was given. Set ${APP_KEY_ENV_VAR} to your app key from the App keys page of your Space, on your server, and pass it in.`;
|
|
199
|
+
const MISSING_EITHER = `${MISSING} A rilodb:// link works too, for databases only.`;
|
|
200
|
+
const FILES_KEY = `That is a files key (it starts with rdbf_), not an app key. Use the app key, which starts with ${APP_KEY_PREFIX}. It works for databases and Files.`;
|
|
201
|
+
const MONGO_LINK = `That is a mongodb:// connect link, not an app key. Use the app key, which starts with ${APP_KEY_PREFIX}. It works for databases and Files. MongoDB drivers and tools use the mongodb:// link; this client reads the app key.`;
|
|
202
|
+
const MONGO_LINK_EITHER = `That is a mongodb:// connect link, not an app key or a rilodb:// link. Use the app key, which starts with ${APP_KEY_PREFIX}, or the rilodb:// link. MongoDB drivers and tools use the mongodb:// link; this client reads the app key or the rilodb:// link.`;
|
|
203
|
+
const RILODB_LINK = `That is a rilodb:// link, not an app key. This needs the app key (${APP_KEY_ENV_VAR}), which starts with ${APP_KEY_PREFIX}. A rilodb:// link holds only the database part.`;
|
|
204
|
+
const MALFORMED = `That is not a valid app key. An app key starts with ${APP_KEY_PREFIX} and has four parts separated by dots. Copy the whole key again from the App keys page. A lost key can't be shown again, so reset it there.`;
|
|
205
|
+
const MALFORMED_EITHER = `That is not a valid app key or rilodb:// link. An app key starts with ${APP_KEY_PREFIX} and has four parts separated by dots. Copy the whole key again from the App keys page. A lost key can't be shown again, so reset it there.`;
|
|
206
|
+
const MALFORMED_RILODB_LINK = `That is not a valid rilodb:// link. It looks like rilodb://<key name>:<database secret>@<space>.<region>.<domain>/<database>. Copy the whole link again, or use your app key (${APP_KEY_ENV_VAR}). A lost key can't be shown again, so reset it on the App keys page.`;
|
|
207
|
+
const isRilodbLink = (text) => /^rilodb:\/\//i.test(text);
|
|
208
|
+
const isMongoLink = (text) => /^mongodb(\+srv)?:\/\//i.test(text);
|
|
209
|
+
/** `parseRilodbLink`, with the one way it can still throw (a bad `%` escape) turned into null. */
|
|
210
|
+
function readRilodbLink(text) {
|
|
211
|
+
try {
|
|
212
|
+
return parseRilodbLink(text);
|
|
213
|
+
} catch {
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* The text of a key, trimmed, or a `RiloKeyError` that says what was given instead. `orLink` words
|
|
219
|
+
* the refusals for a place that takes a `rilodb://` link too. No message repeats any of the input.
|
|
220
|
+
*/
|
|
221
|
+
function keyText(key, orLink) {
|
|
222
|
+
if (key === void 0 || key === null || typeof key === "string" && key.trim() === "") throw new RiloKeyError(orLink ? MISSING_EITHER : MISSING);
|
|
223
|
+
if (typeof key !== "string") throw new RiloKeyError(orLink ? MALFORMED_EITHER : MALFORMED);
|
|
224
|
+
const text = key.trim();
|
|
225
|
+
if (text.startsWith("rdbf_")) throw new RiloKeyError(FILES_KEY);
|
|
226
|
+
if (isMongoLink(text)) throw new RiloKeyError(orLink ? MONGO_LINK_EITHER : MONGO_LINK);
|
|
227
|
+
if (isRilodbLink(text)) throw new RiloKeyError(RILODB_LINK);
|
|
228
|
+
return text;
|
|
229
|
+
}
|
|
230
|
+
function resolveAppKey(text, orLink, options, database) {
|
|
231
|
+
const parts = parseAppKey(text);
|
|
232
|
+
if (parts === null) throw new RiloKeyError(orLink ? MALFORMED_EITHER : MALFORMED);
|
|
233
|
+
const spacesDomain = options.spacesDomain ?? "db.services.rilo.studio";
|
|
234
|
+
const filesUrl = (options.filesUrl ?? `https://files.${parts.region}.${spacesDomain}`).replace(/\/+$/, "");
|
|
235
|
+
return {
|
|
236
|
+
parts,
|
|
237
|
+
values: appKeyValues(parts, {
|
|
238
|
+
spacesDomain,
|
|
239
|
+
port: options.port ?? 27017,
|
|
240
|
+
filesUrl,
|
|
241
|
+
tlsCAFile: options.tlsCAFile
|
|
242
|
+
}, database)
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* What an app key or a `rilodb://` link says, or a `RiloKeyError`. A link brings its own address
|
|
247
|
+
* (`spacesDomain` and `port` come from it); only `tlsCAFile` still applies. The error says what was
|
|
248
|
+
* given and what to give, and never contains any part of the input.
|
|
249
|
+
*/
|
|
250
|
+
function resolveTarget(input, options = {}) {
|
|
251
|
+
if (typeof input === "string" && isRilodbLink(input.trim())) {
|
|
252
|
+
const link = readRilodbLink(input.trim());
|
|
253
|
+
if (link === null) throw new RiloKeyError(MALFORMED_RILODB_LINK);
|
|
254
|
+
return {
|
|
255
|
+
connectLink: mongoLinkOfRilodb(link, { tlsCAFile: options.tlsCAFile }),
|
|
256
|
+
spaceId: link.spaceId,
|
|
257
|
+
database: link.database,
|
|
258
|
+
files: null
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
const { parts, values } = resolveAppKey(keyText(input, true), true, options);
|
|
262
|
+
return {
|
|
263
|
+
connectLink: values.connectLink,
|
|
264
|
+
spaceId: parts.spaceId,
|
|
265
|
+
database: null,
|
|
266
|
+
files: {
|
|
267
|
+
url: values.filesUrl,
|
|
268
|
+
key: values.filesKey
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
Object.defineProperty(exports, "RiloFilesError", {
|
|
273
|
+
enumerable: true,
|
|
274
|
+
get: function() {
|
|
275
|
+
return RiloFilesError;
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
Object.defineProperty(exports, "RiloKeyError", {
|
|
279
|
+
enumerable: true,
|
|
280
|
+
get: function() {
|
|
281
|
+
return RiloKeyError;
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
Object.defineProperty(exports, "resolveTarget", {
|
|
285
|
+
enumerable: true,
|
|
286
|
+
get: function() {
|
|
287
|
+
return resolveTarget;
|
|
288
|
+
}
|
|
289
|
+
});
|