@theholocron/holocron-plugin-clerk 2.0.0-alpha.9 → 2.0.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/README.md CHANGED
@@ -5,8 +5,10 @@ against [Clerk's Backend REST API](https://clerk.com/docs/reference/backend-api)
5
5
 
6
6
  ## Install
7
7
 
8
+ <!-- prettier-ignore -->
8
9
  ```bash
9
10
  pnpm add -D @theholocron/holocron-plugin-clerk@alpha
11
+
10
12
  ```
11
13
 
12
14
  ## Auth
@@ -24,12 +26,14 @@ api …`, but the `clerk` CLI just wraps the same REST API holocron talks to.
24
26
 
25
27
  ## Config
26
28
 
29
+ <!-- prettier-ignore -->
27
30
  ```jsonc
28
31
  {
29
- "providers": {
30
- "auth": "clerk",
31
- },
32
+ "providers": {
33
+ "auth": "clerk",
34
+ },
32
35
  }
36
+
33
37
  ```
34
38
 
35
39
  No plugin-level options today. Per-instance scoping (Development vs.
package/dist/index.d.mts CHANGED
@@ -1,67 +1,16 @@
1
- import { Auth, AuthDescription, AuthEvent, AuthIdentity, AuthUser, CreateAuthUserInput, ParseWebhookInput, WebhookDashboardInfo } from "@theholocron/cli";
1
+ import { Auth, AuthDescription, AuthError, AuthEvent, AuthIdentity, AuthUser, CreateAuthUserInput, ParseWebhookInput, ResolveTokenInput, WebhookDashboardInfo } from "@theholocron/cli";
2
+ import { ClerkClient, createClerkClient } from "@theholocron/clerk-client";
2
3
 
3
4
  //#region src/auth.d.ts
4
- /**
5
- * Token resolution for the Clerk plugin.
6
- *
7
- * Resolution order (matches the standard 4-step precedence set by
8
- * `.notes/tech-auth-bootstrap.spec.md`):
9
- * 1. explicit `cliToken` argument (from `--token` flag)
10
- * 2. HOLOCRON_CLERK_SECRET_KEY env var (preferred — explicit intent)
11
- * 3. CLERK_SECRET_KEY env var (the default Clerk's docs reference)
12
- * 4. keyring (com.theholocron.cli / "clerk")
13
- * 5. AuthError naming all four options + the bootstrap hint
14
- *
15
- * The key (sk_test_* / sk_live_*) determines which Clerk instance —
16
- * Development or Production — every call hits.
17
- */
18
- declare class AuthError extends Error {
19
- name: string;
20
- }
21
- interface ResolveTokenInput {
22
- /** From `--token` CLI flag. */
23
- cliToken?: string;
24
- /** Env vars; passed in for testability. Defaults to `process.env`. */
25
- env?: NodeJS.ProcessEnv;
26
- /** Keyring lookup fn; passed in for testability. Defaults to `getToken(provider)`. */
27
- keyring?: (provider: string) => string | null;
28
- }
29
- declare function resolveToken(input?: ResolveTokenInput): string;
30
- //#endregion
31
- //#region src/rest.d.ts
32
- /**
33
- * Thin REST wrapper around api.clerk.com/v1.
34
- *
35
- * Same pattern as the github/vercel/neon REST clients — bearer auth,
36
- * JSON-only bodies, transport-failure wrapping with `status: 0` so the
37
- * orchestrator's soft-skip path sees a clear "Clerk GET /path failed"
38
- * message instead of a generic `TypeError: fetch failed`.
39
- */
40
- interface RestClientOptions {
41
- token: string;
42
- fetch?: typeof fetch;
43
- baseUrl?: string;
44
- }
45
- interface RequestOptions {
46
- method?: "GET" | "POST" | "PATCH" | "PUT" | "DELETE";
47
- body?: unknown;
48
- query?: Record<string, string>;
49
- }
50
- declare class ClerkRestClient {
51
- private readonly token;
52
- private readonly fetchImpl;
53
- readonly baseUrl: string;
54
- constructor(opts: RestClientOptions);
55
- request<T>(path: string, opts?: RequestOptions): Promise<T>;
56
- }
5
+ declare const resolveToken: (input?: import("@theholocron/http-client").ResolveTokenInput) => string;
57
6
  //#endregion
58
7
  //#region src/capabilities/auth.d.ts
59
8
  type ClerkAuthOptions = Record<string, never>;
60
9
  declare class ClerkAuth implements Auth {
61
- private readonly rest;
10
+ private readonly client;
62
11
  readonly key: "auth";
63
12
  readonly providerName = "clerk";
64
- constructor(rest: ClerkRestClient, _opts?: ClerkAuthOptions);
13
+ constructor(client: ClerkClient, _opts?: ClerkAuthOptions);
65
14
  describe(): Promise<AuthDescription>;
66
15
  whoami(): Promise<AuthIdentity>;
67
16
  ensureWebhookApp(): Promise<{
@@ -109,7 +58,7 @@ interface ClerkPluginOptions extends ResolveTokenInput {
109
58
  }
110
59
  interface PluginContext {
111
60
  options: ClerkPluginOptions;
112
- rest: ClerkRestClient;
61
+ client: ClerkClient;
113
62
  }
114
63
  declare function createContext(options?: ClerkPluginOptions): PluginContext;
115
64
  declare function auth(ctx: PluginContext): Auth;
@@ -127,4 +76,4 @@ declare function createPlugin(options?: ClerkPluginOptions): {
127
76
  */
128
77
  declare const AUTH_HINT: string;
129
78
  //#endregion
130
- export { AUTH_HINT, AuthError, ClerkAuth, ClerkPluginOptions, ClerkRestClient, PluginContext, ResolveTokenInput, type VerifyTokenFailure, type VerifyTokenResult, type VerifyTokenSuccess, auth, createContext, createPlugin, parseWebhook, resolveToken, verifyToken };
79
+ export { AUTH_HINT, AuthError, ClerkAuth, ClerkPluginOptions, PluginContext, type ResolveTokenInput, type VerifyTokenFailure, type VerifyTokenResult, type VerifyTokenSuccess, auth, createClerkClient, createContext, createPlugin, parseWebhook, resolveToken, verifyToken };
package/dist/index.mjs CHANGED
@@ -1,29 +1,13 @@
1
- import { ProviderApiError, WebhookVerificationError, getToken } from "@theholocron/cli";
1
+ import { AuthError, ProviderApiError, WebhookVerificationError, createResolveToken } from "@theholocron/cli";
2
+ import { createClerkClient } from "@theholocron/clerk-client";
3
+ import { createHmac, timingSafeEqual } from "node:crypto";
2
4
  //#region src/auth.ts
3
- /**
4
- * Token resolution for the Clerk plugin.
5
- *
6
- * Resolution order (matches the standard 4-step precedence set by
7
- * `.notes/tech-auth-bootstrap.spec.md`):
8
- * 1. explicit `cliToken` argument (from `--token` flag)
9
- * 2. HOLOCRON_CLERK_SECRET_KEY env var (preferred — explicit intent)
10
- * 3. CLERK_SECRET_KEY env var (the default Clerk's docs reference)
11
- * 4. keyring (com.theholocron.cli / "clerk")
12
- * 5. AuthError naming all four options + the bootstrap hint
13
- *
14
- * The key (sk_test_* / sk_live_*) determines which Clerk instance —
15
- * Development or Production — every call hits.
16
- */
17
- var AuthError = class extends Error {
18
- name = "AuthError";
19
- };
20
- function resolveToken(input = {}) {
21
- const env = input.env ?? process.env;
22
- const keyring = input.keyring ?? getToken;
23
- const token = input.cliToken || env["HOLOCRON_CLERK_SECRET_KEY"] || env["CLERK_SECRET_KEY"] || keyring("clerk");
24
- if (!token) throw new AuthError("no Clerk secret key found. Pass --token <KEY>, set HOLOCRON_CLERK_SECRET_KEY / CLERK_SECRET_KEY, or run: holocron auth set clerk <KEY>");
25
- return token;
26
- }
5
+ const resolveToken = createResolveToken({
6
+ envName: "HOLOCRON_CLERK_SECRET_KEY",
7
+ vendorEnvName: "CLERK_SECRET_KEY",
8
+ keyringService: "clerk",
9
+ errorMessage: "no Clerk secret key found. Pass --token <KEY>, set HOLOCRON_CLERK_SECRET_KEY / CLERK_SECRET_KEY, or run: holocron auth set clerk <KEY>"
10
+ });
27
11
  //#endregion
28
12
  //#region src/capabilities/auth.ts
29
13
  /**
@@ -52,11 +36,11 @@ function resolveToken(input = {}) {
52
36
  * no per-call instance switch.
53
37
  */
54
38
  var ClerkAuth = class {
55
- rest;
39
+ client;
56
40
  key = "auth";
57
41
  providerName = "clerk";
58
- constructor(rest, _opts = {}) {
59
- this.rest = rest;
42
+ constructor(client, _opts = {}) {
43
+ this.client = client;
60
44
  }
61
45
  async describe() {
62
46
  return {
@@ -67,12 +51,12 @@ var ClerkAuth = class {
67
51
  async whoami() {
68
52
  return {
69
53
  provider: "clerk",
70
- details: { userCount: (await this.rest.request("/users/count")).total_count }
54
+ details: { userCount: (await this.client.users.count()).total_count }
71
55
  };
72
56
  }
73
57
  async ensureWebhookApp() {
74
58
  try {
75
- await this.rest.request("/webhooks/svix", { method: "POST" });
59
+ await this.client.webhooks.ensureSvixApp();
76
60
  return { alreadyExists: false };
77
61
  } catch (err) {
78
62
  if (err instanceof ProviderApiError && isAlreadyExistsError(err)) return { alreadyExists: true };
@@ -80,24 +64,21 @@ var ClerkAuth = class {
80
64
  }
81
65
  }
82
66
  async getWebhookDashboardUrl() {
83
- const body = await this.rest.request("/webhooks/svix_url", { method: "POST" });
67
+ const body = await this.client.webhooks.getSvixUrl();
84
68
  const url = body.url ?? body.svix_url;
85
69
  if (!url) throw new ProviderApiError("Clerk POST /webhooks/svix_url returned 200 but no `url` field", 500, void 0);
86
70
  return { url };
87
71
  }
88
72
  async createUser(input) {
89
- const body = await this.rest.request("/users", {
90
- method: "POST",
91
- body: {
92
- email_address: [input.email],
93
- password: input.password,
94
- ...input.firstName ? { first_name: input.firstName } : {},
95
- ...input.lastName ? { last_name: input.lastName } : {}
96
- }
73
+ const user = await this.client.users.create({
74
+ email_address: [input.email],
75
+ password: input.password,
76
+ ...input.firstName ? { first_name: input.firstName } : {},
77
+ ...input.lastName ? { last_name: input.lastName } : {}
97
78
  });
98
- const email = body.email_addresses[0]?.email_address ?? input.email;
79
+ const email = user.email_addresses[0]?.email_address ?? input.email;
99
80
  return {
100
- id: body.id,
81
+ id: user.id,
101
82
  email
102
83
  };
103
84
  }
@@ -115,84 +96,26 @@ function isAlreadyExistsError(err) {
115
96
  return false;
116
97
  }
117
98
  //#endregion
118
- //#region src/rest.ts
119
- /**
120
- * Thin REST wrapper around api.clerk.com/v1.
121
- *
122
- * Same pattern as the github/vercel/neon REST clients — bearer auth,
123
- * JSON-only bodies, transport-failure wrapping with `status: 0` so the
124
- * orchestrator's soft-skip path sees a clear "Clerk GET /path failed"
125
- * message instead of a generic `TypeError: fetch failed`.
126
- */
127
- var ClerkRestClient = class {
128
- token;
129
- fetchImpl;
130
- baseUrl;
131
- constructor(opts) {
132
- this.token = opts.token;
133
- this.fetchImpl = opts.fetch ?? globalThis.fetch;
134
- let url = opts.baseUrl ?? "https://api.clerk.com/v1";
135
- while (url.endsWith("/")) url = url.slice(0, -1);
136
- this.baseUrl = url;
137
- }
138
- async request(path, opts = {}) {
139
- const url = new URL(`${this.baseUrl}${path.startsWith("/") ? path : "/" + path}`);
140
- for (const [k, v] of Object.entries(opts.query ?? {})) url.searchParams.set(k, v);
141
- const fullUrl = url.toString();
142
- const headers = {
143
- authorization: `Bearer ${this.token}`,
144
- accept: "application/json"
145
- };
146
- const init = {
147
- method: opts.method ?? "GET",
148
- headers
149
- };
150
- if (opts.body !== void 0) {
151
- headers["content-type"] = "application/json";
152
- init.body = JSON.stringify(opts.body);
153
- }
154
- let res;
155
- try {
156
- res = await this.fetchImpl(fullUrl, init);
157
- } catch (err) {
158
- const detail = err instanceof Error ? `${err.name}: ${err.message}` : String(err);
159
- throw new ProviderApiError(`Clerk ${init.method} ${path} failed: ${detail}`, 0, void 0);
160
- }
161
- if (!res.ok) {
162
- const body = await res.text().catch(() => "");
163
- throw new ProviderApiError(`Clerk ${init.method} ${path} → ${res.status}`, res.status, body);
164
- }
165
- if (res.status === 204) return void 0;
166
- const text = await res.text();
167
- if (!text) return void 0;
168
- return JSON.parse(text);
169
- }
170
- };
171
- //#endregion
172
99
  //#region src/parse-webhook.ts
173
100
  /**
174
101
  * Translates a Clerk webhook delivery into the normalized `AuthEvent`
175
- * shape defined in `@theholocron/cli`. The first concrete example of
176
- * the cross-provider sync pattern (see `AuthEvent` JSDoc in core).
177
- *
178
- * Status: signature SHIPPED, body STUBBED. Real Svix signature
179
- * verification (HMAC-SHA256 over `svix-id.svix-timestamp.body` with
180
- * the `whsec_…` signing secret, base64-decoded) lands in a follow-up
181
- * (tracked at #80). For now, parseWebhook trusts the request and
182
- * focuses on shape translation — usable in environments where
183
- * signature verification happens upstream (e.g., Vercel middleware,
184
- * an API gateway), and an explicit error in production-grade
185
- * deployments until the verification body lands.
102
+ * shape defined in `@theholocron/cli`, with full Svix HMAC-SHA256
103
+ * signature verification (closes #80).
186
104
  *
187
- * Reference event shapes:
188
- * https://clerk.com/docs/integrations/webhooks/overview
189
- * https://clerk.com/docs/reference/backend-api/tag/Webhooks
105
+ * Verification algorithm (https://docs.svix.com/receiving/verifying-payloads/how-manual):
106
+ * 1. Require svix-id, svix-timestamp, svix-signature headers
107
+ * 2. Reject if svix-timestamp is outside the ±5-minute replay window
108
+ * 3. Decode the whsec_<base64> signing secret
109
+ * 4. Compute HMAC-SHA256(secretBytes, "${id}.${timestamp}.${body}")
110
+ * 5. Constant-time compare against each v1,<base64> signature in the
111
+ * space-separated svix-signature header (supports key rotation)
190
112
  */
191
113
  const CLERK_TO_NORMALIZED = {
192
114
  "user.created": "user.created",
193
115
  "user.updated": "user.updated",
194
116
  "user.deleted": "user.deleted"
195
117
  };
118
+ const REPLAY_WINDOW_SECONDS = 300;
196
119
  async function parseWebhook(input) {
197
120
  await verifySignature(input);
198
121
  const bodyStr = typeof input.body === "string" ? input.body : input.body.toString("utf8");
@@ -218,22 +141,37 @@ async function parseWebhook(input) {
218
141
  occurredAt
219
142
  };
220
143
  }
221
- /**
222
- * Stub for Svix signature verification. The real implementation
223
- * lands at #80 and will:
224
- *
225
- * 1. Read svix-id, svix-timestamp, svix-signature headers
226
- * 2. Verify svix-timestamp is within the replay window (±5 min)
227
- * 3. Compute HMAC-SHA256(<signing_secret>, `${id}.${timestamp}.${body}`)
228
- * 4. Compare (constant-time) against the base64 sig in svix-signature
229
- *
230
- * For now: throw WebhookVerificationError when the signing secret is
231
- * missing, so consumers see the contract; let the call through
232
- * otherwise (signature-validation TODO).
233
- */
234
144
  async function verifySignature(input) {
235
145
  if (!input.signingSecret) throw new WebhookVerificationError("Clerk webhook signingSecret is required (use the Svix whsec_… value from the Clerk dashboard)");
236
- return Promise.resolve();
146
+ const lower = (s) => s.toLowerCase();
147
+ const h = (name) => {
148
+ const target = lower(name);
149
+ for (const [k, v] of Object.entries(input.headers)) if (lower(k) === target) return Array.isArray(v) ? v[0] : v;
150
+ };
151
+ const svixId = h("svix-id");
152
+ const svixTimestamp = h("svix-timestamp");
153
+ const svixSignature = h("svix-signature");
154
+ if (!svixId || !svixTimestamp || !svixSignature) throw new WebhookVerificationError("Missing required Svix headers: svix-id, svix-timestamp, svix-signature");
155
+ const ts = parseInt(svixTimestamp, 10);
156
+ if (isNaN(ts)) throw new WebhookVerificationError(`Invalid svix-timestamp: "${svixTimestamp}"`);
157
+ const nowSeconds = Math.floor(Date.now() / 1e3);
158
+ if (Math.abs(nowSeconds - ts) > REPLAY_WINDOW_SECONDS) throw new WebhookVerificationError(`Message timestamp is outside the ${REPLAY_WINDOW_SECONDS / 60}-minute replay window`);
159
+ const secretBase64 = input.signingSecret.startsWith("whsec_") ? input.signingSecret.slice(6) : input.signingSecret;
160
+ const secretBytes = Buffer.from(secretBase64, "base64");
161
+ const toSign = `${svixId}.${svixTimestamp}.${typeof input.body === "string" ? input.body : input.body.toString("utf8")}`;
162
+ const computed = createHmac("sha256", secretBytes).update(toSign).digest();
163
+ if (!svixSignature.split(" ").some((sig) => {
164
+ const comma = sig.indexOf(",");
165
+ if (comma === -1 || sig.slice(0, comma) !== "v1") return false;
166
+ let sigBytes;
167
+ try {
168
+ sigBytes = Buffer.from(sig.slice(comma + 1), "base64");
169
+ } catch {
170
+ return false;
171
+ }
172
+ if (sigBytes.length !== computed.length) return false;
173
+ return timingSafeEqual(sigBytes, computed);
174
+ })) throw new WebhookVerificationError("Svix signature verification failed");
237
175
  }
238
176
  //#endregion
239
177
  //#region src/verify-token.ts
@@ -248,15 +186,16 @@ async function verifySignature(input) {
248
186
  * canonical "is this secret key valid?" endpoint.
249
187
  */
250
188
  async function verifyToken(token, opts = {}) {
251
- const restOpts = { token };
252
- if (opts.baseUrl !== void 0) restOpts.baseUrl = opts.baseUrl;
253
- if (opts.fetch !== void 0) restOpts.fetch = opts.fetch;
254
- const rest = new ClerkRestClient(restOpts);
189
+ const client = createClerkClient({
190
+ token,
191
+ baseUrl: opts.baseUrl,
192
+ fetch: opts.fetch
193
+ });
255
194
  try {
256
- const instance = await rest.request("/instance");
195
+ const inst = await client.instance.get();
257
196
  return {
258
197
  ok: true,
259
- subject: `${instance?.environment_type ?? "unknown"} instance ${instance?.id ?? "unknown"}`
198
+ subject: `${inst?.environment_type ?? "unknown"} instance ${inst?.id ?? "unknown"}`
260
199
  };
261
200
  } catch (err) {
262
201
  return {
@@ -268,16 +207,17 @@ async function verifyToken(token, opts = {}) {
268
207
  //#endregion
269
208
  //#region src/index.ts
270
209
  function createContext(options = {}) {
271
- const restOpts = { token: resolveToken(options) };
272
- if (options.baseUrl !== void 0) restOpts.baseUrl = options.baseUrl;
273
- if (options.fetch !== void 0) restOpts.fetch = options.fetch;
274
210
  return {
275
211
  options,
276
- rest: new ClerkRestClient(restOpts)
212
+ client: createClerkClient({
213
+ token: resolveToken(options),
214
+ baseUrl: options.baseUrl,
215
+ fetch: options.fetch
216
+ })
277
217
  };
278
218
  }
279
219
  function auth(ctx) {
280
- return new ClerkAuth(ctx.rest);
220
+ return new ClerkAuth(ctx.client);
281
221
  }
282
222
  function createPlugin(options = {}) {
283
223
  const ctx = createContext(options);
@@ -294,4 +234,4 @@ function createPlugin(options = {}) {
294
234
  */
295
235
  const AUTH_HINT = "grab your SECRET key (sk_test_* / sk_live_*) at https://dashboard.clerk.com → API Keys, then run: holocron auth set clerk <KEY>. Do NOT use the publishable key.";
296
236
  //#endregion
297
- export { AUTH_HINT, AuthError, ClerkAuth, ClerkRestClient, auth, createContext, createPlugin, parseWebhook, resolveToken, verifyToken };
237
+ export { AUTH_HINT, AuthError, ClerkAuth, auth, createClerkClient, createContext, createPlugin, parseWebhook, resolveToken, verifyToken };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theholocron/holocron-plugin-clerk",
3
- "version": "2.0.0-alpha.9",
3
+ "version": "2.0.0",
4
4
  "description": "Holocron plugin for Clerk. Implements the auth capability against Clerk's Backend REST API.",
5
5
  "homepage": "https://github.com/theholocron/holocron/tree/main/packages/holocron-plugin-clerk#readme",
6
6
  "bugs": "https://github.com/theholocron/holocron/issues",
@@ -21,19 +21,26 @@
21
21
  }
22
22
  },
23
23
  "peerDependencies": {
24
- "@theholocron/cli": "2.0.0-alpha.9"
24
+ "@theholocron/clerk-client": "^1.1.0",
25
+ "@theholocron/cli": "2.0.0"
25
26
  },
26
27
  "devDependencies": {
27
- "@theholocron/tsconfig": "^4.1.0",
28
- "@tsconfig/node-lts": "^24.0.0",
29
- "@vitest/coverage-v8": "^3.2.6",
30
- "eslint": "^9.36.0",
31
- "globals": "^16.5.0",
32
- "typescript": "^5.9.3",
33
- "vitest": "^3.2.6",
28
+ "@theholocron/clerk-client": "^1.1.0",
29
+ "@theholocron/eslint-config": "^7.3.0",
30
+ "@theholocron/tsconfig": "^7.3.0",
31
+ "@theholocron/tsdown-config": "^7.3.0",
32
+ "@theholocron/vitest-config": "^7.3.0",
33
+ "@types/node": "^26",
34
+ "@vitest/coverage-v8": "^4.1.10",
35
+ "@vitest/eslint-plugin": "^1.6.23",
36
+ "eslint": "^10.7.0",
37
+ "eslint-plugin-n": "^18.2.2",
38
+ "globals": "^17.7.0",
34
39
  "tsdown": "^0.22.3",
35
40
  "tsx": "^4.22.4",
36
- "@theholocron/cli": "2.0.0-alpha.9"
41
+ "typescript": "^5.9.3",
42
+ "vitest": "^4.1.10",
43
+ "@theholocron/cli": "2.0.0"
37
44
  },
38
45
  "publishConfig": {
39
46
  "access": "public"