{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "knock-codes-core",
  "title": "Knock Codes Core",
  "description": "Framework-agnostic hash/session/storage/verify logic. Installed automatically as a dependency of every React block — install directly only if you're building a custom framework binding.",
  "files": [
    {
      "path": "packages/core/hash.ts",
      "content": "/**\r\n * Canonical hashing contract.\r\n *\r\n * Input is hashed exactly as given: UTF-8 encoded, no trimming, no\r\n * case-folding, no Unicode normalization. Output is lowercase hex SHA-256.\r\n * The hash generator, install docs, and any server template's comparison\r\n * logic must all produce the same hash for the same code string — that only\r\n * holds if every implementation follows this exact procedure.\r\n *\r\n * Uses Web Crypto (`crypto.subtle`), available unmodified in both browsers\r\n * and Node (>=19) — no import, no dependency.\r\n */\r\nexport async function sha256Hex(input: string): Promise<string> {\r\n  const bytes = new TextEncoder().encode(input);\r\n  const digest = await crypto.subtle.digest(\"SHA-256\", bytes);\r\n  return Array.from(new Uint8Array(digest))\r\n    .map((byte) => byte.toString(16).padStart(2, \"0\"))\r\n    .join(\"\");\r\n}\r\n",
      "type": "registry:file",
      "target": "components/knock-codes/core/hash.ts"
    },
    {
      "path": "packages/core/verify.ts",
      "content": "import { sha256Hex } from \"./hash.ts\";\r\n\r\n/**\r\n * Verification contract.\r\n *\r\n * Both the local-hash strategy and any custom server-mode strategy resolve\r\n * to this same shape. `reason` exists so the UI can distinguish \"wrong\r\n * code\" from \"couldn't reach the server\" without either strategy needing\r\n * to know about the other.\r\n */\r\nexport type VerifyResult =\r\n  | { ok: true; token?: string }\r\n  | { ok: false; reason?: \"invalid\" | \"network\" | \"unknown\" };\r\n\r\nexport type VerifyFn = (code: string) => Promise<VerifyResult>;\r\n\r\n/**\r\n * Builds the default local-hash `VerifyFn`. Hashing follows the canonical\r\n * contract in hash.ts — no normalization beyond that. A local hash\r\n * comparison cannot fail for network reasons, so this verifier only ever\r\n * resolves `{ ok: true }` or `{ ok: false, reason: \"invalid\" }`.\r\n */\r\nexport function createLocalHashVerifier(expectedHash: string): VerifyFn {\r\n  return async (code) => {\r\n    const actualHash = await sha256Hex(code);\r\n    return actualHash === expectedHash ? { ok: true } : { ok: false, reason: \"invalid\" };\r\n  };\r\n}\r\n\r\nexport interface VerifyConfig {\r\n  expectedHash?: string;\r\n  verify?: VerifyFn;\r\n}\r\n\r\n/**\r\n * Resolves a `{ expectedHash, verify }` config into the single `VerifyFn`\r\n * a session lifecycle actually calls. Supplying both or neither is a\r\n * configuration error — this always throws for either case rather than\r\n * silently picking a winner. Framework surfaces (e.g. `useKnockCodes`)\r\n * decide *when* to call this — whether that's once at construction or\r\n * gated behind a dev-only check is a framework-layer choice, not a core\r\n * one; this function itself has no dev/prod branch.\r\n */\r\nexport function resolveVerifyFn(config: VerifyConfig): VerifyFn {\r\n  const hasHash = config.expectedHash !== undefined;\r\n  const hasVerify = config.verify !== undefined;\r\n\r\n  if (hasHash === hasVerify) {\r\n    throw new Error(\r\n      hasHash\r\n        ? \"Knock Codes: supply either `expectedHash` or `verify`, not both.\"\r\n        : \"Knock Codes: supply either `expectedHash` or `verify` — no implicit default verification strategy.\"\r\n    );\r\n  }\r\n\r\n  return hasHash ? createLocalHashVerifier(config.expectedHash as string) : (config.verify as VerifyFn);\r\n}\r\n",
      "type": "registry:file",
      "target": "components/knock-codes/core/verify.ts"
    },
    {
      "path": "packages/core/session.ts",
      "content": "/**\r\n * Session record schema and lifecycle.\r\n *\r\n * This is the one and only shape any Knock Codes surface (React hook,\r\n * vanilla snippet) writes. The raw code and its hash are never part of\r\n * this record — only the fact and timing of a successful unlock.\r\n */\r\nexport type KnockCodesSession = {\r\n  /** epoch ms, set once at successful verification */\r\n  unlockedAt: number;\r\n  /** epoch ms; fixed at creation, or rewritten on each interaction under the sliding model */\r\n  expiresAt: number;\r\n  /** present only if the VerifyFn resolved one (server mode); absent in local-hash mode */\r\n  token?: string;\r\n};\r\n\r\n/**\r\n * Creates a new session from a successful verification result. `timeoutMs`\r\n * sets the initial expiry; `now` is injectable for deterministic tests.\r\n *\r\n * `token` is included only when the verify result actually provided one —\r\n * a genuinely missing field, not a `token: undefined` key.\r\n */\r\nexport function createSession(\r\n  result: { token?: string },\r\n  timeoutMs: number,\r\n  now: number = Date.now()\r\n): KnockCodesSession {\r\n  return {\r\n    unlockedAt: now,\r\n    expiresAt: now + timeoutMs,\r\n    ...(result.token !== undefined ? { token: result.token } : {}),\r\n  };\r\n}\r\n\r\nexport function isExpired(session: KnockCodesSession, now: number = Date.now()): boolean {\r\n  return now >= session.expiresAt;\r\n}\r\n\r\n/**\r\n * Sliding-timeout model (opt-in activity tracking): rewrites `expiresAt`\r\n * relative to `now`, leaving `unlockedAt` and `token` untouched. Returns a\r\n * new object rather than mutating the input.\r\n */\r\nexport function touchExpiry(\r\n  session: KnockCodesSession,\r\n  timeoutMs: number,\r\n  now: number = Date.now()\r\n): KnockCodesSession {\r\n  return { ...session, expiresAt: now + timeoutMs };\r\n}\r\n",
      "type": "registry:file",
      "target": "components/knock-codes/core/session.ts"
    },
    {
      "path": "packages/core/storage.ts",
      "content": "import type { KnockCodesSession } from \"./session.ts\";\r\n\r\n/** Structural subset of the Web Storage API — avoids depending on DOM lib types. */\r\nexport interface StorageLike {\r\n  getItem(key: string): string | null;\r\n  setItem(key: string, value: string): void;\r\n  removeItem(key: string): void;\r\n}\r\n\r\n/** Minimal shape of the native `storage` event, enough to filter by key. */\r\nexport interface MinimalStorageEvent {\r\n  key: string | null;\r\n}\r\n\r\n/** Structural subset of an event target that can fire `storage` events (i.e. `window`). */\r\nexport interface StorageEventTarget {\r\n  addEventListener(type: \"storage\", listener: (event: MinimalStorageEvent) => void): void;\r\n  removeEventListener(type: \"storage\", listener: (event: MinimalStorageEvent) => void): void;\r\n}\r\n\r\nexport type StorageMode = \"localStorage\" | \"sessionStorage\" | \"memory\";\r\n\r\n/**\r\n * Unified interface all three storage backends implement.\r\n * `subscribe` fires `callback` when the session changes in another tab; it\r\n * is a real cross-tab mechanism only for `localStorage` (native `storage`\r\n * events). For `sessionStorage` and `memory` it never fires — that's\r\n * correct behavior, not a missing feature, since neither mode has anything\r\n * to sync across tabs to begin with.\r\n */\r\nexport interface SessionStore {\r\n  get(): KnockCodesSession | null;\r\n  set(session: KnockCodesSession): void;\r\n  clear(): void;\r\n  subscribe(callback: () => void): () => void;\r\n}\r\n\r\nexport const DEFAULT_STORAGE_KEY = \"knock-codes:session\";\r\n\r\nconst NOOP_UNSUBSCRIBE = () => {};\r\n\r\nfunction isValidSession(value: unknown): value is KnockCodesSession {\r\n  if (typeof value !== \"object\" || value === null) return false;\r\n  const v = value as Record<string, unknown>;\r\n  return typeof v.unlockedAt === \"number\" && typeof v.expiresAt === \"number\";\r\n}\r\n\r\nexport interface CreateSessionStoreOptions {\r\n  storageKey?: string;\r\n  /** Injectable for tests or non-global environments; defaults to the real global. Ignored for `\"memory\"`. */\r\n  storage?: StorageLike;\r\n  /** Injectable for tests; defaults to the real global. Ignored for `\"memory\"` and `\"sessionStorage\"` — see the `subscribe` note above. */\r\n  eventTarget?: StorageEventTarget;\r\n}\r\n\r\nexport function createSessionStore(mode: StorageMode, options: CreateSessionStoreOptions = {}): SessionStore {\r\n  const storageKey = options.storageKey ?? DEFAULT_STORAGE_KEY;\r\n\r\n  if (mode === \"memory\") return createMemoryStore();\r\n\r\n  const storage = options.storage ?? getGlobalStorage(mode);\r\n  const eventTarget = mode === \"localStorage\" ? options.eventTarget ?? getGlobalEventTarget() : undefined;\r\n  return createWebStorageStore(mode, storageKey, storage, eventTarget);\r\n}\r\n\r\nfunction createMemoryStore(): SessionStore {\r\n  let current: KnockCodesSession | null = null;\r\n  return {\r\n    get: () => current,\r\n    set: (session) => {\r\n      current = session;\r\n    },\r\n    clear: () => {\r\n      current = null;\r\n    },\r\n    subscribe: () => NOOP_UNSUBSCRIBE,\r\n  };\r\n}\r\n\r\nfunction createWebStorageStore(\r\n  mode: \"localStorage\" | \"sessionStorage\",\r\n  storageKey: string,\r\n  storage: StorageLike | undefined,\r\n  eventTarget: StorageEventTarget | undefined\r\n): SessionStore {\r\n  function requireStorage(): StorageLike {\r\n    if (!storage) {\r\n      throw new Error(\r\n        `Knock Codes: ${mode} is not available in this environment. Pass a \\`storage\\` implementation explicitly, or use \\`storage: \"memory\"\\`.`\r\n      );\r\n    }\r\n    return storage;\r\n  }\r\n\r\n  return {\r\n    get() {\r\n      const raw = requireStorage().getItem(storageKey);\r\n      if (raw === null) return null;\r\n      let parsed: unknown;\r\n      try {\r\n        parsed = JSON.parse(raw);\r\n      } catch {\r\n        return null; // corrupted entry reads as \"no session\", not a crash\r\n      }\r\n      return isValidSession(parsed) ? parsed : null;\r\n    },\r\n    set(session) {\r\n      requireStorage().setItem(storageKey, JSON.stringify(session));\r\n    },\r\n    clear() {\r\n      requireStorage().removeItem(storageKey);\r\n    },\r\n    subscribe(callback) {\r\n      if (mode !== \"localStorage\" || !eventTarget) return NOOP_UNSUBSCRIBE;\r\n      const handler = (event: MinimalStorageEvent) => {\r\n        if (event.key === storageKey || event.key === null) callback();\r\n      };\r\n      eventTarget.addEventListener(\"storage\", handler);\r\n      return () => eventTarget.removeEventListener(\"storage\", handler);\r\n    },\r\n  };\r\n}\r\n\r\nfunction getGlobalStorage(mode: \"localStorage\" | \"sessionStorage\"): StorageLike | undefined {\r\n  const g = globalThis as unknown as { localStorage?: StorageLike; sessionStorage?: StorageLike };\r\n  return mode === \"localStorage\" ? g.localStorage : g.sessionStorage;\r\n}\r\n\r\nfunction getGlobalEventTarget(): StorageEventTarget | undefined {\r\n  const g = globalThis as unknown as Partial<StorageEventTarget>;\r\n  if (typeof g.addEventListener !== \"function\" || typeof g.removeEventListener !== \"function\") return undefined;\r\n  return g as StorageEventTarget;\r\n}\r\n",
      "type": "registry:file",
      "target": "components/knock-codes/core/storage.ts"
    }
  ],
  "docs": "These files use explicit `.ts` extensions in their relative imports. If your TypeScript config reports \"An import path can only end with a '.ts' extension when 'allowImportingTsExtensions' is enabled,\" add `\"allowImportingTsExtensions\": true` to your tsconfig.json's compilerOptions.",
  "type": "registry:block"
}