@runqora/sdk 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 +99 -2
- package/dist/auth.d.ts +55 -0
- package/dist/auth.js +64 -0
- package/dist/better-auth.d.ts +13 -0
- package/dist/better-auth.js +223 -0
- package/dist/bridge-CckdQVKg.js +171 -0
- package/dist/index.d.ts +147 -0
- package/dist/index.js +239 -0
- package/dist/protocol.d.ts +91 -0
- package/dist/protocol.js +63 -0
- package/package.json +77 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Amblydia
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,100 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @runqora/sdk
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Everything your server needs to work with [Runqora](https://runqora.dev):
|
|
4
|
+
verify the API keys your customers send, send and receive webhooks, report
|
|
5
|
+
cron heartbeats, and let Runqora manage your application's users.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
bun add @runqora/sdk
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
It has no dependencies and uses only `fetch` and Web Crypto, so it runs in
|
|
12
|
+
Node.js, Bun, Deno and edge runtimes. Use it on a server: the API key and the
|
|
13
|
+
auth secret must never reach a browser.
|
|
14
|
+
|
|
15
|
+
## API keys, webhooks and cron monitors
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { Runqora } from "@runqora/sdk";
|
|
19
|
+
|
|
20
|
+
const runqora = new Runqora({ apiKey: process.env.RUNQORA_API_KEY! });
|
|
21
|
+
|
|
22
|
+
const result = await runqora.keys.verify(key, { scopes: ["orders:read"] });
|
|
23
|
+
await runqora.webhooks.send({ type: "order.created", data: { id: "o_1" } });
|
|
24
|
+
await runqora.monitoring.heartbeat(process.env.NIGHTLY_EXPORT_MONITOR_ID!);
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Receiving webhooks Runqora sends to your application:
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
import { verifyWebhook } from "@runqora/sdk";
|
|
31
|
+
|
|
32
|
+
export async function POST(request: Request) {
|
|
33
|
+
const event = await verifyWebhook({
|
|
34
|
+
headers: request.headers,
|
|
35
|
+
payload: await request.text(),
|
|
36
|
+
secret: process.env.RUNQORA_WEBHOOK_SECRET!,
|
|
37
|
+
});
|
|
38
|
+
// event.id is the same on every retry, so use it to skip duplicates.
|
|
39
|
+
return new Response(null, { status: 204 });
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Users, with Better Auth
|
|
44
|
+
|
|
45
|
+
`@runqora/sdk/better-auth` is a Better Auth plugin (Better Auth 1.6.13 or
|
|
46
|
+
later). Your users stay in your database: the plugin answers signed requests
|
|
47
|
+
from Runqora with data it reads through Better Auth's own adapter, so custom
|
|
48
|
+
table and field names keep working.
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
import { betterAuth } from "better-auth";
|
|
52
|
+
import { runqora } from "@runqora/sdk/better-auth";
|
|
53
|
+
|
|
54
|
+
export const auth = betterAuth({
|
|
55
|
+
// ...your existing configuration
|
|
56
|
+
plugins: [runqora({ secret: process.env.RUNQORA_AUTH_SECRET! })],
|
|
57
|
+
});
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Then connect Better Auth in your Runqora project with your Better Auth URL
|
|
61
|
+
(usually your site followed by `/api/auth`) and the same secret. Banning users
|
|
62
|
+
and showing roles need Better Auth's `admin` plugin; organizations need its
|
|
63
|
+
`organization` plugin. Without them, Runqora hides those features.
|
|
64
|
+
|
|
65
|
+
## Users, with any other auth system
|
|
66
|
+
|
|
67
|
+
`@runqora/sdk/auth` turns a few functions into a handler you mount at a route:
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
import { createAuthBridge } from "@runqora/sdk/auth";
|
|
71
|
+
|
|
72
|
+
const bridge = createAuthBridge({
|
|
73
|
+
basePath: "/api/runqora",
|
|
74
|
+
secret: process.env.RUNQORA_AUTH_SECRET!,
|
|
75
|
+
users: {
|
|
76
|
+
get: (id) => db.users.find(id),
|
|
77
|
+
list: ({ limit, offset, search }) => db.users.page({ limit, offset, search }),
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// Any runtime with Web Request/Response: Next.js route handlers, Hono, Bun...
|
|
82
|
+
export const GET = bridge;
|
|
83
|
+
export const POST = bridge;
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`users.get` and `users.list` are required. Every optional function you add
|
|
87
|
+
(`users.ban`, `users.unban`, `sessions`, `organizations`, `stats`) switches the
|
|
88
|
+
matching dashboard feature on.
|
|
89
|
+
|
|
90
|
+
`RUNQORA_AUTH_SECRET` is shared with Runqora when you connect the integration.
|
|
91
|
+
It must be at least 32 characters.
|
|
92
|
+
|
|
93
|
+
## Contributing
|
|
94
|
+
|
|
95
|
+
Issues and pull requests are welcome; see [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
96
|
+
Report security problems privately, as [SECURITY.md](SECURITY.md) describes.
|
|
97
|
+
|
|
98
|
+
## License
|
|
99
|
+
|
|
100
|
+
[MIT](LICENSE)
|
package/dist/auth.d.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { BridgeOrganization, BridgeSession, BridgeStats, BridgeUser } from "./protocol.js";
|
|
2
|
+
//#region src/auth/bridge.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* What an application provides to be manageable from Runqora. Only
|
|
5
|
+
* `users.list` and `users.get` are required; every optional function that is
|
|
6
|
+
* present switches the matching dashboard feature on.
|
|
7
|
+
*/
|
|
8
|
+
interface AuthSource {
|
|
9
|
+
organizations?: {
|
|
10
|
+
list: () => Promise<BridgeOrganization[]>;
|
|
11
|
+
};
|
|
12
|
+
/** Set when users carry a `role`. */
|
|
13
|
+
roles?: boolean;
|
|
14
|
+
sessions?: {
|
|
15
|
+
list: (input: {
|
|
16
|
+
limit: number;
|
|
17
|
+
offset: number;
|
|
18
|
+
userId?: string;
|
|
19
|
+
}) => Promise<BridgeSession[]>;
|
|
20
|
+
revoke: (id: string) => Promise<void>;
|
|
21
|
+
};
|
|
22
|
+
stats?: () => Promise<Partial<BridgeStats>>;
|
|
23
|
+
users: {
|
|
24
|
+
ban?: (id: string) => Promise<void>;
|
|
25
|
+
get: (id: string) => Promise<BridgeUser | null>;
|
|
26
|
+
list: (input: {
|
|
27
|
+
limit: number;
|
|
28
|
+
offset: number;
|
|
29
|
+
search?: string;
|
|
30
|
+
}) => Promise<{
|
|
31
|
+
total?: number | null;
|
|
32
|
+
users: BridgeUser[];
|
|
33
|
+
}>;
|
|
34
|
+
unban?: (id: string) => Promise<void>;
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Throw this from an `AuthSource` function when the user or session it was
|
|
39
|
+
* asked to change does not exist. Runqora then reports "not found".
|
|
40
|
+
*/
|
|
41
|
+
export declare class BridgeNotFoundError extends Error {
|
|
42
|
+
constructor(message?: string);
|
|
43
|
+
}
|
|
44
|
+
//#endregion
|
|
45
|
+
//#region src/auth/index.d.ts
|
|
46
|
+
export interface AuthBridgeOptions extends AuthSource {
|
|
47
|
+
/** Path the handler is mounted at, e.g. "/api/runqora". */
|
|
48
|
+
basePath: string;
|
|
49
|
+
/** Shared with Runqora when connecting. At least 32 characters; keep it in an environment variable. */
|
|
50
|
+
secret: string;
|
|
51
|
+
}
|
|
52
|
+
/** Returns a `(request: Request) => Promise<Response>` handler for the bridge. */
|
|
53
|
+
export declare function createAuthBridge(options: AuthBridgeOptions): (request: Request) => Promise<Response>;
|
|
54
|
+
//#endregion
|
|
55
|
+
export type { AuthSource, BridgeOrganization, BridgeSession, BridgeStats, BridgeUser };
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { n as createBridge, r as readBridgeHeaders, t as BridgeNotFoundError } from "./bridge-CckdQVKg.js";
|
|
2
|
+
//#region src/auth/index.ts
|
|
3
|
+
/**
|
|
4
|
+
* @runqora/sdk/auth
|
|
5
|
+
*
|
|
6
|
+
* Connect any authentication system to Runqora: describe how to read your
|
|
7
|
+
* users (and, optionally, sessions and organizations) and mount the returned
|
|
8
|
+
* handler at a route in your application.
|
|
9
|
+
*
|
|
10
|
+
* ```ts
|
|
11
|
+
* import { createAuthBridge } from "@runqora/sdk/auth";
|
|
12
|
+
*
|
|
13
|
+
* const bridge = createAuthBridge({
|
|
14
|
+
* basePath: "/api/runqora",
|
|
15
|
+
* secret: process.env.RUNQORA_AUTH_SECRET!,
|
|
16
|
+
* users: {
|
|
17
|
+
* get: (id) => db.users.find(id),
|
|
18
|
+
* list: ({ limit, offset, search }) => db.users.page({ limit, offset, search }),
|
|
19
|
+
* },
|
|
20
|
+
* });
|
|
21
|
+
*
|
|
22
|
+
* // Any runtime with Web `Request`/`Response`: Next.js route handlers, Hono, Bun...
|
|
23
|
+
* export const GET = bridge;
|
|
24
|
+
* export const POST = bridge;
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
const EDGE_SLASHES = /^\/+|\/+$/g;
|
|
28
|
+
/** Returns a `(request: Request) => Promise<Response>` handler for the bridge. */
|
|
29
|
+
function createAuthBridge(options) {
|
|
30
|
+
const { basePath, secret, ...source } = options;
|
|
31
|
+
const bridge = createBridge(source, {
|
|
32
|
+
provider: "custom",
|
|
33
|
+
secret
|
|
34
|
+
});
|
|
35
|
+
const base = `/${basePath.replace(EDGE_SLASHES, "")}`;
|
|
36
|
+
return async (request) => {
|
|
37
|
+
const url = new URL(request.url);
|
|
38
|
+
if (url.pathname !== base && !url.pathname.startsWith(`${base}/`)) return Response.json({ error: {
|
|
39
|
+
code: "not_found",
|
|
40
|
+
message: "Not found."
|
|
41
|
+
} }, { status: 404 });
|
|
42
|
+
let segments;
|
|
43
|
+
try {
|
|
44
|
+
segments = url.pathname.slice(base.length).split("/").filter(Boolean).map(decodeURIComponent);
|
|
45
|
+
} catch {
|
|
46
|
+
return Response.json({ error: {
|
|
47
|
+
code: "bad_request",
|
|
48
|
+
message: "Malformed path."
|
|
49
|
+
} }, { status: 400 });
|
|
50
|
+
}
|
|
51
|
+
const result = await bridge.handle({
|
|
52
|
+
method: request.method,
|
|
53
|
+
query: Object.fromEntries(url.searchParams),
|
|
54
|
+
segments,
|
|
55
|
+
...readBridgeHeaders(request.headers)
|
|
56
|
+
});
|
|
57
|
+
return Response.json(result.body, {
|
|
58
|
+
headers: { "Cache-Control": "no-store" },
|
|
59
|
+
status: result.status
|
|
60
|
+
});
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
//#endregion
|
|
64
|
+
export { BridgeNotFoundError, createAuthBridge };
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { BetterAuthPlugin } from "better-auth";
|
|
2
|
+
//#region src/better-auth/index.d.ts
|
|
3
|
+
export interface RunqoraOptions {
|
|
4
|
+
/** Shared with Runqora when connecting. At least 32 characters; keep it in an environment variable. */
|
|
5
|
+
secret: string;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Typed as a plain `BetterAuthPlugin`: its endpoints are for Runqora's servers,
|
|
9
|
+
* not the application's own code, and naming their exact types would tie this
|
|
10
|
+
* package to the Better Auth version it was built with.
|
|
11
|
+
*/
|
|
12
|
+
export declare function runqora(options: RunqoraOptions): BetterAuthPlugin;
|
|
13
|
+
//#endregion
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { n as createBridge, r as readBridgeHeaders, t as BridgeNotFoundError } from "./bridge-CckdQVKg.js";
|
|
2
|
+
import { createAuthEndpoint } from "better-auth/api";
|
|
3
|
+
//#region src/better-auth/index.ts
|
|
4
|
+
const MAX_ORGANIZATIONS = 100;
|
|
5
|
+
const RATE_LIMIT_WINDOW_SECONDS = 60;
|
|
6
|
+
const RATE_LIMIT_MAX_REQUESTS = 600;
|
|
7
|
+
function toIso(value) {
|
|
8
|
+
return value ? new Date(value).toISOString() : null;
|
|
9
|
+
}
|
|
10
|
+
/** Reads through Better Auth's own adapters, so custom table and field names keep working. */
|
|
11
|
+
function createSource(context) {
|
|
12
|
+
const { adapter, internalAdapter } = context;
|
|
13
|
+
const plugins = new Set((context.options.plugins ?? []).map((plugin) => plugin.id));
|
|
14
|
+
const hasAdmin = plugins.has("admin");
|
|
15
|
+
const notExpired = {
|
|
16
|
+
field: "expiresAt",
|
|
17
|
+
operator: "gt",
|
|
18
|
+
value: /* @__PURE__ */ new Date()
|
|
19
|
+
};
|
|
20
|
+
function toUser(user) {
|
|
21
|
+
return {
|
|
22
|
+
banned: hasAdmin ? Boolean(user.banned) : null,
|
|
23
|
+
createdAt: toIso(user.createdAt),
|
|
24
|
+
email: user.email,
|
|
25
|
+
emailVerified: user.emailVerified,
|
|
26
|
+
id: user.id,
|
|
27
|
+
image: user.image ?? null,
|
|
28
|
+
name: user.name,
|
|
29
|
+
role: hasAdmin ? user.role ?? null : null
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
/** Deliberately leaves out `token`, which is the session credential. */
|
|
33
|
+
function toSession(session) {
|
|
34
|
+
return {
|
|
35
|
+
createdAt: toIso(session.createdAt),
|
|
36
|
+
expiresAt: toIso(session.expiresAt),
|
|
37
|
+
id: session.id,
|
|
38
|
+
ipAddress: session.ipAddress ?? null,
|
|
39
|
+
userAgent: session.userAgent ?? null,
|
|
40
|
+
userId: session.userId
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
async function requireUser(id) {
|
|
44
|
+
if (!await internalAdapter.findUserById(id)) throw new BridgeNotFoundError();
|
|
45
|
+
}
|
|
46
|
+
const source = {
|
|
47
|
+
roles: hasAdmin,
|
|
48
|
+
sessions: {
|
|
49
|
+
async list({ limit, offset, userId }) {
|
|
50
|
+
return (await adapter.findMany({
|
|
51
|
+
limit,
|
|
52
|
+
model: "session",
|
|
53
|
+
offset,
|
|
54
|
+
sortBy: {
|
|
55
|
+
direction: "desc",
|
|
56
|
+
field: "createdAt"
|
|
57
|
+
},
|
|
58
|
+
where: userId ? [notExpired, {
|
|
59
|
+
field: "userId",
|
|
60
|
+
value: userId
|
|
61
|
+
}] : [notExpired]
|
|
62
|
+
})).map(toSession);
|
|
63
|
+
},
|
|
64
|
+
async revoke(id) {
|
|
65
|
+
const session = await adapter.findOne({
|
|
66
|
+
model: "session",
|
|
67
|
+
where: [{
|
|
68
|
+
field: "id",
|
|
69
|
+
value: id
|
|
70
|
+
}]
|
|
71
|
+
});
|
|
72
|
+
if (!session) throw new BridgeNotFoundError();
|
|
73
|
+
await internalAdapter.deleteSession(session.token);
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
async stats() {
|
|
77
|
+
const [users, activeSessions, organizations] = await Promise.all([
|
|
78
|
+
internalAdapter.countTotalUsers(),
|
|
79
|
+
adapter.count({
|
|
80
|
+
model: "session",
|
|
81
|
+
where: [notExpired]
|
|
82
|
+
}),
|
|
83
|
+
plugins.has("organization") ? adapter.count({ model: "organization" }) : null
|
|
84
|
+
]);
|
|
85
|
+
return {
|
|
86
|
+
activeSessions,
|
|
87
|
+
organizations,
|
|
88
|
+
users
|
|
89
|
+
};
|
|
90
|
+
},
|
|
91
|
+
users: {
|
|
92
|
+
async get(id) {
|
|
93
|
+
const user = await internalAdapter.findUserById(id);
|
|
94
|
+
return user ? toUser(user) : null;
|
|
95
|
+
},
|
|
96
|
+
async list({ limit, offset, search }) {
|
|
97
|
+
const where = search ? [{
|
|
98
|
+
connector: "OR",
|
|
99
|
+
field: "email",
|
|
100
|
+
mode: "insensitive",
|
|
101
|
+
operator: "contains",
|
|
102
|
+
value: search
|
|
103
|
+
}, {
|
|
104
|
+
connector: "OR",
|
|
105
|
+
field: "name",
|
|
106
|
+
mode: "insensitive",
|
|
107
|
+
operator: "contains",
|
|
108
|
+
value: search
|
|
109
|
+
}] : void 0;
|
|
110
|
+
const [users, total] = await Promise.all([internalAdapter.listUsers(limit, offset, {
|
|
111
|
+
direction: "desc",
|
|
112
|
+
field: "createdAt"
|
|
113
|
+
}, where), internalAdapter.countTotalUsers(where)]);
|
|
114
|
+
return {
|
|
115
|
+
total,
|
|
116
|
+
users: users.map(toUser)
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
if (hasAdmin) {
|
|
122
|
+
source.users.ban = async (id) => {
|
|
123
|
+
await requireUser(id);
|
|
124
|
+
await internalAdapter.updateUser(id, { banned: true });
|
|
125
|
+
await internalAdapter.deleteUserSessions(id);
|
|
126
|
+
};
|
|
127
|
+
source.users.unban = async (id) => {
|
|
128
|
+
await requireUser(id);
|
|
129
|
+
await internalAdapter.updateUser(id, {
|
|
130
|
+
banExpires: null,
|
|
131
|
+
banned: false,
|
|
132
|
+
banReason: null
|
|
133
|
+
});
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
if (plugins.has("organization")) source.organizations = { async list() {
|
|
137
|
+
return (await adapter.findMany({
|
|
138
|
+
limit: MAX_ORGANIZATIONS,
|
|
139
|
+
model: "organization",
|
|
140
|
+
sortBy: {
|
|
141
|
+
direction: "desc",
|
|
142
|
+
field: "createdAt"
|
|
143
|
+
}
|
|
144
|
+
})).map((organization) => ({
|
|
145
|
+
createdAt: toIso(organization.createdAt),
|
|
146
|
+
id: organization.id,
|
|
147
|
+
name: organization.name,
|
|
148
|
+
slug: organization.slug ?? null
|
|
149
|
+
}));
|
|
150
|
+
} };
|
|
151
|
+
return source;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Typed as a plain `BetterAuthPlugin`: its endpoints are for Runqora's servers,
|
|
155
|
+
* not the application's own code, and naming their exact types would tie this
|
|
156
|
+
* package to the Better Auth version it was built with.
|
|
157
|
+
*/
|
|
158
|
+
function runqora(options) {
|
|
159
|
+
createBridge({ users: {
|
|
160
|
+
get: async () => null,
|
|
161
|
+
list: async () => ({ users: [] })
|
|
162
|
+
} }, {
|
|
163
|
+
provider: "better-auth",
|
|
164
|
+
secret: options.secret
|
|
165
|
+
});
|
|
166
|
+
async function respond(ctx, method, segments) {
|
|
167
|
+
const result = await createBridge(createSource(ctx.context), {
|
|
168
|
+
provider: "better-auth",
|
|
169
|
+
secret: options.secret
|
|
170
|
+
}).handle({
|
|
171
|
+
method,
|
|
172
|
+
query: Object.fromEntries(new URL(ctx.request.url).searchParams),
|
|
173
|
+
segments,
|
|
174
|
+
...readBridgeHeaders(ctx.request.headers)
|
|
175
|
+
});
|
|
176
|
+
return Response.json(result.body, {
|
|
177
|
+
headers: { "Cache-Control": "no-store" },
|
|
178
|
+
status: result.status
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
const get = {
|
|
182
|
+
metadata: { isAction: false },
|
|
183
|
+
method: "GET",
|
|
184
|
+
requireRequest: true
|
|
185
|
+
};
|
|
186
|
+
const post = {
|
|
187
|
+
...get,
|
|
188
|
+
method: "POST"
|
|
189
|
+
};
|
|
190
|
+
return {
|
|
191
|
+
endpoints: {
|
|
192
|
+
runqoraBanUser: createAuthEndpoint("/runqora/users/:id/ban", post, (ctx) => respond(ctx, "POST", [
|
|
193
|
+
"users",
|
|
194
|
+
ctx.params.id,
|
|
195
|
+
"ban"
|
|
196
|
+
])),
|
|
197
|
+
runqoraGetUser: createAuthEndpoint("/runqora/users/:id", get, (ctx) => respond(ctx, "GET", ["users", ctx.params.id])),
|
|
198
|
+
runqoraInfo: createAuthEndpoint("/runqora", get, (ctx) => respond(ctx, "GET", [])),
|
|
199
|
+
runqoraListOrganizations: createAuthEndpoint("/runqora/organizations", get, (ctx) => respond(ctx, "GET", ["organizations"])),
|
|
200
|
+
runqoraListSessions: createAuthEndpoint("/runqora/sessions", get, (ctx) => respond(ctx, "GET", ["sessions"])),
|
|
201
|
+
runqoraListUsers: createAuthEndpoint("/runqora/users", get, (ctx) => respond(ctx, "GET", ["users"])),
|
|
202
|
+
runqoraRevokeSession: createAuthEndpoint("/runqora/sessions/:id/revoke", post, (ctx) => respond(ctx, "POST", [
|
|
203
|
+
"sessions",
|
|
204
|
+
ctx.params.id,
|
|
205
|
+
"revoke"
|
|
206
|
+
])),
|
|
207
|
+
runqoraStats: createAuthEndpoint("/runqora/stats", get, (ctx) => respond(ctx, "GET", ["stats"])),
|
|
208
|
+
runqoraUnbanUser: createAuthEndpoint("/runqora/users/:id/unban", post, (ctx) => respond(ctx, "POST", [
|
|
209
|
+
"users",
|
|
210
|
+
ctx.params.id,
|
|
211
|
+
"unban"
|
|
212
|
+
]))
|
|
213
|
+
},
|
|
214
|
+
id: "runqora",
|
|
215
|
+
rateLimit: [{
|
|
216
|
+
max: RATE_LIMIT_MAX_REQUESTS,
|
|
217
|
+
pathMatcher: (path) => path.startsWith("/runqora"),
|
|
218
|
+
window: RATE_LIMIT_WINDOW_SECONDS
|
|
219
|
+
}]
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
//#endregion
|
|
223
|
+
export { runqora };
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { SIGNATURE_HEADER, TIMESTAMP_HEADER, signRequest, timingSafeEqual } from "./protocol.js";
|
|
2
|
+
//#region src/auth/bridge.ts
|
|
3
|
+
const MAX_SEARCH_LENGTH = 200;
|
|
4
|
+
const MILLISECONDS = 1e3;
|
|
5
|
+
/**
|
|
6
|
+
* Throw this from an `AuthSource` function when the user or session it was
|
|
7
|
+
* asked to change does not exist. Runqora then reports "not found".
|
|
8
|
+
*/
|
|
9
|
+
var BridgeNotFoundError = class extends Error {
|
|
10
|
+
constructor(message = "Not found.") {
|
|
11
|
+
super(message);
|
|
12
|
+
this.name = "BridgeNotFoundError";
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
var BridgeError = class extends Error {
|
|
16
|
+
code;
|
|
17
|
+
status;
|
|
18
|
+
constructor(code, status, message) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.code = code;
|
|
21
|
+
this.status = status;
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
const notFound = () => new BridgeError("not_found", 404, "Not found.");
|
|
25
|
+
function toBridgeError(error) {
|
|
26
|
+
if (error instanceof BridgeError) return error;
|
|
27
|
+
if (error instanceof BridgeNotFoundError) return notFound();
|
|
28
|
+
return new BridgeError("internal", 500, "The application failed.");
|
|
29
|
+
}
|
|
30
|
+
const unsupported = (feature) => new BridgeError("unsupported", 501, `This application does not support ${feature}.`);
|
|
31
|
+
function toInteger(value, fallback) {
|
|
32
|
+
if (value === void 0) return fallback;
|
|
33
|
+
const parsed = Number(value);
|
|
34
|
+
if (!Number.isInteger(parsed) || parsed < 0) throw new BridgeError("bad_request", 400, "Invalid pagination value.");
|
|
35
|
+
return parsed;
|
|
36
|
+
}
|
|
37
|
+
function pagination(query) {
|
|
38
|
+
return {
|
|
39
|
+
limit: Math.min(Math.max(toInteger(query.limit, 50), 1), 100),
|
|
40
|
+
offset: toInteger(query.offset, 0)
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
function describeCapabilities(source) {
|
|
44
|
+
return {
|
|
45
|
+
bans: Boolean(source.users.ban && source.users.unban),
|
|
46
|
+
impersonation: false,
|
|
47
|
+
organizations: Boolean(source.organizations),
|
|
48
|
+
roles: Boolean(source.roles),
|
|
49
|
+
sessions: Boolean(source.sessions)
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* The protocol's server side, independent of any web framework. It verifies
|
|
54
|
+
* the signature, then routes the request to the `AuthSource`.
|
|
55
|
+
*/
|
|
56
|
+
function createBridge(source, options) {
|
|
57
|
+
if (options.secret.length < 32) throw new Error(`The Runqora auth secret must be at least 32 characters. Generate one with: openssl rand -base64 32`);
|
|
58
|
+
const now = options.now ?? Date.now;
|
|
59
|
+
async function verify(request, method) {
|
|
60
|
+
const timestamp = Number(request.timestamp);
|
|
61
|
+
const unauthorized = new BridgeError("unauthorized", 401, "The request signature is missing, expired or invalid.");
|
|
62
|
+
if (!(request.signature && Number.isInteger(timestamp)) || Math.abs(now() / MILLISECONDS - timestamp) > 300) throw unauthorized;
|
|
63
|
+
const expected = await signRequest({
|
|
64
|
+
method,
|
|
65
|
+
query: request.query,
|
|
66
|
+
secret: options.secret,
|
|
67
|
+
segments: request.segments,
|
|
68
|
+
timestamp
|
|
69
|
+
});
|
|
70
|
+
if (!timingSafeEqual(expected, request.signature)) throw unauthorized;
|
|
71
|
+
}
|
|
72
|
+
/** GET routes without an id, by first path segment. */
|
|
73
|
+
const collections = /* @__PURE__ */ new Map([
|
|
74
|
+
["organizations", async () => {
|
|
75
|
+
if (!source.organizations) throw unsupported("organizations");
|
|
76
|
+
return { organizations: await source.organizations.list() };
|
|
77
|
+
}],
|
|
78
|
+
["sessions", async (query) => {
|
|
79
|
+
if (!source.sessions) throw unsupported("sessions");
|
|
80
|
+
return { sessions: await source.sessions.list({
|
|
81
|
+
...pagination(query),
|
|
82
|
+
userId: query.userId || void 0
|
|
83
|
+
}) };
|
|
84
|
+
}],
|
|
85
|
+
["stats", async () => ({
|
|
86
|
+
activeSessions: null,
|
|
87
|
+
organizations: null,
|
|
88
|
+
users: null,
|
|
89
|
+
...await source.stats?.()
|
|
90
|
+
})],
|
|
91
|
+
["users", async (query) => {
|
|
92
|
+
const { search = "" } = query;
|
|
93
|
+
const result = await source.users.list({
|
|
94
|
+
...pagination(query),
|
|
95
|
+
search: search.slice(0, MAX_SEARCH_LENGTH) || void 0
|
|
96
|
+
});
|
|
97
|
+
return {
|
|
98
|
+
total: result.total ?? null,
|
|
99
|
+
users: result.users
|
|
100
|
+
};
|
|
101
|
+
}]
|
|
102
|
+
]);
|
|
103
|
+
async function getUser(id) {
|
|
104
|
+
const user = await source.users.get(id);
|
|
105
|
+
if (!user) throw notFound();
|
|
106
|
+
return { user };
|
|
107
|
+
}
|
|
108
|
+
function get(request) {
|
|
109
|
+
const [resource, id, extra] = request.segments;
|
|
110
|
+
if (extra !== void 0) throw notFound();
|
|
111
|
+
if (resource === void 0) {
|
|
112
|
+
const info = {
|
|
113
|
+
capabilities: describeCapabilities(source),
|
|
114
|
+
protocol: 1,
|
|
115
|
+
provider: options.provider
|
|
116
|
+
};
|
|
117
|
+
return Promise.resolve(info);
|
|
118
|
+
}
|
|
119
|
+
if (id !== void 0) {
|
|
120
|
+
if (resource !== "users") throw notFound();
|
|
121
|
+
return getUser(id);
|
|
122
|
+
}
|
|
123
|
+
const list = collections.get(resource);
|
|
124
|
+
if (!list) throw notFound();
|
|
125
|
+
return list(request.query);
|
|
126
|
+
}
|
|
127
|
+
async function post(request) {
|
|
128
|
+
const [resource, id, action, extra] = request.segments;
|
|
129
|
+
if (!(id && action) || extra !== void 0) throw notFound();
|
|
130
|
+
if (resource === "sessions" && action === "revoke") {
|
|
131
|
+
if (!source.sessions) throw unsupported("sessions");
|
|
132
|
+
await source.sessions.revoke(id);
|
|
133
|
+
return { ok: true };
|
|
134
|
+
}
|
|
135
|
+
if (resource === "users" && (action === "ban" || action === "unban")) {
|
|
136
|
+
const change = source.users[action];
|
|
137
|
+
if (!(source.users.ban && source.users.unban && change)) throw unsupported("banning users");
|
|
138
|
+
await change(id);
|
|
139
|
+
return { ok: true };
|
|
140
|
+
}
|
|
141
|
+
throw notFound();
|
|
142
|
+
}
|
|
143
|
+
return { async handle(request) {
|
|
144
|
+
try {
|
|
145
|
+
if (request.method !== "GET" && request.method !== "POST") throw new BridgeError("bad_request", 405, "Method not allowed.");
|
|
146
|
+
await verify(request, request.method);
|
|
147
|
+
return {
|
|
148
|
+
body: request.method === "GET" ? await get(request) : await post(request),
|
|
149
|
+
status: 200
|
|
150
|
+
};
|
|
151
|
+
} catch (error) {
|
|
152
|
+
const failure = toBridgeError(error);
|
|
153
|
+
return {
|
|
154
|
+
body: { error: {
|
|
155
|
+
code: failure.code,
|
|
156
|
+
message: failure.message
|
|
157
|
+
} },
|
|
158
|
+
status: failure.status
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
} };
|
|
162
|
+
}
|
|
163
|
+
/** Reads the protocol's two headers from any `Headers`-like object. */
|
|
164
|
+
function readBridgeHeaders(headers) {
|
|
165
|
+
return {
|
|
166
|
+
signature: headers.get(SIGNATURE_HEADER),
|
|
167
|
+
timestamp: headers.get(TIMESTAMP_HEADER)
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
//#endregion
|
|
171
|
+
export { createBridge as n, readBridgeHeaders as r, BridgeNotFoundError as t };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
//#region src/errors.d.ts
|
|
2
|
+
type RunqoraErrorCode = "invalid_response" | "network_error" | "request_failed" | "timeout";
|
|
3
|
+
/**
|
|
4
|
+
* The request did not get a usable answer: Runqora was unreachable or refused
|
|
5
|
+
* it (for example an API key without the needed permission). For key
|
|
6
|
+
* verification this says nothing about the key, so treat it as "not verified".
|
|
7
|
+
*/
|
|
8
|
+
export declare class RunqoraError extends Error {
|
|
9
|
+
readonly code: RunqoraErrorCode;
|
|
10
|
+
/** HTTP status, when Runqora answered. */
|
|
11
|
+
readonly status: number | null;
|
|
12
|
+
constructor(message: string, options: ErrorOptions & {
|
|
13
|
+
code: RunqoraErrorCode;
|
|
14
|
+
status?: number;
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
//#endregion
|
|
18
|
+
//#region src/webhook-signature.d.ts
|
|
19
|
+
/**
|
|
20
|
+
* Verification for the receiving side of a webhook.
|
|
21
|
+
*
|
|
22
|
+
* Runqora signs deliveries the way the Standard Webhooks specification
|
|
23
|
+
* describes (https://www.standardwebhooks.com), so receivers written in other
|
|
24
|
+
* languages can use any library that implements it instead of this function.
|
|
25
|
+
*/
|
|
26
|
+
type WebhookVerificationCode = "invalid_payload" | "invalid_secret" | "invalid_signature" | "missing_headers" | "timestamp_out_of_range";
|
|
27
|
+
/** The delivery cannot be trusted. Answer it with a 400 and do not act on it. */
|
|
28
|
+
export declare class WebhookVerificationError extends Error {
|
|
29
|
+
readonly code: WebhookVerificationCode;
|
|
30
|
+
constructor(code: WebhookVerificationCode, message: string);
|
|
31
|
+
}
|
|
32
|
+
interface WebhookEvent<TData = unknown> {
|
|
33
|
+
data: TData;
|
|
34
|
+
/** The same for every attempt and replay of one event: use it to deduplicate. */
|
|
35
|
+
id: string;
|
|
36
|
+
/** ISO 8601 time the event was sent to Runqora. */
|
|
37
|
+
timestamp: string;
|
|
38
|
+
type: string;
|
|
39
|
+
}
|
|
40
|
+
interface VerifyWebhookInput {
|
|
41
|
+
/** The request's headers. */
|
|
42
|
+
headers: Headers | Record<string, string | string[] | undefined>;
|
|
43
|
+
/** Overrides the clock, in milliseconds. For tests. */
|
|
44
|
+
now?: number;
|
|
45
|
+
/** The request body exactly as received, before any JSON parsing. */
|
|
46
|
+
payload: string;
|
|
47
|
+
/** The endpoint's signing secret (`whsec_...`). */
|
|
48
|
+
secret: string;
|
|
49
|
+
/** How far the delivery's timestamp may be from now. Defaults to 5 minutes. */
|
|
50
|
+
toleranceSeconds?: number;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Confirms that a delivery was sent by Runqora for this endpoint and returns
|
|
54
|
+
* its event. Throws `WebhookVerificationError` otherwise.
|
|
55
|
+
*/
|
|
56
|
+
export declare function verifyWebhook<TData = unknown>(input: VerifyWebhookInput): Promise<WebhookEvent<TData>>;
|
|
57
|
+
//#endregion
|
|
58
|
+
//#region src/index.d.ts
|
|
59
|
+
export type FetchLike = (input: string, init: RequestInit) => Promise<Response>;
|
|
60
|
+
export interface RunqoraOptions {
|
|
61
|
+
/**
|
|
62
|
+
* An API key of the project, created in the dashboard with the platform
|
|
63
|
+
* permissions this server needs. It also decides the environment: a
|
|
64
|
+
* production key only works with production keys and endpoints.
|
|
65
|
+
*/
|
|
66
|
+
apiKey: string;
|
|
67
|
+
/** Only needed when the API is not at its public address. */
|
|
68
|
+
baseUrl?: string;
|
|
69
|
+
/** For tests and runtimes without a global `fetch`. */
|
|
70
|
+
fetch?: FetchLike;
|
|
71
|
+
/** How long to wait for an answer. Defaults to 5 seconds. */
|
|
72
|
+
timeoutMs?: number;
|
|
73
|
+
}
|
|
74
|
+
/** Why a key was refused. `not_found` includes keys of other projects. */
|
|
75
|
+
export type KeyRejectionCode = "expired" | "insufficient_scopes" | "not_found" | "revoked" | "wrong_environment";
|
|
76
|
+
export interface VerifiedKey {
|
|
77
|
+
/** Identifier of the environment the key was issued for. */
|
|
78
|
+
environment: string;
|
|
79
|
+
/** ISO 8601 timestamp, or null when the key does not expire. */
|
|
80
|
+
expiresAt: string | null;
|
|
81
|
+
keyId: string;
|
|
82
|
+
name: string;
|
|
83
|
+
scopes: string[];
|
|
84
|
+
valid: true;
|
|
85
|
+
}
|
|
86
|
+
export interface RejectedKey {
|
|
87
|
+
code: KeyRejectionCode;
|
|
88
|
+
valid: false;
|
|
89
|
+
}
|
|
90
|
+
export type VerifyKeyResult = RejectedKey | VerifiedKey;
|
|
91
|
+
export interface VerifyKeyOptions {
|
|
92
|
+
/** Scopes the key must all have. */
|
|
93
|
+
scopes?: string[];
|
|
94
|
+
}
|
|
95
|
+
export interface KeysClient {
|
|
96
|
+
/**
|
|
97
|
+
* Checks a key one of your consumers presented. Resolves for every key,
|
|
98
|
+
* good or bad; rejects with `RunqoraError` only when no answer was obtained.
|
|
99
|
+
* Needs the "Verify API keys" permission.
|
|
100
|
+
*/
|
|
101
|
+
verify: (key: string, options?: VerifyKeyOptions) => Promise<VerifyKeyResult>;
|
|
102
|
+
}
|
|
103
|
+
export interface SendWebhookInput {
|
|
104
|
+
/** Any JSON value. Delivered as the event's `data`. */
|
|
105
|
+
data: unknown;
|
|
106
|
+
/**
|
|
107
|
+
* Your own identifier for this event. Sending the same id again delivers
|
|
108
|
+
* nothing new, which makes a retried request safe.
|
|
109
|
+
*/
|
|
110
|
+
id?: string;
|
|
111
|
+
/** Your own event name, e.g. `order.created`. Endpoints subscribe by type. */
|
|
112
|
+
type: string;
|
|
113
|
+
}
|
|
114
|
+
export interface SentWebhook {
|
|
115
|
+
/** Endpoints the event was queued for. Zero when none subscribe to the type. */
|
|
116
|
+
deliveries: number;
|
|
117
|
+
eventId: string;
|
|
118
|
+
}
|
|
119
|
+
export interface WebhooksClient {
|
|
120
|
+
/**
|
|
121
|
+
* Queues an event for every enabled endpoint of this key's environment that
|
|
122
|
+
* subscribes to its type. Returns as soon as it is queued; delivery and
|
|
123
|
+
* retries happen in the background. Needs the "Send webhooks" permission.
|
|
124
|
+
*/
|
|
125
|
+
send: (event: SendWebhookInput) => Promise<SentWebhook>;
|
|
126
|
+
}
|
|
127
|
+
export interface HeartbeatOptions {
|
|
128
|
+
/** Report that the job ran and failed, instead of that it finished. */
|
|
129
|
+
failed?: boolean;
|
|
130
|
+
}
|
|
131
|
+
export interface MonitoringClient {
|
|
132
|
+
/**
|
|
133
|
+
* Tells a cron monitor that its job has run. Call it when the job finishes;
|
|
134
|
+
* if no heartbeat arrives in time, the monitor turns late and then failed.
|
|
135
|
+
* Needs the "Send cron heartbeats" permission, and a key of the monitor's
|
|
136
|
+
* environment.
|
|
137
|
+
*/
|
|
138
|
+
heartbeat: (monitorId: string, options?: HeartbeatOptions) => Promise<void>;
|
|
139
|
+
}
|
|
140
|
+
export declare class Runqora {
|
|
141
|
+
readonly keys: KeysClient;
|
|
142
|
+
readonly monitoring: MonitoringClient;
|
|
143
|
+
readonly webhooks: WebhooksClient;
|
|
144
|
+
constructor(options: RunqoraOptions);
|
|
145
|
+
}
|
|
146
|
+
//#endregion
|
|
147
|
+
export type { RunqoraErrorCode, VerifyWebhookInput, WebhookEvent };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
//#region src/errors.ts
|
|
2
|
+
/**
|
|
3
|
+
* The request did not get a usable answer: Runqora was unreachable or refused
|
|
4
|
+
* it (for example an API key without the needed permission). For key
|
|
5
|
+
* verification this says nothing about the key, so treat it as "not verified".
|
|
6
|
+
*/
|
|
7
|
+
var RunqoraError = class extends Error {
|
|
8
|
+
code;
|
|
9
|
+
/** HTTP status, when Runqora answered. */
|
|
10
|
+
status;
|
|
11
|
+
constructor(message, options) {
|
|
12
|
+
super(message, { cause: options.cause });
|
|
13
|
+
this.name = "RunqoraError";
|
|
14
|
+
this.code = options.code;
|
|
15
|
+
this.status = options.status ?? null;
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
//#endregion
|
|
19
|
+
//#region src/webhook-signature.ts
|
|
20
|
+
/**
|
|
21
|
+
* Verification for the receiving side of a webhook.
|
|
22
|
+
*
|
|
23
|
+
* Runqora signs deliveries the way the Standard Webhooks specification
|
|
24
|
+
* describes (https://www.standardwebhooks.com), so receivers written in other
|
|
25
|
+
* languages can use any library that implements it instead of this function.
|
|
26
|
+
*/
|
|
27
|
+
const SECRET_PREFIX = "whsec_";
|
|
28
|
+
const SIGNATURE_VERSION = "v1";
|
|
29
|
+
const DEFAULT_TOLERANCE_SECONDS = 300;
|
|
30
|
+
const MILLISECONDS_PER_SECOND = 1e3;
|
|
31
|
+
/** The delivery cannot be trusted. Answer it with a 400 and do not act on it. */
|
|
32
|
+
var WebhookVerificationError = class extends Error {
|
|
33
|
+
code;
|
|
34
|
+
constructor(code, message) {
|
|
35
|
+
super(message);
|
|
36
|
+
this.name = "WebhookVerificationError";
|
|
37
|
+
this.code = code;
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
function readHeader(headers, name) {
|
|
41
|
+
if (headers instanceof Headers) return headers.get(name);
|
|
42
|
+
for (const [key, value] of Object.entries(headers)) if (key.toLowerCase() === name) return (Array.isArray(value) ? value[0] : value) ?? null;
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
function fromBase64(value) {
|
|
46
|
+
try {
|
|
47
|
+
const binary = atob(value);
|
|
48
|
+
const bytes = new Uint8Array(binary.length);
|
|
49
|
+
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
|
|
50
|
+
return bytes;
|
|
51
|
+
} catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
function parseEvent(payload) {
|
|
56
|
+
let body;
|
|
57
|
+
try {
|
|
58
|
+
body = JSON.parse(payload);
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
if (typeof body !== "object" || body === null) return null;
|
|
63
|
+
const { data, id, timestamp, type } = body;
|
|
64
|
+
return typeof id === "string" && typeof type === "string" && typeof timestamp === "string" ? {
|
|
65
|
+
data,
|
|
66
|
+
id,
|
|
67
|
+
timestamp,
|
|
68
|
+
type
|
|
69
|
+
} : null;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Confirms that a delivery was sent by Runqora for this endpoint and returns
|
|
73
|
+
* its event. Throws `WebhookVerificationError` otherwise.
|
|
74
|
+
*/
|
|
75
|
+
async function verifyWebhook(input) {
|
|
76
|
+
const id = readHeader(input.headers, "webhook-id");
|
|
77
|
+
const timestamp = readHeader(input.headers, "webhook-timestamp");
|
|
78
|
+
const signatures = readHeader(input.headers, "webhook-signature");
|
|
79
|
+
if (!(id && timestamp && signatures)) throw new WebhookVerificationError("missing_headers", "The webhook-id, webhook-timestamp and webhook-signature headers are required.");
|
|
80
|
+
const key = input.secret.startsWith(SECRET_PREFIX) ? fromBase64(input.secret.slice(6)) : null;
|
|
81
|
+
if (!key || key.length === 0) throw new WebhookVerificationError("invalid_secret", "The signing secret must be the whsec_ value shown for the endpoint.");
|
|
82
|
+
const sentAt = Number(timestamp);
|
|
83
|
+
const tolerance = input.toleranceSeconds ?? DEFAULT_TOLERANCE_SECONDS;
|
|
84
|
+
const now = (input.now ?? Date.now()) / MILLISECONDS_PER_SECOND;
|
|
85
|
+
if (!Number.isInteger(sentAt) || Math.abs(now - sentAt) > tolerance) throw new WebhookVerificationError("timestamp_out_of_range", "The delivery is too old or too far in the future to be trusted.");
|
|
86
|
+
const encoder = new TextEncoder();
|
|
87
|
+
const cryptoKey = await crypto.subtle.importKey("raw", key, {
|
|
88
|
+
hash: "SHA-256",
|
|
89
|
+
name: "HMAC"
|
|
90
|
+
}, false, ["verify"]);
|
|
91
|
+
const signed = encoder.encode(`${id}.${timestamp}.${input.payload}`);
|
|
92
|
+
let trusted = false;
|
|
93
|
+
for (const entry of signatures.split(" ")) {
|
|
94
|
+
const [version, value] = entry.split(",");
|
|
95
|
+
const signature = version === SIGNATURE_VERSION && value ? fromBase64(value) : null;
|
|
96
|
+
if (signature && await crypto.subtle.verify("HMAC", cryptoKey, signature, signed)) trusted = true;
|
|
97
|
+
}
|
|
98
|
+
if (!trusted) throw new WebhookVerificationError("invalid_signature", "The signature does not match this endpoint's signing secret.");
|
|
99
|
+
const event = parseEvent(input.payload);
|
|
100
|
+
if (!event) throw new WebhookVerificationError("invalid_payload", "The delivery is signed correctly but is not a Runqora event.");
|
|
101
|
+
return event;
|
|
102
|
+
}
|
|
103
|
+
//#endregion
|
|
104
|
+
//#region src/index.ts
|
|
105
|
+
/**
|
|
106
|
+
* Server-side client for Runqora.
|
|
107
|
+
*
|
|
108
|
+
* ```ts
|
|
109
|
+
* const runqora = new Runqora({ apiKey: process.env.RUNQORA_API_KEY });
|
|
110
|
+
*
|
|
111
|
+
* const result = await runqora.keys.verify(key, { scopes: ["orders:read"] });
|
|
112
|
+
* await runqora.webhooks.send({ type: "order.created", data: { id: "o_1" } });
|
|
113
|
+
* await runqora.monitoring.heartbeat(process.env.NIGHTLY_EXPORT_MONITOR_ID);
|
|
114
|
+
* ```
|
|
115
|
+
*
|
|
116
|
+
* Has no dependencies and uses only `fetch` and Web Crypto, so it runs in
|
|
117
|
+
* Node.js, Bun, Deno and edge runtimes. Use it on a server: the API key it
|
|
118
|
+
* holds must never reach a browser.
|
|
119
|
+
*/
|
|
120
|
+
const DEFAULT_BASE_URL = "https://api.runqora.dev";
|
|
121
|
+
const DEFAULT_TIMEOUT_MS = 5e3;
|
|
122
|
+
/** Longer than any real key; anything above it is refused without a request. */
|
|
123
|
+
const MAX_KEY_LENGTH = 256;
|
|
124
|
+
const TRAILING_SLASHES = /\/+$/;
|
|
125
|
+
const REJECTION_CODES = [
|
|
126
|
+
"expired",
|
|
127
|
+
"insufficient_scopes",
|
|
128
|
+
"not_found",
|
|
129
|
+
"revoked",
|
|
130
|
+
"wrong_environment"
|
|
131
|
+
];
|
|
132
|
+
function isRecord(value) {
|
|
133
|
+
return typeof value === "object" && value !== null;
|
|
134
|
+
}
|
|
135
|
+
function parseVerification(data) {
|
|
136
|
+
if (!isRecord(data)) return null;
|
|
137
|
+
if (data.valid === false) return typeof data.code === "string" && REJECTION_CODES.includes(data.code) ? {
|
|
138
|
+
code: data.code,
|
|
139
|
+
valid: false
|
|
140
|
+
} : null;
|
|
141
|
+
const { environment, expiresAt, keyId, name, scopes } = data;
|
|
142
|
+
if (data.valid !== true || typeof environment !== "string" || typeof keyId !== "string" || typeof name !== "string" || !(expiresAt === null || typeof expiresAt === "string") || !(Array.isArray(scopes) && scopes.every((scope) => typeof scope === "string"))) return null;
|
|
143
|
+
return {
|
|
144
|
+
environment,
|
|
145
|
+
expiresAt,
|
|
146
|
+
keyId,
|
|
147
|
+
name,
|
|
148
|
+
scopes,
|
|
149
|
+
valid: true
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
function parseSent(data) {
|
|
153
|
+
return isRecord(data) && typeof data.eventId === "string" && typeof data.deliveries === "number" ? {
|
|
154
|
+
deliveries: data.deliveries,
|
|
155
|
+
eventId: data.eventId
|
|
156
|
+
} : null;
|
|
157
|
+
}
|
|
158
|
+
/** The API's own explanation, which never contains a key. */
|
|
159
|
+
function describeFailure(body, status) {
|
|
160
|
+
const error = isRecord(body) ? body.error : null;
|
|
161
|
+
return isRecord(error) && typeof error.message === "string" ? `Runqora refused the request (${status}): ${error.message}` : `Runqora refused the request (${status}).`;
|
|
162
|
+
}
|
|
163
|
+
function toTransportError(error) {
|
|
164
|
+
return error instanceof Error && (error.name === "TimeoutError" || error.name === "AbortError") ? new RunqoraError("Runqora did not answer in time.", {
|
|
165
|
+
cause: error,
|
|
166
|
+
code: "timeout"
|
|
167
|
+
}) : new RunqoraError("Runqora could not be reached.", {
|
|
168
|
+
cause: error,
|
|
169
|
+
code: "network_error"
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
function notUnderstood() {
|
|
173
|
+
return new RunqoraError("Runqora sent an answer that was not understood.", { code: "invalid_response" });
|
|
174
|
+
}
|
|
175
|
+
var Runqora = class {
|
|
176
|
+
keys;
|
|
177
|
+
monitoring;
|
|
178
|
+
webhooks;
|
|
179
|
+
constructor(options) {
|
|
180
|
+
const { apiKey } = options;
|
|
181
|
+
if (!apiKey) throw new TypeError("Runqora: apiKey is required.");
|
|
182
|
+
const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(TRAILING_SLASHES, "");
|
|
183
|
+
const send = options.fetch ?? ((input, init) => fetch(input, init));
|
|
184
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
185
|
+
/** Returns the `data` of a successful answer. */
|
|
186
|
+
async function post(path, payload) {
|
|
187
|
+
let response;
|
|
188
|
+
let body;
|
|
189
|
+
try {
|
|
190
|
+
response = await send(`${baseUrl}${path}`, {
|
|
191
|
+
body: JSON.stringify(payload),
|
|
192
|
+
headers: {
|
|
193
|
+
Authorization: `Bearer ${apiKey}`,
|
|
194
|
+
"Content-Type": "application/json"
|
|
195
|
+
},
|
|
196
|
+
method: "POST",
|
|
197
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
198
|
+
});
|
|
199
|
+
body = await response.json().catch(() => null);
|
|
200
|
+
} catch (error) {
|
|
201
|
+
throw toTransportError(error);
|
|
202
|
+
}
|
|
203
|
+
if (!response.ok) throw new RunqoraError(describeFailure(body, response.status), {
|
|
204
|
+
code: "request_failed",
|
|
205
|
+
status: response.status
|
|
206
|
+
});
|
|
207
|
+
return isRecord(body) ? body.data : null;
|
|
208
|
+
}
|
|
209
|
+
this.keys = { async verify(key, verifyOptions = {}) {
|
|
210
|
+
if (typeof key !== "string" || !key || key.length > MAX_KEY_LENGTH) return {
|
|
211
|
+
code: "not_found",
|
|
212
|
+
valid: false
|
|
213
|
+
};
|
|
214
|
+
const result = parseVerification(await post("/v1/keys/verify", {
|
|
215
|
+
key,
|
|
216
|
+
scopes: verifyOptions.scopes
|
|
217
|
+
}));
|
|
218
|
+
if (!result) throw notUnderstood();
|
|
219
|
+
return result;
|
|
220
|
+
} };
|
|
221
|
+
this.monitoring = { async heartbeat(monitorId, heartbeatOptions = {}) {
|
|
222
|
+
await post("/v1/monitoring/heartbeats", {
|
|
223
|
+
monitorId,
|
|
224
|
+
status: heartbeatOptions.failed ? "failure" : "success"
|
|
225
|
+
});
|
|
226
|
+
} };
|
|
227
|
+
this.webhooks = { async send(event) {
|
|
228
|
+
const result = parseSent(await post("/v1/webhooks/events", {
|
|
229
|
+
data: event.data,
|
|
230
|
+
id: event.id,
|
|
231
|
+
type: event.type
|
|
232
|
+
}));
|
|
233
|
+
if (!result) throw notUnderstood();
|
|
234
|
+
return result;
|
|
235
|
+
} };
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
//#endregion
|
|
239
|
+
export { Runqora, RunqoraError, WebhookVerificationError, verifyWebhook };
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
//#region src/auth/protocol.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* The Runqora auth bridge protocol.
|
|
4
|
+
*
|
|
5
|
+
* Runqora never reads a customer's user database. Instead the customer's
|
|
6
|
+
* application exposes a few read-mostly HTTP endpoints (the "bridge") and
|
|
7
|
+
* Runqora calls them with signed requests. This file is the whole contract
|
|
8
|
+
* and is shared by both sides. It has no dependencies.
|
|
9
|
+
*
|
|
10
|
+
* Requests carry no body: everything is in the method, the path and the
|
|
11
|
+
* query string, and all three are covered by the signature.
|
|
12
|
+
*/
|
|
13
|
+
export declare const PROTOCOL_VERSION = 1;
|
|
14
|
+
export declare const TIMESTAMP_HEADER = "x-runqora-timestamp";
|
|
15
|
+
export declare const SIGNATURE_HEADER = "x-runqora-signature";
|
|
16
|
+
export declare const SIGNATURE_PREFIX = "v1=";
|
|
17
|
+
/** A request older or newer than this is refused, which limits replays. */
|
|
18
|
+
export declare const MAX_CLOCK_SKEW_SECONDS = 300;
|
|
19
|
+
export declare const MIN_SECRET_LENGTH = 32;
|
|
20
|
+
export declare const DEFAULT_PAGE_SIZE = 50;
|
|
21
|
+
export declare const MAX_PAGE_SIZE = 100;
|
|
22
|
+
export type BridgeMethod = "GET" | "POST";
|
|
23
|
+
/** Dates travel as ISO 8601 strings. */
|
|
24
|
+
export interface BridgeUser {
|
|
25
|
+
banned?: boolean | null;
|
|
26
|
+
createdAt?: string | null;
|
|
27
|
+
email?: string | null;
|
|
28
|
+
emailVerified?: boolean;
|
|
29
|
+
id: string;
|
|
30
|
+
image?: string | null;
|
|
31
|
+
name?: string | null;
|
|
32
|
+
role?: string | null;
|
|
33
|
+
}
|
|
34
|
+
/** The session token is a credential and must never be included. */
|
|
35
|
+
export interface BridgeSession {
|
|
36
|
+
createdAt?: string | null;
|
|
37
|
+
expiresAt?: string | null;
|
|
38
|
+
id: string;
|
|
39
|
+
ipAddress?: string | null;
|
|
40
|
+
userAgent?: string | null;
|
|
41
|
+
userId: string;
|
|
42
|
+
}
|
|
43
|
+
export interface BridgeOrganization {
|
|
44
|
+
createdAt?: string | null;
|
|
45
|
+
id: string;
|
|
46
|
+
name: string;
|
|
47
|
+
slug?: string | null;
|
|
48
|
+
}
|
|
49
|
+
export interface BridgeCapabilities {
|
|
50
|
+
bans: boolean;
|
|
51
|
+
impersonation: boolean;
|
|
52
|
+
organizations: boolean;
|
|
53
|
+
roles: boolean;
|
|
54
|
+
sessions: boolean;
|
|
55
|
+
}
|
|
56
|
+
export interface BridgeStats {
|
|
57
|
+
activeSessions: number | null;
|
|
58
|
+
organizations: number | null;
|
|
59
|
+
users: number | null;
|
|
60
|
+
}
|
|
61
|
+
export interface BridgeInfo {
|
|
62
|
+
capabilities: BridgeCapabilities;
|
|
63
|
+
protocol: number;
|
|
64
|
+
/** Free-form name of the implementation, e.g. "better-auth". */
|
|
65
|
+
provider: string;
|
|
66
|
+
}
|
|
67
|
+
export type BridgeErrorCode = "bad_request" | "internal" | "not_found" | "unauthorized" | "unsupported";
|
|
68
|
+
export interface BridgeErrorBody {
|
|
69
|
+
error: {
|
|
70
|
+
code: BridgeErrorCode;
|
|
71
|
+
message: string;
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
/** `["users", "a b"]` -> `/users/a%20b`. Both sides build paths this way. */
|
|
75
|
+
export declare function buildRoute(segments: readonly string[]): string;
|
|
76
|
+
/** Query parameters in one fixed order, so both sides sign the same text. */
|
|
77
|
+
export declare function canonicalQuery(query: Record<string, string>): string;
|
|
78
|
+
/**
|
|
79
|
+
* HMAC-SHA256 over the timestamp, method, route and query. Uses Web Crypto,
|
|
80
|
+
* so it runs in Node.js, Bun, Deno and edge runtimes alike.
|
|
81
|
+
*/
|
|
82
|
+
export declare function signRequest(input: {
|
|
83
|
+
method: BridgeMethod;
|
|
84
|
+
query: Record<string, string>;
|
|
85
|
+
secret: string;
|
|
86
|
+
segments: readonly string[];
|
|
87
|
+
timestamp: number;
|
|
88
|
+
}): Promise<string>;
|
|
89
|
+
/** Compares without stopping at the first difference, to avoid leaking a prefix through timing. */
|
|
90
|
+
export declare function timingSafeEqual(a: string, b: string): boolean;
|
|
91
|
+
//#endregion
|
package/dist/protocol.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
//#region src/auth/protocol.ts
|
|
2
|
+
/**
|
|
3
|
+
* The Runqora auth bridge protocol.
|
|
4
|
+
*
|
|
5
|
+
* Runqora never reads a customer's user database. Instead the customer's
|
|
6
|
+
* application exposes a few read-mostly HTTP endpoints (the "bridge") and
|
|
7
|
+
* Runqora calls them with signed requests. This file is the whole contract
|
|
8
|
+
* and is shared by both sides. It has no dependencies.
|
|
9
|
+
*
|
|
10
|
+
* Requests carry no body: everything is in the method, the path and the
|
|
11
|
+
* query string, and all three are covered by the signature.
|
|
12
|
+
*/
|
|
13
|
+
const PROTOCOL_VERSION = 1;
|
|
14
|
+
const TIMESTAMP_HEADER = "x-runqora-timestamp";
|
|
15
|
+
const SIGNATURE_HEADER = "x-runqora-signature";
|
|
16
|
+
const SIGNATURE_PREFIX = "v1=";
|
|
17
|
+
/** A request older or newer than this is refused, which limits replays. */
|
|
18
|
+
const MAX_CLOCK_SKEW_SECONDS = 300;
|
|
19
|
+
const MIN_SECRET_LENGTH = 32;
|
|
20
|
+
const DEFAULT_PAGE_SIZE = 50;
|
|
21
|
+
const MAX_PAGE_SIZE = 100;
|
|
22
|
+
/** `["users", "a b"]` -> `/users/a%20b`. Both sides build paths this way. */
|
|
23
|
+
function buildRoute(segments) {
|
|
24
|
+
return `/${segments.map(encodeURIComponent).join("/")}`;
|
|
25
|
+
}
|
|
26
|
+
/** Query parameters in one fixed order, so both sides sign the same text. */
|
|
27
|
+
function canonicalQuery(query) {
|
|
28
|
+
return Object.keys(query).sort().map((key) => `${encodeURIComponent(key)}=${encodeURIComponent(query[key] ?? "")}`).join("&");
|
|
29
|
+
}
|
|
30
|
+
function toHex(bytes) {
|
|
31
|
+
let hex = "";
|
|
32
|
+
for (const byte of bytes) hex += byte.toString(16).padStart(2, "0");
|
|
33
|
+
return hex;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* HMAC-SHA256 over the timestamp, method, route and query. Uses Web Crypto,
|
|
37
|
+
* so it runs in Node.js, Bun, Deno and edge runtimes alike.
|
|
38
|
+
*/
|
|
39
|
+
async function signRequest(input) {
|
|
40
|
+
const encoder = new TextEncoder();
|
|
41
|
+
const key = await crypto.subtle.importKey("raw", encoder.encode(input.secret), {
|
|
42
|
+
hash: "SHA-256",
|
|
43
|
+
name: "HMAC"
|
|
44
|
+
}, false, ["sign"]);
|
|
45
|
+
const message = [
|
|
46
|
+
`v1`,
|
|
47
|
+
input.timestamp,
|
|
48
|
+
input.method,
|
|
49
|
+
buildRoute(input.segments),
|
|
50
|
+
canonicalQuery(input.query)
|
|
51
|
+
].join("\n");
|
|
52
|
+
const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(message));
|
|
53
|
+
return `v1=${toHex(new Uint8Array(signature))}`;
|
|
54
|
+
}
|
|
55
|
+
/** Compares without stopping at the first difference, to avoid leaking a prefix through timing. */
|
|
56
|
+
function timingSafeEqual(a, b) {
|
|
57
|
+
if (a.length !== b.length) return false;
|
|
58
|
+
let difference = 0;
|
|
59
|
+
for (let index = 0; index < a.length; index += 1) difference |= a.charCodeAt(index) ^ b.charCodeAt(index);
|
|
60
|
+
return difference === 0;
|
|
61
|
+
}
|
|
62
|
+
//#endregion
|
|
63
|
+
export { DEFAULT_PAGE_SIZE, MAX_CLOCK_SKEW_SECONDS, MAX_PAGE_SIZE, MIN_SECRET_LENGTH, PROTOCOL_VERSION, SIGNATURE_HEADER, SIGNATURE_PREFIX, TIMESTAMP_HEADER, buildRoute, canonicalQuery, signRequest, timingSafeEqual };
|
package/package.json
CHANGED
|
@@ -1,6 +1,79 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@runqora/sdk",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Runqora for your server: verify API keys, send and receive webhooks, report cron heartbeats, and connect your users through Better Auth or any auth system.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"runqora",
|
|
7
|
+
"api-keys",
|
|
8
|
+
"webhooks",
|
|
9
|
+
"cron",
|
|
10
|
+
"monitoring",
|
|
11
|
+
"auth",
|
|
12
|
+
"better-auth",
|
|
13
|
+
"better-auth-plugin"
|
|
14
|
+
],
|
|
15
|
+
"homepage": "https://runqora.dev",
|
|
16
|
+
"bugs": {
|
|
17
|
+
"url": "https://github.com/amblydia/runqora-js/issues"
|
|
18
|
+
},
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/amblydia/runqora-js.git"
|
|
22
|
+
},
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"sideEffects": false,
|
|
25
|
+
"type": "module",
|
|
26
|
+
"exports": {
|
|
27
|
+
".": {
|
|
28
|
+
"types": "./dist/index.d.ts",
|
|
29
|
+
"import": "./dist/index.js"
|
|
30
|
+
},
|
|
31
|
+
"./auth": {
|
|
32
|
+
"types": "./dist/auth.d.ts",
|
|
33
|
+
"import": "./dist/auth.js"
|
|
34
|
+
},
|
|
35
|
+
"./better-auth": {
|
|
36
|
+
"types": "./dist/better-auth.d.ts",
|
|
37
|
+
"import": "./dist/better-auth.js"
|
|
38
|
+
},
|
|
39
|
+
"./protocol": {
|
|
40
|
+
"types": "./dist/protocol.d.ts",
|
|
41
|
+
"import": "./dist/protocol.js"
|
|
42
|
+
},
|
|
43
|
+
"./package.json": "./package.json"
|
|
44
|
+
},
|
|
45
|
+
"main": "./dist/index.js",
|
|
46
|
+
"types": "./dist/index.d.ts",
|
|
47
|
+
"files": [
|
|
48
|
+
"dist"
|
|
49
|
+
],
|
|
50
|
+
"scripts": {
|
|
51
|
+
"build": "bun scripts/build.ts",
|
|
52
|
+
"check": "ultracite check && tsc --noEmit",
|
|
53
|
+
"fix": "ultracite fix",
|
|
54
|
+
"prepublishOnly": "bun run check && bun run test && bun run build",
|
|
55
|
+
"test": "vitest run",
|
|
56
|
+
"typecheck": "tsc --noEmit"
|
|
57
|
+
},
|
|
58
|
+
"devDependencies": {
|
|
59
|
+
"@biomejs/biome": "2.5.14",
|
|
60
|
+
"@types/bun": "^1.3.6",
|
|
61
|
+
"better-auth": "1.7.7",
|
|
62
|
+
"tsdown": "0.23.0",
|
|
63
|
+
"typescript": "^5.9.0",
|
|
64
|
+
"ultracite": "7.12.2",
|
|
65
|
+
"vitest": "^5.0.4"
|
|
66
|
+
},
|
|
67
|
+
"peerDependencies": {
|
|
68
|
+
"better-auth": "^1.6.13"
|
|
69
|
+
},
|
|
70
|
+
"peerDependenciesMeta": {
|
|
71
|
+
"better-auth": {
|
|
72
|
+
"optional": true
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
"packageManager": "bun@1.3.6",
|
|
76
|
+
"engines": {
|
|
77
|
+
"node": ">=20"
|
|
78
|
+
}
|
|
79
|
+
}
|