@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 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
- # Temporary Holding Version
1
+ # @runqora/sdk
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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 };
@@ -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
@@ -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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }