@planetlogin/core 0.1.0 → 0.3.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/dist/index.d.ts CHANGED
@@ -6,6 +6,8 @@ interface Locale {
6
6
  language?: string;
7
7
  timezone?: string;
8
8
  country?: string;
9
+ lat?: number;
10
+ lon?: number;
9
11
  }
10
12
  interface SessionClaims {
11
13
  sub: string;
@@ -13,7 +15,9 @@ interface SessionClaims {
13
15
  name?: string;
14
16
  locale?: Locale;
15
17
  }
16
- /** Public JWK Set served at GET /auth/.well-known/jwks.json. */
18
+ type JwtAlg = 'EdDSA' | 'RS256' | 'ES256' | 'HS256';
19
+ /** Public JWK Set served at GET /auth/.well-known/jwks.json. Empty for HS256
20
+ * (symmetric secrets are shared out of band, never published). */
17
21
  declare function jwks(): Promise<{
18
22
  keys: JWK[];
19
23
  }>;
@@ -22,7 +26,7 @@ declare function signSession(claims: SessionClaims, opts?: {
22
26
  audience?: string;
23
27
  ttlSeconds?: number;
24
28
  }): Promise<string>;
25
- /** Verify a session token against our own JWKS (what a downstream service does). */
29
+ /** Verify a session token against our own key material (what a downstream does). */
26
30
  declare function verifySession(token: string): Promise<jose.JWTPayload>;
27
31
  declare function signMagicToken(identifier: string, ttlSeconds?: number): Promise<string>;
28
32
  /** Returns {identifier, jti} if the token is a valid, unexpired magic token, else null. */
@@ -39,6 +43,13 @@ interface DownstreamUser {
39
43
  locale?: Locale;
40
44
  totpEnabled?: boolean;
41
45
  }
46
+ /** Per-user, dev-owned preferences (spec §4). `locale` is the typed, first-class
47
+ * piece PlanetLogin reads/writes (fly-to + i18n); `data` is an open bag the
48
+ * integrator can store any "that kind of info" in. */
49
+ interface UserPreferences {
50
+ locale?: Locale;
51
+ data?: Record<string, unknown>;
52
+ }
42
53
  declare class Downstream {
43
54
  private baseUrl;
44
55
  private secret;
@@ -84,6 +95,13 @@ declare class Downstream {
84
95
  secret: string;
85
96
  enabled: boolean;
86
97
  }): Promise<unknown>;
98
+ /** Preferences (spec §4): per-user locale + open data bag. Null when none. */
99
+ preferencesGet(query: {
100
+ userId: string;
101
+ }): Promise<UserPreferences | null>;
102
+ preferencesSave(data: {
103
+ userId: string;
104
+ } & UserPreferences): Promise<unknown>;
87
105
  }
88
106
 
