sourcecode 1.36.1__tar.gz → 1.36.2__tar.gz

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.
Files changed (118) hide show
  1. {sourcecode-1.36.1 → sourcecode-1.36.2}/PKG-INFO +1 -1
  2. {sourcecode-1.36.1 → sourcecode-1.36.2}/pyproject.toml +1 -1
  3. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/__init__.py +1 -1
  4. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/cli.py +10 -1
  5. sourcecode-1.36.2/src/sourcecode/version_check.py +149 -0
  6. sourcecode-1.36.2/supabase/.temp/cli-latest +1 -0
  7. sourcecode-1.36.2/supabase/functions/lemonsqueezy-webhook/index.ts +220 -0
  8. sourcecode-1.36.2/supabase/sql/license_event_ordering.sql +93 -0
  9. sourcecode-1.36.1/supabase/functions/lemonsqueezy-webhook/index.ts +0 -163
  10. {sourcecode-1.36.1 → sourcecode-1.36.2}/.github/workflows/build-windows.yml +0 -0
  11. {sourcecode-1.36.1 → sourcecode-1.36.2}/.gitignore +0 -0
  12. {sourcecode-1.36.1 → sourcecode-1.36.2}/.ruff.toml +0 -0
  13. {sourcecode-1.36.1 → sourcecode-1.36.2}/CHANGELOG.md +0 -0
  14. {sourcecode-1.36.1 → sourcecode-1.36.2}/CONTRIBUTING.md +0 -0
  15. {sourcecode-1.36.1 → sourcecode-1.36.2}/LICENSE +0 -0
  16. {sourcecode-1.36.1 → sourcecode-1.36.2}/README.md +0 -0
  17. {sourcecode-1.36.1 → sourcecode-1.36.2}/SECURITY.md +0 -0
  18. {sourcecode-1.36.1 → sourcecode-1.36.2}/raw +0 -0
  19. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/adaptive_scanner.py +0 -0
  20. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/architecture_analyzer.py +0 -0
  21. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/architecture_summary.py +0 -0
  22. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/ast_extractor.py +0 -0
  23. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/cache.py +0 -0
  24. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/canonical_ir.py +0 -0
  25. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/cir_graphs.py +0 -0
  26. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/classifier.py +0 -0
  27. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/code_notes_analyzer.py +0 -0
  28. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/confidence_analyzer.py +0 -0
  29. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/context_scorer.py +0 -0
  30. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/context_summarizer.py +0 -0
  31. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/contract_model.py +0 -0
  32. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/contract_pipeline.py +0 -0
  33. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/coverage_parser.py +0 -0
  34. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/dependency_analyzer.py +0 -0
  35. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/detectors/__init__.py +0 -0
  36. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/detectors/base.py +0 -0
  37. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/detectors/csproj_parser.py +0 -0
  38. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/detectors/dart.py +0 -0
  39. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/detectors/dotnet.py +0 -0
  40. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/detectors/elixir.py +0 -0
  41. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/detectors/go.py +0 -0
  42. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/detectors/heuristic.py +0 -0
  43. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/detectors/hybrid.py +0 -0
  44. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/detectors/java.py +0 -0
  45. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/detectors/jvm_ext.py +0 -0
  46. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/detectors/nodejs.py +0 -0
  47. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/detectors/parsers.py +0 -0
  48. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/detectors/php.py +0 -0
  49. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/detectors/project.py +0 -0
  50. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/detectors/python.py +0 -0
  51. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/detectors/ruby.py +0 -0
  52. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/detectors/rust.py +0 -0
  53. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/detectors/systems.py +0 -0
  54. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/detectors/terraform.py +0 -0
  55. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/detectors/tooling.py +0 -0
  56. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/doc_analyzer.py +0 -0
  57. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/entrypoint_classifier.py +0 -0
  58. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/env_analyzer.py +0 -0
  59. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/error_schema.py +0 -0
  60. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/explain.py +0 -0
  61. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/file_chunker.py +0 -0
  62. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/file_classifier.py +0 -0
  63. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/flow_analyzer.py +0 -0
  64. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/fqn_utils.py +0 -0
  65. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/git_analyzer.py +0 -0
  66. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/graph_analyzer.py +0 -0
  67. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/license.py +0 -0
  68. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/mcp/__init__.py +0 -0
  69. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/mcp/onboarding/__init__.py +0 -0
  70. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/mcp/onboarding/applier.py +0 -0
  71. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/mcp/onboarding/backup.py +0 -0
  72. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/mcp/onboarding/detector.py +0 -0
  73. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/mcp/onboarding/planner.py +0 -0
  74. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/mcp/orchestrator.py +0 -0
  75. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/mcp/registry.py +0 -0
  76. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/mcp/runner.py +0 -0
  77. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/mcp/server.py +0 -0
  78. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/mcp_nudge.py +0 -0
  79. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/metrics_analyzer.py +0 -0
  80. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/migrate_check.py +0 -0
  81. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/output_budget.py +0 -0
  82. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/path_filters.py +0 -0
  83. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/pr_comment_renderer.py +0 -0
  84. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/pr_impact.py +0 -0
  85. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/prepare_context.py +0 -0
  86. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/progress.py +0 -0
  87. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/ranking_engine.py +0 -0
  88. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/redactor.py +0 -0
  89. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/relevance_scorer.py +0 -0
  90. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/rename_refactor.py +0 -0
  91. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/repo_classifier.py +0 -0
  92. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/repository_ir.py +0 -0
  93. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/ris.py +0 -0
  94. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/runtime_classifier.py +0 -0
  95. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/scanner.py +0 -0
  96. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/schema.py +0 -0
  97. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/semantic_analyzer.py +0 -0
  98. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/serializer.py +0 -0
  99. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/spring_event_topology.py +0 -0
  100. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/spring_findings.py +0 -0
  101. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/spring_impact.py +0 -0
  102. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/spring_model.py +0 -0
  103. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/spring_security_audit.py +0 -0
  104. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/spring_semantic.py +0 -0
  105. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/spring_tx_analyzer.py +0 -0
  106. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/summarizer.py +0 -0
  107. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/telemetry/__init__.py +0 -0
  108. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/telemetry/config.py +0 -0
  109. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/telemetry/consent.py +0 -0
  110. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/telemetry/events.py +0 -0
  111. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/telemetry/filters.py +0 -0
  112. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/telemetry/transport.py +0 -0
  113. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/tree_utils.py +0 -0
  114. {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/workspace.py +0 -0
  115. {sourcecode-1.36.1 → sourcecode-1.36.2}/supabase/functions/README.md +0 -0
  116. {sourcecode-1.36.1 → sourcecode-1.36.2}/supabase/functions/get-license/index.ts +0 -0
  117. {sourcecode-1.36.1 → sourcecode-1.36.2}/supabase/functions/telemetry/index.ts +0 -0
  118. {sourcecode-1.36.1 → sourcecode-1.36.2}/supabase/sql/telemetry_events.sql +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sourcecode
3
- Version: 1.36.1
3
+ Version: 1.36.2
4
4
  Summary: Persistent structural context and ultra-fast repeated analysis for AI coding agents
5
5
  License-File: LICENSE
6
6
  Keywords: agents,ai,codebase,context,developer-tools,llm
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "sourcecode"
7
- version = "1.36.1"
7
+ version = "1.36.2"
8
8
  description = "Persistent structural context and ultra-fast repeated analysis for AI coding agents"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -1,3 +1,3 @@
1
1
  """sourcecode — Deterministic codebase context maps for AI coding agents."""
2
2
 
3
- __version__ = "1.36.1"
3
+ __version__ = "1.36.2"
@@ -6212,4 +6212,13 @@ def main_entry() -> None:
6212
6212
  except Exception:
6213
6213
  pass
6214
6214
  _preprocess_argv()
6215
- app()
6215
+ try:
6216
+ app()
6217
+ finally:
6218
+ # Best-effort "new version available" nudge. Only speaks on an
6219
+ # interactive terminal; never blocks, raises, or affects exit status.
6220
+ try:
6221
+ from sourcecode.version_check import maybe_notify_update
6222
+ maybe_notify_update(__version__)
6223
+ except Exception:
6224
+ pass
@@ -0,0 +1,149 @@
1
+ """Best-effort "new version available" nudge.
2
+
3
+ Prints a single stderr line when a newer release exists on PyPI. Designed to be
4
+ invisible unless it has something useful to say:
5
+
6
+ * Only runs in an interactive terminal (stderr.isatty()) — never pollutes
7
+ piped output, MCP stdio, CI logs, or test runners.
8
+ * Hits the network at most once per 24h (cached in
9
+ ~/.sourcecode/version_check.json); warm runs read the cache and are instant.
10
+ * Re-shows the same nudge at most ~once per 20h so it informs without nagging.
11
+ * Swallows every error and never blocks meaningfully (1.5s network timeout).
12
+
13
+ Disable entirely with SOURCECODE_NO_UPDATE_CHECK=1 (also off under SOURCECODE_CI).
14
+ The check reads PyPI only; it never touches the license in ~/.sourcecode/license.json.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import os
20
+ import sys
21
+ from datetime import datetime, timezone
22
+ from pathlib import Path
23
+ from typing import Optional
24
+
25
+ _CACHE_DIR = Path.home() / ".sourcecode"
26
+ _CACHE_FILE = _CACHE_DIR / "version_check.json"
27
+ _PYPI_URL = "https://pypi.org/pypi/sourcecode/json"
28
+ _CHECK_TTL_SECONDS = 86_400 # refresh the PyPI lookup at most once per 24h
29
+ _NOTIFY_TTL_SECONDS = 72_000 # re-show the nudge at most every ~20h
30
+ _FETCH_TIMEOUT = 1.5
31
+
32
+
33
+ def _disabled() -> bool:
34
+ """True when the nudge must stay silent (opt-out, CI, or non-interactive)."""
35
+ if os.environ.get("SOURCECODE_NO_UPDATE_CHECK"):
36
+ return True
37
+ if os.environ.get("SOURCECODE_CI"):
38
+ return True
39
+ try:
40
+ return not sys.stderr.isatty()
41
+ except Exception:
42
+ return True # no usable stderr -> stay silent
43
+
44
+
45
+ def _read_cache() -> dict:
46
+ try:
47
+ if _CACHE_FILE.exists():
48
+ return json.loads(_CACHE_FILE.read_text(encoding="utf-8"))
49
+ except Exception:
50
+ pass
51
+ return {}
52
+
53
+
54
+ def _write_cache(data: dict) -> None:
55
+ try:
56
+ _CACHE_DIR.mkdir(parents=True, exist_ok=True)
57
+ tmp = _CACHE_FILE.with_suffix(".tmp")
58
+ tmp.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
59
+ tmp.replace(_CACHE_FILE)
60
+ except Exception:
61
+ pass
62
+
63
+
64
+ def _age_seconds(iso: Optional[str]) -> float:
65
+ if not iso:
66
+ return float("inf")
67
+ try:
68
+ ts = datetime.fromisoformat(iso)
69
+ if ts.tzinfo is None:
70
+ ts = ts.replace(tzinfo=timezone.utc)
71
+ return (datetime.now(timezone.utc) - ts).total_seconds()
72
+ except Exception:
73
+ return float("inf")
74
+
75
+
76
+ def _fetch_latest() -> Optional[str]:
77
+ import urllib.request
78
+ try:
79
+ req = urllib.request.Request(_PYPI_URL, headers={"Accept": "application/json"})
80
+ with urllib.request.urlopen(req, timeout=_FETCH_TIMEOUT) as resp:
81
+ data = json.loads(resp.read().decode("utf-8"))
82
+ return ((data.get("info") or {}).get("version")) or None
83
+ except Exception:
84
+ return None
85
+
86
+
87
+ def _parse(v: str) -> tuple:
88
+ """Lenient dotted-numeric parse for the fallback path (no packaging dep)."""
89
+ parts = []
90
+ for chunk in str(v).split("."):
91
+ num = ""
92
+ for ch in chunk:
93
+ if ch.isdigit():
94
+ num += ch
95
+ else:
96
+ break
97
+ parts.append(int(num) if num else 0)
98
+ return tuple(parts)
99
+
100
+
101
+ def _is_newer(latest: str, current: str) -> bool:
102
+ try:
103
+ from packaging.version import parse as _vparse # type: ignore
104
+ return _vparse(latest) > _vparse(current)
105
+ except Exception:
106
+ return _parse(latest) > _parse(current)
107
+
108
+
109
+ def maybe_notify_update(current_version: str) -> None:
110
+ """Print an upgrade nudge to stderr if PyPI has a newer release.
111
+
112
+ Best-effort and fully guarded: any failure is silently ignored. Safe to call
113
+ unconditionally from the CLI entry point.
114
+ """
115
+ if _disabled():
116
+ return
117
+ try:
118
+ cache = _read_cache()
119
+
120
+ # Refresh the cached "latest" at most once per TTL (the only network hit).
121
+ if _age_seconds(cache.get("checked_at")) >= _CHECK_TTL_SECONDS:
122
+ latest = _fetch_latest()
123
+ if latest:
124
+ cache["latest"] = latest
125
+ cache["checked_at"] = datetime.now(timezone.utc).isoformat()
126
+ _write_cache(cache)
127
+
128
+ latest = cache.get("latest")
129
+ if not latest or not _is_newer(latest, current_version):
130
+ return
131
+
132
+ # Throttle: don't nag for the same version more than once per ~20h.
133
+ if (
134
+ cache.get("notified_for") == latest
135
+ and _age_seconds(cache.get("notified_at")) < _NOTIFY_TTL_SECONDS
136
+ ):
137
+ return
138
+
139
+ sys.stderr.write(
140
+ f"\n[sourcecode] v{latest} is available (you have {current_version}). "
141
+ "Upgrade: pipx upgrade sourcecode (pip: pip install -U sourcecode)\n"
142
+ )
143
+ sys.stderr.flush()
144
+
145
+ cache["notified_for"] = latest
146
+ cache["notified_at"] = datetime.now(timezone.utc).isoformat()
147
+ _write_cache(cache)
148
+ except Exception:
149
+ pass
@@ -0,0 +1 @@
1
+ v2.106.0
@@ -0,0 +1,220 @@
1
+ import { serve } from "https://deno.land/std@0.177.0/http/server.ts";
2
+ import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
3
+
4
+ // Lemon Squeezy webhook. Source of truth for plan/status and the license key.
5
+ // Deploy with --no-verify-jwt (Lemon Squeezy does not send a Supabase JWT;
6
+ // HMAC signature is the authentication). Keep "Generate license keys" ON on
7
+ // every Pro variant: LS emails the key to the customer and we store that same
8
+ // native key here, so there is a single key system end to end.
9
+ //
10
+ // Ordering safety: LS delivers events out of order and Edge Functions run
11
+ // concurrently. We never blind-upsert status. Instead apply_license_event()
12
+ // (see supabase/sql/license_event_ordering.sql) locks the row and applies a
13
+ // status change only when the event is not older than the last one applied,
14
+ // using each event's own LS timestamp. A stale `subscription_paused`/`expired`
15
+ // can therefore never clobber a newer paid state.
16
+ const LEMON_SQUEEZY_WEBHOOK_SECRET = Deno.env.get("LEMON_SQUEEZY_WEBHOOK_SECRET")!;
17
+ const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!;
18
+ const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
19
+
20
+ const PRO_FEATURES = ["impact", "review-pr", "generate-tests", "mcp"];
21
+
22
+ // The license key is delivered by this event (LS generates + emails it):
23
+ const LICENSE_EVENTS = ["license_key_created"];
24
+ // Activate / keep Pro:
25
+ const ACTIVATE_EVENTS = [
26
+ "order_created",
27
+ "subscription_created",
28
+ "subscription_updated",
29
+ "subscription_resumed",
30
+ "subscription_unpaused",
31
+ "subscription_payment_success",
32
+ ];
33
+ // Revocation — real end of access. NOT subscription_cancelled: that keeps
34
+ // access until period end; LS sends subscription_expired when it actually ends.
35
+ const REVOKE_EVENTS = [
36
+ "subscription_expired",
37
+ "subscription_paused",
38
+ ];
39
+ const HANDLED_EVENTS = [...LICENSE_EVENTS, ...ACTIVATE_EVENTS, ...REVOKE_EVENTS];
40
+
41
+ // Map a Lemon Squeezy subscription status to our access status. This is the
42
+ // authoritative current state LS attaches to every subscription_* event, so we
43
+ // prefer it over inferring from the event name (an out-of-order event still
44
+ // carries the status that was true at its own event time, and the recency
45
+ // guard in apply_license_event decides whether it wins).
46
+ // cancelled keeps access until period end (LS sends subscription_expired at
47
+ // the real end); past_due is a payment-retry grace window — keep access.
48
+ function mapLsStatus(lsStatus: string | undefined): "active" | "inactive" | null {
49
+ switch ((lsStatus ?? "").toLowerCase()) {
50
+ case "on_trial":
51
+ case "active":
52
+ case "cancelled":
53
+ case "past_due":
54
+ return "active";
55
+ case "paused":
56
+ case "unpaid":
57
+ case "expired":
58
+ return "inactive";
59
+ default:
60
+ return null; // unknown -> do not infer a status
61
+ }
62
+ }
63
+
64
+ async function verifySignature(rawBody: string, signature: string): Promise<boolean> {
65
+ if (!signature) return false;
66
+ const encoder = new TextEncoder();
67
+ const key = await crypto.subtle.importKey(
68
+ "raw",
69
+ encoder.encode(LEMON_SQUEEZY_WEBHOOK_SECRET),
70
+ { name: "HMAC", hash: "SHA-256" },
71
+ false,
72
+ ["sign"],
73
+ );
74
+ const expected = await crypto.subtle.sign("HMAC", key, encoder.encode(rawBody));
75
+ const expectedHex = Array.from(new Uint8Array(expected))
76
+ .map((b) => b.toString(16).padStart(2, "0"))
77
+ .join("");
78
+ return expectedHex === signature;
79
+ }
80
+
81
+ const json = (body: unknown, status = 200) =>
82
+ new Response(JSON.stringify(body), {
83
+ status,
84
+ headers: { "Content-Type": "application/json" },
85
+ });
86
+
87
+ serve(async (req: Request) => {
88
+ if (req.method !== "POST") return new Response("Method not allowed", { status: 405 });
89
+
90
+ const rawBody = await req.text();
91
+ const signature =
92
+ req.headers.get("X-Signature") ?? req.headers.get("x-signature") ?? "";
93
+
94
+ if (!(await verifySignature(rawBody, signature))) {
95
+ console.error("Invalid webhook signature");
96
+ return new Response("Unauthorized", { status: 401 });
97
+ }
98
+
99
+ let payload: Record<string, unknown>;
100
+ try {
101
+ payload = JSON.parse(rawBody);
102
+ } catch {
103
+ return new Response("Bad request: invalid JSON", { status: 400 });
104
+ }
105
+
106
+ const meta = payload.meta as Record<string, unknown>;
107
+ const data = payload.data as Record<string, unknown>;
108
+ const eventName = meta?.event_name as string;
109
+ const eventId = meta?.event_id as string | undefined;
110
+
111
+ if (!HANDLED_EVENTS.includes(eventName)) {
112
+ return json({ received: true, skipped: true });
113
+ }
114
+
115
+ const attributes = (data?.attributes ?? {}) as Record<string, unknown>;
116
+ const email = ((attributes?.user_email ?? attributes?.customer_email) as string ?? "")
117
+ .toLowerCase();
118
+
119
+ if (!email || !email.includes("@")) {
120
+ console.error("No valid email in payload", { eventName });
121
+ return new Response("Bad request: no email", { status: 400 });
122
+ }
123
+
124
+ // The license key only ever arrives on license_key_created.
125
+ const licenseKey = LICENSE_EVENTS.includes(eventName)
126
+ ? ((attributes?.key as string) ?? null)
127
+ : null;
128
+ if (LICENSE_EVENTS.includes(eventName) && !licenseKey) {
129
+ console.error("license_key_created without attributes.key");
130
+ return new Response("Bad request: no key", { status: 400 });
131
+ }
132
+
133
+ // Desired status: prefer LS's authoritative subscription status, fall back to
134
+ // event-name semantics. Non-subscription events (order/license) only activate.
135
+ let desiredStatus: "active" | "inactive" | null;
136
+ if (eventName.startsWith("subscription_")) {
137
+ desiredStatus = mapLsStatus(attributes?.status as string | undefined);
138
+ if (desiredStatus === null) {
139
+ desiredStatus = REVOKE_EVENTS.includes(eventName)
140
+ ? "inactive"
141
+ : ACTIVATE_EVENTS.includes(eventName)
142
+ ? "active"
143
+ : null;
144
+ }
145
+ } else if (LICENSE_EVENTS.includes(eventName) || ACTIVATE_EVENTS.includes(eventName)) {
146
+ desiredStatus = "active";
147
+ } else if (REVOKE_EVENTS.includes(eventName)) {
148
+ desiredStatus = "inactive";
149
+ } else {
150
+ desiredStatus = null;
151
+ }
152
+
153
+ // Recency key: the event's own LS timestamp, NOT our receipt time.
154
+ const eventAt =
155
+ (attributes?.updated_at as string) ??
156
+ (attributes?.created_at as string) ??
157
+ new Date().toISOString();
158
+
159
+ // Only grant features when we are activating; a revoke leaves them untouched.
160
+ const features = desiredStatus === "active" ? PRO_FEATURES : null;
161
+
162
+ const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY);
163
+
164
+ // Idempotency: skip if we already logged this exact event.
165
+ if (eventId) {
166
+ const { data: existing } = await supabase
167
+ .from("license_events").select("id").eq("event_id", eventId).maybeSingle();
168
+ if (existing) return json({ received: true, duplicate: true });
169
+ }
170
+
171
+ // Atomic, recency-guarded apply. A stale event cannot downgrade newer state.
172
+ const { data: rpcData, error: rpcErr } = await supabase.rpc("apply_license_event", {
173
+ p_email: email,
174
+ p_desired_status: desiredStatus,
175
+ p_event_at: eventAt,
176
+ p_features: features,
177
+ p_license_key: licenseKey,
178
+ p_plan: "pro",
179
+ });
180
+
181
+ if (rpcErr) {
182
+ console.error("apply_license_event", rpcErr);
183
+ return json({ error: "DB" }, 500); // LS retries; the apply is idempotent.
184
+ }
185
+ // PostgREST may return the composite as an object or a single-element array.
186
+ const row = (Array.isArray(rpcData) ? rpcData[0] : rpcData) as
187
+ | Record<string, unknown>
188
+ | null;
189
+ const userId = (row?.id as string | undefined) ?? null;
190
+
191
+ // Mirror period end into the subscriptions table on activation (informational;
192
+ // get-license reads users.status, not this row).
193
+ if (ACTIVATE_EVENTS.includes(eventName) && userId) {
194
+ const now = new Date().toISOString();
195
+ const periodEnd = (attributes?.renews_at ?? attributes?.ends_at ?? null) as string | null;
196
+ await supabase.from("subscriptions").upsert(
197
+ { user_id: userId, provider: "lemonsqueezy", status: "active",
198
+ current_period_end: periodEnd, created_at: now },
199
+ { onConflict: "user_id" },
200
+ );
201
+ }
202
+
203
+ // Reliable audit: a failed log returns 500 so LS retries. Because the apply
204
+ // above is idempotent (same event_at re-applies the same state) and the
205
+ // idempotency guard keys on this not-yet-inserted event_id, the retry safely
206
+ // reprocesses and re-attempts the log. No silent, unlogged state changes.
207
+ const { error: evErr } = await supabase.from("license_events").insert({
208
+ user_id: userId,
209
+ event_type: eventName,
210
+ event_id: eventId ?? null,
211
+ payload: JSON.parse(JSON.stringify(payload)),
212
+ });
213
+ if (evErr) {
214
+ console.error("license_event insert", evErr);
215
+ return json({ error: "audit_failed" }, 500);
216
+ }
217
+
218
+ console.log(`Processed ${eventName} for ${email} -> status=${desiredStatus}`);
219
+ return json({ received: true, email, event: eventName, status: desiredStatus });
220
+ });
@@ -0,0 +1,93 @@
1
+ -- License event ordering guard.
2
+ --
3
+ -- Problem this fixes: the Lemon Squeezy webhook used blind last-writer-wins
4
+ -- upserts. LS delivers events out of order and Edge Functions run concurrently,
5
+ -- so a stale/duplicate `subscription_paused`/`expired` arriving after a
6
+ -- `subscription_payment_success` could clobber a paying customer's row to
7
+ -- status='inactive'. There was no recency check and no atomicity.
8
+ --
9
+ -- This migration adds:
10
+ -- 1. users.last_event_at — the LS event timestamp of the last APPLIED change.
11
+ -- 2. apply_license_event() — an atomic, recency-guarded upsert. A row is
12
+ -- locked (FOR UPDATE), and a status change is applied only when the
13
+ -- incoming event is not older than the last one we applied. Older events
14
+ -- can never downgrade a newer state. The license_key is still persisted
15
+ -- from an out-of-order event when the row has none yet (immutable fact).
16
+ --
17
+ -- Run order in the Supabase SQL editor: execute each statement block below.
18
+ -- A named dollar-quote tag ($fn$) is used so the editor cannot mis-split the
19
+ -- function body on the semicolons inside it.
20
+
21
+ -- 1. Ordering column ---------------------------------------------------------
22
+ alter table users
23
+ add column if not exists last_event_at timestamptz;
24
+
25
+ -- 2. Atomic, recency-guarded apply function ----------------------------------
26
+ create or replace function apply_license_event(
27
+ p_email text,
28
+ p_desired_status text, -- 'active' | 'inactive' | null (no status change)
29
+ p_event_at timestamptz,
30
+ p_features jsonb default null,
31
+ p_license_key text default null,
32
+ p_plan text default 'pro'
33
+ ) returns users
34
+ language plpgsql
35
+ as $fn$
36
+ declare
37
+ v_row users;
38
+ begin
39
+ -- Lock the existing row for this customer (no-op if absent).
40
+ select * into v_row from users where email = p_email for update;
41
+
42
+ -- New customer: insert. A brand-new event with no prior state wins.
43
+ if v_row.id is null then
44
+ insert into users (email, plan, status, features, license_key, last_event_at, updated_at)
45
+ values (
46
+ p_email,
47
+ coalesce(p_plan, 'pro'),
48
+ coalesce(p_desired_status, 'active'),
49
+ coalesce(p_features, '[]'::jsonb),
50
+ p_license_key,
51
+ p_event_at,
52
+ now()
53
+ )
54
+ returning * into v_row;
55
+ return v_row;
56
+ end if;
57
+
58
+ -- Existing row: apply state only if this event is NOT stale.
59
+ -- `>=` (not `>`) so a webhook retry with the same timestamp re-applies the
60
+ -- identical state harmlessly (idempotent).
61
+ if v_row.last_event_at is null or p_event_at >= v_row.last_event_at then
62
+ update users set
63
+ plan = coalesce(p_plan, plan),
64
+ status = coalesce(p_desired_status, status),
65
+ features = coalesce(p_features, features),
66
+ license_key = coalesce(p_license_key, license_key),
67
+ last_event_at = p_event_at,
68
+ updated_at = now()
69
+ where id = v_row.id
70
+ returning * into v_row;
71
+ else
72
+ -- Stale event: never downgrade or clobber newer state. Still persist the
73
+ -- license key if we don't have one yet (key creation can arrive late).
74
+ if p_license_key is not null and v_row.license_key is null then
75
+ update users set license_key = p_license_key, updated_at = now()
76
+ where id = v_row.id
77
+ returning * into v_row;
78
+ end if;
79
+ end if;
80
+
81
+ return v_row;
82
+ end;
83
+ $fn$;
84
+
85
+ -- 3. Lock down execution -----------------------------------------------------
86
+ -- PostgREST grants EXECUTE to PUBLIC by default. Without this, anyone holding
87
+ -- the anon key could call /rpc/apply_license_event and self-grant Pro. Only the
88
+ -- service role (used by the Edge Function) may call it.
89
+ revoke all on function apply_license_event(text, text, timestamptz, jsonb, text, text)
90
+ from public, anon, authenticated;
91
+
92
+ grant execute on function apply_license_event(text, text, timestamptz, jsonb, text, text)
93
+ to service_role;
@@ -1,163 +0,0 @@
1
- import { serve } from "https://deno.land/std@0.177.0/http/server.ts";
2
- import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
3
-
4
- // Lemon Squeezy webhook. Source of truth for plan/status and the license key.
5
- // Deploy with --no-verify-jwt (Lemon Squeezy does not send a Supabase JWT;
6
- // HMAC signature is the authentication). Keep "Generate license keys" ON on
7
- // every Pro variant: LS emails the key to the customer and we store that same
8
- // native key here, so there is a single key system end to end.
9
- const LEMON_SQUEEZY_WEBHOOK_SECRET = Deno.env.get("LEMON_SQUEEZY_WEBHOOK_SECRET")!;
10
- const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!;
11
- const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
12
-
13
- const PRO_FEATURES = ["impact", "review-pr", "generate-tests", "mcp"];
14
-
15
- // The license key is delivered by this event (LS generates + emails it):
16
- const LICENSE_EVENTS = ["license_key_created"];
17
- // Activate / keep Pro:
18
- const ACTIVATE_EVENTS = [
19
- "order_created",
20
- "subscription_created",
21
- "subscription_updated",
22
- "subscription_resumed",
23
- "subscription_unpaused",
24
- "subscription_payment_success",
25
- ];
26
- // Revocation — real end of access. NOT subscription_cancelled: that keeps
27
- // access until period end; LS sends subscription_expired when it actually ends.
28
- const REVOKE_EVENTS = [
29
- "subscription_expired",
30
- "subscription_paused",
31
- ];
32
- const HANDLED_EVENTS = [...LICENSE_EVENTS, ...ACTIVATE_EVENTS, ...REVOKE_EVENTS];
33
-
34
- async function verifySignature(rawBody: string, signature: string): Promise<boolean> {
35
- if (!signature) return false;
36
- const encoder = new TextEncoder();
37
- const key = await crypto.subtle.importKey(
38
- "raw",
39
- encoder.encode(LEMON_SQUEEZY_WEBHOOK_SECRET),
40
- { name: "HMAC", hash: "SHA-256" },
41
- false,
42
- ["sign"],
43
- );
44
- const expected = await crypto.subtle.sign("HMAC", key, encoder.encode(rawBody));
45
- const expectedHex = Array.from(new Uint8Array(expected))
46
- .map((b) => b.toString(16).padStart(2, "0"))
47
- .join("");
48
- return expectedHex === signature;
49
- }
50
-
51
- const json = (body: unknown, status = 200) =>
52
- new Response(JSON.stringify(body), {
53
- status,
54
- headers: { "Content-Type": "application/json" },
55
- });
56
-
57
- serve(async (req: Request) => {
58
- if (req.method !== "POST") return new Response("Method not allowed", { status: 405 });
59
-
60
- const rawBody = await req.text();
61
- const signature =
62
- req.headers.get("X-Signature") ?? req.headers.get("x-signature") ?? "";
63
-
64
- if (!(await verifySignature(rawBody, signature))) {
65
- console.error("Invalid webhook signature");
66
- return new Response("Unauthorized", { status: 401 });
67
- }
68
-
69
- let payload: Record<string, unknown>;
70
- try {
71
- payload = JSON.parse(rawBody);
72
- } catch {
73
- return new Response("Bad request: invalid JSON", { status: 400 });
74
- }
75
-
76
- const meta = payload.meta as Record<string, unknown>;
77
- const data = payload.data as Record<string, unknown>;
78
- const eventName = meta?.event_name as string;
79
- const eventId = meta?.event_id as string | undefined;
80
-
81
- if (!HANDLED_EVENTS.includes(eventName)) {
82
- return json({ received: true, skipped: true });
83
- }
84
-
85
- const attributes = data?.attributes as Record<string, unknown>;
86
- const email = ((attributes?.user_email ?? attributes?.customer_email) as string ?? "")
87
- .toLowerCase();
88
-
89
- if (!email || !email.includes("@")) {
90
- console.error("No valid email in payload", { eventName });
91
- return new Response("Bad request: no email", { status: 400 });
92
- }
93
-
94
- const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY);
95
-
96
- // Idempotency
97
- if (eventId) {
98
- const { data: existing } = await supabase
99
- .from("license_events").select("id").eq("event_id", eventId).maybeSingle();
100
- if (existing) return json({ received: true, duplicate: true });
101
- }
102
-
103
- const { data: existingUser } = await supabase
104
- .from("users").select("id, license_key").eq("email", email).maybeSingle();
105
-
106
- let userId = existingUser?.id;
107
- const now = new Date().toISOString();
108
-
109
- // #3 license_key_created -> store the native Lemon Squeezy key
110
- if (LICENSE_EVENTS.includes(eventName)) {
111
- const lsKey = attributes?.key as string;
112
- if (!lsKey) {
113
- console.error("license_key_created without attributes.key");
114
- return new Response("Bad request: no key", { status: 400 });
115
- }
116
- const { data: up, error } = await supabase.from("users").upsert(
117
- { email, plan: "pro", status: "active", features: PRO_FEATURES,
118
- license_key: lsKey, updated_at: now },
119
- { onConflict: "email", ignoreDuplicates: false },
120
- ).select("id").single();
121
- if (error) { console.error("upsert key", error); return json({ error: "DB" }, 500); }
122
- userId = up?.id ?? userId;
123
- }
124
-
125
- // #4 Revocation -> status inactive (does NOT touch license_key or plan)
126
- else if (REVOKE_EVENTS.includes(eventName)) {
127
- const { error } = await supabase.from("users")
128
- .update({ status: "inactive", updated_at: now }).eq("email", email);
129
- if (error) console.error("revoke", error);
130
- if (userId) {
131
- await supabase.from("subscriptions").update({ status: "inactive" }).eq("user_id", userId);
132
- }
133
- }
134
-
135
- // Activation -> plan pro + active (preserves existing license_key)
136
- else {
137
- const { data: up, error } = await supabase.from("users").upsert(
138
- { email, plan: "pro", status: "active", features: PRO_FEATURES, updated_at: now },
139
- { onConflict: "email", ignoreDuplicates: false },
140
- ).select("id").single();
141
- if (error) { console.error("upsert activate", error); return json({ error: "DB" }, 500); }
142
- userId = up?.id ?? userId;
143
-
144
- const periodEnd = (attributes?.renews_at ?? attributes?.ends_at ?? null) as string | null;
145
- await supabase.from("subscriptions").upsert(
146
- { user_id: userId, provider: "lemonsqueezy", status: "active",
147
- current_period_end: periodEnd, created_at: now },
148
- { onConflict: "user_id" },
149
- );
150
- }
151
-
152
- // Audit
153
- const { error: evErr } = await supabase.from("license_events").insert({
154
- user_id: userId ?? null,
155
- event_type: eventName,
156
- event_id: eventId ?? null,
157
- payload: JSON.parse(JSON.stringify(payload)),
158
- });
159
- if (evErr) console.error("license_event insert", evErr);
160
-
161
- console.log(`Processed ${eventName} for ${email}`);
162
- return json({ received: true, email, event: eventName });
163
- });
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes