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.
- {sourcecode-1.36.1 → sourcecode-1.36.2}/PKG-INFO +1 -1
- {sourcecode-1.36.1 → sourcecode-1.36.2}/pyproject.toml +1 -1
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/__init__.py +1 -1
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/cli.py +10 -1
- sourcecode-1.36.2/src/sourcecode/version_check.py +149 -0
- sourcecode-1.36.2/supabase/.temp/cli-latest +1 -0
- sourcecode-1.36.2/supabase/functions/lemonsqueezy-webhook/index.ts +220 -0
- sourcecode-1.36.2/supabase/sql/license_event_ordering.sql +93 -0
- sourcecode-1.36.1/supabase/functions/lemonsqueezy-webhook/index.ts +0 -163
- {sourcecode-1.36.1 → sourcecode-1.36.2}/.github/workflows/build-windows.yml +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/.gitignore +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/.ruff.toml +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/CHANGELOG.md +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/CONTRIBUTING.md +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/LICENSE +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/README.md +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/SECURITY.md +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/raw +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/adaptive_scanner.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/architecture_analyzer.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/architecture_summary.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/ast_extractor.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/cache.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/canonical_ir.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/cir_graphs.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/classifier.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/code_notes_analyzer.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/confidence_analyzer.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/context_scorer.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/context_summarizer.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/contract_model.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/contract_pipeline.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/coverage_parser.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/dependency_analyzer.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/detectors/__init__.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/detectors/base.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/detectors/csproj_parser.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/detectors/dart.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/detectors/dotnet.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/detectors/elixir.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/detectors/go.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/detectors/heuristic.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/detectors/hybrid.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/detectors/java.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/detectors/jvm_ext.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/detectors/nodejs.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/detectors/parsers.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/detectors/php.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/detectors/project.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/detectors/python.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/detectors/ruby.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/detectors/rust.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/detectors/systems.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/detectors/terraform.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/detectors/tooling.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/doc_analyzer.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/entrypoint_classifier.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/env_analyzer.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/error_schema.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/explain.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/file_chunker.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/file_classifier.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/flow_analyzer.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/fqn_utils.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/git_analyzer.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/graph_analyzer.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/license.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/mcp/__init__.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/mcp/onboarding/__init__.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/mcp/onboarding/applier.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/mcp/onboarding/backup.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/mcp/onboarding/detector.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/mcp/onboarding/planner.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/mcp/orchestrator.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/mcp/registry.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/mcp/runner.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/mcp/server.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/mcp_nudge.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/metrics_analyzer.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/migrate_check.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/output_budget.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/path_filters.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/pr_comment_renderer.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/pr_impact.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/prepare_context.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/progress.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/ranking_engine.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/redactor.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/relevance_scorer.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/rename_refactor.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/repo_classifier.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/repository_ir.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/ris.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/runtime_classifier.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/scanner.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/schema.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/semantic_analyzer.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/serializer.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/spring_event_topology.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/spring_findings.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/spring_impact.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/spring_model.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/spring_security_audit.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/spring_semantic.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/spring_tx_analyzer.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/summarizer.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/telemetry/__init__.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/telemetry/config.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/telemetry/consent.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/telemetry/events.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/telemetry/filters.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/telemetry/transport.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/tree_utils.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/src/sourcecode/workspace.py +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/supabase/functions/README.md +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/supabase/functions/get-license/index.ts +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/supabase/functions/telemetry/index.ts +0 -0
- {sourcecode-1.36.1 → sourcecode-1.36.2}/supabase/sql/telemetry_events.sql +0 -0
|
@@ -6212,4 +6212,13 @@ def main_entry() -> None:
|
|
|
6212
6212
|
except Exception:
|
|
6213
6213
|
pass
|
|
6214
6214
|
_preprocess_argv()
|
|
6215
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|