89
107
  interface PlanetLoginConfig {
@@ -125,6 +143,10 @@ interface PlanetLoginConfig {
125
143
  showSearch?: boolean;
126
144
  autoSpin?: boolean;
127
145
  };
146
+ locale?: {
147
+ persist?: boolean;
148
+ flyToOnLogin?: boolean;
149
+ };
128
150
  token?: {
129
151
  issuer?: string;
130
152
  audience?: string;
@@ -134,6 +156,26 @@ interface PlanetLoginConfig {
134
156
  session?: {
135
157
  store?: 'none' | 'memory' | 'redis' | 'sqlite' | 'downstream';
136
158
  };
159
+ security?: {
160
+ cors?: {
161
+ origins?: string[];
162
+ credentials?: boolean;
163
+ };
164
+ rateLimit?: {
165
+ login?: {
166
+ limit: number;
167
+ windowSeconds: number;
168
+ };
169
+ magic?: {
170
+ limit: number;
171
+ windowSeconds: number;
172
+ };
173
+ totp?: {
174
+ limit: number;
175
+ windowSeconds: number;
176
+ };
177
+ };
178
+ };
137
179
  }
138
180
  declare function loadConfig(): PlanetLoginConfig;
139
181
  /** The public subset served at GET /auth/config (no secrets). */
@@ -176,6 +218,9 @@ declare function publicConfig(c?: PlanetLoginConfig): {
176
218
  showSearch?: boolean;
177
219
  autoSpin?: boolean;
178
220
  };
221
+ locale: {
222
+ flyToOnLogin: boolean;
223
+ };
179
224
  };
180
225
  declare function downstreamFromEnv(): Downstream;
181
226
 
@@ -193,13 +238,19 @@ interface SessionStore {
193
238
  delete(key: string): Promise<void>;
194
239
  /** Atomically claim a key once: true if newly claimed, false if already present. */
195
240
  claimOnce(key: string, ttlSeconds: number): Promise<boolean>;
241
+ /** Atomically increment a counter, setting its TTL on first touch; returns the
242
+ * new count. Used by the rate limiter (fixed window). */
243
+ incr(key: string, ttlSeconds: number): Promise<number>;
196
244
  }
197
- /** No store: stateless. claimOnce always allows (single-use not enforced → TTL only). */
245
+ /** No store: stateless. claimOnce always allows (single-use not enforced → TTL
246
+ * only); incr always returns 1 → the rate limiter never blocks. Both real
247
+ * single-use and rate limiting need a configured store (memory/redis/…). */
198
248
  declare class NoneStore implements SessionStore {
199
249
  get(): Promise<null>;
200
250
  set(): Promise<void>;
201
251
  delete(): Promise<void>;
202
252
  claimOnce(): Promise<boolean>;
253
+ incr(): Promise<number>;
203
254
  }
204
255
  declare class MemoryStore implements SessionStore {
205
256
  private m;
@@ -208,6 +259,7 @@ declare class MemoryStore implements SessionStore {
208
259
  set(key: string, value: string, ttlSeconds: number): Promise<void>;
209
260
  delete(key: string): Promise<void>;
210
261
  claimOnce(key: string, ttlSeconds: number): Promise<boolean>;
262
+ incr(key: string, ttlSeconds: number): Promise<number>;
211
263
  }
212
264
  type StoreKind = 'none' | 'memory' | 'redis' | 'sqlite' | 'downstream';
213
265
  /** Build the store from config. Only none+memory ship in this flavor; the rest
@@ -218,6 +270,57 @@ declare const _stores: {
218
270
  MemoryStore: typeof MemoryStore;
219
271
  };
220
272
 
273
+ interface RateLimitRule {
274
+ /** Max attempts allowed within the window. */
275
+ limit: number;
276
+ /** Window length in seconds. */
277
+ windowSeconds: number;
278
+ }
279
+ interface RateLimitDecision {
280
+ ok: boolean;
281
+ /** Attempts left in the current window (0 when blocked). */
282
+ remaining: number;
283
+ /** Seconds the caller should wait before retrying (only meaningful when blocked). */
284
+ retryAfter: number;
285
+ limit: number;
286
+ }
287
+ /** Built-in defaults (per key, fixed window). Tuned for human use; override via
288
+ * config.security.rateLimit or PLANETLOGIN_RATELIMIT_* env. */
289
+ declare const DEFAULT_RULES: Record<string, RateLimitRule>;
290
+ /**
291
+ * Consume one attempt for `key` under `rule`. `key` should already encode the
292
+ * action and a client discriminator (IP and/or identifier), e.g.
293
+ * `login:203.0.113.7` or `magic:user@x.com`. Builders below help.
294
+ */
295
+ declare function rateLimit(store: SessionStore, key: string, rule: RateLimitRule): Promise<RateLimitDecision>;
296
+ /** Resolve a rule for an action: config/env override, else built-in default. */
297
+ declare function ruleFor(action: keyof typeof DEFAULT_RULES, overrides?: Partial<Record<string, RateLimitRule>>): RateLimitRule;
298
+ /** Build a rate-limit key from an action + the client discriminators available.
299
+ * Both IP and identifier are optional; whatever is present is hashed into the key. */
300
+ declare function rlKey(action: string, parts: {
301
+ ip?: string | null;
302
+ identifier?: string | null;
303
+ }): string;
304
+
305
+ interface CorsConfig {
306
+ /** Allowed origins, or ['*'] to reflect any. */
307
+ origins: string[];
308
+ /** Send Access-Control-Allow-Credentials (cookies). Default true. */
309
+ credentials?: boolean;
310
+ }
311
+ /** Read the allowlist from env (flavors may merge with config). */
312
+ declare function corsFromEnv(): CorsConfig;
313
+ /** Is `origin` allowed under this config? */
314
+ declare function originAllowed(origin: string | null | undefined, cfg: CorsConfig): boolean;
315
+ /**
316
+ * CORS response headers for a given request origin. Returns {} when the origin
317
+ * isn't allowed (no CORS headers → the browser blocks the cross-origin read).
318
+ * Always includes `Vary: Origin` so caches don't serve the wrong ACAO.
319
+ */
320
+ declare function corsHeaders(origin: string | null | undefined, cfg: CorsConfig): Record<string, string>;
321
+ /** True for a CORS preflight (OPTIONS + Access-Control-Request-Method). */
322
+ declare function isPreflight(method: string, requestMethodHeader: string | null | undefined): boolean;
323
+
221
324
  interface ProviderConfig {
222
325
  authorizeUrl: string;
223
326
  tokenUrl: string;
@@ -347,6 +450,24 @@ type PasswordLoginResult = {
347
450
  };
348
451
  declare function passwordLogin(deps: PasswordLoginDeps, input: PasswordLoginInput): Promise<PasswordLoginResult>;
349
452
 
453
+ interface PreferencesDeps {
454
+ downstream: Pick<Downstream, 'preferencesGet' | 'preferencesSave'>;
455
+ }
456
+ /** Keep only the known, well-typed locale fields (drop anything else). */
457
+ declare function sanitizeLocale(input: unknown): Locale | undefined;
458
+ /** Read the user's saved preferences. Always resolves (empty object if none/error). */
459
+ declare function getPreferences(deps: PreferencesDeps, input: {
460
+ userId: string;
461
+ }): Promise<UserPreferences>;
462
+ /** Write the user's preferences. No-op when there's nothing valid to write. */
463
+ declare function savePreferences(deps: PreferencesDeps, input: {
464
+ userId: string;
465
+ locale?: unknown;
466
+ data?: unknown;
467
+ }): Promise<{
468
+ saved: boolean;
469
+ }>;
470
+
350
471
  interface MagicRequestDeps {
351
472
  downstream: Downstream;
352
473
  signMagicToken: (identifier: string) => Promise<string>;
@@ -490,4 +611,4 @@ declare function totpVerify(deps: TotpDeps, input: {
490
611
  ok: boolean;
491
612
  }>;
492
613
 
493
- export { type AuthResult, type AuthVerifyDeps, Downstream, type DownstreamUser, type Locale, type MagicRequestDeps, type MagicVerifyDeps, type MagicVerifyResult, type OAuthLoginDeps, type OAuthLoginInput, type OAuthLoginResult, type OAuthStateData, type PasswordLoginDeps, type PasswordLoginInput, type PasswordLoginResult, type PlanetLoginConfig, type ProviderConfig, type RegisterResult, type RegisterVerifyDeps, type SessionClaims, type SessionStore, type StoreKind, type StoredCredential, type TotpDeps, _stores, authenticationOptions, buildAuthUrl, downstreamFromEnv, exchangeCode, fetchProfile, getProvider, getStore, hashPassword, jwks, loadConfig, newTotpSecret, oauthCallback, oauthStart, openEnc, openOAuthState, passkeyAuthVerify, passkeyRegisterVerify, passwordLogin, pkcePair, publicConfig, registrationOptions, requestMagicLink, sealEnc, sealOAuthState, signMagicToken, signSession, totpEnroll, totpKeyUri, totpVerify, verifyAuthentication, verifyMagicLink, verifyMagicToken, verifyPassword, verifyRegistration, verifySession, verifyTotp };
614
+ export { type AuthResult, type AuthVerifyDeps, type CorsConfig, DEFAULT_RULES, Downstream, type DownstreamUser, type JwtAlg, type Locale, type MagicRequestDeps, type MagicVerifyDeps, type MagicVerifyResult, type OAuthLoginDeps, type OAuthLoginInput, type OAuthLoginResult, type OAuthStateData, type PasswordLoginDeps, type PasswordLoginInput, type PasswordLoginResult, type PlanetLoginConfig, type PreferencesDeps, type ProviderConfig, type RateLimitDecision, type RateLimitRule, type RegisterResult, type RegisterVerifyDeps, type SessionClaims, type SessionStore, type StoreKind, type StoredCredential, type TotpDeps, type UserPreferences, _stores, authenticationOptions, buildAuthUrl, corsFromEnv, corsHeaders, downstreamFromEnv, exchangeCode, fetchProfile, getPreferences, getProvider, getStore, hashPassword, isPreflight, jwks, loadConfig, newTotpSecret, oauthCallback, oauthStart, openEnc, openOAuthState, originAllowed, passkeyAuthVerify, passkeyRegisterVerify, passwordLogin, pkcePair, publicConfig, rateLimit, registrationOptions, requestMagicLink, rlKey, ruleFor, sanitizeLocale, savePreferences, sealEnc, sealOAuthState, signMagicToken, signSession, totpEnroll, totpKeyUri, totpVerify, verifyAuthentication, verifyMagicLink, verifyMagicToken, verifyPassword, verifyRegistration, verifySession, verifyTotp };
package/dist/index.js CHANGED
@@ -55,6 +55,13 @@ var Downstream = class {
55
55
  totpSave(data) {
56
56
  return this.call("/totp/save", data);
57
57
  }
58
+ /** Preferences (spec §4): per-user locale + open data bag. Null when none. */
59
+ preferencesGet(query) {
60
+ return this.call("/preferences/find", query);
61
+ }
62
+ preferencesSave(data) {
63
+ return this.call("/preferences/save", data);
64
+ }
58
65
  };
59
66
 
60
67
  // src/config.ts
@@ -71,7 +78,15 @@ function loadConfig() {
71
78
  return cfg;
72
79
  }
73
80
  function publicConfig(c = loadConfig()) {
74
- return { spec: c.spec, brand: c.brand, providers: c.providers, copy: c.copy ?? {}, layout: c.layout ?? {} };
81
+ return {
82
+ spec: c.spec,
83
+ brand: c.brand,
84
+ providers: c.providers,
85
+ copy: c.copy ?? {},
86
+ layout: c.layout ?? {},
87
+ // Only the client-relevant gate (flyToOnLogin drives the post-login fly-to).
88
+ locale: { flyToOnLogin: c.locale?.flyToOnLogin ?? false }
89
+ };
75
90
  }
76
91
  function downstreamFromEnv() {
77
92
  const url = process.env.PLANETLOGIN_DOWNSTREAM_URL;
@@ -86,47 +101,84 @@ import {
86
101
  jwtVerify,
87
102
  exportJWK,
88
103
  generateKeyPair,
104
+ generateSecret,
89
105
  importPKCS8,
90
106
  createLocalJWKSet
91
107
  } from "jose";
108
+ import { readFileSync as readFileSync2 } from "fs";
109
+ var ASYMMETRIC = ["EdDSA", "RS256", "ES256"];
92
110
  var cache = null;
111
+ function resolveAlg() {
112
+ const a = process.env.PLANETLOGIN_JWT_ALG || "EdDSA";
113
+ if (!ASYMMETRIC.includes(a) && a !== "HS256") {
114
+ throw new Error(`Unsupported PLANETLOGIN_JWT_ALG "${a}" (use EdDSA|RS256|ES256|HS256)`);
115
+ }
116
+ return a;
117
+ }
118
+ function loadPrivatePem() {
119
+ const v = process.env.PLANETLOGIN_JWT_PRIVATE_KEY ?? process.env.PLANETLOGIN_JWT_PRIVATE_KEY_PEM;
120
+ if (!v) return null;
121
+ return v.includes("-----BEGIN") ? v : readFileSync2(v, "utf8");
122
+ }
123
+ function loadSecret() {
124
+ const v = process.env.PLANETLOGIN_JWT_SECRET;
125
+ if (!v) return null;
126
+ const raw = v.includes("\n") || v.length > 256 ? v : v.startsWith("/") ? readFileSync2(v, "utf8").trim() : v;
127
+ return new TextEncoder().encode(raw);
128
+ }
93
129
  async function getKeys() {
94
130
  if (cache) return cache;
131
+ const alg = resolveAlg();
132
+ const kid = process.env.PLANETLOGIN_JWT_KID || "dev";
133
+ if (alg === "HS256") {
134
+ let secret = loadSecret();
135
+ if (!secret) {
136
+ console.warn("[planetlogin] WARNING: no PLANETLOGIN_JWT_SECRET \u2014 using an EPHEMERAL HS256 secret. Tokens die on restart and cannot be verified across instances. Do not use in production.");
137
+ secret = await generateSecret("HS256", { extractable: true });
138
+ }
139
+ cache = { alg, kid, sign: secret, verify: secret, pubJwk: null };
140
+ return cache;
141
+ }
95
142
  let priv;
96
- const pem = process.env.PLANETLOGIN_JWT_PRIVATE_KEY_PEM;
143
+ const pem = loadPrivatePem();
97
144
  if (pem) {
98
- priv = await importPKCS8(pem, "EdDSA");
145
+ priv = await importPKCS8(pem, alg);
99
146
  } else {
100
- priv = (await generateKeyPair("EdDSA", { extractable: true })).privateKey;
147
+ console.warn(`[planetlogin] WARNING: no PLANETLOGIN_JWT_PRIVATE_KEY \u2014 using an EPHEMERAL ${alg} keypair. Do not use in production.`);
148
+ priv = (await generateKeyPair(alg, { extractable: true })).privateKey;
101
149
  }
102
150
  const full = await exportJWK(priv);
103
- const { d, ...pub } = full;
104
- const kid = process.env.PLANETLOGIN_JWT_KID || "dev";
105
- cache = { priv, pubJwk: { ...pub, kid, use: "sig", alg: "EdDSA" }, kid };
151
+ const { d, p, q, dp, dq, qi, ...pub } = full;
152
+ cache = { alg, kid, sign: priv, verify: priv, pubJwk: { ...pub, kid, use: "sig", alg } };
106
153
  return cache;
107
154
  }
108
155
  async function jwks() {
109
156
  const k = await getKeys();
110
- return { keys: [k.pubJwk] };
157
+ return { keys: k.pubJwk ? [k.pubJwk] : [] };
158
+ }
159
+ async function verifier() {
160
+ const k = await getKeys();
161
+ if (k.alg === "HS256") return k.verify;
162
+ return createLocalJWKSet({ keys: [k.pubJwk] });
111
163
  }
112
164
  async function signSession(claims, opts = {}) {
113
165
  const k = await getKeys();
114
- return new SignJWT({ email: claims.email, name: claims.name, locale: claims.locale }).setProtectedHeader({ alg: "EdDSA", kid: k.kid }).setSubject(claims.sub).setIssuedAt().setExpirationTime(`${opts.ttlSeconds ?? 3600}s`).setIssuer(opts.issuer ?? "planetlogin").setAudience(opts.audience ?? "planetlogin").sign(k.priv);
166
+ return new SignJWT({ email: claims.email, name: claims.name, locale: claims.locale }).setProtectedHeader({ alg: k.alg, kid: k.kid }).setSubject(claims.sub).setIssuedAt().setExpirationTime(`${opts.ttlSeconds ?? 3600}s`).setIssuer(opts.issuer ?? "planetlogin").setAudience(opts.audience ?? "planetlogin").sign(k.sign);
115
167
  }
116
168
  async function verifySession(token) {
117
- const set = await jwks();
118
- const keySet = createLocalJWKSet(set);
119
- const { payload } = await jwtVerify(token, keySet, { issuer: "planetlogin", audience: "planetlogin" });
169
+ const { payload } = await jwtVerify(token, await verifier(), {
170
+ issuer: "planetlogin",
171
+ audience: "planetlogin"
172
+ });
120
173
  return payload;
121
174
  }
122
175
  async function signMagicToken(identifier, ttlSeconds = 900) {
123
176
  const k = await getKeys();
124
- return new SignJWT({ purpose: "magic" }).setProtectedHeader({ alg: "EdDSA", kid: k.kid }).setSubject(identifier).setJti(crypto.randomUUID()).setIssuedAt().setExpirationTime(`${ttlSeconds}s`).setIssuer("planetlogin").setAudience("planetlogin:magic").sign(k.priv);
177
+ return new SignJWT({ purpose: "magic" }).setProtectedHeader({ alg: k.alg, kid: k.kid }).setSubject(identifier).setJti(crypto.randomUUID()).setIssuedAt().setExpirationTime(`${ttlSeconds}s`).setIssuer("planetlogin").setAudience("planetlogin:magic").sign(k.sign);
125
178
  }
126
179
  async function verifyMagicToken(token) {
127
180
  try {
128
- const set = await jwks();
129
- const { payload } = await jwtVerify(token, createLocalJWKSet(set), {
181
+ const { payload } = await jwtVerify(token, await verifier(), {
130
182
  issuer: "planetlogin",
131
183
  audience: "planetlogin:magic"
132
184
  });
@@ -201,6 +253,9 @@ var NoneStore = class {
201
253
  async claimOnce() {
202
254
  return true;
203
255
  }
256
+ async incr() {
257
+ return 1;
258
+ }
204
259
  };
205
260
  var MemoryStore = class {
206
261
  m = /* @__PURE__ */ new Map();
@@ -227,6 +282,16 @@ var MemoryStore = class {
227
282
  await this.set(key, "1", ttlSeconds);
228
283
  return true;
229
284
  }
285
+ async incr(key, ttlSeconds) {
286
+ const e = this.alive(key);
287
+ if (!e) {
288
+ await this.set(key, "1", ttlSeconds);
289
+ return 1;
290
+ }
291
+ const n = Number(e.v) + 1;
292
+ e.v = String(n);
293
+ return n;
294
+ }
230
295
  };
231
296
  var cached = null;
232
297
  function getStore(kind = process.env.PLANETLOGIN_SESSION_STORE || "none") {
@@ -245,13 +310,75 @@ function getStore(kind = process.env.PLANETLOGIN_SESSION_STORE || "none") {
245
310
  }
246
311
  var _stores = { NoneStore, MemoryStore };
247
312
 
313
+ // src/ratelimit.ts
314
+ var DEFAULT_RULES = {
315
+ login: { limit: 10, windowSeconds: 300 },
316
+ // 10 / 5 min
317
+ magic: { limit: 5, windowSeconds: 900 },
318
+ // 5 / 15 min
319
+ totp: { limit: 10, windowSeconds: 300 }
320
+ };
321
+ async function rateLimit(store, key, rule) {
322
+ try {
323
+ const count = await store.incr(`rl:${key}`, rule.windowSeconds);
324
+ const remaining = Math.max(0, rule.limit - count);
325
+ if (count > rule.limit) {
326
+ return { ok: false, remaining: 0, retryAfter: rule.windowSeconds, limit: rule.limit };
327
+ }
328
+ return { ok: true, remaining, retryAfter: 0, limit: rule.limit };
329
+ } catch {
330
+ return { ok: true, remaining: rule.limit, retryAfter: 0, limit: rule.limit };
331
+ }
332
+ }
333
+ function ruleFor(action, overrides) {
334
+ const envLimit = process.env[`PLANETLOGIN_RATELIMIT_${action.toUpperCase()}_LIMIT`];
335
+ const envWindow = process.env[`PLANETLOGIN_RATELIMIT_${action.toUpperCase()}_WINDOW`];
336
+ const base = overrides?.[action] ?? DEFAULT_RULES[action];
337
+ return {
338
+ limit: envLimit ? Number(envLimit) : base.limit,
339
+ windowSeconds: envWindow ? Number(envWindow) : base.windowSeconds
340
+ };
341
+ }
342
+ function rlKey(action, parts) {
343
+ const disc = [parts.ip, parts.identifier].filter(Boolean).join("|") || "anon";
344
+ return `${action}:${disc}`;
345
+ }
346
+
347
+ // src/cors.ts
348
+ var METHODS = "GET, POST, OPTIONS";
349
+ var ALLOW_HEADERS = "content-type, authorization";
350
+ function corsFromEnv() {
351
+ const raw = process.env.PLANETLOGIN_CORS_ORIGINS ?? "";
352
+ const origins = raw.split(",").map((s) => s.trim()).filter(Boolean);
353
+ const credentials = process.env.PLANETLOGIN_CORS_CREDENTIALS !== "false";
354
+ return { origins, credentials };
355
+ }
356
+ function originAllowed(origin, cfg2) {
357
+ if (!origin) return false;
358
+ if (cfg2.origins.includes(origin)) return true;
359
+ return cfg2.origins.includes("*") && cfg2.credentials === false;
360
+ }
361
+ function corsHeaders(origin, cfg2) {
362
+ const h = { vary: "Origin" };
363
+ if (!originAllowed(origin, cfg2)) return h;
364
+ h["access-control-allow-origin"] = cfg2.credentials === false && cfg2.origins.includes("*") ? "*" : origin;
365
+ if (cfg2.credentials !== false) h["access-control-allow-credentials"] = "true";
366
+ h["access-control-allow-methods"] = METHODS;
367
+ h["access-control-allow-headers"] = ALLOW_HEADERS;
368
+ h["access-control-max-age"] = "600";
369
+ return h;
370
+ }
371
+ function isPreflight(method, requestMethodHeader) {
372
+ return method === "OPTIONS" && !!requestMethodHeader;
373
+ }
374
+
248
375
  // src/oauth.ts
249
376
  import { createHash, randomBytes } from "crypto";
250
377
  var b64url = (b) => b.toString("base64url");
251
378
  function pkcePair() {
252
- const verifier = b64url(randomBytes(32));
253
- const challenge = b64url(createHash("sha256").update(verifier).digest());
254
- return { verifier, challenge };
379
+ const verifier2 = b64url(randomBytes(32));
380
+ const challenge = b64url(createHash("sha256").update(verifier2).digest());
381
+ return { verifier: verifier2, challenge };
255
382
  }
256
383
  var REGISTRY = {
257
384
  google: {
@@ -297,9 +424,9 @@ function buildAuthUrl(p, a) {
297
424
  return u.toString();
298
425
  }
299
426
  function oauthStart(p, a) {
300
- const { verifier, challenge } = pkcePair();
427
+ const { verifier: verifier2, challenge } = pkcePair();
301
428
  const state = b64url(randomBytes(16));
302
- return { url: buildAuthUrl(p, { ...a, state, challenge }), state, codeVerifier: verifier };
429
+ return { url: buildAuthUrl(p, { ...a, state, challenge }), state, codeVerifier: verifier2 };
303
430
  }
304
431
  async function exchangeCode(p, a) {
305
432
  const res = await fetch(p.tokenUrl, {
@@ -445,6 +572,30 @@ async function passwordLogin(deps, input) {
445
572
  return { ok: true, token, user: { id: user.id, email: user.email, name: user.name } };
446
573
  }
447
574
 
575
+ // src/flows/preferences.ts
576
+ function sanitizeLocale(input) {
577
+ if (!input || typeof input !== "object") return void 0;
578
+ const i = input;
579
+ const out = {};
580
+ if (typeof i.language === "string") out.language = i.language.slice(0, 16);
581
+ if (typeof i.timezone === "string") out.timezone = i.timezone.slice(0, 64);
582
+ if (typeof i.country === "string") out.country = i.country.slice(0, 2).toUpperCase();
583
+ if (typeof i.lat === "number" && Number.isFinite(i.lat)) out.lat = i.lat;
584
+ if (typeof i.lon === "number" && Number.isFinite(i.lon)) out.lon = i.lon;
585
+ return Object.keys(out).length ? out : void 0;
586
+ }
587
+ async function getPreferences(deps, input) {
588
+ const p = await deps.downstream.preferencesGet({ userId: input.userId }).catch(() => null);
589
+ return p ?? {};
590
+ }
591
+ async function savePreferences(deps, input) {
592
+ const locale = sanitizeLocale(input.locale);
593
+ const data = input.data && typeof input.data === "object" && !Array.isArray(input.data) ? input.data : void 0;
594
+ if (!locale && !data) return { saved: false };
595
+ await deps.downstream.preferencesSave({ userId: input.userId, ...locale ? { locale } : {}, ...data ? { data } : {} });
596
+ return { saved: true };
597
+ }
598
+
448
599
  // src/flows/magicLink.ts
449
600
  async function requestMagicLink(deps, input) {
450
601
  try {
@@ -553,16 +704,21 @@ async function totpVerify(deps, input) {
553
704
  return { ok: true };
554
705
  }
555
706
  export {
707
+ DEFAULT_RULES,
556
708
  Downstream,
557
709
  _stores,
558
710
  authenticationOptions,
559
711
  buildAuthUrl,
712
+ corsFromEnv,
713
+ corsHeaders,
560
714
  downstreamFromEnv,
561
715
  exchangeCode,
562
716
  fetchProfile,
717
+ getPreferences,
563
718
  getProvider,
564
719
  getStore,
565
720
  hashPassword,
721
+ isPreflight,
566
722
  jwks,
567
723
  loadConfig,
568
724
  newTotpSecret,
@@ -570,13 +726,19 @@ export {
570
726
  oauthStart,
571
727
  openEnc,
572
728
  openOAuthState,
729
+ originAllowed,
573
730
  passkeyAuthVerify,
574
731
  passkeyRegisterVerify,
575
732
  passwordLogin,
576
733
  pkcePair,
577
734
  publicConfig,
735
+ rateLimit,
578
736
  registrationOptions,
579
737
  requestMagicLink,
738
+ rlKey,
739
+ ruleFor,
740
+ sanitizeLocale,
741
+ savePreferences,
580
742
  sealEnc,
581
743
  sealOAuthState,
582
744
  signMagicToken,
@@ -0,0 +1 @@
1
+ #!/usr/bin/env node
package/dist/keygen.js ADDED
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env node
2
+
3
+ // bin/keygen.ts
4
+ import { generateKeyPair, exportPKCS8, exportJWK } from "jose";
5
+ import { writeFileSync } from "fs";
6
+ var { privateKey, publicKey } = await generateKeyPair("EdDSA", { extractable: true });
7
+ var pem = await exportPKCS8(privateKey);
8
+ var pubJwk = await exportJWK(publicKey);
9
+ var out = process.argv[2];
10
+ if (out) {
11
+ writeFileSync(out, pem, { mode: 384 });
12
+ console.error(`Private key written to ${out} (chmod 600).`);
13
+ console.error(`
14
+ Set: PLANETLOGIN_JWT_PRIVATE_KEY=${out}`);
15
+ } else {
16
+ process.stdout.write(pem);
17
+ console.error("\n# \u2191 EdDSA private key (PEM). Keep it secret. Then set one of:");
18
+ console.error("# PLANETLOGIN_JWT_PRIVATE_KEY=/path/to/this/key.pem (recommended: a secret file)");
19
+ console.error('# PLANETLOGIN_JWT_PRIVATE_KEY="$(cat key.pem)" (inline)');
20
+ }
21
+ console.error(`
22
+ # Public key (the kid in your JWKS will be PLANETLOGIN_JWT_KID, default "dev"):`);
23
+ console.error("# " + JSON.stringify({ ...pubJwk, use: "sig", alg: "EdDSA" }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@planetlogin/core",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "description": "PlanetLogin auth core — framework-agnostic flows, JWT, crypto, downstream contract. Consumed by every flavor; the HTTP binding stays per-framework.",
6
6
  "exports": {
@@ -12,10 +12,17 @@
12
12
  "main": "./dist/index.js",
13
13
  "module": "./dist/index.js",
14
14
  "types": "./dist/index.d.ts",
15
- "files": ["dist"],
15
+ "bin": {
16
+ "planetlogin-keygen": "./dist/keygen.js"
17
+ },
18
+ "files": [
19
+ "dist"
20
+ ],
16
21
  "scripts": {
17
22
  "build": "tsup",
23
+ "prepare": "tsup",
18
24
  "prepublishOnly": "tsup",
25
+ "keygen": "tsx bin/keygen.ts",
19
26
  "test": "vitest run",
20
27
  "typecheck": "tsc --noEmit",
21
28
  "mock": "tsx mock-downstream/server.ts"